├── .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