├── .assets └── help-screen.png ├── .github ├── ISSUE_TEMPLATE │ ├── bug-report.yml │ └── config.yml ├── dependabot.yml └── workflows │ └── main.yml ├── .gitignore ├── CliFx.Analyzers.Tests ├── CliFx.Analyzers.Tests.csproj ├── CommandMustBeAnnotatedAnalyzerSpecs.cs ├── CommandMustImplementInterfaceAnalyzerSpecs.cs ├── GeneralSpecs.cs ├── OptionMustBeInsideCommandAnalyzerSpecs.cs ├── OptionMustBeRequiredIfPropertyRequiredAnalyzerSpecs.cs ├── OptionMustHaveNameOrShortNameAnalyzerSpecs.cs ├── OptionMustHaveUniqueNameAnalyzerSpecs.cs ├── OptionMustHaveUniqueShortNameAnalyzerSpecs.cs ├── OptionMustHaveValidConverterAnalyzerSpecs.cs ├── OptionMustHaveValidNameAnalyzerSpecs.cs ├── OptionMustHaveValidShortNameAnalyzerSpecs.cs ├── OptionMustHaveValidValidatorsAnalyzerSpecs.cs ├── ParameterMustBeInsideCommandAnalyzerSpecs.cs ├── ParameterMustBeLastIfNonRequiredAnalyzerSpecs.cs ├── ParameterMustBeLastIfNonScalarAnalyzerSpecs.cs ├── ParameterMustBeRequiredIfPropertyRequiredAnalyzerSpecs.cs ├── ParameterMustBeSingleIfNonRequiredAnalyzerSpecs.cs ├── ParameterMustBeSingleIfNonScalarAnalyzerSpecs.cs ├── ParameterMustHaveUniqueNameAnalyzerSpecs.cs ├── ParameterMustHaveUniqueOrderAnalyzerSpecs.cs ├── ParameterMustHaveValidConverterAnalyzerSpecs.cs ├── ParameterMustHaveValidValidatorsAnalyzerSpecs.cs ├── SystemConsoleShouldBeAvoidedAnalyzerSpecs.cs ├── Utils │ └── AnalyzerAssertions.cs └── xunit.runner.json ├── CliFx.Analyzers ├── AnalyzerBase.cs ├── CliFx.Analyzers.csproj ├── CommandMustBeAnnotatedAnalyzer.cs ├── CommandMustImplementInterfaceAnalyzer.cs ├── ObjectModel │ ├── CommandOptionSymbol.cs │ ├── CommandParameterSymbol.cs │ ├── ICommandMemberSymbol.cs │ └── SymbolNames.cs ├── OptionMustBeInsideCommandAnalyzer.cs ├── OptionMustBeRequiredIfPropertyRequiredAnalyzer.cs ├── OptionMustHaveNameOrShortNameAnalyzer.cs ├── OptionMustHaveUniqueNameAnalyzer.cs ├── OptionMustHaveUniqueShortNameAnalyzer.cs ├── OptionMustHaveValidConverterAnalyzer.cs ├── OptionMustHaveValidNameAnalyzer.cs ├── OptionMustHaveValidShortNameAnalyzer.cs ├── OptionMustHaveValidValidatorsAnalyzer.cs ├── ParameterMustBeInsideCommandAnalyzer.cs ├── ParameterMustBeLastIfNonRequiredAnalyzer.cs ├── ParameterMustBeLastIfNonScalarAnalyzer.cs ├── ParameterMustBeRequiredIfPropertyRequiredAnalyzer.cs ├── ParameterMustBeSingleIfNonRequiredAnalyzer.cs ├── ParameterMustBeSingleIfNonScalarAnalyzer.cs ├── ParameterMustHaveUniqueNameAnalyzer.cs ├── ParameterMustHaveUniqueOrderAnalyzer.cs ├── ParameterMustHaveValidConverterAnalyzer.cs ├── ParameterMustHaveValidValidatorsAnalyzer.cs ├── SystemConsoleShouldBeAvoidedAnalyzer.cs └── Utils │ └── Extensions │ ├── RoslynExtensions.cs │ └── StringExtensions.cs ├── CliFx.Benchmarks ├── Benchmarks.CliFx.cs ├── Benchmarks.Clipr.cs ├── Benchmarks.Cocona.cs ├── Benchmarks.CommandLineParser.cs ├── Benchmarks.McMaster.cs ├── Benchmarks.PowerArgs.cs ├── Benchmarks.SystemCommandLine.cs ├── Benchmarks.cs ├── CliFx.Benchmarks.csproj └── Readme.md ├── CliFx.Demo ├── CliFx.Demo.csproj ├── Commands │ ├── BookAddCommand.cs │ ├── BookCommand.cs │ ├── BookListCommand.cs │ └── BookRemoveCommand.cs ├── Domain │ ├── Book.cs │ ├── Isbn.cs │ ├── Library.cs │ └── LibraryProvider.cs ├── Program.cs ├── Readme.md └── Utils │ └── ConsoleExtensions.cs ├── CliFx.Tests.Dummy ├── CliFx.Tests.Dummy.csproj ├── Commands │ ├── CancellationTestCommand.cs │ ├── ConsoleTestCommand.cs │ └── EnvironmentTestCommand.cs └── Program.cs ├── CliFx.Tests ├── ApplicationSpecs.cs ├── CancellationSpecs.cs ├── CliFx.Tests.csproj ├── ConsoleSpecs.cs ├── ConversionSpecs.cs ├── DirectivesSpecs.cs ├── EnvironmentSpecs.cs ├── ErrorReportingSpecs.cs ├── HelpTextSpecs.cs ├── OptionBindingSpecs.cs ├── ParameterBindingSpecs.cs ├── RoutingSpecs.cs ├── SpecsBase.cs ├── TypeActivationSpecs.cs ├── Utils │ ├── DynamicCommandBuilder.cs │ ├── Extensions │ │ ├── AssertionExtensions.cs │ │ └── ConsoleExtensions.cs │ └── NoOpCommand.cs └── xunit.runner.json ├── CliFx.sln ├── CliFx ├── ApplicationConfiguration.cs ├── ApplicationMetadata.cs ├── Attributes │ ├── CommandAttribute.cs │ ├── CommandOptionAttribute.cs │ └── CommandParameterAttribute.cs ├── CliApplication.cs ├── CliApplicationBuilder.cs ├── CliFx.csproj ├── CommandBinder.cs ├── Exceptions │ ├── CliFxException.cs │ └── CommandException.cs ├── Extensibility │ ├── BindingConverter.cs │ ├── BindingValidationError.cs │ └── BindingValidator.cs ├── FallbackDefaultCommand.cs ├── Formatting │ ├── CommandInputConsoleFormatter.cs │ ├── ConsoleFormatter.cs │ ├── ExceptionConsoleFormatter.cs │ ├── HelpConsoleFormatter.cs │ └── HelpContext.cs ├── ICommand.cs ├── Infrastructure │ ├── ConsoleReader.cs │ ├── ConsoleWriter.cs │ ├── DefaultTypeActivator.cs │ ├── DelegateTypeActivator.cs │ ├── FakeConsole.cs │ ├── FakeInMemoryConsole.cs │ ├── IConsole.cs │ ├── ITypeActivator.cs │ └── SystemConsole.cs ├── Input │ ├── CommandInput.cs │ ├── DirectiveInput.cs │ ├── EnvironmentVariableInput.cs │ ├── OptionInput.cs │ └── ParameterInput.cs ├── Schema │ ├── ApplicationSchema.cs │ ├── BindablePropertyDescriptor.cs │ ├── CommandSchema.cs │ ├── IMemberSchema.cs │ ├── IPropertyDescriptor.cs │ ├── NullPropertyDescriptor.cs │ ├── OptionSchema.cs │ └── ParameterSchema.cs └── Utils │ ├── Disposable.cs │ ├── EnvironmentEx.cs │ ├── Extensions │ ├── CollectionExtensions.cs │ ├── PropertyExtensions.cs │ ├── StringExtensions.cs │ ├── TypeExtensions.cs │ └── VersionExtensions.cs │ ├── NoPreambleEncoding.cs │ ├── PathEx.cs │ ├── ProcessEx.cs │ └── StackFrame.cs ├── Directory.Build.props ├── License.txt ├── NuGet.config ├── Readme.md ├── favicon.ico └── favicon.png /.assets/help-screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tyrrrz/CliFx/d80d01293804bbb6533e167aaac659f6bf8939da/.assets/help-screen.png -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.yml: -------------------------------------------------------------------------------- 1 | name: 🐛 Bug report 2 | description: Report broken functionality. 3 | labels: [bug] 4 | 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | - Avoid generic or vague titles such as "Something's not working" or "A couple of problems" — be as descriptive as possible. 10 | - Keep your issue focused on one single problem. If you have multiple bug reports, please create a separate issue for each of them. 11 | - Issues should represent **complete and actionable** work items. If you are unsure about something or have a question, please start a [discussion](https://github.com/Tyrrrz/CliFx/discussions/new) instead. 12 | - Remember that **CliFx** is an open-source project funded by the community. If you find it useful, **please consider [donating](https://tyrrrz.me/donate) to support its development**. 13 | 14 | ___ 15 | 16 | - type: input 17 | attributes: 18 | label: Version 19 | description: Which version of the package does this bug affect? Make sure you're not using an outdated version. 20 | placeholder: v1.0.0 21 | validations: 22 | required: true 23 | 24 | - type: input 25 | attributes: 26 | label: Platform 27 | description: Which platform do you experience this bug on? 28 | placeholder: .NET 7.0 / Windows 11 29 | validations: 30 | required: true 31 | 32 | - type: textarea 33 | attributes: 34 | label: Steps to reproduce 35 | description: > 36 | Minimum steps required to reproduce the bug, including prerequisites, code snippets, or other relevant items. 37 | The information provided in this field must be readily actionable, meaning that anyone should be able to reproduce the bug by following these steps. 38 | If the reproduction steps are too complex to fit in this field, please provide a link to a repository instead. 39 | placeholder: | 40 | - Step 1 41 | - Step 2 42 | - Step 3 43 | validations: 44 | required: true 45 | 46 | - type: textarea 47 | attributes: 48 | label: Details 49 | description: Clear and thorough explanation of the bug, including any additional information you may find relevant. 50 | placeholder: | 51 | - Expected behavior: ... 52 | - Actual behavior: ... 53 | validations: 54 | required: true 55 | 56 | - type: checkboxes 57 | attributes: 58 | label: Checklist 59 | description: Quick list of checks to ensure that everything is in order. 60 | options: 61 | - label: I have looked through existing issues to make sure that this bug has not been reported before 62 | required: true 63 | - label: I have provided a descriptive title for this issue 64 | required: true 65 | - label: I have made sure that this bug is reproducible on the latest version of the package 66 | required: true 67 | - label: I have provided all the information needed to reproduce this bug as efficiently as possible 68 | required: true 69 | - label: I have sponsored this project 70 | required: false 71 | - label: I have not read any of the above and just checked all the boxes to submit the issue 72 | required: false 73 | 74 | - type: markdown 75 | attributes: 76 | value: | 77 | If you are struggling to provide actionable reproduction steps, or if something else is preventing you from creating a complete bug report, please start a [discussion](https://github.com/Tyrrrz/CliFx/discussions/new) instead. 78 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: ⚠ Feature request 4 | url: https://github.com/Tyrrrz/.github/blob/master/docs/project-status.md 5 | about: Sorry, but this project is in maintenance mode and no longer accepts new feature requests. 6 | - name: 🗨 Discussions 7 | url: https://github.com/Tyrrrz/CliFx/discussions/new 8 | about: Ask and answer questions. 9 | - name: 💬 Discord server 10 | url: https://discord.gg/2SUWKFnHSm 11 | about: Chat with the project community. 12 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: "/" 5 | schedule: 6 | interval: monthly 7 | labels: 8 | - enhancement 9 | groups: 10 | actions: 11 | patterns: 12 | - "*" 13 | - package-ecosystem: nuget 14 | directory: "/" 15 | schedule: 16 | interval: monthly 17 | labels: 18 | - enhancement 19 | groups: 20 | nuget: 21 | patterns: 22 | - "*" 23 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: main 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | package-version: 7 | type: string 8 | description: Package version 9 | required: false 10 | deploy: 11 | type: boolean 12 | description: Deploy package 13 | required: false 14 | default: false 15 | push: 16 | branches: 17 | - master 18 | tags: 19 | - "*" 20 | pull_request: 21 | branches: 22 | - master 23 | 24 | jobs: 25 | main: 26 | uses: Tyrrrz/.github/.github/workflows/nuget.yml@master 27 | with: 28 | deploy: ${{ inputs.deploy || github.ref_type == 'tag' }} 29 | package-version: ${{ inputs.package-version || (github.ref_type == 'tag' && github.ref_name) || format('0.0.0-ci-{0}', github.sha) }} 30 | dotnet-version: 9.0.x 31 | secrets: 32 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 33 | NUGET_TOKEN: ${{ secrets.NUGET_TOKEN }} 34 | DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # User-specific files 2 | .vs/ 3 | .idea/ 4 | *.suo 5 | *.user 6 | 7 | # Build results 8 | bin/ 9 | obj/ 10 | 11 | # Test results 12 | TestResults/ -------------------------------------------------------------------------------- /CliFx.Analyzers.Tests/CliFx.Analyzers.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net9.0 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /CliFx.Analyzers.Tests/CommandMustBeAnnotatedAnalyzerSpecs.cs: -------------------------------------------------------------------------------- 1 | using CliFx.Analyzers.Tests.Utils; 2 | using Microsoft.CodeAnalysis.Diagnostics; 3 | using Xunit; 4 | 5 | namespace CliFx.Analyzers.Tests; 6 | 7 | public class CommandMustBeAnnotatedAnalyzerSpecs 8 | { 9 | private static DiagnosticAnalyzer Analyzer { get; } = new CommandMustBeAnnotatedAnalyzer(); 10 | 11 | [Fact] 12 | public void Analyzer_reports_an_error_if_a_command_is_not_annotated_with_the_command_attribute() 13 | { 14 | // Arrange 15 | // lang=csharp 16 | const string code = """ 17 | public class MyCommand : ICommand 18 | { 19 | public ValueTask ExecuteAsync(IConsole console) => default; 20 | } 21 | """; 22 | 23 | // Act & assert 24 | Analyzer.Should().ProduceDiagnostics(code); 25 | } 26 | 27 | [Fact] 28 | public void Analyzer_does_not_report_an_error_if_a_command_is_annotated_with_the_command_attribute() 29 | { 30 | // Arrange 31 | // lang=csharp 32 | const string code = """ 33 | [Command] 34 | public abstract class MyCommand : ICommand 35 | { 36 | public ValueTask ExecuteAsync(IConsole console) => default; 37 | } 38 | """; 39 | 40 | // Act & assert 41 | Analyzer.Should().NotProduceDiagnostics(code); 42 | } 43 | 44 | [Fact] 45 | public void Analyzer_does_not_report_an_error_if_a_command_is_implemented_as_an_abstract_class() 46 | { 47 | // Arrange 48 | // lang=csharp 49 | const string code = """ 50 | public abstract class MyCommand : ICommand 51 | { 52 | public ValueTask ExecuteAsync(IConsole console) => default; 53 | } 54 | """; 55 | 56 | // Act & assert 57 | Analyzer.Should().NotProduceDiagnostics(code); 58 | } 59 | 60 | [Fact] 61 | public void Analyzer_does_not_report_an_error_on_a_class_that_is_not_a_command() 62 | { 63 | // Arrange 64 | // lang=csharp 65 | const string code = """ 66 | public class Foo 67 | { 68 | public int Bar { get; init; } = 5; 69 | } 70 | """; 71 | 72 | // Act & assert 73 | Analyzer.Should().NotProduceDiagnostics(code); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /CliFx.Analyzers.Tests/CommandMustImplementInterfaceAnalyzerSpecs.cs: -------------------------------------------------------------------------------- 1 | using CliFx.Analyzers.Tests.Utils; 2 | using Microsoft.CodeAnalysis.Diagnostics; 3 | using Xunit; 4 | 5 | namespace CliFx.Analyzers.Tests; 6 | 7 | public class CommandMustImplementInterfaceAnalyzerSpecs 8 | { 9 | private static DiagnosticAnalyzer Analyzer { get; } = 10 | new CommandMustImplementInterfaceAnalyzer(); 11 | 12 | [Fact] 13 | public void Analyzer_reports_an_error_if_a_command_does_not_implement_ICommand_interface() 14 | { 15 | // Arrange 16 | // lang=csharp 17 | const string code = """ 18 | [Command] 19 | public class MyCommand 20 | { 21 | public ValueTask ExecuteAsync(IConsole console) => default; 22 | } 23 | """; 24 | 25 | // Act & assert 26 | Analyzer.Should().ProduceDiagnostics(code); 27 | } 28 | 29 | [Fact] 30 | public void Analyzer_does_not_report_an_error_if_a_command_implements_ICommand_interface() 31 | { 32 | // Arrange 33 | // lang=csharp 34 | const string code = """ 35 | [Command] 36 | public class MyCommand : ICommand 37 | { 38 | public ValueTask ExecuteAsync(IConsole console) => default; 39 | } 40 | """; 41 | 42 | // Act & assert 43 | Analyzer.Should().NotProduceDiagnostics(code); 44 | } 45 | 46 | [Fact] 47 | public void Analyzer_does_not_report_an_error_on_a_class_that_is_not_a_command() 48 | { 49 | // Arrange 50 | // lang=csharp 51 | const string code = """ 52 | public class Foo 53 | { 54 | public int Bar { get; init; } = 5; 55 | } 56 | """; 57 | 58 | // Act & assert 59 | Analyzer.Should().NotProduceDiagnostics(code); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /CliFx.Analyzers.Tests/GeneralSpecs.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using FluentAssertions; 4 | using Microsoft.CodeAnalysis.Diagnostics; 5 | using Xunit; 6 | 7 | namespace CliFx.Analyzers.Tests; 8 | 9 | public class GeneralSpecs 10 | { 11 | [Fact] 12 | public void All_analyzers_have_unique_diagnostic_IDs() 13 | { 14 | // Arrange 15 | var analyzers = typeof(AnalyzerBase) 16 | .Assembly.GetTypes() 17 | .Where(t => !t.IsAbstract && t.IsAssignableTo(typeof(DiagnosticAnalyzer))) 18 | .Select(t => (DiagnosticAnalyzer)Activator.CreateInstance(t)!) 19 | .ToArray(); 20 | 21 | // Act 22 | var diagnosticIds = analyzers 23 | .SelectMany(a => a.SupportedDiagnostics.Select(d => d.Id)) 24 | .ToArray(); 25 | 26 | // Assert 27 | diagnosticIds.Should().OnlyHaveUniqueItems(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /CliFx.Analyzers.Tests/OptionMustBeInsideCommandAnalyzerSpecs.cs: -------------------------------------------------------------------------------- 1 | using CliFx.Analyzers.Tests.Utils; 2 | using Microsoft.CodeAnalysis.Diagnostics; 3 | using Xunit; 4 | 5 | namespace CliFx.Analyzers.Tests; 6 | 7 | public class OptionMustBeInsideCommandAnalyzerSpecs 8 | { 9 | private static DiagnosticAnalyzer Analyzer { get; } = new OptionMustBeInsideCommandAnalyzer(); 10 | 11 | [Fact] 12 | public void Analyzer_reports_an_error_if_an_option_is_inside_a_class_that_is_not_a_command() 13 | { 14 | // Arrange 15 | // lang=csharp 16 | const string code = """ 17 | public class MyClass 18 | { 19 | [CommandOption("foo")] 20 | public string? Foo { get; init; } 21 | } 22 | """; 23 | 24 | // Act & assert 25 | Analyzer.Should().ProduceDiagnostics(code); 26 | } 27 | 28 | [Fact] 29 | public void Analyzer_does_not_report_an_error_if_an_option_is_inside_a_command() 30 | { 31 | // Arrange 32 | // lang=csharp 33 | const string code = """ 34 | [Command] 35 | public class MyCommand : ICommand 36 | { 37 | [CommandOption("foo")] 38 | public string? Foo { get; init; } 39 | 40 | public ValueTask ExecuteAsync(IConsole console) => default; 41 | } 42 | """; 43 | 44 | // Act & assert 45 | Analyzer.Should().NotProduceDiagnostics(code); 46 | } 47 | 48 | [Fact] 49 | public void Analyzer_does_not_report_an_error_if_an_option_is_inside_an_abstract_class() 50 | { 51 | // Arrange 52 | // lang=csharp 53 | const string code = """ 54 | public abstract class MyCommand 55 | { 56 | [CommandOption("foo")] 57 | public string? Foo { get; init; } 58 | } 59 | """; 60 | 61 | // Act & assert 62 | Analyzer.Should().NotProduceDiagnostics(code); 63 | } 64 | 65 | [Fact] 66 | public void Analyzer_does_not_report_an_error_on_a_property_that_is_not_an_option() 67 | { 68 | // Arrange 69 | // lang=csharp 70 | const string code = """ 71 | [Command] 72 | public class MyCommand : ICommand 73 | { 74 | public string? Foo { get; init; } 75 | 76 | public ValueTask ExecuteAsync(IConsole console) => default; 77 | } 78 | """; 79 | 80 | // Act & assert 81 | Analyzer.Should().NotProduceDiagnostics(code); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /CliFx.Analyzers.Tests/OptionMustBeRequiredIfPropertyRequiredAnalyzerSpecs.cs: -------------------------------------------------------------------------------- 1 | using CliFx.Analyzers.Tests.Utils; 2 | using Microsoft.CodeAnalysis.Diagnostics; 3 | using Xunit; 4 | 5 | namespace CliFx.Analyzers.Tests; 6 | 7 | public class OptionMustBeRequiredIfPropertyRequiredAnalyzerSpecs 8 | { 9 | private static DiagnosticAnalyzer Analyzer { get; } = 10 | new OptionMustBeRequiredIfPropertyRequiredAnalyzer(); 11 | 12 | [Fact] 13 | public void Analyzer_reports_an_error_if_a_non_required_option_is_bound_to_a_required_property() 14 | { 15 | // Arrange 16 | // lang=csharp 17 | const string code = """ 18 | [Command] 19 | public class MyCommand : ICommand 20 | { 21 | [CommandOption('f', IsRequired = false)] 22 | public required string Foo { get; init; } 23 | 24 | public ValueTask ExecuteAsync(IConsole console) => default; 25 | } 26 | """; 27 | 28 | // Act & assert 29 | Analyzer.Should().ProduceDiagnostics(code); 30 | } 31 | 32 | [Fact] 33 | public void Analyzer_does_not_report_an_error_if_a_required_option_is_bound_to_a_required_property() 34 | { 35 | // Arrange 36 | // lang=csharp 37 | const string code = """ 38 | [Command] 39 | public class MyCommand : ICommand 40 | { 41 | [CommandOption('f')] 42 | public required string Foo { get; init; } 43 | 44 | public ValueTask ExecuteAsync(IConsole console) => default; 45 | } 46 | """; 47 | 48 | // Act & assert 49 | Analyzer.Should().NotProduceDiagnostics(code); 50 | } 51 | 52 | [Fact] 53 | public void Analyzer_does_not_report_an_error_if_a_non_required_option_is_bound_to_a_non_required_property() 54 | { 55 | // Arrange 56 | // lang=csharp 57 | const string code = """ 58 | [Command] 59 | public class MyCommand : ICommand 60 | { 61 | [CommandOption('f', IsRequired = false)] 62 | public string? Foo { get; init; } 63 | 64 | public ValueTask ExecuteAsync(IConsole console) => default; 65 | } 66 | """; 67 | 68 | // Act & assert 69 | Analyzer.Should().NotProduceDiagnostics(code); 70 | } 71 | 72 | [Fact] 73 | public void Analyzer_does_not_report_an_error_if_a_required_option_is_bound_to_a_non_required_property() 74 | { 75 | // Arrange 76 | // lang=csharp 77 | const string code = """ 78 | [Command] 79 | public class MyCommand : ICommand 80 | { 81 | [CommandOption('f')] 82 | public required string Foo { get; init; } 83 | 84 | public ValueTask ExecuteAsync(IConsole console) => default; 85 | } 86 | """; 87 | 88 | // Act & assert 89 | Analyzer.Should().NotProduceDiagnostics(code); 90 | } 91 | 92 | [Fact] 93 | public void Analyzer_does_not_report_an_error_on_a_property_that_is_not_an_option() 94 | { 95 | // Arrange 96 | // lang=csharp 97 | const string code = """ 98 | [Command] 99 | public class MyCommand : ICommand 100 | { 101 | public required string Foo { get; init; } 102 | 103 | public ValueTask ExecuteAsync(IConsole console) => default; 104 | } 105 | """; 106 | 107 | // Act & assert 108 | Analyzer.Should().NotProduceDiagnostics(code); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /CliFx.Analyzers.Tests/OptionMustHaveNameOrShortNameAnalyzerSpecs.cs: -------------------------------------------------------------------------------- 1 | using CliFx.Analyzers.Tests.Utils; 2 | using Microsoft.CodeAnalysis.Diagnostics; 3 | using Xunit; 4 | 5 | namespace CliFx.Analyzers.Tests; 6 | 7 | public class OptionMustHaveNameOrShortNameAnalyzerSpecs 8 | { 9 | private static DiagnosticAnalyzer Analyzer { get; } = 10 | new OptionMustHaveNameOrShortNameAnalyzer(); 11 | 12 | [Fact] 13 | public void Analyzer_reports_an_error_if_an_option_does_not_have_a_name_or_short_name() 14 | { 15 | // Arrange 16 | // lang=csharp 17 | const string code = """ 18 | [Command] 19 | public class MyCommand : ICommand 20 | { 21 | [CommandOption(null)] 22 | public string? Foo { get; init; } 23 | 24 | public ValueTask ExecuteAsync(IConsole console) => default; 25 | } 26 | """; 27 | 28 | // Act & assert 29 | Analyzer.Should().ProduceDiagnostics(code); 30 | } 31 | 32 | [Fact] 33 | public void Analyzer_does_not_report_an_error_if_an_option_has_a_name() 34 | { 35 | // Arrange 36 | // lang=csharp 37 | const string code = """ 38 | [Command] 39 | public class MyCommand : ICommand 40 | { 41 | [CommandOption("foo")] 42 | public string? Foo { get; init; } 43 | 44 | public ValueTask ExecuteAsync(IConsole console) => default; 45 | } 46 | """; 47 | 48 | // Act & assert 49 | Analyzer.Should().NotProduceDiagnostics(code); 50 | } 51 | 52 | [Fact] 53 | public void Analyzer_does_not_report_an_error_if_an_option_has_a_short_name() 54 | { 55 | // Arrange 56 | // lang=csharp 57 | const string code = """ 58 | [Command] 59 | public class MyCommand : ICommand 60 | { 61 | [CommandOption('f')] 62 | public string? Foo { get; init; } 63 | 64 | public ValueTask ExecuteAsync(IConsole console) => default; 65 | } 66 | """; 67 | 68 | // Act & assert 69 | Analyzer.Should().NotProduceDiagnostics(code); 70 | } 71 | 72 | [Fact] 73 | public void Analyzer_does_not_report_an_error_on_a_property_that_is_not_an_option() 74 | { 75 | // Arrange 76 | // lang=csharp 77 | const string code = """ 78 | [Command] 79 | public class MyCommand : ICommand 80 | { 81 | public string? Foo { get; init; } 82 | 83 | public ValueTask ExecuteAsync(IConsole console) => default; 84 | } 85 | """; 86 | 87 | // Act & assert 88 | Analyzer.Should().NotProduceDiagnostics(code); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /CliFx.Analyzers.Tests/OptionMustHaveUniqueNameAnalyzerSpecs.cs: -------------------------------------------------------------------------------- 1 | using CliFx.Analyzers.Tests.Utils; 2 | using Microsoft.CodeAnalysis.Diagnostics; 3 | using Xunit; 4 | 5 | namespace CliFx.Analyzers.Tests; 6 | 7 | public class OptionMustHaveUniqueNameAnalyzerSpecs 8 | { 9 | private static DiagnosticAnalyzer Analyzer { get; } = new OptionMustHaveUniqueNameAnalyzer(); 10 | 11 | [Fact] 12 | public void Analyzer_reports_an_error_if_an_option_has_the_same_name_as_another_option() 13 | { 14 | // Arrange 15 | // lang=csharp 16 | const string code = """ 17 | [Command] 18 | public class MyCommand : ICommand 19 | { 20 | [CommandOption("foo")] 21 | public string? Foo { get; init; } 22 | 23 | [CommandOption("foo")] 24 | public string? Bar { get; init; } 25 | 26 | public ValueTask ExecuteAsync(IConsole console) => default; 27 | } 28 | """; 29 | 30 | // Act & assert 31 | Analyzer.Should().ProduceDiagnostics(code); 32 | } 33 | 34 | [Fact] 35 | public void Analyzer_does_not_report_an_error_if_an_option_has_a_unique_name() 36 | { 37 | // Arrange 38 | // lang=csharp 39 | const string code = """ 40 | [Command] 41 | public class MyCommand : ICommand 42 | { 43 | [CommandOption("foo")] 44 | public string? Foo { get; init; } 45 | 46 | [CommandOption("bar")] 47 | public string? Bar { get; init; } 48 | 49 | public ValueTask ExecuteAsync(IConsole console) => default; 50 | } 51 | """; 52 | 53 | // Act & assert 54 | Analyzer.Should().NotProduceDiagnostics(code); 55 | } 56 | 57 | [Fact] 58 | public void Analyzer_does_not_report_an_error_if_an_option_does_not_have_a_name() 59 | { 60 | // Arrange 61 | // lang=csharp 62 | const string code = """ 63 | [Command] 64 | public class MyCommand : ICommand 65 | { 66 | [CommandOption('f')] 67 | public string? Foo { get; init; } 68 | 69 | public ValueTask ExecuteAsync(IConsole console) => default; 70 | } 71 | """; 72 | 73 | // Act & assert 74 | Analyzer.Should().NotProduceDiagnostics(code); 75 | } 76 | 77 | [Fact] 78 | public void Analyzer_does_not_report_an_error_on_a_property_that_is_not_an_option() 79 | { 80 | // Arrange 81 | // lang=csharp 82 | const string code = """ 83 | [Command] 84 | public class MyCommand : ICommand 85 | { 86 | public string? Foo { get; init; } 87 | 88 | public ValueTask ExecuteAsync(IConsole console) => default; 89 | } 90 | """; 91 | 92 | // Act & assert 93 | Analyzer.Should().NotProduceDiagnostics(code); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /CliFx.Analyzers.Tests/OptionMustHaveUniqueShortNameAnalyzerSpecs.cs: -------------------------------------------------------------------------------- 1 | using CliFx.Analyzers.Tests.Utils; 2 | using Microsoft.CodeAnalysis.Diagnostics; 3 | using Xunit; 4 | 5 | namespace CliFx.Analyzers.Tests; 6 | 7 | public class OptionMustHaveUniqueShortNameAnalyzerSpecs 8 | { 9 | private static DiagnosticAnalyzer Analyzer { get; } = 10 | new OptionMustHaveUniqueShortNameAnalyzer(); 11 | 12 | [Fact] 13 | public void Analyzer_reports_an_error_if_an_option_has_the_same_short_name_as_another_option() 14 | { 15 | // Arrange 16 | // lang=csharp 17 | const string code = """ 18 | [Command] 19 | public class MyCommand : ICommand 20 | { 21 | [CommandOption('f')] 22 | public string? Foo { get; init; } 23 | 24 | [CommandOption('f')] 25 | public string? Bar { get; init; } 26 | 27 | public ValueTask ExecuteAsync(IConsole console) => default; 28 | } 29 | """; 30 | 31 | // Act & assert 32 | Analyzer.Should().ProduceDiagnostics(code); 33 | } 34 | 35 | [Fact] 36 | public void Analyzer_does_not_report_an_error_if_an_option_has_a_unique_short_name() 37 | { 38 | // Arrange 39 | // lang=csharp 40 | const string code = """ 41 | [Command] 42 | public class MyCommand : ICommand 43 | { 44 | [CommandOption('f')] 45 | public string? Foo { get; init; } 46 | 47 | [CommandOption('b')] 48 | public string? Bar { get; init; } 49 | 50 | public ValueTask ExecuteAsync(IConsole console) => default; 51 | } 52 | """; 53 | 54 | // Act & assert 55 | Analyzer.Should().NotProduceDiagnostics(code); 56 | } 57 | 58 | [Fact] 59 | public void Analyzer_does_not_report_an_error_if_an_option_has_a_short_name_which_is_unique_only_in_casing() 60 | { 61 | // Arrange 62 | // lang=csharp 63 | const string code = """ 64 | [Command] 65 | public class MyCommand : ICommand 66 | { 67 | [CommandOption('f')] 68 | public string? Foo { get; init; } 69 | 70 | [CommandOption('F')] 71 | public string? Bar { get; init; } 72 | 73 | public ValueTask ExecuteAsync(IConsole console) => default; 74 | } 75 | """; 76 | 77 | // Act & assert 78 | Analyzer.Should().NotProduceDiagnostics(code); 79 | } 80 | 81 | [Fact] 82 | public void Analyzer_does_not_report_an_error_if_an_option_does_not_have_a_short_name() 83 | { 84 | // Arrange 85 | // lang=csharp 86 | const string code = """ 87 | [Command] 88 | public class MyCommand : ICommand 89 | { 90 | [CommandOption("foo")] 91 | public string? Foo { get; init; } 92 | 93 | public ValueTask ExecuteAsync(IConsole console) => default; 94 | } 95 | """; 96 | 97 | // Act & assert 98 | Analyzer.Should().NotProduceDiagnostics(code); 99 | } 100 | 101 | [Fact] 102 | public void Analyzer_does_not_report_an_error_on_a_property_that_is_not_an_option() 103 | { 104 | // Arrange 105 | // lang=csharp 106 | const string code = """ 107 | [Command] 108 | public class MyCommand : ICommand 109 | { 110 | public string? Foo { get; init; } 111 | 112 | public ValueTask ExecuteAsync(IConsole console) => default; 113 | } 114 | """; 115 | 116 | // Act & assert 117 | Analyzer.Should().NotProduceDiagnostics(code); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /CliFx.Analyzers.Tests/OptionMustHaveValidNameAnalyzerSpecs.cs: -------------------------------------------------------------------------------- 1 | using CliFx.Analyzers.Tests.Utils; 2 | using Microsoft.CodeAnalysis.Diagnostics; 3 | using Xunit; 4 | 5 | namespace CliFx.Analyzers.Tests; 6 | 7 | public class OptionMustHaveValidNameAnalyzerSpecs 8 | { 9 | private static DiagnosticAnalyzer Analyzer { get; } = new OptionMustHaveValidNameAnalyzer(); 10 | 11 | [Fact] 12 | public void Analyzer_reports_an_error_if_an_option_has_a_name_which_is_too_short() 13 | { 14 | // Arrange 15 | // lang=csharp 16 | const string code = """ 17 | [Command] 18 | public class MyCommand : ICommand 19 | { 20 | [CommandOption("f")] 21 | public string? Foo { get; init; } 22 | 23 | public ValueTask ExecuteAsync(IConsole console) => default; 24 | } 25 | """; 26 | 27 | // Act & assert 28 | Analyzer.Should().ProduceDiagnostics(code); 29 | } 30 | 31 | [Fact] 32 | public void Analyzer_reports_an_error_if_an_option_has_a_name_that_starts_with_a_non_letter_character() 33 | { 34 | // Arrange 35 | // lang=csharp 36 | const string code = """ 37 | [Command] 38 | public class MyCommand : ICommand 39 | { 40 | [CommandOption("1foo")] 41 | public string? Foo { get; init; } 42 | 43 | public ValueTask ExecuteAsync(IConsole console) => default; 44 | } 45 | """; 46 | 47 | // Act & assert 48 | Analyzer.Should().ProduceDiagnostics(code); 49 | } 50 | 51 | [Fact] 52 | public void Analyzer_does_not_report_an_error_if_an_option_has_a_valid_name() 53 | { 54 | // Arrange 55 | // lang=csharp 56 | const string code = """ 57 | [Command] 58 | public class MyCommand : ICommand 59 | { 60 | [CommandOption("foo")] 61 | public string? Foo { get; init; } 62 | 63 | public ValueTask ExecuteAsync(IConsole console) => default; 64 | } 65 | """; 66 | 67 | // Act & assert 68 | Analyzer.Should().NotProduceDiagnostics(code); 69 | } 70 | 71 | [Fact] 72 | public void Analyzer_does_not_report_an_error_if_an_option_does_not_have_a_name() 73 | { 74 | // Arrange 75 | // lang=csharp 76 | const string code = """ 77 | [Command] 78 | public class MyCommand : ICommand 79 | { 80 | [CommandOption('f')] 81 | public string? Foo { get; init; } 82 | 83 | public ValueTask ExecuteAsync(IConsole console) => default; 84 | } 85 | """; 86 | 87 | // Act & assert 88 | Analyzer.Should().NotProduceDiagnostics(code); 89 | } 90 | 91 | [Fact] 92 | public void Analyzer_does_not_report_an_error_on_a_property_that_is_not_an_option() 93 | { 94 | // Arrange 95 | // lang=csharp 96 | const string code = """ 97 | [Command] 98 | public class MyCommand : ICommand 99 | { 100 | public string? Foo { get; init; } 101 | 102 | public ValueTask ExecuteAsync(IConsole console) => default; 103 | } 104 | """; 105 | 106 | // Act & assert 107 | Analyzer.Should().NotProduceDiagnostics(code); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /CliFx.Analyzers.Tests/OptionMustHaveValidShortNameAnalyzerSpecs.cs: -------------------------------------------------------------------------------- 1 | using CliFx.Analyzers.Tests.Utils; 2 | using Microsoft.CodeAnalysis.Diagnostics; 3 | using Xunit; 4 | 5 | namespace CliFx.Analyzers.Tests; 6 | 7 | public class OptionMustHaveValidShortNameAnalyzerSpecs 8 | { 9 | private static DiagnosticAnalyzer Analyzer { get; } = 10 | new OptionMustHaveValidShortNameAnalyzer(); 11 | 12 | [Fact] 13 | public void Analyzer_reports_an_error_if_an_option_has_a_short_name_which_is_not_a_letter_character() 14 | { 15 | // Arrange 16 | // lang=csharp 17 | const string code = """ 18 | [Command] 19 | public class MyCommand : ICommand 20 | { 21 | [CommandOption('1')] 22 | public string? Foo { get; init; } 23 | 24 | public ValueTask ExecuteAsync(IConsole console) => default; 25 | } 26 | """; 27 | 28 | // Act & assert 29 | Analyzer.Should().ProduceDiagnostics(code); 30 | } 31 | 32 | [Fact] 33 | public void Analyzer_does_not_report_an_error_if_an_option_has_a_valid_short_name() 34 | { 35 | // Arrange 36 | // lang=csharp 37 | const string code = """ 38 | [Command] 39 | public class MyCommand : ICommand 40 | { 41 | [CommandOption('f')] 42 | public string? Foo { get; init; } 43 | 44 | public ValueTask ExecuteAsync(IConsole console) => default; 45 | } 46 | """; 47 | 48 | // Act & assert 49 | Analyzer.Should().NotProduceDiagnostics(code); 50 | } 51 | 52 | [Fact] 53 | public void Analyzer_does_not_report_an_error_if_an_option_does_not_have_a_short_name() 54 | { 55 | // Arrange 56 | // lang=csharp 57 | const string code = """ 58 | [Command] 59 | public class MyCommand : ICommand 60 | { 61 | [CommandOption("foo")] 62 | public string? Foo { get; init; } 63 | 64 | public ValueTask ExecuteAsync(IConsole console) => default; 65 | } 66 | """; 67 | 68 | // Act & assert 69 | Analyzer.Should().NotProduceDiagnostics(code); 70 | } 71 | 72 | [Fact] 73 | public void Analyzer_does_not_report_an_error_on_a_property_that_is_not_an_option() 74 | { 75 | // Arrange 76 | // lang=csharp 77 | const string code = """ 78 | [Command] 79 | public class MyCommand : ICommand 80 | { 81 | public string? Foo { get; init; } 82 | 83 | public ValueTask ExecuteAsync(IConsole console) => default; 84 | } 85 | """; 86 | 87 | // Act & assert 88 | Analyzer.Should().NotProduceDiagnostics(code); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /CliFx.Analyzers.Tests/ParameterMustBeInsideCommandAnalyzerSpecs.cs: -------------------------------------------------------------------------------- 1 | using CliFx.Analyzers.Tests.Utils; 2 | using Microsoft.CodeAnalysis.Diagnostics; 3 | using Xunit; 4 | 5 | namespace CliFx.Analyzers.Tests; 6 | 7 | public class ParameterMustBeInsideCommandAnalyzerSpecs 8 | { 9 | private static DiagnosticAnalyzer Analyzer { get; } = 10 | new ParameterMustBeInsideCommandAnalyzer(); 11 | 12 | [Fact] 13 | public void Analyzer_reports_an_error_if_a_parameter_is_inside_a_class_that_is_not_a_command() 14 | { 15 | // Arrange 16 | // lang=csharp 17 | const string code = """ 18 | public class MyClass 19 | { 20 | [CommandParameter(0)] 21 | public required string Foo { get; init; } 22 | } 23 | """; 24 | 25 | // Act & assert 26 | Analyzer.Should().ProduceDiagnostics(code); 27 | } 28 | 29 | [Fact] 30 | public void Analyzer_does_not_report_an_error_if_a_parameter_is_inside_a_command() 31 | { 32 | // Arrange 33 | // lang=csharp 34 | const string code = """ 35 | [Command] 36 | public class MyCommand : ICommand 37 | { 38 | [CommandParameter(0)] 39 | public required string Foo { get; init; } 40 | 41 | public ValueTask ExecuteAsync(IConsole console) => default; 42 | } 43 | """; 44 | 45 | // Act & assert 46 | Analyzer.Should().NotProduceDiagnostics(code); 47 | } 48 | 49 | [Fact] 50 | public void Analyzer_does_not_report_an_error_if_a_parameter_is_inside_an_abstract_class() 51 | { 52 | // Arrange 53 | // lang=csharp 54 | const string code = """ 55 | public abstract class MyCommand 56 | { 57 | [CommandParameter(0)] 58 | public required string Foo { get; init; } 59 | } 60 | """; 61 | 62 | // Act & assert 63 | Analyzer.Should().NotProduceDiagnostics(code); 64 | } 65 | 66 | [Fact] 67 | public void Analyzer_does_not_report_an_error_on_a_property_that_is_not_a_parameter() 68 | { 69 | // Arrange 70 | // lang=csharp 71 | const string code = """ 72 | [Command] 73 | public class MyCommand : ICommand 74 | { 75 | public string? Foo { get; init; } 76 | 77 | public ValueTask ExecuteAsync(IConsole console) => default; 78 | } 79 | """; 80 | 81 | // Act & assert 82 | Analyzer.Should().NotProduceDiagnostics(code); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /CliFx.Analyzers.Tests/ParameterMustBeLastIfNonRequiredAnalyzerSpecs.cs: -------------------------------------------------------------------------------- 1 | using CliFx.Analyzers.Tests.Utils; 2 | using Microsoft.CodeAnalysis.Diagnostics; 3 | using Xunit; 4 | 5 | namespace CliFx.Analyzers.Tests; 6 | 7 | public class ParameterMustBeLastIfNonRequiredAnalyzerSpecs 8 | { 9 | private static DiagnosticAnalyzer Analyzer { get; } = 10 | new ParameterMustBeLastIfNonRequiredAnalyzer(); 11 | 12 | [Fact] 13 | public void Analyzer_reports_an_error_if_a_non_required_parameter_is_not_the_last_in_order() 14 | { 15 | // Arrange 16 | // lang=csharp 17 | const string code = """ 18 | [Command] 19 | public class MyCommand : ICommand 20 | { 21 | [CommandParameter(0, IsRequired = false)] 22 | public string? Foo { get; init; } 23 | 24 | [CommandParameter(1)] 25 | public required string Bar { get; init; } 26 | 27 | public ValueTask ExecuteAsync(IConsole console) => default; 28 | } 29 | """; 30 | 31 | // Act & assert 32 | Analyzer.Should().ProduceDiagnostics(code); 33 | } 34 | 35 | [Fact] 36 | public void Analyzer_does_not_report_an_error_if_a_non_required_parameter_is_the_last_in_order() 37 | { 38 | // Arrange 39 | // lang=csharp 40 | const string code = """ 41 | [Command] 42 | public class MyCommand : ICommand 43 | { 44 | [CommandParameter(0)] 45 | public required string Foo { get; init; } 46 | 47 | [CommandParameter(1, IsRequired = false)] 48 | public string? Bar { get; init; } 49 | 50 | public ValueTask ExecuteAsync(IConsole console) => default; 51 | } 52 | """; 53 | 54 | // Act & assert 55 | Analyzer.Should().NotProduceDiagnostics(code); 56 | } 57 | 58 | [Fact] 59 | public void Analyzer_does_not_report_an_error_if_no_non_required_parameters_are_defined() 60 | { 61 | // Arrange 62 | // lang=csharp 63 | const string code = """ 64 | [Command] 65 | public class MyCommand : ICommand 66 | { 67 | [CommandParameter(0)] 68 | public required string Foo { get; init; } 69 | 70 | [CommandParameter(1)] 71 | public required string Bar { get; init; } 72 | 73 | public ValueTask ExecuteAsync(IConsole console) => default; 74 | } 75 | """; 76 | 77 | // Act & assert 78 | Analyzer.Should().NotProduceDiagnostics(code); 79 | } 80 | 81 | [Fact] 82 | public void Analyzer_does_not_report_an_error_on_a_property_that_is_not_a_parameter() 83 | { 84 | // Arrange 85 | // lang=csharp 86 | const string code = """ 87 | [Command] 88 | public class MyCommand : ICommand 89 | { 90 | public string? Foo { get; init; } 91 | 92 | public ValueTask ExecuteAsync(IConsole console) => default; 93 | } 94 | """; 95 | 96 | // Act & assert 97 | Analyzer.Should().NotProduceDiagnostics(code); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /CliFx.Analyzers.Tests/ParameterMustBeLastIfNonScalarAnalyzerSpecs.cs: -------------------------------------------------------------------------------- 1 | using CliFx.Analyzers.Tests.Utils; 2 | using Microsoft.CodeAnalysis.Diagnostics; 3 | using Xunit; 4 | 5 | namespace CliFx.Analyzers.Tests; 6 | 7 | public class ParameterMustBeLastIfNonScalarAnalyzerSpecs 8 | { 9 | private static DiagnosticAnalyzer Analyzer { get; } = 10 | new ParameterMustBeLastIfNonScalarAnalyzer(); 11 | 12 | [Fact] 13 | public void Analyzer_reports_an_error_if_a_non_scalar_parameter_is_not_the_last_in_order() 14 | { 15 | // Arrange 16 | // lang=csharp 17 | const string code = """ 18 | [Command] 19 | public class MyCommand : ICommand 20 | { 21 | [CommandParameter(0)] 22 | public required string[] Foo { get; init; } 23 | 24 | [CommandParameter(1)] 25 | public required string Bar { get; init; } 26 | 27 | public ValueTask ExecuteAsync(IConsole console) => default; 28 | } 29 | """; 30 | 31 | // Act & assert 32 | Analyzer.Should().ProduceDiagnostics(code); 33 | } 34 | 35 | [Fact] 36 | public void Analyzer_does_not_report_an_error_if_a_non_scalar_parameter_is_the_last_in_order() 37 | { 38 | // Arrange 39 | // lang=csharp 40 | const string code = """ 41 | [Command] 42 | public class MyCommand : ICommand 43 | { 44 | [CommandParameter(0)] 45 | public required string Foo { get; init; } 46 | 47 | [CommandParameter(1)] 48 | public required string[] Bar { get; init; } 49 | 50 | public ValueTask ExecuteAsync(IConsole console) => default; 51 | } 52 | """; 53 | 54 | // Act & assert 55 | Analyzer.Should().NotProduceDiagnostics(code); 56 | } 57 | 58 | [Fact] 59 | public void Analyzer_does_not_report_an_error_if_no_non_scalar_parameters_are_defined() 60 | { 61 | // Arrange 62 | // lang=csharp 63 | const string code = """ 64 | [Command] 65 | public class MyCommand : ICommand 66 | { 67 | [CommandParameter(0)] 68 | public required string Foo { get; init; } 69 | 70 | [CommandParameter(1)] 71 | public required string Bar { get; init; } 72 | 73 | public ValueTask ExecuteAsync(IConsole console) => default; 74 | } 75 | """; 76 | 77 | // Act & assert 78 | Analyzer.Should().NotProduceDiagnostics(code); 79 | } 80 | 81 | [Fact] 82 | public void Analyzer_does_not_report_an_error_on_a_property_that_is_not_a_parameter() 83 | { 84 | // Arrange 85 | // lang=csharp 86 | const string code = """ 87 | [Command] 88 | public class MyCommand : ICommand 89 | { 90 | public string? Foo { get; init; } 91 | 92 | public ValueTask ExecuteAsync(IConsole console) => default; 93 | } 94 | """; 95 | 96 | // Act & assert 97 | Analyzer.Should().NotProduceDiagnostics(code); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /CliFx.Analyzers.Tests/ParameterMustBeRequiredIfPropertyRequiredAnalyzerSpecs.cs: -------------------------------------------------------------------------------- 1 | using CliFx.Analyzers.Tests.Utils; 2 | using Microsoft.CodeAnalysis.Diagnostics; 3 | using Xunit; 4 | 5 | namespace CliFx.Analyzers.Tests; 6 | 7 | public class ParameterMustBeRequiredIfPropertyRequiredAnalyzerSpecs 8 | { 9 | private static DiagnosticAnalyzer Analyzer { get; } = 10 | new ParameterMustBeRequiredIfPropertyRequiredAnalyzer(); 11 | 12 | [Fact] 13 | public void Analyzer_reports_an_error_if_a_non_required_parameter_is_bound_to_a_required_property() 14 | { 15 | // Arrange 16 | // lang=csharp 17 | const string code = """ 18 | [Command] 19 | public class MyCommand : ICommand 20 | { 21 | [CommandParameter(0, IsRequired = false)] 22 | public required string? Foo { get; init; } 23 | 24 | public ValueTask ExecuteAsync(IConsole console) => default; 25 | } 26 | """; 27 | 28 | // Act & assert 29 | Analyzer.Should().ProduceDiagnostics(code); 30 | } 31 | 32 | [Fact] 33 | public void Analyzer_does_not_report_an_error_if_a_required_parameter_is_bound_to_a_required_property() 34 | { 35 | // Arrange 36 | // lang=csharp 37 | const string code = """ 38 | [Command] 39 | public class MyCommand : ICommand 40 | { 41 | [CommandParameter(0)] 42 | public required string Foo { get; init; } 43 | 44 | public ValueTask ExecuteAsync(IConsole console) => default; 45 | } 46 | """; 47 | 48 | // Act & assert 49 | Analyzer.Should().NotProduceDiagnostics(code); 50 | } 51 | 52 | [Fact] 53 | public void Analyzer_does_not_report_an_error_if_a_non_required_parameter_is_bound_to_a_non_required_property() 54 | { 55 | // Arrange 56 | // lang=csharp 57 | const string code = """ 58 | [Command] 59 | public class MyCommand : ICommand 60 | { 61 | [CommandParameter(0, IsRequired = false)] 62 | public string? Foo { get; init; } 63 | 64 | public ValueTask ExecuteAsync(IConsole console) => default; 65 | } 66 | """; 67 | 68 | // Act & assert 69 | Analyzer.Should().NotProduceDiagnostics(code); 70 | } 71 | 72 | [Fact] 73 | public void Analyzer_does_not_report_an_error_if_a_required_parameter_is_bound_to_a_non_required_property() 74 | { 75 | // Arrange 76 | // lang=csharp 77 | const string code = """ 78 | [Command] 79 | public class MyCommand : ICommand 80 | { 81 | [CommandParameter(0)] 82 | public required string Foo { get; init; } 83 | 84 | public ValueTask ExecuteAsync(IConsole console) => default; 85 | } 86 | """; 87 | 88 | // Act & assert 89 | Analyzer.Should().NotProduceDiagnostics(code); 90 | } 91 | 92 | [Fact] 93 | public void Analyzer_does_not_report_an_error_on_a_property_that_is_not_a_parameter() 94 | { 95 | // Arrange 96 | // lang=csharp 97 | const string code = """ 98 | [Command] 99 | public class MyCommand : ICommand 100 | { 101 | public required string Foo { get; init; } 102 | 103 | public ValueTask ExecuteAsync(IConsole console) => default; 104 | } 105 | """; 106 | 107 | // Act & assert 108 | Analyzer.Should().NotProduceDiagnostics(code); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /CliFx.Analyzers.Tests/ParameterMustBeSingleIfNonRequiredAnalyzerSpecs.cs: -------------------------------------------------------------------------------- 1 | using CliFx.Analyzers.Tests.Utils; 2 | using Microsoft.CodeAnalysis.Diagnostics; 3 | using Xunit; 4 | 5 | namespace CliFx.Analyzers.Tests; 6 | 7 | public class ParameterMustBeSingleIfNonRequiredAnalyzerSpecs 8 | { 9 | private static DiagnosticAnalyzer Analyzer { get; } = 10 | new ParameterMustBeSingleIfNonRequiredAnalyzer(); 11 | 12 | [Fact] 13 | public void Analyzer_reports_an_error_if_more_than_one_non_required_parameters_are_defined() 14 | { 15 | // Arrange 16 | // lang=csharp 17 | const string code = """ 18 | [Command] 19 | public class MyCommand : ICommand 20 | { 21 | [CommandParameter(0, IsRequired = false)] 22 | public string? Foo { get; init; } 23 | 24 | [CommandParameter(1, IsRequired = false)] 25 | public string? Bar { get; init; } 26 | 27 | public ValueTask ExecuteAsync(IConsole console) => default; 28 | } 29 | """; 30 | 31 | // Act & assert 32 | Analyzer.Should().ProduceDiagnostics(code); 33 | } 34 | 35 | [Fact] 36 | public void Analyzer_does_not_report_an_error_if_only_one_non_required_parameter_is_defined() 37 | { 38 | // Arrange 39 | // lang=csharp 40 | const string code = """ 41 | [Command] 42 | public class MyCommand : ICommand 43 | { 44 | [CommandParameter(0)] 45 | public required string Foo { get; init; } 46 | 47 | [CommandParameter(1, IsRequired = false)] 48 | public string? Bar { get; init; } 49 | 50 | public ValueTask ExecuteAsync(IConsole console) => default; 51 | } 52 | """; 53 | 54 | // Act & assert 55 | Analyzer.Should().NotProduceDiagnostics(code); 56 | } 57 | 58 | [Fact] 59 | public void Analyzer_does_not_report_an_error_if_no_non_required_parameters_are_defined() 60 | { 61 | // Arrange 62 | // lang=csharp 63 | const string code = """ 64 | [Command] 65 | public class MyCommand : ICommand 66 | { 67 | [CommandParameter(0)] 68 | public required string Foo { get; init; } 69 | 70 | [CommandParameter(1)] 71 | public required string Bar { get; init; } 72 | 73 | public ValueTask ExecuteAsync(IConsole console) => default; 74 | } 75 | """; 76 | 77 | // Act & assert 78 | Analyzer.Should().NotProduceDiagnostics(code); 79 | } 80 | 81 | [Fact] 82 | public void Analyzer_does_not_report_an_error_on_a_property_that_is_not_a_parameter() 83 | { 84 | // Arrange 85 | // lang=csharp 86 | const string code = """ 87 | [Command] 88 | public class MyCommand : ICommand 89 | { 90 | public string? Foo { get; init; } 91 | 92 | public ValueTask ExecuteAsync(IConsole console) => default; 93 | } 94 | """; 95 | 96 | // Act & assert 97 | Analyzer.Should().NotProduceDiagnostics(code); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /CliFx.Analyzers.Tests/ParameterMustBeSingleIfNonScalarAnalyzerSpecs.cs: -------------------------------------------------------------------------------- 1 | using CliFx.Analyzers.Tests.Utils; 2 | using Microsoft.CodeAnalysis.Diagnostics; 3 | using Xunit; 4 | 5 | namespace CliFx.Analyzers.Tests; 6 | 7 | public class ParameterMustBeSingleIfNonScalarAnalyzerSpecs 8 | { 9 | private static DiagnosticAnalyzer Analyzer { get; } = 10 | new ParameterMustBeSingleIfNonScalarAnalyzer(); 11 | 12 | [Fact] 13 | public void Analyzer_reports_an_error_if_more_than_one_non_scalar_parameters_are_defined() 14 | { 15 | // Arrange 16 | // lang=csharp 17 | const string code = """ 18 | [Command] 19 | public class MyCommand : ICommand 20 | { 21 | [CommandParameter(0)] 22 | public required string[] Foo { get; init; } 23 | 24 | [CommandParameter(1)] 25 | public required string[] Bar { get; init; } 26 | 27 | public ValueTask ExecuteAsync(IConsole console) => default; 28 | } 29 | """; 30 | 31 | // Act & assert 32 | Analyzer.Should().ProduceDiagnostics(code); 33 | } 34 | 35 | [Fact] 36 | public void Analyzer_does_not_report_an_error_if_only_one_non_scalar_parameter_is_defined() 37 | { 38 | // Arrange 39 | // lang=csharp 40 | const string code = """ 41 | [Command] 42 | public class MyCommand : ICommand 43 | { 44 | [CommandParameter(0)] 45 | public required string Foo { get; init; } 46 | 47 | [CommandParameter(1)] 48 | public required string[] Bar { get; init; } 49 | 50 | public ValueTask ExecuteAsync(IConsole console) => default; 51 | } 52 | """; 53 | 54 | // Act & assert 55 | Analyzer.Should().NotProduceDiagnostics(code); 56 | } 57 | 58 | [Fact] 59 | public void Analyzer_does_not_report_an_error_if_no_non_scalar_parameters_are_defined() 60 | { 61 | // Arrange 62 | // lang=csharp 63 | const string code = """ 64 | [Command] 65 | public class MyCommand : ICommand 66 | { 67 | [CommandParameter(0)] 68 | public required string Foo { get; init; } 69 | 70 | [CommandParameter(1)] 71 | public required string Bar { get; init; } 72 | 73 | public ValueTask ExecuteAsync(IConsole console) => default; 74 | } 75 | """; 76 | 77 | // Act & assert 78 | Analyzer.Should().NotProduceDiagnostics(code); 79 | } 80 | 81 | [Fact] 82 | public void Analyzer_does_not_report_an_error_on_a_property_that_is_not_a_parameter() 83 | { 84 | // Arrange 85 | // lang=csharp 86 | const string code = """ 87 | [Command] 88 | public class MyCommand : ICommand 89 | { 90 | public string? Foo { get; init; } 91 | 92 | public ValueTask ExecuteAsync(IConsole console) => default; 93 | } 94 | """; 95 | 96 | // Act & assert 97 | Analyzer.Should().NotProduceDiagnostics(code); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /CliFx.Analyzers.Tests/ParameterMustHaveUniqueNameAnalyzerSpecs.cs: -------------------------------------------------------------------------------- 1 | using CliFx.Analyzers.Tests.Utils; 2 | using Microsoft.CodeAnalysis.Diagnostics; 3 | using Xunit; 4 | 5 | namespace CliFx.Analyzers.Tests; 6 | 7 | public class ParameterMustHaveUniqueNameAnalyzerSpecs 8 | { 9 | private static DiagnosticAnalyzer Analyzer { get; } = new ParameterMustHaveUniqueNameAnalyzer(); 10 | 11 | [Fact] 12 | public void Analyzer_reports_an_error_if_a_parameter_has_the_same_name_as_another_parameter() 13 | { 14 | // Arrange 15 | // lang=csharp 16 | const string code = """ 17 | [Command] 18 | public class MyCommand : ICommand 19 | { 20 | [CommandParameter(0, Name = "foo")] 21 | public required string Foo { get; init; } 22 | 23 | [CommandParameter(1, Name = "foo")] 24 | public required string Bar { get; init; } 25 | 26 | public ValueTask ExecuteAsync(IConsole console) => default; 27 | } 28 | """; 29 | 30 | // Act & assert 31 | Analyzer.Should().ProduceDiagnostics(code); 32 | } 33 | 34 | [Fact] 35 | public void Analyzer_does_not_report_an_error_if_a_parameter_has_unique_name() 36 | { 37 | // Arrange 38 | // lang=csharp 39 | const string code = """ 40 | [Command] 41 | public class MyCommand : ICommand 42 | { 43 | [CommandParameter(0, Name = "foo")] 44 | public required string Foo { get; init; } 45 | 46 | [CommandParameter(1, Name = "bar")] 47 | public required string Bar { get; init; } 48 | 49 | public ValueTask ExecuteAsync(IConsole console) => default; 50 | } 51 | """; 52 | 53 | // Act & assert 54 | Analyzer.Should().NotProduceDiagnostics(code); 55 | } 56 | 57 | [Fact] 58 | public void Analyzer_does_not_report_an_error_on_a_property_that_is_not_a_parameter() 59 | { 60 | // Arrange 61 | // lang=csharp 62 | const string code = """ 63 | [Command] 64 | public class MyCommand : ICommand 65 | { 66 | public string? Foo { get; init; } 67 | 68 | public ValueTask ExecuteAsync(IConsole console) => default; 69 | } 70 | """; 71 | 72 | // Act & assert 73 | Analyzer.Should().NotProduceDiagnostics(code); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /CliFx.Analyzers.Tests/ParameterMustHaveUniqueOrderAnalyzerSpecs.cs: -------------------------------------------------------------------------------- 1 | using CliFx.Analyzers.Tests.Utils; 2 | using Microsoft.CodeAnalysis.Diagnostics; 3 | using Xunit; 4 | 5 | namespace CliFx.Analyzers.Tests; 6 | 7 | public class ParameterMustHaveUniqueOrderAnalyzerSpecs 8 | { 9 | private static DiagnosticAnalyzer Analyzer { get; } = 10 | new ParameterMustHaveUniqueOrderAnalyzer(); 11 | 12 | [Fact] 13 | public void Analyzer_reports_an_error_if_a_parameter_has_the_same_order_as_another_parameter() 14 | { 15 | // Arrange 16 | // lang=csharp 17 | const string code = """ 18 | [Command] 19 | public class MyCommand : ICommand 20 | { 21 | [CommandParameter(0)] 22 | public required string Foo { get; init; } 23 | 24 | [CommandParameter(0)] 25 | public required string Bar { get; init; } 26 | 27 | public ValueTask ExecuteAsync(IConsole console) => default; 28 | } 29 | """; 30 | 31 | // Act & assert 32 | Analyzer.Should().ProduceDiagnostics(code); 33 | } 34 | 35 | [Fact] 36 | public void Analyzer_does_not_report_an_error_if_a_parameter_has_unique_order() 37 | { 38 | // Arrange 39 | // lang=csharp 40 | const string code = """ 41 | [Command] 42 | public class MyCommand : ICommand 43 | { 44 | [CommandParameter(0)] 45 | public required string Foo { get; init; } 46 | 47 | [CommandParameter(1)] 48 | public required string Bar { get; init; } 49 | 50 | public ValueTask ExecuteAsync(IConsole console) => default; 51 | } 52 | """; 53 | 54 | // Act & assert 55 | Analyzer.Should().NotProduceDiagnostics(code); 56 | } 57 | 58 | [Fact] 59 | public void Analyzer_does_not_report_an_error_on_a_property_that_is_not_a_parameter() 60 | { 61 | // Arrange 62 | // lang=csharp 63 | const string code = """ 64 | [Command] 65 | public class MyCommand : ICommand 66 | { 67 | public string? Foo { get; init; } 68 | 69 | public ValueTask ExecuteAsync(IConsole console) => default; 70 | } 71 | """; 72 | 73 | // Act & assert 74 | Analyzer.Should().NotProduceDiagnostics(code); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /CliFx.Analyzers.Tests/xunit.runner.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", 3 | "methodDisplayOptions": "all", 4 | "methodDisplay": "method" 5 | } -------------------------------------------------------------------------------- /CliFx.Analyzers/AnalyzerBase.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Immutable; 2 | using CliFx.Analyzers.Utils.Extensions; 3 | using Microsoft.CodeAnalysis; 4 | using Microsoft.CodeAnalysis.Diagnostics; 5 | 6 | namespace CliFx.Analyzers; 7 | 8 | public abstract class AnalyzerBase : DiagnosticAnalyzer 9 | { 10 | public DiagnosticDescriptor SupportedDiagnostic { get; } 11 | 12 | public sealed override ImmutableArray SupportedDiagnostics { get; } 13 | 14 | protected AnalyzerBase( 15 | string diagnosticTitle, 16 | string diagnosticMessage, 17 | DiagnosticSeverity diagnosticSeverity = DiagnosticSeverity.Error 18 | ) 19 | { 20 | SupportedDiagnostic = new DiagnosticDescriptor( 21 | "CliFx_" + GetType().Name.TrimEnd("Analyzer"), 22 | diagnosticTitle, 23 | diagnosticMessage, 24 | "CliFx", 25 | diagnosticSeverity, 26 | true 27 | ); 28 | 29 | SupportedDiagnostics = ImmutableArray.Create(SupportedDiagnostic); 30 | } 31 | 32 | protected Diagnostic CreateDiagnostic(Location location, params object?[]? messageArgs) => 33 | Diagnostic.Create(SupportedDiagnostic, location, messageArgs); 34 | 35 | public override void Initialize(AnalysisContext context) 36 | { 37 | context.EnableConcurrentExecution(); 38 | context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /CliFx.Analyzers/CliFx.Analyzers.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | netstandard2.0 5 | true 6 | true 7 | true 8 | $(NoWarn);RS1025;RS1026 9 | 10 | 11 | 12 | 16 | annotations 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /CliFx.Analyzers/CommandMustBeAnnotatedAnalyzer.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using CliFx.Analyzers.ObjectModel; 3 | using CliFx.Analyzers.Utils.Extensions; 4 | using Microsoft.CodeAnalysis; 5 | using Microsoft.CodeAnalysis.CSharp.Syntax; 6 | using Microsoft.CodeAnalysis.Diagnostics; 7 | 8 | namespace CliFx.Analyzers; 9 | 10 | [DiagnosticAnalyzer(LanguageNames.CSharp)] 11 | public class CommandMustBeAnnotatedAnalyzer() 12 | : AnalyzerBase( 13 | $"Commands must be annotated with `{SymbolNames.CliFxCommandAttribute}`", 14 | $"This type must be annotated with `{SymbolNames.CliFxCommandAttribute}` in order to be a valid command." 15 | ) 16 | { 17 | private void Analyze( 18 | SyntaxNodeAnalysisContext context, 19 | ClassDeclarationSyntax classDeclaration, 20 | ITypeSymbol type 21 | ) 22 | { 23 | // Ignore abstract classes, because they may be used to define 24 | // base implementations for commands, in which case the command 25 | // attribute doesn't make sense. 26 | if (type.IsAbstract) 27 | return; 28 | 29 | var implementsCommandInterface = type.AllInterfaces.Any(i => 30 | i.DisplayNameMatches(SymbolNames.CliFxCommandInterface) 31 | ); 32 | 33 | var hasCommandAttribute = type.GetAttributes() 34 | .Select(a => a.AttributeClass) 35 | .Any(c => c.DisplayNameMatches(SymbolNames.CliFxCommandAttribute)); 36 | 37 | // If the interface is implemented, but the attribute is missing, 38 | // then it's very likely a user error. 39 | if (implementsCommandInterface && !hasCommandAttribute) 40 | { 41 | context.ReportDiagnostic(CreateDiagnostic(classDeclaration.Identifier.GetLocation())); 42 | } 43 | } 44 | 45 | public override void Initialize(AnalysisContext context) 46 | { 47 | base.Initialize(context); 48 | context.HandleClassDeclaration(Analyze); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /CliFx.Analyzers/CommandMustImplementInterfaceAnalyzer.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using CliFx.Analyzers.ObjectModel; 3 | using CliFx.Analyzers.Utils.Extensions; 4 | using Microsoft.CodeAnalysis; 5 | using Microsoft.CodeAnalysis.CSharp.Syntax; 6 | using Microsoft.CodeAnalysis.Diagnostics; 7 | 8 | namespace CliFx.Analyzers; 9 | 10 | [DiagnosticAnalyzer(LanguageNames.CSharp)] 11 | public class CommandMustImplementInterfaceAnalyzer() 12 | : AnalyzerBase( 13 | $"Commands must implement `{SymbolNames.CliFxCommandInterface}` interface", 14 | $"This type must implement `{SymbolNames.CliFxCommandInterface}` interface in order to be a valid command." 15 | ) 16 | { 17 | private void Analyze( 18 | SyntaxNodeAnalysisContext context, 19 | ClassDeclarationSyntax classDeclaration, 20 | ITypeSymbol type 21 | ) 22 | { 23 | var hasCommandAttribute = type.GetAttributes() 24 | .Select(a => a.AttributeClass) 25 | .Any(c => c.DisplayNameMatches(SymbolNames.CliFxCommandAttribute)); 26 | 27 | var implementsCommandInterface = type.AllInterfaces.Any(i => 28 | i.DisplayNameMatches(SymbolNames.CliFxCommandInterface) 29 | ); 30 | 31 | // If the attribute is present, but the interface is not implemented, 32 | // it's very likely a user error. 33 | if (hasCommandAttribute && !implementsCommandInterface) 34 | { 35 | context.ReportDiagnostic(CreateDiagnostic(classDeclaration.Identifier.GetLocation())); 36 | } 37 | } 38 | 39 | public override void Initialize(AnalysisContext context) 40 | { 41 | base.Initialize(context); 42 | context.HandleClassDeclaration(Analyze); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /CliFx.Analyzers/ObjectModel/CommandOptionSymbol.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using CliFx.Analyzers.Utils.Extensions; 4 | using Microsoft.CodeAnalysis; 5 | 6 | namespace CliFx.Analyzers.ObjectModel; 7 | 8 | internal partial class CommandOptionSymbol( 9 | IPropertySymbol property, 10 | string? name, 11 | char? shortName, 12 | bool? isRequired, 13 | ITypeSymbol? converterType, 14 | IReadOnlyList validatorTypes 15 | ) : ICommandMemberSymbol 16 | { 17 | public IPropertySymbol Property { get; } = property; 18 | 19 | public string? Name { get; } = name; 20 | 21 | public char? ShortName { get; } = shortName; 22 | 23 | public bool? IsRequired { get; } = isRequired; 24 | 25 | public ITypeSymbol? ConverterType { get; } = converterType; 26 | 27 | public IReadOnlyList ValidatorTypes { get; } = validatorTypes; 28 | } 29 | 30 | internal partial class CommandOptionSymbol 31 | { 32 | private static AttributeData? TryGetOptionAttribute(IPropertySymbol property) => 33 | property 34 | .GetAttributes() 35 | .FirstOrDefault(a => 36 | a.AttributeClass?.DisplayNameMatches(SymbolNames.CliFxCommandOptionAttribute) 37 | == true 38 | ); 39 | 40 | public static CommandOptionSymbol? TryResolve(IPropertySymbol property) 41 | { 42 | var attribute = TryGetOptionAttribute(property); 43 | if (attribute is null) 44 | return null; 45 | 46 | var name = 47 | attribute 48 | .ConstructorArguments.Where(a => a.Type?.SpecialType == SpecialType.System_String) 49 | .Select(a => a.Value) 50 | .FirstOrDefault() as string; 51 | 52 | var shortName = 53 | attribute 54 | .ConstructorArguments.Where(a => a.Type?.SpecialType == SpecialType.System_Char) 55 | .Select(a => a.Value) 56 | .FirstOrDefault() as char?; 57 | 58 | var isRequired = 59 | attribute 60 | .NamedArguments.Where(a => a.Key == "IsRequired") 61 | .Select(a => a.Value.Value) 62 | .FirstOrDefault() as bool?; 63 | 64 | var converter = attribute 65 | .NamedArguments.Where(a => a.Key == "Converter") 66 | .Select(a => a.Value.Value) 67 | .Cast() 68 | .FirstOrDefault(); 69 | 70 | var validators = attribute 71 | .NamedArguments.Where(a => a.Key == "Validators") 72 | .SelectMany(a => a.Value.Values) 73 | .Select(c => c.Value) 74 | .Cast() 75 | .ToArray(); 76 | 77 | return new CommandOptionSymbol( 78 | property, 79 | name, 80 | shortName, 81 | isRequired, 82 | converter, 83 | validators 84 | ); 85 | } 86 | 87 | public static bool IsOptionProperty(IPropertySymbol property) => 88 | TryGetOptionAttribute(property) is not null; 89 | } 90 | -------------------------------------------------------------------------------- /CliFx.Analyzers/ObjectModel/CommandParameterSymbol.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using CliFx.Analyzers.Utils.Extensions; 4 | using Microsoft.CodeAnalysis; 5 | 6 | namespace CliFx.Analyzers.ObjectModel; 7 | 8 | internal partial class CommandParameterSymbol( 9 | IPropertySymbol property, 10 | int order, 11 | string? name, 12 | bool? isRequired, 13 | ITypeSymbol? converterType, 14 | IReadOnlyList validatorTypes 15 | ) : ICommandMemberSymbol 16 | { 17 | public IPropertySymbol Property { get; } = property; 18 | 19 | public int Order { get; } = order; 20 | 21 | public string? Name { get; } = name; 22 | 23 | public bool? IsRequired { get; } = isRequired; 24 | 25 | public ITypeSymbol? ConverterType { get; } = converterType; 26 | 27 | public IReadOnlyList ValidatorTypes { get; } = validatorTypes; 28 | } 29 | 30 | internal partial class CommandParameterSymbol 31 | { 32 | private static AttributeData? TryGetParameterAttribute(IPropertySymbol property) => 33 | property 34 | .GetAttributes() 35 | .FirstOrDefault(a => 36 | a.AttributeClass?.DisplayNameMatches(SymbolNames.CliFxCommandParameterAttribute) 37 | == true 38 | ); 39 | 40 | public static CommandParameterSymbol? TryResolve(IPropertySymbol property) 41 | { 42 | var attribute = TryGetParameterAttribute(property); 43 | if (attribute is null) 44 | return null; 45 | 46 | var order = (int)attribute.ConstructorArguments.Select(a => a.Value).First()!; 47 | 48 | var name = 49 | attribute 50 | .NamedArguments.Where(a => a.Key == "Name") 51 | .Select(a => a.Value.Value) 52 | .FirstOrDefault() as string; 53 | 54 | var isRequired = 55 | attribute 56 | .NamedArguments.Where(a => a.Key == "IsRequired") 57 | .Select(a => a.Value.Value) 58 | .FirstOrDefault() as bool?; 59 | 60 | var converter = attribute 61 | .NamedArguments.Where(a => a.Key == "Converter") 62 | .Select(a => a.Value.Value) 63 | .Cast() 64 | .FirstOrDefault(); 65 | 66 | var validators = attribute 67 | .NamedArguments.Where(a => a.Key == "Validators") 68 | .SelectMany(a => a.Value.Values) 69 | .Select(c => c.Value) 70 | .Cast() 71 | .ToArray(); 72 | 73 | return new CommandParameterSymbol(property, order, name, isRequired, converter, validators); 74 | } 75 | 76 | public static bool IsParameterProperty(IPropertySymbol property) => 77 | TryGetParameterAttribute(property) is not null; 78 | } 79 | -------------------------------------------------------------------------------- /CliFx.Analyzers/ObjectModel/ICommandMemberSymbol.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using CliFx.Analyzers.Utils.Extensions; 3 | using Microsoft.CodeAnalysis; 4 | 5 | namespace CliFx.Analyzers.ObjectModel; 6 | 7 | internal interface ICommandMemberSymbol 8 | { 9 | IPropertySymbol Property { get; } 10 | 11 | ITypeSymbol? ConverterType { get; } 12 | 13 | IReadOnlyList ValidatorTypes { get; } 14 | } 15 | 16 | internal static class CommandMemberSymbolExtensions 17 | { 18 | public static bool IsScalar(this ICommandMemberSymbol member) => 19 | member.Property.Type.SpecialType == SpecialType.System_String 20 | || member.Property.Type.TryGetEnumerableUnderlyingType() is null; 21 | } 22 | -------------------------------------------------------------------------------- /CliFx.Analyzers/ObjectModel/SymbolNames.cs: -------------------------------------------------------------------------------- 1 | namespace CliFx.Analyzers.ObjectModel; 2 | 3 | internal static class SymbolNames 4 | { 5 | public const string CliFxCommandInterface = "CliFx.ICommand"; 6 | public const string CliFxCommandAttribute = "CliFx.Attributes.CommandAttribute"; 7 | public const string CliFxCommandParameterAttribute = 8 | "CliFx.Attributes.CommandParameterAttribute"; 9 | public const string CliFxCommandOptionAttribute = "CliFx.Attributes.CommandOptionAttribute"; 10 | public const string CliFxConsoleInterface = "CliFx.Infrastructure.IConsole"; 11 | public const string CliFxBindingConverterClass = "CliFx.Extensibility.BindingConverter"; 12 | public const string CliFxBindingValidatorClass = "CliFx.Extensibility.BindingValidator"; 13 | } 14 | -------------------------------------------------------------------------------- /CliFx.Analyzers/OptionMustBeInsideCommandAnalyzer.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using CliFx.Analyzers.ObjectModel; 3 | using CliFx.Analyzers.Utils.Extensions; 4 | using Microsoft.CodeAnalysis; 5 | using Microsoft.CodeAnalysis.CSharp.Syntax; 6 | using Microsoft.CodeAnalysis.Diagnostics; 7 | 8 | namespace CliFx.Analyzers; 9 | 10 | [DiagnosticAnalyzer(LanguageNames.CSharp)] 11 | public class OptionMustBeInsideCommandAnalyzer() 12 | : AnalyzerBase( 13 | "Options must be defined inside commands", 14 | $"This option must be defined inside a class that implements `{SymbolNames.CliFxCommandInterface}`." 15 | ) 16 | { 17 | private void Analyze( 18 | SyntaxNodeAnalysisContext context, 19 | PropertyDeclarationSyntax propertyDeclaration, 20 | IPropertySymbol property 21 | ) 22 | { 23 | if (property.ContainingType is null) 24 | return; 25 | 26 | if (property.ContainingType.IsAbstract) 27 | return; 28 | 29 | if (!CommandOptionSymbol.IsOptionProperty(property)) 30 | return; 31 | 32 | var isInsideCommand = property.ContainingType.AllInterfaces.Any(i => 33 | i.DisplayNameMatches(SymbolNames.CliFxCommandInterface) 34 | ); 35 | 36 | if (!isInsideCommand) 37 | { 38 | context.ReportDiagnostic( 39 | CreateDiagnostic(propertyDeclaration.Identifier.GetLocation()) 40 | ); 41 | } 42 | } 43 | 44 | public override void Initialize(AnalysisContext context) 45 | { 46 | base.Initialize(context); 47 | context.HandlePropertyDeclaration(Analyze); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /CliFx.Analyzers/OptionMustBeRequiredIfPropertyRequiredAnalyzer.cs: -------------------------------------------------------------------------------- 1 | using CliFx.Analyzers.ObjectModel; 2 | using CliFx.Analyzers.Utils.Extensions; 3 | using Microsoft.CodeAnalysis; 4 | using Microsoft.CodeAnalysis.CSharp.Syntax; 5 | using Microsoft.CodeAnalysis.Diagnostics; 6 | 7 | namespace CliFx.Analyzers; 8 | 9 | [DiagnosticAnalyzer(LanguageNames.CSharp)] 10 | public class OptionMustBeRequiredIfPropertyRequiredAnalyzer() 11 | : AnalyzerBase( 12 | "Options bound to required properties cannot be marked as non-required", 13 | "This option cannot be marked as non-required because it's bound to a required property." 14 | ) 15 | { 16 | private void Analyze( 17 | SyntaxNodeAnalysisContext context, 18 | PropertyDeclarationSyntax propertyDeclaration, 19 | IPropertySymbol property 20 | ) 21 | { 22 | if (property.ContainingType is null) 23 | return; 24 | 25 | if (!property.IsRequired()) 26 | return; 27 | 28 | var option = CommandOptionSymbol.TryResolve(property); 29 | if (option is null) 30 | return; 31 | 32 | if (option.IsRequired != false) 33 | return; 34 | 35 | context.ReportDiagnostic(CreateDiagnostic(propertyDeclaration.Identifier.GetLocation())); 36 | } 37 | 38 | public override void Initialize(AnalysisContext context) 39 | { 40 | base.Initialize(context); 41 | context.HandlePropertyDeclaration(Analyze); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /CliFx.Analyzers/OptionMustHaveNameOrShortNameAnalyzer.cs: -------------------------------------------------------------------------------- 1 | using CliFx.Analyzers.ObjectModel; 2 | using CliFx.Analyzers.Utils.Extensions; 3 | using Microsoft.CodeAnalysis; 4 | using Microsoft.CodeAnalysis.CSharp.Syntax; 5 | using Microsoft.CodeAnalysis.Diagnostics; 6 | 7 | namespace CliFx.Analyzers; 8 | 9 | [DiagnosticAnalyzer(LanguageNames.CSharp)] 10 | public class OptionMustHaveNameOrShortNameAnalyzer() 11 | : AnalyzerBase( 12 | "Options must have either a name or short name specified", 13 | "This option must have either a name or short name specified." 14 | ) 15 | { 16 | private void Analyze( 17 | SyntaxNodeAnalysisContext context, 18 | PropertyDeclarationSyntax propertyDeclaration, 19 | IPropertySymbol property 20 | ) 21 | { 22 | var option = CommandOptionSymbol.TryResolve(property); 23 | if (option is null) 24 | return; 25 | 26 | if (string.IsNullOrWhiteSpace(option.Name) && option.ShortName is null) 27 | { 28 | context.ReportDiagnostic( 29 | CreateDiagnostic(propertyDeclaration.Identifier.GetLocation()) 30 | ); 31 | } 32 | } 33 | 34 | public override void Initialize(AnalysisContext context) 35 | { 36 | base.Initialize(context); 37 | context.HandlePropertyDeclaration(Analyze); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /CliFx.Analyzers/OptionMustHaveUniqueNameAnalyzer.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using CliFx.Analyzers.ObjectModel; 4 | using CliFx.Analyzers.Utils.Extensions; 5 | using Microsoft.CodeAnalysis; 6 | using Microsoft.CodeAnalysis.CSharp.Syntax; 7 | using Microsoft.CodeAnalysis.Diagnostics; 8 | 9 | namespace CliFx.Analyzers; 10 | 11 | [DiagnosticAnalyzer(LanguageNames.CSharp)] 12 | public class OptionMustHaveUniqueNameAnalyzer() 13 | : AnalyzerBase( 14 | "Options must have unique names", 15 | "This option's name must be unique within the command (comparison IS NOT case sensitive). " 16 | + "Specified name: `{0}`. " 17 | + "Property bound to another option with the same name: `{1}`." 18 | ) 19 | { 20 | private void Analyze( 21 | SyntaxNodeAnalysisContext context, 22 | PropertyDeclarationSyntax propertyDeclaration, 23 | IPropertySymbol property 24 | ) 25 | { 26 | if (property.ContainingType is null) 27 | return; 28 | 29 | var option = CommandOptionSymbol.TryResolve(property); 30 | if (option is null) 31 | return; 32 | 33 | if (string.IsNullOrWhiteSpace(option.Name)) 34 | return; 35 | 36 | var otherProperties = property 37 | .ContainingType.GetMembers() 38 | .OfType() 39 | .Where(m => !m.Equals(property)) 40 | .ToArray(); 41 | 42 | foreach (var otherProperty in otherProperties) 43 | { 44 | var otherOption = CommandOptionSymbol.TryResolve(otherProperty); 45 | if (otherOption is null) 46 | continue; 47 | 48 | if (string.IsNullOrWhiteSpace(otherOption.Name)) 49 | continue; 50 | 51 | if (string.Equals(option.Name, otherOption.Name, StringComparison.OrdinalIgnoreCase)) 52 | { 53 | context.ReportDiagnostic( 54 | CreateDiagnostic( 55 | propertyDeclaration.Identifier.GetLocation(), 56 | option.Name, 57 | otherProperty.Name 58 | ) 59 | ); 60 | } 61 | } 62 | } 63 | 64 | public override void Initialize(AnalysisContext context) 65 | { 66 | base.Initialize(context); 67 | context.HandlePropertyDeclaration(Analyze); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /CliFx.Analyzers/OptionMustHaveUniqueShortNameAnalyzer.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using CliFx.Analyzers.ObjectModel; 3 | using CliFx.Analyzers.Utils.Extensions; 4 | using Microsoft.CodeAnalysis; 5 | using Microsoft.CodeAnalysis.CSharp.Syntax; 6 | using Microsoft.CodeAnalysis.Diagnostics; 7 | 8 | namespace CliFx.Analyzers; 9 | 10 | [DiagnosticAnalyzer(LanguageNames.CSharp)] 11 | public class OptionMustHaveUniqueShortNameAnalyzer() 12 | : AnalyzerBase( 13 | "Options must have unique short names", 14 | "This option's short name must be unique within the command (comparison IS case sensitive). " 15 | + "Specified short name: `{0}` " 16 | + "Property bound to another option with the same short name: `{1}`." 17 | ) 18 | { 19 | private void Analyze( 20 | SyntaxNodeAnalysisContext context, 21 | PropertyDeclarationSyntax propertyDeclaration, 22 | IPropertySymbol property 23 | ) 24 | { 25 | if (property.ContainingType is null) 26 | return; 27 | 28 | var option = CommandOptionSymbol.TryResolve(property); 29 | if (option is null) 30 | return; 31 | 32 | if (option.ShortName is null) 33 | return; 34 | 35 | var otherProperties = property 36 | .ContainingType.GetMembers() 37 | .OfType() 38 | .Where(m => !m.Equals(property)) 39 | .ToArray(); 40 | 41 | foreach (var otherProperty in otherProperties) 42 | { 43 | var otherOption = CommandOptionSymbol.TryResolve(otherProperty); 44 | if (otherOption is null) 45 | continue; 46 | 47 | if (otherOption.ShortName is null) 48 | continue; 49 | 50 | if (option.ShortName == otherOption.ShortName) 51 | { 52 | context.ReportDiagnostic( 53 | CreateDiagnostic( 54 | propertyDeclaration.Identifier.GetLocation(), 55 | option.ShortName, 56 | otherProperty.Name 57 | ) 58 | ); 59 | } 60 | } 61 | } 62 | 63 | public override void Initialize(AnalysisContext context) 64 | { 65 | base.Initialize(context); 66 | context.HandlePropertyDeclaration(Analyze); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /CliFx.Analyzers/OptionMustHaveValidConverterAnalyzer.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using CliFx.Analyzers.ObjectModel; 3 | using CliFx.Analyzers.Utils.Extensions; 4 | using Microsoft.CodeAnalysis; 5 | using Microsoft.CodeAnalysis.CSharp.Syntax; 6 | using Microsoft.CodeAnalysis.Diagnostics; 7 | 8 | namespace CliFx.Analyzers; 9 | 10 | [DiagnosticAnalyzer(LanguageNames.CSharp)] 11 | public class OptionMustHaveValidConverterAnalyzer() 12 | : AnalyzerBase( 13 | $"Option converters must derive from `{SymbolNames.CliFxBindingConverterClass}`", 14 | $"Converter specified for this option must derive from a compatible `{SymbolNames.CliFxBindingConverterClass}`." 15 | ) 16 | { 17 | private void Analyze( 18 | SyntaxNodeAnalysisContext context, 19 | PropertyDeclarationSyntax propertyDeclaration, 20 | IPropertySymbol property 21 | ) 22 | { 23 | var option = CommandOptionSymbol.TryResolve(property); 24 | if (option is null) 25 | return; 26 | 27 | if (option.ConverterType is null) 28 | return; 29 | 30 | var converterValueType = option 31 | .ConverterType.GetBaseTypes() 32 | .FirstOrDefault(t => 33 | t.ConstructedFrom.DisplayNameMatches(SymbolNames.CliFxBindingConverterClass) 34 | ) 35 | ?.TypeArguments.FirstOrDefault(); 36 | 37 | // Value returned by the converter must be assignable to the property type 38 | var isCompatible = 39 | converterValueType is not null 40 | && ( 41 | option.IsScalar() 42 | // Scalar 43 | ? context.Compilation.IsAssignable(converterValueType, property.Type) 44 | // Non-scalar (assume we can handle all IEnumerable types for simplicity) 45 | : property.Type.TryGetEnumerableUnderlyingType() is { } enumerableUnderlyingType 46 | && context.Compilation.IsAssignable( 47 | converterValueType, 48 | enumerableUnderlyingType 49 | ) 50 | ); 51 | 52 | if (!isCompatible) 53 | { 54 | context.ReportDiagnostic( 55 | CreateDiagnostic(propertyDeclaration.Identifier.GetLocation()) 56 | ); 57 | } 58 | } 59 | 60 | public override void Initialize(AnalysisContext context) 61 | { 62 | base.Initialize(context); 63 | context.HandlePropertyDeclaration(Analyze); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /CliFx.Analyzers/OptionMustHaveValidNameAnalyzer.cs: -------------------------------------------------------------------------------- 1 | using CliFx.Analyzers.ObjectModel; 2 | using CliFx.Analyzers.Utils.Extensions; 3 | using Microsoft.CodeAnalysis; 4 | using Microsoft.CodeAnalysis.CSharp.Syntax; 5 | using Microsoft.CodeAnalysis.Diagnostics; 6 | 7 | namespace CliFx.Analyzers; 8 | 9 | [DiagnosticAnalyzer(LanguageNames.CSharp)] 10 | public class OptionMustHaveValidNameAnalyzer() 11 | : AnalyzerBase( 12 | "Options must have valid names", 13 | "This option's name must be at least 2 characters long and must start with a letter. " 14 | + "Specified name: `{0}`." 15 | ) 16 | { 17 | private void Analyze( 18 | SyntaxNodeAnalysisContext context, 19 | PropertyDeclarationSyntax propertyDeclaration, 20 | IPropertySymbol property 21 | ) 22 | { 23 | var option = CommandOptionSymbol.TryResolve(property); 24 | if (option is null) 25 | return; 26 | 27 | if (string.IsNullOrWhiteSpace(option.Name)) 28 | return; 29 | 30 | if (option.Name.Length < 2 || !char.IsLetter(option.Name[0])) 31 | { 32 | context.ReportDiagnostic( 33 | CreateDiagnostic(propertyDeclaration.Identifier.GetLocation(), option.Name) 34 | ); 35 | } 36 | } 37 | 38 | public override void Initialize(AnalysisContext context) 39 | { 40 | base.Initialize(context); 41 | context.HandlePropertyDeclaration(Analyze); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /CliFx.Analyzers/OptionMustHaveValidShortNameAnalyzer.cs: -------------------------------------------------------------------------------- 1 | using CliFx.Analyzers.ObjectModel; 2 | using CliFx.Analyzers.Utils.Extensions; 3 | using Microsoft.CodeAnalysis; 4 | using Microsoft.CodeAnalysis.CSharp.Syntax; 5 | using Microsoft.CodeAnalysis.Diagnostics; 6 | 7 | namespace CliFx.Analyzers; 8 | 9 | [DiagnosticAnalyzer(LanguageNames.CSharp)] 10 | public class OptionMustHaveValidShortNameAnalyzer() 11 | : AnalyzerBase( 12 | "Option short names must be letter characters", 13 | "This option's short name must be a single letter character. " 14 | + "Specified short name: `{0}`." 15 | ) 16 | { 17 | private void Analyze( 18 | SyntaxNodeAnalysisContext context, 19 | PropertyDeclarationSyntax propertyDeclaration, 20 | IPropertySymbol property 21 | ) 22 | { 23 | var option = CommandOptionSymbol.TryResolve(property); 24 | if (option is null) 25 | return; 26 | 27 | if (option.ShortName is null) 28 | return; 29 | 30 | if (!char.IsLetter(option.ShortName.Value)) 31 | { 32 | context.ReportDiagnostic( 33 | CreateDiagnostic(propertyDeclaration.Identifier.GetLocation(), option.ShortName) 34 | ); 35 | } 36 | } 37 | 38 | public override void Initialize(AnalysisContext context) 39 | { 40 | base.Initialize(context); 41 | context.HandlePropertyDeclaration(Analyze); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /CliFx.Analyzers/OptionMustHaveValidValidatorsAnalyzer.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using CliFx.Analyzers.ObjectModel; 3 | using CliFx.Analyzers.Utils.Extensions; 4 | using Microsoft.CodeAnalysis; 5 | using Microsoft.CodeAnalysis.CSharp.Syntax; 6 | using Microsoft.CodeAnalysis.Diagnostics; 7 | 8 | namespace CliFx.Analyzers; 9 | 10 | [DiagnosticAnalyzer(LanguageNames.CSharp)] 11 | public class OptionMustHaveValidValidatorsAnalyzer() 12 | : AnalyzerBase( 13 | $"Option validators must derive from `{SymbolNames.CliFxBindingValidatorClass}`", 14 | $"Each validator specified for this option must derive from a compatible `{SymbolNames.CliFxBindingValidatorClass}`." 15 | ) 16 | { 17 | private void Analyze( 18 | SyntaxNodeAnalysisContext context, 19 | PropertyDeclarationSyntax propertyDeclaration, 20 | IPropertySymbol property 21 | ) 22 | { 23 | var option = CommandOptionSymbol.TryResolve(property); 24 | if (option is null) 25 | return; 26 | 27 | foreach (var validatorType in option.ValidatorTypes) 28 | { 29 | var validatorValueType = validatorType 30 | .GetBaseTypes() 31 | .FirstOrDefault(t => 32 | t.ConstructedFrom.DisplayNameMatches(SymbolNames.CliFxBindingValidatorClass) 33 | ) 34 | ?.TypeArguments.FirstOrDefault(); 35 | 36 | // Value passed to the validator must be assignable from the property type 37 | var isCompatible = 38 | validatorValueType is not null 39 | && context.Compilation.IsAssignable(property.Type, validatorValueType); 40 | 41 | if (!isCompatible) 42 | { 43 | context.ReportDiagnostic( 44 | CreateDiagnostic(propertyDeclaration.Identifier.GetLocation()) 45 | ); 46 | 47 | // No need to report multiple identical diagnostics on the same node 48 | break; 49 | } 50 | } 51 | } 52 | 53 | public override void Initialize(AnalysisContext context) 54 | { 55 | base.Initialize(context); 56 | context.HandlePropertyDeclaration(Analyze); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /CliFx.Analyzers/ParameterMustBeInsideCommandAnalyzer.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using CliFx.Analyzers.ObjectModel; 3 | using CliFx.Analyzers.Utils.Extensions; 4 | using Microsoft.CodeAnalysis; 5 | using Microsoft.CodeAnalysis.CSharp.Syntax; 6 | using Microsoft.CodeAnalysis.Diagnostics; 7 | 8 | namespace CliFx.Analyzers; 9 | 10 | [DiagnosticAnalyzer(LanguageNames.CSharp)] 11 | public class ParameterMustBeInsideCommandAnalyzer() 12 | : AnalyzerBase( 13 | "Parameters must be defined inside commands", 14 | $"This parameter must be defined inside a class that implements `{SymbolNames.CliFxCommandInterface}`." 15 | ) 16 | { 17 | private void Analyze( 18 | SyntaxNodeAnalysisContext context, 19 | PropertyDeclarationSyntax propertyDeclaration, 20 | IPropertySymbol property 21 | ) 22 | { 23 | if (property.ContainingType is null) 24 | return; 25 | 26 | if (property.ContainingType.IsAbstract) 27 | return; 28 | 29 | if (!CommandParameterSymbol.IsParameterProperty(property)) 30 | return; 31 | 32 | var isInsideCommand = property.ContainingType.AllInterfaces.Any(i => 33 | i.DisplayNameMatches(SymbolNames.CliFxCommandInterface) 34 | ); 35 | 36 | if (!isInsideCommand) 37 | { 38 | context.ReportDiagnostic( 39 | CreateDiagnostic(propertyDeclaration.Identifier.GetLocation()) 40 | ); 41 | } 42 | } 43 | 44 | public override void Initialize(AnalysisContext context) 45 | { 46 | base.Initialize(context); 47 | context.HandlePropertyDeclaration(Analyze); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /CliFx.Analyzers/ParameterMustBeLastIfNonRequiredAnalyzer.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using CliFx.Analyzers.ObjectModel; 3 | using CliFx.Analyzers.Utils.Extensions; 4 | using Microsoft.CodeAnalysis; 5 | using Microsoft.CodeAnalysis.CSharp.Syntax; 6 | using Microsoft.CodeAnalysis.Diagnostics; 7 | 8 | namespace CliFx.Analyzers; 9 | 10 | [DiagnosticAnalyzer(LanguageNames.CSharp)] 11 | public class ParameterMustBeLastIfNonRequiredAnalyzer() 12 | : AnalyzerBase( 13 | "Parameters marked as non-required must be the last in order", 14 | "This parameter is non-required so it must be the last in order (its order must be highest within the command). " 15 | + "Property bound to another non-required parameter: `{0}`." 16 | ) 17 | { 18 | private void Analyze( 19 | SyntaxNodeAnalysisContext context, 20 | PropertyDeclarationSyntax propertyDeclaration, 21 | IPropertySymbol property 22 | ) 23 | { 24 | if (property.ContainingType is null) 25 | return; 26 | 27 | var parameter = CommandParameterSymbol.TryResolve(property); 28 | if (parameter is null) 29 | return; 30 | 31 | if (parameter.IsRequired != false) 32 | return; 33 | 34 | var otherProperties = property 35 | .ContainingType.GetMembers() 36 | .OfType() 37 | .Where(m => !m.Equals(property)) 38 | .ToArray(); 39 | 40 | foreach (var otherProperty in otherProperties) 41 | { 42 | var otherParameter = CommandParameterSymbol.TryResolve(otherProperty); 43 | if (otherParameter is null) 44 | continue; 45 | 46 | if (otherParameter.Order > parameter.Order) 47 | { 48 | context.ReportDiagnostic( 49 | CreateDiagnostic( 50 | propertyDeclaration.Identifier.GetLocation(), 51 | otherProperty.Name 52 | ) 53 | ); 54 | } 55 | } 56 | } 57 | 58 | public override void Initialize(AnalysisContext context) 59 | { 60 | base.Initialize(context); 61 | context.HandlePropertyDeclaration(Analyze); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /CliFx.Analyzers/ParameterMustBeLastIfNonScalarAnalyzer.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using CliFx.Analyzers.ObjectModel; 3 | using CliFx.Analyzers.Utils.Extensions; 4 | using Microsoft.CodeAnalysis; 5 | using Microsoft.CodeAnalysis.CSharp.Syntax; 6 | using Microsoft.CodeAnalysis.Diagnostics; 7 | 8 | namespace CliFx.Analyzers; 9 | 10 | [DiagnosticAnalyzer(LanguageNames.CSharp)] 11 | public class ParameterMustBeLastIfNonScalarAnalyzer() 12 | : AnalyzerBase( 13 | "Parameters of non-scalar types must be the last in order", 14 | "This parameter has a non-scalar type so it must be the last in order (its order must be highest within the command). " 15 | + "Property bound to another non-scalar parameter: `{0}`." 16 | ) 17 | { 18 | private void Analyze( 19 | SyntaxNodeAnalysisContext context, 20 | PropertyDeclarationSyntax propertyDeclaration, 21 | IPropertySymbol property 22 | ) 23 | { 24 | if (property.ContainingType is null) 25 | return; 26 | 27 | var parameter = CommandParameterSymbol.TryResolve(property); 28 | if (parameter is null) 29 | return; 30 | 31 | if (parameter.IsScalar()) 32 | return; 33 | 34 | var otherProperties = property 35 | .ContainingType.GetMembers() 36 | .OfType() 37 | .Where(m => !m.Equals(property)) 38 | .ToArray(); 39 | 40 | foreach (var otherProperty in otherProperties) 41 | { 42 | var otherParameter = CommandParameterSymbol.TryResolve(otherProperty); 43 | if (otherParameter is null) 44 | continue; 45 | 46 | if (otherParameter.Order > parameter.Order) 47 | { 48 | context.ReportDiagnostic( 49 | CreateDiagnostic( 50 | propertyDeclaration.Identifier.GetLocation(), 51 | otherProperty.Name 52 | ) 53 | ); 54 | } 55 | } 56 | } 57 | 58 | public override void Initialize(AnalysisContext context) 59 | { 60 | base.Initialize(context); 61 | context.HandlePropertyDeclaration(Analyze); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /CliFx.Analyzers/ParameterMustBeRequiredIfPropertyRequiredAnalyzer.cs: -------------------------------------------------------------------------------- 1 | using CliFx.Analyzers.ObjectModel; 2 | using CliFx.Analyzers.Utils.Extensions; 3 | using Microsoft.CodeAnalysis; 4 | using Microsoft.CodeAnalysis.CSharp.Syntax; 5 | using Microsoft.CodeAnalysis.Diagnostics; 6 | 7 | namespace CliFx.Analyzers; 8 | 9 | [DiagnosticAnalyzer(LanguageNames.CSharp)] 10 | public class ParameterMustBeRequiredIfPropertyRequiredAnalyzer() 11 | : AnalyzerBase( 12 | "Parameters bound to required properties cannot be marked as non-required", 13 | "This parameter cannot be marked as non-required because it's bound to a required property." 14 | ) 15 | { 16 | private void Analyze( 17 | SyntaxNodeAnalysisContext context, 18 | PropertyDeclarationSyntax propertyDeclaration, 19 | IPropertySymbol property 20 | ) 21 | { 22 | if (property.ContainingType is null) 23 | return; 24 | 25 | if (!property.IsRequired()) 26 | return; 27 | 28 | var parameter = CommandParameterSymbol.TryResolve(property); 29 | if (parameter is null) 30 | return; 31 | 32 | if (parameter.IsRequired != false) 33 | return; 34 | 35 | context.ReportDiagnostic(CreateDiagnostic(propertyDeclaration.Identifier.GetLocation())); 36 | } 37 | 38 | public override void Initialize(AnalysisContext context) 39 | { 40 | base.Initialize(context); 41 | context.HandlePropertyDeclaration(Analyze); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /CliFx.Analyzers/ParameterMustBeSingleIfNonRequiredAnalyzer.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using CliFx.Analyzers.ObjectModel; 3 | using CliFx.Analyzers.Utils.Extensions; 4 | using Microsoft.CodeAnalysis; 5 | using Microsoft.CodeAnalysis.CSharp.Syntax; 6 | using Microsoft.CodeAnalysis.Diagnostics; 7 | 8 | namespace CliFx.Analyzers; 9 | 10 | [DiagnosticAnalyzer(LanguageNames.CSharp)] 11 | public class ParameterMustBeSingleIfNonRequiredAnalyzer() 12 | : AnalyzerBase( 13 | "Parameters marked as non-required are limited to one per command", 14 | "This parameter is non-required so it must be the only such parameter in the command. " 15 | + "Property bound to another non-required parameter: `{0}`." 16 | ) 17 | { 18 | private void Analyze( 19 | SyntaxNodeAnalysisContext context, 20 | PropertyDeclarationSyntax propertyDeclaration, 21 | IPropertySymbol property 22 | ) 23 | { 24 | if (property.ContainingType is null) 25 | return; 26 | 27 | var parameter = CommandParameterSymbol.TryResolve(property); 28 | if (parameter is null) 29 | return; 30 | 31 | if (parameter.IsRequired != false) 32 | return; 33 | 34 | var otherProperties = property 35 | .ContainingType.GetMembers() 36 | .OfType() 37 | .Where(m => !m.Equals(property)) 38 | .ToArray(); 39 | 40 | foreach (var otherProperty in otherProperties) 41 | { 42 | var otherParameter = CommandParameterSymbol.TryResolve(otherProperty); 43 | if (otherParameter is null) 44 | continue; 45 | 46 | if (otherParameter.IsRequired == false) 47 | { 48 | context.ReportDiagnostic( 49 | CreateDiagnostic( 50 | propertyDeclaration.Identifier.GetLocation(), 51 | otherProperty.Name 52 | ) 53 | ); 54 | } 55 | } 56 | } 57 | 58 | public override void Initialize(AnalysisContext context) 59 | { 60 | base.Initialize(context); 61 | context.HandlePropertyDeclaration(Analyze); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /CliFx.Analyzers/ParameterMustBeSingleIfNonScalarAnalyzer.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using CliFx.Analyzers.ObjectModel; 3 | using CliFx.Analyzers.Utils.Extensions; 4 | using Microsoft.CodeAnalysis; 5 | using Microsoft.CodeAnalysis.CSharp.Syntax; 6 | using Microsoft.CodeAnalysis.Diagnostics; 7 | 8 | namespace CliFx.Analyzers; 9 | 10 | [DiagnosticAnalyzer(LanguageNames.CSharp)] 11 | public class ParameterMustBeSingleIfNonScalarAnalyzer() 12 | : AnalyzerBase( 13 | "Parameters of non-scalar types are limited to one per command", 14 | "This parameter has a non-scalar type so it must be the only such parameter in the command. " 15 | + "Property bound to another non-scalar parameter: `{0}`." 16 | ) 17 | { 18 | private void Analyze( 19 | SyntaxNodeAnalysisContext context, 20 | PropertyDeclarationSyntax propertyDeclaration, 21 | IPropertySymbol property 22 | ) 23 | { 24 | if (property.ContainingType is null) 25 | return; 26 | 27 | var parameter = CommandParameterSymbol.TryResolve(property); 28 | if (parameter is null) 29 | return; 30 | 31 | if (parameter.IsScalar()) 32 | return; 33 | 34 | var otherProperties = property 35 | .ContainingType.GetMembers() 36 | .OfType() 37 | .Where(m => !m.Equals(property)) 38 | .ToArray(); 39 | 40 | foreach (var otherProperty in otherProperties) 41 | { 42 | var otherParameter = CommandParameterSymbol.TryResolve(otherProperty); 43 | if (otherParameter is null) 44 | continue; 45 | 46 | if (!otherParameter.IsScalar()) 47 | { 48 | context.ReportDiagnostic( 49 | CreateDiagnostic( 50 | propertyDeclaration.Identifier.GetLocation(), 51 | otherProperty.Name 52 | ) 53 | ); 54 | } 55 | } 56 | } 57 | 58 | public override void Initialize(AnalysisContext context) 59 | { 60 | base.Initialize(context); 61 | context.HandlePropertyDeclaration(Analyze); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /CliFx.Analyzers/ParameterMustHaveUniqueNameAnalyzer.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using CliFx.Analyzers.ObjectModel; 4 | using CliFx.Analyzers.Utils.Extensions; 5 | using Microsoft.CodeAnalysis; 6 | using Microsoft.CodeAnalysis.CSharp.Syntax; 7 | using Microsoft.CodeAnalysis.Diagnostics; 8 | 9 | namespace CliFx.Analyzers; 10 | 11 | [DiagnosticAnalyzer(LanguageNames.CSharp)] 12 | public class ParameterMustHaveUniqueNameAnalyzer() 13 | : AnalyzerBase( 14 | "Parameters must have unique names", 15 | "This parameter's name must be unique within the command (comparison IS NOT case sensitive). " 16 | + "Specified name: `{0}`. " 17 | + "Property bound to another parameter with the same name: `{1}`." 18 | ) 19 | { 20 | private void Analyze( 21 | SyntaxNodeAnalysisContext context, 22 | PropertyDeclarationSyntax propertyDeclaration, 23 | IPropertySymbol property 24 | ) 25 | { 26 | if (property.ContainingType is null) 27 | return; 28 | 29 | var parameter = CommandParameterSymbol.TryResolve(property); 30 | if (parameter is null) 31 | return; 32 | 33 | if (string.IsNullOrWhiteSpace(parameter.Name)) 34 | return; 35 | 36 | var otherProperties = property 37 | .ContainingType.GetMembers() 38 | .OfType() 39 | .Where(m => !m.Equals(property)) 40 | .ToArray(); 41 | 42 | foreach (var otherProperty in otherProperties) 43 | { 44 | var otherParameter = CommandParameterSymbol.TryResolve(otherProperty); 45 | if (otherParameter is null) 46 | continue; 47 | 48 | if (string.IsNullOrWhiteSpace(otherParameter.Name)) 49 | continue; 50 | 51 | if ( 52 | string.Equals( 53 | parameter.Name, 54 | otherParameter.Name, 55 | StringComparison.OrdinalIgnoreCase 56 | ) 57 | ) 58 | { 59 | context.ReportDiagnostic( 60 | CreateDiagnostic( 61 | propertyDeclaration.Identifier.GetLocation(), 62 | parameter.Name, 63 | otherProperty.Name 64 | ) 65 | ); 66 | } 67 | } 68 | } 69 | 70 | public override void Initialize(AnalysisContext context) 71 | { 72 | base.Initialize(context); 73 | context.HandlePropertyDeclaration(Analyze); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /CliFx.Analyzers/ParameterMustHaveUniqueOrderAnalyzer.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using CliFx.Analyzers.ObjectModel; 3 | using CliFx.Analyzers.Utils.Extensions; 4 | using Microsoft.CodeAnalysis; 5 | using Microsoft.CodeAnalysis.CSharp.Syntax; 6 | using Microsoft.CodeAnalysis.Diagnostics; 7 | 8 | namespace CliFx.Analyzers; 9 | 10 | [DiagnosticAnalyzer(LanguageNames.CSharp)] 11 | public class ParameterMustHaveUniqueOrderAnalyzer() 12 | : AnalyzerBase( 13 | "Parameters must have unique order", 14 | "This parameter's order must be unique within the command. " 15 | + "Specified order: {0}. " 16 | + "Property bound to another parameter with the same order: `{1}`." 17 | ) 18 | { 19 | private void Analyze( 20 | SyntaxNodeAnalysisContext context, 21 | PropertyDeclarationSyntax propertyDeclaration, 22 | IPropertySymbol property 23 | ) 24 | { 25 | if (property.ContainingType is null) 26 | return; 27 | 28 | var parameter = CommandParameterSymbol.TryResolve(property); 29 | if (parameter is null) 30 | return; 31 | 32 | var otherProperties = property 33 | .ContainingType.GetMembers() 34 | .OfType() 35 | .Where(m => !m.Equals(property)) 36 | .ToArray(); 37 | 38 | foreach (var otherProperty in otherProperties) 39 | { 40 | var otherParameter = CommandParameterSymbol.TryResolve(otherProperty); 41 | if (otherParameter is null) 42 | continue; 43 | 44 | if (parameter.Order == otherParameter.Order) 45 | { 46 | context.ReportDiagnostic( 47 | CreateDiagnostic( 48 | propertyDeclaration.Identifier.GetLocation(), 49 | parameter.Order, 50 | otherProperty.Name 51 | ) 52 | ); 53 | } 54 | } 55 | } 56 | 57 | public override void Initialize(AnalysisContext context) 58 | { 59 | base.Initialize(context); 60 | context.HandlePropertyDeclaration(Analyze); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /CliFx.Analyzers/ParameterMustHaveValidConverterAnalyzer.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using CliFx.Analyzers.ObjectModel; 3 | using CliFx.Analyzers.Utils.Extensions; 4 | using Microsoft.CodeAnalysis; 5 | using Microsoft.CodeAnalysis.CSharp.Syntax; 6 | using Microsoft.CodeAnalysis.Diagnostics; 7 | 8 | namespace CliFx.Analyzers; 9 | 10 | [DiagnosticAnalyzer(LanguageNames.CSharp)] 11 | public class ParameterMustHaveValidConverterAnalyzer() 12 | : AnalyzerBase( 13 | $"Parameter converters must derive from `{SymbolNames.CliFxBindingConverterClass}`", 14 | $"Converter specified for this parameter must derive from a compatible `{SymbolNames.CliFxBindingConverterClass}`." 15 | ) 16 | { 17 | private void Analyze( 18 | SyntaxNodeAnalysisContext context, 19 | PropertyDeclarationSyntax propertyDeclaration, 20 | IPropertySymbol property 21 | ) 22 | { 23 | var parameter = CommandParameterSymbol.TryResolve(property); 24 | if (parameter is null) 25 | return; 26 | 27 | if (parameter.ConverterType is null) 28 | return; 29 | 30 | var converterValueType = parameter 31 | .ConverterType.GetBaseTypes() 32 | .FirstOrDefault(t => 33 | t.ConstructedFrom.DisplayNameMatches(SymbolNames.CliFxBindingConverterClass) 34 | ) 35 | ?.TypeArguments.FirstOrDefault(); 36 | 37 | // Value returned by the converter must be assignable to the property type 38 | var isCompatible = 39 | converterValueType is not null 40 | && ( 41 | parameter.IsScalar() 42 | // Scalar 43 | ? context.Compilation.IsAssignable(converterValueType, property.Type) 44 | // Non-scalar (assume we can handle all IEnumerable types for simplicity) 45 | : property.Type.TryGetEnumerableUnderlyingType() is { } enumerableUnderlyingType 46 | && context.Compilation.IsAssignable( 47 | converterValueType, 48 | enumerableUnderlyingType 49 | ) 50 | ); 51 | 52 | if (!isCompatible) 53 | { 54 | context.ReportDiagnostic( 55 | CreateDiagnostic(propertyDeclaration.Identifier.GetLocation()) 56 | ); 57 | } 58 | } 59 | 60 | public override void Initialize(AnalysisContext context) 61 | { 62 | base.Initialize(context); 63 | context.HandlePropertyDeclaration(Analyze); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /CliFx.Analyzers/ParameterMustHaveValidValidatorsAnalyzer.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using CliFx.Analyzers.ObjectModel; 3 | using CliFx.Analyzers.Utils.Extensions; 4 | using Microsoft.CodeAnalysis; 5 | using Microsoft.CodeAnalysis.CSharp.Syntax; 6 | using Microsoft.CodeAnalysis.Diagnostics; 7 | 8 | namespace CliFx.Analyzers; 9 | 10 | [DiagnosticAnalyzer(LanguageNames.CSharp)] 11 | public class ParameterMustHaveValidValidatorsAnalyzer() 12 | : AnalyzerBase( 13 | $"Parameter validators must derive from `{SymbolNames.CliFxBindingValidatorClass}`", 14 | $"Each validator specified for this parameter must derive from a compatible `{SymbolNames.CliFxBindingValidatorClass}`." 15 | ) 16 | { 17 | private void Analyze( 18 | SyntaxNodeAnalysisContext context, 19 | PropertyDeclarationSyntax propertyDeclaration, 20 | IPropertySymbol property 21 | ) 22 | { 23 | var parameter = CommandParameterSymbol.TryResolve(property); 24 | if (parameter is null) 25 | return; 26 | 27 | foreach (var validatorType in parameter.ValidatorTypes) 28 | { 29 | var validatorValueType = validatorType 30 | .GetBaseTypes() 31 | .FirstOrDefault(t => 32 | t.ConstructedFrom.DisplayNameMatches(SymbolNames.CliFxBindingValidatorClass) 33 | ) 34 | ?.TypeArguments.FirstOrDefault(); 35 | 36 | // Value passed to the validator must be assignable from the property type 37 | var isCompatible = 38 | validatorValueType is not null 39 | && context.Compilation.IsAssignable(property.Type, validatorValueType); 40 | 41 | if (!isCompatible) 42 | { 43 | context.ReportDiagnostic( 44 | CreateDiagnostic(propertyDeclaration.Identifier.GetLocation()) 45 | ); 46 | 47 | // No need to report multiple identical diagnostics on the same node 48 | break; 49 | } 50 | } 51 | } 52 | 53 | public override void Initialize(AnalysisContext context) 54 | { 55 | base.Initialize(context); 56 | context.HandlePropertyDeclaration(Analyze); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /CliFx.Analyzers/SystemConsoleShouldBeAvoidedAnalyzer.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using CliFx.Analyzers.ObjectModel; 3 | using CliFx.Analyzers.Utils.Extensions; 4 | using Microsoft.CodeAnalysis; 5 | using Microsoft.CodeAnalysis.CSharp; 6 | using Microsoft.CodeAnalysis.CSharp.Syntax; 7 | using Microsoft.CodeAnalysis.Diagnostics; 8 | 9 | namespace CliFx.Analyzers; 10 | 11 | [DiagnosticAnalyzer(LanguageNames.CSharp)] 12 | public class SystemConsoleShouldBeAvoidedAnalyzer() 13 | : AnalyzerBase( 14 | $"Avoid calling `System.Console` where `{SymbolNames.CliFxConsoleInterface}` is available", 15 | $"Use the provided `{SymbolNames.CliFxConsoleInterface}` abstraction instead of `System.Console` to ensure that the command can be tested in isolation.", 16 | DiagnosticSeverity.Warning 17 | ) 18 | { 19 | private MemberAccessExpressionSyntax? TryGetSystemConsoleMemberAccess( 20 | SyntaxNodeAnalysisContext context, 21 | SyntaxNode node 22 | ) 23 | { 24 | var currentNode = node; 25 | 26 | while (currentNode is MemberAccessExpressionSyntax memberAccess) 27 | { 28 | var member = context.SemanticModel.GetSymbolInfo(memberAccess).Symbol; 29 | 30 | if (member?.ContainingType?.DisplayNameMatches("System.Console") == true) 31 | { 32 | return memberAccess; 33 | } 34 | 35 | // Get inner expression, which may be another member access expression. 36 | // Example: System.Console.Error 37 | // ~~~~~~~~~~~~~~ <- inner member access expression 38 | // -------------------- <- outer member access expression 39 | currentNode = memberAccess.Expression; 40 | } 41 | 42 | return null; 43 | } 44 | 45 | private void Analyze(SyntaxNodeAnalysisContext context) 46 | { 47 | // Try to get a member access on System.Console in the current expression, 48 | // or in any of its inner expressions. 49 | var systemConsoleMemberAccess = TryGetSystemConsoleMemberAccess(context, context.Node); 50 | if (systemConsoleMemberAccess is null) 51 | return; 52 | 53 | // Check if IConsole is available in scope as an alternative to System.Console 54 | var isConsoleInterfaceAvailable = context 55 | .Node.Ancestors() 56 | .OfType() 57 | .SelectMany(m => m.ParameterList.Parameters) 58 | .Select(p => p.Type) 59 | .Select(t => context.SemanticModel.GetSymbolInfo(t).Symbol) 60 | .Where(s => s is not null) 61 | .Any(s => s.DisplayNameMatches(SymbolNames.CliFxConsoleInterface)); 62 | 63 | if (isConsoleInterfaceAvailable) 64 | { 65 | context.ReportDiagnostic(CreateDiagnostic(systemConsoleMemberAccess.GetLocation())); 66 | } 67 | } 68 | 69 | public override void Initialize(AnalysisContext context) 70 | { 71 | base.Initialize(context); 72 | context.RegisterSyntaxNodeAction(Analyze, SyntaxKind.SimpleMemberAccessExpression); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /CliFx.Analyzers/Utils/Extensions/RoslynExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using Microsoft.CodeAnalysis; 5 | using Microsoft.CodeAnalysis.CSharp; 6 | using Microsoft.CodeAnalysis.CSharp.Syntax; 7 | using Microsoft.CodeAnalysis.Diagnostics; 8 | 9 | namespace CliFx.Analyzers.Utils.Extensions; 10 | 11 | internal static class RoslynExtensions 12 | { 13 | public static bool DisplayNameMatches(this ISymbol symbol, string name) => 14 | string.Equals( 15 | // Fully qualified name, without `global::` 16 | symbol.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat), 17 | name, 18 | StringComparison.Ordinal 19 | ); 20 | 21 | public static IEnumerable GetBaseTypes(this ITypeSymbol type) 22 | { 23 | var current = type.BaseType; 24 | 25 | while (current is not null) 26 | { 27 | yield return current; 28 | current = current.BaseType; 29 | } 30 | } 31 | 32 | public static ITypeSymbol? TryGetEnumerableUnderlyingType(this ITypeSymbol type) => 33 | type 34 | .AllInterfaces.FirstOrDefault(i => 35 | i.ConstructedFrom.SpecialType 36 | == SpecialType.System_Collections_Generic_IEnumerable_T 37 | ) 38 | ?.TypeArguments[0]; 39 | 40 | // Detect if the property is required through roundabout means so as to not have to take dependency 41 | // on higher versions of the C# compiler. 42 | public static bool IsRequired(this IPropertySymbol property) => 43 | property 44 | // Can't rely on the RequiredMemberAttribute because it's generated by the compiler, not added by the user, 45 | // so we have to check for the presence of the `required` modifier in the syntax tree instead. 46 | .DeclaringSyntaxReferences.Select(r => r.GetSyntax()) 47 | .OfType() 48 | .SelectMany(p => p.Modifiers) 49 | .Any(m => m.IsKind((SyntaxKind)8447)); 50 | 51 | public static bool IsAssignable( 52 | this Compilation compilation, 53 | ITypeSymbol source, 54 | ITypeSymbol destination 55 | ) => compilation.ClassifyConversion(source, destination).Exists; 56 | 57 | public static void HandleClassDeclaration( 58 | this AnalysisContext analysisContext, 59 | Action analyze 60 | ) 61 | { 62 | analysisContext.RegisterSyntaxNodeAction( 63 | ctx => 64 | { 65 | if (ctx.Node is not ClassDeclarationSyntax classDeclaration) 66 | return; 67 | 68 | var type = ctx.SemanticModel.GetDeclaredSymbol(classDeclaration); 69 | if (type is null) 70 | return; 71 | 72 | analyze(ctx, classDeclaration, type); 73 | }, 74 | SyntaxKind.ClassDeclaration 75 | ); 76 | } 77 | 78 | public static void HandlePropertyDeclaration( 79 | this AnalysisContext analysisContext, 80 | Action analyze 81 | ) 82 | { 83 | analysisContext.RegisterSyntaxNodeAction( 84 | ctx => 85 | { 86 | if (ctx.Node is not PropertyDeclarationSyntax propertyDeclaration) 87 | return; 88 | 89 | var property = ctx.SemanticModel.GetDeclaredSymbol(propertyDeclaration); 90 | if (property is null) 91 | return; 92 | 93 | analyze(ctx, propertyDeclaration, property); 94 | }, 95 | SyntaxKind.PropertyDeclaration 96 | ); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /CliFx.Analyzers/Utils/Extensions/StringExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace CliFx.Analyzers.Utils.Extensions; 4 | 5 | internal static class StringExtensions 6 | { 7 | public static string TrimEnd( 8 | this string str, 9 | string sub, 10 | StringComparison comparison = StringComparison.Ordinal 11 | ) 12 | { 13 | while (str.EndsWith(sub, comparison)) 14 | str = str[..^sub.Length]; 15 | 16 | return str; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /CliFx.Benchmarks/Benchmarks.CliFx.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Threading.Tasks; 3 | using BenchmarkDotNet.Attributes; 4 | using CliFx.Attributes; 5 | using CliFx.Infrastructure; 6 | 7 | namespace CliFx.Benchmarks; 8 | 9 | public partial class Benchmarks 10 | { 11 | [Command] 12 | public class CliFxCommand : ICommand 13 | { 14 | [CommandOption("str", 's')] 15 | public string? StrOption { get; set; } 16 | 17 | [CommandOption("int", 'i')] 18 | public int IntOption { get; set; } 19 | 20 | [CommandOption("bool", 'b')] 21 | public bool BoolOption { get; set; } 22 | 23 | public ValueTask ExecuteAsync(IConsole console) => default; 24 | } 25 | 26 | [Benchmark(Description = "CliFx", Baseline = true)] 27 | public async ValueTask ExecuteWithCliFx() => 28 | await new CliApplicationBuilder() 29 | .AddCommand() 30 | .Build() 31 | .RunAsync(Arguments, new Dictionary()); 32 | } 33 | -------------------------------------------------------------------------------- /CliFx.Benchmarks/Benchmarks.Clipr.cs: -------------------------------------------------------------------------------- 1 | using BenchmarkDotNet.Attributes; 2 | using clipr; 3 | 4 | namespace CliFx.Benchmarks; 5 | 6 | public partial class Benchmarks 7 | { 8 | public class CliprCommand 9 | { 10 | [NamedArgument('s', "str")] 11 | public string? StrOption { get; set; } 12 | 13 | [NamedArgument('i', "int")] 14 | public int IntOption { get; set; } 15 | 16 | [NamedArgument('b', "bool", Constraint = NumArgsConstraint.Optional, Const = true)] 17 | public bool BoolOption { get; set; } 18 | 19 | public void Execute() { } 20 | } 21 | 22 | [Benchmark(Description = "Clipr")] 23 | public void ExecuteWithClipr() => CliParser.Parse(Arguments).Execute(); 24 | } 25 | -------------------------------------------------------------------------------- /CliFx.Benchmarks/Benchmarks.Cocona.cs: -------------------------------------------------------------------------------- 1 | using BenchmarkDotNet.Attributes; 2 | using Cocona; 3 | 4 | namespace CliFx.Benchmarks; 5 | 6 | public partial class Benchmarks 7 | { 8 | public class CoconaCommand 9 | { 10 | public void Execute( 11 | [Option("str", ['s'])] string? strOption, 12 | [Option("int", ['i'])] int intOption, 13 | [Option("bool", ['b'])] bool boolOption 14 | ) { } 15 | } 16 | 17 | [Benchmark(Description = "Cocona")] 18 | public void ExecuteWithCocona() => CoconaApp.Run(Arguments); 19 | } 20 | -------------------------------------------------------------------------------- /CliFx.Benchmarks/Benchmarks.CommandLineParser.cs: -------------------------------------------------------------------------------- 1 | using BenchmarkDotNet.Attributes; 2 | using CommandLine; 3 | 4 | namespace CliFx.Benchmarks; 5 | 6 | public partial class Benchmarks 7 | { 8 | public class CommandLineParserCommand 9 | { 10 | [Option('s', "str")] 11 | public string? StrOption { get; set; } 12 | 13 | [Option('i', "int")] 14 | public int IntOption { get; set; } 15 | 16 | [Option('b', "bool")] 17 | public bool BoolOption { get; set; } 18 | 19 | public void Execute() { } 20 | } 21 | 22 | [Benchmark(Description = "CommandLineParser")] 23 | public void ExecuteWithCommandLineParser() => 24 | new Parser() 25 | .ParseArguments(Arguments, typeof(CommandLineParserCommand)) 26 | .WithParsed(c => c.Execute()); 27 | } 28 | -------------------------------------------------------------------------------- /CliFx.Benchmarks/Benchmarks.McMaster.cs: -------------------------------------------------------------------------------- 1 | using BenchmarkDotNet.Attributes; 2 | using McMaster.Extensions.CommandLineUtils; 3 | 4 | namespace CliFx.Benchmarks; 5 | 6 | public partial class Benchmarks 7 | { 8 | public class McMasterCommand 9 | { 10 | [Option("--str|-s")] 11 | public string? StrOption { get; set; } 12 | 13 | [Option("--int|-i")] 14 | public int IntOption { get; set; } 15 | 16 | [Option("--bool|-b")] 17 | public bool BoolOption { get; set; } 18 | 19 | public int OnExecute() => 0; 20 | } 21 | 22 | [Benchmark(Description = "McMaster.Extensions.CommandLineUtils")] 23 | public int ExecuteWithMcMaster() => CommandLineApplication.Execute(Arguments); 24 | } 25 | -------------------------------------------------------------------------------- /CliFx.Benchmarks/Benchmarks.PowerArgs.cs: -------------------------------------------------------------------------------- 1 | using BenchmarkDotNet.Attributes; 2 | using PowerArgs; 3 | 4 | namespace CliFx.Benchmarks; 5 | 6 | public partial class Benchmarks 7 | { 8 | public class PowerArgsCommand 9 | { 10 | [ArgShortcut("--str"), ArgShortcut("-s")] 11 | public string? StrOption { get; set; } 12 | 13 | [ArgShortcut("--int"), ArgShortcut("-i")] 14 | public int IntOption { get; set; } 15 | 16 | [ArgShortcut("--bool"), ArgShortcut("-b")] 17 | public bool BoolOption { get; set; } 18 | 19 | public void Main() { } 20 | } 21 | 22 | [Benchmark(Description = "PowerArgs")] 23 | public void ExecuteWithPowerArgs() => Args.InvokeMain(Arguments); 24 | } 25 | -------------------------------------------------------------------------------- /CliFx.Benchmarks/Benchmarks.SystemCommandLine.cs: -------------------------------------------------------------------------------- 1 | using System.CommandLine; 2 | using System.Threading.Tasks; 3 | using BenchmarkDotNet.Attributes; 4 | 5 | namespace CliFx.Benchmarks; 6 | 7 | public partial class Benchmarks 8 | { 9 | public class SystemCommandLineCommand 10 | { 11 | public static void ExecuteHandler(string s, int i, bool b) { } 12 | 13 | public Task ExecuteAsync(string[] args) 14 | { 15 | var stringOption = new Option(["--str", "-s"]); 16 | var intOption = new Option(["--int", "-i"]); 17 | var boolOption = new Option(["--bool", "-b"]); 18 | 19 | var command = new RootCommand(); 20 | command.AddOption(stringOption); 21 | command.AddOption(intOption); 22 | command.AddOption(boolOption); 23 | 24 | command.SetHandler(ExecuteHandler, stringOption, intOption, boolOption); 25 | 26 | return command.InvokeAsync(args); 27 | } 28 | } 29 | 30 | [Benchmark(Description = "System.CommandLine")] 31 | public async Task ExecuteWithSystemCommandLine() => 32 | await new SystemCommandLineCommand().ExecuteAsync(Arguments); 33 | } 34 | -------------------------------------------------------------------------------- /CliFx.Benchmarks/Benchmarks.cs: -------------------------------------------------------------------------------- 1 | using BenchmarkDotNet.Attributes; 2 | using BenchmarkDotNet.Configs; 3 | using BenchmarkDotNet.Order; 4 | using BenchmarkDotNet.Running; 5 | 6 | namespace CliFx.Benchmarks; 7 | 8 | [RankColumn] 9 | [Orderer(SummaryOrderPolicy.FastestToSlowest)] 10 | public partial class Benchmarks 11 | { 12 | private static readonly string[] Arguments = ["--str", "hello world", "-i", "13", "-b"]; 13 | 14 | public static void Main() => 15 | BenchmarkRunner.Run( 16 | DefaultConfig.Instance.WithOptions(ConfigOptions.DisableOptimizationsValidator) 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /CliFx.Benchmarks/CliFx.Benchmarks.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Exe 5 | net9.0 6 | false 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /CliFx.Benchmarks/Readme.md: -------------------------------------------------------------------------------- 1 | ## CliFx.Benchmarks 2 | 3 | All benchmarks below were ran with the following configuration: 4 | 5 | ```ini 6 | BenchmarkDotNet=v0.12.0, OS=Windows 10.0.14393.3443 (1607/AnniversaryUpdate/Redstone1) 7 | Intel Core i5-4460 CPU 3.20GHz (Haswell), 1 CPU, 4 logical and 4 physical cores 8 | Frequency=3124994 Hz, Resolution=320.0006 ns, Timer=TSC 9 | .NET Core SDK=3.1.100 10 | [Host] : .NET Core 3.1.0 (CoreCLR 4.700.19.56402, CoreFX 4.700.19.56404), X64 RyuJIT 11 | DefaultJob : .NET Core 3.1.0 (CoreCLR 4.700.19.56402, CoreFX 4.700.19.56404), X64 RyuJIT 12 | ``` 13 | 14 | | Method | Mean | Error | StdDev | Ratio | RatioSD | Rank | 15 | | ------------------------------------ | ----------: | --------: | ---------: | ----: | ------: | ---: | 16 | | CommandLineParser | 24.79 us | 0.166 us | 0.155 us | 0.49 | 0.00 | 1 | 17 | | CliFx | 50.27 us | 0.248 us | 0.232 us | 1.00 | 0.00 | 2 | 18 | | Clipr | 160.22 us | 0.817 us | 0.764 us | 3.19 | 0.02 | 3 | 19 | | McMaster.Extensions.CommandLineUtils | 166.45 us | 1.111 us | 1.039 us | 3.31 | 0.03 | 4 | 20 | | System.CommandLine | 170.27 us | 0.599 us | 0.560 us | 3.39 | 0.02 | 5 | 21 | | PowerArgs | 306.12 us | 1.495 us | 1.398 us | 6.09 | 0.03 | 6 | 22 | | Cocona | 1,856.07 us | 48.727 us | 141.367 us | 37.88 | 2.60 | 7 | 23 | -------------------------------------------------------------------------------- /CliFx.Demo/CliFx.Demo.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Exe 5 | net9.0 6 | ../favicon.ico 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /CliFx.Demo/Commands/BookAddCommand.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using CliFx.Attributes; 4 | using CliFx.Demo.Domain; 5 | using CliFx.Demo.Utils; 6 | using CliFx.Exceptions; 7 | using CliFx.Infrastructure; 8 | 9 | namespace CliFx.Demo.Commands; 10 | 11 | [Command("book add", Description = "Adds a book to the library.")] 12 | public class BookAddCommand(LibraryProvider libraryProvider) : ICommand 13 | { 14 | [CommandParameter(0, Name = "title", Description = "Book title.")] 15 | public required string Title { get; init; } 16 | 17 | [CommandOption("author", 'a', Description = "Book author.")] 18 | public required string Author { get; init; } 19 | 20 | [CommandOption("published", 'p', Description = "Book publish date.")] 21 | public DateTimeOffset Published { get; init; } = 22 | new( 23 | Random.Shared.Next(1800, 2020), 24 | Random.Shared.Next(1, 12), 25 | Random.Shared.Next(1, 28), 26 | Random.Shared.Next(1, 23), 27 | Random.Shared.Next(1, 59), 28 | Random.Shared.Next(1, 59), 29 | TimeSpan.Zero 30 | ); 31 | 32 | [CommandOption("isbn", 'n', Description = "Book ISBN.")] 33 | public Isbn Isbn { get; init; } = 34 | new( 35 | Random.Shared.Next(0, 999), 36 | Random.Shared.Next(0, 99), 37 | Random.Shared.Next(0, 99999), 38 | Random.Shared.Next(0, 99), 39 | Random.Shared.Next(0, 9) 40 | ); 41 | 42 | public ValueTask ExecuteAsync(IConsole console) 43 | { 44 | if (libraryProvider.TryGetBook(Title) is not null) 45 | throw new CommandException($"Book '{Title}' already exists.", 10); 46 | 47 | var book = new Book(Title, Author, Published, Isbn); 48 | libraryProvider.AddBook(book); 49 | 50 | console.WriteLine($"Book '{Title}' added."); 51 | console.WriteBook(book); 52 | 53 | return default; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /CliFx.Demo/Commands/BookCommand.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using CliFx.Attributes; 3 | using CliFx.Demo.Domain; 4 | using CliFx.Demo.Utils; 5 | using CliFx.Exceptions; 6 | using CliFx.Infrastructure; 7 | 8 | namespace CliFx.Demo.Commands; 9 | 10 | [Command("book", Description = "Retrieves a book from the library.")] 11 | public class BookCommand(LibraryProvider libraryProvider) : ICommand 12 | { 13 | [CommandParameter(0, Name = "title", Description = "Title of the book to retrieve.")] 14 | public required string Title { get; init; } 15 | 16 | public ValueTask ExecuteAsync(IConsole console) 17 | { 18 | var book = libraryProvider.TryGetBook(Title); 19 | 20 | if (book is null) 21 | throw new CommandException($"Book '{Title}' not found.", 10); 22 | 23 | console.WriteBook(book); 24 | 25 | return default; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /CliFx.Demo/Commands/BookListCommand.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using CliFx.Attributes; 3 | using CliFx.Demo.Domain; 4 | using CliFx.Demo.Utils; 5 | using CliFx.Infrastructure; 6 | 7 | namespace CliFx.Demo.Commands; 8 | 9 | [Command("book list", Description = "Lists all books in the library.")] 10 | public class BookListCommand(LibraryProvider libraryProvider) : ICommand 11 | { 12 | public ValueTask ExecuteAsync(IConsole console) 13 | { 14 | var library = libraryProvider.GetLibrary(); 15 | 16 | for (var i = 0; i < library.Books.Count; i++) 17 | { 18 | // Add margin 19 | if (i != 0) 20 | console.WriteLine(); 21 | 22 | // Render book 23 | var book = library.Books[i]; 24 | console.WriteBook(book); 25 | } 26 | 27 | return default; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /CliFx.Demo/Commands/BookRemoveCommand.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using CliFx.Attributes; 3 | using CliFx.Demo.Domain; 4 | using CliFx.Exceptions; 5 | using CliFx.Infrastructure; 6 | 7 | namespace CliFx.Demo.Commands; 8 | 9 | [Command("book remove", Description = "Removes a book from the library.")] 10 | public class BookRemoveCommand(LibraryProvider libraryProvider) : ICommand 11 | { 12 | [CommandParameter(0, Name = "title", Description = "Title of the book to remove.")] 13 | public required string Title { get; init; } 14 | 15 | public ValueTask ExecuteAsync(IConsole console) 16 | { 17 | var book = libraryProvider.TryGetBook(Title); 18 | 19 | if (book is null) 20 | throw new CommandException($"Book '{Title}' not found.", 10); 21 | 22 | libraryProvider.RemoveBook(book); 23 | 24 | console.WriteLine($"Book '{Title}' removed."); 25 | 26 | return default; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /CliFx.Demo/Domain/Book.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace CliFx.Demo.Domain; 4 | 5 | public record Book(string Title, string Author, DateTimeOffset Published, Isbn Isbn); 6 | -------------------------------------------------------------------------------- /CliFx.Demo/Domain/Isbn.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace CliFx.Demo.Domain; 4 | 5 | public partial record Isbn( 6 | int EanPrefix, 7 | int RegistrationGroup, 8 | int Registrant, 9 | int Publication, 10 | int CheckDigit 11 | ) 12 | { 13 | public override string ToString() => 14 | $"{EanPrefix:000}-{RegistrationGroup:00}-{Registrant:00000}-{Publication:00}-{CheckDigit:0}"; 15 | } 16 | 17 | public partial record Isbn 18 | { 19 | public static Isbn Parse(string value, IFormatProvider formatProvider) 20 | { 21 | var components = value.Split('-', 5, StringSplitOptions.RemoveEmptyEntries); 22 | 23 | return new Isbn( 24 | int.Parse(components[0], formatProvider), 25 | int.Parse(components[1], formatProvider), 26 | int.Parse(components[2], formatProvider), 27 | int.Parse(components[3], formatProvider), 28 | int.Parse(components[4], formatProvider) 29 | ); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /CliFx.Demo/Domain/Library.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | 5 | namespace CliFx.Demo.Domain; 6 | 7 | public partial record Library(IReadOnlyList Books) 8 | { 9 | public Library WithBook(Book book) 10 | { 11 | var books = Books.ToList(); 12 | books.Add(book); 13 | 14 | return new Library(books); 15 | } 16 | 17 | public Library WithoutBook(Book book) 18 | { 19 | var books = Books.Where(b => b != book).ToArray(); 20 | 21 | return new Library(books); 22 | } 23 | } 24 | 25 | public partial record Library 26 | { 27 | public static Library Empty { get; } = new(Array.Empty()); 28 | } 29 | -------------------------------------------------------------------------------- /CliFx.Demo/Domain/LibraryProvider.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using System.Linq; 3 | using System.Text.Json; 4 | 5 | namespace CliFx.Demo.Domain; 6 | 7 | public class LibraryProvider 8 | { 9 | private static string StorageFilePath { get; } = 10 | Path.Combine(Directory.GetCurrentDirectory(), "Library.json"); 11 | 12 | private void StoreLibrary(Library library) 13 | { 14 | var data = JsonSerializer.Serialize(library); 15 | File.WriteAllText(StorageFilePath, data); 16 | } 17 | 18 | public Library GetLibrary() 19 | { 20 | if (!File.Exists(StorageFilePath)) 21 | return Library.Empty; 22 | 23 | var data = File.ReadAllText(StorageFilePath); 24 | 25 | return JsonSerializer.Deserialize(data) ?? Library.Empty; 26 | } 27 | 28 | public Book? TryGetBook(string title) => 29 | GetLibrary().Books.FirstOrDefault(b => b.Title == title); 30 | 31 | public void AddBook(Book book) 32 | { 33 | var updatedLibrary = GetLibrary().WithBook(book); 34 | StoreLibrary(updatedLibrary); 35 | } 36 | 37 | public void RemoveBook(Book book) 38 | { 39 | var updatedLibrary = GetLibrary().WithoutBook(book); 40 | StoreLibrary(updatedLibrary); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /CliFx.Demo/Program.cs: -------------------------------------------------------------------------------- 1 | using CliFx; 2 | using CliFx.Demo.Domain; 3 | using Microsoft.Extensions.DependencyInjection; 4 | 5 | return await new CliApplicationBuilder() 6 | .SetDescription("Demo application showcasing CliFx features.") 7 | .AddCommandsFromThisAssembly() 8 | .UseTypeActivator(commandTypes => 9 | { 10 | // We use Microsoft.Extensions.DependencyInjection for injecting dependencies in commands 11 | var services = new ServiceCollection(); 12 | services.AddSingleton(); 13 | 14 | // Register all commands as transient services 15 | foreach (var commandType in commandTypes) 16 | services.AddTransient(commandType); 17 | 18 | return services.BuildServiceProvider(); 19 | }) 20 | .Build() 21 | .RunAsync(); 22 | -------------------------------------------------------------------------------- /CliFx.Demo/Readme.md: -------------------------------------------------------------------------------- 1 | # CliFx Demo Project 2 | 3 | Sample command-line interface for managing a library of books. 4 | 5 | This demo project showcases basic CliFx functionality such as command routing, argument parsing, and autogenerated help text. 6 | -------------------------------------------------------------------------------- /CliFx.Demo/Utils/ConsoleExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using CliFx.Demo.Domain; 3 | using CliFx.Infrastructure; 4 | 5 | namespace CliFx.Demo.Utils; 6 | 7 | internal static class ConsoleExtensions 8 | { 9 | public static void WriteBook(this ConsoleWriter writer, Book book) 10 | { 11 | // Title 12 | using (writer.Console.WithForegroundColor(ConsoleColor.White)) 13 | writer.WriteLine(book.Title); 14 | 15 | // Author 16 | writer.Write(" "); 17 | writer.Write("Author: "); 18 | 19 | using (writer.Console.WithForegroundColor(ConsoleColor.White)) 20 | writer.WriteLine(book.Author); 21 | 22 | // Published 23 | writer.Write(" "); 24 | writer.Write("Published: "); 25 | 26 | using (writer.Console.WithForegroundColor(ConsoleColor.White)) 27 | writer.WriteLine($"{book.Published:d}"); 28 | 29 | // ISBN 30 | writer.Write(" "); 31 | writer.Write("ISBN: "); 32 | 33 | using (writer.Console.WithForegroundColor(ConsoleColor.White)) 34 | writer.WriteLine(book.Isbn); 35 | } 36 | 37 | public static void WriteBook(this IConsole console, Book book) => 38 | console.Output.WriteBook(book); 39 | } 40 | -------------------------------------------------------------------------------- /CliFx.Tests.Dummy/CliFx.Tests.Dummy.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Exe 5 | net9.0 6 | ../favicon.ico 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /CliFx.Tests.Dummy/Commands/CancellationTestCommand.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using CliFx.Attributes; 4 | using CliFx.Infrastructure; 5 | 6 | namespace CliFx.Tests.Dummy.Commands; 7 | 8 | [Command("cancel-test")] 9 | public class CancellationTestCommand : ICommand 10 | { 11 | public async ValueTask ExecuteAsync(IConsole console) 12 | { 13 | try 14 | { 15 | console.WriteLine("Started."); 16 | 17 | await Task.Delay(TimeSpan.FromSeconds(3), console.RegisterCancellationHandler()); 18 | 19 | console.WriteLine("Completed."); 20 | } 21 | catch (OperationCanceledException) 22 | { 23 | console.WriteLine("Cancelled."); 24 | throw; 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /CliFx.Tests.Dummy/Commands/ConsoleTestCommand.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using CliFx.Attributes; 4 | using CliFx.Infrastructure; 5 | 6 | namespace CliFx.Tests.Dummy.Commands; 7 | 8 | [Command("console-test")] 9 | public class ConsoleTestCommand : ICommand 10 | { 11 | public ValueTask ExecuteAsync(IConsole console) 12 | { 13 | var input = console.Input.ReadToEnd(); 14 | 15 | using (console.WithColors(ConsoleColor.Black, ConsoleColor.White)) 16 | { 17 | console.Output.WriteLine(input); 18 | console.Error.WriteLine(input); 19 | } 20 | 21 | return default; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /CliFx.Tests.Dummy/Commands/EnvironmentTestCommand.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using CliFx.Attributes; 3 | using CliFx.Infrastructure; 4 | 5 | namespace CliFx.Tests.Dummy.Commands; 6 | 7 | [Command("env-test")] 8 | public class EnvironmentTestCommand : ICommand 9 | { 10 | [CommandOption("target", EnvironmentVariable = "ENV_TARGET")] 11 | public string GreetingTarget { get; init; } = "World"; 12 | 13 | public ValueTask ExecuteAsync(IConsole console) 14 | { 15 | console.WriteLine($"Hello {GreetingTarget}!"); 16 | return default; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /CliFx.Tests.Dummy/Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Reflection; 4 | using System.Runtime.InteropServices; 5 | using System.Threading.Tasks; 6 | 7 | namespace CliFx.Tests.Dummy; 8 | 9 | // This dummy application is used in tests for scenarios that require an external process to properly verify 10 | public static class Program 11 | { 12 | // Path to the apphost 13 | public static string FilePath { get; } = 14 | Path.ChangeExtension( 15 | Assembly.GetExecutingAssembly().Location, 16 | RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "exe" : null 17 | ); 18 | 19 | public static async Task Main() 20 | { 21 | // Make sure color codes are not produced because we rely on the output in tests 22 | Environment.SetEnvironmentVariable( 23 | "DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION", 24 | "false" 25 | ); 26 | 27 | await new CliApplicationBuilder().AddCommandsFromThisAssembly().Build().RunAsync(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /CliFx.Tests/ApplicationSpecs.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | using CliFx.Tests.Utils; 5 | using FluentAssertions; 6 | using Xunit; 7 | using Xunit.Abstractions; 8 | 9 | namespace CliFx.Tests; 10 | 11 | public class ApplicationSpecs(ITestOutputHelper testOutput) : SpecsBase(testOutput) 12 | { 13 | [Fact] 14 | public async Task I_can_create_an_application_with_the_default_configuration() 15 | { 16 | // Act 17 | var app = new CliApplicationBuilder() 18 | .AddCommandsFromThisAssembly() 19 | .UseConsole(FakeConsole) 20 | .Build(); 21 | 22 | var exitCode = await app.RunAsync(Array.Empty(), new Dictionary()); 23 | 24 | // Assert 25 | exitCode.Should().Be(0); 26 | } 27 | 28 | [Fact] 29 | public async Task I_can_create_an_application_with_a_custom_configuration() 30 | { 31 | // Act 32 | var app = new CliApplicationBuilder() 33 | .AddCommand() 34 | .AddCommandsFrom(typeof(NoOpCommand).Assembly) 35 | .AddCommands([typeof(NoOpCommand)]) 36 | .AddCommandsFrom([typeof(NoOpCommand).Assembly]) 37 | .AddCommandsFromThisAssembly() 38 | .AllowDebugMode() 39 | .AllowPreviewMode() 40 | .SetTitle("test") 41 | .SetExecutableName("test") 42 | .SetVersion("test") 43 | .SetDescription("test") 44 | .UseConsole(FakeConsole) 45 | .UseTypeActivator(Activator.CreateInstance!) 46 | .Build(); 47 | 48 | var exitCode = await app.RunAsync(Array.Empty(), new Dictionary()); 49 | 50 | // Assert 51 | exitCode.Should().Be(0); 52 | } 53 | 54 | [Fact] 55 | public async Task I_can_try_to_create_an_application_and_get_an_error_if_it_has_invalid_commands() 56 | { 57 | // Act 58 | var app = new CliApplicationBuilder() 59 | .AddCommand(typeof(ApplicationSpecs)) 60 | .UseConsole(FakeConsole) 61 | .Build(); 62 | 63 | var exitCode = await app.RunAsync(Array.Empty(), new Dictionary()); 64 | 65 | // Assert 66 | exitCode.Should().NotBe(0); 67 | 68 | var stdErr = FakeConsole.ReadErrorString(); 69 | stdErr.Should().Contain("not a valid command"); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /CliFx.Tests/CancellationSpecs.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | using CliFx.Tests.Utils; 7 | using CliFx.Tests.Utils.Extensions; 8 | using CliWrap; 9 | using FluentAssertions; 10 | using Xunit; 11 | using Xunit.Abstractions; 12 | 13 | namespace CliFx.Tests; 14 | 15 | public class CancellationSpecs(ITestOutputHelper testOutput) : SpecsBase(testOutput) 16 | { 17 | [Fact(Timeout = 15000)] 18 | public async Task I_can_configure_the_command_to_listen_to_the_interrupt_signal() 19 | { 20 | // Arrange 21 | using var cts = new CancellationTokenSource(); 22 | 23 | // We need to send the cancellation request right after the process has registered 24 | // a handler for the interrupt signal, otherwise the default handler will trigger 25 | // and just kill the process. 26 | void HandleStdOut(string line) 27 | { 28 | if (string.Equals(line, "Started.", StringComparison.OrdinalIgnoreCase)) 29 | cts.CancelAfter(TimeSpan.FromSeconds(0.2)); 30 | } 31 | 32 | var stdOutBuffer = new StringBuilder(); 33 | 34 | var pipeTarget = PipeTarget.Merge( 35 | PipeTarget.ToDelegate(HandleStdOut), 36 | PipeTarget.ToStringBuilder(stdOutBuffer) 37 | ); 38 | 39 | var command = Cli.Wrap(Dummy.Program.FilePath).WithArguments("cancel-test") | pipeTarget; 40 | 41 | // Act & assert 42 | await Assert.ThrowsAnyAsync( 43 | async () => 44 | await command.ExecuteAsync( 45 | // Forceful cancellation (not required because we have a timeout) 46 | CancellationToken.None, 47 | // Graceful cancellation 48 | cts.Token 49 | ) 50 | ); 51 | 52 | stdOutBuffer.ToString().Trim().Should().ConsistOfLines("Started.", "Cancelled."); 53 | } 54 | 55 | [Fact] 56 | public async Task I_can_configure_the_command_to_listen_to_the_interrupt_signal_when_running_in_isolation() 57 | { 58 | // Arrange 59 | var commandType = DynamicCommandBuilder.Compile( 60 | // lang=csharp 61 | """ 62 | [Command] 63 | public class Command : ICommand 64 | { 65 | public async ValueTask ExecuteAsync(IConsole console) 66 | { 67 | try 68 | { 69 | console.WriteLine("Started."); 70 | 71 | await Task.Delay( 72 | TimeSpan.FromSeconds(3), 73 | console.RegisterCancellationHandler() 74 | ); 75 | 76 | console.WriteLine("Completed."); 77 | } 78 | catch (OperationCanceledException) 79 | { 80 | console.WriteLine("Cancelled."); 81 | throw; 82 | } 83 | } 84 | } 85 | """ 86 | ); 87 | 88 | var application = new CliApplicationBuilder() 89 | .AddCommand(commandType) 90 | .UseConsole(FakeConsole) 91 | .Build(); 92 | 93 | FakeConsole.RequestCancellation(TimeSpan.FromSeconds(0.2)); 94 | 95 | // Act 96 | var exitCode = await application.RunAsync( 97 | Array.Empty(), 98 | new Dictionary() 99 | ); 100 | 101 | // Assert 102 | exitCode.Should().NotBe(0); 103 | 104 | var stdOut = FakeConsole.ReadOutputString(); 105 | stdOut.Trim().Should().ConsistOfLines("Started.", "Cancelled."); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /CliFx.Tests/CliFx.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net9.0 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /CliFx.Tests/DirectivesSpecs.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using CliFx.Tests.Utils; 6 | using CliFx.Tests.Utils.Extensions; 7 | using CliWrap; 8 | using FluentAssertions; 9 | using Xunit; 10 | using Xunit.Abstractions; 11 | 12 | namespace CliFx.Tests; 13 | 14 | public class DirectivesSpecs(ITestOutputHelper testOutput) : SpecsBase(testOutput) 15 | { 16 | [Fact(Timeout = 15000)] 17 | public async Task I_can_use_the_debug_directive_to_make_the_application_wait_for_the_debugger_to_attach() 18 | { 19 | // Arrange 20 | using var cts = new CancellationTokenSource(); 21 | 22 | // We can't actually attach a debugger, but we can ensure that the process is waiting for one 23 | void HandleStdOut(string line) 24 | { 25 | // Kill the process once it writes the output we expect 26 | if (line.Contains("Attach the debugger to", StringComparison.OrdinalIgnoreCase)) 27 | cts.Cancel(); 28 | } 29 | 30 | var command = Cli.Wrap(Dummy.Program.FilePath).WithArguments("[debug]") | HandleStdOut; 31 | 32 | // Act & assert 33 | try 34 | { 35 | await command.ExecuteAsync(cts.Token); 36 | } 37 | catch (OperationCanceledException ex) when (ex.CancellationToken == cts.Token) 38 | { 39 | // This means that the process was killed after it wrote the expected output 40 | } 41 | } 42 | 43 | [Fact] 44 | public async Task I_can_use_the_preview_directive_to_make_the_application_print_the_parsed_command_input() 45 | { 46 | // Arrange 47 | var commandType = DynamicCommandBuilder.Compile( 48 | // lang=csharp 49 | """ 50 | [Command("cmd")] 51 | public class Command : ICommand 52 | { 53 | public ValueTask ExecuteAsync(IConsole console) => default; 54 | } 55 | """ 56 | ); 57 | 58 | var application = new CliApplicationBuilder() 59 | .AddCommand(commandType) 60 | .UseConsole(FakeConsole) 61 | .AllowPreviewMode() 62 | .Build(); 63 | 64 | // Act 65 | var exitCode = await application.RunAsync( 66 | ["[preview]", "cmd", "param", "-abc", "--option", "foo"], 67 | new Dictionary { ["ENV_QOP"] = "hello", ["ENV_KIL"] = "world" } 68 | ); 69 | 70 | // Assert 71 | exitCode.Should().Be(0); 72 | 73 | var stdOut = FakeConsole.ReadOutputString(); 74 | stdOut 75 | .Should() 76 | .ContainAllInOrder( 77 | "cmd", 78 | "", 79 | "[-a]", 80 | "[-b]", 81 | "[-c]", 82 | "[--option \"foo\"]", 83 | "ENV_QOP", 84 | "=", 85 | "\"hello\"", 86 | "ENV_KIL", 87 | "=", 88 | "\"world\"" 89 | ); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /CliFx.Tests/SpecsBase.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using CliFx.Infrastructure; 3 | using CliFx.Tests.Utils.Extensions; 4 | using Xunit.Abstractions; 5 | 6 | namespace CliFx.Tests; 7 | 8 | public abstract class SpecsBase(ITestOutputHelper testOutput) : IDisposable 9 | { 10 | public ITestOutputHelper TestOutput { get; } = testOutput; 11 | 12 | public FakeInMemoryConsole FakeConsole { get; } = new(); 13 | 14 | public void Dispose() 15 | { 16 | FakeConsole.DumpToTestOutput(TestOutput); 17 | FakeConsole.Dispose(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /CliFx.Tests/Utils/Extensions/AssertionExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using FluentAssertions; 4 | using FluentAssertions.Primitives; 5 | 6 | namespace CliFx.Tests.Utils.Extensions; 7 | 8 | internal static class AssertionExtensions 9 | { 10 | public static void ConsistOfLines( 11 | this StringAssertions assertions, 12 | params IEnumerable lines 13 | ) => 14 | assertions 15 | .Subject.Split(['\n', '\r'], StringSplitOptions.RemoveEmptyEntries) 16 | .Should() 17 | .Equal(lines); 18 | 19 | public static AndConstraint ContainAllInOrder( 20 | this StringAssertions assertions, 21 | IEnumerable values 22 | ) 23 | { 24 | var lastIndex = 0; 25 | 26 | foreach (var value in values) 27 | { 28 | var index = assertions.Subject.IndexOf(value, lastIndex, StringComparison.Ordinal); 29 | 30 | if (index < 0) 31 | { 32 | assertions.CurrentAssertionChain.FailWith( 33 | $"Expected string '{assertions.Subject}' to contain '{value}' after position {lastIndex}." 34 | ); 35 | } 36 | 37 | lastIndex = index; 38 | } 39 | 40 | return new AndConstraint(assertions); 41 | } 42 | 43 | public static AndConstraint ContainAllInOrder( 44 | this StringAssertions assertions, 45 | params string[] values 46 | ) => assertions.ContainAllInOrder((IEnumerable)values); 47 | } 48 | -------------------------------------------------------------------------------- /CliFx.Tests/Utils/Extensions/ConsoleExtensions.cs: -------------------------------------------------------------------------------- 1 | using CliFx.Infrastructure; 2 | using Xunit.Abstractions; 3 | 4 | namespace CliFx.Tests.Utils.Extensions; 5 | 6 | internal static class ConsoleExtensions 7 | { 8 | public static void DumpToTestOutput( 9 | this FakeInMemoryConsole console, 10 | ITestOutputHelper testOutput 11 | ) 12 | { 13 | testOutput.WriteLine("[*] Captured standard output:"); 14 | testOutput.WriteLine(console.ReadOutputString()); 15 | 16 | testOutput.WriteLine("[*] Captured standard error:"); 17 | testOutput.WriteLine(console.ReadErrorString()); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /CliFx.Tests/Utils/NoOpCommand.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using CliFx.Attributes; 3 | using CliFx.Infrastructure; 4 | 5 | namespace CliFx.Tests.Utils; 6 | 7 | [Command] 8 | internal class NoOpCommand : ICommand 9 | { 10 | public ValueTask ExecuteAsync(IConsole console) => default; 11 | } 12 | -------------------------------------------------------------------------------- /CliFx.Tests/xunit.runner.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", 3 | "methodDisplayOptions": "all", 4 | "methodDisplay": "method" 5 | } -------------------------------------------------------------------------------- /CliFx/ApplicationConfiguration.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace CliFx; 5 | 6 | /// 7 | /// Configuration of an application. 8 | /// 9 | public class ApplicationConfiguration( 10 | IReadOnlyList commandTypes, 11 | bool isDebugModeAllowed, 12 | bool isPreviewModeAllowed 13 | ) 14 | { 15 | /// 16 | /// Command types defined in the application. 17 | /// 18 | public IReadOnlyList CommandTypes { get; } = commandTypes; 19 | 20 | /// 21 | /// Whether debug mode is allowed in the application. 22 | /// 23 | public bool IsDebugModeAllowed { get; } = isDebugModeAllowed; 24 | 25 | /// 26 | /// Whether preview mode is allowed in the application. 27 | /// 28 | public bool IsPreviewModeAllowed { get; } = isPreviewModeAllowed; 29 | } 30 | -------------------------------------------------------------------------------- /CliFx/ApplicationMetadata.cs: -------------------------------------------------------------------------------- 1 | namespace CliFx; 2 | 3 | /// 4 | /// Metadata associated with an application. 5 | /// 6 | public class ApplicationMetadata( 7 | string title, 8 | string executableName, 9 | string version, 10 | string? description 11 | ) 12 | { 13 | /// 14 | /// Application title. 15 | /// 16 | public string Title { get; } = title; 17 | 18 | /// 19 | /// Application executable name. 20 | /// 21 | public string ExecutableName { get; } = executableName; 22 | 23 | /// 24 | /// Application version. 25 | /// 26 | public string Version { get; } = version; 27 | 28 | /// 29 | /// Application description. 30 | /// 31 | public string? Description { get; } = description; 32 | } 33 | -------------------------------------------------------------------------------- /CliFx/Attributes/CommandAttribute.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace CliFx.Attributes; 4 | 5 | /// 6 | /// Annotates a type that defines a command. 7 | /// 8 | [AttributeUsage(AttributeTargets.Class, Inherited = false)] 9 | public sealed class CommandAttribute : Attribute 10 | { 11 | /// 12 | /// Initializes an instance of . 13 | /// 14 | public CommandAttribute(string name) => Name = name; 15 | 16 | /// 17 | /// Initializes an instance of . 18 | /// 19 | public CommandAttribute() { } 20 | 21 | /// 22 | /// Command name. 23 | /// 24 | /// 25 | /// Command can have no name, in which case it's treated as the application's default command. 26 | /// Only one default command is allowed in an application. 27 | /// All commands registered in an application must have unique names (comparison IS NOT case-sensitive). 28 | /// 29 | public string? Name { get; } 30 | 31 | /// 32 | /// Command description. 33 | /// This is shown to the user in the help text. 34 | /// 35 | public string? Description { get; set; } 36 | } 37 | -------------------------------------------------------------------------------- /CliFx/Attributes/CommandOptionAttribute.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using CliFx.Extensibility; 3 | 4 | namespace CliFx.Attributes; 5 | 6 | /// 7 | /// Annotates a property that defines a command option. 8 | /// 9 | [AttributeUsage(AttributeTargets.Property)] 10 | public sealed class CommandOptionAttribute : Attribute 11 | { 12 | /// 13 | /// Initializes an instance of . 14 | /// 15 | private CommandOptionAttribute(string? name, char? shortName) 16 | { 17 | Name = name; 18 | ShortName = shortName; 19 | } 20 | 21 | /// 22 | /// Initializes an instance of . 23 | /// 24 | public CommandOptionAttribute(string name, char shortName) 25 | : this(name, (char?)shortName) { } 26 | 27 | /// 28 | /// Initializes an instance of . 29 | /// 30 | public CommandOptionAttribute(string name) 31 | : this(name, null) { } 32 | 33 | /// 34 | /// Initializes an instance of . 35 | /// 36 | public CommandOptionAttribute(char shortName) 37 | : this(null, (char?)shortName) { } 38 | 39 | /// 40 | /// Option name. 41 | /// 42 | /// 43 | /// Must contain at least two characters and start with a letter. 44 | /// Either or must be set. 45 | /// All options in a command must have unique names (comparison IS NOT case-sensitive). 46 | /// 47 | public string? Name { get; } 48 | 49 | /// 50 | /// Option short name. 51 | /// 52 | /// 53 | /// Either or must be set. 54 | /// All options in a command must have unique short names (comparison IS case-sensitive). 55 | /// 56 | public char? ShortName { get; } 57 | 58 | /// 59 | /// Whether this option is required (default: false). 60 | /// If an option is required, the user will get an error if they don't set it. 61 | /// 62 | /// 63 | /// You can use the required keyword on the property (introduced in C# 11) to implicitly 64 | /// set to true. 65 | /// 66 | public bool IsRequired { get; set; } 67 | 68 | /// 69 | /// Environment variable whose value will be used as a fallback if the option 70 | /// has not been explicitly set through command-line arguments. 71 | /// 72 | public string? EnvironmentVariable { get; set; } 73 | 74 | /// 75 | /// Option description. 76 | /// This is shown to the user in the help text. 77 | /// 78 | public string? Description { get; set; } 79 | 80 | /// 81 | /// Custom converter used for mapping the raw command-line argument into 82 | /// a value expected by the underlying property. 83 | /// 84 | /// 85 | /// Converter must derive from . 86 | /// 87 | public Type? Converter { get; set; } 88 | 89 | /// 90 | /// Custom validators used for verifying the value of the underlying 91 | /// property, after it has been bound. 92 | /// 93 | /// 94 | /// Validators must derive from . 95 | /// 96 | public Type[] Validators { get; set; } = Array.Empty(); 97 | } 98 | -------------------------------------------------------------------------------- /CliFx/Attributes/CommandParameterAttribute.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using CliFx.Extensibility; 3 | 4 | namespace CliFx.Attributes; 5 | 6 | /// 7 | /// Annotates a property that defines a command parameter. 8 | /// 9 | [AttributeUsage(AttributeTargets.Property)] 10 | public sealed class CommandParameterAttribute(int order) : Attribute 11 | { 12 | /// 13 | /// Parameter order. 14 | /// Higher order means the parameter appears later, lower order means it appears earlier. 15 | /// 16 | /// 17 | /// All parameters in a command must have unique order. 18 | /// Parameter whose type is a non-scalar (e.g. array), must always be the last in order. 19 | /// Only one non-scalar parameter is allowed in a command. 20 | /// 21 | public int Order { get; } = order; 22 | 23 | /// 24 | /// Whether this parameter is required (default: true). 25 | /// If a parameter is required, the user will get an error if they don't set it. 26 | /// 27 | /// 28 | /// Parameter marked as non-required must always be the last in order. 29 | /// Only one non-required parameter is allowed in a command. 30 | /// 31 | public bool IsRequired { get; set; } = true; 32 | 33 | /// 34 | /// Parameter name. 35 | /// This is shown to the user in the help text. 36 | /// 37 | /// 38 | /// If this isn't specified, parameter name is inferred from the property name. 39 | /// 40 | public string? Name { get; set; } 41 | 42 | /// 43 | /// Parameter description. 44 | /// This is shown to the user in the help text. 45 | /// 46 | public string? Description { get; set; } 47 | 48 | /// 49 | /// Custom converter used for mapping the raw command-line argument into 50 | /// a value expected by the underlying property. 51 | /// 52 | /// 53 | /// Converter must derive from . 54 | /// 55 | public Type? Converter { get; set; } 56 | 57 | /// 58 | /// Custom validators used for verifying the value of the underlying 59 | /// property, after it has been bound. 60 | /// 61 | /// 62 | /// Validators must derive from . 63 | /// 64 | public Type[] Validators { get; set; } = Array.Empty(); 65 | } 66 | -------------------------------------------------------------------------------- /CliFx/CliFx.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | netstandard2.0;netstandard2.1;net8.0 5 | true 6 | 7 | 8 | 9 | $(Company) 10 | Class-first framework for building command-line interfaces 11 | command line executable interface framework parser arguments cli app application net core 12 | https://github.com/Tyrrrz/CliFx 13 | https://github.com/Tyrrrz/CliFx/releases 14 | favicon.png 15 | MIT 16 | true 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /CliFx/Exceptions/CliFxException.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace CliFx.Exceptions; 4 | 5 | /// 6 | /// Exception thrown when there is an error during application execution. 7 | /// 8 | public partial class CliFxException( 9 | string message, 10 | int exitCode = CliFxException.DefaultExitCode, 11 | bool showHelp = false, 12 | Exception? innerException = null 13 | ) : Exception(message, innerException) 14 | { 15 | internal const int DefaultExitCode = 1; 16 | 17 | // When an exception is created without a message, the base Exception class 18 | // provides a default message that is not very useful. 19 | // This property is used to identify whether this instance was created with 20 | // a custom message, so that we can avoid printing the default message. 21 | internal bool HasCustomMessage { get; } = !string.IsNullOrWhiteSpace(message); 22 | 23 | /// 24 | /// Returned exit code. 25 | /// 26 | public int ExitCode { get; } = exitCode; 27 | 28 | /// 29 | /// Whether to show the help text before exiting. 30 | /// 31 | public bool ShowHelp { get; } = showHelp; 32 | } 33 | 34 | public partial class CliFxException 35 | { 36 | // Internal errors don't show help because they're meant for the developer and 37 | // not the end-user of the application. 38 | internal static CliFxException InternalError( 39 | string message, 40 | Exception? innerException = null 41 | ) => new(message, DefaultExitCode, false, innerException); 42 | 43 | // User errors are typically caused by invalid input and they're meant for the end-user, 44 | // so we want to show help. 45 | internal static CliFxException UserError(string message, Exception? innerException = null) => 46 | new(message, DefaultExitCode, true, innerException); 47 | } 48 | -------------------------------------------------------------------------------- /CliFx/Exceptions/CommandException.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace CliFx.Exceptions; 4 | 5 | /// 6 | /// Exception thrown when a command cannot proceed with its normal execution due to an error. 7 | /// Use this exception to report an error to the console and return a specific exit code. 8 | /// 9 | public class CommandException( 10 | string message, 11 | int exitCode = CliFxException.DefaultExitCode, 12 | bool showHelp = false, 13 | Exception? innerException = null 14 | ) : CliFxException(message, exitCode, showHelp, innerException); 15 | -------------------------------------------------------------------------------- /CliFx/Extensibility/BindingConverter.cs: -------------------------------------------------------------------------------- 1 | namespace CliFx.Extensibility; 2 | 3 | // Used internally to simplify the usage from reflection 4 | internal interface IBindingConverter 5 | { 6 | object? Convert(string? rawValue); 7 | } 8 | 9 | /// 10 | /// Base type for custom converters. 11 | /// 12 | public abstract class BindingConverter : IBindingConverter 13 | { 14 | /// 15 | /// Parses value from a raw command-line argument. 16 | /// 17 | public abstract T Convert(string? rawValue); 18 | 19 | object? IBindingConverter.Convert(string? rawValue) => Convert(rawValue); 20 | } 21 | -------------------------------------------------------------------------------- /CliFx/Extensibility/BindingValidationError.cs: -------------------------------------------------------------------------------- 1 | namespace CliFx.Extensibility; 2 | 3 | /// 4 | /// Represents a validation error. 5 | /// 6 | public class BindingValidationError(string message) 7 | { 8 | /// 9 | /// Error message shown to the user. 10 | /// 11 | public string Message { get; } = message; 12 | } 13 | -------------------------------------------------------------------------------- /CliFx/Extensibility/BindingValidator.cs: -------------------------------------------------------------------------------- 1 | namespace CliFx.Extensibility; 2 | 3 | // Used internally to simplify the usage from reflection 4 | internal interface IBindingValidator 5 | { 6 | BindingValidationError? Validate(object? value); 7 | } 8 | 9 | /// 10 | /// Base type for custom validators. 11 | /// 12 | public abstract class BindingValidator : IBindingValidator 13 | { 14 | /// 15 | /// Returns a successful validation result. 16 | /// 17 | protected BindingValidationError? Ok() => null; 18 | 19 | /// 20 | /// Returns a non-successful validation result. 21 | /// 22 | protected BindingValidationError Error(string message) => new(message); 23 | 24 | /// 25 | /// Validates the value bound to a parameter or an option. 26 | /// Returns null if validation is successful, or an error in case of failure. 27 | /// 28 | /// 29 | /// You can use the utility methods and to 30 | /// create an appropriate result. 31 | /// 32 | public abstract BindingValidationError? Validate(T? value); 33 | 34 | BindingValidationError? IBindingValidator.Validate(object? value) => Validate((T?)value); 35 | } 36 | -------------------------------------------------------------------------------- /CliFx/FallbackDefaultCommand.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | using System.Threading.Tasks; 3 | using CliFx.Attributes; 4 | using CliFx.Infrastructure; 5 | using CliFx.Schema; 6 | 7 | namespace CliFx; 8 | 9 | // Fallback command used when the application doesn't have one configured. 10 | // This command is only used as a stub for help text. 11 | [Command] 12 | internal class FallbackDefaultCommand : ICommand 13 | { 14 | public static CommandSchema Schema { get; } = 15 | CommandSchema.Resolve(typeof(FallbackDefaultCommand)); 16 | 17 | // Never actually executed 18 | [ExcludeFromCodeCoverage] 19 | public ValueTask ExecuteAsync(IConsole console) => default; 20 | } 21 | -------------------------------------------------------------------------------- /CliFx/Formatting/CommandInputConsoleFormatter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using CliFx.Infrastructure; 3 | using CliFx.Input; 4 | 5 | namespace CliFx.Formatting; 6 | 7 | internal class CommandInputConsoleFormatter(ConsoleWriter consoleWriter) 8 | : ConsoleFormatter(consoleWriter) 9 | { 10 | private void WriteCommandLineArguments(CommandInput commandInput) 11 | { 12 | Write("Command-line:"); 13 | WriteLine(); 14 | 15 | WriteHorizontalMargin(); 16 | 17 | // Command name 18 | if (!string.IsNullOrWhiteSpace(commandInput.CommandName)) 19 | { 20 | Write(ConsoleColor.Cyan, commandInput.CommandName); 21 | Write(' '); 22 | } 23 | 24 | // Parameters 25 | foreach (var parameterInput in commandInput.Parameters) 26 | { 27 | Write('<'); 28 | Write(ConsoleColor.White, parameterInput.Value); 29 | Write('>'); 30 | Write(' '); 31 | } 32 | 33 | // Options 34 | foreach (var optionInput in commandInput.Options) 35 | { 36 | Write('['); 37 | 38 | // Identifier 39 | Write(ConsoleColor.White, optionInput.GetFormattedIdentifier()); 40 | 41 | // Value(s) 42 | foreach (var value in optionInput.Values) 43 | { 44 | Write(' '); 45 | Write('"'); 46 | Write(value); 47 | Write('"'); 48 | } 49 | 50 | Write(']'); 51 | Write(' '); 52 | } 53 | 54 | WriteLine(); 55 | } 56 | 57 | private void WriteEnvironmentVariables(CommandInput commandInput) 58 | { 59 | Write("Environment:"); 60 | WriteLine(); 61 | 62 | // Environment variables 63 | foreach (var environmentVariableInput in commandInput.EnvironmentVariables) 64 | { 65 | WriteHorizontalMargin(); 66 | 67 | // Name 68 | Write(ConsoleColor.White, environmentVariableInput.Name); 69 | 70 | Write('='); 71 | 72 | // Value 73 | Write('"'); 74 | Write(environmentVariableInput.Value); 75 | Write('"'); 76 | 77 | WriteLine(); 78 | } 79 | } 80 | 81 | public void WriteCommandInput(CommandInput commandInput) 82 | { 83 | WriteCommandLineArguments(commandInput); 84 | WriteLine(); 85 | WriteEnvironmentVariables(commandInput); 86 | } 87 | } 88 | 89 | internal static class CommandInputConsoleFormatterExtensions 90 | { 91 | public static void WriteCommandInput( 92 | this ConsoleWriter consoleWriter, 93 | CommandInput commandInput 94 | ) => new CommandInputConsoleFormatter(consoleWriter).WriteCommandInput(commandInput); 95 | 96 | public static void WriteCommandInput(this IConsole console, CommandInput commandInput) => 97 | console.Output.WriteCommandInput(commandInput); 98 | } 99 | -------------------------------------------------------------------------------- /CliFx/Formatting/ConsoleFormatter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using CliFx.Infrastructure; 3 | 4 | namespace CliFx.Formatting; 5 | 6 | internal class ConsoleFormatter(ConsoleWriter consoleWriter) 7 | { 8 | private int _column; 9 | private int _row; 10 | 11 | public bool IsEmpty => _column == 0 && _row == 0; 12 | 13 | public void Write(string? value) 14 | { 15 | consoleWriter.Write(value); 16 | _column += value?.Length ?? 0; 17 | } 18 | 19 | public void Write(char value) 20 | { 21 | consoleWriter.Write(value); 22 | _column++; 23 | } 24 | 25 | public void Write(ConsoleColor foregroundColor, string? value) 26 | { 27 | using (consoleWriter.Console.WithForegroundColor(foregroundColor)) 28 | Write(value); 29 | } 30 | 31 | public void Write(ConsoleColor foregroundColor, char value) 32 | { 33 | using (consoleWriter.Console.WithForegroundColor(foregroundColor)) 34 | Write(value); 35 | } 36 | 37 | public void Write(ConsoleColor foregroundColor, ConsoleColor backgroundColor, string? value) 38 | { 39 | using (consoleWriter.Console.WithColors(foregroundColor, backgroundColor)) 40 | Write(value); 41 | } 42 | 43 | public void WriteLine() 44 | { 45 | consoleWriter.WriteLine(); 46 | _column = 0; 47 | _row++; 48 | } 49 | 50 | public void WriteVerticalMargin(int size = 1) 51 | { 52 | for (var i = 0; i < size; i++) 53 | WriteLine(); 54 | } 55 | 56 | public void WriteHorizontalMargin(int size = 2) 57 | { 58 | for (var i = 0; i < size; i++) 59 | Write(' '); 60 | } 61 | 62 | public void WriteColumnMargin(int columnSize = 20, int offsetSize = 2) 63 | { 64 | if (_column + offsetSize < columnSize) 65 | WriteHorizontalMargin(columnSize - _column); 66 | else 67 | WriteHorizontalMargin(offsetSize); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /CliFx/Formatting/HelpContext.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using CliFx.Schema; 3 | 4 | namespace CliFx.Formatting; 5 | 6 | internal class HelpContext( 7 | ApplicationMetadata applicationMetadata, 8 | ApplicationSchema applicationSchema, 9 | CommandSchema commandSchema, 10 | IReadOnlyDictionary commandDefaultValues 11 | ) 12 | { 13 | public ApplicationMetadata ApplicationMetadata { get; } = applicationMetadata; 14 | 15 | public ApplicationSchema ApplicationSchema { get; } = applicationSchema; 16 | 17 | public CommandSchema CommandSchema { get; } = commandSchema; 18 | 19 | public IReadOnlyDictionary CommandDefaultValues { get; } = 20 | commandDefaultValues; 21 | } 22 | -------------------------------------------------------------------------------- /CliFx/ICommand.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using CliFx.Infrastructure; 3 | 4 | namespace CliFx; 5 | 6 | /// 7 | /// Entry point through which the user interacts with the command-line application. 8 | /// 9 | public interface ICommand 10 | { 11 | /// 12 | /// Executes the command using the specified implementation of . 13 | /// 14 | /// 15 | /// If the execution of the command is not asynchronous, simply end the method with 16 | /// return default; 17 | /// 18 | ValueTask ExecuteAsync(IConsole console); 19 | } 20 | -------------------------------------------------------------------------------- /CliFx/Infrastructure/ConsoleReader.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | using System.IO; 3 | using System.Runtime.CompilerServices; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | 7 | namespace CliFx.Infrastructure; 8 | 9 | /// 10 | /// Implements a for reading characters from a console stream. 11 | /// 12 | // Both the underlying stream AND the stream reader must be synchronized! 13 | // https://github.com/Tyrrrz/CliFx/issues/123 14 | public class ConsoleReader(IConsole console, Stream stream, Encoding encoding) 15 | : StreamReader(Stream.Synchronized(stream), encoding, false, 4096) 16 | { 17 | /// 18 | /// Initializes an instance of . 19 | /// 20 | public ConsoleReader(IConsole console, Stream stream) 21 | : this(console, stream, System.Console.InputEncoding) { } 22 | 23 | /// 24 | /// Console that owns this stream. 25 | /// 26 | public IConsole Console { get; } = console; 27 | 28 | // The following overrides are required to establish thread-safe behavior 29 | // in methods deriving from StreamReader. 30 | 31 | /// 32 | [ExcludeFromCodeCoverage, MethodImpl(MethodImplOptions.Synchronized)] 33 | public override int Peek() => base.Peek(); 34 | 35 | /// 36 | [ExcludeFromCodeCoverage, MethodImpl(MethodImplOptions.Synchronized)] 37 | public override int Read() => base.Read(); 38 | 39 | /// 40 | [ExcludeFromCodeCoverage, MethodImpl(MethodImplOptions.Synchronized)] 41 | public override int Read(char[] buffer, int index, int count) => 42 | base.Read(buffer, index, count); 43 | 44 | /// 45 | [ExcludeFromCodeCoverage, MethodImpl(MethodImplOptions.Synchronized)] 46 | public override int ReadBlock(char[] buffer, int index, int count) => 47 | base.ReadBlock(buffer, index, count); 48 | 49 | /// 50 | [ExcludeFromCodeCoverage, MethodImpl(MethodImplOptions.Synchronized)] 51 | public override string? ReadLine() => base.ReadLine(); 52 | 53 | /// 54 | [ExcludeFromCodeCoverage, MethodImpl(MethodImplOptions.Synchronized)] 55 | public override string ReadToEnd() => base.ReadToEnd(); 56 | 57 | /// 58 | [ExcludeFromCodeCoverage, MethodImpl(MethodImplOptions.Synchronized)] 59 | public override Task ReadAsync(char[] buffer, int index, int count) => 60 | // Must be non-async to work with locks 61 | Task.FromResult(Read(buffer, index, count)); 62 | 63 | /// 64 | [ExcludeFromCodeCoverage, MethodImpl(MethodImplOptions.Synchronized)] 65 | public override Task ReadBlockAsync(char[] buffer, int index, int count) => 66 | // Must be non-async to work with locks 67 | Task.FromResult(ReadBlock(buffer, index, count)); 68 | 69 | /// 70 | [ExcludeFromCodeCoverage, MethodImpl(MethodImplOptions.Synchronized)] 71 | public override Task ReadLineAsync() => 72 | // Must be non-async to work with locks 73 | Task.FromResult(ReadLine()); 74 | 75 | /// 76 | [ExcludeFromCodeCoverage, MethodImpl(MethodImplOptions.Synchronized)] 77 | public override Task ReadToEndAsync() => 78 | // Must be non-async to work with locks 79 | Task.FromResult(ReadToEnd()); 80 | 81 | /// 82 | [ExcludeFromCodeCoverage, MethodImpl(MethodImplOptions.Synchronized)] 83 | public override void Close() => base.Close(); 84 | 85 | /// 86 | [ExcludeFromCodeCoverage, MethodImpl(MethodImplOptions.Synchronized)] 87 | protected override void Dispose(bool disposing) => base.Dispose(disposing); 88 | } 89 | -------------------------------------------------------------------------------- /CliFx/Infrastructure/DefaultTypeActivator.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using CliFx.Exceptions; 3 | 4 | namespace CliFx.Infrastructure; 5 | 6 | /// 7 | /// Implementation of that instantiates an object 8 | /// by using its parameterless constructor. 9 | /// 10 | public class DefaultTypeActivator : ITypeActivator 11 | { 12 | /// 13 | public object CreateInstance(Type type) 14 | { 15 | try 16 | { 17 | return Activator.CreateInstance(type) 18 | ?? throw CliFxException.InternalError( 19 | $""" 20 | Failed to create an instance of type `{type.FullName}`, received instead. 21 | This may be caused by the type's constructor being trimmed away. 22 | """ 23 | ); 24 | } 25 | // Only catch MemberAccessException because the constructor can throw for its own reasons too 26 | catch (MemberAccessException ex) 27 | { 28 | throw CliFxException.InternalError( 29 | $""" 30 | Failed to create an instance of type `{type.FullName}` because an appropriate constructor is not available. 31 | Default type activator is only capable of instantiating a type if it has a public parameterless constructor. 32 | To fix this, either add a parameterless constructor to the type or configure a custom activator for the application. 33 | """, 34 | ex 35 | ); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /CliFx/Infrastructure/DelegateTypeActivator.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using CliFx.Exceptions; 3 | 4 | namespace CliFx.Infrastructure; 5 | 6 | /// 7 | /// Implementation of that instantiates an object by using a predefined delegate. 8 | /// 9 | public class DelegateTypeActivator(Func createInstance) : ITypeActivator 10 | { 11 | /// 12 | public object CreateInstance(Type type) => 13 | createInstance(type) 14 | ?? throw CliFxException.InternalError( 15 | $""" 16 | Failed to create an instance of type `{type.FullName}`, received instead. 17 | To fix this, ensure that the provided type activator is configured correctly, as it's not expected to return . 18 | If you are relying on a dependency container, this error may indicate that the specified type has not been registered. 19 | """ 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /CliFx/Infrastructure/FakeInMemoryConsole.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | 3 | namespace CliFx.Infrastructure; 4 | 5 | /// 6 | /// Implementation of that uses fake standard input, output, and error streams 7 | /// backed by in-memory stores. 8 | /// Use this implementation in tests to verify how a command interacts with the console. 9 | /// 10 | public class FakeInMemoryConsole : FakeConsole 11 | { 12 | private readonly MemoryStream _input; 13 | private readonly MemoryStream _output; 14 | private readonly MemoryStream _error; 15 | 16 | private FakeInMemoryConsole(MemoryStream input, MemoryStream output, MemoryStream error) 17 | : base(input, output, error) 18 | { 19 | _input = input; 20 | _output = output; 21 | _error = error; 22 | } 23 | 24 | /// 25 | /// Initializes an instance of . 26 | /// 27 | public FakeInMemoryConsole() 28 | : this(new MemoryStream(), new MemoryStream(), new MemoryStream()) { } 29 | 30 | /// 31 | /// Writes data to the input stream. 32 | /// 33 | public void WriteInput(byte[] data) 34 | { 35 | lock (_input) 36 | { 37 | var lastPosition = _input.Position; 38 | 39 | // Write the data to the end of the stream 40 | _input.Seek(0, SeekOrigin.End); 41 | _input.Write(data); 42 | _input.Flush(); 43 | 44 | // Reset position to where it was before 45 | _input.Seek(lastPosition, SeekOrigin.Begin); 46 | } 47 | } 48 | 49 | /// 50 | /// Writes data to the input stream. 51 | /// 52 | public void WriteInput(string data) => WriteInput(Input.CurrentEncoding.GetBytes(data)); 53 | 54 | /// 55 | /// Reads the data written to the output stream. 56 | /// 57 | public byte[] ReadOutputBytes() 58 | { 59 | lock (_output) 60 | { 61 | _output.Flush(); 62 | return _output.ToArray(); 63 | } 64 | } 65 | 66 | /// 67 | /// Reads the data written to the output stream. 68 | /// 69 | public string ReadOutputString() => Output.Encoding.GetString(ReadOutputBytes()); 70 | 71 | /// 72 | /// Reads the data written to the error stream. 73 | /// 74 | public byte[] ReadErrorBytes() 75 | { 76 | lock (_error) 77 | { 78 | _error.Flush(); 79 | return _error.ToArray(); 80 | } 81 | } 82 | 83 | /// 84 | /// Reads the data written to the error stream. 85 | /// 86 | public string ReadErrorString() => Error.Encoding.GetString(ReadErrorBytes()); 87 | 88 | /// 89 | public override void Dispose() 90 | { 91 | _input.Dispose(); 92 | _output.Dispose(); 93 | _error.Dispose(); 94 | 95 | base.Dispose(); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /CliFx/Infrastructure/ITypeActivator.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using CliFx.Exceptions; 3 | 4 | namespace CliFx.Infrastructure; 5 | 6 | /// 7 | /// Abstraction for a service that can instantiate objects at runtime. 8 | /// 9 | public interface ITypeActivator 10 | { 11 | /// 12 | /// Creates an instance of the specified type. 13 | /// 14 | object CreateInstance(Type type); 15 | } 16 | 17 | internal static class TypeActivatorExtensions 18 | { 19 | public static T CreateInstance(this ITypeActivator activator, Type type) 20 | { 21 | if (!typeof(T).IsAssignableFrom(type)) 22 | { 23 | throw CliFxException.InternalError( 24 | $"Type '{type.FullName}' is not assignable to '{typeof(T).FullName}'." 25 | ); 26 | } 27 | 28 | return (T)activator.CreateInstance(type); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /CliFx/Infrastructure/SystemConsole.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | 4 | namespace CliFx.Infrastructure; 5 | 6 | /// 7 | /// Implementation of that represents the real system console. 8 | /// 9 | public class SystemConsole : IConsole, IDisposable 10 | { 11 | private CancellationTokenSource? _cancellationTokenSource; 12 | 13 | /// 14 | /// Initializes an instance of . 15 | /// 16 | public SystemConsole() 17 | { 18 | Input = new ConsoleReader(this, Console.OpenStandardInput()); 19 | Output = new ConsoleWriter(this, Console.OpenStandardOutput()); 20 | Error = new ConsoleWriter(this, Console.OpenStandardError()); 21 | } 22 | 23 | /// 24 | public ConsoleReader Input { get; } 25 | 26 | /// 27 | public bool IsInputRedirected => Console.IsInputRedirected; 28 | 29 | /// 30 | public ConsoleWriter Output { get; } 31 | 32 | /// 33 | public bool IsOutputRedirected => Console.IsOutputRedirected; 34 | 35 | /// 36 | public ConsoleWriter Error { get; } 37 | 38 | /// 39 | public bool IsErrorRedirected => Console.IsErrorRedirected; 40 | 41 | /// 42 | public ConsoleColor ForegroundColor 43 | { 44 | get => Console.ForegroundColor; 45 | set => Console.ForegroundColor = value; 46 | } 47 | 48 | /// 49 | public ConsoleColor BackgroundColor 50 | { 51 | get => Console.BackgroundColor; 52 | set => Console.BackgroundColor = value; 53 | } 54 | 55 | /// 56 | public int WindowWidth 57 | { 58 | get => Console.WindowWidth; 59 | set => Console.WindowWidth = value; 60 | } 61 | 62 | /// 63 | public int WindowHeight 64 | { 65 | get => Console.WindowHeight; 66 | set => Console.WindowHeight = value; 67 | } 68 | 69 | /// 70 | public int CursorLeft 71 | { 72 | get => Console.CursorLeft; 73 | set => Console.CursorLeft = value; 74 | } 75 | 76 | /// 77 | public int CursorTop 78 | { 79 | get => Console.CursorTop; 80 | set => Console.CursorTop = value; 81 | } 82 | 83 | /// 84 | public ConsoleKeyInfo ReadKey(bool intercept = false) => Console.ReadKey(intercept); 85 | 86 | /// 87 | public void ResetColor() => Console.ResetColor(); 88 | 89 | /// 90 | public void Clear() => Console.Clear(); 91 | 92 | /// 93 | public CancellationToken RegisterCancellationHandler() 94 | { 95 | if (_cancellationTokenSource is not null) 96 | return _cancellationTokenSource.Token; 97 | 98 | var cts = new CancellationTokenSource(); 99 | 100 | Console.CancelKeyPress += (_, args) => 101 | { 102 | // Don't delay cancellation more than once 103 | if (!cts.IsCancellationRequested) 104 | { 105 | args.Cancel = true; 106 | cts.Cancel(); 107 | } 108 | }; 109 | 110 | return (_cancellationTokenSource = cts).Token; 111 | } 112 | 113 | /// 114 | public void Dispose() 115 | { 116 | _cancellationTokenSource?.Dispose(); 117 | 118 | Input.Dispose(); 119 | Output.Dispose(); 120 | Error.Dispose(); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /CliFx/Input/DirectiveInput.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace CliFx.Input; 4 | 5 | internal class DirectiveInput(string name) 6 | { 7 | public string Name { get; } = name; 8 | 9 | public bool IsDebugDirective => 10 | string.Equals(Name, "debug", StringComparison.OrdinalIgnoreCase); 11 | 12 | public bool IsPreviewDirective => 13 | string.Equals(Name, "preview", StringComparison.OrdinalIgnoreCase); 14 | } 15 | -------------------------------------------------------------------------------- /CliFx/Input/EnvironmentVariableInput.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.IO; 3 | 4 | namespace CliFx.Input; 5 | 6 | internal class EnvironmentVariableInput(string name, string value) 7 | { 8 | public string Name { get; } = name; 9 | 10 | public string Value { get; } = value; 11 | 12 | public IReadOnlyList SplitValues() => Value.Split(Path.PathSeparator); 13 | } 14 | -------------------------------------------------------------------------------- /CliFx/Input/OptionInput.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using CliFx.Schema; 3 | 4 | namespace CliFx.Input; 5 | 6 | internal class OptionInput(string identifier, IReadOnlyList values) 7 | { 8 | public string Identifier { get; } = identifier; 9 | 10 | public IReadOnlyList Values { get; } = values; 11 | 12 | public bool IsHelpOption => OptionSchema.ImplicitHelpOption.MatchesIdentifier(Identifier); 13 | 14 | public bool IsVersionOption => OptionSchema.ImplicitVersionOption.MatchesIdentifier(Identifier); 15 | 16 | public string GetFormattedIdentifier() => 17 | Identifier switch 18 | { 19 | { Length: >= 2 } => "--" + Identifier, 20 | _ => '-' + Identifier, 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /CliFx/Input/ParameterInput.cs: -------------------------------------------------------------------------------- 1 | namespace CliFx.Input; 2 | 3 | internal class ParameterInput(string value) 4 | { 5 | public string Value { get; } = value; 6 | 7 | public string GetFormattedIdentifier() => $"<{Value}>"; 8 | } 9 | -------------------------------------------------------------------------------- /CliFx/Schema/ApplicationSchema.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using CliFx.Utils.Extensions; 5 | 6 | namespace CliFx.Schema; 7 | 8 | internal partial class ApplicationSchema(IReadOnlyList commands) 9 | { 10 | public IReadOnlyList Commands { get; } = commands; 11 | 12 | public IReadOnlyList GetCommandNames() => 13 | Commands.Select(c => c.Name).WhereNotNullOrWhiteSpace().ToArray(); 14 | 15 | public CommandSchema? TryFindDefaultCommand() => Commands.FirstOrDefault(c => c.IsDefault); 16 | 17 | public CommandSchema? TryFindCommand(string commandName) => 18 | Commands.FirstOrDefault(c => c.MatchesName(commandName)); 19 | 20 | private IReadOnlyList GetDescendantCommands( 21 | IReadOnlyList potentialParentCommandSchemas, 22 | string? parentCommandName 23 | ) 24 | { 25 | var result = new List(); 26 | 27 | foreach (var potentialParentCommandSchema in potentialParentCommandSchemas) 28 | { 29 | // Default commands can't be descendant of anything 30 | if (string.IsNullOrWhiteSpace(potentialParentCommandSchema.Name)) 31 | continue; 32 | 33 | // Command can't be its own descendant 34 | if (potentialParentCommandSchema.MatchesName(parentCommandName)) 35 | continue; 36 | 37 | var isDescendant = 38 | // Every command is a descendant of the default command 39 | string.IsNullOrWhiteSpace(parentCommandName) 40 | || 41 | // Otherwise a command is a descendant if it starts with the same name segments 42 | potentialParentCommandSchema.Name.StartsWith( 43 | parentCommandName + ' ', 44 | StringComparison.OrdinalIgnoreCase 45 | ); 46 | 47 | if (isDescendant) 48 | result.Add(potentialParentCommandSchema); 49 | } 50 | 51 | return result; 52 | } 53 | 54 | public IReadOnlyList GetDescendantCommands(string? parentCommandName) => 55 | GetDescendantCommands(Commands, parentCommandName); 56 | 57 | public IReadOnlyList GetChildCommands(string? parentCommandName) 58 | { 59 | var descendants = GetDescendantCommands(parentCommandName); 60 | 61 | var result = descendants.ToList(); 62 | 63 | // Filter out descendants of descendants, leave only direct children 64 | foreach (var descendant in descendants) 65 | { 66 | result.RemoveRange(GetDescendantCommands(descendants, descendant.Name)); 67 | } 68 | 69 | return result; 70 | } 71 | } 72 | 73 | internal partial class ApplicationSchema 74 | { 75 | public static ApplicationSchema Resolve(IReadOnlyList commandTypes) => 76 | new(commandTypes.Select(CommandSchema.Resolve).ToArray()); 77 | } 78 | -------------------------------------------------------------------------------- /CliFx/Schema/BindablePropertyDescriptor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Reflection; 4 | using CliFx.Utils.Extensions; 5 | 6 | namespace CliFx.Schema; 7 | 8 | internal class BindablePropertyDescriptor(PropertyInfo property) : IPropertyDescriptor 9 | { 10 | public Type Type => property.PropertyType; 11 | 12 | public object? GetValue(ICommand commandInstance) => property.GetValue(commandInstance); 13 | 14 | public void SetValue(ICommand commandInstance, object? value) => 15 | property.SetValue(commandInstance, value); 16 | 17 | public IReadOnlyList GetValidValues() 18 | { 19 | static Type GetUnderlyingType(Type type) 20 | { 21 | var enumerableUnderlyingType = type.TryGetEnumerableUnderlyingType(); 22 | if (enumerableUnderlyingType is not null) 23 | return GetUnderlyingType(enumerableUnderlyingType); 24 | 25 | var nullableUnderlyingType = type.TryGetNullableUnderlyingType(); 26 | if (nullableUnderlyingType is not null) 27 | return GetUnderlyingType(nullableUnderlyingType); 28 | 29 | return type; 30 | } 31 | 32 | var underlyingType = GetUnderlyingType(Type); 33 | 34 | // We can only get valid values for enums 35 | if (underlyingType.IsEnum) 36 | return Enum.GetNames(underlyingType); 37 | 38 | return Array.Empty(); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /CliFx/Schema/IMemberSchema.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace CliFx.Schema; 5 | 6 | internal interface IMemberSchema 7 | { 8 | IPropertyDescriptor Property { get; } 9 | 10 | Type? ConverterType { get; } 11 | 12 | IReadOnlyList ValidatorTypes { get; } 13 | 14 | string GetFormattedIdentifier(); 15 | } 16 | 17 | internal static class MemberSchemaExtensions 18 | { 19 | public static string GetKind(this IMemberSchema memberSchema) => 20 | memberSchema switch 21 | { 22 | ParameterSchema => "Parameter", 23 | OptionSchema => "Option", 24 | _ => throw new ArgumentOutOfRangeException(nameof(memberSchema)), 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /CliFx/Schema/IPropertyDescriptor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using CliFx.Utils.Extensions; 4 | 5 | namespace CliFx.Schema; 6 | 7 | internal interface IPropertyDescriptor 8 | { 9 | Type Type { get; } 10 | 11 | object? GetValue(ICommand commandInstance); 12 | 13 | void SetValue(ICommand commandInstance, object? value); 14 | 15 | IReadOnlyList GetValidValues(); 16 | } 17 | 18 | internal static class PropertyDescriptorExtensions 19 | { 20 | public static bool IsScalar(this IPropertyDescriptor propertyDescriptor) => 21 | propertyDescriptor.Type == typeof(string) 22 | || propertyDescriptor.Type.TryGetEnumerableUnderlyingType() is null; 23 | } 24 | -------------------------------------------------------------------------------- /CliFx/Schema/NullPropertyDescriptor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace CliFx.Schema; 5 | 6 | internal partial class NullPropertyDescriptor : IPropertyDescriptor 7 | { 8 | public Type Type { get; } = typeof(object); 9 | 10 | public object? GetValue(ICommand commandInstance) => null; 11 | 12 | public void SetValue(ICommand commandInstance, object? value) { } 13 | 14 | public IReadOnlyList GetValidValues() => Array.Empty(); 15 | } 16 | 17 | internal partial class NullPropertyDescriptor 18 | { 19 | public static NullPropertyDescriptor Instance { get; } = new(); 20 | } 21 | -------------------------------------------------------------------------------- /CliFx/Schema/ParameterSchema.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Reflection; 4 | using CliFx.Attributes; 5 | using CliFx.Utils.Extensions; 6 | 7 | namespace CliFx.Schema; 8 | 9 | internal partial class ParameterSchema( 10 | IPropertyDescriptor property, 11 | int order, 12 | string name, 13 | bool isRequired, 14 | string? description, 15 | Type? converterType, 16 | IReadOnlyList validatorTypes 17 | ) : IMemberSchema 18 | { 19 | public IPropertyDescriptor Property { get; } = property; 20 | 21 | public int Order { get; } = order; 22 | 23 | public string Name { get; } = name; 24 | 25 | public bool IsRequired { get; } = isRequired; 26 | 27 | public string? Description { get; } = description; 28 | 29 | public Type? ConverterType { get; } = converterType; 30 | 31 | public IReadOnlyList ValidatorTypes { get; } = validatorTypes; 32 | 33 | public string GetFormattedIdentifier() => Property.IsScalar() ? $"<{Name}>" : $"<{Name}...>"; 34 | } 35 | 36 | internal partial class ParameterSchema 37 | { 38 | public static ParameterSchema? TryResolve(PropertyInfo property) 39 | { 40 | var attribute = property.GetCustomAttribute(); 41 | if (attribute is null) 42 | return null; 43 | 44 | var name = attribute.Name?.Trim() ?? property.Name.ToLowerInvariant(); 45 | var isRequired = attribute.IsRequired || property.IsRequired(); 46 | var description = attribute.Description?.Trim(); 47 | 48 | return new ParameterSchema( 49 | new BindablePropertyDescriptor(property), 50 | attribute.Order, 51 | name, 52 | isRequired, 53 | description, 54 | attribute.Converter, 55 | attribute.Validators 56 | ); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /CliFx/Utils/Disposable.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace CliFx.Utils; 5 | 6 | internal partial class Disposable(Action dispose) : IDisposable 7 | { 8 | public void Dispose() => dispose(); 9 | } 10 | 11 | internal partial class Disposable 12 | { 13 | public static IDisposable Create(Action dispose) => new Disposable(dispose); 14 | 15 | public static IDisposable Merge(params IEnumerable disposables) => 16 | Create(() => 17 | { 18 | foreach (var disposable in disposables) 19 | disposable.Dispose(); 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /CliFx/Utils/EnvironmentEx.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics; 3 | using System.Reflection; 4 | 5 | namespace CliFx.Utils; 6 | 7 | internal static class EnvironmentEx 8 | { 9 | private static readonly Lazy ProcessPathLazy = new(() => 10 | { 11 | using var process = Process.GetCurrentProcess(); 12 | return process.MainModule?.FileName; 13 | }); 14 | 15 | public static string? ProcessPath => ProcessPathLazy.Value; 16 | 17 | private static readonly Lazy EntryAssemblyLazy = new(Assembly.GetEntryAssembly); 18 | 19 | public static Assembly? EntryAssembly => EntryAssemblyLazy.Value; 20 | } 21 | -------------------------------------------------------------------------------- /CliFx/Utils/Extensions/CollectionExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | 6 | namespace CliFx.Utils.Extensions; 7 | 8 | internal static class CollectionExtensions 9 | { 10 | public static IEnumerable<(T value, int index)> WithIndex(this IEnumerable source) 11 | { 12 | var i = 0; 13 | foreach (var o in source) 14 | yield return (o, i++); 15 | } 16 | 17 | public static IEnumerable WhereNotNull(this IEnumerable source) 18 | where T : class 19 | { 20 | foreach (var i in source) 21 | { 22 | if (i is not null) 23 | yield return i; 24 | } 25 | } 26 | 27 | public static IEnumerable WhereNotNullOrWhiteSpace(this IEnumerable source) 28 | { 29 | foreach (var i in source) 30 | { 31 | if (!string.IsNullOrWhiteSpace(i)) 32 | yield return i; 33 | } 34 | } 35 | 36 | public static void RemoveRange(this ICollection source, IEnumerable items) 37 | { 38 | foreach (var item in items) 39 | source.Remove(item); 40 | } 41 | 42 | public static Dictionary ToDictionary( 43 | this IDictionary dictionary, 44 | IEqualityComparer comparer 45 | ) 46 | where TKey : notnull => 47 | dictionary 48 | .Cast() 49 | .ToDictionary(entry => (TKey)entry.Key, entry => (TValue)entry.Value!, comparer); 50 | 51 | public static Array ToNonGenericArray(this IEnumerable source, Type elementType) 52 | { 53 | var sourceAsCollection = source as ICollection ?? source.ToArray(); 54 | 55 | var array = Array.CreateInstance(elementType, sourceAsCollection.Count); 56 | sourceAsCollection.CopyTo(array, 0); 57 | 58 | return array; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /CliFx/Utils/Extensions/PropertyExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Reflection; 4 | 5 | namespace CliFx.Utils.Extensions; 6 | 7 | internal static class PropertyExtensions 8 | { 9 | public static bool IsRequired(this PropertyInfo propertyInfo) => 10 | // Match attribute by name to avoid depending on .NET 7.0+ and to allow polyfilling 11 | propertyInfo 12 | .GetCustomAttributes() 13 | .Any(a => 14 | string.Equals( 15 | a.GetType().FullName, 16 | "System.Runtime.CompilerServices.RequiredMemberAttribute", 17 | StringComparison.Ordinal 18 | ) 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /CliFx/Utils/Extensions/StringExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace CliFx.Utils.Extensions; 5 | 6 | internal static class StringExtensions 7 | { 8 | public static string? NullIfWhiteSpace(this string str) => 9 | !string.IsNullOrWhiteSpace(str) ? str : null; 10 | 11 | public static string Repeat(this char c, int count) => new(c, count); 12 | 13 | public static string AsString(this char c) => c.Repeat(1); 14 | 15 | public static string JoinToString(this IEnumerable source, string separator) => 16 | string.Join(separator, source); 17 | 18 | public static string? ToString( 19 | this object obj, 20 | IFormatProvider? formatProvider = null, 21 | string? format = null 22 | ) => 23 | obj is IFormattable formattable 24 | ? formattable.ToString(format, formatProvider) 25 | : obj.ToString(); 26 | } 27 | -------------------------------------------------------------------------------- /CliFx/Utils/Extensions/TypeExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using System.Reflection; 6 | 7 | namespace CliFx.Utils.Extensions; 8 | 9 | internal static class TypeExtensions 10 | { 11 | public static bool Implements(this Type type, Type interfaceType) => 12 | type.GetInterfaces().Contains(interfaceType); 13 | 14 | public static Type? TryGetNullableUnderlyingType(this Type type) => 15 | Nullable.GetUnderlyingType(type); 16 | 17 | public static Type? TryGetEnumerableUnderlyingType(this Type type) 18 | { 19 | if (type.IsPrimitive) 20 | return null; 21 | 22 | if (type == typeof(IEnumerable)) 23 | return typeof(object); 24 | 25 | if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) 26 | return type.GetGenericArguments().FirstOrDefault(); 27 | 28 | return type.GetInterfaces() 29 | .Select(TryGetEnumerableUnderlyingType) 30 | .Where(t => t is not null) 31 | // Every IEnumerable implements IEnumerable (which is essentially IEnumerable), 32 | // so we try to get a more specific underlying type. Still, if the type only implements 33 | // IEnumerable and nothing else, then we'll just return that. 34 | .MaxBy(t => t != typeof(object)); 35 | } 36 | 37 | public static MethodInfo? TryGetStaticParseMethod( 38 | this Type type, 39 | bool withFormatProvider = false 40 | ) 41 | { 42 | var argumentTypes = withFormatProvider 43 | ? new[] { typeof(string), typeof(IFormatProvider) } 44 | : new[] { typeof(string) }; 45 | 46 | return type.GetMethod( 47 | "Parse", 48 | BindingFlags.Public | BindingFlags.Static, 49 | null, 50 | argumentTypes, 51 | null 52 | ); 53 | } 54 | 55 | public static bool IsToStringOverriden(this Type type) 56 | { 57 | var toStringMethod = type.GetMethod(nameof(ToString), Type.EmptyTypes); 58 | return toStringMethod?.GetBaseDefinition()?.DeclaringType != toStringMethod?.DeclaringType; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /CliFx/Utils/Extensions/VersionExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace CliFx.Utils.Extensions; 4 | 5 | internal static class VersionExtensions 6 | { 7 | public static string ToSemanticString(this Version version) => 8 | version.Revision <= 0 ? version.ToString(3) : version.ToString(); 9 | } 10 | -------------------------------------------------------------------------------- /CliFx/Utils/PathEx.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Runtime.InteropServices; 4 | 5 | namespace CliFx.Utils; 6 | 7 | internal static class PathEx 8 | { 9 | private static StringComparer EqualityComparer { get; } = 10 | RuntimeInformation.IsOSPlatform(OSPlatform.Windows) 11 | ? StringComparer.OrdinalIgnoreCase 12 | : StringComparer.Ordinal; 13 | 14 | public static bool AreEqual(string path1, string path2) 15 | { 16 | static string Normalize(string path) => 17 | Path.GetFullPath(path) 18 | .Trim(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); 19 | 20 | return EqualityComparer.Equals(Normalize(path1), Normalize(path2)); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /CliFx/Utils/ProcessEx.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | 3 | namespace CliFx.Utils; 4 | 5 | internal static class ProcessEx 6 | { 7 | public static int GetCurrentProcessId() 8 | { 9 | using var process = Process.GetCurrentProcess(); 10 | return process.Id; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 0.0.0-dev 5 | Tyrrrz 6 | Copyright (C) Oleksii Holub 7 | latest 8 | enable 9 | true 10 | false 11 | false 12 | 13 | 14 | 15 | 16 | annotations 17 | 18 | 19 | -------------------------------------------------------------------------------- /License.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019-2024 Oleksii Holub 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /NuGet.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tyrrrz/CliFx/d80d01293804bbb6533e167aaac659f6bf8939da/favicon.ico -------------------------------------------------------------------------------- /favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tyrrrz/CliFx/d80d01293804bbb6533e167aaac659f6bf8939da/favicon.png --------------------------------------------------------------------------------