diff --git a/.github/workflows/dotnet-build.yml b/.github/workflows/dotnet-build.yml index 69b84ffe..12bfa2ef 100644 --- a/.github/workflows/dotnet-build.yml +++ b/.github/workflows/dotnet-build.yml @@ -13,15 +13,17 @@ jobs: uses: actions/setup-dotnet@v1 with: dotnet-version: | - 3.1.x - 6.0.x 8.0.x + 9.0.x - name: Build - run: dotnet build --configuration Release + run: dotnet build -c release - - name: Test - run: dotnet test --configuration Release + - name: Test Debug + run: dotnet test -c debug + + - name: Test Release + run: dotnet test -c release - name: Pack - run: dotnet pack --configuration Release --no-build + run: dotnet pack ./src/DotNetCampus.CommandLine -c release --no-build diff --git a/.github/workflows/nuget-tag-publish.yml b/.github/workflows/nuget-tag-publish.yml index 40efc1a7..801818e4 100644 --- a/.github/workflows/nuget-tag-publish.yml +++ b/.github/workflows/nuget-tag-publish.yml @@ -17,9 +17,8 @@ jobs: uses: actions/setup-dotnet@v1 with: dotnet-version: | - 3.1.x - 6.0.x 8.0.x + 9.0.x - name: Install dotnet tool run: dotnet tool install -g dotnetCampus.TagToVersion @@ -29,8 +28,8 @@ jobs: - name: Build with dotnet run: | - dotnet build --configuration Release - dotnet pack --configuration Release --no-build + dotnet build -c release + dotnet pack ./src/DotNetCampus.CommandLine -c release --no-build - name: Install Nuget uses: nuget/setup-nuget@v1 diff --git a/Directory.Packages.props b/Directory.Packages.props index 3ee7ca77..2194a75f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,14 +1,17 @@ - - + + + + + - - + + diff --git a/DotNetCampus.CommandLine.sln b/DotNetCampus.CommandLine.sln index e5250e37..8a9bfbdc 100644 --- a/DotNetCampus.CommandLine.sln +++ b/DotNetCampus.CommandLine.sln @@ -28,6 +28,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DotNetCampus.CommandLine.Te EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DotNetCampus.CommandLine.Performance", "tests\DotNetCampus.CommandLine.Performance\DotNetCampus.CommandLine.Performance.csproj", "{56B65FC1-CE75-4981-A880-954D891901D6}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DotNetCampus.CommandLine.FakeObjects", "tests\DotNetCampus.CommandLine.FakeObjects\DotNetCampus.CommandLine.FakeObjects.csproj", "{413E4F7B-4E26-4D88-B7E9-13A6831A9E42}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -76,6 +78,14 @@ Global {56B65FC1-CE75-4981-A880-954D891901D6}.Release|Any CPU.Build.0 = Release|Any CPU {56B65FC1-CE75-4981-A880-954D891901D6}.Release|x86.ActiveCfg = Release|Any CPU {56B65FC1-CE75-4981-A880-954D891901D6}.Release|x86.Build.0 = Release|Any CPU + {413E4F7B-4E26-4D88-B7E9-13A6831A9E42}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {413E4F7B-4E26-4D88-B7E9-13A6831A9E42}.Debug|Any CPU.Build.0 = Debug|Any CPU + {413E4F7B-4E26-4D88-B7E9-13A6831A9E42}.Debug|x86.ActiveCfg = Debug|Any CPU + {413E4F7B-4E26-4D88-B7E9-13A6831A9E42}.Debug|x86.Build.0 = Debug|Any CPU + {413E4F7B-4E26-4D88-B7E9-13A6831A9E42}.Release|Any CPU.ActiveCfg = Release|Any CPU + {413E4F7B-4E26-4D88-B7E9-13A6831A9E42}.Release|Any CPU.Build.0 = Release|Any CPU + {413E4F7B-4E26-4D88-B7E9-13A6831A9E42}.Release|x86.ActiveCfg = Release|Any CPU + {413E4F7B-4E26-4D88-B7E9-13A6831A9E42}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -84,6 +94,7 @@ Global {6B8F7500-B161-408D-BFA3-AE77CB8CF4D8} = {F26EAA27-AA79-4B28-890C-D759F1D1A374} {70991994-BB0C-4D00-9B74-E8736D0AD7C1} = {7DDA8183-3606-4B08-86E3-A4537860448F} {56B65FC1-CE75-4981-A880-954D891901D6} = {7DDA8183-3606-4B08-86E3-A4537860448F} + {413E4F7B-4E26-4D88-B7E9-13A6831A9E42} = {7DDA8183-3606-4B08-86E3-A4537860448F} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {52E30C59-C5C8-4517-811A-667BFA2BFABB} diff --git a/Nuget.config b/Nuget.config new file mode 100644 index 00000000..d7068e80 --- /dev/null +++ b/Nuget.config @@ -0,0 +1,6 @@ + + + + + + diff --git a/README.md b/README.md index 7f05ae3f..7ed44758 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,9 @@ [zh-hans]: /docs/zh-hans/README.md [zh-hant]: /docs/zh-hant/README.md -DotNetCampus.CommandLine is a simple yet high-performance command line parsing library for .NET. Thanks to the power of source code generators, it provides efficient parsing capabilities with a developer-friendly experience. +DotNetCampus.CommandLine is a simple and high-performance command line parsing library for .NET. Benefiting from source generators (and interceptors), it delivers efficient parsing and a friendly development experience across multiple command line styles. All features live under the `DotNetCampus.Cli` namespace. -Parsing a typical command line takes only about 0.8μs (microseconds), making it one of the fastest command line parsers available in .NET. +Benchmarks show parsing a typical command line takes well under a microsecond in many scenarios, placing it among the fastest .NET command line parsers while still pursuing full‑featured syntax support. ## Get Started @@ -37,63 +37,81 @@ class Program Define a class that maps command line arguments: ```csharp -class Options +public class Options { - [Value(0)] - public required string FilePath { get; init; } + [Option("debug")] + public required bool IsDebugMode { get; init; } - [Option('s', "silence")] - public bool IsSilence { get; init; } + [Option('c', "count")] + public required int TestCount { get; init; } - [Option('m', "mode")] - public string? StartMode { get; init; } + [Option('n', "test-name")] + public string? TestName { get; set; } - [Option("startup-sessions")] - public IReadOnlyList StartupSessions { get; init; } = []; + [Option("test-category")] + public string? TestCategory { get; set; } + + [Option('d', "detail-level")] + public DetailLevel DetailLevel { get; set; } = DetailLevel.Medium; + + [Value(0, int.MaxValue)] + public IReadOnlyList TestItems { get; init; } = null!; +} + +public enum DetailLevel +{ + Low, + Medium, + High, } ``` -Then use different command line styles to populate instances of this type: +Then use different command line styles to populate instances of this type (the library supports multiple styles): -### Windows PowerShell Style +| Style | Example | +| --------------- | ------------------------------------------------------------------------------------------ | +| DotNet | `demo.exe 1.txt 2.txt -c:20 --test-name:BenchmarkTest --detail-level=High --debug` | +| Windows Classic | `demo.exe 1.txt 2.txt 3.txt -c 20 -TestName BenchmarkTest -DetailLevel High -Debug` | +| CMD | `demo.exe 1.txt 2.txt 3.txt /c 20 /TestName BenchmarkTest /DetailLevel High /Debug` | +| Gnu | `demo.exe 1.txt 2.txt 3.txt -c 20 --test-name BenchmarkTest --detail-level High --debug` | +| Flexible | `demo.exe 1.txt 2.txt 3.txt --count:20 /TestName BenchmarkTest --detail-level=High -Debug` | -```powershell -> demo.exe "C:\Users\lvyi\Desktop\demo.txt" -s -Mode Edit -StartupSessions A B C -``` +## Command Styles and Features -### Windows CMD Style +Multiple command line styles are supported; select one when parsing (Flexible is default). Styles differ in case sensitivity, accepted prefixes, separators, and naming forms. A detailed capability matrix (boolean literals, collection parsing forms, naming conventions, URL form, etc.) is documented in the full English guide under `docs/en/README.md`. -```cmd -> demo.exe "C:\Users\lvyi\Desktop\demo.txt" /s /Mode Edit /StartupSessions A B C -``` +Core capabilities: +- Rich option syntax: long & short options; separators `= : space`; multi-value & repeat forms +- Boolean literals: `true/false`, `yes/no`, `on/off`, `1/0` +- Collections & dictionaries: repeat, comma, semicolon forms; key-value dictionaries +- Positional arguments: via `[Value(index)]` (ranges supported with `(index, length)` overload — second parameter is count) +- Property semantics: `required`, `init`, nullable reference/value types unified behavior +- Commands & subcommands: multi-word `[Command]` supported with handler chaining or `ICommandHandler` +- URL protocol parsing: `scheme://command/sub/positional1?...` for integration scenarios +- High performance: source generators + interceptors, minimizing allocations +- AOT compatible: no reflection; even enum name lookups are avoided at runtime -### Linux/GNU Style +For the full feature matrix (including whether a style supports space-separated collections, explicit boolean values, multi-char short option groups, etc.), see the English documentation table. -```bash -$ demo.exe "C:/Users/lvyi/Desktop/demo.txt" -s --mode Edit --startup-sessions A --startup-sessions B --startup-sessions C -``` +### Naming -### .NET CLI Style -``` -> demo.exe "C:\Users\lvyi\Desktop\demo.txt" -s:true --mode:Edit --startup-sessions:A;B;C -``` +Define options using kebab-case in attributes (e.g., `[Option("test-name")]`). The analyzer warns (`DCL101`) if not kebab-case; we still treat what you write as kebab-case so users may invoke with PascalCase/camelCase depending on style. -## Command Styles and Features +### Required Options and Default Values + +Modifiers: `required` (must be supplied), `init` (immutable after construction), `?` (nullable). Initial value semantics follow the table in `docs/en/README.md`: required & missing → exception; nullable + init → null; non-nullable collection → empty; non-nullable scalar → default value (value types) or empty string for `string`; otherwise keep initializer. + +### Commands and Subcommands + +Register handlers with `AddHandler()` or implement `ICommandHandler`. Multi-word `[Command("remote add")]` expresses subcommands. Ambiguity throws `CommandNameAmbiguityException`. Use `RunAsync` if any handler is async. + +### URL Protocol + +You may express a command invocation as a URL: `dotnet-campus://1.txt/2.txt?count=20&test-name=BenchmarkTest&detail-level=High&debug` enabling shell integration or deep links. + +### Performance -The library supports multiple command line styles through `CommandLineStyle` enum: -- Flexible (default): Intelligently recognizes multiple styles -- GNU: GNU standard compliant -- POSIX: POSIX standard compliant -- DotNet: .NET CLI style -- PowerShell: PowerShell style - -Advanced features include: -- Support for various data types including collections and dictionaries -- Positional arguments with `ValueAttribute` -- Required properties with C# `required` modifier -- Command handling with command support -- URL protocol parsing -- High performance thanks to source generators +Benchmarks (see docs for detailed tables) show very low latency (hundreds of ns typical) and minimal allocations compared to earlier versions and other libraries, while preserving rich syntax coverage. ## Engage, Contribute and Provide Feedback @@ -107,7 +125,7 @@ Click here to file a new issue: ### Contributing Guide -Be kindly. +Be kind. ## License diff --git a/docs/en/README.md b/docs/en/README.md index 6d1be840..70c64089 100644 --- a/docs/en/README.md +++ b/docs/en/README.md @@ -1,4 +1,4 @@ -# Command Line Parser +# Command Line Parser | [English][en] | [简体中文][zh-hans] | [繁體中文][zh-hant] | | ------------- | ------------------- | ------------------- | @@ -7,268 +7,272 @@ [zh-hans]: /docs/zh-hans/README.md [zh-hant]: /docs/zh-hant/README.md -DotNetCampus.CommandLine provides a simple yet high-performance command line parsing functionality. Thanks to the power of source code generators, it now offers more efficient parsing capabilities and a more developer-friendly experience. All features are available under the DotNetCampus.Cli namespace. +DotNetCampus.CommandLine provides simple and high-performance command line parsing. Benefiting from source generators (and interceptors), it now delivers more efficient parsing and a friendlier development experience. All features live under the `DotNetCampus.Cli` namespace. -## Quick Start +## Quick Usage ```csharp class Program { static void Main(string[] args) { - // Create a new instance of CommandLine type from command-line arguments + // Create a new CommandLine instance from the command line arguments var commandLine = CommandLine.Parse(args); - // Parse the command line into an instance of Options type - // Source generator will automatically handle the parsing process for you, no need to manually create a parser + // Parse the command line into an instance of the Options type + // The source generator automatically performs the parsing; no manual parser creation needed var options = commandLine.As(); - // Next, write your other functionality using your options object + // Next, use your options object to implement other functionality } } ``` -You need to define a type that maps to command-line parameters: +You need to define a type that contains the mapping for command line arguments: ```csharp -class Options +public class Options { - [Value(0)] - public required string FilePath { get; init; } - - [Option('s', "silence")] - public bool IsSilence { get; init; } + [Option("debug")] + public required bool IsDebugMode { get; init; } - [Option('m', "mode")] - public string? StartMode { get; init; } + [Option('c', "count")] + public required int TestCount { get; init; } - [Option("startup-sessions")] - public IReadOnlyList StartupSessions { get; init; } = []; -} -``` + [Option('n', "test-name")] + public string? TestName { get; set; } -Then use different command styles in the command line to populate an instance of this type. The library supports multiple command line styles: + [Option("test-category")] + public string? TestCategory { get; set; } -### Windows PowerShell Style + [Option('d', "detail-level")] + public DetailLevel DetailLevel { get; set; } = DetailLevel.Medium; -```powershell -> demo.exe "C:\Users\lvyi\Desktop\demo.txt" -s -Mode Edit -StartupSessions A B C -``` - -### Windows CMD Style + [Value(0, int.MaxValue)] + public IReadOnlyList TestItems { get; init; } = null!; +} -```cmd -> demo.exe "C:\Users\lvyi\Desktop\demo.txt" /s /Mode Edit /StartupSessions A B C +public enum DetailLevel +{ + Low, + Medium, + High, +} ``` -### Linux/GNU Style +Then use different styles of command lines to populate an instance. The library supports multiple styles: -```bash -$ demo.exe "C:/Users/lvyi/Desktop/demo.txt" -s --mode Edit --startup-sessions A --startup-sessions B --startup-sessions C -``` - -### .NET CLI Style -``` -> demo.exe "C:\Users\lvyi\Desktop\demo.txt" -s:true --mode:Edit --startup-sessions:A;B;C -``` +| Style | Example | +| --------------- | ------------------------------------------------------------------------------------------ | +| DotNet | `demo.exe 1.txt 2.txt -c:20 --test-name:BenchmarkTest --detail-level=High --debug` | +| Windows Classic | `demo.exe 1.txt 2.txt 3.txt -c 20 -TestName BenchmarkTest -DetailLevel High -Debug` | +| CMD | `demo.exe 1.txt 2.txt 3.txt /c 20 /TestName BenchmarkTest /DetailLevel High /Debug` | +| Gnu | `demo.exe 1.txt 2.txt 3.txt -c 20 --test-name BenchmarkTest --detail-level High --debug` | +| Flexible | `demo.exe 1.txt 2.txt 3.txt --count:20 /TestName BenchmarkTest --detail-level=High -Debug` | ## Command Line Styles -DotNetCampus.CommandLine supports multiple command line styles, and you can specify which style to use during parsing: +DotNetCampus.CommandLine supports multiple styles; you can specify one when parsing: ```csharp -// Use .NET CLI style to parse command-line arguments +// Parse using the .NET CLI style var commandLine = CommandLine.Parse(args, CommandLineParsingOptions.DotNet); ``` Supported styles include: -- `CommandLineStyle.Flexible` (default): Smartly recognizes multiple styles, case-insensitive by default, and is an effective combination of DotNet/GNU/PowerShell styles - - Supports all styles shown in the previous examples and can correctly parse them - - Fully supports all command-line features of the DotNet style (including lists and dictionaries) - - Supports all features of the GNU style except short name parameters (e.g., `-o1.txt`) and short name abbreviations (e.g., `-abc` represents `-a -b -c`) - - Due to strict Posix rules, Flexible style naturally supports Posix style - - The DotNet style itself is compatible with PowerShell command line style, so Flexible style also supports PowerShell style -- `CommandLineStyle.Gnu`: Style conforming to the GNU specification, case-sensitive by default -- `CommandLineStyle.Posix`: Style conforming to the POSIX specification, case-sensitive by default -- `CommandLineStyle.DotNet`: .NET CLI style, case-insensitive by default -- `CommandLineStyle.PowerShell`: PowerShell style, case-insensitive by default - -## Data Type Support - -The library supports parsing of multiple data types: - -1. **Basic Types**: Strings, integers, booleans, enums, etc. -2. **Collection Types**: Arrays, lists, read-only collections, immutable collections -3. **Dictionary Types**: IDictionary, IReadOnlyDictionary, ImmutableDictionary, etc. - -### Boolean Type Options - -For boolean type options, there are multiple ways to specify them in the command line: - -- Specifying only the option name indicates `true`: `-s` or `--silence` -- Explicitly specify a value: `-s:true`, `-s=false`, `--silence:on`, `--silence=off` - -### Collection Type Options - -For collection type options, you can specify the same option multiple times, or use semicolons to separate multiple values: - -``` -demo.exe --files file1.txt --files file2.txt -demo.exe --files:file1.txt;file2.txt;file3.txt -``` - -### Dictionary Type Options - -For dictionary type options, multiple input methods are supported: - -``` -demo.exe --properties key1=value1 --properties key2=value2 -demo.exe --properties:key1=value1;key2=value2 -``` - -## Positional Arguments - -In addition to named options, you can also use positional arguments, specifying the position of the arguments using the `ValueAttribute`: +- `CommandLineStyle.Flexible` (default): Flexible style offering broad compatibility among styles; case-insensitive +- `CommandLineStyle.DotNet`: .NET CLI style; case-sensitive +- `CommandLineStyle.Gnu`: GNU-compliant style; case-sensitive +- `CommandLineStyle.Posix`: POSIX-compliant style; case-sensitive +- `CommandLineStyle.Windows`: Windows style; case-insensitive, mixing `-` and `/` as option prefixes + +By default, their detailed differences are: + +| Style | Flexible | DotNet | Gnu | Posix | Windows | URL | +| ---------------------- | -------------- | -------------- | ----------------- | ------------- | ------------- | ----------------- | +| Positional args | Supported | Supported | Supported | Supported | Supported | Supported | +| Trailing args `--` | Supported | Supported | Supported | Supported | Not supported | Not supported | +| Case | Insensitive | Sensitive | Sensitive | Sensitive | Insensitive | Insensitive | +| Long options | Supported | Supported | Supported | Not supported | Supported | Supported | +| Short options | Supported | Supported | Supported | Supported | Supported | Not supported | +| Long option prefixes | `--` `-` `/` | `--` | `--` | (None) | `-` `/` | | +| Short option prefixes | `-` `/` | `-` | `-` | `-` | `-` `/` | | +| Long option (space) | --option value | --option value | --option value | -o value | -o value | | +| Long option `=` | --option=value | --option=value | --option=value | | -o=value | option=value | +| Long option `:` | --option:value | --option:value | | | -o:value | | +| Short option (space) | -o value | -o value | -o value | -o value | -o value | | +| Short option `=` | -o=value | -o=value | | | -o=value | option=value | +| Short option `:` | -o:value | -o:value | | | -o:value | | +| Short option inline | | | -ovalue | | | | +| Multi-char short opt | -abc value | -abc value | | | -abc value | | +| Long boolean option | --option | --option | --option | | -Option | option | +| Long boolean ` ` | --option true | --option true | | | -Option true | | +| Long boolean `=` | --option=true | --option=true | --option=true[^1] | | -Option=true | | +| Long boolean `:` | --option:true | --option:true | | | -Option:true | | +| Short boolean option | -o | -o | -o | -o | -o | | +| Short boolean ` ` | -o true | -o true | | | -o true | | +| Short boolean `=` | -o=true | -o=true | | | -o=true | option=true | +| Short boolean `:` | -o:true | -o:true | | | -o:true | | +| Short boolean inline | | | -o1 | | | | +| Boolean values | true/false | true/false | true/false | true/false | true/false | true/false | +| Boolean values | yes/no | yes/no | yes/no | yes/no | yes/no | yes/no | +| Boolean values | on/off | on/off | on/off | on/off | on/off | on/off | +| Boolean values | 1/0 | 1/0 | 1/0 | 1/0 | 1/0 | 1/0 | +| Combined short bools | | | -abc | -abc | | | +| Collection option | -o A -o B | -o A -o B | -o A -o B | -o A -o B | -o A -o B | option=A&option=B | +| Collection (space)[^2] | | | | | | | +| Collection `,` | -o A,B,C | -o A,B,C | -o A,B,C | -o A,B,C | -o A,B,C | | +| Collection `;` | -o A;B;C | -o A;B;C | -o A;B;C | -o A;B;C | -o A;B;C | | +| Dictionary option | -o:A=X;B=Y | -o:A=X;B=Y | | | -o:A=X;B=Y | | +| Naming | --kebab-case | --kebab-case | --kebab-case | | | kebab-case | +| Naming | -PascalCase | | | | -PascalCase | | +| Naming | -camelCase | | | | -camelCase | | +| Naming | /PascalCase | | | | /PascalCase | | +| Naming | /camelCase | | | | /camelCase | | + +[^1]: GNU style does not officially support supplying an explicit value to a boolean option, but since the syntax is unambiguous we additionally allow it. +[^2]: All styles default to not supporting space-separated collections, to avoid ambiguity with positional arguments as much as possible. If you need it, you can enable it via `CommandLineParsingOptions.Style.SupportsSpaceSeparatedCollectionValues`. + +Notes: + +1. Except for Windows style, all other styles support using `--` to mark the start of trailing positional arguments; everything after is treated as a positional argument. URL style cannot express trailing positionals. +2. Before `--`, options and positional arguments may be interleaved. The rule: an option greedily consumes following tokens as long as they can be accepted by that option; once it can no longer take a token, the remaining tokens (until the next option or `--`) are treated as positional arguments. + +An option takes the immediate values greedily: + +For example, if `--option` is a boolean option, then in `--option true text` or `--option 1 text`, the `true` or `1` is consumed by `--option`, and `text` becomes a positional argument. +Another example: if `--option` is a boolean option, `--option text` leaves `text` as a positional argument because it is not a boolean value. +Another example: if a style supports space separated collections (see table), then when `--option a b c` is a collection option, `a` `b` `c` are consumed until the next option or `--`. GNU does not support space separated collections. + +## Naming + +1. When defining an option in code, you should use kebab-case + - [Why do this?](https://github.com/dotnet-campus/DotNetCampus.CommandLine/blob/main/docs/analyzers/DCL101.md) + - If we suspect you did not use kebab-case, we'll emit warning DCL101 + - You may ignore the warning; regardless of the string you write, we treat it as kebab-case (this provides unambiguous word boundary info; see example) +2. After you define a string treated as kebab-case + - Depending on the style you set, you can use any of kebab-case, PascalCase, and camelCase + +Example command line type: ```csharp -class FileOptions +[Command("open command-line")] +public class Options { - [Value(0)] - public string InputFile { get; init; } - - [Value(1)] - public string OutputFile { get; init; } - - [Option('v', "verbose")] - public bool Verbose { get; init; } + [Option('o', "option-name")] + public required string OptionName { get; init; } } ``` -Usage: +Two kebab-case usages here: the `Command` attribute and the `Option` attribute. You can accept: -``` -demo.exe input.txt output.txt --verbose -``` +- DotNet/Gnu style: `demo.exe open command-line --option-name value` +- Windows style: `demo.exe Open CommandLine -OptionName value` +- CMD style: `demo.exe Open CommandLine /optionName value` -You can also capture multiple positional arguments into an array or collection: +If you instead write them in other styles, you might get results different from expectations (or maybe intentional): ```csharp -class MultiFileOptions +#pragma warning disable DCL101 +[Command("Open CommandLine")] +public class Options { - [Value(0, Length = int.MaxValue)] - public string[] Files { get; init; } = []; + // Analyzer warning: OptionName is not kebab-case. Suppress DCL101 if desired. + [Option('o', "OptionName")] + public required string OptionName { get; init; } } +#pragma warning restore DCL101 ``` -## Combining Options and Positional Arguments +Because we treat them as kebab-case anyway, you will accept: -`ValueAttribute` and `OptionAttribute` can be applied to the same property simultaneously: +- DotNet/Gnu style: `demo.exe Open CommandLine --OptionName value` +- Windows style: `demo.exe Open CommandLine -OptionName value` +- CMD style: `demo.exe Open CommandLine /optionName value` -```csharp -class Options -{ - [Value(0), Option('f', "file")] - public string FilePath { get; init; } -} -``` +## Data Types -This way, all of the following command lines will assign the file path to the `FilePath` property: +The library supports many data types: -``` -demo.exe file.txt -demo.exe -f file.txt -demo.exe --file file.txt -``` +1. **Basic types**: string, integer, boolean, enum, etc. +2. **Collection types**: arrays, lists, read-only collections, immutable collections +3. **Dictionary types**: `IDictionary`, `IReadOnlyDictionary`, `ImmutableDictionary`, etc. -## Required and Optional Options +See the big table above for how these are passed on the command line. -In C# 11 and above, you can use the `required` modifier to mark required options: +## Required Options and Default Values -```csharp -class Options -{ - [Option('i', "input")] - public required string InputFile { get; init; } // Required option - - [Option('o', "output")] - public string? OutputFile { get; init; } // Optional option -} -``` +When defining a property, these modifiers apply: + +1. Use `required` to mark that an option is mandatory +2. Use `init` to mark that an option is immutable +3. Use `?` to mark that an option is nullable + +What value a property ultimately receives depends on: -If a required option is not provided, a `RequiredPropertyNotAssignedException` exception will be thrown during parsing. +| required | init | nullable | Collection | Behavior | Explanation | +| -------- | ---- | -------- | ---------- | ------------------- | -------------------------------------------------- | +| 1 | _ | _ | _ | Throw | Must be supplied; missing throws exception | +| 0 | 1 | 1 | _ | null | Nullable; missing => null | +| 0 | 1 | 0 | 1 | Empty collection | Collections are never null; missing => empty | +| 0 | 1 | 0 | 0 | Default/empty value | Non-nullable; missing => default value[^2] | +| 0 | 0 | _ | _ | Keep initial | Not required or immediate; keeps initializer value | -## Property Initial Values and Accessor Modifiers +[^2]: If it's a value type, it receives its default value; if it's a reference type (currently only string), it becomes the empty string `""`. -When defining option types, you need to be aware of the relationship between property initial values and accessor modifiers (`init`, `required`): +- 1 = present +- 0 = absent +- _ = regardless of presence + +1. Nullable behavior is the same for reference and value types (default value just yields `null` for reference types) +2. Missing required option throws `RequiredPropertyNotAssignedException` +3. "Keep initial" means you may assign an initial value at definition time: ```csharp -class Options -{ - // Incorrect example: When using init or required, default values will be ignored - [Option('f', "format")] - public string Format { get; init; } = "json"; // Default value won't take effect! - - // Correct example: Use set to preserve default values - [Option('f', "format")] - public string Format { get; set; } = "json"; // Default value will be correctly preserved -} +// Note: Initial value only applies when neither required nor init is used. +[Option('o', "option-name")] +public string OptionName { get; set; } = "Default Value"; ``` -### Important Notes on Property Initial Values - -1. **Behavior when using `init` or `required`**: - - When a property includes the `required` or `init` modifier, the property's initial value will be ignored - - If the command-line arguments don't provide a value for this option, the property will be set to `default(T)` (which is `null` for reference types) - - This is determined by C# language features; if the command-line library were to overcome this limitation, it would need to handle all possible combinations of properties, which is obviously very wasteful +## Exceptions -2. **Ways to preserve default values**: - - If you need to provide default values for properties, use `{ get; set; }` instead of `{ get; init; }` +The command-line library's exceptions fall into several categories: -3. **Nullable types and warning handling**: - - For non-required reference type properties, they should be marked as nullable (e.g., `string?`) to avoid nullable warnings - - For value types (e.g., `int`, `bool`), if you want to preserve the default value rather than `null`, they should not be marked as nullable +1. Command-line parsing exceptions `CommandLineParseException` + - Option or positional argument mismatch exceptions + - Command-line argument format exceptions + - Command-line value conversion exceptions +2. Command-line object creation exceptions + - Only one: `RequiredPropertyNotAssignedException`, which occurs when a property marked `required` is not provided in the command line +3. Command and subcommand matching exceptions + - Multiple match exception `CommandNameAmbiguityException` + - No match exception `CommandNameNotFoundException` -Example: +A common scenario occurs when multiple cooperating applications are not upgraded synchronously; one application might call this program using new command-line options, but the current version cannot recognize options that will only appear in the "next version". In such cases, you might need to ignore these compatibility errors (option or positional argument mismatch exceptions). If you anticipate this situation happening frequently, you can ignore such errors: ```csharp -class OptionsBestPractice +var commandLine = CommandLine.Parse(args, CommandLineParsingOptions.DotNet with { - // Required option: Use required, no need to worry about default values - [Option("input")] - public required string InputFile { get; init; } - - // Optional option: Mark as nullable type to avoid warnings - [Option("output")] - public string? OutputFile { get; init; } - - // Option that needs default value: Use set instead of init - [Option("format")] - public string Format { get; set; } = "json"; - - // Value type option: No need to mark as nullable - [Option("count")] - public int Count { get; set; } = 1; -} + // You can ignore only options, only positional arguments, or both like this. + UnknownArgumentsHandling = UnknownCommandArgumentHandling.IgnoreAllUnknownArguments, +}); ``` -## Command Handling and Commands - -You can use the command handler pattern to handle different commands, similar to `git commit`, `git push`, etc. DotNetCampus.CommandLine provides multiple ways to add command handlers: +## Commands and Subcommands -### 1. Using Delegates to Handle Commands +You can use the command handler pattern to process different commands, like `git commit` or `git remote add`. Multiple ways are provided: -The simplest way is to handle commands through delegates, separating command option types and handling logic: +### 1. Delegate-based handlers ```csharp var commandLine = CommandLine.Parse(args); -commandLine.AddHandler(options => { /* Handle the add command */ }) - .AddHandler(options => { /* Handle the remove command */ }) +commandLine + .AddHandler(options => { /* handle add */ }) + .AddHandler(options => { /* handle remove */ }) .Run(); ``` -Use the `Command` attribute to mark commands when defining command option classes: - ```csharp [Command("add")] public class AddOptions @@ -285,9 +289,7 @@ public class RemoveOptions } ``` -### 2. Using the ICommandHandler Interface - -For more complex command handling logic, you can create classes that implement the `ICommandHandler` interface, encapsulating command options and handling logic together: +### 2. `ICommandHandler` interface ```csharp [Command("convert")] @@ -295,331 +297,465 @@ internal class ConvertCommandHandler : ICommandHandler { [Option('i', "input")] public required string InputFile { get; init; } - + [Option('o', "output")] public string? OutputFile { get; init; } - + [Option('f', "format")] public string Format { get; set; } = "json"; - + public Task RunAsync() { - // Implement command handling logic + // Command handling logic Console.WriteLine($"Converting {InputFile} to {Format} format"); // ... - return Task.FromResult(0); // Return exit code + return Task.FromResult(0); // Exit code } } ``` -Then add it directly to the command line parser: - ```csharp -var commandLine = CommandLine.Parse(args); -commandLine.AddHandler() - .Run(); +var commandLine = CommandLine.Parse(args) + .AddHandler() + .AddHandler() + .AddHandler(options => { /* handle remove */ }) + .RunAsync(); ``` -### 3. Using Assembly Auto-Discovery of Command Handlers +### 3. Using the ICommandHandler Interface -For more convenient management of a large number of commands without manually adding each one, you can use the assembly auto-discovery feature to automatically add all classes in the assembly that implement the `ICommandHandler` interface: +Sometimes, the program's state is not entirely determined by command line arguments; there may be some internal program state that affects the execution of command line handlers. Since we cannot pass any parameters when using `AddHandler` as shown before, we have other methods to pass state in: ```csharp -// Define a partial class to mark auto-discovery of command handlers -[CollectCommandHandlersFromThisAssembly] -internal partial class AssemblyCommandHandler; - -// Add all command handlers at the program entry point -var commandLine = CommandLine.Parse(args); -commandLine.AddHandlers() - .Run(); +using var scope = serviceProvider.BeginScope(); +var state = scope.ServiceProvider.GetRequiredService(); +var commandLine = CommandLine.Parse(args) + .ForState(state).AddHandler() + .RunAsync(); ``` -Typically, handler classes need to add the `[Command]` attribute and implement the `ICommandHandler` interface, and they will be automatically discovered and added: - ```csharp -[Command("sample")] -internal class SampleCommandHandler : ICommandHandler +internal class CommandHandlerWithState : ICommandHandler { - [Option("SampleProperty")] + [Option('o', "option")] public required string Option { get; init; } - [Value(Length = int.MaxValue)] - public string? Argument { get; init; } - - public Task RunAsync() + public Task RunAsync(MyState state) { - // Implement command handling logic - return Task.FromResult(0); + // At this point, you can additionally use the passed-in state. } } ``` -Additionally, you can create a command handler without the `[Command]` attribute as the default handler. There can be at most one command handler without the `[Command]` attribute in the assembly, which will be used when no other commands match: +If multiple handlers can be executed for the same state, you can keep chaining `AddHandler` calls; if different command handlers need to handle different states, you can use `ForState` again; if no state is needed afterwards, don't pass parameters to `ForState`. Here's a more complex example: ```csharp -// Default handler without [Command] attribute -internal class DefaultCommandHandler : ICommandHandler -{ - [Option('h', "help")] - public bool ShowHelp { get; init; } - - public Task RunAsync() - { - // Handle default commands, such as displaying help information - if (ShowHelp) - { - Console.WriteLine("Displaying help information..."); - } - return Task.FromResult(0); - } -} +commandLine + .AddHandler() + .ForState(state1).AddHandler().AddHandler() + .ForState(state2).AddHandler() + .ForState().AddHandler() + .RunAsync(); ``` -This approach is particularly suitable for large applications or command-line tools with strong extensibility, allowing for the addition of new commands without modifying the entry code. +### Notes -### Asynchronous Command Handling - -For commands that need to execute asynchronously, you can use the `RunAsync` method: - -```csharp -await commandLine.AddHandler(async options => -{ - await ImportDataAsync(options); - return 0; -}) -.RunAsync(); -``` +1. `[Command]` supports multiple words, representing subcommands (e.g., `[Command("remote add")]`). +2. Absence of `[Command]`, or one with null/empty string, means default command (`[Command("")]`). +3. If multiple handlers match the same command, `CommandNameAmbiguityException` is thrown. +4. If any handler is asynchronous, you must use `RunAsync` instead of `Run` (otherwise compilation fails). ## URL Protocol Support -DotNetCampus.CommandLine supports parsing URL protocol strings: +DotNetCampus.CommandLine can parse a URL protocol string: -``` -dotnet-campus://open/document.txt?readOnly=true&mode=Display&silence=true&startup-sessions=89EA9D26-6464-4E71-BD04-AA6516063D83 +```ini +// scheme://command/subcommand/positional-argument1/positional-argument2?option1=value1&option2=value2 ``` -Features and usage of URL protocol parsing: +The example near the top expressed as URL: -1. The URL path part (such as `open/document.txt` in the example) will be parsed as positional arguments or command plus positional arguments - - The first part of the path can serve as a command (needs to be marked with the `[Command]` attribute) - - The subsequent path parts will be parsed as positional arguments -2. Query parameters (the part after `?`) will be parsed as named options -3. Collection type options can be passed multiple values by repeating parameter names, such as: `tags=csharp&tags=dotnet` -4. Special characters and non-ASCII characters in the URL will be automatically URL-decoded - -## Naming Conventions and Best Practices +```ini +# `demo.exe 1.txt 2.txt -c:20 --test-name:BenchmarkTest --detail-level=High --debug` +dotnet-campus://1.txt/2.txt?count=20&test-name=BenchmarkTest&detail-level=High&debug +``` -To ensure better compatibility and user experience, we recommend using the kebab-case style for naming long options: +Details: -```csharp -// Recommended -[Option('o', "output-file")] -public string OutputFile { get; init; } +1. Collection options can repeat query names: `tags=csharp&tags=dotnet` +2. Special and non-ASCII characters are URL-decoded automatically -// Not recommended -[Option('o', "OutputFile")] -public string OutputFile { get; init; } -``` +## Source Generators, Interceptors & Performance -Benefits of using kebab-case naming: +DotNetCampus.CommandLine leverages source generators and interceptors for major performance gains. -1. Provides clearer word separation information (e.g., can guess "DotNet-Campus" rather than "Dot-Net-Campus") -2. Resolves digital subordination issues (e.g., whether "Version2Info" is "Version2-Info" or "Version-2-Info") -3. Better compatibility with various command-line styles +### Example user code -## Source Generators, Interceptors, and Performance Optimization +```csharp +public class BenchmarkOptions41 +{ + [Option("debug")] + public required bool IsDebugMode { get; init; } -DotNetCampus.CommandLine uses source code generator technology to significantly improve the performance of command-line parsing. The interceptors ([Interceptor](https://github.com/dotnet/roslyn/blob/main/docs/features/interceptors.md)) make the performance improvement even more impressive. + [Option('c', "count")] + public required int TestCount { get; init; } -### How Interceptors Work + [Option('n', "test-name")] + public string? TestName { get; set; } -When you call methods like `CommandLine.As()` or `CommandLine.AddHandler()`, the source generator automatically generates intercepting code that redirects the call to a high-performance code path generated at compile time. This significantly improves the performance of command-line argument parsing and object creation. + [Option("test-category")] + public string? TestCategory { get; set; } -For example, when you write the following code: + [Option('d', "detail-level")] + public DetailLevel DetailLevel { get; set; } = DetailLevel.Medium; -```csharp -var options = CommandLine.Parse(args).As(); + [Value(0, int.MaxValue)] + public IReadOnlyList TestItems { get; init; } = null!; +} ``` -The source generator will intercept this call and automatically generate code similar to the following to replace the default way of implementing it by looking up creators in a dictionary (older versions used reflection): +
+ Corresponding generated source ```csharp +#nullable enable +using global::System; +using global::DotNetCampus.Cli.Compiler; + +namespace DotNetCampus.Cli.Performance.Fakes; + /// -/// Interceptor for method. Intercepts to improve performance. +/// 辅助 生成命令行选项、子命令或处理函数的创建。 /// -[global::System.Runtime.CompilerServices.InterceptsLocation(1, /* Program.Run4xInterceptor @Program.cs */ "G4GJAK7udHFnPkRUqV6VzzgRAABQcm9ncmFtLmNz")] -public static T CommandLine_As_DotNetCampusCliTestsFakesOptions(this global::DotNetCampus.Cli.CommandLine commandLine) - where T : global::DotNetCampus.Cli.Tests.Fakes.Options +public sealed class BenchmarkOptions41Builder(global::DotNetCampus.Cli.CommandLine commandLine) { - return (T)global::DotNetCampus.Cli.Tests.Fakes.OptionsBuilder.CreateInstance(commandLine); -} -``` + public static readonly global::DotNetCampus.Cli.Compiler.NamingPolicyNameGroup CommandNameGroup = default; -### Examples of Source Generator Generated Code + public static global::DotNetCampus.Cli.Performance.Fakes.BenchmarkOptions41 CreateInstance(global::DotNetCampus.Cli.CommandLine commandLine) + { + return new DotNetCampus.Cli.Performance.Fakes.BenchmarkOptions41Builder(commandLine).Build(); + } -Below is a simple command-line option type and its corresponding generated source code: + private global::DotNetCampus.Cli.Compiler.BooleanArgument IsDebugMode = new(); -```csharp -// Type in user code -internal record DotNet03_MixedOptions -{ - [Option] - public int Number { get; init; } + private global::DotNetCampus.Cli.Compiler.NumberArgument TestCount = new(); - [Option] - public required string Text { get; init; } + private global::DotNetCampus.Cli.Compiler.StringArgument TestName = new(); - [Option] - public bool Flag { get; init; } -} -``` + private global::DotNetCampus.Cli.Compiler.StringArgument TestCategory = new(); -Corresponding generated source: + private __GeneratedEnumArgument__DotNetCampus_Cli_Performance_Fakes_DetailLevel__ DetailLevel = new(); -```csharp -#nullable enable -namespace DotNetCampus.Cli.Tests; + private global::DotNetCampus.Cli.Compiler.StringListArgument TestItems = new(); -/// -/// Helper for generating command-line options, commands, or handler functions for . -/// -internal sealed class DotNet03_MixedOptionsBuilder -{ - public static object CreateInstance(global::DotNetCampus.Cli.CommandLine commandLine) + public global::DotNetCampus.Cli.Performance.Fakes.BenchmarkOptions41 Build() { - var caseSensitive = commandLine.DefaultCaseSensitive; - var result = new global::DotNetCampus.Cli.Tests.DotNet03_MixedOptions + if (commandLine.RawArguments.Count is 0) + { + return BuildDefault(); + } + + var parser = new global::DotNetCampus.Cli.Utils.Parsers.CommandLineParser(commandLine, "BenchmarkOptions41", 0) { - Number = commandLine.GetOption("number") ?? default, - Text = commandLine.GetOption("text") ?? throw new global::DotNetCampus.Cli.Exceptions.RequiredPropertyNotAssignedException($"The command line arguments doesn't contain a required option '--text'. Command line: {commandLine}", "Text"), - Flag = commandLine.GetOption("flag") ?? default, - // There is no positional argument to be initialized. + MatchLongOption = MatchLongOption, + MatchShortOption = MatchShortOption, + MatchPositionalArguments = MatchPositionalArguments, + AssignPropertyValue = AssignPropertyValue, }; - // There is no option to be assigned. - // There is no positional argument to be assigned. - return result; + parser.Parse().WithFallback(commandLine); + return BuildCore(commandLine); } -} -``` -Method call in code: - -```csharp -_ = CommandLine.Parse(args, CommandLineParsingOptions.DotNet).As(); -``` + private global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch MatchLongOption(ReadOnlySpan longOption, bool defaultCaseSensitive, global::DotNetCampus.Cli.CommandNamingPolicy namingPolicy) + { + // 1. 先匹配 kebab-case 命名法(原样字符串) + if (namingPolicy.SupportsOrdinal()) + { + // 1.1 先快速原字符匹配一遍(能应对规范命令行大小写,并优化 DotNet / GNU 风格的性能)。 + switch (longOption) + { + case "debug": + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(IsDebugMode), 0, global::DotNetCampus.Cli.Compiler.OptionValueType.Boolean); + case "count": + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(TestCount), 1, global::DotNetCampus.Cli.Compiler.OptionValueType.Normal); + case "test-name": + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(TestName), 2, global::DotNetCampus.Cli.Compiler.OptionValueType.Normal); + case "test-category": + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(TestCategory), 3, global::DotNetCampus.Cli.Compiler.OptionValueType.Normal); + case "detail-level": + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(DetailLevel), 4, global::DotNetCampus.Cli.Compiler.OptionValueType.Normal); + } + + // 1.2 再按指定大小写匹配一遍(能应对不规范命令行大小写)。 + var defaultComparison = defaultCaseSensitive + ? global::System.StringComparison.Ordinal + : global::System.StringComparison.OrdinalIgnoreCase; + if (longOption.Equals("debug".AsSpan(), defaultComparison)) + { + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(IsDebugMode), 0, global::DotNetCampus.Cli.Compiler.OptionValueType.Boolean); + } + if (longOption.Equals("count".AsSpan(), defaultComparison)) + { + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(TestCount), 1, global::DotNetCampus.Cli.Compiler.OptionValueType.Normal); + } + if (longOption.Equals("test-name".AsSpan(), defaultComparison)) + { + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(TestName), 2, global::DotNetCampus.Cli.Compiler.OptionValueType.Normal); + } + if (longOption.Equals("test-category".AsSpan(), defaultComparison)) + { + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(TestCategory), 3, global::DotNetCampus.Cli.Compiler.OptionValueType.Normal); + } + if (longOption.Equals("detail-level".AsSpan(), defaultComparison)) + { + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(DetailLevel), 4, global::DotNetCampus.Cli.Compiler.OptionValueType.Normal); + } + } -Corresponding generated source (interceptor): + // 2. 再匹配其他命名法(能应对所有不规范命令行大小写,并支持所有风格)。 + if (namingPolicy.SupportsPascalCase()) + { + var defaultComparison = defaultCaseSensitive + ? global::System.StringComparison.Ordinal + : global::System.StringComparison.OrdinalIgnoreCase; + if (longOption.Equals("Debug".AsSpan(), defaultComparison)) + { + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(IsDebugMode), 0, global::DotNetCampus.Cli.Compiler.OptionValueType.Boolean); + } + if (longOption.Equals("Count".AsSpan(), defaultComparison)) + { + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(TestCount), 1, global::DotNetCampus.Cli.Compiler.OptionValueType.Normal); + } + if (longOption.Equals("TestName".AsSpan(), defaultComparison)) + { + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(TestName), 2, global::DotNetCampus.Cli.Compiler.OptionValueType.Normal); + } + if (longOption.Equals("TestCategory".AsSpan(), defaultComparison)) + { + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(TestCategory), 3, global::DotNetCampus.Cli.Compiler.OptionValueType.Normal); + } + if (longOption.Equals("DetailLevel".AsSpan(), defaultComparison)) + { + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(DetailLevel), 4, global::DotNetCampus.Cli.Compiler.OptionValueType.Normal); + } + } -```csharp -#nullable enable + return global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch.NotMatch; + } -namespace DotNetCampus.Cli.Compiler -{ - file static class Interceptors + private global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch MatchShortOption(ReadOnlySpan shortOption, bool defaultCaseSensitive) { - /// - /// Interceptor for method. Intercepts to improve performance. - /// - [global::System.Runtime.CompilerServices.InterceptsLocation(1, /* Program.Run4xInterceptor @Program.cs */ "G4GJAK7udHFnPkRUqV6VzzgRAABQcm9ncmFtLmNz")] - [global::System.Runtime.CompilerServices.InterceptsLocation(1, /* Program.Run4xModule @Program.cs */ "G4GJAK7udHFnPkRUqV6VzxkSAABQcm9ncmFtLmNz")] - public static T CommandLine_As_DotNetCampusCliTestsFakesOptions(this global::DotNetCampus.Cli.CommandLine commandLine) - where T : global::DotNetCampus.Cli.Tests.Fakes.Options + // 1. 先快速原字符匹配一遍(能应对规范命令行大小写,并优化 DotNet / GNU 风格的性能)。 + switch (shortOption) + { + // 属性 IsDebugMode 没有短名称,无需匹配。 + case "c": + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(TestCount), 1, global::DotNetCampus.Cli.Compiler.OptionValueType.Normal); + case "n": + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(TestName), 2, global::DotNetCampus.Cli.Compiler.OptionValueType.Normal); + // 属性 TestCategory 没有短名称,无需匹配。 + case "d": + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(DetailLevel), 4, global::DotNetCampus.Cli.Compiler.OptionValueType.Normal); + } + + var defaultComparison = defaultCaseSensitive + ? global::System.StringComparison.Ordinal + : global::System.StringComparison.OrdinalIgnoreCase; + + // 2. 再按指定大小写指定命名法匹配一遍(能应对不规范命令行大小写)。 + // 属性 IsDebugMode 没有短名称,无需匹配。 + if (shortOption.Equals("c".AsSpan(), defaultComparison)) + { + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(TestCount), 1, global::DotNetCampus.Cli.Compiler.OptionValueType.Normal); + } + if (shortOption.Equals("n".AsSpan(), defaultComparison)) + { + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(TestName), 2, global::DotNetCampus.Cli.Compiler.OptionValueType.Normal); + } + // 属性 TestCategory 没有短名称,无需匹配。 + if (shortOption.Equals("d".AsSpan(), defaultComparison)) { - return (T)global::DotNetCampus.Cli.Tests.Fakes.OptionsBuilder.CreateInstance(commandLine); + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(DetailLevel), 4, global::DotNetCampus.Cli.Compiler.OptionValueType.Normal); } + + return global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch.NotMatch; } -} -namespace System.Runtime.CompilerServices -{ - [global::System.AttributeUsage(global::System.AttributeTargets.Method, AllowMultiple = true)] - file sealed class InterceptsLocationAttribute : global::System.Attribute + private global::DotNetCampus.Cli.Utils.Parsers.PositionalArgumentValueMatch MatchPositionalArguments(ReadOnlySpan value, int argumentIndex) + { + // 属性 TestItems 覆盖了所有位置参数,直接匹配。 + return new global::DotNetCampus.Cli.Utils.Parsers.PositionalArgumentValueMatch("TestItems", 5, global::DotNetCampus.Cli.Compiler.PositionalArgumentValueType.Normal); + } + + private void AssignPropertyValue(string propertyName, int propertyIndex, ReadOnlySpan key, ReadOnlySpan value) { - public InterceptsLocationAttribute(int version, string data) + switch (propertyIndex) { - _ = version; - _ = data; + case 0: + IsDebugMode = IsDebugMode.Assign(value); + break; + case 1: + TestCount = TestCount.Assign(value); + break; + case 2: + TestName = TestName.Assign(value); + break; + case 3: + TestCategory = TestCategory.Assign(value); + break; + case 4: + DetailLevel = DetailLevel.Assign(value); + break; + case 5: + TestItems = TestItems.Append(value); + break; } } -} -``` -Assembly command handler collection in code: + private global::DotNetCampus.Cli.Performance.Fakes.BenchmarkOptions41 BuildCore(global::DotNetCampus.Cli.CommandLine commandLine) + { + var result = new global::DotNetCampus.Cli.Performance.Fakes.BenchmarkOptions41 + { + // 1. There is no [RawArguments] property to be initialized. -```csharp -[CollectCommandHandlersFromThisAssembly] -internal partial class AssemblyCommandHandler; -``` + // 2. [Option] + IsDebugMode = IsDebugMode.ToBoolean() ?? throw new global::DotNetCampus.Cli.Exceptions.RequiredPropertyNotAssignedException($"The command line arguments doesn't contain a required option 'debug'. Command line: {commandLine}", "IsDebugMode"), + TestCount = TestCount.ToInt32() ?? throw new global::DotNetCampus.Cli.Exceptions.RequiredPropertyNotAssignedException($"The command line arguments doesn't contain a required option 'count'. Command line: {commandLine}", "TestCount"), -Corresponding generated source: + // 3. [Value] + TestItems = TestItems.ToList() ?? [], + }; -```csharp -#nullable enable -namespace DotNetCampus.Cli.Tests.Fakes; + // 1. There is no [RawArguments] property to be assigned. -/// -/// Provides a way to automatically collect and execute all command line handlers in this assembly. -/// -partial class AssemblyCommandHandler : global::DotNetCampus.Cli.Compiler.ICommandHandlerCollection -{ - public global::DotNetCampus.Cli.ICommandHandler? TryMatch(string? command, global::DotNetCampus.Cli.CommandLine cl) => command switch + // 2. [Option] + if (TestName.ToString() is { } o0) + { + result.TestName = o0; + } + if (TestCategory.ToString() is { } o1) + { + result.TestCategory = o1; + } + if (DetailLevel.ToEnum() is { } o2) + { + result.DetailLevel = o2; + } + + // 3. There is no [Value] property to be assigned. + + return result; + } + + private global::DotNetCampus.Cli.Performance.Fakes.BenchmarkOptions41 BuildDefault() { - null => throw new global::DotNetCampus.Cli.Exceptions.CommandVerbAmbiguityException($"Multiple command handlers match the same command name 'null': AmbiguousOptions, CollectionOptions, ComparedOptions, DefaultCommandHandler, DictionaryOptions, FakeCommandOptions, Options, PrimaryOptions, UnlimitedValueOptions, ValueOptions.", null), - // Type EditOptions does not implement the ICommandHandler interface, so it cannot be dispatched uniformly and must be called by the developer separately. - "Fake" => (global::DotNetCampus.Cli.ICommandHandler)global::DotNetCampus.Cli.Tests.Fakes.FakeCommandHandlerBuilder.CreateInstance(cl), - // Type PrintOptions does not implement the ICommandHandler interface, so it cannot be dispatched uniformly and must be called by the developer separately. - // Type ShareOptions does not implement the ICommandHandler interface, so it cannot be dispatched uniformly and must be called by the developer separately. - _ => null, - }; + throw new global::DotNetCampus.Cli.Exceptions.RequiredPropertyNotAssignedException($"The command line arguments doesn't contain any required option or positional argument. Command line: {commandLine}", null!); + } + + /// + /// Provides parsing and assignment for the enum type . + /// + private readonly record struct __GeneratedEnumArgument__DotNetCampus_Cli_Performance_Fakes_DetailLevel__ + { + /// + /// Indicates whether to ignore exceptions when parsing fails. + /// + public bool IgnoreExceptions { get; init; } + + /// + /// Stores the parsed enum value. + /// + private global::DotNetCampus.Cli.Performance.Fakes.DetailLevel? Value { get; init; } + + /// + /// Assigns a value when a command line input is parsed. + /// + /// The parsed string value. + public __GeneratedEnumArgument__DotNetCampus_Cli_Performance_Fakes_DetailLevel__ Assign(ReadOnlySpan value) + { + Span lowerValue = stackalloc char[value.Length]; + for (var i = 0; i < value.Length; i++) + { + lowerValue[i] = char.ToLowerInvariant(value[i]); + } + global::DotNetCampus.Cli.Performance.Fakes.DetailLevel? newValue = lowerValue switch + { + "low" => global::DotNetCampus.Cli.Performance.Fakes.DetailLevel.Low, + "medium" => global::DotNetCampus.Cli.Performance.Fakes.DetailLevel.Medium, + "high" => global::DotNetCampus.Cli.Performance.Fakes.DetailLevel.High, + _ when IgnoreExceptions => null, + _ => throw new global::DotNetCampus.Cli.Exceptions.CommandLineParseValueException($"Cannot convert '{value.ToString()}' to enum type 'DotNetCampus.Cli.Performance.Fakes.DetailLevel'."), + }; + return this with { Value = newValue }; + } + + /// + /// Converts the parsed value to the enum type. + /// + public global::DotNetCampus.Cli.Performance.Fakes.DetailLevel? ToEnum() => Value; + } } ``` +
+ ## Performance Data -The source code generator implementation provides extremely high command line parsing performance: - -| Method | Mean | Error | StdDev | Median | Gen0 | Gen1 | Allocated | -|---------------------------------------- |----------------:|--------------:|--------------:|----------------:|-------:|-------:|----------:| -| 'parse [] --flexible' | 39.16 ns | 0.402 ns | 0.357 ns | 39.15 ns | 0.0124 | - | 208 B | -| 'parse [] --gnu' | 38.22 ns | 0.518 ns | 0.459 ns | 38.30 ns | 0.0124 | - | 208 B | -| 'parse [] --posix' | 38.45 ns | 0.792 ns | 0.741 ns | 38.45 ns | 0.0124 | - | 208 B | -| 'parse [] --dotnet' | 42.14 ns | 0.878 ns | 2.588 ns | 42.06 ns | 0.0124 | - | 208 B | -| 'parse [] --powershell' | 38.67 ns | 0.772 ns | 1.451 ns | 38.42 ns | 0.0124 | - | 208 B | -| 'parse [] -v=3.x -p=parser' | 44.07 ns | 0.665 ns | 0.841 ns | 44.08 ns | 0.0220 | - | 368 B | -| 'parse [] -v=3.x -p=runtime' | 365.36 ns | 7.186 ns | 13.319 ns | 361.47 ns | 0.0367 | - | 616 B | -| 'parse [PS1] --flexible' | 907.15 ns | 17.887 ns | 38.504 ns | 899.46 ns | 0.1612 | - | 2704 B | -| 'parse [PS1] --dotnet' | 969.51 ns | 18.977 ns | 31.179 ns | 964.56 ns | 0.1612 | - | 2704 B | -| 'parse [PS1] -v=3.x -p=parser' | 448.38 ns | 8.883 ns | 13.830 ns | 445.91 ns | 0.0715 | - | 1200 B | -| 'parse [PS1] -v=3.x -p=runtime' | 835.83 ns | 16.055 ns | 38.774 ns | 830.59 ns | 0.0858 | - | 1448 B | -| 'parse [CMD] --flexible' | 932.31 ns | 18.636 ns | 40.907 ns | 936.14 ns | 0.1612 | - | 2704 B | -| 'parse [CMD] --dotnet' | 877.96 ns | 8.846 ns | 9.832 ns | 877.67 ns | 0.1612 | - | 2704 B | -| 'parse [CMD] -v=3.x -p=parser' | 438.09 ns | 8.591 ns | 11.469 ns | 433.77 ns | 0.0715 | - | 1200 B | -| 'parse [CMD] -v=3.x -p=runtime' | 822.05 ns | 16.417 ns | 25.560 ns | 811.08 ns | 0.0858 | - | 1448 B | -| 'parse [GNU] --flexible' | 880.14 ns | 17.627 ns | 36.794 ns | 878.35 ns | 0.1574 | - | 2648 B | -| 'parse [GNU] --gnu' | 811.59 ns | 13.691 ns | 20.492 ns | 805.61 ns | 0.1554 | - | 2608 B | -| 'parse [GNU] -v=3.x -p=parser' | 492.48 ns | 9.757 ns | 11.615 ns | 491.95 ns | 0.0896 | - | 1512 B | -| 'parse [GNU] -v=3.x -p=runtime' | 873.40 ns | 15.873 ns | 24.713 ns | 865.86 ns | 0.1049 | - | 1760 B | -| 'handle [Edit,Print] --flexible' | 693.30 ns | 13.894 ns | 28.066 ns | 681.77 ns | 0.2375 | 0.0019 | 3984 B | -| 'handle [Edit,Print] -v=3.x -p=parser' | 949.15 ns | 18.959 ns | 25.952 ns | 939.97 ns | 0.2775 | 0.0038 | 4648 B | -| 'handle [Edit,Print] -v=3.x -p=runtime' | 6,232.90 ns | 122.601 ns | 217.924 ns | 6,190.80 ns | 0.2594 | - | 4592 B | -| 'parse [URL]' | 2,942.05 ns | 54.322 ns | 76.152 ns | 2,926.04 ns | 0.4578 | - | 7704 B | -| 'parse [URL] -v=3.x -p=parser' | 121.43 ns | 2.457 ns | 5.496 ns | 121.10 ns | 0.0440 | - | 736 B | -| 'parse [URL] -v=3.x -p=runtime' | 462.92 ns | 9.017 ns | 10.023 ns | 464.26 ns | 0.0587 | - | 984 B | -| 'NuGet: CommandLineParser' | 212,745.53 ns | 4,237.822 ns | 11,384.635 ns | 211,418.82 ns | 5.3711 | - | 90696 B | -| 'NuGet: System.CommandLine' | 1,751,023.59 ns | 34,134.634 ns | 50,034.108 ns | 1,727,339.45 ns | 3.9063 | - | 84138 B | - -Where: -1. `parse` indicates calling the `CommandLine.Parse` method -2. `handle` indicates calling the `CommandLine.AddHandler` method -3. Square brackets `[Xxx]` indicate the style of command-line arguments passed in -4. `--flexible`, `--gnu`, etc. indicate the parser style used when parsing the incoming command line (highest efficiency when matched) -5. `-v=3.x -p=parser` indicates the performance of manually written parsers passed in the old version (best performance, but the old version supports fewer command-line specifications, and many legal command formats are not supported) -6. `-v=3.x -p=runtime` indicates the performance of the old version using the default reflection parser -7. `NuGet: CommandLineParser` and `NuGet: System.CommandLine` indicate the performance when using the corresponding NuGet packages to parse command-line arguments -8. `parse [URL]` indicates the performance when parsing URL protocol strings - -Thanks to source generators and interceptors, the new version: -1. Completes a parsing in about 0.8μs (microseconds) (Benchmark) -2. During application startup, completing one parsing only takes about 34μs -3. During application startup, including dll loading and type initialization, one parsing takes about 8ms (using AOT compilation can reduce it back to 34μs). +Source generator implementation yields very high parsing performance. + +Parsing empty command line arguments: + +| Method | Mean | Error | StdDev | Gen0 | Allocated | +| ----------------------------- | -----------: | ---------: | ---------: | -----: | --------: | +| 'parse [] -v=4.1 -p=flexible' | 27.25 ns | 0.485 ns | 0.454 ns | 0.0143 | 240 B | +| 'parse [] -v=4.1 -p=dotnet' | 27.35 ns | 0.471 ns | 0.440 ns | 0.0143 | 240 B | +| 'parse [] -v=4.0 -p=flexible' | 97.16 ns | 0.708 ns | 0.628 ns | 0.0134 | 224 B | +| 'parse [] -v=4.0 -p=dotnet' | 95.90 ns | 0.889 ns | 0.742 ns | 0.0134 | 224 B | +| 'parse [] -v=3.x -p=parser' | 49.73 ns | 0.931 ns | 0.870 ns | 0.0239 | 400 B | +| 'parse [] -v=3.x -p=runtime' | 19,304.17 ns | 194.337 ns | 162.280 ns | 0.4272 | 7265 B | + +Parsing GNU style command line arguments: + +```bash +test DotNetCampus.CommandLine.Performance.dll DotNetCampus.CommandLine.Sample.dll DotNetCampus.CommandLine.Test.dll -c 20 --test-name BenchmarkTest --detail-level High --debug +``` + +| Method | Runtime | Mean | Error | StdDev | Gen0 | Allocated | +| -------------------------------- | ------------- | -----------: | ----------: | ----------: | -----: | --------: | +| 'parse [GNU] -v=4.1 -p=flexible' | .NET 10.0 | 355.9 ns | 4.89 ns | 4.58 ns | 0.0548 | 920 B | +| 'parse [GNU] -v=4.1 -p=gnu' | .NET 10.0 | 339.7 ns | 6.81 ns | 7.57 ns | 0.0548 | 920 B | +| 'parse [GNU] -v=4.0 -p=flexible' | .NET 10.0 | 945.9 ns | 14.87 ns | 13.19 ns | 0.1583 | 2656 B | +| 'parse [GNU] -v=4.0 -p=gnu' | .NET 10.0 | 882.1 ns | 11.30 ns | 10.57 ns | 0.1631 | 2736 B | +| 'parse [GNU] -v=3.x -p=parser' | .NET 10.0 | 495.7 ns | 9.26 ns | 9.09 ns | 0.1040 | 1752 B | +| 'parse [GNU] -v=3.x -p=runtime' | .NET 10.0 | 18,025.5 ns | 194.73 ns | 162.61 ns | 0.4883 | 8730 B | +| 'NuGet: ConsoleAppFramework' | .NET 10.0 | 134.1 ns | 2.70 ns | 2.65 ns | 0.0215 | 360 B | +| 'NuGet: CommandLineParser' | .NET 10.0 | 177,520.8 ns | 2,225.66 ns | 1,737.65 ns | 3.9063 | 68895 B | +| 'NuGet: System.CommandLine' | .NET 10.0 | 66,581.6 ns | 1,323.17 ns | 3,245.76 ns | 1.0986 | 18505 B | +| 'parse [GNU] -v=4.1 -p=flexible' | NativeAOT 9.0 | 624.3 ns | 7.06 ns | 6.60 ns | 0.0505 | 856 B | +| 'parse [GNU] -v=4.1 -p=gnu' | NativeAOT 9.0 | 600.3 ns | 6.72 ns | 6.28 ns | 0.0505 | 856 B | +| 'parse [GNU] -v=4.0 -p=flexible' | NativeAOT 9.0 | 1,395.6 ns | 20.43 ns | 19.11 ns | 0.1507 | 2529 B | +| 'parse [GNU] -v=4.0 -p=gnu' | NativeAOT 9.0 | 1,438.1 ns | 19.84 ns | 18.55 ns | 0.1545 | 2609 B | +| 'parse [GNU] -v=3.x -p=parser' | NativeAOT 9.0 | 720.8 ns | 7.47 ns | 6.99 ns | 0.1030 | 1737 B | +| 'parse [GNU] -v=3.x -p=runtime' | NativeAOT 9.0 | NA | NA | NA | NA | NA | +| 'NuGet: ConsoleAppFramework' | NativeAOT 9.0 | 195.3 ns | 3.76 ns | 3.69 ns | 0.0234 | 392 B | +| 'NuGet: CommandLineParser' | NativeAOT 9.0 | NA | NA | NA | NA | NA | +| 'NuGet: System.CommandLine' | NativeAOT 9.0 | NA | NA | NA | NA | NA | + +Notes: + +1. `parse` means calling `CommandLine.Parse` +2. `handle` means calling `CommandLine.AddHandler` +3. Brackets `[Xxx]` show the style of passed arguments +4. `--flexible`, `--gnu` etc. indicate parser style used (matching improves efficiency) +5. `-v=3.x -p=parser` shows old manually-written parsers (best performance but limited syntax support) +6. `-v=3.x -p=runtime` shows old reflection-based runtime parser +7. `-v=4.0` vs `-v=4.1` illustrate performance evolution +8. `NuGet: ...` rows show performance of other libraries +9. `parse [URL]` rows (omitted above) indicate URL protocol parsing performance + +Author's perspective (@walterlv): + +1. Fastest library observed is [ConsoleAppFramework](https://github.com/Cysharp/ConsoleAppFramework); ours is close and same order of magnitude. +2. Great thanks to ConsoleAppFramework's pursuit of zero dependencies / allocations / reflection; it motivated the current version (`-v4.1`). +3. ConsoleAppFramework targets extreme performance (sacrificing some syntax breadth). Our goal: full-featured plus high performance—so we sit in the same tier but can't surpass it. Choose based on audience and requirements. + diff --git a/docs/zh-hans/README.md b/docs/zh-hans/README.md index a6994937..ac347842 100644 --- a/docs/zh-hans/README.md +++ b/docs/zh-hans/README.md @@ -31,46 +31,44 @@ class Program 你需要定义一个包含命令行参数映射的类型: ```csharp -class Options +public class Options { - [Value(0)] - public required string FilePath { get; init; } + [Option("debug")] + public required bool IsDebugMode { get; init; } - [Option('s', "silence")] - public bool IsSilence { get; init; } + [Option('c', "count")] + public required int TestCount { get; init; } - [Option('m', "mode")] - public string? StartMode { get; init; } + [Option('n', "test-name")] + public string? TestName { get; set; } - [Option("startup-sessions")] - public IReadOnlyList StartupSessions { get; init; } = []; -} -``` + [Option("test-category")] + public string? TestCategory { get; set; } -然后在命令行中使用不同风格的命令填充这个类型的实例。库支持多种命令行风格: - -### Windows PowerShell 风格 - -```powershell -> demo.exe "C:\Users\lvyi\Desktop\demo.txt" -s -Mode Edit -StartupSessions A B C -``` + [Option('d', "detail-level")] + public DetailLevel DetailLevel { get; set; } = DetailLevel.Medium; -### Windows CMD 风格 + [Value(0, int.MaxValue)] + public IReadOnlyList TestItems { get; init; } = null!; +} -```cmd -> demo.exe "C:\Users\lvyi\Desktop\demo.txt" /s /Mode Edit /StartupSessions A B C +public enum DetailLevel +{ + Low, + Medium, + High, +} ``` -### Linux/GNU 风格 - -```bash -$ demo.exe "C:/Users/lvyi/Desktop/demo.txt" -s --mode Edit --startup-sessions A --startup-sessions B --startup-sessions C -``` +然后在命令行中使用不同风格的命令填充这个类型的实例。库支持多种命令行风格: -### .NET CLI 风格 -``` -> demo.exe "C:\Users\lvyi\Desktop\demo.txt" -s:true --mode:Edit --startup-sessions:A;B;C -``` +| 风格 | 示例 | +| -------------- | ------------------------------------------------------------------------------------------ | +| DotNet | `demo.exe 1.txt 2.txt -c:20 --test-name:BenchmarkTest --detail-level=High --debug` | +| Windows 经典 | `demo.exe 1.txt 2.txt 3.txt -c 20 -TestName BenchmarkTest -DetailLevel High -Debug` | +| CMD | `demo.exe 1.txt 2.txt 3.txt /c 20 /TestName BenchmarkTest /DetailLevel High /Debug` | +| Gnu | `demo.exe 1.txt 2.txt 3.txt -c 20 --test-name BenchmarkTest --detail-level High --debug` | +| 灵活(Flexible) | `demo.exe 1.txt 2.txt 3.txt --count:20 /TestName BenchmarkTest --detail-level=High -Debug` | ## 命令行风格 @@ -83,186 +81,196 @@ var commandLine = CommandLine.Parse(args, CommandLineParsingOptions.DotNet); 支持的风格包括: -- `CommandLineStyle.Flexible`(默认):智能识别多种风格,默认大小写不敏感,是 DotNet/GNU/PowerShell 风格的有效组合 - - 支持前面示例中所有风格的命令行参数,可正确解析 - - 完整支持 DotNet 风格的所有命令行功能(包括列表和字典) - - 支持 GNU 风格中除短名称接参数(如 `-o1.txt`)和短名称缩写(如 `-abc` 表示 `-a -b -c`)外的所有功能 - - 由于 Posix 规则限制严格,Flexible 风格自然兼容 Posix 风格 - - DotNet 风格本身兼容 PowerShell 命令行风格,因此 Flexible 风格也支持 PowerShell 风格 -- `CommandLineStyle.Gnu`:符合 GNU 规范的风格,默认大小写敏感 -- `CommandLineStyle.Posix`:符合 POSIX 规范的风格,默认大小写敏感 -- `CommandLineStyle.DotNet`:.NET CLI 风格,默认大小写不敏感 -- `CommandLineStyle.PowerShell`:PowerShell 风格,默认大小写不敏感 - -## 数据类型支持 - -库支持多种数据类型的解析: - -1. **基本类型**: 字符串、整数、布尔值、枚举等 -2. **集合类型**: 数组、列表、只读集合、不可变集合 -3. **字典类型**: IDictionary、IReadOnlyDictionary、ImmutableDictionary等 - -### 布尔类型选项 - -对于布尔类型的选项,在命令行中有多种指定方式: - -- 仅指定选项名称,表示 `true`:`-s` 或 `--silence` -- 显式指定值:`-s:true`、`-s=false`、`--silence:on`、`--silence=off` - -### 集合类型选项 - -对于集合类型的选项,可以通过多次指定同一选项,或使用分号分隔多个值: - -``` -demo.exe --files file1.txt --files file2.txt -demo.exe --files:file1.txt;file2.txt;file3.txt -``` - -### 字典类型选项 - -对于字典类型的选项,支持多种传入方式: - -``` -demo.exe --properties key1=value1 --properties key2=value2 -demo.exe --properties:key1=value1;key2=value2 -``` - -## 位置参数 - -除了命名选项外,你还可以使用位置参数,通过 `ValueAttribute` 指定参数的位置: +- `CommandLineStyle.Flexible`(默认):灵活风格,在各种风格间提供最大的兼容性,大小写不敏感 +- `CommandLineStyle.DotNet`:.NET CLI 风格,大小写敏感 +- `CommandLineStyle.Gnu`:符合 GNU 规范的风格,大小写敏感 +- `CommandLineStyle.Posix`:符合 POSIX 规范的风格,大小写敏感 +- `CommandLineStyle.Windows`:Windows 经典风格,大小写不敏感,混用 `-` 和 `/` 作为选项前缀 + +默认情况下,这些风格的详细区别如下: + +| 风格 | Flexible | DotNet | Gnu | Posix | Windows | URL | +| ----------------- | -------------- | -------------- | ----------------- | ---------- | ------------ | ----------------- | +| 位置参数 | 支持 | 支持 | 支持 | 支持 | 支持 | 支持 | +| 后置位置参数 `--` | 支持 | 支持 | 支持 | 支持 | 不支持 | 不支持 | +| 大小写 | 不敏感 | 敏感 | 敏感 | 敏感 | 不敏感 | 不敏感 | +| 长选项 | 支持 | 支持 | 支持 | 不支持 | 支持 | 支持 | +| 短选项 | 支持 | 支持 | 支持 | 支持 | 支持 | 不支持 | +| 长选项前缀 | `--` `-` `/` | `--` | `--` | (无) | `-` `/` | | +| 短选项前缀 | `-` `/` | `-` | `-` | `-` | `-` `/` | | +| 长选项 ` ` | --option value | --option value | --option value | | -o value | | +| 长选项 `=` | --option=value | --option=value | --option=value | | -o=value | option=value | +| 长选项 `:` | --option:value | --option:value | | | -o:value | | +| 短选项 ` ` | -o value | -o value | -o value | -o value | -o value | | +| 短选项 `=` | -o=value | -o=value | | | -o=value | option=value | +| 短选项 `:` | -o:value | -o:value | | | -o:value | | +| 短选项 `null` | | | -ovalue | | | | +| 多字符短选项 | -abc value | -abc value | | | -abc value | | +| 长布尔选项 | --option | --option | --option | | -Option | option | +| 长布尔选项 ` ` | --option true | --option true | | | -Option true | | +| 长布尔选项 `=` | --option=true | --option=true | --option=true[^1] | | -Option=true | | +| 长布尔选项 `:` | --option:true | --option:true | | | -Option:true | | +| 短布尔选项 | -o | -o | -o | -o | -o | | +| 短布尔选项 ` ` | -o true | -o true | | | -o true | | +| 短布尔选项 `=` | -o=true | -o=true | | | -o=true | option=true | +| 短布尔选项 `:` | -o:true | -o:true | | | -o:true | | +| 短布尔选项 `null` | | | -o1 | | | | +| 布尔/开关值 | true/false | true/false | true/false | true/false | true/false | true/false | +| 布尔/开关值 | yes/no | yes/no | yes/no | yes/no | yes/no | yes/no | +| 布尔/开关值 | on/off | on/off | on/off | on/off | on/off | on/off | +| 布尔/开关值 | 1/0 | 1/0 | 1/0 | 1/0 | 1/0 | 1/0 | +| 短布尔选项合并 | | | -abc | -abc | | | +| 集合选项 | -o A -o B | -o A -o B | -o A -o B | -o A -o B | -o A -o B | option=A&option=B | +| 集合选项 ` `[^2] | | | | | | | +| 集合选项 `,` | -o A,B,C | -o A,B,C | -o A,B,C | -o A,B,C | -o A,B,C | | +| 集合选项 `;` | -o A;B;C | -o A;B;C | -o A;B;C | -o A;B;C | -o A;B;C | | +| 字典选项 | -o:A=X;B=Y | -o:A=X;B=Y | | | -o:A=X;B=Y | | +| 命名法 | --kebab-case | --kebab-case | --kebab-case | | | kebab-case | +| 命名法 | -PascalCase | | | | -PascalCase | | +| 命名法 | -camelCase | | | | -camelCase | | +| 命名法 | /PascalCase | | | | /PascalCase | | +| 命名法 | /camelCase | | | | /camelCase | | + +[^1]: GNU 风格并不支持布尔选项显式带值,但因为这种情况并没有歧义,所以我们考虑额外支持它。 +[^2]: 所有风格默认都不支持空格分隔集合,以尽可能避免与位置参数的歧义。但如果你需要,可以通过 `CommandLineParsingOptions.Style.SupportsSpaceSeparatedCollectionValues` 启用它。 + +说明: + +1. 除 Windows 风格外,其他风格均支持 `--` 作为后置位置参数的标记,之后的所有参数均视为位置参数;另外,URL 风格写不出来后置位置参数。 +1. 在 `--` 之前,选项和位置参数是可以混合使用的,规则如下。 + +选项会优先取出紧跟着的值,但凡能放入该选项的,均会放入该选项,一旦放不下了,后面如果还有值,就会算作位置参数。 + +例如,`--option` 是个布尔选项时,`--option true text` 或 `--option 1 text` 后面的 `true` 和 `1` 会被 `--option` 选项取走,再后面的 `text` 则是位置参数。 +再例如,`--option` 是个布尔选项时,`--option text` 由于 `text` 不是布尔值,所以 `text` 直接就是位置参数。 +再例如,如果风格支持空格分隔集合(见上表),那么当 `--option a b c` 是个集合选项时,`a` `b` `c` 都会被取走,直到遇到下一个选项或 `--`。GNU 不支持空格分隔集合。 + +## 命名法 + +1. 在代码中定义一个选项时,你应该使用 kebab-case 命名法 + - [为什么要这么做?](https://github.com/dotnet-campus/DotNetCampus.CommandLine/blob/main/docs/analyzers/DCL101.md) + - 如果我们猜测你写的不是 kebab-case 命名法,会提供一个警告 DCL101 + - 但你可以忽略这个警告,无论你最终写了什么字符串,我们都视你写的是 kebab-case 命名法(这可以提供无歧义的命名信息,见下例) +2. 当你在代码中定义了被视为 kebab-case 命名法的字符串后 + - 根据你设置的不同命令行解析风格,你可以使用 kebab-case PascalCase 和 camelCase 三种风格的命名法 + +例如你定义了如下命令行对象: ```csharp -class FileOptions +[Command("open command-line")] +public class Options { - [Value(0)] - public string InputFile { get; init; } - - [Value(1)] - public string OutputFile { get; init; } - - [Option('v', "verbose")] - public bool Verbose { get; init; } + [Option('o', "option-name")] + public required string OptionName { get; init; } } ``` -使用方式: +这里存在两个使用了 kebab-case 命名法的地方,一个是 `Command` 特性,另一个是 `Option` 特性。你可以接受以下这些命令行传入: -``` -demo.exe input.txt output.txt --verbose -``` +- DotNet/Gnu 风格: `demo.exe open command-line --option-name value` +- Windows 风格: `demo.exe Open CommandLine -OptionName value` +- CMD 风格: `demo.exe Open CommandLine /optionName value` -你也可以捕获多个位置参数到一个数组或集合中: +但加入你把这两处的名字都写成其他风格,你可能会获得不太符合预期的结果(当然,也可能你故意如此): ```csharp -class MultiFileOptions +#pragma warning disable DCL101 +[Command("Open CommandLine")] +public class Options { - [Value(0, Length = int.MaxValue)] - public string[] Files { get; init; } = []; + // 此时会有分析器警告,OptionName 不是 kebab-case 风格。如果需要,你可以抑制 DCL101。 + [Option('o', "OptionName")] + public required string OptionName { get; init; } } +#pragma warning restore DCL101 ``` -## 组合使用选项和位置参数 +由于我们视这些都是 kebab-case 风格,所以你将接受以下这些命令行传入(注意 DotNet/Gnu 风格已经发生了变化): -`ValueAttribute` 和 `OptionAttribute` 可以同时应用于同一个属性: +- DotNet/Gnu 风格: `demo.exe Open CommandLine --OptionName value` +- Windows 风格: `demo.exe Open CommandLine -OptionName value` +- CMD 风格: `demo.exe Open CommandLine /optionName value` -```csharp -class Options -{ - [Value(0), Option('f', "file")] - public string FilePath { get; init; } -} -``` +## 数据类型 -这样,以下命令行都会将文件路径赋值给 `FilePath` 属性: +库支持多种数据类型的解析: -``` -demo.exe file.txt -demo.exe -f file.txt -demo.exe --file file.txt -``` +1. **基本类型**: 字符串、整数、布尔值、枚举等 +2. **集合类型**: 数组、列表、只读集合、不可变集合 +3. **字典类型**: `IDictionary`、`IReadOnlyDictionary`、`ImmutableDictionary` 等 -## 必需选项与可选选项 +关于这些类型如何通过命令行传入,请见上表(最详细的那个)。 -在C# 11及以上版本中,可以使用`required`修饰符标记必需的选项: +## 必需选项与默认值 -```csharp -class Options -{ - [Option('i', "input")] - public required string InputFile { get; init; } // 必需选项 - - [Option('o', "output")] - public string? OutputFile { get; init; } // 可选选项 -} -``` +当你定义一个属性的时候,这些标记会影响到默认值: + +1. `required`:标记一个属性是必须的 +1. `init`:标记一个属性是不可变的 +1. `?`:标记一个属性是可空的 +1. 特别的,集合类型也会有特别处理 -如果未提供必需选项,解析时会抛出`RequiredPropertyNotAssignedException`异常。 +这些行为具体以如下表格影响着属性的初值: -## 属性初始值与访问器修饰符 +| required | init | nullable | list | 行为 | 解释 | +| -------- | ---- | -------- | ---- | ----------- | --------------------------------- | +| 1 | _ | _ | _ | 抛异常 | 要求必须传入,没有传就抛异常 | +| 0 | 1 | 1 | _ | null | 可空,没有传就赋值 null | +| 0 | 1 | 0 | 1 | 空集合 | 集合永不为 null,没传就赋值空集合 | +| 0 | 1 | 0 | 0 | 默认值/空值 | 不可空,没有传就赋值默认值[^2] | +| 0 | 0 | _ | _ | 保留初值 | 不要求必须或立即赋值的,保留初值 | -在定义选项类型时,需要注意属性初始值与访问器修饰符(`init`、`required`)之间的关系: +[^2]: 如果是值类型,则会赋值其默认值;如果是引用类型,目前只有一种情况,就是字符串,会赋值为空字符串 `""`。 + +- 1 = 标记了 +- 0 = 没标记 +- _ = 无论有没有标记 + +1. 可空,无论是引用类型还是值类型,其行为完全一致。要硬说不同,就是那个「默认值」会导致引用类型得到 `null`。 +2. 如果未提供必需选项,解析时会抛出`RequiredPropertyNotAssignedException`异常。 +3. 上述行为的「保留初值」的意思是,你可以在定义这个属性的时候写一个初值,就像下面这样: ```csharp -class Options -{ - // 错误示例:当使用 init 或 required 时,默认值将被忽略 - [Option('f', "format")] - public string Format { get; init; } = "json"; // 默认值不会生效! - - // 正确示例:使用 set 以保留默认值 - [Option('f', "format")] - public string Format { get; set; } = "json"; // 默认值会正确保留 -} +// 请注意,这里的初值仅在没有 required 也没有 init 时才生效。 +[Option('o', "option-name")] +public string OptionName { get; set; } = "Default Value" ``` -### 关于属性初始值的重要说明 +## 异常 -1. **使用 `init` 或 `required` 时的行为**: - - 当属性包含 `required` 或 `init` 修饰符时,属性的初始值会被忽略 - - 如果命令行参数中未提供该选项的值,属性将被设置为 `default(T)`(对于引用类型为 `null`) - - 这是由 C# 语言特性决定的,命令行库如果希望突破此限制需要针对所有属性排列组合进行处理,显然是非常浪费的 +命令行库的异常分为以下几种: -2. **保留默认值的方式**: - - 如果需要为属性提供默认值,应使用 `{ get; set; }` 而非 `{ get; init; }` +1. 命令行解析异常 `CommandLineParseException` + - 选项或位置参数未匹配异常 + - 命令行参数格式异常 + - 命令行值转换异常 +2. 命令行对象创建异常 + - 仅此一个 `RequiredPropertyNotAssignedException`,当属性标记了 `required` 而未在命令行中传入时发生异常 +3. 命令与子命令匹配异常 + - 多次匹配异常 `CommandNameAmbiguityException` + - 未匹配异常 `CommandNameNotFoundException` -3. **可空类型与警告处理**: - - 对于非必需的引用类型属性,应将其标记为可空(如 `string?`)以避免可空警告 - - 对于值类型(如 `int`、`bool`),如果想保留默认值而非 `null`,不应将其标记为可空 - -示例: +一个很常见的情况是多个协同工作的应用程序未同步升级时,可能某程序使用了新的命令行选项调用了本程序,本程序当前版本不可能认识这种「下个版本」才会出现的选项。此时有可能需要忽略这种兼容性错误(选项或位置参数未匹配异常)。如果你预感到这种情况会经常发生,你可以忽略这种错误: ```csharp -class OptionsBestPractice +var commandLine = CommandLine.Parse(args, CommandLineParsingOptions.DotNet with { - // 必需选项:使用 required,无需担心默认值 - [Option("input")] - public required string InputFile { get; init; } - - // 可选选项:标记为可空类型以避免警告 - [Option("output")] - public string? OutputFile { get; init; } - - // 需要默认值的选项:使用 set 而非 init - [Option("format")] - public string Format { get; set; } = "json"; - - // 值类型选项:不需要标记为可空 - [Option("count")] - public int Count { get; set; } = 1; -} + // 可以只忽略选项,也可以只忽略位置参数;也可以像这样都忽略。 + UnknownArgumentsHandling = UnknownCommandArgumentHandling.IgnoreAllUnknownArguments, +}); ``` -## 命令处理与命令 +## 命令与子命令 -你可以使用命令处理器模式处理不同的命令,类似于`git commit`、`git push`等。DotNetCampus.CommandLine 提供了多种添加命令处理器的方式: +你可以使用命令处理器模式处理不同的命令,类似于`git commit`、`git remote add`等。DotNetCampus.CommandLine 提供了多种添加命令处理器的方式: ### 1. 使用委托处理命令 最简单的方式是通过委托处理命令,将命令选项类型和处理逻辑分离: ```csharp -var commandLine = CommandLine.Parse(args); -commandLine.AddHandler(options => { /* 处理add命令 */ }) +var commandLine = CommandLine.Parse(args) + .AddHandler(options => { /* 处理add命令 */ }) .AddHandler(options => { /* 处理remove命令 */ }) .Run(); ``` @@ -295,13 +303,13 @@ internal class ConvertCommandHandler : ICommandHandler { [Option('i', "input")] public required string InputFile { get; init; } - + [Option('o', "output")] public string? OutputFile { get; init; } - + [Option('f', "format")] public string Format { get; set; } = "json"; - + public Task RunAsync() { // 实现命令处理逻辑 @@ -315,311 +323,450 @@ internal class ConvertCommandHandler : ICommandHandler 然后直接添加到命令行解析器中: ```csharp -var commandLine = CommandLine.Parse(args); -commandLine.AddHandler() - .Run(); +var commandLine = CommandLine.Parse(args) + .AddHandler() + .AddHandler() + .AddHandler(options => { /* 处理remove命令 */ }) + .RunAsync(); ``` -### 3. 使用程序集自动发现命令处理器 +### 3. 使用 ICommandHandler 接口 -为了更方便地管理大量命令且无需手动逐个添加,可以使用程序集自动发现功能,自动添加程序集中所有实现了 `ICommandHandler` 接口的类: +有时候,程序的状态不完全由命令行确定,程序内部也会有一些状态会影响到命令行处理器的执行。由于我们前面使用 `AddHandler` 没有办法传入任何参数,所以我们还有其他方法传入状态进去: ```csharp -// 定义一个部分类用于标记自动发现命令处理器 -[CollectCommandHandlersFromThisAssembly] -internal partial class AssemblyCommandHandler; - -// 在程序入口添加所有命令处理器 -var commandLine = CommandLine.Parse(args); -commandLine.AddHandlers() - .Run(); +using var scope = serviceProvider.BeginScope(); +var state = scope.ServiceProvider.GetRequiredService(); +var commandLine = CommandLine.Parse(args) + .ForState(state).AddHandler() + .RunAsync(); ``` -通常,处理器类需要添加 `[Command]` 特性并实现 `ICommandHandler` 接口,它就会被自动发现和添加: - ```csharp -[Command("sample")] -internal class SampleCommandHandler : ICommandHandler +internal class CommandHandlerWithState : ICommandHandler { - [Option("SampleProperty")] + [Option('o', "option")] public required string Option { get; init; } - [Value(Length = int.MaxValue)] - public string? Argument { get; init; } - - public Task RunAsync() + public Task RunAsync(MyState state) { - // 实现命令处理逻辑 - return Task.FromResult(0); + // 这时,你可以额外使用这个传入的 state。 } } ``` -此外,你也可以创建一个没有 `[Command]` 特性的命令处理器作为默认处理器。在程序集中最多只能有一个没有 `[Command]` 特性的命令处理器,它将在没有其他命令匹配时被使用: +如果对同一个状态可以执行多个处理器,可以一直链式调用 `AddHandler`;而如果不同的命令处理器要处理不同的状态,可以再次使用 `ForState`;如果后面不再需要状态,则 `ForState` 中不要传入参数。一个更复杂的例子如下: ```csharp -// 没有 [Command] 特性的默认处理器 -internal class DefaultCommandHandler : ICommandHandler -{ - [Option('h', "help")] - public bool ShowHelp { get; init; } - - public Task RunAsync() - { - // 处理默认命令,如显示帮助信息等 - if (ShowHelp) - { - Console.WriteLine("显示帮助信息..."); - } - return Task.FromResult(0); - } -} +commandLine + .AddHandler() + .ForState(state1).AddHandler().AddHandler() + .ForState(state2).AddHandler() + .ForState().AddHandler() + .RunAsync(); ``` -这种方式特别适合大型应用或扩展性强的命令行工具,可以在不修改入口代码的情况下添加新命令。 +### 一些说明 -### 异步命令处理 - -对于需要异步执行的命令处理,可以使用`RunAsync`方法: - -```csharp -await commandLine.AddHandler(async options => -{ - await ImportDataAsync(options); - return 0; -}) -.RunAsync(); -``` +1. `[Command]` 特性支持多个单词,表示子命令,如 `[Command("remote add")]`。 +1. 没有标 `[Command]` 特性,或标了但传 `null` 或空字符串时,表示默认命令,如 `[Command("")]`。 +1. 如果多个命令处理器匹配同一个命令,会抛出 `CommandNameAmbiguityException`。 +1. 命令处理器中,有任何一个是异步时,你将必须使用 `RunAsync` 替代 `Run`,否则会编译不通过。 ## URL协议支持 -DotNetCampus.CommandLine 支持解析 URL 协议字符串: +DotNetCampus.CommandLine 支持解析 URL 协议字符串,格式如下: -``` -dotnet-campus://open/document.txt?readOnly=true&mode=Display&silence=true&startup-sessions=89EA9D26-6464-4E71-BD04-AA6516063D83 +```ini +// scheme://command/subcommand/positional-argument1/positional-argument2?option1=value1&option2=value2 ``` -URL协议解析的特点和用法: +本文开头示例中的那个命令行,使用 URL 传入的话将是下面这样: -1. URL路径部分(如示例中的 `open/document.txt`)会被解析为位置参数或命令加位置参数 - - 路径的第一部分可作为命令(需标记 `[Command]` 特性) - - 随后的路径部分会被解析为位置参数 -2. 查询参数(`?` 后的部分)会被解析为命名选项 -3. 集合类型选项可通过重复参数名传入多个值,如:`tags=csharp&tags=dotnet` -4. URL中的特殊字符和非ASCII字符会自动进行URL解码 - -## 命名约定与最佳实践 - -为确保更好的兼容性和用户体验,我们建议使用 kebab-case 风格命名长选项: - -```csharp -// 推荐 -[Option('o', "output-file")] -public string OutputFile { get; init; } - -// 不推荐 -[Option('o', "OutputFile")] -public string OutputFile { get; init; } +```ini +# `demo.exe 1.txt 2.txt -c:20 --test-name:BenchmarkTest --detail-level=High --debug` +dotnet-campus://1.txt/2.txt?count=20&test-name=BenchmarkTest&detail-level=High&debug ``` -使用kebab-case命名的好处: +特别的: -1. 提供更清晰的单词分割信息(如能猜出"DotNet-Campus"而不是"Dot-Net-Campus") -2. 解决数字从属问题(如"Version2Info"是"Version2-Info"还是"Version-2-Info") -3. 与多种命令行风格更好地兼容 +1. 集合类型选项可通过重复参数名传入多个值,如:`tags=csharp&tags=dotnet` +2. URL中的特殊字符和非 ASCII 字符会自动进行 URL 解码 ## 源生成器、拦截器与性能优化 DotNetCampus.CommandLine 使用源代码生成器技术大幅提升了命令行解析的性能。其中的拦截器([Interceptor](https://github.com/dotnet/roslyn/blob/main/docs/features/interceptors.md))让性能提升发挥得更淋漓尽致。 -### 拦截器的工作原理 - -当你调用 `CommandLine.As()` 或 `CommandLine.AddHandler()` 等方法时,源生成器会自动生成拦截代码,将调用重定向到编译时生成的高性能代码路径。这使得命令行参数解析和对象创建的性能得到了大幅提升。 - -例如,当你编写以下代码时: - -```csharp -var options = CommandLine.Parse(args).As(); -``` - -源生成器会拦截这个调用,自动生成类似以下的代码来替代默认通过字典查找创建器的方式实现(旧版本曾使用过反射): - -```csharp -/// -/// 方法的拦截器。拦截以提高性能。 -/// -[global::System.Runtime.CompilerServices.InterceptsLocation(1, /* Program.Run4xInterceptor @Program.cs */ "G4GJAK7udHFnPkRUqV6VzzgRAABQcm9ncmFtLmNz")] -public static T CommandLine_As_DotNetCampusCliTestsFakesOptions(this global::DotNetCampus.Cli.CommandLine commandLine) - where T : global::DotNetCampus.Cli.Tests.Fakes.Options -{ - return (T)global::DotNetCampus.Cli.Tests.Fakes.OptionsBuilder.CreateInstance(commandLine); -} -``` - ### 源生成器生成的代码示例 下面是一个简单的命令行选项类型及其对应生成的源代码示例: ```csharp // 用户代码中的类型 -internal record DotNet03_MixedOptions +public class BenchmarkOptions41 { - [Option] - public int Number { get; init; } + [Option("debug")] + public required bool IsDebugMode { get; init; } - [Option] - public required string Text { get; init; } + [Option('c', "count")] + public required int TestCount { get; init; } - [Option] - public bool Flag { get; init; } + [Option('n', "test-name")] + public string? TestName { get; set; } + + [Option("test-category")] + public string? TestCategory { get; set; } + + [Option('d', "detail-level")] + public DetailLevel DetailLevel { get; set; } = DetailLevel.Medium; + + [Value(0, int.MaxValue)] + public IReadOnlyList TestItems { get; init; } = null!; } ``` -对应生成的源: +
+ 对应生成的源 ```csharp #nullable enable -namespace DotNetCampus.Cli.Tests; +using global::System; +using global::DotNetCampus.Cli.Compiler; + +namespace DotNetCampus.Cli.Performance.Fakes; /// -/// 辅助 生成命令行选项、命令或处理函数的创建。 +/// 辅助 生成命令行选项、子命令或处理函数的创建。 /// -internal sealed class DotNet03_MixedOptionsBuilder +public sealed class BenchmarkOptions41Builder(global::DotNetCampus.Cli.CommandLine commandLine) { - public static object CreateInstance(global::DotNetCampus.Cli.CommandLine commandLine) + public static readonly global::DotNetCampus.Cli.Compiler.NamingPolicyNameGroup CommandNameGroup = default; + + public static global::DotNetCampus.Cli.Performance.Fakes.BenchmarkOptions41 CreateInstance(global::DotNetCampus.Cli.CommandLine commandLine) + { + return new DotNetCampus.Cli.Performance.Fakes.BenchmarkOptions41Builder(commandLine).Build(); + } + + private global::DotNetCampus.Cli.Compiler.BooleanArgument IsDebugMode = new(); + + private global::DotNetCampus.Cli.Compiler.NumberArgument TestCount = new(); + + private global::DotNetCampus.Cli.Compiler.StringArgument TestName = new(); + + private global::DotNetCampus.Cli.Compiler.StringArgument TestCategory = new(); + + private __GeneratedEnumArgument__DotNetCampus_Cli_Performance_Fakes_DetailLevel__ DetailLevel = new(); + + private global::DotNetCampus.Cli.Compiler.StringListArgument TestItems = new(); + + public global::DotNetCampus.Cli.Performance.Fakes.BenchmarkOptions41 Build() { - var caseSensitive = commandLine.DefaultCaseSensitive; - var result = new global::DotNetCampus.Cli.Tests.DotNet03_MixedOptions + if (commandLine.RawArguments.Count is 0) + { + return BuildDefault(); + } + + var parser = new global::DotNetCampus.Cli.Utils.Parsers.CommandLineParser(commandLine, "BenchmarkOptions41", 0) { - Number = commandLine.GetOption("number") ?? default, - Text = commandLine.GetOption("text") ?? throw new global::DotNetCampus.Cli.Exceptions.RequiredPropertyNotAssignedException($"The command line arguments doesn't contain a required option '--text'. Command line: {commandLine}", "Text"), - Flag = commandLine.GetOption("flag") ?? default, - // There is no positional argument to be initialized. + MatchLongOption = MatchLongOption, + MatchShortOption = MatchShortOption, + MatchPositionalArguments = MatchPositionalArguments, + AssignPropertyValue = AssignPropertyValue, }; - // There is no option to be assigned. - // There is no positional argument to be assigned. - return result; + parser.Parse().WithFallback(commandLine); + return BuildCore(commandLine); } -} -``` - -代码中的方法调用: -```csharp -_ = CommandLine.Parse(args, CommandLineParsingOptions.DotNet).As(); -``` + private global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch MatchLongOption(ReadOnlySpan longOption, bool defaultCaseSensitive, global::DotNetCampus.Cli.CommandNamingPolicy namingPolicy) + { + // 1. 先匹配 kebab-case 命名法(原样字符串) + if (namingPolicy.SupportsOrdinal()) + { + // 1.1 先快速原字符匹配一遍(能应对规范命令行大小写,并优化 DotNet / GNU 风格的性能)。 + switch (longOption) + { + case "debug": + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(IsDebugMode), 0, global::DotNetCampus.Cli.Compiler.OptionValueType.Boolean); + case "count": + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(TestCount), 1, global::DotNetCampus.Cli.Compiler.OptionValueType.Normal); + case "test-name": + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(TestName), 2, global::DotNetCampus.Cli.Compiler.OptionValueType.Normal); + case "test-category": + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(TestCategory), 3, global::DotNetCampus.Cli.Compiler.OptionValueType.Normal); + case "detail-level": + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(DetailLevel), 4, global::DotNetCampus.Cli.Compiler.OptionValueType.Normal); + } + + // 1.2 再按指定大小写匹配一遍(能应对不规范命令行大小写)。 + var defaultComparison = defaultCaseSensitive + ? global::System.StringComparison.Ordinal + : global::System.StringComparison.OrdinalIgnoreCase; + if (longOption.Equals("debug".AsSpan(), defaultComparison)) + { + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(IsDebugMode), 0, global::DotNetCampus.Cli.Compiler.OptionValueType.Boolean); + } + if (longOption.Equals("count".AsSpan(), defaultComparison)) + { + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(TestCount), 1, global::DotNetCampus.Cli.Compiler.OptionValueType.Normal); + } + if (longOption.Equals("test-name".AsSpan(), defaultComparison)) + { + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(TestName), 2, global::DotNetCampus.Cli.Compiler.OptionValueType.Normal); + } + if (longOption.Equals("test-category".AsSpan(), defaultComparison)) + { + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(TestCategory), 3, global::DotNetCampus.Cli.Compiler.OptionValueType.Normal); + } + if (longOption.Equals("detail-level".AsSpan(), defaultComparison)) + { + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(DetailLevel), 4, global::DotNetCampus.Cli.Compiler.OptionValueType.Normal); + } + } -对应生成的源(拦截器): + // 2. 再匹配其他命名法(能应对所有不规范命令行大小写,并支持所有风格)。 + if (namingPolicy.SupportsPascalCase()) + { + var defaultComparison = defaultCaseSensitive + ? global::System.StringComparison.Ordinal + : global::System.StringComparison.OrdinalIgnoreCase; + if (longOption.Equals("Debug".AsSpan(), defaultComparison)) + { + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(IsDebugMode), 0, global::DotNetCampus.Cli.Compiler.OptionValueType.Boolean); + } + if (longOption.Equals("Count".AsSpan(), defaultComparison)) + { + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(TestCount), 1, global::DotNetCampus.Cli.Compiler.OptionValueType.Normal); + } + if (longOption.Equals("TestName".AsSpan(), defaultComparison)) + { + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(TestName), 2, global::DotNetCampus.Cli.Compiler.OptionValueType.Normal); + } + if (longOption.Equals("TestCategory".AsSpan(), defaultComparison)) + { + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(TestCategory), 3, global::DotNetCampus.Cli.Compiler.OptionValueType.Normal); + } + if (longOption.Equals("DetailLevel".AsSpan(), defaultComparison)) + { + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(DetailLevel), 4, global::DotNetCampus.Cli.Compiler.OptionValueType.Normal); + } + } -```csharp -#nullable enable + return global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch.NotMatch; + } -namespace DotNetCampus.Cli.Compiler -{ - file static class Interceptors + private global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch MatchShortOption(ReadOnlySpan shortOption, bool defaultCaseSensitive) { - /// - /// 方法的拦截器。拦截以提高性能。 - /// - [global::System.Runtime.CompilerServices.InterceptsLocation(1, /* Program.Run4xInterceptor @Program.cs */ "G4GJAK7udHFnPkRUqV6VzzgRAABQcm9ncmFtLmNz")] - [global::System.Runtime.CompilerServices.InterceptsLocation(1, /* Program.Run4xModule @Program.cs */ "G4GJAK7udHFnPkRUqV6VzxkSAABQcm9ncmFtLmNz")] - public static T CommandLine_As_DotNetCampusCliTestsFakesOptions(this global::DotNetCampus.Cli.CommandLine commandLine) - where T : global::DotNetCampus.Cli.Tests.Fakes.Options + // 1. 先快速原字符匹配一遍(能应对规范命令行大小写,并优化 DotNet / GNU 风格的性能)。 + switch (shortOption) + { + // 属性 IsDebugMode 没有短名称,无需匹配。 + case "c": + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(TestCount), 1, global::DotNetCampus.Cli.Compiler.OptionValueType.Normal); + case "n": + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(TestName), 2, global::DotNetCampus.Cli.Compiler.OptionValueType.Normal); + // 属性 TestCategory 没有短名称,无需匹配。 + case "d": + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(DetailLevel), 4, global::DotNetCampus.Cli.Compiler.OptionValueType.Normal); + } + + var defaultComparison = defaultCaseSensitive + ? global::System.StringComparison.Ordinal + : global::System.StringComparison.OrdinalIgnoreCase; + + // 2. 再按指定大小写指定命名法匹配一遍(能应对不规范命令行大小写)。 + // 属性 IsDebugMode 没有短名称,无需匹配。 + if (shortOption.Equals("c".AsSpan(), defaultComparison)) { - return (T)global::DotNetCampus.Cli.Tests.Fakes.OptionsBuilder.CreateInstance(commandLine); + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(TestCount), 1, global::DotNetCampus.Cli.Compiler.OptionValueType.Normal); } + if (shortOption.Equals("n".AsSpan(), defaultComparison)) + { + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(TestName), 2, global::DotNetCampus.Cli.Compiler.OptionValueType.Normal); + } + // 属性 TestCategory 没有短名称,无需匹配。 + if (shortOption.Equals("d".AsSpan(), defaultComparison)) + { + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(DetailLevel), 4, global::DotNetCampus.Cli.Compiler.OptionValueType.Normal); + } + + return global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch.NotMatch; } -} -namespace System.Runtime.CompilerServices -{ - [global::System.AttributeUsage(global::System.AttributeTargets.Method, AllowMultiple = true)] - file sealed class InterceptsLocationAttribute : global::System.Attribute + private global::DotNetCampus.Cli.Utils.Parsers.PositionalArgumentValueMatch MatchPositionalArguments(ReadOnlySpan value, int argumentIndex) + { + // 属性 TestItems 覆盖了所有位置参数,直接匹配。 + return new global::DotNetCampus.Cli.Utils.Parsers.PositionalArgumentValueMatch("TestItems", 5, global::DotNetCampus.Cli.Compiler.PositionalArgumentValueType.Normal); + } + + private void AssignPropertyValue(string propertyName, int propertyIndex, ReadOnlySpan key, ReadOnlySpan value) { - public InterceptsLocationAttribute(int version, string data) + switch (propertyIndex) { - _ = version; - _ = data; + case 0: + IsDebugMode = IsDebugMode.Assign(value); + break; + case 1: + TestCount = TestCount.Assign(value); + break; + case 2: + TestName = TestName.Assign(value); + break; + case 3: + TestCategory = TestCategory.Assign(value); + break; + case 4: + DetailLevel = DetailLevel.Assign(value); + break; + case 5: + TestItems = TestItems.Append(value); + break; } } -} -``` -代码中的程序集命令处理器搜集: + private global::DotNetCampus.Cli.Performance.Fakes.BenchmarkOptions41 BuildCore(global::DotNetCampus.Cli.CommandLine commandLine) + { + var result = new global::DotNetCampus.Cli.Performance.Fakes.BenchmarkOptions41 + { + // 1. There is no [RawArguments] property to be initialized. -```csharp -[CollectCommandHandlersFromThisAssembly] -internal partial class AssemblyCommandHandler; -``` + // 2. [Option] + IsDebugMode = IsDebugMode.ToBoolean() ?? throw new global::DotNetCampus.Cli.Exceptions.RequiredPropertyNotAssignedException($"The command line arguments doesn't contain a required option 'debug'. Command line: {commandLine}", "IsDebugMode"), + TestCount = TestCount.ToInt32() ?? throw new global::DotNetCampus.Cli.Exceptions.RequiredPropertyNotAssignedException($"The command line arguments doesn't contain a required option 'count'. Command line: {commandLine}", "TestCount"), -对应生成的源: + // 3. [Value] + TestItems = TestItems.ToList() ?? [], + }; -```csharp -#nullable enable -namespace DotNetCampus.Cli.Tests.Fakes; + // 1. There is no [RawArguments] property to be assigned. -/// -/// 提供一种辅助自动搜集并执行本程序集中所有命令行处理器的方式。 -/// -partial class AssemblyCommandHandler : global::DotNetCampus.Cli.Compiler.ICommandHandlerCollection -{ - public global::DotNetCampus.Cli.ICommandHandler? TryMatch(string? command, global::DotNetCampus.Cli.CommandLine cl) => command switch + // 2. [Option] + if (TestName.ToString() is { } o0) + { + result.TestName = o0; + } + if (TestCategory.ToString() is { } o1) + { + result.TestCategory = o1; + } + if (DetailLevel.ToEnum() is { } o2) + { + result.DetailLevel = o2; + } + + // 3. There is no [Value] property to be assigned. + + return result; + } + + private global::DotNetCampus.Cli.Performance.Fakes.BenchmarkOptions41 BuildDefault() + { + throw new global::DotNetCampus.Cli.Exceptions.RequiredPropertyNotAssignedException($"The command line arguments doesn't contain any required option or positional argument. Command line: {commandLine}", null!); + } + + /// + /// Provides parsing and assignment for the enum type . + /// + private readonly record struct __GeneratedEnumArgument__DotNetCampus_Cli_Performance_Fakes_DetailLevel__ { - null => throw new global::DotNetCampus.Cli.Exceptions.CommandVerbAmbiguityException($"Multiple command handlers match the same command name 'null': AmbiguousOptions, CollectionOptions, ComparedOptions, DefaultCommandHandler, DictionaryOptions, FakeCommandOptions, Options, PrimaryOptions, UnlimitedValueOptions, ValueOptions.", null), - // 类型 EditOptions 没有继承 ICommandHandler 接口,因此无法统一调度执行,只能由开发者单独调用。 - "Fake" => (global::DotNetCampus.Cli.ICommandHandler)global::DotNetCampus.Cli.Tests.Fakes.FakeCommandHandlerBuilder.CreateInstance(cl), - // 类型 PrintOptions 没有继承 ICommandHandler 接口,因此无法统一调度执行,只能由开发者单独调用。 - // 类型 ShareOptions 没有继承 ICommandHandler 接口,因此无法统一调度执行,只能由开发者单独调用。 - _ => null, - }; + /// + /// Indicates whether to ignore exceptions when parsing fails. + /// + public bool IgnoreExceptions { get; init; } + + /// + /// Stores the parsed enum value. + /// + private global::DotNetCampus.Cli.Performance.Fakes.DetailLevel? Value { get; init; } + + /// + /// Assigns a value when a command line input is parsed. + /// + /// The parsed string value. + public __GeneratedEnumArgument__DotNetCampus_Cli_Performance_Fakes_DetailLevel__ Assign(ReadOnlySpan value) + { + Span lowerValue = stackalloc char[value.Length]; + for (var i = 0; i < value.Length; i++) + { + lowerValue[i] = char.ToLowerInvariant(value[i]); + } + global::DotNetCampus.Cli.Performance.Fakes.DetailLevel? newValue = lowerValue switch + { + "low" => global::DotNetCampus.Cli.Performance.Fakes.DetailLevel.Low, + "medium" => global::DotNetCampus.Cli.Performance.Fakes.DetailLevel.Medium, + "high" => global::DotNetCampus.Cli.Performance.Fakes.DetailLevel.High, + _ when IgnoreExceptions => null, + _ => throw new global::DotNetCampus.Cli.Exceptions.CommandLineParseValueException($"Cannot convert '{value.ToString()}' to enum type 'DotNetCampus.Cli.Performance.Fakes.DetailLevel'."), + }; + return this with { Value = newValue }; + } + + /// + /// Converts the parsed value to the enum type. + /// + public global::DotNetCampus.Cli.Performance.Fakes.DetailLevel? ToEnum() => Value; + } } ``` +
+ ## 性能数据 -源代码生成器实现提供了极高的命令行解析性能: - -| Method | Mean | Error | StdDev | Median | Gen0 | Gen1 | Allocated | -|---------------------------------------- |----------------:|--------------:|--------------:|----------------:|-------:|-------:|----------:| -| 'parse [] --flexible' | 39.16 ns | 0.402 ns | 0.357 ns | 39.15 ns | 0.0124 | - | 208 B | -| 'parse [] --gnu' | 38.22 ns | 0.518 ns | 0.459 ns | 38.30 ns | 0.0124 | - | 208 B | -| 'parse [] --posix' | 38.45 ns | 0.792 ns | 0.741 ns | 38.45 ns | 0.0124 | - | 208 B | -| 'parse [] --dotnet' | 42.14 ns | 0.878 ns | 2.588 ns | 42.06 ns | 0.0124 | - | 208 B | -| 'parse [] --powershell' | 38.67 ns | 0.772 ns | 1.451 ns | 38.42 ns | 0.0124 | - | 208 B | -| 'parse [] -v=3.x -p=parser' | 44.07 ns | 0.665 ns | 0.841 ns | 44.08 ns | 0.0220 | - | 368 B | -| 'parse [] -v=3.x -p=runtime' | 365.36 ns | 7.186 ns | 13.319 ns | 361.47 ns | 0.0367 | - | 616 B | -| 'parse [PS1] --flexible' | 907.15 ns | 17.887 ns | 38.504 ns | 899.46 ns | 0.1612 | - | 2704 B | -| 'parse [PS1] --dotnet' | 969.51 ns | 18.977 ns | 31.179 ns | 964.56 ns | 0.1612 | - | 2704 B | -| 'parse [PS1] -v=3.x -p=parser' | 448.38 ns | 8.883 ns | 13.830 ns | 445.91 ns | 0.0715 | - | 1200 B | -| 'parse [PS1] -v=3.x -p=runtime' | 835.83 ns | 16.055 ns | 38.774 ns | 830.59 ns | 0.0858 | - | 1448 B | -| 'parse [CMD] --flexible' | 932.31 ns | 18.636 ns | 40.907 ns | 936.14 ns | 0.1612 | - | 2704 B | -| 'parse [CMD] --dotnet' | 877.96 ns | 8.846 ns | 9.832 ns | 877.67 ns | 0.1612 | - | 2704 B | -| 'parse [CMD] -v=3.x -p=parser' | 438.09 ns | 8.591 ns | 11.469 ns | 433.77 ns | 0.0715 | - | 1200 B | -| 'parse [CMD] -v=3.x -p=runtime' | 822.05 ns | 16.417 ns | 25.560 ns | 811.08 ns | 0.0858 | - | 1448 B | -| 'parse [GNU] --flexible' | 880.14 ns | 17.627 ns | 36.794 ns | 878.35 ns | 0.1574 | - | 2648 B | -| 'parse [GNU] --gnu' | 811.59 ns | 13.691 ns | 20.492 ns | 805.61 ns | 0.1554 | - | 2608 B | -| 'parse [GNU] -v=3.x -p=parser' | 492.48 ns | 9.757 ns | 11.615 ns | 491.95 ns | 0.0896 | - | 1512 B | -| 'parse [GNU] -v=3.x -p=runtime' | 873.40 ns | 15.873 ns | 24.713 ns | 865.86 ns | 0.1049 | - | 1760 B | -| 'handle [Edit,Print] --flexible' | 693.30 ns | 13.894 ns | 28.066 ns | 681.77 ns | 0.2375 | 0.0019 | 3984 B | -| 'handle [Edit,Print] -v=3.x -p=parser' | 949.15 ns | 18.959 ns | 25.952 ns | 939.97 ns | 0.2775 | 0.0038 | 4648 B | -| 'handle [Edit,Print] -v=3.x -p=runtime' | 6,232.90 ns | 122.601 ns | 217.924 ns | 6,190.80 ns | 0.2594 | - | 4592 B | -| 'parse [URL]' | 2,942.05 ns | 54.322 ns | 76.152 ns | 2,926.04 ns | 0.4578 | - | 7704 B | -| 'parse [URL] -v=3.x -p=parser' | 121.43 ns | 2.457 ns | 5.496 ns | 121.10 ns | 0.0440 | - | 736 B | -| 'parse [URL] -v=3.x -p=runtime' | 462.92 ns | 9.017 ns | 10.023 ns | 464.26 ns | 0.0587 | - | 984 B | -| 'NuGet: CommandLineParser' | 212,745.53 ns | 4,237.822 ns | 11,384.635 ns | 211,418.82 ns | 5.3711 | - | 90696 B | -| 'NuGet: System.CommandLine' | 1,751,023.59 ns | 34,134.634 ns | 50,034.108 ns | 1,727,339.45 ns | 3.9063 | - | 84138 B | +源代码生成器实现提供了极高的命令行解析性能。 + +解析空白命令行参数: + +| Method | Mean | Error | StdDev | Gen0 | Allocated | +| ----------------------------- | -----------: | ---------: | ---------: | -----: | --------: | +| 'parse [] -v=4.1 -p=flexible' | 27.25 ns | 0.485 ns | 0.454 ns | 0.0143 | 240 B | +| 'parse [] -v=4.1 -p=dotnet' | 27.35 ns | 0.471 ns | 0.440 ns | 0.0143 | 240 B | +| 'parse [] -v=4.0 -p=flexible' | 97.16 ns | 0.708 ns | 0.628 ns | 0.0134 | 224 B | +| 'parse [] -v=4.0 -p=dotnet' | 95.90 ns | 0.889 ns | 0.742 ns | 0.0134 | 224 B | +| 'parse [] -v=3.x -p=parser' | 49.73 ns | 0.931 ns | 0.870 ns | 0.0239 | 400 B | +| 'parse [] -v=3.x -p=runtime' | 19,304.17 ns | 194.337 ns | 162.280 ns | 0.4272 | 7265 B | + +解析 GNU 风格命令行参数: + +```bash +test DotNetCampus.CommandLine.Performance.dll DotNetCampus.CommandLine.Sample.dll DotNetCampus.CommandLine.Test.dll -c 20 --test-name BenchmarkTest --detail-level High --debug +``` + +| Method | Runtime | Mean | Error | StdDev | Gen0 | Allocated | +| -------------------------------- | ------------- | -----------: | ----------: | ----------: | -----: | --------: | +| 'parse [GNU] -v=4.1 -p=flexible' | .NET 10.0 | 355.9 ns | 4.89 ns | 4.58 ns | 0.0548 | 920 B | +| 'parse [GNU] -v=4.1 -p=gnu' | .NET 10.0 | 339.7 ns | 6.81 ns | 7.57 ns | 0.0548 | 920 B | +| 'parse [GNU] -v=4.0 -p=flexible' | .NET 10.0 | 945.9 ns | 14.87 ns | 13.19 ns | 0.1583 | 2656 B | +| 'parse [GNU] -v=4.0 -p=gnu' | .NET 10.0 | 882.1 ns | 11.30 ns | 10.57 ns | 0.1631 | 2736 B | +| 'parse [GNU] -v=3.x -p=parser' | .NET 10.0 | 495.7 ns | 9.26 ns | 9.09 ns | 0.1040 | 1752 B | +| 'parse [GNU] -v=3.x -p=runtime' | .NET 10.0 | 18,025.5 ns | 194.73 ns | 162.61 ns | 0.4883 | 8730 B | +| 'NuGet: ConsoleAppFramework' | .NET 10.0 | 134.1 ns | 2.70 ns | 2.65 ns | 0.0215 | 360 B | +| 'NuGet: CommandLineParser' | .NET 10.0 | 177,520.8 ns | 2,225.66 ns | 1,737.65 ns | 3.9063 | 68895 B | +| 'NuGet: System.CommandLine' | .NET 10.0 | 66,581.6 ns | 1,323.17 ns | 3,245.76 ns | 1.0986 | 18505 B | +| 'parse [GNU] -v=4.1 -p=flexible' | NativeAOT 9.0 | 624.3 ns | 7.06 ns | 6.60 ns | 0.0505 | 856 B | +| 'parse [GNU] -v=4.1 -p=gnu' | NativeAOT 9.0 | 600.3 ns | 6.72 ns | 6.28 ns | 0.0505 | 856 B | +| 'parse [GNU] -v=4.0 -p=flexible' | NativeAOT 9.0 | 1,395.6 ns | 20.43 ns | 19.11 ns | 0.1507 | 2529 B | +| 'parse [GNU] -v=4.0 -p=gnu' | NativeAOT 9.0 | 1,438.1 ns | 19.84 ns | 18.55 ns | 0.1545 | 2609 B | +| 'parse [GNU] -v=3.x -p=parser' | NativeAOT 9.0 | 720.8 ns | 7.47 ns | 6.99 ns | 0.1030 | 1737 B | +| 'parse [GNU] -v=3.x -p=runtime' | NativeAOT 9.0 | NA | NA | NA | NA | NA | +| 'NuGet: ConsoleAppFramework' | NativeAOT 9.0 | 195.3 ns | 3.76 ns | 3.69 ns | 0.0234 | 392 B | +| 'NuGet: CommandLineParser' | NativeAOT 9.0 | NA | NA | NA | NA | NA | +| 'NuGet: System.CommandLine' | NativeAOT 9.0 | NA | NA | NA | NA | NA | 其中: + 1. `parse` 表示调用的是 `CommandLine.Parse` 方法 -2. `handle` 表示调用的是 `CommandLine.AddHandler` 方法 -3. 中括号 `[Xxx]` 表示传入的命令行参数的风格 -4. `--flexible` `--gnu` 等表示解析传入命令行时所使用的解析器风格(相匹配时效率最高) -5. `-v=3.x -p=parser` 表示旧版本手工编写解析器并传入时的性能(性能最好,不过旧版本支持的命令行规范较少,很多合法的命令写法并不支持) -6. `-v=3.x -p=runtime` 表示旧版本使用默认的反射解析器时的性能 -7. `NuGet: CommandLineParser` 和 `NuGet: System.CommandLine` 表示使用对应名称的 NuGet 包解析命令行参数时的性能 -8. `parse [URL]` 表示解析 URL 协议字符串时的性能 - -新版本得益于源生成器和拦截器: -1. 完成一次解析大约在 0.8μs(微秒)左右(Benchmark) -2. 在应用程序启动期间,完成一次解析只需要大约 34μs -3. 在应用程序启动期间,包含dll加载、类型初始化在内的解析一次大约8ms(使用 AOT 编译能重新降至 34μs)。 +1. `handle` 表示调用的是 `CommandLine.AddHandler` 方法 +1. 中括号 `[Xxx]` 表示传入的命令行参数的风格 +1. `--flexible` `--gnu` 等表示解析传入命令行时所使用的解析器风格(相匹配时效率最高) +1. `-v=3.x -p=parser` 表示旧版本手工编写解析器并传入时的性能(性能最好,不过旧版本支持的命令行规范较少,很多合法的命令写法并不支持) +1. `-v=3.x -p=runtime` 表示旧版本使用默认的反射解析器时的性能 +1. `-v=4.0 -p=dotnet` 表示数月前的 4.0 预览版的性能 +1. `-v=4.1 -p=dotnet` 表示当前版本的性能 +1. `NuGet: ConsoleAppFramework`、`NuGet: CommandLineParser` 和 `NuGet: System.CommandLine` 表示使用对应名称的 NuGet 包解析命令行参数时的性能 +1. `parse [URL]` 表示解析 URL 协议字符串时的性能 + +库作者 @walterlv 的感受: + +1. 性能最好的是 [ConsoleAppFramework](https://github.com/Cysharp/ConsoleAppFramework) 库,我们的 [DotNetCampus.CommandLine](https://github.com/dotnet-campus/DotNetCampus.CommandLine) 比它差一点,不过仍然在同一个数量级。对比其他库,我们俩比他们好了几个数量级。 +1. 非常感谢 [ConsoleAppFramework](https://github.com/Cysharp/ConsoleAppFramework) 的极致追求(零依赖、零开销、零反射、零分配,由 C# 源码生成器提供的 AOT 安全 CLI 框架)。虽然在发现它之前我们就已经在使用源生成器和拦截器了(`-v4.0`),但确实是它让我们看到了更高的目标和动力,写了现在的版本(`-v4.1`)。 +1. 当然,[ConsoleAppFramework](https://github.com/Cysharp/ConsoleAppFramework) 的目标是极致的性能追求,为此确实也牺牲了一部分命令行语法支持;而我们的目标是在「全功能」的基础上实现极致的性能追求,所以性能最多只能打在同一级别,确实也无法超越它。如果你的程序极致追求性能,并且使用人群倾向于专业人士或应用程序,则非常推荐使用它;不过如果你希望你的程序极致追求性能的同时,也面向大众群体(非专业人士)或各种不同喜好的人群体,则非常推荐使用我们 [DotNetCampus.CommandLine](https://github.com/dotnet-campus/DotNetCampus.CommandLine)。 diff --git a/docs/zh-hant/README.md b/docs/zh-hant/README.md index 0b8e95a3..beda4a2e 100644 --- a/docs/zh-hant/README.md +++ b/docs/zh-hant/README.md @@ -1,5 +1,4 @@ - -# 命令行解析 +# 命令列解析 | [English][en] | [简体中文][zh-hans] | [繁體中文][zh-hant] | | ------------- | ------------------- | ------------------- | @@ -8,7 +7,7 @@ [zh-hans]: /docs/zh-hans/README.md [zh-hant]: /docs/zh-hant/README.md -DotNetCampus.CommandLine 提供了簡單而高性能的命令行解析功能,得益於源代碼生成器的加持,它現在提供了更高效的解析能力和更友好的開發體驗。所有功能都位於 DotNetCampus.Cli 命名空間下。 +DotNetCampus.CommandLine 提供簡單且高效能的命令列解析功能。得益於原始碼產生器(以及攔截器),它現在提供更高效率的解析能力與更友善的開發體驗。所有功能均位於 `DotNetCampus.Cli` 命名空間下。 ## 快速使用 @@ -17,259 +16,262 @@ class Program { static void Main(string[] args) { - // 從命令行參數創建一個 CommandLine 類型的新實例 + // 從命令列參數建立一個新的 CommandLine 執行個體 var commandLine = CommandLine.Parse(args); - // 將命令行解析為 Options 類型的實例 - // 源生成器會自動為你處理解析過程,無需手動創建解析器 + // 將命令列解析為 Options 型別的執行個體 + // 原始碼產生器會自動處理解析過程,無需手動建立解析器 var options = commandLine.As(); - // 接下來,使用你的 options 對象編寫其他的功能 + // 接下來,使用 options 物件撰寫其他功能 } } ``` -你需要定義一個包含命令行參數映射的類型: +你需要定義一個包含命令列參數對應的型別: ```csharp -class Options +public class Options { - [Value(0)] - public required string FilePath { get; init; } - - [Option('s', "silence")] - public bool IsSilence { get; init; } - - [Option('m', "mode")] - public string? StartMode { get; init; } + [Option("debug")] + public required bool IsDebugMode { get; init; } - [Option("startup-sessions")] - public IReadOnlyList StartupSessions { get; init; } = []; -} -``` + [Option('c', "count")] + public required int TestCount { get; init; } -然後在命令行中使用不同風格的命令填充這個類型的實例。庫支持多種命令行風格: + [Option('n', "test-name")] + public string? TestName { get; set; } -### Windows PowerShell 風格 + [Option("test-category")] + public string? TestCategory { get; set; } -```powershell -> demo.exe "C:\Users\lvyi\Desktop\demo.txt" -s -Mode Edit -StartupSessions A B C -``` + [Option('d', "detail-level")] + public DetailLevel DetailLevel { get; set; } = DetailLevel.Medium; -### Windows CMD 風格 + [Value(0, int.MaxValue)] + public IReadOnlyList TestItems { get; init; } = null!; +} -```cmd -> demo.exe "C:\Users\lvyi\Desktop\demo.txt" /s /Mode Edit /StartupSessions A B C +public enum DetailLevel +{ + Low, + Medium, + High, +} ``` -### Linux/GNU 風格 - -```bash -$ demo.exe "C:/Users/lvyi/Desktop/demo.txt" -s --mode Edit --startup-sessions A --startup-sessions B --startup-sessions C -``` +然後在命令列中使用不同風格的命令填充這個型別的執行個體。程式庫支援多種命令列風格: -### .NET CLI 風格 -``` -> demo.exe "C:\Users\lvyi\Desktop\demo.txt" -s:true --mode:Edit --startup-sessions:A;B;C -``` +| 風格 | 範例 | +| --------------- | ------------------------------------------------------------------------------------------ | +| DotNet | `demo.exe 1.txt 2.txt -c:20 --test-name:BenchmarkTest --detail-level=High --debug` | +| Windows 经典 | `demo.exe 1.txt 2.txt 3.txt -c 20 -TestName BenchmarkTest -DetailLevel High -Debug` | +| CMD | `demo.exe 1.txt 2.txt 3.txt /c 20 /TestName BenchmarkTest /DetailLevel High /Debug` | +| Gnu | `demo.exe 1.txt 2.txt 3.txt -c 20 --test-name BenchmarkTest --detail-level High --debug` | +| 彈性 (Flexible) | `demo.exe 1.txt 2.txt 3.txt --count:20 /TestName BenchmarkTest --detail-level=High -Debug` | -## 命令行風格 +## 命令列風格 -DotNetCampus.CommandLine 支持多種命令行風格,你可以在解析時指定使用哪種風格: +DotNetCampus.CommandLine 支援多種命令列風格,你可以在解析時指定使用哪種風格: ```csharp -// 使用 .NET CLI 風格解析命令行參數 +// 使用 .NET CLI 風格解析命令列參數 var commandLine = CommandLine.Parse(args, CommandLineParsingOptions.DotNet); ``` -支持的風格包括: - -- `CommandLineStyle.Flexible`(默認):智能識別多種風格,默認大小寫不敏感,是 DotNet/GNU/PowerShell 風格的有效組合 - - 支持前面示例中所有風格的命令行參數,可正確解析 - - 完整支持 DotNet 風格的所有命令行功能(包括列表和字典) - - 支持 GNU 風格中除短名稱接參數(如 `-o1.txt`)和短名稱縮寫(如 `-abc` 表示 `-a -b -c`)外的所有功能 - - 由於 Posix 規則限制嚴格,Flexible 風格自然兼容 Posix 風格 - - DotNet 風格本身兼容 PowerShell 命令行風格,因此 Flexible 風格也支持 PowerShell 風格 -- `CommandLineStyle.Gnu`:符合 GNU 規範的風格,默認大小寫敏感 -- `CommandLineStyle.Posix`:符合 POSIX 規範的風格,默認大小寫敏感 -- `CommandLineStyle.DotNet`:.NET CLI 風格,默認大小寫不敏感 -- `CommandLineStyle.PowerShell`:PowerShell 風格,默認大小寫不敏感 - -## 數據類型支持 - -庫支持多種數據類型的解析: - -1. **基本類型**: 字符串、整數、布爾值、枚舉等 -2. **集合類型**: 數組、列表、只讀集合、不可變集合 -3. **字典類型**: IDictionary、IReadOnlyDictionary、ImmutableDictionary等 - -### 布爾類型選項 - -對於布爾類型的選項,在命令行中有多種指定方式: - -- 僅指定選項名稱,表示 `true`:`-s` 或 `--silence` -- 顯式指定值:`-s:true`、`-s=false`、`--silence:on`、`--silence=off` - -### 集合類型選項 - -對於集合類型的選項,可以通過多次指定同一選項,或使用分號分隔多個值: - -``` -demo.exe --files file1.txt --files file2.txt -demo.exe --files:file1.txt;file2.txt;file3.txt -``` - -### 字典類型選項 - -對於字典類型的選項,支持多種傳入方式: - -``` -demo.exe --properties key1=value1 --properties key2=value2 -demo.exe --properties:key1=value1;key2=value2 -``` - -## 位置參數 - -除了命名選項外,你還可以使用位置參數,通過 `ValueAttribute` 指定參數的位置: +支援的風格包含: + +- `CommandLineStyle.Flexible`(預設):彈性風格,於各種風格間提供最大相容性,大小寫不敏感 +- `CommandLineStyle.DotNet`:.NET CLI 風格,大小寫敏感 +- `CommandLineStyle.Gnu`:符合 GNU 規範,大小寫敏感 +- `CommandLineStyle.Posix`:符合 POSIX 規範,大小寫敏感 +- `CommandLineStyle.Windows`:Windows 風格,大小寫不敏感,混用 `-` 和 `/` 作为选项前缀 + +預設情況下,這些風格的詳細差異如下: + +| 風格 | Flexible | DotNet | Gnu | Posix | Windows | URL | +| ----------------- | -------------- | -------------- | ----------------- | ---------- | ------------ | ----------------- | +| 位置參數 | 支援 | 支援 | 支援 | 支援 | 支援 | 支援 | +| 後置位置參數 `--` | 支援 | 支援 | 支援 | 支援 | 不支援 | 不支援 | +| 大小寫 | 不敏感 | 敏感 | 敏感 | 敏感 | 不敏感 | 不敏感 | +| 長選項 | 支援 | 支援 | 支援 | 不支援 | 支援 | 支援 | +| 短選項 | 支援 | 支援 | 支援 | 支援 | 支援 | 不支援 | +| 長選項前綴 | `--` `-` `/` | `--` | `--` | (無) | `-` `/` | | +| 短選項前綴 | `-` `/` | `-` | `-` | `-` | `-` `/` | | +| 長選項 ` ` | --option value | --option value | --option value | -o value | -o value | | +| 長選項 `=` | --option=value | --option=value | --option=value | | -o=value | option=value | +| 長選項 `:` | --option:value | --option:value | | | -o:value | | +| 短選項 ` ` | -o value | -o value | -o value | -o value | -o value | | +| 短選項 `=` | -o=value | -o=value | | | -o=value | option=value | +| 短選項 `:` | -o:value | -o:value | | | -o:value | | +| 短選項 `null` | | | -ovalue | | | | +| 多字元短選項 | -abc value | -abc value | | | -abc value | | +| 長布林選項 | --option | --option | --option | | -Option | option | +| 長布林選項 ` ` | --option true | --option true | | | -Option true | | +| 長布林選項 `=` | --option=true | --option=true | --option=true[^1] | | -Option=true | | +| 長布林選項 `:` | --option:true | --option:true | | | -Option:true | | +| 短布林選項 | -o | -o | -o | -o | -o | | +| 短布林選項 ` ` | -o true | -o true | | | -o true | | +| 短布林選項 `=` | -o=true | -o=true | | | -o=true | option=true | +| 短布林選項 `:` | -o:true | -o:true | | | -o:true | | +| 短布林選項 `null` | | | -o1 | | | | +| 布林/開關值 | true/false | true/false | true/false | true/false | true/false | true/false | +| 布林/開關值 | yes/no | yes/no | yes/no | yes/no | yes/no | yes/no | +| 布林/開關值 | on/off | on/off | on/off | on/off | on/off | on/off | +| 布林/開關值 | 1/0 | 1/0 | 1/0 | 1/0 | 1/0 | 1/0 | +| 多短布林合併 | | | -abc | -abc | | | +| 集合選項 | -o A -o B | -o A -o B | -o A -o B | -o A -o B | -o A -o B | option=A&option=B | +| 集合選項 ` `[^2] | | | | | | | +| 集合選項 `,` | -o A,B,C | -o A,B,C | -o A,B,C | -o A,B,C | -o A,B,C | | +| 集合選項 `;` | -o A;B;C | -o A;B;C | -o A;B;C | -o A;B;C | -o A;B;C | | +| 字典選項 | -o:A=X;B=Y | -o:A=X;B=Y | | | -o:A=X;B=Y | | +| 命名法 | --kebab-case | --kebab-case | --kebab-case | | | kebab-case | +| 命名法 | -PascalCase | | | | -PascalCase | | +| 命名法 | -camelCase | | | | -camelCase | | +| 命名法 | /PascalCase | | | | /PascalCase | | +| 命名法 | /camelCase | | | | /camelCase | | + +[^1]: GNU 風格並不支援布林選項顯式帶值,但因為這種情況沒有歧義,所以我們額外支援它。 +[^2]: 所有風格預設都不支援空格分隔集合,以儘可能避免與位置參數的歧義。但如果你需要,可以透過 `CommandLineParsingOptions.Style.SupportsSpaceSeparatedCollectionValues` 啟用它。 + +說明: + +1. 除 Windows 風格外,其他風格都支援使用 `--` 作為後置位置參數標記,其後所有參數皆視為位置參數;另外,URL 風格無法表達後置位置參數。 +2. 在 `--` 之前,選項與位置參數可以交錯出現,規則如下。 + +選項會優先取得緊跟的值;凡是能放進該選項的值都會被取走。一旦放不下,後面若還有值,就視為位置參數。 + +例如,`--option` 是布林選項時,`--option true text` 或 `--option 1 text` 中的 `true` 與 `1` 會被 `--option` 取走,之後的 `text` 為位置參數。 +再例如,`--option` 是布林選項時,`--option text` 因為 `text` 不是布林值,所以 `text` 直接視為位置參數。 +再例如,若風格支援空白分隔集合(見上表),則當 `--option a b c` 是集合選項時,`a` `b` `c` 都會被取走,直到遇到下一個選項或 `--`。GNU 不支援空白分隔集合。 + +## 命名法 + +1. 在程式碼中定義選項時,應使用 kebab-case 命名法 + - [為什麼要這麼做?](https://github.com/dotnet-campus/DotNetCampus.CommandLine/blob/main/docs/analyzers/DCL101.md) + - 若推測你寫的不是 kebab-case,會提供警告 DCL101 + - 你可以忽略該警告;無論實際字串為何,都當作 kebab-case(提供無歧義的單詞邊界資訊,見下例) +2. 當你定義了被視為 kebab-case 的字串後 + - 依據設定的解析風格,可使用 kebab-case / PascalCase / camelCase 三種風格 + +範例: ```csharp -class FileOptions +[Command("open command-line")] +public class Options { - [Value(0)] - public string InputFile { get; init; } - - [Value(1)] - public string OutputFile { get; init; } - - [Option('v', "verbose")] - public bool Verbose { get; init; } + [Option('o', "option-name")] + public required string OptionName { get; init; } } ``` -使用方式: +此處有兩個 kebab-case:`Command` 特性與 `Option` 特性。可接受: -``` -demo.exe input.txt output.txt --verbose -``` +- DotNet/Gnu:`demo.exe open command-line --option-name value` +- Windows:`demo.exe Open CommandLine -OptionName value` +- CMD:`demo.exe Open CommandLine /optionName value` -你也可以捕獲多個位置參數到一個數組或集合中: +若改寫為其他風格,可能出現與預期不同(或是刻意的)結果: ```csharp -class MultiFileOptions +#pragma warning disable DCL101 +[Command("Open CommandLine")] +public class Options { - [Value(0, Length = int.MaxValue)] - public string[] Files { get; init; } = []; + // 分析器警告:OptionName 不是 kebab-case,可視需要抑制 DCL101。 + [Option('o', "OptionName")] + public required string OptionName { get; init; } } +#pragma warning restore DCL101 ``` -## 組合使用選項和位置參數 +因為仍視為 kebab-case,於是可接受: -`ValueAttribute` 和 `OptionAttribute` 可以同時應用於同一個屬性: +- DotNet/Gnu:`demo.exe Open CommandLine --OptionName value` +- Windows:`demo.exe Open CommandLine -OptionName value` +- CMD:`demo.exe Open CommandLine /optionName value` -```csharp -class Options -{ - [Value(0), Option('f', "file")] - public string FilePath { get; init; } -} -``` +## 資料型別 -這樣,以下命令行都會將文件路徑賦值給 `FilePath` 屬性: +程式庫支援多種資料型別: -``` -demo.exe file.txt -demo.exe -f file.txt -demo.exe --file file.txt -``` +1. **基本型別**:字串、整數、布林、列舉等 +2. **集合型別**:陣列、List、唯讀集合、不可變集合 +3. **字典型別**:`IDictionary`、`IReadOnlyDictionary`、`ImmutableDictionary` 等 -## 必需選項與可選選項 +如何透過命令列傳入,詳見前面的大型表格。 -在C# 11及以上版本中,可以使用`required`修飾符標記必需的選項: +## 必需選項與預設值 -```csharp -class Options -{ - [Option('i', "input")] - public required string InputFile { get; init; } // 必需選項 - - [Option('o', "output")] - public string? OutputFile { get; init; } // 可選選項 -} -``` +定義屬性時,可用下列標記: + +1. 使用 `required` 標記選項為必需 +2. 使用 `init` 標記選項為唯讀(初始化後不可改) +3. 使用 `?` 標記選項可為 null + +實際指派的值依下表行為: -如果未提供必需選項,解析時會拋出`RequiredPropertyNotAssignedException`異常。 +| required | init | nullable | 集合屬性 | 行為 | 說明 | +| -------- | ---- | -------- | -------- | ------------ | -------------------------------- | +| 1 | _ | _ | _ | 擲出例外 | 必須傳入,缺少則擲出例外 | +| 0 | 1 | 1 | _ | null | 可為 null,缺少則給 null | +| 0 | 1 | 0 | 1 | 空集合 | 集合永不為 null,缺少則給空集合 | +| 0 | 1 | 0 | 0 | 預設值/空值 | 不可為 null,缺少則給預設值[^2] | +| 0 | 0 | _ | _ | 保留初始值 | 非必需或非立即,保留定義時初始值 | -## 屬性初始值與訪問器修飾符 +[^2]: 如果是值型別,則會賦值其預設值;如果是參考型別,目前只有一種情況,就是字串,會賦值為空字串 `""`。 -在定義選項類型時,需要注意屬性初始值與訪問器修飾符(`init`、`required`)之間的關係: +- 1 = 標記過 +- 0 = 未標記 +- _ = 不論是否標記 + +1. 可空行為對參考與值型別一致(差別只是預設值對參考型別為 null) +2. 缺少必需選項會擲出 `RequiredPropertyNotAssignedException` +3. 「保留初始值」表示可直接在屬性定義時給初值: ```csharp -class Options -{ - // 錯誤示例:當使用 init 或 required 時,默認值將被忽略 - [Option('f', "format")] - public string Format { get; init; } = "json"; // 默認值不會生效! - - // 正確示例:使用 set 以保留默認值 - [Option('f', "format")] - public string Format { get; set; } = "json"; // 默認值會正確保留 -} +// 注意:只有未使用 required 與 init 時,初值才會生效。 +[Option('o', "option-name")] +public string OptionName { get; set; } = "Default Value"; ``` -### 關於屬性初始值的重要說明 +## 異常 -1. **使用 `init` 或 `required` 時的行為**: - - 當屬性包含 `required` 或 `init` 修飾符時,屬性的初始值會被忽略 - - 如果命令行參數中未提供該選項的值,屬性將被設置為 `default(T)`(對於引用類型為 `null`) - - 這是由 C# 語言特性決定的,命令行庫如果希望突破此限制需要針對所有屬性排列組合進行處理,顯然是非常浪費的 +命令列程式庫的異常分為以下幾種: -2. **保留默認值的方式**: - - 如果需要為屬性提供默認值,應使用 `{ get; set; }` 而非 `{ get; init; }` +1. 命令列解析異常 `CommandLineParseException` + - 選項或位置參數未匹配異常 + - 命令列參數格式異常 + - 命令列值轉換異常 +2. 命令列物件建立異常 + - 僅此一個 `RequiredPropertyNotAssignedException`,當屬性標記了 `required` 而未在命令列中傳入時發生異常 +3. 命令與子命令匹配異常 + - 多次匹配異常 `CommandNameAmbiguityException` + - 未匹配異常 `CommandNameNotFoundException` -3. **可空類型與警告處理**: - - 對於非必需的引用類型屬性,應將其標記為可空(如 `string?`)以避免可空警告 - - 對於值類型(如 `int`、`bool`),如果想保留默認值而非 `null`,不應將其標記為可空 - -示例: +一個很常見的情況是多個協同工作的應用程式未同步升級時,可能某程式使用了新的命令列選項呼叫了本程式,本程式當前版本不可能認識這種「下個版本」才會出現的選項。此時有可能需要忽略這種相容性錯誤(選項或位置參數未匹配異常)。如果你預感到這種情況會經常發生,你可以忽略這種錯誤: ```csharp -class OptionsBestPractice +var commandLine = CommandLine.Parse(args, CommandLineParsingOptions.DotNet with { - // 必需選項:使用 required,無需擔心默認值 - [Option("input")] - public required string InputFile { get; init; } - - // 可選選項:標記為可空類型以避免警告 - [Option("output")] - public string? OutputFile { get; init; } - - // 需要默認值的選項:使用 set 而非 init - [Option("format")] - public string Format { get; set; } = "json"; - - // 值類型選項:不需要標記為可空 - [Option("count")] - public int Count { get; set; } = 1; -} + // 可以只忽略選項,也可以只忽略位置參數;也可以像這樣都忽略。 + UnknownArgumentsHandling = UnknownCommandArgumentHandling.IgnoreAllUnknownArguments, +}); ``` -## 命令處理與謂詞 - -你可以使用命令處理器模式處理不同的命令(謂詞),類似於`git commit`、`git push`等。DotNetCampus.CommandLine 提供了多種添加命令處理器的方式: +## 命令與子命令 -### 1. 使用委託處理命令 +可使用命令處理器模式處理不同命令,類似 `git commit`、`git remote add`。提供多種方式: -最簡單的方式是通過委託處理命令,將命令選項類型和處理邏輯分離: +### 1. 使用委派處理 ```csharp -var commandLine = CommandLine.Parse(args); -commandLine.AddHandler(options => { /* 處理add命令 */ }) - .AddHandler(options => { /* 處理remove命令 */ }) +var commandLine = CommandLine.Parse(args) + .AddHandler(options => { /* 處理 add */ }) + .AddHandler(options => { /* 處理 remove */ }) .Run(); ``` -定義命令選項類時使用`Command`特性標記命令: - ```csharp [Command("add")] public class AddOptions @@ -286,9 +288,7 @@ public class RemoveOptions } ``` -### 2. 使用 ICommandHandler 接口 - -對於更複雜的命令處理邏輯,你可以創建實現 `ICommandHandler` 接口的類,將命令選項和處理邏輯封裝在一起: +### 2. `ICommandHandler` 介面 ```csharp [Command("convert")] @@ -296,259 +296,462 @@ internal class ConvertCommandHandler : ICommandHandler { [Option('i', "input")] public required string InputFile { get; init; } - + [Option('o', "output")] public string? OutputFile { get; init; } - + [Option('f', "format")] public string Format { get; set; } = "json"; - + public Task RunAsync() { - // 實現命令處理邏輯 + // 命令處理邏輯 Console.WriteLine($"Converting {InputFile} to {Format} format"); // ... - return Task.FromResult(0); // 返回退出碼 + return Task.FromResult(0); // 結束代碼 } } ``` -然後直接添加到命令行解析器中: - ```csharp -var commandLine = CommandLine.Parse(args); -commandLine.AddHandler() - .Run(); +var commandLine = CommandLine.Parse(args) + .AddHandler() + .AddHandler() + .AddHandler(options => { /* 處理 remove */ }) + .RunAsync(); ``` -### 3. 使用程序集自動發現命令處理器 +### 3. 使用 ICommandHandler 介面 -為了更方便地管理大量命令且無需手動逐個添加,可以使用程序集自動發現功能,自動添加程序集中所有實現了 `ICommandHandler` 接口的類: +有時候,程式的狀態不完全由命令列決定,程式內部也會有一些狀態會影響到命令列處理器的執行。由於我們前面使用 `AddHandler` 沒有辦法傳入任何參數,所以我們還有其他方法傳入狀態進去: ```csharp -// 定義一個部分類用於標記自動發現命令處理器 -[CollectCommandHandlersFromThisAssembly] -internal partial class AssemblyCommandHandler; - -// 在程序入口添加所有命令處理器 -var commandLine = CommandLine.Parse(args); -commandLine.AddHandlers() - .Run(); +using var scope = serviceProvider.BeginScope(); +var state = scope.ServiceProvider.GetRequiredService(); +var commandLine = CommandLine.Parse(args) + .ForState(state).AddHandler() + .RunAsync(); ``` -通常,處理器類需要添加 `[Command]` 特性並實現 `ICommandHandler` 接口,它就會被自動發現和添加: - ```csharp -[Command("sample")] -internal class SampleCommandHandler : ICommandHandler +internal class CommandHandlerWithState : ICommandHandler { - [Option("SampleProperty")] + [Option('o', "option")] public required string Option { get; init; } - [Value(Length = int.MaxValue)] - public string? Argument { get; init; } - - public Task RunAsync() + public Task RunAsync(MyState state) { - // 實現命令處理邏輯 - return Task.FromResult(0); + // 這時,你可以額外使用這個傳入的 state。 } } ``` -此外,你也可以創建一個沒有 `[Command]` 特性的命令處理器作為默認處理器。在程序集中最多只能有一個沒有 `[Command]` 特性的命令處理器,它將在沒有其他命令匹配時被使用: +如果對同一個狀態可以執行多個處理器,可以一直鏈式呼叫 `AddHandler`;而如果不同的命令處理器要處理不同的狀態,可以再次使用 `ForState`;如果後面不再需要狀態,則 `ForState` 中不要傳入參數。一個更複雜的例子如下: ```csharp -// 沒有 [Command] 特性的默認處理器 -internal class DefaultCommandHandler : ICommandHandler -{ - [Option('h', "help")] - public bool ShowHelp { get; init; } - - public Task RunAsync() - { - // 處理默認命令,如顯示幫助信息等 - if (ShowHelp) - { - Console.WriteLine("顯示幫助信息..."); - } - return Task.FromResult(0); - } -} +commandLine + .AddHandler() + .ForState(state1).AddHandler().AddHandler() + .ForState(state2).AddHandler() + .ForState().AddHandler() + .RunAsync(); ``` -這種方式特別適合大型應用或擴展性強的命令行工具,可以在不修改入口代碼的情況下添加新命令。 +### 說明 -### 異步命令處理 +1. `[Command]` 支援多個單字,表示子命令(例:`[Command("remote add")]`)。 +2. 未標記 `[Command]`,或標記為 null / 空字串,表示預設命令(`[Command("")]`)。 +3. 多個處理器匹配同一命令會擲出 `CommandNameAmbiguityException`。 +4. 若有任何處理器為非同步,必須使用 `RunAsync`(否則編譯失敗)。 -對於需要異步執行的命令處理,可以使用`RunAsync`方法: +## URL 協議支援 -```csharp -await commandLine.AddHandler(async options => -{ - await ImportDataAsync(options); - return 0; -}) -.RunAsync(); -``` +可解析 URL 協議字串: -## URL協議支持 +```ini +// scheme://command/subcommand/positional-argument1/positional-argument2?option1=value1&option2=value2 +``` -DotNetCampus.CommandLine 支持解析 URL 協議字符串: +開頭示例命令列可寫成: -``` -dotnet-campus://open/document.txt?readOnly=true&mode=Display&silence=true&startup-sessions=89EA9D26-6464-4E71-BD04-AA6516063D83 +```ini +# `demo.exe 1.txt 2.txt -c:20 --test-name:BenchmarkTest --detail-level=High --debug` +dotnet-campus://1.txt/2.txt?count=20&test-name=BenchmarkTest&detail-level=High&debug ``` -URL協議解析的特點和用法: +特別說明: -1. URL路徑部分(如示例中的 `open/document.txt`)會被解析為位置參數或謂詞加位置參數 - - 路徑的第一部分可作為謂詞(需標記 `[Command]` 特性) - - 隨後的路徑部分會被解析為位置參數 -2. 查詢參數(`?` 後的部分)會被解析為命名選項 -3. 集合類型選項可通過重複參數名傳入多個值,如:`tags=csharp&tags=dotnet` -4. URL中的特殊字符和非ASCII字符會自動進行URL解碼 +1. 集合型別可重複參數名:`tags=csharp&tags=dotnet` +2. URL 中特殊與非 ASCII 字元會自動進行解碼 -## 命名約定與最佳實踐 +## 原始碼產生器、攔截器與效能 -為確保更好的兼容性和用戶體驗,我們建議使用 kebab-case 風格命名長選項: - -```csharp -// 推薦 -[Option('o', "output-file")] -public string OutputFile { get; init; } +使用原始碼產生器與攔截器大幅提升效能。 -// 不推薦 -[Option('o', "OutputFile")] -public string OutputFile { get; init; } -``` +### 使用者程式碼範例 -使用kebab-case命名的好處: +```csharp +public class BenchmarkOptions41 +{ + [Option("debug")] + public required bool IsDebugMode { get; init; } -1. 提供更清晰的單詞分割信息(如能猜出"DotNet-Campus"而不是"Dot-Net-Campus") -2. 解決數字從屬問題(如"Version2Info"是"Version2-Info"還是"Version-2-Info") -3. 與多種命令行風格更好地兼容 + [Option('c', "count")] + public required int TestCount { get; init; } -## 源生成器、攔截器與性能優化 + [Option('n', "test-name")] + public string? TestName { get; set; } -DotNetCampus.CommandLine 使用源代碼生成器技術大幅提升了命令行解析的性能。其中的攔截器([Interceptor](https://github.com/dotnet/roslyn/blob/main/docs/features/interceptors.md))讓性能提升發揮得更淋漓盡致。 + [Option("test-category")] + public string? TestCategory { get; set; } -### 攔截器的工作原理 + [Option('d', "detail-level")] + public DetailLevel DetailLevel { get; set; } = DetailLevel.Medium; -當你調用 `CommandLine.As()` 或 `CommandLine.AddHandler()` 等方法時,源生成器會自動生成攔截代碼,將調用重定向到編譯時生成的高性能代碼路徑。這使得命令行參數解析和對象創建的性能得到了大幅提升。 + [Value(0, int.MaxValue)] + public IReadOnlyList TestItems { get; init; } = null!; +} +``` -例如,當你編寫以下代碼時: +
+ 對應產生的原始碼 ```csharp -var options = CommandLine.Parse(args).As(); -``` +#nullable enable +using global::System; +using global::DotNetCampus.Cli.Compiler; -源生成器會攔截這個調用,自動生成類似以下的代碼來替代默認通過字典查找創建器的方式實現(舊版本曾使用過反射): +namespace DotNetCampus.Cli.Performance.Fakes; -```csharp /// -/// 方法的攔截器。攔截以提高性能。 +/// 辅助 生成命令行选项、子命令或处理函数的创建。 /// -[global::System.Runtime.CompilerServices.InterceptsLocation(1, /* Program.Run4xInterceptor @Program.cs */ "G4GJAK7udHFnPkRUqV6VzzgRAABQcm9ncmFtLmNz")] -public static T CommandLine_As_DotNetCampusCliTestsFakesOptions(this global::DotNetCampus.Cli.CommandLine commandLine) - where T : global::DotNetCampus.Cli.Tests.Fakes.Options +public sealed class BenchmarkOptions41Builder(global::DotNetCampus.Cli.CommandLine commandLine) { - return (T)global::DotNetCampus.Cli.Tests.Fakes.OptionsBuilder.CreateInstance(commandLine); -} -``` + public static readonly global::DotNetCampus.Cli.Compiler.NamingPolicyNameGroup CommandNameGroup = default; -### 源生成器生成的代碼示例 + public static global::DotNetCampus.Cli.Performance.Fakes.BenchmarkOptions41 CreateInstance(global::DotNetCampus.Cli.CommandLine commandLine) + { + return new DotNetCampus.Cli.Performance.Fakes.BenchmarkOptions41Builder(commandLine).Build(); + } -下面是一個簡單的命令行選項類型及其對應生成的源代碼示例: + private global::DotNetCampus.Cli.Compiler.BooleanArgument IsDebugMode = new(); -```csharp -// 用戶代碼中的類型 -internal record DotNet03_MixedOptions -{ - [Option] - public int Number { get; init; } + private global::DotNetCampus.Cli.Compiler.NumberArgument TestCount = new(); - [Option] - public required string Text { get; init; } + private global::DotNetCampus.Cli.Compiler.StringArgument TestName = new(); - [Option] - public bool Flag { get; init; } -} -``` + private global::DotNetCampus.Cli.Compiler.StringArgument TestCategory = new(); -對應生成的源: + private __GeneratedEnumArgument__DotNetCampus_Cli_Performance_Fakes_DetailLevel__ DetailLevel = new(); -```csharp -#nullable enable -namespace DotNetCampus.Cli.Tests; + private global::DotNetCampus.Cli.Compiler.StringListArgument TestItems = new(); -/// -/// 輔助 生成命令行選項、謂詞或處理函數的創建。 -/// -internal sealed class DotNet03_MixedOptionsBuilder -{ - public static object CreateInstance(global::DotNetCampus.Cli.CommandLine commandLine) + public global::DotNetCampus.Cli.Performance.Fakes.BenchmarkOptions41 Build() + { + if (commandLine.RawArguments.Count is 0) + { + return BuildDefault(); + } + + var parser = new global::DotNetCampus.Cli.Utils.Parsers.CommandLineParser(commandLine, "BenchmarkOptions41", 0) + { + MatchLongOption = MatchLongOption, + MatchShortOption = MatchShortOption, + MatchPositionalArguments = MatchPositionalArguments, + AssignPropertyValue = AssignPropertyValue, + }; + parser.Parse().WithFallback(commandLine); + return BuildCore(commandLine); + } + + private global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch MatchLongOption(ReadOnlySpan longOption, bool defaultCaseSensitive, global::DotNetCampus.Cli.CommandNamingPolicy namingPolicy) + { + // 1. 先匹配 kebab-case 命名法(原样字符串) + if (namingPolicy.SupportsOrdinal()) + { + // 1.1 先快速原字符匹配一遍(能应对规范命令行大小写,并优化 DotNet / GNU 风格的性能)。 + switch (longOption) + { + case "debug": + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(IsDebugMode), 0, global::DotNetCampus.Cli.Compiler.OptionValueType.Boolean); + case "count": + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(TestCount), 1, global::DotNetCampus.Cli.Compiler.OptionValueType.Normal); + case "test-name": + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(TestName), 2, global::DotNetCampus.Cli.Compiler.OptionValueType.Normal); + case "test-category": + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(TestCategory), 3, global::DotNetCampus.Cli.Compiler.OptionValueType.Normal); + case "detail-level": + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(DetailLevel), 4, global::DotNetCampus.Cli.Compiler.OptionValueType.Normal); + } + + // 1.2 再按指定大小写匹配一遍(能应对不规范命令行大小写)。 + var defaultComparison = defaultCaseSensitive + ? global::System.StringComparison.Ordinal + : global::System.StringComparison.OrdinalIgnoreCase; + if (longOption.Equals("debug".AsSpan(), defaultComparison)) + { + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(IsDebugMode), 0, global::DotNetCampus.Cli.Compiler.OptionValueType.Boolean); + } + if (longOption.Equals("count".AsSpan(), defaultComparison)) + { + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(TestCount), 1, global::DotNetCampus.Cli.Compiler.OptionValueType.Normal); + } + if (longOption.Equals("test-name".AsSpan(), defaultComparison)) + { + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(TestName), 2, global::DotNetCampus.Cli.Compiler.OptionValueType.Normal); + } + if (longOption.Equals("test-category".AsSpan(), defaultComparison)) + { + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(TestCategory), 3, global::DotNetCampus.Cli.Compiler.OptionValueType.Normal); + } + if (longOption.Equals("detail-level".AsSpan(), defaultComparison)) + { + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(DetailLevel), 4, global::DotNetCampus.Cli.Compiler.OptionValueType.Normal); + } + } + + // 2. 再匹配其他命名法(能应对所有不规范命令行大小写,并支持所有风格)。 + if (namingPolicy.SupportsPascalCase()) + { + var defaultComparison = defaultCaseSensitive + ? global::System.StringComparison.Ordinal + : global::System.StringComparison.OrdinalIgnoreCase; + if (longOption.Equals("Debug".AsSpan(), defaultComparison)) + { + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(IsDebugMode), 0, global::DotNetCampus.Cli.Compiler.OptionValueType.Boolean); + } + if (longOption.Equals("Count".AsSpan(), defaultComparison)) + { + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(TestCount), 1, global::DotNetCampus.Cli.Compiler.OptionValueType.Normal); + } + if (longOption.Equals("TestName".AsSpan(), defaultComparison)) + { + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(TestName), 2, global::DotNetCampus.Cli.Compiler.OptionValueType.Normal); + } + if (longOption.Equals("TestCategory".AsSpan(), defaultComparison)) + { + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(TestCategory), 3, global::DotNetCampus.Cli.Compiler.OptionValueType.Normal); + } + if (longOption.Equals("DetailLevel".AsSpan(), defaultComparison)) + { + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(DetailLevel), 4, global::DotNetCampus.Cli.Compiler.OptionValueType.Normal); + } + } + + return global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch.NotMatch; + } + + private global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch MatchShortOption(ReadOnlySpan shortOption, bool defaultCaseSensitive) + { + // 1. 先快速原字符匹配一遍(能应对规范命令行大小写,并优化 DotNet / GNU 风格的性能)。 + switch (shortOption) + { + // 属性 IsDebugMode 没有短名称,无需匹配。 + case "c": + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(TestCount), 1, global::DotNetCampus.Cli.Compiler.OptionValueType.Normal); + case "n": + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(TestName), 2, global::DotNetCampus.Cli.Compiler.OptionValueType.Normal); + // 属性 TestCategory 没有短名称,无需匹配。 + case "d": + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(DetailLevel), 4, global::DotNetCampus.Cli.Compiler.OptionValueType.Normal); + } + + var defaultComparison = defaultCaseSensitive + ? global::System.StringComparison.Ordinal + : global::System.StringComparison.OrdinalIgnoreCase; + + // 2. 再按指定大小写指定命名法匹配一遍(能应对不规范命令行大小写)。 + // 属性 IsDebugMode 没有短名称,无需匹配。 + if (shortOption.Equals("c".AsSpan(), defaultComparison)) + { + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(TestCount), 1, global::DotNetCampus.Cli.Compiler.OptionValueType.Normal); + } + if (shortOption.Equals("n".AsSpan(), defaultComparison)) + { + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(TestName), 2, global::DotNetCampus.Cli.Compiler.OptionValueType.Normal); + } + // 属性 TestCategory 没有短名称,无需匹配。 + if (shortOption.Equals("d".AsSpan(), defaultComparison)) + { + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof(DetailLevel), 4, global::DotNetCampus.Cli.Compiler.OptionValueType.Normal); + } + + return global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch.NotMatch; + } + + private global::DotNetCampus.Cli.Utils.Parsers.PositionalArgumentValueMatch MatchPositionalArguments(ReadOnlySpan value, int argumentIndex) + { + // 属性 TestItems 覆盖了所有位置参数,直接匹配。 + return new global::DotNetCampus.Cli.Utils.Parsers.PositionalArgumentValueMatch("TestItems", 5, global::DotNetCampus.Cli.Compiler.PositionalArgumentValueType.Normal); + } + + private void AssignPropertyValue(string propertyName, int propertyIndex, ReadOnlySpan key, ReadOnlySpan value) { - var caseSensitive = commandLine.DefaultCaseSensitive; - var result = new global::DotNetCampus.Cli.Tests.DotNet03_MixedOptions + switch (propertyIndex) { - Number = commandLine.GetOption("number") ?? default, - Text = commandLine.GetOption("text") ?? throw new global::DotNetCampus.Cli.Exceptions.RequiredPropertyNotAssignedException($"The command line arguments doesn't contain a required option '--text'. Command line: {commandLine}", "Text"), - Flag = commandLine.GetOption("flag") ?? default, - // There is no positional argument to be initialized. + case 0: + IsDebugMode = IsDebugMode.Assign(value); + break; + case 1: + TestCount = TestCount.Assign(value); + break; + case 2: + TestName = TestName.Assign(value); + break; + case 3: + TestCategory = TestCategory.Assign(value); + break; + case 4: + DetailLevel = DetailLevel.Assign(value); + break; + case 5: + TestItems = TestItems.Append(value); + break; + } + } + + private global::DotNetCampus.Cli.Performance.Fakes.BenchmarkOptions41 BuildCore(global::DotNetCampus.Cli.CommandLine commandLine) + { + var result = new global::DotNetCampus.Cli.Performance.Fakes.BenchmarkOptions41 + { + // 1. There is no [RawArguments] property to be initialized. + + // 2. [Option] + IsDebugMode = IsDebugMode.ToBoolean() ?? throw new global::DotNetCampus.Cli.Exceptions.RequiredPropertyNotAssignedException($"The command line arguments doesn't contain a required option 'debug'. Command line: {commandLine}", "IsDebugMode"), + TestCount = TestCount.ToInt32() ?? throw new global::DotNetCampus.Cli.Exceptions.RequiredPropertyNotAssignedException($"The command line arguments doesn't contain a required option 'count'. Command line: {commandLine}", "TestCount"), + + // 3. [Value] + TestItems = TestItems.ToList() ?? [], }; - // There is no option to be assigned. - // There is no positional argument to be assigned. + + // 1. There is no [RawArguments] property to be assigned. + + // 2. [Option] + if (TestName.ToString() is { } o0) + { + result.TestName = o0; + } + if (TestCategory.ToString() is { } o1) + { + result.TestCategory = o1; + } + if (DetailLevel.ToEnum() is { } o2) + { + result.DetailLevel = o2; + } + + // 3. There is no [Value] property to be assigned. + return result; } + + private global::DotNetCampus.Cli.Performance.Fakes.BenchmarkOptions41 BuildDefault() + { + throw new global::DotNetCampus.Cli.Exceptions.RequiredPropertyNotAssignedException($"The command line arguments doesn't contain any required option or positional argument. Command line: {commandLine}", null!); + } + + /// + /// Provides parsing and assignment for the enum type . + /// + private readonly record struct __GeneratedEnumArgument__DotNetCampus_Cli_Performance_Fakes_DetailLevel__ + { + /// + /// Indicates whether to ignore exceptions when parsing fails. + /// + public bool IgnoreExceptions { get; init; } + + /// + /// Stores the parsed enum value. + /// + private global::DotNetCampus.Cli.Performance.Fakes.DetailLevel? Value { get; init; } + + /// + /// Assigns a value when a command line input is parsed. + /// + /// The parsed string value. + public __GeneratedEnumArgument__DotNetCampus_Cli_Performance_Fakes_DetailLevel__ Assign(ReadOnlySpan value) + { + Span lowerValue = stackalloc char[value.Length]; + for (var i = 0; i < value.Length; i++) + { + lowerValue[i] = char.ToLowerInvariant(value[i]); + } + global::DotNetCampus.Cli.Performance.Fakes.DetailLevel? newValue = lowerValue switch + { + "low" => global::DotNetCampus.Cli.Performance.Fakes.DetailLevel.Low, + "medium" => global::DotNetCampus.Cli.Performance.Fakes.DetailLevel.Medium, + "high" => global::DotNetCampus.Cli.Performance.Fakes.DetailLevel.High, + _ when IgnoreExceptions => null, + _ => throw new global::DotNetCampus.Cli.Exceptions.CommandLineParseValueException($"Cannot convert '{value.ToString()}' to enum type 'DotNetCampus.Cli.Performance.Fakes.DetailLevel'."), + }; + return this with { Value = newValue }; + } + + /// + /// Converts the parsed value to the enum type. + /// + public global::DotNetCampus.Cli.Performance.Fakes.DetailLevel? ToEnum() => Value; + } } ``` -## 性能數據 - -源代碼生成器實現提供了極高的命令行解析性能: - -| Method | Mean | Error | StdDev | Median | Gen0 | Gen1 | Allocated | -|---------------------------------------- |----------------:|--------------:|--------------:|----------------:|-------:|-------:|----------:| -| 'parse [] --flexible' | 39.16 ns | 0.402 ns | 0.357 ns | 39.15 ns | 0.0124 | - | 208 B | -| 'parse [] --gnu' | 38.22 ns | 0.518 ns | 0.459 ns | 38.30 ns | 0.0124 | - | 208 B | -| 'parse [] --posix' | 38.45 ns | 0.792 ns | 0.741 ns | 38.45 ns | 0.0124 | - | 208 B | -| 'parse [] --dotnet' | 42.14 ns | 0.878 ns | 2.588 ns | 42.06 ns | 0.0124 | - | 208 B | -| 'parse [] --powershell' | 38.67 ns | 0.772 ns | 1.451 ns | 38.42 ns | 0.0124 | - | 208 B | -| 'parse [] -v=3.x -p=parser' | 44.07 ns | 0.665 ns | 0.841 ns | 44.08 ns | 0.0220 | - | 368 B | -| 'parse [] -v=3.x -p=runtime' | 365.36 ns | 7.186 ns | 13.319 ns | 361.47 ns | 0.0367 | - | 616 B | -| 'parse [PS1] --flexible' | 907.15 ns | 17.887 ns | 38.504 ns | 899.46 ns | 0.1612 | - | 2704 B | -| 'parse [PS1] --dotnet' | 969.51 ns | 18.977 ns | 31.179 ns | 964.56 ns | 0.1612 | - | 2704 B | -| 'parse [PS1] -v=3.x -p=parser' | 448.38 ns | 8.883 ns | 13.830 ns | 445.91 ns | 0.0715 | - | 1200 B | -| 'parse [PS1] -v=3.x -p=runtime' | 835.83 ns | 16.055 ns | 38.774 ns | 830.59 ns | 0.0858 | - | 1448 B | -| 'parse [CMD] --flexible' | 932.31 ns | 18.636 ns | 40.907 ns | 936.14 ns | 0.1612 | - | 2704 B | -| 'parse [CMD] --dotnet' | 877.96 ns | 8.846 ns | 9.832 ns | 877.67 ns | 0.1612 | - | 2704 B | -| 'parse [CMD] -v=3.x -p=parser' | 438.09 ns | 8.591 ns | 11.469 ns | 433.77 ns | 0.0715 | - | 1200 B | -| 'parse [CMD] -v=3.x -p=runtime' | 822.05 ns | 16.417 ns | 25.560 ns | 811.08 ns | 0.0858 | - | 1448 B | -| 'parse [GNU] --flexible' | 880.14 ns | 17.627 ns | 36.794 ns | 878.35 ns | 0.1574 | - | 2648 B | -| 'parse [GNU] --gnu' | 811.59 ns | 13.691 ns | 20.492 ns | 805.61 ns | 0.1554 | - | 2608 B | -| 'parse [GNU] -v=3.x -p=parser' | 492.48 ns | 9.757 ns | 11.615 ns | 491.95 ns | 0.0896 | - | 1512 B | -| 'parse [GNU] -v=3.x -p=runtime' | 873.40 ns | 15.873 ns | 24.713 ns | 865.86 ns | 0.1049 | - | 1760 B | -| 'handle [Edit,Print] --flexible' | 693.30 ns | 13.894 ns | 28.066 ns | 681.77 ns | 0.2375 | 0.0019 | 3984 B | -| 'handle [Edit,Print] -v=3.x -p=parser' | 949.15 ns | 18.959 ns | 25.952 ns | 939.97 ns | 0.2775 | 0.0038 | 4648 B | -| 'handle [Edit,Print] -v=3.x -p=runtime' | 6,232.90 ns | 122.601 ns | 217.924 ns | 6,190.80 ns | 0.2594 | - | 4592 B | -| 'parse [URL]' | 2,942.05 ns | 54.322 ns | 76.152 ns | 2,926.04 ns | 0.4578 | - | 7704 B | -| 'parse [URL] -v=3.x -p=parser' | 121.43 ns | 2.457 ns | 5.496 ns | 121.10 ns | 0.0440 | - | 736 B | -| 'parse [URL] -v=3.x -p=runtime' | 462.92 ns | 9.017 ns | 10.023 ns | 464.26 ns | 0.0587 | - | 984 B | -| 'NuGet: CommandLineParser' | 212,745.53 ns | 4,237.822 ns | 11,384.635 ns | 211,418.82 ns | 5.3711 | - | 90696 B | -| 'NuGet: System.CommandLine' | 1,751,023.59 ns | 34,134.634 ns | 50,034.108 ns | 1,727,339.45 ns | 3.9063 | - | 84138 B | +
+ +## 效能數據 + +解析空白命令列參數: + +| Method | Mean | Error | StdDev | Gen0 | Allocated | +| ----------------------------- | -----------: | ---------: | ---------: | -----: | --------: | +| 'parse [] -v=4.1 -p=flexible' | 27.25 ns | 0.485 ns | 0.454 ns | 0.0143 | 240 B | +| 'parse [] -v=4.1 -p=dotnet' | 27.35 ns | 0.471 ns | 0.440 ns | 0.0143 | 240 B | +| 'parse [] -v=4.0 -p=flexible' | 97.16 ns | 0.708 ns | 0.628 ns | 0.0134 | 224 B | +| 'parse [] -v=4.0 -p=dotnet' | 95.90 ns | 0.889 ns | 0.742 ns | 0.0134 | 224 B | +| 'parse [] -v=3.x -p=parser' | 49.73 ns | 0.931 ns | 0.870 ns | 0.0239 | 400 B | +| 'parse [] -v=3.x -p=runtime' | 19,304.17 ns | 194.337 ns | 162.280 ns | 0.4272 | 7265 B | + +解析 GNU 風格命令列參數: + +```bash +test DotNetCampus.CommandLine.Performance.dll DotNetCampus.CommandLine.Sample.dll DotNetCampus.CommandLine.Test.dll -c 20 --test-name BenchmarkTest --detail-level High --debug +``` + +| Method | Runtime | Mean | Error | StdDev | Gen0 | Allocated | +| -------------------------------- | ------------- | -----------: | ----------: | ----------: | -----: | --------: | +| 'parse [GNU] -v=4.1 -p=flexible' | .NET 10.0 | 355.9 ns | 4.89 ns | 4.58 ns | 0.0548 | 920 B | +| 'parse [GNU] -v=4.1 -p=gnu' | .NET 10.0 | 339.7 ns | 6.81 ns | 7.57 ns | 0.0548 | 920 B | +| 'parse [GNU] -v=4.0 -p=flexible' | .NET 10.0 | 945.9 ns | 14.87 ns | 13.19 ns | 0.1583 | 2656 B | +| 'parse [GNU] -v=4.0 -p=gnu' | .NET 10.0 | 882.1 ns | 11.30 ns | 10.57 ns | 0.1631 | 2736 B | +| 'parse [GNU] -v=3.x -p=parser' | .NET 10.0 | 495.7 ns | 9.26 ns | 9.09 ns | 0.1040 | 1752 B | +| 'parse [GNU] -v=3.x -p=runtime' | .NET 10.0 | 18,025.5 ns | 194.73 ns | 162.61 ns | 0.4883 | 8730 B | +| 'NuGet: ConsoleAppFramework' | .NET 10.0 | 134.1 ns | 2.70 ns | 2.65 ns | 0.0215 | 360 B | +| 'NuGet: CommandLineParser' | .NET 10.0 | 177,520.8 ns | 2,225.66 ns | 1,737.65 ns | 3.9063 | 68895 B | +| 'NuGet: System.CommandLine' | .NET 10.0 | 66,581.6 ns | 1,323.17 ns | 3,245.76 ns | 1.0986 | 18505 B | +| 'parse [GNU] -v=4.1 -p=flexible' | NativeAOT 9.0 | 624.3 ns | 7.06 ns | 6.60 ns | 0.0505 | 856 B | +| 'parse [GNU] -v=4.1 -p=gnu' | NativeAOT 9.0 | 600.3 ns | 6.72 ns | 6.28 ns | 0.0505 | 856 B | +| 'parse [GNU] -v=4.0 -p=flexible' | NativeAOT 9.0 | 1,395.6 ns | 20.43 ns | 19.11 ns | 0.1507 | 2529 B | +| 'parse [GNU] -v=4.0 -p=gnu' | NativeAOT 9.0 | 1,438.1 ns | 19.84 ns | 18.55 ns | 0.1545 | 2609 B | +| 'parse [GNU] -v=3.x -p=parser' | NativeAOT 9.0 | 720.8 ns | 7.47 ns | 6.99 ns | 0.1030 | 1737 B | +| 'parse [GNU] -v=3.x -p=runtime' | NativeAOT 9.0 | NA | NA | NA | NA | NA | +| 'NuGet: ConsoleAppFramework' | NativeAOT 9.0 | 195.3 ns | 3.76 ns | 3.69 ns | 0.0234 | 392 B | +| 'NuGet: CommandLineParser' | NativeAOT 9.0 | NA | NA | NA | NA | NA | +| 'NuGet: System.CommandLine' | NativeAOT 9.0 | NA | NA | NA | NA | NA | 其中: -1. `parse` 表示調用的是 `CommandLine.Parse` 方法 -2. `handle` 表示調用的是 `CommandLine.AddHandler` 方法 -3. 中括號 `[Xxx]` 表示傳入的命令行參數的風格 -4. `--flexible` `--gnu` 等表示解析傳入命令行時所使用的解析器風格(相匹配時效率最高) -5. `-v=3.x -p=parser` 表示舊版本手工編寫解析器並傳入時的性能(性能最好,不過舊版本支持的命令行規範較少,很多合法的命令寫法並不支持) -6. `-v=3.x -p=runtime` 表示舊版本使用默認的反射解析器時的性能 -7. `NuGet: CommandLineParser` 和 `NuGet: System.CommandLine` 表示使用對應名稱的 NuGet 包解析命令行參數時的性能 -8. `parse [URL]` 表示解析 URL 協議字符串時的性能 - -新版本得益於源生成器和攔截器: -1. 完成一次解析大約在 0.8μs(微秒)左右(Benchmark) -2. 在應用程序啟動期間,完成一次解析只需要大約 34μs -3. 在應用程序啟動期間,包含dll加載、類型初始化在內的解析一次大約8ms(使用 AOT 編譯能重新降至 34μs)。 + +1. `parse` 表示呼叫 `CommandLine.Parse` +2. `handle` 表示呼叫 `CommandLine.AddHandler` +3. 中括號 `[Xxx]` 表示傳入參數風格 +4. `--flexible`、`--gnu` 等表示解析使用的風格(匹配效率最高) +5. `-v=3.x -p=parser` 為舊版手寫解析器效能(最佳但語法支援少) +6. `-v=3.x -p=runtime` 為舊版反射解析器效能 +7. `-v=4.0` 與 `-v=4.1` 顯示版本效能演進 +8. `NuGet: ...` 為其他程式庫效能 +9. `parse [URL]`(本文省略部分)為解析 URL 協議效能 + +作者觀察(@walterlv): + +1. 最快的是 [ConsoleAppFramework](https://github.com/Cysharp/ConsoleAppFramework);本庫性能非常接近,同量級。 +2. 感謝其對零依賴、零配置、零反射、零分配的極致追求,激勵我們完成目前版本(`-v4.1`)。 +3. 它主打極致性能,犧牲部分語法支援;我們主打「全功能 + 高性能」,因此位於同級別,很難超越它。依你的受眾與需求選擇適用方案。 diff --git a/samples/DotNetCampus.CommandLine.Sample/DotNetCampus.CommandLine.Sample.csproj b/samples/DotNetCampus.CommandLine.Sample/DotNetCampus.CommandLine.Sample.csproj index 0ec53685..ca8d8431 100644 --- a/samples/DotNetCampus.CommandLine.Sample/DotNetCampus.CommandLine.Sample.csproj +++ b/samples/DotNetCampus.CommandLine.Sample/DotNetCampus.CommandLine.Sample.csproj @@ -14,12 +14,6 @@
- - - - - - ..\..\tests\dotnetCampus.CommandLine.Performance\dotnetCampus.CommandLine.Legacy.dll diff --git a/tests/DotNetCampus.CommandLine.Tests/Fakes/Options.cs b/samples/DotNetCampus.CommandLine.Sample/Fakes/Options.cs similarity index 89% rename from tests/DotNetCampus.CommandLine.Tests/Fakes/Options.cs rename to samples/DotNetCampus.CommandLine.Sample/Fakes/Options.cs index fa7e8607..08f7e2df 100644 --- a/tests/DotNetCampus.CommandLine.Tests/Fakes/Options.cs +++ b/samples/DotNetCampus.CommandLine.Sample/Fakes/Options.cs @@ -10,49 +10,49 @@ public class Options /// /// 表示通过打开的文件路径。此属性可能为 null,但绝不会是空字符串或空白字符串。 /// - [DotNetCampus.Cli.Compiler.Value(0), DotNetCampus.Cli.Compiler.Option('f', "File")] + [DotNetCampus.Cli.Compiler.Value(0), DotNetCampus.Cli.Compiler.Option('f', "file")] [dotnetCampus.Cli.Value(0), dotnetCampus.Cli.Option('f', "File")] public string? FilePath { get; set; } /// /// 当此参数值为 true 时,表示此进程是从 Cloud 端启动的 Shell 进程。此属性默认值是 false。 /// - [DotNetCampus.Cli.Compiler.Option("Cloud")] + [DotNetCampus.Cli.Compiler.Option("cloud")] [dotnetCampus.Cli.Option("Cloud"), DefaultValue(false)] public bool IsFromCloud { get; init; } /// /// 表示 Shell 端启动的模式。此属性可能为 null,但绝不会是空字符串或空白字符串。 /// - [DotNetCampus.Cli.Compiler.Option('m', "Mode")] + [DotNetCampus.Cli.Compiler.Option('m', "mode")] [dotnetCampus.Cli.Option('m', "Mode")] public string? StartupMode { get; init; } /// /// 表示当前是否是静默方式启动,通常由 Shell 启动 Cloud 时使用。此属性默认值是 false。 /// - [DotNetCampus.Cli.Compiler.Option('s', "Silence")] + [DotNetCampus.Cli.Compiler.Option('s', "silence")] [dotnetCampus.Cli.Option('s', "Silence"), DefaultValue(false)] public bool IsSilence { get; init; } /// /// 表示当前启动时需要针对 IWB 进行处理。此属性默认值是 false。 /// - [DotNetCampus.Cli.Compiler.Option("Iwb")] + [DotNetCampus.Cli.Compiler.Option("iwb")] [dotnetCampus.Cli.Option("Iwb"), DefaultValue(false)] public bool IsIwb { get; init; } /// /// 表示当前窗口启动时应该安放的位置。此属性可能为 null,但绝不会是空字符串或空白字符串。 /// - [DotNetCampus.Cli.Compiler.Option('p', "Placement")] + [DotNetCampus.Cli.Compiler.Option('p', "placement")] [dotnetCampus.Cli.Option('p', "Placement")] public string? Placement { get; init; } /// /// 表示一个启动会话 Id,用于在多个进程间同步一些信息。此属性可能为 null,但绝不会是空字符串或空白字符串。 /// - [DotNetCampus.Cli.Compiler.Option("StartupSession")] + [DotNetCampus.Cli.Compiler.Option("startup-session")] [dotnetCampus.Cli.Option("StartupSession")] public string? StartupSession { get; init; } diff --git a/tests/DotNetCampus.CommandLine.Tests/Fakes/OptionsParser.cs b/samples/DotNetCampus.CommandLine.Sample/Fakes/OptionsParser.cs similarity index 100% rename from tests/DotNetCampus.CommandLine.Tests/Fakes/OptionsParser.cs rename to samples/DotNetCampus.CommandLine.Sample/Fakes/OptionsParser.cs diff --git a/tests/DotNetCampus.CommandLine.Tests/Fakes/VerbOptions.cs b/samples/DotNetCampus.CommandLine.Sample/Fakes/VerbOptions.cs similarity index 100% rename from tests/DotNetCampus.CommandLine.Tests/Fakes/VerbOptions.cs rename to samples/DotNetCampus.CommandLine.Sample/Fakes/VerbOptions.cs diff --git a/samples/DotNetCampus.CommandLine.Sample/Program.cs b/samples/DotNetCampus.CommandLine.Sample/Program.cs index f7c28fe1..4260489a 100644 --- a/samples/DotNetCampus.CommandLine.Sample/Program.cs +++ b/samples/DotNetCampus.CommandLine.Sample/Program.cs @@ -1,5 +1,4 @@ #define Benchmark -using System.Data.Common; using System.Diagnostics; using System.Runtime.CompilerServices; using DotNetCampus.Cli.Compiler; @@ -11,7 +10,7 @@ class Program { static void Main(string[] args) { -#if Benchmark +#if !Benchmark // 第一次运行,排除类型初始化的影响,只测试代码执行性能。 // 注释掉这句话,可以: // 1. 测试带类型初始化的性能 @@ -22,10 +21,11 @@ static void Main(string[] args) stopwatch.Stop(); Console.WriteLine($"[# Elapsed: {stopwatch.Elapsed.TotalMicroseconds} us #]"); #else - const int testCount = 1000000; + const int warmupCount = 10000; + const int testCount = 10000000; CommandLineParsingOptions parsingOptions = CommandLineParsingOptions.DotNet; - for (var i = 0; i < testCount; i++) + for (var i = 0; i < warmupCount; i++) { dotnetCampus.Cli.CommandLine.Parse(args).As(new OptionsParser()); dotnetCampus.Cli.CommandLine.Parse(args).As(); @@ -39,53 +39,56 @@ static void Main(string[] args) Console.WriteLine("| Version | Parse | As(Parser) | As(Runtime) |"); Console.WriteLine("| ------- | ------- | ---------- | ----------- |"); - Console.Write("| 3.x | "); - stopwatch.Restart(); - for (var i = 0; i < testCount; i++) { - _ = dotnetCampus.Cli.CommandLine.Parse(args); + Console.Write("| 3.x | "); + stopwatch.Restart(); + for (var i = 0; i < testCount; i++) + { + _ = dotnetCampus.Cli.CommandLine.Parse(args); + } + stopwatch.Stop(); + Console.Write($"{stopwatch.ElapsedMilliseconds.ToString(),4} ms | "); + var oldCommandLine = dotnetCampus.Cli.CommandLine.Parse(args); + stopwatch.Restart(); + for (var i = 0; i < testCount; i++) + { + _ = oldCommandLine.As(new OptionsParser()); + } + stopwatch.Stop(); + Console.Write($"{stopwatch.ElapsedMilliseconds.ToString(),7} ms | "); + stopwatch.Restart(); + for (var i = 0; i < testCount; i++) + { + _ = oldCommandLine.As(); + } + stopwatch.Stop(); + Console.WriteLine($"{stopwatch.ElapsedMilliseconds.ToString(),8} ms |"); } - stopwatch.Stop(); - Console.Write($"{stopwatch.ElapsedMilliseconds.ToString(),4} ms | "); - var oldCommandLine = dotnetCampus.Cli.CommandLine.Parse(args); - stopwatch.Restart(); - for (var i = 0; i < testCount; i++) - { - _ = oldCommandLine.As(new OptionsParser()); - } - stopwatch.Stop(); - Console.Write($"{stopwatch.ElapsedMilliseconds.ToString(),7} ms | "); - stopwatch.Restart(); - for (var i = 0; i < testCount; i++) - { - _ = oldCommandLine.As(); - } - stopwatch.Stop(); - Console.WriteLine($"{stopwatch.ElapsedMilliseconds.ToString(),8} ms |"); - - Console.Write("| 4.x | "); - stopwatch.Restart(); - for (var i = 0; i < testCount; i++) { - _ = CommandLine.Parse(args, parsingOptions); + Console.Write("| 4.x | "); + stopwatch.Restart(); + for (var i = 0; i < testCount; i++) + { + _ = CommandLine.Parse(args, parsingOptions); + } + stopwatch.Stop(); + Console.Write($"{stopwatch.ElapsedMilliseconds.ToString(),4} ms | "); + var newCommandLine = CommandLine.Parse(args, parsingOptions); + stopwatch.Restart(); + for (var i = 0; i < testCount; i++) + { + _ = newCommandLine.As(OptionsBuilder.CreateInstance); + } + stopwatch.Stop(); + Console.Write($"{stopwatch.ElapsedMilliseconds.ToString(),7} ms | "); + stopwatch.Restart(); + for (var i = 0; i < testCount; i++) + { + _ = newCommandLine.As(); + } + stopwatch.Stop(); + Console.WriteLine($"{stopwatch.ElapsedMilliseconds.ToString(),8} ms |"); } - stopwatch.Stop(); - Console.Write($"{stopwatch.ElapsedMilliseconds.ToString(),4} ms | "); - var newCommandLine = CommandLine.Parse(args, parsingOptions); - stopwatch.Restart(); - for (var i = 0; i < testCount; i++) - { - _ = newCommandLine.As(OptionsBuilder.CreateInstance); - } - stopwatch.Stop(); - Console.Write($"{stopwatch.ElapsedMilliseconds.ToString(),7} ms | "); - stopwatch.Restart(); - for (var i = 0; i < testCount; i++) - { - _ = newCommandLine.As(); - } - stopwatch.Stop(); - Console.WriteLine($"{stopwatch.ElapsedMilliseconds.ToString(),8} ms |"); #endif } @@ -107,10 +110,6 @@ private static void Run(string[] args) { Run4xInterceptor(args); } - else if (args[0] == "4.x-module") - { - Run4xModule(args); - } } [MethodImpl(MethodImplOptions.NoInlining)] @@ -130,52 +129,6 @@ private static void Run4xInterceptor(string[] args) { _ = CommandLine.Parse(args, CommandLineParsingOptions.DotNet).As(); } - - [MethodImpl(MethodImplOptions.NoInlining)] - private static void Run4xModule(string[] args) - { - Initialize(); - _ = CommandLine.Parse(args, CommandLineParsingOptions.DotNet).As(); - } - - [MethodImpl(MethodImplOptions.NoInlining)] - internal static void Initialize() - { - // DefaultOptions { CommandName = null } - global::DotNetCampus.Cli.CommandRunner.Register( - null, - global::DotNetCampus.Cli.DefaultOptionsBuilder.CreateInstance); - - // EditOptions { CommandName = "Edit" } - global::DotNetCampus.Cli.CommandRunner.Register( - "Edit", - global::DotNetCampus.Cli.Tests.Fakes.EditOptionsBuilder.CreateInstance); - - // Options { CommandName = null } - global::DotNetCampus.Cli.CommandRunner.Register( - null, - global::DotNetCampus.Cli.Tests.Fakes.OptionsBuilder.CreateInstance); - - // PrintOptions { CommandName = "Print" } - global::DotNetCampus.Cli.CommandRunner.Register( - "Print", - global::DotNetCampus.Cli.Tests.Fakes.PrintOptionsBuilder.CreateInstance); - - // SampleCommandHandler { CommandName = "sample" } - global::DotNetCampus.Cli.CommandRunner.Register( - "sample", - global::DotNetCampus.Cli.SampleCommandHandlerBuilder.CreateInstance); - - // SampleOptions { CommandName = "sample-options" } - global::DotNetCampus.Cli.CommandRunner.Register( - "sample-options", - global::DotNetCampus.Cli.SampleOptionsBuilder.CreateInstance); - - // ShareOptions { CommandName = "Share" } - global::DotNetCampus.Cli.CommandRunner.Register( - "Share", - global::DotNetCampus.Cli.Tests.Fakes.ShareOptionsBuilder.CreateInstance); - } } // [CollectCommandHandlersFromThisAssembly] diff --git a/src/DotNetCampus.CommandLine.Analyzer/AnalyzerReleases.Shipped.md b/src/DotNetCampus.CommandLine.Analyzer/AnalyzerReleases.Shipped.md index 78649d82..90c303ef 100644 --- a/src/DotNetCampus.CommandLine.Analyzer/AnalyzerReleases.Shipped.md +++ b/src/DotNetCampus.CommandLine.Analyzer/AnalyzerReleases.Shipped.md @@ -8,9 +8,12 @@ Rule ID | Category | Severity | Notes --------|----------|----------|------- DCL101 | DotNetCampus.AvoidBugs | Warning | DCL102 | DotNetCampus.AvoidBugs | Info | +DCL103 | DotNetCampus.RuntimeException | Error | DCL201 | DotNetCampus.CodeFixOnly | Hidden | DCL202 | DotNetCampus.RuntimeException | Error | DCL203 | DotNetCampus.Mechanism | Error | +DCL204 | DotNetCampus.Mechanism | Error | +DCL301 | DotNetCampus.Mechanism | Error | ## Release 3.2 diff --git a/src/DotNetCampus.CommandLine.Analyzer/Analyzers/FindOptionPropertyTypeAnalyzer.cs b/src/DotNetCampus.CommandLine.Analyzer/Analyzers/FindOptionPropertyTypeAnalyzer.cs index 19ae42e1..5fbc3127 100644 --- a/src/DotNetCampus.CommandLine.Analyzer/Analyzers/FindOptionPropertyTypeAnalyzer.cs +++ b/src/DotNetCampus.CommandLine.Analyzer/Analyzers/FindOptionPropertyTypeAnalyzer.cs @@ -1,4 +1,6 @@ using System.Collections.Immutable; +using DotNetCampus.CommandLine.CodeAnalysis; +using DotNetCampus.CommandLine.Generators.Models; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -9,30 +11,6 @@ namespace DotNetCampus.CommandLine.Analyzers; [DiagnosticAnalyzer(LanguageNames.CSharp)] public class FindOptionPropertyTypeAnalyzer : DiagnosticAnalyzer { - private readonly HashSet _nonGenericTypeNames = - [ - "String", "string", "Boolean", "bool", "Byte", "byte", "Int16", "short", "UInt16", "ushort", "Int32", "int", "UInt32", "uint", "Int64", "long", - "UInt64", "ulong", "Single", "float", "Double", "double", "Decimal", "decimal", "IList", "ICollection", "IEnumerable", - ]; - - private readonly HashSet _oneGenericTypeNames = - [ - "[]", "ImmutableArray", "List", "IList", "IReadOnlyList", "ImmutableHashSet", "Collection", "ICollection", "IReadOnlyCollection", "IEnumerable", - ]; - - private readonly HashSet _rawArgumentsGenericTypeNames = - [ - "[]", "IList", "IReadOnlyList", "ICollection", "IReadOnlyCollection", "IEnumerable", - ]; - - private readonly HashSet _twoGenericTypeNames = - [ - "ImmutableDictionary", "Dictionary", "IDictionary", "IReadOnlyDictionary", "KeyValuePair", - ]; - - private readonly HashSet _genericKeyArgumentTypeNames = ["String", "string"]; - private readonly HashSet _genericArgumentTypeNames = ["String", "string"]; - /// /// Supported diagnostics. /// @@ -49,12 +27,8 @@ public class FindOptionPropertyTypeAnalyzer : DiagnosticAnalyzer /// public override void Initialize(AnalysisContext context) { - if (context is null) - { - throw new ArgumentNullException(nameof(context)); - } - context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); context.RegisterSyntaxNodeAction(AnalyzeProperty, SyntaxKind.PropertyDeclaration); } @@ -73,60 +47,60 @@ private void AnalyzeProperty(SyntaxNodeAnalysisContext context) foreach (var attributeSyntax in propertyNode.AttributeLists.SelectMany(x => x.Attributes)) { - string? attributeName = attributeSyntax.Name switch + var attributeName = attributeSyntax.Name switch { IdentifierNameSyntax identifierName => identifierName.ToString(), QualifiedNameSyntax qualifiedName => qualifiedName.ChildNodes().OfType().LastOrDefault()?.ToString(), _ => null, }; - if (attributeName != null) + if (attributeName == null) { - var attributeType = context.SemanticModel.GetTypeInfo(attributeSyntax).Type; - var isOptionAttributeType = optionTypes.Any(x => SymbolEqualityComparer.Default.Equals(x, attributeType)); - var isRawArgumentsAttributeType = rawArgumentsTypes.Any(x => SymbolEqualityComparer.Default.Equals(x, attributeType)); + continue; + } + + var attributeType = context.SemanticModel.GetTypeInfo(attributeSyntax).Type; + var isOptionAttributeType = optionTypes.Any(x => SymbolEqualityComparer.Default.Equals(x, attributeType)); + var isRawArgumentsAttributeType = rawArgumentsTypes.Any(x => SymbolEqualityComparer.Default.Equals(x, attributeType)); + + // [Option], [Value] + if (isOptionAttributeType) + { + var isValidPropertyUsage = AnalyzeOptionPropertyType(context.SemanticModel, propertyNode); + var diagnostic = CreateDiagnosticForTypeSyntax( + context.SemanticModel, + isValidPropertyUsage + ? Diagnostics.DCL201_SupportedOptionPropertyType + : Diagnostics.DCL202_NotSupportedOptionPropertyType, + propertyNode); + context.ReportDiagnostic(diagnostic); + break; + } - // [Option], [Value] - if (isOptionAttributeType) + // [RawArguments] + if (isRawArgumentsAttributeType) + { + var isValidPropertyUsage = AnalyzeRawArgumentsPropertyType(context.SemanticModel, propertyNode); + if (!isValidPropertyUsage) { - var isValidPropertyUsage = AnalyzeOptionPropertyType(context.SemanticModel, propertyNode); var diagnostic = CreateDiagnosticForTypeSyntax( - isValidPropertyUsage - ? Diagnostics.DCL201_SupportedOptionPropertyType - : Diagnostics.DCL202_NotSupportedOptionPropertyType, + context.SemanticModel, + Diagnostics.DCL203_NotSupportedRawArgumentsPropertyType, propertyNode); context.ReportDiagnostic(diagnostic); break; } - - // [RawArguments] - if (isRawArgumentsAttributeType) - { - var isValidPropertyUsage = AnalyzeRawArgumentsPropertyType(context.SemanticModel, propertyNode); - if (!isValidPropertyUsage) - { - var diagnostic = CreateDiagnosticForTypeSyntax( - Diagnostics.DCL203_NotSupportedRawArgumentsPropertyType, - propertyNode); - context.ReportDiagnostic(diagnostic); - break; - } - } } } } - private Diagnostic CreateDiagnosticForTypeSyntax(DiagnosticDescriptor rule, PropertyDeclarationSyntax propertySyntax) + private Diagnostic CreateDiagnosticForTypeSyntax(SemanticModel semanticModel, DiagnosticDescriptor rule, PropertyDeclarationSyntax propertySyntax) { - var typeSyntax = propertySyntax.Type; - if (typeSyntax is NullableTypeSyntax nullableTypeSyntax) - { - // string? - typeSyntax = nullableTypeSyntax.ElementType; - } - string typeName = GetTypeName(typeSyntax); - - return Diagnostic.Create(rule, typeSyntax.GetLocation(), typeName); + var typeSyntax = propertySyntax.Type is NullableTypeSyntax nullableTypeSyntax + ? nullableTypeSyntax.ElementType + : propertySyntax.Type; + var propertyTypeSymbol = (ITypeSymbol)semanticModel.GetSymbolInfo(propertySyntax.Type).Symbol!; + return Diagnostic.Create(rule, typeSyntax.GetLocation(), propertyTypeSymbol.GetSymbolInfoAsCommandProperty().GetSimpleName()); } /// @@ -137,44 +111,9 @@ private Diagnostic CreateDiagnosticForTypeSyntax(DiagnosticDescriptor rule, Prop /// private bool AnalyzeOptionPropertyType(SemanticModel semanticModel, PropertyDeclarationSyntax propertySyntax) { - var propertyTypeSyntax = propertySyntax.Type; - string typeName = GetTypeName(propertyTypeSyntax); - var (genericType0, genericType1) = GetGenericTypeNames(propertyTypeSyntax); - - if (IsTwoGenericType(typeName) - && genericType0 != null && genericType1 != null - && IsGenericKeyArgumentType(genericType0) - && IsGenericArgumentType(genericType1)) - { - return true; - } - - if (IsOneGenericType(typeName) - && genericType0 != null - && IsGenericArgumentType(genericType0)) - { - return true; - } - - if (IsNonGenericType(typeName)) - { - return true; - } - - if (propertyTypeSyntax is NullableTypeSyntax nullableTypeSyntax - && semanticModel.GetSymbolInfo(nullableTypeSyntax.ElementType).Symbol is INamedTypeSymbol { TypeKind: TypeKind.Enum }) - { - // Enum? - return true; - } - - if (semanticModel.GetSymbolInfo(propertyTypeSyntax).Symbol is INamedTypeSymbol { TypeKind: TypeKind.Enum }) - { - // Enum - return true; - } - - return false; + var propertyTypeSymbol = (ITypeSymbol)semanticModel.GetSymbolInfo(propertySyntax.Type).Symbol!; + var propertyInfo = propertyTypeSymbol.GetSymbolInfoAsCommandProperty(); + return propertyInfo.Kind is not CommandValueKind.Unknown; } /// @@ -185,102 +124,8 @@ private bool AnalyzeOptionPropertyType(SemanticModel semanticModel, PropertyDecl /// private bool AnalyzeRawArgumentsPropertyType(SemanticModel semanticModel, PropertyDeclarationSyntax propertySyntax) { - var propertyTypeSyntax = propertySyntax.Type; - string typeName = GetTypeName(propertyTypeSyntax); - var (genericType0, genericType1) = GetGenericTypeNames(propertyTypeSyntax); - - if (IsRawArgumentsGenericType(typeName) - && genericType0 != null - && IsGenericArgumentType(genericType0)) - { - return true; - } - - return false; + var propertyTypeSymbol = (ITypeSymbol)semanticModel.GetSymbolInfo(propertySyntax.Type).Symbol!; + var propertyInfo = propertyTypeSymbol.GetSymbolInfoAsCommandProperty(); + return propertyInfo.IsAssignableFromArrayOrList(); } - - private string GetTypeName(TypeSyntax typeSyntax) - { - if (typeSyntax is NullableTypeSyntax nullableTypeSyntax) - { - // string? - typeSyntax = nullableTypeSyntax.ElementType; - } - - if (typeSyntax is GenericNameSyntax genericNameSyntax) - { - // List - // Dictionary - return genericNameSyntax.Identifier.ToString(); - } - - if (typeSyntax is ArrayTypeSyntax) - { - // string[] - return "[]"; - } - - if (typeSyntax is PredefinedTypeSyntax predefinedTypeSyntax) - { - // string - return predefinedTypeSyntax.ToString(); - } - - if (typeSyntax is QualifiedNameSyntax qualifiedNameSyntax) - { - // System.String - return qualifiedNameSyntax.ChildNodes().OfType().Last().ToString(); - } - - // String - return typeSyntax.ToString(); - } - - private (string?, string?) GetGenericTypeNames(TypeSyntax typeSyntax) - { - if (typeSyntax is NullableTypeSyntax nullableTypeSyntax) - { - // string? - typeSyntax = nullableTypeSyntax.ElementType; - } - - string? genericType0 = null, genericType1 = null; - if (typeSyntax is GenericNameSyntax genericNameSyntax) - { - var genericTypes = genericNameSyntax.TypeArgumentList.ChildNodes().OfType().ToList(); - genericType0 = GetTypeName(genericTypes[0]); - if (genericTypes.Count == 2) - { - genericType1 = GetTypeName(genericTypes[1]); - } - else if (genericTypes.Count > 2) - { - genericType0 = null; - genericType1 = null; - } - } - else if (typeSyntax is ArrayTypeSyntax arrayTypeSyntax) - { - genericType0 = GetTypeName(arrayTypeSyntax.ElementType); - } - return (genericType0, genericType1); - } - - private bool IsNonGenericType(string typeName) - => _nonGenericTypeNames.Contains(typeName); - - private bool IsOneGenericType(string typeName) - => _oneGenericTypeNames.Contains(typeName); - - private bool IsRawArgumentsGenericType(string typeName) - => _rawArgumentsGenericTypeNames.Contains(typeName); - - private bool IsTwoGenericType(string typeName) - => _twoGenericTypeNames.Contains(typeName); - - private bool IsGenericKeyArgumentType(string typeName) - => _genericKeyArgumentTypeNames.Contains(typeName); - - private bool IsGenericArgumentType(string typeName) - => _genericArgumentTypeNames.Contains(typeName); } diff --git a/src/DotNetCampus.CommandLine.Analyzer/Analyzers/OptionLongNameMustBeKebabCaseAnalyzer.cs b/src/DotNetCampus.CommandLine.Analyzer/Analyzers/OptionLongNameMustBeKebabCaseAnalyzer.cs index 6d313e9d..64715106 100644 --- a/src/DotNetCampus.CommandLine.Analyzer/Analyzers/OptionLongNameMustBeKebabCaseAnalyzer.cs +++ b/src/DotNetCampus.CommandLine.Analyzer/Analyzers/OptionLongNameMustBeKebabCaseAnalyzer.cs @@ -31,6 +31,7 @@ public class OptionLongNameMustBeKebabCaseAnalyzer : DiagnosticAnalyzer [ Diagnostics.DCL101_OptionLongNameMustBeKebabCase, Diagnostics.DCL102_OptionLongNameCanBeKebabCase, + Diagnostics.DCL103_OptionNameIsInvalid, ]; /// @@ -39,14 +40,11 @@ public class OptionLongNameMustBeKebabCaseAnalyzer : DiagnosticAnalyzer /// public override void Initialize(AnalysisContext context) { - if (context is null) - { - throw new ArgumentNullException(nameof(context)); - } - context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); context.RegisterSyntaxNodeAction(AnalyzeProperty, SyntaxKind.PropertyDeclaration); context.RegisterSyntaxNodeAction(AnalyzeClass, SyntaxKind.ClassDeclaration); + context.RegisterSyntaxNodeAction(AnalyzeRecord, SyntaxKind.RecordDeclaration); } /// @@ -59,6 +57,16 @@ private void AnalyzeClass(SyntaxNodeAnalysisContext context) AnalyzeAttribute(context, classNode.AttributeLists, true); } + /// + /// Find CommandAttribute from a property. + /// + /// + private void AnalyzeRecord(SyntaxNodeAnalysisContext context) + { + var classNode = (RecordDeclarationSyntax)context.Node; + AnalyzeAttribute(context, classNode.AttributeLists, true); + } + /// /// Find OptionAttribute from a property. /// @@ -81,63 +89,162 @@ private void AnalyzeAttribute(SyntaxNodeAnalysisContext context, SyntaxList /// Find LongName argument from the OptionAttribute. /// + /// /// /// /// /// name: the LongName value. /// location: the syntax tree location of the LongName argument value. /// - private (string? Name, Location? Location, SuggestionType SuggestionType) AnalyzeOptionAttributeArguments(AttributeSyntax attributeSyntax, - bool hasSeparator) + private void AnalyzeOptionAttributeArguments(SyntaxNodeAnalysisContext context, AttributeSyntax attributeSyntax, bool hasSeparator) { var argumentList = attributeSyntax.ChildNodes().OfType().FirstOrDefault(); - if (argumentList != null) + if (argumentList == null) + { + return; + } + + var attributeArguments = argumentList.ChildNodes().OfType().ToList(); + var optionNameArguments = attributeArguments.Where(x => x.NameEquals is null).ToList(); + List shortNameExpressions = optionNameArguments.Count switch + { + 2 => + [ + ..optionNameArguments[0].DescendantNodes().OfType(), + ..optionNameArguments[0].DescendantNodes().OfType(), + ], + _ => [], + }; + List longNameExpressions = optionNameArguments.Count switch + { + 1 => + [ + ..optionNameArguments[0].DescendantNodes().OfType(), + ..optionNameArguments[0].DescendantNodes().OfType(), + ], + 2 => + [ + ..optionNameArguments[1].DescendantNodes().OfType(), + ..optionNameArguments[1].DescendantNodes().OfType(), + ], + _ => [], + }; + foreach (var shortNameExpression in shortNameExpressions) + { + var shortName = shortNameExpression switch + { + LiteralExpressionSyntax le => le.Token.ValueText, + InvocationExpressionSyntax ie => ie.DescendantNodes().OfType() + .Select(x => x.DescendantNodes().OfType().FirstOrDefault()?.Identifier.ToString()).FirstOrDefault(), + _ => null, + }; + if (shortName is null) + { + continue; + } + if (CheckIsInvalidOptionName(shortName, hasSeparator)) + { + context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.DCL103_OptionNameIsInvalid, + shortNameExpression?.GetLocation(), shortName)); + } + } + foreach (var longNameExpression in longNameExpressions) { - var attributeArguments = argumentList.ChildNodes().OfType().ToList(); - var longNameExpression = attributeArguments.FirstOrDefault()?.Expression as LiteralExpressionSyntax; - var longName = longNameExpression?.Token.ValueText; - var ignoreCaseExpression = - attributeArguments.FirstOrDefault(x => x.NameEquals?.Name.ToString() == "ExactSpelling")?.Expression as LiteralExpressionSyntax; - var exactSpelling = ignoreCaseExpression?.Token.ValueText.Equals("true", StringComparison.OrdinalIgnoreCase) is true; - if (!exactSpelling && longName is not null) + var longName = longNameExpression switch + { + LiteralExpressionSyntax le => le.Token.ValueText, + InvocationExpressionSyntax ie => ie.DescendantNodes().OfType() + .Select(x => x.DescendantNodes().OfType().FirstOrDefault()?.Identifier.ToString()).FirstOrDefault(), + _ => null, + }; + if (longName is null) + { + continue; + } + if (CheckIsInvalidOptionName(longName, hasSeparator)) + { + context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.DCL103_OptionNameIsInvalid, + longNameExpression?.GetLocation(), longName)); + } + var caseSensitiveExpression = attributeArguments + .FirstOrDefault(x => x.NameEquals?.Name.ToString() == "CaseSensitive")? + .Expression as LiteralExpressionSyntax; + var caseSensitive = caseSensitiveExpression?.Token.ValueText.Equals("true", StringComparison.OrdinalIgnoreCase) is true; + if (!caseSensitive) { // 严格检查。 - var kebabCase1 = MakeKebabCase(longName, true, false, hasSeparator); - var isKebabCase1 = string.Equals(kebabCase1, longName, StringComparison.Ordinal); - if (!isKebabCase1) + var kebabCase = MakeKebabCase(longName, true, false, hasSeparator); + var isKebabCase = string.Equals(kebabCase, longName, StringComparison.Ordinal); + if (!isKebabCase) + { + context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.DCL101_OptionLongNameMustBeKebabCase, + longNameExpression?.GetLocation(), longName)); + } + else { - return (longName, longNameExpression?.GetLocation(), SuggestionType.Warning); + // 宽松检查。 + var kebabCase2 = MakeKebabCase(longName, true, true, hasSeparator); + var isKebabCase2 = string.Equals(kebabCase2, longName, StringComparison.Ordinal); + if (!isKebabCase2) + { + context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.DCL102_OptionLongNameCanBeKebabCase, + longNameExpression?.GetLocation(), longName)); + } } + } + } + } - // 宽松检查。 - var kebabCase2 = MakeKebabCase(longName, true, true, hasSeparator); - var isKebabCase2 = string.Equals(kebabCase2, longName, StringComparison.Ordinal); - if (!isKebabCase2) + private bool CheckIsInvalidOptionName(string optionName, bool hasSeparator) + { + if (hasSeparator) + { + return optionName.Split([' '], StringSplitOptions.RemoveEmptyEntries) + .Any(CheckIsInvalidOptionNameCore); + } + else + { + return CheckIsInvalidOptionNameCore(optionName); + } + + static bool CheckIsInvalidOptionNameCore(string name) + { + if (string.IsNullOrEmpty(name)) + { + return true; + } + + for (var i = 0; i < name.Length; i++) + { + var c = name[i]; + if (i is 0 && c is '-') { - return (longName, longNameExpression?.GetLocation(), SuggestionType.Hidden); + return true; + } + if (!char.IsLetterOrDigit(c) && c != '-' && c != '_') + { + return true; } } + + return false; } - return (null, null, SuggestionType.Hidden); } private string MakeKebabCase(string oldName, bool isUpperSeparator, bool toLower, bool hasSeparator) @@ -155,7 +262,7 @@ private string MakeKebabCase(string oldName, bool isUpperSeparator, bool toLower private enum SuggestionType { - Hidden, + Info, Warning, } } diff --git a/src/DotNetCampus.CommandLine.Analyzer/CodeAnalysis/CommandPropertyTypeInfo.cs b/src/DotNetCampus.CommandLine.Analyzer/CodeAnalysis/CommandPropertyTypeInfo.cs new file mode 100644 index 00000000..8eeb15f7 --- /dev/null +++ b/src/DotNetCampus.CommandLine.Analyzer/CodeAnalysis/CommandPropertyTypeInfo.cs @@ -0,0 +1,255 @@ +using Microsoft.CodeAnalysis; + +namespace DotNetCampus.CommandLine.CodeAnalysis; + +/// +/// 命令行属性的类型信息。 +/// +internal record CommandPropertyTypeInfo +{ + public CommandPropertyTypeInfo(ITypeSymbol typeSymbol) + { + TypeSymbol = GetNotNullTypeSymbol(typeSymbol); + Kind = GetSymbolInfoAsCommandProperty(typeSymbol); + } + + public ITypeSymbol TypeSymbol { get; } + + public CommandValueKind Kind { get; } + + /// + /// 获取类型的简单名称,仅包含名称本身,不包含命名空间、泛型参数、可空标记等信息,尽可能使用类型关键字。 + /// + /// + public string GetSimpleName() => TypeSymbol.ToDisplayString(SimpleNameFormat); + + /// + /// 获取类型的简单名称,仅包含名称本身,不包含命名空间、泛型参数、可空标记等信息,尽可能使用类型名称而不是关键字。 + /// + /// + public string GetSimpleDeclarationName() => TypeSymbol.ToDisplayString(SimpleDeclarationNameFormat); + + /// + /// 获取当前类型是否可以从数组或列表赋值。 + /// + /// 如果可以从数组或列表赋值,则返回 ;否则返回 + public bool IsAssignableFromArrayOrList() + { + if (Kind is not CommandValueKind.List) + { + return false; + } + + if (TypeSymbol.Kind is SymbolKind.ArrayType) + { + return true; + } + + var simpleName = GetSimpleName(); + return AllowedArrayOrListTypeNames.Contains(simpleName); + } + + /// + /// 如果当前类型是枚举类型,则返回其枚举类型符号,否则返回 。 + /// + /// 枚举类型符号,或 + public ITypeSymbol? AsEnumSymbol() + { + return Kind is not CommandValueKind.Enum ? null : TypeSymbol; + } + + /// + /// 获取类型的非抽象名称。
+ /// 对于命令行解析中所支持的各种接口,会被映射为其常见的具体类型名称。 + ///
+ /// 非抽象名称。 + public string GetGeneratedNotAbstractTypeName() + { + if (TypeSymbol.Kind is SymbolKind.ArrayType) + { + return "Array"; + } + + return Kind switch + { + CommandValueKind.Boolean => TypeSymbol.ToDisplayString(SimpleDeclarationNameFormat), + CommandValueKind.Number => TypeSymbol.ToDisplayString(SimpleDeclarationNameFormat), + CommandValueKind.Enum => "Enum", + CommandValueKind.String => TypeSymbol.ToDisplayString(SimpleDeclarationNameFormat), + CommandValueKind.List => AllowedListTypeNames.TryGetValue(GetSimpleName(), out var list) + ? list + : "List", + CommandValueKind.Dictionary => AllowedDictionaryTypeNames.TryGetValue(GetSimpleName(), out var dict) + ? dict + : "Dictionary", + _ => "Unknown", + }; + } + + /// + /// 允许的单泛型类型名称。 + /// + /// + /// 允许的单泛型类型名称。 + /// + private static readonly Dictionary AllowedListTypeNames = new Dictionary + { + ["Collection"] = "Collection", + ["HashSet"] = "HashSet", + ["ICollection"] = "List", + ["IEnumerable"] = "List", + ["IImmutableList"] = "ImmutableList", + ["IImmutableSet"] = "ImmutableHashSet", + ["IList"] = "List", + ["ImmutableArray"] = "ImmutableArray", + ["ImmutableHashSet"] = "ImmutableHashSet", + ["ImmutableList"] = "ImmutableList", + ["ImmutableSortedSet"] = "ImmutableSortedSet", + ["IReadOnlyCollection"] = "List", + ["IReadOnlyList"] = "List", + ["ISet"] = "HashSet", + ["List"] = "List", + ["ReadOnlyCollection"] = "ReadOnlyCollection", + ["SortedSet"] = "SortedSet", + }; + + /// + /// 允许的双泛型类型名称。 + /// + private static readonly Dictionary AllowedDictionaryTypeNames = new Dictionary + { + ["Dictionary"] = "Dictionary", + ["IDictionary"] = "Dictionary", + ["ImmutableDictionary"] = "ImmutableDictionary", + ["ImmutableSortedDictionary"] = "ImmutableSortedDictionary", + ["IReadOnlyDictionary"] = "Dictionary", + ["KeyValuePair"] = "KeyValuePair", + ["SortedDictionary"] = "SortedDictionary", + }; + + /// + /// 允许的 RawArguments 泛型类型名称。 + /// + private static readonly HashSet AllowedArrayOrListTypeNames = + [ + "IList", "IReadOnlyList", "ICollection", "IReadOnlyCollection", "IEnumerable", + ]; + + /// + /// 用于将类型符号转换为仅包含名称的字符串形式。会去掉可空标记、命名空间、泛型参数等信息。 + /// + private static readonly SymbolDisplayFormat SimpleNameFormat = new SymbolDisplayFormat( + globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Omitted, + typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameOnly, + genericsOptions: SymbolDisplayGenericsOptions.None, + kindOptions: SymbolDisplayKindOptions.None, + miscellaneousOptions: SymbolDisplayMiscellaneousOptions.UseSpecialTypes + ); + + private static readonly SymbolDisplayFormat SimpleDeclarationNameFormat = new SymbolDisplayFormat( + globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Omitted, + typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameOnly, + genericsOptions: SymbolDisplayGenericsOptions.None, + kindOptions: SymbolDisplayKindOptions.None + ); + + /// + /// 视 为命令行属性的类型,按命令行属性的要求获取其所需的类型信息。
+ /// 这个过程会丢掉类型的可空性信息。 + ///
+ /// 类型符号。 + /// 类型信息。 + private static CommandValueKind GetSymbolInfoAsCommandProperty(ITypeSymbol typeSymbol) + { + var notNullTypeSymbol = GetNotNullTypeSymbol(typeSymbol); + + switch (notNullTypeSymbol.SpecialType) + { + case SpecialType.System_Boolean: + return CommandValueKind.Boolean; + case SpecialType.System_Byte: + case SpecialType.System_SByte: + case SpecialType.System_Decimal: + case SpecialType.System_Double: + case SpecialType.System_Single: + case SpecialType.System_Int16: + case SpecialType.System_UInt16: + case SpecialType.System_Int32: + case SpecialType.System_UInt32: + // 不应支持这种不能跨进程传递的类型。 + // case SpecialType.System_IntPtr: + // case SpecialType.System_UIntPtr: + case SpecialType.System_Int64: + case SpecialType.System_UInt64: + return CommandValueKind.Number; + case SpecialType.System_Char: + case SpecialType.System_String: + return CommandValueKind.String; + case SpecialType.System_Array: + case SpecialType.System_Collections_IEnumerable: + case SpecialType.System_Collections_Generic_IEnumerable_T: + case SpecialType.System_Collections_Generic_IList_T: + case SpecialType.System_Collections_Generic_ICollection_T: + case SpecialType.System_Collections_IEnumerator: + case SpecialType.System_Collections_Generic_IEnumerator_T: + case SpecialType.System_Collections_Generic_IReadOnlyList_T: + case SpecialType.System_Collections_Generic_IReadOnlyCollection_T: + return CommandValueKind.List; + case SpecialType.None: + // 其他类型,进行后续分析。 + break; + default: + return CommandValueKind.Unknown; + } + + if (notNullTypeSymbol.TypeKind is TypeKind.Enum) + { + return CommandValueKind.Enum; + } + + // List + if (typeSymbol is IArrayTypeSymbol { ElementType.SpecialType: SpecialType.System_String }) + { + return CommandValueKind.List; + } + + // List + if (notNullTypeSymbol is INamedTypeSymbol + { + TypeArguments: [{ SpecialType: SpecialType.System_String }], + OriginalDefinition.Name: { } oneGenericName, + } && AllowedListTypeNames.ContainsKey(oneGenericName)) + { + return CommandValueKind.List; + } + + // Dictionary + if (notNullTypeSymbol is INamedTypeSymbol + { + TypeArguments: [{ SpecialType: SpecialType.System_String }, { SpecialType: SpecialType.System_String }], + OriginalDefinition.Name: { } twoGenericName, + } && AllowedDictionaryTypeNames.ContainsKey(twoGenericName)) + { + return CommandValueKind.Dictionary; + } + + return CommandValueKind.Unknown; + } + + /// + /// 如果 是可空值类型,则递归返回其基础类型,否则直接返回 本身。
+ /// 不会处理其泛型参数的可空性。 + ///
+ /// 要处理的类型符号。 + /// 基础类型符号。 + private static ITypeSymbol GetNotNullTypeSymbol(ITypeSymbol typeSymbol) => typeSymbol switch + { + INamedTypeSymbol + { + IsValueType: true, + IsGenericType: true, + OriginalDefinition.SpecialType: SpecialType.System_Nullable_T, + } nullableTypeSymbol => nullableTypeSymbol.TypeArguments[0], + _ => typeSymbol, + }; +} diff --git a/src/DotNetCampus.CommandLine.Analyzer/CodeAnalysis/CommandValueKind.cs b/src/DotNetCampus.CommandLine.Analyzer/CodeAnalysis/CommandValueKind.cs new file mode 100644 index 00000000..825be56f --- /dev/null +++ b/src/DotNetCampus.CommandLine.Analyzer/CodeAnalysis/CommandValueKind.cs @@ -0,0 +1,43 @@ +namespace DotNetCampus.CommandLine.CodeAnalysis; + +/// +/// 从命令行解析参数的含义时,对于值(选项和位置参数)的类型的分类。
+/// 源生成器给命令行对象的不同类型属性赋值时,只有这些类型才存在代码上的差异,其他类型都可映射到这些类型上。 +///
+internal enum CommandValueKind +{ + /// + /// 尚不知道是什么类型。 + /// + Unknown, + + /// + /// 布尔类型。 + /// + Boolean, + + /// + /// 数值类型,包括所有的整数和浮点数。 + /// + Number, + + /// + /// 枚举类型。 + /// + Enum, + + /// + /// 字符串类型。 + /// + String, + + /// + /// 列表类型。 + /// + List, + + /// + /// 字典类型。 + /// + Dictionary, +} diff --git a/src/DotNetCampus.CommandLine.Analyzer/CodeFixes/OptionLongNameMustBeKebabCaseCodeFixProvider.cs b/src/DotNetCampus.CommandLine.Analyzer/CodeFixes/OptionLongNameMustBeKebabCaseCodeFixProvider.cs index b8e9a72a..9ad0f748 100644 --- a/src/DotNetCampus.CommandLine.Analyzer/CodeFixes/OptionLongNameMustBeKebabCaseCodeFixProvider.cs +++ b/src/DotNetCampus.CommandLine.Analyzer/CodeFixes/OptionLongNameMustBeKebabCaseCodeFixProvider.cs @@ -40,11 +40,10 @@ public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context) var diagnostic = context.Diagnostics.First(); var diagnosticSpan = diagnostic.Location.SourceSpan; - ExpressionSyntax? syntax = root.FindNode(diagnosticSpan) switch + var syntax = root.FindNode(diagnosticSpan) switch { - AttributeArgumentSyntax attributeArgumentSyntax => attributeArgumentSyntax.Expression, - ExpressionSyntax expressionSyntax => expressionSyntax, - _ => null, + LiteralExpressionSyntax expressionSyntax => expressionSyntax, + { } node => node.DescendantNodes().OfType().FirstOrDefault(), }; // 判断此 syntax 是属于 CommandAttribute 还是 OptionAttribute。 var attributeSyntax = syntax?.FirstAncestorOrSelf(); @@ -62,7 +61,7 @@ public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context) } } - private async Task MakeKebabCaseAsync(Document document, ExpressionSyntax expressionSyntax, + private async Task MakeKebabCaseAsync(Document document, LiteralExpressionSyntax expressionSyntax, bool hasSeparator, CancellationToken cancellationToken) { var expression = expressionSyntax.ToString(); diff --git a/src/DotNetCampus.CommandLine.Analyzer/Diagnostics.cs b/src/DotNetCampus.CommandLine.Analyzer/Diagnostics.cs index 93f41902..e2677925 100644 --- a/src/DotNetCampus.CommandLine.Analyzer/Diagnostics.cs +++ b/src/DotNetCampus.CommandLine.Analyzer/Diagnostics.cs @@ -18,7 +18,7 @@ public static class Diagnostics DiagnosticSeverity.Warning, isEnabledByDefault: true, description: Localize(nameof(DCL101_Description)), - helpLinkUri: Url(OptionLongNameMustBeKebabCase)); + helpLinkUri: Url(nameof(DCL101))); public static readonly DiagnosticDescriptor DCL102_OptionLongNameCanBeKebabCase = new DiagnosticDescriptor( nameof(DCL102), @@ -28,7 +28,18 @@ public static class Diagnostics DiagnosticSeverity.Info, isEnabledByDefault: true, description: Localize(nameof(DCL102_Description)), - helpLinkUri: Url(OptionLongNameCanBeKebabCase)); + helpLinkUri: Url(nameof(DCL102))); + + public static readonly DiagnosticDescriptor DCL103_OptionNameIsInvalid = new DiagnosticDescriptor( + nameof(DCL103), + Localize(nameof(DCL103)), + Localize(nameof(DCL103_Message)), + Categories.RuntimeException, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: Localize(nameof(DCL103_Description)), + helpLinkUri: Url(nameof(DCL103)), + customTags: WellKnownDiagnosticTags.NotConfigurable); #endregion @@ -42,7 +53,7 @@ public static class Diagnostics DiagnosticSeverity.Hidden, isEnabledByDefault: true, description: Localize(nameof(DCL201_Description)), - helpLinkUri: Url(SupportedOptionPropertyType)); + helpLinkUri: Url(nameof(DCL201))); public static readonly DiagnosticDescriptor DCL202_NotSupportedOptionPropertyType = new DiagnosticDescriptor( nameof(DCL202), @@ -52,7 +63,7 @@ public static class Diagnostics DiagnosticSeverity.Error, isEnabledByDefault: true, description: Localize(nameof(DCL202_Description)), - helpLinkUri: Url(NotSupportedOptionPropertyType)); + helpLinkUri: Url(nameof(DCL202))); public static readonly DiagnosticDescriptor DCL203_NotSupportedRawArgumentsPropertyType = new DiagnosticDescriptor( nameof(DCL203), @@ -62,15 +73,43 @@ public static class Diagnostics DiagnosticSeverity.Error, isEnabledByDefault: true, description: Localize(nameof(DCL203_Description)), - helpLinkUri: Url(NotSupportedRawArgumentsPropertyType)); + helpLinkUri: Url(nameof(DCL203))); + + public static readonly DiagnosticDescriptor DCL204_DuplicateOptionNames = new DiagnosticDescriptor( + nameof(DCL204), + Localize(nameof(DCL204)), + Localize(nameof(DCL204_Message)), + Categories.Mechanism, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: Localize(nameof(DCL204_Description)), + helpLinkUri: Url(nameof(DCL204))); + + #endregion + + #region Generator 301-399 + + public static readonly DiagnosticDescriptor DCL301_GenericCommandObjectTypeNotSupported = new DiagnosticDescriptor( + nameof(DCL301), + Localize(nameof(DCL301)), + Localize(nameof(DCL301_Message)), + Categories.Mechanism, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: Localize(nameof(DCL301_Description)), + helpLinkUri: Url(nameof(DCL301)), + customTags: WellKnownDiagnosticTags.NotConfigurable); #endregion public const string OptionLongNameMustBeKebabCase = "DCL101"; public const string OptionLongNameCanBeKebabCase = "DCL102"; + public const string OptionNameIsInvalid = "DCL103"; public const string SupportedOptionPropertyType = "DCL201"; public const string NotSupportedOptionPropertyType = "DCL202"; public const string NotSupportedRawArgumentsPropertyType = "DCL203"; + public const string DuplicateOptionNames = "DCL204"; + public const string GenericCommandObjectTypeNotSupported = "DCL301"; private static class Categories { diff --git a/src/DotNetCampus.CommandLine.Analyzer/DotNetCampus.CommandLine.Analyzer.csproj b/src/DotNetCampus.CommandLine.Analyzer/DotNetCampus.CommandLine.Analyzer.csproj index 2495d2f5..076fb0da 100644 --- a/src/DotNetCampus.CommandLine.Analyzer/DotNetCampus.CommandLine.Analyzer.csproj +++ b/src/DotNetCampus.CommandLine.Analyzer/DotNetCampus.CommandLine.Analyzer.csproj @@ -8,7 +8,8 @@ - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/DotNetCampus.CommandLine.Analyzer/GeneratorInfo.cs b/src/DotNetCampus.CommandLine.Analyzer/GeneratorInfo.cs deleted file mode 100644 index 6fead507..00000000 --- a/src/DotNetCampus.CommandLine.Analyzer/GeneratorInfo.cs +++ /dev/null @@ -1,68 +0,0 @@ -using System.Reflection; -using Microsoft.CodeAnalysis; - -namespace DotNetCampus.CommandLine; - -internal static class GeneratorInfo -{ - public static string RootNamespace => typeof(GeneratorInfo).Namespace!; - - public static string ToolName { get; } = typeof(GeneratorInfo).Assembly - .GetCustomAttribute()?.Title ?? typeof(GeneratorInfo).Namespace!; - - public static string ToolVersion { get; } = typeof(GeneratorInfo).Assembly - .GetCustomAttribute()?.InformationalVersion ?? "0.0.0"; - - private static readonly SymbolDisplayFormat GlobalDisplayFormat = new SymbolDisplayFormat( - globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Included, - typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, - genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters, - miscellaneousOptions: - SymbolDisplayMiscellaneousOptions.EscapeKeywordIdentifiers | - SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier | - SymbolDisplayMiscellaneousOptions.UseSpecialTypes); - - private static readonly SymbolDisplayFormat NotNullGlobalDisplayFormat = new SymbolDisplayFormat( - globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Included, - typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, - genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters, - miscellaneousOptions: - SymbolDisplayMiscellaneousOptions.EscapeKeywordIdentifiers | - SymbolDisplayMiscellaneousOptions.UseSpecialTypes); - - private static readonly SymbolDisplayFormat GlobalTypeOfDisplayFormat = new SymbolDisplayFormat( - globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Included, - typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, - genericsOptions: SymbolDisplayGenericsOptions.None, - miscellaneousOptions: - SymbolDisplayMiscellaneousOptions.EscapeKeywordIdentifiers | - SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier | - SymbolDisplayMiscellaneousOptions.UseSpecialTypes); - - public static string ToGlobalDisplayString(this ISymbol symbol) - { - return symbol.ToDisplayString(GlobalDisplayFormat); - } - - public static string ToNotNullGlobalDisplayString(this ISymbol symbol) - { - // 对于 Nullable(例如 Nullable、int?)等,是类型而不是可空标记,所以需要特别取出里面的类型 T。 - if (symbol is ITypeSymbol { IsValueType: true, OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } typeSymbol) - { - return typeSymbol is INamedTypeSymbol { IsGenericType: true, ConstructedFrom.SpecialType: SpecialType.System_Nullable_T } namedType - // 获取 Nullable 中的 T。 - ? namedType.TypeArguments[0].ToDisplayString(GlobalDisplayFormat) - // 处理直接带有可空标记的类型 (int? 这种形式)。 - : typeSymbol.WithNullableAnnotation(NullableAnnotation.None).ToDisplayString(GlobalDisplayFormat); - } - - // 对于其他符号或非可空类型,使用不包含可空引用类型修饰符的格式 - return symbol.ToDisplayString(NotNullGlobalDisplayFormat); - } - - public static string ToGlobalTypeOfDisplayString(this INamedTypeSymbol symbol) - { - var name = symbol.ToDisplayString(GlobalTypeOfDisplayFormat); - return symbol.IsGenericType ? $"{name}<{new string(',', symbol.TypeArguments.Length - 1)}>" : name; - } -} diff --git a/src/DotNetCampus.CommandLine.Analyzer/Generators/BuilderGenerator.cs b/src/DotNetCampus.CommandLine.Analyzer/Generators/BuilderGenerator.cs deleted file mode 100644 index 48bd6b4e..00000000 --- a/src/DotNetCampus.CommandLine.Analyzer/Generators/BuilderGenerator.cs +++ /dev/null @@ -1,345 +0,0 @@ -using System.Collections.Immutable; -using DotNetCampus.CommandLine.Generators.ModelProviding; -using DotNetCampus.CommandLine.Utils.CodeAnalysis; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.Diagnostics; - -namespace DotNetCampus.CommandLine.Generators; - -[Generator(LanguageNames.CSharp)] -public class BuilderGenerator : IIncrementalGenerator -{ - public void Initialize(IncrementalGeneratorInitializationContext context) - { - var analyzerConfigOptionsProvider = context.AnalyzerConfigOptionsProvider; - var commandOptionsProvider = context.SelectCommandObjects(); - var assemblyCommandsProvider = context.SelectAssemblyCommands(); - - context.RegisterSourceOutput( - commandOptionsProvider, - Execute); - - context.RegisterSourceOutput( - assemblyCommandsProvider.Collect().Combine(commandOptionsProvider.Collect()).Combine(analyzerConfigOptionsProvider), - Execute); - } - - private void Execute(SourceProductionContext context, CommandObjectGeneratingModel model) - { - var code = GenerateCommandObjectCreatorCode(model); - context.AddSource($"CommandLine.Models/{model.Namespace}.{model.CommandObjectType.Name}.cs", code); - } - - private void Execute(SourceProductionContext context, - ((ImmutableArray Left, ImmutableArray Right) Left, AnalyzerConfigOptionsProvider Right) - args) - { - var ((assemblyCommandsGeneratingModels, commandOptionsGeneratingModels), analyzerConfigOptions) = args; - commandOptionsGeneratingModels = [..commandOptionsGeneratingModels.OrderBy(x => x.GetBuilderTypeName())]; - - if (analyzerConfigOptions.GlobalOptions.TryGetValue("DotNetCampusCommandLineUseInterceptor", out var useInterceptor) - && !useInterceptor) - { - var moduleInitializerCode = GenerateModuleInitializerCode(commandOptionsGeneratingModels); - context.AddSource("CommandLine.Metadata/_ModuleInitializer.g.cs", moduleInitializerCode); - } - - foreach (var assemblyCommandsGeneratingModel in assemblyCommandsGeneratingModels) - { - var code = GenerateAssemblyCommandHandlerCode(assemblyCommandsGeneratingModel, commandOptionsGeneratingModels); - context.AddSource( - $"CommandLine.Metadata/{assemblyCommandsGeneratingModel.Namespace}.{assemblyCommandsGeneratingModel.AssemblyCommandHandlerType.Name}.g.cs", - code); - } - } - - private string GenerateCommandObjectCreatorCode(CommandObjectGeneratingModel model) - { - // | required | nullable | cli | 行为 | - // | -------- | -------- | --- | ---------- | - // | 0 | 0 | 0 | 分析器警告 | - // | 1 | 0 | 0 | 抛异常 | - // | 0 | 1 | 0 | 默认值 | - // | 1 | 1 | 0 | 抛异常 | - // | 0 | 0 | 1 | 赋值 | - // | 1 | 0 | 1 | 赋值 | - // | 0 | 1 | 1 | 赋值 | - // | 1 | 1 | 1 | 赋值 | - - var initRawArgumentsProperties = model.RawArgumentsProperties.Where(x => x.IsRequired || x.IsInitOnly).ToImmutableArray(); - var initOptionProperties = model.OptionProperties.Where(x => x.IsRequired || x.IsInitOnly).ToImmutableArray(); - var initValueProperties = model.ValueProperties.Where(x => x.IsRequired || x.IsInitOnly).ToImmutableArray(); - var setRawArgumentsProperties = model.RawArgumentsProperties.Where(x => !x.IsRequired && !x.IsInitOnly).ToImmutableArray(); - var setOptionProperties = model.OptionProperties.Where(x => !x.IsRequired && !x.IsInitOnly).ToImmutableArray(); - var setValueProperties = model.ValueProperties.Where(x => !x.IsRequired && !x.IsInitOnly).ToImmutableArray(); - return $$""" -#nullable enable -namespace {{model.Namespace}}; - -/// -/// 辅助 生成命令行选项、子命令或处理函数的创建。 -/// -{{(model.IsPublic ? "public" : "internal")}} sealed class {{model.GetBuilderTypeName()}} -{ - public static object CreateInstance(global::DotNetCampus.Cli.CommandLine commandLine) - { - var caseSensitive = commandLine.DefaultCaseSensitive; - var result = new {{model.CommandObjectType.ToGlobalDisplayString()}} - { - // 1. [RawArguments] -{{(initRawArgumentsProperties.Length is 0 ? " // MainArgs = commandLine.CommandLineArguments," : string.Join("\n", initRawArgumentsProperties.Select(GenerateRawArgumentsPropertyAssignment)))}} - - // 2. [Option] -{{(initOptionProperties.Length is 0 ? " // There is no option to be initialized." : string.Join("\n", initOptionProperties.Select(GenerateOptionPropertyAssignment)))}} - - // 3. [Value] -{{(initValueProperties.Length is 0 ? " // There is no positional argument to be initialized." : string.Join("\n", initValueProperties.Select((x, i) => GenerateValuePropertyAssignment(model, x, i))))}} - }; - - // 1. [RawArguments] -{{(setRawArgumentsProperties.Length is 0 ? " // result.MainArgs = commandLine.CommandLineArguments;" : string.Join("\n", setRawArgumentsProperties.Select(GenerateRawArgumentsPropertyAssignment)))}} - - // 2. [Option] -{{(setOptionProperties.Length is 0 ? " // There is no option to be assigned." : string.Join("\n", setOptionProperties.Select(GenerateOptionPropertyAssignment)))}} - - // 3. [Value] -{{(setValueProperties.Length is 0 ? " // There is no positional argument to be assigned." : string.Join("\n", setValueProperties.Select((x, i) => GenerateValuePropertyAssignment(model, x, i))))}} - - return result; - } -} - -"""; - } - - private string GenerateOptionPropertyAssignment(OptionPropertyGeneratingModel property, int modelIndex) - { - var isInitProperty = property.IsRequired || property.IsInitOnly; - var toMethod = GetCommandLinePropertyValueToMethodName(property.Type) is { } tm ? $"?.{tm}()" : ""; - var caseSensitive = property.CaseSensitive switch - { - true => ", true", - false => ", false", - null => "", - }; - var exception = property.IsRequired - ? $"throw new global::DotNetCampus.Cli.Exceptions.RequiredPropertyNotAssignedException($\"The command line arguments doesn't contain a required option '{property.GetDisplayCommandOption()}'. Command line: {{commandLine}}\", \"{property.PropertyName}\")" - : (property.IsNullable, property.IsValueType) switch - { - (true, _) => "null", - (false, true) => "default", - (false, false) => "null!", - }; - - var getters = property.GenerateAllNames( - shortOption => $"""commandLine.GetShortOption("{shortOption}"{caseSensitive})""", - longOption => $"""commandLine.GetOption("{longOption}"{caseSensitive})""", - (caseSensitiveLongOption, ignoreCaseLongName) => - $"""commandLine.GetOption(caseSensitive ? "{caseSensitiveLongOption}" : "{ignoreCaseLongName}"{caseSensitive})""", - aliasOption => $"""commandLine.GetOption("{aliasOption}")""" - ); - - return (isInitProperty, getters) switch - { - // [Option("OptionName")] - // public required string PropertyName { get; init; } - (true, { Count: 1 }) => $""" - {property.PropertyName} = {getters[0]}{toMethod} ?? {exception}, -""", - // [Option('o', "OptionName")] - // public required string PropertyName { get; init; } - (true, _) => $""" - {property.PropertyName} = ({string.Join("\n ?? ", getters)}){toMethod} - ?? {exception}, -""", - // [Option("OptionName")] - // public string PropertyName { get; set; } - (false, { Count: 1 }) => $$""" - if ({{getters[0]}}{{toMethod}} is { } o{{modelIndex}}) - { - result.{{property.PropertyName}} = o{{modelIndex}}; - } -""", - // [Option('o', "OptionName")] - // public string PropertyName { get; set; } - (false, _) => $$""" - if (({{string.Join("\n ?? ", getters)}}){{toMethod}} is { } o{{modelIndex}}) - { - result.{{property.PropertyName}} = o{{modelIndex}}; - } -""", - }; - } - - private string GenerateValuePropertyAssignment(CommandObjectGeneratingModel model, ValuePropertyGeneratingModel property, int modelIndex) - { - var toMethod = GetCommandLinePropertyValueToMethodName(property.Type) is { } tm ? $"?.{tm}()" : ""; - var baseIndex = model.GetCommandLevel(); - var indexLengthCode = (property.Index, property.Length) switch - { - (null, null) => $"{baseIndex}, 1", - (null, { } length) when length == int.MaxValue => $"{baseIndex}, int.MaxValue", - (null, { } length) => $"{baseIndex}, {length}", - ({ } index, null) => $"{baseIndex + index}, 1", - ({ } index, { } length) when length == int.MaxValue => $"{baseIndex + index}, int.MaxValue", - ({ } index, { } length) => $"{baseIndex + index}, {length}", - }; - var exception = property.IsRequired - ? $"throw new global::DotNetCampus.Cli.Exceptions.RequiredPropertyNotAssignedException($\"The command line arguments doesn't contain a required positional argument at {property.Index ?? 0}. Command line: {{commandLine}}\", \"{property.PropertyName}\")" - : (property.IsNullable, property.IsValueType) switch - { - (true, _) => "null", - (false, true) => "default", - (false, false) => "null!", - }; - if (property.IsRequired || property.IsInitOnly) - { - return $""" - {property.PropertyName} = commandLine.GetPositionalArgument({$"{indexLengthCode}"}){toMethod} ?? {exception}, -"""; - } - else - { - return $$""" - if (commandLine.GetPositionalArgument({{$"{indexLengthCode}"}}){{toMethod}} is { } p{{modelIndex}}) - { - result.{{property.PropertyName}} = p{{modelIndex}}; - } -"""; - } - } - - private string GenerateRawArgumentsPropertyAssignment(RawArgumentsPropertyGeneratingModel property) - { - var isInitProperty = property.IsRequired || property.IsInitOnly; - if (isInitProperty) - { - return $""" - {property.PropertyName} = (commandLine.CommandLineArguments as {property.Type.ToDisplayString()}) ?? [..commandLine.CommandLineArguments], -"""; - } - else - { - return $$""" - result.{{property.PropertyName}} = (commandLine.CommandLineArguments as {{property.Type.ToDisplayString()}}) ?? [..commandLine.CommandLineArguments]; -"""; - } - } - - /// - /// 获取一个方法名,调用该方法可使“命令行属性值”转换为“目标类型”。 - /// - /// 目标类型。 - /// 方法名。 - private string? GetCommandLinePropertyValueToMethodName(ITypeSymbol targetType) - { - // 特殊处理接口,因为接口不支持隐式转换,所以要调用专门的转换方法。 - if (targetType.TypeKind is TypeKind.Interface) - { - return targetType.Name switch - { - "IEnumerable" or "IReadOnlyList" or "IList" or "ICollection" => "ToList", - "IReadOnlyDictionary" or "IDictionary" => "ToDictionary", - // 专门生成不存在的方法名和全名注释,编译不通过,同时还能辅助报告错误原因。 - _ => $"To{targetType.Name}/* {targetType.ToDisplayString()} */", - }; - } - - // 特殊处理枚举和可空枚举,因为枚举类型不可穷举,所以要调用专门的转换方法。 - if (targetType.ToDisplayString().EndsWith("?") && targetType.TypeKind is TypeKind.Struct) - { - // 拿到可空类型内部的类型,如 int? -> int。 - targetType = ((INamedTypeSymbol)targetType).TypeArguments[0]; - } - if (targetType.TypeKind is TypeKind.Enum) - { - return $"ToEnum<{targetType.ToNotNullGlobalDisplayString()}>"; - } - - // 其他类型使用隐式转换。 - return null; - } - - private string GenerateModuleInitializerCode(ImmutableArray models) - { - return $$""" -#nullable enable -namespace DotNetCampus.Cli; - -/// -/// 为本程序集中的所有命令行选项、子命令或处理函数编译时信息初始化。 -/// -internal static class CommandLineModuleInitializer -{ - [global::System.Runtime.CompilerServices.ModuleInitializerAttribute] - internal static void Initialize() - { -{{string.Join("\n\n", models.Select(GenerateCommandRunnerRegisterCode))}} - } -} - -"""; - } - - private string GenerateCommandRunnerRegisterCode(CommandObjectGeneratingModel model) - { - var commandCode = model.GetKebabCaseCommandNames() is { } vn ? $"\"{vn}\"" : "null"; - return $$""" - // {{model.CommandObjectType.Name}} { CommandName = {{commandCode}} } - global::DotNetCampus.Cli.CommandRunner.Register<{{model.CommandObjectType.ToGlobalDisplayString()}}>( - {{commandCode}}, - global::{{model.Namespace}}.{{model.GetBuilderTypeName()}}.CreateInstance); -"""; - } - - private string GenerateAssemblyCommandHandlerCode(AssemblyCommandsGeneratingModel model, ImmutableArray models) - { - return $$""" -#nullable enable -namespace {{model.Namespace}}; - -#pragma warning disable CS0162 - -/// -/// 提供一种辅助自动搜集并执行本程序集中所有命令行处理器的方式。 -/// -partial class {{model.AssemblyCommandHandlerType.Name}} : global::DotNetCampus.Cli.Utils.Handlers.GeneratedAssemblyCommandHandlerCollection -{ - public {{model.AssemblyCommandHandlerType.Name}}() - { -{{string.Join("\n", models.GroupBy(x => x.GetKebabCaseCommandNames()).Select(GenerateAssemblyCommandHandlerMatchCode))}} - } -} - -"""; - } - - private string GenerateAssemblyCommandHandlerMatchCode(IGrouping group) - { - var models = group.ToList(); - if (models.Count is 1) - { - var model = models[0]; - if (model.IsHandler) - { - var assignment = group.Key is { } commandName ? $"Creators[\"{commandName}\"]" : "Default"; - return $""" - {assignment} = cl => (global::DotNetCampus.Cli.ICommandHandler)global::{model.Namespace}.{model.GetBuilderTypeName()}.CreateInstance(cl); -"""; - } - else - { - return $""" - // 类型 {model.CommandObjectType.Name} 没有继承 ICommandHandler 接口,因此无法统一调度执行,只能由开发者单独调用。 -"""; - } - } - else - { - var commandName = group.Key is { } cn ? $"\"{cn}\"" : "null"; - return $""" - throw new global::DotNetCampus.Cli.Exceptions.CommandNameAmbiguityException($"Multiple command handlers match the same command name '{group.Key ?? "null"}': {string.Join(", ", models.Select(x => x.CommandObjectType.Name))}.", {commandName}); -"""; - } - } -} diff --git a/src/DotNetCampus.CommandLine.Analyzer/Generators/InterceptorGenerator.cs b/src/DotNetCampus.CommandLine.Analyzer/Generators/InterceptorGenerator.cs index 06f80cc3..5ac7f404 100644 --- a/src/DotNetCampus.CommandLine.Analyzer/Generators/InterceptorGenerator.cs +++ b/src/DotNetCampus.CommandLine.Analyzer/Generators/InterceptorGenerator.cs @@ -1,6 +1,7 @@ #pragma warning disable RSEXPERIMENTAL002 using System.Collections.Immutable; using DotNetCampus.Cli.Utils; +using DotNetCampus.CommandLine.Generators.Builders; using DotNetCampus.CommandLine.Generators.ModelProviding; using DotNetCampus.CommandLine.Utils.CodeAnalysis; using Microsoft.CodeAnalysis; @@ -13,44 +14,80 @@ public class InterceptorGenerator : IIncrementalGenerator { public void Initialize(IncrementalGeneratorInitializationContext context) { - var analyzerConfigOptionsProvider = context.AnalyzerConfigOptionsProvider; - var commandLineAsProvider = context.SelectCommandLineAsProvider(); - var commandRunnerAddHandlerProvider = context.SelectCommandBuilderAddHandlerProvider(); - var commandRunnerAddHandlerCoreActionProvider = context.SelectCommandBuilderAddHandlerProvider("ICoreCommandRunnerBuilder", "global::System.Action"); - var commandRunnerAddHandlerAsyncActionProvider = context.SelectCommandBuilderAddHandlerProvider("IAsyncCommandRunnerBuilder", "global::System.Action"); - var commandRunnerAddHandlerCoreFuncIntProvider = context.SelectCommandBuilderAddHandlerProvider("ICoreCommandRunnerBuilder", "global::System.Func"); - var commandRunnerAddHandlerAsyncFuncIntProvider = context.SelectCommandBuilderAddHandlerProvider("IAsyncCommandRunnerBuilder", "global::System.Func"); - var commandRunnerAddHandlerCoreFuncTaskProvider = context.SelectCommandBuilderAddHandlerProvider("ICoreCommandRunnerBuilder", "global::System.Func"); - var commandRunnerAddHandlerCoreFuncTaskIntProvider = context.SelectCommandBuilderAddHandlerProvider("ICoreCommandRunnerBuilder", "global::System.Func>"); - - context.RegisterSourceOutput(commandLineAsProvider.Collect().Combine(analyzerConfigOptionsProvider), CommandLineAs); - context.RegisterSourceOutput(commandRunnerAddHandlerProvider.Collect().Combine(analyzerConfigOptionsProvider), CommandRunnerAddHandler); - - // ICommandRunnerBuilder AddHandler(this ICoreCommandRunnerBuilder builder, Action handler) - context.RegisterSourceOutput(commandRunnerAddHandlerCoreActionProvider.Collect().Combine(analyzerConfigOptionsProvider), (c, args) => - CommandRunnerAddHandlerAction(c, args, "ICoreCommandRunnerBuilder.AddHandler(Action{T})", "ICoreCommandRunnerBuilder", "System.Action", "ICommandRunnerBuilder")); - - // IAsyncCommandRunnerBuilder AddHandler(this IAsyncCommandRunnerBuilder builder, Action handler) - context.RegisterSourceOutput(commandRunnerAddHandlerAsyncActionProvider.Collect().Combine(analyzerConfigOptionsProvider), (c, args) => - CommandRunnerAddHandlerAction(c, args, "IAsyncCommandRunnerBuilder.AddHandler(Action{T})", "IAsyncCommandRunnerBuilder", "System.Action", "IAsyncCommandRunnerBuilder")); - - // ICommandRunnerBuilder AddHandler(this ICoreCommandRunnerBuilder builder, Func handler) - context.RegisterSourceOutput(commandRunnerAddHandlerCoreFuncIntProvider.Collect().Combine(analyzerConfigOptionsProvider), (c, args) => - CommandRunnerAddHandlerAction(c, args, "ICoreCommandRunnerBuilder.AddHandler(Func{T,int})", "ICoreCommandRunnerBuilder", "System.Func", "ICommandRunnerBuilder")); - - // IAsyncCommandRunnerBuilder AddHandler(this IAsyncCommandRunnerBuilder builder, Func handler) - context.RegisterSourceOutput(commandRunnerAddHandlerAsyncFuncIntProvider.Collect().Combine(analyzerConfigOptionsProvider), (c, args) => - CommandRunnerAddHandlerAction(c, args, "IAsyncCommandRunnerBuilder.AddHandler(Func{T,int})", "IAsyncCommandRunnerBuilder", "System.Func", "IAsyncCommandRunnerBuilder")); - - // IAsyncCommandRunnerBuilder AddHandler(this ICoreCommandRunnerBuilder builder, Func handler) - context.RegisterSourceOutput(commandRunnerAddHandlerCoreFuncTaskProvider.Collect().Combine(analyzerConfigOptionsProvider), (c, args) => - CommandRunnerAddHandlerAction(c, args, "ICoreCommandRunnerBuilder.AddHandler(Func{T,Task})", "ICoreCommandRunnerBuilder", "System.Func", - "IAsyncCommandRunnerBuilder")); - - // IAsyncCommandRunnerBuilder AddHandler(this ICoreCommandRunnerBuilder builder, Func> handler) - context.RegisterSourceOutput(commandRunnerAddHandlerCoreFuncTaskIntProvider.Collect().Combine(analyzerConfigOptionsProvider), (c, args) => - CommandRunnerAddHandlerAction(c, args, "ICoreCommandRunnerBuilder.AddHandler(Func{T,Task{int}})", "ICoreCommandRunnerBuilder", "System.Func>", - "IAsyncCommandRunnerBuilder")); + var ac = context.AnalyzerConfigOptionsProvider; + + // CommandLine.As + var ca = context.SelectCommandLineAsProvider(); + + // CommandLine.AddHandler + var c0 = context.SelectAddHandlers("CommandLine"); + var c1 = context.SelectAddHandlers("CommandLine", "global::System.Action"); + var c2 = context.SelectAddHandlers("CommandLine", "global::System.Func"); + var c3 = context.SelectAddHandlers("CommandLine", "global::System.Func"); + var c4 = context.SelectAddHandlers("CommandLine", "global::System.Func>"); + + // ICommandRunnerBuilder.AddHandler + var s0 = context.SelectAddHandlers("ICommandRunnerBuilder"); + var s1 = context.SelectAddHandlers("ICommandRunnerBuilder", "global::System.Action"); + var s2 = context.SelectAddHandlers("ICommandRunnerBuilder", "global::System.Func"); + var s3 = context.SelectAddHandlers("ICommandRunnerBuilder", "global::System.Func"); + var s4 = context.SelectAddHandlers("ICommandRunnerBuilder", "global::System.Func>"); + + // IAsyncCommandRunnerBuilder.AddHandler + var a0 = context.SelectAddHandlers("IAsyncCommandRunnerBuilder"); + var a1 = context.SelectAddHandlers("IAsyncCommandRunnerBuilder", "global::System.Action"); + var a2 = context.SelectAddHandlers("IAsyncCommandRunnerBuilder", "global::System.Func"); + var a3 = context.SelectAddHandlers("IAsyncCommandRunnerBuilder", "global::System.Func"); + var a4 = context.SelectAddHandlers("IAsyncCommandRunnerBuilder", "global::System.Func>"); + + // StatedCommandRunnerBuilder.AddHandler + var t0 = context.SelectAddHandlers("StatedCommandRunnerBuilder"); + var t1 = context.SelectAddHandlers("StatedCommandRunnerLinkedBuilder"); + + // CommandLine.As + context.RegisterSourceOutput(ca.Collect().Combine(ac), CommandLineAs); + + // CommandLine.AddHandler + context.RegisterSourceOutput(c0.Collect().Combine(ac), (c, args) => CommandLineAddHandler(c, args, + "CommandLine", "AddHandler()")); + context.RegisterSourceOutput(c1.Collect().Combine(ac), (c, args) => CommandLineAddHandlerAction(c, args, + "CommandLine", "AddHandler(Action{T})", "System.Action", "ICommandRunnerBuilder")); + context.RegisterSourceOutput(c2.Collect().Combine(ac), (c, args) => CommandLineAddHandlerAction(c, args, + "CommandLine", "AddHandler(Func{T,int})", "System.Func", "ICommandRunnerBuilder")); + context.RegisterSourceOutput(c3.Collect().Combine(ac), (c, args) => CommandLineAddHandlerAction(c, args, + "CommandLine", "AddHandler(Func{T,Task})", "System.Func", "IAsyncCommandRunnerBuilder")); + context.RegisterSourceOutput(c4.Collect().Combine(ac), (c, args) => CommandLineAddHandlerAction(c, args, + "CommandLine", "AddHandler(Func{T,Task{int}})", "System.Func>", "IAsyncCommandRunnerBuilder")); + + // ICommandRunnerBuilder.AddHandler + context.RegisterSourceOutput(s0.Collect().Combine(ac), (c, args) => CommandRunnerAddHandler(c, args, + "ICommandRunnerBuilder", "AddHandler()")); + context.RegisterSourceOutput(s1.Collect().Combine(ac), (c, args) => CommandRunnerAddHandlerAction(c, args, + "ICommandRunnerBuilder", "AddHandler(Action{T})", "System.Action", "ICommandRunnerBuilder")); + context.RegisterSourceOutput(s2.Collect().Combine(ac), (c, args) => CommandRunnerAddHandlerAction(c, args, + "ICommandRunnerBuilder", "AddHandler(Func{T,int})", "System.Func", "ICommandRunnerBuilder")); + context.RegisterSourceOutput(s3.Collect().Combine(ac), (c, args) => CommandRunnerAddHandlerAction(c, args, + "ICommandRunnerBuilder", "AddHandler(Func{T,Task})", "System.Func", "IAsyncCommandRunnerBuilder")); + context.RegisterSourceOutput(s4.Collect().Combine(ac), (c, args) => CommandRunnerAddHandlerAction(c, args, + "ICommandRunnerBuilder", "AddHandler(Func{T,Task{int}})", "System.Func>", "IAsyncCommandRunnerBuilder")); + + // IAsyncCommandRunnerBuilder.AddHandler + context.RegisterSourceOutput(a0.Collect().Combine(ac), (c, args) => CommandRunnerAddHandler(c, args, + "IAsyncCommandRunnerBuilder", "AddHandler()")); + context.RegisterSourceOutput(a1.Collect().Combine(ac), (c, args) => CommandRunnerAddHandlerAction(c, args, + "IAsyncCommandRunnerBuilder", "AddHandler(Action{T})", "System.Action", "IAsyncCommandRunnerBuilder")); + context.RegisterSourceOutput(a2.Collect().Combine(ac), (c, args) => CommandRunnerAddHandlerAction(c, args, + "IAsyncCommandRunnerBuilder", "AddHandler(Func{T,int})", "System.Func", "IAsyncCommandRunnerBuilder")); + context.RegisterSourceOutput(a3.Collect().Combine(ac), (c, args) => CommandRunnerAddHandlerAction(c, args, + "IAsyncCommandRunnerBuilder", "AddHandler(Func{T,Task})", "System.Func", "IAsyncCommandRunnerBuilder")); + context.RegisterSourceOutput(a4.Collect().Combine(ac), (c, args) => CommandRunnerAddHandlerAction(c, args, + "IAsyncCommandRunnerBuilder", "AddHandler(Func{T,Task{int}})", "System.Func>", "IAsyncCommandRunnerBuilder")); + + // StatedCommandRunnerBuilder.AddHandler + context.RegisterSourceOutput(t0.Collect().Combine(ac), (c, args) => StatedCommandRunnerAddHandler(c, args, + "StatedCommandRunnerBuilder", "AddHandler()")); + context.RegisterSourceOutput(t1.Collect().Combine(ac), (c, args) => StatedCommandRunnerAddHandler(c, args, + "StatedCommandRunnerLinkedBuilder", "AddHandler()")); } /// @@ -67,127 +104,237 @@ private void CommandLineAs(SourceProductionContext context, (ImmutableArray - /// CommandRunner.AddHandler + /// CommandLine.AddHandler /// - private void CommandRunnerAddHandler(SourceProductionContext context, (ImmutableArray Left, AnalyzerConfigOptionsProvider Right) args) + private void CommandLineAddHandler(SourceProductionContext context, + (ImmutableArray Left, AnalyzerConfigOptionsProvider Right) args, + string thisName, string methodSignature) { if (context.ToDictionary(args) is not { } modelGroups || modelGroups.Count is 0) { return; } - var code = GenerateCode(modelGroups, GenerateCommandBuilderAddHandlerCode); - context.AddSource("CommandLine.Interceptors/CommandBuilder.AddHandler.g.cs", code); + var code = GenerateCode(modelGroups, (t, x) => + GenerateCommandLineAddHandlerCode(t, x, thisName)); + context.AddSource($"CommandLine.Interceptors/{thisName}.{methodSignature}.g.cs", code); } /// - /// CommandRunner.AddHandler(Action) + /// CommandLine.AddHandler(Action) /// - private void CommandRunnerAddHandlerAction(SourceProductionContext context, + private void CommandLineAddHandlerAction(SourceProductionContext context, (ImmutableArray Left, AnalyzerConfigOptionsProvider Right) args, - string fileName, string parameterThisName, string parameterTypeFullName, string returnName) + string thisName, string methodSignature, string parameterTypeFullName, string returnName) { if (context.ToDictionary(args) is not { } modelGroups || modelGroups.Count is 0) { return; } - var code = GenerateCode(modelGroups, x => GenerateCommandBuilderAddHandlerActionCode(x, parameterThisName, parameterTypeFullName, returnName)); - context.AddSource($"CommandLine.Interceptors/{fileName}.g.cs", code); + var code = GenerateCode(modelGroups, (t, x) => + GenerateCommandLineAddHandlerActionCode(t, x, thisName, parameterTypeFullName, returnName)); + context.AddSource($"CommandLine.Interceptors/{thisName}.{methodSignature}.g.cs", code); } - private string GenerateCode(Dictionary> models, - Func, string> methodCreator) + private void GenerateCommandLineAddHandlerCode(TypeDeclarationSourceTextBuilder builder, IReadOnlyList models, + string parameterThisName) { - return $$""" -#nullable enable + var model = models[0]; + builder.AddMethodDeclaration($""" + public static global::DotNetCampus.Cli.IAsyncCommandRunnerBuilder CommandLine_AddHandler_{NamingHelper.MakePascalCase(model.CommandObjectType.ToDisplayString())}(this global::DotNetCampus.Cli.{parameterThisName} commandLine) + """, m => m + .AddAttributes(models.Select(GenerateInterceptsLocationCode)) + .AddTypeConstraints("where T : class, global::DotNetCampus.Cli.ICommandHandler") + .AddRawStatement(GenerateComment(model)) + .AddRawStatements($""" + return commandLine.AsRunner().AddHandler(global::{model.CommandObjectType.ContainingNamespace}.{model.GetBuilderTypeName()}.CommandNameGroup, global::{model.CommandObjectType.ContainingNamespace}.{model.GetBuilderTypeName()}.CreateInstance); + """)); + } -namespace DotNetCampus.Cli.Compiler -{ - file static class Interceptors + private void GenerateCommandLineAddHandlerActionCode(TypeDeclarationSourceTextBuilder builder, IReadOnlyList models, + string parameterThisName, string parameterTypeFullName, string returnName) { -{{string.Join("\n\n", models.Select(x => methodCreator(x.Value)))}} + var model = models[0]; + builder.AddMethodDeclaration($""" + public static global::DotNetCampus.Cli.{returnName} CommandLine_AddHandler_{NamingHelper.MakePascalCase(model.CommandObjectType.ToDisplayString())}(this global::DotNetCampus.Cli.{parameterThisName} commandLine, global::{parameterTypeFullName} handler) + """, m => m + .AddAttributes(models.Select(GenerateInterceptsLocationCode)) + .AddTypeConstraints("where T : class") + .AddRawStatement(GenerateComment(model)) + .AddRawStatements($""" + return commandLine.AsRunner().AddHandler(handler, global::{model.CommandObjectType.ContainingNamespace}.{model.GetBuilderTypeName()}.CommandNameGroup, global::{model.CommandObjectType.ContainingNamespace}.{model.GetBuilderTypeName()}.CreateInstance); + """)); } -} -namespace System.Runtime.CompilerServices -{ - [global::System.AttributeUsage(global::System.AttributeTargets.Method, AllowMultiple = true)] - file sealed class InterceptsLocationAttribute : global::System.Attribute + #endregion + + /// + /// CommandRunner.AddHandler + /// + private void CommandRunnerAddHandler(SourceProductionContext context, + (ImmutableArray Left, AnalyzerConfigOptionsProvider Right) args, + string thisName, string methodSignature) { - public InterceptsLocationAttribute(int version, string data) + if (context.ToDictionary(args) is not { } modelGroups || modelGroups.Count is 0) { - _ = version; - _ = data; + return; } + + var code = GenerateCode(modelGroups, (t, x) => + GenerateCommandBuilderAddHandlerCode(t, x, thisName)); + context.AddSource($"CommandLine.Interceptors/{thisName}.{methodSignature}.g.cs", code); } -} -"""; + /// + /// StatedCommandRunnerBuilder{TState}.AddHandler + /// + private void StatedCommandRunnerAddHandler(SourceProductionContext context, + (ImmutableArray Left, AnalyzerConfigOptionsProvider Right) args, + string thisName, string methodSignature) + { + if (context.ToDictionary(args) is not { } modelGroups || modelGroups.Count is 0) + { + return; + } + + var code = GenerateCode(modelGroups, (t, x) => + GenerateStatedCommandBuilderAddHandlerCode(t, x, thisName)); + context.AddSource($"CommandLine.Interceptors/{thisName}.{methodSignature}.g.cs", code); } - private string GenerateInterceptsLocationCode(InterceptorGeneratingModel model) + /// + /// CommandRunner.AddHandler(Action) + /// + private void CommandRunnerAddHandlerAction(SourceProductionContext context, + (ImmutableArray Left, AnalyzerConfigOptionsProvider Right) args, + string thisName, string methodSignature, string parameterTypeFullName, string returnName) { - return $""" + if (context.ToDictionary(args) is not { } modelGroups || modelGroups.Count is 0) + { + return; + } + + var code = GenerateCode(modelGroups, (t, x) => + GenerateCommandBuilderAddHandlerActionCode(t, x, thisName, parameterTypeFullName, returnName)); + context.AddSource($"CommandLine.Interceptors/{thisName}.{methodSignature}.g.cs", code); + } + + private string GenerateCode(Dictionary> models, + Action> methodCreator) + { + var builder = new SourceTextBuilder() + .AddNamespaceDeclaration("DotNetCampus.Cli.Compiler", n => n + .AddTypeDeclaration("file static class Interceptors", t => + { + foreach (var pair in models) + { + methodCreator(t, pair.Value); + } + })) + .AddNamespaceDeclaration("System.Runtime.CompilerServices", n => n + .AddTypeDeclaration("file sealed class InterceptsLocationAttribute : global::System.Attribute", t => t + .AddAttribute("""[global::System.Diagnostics.Conditional("FOR_SOURCE_GENERATION_ONLY")]""") + .AddAttribute("[global::System.AttributeUsage(global::System.AttributeTargets.Method, AllowMultiple = true)]") + .AddRawText(""" + public InterceptsLocationAttribute(int version, string data) + { + _ = version; + _ = data; + } + """))); + return builder.ToString(); + } + + private string GenerateInterceptsLocationCode(InterceptorGeneratingModel model) => $""" [global::System.Runtime.CompilerServices.InterceptsLocation({model.InterceptableLocation.Version}, /* {model.InvocationInfo} */ "{model.InterceptableLocation.Data}")] -"""; + """; + + private void GenerateCommandLineAsCode(TypeDeclarationSourceTextBuilder builder, IReadOnlyList models) + { + var model = models[0]; + builder.AddMethodDeclaration( + $"public static T CommandLine_As_{NamingHelper.MakePascalCase(model.CommandObjectType.ToDisplayString())}(this global::DotNetCampus.Cli.CommandLine commandLine)", + m => m + .WithSummaryComment($$""" 方法的拦截器。拦截以提高性能。""") + .AddAttributes(models.Select(GenerateInterceptsLocationCode)) + .AddTypeConstraints(model.UseFullStackParser ? "where T : struct" : "where T : notnull") + .AddRawStatement(GenerateComment(model)) + .AddRawStatements($$""""" + var context = new global::DotNetCampus.Cli.Compiler.CommandRunningContext { CommandLine = commandLine }; + var instance = new global::{{model.CommandObjectType.ContainingNamespace}}.{{model.GetBuilderTypeName()}}().Build(context); + {{( + model.UseFullStackParser + ? $""" + #if NET8_0_OR_GREATER + return global::System.Runtime.CompilerServices.Unsafe.BitCast<{model.CommandObjectType.ToGlobalDisplayString()}, T>(instance); + #else + return global::System.Runtime.CompilerServices.Unsafe.As<{model.CommandObjectType.ToGlobalDisplayString()}, T>(ref instance); + #endif + """ + : "return (T)(object)instance;" + )}} + """"")); } - private string GenerateCommandLineAsCode(ImmutableArray models) + private void GenerateCommandBuilderAddHandlerCode(TypeDeclarationSourceTextBuilder builder, IReadOnlyList models, + string parameterThisName) { var model = models[0]; - return $$""" - /// - /// 方法的拦截器。拦截以提高性能。 - /// -{{string.Join("\n", models.Select(GenerateInterceptsLocationCode))}} - public static T CommandLine_As_{{NamingHelper.MakePascalCase(model.CommandObjectType.ToDisplayString())}}(this global::DotNetCampus.Cli.CommandLine commandLine) - where T : {{model.CommandObjectType.ToGlobalDisplayString()}} - { - // 请确保 {{model.CommandObjectType.Name}} 类型中至少有一个属性标记了 [Option] 或 [Value] 特性; - // 否则下面的 {{model.GetBuilderTypeName()}} 类型将不存在,导致编译不通过。 - return (T)global::{{model.CommandObjectType.ContainingNamespace}}.{{model.GetBuilderTypeName()}}.CreateInstance(commandLine); - } -"""; + builder.AddMethodDeclaration($""" + public static global::DotNetCampus.Cli.IAsyncCommandRunnerBuilder CommandBuilder_AddHandler_{NamingHelper.MakePascalCase(model.CommandObjectType.ToDisplayString())}(this global::DotNetCampus.Cli.{parameterThisName} builder) + """, m => m + .WithSummaryComment( + $$""" 方法的拦截器。拦截以提高性能。""") + .AddAttributes(models.Select(GenerateInterceptsLocationCode)) + .AddTypeConstraints("where T : class, global::DotNetCampus.Cli.ICommandHandler") + .AddRawStatement(GenerateComment(model)) + .AddRawStatements($""" + return global::DotNetCampus.Cli.CommandRunnerBuilderExtensions.AddHandler(builder, global::{model.CommandObjectType.ContainingNamespace}.{model.GetBuilderTypeName()}.CommandNameGroup, global::{model.CommandObjectType.ContainingNamespace}.{model.GetBuilderTypeName()}.CreateInstance); + """)); } - private string GenerateCommandBuilderAddHandlerCode(ImmutableArray models) + private void GenerateStatedCommandBuilderAddHandlerCode(TypeDeclarationSourceTextBuilder builder, IReadOnlyList models, + string parameterThisName) { var model = models[0]; - return $$""" - /// - /// 方法的拦截器。拦截以提高性能。 - /// -{{string.Join("\n", models.Select(GenerateInterceptsLocationCode))}} - public static global::DotNetCampus.Cli.IAsyncCommandRunnerBuilder CommandBuilder_AddHandler_{{NamingHelper.MakePascalCase(model.CommandObjectType.ToDisplayString())}}(this global::DotNetCampus.Cli.ICoreCommandRunnerBuilder builder) - where T : {{model.CommandObjectType.ToGlobalDisplayString()}}, global::DotNetCampus.Cli.ICommandHandler - { - // 请确保 {{model.CommandObjectType.Name}} 类型中至少有一个属性标记了 [Option] 或 [Value] 特性; - // 否则下面的 {{model.GetBuilderTypeName()}} 类型将不存在,导致编译不通过。 - return global::DotNetCampus.Cli.CommandRunnerBuilderExtensions.AddHandler(builder, {{(model.GetKebabCaseCommandNames() is { } cn ? $"\"{cn}\"" : "null")}}, global::{{model.CommandObjectType.ContainingNamespace}}.{{model.GetBuilderTypeName()}}.CreateInstance); - } -"""; + builder.AddMethodDeclaration($""" + public static global::DotNetCampus.Cli.StatedCommandRunnerLinkedBuilder CommandBuilder_AddHandler_{NamingHelper.MakePascalCase(model.CommandObjectType.ToDisplayString())}(this in global::DotNetCampus.Cli.{parameterThisName} builder) + """, m => m + .WithSummaryComment( + $$""" 方法的拦截器。拦截以提高性能。""") + .AddAttributes(models.Select(GenerateInterceptsLocationCode)) + .AddTypeConstraints("where T : class, global::DotNetCampus.Cli.ICommandHandler") + .AddRawStatement(GenerateComment(model)) + .AddRawStatements($""" + return builder.AddHandler(global::{model.CommandObjectType.ContainingNamespace}.{model.GetBuilderTypeName()}.CommandNameGroup, global::{model.CommandObjectType.ContainingNamespace}.{model.GetBuilderTypeName()}.CreateInstance); + """)); } - private string GenerateCommandBuilderAddHandlerActionCode(ImmutableArray models, string parameterThisName, string parameterTypeFullName, string returnName) + private void GenerateCommandBuilderAddHandlerActionCode(TypeDeclarationSourceTextBuilder builder, IReadOnlyList models, + string parameterThisName, string parameterTypeFullName, string returnName) { var model = models[0]; - return $$""" - /// - /// 方法的拦截器。拦截以提高性能。 - /// -{{string.Join("\n", models.Select(GenerateInterceptsLocationCode))}} - public static global::DotNetCampus.Cli.{{returnName}} CommandBuilder_AddHandler_{{NamingHelper.MakePascalCase(model.CommandObjectType.ToDisplayString())}}(this global::DotNetCampus.Cli.{{parameterThisName}} builder, - global::{{parameterTypeFullName}} handler) - where T : class - { - // 请确保 {{model.CommandObjectType.Name}} 类型中至少有一个属性标记了 [Option] 或 [Value] 特性; - // 否则下面的 {{model.GetBuilderTypeName()}} 类型将不存在,导致编译不通过。 - return global::DotNetCampus.Cli.CommandRunnerBuilderExtensions.AddHandler(builder, {{(model.GetKebabCaseCommandNames() is { } cn ? $"\"{cn}\"" : "null")}}, global::{{model.CommandObjectType.ContainingNamespace}}.{{model.GetBuilderTypeName()}}.CreateInstance, handler); - } -"""; + builder.AddMethodDeclaration($""" + public static global::DotNetCampus.Cli.{returnName} CommandBuilder_AddHandler_{NamingHelper.MakePascalCase(model.CommandObjectType.ToDisplayString())}(this global::DotNetCampus.Cli.{parameterThisName} builder, global::{parameterTypeFullName} handler) + """, m => m + .WithSummaryComment( + $$""" 方法的拦截器。拦截以提高性能。""") + .AddAttributes(models.Select(GenerateInterceptsLocationCode)) + .AddTypeConstraints("where T : class") + .AddRawStatement(GenerateComment(model)) + .AddRawStatements($""" + return global::DotNetCampus.Cli.CommandRunnerBuilderExtensions.AddHandler(builder, handler, global::{model.CommandObjectType.ContainingNamespace}.{model.GetBuilderTypeName()}.CommandNameGroup, global::{model.CommandObjectType.ContainingNamespace}.{model.GetBuilderTypeName()}.CreateInstance); + """)); } + + private string GenerateComment(InterceptorGeneratingModel model) => $""" + // 请确保 {model.CommandObjectType.Name} 类型中至少有一个属性标记了 [Option] 或 [Value] 特性; + // 否则下面的 {model.GetBuilderTypeName()} 类型将不存在,导致编译不通过。 + """; } file static class Extensions diff --git a/src/DotNetCampus.CommandLine.Analyzer/Generators/ModelBuilderGenerator.cs b/src/DotNetCampus.CommandLine.Analyzer/Generators/ModelBuilderGenerator.cs new file mode 100644 index 00000000..608b74c0 --- /dev/null +++ b/src/DotNetCampus.CommandLine.Analyzer/Generators/ModelBuilderGenerator.cs @@ -0,0 +1,611 @@ +using System.Text; +using DotNetCampus.CommandLine.CodeAnalysis; +using DotNetCampus.CommandLine.Generators.Builders; +using DotNetCampus.CommandLine.Generators.ModelProviding; +using DotNetCampus.CommandLine.Generators.Models; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace DotNetCampus.CommandLine.Generators; + +/// +/// 为命令行参数的模型对象生成创建器代码。 +/// +[Generator(LanguageNames.CSharp)] +public class ModelBuilderGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var commandOptionsProvider = context.SelectCommandObjects(); + context.RegisterSourceOutput(commandOptionsProvider, Execute); + } + + private void Execute(SourceProductionContext context, CommandObjectGeneratingModel model) + { + if (ReportDiagnostics(context, model)) + { + return; + } + + var code = GenerateCommandObjectCreatorCode(model); + context.AddSource($"CommandLine.Models/{model.CommandObjectType.ToDisplayString()}.cs", code); + + // if (model.UseFullStackParser) + // { + // var originalCode = EmbeddedSourceFiles.Enumerate(null) + // .First(x => x.FileName == "CommandLineParser.cs") + // .Content; + // var parserCode = GenerateParserCode(originalCode, model); + // context.AddSource($"CommandLine.Models/{model.Namespace}.{model.CommandObjectType.Name}.parser.cs", parserCode); + // } + } + + private static bool ReportDiagnostics(SourceProductionContext context, CommandObjectGeneratingModel model) + { + var fileName = model.CommandObjectType.ToDisplayString(); + if (fileName.Contains('<')) + { + context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.DCL301_GenericCommandObjectTypeNotSupported, + model.CommandObjectType.DeclaringSyntaxReferences.FirstOrDefault()? + .GetSyntax().DescendantNodesAndSelf().OfType().FirstOrDefault()? + .Identifier.GetLocation(), + fileName)); + return true; + } + + if (model.OptionProperties.FindFirstDuplicateName() is ({ } name, { } location)) + { + context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.DCL204_DuplicateOptionNames, + location, + name)); + return true; + } + + return false; + } + + private string GenerateCommandObjectCreatorCode(CommandObjectGeneratingModel model) + { + var builder = new SourceTextBuilder(model.Namespace) + .Using("System") + .Using("DotNetCampus.Cli.Compiler") + .AddTypeDeclaration(GenerateBuilderTypeDeclarationLine(model), t => t + .WithSummaryComment($"""辅助 生成命令行选项、子命令或处理函数的创建。""") + .AddRawMembers(GenerateCommandNames(model)) + .AddMethodDeclaration( + $"public static {model.CommandObjectType.ToUsingString()} CreateInstance(global::DotNetCampus.Cli.Compiler.CommandRunningContext context)", + m => m + .AddRawStatements($"return new {model.Namespace}.{model.GetBuilderTypeName()}().Build(context);")) + .AddRawMembers(model.OptionProperties.Select(GenerateArgumentPropertyCode)) + .AddRawMembers(model.EnumeratePositionalArgumentExcludingSameNameOptions().Select(GenerateArgumentPropertyCode)) + .AddRawText(GenerateBuildCode(model)) + .AddMethodDeclaration( + "private global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch MatchLongOption(ReadOnlySpan longOption, bool defaultCaseSensitive, global::DotNetCampus.Cli.CommandNamingPolicy namingPolicy)", + m => GenerateMatchLongOptionCode(m, model)) + .AddMethodDeclaration( + "private global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch MatchShortOption(ReadOnlySpan shortOption, bool defaultCaseSensitive)", + m => GenerateMatchShortOptionCode(m, model)) + .AddMethodDeclaration( + "private global::DotNetCampus.Cli.Utils.Parsers.PositionalArgumentValueMatch MatchPositionalArguments(ReadOnlySpan value, int argumentIndex)", + m => GenerateMatchPositionalArgumentsCode(m, model)) + .AddMethodDeclaration( + "private void AssignPropertyValue(string propertyName, int propertyIndex, ReadOnlySpan key, ReadOnlySpan value)", + m => m + .Condition(model.OptionProperties.Count > 0 || model.PositionalArgumentProperties.Count > 0, b => b + .AddBracketScope("switch (propertyIndex)", l => l + .AddRawStatements(model.OptionProperties.Select(GenerateAssignPropertyValueCode)) + .AddRawStatements(model.EnumeratePositionalArgumentExcludingSameNameOptions().Select(GenerateAssignPropertyValueCode)))) + .Otherwise(b => b.AddRawStatement("// 没有可赋值的属性。")) + .EndCondition()) + .AddMethodDeclaration( + $"private {model.CommandObjectType.ToUsingString()} BuildCore(global::DotNetCampus.Cli.CommandLine commandLine)", + m => GenerateBuildCoreCode(m, model)) + .AddMethodDeclaration( + $"private {model.CommandObjectType.ToUsingString()} BuildDefault(global::DotNetCampus.Cli.CommandLine commandLine)", + m => GenerateBuildDefaultCode(m, model)) + .AddRawMembers(model.EnumerateEnumPropertyTypes().Select(GenerateEnumDeclarationCode)) + ); + return builder.ToString(); + } + + private static string GenerateBuilderTypeDeclarationLine(CommandObjectGeneratingModel model) + { + var modifier = model.IsPublic ? "public" : "internal"; + return model.UseFullStackParser + ? $"{modifier} partial struct {model.GetBuilderTypeName()}()" + : $"{modifier} sealed class {model.GetBuilderTypeName()}"; + } + + private static string GenerateCommandNames(CommandObjectGeneratingModel model) + { + var ordinalName = model.CommandNames; + var pascalCaseName = model.GetPascalCaseCommandNames(); + var value = string.IsNullOrWhiteSpace(ordinalName) + ? "default" + : $"new global::DotNetCampus.Cli.Compiler.NamingPolicyNameGroup(\"{ordinalName}\", \"{pascalCaseName}\")"; + return $""" + public static readonly global::DotNetCampus.Cli.Compiler.NamingPolicyNameGroup CommandNameGroup = {value}; + """; + } + + private string GenerateArgumentPropertyCode(PropertyGeneratingModel model) => + $"private {GetArgumentPropertyTypeName(model)} {model.PropertyName} = new();"; + + private string GetArgumentPropertyTypeName(PropertyGeneratingModel model) => model.Type.AsCommandValueKind() switch + { + CommandValueKind.Boolean => "global::DotNetCampus.Cli.Compiler.BooleanArgument", + CommandValueKind.Number => "global::DotNetCampus.Cli.Compiler.NumberArgument", + CommandValueKind.Enum => model.Type.GetGeneratedEnumArgumentTypeName(), + CommandValueKind.String => "global::DotNetCampus.Cli.Compiler.StringArgument", + CommandValueKind.List => "global::DotNetCampus.Cli.Compiler.StringListArgument", + CommandValueKind.Dictionary => "global::DotNetCampus.Cli.Compiler.StringDictionaryArgument", + _ => "global::DotNetCampus.Cli.Compiler.ErrorArgument", + }; + + private static string GenerateBuildCode(CommandObjectGeneratingModel model) => $$""" + public {{model.CommandObjectType.ToUsingString()}} Build(global::DotNetCampus.Cli.Compiler.CommandRunningContext context) + { + if (context.CommandLine.RawArguments.Count is 0) + { + return BuildDefault(context.CommandLine); + } + + var parser = new global::DotNetCampus.Cli.Utils.Parsers.CommandLineParser(context.CommandLine, "{{model.CommandObjectType.Name}}", {{model.GetCommandLevel()}}) + { + MatchLongOption = MatchLongOption, + MatchShortOption = MatchShortOption, + MatchPositionalArguments = MatchPositionalArguments, + AssignPropertyValue = AssignPropertyValue, + }; + parser.Parse().WithFallback(context); + return BuildCore(context.CommandLine); + } + """; + + private MethodDeclarationSourceTextBuilder GenerateMatchLongOptionCode(MethodDeclarationSourceTextBuilder builder, CommandObjectGeneratingModel model) + { + var optionProperties = model.OptionProperties; + return builder + .Condition(optionProperties.Count is 0, b => b + .AddRawStatement("// 没有长名称选项,无需匹配。")) + .Otherwise(b => b + .AddRawStatement("// 1. 先匹配 kebab-case 命名法(原样字符串)") + .AddBracketScope("if (namingPolicy.SupportsOrdinal())", s => s + .AddRawStatement("// 1.1 先快速原字符匹配一遍(能应对规范命令行大小写,并优化 DotNet / GNU 风格的性能)。") + .AddBracketScope("switch (longOption)", c => c + .AddRawStatements(optionProperties.Select(x => GenerateLongOptionCaseCode(x, x.GetOrdinalLongNames())))) + .AddLineSeparator() + .AddRawStatement("// 1.2 再按指定大小写匹配一遍(能应对不规范命令行大小写)。") + .AddDefaultStringComparisonIfNeeded(optionProperties) + .AddRawStatements(optionProperties.Select(x => GenerateLongOptionEqualsCode(x, x.GetOrdinalLongNames())))) + .AddLineSeparator() + .AddRawStatement("// 2. 再匹配其他命名法(能应对所有不规范命令行大小写,并支持所有风格)。") + .AddBracketScope("if (namingPolicy.SupportsPascalCase())", s => s + .AddDefaultStringComparisonIfNeeded(optionProperties) + .AddRawStatements(optionProperties.Select(x => GenerateLongOptionEqualsCode(x, x.GetPascalCaseLongNames())))) + .AddLineSeparator()) + .EndCondition() + .AddRawStatement("return global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch.NotMatch;"); + + static string GenerateLongOptionCaseCode(OptionalArgumentPropertyGeneratingModel model, IReadOnlyList names) + { + return string.Join("\n", names.Select(name => $""" + case "{name}": + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof({model.PropertyName}), {model.PropertyIndex}, {model.Type.AsCommandValueKind().ToCommandValueTypeName()}); + """)); + } + + static string GenerateLongOptionEqualsCode(OptionalArgumentPropertyGeneratingModel model, IReadOnlyList names) + { + return string.Join("\n", names.Select(name => $$""" + if (longOption.Equals("{{name}}".AsSpan(), {{model.GetStringComparisonExpression()}})) + { + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof({{model.PropertyName}}), {{model.PropertyIndex}}, {{model.Type.AsCommandValueKind().ToCommandValueTypeName()}}); + } + """)); + } + } + + private MethodDeclarationSourceTextBuilder GenerateMatchShortOptionCode(MethodDeclarationSourceTextBuilder builder, CommandObjectGeneratingModel model) + { + var optionProperties = model.OptionProperties; + var hasShortName = optionProperties.SelectMany(x => x.GetShortNames()).Any(); + return builder + .Condition(!hasShortName, b => b + .AddRawStatement("// 没有短名称选项,无需匹配。")) + .Otherwise(b => b + .AddRawStatement("// 1. 先快速原字符匹配一遍(能应对规范命令行大小写,并优化 DotNet / GNU 风格的性能)。") + .AddBracketScope("switch (shortOption)", s => s + .AddRawStatements(optionProperties.Select(x => GenerateOptionCaseCode(x, x.GetShortNames())))) + .AddLineSeparator() + .AddDefaultStringComparisonIfNeeded(optionProperties) + .AddLineSeparator() + .AddRawStatement("// 2. 再按指定大小写指定命名法匹配一遍(能应对不规范命令行大小写)。") + .AddRawStatements(optionProperties.Select(x => GenerateOptionEqualsCode(x, x.GetShortNames()))) + .AddLineSeparator()) + .EndCondition() + .AddRawStatement("return global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch.NotMatch;"); + + static string GenerateOptionCaseCode(OptionalArgumentPropertyGeneratingModel model, IReadOnlyList names) + { + if (names.Count == 0) + { + return $""" + // 属性 {model.PropertyName} 没有短名称,无需匹配。 + """; + } + return string.Join("\n", names.Select(name => $""" + case "{name}": + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof({model.PropertyName}), {model.PropertyIndex}, {model.Type.AsCommandValueKind().ToCommandValueTypeName()}); + """)); + } + + static string GenerateOptionEqualsCode(OptionalArgumentPropertyGeneratingModel model, IReadOnlyList names) + { + if (names.Count == 0) + { + return $""" + // 属性 {model.PropertyName} 没有短名称,无需匹配。 + """; + } + return string.Join("\n", names.Select(name => $$""" + if (shortOption.Equals("{{name}}".AsSpan(), {{model.GetStringComparisonExpression()}})) + { + return new global::DotNetCampus.Cli.Utils.Parsers.OptionValueMatch(nameof({{model.PropertyName}}), {{model.PropertyIndex}}, {{model.Type.AsCommandValueKind().ToCommandValueTypeName()}}); + } + """)); + } + } + + private MethodDeclarationSourceTextBuilder GenerateMatchPositionalArgumentsCode(MethodDeclarationSourceTextBuilder builder, + CommandObjectGeneratingModel model) + { + var positionalArgumentProperties = model.PositionalArgumentProperties; + var matchAllProperty = positionalArgumentProperties.FirstOrDefault(x => x.Index is 0 && x.Length is int.MaxValue); + return builder + .Condition(positionalArgumentProperties.Count is 0, b => b + .AddRawStatement("// 没有位置参数,无需匹配。") + .AddRawStatement("return global::DotNetCampus.Cli.Utils.Parsers.PositionalArgumentValueMatch.NotMatch;")) + .Condition(matchAllProperty is not null, b => b + .AddRawStatement($"// 属性 {matchAllProperty!.PropertyName} 覆盖了所有位置参数,直接匹配。") + .AddRawStatement($""" +return new global::DotNetCampus.Cli.Utils.Parsers.PositionalArgumentValueMatch("{matchAllProperty.PropertyName}", {matchAllProperty.PropertyIndex}, global::DotNetCampus.Cli.Compiler.PositionalArgumentValueType.Normal); +""")) + .Otherwise(b => b + .AddRawStatements(positionalArgumentProperties.Select(x => GenerateMatchPositionalArgumentCode(x, x.Index, x.Length))) + .AddLineSeparator() + .AddRawStatement("return global::DotNetCampus.Cli.Utils.Parsers.PositionalArgumentValueMatch.NotMatch;")) + .EndCondition(); + } + + private string GenerateMatchPositionalArgumentCode(PositionalArgumentPropertyGeneratingModel model, int index, int length) + { + return length switch + { + <= 0 => "// 属性 {model.PropertyName} 的范围不包含任何位置参数,无法匹配。", + 1 => $$""" + if (argumentIndex is {{index}}) + { + return new global::DotNetCampus.Cli.Utils.Parsers.PositionalArgumentValueMatch("{{model.PropertyName}}", {{model.PropertyIndex}}, global::DotNetCampus.Cli.Compiler.PositionalArgumentValueType.Normal); + } + """, + _ when (index + length) is <= 0 or int.MaxValue => $$""" + if (argumentIndex >= {{index}}) + { + return new global::DotNetCampus.Cli.Utils.Parsers.PositionalArgumentValueMatch("{{model.PropertyName}}", {{model.PropertyIndex}}, global::DotNetCampus.Cli.Compiler.PositionalArgumentValueType.Normal); + } + """, + _ => $$""" + if (argumentIndex is >= {{index}} and < {{index + length}}) + { + return new global::DotNetCampus.Cli.Utils.Parsers.PositionalArgumentValueMatch("{{model.PropertyName}}", {{model.PropertyIndex}}, global::DotNetCampus.Cli.Compiler.PositionalArgumentValueType.Normal); + } + """, + }; + } + + private string GenerateAssignPropertyValueCode(PropertyGeneratingModel model) + { + var assign = model.Type.AsCommandValueKind() switch + { + CommandValueKind.Boolean => $"{model.PropertyName} = {model.PropertyName}.Assign(value);", + CommandValueKind.List => $"{model.PropertyName} = {model.PropertyName}.Append(value);", + CommandValueKind.Dictionary => $"{model.PropertyName} = {model.PropertyName}.Append(key, value);", + _ => $"{model.PropertyName} = {model.PropertyName}.Assign(value);", + }; + var propertyIndex = model switch + { + OptionalArgumentPropertyGeneratingModel optionPropertyGeneratingModel => optionPropertyGeneratingModel.PropertyIndex, + PositionalArgumentPropertyGeneratingModel positionalArgumentPropertyGeneratingModel => positionalArgumentPropertyGeneratingModel.PropertyIndex, + _ => -1, + }; + return $""" + case {propertyIndex}: + {assign} + break; + """; + } + + private MethodDeclarationSourceTextBuilder GenerateBuildCoreCode(MethodDeclarationSourceTextBuilder builder, CommandObjectGeneratingModel model) + { + var initRawArgumentsProperties = model.RawArgumentsProperties.Where(x => x.IsRequiredOrInit).ToList(); + var initOptionProperties = model.OptionProperties.Where(x => x.IsRequiredOrInit).ToList(); + var initPositionalArgumentProperties = model.PositionalArgumentProperties.Where(x => x.IsRequiredOrInit).ToList(); + var setRawArgumentsProperties = model.RawArgumentsProperties.Where(x => !x.IsRequiredOrInit).ToList(); + var setOptionProperties = model.OptionProperties.Where(x => !x.IsRequiredOrInit).ToList(); + var setPositionalArgumentProperties = model.PositionalArgumentProperties.Where(x => !x.IsRequiredOrInit).ToList(); + + return builder + .AddBracketScope($"var result = new {model.CommandObjectType.ToUsingString()}", "{", "};", c => c + + // 1. [RawArguments] + .Condition(initRawArgumentsProperties.Count is 0, b => b + .AddRawStatement("// 1. There is no [RawArguments] property to be initialized.")) + .Otherwise(b => b + .AddRawStatement("// 1. [RawArguments]") + .AddRawStatements(initRawArgumentsProperties.Select(x => GenerateRawArgumentProperty(x, false)))) + .EndCondition() + + // 2. [Option] + .AddLineSeparator() + .Condition(initOptionProperties.Count is 0, b => b + .AddRawStatement("// 2. There is no [Option] property to be initialized.")) + .Otherwise(b => b + .AddRawStatement("// 2. [Option]") + .AddRawStatements(initOptionProperties.Select(x => GenerateInitProperty(x, false)))) + .EndCondition() + + // 3. [Value] + .AddLineSeparator() + .Condition(initPositionalArgumentProperties.Count is 0, b => b + .AddRawStatement("// 3. There is no [Value] property to be initialized.")) + .Otherwise(b => b + .AddRawStatement("// 3. [Value]") + .AddRawStatements(initPositionalArgumentProperties.Select(x => GenerateInitProperty(x, false)))) + .EndCondition()) + + // 1. [RawArguments] + .AddLineSeparator() + .Condition(setRawArgumentsProperties.Count is 0, b => b + .AddRawStatement("// 1. There is no [RawArguments] property to be assigned.")) + .Otherwise(b => b + .AddRawStatement("// 1. [RawArguments]") + .AddRawStatements(setRawArgumentsProperties.Select(x => GenerateRawArgumentProperty(x, false)))) + .EndCondition() + + // 2. [Option] + .AddLineSeparator() + .Condition(setOptionProperties.Count is 0, b => b + .AddRawStatement("// 2. There is no [Option] property to be assigned.")) + .Otherwise(b => b + .AddRawStatement("// 2. [Option]") + .AddRawStatements(setOptionProperties.Select(GenerateSetProperty))) + .EndCondition() + + // 3. [Value] + .AddLineSeparator() + .Condition(setPositionalArgumentProperties.Count is 0, b => b + .AddRawStatement("// 3. There is no [Value] property to be assigned.")) + .Otherwise(b => b + .AddRawStatement("// 3. [Value]") + .AddRawStatements(setPositionalArgumentProperties.Select(GenerateSetProperty))) + .EndCondition() + .AddLineSeparator() + + // 返回。 + .AddRawStatement("return result;"); + } + + private MethodDeclarationSourceTextBuilder GenerateBuildDefaultCode(MethodDeclarationSourceTextBuilder builder, CommandObjectGeneratingModel model) + { + var initRawArgumentsProperties = model.RawArgumentsProperties.Where(x => x.IsRequiredOrInit).ToList(); + var initOptionProperties = model.OptionProperties.Where(x => x.IsRequiredOrInit).ToList(); + var initPositionalArgumentProperties = model.PositionalArgumentProperties.Where(x => x.IsRequiredOrInit).ToList(); + + if (initOptionProperties.Any(x => x.IsRequired) + || initPositionalArgumentProperties.Any(x => x.IsRequired)) + { + // 存在必须赋值的属性,不能生成默认值创建代码。 + builder.AddRawStatement(""" +throw new global::DotNetCampus.Cli.Exceptions.RequiredPropertyNotAssignedException($"The command line arguments doesn't contain any required option or positional argument. Command line: {commandLine}", null!); +"""); + return builder; + } + + return builder + .AddBracketScope($"var result = new {model.CommandObjectType.ToUsingString()}", "{", "};", c => c + + // 1. [RawArguments] + .Condition(initRawArgumentsProperties.Count is 0, b => b.Ignore()) + .Otherwise(b => b + .AddRawStatements(initRawArgumentsProperties.Select(x => GenerateRawArgumentProperty(x, true)))) + .EndCondition() + + // 2. [Option] + .AddLineSeparator() + .Condition(initOptionProperties.Count is 0, b => b.Ignore()) + .Otherwise(b => b + .AddRawStatements(initOptionProperties.Select(x => GenerateInitProperty(x, true)))) + .EndCondition() + + // 3. [Value] + .AddLineSeparator() + .Condition(initPositionalArgumentProperties.Count is 0, b => b.Ignore()) + .Otherwise(b => b + .AddRawStatements(initPositionalArgumentProperties.Select(x => GenerateInitProperty(x, true)))) + .EndCondition()) + + // 返回。 + .AddRawStatement("return result;"); + } + + private string GenerateInitProperty(PropertyGeneratingModel model, bool forDefault) + { + // 对于不同的属性种类,如果命令行中没有赋值,则行为不同。 + + // required: 属性要求必须由命令行传入 + // init: 属性要求必须赋值(没有传入则使用该类型默认值) + // nullable: 属性允许为 null + // list: 属性是一个集合 + // cli: 实际命令行参数是否传入 + + // | required | init | nullable | list | 行为 | 解释 | + // | -------- | ---- | -------- | ---- | ----------- | --------------------------------- | + // | 1 | _ | _ | _ | 抛异常 | 要求必须传入,没有传就抛异常 | + // | 0 | 1 | 1 | _ | null | 可空,没有传就赋值 null | + // | 0 | 1 | 0 | 1 | 空集合 | 集合永不为 null,没传就赋值空集合 | + // | 0 | 1 | 0 | 0 | 默认值/空值 | 不可空,没有传就赋值默认值 | + // | 0 | 0 | _ | _ | 保留初值 | 不要求必须或立即赋值的,保留初值 | + // + // [默认值/空值] 如果是值类型,则会赋值其默认值;如果是引用类型,目前只有一种情况,就是字符串,会赋值为空字符串 `""`。 + + var toTarget = model.Type.GetGeneratedNotAbstractTypeName(); + var kind = model.Type.AsCommandValueKind(); + var isString = kind is CommandValueKind.String; + var isList = kind is CommandValueKind.List or CommandValueKind.Dictionary; + var supportCollectionExpression = model.Type.SupportCollectionExpression(false); + var fallback = (model.IsRequired, model.IsInitOnly, model.IsNullable, isList) switch + { + (true, _, _, _) => model switch + { + OptionalArgumentPropertyGeneratingModel option => + $"throw new global::DotNetCampus.Cli.Exceptions.RequiredPropertyNotAssignedException($\"The command line arguments doesn't contain a required option '{option.GetOrdinalLongNames()[0]}'. Command line: {{commandLine}}\", \"{option.PropertyName}\")", + PositionalArgumentPropertyGeneratingModel positionalArgument => + $"throw new global::DotNetCampus.Cli.Exceptions.RequiredPropertyNotAssignedException($\"The command line arguments doesn't contain a required positional argument at index {positionalArgument.Index}. Command line: {{commandLine}}\", \"{positionalArgument.PropertyName}\")", + _ => "", + }, + (_, true, true, _) => "null", + (_, true, false, true) => supportCollectionExpression + ? "[]" + : $"new {GetArgumentPropertyTypeName(model)}().To{toTarget}(true)", + (_, true, false, false) => isString + ? "\"\"" + : $"default({model.Type.ToDisplayString()})", + _ => "/* 非 init 属性,在下面单独赋值 */", + }; + + return !forDefault + // 正常传入了命令行参数时的通用赋值。 + ? $"{model.PropertyName} = {model.PropertyName}.To{toTarget}(){(fallback is "" ? "" : $" ?? {fallback}")}," + // 未传命令行参数时,直接赋回退值。 + : $"{model.PropertyName} = {fallback},"; + } + + private string GenerateSetProperty(PropertyGeneratingModel model, int modelIndex) + { + var toTarget = model.Type.GetGeneratedNotAbstractTypeName(); + var variablePrefix = model switch + { + RawArgumentPropertyGeneratingModel => "a", + OptionalArgumentPropertyGeneratingModel => "o", + PositionalArgumentPropertyGeneratingModel => "v", + _ => "", + }; + return $$""" + if ({{model.PropertyName}}.To{{toTarget}}() is { } {{variablePrefix}}{{modelIndex}}) + { + result.{{model.PropertyName}} = {{variablePrefix}}{{modelIndex}}; + } + """; + } + + private string GenerateRawArgumentProperty(RawArgumentPropertyGeneratingModel model, bool forDefault) + { + if (forDefault) + { + return $"{model.PropertyName} = [],"; + } + + var assignment = $"{model.PropertyName} = (commandLine.CommandLineArguments as {model.Type.ToDisplayString()}) ?? [..commandLine.CommandLineArguments]"; + return model.IsRequiredOrInit + ? $"{assignment}," + : $"result.{assignment};"; + } + + private string GenerateEnumDeclarationCode(ITypeSymbol enumType) + { + var enumNames = enumType.GetMembers().OfType().Select(x => x.Name); + return $$""" +/// +/// Provides parsing and assignment for the enum type . +/// +private readonly record struct {{enumType.GetGeneratedEnumArgumentTypeName()}} +{ + /// + /// Indicates whether to ignore exceptions when parsing fails. + /// + public bool IgnoreExceptions { get; init; } + + /// + /// Stores the parsed enum value. + /// + private {{enumType.ToUsingString()}}? Value { get; init; } + + /// + /// Assigns a value when a command line input is parsed. + /// + /// The parsed string value. + public {{enumType.GetGeneratedEnumArgumentTypeName()}} Assign(ReadOnlySpan value) + { + Span lowerValue = stackalloc char[value.Length]; + for (var i = 0; i < value.Length; i++) + { + lowerValue[i] = char.ToLowerInvariant(value[i]); + } + {{enumType.ToUsingString()}}? newValue = lowerValue switch + { + {{string.Join("\n ", enumNames.Select(x => $" \"{x.ToLowerInvariant()}\" => {enumType.ToUsingString()}.{x},"))}} + _ when IgnoreExceptions => null, + _ => throw new global::DotNetCampus.Cli.Exceptions.CommandLineParseValueException($"Cannot convert '{value.ToString()}' to enum type '{{enumType.ToDisplayString()}}'."), + }; + return this with { Value = newValue }; + } + + /// + /// Converts the parsed value to the enum type. + /// + public {{enumType.ToUsingString()}}? To{{enumType.GetGeneratedNotAbstractTypeName()}}() => Value; +} +"""; + } + + private string GenerateParserCode(string originalCode, CommandObjectGeneratingModel model) => new StringBuilder() + .AppendLine("#nullable enable") + .AppendLine("using DotNetCampus.Cli;") + .Append(originalCode) + .Replace("namespace DotNetCampus.Cli.Utils.Parsers;", $"namespace {model.Namespace};") + .Replace("public readonly ref struct CommandLineParser", $"partial struct {model.GetBuilderTypeName()}") + .ToString(); +} + +file static class Extensions +{ + public static string GetStringComparisonExpression(this OptionalArgumentPropertyGeneratingModel model) + { + return model.CaseSensitive switch + { + true => "global::System.StringComparison.Ordinal", + false => "global::System.StringComparison.OrdinalIgnoreCase", + null => "defaultComparison", + }; + } + + public static TBuilder AddDefaultStringComparisonIfNeeded(this TBuilder builder, + IReadOnlyList optionProperties) + where TBuilder : IAllowStatements + { + var needStringComparison = optionProperties.Any(x => x.CaseSensitive is null); + if (needStringComparison) + { + builder.AddRawStatement( + """ + var defaultComparison = defaultCaseSensitive + ? global::System.StringComparison.Ordinal + : global::System.StringComparison.OrdinalIgnoreCase; + """); + } + return builder; + } +} diff --git a/src/DotNetCampus.CommandLine.Analyzer/Generators/ModelProviding/CommandModelProvider.cs b/src/DotNetCampus.CommandLine.Analyzer/Generators/ModelProviding/CommandModelProvider.cs index 0e0aa34a..af33093b 100644 --- a/src/DotNetCampus.CommandLine.Analyzer/Generators/ModelProviding/CommandModelProvider.cs +++ b/src/DotNetCampus.CommandLine.Analyzer/Generators/ModelProviding/CommandModelProvider.cs @@ -1,7 +1,5 @@ -using System.Collections.Immutable; -using System.Globalization; -using DotNetCampus.Cli.Compiler; -using DotNetCampus.Cli.Utils; +using DotNetCampus.Cli.Compiler; +using DotNetCampus.CommandLine.Generators.Models; using DotNetCampus.CommandLine.Utils.CodeAnalysis; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -56,367 +54,80 @@ public static IncrementalValuesProvider SelectComm ; // 4. 拥有 [Option] 特性的属性。 var optionProperties = typeSymbol - .GetAttributedProperties(OptionPropertyGeneratingModel.TryParse); + .GetAttributedProperties(OptionalArgumentPropertyGeneratingModel.TryParse); // 5. 拥有 [Value] 特性的属性。 var valueProperties = typeSymbol - .GetAttributedProperties(ValuePropertyGeneratingModel.TryParse); + .GetAttributedProperties(PositionalArgumentPropertyGeneratingModel.TryParse); // 6. 拥有 [RawArguments] 特性的属性。 var rawArgumentsProperties = typeSymbol - .GetAttributedProperties(RawArgumentsPropertyGeneratingModel.TryParse); + .GetAttributedProperties(RawArgumentPropertyGeneratingModel.TryParse); - if (!isOptions && !isHandler && attribute is null && optionProperties.IsEmpty && valueProperties.IsEmpty && rawArgumentsProperties.IsEmpty) + if (!isOptions && !isHandler && attribute is null + && optionProperties.Count is 0 && valueProperties.Count is 0 && rawArgumentsProperties.Count is 0) { // 不是命令行选项类型。 return null; } var @namespace = typeSymbol.ContainingNamespace.ToDisplayString(); - var commandNames = attribute?.ConstructorArguments[0].Value?.ToString(); + var commandNames = attribute?.ConstructorArguments.FirstOrDefault().Value?.ToString(); + var useFullStackParser = attribute?.NamedArguments + .FirstOrDefault(kv => kv.Key == "ExperimentalUseFullStackParser").Value.Value as bool? ?? false; var isPublic = typeSymbol.DeclaredAccessibility == Accessibility.Public; + for (var i = 0; i < optionProperties.Count; i++) + { + optionProperties[i].PropertyIndex = i; + } + for (var i = 0; i < valueProperties.Count; i++) + { + valueProperties[i].PropertyIndex = i + optionProperties.Count; + } + return new CommandObjectGeneratingModel { Namespace = @namespace, CommandObjectType = typeSymbol, + UseFullStackParser = useFullStackParser, IsPublic = isPublic, CommandNames = commandNames, IsHandler = isHandler, OptionProperties = optionProperties, - ValueProperties = valueProperties, + PositionalArgumentProperties = valueProperties, RawArgumentsProperties = rawArgumentsProperties, }; }) .Where(m => m is not null) .Select((m, ct) => m!); } - - public static IncrementalValuesProvider SelectAssemblyCommands(this IncrementalGeneratorInitializationContext context) - { - return context.SyntaxProvider.ForAttributeWithMetadataName(typeof(CollectCommandHandlersFromThisAssemblyAttribute).FullName!, (node, ct) => - { - if (node is not ClassDeclarationSyntax cds) - { - // 必须是类型。 - return false; - } - - return true; - }, (c, ct) => - { - var typeSymbol = c.TargetSymbol; - var rootNamespace = typeSymbol.ContainingNamespace.ToDisplayString(); - var typeName = typeSymbol.Name; - var attribute = typeSymbol.GetAttributes() - .FirstOrDefault(a => a.AttributeClass!.IsAttributeOf()); - - return new AssemblyCommandsGeneratingModel - { - Namespace = rootNamespace, - AssemblyCommandHandlerType = (INamedTypeSymbol)typeSymbol, - }; - }); - } -} - -internal record CommandObjectGeneratingModel -{ - private static readonly ImmutableArray SupportedPostfixes = ["Options", "CommandOptions", "Handler", "CommandHandler", ""]; - - public required string Namespace { get; init; } - - public required INamedTypeSymbol CommandObjectType { get; init; } - - public required bool IsPublic { get; init; } - - public required string? CommandNames { get; init; } - - public required bool IsHandler { get; init; } - - public required ImmutableArray OptionProperties { get; init; } - - public required ImmutableArray ValueProperties { get; init; } - - public required ImmutableArray RawArgumentsProperties { get; init; } - - public string GetBuilderTypeName() => GetBuilderTypeName(CommandObjectType); - - public int GetCommandLevel() => CommandNames switch - { - null => 0, - { } names => names.Count(x => x == ' ') + 1, - }; - - public string? GetKebabCaseCommandNames() - { - if (CommandNames is not { } commandNames) - { - return null; - } - return string.Join(" ", commandNames.Split([' '], StringSplitOptions.RemoveEmptyEntries) - .Select(x => NamingHelper.MakeKebabCase(x, false, false))); - } - - public static string GetBuilderTypeName(INamedTypeSymbol commandObjectType) - { - return $"{commandObjectType.Name}Builder"; - } -} - -internal record OptionPropertyGeneratingModel -{ - public required string PropertyName { get; init; } - - public required ITypeSymbol Type { get; init; } - - public required bool IsRequired { get; init; } - - public required bool IsInitOnly { get; init; } - - public required bool IsNullable { get; init; } - - public required bool IsValueType { get; init; } - - public required char? ShortName { get; init; } - - public required string? LongName { get; init; } - - public required bool? CaseSensitive { get; init; } - - public required bool ExactSpelling { get; init; } - - public required ImmutableArray Aliases { get; init; } - - public ImmutableArray GetNormalizedLongNames() - { - if (ExactSpelling) - { - return [LongName ?? PropertyName]; - } - - return (CaseSensitive, LongName) switch - { - // 如果没有指定长名称,那么长名称就是根据属性名推测的,这时一定自动将其转换为 kebab-case 小写风格。 - (_, null) => [NamingHelper.MakeKebabCase(LongName ?? PropertyName, true, true)], - - // 如果指定了大小写敏感,那么在转换为 kebab-case 时,不转换大小写。 - (true, _) => [NamingHelper.MakeKebabCase(LongName, true, false)], - - // 如果指定了大小写不敏感,那么在转换为 kebab-case 时,统一转换为小写。 - (false, _) => [NamingHelper.MakeKebabCase(LongName, true, true)], - - // 如果没有在属性处指定大小写敏感,那么给出两个转换的候选,延迟到运行时再决定。 - (null, _) => - [ - ..new List - { - NamingHelper.MakeKebabCase(LongName, true, false), - NamingHelper.MakeKebabCase(LongName, true, true), - }.Distinct(StringComparer.Ordinal), - ], - }; - } - - public string GetDisplayCommandOption() - { - var caseSensitive = CaseSensitive is true; - - if (LongName is { } longName) - { - return $"--{NamingHelper.MakeKebabCase(longName, !caseSensitive, !caseSensitive)}"; - } - - if (ShortName is { } shortName) - { - return $"-{shortName}"; - } - - return $"--{NamingHelper.MakeKebabCase(PropertyName, !caseSensitive, !caseSensitive)}"; - } - - public IReadOnlyList GenerateAllNames( - Func shortNameCreator, - Func longNameCreator, - Func caseLongNameCreator, - Func aliasCreator) - { - var list = new List(); - - if (ShortName is { } shortName) - { - list.Add(shortNameCreator(shortName.ToString(CultureInfo.InvariantCulture))); - } - - var longNames = GetNormalizedLongNames(); - if (longNames.Length is 1) - { - list.Add(longNameCreator(longNames[0])); - } - else if (longNames.Length is 2) - { - list.Add(caseLongNameCreator(longNames[0], longNames[1])); - } - - if (Aliases is { Length: > 0 } aliases) - { - foreach (var alias in aliases) - { - list.Add(aliasCreator(alias)); - } - } - - return list; - } - - public static OptionPropertyGeneratingModel? TryParse(IPropertySymbol propertySymbol) - { - var optionAttribute = propertySymbol.GetAttributes().FirstOrDefault(a => a.AttributeClass!.IsAttributeOf()); - if (optionAttribute is null) - { - return null; - } - - var longName = optionAttribute.ConstructorArguments.FirstOrDefault(x => x.Type?.SpecialType is SpecialType.System_String).Value?.ToString(); - var shortName = optionAttribute.ConstructorArguments.FirstOrDefault(x => x.Type?.SpecialType is SpecialType.System_Char).Value?.ToString(); - var caseSensitive = optionAttribute.NamedArguments.FirstOrDefault(a => a.Key == nameof(OptionAttribute.CaseSensitive)).Value.Value?.ToString(); - var exactSpelling = optionAttribute.NamedArguments.FirstOrDefault(a => a.Key == nameof(OptionAttribute.ExactSpelling)).Value.Value is true; - var aliases = optionAttribute.NamedArguments.FirstOrDefault(a => a.Key == nameof(OptionAttribute.Aliases)).Value switch - { - { Kind: TypedConstantKind.Array } typedConstant => typedConstant.Values.Select(a => a.Value?.ToString()) - .Where(a => !string.IsNullOrEmpty(a)) - .OfType() - .ToImmutableArray(), - _ => [], - }; - - return new OptionPropertyGeneratingModel - { - PropertyName = propertySymbol.Name, - Type = propertySymbol.Type, - IsRequired = propertySymbol.IsRequired, - IsInitOnly = propertySymbol.SetMethod?.IsInitOnly ?? false, - IsNullable = propertySymbol.Type.NullableAnnotation == NullableAnnotation.Annotated, - IsValueType = propertySymbol.Type.IsValueType, - ShortName = shortName?.Length == 1 ? shortName[0] : null, - LongName = longName, - CaseSensitive = caseSensitive is not null && bool.TryParse(caseSensitive, out var result) ? result : null, - ExactSpelling = exactSpelling, - Aliases = aliases, - }; - } -} - -internal record ValuePropertyGeneratingModel -{ - public required string PropertyName { get; init; } - - public required ITypeSymbol Type { get; init; } - - public required bool IsRequired { get; init; } - - public required bool IsInitOnly { get; init; } - - public required bool IsNullable { get; init; } - - public required bool IsValueType { get; init; } - - public required int? Index { get; init; } - - public required int? Length { get; init; } - - public static ValuePropertyGeneratingModel? TryParse(IPropertySymbol propertySymbol) - { - var valueAttribute = propertySymbol.GetAttributes().FirstOrDefault(a => a.AttributeClass!.IsAttributeOf()); - if (valueAttribute is null) - { - return null; - } - - var index = valueAttribute.ConstructorArguments.FirstOrDefault().Value?.ToString(); - var length = - // 优先从命名属性中拿。 - valueAttribute.NamedArguments - .FirstOrDefault(a => a.Key == nameof(ValueAttribute.Length)).Value.Value?.ToString() - // 其次从构造函数参数中拿。 - ?? valueAttribute.ConstructorArguments.ElementAtOrDefault(1).Value?.ToString(); - - return new ValuePropertyGeneratingModel - { - PropertyName = propertySymbol.Name, - Type = propertySymbol.Type, - IsRequired = propertySymbol.IsRequired, - IsInitOnly = propertySymbol.SetMethod?.IsInitOnly ?? false, - IsNullable = propertySymbol.Type.NullableAnnotation == NullableAnnotation.Annotated, - IsValueType = propertySymbol.Type.IsValueType, - Index = index is not null && int.TryParse(index, out var result) ? result : null, - Length = length is not null && int.TryParse(length, out var result2) ? result2 : null, - }; - } -} - -internal record RawArgumentsPropertyGeneratingModel -{ - public required string PropertyName { get; init; } - - public required ITypeSymbol Type { get; init; } - - public required bool IsRequired { get; init; } - - public required bool IsInitOnly { get; init; } - - public required bool IsNullable { get; init; } - - public static RawArgumentsPropertyGeneratingModel? TryParse(IPropertySymbol propertySymbol) - { - var rawArgumentsAttribute = propertySymbol.GetAttributes().FirstOrDefault(a => a.AttributeClass!.IsAttributeOf()); - if (rawArgumentsAttribute is null) - { - return null; - } - - return new RawArgumentsPropertyGeneratingModel - { - PropertyName = propertySymbol.Name, - Type = propertySymbol.Type, - IsRequired = propertySymbol.IsRequired, - IsInitOnly = propertySymbol.SetMethod?.IsInitOnly ?? false, - IsNullable = propertySymbol.Type.NullableAnnotation == NullableAnnotation.Annotated, - }; - } -} - -internal record AssemblyCommandsGeneratingModel -{ - public required string Namespace { get; init; } - - public required INamedTypeSymbol AssemblyCommandHandlerType { get; init; } } file static class Extensions { - public static IEnumerable EnumerateBaseTypesRecursively(this ITypeSymbol type) - { - var current = type; - while (current != null) - { - yield return current; - current = current.BaseType; - } - } - - public static ImmutableArray GetAttributedProperties(this ITypeSymbol typeSymbol, + public static IReadOnlyList GetAttributedProperties(this ITypeSymbol typeSymbol, Func propertyParser) where TModel : class { return typeSymbol .EnumerateBaseTypesRecursively() // 递归获取所有基类 .Reverse() // (注意我们先给父类属性赋值,再给子类属性赋值) - .SelectMany(x => x.GetMembers()) // 的所有成员, - .OfType() // 然后取出属性, + .SelectMany(x => x.GetMembers()) // 的所有成员, + .OfType() // 然后取出属性, .Select(x => (PropertyName: x.Name, Model: propertyParser(x))) // 解析出 OptionPropertyGeneratingModel。 .Where(x => x.Model is not null) .GroupBy(x => x.PropertyName) // 按属性名去重。 .Select(x => x.Last().Model) // 随后,取子类的属性(去除父类的重名属性)。 .Cast() - .ToImmutableArray(); + .ToList(); + } + + private static IEnumerable EnumerateBaseTypesRecursively(this ITypeSymbol type) + { + var current = type; + while (current != null) + { + yield return current; + current = current.BaseType; + } } } diff --git a/src/DotNetCampus.CommandLine.Analyzer/Generators/ModelProviding/InterceptorModelProvider.cs b/src/DotNetCampus.CommandLine.Analyzer/Generators/ModelProviding/InterceptorModelProvider.cs index ba9b865b..05a1be41 100644 --- a/src/DotNetCampus.CommandLine.Analyzer/Generators/ModelProviding/InterceptorModelProvider.cs +++ b/src/DotNetCampus.CommandLine.Analyzer/Generators/ModelProviding/InterceptorModelProvider.cs @@ -1,7 +1,7 @@ #pragma warning disable RSEXPERIMENTAL002 using System.Text.RegularExpressions; using DotNetCampus.Cli.Compiler; -using DotNetCampus.Cli.Utils; +using DotNetCampus.CommandLine.Generators.Models; using DotNetCampus.CommandLine.Utils.CodeAnalysis; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; @@ -11,28 +11,36 @@ namespace DotNetCampus.CommandLine.Generators.ModelProviding; internal static class InterceptorModelProvider { + private static readonly SymbolDisplayFormat NoGenericTypeNameFormat = new SymbolDisplayFormat( + globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Omitted, + typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, + genericsOptions: SymbolDisplayGenericsOptions.None, + kindOptions: SymbolDisplayKindOptions.None + ); + public static IncrementalValuesProvider SelectCommandLineAsProvider(this IncrementalGeneratorInitializationContext context) { return SelectMethodInvocationProvider(context, "DotNetCampus.Cli.CommandLine", "As"); } - public static IncrementalValuesProvider SelectCommandBuilderAddHandlerProvider( - this IncrementalGeneratorInitializationContext context) + public static IncrementalValuesProvider SelectAddHandlers( + this IncrementalGeneratorInitializationContext context, string thisTypeName) { - return SelectMethodInvocationProvider(context, "DotNetCampus.Cli.CommandRunnerBuilderExtensions", "AddHandler"); + return SelectMethodInvocationProvider(context, + $"DotNetCampus.Cli.{thisTypeName}", "AddHandler"); } - public static IncrementalValuesProvider SelectCommandBuilderAddHandlerProvider( - this IncrementalGeneratorInitializationContext context, string extensionMethodThisTypeName, string parameterTypeFullName) + public static IncrementalValuesProvider SelectAddHandlers( + this IncrementalGeneratorInitializationContext context, string thisTypeName, string parameterTypeFullName) { return SelectMethodInvocationProvider(context, - $"DotNetCampus.Cli.{extensionMethodThisTypeName}", "AddHandler", + $"DotNetCampus.Cli.{thisTypeName}", "AddHandler", parameterTypeFullName.Replace(".", @"\.").Replace("", @"<[\w_\.:\?]+>").Replace(" SelectMethodInvocationProvider(this IncrementalGeneratorInitializationContext context, - string typeFullName, string methodName, params string[] parameterTypeFullNameRegexes) + string thisTypeFullName, string methodName, params string[] parameterTypeFullNameRegexes) { return context.SyntaxProvider.CreateSyntaxProvider((node, ct) => { @@ -68,8 +76,8 @@ public static IncrementalValuesProvider SelectMethod // 没有方法。 return null; } - if (methodSymbol.ContainingType.ToDisplayString() != typeFullName - && methodSymbol.ReceiverType?.ToDisplayString() != typeFullName) + if (methodSymbol.ContainingType.ToDisplayString(NoGenericTypeNameFormat) != thisTypeFullName + && methodSymbol.ReceiverType?.ToDisplayString(NoGenericTypeNameFormat) != thisTypeFullName) { // 方法所在的类型不匹配,且扩展方法的 this 参数类型不匹配。 return null; @@ -100,19 +108,25 @@ public static IncrementalValuesProvider SelectMethod // 获取 [Command("xxx")] 或 [Verb("xxx")] 特性中的 xxx。 var commandAttribute = symbol.GetAttributes().FirstOrDefault(a => a.AttributeClass!.IsAttributeOf()) #pragma warning disable CS0618 // 类型或成员已过时 - ?? symbol.GetAttributes().FirstOrDefault(a => a.AttributeClass!.IsAttributeOf()) + ?? symbol.GetAttributes().FirstOrDefault(a => a.AttributeClass!.IsAttributeOf()) #pragma warning restore CS0618 // 类型或成员已过时 ; var commandNames = commandAttribute?.ConstructorArguments.FirstOrDefault() is { Kind: TypedConstantKind.Primitive } commandArgument ? commandArgument.Value?.ToString() : null; + var useFullStackParser = commandAttribute?.NamedArguments + .FirstOrDefault(kv => kv.Key == "ExperimentalUseFullStackParser").Value.Value as bool? ?? false; // 获取调用代码所在的类和方法。 var methodDeclaration = node.FirstAncestorOrSelf(); var classDeclaration = methodDeclaration?.FirstAncestorOrSelf(); var invocationFileName = Path.GetFileName(node.SyntaxTree.FilePath); var invocationInfo = $"{classDeclaration?.Identifier.ToString()}.{methodDeclaration?.Identifier.ToString()} @{invocationFileName}"; - return new InterceptorGeneratingModel(interceptableLocation, symbol, commandNames, invocationInfo); + return new InterceptorGeneratingModel(interceptableLocation, symbol, invocationInfo) + { + CommandNames = commandNames, + UseFullStackParser = useFullStackParser, + }; }) .Where(model => model is not null) .Select((model, ct) => model!); @@ -122,21 +136,14 @@ public static IncrementalValuesProvider SelectMethod internal record InterceptorGeneratingModel( InterceptableLocation InterceptableLocation, INamedTypeSymbol CommandObjectType, - string? CommandNames, string InvocationInfo ) { - public string GetBuilderTypeName() => CommandObjectGeneratingModel.GetBuilderTypeName(CommandObjectType); + public required string? CommandNames { get; init; } - public string? GetKebabCaseCommandNames() - { - if (CommandNames is not { } commandNames) - { - return null; - } - return string.Join(" ", commandNames.Split([' '], StringSplitOptions.RemoveEmptyEntries) - .Select(x => NamingHelper.MakeKebabCase(x, false, false))); - } + public required bool UseFullStackParser { get; init; } + + public string GetBuilderTypeName() => CommandObjectGeneratingModel.GetBuilderTypeName(CommandObjectType); internal static IEqualityComparer CommandObjectTypeEqualityComparer { get; } = new PrivateTypeSymbolEqualityComparer(); diff --git a/src/DotNetCampus.CommandLine.Analyzer/Generators/Models/AssemblyCommandsGeneratingModel.cs b/src/DotNetCampus.CommandLine.Analyzer/Generators/Models/AssemblyCommandsGeneratingModel.cs new file mode 100644 index 00000000..4461abc6 --- /dev/null +++ b/src/DotNetCampus.CommandLine.Analyzer/Generators/Models/AssemblyCommandsGeneratingModel.cs @@ -0,0 +1,10 @@ +using Microsoft.CodeAnalysis; + +namespace DotNetCampus.CommandLine.Generators.Models; + +internal record AssemblyCommandsGeneratingModel +{ + public required string Namespace { get; init; } + + public required INamedTypeSymbol AssemblyCommandHandlerType { get; init; } +} diff --git a/src/DotNetCampus.CommandLine.Analyzer/Generators/Models/CommandObjectGeneratingModel.cs b/src/DotNetCampus.CommandLine.Analyzer/Generators/Models/CommandObjectGeneratingModel.cs new file mode 100644 index 00000000..c8cf4379 --- /dev/null +++ b/src/DotNetCampus.CommandLine.Analyzer/Generators/Models/CommandObjectGeneratingModel.cs @@ -0,0 +1,97 @@ +using DotNetCampus.Cli.Utils; +using Microsoft.CodeAnalysis; + +namespace DotNetCampus.CommandLine.Generators.Models; + +internal record CommandObjectGeneratingModel +{ + private static readonly SymbolDisplayFormat SimpleContainingTypeFormat = new SymbolDisplayFormat( + globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Omitted, + typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypes, + genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters); + + public required string Namespace { get; init; } + + public required INamedTypeSymbol CommandObjectType { get; init; } + + public required bool IsPublic { get; init; } + + public required string? CommandNames { get; init; } + + public required bool UseFullStackParser { get; init; } + + public required bool IsHandler { get; init; } + + public required IReadOnlyList RawArgumentsProperties { get; init; } + + public required IReadOnlyList OptionProperties { get; init; } + + public required IReadOnlyList PositionalArgumentProperties { get; init; } + + public string GetBuilderTypeName() => GetBuilderTypeName(CommandObjectType); + + public static string GetBuilderTypeName(INamedTypeSymbol commandObjectType) + { + var name = commandObjectType.ToDisplayString(SimpleContainingTypeFormat).Replace('.', '_'); + return $"{name}Builder"; + } + + public int GetCommandLevel() => CommandNames switch + { + null => 0, + { } names => names.Count(x => x == ' ') + 1, + }; + + public string? GetPascalCaseCommandNames() + { + if (CommandNames is not { } commandNames) + { + return null; + } + return string.Join(" ", commandNames.Split([' '], StringSplitOptions.RemoveEmptyEntries) + .Select(NamingHelper.MakePascalCase)); + } + + public IEnumerable EnumeratePositionalArgumentExcludingSameNameOptions() + { + var optionNames = new HashSet(OptionProperties.Select(x => x.PropertyName)); + foreach (var positionalArgumentProperty in PositionalArgumentProperties) + { + if (!optionNames.Contains(positionalArgumentProperty.PropertyName)) + { + yield return positionalArgumentProperty; + } + } + } + + public string? GetKebabCaseCommandNames() + { + if (CommandNames is not { } commandNames) + { + return null; + } + return string.Join(" ", commandNames.Split([' '], StringSplitOptions.RemoveEmptyEntries) + .Select(x => NamingHelper.MakeKebabCase(x, false, false))); + } + + public IEnumerable EnumerateEnumPropertyTypes() + { + var enums = new HashSet(SymbolEqualityComparer.Default); + + foreach (var option in OptionProperties) + { + if (option.Type.GetSymbolInfoAsCommandProperty().AsEnumSymbol() is { } enumTypeSymbol) + { + enums.Add(enumTypeSymbol); + } + } + foreach (var value in PositionalArgumentProperties) + { + if (value.Type.GetSymbolInfoAsCommandProperty().AsEnumSymbol() is { } enumTypeSymbol) + { + enums.Add(enumTypeSymbol); + } + } + return enums; + } +} diff --git a/src/DotNetCampus.CommandLine.Analyzer/Generators/Models/GeneratingModelExtensions.cs b/src/DotNetCampus.CommandLine.Analyzer/Generators/Models/GeneratingModelExtensions.cs new file mode 100644 index 00000000..4b29f6c6 --- /dev/null +++ b/src/DotNetCampus.CommandLine.Analyzer/Generators/Models/GeneratingModelExtensions.cs @@ -0,0 +1,84 @@ +using DotNetCampus.CommandLine.CodeAnalysis; +using Microsoft.CodeAnalysis; + +namespace DotNetCampus.CommandLine.Generators.Models; + +/// +/// 为源生成器使用的数据模型提供扩展方法。 +/// +internal static class GeneratingModelExtensions +{ + public static string ToCommandValueTypeName(this CommandValueKind type) => type switch + { + CommandValueKind.Boolean => "global::DotNetCampus.Cli.Compiler.OptionValueType.Boolean", + CommandValueKind.List => "global::DotNetCampus.Cli.Compiler.OptionValueType.List", + CommandValueKind.Dictionary => "global::DotNetCampus.Cli.Compiler.OptionValueType.Dictionary", + _ => "global::DotNetCampus.Cli.Compiler.OptionValueType.Normal", + }; + + /// + /// 视 为命令行属性的类型,按命令行属性的要求获取其所需的类型信息。
+ /// 这个过程会丢掉类型的可空性信息。 + ///
+ /// 类型符号。 + /// 类型信息。 + public static CommandPropertyTypeInfo GetSymbolInfoAsCommandProperty(this ITypeSymbol typeSymbol) + { + return new CommandPropertyTypeInfo(typeSymbol); + } + + /// + /// 获取类型的非抽象名称。
+ /// 对于命令行解析中所支持的各种接口,会被映射为其常见的具体类型名称。 + ///
+ /// 类型符号。 + /// 非抽象名称。 + public static string GetGeneratedNotAbstractTypeName(this ITypeSymbol typeSymbol) + { + return typeSymbol.GetSymbolInfoAsCommandProperty().GetGeneratedNotAbstractTypeName(); + } + + /// + /// 假定 是一个命令行对象中一个枚举属性的属性类型, + /// 现在我们要为这个枚举生成一个用来赋值命令行值的辅助类型, + /// 此方法返回这个辅助类型的名称。 + /// + /// 命令行对象中一个枚举属性的属性类型。 + /// 辅助类型的名称。 + public static string GetGeneratedEnumArgumentTypeName(this ITypeSymbol symbol) + { + return symbol.GetSymbolInfoAsCommandProperty().AsEnumSymbol() is { } enumTypeSymbol + ? $"__GeneratedEnumArgument__{enumTypeSymbol.ToDisplayString().Replace('.', '_')}__" + : symbol.ToDisplayString(); + } + + /// + /// 将类型符号映射为命令行值的种类。 + /// + /// 类型符号。 + /// 命令行值的种类。 + public static CommandValueKind AsCommandValueKind(this ITypeSymbol typeSymbol) + { + return typeSymbol.GetSymbolInfoAsCommandProperty().Kind; + } + + /// + /// 判断类型是否确定支持集合表达式(Collection Expression)。 + /// + /// 要检查的类型符号。 + /// 当前框架是否支持不可变集合类型(如 ImmutableListImmutableHashSet)。 + /// 如果类型确定支持集合表达式,则返回 ;否则返回 + public static bool SupportCollectionExpression(this ITypeSymbol typeSymbol, bool supportImmutableCollections) + { + var info = typeSymbol.GetSymbolInfoAsCommandProperty(); + if (info.Kind is not CommandValueKind.List) + { + return false; + } + + // 不可变集合在 .NET 8 及以上版本中支持集合表达式。 + // 其他类型均直接支持集合表达式。 + var simpleName = info.GetSimpleDeclarationName(); + return !simpleName.Contains("Immutable") || supportImmutableCollections; + } +} diff --git a/src/DotNetCampus.CommandLine.Analyzer/Generators/Models/OptionalArgumentPropertyGeneratingModel.cs b/src/DotNetCampus.CommandLine.Analyzer/Generators/Models/OptionalArgumentPropertyGeneratingModel.cs new file mode 100644 index 00000000..e11a60ef --- /dev/null +++ b/src/DotNetCampus.CommandLine.Analyzer/Generators/Models/OptionalArgumentPropertyGeneratingModel.cs @@ -0,0 +1,265 @@ +using DotNetCampus.Cli.Compiler; +using DotNetCampus.Cli.Utils; +using DotNetCampus.CommandLine.Utils.CodeAnalysis; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace DotNetCampus.CommandLine.Generators.Models; + +internal sealed record OptionalArgumentPropertyGeneratingModel : PropertyGeneratingModel +{ + private OptionalArgumentPropertyGeneratingModel(IPropertySymbol propertySymbol) : base(propertySymbol) + { + } + + public required IReadOnlyList ShortNames { get; init; } + + public required IReadOnlyList LongNames { get; init; } + + public required bool? CaseSensitive { get; init; } + + public int PropertyIndex { get; set; } = -1; + + /// + /// 返回开发者定义的长选项名称列表,按定义顺序返回。
+ /// 如果没有定义,则返回 kebab-case 风格的属性名作为默认名称; + /// 如果有定义,无论定义了什么,都视其为 kebab-case 风格的名称。 + ///
+ public IReadOnlyList GetOrdinalLongNames() + { + List list = []; + if (LongNames.Count is 0) + { + list.Add(NamingHelper.MakeKebabCase(PropertyName)); + } + else + { + foreach (var longName in LongNames) + { + if (!string.IsNullOrEmpty(longName) && !list.Contains(longName, StringComparer.Ordinal)) + { + list.Add(longName); + } + } + } + return list; + } + + public IReadOnlyList GetPascalCaseLongNames() + { + List list = []; + if (LongNames.Count is 0) + { + list.Add(PropertyName); + } + else + { + foreach (var longName in LongNames) + { + if (!string.IsNullOrEmpty(longName)) + { + var pascalCase = NamingHelper.MakePascalCase(longName); + if (!list.Contains(pascalCase, StringComparer.Ordinal)) + { + list.Add(pascalCase); + } + } + } + } + return list; + } + + public IReadOnlyList GetShortNames() + { + List list = []; + foreach (var shortName in ShortNames) + { + if (!string.IsNullOrEmpty(shortName) && !list.Contains(shortName, StringComparer.Ordinal)) + { + list.Add(shortName); + } + } + return list; + } + + public static OptionalArgumentPropertyGeneratingModel? TryParse(IPropertySymbol propertySymbol) + { + var optionAttribute = propertySymbol.GetAttributes().FirstOrDefault(a => a.AttributeClass!.IsAttributeOf()); + if (optionAttribute is null) + { + return null; + } + + List shortNames = []; + List longNames = []; + + if (optionAttribute.ConstructorArguments.Length is 0) + { + // 没有构造函数参数时,不设置任何名称。 + } + else if (optionAttribute.ConstructorArguments.Length is 1) + { + // 只有一个构造函数参数时,要么是短名称(一定是字符),要么是长名称(一定是字符串)。 + var arg = optionAttribute.ConstructorArguments[0]; + if (arg.Type?.SpecialType is SpecialType.System_Char) + { + var shortName = arg.Value?.ToString(); + if (!string.IsNullOrEmpty(shortName)) + { + shortNames.Add(shortName!); + } + } + else if (arg.Type?.SpecialType is SpecialType.System_String) + { + var longName = arg.Value?.ToString(); + if (!string.IsNullOrEmpty(longName)) + { + longNames.Add(longName!); + } + } + } + else if (optionAttribute.ConstructorArguments.Length is 2) + { + // 有两个构造函数参数时,第一个参数是短名称(字符、字符串、字符串数组),第二个参数是长名称(字符串、字符串数组)。 + var shortArg = optionAttribute.ConstructorArguments[0]; + if (shortArg.Type?.SpecialType is SpecialType.System_Char) + { + var shortName = shortArg.Value?.ToString(); + if (!string.IsNullOrEmpty(shortName)) + { + shortNames.Add(shortName!); + } + } + else if (shortArg.Type?.SpecialType is SpecialType.System_String) + { + var shortName = shortArg.Value?.ToString(); + if (!string.IsNullOrEmpty(shortName)) + { + shortNames.Add(shortName!); + } + } + else if (shortArg.Kind is TypedConstantKind.Array) + { + foreach (var value in shortArg.Values) + { + var shortName = value.Value?.ToString(); + if (!string.IsNullOrEmpty(shortName) && !shortNames.Contains(shortName, StringComparer.Ordinal)) + { + shortNames.Add(shortName!); + } + } + } + var longArg = optionAttribute.ConstructorArguments[1]; + if (longArg.Type?.SpecialType is SpecialType.System_String) + { + var longName = longArg.Value?.ToString(); + if (!string.IsNullOrEmpty(longName)) + { + longNames.Add(longName!); + } + } + else if (longArg.Kind is TypedConstantKind.Array) + { + foreach (var value in longArg.Values) + { + var longName = value.Value?.ToString(); + if (!string.IsNullOrEmpty(longName) && !longNames.Contains(longName, StringComparer.Ordinal)) + { + longNames.Add(longName!); + } + } + } + } + + var caseSensitive = optionAttribute.NamedArguments.FirstOrDefault(a => a.Key == nameof(OptionAttribute.CaseSensitive)).Value.Value?.ToString(); + + return new OptionalArgumentPropertyGeneratingModel(propertySymbol) + { + ShortNames = shortNames, + LongNames = longNames, + CaseSensitive = caseSensitive is not null && bool.TryParse(caseSensitive, out var result) ? result : null, + }; + } +} + +internal static class OptionalArgumentPropertyGeneratingModelExtensions +{ + public static (string? Name, Location? propertySymbol) FindFirstDuplicateName(this IReadOnlyList models) + { + var shortNames = new HashSet(); + var longNames = new HashSet(); + foreach (var model in models) + { + foreach (var shortName in model.GetShortNames()) + { + if (!shortNames.Add(shortName)) + { + return (shortName, model.PropertySymbol.FindDuplicateNameLocation(shortName)); + } + } + + foreach (var longName in model.GetOrdinalLongNames()) + { + if (!longNames.Add(longName)) + { + return (longName, model.PropertySymbol.FindDuplicateNameLocation(longName)); + } + } + } + return (null, null); + } + + private static Location? FindDuplicateNameLocation(this IPropertySymbol propertySymbol, string name) + { + var optionAttribute = propertySymbol.GetAttributes().FirstOrDefault(a => a.AttributeClass!.IsAttributeOf())!; + + // 有两个构造函数参数时,第一个参数是短名称(字符、字符串、字符串数组),第二个参数是长名称(字符串、字符串数组)。 + foreach (var argument in optionAttribute.ConstructorArguments.EnumerateDefinedNames()) + { + if (argument.Type?.SpecialType is SpecialType.System_Char) + { + var definedName = argument.Value?.ToString(); + if (definedName == name) + { + return optionAttribute.ApplicationSyntaxReference?.GetSyntax().DescendantNodes() + .OfType() + .FirstOrDefault(x => x.Expression.ToString().Contains($"'{name}'"))?.GetLocation()!; + } + } + else if (argument.Type?.SpecialType is SpecialType.System_String) + { + var definedName = argument.Value?.ToString(); + if (definedName == name) + { + return optionAttribute.ApplicationSyntaxReference?.GetSyntax().DescendantNodes() + .OfType() + .FirstOrDefault(x => x.Expression switch + { + InvocationExpressionSyntax s => x.ToString().Contains($"nameof({name})"), + _ => x.ToString().Contains($"\"{name}\""), + })?.GetLocation()!; + } + } + } + + return null; + } + + private static IEnumerable EnumerateDefinedNames(this IEnumerable arguments) + { + foreach (var argument in arguments) + { + if (argument.Kind is TypedConstantKind.Array) + { + foreach (var item in EnumerateDefinedNames(argument.Values)) + { + yield return item; + } + } + else + { + yield return argument; + } + } + } +} diff --git a/src/DotNetCampus.CommandLine.Analyzer/Generators/Models/PositionalArgumentPropertyGeneratingModel.cs b/src/DotNetCampus.CommandLine.Analyzer/Generators/Models/PositionalArgumentPropertyGeneratingModel.cs new file mode 100644 index 00000000..bd01913f --- /dev/null +++ b/src/DotNetCampus.CommandLine.Analyzer/Generators/Models/PositionalArgumentPropertyGeneratingModel.cs @@ -0,0 +1,41 @@ +using DotNetCampus.Cli.Compiler; +using DotNetCampus.CommandLine.Utils.CodeAnalysis; +using Microsoft.CodeAnalysis; + +namespace DotNetCampus.CommandLine.Generators.Models; + +internal sealed record PositionalArgumentPropertyGeneratingModel : PropertyGeneratingModel +{ + private PositionalArgumentPropertyGeneratingModel(IPropertySymbol propertySymbol) : base(propertySymbol) + { + } + + public required int Index { get; init; } + + public required int Length { get; init; } + + public int PropertyIndex { get; set; } = -1; + + public static PositionalArgumentPropertyGeneratingModel? TryParse(IPropertySymbol propertySymbol) + { + var valueAttribute = propertySymbol.GetAttributes().FirstOrDefault(a => a.AttributeClass!.IsAttributeOf()); + if (valueAttribute is null) + { + return null; + } + + var index = valueAttribute.ConstructorArguments.FirstOrDefault().Value?.ToString(); + var length = + // 优先从命名属性中拿。 + valueAttribute.NamedArguments + .FirstOrDefault(a => a.Key == nameof(ValueAttribute.Length)).Value.Value?.ToString() + // 其次从构造函数参数中拿。 + ?? valueAttribute.ConstructorArguments.ElementAtOrDefault(1).Value?.ToString(); + + return new PositionalArgumentPropertyGeneratingModel(propertySymbol) + { + Index = index is not null && int.TryParse(index, out var result) ? result : 0, + Length = length is not null && int.TryParse(length, out var result2) ? result2 : 1, + }; + } +} diff --git a/src/DotNetCampus.CommandLine.Analyzer/Generators/Models/PropertyGeneratingModel.cs b/src/DotNetCampus.CommandLine.Analyzer/Generators/Models/PropertyGeneratingModel.cs new file mode 100644 index 00000000..e13b23bc --- /dev/null +++ b/src/DotNetCampus.CommandLine.Analyzer/Generators/Models/PropertyGeneratingModel.cs @@ -0,0 +1,33 @@ +using Microsoft.CodeAnalysis; + +namespace DotNetCampus.CommandLine.Generators.Models; + +internal abstract record PropertyGeneratingModel +{ + protected PropertyGeneratingModel(IPropertySymbol property) + { + PropertySymbol = property; + PropertyName = property.Name; + Type = property.Type; + IsRequired = property.IsRequired; + IsInitOnly = property.SetMethod?.IsInitOnly ?? false; + IsNullable = property.Type.NullableAnnotation == NullableAnnotation.Annotated; + IsValueType = property.Type.IsValueType; + } + + public IPropertySymbol PropertySymbol { get; } + + public string PropertyName { get; } + + public ITypeSymbol Type { get; } + + public bool IsRequired { get; } + + public bool IsInitOnly { get; } + + public bool IsRequiredOrInit => IsRequired || IsInitOnly; + + public bool IsNullable { get; } + + public bool IsValueType { get; } +} diff --git a/src/DotNetCampus.CommandLine.Analyzer/Generators/Models/RawArgumentPropertyGeneratingModel.cs b/src/DotNetCampus.CommandLine.Analyzer/Generators/Models/RawArgumentPropertyGeneratingModel.cs new file mode 100644 index 00000000..52008398 --- /dev/null +++ b/src/DotNetCampus.CommandLine.Analyzer/Generators/Models/RawArgumentPropertyGeneratingModel.cs @@ -0,0 +1,25 @@ +using DotNetCampus.Cli.Compiler; +using DotNetCampus.CommandLine.Utils.CodeAnalysis; +using Microsoft.CodeAnalysis; + +namespace DotNetCampus.CommandLine.Generators.Models; + +internal sealed record RawArgumentPropertyGeneratingModel : PropertyGeneratingModel +{ + private RawArgumentPropertyGeneratingModel(IPropertySymbol propertySymbol) : base(propertySymbol) + { + } + + public static RawArgumentPropertyGeneratingModel? TryParse(IPropertySymbol propertySymbol) + { + var rawArgumentsAttribute = propertySymbol.GetAttributes().FirstOrDefault(a => a.AttributeClass!.IsAttributeOf()); + if (rawArgumentsAttribute is null) + { + return null; + } + + return new RawArgumentPropertyGeneratingModel(propertySymbol) + { + }; + } +} diff --git a/src/DotNetCampus.CommandLine.Analyzer/Properties/Localizations.Designer.cs b/src/DotNetCampus.CommandLine.Analyzer/Properties/Localizations.Designer.cs index b0bdf876..ef7a4250 100644 --- a/src/DotNetCampus.CommandLine.Analyzer/Properties/Localizations.Designer.cs +++ b/src/DotNetCampus.CommandLine.Analyzer/Properties/Localizations.Designer.cs @@ -69,7 +69,7 @@ public static string DCL101 { } /// - /// Looks up a localized string similar to The command-line option/command definition names should be kebab-case, even though you can use any kind of style in the command line environment.. + /// Looks up a localized string similar to The option/command name should be kebab-case nomenclature to disambiguate. /// public static string DCL101_Description { get { @@ -87,7 +87,7 @@ public static string DCL101_Fix1 { } /// - /// Looks up a localized string similar to The option/command definition long name '{0}' should be kebab-case, even though you can use any kind of style in the command line environment.. + /// Looks up a localized string similar to The option/command name should be kebab-case nomenclature to disambiguate. /// public static string DCL101_Message { get { @@ -122,6 +122,33 @@ public static string DCL102_Message { } } + /// + /// Looks up a localized string similar to The option name is invalid. + /// + public static string DCL103 { + get { + return ResourceManager.GetString("DCL103", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The option name {0} must not be empty string, and not starts with '-', and not contains invalid characters.. + /// + public static string DCL103_Description { + get { + return ResourceManager.GetString("DCL103_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The option name {0} must not be empty string, and not starts with '-', and not contains invalid characters.. + /// + public static string DCL103_Message { + get { + return ResourceManager.GetString("DCL103_Message", resourceCulture); + } + } + /// /// Looks up a localized string similar to Recommended option property type. /// @@ -256,5 +283,59 @@ public static string DCL203_Message { return ResourceManager.GetString("DCL203_Message", resourceCulture); } } + + /// + /// Looks up a localized string similar to Duplicated option name. + /// + public static string DCL204 { + get { + return ResourceManager.GetString("DCL204", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Duplicated option name {0}. + /// + public static string DCL204_Description { + get { + return ResourceManager.GetString("DCL204_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Duplicated option name {0}. + /// + public static string DCL204_Message { + get { + return ResourceManager.GetString("DCL204_Message", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The command type must not be a generic type or be declared in a generic type. + /// + public static string DCL301 { + get { + return ResourceManager.GetString("DCL301", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The command type {0} must not be a generic type or be declared in a generic type.. + /// + public static string DCL301_Description { + get { + return ResourceManager.GetString("DCL301_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The command type {0} must not be a generic type or be declared in a generic type.. + /// + public static string DCL301_Message { + get { + return ResourceManager.GetString("DCL301_Message", resourceCulture); + } + } } } diff --git a/src/DotNetCampus.CommandLine.Analyzer/Properties/Localizations.resx b/src/DotNetCampus.CommandLine.Analyzer/Properties/Localizations.resx index c4794da6..c8741fe8 100644 --- a/src/DotNetCampus.CommandLine.Analyzer/Properties/Localizations.resx +++ b/src/DotNetCampus.CommandLine.Analyzer/Properties/Localizations.resx @@ -150,13 +150,13 @@ Not supported command-line property type - The command-line option/command definition names should be kebab-case, even though you can use any kind of style in the command line environment. + The option/command name should be kebab-case nomenclature to disambiguate Convert to kebab-case - The option/command definition long name '{0}' should be kebab-case, even though you can use any kind of style in the command line environment. + The option/command name should be kebab-case nomenclature to disambiguate Option/Command long name should be kebab-case @@ -188,4 +188,31 @@ The command-line option/command definition names may not be kebab-case. + + The command type must not be a generic type or be declared in a generic type + + + The command type {0} must not be a generic type or be declared in a generic type. + + + The command type {0} must not be a generic type or be declared in a generic type. + + + Duplicated option name + + + Duplicated option name {0} + + + Duplicated option name {0} + + + The option name is invalid + + + The option name {0} must not be empty string, and not starts with '-', and not contains invalid characters. + + + The option name {0} must not be empty string, and not starts with '-', and not contains invalid characters. + diff --git a/src/DotNetCampus.CommandLine.Analyzer/Properties/Localizations.zh-hans.resx b/src/DotNetCampus.CommandLine.Analyzer/Properties/Localizations.zh-hans.resx index e6d41df1..daa021e6 100644 --- a/src/DotNetCampus.CommandLine.Analyzer/Properties/Localizations.zh-hans.resx +++ b/src/DotNetCampus.CommandLine.Analyzer/Properties/Localizations.zh-hans.resx @@ -43,10 +43,10 @@ 不支持此类型的命令行属性 - 选项/命令名称 {0} 应该使用 kebab-case 命名法命名,即使真实使用命令行传参的时候你可以使用任意风格,但定义应该是 kebab-case 风格。 + 选项/命令名称 {0} 应使用 kebab-case 命名法以消除歧义 - 选项/命令名称的定义建议使用 kebab-case 命名法命名,即使真实使用命令行传参的时候你可以使用任意风格,但定义也应该是 kebab-case 风格。 + 选项/命令名称应使用 kebab-case 命名法以消除歧义 改成 kebab-case 命名法 @@ -81,4 +81,31 @@ 选项/命令名称的定义有可能不是 kebab-case 命名法命名。 + + 命令行类型不能是泛型类型或在泛型类型中 + + + 命令行类型 {0} 不能是泛型类型或在泛型类型中。 + + + 命令行类型 {0} 不能是泛型类型或在泛型类型中。 + + + 选项名出现了重复 + + + 选项名出现了重复 {0} + + + 选项名出现了重复 {0} + + + 选项名无效 + + + 选项名 {0} 不应为空字符串,不应有 '-' 前缀,不应包含不支持的字符 + + + 选项名 {0} 不应为空字符串,不应有 '-' 前缀,不应包含不支持的字符 + diff --git a/src/DotNetCampus.CommandLine/CommandLine.cs b/src/DotNetCampus.CommandLine/CommandLine.cs index bbfe71ca..da5e61d9 100644 --- a/src/DotNetCampus.CommandLine/CommandLine.cs +++ b/src/DotNetCampus.CommandLine/CommandLine.cs @@ -1,152 +1,60 @@ -using System.ComponentModel; +using System.ComponentModel; using System.Diagnostics.Contracts; -using System.Globalization; +using System.Runtime.CompilerServices; using DotNetCampus.Cli.Compiler; using DotNetCampus.Cli.Utils; -using DotNetCampus.Cli.Utils.Collections; namespace DotNetCampus.Cli; /// /// 为应用程序提供统一的命令行参数解析功能。 /// -public class CommandLine : ICoreCommandRunnerBuilder +public class CommandLine : ICommandRunnerBuilder { /// - /// 获取此命令行解析类型所关联的命令行参数。 - /// - public IReadOnlyList CommandLineArguments { get; } - - /// - /// 获取解析此命令行时所使用的各种选项。 + /// 存储与此命令行解析类型关联的命令行执行器。 /// - internal CommandLineParsingOptions ParsingOptions { get; } + private CommandRunner? _runner; /// - /// 在特定的属性不指定时,默认应使用的大小写敏感性。 + /// 存储特殊处理过 URL 的命令行参数。 /// - public bool DefaultCaseSensitive { get; } + private readonly IReadOnlyList? _urlNormalizedArguments; /// - /// 获取命令行参数中猜测的多级命令名称。 - /// 请注意,此字符串中可能包含空格,表示多级命令名称。也可能比预期的更长,包含后续的一部分位置参数,因为暂时还无法确定那些位置参数是否是命令名称。 + /// 获取此命令行解析类型所关联的命令行参数。 /// /// - /// - /// # 对于以下命令: - /// do something --option value - /// # 本属性的值为 "do something"。 - /// # 对于以下命令: - /// do something /var/file --option value - /// # 本属性的值为 "do something"(因为 /var/file 可以提前判断出来不可能是命令) - /// # 可能存在三种情况: - /// # 1. do 和 something 都是位置参数。 - /// # 2. do 是命令,something 是位置参数。 - /// # 3. do 和 something 都是命令。 - /// - /// 此属性保存这个 something 的值,待后续决定使用处理器时,根据处理器是否要求有命令来决定这个词是否是位置参数。
- /// 另外,**特别强调**,此属性的值可能是命名变体,例如命令行传入 DoSomething 时,此属性则是 Do-Something。 + /// 如果命令行参数中传入的是 URL,则此参数不会保存原始的 URL,而是将 URL 转换为普通的命令行参数列表。 ///
- internal string PossibleCommandNames { get; } + public IReadOnlyList CommandLineArguments => _urlNormalizedArguments ?? RawArguments; /// - /// 如果此命令行是从 Web 请求的 URL 中解析出来的,则此属性保存 URL 的 Scheme 部分。 - /// - private string? MatchedUrlScheme { get; } - - /// - /// 适用于选项的多值处理方式。 + /// 获取命令行传入的原始参数列表。 /// - private MultiValueHandling OptionMultiValueHandling { get; } + public IReadOnlyList RawArguments { get; } /// - /// 适用于位置参数的多值处理方式。 - /// - private MultiValueHandling PositionalArgumentsMultiValueHandling { get; } - - /// - /// 从命令行中解析出来的长名称选项。始终大小写敏感。 - /// - private OptionDictionary LongOptionValuesDefault { get; } - - /// - /// 从命令行中解析出来的长名称选项。始终大小写敏感。 - /// - private OptionDictionary LongOptionValuesCaseSensitive { get; } - - /// - /// 从命令行中解析出来的长名称选项。始终大小写不敏感。 - /// - private OptionDictionary LongOptionValuesIgnoreCase { get; } - - /// - /// 从命令行中解析出来的短名称选项。始终大小写敏感。 - /// - private OptionDictionary ShortOptionValuesDefault { get; } - - /// - /// 从命令行中解析出来的短名称选项。始终大小写敏感。 - /// - private OptionDictionary ShortOptionValuesCaseSensitive { get; } - - /// - /// 从命令行中解析出来的短名称选项。始终大小写不敏感。 + /// 获取解析此命令行时所使用的各种选项。 /// - private OptionDictionary ShortOptionValuesIgnoreCase { get; } + public CommandLineParsingOptions ParsingOptions { get; } /// - /// 从命令行中解析出来的位置参数。 + /// 如果此命令行是从 Web 请求的 URL 中解析出来的,则此属性保存 URL 的 Scheme 部分。 /// - /// - /// 注意,位置参数的前几个值可能是命令名称;这取决于 和实际处理器的命令。 - /// - /// # 对于以下命令: - /// do something --option value - /// # 可能存在三种情况: - /// # 1. do 和 something 都是位置参数。 - /// # 2. do 是命令,something 是位置参数。 - /// # 3. do 和 something 都是命令。 - /// - /// 如果处理器决定将 something 作为命令名称,那么当需要取出位置参数时,此属性的第一个值需要排除。 - /// - private ReadOnlyListRange PositionalArguments { get; } + internal string? MatchedUrlScheme { get; } private CommandLine() { - var options = OptionDictionary.Empty; - var arguments = new ReadOnlyListRange(); - CommandLineArguments = arguments; + RawArguments = []; ParsingOptions = CommandLineParsingOptions.Flexible; - DefaultCaseSensitive = false; - PossibleCommandNames = ""; - MatchedUrlScheme = null; - OptionMultiValueHandling = MultiValueHandling.First; - PositionalArgumentsMultiValueHandling = MultiValueHandling.First; - LongOptionValuesCaseSensitive = options; - LongOptionValuesIgnoreCase = options; - LongOptionValuesDefault = options; - ShortOptionValuesCaseSensitive = options; - ShortOptionValuesIgnoreCase = options; - ShortOptionValuesDefault = options; - PositionalArguments = arguments; } private CommandLine(IReadOnlyList arguments, CommandLineParsingOptions? parsingOptions = null) { - CommandLineArguments = arguments; + RawArguments = arguments; ParsingOptions = parsingOptions ?? CommandLineParsingOptions.Flexible; - DefaultCaseSensitive = parsingOptions?.CaseSensitive ?? false; - (MatchedUrlScheme, var result) = CommandLineConverter.ParseCommandLineArguments(arguments, parsingOptions); - PossibleCommandNames = result.PossibleCommandNames; - OptionMultiValueHandling = MatchedUrlScheme is null ? MultiValueHandling.First : MultiValueHandling.Last; - PositionalArgumentsMultiValueHandling = MatchedUrlScheme is null ? MultiValueHandling.SpaceAll : MultiValueHandling.SlashAll; - LongOptionValuesCaseSensitive = result.LongOptions.ToOptionLookup(true); - LongOptionValuesIgnoreCase = result.LongOptions.ToOptionLookup(false); - LongOptionValuesDefault = DefaultCaseSensitive ? LongOptionValuesCaseSensitive : LongOptionValuesIgnoreCase; - ShortOptionValuesCaseSensitive = result.ShortOptions.ToOptionLookup(true); - ShortOptionValuesIgnoreCase = result.ShortOptions.ToOptionLookup(false); - ShortOptionValuesDefault = DefaultCaseSensitive ? ShortOptionValuesCaseSensitive : ShortOptionValuesIgnoreCase; - PositionalArguments = result.Arguments; + (MatchedUrlScheme, _urlNormalizedArguments) = CommandLineConverter.TryNormalizeUrlArguments(arguments, ParsingOptions); } /// @@ -180,218 +88,86 @@ public static CommandLine Parse(IReadOnlyList args, CommandLineParsingOp [Pure] public static CommandLine Parse(string singleLineCommandLineArgs, CommandLineParsingOptions? parsingOptions = null) { - var args = CommandLineConverter.SingleLineCommandLineArgsToArrayCommandLineArgs(singleLineCommandLineArgs); + var args = CommandLineConverter.SingleLineToList(singleLineCommandLineArgs); return new CommandLine(args, parsingOptions); } - CommandRunner ICoreCommandRunnerBuilder.GetOrCreateRunner() => new(this); - /// /// 尝试将命令行参数转换为指定类型的实例。 /// /// 要转换的类型。 /// 转换后的实例。 [Pure] - public T As() where T : class => CommandRunner.CreateInstance(this); +#pragma warning disable CA1822 + public T As() where T : notnull => throw MethodShouldBeInspected(); +#pragma warning restore CA1822 /// /// 尝试将命令行参数转换为指定类型的实例。 /// - /// 由拦截器传入的命令处理器创建方法。 + /// 由拦截器传入的命令处理器创建方法。 /// 要转换的类型。 /// 转换后的实例。 [Pure, EditorBrowsable(EditorBrowsableState.Never)] - public T As(CommandObjectCreator creator) where T : class => CommandRunner.CreateInstance(this, creator); - - /// - /// 获取命令行参数中指定短名称的选项的值。 - /// - /// 短名称选项。 - /// 返回选项的值。当命令行未传入此参数时返回 - [Pure] - public CommandLinePropertyValue? GetOption(char shortOption) => GetShortOption(shortOption.ToString(CultureInfo.InvariantCulture)); - - /// - /// 获取命令行参数中指定短名称的选项的值。 - /// - /// 短名称选项。 - /// 返回选项的值。当命令行未传入此参数时返回 - [Pure] - public CommandLinePropertyValue? GetShortOption(string shortOption) + public T As(CommandObjectFactory factory) where T : notnull { - return ShortOptionValuesDefault.TryGetValue(shortOption, out var defaultValues) - ? new CommandLinePropertyValue(defaultValues, OptionMultiValueHandling) - : null; + return (T)factory(new CommandRunningContext { CommandLine = this }); } /// - /// 获取命令行参数中指定名称的选项的值。 + /// 输出传入的命令行参数字符串。如果命令行参数中传入的是 URL,此方法会将 URL 转换为普通的命令行参数再输出。 /// - /// 选项的名称。 - /// 返回选项的值。当命令行未传入此参数时返回 - [Pure] - public CommandLinePropertyValue? GetOption(string optionName) - { - return LongOptionValuesDefault.TryGetValue(optionName, out var defaultValues) - ? new CommandLinePropertyValue(defaultValues, OptionMultiValueHandling) - : null; - } - - /// - /// 获取命令行参数中指定名称的选项的值。 - /// - /// 短名称选项。 - /// 选项的名称。 - /// 返回选项的值。当命令行未传入此参数时返回 - [Pure] - public CommandLinePropertyValue? GetOption(char shortName, string longName) => - // 优先使用短名称(因为长名称可能是根据属性名猜出来的)。 - GetOption(shortName) - // 其次使用长名称。 - ?? GetOption(longName); - - /// - /// 获取命令行参数中指定名称的选项的值。 - /// - /// 选项的名称。 - /// 单独为此选项设置的大小写敏感性。 - /// 返回选项的值。当命令行未传入此参数时返回 - [Pure] - public CommandLinePropertyValue? GetOption(char optionName, bool caseSensitive) => - GetShortOption(optionName.ToString(CultureInfo.InvariantCulture), caseSensitive); - - /// - /// 获取命令行参数中指定短名称的选项的值。 - /// - /// 短名称选项。 - /// 单独为此选项设置的大小写敏感性。 - /// 返回选项的值。当命令行未传入此参数时返回 + /// 传入的命令行参数字符串。 [Pure] - public CommandLinePropertyValue? GetShortOption(string shortOption, bool caseSensitive) + public override string ToString() { - var optionValues = caseSensitive - ? ShortOptionValuesCaseSensitive - : ShortOptionValuesIgnoreCase; - return optionValues.TryGetValue(shortOption, out var defaultValues) - ? new CommandLinePropertyValue(defaultValues, OptionMultiValueHandling) - : null; + return string.Join(" ", CommandLineArguments.Select(x => x.Contains(' ') ? $"\"{x}\"" : x)); } /// - /// 获取命令行参数中指定名称的选项的值。 + /// 输出原始版本的传入的命令行参数字符串。如果命令行参数中传入的是 URL,此方法会原样输出 URL。 /// - /// 选项的名称。 - /// 单独为此选项设置的大小写敏感性。 - /// 返回选项的值。当命令行未传入此参数时返回 + /// 原始传入的命令行参数字符串。 [Pure] - public CommandLinePropertyValue? GetOption(string optionName, bool caseSensitive) + public string ToRawString() { - var optionValues = caseSensitive - ? LongOptionValuesCaseSensitive - : LongOptionValuesIgnoreCase; - return optionValues.TryGetValue(optionName, out var defaultValues) - ? new CommandLinePropertyValue(defaultValues, OptionMultiValueHandling) - : null; + return string.Join(" ", RawArguments.Select(x => x.Contains(' ') ? $"\"{x}\"" : x)); } /// - /// 获取命令行参数中指定名称的选项的值。 - /// - /// 短名称选项。 - /// 选项的名称。 - /// 单独为此选项设置的大小写敏感性。 - /// 返回选项的值。当命令行未传入此参数时返回 - [Pure] - public CommandLinePropertyValue? GetOption(char shortName, string longName, bool caseSensitive) => - // 优先使用短名称(因为长名称可能是根据属性名猜出来的)。 - GetOption(shortName, caseSensitive) - // 其次使用长名称。 - ?? GetOption(longName, caseSensitive); - - /// - /// 获取命令行参数中位置参数的值。 + /// 将当前命令行对象视作一个命令行执行器,以支持根据命令自动选择命令处理器运行。
+ /// 随后后,可通过 AddHandler 方法添加多个命令处理器。 ///
- /// 获取指定索引处的参数值。 - /// 从索引处获取参数值的最长长度。当大于 1 时,会将这些值合并为一个字符串。 - /// 因为子命令会影响到位置参数的序号,所以如果存在命令和子命令,则需要传入所有多级命令共同组成的字符串。 - /// 位置参数的值。 - [Pure] - public CommandLinePropertyValue? GetPositionalArgument(int index, int length, string? commandNames = null) - { - var commandLevel = GetCommandLevel(commandNames); - var positionalArgumentsStartIndex = Math.Max(0, commandLevel); - positionalArgumentsStartIndex = Math.Min(positionalArgumentsStartIndex, commandLevel); - var realIndex = index + positionalArgumentsStartIndex; - return realIndex < 0 || realIndex >= PositionalArguments.Count - ? null - : new CommandLinePropertyValue( - PositionalArguments.Slice(realIndex, - Math.Min(length, PositionalArguments.Count - realIndex)), PositionalArgumentsMultiValueHandling); - } + /// 命令行执行器。 + /// + /// 与 方法不同,本方法每次都会返回同一个命令行执行器实例。
+ /// 如果多次调用本方法,后续对命令行执行器的修改会影响之前获得的命令行执行器。 + ///
+ public ICommandRunnerBuilder AsRunner() => _runner ??= new CommandRunner(this); /// - /// 获取命令行参数中所有位置参数值的集合。 + /// 创建一个命令行执行器,以支持根据命令自动选择命令处理器运行。
+ /// 创建后,可通过 AddHandler 方法添加多个命令处理器。 ///
- /// 因为子命令会影响到位置参数的序号,所以如果存在命令和子命令,则需要传入所有多级命令共同组成的字符串。 - /// 命令行参数中位置参数值的集合。 - [Pure] - public IReadOnlyList GetPositionalArguments(string? commandNames = null) - { - var commandLevel = GetCommandLevel(commandNames); - var positionalArgumentsStartIndex = Math.Max(0, commandLevel); - positionalArgumentsStartIndex = Math.Min(positionalArgumentsStartIndex, commandLevel); - return PositionalArguments.Slice(positionalArgumentsStartIndex, PositionalArguments.Count - 1); - } + /// 命令行执行器。 + /// + /// 与 方法不同,本方法每次都会返回一个新的命令行执行器实例。
+ /// 如果多次调用本方法,后续对命令行执行器的修改不会影响之前获得的命令行执行器。 + ///
+ public ICommandRunnerBuilder ToRunner() => new CommandRunner(this); - /// - /// 根据某个特定的命令名称字符串,获取此字符串中包含了多少级命令。 - /// - /// 命令名称字符串。 - /// 命令的层级数。 - private int GetCommandLevel(string? commandNames) - { - var possibleCommandNames = PossibleCommandNames; + /// + CommandRunner ICoreCommandRunnerBuilder.AsRunner() => _runner ??= new CommandRunner(this); - // 如果没有命令,则不需要排除任何位置参数。 - if (string.IsNullOrEmpty(commandNames) || string.IsNullOrEmpty(possibleCommandNames)) - { - return 0; - } -#if !NETCOREAPP3_1_OR_GREATER - if (commandNames is null) - { - return 0; - } -#endif - if (commandNames.Length > possibleCommandNames.Length) - { - return 0; - } - if (!possibleCommandNames.StartsWith(commandNames, DefaultCaseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase)) - { - return 0; - } - // 计算 possibleCommandNames 中有多少个空格。 - var commandLevel = 1; - for (var i = 0; i < commandNames.Length; i++) - { - if (possibleCommandNames[i] == ' ') - { - commandLevel++; - } - } - return commandLevel; - } + /// + CommandRunningResult ICommandRunnerBuilder.Run() => AsRunner().Run(); /// - /// 输出传入的命令行参数字符串。 + /// 当某个方法本应该被源生成器拦截时,却仍然被调用了,就调用此方法抛出异常。 /// - /// 传入的命令行参数字符串。 - [Pure] - public override string ToString() + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static InvalidOperationException MethodShouldBeInspected() { - return MatchedUrlScheme is { } scheme - ? $"{scheme}://{string.Join("/", PositionalArguments)}?{string.Join("&", LongOptionValuesCaseSensitive.Select(x => $"{x.Key}={string.Join("&", x.Value)}"))}" - : string.Join(" ", CommandLineArguments.Select(x => x.Contains(' ') ? $"\"{x}\"" : x)); + return new InvalidOperationException("源生成器本应该在编译时拦截了此方法的调用。请检查编译警告,查看 DotNetCampus.CommandLine 的源生成器是否正常工作。"); } } diff --git a/src/DotNetCampus.CommandLine/CommandLineExceptionHandler.cs b/src/DotNetCampus.CommandLine/CommandLineExceptionHandler.cs new file mode 100644 index 00000000..8b454f1a --- /dev/null +++ b/src/DotNetCampus.CommandLine/CommandLineExceptionHandler.cs @@ -0,0 +1,39 @@ +using DotNetCampus.Cli.Utils.Parsers; + +namespace DotNetCampus.Cli; + +internal class CommandLineExceptionHandler(CommandLine commandLine, bool ignoreAllExceptions) : ICommandHandler +{ + public CommandLineParsingResult ErrorResult { get; set; } + + public Task RunAsync() + { + if (!ignoreAllExceptions) + { + ErrorResult.ThrowIfError(); + } + else + { + Console.WriteLine(commandLine); + } + return Task.FromResult(-1); + } +} + +/// +/// 辅助创建命令行异常处理器。 +/// +public static class CommandLineExceptionHandlerExtensions +{ + /// + /// 添加一个命令处理器。 + /// + /// 命令行执行器构造的链式调用。 + /// 是否忽略所有异常。 + /// 命令行执行器构造的链式调用。 + [Obsolete("此方法的实现正在讨论中,API 可能不稳定,请谨慎使用。")] + public static IAsyncCommandRunnerBuilder HandleException(this ICoreCommandRunnerBuilder builder, bool ignoreAllExceptions) + { + return builder.AsRunner().AddFallbackHandler(c => new CommandLineExceptionHandler(c.CommandLine, ignoreAllExceptions)); + } +} diff --git a/src/DotNetCampus.CommandLine/CommandLineParsingOptions.cs b/src/DotNetCampus.CommandLine/CommandLineParsingOptions.cs index e3752d34..251e93f8 100644 --- a/src/DotNetCampus.CommandLine/CommandLineParsingOptions.cs +++ b/src/DotNetCampus.CommandLine/CommandLineParsingOptions.cs @@ -3,58 +3,36 @@ namespace DotNetCampus.Cli; /// /// 在解析命令行参数时,指定命令行参数的解析方式。 /// -public readonly record struct CommandLineParsingOptions() +public readonly record struct CommandLineParsingOptions { /// - public static CommandLineParsingOptions Flexible => new CommandLineParsingOptions - { - Style = CommandLineStyle.Flexible, - CaseSensitive = false, - }; + public static CommandLineParsingOptions Flexible => new() { Style = CommandLineStyle.Flexible }; + + /// + public static CommandLineParsingOptions DotNet => new() { Style = CommandLineStyle.DotNet }; /// - public static CommandLineParsingOptions Gnu => new CommandLineParsingOptions - { - Style = CommandLineStyle.Gnu, - CaseSensitive = true, - }; + public static CommandLineParsingOptions Gnu => new() { Style = CommandLineStyle.Gnu }; /// - public static CommandLineParsingOptions Posix => new CommandLineParsingOptions - { - Style = CommandLineStyle.Posix, - CaseSensitive = true, - }; + public static CommandLineParsingOptions Posix => new() { Style = CommandLineStyle.Posix }; - /// - public static CommandLineParsingOptions DotNet => new CommandLineParsingOptions - { - Style = CommandLineStyle.DotNet, - CaseSensitive = false, - }; + /// + public static CommandLineParsingOptions Windows => new() { Style = CommandLineStyle.Windows }; - /// - public static CommandLineParsingOptions PowerShell => new CommandLineParsingOptions - { - Style = CommandLineStyle.PowerShell, - CaseSensitive = false, - }; + /// + [Obsolete("为避免理解歧义,已弃用此名称,请使用 Windows 代替。")] + public static CommandLineParsingOptions PowerShell => Windows; /// - /// 以此风格解析命令行参数。 + /// 详细设置命令行解析时的各种细节。 /// - /// - /// 不指定时会自动根据用户输入的命令行参数判断风格。 - /// public CommandLineStyle Style { get; init; } /// - /// 默认是大小写不敏感的,设置此值为 可以让命令行参数大小写敏感。 + /// 指定在解析命令行参数时,遇到无法识别的参数时的处理方式。 /// - /// - /// 当然,可以在单独的属性上设置大小写敏感,设置后将在那个属性上覆盖此默认值。不设置的属性会使用此默认值。 - /// - public bool CaseSensitive { get; init; } + public UnknownCommandArgumentHandling UnknownArgumentsHandling { get; init; } /// /// 此命令行解析器支持从 Web 打开本地应用时传入的参数。
@@ -66,41 +44,43 @@ public readonly record struct CommandLineParsingOptions() /// 这里的 "sample" 就是方案名。
/// 当解析命令行参数时,如果只传入了一个参数,且参数开头满足 sample:// 格式时,则会认为方案名匹配,将进行后续 url 的参数解析。设置此属性后,无论选择哪种命令行风格(),都会优先识别并解析URL格式的参数。 /// - /// /// - /// URL风格命令行参数模拟Web请求中的查询字符串格式,适用于习惯于Web开发的用户,以及需要通过URL协议方案(URL Scheme)启动的应用程序。
- ///
- /// 详细规则:
- /// 1. 完整格式为 [scheme://][path][?option1=value1&option2=value2]
- /// 2. 参数部分以问号(?)开始,后面是键值对
- /// 3. 多个参数之间用(&)符号分隔
- /// 4. 每个参数的键值之间用等号(=)分隔
- /// 5. 支持URL编码规则,如空格编码为%20,特殊字符需编码
- /// 6. 支持数组格式参数,如tags=tag1&tags=tag2表示tags参数有多个值
- /// 7. 支持无值参数,被视为布尔值true,如?enabled
- /// 8. 参数值为空字符串时保留等号,如?name=
- /// 9. 路径部分(path)一般情况下会被视为位置参数,例如 myapp://documents/open 中,documents/open 被视为位置参数
- /// 10. 但在某些情况下,路径的前几个部分可能会被当作命令(含子命令),例如 myapp://open/file.txt 中,open 可能是命令,file.txt 是位置参数。具体解释为位置参数还是命令取决于应用的命令行处理器实现
- /// 11. 整个URL可以用引号包围,以避免特殊字符被shell解释
+ /// + /// + /// 完整格式为 [scheme://][path1/path2][?option1=value1&option2=value2] + /// 整个解析过程不区分大小写 + /// scheme 为方案名,根据传入的命令行命名法进行匹配 + /// path1, path2 等路径会被视为命令和位置参数,具体是命令还是位置参数,跟普通命令行一样,优先匹配命令,剩下的全是位置参数 + /// option1, option2 等参数会被视为选项,只支持长选项;选项名根据传入的命令行命名法进行匹配 + /// 提取命令、位置参数、选项名和值时,会根据 URL 编码规则进行解码 + /// 支持布尔选项(无值选项),视为 true + /// /// - /// - /// # 完整URL格式(通常由Web浏览器或其他应用程序传递) - /// myapp://open?url=https://example.com # 包含方案(scheme)、路径和参数 - /// myapp://user/profile?id=123&tab=info # 带层级路径 - /// sample://document/edit?id=42&mode=full # 多参数和路径组合 - /// - /// # 特殊字符与编码 - /// yourapp://search?q=hello%20world # 编码空格 - /// myapp://open?query=C%23%20programming # 特殊字符编码 - /// appname://tags?value=c%23&value=.net # 数组参数(相同参数名多次出现) - /// - /// # 无值和空值参数 - /// myapp://settings?debug # 无值参数(视为true) - /// yourapp://profile?name=&id=123 # 空字符串值 - /// - /// # 路径与命令示例 - /// myapp://documents/open?readonly=true # documents 和 open 作为位置参数 - /// myapp://open/file.txt?temporary=true # open 是命令,file.txt 是位置参数;或 open 和 file.txt 都是位置参数 - /// /// - public IReadOnlyList SchemeNames { get; init; } = []; + public IReadOnlyList? SchemeNames { get; init; } +} + +/// +/// 指定在解析命令行参数时,遇到无法识别的参数时的处理方式。 +/// +public enum UnknownCommandArgumentHandling : byte +{ + /// + /// 所有参数都必须被识别,否则进入到回退处理逻辑。当然,就算在回退处理逻辑里面也可以继续忽略未识别的参数。 + /// + AllArgumentsMustBeRecognized, + + /// + /// 忽略未识别的选项。 + /// + IgnoreUnknownOptionalArguments, + + /// + /// 忽略未识别的位置参数。 + /// + IgnoreUnknownPositionalArguments, + + /// + /// 忽略所有未识别的参数。 + /// + IgnoreAllUnknownArguments, } diff --git a/src/DotNetCampus.CommandLine/CommandLinePropertyValue.cs b/src/DotNetCampus.CommandLine/CommandLinePropertyValue.cs deleted file mode 100644 index 4f72b011..00000000 --- a/src/DotNetCampus.CommandLine/CommandLinePropertyValue.cs +++ /dev/null @@ -1,616 +0,0 @@ -using System.Collections; -using System.Collections.ObjectModel; -using DotNetCampus.Cli.Exceptions; -#if NETCOREAPP3_1_OR_GREATER -using System.Collections.Immutable; -#endif - -namespace DotNetCampus.Cli; - -/// -/// 包含从命令行解析出来的属性值,可供转换为各种常见类型。 -/// -public readonly struct CommandLinePropertyValue : IReadOnlyList -{ - private readonly IReadOnlyList? _values; - private readonly MultiValueHandling _multiValueHandling; - - internal CommandLinePropertyValue(IReadOnlyList values, MultiValueHandling multiValueHandling) - { - _values = values; - _multiValueHandling = multiValueHandling; - } - - IEnumerator IEnumerable.GetEnumerator() => (_values ?? []).GetEnumerator(); - IEnumerator IEnumerable.GetEnumerator() => (_values ?? []).GetEnumerator(); - int IReadOnlyCollection.Count => _values?.Count ?? 0; - string IReadOnlyList.this[int index] => _values?[index] ?? throw new IndexOutOfRangeException(); - - /// - /// 将从命令行解析出来的属性值转换为 。 - /// - public static implicit operator bool(CommandLinePropertyValue propertyValue) - { - return propertyValue._values switch - { - // 没传选项时,相当于传了 false。 - null => false, - // 传了选项时,相当于传了 true。 - { Count: 0 } => true, - // 传了选项,后面还带了参数时,取第一个参数的值作为 true/false。 - { } values => ParseBoolean(values[0]) ?? throw new CommandLineParseValueException( - $"Value [{values[0]}] is not a valid boolean value. Available values are: 1, true, yes, on, 0, false, no, off."), - }; - - static bool? ParseBoolean(string value) - { - var isTrue = value.Equals("1", StringComparison.OrdinalIgnoreCase) || - value.Equals("true", StringComparison.OrdinalIgnoreCase) || - value.Equals("yes", StringComparison.OrdinalIgnoreCase) || - value.Equals("on", StringComparison.OrdinalIgnoreCase); - if (isTrue) - { - return true; - } - var isFalse = value.Equals("0", StringComparison.OrdinalIgnoreCase) || - value.Equals("false", StringComparison.OrdinalIgnoreCase) || - value.Equals("no", StringComparison.OrdinalIgnoreCase) || - value.Equals("off", StringComparison.OrdinalIgnoreCase); - if (isFalse) - { - return false; - } - return null; - } - } - - /// - /// 将从命令行解析出来的属性值转换为 。 - /// - public static implicit operator byte(CommandLinePropertyValue propertyValue) => propertyValue._values switch - { - null or { Count: 0 } => default, - { } values => byte.TryParse(values[0], out var result) - ? result - : throw new CommandLineParseValueException( - $"Value [{values[0]}] is not a valid byte value."), - }; - - /// - /// 将从命令行解析出来的属性值转换为 。 - /// - public static implicit operator sbyte(CommandLinePropertyValue propertyValue) => propertyValue._values switch - { - null or { Count: 0 } => default, - { } values => sbyte.TryParse(values[0], out var result) - ? result - : throw new CommandLineParseValueException( - $"Value [{values[0]}] is not a valid sbyte value."), - }; - - /// - /// 将从命令行解析出来的属性值转换为 。 - /// - public static implicit operator char(CommandLinePropertyValue propertyValue) => propertyValue._values switch - { - null or { Count: 0 } => default, - { } values => char.TryParse(values[0], out var result) ? result : '\0', - }; - - /// - /// 将从命令行解析出来的属性值转换为 。 - /// - public static implicit operator decimal(CommandLinePropertyValue propertyValue) => propertyValue._values switch - { - null or { Count: 0 } => default, - { } values => decimal.TryParse(values[0], out var result) - ? result - : throw new CommandLineParseValueException( - $"Value [{values[0]}] is not a valid decimal value."), - }; - - /// - /// 将从命令行解析出来的属性值转换为 。 - /// - public static implicit operator double(CommandLinePropertyValue propertyValue) => propertyValue._values switch - { - null or { Count: 0 } => default, - { } values => double.TryParse(values[0], out var result) - ? result - : throw new CommandLineParseValueException( - $"Value [{values[0]}] is not a valid double value."), - }; - - /// - /// 将从命令行解析出来的属性值转换为 。 - /// - public static implicit operator float(CommandLinePropertyValue propertyValue) => propertyValue._values switch - { - null or { Count: 0 } => default, - { } values => float.TryParse(values[0], out var result) - ? result - : throw new CommandLineParseValueException( - $"Value [{values[0]}] is not a valid float value."), - }; - - /// - /// 将从命令行解析出来的属性值转换为 。 - /// - public static implicit operator int(CommandLinePropertyValue propertyValue) => propertyValue._values switch - { - null or { Count: 0 } => default, - { } values => int.TryParse(values[0], out var result) - ? result - : throw new CommandLineParseValueException( - $"Value [{values[0]}] is not a valid int value."), - }; - - /// - /// 将从命令行解析出来的属性值转换为 。 - /// - public static implicit operator uint(CommandLinePropertyValue propertyValue) => propertyValue._values switch - { - null or { Count: 0 } => default, - { } values => uint.TryParse(values[0], out var result) - ? result - : throw new CommandLineParseValueException( - $"Value [{values[0]}] is not a valid uint value."), - }; - -#if NET5_0_OR_GREATER - /// - /// 将从命令行解析出来的属性值转换为 。 - /// - public static implicit operator nint(CommandLinePropertyValue propertyValue) => propertyValue._values switch - { - null or { Count: 0 } => default, - { } values => nint.TryParse(values[0], out var result) - ? result - : throw new CommandLineParseValueException( - $"Value [{values[0]}] is not a valid nint value."), - }; -#endif - -#if NET5_0_OR_GREATER - /// - /// 将从命令行解析出来的属性值转换为 。 - /// - public static implicit operator nuint(CommandLinePropertyValue propertyValue) => propertyValue._values switch - { - null or { Count: 0 } => default, - { } values => nuint.TryParse(values[0], out var result) - ? result - : throw new CommandLineParseValueException( - $"Value [{values[0]}] is not a valid unint value."), - }; -#endif - - /// - /// 将从命令行解析出来的属性值转换为 。 - /// - public static implicit operator long(CommandLinePropertyValue propertyValue) => propertyValue._values switch - { - null or { Count: 0 } => default, - { } values => long.TryParse(values[0], out var result) - ? result - : throw new CommandLineParseValueException( - $"Value [{values[0]}] is not a valid long value."), - }; - - /// - /// 将从命令行解析出来的属性值转换为 。 - /// - public static implicit operator ulong(CommandLinePropertyValue propertyValue) => propertyValue._values switch - { - null or { Count: 0 } => default, - { } values => ulong.TryParse(values[0], out var result) - ? result - : throw new CommandLineParseValueException( - $"Value [{values[0]}] is not a valid ulong value."), - }; - - /// - /// 将从命令行解析出来的属性值转换为 。 - /// - public static implicit operator short(CommandLinePropertyValue propertyValue) => propertyValue._values switch - { - null or { Count: 0 } => default, - { } values => short.TryParse(values[0], out var result) - ? result - : throw new CommandLineParseValueException( - $"Value [{values[0]}] is not a valid short value."), - }; - - /// - /// 将从命令行解析出来的属性值转换为 。 - /// - public static implicit operator ushort(CommandLinePropertyValue propertyValue) => propertyValue._values switch - { - null or { Count: 0 } => default, - { } values => ushort.TryParse(values[0], out var result) - ? result - : throw new CommandLineParseValueException( - $"Value [{values[0]}] is not a valid ushort value."), - }; - - /// - /// 将从命令行解析出来的属性值转换为 。 - /// - public static implicit operator string(CommandLinePropertyValue propertyValue) => propertyValue._values switch - { - null or { Count: 0 } => "", - { } values => propertyValue._multiValueHandling switch - { - MultiValueHandling.First => values[0], - MultiValueHandling.Last => values[^1], -#if NETCOREAPP3_1_OR_GREATER - MultiValueHandling.SpaceAll => string.Join(' ', values), - MultiValueHandling.SlashAll => string.Join('/', values), -#else - MultiValueHandling.SpaceAll => string.Join(" ", values), - MultiValueHandling.SlashAll => string.Join("/", values), -#endif - _ => values[0], - }, - }; - - /// - /// 将从命令行解析出来的属性值转换为字符串数组。 - /// - public static implicit operator string[](CommandLinePropertyValue propertyValue) => propertyValue._values switch - { - null or { Count: 0 } => [], - { } values => [..SplitValues(values)], - }; - -#if NETCOREAPP3_1_OR_GREATER - /// - /// 将从命令行解析出来的属性值转换为不可变字符串数组。 - /// - public static implicit operator ImmutableArray(CommandLinePropertyValue propertyValue) => propertyValue._values switch - { -#if NET8_0_OR_GREATER - null or { Count: 0 } => [], - { } values => [..SplitValues(values)], -#else - null or { Count: 0 } => ImmutableArray.Empty, - { } values => SplitValues(values).ToImmutableArray(), -#endif - }; -#endif - -#if NETCOREAPP3_1_OR_GREATER - /// - /// 将从命令行解析出来的属性值转换为不可变字符串哈希集合。 - /// - public static implicit operator ImmutableHashSet(CommandLinePropertyValue propertyValue) => propertyValue._values switch - { -#if NET8_0_OR_GREATER - null or { Count: 0 } => [], - { } values => [..SplitValues(values)], -#else - null or { Count: 0 } => ImmutableHashSet.Empty, - { } values => SplitValues(values).ToImmutableHashSet(), -#endif - }; -#endif - - /// - /// 将从命令行解析出来的属性值转换为字符串集合。 - /// - public static implicit operator Collection(CommandLinePropertyValue propertyValue) => propertyValue._values switch - { - null or { Count: 0 } => [], - { } values => [..SplitValues(values)], - }; - - /// - /// 将从命令行解析出来的属性值转换为字符串列表。 - /// - public static implicit operator List(CommandLinePropertyValue propertyValue) => propertyValue.ToList(); - - /// - /// 将从命令行解析出来的属性值转换为字符串键值对。 - /// - public static implicit operator KeyValuePair(CommandLinePropertyValue propertyValue) => propertyValue.ToDictionary().FirstOrDefault(); - - /// - /// 将从命令行解析出来的属性值转换为字符串字典。 - /// - public static implicit operator Dictionary(CommandLinePropertyValue propertyValue) => propertyValue.ToDictionary(); - - /// - /// 将从命令行解析出来的属性值转换为枚举值。 - /// - public T ToEnum() where T : unmanaged, Enum => _values switch - { - null or { Count: 0 } => default, - { } values => Enum.TryParse(values[0], true, out var result) ? result : default!, - }; - - /// - /// 将从命令行解析出来的属性值转换为字符串列表。 - /// - public List ToList() => _values switch - { - null or { Count: 0 } => [], - { } values => [..SplitValues(values)], - }; - - /// - /// 将从命令行解析出来的属性值转换为字符串字典。 - /// - public Dictionary ToDictionary() => _values switch - { - null or { Count: 0 } => new Dictionary(), - { } values => values - .SelectMany(x => x.Split( -#if NETCOREAPP3_1_OR_GREATER - ';' -#else - [";"] -#endif - , StringSplitOptions.RemoveEmptyEntries)) - .Select(x => - { - var parts = x.Split('='); - if (parts.Length is not 2) - { - throw new CommandLineParseValueException( - $"Value [{x}] is not a valid dictionary. Expected format is key1=value1;key2=value2."); - } - return new KeyValuePair(parts[0], parts[1]); - }) - .GroupBy(x => x.Key) - .ToDictionary(x => x.Key, x => x.Last().Value), - }; - - private static IEnumerable SplitValues(IReadOnlyList commandLineValues) - { - for (var commandLineValueIndex = 0; commandLineValueIndex < commandLineValues.Count; commandLineValueIndex++) - { - var optionValue = commandLineValues[commandLineValueIndex]; - var lastPart = ListValueParsingType.Start; - var thisPartStartIndex = 0; - for (var index = 0; index < optionValue.Length; index++) - { - var c = optionValue[index]; - - // 引号 - if (c is '"') - { - if (lastPart is ListValueParsingType.Start) - { - // 开始的引号 - lastPart = ListValueParsingType.QuoteStart; - continue; - } - if (lastPart is ListValueParsingType.QuoteStart) - { - // 连续出现的引号 - yield return ""; - lastPart = ListValueParsingType.QuoteEnd; - continue; - } - if (lastPart is ListValueParsingType.QuotedValue) - { - // 引号中值后的引号 - yield return optionValue[thisPartStartIndex..index]; - lastPart = ListValueParsingType.QuoteEnd; - continue; - } - if (lastPart is ListValueParsingType.QuotedSeparator) - { - // 引号中分割符后的引号 - yield return ""; - lastPart = ListValueParsingType.QuotedValue; - continue; - } - if (lastPart is ListValueParsingType.QuoteEnd) - { - // 引号结束后的引号 - lastPart = ListValueParsingType.QuoteStart; - continue; - } - if (lastPart is ListValueParsingType.Value) - { - // 正常值后的引号 - throw new CommandLineParseValueException( - $"Invalid value format at index [{index}]: {optionValue}"); - } - if (lastPart is ListValueParsingType.Separator) - { - // 正常分隔符后的引号 - lastPart = ListValueParsingType.QuoteStart; - continue; - } - } - - // 分割符 - if (c is ';' or ',') - { - if (lastPart is ListValueParsingType.Start) - { - // 开始的分割符 - yield return ""; - lastPart = ListValueParsingType.Separator; - continue; - } - if (lastPart is ListValueParsingType.QuoteStart) - { - // 引号后紧跟着的分割符(等同于正常字符) - lastPart = ListValueParsingType.QuotedValue; - continue; - } - if (lastPart is ListValueParsingType.QuotedValue) - { - // 引号中值后的分割符(等同于正常字符) - lastPart = ListValueParsingType.QuotedValue; - continue; - } - if (lastPart is ListValueParsingType.QuotedSeparator) - { - // 引号中连续出现的分割符(等同于正常字符) - lastPart = ListValueParsingType.QuotedValue; - continue; - } - if (lastPart is ListValueParsingType.QuoteEnd) - { - // 引号结束后的分割符 - lastPart = ListValueParsingType.Separator; - continue; - } - if (lastPart is ListValueParsingType.Value) - { - // 正常值后的分割符 - yield return optionValue[thisPartStartIndex..index]; - lastPart = ListValueParsingType.Separator; - continue; - } - if (lastPart is ListValueParsingType.Separator) - { - // 连续出现的分割符 - yield return ""; - lastPart = ListValueParsingType.Separator; - continue; - } - } - - // 其他字符 - if (lastPart is ListValueParsingType.Start) - { - // 开始的值 - thisPartStartIndex = index; - lastPart = ListValueParsingType.Value; - continue; - } - if (lastPart is ListValueParsingType.QuoteStart) - { - // 引号后紧跟着的值 - thisPartStartIndex = index; - lastPart = ListValueParsingType.QuotedValue; - continue; - } - if (lastPart is ListValueParsingType.QuotedValue) - { - // 引号中值后的值 - lastPart = ListValueParsingType.QuotedValue; - continue; - } - if (lastPart is ListValueParsingType.QuotedSeparator) - { - // 引号中分割符(实际上就是正常值)后的值 - lastPart = ListValueParsingType.QuotedValue; - continue; - } - if (lastPart is ListValueParsingType.QuoteEnd) - { - // 引号结束后的值 - throw new CommandLineParseValueException( - $"Invalid value format at index [{index}]: {optionValue}"); - } - if (lastPart is ListValueParsingType.Value) - { - // 正常值后的值 - lastPart = ListValueParsingType.Value; - continue; - } - if (lastPart is ListValueParsingType.Separator) - { - // 正常分割符后的值 - thisPartStartIndex = index; - lastPart = ListValueParsingType.Value; - continue; - } - } - - // 处理最后一个值 - if (lastPart is ListValueParsingType.Start) - { - // 一开始就结束了(字符串里就没有值) - yield return ""; - } - else if (lastPart is ListValueParsingType.QuoteStart or ListValueParsingType.QuotedValue or ListValueParsingType.QuotedSeparator) - { - // 引号还没结束,字符串就结束了 - throw new CommandLineParseValueException( - $"Missing quote end at index [{optionValue.Length}]: {optionValue}"); - } - else if (lastPart is ListValueParsingType.QuoteEnd) - { - // 引号结束后字符串正常结束 - } - else if (lastPart is ListValueParsingType.Value) - { - // 正常值结束的字符串 - yield return optionValue[thisPartStartIndex..]; - } - else if (lastPart is ListValueParsingType.Separator) - { - // 正常分割符后就结束了字符串 - yield return ""; - } - } - } -} - -file enum ListValueParsingType -{ - /// - /// 尚未开始分割。 - /// - Start, - - /// - /// 引号开始。 - /// - QuoteStart, - - /// - /// 引号中的值。 - /// - QuotedValue, - - /// - /// 引号中的分割符。 - /// - QuotedSeparator, - - /// - /// 引号结束。 - /// - QuoteEnd, - - /// - /// 正常值。 - /// - Value, - - /// - /// 正常分割符。 - /// - Separator, -} - -internal enum MultiValueHandling -{ - /// - /// 仅返回第一个值。 - /// - First, - - /// - /// 返回最后一个值。 - /// - Last, - - /// - /// 用空格连接返回所有值。 - /// - SpaceAll, - - /// - /// 用斜杠 '/' 连接返回所有值。 - /// - SlashAll, -} diff --git a/src/DotNetCampus.CommandLine/CommandLineStyle.cs b/src/DotNetCampus.CommandLine/CommandLineStyle.cs index 58f0c29f..8db00283 100644 --- a/src/DotNetCampus.CommandLine/CommandLineStyle.cs +++ b/src/DotNetCampus.CommandLine/CommandLineStyle.cs @@ -1,252 +1,306 @@ -namespace DotNetCampus.Cli; +using System.Diagnostics.Contracts; +using System.Runtime.Versioning; +using DotNetCampus.Cli.Utils; + +namespace DotNetCampus.Cli; /// -/// 命令行参数的风格规范。 -/// 不同的命令行工具可能使用不同的参数风格,本枚举定义了常见的几种命令行参数风格。 +/// 详细指定一种命令行风格的细节。 /// -public enum CommandLineStyle +public readonly partial record struct CommandLineStyle() { + private readonly BooleanValues16 _booleans; + /// - /// 灵活风格。
- /// 根据实际传入的参数,自动识别并支持多种主流风格,包括 等风格。 - /// 适用于希望为用户提供更灵活的参数传递体验的工具。 + /// 直接由程序员提前算好各种属性赋值完成后的魔数,节省应用程序启动期间的额外计算。 ///
- /// - /// 灵活风格是一种包容性最强的命令行参数风格,旨在让不熟悉命令行操作的用户也能轻松使用。它通过智能识别尝试理解用户输入的意图,支持多种参数格式共存。
- ///
- /// 详细规则:
- /// 1. 参数前缀支持多种形式:双破折线(--), 单破折线(-), 斜杠(/,仅 Windows)
- /// 2. 参数值分隔符兼容多种形式:空格、等号(=)、冒号(:)
- /// 3. 参数命名风格兼容kebab-case(--parameter-name)、PascalCase(-ParameterName)和camelCase
- /// 4. 默认大小写不敏感,便于初学者使用
- /// 5. 支持短选项(-a)和长选项(--parameter),优先识别长选项
- /// 6. 支持布尔开关参数,可不带值或使用true/false、yes/no、on/off等常见值
- /// 7. 支持位置参数,并可通过双破折号(--)标记位置参数的开始
- /// 8. 支持有限的短选项组合(-abc),但当发生歧义时优先解析为单个选项
- /// 9. 当特性之间发生冲突时,优先保留简单、直观的用法,牺牲高级但复杂的功能
- /// 10. 自动检测并处理常见的用户错误,如选项名称拼写错误提示最接近的选项
- /// 11. 允许不同风格在同一命令行中混合使用
- ///
- /// 不支持的特性(为避免冲突):
- /// 1. 短选项组合中的最后一个选项不能直接附带参数(如-abc value,c无法接收value作为参数)
- /// 2. 不支持POSIX风格中的特殊数字操作数形式(如-42表示数字42)
- ///
- /// - /// # 长选项示例(多种风格) - /// app --parameter value # GNU风格空格分隔 - /// app --parameter=value # GNU风格等号分隔 - /// app --parameter:value # DotNet风格冒号分隔 - /// app -Parameter value # PowerShell风格(Pascal命名) - /// app --param-name value # Kebab-case命名 - /// app --paramName value # CamelCase命名 - /// - /// # 短选项示例(兼容多种形式) - /// app -p value # 短选项空格分隔 - /// app -p=value # 短选项等号分隔 - /// app -p:value # 短选项冒号分隔 - /// app -pvalue # 短选项直接跟值(GNU风格) - /// - /// # 斜杠选项(Windows风格,仅在 Windows 系统可用) - /// app /parameter value # 斜杠前缀长选项 - /// app /p value # 斜杠前缀短选项 - /// app /parameter:value # 斜杠前缀冒号分隔(类MSBuild) - /// - /// # 布尔开关参数 - /// app --enable # 不带值的布尔参数(视为true) - /// app --no-feature # 否定形式(视为false) - /// app --feature=false # 显式布尔值 - /// app --feature=off # 替代布尔值形式 - /// app -e # 短格式布尔参数 - /// - /// # 位置参数和混合用法 - /// app value1 --param value2 # 位置参数和命名参数混用 - /// app --param value -- -value1 --value2 # -- 后的内容视为位置参数 - /// app -a value1 --param-b value2 /c:value3 # 混合使用不同风格 - /// - /// # 大小写不敏感(便于初学者) - /// app --PARAMETER value # 等同于 --parameter value - /// app -P value # 等同于 -p value - /// - /// # 有限支持的短选项组合 - /// app -abc # 等同于 -a -b -c(所有都是布尔开关) - /// - ///
- Flexible, + /// 魔数。 + internal CommandLineStyle(ushort magic) : this() + { + _booleans = new BooleanValues16(magic); + } + + /// + /// 允许用户在命令行中使用的命令行参数风格。 + /// + public CommandNamingPolicy NamingPolicy + { + // [0] 表示是否额外编译时转换以支持 PascalCase/CamelCase 命名法 + // [1] 表示视选项上定义的命名法为 kebab-case,并允许用户使用此 kebab-case 命名法输入命令 + get => _booleans[0, 1] switch + { + (true, true) => CommandNamingPolicy.Both, + (true, false) => CommandNamingPolicy.KebabCase, + (false, true) => CommandNamingPolicy.PascalCase, + (false, false) => CommandNamingPolicy.Ordinal, + }; + init => _booleans[0, 1] = value switch + { + CommandNamingPolicy.Both => (true, true), + CommandNamingPolicy.KebabCase => (true, false), + CommandNamingPolicy.PascalCase => (false, true), + CommandNamingPolicy.Ordinal => (false, false), + _ => throw new ArgumentOutOfRangeException(nameof(value), value, null), + }; + } + + /// + /// 指定命令行选项前缀的风格。 + /// + public CommandOptionPrefix OptionPrefix + { + get => _booleans[2, 3, 4] switch + { + (false, false, false) => CommandOptionPrefix.DoubleDash, + (false, false, true) => CommandOptionPrefix.SingleDash, + (false, true, false) => CommandOptionPrefix.Slash, + (false, true, true) => CommandOptionPrefix.SlashOrDash, + (true, _, _) => CommandOptionPrefix.Any, + }; + init => _booleans[2, 3, 4] = value switch + { + CommandOptionPrefix.DoubleDash => (false, false, false), + CommandOptionPrefix.SingleDash => (false, false, true), + CommandOptionPrefix.Slash => (false, true, false), + CommandOptionPrefix.SlashOrDash => (false, true, true), + CommandOptionPrefix.Any => (true, false, false), + _ => (true, true, true), + }; + } + + /// + /// 在单独的选项没有特别指定时,默认是否区分大小写。 + /// + public bool CaseSensitive + { + get => _booleans[5]; + init => _booleans[5] = value; + } + + /// + /// 是否支持长选项。 + /// + public bool SupportsLongOption + { + get => _booleans[6]; + init => _booleans[6] = value; + } /// - /// GNU风格,支持长选项和短选项:
- /// 1. 双破折线(--) + 长选项名称,通过等号(=)或空格赋值
- /// 2. 单破折线(-) + 短选项字符,可以空格赋值,也可以紧跟参数值
- /// 3. 同时支持多个单字符选项合并(如-abc 表示 -a -b -c) + /// 是否支持短选项。 + ///
+ public bool SupportsShortOption + { + get => _booleans[7]; + init => _booleans[7] = value; + } + + /// + /// 当支持短选项时,是否支持将多个短选项组合在一起使用(短选项捆绑)。
+ /// 例如 -abc 等同于 -a -b -c。
+ /// 如果为 ,则 -abc 会被视为一个名为 "abc" 的短选项。 ///
/// - /// GNU风格是现代命令行工具中最广泛采用的标准之一,包括大多数Linux工具和跨平台应用程序。
- ///
- /// 详细规则:
- /// 1. 长选项以双破折线(--)开头,后跟由字母、数字、连字符组成的选项名
- /// 2. 长选项参数可以用等号(=)连接或用空格分隔
- /// 3. 短选项以单破折线(-)开头,后跟单个字符
- /// 4. 短选项参数可以直接跟在选项字符后,无需空格
- /// 5. 短选项也可以用空格分隔参数,或用等号连接参数
- /// 6. 多个不需要参数的短选项可以合并(如 -abc 等同于 -a -b -c)
- /// 7. 合并的短选项中,最后一个短选项可以带参数(如 -abc value 中 -c 接收 value 参数)
- /// 8. 双破折号(--) 作为单独参数时表示选项结束标记,之后的所有内容都被视为位置参数
- /// - /// - /// # 长选项示例 - /// app --option=value # 长选项用等号赋值 - /// app --option value # 长选项用空格赋值 - /// app --enable-feature # 布尔类型长选项(不需要值) - /// app --no-color # 否定形式的布尔长选项 - /// - /// # 短选项示例 - /// app -o=value # 短选项用等号赋值 - /// app -o value # 短选项用空格赋值 - /// app -ovalue # 短选项直接跟参数值(无空格) - /// app -abc # 多个布尔短选项合并(等同于 -a -b -c) - /// app -abc value # 合并短选项,其中 c 接收参数值 - /// - /// # 混合使用 - /// app value1 value2 --option value -f # 位置参数 + 长选项 + 短选项 - /// app --option value -- -value1 --value2 # -- 后的 -value1 和 --value2 被视为位置参数 - /// + /// 此选项与 互斥。 ///
- Gnu, + public bool SupportsShortOptionCombination + { + get => _booleans[8]; + init => _booleans[8] = value; + } /// - /// POSIX/UNIX风格,类似GNU但更严格:
- /// 1. 支持 - 开头的短选项,单个字符
- /// 2. 短选项可以组合使用(-abc 表示 -a -b -c)
- /// 3. 需要参数的选项必须与参数分开或使用特定格式 + /// 当支持短选项时,是否支持多字符短选项名称。
+ /// 例如 -tl 作为 --terminal-logger 的短选项。 ///
/// - /// POSIX风格是UNIX系统中规范的命令行参数格式,相比GNU风格更加严格和精简,许多传统UNIX工具遵循此规范。
- ///
- /// 详细规则:
- /// 1. 只支持短选项,以单破折线(-)开头,后跟单个字母
- /// 2. 短选项参数必须用空格与选项分隔(标准做法)
- /// 3. 不需要参数的短选项(布尔选项)可以组合在一起(如 -abc 等同于 -a -b -c)
- /// 4. 在组合的短选项中,通常不支持为最后一个选项提供参数(这点与GNU不同)
- /// 5. 标准POSIX不支持长选项(以--开头)
- /// 6. 有些遵循POSIX的工具允许用破折线后跟操作数而不是选项(如 -42 表示数字42)
- /// 7. 双破折号(--)作为选项终止符,之后的参数被当作操作数而非选项
- /// - /// - /// # 标准短选项 - /// app -o value # 短选项用空格赋值 - /// app -a # 布尔短选项 - /// app -abc # 多个布尔短选项合并(等同于 -a -b -c) - /// - /// # 选项结束标记 - /// app -a -- -b file.txt # -- 后的 -b 被视为文件名而非选项 - /// app -a -b -- -c # -a 和 -b 是选项,-c 是参数 - /// - /// # 位置参数 - /// app file1.txt -a file2.txt # file1.txt 和 file2.txt 是位置参数 - /// + /// 此选项与 互斥。 ///
- Posix, + public bool SupportsMultiCharShortOption + { + get => _booleans[9]; + init => _booleans[9] = value; + } + + /// + /// 当支持短选项时,是否支持短选项直接跟值(不使用分隔符)。
+ /// 例如 -abc 会被视为短选项 -a,值为 "bc"。
+ /// 如果为 ,则会根据 的值来决定 + /// -abc 是一个名为 "abc" 的短选项,还是 -a -b -c 三个短选项。 + ///
+ public bool SupportsShortOptionValueWithoutSeparator + { + get => _booleans[10]; + init => _booleans[10] = value; + } /// - /// .NET CLI风格,使用冒号分隔参数:
- /// 1. 短选项形式为 -参数:值
- /// 2. 长选项可以是 --参数:值
- /// 3. 也支持斜杠前缀 /参数:值(仅 Windows 环境下可用) + /// 是否支持使用空格分隔选项名和选项值。
+ /// 例如 --option value 等同于 --option=value。
+ /// 如果为 ,则 --option value 会被视为 --option 选项,value 会被视为下一个位置参数或选项。 + ///
+ public bool SupportsSpaceSeparatedOptionValue + { + get => _booleans[11]; + init => _booleans[11] = value; + } + + /// + /// 是否支持为布尔选项显式指定值。
+ /// 例如 --option true, --option false, --option:1, --option:0 等等。
+ /// 如果为 ,则 --option true 会被视为 --option 选项,true 会被视为下一个位置参数或选项。 + ///
+ public bool SupportsExplicitBooleanOptionValue + { + get => _booleans[12]; + init => _booleans[12] = value; + } + + /// + /// 当选项值为集合类型时,是否支持使用空格分隔多个选项值。
+ /// 例如 --option value1 value2 等同于 --option value1,value2。
+ /// 如果为 ,则 --option value1 value2 会被视为 --option 的值为 "value1",而 "value2" 会被视为下一个位置参数或选项。 + ///
+ public bool SupportsSpaceSeparatedCollectionValues + { + get => _booleans[13]; + init => _booleans[13] = value; + } + + /// + /// 遇到未知选项时,命令行解析器应如何处理后续的非选项参数。 + /// + public UnknownOptionBehavior UnknownOptionTakesValue + { + get => _booleans[14, 15] switch + { + (false, false) => UnknownOptionBehavior.TakesNoValue, + (false, true) => UnknownOptionBehavior.TakesOptionalValue, + (true, _) => UnknownOptionBehavior.TakesAllValues, + }; + init => _booleans[14, 15] = value switch + { + UnknownOptionBehavior.TakesNoValue => (false, false), + UnknownOptionBehavior.TakesOptionalValue => (false, true), + UnknownOptionBehavior.TakesAllValues => (true, false), + _ => throw new ArgumentOutOfRangeException(nameof(value), value, null), + }; + } + + /// + /// 允许用户使用哪些分隔符来分隔选项名和选项值。
+ /// 如 ':', '=', ' ' 分别对应: --option:value, --option=value, --option value。 ///
/// - /// 这种风格在现代.NET工具链(dotnet CLI、NuGet、MSBuild等)和其他Microsoft工具中广泛使用。
- ///
- /// 详细规则:
- /// 1. 支持使用冒号(:)作为选项和参数值的分隔符
- /// 2. 短选项以单破折线(-)开头,后跟选项名,然后是冒号和参数值
- /// 3. 长选项以双破折线(--)开头,后跟选项名,然后是冒号和参数值
- /// 4. 也支持使用斜杠(/)作为选项前缀,仅在Windows环境中可用
- /// 5. 参数名可以是单个字母、多字符缩写或完整的单词,支持各种命名规范
- /// 6. 布尔选项通常不需要值,或使用true/false、on/off等值
- /// 7. 多个短选项一般不支持合并(与GNU/POSIX不同)
- /// 8. 某些.NET工具也接受等号(=)作为选项和值的分隔符
- /// - /// - /// # 短选项示例 - /// dotnet build -c:Release # 短选项冒号语法 - /// dotnet test -t:UnitTest # 短选项指定测试类别 - /// dotnet publish -o:./publish # 指定输出目录 - /// dotnet build -tl:off # 双字符短选项 - /// - /// # 长选项示例 - /// dotnet build --verbosity:minimal # 长选项冒号语法 - /// dotnet run --project:App1 # 指定项目 - /// msbuild --target:Rebuild # MSBuild长选项 - /// - /// # 不同命名风格 - /// dotnet build -Configuration:Release # PascalCase,单破折号 - /// dotnet build --Configuration:Release # PascalCase,双破折号 - /// dotnet build /Configuration:Release # PascalCase,斜杠前缀 - /// dotnet test --test-category:UnitTest # kebab-case,双破折号 - /// dotnet run --projectName:App1 # camelCase,双破折号 - /// - /// # 斜杠选项(Windows风格,仅在 Windows 系统可用) - /// msbuild /p:Configuration=Release # MSBuild属性 - /// dotnet test /blame # 启用故障分析 - /// dotnet nuget push /source:feed # 指定源 - /// dotnet test /tl:off # 斜杠前缀的短选项 - /// - /// # 布尔选项 - /// dotnet build -m:1 # 最大并行度 - /// dotnet test --blame # 不带值的布尔选项 - /// dotnet build --no-restore # 否定形式的布尔选项 - /// - /// # 混合用法 - /// dotnet publish -c:Release --no-build -o:./bin - /// dotnet test -Framework:net8.0 --verbosity:normal /blame - /// + /// 如果指定空格(' '),则表示选项名和选项值之间可以用空格分隔,如 --option value。
+ /// 如果指定冒号(':'),则表示选项名和选项值之间可以用冒号分隔,如 --option:value。
+ /// 如果指定等号('='),则表示选项名和选项值之间可以用等号分隔,如 --option=value。
+ /// 而如果指定为空字符('\0'),则此字符只会对短选项生效,表示短选项可以直接跟值,如 -oValue。毕竟长选项跟值也分不开,对吧!
+ /// 基本上不会再存在其他种类的分隔符了…… ///
- DotNet, + public CommandSeparatorChars OptionValueSeparators { get; init; } + + /// + /// 允许用户使用哪些分隔符来分隔集合类型的选项值。
+ /// 如 ',', ';', ' ' 分别对应: --option value1,value2, --option value1;value2, --option value1 value2。 + ///
+ public CommandSeparatorChars CollectionValueSeparators { get; init; } + + /// + /// 此命令行风格的名称,用于调试和日志记录。 + /// + public string Name { get; init; } = "Custom"; + + /// + /// 获取用于存储样式细节的魔术数字。 + /// + /// 魔术数字。 + [Pure] + internal ushort GetMagicNumber() => _booleans.GetMagicNumber(); +} + +/// +/// 允许用户在命令行中使用的命令和选项的命名风格。 +/// +[Flags] +public enum CommandNamingPolicy : byte +{ + /// + /// 无视明明风格,属性上定义的字符串必须与用户输入的命令或选项名称完全匹配。 + /// + Ordinal = 0, /// - /// PowerShell风格,使用 - 开头,但参数名称通常是完整单词或驼峰形式:
- /// 1. 长参数形式为 -参数名 值
- /// 2. 支持不带值的开关参数(开关参数)
- /// 3. 支持参数名称缩写 + /// PascalCase/camelCase 风格命名。 + ///
+ PascalCase = 1, + + /// + /// kebab-case 风格命名。 /// /// - /// PowerShell命令行风格在微软的PowerShell脚本语言和相关工具中使用,具有独特的参数处理方式。
- ///
- /// 详细规则:
- /// 1. 参数名称前使用单个破折线(-),后跟完整的参数名(通常是Pascal或Camel大小写)
- /// 2. 参数名称与值之间用空格分隔
- /// 3. 支持参数名称的部分匹配和自动补全(只要能唯一标识参数)
- /// 4. 支持位置参数(根据位置而非参数名赋值)
- /// 5. 布尔开关参数不需要显式值(存在即为true)
- /// 6. 可以使用冒号语法传递数组或哈希表值
- /// 7. 不支持GNU/POSIX风格的短选项合并
- /// 8. 支持使用双引号或单引号包围包含空格的参数值
- /// 9. 支持参数别名(一个参数可以有多个名称)
- /// - /// - /// # 基本参数用法 - /// Get-Process -Name chrome # 带值的标准参数 - /// New-Item -Path "C:\temp" -ItemType Directory # 多个参数 - /// - /// # 开关参数(布尔参数) - /// Remove-Item -Recurse -Force # 两个开关参数(无需值) - /// Copy-Item file.txt backup/ -Verbose # 启用详细输出 - /// - /// # 参数名称缩写 - /// Get-Process -n chrome # -n 是 -Name 的缩写 - /// Get-ChildItem -Recurse -Fo *.txt # -Fo 是 -Force 的缩写(只要能唯一识别) - /// - /// # 位置参数(无需指定参数名) - /// Get-Process chrome # 位置参数,等同于 -Name chrome - /// - /// # 数组参数 - /// Get-Process -Name chrome,firefox,edge # 逗号分隔的数组 - /// Get-Process -ComputerName "srv1","srv2" # 引号包围的数组元素 - /// - /// # 复杂值和高级用法 - /// New-Object -TypeName PSObject -Property @{Name="Value"; Count=1} # 哈希表参数 - /// Invoke-Command -ScriptBlock { Get-Process } -ComputerName Server01 # 脚本块参数 - /// + /// 由于我们已经约定在定义属性时,属性已经用 kebab-case 命名风格标记了名字,所以此选项实际上含义与 是等同的。 ///
- PowerShell, + KebabCase = 1 << 1, + + /// + /// 以 kebab-case 命名风格为主,兼顾支持 PascalCase/camelCase。 + /// + Both = PascalCase | KebabCase, +} + +/// +/// 指定命令行选项前缀的风格。 +/// +public enum CommandOptionPrefix : byte +{ + /// + /// 使用双短横线(--)作为长选项前缀,使用单个短横线(-)作为短选项前缀。 + /// + DoubleDash, + + /// + /// 使用单个短横线(-)作为长选项和短选项前缀。
+ /// 注意:如果启用此选项,将不支持短选项组合和短选项直接跟值;仍支持多字符短选项,但解析会造成轻微的性能下降(因为会两次尝试匹配选项名)。 + ///
+ SingleDash, + + /// + /// 使用斜杠(/)作为长选项和短选项前缀。
+ /// 注意:如果启用此选项,将不支持短选项组合和短选项直接跟值;仍支持多字符短选项,但解析会造成轻微的性能下降(因为会两次尝试匹配选项名)。 + ///
+ Slash, + + /// + /// 使用斜杠(/)或单个短横线(-)作为长选项和短选项前缀。
+ /// 注意:如果启用此选项,将不支持短选项组合和短选项直接跟值;仍支持多字符短选项,但解析会造成轻微的性能下降(因为会两次尝试匹配选项名)。 + ///
+ SlashOrDash, + + /// + /// 允许使用任意一种前缀风格(-、--、/)。
+ /// 注意:如果启用此选项,将不支持短选项组合和短选项直接跟值;仍支持多字符短选项,但解析会造成轻微的性能下降(因为会两次尝试匹配选项名)。 + ///
+ Any, +} + +/// +/// 指定遇到未知选项时,命令行解析器应如何处理后续的非选项参数。 +/// +public enum UnknownOptionBehavior : byte +{ + /// + /// 遇到未知选项时,后续如果出现了非选项,则视为位置参数。 + /// + TakesNoValue, + + /// + /// 遇到未知选项时,后续如果出现了非选项,最多取走一个作为选项值,其他视为位置参数。 + /// + TakesOptionalValue, + + /// + /// 遇到未知选项时,后续如果出现了非选项,取走直到下一个选项之前的所有值作为选项值。 + /// + TakesAllValues, } diff --git a/src/DotNetCampus.CommandLine/CommandLineStyle.static.cs b/src/DotNetCampus.CommandLine/CommandLineStyle.static.cs new file mode 100644 index 00000000..6d46acf9 --- /dev/null +++ b/src/DotNetCampus.CommandLine/CommandLineStyle.static.cs @@ -0,0 +1,268 @@ +using DotNetCampus.Cli.Utils; + +namespace DotNetCampus.Cli; + +partial record struct CommandLineStyle +{ + private const ushort FlexibleMagic = 0x98C7; + private const ushort DotNetMagic = 0x9AE1; + private const ushort GnuMagic = 0x8DE1; + private const ushort PosixMagic = 0x89A2; + private const ushort WindowsMagic = 0x9ADA; + private const ushort UrlMagic = 0x9043; + + /// + /// 灵活风格。
+ /// 在绝大多数情况下,接受用户输入各种风格的命令行参数(包括 等);
+ /// 此风格给了用户最大的灵活性。但同时,作为开发者,你定义命令行选项时也应该尽可能避免不同风格间可能出现的歧义。
+ /// 当然,绝大多数情况下,你都不会碰到可能歧义的情况。 + ///
+ /// + /// 注意:在非 Windows 系统上使用 可能在某些情况下出现解析歧义,
+ /// 这是因为 Linux/macOS 系统中,`/` 字符是合法的文件名字符,
+ /// 如果你定义了一个需要传入路径的位置参数,那么这个位置参数可能会被误解析为选项。 + ///
+ public static CommandLineStyle Flexible => new CommandLineStyle(FlexibleMagic) + { + Name = "Flexible", + OptionValueSeparators = CommandSeparatorChars.Create(':', '='), + CollectionValueSeparators = CommandSeparatorChars.Create(',', ';'), + }; + + /// + /// .NET CLI 风格。
+ /// + /// 命令和长选项采用 kebab-case 命名法,区分大小写 + /// 长选项使用 -- 前缀,如 --option-name + /// 短选项使用 - 前缀,如 -o;支持多个字符的短选项(仍是一个选项),如 -tl + /// 选项和值之间使用这些分隔符之一:冒号(:)、等号(=)、空格( ) + /// 布尔选项可以不带值,视为 true;也可以带 true/false、on/off、yes/no、1/0 等值 + /// 位置参数按顺序解析,可与选项交叉出现;使用 -- 单独一项来标记位置参数的开始,后续所有参数均视为位置参数 + /// 当值为集合时,可使用这些分隔符之一:逗号(,)、分号(;),也可多次指定,如 --option value1 --option value2 + /// 当值为字典时,使用等号(=)分隔键和值,如 --option key=value + /// + ///
+ public static CommandLineStyle DotNet => new CommandLineStyle(DotNetMagic) + { + Name = "DotNet", + OptionValueSeparators = CommandSeparatorChars.Create(':', '='), + CollectionValueSeparators = CommandSeparatorChars.Create(',', ';'), + }; + + /// + /// GNU 风格。
+ /// + /// 命令和选项采用 kebab-case 命名法,区分大小写 + /// 长选项使用 -- 前缀,如 --option-name + /// 短选项使用 - 前缀,如 -o;支持多个字符的短选项组合,如 -abc(等同于 -a -b -c) + /// 选项和值之间使用这些分隔符之一:等号(=)、空格( );短选项还支持直接跟值,如 -o1.txt + /// 布尔选项可以不带值,视为 true;也可以带 true/false、on/off、yes/no、1/0 等值 + /// 位置参数按顺序解析,可与选项交叉出现;使用 -- 单独一项来标记位置参数的开始,后续所有参数均视为位置参数 + /// 当值为集合时,可使用这些分隔符之一:逗号(,)、分号(;),也可多次指定,如 --option value1 --option value2 + /// 当值为字典时,使用等号(=)分隔键和值,如 --option key=value + /// + ///
+ public static CommandLineStyle Gnu => new CommandLineStyle(GnuMagic) + { + Name = "Gnu", + OptionValueSeparators = CommandSeparatorChars.Create('='), + CollectionValueSeparators = CommandSeparatorChars.Create(',', ';'), + }; + + /// + /// POSIX/UNIX 风格。
+ /// + /// 只支持短选项,采用单字符命名法,区分大小写 + /// 短选项使用 - 前缀,如 -o;支持多个字符的短选项组合,如 -abc(等同于 -a -b -c) + /// 选项和值之间使用空格( ) 分隔;不支持其他分隔符 + /// 布尔选项可以不带值,视为 true;也可以带 true/false、on/off、yes/no、1/0 等值 + /// 位置参数按顺序解析,可与选项交叉出现;使用 -- 单独一项来标记位置参数的开始,后续所有参数均视为位置参数 + /// 当值为集合时,可使用这些分隔符之一:逗号(,)、分号(;),也可多次指定,如 -o value1 -o value2 + /// 当值为字典时,使用等号(=)分隔键和值,如 -o key=value + /// + ///
+ public static CommandLineStyle Posix => new CommandLineStyle(PosixMagic) + { + Name = "Posix", + OptionValueSeparators = CommandSeparatorChars.Create(), + CollectionValueSeparators = CommandSeparatorChars.Create(',', ';'), + }; + + /// + [Obsolete("为避免理解歧义,已弃用此名称,请使用 Windows 代替。")] + public static CommandLineStyle PowerShell => Windows; + + /// + /// Windows 经典风格。 + /// 这是一种源自 Windows 传统与现代生态的混合命令行风格。它深深植根于 MS-DOS 和 Windows Command Prompt (CMD) 的历史,其最显著的特征是使用斜杠 (/) 作为选项前缀(例如 dir /w, taskkill /f)。同时,为了适应现代跨平台开发和 POSIX 风格的影响,它也广泛接纳了连字符 (-) 作为选项前缀(例如 wsl -l -v)。 + /// 其核心特点包括:
+ /// 1. 前缀混用:同时接受 / 和 - 作为选项引导符。
+ /// 2. 大小写不敏感:这与 Windows 环境的普遍习惯保持一致。
+ /// 3. 命名法偏好:选项名称常采用 PascalCase 或 camelCase,反映了 Windows 开发生态(如 .NET)的命名习惯。
+ /// 4. 灵活性:在选项和值的连接上通常比较灵活,支持空格、冒号等多种形式。
+ /// + /// 命令和选项采用 PascalCase 命名法,不区分大小写 + /// 长选项使用 - 前缀,如 -OptionName + /// 短选项使用 - 前缀,如 -o;支持多个字符的短选项(仍是一个选项),如 -tl + /// 选项和值之间使用这些分隔符之一:冒号(:)、等号(=)、空格( ) + /// 布尔选项可以不带值,视为 true;也可以带 true/false、on/off、yes/no、1/0 等值 + /// 位置参数按顺序解析,可与选项交叉出现 + /// 当值为集合时,可使用这些分隔符之一:逗号(,)、分号(;),也可多次指定,如 -Option value1 -Option value2 + /// 当值为字典时,使用等号(=)分隔键和值,如 -Option key=value + /// + ///
+ /// + /// 注意:在非 Windows 系统上使用 可能在某些情况下出现解析歧义,
+ /// 这是因为 Linux/macOS 系统中,`/` 字符是合法的文件名字符,
+ /// 如果你定义了一个需要传入路径的位置参数,那么这个位置参数可能会被误解析为选项。 + ///
+ public static CommandLineStyle Windows => new CommandLineStyle(WindowsMagic) + { + Name = "Windows", + OptionValueSeparators = CommandSeparatorChars.Create(':', '='), + CollectionValueSeparators = CommandSeparatorChars.Create(',', ';'), + }; + + /// + /// 内部使用。当发现命令行参数只有一个,且符合 URL 格式时,无论用户设置了哪种命令行风格,都会使用此风格进行解析。 + /// + public static CommandLineStyle Url => new CommandLineStyle(UrlMagic) + { + Name = "Url", + OptionValueSeparators = CommandSeparatorChars.Create('='), + CollectionValueSeparators = CommandSeparatorChars.Create(',', ';'), + }; + +#if DEBUG + + private static CommandLineStyle FlexibleDefinition => new CommandLineStyle + { + CaseSensitive = false, + SupportsLongOption = true, + SupportsShortOption = true, + SupportsShortOptionCombination = false, + SupportsMultiCharShortOption = false, + SupportsShortOptionValueWithoutSeparator = false, + SupportsSpaceSeparatedOptionValue = true, + SupportsExplicitBooleanOptionValue = true, + SupportsSpaceSeparatedCollectionValues = false, + NamingPolicy = CommandNamingPolicy.Both, + OptionPrefix = CommandOptionPrefix.Any, + UnknownOptionTakesValue = UnknownOptionBehavior.TakesOptionalValue, + }; + + private static CommandLineStyle DotNetDefinition => new CommandLineStyle + { + CaseSensitive = true, + SupportsLongOption = true, + SupportsShortOption = true, + SupportsShortOptionCombination = false, + SupportsMultiCharShortOption = true, + SupportsShortOptionValueWithoutSeparator = false, + SupportsSpaceSeparatedOptionValue = true, + SupportsExplicitBooleanOptionValue = true, + SupportsSpaceSeparatedCollectionValues = false, + NamingPolicy = CommandNamingPolicy.KebabCase, + OptionPrefix = CommandOptionPrefix.DoubleDash, + UnknownOptionTakesValue = UnknownOptionBehavior.TakesOptionalValue, + }; + + private static CommandLineStyle GnuDefinition => new CommandLineStyle + { + CaseSensitive = true, + SupportsLongOption = true, + SupportsShortOption = true, + SupportsShortOptionCombination = true, + SupportsMultiCharShortOption = false, + SupportsShortOptionValueWithoutSeparator = true, + SupportsSpaceSeparatedOptionValue = true, + SupportsExplicitBooleanOptionValue = false, + SupportsSpaceSeparatedCollectionValues = false, + NamingPolicy = CommandNamingPolicy.KebabCase, + OptionPrefix = CommandOptionPrefix.DoubleDash, + UnknownOptionTakesValue = UnknownOptionBehavior.TakesOptionalValue, + }; + + private static CommandLineStyle PosixDefinition => new CommandLineStyle + { + CaseSensitive = true, + SupportsLongOption = false, + SupportsShortOption = true, + SupportsShortOptionCombination = true, + SupportsMultiCharShortOption = false, + SupportsShortOptionValueWithoutSeparator = false, + SupportsSpaceSeparatedOptionValue = true, + SupportsExplicitBooleanOptionValue = false, + SupportsSpaceSeparatedCollectionValues = false, + NamingPolicy = CommandNamingPolicy.PascalCase, + // Posix 不支持长选项,使用 DoubleDash 的含义是 '-' 一定表示短选项。 + OptionPrefix = CommandOptionPrefix.DoubleDash, + UnknownOptionTakesValue = UnknownOptionBehavior.TakesOptionalValue, + }; + + private static CommandLineStyle WindowsDefinition => new CommandLineStyle + { + CaseSensitive = false, + SupportsLongOption = true, + SupportsShortOption = true, + SupportsShortOptionCombination = false, + SupportsMultiCharShortOption = true, + SupportsShortOptionValueWithoutSeparator = false, + SupportsSpaceSeparatedOptionValue = true, + SupportsExplicitBooleanOptionValue = true, + SupportsSpaceSeparatedCollectionValues = false, + NamingPolicy = CommandNamingPolicy.PascalCase, + OptionPrefix = CommandOptionPrefix.SlashOrDash, + UnknownOptionTakesValue = UnknownOptionBehavior.TakesOptionalValue, + }; + + /// + /// 内部使用。当发现命令行参数只有一个,且符合 URL 格式时,无论用户设置了哪种命令行风格,都会使用此风格进行解析。 + /// + private static CommandLineStyle UrlDefinition => new CommandLineStyle + { + CaseSensitive = false, + SupportsLongOption = true, + SupportsShortOption = false, + SupportsShortOptionCombination = false, + SupportsMultiCharShortOption = false, + SupportsShortOptionValueWithoutSeparator = false, + SupportsSpaceSeparatedOptionValue = false, + SupportsExplicitBooleanOptionValue = true, + SupportsSpaceSeparatedCollectionValues = false, + NamingPolicy = CommandNamingPolicy.Both, + OptionPrefix = CommandOptionPrefix.DoubleDash, + UnknownOptionTakesValue = UnknownOptionBehavior.TakesOptionalValue, + }; + + /// + /// 在单元测试里调用,以验证各种预定义的命令行风格没有被意外修改。 + /// + public static void VerifyMagicNumbers() + { + var flexibleMagic = FlexibleDefinition.GetMagicNumber(); + var dotNetMagic = DotNetDefinition.GetMagicNumber(); + var gnuMagic = GnuDefinition.GetMagicNumber(); + var posixMagic = PosixDefinition.GetMagicNumber(); + var windowsMagic = WindowsDefinition.GetMagicNumber(); + var urlMagic = UrlDefinition.GetMagicNumber(); + if (flexibleMagic != FlexibleMagic || dotNetMagic != DotNetMagic || + gnuMagic != GnuMagic || posixMagic != PosixMagic || + windowsMagic != WindowsMagic || urlMagic != UrlMagic) + { + throw new InvalidOperationException($""" +The magic numbers of predefined command line styles have been changed. Please copy the code to update them: +```csharp +private const ushort FlexibleMagic = 0x{flexibleMagic:X4}; +private const ushort DotNetMagic = 0x{dotNetMagic:X4}; +private const ushort GnuMagic = 0x{gnuMagic:X4}; +private const ushort PosixMagic = 0x{posixMagic:X4}; +private const ushort WindowsMagic = 0x{windowsMagic:X4}; +private const ushort UrlMagic = 0x{urlMagic:X4}; +``` +"""); + } + } + +#endif +} diff --git a/src/DotNetCampus.CommandLine/CommandRunner.cs b/src/DotNetCampus.CommandLine/CommandRunner.cs index a447de68..d6df6577 100644 --- a/src/DotNetCampus.CommandLine/CommandRunner.cs +++ b/src/DotNetCampus.CommandLine/CommandRunner.cs @@ -1,212 +1,399 @@ -using System.Collections.Concurrent; using System.ComponentModel; +using System.Runtime.ExceptionServices; using DotNetCampus.Cli.Compiler; using DotNetCampus.Cli.Exceptions; using DotNetCampus.Cli.Utils.Handlers; +using DotNetCampus.Cli.Utils.Parsers; namespace DotNetCampus.Cli; +using FactoryAndRunner = (CommandObjectFactory Factory, CommandHandlerRunner? Runner); + /// /// 辅助 根据已解析的命令行参数执行对应的命令处理器。 /// public class CommandRunner : ICommandRunnerBuilder, IAsyncCommandRunnerBuilder { - private static ConcurrentDictionary CommandObjectCreationInfos { get; } = new( -#if NET5_0_OR_GREATER - ReferenceEqualityComparer.Instance -#endif - ); - private readonly CommandLine _commandLine; - private readonly DictionaryCommandHandlerCollection _dictionaryCommandHandlers = new(); - private readonly ConcurrentDictionary _assemblyCommandHandlers = []; + private readonly SortedList _factories; + private readonly StringComparison _stringComparison; + private readonly bool _supportsOrdinal; + private readonly bool _supportsPascalCase; + private FactoryAndRunner? _defaultFactory; + private CommandObjectFactory? _fallbackFactory; + private int _maxCommandLength; internal CommandRunner(CommandLine commandLine) { _commandLine = commandLine; + var caseSensitive = commandLine.ParsingOptions.Style.CaseSensitive; + _factories = caseSensitive + ? new SortedList(StringLengthDescendingComparer.CaseSensitive) + : new SortedList(StringLengthDescendingComparer.CaseInsensitive); + _stringComparison = caseSensitive + ? StringComparison.Ordinal + : StringComparison.OrdinalIgnoreCase; + _supportsOrdinal = commandLine.ParsingOptions.Style.NamingPolicy.SupportsOrdinal(); + _supportsPascalCase = commandLine.ParsingOptions.Style.NamingPolicy.SupportsPascalCase(); } - internal CommandRunner(CommandRunner commandRunner) + /// + public CommandRunningResult Run() { - _commandLine = commandRunner._commandLine; + try + { + return RunAsync().Result; + } + catch (AggregateException ex) when (ex.InnerExceptions.Count == 1) + { + // 当内部只有一个异常时,直接抛出这个异常,而不是 AggregateException。 + // 以便让同步方法的调用者看起来更像在用一个同步方法。 + ExceptionDispatchInfo.Capture(ex.InnerExceptions[0]).Throw(); + throw; + } } /// - /// 供源生成器调用,注册一个专门用来处理主命令(Main Command)或子命令/多级子命令(Sub Command)的命令处理器。 + /// 处理命令解析过程中发生的错误。 /// - /// 关联的命令。 - /// 命令处理器的创建方法。 - /// 选项类型,或命令处理器类型,或任意类型。 - [EditorBrowsable(EditorBrowsableState.Never)] - public static void Register(string? commandNames, CommandObjectCreator creator) - where T : class + /// + /// + internal bool RunFallback(CommandLineParsingResult result) { - CommandObjectCreationInfos[typeof(T)] = new CommandObjectCreationInfo(commandNames, creator); + var context = new CommandRunningContext + { + CommandLine = _commandLine, + CommandRunner = this, + }; + if (_fallbackFactory?.Invoke(context) is not ICommandHandler fallback) + { + return false; + } + + if (fallback is CommandLineExceptionHandler exceptionHandler) + { + exceptionHandler.ErrorResult = result; + } + + fallback.RunAsync().Wait(); + return true; } - /// - /// 创建一个命令处理器实例。 - /// - /// 已解析的命令行参数。 - /// 命令处理器的类型。 - /// 命令处理器实例。 - internal static T CreateInstance(CommandLine commandLine) + /// + CommandRunner ICoreCommandRunnerBuilder.AsRunner() => this; + + /// + public Task RunAsync() { - if (!CommandObjectCreationInfos.TryGetValue(typeof(T), out var info)) + var (possibleCommandNames, nullableFactoryAndRunner) = MatchCreator(); + + if (nullableFactoryAndRunner is not { } factoryAndRunner) { - throw new InvalidOperationException($"Handler '{typeof(T)}' is not registered. This may be a bug of the source generator."); + throw new CommandNameNotFoundException( + string.IsNullOrEmpty(possibleCommandNames) + ? "No command handler found. Please ensure that at least one command handler is registered by AddHandler()." + : $"No command handler found for command '{possibleCommandNames}'. Please ensure that the command handler is registered by AddHandler().", + possibleCommandNames); } - return (T)info.Creator(commandLine); + var (factory, runner) = factoryAndRunner; + var context = new CommandRunningContext + { + CommandLine = _commandLine, + CommandRunner = this, + }; + var handler = factory(context); + var exitCode = runner switch + { + null => ((ICommandHandler)handler).RunAsync(), + _ => runner(handler), + }; + return CommandRunningResult.FromTask(exitCode, _commandLine, handler); } - /// - /// 创建一个命令处理器实例。 - /// - /// 已解析的命令行参数。 - /// 命令处理器的创建方法。 - /// 命令处理器的类型。 - /// 命令处理器实例。 - internal static T CreateInstance(CommandLine commandLine, CommandObjectCreator creator) + private (string PossibleCommandNames, FactoryAndRunner? FactoryAndRunner) MatchCreator() { - return (T)creator(commandLine); - } + if (_factories.Count > 0) + { + var maxLength = _maxCommandLength; + var header = _commandLine.GetHeader(maxLength); + + foreach (var (command, factory) in _factories) + { + if (header.StartsWith(command, _stringComparison)) + { + // 前缀已匹配成功,接下来判断这是否是命令单词边界。 + if (header.Length == command.Length || char.IsWhiteSpace(header[command.Length])) + { + return (command, factory); + } + } + } + } + + if (_defaultFactory is { } defaultFactory) + { + return ("", defaultFactory); + } - CommandRunner ICoreCommandRunnerBuilder.GetOrCreateRunner() => this; + return (_commandLine.GetHeader(1), null); + } /// /// 添加一个命令处理器。 /// - /// 命令处理器的类型。 + /// 由拦截器传入的的命令处理器的命令, 表示此处理器没有命令名称。 + /// 由拦截器传入的命令处理器创建方法。 + /// 由拦截器传入的命令处理器运行方法。 /// 返回一个命令处理器构建器。 - internal CommandRunner AddHandler() - where T : class, ICommandHandler + [EditorBrowsable(EditorBrowsableState.Never)] + internal CommandRunner AddHandlerCore(NamingPolicyNameGroup command, CommandObjectFactory factory, CommandHandlerRunner? runner) { - if (!CommandObjectCreationInfos.TryGetValue(typeof(T), out var info)) + if (_supportsOrdinal) { - throw new InvalidOperationException($"Handler '{typeof(T)}' is not registered. This may be a bug of the source generator."); + if (command.Ordinal is { } ordinal && !string.IsNullOrEmpty(ordinal)) + { + // 包含命令名称。 + var isAdded = _factories.TryAdd(ordinal, (factory, runner)); + if (!isAdded) + { + throw new CommandNameAmbiguityException($"The command '{ordinal}' is already registered.", ordinal); + } + _maxCommandLength = Math.Max(_maxCommandLength, ordinal.Length); + } + else + { + // 不包含命令名称,表示这是默认命令。 + if (_defaultFactory is not null) + { + throw new CommandNameAmbiguityException("The default command handler is already registered.", null); + } + _defaultFactory = (factory, runner); + } + } + if (_supportsPascalCase) + { + if (command.PascalCase is { } pascal && !string.IsNullOrEmpty(pascal)) + { + // 包含命令名称。 + var isAdded = _factories.TryAdd(pascal, (factory, runner)); + if (!isAdded && !_supportsOrdinal) + { + // 转换的名称,之后在仅用转换名称时才需要抛出异常;否则很可能前面已经添加了一个相同的名称。 + throw new CommandNameAmbiguityException($"The command '{pascal}' is already registered.", pascal); + } + _maxCommandLength = Math.Max(_maxCommandLength, pascal.Length); + } + else + { + // 不包含命令名称,表示这是默认命令。 + if (_defaultFactory is not null && !_supportsOrdinal) + { + // 如果支持双命名法,则允许前面已经注册了一个默认命令。 + throw new CommandNameAmbiguityException("The default command handler is already registered.", null); + } + _defaultFactory = (factory, runner); + } } - - _dictionaryCommandHandlers.AddHandler(info.CommandNames, cl => (T)info.Creator(cl)); return this; } /// - /// 添加一个命令处理器。 + /// 添加一个回退的命令处理器。当其他命令出现了错误时,会执行此命令处理器。 /// - /// 由拦截器传入的的命令处理器的命令。 - /// 由拦截器传入的命令处理器创建方法。 - /// 命令处理器的类型。 + /// 回退命令处理器的创建方法。 /// 返回一个命令处理器构建器。 - [EditorBrowsable(EditorBrowsableState.Never)] - internal CommandRunner AddHandler(string? command, CommandObjectCreator creator) - where T : class, ICommandHandler + internal CommandRunner AddFallbackHandler(CommandObjectFactory factory) { - _dictionaryCommandHandlers.AddHandler(command, creator); + _fallbackFactory = factory; return this; } +} +/// +/// 表示命令行处理器的运行结果。 +/// +public readonly record struct CommandRunningResult +{ /// - /// 添加一个命令处理器。 + /// 命令行处理器的退出代码。 /// - /// 用于处理已解析的命令行参数的委托。 - /// 命令处理器的类型。 - /// 返回一个命令处理器构建器。 - internal CommandRunner AddHandler(Func> handler) - where T : class + public required int ExitCode { get; init; } + + /// + /// 被执行的命令行。 + /// + public required CommandLine CommandLine { get; init; } + + /// + /// 处理此命令行的命令处理器实例。 + /// + public required object? HandledBy { get; init; } + + /// + /// 隐式转换为退出代码。 + /// + /// 命令行处理结果。 + /// 退出代码。 + public static implicit operator int(CommandRunningResult result) => result.ExitCode; + + /// + /// 从一个异步的命令行处理任务创建一个命令行处理结果。 + /// + /// 异步的命令行处理任务。 + /// 被执行的命令行。 + /// 处理此命令行的命令处理器实例。 + /// 命令行处理结果。 + public static async Task FromTask(Task exitCodeTask, CommandLine commandLine, object handler) { - if (!CommandObjectCreationInfos.TryGetValue(typeof(T), out var info)) + return new CommandRunningResult { - throw new InvalidOperationException($"Handler '{typeof(T)}' is not registered. This may be a bug of the source generator."); - } + ExitCode = await exitCodeTask, + CommandLine = commandLine, + HandledBy = handler switch + { + IAnonymousCommandHandler anonymousHandler => anonymousHandler.CreatedCommandOptions, + _ => handler, + }, + }; + } +} - _dictionaryCommandHandlers.AddHandler(info.CommandNames, cl => new TaskCommandHandler( - () => (T)info.Creator(cl), - handler)); - return this; +/// +/// 命令行处理结果的扩展方法。 +/// +public static class CommandRunningResultExtensions +{ + /// + /// 将一个异步的命令行处理结果任务转换为一个异步的退出代码任务。 + /// + /// 异步的命令行处理结果任务。 + /// 异步的退出代码任务。 + public static async Task AsExitCodeTask(this Task task) + { + var result = await task.ConfigureAwait(false); + return result.ExitCode; } +#if NETCOREAPP3_1_OR_GREATER + /// - /// 添加一个命令处理器。 + /// 将一个异步的命令行处理结果任务转换为一个异步的退出代码任务。 /// - /// 由拦截器传入的的命令处理器的命令。 - /// 由拦截器传入的命令处理器创建方法。 - /// 用于处理已解析的命令行参数的委托。 - /// 命令处理器的类型。 - /// 返回一个命令处理器构建器。 - internal CommandRunner AddHandler(string? command, CommandObjectCreator creator, Func> handler) - where T : class + /// 异步的命令行处理结果任务。 + /// 异步的退出代码任务。 + public static async Task AsExitCodeTask(this ValueTask task) { - _dictionaryCommandHandlers.AddHandler(command, cl => new TaskCommandHandler( - () => (T)creator(cl), - handler)); - return this; + var result = await task.ConfigureAwait(false); + return result.ExitCode; } - internal CommandRunner AddHandlers() - where T : ICommandHandlerCollection, new() + /// + /// 将一个异步的命令行处理结果任务转换为一个异步的退出代码任务。 + /// + /// 异步的命令行处理结果任务。 + /// 异步的退出代码任务。 + public static async ValueTask AsExitCodeValueTask(this Task task) { - var c = new T(); - _assemblyCommandHandlers.TryAdd(c, c); - return this; + var result = await task.ConfigureAwait(false); + return result.ExitCode; } - private ICommandHandler? MatchHandler() + /// + /// 将一个异步的命令行处理结果任务转换为一个异步的退出代码任务。 + /// + /// 异步的命令行处理结果任务。 + /// 异步的退出代码任务。 + public static async ValueTask AsExitCodeValueTask(this ValueTask task) { - var possibleCommandNames = _commandLine.PossibleCommandNames; + var result = await task.ConfigureAwait(false); + return result.ExitCode; + } + +#endif +} - // 优先寻找单独添加的处理器。 - if (_dictionaryCommandHandlers.TryMatch(possibleCommandNames, _commandLine) is { } h1) +file static class CommandRunnerExtensions +{ + /// + /// 获取命令行前几个字符组成的字符串(空格分隔),长度等于或轻微超过指定的最大长度,除非命令行本身没有那么长。 + /// + /// 要获取前缀的命令行。 + /// 要比较的长度。 + /// 命令行前几个字符组成的字符串(空格分隔)。 + public static string GetHeader(this CommandLine commandLine, int compareToLength) + { + var args = commandLine.CommandLineArguments; + if (args.Count is 0 || compareToLength <= 0) { - return h1; + return ""; } - // 其次寻找程序集中自动搜集到的处理器。 - foreach (var handler in _assemblyCommandHandlers) + int index; + var currentLength = 0; + for (index = 0; index < args.Count; index++) { - if (handler.Value.TryMatch(possibleCommandNames, _commandLine) is { } h2) + if (index > 0) { - return h2; + // 加上空格的长度。 + currentLength++; } - } - // 如果没有找到,那么很可能此命令没有命令名称,需要使用默认的处理器。 - if (_dictionaryCommandHandlers.TryMatch("", _commandLine) is { } h3) - { - return h3; - } - foreach (var handler in _assemblyCommandHandlers) - { - if (handler.Value.TryMatch("", _commandLine) is { } h4) + var arg = args[index]; + var length = currentLength + arg.Length; + if (length > compareToLength) { - return h4; + break; } + + currentLength = length; } - // 如果连默认的处理器都没有找到,说明根本没有能处理此命令的处理器。 - return null; + return string.Join(" ", args.Take(index + 1)); } +} - /// - public int Run() - { - return RunAsync().Result; - } +/// +/// 按长度比较字符串的比较器。更长的字符串在排序中更靠前。 +/// +/// +file class StringLengthDescendingComparer(bool caseSensitive) : IComparer +{ + /// + /// 区分大小写的字符串长度降序比较器。 + /// + public static StringLengthDescendingComparer CaseSensitive { get; } = new StringLengthDescendingComparer(true); - /// - public Task RunAsync() + /// + /// 不区分大小写的字符串长度降序比较器。 + /// + public static StringLengthDescendingComparer CaseInsensitive { get; } = new StringLengthDescendingComparer(false); + + public int Compare(string? x, string? y) { - var handler = MatchHandler(); + if (x == null && y == null) + { + return 0; + } + if (x == null) + { + return 1; + } + if (y == null) + { + return -1; + } - if (handler is null) + // 先按长度比较,长度更长的排在前面。 + var lengthComparison = y.Length.CompareTo(x.Length); + if (lengthComparison != 0) { - throw new CommandNameNotFoundException( - $"No command handler found for command '{_commandLine.PossibleCommandNames}'. Please ensure that the command handler is registered correctly.", - _commandLine.PossibleCommandNames); + return lengthComparison; } - return handler.RunAsync(); + // 当长度相同时,按字典序比较。 + return caseSensitive + ? string.Compare(x, y, StringComparison.Ordinal) + : string.Compare(x, y, StringComparison.OrdinalIgnoreCase); } - - private readonly record struct CommandObjectCreationInfo(string? CommandNames, CommandObjectCreator Creator); } diff --git a/src/DotNetCampus.CommandLine/CommandRunnerBuilderExtensions.cs b/src/DotNetCampus.CommandLine/CommandRunnerBuilderExtensions.cs index 362e8afb..af41efd5 100644 --- a/src/DotNetCampus.CommandLine/CommandRunnerBuilderExtensions.cs +++ b/src/DotNetCampus.CommandLine/CommandRunnerBuilderExtensions.cs @@ -1,5 +1,6 @@ using System.ComponentModel; using DotNetCampus.Cli.Compiler; +using DotNetCampus.Cli.Utils.Handlers; namespace DotNetCampus.Cli; @@ -14,162 +15,283 @@ public static class CommandRunnerBuilderExtensions /// 命令行执行器构造的链式调用。 /// 命令处理器的类型。 /// 命令行执行器构造的链式调用。 - public static IAsyncCommandRunnerBuilder AddHandler(this ICoreCommandRunnerBuilder builder) - where T : class, ICommandHandler + public static IAsyncCommandRunnerBuilder AddHandler(this CommandLine builder) + { + throw CommandLine.MethodShouldBeInspected(); + } + + /// + /// 为命令执行器指定一个状态对象,后续添加的命令处理器如果被执行,将会收到这个状态对象。 + /// + /// 命令行执行器构造的链式调用。 + /// 状态对象。 + /// 状态对象的类型。 + /// 命令行执行器构造的链式调用。 + public static StatedCommandRunnerBuilder ForState(this CommandLine builder, TState state) + { + return new StatedCommandRunnerBuilder(((ICommandRunnerBuilder)builder).AsRunner(), state); + } + + /// + public static ICommandRunnerBuilder AddHandler(this CommandLine builder, Action handler) + { + throw CommandLine.MethodShouldBeInspected(); + } + + /// + public static ICommandRunnerBuilder AddHandler(this CommandLine builder, Func handler) + { + throw CommandLine.MethodShouldBeInspected(); + } + + /// + public static IAsyncCommandRunnerBuilder AddHandler(this CommandLine builder, Func handler) { - return builder.GetOrCreateRunner() - .AddHandler(); + throw CommandLine.MethodShouldBeInspected(); } /// /// 添加一个命令处理器。 /// /// 命令行执行器构造的链式调用。 - /// 由拦截器传入的的命令处理器的命令名称。 - /// 由拦截器传入的命令处理器创建方法。 + /// 用于处理已解析的命令行参数的委托。 /// 命令处理器的类型。 /// 命令行执行器构造的链式调用。 - [EditorBrowsable(EditorBrowsableState.Never)] - public static IAsyncCommandRunnerBuilder AddHandler(this ICoreCommandRunnerBuilder builder, - string? command, CommandObjectCreator creator) + public static IAsyncCommandRunnerBuilder AddHandler(this CommandLine builder, Func> handler) + { + throw CommandLine.MethodShouldBeInspected(); + } + + /// + /// 添加一个命令处理器。 + /// + /// 命令行执行器构造的链式调用。 + /// 命令处理器的类型。 + /// 命令行执行器构造的链式调用。 + public static IAsyncCommandRunnerBuilder AddHandler(this ICommandRunnerBuilder builder) where T : class, ICommandHandler { - return builder.GetOrCreateRunner() - .AddHandler(command, creator); + throw CommandLine.MethodShouldBeInspected(); } - /// - public static ICommandRunnerBuilder AddHandler(this ICoreCommandRunnerBuilder builder, Action handler) + /// + /// 为命令执行器指定一个状态对象,后续添加的命令处理器如果被执行,将会收到这个状态对象。 + /// + /// 命令行执行器构造的链式调用。 + /// 状态对象。 + /// 状态对象的类型。 + /// 命令行执行器构造的链式调用。 + public static StatedCommandRunnerBuilder ForState(this ICommandRunnerBuilder builder, TState state) + { + return new StatedCommandRunnerBuilder(builder.AsRunner(), state); + } + + /// + public static ICommandRunnerBuilder AddHandler(this ICommandRunnerBuilder builder, Action handler) where T : class { - return builder.GetOrCreateRunner() - .AddHandler(t => - { - handler(t); - return Task.FromResult(0); - }); + throw CommandLine.MethodShouldBeInspected(); } - /// + /// [EditorBrowsable(EditorBrowsableState.Never)] - public static ICommandRunnerBuilder AddHandler(this ICoreCommandRunnerBuilder builder, - string? command, CommandObjectCreator creator, Action handler) + public static ICommandRunnerBuilder AddHandler(this ICommandRunnerBuilder builder, Func handler) where T : class { - return builder.GetOrCreateRunner() - .AddHandler(command, creator, t => - { - handler(t); - return Task.FromResult(0); - }); + throw CommandLine.MethodShouldBeInspected(); } - /// - public static IAsyncCommandRunnerBuilder AddHandler(this IAsyncCommandRunnerBuilder builder, Action handler) + /// + public static IAsyncCommandRunnerBuilder AddHandler(this ICommandRunnerBuilder builder, Func handler) where T : class { - return (IAsyncCommandRunnerBuilder)((ICoreCommandRunnerBuilder)builder).AddHandler(handler); + throw CommandLine.MethodShouldBeInspected(); } - /// - [EditorBrowsable(EditorBrowsableState.Never)] - public static IAsyncCommandRunnerBuilder AddHandler(this IAsyncCommandRunnerBuilder builder, - string? command, CommandObjectCreator creator, Action handler) + /// + /// 添加一个命令处理器。 + /// + /// 命令行执行器构造的链式调用。 + /// 用于处理已解析的命令行参数的委托。 + /// 命令处理器的类型。 + /// 命令行执行器构造的链式调用。 + public static IAsyncCommandRunnerBuilder AddHandler(this ICommandRunnerBuilder builder, Func> handler) where T : class { - return (IAsyncCommandRunnerBuilder)((ICoreCommandRunnerBuilder)builder).AddHandler(command, creator, handler); + throw CommandLine.MethodShouldBeInspected(); } - /// - public static ICommandRunnerBuilder AddHandler(this ICoreCommandRunnerBuilder builder, Func handler) - where T : class + /// + public static IAsyncCommandRunnerBuilder AddHandler(this IAsyncCommandRunnerBuilder builder) + where T : class, ICommandHandler { - return builder.GetOrCreateRunner() - .AddHandler(t => Task.FromResult(handler(t))); + throw CommandLine.MethodShouldBeInspected(); } - /// - [EditorBrowsable(EditorBrowsableState.Never)] - public static ICommandRunnerBuilder AddHandler(this ICoreCommandRunnerBuilder builder, - string? command, CommandObjectCreator creator, Func handler) - where T : class + /// + /// 为命令执行器指定一个状态对象,后续添加的命令处理器如果被执行,将会收到这个状态对象。 + /// + /// 命令行执行器构造的链式调用。 + /// 状态对象。 + /// 状态对象的类型。 + /// 命令行执行器构造的链式调用。 + public static StatedCommandRunnerBuilder ForState(this IAsyncCommandRunnerBuilder builder, TState state) { - return builder.GetOrCreateRunner() - .AddHandler(command, creator, t => Task.FromResult(handler(t))); + return new StatedCommandRunnerBuilder(builder.AsRunner(), state); } - /// - public static IAsyncCommandRunnerBuilder AddHandler(this IAsyncCommandRunnerBuilder builder, Func handler) + /// + public static IAsyncCommandRunnerBuilder AddHandler(this IAsyncCommandRunnerBuilder builder, Action handler) where T : class { - return (IAsyncCommandRunnerBuilder)((ICoreCommandRunnerBuilder)builder).AddHandler(handler); + throw CommandLine.MethodShouldBeInspected(); } - /// - [EditorBrowsable(EditorBrowsableState.Never)] - public static IAsyncCommandRunnerBuilder AddHandler(this IAsyncCommandRunnerBuilder builder, - string? command, CommandObjectCreator creator, Func handler) + /// + public static IAsyncCommandRunnerBuilder AddHandler(this IAsyncCommandRunnerBuilder builder, Func handler) where T : class { - return (IAsyncCommandRunnerBuilder)((ICoreCommandRunnerBuilder)builder).AddHandler(command, creator, handler); + throw CommandLine.MethodShouldBeInspected(); } - /// - public static IAsyncCommandRunnerBuilder AddHandler(this ICoreCommandRunnerBuilder builder, Func handler) + /// + public static IAsyncCommandRunnerBuilder AddHandler(this IAsyncCommandRunnerBuilder builder, Func handler) where T : class { - return builder.GetOrCreateRunner() - .AddHandler(async t => - { - await handler(t); - return 0; - }); + throw CommandLine.MethodShouldBeInspected(); } - /// - [EditorBrowsable(EditorBrowsableState.Never)] - public static IAsyncCommandRunnerBuilder AddHandler(this ICoreCommandRunnerBuilder builder, - string? command, CommandObjectCreator creator, Func handler) + /// + public static IAsyncCommandRunnerBuilder AddHandler(this IAsyncCommandRunnerBuilder builder, Func> handler) where T : class { - return builder.GetOrCreateRunner() - .AddHandler(command, creator, async t => - { - await handler(t); - return 0; - }); + throw CommandLine.MethodShouldBeInspected(); } /// - /// 添加一个命令处理器。 + /// 由拦截器调用,用于添加一个命令处理器。 /// /// 命令行执行器构造的链式调用。 - /// 用于处理已解析的命令行参数的委托。 + /// 由拦截器传入的的命令处理器的命令, 或空字符串表示此处理器没有命令名称。 + /// 由拦截器传入的命令处理器创建方法。 + /// 如果有需要,拦截器可以传入一个已有的命令行执行器实例。 /// 命令处理器的类型。 /// 命令行执行器构造的链式调用。 - public static IAsyncCommandRunnerBuilder AddHandler(this ICoreCommandRunnerBuilder builder, Func> handler) + [EditorBrowsable(EditorBrowsableState.Never)] + public static IAsyncCommandRunnerBuilder AddHandler(this ICommandRunnerBuilder builder, + NamingPolicyNameGroup command, CommandObjectFactory factory, CommandHandlerRunner? runner = null + ) + where T : class, ICommandHandler + { + return builder.AsRunner() + .AddHandlerCore(command, factory, runner); + } + + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public static IAsyncCommandRunnerBuilder AddHandler(this IAsyncCommandRunnerBuilder builder, + NamingPolicyNameGroup command, CommandObjectFactory factory, CommandHandlerRunner? runner = null + ) + where T : class, ICommandHandler + { + return builder.AsRunner() + .AddHandlerCore(command, factory, runner); + } + + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public static ICommandRunnerBuilder AddHandler(this ICommandRunnerBuilder builder, Action handler, + NamingPolicyNameGroup command, CommandObjectFactory factory + ) + where T : class + { + return builder.AsRunner() + .AddHandlerCore(command, c => new AnonymousCommandHandler(c, factory, handler), null); + } + + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public static ICommandRunnerBuilder AddHandler(this ICommandRunnerBuilder builder, Func handler, + NamingPolicyNameGroup command, CommandObjectFactory factory + ) where T : class { - return builder.GetOrCreateRunner() - .AddHandler(handler); + return builder.AsRunner() + .AddHandlerCore(command, c => new AnonymousInt32CommandHandler(c, factory, handler), null); + } + + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public static IAsyncCommandRunnerBuilder AddHandler(this ICommandRunnerBuilder builder, Func handler, + NamingPolicyNameGroup command, CommandObjectFactory factory + ) + where T : class + { + return builder.AsRunner() + .AddHandlerCore(command, c => new AnonymousTaskCommandHandler(c, factory, handler), null); } /// - /// 添加一个命令处理器。 + /// 由拦截器调用,用于添加一个命令处理器。 /// /// 命令行执行器构造的链式调用。 - /// 由拦截器传入的的命令处理器的命令名称。 - /// 由拦截器传入的命令处理器创建方法。 /// 用于处理已解析的命令行参数的委托。 + /// 由拦截器传入的的命令处理器的命令, 或空字符串表示此处理器没有命令名称。 + /// 由拦截器传入的命令处理器创建方法。 /// 命令处理器的类型。 /// 命令行执行器构造的链式调用。 [EditorBrowsable(EditorBrowsableState.Never)] - public static IAsyncCommandRunnerBuilder AddHandler(this ICoreCommandRunnerBuilder builder, - string? command, CommandObjectCreator creator, Func> handler) + public static IAsyncCommandRunnerBuilder AddHandler(this ICommandRunnerBuilder builder, Func> handler, + NamingPolicyNameGroup command, CommandObjectFactory factory + ) + where T : class + { + return builder.AsRunner() + .AddHandlerCore(command, c => new AnonymousTaskInt32CommandHandler(c, factory, handler), null); + } + + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public static IAsyncCommandRunnerBuilder AddHandler(this IAsyncCommandRunnerBuilder builder, Action handler, + NamingPolicyNameGroup command, CommandObjectFactory factory + ) + where T : class + { + return builder.AsRunner() + .AddHandlerCore(command, c => new AnonymousCommandHandler(c, factory, handler), null); + } + + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public static IAsyncCommandRunnerBuilder AddHandler(this IAsyncCommandRunnerBuilder builder, Func handler, + NamingPolicyNameGroup command, CommandObjectFactory factory + ) + where T : class + { + return builder.AsRunner() + .AddHandlerCore(command, c => new AnonymousInt32CommandHandler(c, factory, handler), null); + } + + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public static IAsyncCommandRunnerBuilder AddHandler(this IAsyncCommandRunnerBuilder builder, Func handler, + NamingPolicyNameGroup command, CommandObjectFactory factory + ) + where T : class + { + return builder.AsRunner() + .AddHandlerCore(command, c => new AnonymousTaskCommandHandler(c, factory, handler), null); + } + + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public static IAsyncCommandRunnerBuilder AddHandler(this IAsyncCommandRunnerBuilder builder, Func> handler, + NamingPolicyNameGroup command, CommandObjectFactory factory + ) where T : class { - return builder.GetOrCreateRunner() - .AddHandler(command, creator, handler); + return builder.AsRunner() + .AddHandlerCore(command, c => new AnonymousTaskInt32CommandHandler(c, factory, handler), null); } /// @@ -178,11 +300,11 @@ public static IAsyncCommandRunnerBuilder AddHandler(this ICoreCommandRunnerBu /// 命令行执行器构造的链式调用。 /// 命令处理器集合的类型。 /// 命令行执行器构造的链式调用。 - public static IAsyncCommandRunnerBuilder AddHandlers(this ICoreCommandRunnerBuilder builder) - where T : ICommandHandlerCollection, new() + [Obsolete("我们正在考虑更好的实现方式。此前这个依赖于模块初始化器,但我们正在用拦截器替换它。", true)] + public static IAsyncCommandRunnerBuilder AddHandlers(this ICommandRunnerBuilder builder) + // where T : class, ICommandHandlerCollection, new() { - return builder.GetOrCreateRunner() - .AddHandlers(); + throw new NotImplementedException(); } /// @@ -193,7 +315,7 @@ public static IAsyncCommandRunnerBuilder AddHandlers(this ICoreCommandRunnerB /// 命令行执行器构造的链式调用。 /// 任何时候调用这个方法都会抛出这个异常。 [Obsolete("Considering that almost no developer thinks the behavior of this method meets expectations, we removed this feature.", true)] - public static IAsyncCommandRunnerBuilder AddStandardHandlers(this ICoreCommandRunnerBuilder builder) + public static IAsyncCommandRunnerBuilder AddStandardHandlers(this ICommandRunnerBuilder builder) { throw new NotSupportedException("Considering that almost no developer thinks the behavior of this method meets expectations, we removed this feature."); } diff --git a/src/DotNetCampus.CommandLine/Compiler/CollectCommandHandlersFromThisAssemblyAttribute.cs b/src/DotNetCampus.CommandLine/Compiler/CollectCommandHandlersFromThisAssemblyAttribute.cs deleted file mode 100644 index 26c98482..00000000 --- a/src/DotNetCampus.CommandLine/Compiler/CollectCommandHandlersFromThisAssemblyAttribute.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace DotNetCampus.Cli.Compiler; - -/// -/// 在一个 partial 类上标记,源生成器会自动查找此类型所在项目中所有支持的命令,并允许添加到 命令行解析器中执行。 -/// -[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)] -public class CollectCommandHandlersFromThisAssemblyAttribute : Attribute -{ -} diff --git a/src/DotNetCampus.CommandLine/Compiler/CommandAttribute.cs b/src/DotNetCampus.CommandLine/Compiler/CommandAttribute.cs index d9f6a4aa..a243e469 100644 --- a/src/DotNetCampus.CommandLine/Compiler/CommandAttribute.cs +++ b/src/DotNetCampus.CommandLine/Compiler/CommandAttribute.cs @@ -1,4 +1,6 @@ -namespace DotNetCampus.Cli.Compiler; +using System.Diagnostics; + +namespace DotNetCampus.Cli.Compiler; /// /// 将一个类绑定一个命令行命令。使用空格(` `)分隔多级子命令。 @@ -23,19 +25,26 @@ /// 多个 kebab-case 风格的词组以空格(` `)分隔,表示一个子命令或多级子命令。当启动程序传入多个命令且逐一匹配时,会匹配此类型。例如 `dotnet sln add`。 /// /// -[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)] -public sealed class CommandAttribute(string? names) : CommandLineAttribute +[Conditional("FOR_SOURCE_GENERATION_ONLY")] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = true, Inherited = false)] +public sealed class CommandAttribute(string? names = null) : CommandLineAttribute { /// /// 获取命令行的命令,可以是单个词组的主命令(Main Command),也可以是多个词组的子命令或多级子命令(Sub Command)。 /// public string? Names { get; } = names; + + /// + /// 实验性功能:完全使用栈上解析器,避免任何非业务的堆内存分配。 + /// + public bool ExperimentalUseFullStackParser { get; set; } } /// /// 将一个类绑定一个命令行命令。使用空格(` `)分隔多级子命令。 /// /// +[Conditional("FOR_SOURCE_GENERATION_ONLY")] [Obsolete("因为子命令(MainCommand/SubCommand)具有更主流和广泛的认知,所以我们采用新名字 CommandAttribute 来替代 VerbAttribute。")] [AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)] public sealed class VerbAttribute(string? name) : CommandLineAttribute diff --git a/src/DotNetCampus.CommandLine/Compiler/CommandLineAttribute.cs b/src/DotNetCampus.CommandLine/Compiler/CommandLineAttribute.cs index 14b888d5..e70e27e4 100644 --- a/src/DotNetCampus.CommandLine/Compiler/CommandLineAttribute.cs +++ b/src/DotNetCampus.CommandLine/Compiler/CommandLineAttribute.cs @@ -1,10 +1,12 @@ -using System.Diagnostics.CodeAnalysis; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; namespace DotNetCampus.Cli.Compiler; /// /// 为命令行参数与类型属性的关联提供特性基类。 /// +[Conditional("FOR_SOURCE_GENERATION_ONLY")] public abstract class CommandLineAttribute : Attribute { /// diff --git a/src/DotNetCampus.CommandLine/Compiler/CommandLineParsingExtensions.cs b/src/DotNetCampus.CommandLine/Compiler/CommandLineParsingExtensions.cs new file mode 100644 index 00000000..bd634df2 --- /dev/null +++ b/src/DotNetCampus.CommandLine/Compiler/CommandLineParsingExtensions.cs @@ -0,0 +1,41 @@ +namespace DotNetCampus.Cli.Compiler; + +/// +/// 提供一些扩展方法,辅助命令行解析器进行命令行解析。 +/// +public static class CommandLineParsingExtensions +{ + /// + /// 此命名风格是否支持 kebab-case 命名法。 + /// + /// 命名风格。 + /// 如果支持 kebab-case 命名法,则返回 ;否则返回 + /// + /// 由于我们已经约定在定义属性时,属性已经用 kebab-case 命名风格标记了名字,所以此选项实际上就是在判断是否使用定义的原样字符串。 + /// + public static bool SupportsOrdinal(this CommandNamingPolicy namingPolicy) + { + return namingPolicy switch + { + CommandNamingPolicy.KebabCase => true, + CommandNamingPolicy.Both => true, + CommandNamingPolicy.Ordinal => true, + _ => false, + }; + } + + /// + /// 此命名风格是否支持 PascalCase/camelCase 命名法。 + /// + /// 命名风格。 + /// 如果支持 PascalCase/camelCase 命名法,则返回 ;否则返回 + public static bool SupportsPascalCase(this CommandNamingPolicy namingPolicy) + { + return namingPolicy switch + { + CommandNamingPolicy.PascalCase => true, + CommandNamingPolicy.Both => true, + _ => false, + }; + } +} diff --git a/src/DotNetCampus.CommandLine/Compiler/CommandObjectCreator.cs b/src/DotNetCampus.CommandLine/Compiler/CommandObjectCreator.cs deleted file mode 100644 index 5a8ce5fb..00000000 --- a/src/DotNetCampus.CommandLine/Compiler/CommandObjectCreator.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace DotNetCampus.Cli.Compiler; - -/// -/// 从已解析的命令行参数创建命令数据模型或处理器的委托。 -/// -public delegate object CommandObjectCreator(CommandLine commandLine); diff --git a/src/DotNetCampus.CommandLine/Compiler/CommandObjectFactory.cs b/src/DotNetCampus.CommandLine/Compiler/CommandObjectFactory.cs new file mode 100644 index 00000000..b6b64aa5 --- /dev/null +++ b/src/DotNetCampus.CommandLine/Compiler/CommandObjectFactory.cs @@ -0,0 +1,31 @@ +namespace DotNetCampus.Cli.Compiler; + +/// +/// 从已解析的命令行参数创建命令数据模型或处理器的委托。 +/// +/// 命令数据模型或处理器的创建上下文。 +/// 命令数据模型或处理器。 +public delegate object CommandObjectFactory(CommandRunningContext context); + +/// +/// 运行命令处理器的委托。 +/// +/// 传递给命令处理器的状态对象。 +/// 命令处理器的返回值。 +public delegate Task CommandHandlerRunner(object state); + +/// +/// 命令执行的上下文。 +/// +public readonly struct CommandRunningContext +{ + /// + /// 已解析的命令行参数。 + /// + public required CommandLine CommandLine { get; init; } + + /// + /// 运行执行器的实例。如果直接通过 方法创建执行器,则该属性为 null。 + /// + internal CommandRunner? CommandRunner { get; init; } +} diff --git a/src/DotNetCampus.CommandLine/Compiler/ICommandHandlerCollection.cs b/src/DotNetCampus.CommandLine/Compiler/ICommandHandlerCollection.cs deleted file mode 100644 index f3d1987a..00000000 --- a/src/DotNetCampus.CommandLine/Compiler/ICommandHandlerCollection.cs +++ /dev/null @@ -1,84 +0,0 @@ -namespace DotNetCampus.Cli.Compiler; - -/// -/// 管理一组命令处理器的集合,在命令匹配的情况下辅助执行对应的命令处理器。 -/// -public interface ICommandHandlerCollection -{ - /// - /// 尝试匹配一个命令处理器。 - /// - /// - /// 可能的命令名称。 - /// - /// 可能是空字符串,表示只匹配默认命令。 - /// 可能包含无空格的名称,表示只匹配主命令。 - /// 可能包含有空格的名称,表示匹配多级命令。 - /// - /// - /// 已解析的命令行参数。 - /// 匹配的命令处理器,如果没有匹配的命令处理器,则返回 - ICommandHandler? TryMatch(string possibleCommandNames, CommandLine commandLine); -} - -internal static class CommandHandlerCollectionMatcher -{ - /// - /// 尝试匹配一个命令处理器。 - /// - /// 已解析的命令行参数。 - /// - /// 这是来自命令行传入的参数,一般来说会多于实际需要的命令层级数。(会多几个位置参数进来,但我们也不知道这位置参数有没有可能是命令啊) - /// - /// 当没有任何命令匹配时,使用的默认命令处理器创建器。 - /// 尝试匹配命令时,使用此集合中的命令处理器创建器。 - /// 匹配的命令处理器,如果没有匹配的命令处理器,则返回 - internal static ICommandHandler? TryMatch( - this CommandLine commandLine, - string possibleCommandNames, - CommandObjectCreator? defaultHandlerCreator, - IReadOnlyDictionary commandHandlerCreators) - { - var caseSensitive = commandLine.ParsingOptions.CaseSensitive; - if (string.IsNullOrEmpty(possibleCommandNames)) - { - return (ICommandHandler?)defaultHandlerCreator?.Invoke(commandLine); - } - - var bestMatchLength = -1; - var bestMatch = new KeyValuePair("", null!); - foreach (var pair in commandHandlerCreators) - { - var names = pair.Key; - var creator = pair.Value; - - // 检查是否为精确匹配或完整的前缀匹配(后面跟空格或结束) - var comparison = caseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; - bool isMatch = false; - - if (string.Equals(possibleCommandNames, names, comparison)) - { - // 完全匹配 - isMatch = true; - } - else if (possibleCommandNames.StartsWith(names, comparison)) - { - // 前缀匹配,但需要确保是完整单词匹配 - // 即命令名称后面必须是空格或字符串结束 - if (possibleCommandNames.Length > names.Length && possibleCommandNames[names.Length] == ' ') - { - isMatch = true; - } - } - - if (isMatch && names.Length > bestMatchLength) - { - bestMatchLength = names.Length; - bestMatch = new KeyValuePair(names, creator); - } - } - return bestMatch.Value is { } handlerCreator - ? (ICommandHandler)handlerCreator.Invoke(commandLine) - : null; - } -} diff --git a/src/DotNetCampus.CommandLine/Compiler/NamingPolicyNameGroup.cs b/src/DotNetCampus.CommandLine/Compiler/NamingPolicyNameGroup.cs new file mode 100644 index 00000000..94a6e1ae --- /dev/null +++ b/src/DotNetCampus.CommandLine/Compiler/NamingPolicyNameGroup.cs @@ -0,0 +1,33 @@ +namespace DotNetCampus.Cli.Compiler; + +/// +/// 同一个名称的不同命名法表示。 +/// +public readonly record struct NamingPolicyNameGroup +{ + /// + /// 创建一个新的命名组。 + /// + /// + /// + public NamingPolicyNameGroup(string ordinal, string pascalCase) + { + Ordinal = ordinal; + PascalCase = pascalCase; + } + + /// + /// 原始名称(我们将视之为 kebab-case)。 + /// + public string? Ordinal { get; } + + /// + /// kebab-case 名称,与 相同。 + /// + public string? KebabCase => Ordinal; + + /// + /// PascalCase 名称,基于 转换而来。 + /// + public string? PascalCase { get; } +} diff --git a/src/DotNetCampus.CommandLine/Compiler/OptionAttribute.cs b/src/DotNetCampus.CommandLine/Compiler/OptionAttribute.cs index a11e0bff..b6fbfe9f 100644 --- a/src/DotNetCampus.CommandLine/Compiler/OptionAttribute.cs +++ b/src/DotNetCampus.CommandLine/Compiler/OptionAttribute.cs @@ -1,3 +1,5 @@ +using System.Diagnostics; + namespace DotNetCampus.Cli.Compiler; /// @@ -35,6 +37,7 @@ namespace DotNetCampus.Cli.Compiler; /// do --property-name:key1=value1;key2=value2 /// /// +[Conditional("FOR_SOURCE_GENERATION_ONLY")] [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = false)] public sealed class OptionAttribute : CommandLineAttribute { @@ -43,92 +46,111 @@ public sealed class OptionAttribute : CommandLineAttribute /// public OptionAttribute() { + ShortNames = []; + LongNames = []; } /// - /// 标记一个属性为命令行选项,并具有指定的长名称。 + /// 标记一个属性为命令行选项,并具有指定的短名称。 /// /// 选项的短名称。必须是单个字符。 public OptionAttribute(char shortName) { - if (!char.IsLetter(shortName)) - { - throw new ArgumentException($"选项的短名称必须是字母字符,但实际为 '{shortName}'。", nameof(shortName)); - } - - ShortName = shortName; + ShortNames = [shortName.ToString()]; + LongNames = []; } /// /// 标记一个属性为命令行选项,并具有指定的长名称。 /// - /// - /// 选项名称。必须使用 kebab-case 命名规则,且不带 -- 前缀。 - /// + /// 选项名称。必须使用 kebab-case 命名规则,且不带 -- 前缀。 public OptionAttribute(string longName) { - LongName = longName; + ShortNames = []; + LongNames = [longName]; } /// /// 标记一个属性为命令行选项,并具有指定的长名称和短名称。 /// /// 选项的短名称。必须是单个字符。 - /// - /// 选项名称。必须使用 kebab-case 命名规则,且不带 -- 前缀。 - /// + /// 选项名称。必须使用 kebab-case 命名规则,且不带 -- 前缀。 public OptionAttribute(char shortName, string longName) { - if (!char.IsLetter(shortName)) - { - throw new ArgumentException($"选项的短名称必须是字母字符,但实际为 '{shortName}'。", nameof(shortName)); - } + ShortNames = [shortName.ToString()]; + LongNames = [longName]; + } - LongName = longName; - ShortName = shortName; + /// + /// 标记一个属性为命令行选项,并具有指定的长名称和短名称。 + /// + /// 选项的短名称。必须是单个字符。 + /// 选项名称。必须使用 kebab-case 命名规则,且不带 -- 前缀。 + public OptionAttribute(char shortName, string[] longNames) + { + ShortNames = [shortName.ToString()]; + LongNames = longNames; } /// - /// 获取或初始化选项的短名称。 + /// 标记一个属性为命令行选项,并具有指定的长名称和短名称。 /// - public char ShortName { get; } = '\0'; + /// 支持多字符的多个短名称,如用 -tl 来表示 --terminal-logger。 + /// 选项名称。必须使用 kebab-case 命名规则,且不带 -- 前缀。 + public OptionAttribute(string shortName, string longName) + { + ShortNames = [shortName]; + LongNames = [longName]; + } /// - /// 获取选项的长名称。 + /// 标记一个属性为命令行选项,并具有指定的长名称和短名称。 /// - public string? LongName { get; } + /// 支持多字符的多个短名称,如用 -tl 来表示 --terminal-logger。 + /// 选项名称。必须使用 kebab-case 命名规则,且不带 -- 前缀。 + public OptionAttribute(string shortName, string[] longNames) + { + ShortNames = [shortName]; + LongNames = longNames; + } /// - /// 获取或设置选项的别名。 + /// 标记一个属性为命令行选项,并具有指定的长名称和短名称。 /// - /// - /// 可以指定短名称(如 `v`)或长名称(如 `verbose`)。单个字符的别名会被视为短名称。
- /// 如果指定区分大小写,但期望允许部分单词使用多种大小写,则应该在别名中指定多个大小写形式。如将 `verbose` 的别名指定为 `verbose Verbose VERBOSE`。 - ///
- public string[] Aliases { get; init; } = []; + /// 支持多字符的多个短名称,如用 -tl 来表示 --terminal-logger。 + /// 选项名称。必须使用 kebab-case 命名规则,且不带 -- 前缀。 + public OptionAttribute(string[] shortNames, string longName) + { + ShortNames = shortNames; + LongNames = [longName]; + } /// - /// 获取或设置是否大小写敏感。 + /// 标记一个属性为命令行选项,并具有指定的长名称和短名称。 /// - /// - /// 默认情况下使用 解析时所指定的大小写敏感性(而 默认为大小写不敏感)。 - /// - public bool CaseSensitive { get; init; } + /// 支持多字符的多个短名称,如用 -tl 来表示 --terminal-logger。 + /// 选项名称。必须使用 kebab-case 命名规则,且不带 -- 前缀。 + public OptionAttribute(string[] shortNames, string[] longNames) + { + ShortNames = shortNames; + LongNames = longNames; + } + + /// + /// 获取或初始化选项的短名称。 + /// + public string[] ShortNames { get; } + + /// + /// 获取选项的长名称。 + /// + public string[] LongNames { get; } /// - /// 命令行参数中传入的选项名称必须严格保持与此属性中指定的长名称一致。 + /// 获取或设置是否大小写敏感。 /// /// - /// 默认情况下,我们会为了支持多种不同的命令行风格而自动识别选项的长名称,例如: - /// - /// 属性名 SampleProperty 可匹配:--Sample-Property --sample-property -SampleProperty - /// 属性名 sample-property 可匹配:--Sample-Property --sample-property -SampleProperty - /// - /// 但设置了此属性为 后,命令行中传入的选项名称必须完全一致: - /// - /// 属性名 SampleProperty 可匹配:--SampleProperty --sampleproperty -SampleProperty - /// 属性名 sample-property 可匹配:--Sample-Property --sample-property -Sample-Property - /// + /// 默认情况下使用 解析时所指定的大小写敏感性(而 默认为大小写不敏感)。 /// - public bool ExactSpelling { get; init; } + public bool CaseSensitive { get; init; } } diff --git a/src/DotNetCampus.CommandLine/Compiler/PropertyAssignments.cs b/src/DotNetCampus.CommandLine/Compiler/PropertyAssignments.cs new file mode 100644 index 00000000..49c8e341 --- /dev/null +++ b/src/DotNetCampus.CommandLine/Compiler/PropertyAssignments.cs @@ -0,0 +1,485 @@ +#if NETCOREAPP3_1_OR_GREATER +using System.Collections.Immutable; +#endif +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using DotNetCampus.Cli.Exceptions; + +namespace DotNetCampus.Cli.Compiler; + +/// +/// 专门解析来自命令行的布尔类型,并辅助赋值给属性。 +/// +[DebuggerDisplay("Boolean: {Value,nq}")] +public readonly record struct BooleanArgument +{ + /// + /// 存储解析到的布尔值。 + /// + private bool? Value { get; init; } + + /// + /// 当命令行直接或间接输入了一个布尔参数时,调用此方法赋值。 + /// + /// 解析到的布尔值。 + public BooleanArgument Assign(ReadOnlySpan value) + { + return value switch + { + // 因为解析器已经保证了布尔参数只可能出现以下三种值: + // - []: 表示 true + // - ['1', ..]: 表示 true + // - ['0', ..]: 表示 false + [] => new BooleanArgument { Value = true }, + ['1', ..] => new BooleanArgument { Value = true }, + _ => new BooleanArgument { Value = false }, + }; + } + + /// + /// 将解析到的值转换为布尔值。 + /// + public bool? ToBoolean() + { + return Value; + } +} + +/// +/// 专门解析来自命令行的数值类型,并辅助赋值给属性。 +/// +[DebuggerDisplay("Number: {Value,nq}")] +public readonly record struct NumberArgument +{ + /// + /// 指示在解析失败时是否忽略异常并保持未初始化的状态。 + /// + public bool IgnoreExceptions { get; init; } + + /// + /// 存储解析到的数值。 + /// + private decimal? Value { get; init; } + + /// + /// 当命令行输入了一个数值参数时,调用此方法赋值。 + /// + /// 解析到的数值字符串。 + public NumberArgument Assign(ReadOnlySpan value) + { + if (decimal.TryParse(value +#if !NETCOREAPP3_1_OR_GREATER + .ToString() +#endif + , out var doubleValue)) + { + return this with { Value = doubleValue }; + } + if (!IgnoreExceptions) + { + throw new CommandLineParseValueException($"无法将 \"{value.ToString()}\" 转换为数值。"); + } + return this; + } + + /// + /// 将解析到的值转换为字节。 + /// + public byte? ToByte() => (byte?)Value; + + /// + /// 将解析到的值转换为有符号字节。 + /// + public sbyte? ToSByte() => (sbyte?)Value; + + /// + /// 将解析到的值转换为高精度浮点数。 + /// + public decimal? ToDecimal() => Value; + + /// + /// 将解析到的值转换为双精度浮点数。 + /// + public double? ToDouble() => (double?)Value; + + /// + /// 将解析到的值转换为单精度浮点数。 + /// + public float? ToSingle() => (float?)Value; + + /// + /// 将解析到的值转换为 32 位整数。 + /// + public int? ToInt32() => (int?)Value; + + /// + /// 将解析到的值转换为无符号 32 位整数。 + /// + public uint? ToUInt32() => (uint?)Value; + + /// + /// 将解析到的值转换为指针大小的整数。 + /// + public nint? ToIntPtr() => (nint?)Value; + + /// + /// 将解析到的值转换为无符号指针大小的整数。 + /// + public nuint? ToUIntPtr() => (nuint?)Value; + + /// + /// 将解析到的值转换为 64 位整数。 + /// + public long? ToInt64() => (long?)Value; + + /// + /// 将解析到的值转换为无符号 64 位整数。 + /// + public ulong? ToUInt64() => (ulong?)Value; + + /// + /// 将解析到的值转换为 16 位整数。 + /// + public short? ToInt16() => (short?)Value; + + /// + /// 将解析到的值转换为无符号 16 位整数。 + /// + public ushort? ToUInt16() => (ushort?)Value; +} + +/// +/// 专门解析来自命令行的字符串类型,并辅助赋值给属性。 +/// +[DebuggerDisplay("String: {Value,nq}")] +public readonly record struct StringArgument +{ + /// + /// 指示在解析失败时是否忽略异常并保持未初始化的状态。 + /// + public bool IgnoreExceptions { get; init; } + + /// + /// 存储解析到的字符串值。 + /// + private string? Value { get; init; } + + /// + /// 当命令行输入了一个字符串参数时,调用此方法赋值。 + /// + /// 解析到的字符串值。 + public StringArgument Assign(ReadOnlySpan value) + { + return this with { Value = value.ToString() }; + } + + /// + /// 将解析到的值转换为字符。 + /// + /// 如果字符串长度为 1,则返回该字符;否则返回 null。 + public char? ToChar() => Value switch + { + null => null, + { Length: 1 } => Value[0], + _ when IgnoreExceptions => null, + _ => throw new CommandLineParseValueException($"无法将 \"{Value}\" 转换为字符,因为它的长度不为 1。"), + }; + + /// + /// 将解析到的值转换为字符串。 + /// + public override string? ToString() + { + return Value; + } +} + +/// +/// 专门解析来自命令行的字符串集合类型,并辅助赋值给属性。 +/// +[DebuggerDisplay("String: {Value,nq}")] +public readonly record struct StringListArgument +{ + /// + /// 存储解析到的字符串列表。 + /// + private List? Value { get; init; } + + /// + /// 当命令行输入了一个字符串参数时,调用此方法追加值。 + /// + /// 解析到的字符串值。 + public StringListArgument Append(ReadOnlySpan value) + { + var list = Value; + list ??= []; + list.Add(value.ToString()); + return new StringListArgument { Value = list }; + } + + /// + /// 将解析到的值转换为集合。 + /// + public Collection? ToCollection() => Value switch + { + null or { Count: 0 } => null, + { } values => [..values], + }; + + /// + /// 将解析到的值转换为字符串数组。 + /// + public string[]? ToArray() => Value switch + { + null or { Count: 0 } => null, + { } values => [..values], + }; + + /// + /// 将解析到的值转换为哈希集合。 + /// + public HashSet? ToHashSet() => Value switch + { + null or { Count: 0 } => null, + { } values => [..values], + }; + + /// + /// 将解析到的值转换为列表。 + /// + public List? ToList() => Value switch + { + null or { Count: 0 } => null, + { } values => values, + }; + + /// + /// 将解析到的值转换为只读集合。 + /// + public ReadOnlyCollection? ToReadOnlyCollection() => Value switch + { + null or { Count: 0 } => null, + { } values => new ReadOnlyCollection(values), + }; + + /// + /// 将解析到的值转换为排序集合。 + /// + public SortedSet? ToSortedSet() => Value switch + { + null or { Count: 0 } => null, + { } values => [..values], + }; + +#if NETCOREAPP3_1_OR_GREATER + /// + /// 将解析到的值转换为不可变数组。 + /// + public ImmutableArray? ToImmutableArray(bool notNull = false) => Value switch + { +#if NET8_0_OR_GREATER + null or { Count: 0 } => notNull ? ImmutableArray.Empty : null, + { } values => [..values], +#else + null or { Count: 0 } => null, + { } values => values.ToImmutableArray(), +#endif + }; + + /// + /// 将解析到的值转换为不可变列表。 + /// + public ImmutableList? ToImmutableList(bool notNull = false) => Value switch + { +#if NET8_0_OR_GREATER + null or { Count: 0 } => notNull ? ImmutableList.Empty : null, + { } values => [..values], +#else + null or { Count: 0 } => null, + { } values => values.ToImmutableList(), +#endif + }; + + /// + /// 将解析到的值转换为不可变排序集合。 + /// + public ImmutableSortedSet? ToImmutableSortedSet(bool notNull = false) => Value switch + { +#if NET8_0_OR_GREATER + null or { Count: 0 } => notNull ? ImmutableSortedSet.Empty : null, + { } values => [..values], +#else + null or { Count: 0 } => null, + { } values => values.ToImmutableSortedSet(), +#endif + }; + + /// + /// 将解析到的值转换为不可变哈希集合。 + /// + public ImmutableHashSet? ToImmutableHashSet(bool notNull = false) => Value switch + { +#if NET8_0_OR_GREATER + null or { Count: 0 } => notNull ? ImmutableHashSet.Empty : null, + { } values => [..values], +#else + null or { Count: 0 } => null, + { } values => values.ToImmutableHashSet(), +#endif + }; + +#endif +} + +/// +/// 专门解析来自命令行的字典类型,并辅助赋值给属性。 +/// +[DebuggerDisplay("String: {Value,nq}")] +public readonly record struct StringDictionaryArgument +{ + /// + /// 存储解析到的字符串字典。 + /// + private Dictionary Value { get; init; } + + /// + /// 当命令行输入了一个键值对参数时,调用此方法追加值。 + /// + /// 解析到的键。 + /// 解析到的值。 + public StringDictionaryArgument Append(ReadOnlySpan key, ReadOnlySpan value) + { + var dictionary = Value; + dictionary ??= []; + dictionary[key.ToString()] = value.ToString(); + return new StringDictionaryArgument { Value = dictionary }; + } + + /// + /// 将解析到的值转换为键值对。 + /// + public KeyValuePair? ToKeyValuePair() + { + if (Value is null || Value.Count == 0) + { + return null; + } + + if (Value.Count > 1) + { + throw new CommandLineParseValueException("字典包含多个元素,无法转换为 KeyValuePair。"); + } + + using var enumerator = Value.GetEnumerator(); + enumerator.MoveNext(); + return enumerator.Current; + } + + /// + /// 将解析到的值转换为字典。 + /// + public Dictionary? ToDictionary() + { + return Value; + } + + /// + /// 将解析到的值转换为排序字典。 + /// + public SortedDictionary? ToSortedDictionary() => Value switch + { + null or { Count: 0 } => null, + { } values => new SortedDictionary(values), + }; + +#if NETCOREAPP3_1_OR_GREATER + /// + /// 将解析到的值转换为不可变字典。 + /// + public ImmutableDictionary? ToImmutableDictionary(bool notNull = false) => Value switch + { + null or { Count: 0 } => notNull ? ImmutableDictionary.Empty : null, + { } values => values.ToImmutableDictionary(), + }; + + /// + /// 将解析到的值转换为不可变排序字典。 + /// + public ImmutableSortedDictionary? ToImmutableSortedDictionary(bool notNull = false) => Value switch + { + null or { Count: 0 } => notNull ? ImmutableSortedDictionary.Empty : null, + { } values => values.ToImmutableSortedDictionary(), + }; +#endif +} + +/// +/// 专门解析来自命令行的错误参数,并假装赋值给属性。 +/// +public readonly record struct ErrorArgument +{ + /// + /// 当命令行属性赋值不受支持时,调用此方法假装赋值。 + /// + /// 传什么值进来都当作没看见。 + public ErrorArgument Assign(ReadOnlySpan value) + { + return this; + } + + /// + /// 将解析到的值转换为字符串。 + /// + [DoesNotReturn] + public object ToUnknown() + { + throw new CommandLineParseValueException("命令行属性赋值不受支持。"); + } +} + +/// +/// 在运行时解析来自命令行的枚举类型,并辅助赋值给属性。 +/// +/// +/// 源生成器会为各个枚举生成专门的编译时类型来处理枚举的赋值。
+/// 此类型是为那些在运行时才知道枚举类型的场景准备的。 +///
+public readonly record struct RuntimeEnumArgument where T : unmanaged, Enum +{ + /// + /// 指示在解析失败时是否忽略异常并保持未初始化的状态。 + /// + public bool IgnoreExceptions { get; init; } + + /// + /// 存储解析到的枚举值。 + /// + private T? Value { get; init; } + + /// + /// 当命令行输入了一个数值参数时,调用此方法赋值。 + /// + /// 解析到的数值字符串。 + public RuntimeEnumArgument Assign(ReadOnlySpan value) + { + if (Enum.TryParse(value +#if !NET6_0_OR_GREATER + .ToString() +#endif + , ignoreCase: true, out var enumValue)) + { + return this with { Value = enumValue }; + } + if (!IgnoreExceptions) + { + throw new CommandLineParseValueException($"无法将 \"{value.ToString()}\" 转换为 {typeof(T).FullName} 枚举。"); + } + return this; + } + + /// + /// 将解析到的值转换为枚举。 + /// + public T? ToEnum() => Value; +} diff --git a/src/DotNetCampus.CommandLine/Compiler/PropertyMatching.cs b/src/DotNetCampus.CommandLine/Compiler/PropertyMatching.cs new file mode 100644 index 00000000..f8983925 --- /dev/null +++ b/src/DotNetCampus.CommandLine/Compiler/PropertyMatching.cs @@ -0,0 +1,63 @@ +namespace DotNetCampus.Cli.Compiler; + +/// +/// 选项值的类型。此枚举中的选项值类型会影响到选项值的解析方式。 +/// +public enum OptionValueType : byte +{ + /// + /// 普通值。只解析一个参数。 + /// + Normal, + + /// + /// 布尔值。会尝试解析一个参数,如果无法解析,则视为 。 + /// + Boolean, + + /// + /// 集合值。会尝试解析多个参数,直到遇到下一个选项或位置参数分隔符为止。 + /// + List, + + /// + /// 字典值。会尝试解析多个键值对,直到遇到下一个选项或位置参数分隔符为止。 + /// + Dictionary, + + /// + /// 用户输入的选项没有命中到任何已知的选项。 + /// + NotExist, + + /// + /// 用户输入的选项没有命中到任何已知的选项,并且开发者认为此选项也不应该接受任何值。 + /// + NotExistAndTakesNoValue, + + /// + /// 用户输入的选项没有命中到任何已知的选项,但开发者认为此选项可以接受一个可选值。 + /// + NotExistButTakesOptionalValue, + + /// + /// 用户输入的选项没有命中到任何已知的选项,但开发者认为此选项可以接受多个值。 + /// + NotExistButTakesAllValues, +} + +/// +/// 位置参数值的类型。此枚举中的位置参数值类型会影响到位置参数值的解析方式。 +/// +public enum PositionalArgumentValueType : byte +{ + /// + /// 正常的位置参数。 + /// + Normal, + + /// + /// 指定位置处的位置参数没有匹配到任何位置参数范围。 + /// + NotExist, +} diff --git a/src/DotNetCampus.CommandLine/Compiler/RawArgumentsAttribute.cs b/src/DotNetCampus.CommandLine/Compiler/RawArgumentsAttribute.cs index 8b0b51a0..d38aefc6 100644 --- a/src/DotNetCampus.CommandLine/Compiler/RawArgumentsAttribute.cs +++ b/src/DotNetCampus.CommandLine/Compiler/RawArgumentsAttribute.cs @@ -1,9 +1,18 @@ +using System.Diagnostics; + namespace DotNetCampus.Cli.Compiler; /// /// 标记在一个 string[] 或 IReadOnlyList<string> 类型的属性上,表示此属性将接收保留的原始命令行参数。 /// +[Conditional("FOR_SOURCE_GENERATION_ONLY")] [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = false)] public sealed class RawArgumentsAttribute : CommandLineAttribute { + /// + /// 设置为 时,如果传入 URL,则会自动将其转换为普通的命令行参数列表(选项自动添加 -- 前缀,但不会改变命名规则)。
+ /// 设置为 时,则不会转换 URL,Main 方法传入时收到什么就是什么。
+ /// 默认值为 。 + ///
+ public bool ConvertUrl { get; set; } = true; } diff --git a/src/DotNetCampus.CommandLine/Compiler/ValueAttribute.cs b/src/DotNetCampus.CommandLine/Compiler/ValueAttribute.cs index 76c9d14f..25991652 100644 --- a/src/DotNetCampus.CommandLine/Compiler/ValueAttribute.cs +++ b/src/DotNetCampus.CommandLine/Compiler/ValueAttribute.cs @@ -1,3 +1,5 @@ +using System.Diagnostics; + namespace DotNetCampus.Cli.Compiler; /// @@ -28,6 +30,7 @@ namespace DotNetCampus.Cli.Compiler; /// ImmutableDictionary<string, string> /// /// +[Conditional("FOR_SOURCE_GENERATION_ONLY")] [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = false)] public sealed class ValueAttribute : CommandLineAttribute { diff --git a/src/DotNetCampus.CommandLine/DotNetCampus.CommandLine.csproj b/src/DotNetCampus.CommandLine/DotNetCampus.CommandLine.csproj index bb0eae71..3ca002a6 100644 --- a/src/DotNetCampus.CommandLine/DotNetCampus.CommandLine.csproj +++ b/src/DotNetCampus.CommandLine/DotNetCampus.CommandLine.csproj @@ -4,8 +4,8 @@ net8.0;net6.0;net5.0;netcoreapp3.1;netstandard2.0 enable @@ -16,21 +16,27 @@ snupkg true true - true - false + false + true true - + + $(NoWarn);CS8767 - + + + + $(NoWarn);CS8785 + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/DotNetCampus.CommandLine/Exceptions/CommandLineParseException.cs b/src/DotNetCampus.CommandLine/Exceptions/CommandLineParseException.cs index f78ca510..dfcfe59f 100644 --- a/src/DotNetCampus.CommandLine/Exceptions/CommandLineParseException.cs +++ b/src/DotNetCampus.CommandLine/Exceptions/CommandLineParseException.cs @@ -1,4 +1,6 @@ -namespace DotNetCampus.Cli.Exceptions; +using DotNetCampus.Cli.Utils.Parsers; + +namespace DotNetCampus.Cli.Exceptions; /// /// 在解析命令行参数的过程中发生的异常。 @@ -7,6 +9,11 @@ public class CommandLineParseException : CommandLineException { private const string DefaultMessage = "Parse the command line failed."; + /// + /// 获取导致异常的命令行解析错误类型。 + /// + public CommandLineParsingError Reason { get; } + /// /// 初始化 类的新实例。 /// @@ -22,6 +29,16 @@ public CommandLineParseException(string message) : base(message) { } + /// + /// 初始化 类的新实例。 + /// + /// 导致异常的命令行解析错误类型。 + /// 异常消息。 + public CommandLineParseException(CommandLineParsingError reason, string message) : base(message) + { + Reason = reason; + } + /// /// 初始化 类的新实例。 /// @@ -54,6 +71,15 @@ public CommandLineParseValueException(string message) : base(message) { } + /// + /// 初始化 类的新实例。 + /// + /// 导致异常的命令行解析错误类型。 + /// 异常消息。 + public CommandLineParseValueException(CommandLineParsingError reason, string message) : base(reason, message) + { + } + /// /// 初始化 类的新实例。 /// diff --git a/src/DotNetCampus.CommandLine/ICommandHandler.cs b/src/DotNetCampus.CommandLine/ICommandHandler.cs index f41d0659..8d585e36 100644 --- a/src/DotNetCampus.CommandLine/ICommandHandler.cs +++ b/src/DotNetCampus.CommandLine/ICommandHandler.cs @@ -7,6 +7,13 @@ public interface ICommandOptions { } +/// +/// 表示可以接收命令行参数,然后带着额外状态处理一条命令。 +/// +public interface IStatedCommandHandler : ICommandOptions +{ +} + /// /// 表示可以接收命令行参数,然后处理一条命令。 /// @@ -18,3 +25,16 @@ public interface ICommandHandler : ICommandOptions /// 返回处理结果。 Task RunAsync(); } + +/// +/// 表示可以接收命令行参数,然后带着额外状态处理一条命令。 +/// +public interface ICommandHandler : IStatedCommandHandler +{ + /// + /// 处理一条命令。 + /// + /// 额外状态。 + /// 返回处理结果。 + Task RunAsync(T state); +} diff --git a/src/DotNetCampus.CommandLine/ICommandRunnerBuilder.cs b/src/DotNetCampus.CommandLine/ICommandRunnerBuilder.cs index b985f3ce..defb5c7d 100644 --- a/src/DotNetCampus.CommandLine/ICommandRunnerBuilder.cs +++ b/src/DotNetCampus.CommandLine/ICommandRunnerBuilder.cs @@ -1,4 +1,7 @@ -namespace DotNetCampus.Cli; +using System.ComponentModel; +using DotNetCampus.Cli.Compiler; + +namespace DotNetCampus.Cli; /// /// 命令行执行器构造器,用于链式创建命令行执行器。 @@ -9,7 +12,7 @@ public interface ICoreCommandRunnerBuilder /// 获取或创建一个命令行执行器。 /// /// 命令行执行器。 - internal CommandRunner GetOrCreateRunner(); + internal CommandRunner AsRunner(); } /// @@ -21,7 +24,7 @@ public interface ICommandRunnerBuilder : ICoreCommandRunnerBuilder /// 以同步方式运行命令行处理器。 /// /// 将被执行的命令行处理器的返回值。 - int Run(); + CommandRunningResult Run(); } /// @@ -33,5 +36,136 @@ public interface IAsyncCommandRunnerBuilder : ICoreCommandRunnerBuilder /// 以异步方式运行命令行处理器。 /// /// 将被执行的命令行处理器的返回值。 - Task RunAsync(); + Task RunAsync(); +} + +/// +/// 带有状态的命令行执行器构造器,专门用来添加执行时需要额外状态的命令处理器。 +/// +/// 状态对象的类型。 +public readonly ref struct StatedCommandRunnerBuilder +{ + private readonly IAsyncCommandRunnerBuilder _builder; + private readonly TState _state; + + /// + /// 创建一个带有状态的命令行执行器构造器。 + /// + /// 命令行执行器构造器。 + /// 状态对象。 + public StatedCommandRunnerBuilder(IAsyncCommandRunnerBuilder builder, TState state) + { + _builder = builder; + _state = state; + } + + /// + /// 添加一个带有状态的命令处理器。 + /// + /// 带有状态的命令处理器的类型。 + /// 命令行执行器构造的链式调用。 + public StatedCommandRunnerLinkedBuilder AddHandler() + where T : class, ICommandHandler + { + throw CommandLine.MethodShouldBeInspected(); + } + + /// + /// 由拦截器调用,用于添加一个带有状态的命令处理器。 + /// + /// 由拦截器传入的的命令处理器的命令, 或空字符串表示此处理器没有命令名称。 + /// 由拦截器传入的命令处理器创建方法。 + /// 带有状态的命令处理器的类型。 + /// 命令行执行器构造的链式调用。 + [EditorBrowsable(EditorBrowsableState.Never)] + public StatedCommandRunnerLinkedBuilder AddHandler( + NamingPolicyNameGroup command, CommandObjectFactory factory + ) + where T : class, ICommandHandler + { + var state = _state; + _builder.AsRunner() + .AddHandlerCore(command, factory, o => ((T)o).RunAsync(state)); + return new StatedCommandRunnerLinkedBuilder(_builder, _state); + } +} + +/// +/// 带有状态的命令行执行器构造器,专门用来添加执行时需要额外状态的命令处理器。 +/// +/// 状态对象的类型。 +public readonly ref struct StatedCommandRunnerLinkedBuilder +{ + private readonly IAsyncCommandRunnerBuilder _builder; + private readonly TState _state; + + /// + /// 创建一个带有状态的命令行执行器构造器。 + /// + /// 命令行执行器构造器。 + /// 状态对象。 + public StatedCommandRunnerLinkedBuilder(IAsyncCommandRunnerBuilder builder, TState state) + { + _builder = builder; + _state = state; + } + + /// + /// 为命令执行器指定一个新的状态对象,后续添加的命令处理器如果被执行,将会收到这个新的状态对象。 + /// + /// 新的状态对象。 + /// 新的状态对象的类型。 + /// 命令行执行器构造的链式调用。 + public StatedCommandRunnerBuilder ForState(TAnotherState state) + { + return new StatedCommandRunnerBuilder(_builder, state); + } + + /// + /// 返回不带状态的命令行执行器构造器,后续添加的命令处理器将不会收到状态对象。 + /// + /// 命令行执行器构造的链式调用。 + public IAsyncCommandRunnerBuilder ForState() + { + return _builder; + } + + /// + /// 添加一个带有状态的命令处理器。 + /// + /// 带有状态的命令处理器的类型。 + /// 命令行执行器构造的链式调用。 + public StatedCommandRunnerLinkedBuilder AddHandler() + where T : class, ICommandHandler + { + throw CommandLine.MethodShouldBeInspected(); + } + + /// + /// 由拦截器调用,用于添加一个带有状态的命令处理器。 + /// + /// 由拦截器传入的的命令处理器的命令, 或空字符串表示此处理器没有命令名称。 + /// 由拦截器传入的命令处理器创建方法。 + /// 带有状态的命令处理器的类型。 + /// 命令行执行器构造的链式调用。 + [EditorBrowsable(EditorBrowsableState.Never)] + public StatedCommandRunnerLinkedBuilder AddHandler( + NamingPolicyNameGroup command, CommandObjectFactory factory + ) + where T : class, ICommandHandler + { + var state = _state; + _builder.AsRunner() + .AddHandlerCore(command, factory, o => ((T)o).RunAsync(state)); + return this; + } + + /// + /// 以异步方式运行命令行处理器。 + /// + /// 将被执行的命令行处理器的返回值。 + public Task RunAsync() + { + return _builder.RunAsync(); + } } diff --git a/src/DotNetCampus.CommandLine/Properties/Compatibility.cs b/src/DotNetCampus.CommandLine/Properties/Compatibility.cs new file mode 100644 index 00000000..729274c1 --- /dev/null +++ b/src/DotNetCampus.CommandLine/Properties/Compatibility.cs @@ -0,0 +1,20 @@ +global using DotNetCampus.Cli.Properties; + +namespace DotNetCampus.Cli.Properties; + +internal static class CompatibilityExtensionMethods +{ +#if NETCOREAPP3_1_OR_GREATER +#else + internal static bool TryAdd(this IDictionary dictionary, TKey key, TValue value) + where TKey : notnull + { + if (dictionary.ContainsKey(key)) + { + return false; + } + dictionary.Add(key, value); + return true; + } +#endif +} diff --git a/src/DotNetCampus.CommandLine/Utils/BooleanValues16.cs b/src/DotNetCampus.CommandLine/Utils/BooleanValues16.cs new file mode 100644 index 00000000..5fe10942 --- /dev/null +++ b/src/DotNetCampus.CommandLine/Utils/BooleanValues16.cs @@ -0,0 +1,82 @@ +using System.Diagnostics.Contracts; + +namespace DotNetCampus.Cli.Utils; + +/// +/// 用节省空间的方式存储多个布尔值。 +/// +internal struct BooleanValues16() +{ + private ushort _value; + + internal BooleanValues16(ushort packedValue) : this() + { + _value = packedValue; + } + + /// + /// 获取或设置指定索引处的布尔值。 + /// + /// 索引,范围 0-15。 + internal bool this[int index] + { + get => (_value & (1 << index)) != 0; + set + { + if (value) + { + _value |= (ushort)(1 << index); + } + else + { + _value &= (ushort)~(1 << index); + } + } + } + + /// + /// 获取或设置指定索引处的两个布尔值。 + /// + /// 索引,范围 0-14。 + /// 必须等于 + 1。 + internal (bool Item1, bool Item2) this[int index, int index1] + { + get + { + var bits = (_value & (3 << index)) >> index; + return ((bits & 1) != 0, (bits & 2) != 0); + } + set + { + var bits = (value.Item1 ? 1 : 0) | (value.Item2 ? 2 : 0); + _value = (ushort)((_value & ~(3 << index)) | (bits << index)); + } + } + + /// + /// 获取或设置指定索引处的三个布尔值。 + /// + /// 索引,范围 0-13。 + /// 必须等于 + 1。 + /// 必须等于 + 2。 + internal (bool Item1, bool Item2, bool Item3) this[int index, int index1, int index2] + { + get + { + var bits = (_value & (7 << index)) >> index; + return ((bits & 1) != 0, (bits & 2) != 0, (bits & 4) != 0); + } + set + { + var bits = (value.Item1 ? 1 : 0) | (value.Item2 ? 2 : 0) | (value.Item3 ? 4 : 0); + _value = (ushort)((_value & ~(7 << index)) | (bits << index)); + } + } + + /// + /// 获取用于存储布尔值的魔术数字。 + /// + /// 魔术数字。 + [Pure] + internal ushort GetMagicNumber() => _value; +} diff --git a/src/DotNetCampus.CommandLine/Utils/Collections/OptionDictionary.cs b/src/DotNetCampus.CommandLine/Utils/Collections/OptionDictionary.cs deleted file mode 100644 index 33c3f0b6..00000000 --- a/src/DotNetCampus.CommandLine/Utils/Collections/OptionDictionary.cs +++ /dev/null @@ -1,343 +0,0 @@ -using System.Collections; -using System.Diagnostics.CodeAnalysis; - -namespace DotNetCampus.Cli.Utils.Collections; - -/// -/// 为命令行选项特别优化的字典。优化了无值/单值的内存占用和拷贝,优化了多种不同的选项命名风格,优化了大小写敏感性。 -/// -internal class OptionDictionary(bool caseSensitive) : IReadOnlyDictionary> -{ - public static OptionDictionary Empty { get; } = new OptionDictionary(true); - - private readonly List>> _optionValues = []; - - private readonly StringComparison _stringComparer = caseSensitive - ? StringComparison.Ordinal - : StringComparison.OrdinalIgnoreCase; - - private OptionDictionary(bool caseSensitive, List>> optionValues) : this(caseSensitive) - { - _optionValues = optionValues; - } - - public int Count => _optionValues.Count; - - public IReadOnlyList this[string key] - { - get - { - for (var i = 0; i < _optionValues.Count; i++) - { - var pair = _optionValues[i]; - if (string.Equals(pair.Key, key, _stringComparer)) - { - return pair.Value; - } - } - - throw new KeyNotFoundException($"Option '{key}' not found."); - } - } - - public IEnumerable Keys - { - get - { - for (var i = 0; i < _optionValues.Count; i++) - { - var pair = _optionValues[i]; - yield return pair.Key; - } - } - } - - public IEnumerable> Values - { - get - { - for (var i = 0; i < _optionValues.Count; i++) - { - var pair = _optionValues[i]; - yield return pair.Value; - } - } - } - - public bool ContainsKey(string key) - { - for (var i = 0; i < _optionValues.Count; i++) - { - var pair = _optionValues[i]; - if (string.Equals(pair.Key, key, _stringComparer)) - { - return true; - } - } - return false; - } - - public bool TryGetValue(string key, [MaybeNullWhen(false)] out IReadOnlyList value) - { - for (var i = 0; i < _optionValues.Count; i++) - { - var pair = _optionValues[i]; - if (string.Equals(pair.Key, key, _stringComparer)) - { - value = pair.Value; - return true; - } - } - - value = null; - return false; - } - - public void AddOption(OptionName optionName) - { - var optionNameText = optionName.ToString(); - var index = _optionValues.FindIndex(p => string.Equals(p.Key, optionNameText, _stringComparer)); - if (index < 0) - { - _optionValues.Add(new KeyValuePair>(optionNameText, [])); - } - } - - public void AddValue(OptionName optionName, string value) - { - var optionNameText = optionName.ToString(); - var index = _optionValues.FindIndex(p => string.Equals(p.Key, optionNameText, _stringComparer)); - if (index >= 0) - { - _optionValues[index] = new KeyValuePair>(optionNameText, _optionValues[index].Value.Add(value)); - } - else - { - _optionValues.Add(new KeyValuePair>(optionNameText, new SingleOptimizedList(value))); - } - } - - public void AddValues(OptionName optionName, IReadOnlyList values) - { - var optionNameText = optionName.ToString(); - var index = _optionValues.FindIndex(p => string.Equals(p.Key, optionNameText, _stringComparer)); - if (index >= 0) - { - _optionValues[index] = new KeyValuePair>(optionNameText, _optionValues[index].Value.AddRange(values)); - } - else - { - _optionValues.Add(new KeyValuePair>(optionNameText, new SingleOptimizedList().AddRange(values))); - } - } - - public void UpdateValue(OptionName optionName, string value) - { - var optionNameText = optionName.ToString(); - var index = _optionValues.FindIndex(p => string.Equals(p.Key, optionNameText, _stringComparer)); - if (index >= 0) - { - _optionValues[index] = new KeyValuePair>(optionNameText, new SingleOptimizedList(value)); - } - else - { - _optionValues.Add(new KeyValuePair>(optionNameText, new SingleOptimizedList(value))); - } - } - - /// - /// 保留当前字典的所有内容,但返回一个新字典,使用指定的大小写敏感性来查询选项的值。 - /// - /// 新的大小写敏感性。 - /// 原有字典内容但新的查询方式的字典。 - public OptionDictionary ToOptionLookup(bool newCaseSensitive) - { - if (newCaseSensitive == caseSensitive) - { - return this; - } - - return new OptionDictionary(newCaseSensitive, _optionValues); - } - - public IEnumerator>> GetEnumerator() - { - for (var i = 0; i < _optionValues.Count; i++) - { - var pair = _optionValues[i]; - yield return new KeyValuePair>(pair.Key, pair.Value); - } - } - - IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); -} - -internal readonly record struct OptionName(string Argument, Range Range) : IEnumerable -{ - public char this[int index] - { - get - { - var (offset, length) = Range.GetOffsetAndLength(Argument.Length); - var realIndex = index + offset; - if (realIndex < offset || realIndex >= offset + length) - { - throw new ArgumentOutOfRangeException(nameof(index), $"Index {index} is out of range."); - } - return Argument[realIndex]; - } - } - -#if NET8_0_OR_GREATER - public ReadOnlySpan AsSpan() => Argument.AsSpan(Range); -#else - public ReadOnlySpan AsSpan() - { - var (offset, length) = Range.GetOffsetAndLength(Argument.Length); - return Argument.AsSpan(offset, length); - } -#endif - - public bool Equals(OptionName? other) - { - if (other is null) - { - return false; - } - - var (thisOffset, thisLength) = Range.GetOffsetAndLength(Argument.Length); - var (thatOffset, thatLength) = other.Value.Range.GetOffsetAndLength(other.Value.Argument.Length); - if (thisLength != thatLength) - { - return false; - } - - for (var i = 0; i < thisLength; i++) - { - if (Argument[thisOffset + i] != other.Value.Argument[thatOffset + i]) - { - return false; - } - } - - return true; - } - - public bool Equals(OptionName other, bool caseSensitive) - { - var (thisOffset, thisLength) = Range.GetOffsetAndLength(Argument.Length); - var (thatOffset, thatLength) = other.Range.GetOffsetAndLength(other.Argument.Length); - if (thisLength != thatLength) - { - return false; - } - - for (var i = 0; i < thisLength; i++) - { - var thisChar = Argument[thisOffset + i]; - var thatChar = other.Argument[thatOffset + i]; - if (thisChar != thatChar) - { - if (caseSensitive) - { - return false; - } - if (char.ToLowerInvariant(thisChar) != char.ToLowerInvariant(thatChar)) - { - return false; - } - } - } - - return true; - } - - public IEnumerator GetEnumerator() - { - var (offset, length) = Range.GetOffsetAndLength(Argument.Length); - for (var i = offset; i < offset + length; i++) - { - yield return Argument[i]; - } - } - - IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); - - public override string ToString() => AsSpan().ToString(); - - public static implicit operator OptionName(string optionName) => new OptionName(optionName, Range.All); - - public static implicit operator OptionName(char optionName) => new OptionName(optionName.ToString(), Range.All); - - public static bool IsValidOptionName(ReadOnlySpan span) - { - if (span.Length == 0) - { - return false; - } - if (!char.IsLetterOrDigit(span[0])) - { - return false; - } - for (var i = 1; i < span.Length; i++) - { - var c = span[i]; - if (!(char.IsLetterOrDigit(c) || c is '-' or '_')) - { - return false; - } - } - return true; - } - - public static OptionName MakeKebabCase(ReadOnlySpan span, bool isUpperSeparator) - { - var name = NamingHelper.MakeKebabCase(span.ToString(), isUpperSeparator, false); - return new OptionName(name, Range.All); - } - - public static string MakeKebabCase(ReadOnlySpan span) - { - Span builder = stackalloc char[span.Length * 2]; - var needSeparator = false; - var actualBuilderCount = 0; - for (var i = 0; i < span.Length; i++) - { - var c = span[i]; - if (char.IsUpper(c)) - { - // 大写字母。 - if (needSeparator) - { - // 需要使用分隔符。 - builder[actualBuilderCount++] = '-'; - } - builder[actualBuilderCount++] = char.ToLowerInvariant(c); - } - else if (char.IsLetterOrDigit(c)) - { - // 无大小写,但可作为标识符的字符(对 char 来说也视为字母)。 - builder[actualBuilderCount++] = c; - needSeparator = i + 1 < span.Length && char.IsUpper(span[i + 1]); - } - else - { - // 其他字符,直接添加。 - builder[actualBuilderCount++] = c; - } - } - if (actualBuilderCount == 0) - { - return ""; - } - if (actualBuilderCount == builder.Length) - { - return builder.ToString(); - } -#if NETCOREAPP3_1_OR_GREATER - return new string(builder[..actualBuilderCount]); -#else - return builder[..actualBuilderCount].ToString(); -#endif - } -} diff --git a/src/DotNetCampus.CommandLine/Utils/Collections/ReadOnlyListRange.cs b/src/DotNetCampus.CommandLine/Utils/Collections/ReadOnlyListRange.cs deleted file mode 100644 index 9965ff50..00000000 --- a/src/DotNetCampus.CommandLine/Utils/Collections/ReadOnlyListRange.cs +++ /dev/null @@ -1,82 +0,0 @@ -using System.Collections; - -namespace DotNetCampus.Cli.Utils.Collections; - -/// -/// 从一个只读集合中取出一个范围,让此集合表现得就像那个范围内的一个子集合一样。 -/// -/// 集合的元素类型。 -internal readonly struct ReadOnlyListRange : IReadOnlyList -{ - private readonly IReadOnlyList? _sourceList; - private readonly Range _range; - - /// - /// 从一个只读集合中取出一个范围,让此集合表现得就像那个范围内的一个子集合一样。 - /// - /// 原集合。 - /// 范围。 - public ReadOnlyListRange(IReadOnlyList sourceList, Range range) - { - _sourceList = sourceList; - _range = range; - } - - public int Count => _range.GetOffsetAndLength(_sourceList?.Count ?? 0).Length; - - public T this[int index] => _sourceList is null - ? throw new ArgumentOutOfRangeException(nameof(index)) - : _sourceList[_range.GetOffsetAndLength(_sourceList.Count).Offset + index]; - - /// - /// 获取第一个元素,如果没有元素则返回默认值。 - /// - public T? FirstOrDefault => Count is 0 ? default : this[0]; - - public ReadOnlyListRange Slice(int offset, int length) - { - if (_sourceList is null) - { - return offset is 0 && length is 0 - ? new ReadOnlyListRange([], new Range(0, 0)) - : throw new ArgumentOutOfRangeException(nameof(length)); - } - - var (start, _) = _range.GetOffsetAndLength(_sourceList.Count); - return new ReadOnlyListRange(_sourceList, new Range(start + offset, start + offset + length)); - } - - public IEnumerator GetEnumerator() - { - if (_sourceList is null) - { - yield break; - } - - var (offset, length) = _range.GetOffsetAndLength(_sourceList.Count); - for (var i = offset; i < offset + length; i++) - { - yield return _sourceList[i]; - } - } - - IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); -} - -internal static class ReadOnlyListRangeExtensions -{ - public static ReadOnlyListRange Slice(this IReadOnlyList sourceList, Range range) - { - return new ReadOnlyListRange(sourceList, range); - } - - public static ReadOnlyListRange Slice(this IReadOnlyList sourceList, int offset, int length) - { - return new ReadOnlyListRange(sourceList, new Range(offset, offset + length)); - } - - public static ReadOnlyListRange ToReadOnlyList(this IReadOnlyList sourceList) - { - return new ReadOnlyListRange(sourceList, new Range(0, sourceList.Count)); - } -} diff --git a/src/DotNetCampus.CommandLine/Utils/Collections/SingleOptimizedList.cs b/src/DotNetCampus.CommandLine/Utils/Collections/SingleOptimizedList.cs deleted file mode 100644 index b2c55a0a..00000000 --- a/src/DotNetCampus.CommandLine/Utils/Collections/SingleOptimizedList.cs +++ /dev/null @@ -1,165 +0,0 @@ -using System.Collections; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Diagnostics.Contracts; - -namespace DotNetCampus.Cli.Utils.Collections; - -/// -/// 为 0 个和 1 个值特殊优化的列表。 -/// -[DebuggerDisplay(nameof(SingleOptimizedList) + " {_firstValue,nq}, {_restValues}")] -internal readonly struct SingleOptimizedList : IReadOnlyList -{ - /// - /// 是否有值。如果为 ,则是空列表。 - /// - [MemberNotNullWhen(true, nameof(_firstValue))] - private bool HasValue { get; } - - /// - /// 在此命令行解析的上下文中,通常也不会为空字符串或空白字符串。 - /// - private readonly T? _firstValue; - - /// - /// 当所需储存的值超过 1 个时,将启用此列表。所以此列表要么为 null,要么有多于 1 个的值。 - /// - private readonly List? _restValues; - - public SingleOptimizedList() - { - } - - public SingleOptimizedList(T value) - { - HasValue = true; - _firstValue = value; - } - - private SingleOptimizedList(T firstValue, List restValues) - { - HasValue = true; - _firstValue = firstValue; - _restValues = restValues; - } - - /// - /// 获取集合中值的个数。 - /// - public int Count => HasValue switch - { - false => 0, - true when _restValues is null => 1, - true => _restValues.Count + 1, - }; - - /// - /// 获取集合中指定索引处的值。 - /// - public T this[int index] => HasValue - ? index is 0 ? _firstValue! : _restValues![index - 1] - : throw new ArgumentOutOfRangeException(nameof(index), "集合中没有值。"); - - /// - /// 添加一个值到集合中,并返回包含该值的新集合。 - /// - /// 要添加的值。 - [Pure] - public SingleOptimizedList Add(T value) - { - if (!HasValue) - { - // 空集合,添加第一个值。 - return new SingleOptimizedList(value); - } - - if (_restValues is null) - { - // 只有一个值,添加第二个值。 - return new SingleOptimizedList(_firstValue, [value]); - } - - // 已经有多个值,添加到现有的列表中。 - // 注意!此行为与其他任何集合都不同,会导致新旧对象共享同一个列表的引用,同时被修改!所以日常不要使用此集合。 - _restValues.Add(value); - return new SingleOptimizedList(_firstValue, _restValues); - } - - public SingleOptimizedList AddRange(IReadOnlyList values) - { - if (values.Count is 0) - { - return this; - } - - if (values.Count is 1) - { - return Add(values[0]); - } - - if (!HasValue) - { - // 空集合,添加第一个值。 - return new SingleOptimizedList(values[0], values.Skip(1).ToList()); - } - - if (_restValues is null) - { - // 只有一个值,添加第二个值。 - return new SingleOptimizedList(_firstValue, values.ToList()); - } - - // 已经有多个值,添加到现有的列表中。 - // 注意!此行为与其他任何集合都不同,会导致新旧对象共享同一个列表的引用,同时被修改!所以日常不要使用此集合。 - _restValues.AddRange(values); - return new SingleOptimizedList(_firstValue, _restValues); - } - - public IEnumerator GetEnumerator() - { - if (!HasValue) - { - yield break; - } - - yield return _firstValue; - - if (_restValues is not { } restValues) - { - yield break; - } - - for (var i = 0; i < restValues.Count; i++) - { - var value = restValues[i]; - yield return value; - } - } - - IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); -} - -internal static class SingleOptimizedListExtensions -{ - public static bool TryAdd(this Dictionary> dictionary, TKey key, TValue value) - where TKey : notnull - { - if (dictionary.TryGetValue(key, out var list)) - { - // 已经有值了,添加到列表中。 - dictionary[key] = list.Add(value); - return false; - } - - // 没有值,添加一个新的值。 - dictionary[key] = new SingleOptimizedList(value); - return true; - } - - public static void AddOrUpdateSingle(this Dictionary> dictionary, TKey key, TValue value) - where TKey : notnull - { - dictionary[key] = new SingleOptimizedList(value); - } -} diff --git a/src/DotNetCampus.CommandLine/Utils/CommandLineConverter.cs b/src/DotNetCampus.CommandLine/Utils/CommandLineConverter.cs index 669e3a6c..a2cc7644 100644 --- a/src/DotNetCampus.CommandLine/Utils/CommandLineConverter.cs +++ b/src/DotNetCampus.CommandLine/Utils/CommandLineConverter.cs @@ -1,6 +1,4 @@ -using DotNetCampus.Cli.Utils.Parsers; - -namespace DotNetCampus.Cli.Utils; +namespace DotNetCampus.Cli.Utils; /// /// 命令行参数转换器。 @@ -12,14 +10,14 @@ internal static class CommandLineConverter /// /// 一整行命令。 /// 命令行参数数组。 - internal static IReadOnlyList SingleLineCommandLineArgsToArrayCommandLineArgs(string singleLineCommandLineArgs) + internal static IReadOnlyList SingleLineToList(string singleLineCommandLineArgs) { if (string.IsNullOrWhiteSpace(singleLineCommandLineArgs)) { return []; } - List parts = []; + List arguments = []; var start = 0; var length = 0; @@ -33,7 +31,7 @@ internal static IReadOnlyList SingleLineCommandLineArgsToArrayCommandLin { if (length > 0) { - parts.Add(new Range(start, start + length)); + arguments.Add(singleLineCommandLineArgs[start..(start + length)]); } start = i + 1; @@ -59,29 +57,164 @@ internal static IReadOnlyList SingleLineCommandLineArgsToArrayCommandLin if (length > 0) { - parts.Add(new Range(start, start + length)); + arguments.Add(singleLineCommandLineArgs[start..(start + length)]); } - return [..parts.Select(part => singleLineCommandLineArgs[part])]; + return arguments; } - public static (string? MatchedUrlScheme, CommandLineParsedResult Result) ParseCommandLineArguments( - IReadOnlyList arguments, CommandLineParsingOptions? parsingOptions) + /// + /// 尝试将命令行参数中的 URL 转换为普通的命令行参数列表。 + /// + /// 原始传入的命令行参数。 + /// 命令行解析选项。 + /// 如果传入的命令行参数中包含 URL,则返回转换后的命令行参数列表和 URL 的 Scheme 部分。 + internal static (string? MatchedUrlScheme, IReadOnlyList? UrlNormalizedArguments) TryNormalizeUrlArguments( + IReadOnlyList originalArguments, CommandLineParsingOptions options) + { + if (originalArguments.Count is not 1 || options.SchemeNames is not { Count: > 0 } schemeNames) + { + return (null, null); + } + + var argument = originalArguments[0]; + foreach (var schemeName in schemeNames) + { + if (argument.StartsWith($"{schemeName}://", StringComparison.OrdinalIgnoreCase)) + { + return (schemeName, NormalizeUrlArguments(schemeName, argument)); + } + } + return (null, null); + } + + /// + /// 将 URL 转换为普通的命令行参数列表。
+ ///
+ /// URL 的 Scheme 部分。 + /// URL 字符串。 + /// 普通的命令行参数列表。 + private static IReadOnlyList NormalizeUrlArguments(string scheme, string argument) { - var matchedUrlScheme = arguments.Count is 1 && parsingOptions?.SchemeNames is { Count: > 0 } schemeNames - ? schemeNames.FirstOrDefault(x => arguments[0].StartsWith($"{x}://", StringComparison.OrdinalIgnoreCase)) - : null; + // scheme://command/subcommand/positional-argument1/positional-argument2?option1=value1&option2=value2 - ICommandLineParser parser = (matchUrlScheme: matchedUrlScheme, parsingOptions?.Style) switch + var span = argument.AsSpan(); + + // 1. 跳过 scheme:// + span = span[(scheme.Length + 3)..]; + + // 2. 分成三个部分,分别解析。 + var questionMarkIndex = span.IndexOf('?'); + var fragmentIndex = span.IndexOf('#'); + var commandAndPositionalArgumentSpan = questionMarkIndex switch + { + -1 when fragmentIndex == -1 => span, + -1 => span[..fragmentIndex], + _ when fragmentIndex == -1 => span[..questionMarkIndex], + _ => span[..Math.Min(questionMarkIndex, fragmentIndex)], + }; + var optionSpan = questionMarkIndex switch { - ({ } scheme, _) => new UrlStyleParser(scheme), - (_, CommandLineStyle.Flexible) => new FlexibleStyleParser(), - (_, CommandLineStyle.Gnu) => new GnuStyleParser(), - (_, CommandLineStyle.Posix) => new PosixStyleParser(), - (_, CommandLineStyle.DotNet) => new DotNetStyleParser(), - (_, CommandLineStyle.PowerShell) => new PowerShellStyleParser(), - _ => new FlexibleStyleParser(), + -1 => [], + _ when fragmentIndex == -1 => span[(questionMarkIndex + 1)..], + _ => span[(questionMarkIndex + 1)..fragmentIndex], }; - return (matchedUrlScheme, parser.Parse(arguments)); + var fragmentSpan = fragmentIndex switch + { + -1 => [], + _ => span[(fragmentIndex + 1)..], + }; + + // 3. 解析各个部分。 + var commandAndPositionalArgumentList = ParseCommandAndPositionalArguments(commandAndPositionalArgumentSpan); + var optionList = ParseOptions(optionSpan); + var fragmentList = ParseFragment(fragmentSpan); + + return [..commandAndPositionalArgumentList, ..optionList, ..fragmentList]; + } + + private static IReadOnlyList ParseCommandAndPositionalArguments(ReadOnlySpan argument) + { + if (argument.IsEmpty) + { + return []; + } + +#if NET8_0_OR_GREATER + Span ranges = stackalloc Range[argument.Count('/') + 1]; + var count = argument.Split(ranges, '/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var result = new List(count); + for (var i = 0; i < count; i++) + { + var part = argument[ranges[i]]; + result.Add(Uri.UnescapeDataString(part.ToString())); + } + return result; +#else + var parts = argument.ToString().Split(['/'], StringSplitOptions.RemoveEmptyEntries); + var result = parts.Select(Uri.UnescapeDataString).ToList(); + return result; +#endif + } + + private static IReadOnlyList ParseOptions(ReadOnlySpan argument) + { + if (argument.IsEmpty) + { + return []; + } + +#if NET8_0_OR_GREATER + Span ranges = stackalloc Range[argument.Count('&') + 1]; + var count = argument.Split(ranges, '&', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var result = new List(count); + for (var i = 0; i < count; i++) + { + var part = argument[ranges[i]]; + var equalSignIndex = part.IndexOf('='); + if (equalSignIndex < 0) + { + // 只有键,没有值 + result.Add($"--{Uri.UnescapeDataString(part.ToString())}"); + } + else + { + var key = part[..equalSignIndex].ToString(); + var value = part[(equalSignIndex + 1)..].ToString(); + result.Add($"--{Uri.UnescapeDataString(key)}={Uri.UnescapeDataString(value)}"); + } + } + return result; +#else + var parts = argument.ToString().Split(['&'], StringSplitOptions.RemoveEmptyEntries); + var result = new List(parts.Length); + foreach (var part in parts) + { + var equalSignIndex = part.IndexOf('='); + if (equalSignIndex == -1) + { + // 只有键,没有值 + result.Add($"--{Uri.UnescapeDataString(part)}"); + } + else + { + var key = part[..equalSignIndex]; + var value = part[(equalSignIndex + 1)..]; + result.Add($"--{Uri.UnescapeDataString(key)}={Uri.UnescapeDataString(value)}"); + } + } + return result; +#endif + } + + private static IReadOnlyList ParseFragment(ReadOnlySpan argument) + { + if (argument.IsEmpty) + { + return []; + } + + // 片段部分直接作为一个位置参数 + return ["--fragment", Uri.UnescapeDataString(argument.ToString())]; } } diff --git a/src/DotNetCampus.CommandLine/Utils/CommandSeparatorChars.cs b/src/DotNetCampus.CommandLine/Utils/CommandSeparatorChars.cs new file mode 100644 index 00000000..278e70d3 --- /dev/null +++ b/src/DotNetCampus.CommandLine/Utils/CommandSeparatorChars.cs @@ -0,0 +1,126 @@ +using System.Collections; +using System.Runtime.CompilerServices; + +namespace DotNetCampus.Cli.Utils; + +/// +/// 允许用户在命令行中使用的分隔符字符集合。最多只能支持 个字符。 +/// +#if NET8_0_OR_GREATER +[CollectionBuilder(typeof(CommandSeparatorChars), nameof(Create))] +#endif +public readonly record struct CommandSeparatorChars : IEnumerable +{ + /// + /// 获取一个空的分隔符字符集合实例。 + /// + public static CommandSeparatorChars Empty => new CommandSeparatorChars('\0', '\0'); + + /// + /// 分隔符字符集合中允许的最大字符数量。 + /// + internal const int MaxSupportedCount = 2; + + private readonly char _char0; + + private readonly char _char1; + + private CommandSeparatorChars(char char0, char char1) + { + _char0 = char0; + _char1 = char1; + } + + /// + /// 返回指定文本中第一个分隔符字符的索引;如果未找到任何分隔符字符,则返回 -1。 + /// + /// 要搜索的文本。 + /// 第一个分隔符字符的索引;如果未找到任何分隔符字符,则返回 -1。 + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public int SeparateIndex(ReadOnlySpan text) + { + for (var i = 0; i < text.Length; i++) + { + var c = text[i]; + if (c == _char0 || c == _char1) + { + return i; + } + } + return -1; + } + + /// + /// 以只读列表形式返回分隔符字符集合。 + /// + /// 分隔符字符集合。 + public void CopyTo(Span buffer, out int length) + { + if (_char0 is '\0') + { + length = 0; + return; + } + if (_char1 is '\0') + { + buffer[0] = _char0; + length = 1; + return; + } + buffer[0] = _char0; + buffer[1] = _char1; + length = 2; + } + + /// + /// 返回一个枚举器,该枚举器按添加顺序遍历 中的字符。 + /// + /// 一个可用于遍历 中字符的枚举器。 + public IEnumerator GetEnumerator() + { + if (_char0 is not '\0') + { + yield return _char0; + } + if (_char1 is not '\0') + { + yield return _char1; + } + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + /// + /// 从长度不大于 的字符(ASCII)集合创建一个新的 实例。 + /// + /// 分隔符字符集合。 + /// 新的 实例。 + /// 如果 长度大于 + /// 如果 中包含 null 字符。 + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static CommandSeparatorChars Create(params ReadOnlySpan chars) => chars.Length switch + { + 0 => new CommandSeparatorChars('\0', '\0'), + 1 => new CommandSeparatorChars(chars[0], '\0'), + 2 => new CommandSeparatorChars(chars[0], chars[1]), + _ => throw new ArgumentOutOfRangeException(nameof(chars), $"The length of chars cannot be greater than {MaxSupportedCount}."), + }; +} + +/// +/// 的扩展方法。 +/// +public static class CommandSeparatorCharsExtensions +{ + /// + /// 返回指定文本中第一个分隔符字符的索引;如果未找到任何分隔符字符,则返回 -1。 + /// + /// 要搜索的文本。 + /// 分隔符字符集合。 + /// 第一个分隔符字符的索引;如果未找到任何分隔符字符,则返回 -1。 + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int IndexOfAny(this ReadOnlySpan span, CommandSeparatorChars separatorChars) + { + return separatorChars.SeparateIndex(span); + } +} diff --git a/src/DotNetCampus.CommandLine/Utils/Handlers/DictionaryCommandHandlerCollection.cs b/src/DotNetCampus.CommandLine/Utils/Handlers/DictionaryCommandHandlerCollection.cs deleted file mode 100644 index 17330411..00000000 --- a/src/DotNetCampus.CommandLine/Utils/Handlers/DictionaryCommandHandlerCollection.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System.Collections.Concurrent; -using DotNetCampus.Cli.Compiler; - -namespace DotNetCampus.Cli.Utils.Handlers; - -internal sealed class DictionaryCommandHandlerCollection : ICommandHandlerCollection -{ - private CommandObjectCreator? _defaultHandlerCreator; - private readonly ConcurrentDictionary _commandHandlerCreators = []; - - public void AddHandler(string? commandNames, CommandObjectCreator handlerCreator) - { - if ( -#if !NETCOREAPP3_1_OR_GREATER - commandNames is null || -#endif - string.IsNullOrEmpty(commandNames)) - { - if (_defaultHandlerCreator is not null) - { - throw new InvalidOperationException($"Duplicate default handler creator. Existed: {_defaultHandlerCreator}, new: {handlerCreator}"); - } - _defaultHandlerCreator = handlerCreator; - } - else - { - if (!_commandHandlerCreators.TryAdd(commandNames, handlerCreator)) - { - throw new InvalidOperationException($"Duplicate handler with command {commandNames}. Existed: {_commandHandlerCreators}, new: {handlerCreator}"); - } - } - } - - public ICommandHandler? TryMatch(string possibleCommandNames, CommandLine commandLine) - { - return commandLine.TryMatch(possibleCommandNames, _defaultHandlerCreator, _commandHandlerCreators); - } -} diff --git a/src/DotNetCampus.CommandLine/Utils/Handlers/GeneratedAssemblyCommandHandlerCollection.cs b/src/DotNetCampus.CommandLine/Utils/Handlers/GeneratedAssemblyCommandHandlerCollection.cs deleted file mode 100644 index 88e1ccd6..00000000 --- a/src/DotNetCampus.CommandLine/Utils/Handlers/GeneratedAssemblyCommandHandlerCollection.cs +++ /dev/null @@ -1,25 +0,0 @@ -using DotNetCampus.Cli.Compiler; - -namespace DotNetCampus.Cli.Utils.Handlers; - -/// -/// 由源生成器继承,用于收集某个特定程序集中所有的命令处理器,然后统一处理。 -/// -public abstract class GeneratedAssemblyCommandHandlerCollection : ICommandHandlerCollection -{ - /// - /// 源生成器在构造函数中,为没有命令名称的命令处理器赋值。 - /// - protected CommandObjectCreator? Default { get; init; } - - /// - /// 源生成器在构造函数中,为有命令名称的命令处理器赋值。 - /// - protected Dictionary Creators { get; init; } = []; - - /// - public ICommandHandler? TryMatch(string possibleCommandNames, CommandLine commandLine) - { - return commandLine.TryMatch(possibleCommandNames, Default, Creators); - } -} diff --git a/src/DotNetCampus.CommandLine/Utils/Handlers/TaskCommandHandler.cs b/src/DotNetCampus.CommandLine/Utils/Handlers/TaskCommandHandler.cs index 8afc7984..16b7c901 100644 --- a/src/DotNetCampus.CommandLine/Utils/Handlers/TaskCommandHandler.cs +++ b/src/DotNetCampus.CommandLine/Utils/Handlers/TaskCommandHandler.cs @@ -1,20 +1,94 @@ +using DotNetCampus.Cli.Compiler; + namespace DotNetCampus.Cli.Utils.Handlers; -internal sealed class TaskCommandHandler( - Func optionsCreator, - Func> handler) : ICommandHandler - where TOptions : class +internal interface IAnonymousCommandHandler : ICommandHandler +{ + object? CreatedCommandOptions { get; } +} + +internal sealed class AnonymousCommandHandler( + CommandRunningContext context, + CommandObjectFactory factory, + Action handler) : IAnonymousCommandHandler + where T : notnull +{ + private T? _options; + + public object? CreatedCommandOptions => _options; + + public Task RunAsync() + { + _options ??= (T)factory(context); + if (_options is null) + { + throw new InvalidOperationException($"No options of type {typeof(T)} were created."); + } + handler(_options); + return Task.FromResult(0); + } +} + +internal sealed class AnonymousInt32CommandHandler( + CommandRunningContext context, + CommandObjectFactory factory, + Func handler) : IAnonymousCommandHandler + where T : notnull { - private TOptions? _options; + private T? _options; + + public object? CreatedCommandOptions => _options; public Task RunAsync() { - _options ??= optionsCreator(); + _options ??= (T)factory(context); + if (_options is null) + { + throw new InvalidOperationException($"No options of type {typeof(T)} were created."); + } + return Task.FromResult(handler(_options)); + } +} + +internal sealed class AnonymousTaskCommandHandler( + CommandRunningContext context, + CommandObjectFactory factory, + Func handler) : IAnonymousCommandHandler + where T : notnull +{ + private T? _options; + + public object? CreatedCommandOptions => _options; + + public async Task RunAsync() + { + _options ??= (T)factory(context); if (_options is null) { - throw new InvalidOperationException($"No options of type {typeof(TOptions)} were created."); + throw new InvalidOperationException($"No options of type {typeof(T)} were created."); } + await handler(_options); + return 0; + } +} + +internal sealed class AnonymousTaskInt32CommandHandler( + CommandRunningContext context, + CommandObjectFactory factory, + Func> handler) : IAnonymousCommandHandler + where T : notnull +{ + private T? _options; + public object? CreatedCommandOptions => _options; + + public Task RunAsync() + { + _options ??= (T)factory(context); + if (_options is null) + { + throw new InvalidOperationException($"No options of type {typeof(T)} were created."); + } return handler(_options); } } diff --git a/src/DotNetCampus.CommandLine/Utils/Parsers/Callbacks.cs b/src/DotNetCampus.CommandLine/Utils/Parsers/Callbacks.cs new file mode 100644 index 00000000..c88997c6 --- /dev/null +++ b/src/DotNetCampus.CommandLine/Utils/Parsers/Callbacks.cs @@ -0,0 +1,72 @@ +using DotNetCampus.Cli.Compiler; + +namespace DotNetCampus.Cli.Utils.Parsers; + +/// +/// 要求源生成器匹配长名称,返回此长选项的值类型和追加值的回调。 +/// +/// 由用户输入的长名称(已去掉前缀符号和后续所带的值,未处理命名法变换)。 +/// 如果此参数未指定大小写敏感性,则使用此默认值。 +/// 由开发者配置的允许的命名法。 +/// 此长选项的匹配结果。 +public delegate OptionValueMatch LongOptionMatchingCallback(ReadOnlySpan longOption, bool defaultCaseSensitive, CommandNamingPolicy namingPolicy); + +/// +/// 要求源生成器匹配短名称,返回此短选项的值类型和追加值的回调。 +/// +/// 由用户输入的短名称(已去掉前缀符号和后续所带的值,包含多个字符时也只允许匹配一个短选项)。 +/// 如果此参数未指定大小写敏感性,则使用此默认值。 +/// 此短选项的匹配结果。 +public delegate OptionValueMatch ShortOptionMatchingCallback(ReadOnlySpan shortOption, bool defaultCaseSensitive); + +/// +/// 要求源生成器匹配位置参数,返回此位置参数的范围和追加值的回调。 +/// +/// 由用户输入的位置参数的值。 +/// 位置参数的索引(从 0 开始)。 +/// 此位置参数的匹配结果。 +public delegate PositionalArgumentValueMatch PositionalArgumentMatchingCallback(ReadOnlySpan value, int argumentIndex); + +/// +/// 向某个选项或位置参数追加一个值的回调。 +/// +/// 要追加的键(对于字典类型的选项有效,其他类型永远为空)。 +/// 要追加的值。 +public delegate void AppendValueCallback(ReadOnlySpan key, ReadOnlySpan value); + +/// +/// 向指定索引处的属性赋值。 +/// +/// 要赋值的属性名称(调试追踪用)。 +/// 要赋值的属性索引(源生成器生成的索引)。 +/// 要赋值的键(对于字典类型的选项有效,其他类型永远为空)。 +/// 要赋值的值。 +public delegate void AssignPropertyValueCallback(string propertyName, int propertyIndex, ReadOnlySpan key, ReadOnlySpan value); + +/// +/// 源生成器匹配属性的匹配结果。 +/// +/// 此选项对应的属性名称。 +/// 此选项对应的属性索引。 +/// 此选项的值类型。 +public readonly record struct OptionValueMatch(string PropertyName, int PropertyIndex, OptionValueType ValueType) +{ + /// + /// 获取一个表示未匹配任何选项的匹配结果。 + /// + public static OptionValueMatch NotMatch => new("", -1, OptionValueType.NotExist); +} + +/// +/// 源生成器匹配位置参数的匹配结果。 +/// +/// 此选项对应的属性名称。 +/// 此选项对应的属性索引。 +/// 此位置参数的值类型。 +public readonly record struct PositionalArgumentValueMatch(string PropertyName, int PropertyIndex, PositionalArgumentValueType ValueType) +{ + /// + /// 获取一个表示未匹配任何位置参数的匹配结果。 + /// + public static PositionalArgumentValueMatch NotMatch => new("", -1, PositionalArgumentValueType.NotExist); +} diff --git a/src/DotNetCampus.CommandLine/Utils/Parsers/CommandArgumentType.cs b/src/DotNetCampus.CommandLine/Utils/Parsers/CommandArgumentType.cs new file mode 100644 index 00000000..b484c9ad --- /dev/null +++ b/src/DotNetCampus.CommandLine/Utils/Parsers/CommandArgumentType.cs @@ -0,0 +1,85 @@ +namespace DotNetCampus.Cli.Utils.Parsers; + +/// +/// 命令行参数的类型。 +/// +internal enum CommandArgumentType +{ + /// + /// 尚未开始解析。 + /// + Start, + + /// + /// 命令(主命令、子命令或多级子命令)。 + /// + Command, + + /// + /// 混在选项间的位置参数。 + /// + PositionalArgument, + + /// + /// 长选项。--option -Option /option -tl /tl + /// + LongOption, + + /// + /// 带值的长选项。--option:value -Option:value /option:value -tl:off /tl:off + /// + LongOptionWithValue, + + /// + /// 短选项。-o /o + /// + ShortOption, + + /// + /// 带值的短选项。-o:value /o:value + /// + ShortOptionWithValue, + + /// + /// 无法确定长还是短的选项。-o /o /option -tl /tl -Option + /// + Option, + + /// + /// 带值的无法确定长还是短的选项。-o:value /o:value /option:value -tl:off /tl:off -Option:value + /// + OptionWithValue, + + /// + /// 无法解析的选项。 + /// + ErrorOption, + + /// + /// 多个短选项。-abc + /// + /// + /// 存在以下三种情况: + /// + /// -abc 表示 -a -b -c 三个布尔短选项。 + /// -abc 表示 -a 选项的值为 bc。 + /// -abc 表示一个名为 abc 的多字符短选项。 + /// + /// + MultiShortOptions, + + /// + /// 选项值。value + /// + OptionValue, + + /// + /// 位置参数分隔符。-- 之后的参数都被视为位置参数。 + /// + PositionalArgumentSeparator, + + /// + /// 后置的位置参数。 + /// + PostPositionalArgument, +} diff --git a/src/DotNetCampus.CommandLine/Utils/Parsers/CommandLineParsedResult.cs b/src/DotNetCampus.CommandLine/Utils/Parsers/CommandLineParsedResult.cs deleted file mode 100644 index 652b9e95..00000000 --- a/src/DotNetCampus.CommandLine/Utils/Parsers/CommandLineParsedResult.cs +++ /dev/null @@ -1,20 +0,0 @@ -using DotNetCampus.Cli.Utils.Collections; - -namespace DotNetCampus.Cli.Utils.Parsers; - -internal readonly record struct CommandLineParsedResult( - string PossibleCommandNames, - OptionDictionary LongOptions, - OptionDictionary ShortOptions, - ReadOnlyListRange Arguments) -{ - public static string MakePossibleCommandNames(IEnumerable possibleCommandNames, bool isUpperSeparator) - { - return string.Join(" ", possibleCommandNames.Select(x => NamingHelper.MakeKebabCase(x, isUpperSeparator, false))); - } - - public static string MakePossibleCommandNames(IEnumerable commandLineArguments, int possibleCommandNamesLength, bool isUpperSeparator) - { - return string.Join(" ", commandLineArguments.Take(possibleCommandNamesLength).Select(x => NamingHelper.MakeKebabCase(x, isUpperSeparator, false))); - } -} diff --git a/src/DotNetCampus.CommandLine/Utils/Parsers/CommandLineParser.cs b/src/DotNetCampus.CommandLine/Utils/Parsers/CommandLineParser.cs new file mode 100644 index 00000000..3bf1bf6d --- /dev/null +++ b/src/DotNetCampus.CommandLine/Utils/Parsers/CommandLineParser.cs @@ -0,0 +1,856 @@ +using DotNetCampus.Cli.Compiler; +using DotNetCampus.Cli.Exceptions; +using Cat = DotNetCampus.Cli.Utils.Parsers.CommandArgumentType; + +namespace DotNetCampus.Cli.Utils.Parsers; + +/// +/// 通用的命令行参数解析器。(此解析器不可解析 URL 类型的参数。) +/// +public readonly ref struct CommandLineParser +{ + private readonly CommandLine _commandLine; + private readonly string _commandObjectName; + private readonly int _commandCount; + private readonly bool _caseSensitive; + private readonly CommandNamingPolicy _namingPolicy; + + /// + /// 通用的命令行参数解析器。(此解析器不可解析 URL 类型的参数。) + /// + /// 要解析的命令行参数。 + /// 正在解析此参数的命令对象的名称。 + /// 主命令/子命令/多级子命令的数量。在解析时,要跳过这些命令。 + public CommandLineParser(CommandLine commandLine, string commandObjectName, int commandCount) + { + _commandLine = commandLine; + _commandObjectName = commandObjectName; + _commandCount = commandCount; + var isUrl = commandLine.MatchedUrlScheme is not null; + Style = isUrl + ? CommandLineStyle.Url + : commandLine.ParsingOptions.Style; + _namingPolicy = Style.NamingPolicy; + OptionPrefix = Style.OptionPrefix; + _caseSensitive = Style.CaseSensitive; + SupportsLongOption = Style.SupportsLongOption; + SupportsShortOption = Style.SupportsShortOption; + SupportsShortOptionCombination = Style.SupportsShortOptionCombination; + SupportsMultiCharShortOption = Style.SupportsMultiCharShortOption; + SupportsShortOptionValueWithoutSeparator = Style.SupportsShortOptionValueWithoutSeparator; + SupportsSpaceSeparatedOptionValue = Style.SupportsSpaceSeparatedOptionValue; + SupportsExplicitBooleanOptionValue = Style.SupportsExplicitBooleanOptionValue; + SupportsSpaceSeparatedCollectionValues = Style.SupportsSpaceSeparatedCollectionValues; + UnknownOptionTakesValue = Style.UnknownOptionTakesValue; + UnknownArgumentsHandling = commandLine.ParsingOptions.UnknownArgumentsHandling; + } + + /// + /// 获取解析命令行时所使用的各种选项。 + /// + internal CommandLineStyle Style { get; } + + /// + internal CommandOptionPrefix OptionPrefix { get; } + + /// + public UnknownOptionBehavior UnknownOptionTakesValue { get; } + + /// + internal bool SupportsLongOption { get; } + + /// + internal bool SupportsShortOption { get; } + + /// + internal bool SupportsShortOptionCombination { get; } + + /// + internal bool SupportsMultiCharShortOption { get; } + + /// + internal bool SupportsShortOptionValueWithoutSeparator { get; } + + /// + internal bool SupportsSpaceSeparatedOptionValue { get; } + + /// + internal bool SupportsExplicitBooleanOptionValue { get; } + + /// + internal bool SupportsSpaceSeparatedCollectionValues { get; } + + /// + public UnknownCommandArgumentHandling UnknownArgumentsHandling { get; } + + /// + /// 要求源生成器匹配长名称,返回此长选项的值类型。 + /// + public required LongOptionMatchingCallback MatchLongOption { get; init; } + + /// + /// 要求源生成器匹配短名称,返回此短选项的值类型。 + /// + public required ShortOptionMatchingCallback MatchShortOption { get; init; } + + /// + /// 要求源生成器匹配位置参数,返回位置参数的范围。 + /// + public required PositionalArgumentMatchingCallback MatchPositionalArguments { get; init; } + + /// + /// 要求源生成器将解析到的值赋值给指定索引处的属性。 + /// + public required AssignPropertyValueCallback AssignPropertyValue { get; init; } + + /// + /// 获取默认的选项值处理器(默认的选项处理器仅为了避免代码错误产生误用,实际永远不会被使用)。 + /// + private static OptionValueMatch DefaultOptionValueHandler => new OptionValueMatch("", -1, OptionValueType.Normal); + + /// + /// 解析命令行参数,并返回解析结果。 + /// + /// 命令行参数解析结果。 + public CommandLineParsingResult Parse() + { + var result = CommandLineParsingResult.Success; + var arguments = _commandLine.CommandLineArguments; + var currentOption = OptionValueMatch.NotMatch; + var currentPositionArgumentIndex = 0; + var lastState = Cat.Start; + + for (var index = _commandCount; index < arguments.Count; index++) + { + var argument = arguments[index]; + + // 状态机状态转移。 + var part = new CommandArgumentPart(this, argument, lastState, currentOption.ValueType); + part.Parse(); + var (state, optionName, value) = part; + lastState = state; + + // 应用新状态下的值。 + switch (state) + { + case Cat.LongOption or Cat.ShortOption or Cat.Option: + { + // 如果当前是一个选项,则记录下来,供后面解析选项值时使用。 + var optionMatch = state switch + { + Cat.LongOption => MatchLongOption(optionName, _caseSensitive, _namingPolicy), + Cat.ShortOption => MatchShortOption(optionName, _caseSensitive), + _ => MatchLongOption(optionName, _caseSensitive, _namingPolicy) switch + { + { ValueType: OptionValueType.NotExist } => MatchShortOption(optionName, _caseSensitive), + var t => t, + }, + }; + if (optionMatch.ValueType is OptionValueType.NotExist) + { + // 如果选项不存在,则报告错误。 + optionMatch = optionMatch.HandleNotExist(UnknownOptionTakesValue); + result = CommandLineParsingResult.OptionalArgumentNotFound(_commandLine, index, _commandObjectName, optionName, state.IsLongOption()) + .Combine(result, UnknownArgumentsHandling); + } + if (optionMatch.ValueType is OptionValueType.Boolean) + { + // 布尔选项必须立即赋值,因为后面是不一定需要跟值的。 + result = AssignOptionValue(optionMatch, []).Combine(result, UnknownArgumentsHandling); + } + currentOption = optionMatch; + break; + } + case Cat.OptionValue: + { + result = AssignOptionValue(currentOption, value).Combine(result, UnknownArgumentsHandling); + if (currentOption.ValueType is not OptionValueType.List) + { + // 如果不是集合,那么此选项已经结束。 + // 清空上一个选项,避免误用。 + currentOption = DefaultOptionValueHandler; + } + break; + } + case Cat.PositionalArgument or Cat.PostPositionalArgument: + { + var positionalArgumentMatch = MatchPositionalArguments(value, currentPositionArgumentIndex); + if (positionalArgumentMatch.ValueType is PositionalArgumentValueType.NotExist) + { + // 如果位置参数不存在,则报告错误。 + result = CommandLineParsingResult.PositionalArgumentNotFound(_commandLine, index, _commandObjectName, currentPositionArgumentIndex) + .Combine(result, UnknownArgumentsHandling); + } + currentPositionArgumentIndex++; + if (positionalArgumentMatch.ValueType is not PositionalArgumentValueType.NotExist) + { + AssignPositionalArgumentValue(positionalArgumentMatch, value); + } + break; + } + case Cat.LongOptionWithValue or Cat.ShortOptionWithValue or Cat.OptionWithValue: + { + var optionMatch = state switch + { + Cat.LongOptionWithValue => MatchLongOption(optionName, _caseSensitive, _namingPolicy), + Cat.ShortOptionWithValue => MatchShortOption(optionName, _caseSensitive), + _ => MatchLongOption(optionName, _caseSensitive, _namingPolicy) switch + { + { ValueType: OptionValueType.NotExist } => MatchShortOption(optionName, _caseSensitive), + var t => t, + }, + }; + if (optionMatch.ValueType is OptionValueType.NotExist) + { + // 如果选项不存在,则报告错误。 + result = CommandLineParsingResult.OptionalArgumentNotFound(_commandLine, index, _commandObjectName, optionName, state.IsLongOption()) + .Combine(result, UnknownArgumentsHandling); + } + else + { + result = AssignOptionValue(optionMatch, value).Combine(result, UnknownArgumentsHandling); + } + break; + } + case Cat.ErrorOption: + { + // 如果当前参数疑似选项但解析失败,则报告错误。 + result = CommandLineParsingResult.OptionalArgumentParseError(_commandLine, index, _commandObjectName) + .Combine(result, UnknownArgumentsHandling); + break; + } + case Cat.MultiShortOptions: + { + var name = optionName; + if (SupportsMultiCharShortOption) + { + // 如果支持多字符短选项,则优先作为多字符短选项处理。 + var optionMatch = MatchShortOption(name, _caseSensitive); + if (optionMatch.ValueType is not OptionValueType.NotExist) + { + // 多字符短选项存在,则作为多字符短选项处理。 + if (optionMatch.ValueType is OptionValueType.Boolean) + { + // 布尔选项必须立即赋值,因为后面是不一定需要跟值的。 + result = AssignOptionValue(optionMatch, []).Combine(result, UnknownArgumentsHandling); + } + // 短选项已经无歧义地确定为了正常短选项了。 + lastState = Cat.ShortOption; + currentOption = optionMatch; + break; + } + // 如果选项不存在,则可能是其他短选项类型。 + } + if (!SupportsShortOptionCombination && !SupportsShortOptionValueWithoutSeparator) + { + // 如果既不支持组合短选项,也不支持无分隔符带值的短选项,则报告错误。 + result = CommandLineParsingResult.OptionalArgumentNotFound(_commandLine, index, _commandObjectName, name, false) + .Combine(result, UnknownArgumentsHandling); + break; + } + var first = name[..1]; + var others = name[1..]; + var firstOptionMatch = MatchShortOption(first, _caseSensitive); + if (SupportsShortOptionCombination && firstOptionMatch.ValueType is OptionValueType.Boolean) + { + // 第一个字符是布尔选项,则可能是组合,也可能跟值。 + var parsedBoolean = ParseBoolean(others); + // 后面是一个布尔值。 + if (parsedBoolean is { } @bool) + { + result = AssignOptionValue(firstOptionMatch, @bool.ToBooleanSpan()).Combine(result, UnknownArgumentsHandling); + break; + } + // 后面不是布尔值。而由于第一个是布尔值,所以后面必须全是布尔值才能组合。 + AssignOptionValue(firstOptionMatch, []); + for (var i = 0; i < others.Length; i++) + { + var n = others.Slice(i, 1); + var optionMatch = MatchShortOption(n, _caseSensitive); + if (optionMatch.ValueType is OptionValueType.Boolean) + { + // 仍是布尔选项,继续赋值。 + AssignOptionValue(optionMatch, []); + } + else if (optionMatch.ValueType is OptionValueType.NotExist) + { + // 后面的某个组合选项不存在了,报告错误。 + result = CommandLineParsingResult.OptionalArgumentNotFound(_commandLine, index, _commandObjectName, n, false) + .Combine(result, UnknownArgumentsHandling); + break; + } + else + { + // 后面的某个组合选项不是布尔选项,报告不可组合错误。 + result = CommandLineParsingResult.OptionalArgumentCombinationIsNotBoolean(_commandLine, index, _commandObjectName, n) + .Combine(result, UnknownArgumentsHandling); + break; + } + } + // 所有字符都处理完毕,组合成功或失败。 + break; + } + if (SupportsShortOptionValueWithoutSeparator && firstOptionMatch.ValueType is not OptionValueType.Boolean and not OptionValueType.NotExist) + { + // 第一个字符不是布尔选项,则不允许组合,后面只可能是值(无分隔符带值的短选项)。 + result = AssignOptionValue(firstOptionMatch, others).Combine(result, UnknownArgumentsHandling); + break; + } + // 能进到这里,说明短选项的上述三种情况都不满足,应该报告错误。 + result = CommandLineParsingResult.OptionalArgumentNotFound(_commandLine, index, _commandObjectName, name, false) + .Combine(result, UnknownArgumentsHandling); + break; + } + // 其他状态要么已经处理过了,要不还未处理,要么不需要处理,所以不需要做任何事情。 + } + } + + return result; + } + + /// + /// 配合源生成器生成的匹配结果,将选项值赋值给指定索引处的属性。 + /// + /// 源生成器生成的匹配结果。 + /// 选项值。 + /// 命令行参数解析结果。 + private CommandLineParsingResult AssignOptionValue(OptionValueMatch match, ReadOnlySpan value) + { + var result = CommandLineParsingResult.Success; + switch (match.ValueType) + { + // 普通值 + case OptionValueType.Normal: + { + AssignPropertyValue(match.PropertyName, match.PropertyIndex, [], value); + break; + } + // 布尔值 + case OptionValueType.Boolean: + { + var booleanValue = ParseBoolean(value); + if (booleanValue is { } @bool) + { + var finalValue = @bool.ToBooleanSpan(); + AssignPropertyValue(match.PropertyName, match.PropertyIndex, [], finalValue); + } + else + { + result = CommandLineParsingResult.BooleanValueParseError(_commandLine, value).Combine(result, UnknownArgumentsHandling); + } + break; + } + case OptionValueType.List: + { + var separators = Style.CollectionValueSeparators; + var start = 0; + while (start < value.Length) + { + var index = value[start..].IndexOfAny(separators); + if (index < 0) + { + // 剩余部分没有分隔符,全部作为一个值。 + AssignPropertyValue(match.PropertyName, match.PropertyIndex, [], value[start..]); + break; + } + if (index > 0) + { + // 截取分隔符前的部分作为一个值。 + AssignPropertyValue(match.PropertyName, match.PropertyIndex, [], value.Slice(start, index)); + } + // 跳过分隔符,继续处理后续部分。 + start += index + 1; + } + break; + } + case OptionValueType.Dictionary: + { + var separators = Style.CollectionValueSeparators; + var start = 0; + while (start < value.Length) + { + var index = value[start..].IndexOfAny(separators); + if (index < 0) + { + // 剩余部分没有分隔符,全部作为一个值。 + result = SplitKeyValue(value[start..], out var k, out var v).Combine(result, UnknownArgumentsHandling); + AssignPropertyValue(match.PropertyName, match.PropertyIndex, k, v); + break; + } + if (index > 0) + { + // 截取分隔符前的部分作为一个值。 + result = SplitKeyValue(value.Slice(start, index), out var k, out var v).Combine(result, UnknownArgumentsHandling); + AssignPropertyValue(match.PropertyName, match.PropertyIndex, k, v); + } + // 跳过分隔符,继续处理后续部分。 + start += index + 1; + } + break; + } + case OptionValueType.NotExistButTakesOptionalValue or OptionValueType.NotExistButTakesAllValues: + { + // 不存在的选项,但允许接受值。 + // 此时不能做任何事,等全部解析完成后报告异常。 + break; + } + default: + { + throw new CommandLineException("Unreachable code."); + } + } + return result; + } + + private void AssignPositionalArgumentValue(PositionalArgumentValueMatch match, ReadOnlySpan value) + { + AssignPropertyValue(match.PropertyName, match.PropertyIndex, [], value); + } + + private CommandLineParsingResult SplitKeyValue(ReadOnlySpan item, + out ReadOnlySpan key, out ReadOnlySpan value) + { + // 截至目前,所有的字典类型都使用 key=value 形式,如果将来新增的风格有其他符号,我们再用一样的分隔符方式来配置。 + var index = item.IndexOf('='); + if (index < 0) + { + key = item; + value = []; + return CommandLineParsingResult.DictionaryValueParseError(_commandLine, item); + } + key = item[..index]; + value = item[(index + 1)..]; + return CommandLineParsingResult.Success; + } + + internal static bool? ParseBoolean(ReadOnlySpan value) + { + if (value.Length <= 4 && ( + value.Equals("true".AsSpan(), StringComparison.OrdinalIgnoreCase) || + value.Equals("yes".AsSpan(), StringComparison.OrdinalIgnoreCase) || + value.Equals("on".AsSpan(), StringComparison.OrdinalIgnoreCase) || + value.Equals("1".AsSpan(), StringComparison.OrdinalIgnoreCase) || + value.Length is 0)) + { + return true; + } + if (value.Length is > 0 and <= 5 && ( + value.Equals("false".AsSpan(), StringComparison.OrdinalIgnoreCase) || + value.Equals("no".AsSpan(), StringComparison.OrdinalIgnoreCase) || + value.Equals("off".AsSpan(), StringComparison.OrdinalIgnoreCase) || + value.Equals("0".AsSpan(), StringComparison.OrdinalIgnoreCase))) + { + return false; + } + return null; + } +} + +/// +/// 这是命令行解析状态机中的其中一个状态。当调用 方法后,此对象会被修改以转移到新的状态。 +/// +internal ref struct CommandArgumentPart +{ + private readonly CommandLineParser _parser; + private readonly string _argument; + private readonly Cat _lastType; + private readonly OptionValueType _lastOptionType; + + /// + /// 辅助解析命令行参数中的其中一个参数。 + /// + /// 正在使用的命令行参数解析器。 + /// 要解析的参数。 + /// 上一个参数的类型,初始为 。 + /// 上一个参数的选项值类型,如果上一个参数不是选项,则为默认值。 + public CommandArgumentPart(CommandLineParser parser, string argument, Cat lastType, OptionValueType lastOptionType) + { + _parser = parser; + _argument = argument; + _lastType = lastType; + _lastOptionType = lastOptionType; + } + + /// + /// 解析完成后,发现此参数的类型。 + /// + public Cat Type { get; private set; } + + /// + /// 如果此参数是一个选项(长选项或短选项),则为此选项的名称;否则为默认值。 + /// + public ReadOnlySpan Option { get; private set; } + + /// + /// 如果此参数包含值(位置参数或选项值),则为此值;否则为默认值。 + /// + public ReadOnlySpan Value { get; private set; } + + /// + /// 将此参数解构为各个部分。 + /// + /// 此参数的类型。 + /// 如果此参数是一个选项(长选项或短选项),则为此选项的名称;否则为默认值。 + /// 如果此参数包含值(位置参数或选项值),则为此值;否则为默认值。 + public void Deconstruct(out Cat type, out ReadOnlySpan optionName, out ReadOnlySpan value) + { + type = Type; + optionName = Option; + value = Value; + } + + /// + /// 以上一个状态为基准,解析当前参数,并转移到新的状态。 + /// + /// + /// 返回值没有意义,纯粹为了使用 switch 表达式。 + /// + public void Parse() => _ = _lastType switch + { + Cat.Start or Cat.Command => ParseCommandRegion(), + Cat.PositionalArgumentSeparator or Cat.PostPositionalArgument => ParsePostPositionalArgumentRegion(), + _ => ParseOptionAndPositionalArgumentRegion(), + }; + + /// + /// 起点/命令/子命令区 --> 命令/子命令区 + /// 起点/命令/子命令区 --> 选项和位置参数混合区 + /// + private bool ParseCommandRegion() + { + // 由于命令已提前跳过,所以这里直接进入选项和位置参数混合区。 + return ParseOptionAndPositionalArgumentRegion(); + } + + /// + /// 选项和位置参数混合区 --> 后置位置参数区 + /// 选项和位置参数混合区 --> 选项和位置参数混合区 + /// + private bool ParseOptionAndPositionalArgumentRegion() + { + // 只有使用双前缀的风格才支持后置位置参数区。 + var isPostPositionalArgument = _parser.OptionPrefix is CommandOptionPrefix.DoubleDash or CommandOptionPrefix.Any + && string.Equals(_argument, "--", StringComparison.Ordinal); + if (isPostPositionalArgument) + { + Type = Cat.PositionalArgumentSeparator; + return true; + } + + var isManyValueType = _lastOptionType is OptionValueType.List or OptionValueType.Dictionary or OptionValueType.NotExistButTakesAllValues; + return _lastType switch + { + // 上一个是起点或命令,后面只能是新的选项或位置参数。 + Cat.Start or Cat.Command => ParseOptionOrPositionalArgument(), + // 值已经被上一个选项消费掉了,必须是新的选项或位置参数。 + Cat.PositionalArgument or Cat.LongOptionWithValue or Cat.ShortOptionWithValue or Cat.OptionWithValue => ParseOptionOrPositionalArgument(), + // 多个短选项,后面不允许带值。 + Cat.MultiShortOptions => ParseOptionOrPositionalArgument(), + // 上一个是选项。 + Cat.LongOption or Cat.ShortOption or Cat.Option => (_lastOptionType switch + { + // 如果是布尔选项,则后面只能跟布尔值,否则只能是新的选项或位置参数。 + OptionValueType.Boolean when _parser.SupportsExplicitBooleanOptionValue => ParseBooleanOptionValueOrNewOptionOrPositionalArgument(), + // 如果是布尔选项,但不支持显式布尔值,则只能是新的选项或位置参数。 + OptionValueType.Boolean => ParseOptionOrPositionalArgument(), + // 如果是集合选项,则后面可以跟多个值,直到遇到新的选项或位置参数分隔符为止。 + OptionValueType.List or OptionValueType.Dictionary => ParseOptionValueOrNewOptionOrPositionalArgument(isManyValueType), + // 如果是不存在的选项,则根据后面能否接受值来决定。 + OptionValueType.NotExistAndTakesNoValue => ParseOptionOrPositionalArgument(), + OptionValueType.NotExistButTakesOptionalValue or OptionValueType.NotExistButTakesAllValues => + ParseOptionValueOrNewOptionOrPositionalArgument(isManyValueType), + // 如果是普通选项,则后面只能是选项值。 + _ => ParseOptionValue(_argument.AsSpan()), + }), + // 上一个是选项的值。 + Cat.OptionValue => (_lastOptionType switch + { + // 只有集合才可以继续跟值(且必须允许),其他都要解析为新的选项或位置参数。 + OptionValueType.List or OptionValueType.Dictionary when _parser.SupportsSpaceSeparatedCollectionValues => + ParseOptionValueOrNewOptionOrPositionalArgument(isManyValueType), + // 如果是不存在的选项,则只有在允许后面跟值,且允许空格分隔值的情况下,才可以继续跟值。 + OptionValueType.NotExistButTakesAllValues when _parser.SupportsSpaceSeparatedCollectionValues => + ParseOptionValueOrNewOptionOrPositionalArgument(isManyValueType), + // 解析为新的选项或位置参数。 + _ => ParseOptionOrPositionalArgument(), + }), + _ => throw new InvalidOperationException($"解析上一个参数时已进入错误的状态:{_lastType}。"), + }; + } + + /// + /// 后置位置参数区 --> 后置位置参数区 + /// + private bool ParsePostPositionalArgumentRegion() + { + Type = Cat.PostPositionalArgument; + Value = _argument.AsSpan(); + return true; + } + + /// + /// 选项和位置参数混合区(状态内部) + /// 起点 --> 位置参数 + /// 起点 --> 选项 + /// + /// + private bool ParseOptionOrPositionalArgument() + { + var argument = _argument.AsSpan(); + if (argument.Length is 0) + { + // 空字符串,视为位置参数。 + Type = Cat.PositionalArgument; + Value = argument; + return true; + } + if (argument.Length is 1) + { + // 单个字符,确定一下是否是选项分隔符,如果是则要报错。 + var separators = _parser.Style.OptionValueSeparators; + if (argument.IndexOfAny(separators) >= 0) + { + // 仅包含分隔符,视为错误选项。 + Type = Cat.ErrorOption; + return true; + } + // 单个字符(无法组成选项),视为位置参数。 + Type = Cat.PositionalArgument; + Value = argument; + return true; + } + + return _parser.OptionPrefix switch + { + CommandOptionPrefix.DoubleDash => (argument[0], argument[1]) switch + { + ('-', '-') => ParseLongOptionOrLongOptionWithValue(argument[2..]), + ('-', _) => ParseShortOptionOrMultiShortOptions(argument[1..]), + _ => ParsePositionalArgument(argument), + }, + CommandOptionPrefix.SingleDash => argument[0] switch + { + '-' => ParseLongShortOptionOrLongShortOptionWithValue(argument[1..]), + _ => ParsePositionalArgument(argument), + }, + CommandOptionPrefix.Slash => argument[0] switch + { + '/' => ParseLongShortOptionOrLongShortOptionWithValue(argument[1..]), + _ => ParsePositionalArgument(argument), + }, + CommandOptionPrefix.SlashOrDash => argument[0] switch + { + '-' or '/' => ParseLongShortOptionOrLongShortOptionWithValue(argument[1..]), + _ => ParsePositionalArgument(argument), + }, + CommandOptionPrefix.Any => (argument[0], argument[1]) switch + { + ('-', '-') => ParseLongOptionOrLongOptionWithValue(argument[2..]), + ('-', _) or ('/', _) => ParseLongShortOptionOrLongShortOptionWithValue(argument[1..]), + _ => ParsePositionalArgument(argument), + }, + _ => throw new ArgumentOutOfRangeException(), + }; + } + + private bool ParseLongOptionOrLongOptionWithValue(ReadOnlySpan argument) + { + var separators = _parser.Style.OptionValueSeparators; + var index = argument.IndexOfAny(separators); + if (index is 0) + { + // 没有选项名,报告错误。 + Type = Cat.ErrorOption; + return true; + } + if (index > 0) + { + // 带值的长选项。 + Type = Cat.LongOptionWithValue; + Option = argument[..index]; + Value = argument[(index + 1)..]; + return true; + } + // 不带值的长选项。 + Type = Cat.LongOption; + Option = argument; + return true; + } + + private bool ParseShortOptionOrMultiShortOptions(ReadOnlySpan argument) + { + var separators = _parser.SupportsShortOptionValueWithoutSeparator + ? CommandSeparatorChars.Empty + : _parser.Style.OptionValueSeparators; + var index = argument.IndexOfAny(separators); + if (index is 0) + { + // 没有选项名,报告错误。 + Type = Cat.ErrorOption; + return true; + } + if (argument.Length is 1) + { + // 单独的短选项。 + Type = Cat.ShortOption; + Option = argument; + return true; + } + if (index > 0) + { + if (index is 1 || _parser.SupportsMultiCharShortOption) + { + // 带值的短选项。 + Type = Cat.ShortOptionWithValue; + Option = argument[..index]; + Value = argument[(index + 1)..]; + return true; + } + // 分隔符出现在第二个字符之后,但不支持多字符短选项,报告错误。 + Type = Cat.ErrorOption; + return true; + } + + // 直接返回,延迟处理。 + Type = Cat.MultiShortOptions; + Option = argument; + return true; + } + + private bool ParseLongShortOptionOrLongShortOptionWithValue(ReadOnlySpan argument) + { + var separators = _parser.Style.OptionValueSeparators; + var index = argument.IndexOfAny(separators); + if (index is 0) + { + // 没有选项名,报告错误。 + Type = Cat.ErrorOption; + return true; + } + if (index > 0) + { + // 带值的选项。 + Type = Cat.OptionWithValue; + Option = argument[..index]; + Value = argument[(index + 1)..]; + return true; + } + // 不带值的选项。 + Type = Cat.Option; + Option = argument; + return true; + } + + /// + /// 尝试解析布尔值。解析成功则视为选项值,失败则视为新的选项或位置参数。 + /// + /// + private bool ParseBooleanOptionValueOrNewOptionOrPositionalArgument() + { + var argument = _argument; + var booleanValue = CommandLineParser.ParseBoolean(argument.AsSpan()); + if (booleanValue is true) + { + Type = Cat.OptionValue; + Value = "1".AsSpan(); + return true; + } + if (booleanValue is false) + { + Type = Cat.OptionValue; + Value = "0".AsSpan(); + return true; + } + return ParseOptionOrPositionalArgument(); + } + + private bool ParseOptionValueOrNewOptionOrPositionalArgument(bool acceptMoreValues) + { + var argument = _argument.AsSpan(); + if (argument.Length is 0 or 1) + { + // 空参数或单个字符(无法组成选项),视为选项值。 + Type = Cat.OptionValue; + Value = argument; + return true; + } + + var optionPrefix = _parser.OptionPrefix; + return optionPrefix switch + { + CommandOptionPrefix.DoubleDash => (argument[0], argument[1]) switch + { + ('-', '-') => ParseLongOptionOrLongOptionWithValue(argument[2..]), + ('-', _) => ParseShortOptionOrMultiShortOptions(argument[1..]), + _ when acceptMoreValues => ParseOptionValue(argument), + _ => ParsePositionalArgument(argument), + }, + CommandOptionPrefix.SingleDash => argument[0] switch + { + '-' => ParseLongShortOptionOrLongShortOptionWithValue(argument[1..]), + _ when acceptMoreValues => ParseOptionValue(argument), + _ => ParsePositionalArgument(argument), + }, + CommandOptionPrefix.Slash => argument[0] switch + { + '/' => ParseLongShortOptionOrLongShortOptionWithValue(argument[1..]), + _ when acceptMoreValues => ParseOptionValue(argument), + _ => ParsePositionalArgument(argument), + }, + CommandOptionPrefix.SlashOrDash => argument[0] switch + { + '-' or '/' => ParseLongShortOptionOrLongShortOptionWithValue(argument[1..]), + _ when acceptMoreValues => ParseOptionValue(argument), + _ => ParsePositionalArgument(argument), + }, + CommandOptionPrefix.Any => (argument[0], argument[1]) switch + { + ('-', '-') => ParseLongOptionOrLongOptionWithValue(argument[2..]), + ('-', _) or ('/', _) => ParseLongShortOptionOrLongShortOptionWithValue(argument[1..]), + _ when acceptMoreValues => ParseOptionValue(argument), + _ => ParsePositionalArgument(argument), + }, + _ => throw new ArgumentOutOfRangeException(), + }; + } + + private bool ParseOptionValue(ReadOnlySpan argument) + { + Type = Cat.OptionValue; + Value = argument; + return true; + } + + private bool ParsePositionalArgument(ReadOnlySpan argument) + { + Type = Cat.PositionalArgument; + Value = argument; + return true; + } +} + +file static class Extensions +{ + internal static bool? IsLongOption(this Cat type) => type switch + { + Cat.LongOption or Cat.LongOptionWithValue => true, + Cat.ShortOption or Cat.ShortOptionWithValue or Cat.MultiShortOptions => false, + _ => null, + }; + + internal static ReadOnlySpan ToBooleanSpan(this bool value) => value switch + { + // 用户输入明确指定为 true。 + true => ['1'], + // 用户输入明确指定为 false。 + false => ['0'], + }; + + internal static OptionValueMatch HandleNotExist(this OptionValueMatch match, UnknownOptionBehavior behavior) => behavior switch + { + UnknownOptionBehavior.TakesNoValue => match with { ValueType = OptionValueType.NotExistAndTakesNoValue }, + UnknownOptionBehavior.TakesOptionalValue => match with { ValueType = OptionValueType.NotExistButTakesOptionalValue }, + UnknownOptionBehavior.TakesAllValues => match with { ValueType = OptionValueType.NotExistButTakesAllValues }, + _ => throw new ArgumentOutOfRangeException(nameof(behavior), behavior, null), + }; +} diff --git a/src/DotNetCampus.CommandLine/Utils/Parsers/CommandLineParsingResult.cs b/src/DotNetCampus.CommandLine/Utils/Parsers/CommandLineParsingResult.cs new file mode 100644 index 00000000..e44a6e95 --- /dev/null +++ b/src/DotNetCampus.CommandLine/Utils/Parsers/CommandLineParsingResult.cs @@ -0,0 +1,331 @@ +using DotNetCampus.Cli.Compiler; +using DotNetCampus.Cli.Exceptions; + +namespace DotNetCampus.Cli.Utils.Parsers; + +/// +/// 命令行参数解析结果。 +/// +/// 如果解析失败,此处包含错误类型;否则为 。 +/// 如果解析失败,此处包含错误消息;否则为 。 +public readonly record struct CommandLineParsingResult(CommandLineParsingError ErrorType, string? ErrorMessage) +{ + /// + /// 获取一个值,指示此解析是否成功。 + /// + public bool IsSuccess => ErrorMessage is null; + + /// + /// 将另一个解析结果与当前实例合并。合并后,如果全部成功,则结果为成功;如果有任何一个失败,则结果为失败,并包含第一个失败的错误信息。 + /// + /// + /// 两个都失败,则会使用此实例的错误信息。 + /// + /// 合并后的解析结果。 + public CommandLineParsingResult Combine(CommandLineParsingResult other) + { + return (IsSuccess, other.IsSuccess) switch + { + (true, true) => Success, + (false, true) => this, + (true, false) => other, + (false, false) => other, + }; + } + + /// + /// 将另一个解析结果与当前实例按照忽略优先级合并。合并后,会优先保留没有被忽略的错误信息。 + /// + /// + /// 两个都失败且未被忽略,则会使用此实例的错误信息。 + /// + /// 对于未知参数的处理方式。 + /// 合并后的解析结果。 + public CommandLineParsingResult Combine(CommandLineParsingResult other, UnknownCommandArgumentHandling unknownHandling) + { + return unknownHandling switch + { + UnknownCommandArgumentHandling.AllArgumentsMustBeRecognized => (IsSuccess, other.IsSuccess) switch + { + (true, true) => Success, + (false, true) => this, + (true, false) => other, + (false, false) => other, + }, + UnknownCommandArgumentHandling.IgnoreUnknownOptionalArguments => (ErrorType, other.ErrorType) switch + { + (CommandLineParsingError.None, CommandLineParsingError.None) => Success, + (_, CommandLineParsingError.None) => this, + (CommandLineParsingError.None, _) => other, + (CommandLineParsingError.OptionalArgumentNotFound, CommandLineParsingError.OptionalArgumentNotFound) => this, + (_, CommandLineParsingError.OptionalArgumentNotFound) => this, + _ => other, + }, + UnknownCommandArgumentHandling.IgnoreUnknownPositionalArguments => (ErrorType, other.ErrorType) switch + { + (CommandLineParsingError.None, CommandLineParsingError.None) => Success, + (_, CommandLineParsingError.None) => this, + (CommandLineParsingError.None, _) => other, + (CommandLineParsingError.PositionalArgumentNotFound, CommandLineParsingError.PositionalArgumentNotFound) => this, + (_, CommandLineParsingError.PositionalArgumentNotFound) => this, + _ => other, + }, + UnknownCommandArgumentHandling.IgnoreAllUnknownArguments => (ErrorType, other.ErrorType) switch + { + (CommandLineParsingError.None, CommandLineParsingError.None) => Success, + (_, CommandLineParsingError.None) => this, + (CommandLineParsingError.None, _) => other, + (CommandLineParsingError.OptionalArgumentNotFound or CommandLineParsingError.PositionalArgumentNotFound, CommandLineParsingError + .OptionalArgumentNotFound or CommandLineParsingError.PositionalArgumentNotFound) => this, + (_, CommandLineParsingError.OptionalArgumentNotFound or CommandLineParsingError.PositionalArgumentNotFound) => this, + _ => other, + }, + _ => throw new ArgumentOutOfRangeException(nameof(unknownHandling), unknownHandling, null) + }; + } + + /// + /// 处理解析错误。如果解析结果表示失败,则调用此方法来处理错误。 + /// + /// 命令对象工厂上下文,包含命令行参数和相关配置。 + public void WithFallback(CommandRunningContext context) + { + // 如果解析成功,则不需要处理错误。 + if (IsSuccess) + { + return; + } + + // 根据命令行参数解析选项时,指定的未知参数处理方式,决定是否忽略某些错误。 + var unknownHandling = context.CommandLine.ParsingOptions.UnknownArgumentsHandling; + var ignoreOptionalArguments = unknownHandling is + UnknownCommandArgumentHandling.IgnoreAllUnknownArguments or UnknownCommandArgumentHandling.IgnoreUnknownOptionalArguments; + var ignorePositionalArguments = unknownHandling is + UnknownCommandArgumentHandling.IgnoreAllUnknownArguments or UnknownCommandArgumentHandling.IgnoreUnknownPositionalArguments; + if (ignoreOptionalArguments && ErrorType is CommandLineParsingError.OptionalArgumentNotFound + || ignorePositionalArguments && ErrorType is CommandLineParsingError.PositionalArgumentNotFound) + { + return; + } + + // 尝试使用命令行参数解析器的回调来处理错误。 + if (context.CommandRunner?.RunFallback(this) is true) + { + return; + } + + // 最终还是没有被处理,则抛出异常。 + ThrowIfError(); + } + + /// + /// 如果解析结果表示失败,则抛出一个异常,包含错误信息。 + /// + public void ThrowIfError() + { + if (IsSuccess) + { + return; + } + + throw ErrorType switch + { + CommandLineParsingError.OptionalArgumentNotFound => new CommandLineParseException(ErrorType, ErrorMessage!), + CommandLineParsingError.OptionalArgumentSeparatorNotSupported => new CommandLineParseException(ErrorType, ErrorMessage!), + CommandLineParsingError.MultiCharShortOptionalArgumentNotSupported => new CommandLineParseException(ErrorType, ErrorMessage!), + CommandLineParsingError.OptionalArgumentParseError => new CommandLineParseException(ErrorType, ErrorMessage!), + CommandLineParsingError.PositionalArgumentNotFound => new CommandLineParseException(ErrorType, ErrorMessage!), + CommandLineParsingError.ArgumentCombinationIsNotBoolean => new CommandLineParseException(ErrorType, ErrorMessage!), + CommandLineParsingError.BooleanValueParseError => new CommandLineParseValueException(ErrorType, ErrorMessage!), + CommandLineParsingError.DictionaryValueParseError => new CommandLineParseValueException(ErrorType, ErrorMessage!), + CommandLineParsingError.None => throw new CommandLineException("解析过程中没有发生任何错误。"), + _ => throw new CommandLineException("未知的命令行解析错误类型。"), + }; + } + + /// + /// 隐式转换运算符,允许将 直接转换为布尔值,表示解析是否成功。 + /// + /// 要转换的解析结果。 + /// 如果解析成功,返回 ;否则返回 + public static implicit operator bool(CommandLineParsingResult result) => result.IsSuccess; + + /// + /// 获取一个表示成功的解析结果。 + /// + public static CommandLineParsingResult Success => new(CommandLineParsingError.None, null); + + /// + /// 创建一个表示选项未找到的解析结果。 + /// + /// 整个命令行参数列表。 + /// 当前正在解析的参数索引。 + /// 正在解析此参数的命令对象的名称。 + /// 确定没有找到的选项名称。 + /// 指示此选项名称是否为长选项名称。如果为 ,表示无法确定是长选项还是短选项。 + /// 表示选项未找到的解析结果。 + public static CommandLineParsingResult OptionalArgumentNotFound(CommandLine commandLine, int index, string commandObjectName, + ReadOnlySpan optionName, bool? isLongOption) + { + var isUrl = commandLine.MatchedUrlScheme is not null; + var possibleSeparatorIndex = optionName.IndexOfAnyPossibleSeparators(); + var reason = (isLongOption, possibleSeparatorIndex) switch + { + (_, < 0) => CommandLineParsingError.OptionalArgumentNotFound, + (_, 0) => CommandLineParsingError.OptionalArgumentParseError, + (_, 1) => CommandLineParsingError.OptionalArgumentSeparatorNotSupported, + (false, _) => CommandLineParsingError.MultiCharShortOptionalArgumentNotSupported, + _ => CommandLineParsingError.OptionalArgumentSeparatorNotSupported, + }; + var message = reason switch + { + CommandLineParsingError.OptionalArgumentNotFound when isUrl => + $"命令行对象 {commandObjectName} 没有任何属性的选项名为 {optionName.ToString()},请注意解析 URL 时不支持短选项参数。URL={commandLine.ToRawString()}", + CommandLineParsingError.OptionalArgumentNotFound => + $"命令行对象 {commandObjectName} 没有任何属性的选项名为 {optionName.ToString()}。参数列表:{commandLine},索引 {index},参数 {commandLine.CommandLineArguments[index]}。", + CommandLineParsingError.OptionalArgumentParseError => + $"命令行参数 {commandLine.CommandLineArguments[index]} 中不包含选项名称,解析失败。参数列表:{commandLine},索引 {index}。", + CommandLineParsingError.OptionalArgumentSeparatorNotSupported => + $"当前解析风格 {commandLine.ParsingOptions.Style.Name} 不支持选项值分隔符 '{optionName[possibleSeparatorIndex]}',因此无法识别参数 {commandLine.CommandLineArguments[index]}。参数列表:{commandLine},索引 {index},参数 {commandLine.CommandLineArguments[index]}。", + CommandLineParsingError.MultiCharShortOptionalArgumentNotSupported => + $"当前解析风格 {commandLine.ParsingOptions.Style.Name} 不支持多字符短选项,因此无法识别参数 {commandLine.CommandLineArguments[index]}。参数列表:{commandLine},索引 {index},参数 {commandLine.CommandLineArguments[index]}。", + _ => throw new CommandLineException("Unreachable code."), + }; + return new CommandLineParsingResult(reason, message); + } + + /// + /// 创建一个表示选项组合不支持非布尔类型的解析结果。 + /// + /// 整个命令行参数列表。 + /// 当前正在解析的参数索引。 + /// 正在解析此参数的命令对象的名称。 + /// 类型为非布尔类型的选项名称。 + /// 表示选项组合不支持非布尔类型的解析结果。 + public static CommandLineParsingResult OptionalArgumentCombinationIsNotBoolean(CommandLine commandLine, int index, string commandObjectName, + ReadOnlySpan optionName) + { + var message = + $"命令行对象 {commandObjectName} 中,选项 {optionName.ToString()} 的类型不是布尔类型,因此不支持使用短布尔选项组合的方式来表示此选项。参数列表:{commandLine},索引 {index},参数 {commandLine.CommandLineArguments[index]}。"; + return new CommandLineParsingResult(CommandLineParsingError.ArgumentCombinationIsNotBoolean, message); + } + + /// + /// 创建一个表示选项未找到的解析结果。 + /// + /// 整个命令行参数列表。 + /// 当前正在解析的参数索引。 + /// 正在解析此参数的命令对象的名称。 + /// 表示选项未找到的解析结果。 + public static CommandLineParsingResult OptionalArgumentParseError(CommandLine commandLine, int index, string commandObjectName) + { + var message = $"命令行参数 {commandLine.CommandLineArguments[index]} 中不包含选项名称,解析失败。参数列表:{commandLine},索引 {index}。"; + return new CommandLineParsingResult(CommandLineParsingError.OptionalArgumentParseError, message); + } + + /// + /// 创建一个表示位置参数未找到的解析结果。 + /// + /// 整个命令行参数列表。 + /// 当前正在解析的参数索引。 + /// 正在解析此参数的命令对象的名称。 + /// 要查找的位置参数的索引。 + /// 表示位置参数未找到的解析结果。 + public static CommandLineParsingResult PositionalArgumentNotFound(CommandLine commandLine, int index, string commandObjectName, int positionalArgumentIndex) + { + var message = + $"命令行对象 {commandObjectName} 位置参数范围不包含索引 {positionalArgumentIndex}。参数列表:{commandLine},索引 {index},参数 {commandLine.CommandLineArguments[index]}。"; + return new CommandLineParsingResult(CommandLineParsingError.PositionalArgumentNotFound, message); + } + + /// + /// 创建一个表示无法将值解析为布尔值的解析结果。 + /// + /// 整个命令行参数列表。 + /// 要解析的值。 + /// 表示无法将值解析为布尔值的解析结果。 + public static CommandLineParsingResult BooleanValueParseError(CommandLine commandLine, ReadOnlySpan value) + { + var message = $"无法将 {value.ToString()} 解析为布尔值。参数列表:{commandLine}。"; + return new CommandLineParsingResult(CommandLineParsingError.BooleanValueParseError, message); + } + + /// + /// 创建一个表示无法将值解析为键值对的解析结果。 + /// + /// 整个命令行参数列表。 + /// 要解析的值。 + /// 表示无法将值解析为键值对的解析结果。 + public static CommandLineParsingResult DictionaryValueParseError(CommandLine commandLine, ReadOnlySpan value) + { + var message = $"无法将 {value.ToString()} 解析为键值对。参数列表:{commandLine}。"; + return new CommandLineParsingResult(CommandLineParsingError.DictionaryValueParseError, message); + } +} + +file static class Extensions +{ + internal static int IndexOfAnyPossibleSeparators(this ReadOnlySpan span) + { + for (var i = 0; i < span.Length; i++) + { + if (!char.IsLetterOrDigit(span[i]) && span[i] is not '-' and not '_' and not '.') + { + return i; + } + } + + return -1; + } +} + +/// +/// 命令行参数解析错误类型。 +/// +public enum CommandLineParsingError : byte +{ + /// + /// 没有错误。 + /// + None, + + /// + /// 没有任何选项能够匹配当前的命令行参数。 + /// + OptionalArgumentNotFound, + + /// + /// 没有任何选项能够匹配当前的命令行参数,可能是因为当前的命令行参数使用了不被支持的选项值分隔符。 + /// + OptionalArgumentSeparatorNotSupported, + + /// + /// 当前命令行风格不支持多字符短选项。 + /// + MultiCharShortOptionalArgumentNotSupported, + + /// + /// 当前的命令行参数正试图使用短布尔选项组合的方式来表示一个非布尔类型的选项。 + /// + ArgumentCombinationIsNotBoolean, + + /// + /// 当前的命令行参数无法解析出选项名。 + /// + OptionalArgumentParseError, + + /// + /// 没有任何位置参数的范围能够匹配当前的命令行参数。 + /// + PositionalArgumentNotFound, + + /// + /// 无法将值解析为布尔值。 + /// + BooleanValueParseError, + + /// + /// 无法将值解析为键值对。 + /// + DictionaryValueParseError, +} diff --git a/src/DotNetCampus.CommandLine/Utils/Parsers/DotNetStyleParser.cs b/src/DotNetCampus.CommandLine/Utils/Parsers/DotNetStyleParser.cs deleted file mode 100644 index b3f93141..00000000 --- a/src/DotNetCampus.CommandLine/Utils/Parsers/DotNetStyleParser.cs +++ /dev/null @@ -1,271 +0,0 @@ -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; -using DotNetCampus.Cli.Exceptions; -using DotNetCampus.Cli.Utils.Collections; - -namespace DotNetCampus.Cli.Utils.Parsers; - -/// -internal sealed class DotNetStyleParser : ICommandLineParser -{ - internal static bool ConvertPascalCaseToKebabCase { get; } = true; - - public CommandLineParsedResult Parse(IReadOnlyList commandLineArguments) - { - var longOptions = new OptionDictionary(true); - var shortOptions = new OptionDictionary(true); - var possibleCommandNamesLength = 0; - List arguments = []; - - OptionName? lastOption = null; - var lastType = DotNetParsedType.Start; - - for (var i = 0; i < commandLineArguments.Count; i++) - { - var commandLineArgument = commandLineArguments[i]; - var result = DotNetArgument.Parse(commandLineArgument, lastType); - var tempLastType = lastType; - lastType = result.Type; - - if (result.Type is DotNetParsedType.CommandNameOrPositionalArgument) - { - lastOption = null; - possibleCommandNamesLength++; - var commandNameOrPositionalArgument = result.Value.ToString(); - arguments.Add(commandNameOrPositionalArgument); - continue; - } - - if (result.Type is DotNetParsedType.PositionalArgument - or DotNetParsedType.PostPositionalArgument) - { - lastOption = null; - arguments.Add(result.Value.ToString()); - continue; - } - - if (result.Type is DotNetParsedType.LongOption) - { - lastOption = result.Option; - longOptions.AddOption(result.Option); - continue; - } - - if (result.Type is DotNetParsedType.LongOptionWithValue) - { - lastOption = null; - longOptions.AddValue(result.Option, result.Value.ToString()); - continue; - } - - if (result.Type is DotNetParsedType.ShortOption) - { - lastOption = result.Option; - shortOptions.AddOption(result.Option); - continue; - } - - if (result.Type is DotNetParsedType.ShortOptionWithValue) - { - lastOption = null; - shortOptions.AddValue(result.Option, result.Value.ToString()); - continue; - } - - if (result.Type is DotNetParsedType.OptionValue) - { - // 选项值,直接添加到参数列表中。 - var options = tempLastType switch - { - DotNetParsedType.LongOption => longOptions, - DotNetParsedType.ShortOption => shortOptions, - _ => throw new CommandLineParseException($"Argument value {result.Value.ToString()} does not belong to any option."), - }; - if (lastOption is { } option) - { - options.AddValue(option, result.Value.ToString()); - } - continue; - } - - if (result.Type is DotNetParsedType.PositionalArgumentSeparator) - { - lastOption = null; - } - } - - return new CommandLineParsedResult( - CommandLineParsedResult.MakePossibleCommandNames(commandLineArguments, possibleCommandNamesLength, ConvertPascalCaseToKebabCase), - longOptions, - shortOptions, - arguments.ToReadOnlyList()); - } -} - -internal readonly ref struct DotNetArgument(DotNetParsedType type) -{ - public DotNetParsedType Type { get; } = type; - public OptionName Option { get; private init; } - public ReadOnlySpan Value { get; private init; } - - public static DotNetArgument Parse(string argument, DotNetParsedType lastType) - { - var isPostPositionalArgument = lastType is DotNetParsedType.PositionalArgumentSeparator or DotNetParsedType.PostPositionalArgument; - var hasPrefix = -#if NET5_0_OR_GREATER - OperatingSystem.IsWindows() -#else - RuntimeInformation.IsOSPlatform(OSPlatform.Windows) -#endif - ? argument.Length > 0 && (argument[0] is '-' or '/') - : argument.Length > 0 && argument[0] is '-'; - - if (!isPostPositionalArgument && hasPrefix) - { - if (argument.Length is 1) - { - // 只有一个破折号或斜杠,这在.NET CLI风格中通常被视为位置参数。 - return new DotNetArgument(DotNetParsedType.PositionalArgument) { Value = argument.AsSpan() }; - } - - if (argument.Length is 2) - { - if (argument[0] is '-' && argument[1] is '-') - { - // 位置参数分隔符。 - return new DotNetArgument(DotNetParsedType.PositionalArgumentSeparator); - } - if (char.IsLetterOrDigit(argument[1])) - { - // 短选项。 - return new DotNetArgument(DotNetParsedType.ShortOption) { Option = new OptionName(argument, Range.StartAt(1)) }; - } - throw new CommandLineParseException($"Invalid option format at index [0, 1]: {argument}"); - } - - // 长选项。 - var isKebabCase = true; - var wordStartIndex = argument[1] is '-' ? 2 : 1; - var spans = argument.AsSpan(wordStartIndex); - for (var i = 0; i < spans.Length; i++) - { - var c = spans[i]; - if (i == 0 && !char.IsLetterOrDigit(c)) - { - // 长选项的第一个字符必须是字母或数字。 - throw new CommandLineParseException($"Invalid option format at index [{i}, 2]: {argument}"); - } - if (i > 0 && char.IsUpper(c) && spans[i - 1] != '-') - { - // 遇到 PascalCase 或 camelCase,需要转换为 kebab-case。 - isKebabCase = false; - } - if (c is ':') - { - // 带值的长选项。--option:value - return new DotNetArgument(DotNetParsedType.LongOptionWithValue) - { - Option = isKebabCase - ? new OptionName(argument, new Range(wordStartIndex, i + wordStartIndex)) - : OptionName.MakeKebabCase(spans[..i], DotNetStyleParser.ConvertPascalCaseToKebabCase), - Value = spans[(i + 1)..], - }; - } - } - // 单独的长选项。--option - return new DotNetArgument(DotNetParsedType.LongOption) - { - Option = isKebabCase - ? new OptionName(argument, Range.StartAt(wordStartIndex)) - : OptionName.MakeKebabCase(spans, DotNetStyleParser.ConvertPascalCaseToKebabCase), - }; - } - - if (lastType is DotNetParsedType.Start or DotNetParsedType.CommandNameOrPositionalArgument) - { - // 如果是第一个参数,则后续可能是命令名或位置参数。 - // 如果可能是命令名或位置参数,则后续也可能是命令名或位置参数。 - var isValidName = OptionName.IsValidOptionName(argument.AsSpan()); - return new DotNetArgument(isValidName ? DotNetParsedType.CommandNameOrPositionalArgument : DotNetParsedType.PositionalArgument) - { - Value = argument.AsSpan(), - }; - } - - if (lastType is DotNetParsedType.PositionalArgument) - { - // 如果是位置参数,则后续必定是位置参数。 - return new DotNetArgument(DotNetParsedType.PositionalArgument) { Value = argument.AsSpan() }; - } - - if (lastType is DotNetParsedType.OptionValue - or DotNetParsedType.LongOptionWithValue - or DotNetParsedType.ShortOptionWithValue) - { - // 如果前一个已经是选项值了,那么后一个是位置参数。 - return new DotNetArgument(DotNetParsedType.PositionalArgument) { Value = argument.AsSpan() }; - } - - if (lastType is DotNetParsedType.PositionalArgumentSeparator or DotNetParsedType.PostPositionalArgument) - { - // 如果是位置参数分隔符或后置位置参数,则必定是后置位置参数。 - return new DotNetArgument(DotNetParsedType.PostPositionalArgument) { Value = argument.AsSpan() }; - } - - // 其他情况,都是选项的值。 - return new DotNetArgument(DotNetParsedType.OptionValue) { Value = argument.AsSpan() }; - } -} - -internal enum DotNetParsedType -{ - /// - /// 尚未开始解析。 - /// - Start, - - /// - /// 前几个位置参数,也可能是命令名。 - /// - CommandNameOrPositionalArgument, - - /// - /// 位置参数。 - /// - PositionalArgument, - - /// - /// 长选项。--option -Option /option -tl /tl - /// - LongOption, - - /// - /// 带值的长选项。--option:value -Option:value /option:value -tl:off /tl:off - /// - LongOptionWithValue, - - /// - /// 短选项。-o /o - /// - ShortOption, - - /// - /// 带值的短选项。-o:value /o:value - /// - ShortOptionWithValue, - - /// - /// 选项值。value - /// - OptionValue, - - /// - /// 位置参数分隔符。-- 之后的参数都被视为位置参数。 - /// - PositionalArgumentSeparator, - - /// - /// 后置的位置参数。 - /// - PostPositionalArgument, -} diff --git a/src/DotNetCampus.CommandLine/Utils/Parsers/FlexibleStyleParser.cs b/src/DotNetCampus.CommandLine/Utils/Parsers/FlexibleStyleParser.cs deleted file mode 100644 index c3301428..00000000 --- a/src/DotNetCampus.CommandLine/Utils/Parsers/FlexibleStyleParser.cs +++ /dev/null @@ -1,282 +0,0 @@ -using System.Runtime.InteropServices; -using DotNetCampus.Cli.Exceptions; -using DotNetCampus.Cli.Utils.Collections; - -namespace DotNetCampus.Cli.Utils.Parsers; - -/// -internal sealed class FlexibleStyleParser : ICommandLineParser -{ - internal static bool ConvertPascalCaseToKebabCase { get; } = true; - - public CommandLineParsedResult Parse(IReadOnlyList commandLineArguments) - { - var longOptions = new OptionDictionary(true); - var shortOptions = new OptionDictionary(true); - var possibleCommandNamesLength = 0; - List arguments = []; - - OptionDictionary? lastOptions = null; - OptionName? lastOption = null; - var lastType = FlexibleParsedType.Start; - - for (var i = 0; i < commandLineArguments.Count; i++) - { - var commandLineArgument = commandLineArguments[i]; - var result = FlexibleArgument.Parse(commandLineArgument, lastType); - var tempLastType = lastType; - lastType = result.Type; - - if (result.Type is FlexibleParsedType.CommandNameOrPositionalArgument) - { - lastOptions = null; - lastOption = null; - possibleCommandNamesLength++; - var commandNameOrPositionalArgument = result.Value.ToString(); - arguments.Add(commandNameOrPositionalArgument); - continue; - } - - if (result.Type is FlexibleParsedType.PositionalArgument - or FlexibleParsedType.PostPositionalArgument) - { - lastOptions = null; - lastOption = null; - arguments.Add(result.Value.ToString()); - continue; - } - - if (result.Type is FlexibleParsedType.LongOption) - { - lastOptions = longOptions; - lastOption = result.Option; - longOptions.AddOption(result.Option); - continue; - } - - if (result.Type is FlexibleParsedType.LongOptionWithValue) - { - lastOptions = null; - lastOption = null; - longOptions.AddValue(result.Option, result.Value.ToString()); - continue; - } - - if (result.Type is FlexibleParsedType.ShortOption) - { - lastOptions = shortOptions; - lastOption = result.Option; - shortOptions.AddOption(result.Option); - continue; - } - - if (result.Type is FlexibleParsedType.ShortOptionWithValue) - { - lastOptions = null; - lastOption = null; - shortOptions.AddValue(result.Option, result.Value.ToString()); - continue; - } - - if (result.Type is FlexibleParsedType.OptionValue) - { - // 选项值,直接添加到参数列表中。 - if (lastOptions is { } options && lastOption is { } option) - { - options.AddValue(option, result.Value.ToString()); - } - continue; - } - - if (result.Type is FlexibleParsedType.PositionalArgumentSeparator) - { - lastOption = null; - } - } - - return new CommandLineParsedResult( - CommandLineParsedResult.MakePossibleCommandNames(commandLineArguments, possibleCommandNamesLength, ConvertPascalCaseToKebabCase), - longOptions, - shortOptions, - arguments.ToReadOnlyList()); - } -} - -internal readonly ref struct FlexibleArgument(FlexibleParsedType type) -{ - public FlexibleParsedType Type { get; } = type; - public OptionName Option { get; private init; } - public ReadOnlySpan Value { get; private init; } - - public static FlexibleArgument Parse(string argument, FlexibleParsedType lastType) - { - var isPostPositionalArgument = lastType is FlexibleParsedType.PositionalArgumentSeparator or FlexibleParsedType.PostPositionalArgument; - var hasPrefix = -#if NET5_0_OR_GREATER - OperatingSystem.IsWindows() -#else - RuntimeInformation.IsOSPlatform(OSPlatform.Windows) -#endif - ? argument.Length > 0 && (argument[0] is '-' or '/') - : argument.Length > 0 && argument[0] is '-'; - - if (!isPostPositionalArgument && hasPrefix) - { - if (argument.Length is 1) - { - // 只有一个破折号或斜杠,这在.NET CLI风格中通常被视为位置参数。 - return new FlexibleArgument(FlexibleParsedType.PositionalArgument) { Value = argument.AsSpan() }; - } - - if (argument.Length is 2) - { - if (argument[0] is '-' && argument[1] is '-') - { - // 位置参数分隔符。 - return new FlexibleArgument(FlexibleParsedType.PositionalArgumentSeparator); - } - if (char.IsLetterOrDigit(argument[1])) - { - // 短选项。 - return new FlexibleArgument(FlexibleParsedType.ShortOption) { Option = new OptionName(argument, Range.StartAt(1)) }; - } - throw new CommandLineParseException($"Invalid option format at index [0, 1]: {argument}"); - } - - // 长选项。 - var isKebabCase = true; - var wordStartIndex = argument[1] is '-' ? 2 : 1; - var spans = argument.AsSpan(wordStartIndex); - for (var i = 0; i < spans.Length; i++) - { - var c = spans[i]; - if (i == 0 && !char.IsLetterOrDigit(c)) - { - // 长选项的第一个字符必须是字母或数字。 - throw new CommandLineParseException($"Invalid option format at index [{i}, 2]: {argument}"); - } - if (i > 0 && char.IsUpper(c) && spans[i - 1] != '-') - { - // 遇到 PascalCase 或 camelCase,需要转换为 kebab-case。 - isKebabCase = false; - } - if (c is ':' or '=') - { - // 带值的长选项。--option:value --option=value - return new FlexibleArgument(FlexibleParsedType.LongOptionWithValue) - { - Option = isKebabCase - ? new OptionName(argument, new Range(wordStartIndex, i + wordStartIndex)) - : OptionName.MakeKebabCase(spans[..i], FlexibleStyleParser.ConvertPascalCaseToKebabCase), - Value = spans[(i + 1)..], - }; - } - } - // 单独的长选项。--option - return new FlexibleArgument(FlexibleParsedType.LongOption) - { - Option = isKebabCase - ? new OptionName(argument, Range.StartAt(wordStartIndex)) - : OptionName.MakeKebabCase(spans, FlexibleStyleParser.ConvertPascalCaseToKebabCase), - }; - } - - // 处理各种类型的位置参数 - if (lastType is FlexibleParsedType.Start or FlexibleParsedType.CommandNameOrPositionalArgument) - { - // 如果是第一个参数,则后续可能是命令名或位置参数。 - // 如果可能是命令名或位置参数,则后续也可能是命令名或位置参数。 - var isValidName = OptionName.IsValidOptionName(argument.AsSpan()); - return new FlexibleArgument(isValidName ? FlexibleParsedType.CommandNameOrPositionalArgument : FlexibleParsedType.PositionalArgument) - { - Value = argument.AsSpan(), - }; - } - - if (lastType is FlexibleParsedType.PositionalArgument) - { - // 如果是位置参数,则后续必定是位置参数。 - return new FlexibleArgument(FlexibleParsedType.PositionalArgument) { Value = argument.AsSpan() }; - } - - if (lastType is FlexibleParsedType.OptionValue) - { - // 如果前一个已经是选项值了,那么后一个是位置参数。 - return new FlexibleArgument(FlexibleParsedType.PositionalArgument) { Value = argument.AsSpan() }; - } - - if (lastType is FlexibleParsedType.LongOptionWithValue - or FlexibleParsedType.ShortOptionWithValue) - { - // 如果前一个已经是带有值的选项了,那么后一个是位置参数。 - return new FlexibleArgument(FlexibleParsedType.PositionalArgument) { Value = argument.AsSpan() }; - } - - if (lastType is FlexibleParsedType.PositionalArgumentSeparator or FlexibleParsedType.PostPositionalArgument) - { - // 如果是位置参数分隔符或后置位置参数,则必定是后置位置参数。 - return new FlexibleArgument(FlexibleParsedType.PostPositionalArgument) { Value = argument.AsSpan() }; - } - - // 其他情况,都是选项的值。 - return new FlexibleArgument(FlexibleParsedType.OptionValue) { Value = argument.AsSpan() }; - } -} - -internal enum FlexibleParsedType -{ - /// - /// 尚未开始解析。 - /// - Start, - - /// - /// 前几个位置参数,也可能是命令名。 - /// - CommandNameOrPositionalArgument, - - /// - /// 位置参数。 - /// - PositionalArgument, - - /// - /// 长选项。--option -Option /option -tl /tl - /// - LongOption, - - /// - /// 带值的长选项。--option:value -Option:value /option:value -tl:off /tl:off - /// - LongOptionWithValue, - - /// - /// 短选项。-o /o - /// - ShortOption, - - /// - /// 带值的短选项。-o:value /o:value - /// - ShortOptionWithValue, - - /// - /// 多个短选项。-abc - /// - MultiShortOptions, - - /// - /// 选项值。value - /// - OptionValue, - - /// - /// 位置参数分隔符。-- 之后的参数都被视为位置参数。 - /// - PositionalArgumentSeparator, - - /// - /// 后置的位置参数。 - /// - PostPositionalArgument, -} diff --git a/src/DotNetCampus.CommandLine/Utils/Parsers/GnuStyleParser.cs b/src/DotNetCampus.CommandLine/Utils/Parsers/GnuStyleParser.cs deleted file mode 100644 index ebbb11c6..00000000 --- a/src/DotNetCampus.CommandLine/Utils/Parsers/GnuStyleParser.cs +++ /dev/null @@ -1,308 +0,0 @@ -using DotNetCampus.Cli.Exceptions; -using DotNetCampus.Cli.Utils.Collections; - -namespace DotNetCampus.Cli.Utils.Parsers; - -/// -internal sealed class GnuStyleParser : ICommandLineParser -{ - internal static bool ConvertPascalCaseToKebabCase { get; } = false; - - public CommandLineParsedResult Parse(IReadOnlyList commandLineArguments) - { - var longOptions = new OptionDictionary(true); - var shortOptions = new OptionDictionary(true); - var possibleCommandNamesLength = 0; - List arguments = []; - - OptionName? lastOption = null; - var lastType = GnuParsedType.Start; - var shortLowPriorityOptions = new Dictionary(); - - for (var i = 0; i < commandLineArguments.Count; i++) - { - var commandLineArgument = commandLineArguments[i]; - var result = GnuArgument.Parse(commandLineArgument, lastType); - var tempLastType = lastType; - lastType = result.Type; - - if (result.Type is GnuParsedType.CommandNameOrPositionalArgument) - { - lastOption = null; - possibleCommandNamesLength++; - var commandNameOrPositionalArgument = result.Value.ToString(); - arguments.Add(commandNameOrPositionalArgument); - continue; - } - - if (result.Type is GnuParsedType.PositionalArgument - or GnuParsedType.PostPositionalArgument) - { - lastOption = null; - arguments.Add(result.Value.ToString()); - continue; - } - - if (result.Type is GnuParsedType.LongOption) - { - lastOption = result.Option; - longOptions.AddOption(result.Option); - continue; - } - - if (result.Type is GnuParsedType.LongOptionWithValue) - { - lastOption = null; - longOptions.AddValue(result.Option, result.Value.ToString()); - continue; - } - - if (result.Type is GnuParsedType.ShortOption) - { - lastOption = result.Option; - shortOptions.AddOption(result.Option); - continue; - } - - if (result.Type is GnuParsedType.ShortOptionWithValue) - { - lastOption = null; - shortOptions.AddValue(result.Option, result.Value.ToString()); - continue; - } - - if (result.Type is GnuParsedType.MultiShortOptions) - { - lastOption = null; - foreach (var shortOption in result.Option) - { - shortOptions.AddOption(shortOption); - } - continue; - } - - if (result.Type is GnuParsedType.MultiShortOptionsOrShortOptionWithValue) - { - lastOption = null; - foreach (var shortOption in result.Option) - { - shortOptions.AddOption(shortOption); - } - shortLowPriorityOptions[result.Option[0].ToString()] = result.Value.ToString(); - continue; - } - - if (result.Type is GnuParsedType.OptionValue) - { - // 选项值,直接添加到参数列表中。 - var options = tempLastType switch - { - GnuParsedType.LongOption => longOptions, - GnuParsedType.ShortOption => shortOptions, - _ => throw new CommandLineParseException($"Argument value {result.Value.ToString()} does not belong to any option."), - }; - if (lastOption is { } option) - { - options.AddValue(option, result.Value.ToString()); - } - continue; - } - - if (result.Type is GnuParsedType.PositionalArgumentSeparator) - { - lastOption = null; - } - } - - // 最后,将潜在可能的短选项值添加到短选项中。-abc 其中 a 为选项,bc 为值。 - foreach (var pair in shortLowPriorityOptions) - { - if (!shortOptions.ContainsKey(pair.Key)) - { - shortOptions.AddValue(pair.Key, pair.Value); - } - } - - return new CommandLineParsedResult( - CommandLineParsedResult.MakePossibleCommandNames(commandLineArguments, possibleCommandNamesLength, ConvertPascalCaseToKebabCase), - longOptions, - shortOptions, - arguments.ToReadOnlyList()); - } -} - -internal readonly ref struct GnuArgument(GnuParsedType type) -{ - public GnuParsedType Type { get; } = type; - public OptionName Option { get; private init; } - public ReadOnlySpan Value { get; private init; } - - public static GnuArgument Parse(string argument, GnuParsedType lastType) - { - var isPostPositionalArgument = lastType is GnuParsedType.PositionalArgumentSeparator or GnuParsedType.PostPositionalArgument; - - if (!isPostPositionalArgument && argument is ['-', '-', ..]) - { - if (argument.Length is 2) - { - // 位置参数分隔符。 - return new GnuArgument(GnuParsedType.PositionalArgumentSeparator); - } - - // 长选项。 - var spans = argument.AsSpan(2); - for (var i = 0; i < spans.Length; i++) - { - if (i == 0 && !char.IsLetterOrDigit(spans[i])) - { - // 长选项的第一个字符必须是字母或数字。 - throw new CommandLineParseException($"Invalid option format at index [{i}, 2]: {argument}"); - } - if (spans[i] == '=') - { - // 带值的长选项。--option=value - return new GnuArgument(GnuParsedType.LongOptionWithValue) - { Option = new OptionName(argument, new Range(2, i + 2)), Value = spans[(i + 1)..] }; - } - } - // 单独的长选项。--option - return new GnuArgument(GnuParsedType.LongOption) { Option = new OptionName(argument, Range.StartAt(2)) }; - } - - if (!isPostPositionalArgument && argument is ['-', _, ..]) - { - if (argument.Length is 2) - { - if (!char.IsLetterOrDigit(argument[1])) - { - // 短选项字符必须是字母或数字。 - throw new CommandLineParseException($"Invalid option format at index [{argument.Length}, 1]: {argument}"); - } - // 单独的短选项。 - return new GnuArgument(GnuParsedType.ShortOption) { Option = new OptionName(argument, Range.StartAt(1)) }; - } - - var spans = argument.AsSpan(1); - for (var i = 0; i < spans.Length; i++) - { - if (i == 0 && !char.IsLetterOrDigit(spans[i])) - { - // 短选项的第一个字符必须是字母或数字。 - throw new CommandLineParseException($"Invalid option format at index [{i}, 1]: {argument}"); - } - if (spans[i] == '=') - { - // 带值的短选项。 - return new GnuArgument(GnuParsedType.ShortOptionWithValue) - { Option = new OptionName(argument, new Range(1, 2)), Value = spans[2..] }; - } - if (!char.IsLetterOrDigit(spans[i])) - { - // 包含非字母或数字,说明必定是带值的短选项。-o1.txt - return new GnuArgument(GnuParsedType.ShortOptionWithValue) { Option = new OptionName(argument, new Range(1, 2)), Value = spans[1..] }; - } - } - // 多个短选项,或者带值的短选项。 - return new GnuArgument(GnuParsedType.MultiShortOptionsOrShortOptionWithValue) - { Option = new OptionName(argument, Range.StartAt(1)), Value = spans[1..] }; - } - - if (lastType is GnuParsedType.Start or GnuParsedType.CommandNameOrPositionalArgument) - { - // 如果是第一个参数,则后续可能是命令名或位置参数。 - // 如果可能是命令名或位置参数,则后续也可能是命令名或位置参数。 - var isValidName = OptionName.IsValidOptionName(argument.AsSpan()); - return new GnuArgument(isValidName ? GnuParsedType.CommandNameOrPositionalArgument : GnuParsedType.PositionalArgument) - { - Value = argument.AsSpan(), - }; - } - - if (lastType is GnuParsedType.PositionalArgument) - { - // 如果是位置参数,则必定是位置参数。 - return new GnuArgument(GnuParsedType.PositionalArgument) { Value = argument.AsSpan() }; - } - - if (lastType is GnuParsedType.OptionValue - or GnuParsedType.LongOptionWithValue - or GnuParsedType.ShortOptionWithValue - or GnuParsedType.MultiShortOptionsOrShortOptionWithValue) - { - // 如果前一个已经是选项值了,那么后一个是位置参数。 - return new GnuArgument(GnuParsedType.PositionalArgument) { Value = argument.AsSpan() }; - } - - if (lastType is GnuParsedType.PositionalArgumentSeparator or GnuParsedType.PostPositionalArgument) - { - // 如果是位置参数分隔符或后置位置参数,则必定是后置位置参数。 - return new GnuArgument(GnuParsedType.PostPositionalArgument) { Value = argument.AsSpan() }; - } - - // 其他情况,都是选项的值。 - return new GnuArgument(GnuParsedType.OptionValue) { Value = argument.AsSpan() }; - } -} - -internal enum GnuParsedType -{ - /// - /// 尚未开始解析。 - /// - Start, - - /// - /// 前几个位置参数,也可能是命令名。 - /// - CommandNameOrPositionalArgument, - - /// - /// 位置参数。 - /// - PositionalArgument, - - /// - /// 长选项。--long-option - /// - LongOption, - - /// - /// 带值的长选项。--long-option=value - /// - LongOptionWithValue, - - /// - /// 短选项。-o - /// - ShortOption, - - /// - /// 带值的短选项。-o=value - /// - ShortOptionWithValue, - - /// - /// 多个短选项。-abc - /// - MultiShortOptions, - - /// - /// 多个短选项,也可能是带值的短选项。-abc -o1.txt - /// - MultiShortOptionsOrShortOptionWithValue, - - /// - /// 选项值。value - /// - OptionValue, - - /// - /// 位置参数分隔符。-- 之后的参数都被视为位置参数。 - /// - PositionalArgumentSeparator, - - /// - /// 后置的位置参数。 - /// - PostPositionalArgument, -} diff --git a/src/DotNetCampus.CommandLine/Utils/Parsers/ICommandLineParser.cs b/src/DotNetCampus.CommandLine/Utils/Parsers/ICommandLineParser.cs deleted file mode 100644 index b82d62f9..00000000 --- a/src/DotNetCampus.CommandLine/Utils/Parsers/ICommandLineParser.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace DotNetCampus.Cli.Utils.Parsers; - -internal interface ICommandLineParser -{ - CommandLineParsedResult Parse(IReadOnlyList commandLineArguments); -} diff --git a/src/DotNetCampus.CommandLine/Utils/Parsers/PosixStyleParser.cs b/src/DotNetCampus.CommandLine/Utils/Parsers/PosixStyleParser.cs deleted file mode 100644 index c461fa5b..00000000 --- a/src/DotNetCampus.CommandLine/Utils/Parsers/PosixStyleParser.cs +++ /dev/null @@ -1,218 +0,0 @@ -using DotNetCampus.Cli.Exceptions; -using DotNetCampus.Cli.Utils.Collections; - -namespace DotNetCampus.Cli.Utils.Parsers; - -/// -internal sealed class PosixStyleParser : ICommandLineParser -{ - internal static bool ConvertPascalCaseToKebabCase { get; } = false; - - public CommandLineParsedResult Parse(IReadOnlyList commandLineArguments) - { - var shortOptions = new OptionDictionary(true); - var possibleCommandNamesLength = 0; - List arguments = []; - - OptionName? lastOption = null; - var lastType = PosixParsedType.Start; - - for (var i = 0; i < commandLineArguments.Count; i++) - { - var commandLineArgument = commandLineArguments[i]; - var result = PosixArgument.Parse(commandLineArgument, lastType); - var tempLastType = lastType; - lastType = result.Type; - - if (result.Type is PosixParsedType.CommandNameOrPositionalArgument) - { - lastOption = null; - possibleCommandNamesLength++; - var commandNameOrPositionalArgument = result.Value.ToString(); - arguments.Add(commandNameOrPositionalArgument); - continue; - } - - if (result.Type is PosixParsedType.PositionalArgument - or PosixParsedType.PostPositionalArgument) - { - lastOption = null; - arguments.Add(result.Value.ToString()); - continue; - } - - if (result.Type is PosixParsedType.ShortOption) - { - lastOption = result.Option; - shortOptions.AddOption(result.Option); - continue; - } - - if (result.Type is PosixParsedType.MultiShortOptions) - { - lastOption = null; - foreach (var shortOption in result.Option) - { - shortOptions.AddOption(shortOption); - } - continue; - } - - if (result.Type is PosixParsedType.OptionValue) - { - // 选项值,直接添加到参数列表中。 - var options = tempLastType switch - { - PosixParsedType.ShortOption => shortOptions, - _ => throw new CommandLineParseException($"Argument value {result.Value.ToString()} does not belong to any option."), - }; - if (lastOption is { } option) - { - options.AddValue(option, result.Value.ToString()); - lastOption = null; - } - continue; - } - - if (result.Type is PosixParsedType.PositionalArgumentSeparator) - { - lastOption = null; - } - } - - return new CommandLineParsedResult( - CommandLineParsedResult.MakePossibleCommandNames(commandLineArguments, possibleCommandNamesLength, ConvertPascalCaseToKebabCase), - OptionDictionary.Empty, // POSIX 风格不支持长选项 - shortOptions, - arguments.ToReadOnlyList()); - } -} - -internal readonly ref struct PosixArgument(PosixParsedType type) -{ - public PosixParsedType Type { get; } = type; - public OptionName Option { get; private init; } - public ReadOnlySpan Value { get; private init; } - - public static PosixArgument Parse(string argument, PosixParsedType lastType) - { - var isPostPositionalArgument = lastType is PosixParsedType.PositionalArgumentSeparator or PosixParsedType.PostPositionalArgument; - - if (!isPostPositionalArgument && argument is "--") - { - // 位置参数分隔符。 - return new PosixArgument(PosixParsedType.PositionalArgumentSeparator); - } - - if (!isPostPositionalArgument && argument is ['-', '-', ..]) - { - // POSIX 风格不支持长选项 - throw new CommandLineParseException($"Long options (starting with '--') are not supported in POSIX style: {argument}"); - } - - if (!isPostPositionalArgument && argument is ['-', _, ..]) - { - if (argument.Length is 2) - { - if (!char.IsLetterOrDigit(argument[1])) - { - // 短选项字符必须是字母或数字。 - throw new CommandLineParseException($"Invalid option format at index [{argument.Length}, 1]: {argument}"); - } - // 单独的短选项。 - return new PosixArgument(PosixParsedType.ShortOption) { Option = new OptionName(argument, Range.StartAt(1)) }; - } - - // 检查所有字符是否都是有效的选项字符 - for (var i = 1; i < argument.Length; i++) - { - if (!char.IsLetterOrDigit(argument[i])) - { - throw new CommandLineParseException($"Invalid option character in POSIX style: {argument[i]} in {argument}"); - } - } - - // 多个短选项,如 -abc - return new PosixArgument(PosixParsedType.MultiShortOptions) { Option = new OptionName(argument, Range.StartAt(1)) }; - } - - if (lastType is PosixParsedType.Start or PosixParsedType.CommandNameOrPositionalArgument) - { - // 如果是第一个参数,则后续可能是命令名或位置参数。 - // 如果可能是命令名或位置参数,则后续也可能是命令名或位置参数。 - var isValidName = OptionName.IsValidOptionName(argument.AsSpan()); - return new PosixArgument(isValidName ? PosixParsedType.CommandNameOrPositionalArgument : PosixParsedType.PositionalArgument) - { - Value = argument.AsSpan(), - }; - } - - if (lastType is PosixParsedType.PositionalArgument) - { - // 如果上一个是位置参数,则这个也是位置参数。 - return new PosixArgument(PosixParsedType.PositionalArgument) { Value = argument.AsSpan() }; - } - - if (lastType is PosixParsedType.OptionValue) - { - // 如果前一个已经是选项值了,那么后一个是位置参数。 - return new PosixArgument(PosixParsedType.PositionalArgument) { Value = argument.AsSpan() }; - } - - if (lastType is PosixParsedType.PositionalArgumentSeparator or PosixParsedType.PostPositionalArgument) - { - // 如果是位置参数分隔符或后置位置参数,则必定是后置位置参数。 - return new PosixArgument(PosixParsedType.PostPositionalArgument) { Value = argument.AsSpan() }; - } if (lastType is PosixParsedType.MultiShortOptions) - { - // 在POSIX风格中,组合短选项后面不能直接跟参数值 - throw new CommandLineParseException($"Combined short options cannot have parameters in POSIX style: {argument}"); - } - - // 其他情况,是单个短选项的值。 - return new PosixArgument(PosixParsedType.OptionValue) { Value = argument.AsSpan() }; - } -} - -internal enum PosixParsedType -{ - /// - /// 尚未开始解析。 - /// - Start, - - /// - /// 前几个位置参数,也可能是命令名。 - /// - CommandNameOrPositionalArgument, - - /// - /// 位置参数。 - /// - PositionalArgument, - - /// - /// 短选项。-o - /// - ShortOption, - - /// - /// 多个短选项。-abc - /// - MultiShortOptions, - - /// - /// 选项值。value - /// - OptionValue, - - /// - /// 位置参数分隔符。-- 之后的参数都被视为位置参数。 - /// - PositionalArgumentSeparator, - - /// - /// 后置的位置参数。 - /// - PostPositionalArgument, -} diff --git a/src/DotNetCampus.CommandLine/Utils/Parsers/PowerShellStyleParser.cs b/src/DotNetCampus.CommandLine/Utils/Parsers/PowerShellStyleParser.cs deleted file mode 100644 index 86a0c18a..00000000 --- a/src/DotNetCampus.CommandLine/Utils/Parsers/PowerShellStyleParser.cs +++ /dev/null @@ -1,183 +0,0 @@ -using DotNetCampus.Cli.Utils.Collections; - -namespace DotNetCampus.Cli.Utils.Parsers; - -/// -internal sealed class PowerShellStyleParser : ICommandLineParser -{ - internal static bool ConvertPascalCaseToKebabCase { get; } = true; - - public CommandLineParsedResult Parse(IReadOnlyList commandLineArguments) - { - var longOptions = new OptionDictionary(true); - var possibleCommandNamesLength = 0; - List arguments = []; - - OptionName? lastOption = null; - var lastType = PowerShellParsedType.Start; - - for (var i = 0; i < commandLineArguments.Count; i++) - { - var commandLineArgument = commandLineArguments[i]; - var result = PowerShellArgument.Parse(commandLineArgument, lastType); - lastType = result.Type; - - if (result.Type is PowerShellParsedType.CommandNameOrPositionalArgument) - { - lastOption = null; - possibleCommandNamesLength++; - var commandNameOrPositionalArgument = result.Value.ToString(); - arguments.Add(commandNameOrPositionalArgument); - continue; - } - - if (result.Type is PowerShellParsedType.PositionalArgument - or PowerShellParsedType.PostPositionalArgument) - { - lastOption = null; - arguments.Add(result.Value.ToString()); - continue; - } - - if (result.Type is PowerShellParsedType.Option) - { - lastOption = result.Option; - longOptions.AddOption(result.Option); - continue; - } - - if (result.Type is PowerShellParsedType.OptionValue) - { - // 选项值 - if (lastOption is { } option) - { - longOptions.AddValue(option, result.Value.ToString()); - } - continue; - } - - if (result.Type is PowerShellParsedType.PositionalArgumentSeparator) - { - lastOption = null; - } - } - - return new CommandLineParsedResult( - CommandLineParsedResult.MakePossibleCommandNames(commandLineArguments, possibleCommandNamesLength, ConvertPascalCaseToKebabCase), - longOptions, - // PowerShell 风格不使用短选项,所以直接使用空字典。 - OptionDictionary.Empty, - arguments.ToReadOnlyList()); - } -} - -internal readonly ref struct PowerShellArgument(PowerShellParsedType type) -{ - public PowerShellParsedType Type { get; } = type; - public OptionName Option { get; private init; } - public ReadOnlySpan Value { get; private init; } - - public static PowerShellArgument Parse(string argument, PowerShellParsedType lastType) - { - var isPostPositionalArgument = lastType is PowerShellParsedType.PositionalArgumentSeparator or PowerShellParsedType.PostPositionalArgument; - - if (!isPostPositionalArgument && argument is "--") - { - // 位置参数分隔符。 - return new PowerShellArgument(PowerShellParsedType.PositionalArgumentSeparator); - } - - if (!isPostPositionalArgument && argument.StartsWith( -#if NETCOREAPP3_1_OR_GREATER - '-' -#else - "-" -#endif - ) && argument.Length > 1 && !char.IsDigit(argument[1])) - { - // PowerShell 风格的选项 (-ParameterName) - var optionSpan = argument.AsSpan(1); - return new PowerShellArgument(PowerShellParsedType.Option) - { - Option = OptionName.MakeKebabCase(optionSpan, PowerShellStyleParser.ConvertPascalCaseToKebabCase), - }; - } - - // 处理各种类型的位置参数和选项值 - if (lastType is PowerShellParsedType.Start or PowerShellParsedType.CommandNameOrPositionalArgument) - { - // 如果是第一个参数,则后续可能是命令名或位置参数。 - // 如果可能是命令名或位置参数,则后续也可能是命令名或位置参数。 - var isValidName = OptionName.IsValidOptionName(argument.AsSpan()); - return new PowerShellArgument(isValidName ? PowerShellParsedType.CommandNameOrPositionalArgument : PowerShellParsedType.PositionalArgument) - { - Value = argument.AsSpan(), - }; - } - - if (lastType is PowerShellParsedType.PositionalArgument) - { - // 如果前一个是位置参数,则当前也是位置参数。 - return new PowerShellArgument(PowerShellParsedType.PositionalArgument) { Value = argument.AsSpan() }; - } - - if (lastType is PowerShellParsedType.Option) - { - // 如果前一个是选项,则当前是选项值。 - return new PowerShellArgument(PowerShellParsedType.OptionValue) { Value = argument.AsSpan() }; - } - - if (lastType is PowerShellParsedType.OptionValue) - { - // 如果前一个已经是选项值了,那么后一个是位置参数。 - return new PowerShellArgument(PowerShellParsedType.PositionalArgument) { Value = argument.AsSpan() }; - } - - if (lastType is PowerShellParsedType.PositionalArgumentSeparator or PowerShellParsedType.PostPositionalArgument) - { - // 如果前一个是位置参数分隔符或后置位置参数,则当前是后置位置参数。 - return new PowerShellArgument(PowerShellParsedType.PostPositionalArgument) { Value = argument.AsSpan() }; - } - - // 其他情况,都视为位置参数。 - return new PowerShellArgument(PowerShellParsedType.PositionalArgument) { Value = argument.AsSpan() }; - } -} - -internal enum PowerShellParsedType -{ - /// - /// 尚未开始解析。 - /// - Start, - - /// - /// 前几个位置参数,也可能是命令名。 - /// - CommandNameOrPositionalArgument, - - /// - /// 位置参数。 - /// - PositionalArgument, - - /// - /// PowerShell风格的选项。-ParameterName - /// - Option, - - /// - /// 选项值。 - /// - OptionValue, - - /// - /// 位置参数分隔符。-- 之后的参数都被视为位置参数。 - /// - PositionalArgumentSeparator, - - /// - /// 后置的位置参数。 - /// - PostPositionalArgument, -} diff --git a/src/DotNetCampus.CommandLine/Utils/Parsers/UrlStyleParser.cs b/src/DotNetCampus.CommandLine/Utils/Parsers/UrlStyleParser.cs deleted file mode 100644 index 386b8771..00000000 --- a/src/DotNetCampus.CommandLine/Utils/Parsers/UrlStyleParser.cs +++ /dev/null @@ -1,321 +0,0 @@ -using System.Runtime.CompilerServices; -using System.Web; -using DotNetCampus.Cli.Exceptions; -using DotNetCampus.Cli.Utils.Collections; - -namespace DotNetCampus.Cli.Utils.Parsers; - -/// -/// URL风格的命令行参数解析器 -/// 用于解析来自Web的URL格式命令行参数 -/// -internal sealed class UrlStyleParser : ICommandLineParser -{ - internal static bool ConvertPascalCaseToKebabCase { get; } = false; - private const string FragmentName = "fragment"; - private readonly string _scheme; - - /// - /// 创建URL风格解析器 - /// - /// URL方案名(scheme) - public UrlStyleParser(string scheme) - { - _scheme = scheme; - } - - public CommandLineParsedResult Parse(IReadOnlyList commandLineArguments) - { - if (commandLineArguments.Count is not 1) - { - throw new CommandLineParseException($"URL style parser expects exactly one argument, but got {commandLineArguments.Count}."); - } - - var url = commandLineArguments[0]; - - var longOptions = new OptionDictionary(true); - var shortOptions = new OptionDictionary(true); - List possibleCommandNames = []; - List arguments = []; - - string? lastParameterName = null; - var lastType = UrlParsedType.Start; - - for (var i = 0; i < url.Length;) - { - var result = UrlPart.ReadNext(url, ref i, lastType); - lastType = result.Type; - - if (result.Type is UrlParsedType.CommandNameOrPositionalArgument) - { - lastParameterName = null; - var commandNameOrPositionalArgument = result.Value; - possibleCommandNames.Add(commandNameOrPositionalArgument); - arguments.Add(commandNameOrPositionalArgument); - continue; - } - - if (result.Type is UrlParsedType.PositionalArgument) - { - lastParameterName = null; - arguments.Add(result.Value); - continue; - } - - if (result.Type is UrlParsedType.ParameterName) - { - lastParameterName = result.Name; - longOptions.AddOption(result.Name); - continue; - } - - if (result.Type is UrlParsedType.ParameterValue) - { - if (lastParameterName is null) - { - throw new CommandLineParseException($"Invalid URL format: {url}. Parameter value '{result.Value}' without a name."); - } - - longOptions.AddValue(lastParameterName, result.Value); - lastParameterName = null; - continue; - } - - if (result.Type is UrlParsedType.Fragment) - { - lastParameterName = null; - longOptions.AddValue(result.Name, result.Value); - continue; - } - } - - return new CommandLineParsedResult( - CommandLineParsedResult.MakePossibleCommandNames(possibleCommandNames, ConvertPascalCaseToKebabCase), - longOptions, - shortOptions, - arguments.ToReadOnlyList()); - } - - - internal readonly ref struct UrlPart(UrlParsedType type) - { - public UrlParsedType Type { get; } = type; - public string Name { get; private init; } = ""; - public string Value { get; private init; } = ""; - - public static UrlPart ReadNext(string url, ref int index, UrlParsedType lastType) - { - if (lastType is UrlParsedType.Start) - { - // 取出第一个位置参数(或命令名) - var startIndex = -1; - for (var i = index; i < url.Length - 3; i++) - { - if (url[i] is ':' && url[i + 1] is '/' && url[i + 2] is '/') - { - startIndex = i + 3; - break; - } - } - if (startIndex < 0) - { - // 如果是开始,则必须包含 scheme:// - throw new CommandLineParseException($"Invalid URL format: {url}. Missing '://'"); - } - var endIndex = url.IndexOfAny(['/', '?', '#', '&'], startIndex); - if (endIndex < 0) - { - // 此 URL 没有选项,最后一个值是位置参数或命令名 - endIndex = url.Length; - index = endIndex + 1; - } - else - { - index = endIndex; - } - - var value = HttpUtility.UrlDecode(url.AsSpan(startIndex, endIndex - startIndex).ToString()); - var isValidName = OptionName.IsValidOptionName(value.AsSpan()); - return new UrlPart(isValidName ? UrlParsedType.CommandNameOrPositionalArgument : UrlParsedType.PositionalArgument) - { - Value = value, - }; - } - - if (lastType is UrlParsedType.CommandNameOrPositionalArgument) - { - return url[index] switch - { - // 取出非第一个位置参数(或命令名) - '/' => ReadNextPositionalArgument(url, ref index, lastType), - // 查询参数名。 - '?' => ReadNextParameterName(url, ref index), - // 片段。 - '#' => ReadFragment(url, ref index), - _ => throw new CommandLineParseException($"Invalid URL format: {url}. Expected '/', '?' or '#' after a positional argument."), - }; - } - - if (lastType is UrlParsedType.PositionalArgument) - { - return url[index] switch - { - // 新的位置参数。 - '/' => ReadNextPositionalArgument(url, ref index, lastType), - // 查询参数名。 - '?' => ReadNextParameterName(url, ref index), - // 片段。 - '#' => ReadFragment(url, ref index), - _ => throw new CommandLineParseException($"Invalid URL format: {url}. Expected '/', '?' or '#' after a positional argument."), - }; - } - - if (lastType is UrlParsedType.ParameterName) - { - return url[index] switch - { - // 查询参数值。 - '=' => ReadNextParameterValue(url, ref index), - // 查询新的参数名。 - '&' => ReadNextParameterName(url, ref index), - // 片段。 - '#' => ReadFragment(url, ref index), - _ => throw new CommandLineParseException($"Invalid URL format: {url}. Expected '=', '&' or '#' after a parameter name."), - }; - } - - if (lastType is UrlParsedType.ParameterValue) - { - return url[index] switch - { - // 查询新的参数名。 - '&' => ReadNextParameterName(url, ref index), - // 片段。 - '#' => ReadFragment(url, ref index), - _ => throw new CommandLineParseException($"Invalid URL format: {url}. Expected '&' or '#' after a parameter value."), - }; - } - - throw new CommandLineParseException($"Invalid URL format: {url}"); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static UrlPart ReadNextPositionalArgument(string url, ref int index, UrlParsedType lastType) - { - var startIndex = index; - var endIndex = url.IndexOfAny(['/', '?', '#', '&'], startIndex + 1); - if (endIndex < 0) - { - endIndex = url.Length; - index = endIndex + 1; - } - else - { - index = endIndex; - } - var value = HttpUtility.UrlDecode(url.AsSpan(startIndex + 1, endIndex - startIndex - 1).ToString()); - var isValidName = OptionName.IsValidOptionName(value.AsSpan()); - var type = lastType is UrlParsedType.PositionalArgument - ? UrlParsedType.PositionalArgument - : UrlParsedType.CommandNameOrPositionalArgument; - index = endIndex; - return new UrlPart(isValidName ? type : UrlParsedType.PositionalArgument) - { - Value = value, - }; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static UrlPart ReadNextParameterName(string url, ref int index) - { - var startIndex = index; - var endIndex = url.IndexOfAny(['=', '#', '&'], index + 1); - if (endIndex < 0) - { - endIndex = url.Length; - index = endIndex + 1; - } - else - { - index = endIndex; - } - var value = HttpUtility.UrlDecode(url.AsSpan(startIndex + 1, endIndex - startIndex - 1).ToString()); - index = endIndex; - return new UrlPart(UrlParsedType.ParameterName) - { - Name = OptionName.MakeKebabCase(value -#if !NETCOREAPP3_1_OR_GREATER - .AsSpan() -#endif - ), - }; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static UrlPart ReadNextParameterValue(string url, ref int index) - { - var startIndex = index; - var endIndex = url.IndexOfAny(['&', '#'], index + 1); - if (endIndex < 0) - { - endIndex = url.Length; - index = endIndex + 1; - } - else - { - index = endIndex; - } - var value = HttpUtility.UrlDecode(url.AsSpan(startIndex + 1, endIndex - startIndex - 1).ToString()); - index = endIndex; - return new UrlPart(UrlParsedType.ParameterValue) - { - Value = value, - }; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static UrlPart ReadFragment(string url, ref int index) - { - var startIndex = index; - index = url.Length + 1; - return new UrlPart(UrlParsedType.Fragment) - { - Name = FragmentName, - Value = HttpUtility.UrlDecode(url.AsSpan(startIndex + 1).ToString()), - }; - } - } -} - -internal enum UrlParsedType -{ - /// - /// 尚未开始解析。 - /// - Start, - - /// - /// 前几个位置参数,也可能是命令名。 - /// - CommandNameOrPositionalArgument, - - /// - /// 位置参数。 - /// - PositionalArgument, - - /// - /// 查询参数名。 - /// - ParameterName, - - /// - /// 查询参数值。 - /// - ParameterValue, - - /// - /// 片段参数名。 - /// - Fragment, -} diff --git a/tests/DotNetCampus.CommandLine.FakeObjects/CommandObjectInAnotherAssembly.cs b/tests/DotNetCampus.CommandLine.FakeObjects/CommandObjectInAnotherAssembly.cs new file mode 100644 index 00000000..8d0fc99c --- /dev/null +++ b/tests/DotNetCampus.CommandLine.FakeObjects/CommandObjectInAnotherAssembly.cs @@ -0,0 +1,23 @@ +using DotNetCampus.Cli.Compiler; + +namespace DotNetCampus.CommandLine.FakeObjects; + +public class CommandObject0InAnotherAssembly +{ + [Option('o', "option")] + public string? Option { get; set; } +} + +[Command("test")] +public class CommandObject1InAnotherAssembly +{ + [Option('o', "option")] + public string? Option { get; set; } +} + +[Command("command in-another-assembly")] +public class CommandObject2InAnotherAssembly +{ + [Option('o', "option")] + public string? Option { get; set; } +} diff --git a/tests/DotNetCampus.CommandLine.FakeObjects/DotNetCampus.CommandLine.FakeObjects.csproj b/tests/DotNetCampus.CommandLine.FakeObjects/DotNetCampus.CommandLine.FakeObjects.csproj new file mode 100644 index 00000000..a019adf9 --- /dev/null +++ b/tests/DotNetCampus.CommandLine.FakeObjects/DotNetCampus.CommandLine.FakeObjects.csproj @@ -0,0 +1,12 @@ + + + + net8.0 + + + + + + + + diff --git a/tests/DotNetCampus.CommandLine.Performance/CommandLineParserTest.cs b/tests/DotNetCampus.CommandLine.Performance/CommandLineParserTest.cs deleted file mode 100644 index a251bf9d..00000000 --- a/tests/DotNetCampus.CommandLine.Performance/CommandLineParserTest.cs +++ /dev/null @@ -1,222 +0,0 @@ -using System.CommandLine; -using System.IO; -using BenchmarkDotNet.Attributes; -using CommandLine; -using dotnetCampus.Cli; -using DotNetCampus.Cli.Performance.Fakes; -using DotNetCampus.Cli.Tests.Fakes; -using static DotNetCampus.Cli.Tests.Fakes.CommandLineArgs; -using static DotNetCampus.Cli.CommandLineParsingOptions; - -// ReSharper disable ReturnValueOfPureMethodIsNotUsed - -namespace DotNetCampus.Cli.Performance; - -// [DryJob] // 取消注释以验证测试能否运行。 -[MemoryDiagnoser] -[BenchmarkCategory("CommandLine.Parse")] -public class CommandLineParserTest -{ - [Benchmark(Description = "parse [] --flexible")] - public void Parse_NoArgs_Flexible() - { - var commandLine = CommandLine.Parse(NoArgs, Flexible); - commandLine.As(); - } - - [Benchmark(Description = "parse [] --gnu")] - public void Parse_NoArgs_Gnu() - { - var commandLine = CommandLine.Parse(NoArgs, Gnu); - commandLine.As(); - } - - [Benchmark(Description = "parse [] --posix")] - public void Parse_NoArgs_Posix() - { - var commandLine = CommandLine.Parse(NoArgs, Posix); - commandLine.As(); - } - - [Benchmark(Description = "parse [] --dotnet")] - public void Parse_NoArgs_DotNet() - { - var commandLine = CommandLine.Parse(NoArgs, DotNet); - commandLine.As(); - } - - [Benchmark(Description = "parse [] --powershell")] - public void Parse_NoArgs_PowerShell() - { - var commandLine = CommandLine.Parse(NoArgs, PowerShell); - commandLine.As(); - } - - [Benchmark(Description = "parse [] -v=3.x -p=parser")] - public void Parse_NoArgs_3x_Parser() - { - var commandLine = dotnetCampus.Cli.CommandLine.Parse(NoArgs); - commandLine.As(new OptionsParser()); - } - - [Benchmark(Description = "parse [] -v=3.x -p=runtime")] - public void Parse_NoArgs_3x_Runtime() - { - var commandLine = dotnetCampus.Cli.CommandLine.Parse(NoArgs); - commandLine.As(); - } - - [Benchmark(Description = "parse [PS1] --flexible")] - public void Parse_PowerShell_Flexible() - { - var commandLine = CommandLine.Parse(WindowsStyleArgs, Flexible); - commandLine.As(); - } - - [Benchmark(Description = "parse [PS1] --dotnet")] - public void Parse_PowerShell_PowerShell() - { - var commandLine = CommandLine.Parse(WindowsStyleArgs, DotNet); - commandLine.As(); - } - - [Benchmark(Description = "parse [PS1] -v=3.x -p=parser")] - public void Parse_PowerShell_3x_Parser() - { - var commandLine = dotnetCampus.Cli.CommandLine.Parse(WindowsStyleArgs); - commandLine.As(new OptionsParser()); - } - - [Benchmark(Description = "parse [PS1] -v=3.x -p=runtime")] - public void Parse_PowerShell_3x_Runtime() - { - var commandLine = dotnetCampus.Cli.CommandLine.Parse(WindowsStyleArgs); - commandLine.As(); - } - - [Benchmark(Description = "parse [CMD] --flexible")] - public void Parse_Cmd_Flexible() - { - var commandLine = CommandLine.Parse(CmdStyleArgs, Flexible); - commandLine.As(); - } - - [Benchmark(Description = "parse [CMD] --dotnet")] - public void Parse_Cmd_PowerShell() - { - var commandLine = CommandLine.Parse(CmdStyleArgs, DotNet); - commandLine.As(); - } - - [Benchmark(Description = "parse [CMD] -v=3.x -p=parser")] - public void Parse_Cmd_3x_Parser() - { - var commandLine = dotnetCampus.Cli.CommandLine.Parse(CmdStyleArgs); - commandLine.As(new OptionsParser()); - } - - [Benchmark(Description = "parse [CMD] -v=3.x -p=runtime")] - public void Parse_Cmd_3x_Runtime() - { - var commandLine = dotnetCampus.Cli.CommandLine.Parse(CmdStyleArgs); - commandLine.As(); - } - - [Benchmark(Description = "parse [GNU] --flexible")] - public void Parse_Gnu_Flexible() - { - var commandLine = CommandLine.Parse(LinuxStyleArgs, Flexible); - commandLine.As(); - } - - [Benchmark(Description = "parse [GNU] --gnu")] - public void Parse_Gnu_Gnu() - { - var commandLine = CommandLine.Parse(LinuxStyleArgs, Gnu); - commandLine.As(); - } - - [Benchmark(Description = "parse [GNU] -v=3.x -p=parser")] - public void Parse_Gnu_3x_Parser() - { - var commandLine = dotnetCampus.Cli.CommandLine.Parse(LinuxStyleArgs); - commandLine.As(new OptionsParser()); - } - - [Benchmark(Description = "parse [GNU] -v=3.x -p=runtime")] - public void Parse_Gnu_3x_Runtime() - { - var commandLine = dotnetCampus.Cli.CommandLine.Parse(LinuxStyleArgs); - commandLine.As(); - } - - [Benchmark(Description = "handle [Edit,Print] --flexible")] - public void Handle_Verbs_Flexible() - { - CommandLine.Parse(EditVerbArgs) - .AddHandler(options => 0) - .AddHandler(options => 0) - .Run(); - } - - [Benchmark(Description = "handle [Edit,Print] -v=3.x -p=parser")] - public void Handle_Verbs_Parser() - { - var commandLine = dotnetCampus.Cli.CommandLine.Parse(EditVerbArgs); - commandLine - .AddHandler(options => 0, new SelfWrittenEditOptionsParser()) - .AddHandler(options => 0, new SelfWrittenPrintOptionsParser()) - .Run(); - } - - [Benchmark(Description = "handle [Edit,Print] -v=3.x -p=runtime")] - public void Handle_Verbs_Runtime() - { - var commandLine = dotnetCampus.Cli.CommandLine.Parse(EditVerbArgs); - commandLine - .AddHandler(options => 0) - .AddHandler(options => 0) - .Run(); - } - - [Benchmark(Description = "parse [URL]")] - public void Parse_Url() - { - var commandLine = CommandLine.Parse(UrlArgs, new CommandLineParsingOptions { SchemeNames = ["walterlv"] }); - commandLine.As(); - } - - [Benchmark(Description = "parse [URL] -v=3.x -p=parser")] - public void Parse_Url_3x_Parser() - { - var commandLine = dotnetCampus.Cli.CommandLine.Parse(UrlArgs); - commandLine.As(new OptionsParser()); - } - - [Benchmark(Description = "parse [URL] -v=3.x -p=runtime")] - public void Parse_Url_3x_Runtime() - { - var commandLine = dotnetCampus.Cli.CommandLine.Parse(UrlArgs); - commandLine.As(); - } - - [Benchmark(Description = "NuGet: CommandLineParser")] - public void CommandLineParser() - { - Parser.Default.ParseArguments(LinuxStyleArgs).WithParsed(options => { }); - } - - [Benchmark(Description = "NuGet: System.CommandLine")] - public void SystemCommandLine() - { - var fileOption = new System.CommandLine.Option( - name: "--file", - description: "The file to read and display on the console."); - - var rootCommand = new RootCommand("Benchmark for System.CommandLine"); - rootCommand.AddOption(fileOption); - rootCommand.SetHandler(file => { }, fileOption); - - rootCommand.Invoke(LinuxStyleArgs); - } -} diff --git a/tests/DotNetCampus.CommandLine.Performance/CompareTo/DotNetCampus.CommandLine.Temp40.4.0.1-benchmark.1.nupkg b/tests/DotNetCampus.CommandLine.Performance/CompareTo/DotNetCampus.CommandLine.Temp40.4.0.1-benchmark.1.nupkg new file mode 100644 index 00000000..4d8c2cdc Binary files /dev/null and b/tests/DotNetCampus.CommandLine.Performance/CompareTo/DotNetCampus.CommandLine.Temp40.4.0.1-benchmark.1.nupkg differ diff --git a/tests/DotNetCampus.CommandLine.Performance/CompareTo/DotNetCampus.CommandLine.Temp40.4.0.1-benchmark.1.snupkg b/tests/DotNetCampus.CommandLine.Performance/CompareTo/DotNetCampus.CommandLine.Temp40.4.0.1-benchmark.1.snupkg new file mode 100644 index 00000000..a3cdbe63 Binary files /dev/null and b/tests/DotNetCampus.CommandLine.Performance/CompareTo/DotNetCampus.CommandLine.Temp40.4.0.1-benchmark.1.snupkg differ diff --git a/tests/DotNetCampus.CommandLine.Performance/DotNetCampus.CommandLine.Performance.csproj b/tests/DotNetCampus.CommandLine.Performance/DotNetCampus.CommandLine.Performance.csproj index 0eec5c83..a2f53c9a 100644 --- a/tests/DotNetCampus.CommandLine.Performance/DotNetCampus.CommandLine.Performance.csproj +++ b/tests/DotNetCampus.CommandLine.Performance/DotNetCampus.CommandLine.Performance.csproj @@ -6,14 +6,22 @@ DotNetCampus.Cli.Performance - - - - + + + false + true + $(DefineConstants);IS_USING_AOT + $(DefineConstants);IS_NOT_USING_AOT + - - + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/tests/DotNetCampus.CommandLine.Performance/Fakes/BenchmarkOptions3.cs b/tests/DotNetCampus.CommandLine.Performance/Fakes/BenchmarkOptions3.cs new file mode 100644 index 00000000..85a9263a --- /dev/null +++ b/tests/DotNetCampus.CommandLine.Performance/Fakes/BenchmarkOptions3.cs @@ -0,0 +1,134 @@ +using System; +using System.Collections.Generic; +using dotnetCampus.Cli; + +namespace DotNetCampus.Cli.Performance.Fakes; + +public class BenchmarkOptions3 +{ + [Option("Debug")] + public required bool IsDebugMode { get; init; } + + [Option('c', "Count")] + public required int TestCount { get; init; } + + [Option('n', "TestName")] + public string? TestName { get; set; } + + [Option("TestCategory")] + public string? TestCategory { get; set; } + + [Option('d', "DetailLevel")] + public DetailLevel DetailLevel { get; set; } = DetailLevel.Medium; + + [Value(0, int.MaxValue)] + public IReadOnlyList TestItems { get; init; } = null!; +} + +public class RuntimeBenchmarkOptions3 +{ + [Option("Debug")] + public required bool IsDebugMode { get; init; } + + [Option('c', "Count")] + public required int TestCount { get; init; } + + [Option('n', "TestName")] + public string? TestName { get; set; } + + [Option("TestCategory")] + public string? TestCategory { get; set; } + + [Option('d', "DetailLevel")] + public string DetailLevel { get; set; } = nameof(DotNetCampus.Cli.Performance.Fakes.DetailLevel.Medium); + + [Value(0, int.MaxValue)] + public IReadOnlyList TestItems { get; init; } = null!; +} + +internal sealed class BenchmarkOption3Parser : ICommandLineOptionParser +{ + private bool _isDebugMode; + private int _testCount; + private string? _testName; + private string? _testCategory; + private DetailLevel _detailLevel = DetailLevel.Medium; + private List _testItems = new(); + + public string? Verb => null; + + public void SetValue(IReadOnlyList values) + { + _testItems = new List(values); + } + + public void SetValue(char shortName, bool value) + { + switch (shortName) + { + case 'd': + _isDebugMode = value; + break; + } + } + + public void SetValue(char shortName, string value) + { + switch (shortName) + { + case 'n': + _testName = value; + break; + case 'c': + _testCount = int.Parse(value); + break; + } + } + + public void SetValue(char shortName, IReadOnlyList values) + { + } + + public void SetValue(string longName, bool value) + { + switch (longName) + { + case "Debug": + _isDebugMode = value; + break; + } + } + + public void SetValue(string longName, string value) + { + switch (longName) + { + case "Count": + _testCount = int.Parse(value); + break; + case "TestName": + _testName = value; + break; + case "TestCategory": + _testCategory = value; + break; + case "DetailLevel": + _detailLevel = Enum.Parse(value); + break; + } + } + + public void SetValue(string longName, IReadOnlyList values) + { + } + + public BenchmarkOptions3 Commit() => new() + { + IsDebugMode = _isDebugMode, + TestCount = _testCount, + TestName = _testName, + TestCategory = _testCategory, + DetailLevel = _detailLevel, + TestItems = _testItems, + }; +} diff --git a/tests/DotNetCampus.CommandLine.Performance/Fakes/BenchmarkOptions40.cs b/tests/DotNetCampus.CommandLine.Performance/Fakes/BenchmarkOptions40.cs new file mode 100644 index 00000000..2b9f1466 --- /dev/null +++ b/tests/DotNetCampus.CommandLine.Performance/Fakes/BenchmarkOptions40.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; +using DotNetCampus.Cli.Temp40.Compiler; + +namespace DotNetCampus.Cli.Performance.Fakes; + +public class BenchmarkOptions40 +{ + [Option("debug")] + public required bool IsDebugMode { get; init; } + + [Option('c', "count")] + public required int TestCount { get; init; } + + [Option('n', "test-name")] + public string? TestName { get; set; } + + [Option("test-category")] + public string? TestCategory { get; set; } + + [Option('d', "detail-level")] + public DetailLevel DetailLevel { get; set; } = DetailLevel.Medium; + + [Value(0, int.MaxValue)] + public IReadOnlyList TestItems { get; init; } = null!; +} + +public class NullableBenchmarkOptions40 +{ + [Option("debug")] + public bool IsDebugMode { get; set; } + + [Option('c', "count")] + public int TestCount { get; set; } + + [Option('n', "test-name")] + public string? TestName { get; set; } + + [Option("test-category")] + public string? TestCategory { get; set; } + + [Option('d', "detail-level")] + public DetailLevel DetailLevel { get; set; } = DetailLevel.Medium; + + [Value(0, int.MaxValue)] + public IList TestItems { get; set; } = null!; +} diff --git a/tests/DotNetCampus.CommandLine.Performance/Fakes/BenchmarkOptions41.cs b/tests/DotNetCampus.CommandLine.Performance/Fakes/BenchmarkOptions41.cs new file mode 100644 index 00000000..316a9fab --- /dev/null +++ b/tests/DotNetCampus.CommandLine.Performance/Fakes/BenchmarkOptions41.cs @@ -0,0 +1,68 @@ +using System.Collections.Generic; +using DotNetCampus.Cli.Compiler; + +namespace DotNetCampus.Cli.Performance.Fakes; + +[Command("", ExperimentalUseFullStackParser = true)] +public readonly record struct FullStackBenchmarkOptions41() +{ + [Option("debug")] + public required bool IsDebugMode { get; init; } + + [Option('c', "count")] + public required int TestCount { get; init; } + + [Option('n', "test-name")] + public string? TestName { get; init; } + + [Option("test-category")] + public string? TestCategory { get; init; } + + [Option('d', "detail-level")] + public DetailLevel DetailLevel { get; init; } + + [Value(0, int.MaxValue)] + public IReadOnlyList TestItems { get; init; } = null!; +} + +public class BenchmarkOptions41 +{ + [Option("debug")] + public required bool IsDebugMode { get; init; } + + [Option('c', "count")] + public required int TestCount { get; init; } + + [Option('n', "test-name")] + public string? TestName { get; set; } + + [Option("test-category")] + public string? TestCategory { get; set; } + + [Option('d', "detail-level")] + public DetailLevel DetailLevel { get; set; } = DetailLevel.Medium; + + [Value(0, int.MaxValue)] + public IReadOnlyList TestItems { get; init; } = null!; +} + +public class NullableBenchmarkOptions41 +{ + [Option("debug")] + public bool IsDebugMode { get; set; } + + [Option('c', "count")] + public int TestCount { get; set; } + + [Option('n', "test-name")] + public string? TestName { get; set; } + + [Option("test-category")] + public string? TestCategory { get; set; } + + [Option('d', "detail-level")] + public DetailLevel DetailLevel { get; set; } = DetailLevel.Medium; + + [Value(0, int.MaxValue)] + public IList TestItems { get; set; } = null!; +} diff --git a/tests/DotNetCampus.CommandLine.Performance/Fakes/BenchmarkOptionsConsoleAppFramework.cs b/tests/DotNetCampus.CommandLine.Performance/Fakes/BenchmarkOptionsConsoleAppFramework.cs new file mode 100644 index 00000000..a41a44a1 --- /dev/null +++ b/tests/DotNetCampus.CommandLine.Performance/Fakes/BenchmarkOptionsConsoleAppFramework.cs @@ -0,0 +1,25 @@ +using System.Runtime.CompilerServices; +using ConsoleAppFramework; + +namespace DotNetCampus.Cli.Performance.Fakes; + +public class BenchmarkOptionsConsoleAppFramework +{ + /// + /// 性能测试的命令行参数 + /// + /// 表示是否开启调试模式 + /// -c, 表示测试的次数 + /// -n, 表示测试的名称 + /// 表示测试的类别 + /// -d, 表示测试的详细等级 + /// 要测试的项目,可以是多个 + [Command("")] + [MethodImpl(MethodImplOptions.NoInlining)] + public void Root( + [Argument] string[] testItems, + bool debug, int testCount, string? testName, + DetailLevel detailLevel, string[]? testCategories = null) + { + } +} diff --git a/tests/DotNetCampus.CommandLine.Performance/Fakes/CommandLineArguments.cs b/tests/DotNetCampus.CommandLine.Performance/Fakes/CommandLineArguments.cs new file mode 100644 index 00000000..56ccdf34 --- /dev/null +++ b/tests/DotNetCampus.CommandLine.Performance/Fakes/CommandLineArguments.cs @@ -0,0 +1,103 @@ +namespace DotNetCampus.Cli.Performance.Fakes; + +internal static class CommandLineArguments +{ + public static readonly string[] NoArgs = []; + + public static readonly string[] DotNetArgs = + [ + "DotNetCampus.CommandLine.Performance.dll", + "DotNetCampus.CommandLine.Sample.dll", + "DotNetCampus.CommandLine.Test.dll", + "-c:20", + "--test-name:BenchmarkTest", + "--detail-level=High", + "--debug", + ]; + + public static readonly string[] DotNetArgsFor40 = + [ + "DotNetCampus.CommandLine.Performance.dll", + "DotNetCampus.CommandLine.Sample.dll", + "DotNetCampus.CommandLine.Test.dll", + "--count:20", + "--test-name:BenchmarkTest", + "--detail-level=High", + "--debug", + ]; + + public static readonly string[] WindowsArgs = + [ + "DotNetCampus.CommandLine.Performance.dll", + "DotNetCampus.CommandLine.Sample.dll", + "DotNetCampus.CommandLine.Test.dll", + "-c", "20", + "-TestName", "BenchmarkTest", + "-DetailLevel", "High", + "-Debug", + ]; + + public static readonly string[] WindowsArgsFor40 = + [ + "DotNetCampus.CommandLine.Performance.dll", + "DotNetCampus.CommandLine.Sample.dll", + "DotNetCampus.CommandLine.Test.dll", + "-Count", "20", + "-TestName", "BenchmarkTest", + "-DetailLevel", "High", + "-Debug", + ]; + + public static readonly string[] CmdArgs = + [ + "DotNetCampus.CommandLine.Performance.dll", + "DotNetCampus.CommandLine.Sample.dll", + "DotNetCampus.CommandLine.Test.dll", + "/c", "20", + "/TestName", "BenchmarkTest", + "/DetailLevel", "High", + "/Debug", + ]; + + public static readonly string[] GnuArgs = + [ + "DotNetCampus.CommandLine.Performance.dll", + "DotNetCampus.CommandLine.Sample.dll", + "DotNetCampus.CommandLine.Test.dll", + "-c", "20", + "--test-name", "BenchmarkTest", + "--detail-level", "High", + "--debug", + ]; + + public static readonly string[] GnuForConsoleAppFrameworkArgs = + [ + "DotNetCampus.CommandLine.Performance.dll,DotNetCampus.CommandLine.Sample.dll,DotNetCampus.CommandLine.Test.dll", + "-c", "20", + "--test-name", "BenchmarkTest", + "--detail-level", "High", + "--debug", + ]; + + public static readonly string[] MixArgs = + [ + "DotNetCampus.CommandLine.Performance.dll", + "DotNetCampus.CommandLine.Sample.dll", + "DotNetCampus.CommandLine.Test.dll", + "-c:20", + "/TestName", "BenchmarkTest", + "--detail-level=High", + "-Debug", + ]; + + public static readonly string[] MixArgsFor40 = + [ + "DotNetCampus.CommandLine.Performance.dll", + "DotNetCampus.CommandLine.Sample.dll", + "DotNetCampus.CommandLine.Test.dll", + "--count:20", + "/TestName", "BenchmarkTest", + "--detail-level=High", + "-Debug", + ]; +} diff --git a/tests/DotNetCampus.CommandLine.Performance/Fakes/ComparedOptions.cs b/tests/DotNetCampus.CommandLine.Performance/Fakes/ComparedOptions.cs deleted file mode 100644 index e6511dd5..00000000 --- a/tests/DotNetCampus.CommandLine.Performance/Fakes/ComparedOptions.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System.ComponentModel; -using DotNetCampus.Cli.Compiler; - -namespace DotNetCampus.Cli.Performance.Fakes; - -/// -/// 表示此程序在被启动的时候使用的参数信息。此类型是不可变类型,所有实例都是线程安全的。 -/// -public class ComparedOptions -{ - /// - /// 表示通过打开的文件路径。此属性可能为 null,但绝不会是空字符串或空白字符串。 - /// - [Value(0), Option('f', "file")] - public string? FilePath { get; set; } - - /// - /// 当此参数值为 true 时,表示此进程是从 Cloud 端启动的 Shell 进程。此属性默认值是 false。 - /// - [Option("cloud"), DefaultValue(false)] - public bool IsFromCloud { get; set; } - - /// - /// 表示 Shell 端启动的模式。此属性可能为 null,但绝不会是空字符串或空白字符串。 - /// - [Option('m', "mode")] - public string? StartupMode { get; set; } - - /// - /// 表示当前是否是静默方式启动,通常由 Shell 启动 Cloud 时使用。此属性默认值是 false。 - /// - [Option('s', "silence"), DefaultValue(false)] - public bool IsSilence { get; set; } - - /// - /// 表示当前启动时需要针对 IWB 进行处理。此属性默认值是 false。 - /// - [Option("iwb"), DefaultValue(false)] - public bool IsIwb { get; set; } - - /// - /// 表示当前窗口启动时应该安放的位置。此属性可能为 null,但绝不会是空字符串或空白字符串。 - /// - [Option('p', "placement")] - public string? Placement { get; set; } - - /// - /// 表示一个启动会话 Id,用于在多个进程间同步一些信息。此属性可能为 null,但绝不会是空字符串或空白字符串。 - /// - [Option("startup-session")] - public string? StartupSession { get; set; } -} diff --git a/tests/DotNetCampus.CommandLine.Performance/Fakes/DetailLevel.cs b/tests/DotNetCampus.CommandLine.Performance/Fakes/DetailLevel.cs new file mode 100644 index 00000000..d8d185bb --- /dev/null +++ b/tests/DotNetCampus.CommandLine.Performance/Fakes/DetailLevel.cs @@ -0,0 +1,8 @@ +namespace DotNetCampus.Cli.Performance.Fakes; + +public enum DetailLevel +{ + Low, + Medium, + High, +} diff --git a/tests/DotNetCampus.CommandLine.Performance/Others.cs b/tests/DotNetCampus.CommandLine.Performance/Others.cs new file mode 100644 index 00000000..07c65001 --- /dev/null +++ b/tests/DotNetCampus.CommandLine.Performance/Others.cs @@ -0,0 +1,93 @@ +// using System.CommandLine; +// using System.IO; +// using BenchmarkDotNet.Attributes; +// using dotnetCampus.Cli; +// using Microsoft.Extensions.Options; +// using static DotNetCampus.Cli.Performance.Fakes.CommandLineArguments; +// using static DotNetCampus.Cli.CommandLineParsingOptions; +// +// namespace DotNetCampus.Cli.Performance; +// +// // [MemoryDiagnoser] +// // [BenchmarkCategory("Parse No Args")] +// public class Others +// { +// [Benchmark(Description = "handle [Edit,Print] --flexible")] +// public void Handle_Verbs_Flexible() +// { +// CommandLine.Parse(EditVerbArgs) +// .AddHandler(options => 0) +// .AddHandler(options => 0) +// .Run(); +// } +// +// [Benchmark(Description = "handle [Edit,Print] --dotnet")] +// public void Handle_Verbs_DotNet() +// { +// CommandLine.Parse(EditVerbArgs) +// .AddHandler(options => 0) +// .AddHandler(options => 0) +// .Run(); +// } +// +// [Benchmark(Description = "handle [Edit,Print] -v=3.x -p=parser")] +// public void Handle_Verbs_Parser() +// { +// var commandLine = dotnetCampus.Cli.CommandLine.Parse(EditVerbArgs); +// commandLine +// .AddHandler(options => 0, new SelfWrittenEditOptionsParser()) +// .AddHandler(options => 0, new SelfWrittenPrintOptionsParser()) +// .Run(); +// } +// +// [Benchmark(Description = "handle [Edit,Print] -v=3.x -p=runtime")] +// public void Handle_Verbs_Runtime() +// { +// var commandLine = dotnetCampus.Cli.CommandLine.Parse(EditVerbArgs); +// commandLine +// .AddHandler(options => 0) +// .AddHandler(options => 0) +// .Run(); +// } +// +// [Benchmark(Description = "parse [URL]")] +// public void Parse_Url() +// { +// var commandLine = CommandLine.Parse(UrlArgs, new CommandLineParsingOptions { SchemeNames = ["walterlv"] }); +// commandLine.As(); +// } +// +// [Benchmark(Description = "parse [URL] -v=3.x -p=parser")] +// public void Parse_Url_3x_Parser() +// { +// var commandLine = dotnetCampus.Cli.CommandLine.Parse(UrlArgs); +// commandLine.As(new OptionsParser()); +// } +// +// [Benchmark(Description = "parse [URL] -v=3.x -p=runtime")] +// public void Parse_Url_3x_Runtime() +// { +// var commandLine = dotnetCampus.Cli.CommandLine.Parse(UrlArgs); +// commandLine.As(); +// } +// +// [Benchmark(Description = "NuGet: CommandLineParser")] +// public void CommandLineParser() +// { +// Parser.Default.ParseArguments(GnuStyleArgs).WithParsed(options => { }); +// } +// +// [Benchmark(Description = "NuGet: System.CommandLine")] +// public void SystemCommandLine() +// { +// var fileOption = new System.CommandLine.Option( +// name: "--file", +// description: "The file to read and display on the console."); +// +// var rootCommand = new RootCommand("Benchmark for System.CommandLine"); +// rootCommand.AddOption(fileOption); +// rootCommand.SetHandler(file => { }, fileOption); +// +// rootCommand.Invoke(GnuStyleArgs); +// } +// } diff --git a/tests/DotNetCampus.CommandLine.Performance/ParseArgs/ParseCmdArgs.cs b/tests/DotNetCampus.CommandLine.Performance/ParseArgs/ParseCmdArgs.cs new file mode 100644 index 00000000..f54d0950 --- /dev/null +++ b/tests/DotNetCampus.CommandLine.Performance/ParseArgs/ParseCmdArgs.cs @@ -0,0 +1,54 @@ +using BenchmarkDotNet.Attributes; +using DotNetCampus.Cli.Performance.Fakes; +using static DotNetCampus.Cli.Performance.Fakes.CommandLineArguments; + +// ReSharper disable ReturnValueOfPureMethodIsNotUsed + +namespace DotNetCampus.Cli.Performance.ParseArgs; + +[MemoryDiagnoser] +[BenchmarkCategory("Parse CMD Args")] +public class ParseCmdArgs +{ + [Benchmark(Description = "parse [CMD] -v=4.1 -p=flexible")] + public void Parse41_Flexible() + { + var commandLine = CommandLine41.Parse(CmdArgs, Options41.Flexible); + commandLine.As(); + } + + [Benchmark(Description = "parse [CMD] -v=4.1 -p=windows")] + public void Parse41_Windows() + { + var commandLine = CommandLine41.Parse(CmdArgs, Options41.Windows); + commandLine.As(); + } + + [Benchmark(Description = "parse [CMD] -v=4.0 -p=flexible")] + public void Parse40_Flexible() + { + var commandLine = CommandLine40.Parse(CmdArgs, Options40.Flexible); + commandLine.As(); + } + + [Benchmark(Description = "parse [CMD] -v=4.0 -p=powershell")] + public void Parse40_PowerShell() + { + var commandLine = CommandLine40.Parse(CmdArgs, Options40.PowerShell); + commandLine.As(); + } + + [Benchmark(Description = "parse [CMD] -v=3.x -p=parser")] + public void Parse3x_Parser() + { + var commandLine = CommandLine3.Parse(CmdArgs); + commandLine.As(new BenchmarkOption3Parser()); + } + + [Benchmark(Description = "parse [CMD] -v=3.x -p=runtime")] + public void Parse3x_Runtime() + { + var commandLine = CommandLine3.Parse(CmdArgs); + commandLine.As(); + } +} diff --git a/tests/DotNetCampus.CommandLine.Performance/ParseArgs/ParseDotNetArgs.cs b/tests/DotNetCampus.CommandLine.Performance/ParseArgs/ParseDotNetArgs.cs new file mode 100644 index 00000000..f4fa5a26 --- /dev/null +++ b/tests/DotNetCampus.CommandLine.Performance/ParseArgs/ParseDotNetArgs.cs @@ -0,0 +1,55 @@ +using System; +using BenchmarkDotNet.Attributes; +using DotNetCampus.Cli.Performance.Fakes; +using static DotNetCampus.Cli.Performance.Fakes.CommandLineArguments; + +// ReSharper disable ReturnValueOfPureMethodIsNotUsed + +namespace DotNetCampus.Cli.Performance.ParseArgs; + +[MemoryDiagnoser] +[BenchmarkCategory("Parse DotNet Args")] +public class ParseDotNetArgs +{ + [Benchmark(Description = "parse [NET] -v=4.1 -p=flexible")] + public void Parse41_Flexible() + { + var commandLine = CommandLine41.Parse(DotNetArgs, Options41.Flexible); + commandLine.As(); + } + + [Benchmark(Description = "parse [NET] -v=4.1 -p=dotnet")] + public void Parse41_Dotnet() + { + var commandLine = CommandLine41.Parse(DotNetArgs, Options41.DotNet); + commandLine.As(); + } + + [Benchmark(Description = "parse [NET] -v=4.0 -p=flexible")] + public void Parse40_Flexible() + { + var commandLine = CommandLine40.Parse(DotNetArgsFor40, Options40.Flexible); + commandLine.As(); + } + + [Benchmark(Description = "parse [NET] -v=4.0 -p=dotnet")] + public void Parse40_Dotnet() + { + var commandLine = CommandLine40.Parse(DotNetArgsFor40, Options40.DotNet); + commandLine.As(); + } + + [Benchmark(Description = "parse [NET] -v=3.x -p=parser")] + public void Parse3x_Parser() + { + var commandLine = CommandLine3.Parse(DotNetArgs); + commandLine.As(new BenchmarkOption3Parser()); + } + + [Benchmark(Description = "parse [NET] -v=3.x -p=runtime")] + public void Parse3x_Runtime() + { + var commandLine = CommandLine3.Parse(DotNetArgs); + commandLine.As(); + } +} diff --git a/tests/DotNetCampus.CommandLine.Performance/ParseArgs/ParseGnuArgs.cs b/tests/DotNetCampus.CommandLine.Performance/ParseArgs/ParseGnuArgs.cs new file mode 100644 index 00000000..edf3c9bc --- /dev/null +++ b/tests/DotNetCampus.CommandLine.Performance/ParseArgs/ParseGnuArgs.cs @@ -0,0 +1,105 @@ +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Jobs; +using ConsoleAppFramework; +using DotNetCampus.Cli.Performance.Fakes; +using static DotNetCampus.Cli.Performance.Fakes.CommandLineArguments; + +#if IS_NOT_USING_AOT +using System.Collections.Generic; +using System.CommandLine; +using CommandLine; +#endif + +// ReSharper disable ReturnValueOfPureMethodIsNotUsed + +namespace DotNetCampus.Cli.Performance.ParseArgs; + +[SimpleJob(RuntimeMoniker.Net10_0)] +[SimpleJob(RuntimeMoniker.NativeAot90)] +[MemoryDiagnoser] +[BenchmarkCategory("Parse GNU Args")] +public class ParseGnuArgs +{ + [Benchmark(Description = "parse [GNU] -v=4.1 -p=flexible")] + public void Parse41_Flexible() + { + var commandLine = CommandLine41.Parse(GnuArgs, Options41.Flexible); + commandLine.As(); + } + + [Benchmark(Description = "parse [GNU] -v=4.1 -p=gnu")] + public void Parse41_Gnu() + { + var commandLine = CommandLine41.Parse(GnuArgs, Options41.Gnu); + commandLine.As(); + } + + [Benchmark(Description = "parse [GNU] -v=4.0 -p=flexible")] + public void Parse40_Flexible() + { + var commandLine = CommandLine40.Parse(GnuArgs, Options40.Flexible); + commandLine.As(); + } + + [Benchmark(Description = "parse [GNU] -v=4.0 -p=gnu")] + public void Parse40_Gnu() + { + var commandLine = CommandLine40.Parse(GnuArgs, Options40.Gnu); + commandLine.As(); + } + + [Benchmark(Description = "parse [GNU] -v=3.x -p=parser")] + public void Parse3x_Parser() + { + var commandLine = CommandLine3.Parse(GnuArgs); + commandLine.As(new BenchmarkOption3Parser()); + } + + [Benchmark(Description = "parse [GNU] -v=3.x -p=runtime")] + public void Parse3x_Runtime() + { + var commandLine = CommandLine3.Parse(GnuArgs); + commandLine.As(); + } + + [Benchmark(Description = "NuGet: ConsoleAppFramework")] + public void ConsoleAppFramework() + { + var app = ConsoleApp.Create(); + app.Add(); + app.Run(GnuForConsoleAppFrameworkArgs); + } + +#if IS_NOT_USING_AOT + + [Benchmark(Description = "NuGet: CommandLineParser")] + public void CommandLineParser() + { + Parser.Default.ParseArguments(GnuArgs).WithParsed(options => { }); + } + + [Benchmark(Description = "NuGet: System.CommandLine")] + public void SystemCommandLine() + { + var debug = new Option("--debug"); + var count = new Option("--count"); + var testName = new Option("--test-name"); + var testCategory = new Option("--test-category"); + var detailLevel = new Option("--detail-level"); + var testItems = new Argument>(); + + var rootCommand = new RootCommand("Benchmark for System.CommandLine"); + rootCommand.AddOption(debug); + rootCommand.AddOption(count); + rootCommand.AddOption(testName); + rootCommand.AddOption(testCategory); + rootCommand.AddOption(detailLevel); + rootCommand.AddArgument(testItems); + rootCommand.SetHandler(file => { }); + + rootCommand.Invoke(GnuArgs); + } + +#endif + +} diff --git a/tests/DotNetCampus.CommandLine.Performance/ParseArgs/ParseMixArgs.cs b/tests/DotNetCampus.CommandLine.Performance/ParseArgs/ParseMixArgs.cs new file mode 100644 index 00000000..ebe04d80 --- /dev/null +++ b/tests/DotNetCampus.CommandLine.Performance/ParseArgs/ParseMixArgs.cs @@ -0,0 +1,40 @@ +using BenchmarkDotNet.Attributes; +using DotNetCampus.Cli.Performance.Fakes; +using static DotNetCampus.Cli.Performance.Fakes.CommandLineArguments; + +// ReSharper disable ReturnValueOfPureMethodIsNotUsed + +namespace DotNetCampus.Cli.Performance.ParseArgs; + +[MemoryDiagnoser] +[BenchmarkCategory("Parse MIX Args")] +public class ParseMixArgs +{ + [Benchmark(Description = "parse [MIX] -v=4.1 -p=flexible")] + public void Parse41_Flexible() + { + var commandLine = CommandLine41.Parse(MixArgs, Options41.Flexible); + commandLine.As(); + } + + [Benchmark(Description = "parse [MIX] -v=4.0 -p=flexible")] + public void Parse40_Flexible() + { + var commandLine = CommandLine40.Parse(MixArgsFor40, Options40.Flexible); + commandLine.As(); + } + + [Benchmark(Description = "parse [MIX] -v=3.x -p=parser")] + public void Parse3x_Parser() + { + var commandLine = CommandLine3.Parse(MixArgs); + commandLine.As(new BenchmarkOption3Parser()); + } + + [Benchmark(Description = "parse [MIX] -v=3.x -p=runtime")] + public void Parse3x_Runtime() + { + var commandLine = CommandLine3.Parse(MixArgs); + commandLine.As(); + } +} diff --git a/tests/DotNetCampus.CommandLine.Performance/ParseArgs/ParseNoArgs.cs b/tests/DotNetCampus.CommandLine.Performance/ParseArgs/ParseNoArgs.cs new file mode 100644 index 00000000..34a40581 --- /dev/null +++ b/tests/DotNetCampus.CommandLine.Performance/ParseArgs/ParseNoArgs.cs @@ -0,0 +1,54 @@ +using BenchmarkDotNet.Attributes; +using DotNetCampus.Cli.Performance.Fakes; +using static DotNetCampus.Cli.Performance.Fakes.CommandLineArguments; + +// ReSharper disable ReturnValueOfPureMethodIsNotUsed + +namespace DotNetCampus.Cli.Performance.ParseArgs; + +[MemoryDiagnoser] +[BenchmarkCategory("Parse No Args")] +public class ParseNoArgs +{ + [Benchmark(Description = "parse [] -v=4.1 -p=flexible")] + public void Parse41_Flexible() + { + var commandLine = CommandLine41.Parse(NoArgs, Options41.Flexible); + commandLine.As(); + } + + [Benchmark(Description = "parse [] -v=4.1 -p=dotnet")] + public void Parse41_DotNet() + { + var commandLine = CommandLine41.Parse(NoArgs, Options41.DotNet); + commandLine.As(); + } + + [Benchmark(Description = "parse [] -v=4.0 -p=flexible")] + public void Parse40_Flexible() + { + var commandLine = CommandLine40.Parse(NoArgs, Options40.Flexible); + commandLine.As(); + } + + [Benchmark(Description = "parse [] -v=4.0 -p=dotnet")] + public void Parse40_DotNet() + { + var commandLine = CommandLine40.Parse(NoArgs, Options40.DotNet); + commandLine.As(); + } + + [Benchmark(Description = "parse [] -v=3.x -p=parser")] + public void Parse3x_Parser() + { + var commandLine = CommandLine3.Parse(NoArgs); + commandLine.As(new BenchmarkOption3Parser()); + } + + [Benchmark(Description = "parse [] -v=3.x -p=runtime")] + public void Parse3x_Runtime() + { + var commandLine = CommandLine3.Parse(NoArgs); + commandLine.As(); + } +} diff --git a/tests/DotNetCampus.CommandLine.Performance/ParseArgs/ParseWindowsArgs.cs b/tests/DotNetCampus.CommandLine.Performance/ParseArgs/ParseWindowsArgs.cs new file mode 100644 index 00000000..64fb511d --- /dev/null +++ b/tests/DotNetCampus.CommandLine.Performance/ParseArgs/ParseWindowsArgs.cs @@ -0,0 +1,54 @@ +using BenchmarkDotNet.Attributes; +using DotNetCampus.Cli.Performance.Fakes; +using static DotNetCampus.Cli.Performance.Fakes.CommandLineArguments; + +// ReSharper disable ReturnValueOfPureMethodIsNotUsed + +namespace DotNetCampus.Cli.Performance.ParseArgs; + +[MemoryDiagnoser] +[BenchmarkCategory("Parse Windows Args")] +public class ParseWindowsArgs +{ + [Benchmark(Description = "parse [PS1] -v=4.1 -p=flexible")] + public void Parse41_Flexible() + { + var commandLine = CommandLine41.Parse(WindowsArgs, Options41.Flexible); + commandLine.As(); + } + + [Benchmark(Description = "parse [PS1] -v=4.1 -p=windows")] + public void Parse41_Windows() + { + var commandLine = CommandLine41.Parse(WindowsArgs, Options41.Windows); + commandLine.As(); + } + + [Benchmark(Description = "parse [PS1] -v=4.0 -p=flexible")] + public void Parse40_Flexible() + { + var commandLine = CommandLine40.Parse(WindowsArgsFor40, Options40.Flexible); + commandLine.As(); + } + + [Benchmark(Description = "parse [PS1] -v=4.0 -p=powershell")] + public void Parse40_PowerShell() + { + var commandLine = CommandLine40.Parse(WindowsArgsFor40, Options40.PowerShell); + commandLine.As(); + } + + [Benchmark(Description = "parse [PS1] -v=3.x -p=parser")] + public void Parse3x_Parser() + { + var commandLine = CommandLine3.Parse(WindowsArgs); + commandLine.As(new BenchmarkOption3Parser()); + } + + [Benchmark(Description = "parse [PS1] -v=3.x -p=runtime")] + public void Parse3x_Runtime() + { + var commandLine = CommandLine3.Parse(WindowsArgs); + commandLine.As(); + } +} diff --git a/tests/DotNetCampus.CommandLine.Performance/Program.cs b/tests/DotNetCampus.CommandLine.Performance/Program.cs index a4ab5d2e..b90d6c20 100644 --- a/tests/DotNetCampus.CommandLine.Performance/Program.cs +++ b/tests/DotNetCampus.CommandLine.Performance/Program.cs @@ -1,4 +1,9 @@ -using System.Reflection; +using System; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Reflection; +using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Running; namespace DotNetCampus.Cli.Performance; @@ -7,6 +12,27 @@ class Program { static void Main(string[] args) { +#if DEBUG + if (args.Contains("--debug")) + { + DebugAll(); + return; + } +#endif + BenchmarkSwitcher.FromAssembly(typeof(Program).GetTypeInfo().Assembly).Run(args); } + + [Conditional("DEBUG")] + private static void DebugAll() + { + var methods = typeof(Program).Assembly.GetTypes() + .Where(x => x.IsDefined(typeof(BenchmarkCategoryAttribute))) + .Select(x => (Instance: Activator.CreateInstance(x), Methods: x.GetMethods().Where(m => m.IsDefined(typeof(BenchmarkAttribute))))) + .SelectMany(x => x.Methods.Select(m => (x.Instance, m))); + foreach (var (instance, method) in methods) + { + method.Invoke(instance, []); + } + } } diff --git a/tests/DotNetCampus.CommandLine.Performance/Properties/GlobalUsings.cs b/tests/DotNetCampus.CommandLine.Performance/Properties/GlobalUsings.cs new file mode 100644 index 00000000..830e6018 --- /dev/null +++ b/tests/DotNetCampus.CommandLine.Performance/Properties/GlobalUsings.cs @@ -0,0 +1,5 @@ +global using CommandLine3 = dotnetCampus.Cli.CommandLine; +global using CommandLine41 = DotNetCampus.Cli.CommandLine; +global using Options41 = DotNetCampus.Cli.CommandLineParsingOptions; +global using CommandLine40 = DotNetCampus.Cli.Temp40.CommandLine; +global using Options40 = DotNetCampus.Cli.Temp40.CommandLineParsingOptions; diff --git a/tests/DotNetCampus.CommandLine.Tests/AddHandlerTests.cs b/tests/DotNetCampus.CommandLine.Tests/AddHandlerTests.cs index 0b149244..b4d03b7b 100644 --- a/tests/DotNetCampus.CommandLine.Tests/AddHandlerTests.cs +++ b/tests/DotNetCampus.CommandLine.Tests/AddHandlerTests.cs @@ -230,8 +230,8 @@ public void ChainedCalls_ReturnCorrectBuilderTypes() var commandLine = CommandLine.Parse(args, Flexible); // Act - var syncBuilder = commandLine.AddHandler(_ => { }); - var asyncBuilder = commandLine.AddHandler(async _ => { + var syncBuilder = commandLine.ToRunner().AddHandler(_ => { }); + var asyncBuilder = commandLine.ToRunner().AddHandler(async _ => { await Task.Delay(1); return 0; }); diff --git a/tests/DotNetCampus.CommandLine.Tests/Analyzers/OptionLongNameMustBePascalCaseAnalyzerTest.cs b/tests/DotNetCampus.CommandLine.Tests/Analyzers/OptionLongNameMustBePascalCaseAnalyzerTest.cs deleted file mode 100644 index 6d275233..00000000 --- a/tests/DotNetCampus.CommandLine.Tests/Analyzers/OptionLongNameMustBePascalCaseAnalyzerTest.cs +++ /dev/null @@ -1,99 +0,0 @@ -//using System; -//using System.Collections.Generic; -//using System.Linq; -//using System.Text; -//using System.Threading.Tasks; -//using System.Xml; - -//using DotNetCampus.CommandLine; -//using DotNetCampus.CommandLine.Analyzers; - -//using Microsoft.CodeAnalysis; -//using Microsoft.CodeAnalysis.Diagnostics; -//using Microsoft.VisualStudio.TestTools.UnitTesting; - -//using MSTest.Extensions.Contracts; - -//using RoslynTestKit; - -//namespace DotNetCampus.Cli.Tests.Analyzers -//{ -// [TestClass] -// public class OptionLongNameMustBePascalCaseAnalyzerTest : AnalyzerTestFixture -// { -// protected override string LanguageName => LanguageNames.CSharp; -// protected override DiagnosticAnalyzer CreateAnalyzer() => new OptionLongNameMustBePascalCaseAnalyzer(); - -// [ContractTestCase] -// public void TestWithoutNumbers() -// { -// "使用 Pascal 风格的长名称,不报告 Pascal 诊断。".Test(TestNoDiagnostic).WithArguments( -// "WalterlvIsAdobe", -// "Walterlv"); - -// "使用非 Pascal 风格的长名称,报告 Pascal 诊断。".Test(TestHasDiagnostic).WithArguments( -// "--walterlv-is-adobe", -// "-WalterlvIsAdobe", -// "/WalterlvIsAdobe", -// "walterlv-is-adobe", -// "walterlvIsAdobe", -// "walterlv_is_adobe", -// "waltelv", -// "--walterlv", -// "-Walterlv", -// "/Walterlv"); - -// "多位全大写字母,报告 Pascal 诊断。".Test(TestHasDiagnostic).WithArguments( -// "HTML", -// "AddedHTMLFile"); - -// "两位全大写字母,不报告 Pascal 诊断。".Test(TestNoDiagnostic).WithArguments( -// "IO", -// "IOSetting", -// "TestIO", -// "TestIOSetting"); -// } - -// [ContractTestCase] -// public void TestWithNumbers() -// { -// "使用 Pascal 风格的长名称,不报告 Pascal 诊断。".Test(TestNoDiagnostic).WithArguments( -// "Files2Build", -// "Html5"); - -// "使用非 Pascal 风格的长名称,报告 Pascal 诊断。".Test(TestHasDiagnostic).WithArguments( -// "--walterlv-is-adobe", -// "-WalterlvIsAdobe", -// "/WalterlvIsAdobe", -// "walterlv-is-adobe", -// "walterlvIsAdobe", -// "walterlv_is_adobe", -// "waltelv", -// "--walterlv", -// "-Walterlv", -// "/Walterlv"); -// } - -// private void TestHasDiagnostic(string longName) -// { -// string code = $@" -//class Options -//{{ -// [Option('d', ""{longName}"")] -// public string? DemoOption {{ get; set; }} -//}}"; -// HasDiagnostic(code, DiagnosticIds.OptionLongNameMustBePascalCase); -// } - -// private void TestNoDiagnostic(string longName) -// { -// string code = $@" -//class Options -//{{ -// [Option('d', ""{longName}"")] -// public string? DemoOption {{ get; set; }} -//}}"; -// NoDiagnostic(code, DiagnosticIds.OptionLongNameMustBePascalCase); -// } -// } -//} diff --git a/tests/DotNetCampus.CommandLine.Tests/CommandLineStyleTestingExtensions.cs b/tests/DotNetCampus.CommandLine.Tests/CommandLineStyleTestingExtensions.cs new file mode 100644 index 00000000..4a6673df --- /dev/null +++ b/tests/DotNetCampus.CommandLine.Tests/CommandLineStyleTestingExtensions.cs @@ -0,0 +1,27 @@ +using System; + +namespace DotNetCampus.Cli.Tests; + +internal static class CommandLineStyleTestingExtensions +{ + public static CommandLineParsingOptions ToParsingOptions(this TestCommandLineStyle style) => style switch + { + TestCommandLineStyle.Flexible => CommandLineParsingOptions.Flexible, + TestCommandLineStyle.DotNet => CommandLineParsingOptions.DotNet, + TestCommandLineStyle.Gnu => CommandLineParsingOptions.Gnu, + TestCommandLineStyle.Posix => CommandLineParsingOptions.Posix, + TestCommandLineStyle.Windows => CommandLineParsingOptions.Windows, + TestCommandLineStyle.Url => CommandLineParsingOptions.Flexible with { SchemeNames = ["test"] }, + _ => throw new ArgumentOutOfRangeException(nameof(style), style, null), + }; +} + +public enum TestCommandLineStyle +{ + Flexible, + DotNet, + Gnu, + Posix, + Windows, + Url, +} diff --git a/tests/DotNetCampus.CommandLine.Tests/CommandLineTests.ValueRange.cs b/tests/DotNetCampus.CommandLine.Tests/CommandLineTests.ValueRange.cs deleted file mode 100644 index e5c56468..00000000 --- a/tests/DotNetCampus.CommandLine.Tests/CommandLineTests.ValueRange.cs +++ /dev/null @@ -1,62 +0,0 @@ -using System.Collections; - -using DotNetCampus.Cli.Tests.Fakes; - -using Microsoft.VisualStudio.TestTools.UnitTesting; - -using MSTest.Extensions.Contracts; - -namespace DotNetCampus.Cli.Tests -{ - public partial class CommandLineTests - { - [ContractTestCase] - public void ParseValues() - { - "命令行中包含 --,那么 -- 后的字符串完全属于值。".Test((string[] args) => - { - // Arrange & Action - var commandLine = CommandLine.Parse(args); - var options = commandLine.As(); - - // Assert - Assert.AreEqual("foo", options.Foo); - Assert.AreEqual(8, options.LongValue); - CollectionAssert.AreEqual(new[] { "x", "y" }, (ICollection?)options.Values); - Assert.AreEqual(2, options.Int32Value); - }).WithArguments( - new[] { "8", "x", "y", "2", "-f", "foo" }, - new[] { "-f", "foo", "--", "8", "x", "y", "2" } - ); - - "命令行中包含 --,那么 -- 后的字符串完全属于值,即使后面包含 -。".Test((string[] args) => - { - // Arrange & Action - var commandLine = CommandLine.Parse(args); - var options = commandLine.As(); - - // Assert - Assert.AreEqual("foo", options.Foo); - Assert.AreEqual(-8, options.LongValue); - CollectionAssert.AreEqual(new[] { "-x", "-y" }, (ICollection?)options.Values); - Assert.AreEqual(-2, options.Int32Value); - }).WithArguments( - new[] { "-f", "foo", "--", "-8", "-x", "-y", "-2" } - ); - - "命令行中包含 --,那么 -- 后的字符串完全属于值,且完全赋值。".Test((string[] args) => - { - // Arrange & Action - var commandLine = CommandLine.Parse(args); - var options = commandLine.As(); - - // Assert - Assert.AreEqual("foo", options.Section); - Assert.AreEqual(8, options.Count); - CollectionAssert.AreEqual(new[] { "dcl.exe", "--foo", "xyz", "-s", "some", "2" }, (ICollection?)options.Args); - }).WithArguments( - new[] { "-s", "foo", "--", "8", "dcl.exe", "--foo", "xyz", "-s", "some", "2" } - ); - } - } -} diff --git a/tests/DotNetCampus.CommandLine.Tests/CommandLineTests.bak.cs b/tests/DotNetCampus.CommandLine.Tests/CommandLineTests.bak.cs deleted file mode 100644 index 43803c1c..00000000 --- a/tests/DotNetCampus.CommandLine.Tests/CommandLineTests.bak.cs +++ /dev/null @@ -1,419 +0,0 @@ -using System.IO; -using System.Linq; -using System.Threading.Tasks; - -using DotNetCampus.Cli.Tests.Fakes; - -using Microsoft.VisualStudio.TestTools.UnitTesting; - -using MSTest.Extensions.Contracts; - -using static DotNetCampus.Cli.Tests.Fakes.CommandLineArgs; - -namespace DotNetCampus.Cli.Tests -{ - [TestClass] - public partial class CommandLineTests - { - [ContractTestCase] - public void ParseAs() - { - "命令行中没有参数,正确完成解析。".Test(() => - { - // Arrange & Action - var commandLine = CommandLine.Parse(NoArgs); - var options = commandLine.As(new OptionsParser()); - - // Assert - Assert.AreEqual(null, options.FilePath); - Assert.AreEqual(false, options.IsFromCloud); - Assert.AreEqual(false, options.IsIwb); - Assert.AreEqual(null, options.StartupMode); - Assert.AreEqual(false, options.IsSilence); - Assert.AreEqual(null, options.Placement); - Assert.AreEqual(null, options.StartupSession); - }); - - "使用 {0} 风格的命令行,正确完成解析。".Test((string name, string[] args) => - { - // Arrange & Action - var commandLine = CommandLine.Parse(args, protocolName: UrlProtocol); - var options = commandLine.As(new OptionsParser()); - - // Assert - Assert.AreEqual(FileValue, options.FilePath); - Assert.AreEqual(CloudValue, options.IsFromCloud); - Assert.AreEqual(IwbValue, options.IsIwb); - Assert.AreEqual(ModeValue, options.StartupMode); - Assert.AreEqual(SilenceValue, options.IsSilence); - Assert.AreEqual(PlacementValue, options.Placement); - Assert.AreEqual(StartupSessionValue, options.StartupSession); - }).WithArguments( - ("Windows", WindowsStyleArgs), - ("Cmd", CmdStyleArgs), - ("Cmd2", Cmd2StyleArgs), - ("Linux", LinuxStyleArgs), - ("Url", UrlArgs)); - - "使用运行时解析器解析至可变类型,正确完成解析。".Test(() => - { - // Arrange & Action - var commandLine = CommandLine.Parse(WindowsStyleArgs); - var options = commandLine.As(); - - // Assert - Assert.AreEqual(FileValue, options.FilePath); - Assert.AreEqual(CloudValue, options.IsFromCloud); - Assert.AreEqual(IwbValue, options.IsIwb); - Assert.AreEqual(ModeValue, options.StartupMode); - Assert.AreEqual(SilenceValue, options.IsSilence); - Assert.AreEqual(PlacementValue, options.Placement); - Assert.AreEqual(StartupSessionValue, options.StartupSession); - }); - - "使用运行时解析器解析至不可变类型,正确完成解析。".Test(() => - { - // Arrange & Action - var commandLine = CommandLine.Parse(WindowsStyleArgs); - var options = commandLine.As(); - - // Assert - Assert.AreEqual(FileValue, options.FilePath); - Assert.AreEqual(CloudValue, options.IsFromCloud); - Assert.AreEqual(IwbValue, options.IsIwb); - Assert.AreEqual(ModeValue, options.StartupMode); - Assert.AreEqual(SilenceValue, options.IsSilence); - Assert.AreEqual(PlacementValue, options.Placement); - Assert.AreEqual(StartupSessionValue, options.StartupSession); - }); - } - - [ContractTestCase] - public void ParseToPrimary() - { - "命令行传入数值,可以解析为数值类型。".Test((string[] args) => - { - // Arrange & Action - var commandLine = CommandLine.Parse(args); - var options = commandLine.As(); - - // Assert - Assert.AreEqual((byte)1, options.Aaa); - Assert.AreEqual((short)2, options.Bbb); - Assert.AreEqual((ushort)3, options.Ccc); - Assert.AreEqual((int)4, options.Ddd); - Assert.AreEqual((uint)5, options.Eee); - Assert.AreEqual((long)6, options.Fff); - Assert.AreEqual((ulong)7, options.Ggg); - Assert.AreEqual((float)8, options.Hhh); - Assert.AreEqual((double)9, options.Iii); - Assert.AreEqual((decimal)10, options.Jjj); - }).WithArguments( - new[] { "-a", "1", "-b", "2", "-c", "3", "-d", "4", "-e", "5", "-f", "6", "-g", "7", "-h", "8", "-i", "9", "-j", "10" }, - new[] { "-a", "1", "-b", "2", "-c", "3", "-d", "4", "-e", "5", "-f", "6", "-g", "7", "-h", "8.0", "-i", "9.0", "-j", "10.0" } - ); - } - - [ContractTestCase] - public void ParseToIO() - { - "命令行传入文件路径,可以解析为文件路径类型。".Test((string[] args) => - { - // Arrange & Action - var commandLine = CommandLine.Parse(args); - var options = commandLine.As(); - - // Assert - Assert.AreEqual(Path.Combine(Directory.GetCurrentDirectory(), "a.txt"), options.File!.FullName); - Assert.AreEqual(Path.Combine(Directory.GetCurrentDirectory(), "b"), options.Directory!.FullName); - }).WithArguments( - new[] { "-f", "a.txt", "-d", "b" }, - new[] { "-f", " a.txt ", "-d", " b " } - ); - } - - [ContractTestCase] - public void ParseToDictionary() - { - "命令行传入字典(一项),能接收到字典的所有值。".Test((string[] args) => - { - // Arrange & Action - var commandLine = CommandLine.Parse(args); - var options = commandLine.As(); - - // Assert - Assert.AreEqual("1", options.Aaa!["a"]); - Assert.AreEqual("1", options.Bbb!["a"]); - Assert.AreEqual("1", options.Ccc!["a"]); - Assert.AreEqual("a", options.Ddd.Key); - Assert.AreEqual("1", options.Ddd.Value); - }).WithArguments( - new[] { "-a", "a=1", "-b", "a=1", "-c", "a=1", "-d", "a=1" }, - new[] { "-a:a=1", "-b:a=1", "-c:a=1", "-d:a=1" } - ); - - "命令行传入字典(三项),能接收到字典的所有值。".Test((string[] args) => - { - // Arrange & Action - var commandLine = CommandLine.Parse(args); - var options = commandLine.As(); - - // Assert - Assert.AreEqual("1", options.Aaa!["a"]); - Assert.AreEqual("2", options.Aaa["b"]); - Assert.AreEqual("3", options.Aaa["c"]); - Assert.AreEqual("1", options.Bbb!["a"]); - Assert.AreEqual("2", options.Bbb["b"]); - Assert.AreEqual("3", options.Bbb["c"]); - Assert.AreEqual("1", options.Ccc!["a"]); - Assert.AreEqual("2", options.Ccc["b"]); - Assert.AreEqual("3", options.Ccc["c"]); - }).WithArguments( - new[] { "-a", "a=1;b=2;c=3", "-b", "a=1;b=2;c=3", "-c", "a=1;b=2;c=3" }, - new[] { "-a:a=1;b=2;c=3", "-b:a=1;b=2;c=3", "-c:a=1;b=2;c=3" } - ); - - "命令行传入字典,能正确处理参数中的空格。".Test((string[] args) => - { - // Arrange & Action - var commandLine = CommandLine.Parse(args); - var options = commandLine.As(); - - // Assert - Assert.AreEqual("1", options.Aaa!["a"]); - Assert.AreEqual("1 1", options.Bbb!["a"]); - Assert.AreEqual("1", options.Ccc!["a"]); - Assert.AreEqual("a", options.Ddd.Key); - Assert.AreEqual("1", options.Ddd.Value); - }).WithArguments( - new[] { "-a", "a = 1", "-b", "a=1 1", "-c", " a=1 ", "-d", "a =1" }, - new[] { "-a:a = 1", "-b:a=1 1", "-c: a=1 ", "-d:a =1" } - ); - } - - [ContractTestCase] - public void ParseAsAmbiguously() - { - "命令行传入开关参数,或者传入带有 true/false 值的参数,可以赋值给 bool 类型。".Test((string[] args) => - { - // Arrange & Action - var commandLine = CommandLine.Parse(args); - var options = commandLine.As(new AmbiguousOptionsParser()); - - // Assert - Assert.AreEqual(true, options.Boolean); - }).WithArguments( - new[] { "--boolean" }, - new[] { "--boolean", "true" } - ); - - "命令行传入带有 true/false 值的参数,可以赋值给 string 类型。".Test(() => - { - // Arrange & Action - var commandLine = CommandLine.Parse(new[] { "--string-boolean", "true" }); - var options = commandLine.As(new AmbiguousOptionsParser()); - - // Assert - Assert.AreEqual("true", options.StringBoolean); - }); - - "命令行传入带有多个值的参数,可以赋值给 string 类型。".Test(() => - { - // Arrange & Action - var commandLine = CommandLine.Parse(new[] { "--string-array", "a", "b" }); - var options = commandLine.As(new AmbiguousOptionsParser()); - - // Assert - Assert.AreEqual("a b", options.StringArray); - }); - - "命令行传入带有多个值的参数,可以赋值给 string 集合类型。".Test(() => - { - // Arrange & Action - var commandLine = CommandLine.Parse(new[] - { - "--array", "a", "b", - "--list", "a", "b", - "--read-only-list", "a", "b", - "--enumerable", "a", "b", - }); - var options = commandLine.As(); - - // Assert - CollectionAssert.AreEqual(new[] { "a", "b" }, options.Array); - CollectionAssert.AreEqual(new[] { "a", "b" }, options.List.ToArray()); - CollectionAssert.AreEqual(new[] { "a", "b" }, options.ReadOnlyList.ToArray()); - CollectionAssert.AreEqual(new[] { "a", "b" }, options.Enumerable.ToArray()); - }); - - "命令行传入带有多个值的参数,可以赋值给未内置的 string 集合类型。".Test(() => - { - // Arrange & Action - var commandLine = CommandLine.Parse(new[] - { - "--collection", "a", "b", - }); - var options = commandLine.As(); - - // Assert - CollectionAssert.AreEqual(new[] { "a", "b" }, options.Collection.ToArray()); - }); - } - - [ContractTestCase] - public void Handle() - { - const string expectedFilePath = @"C:\Users\lvyi\Test.txt"; - - "处理带有谓词的命令行参数,可以正确根据谓词选择处理函数。".Test((string[] args, int expectedExitCode) => - { - // Arrange - var commandLine = CommandLine.Parse(args); - - // Action - var exitCode = commandLine - .AddHandler(options => 0) - .AddHandler(options => 1) - .Run(); - - // Assert - Assert.AreEqual(expectedExitCode, exitCode); - }).WithArguments( - // 不区分大小写。 - (new[] { "Edit", expectedFilePath }, 0), - (new[] { "edit", expectedFilePath }, 0), - (new[] { "Print", expectedFilePath }, 1)); - - "处理带有谓词的命令行参数,可以正确解析出含谓词的命令行参数。".Test((string[] args) => - { - // Arrange - var commandLine = CommandLine.Parse(args); - - // Action - string? filePath = null; - commandLine - .AddHandler(options => filePath = options.FilePath) - .AddHandler(options => filePath = options.FilePath) - .AddHandler(options => { }) - .Run(); - - // Assert - Assert.AreEqual(expectedFilePath, filePath); - }).WithArguments( - // 不区分大小写。 - new[] { "Edit", expectedFilePath }, - new[] { "Print", expectedFilePath }); - - "处理带有默认谓词的命令行参数,可以在没有谓词的情况下解析。".Test((string[] args) => - { - // Arrange - var commandLine = CommandLine.Parse(args); - - // Action - string? filePath = null; - commandLine - .AddHandler(options => filePath = options.FilePath) - .AddHandler(options => filePath = options.FilePath) - .Run(); - - // Assert - Assert.AreEqual(expectedFilePath, filePath); - }).WithArguments( - // 不区分大小写。 - new[] { expectedFilePath }, - new[] { "Print", expectedFilePath }); - } - - [ContractTestCase] - public void HandleAsync() - { - const string expectedFilePath = @"C:\Users\lvyi\Test.txt"; - - "处理带有谓词的命令行参数,可以正确根据谓词选择处理函数。".Test(async (string[] args, int expectedExitCode) => - { - // Arrange - var commandLine = CommandLine.Parse(args); - - // Action - var exitCode = await commandLine - .AddHandler(async options => - { - await Task.Delay(10).ConfigureAwait(false); - return 1; - }) - .AddHandler(async options => - { - await Task.Delay(10).ConfigureAwait(false); - return 2; - }) - .RunAsync().ConfigureAwait(false); - - // Assert - Assert.AreEqual(expectedExitCode, exitCode); - }).WithArguments( - // 不区分大小写。 - (new[] { "Edit", expectedFilePath }, 1), - (new[] { "edit", expectedFilePath }, 1), - (new[] { "Print", expectedFilePath }, 2)); - - "处理带有谓词的命令行参数,可以正确解析出含谓词的命令行参数。".Test(async (string[] args) => - { - // Arrange - var commandLine = CommandLine.Parse(args); - - // Action - string? filePath = null; - await commandLine - .AddHandler(async options => - { - await Task.Delay(10).ConfigureAwait(false); - filePath = options.FilePath; - }) - .AddHandler(async options => - { - await Task.Delay(10).ConfigureAwait(false); - filePath = options.FilePath; - }) - .AddHandler( -#pragma warning disable 1998 - async options => { } -#pragma warning restore 1998 - ) - .RunAsync().ConfigureAwait(false); - - // Assert - Assert.AreEqual(expectedFilePath, filePath); - }).WithArguments( - // 不区分大小写。 - new[] { "Edit", expectedFilePath }, - new[] { "Print", expectedFilePath }); - - "处理带有默认谓词的命令行参数,可以在没有谓词的情况下解析。".Test(async (string[] args) => - { - // Arrange - var commandLine = CommandLine.Parse(args); - - // Action - string? filePath = null; - await commandLine - .AddHandler(async options => - { - await Task.Delay(10).ConfigureAwait(false); - filePath = options.FilePath; - }) - .AddHandler(async options => - { - await Task.Delay(10).ConfigureAwait(false); - filePath = options.FilePath; - }) - .RunAsync().ConfigureAwait(false); - - // Assert - Assert.AreEqual(expectedFilePath, filePath); - }).WithArguments( - // 不区分大小写。 - new[] { expectedFilePath }, - new[] { "Print", expectedFilePath }); - } - } -} diff --git a/tests/DotNetCampus.CommandLine.Tests/CommandMatching/AddHandlerTests.cs b/tests/DotNetCampus.CommandLine.Tests/CommandMatching/AddHandlerTests.cs new file mode 100644 index 00000000..a39b8b96 --- /dev/null +++ b/tests/DotNetCampus.CommandLine.Tests/CommandMatching/AddHandlerTests.cs @@ -0,0 +1,278 @@ +using System.Threading.Tasks; +using DotNetCampus.Cli.Compiler; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace DotNetCampus.Cli.Tests.CommandMatching; + +[TestClass] +public class AddHandlerTests +{ + [TestMethod] + [DataRow(new[] { "foo" }, nameof(DefaultHandler), "DefaultHandler", TestCommandLineStyle.Flexible, DisplayName = "[Flexible] foo")] + [DataRow(new[] { "foo" }, nameof(DefaultHandler), "DefaultHandler", TestCommandLineStyle.DotNet, DisplayName = "[DotNet] foo")] + [DataRow(new[] { "foo" }, nameof(DefaultHandler), "DefaultHandler", TestCommandLineStyle.Gnu, DisplayName = "[Gnu] foo")] + [DataRow(new[] { "foo" }, nameof(DefaultHandler), "DefaultHandler", TestCommandLineStyle.Windows, DisplayName = "[Windows] foo")] + public void AddHandler(string[] args, string expectedCommand, string expectedValue, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var result = commandLine + .AddHandler() + .RunAsync(); + var matchedTypeName = result.Result.HandledBy!.GetType().Name; + var exitCode = result.Result.ExitCode; + + // Assert + Assert.AreEqual(expectedCommand, matchedTypeName); + Assert.AreEqual(1, exitCode); + } + + [TestMethod] + [DataRow(new[] { "foo" }, nameof(FooOptions), "Foo", TestCommandLineStyle.Flexible, DisplayName = "[Flexible] foo")] + [DataRow(new[] { "foo" }, nameof(FooOptions), "Foo", TestCommandLineStyle.DotNet, DisplayName = "[DotNet] foo")] + [DataRow(new[] { "foo" }, nameof(FooOptions), "Foo", TestCommandLineStyle.Gnu, DisplayName = "[Gnu] foo")] + [DataRow(new[] { "foo" }, nameof(FooOptions), "Foo", TestCommandLineStyle.Windows, DisplayName = "[Windows] foo")] + public void AddHandler_Action(string[] args, string expectedCommand, string expectedValue, TestCommandLineStyle style) + { + // Arrange + string? matched = null; + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var result = commandLine + .AddHandler(o => matched = o.Value) + .AddHandler(o => matched = o.Value) + .AddHandler(o => matched = o.Value) + .Run(); + var matchedTypeName = result.HandledBy!.GetType().Name; + + // Assert + Assert.AreEqual(expectedCommand, matchedTypeName); + Assert.AreEqual(expectedValue, matched); + } + + [TestMethod] + [DataRow(new[] { "foo" }, nameof(FooOptions), "Foo", TestCommandLineStyle.Flexible, DisplayName = "[Flexible] foo")] + [DataRow(new[] { "foo" }, nameof(FooOptions), "Foo", TestCommandLineStyle.DotNet, DisplayName = "[DotNet] foo")] + [DataRow(new[] { "foo" }, nameof(FooOptions), "Foo", TestCommandLineStyle.Gnu, DisplayName = "[Gnu] foo")] + [DataRow(new[] { "foo" }, nameof(FooOptions), "Foo", TestCommandLineStyle.Windows, DisplayName = "[Windows] foo")] + public void AddHandler_FuncInt32(string[] args, string expectedCommand, string expectedValue, TestCommandLineStyle style) + { + // Arrange + string? matched = null; + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var result = commandLine + .AddHandler(o => RunWithExitCode(ref matched, o.Value)) + .AddHandler(o => RunWithExitCode(ref matched, o.Value)) + .AddHandler(o => RunWithExitCode(ref matched, o.Value)) + .Run(); + var matchedTypeName = result.HandledBy!.GetType().Name; + var exitCode = result.ExitCode; + + // Assert + Assert.AreEqual(expectedCommand, matchedTypeName); + Assert.AreEqual(expectedValue, matched); + Assert.AreEqual(1, exitCode); + } + + [TestMethod] + [DataRow(new[] { "foo" }, nameof(FooOptions), "Foo", TestCommandLineStyle.Flexible, DisplayName = "[Flexible] foo")] + [DataRow(new[] { "foo" }, nameof(FooOptions), "Foo", TestCommandLineStyle.DotNet, DisplayName = "[DotNet] foo")] + [DataRow(new[] { "foo" }, nameof(FooOptions), "Foo", TestCommandLineStyle.Gnu, DisplayName = "[Gnu] foo")] + [DataRow(new[] { "foo" }, nameof(FooOptions), "Foo", TestCommandLineStyle.Windows, DisplayName = "[Windows] foo")] + public void AddHandler_FuncTask(string[] args, string expectedCommand, string expectedValue, TestCommandLineStyle style) + { + // Arrange + Task? matched = null; + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var result = commandLine + .AddHandler(o => matched = Task.FromResult(o.Value)) + .AddHandler(o => matched = Task.FromResult(o.Value)) + .AddHandler(o => matched = Task.FromResult(o.Value)) + .RunAsync(); + var matchedTypeName = result.Result.HandledBy!.GetType().Name; + + // Assert + Assert.AreEqual(expectedCommand, matchedTypeName); + Assert.AreEqual(expectedValue, matched?.Result); + } + + [TestMethod] + [DataRow(new[] { "foo" }, nameof(FooOptions), "Foo", TestCommandLineStyle.Flexible, DisplayName = "[Flexible] foo")] + [DataRow(new[] { "foo" }, nameof(FooOptions), "Foo", TestCommandLineStyle.DotNet, DisplayName = "[DotNet] foo")] + [DataRow(new[] { "foo" }, nameof(FooOptions), "Foo", TestCommandLineStyle.Gnu, DisplayName = "[Gnu] foo")] + [DataRow(new[] { "foo" }, nameof(FooOptions), "Foo", TestCommandLineStyle.Windows, DisplayName = "[Windows] foo")] + public void AddHandler_FuncTaskInt32(string[] args, string expectedCommand, string expectedValue, TestCommandLineStyle style) + { + // Arrange + Task? matched = null; + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var result = commandLine + .AddHandler(o => RunWithExitCode(ref matched, o.Value)) + .AddHandler(o => RunWithExitCode(ref matched, o.Value)) + .AddHandler(o => RunWithExitCode(ref matched, o.Value)) + .RunAsync(); + var matchedTypeName = result.Result.HandledBy!.GetType().Name; + var exitCode = result.Result.ExitCode; + + // Assert + Assert.AreEqual(expectedCommand, matchedTypeName); + Assert.AreEqual(expectedValue, matched?.Result); + Assert.AreEqual(1, exitCode); + } + + [TestMethod] + [DataRow(new[] { "foo" }, nameof(FooOptions), "Foo", TestCommandLineStyle.Flexible, DisplayName = "[Flexible] foo")] + [DataRow(new[] { "foo" }, nameof(FooOptions), "Foo", TestCommandLineStyle.DotNet, DisplayName = "[DotNet] foo")] + [DataRow(new[] { "foo" }, nameof(FooOptions), "Foo", TestCommandLineStyle.Gnu, DisplayName = "[Gnu] foo")] + [DataRow(new[] { "foo" }, nameof(FooOptions), "Foo", TestCommandLineStyle.Windows, DisplayName = "[Windows] foo")] + public void AddHandler_Mix1(string[] args, string expectedCommand, string expectedValue, TestCommandLineStyle style) + { + // Arrange + Task? matched = null; + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var result = commandLine + .AddHandler(o => RunSyncWithExitCode(ref matched, o.Value)) + .AddHandler(o => RunWithExitCode(ref matched, o.Value)) + .AddHandler() + .RunAsync(); + var matchedTypeName = result.Result.HandledBy!.GetType().Name; + var exitCode = result.Result.ExitCode; + + // Assert + Assert.AreEqual(expectedCommand, matchedTypeName); + Assert.AreEqual(expectedValue, matched?.Result); + Assert.AreEqual(1, exitCode); + } + + [TestMethod] + [DataRow(new[] { "foo" }, nameof(FooOptions), "Foo", TestCommandLineStyle.Flexible, DisplayName = "[Flexible] foo")] + [DataRow(new[] { "foo" }, nameof(FooOptions), "Foo", TestCommandLineStyle.DotNet, DisplayName = "[DotNet] foo")] + [DataRow(new[] { "foo" }, nameof(FooOptions), "Foo", TestCommandLineStyle.Gnu, DisplayName = "[Gnu] foo")] + [DataRow(new[] { "foo" }, nameof(FooOptions), "Foo", TestCommandLineStyle.Windows, DisplayName = "[Windows] foo")] + public void AddHandler_Mix2(string[] args, string expectedCommand, string expectedValue, TestCommandLineStyle style) + { + // Arrange + Task? matched = null; + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var result = commandLine + .AddHandler(o => RunWithExitCode(ref matched, o.Value)) + .AddHandler(o => RunSyncWithExitCode(ref matched, o.Value)) + .AddHandler(o => RunSyncVoidWithExitCode(ref matched, o.Value)) + .RunAsync(); + var matchedTypeName = result.Result.HandledBy!.GetType().Name; + var exitCode = result.Result.ExitCode; + + // Assert + Assert.AreEqual(expectedCommand, matchedTypeName); + Assert.AreEqual(expectedValue, matched?.Result); + Assert.AreEqual(1, exitCode); + } + + [TestMethod] + [DataRow(new[] { "foo" }, nameof(FooOptions), "Foo", TestCommandLineStyle.Flexible, DisplayName = "[Flexible] foo")] + [DataRow(new[] { "foo" }, nameof(FooOptions), "Foo", TestCommandLineStyle.DotNet, DisplayName = "[DotNet] foo")] + [DataRow(new[] { "foo" }, nameof(FooOptions), "Foo", TestCommandLineStyle.Gnu, DisplayName = "[Gnu] foo")] + [DataRow(new[] { "foo" }, nameof(FooOptions), "Foo", TestCommandLineStyle.Windows, DisplayName = "[Windows] foo")] + public void AddHandler_Mix3(string[] args, string expectedCommand, string expectedValue, TestCommandLineStyle style) + { + // Arrange + Task? matched = null; + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var result = commandLine + .AddHandler(o => RunSyncWithExitCode(ref matched, o.Value)) + .AddHandler() + .RunAsync(); + var matchedTypeName = result.Result.HandledBy!.GetType().Name; + var exitCode = result.Result.ExitCode; + + // Assert + Assert.AreEqual(expectedCommand, matchedTypeName); + Assert.AreEqual(expectedValue, matched?.Result); + Assert.AreEqual(1, exitCode); + } + + // ReSharper disable once RedundantAssignment + private int RunWithExitCode(ref T field, T value) + { + field = value; + return 1; + } + + // ReSharper disable once RedundantAssignment + private Task RunWithExitCode(ref Task? field, T value) + { + field = Task.FromResult(value); + return Task.FromResult(1); + } + + // ReSharper disable once RedundantAssignment + private int RunSyncWithExitCode(ref Task? field, T value) + { + field = Task.FromResult(value); + return 1; + } + + // ReSharper disable once RedundantAssignment + private void RunSyncVoidWithExitCode(ref Task? field, T value) + { + field = Task.FromResult(value); + } + + public record DefaultOptions + { + [Value(0)] + public string? Value { get; set; } = "Default"; + } + + [Command("foo")] + public record FooOptions + { + [Value(0)] + public string? Value { get; set; } = "Foo"; + } + + [Command("bar")] + public record BarOptions + { + [Value(0)] + public string? Value { get; set; } = "Bar"; + } + + public record DefaultHandler : ICommandHandler + { + [Value(0)] + public string? Value { get; set; } = "DefaultHandler"; + + public Task RunAsync() + { + return Task.FromResult(1); + } + } + + [Command("foo")] + public record FooHandler : ICommandHandler + { + [Value(0)] + public string? Value { get; set; } = "FooHandler"; + + public Task RunAsync() + { + return Task.FromResult(2); + } + } +} diff --git a/tests/DotNetCampus.CommandLine.Tests/CommandMatching/AddHandlerWithStateTests.cs b/tests/DotNetCampus.CommandLine.Tests/CommandMatching/AddHandlerWithStateTests.cs new file mode 100644 index 00000000..5aa95b94 --- /dev/null +++ b/tests/DotNetCampus.CommandLine.Tests/CommandMatching/AddHandlerWithStateTests.cs @@ -0,0 +1,169 @@ +using System.Threading.Tasks; +using DotNetCampus.Cli.Compiler; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace DotNetCampus.Cli.Tests.CommandMatching; + +[TestClass] +public class AddHandlerWithStateTests +{ + [TestMethod] + [DataRow(new[] { "test1", "--option=value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] test1 --option=value")] + [DataRow(new[] { "test1", "--option=value" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] test1 --option=value")] + [DataRow(new[] { "test1", "--option=value" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] test1 --option=value")] + [DataRow(new[] { "test1", "-Option=value" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] test1 -Option=value")] + public async Task AddHandlerWithState1(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var result = await commandLine + .ForState(123).AddHandler() + .ForState("test2").AddHandler() + .ForState().AddHandler() + .RunAsync(); + var state = ((Test1Handler)result.HandledBy!).State; + + // Assert + Assert.AreEqual(123, state); + } + + [TestMethod] + [DataRow(new[] { "test2", "--option=value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] test1 --option=value")] + [DataRow(new[] { "test2", "--option=value" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] test1 --option=value")] + [DataRow(new[] { "test2", "--option=value" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] test1 --option=value")] + [DataRow(new[] { "test2", "-Option=value" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] test1 -Option=value")] + public async Task AddHandlerWithState2(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var result = await commandLine + .ForState(123).AddHandler() + .ForState("test2").AddHandler() + .ForState().AddHandler() + .RunAsync(); + var state = ((Test2Handler)result.HandledBy!).State; + + // Assert + Assert.AreEqual("test2", state); + } + + [TestMethod] + [DataRow(new[] { "test1", "test", "--option=value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] test1 --option=value")] + [DataRow(new[] { "test1", "test", "--option=value" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] test1 --option=value")] + [DataRow(new[] { "test1", "test", "--option=value" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] test1 --option=value")] + [DataRow(new[] { "test1", "test", "-Option=value" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] test1 -Option=value")] + public async Task MultipleAddHandlerWithState1(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var result = await commandLine + .ForState(123).AddHandler().AddHandler() + .ForState("test2").AddHandler() + .ForState().AddHandler() + .RunAsync(); + var state = ((Test11Handler)result.HandledBy!).State; + + // Assert + Assert.AreEqual(123, state); + } + + [TestMethod] + [DataRow(new[] { "test1", "test", "--option=value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] test1 --option=value")] + [DataRow(new[] { "test1", "test", "--option=value" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] test1 --option=value")] + [DataRow(new[] { "test1", "test", "--option=value" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] test1 --option=value")] + [DataRow(new[] { "test1", "test", "-Option=value" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] test1 -Option=value")] + public async Task MultipleAddHandlerWithState2(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var result = await commandLine + .AddHandler() + .ForState(123).AddHandler().AddHandler() + .ForState("test2").AddHandler() + .RunAsync(); + var state = ((Test11Handler)result.HandledBy!).State; + + // Assert + Assert.AreEqual(123, state); + } + + public record Test0Handler : ICommandHandler + { + [Option('o', "option")] + public string? Option { get; set; } + + public Task RunAsync() + { + return Task.FromResult(0); + } + } + + [Command("test1")] + public record Test1Handler : ICommandHandler + { + [Option('o', "option")] + public string? Option { get; set; } + + public int? State { get; private set; } + + public Task RunAsync(int state) + { + State = state; + return Task.FromResult(0); + } + } + + [Command("test1 test")] + public record Test11Handler : ICommandHandler + { + [Option('o', "option")] + public string? Option { get; set; } + + public int? State { get; private set; } + + public Task RunAsync(int state) + { + State = state; + return Task.FromResult(0); + } + } + + [Command("test2")] + public record Test2Handler : ICommandHandler + { + [Option('o', "option")] + public string? Option { get; set; } + + public string? State { get; private set; } + + public Task RunAsync(string state) + { + State = state; + return Task.FromResult(0); + } + } +} + +file static class Extensions +{ + // public static StatedCommandRunnerLinkedBuilder AddHandler(this StatedCommandRunnerBuilder builder) + // where T : class, ICommandHandler + // { + // throw CommandLine.MethodShouldBeInspected(); + // } + // public static global::DotNetCampus.Cli.StatedCommandRunnerLinkedBuilder AddHandler(this global::DotNetCampus.Cli.StatedCommandRunnerBuilder builder) + // where T : class, global::DotNetCampus.Cli.ICommandHandler + // { + // // 请确保 Test2Handler 类型中至少有一个属性标记了 [Option] 或 [Value] 特性; + // // 否则下面的 AddHandlerWithStateTests_Test2HandlerBuilder 类型将不存在,导致编译不通过。 + // return builder.AddHandler(global::DotNetCampus.Cli.Tests.CommandMatching.AddHandlerWithStateTests_Test2HandlerBuilder.CommandNameGroup, global::DotNetCampus.Cli.Tests.CommandMatching.AddHandlerWithStateTests_Test2HandlerBuilder.CreateInstance); + // } +} diff --git a/tests/DotNetCampus.CommandLine.Tests/CommandMatching/CommandAttributeTests.cs b/tests/DotNetCampus.CommandLine.Tests/CommandMatching/CommandAttributeTests.cs new file mode 100644 index 00000000..8e21dc07 --- /dev/null +++ b/tests/DotNetCampus.CommandLine.Tests/CommandMatching/CommandAttributeTests.cs @@ -0,0 +1,43 @@ +using DotNetCampus.Cli.Compiler; +using DotNetCampus.Cli.Tests.ParsingStyles; +using DotNetCampus.CommandLine.FakeObjects; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace DotNetCampus.Cli.Tests.CommandMatching; + +/// +/// 我们所有的特性(包括 都标记了 +/// [Conditional("FOR_SOURCE_GENERATION_ONLY")]。由于我们一般不会标记条件编译符 FOR_SOURCE_GENERATION_ONLY,所以这些特性在编译完成后都会消失。 +/// 源生成器可以看见源代码中的这些特性,看不见已编译好的程序集中的这些特性; +/// 所以源生成器只能生成本项目(程序集)中与这些特性相关的代码,无法生成其他程序集中的相关代码;这就可能导致无法生成跨程序集对象的命令。
+/// 为了解决这个问题,我们要求所有与这些特性相关的代码必须在本项目中生成,跨程序集的必须直接引用原项目中生成的代码。
+/// 本单元测试旨在测试以确保这种情况的正确性。 +///
+[TestClass] +public class CommandAttributeTests +{ + [TestMethod] + [DataRow(new[] { "--option=value" }, nameof(CommandObject0InAnotherAssembly), TestCommandLineStyle.Flexible, + DisplayName = "[Flexible] --option=value")] + [DataRow(new[] { "test", "--option=value" }, nameof(CommandObject1InAnotherAssembly), TestCommandLineStyle.Flexible, + DisplayName = "[Flexible] test --option=value")] + [DataRow(new[] { "command", "in-another-assembly", "--option=value" }, nameof(CommandObject2InAnotherAssembly), TestCommandLineStyle.Flexible, + DisplayName = "[Flexible] command in-another-assembly --option=value")] + public void MatchCommand(string[] args, string expectedCommand, TestCommandLineStyle style) + { + // Arrange + (string? TypeName, string? Value) matched = default; + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + commandLine + .AddHandler(o => matched = (o.GetType().Name, o.Option)) + .AddHandler(o => matched = (o.GetType().Name, o.Option)) + .AddHandler(o => matched = (o.GetType().Name, o.Option)) + .Run(); + + // Assert + Assert.AreEqual(expectedCommand, matched.TypeName); + Assert.AreEqual("value", matched.Value); + } +} diff --git a/tests/DotNetCampus.CommandLine.Tests/CommandMatching/MatchCommandTests.cs b/tests/DotNetCampus.CommandLine.Tests/CommandMatching/MatchCommandTests.cs new file mode 100644 index 00000000..2c62daaa --- /dev/null +++ b/tests/DotNetCampus.CommandLine.Tests/CommandMatching/MatchCommandTests.cs @@ -0,0 +1,133 @@ +using DotNetCampus.Cli.Compiler; +using DotNetCampus.Cli.Exceptions; +using DotNetCampus.Cli.Tests.ParsingStyles; +using DotNetCampus.Cli.Utils.Parsers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace DotNetCampus.Cli.Tests.CommandMatching; + +[TestClass] +public class MatchCommandTests +{ + [TestMethod] + [DataRow(new string[] { }, nameof(DefaultOptions), "Default", TestCommandLineStyle.Flexible, DisplayName = "[Flexible]")] + [DataRow(new string[] { }, nameof(DefaultOptions), "Default", TestCommandLineStyle.DotNet, DisplayName = "[DotNet]")] + [DataRow(new string[] { }, nameof(DefaultOptions), "Default", TestCommandLineStyle.Gnu, DisplayName = "[Gnu]")] + [DataRow(new string[] { }, nameof(DefaultOptions), "Default", TestCommandLineStyle.Windows, DisplayName = "[Windows]")] + [DataRow(new[] { "test://" }, nameof(DefaultOptions), "Default", TestCommandLineStyle.Url, DisplayName = "[Url] test://")] + [DataRow(new[] { "unknown://" }, nameof(DefaultOptions), "unknown://", TestCommandLineStyle.Url, DisplayName = "[Url] unknown://")] + [DataRow(new[] { "foo" }, nameof(FooOptions), "Foo", TestCommandLineStyle.Flexible, DisplayName = "[Flexible] foo")] + [DataRow(new[] { "foo" }, nameof(FooOptions), "Foo", TestCommandLineStyle.DotNet, DisplayName = "[DotNet] foo")] + [DataRow(new[] { "foo" }, nameof(FooOptions), "Foo", TestCommandLineStyle.Gnu, DisplayName = "[Gnu] foo")] + [DataRow(new[] { "test://foo" }, nameof(FooOptions), "Foo", TestCommandLineStyle.Url, DisplayName = "[Url] test://foo")] + [DataRow(new[] { "foo" }, nameof(FooOptions), "Foo", TestCommandLineStyle.Windows, DisplayName = "[Windows] foo")] + [DataRow(new[] { "fooo" }, nameof(DefaultOptions), "fooo", TestCommandLineStyle.Flexible, DisplayName = "[Flexible] fooo")] + [DataRow(new[] { "fooo" }, nameof(DefaultOptions), "fooo", TestCommandLineStyle.DotNet, DisplayName = "[DotNet] fooo")] + [DataRow(new[] { "fooo" }, nameof(DefaultOptions), "fooo", TestCommandLineStyle.Gnu, DisplayName = "[Gnu] fooo")] + [DataRow(new[] { "fooo" }, nameof(DefaultOptions), "fooo", TestCommandLineStyle.Windows, DisplayName = "[Windows] fooo")] + [DataRow(new[] { "test://fooo" }, nameof(DefaultOptions), "fooo", TestCommandLineStyle.Url, DisplayName = "[Url] test://fooo")] + [DataRow(new[] { "bar", "baz" }, nameof(BarBazOptions), "BarBaz", TestCommandLineStyle.Flexible, DisplayName = "[Flexible] bar baz")] + [DataRow(new[] { "bar", "baz" }, nameof(BarBazOptions), "BarBaz", TestCommandLineStyle.DotNet, DisplayName = "[DotNet] bar baz")] + [DataRow(new[] { "bar", "baz" }, nameof(BarBazOptions), "BarBaz", TestCommandLineStyle.Gnu, DisplayName = "[Gnu] bar baz")] + [DataRow(new[] { "bar", "baz" }, nameof(BarBazOptions), "BarBaz", TestCommandLineStyle.Windows, DisplayName = "[Windows] bar baz")] + [DataRow(new[] { "test://bar/baz" }, nameof(BarBazOptions), "BarBaz", TestCommandLineStyle.Url, DisplayName = "[Url] test://bar/baz")] + [DataRow(new[] { "bar", "bazz" }, nameof(BarOptions), "bazz", TestCommandLineStyle.Flexible, DisplayName = "[Flexible] bar bazz")] + [DataRow(new[] { "bar", "bazz" }, nameof(BarOptions), "bazz", TestCommandLineStyle.DotNet, DisplayName = "[DotNet] bar bazz")] + [DataRow(new[] { "bar", "bazz" }, nameof(BarOptions), "bazz", TestCommandLineStyle.Gnu, DisplayName = "[Gnu] bar bazz")] + [DataRow(new[] { "bar", "bazz" }, nameof(BarOptions), "bazz", TestCommandLineStyle.Windows, DisplayName = "[Windows] bar bazz")] + [DataRow(new[] { "test://bar/bazz" }, nameof(BarOptions), "bazz", TestCommandLineStyle.Url, DisplayName = "[Url] test://bar/bazz")] + [DataRow(new[] { "another", "sub-command" }, nameof(SubCommandOptions), "AnotherSubCommand", TestCommandLineStyle.Flexible, + DisplayName = "[Flexible] another sub-command")] + [DataRow(new[] { "another", "sub-command" }, nameof(SubCommandOptions), "AnotherSubCommand", TestCommandLineStyle.DotNet, + DisplayName = "[DotNet] another sub-command")] + [DataRow(new[] { "another", "sub-command" }, nameof(SubCommandOptions), "AnotherSubCommand", TestCommandLineStyle.Gnu, + DisplayName = "[Gnu] another sub-command")] + [DataRow(new[] { "Another", "SubCommand" }, nameof(SubCommandOptions), "AnotherSubCommand", TestCommandLineStyle.Windows, + DisplayName = "[Windows] Another SubCommand")] + [DataRow(new[] { "another", "subCommand" }, nameof(SubCommandOptions), "AnotherSubCommand", TestCommandLineStyle.Windows, + DisplayName = "[Windows] another subCommand")] + [DataRow(new[] { "test://another/sub-command" }, nameof(SubCommandOptions), "AnotherSubCommand", TestCommandLineStyle.Url, + DisplayName = "[Url] test://another/sub-command")] + public void MatchCommand(string[] args, string expectedCommand, string expectedValue, TestCommandLineStyle style) + { + // Arrange + string? matched = null; + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var result = commandLine + .AddHandler(o => matched = o.Value) + .AddHandler(o => matched = o.Value) + .AddHandler(o => matched = o.Value) + .AddHandler(o => matched = o.Value) + .AddHandler(o => matched = o.Value) + .Run(); + var matchedTypeName = result.HandledBy!.GetType().Name; + + // Assert + Assert.AreEqual(expectedCommand, matchedTypeName); + Assert.AreEqual(expectedValue, matched); + } + + [TestMethod] + [DataRow(new[] { "another", "sub-command" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] another sub-command")] + public void MatchCommand_PositionalArgumentNotMatch(string[] args, TestCommandLineStyle style) + { + // Arrange + var matched = ""; + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var exception = Assert.ThrowsExactly(() => commandLine + .AddHandler(o => matched = o.Value) + .AddHandler(o => matched = o.Value) + .AddHandler(o => matched = o.Value) + .AddHandler(o => matched = o.Value) + .Run()); + + // Assert + Assert.IsEmpty(matched); + Assert.AreEqual(CommandLineParsingError.PositionalArgumentNotFound, exception.Reason); + } + + public record DefaultOptions + { + [Value(0)] + public string? Value { get; set; } = "Default"; + } + + [Command("foo")] + public record FooOptions + { + [Value(0)] + public string? Value { get; set; } = "Foo"; + } + + [Command("bar")] + public record BarOptions + { + [Value(0)] + public string? Value { get; set; } = "Bar"; + } + + [Command("bar baz")] + public record BarBazOptions + { + [Value(0)] + public string? Value { get; set; } = "BarBaz"; + } + + [Command("bar qux")] + public record BarQuxOptions + { + [Value(0)] + public string? Value { get; set; } = "BarQux"; + } + + [Command("another sub-command")] + public record SubCommandOptions + { + [Value(0)] + public string? Value { get; set; } = "AnotherSubCommand"; + } +} diff --git a/tests/DotNetCampus.CommandLine.Tests/DotNetCampus.CommandLine.Tests.csproj b/tests/DotNetCampus.CommandLine.Tests/DotNetCampus.CommandLine.Tests.csproj index 1df9aa37..d356b28b 100644 --- a/tests/DotNetCampus.CommandLine.Tests/DotNetCampus.CommandLine.Tests.csproj +++ b/tests/DotNetCampus.CommandLine.Tests/DotNetCampus.CommandLine.Tests.csproj @@ -2,9 +2,7 @@ net8.0 - false - DotNetCampus.Cli.Tests @@ -18,20 +16,7 @@ - - - - - - - - - - - - - ..\dotnetCampus.CommandLine.Performance\dotnetCampus.CommandLine.Legacy.dll - + diff --git a/tests/DotNetCampus.CommandLine.Tests/DotNetCommandLineParserTests.cs b/tests/DotNetCampus.CommandLine.Tests/DotNetCommandLineParserTests.cs deleted file mode 100644 index 6914ddaf..00000000 --- a/tests/DotNetCampus.CommandLine.Tests/DotNetCommandLineParserTests.cs +++ /dev/null @@ -1,1148 +0,0 @@ -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using System.Threading.Tasks; -using DotNetCampus.Cli.Compiler; -using DotNetCampus.Cli.Exceptions; -using Microsoft.VisualStudio.TestTools.UnitTesting; - -// ReSharper disable UnusedAutoPropertyAccessor.Global -// ReSharper disable InconsistentNaming - -namespace DotNetCampus.Cli.Tests; - -/// -/// 测试 DotNet 风格命令行参数是否正确被解析。 -/// -[TestClass] -public class DotNetCommandLineParserTests -{ - private CommandLineParsingOptions DotNet { get; } = CommandLineParsingOptions.DotNet; - - #region 1. 选项识别与解析 - - [TestMethod("1.1. 短选项冒号形式 (-option:value),字符串类型,可正常赋值。")] - public void ShortOption_WithColon_StringType_ValueAssigned() - { - // Arrange - string[] args = ["-value:test"]; - string? value = null; - - // Act - CommandLine.Parse(args, DotNet) - .AddHandler(o => value = o.Value) - .Run(); - - // Assert - Assert.AreEqual("test", value); - } - - [TestMethod("1.2. 长选项冒号形式 (--option:value),字符串类型,可正常赋值。")] - public void LongOption_WithColon_StringType_ValueAssigned() - { - // Arrange - string[] args = ["--value:test"]; - string? value = null; - - // Act - CommandLine.Parse(args, DotNet) - .AddHandler(o => value = o.Value) - .Run(); - - // Assert - Assert.AreEqual("test", value); - } - - [TestMethod("1.3. 斜杠前缀形式 (/option:value),字符串类型,可正常赋值。")] - public void SlashPrefix_WithColon_StringType_ValueAssigned() - { - // Arrange - string[] args = ["/value:test"]; - string? value = null; - - // Act - CommandLine.Parse(args, DotNet) - .AddHandler(o => value = o.Value) - .Run(); - - // Assert - Assert.AreEqual("test", value); - } - - [TestMethod("1.4. 多个选项混合使用,全部正确解析。")] - public void MixedOptions_MultipleParsed_AllAssigned() - { - // Arrange - string[] args = ["-number:42", "--text:hello", "/flag:true"]; - int? number = null; - string? text = null; - bool? flag = null; - - // Act - CommandLine.Parse(args, DotNet) - .AddHandler(o => - { - number = o.Number; - text = o.Text; - flag = o.Flag; - }) - .Run(); - - // Assert - Assert.AreEqual(42, number); - Assert.AreEqual("hello", text); - Assert.IsTrue(flag); - } - - [TestMethod("1.5. PascalCase命名风格选项,可正常解析。")] - public void PascalCaseOption_Parsed_ValueAssigned() - { - // Arrange - string[] args = ["-PascalCase:value"]; - string? value = null; - - // Act - CommandLine.Parse(args, DotNet) - .AddHandler(o => value = o.PascalCase) - .Run(); - - // Assert - Assert.AreEqual("value", value); - } - - [TestMethod("1.6. camelCase命名风格选项,可正常解析。")] - public void CamelCaseOption_Parsed_ValueAssigned() - { - // Arrange - string[] args = ["--camelCase:value"]; - string? value = null; - - // Act - CommandLine.Parse(args, DotNet) - .AddHandler(o => value = o.CamelCase) - .Run(); - - // Assert - Assert.AreEqual("value", value); - } - - [TestMethod("1.7. kebab-case命名风格选项,可正常解析。")] - public void KebabCaseOption_Parsed_ValueAssigned() - { - // Arrange - string[] args = ["--kebab-case:value"]; - string? value = null; - - // Act - CommandLine.Parse(args, DotNet) - .AddHandler(o => value = o.KebabCase) - .Run(); - - // Assert - Assert.AreEqual("value", value); - } - - [TestMethod("1.8. 不同前缀的PascalCase风格选项,可正常解析。")] - public void MixedPrefixWithPascalCase_Parsed_ValueAssigned() - { - // Arrange - string[] args = ["-Option1:value1", "--Option2:value2", "/Option3:value3"]; - string? option1 = null; - string? option2 = null; - string? option3 = null; - - // Act - CommandLine.Parse(args, DotNet) - .AddHandler(o => - { - option1 = o.Option1; - option2 = o.Option2; - option3 = o.Option3; - }) - .Run(); - - // Assert - Assert.AreEqual("value1", option1); - Assert.AreEqual("value2", option2); - Assert.AreEqual("value3", option3); - } - - [TestMethod("1.9. 单字符短选项,不同前缀,可正常解析。")] - public void SingleCharOptions_DifferentPrefixes_Parsed() - { - // Arrange - string[] args = ["-a:value1", "/b:value2"]; - string? optionA = null; - string? optionB = null; - - // Act - CommandLine.Parse(args, DotNet) - .AddHandler(o => - { - optionA = o.A; - optionB = o.B; - }) - .Run(); - - // Assert - Assert.AreEqual("value1", optionA); - Assert.AreEqual("value2", optionB); - } - - #endregion - - #region 2. 类型转换 - - [TestMethod("2.1. 整数类型,赋值成功。")] - public void IntegerOption_ValueAssigned() - { - // Arrange - string[] args = ["--number:42"]; - int? number = null; - - // Act - CommandLine.Parse(args, DotNet) - .AddHandler(o => number = o.Number) - .Run(); - - // Assert - Assert.AreEqual(42, number); - } - - [TestMethod("2.2. 布尔类型,不带值赋为true。")] - public void BooleanOption_NoValue_SetTrue() - { - // Arrange - string[] args = ["--flag"]; - bool? flag = null; - - // Act - CommandLine.Parse(args, DotNet) - .AddHandler(o => flag = o.Flag) - .Run(); - - // Assert - Assert.IsTrue(flag); - } - - [TestMethod("2.2.1. 布尔类型,带值true赋为true。")] - public void BooleanOption_TrueValue_SetTrue() - { - // Arrange - string[] args = ["--flag:true"]; - bool? flag = null; - - // Act - CommandLine.Parse(args, DotNet) - .AddHandler(o => flag = o.Flag) - .Run(); - - // Assert - Assert.IsTrue(flag); - } - - [TestMethod("2.2.2. 布尔类型,带值false赋为false。")] - public void BooleanOption_FalseValue_SetFalse() - { - // Arrange - string[] args = ["--flag:false"]; - bool? flag = null; - - // Act - CommandLine.Parse(args, DotNet) - .AddHandler(o => flag = o.Flag) - .Run(); - - // Assert - Assert.IsFalse(flag); - } - - [TestMethod("2.3. 枚举类型,赋值成功。")] - public void EnumOption_ValueAssigned() - { - // Arrange - string[] args = ["--log-level:Warning"]; - LogLevel? logLevel = null; - - // Act - CommandLine.Parse(args, DotNet) - .AddHandler(o => logLevel = o.LogLevel) - .Run(); - - // Assert - Assert.AreEqual(LogLevel.Warning, logLevel); - } - - [TestMethod("2.4. 字符串数组,赋值成功。")] - public void StringArrayOption_ValueAssigned() - { - // Arrange - string[] args = ["--files:file1.txt", "--files:file2.txt", "--files:file3.txt"]; - string[]? files = null; - - // Act - CommandLine.Parse(args, DotNet) - .AddHandler(o => files = o.Files) - .Run(); - - // Assert - Assert.IsNotNull(files); - Assert.AreEqual(3, files.Length); - CollectionAssert.AreEqual(new[] { "file1.txt", "file2.txt", "file3.txt" }, files); - } - - [TestMethod("2.4.1. 字符串数组,使用分号分隔多个值,赋值成功。")] - public void StringArrayOption_SemicolonSeparated_ValueAssigned() - { - // Arrange - string[] args = ["--files:file1.txt;file2.txt;file3.txt"]; - string[]? files = null; - - // Act - CommandLine.Parse(args, DotNet) - .AddHandler(o => files = o.Files) - .Run(); - - // Assert - Assert.IsNotNull(files); - Assert.AreEqual(3, files.Length); - CollectionAssert.AreEqual(new[] { "file1.txt", "file2.txt", "file3.txt" }, files); - } - - [TestMethod("2.4.2. 字符串数组,使用逗号分隔多个值,赋值成功。")] - public void StringArrayOption_CommaSeparated_ValueAssigned() - { - // Arrange - string[] args = ["--files:file1.txt,file2.txt,file3.txt"]; - string[]? files = null; - - // Act - CommandLine.Parse(args, DotNet) - .AddHandler(o => files = o.Files) - .Run(); - - // Assert - Assert.IsNotNull(files); - Assert.AreEqual(3, files.Length); - CollectionAssert.AreEqual(new[] { "file1.txt", "file2.txt", "file3.txt" }, files); - } - - [TestMethod("2.4.3. 字符串数组,包含带引号的值,赋值成功。")] - public void StringArrayOption_QuotedValues_ValueAssigned() - { - // Arrange - string[] args = ["--files:\"file with spaces.txt\"", "--files:normal.txt", "--files:\"another file.txt\""]; - string[]? files = null; - - // Act - CommandLine.Parse(args, DotNet) - .AddHandler(o => - { - files = o.Files; - return 0; - }) - .Run(); - - // Assert - Assert.IsNotNull(files); - Assert.AreEqual(3, files.Length); - CollectionAssert.AreEqual(new[] { "file with spaces.txt", "normal.txt", "another file.txt" }, files); - } - - [TestMethod("2.4.4. 字符串数组,使用分号分隔的带引号值,赋值成功。")] - public void StringArrayOption_SemicolonSeparatedQuoted_ValueAssigned() - { - // Arrange - string[] args = ["--files:\"file with spaces.txt\";normal.txt;\"another file.txt\""]; - string[]? files = null; - - // Act - CommandLine.Parse(args, DotNet) - .AddHandler(o => - { - files = o.Files; - return 0; - }) - .Run(); - - // Assert - Assert.IsNotNull(files); - Assert.AreEqual(3, files.Length); - CollectionAssert.AreEqual(new[] { "file with spaces.txt", "normal.txt", "another file.txt" }, files); - } - - [TestMethod("2.5. 列表类型,赋值成功。")] - public void ListOption_ValueAssigned() - { - // Arrange - string[] args = ["--tags:tag1", "--tags:tag2", "--tags:tag3"]; - List? tags = null; - - // Act - CommandLine.Parse(args, DotNet) - .AddHandler(o => tags = o.Tags.ToList()) - .Run(); - - // Assert - Assert.IsNotNull(tags); - Assert.AreEqual(3, tags.Count); - CollectionAssert.AreEqual(new[] { "tag1", "tag2", "tag3" }, tags); - } - - [TestMethod("2.6.1. 字典类型,多次传入相同选项,赋值成功。")] - public void DictionaryOption_MultipleEntries_ValueAssigned() - { - // Arrange - string[] args = ["--properties:key1=value1", "--properties:key2=value2", "--properties:key3=value3"]; - Dictionary? properties = null; - - // Act - CommandLine.Parse(args, DotNet) - .AddHandler(o => properties = new Dictionary(o.Properties)) - .Run(); - - // Assert - Assert.IsNotNull(properties); - Assert.AreEqual(3, properties.Count); - Assert.AreEqual("value1", properties["key1"]); - Assert.AreEqual("value2", properties["key2"]); - Assert.AreEqual("value3", properties["key3"]); - } - - [TestMethod("2.6.2. 字典类型,单次传入多个键值对,赋值成功。")] - public void DictionaryOption_SingleEntryMultiplePairs_ValueAssigned() - { - // Arrange - string[] args = ["--properties:key1=value1;key2=value2;key3=value3"]; - Dictionary? properties = null; - - // Act - CommandLine.Parse(args, DotNet) - .AddHandler(o => properties = new Dictionary(o.Properties)) - .Run(); - - // Assert - Assert.IsNotNull(properties); - Assert.AreEqual(3, properties.Count); - Assert.AreEqual("value1", properties["key1"]); - Assert.AreEqual("value2", properties["key2"]); - Assert.AreEqual("value3", properties["key3"]); - } - - [TestMethod("2.6.3. 字典类型,混合方式传入,赋值成功。")] - public void DictionaryOption_MixedWays_ValueAssigned() - { - // Arrange - string[] args = ["--properties:key1=value1;key2=value2", "--properties:key3=value3"]; - Dictionary? properties = null; - - // Act - CommandLine.Parse(args, DotNet) - .AddHandler(o => properties = new Dictionary(o.Properties)) - .Run(); - - // Assert - Assert.IsNotNull(properties); - Assert.AreEqual(3, properties.Count); - Assert.AreEqual("value1", properties["key1"]); - Assert.AreEqual("value2", properties["key2"]); - Assert.AreEqual("value3", properties["key3"]); - } - - [TestMethod("2.6.4. 字典类型,键没有对应值,解析抛出异常。")] - public void DictionaryOption_KeyWithoutValue_ThrowsException() - { - // Arrange - string[] args = ["--properties:key1=value1;key2"]; - - // Act & Assert - Assert.ThrowsExactly(() => - { - CommandLine.Parse(args, DotNet) - .AddHandler(_ => { }) - .Run(); - }); - } - - [TestMethod("2.6.5. 字典类型,键值对格式错误,解析抛出异常。")] - public void DictionaryOption_InvalidFormat_ThrowsException() - { - // Arrange - string[] args = ["--properties:key1:value1"]; - - // Act & Assert - Assert.ThrowsExactly(() => - { - CommandLine.Parse(args, DotNet) - .AddHandler(_ => { }) - .Run(); - }); - } - - [TestMethod("2.6.6. 字典类型,重复的键,后者覆盖前者。")] - public void DictionaryOption_DuplicateKeys_LastOneWins() - { - // Arrange - string[] args = ["--properties:key1=value1", "--properties:key1=value2"]; - Dictionary? properties = null; - - // Act - CommandLine.Parse(args, DotNet) - .AddHandler(o => properties = new Dictionary(o.Properties)) - .Run(); - - // Assert - Assert.IsNotNull(properties); - Assert.AreEqual(1, properties.Count); - Assert.AreEqual("value2", properties["key1"]); - } - - [TestMethod("2.6.7. 字典类型,空值场景,成功解析为空字符串。")] - public void DictionaryOption_EmptyValue_ParsedAsEmptyString() - { - // Arrange - string[] args = ["--properties:key1="]; - Dictionary? properties = null; - - // Act - CommandLine.Parse(args, DotNet) - .AddHandler(o => properties = new Dictionary(o.Properties)) - .Run(); - - // Assert - Assert.IsNotNull(properties); - Assert.AreEqual(1, properties.Count); - Assert.AreEqual("", properties["key1"]); - } - - [TestMethod("2.7. 不可变集合类型,赋值成功。")] - public void ImmutableCollectionOption_ValueAssigned() - { - // Arrange - string[] args = ["--items:item1", "--items:item2", "--items:item3"]; - ImmutableArray? items = null; - - // Act - CommandLine.Parse(args, DotNet) - .AddHandler(o => items = o.Items) - .Run(); - - // Assert - Assert.IsNotNull(items); - Assert.AreEqual(3, items.Value.Length); - Assert.AreEqual("item1", items.Value[0]); - Assert.AreEqual("item2", items.Value[1]); - Assert.AreEqual("item3", items.Value[2]); - } - - #endregion - - #region 3. 边界情况处理 - - [TestMethod("3.1. 缺失必需选项,抛出异常。")] - public void MissingRequiredOption_ThrowsException() - { - // Arrange - string[] args = []; - - // Act & Assert - Assert.ThrowsExactly(() => - { - CommandLine.Parse(args, DotNet) - .AddHandler(_ => { }) - .Run(); - }); - } - - [TestMethod("3.2. 无效格式选项,抛出异常。")] - public void InvalidOption_ThrowsException() - { - // Arrange - string[] args = ["---invalid:value"]; // 三个破折号是无效的 - - // Act & Assert - Assert.ThrowsExactly(() => - { - CommandLine.Parse(args, DotNet) - .AddHandler(_ => { }) - .Run(); - }); - } - - [TestMethod("3.3. 类型不匹配,抛出异常。")] - public void TypeMismatch_ThrowsException() - { - // Arrange - string[] args = ["--number:not-a-number"]; - - // Act & Assert - Assert.ThrowsExactly(() => - { - CommandLine.Parse(args, DotNet) - .AddHandler(_ => { }) - .Run(); - }); - } - - [TestMethod("3.4. 大小写不敏感,识别正确。")] - public void CaseInsensitive_CorrectOptionParsed() - { - // Arrange - string[] args = ["--Ignore-Case:value"]; - string? value = null; - - // Act - CommandLine.Parse(args, DotNet) - .AddHandler(o => value = o.IgnoreCase) - .Run(); - - // Assert - Assert.AreEqual("value", value); - } - - #endregion - - #region 4. 特殊特性 - - [TestMethod("4.1. 选项别名,识别正确。")] - public void OptionAliases_CorrectOptionParsed() - { - // Arrange - string[] args = ["--alt:value"]; - string? value = null; - - // Act - CommandLine.Parse(args, DotNet) - .AddHandler(o => value = o.OptionWithAlias) - .Run(); - - // Assert - Assert.AreEqual("value", value); - } - - [TestMethod("4.2. 终止选项解析符号,识别正确。")] - public void OptionTerminator_FollowingArgsAreValues() - { - // Arrange - string[] args = ["--option:value", "--", "--not-an-option", "-x"]; - string? option = null; - string[]? values = null; - - // Act - CommandLine.Parse(args, DotNet) - .AddHandler(o => - { - option = o.Option; - values = o.Values; - }) - .Run(); - - // Assert - Assert.AreEqual("value", option); - Assert.IsNotNull(values); - Assert.AreEqual(2, values.Length); - Assert.AreEqual("--not-an-option", values[0]); - Assert.AreEqual("-x", values[1]); - } - - #endregion - - #region 5. 位置参数处理 - - [TestMethod("5.1. 单个位置参数,赋值成功。")] - public void SinglePositionalValue_ValueAssigned() - { - // Arrange - string[] args = ["positional-value"]; - string? value = null; - - // Act - CommandLine.Parse(args, DotNet) - .AddHandler(o => value = o.Value) - .Run(); - - // Assert - Assert.AreEqual("positional-value", value); - } - - [TestMethod("5.2. 多个位置参数,赋值成功。")] - public void MultiplePositionalValues_AllAssigned() - { - // Arrange - string[] args = ["value1", "value2", "value3"]; - string[]? values = null; - - // Act - CommandLine.Parse(args, DotNet) - .AddHandler(o => values = o.Values) - .Run(); - - // Assert - Assert.IsNotNull(values); - Assert.AreEqual(3, values.Length); - CollectionAssert.AreEqual(new[] { "value1", "value2", "value3" }, values); - } - - [TestMethod("5.3. 位置参数与选项混合,识别正确。")] - public void MixedPositionalAndOptions_AllParsedCorrectly() - { - // Arrange - string[] args = ["value1", "--option:opt-val", "value2"]; - string? option = null; - string? value1 = null; - string? value2 = null; - - // Act - CommandLine.Parse(args, DotNet) - .AddHandler(o => - { - option = o.Option; - value1 = o.Value1; - value2 = o.Value2; - }) - .Run(); - - // Assert - Assert.AreEqual("opt-val", option); - Assert.AreEqual("value1", value1); - Assert.AreEqual("value2", value2); - } - - [TestMethod("5.4. 指定索引位置参数,识别正确。")] - public void IndexedPositionalValues_CorrectAssignment() - { - // Arrange - string[] args = ["first", "second", "third"]; - string? first = null; - string? third = null; - - // Act - CommandLine.Parse(args, DotNet) - .AddHandler(o => - { - first = o.First; - third = o.Third; - }) - .Run(); - - // Assert - Assert.AreEqual("first", first); - Assert.AreEqual("third", third); - } - - #endregion - - #region 6. Required 和 Nullable 组合测试 - - [TestMethod("6.1. Non-required, Non-nullable, 无CLI参数,使用默认值。")] - public void NonRequiredNonNullable_NoCli_UsesDefault() - { - // Arrange - string[] args = []; - string? value = null; - - // Act - CommandLine.Parse(args, DotNet) - .AddHandler(o => value = o.Value) - .Run(); - - // Assert - Assert.AreEqual(null, value); // 使用初始化时的默认值 - } - - [TestMethod("6.2. Required, Non-nullable, 无CLI参数,抛出异常。")] - public void RequiredNonNullable_NoCli_ThrowsException() - { - // Arrange - string[] args = []; - - // Act & Assert - Assert.ThrowsExactly(() => - { - CommandLine.Parse(args, DotNet) - .AddHandler(_ => { }) - .Run(); - }); - } - - [TestMethod("6.3. Non-required, Nullable, 无CLI参数,赋默认值(null)。")] - public void NonRequiredNullable_NoCli_DefaultNull() - { - // Arrange - string[] args = []; - string? value = "not-null"; - - // Act - CommandLine.Parse(args, DotNet) - .AddHandler(o => value = o.Value) - .Run(); - - // Assert - Assert.IsNull(value); - } - - [TestMethod("6.4. Required, Nullable, 无CLI参数,抛出异常。")] - public void RequiredNullable_NoCli_ThrowsException() - { - // Arrange - string[] args = []; - - // Act & Assert - Assert.ThrowsExactly(() => - { - CommandLine.Parse(args, DotNet) - .AddHandler(_ => { }) - .Run(); - }); - } - - [TestMethod("6.5. 各种组合都提供CLI参数,全部赋值成功。")] - public void AllCombinations_WithCli_AllAssigned() - { - // Arrange - string[] args = - [ - "--req-non-null:value1", "--non-req-null:value2", - "--req-null:value3", "--non-req-non-null:value4" - ]; - string? reqNonNull = null; - string? nonReqNull = null; - string? reqNull = null; - string? nonReqNonNull = null; - - // Act - CommandLine.Parse(args, DotNet) - .AddHandler(o => - { - reqNonNull = o.ReqNonNull; - nonReqNull = o.NonReqNull; - reqNull = o.ReqNull; - nonReqNonNull = o.NonReqNonNull; - }) - .Run(); - - // Assert - Assert.AreEqual("value1", reqNonNull); - Assert.AreEqual("value2", nonReqNull); - Assert.AreEqual("value3", reqNull); - Assert.AreEqual("value4", nonReqNonNull); - } - - #endregion - - #region 7. 异步处理测试 - - [TestMethod("7.1. 异步处理方法,正确执行。")] - public async Task AsyncHandler_ExecutesCorrectly() - { - // Arrange - string[] args = ["--value:async-test"]; - string? value = null; - - // Act - await CommandLine.Parse(args, DotNet) - .AddHandler(async o => - { - await Task.Delay(10); // 模拟异步操作 - value = o.Value; - }) - .RunAsync(); - - // Assert - Assert.AreEqual("async-test", value); - } - - #endregion - - #region 8. DotNet特定风格测试 - - [TestMethod("8.1. DotNet风格,双破折号+PascalCase,可正常解析。")] - public void DotNetStyle_DoubleDashPascalCase_Parsed() - { - // Arrange - string[] args = ["--OptionName:value"]; - string? value = null; - - // Act - CommandLine.Parse(args, DotNet) - .AddHandler(o => value = o.OptionName) - .Run(); - - // Assert - Assert.AreEqual("value", value); - } - - [TestMethod("8.2. DotNet风格,单破折号+PascalCase,可正常解析。")] - public void DotNetStyle_SingleDashPascalCase_Parsed() - { - // Arrange - string[] args = ["-OptionName:value"]; - string? value = null; - - // Act - CommandLine.Parse(args, DotNet) - .AddHandler(o => value = o.OptionName) - .Run(); - - // Assert - Assert.AreEqual("value", value); - } - - [TestMethod("8.3. DotNet风格,斜杠+PascalCase,可正常解析。")] - public void DotNetStyle_SlashPascalCase_Parsed() - { - // Arrange - string[] args = ["/OptionName:value"]; - string? value = null; - - // Act - CommandLine.Parse(args, DotNet) - .AddHandler(o => value = o.OptionName) - .Run(); - - // Assert - Assert.AreEqual("value", value); - } - - [TestMethod("8.4. DotNet风格,支持两字符短选项,可正常解析。")] - public void DotNetStyle_TwoCharShortOption_Parsed() - { - // Arrange - string[] args = ["-tl:off"]; - string? value = null; - - // Act - CommandLine.Parse(args, DotNet) - .AddHandler(o => value = o.Tl) - .Run(); - - // Assert - Assert.AreEqual("off", value); - } - - [TestMethod("8.5. DotNet风格,斜杠前缀两字符短选项,可正常解析。")] - public void DotNetStyle_SlashTwoCharOption_Parsed() - { - // Arrange - string[] args = ["/tl:off"]; - string? value = null; - - // Act - CommandLine.Parse(args, DotNet) - .AddHandler(o => value = o.Tl) - .Run(); - - // Assert - Assert.AreEqual("off", value); - } - - #endregion -} - -#region 测试用数据模型 - -internal record DotNet01_StringOptions -{ - [Option] - public required string Value { get; init; } -} - -internal record DotNet02_PascalCaseOptions -{ - [Option("PascalCase")] - public string PascalCase { get; init; } = string.Empty; - - [Option("camelCase")] - public string CamelCase { get; init; } = string.Empty; - - [Option("kebab-case")] - public string KebabCase { get; init; } = string.Empty; -} - -internal record DotNet03_MixedOptions -{ - [Option] - public int Number { get; init; } - - [Option] - public required string Text { get; init; } - - [Option] - public bool Flag { get; init; } -} - -internal record DotNet04_IntegerOptions -{ - [Option] - public int Number { get; init; } -} - -internal record DotNet05_BooleanOptions -{ - [Option] - public bool Flag { get; init; } -} - -internal record DotNet06_EnumOptions -{ - [Option("log-level")] - public LogLevel LogLevel { get; init; } -} - -internal record DotNet07_ArrayOptions -{ - [Option] - public string[] Files { get; init; } = []; -} - -internal record DotNet08_ListOptions -{ - [Option] - public IReadOnlyList Tags { get; init; } = []; -} - -internal record DotNet09_RequiredOptions -{ - [Option] - public required string RequiredValue { get; init; } -} - -internal record DotNet11_CaseInsensitiveOptions -{ - [Option("ignore-case")] - public string IgnoreCase { get; init; } = string.Empty; -} - -internal record DotNet12_AliasOptions -{ - [Option("option-with-alias", Aliases = ["alt", "alternate"])] - public string OptionWithAlias { get; init; } = string.Empty; -} - -internal record DotNet14_TerminatorOptions -{ - [Option] - public string Option { get; init; } = string.Empty; - - [Value(Length = int.MaxValue)] - public string[] Values { get; init; } = []; -} - -internal record DotNet15_SingleValueOptions -{ - [Value] - public string Value { get; init; } = string.Empty; -} - -internal record DotNet16_MultipleValueOptions -{ - [Value(Length = int.MaxValue)] - public string[] Values { get; init; } = []; -} - -internal record DotNet17_MixedValueOptions -{ - [Value(0)] - public string Value1 { get; init; } = string.Empty; - - [Option] - public string Option { get; init; } = string.Empty; - - [Value(1)] - public string Value2 { get; init; } = string.Empty; -} - -internal record DotNet18_IndexedValueOptions -{ - [Value(0)] - public string First { get; init; } = string.Empty; - - [Value(2)] - public string Third { get; init; } = string.Empty; -} - -internal record DotNet19_RequiredNonNullableOption -{ - [Option] - public required string Value { get; init; } -} - -internal record DotNet20_NonRequiredNullableOption -{ - [Option] - public string? Value { get; init; } -} - -internal record DotNet21_RequiredNullableOption -{ - [Option] - public required string? Value { get; init; } -} - -internal record DotNet22_AllCombinationsOption -{ - [Option("req-non-null")] - public required string ReqNonNull { get; init; } - - [Option("non-req-null")] - public string? NonReqNull { get; init; } - - [Option("req-null")] - public required string? ReqNull { get; init; } - - [Option("non-req-non-null")] - public string NonReqNonNull { get; init; } = string.Empty; -} - -internal record DotNet23_DictionaryOptions -{ - [Option] - public IReadOnlyDictionary Properties { get; init; } = new Dictionary(); -} - -internal record DotNet24_ImmutableCollectionOptions -{ - [Option] - public ImmutableArray Items { get; init; } = ImmutableArray.Empty; -} - -internal record DotNet25_MixedPrefixOptions -{ - [Option("Option1")] - public string Option1 { get; init; } = string.Empty; - - [Option("Option2")] - public string Option2 { get; init; } = string.Empty; - - [Option("Option3")] - public string Option3 { get; init; } = string.Empty; -} - -internal record DotNet26_SingleCharOptions -{ - [Option("a")] - public string A { get; init; } = string.Empty; - - [Option("b")] - public string B { get; init; } = string.Empty; -} - -internal record DotNet27_NonRequiredNonNullableOption -{ - [Option] - public string Value { get; init; } = string.Empty; -} - -internal record DotNet28_DotNetSpecificOptions -{ - [Option("OptionName")] - public string OptionName { get; init; } = string.Empty; -} - -internal record DotNet29_TwoCharOptions -{ - [Option("tl")] - public string Tl { get; init; } = string.Empty; -} - -#endregion diff --git a/tests/DotNetCampus.CommandLine.Tests/Fakes/AmbiguousOptions.cs b/tests/DotNetCampus.CommandLine.Tests/Fakes/AmbiguousOptions.cs deleted file mode 100644 index a0078893..00000000 --- a/tests/DotNetCampus.CommandLine.Tests/Fakes/AmbiguousOptions.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System.Collections.Generic; -using DotNetCampus.Cli.Compiler; - -namespace DotNetCampus.Cli.Tests.Fakes; - -public class AmbiguousOptions -{ - /// - /// 命令行中传入 --boolean 也可,传入 --boolean true 也可。 - /// - [Option("Boolean")] - public bool Boolean { get; set; } - - /// - /// 命令行中传入 --string-boolean true 也可,会使得值为 true。 - /// - [Option("StringBoolean")] - public string? StringBoolean { get; set; } - - /// - /// 命令行中传入 --string-array a 也可。 - /// - [Option("StringArray")] - public string? StringArray { get; set; } - - /// - /// 命令行中传入 --string-array a 也可,传入 --string-array a b 也可。 - /// - [Option("Array")] - public IReadOnlyList? Array { get; set; } -} diff --git a/tests/DotNetCampus.CommandLine.Tests/Fakes/AmbiguousOptionsParser.cs b/tests/DotNetCampus.CommandLine.Tests/Fakes/AmbiguousOptionsParser.cs deleted file mode 100644 index faa7640b..00000000 --- a/tests/DotNetCampus.CommandLine.Tests/Fakes/AmbiguousOptionsParser.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using dotnetCampus.Cli; - -namespace DotNetCampus.Cli.Tests.Fakes -{ - public class AmbiguousOptionsParser : CommandLineOptionParser - { - public AmbiguousOptionsParser() - { - bool boolean = false; - string? stringBoolean = null; - string? stringArray = null; - IReadOnlyList? array = null; - - AddMatch("Boolean", value => boolean = value); - AddMatch("StringBoolean", value => stringBoolean = value); - AddMatch("StringArray", value => stringArray = value); - AddMatch("Array", value => array = value); - - SetResult(() => new AmbiguousOptions() - { - Boolean = boolean, - StringBoolean = stringBoolean, - StringArray = stringArray, - Array = array, - }); - } - } -} diff --git a/tests/DotNetCampus.CommandLine.Tests/Fakes/AssemblyCommandHandler.cs b/tests/DotNetCampus.CommandLine.Tests/Fakes/AssemblyCommandHandler.cs deleted file mode 100644 index 0fab08b8..00000000 --- a/tests/DotNetCampus.CommandLine.Tests/Fakes/AssemblyCommandHandler.cs +++ /dev/null @@ -1,6 +0,0 @@ -using DotNetCampus.Cli.Compiler; - -namespace DotNetCampus.Cli.Tests.Fakes; - -[CollectCommandHandlersFromThisAssembly] -internal partial class AssemblyCommandHandler; diff --git a/tests/DotNetCampus.CommandLine.Tests/Fakes/CollectionOptions.cs b/tests/DotNetCampus.CommandLine.Tests/Fakes/CollectionOptions.cs deleted file mode 100644 index 8390a3f2..00000000 --- a/tests/DotNetCampus.CommandLine.Tests/Fakes/CollectionOptions.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System.Collections.Generic; -using System.Collections.ObjectModel; -using DotNetCampus.Cli.Compiler; - -namespace DotNetCampus.Cli.Tests.Fakes; - -public class CollectionOptions -{ - [Option("ReadOnlyList")] - public IReadOnlyList? ReadOnlyList { get; set; } - - [Option("List")] - public IList? List { get; set; } - - [Option("Collection")] - public Collection? Collection { get; set; } - - [Option("Array")] - public string[]? Array { get; set; } - - [Option("Enumerable")] - public IEnumerable? Enumerable { get; set; } -} diff --git a/tests/DotNetCampus.CommandLine.Tests/Fakes/CommandLineArgs.cs b/tests/DotNetCampus.CommandLine.Tests/Fakes/CommandLineArgs.cs deleted file mode 100644 index 203000bc..00000000 --- a/tests/DotNetCampus.CommandLine.Tests/Fakes/CommandLineArgs.cs +++ /dev/null @@ -1,78 +0,0 @@ -namespace DotNetCampus.Cli.Tests.Fakes; - -internal static class CommandLineArgs -{ - internal const string UrlProtocol = "walterlv"; - internal const string FileValue = @"C:\Users\lvyi\Desktop\文件.txt"; - internal const bool CloudValue = true; - internal const bool IwbValue = true; - internal const string ModeValue = "Display"; - internal const bool SilenceValue = true; - internal const string PlacementValue = "Outside"; - internal const string StartupSessionValue = "89EA9D26-6464-4E71-BD04-AA6516063D83"; - - internal static readonly string[] NoArgs = new string[0]; - - internal static readonly string[] WindowsStyleArgs = - { - FileValue, - "-Cloud", - "-Iwb", - "-m", - ModeValue, - "-s", - "-p", - PlacementValue, - "-StartupSession", - StartupSessionValue, - }; - - internal static readonly string[] CmdStyleArgs = - { - FileValue, - "/Cloud", - "/Iwb", - "/m", - ModeValue, - "/s", - "/p", - PlacementValue, - "/StartupSession", - StartupSessionValue, - }; - - internal static readonly string[] Cmd2StyleArgs = - { - FileValue, - "/Cloud", - "/Iwb", - $"/m:{ModeValue}", - "/s", - $"/p:{PlacementValue}", - $"/StartupSession:{StartupSessionValue}", - }; - - internal static readonly string[] LinuxStyleArgs = - { - FileValue, - "--cloud", - "--iwb", - "-m", - ModeValue, - "-s", - "-p", - PlacementValue, - "--startup-session", - StartupSessionValue, - }; - - internal static readonly string[] UrlArgs = - { - @"walterlv://open/?file=C:\Users\lvyi\Desktop\%E6%96%87%E4%BB%B6.txt&cloud=true&iwb=true&mode=Display&silence=true&placement=Outside&startupSession=89EA9D26-6464-4E71-BD04-AA6516063D83", - }; - - internal static readonly string[] EditVerbArgs = - { - "Edit", "XXX", - }; -} diff --git a/tests/DotNetCampus.CommandLine.Tests/Fakes/DefaultCommandHandler.cs b/tests/DotNetCampus.CommandLine.Tests/Fakes/DefaultCommandHandler.cs deleted file mode 100644 index 53cc18bd..00000000 --- a/tests/DotNetCampus.CommandLine.Tests/Fakes/DefaultCommandHandler.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System; -using System.Threading.Tasks; -using DotNetCampus.Cli.Compiler; - -namespace DotNetCampus.Cli.Tests.Fakes; - -public class DefaultCommandHandler : ICommandHandler -{ - [Option("Fake")] - public string? Fake { get; init; } - - [Option("FakeProperty")] - public string? FakeProperty { get; init; } - - [Value] - public string? Argument { get; init; } - - public Func? Runner { get; set; } - - public Task RunAsync() - { - if (Runner is not { } runner) - { - throw new InvalidOperationException("No runner is set."); - } - - return Task.FromResult(runner()); - } -} diff --git a/tests/DotNetCampus.CommandLine.Tests/Fakes/DictionaryOptions.cs b/tests/DotNetCampus.CommandLine.Tests/Fakes/DictionaryOptions.cs deleted file mode 100644 index 578a13ac..00000000 --- a/tests/DotNetCampus.CommandLine.Tests/Fakes/DictionaryOptions.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Collections.Generic; -using DotNetCampus.Cli.Compiler; - -namespace DotNetCampus.Cli.Tests.Fakes; - -public class DictionaryOptions -{ - [Option('a', "Aaa")] - public IReadOnlyDictionary? Aaa { get; set; } - - [Option('b', "Bbb")] - public IDictionary? Bbb { get; set; } - - [Option('c', "Ccc")] - public Dictionary? Ccc { get; set; } - - [Option('d', "Ddd")] - public KeyValuePair Ddd { get; set; } -} diff --git a/tests/DotNetCampus.CommandLine.Tests/Fakes/FakeCommandHandler.cs b/tests/DotNetCampus.CommandLine.Tests/Fakes/FakeCommandHandler.cs deleted file mode 100644 index 2d25a42e..00000000 --- a/tests/DotNetCampus.CommandLine.Tests/Fakes/FakeCommandHandler.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System; -using System.Threading.Tasks; -using DotNetCampus.Cli.Compiler; - -namespace DotNetCampus.Cli.Tests.Fakes; - -[Command("Fake")] -public class FakeCommandHandler : ICommandHandler -{ - [Option("Fake")] - public string? Fake { get; init; } - - [Option("FakeProperty")] - public string? FakeProperty { get; init; } - - [Value] - public string? Argument { get; init; } - - public Func? Runner { get; set; } - - public Task RunAsync() - { - if (Runner is not { } runner) - { - throw new InvalidOperationException("No runner is set."); - } - - return Task.FromResult(runner()); - } -} diff --git a/tests/DotNetCampus.CommandLine.Tests/Fakes/FakeCommandOptions.cs b/tests/DotNetCampus.CommandLine.Tests/Fakes/FakeCommandOptions.cs deleted file mode 100644 index 88b7955b..00000000 --- a/tests/DotNetCampus.CommandLine.Tests/Fakes/FakeCommandOptions.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System; -using DotNetCampus.Cli.Compiler; - -namespace DotNetCampus.Cli.Tests.Fakes; - -public class FakeCommandOptions -{ - [Option("Fake")] - public string? Fake { get; init; } - - [Option("FakeProperty")] - public string? FakeProperty { get; init; } - - [Value] - public string? Argument { get; init; } - - public Func? Runner { get; set; } -} diff --git a/tests/DotNetCampus.CommandLine.Tests/Fakes/PrimaryOptions.cs b/tests/DotNetCampus.CommandLine.Tests/Fakes/PrimaryOptions.cs deleted file mode 100644 index d9ae4de1..00000000 --- a/tests/DotNetCampus.CommandLine.Tests/Fakes/PrimaryOptions.cs +++ /dev/null @@ -1,36 +0,0 @@ -using DotNetCampus.Cli.Compiler; - -namespace DotNetCampus.Cli.Tests.Fakes; - -public class PrimaryOptions -{ - [Option('a', "Byte")] - public byte Aaa { get; set; } - - [Option('b', "Int16")] - public short Bbb { get; set; } - - [Option('c', "UInt16")] - public ushort Ccc { get; set; } - - [Option('d', "Int32")] - public int Ddd { get; set; } - - [Option('e', "UInt32")] - public uint Eee { get; set; } - - [Option('f', "Int64")] - public long Fff { get; set; } - - [Option('g', "UInt64")] - public ulong Ggg { get; set; } - - [Option('h', "Single")] - public float Hhh { get; set; } - - [Option('i', "Double")] - public double Iii { get; set; } - - [Option('j', "Decimal")] - public decimal Jjj { get; set; } -} diff --git a/tests/DotNetCampus.CommandLine.Tests/Fakes/RuntimeImmutableOptions.cs b/tests/DotNetCampus.CommandLine.Tests/Fakes/RuntimeImmutableOptions.cs deleted file mode 100644 index 5af7c6de..00000000 --- a/tests/DotNetCampus.CommandLine.Tests/Fakes/RuntimeImmutableOptions.cs +++ /dev/null @@ -1,73 +0,0 @@ -using System.ComponentModel; - -namespace DotNetCampus.Cli.Tests.Fakes -{ - /// - /// 表示此程序在被启动的时候使用的参数信息。此类型是不可变类型,所有实例都是线程安全的。 - /// - public class RuntimeImmutableOptions - { - /// - /// 表示通过打开的文件路径。此属性可能为 null,但绝不会是空字符串或空白字符串。 - /// - [Value(0), Option('f', "File")] - public string FilePath { get; } - - /// - /// 当此参数值为 true 时,表示此进程是从 Cloud 端启动的 Shell 进程。此属性默认值是 false。 - /// - [Option("Cloud"), DefaultValue(false)] - public bool IsFromCloud { get; } - - /// - /// 表示 Shell 端启动的模式。此属性可能为 null,但绝不会是空字符串或空白字符串。 - /// - [Option('m', "Mode")] - public string StartupMode { get; } - - /// - /// 表示当前是否是静默方式启动,通常由 Shell 启动 Cloud 时使用。此属性默认值是 false。 - /// - [Option('s', "Silence"), DefaultValue(false)] - public bool IsSilence { get; } - - /// - /// 表示当前启动时需要针对 IWB 进行处理。此属性默认值是 false。 - /// - [Option("Iwb"), DefaultValue(false)] - public bool IsIwb { get; } - - /// - /// 表示当前窗口启动时应该安放的位置。此属性可能为 null,但绝不会是空字符串或空白字符串。 - /// - [Option('p', "Placement")] - public string Placement { get; } - - /// - /// 表示一个启动会话 Id,用于在多个进程间同步一些信息。此属性可能为 null,但绝不会是空字符串或空白字符串。 - /// - [Option("StartupSession")] - public string StartupSession { get; } - - /// - /// 创建 类的新实例。 - /// - public RuntimeImmutableOptions( - string filePath, - bool isFromCloud, - string startupMode, - bool isSilence, - bool isIwb, - string placement, - string startupSession) - { - FilePath = filePath; - IsFromCloud = isFromCloud; - StartupMode = startupMode; - IsSilence = isSilence; - IsIwb = isIwb; - Placement = placement; - StartupSession = startupSession; - } - } -} diff --git a/tests/DotNetCampus.CommandLine.Tests/Fakes/RuntimeOptions.cs b/tests/DotNetCampus.CommandLine.Tests/Fakes/RuntimeOptions.cs deleted file mode 100644 index acc615d6..00000000 --- a/tests/DotNetCampus.CommandLine.Tests/Fakes/RuntimeOptions.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System.ComponentModel; - -namespace DotNetCampus.Cli.Tests.Fakes -{ - /// - /// 表示此程序在被启动的时候使用的参数信息。此类型是不可变类型,所有实例都是线程安全的。 - /// - public class RuntimeOptions - { - /// - /// 表示通过打开的文件路径。此属性可能为 null,但绝不会是空字符串或空白字符串。 - /// - [Value(0), Option('f', "File")] - public string? FilePath { get; set; } - - /// - /// 当此参数值为 true 时,表示此进程是从 Cloud 端启动的 Shell 进程。此属性默认值是 false。 - /// - [Option("Cloud"), DefaultValue(false)] - public bool IsFromCloud { get; set; } - - /// - /// 表示 Shell 端启动的模式。此属性可能为 null,但绝不会是空字符串或空白字符串。 - /// - [Option('m', "Mode")] - public string? StartupMode { get; set; } - - /// - /// 表示当前是否是静默方式启动,通常由 Shell 启动 Cloud 时使用。此属性默认值是 false。 - /// - [Option('s', "Silence"), DefaultValue(false)] - public bool IsSilence { get; set; } - - /// - /// 表示当前启动时需要针对 IWB 进行处理。此属性默认值是 false。 - /// - [Option("Iwb"), DefaultValue(false)] - public bool IsIwb { get; set; } - - /// - /// 表示当前窗口启动时应该安放的位置。此属性可能为 null,但绝不会是空字符串或空白字符串。 - /// - [Option('p', "Placement")] - public string? Placement { get; set; } - - /// - /// 表示一个启动会话 Id,用于在多个进程间同步一些信息。此属性可能为 null,但绝不会是空字符串或空白字符串。 - /// - [Option("StartupSession")] - public string? StartupSession { get; set; } - } -} diff --git a/tests/DotNetCampus.CommandLine.Tests/Fakes/UnlimitedValueOptions.cs b/tests/DotNetCampus.CommandLine.Tests/Fakes/UnlimitedValueOptions.cs deleted file mode 100644 index bdfcb14d..00000000 --- a/tests/DotNetCampus.CommandLine.Tests/Fakes/UnlimitedValueOptions.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Collections.Generic; -using DotNetCampus.Cli.Compiler; - -namespace DotNetCampus.Cli.Tests.Fakes; - -public class UnlimitedValueOptions -{ - [Option('s', nameof(Section))] - public string? Section { get; set; } - - [Value(0)] - public int Count { get; set; } - - [Value(1, int.MaxValue)] - public IEnumerable? Args { get; set; } -} diff --git a/tests/DotNetCampus.CommandLine.Tests/Fakes/ValueOptions.cs b/tests/DotNetCampus.CommandLine.Tests/Fakes/ValueOptions.cs deleted file mode 100644 index 5e758463..00000000 --- a/tests/DotNetCampus.CommandLine.Tests/Fakes/ValueOptions.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Collections.Generic; -using DotNetCampus.Cli.Compiler; - -namespace DotNetCampus.Cli.Tests.Fakes; - -public class ValueOptions -{ - [Option('f', nameof(Foo))] - public string? Foo { get; set; } - - [Value(0)] - public long LongValue { get; set; } - - [Value(1, 2)] - public IReadOnlyList? Values { get; set; } - - [Value(2)] - public int Int32Value { get; set; } -} diff --git a/tests/DotNetCampus.CommandLine.Tests/FlexibleCommandLineParserTests.cs b/tests/DotNetCampus.CommandLine.Tests/FlexibleCommandLineParserTests.cs deleted file mode 100644 index e0c7a423..00000000 --- a/tests/DotNetCampus.CommandLine.Tests/FlexibleCommandLineParserTests.cs +++ /dev/null @@ -1,991 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using DotNetCampus.Cli.Compiler; -using DotNetCampus.Cli.Exceptions; -using Microsoft.VisualStudio.TestTools.UnitTesting; - -// ReSharper disable UnusedAutoPropertyAccessor.Global -// ReSharper disable InconsistentNaming - -namespace DotNetCampus.Cli.Tests; - -/// -/// 测试 Flexible 风格命令行参数是否正确被解析。 -/// -[TestClass] -public class FlexibleCommandLineParserTests -{ - private CommandLineParsingOptions Flexible { get; } = CommandLineParsingOptions.Flexible; - - #region 1. 参数前缀支持多种形式 - - [TestMethod("1.1. 支持双破折线(--) + 字符串类型参数")] - public void DoubleHyphen_StringType_ValueAssigned() - { - // Arrange - string[] args = ["--value", "test"]; - string? value = null; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => value = o.Value) - .Run(); - - // Assert - Assert.AreEqual("test", value); - } - - [TestMethod("1.2. 支持单破折线(-) + 字符串类型参数")] - public void SingleHyphen_StringType_ValueAssigned() - { - // Arrange - string[] args = ["-value", "test"]; - string? value = null; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => value = o.Value) - .Run(); - - // Assert - Assert.AreEqual("test", value); - } - - [TestMethod("1.3. 支持斜杠(/) + 字符串类型参数")] - public void Slash_StringType_ValueAssigned() - { - // Arrange - string[] args = ["/value", "test"]; - string? value = null; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => value = o.Value) - .Run(); - - // Assert - Assert.AreEqual("test", value); - } - - #endregion - - #region 2. 参数值分隔符兼容多种形式 - - [TestMethod("2.1. 支持空格作为分隔符")] - public void SpaceSeparator_ValueAssigned() - { - // Arrange - string[] args = ["--value", "test"]; - string? value = null; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => value = o.Value) - .Run(); - - // Assert - Assert.AreEqual("test", value); - } - - [TestMethod("2.2. 支持等号(=)作为分隔符")] - public void EqualSeparator_ValueAssigned() - { - // Arrange - string[] args = ["--value=test"]; - string? value = null; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => value = o.Value) - .Run(); - - // Assert - Assert.AreEqual("test", value); - } - - [TestMethod("2.3. 支持冒号(:)作为分隔符")] - public void ColonSeparator_ValueAssigned() - { - // Arrange - string[] args = ["--value:test"]; - string? value = null; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => value = o.Value) - .Run(); - - // Assert - Assert.AreEqual("test", value); - } - - [Ignore("只有 GNU 风格支持。Flexible 包容万象,但包容不下这种偏门功能。")] - [TestMethod("2.4. 短选项支持无分隔符直接跟参数(GNU风格)")] - public void ShortOption_NoSeparator_ValueAssigned() - { - // Arrange - string[] args = ["-vtest"]; - string? value = null; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => value = o.V) - .Run(); - - // Assert - Assert.AreEqual("test", value); - } - - [TestMethod("2.5. 短选项与分隔符混合使用")] - public void ShortOption_MixedSeparators_AllAssigned() - { - // Arrange - string[] args = ["-a", "value1", "-b=value2", "-c:value3"]; - string? valueA = null; - string? valueB = null; - string? valueC = null; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => - { - valueA = o.A; - valueB = o.B; - valueC = o.C; - }) - .Run(); - - // Assert - Assert.AreEqual("value1", valueA); - Assert.AreEqual("value2", valueB); - Assert.AreEqual("value3", valueC); - } - - #endregion - - #region 3. 参数命名风格兼容性 - - [TestMethod("3.1. 支持kebab-case命名风格")] - public void KebabCase_OptionName_ValueAssigned() - { - // Arrange - string[] args = ["--parameter-name", "test"]; - string? value = null; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => value = o.ParameterName) - .Run(); - - // Assert - Assert.AreEqual("test", value); - } - - [TestMethod("3.2. 支持PascalCase命名风格")] - public void PascalCase_OptionName_ValueAssigned() - { - // Arrange - string[] args = ["-ParameterName", "test"]; - string? value = null; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => value = o.ParameterName) - .Run(); - - // Assert - Assert.AreEqual("test", value); - } - - [TestMethod("3.3. 支持camelCase命名风格")] - public void CamelCase_OptionName_ValueAssigned() - { - // Arrange - string[] args = ["--parameterName", "test"]; - string? value = null; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => value = o.ParameterName) - .Run(); - - // Assert - Assert.AreEqual("test", value); - } - - #endregion - - #region 4. 大小写不敏感测试 - - [TestMethod("4.1. 选项名大小写不敏感")] - public void CaseInsensitive_OptionName_ValueAssigned() - { - // Arrange - string[] args = ["--VALUE", "test"]; - string? value = null; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => value = o.Value) - .Run(); - - // Assert - Assert.AreEqual("test", value); - } - - [TestMethod("4.2. 短选项大小写不敏感")] - public void CaseInsensitive_ShortOption_ValueAssigned() - { - // Arrange - string[] args = ["-V", "test"]; - string? value = null; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => value = o.V) - .Run(); - - // Assert - Assert.AreEqual("test", value); - } - - #endregion - - #region 5. 短选项和长选项测试 - - [TestMethod("5.1. 短选项与长选项对应相同属性")] - public void ShortAndLongOption_SameProperty_ValueAssigned() - { - // Arrange - string[] args1 = ["--output", "file.txt"]; - string[] args2 = ["-o", "file.txt"]; - string? value1 = null; - string? value2 = null; - - // Act - CommandLine.Parse(args1, Flexible) - .AddHandler(o => value1 = o.Output) - .Run(); - - CommandLine.Parse(args2, Flexible) - .AddHandler(o => value2 = o.Output) - .Run(); - - // Assert - Assert.AreEqual("file.txt", value1); - Assert.AreEqual("file.txt", value2); - } - - [Ignore("自动形式经过讨论,不支持短选项组合;如果需要,请改用 GNU 风格。")] - [TestMethod("5.2. 支持有限的短选项组合")] - public void ShortOptionCombination_AllAssigned() - { - // Arrange - string[] args = ["-abc"]; - bool? flagA = null; - bool? flagB = null; - bool? flagC = null; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => - { - flagA = o.A; - flagB = o.B; - flagC = o.C; - }) - .Run(); - - // Assert - Assert.IsTrue(flagA); - Assert.IsTrue(flagB); - Assert.IsTrue(flagC); - } - - #endregion - - #region 6. 布尔开关参数测试 - - [TestMethod("6.1. 不带值的布尔参数默认为true")] - public void BooleanFlag_NoValue_DefaultTrue() - { - // Arrange - string[] args = ["--flag"]; - bool? flag = null; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => flag = o.Flag) - .Run(); - - // Assert - Assert.IsTrue(flag); - } - - [TestMethod("6.2. 布尔参数支持true/false值")] - public void BooleanFlag_ExplicitValue_Assigned() - { - // Arrange - string[] args = ["--flag=false"]; - bool? flag = null; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => flag = o.Flag) - .Run(); - - // Assert - Assert.IsFalse(flag); - } - - [TestMethod("6.3. 布尔参数支持yes/no值")] - public void BooleanFlag_YesNoValue_Assigned() - { - // Arrange - string[] args = ["--flag=yes"]; - bool? flag = null; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => flag = o.Flag) - .Run(); - - // Assert - Assert.IsTrue(flag); - } - - [TestMethod("6.4. 布尔参数支持on/off值")] - public void BooleanFlag_OnOffValue_Assigned() - { - // Arrange - string[] args = ["--flag=off"]; - bool? flag = null; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => flag = o.Flag) - .Run(); - - // Assert - Assert.IsFalse(flag); - } - - [Ignore("否定形式(no-prefix)计划以后再实现。")] - [TestMethod("6.5. 支持否定形式(no-prefix)的布尔参数")] - public void BooleanFlag_NegatePrefix_Assigned() - { - // Arrange - string[] args = ["--no-feature"]; - bool? feature = null; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => feature = o.Feature) - .Run(); - - // Assert - Assert.IsFalse(feature); - } - - #endregion - - #region 7. 位置参数测试 - - [TestMethod("7.1. 单个位置参数解析正确")] - public void SinglePositionalParameter_ValueAssigned() - { - // Arrange - string[] args = ["positional-value"]; - string? value = null; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => value = o.Value) - .Run(); - - // Assert - Assert.AreEqual("positional-value", value); - } - - [TestMethod("7.2. 多个位置参数解析正确")] - public void MultiplePositionalParameters_AllAssigned() - { - // Arrange - string[] args = ["value1", "value2", "value3"]; - string[]? values = null; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => values = o.Values) - .Run(); - - // Assert - Assert.IsNotNull(values); - Assert.AreEqual(3, values.Length); - CollectionAssert.AreEqual(new[] { "value1", "value2", "value3" }, values); - } - - [TestMethod("7.3. 双破折号(--)后的内容作为位置参数")] - public void DoubleHyphen_TreatsFollowingAsValues() - { - // Arrange - string[] args = ["--option", "value", "--", "--not-an-option", "-x"]; - string? option = null; - string[]? values = null; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => - { - option = o.Option; - values = o.Values; - }) - .Run(); - - // Assert - Assert.AreEqual("value", option); - Assert.IsNotNull(values); - Assert.AreEqual(2, values.Length); - Assert.AreEqual("--not-an-option", values[0]); - Assert.AreEqual("-x", values[1]); - } - - [TestMethod("7.4. 选项与位置参数混合使用")] - public void MixedOptionsAndValues_AllAssigned() - { - // Arrange - string[] args = ["value1", "--option=test", "value2"]; - string? option = null; - string? value1 = null; - string? value2 = null; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => - { - option = o.Option; - value1 = o.Value1; - value2 = o.Value2; - }) - .Run(); - - // Assert - Assert.AreEqual("test", option); - Assert.AreEqual("value1", value1); - Assert.AreEqual("value2", value2); - } - - #endregion - - #region 8. 混合风格测试 - - [TestMethod("8.1. 混合使用多种风格的选项前缀")] - public void MixedPrefixStyles_AllAssigned() - { - // Arrange - string[] args = ["--option1", "value1", "-option2", "value2", "/option3", "value3"]; - string? value1 = null; - string? value2 = null; - string? value3 = null; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => - { - value1 = o.Option1; - value2 = o.Option2; - value3 = o.Option3; - }) - .Run(); - - // Assert - Assert.AreEqual("value1", value1); - Assert.AreEqual("value2", value2); - Assert.AreEqual("value3", value3); - } - - [TestMethod("8.2. 混合使用多种分隔符")] - public void MixedSeparatorStyles_AllAssigned() - { - // Arrange - string[] args = ["--option1", "value1", "--option2=value2", "--option3:value3"]; - string? value1 = null; - string? value2 = null; - string? value3 = null; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => - { - value1 = o.Option1; - value2 = o.Option2; - value3 = o.Option3; - }) - .Run(); - - // Assert - Assert.AreEqual("value1", value1); - Assert.AreEqual("value2", value2); - Assert.AreEqual("value3", value3); - } - - [TestMethod("8.3. 混合使用多种命名风格")] - public void MixedNamingStyles_AllAssigned() - { - // Arrange - string[] args = ["--kebab-case", "value1", "-PascalCase", "value2", "--camelCase", "value3"]; - string? kebabValue = null; - string? pascalValue = null; - string? camelValue = null; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => - { - kebabValue = o.KebabCase; - pascalValue = o.PascalCase; - camelValue = o.CamelCase; - }) - .Run(); - - // Assert - Assert.AreEqual("value1", kebabValue); - Assert.AreEqual("value2", pascalValue); - Assert.AreEqual("value3", camelValue); - } - - #endregion - - #region 9. 边界情况和错误处理 - - [TestMethod("9.1. 未知选项,抛出异常")] - public void UnknownOption_ThrowsException() - { - // Arrange - string[] args = ["--non-existent", "value"]; - - // Act & Assert - Assert.ThrowsExactly(() => - { - CommandLine.Parse(args, Flexible) - .AddHandler(_ => { }) - .Run(); - }); - } - - [TestMethod("9.2. 选项名称拼写错误时,抛出异常并提示近似选项")] - public void MisspelledOption_ThrowsExceptionWithHint() - { - // Arrange - string[] args = ["--valu", "test"]; // 应该是 --value - - // Act - var exception = Assert.ThrowsExactly(() => - { - CommandLine.Parse(args, Flexible) - .AddHandler(_ => { }) - .Run(); - }); - - // Assert - StringAssert.Contains(exception.Message, "value"); // 确保消息中包含近似的正确选项 - } - - [TestMethod("9.3. 类型不匹配时,抛出异常")] - public void TypeMismatch_ThrowsException() - { - // Arrange - string[] args = ["--number=not-a-number"]; - - // Act & Assert - Assert.ThrowsExactly(() => - { - CommandLine.Parse(args, Flexible) - .AddHandler(_ => { }) - .Run(); - }); - } - - [TestMethod("9.4. 缺失必需参数时,抛出异常")] - public void MissingRequiredOption_ThrowsException() - { - // Arrange - string[] args = []; - - // Act & Assert - Assert.ThrowsExactly(() => - { - CommandLine.Parse(args, Flexible) - .AddHandler(_ => { }) - .Run(); - }); - } - - #endregion - - #region 10. 异步处理测试 - - [TestMethod("10.1. 异步处理方法,正确执行")] - public async Task AsyncHandler_ExecutesCorrectly() - { - // Arrange - string[] args = ["--value=async-test"]; - string? value = null; - - // Act - await CommandLine.Parse(args, Flexible) - .AddHandler(async o => - { - await Task.Delay(10); // 模拟异步操作 - value = o.Value; - }) - .RunAsync(); - - // Assert - Assert.AreEqual("async-test", value); - } - - #endregion - - #region 11. 列表参数测试 - - [TestMethod("11.1. 支持空列表")] - public void EmptyList_ParsedCorrectly() - { - // Arrange - string[] args = ["--files"]; - string[]? files = null; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => - { - files = o.Files; - return 0; - }) - .Run(); - - // Assert - Assert.IsNotNull(files); - Assert.AreEqual(0, files.Length); - } - - [TestMethod("11.3. 支持分号分隔的列表")] - public void SemicolonSeparatedList_ParsedCorrectly() - { - // Arrange - string[] args = ["--names:John;Jane;Doe"]; - IEnumerable? names = null; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => - { - names = o.Names; - return 0; - }) - .Run(); - - // Assert - Assert.IsNotNull(names); - Assert.AreEqual(3, names.Count()); - CollectionAssert.AreEqual(new[] { "John", "Jane", "Doe" }, names.ToArray()); - } - - [TestMethod("11.4. 支持混合分隔符的列表")] - public void MixedSeparatorList_ParsedCorrectly() - { - // Arrange - string[] args = ["--files:file1.txt,file2.txt;file3.txt"]; - string[]? files = null; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => - { - files = o.Files; - return 0; - }) - .Run(); - - // Assert - Assert.IsNotNull(files); - Assert.AreEqual(3, files.Length); - CollectionAssert.AreEqual(new[] { "file1.txt", "file2.txt", "file3.txt" }, files); - } - - [TestMethod("11.5. 支持带引号的列表参数")] - public void QuotedListElements_ParsedCorrectly() - { - // Arrange - string[] args = ["--files:\"file with spaces.txt\",\"another file.txt\""]; - string[]? files = null; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => - { - files = o.Files; - return 0; - }) - .Run(); - - // Assert - Assert.IsNotNull(files); - Assert.AreEqual(2, files.Length); - CollectionAssert.AreEqual(new[] { "file with spaces.txt", "another file.txt" }, files); - } - - [TestMethod("11.6. 带引号的列表参数,多次指定")] - public void QuotedListElements_MultipleOptions() - { - // Arrange - string[] args = ["--files", "\"file with spaces.txt\"", "--files", "normal.txt", "--files", "\"another file.txt\""]; - string[]? files = null; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => - { - files = o.Files; - return 0; - }) - .Run(); - - // Assert - Assert.IsNotNull(files); - Assert.AreEqual(3, files.Length); - CollectionAssert.AreEqual(new[] { "file with spaces.txt", "normal.txt", "another file.txt" }, files); - } - - [TestMethod("11.7. 带引号的列表参数,通过分隔符")] - public void QuotedListElements_WithSeparators() - { - // Arrange - string[] args = ["--names:\"John Doe\";\"Jane Smith\";Anonymous"]; - IEnumerable? names = null; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => - { - names = o.Names; - return 0; - }) - .Run(); - - // Assert - Assert.IsNotNull(names); - Assert.AreEqual(3, names.Count()); - CollectionAssert.AreEqual(new[] { "John Doe", "Jane Smith", "Anonymous" }, names.ToArray()); - } - - [TestMethod("11.9. 单选项后接带引号且引号内有逗号或分号的多个值")] - public void SingleOption_QuotedMultipleValuesWithColonOrSemicolon() - { - // Arrange - string[] args = ["--files", "\"tag1,with,colon\",\"tag2,with,colon\"", "--tags", "\"tag1;with;semicolon\";\"tag2;with;semicolon\""]; - string[]? files = null; - IReadOnlyList? tags = null; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => - { - files = o.Files; - tags = o.Tags; - return 0; - }) - .Run(); - - // Assert - Assert.IsNotNull(files); - Assert.IsNotNull(tags); - Assert.AreEqual(2, files.Length); - Assert.AreEqual(2, tags.Count); - CollectionAssert.AreEqual(new[] { "tag1,with,colon", "tag2,with,colon" }, files.ToArray()); - CollectionAssert.AreEqual(new[] { "tag1;with;semicolon", "tag2;with;semicolon" }, tags.ToArray()); - } - - [TestMethod("11.10. 单选项后接带引号且引号内有逗号或分号的多个值,其中部分引号和分隔符含空字符串")] - public void SingleOption_QuotedMultipleValuesWithColonOrSemicolonAndEmpty() - { - // Arrange - string[] args = ["--files", "\"tag1,with,colon\",,\"tag2,with,colon\"", "--tags", "\"tag1;with;semicolon\";;;\"\";\"tag2;with;semicolon\""]; - string[]? files = null; - IReadOnlyList? tags = null; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => - { - files = o.Files; - tags = o.Tags; - return 0; - }) - .Run(); - - // Assert - Assert.IsNotNull(files); - Assert.IsNotNull(tags); - Assert.AreEqual(3, files.Length); - Assert.AreEqual(5, tags.Count); - CollectionAssert.AreEqual(new[] { "tag1,with,colon", "", "tag2,with,colon" }, files.ToArray()); - CollectionAssert.AreEqual(new[] { "tag1;with;semicolon", "", "", "", "tag2;with;semicolon" }, tags.ToArray()); - } - - #endregion -} - -#region 测试用数据模型 - -internal record Flexible01_StringOptions -{ - [Option] - public required string Value { get; init; } -} - -internal record Flexible02_ShortOption -{ - [Option('v')] - public required string V { get; init; } -} - -internal record Flexible03_MultipleShortOptions -{ - [Option('a')] - public required string A { get; init; } - - [Option('b')] - public required string B { get; init; } - - [Option('c')] - public required string C { get; init; } -} - -internal record Flexible04_KebabCaseOptions -{ - [Option("parameter-name")] - public required string ParameterName { get; init; } -} - -internal record Flexible05_ShortLongOptions -{ - [Option('o', "output")] - public required string Output { get; init; } -} - -internal record Flexible06_BooleanShortOptions -{ - [Option('a')] - public bool A { get; init; } - - [Option('b')] - public bool B { get; init; } - - [Option('c')] - public bool C { get; init; } -} - -internal record Flexible07_BooleanOptions -{ - [Option] - public bool Flag { get; init; } -} - -internal record Flexible08_NegatedBooleanOptions -{ - [Option("feature", Aliases = ["no-feature"])] - public bool Feature { get; init; } = true; -} - -internal record Flexible09_PositionalOptions -{ - [Value] - public required string Value { get; init; } -} - -internal record Flexible10_MultiplePositionalOptions -{ - [Value(Length = int.MaxValue)] - public required string[] Values { get; init; } -} - -internal record Flexible11_TerminatorOptions -{ - [Option] - public required string Option { get; init; } - - [Value(Length = int.MaxValue)] - public string[] Values { get; init; } = []; -} - -internal record Flexible12_MixedOptions -{ - [Value(0)] - public required string Value1 { get; init; } - - [Option] - public required string Option { get; init; } - - [Value(1)] - public required string Value2 { get; init; } -} - -internal record Flexible13_MixedPrefixOptions -{ - [Option] - public required string Option1 { get; init; } - - [Option] - public required string Option2 { get; init; } - - [Option] - public required string Option3 { get; init; } -} - -internal record Flexible14_MixedNamingOptions -{ - [Option("kebab-case")] - public required string KebabCase { get; init; } - - [Option("PascalCase")] - public required string PascalCase { get; init; } - - [Option("camelCase")] - public required string CamelCase { get; init; } -} - -internal record Flexible15_TypedOptions -{ - [Option] - public int Number { get; init; } -} - -internal record Flexible16_ListOptions -{ - [Option] - public string[] Files { get; init; } = []; - - [Option] - public IReadOnlyList Tags { get; init; } = []; - - [Option] - public IEnumerable Names { get; init; } = []; -} - -internal record Flexible16_RequiredOptions -{ - [Option] - public required string RequiredValue { get; init; } -} - -#endregion - -#region 代码清理 - -// ReSharper restore UnusedAutoPropertyAccessor.Global -// ReSharper restore InconsistentNaming - -#endregion diff --git a/tests/DotNetCampus.CommandLine.Tests/GnuCommandLineParserTests.cs b/tests/DotNetCampus.CommandLine.Tests/GnuCommandLineParserTests.cs deleted file mode 100644 index b80957c5..00000000 --- a/tests/DotNetCampus.CommandLine.Tests/GnuCommandLineParserTests.cs +++ /dev/null @@ -1,1114 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using DotNetCampus.Cli.Compiler; -using DotNetCampus.Cli.Exceptions; -using Microsoft.VisualStudio.TestTools.UnitTesting; - -// ReSharper disable UnusedAutoPropertyAccessor.Global -// ReSharper disable InconsistentNaming - -namespace DotNetCampus.Cli.Tests; - -/// -/// 测试GNU风格命令行参数是否正确被解析到了。 -/// -[TestClass] -public class GnuCommandLineParserTests -{ - private CommandLineParsingOptions GNU { get; } = CommandLineParsingOptions.Gnu; - - #region 1. 选项识别与解析 - - [TestMethod("1.1. 长选项,字符串类型,可正常赋值。")] - public void LongOption_StringType_ValueAssigned() - { - // Arrange - string[] args = ["--value", "test"]; - string? value = null; - - // Act - CommandLine.Parse(args, GNU) - .AddHandler(o => value = o.Value) - .Run(); - - // Assert - Assert.AreEqual("test", value); - } - - [TestMethod("1.2. 短选项,字符串类型,可正常赋值。")] - public void ShortOption_StringType_ValueAssigned() - { - // Arrange - string[] args = ["-v", "test"]; - string? value = null; - - // Act - CommandLine.Parse(args, GNU) - .AddHandler(o => value = o.Value) - .Run(); - - // Assert - Assert.AreEqual("test", value); - } - - [TestMethod("1.3. 长选项带等号,字符串类型,可正常赋值。")] - public void LongOptionWithEquals_StringType_ValueAssigned() - { - // Arrange - string[] args = ["--value=test"]; - string? value = null; - - // Act - CommandLine.Parse(args, GNU) - .AddHandler(o => value = o.Value) - .Run(); - - // Assert - Assert.AreEqual("test", value); - } - - [TestMethod("1.4.1 短选项无空格,字符串类型,可正常赋值。")] - public void ShortOptionNoSpace_StringType_ValueAssigned() - { - // Arrange - string[] args = ["-vtest.txt"]; - string? value = null; - - // Act - CommandLine.Parse(args, GNU) - .AddHandler(o => value = o.Value) - .Run(); - - // Assert - Assert.AreEqual("test.txt", value); - } - - [Ignore("目前先 Parse 后 As 的两个步骤,会使得第 1 步的 Parse 无法区分这种短选项无空格的值。1.4.1 因为带了非字母的符号所以还能勉强区分。除非我们未来在 CommandLine 对象里对同一个短选项存两种值才可能。")] - [TestMethod("1.4.2 短选项无空格,但难以与缩写区分,字符串类型,可正常赋值。")] - public void ShortOptionNoSpace2_StringType_ValueAssigned() - { - // Arrange - string[] args = ["-vtest"]; - string? value = null; - - // Act - CommandLine.Parse(args, GNU) - .AddHandler(o => value = o.Value) - .Run(); - - // Assert - Assert.AreEqual("test", value); - } - - [TestMethod("1.5. 多个选项混合使用,全部正确解析。")] - public void MixedOptions_MultipleParsed_AllAssigned() - { - // Arrange - string[] args = - [ - "-n", "42", "-u", "11", "--text", "hello", "--nullable-text", "hello null", "--nullable-list", "a", "--nullable-nullable-list", "b", "-b" - ]; - int? number = null; - int? nullableNumber = null; - string? text = null; - string? nullableText = null; - IReadOnlyList? nullableList = null; - IReadOnlyList? nullableNullableList = null; - bool? flag = null; - - // Act - CommandLine.Parse(args, GNU) - .AddHandler(o => - { - nullableNumber = o.NullableNumber; - number = o.Number; - text = o.Text; - nullableText = o.NullableText; - nullableList = o.NullableList; - nullableNullableList = o.NullableNullableList; - flag = o.Flag; - }) - .Run(); - - // Assert - Assert.AreEqual(11, nullableNumber); - Assert.AreEqual(42, number); - Assert.AreEqual("hello", text); - Assert.AreEqual("hello null", nullableText); - CollectionAssert.AreEqual(new[] { "a" }, nullableList?.ToList()); - CollectionAssert.AreEqual(new[] { "b" }, nullableNullableList?.ToList()); - Assert.IsTrue(flag); - } - - #endregion - - #region 2. 类型转换 - - [TestMethod("2.1. 整数类型,赋值成功。")] - public void IntegerOption_ValueAssigned() - { - // Arrange - string[] args = ["--number", "42"]; - int? number = null; - - // Act - CommandLine.Parse(args, GNU) - .AddHandler(o => number = o.Number) - .Run(); - - // Assert - Assert.AreEqual(42, number); - } - - [TestMethod("2.2. 布尔类型,赋值成功。")] - public void BooleanOption_ValueAssigned() - { - // Arrange - string[] args = ["--flag"]; - bool? flag = null; - - // Act - CommandLine.Parse(args, GNU) - .AddHandler(o => flag = o.Flag) - .Run(); - - // Assert - Assert.IsTrue(flag); - } - - [TestMethod("2.3. 枚举类型,赋值成功。")] - public void EnumOption_ValueAssigned() - { - // Arrange - string[] args = ["--log-level", "Warning", "--nullable-log-level", "Error"]; - LogLevel? logLevel = null; - LogLevel? nullableLogLevel = null; - - // Act - CommandLine.Parse(args, GNU) - .AddHandler(o => - { - logLevel = o.LogLevel; - nullableLogLevel = o.NullableLogLevel; - }) - .Run(); - - // Assert - Assert.AreEqual(LogLevel.Warning, logLevel); - Assert.AreEqual(LogLevel.Error, nullableLogLevel); - } - - [TestMethod("2.4. 字符串数组,赋值成功。")] - public void StringArrayOption_ValueAssigned() - { - // Arrange - string[] args = ["--files", "file1.txt", "--files", "file2.txt", "--files", "file3.txt"]; - string[]? files = null; - - // Act - CommandLine.Parse(args, GNU) - .AddHandler(o => files = o.Files) - .Run(); - - // Assert - Assert.IsNotNull(files); - Assert.AreEqual(3, files.Length); - CollectionAssert.AreEqual(new[] { "file1.txt", "file2.txt", "file3.txt" }, files); - } - - [TestMethod("2.5. 列表类型,赋值成功。")] - public void ListOption_ValueAssigned() - { - // Arrange - string[] args = ["--tags", "tag1", "--tags", "tag2", "--tags", "tag3"]; - List? tags = null; - - // Act - CommandLine.Parse(args, GNU) - .AddHandler(o => tags = o.Tags.ToList()) - .Run(); - - // Assert - Assert.IsNotNull(tags); - Assert.AreEqual(3, tags.Count); - CollectionAssert.AreEqual(new[] { "tag1", "tag2", "tag3" }, tags); - } - - [TestMethod("2.6. 使用等号分隔的列表选项,通过分号划分")] - public void SemicolonSeparatedList_ValueAssigned() - { - // Arrange - string[] args = ["--files=file1.txt;file2.txt;file3.txt"]; - string[]? files = null; - - // Act - CommandLine.Parse(args, GNU) - .AddHandler(o => files = o.Files) - .Run(); - - // Assert - Assert.IsNotNull(files); - Assert.AreEqual(3, files.Length); - CollectionAssert.AreEqual(new[] { "file1.txt", "file2.txt", "file3.txt" }, files); - } - - [TestMethod("2.7. 使用等号分隔的列表选项,通过逗号划分")] - public void CommaSeparatedList_ValueAssigned() - { - // Arrange - string[] args = ["--files=file1.txt,file2.txt,file3.txt"]; - string[]? files = null; - - // Act - CommandLine.Parse(args, GNU) - .AddHandler(o => files = o.Files) - .Run(); - - // Assert - Assert.IsNotNull(files); - Assert.AreEqual(3, files.Length); - CollectionAssert.AreEqual(new[] { "file1.txt", "file2.txt", "file3.txt" }, files); - } - - [TestMethod("2.8. 带引号的列表参数,赋值成功。")] - public void QuotedArrayOption_ValueAssigned() - { - // Arrange - string[] args = ["--files", "\"file with spaces.txt\"", "--files", "normal.txt", "--files", "\"another file.txt\""]; - string[]? files = null; // Act - CommandLine.Parse(args, GNU) - .AddHandler(o => - { - files = o.Files; - return 0; - }) - .Run(); - - // Assert - Assert.IsNotNull(files); - Assert.AreEqual(3, files.Length); - CollectionAssert.AreEqual(new[] { "file with spaces.txt", "normal.txt", "another file.txt" }, files); - } - - [TestMethod("2.9. 等号方式带引号的列表参数,赋值成功。")] - public void QuotedArrayWithEquals_ValueAssigned() - { - // Arrange - string[] args = ["--paths=\"path with spaces\",regular-path,\"another path\""]; - string[]? paths = null; // Act - CommandLine.Parse(args, GNU) - .AddHandler(o => - { - paths = o.Paths; - return 0; - }) - .Run(); - - // Assert - Assert.IsNotNull(paths); - Assert.AreEqual(3, paths.Length); - CollectionAssert.AreEqual(new[] { "path with spaces", "regular-path", "another path" }, paths); - } - - #endregion - - #region 3. 边界情况处理 - - [TestMethod("3.1. 缺失必需选项,抛出异常。")] - public void MissingRequiredOption_ThrowsException() - { - // Arrange - string[] args = []; - - // Act & Assert - Assert.ThrowsExactly(() => - { - CommandLine.Parse(args, GNU) - .AddHandler(_ => { }) - .Run(); - }); - } - - [TestMethod("3.2. 无效格式选项,抛出异常。")] - public void InvalidOption_ThrowsException() - { - // Arrange - string[] args = ["---invalid"]; - - // Act & Assert - Assert.ThrowsExactly(() => - { - CommandLine.Parse(args, GNU) - .AddHandler(_ => { }) - .Run(); - }); - } - - [TestMethod("3.3. 类型不匹配,抛出异常。")] - public void TypeMismatch_ThrowsException() - { - // Arrange - string[] args = ["--number", "not-a-number"]; - - // Act & Assert - Assert.ThrowsExactly(() => - { - CommandLine.Parse(args, GNU) - .AddHandler(_ => { }) - .Run(); - }); - } - - [TestMethod("3.4. 大小写敏感,识别正确。")] - public void CaseSensitive_CorrectOptionParsed() - { - // Arrange - string[] args = ["--case-sensitive", "lower", "--CASE-SENSITIVE", "upper"]; - string? lowerValue = null; - string? upperValue = null; - - // Act - CommandLine.Parse(args, GNU with { CaseSensitive = true }) - .AddHandler(o => - { - lowerValue = o.CaseSensitive; - upperValue = o.CASESENSITIVE; - }) - .Run(); - - // Assert - Assert.AreEqual("lower", lowerValue); - Assert.AreEqual("upper", upperValue); - } - - [TestMethod("3.5. 大小写不敏感,识别正确。")] - public void CaseInsensitive_CorrectOptionParsed() - { - // Arrange - string[] args = ["--Ignore-Case", "value"]; - string? value = null; - - // Act - CommandLine.Parse(args, GNU with { CaseSensitive = false }) - .AddHandler(o => value = o.IgnoreCase) - .Run(); - - // Assert - Assert.AreEqual("value", value); - } - - [TestMethod("3.6. 单个选项设置大小写敏感,全局默认不敏感,识别正确。")] - public void SingleOptionCaseSensitive_GlobalInsensitive_CorrectlyParsed() - { - // Arrange - string[] args = ["--Case-Option", "value1", "--case-option", "value2"]; - string? sensitiveValue = null; - string? insensitiveValue = null; - - // Act - CommandLine.Parse(args, GNU) // 默认大小写不敏感 - .AddHandler(o => - { - sensitiveValue = o.CaseSensitiveOption; - insensitiveValue = o.CaseInsensitiveOption; - }) - .Run(); - - // Assert - Assert.AreEqual("value1", sensitiveValue); // 大小写敏感,匹配第一个 Case-Option - Assert.AreEqual("value2", insensitiveValue); // 大小写不敏感,匹配第二个 case-option - } - - [TestMethod("3.7. 单个选项设置大小写不敏感,全局设置为敏感,识别正确。")] - public void OptionCaseInsensitive_OverridesGlobalSensitive() - { - // Arrange - string[] args = ["--option-one", "value1", "--option-TWO", "value2"]; - string? option1Value = null; - string? option2Value = null; - - // Act - CommandLine.Parse(args, GNU with { CaseSensitive = true }) - .AddHandler(o => - { - option1Value = o.OptionOne; - option2Value = o.OptionTwo; - }) - .Run(); - - // Assert - Assert.IsNull(option1Value); // 全局大小写敏感,--option-one 不匹配 --Option-One - Assert.AreEqual("value2", option2Value); // 选项明确指定为大小写不敏感,所以匹配成功 - } - - [TestMethod("3.8. 全局大小写敏感时,未指定大小写设置的选项不匹配。")] - public void GlobalCaseSensitive_DefaultOption_NotMatched() - { - // Arrange - string[] args = ["--global-sensitive", "value1"]; - string? globalSensitiveValue = null; - - // Act - CommandLine.Parse(args, GNU with { CaseSensitive = true }) - .AddHandler(o => - { - globalSensitiveValue = o.GlobalSensitive; - }) - .Run(); - - // Assert - Assert.IsNull(globalSensitiveValue); // 全局大小写敏感,--global-sensitive 不匹配 --GLOBAL-SENSITIVE - } - - [TestMethod("3.9. 选项设置大小写敏感时,大小写不匹配无效。")] - public void OptionCaseSensitive_CaseMismatch_NotMatched() - { - // Arrange - string[] args = ["--local-sensitive", "value"]; - string? localSensitiveValue = null; - - // Act - CommandLine.Parse(args, GNU with { CaseSensitive = true }) - .AddHandler(o => - { - localSensitiveValue = o.LocalSensitive; - }) - .Run(); - - // Assert - Assert.IsNull(localSensitiveValue); // 局部大小写敏感,--local-sensitive 不匹配 --local-SENSITIVE - } - - [TestMethod("3.10. 选项设置大小写不敏感时,无论全局设置,都能匹配。")] - public void OptionCaseInsensitive_GlobalSensitive_StillMatched() - { - // Arrange - string[] args = ["--LOCAL-insensitive", "value"]; - string? localInsensitiveValue = null; - - // Act - CommandLine.Parse(args, GNU with { CaseSensitive = true }) - .AddHandler(o => - { - localInsensitiveValue = o.LocalInsensitive; - }) - .Run(); - - // Assert - Assert.AreEqual("value", localInsensitiveValue); // 明确指定大小写不敏感,匹配成功 - } - - [TestMethod("3.11. 选项值大小写测试,枚举值不敏感,识别正确。")] - public void EnumValueCaseInsensitive_CorrectlyParsed() - { - // Arrange - string[] args = ["--log-level", "warning", "--second-level", "ERROR"]; - LogLevel? logLevel = null; - LogLevel? secondLevel = null; - - // Act - CommandLine.Parse(args, GNU) - .AddHandler(o => - { - logLevel = o.LogLevel; - secondLevel = o.SecondLevel; - }) - .Run(); - - // Assert - Assert.AreEqual(LogLevel.Warning, logLevel); // 枚举值大小写不敏感 - Assert.AreEqual(LogLevel.Error, secondLevel); // 枚举值大小写不敏感 - } - - #endregion - - #region 4. 特殊特性 - - [TestMethod("4.1. 选项别名,识别正确。")] - public void OptionAliases_CorrectOptionParsed() - { - // Arrange - string[] args = ["--alt", "value"]; - string? value = null; - - // Act - CommandLine.Parse(args, GNU) - .AddHandler(o => value = o.OptionWithAlias) - .Run(); - - // Assert - Assert.AreEqual("value", value); - } - - [TestMethod("4.2. 组合短选项,识别正确。")] - public void CombinedShortOptions_AllParsedCorrectly() - { - // Arrange - string[] args = ["-abc"]; - bool? optionA = null; - bool? optionB = null; - bool? optionC = null; - - // Act - CommandLine.Parse(args, GNU) - .AddHandler(o => - { - optionA = o.OptionA; - optionB = o.OptionB; - optionC = o.OptionC; - }) - .Run(); - - // Assert - Assert.IsTrue(optionA); - Assert.IsTrue(optionB); - Assert.IsTrue(optionC); - } - - [TestMethod("4.3. 终止选项解析符号,识别正确。")] - public void OptionTerminator_FollowingArgsAreValues() - { - // Arrange - string[] args = ["--option", "value", "--", "--not-an-option", "-x"]; - string? option = null; - string[]? values = null; - - // Act - CommandLine.Parse(args, GNU) - .AddHandler(o => - { - option = o.Option; - values = o.Values; - }) - .Run(); - - // Assert - Assert.AreEqual("value", option); - Assert.IsNotNull(values); - Assert.AreEqual(2, values.Length); - Assert.AreEqual("--not-an-option", values[0]); - Assert.AreEqual("-x", values[1]); - } - - #endregion - - #region 5. 位置参数处理 - - [TestMethod("5.1. 单个位置参数,赋值成功。")] - public void SinglePositionalValue_ValueAssigned() - { - // Arrange - string[] args = ["positional-value"]; - string? value = null; - - // Act - CommandLine.Parse(args, GNU) - .AddHandler(o => value = o.Value) - .Run(); - - // Assert - Assert.AreEqual("positional-value", value); - } - - [TestMethod("5.2. 多个位置参数,赋值成功。")] - public void MultiplePositionalValues_AllAssigned() - { - // Arrange - string[] args = ["value1", "value2", "value3"]; - string[]? values = null; - - // Act - CommandLine.Parse(args, GNU) - .AddHandler(o => values = o.Values) - .Run(); - - // Assert - Assert.IsNotNull(values); - Assert.AreEqual(3, values.Length); - CollectionAssert.AreEqual(new[] { "value1", "value2", "value3" }, values); - } - - [TestMethod("5.3. 位置参数与选项混合,识别正确。")] - public void MixedPositionalAndOptions_AllParsedCorrectly() - { - // Arrange - string[] args = ["value1", "--option", "opt-val", "value2"]; - string? option = null; - string? value1 = null; - string? value2 = null; - - // Act - CommandLine.Parse(args, GNU) - .AddHandler(o => - { - option = o.Option; - value1 = o.Value1; - value2 = o.Value2; - }) - .Run(); - - // Assert - Assert.AreEqual("opt-val", option); - Assert.AreEqual("value1", value1); - Assert.AreEqual("value2", value2); - } - - [TestMethod("5.4. 指定索引位置参数,识别正确。")] - public void IndexedPositionalValues_CorrectAssignment() - { - // Arrange - string[] args = ["first", "second", "third"]; - string? first = null; - string? third = null; - - // Act - CommandLine.Parse(args, GNU) - .AddHandler(o => - { - first = o.First; - third = o.Third; - }) - .Run(); - - // Assert - Assert.AreEqual("first", first); - Assert.AreEqual("third", third); - } - - #endregion - - #region 6. Required 和 Nullable 组合测试 - - [TestMethod("6.1. Non-required, Non-nullable, 无CLI参数,使用默认值。")] - public void NonRequiredNonNullable_NoCli_UsesDefault() - { - // Arrange - string[] args = []; - string? value = null; - - // Act - CommandLine.Parse(args, GNU) - .AddHandler(o => value = o.Value) - .Run(); - - // Assert - Assert.AreEqual(null, value); // 使用初始化时的默认值 - } - - [TestMethod("6.2. Required, Non-nullable, 无CLI参数,抛出异常。")] - public void RequiredNonNullable_NoCli_ThrowsException() - { - // Arrange - string[] args = []; - - // Act & Assert - Assert.ThrowsExactly(() => - { - CommandLine.Parse(args, GNU) - .AddHandler(_ => { }) - .Run(); - }); - } - - [TestMethod("6.3. Non-required, Nullable, 无CLI参数,赋默认值(null)。")] - public void NonRequiredNullable_NoCli_DefaultNull() - { - // Arrange - string[] args = []; - string? value = "not-null"; - - // Act - CommandLine.Parse(args, GNU) - .AddHandler(o => value = o.Value) - .Run(); - - // Assert - Assert.IsNull(value); - } - - [TestMethod("6.4. Required, Nullable, 无CLI参数,抛出异常。")] - public void RequiredNullable_NoCli_ThrowsException() - { - // Arrange - string[] args = []; - - // Act & Assert - Assert.ThrowsExactly(() => - { - CommandLine.Parse(args, GNU) - .AddHandler(_ => { }) - .Run(); - }); - } - - [TestMethod("6.5. 各种组合都提供CLI参数,全部赋值成功。")] - public void AllCombinations_WithCli_AllAssigned() - { - // Arrange - string[] args = - [ - "--req-non-null", "value1", "--non-req-null", "value2", - "--req-null", "value3", "--non-req-non-null", "value4" - ]; - string? reqNonNull = null; - string? nonReqNull = null; - string? reqNull = null; - string? nonReqNonNull = null; - - // Act - CommandLine.Parse(args, GNU) - .AddHandler(o => - { - reqNonNull = o.ReqNonNull; - nonReqNull = o.NonReqNull; - reqNull = o.ReqNull; - nonReqNonNull = o.NonReqNonNull; - }) - .Run(); - - // Assert - Assert.AreEqual("value1", reqNonNull); - Assert.AreEqual("value2", nonReqNull); - Assert.AreEqual("value3", reqNull); - Assert.AreEqual("value4", nonReqNonNull); - } - - [TestMethod("6.6. 可空枚举类型,无CLI参数,赋默认值(null)。")] - public void NullableEnumOption_NoCli_DefaultNull() - { - // Arrange - string[] args = ["--log-level", "Warning"]; // 只提供非可空枚举,不提供可空枚举 - LogLevel? logLevel = null; - LogLevel? nullableLogLevel = LogLevel.Error; // 初始化为非null值,验证会被设置为null - - // Act - CommandLine.Parse(args, GNU) - .AddHandler(o => - { - logLevel = o.LogLevel; - nullableLogLevel = o.NullableLogLevel; - }) - .Run(); - - // Assert - Assert.AreEqual(LogLevel.Warning, logLevel); - Assert.IsNull(nullableLogLevel, $"可空枚举应该是null,但实际值是: {nullableLogLevel}"); // 可空枚举应该是null - } - - [TestMethod("6.7. 可空值类型,无CLI参数,赋默认值(null)。")] - public void NullableValueTypes_NoCli_DefaultNull() - { - // Arrange - string[] args = ["--provided-int", "42"]; // 只提供一个必需的参数,不提供其他可空参数 - int? nullableInt = 123; // 初始化为非null值,验证会被设置为null - bool? nullableBool = true; // 初始化为非null值,验证会被设置为null - double? nullableDouble = 3.14; // 初始化为非null值,验证会被设置为null - decimal? nullableDecimal = 100.5m; // 初始化为非null值,验证会被设置为null - int? providedInt = null; - - // Act - CommandLine.Parse(args, GNU) - .AddHandler(o => - { - nullableInt = o.NullableInt; - nullableBool = o.NullableBool; - nullableDouble = o.NullableDouble; - nullableDecimal = o.NullableDecimal; - providedInt = o.ProvidedInt; - }) - .Run(); - - // Assert - Assert.AreEqual(42, providedInt); // 提供的参数应该正确解析 - Assert.IsNull(nullableInt, $"可空int应该是null,但实际值是: {nullableInt}"); - Assert.IsNull(nullableBool, $"可空bool应该是null,但实际值是: {nullableBool}"); - Assert.IsNull(nullableDouble, $"可空double应该是null,但实际值是: {nullableDouble}"); - Assert.IsNull(nullableDecimal, $"可空decimal应该是null,但实际值是: {nullableDecimal}"); - } - - #endregion - - #region 7. 异步处理测试 - - [TestMethod("7.1. 异步处理方法,正确执行。")] - public async Task AsyncHandler_ExecutesCorrectly() - { - // Arrange - string[] args = ["--value", "async-test"]; - string? value = null; - - // Act - await CommandLine.Parse(args, GNU) - .AddHandler(async o => - { - await Task.Delay(10); // 模拟异步操作 - value = o.Value; - }) - .RunAsync(); - - // Assert - Assert.AreEqual("async-test", value); - } - - #endregion -} - -#region 测试用数据模型 - -internal record GNU01_StringOptions -{ - [Option] - public required string Value { get; init; } -} - -internal record GNU02_ShortOptions -{ - [Option('v')] - public required string Value { get; init; } -} - -internal record GNU03_MixedOptions -{ - [Option('n')] - public int Number { get; init; } - - [Option('u')] - public int? NullableNumber { get; init; } - - [Option] - public required string Text { get; init; } - - [Option] - public required string? NullableText { get; init; } - - [Option] - public required IReadOnlyList NullableList { get; init; } - - [Option] - public required IReadOnlyList? NullableNullableList { get; init; } - - [Option('b')] - public bool Flag { get; init; } -} - -internal record GNU04_IntegerOptions -{ - [Option] - public int Number { get; init; } -} - -internal record GNU05_BooleanOptions -{ - [Option] - public bool Flag { get; init; } -} - -internal record GNU06_EnumOptions -{ - [Option("log-level")] - public LogLevel LogLevel { get; init; } - - [Option("nullable-log-level")] - public LogLevel? NullableLogLevel { get; init; } -} - -internal record GNU07_ArrayOptions -{ - [Option] - public string[] Files { get; init; } = []; -} - -internal record GNU08_ListOptions -{ - [Option] - public IReadOnlyList Tags { get; init; } = []; -} - -internal record GNU09_RequiredOptions -{ - [Option] - public required string RequiredValue { get; init; } -} - -internal record GNU10_CaseSensitiveOptions -{ - [Option("case-sensitive", CaseSensitive = true)] - public string CaseSensitive { get; init; } = string.Empty; - - [Option("CASE-SENSITIVE", CaseSensitive = true)] - public string CASESENSITIVE { get; init; } = string.Empty; -} - -internal record GNU11_CaseInsensitiveOptions -{ - [Option("ignore-case")] - public string IgnoreCase { get; init; } = string.Empty; -} - -internal record GNU12_AliasOptions -{ - [Option("option-with-alias", Aliases = ["alt", "alternate"])] - public string OptionWithAlias { get; init; } = string.Empty; -} - -internal record GNU13_CombinedOptions -{ - [Option('a')] - public bool OptionA { get; init; } - - [Option('b')] - public bool OptionB { get; init; } - - [Option('c')] - public bool OptionC { get; init; } -} - -internal record GNU14_TerminatorOptions -{ - [Option] - public string Option { get; init; } = string.Empty; - - [Value(Length = int.MaxValue)] - public string[] Values { get; init; } = []; -} - -internal record GNU15_SingleValueOptions -{ - [Value] - public string Value { get; init; } = string.Empty; -} - -internal record GNU16_MultipleValueOptions -{ - [Value(Length = int.MaxValue)] - public string[] Values { get; init; } = []; -} - -internal record GNU17_MixedValueOptions -{ - [Value(0)] - public string Value1 { get; init; } = string.Empty; - - [Option] - public string Option { get; init; } = string.Empty; - - [Value(1)] - public string Value2 { get; init; } = string.Empty; -} - -internal record GNU18_IndexedValueOptions -{ - [Value(0)] - public string First { get; init; } = string.Empty; - - [Value(2)] - public string Third { get; init; } = string.Empty; -} - -internal record GNU19_RequiredNonNullableOption -{ - [Option] - public required string Value { get; init; } -} - -internal record GNU20_NonRequiredNullableOption -{ - [Option] - public string? Value { get; init; } -} - -internal record GNU21_RequiredNullableOption -{ - [Option] - public required string? Value { get; init; } -} - -internal record GNU22_AllCombinationsOption -{ - [Option("req-non-null")] - public required string ReqNonNull { get; init; } - - [Option("non-req-null")] - public string? NonReqNull { get; init; } - - [Option("req-null")] - public required string? ReqNull { get; init; } - - [Option("non-req-non-null")] - public string NonReqNonNull { get; init; } = string.Empty; -} - -internal record GNU23_MixedCaseOptions -{ - [Option("Case-Option", CaseSensitive = true)] - public string CaseSensitiveOption { get; init; } = string.Empty; - - [Option("case-option")] - public string CaseInsensitiveOption { get; init; } = string.Empty; -} - -internal record GNU24_OverrideCaseOptions -{ - [Option("Option-One")] - public string OptionOne { get; init; } = string.Empty; - - [Option("option-TWO", CaseSensitive = false)] - public string OptionTwo { get; init; } = string.Empty; -} - -internal record GNU25_ComplexCaseOptions -{ - [Option("GLOBAL-SENSITIVE")] - public string GlobalSensitive { get; init; } = string.Empty; - - [Option("local-SENSITIVE", CaseSensitive = true)] - public string LocalSensitive { get; init; } = string.Empty; - - [Option("Local-Insensitive", CaseSensitive = false)] - public string LocalInsensitive { get; init; } = string.Empty; -} - -internal record GNU26_EnumCaseOptions -{ - [Option("log-level")] - public LogLevel LogLevel { get; init; } - - [Option("second-level")] - public LogLevel SecondLevel { get; init; } -} - -internal record GNU27_NonRequiredNonNullableOption -{ - [Option] - public string Value { get; init; } = string.Empty; -} - -internal record GNU28_NullableValueTypesOptions -{ - [Option("nullable-int")] - public int? NullableInt { get; init; } - - [Option("nullable-bool")] - public bool? NullableBool { get; init; } - - [Option("nullable-double")] - public double? NullableDouble { get; init; } - - [Option("nullable-decimal")] - public decimal? NullableDecimal { get; init; } - - [Option("provided-int")] - public int ProvidedInt { get; init; } -} - -internal record GNU14_QuotedArrayOptions -{ - [Option] - public string[] Files { get; init; } = []; - - [Option] - public string[] Paths { get; init; } = []; -} - -#endregion diff --git a/tests/DotNetCampus.CommandLine.Tests/Issues/OptionNameConflictionTests.cs b/tests/DotNetCampus.CommandLine.Tests/Issues/OptionNameConflictionTests.cs new file mode 100644 index 00000000..616d0a71 --- /dev/null +++ b/tests/DotNetCampus.CommandLine.Tests/Issues/OptionNameConflictionTests.cs @@ -0,0 +1,22 @@ +using DotNetCampus.Cli.Compiler; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace DotNetCampus.Cli.Tests.Issues; + +/// +/// 命令行参数定义的短/长名称出现冲突时,没有任何提示,编译错误和运行时异常都没有 +/// https://github.com/dotnet-campus/DotNetCampus.CommandLine/issues/36 +/// +[TestClass] +public class OptionNameConflictionTests +{ + public record Options + { + [Option('d', "data-folder")] + public string? LogFolder { get; set; } + + // 注释以下代码,是因为解除注释能复现问题;但因为我们在 #61 修复了问题,所以会导致编译不通过。 + // [Option('c', "data-folder")] + // public string? DataFolder { get; set; } + } +} diff --git a/tests/DotNetCampus.CommandLine.Tests/Issues/SlashPrefixValueTests.cs b/tests/DotNetCampus.CommandLine.Tests/Issues/SlashPrefixValueTests.cs new file mode 100644 index 00000000..17d53085 --- /dev/null +++ b/tests/DotNetCampus.CommandLine.Tests/Issues/SlashPrefixValueTests.cs @@ -0,0 +1,88 @@ +using DotNetCampus.Cli.Compiler; +using DotNetCampus.Cli.Exceptions; +using DotNetCampus.Cli.Tests.ParsingStyles; +using DotNetCampus.Cli.Utils.Parsers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace DotNetCampus.Cli.Tests.Issues; + +[TestClass] +public class SlashPrefixValueTests +{ + [TestMethod] + [DataRow(new[] { "--option", "/var/log" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] --option /var/log")] + [DataRow(new[] { "-option", "/var/log" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] -option /var/log")] + [DataRow(new[] { "/option", "/var/log" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] /option /var/log")] + [DataRow(new[] { "--option", "/var/log" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] --option /var/log")] + [DataRow(new[] { "--option", "/var/log" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] --option /var/log")] + [DataRow(new[] { "-Option", "/var/log" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -Option /var/log")] + [DataRow(new[] { "/option", "/var/log" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] /option /var/log")] + public void LinuxPathAsOptionValue(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var options = commandLine.As(); + + // Assert + Assert.AreEqual("/var/log", options.Option); + } + + [TestMethod] + [DataRow(new[] { "/var/log" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] /var/log")] + [DataRow(new[] { "/var/log" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] /var/log")] + public void LinuxPathAsPositionalArgumentValue_Supported(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var options = commandLine.As(); + + // Assert + Assert.AreEqual("/var/log", options.Value); + } + + [TestMethod] + [DataRow(new[] { "/var/log" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] /var/log")] + [DataRow(new[] { "/var/log" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] /var/log")] + public void LinuxPathAsPositionalArgumentValue_NotSupported1(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var exception = Assert.ThrowsExactly(() => commandLine.As()); + + // Assert + Assert.AreEqual(CommandLineParsingError.OptionalArgumentSeparatorNotSupported, exception.Reason); + } + + [TestMethod] + [DataRow(new[] { "/var" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] /var/log")] + [DataRow(new[] { "/var" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] /var/log")] + public void LinuxPathAsPositionalArgumentValue_NotSupported2(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var exception = Assert.ThrowsExactly(() => commandLine.As()); + + // Assert + Assert.AreEqual(CommandLineParsingError.OptionalArgumentNotFound, exception.Reason); + } + + public record TestOptions + { + [Option('o', "option")] + public string? Option { get; set; } + } + + public record TestValues + { + [Value(0)] + public string? Value { get; set; } + } +} diff --git a/tests/DotNetCampus.CommandLine.Tests/LogLevel.cs b/tests/DotNetCampus.CommandLine.Tests/LogLevel.cs deleted file mode 100644 index d6569fb7..00000000 --- a/tests/DotNetCampus.CommandLine.Tests/LogLevel.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace DotNetCampus.Cli.Tests; - -internal enum LogLevel -{ - Debug, - Info, - Warning, - Error, - Critical -} diff --git a/tests/DotNetCampus.CommandLine.Tests/NamingConventionTests.cs b/tests/DotNetCampus.CommandLine.Tests/NamingConventionTests.cs deleted file mode 100644 index 653bf19f..00000000 --- a/tests/DotNetCampus.CommandLine.Tests/NamingConventionTests.cs +++ /dev/null @@ -1,710 +0,0 @@ -using System; -using System.Threading.Tasks; -using DotNetCampus.Cli.Compiler; -using DotNetCampus.Cli.Exceptions; -using Microsoft.VisualStudio.TestTools.UnitTesting; - -// ReSharper disable UnusedAutoPropertyAccessor.Global -// ReSharper disable InconsistentNaming - -namespace DotNetCampus.Cli.Tests; - -/// -/// 测试命名规则(Naming Convention)功能,包括 kebab-case、PascalCase、camelCase 等多种命名风格的支持。 -/// 基于 CommandAttribute、OptionAttribute 和 ValueAttribute 的命名规则要求。 -/// -[TestClass] -public class NamingConventionTests -{ - private CommandLineParsingOptions Flexible { get; } = CommandLineParsingOptions.Flexible; - private CommandLineParsingOptions CaseSensitive { get; } = new() { CaseSensitive = true }; - - #region 1. CommandAttribute 命名规则测试 - - [TestMethod("1.1. kebab-case 命令名称 - 基本情况")] - public void Command_KebabCase_BasicCase() - { - // Arrange - string[] args = ["build-project", "--verbose"]; - bool handlerCalled = false; - bool verboseFlag = false; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => - { - handlerCalled = true; - verboseFlag = o.Verbose; - }) - .Run(); - - // Assert - Assert.IsTrue(handlerCalled); - Assert.IsTrue(verboseFlag); - } - - [TestMethod("1.2. kebab-case 多级子命令")] - public void Command_KebabCase_MultiLevel() - { - // Arrange - string[] args = ["user-management", "create-account", "--username", "john"]; - string? capturedUsername = null; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => - { - capturedUsername = o.Username; - }) - .Run(); - - // Assert - Assert.AreEqual("john", capturedUsername); - } - - [TestMethod("1.3. 空命令名称 - 默认命令")] - public void Command_EmptyName_DefaultCommand() - { - // Arrange - string[] args = ["--help"]; - bool handlerCalled = false; - bool helpFlag = false; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => - { - handlerCalled = true; - helpFlag = o.Help; - }) - .Run(); - - // Assert - Assert.IsTrue(handlerCalled); - Assert.IsTrue(helpFlag); - } - - [TestMethod("1.4. 单一命令名称")] - public void Command_SingleName() - { - // Arrange - string[] args = ["build", "--output", "bin"]; - string? capturedOutput = null; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => - { - capturedOutput = o.Output; - }) - .Run(); - - // Assert - Assert.AreEqual("bin", capturedOutput); - } - - [TestMethod("1.5. 命令名称大小写不敏感(默认)")] - public void Command_CaseInsensitive_Default() - { - // Arrange - string[] args = ["BUILD-PROJECT", "--verbose"]; // 大写命令 - bool handlerCalled = false; - - // Act - CommandLine.Parse(args, Flexible) // Flexible 默认大小写不敏感 - .AddHandler(_ => handlerCalled = true) - .Run(); - - // Assert - Assert.IsTrue(handlerCalled); - } - - [TestMethod("1.6. 命令名称大小写敏感")] - public void Command_CaseSensitive() - { - // Arrange - string[] args = ["BUILD-PROJECT", "--verbose"]; // 大写命令 - - // Act & Assert - 大小写敏感模式下应该抛出异常 - Assert.ThrowsExactly(() => - { - CommandLine.Parse(args, CaseSensitive) - .AddHandler(_ => { }) - .Run(); - }); - } - - #endregion - - #region 2. OptionAttribute 命名规则测试 - - [TestMethod("2.1. 无参数 OptionAttribute - 自动使用属性名")] - public void Option_NoParameter_UsePropertyName() - { - // Arrange - string[] args = ["--verbose"]; // 属性名 Verbose 转为 kebab-case - bool verboseFlag = false; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => - { - verboseFlag = o.Verbose; - }) - .Run(); - - // Assert - Assert.IsTrue(verboseFlag); - } - - [TestMethod("2.2. kebab-case 长选项名")] - public void Option_KebabCase_LongName() - { - // Arrange - string[] args = ["--output-directory", "bin"]; - string? capturedOutput = null; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => - { - capturedOutput = o.OutputDirectory; - }) - .Run(); - - // Assert - Assert.AreEqual("bin", capturedOutput); - } - - [TestMethod("2.3. 短选项名(单字符)")] - public void Option_ShortName_SingleCharacter() - { - // Arrange - string[] args = ["-v"]; // 短选项 - bool verboseFlag = false; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => - { - verboseFlag = o.Verbose; - }) - .Run(); - - // Assert - Assert.IsTrue(verboseFlag); - } - - [TestMethod("2.4. 短选项名和长选项名组合")] - public void Option_ShortAndLongName_Combined() - { - // Arrange - 测试短选项 - string[] shortArgs = ["-o", "bin"]; - string? capturedOutputShort = null; - - // Act - CommandLine.Parse(shortArgs, Flexible) - .AddHandler(o => - { - capturedOutputShort = o.Output; - }) - .Run(); - - // Assert - Assert.AreEqual("bin", capturedOutputShort); - - // Arrange - 测试长选项 - string[] longArgs = ["--output", "lib"]; - string? capturedOutputLong = null; - - // Act - CommandLine.Parse(longArgs, Flexible) - .AddHandler(o => - { - capturedOutputLong = o.Output; - }) - .Run(); - - // Assert - Assert.AreEqual("lib", capturedOutputLong); - } - - [TestMethod("2.5. 选项别名(Aliases)")] - public void Option_Aliases() - { - // Arrange - 测试第一个别名 - string[] aliasArgs1 = ["--out", "bin"]; - string? capturedOutput1 = null; - - // Act - CommandLine.Parse(aliasArgs1, Flexible) - .AddHandler(o => - { - capturedOutput1 = o.OutputPath; - }) - .Run(); - - // Assert - Assert.AreEqual("bin", capturedOutput1); - - // Arrange - 测试第二个别名 - string[] aliasArgs2 = ["--directory", "lib"]; - string? capturedOutput2 = null; - - // Act - CommandLine.Parse(aliasArgs2, Flexible) - .AddHandler(o => - { - capturedOutput2 = o.OutputPath; - }) - .Run(); - - // Assert - Assert.AreEqual("lib", capturedOutput2); - } - - [TestMethod("2.6. ExactSpelling 精确拼写")] - public void Option_ExactSpelling() - { - // Arrange - 使用精确拼写的选项名 - string[] exactArgs = ["--SampleProperty", "test"]; - string? capturedValue = null; - - // Act - CommandLine.Parse(exactArgs, CommandLineParsingOptions.Gnu) - .AddHandler(o => - { - capturedValue = o.SampleProperty; - }) - .Run(); - - // Assert - Assert.AreEqual("test", capturedValue); - - // Arrange - 尝试使用自动转换的名称(应该失败) - string[] kebabArgs = ["--sample-property", "test"]; - - // Act & Assert - Assert.ThrowsExactly(() => - { - CommandLine.Parse(kebabArgs, CommandLineParsingOptions.Gnu) - .AddHandler(_ => { }) - .Run(); - }); - } - - [TestMethod("2.7. 选项大小写敏感性")] - public void Option_CaseSensitive() - { - // Arrange - string[] args = ["--VERBOSE"]; // 大写选项 - - // Act - 大小写不敏感模式(默认) - bool verboseFlexible = false; - CommandLine.Parse(args, Flexible) - .AddHandler(o => - { - verboseFlexible = o.Verbose; - }) - .Run(); - - // Assert - Assert.IsTrue(verboseFlexible); - - // Act & Assert - 大小写敏感模式 - Assert.ThrowsExactly(() => - { - CommandLine.Parse(args, CaseSensitive) - .AddHandler(_ => { }) - .Run(); - }); - } - - #endregion - - #region 3. ValueAttribute 命名规则测试 - - [TestMethod("3.1. 无参数 ValueAttribute - 默认索引 0")] - public void Value_NoParameter_DefaultIndex() - { - // Arrange - string[] args = ["input.txt"]; - string? capturedInput = null; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => - { - capturedInput = o.InputFile; - }) - .Run(); - - // Assert - Assert.AreEqual("input.txt", capturedInput); - } - - [TestMethod("3.2. 指定索引的 ValueAttribute")] - public void Value_SpecificIndex() - { - // Arrange - string[] args = ["source.txt", "destination.txt"]; - string? capturedSource = null; - string? capturedDestination = null; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => - { - capturedSource = o.Source; - capturedDestination = o.Destination; - }) - .Run(); - - // Assert - Assert.AreEqual("source.txt", capturedSource); - Assert.AreEqual("destination.txt", capturedDestination); - } - - [TestMethod("3.3. 可变长度 ValueAttribute")] - public void Value_VariableLength() - { - // Arrange - string[] args = ["file1.txt", "file2.txt", "file3.txt"]; - string[]? capturedFiles = null; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => - { - capturedFiles = o.Files; - }) - .Run(); - - // Assert - Assert.IsNotNull(capturedFiles); - Assert.AreEqual(3, capturedFiles.Length); - Assert.AreEqual("file1.txt", capturedFiles[0]); - Assert.AreEqual("file2.txt", capturedFiles[1]); - Assert.AreEqual("file3.txt", capturedFiles[2]); - } - - [TestMethod("3.4. 指定索引和长度的 ValueAttribute")] - public void Value_SpecificIndexAndLength() - { - // Arrange - string[] args = ["cmd", "arg1", "arg2", "remaining"]; - string? capturedCommand = null; - string[]? capturedArgs = null; - string? capturedRemaining = null; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => - { - capturedCommand = o.Command; - capturedArgs = o.Arguments; - capturedRemaining = o.Remaining; - }) - .Run(); - - // Assert - Assert.AreEqual("cmd", capturedCommand); - Assert.IsNotNull(capturedArgs); - Assert.AreEqual(2, capturedArgs.Length); - Assert.AreEqual("arg1", capturedArgs[0]); - Assert.AreEqual("arg2", capturedArgs[1]); - Assert.AreEqual("remaining", capturedRemaining); - } - - #endregion - - #region 4. 混合命名风格测试 - - [TestMethod("4.1. PascalCase 属性名自动转换为 kebab-case")] - public void Mixed_PascalCaseToKebabCase() - { - // Arrange - string[] args = ["--sample-property", "test", "--another-option", "value"]; - string? capturedSample = null; - string? capturedAnother = null; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => - { - capturedSample = o.SampleProperty; - capturedAnother = o.AnotherOption; - }) - .Run(); - - // Assert - Assert.AreEqual("test", capturedSample); - Assert.AreEqual("value", capturedAnother); - } - - [TestMethod("4.2. camelCase 属性名自动转换为 kebab-case")] - public void Mixed_CamelCaseToKebabCase() - { - // Arrange - string[] args = ["--my-option", "test", "--another-value", "value"]; - string? capturedOption = null; - string? capturedValue = null; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => - { - capturedOption = o.myOption; - capturedValue = o.anotherValue; - }) - .Run(); - - // Assert - Assert.AreEqual("test", capturedOption); - Assert.AreEqual("value", capturedValue); - } - - [TestMethod("4.3. 多种命名风格的兼容性")] - public void Mixed_MultipleNamingStyles() - { - // Arrange - 测试不同的输入风格都能被识别 - string[] kebabArgs = ["--output-directory", "bin"]; - string[] pascalArgs = ["--OutputDirectory", "lib"]; - string? capturedKebab = null; - string? capturedPascal = null; - - // Act - kebab-case 输入 - CommandLine.Parse(kebabArgs, Flexible) - .AddHandler(o => - { - capturedKebab = o.OutputDirectory; - }) - .Run(); - - // Act - PascalCase 输入(在非精确拼写模式下应该也能工作) - CommandLine.Parse(pascalArgs, Flexible) - .AddHandler(o => - { - capturedPascal = o.OutputDirectory; - }) - .Run(); - - // Assert - Assert.AreEqual("bin", capturedKebab); - Assert.AreEqual("lib", capturedPascal); - } - - #endregion - - #region 5. 边界情况和错误处理测试 - - [TestMethod("5.1. 无效的短选项名(非字母字符)")] - public void Error_InvalidShortOptionName() - { - // 这个测试主要验证 OptionAttribute 构造函数的参数验证 - // 在实际使用中,这会在编译时就报错,所以我们这里测试运行时的行为 - - // Act & Assert - Assert.ThrowsExactly(() => - { - var _ = new OptionAttribute('1'); // 数字不是有效的短选项名 - }); - - Assert.ThrowsExactly(() => - { - var _ = new OptionAttribute('-'); // 符号不是有效的短选项名 - }); - } - - [TestMethod("5.2. 空字符串选项名")] - public void Error_EmptyOptionName() - { - // Arrange - string[] args = ["test"]; - - // Act & Assert - 空的选项名应该被忽略或使用属性名 - // 这里我们测试使用空字符串作为选项名时的行为 - bool handlerCalled = false; - CommandLine.Parse(args, Flexible) - .AddHandler(_ => handlerCalled = true) - .Run(); - - Assert.IsTrue(handlerCalled); - } - - [TestMethod("5.3. 重复的 ValueAttribute 索引")] - public void Error_DuplicateValueIndex() - { - // Arrange - string[] args = ["value"]; - - // Act & Assert - 这种情况下应该根据实现决定如何处理 - // 通常最后一个定义会生效或者抛出异常 - bool handlerCalled = false; - CommandLine.Parse(args, Flexible) - .AddHandler(o => - { - handlerCalled = true; - // 验证至少有一个值被设置 - Assert.IsTrue(!string.IsNullOrEmpty(o.Value1) || !string.IsNullOrEmpty(o.Value2)); - }) - .Run(); - - Assert.IsTrue(handlerCalled); - } - - #endregion -} - -#region 测试用数据模型 - -// 1. CommandAttribute 测试类 - -[Command("build-project")] -internal class BuildProjectCommand -{ - [Option("verbose")] - public bool Verbose { get; init; } -} - -[Command("user-management create-account")] -internal class UserManagementCreateAccountCommand -{ - [Option("username")] - public required string Username { get; init; } -} - -[Command(null)] // 无参数,表示默认命令 -internal class DefaultCommand -{ - [Option("help")] - public bool Help { get; init; } - - [Option] // 无参数,使用属性名 - public bool Verbose { get; init; } -} - -[Command("build")] -internal class BuildCommand -{ - [Option("output")] - public required string Output { get; init; } -} - -// 2. OptionAttribute 测试类 - -internal class BuildWithOptionsCommand -{ - [Option("output-directory")] - public required string OutputDirectory { get; init; } -} - -internal class BuildWithShortOptionsCommand -{ - [Option('v')] - public bool Verbose { get; init; } -} - -internal class BuildWithCombinedOptionsCommand -{ - [Option('o', "output")] - public required string Output { get; init; } -} - -internal class BuildWithAliasesCommand -{ - [Option("output-path", Aliases = ["out", "directory"])] - public required string OutputPath { get; init; } -} - -internal class ExactSpellingCommand -{ - [Option("SampleProperty", ExactSpelling = true)] - public required string SampleProperty { get; init; } -} - -internal class DefaultCaseSensitiveOptionsCommand -{ - [Option("verbose")] - public required bool Verbose { get; init; } -} - -// 3. ValueAttribute 测试类 - -internal class FileProcessCommand -{ - [Value] // 默认索引 0 - public required string InputFile { get; init; } -} - -internal class FileCopyCommand -{ - [Value(0)] - public required string Source { get; init; } - - [Value(1)] - public required string Destination { get; init; } -} - -internal class MultiFileCommand -{ - [Value(Length = int.MaxValue)] - public required string[] Files { get; init; } -} - -internal class ComplexValueCommand -{ - [Value(0)] - public required string Command { get; init; } - - [Value(1, 2)] - public required string[] Arguments { get; init; } - - [Value(3)] - public required string Remaining { get; init; } -} - -// 4. 混合命名风格测试类 - -internal class MixedNamingCommand -{ - [Option] // 使用属性名,PascalCase -> kebab-case - public required string SampleProperty { get; init; } - - [Option] // 使用属性名,PascalCase -> kebab-case - public required string AnotherOption { get; init; } -} - -internal class CamelCaseNamingCommand -{ - [Option] // camelCase -> kebab-case - public required string myOption { get; init; } - - [Option] // camelCase -> kebab-case - public required string anotherValue { get; init; } -} - -internal class CompatibilityCommand -{ - [Option] // 应该支持多种输入风格 - public required string OutputDirectory { get; init; } -} - -// 5. 边界情况测试类 - -internal class EmptyOptionNameCommand -{ - [Option("")] // 空字符串选项名 - public string EmptyName { get; init; } = ""; -} - -internal class DuplicateValueIndexCommand -{ - [Value(0)] - public string Value1 { get; init; } = ""; - - [Value(0)] // 重复的索引 - public string Value2 { get; init; } = ""; -} - -#endregion diff --git a/tests/DotNetCampus.CommandLine.Tests/ParsingOptions/CommandLineStyleMagicNumberTests.cs b/tests/DotNetCampus.CommandLine.Tests/ParsingOptions/CommandLineStyleMagicNumberTests.cs new file mode 100644 index 00000000..bed97571 --- /dev/null +++ b/tests/DotNetCampus.CommandLine.Tests/ParsingOptions/CommandLineStyleMagicNumberTests.cs @@ -0,0 +1,15 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace DotNetCampus.Cli.Tests.ParsingOptions; + +[TestClass] +public class CommandLineStyleMagicNumberTests +{ +#if DEBUG + [TestMethod("魔法数字必须严格和实际样式匹配")] + public void MagicNumber_MustMatchRealStyle() + { + CommandLineStyle.VerifyMagicNumbers(); + } +#endif +} diff --git a/tests/DotNetCampus.CommandLine.Tests/ParsingStyles/DefaultValueTests.cs b/tests/DotNetCampus.CommandLine.Tests/ParsingStyles/DefaultValueTests.cs new file mode 100644 index 00000000..ab8894f0 --- /dev/null +++ b/tests/DotNetCampus.CommandLine.Tests/ParsingStyles/DefaultValueTests.cs @@ -0,0 +1,325 @@ +using System.Collections; +using System.Collections.Generic; +using DotNetCampus.Cli.Compiler; +using DotNetCampus.Cli.Exceptions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace DotNetCampus.Cli.Tests.ParsingStyles; + +[TestClass] +public class DefaultValueTests +{ + [TestMethod] + [DataRow(new string[] { }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible]")] + [DataRow(new string[] { }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet]")] + [DataRow(new string[] { }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu]")] + [DataRow(new string[] { }, TestCommandLineStyle.Windows, DisplayName = "[Windows]")] + [DataRow(new[] { "test://" }, TestCommandLineStyle.Url, DisplayName = "[Url] test://")] + [DataRow(new[] { "value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] value")] + [DataRow(new[] { "value" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] value")] + [DataRow(new[] { "value" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] value")] + [DataRow(new[] { "value" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] value")] + [DataRow(new[] { "test://value" }, TestCommandLineStyle.Url, DisplayName = "[Url] test://value")] + public void Required_ThrowsException(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act & Assert + Assert.ThrowsExactly(() => commandLine.As()); + Assert.ThrowsExactly(() => commandLine.As()); + Assert.ThrowsExactly(() => commandLine.As()); + Assert.ThrowsExactly(() => commandLine.As()); + Assert.ThrowsExactly(() => commandLine.As()); + Assert.ThrowsExactly(() => commandLine.As()); + Assert.ThrowsExactly(() => commandLine.As()); + Assert.ThrowsExactly(() => commandLine.As()); + } + + [TestMethod] + [DataRow(new string[] { }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible]")] + [DataRow(new string[] { }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet]")] + [DataRow(new string[] { }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu]")] + [DataRow(new string[] { }, TestCommandLineStyle.Windows, DisplayName = "[Windows]")] + [DataRow(new[] { "test://" }, TestCommandLineStyle.Url, DisplayName = "[Url] test://")] + [DataRow(new[] { "value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] value")] + [DataRow(new[] { "value" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] value")] + [DataRow(new[] { "value" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] value")] + [DataRow(new[] { "value" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] value")] + [DataRow(new[] { "test://value" }, TestCommandLineStyle.Url, DisplayName = "[Url] test://value")] + public void WithoutInit_KeepsDefaultValue(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var options = commandLine.As(); + var optionsWithNullable = commandLine.As(); + var optionsWithCollection = commandLine.As(); + var optionsWithNullableCollection = commandLine.As(); + + // Assert + Assert.AreEqual("Default", options.Option); + Assert.AreEqual("Default", optionsWithNullable.Option); + CollectionAssert.AreEqual(new[] { "Default" }, (ICollection)optionsWithCollection.Option); + CollectionAssert.AreEqual(new[] { "Default" }, (ICollection)optionsWithNullableCollection.Option!); + } + + [TestMethod] + [DataRow(new string[] { }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible]")] + [DataRow(new string[] { }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet]")] + [DataRow(new string[] { }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu]")] + [DataRow(new string[] { }, TestCommandLineStyle.Windows, DisplayName = "[Windows]")] + [DataRow(new[] { "test://" }, TestCommandLineStyle.Url, DisplayName = "[Url] test://")] + [DataRow(new[] { "value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] value")] + [DataRow(new[] { "value" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] value")] + [DataRow(new[] { "value" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] value")] + [DataRow(new[] { "value" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] value")] + [DataRow(new[] { "test://value" }, TestCommandLineStyle.Url, DisplayName = "[Url] test://value")] + public void InitNullable_AssignsNull(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var optionsWithNullableInit = commandLine.As(); + var optionsWithNullableInitCollection = commandLine.As(); + var optionsWithInitNullableValueType = commandLine.As(); + + // Assert + Assert.IsNull(optionsWithNullableInit.Option); + Assert.IsNull(optionsWithNullableInitCollection.Option); + Assert.IsNull(optionsWithInitNullableValueType.Option); + } + + [TestMethod] + [DataRow(new string[] { }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible]")] + [DataRow(new string[] { }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet]")] + [DataRow(new string[] { }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu]")] + [DataRow(new string[] { }, TestCommandLineStyle.Windows, DisplayName = "[Windows]")] + [DataRow(new[] { "test://" }, TestCommandLineStyle.Url, DisplayName = "[Url] test://")] + [DataRow(new[] { "value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] value")] + [DataRow(new[] { "value" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] value")] + [DataRow(new[] { "value" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] value")] + [DataRow(new[] { "value" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] value")] + [DataRow(new[] { "test://value" }, TestCommandLineStyle.Url, DisplayName = "[Url] test://value")] + public void InitCollection_Empty(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var optionsWithCollection = commandLine.As(); + + // Assert + Assert.IsEmpty(optionsWithCollection.Option); + } + + [TestMethod] + [DataRow(new string[] { }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible]")] + [DataRow(new string[] { }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet]")] + [DataRow(new string[] { }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu]")] + [DataRow(new string[] { }, TestCommandLineStyle.Windows, DisplayName = "[Windows]")] + [DataRow(new[] { "test://" }, TestCommandLineStyle.Url, DisplayName = "[Url] test://")] + [DataRow(new[] { "value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] value")] + [DataRow(new[] { "value" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] value")] + [DataRow(new[] { "value" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] value")] + [DataRow(new[] { "value" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] value")] + [DataRow(new[] { "test://value" }, TestCommandLineStyle.Url, DisplayName = "[Url] test://value")] + public void InitString_Empty(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var optionsWithInit = commandLine.As(); + + // Assert + Assert.IsEmpty(optionsWithInit.Option); + } + + [TestMethod] + [DataRow(new string[] { }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible]")] + [DataRow(new string[] { }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet]")] + [DataRow(new string[] { }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu]")] + [DataRow(new string[] { }, TestCommandLineStyle.Windows, DisplayName = "[Windows]")] + [DataRow(new[] { "test://" }, TestCommandLineStyle.Url, DisplayName = "[Url] test://")] + [DataRow(new[] { "value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] value")] + [DataRow(new[] { "value" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] value")] + [DataRow(new[] { "value" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] value")] + [DataRow(new[] { "value" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] value")] + [DataRow(new[] { "test://value" }, TestCommandLineStyle.Url, DisplayName = "[Url] test://value")] + public void InitValueType_Default(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var optionsWithInitStruct = commandLine.As(); + + // Assert + Assert.AreEqual(0, optionsWithInitStruct.Option); + } + + public record OptionsWithRequired + { + [Option('o', "option")] + public required string Option { get; set; } + + [Value(0)] + public string? Value { get; set; } + } + + public record OptionsWithRequiredInit + { + [Option('o', "option")] + public required string Option { get; init; } + + [Value(0)] + public string? Value { get; set; } + } + + public record OptionsWithRequiredCollection + { + [Option('o', "option")] + public required IReadOnlyList Option { get; set; } + + [Value(0)] + public string? Value { get; set; } + } + + public record OptionsWithRequiredInitCollection + { + [Option('o', "option")] + public required IReadOnlyList Option { get; init; } + + [Value(0)] + public string? Value { get; set; } + } + + public record OptionsWithNullableRequired + { + [Option('o', "option")] + public required string? Option { get; set; } + + [Value(0)] + public string? Value { get; set; } + } + + public record OptionsWithNullableRequiredInit + { + [Option('o', "option")] + public required string? Option { get; init; } + + [Value(0)] + public string? Value { get; set; } + } + + public record OptionsWithNullableRequiredCollection + { + [Option('o', "option")] + public required IReadOnlyList? Option { get; set; } + + [Value(0)] + public string? Value { get; set; } + } + + public record OptionsWithNullableRequiredInitCollection + { + [Option('o', "option")] + public required IReadOnlyList? Option { get; init; } + + [Value(0)] + public string? Value { get; set; } + } + + public record Options + { + [Option('o', "option")] + public string Option { get; set; } = "Default"; + + [Value(0)] + public string? Value { get; set; } + } + + public record OptionsWithInit + { + [Option('o', "option")] + public string Option { get; init; } = "Default"; + + [Value(0)] + public string? Value { get; set; } + } + + public record OptionsWithCollection + { + [Option('o', "option")] + public IReadOnlyList Option { get; set; } = ["Default"]; + + [Value(0)] + public string? Value { get; set; } + } + + public record OptionsWithInitCollection + { + [Option('o', "option")] + public IReadOnlyList Option { get; init; } = ["Default"]; + + [Value(0)] + public string? Value { get; set; } + } + + public record OptionsWithNullable + { + [Option('o', "option")] + public string? Option { get; set; } = "Default"; + + [Value(0)] + public string? Value { get; set; } + } + + public record OptionsWithNullableInit + { + [Option('o', "option")] + public string? Option { get; init; } = "Default"; + + [Value(0)] + public string? Value { get; set; } + } + + public record OptionsWithNullableCollection + { + [Option('o', "option")] + public IReadOnlyList? Option { get; set; } = ["Default"]; + + [Value(0)] + public string? Value { get; set; } + } + + public record OptionsWithNullableInitCollection + { + [Option('o', "option")] + public IReadOnlyList? Option { get; init; } = ["Default"]; + + [Value(0)] + public string? Value { get; set; } + } + + public record OptionsWithInitNullableValueType + { + [Option('o', "option")] + public int? Option { get; init; } = 42; + + [Value(0)] + public string? Value { get; set; } + } + + public record OptionsWithInitValueType + { + [Option('o', "option")] + public int Option { get; init; } = 42; + + [Value(0)] + public string? Value { get; set; } + } +} diff --git a/tests/DotNetCampus.CommandLine.Tests/ParsingStyles/OptionBooleanValueTests.cs b/tests/DotNetCampus.CommandLine.Tests/ParsingStyles/OptionBooleanValueTests.cs new file mode 100644 index 00000000..c5a7241b --- /dev/null +++ b/tests/DotNetCampus.CommandLine.Tests/ParsingStyles/OptionBooleanValueTests.cs @@ -0,0 +1,235 @@ +using DotNetCampus.Cli.Compiler; +using DotNetCampus.Cli.Exceptions; +using DotNetCampus.Cli.Utils.Parsers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace DotNetCampus.Cli.Tests.ParsingStyles; + +[TestClass] +public class OptionBooleanValueTests +{ + [TestMethod] + // option + [DataRow(new[] { "--option" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] --option")] + [DataRow(new[] { "-Option" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] -Option")] + [DataRow(new[] { "-option" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] -option")] + [DataRow(new[] { "/Option" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] /Option")] + [DataRow(new[] { "/option" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] /option")] + [DataRow(new[] { "--option" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] --option")] + [DataRow(new[] { "--option" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] --option")] + [DataRow(new[] { "-Option" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -Option")] + [DataRow(new[] { "-option" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -option")] + [DataRow(new[] { "/Option" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] /Option")] + [DataRow(new[] { "/option" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] /option")] + // o + [DataRow(new[] { "-o" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] -o")] + [DataRow(new[] { "/o" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] /o")] + [DataRow(new[] { "-o" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] -o")] + [DataRow(new[] { "-o" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] -o")] + [DataRow(new[] { "-o" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -o")] + [DataRow(new[] { "/o" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] /o")] + // option=true + [DataRow(new[] { "--option=true" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] --option=true")] + [DataRow(new[] { "-Option=true" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] -Option=true")] + [DataRow(new[] { "-option=true" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] -option=true")] + [DataRow(new[] { "/Option=true" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] /Option=true")] + [DataRow(new[] { "/option=true" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] /option=true")] + [DataRow(new[] { "--option=true" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] --option=true")] + [DataRow(new[] { "--option=true" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] --option=true")] + [DataRow(new[] { "-Option=true" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -Option=true")] + [DataRow(new[] { "-option=true" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -option=true")] + [DataRow(new[] { "/Option=true" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] /Option=true")] + [DataRow(new[] { "/option=true" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] /option=true")] + // o=true + [DataRow(new[] { "-o=true" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] -o=true")] + [DataRow(new[] { "/o=true" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] /o=true")] + [DataRow(new[] { "-o=true" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] -o=true")] + [DataRow(new[] { "-o=true" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -o=true")] + [DataRow(new[] { "/o=true" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] /o=true")] + // option true + [DataRow(new[] { "--option", "true" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] --option true")] + [DataRow(new[] { "-Option", "true" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] -Option true")] + [DataRow(new[] { "-option", "true" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] -option true")] + [DataRow(new[] { "/Option", "true" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] /Option true")] + [DataRow(new[] { "/option", "true" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] /option true")] + [DataRow(new[] { "--option", "true" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] --option true")] + [DataRow(new[] { "-Option", "true" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -Option true")] + [DataRow(new[] { "-option", "true" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -option true")] + [DataRow(new[] { "/Option", "true" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] /Option true")] + [DataRow(new[] { "/option", "true" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] /option true")] + // o true + [DataRow(new[] { "-o", "true" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] -o true")] + [DataRow(new[] { "/o", "true" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] /o true")] + [DataRow(new[] { "-o", "true" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] -o true")] + [DataRow(new[] { "-o", "true" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -o true")] + [DataRow(new[] { "/o", "true" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] /o true")] + // otrue + [DataRow(new[] { "-otrue" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] -otrue")] + public void Supported_True(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var options = commandLine.As(); + + // Assert + Assert.IsTrue(options.Option); + } + + [TestMethod] + [DataRow(new[] { "-o:true" }, true, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] -o:true")] + [DataRow(new[] { "-o:yes" }, true, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] -o:yes")] + [DataRow(new[] { "-o:on" }, true, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] -o:on")] + [DataRow(new[] { "-o:1" }, true, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] -o:1")] + [DataRow(new[] { "-o:false" }, false, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] -o:false")] + [DataRow(new[] { "-o:no" }, false, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] -o:no")] + [DataRow(new[] { "-o:off" }, false, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] -o:off")] + [DataRow(new[] { "-o:0" }, false, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] -o:0")] + [DataRow(new[] { "-otrue" }, true, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] -otrue")] + [DataRow(new[] { "-oyes" }, true, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] -oyes")] + [DataRow(new[] { "-oon" }, true, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] -oon")] + [DataRow(new[] { "-o1" }, true, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] -o1")] + [DataRow(new[] { "-ofalse" }, false, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] -ofalse")] + [DataRow(new[] { "-ono" }, false, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] -ono")] + [DataRow(new[] { "-ooff" }, false, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] -ooff")] + [DataRow(new[] { "-o0" }, false, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] -o0")] + public void BooleanValue(string[] args, bool value, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var options = commandLine.As(); + + // Assert + Assert.AreEqual(value, options.Option); + } + + [TestMethod] + [DataRow(new[] { "--option", "true" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] --option true")] + [DataRow(new[] { "-o", "true" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] -o true")] + public void GnuDoesNotSupportExplicitBooleanValue(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var exception = Assert.ThrowsExactly(() => commandLine.As()); + + // Assert + Assert.AreEqual(CommandLineParsingError.PositionalArgumentNotFound, exception.Reason); + } + + [TestMethod] + [DataRow(new[] { "-ab" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] -ab")] + public void BooleanOptionCombination(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var options = commandLine.As(); + + // Assert + Assert.IsTrue(options.OptionA); + Assert.IsTrue(options.OptionB); + Assert.IsNull(options.OptionC); + } + + [TestMethod] + [DataRow(new[] { "-abc" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] -abc")] + public void OptionCombinationMustAllBoolean(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var exception = Assert.ThrowsExactly(() => commandLine.As()); + + // Assert + Assert.AreEqual(CommandLineParsingError.ArgumentCombinationIsNotBoolean, exception.Reason); + } + + [TestMethod] + [DataRow(new[] { "-ab" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] -ab")] + [DataRow(new[] { "/ab" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] /ab")] + [DataRow(new[] { "-ab" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] -ab")] + [DataRow(new[] { "-ab" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -ab")] + [DataRow(new[] { "/ab" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] /ab")] + public void DoesNotSupportBooleanOptionCombination(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var exception = Assert.ThrowsExactly(() => commandLine.As()); + + // Assert + Assert.AreEqual(CommandLineParsingError.OptionalArgumentNotFound, exception.Reason); + } + + [TestMethod] + [DataRow(new[] { "-ab" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] -ab")] + [DataRow(new[] { "/ab" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] /ab")] + [DataRow(new[] { "-ab" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] -ab")] + [DataRow(new[] { "-ab" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -ab")] + [DataRow(new[] { "/ab" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] /ab")] + public void SupportMultiCharShortOptions(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var options = commandLine.As(); + + // Assert + Assert.IsTrue(options.OptionA); + Assert.IsNull(options.OptionB); + } + + [TestMethod] + [DataRow(new[] { "-ab", "value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] -ab value")] + [DataRow(new[] { "/ab", "value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] /ab value")] + [DataRow(new[] { "-ab", "value" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] -ab value")] + [DataRow(new[] { "-ab", "value" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -ab value")] + [DataRow(new[] { "/ab", "value" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] /ab value")] + public void MultiCharShortOptionsDoesNotSupportValue(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var exception = Assert.ThrowsExactly(() => commandLine.As()); + + // Assert + Assert.AreEqual(CommandLineParsingError.PositionalArgumentNotFound, exception.Reason); + } + + public record TestOptions + { + [Option('o', "option")] + public bool? Option { get; set; } + } + + public record TestCombinationOptions + { + [Option('a', "option-a")] + public bool? OptionA { get; set; } + + [Option('b', "option-b")] + public bool? OptionB { get; set; } + + [Option('c', "option-c")] + public string? OptionC { get; set; } + } + + public record MultiCharShortOptions + { + [Option("ab", "option-ab")] + public bool? OptionA { get; set; } + + [Option('b', "option-b")] + public bool? OptionB { get; set; } + } +} diff --git a/tests/DotNetCampus.CommandLine.Tests/ParsingStyles/OptionCaseSensitiveTests.cs b/tests/DotNetCampus.CommandLine.Tests/ParsingStyles/OptionCaseSensitiveTests.cs new file mode 100644 index 00000000..da4a25ad --- /dev/null +++ b/tests/DotNetCampus.CommandLine.Tests/ParsingStyles/OptionCaseSensitiveTests.cs @@ -0,0 +1,74 @@ +using DotNetCampus.Cli.Compiler; +using DotNetCampus.Cli.Exceptions; +using DotNetCampus.Cli.Utils.Parsers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace DotNetCampus.Cli.Tests.ParsingStyles; + +[TestClass] +public class OptionCaseSensitiveTests +{ + [TestMethod] + [DataRow(new[] { "--Option-Name1=value" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] --Option-Name1=value")] + [DataRow(new[] { "--OPTION-NAME1=value" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] --OPTION-NAME1=value")] + [DataRow(new[] { "--Option-Name1=value" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] --Option-Name1=value")] + [DataRow(new[] { "--OPTION-NAME1=value" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] --OPTION-NAME1=value")] + public void CaseSensitive(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var exception = Assert.ThrowsExactly(() => commandLine.As()); + + // Assert + Assert.AreEqual(CommandLineParsingError.OptionalArgumentNotFound, exception.Reason); + } + + [TestMethod] + [DataRow(new[] { "--Option-Name1=value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] --Option-Name1=value")] + [DataRow(new[] { "--OPTION-NAME1=value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] --OPTION-NAME1=value")] + [DataRow(new[] { "-optionName1=value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] -optionName1=value")] + [DataRow(new[] { "-optionname1=value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] -optionname1=value")] + [DataRow(new[] { "-optionname1=value" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -optionname1=value")] + [DataRow(new[] { "-OPTIONNAME1=value" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -OPTIONNAME1=value")] + [DataRow(new[] { "-optionName1=value" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -optionName1=value")] + [DataRow(new[] { "test://?Option-Name1=value" }, TestCommandLineStyle.Url, DisplayName = "[Url] test://?Option-Name1=value")] + [DataRow(new[] { "test://?OPTION-NAME1=value" }, TestCommandLineStyle.Url, DisplayName = "[Url] test://?OPTION-NAME1=value")] + public void CaseInsensitive(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var options = commandLine.As(); + + // Assert + Assert.AreEqual("value", options.OptionName1); + } + + [TestMethod] + [DataRow(new[] { "test://?option-name1=value" }, TestCommandLineStyle.Url, DisplayName = "[Url] test://?option-name1=value")] + [DataRow(new[] { "Test://?option-name1=value" }, TestCommandLineStyle.Url, DisplayName = "[Url] Test://?option-name1=value")] + [DataRow(new[] { "TEST://?option-name1=value" }, TestCommandLineStyle.Url, DisplayName = "[Url] TEST://?option-name1=value")] + public void UrlCaseInsensitive(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var options = commandLine.As(); + + // Assert + Assert.AreEqual("value", options.OptionName1); + } + + public record TestOptions + { + [Option("option-name1")] + public string? OptionName1 { get; set; } + + [Option("OptionName2")] + public string? OptionName2 { get; set; } + } +} diff --git a/tests/DotNetCampus.CommandLine.Tests/ParsingStyles/OptionCollectionValueTests.cs b/tests/DotNetCampus.CommandLine.Tests/ParsingStyles/OptionCollectionValueTests.cs new file mode 100644 index 00000000..0955569f --- /dev/null +++ b/tests/DotNetCampus.CommandLine.Tests/ParsingStyles/OptionCollectionValueTests.cs @@ -0,0 +1,162 @@ +using System.Collections; +using System.Collections.Generic; +using DotNetCampus.Cli.Compiler; +using DotNetCampus.Cli.Exceptions; +using DotNetCampus.Cli.Utils.Parsers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace DotNetCampus.Cli.Tests.ParsingStyles; + +[TestClass] +public class OptionCollectionValueTests +{ + [TestMethod] + // option a option b + [DataRow(new[] { "--option", "a", "--option", "b" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] --option a --option b")] + [DataRow(new[] { "--option", "a", "--option", "b" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] --option a --option b")] + [DataRow(new[] { "--option", "a", "--option", "b" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] --option a --option b")] + [DataRow(new[] { "-Option", "a", "-Option", "b" }, TestCommandLineStyle.Windows, DisplayName = "[Gnu] -Option a -Option b")] + [DataRow(new[] { "-o", "a", "-o", "b" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] -o a -o b")] + [DataRow(new[] { "-o", "a", "-o", "b" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] -o a -o b")] + [DataRow(new[] { "-o", "a", "-o", "b" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] -o a -o b")] + [DataRow(new[] { "-o", "a", "-o", "b" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -o a -o b")] + // option a,b + [DataRow(new[] { "--option", "a,b" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] --option a,b")] + [DataRow(new[] { "--option", "a,b" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] --option a,b")] + [DataRow(new[] { "--option", "a,b" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] --option a,b")] + [DataRow(new[] { "-Option", "a,b" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -Option a,b")] + [DataRow(new[] { "-o", "a,b" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] -o a,b")] + [DataRow(new[] { "-o", "a,b" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] -o a,b")] + [DataRow(new[] { "-o", "a,b" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] -o a,b")] + [DataRow(new[] { "-o", "a,b" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -o a,b")] + // option a;b + [DataRow(new[] { "--option", "a;b" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] --option a;b")] + [DataRow(new[] { "--option", "a;b" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] --option a;b")] + [DataRow(new[] { "--option", "a;b" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] --option a;b")] + [DataRow(new[] { "-Option", "a;b" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -Option a;b")] + [DataRow(new[] { "-o", "a;b" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] -o a;b")] + [DataRow(new[] { "-o", "a;b" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] -o a;b")] + [DataRow(new[] { "-o", "a;b" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] -o a;b")] + [DataRow(new[] { "-o", "a;b" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -o a;b")] + // option=a option=b + [DataRow(new[] { "--option=a", "--option=b" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] --option=a --option=b")] + [DataRow(new[] { "--option=a", "--option=b" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] --option=a --option=b")] + [DataRow(new[] { "--option=a", "--option=b" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] --option=a --option=b")] + [DataRow(new[] { "-Option=a", "-Option=b" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -Option=a -Option=b")] + [DataRow(new[] { "test://?option=a&option=b" }, TestCommandLineStyle.Url, DisplayName = "[Uri] test://?option=a&option=b")] + [DataRow(new[] { "-o=a", "-o=b" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] -o=a -o=b")] + [DataRow(new[] { "-o=a", "-o=b" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] -o=a -o=b")] + [DataRow(new[] { "-o=a", "-o=b" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -o=a -o=b")] + // option=a,b + [DataRow(new[] { "--option=a,b" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] --option=a,b")] + [DataRow(new[] { "--option=a,b" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] --option=a,b")] + [DataRow(new[] { "--option=a,b" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] --option=a,b")] + [DataRow(new[] { "-Option=a,b" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -Option=a,b")] + [DataRow(new[] { "test://?option=a,b" }, TestCommandLineStyle.Url, DisplayName = "[Uri] test://?option=a,b")] + [DataRow(new[] { "-o=a,b" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] -o=a,b")] + [DataRow(new[] { "-o=a,b" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] -o=a,b")] + [DataRow(new[] { "-o=a,b" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -o=a,b")] + // option=a;b + [DataRow(new[] { "--option=a;b" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] --option=a;b")] + [DataRow(new[] { "--option=a;b" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] --option=a;b")] + [DataRow(new[] { "--option=a;b" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] --option=a;b")] + [DataRow(new[] { "-Option=a;b" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -Option=a;b")] + [DataRow(new[] { "test://?option=a;b" }, TestCommandLineStyle.Url, DisplayName = "[Uri] test://?option=a;b")] + [DataRow(new[] { "-o=a;b" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] -o=a;b")] + [DataRow(new[] { "-o=a;b" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] -o=a;b")] + [DataRow(new[] { "-o=a;b" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -o=a;b")] + // oa,b + [DataRow(new[] { "-oa,b" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] -oa,b")] + // oa;b + [DataRow(new[] { "-oa;b" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] -oa;b")] + public void Supported_Collection(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var options = commandLine.As(); + + // Assert + Assert.IsNotNull(options.Option); + CollectionAssert.AreEqual(new[] { "a", "b" }, (ICollection)options.Option); + } + + [TestMethod] + [DataRow(new[] { "--option", "a", "--option", "b,c" }, TestCommandLineStyle.DotNet, DisplayName = "[Flexible] --option a --option b,c")] + [DataRow(new[] { "--option", "a;b,c" }, TestCommandLineStyle.DotNet, DisplayName = "[Flexible] --option a;b,c")] + public void SupportedButStrange_Collection(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var options = commandLine.As(); + + // Assert + Assert.IsNotNull(options.Option); + CollectionAssert.AreEqual(new[] { "a", "b", "c" }, (ICollection)options.Option); + } + + [TestMethod] + // option a b + [DataRow(new[] { "--option", "a", "b" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] --option a b")] + [DataRow(new[] { "--option", "a", "b" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] --option a b")] + [DataRow(new[] { "--option", "a", "b" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] --option a b")] + [DataRow(new[] { "-Option", "a", "b" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -Option a b")] + [DataRow(new[] { "-o", "a", "b" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] -o a b")] + [DataRow(new[] { "-o", "a", "b" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] -o a b")] + [DataRow(new[] { "-o", "a", "b" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] -o a b")] + [DataRow(new[] { "-o", "a", "b" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -o a b")] + public void NotSupported_Collection(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var exception = Assert.ThrowsExactly(() => commandLine.As()); + + // Assert + Assert.AreEqual(CommandLineParsingError.PositionalArgumentNotFound, exception.Reason); + } + + [TestMethod] + // o=a o=b + [DataRow(new[] { "-o=a", "-o=b" }, new[] { "=a", "=b" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] -o=a -o=b (预期值为 '=a, =b')")] + [DataRow(new[] { "-o:a", "-o:b" }, new[] { ":a", ":b" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] -o:a -o:b (预期值为 ':a, :b')")] + public void GnuDoesNotSupportShortOptionSeparator(string[] args, string[] expectedValues, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var options = commandLine.As(); + + // Assert + Assert.IsNotNull(options.Option); + CollectionAssert.AreEqual(expectedValues, (ICollection)options.Option); + } + + [TestMethod] + [DataRow(new[] { "--option=a", "b,c" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] --option=a b,c")] + [DataRow(new[] { "--option=a", "b,c" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] --option=a b,c")] + [DataRow(new[] { "--option=a", "b,c" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] --option=a b,c")] + [DataRow(new[] { "-Option=a", "b,c" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -Option=a b,c")] + public void DoesNotSupportOptionWithValueAndArgumentValueCollection(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var exception = Assert.ThrowsExactly(() => commandLine.As()); + + // Assert + Assert.AreEqual(CommandLineParsingError.PositionalArgumentNotFound, exception.Reason); + } + + public record TestOptions + { + [Option('o', "option")] + public IReadOnlyList? Option { get; set; } + } +} diff --git a/tests/DotNetCampus.CommandLine.Tests/ParsingStyles/OptionDictionaryValueTests.cs b/tests/DotNetCampus.CommandLine.Tests/ParsingStyles/OptionDictionaryValueTests.cs new file mode 100644 index 00000000..867a79c0 --- /dev/null +++ b/tests/DotNetCampus.CommandLine.Tests/ParsingStyles/OptionDictionaryValueTests.cs @@ -0,0 +1,69 @@ +using System.Collections; +using System.Collections.Generic; +using DotNetCampus.Cli.Compiler; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace DotNetCampus.Cli.Tests.ParsingStyles; + +[TestClass] +public class OptionDictionaryValueTests +{ + [TestMethod] + // option key1=value1 option key2=value2 + [DataRow(new[] { "--option", "a=x", "--option", "b=y" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] --option a=x --option b=y")] + [DataRow(new[] { "--option", "a=x", "--option", "b=y" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] --option a=x --option b=y")] + [DataRow(new[] { "--option", "a=x", "--option", "b=y" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] --option a=x --option b=y")] + [DataRow(new[] { "-Option", "a=x", "-Option", "b=y" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -Option a=x -Option b=y")] + // option:key1=value1;key2=value2 + [DataRow(new[] { "--option:a=x;b=y" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] --option:a=x;b=y")] + [DataRow(new[] { "--option:a=x;b=y" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] --option:a=x;b=y")] + [DataRow(new[] { "-Option:a=x;b=y" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -Option:a=x;b=y")] + [DataRow(new[] { "-o:a=x;b=y" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] -o:a=x;b=y")] + [DataRow(new[] { "-o:a=x;b=y" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] -o:a=x;b=y")] + [DataRow(new[] { "-o:a=x;b=y" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -o:a=x;b=y")] + public void Supported_Collection(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var options = commandLine.As(); + + // Assert + Assert.IsNotNull(options.Option); + CollectionAssert.AreEquivalent(new Dictionary + { + ["a"] = "x", + ["b"] = "y", + }, (ICollection)options.Option); + } + + [TestMethod] + // option=key1=value1;key2=value2 + [DataRow(new[] { "--option=a=x;b=y" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] --option=a=x;b=y")] + [DataRow(new[] { "--option=a=x;b=y" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] --option=a=x;b=y")] + [DataRow(new[] { "--option=a=x;b=y" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] --option=a=x;b=y")] + [DataRow(new[] { "-Option=a=x;b=y" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -Option=a=x;b=y")] + public void SupportedButStrange_Collection(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var options = commandLine.As(); + + // Assert + Assert.IsNotNull(options.Option); + CollectionAssert.AreEquivalent(new Dictionary + { + ["a"] = "x", + ["b"] = "y", + }, (ICollection)options.Option); + } + + public record TestOptions + { + [Option('o', "option")] + public IReadOnlyDictionary? Option { get; set; } + } +} diff --git a/tests/DotNetCampus.CommandLine.Tests/ParsingStyles/OptionNamingPolicyTests.cs b/tests/DotNetCampus.CommandLine.Tests/ParsingStyles/OptionNamingPolicyTests.cs new file mode 100644 index 00000000..54181d47 --- /dev/null +++ b/tests/DotNetCampus.CommandLine.Tests/ParsingStyles/OptionNamingPolicyTests.cs @@ -0,0 +1,80 @@ +using DotNetCampus.Cli.Compiler; +using DotNetCampus.Cli.Exceptions; +using DotNetCampus.Cli.Utils.Parsers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace DotNetCampus.Cli.Tests.ParsingStyles; + +[TestClass] +public class OptionNamingPolicyTests +{ + [TestMethod] + // --kebab-case + [DataRow(new[] { "--option-name1=value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] --option-name1=value")] + [DataRow(new[] { "--option-name1=value" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] --option-name1=value")] + [DataRow(new[] { "--option-name1=value" }, TestCommandLineStyle.Gnu, DisplayName = "[GNU] --option-name1=value")] + // -PascalCase + [DataRow(new[] { "-OptionName1=value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] -OptionName1=value")] + [DataRow(new[] { "-OptionName1=value" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -OptionName1=value")] + // --PascalCase (Strange but supported in Flexible) + [DataRow(new[] { "--OptionName1=value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] --OptionName1=value")] + public void Supported1(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var options = commandLine.As(); + + // Assert + Assert.AreEqual("value", options.OptionName1); + } + + [TestMethod] + // ordinal + [DataRow(new[] { "--OptionName2=value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] --OptionName2=value")] + [DataRow(new[] { "-OptionName2=value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] -OptionName2=value")] + [DataRow(new[] { "--OptionName2=value" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] --OptionName2=value")] + [DataRow(new[] { "--OptionName2=value" }, TestCommandLineStyle.Gnu, DisplayName = "[GNU] --OptionName2=value")] + [DataRow(new[] { "-OptionName2=value" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -OptionName2=value")] + public void Supported2(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var options = commandLine.As(); + + // Assert + Assert.AreEqual("value", options.OptionName2); + } + + [TestMethod] + [DataRow(new[] { "--OptionName1=value" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] --OptionName1=value")] + [DataRow(new[] { "--OptionName1=value" }, TestCommandLineStyle.Gnu, DisplayName = "[GNU] --OptionName1=value")] + [DataRow(new[] { "-option-name1=value" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -option-name1=value")] + [DataRow(new[] { "--option-name2=value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] --option-name2=value")] + [DataRow(new[] { "--option-name2=value" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] --option-name2=value")] + [DataRow(new[] { "--option-name2=value" }, TestCommandLineStyle.Gnu, DisplayName = "[GNU] --option-name2=value")] + [DataRow(new[] { "-option-name2=value" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -option-name2=value")] + public void NotSupported(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var exception = Assert.ThrowsExactly(() => commandLine.As()); + + // Assert + Assert.AreEqual(CommandLineParsingError.OptionalArgumentNotFound, exception.Reason); + } + + public record TestOptions + { + [Option("option-name1")] + public string? OptionName1 { get; set; } + + [Option("OptionName2")] + public string? OptionName2 { get; set; } + } +} diff --git a/tests/DotNetCampus.CommandLine.Tests/ParsingStyles/OptionValueSeparatorTests.cs b/tests/DotNetCampus.CommandLine.Tests/ParsingStyles/OptionValueSeparatorTests.cs new file mode 100644 index 00000000..f3892e1f --- /dev/null +++ b/tests/DotNetCampus.CommandLine.Tests/ParsingStyles/OptionValueSeparatorTests.cs @@ -0,0 +1,219 @@ +using DotNetCampus.Cli.Compiler; +using DotNetCampus.Cli.Exceptions; +using DotNetCampus.Cli.Utils.Parsers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace DotNetCampus.Cli.Tests.ParsingStyles; + +[TestClass] +public class OptionValueSeparatorTests +{ + [TestMethod] + // option value + [DataRow(new[] { "--option", "value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] --option value")] + [DataRow(new[] { "-Option", "value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] -Option value")] + [DataRow(new[] { "-option", "value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] -option value")] + [DataRow(new[] { "/Option", "value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] /Option value")] + [DataRow(new[] { "/option", "value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] /option value")] + [DataRow(new[] { "--option", "value" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] --option value")] + [DataRow(new[] { "--option", "value" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] --option value")] + [DataRow(new[] { "-Option", "value" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -Option value")] + [DataRow(new[] { "-option", "value" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -option value")] + [DataRow(new[] { "/Option", "value" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] /Option value")] + [DataRow(new[] { "/option", "value" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] /option value")] + // o value + [DataRow(new[] { "-o", "value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] -o value")] + [DataRow(new[] { "/o", "value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] /o value")] + [DataRow(new[] { "-o", "value" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] -o value")] + [DataRow(new[] { "-o", "value" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] -o value")] + [DataRow(new[] { "-o", "value" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -o value")] + [DataRow(new[] { "/o", "value" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] /o value")] + // option=value + [DataRow(new[] { "--option=value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] --option=value")] + [DataRow(new[] { "-Option=value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] -Option=value")] + [DataRow(new[] { "-option=value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] -option=value")] + [DataRow(new[] { "/Option=value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] /Option=value")] + [DataRow(new[] { "/option=value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] /option=value")] + [DataRow(new[] { "--option=value" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] --option=value")] + [DataRow(new[] { "--option=value" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] --option=value")] + [DataRow(new[] { "-Option=value" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -Option=value")] + [DataRow(new[] { "-option=value" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -option=value")] + [DataRow(new[] { "/Option=value" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] /Option=value")] + [DataRow(new[] { "/option=value" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] /option=value")] + [DataRow(new[] { "test://?option=value" }, TestCommandLineStyle.Url, DisplayName = "[Url] option=value")] + // o=value + [DataRow(new[] { "-o=value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] -o=value")] + [DataRow(new[] { "/o=value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] /o=value")] + [DataRow(new[] { "-o=value" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] -o=value")] + [DataRow(new[] { "-o=value" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -o=value")] + [DataRow(new[] { "/o=value" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] /o=value")] + // option:value + [DataRow(new[] { "--option:value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] --option:value")] + [DataRow(new[] { "-Option:value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] -Option:value")] + [DataRow(new[] { "-option:value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] -option:value")] + [DataRow(new[] { "/Option:value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] /Option:value")] + [DataRow(new[] { "/option:value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] /option:value")] + [DataRow(new[] { "--option:value" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] --option:value")] + [DataRow(new[] { "-Option:value" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -Option:value")] + [DataRow(new[] { "-option:value" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -option:value")] + [DataRow(new[] { "/Option:value" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] /Option:value")] + [DataRow(new[] { "/option:value" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] /option:value")] + // o:value + [DataRow(new[] { "-o:value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] -o:value")] + [DataRow(new[] { "/o:value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] /o:value")] + [DataRow(new[] { "-o:value" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] -o:value")] + [DataRow(new[] { "-o:value" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -o:value")] + [DataRow(new[] { "/o:value" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] /o:value")] + // ovalue + [DataRow(new[] { "-ovalue" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] -ovalue")] + public void Supported(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var options = commandLine.As(); + + // Assert + Assert.AreEqual("value", options.Option); + } + + [TestMethod] + [DataRow(new[] { "--option:value" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] --option:value")] + [DataRow(new[] { "test://?option:value" }, TestCommandLineStyle.Url, DisplayName = "[Url] option:value")] + [DataRow(new[] { "test://?o:value" }, TestCommandLineStyle.Url, DisplayName = "[Url] o:value")] + [DataRow(new[] { "test://?option%20value" }, TestCommandLineStyle.Url, DisplayName = "[Url] option value")] + [DataRow(new[] { "test://?o%20value" }, TestCommandLineStyle.Url, DisplayName = "[Url] o value")] + public void NotSupported(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var exception = Assert.ThrowsExactly(() => commandLine.As()); + + // Assert + Assert.AreEqual(CommandLineParsingError.OptionalArgumentSeparatorNotSupported, exception.Reason); + } + + [TestMethod] + [DataRow(new[] { "-o=value" }, "=value", TestCommandLineStyle.Gnu, DisplayName = "[Gnu] -o=value (预期值为 '=value')")] + [DataRow(new[] { "-o:value" }, ":value", TestCommandLineStyle.Gnu, DisplayName = "[Gnu] -o:value (预期值为 ':value')")] + public void GnuDoesNotSupportShortOptionSeparator(string[] args, string expectedValue, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var options = commandLine.As(); + + // Assert + Assert.AreEqual(expectedValue, options.Option); + } + + [TestMethod] + [DataRow(new[] { "-ovalue" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] -ovalue")] + [DataRow(new[] { "/ovalue" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] /ovalue")] + [DataRow(new[] { "-ovalue" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] -ovalue")] + [DataRow(new[] { "-ovalue" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -ovalue")] + [DataRow(new[] { "/ovalue" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] /ovalue")] + public void DoesNotSupportShortOptionWithoutSeparator(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var exception = Assert.ThrowsExactly(() => commandLine.As()); + + // Assert + Assert.AreEqual(CommandLineParsingError.OptionalArgumentNotFound, exception.Reason); + } + + [TestMethod] + [DataRow(new[] { "test://?o=value" }, TestCommandLineStyle.Url, DisplayName = "[Url] o=value")] + public void UrlStyleDoesNotSupportShortOption(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var exception = Assert.ThrowsExactly(() => commandLine.As()); + + // Assert + Assert.AreEqual(CommandLineParsingError.OptionalArgumentNotFound, exception.Reason); + Assert.Contains("URL", exception.Message); + } + + [TestMethod] + [DataRow(new[] { "-ab", "value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] -ab value")] + [DataRow(new[] { "/ab", "value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] /ab value")] + [DataRow(new[] { "-ab", "value" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] -ab value")] + [DataRow(new[] { "-ab", "value" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -ab value")] + [DataRow(new[] { "/ab", "value" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] /ab value")] + [DataRow(new[] { "-ab=value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] -ab value")] + [DataRow(new[] { "/ab=value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] /ab value")] + [DataRow(new[] { "-ab=value" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] -ab value")] + [DataRow(new[] { "-ab=value" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -ab value")] + [DataRow(new[] { "/ab=value" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] /ab value")] + [DataRow(new[] { "-ab:value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] -ab value")] + [DataRow(new[] { "/ab:value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] /ab value")] + [DataRow(new[] { "-ab:value" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] -ab value")] + [DataRow(new[] { "-ab:value" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -ab value")] + [DataRow(new[] { "/ab:value" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] /ab value")] + public void SupportMultiCharShortOptions(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var options = commandLine.As(); + + // Assert + Assert.AreEqual("value", options.OptionA); + Assert.IsNull(options.OptionB); + } + + [TestMethod] + [DataRow(new[] { "-ab", "value" }, TestCommandLineStyle.Gnu, DisplayName = "[Flexible] -ab value")] + public void DoesNotSupportMultiCharShortOptions(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var exception = Assert.ThrowsExactly(() => commandLine.As()); + + // Assert + Assert.AreEqual(CommandLineParsingError.OptionalArgumentNotFound, exception.Reason); + } + + [TestMethod] + [DataRow(new[] { "-ab=value" }, TestCommandLineStyle.Gnu, DisplayName = "[Flexible] -ab value")] + [DataRow(new[] { "-ab:value" }, TestCommandLineStyle.Gnu, DisplayName = "[Flexible] -ab value")] + public void DoesNotSupportMultiCharShortOptionsWithValue(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var exception = Assert.ThrowsExactly(() => commandLine.As()); + + // Assert + Assert.AreEqual(CommandLineParsingError.MultiCharShortOptionalArgumentNotSupported, exception.Reason); + } + + public record TestOptions + { + [Option('o', "option")] + public string? Option { get; set; } + } + + public record MultiCharShortOptions + { + [Option("ab", "option-ab")] + public string? OptionA { get; set; } + + [Option('b', "option-b")] + public string? OptionB { get; set; } + } +} diff --git a/tests/DotNetCampus.CommandLine.Tests/ParsingStyles/ParsingErrorTests.cs b/tests/DotNetCampus.CommandLine.Tests/ParsingStyles/ParsingErrorTests.cs new file mode 100644 index 00000000..d3e71cc6 --- /dev/null +++ b/tests/DotNetCampus.CommandLine.Tests/ParsingStyles/ParsingErrorTests.cs @@ -0,0 +1,78 @@ +using DotNetCampus.Cli.Compiler; +using DotNetCampus.Cli.Exceptions; +using DotNetCampus.Cli.Utils.Parsers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace DotNetCampus.Cli.Tests.ParsingStyles; + +[TestClass] +public class ParsingErrorTests +{ + [TestMethod] + [DataRow(new[] { "--=value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] --=value")] + [DataRow(new[] { "-=value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] -=value")] + [DataRow(new[] { "/=value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] /=value")] + [DataRow(new[] { "--:value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] --:value")] + [DataRow(new[] { "-:value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] -:value")] + [DataRow(new[] { "/:value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] /:value")] + [DataRow(new[] { "--=value" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] --=value")] + [DataRow(new[] { "--:value" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] --:value")] + [DataRow(new[] { "--=value" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] --=value")] + [DataRow(new[] { "-=value" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -=value")] + [DataRow(new[] { "/=value" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] /=value")] + [DataRow(new[] { "-:value" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -:value")] + [DataRow(new[] { "/:value" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] /:value")] + [DataRow(new[] { "test://?=value" }, TestCommandLineStyle.Url, DisplayName = "[Url] test://?=value")] + [DataRow(new[] { "-=value" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] -=value")] + [DataRow(new[] { "-:value" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] -:value")] + [DataRow(new[] { "-=value" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] -=value")] + public void EmptyOptionName(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var exception = Assert.ThrowsExactly(() => commandLine.As()); + + // Assert + Assert.AreEqual(CommandLineParsingError.OptionalArgumentParseError, exception.Reason); + } + + [TestMethod] + [DataRow(new[] { "" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible]")] + [DataRow(new[] { "" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet]")] + [DataRow(new[] { "" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu]")] + [DataRow(new[] { "" }, TestCommandLineStyle.Windows, DisplayName = "[Windows]")] + public void EmptyArgument(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var options = commandLine.As(); + + // Assert + Assert.AreEqual("", options.Value); + } + + [TestMethod] + [DataRow(new[] { "test:///" }, TestCommandLineStyle.Url, DisplayName = "[Url] test:///")] + [DataRow(new[] { "test:////" }, TestCommandLineStyle.Url, DisplayName = "[Url] test:////")] + public void UrlManySlash(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var options = commandLine.As(); + + // Assert + Assert.IsNull(options.Value); + } + + public record TestOptions + { + [Value(0)] + public string? Value { get; set; } + } +} diff --git a/tests/DotNetCampus.CommandLine.Tests/ParsingStyles/ParsingNotFoundTests.cs b/tests/DotNetCampus.CommandLine.Tests/ParsingStyles/ParsingNotFoundTests.cs new file mode 100644 index 00000000..87b1fb6c --- /dev/null +++ b/tests/DotNetCampus.CommandLine.Tests/ParsingStyles/ParsingNotFoundTests.cs @@ -0,0 +1,93 @@ +using DotNetCampus.Cli.Compiler; +using DotNetCampus.Cli.Exceptions; +using DotNetCampus.Cli.Utils.Parsers; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using static DotNetCampus.Cli.Tests.TestCommandLineStyle; + +namespace DotNetCampus.Cli.Tests.ParsingStyles; + +[TestClass] +public class OptionNotFoundTests +{ + [TestMethod] + [DataRow(new[] { "--not-exist", "--option", "value" }, Flexible, DisplayName = "[Flexible] --not-exist --option value")] + [DataRow(new[] { "--not-exist", "--option", "value" }, DotNet, DisplayName = "[DotNet] --not-exist --option value")] + [DataRow(new[] { "--not-exist", "--option", "value" }, Gnu, DisplayName = "[Gnu] --not-exist --option value")] + [DataRow(new[] { "-NotExist", "-Option", "value" }, Windows, DisplayName = "[Windows] -NotExist -Option value")] + [DataRow(new[] { "--not-exist", "test", "--option", "value" }, Flexible, DisplayName = "[Flexible] --not-exist test --option value")] + [DataRow(new[] { "--not-exist", "test", "--option", "value" }, DotNet, DisplayName = "[DotNet] --not-exist test --option value")] + [DataRow(new[] { "--not-exist", "test", "--option", "value" }, Gnu, DisplayName = "[Gnu] --not-exist test --option value")] + [DataRow(new[] { "-NotExist", "test", "-Option", "value" }, Windows, DisplayName = "[Windows] -NotExist test -Option value")] + [DataRow(new[] { "--not-exist", "test1", "test2", "--option", "value" }, Flexible, DisplayName = "[Flexible] --not-exist test --option value")] + [DataRow(new[] { "--not-exist", "test1", "test2", "--option", "value" }, DotNet, DisplayName = "[DotNet] --not-exist test --option value")] + [DataRow(new[] { "--not-exist", "test1", "test2", "--option", "value" }, Gnu, DisplayName = "[Gnu] --not-exist test --option value")] + [DataRow(new[] { "-NotExist", "test1", "test2", "-Option", "value" }, Windows, DisplayName = "[Windows] -NotExist test -Option value")] + [DataRow(new[] { "--not-exist", "a=b,c;d", "--option", "value" }, Flexible, DisplayName = "[Flexible] --not-exist test --option value")] + [DataRow(new[] { "--not-exist", "a=b,c;d", "--option", "value" }, DotNet, DisplayName = "[DotNet] --not-exist test --option value")] + [DataRow(new[] { "--not-exist", "a=b,c;d", "--option", "value" }, Gnu, DisplayName = "[Gnu] --not-exist test --option value")] + [DataRow(new[] { "-NotExist", "a=b,c;d", "-Option", "value" }, Windows, DisplayName = "[Windows] -NotExist test -Option value")] + public void OptionNotFound_IgnoreAllUnknownArguments(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions() with + { + UnknownArgumentsHandling = UnknownCommandArgumentHandling.IgnoreAllUnknownArguments, + }); + + // Act + var options = commandLine.As(); + + // Assert + Assert.AreEqual("value", options.Option); + } + + [TestMethod] + [DataRow(new[] { "--not-exist=test1", "test2", "--option", "value" }, Flexible, DisplayName = "[Flexible] --not-exist test --option value")] + [DataRow(new[] { "--not-exist=test1", "test2", "--option", "value" }, DotNet, DisplayName = "[DotNet] --not-exist test --option value")] + [DataRow(new[] { "--not-exist=test1", "test2", "--option", "value" }, Gnu, DisplayName = "[Gnu] --not-exist test --option value")] + [DataRow(new[] { "-NotExist=test1", "test2", "-Option", "value" }, Windows, DisplayName = "[Windows] -NotExist test -Option value")] + [DataRow(new[] { "--not-exist", "test1", "test2", "--option", "value" }, Flexible, DisplayName = "[Flexible] --not-exist test --option value")] + [DataRow(new[] { "--not-exist", "test1", "test2", "--option", "value" }, DotNet, DisplayName = "[DotNet] --not-exist test --option value")] + [DataRow(new[] { "--not-exist", "test1", "test2", "--option", "value" }, Gnu, DisplayName = "[Gnu] --not-exist test --option value")] + [DataRow(new[] { "-NotExist", "test1", "test2", "-Option", "value" }, Windows, DisplayName = "[Windows] -NotExist test -Option value")] + public void OptionNotFound_IgnoreUnknownOptionalArguments(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions() with + { + UnknownArgumentsHandling = UnknownCommandArgumentHandling.IgnoreUnknownOptionalArguments, + }); + + // Act + var exception = Assert.ThrowsExactly(() => commandLine.As()); + + // Assert + Assert.AreEqual(CommandLineParsingError.PositionalArgumentNotFound, exception.Reason); + } + + [TestMethod] + [DataRow(new[] { "--not-exist", "test1", "test2", "--option", "value" }, Flexible, DisplayName = "[Flexible] --not-exist test --option value")] + [DataRow(new[] { "--not-exist", "test1", "test2", "--option", "value" }, DotNet, DisplayName = "[DotNet] --not-exist test --option value")] + [DataRow(new[] { "--not-exist", "test1", "test2", "--option", "value" }, Gnu, DisplayName = "[Gnu] --not-exist test --option value")] + [DataRow(new[] { "-NotExist", "test1", "test2", "-Option", "value" }, Windows, DisplayName = "[Windows] -NotExist test -Option value")] + public void OptionNotFound_IgnoreUnknownPositionalArguments(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions() with + { + UnknownArgumentsHandling = UnknownCommandArgumentHandling.IgnoreUnknownPositionalArguments, + }); + + // Act + var exception = Assert.ThrowsExactly(() => commandLine.As()); + + // Assert + Assert.AreEqual(CommandLineParsingError.OptionalArgumentNotFound, exception.Reason); + } + + public record TestOptions + { + [Option('o', "option")] + public string? Option { get; set; } + } +} diff --git a/tests/DotNetCampus.CommandLine.Tests/ParsingStyles/PositionalArgumentTests.cs b/tests/DotNetCampus.CommandLine.Tests/ParsingStyles/PositionalArgumentTests.cs new file mode 100644 index 00000000..093dd892 --- /dev/null +++ b/tests/DotNetCampus.CommandLine.Tests/ParsingStyles/PositionalArgumentTests.cs @@ -0,0 +1,179 @@ +using System.Collections; +using System.Collections.Generic; +using DotNetCampus.Cli.Compiler; +using DotNetCampus.Cli.Exceptions; +using DotNetCampus.Cli.Utils.Parsers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace DotNetCampus.Cli.Tests.ParsingStyles; + +[TestClass] +public class PositionalArgumentTests +{ + [TestMethod] + [DataRow(new[] { "value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] value")] + [DataRow(new[] { "value" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] value")] + [DataRow(new[] { "value" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] value")] + [DataRow(new[] { "value" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] value")] + [DataRow(new[] { "-o", "option", "value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] -o option value")] + [DataRow(new[] { "-o", "option", "value" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] -o option value")] + [DataRow(new[] { "-o", "option", "value" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] -o option value")] + [DataRow(new[] { "-o", "option", "value" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -o option value")] + [DataRow(new[] { "value", "-o", "option" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] value -o option")] + [DataRow(new[] { "value", "-o", "option" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] value -o option")] + [DataRow(new[] { "value", "-o", "option" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] value -o option")] + [DataRow(new[] { "value", "-o", "option" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] value -o option")] + [DataRow(new[] { "-o", "option", "--", "value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] -o option -- value")] + [DataRow(new[] { "-o", "option", "--", "value" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] -o option -- value")] + [DataRow(new[] { "-o", "option", "--", "value" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] -o option -- value")] + [DataRow(new[] { "test://value?option=option" }, TestCommandLineStyle.Url, DisplayName = "[Url] test://value?option=option")] + public void Supported(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var options = commandLine.As(); + + // Assert + Assert.AreEqual("value", options.Value); + } + + [TestMethod] + [DataRow(new[] { "-o", "true", "value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] -o true value")] + [DataRow(new[] { "-o", "true", "value" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] -o true value")] + [DataRow(new[] { "-o", "true", "value" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -o true value")] + [DataRow(new[] { "-o", "value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] -o value")] + [DataRow(new[] { "-o", "value" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] -o value")] + [DataRow(new[] { "-o", "value" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] -o value")] + [DataRow(new[] { "-o", "value" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -o value")] + public void Supported_Boolean(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var options = commandLine.As(); + + // Assert + Assert.IsTrue(options.Option); + Assert.AreEqual("value", options.Value); + } + + [TestMethod] + [DataRow(new[] { "-o", "option", "--", "value" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -o option -- value")] + public void DoesNotSupportPostPositionalArguments(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var exception = Assert.ThrowsExactly(() => commandLine.As()); + + // Assert + Assert.AreEqual(CommandLineParsingError.OptionalArgumentNotFound, exception.Reason); + } + + [TestMethod] + [DataRow(new[] { "-o", "true", "value" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] -o true value")] + public void DoesNotMatchPositionalArgumentRange_Boolean(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var exception = Assert.ThrowsExactly(() => commandLine.As()); + + // Assert + Assert.AreEqual(CommandLineParsingError.PositionalArgumentNotFound, exception.Reason); + } + + [TestMethod] + [DataRow(new[] { "value", "-o", "a", "b" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] value -o a b")] + [DataRow(new[] { "value", "-o", "a", "b" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] value -o a b")] + [DataRow(new[] { "value", "-o", "a", "b" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] value -o a b")] + [DataRow(new[] { "value", "-o", "a", "b" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] value -o a b")] + [DataRow(new[] { "-o", "a", "b", "--", "value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] -o a b -- value")] + [DataRow(new[] { "-o", "a", "b", "--", "value" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] -o a b -- value")] + [DataRow(new[] { "-o", "a", "b", "--", "value" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] -o a b -- value")] + [DataRow(new[] { "-o", "a", "b", "value" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] -o a b value")] + [DataRow(new[] { "-o", "a", "b", "value" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] -o a b value")] + [DataRow(new[] { "-o", "a", "b", "value" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] -o a b value")] + [DataRow(new[] { "-o", "a", "b", "value" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -o a b value")] + public void DoesNotMatchPositionalArgumentRange_Collection(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var exception = Assert.ThrowsExactly(() => commandLine.As()); + + // Assert + Assert.AreEqual(CommandLineParsingError.PositionalArgumentNotFound, exception.Reason); + } + + [TestMethod] + [DataRow(new[] { "a", "b", "c", "d", "e", "f" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] a b c d e f")] + [DataRow(new[] { "-o", "value", "a", "b", "c", "d", "e", "f" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] -o value a b c d e f")] + [DataRow(new[] { "a", "b", "c", "d", "e", "f", "-o", "value" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] a b c d e f -o value")] + [DataRow(new[] { "-o", "value", "a", "b", "c", "d", "e", "f", "--" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] -o value a b c d e f --")] + [DataRow(new[] { "a", "b", "c", "d", "e", "f", "-o", "value", "--" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] a b c d e f -o value --")] + [DataRow(new[] { "-o", "value", "--", "a", "b", "c", "d", "e", "f" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] -o value -- a b c d e f")] + [DataRow(new[] { "a", "b", "-o", "value", "c", "d", "e", "f" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] a b -o value c d e f")] + [DataRow(new[] { "a", "b", "c", "-o", "value", "d", "e", "f" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] a b c -o value d e f")] + public void MatchPositionalArgumentRange(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var options = commandLine.As(); + + // Assert + CollectionAssert.AreEqual(new[] { "a", "b" }, (ICollection)options.Value0!); + Assert.AreEqual("c", options.Value1); + CollectionAssert.AreEqual(new[] { "d", "e", "f" }, (ICollection)options.Value2!); + } + + public record TestOptions + { + [Option('o', "option")] + public string? Option { get; set; } + + [Value(0)] + public string? Value { get; set; } + } + + public record BooleanTestOptions + { + [Option('o', "option")] + public bool? Option { get; set; } + + [Value(0)] + public string? Value { get; set; } + } + + public record CollectionTestOptions + { + [Option('o', "option")] + public IReadOnlyList? Option { get; set; } + + [Value(0)] + public string? Value { get; set; } + } + + public record MultiplePositionArgumentsOptions + { + [Option('o', "option")] + public string? Option { get; set; } + + [Value(0, 2)] + public IReadOnlyList? Value0 { get; set; } + + [Value(2)] + public string? Value1 { get; set; } + + [Value(3, int.MaxValue)] + public IReadOnlyList? Value2 { get; set; } + } +} diff --git a/tests/DotNetCampus.CommandLine.Tests/ParsingStyles/PropertyTypeTests.cs b/tests/DotNetCampus.CommandLine.Tests/ParsingStyles/PropertyTypeTests.cs new file mode 100644 index 00000000..47a2fab1 --- /dev/null +++ b/tests/DotNetCampus.CommandLine.Tests/ParsingStyles/PropertyTypeTests.cs @@ -0,0 +1,264 @@ +using System.Collections; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Collections.ObjectModel; +using System.Linq; +using DotNetCampus.Cli.Compiler; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +// ReSharper disable InconsistentNaming + +namespace DotNetCampus.Cli.Tests.ParsingStyles; + +[TestClass] +public class PropertyTypeTests +{ + [TestMethod] + public void SupportManyTypes() + { + // Arrange + string[] args = + [ + "--boolean-property", "true", + "--byte-property", "1", + "--sbyte-property", "1", + "--decimal-property", "1.1", + "--double-property", "1.1", + "--single-property", "1.1", + "--int32-property", "1", + "--uint32-property", "1", + "--int64-property", "1", + "--uint64-property", "1", + "--int16-property", "1", + "--uint16-property", "1", + "--char-property", "a", + "--string-property", "value", + "--array-property", "a,b,c", + "--list-property", "a,b,c", + "--collection-property", "a,b,c", + "--read-only-collection-property", "a,b,c", + "--hash-set-property", "a,b,c", + "--immutable-array-property", "a,b,c", + "--immutable-list-property", "a,b,c", + "--immutable-hash-set-property", "a,b,c", + "--sorted-set-property", "a,b,c", + "--immutable-sorted-set-property", "a,b,c", + "--ienumerable-property", "a,b,c", + "--icollection-property", "a,b,c", + "--ilist-property", "a,b,c", + "--iread-only-collection-property", "a,b,c", + "--iread-only-list-property", "a,b,c", + "--iset-property", "a,b,c", + "--iimmutable-list-property", "a,b,c", + "--iimmutable-set-property", "a,b,c", + "--key-value-pair-property", "key=value", + "--dictionary-property", "key=value,key2=value2", + "--immutable-dictionary-property", "key=value,key2=value2", + "--sorted-dictionary-property", "key=value,key2=value2", + "--immutable-sorted-dictionary-property", "key=value,key2=value2", + "--idictionary-property", "key=value,key2=value2", + "--iread-only-dictionary-property", "key=value,key2=value2", + "--enum-property", "ValueB", + ]; + var commandLine = CommandLine.Parse(args, CommandLineParsingOptions.DotNet); + + // Act + var options = commandLine.As(); + + // Assert + Assert.IsNotNull(options); + Assert.AreEqual(true, options.BooleanProperty); + Assert.AreEqual((byte)1, options.ByteProperty); + Assert.AreEqual((sbyte)1, options.SByteProperty); + Assert.AreEqual(1.1m, options.DecimalProperty); + Assert.AreEqual(1.1, options.DoubleProperty); + Assert.AreEqual(1.1f, options.SingleProperty); + Assert.AreEqual(1, options.Int32Property); + Assert.AreEqual((uint)1, options.UInt32Property); + Assert.AreEqual(1, options.Int64Property); + Assert.AreEqual((ulong)1, options.UInt64Property); + Assert.AreEqual((short)1, options.Int16Property); + Assert.AreEqual((ushort)1, options.UInt16Property); + Assert.AreEqual('a', options.CharProperty); + Assert.AreEqual("value", options.StringProperty); + CollectionAssert.AreEqual(new[] { "a", "b", "c" }, options.ArrayProperty); + CollectionAssert.AreEqual(new[] { "a", "b", "c" }, options.ListProperty!); + CollectionAssert.AreEqual(new[] { "a", "b", "c" }, options.CollectionProperty!); + CollectionAssert.AreEqual(new[] { "a", "b", "c" }, options.ReadOnlyCollectionProperty!); + CollectionAssert.AreEquivalent(new[] { "a", "b", "c" }, options.HashSetProperty!.ToArray()); + CollectionAssert.AreEqual(new[] { "a", "b", "c" }, options.ImmutableArrayProperty.ToArray()); + CollectionAssert.AreEqual(new[] { "a", "b", "c" }, options.ImmutableListProperty!); + CollectionAssert.AreEquivalent(new[] { "a", "b", "c" }, options.ImmutableHashSetProperty!.ToArray()); + CollectionAssert.AreEquivalent(new[] { "a", "b", "c" }, options.SortedSetProperty!.ToArray()); + CollectionAssert.AreEquivalent(new[] { "a", "b", "c" }, options.ImmutableSortedSetProperty!.ToArray()); + CollectionAssert.AreEqual(new[] { "a", "b", "c" }, (ICollection)options.IEnumerableProperty!); + CollectionAssert.AreEqual(new[] { "a", "b", "c" }, (ICollection)options.ICollectionProperty!); + CollectionAssert.AreEqual(new[] { "a", "b", "c" }, (ICollection)options.IListProperty!); + CollectionAssert.AreEqual(new[] { "a", "b", "c" }, (ICollection)options.IReadOnlyCollectionProperty!); + CollectionAssert.AreEqual(new[] { "a", "b", "c" }, (ICollection)options.IReadOnlyListProperty!); + CollectionAssert.AreEquivalent(new[] { "a", "b", "c" }, options.ISetProperty!.ToArray()); + CollectionAssert.AreEqual(new[] { "a", "b", "c" }, (ICollection)options.IImmutableListProperty!); + CollectionAssert.AreEquivalent(new[] { "a", "b", "c" }, options.IImmutableSetProperty!.ToArray()); + Assert.AreEqual(new KeyValuePair("key", "value"), options.KeyValuePairProperty); + CollectionAssert.AreEquivalent(new Dictionary + { + ["key"] = "value", + ["key2"] = "value2", + }, options.DictionaryProperty!); + CollectionAssert.AreEquivalent(new Dictionary + { + ["key"] = "value", + ["key2"] = "value2", + }, options.ImmutableDictionaryProperty!); + CollectionAssert.AreEquivalent(new Dictionary + { + ["key"] = "value", + ["key2"] = "value2", + }, options.SortedDictionaryProperty!); + CollectionAssert.AreEquivalent(new Dictionary + { + ["key"] = "value", + ["key2"] = "value2", + }, options.ImmutableSortedDictionaryProperty!); + CollectionAssert.AreEquivalent(new Dictionary + { + ["key"] = "value", + ["key2"] = "value2", + }, (ICollection)options.IDictionaryProperty!); + CollectionAssert.AreEquivalent(new Dictionary + { + ["key"] = "value", + ["key2"] = "value2", + }, (ICollection)options.IReadOnlyDictionaryProperty!); + Assert.AreEqual(TestEnum.ValueB, options.EnumProperty); + } + + public record AllInOneTestOptions + { + [Option] + public bool? BooleanProperty { get; set; } + + [Option] + public byte? ByteProperty { get; set; } + + [Option] + public sbyte? SByteProperty { get; set; } + + [Option] + public decimal? DecimalProperty { get; set; } + + [Option] + public double? DoubleProperty { get; set; } + + [Option] + public float? SingleProperty { get; set; } + + [Option] + public int? Int32Property { get; set; } + + [Option] + public uint? UInt32Property { get; set; } + + [Option] + public long? Int64Property { get; set; } + + [Option] + public ulong? UInt64Property { get; set; } + + [Option] + public short? Int16Property { get; set; } + + [Option] + public ushort? UInt16Property { get; set; } + + [Option] + public char? CharProperty { get; set; } + + [Option] + public string? StringProperty { get; set; } + + [Option] + public string[]? ArrayProperty { get; set; } + + [Option] + public List? ListProperty { get; set; } + + [Option] + public Collection? CollectionProperty { get; set; } + + [Option] + public ReadOnlyCollection? ReadOnlyCollectionProperty { get; set; } + + [Option] + public HashSet? HashSetProperty { get; set; } + + [Option] + public ImmutableArray ImmutableArrayProperty { get; set; } + + [Option] + public ImmutableList? ImmutableListProperty { get; set; } + + [Option] + public ImmutableHashSet? ImmutableHashSetProperty { get; set; } + + [Option] + public SortedSet? SortedSetProperty { get; set; } + + [Option] + public ImmutableSortedSet? ImmutableSortedSetProperty { get; set; } + + [Option] + public IEnumerable? IEnumerableProperty { get; set; } + + [Option] + public ICollection? ICollectionProperty { get; set; } + + [Option] + public IList? IListProperty { get; set; } + + [Option] + public IReadOnlyCollection? IReadOnlyCollectionProperty { get; set; } + + [Option] + public IReadOnlyList? IReadOnlyListProperty { get; set; } + + [Option] + public ISet? ISetProperty { get; set; } + + [Option] + public IImmutableList? IImmutableListProperty { get; set; } + + [Option] + public IImmutableSet? IImmutableSetProperty { get; set; } + + [Option] + public KeyValuePair? KeyValuePairProperty { get; set; } + + [Option] + public Dictionary? DictionaryProperty { get; set; } + + [Option] + public ImmutableDictionary? ImmutableDictionaryProperty { get; set; } + + [Option] + public SortedDictionary? SortedDictionaryProperty { get; set; } + + [Option] + public ImmutableSortedDictionary? ImmutableSortedDictionaryProperty { get; set; } + + [Option] + public IDictionary? IDictionaryProperty { get; set; } + + [Option] + public IReadOnlyDictionary? IReadOnlyDictionaryProperty { get; set; } + + [Option] + public TestEnum? EnumProperty { get; set; } + } + + public enum TestEnum + { + ValueA, + ValueB, + } +} diff --git a/tests/DotNetCampus.CommandLine.Tests/ParsingStyles/UrlCommandTests.cs b/tests/DotNetCampus.CommandLine.Tests/ParsingStyles/UrlCommandTests.cs new file mode 100644 index 00000000..a03564e4 --- /dev/null +++ b/tests/DotNetCampus.CommandLine.Tests/ParsingStyles/UrlCommandTests.cs @@ -0,0 +1,55 @@ +using DotNetCampus.Cli.Compiler; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace DotNetCampus.Cli.Tests.ParsingStyles; + +[TestClass] +public class UrlCommandTests +{ + [TestMethod] + // 空格 + [DataRow(new[] { "test://?option=value%20with%20space" }, "value with space", DisplayName = "[Uri] test://?option=value%20with%20space")] + // 特殊字符(# & % 等) + [DataRow(new[] { "test://?option=special%23chars%26more%25" }, "special#chars&more%", DisplayName = "[Uri] test://?option=special%23chars%26more%25")] + // 保留字符(/ ? : @ 等) + [DataRow(new[] { "test://?option=reserved%2Fchars%3F%3A%40" }, "reserved/chars?:@", DisplayName = "[Uri] test://?option=reserved%2Fchars%3F%3A%40")] + // 中文和其他非 ASCII 字符 + [DataRow(new[] { "test://?option=%E4%B8%AD%E6%96%87" }, "中文", DisplayName = "[Uri] test://?option=%E4%B8%AD%E6%96%87")] + // emoji 字符 + [DataRow(new[] { "test://?option=%F0%9F%98%81" }, "😁", DisplayName = "[Uri] test://?option=%F0%9F%98%81")] + public void Escape(string[] args, string expectedValue) + { + // Arrange + var commandLine = CommandLine.Parse(args, TestCommandLineStyle.Url.ToParsingOptions()); + + // Act + var options = commandLine.As(); + + // Assert + Assert.AreEqual(expectedValue, options.Option); + } + + [TestMethod] + [DataRow(new[] { "test://#anchor" }, "anchor", DisplayName = "[Uri] test://?option=value#anchor")] + [DataRow(new[] { "test://?option=value#anchor" }, "anchor", DisplayName = "[Uri] test://?option=value#anchor")] + public void Fragment(string[] args, string expectedValue) + { + // Arrange + var commandLine = CommandLine.Parse(args, TestCommandLineStyle.Url.ToParsingOptions()); + + // Act + var options = commandLine.As(); + + // Assert + Assert.AreEqual(expectedValue, options.Fragment); + } + + public record TestOptions + { + [Option('o', "option")] + public string? Option { get; set; } + + [Option("fragment")] + public string? Fragment { get; set; } + } +} diff --git a/tests/DotNetCampus.CommandLine.Tests/PosixCommandLineParserTests.cs b/tests/DotNetCampus.CommandLine.Tests/PosixCommandLineParserTests.cs deleted file mode 100644 index a2c04c2d..00000000 --- a/tests/DotNetCampus.CommandLine.Tests/PosixCommandLineParserTests.cs +++ /dev/null @@ -1,465 +0,0 @@ -using System.Threading.Tasks; -using DotNetCampus.Cli.Compiler; -using DotNetCampus.Cli.Exceptions; -using Microsoft.VisualStudio.TestTools.UnitTesting; - -// ReSharper disable UnusedAutoPropertyAccessor.Global -// ReSharper disable InconsistentNaming - -namespace DotNetCampus.Cli.Tests; - -/// -/// 测试POSIX风格命令行参数是否正确被解析。 -/// -[TestClass] -public class PosixCommandLineParserTests -{ - private CommandLineParsingOptions POSIX { get; } = CommandLineParsingOptions.Posix; - - #region 1. 基本短选项解析 - - [TestMethod("1.1. 单个短选项,字符串类型,可正常赋值。")] - public void SingleShortOption_StringType_ValueAssigned() - { - // Arrange - string[] args = ["-v", "test"]; - string? value = null; - - // Act - CommandLine.Parse(args, POSIX) - .AddHandler(o => value = o.Value) - .Run(); - - // Assert - Assert.AreEqual("test", value); - } - - [TestMethod("1.2. 带参数的短选项,数值类型,可正常赋值。")] - public void ShortOptionWithValue_IntType_ValueAssigned() - { - // Arrange - string[] args = ["-n", "42"]; - int? number = null; - - // Act - CommandLine.Parse(args, POSIX) - .AddHandler(o => number = o.Number) - .Run(); - - // Assert - Assert.AreEqual(42, number); - } - - [TestMethod("1.3. 多个短选项,全部正确解析。")] - public void MultipleShortOptions_AllParsed() - { - // Arrange - string[] args = ["-v", "text", "-n", "42", "-f"]; - string? value = null; - int? number = null; - bool? flag = null; - - // Act - CommandLine.Parse(args, POSIX) - .AddHandler(o => - { - value = o.Value; - number = o.Number; - flag = o.Flag; - }) - .Run(); - - // Assert - Assert.AreEqual("text", value); - Assert.AreEqual(42, number); - Assert.IsTrue(flag); - } - - [TestMethod("1.4. 短选项无空格跟参数 (不支持) 。")] - public void ShortOptionNoSpace_NotSupported_ThrowsException() - { - // Arrange - string[] args = ["-vtest.txt"]; - - // Act & Assert - Assert.ThrowsExactly(() => - { - CommandLine.Parse(args, POSIX) - .AddHandler(_ => { }) - .Run(); - }); - } - - #endregion - - #region 2. 组合短选项 - - [TestMethod("2.1. 组合布尔短选项,全部正确解析。")] - public void CombinedShortOptions_BooleanFlags_AllAssigned() - { - // Arrange - string[] args = ["-abc"]; - bool? optionA = null; - bool? optionB = null; - bool? optionC = null; - - // Act - CommandLine.Parse(args, POSIX) - .AddHandler(o => - { - optionA = o.OptionA; - optionB = o.OptionB; - optionC = o.OptionC; - }) - .Run(); - - // Assert - Assert.IsTrue(optionA); - Assert.IsTrue(optionB); - Assert.IsTrue(optionC); - } - - [TestMethod("2.2. 组合短选项中,最后一个带参数会抛异常。")] - public void CombinedShortOptions_LastWithParam_ThrowsException() - { - // Arrange - string[] args = ["-abc", "value"]; - - // Act & Assert - Assert.ThrowsExactly(() => - { - CommandLine.Parse(args, POSIX) - .AddHandler(_ => { }) - .Run(); - }); - } - - #endregion - - #region 3. 选项终止符(--) - - [TestMethod("3.1. 终止符后的参数被当作位置参数处理。")] - public void OptionTerminator_FollowingArgsAreValues() - { - // Arrange - string[] args = ["-o", "value", "--", "-x", "-y"]; - string? option = null; - string[]? values = null; - - // Act - CommandLine.Parse(args, POSIX) - .AddHandler(o => - { - option = o.Option; - values = o.Values; - }) - .Run(); - - // Assert - Assert.AreEqual("value", option); - Assert.IsNotNull(values); - Assert.AreEqual(2, values.Length); - Assert.AreEqual("-x", values[0]); - Assert.AreEqual("-y", values[1]); - } - - #endregion - - #region 4. 位置参数处理 - - [TestMethod("4.1. 单个位置参数,赋值成功。")] - public void SinglePositionalValue_ValueAssigned() - { - // Arrange - string[] args = ["positional-value"]; - string? value = null; - - // Act - CommandLine.Parse(args, POSIX) - .AddHandler(o => value = o.Value) - .Run(); - - // Assert - Assert.AreEqual("positional-value", value); - } - - [TestMethod("4.2. 多个位置参数,赋值成功。")] - public void MultiplePositionalValues_AllAssigned() - { - // Arrange - string[] args = ["value1", "value2", "value3"]; - string[]? values = null; - - // Act - CommandLine.Parse(args, POSIX) - .AddHandler(o => values = o.Values) - .Run(); - - // Assert - Assert.IsNotNull(values); - Assert.AreEqual(3, values.Length); - CollectionAssert.AreEqual(new[] { "value1", "value2", "value3" }, values); - } - - [TestMethod("4.3. 位置参数与选项混合,识别正确。")] - public void MixedPositionalAndOptions_AllParsedCorrectly() - { - // Arrange - string[] args = ["value1", "-o", "opt-val", "value2"]; - string? option = null; - string? value1 = null; - string? value2 = null; - - // Act - CommandLine.Parse(args, POSIX) - .AddHandler(o => - { - option = o.Option; - value1 = o.Value1; - value2 = o.Value2; - }) - .Run(); - - // Assert - Assert.AreEqual("opt-val", option); - Assert.AreEqual("value1", value1); - Assert.AreEqual("value2", value2); - } - - #endregion - - #region 5. 边界情况测试 - - [TestMethod("5.1. 缺失必需选项,抛出异常。")] - public void MissingRequiredOption_ThrowsException() - { - // Arrange - string[] args = []; - - // Act & Assert - Assert.ThrowsExactly(() => - { - CommandLine.Parse(args, POSIX) - .AddHandler(_ => { }) - .Run(); - }); - } - - [TestMethod("5.2. 无效选项格式,抛出异常。")] - public void InvalidOption_ThrowsException() - { - // Arrange - string[] args = ["-invalid-format"]; - - // Act & Assert - Assert.ThrowsExactly(() => - { - CommandLine.Parse(args, POSIX) - .AddHandler(_ => { }) - .Run(); - }); - } - - [TestMethod("5.3. 类型不匹配,抛出异常。")] - public void TypeMismatch_ThrowsException() - { - // Arrange - string[] args = ["-n", "not-a-number"]; - - // Act & Assert - Assert.ThrowsExactly(() => - { - CommandLine.Parse(args, POSIX) - .AddHandler(_ => { }) - .Run(); - }); - } - - [TestMethod("5.4. 不允许长选项,抛出异常。")] - public void LongOption_NotSupported_ThrowsException() - { - // Arrange - string[] args = ["--option", "value"]; - - // Act & Assert - Assert.ThrowsExactly(() => - { - CommandLine.Parse(args, POSIX) - .AddHandler(_ => { }) - .Run(); - }); - } - - #endregion - - #region 6. 异步处理测试 - - [TestMethod("6.1. 异步处理方法,正确执行。")] - public async Task AsyncHandler_ExecutesCorrectly() - { - // Arrange - string[] args = ["-v", "async-test"]; - string? value = null; - - // Act - await CommandLine.Parse(args, POSIX) - .AddHandler(async o => - { - await Task.Delay(10); // 模拟异步操作 - value = o.Value; - }) - .RunAsync(); - - // Assert - Assert.AreEqual("async-test", value); - } - - #endregion - - #region 7. 列表参数测试 - - [TestMethod("7.1. 多次指定同一选项形成列表")] - public void MultipleOptions_FormList() - { - // Arrange - string[] args = ["-f", "file1.txt", "-f", "file2.txt", "-f", "file3.txt"]; - string[]? files = null; - - // Act - CommandLine.Parse(args, POSIX) - .AddHandler(o => files = o.Files) - .Run(); - - // Assert - Assert.IsNotNull(files); - Assert.AreEqual(3, files.Length); - CollectionAssert.AreEqual(new[] { "file1.txt", "file2.txt", "file3.txt" }, files); - } - - [TestMethod("7.2. 带引号的列表参数")] - public void QuotedArrayElements() - { - // Arrange - string[] args = ["-f", "\"file with spaces.txt\"", "-f", "normal.txt", "-f", "\"another file.txt\""]; - string[]? files = null; - - // Act - CommandLine.Parse(args, POSIX) - .AddHandler(o => - { - files = o.Files; - return 0; - }) - .Run(); - - // Assert - Assert.IsNotNull(files); - Assert.AreEqual(3, files.Length); - CollectionAssert.AreEqual(new[] { "file with spaces.txt", "normal.txt", "another file.txt" }, files); - } - - #endregion -} - -#region 测试用数据模型 - -internal record POSIX01_ShortOptions -{ - [Option('v')] - public required string Value { get; init; } -} - -internal record POSIX02_IntegerOptions -{ - [Option('n')] - public int Number { get; init; } -} - -internal record POSIX03_MixedOptions -{ - [Option('v')] - public required string Value { get; init; } - - [Option('n')] - public int Number { get; init; } - - [Option('f')] - public bool Flag { get; init; } -} - -internal record POSIX04_CombinedOptions -{ - [Option('a')] - public bool OptionA { get; init; } - - [Option('b')] - public bool OptionB { get; init; } - - [Option('c')] - public bool OptionC { get; init; } -} - -internal record POSIX05_CombinedWithValueOptions -{ - [Option('a')] - public bool OptionA { get; init; } - - [Option('b')] - public bool OptionB { get; init; } - - [Option('c')] - public required string OptionC { get; init; } -} - -internal record POSIX06_TerminatorOptions -{ - [Option('o')] - public string Option { get; init; } = string.Empty; - - [Value(Length = int.MaxValue)] - public string[] Values { get; init; } = []; -} - -internal record POSIX07_SingleValueOptions -{ - [Value] - public string Value { get; init; } = string.Empty; -} - -internal record POSIX08_MultipleValueOptions -{ - [Value(Length = int.MaxValue)] - public string[] Values { get; init; } = []; -} - -internal record POSIX09_MixedValueOptions -{ - [Value(0)] - public string Value1 { get; init; } = string.Empty; - - [Option('o')] - public string Option { get; init; } = string.Empty; - - [Value(1)] - public string Value2 { get; init; } = string.Empty; -} - -internal record POSIX10_RequiredOptions -{ - [Option('r')] - public required string RequiredValue { get; init; } -} - -internal record POSIX11_LongOptionTest -{ - [Option("option")] // 这个会被POSIX风格拒绝,因为POSIX不支持长选项 - public string LongOption { get; init; } = string.Empty; -} - -internal record POSIX12_ArrayOptions -{ - [Option('f')] - public string[] Files { get; init; } = []; -} - -#endregion diff --git a/tests/DotNetCampus.CommandLine.Tests/PowerShellCommandLineParserTests.cs b/tests/DotNetCampus.CommandLine.Tests/PowerShellCommandLineParserTests.cs deleted file mode 100644 index af8068bc..00000000 --- a/tests/DotNetCampus.CommandLine.Tests/PowerShellCommandLineParserTests.cs +++ /dev/null @@ -1,662 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using DotNetCampus.Cli.Compiler; -using DotNetCampus.Cli.Exceptions; -using Microsoft.VisualStudio.TestTools.UnitTesting; - -// ReSharper disable UnusedAutoPropertyAccessor.Global -// ReSharper disable InconsistentNaming - -namespace DotNetCampus.Cli.Tests; - -/// -/// 测试PowerShell风格命令行参数是否正确被解析到了。 -/// -[TestClass] -public class PowerShellCommandLineParserTests -{ - private CommandLineParsingOptions PowerShell { get; } = CommandLineParsingOptions.PowerShell; - - #region 1. 基本参数解析 - - [TestMethod("1.1. 单个参数解析,字符串类型,Pascal命名。")] - public void SingleParameter_StringType_PascalNaming() - { - // Arrange - string[] args = ["-Name", "test"]; - string? name = null; - - // Act - CommandLine.Parse(args, PowerShell) - .AddHandler(o => name = o.Name) - .Run(); - - // Assert - Assert.AreEqual("test", name); - } - - [TestMethod("1.2. 多个参数解析,混合类型。")] - public void MultipleParameters_MixedTypes() - { - // Arrange - string[] args = ["-Path", "C:\\temp", "-ItemType", "Directory", "-Force"]; - string? path = null; - string? itemType = null; - bool? force = null; - - // Act - CommandLine.Parse(args, PowerShell) - .AddHandler(o => - { - path = o.Path; - itemType = o.ItemType; - force = o.Force; - }) - .Run(); - - // Assert - Assert.AreEqual("C:\\temp", path); - Assert.AreEqual("Directory", itemType); - Assert.IsTrue(force); - } - - [TestMethod("1.3. 参数名使用Camel命名。")] - public void Parameter_CamelCase_ValueAssigned() - { - // Arrange - string[] args = ["-fileName", "document.txt"]; - string? fileName = null; - - // Act - CommandLine.Parse(args, PowerShell) - .AddHandler(o => fileName = o.fileName) - .Run(); - - // Assert - Assert.AreEqual("document.txt", fileName); - } - - #endregion - - #region 2. 开关参数处理 - - [TestMethod("2.1. 单个开关参数,布尔类型。")] - public void SwitchParameter_BooleanType_True() - { - // Arrange - string[] args = ["-Verbose"]; - bool? verbose = null; - - // Act - CommandLine.Parse(args, PowerShell) - .AddHandler(o => verbose = o.Verbose) - .Run(); - - // Assert - Assert.IsTrue(verbose); - } - - [TestMethod("2.2. 多个开关参数,全部为true。")] - public void MultipleSwitchParameters_AllTrue() - { - // Arrange - string[] args = ["-Recurse", "-Force", "-WhatIf"]; - bool? recurse = null; - bool? force = null; - bool? whatIf = null; - - // Act - CommandLine.Parse(args, PowerShell) - .AddHandler(o => - { - recurse = o.Recurse; - force = o.Force; - whatIf = o.WhatIf; - }) - .Run(); - - // Assert - Assert.IsTrue(recurse); - Assert.IsTrue(force); - Assert.IsTrue(whatIf); - } - - [TestMethod("2.3. 开关参数与值参数混合。")] - public void MixedSwitchAndValueParameters() - { - // Arrange - string[] args = ["-Path", "logs.txt", "-Append", "-Encoding", "UTF8"]; - string? path = null; - bool? append = null; - string? encoding = null; - - // Act - CommandLine.Parse(args, PowerShell) - .AddHandler(o => - { - path = o.Path; - append = o.Append; - encoding = o.Encoding; - }) - .Run(); - - // Assert - Assert.AreEqual("logs.txt", path); - Assert.IsTrue(append); - Assert.AreEqual("UTF8", encoding); - } - - #endregion - - #region 3. 参数名称缩写 - - [Ignore("暂时不打算支持 PowerShell 最短缩写功能,如果后面有需要再说。")] - [TestMethod("3.1. 使用参数的唯一缩写。")] - public void ParameterAbbreviation_UniquePrefix() - { - // Arrange - string[] args = ["-Com", "Server01"]; - string? computerName = null; - - // Act - CommandLine.Parse(args, PowerShell) - .AddHandler(o => computerName = o.ComputerName) - .Run(); - - // Assert - Assert.AreEqual("Server01", computerName); - } - - [TestMethod("3.2. 使用完整参数名。")] - public void ParameterAbbreviation_FullName() - { - // Arrange - string[] args = ["-ComputerName", "Server01"]; - string? computerName = null; - - // Act - CommandLine.Parse(args, PowerShell) - .AddHandler(o => computerName = o.ComputerName) - .Run(); - - // Assert - Assert.AreEqual("Server01", computerName); - } - - [Ignore("暂时不打算支持 PowerShell 最短缩写功能,如果后面有需要再说。")] - [TestMethod("3.3. 使用最短唯一缩写。")] - public void ParameterAbbreviation_ShortestUniquePrefix() - { - // Arrange - string[] args = ["-C", "Server01"]; - string? computerName = null; - - // Act - CommandLine.Parse(args, PowerShell) - .AddHandler(o => computerName = o.ComputerName) - .Run(); - - // Assert - Assert.AreEqual("Server01", computerName); - } - - [Ignore("暂时不打算支持 PowerShell 最短缩写功能,如果后面有需要再说。")] - [TestMethod("3.3. 缩写歧义处理。")] - public void ParameterAbbreviation_AmbiguousPrefix_ThrowsException() - { - // Arrange - string[] args = ["-Co", "value"]; // 可能是 ComputerName 或 Count - - // Act & Assert - Assert.ThrowsExactly(() => - { - CommandLine.Parse(args, PowerShell) - .AddHandler(_ => { }) - .Run(); - }); - } - - #endregion - - #region 4. 位置参数 - - [TestMethod("4.1. 单个位置参数。")] - public void SinglePositionalParameter() - { - // Arrange - string[] args = ["value"]; - string? value = null; - - // Act - CommandLine.Parse(args, PowerShell) - .AddHandler(o => value = o.Value) - .Run(); - - // Assert - Assert.AreEqual("value", value); - } - - [TestMethod("4.2. 多个位置参数。")] - public void MultiplePositionalParameters() - { - // Arrange - string[] args = ["source.txt", "destination.txt"]; - string? source = null; - string? destination = null; - - // Act - CommandLine.Parse(args, PowerShell) - .AddHandler(o => - { - source = o.Source; - destination = o.Destination; - }) - .Run(); - - // Assert - Assert.AreEqual("source.txt", source); - Assert.AreEqual("destination.txt", destination); - } - - [TestMethod("4.3. 位置参数与命名参数混合。")] - public void MixedPositionalAndNamedParameters() - { - // Arrange - string[] args = ["source.txt", "-Destination", "dest.txt", "-Force"]; - string? source = null; - string? destination = null; - bool? force = null; - - // Act - CommandLine.Parse(args, PowerShell) - .AddHandler(o => - { - source = o.Source; - destination = o.Destination; - force = o.Force; - }) - .Run(); - - // Assert - Assert.AreEqual("source.txt", source); - Assert.AreEqual("dest.txt", destination); - Assert.IsTrue(force); - } - - #endregion - - #region 5. 数组参数 - - [TestMethod("5.1. 逗号分隔的数组参数。")] - public void CommaSeparatedArrayParameter() - { - // Arrange - string[] args = ["-Processes", "chrome,firefox,edge"]; - string[]? processes = null; - - // Act - CommandLine.Parse(args, PowerShell) - .AddHandler(o => processes = o.Processes) - .Run(); - - // Assert - Assert.IsNotNull(processes); - Assert.AreEqual(3, processes.Length); - CollectionAssert.AreEqual(new[] { "chrome", "firefox", "edge" }, processes); - } - - [TestMethod("5.2. 多次指定同一参数形成数组。")] - public void RepeatedParameterAsArray() - { - // Arrange - string[] args = ["-ComputerName", "server1", "-ComputerName", "server2", "-ComputerName", "server3"]; - IReadOnlyList? computerNames = null; - - // Act - CommandLine.Parse(args, PowerShell) - .AddHandler(o => computerNames = o.ComputerName) - .Run(); - - // Assert - Assert.IsNotNull(computerNames); - Assert.AreEqual(3, computerNames.Count); - CollectionAssert.AreEqual(new[] { "server1", "server2", "server3" }, computerNames.ToArray()); - } - - [TestMethod("5.4. 分号分隔的数组参数。")] - public void SemicolonSeparatedArrayParameter() - { - // Arrange - string[] args = ["-Processes", "chrome;firefox;edge"]; - string[]? processes = null; - - // Act - CommandLine.Parse(args, PowerShell) - .AddHandler(o => processes = o.Processes) - .Run(); - - // Assert - Assert.IsNotNull(processes); - Assert.AreEqual(3, processes.Length); - CollectionAssert.AreEqual(new[] { "chrome", "firefox", "edge" }, processes); - } - - [TestMethod("5.6. 逗号分隔的带引号数组元素。")] - public void CommaSeparatedQuotedArrayElements() - { - // Arrange - string[] args = ["-ComputerNames", "\"server one\",\"server two\",server3"]; - string[]? computerNames = null; - - // Act - CommandLine.Parse(args, PowerShell) - .AddHandler(o => computerNames = o.ComputerNames) - .Run(); - - // Assert - Assert.IsNotNull(computerNames); - Assert.AreEqual(3, computerNames.Length); - CollectionAssert.AreEqual(new[] { "server one", "server two", "server3" }, computerNames); - } - - #endregion - - #region 6. 边界条件处理 - - [TestMethod("6.1. 必选参数缺失,抛出异常。")] - public void MissingRequiredParameter_ThrowsException() - { - // Arrange - string[] args = []; - - // Act & Assert - Assert.ThrowsExactly(() => - { - CommandLine.Parse(args, PowerShell) - .AddHandler(_ => { }) - .Run(); - }); - } - - [TestMethod("6.2. 类型转换错误,抛出异常。")] - public void TypeConversionError_ThrowsException() - { - // Arrange - string[] args = ["-Count", "not-a-number"]; - - // Act & Assert - Assert.ThrowsExactly(() => - { - CommandLine.Parse(args, PowerShell) - .AddHandler(_ => { }) - .Run(); - }); - } - - [TestMethod("6.3. 参数大小写不敏感。")] - public void ParameterCaseInsensitive() - { - // Arrange - string[] args = ["-NAME", "test", "-path", "C:\\temp"]; - string? name = null; - string? path = null; - - // Act - CommandLine.Parse(args, PowerShell) - .AddHandler(o => - { - name = o.Name; - path = o.Path; - }) - .Run(); - - // Assert - Assert.AreEqual("test", name); - Assert.AreEqual("C:\\temp", path); - } - - #endregion - - #region 7. 特殊场景 - - [TestMethod("7.1. 引号包围的参数值。")] - public void QuotedParameterValues() - { - // Arrange - string[] args = ["-Message", "\"Hello World\""]; - string? message = null; - - // Act - CommandLine.Parse(args, PowerShell) - .AddHandler(o => message = o.Message) - .Run(); - - // Assert - Assert.AreEqual("\"Hello World\"", message); - } - - [TestMethod("7.2. 参数别名支持。")] - public void ParameterAliases() - { - // Arrange - string[] args = ["-Alias", "test"]; - string? value = null; - - // Act - CommandLine.Parse(args, PowerShell) - .AddHandler(o => value = o.ParameterWithAlias) - .Run(); - - // Assert - Assert.AreEqual("test", value); - } - - [TestMethod("7.3. 枚举类型参数。")] - public void EnumTypeParameter() - { - // Arrange - string[] args = ["-LogLevel", "Warning"]; - LogLevel? logLevel = null; - - // Act - CommandLine.Parse(args, PowerShell) - .AddHandler(o => logLevel = o.LogLevel) - .Run(); - - // Assert - Assert.AreEqual(LogLevel.Warning, logLevel); - } - - #endregion - - #region 8. 异步处理测试 - - [TestMethod("8.1. 异步处理方法,正确执行。")] - public async Task AsyncHandler_ExecutesCorrectly() - { - // Arrange - string[] args = ["-Name", "async-test"]; - string? name = null; - - // Act - await CommandLine.Parse(args, PowerShell) - .AddHandler(async o => - { - await Task.Delay(10); // 模拟异步操作 - name = o.Name; - }) - .RunAsync(); - - // Assert - Assert.AreEqual("async-test", name); - } - - #endregion -} - -#region 测试用数据模型 - -internal record PS01_BasicOptions -{ - [Option("Name")] - public string Name { get; init; } = string.Empty; -} - -internal record PS02_MultipleOptions -{ - [Option("Path")] - public string Path { get; init; } = string.Empty; - - [Option("ItemType")] - public string ItemType { get; init; } = string.Empty; - - [Option("Force")] - public bool Force { get; init; } -} - -internal record PS03_CamelCaseOptions -{ - [Option("fileName")] - public string fileName { get; init; } = string.Empty; -} - -internal record PS04_SwitchOptions -{ - [Option("Verbose")] - public bool Verbose { get; init; } -} - -internal record PS05_MultipleSwitchOptions -{ - [Option("Recurse")] - public bool Recurse { get; init; } - - [Option("Force")] - public bool Force { get; init; } - - [Option("WhatIf")] - public bool WhatIf { get; init; } -} - -internal record PS06_MixedParameterTypes -{ - [Option("Path")] - public string Path { get; init; } = string.Empty; - - [Option("Append")] - public bool Append { get; init; } - - [Option("Encoding")] - public string Encoding { get; init; } = string.Empty; -} - -internal record PS07_AbbreviationOptions -{ - [Option("ComputerName")] - public string ComputerName { get; init; } = string.Empty; -} - -internal record PS08_AmbiguousOptions -{ - [Option("ComputerName")] - public string ComputerName { get; init; } = string.Empty; - - [Option("Count")] - public int Count { get; init; } -} - -internal record PS09_PositionalOptions -{ - [Value] - public string Value { get; init; } = string.Empty; -} - -internal record PS10_MultiplePositionalOptions -{ - [Value(0)] - public string Source { get; init; } = string.Empty; - - [Value(1)] - public string Destination { get; init; } = string.Empty; -} - -internal record PS11_MixedParameterOptions -{ - [Value(0)] - public string Source { get; init; } = string.Empty; - - [Option("Destination")] - public string Destination { get; init; } = string.Empty; - - [Option("Force")] - public bool Force { get; init; } -} - -internal record PS12_ArrayOptions -{ - [Option("Processes")] - public string[] Processes { get; init; } = []; -} - -internal record PS13_RepeatedParameterOptions -{ - [Option("ComputerName")] - public IReadOnlyList ComputerName { get; init; } = []; -} - -internal record PS14_RequiredOptions -{ - [Option("Name")] - public required string Name { get; init; } -} - -internal record PS15_TypeConversionOptions -{ - [Option("Count")] - public int Count { get; init; } -} - -internal record PS16_CaseInsensitiveOptions -{ - [Option("Name")] - public string Name { get; init; } = string.Empty; - - [Option("Path")] - public string Path { get; init; } = string.Empty; -} - -internal record PS17_QuotedValueOptions -{ - [Option("Message")] - public string Message { get; init; } = string.Empty; -} - -internal record PS18_AliasOptions -{ - [Option("ParameterWithAlias", Aliases = ["Alias", "Alt"])] - public string ParameterWithAlias { get; init; } = string.Empty; -} - -internal record PS19_EnumOptions -{ - [Option("LogLevel")] - public LogLevel LogLevel { get; init; } -} - -internal record PS19_ArrayMultiValueOptions -{ - [Option("Tags")] - public string[] Tags { get; init; } = []; -} - -internal record PS20_QuotedArrayOptions -{ - [Option("Files")] - public string[] Files { get; init; } = []; - - [Option("ComputerNames")] - public string[] ComputerNames { get; init; } = []; -} - -#endregion diff --git a/tests/DotNetCampus.CommandLine.Tests/SubcommandTests.cs b/tests/DotNetCampus.CommandLine.Tests/SubcommandTests.cs deleted file mode 100644 index 5e59c937..00000000 --- a/tests/DotNetCampus.CommandLine.Tests/SubcommandTests.cs +++ /dev/null @@ -1,898 +0,0 @@ -using System.Threading.Tasks; -using DotNetCampus.Cli.Compiler; -using DotNetCampus.Cli.Exceptions; -using Microsoft.VisualStudio.TestTools.UnitTesting; - -// ReSharper disable UnusedAutoPropertyAccessor.Global -// ReSharper disable InconsistentNaming - -namespace DotNetCampus.Cli.Tests; - -/// -/// 测试子命令(SubCommand)功能,包括二级子命令、多级子命令和嵌套子命令。 -/// -[TestClass] -public class SubcommandTests -{ - private CommandLineParsingOptions Flexible { get; } = CommandLineParsingOptions.Flexible; - - #region 1. 基本子命令测试 - - [TestMethod("1.1. 二级子命令匹配")] - public void BasicSubcommand_MatchesCorrectly() - { - // Arrange - string[] args = ["remote", "add", "origin", "https://github.com/user/repo.git"]; - string? capturedRemoteName = null; - string? capturedRemoteUrl = null; - bool otherHandlerCalled = false; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => - { - capturedRemoteName = o.RemoteName; - capturedRemoteUrl = o.RemoteUrl; - }) - .AddHandler(_ => otherHandlerCalled = true) - .Run(); - - // Assert - Assert.AreEqual("origin", capturedRemoteName); - Assert.AreEqual("https://github.com/user/repo.git", capturedRemoteUrl); - Assert.IsFalse(otherHandlerCalled); - } - - [TestMethod("1.2. 另一个二级子命令匹配")] - public void AnotherBasicSubcommand_MatchesCorrectly() - { - // Arrange - string[] args = ["container", "run", "--name", "test-container", "nginx"]; - string? capturedContainerName = null; - string? capturedImageName = null; - bool otherHandlerCalled = false; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => - { - capturedContainerName = o.ContainerName; - capturedImageName = o.ImageName; - }) - .AddHandler(_ => otherHandlerCalled = true) - .Run(); - - // Assert - Assert.AreEqual("test-container", capturedContainerName); - Assert.AreEqual("nginx", capturedImageName); - Assert.IsFalse(otherHandlerCalled); - } - - [TestMethod("1.3. 单级命令与二级子命令共存")] - public void SingleCommandAndSubcommand_Coexist() - { - // Arrange - 测试主命令 - string[] mainArgs = ["status"]; - bool statusHandlerCalled = false; - bool subcommandHandlerCalled = false; - - // Act - 执行主命令 - CommandLine.Parse(mainArgs, Flexible) - .AddHandler(_ => statusHandlerCalled = true) - .AddHandler(_ => subcommandHandlerCalled = true) - .Run(); - - // Assert - Assert.IsTrue(statusHandlerCalled); - Assert.IsFalse(subcommandHandlerCalled); - - // Reset - statusHandlerCalled = false; - subcommandHandlerCalled = false; - - // Arrange - 测试子命令 - string[] subArgs = ["remote", "add", "origin", "https://example.com"]; - - // Act - 执行子命令 - CommandLine.Parse(subArgs, Flexible) - .AddHandler(_ => statusHandlerCalled = true) - .AddHandler(_ => subcommandHandlerCalled = true) - .Run(); - - // Assert - Assert.IsFalse(statusHandlerCalled); - Assert.IsTrue(subcommandHandlerCalled); - } - - #endregion - - #region 2. 多级子命令测试 - - [TestMethod("2.1. 三级子命令匹配")] - public void ThreeLevelSubcommand_MatchesCorrectly() - { - // Arrange - string[] args = ["container", "image", "list"]; - bool handlerCalled = false; - bool otherHandlerCalled = false; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(_ => handlerCalled = true) - .AddHandler(_ => otherHandlerCalled = true) - .Run(); - - // Assert - Assert.IsTrue(handlerCalled); - Assert.IsFalse(otherHandlerCalled); - } - - [TestMethod("2.2. 另一个三级子命令匹配")] - public void AnotherThreeLevelSubcommand_MatchesCorrectly() - { - // Arrange - string[] args = ["cluster", "node", "delete", "worker-node-1"]; - string? capturedNodeName = null; - bool otherHandlerCalled = false; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => - { - capturedNodeName = o.NodeName; - }) - .AddHandler(_ => otherHandlerCalled = true) - .Run(); - - // Assert - Assert.AreEqual("worker-node-1", capturedNodeName); - Assert.IsFalse(otherHandlerCalled); - } - - [TestMethod("2.3. 四级子命令匹配")] - public void FourLevelSubcommand_MatchesCorrectly() - { - // Arrange - string[] args = ["config", "user", "profile", "set", "development"]; - string? capturedProfile = null; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => - { - capturedProfile = o.ProfileName; - }) - .Run(); - - // Assert - Assert.AreEqual("development", capturedProfile); - } - - [TestMethod("2.4. kebab-case 命名的子命令匹配")] - public void KebabCaseSubcommand_MatchesCorrectly() - { - // Arrange - string[] args = ["get-info", "user", "123"]; - string? capturedUserId = null; - bool otherHandlerCalled = false; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => - { - capturedUserId = o.UserId; - }) - .AddHandler(_ => otherHandlerCalled = true) - .Run(); - - // Assert - Assert.AreEqual("123", capturedUserId); - Assert.IsFalse(otherHandlerCalled); - } - - [TestMethod("2.5. 混合 kebab-case 和普通命名的多级子命令")] - public void MixedKebabCaseAndNormalSubcommand_MatchesCorrectly() - { - // Arrange - string[] args = ["user-management", "create-account", "--username", "john", "--email", "john@example.com"]; - string? capturedUsername = null; - string? capturedEmail = null; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => - { - capturedUsername = o.Username; - capturedEmail = o.Email; - }) - .Run(); - - // Assert - Assert.AreEqual("john", capturedUsername); - Assert.AreEqual("john@example.com", capturedEmail); - } - - [TestMethod("2.6. 复杂的 kebab-case 三级子命令")] - public void ComplexKebabCaseThreeLevelSubcommand_MatchesCorrectly() - { - // Arrange - string[] args = ["cloud-service", "auto-scaling", "set-policy", "scale-up"]; - string? capturedPolicy = null; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => - { - capturedPolicy = o.PolicyName; - }) - .Run(); - - // Assert - Assert.AreEqual("scale-up", capturedPolicy); - } - - #endregion - - #region 3. 子命令优先级与匹配规则测试 - - [TestMethod("3.1. 更具体的子命令优先匹配")] - public void MoreSpecificSubcommand_TakesPriority() - { - // Arrange - string[] args = ["remote", "add", "origin", "https://example.com"]; - bool genericRemoteHandlerCalled = false; - bool specificRemoteAddHandlerCalled = false; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(_ => genericRemoteHandlerCalled = true) - .AddHandler(_ => specificRemoteAddHandlerCalled = true) - .Run(); - - // Assert - Assert.IsFalse(genericRemoteHandlerCalled); - Assert.IsTrue(specificRemoteAddHandlerCalled); - } - - [TestMethod("3.2. 部分匹配子命令的处理")] - public void PartialSubcommandMatch_MatchesLongestPath() - { - // Arrange - string[] args = ["container", "run", "nginx"]; - bool containerHandlerCalled = false; - bool containerRunHandlerCalled = false; - bool containerImageHandlerCalled = false; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(_ => containerHandlerCalled = true) - .AddHandler(_ => containerRunHandlerCalled = true) - .AddHandler(_ => containerImageHandlerCalled = true) - .Run(); - - // Assert - Assert.IsFalse(containerHandlerCalled); - Assert.IsTrue(containerRunHandlerCalled); - Assert.IsFalse(containerImageHandlerCalled); - } - - [TestMethod("3.3. 注册顺序不影响子命令匹配优先级")] - public void RegistrationOrder_DoesNotAffectSubcommandPriority() - { - // Arrange - string[] args = ["remote", "add", "origin", "https://example.com"]; - bool genericRemoteHandlerCalled = false; - bool specificRemoteAddHandlerCalled = false; - - // Act - 先注册具体的,再注册通用的 - CommandLine.Parse(args, Flexible) - .AddHandler(_ => specificRemoteAddHandlerCalled = true) - .AddHandler(_ => genericRemoteHandlerCalled = true) - .Run(); - - // Assert - Assert.IsFalse(genericRemoteHandlerCalled); - Assert.IsTrue(specificRemoteAddHandlerCalled); - } - - [TestMethod("3.4. 最长路径匹配 - 基本情况")] - public void LongestPathMatching_BasicCase() - { - // Arrange - 测试 "git", "git remote", "git remote add" 的优先级 - string[] args = ["git", "remote", "add", "origin", "https://example.com"]; - bool gitHandlerCalled = false; - bool gitRemoteHandlerCalled = false; - bool gitRemoteAddHandlerCalled = false; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(_ => gitHandlerCalled = true) - .AddHandler(_ => gitRemoteHandlerCalled = true) - .AddHandler(_ => gitRemoteAddHandlerCalled = true) - .Run(); - - // Assert - 应该匹配最长的 "git remote add" - Assert.IsFalse(gitHandlerCalled); - Assert.IsFalse(gitRemoteHandlerCalled); - Assert.IsTrue(gitRemoteAddHandlerCalled); - } - - [TestMethod("3.5. 最长路径匹配 - 复杂情况")] - public void LongestPathMatching_ComplexCase() - { - // Arrange - 测试多个不同长度的命令路径 - string[] args = ["cluster", "config", "set-context", "my-context"]; - bool clusterHandlerCalled = false; - bool clusterConfigHandlerCalled = false; - bool clusterConfigSetHandlerCalled = false; - bool clusterConfigSetContextHandlerCalled = false; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(_ => clusterHandlerCalled = true) - .AddHandler(_ => clusterConfigHandlerCalled = true) - .AddHandler(_ => clusterConfigSetHandlerCalled = true) - .AddHandler(_ => clusterConfigSetContextHandlerCalled = true) - .Run(); - - // Assert - 应该匹配最长的 "cluster config set-context" - Assert.IsFalse(clusterHandlerCalled); - Assert.IsFalse(clusterConfigHandlerCalled); - Assert.IsFalse(clusterConfigSetHandlerCalled); - Assert.IsTrue(clusterConfigSetContextHandlerCalled); - } - - [TestMethod("3.6. 最长路径匹配 - 前缀匹配但非完整匹配")] - public void LongestPathMatching_PrefixButNotComplete() - { - // Arrange - "remote addx" 不应该匹配 "remote add" - string[] args = ["remote", "addx", "test"]; - bool remoteHandlerCalled = false; - bool remoteAddHandlerCalled = false; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(_ => remoteHandlerCalled = true) - .AddHandler(_ => remoteAddHandlerCalled = true) - .Run(); - - // Assert - addx 不能匹配 add,所以只有 remote 是匹配的 - Assert.IsTrue(remoteHandlerCalled); - Assert.IsFalse(remoteAddHandlerCalled); - } - - [TestMethod("3.7. 最长路径匹配 - 大小写不敏感")] - public void LongestPathMatching_CaseInsensitive() - { - // Arrange - string[] args = ["Remote", "ADD", "origin", "https://example.com"]; - bool remoteHandlerCalled = false; - bool remoteAddHandlerCalled = false; - - // Act - CommandLine.Parse(args, Flexible) // Flexible 默认大小写不敏感 - .AddHandler(_ => remoteHandlerCalled = true) - .AddHandler(_ => remoteAddHandlerCalled = true) - .Run(); - - // Assert - Assert.IsFalse(remoteHandlerCalled); - Assert.IsTrue(remoteAddHandlerCalled); - } - - [TestMethod("3.8. 最长路径匹配 - 单个字符差异")] - public void LongestPathMatching_SingleCharacterDifference() - { - // Arrange - 测试命令名称相似但不同的情况 - string[] args = ["config", "users", "list"]; - bool configUserHandlerCalled = false; - bool configUsersHandlerCalled = false; - bool configUserListHandlerCalled = false; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(_ => configUserHandlerCalled = true) - .AddHandler(_ => configUsersHandlerCalled = true) - .AddHandler(_ => configUserListHandlerCalled = true) - .Run(); - - // Assert - 应该匹配 "config users" 而不是其他 - Assert.IsFalse(configUserHandlerCalled); - Assert.IsTrue(configUsersHandlerCalled); - Assert.IsFalse(configUserListHandlerCalled); - } - - #endregion - - #region 4. 子命令参数与选项测试 - - [TestMethod("4.1. 子命令带选项参数")] - public void Subcommand_WithOptions_ParsedCorrectly() - { - // Arrange - string[] args = ["remote", "add", "origin", "https://example.com", "--fetch", "--tags"]; - bool fetchEnabled = false; - bool tagsEnabled = false; - string? remoteName = null; - string? remoteUrl = null; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => - { - fetchEnabled = o.EnableFetch; - tagsEnabled = o.EnableTags; - remoteName = o.RemoteName; - remoteUrl = o.RemoteUrl; - }) - .Run(); - - // Assert - Assert.IsTrue(fetchEnabled); - Assert.IsTrue(tagsEnabled); - Assert.AreEqual("origin", remoteName); - Assert.AreEqual("https://example.com", remoteUrl); - } - - [TestMethod("4.2. 子命令带位置参数和选项混合")] - public void Subcommand_WithMixedPositionalAndOptions_ParsedCorrectly() - { - // Arrange - string[] args = ["container", "run", "--detach", "--publish", "8080:80", "nginx", "nginx:latest"]; - bool detached = false; - string? portMapping = null; - string? containerName = null; - string? imageName = null; - - // Act - CommandLine.Parse(args, Flexible) - .AddHandler(o => - { - detached = o.Detach; - portMapping = o.Publish; - containerName = o.ContainerName; - imageName = o.ImageName; - }) - .Run(); - - // Assert - Assert.IsTrue(detached); - Assert.AreEqual("8080:80", portMapping); - Assert.AreEqual("nginx", containerName); - Assert.AreEqual("nginx:latest", imageName); - } - - #endregion - - #region 5. 子命令错误处理测试 - - [TestMethod("5.1. 未知子命令抛出异常")] - public void UnknownSubcommand_ThrowsCommandNameNotFoundException() - { - // Arrange - string[] args = ["unknown", "subcommand"]; - - // Act & Assert - var exception = Assert.ThrowsExactly(() => - { - CommandLine.Parse(args, Flexible) - .AddHandler(_ => { }) - .AddHandler(_ => { }) - .Run(); - }); - - // 确认异常包含正确的子命令信息 - Assert.IsTrue(exception.Message.Contains("unknown")); - } - - [TestMethod("5.2. 子命令缺少必需参数抛出异常")] - public void Subcommand_MissingRequiredParameter_ThrowsException() - { - // Arrange - string[] args = ["remote", "add"]; // 缺少 remote name 和 URL - - // Act & Assert - Assert.ThrowsExactly(() => - { - CommandLine.Parse(args, Flexible) - .AddHandler(_ => { }) - .Run(); - }); - } - - [TestMethod("5.3. 部分匹配但无完全匹配的子命令")] - public void PartialSubcommandMatch_NoExactMatch_ThrowsException() - { - // Arrange - "remote" 存在,但 "remote unknown" 不存在 - string[] args = ["remote", "unknown"]; - - // Act & Assert - var exception = Assert.ThrowsExactly(() => - { - CommandLine.Parse(args, Flexible) - .AddHandler(_ => { }) - .AddHandler(_ => { }) - .Run(); - }); - - Assert.IsTrue(exception.Message.Contains("remote")); - } - - #endregion - - #region 6. 异步子命令处理测试 - - [TestMethod("6.1. 异步子命令处理")] - public async Task AsyncSubcommand_ExecutesSuccessfully() - { - // Arrange - string[] args = ["remote", "sync", "origin"]; - string? capturedRemoteName = null; - bool asyncOperationCompleted = false; - - // Act - await CommandLine.Parse(args, Flexible) - .AddHandler(async o => - { - await Task.Delay(10); // 模拟异步操作 - capturedRemoteName = o.RemoteName; - asyncOperationCompleted = true; - return 0; - }) - .RunAsync(); - - // Assert - Assert.AreEqual("origin", capturedRemoteName); - Assert.IsTrue(asyncOperationCompleted); - } - - [TestMethod("6.2. 混合同步异步子命令处理")] - public async Task MixedSyncAsyncSubcommands_ExecuteCorrectly() - { - // Arrange - string[] args = ["container", "build", ".", "--tag", "myapp"]; - string? capturedTag = null; - string? capturedPath = null; - bool otherHandlerCalled = false; - - // Act - await CommandLine.Parse(args, Flexible) - .AddHandler(o => - { - capturedPath = o.BuildPath; - capturedTag = o.Tag; - }) - .AddHandler(_ => Task.FromResult(otherHandlerCalled = true)) - .RunAsync(); - - // Assert - Assert.AreEqual(".", capturedPath); - Assert.AreEqual("myapp", capturedTag); - Assert.IsFalse(otherHandlerCalled); - } - - #endregion - - #region 7. ICommandHandler 接口子命令测试 - - [TestMethod("7.1. ICommandHandler 接口实现的子命令")] - public async Task ICommandHandler_Subcommand_ExecutesCorrectly() - { - // Arrange - string[] args = ["service", "start", "web-api"]; - - // Act - int exitCode = await CommandLine.Parse(args, Flexible) - .AddHandler() - .RunAsync(); - - // Assert - Assert.AreEqual(ServiceStartCommandHandler.ExpectedExitCode, exitCode); - Assert.IsTrue(ServiceStartCommandHandler.WasHandlerCalled); - Assert.AreEqual("web-api", ServiceStartCommandHandler.CapturedServiceName); - - // Reset static state for other tests - ServiceStartCommandHandler.ResetState(); - } - - #endregion -} - -#region 测试用数据模型 - -// Git 相关子命令选项类 - -[Command("status")] -internal class GitStatusOptions -{ - [Option("short")] - public bool Short { get; init; } -} - -[Command("remote")] -internal class GitRemoteOptions -{ - [Option("verbose")] - public bool Verbose { get; init; } -} - -[Command("remote add")] -internal class GitRemoteAddOptions -{ - [Value(0)] - public required string RemoteName { get; init; } - - [Value(1)] - public required string RemoteUrl { get; init; } -} - -[Command("remote add")] -internal class GitRemoteNullableAddOptions -{ - [Value(0)] - public string? RemoteName { get; init; } - - [Value(1)] - public string? RemoteUrl { get; init; } -} - -[Command("remote list")] -internal class GitRemoteListOptions -{ - [Option("verbose")] - public bool Verbose { get; init; } -} - -[Command("remote add")] -internal class GitRemoteAddOptionsWithFlags -{ - [Option("fetch")] - public bool EnableFetch { get; init; } - - [Option("tags")] - public bool EnableTags { get; init; } - - [Value(0)] - public required string RemoteName { get; init; } - - [Value(1)] - public required string RemoteUrl { get; init; } -} - -[Command("remote sync")] -internal class GitRemoteSyncOptions -{ - [Value(0)] - public required string RemoteName { get; init; } -} - -// Docker 相关子命令选项类 - -[Command("container run")] -internal class DockerContainerRunOptions -{ - [Option("name")] - public string? ContainerName { get; init; } - - [Value(0)] - public required string ImageName { get; init; } -} - -[Command("container list")] -internal class DockerContainerListOptions -{ - [Option("all")] - public bool ShowAll { get; init; } -} - -[Command("container")] -internal class DockerContainerOptions -{ - [Option("help")] - public bool ShowHelp { get; init; } -} - -[Command("container image")] -internal class DockerContainerImageOptions -{ - [Option("help")] - public bool ShowHelp { get; init; } -} - -[Command("container image list")] -internal class DockerContainerImageListOptions -{ - [Option("all")] - public bool ShowAll { get; init; } -} - -[Command("container run")] -internal class DockerContainerRunOptionsDetailed -{ - [Option("detach")] - public bool Detach { get; init; } - - [Option("publish")] - public string? Publish { get; init; } - - [Value(0)] - public required string ContainerName { get; init; } - - [Value(1)] - public required string ImageName { get; init; } -} - -[Command("container build")] -internal class DockerContainerBuildOptions -{ - [Value(0)] - public required string BuildPath { get; init; } - - [Option("tag")] - public string? Tag { get; init; } -} - -// Kubernetes 相关子命令选项类 - -[Command("cluster node delete")] -internal class KubernetesClusterNodeDeleteOptions -{ - [Value(0)] - public required string NodeName { get; init; } -} - -// 配置管理相关子命令选项类 - -[Command("config user profile set")] -internal class ConfigUserProfileSetOptions -{ - [Value(0)] - public required string ProfileName { get; init; } -} - -// 服务管理相关子命令选项类 - -[Command("service start")] -internal class ServiceStartCommandHandler : ICommandHandler -{ - public static bool WasHandlerCalled { get; private set; } - public static string? CapturedServiceName { get; private set; } - public const int ExpectedExitCode = 100; - - [Value(0)] - public required string ServiceName { get; init; } - - public Task RunAsync() - { - WasHandlerCalled = true; - CapturedServiceName = ServiceName; - return Task.FromResult(ExpectedExitCode); - } - - public static void ResetState() - { - WasHandlerCalled = false; - CapturedServiceName = null; - } -} - -// kebab-case 命名相关子命令选项类 - -[Command("get-info user")] -internal class GetInfoUserOptions -{ - [Value(0)] - public required string UserId { get; init; } -} - -[Command("get-info system")] -internal class GetInfoSystemOptions -{ - [Option("verbose")] - public bool Verbose { get; init; } -} - -[Command("user-management create-account")] -internal class UserManagementCreateAccountOptions -{ - [Option("username")] - public required string Username { get; init; } - - [Option("email")] - public required string Email { get; init; } - - [Option("role")] - public string Role { get; init; } = "user"; -} - -[Command("cloud-service auto-scaling set-policy")] -internal class CloudServiceAutoScalingSetPolicyOptions -{ - [Value(0)] - public required string PolicyName { get; init; } - - [Option("min-instances")] - public int MinInstances { get; init; } = 1; - - [Option("max-instances")] - public int MaxInstances { get; init; } = 10; -} - -// 新增的测试数据模型类 - 用于最长路径匹配测试 - -[Command("git")] -internal class GitBaseOptions -{ - [Option("version")] - public bool ShowVersion { get; init; } -} - -[Command("cluster")] -internal class KubernetesClusterOptions -{ - [Option("help")] - public bool ShowHelp { get; init; } -} - -[Command("cluster config")] -internal class KubernetesClusterConfigOptions -{ - [Option("help")] - public bool ShowHelp { get; init; } -} - -[Command("cluster config set")] -internal class KubernetesClusterConfigSetOptions -{ - [Option("help")] - public bool ShowHelp { get; init; } -} - -[Command("cluster config set-context")] -internal class KubernetesClusterConfigSetContextOptions -{ - [Value(0)] - public required string ContextName { get; init; } -} - -[Command("config user")] -internal class ConfigUserOptions -{ - [Option("help")] - public bool ShowHelp { get; init; } -} - -[Command("config users")] -internal class ConfigUsersOptions -{ - [Option("help")] - public bool ShowHelp { get; init; } -} - -[Command("config user list")] -internal class ConfigUserListOptions -{ - [Option("help")] - public bool ShowHelp { get; init; } -} - -// 新增用于最长路径匹配测试的 Git 命令类 - -[Command("git remote")] -internal class GitRemoteOptionsNew -{ - [Option("verbose")] - public bool Verbose { get; init; } -} - -[Command("git remote add")] -internal class GitRemoteAddOptionsNew -{ - [Value(0)] - public required string RemoteName { get; init; } - - [Value(1)] - public required string RemoteUrl { get; init; } -} - -#endregion diff --git a/tests/DotNetCampus.CommandLine.Tests/UrlCommandLineParserTests.cs b/tests/DotNetCampus.CommandLine.Tests/UrlCommandLineParserTests.cs deleted file mode 100644 index 6de6c873..00000000 --- a/tests/DotNetCampus.CommandLine.Tests/UrlCommandLineParserTests.cs +++ /dev/null @@ -1,698 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using DotNetCampus.Cli.Compiler; -using DotNetCampus.Cli.Exceptions; -using Microsoft.VisualStudio.TestTools.UnitTesting; - -// ReSharper disable UnusedAutoPropertyAccessor.Global -// ReSharper disable InconsistentNaming - -namespace DotNetCampus.Cli.Tests; - -/// -/// 测试 URL 风格命令行参数是否正确被解析。 -/// 注意:URL风格参数通常由Web浏览器或其他应用程序传入,而不是用户直接在终端输入。 -/// 因此URL风格参数通常只有一个完整的URL参数,而不是像其他风格那样有多个参数。 -/// -[TestClass] -public class UrlCommandLineParserTests -{ - private CommandLineParsingOptions Scheme { get; } = new CommandLineParsingOptions { SchemeNames = ["myapp"] }; - - #region 1. 基本URL解析测试 - - [TestMethod("1.1. 完整URL格式解析(含scheme、path、query参数)")] - public void CompleteUrl_WithSchemePathAndQuery_ParsedCorrectly() - { - // Arrange - string[] args = ["myapp://documents/open?readOnly=true&highlight=yes"]; - string? path = null; - bool? readOnly = false; - string? highlight = null; - - // Act - CommandLine.Parse(args, Scheme) - .AddHandler(o => - { - path = o.Path; - readOnly = o.ReadOnly; - highlight = o.Highlight; - }) - .Run(); - - // Assert - Assert.AreEqual("documents/open", path); - Assert.IsTrue(readOnly); - Assert.AreEqual("yes", highlight); - } - - [Ignore("虽然正常解析时,这种Scheme不匹配应该抛异常;但我们是主命令行程序,兼容被 Web 调用;所以这种情况代码都进不来。")] - [TestMethod("1.2. 指定SchemeNames时正确匹配scheme")] - public void SchemeNames_SpecifiedAndMatched_ParsedCorrectly() - { - // Arrange - string[] args = ["sample://action?param=value"]; - string? action = null; - string? param = null; - - // Act - CommandLine.Parse(args, Scheme) - .AddHandler(o => - { - action = o.Action; - param = o.Param; - }) - .Run(); - - // Assert - Assert.AreEqual("action", action); - Assert.AreEqual("value", param); - } - - [TestMethod("1.3. 不在SchemeNames列表中的scheme不被识别为URL")] - public void SchemeNames_NotMatched_NotParsedAsUrl() - { - // Arrange - string[] args = ["unknown://path?param=value"]; - string? value = null; - - // Act - CommandLine.Parse(args, Scheme) - .AddHandler(o => value = o.Value) - .Run(); - - // Assert - Assert.AreEqual("unknown://path?param=value", value); // 作为普通位置参数处理 - } - - [TestMethod("1.4. 大小写不敏感的scheme匹配")] - public void SchemeNames_CaseInsensitive_MatchesCorrectly() - { - // Arrange - string[] args = ["MYAPP://path?param=value"]; - string? path = null; - string? param = null; - - // Act - CommandLine.Parse(args, Scheme) - .AddHandler(o => - { - path = o.Path; - param = o.Param; - }) - .Run(); - - // Assert - Assert.AreEqual("path", path); - Assert.AreEqual("value", param); - } - - #endregion - - #region 2. 查询参数(QueryString)解析测试 - - [TestMethod("2.1. 基本键值对参数解析")] - public void BasicQueryParam_KeyValuePair_ParsedCorrectly() - { - // Arrange - string[] args = ["myapp://path?name=value"]; - string? name = null; - - // Act - CommandLine.Parse(args, Scheme) - .AddHandler(o => name = o.Name) - .Run(); - - // Assert - Assert.AreEqual("value", name); - } - - [TestMethod("2.2. 多参数解析")] - public void MultipleQueryParams_ParsedCorrectly() - { - // Arrange - string[] args = ["myapp://path?name=John&age=25&location=Beijing"]; - string? name = null; - int? age = null; - string? location = null; - - // Act - CommandLine.Parse(args, Scheme) - .AddHandler(o => - { - name = o.Name; - age = o.Age; - location = o.Location; - }) - .Run(); - - // Assert - Assert.AreEqual("John", name); - Assert.AreEqual(25, age); - Assert.AreEqual("Beijing", location); - } - - [TestMethod("2.3. 无值参数解析为布尔true")] - public void QueryParamWithoutValue_ParsedAsTrue() - { - // Arrange - string[] args = ["myapp://path?debug&verbose"]; - bool? debug = false; - bool? verbose = false; - - // Act - CommandLine.Parse(args, Scheme) - .AddHandler(o => - { - debug = o.Debug; - verbose = o.Verbose; - }) - .Run(); - - // Assert - Assert.IsTrue(debug); - Assert.IsTrue(verbose); - } - - [TestMethod("2.4. 空值参数解析为空字符串")] - public void QueryParamWithEmptyValue_ParsedAsEmptyString() - { - // Arrange - string[] args = ["myapp://path?name=&comment="]; - string? name = "default"; - string? comment = "default"; - - // Act - CommandLine.Parse(args, Scheme) - .AddHandler(o => - { - name = o.Name; - comment = o.Comment; - }) - .Run(); - - // Assert - Assert.AreEqual("", name); - Assert.AreEqual("", comment); - } - - [TestMethod("2.5. 同名参数多次出现(数组)解析")] - public void DuplicateQueryParams_ParsedAsArray() - { - // Arrange - string[] args = ["myapp://path?tags=csharp&tags=dotnet&tags=cli"]; - string[]? tags = null; - - // Act - CommandLine.Parse(args, Scheme) - .AddHandler(o => tags = o.Tags) - .Run(); - - // Assert - Assert.IsNotNull(tags); - Assert.AreEqual(3, tags.Length); - CollectionAssert.AreEqual(new[] { "csharp", "dotnet", "cli" }, tags); - } - - #endregion - - #region 3. 类型转换测试 - - [TestMethod("3.1. 整数类型转换")] - public void QueryParamIntegerType_ParsedCorrectly() - { - // Arrange - string[] args = ["myapp://path?id=42&count=100"]; - int? id = null; - int? count = null; - - // Act - CommandLine.Parse(args, Scheme) - .AddHandler(o => - { - id = o.Id; - count = o.Count; - }) - .Run(); - - // Assert - Assert.AreEqual(42, id); - Assert.AreEqual(100, count); - } - - [TestMethod("3.2. 布尔类型转换")] - public void QueryParamBooleanType_ParsedCorrectly() - { - // Arrange - string[] args = ["myapp://path?enabled=true&disabled=false&flag"]; - bool? enabled = null; - bool? disabled = null; - bool? flag = null; - - // Act - CommandLine.Parse(args, Scheme) - .AddHandler(o => - { - enabled = o.Enabled; - disabled = o.Disabled; - flag = o.Flag; - }) - .Run(); - - // Assert - Assert.IsTrue(enabled); - Assert.IsFalse(disabled); - Assert.IsTrue(flag); - } - - [TestMethod("3.3. 枚举类型转换")] - public void QueryParamEnumType_ParsedCorrectly() - { - // Arrange - string[] args = ["myapp://path?logLevel=Warning&style=gnu"]; - LogLevel? logLevel = null; - CommandLineStyle? style = null; - - // Act - CommandLine.Parse(args, Scheme) - .AddHandler(o => - { - logLevel = o.LogLevel; - style = o.Style; - }) - .Run(); - - // Assert - Assert.AreEqual(LogLevel.Warning, logLevel); - Assert.AreEqual(CommandLineStyle.Gnu, style); - } - - [TestMethod("3.4. 数组/列表类型转换")] - public void QueryParamCollectionType_ParsedCorrectly() - { - // Arrange - string[] args = ["myapp://path?ids=1&ids=2&ids=3&names=Alice&names=Bob"]; - string[]? ids = null; - List? names = null; - - // Act - CommandLine.Parse(args, Scheme) - .AddHandler(o => - { - ids = o.Ids; - names = o.Names?.ToList(); - }) - .Run(); - - // Assert - Assert.IsNotNull(ids); - Assert.AreEqual(3, ids.Length); - CollectionAssert.AreEqual(new[] { "1", "2", "3" }, ids); - - Assert.IsNotNull(names); - Assert.AreEqual(2, names.Count); - CollectionAssert.AreEqual(new[] { "Alice", "Bob" }, names); - } - - #endregion - - #region 4. URL编码解析测试 - - [TestMethod("4.1. 基本URL编码解析(空格等)")] - public void UrlEncodedSpaces_DecodedCorrectly() - { - // Arrange - string[] args = ["myapp://path?query=hello%20world&path=my%20documents"]; - string? query = null; - string? path = null; - - // Act - CommandLine.Parse(args, Scheme) - .AddHandler(o => - { - query = o.Query; - path = o.Path; - }) - .Run(); - - // Assert - Assert.AreEqual("hello world", query); - Assert.AreEqual("my documents", path); - } - - [TestMethod("4.2. 特殊字符编码解析(#、&、%等)")] - public void UrlEncodedSpecialChars_DecodedCorrectly() - { - // Arrange - string[] args = ["myapp://path?special=hash%23ampersand%26percent%25"]; - string? special = null; - - // Act - CommandLine.Parse(args, Scheme) - .AddHandler(o => special = o.Special) - .Run(); - - // Assert - Assert.AreEqual("hash#ampersand&percent%", special); - } - - [TestMethod("4.3. 中文和非ASCII字符编码解析")] - public void UrlEncodedNonAsciiChars_DecodedCorrectly() - { - // Arrange - string[] args = ["myapp://path?chinese=%E4%BD%A0%E5%A5%BD&emoji=%F0%9F%98%80"]; - string? chinese = null; - string? emoji = null; - - // Act - CommandLine.Parse(args, Scheme) - .AddHandler(o => - { - chinese = o.Chinese; - emoji = o.Emoji; - }) - .Run(); - - // Assert - Assert.AreEqual("你好", chinese); - Assert.AreEqual("😀", emoji); - } - - #endregion - - #region 5. 路径解析测试 - - [TestMethod("5.1. 路径部分作为位置参数")] - public void PathPart_ParsedAsPositionalValue() - { - // Arrange - string[] args = ["myapp://documents/reports/annual"]; - string[]? paths = null; - - // Act - CommandLine.Parse(args, Scheme) - .AddHandler(o => paths = o.Paths) - .Run(); - - // Assert - CollectionAssert.AreEqual(new[] { "documents", "reports", "annual" }, paths); - } - - [TestMethod("5.2. 路径首部分作为命令名称,其余作为位置参数")] - public void FirstPathSegmentAsCommand_RemainingAsPositional() - { - // Arrange - string[] args = ["myapp://open/document.txt?readOnly=true"]; - string? filePath = null; - bool? readOnly = null; - - // Act - CommandLine.Parse(args, Scheme) - .AddHandler(o => - { - filePath = o.FilePath; - readOnly = o.ReadOnly; - }) - .Run(); - - // Assert - Assert.AreEqual("document.txt", filePath); - Assert.IsTrue(readOnly); - } - - #endregion - - #region 6. 边界情况测试 - - [TestMethod("6.1. 空参数列表")] - public void EmptyArgs_ProcessedGracefully() - { - // Arrange - string[] args = []; - string? path = "default"; - bool handlerCalled = false; - - // Act - CommandLine.Parse(args, Scheme) - .AddHandler(o => - { - handlerCalled = true; - path = o.Path; - }) - .Run(); - - // Assert - Assert.IsTrue(handlerCalled); - Assert.AreEqual("default-path", path); // 使用默认值 - } - - [Ignore("虽然正常解析时,这种格式应该抛异常;但我们是主命令行程序,兼容被 Web 调用;所以这种情况代码都进不来。")] - [TestMethod("6.2. 畸形URL格式")] - public void MalformedUrl_ThrowsException() - { - // Arrange - string[] args = ["myapp:/path?invalid-format"]; - - // Act & Assert - Assert.ThrowsExactly(() => - { - CommandLine.Parse(args, Scheme) - .AddHandler(_ => { }) - .Run(); - }); - } - - [TestMethod("6.3. 重复的查询参数名")] - public void DuplicateQueryParamName_LastOneWins() - { - // Arrange - string[] args = ["myapp://path?name=first&name=second&name=last"]; - string? name = null; - - // Act - CommandLine.Parse(args, Scheme) - .AddHandler(o => name = o.Name) - .Run(); - - // Assert - Assert.AreEqual("last", name); // 最后一个值被使用 - } - - [TestMethod("6.4. 特殊URL格式(片段标识符等)")] - public void SpecialUrlFormats_ParsedAppropriately() - { - // Arrange - string[] args = ["myapp://path?param=value#section"]; - string? param = null; - string? fragment = null; - - // Act - CommandLine.Parse(args, Scheme) - .AddHandler(o => - { - param = o.Param; - fragment = o.Fragment; - }) - .Run(); - - // Assert - Assert.AreEqual("value", param); - Assert.AreEqual("section", fragment); // 片段标识符被正确处理 - } - - #endregion -} - -#region 测试用数据模型 - -internal record Url01_BasicUrlOptions -{ - [Value(Length = int.MaxValue)] - public string Path { get; init; } = string.Empty; - - [Option("readOnly")] - public bool ReadOnly { get; init; } - - [Option] - public string Highlight { get; init; } = string.Empty; -} - -internal record Url02_SchemeOptions -{ - [Value] - public string Action { get; init; } = string.Empty; - - [Option] - public string Param { get; init; } = string.Empty; -} - -internal record Url03_PositionalValueOptions -{ - [Value] - public string Value { get; init; } = string.Empty; -} - -internal record Url04_CaseInsensitiveSchemeOptions -{ - [Value] - public string Path { get; init; } = string.Empty; - - [Option] - public string Param { get; init; } = string.Empty; -} - -internal record Url05_BasicQueryParamOptions -{ - [Option] - public string Name { get; init; } = string.Empty; -} - -internal record Url06_MultipleQueryParamOptions -{ - [Option] - public string Name { get; init; } = string.Empty; - - [Option] - public int Age { get; init; } - - [Option] - public string Location { get; init; } = string.Empty; -} - -internal record Url07_BooleanQueryParamOptions -{ - [Option] - public bool Debug { get; init; } - - [Option] - public bool Verbose { get; init; } -} - -internal record Url08_EmptyValueQueryParamOptions -{ - [Option] - public string Name { get; init; } = string.Empty; - - [Option] - public string Comment { get; init; } = string.Empty; -} - -internal record Url09_ArrayQueryParamOptions -{ - [Option] - public string[] Tags { get; init; } = []; -} - -internal record Url10_IntegerTypeOptions -{ - [Option] - public int Id { get; init; } - - [Option] - public int Count { get; init; } -} - -internal record Url11_BooleanTypeOptions -{ - [Option] - public bool Enabled { get; init; } - - [Option] - public bool Disabled { get; init; } - - [Option] - public bool Flag { get; init; } -} - -internal record Url12_EnumTypeOptions -{ - /// - /// 当前项目中的枚举。(源生成器应该要能正确识别。) - /// - [Option] - public LogLevel LogLevel { get; init; } - - /// - /// 引用项目中的枚举。(源生成器应该要能正确识别。) - /// - [Option] - public CommandLineStyle Style { get; init; } -} - -internal record Url13_CollectionTypeOptions -{ - [Option] - public string[] Ids { get; init; } = []; - - [Option] - public IReadOnlyList Names { get; init; } = []; -} - -internal record Url14_UrlEncodedOptions -{ - [Option] - public string Query { get; init; } = string.Empty; - - [Option] - public string Path { get; init; } = string.Empty; -} - -internal record Url15_SpecialCharsOptions -{ - [Option] - public string Special { get; init; } = string.Empty; -} - -internal record Url16_NonAsciiOptions -{ - [Option] - public string Chinese { get; init; } = string.Empty; - - [Option] - public string Emoji { get; init; } = string.Empty; -} - -internal record Url17_PathAsPositionalOptions -{ - [Value(Length = int.MaxValue)] - public required string[] Paths { get; init; } -} - -[Command("open")] -internal record Url18_CommandPathOptions -{ - [Value(0)] - public string FilePath { get; init; } = string.Empty; - - [Option] - public bool ReadOnly { get; init; } -} - -internal record Url19_DefaultValueOptions -{ - [Value] - public string Path { get; set; } = "default-path"; -} - -internal record Url20_MalformedUrlOptions -{ - [Option] - public string Value { get; init; } = string.Empty; -} - -internal record Url21_DuplicateParamOptions -{ - [Option] - public string Name { get; init; } = string.Empty; -} - -internal record Url22_FragmentOptions -{ - [Option] - public string Param { get; init; } = string.Empty; - - [Option("fragment")] - public string Fragment { get; init; } = string.Empty; -} - -#endregion