diff --git a/.github/workflows/dotnet-build.yml b/.github/workflows/dotnet-build.yml index 12bfa2ef..e00887bd 100644 --- a/.github/workflows/dotnet-build.yml +++ b/.github/workflows/dotnet-build.yml @@ -14,7 +14,7 @@ jobs: with: dotnet-version: | 8.0.x - 9.0.x + 10.0.x - name: Build run: dotnet build -c release diff --git a/.github/workflows/nuget-tag-publish.yml b/.github/workflows/nuget-tag-publish.yml index 801818e4..cda9e156 100644 --- a/.github/workflows/nuget-tag-publish.yml +++ b/.github/workflows/nuget-tag-publish.yml @@ -1,6 +1,6 @@ name: NuGet Package -on: +on: push: tags: - '*' @@ -18,12 +18,12 @@ jobs: with: dotnet-version: | 8.0.x - 9.0.x + 10.0.x - name: Install dotnet tool run: dotnet tool install -g dotnetCampus.TagToVersion - - name: Set tag to version + - name: Set tag to version run: dotnet TagToVersion -t ${{ github.ref }} - name: Build with dotnet diff --git a/Directory.Packages.props b/Directory.Packages.props index 2194a75f..7ccae259 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,6 +5,7 @@ + @@ -17,4 +18,4 @@ - \ No newline at end of file + diff --git a/docs/en/README.md b/docs/en/README.md index 70c64089..75326ffc 100644 --- a/docs/en/README.md +++ b/docs/en/README.md @@ -365,6 +365,121 @@ commandLine 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). +## Help Information + +DotNetCampus.CommandLine has a built-in help message generation mechanism. When users pass help flags such as `--help`, `-h`, or `/?`, the program automatically outputs help information and exits. + +### Enabling Help + +Call `AddHelpHandler()` to enable help (it is recommended to place it at the end of the handler chain for consistency, but it can actually be placed at any position): + +```csharp +await CommandLine.Parse(args) + .AddHandler(o => o.Run()) + .AddHandler() + .AddHandler() + .AddHelpHandler() + .RunAsync(); +``` + +### Adding Descriptions to Options and Commands + +Use the `Description` property to add description text to commands, options, and positional arguments: + +```csharp +[Command("convert", Description = "Convert input values and demonstrate type parsing.")] +internal class ConvertHandler : ICommandHandler +{ + [Value(0, Description = "The input file to convert.")] + public required string InputFile { get; init; } + + [Option('f', "format", Description = "Output format.")] + public OutputFormat Format { get; init; } = OutputFormat.Text; + + [Option('n', "count", Description = "Maximum number of records to convert.")] + public int? Count { get; init; } + + public Task RunAsync() { /* ... */ } +} +``` + +`OptionAttribute` also has a `ValueName` property for customizing the value placeholder displayed in help: + +```csharp +[Option(Description = "Pass any directory into this option.", ValueName = "directory_path")] +public string? DefaultDirectory { get; set; } +``` + +This displays as `--default-directory ` in help output instead of the default `--default-directory `. + +When `ValueName` is not set, the value placeholder is automatically generated based on the option type: + +| Option Type | Default Placeholder | +| ----------------- | ------------------- | +| Boolean option | (none) | +| Regular option | `` | +| Collection option | `...` | +| Dictionary option | `=...` | + +After setting `ValueName`, the `value` in the placeholder is replaced with your specified name (collections and dictionaries still retain the `...` suffix). + +### Automatic Detection of Help Flags + +The library automatically detects help flags based on the current command line style: + +| Style | Supported Help Flags | +| ----------------- | ---------------------------------------- | +| Flexible (default)| `--help` `-h` `/help` `/h` `/?` `-?` | +| DotNet / Gnu | `--help` `-h` | +| Posix | `-h` | +| Windows | `/help` `/h` `/?` `-help` `-h` `-?` | +| URL | Help detection not supported | + +When users pass a subcommand + help flag (e.g., `myapp convert --help`), only the help for that subcommand is displayed; when a help flag is passed directly (e.g., `myapp --help`), global help is displayed, including the list of all subcommands. + +After help output completes, the program returns exit code `0` and does not continue executing command handlers. + +### Customizing Help Behavior + +Use `HelpConfigurations` to customize various aspects of help: + +```csharp +.AddHelpHandler(new HelpConfigurations +{ + // Maximum width of the option/command name column (default 30); items exceeding this width have their description on the next line + MaxColumnWidth = 40, + + // Localization: use the Description value as a key and return localized text + HelpTextLocalizer = key => MyResources.ResourceManager.GetString(key) ?? key, + + // Custom output target (defaults to Console.Out) + HelpMessageWriter = text => File.WriteAllText("help.txt", text), + + // Fully custom help handler (implement IHelpHandler interface) + HelpHandler = new MyCustomHelpHandler(), +}) +``` + +#### Help Text Localization + +If your program needs multilingual support, you can set `Description` to a resource key and use the `HelpTextLocalizer` delegate for translation: + +```csharp +// Use a resource key as Description when defining a command +[Command(Description = nameof(LocalizableStrings.SampleCommandDescription))] +internal class DefaultOptions +{ + [Option(Description = nameof(LocalizableStrings.SamplePropertyDescription))] + public string? DefaultText { get; set; } +} + +// Provide the localization delegate when enabling help +.AddHelpHandler(new HelpConfigurations +{ + HelpTextLocalizer = key => LocalizableStrings.ResourceManager.GetString(key) ?? key, +}) +``` + ## URL Protocol Support DotNetCampus.CommandLine can parse a URL protocol string: diff --git a/docs/zh-hans/README.md b/docs/zh-hans/README.md index ac347842..dc4df12e 100644 --- a/docs/zh-hans/README.md +++ b/docs/zh-hans/README.md @@ -373,6 +373,121 @@ commandLine 1. 如果多个命令处理器匹配同一个命令,会抛出 `CommandNameAmbiguityException`。 1. 命令处理器中,有任何一个是异步时,你将必须使用 `RunAsync` 替代 `Run`,否则会编译不通过。 +## 帮助信息 + +DotNetCampus.CommandLine 内置了帮助信息生成机制。当用户传入 `--help`、`-h`、`/?` 等帮助标志时,程序会自动输出帮助信息并退出。 + +### 启用帮助 + +调用 `AddHelpHandler()` 即可启用帮助(推荐放在处理器链末尾以保持格式统一,但实际上放在任意位置均可): + +```csharp +await CommandLine.Parse(args) + .AddHandler(o => o.Run()) + .AddHandler() + .AddHandler() + .AddHelpHandler() + .RunAsync(); +``` + +### 为选项和命令添加描述 + +通过 `Description` 属性为命令、选项和位置参数添加描述文本: + +```csharp +[Command("convert", Description = "Convert input values and demonstrate type parsing.")] +internal class ConvertHandler : ICommandHandler +{ + [Value(0, Description = "The input file to convert.")] + public required string InputFile { get; init; } + + [Option('f', "format", Description = "Output format.")] + public OutputFormat Format { get; init; } = OutputFormat.Text; + + [Option('n', "count", Description = "Maximum number of records to convert.")] + public int? Count { get; init; } + + public Task RunAsync() { /* ... */ } +} +``` + +`OptionAttribute` 还有一个 `ValueName` 属性,用于在帮助中显示值占位符: + +```csharp +[Option(Description = "Pass any directory into this option.", ValueName = "directory_path")] +public string? DefaultDirectory { get; set; } +``` + +这会在帮助输出中显示为 `--default-directory ` 而非默认的 `--default-directory `。 + +未设置 `ValueName` 时,帮助中的值占位符根据选项类型自动生成: + +| 选项类型 | 默认占位符 | +| -------- | ------------------ | +| 布尔选项 | (无) | +| 普通选项 | `` | +| 集合选项 | `...` | +| 字典选项 | `=...` | + +设置 `ValueName` 后,占位符中的 `value` 会被替换为你指定的名称(集合和字典仍保留 `...` 后缀)。 + +### 帮助标志的自动检测 + +库会根据当前的命令行风格自动检测对应的帮助标志: + +| 风格 | 支持的帮助标志 | +| ----------------- | ---------------------------------------- | +| Flexible(默认) | `--help` `-h` `/help` `/h` `/?` `-?` | +| DotNet / Gnu | `--help` `-h` | +| Posix | `-h` | +| Windows | `/help` `/h` `/?` `-help` `-h` `-?` | +| URL | 不支持帮助检测 | + +当用户传入子命令 + 帮助标志时(如 `myapp convert --help`),只显示该子命令的帮助;当直接传入帮助标志时(如 `myapp --help`),则显示全局帮助,包含所有子命令列表。 + +帮助输出完成后程序会返回退出代码 `0`,不会继续执行命令处理器。 + +### 自定义帮助行为 + +通过 `HelpConfigurations` 可以自定义帮助的各个方面: + +```csharp +.AddHelpHandler(new HelpConfigurations +{ + // 选项/命令名称列的最大宽度(默认 30),超过此宽度的项其描述换行显示 + MaxColumnWidth = 40, + + // 本地化:以 Description 的值为键,返回本地化文本 + HelpTextLocalizer = key => MyResources.ResourceManager.GetString(key) ?? key, + + // 自定义输出目标(默认写入 Console.Out) + HelpMessageWriter = text => File.WriteAllText("help.txt", text), + + // 完全自定义帮助处理器(实现 IHelpHandler 接口) + HelpHandler = new MyCustomHelpHandler(), +}) +``` + +#### 帮助文本本地化 + +如果你的程序需要多语言支持,可以将 `Description` 设置为资源键,然后通过 `HelpTextLocalizer` 委托进行翻译: + +```csharp +// 定义命令时使用资源键作为 Description +[Command(Description = nameof(LocalizableStrings.SampleCommandDescription))] +internal class DefaultOptions +{ + [Option(Description = nameof(LocalizableStrings.SamplePropertyDescription))] + public string? DefaultText { get; set; } +} + +// 启用帮助时提供本地化委托 +.AddHelpHandler(new HelpConfigurations +{ + HelpTextLocalizer = key => LocalizableStrings.ResourceManager.GetString(key) ?? key, +}) +``` + ## URL协议支持 DotNetCampus.CommandLine 支持解析 URL 协议字符串,格式如下: diff --git a/docs/zh-hant/README.md b/docs/zh-hant/README.md index beda4a2e..b9e17cd0 100644 --- a/docs/zh-hant/README.md +++ b/docs/zh-hant/README.md @@ -364,6 +364,121 @@ commandLine 3. 多個處理器匹配同一命令會擲出 `CommandNameAmbiguityException`。 4. 若有任何處理器為非同步,必須使用 `RunAsync`(否則編譯失敗)。 +## 幫助資訊 + +DotNetCampus.CommandLine 內建了幫助資訊產生機制。當使用者傳入 `--help`、`-h`、`/?` 等幫助旗標時,程式會自動輸出幫助資訊並結束。 + +### 啟用幫助 + +呼叫 `AddHelpHandler()` 即可啟用幫助(建議放在處理器鏈末尾以保持格式統一,但實際上放在任意位置均可): + +```csharp +await CommandLine.Parse(args) + .AddHandler(o => o.Run()) + .AddHandler() + .AddHandler() + .AddHelpHandler() + .RunAsync(); +``` + +### 為選項和命令新增描述 + +透過 `Description` 屬性為命令、選項和位置參數新增描述文字: + +```csharp +[Command("convert", Description = "Convert input values and demonstrate type parsing.")] +internal class ConvertHandler : ICommandHandler +{ + [Value(0, Description = "The input file to convert.")] + public required string InputFile { get; init; } + + [Option('f', "format", Description = "Output format.")] + public OutputFormat Format { get; init; } = OutputFormat.Text; + + [Option('n', "count", Description = "Maximum number of records to convert.")] + public int? Count { get; init; } + + public Task RunAsync() { /* ... */ } +} +``` + +`OptionAttribute` 還有一個 `ValueName` 屬性,用於在幫助中顯示值佔位符: + +```csharp +[Option(Description = "Pass any directory into this option.", ValueName = "directory_path")] +public string? DefaultDirectory { get; set; } +``` + +這會在幫助輸出中顯示為 `--default-directory ` 而非預設的 `--default-directory `。 + +未設定 `ValueName` 時,幫助中的值佔位符根據選項型別自動產生: + +| 選項型別 | 預設佔位符 | +| -------- | ------------------ | +| 布林選項 | (無) | +| 普通選項 | `` | +| 集合選項 | `...` | +| 字典選項 | `=...` | + +設定 `ValueName` 後,佔位符中的 `value` 會被替換為你指定的名稱(集合和字典仍保留 `...` 後綴)。 + +### 幫助旗標的自動偵測 + +程式庫會根據目前的命令列風格自動偵測對應的幫助旗標: + +| 風格 | 支援的幫助旗標 | +| ----------------- | ---------------------------------------- | +| Flexible(預設) | `--help` `-h` `/help` `/h` `/?` `-?` | +| DotNet / Gnu | `--help` `-h` | +| Posix | `-h` | +| Windows | `/help` `/h` `/?` `-help` `-h` `-?` | +| URL | 不支援幫助偵測 | + +當使用者傳入子命令 + 幫助旗標時(如 `myapp convert --help`),只顯示該子命令的幫助;當直接傳入幫助旗標時(如 `myapp --help`),則顯示全域幫助,包含所有子命令列表。 + +幫助輸出完成後程式會回傳結束代碼 `0`,不會繼續執行命令處理器。 + +### 自訂幫助行為 + +透過 `HelpConfigurations` 可以自訂幫助的各個面向: + +```csharp +.AddHelpHandler(new HelpConfigurations +{ + // 選項/命令名稱欄的最大寬度(預設 30),超過此寬度的項目其描述會換行顯示 + MaxColumnWidth = 40, + + // 本地化:以 Description 的值為鍵,回傳本地化文字 + HelpTextLocalizer = key => MyResources.ResourceManager.GetString(key) ?? key, + + // 自訂輸出目標(預設寫入 Console.Out) + HelpMessageWriter = text => File.WriteAllText("help.txt", text), + + // 完全自訂幫助處理器(實作 IHelpHandler 介面) + HelpHandler = new MyCustomHelpHandler(), +}) +``` + +#### 幫助文字本地化 + +如果你的程式需要多語言支援,可以將 `Description` 設定為資源鍵,然後透過 `HelpTextLocalizer` 委派進行翻譯: + +```csharp +// 定義命令時使用資源鍵作為 Description +[Command(Description = nameof(LocalizableStrings.SampleCommandDescription))] +internal class DefaultOptions +{ + [Option(Description = nameof(LocalizableStrings.SamplePropertyDescription))] + public string? DefaultText { get; set; } +} + +// 啟用幫助時提供本地化委派 +.AddHelpHandler(new HelpConfigurations +{ + HelpTextLocalizer = key => LocalizableStrings.ResourceManager.GetString(key) ?? key, +}) +``` + ## URL 協議支援 可解析 URL 協議字串: diff --git a/samples/DotNetCampus.CommandLine.Sample/ConvertHandler.cs b/samples/DotNetCampus.CommandLine.Sample/ConvertHandler.cs new file mode 100644 index 00000000..72275d71 --- /dev/null +++ b/samples/DotNetCampus.CommandLine.Sample/ConvertHandler.cs @@ -0,0 +1,41 @@ +using DotNetCampus.Cli.Compiler; + +namespace DotNetCampus.Cli; + +[Command("convert", Description = "Convert input values and demonstrate type parsing.")] +internal class ConvertHandler : ICommandHandler +{ + [Value(0, Description = "The input file to convert.")] + public required string InputFile { get; init; } + + [Option('f', "format", Description = "Output format.")] + public OutputFormat Format { get; init; } = OutputFormat.Text; + + [Option("columns", Description = "Columns to include in the output.")] + public IReadOnlyList? Columns { get; init; } + + [Option('n', "count", Description = "Maximum number of records to convert.")] + public int? Count { get; init; } + + public Task RunAsync() + { + Console.WriteLine($"Converting: {InputFile}"); + Console.WriteLine($"Format: {Format}"); + if (Count is { } count) + { + Console.WriteLine($"Max records: {count}"); + } + if (Columns is { Count: > 0 } columns) + { + Console.WriteLine($"Columns: {string.Join(", ", columns)}"); + } + return Task.FromResult(0); + } +} + +public enum OutputFormat +{ + Text, + Json, + Xml, +} diff --git a/samples/DotNetCampus.CommandLine.Sample/DefaultOptions.cs b/samples/DotNetCampus.CommandLine.Sample/DefaultOptions.cs index d9281106..0749dfc3 100644 --- a/samples/DotNetCampus.CommandLine.Sample/DefaultOptions.cs +++ b/samples/DotNetCampus.CommandLine.Sample/DefaultOptions.cs @@ -1,23 +1,33 @@ -using DotNetCampus.Cli.Compiler; +using DotNetCampus.Cli.Compiler; using DotNetCampus.Cli.Properties; -#pragma warning disable CS0618 // 类型或成员已过时 - namespace DotNetCampus.Cli; +[Command(Description = nameof(LocalizableStrings.SampleCommandDescription))] internal class DefaultOptions { [RawArguments] public required string[] MainArgs { get; init; } - [Option(LocalizableDescription = nameof(LocalizableStrings.SamplePropertyDescription))] + [Option(Description = nameof(LocalizableStrings.SamplePropertyDescription))] public string? DefaultText { get; set; } - [Option(LocalizableDescription = nameof(LocalizableStrings.SampleDirectoryPropertyDescription))] + [Option(Description = nameof(LocalizableStrings.SampleDirectoryPropertyDescription), ValueName = "directory_path")] public string? DefaultDirectory { get; set; } internal void Run() { - Console.WriteLine("默认行为执行……"); + if (DefaultText is { } text) + { + Console.WriteLine($"Text: {text}"); + } + if (DefaultDirectory is { } dir) + { + Console.WriteLine($"Directory: {dir}"); + } + if (MainArgs is { Length: > 0 }) + { + Console.WriteLine($"Raw args: {string.Join(" ", MainArgs)}"); + } } } diff --git a/samples/DotNetCampus.CommandLine.Sample/EditHandler.cs b/samples/DotNetCampus.CommandLine.Sample/EditHandler.cs new file mode 100644 index 00000000..7c36ee02 --- /dev/null +++ b/samples/DotNetCampus.CommandLine.Sample/EditHandler.cs @@ -0,0 +1,40 @@ +using DotNetCampus.Cli.Compiler; + +namespace DotNetCampus.Cli; + +internal class EditOptionsBase +{ + [Option('f', "file", Description = "The file to edit.")] + public required string FilePath { get; init; } + + [Option("read-only", Description = "Open in read-only mode.")] + public bool? ReadOnly { get; init; } +} + +[Command("edit", Description = "Open a file for editing.")] +internal class EditHandler : EditOptionsBase, ICommandHandler +{ + [Option('l', "line", Description = "Jump to line number.")] + public int? Line { get; init; } + + [Option(["e", "E"], ["encoding", "enc"], Description = "File encoding.")] + public string? Encoding { get; init; } + + public Task RunAsync() + { + Console.WriteLine($"Editing: {FilePath}"); + if (ReadOnly is true) + { + Console.WriteLine("(read-only)"); + } + if (Line is { } line) + { + Console.WriteLine($"Line: {line}"); + } + if (Encoding is { } encoding) + { + Console.WriteLine($"Encoding: {encoding}"); + } + return Task.FromResult(0); + } +} diff --git a/samples/DotNetCampus.CommandLine.Sample/Fakes/OptionsParser.cs b/samples/DotNetCampus.CommandLine.Sample/Fakes/OptionsParser.cs deleted file mode 100644 index 94c66aa0..00000000 --- a/samples/DotNetCampus.CommandLine.Sample/Fakes/OptionsParser.cs +++ /dev/null @@ -1,97 +0,0 @@ -using System.Collections.Generic; -using dotnetCampus.Cli; - -namespace DotNetCampus.Cli.Tests.Fakes -{ - public class OptionsParser : ICommandLineOptionParser - { - private bool _isFromCloud; - private string? _filePath; - private string? _startupMode; - private bool _isSilence; - private bool _isIwb; - private string? _placement; - private string? _startupSession; - - public string? Verb => null; - - public void SetValue(IReadOnlyList values) - { - _filePath = values[0]; - } - - public void SetValue(char shortName, bool value) - { - switch (shortName) - { - case 's': - _isSilence = value; - break; - } - } - - public void SetValue(char shortName, string value) - { - switch (shortName) - { - case 'f': - _filePath = value; - break; - case 'm': - _startupMode = value; - break; - case 'p': - _placement = value; - break; - } - } - - public void SetValue(char shortName, IReadOnlyList values) - { - } - - public void SetValue(string longName, bool value) - { - switch (longName) - { - case "Cloud": - _isFromCloud = value; - break; - case "Silence": - _isSilence = value; - break; - case "Iwb": - _isIwb = value; - break; - } - } - - public void SetValue(string longName, string value) - { - switch (longName) - { - case "File": - _filePath = value; - break; - case "Mode": - _startupMode = value; - break; - case "Placement": - _placement = value; - break; - case "StartupSession": - _startupSession = value; - break; - } - } - - public void SetValue(string longName, IReadOnlyList values) - { - } - - public Options Commit() - { - return new Options(_filePath, _isFromCloud, _startupMode, _isSilence, _isIwb, _placement, _startupSession); - } - } -} diff --git a/samples/DotNetCampus.CommandLine.Sample/Legacy/BenchmarkHandler.cs b/samples/DotNetCampus.CommandLine.Sample/Legacy/BenchmarkHandler.cs new file mode 100644 index 00000000..4797c870 --- /dev/null +++ b/samples/DotNetCampus.CommandLine.Sample/Legacy/BenchmarkHandler.cs @@ -0,0 +1,100 @@ +using System.Diagnostics; +using System.Runtime.CompilerServices; +using DotNetCampus.Cli.Compiler; + +namespace DotNetCampus.Cli.Legacy; + +[Command("benchmark", Description = "Run performance benchmarks comparing 3.x and 4.x APIs.")] +internal class BenchmarkHandler : ICommandHandler +{ + [Option('n', "count", Description = "Number of iterations for the benchmark.")] + public int Count { get; init; } = 10_000_000; + + [Option('w', "warmup", Description = "Number of warmup iterations.")] + public int Warmup { get; init; } = 10_000; + + public Task RunAsync() + { + var args = new[] { "--file", "test.txt", "--mode", "edit", "--silence" }; + CommandLineParsingOptions parsingOptions = CommandLineParsingOptions.DotNet; + + for (var i = 0; i < Warmup; i++) + { + dotnetCampus.Cli.CommandLine.Parse(args).As(new LegacyOptionsParser()); + dotnetCampus.Cli.CommandLine.Parse(args).As(); + _ = CommandLine.Parse(args, parsingOptions).As(); + } + + var stopwatch = new Stopwatch(); + + Console.WriteLine($"Run {Count} times for: {string.Join(" ", args)}"); + Console.WriteLine("| Version | Parse | As(Parser) | As(Runtime) |"); + Console.WriteLine("| ------- | ------- | ---------- | ----------- |"); + + RunLegacy(stopwatch, args); + RunNew(stopwatch, args, parsingOptions); + + return Task.FromResult(0); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private void RunLegacy(Stopwatch stopwatch, string[] args) + { + Console.Write("| 3.x | "); + stopwatch.Restart(); + for (var i = 0; i < Count; 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 < Count; i++) + { + _ = oldCommandLine.As(new LegacyOptionsParser()); + } + stopwatch.Stop(); + Console.Write($"{stopwatch.ElapsedMilliseconds.ToString(),7} ms | "); + + stopwatch.Restart(); + for (var i = 0; i < Count; i++) + { + _ = oldCommandLine.As(); + } + stopwatch.Stop(); + Console.WriteLine($"{stopwatch.ElapsedMilliseconds.ToString(),8} ms |"); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private void RunNew(Stopwatch stopwatch, string[] args, CommandLineParsingOptions parsingOptions) + { + Console.Write("| 4.x | "); + stopwatch.Restart(); + for (var i = 0; i < Count; 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 < Count; i++) + { + var context = new CommandRunningContext { CommandLine = newCommandLine }; + _ = new LegacyOptionsBuilder().Build(context); + } + stopwatch.Stop(); + Console.Write($"{stopwatch.ElapsedMilliseconds.ToString(),7} ms | "); + + stopwatch.Restart(); + for (var i = 0; i < Count; i++) + { + _ = newCommandLine.As(); + } + stopwatch.Stop(); + Console.WriteLine($"{stopwatch.ElapsedMilliseconds.ToString(),8} ms |"); + } +} diff --git a/samples/DotNetCampus.CommandLine.Sample/Fakes/Options.cs b/samples/DotNetCampus.CommandLine.Sample/Legacy/LegacyOptions.cs similarity index 89% rename from samples/DotNetCampus.CommandLine.Sample/Fakes/Options.cs rename to samples/DotNetCampus.CommandLine.Sample/Legacy/LegacyOptions.cs index 08f7e2df..b4f55ad3 100644 --- a/samples/DotNetCampus.CommandLine.Sample/Fakes/Options.cs +++ b/samples/DotNetCampus.CommandLine.Sample/Legacy/LegacyOptions.cs @@ -1,11 +1,11 @@ -using System.ComponentModel; +using System.ComponentModel; -namespace DotNetCampus.Cli.Tests.Fakes; +namespace DotNetCampus.Cli.Legacy; /// /// 表示此程序在被启动的时候使用的参数信息。此类型是不可变类型,所有实例都是线程安全的。 /// -public class Options +public class LegacyOptions { /// /// 表示通过打开的文件路径。此属性可能为 null,但绝不会是空字符串或空白字符串。 @@ -56,17 +56,11 @@ public class Options [dotnetCampus.Cli.Option("StartupSession")] public string? StartupSession { get; init; } - /// - /// 创建 类的新实例。 - /// - public Options() + public LegacyOptions() { } - /// - /// 创建 类的新实例。 - /// - public Options( + public LegacyOptions( string? filePath, bool isFromCloud, string? startupMode, diff --git a/samples/DotNetCampus.CommandLine.Sample/Legacy/LegacyOptionsParser.cs b/samples/DotNetCampus.CommandLine.Sample/Legacy/LegacyOptionsParser.cs new file mode 100644 index 00000000..29f282ab --- /dev/null +++ b/samples/DotNetCampus.CommandLine.Sample/Legacy/LegacyOptionsParser.cs @@ -0,0 +1,95 @@ +using dotnetCampus.Cli; + +namespace DotNetCampus.Cli.Legacy; + +public class LegacyOptionsParser : ICommandLineOptionParser +{ + private bool _isFromCloud; + private string? _filePath; + private string? _startupMode; + private bool _isSilence; + private bool _isIwb; + private string? _placement; + private string? _startupSession; + + public string? Verb => null; + + public void SetValue(IReadOnlyList values) + { + _filePath = values[0]; + } + + public void SetValue(char shortName, bool value) + { + switch (shortName) + { + case 's': + _isSilence = value; + break; + } + } + + public void SetValue(char shortName, string value) + { + switch (shortName) + { + case 'f': + _filePath = value; + break; + case 'm': + _startupMode = value; + break; + case 'p': + _placement = value; + break; + } + } + + public void SetValue(char shortName, IReadOnlyList values) + { + } + + public void SetValue(string longName, bool value) + { + switch (longName) + { + case "Cloud": + _isFromCloud = value; + break; + case "Silence": + _isSilence = value; + break; + case "Iwb": + _isIwb = value; + break; + } + } + + public void SetValue(string longName, string value) + { + switch (longName) + { + case "File": + _filePath = value; + break; + case "Mode": + _startupMode = value; + break; + case "Placement": + _placement = value; + break; + case "StartupSession": + _startupSession = value; + break; + } + } + + public void SetValue(string longName, IReadOnlyList values) + { + } + + public LegacyOptions Commit() + { + return new LegacyOptions(_filePath, _isFromCloud, _startupMode, _isSilence, _isIwb, _placement, _startupSession); + } +} diff --git a/samples/DotNetCampus.CommandLine.Sample/Fakes/VerbOptions.cs b/samples/DotNetCampus.CommandLine.Sample/Legacy/LegacyVerbOptions.cs similarity index 80% rename from samples/DotNetCampus.CommandLine.Sample/Fakes/VerbOptions.cs rename to samples/DotNetCampus.CommandLine.Sample/Legacy/LegacyVerbOptions.cs index 27db29aa..2d6d87bd 100644 --- a/samples/DotNetCampus.CommandLine.Sample/Fakes/VerbOptions.cs +++ b/samples/DotNetCampus.CommandLine.Sample/Legacy/LegacyVerbOptions.cs @@ -1,30 +1,32 @@ -namespace DotNetCampus.Cli.Tests.Fakes; +using DotNetCampus.Cli.Compiler; + +namespace DotNetCampus.Cli.Legacy; [dotnetCampus.Cli.Verb("Edit")] -[DotNetCampus.Cli.Compiler.Command("Edit")] +[Command("Edit")] public class EditOptions { [dotnetCampus.Cli.Value(0), dotnetCampus.Cli.Option('f', "File")] - [DotNetCampus.Cli.Compiler.Value(0), DotNetCampus.Cli.Compiler.Option('f', "File")] + [Value(0), Option('f', "File")] public string? FilePath { get; set; } } [dotnetCampus.Cli.Verb("Print")] -[DotNetCampus.Cli.Compiler.Command("Print")] +[Command("Print")] public class PrintOptions { - [DotNetCampus.Cli.Compiler.Value(0), Compiler.Option('f', "File")] + [Value(0), Option('f', "File")] public string? FilePath { get; set; } - [DotNetCampus.Cli.Compiler.Option('p', "Printer")] + [Option('p', "Printer")] public string? Printer { get; set; } } [dotnetCampus.Cli.Verb("Share")] -[DotNetCampus.Cli.Compiler.Command("Share")] +[Command("Share")] public class ShareOptions { - [DotNetCampus.Cli.Compiler.Option('t', "Target")] + [Option('t', "Target")] public string? Target { get; set; } } diff --git a/samples/DotNetCampus.CommandLine.Sample/Program.cs b/samples/DotNetCampus.CommandLine.Sample/Program.cs index 9178e4fe..bbf7129f 100644 --- a/samples/DotNetCampus.CommandLine.Sample/Program.cs +++ b/samples/DotNetCampus.CommandLine.Sample/Program.cs @@ -1,153 +1,28 @@ -#define Benchmark -using System.Diagnostics; -using System.Runtime.CompilerServices; -using DotNetCampus.Cli.Compiler; -using DotNetCampus.Cli.Tests.Fakes; +using DotNetCampus.Cli.Legacy; +using DotNetCampus.Cli.Properties; namespace DotNetCampus.Cli; class Program { - static void Main(string[] args) + static async Task Main(string[] args) { -#if !Benchmark - // 第一次运行,排除类型初始化的影响,只测试代码执行性能。 - // 注释掉这句话,可以: - // 1. 测试带类型初始化的性能 - // 2. 测试 AOT 性能 dotnet publish --self-contained -r win-x64 -c release -tl:off .\src\DotNetCampus.CommandLine.Sample\DotNetCampus.CommandLine.Sample.csproj - Run(args); - var stopwatch = Stopwatch.StartNew(); - Run(args); - stopwatch.Stop(); - Console.WriteLine($"[# Elapsed: {stopwatch.Elapsed.TotalMicroseconds} us #]"); -#else - const int warmupCount = 10000; - const int testCount = 10000000; - CommandLineParsingOptions parsingOptions = CommandLineParsingOptions.DotNet; - - for (var i = 0; i < warmupCount; i++) - { - dotnetCampus.Cli.CommandLine.Parse(args).As(new OptionsParser()); - dotnetCampus.Cli.CommandLine.Parse(args).As(); - _ = CommandLine.Parse(args, parsingOptions).As(); - } - - var stopwatch = new Stopwatch(); - - Console.WriteLine($"Run {testCount} times for: {string.Join(" ", 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); - } - 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 |"); - } + var appState = new AppState { - 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++) - { - var context = new CommandRunningContext { CommandLine = newCommandLine }; - _ = new OptionsBuilder().Build(context); - } - stopwatch.Stop(); - Console.Write($"{stopwatch.ElapsedMilliseconds.ToString(),7} ms | "); - stopwatch.Restart(); - for (var i = 0; i < testCount; i++) + AppName = "DotNetCampus.CommandLine.Sample", + }; + + await CommandLine.Parse(args, CommandLineParsingOptions.Flexible) + .AddHandler(o => o.Run()) + .AddHandler() + .AddHandler() + .AddHandler() + .AddHelpHandler(new HelpConfigurations { - _ = newCommandLine.As(); - } - stopwatch.Stop(); - Console.WriteLine($"{stopwatch.ElapsedMilliseconds.ToString(),8} ms |"); - } -#endif - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void Run(string[] args) - { - if (args.Length is 0) - { - } - else if (args[0] == "3.x-parser") - { - Run3xParser(args); - } - else if (args[0] == "3.x-runtime") - { - Run3xRuntime(args); - } - else if (args[0] == "4.x-interceptor") - { - Run4xInterceptor(args); - } - } - - [MethodImpl(MethodImplOptions.NoInlining)] - private static void Run3xParser(string[] args) - { - _ = dotnetCampus.Cli.CommandLine.Parse(args).As(new OptionsParser()); - } - - [MethodImpl(MethodImplOptions.NoInlining)] - private static void Run3xRuntime(string[] args) - { - _ = dotnetCampus.Cli.CommandLine.Parse(args).As(); - } - - [MethodImpl(MethodImplOptions.NoInlining)] - private static void Run4xInterceptor(string[] args) - { - _ = CommandLine.Parse(args, CommandLineParsingOptions.DotNet).As(); - } -} - -// [CollectCommandHandlersFromThisAssembly] -// internal partial class AssemblyCommandHandler; - -[Command("sample")] -internal class SampleCommandHandler : ICommandHandler -{ - [Option("SampleProperty")] - public required string Option { get; init; } - - [Value(Length = int.MaxValue)] - public string? Argument { get; init; } - - public Task RunAsync() - { - Console.WriteLine($"Option: {Option}"); - Console.WriteLine($"Argument: {Argument}"); - return Task.FromResult(0); + HelpTextLocalizer = key => LocalizableStrings.ResourceManager.GetString(key) ?? key, + }) + .ForState(appState).AddHandler() + .ForState() + .RunAsync(); } } diff --git a/samples/DotNetCampus.CommandLine.Sample/SampleOptions.cs b/samples/DotNetCampus.CommandLine.Sample/SampleOptions.cs deleted file mode 100644 index 86fbcdcd..00000000 --- a/samples/DotNetCampus.CommandLine.Sample/SampleOptions.cs +++ /dev/null @@ -1,21 +0,0 @@ -using DotNetCampus.Cli.Compiler; -using DotNetCampus.Cli.Properties; - -#pragma warning disable CS0618 // 类型或成员已过时 - -namespace DotNetCampus.Cli; - -[Command("sample-options", LocalizableDescription = nameof(LocalizableStrings.SampleCommandDescription))] -internal class SampleOptions -{ - [Option(LocalizableDescription = nameof(LocalizableStrings.SamplePropertyDescription))] - public string? SampleText { get; set; } - - [Option(LocalizableDescription = nameof(LocalizableStrings.SampleFilePropertyDescription))] - public string? SampleFile { get; set; } - - internal void Run() - { - Console.WriteLine("示例行为执行……"); - } -} diff --git a/samples/DotNetCampus.CommandLine.Sample/UrlOpenHandler.cs b/samples/DotNetCampus.CommandLine.Sample/UrlOpenHandler.cs new file mode 100644 index 00000000..edc100fa --- /dev/null +++ b/samples/DotNetCampus.CommandLine.Sample/UrlOpenHandler.cs @@ -0,0 +1,35 @@ +using DotNetCampus.Cli.Compiler; + +namespace DotNetCampus.Cli; + +[Command("open", Description = "Open a URL or protocol link.")] +internal class UrlOpenHandler : ICommandHandler +{ + [Value(0, Description = "The URL or path to open.")] + public string? Target { get; init; } + + [Option("fragment", Description = "URL fragment identifier.")] + public string? Fragment { get; init; } + + [Option("ref", Description = "A reference parameter from the URL query.")] + public string? Ref { get; init; } + + public Task RunAsync(AppState state) + { + Console.WriteLine($"[{state.AppName}] Opening: {Target}"); + if (Fragment is { } fragment) + { + Console.WriteLine($"Fragment: #{fragment}"); + } + if (Ref is { } r) + { + Console.WriteLine($"Ref: {r}"); + } + return Task.FromResult(0); + } +} + +internal class AppState +{ + public required string AppName { get; init; } +} diff --git a/src/DotNetCampus.CommandLine.Analyzer/Generators/ModelBuilderGenerator.cs b/src/DotNetCampus.CommandLine.Analyzer/Generators/ModelBuilderGenerator.cs index 323d61cc..7bc2a254 100644 --- a/src/DotNetCampus.CommandLine.Analyzer/Generators/ModelBuilderGenerator.cs +++ b/src/DotNetCampus.CommandLine.Analyzer/Generators/ModelBuilderGenerator.cs @@ -1,3 +1,4 @@ +using DotNetCampus.Cli.Utils; using DotNetCampus.CommandLine.CodeAnalysis; using DotNetCampus.CommandLine.Generators.Builders; using DotNetCampus.CommandLine.Generators.ModelProviding; @@ -127,9 +128,79 @@ private void GenerateCommandObjectMetadata(TypeDeclarationSourceTextBuilder buil { builder .AddMethodDeclaration("public object Build(global::DotNetCampus.Cli.Compiler.CommandRunningContext context)", m => m - .AddRawStatement($"return new {model.Namespace}.{model.GetBuilderTypeName()}().Build(context);")); + .AddRawStatement($"return new {model.Namespace}.{model.GetBuilderTypeName()}().Build(context);")) + .AddMethodDeclaration("public global::DotNetCampus.Cli.Compiler.CommandHelpMetadata? GetHelp()", m => m + .AddRawStatement(GenerateGetHelpReturnStatement(model))); } + private static string GenerateGetHelpReturnStatement(CommandObjectGeneratingModel model) + { + var commandName = model.CommandNames is not null + ? $"\"{EscapeString(model.CommandNames)}\"" + : "null"; + var description = model.Description is not null + ? $"\"{EscapeString(model.Description)}\"" + : "null"; + + var optionEntries = model.OptionProperties.Select(x => + { + var shortNames = string.Join(", ", x.GetShortNames().Select(s => $"\"{EscapeString(s)}\"")); + var longNames = string.Join(", ", x.GetOrdinalLongNames().Select(s => $"\"{EscapeString(s)}\"")); + var valueName = x.ValueName is not null ? $"\"{EscapeString(x.ValueName)}\"" : "null"; + var optionDescription = x.Description is not null ? $"\"{EscapeString(x.Description)}\"" : "null"; + var optionValueType = x.Type.AsCommandValueKind().ToCommandValueTypeName(); + return $$""" + new global::DotNetCampus.Cli.Compiler.OptionHelpInfo + { + ShortNames = [{{shortNames}}], + LongNames = [{{longNames}}], + ValueName = {{valueName}}, + Description = {{optionDescription}}, + IsRequired = {{(x.IsRequired ? "true" : "false")}}, + ValueType = {{optionValueType}}, + } + """; + }); + + var positionalEntries = model.PositionalArgumentProperties.Select(x => + { + var argumentName = NamingHelper.MakeKebabCase(x.PropertyName, true, true).Replace('-', '_'); + var argumentDescription = x.Description is not null ? $"\"{EscapeString(x.Description)}\"" : "null"; + var count = x.Length == int.MaxValue ? "null" : x.Length.ToString(); + return $$""" + new global::DotNetCampus.Cli.Compiler.ValueHelpInfo + { + Index = {{x.Index}}, + Count = {{count}}, + Name = "{{argumentName}}", + Description = {{argumentDescription}}, + IsRequired = {{(x.IsRequired ? "true" : "false")}}, + } + """; + }); + + var options = string.Join("\n", optionEntries.Select(x => $"{x},")); + var positionals = string.Join("\n", positionalEntries.Select(x => $"{x},")); + + return $$""" + return new global::DotNetCampus.Cli.Compiler.CommandHelpMetadata + { + CommandName = {{commandName}}, + Description = {{description}}, + Options = + [ + {{options}} + ], + PositionalArguments = + [ + {{positionals}} + ], + }; + """; + } + + private static string EscapeString(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); + private string GenerateArgumentPropertyCode(PropertyGeneratingModel model) => $"private {GetArgumentPropertyTypeName(model)} {model.PropertyName} = new();"; diff --git a/src/DotNetCampus.CommandLine.Analyzer/Generators/ModelProviding/CommandModelProvider.cs b/src/DotNetCampus.CommandLine.Analyzer/Generators/ModelProviding/CommandModelProvider.cs index af33093b..2238ca6c 100644 --- a/src/DotNetCampus.CommandLine.Analyzer/Generators/ModelProviding/CommandModelProvider.cs +++ b/src/DotNetCampus.CommandLine.Analyzer/Generators/ModelProviding/CommandModelProvider.cs @@ -71,6 +71,8 @@ public static IncrementalValuesProvider SelectComm var @namespace = typeSymbol.ContainingNamespace.ToDisplayString(); var commandNames = attribute?.ConstructorArguments.FirstOrDefault().Value?.ToString(); + var commandDescription = attribute?.NamedArguments + .FirstOrDefault(kv => kv.Key == "Description").Value.Value?.ToString(); var useFullStackParser = attribute?.NamedArguments .FirstOrDefault(kv => kv.Key == "ExperimentalUseFullStackParser").Value.Value as bool? ?? false; var isPublic = typeSymbol.DeclaredAccessibility == Accessibility.Public; @@ -91,6 +93,7 @@ public static IncrementalValuesProvider SelectComm UseFullStackParser = useFullStackParser, IsPublic = isPublic, CommandNames = commandNames, + Description = commandDescription, IsHandler = isHandler, OptionProperties = optionProperties, PositionalArgumentProperties = valueProperties, diff --git a/src/DotNetCampus.CommandLine.Analyzer/Generators/Models/CommandObjectGeneratingModel.cs b/src/DotNetCampus.CommandLine.Analyzer/Generators/Models/CommandObjectGeneratingModel.cs index 7b0e65bf..4d62701d 100644 --- a/src/DotNetCampus.CommandLine.Analyzer/Generators/Models/CommandObjectGeneratingModel.cs +++ b/src/DotNetCampus.CommandLine.Analyzer/Generators/Models/CommandObjectGeneratingModel.cs @@ -18,6 +18,8 @@ internal record CommandObjectGeneratingModel public required string? CommandNames { get; init; } + public required string? Description { get; init; } + public required bool UseFullStackParser { get; init; } public required bool IsHandler { get; init; } diff --git a/src/DotNetCampus.CommandLine.Analyzer/Generators/Models/OptionalArgumentPropertyGeneratingModel.cs b/src/DotNetCampus.CommandLine.Analyzer/Generators/Models/OptionalArgumentPropertyGeneratingModel.cs index e11a60ef..1b3362a7 100644 --- a/src/DotNetCampus.CommandLine.Analyzer/Generators/Models/OptionalArgumentPropertyGeneratingModel.cs +++ b/src/DotNetCampus.CommandLine.Analyzer/Generators/Models/OptionalArgumentPropertyGeneratingModel.cs @@ -18,6 +18,10 @@ private OptionalArgumentPropertyGeneratingModel(IPropertySymbol propertySymbol) public required bool? CaseSensitive { get; init; } + public required string? ValueName { get; init; } + + public required string? Description { get; init; } + public int PropertyIndex { get; set; } = -1; /// @@ -172,12 +176,16 @@ public IReadOnlyList GetShortNames() } var caseSensitive = optionAttribute.NamedArguments.FirstOrDefault(a => a.Key == nameof(OptionAttribute.CaseSensitive)).Value.Value?.ToString(); + var description = optionAttribute.NamedArguments.FirstOrDefault(a => a.Key == nameof(CommandLineAttribute.Description)).Value.Value?.ToString(); + var valueName = optionAttribute.NamedArguments.FirstOrDefault(a => a.Key == nameof(OptionAttribute.ValueName)).Value.Value?.ToString(); return new OptionalArgumentPropertyGeneratingModel(propertySymbol) { ShortNames = shortNames, LongNames = longNames, CaseSensitive = caseSensitive is not null && bool.TryParse(caseSensitive, out var result) ? result : null, + Description = description, + ValueName = valueName, }; } } diff --git a/src/DotNetCampus.CommandLine.Analyzer/Generators/Models/PositionalArgumentPropertyGeneratingModel.cs b/src/DotNetCampus.CommandLine.Analyzer/Generators/Models/PositionalArgumentPropertyGeneratingModel.cs index bd01913f..eb139d97 100644 --- a/src/DotNetCampus.CommandLine.Analyzer/Generators/Models/PositionalArgumentPropertyGeneratingModel.cs +++ b/src/DotNetCampus.CommandLine.Analyzer/Generators/Models/PositionalArgumentPropertyGeneratingModel.cs @@ -14,6 +14,8 @@ private PositionalArgumentPropertyGeneratingModel(IPropertySymbol propertySymbol public required int Length { get; init; } + public required string? Description { get; init; } + public int PropertyIndex { get; set; } = -1; public static PositionalArgumentPropertyGeneratingModel? TryParse(IPropertySymbol propertySymbol) @@ -31,11 +33,13 @@ private PositionalArgumentPropertyGeneratingModel(IPropertySymbol propertySymbol .FirstOrDefault(a => a.Key == nameof(ValueAttribute.Length)).Value.Value?.ToString() // 其次从构造函数参数中拿。 ?? valueAttribute.ConstructorArguments.ElementAtOrDefault(1).Value?.ToString(); + var description = valueAttribute.NamedArguments.FirstOrDefault(a => a.Key == nameof(CommandLineAttribute.Description)).Value.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, + Description = description, }; } } diff --git a/src/DotNetCampus.CommandLine/CommandLineExceptionHandler.cs b/src/DotNetCampus.CommandLine/CommandLineExceptionHandler.cs index 9fc0f5a7..b1b19831 100644 --- a/src/DotNetCampus.CommandLine/CommandLineExceptionHandler.cs +++ b/src/DotNetCampus.CommandLine/CommandLineExceptionHandler.cs @@ -28,6 +28,8 @@ public object Build(CommandRunningContext context) return new CommandLineExceptionHandler(context.CommandLine, ignoreAllExceptions); } + public CommandHelpMetadata? GetHelp() => null; + public Task RunAsync(object createdCommandObject) { return ((CommandLineExceptionHandler)createdCommandObject).RunAsync(); diff --git a/src/DotNetCampus.CommandLine/CommandRunner.cs b/src/DotNetCampus.CommandLine/CommandRunner.cs index b14c1c21..beaf4bee 100644 --- a/src/DotNetCampus.CommandLine/CommandRunner.cs +++ b/src/DotNetCampus.CommandLine/CommandRunner.cs @@ -1,7 +1,10 @@ +using System.Collections.ObjectModel; using System.ComponentModel; using System.Runtime.ExceptionServices; using DotNetCampus.Cli.Compiler; using DotNetCampus.Cli.Exceptions; +using DotNetCampus.Cli.Help; +using DotNetCampus.Cli.Localizations; using DotNetCampus.Cli.Utils.Parsers; namespace DotNetCampus.Cli; @@ -20,6 +23,7 @@ public class CommandRunner : ICommandRunnerBuilder, IAsyncCommandRunnerBuilder private readonly SortedList _candidates; private ICommandObjectMetadata? _default; private ICommandObjectMetadata? _fallback; + private HelpConfigurations? _helpConfigurations; internal CommandRunner(CommandLine commandLine) { @@ -83,15 +87,25 @@ internal bool RunFallback(CommandLineParsingResult result) /// public Task RunAsync() { - var (possibleCommandNames, nullableMetadata) = MatchCommandObject(); + // 帮助检测阶段:在正常命令匹配之前检测帮助请求。 + if (_helpConfigurations is { } help) + { + var args = _commandLine.CommandLineArguments; + var style = _commandLine.ParsingOptions.Style; + if (HelpDetector.IsHelpRequested(args, style)) + { + return RunHelpAsync(style); + } + } - if (nullableMetadata is not { } metadata) + var matched = MatchCommandObject(); + if (matched.Metadata is not { } metadata) { throw new CommandNameNotFoundException( - string.IsNullOrEmpty(possibleCommandNames) - ? "No command handler found. Please ensure that at least one command handler is registered by AddHandler(), especially a default command handler." - : $"No command handler found for command '{possibleCommandNames}'. Please ensure that the command handler is registered by AddHandler().", - possibleCommandNames); + string.IsNullOrEmpty(matched.PossibleCommandNames) + ? Lang.Current.DotNetCampus.CommandLine.Error.CommandNotFound.ToString() + : Lang.Current.DotNetCampus.CommandLine.Error.CommandNotFoundForName.ToString(matched.PossibleCommandNames), + matched.PossibleCommandNames); } var context = new CommandRunningContext @@ -109,7 +123,7 @@ public Task RunAsync() return CommandRunningResult.FromTask(exitCode, _commandLine, commandObject); } - private (string PossibleCommandNames, ICommandObjectMetadata? Metadata) MatchCommandObject() + private MatchedCommand MatchCommandObject() { if (_candidates.Count > 0) { @@ -123,7 +137,7 @@ public Task RunAsync() // 前缀已匹配成功,接下来判断这是否是命令单词边界。 if (header.Length == command.Length || char.IsWhiteSpace(header[command.Length])) { - return (command, factory); + return new MatchedCommand(command, factory, MatchedCommandType.Command); } } } @@ -131,10 +145,10 @@ public Task RunAsync() if (_default is { } defaultFactory) { - return ("", defaultFactory); + return new MatchedCommand("", defaultFactory, MatchedCommandType.Default); } - return (_commandLine.GetHeader(1), null); + return new MatchedCommand(_commandLine.GetHeader(1), null, MatchedCommandType.Unknown); } /// @@ -205,6 +219,27 @@ internal CommandRunner AddFallbackHandler(ICommandObjectMetadata metadata) _fallback = metadata; return this; } + + /// + /// 启用内置帮助支持。 + /// + internal CommandRunner EnableHelp(HelpConfigurations helpConfigurations) + { + _helpConfigurations = helpConfigurations; + return this; + } + + private Task RunHelpAsync(CommandLineStyle style) + { + var matched = MatchCommandObject(); + var helpBuilder = _helpConfigurations?.HelpHandler ?? new HelpHandler + { + Style = style, + Configurations = _helpConfigurations, + }; + helpBuilder.Handle(matched, _default, new ReadOnlyCollection(_candidates.Values)); + return CommandRunningResult.FromTask(Task.FromResult(0), _commandLine, null!); + } } /// diff --git a/src/DotNetCampus.CommandLine/CommandRunnerBuilderExtensions.cs b/src/DotNetCampus.CommandLine/CommandRunnerBuilderExtensions.cs index 1529f2ee..0efd0e8a 100644 --- a/src/DotNetCampus.CommandLine/CommandRunnerBuilderExtensions.cs +++ b/src/DotNetCampus.CommandLine/CommandRunnerBuilderExtensions.cs @@ -308,4 +308,73 @@ public static IAsyncCommandRunnerBuilder AddStandardHandlers(this ICommandRunner { throw new NotSupportedException("Considering that almost no developer thinks the behavior of this method meets expectations, we removed this feature."); } + + /// + /// 启用内置帮助支持。启用后,当检测到帮助请求(如 --help、-h、/? 等)时,将自动输出帮助信息并返回退出码 0。 + /// + /// 命令行执行器构造的链式调用。 + /// 命令行执行器构造的链式调用。 + public static ICommandRunnerBuilder AddHelpHandler(this CommandLine builder) + { + ((ICoreCommandRunnerBuilder)builder).AsRunner().EnableHelp(new HelpConfigurations()); + return builder; + } + + /// + /// 启用内置帮助支持。启用后,当检测到帮助请求(如 --help、-h、/? 等)时,将自动输出帮助信息并返回退出码 0。 + /// + /// 命令行执行器构造的链式调用。 + /// 定制帮助行为的配置项。 + /// 命令行执行器构造的链式调用。 + public static ICommandRunnerBuilder AddHelpHandler(this CommandLine builder, HelpConfigurations configurations) + { + ((ICoreCommandRunnerBuilder)builder).AsRunner().EnableHelp(configurations); + return builder; + } + + /// + /// 启用内置帮助支持。启用后,当检测到帮助请求(如 --help、-h、/? 等)时,将自动输出帮助信息并返回退出码 0。 + /// + /// 命令行执行器构造的链式调用。 + /// 命令行执行器构造的链式调用。 + public static ICommandRunnerBuilder AddHelpHandler(this ICommandRunnerBuilder builder) + { + builder.AsRunner().EnableHelp(new HelpConfigurations()); + return builder; + } + + /// + /// 启用内置帮助支持。启用后,当检测到帮助请求(如 --help、-h、/? 等)时,将自动输出帮助信息并返回退出码 0。 + /// + /// 命令行执行器构造的链式调用。 + /// 定制帮助行为的配置项。 + /// 命令行执行器构造的链式调用。 + public static ICommandRunnerBuilder AddHelpHandler(this ICommandRunnerBuilder builder, HelpConfigurations configurations) + { + builder.AsRunner().EnableHelp(configurations); + return builder; + } + + /// + /// 启用内置帮助支持。启用后,当检测到帮助请求(如 --help、-h、/? 等)时,将自动输出帮助信息并返回退出码 0。 + /// + /// 命令行执行器构造的链式调用。 + /// 命令行执行器构造的链式调用。 + public static IAsyncCommandRunnerBuilder AddHelpHandler(this IAsyncCommandRunnerBuilder builder) + { + builder.AsRunner().EnableHelp(new HelpConfigurations()); + return builder; + } + + /// + /// 启用内置帮助支持。启用后,当检测到帮助请求(如 --help、-h、/? 等)时,将自动输出帮助信息并返回退出码 0。 + /// + /// 命令行执行器构造的链式调用。 + /// 定制帮助行为的配置项。 + /// 命令行执行器构造的链式调用。 + public static IAsyncCommandRunnerBuilder AddHelpHandler(this IAsyncCommandRunnerBuilder builder, HelpConfigurations configurations) + { + builder.AsRunner().EnableHelp(configurations); + return builder; + } } diff --git a/src/DotNetCampus.CommandLine/Compiler/CommandHelpMetadata.cs b/src/DotNetCampus.CommandLine/Compiler/CommandHelpMetadata.cs new file mode 100644 index 00000000..08a5c8da --- /dev/null +++ b/src/DotNetCampus.CommandLine/Compiler/CommandHelpMetadata.cs @@ -0,0 +1,94 @@ +namespace DotNetCampus.Cli.Compiler; + +/// +/// 命令的帮助元数据。 +/// +public sealed class CommandHelpMetadata +{ + /// + /// 命令的名称(如 "add" 或 "remote add")。对于默认命令,此属性为 。 + /// + public required string? CommandName { get; init; } + + /// + /// 命令的描述。如果命令没有指定 ,则此属性为 。 + /// + public required string? Description { get; init; } + + /// + /// 此命令的选项帮助信息列表。没有选项时为空集合。 + /// + public required IReadOnlyList Options { get; init; } + + /// + /// 此命令的位置参数帮助信息列表。没有位置参数时为空集合。 + /// + public required IReadOnlyList PositionalArguments { get; init; } +} + +/// +/// 单个选项的帮助信息。 +/// +public readonly record struct OptionHelpInfo +{ + /// + /// 选项的短名称列表。 + /// + public required IReadOnlyList ShortNames { get; init; } + + /// + /// 选项的长名称列表。 + /// + public required IReadOnlyList LongNames { get; init; } + + /// + /// 选项值在帮助文本中的占位符名称。 + /// + public string? ValueName { get; init; } + + /// + /// 选项的描述。如果选项没有指定 ,则此属性为 。 + /// + public required string? Description { get; init; } + + /// + /// 是否为必需选项。 + /// + public required bool IsRequired { get; init; } + + /// + /// 选项值的类型。 + /// + public required OptionValueType ValueType { get; init; } +} + +/// +/// 单个位置参数的帮助信息。 +/// +public readonly record struct ValueHelpInfo +{ + /// + /// 位置参数的起始索引。 + /// + public required int Index { get; init; } + + /// + /// 位置参数的数量; 表示无限制。 + /// + public required int? Count { get; init; } + + /// + /// 位置参数的名称,从属性名推断。 + /// + public required string Name { get; init; } + + /// + /// 位置参数的描述。如果位置参数没有指定 ,则此属性为 。 + /// + public required string? Description { get; init; } + + /// + /// 是否为必需位置参数。 + /// + public required bool IsRequired { get; init; } +} diff --git a/src/DotNetCampus.CommandLine/Compiler/ICommandObjectMetadata.cs b/src/DotNetCampus.CommandLine/Compiler/ICommandObjectMetadata.cs index 0bd49096..2e32eed7 100644 --- a/src/DotNetCampus.CommandLine/Compiler/ICommandObjectMetadata.cs +++ b/src/DotNetCampus.CommandLine/Compiler/ICommandObjectMetadata.cs @@ -16,6 +16,11 @@ public interface ICommandObjectMetadata /// 包含此命令行对象创建时,命令行运行命令的相关信息。 /// 命令行对象实例。 object Build(CommandRunningContext context); + + /// + /// 获取此命令的帮助元数据。如果无法提供帮助信息,则返回 。 + /// + CommandHelpMetadata? GetHelp(); } /// diff --git a/src/DotNetCampus.CommandLine/Compiler/OptionAttribute.cs b/src/DotNetCampus.CommandLine/Compiler/OptionAttribute.cs index b6fbfe9f..12e90ebd 100644 --- a/src/DotNetCampus.CommandLine/Compiler/OptionAttribute.cs +++ b/src/DotNetCampus.CommandLine/Compiler/OptionAttribute.cs @@ -71,7 +71,7 @@ public OptionAttribute(string longName) } /// - /// 标记一个属性为命令行选项,并具有指定的长名称和短名称。 + /// 标记一个属性为命令行选项,并具有指定的短名称和长名称。 /// /// 选项的短名称。必须是单个字符。 /// 选项名称。必须使用 kebab-case 命名规则,且不带 -- 前缀。 @@ -82,54 +82,15 @@ public OptionAttribute(char shortName, string longName) } /// - /// 标记一个属性为命令行选项,并具有指定的长名称和短名称。 - /// - /// 选项的短名称。必须是单个字符。 - /// 选项名称。必须使用 kebab-case 命名规则,且不带 -- 前缀。 - public OptionAttribute(char shortName, string[] longNames) - { - ShortNames = [shortName.ToString()]; - LongNames = longNames; - } - - /// - /// 标记一个属性为命令行选项,并具有指定的长名称和短名称。 - /// - /// 支持多字符的多个短名称,如用 -tl 来表示 --terminal-logger。 - /// 选项名称。必须使用 kebab-case 命名规则,且不带 -- 前缀。 - public OptionAttribute(string shortName, string longName) - { - ShortNames = [shortName]; - LongNames = [longName]; - } - - /// - /// 标记一个属性为命令行选项,并具有指定的长名称和短名称。 - /// - /// 支持多字符的多个短名称,如用 -tl 来表示 --terminal-logger。 - /// 选项名称。必须使用 kebab-case 命名规则,且不带 -- 前缀。 - public OptionAttribute(string shortName, string[] longNames) - { - ShortNames = [shortName]; - LongNames = longNames; - } - - /// - /// 标记一个属性为命令行选项,并具有指定的长名称和短名称。 - /// - /// 支持多字符的多个短名称,如用 -tl 来表示 --terminal-logger。 - /// 选项名称。必须使用 kebab-case 命名规则,且不带 -- 前缀。 - public OptionAttribute(string[] shortNames, string longName) - { - ShortNames = shortNames; - LongNames = [longName]; - } - - /// - /// 标记一个属性为命令行选项,并具有指定的长名称和短名称。 + /// 标记一个属性为命令行选项,并具有指定的短名称和长名称。
+ /// 如果希望指定多个字符的短名称(注意,只有部分风格支持此语法),则你只能使用此构造函数。 ///
/// 支持多字符的多个短名称,如用 -tl 来表示 --terminal-logger。 /// 选项名称。必须使用 kebab-case 命名规则,且不带 -- 前缀。 + /// + /// 我们使用先短名称后长名称的指定顺序,是因为主流命令行工具的 help 输出是这个顺序; + /// 我们采用相同的顺序以便给开发者带来最熟悉的体验。 + /// public OptionAttribute(string[] shortNames, string[] longNames) { ShortNames = shortNames; @@ -153,4 +114,13 @@ public OptionAttribute(string[] shortNames, string[] longNames) /// 默认情况下使用 解析时所指定的大小写敏感性(而 默认为大小写不敏感)。 /// public bool CaseSensitive { get; init; } + + /// + /// 获取或设置选项值在帮助文本中的占位符名称。 + /// + /// + /// 例如设置为 "file_path",则帮助文本中显示为 --source <file_path>。
+ /// 如果未设置,则根据属性类型自动推断。 + ///
+ public string? ValueName { get; init; } } diff --git a/src/DotNetCampus.CommandLine/Compiler/PropertyAssignments.cs b/src/DotNetCampus.CommandLine/Compiler/PropertyAssignments.cs index 49c8e341..a6ae7f39 100644 --- a/src/DotNetCampus.CommandLine/Compiler/PropertyAssignments.cs +++ b/src/DotNetCampus.CommandLine/Compiler/PropertyAssignments.cs @@ -5,6 +5,7 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using DotNetCampus.Cli.Exceptions; +using DotNetCampus.Cli.Localizations; namespace DotNetCampus.Cli.Compiler; @@ -78,7 +79,7 @@ public NumberArgument Assign(ReadOnlySpan value) } if (!IgnoreExceptions) { - throw new CommandLineParseValueException($"无法将 \"{value.ToString()}\" 转换为数值。"); + throw new CommandLineParseValueException(Lang.Current.DotNetCampus.CommandLine.Parse.CannotConvertToNumber.ToString(value.ToString())); } return this; } @@ -183,7 +184,7 @@ public StringArgument Assign(ReadOnlySpan value) null => null, { Length: 1 } => Value[0], _ when IgnoreExceptions => null, - _ => throw new CommandLineParseValueException($"无法将 \"{Value}\" 转换为字符,因为它的长度不为 1。"), + _ => throw new CommandLineParseValueException(Lang.Current.DotNetCampus.CommandLine.Parse.CannotConvertToChar.ToString(Value)), }; /// @@ -368,7 +369,7 @@ public StringDictionaryArgument Append(ReadOnlySpan key, ReadOnlySpan 1) { - throw new CommandLineParseValueException("字典包含多个元素,无法转换为 KeyValuePair。"); + throw new CommandLineParseValueException(Lang.Current.DotNetCampus.CommandLine.Parse.DictionaryCannotConvertToKeyValuePair); } using var enumerator = Value.GetEnumerator(); @@ -434,7 +435,7 @@ public ErrorArgument Assign(ReadOnlySpan value) [DoesNotReturn] public object ToUnknown() { - throw new CommandLineParseValueException("命令行属性赋值不受支持。"); + throw new CommandLineParseValueException(Lang.Current.DotNetCampus.CommandLine.Parse.PropertyAssignmentNotSupported); } } @@ -473,7 +474,7 @@ public RuntimeEnumArgument Assign(ReadOnlySpan value) } if (!IgnoreExceptions) { - throw new CommandLineParseValueException($"无法将 \"{value.ToString()}\" 转换为 {typeof(T).FullName} 枚举。"); + throw new CommandLineParseValueException(Lang.Current.DotNetCampus.CommandLine.Parse.CannotConvertToEnum.ToString(value.ToString(), typeof(T).FullName!)); } return this; } diff --git a/src/DotNetCampus.CommandLine/DotNetCampus.CommandLine.csproj b/src/DotNetCampus.CommandLine/DotNetCampus.CommandLine.csproj index 078d0d94..0bfc3615 100644 --- a/src/DotNetCampus.CommandLine/DotNetCampus.CommandLine.csproj +++ b/src/DotNetCampus.CommandLine/DotNetCampus.CommandLine.csproj @@ -37,6 +37,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/DotNetCampus.CommandLine/Exceptions/CommandLineException.cs b/src/DotNetCampus.CommandLine/Exceptions/CommandLineException.cs index c6f9505d..16761084 100644 --- a/src/DotNetCampus.CommandLine/Exceptions/CommandLineException.cs +++ b/src/DotNetCampus.CommandLine/Exceptions/CommandLineException.cs @@ -1,11 +1,13 @@ -namespace DotNetCampus.Cli.Exceptions; +using DotNetCampus.Cli.Localizations; + +namespace DotNetCampus.Cli.Exceptions; /// /// 表示命令行解析或执行过程中发生的异常。 /// public class CommandLineException : Exception { - private const string DefaultMessage = "Operation failed due to an error in the command line mechanism."; + private static string DefaultMessage => Lang.Current.DotNetCampus.CommandLine.Error.CommandLineError; /// /// 初始化 类的新实例。 diff --git a/src/DotNetCampus.CommandLine/Exceptions/CommandLineParseException.cs b/src/DotNetCampus.CommandLine/Exceptions/CommandLineParseException.cs index dfcfe59f..a03a5c2a 100644 --- a/src/DotNetCampus.CommandLine/Exceptions/CommandLineParseException.cs +++ b/src/DotNetCampus.CommandLine/Exceptions/CommandLineParseException.cs @@ -1,4 +1,5 @@ -using DotNetCampus.Cli.Utils.Parsers; +using DotNetCampus.Cli.Localizations; +using DotNetCampus.Cli.Utils.Parsers; namespace DotNetCampus.Cli.Exceptions; @@ -7,7 +8,7 @@ namespace DotNetCampus.Cli.Exceptions; /// public class CommandLineParseException : CommandLineException { - private const string DefaultMessage = "Parse the command line failed."; + private static string DefaultMessage => Lang.Current.DotNetCampus.CommandLine.Error.ParseFailed; /// /// 获取导致异常的命令行解析错误类型。 @@ -54,7 +55,7 @@ public CommandLineParseException(string message, Exception innerException) : bas /// public class CommandLineParseValueException : CommandLineParseException { - private const string DefaultMessage = "Failed to parse the command line value."; + private static string DefaultMessage => Lang.Current.DotNetCampus.CommandLine.Error.ParseValueFailed; /// /// 初始化 类的新实例。 diff --git a/src/DotNetCampus.CommandLine/Help/HelpDetector.cs b/src/DotNetCampus.CommandLine/Help/HelpDetector.cs new file mode 100644 index 00000000..8573b2fa --- /dev/null +++ b/src/DotNetCampus.CommandLine/Help/HelpDetector.cs @@ -0,0 +1,100 @@ +namespace DotNetCampus.Cli.Help; + +/// +/// 根据命令行风格检测帮助请求。 +/// +internal static class HelpDetector +{ + /// + /// 检测命令行参数中是否包含帮助请求。 + /// + public static bool IsHelpRequested(IReadOnlyList args, CommandLineStyle style) + { + if (style.Name == "Url") + { + return false; + } + + foreach (var argument in args) + { + if (IsHelpOption(argument, style)) + { + return true; + } + } + return false; + } + + private static bool IsHelpOption(string argument, CommandLineStyle style) + { + var comparison = style.CaseSensitive + ? StringComparison.Ordinal + : StringComparison.OrdinalIgnoreCase; + + var prefix = style.OptionPrefix; + + // --help (DotNet, Gnu, Flexible) + if (prefix is CommandOptionPrefix.DoubleDash or CommandOptionPrefix.Any) + { + if (style.SupportsLongOption && argument.Equals("--help", comparison)) + { + return true; + } + } + + // -h (DotNet, Gnu, Posix, Flexible) + if (prefix is CommandOptionPrefix.DoubleDash or CommandOptionPrefix.Any) + { + if (style.SupportsShortOption) + { + if (argument.Equals("-h", comparison)) + { + return true; + } + + // 短选项组合 (Gnu, Posix): -hxxx contains -h + if (style.SupportsShortOptionCombination && argument.Length > 2 && argument[0] == '-' && argument[1] != '-') + { + var chars = argument.AsSpan(1); + foreach (var c in chars) + { + if (c == 'h' || (!style.CaseSensitive && (c == 'H'))) + { + return true; + } + } + } + } + } + + // /help, /h (Flexible, Windows) + if (prefix is CommandOptionPrefix.Slash or CommandOptionPrefix.SlashOrDash or CommandOptionPrefix.Any) + { + if (argument.Equals("/help", comparison)) + { + return true; + } + if (argument.Equals("/h", comparison)) + { + return true; + } + if (argument.Equals("/?", StringComparison.Ordinal)) + { + return true; + } + } + + // -? (Flexible, Windows) + if (prefix is CommandOptionPrefix.SlashOrDash or CommandOptionPrefix.Any) + { + if (argument.Equals("-?", StringComparison.Ordinal)) + { + return true; + } + } + // -? also supported for DoubleDash prefix in Flexible (which uses Any prefix) + // Already covered by the Any case above. + + return false; + } +} diff --git a/src/DotNetCampus.CommandLine/Help/HelpHandler.cs b/src/DotNetCampus.CommandLine/Help/HelpHandler.cs new file mode 100644 index 00000000..5192ebc2 --- /dev/null +++ b/src/DotNetCampus.CommandLine/Help/HelpHandler.cs @@ -0,0 +1,478 @@ +using System.Text; +using DotNetCampus.Cli.Compiler; +using DotNetCampus.Cli.Localizations; + +namespace DotNetCampus.Cli.Help; + +/// +/// 帮助文本构建器。 +/// +public class HelpHandler : IHelpHandler +{ + /// + /// 帮助的配置信息。 + /// + public HelpConfigurations? Configurations { get; init; } + + /// + /// 要显示命令行帮助的命令行风格。是开发者期望的风格。 + /// + public CommandLineStyle Style { get; init; } + + /// + /// 帮助文本中选项/命令/位置参数名称列的最大宽度。超过此宽度的项,其描述将换到下一行显示。 + /// + private int MaxColumnWidth => Configurations?.MaxColumnWidth ?? 30; + + /// + public void Handle(MatchedCommand matchedCommand, ICommandObjectMetadata? defaultCommandMetadata, + IReadOnlyList subCommandMetadataList) + { + var matchedHelp = matchedCommand.Type switch + { + // 用户传入的参数,对应了一个现有的子命令。 + MatchedCommandType.Command => matchedCommand.Metadata?.GetHelp(), + // 用户传入的参数,没有对应任何已注册的子命令,但有已注册的默认命令可用。 + MatchedCommandType.Default => null, + // 用户传入的参数,没有对应任何已注册的子命令,也没有已注册的默认命令可用。 + _ => null, + }; + + string helpText; + if (matchedHelp is not null) + { + // 特定子命令的帮助。 + helpText = BuildCommandHelp(matchedHelp); + } + else + { + // 根帮助 + 所有子命令的帮助。 + var defaultHelp = defaultCommandMetadata?.GetHelp(); + var commandHelpList = new List(); + foreach (var metadata in subCommandMetadataList) + { + if (metadata.GetHelp() is { } h) + { + commandHelpList.Add(h); + } + } + helpText = BuildRootHelp(defaultHelp, commandHelpList); + } + + if (Configurations?.HelpMessageWriter is { } writer) + { + writer(helpText); + } + else + { + Console.Out.WriteLine(helpText); + } + } + + /// + /// 构建根帮助文本,即用户输入 app --help 时的帮助文本。 + /// + /// 默认帮助元数据(如果没有注册默认命令,则为 )。 + /// 所有子命令的帮助元数据列表。 + /// 用于输出到控制台的帮助文本。 + private string BuildRootHelp( + CommandHelpMetadata? defaultCommandMetadata, + IReadOnlyList subCommandMetadataList) + { + var builder = new StringBuilder(); + + // 1. 程序描述 + var hasDescription = BuildDescription(builder, defaultCommandMetadata); + if (hasDescription) + { + builder.AppendLine(); + } + + // 2. 基本用法示例 + var hasUsage = BuildUsage(builder, null, defaultCommandMetadata, subCommandMetadataList); + if (hasUsage) + { + builder.AppendLine(); + } + + // 3. 子命令 + var hasCommands = BuildCommands(builder, subCommandMetadataList); + if (hasCommands) + { + builder.AppendLine(); + } + + // 4. 位置参数 + var hasPositionalArguments = BuildPositionalArguments(builder, defaultCommandMetadata); + if (hasPositionalArguments) + { + builder.AppendLine(); + } + + // 5. 选项 + var hasOptions = BuildOptions(builder, defaultCommandMetadata); + if (hasOptions) + { + builder.AppendLine(); + } + + return builder.ToString(); + } + + /// + /// 构建子命令帮助文本,即用户输入 app command --help 时的帮助文本。 + /// + /// 用户输入的命令行参数所匹配的特定子命令的帮助元数据。 + /// 用于输出到控制台的帮助文本。 + private string BuildCommandHelp(CommandHelpMetadata help) + { + var builder = new StringBuilder(); + + var hasDescription = BuildDescription(builder, help); + if (hasDescription) + { + builder.AppendLine(); + } + + var hasUsage = BuildUsage(builder, help.CommandName, help, []); + if (hasUsage) + { + builder.AppendLine(); + } + + var hasPositionalArguments = BuildPositionalArguments(builder, help); + if (hasPositionalArguments) + { + builder.AppendLine(); + } + + var hasOptions = BuildOptions(builder, help); + if (hasOptions) + { + builder.AppendLine(); + } + + return builder.ToString(); + } + + /// + /// 派生类重写此方法时,构建程序描述信息。 + /// + /// 用于构建帮助文本的 。 + /// 默认命令的元数据,如果没有注册默认命令,则此参数为 。 + /// 如果存在描述信息,则返回 ;否则返回 + protected virtual bool BuildDescription(StringBuilder builder, CommandHelpMetadata? defaultCommandMetadata) + { + if (defaultCommandMetadata?.Description is not { } description) + { + return false; + } + + builder.AppendLine(ResolveLocalization(description)); + return true; + } + + /// + /// 派生类重写此方法时,构建用法信息(如 用法:app [选项] <命令>)。 + /// + /// 用于构建帮助文本的 。 + /// 当前正在显示帮助的子命令名称。为 时表示根帮助。 + /// 默认命令的元数据,如果没有注册默认命令,则此参数为 。 + /// 所有子命令的帮助元数据列表。 + /// 如果存在用法信息,则返回 ;否则返回 + protected virtual bool BuildUsage(StringBuilder builder, + string? commandName, CommandHelpMetadata? defaultCommandMetadata, IReadOnlyList subCommandMetadataList) + { + var defaultHasOptions = defaultCommandMetadata?.Options.Count > 0; + var hasSubCommands = subCommandMetadataList.Count > 0; + var hasPositionalArguments = defaultCommandMetadata?.PositionalArguments.Count > 0; + var hasUsage = defaultHasOptions || hasSubCommands || hasPositionalArguments; + if (!hasUsage) + { + return false; + } + + builder.Append(Lang.Current.DotNetCampus.CommandLine.Help.UsageHeader); + builder.Append(GetProgramName()); + if (commandName is not null) + { + builder.Append(' '); + builder.Append(commandName); + } + if (defaultHasOptions) + { + builder.Append(' '); + builder.Append(Lang.Current.DotNetCampus.CommandLine.Help.UsageOptions); + } + if (hasSubCommands) + { + builder.Append(' '); + builder.Append(Lang.Current.DotNetCampus.CommandLine.Help.UsageCommand); + } + if (hasPositionalArguments) + { + builder.Append(' '); + builder.Append(Lang.Current.DotNetCampus.CommandLine.Help.UsagePositionalArguments); + } + builder.AppendLine(); + return true; + } + + /// + /// 派生类重写此方法时,构建子命令列表信息。 + /// + /// 用于构建帮助文本的 。 + /// 所有子命令的帮助元数据列表。 + /// 如果存在子命令,则返回 ;否则返回 + protected virtual bool BuildCommands(StringBuilder builder, IReadOnlyList subCommandMetadataList) + { + if (subCommandMetadataList.Count <= 0) + { + return false; + } + + builder.AppendLine(Lang.Current.DotNetCampus.CommandLine.Help.CommandHeader.ToString()); + + var maxColumnWidth = MaxColumnWidth; + var columnWidth = 0; + foreach (var metadata in subCommandMetadataList) + { + var nameLength = metadata.CommandName!.Length; + if (nameLength <= maxColumnWidth && nameLength > columnWidth) + { + columnWidth = nameLength; + } + } + + foreach (var metadata in subCommandMetadataList) + { + var name = metadata.CommandName!; + var prefix = $" {name}"; + + if (name.Length > maxColumnWidth) + { + builder.AppendLine(prefix); + if (metadata.Description is { } description) + { + builder.Append(new string(' ', columnWidth + 4)); + builder.AppendLine(ResolveLocalization(description)); + } + } + else + { + builder.Append(prefix.PadRight(columnWidth + 4)); + if (metadata.Description is { } description) + { + builder.Append(ResolveLocalization(description)); + } + builder.AppendLine(); + } + } + return true; + } + + /// + /// 派生类重写此方法时,构建位置参数列表信息。 + /// + /// 用于构建帮助文本的 。 + /// 默认命令的元数据,如果没有注册默认命令,则此参数为 。 + /// 如果存在位置参数,则返回 ;否则返回 + protected virtual bool BuildPositionalArguments(StringBuilder builder, CommandHelpMetadata? defaultCommandMetadata) + { + if (!(defaultCommandMetadata?.PositionalArguments.Count > 0)) + { + return false; + } + + builder.AppendLine(Lang.Current.DotNetCampus.CommandLine.Help.PositionalArgumentsHeader); + + var maxColumnWidth = MaxColumnWidth; + var columnWidth = 0; + foreach (var positionalArgument in defaultCommandMetadata.PositionalArguments) + { + var nameLength = positionalArgument.Name.Length + 2; // 2 = [ + ] + if (nameLength <= maxColumnWidth && nameLength > columnWidth) + { + columnWidth = nameLength; + } + } + + foreach (var positionalArgument in defaultCommandMetadata.PositionalArguments) + { + var nameDisplay = $"[{positionalArgument.Name}]"; + var prefix = $" {nameDisplay}"; + + if (nameDisplay.Length > maxColumnWidth) + { + builder.AppendLine(prefix); + if (positionalArgument.Description is { } description) + { + builder.Append(new string(' ', columnWidth + 4)); + builder.AppendLine(ResolveLocalization(description)); + } + } + else + { + builder.Append(prefix.PadRight(columnWidth + 4)); + if (positionalArgument.Description is { } description) + { + builder.Append(ResolveLocalization(description)); + } + builder.AppendLine(); + } + } + return true; + } + + /// + /// 派生类重写此方法时,构建选项列表信息。末尾会自动追加 -h|--help 选项。 + /// + /// 用于构建帮助文本的 。 + /// 默认命令的元数据,如果没有注册默认命令,则此参数为 。 + /// 始终返回 ,因为至少会输出 --help 选项。 + protected virtual bool BuildOptions(StringBuilder builder, CommandHelpMetadata? defaultCommandMetadata) + { + if (!(defaultCommandMetadata?.Options.Count > 0)) + { + builder.AppendLine(Lang.Current.DotNetCampus.CommandLine.Help.OptionsHeader); + AppendHelpOption(builder, 0); + return true; + } + + builder.AppendLine(Lang.Current.DotNetCampus.CommandLine.Help.OptionsHeader); + + var maxColumnWidth = MaxColumnWidth; + var columnWidth = 0; + + var optionDisplays = new List<(string NamePart, bool IsRequired, string? Description)>(); + foreach (var option in defaultCommandMetadata.Options) + { + var namePart = FormatOptionName(option); + optionDisplays.Add((namePart, option.IsRequired, option.Description)); + + if (namePart.Length <= maxColumnWidth && namePart.Length > columnWidth) + { + columnWidth = namePart.Length; + } + } + + var helpNamePart = "-h|--help"; + if (helpNamePart.Length <= maxColumnWidth && helpNamePart.Length > columnWidth) + { + columnWidth = helpNamePart.Length; + } + + foreach (var (namePart, isRequired, description) in optionDisplays) + { + AppendOptionLine(builder, namePart, isRequired, description, columnWidth, maxColumnWidth); + } + + AppendHelpOption(builder, columnWidth); + return true; + } + + /// + /// 获取程序名,默认为进程名。 + /// + protected virtual string GetProgramName() + { +#if NET6_0_OR_GREATER + var processName = Environment.ProcessPath; + if (processName is not null) + { + return Path.GetFileNameWithoutExtension(processName); + } +#endif + return Path.GetFileNameWithoutExtension(AppDomain.CurrentDomain.FriendlyName); + } + + /// + /// 派生类重写此方法时,对帮助文本中的描述进行本地化处理。 + /// + /// 原始文本,即开发者在 中指定的值。 + /// 本地化后的文本。如果未设置本地化委托,则原样返回。 + protected virtual string ResolveLocalization(string rawText) + { + return Configurations?.HelpTextLocalizer?.Invoke(rawText) ?? rawText; + } + + private string FormatOptionName(OptionHelpInfo option) + { + var builder = new StringBuilder(); + var first = true; + foreach (var shortName in option.ShortNames) + { + if (!first) builder.Append('|'); + builder.Append('-'); + builder.Append(shortName); + first = false; + } + foreach (var longName in option.LongNames) + { + if (!first) builder.Append('|'); + builder.Append("--"); + builder.Append(longName); + first = false; + } + + var valuePlaceholder = GetValuePlaceholder(option); + if (valuePlaceholder is not null) + { + builder.Append(' '); + builder.Append(valuePlaceholder); + } + + return builder.ToString(); + } + + private static string? GetValuePlaceholder(OptionHelpInfo option) + { + if (option.ValueName is { } valueName) + { + return option.ValueType is OptionValueType.List or OptionValueType.Dictionary + ? $"<{valueName}>..." + : $"<{valueName}>"; + } + + return option.ValueType switch + { + OptionValueType.Boolean => null, + OptionValueType.List => "...", + OptionValueType.Dictionary => "=...", + _ => "", + }; + } + + private void AppendOptionLine(StringBuilder builder, string namePart, bool isRequired, string? description, int columnWidth, int maxColumnWidth) + { + var prefix = $" {namePart}"; + + if (namePart.Length > maxColumnWidth) + { + builder.AppendLine(prefix); + builder.Append(new string(' ', columnWidth + 4)); + } + else + { + builder.Append(prefix.PadRight(columnWidth + 4)); + } + + if (isRequired) + { + builder.Append(Lang.Current.DotNetCampus.CommandLine.Help.Required).Append(' '); + } + if (description is not null) + { + builder.Append(ResolveLocalization(description)); + } + builder.AppendLine(); + } + + private void AppendHelpOption(StringBuilder builder, int columnWidth) + { + var helpNamePart = "-h|--help"; + var prefix = $" {helpNamePart}"; + builder.Append(prefix.PadRight(columnWidth + 4)); + builder.AppendLine(Lang.Current.DotNetCampus.CommandLine.Help.HelpDescription); + } +} diff --git a/src/DotNetCampus.CommandLine/Help/IHelpHandler.cs b/src/DotNetCampus.CommandLine/Help/IHelpHandler.cs new file mode 100644 index 00000000..96bc783a --- /dev/null +++ b/src/DotNetCampus.CommandLine/Help/IHelpHandler.cs @@ -0,0 +1,20 @@ +using DotNetCampus.Cli.Compiler; + +namespace DotNetCampus.Cli.Help; + +/// +/// 处理命令行帮助请求的处理器。 +/// +public interface IHelpHandler +{ + /// + /// 处理帮助请求。 + /// + /// 本次用户输入的命令所匹配到的命令信息。 + /// 默认命令的元数据,可通过 获取其帮助信息。 + /// 所有子命令的元数据,可分别通过 获取其帮助信息。 + void Handle( + MatchedCommand matchedCommand, + ICommandObjectMetadata? defaultCommandMetadata, + IReadOnlyList subCommandMetadataList); +} diff --git a/src/DotNetCampus.CommandLine/HelpConfigurations.cs b/src/DotNetCampus.CommandLine/HelpConfigurations.cs new file mode 100644 index 00000000..1ead4115 --- /dev/null +++ b/src/DotNetCampus.CommandLine/HelpConfigurations.cs @@ -0,0 +1,40 @@ +using DotNetCampus.Cli.Compiler; +using DotNetCampus.Cli.Help; + +namespace DotNetCampus.Cli; + +/// +/// 定制命令行帮助。 +/// +public class HelpConfigurations +{ + /// + /// 决定如何处理命令行的帮助请求。 + /// + /// + /// 默认实现中,会调用 获取帮助的本地化文本,然后使用 输出。 + /// + public IHelpHandler? HelpHandler { get; init; } + + /// + /// 帮助文本中选项/命令/位置参数名称列的最大宽度。超过此宽度的项,其描述将换到下一行显示。 + /// + public int MaxColumnWidth { get; init; } = 30; + + /// + /// 提供帮助文本的本地化。 + /// + /// + /// 默认情况下,写在命令、选项和位置参数上的 属性会直接作为帮助文本显示,
+ /// 但如果希望进行本地化,可以设置此委托,以 的值为键,返回本地化的文本。 + ///
+ public Func? HelpTextLocalizer { get; init; } + + /// + /// 由开发者自行决定如何输出帮助文本。 + /// + /// + /// 默认情况下为标准控制台输出。 + /// + public Action? HelpMessageWriter { get; init; } +} diff --git a/src/DotNetCampus.CommandLine/Localizations/Lang.cs b/src/DotNetCampus.CommandLine/Localizations/Lang.cs new file mode 100644 index 00000000..a5bab0cb --- /dev/null +++ b/src/DotNetCampus.CommandLine/Localizations/Lang.cs @@ -0,0 +1,12 @@ +using DotNetCampus.Localizations; + +namespace DotNetCampus.Cli.Localizations; + +[LocalizedConfiguration(Default = "en", + EnsureKeysIdentical = true, + DependencyMode = DependencyMode.NestedSource, + GenerationMode = GenerationMode.Compiled, + NotificationMode = NotificationMode.InitOnly)] +internal partial class Lang +{ +} diff --git a/src/DotNetCampus.CommandLine/Localizations/en.toml b/src/DotNetCampus.CommandLine/Localizations/en.toml new file mode 100644 index 00000000..491545f5 --- /dev/null +++ b/src/DotNetCampus.CommandLine/Localizations/en.toml @@ -0,0 +1,35 @@ +# Help +DotNetCampus.CommandLine.Help.UsageHeader = "Usage: " +DotNetCampus.CommandLine.Help.UsageOptions = "[options]" +DotNetCampus.CommandLine.Help.UsageCommand = "" +DotNetCampus.CommandLine.Help.UsagePositionalArguments = "[arguments]" +DotNetCampus.CommandLine.Help.CommandHeader = "Commands:" +DotNetCampus.CommandLine.Help.OptionsHeader = "Options:" +DotNetCampus.CommandLine.Help.PositionalArgumentsHeader = "Arguments:" +DotNetCampus.CommandLine.Help.Required = "(required)" +DotNetCampus.CommandLine.Help.HelpDescription = "Show help information" + +# Parse - Value conversion errors +DotNetCampus.CommandLine.Parse.CannotConvertToNumber = "Cannot convert \"{value:string}\" to a number." +DotNetCampus.CommandLine.Parse.CannotConvertToChar = "Cannot convert \"{value:string}\" to a character because its length is not 1." +DotNetCampus.CommandLine.Parse.DictionaryCannotConvertToKeyValuePair = "The dictionary contains more than one element and cannot be converted to a KeyValuePair." +DotNetCampus.CommandLine.Parse.PropertyAssignmentNotSupported = "Command line property assignment is not supported." +DotNetCampus.CommandLine.Parse.CannotConvertToEnum = "Cannot convert \"{value:string}\" to enum {typeName:string}." + +# Parse - Parsing diagnostic errors +DotNetCampus.CommandLine.Parse.OptionNotFound = "Command object {commandObjectName:string} has no option named {optionName:string}. Arguments: {arguments:string}, index {index:string}, argument {argument:string}." +DotNetCampus.CommandLine.Parse.OptionNotFoundInUrl = "Command object {commandObjectName:string} has no option named {optionName:string}. Note that short options are not supported when parsing URLs. URL={url:string}" +DotNetCampus.CommandLine.Parse.OptionParseError = "The argument {argument:string} does not contain an option name. Arguments: {arguments:string}, index {index:string}." +DotNetCampus.CommandLine.Parse.OptionSeparatorNotSupported = "The style {styleName:string} does not support the separator '{separator:string}' in argument {argument:string}. Arguments: {arguments:string}, index {index:string}." +DotNetCampus.CommandLine.Parse.MultiCharShortOptionNotSupported = "The style {styleName:string} does not support multi-character short options in argument {argument:string}. Arguments: {arguments:string}, index {index:string}." +DotNetCampus.CommandLine.Parse.CombinationIsNotBoolean = "Option {optionName:string} in command object {commandObjectName:string} is not a boolean type and cannot be used with short boolean option combination. Arguments: {arguments:string}, index {index:string}, argument {argument:string}." +DotNetCampus.CommandLine.Parse.PositionalArgumentNotFound = "Command object {commandObjectName:string} positional argument range does not contain index {positionalIndex:string}. Arguments: {arguments:string}, index {index:string}, argument {argument:string}." +DotNetCampus.CommandLine.Parse.CannotParseAsBoolean = "Cannot parse {value:string} as a boolean. Arguments: {arguments:string}." +DotNetCampus.CommandLine.Parse.CannotParseAsDictionary = "Cannot parse {value:string} as a key-value pair. Arguments: {arguments:string}." + +# Error - Exception default messages +DotNetCampus.CommandLine.Error.CommandLineError = "Operation failed due to an error in the command line mechanism." +DotNetCampus.CommandLine.Error.ParseFailed = "Failed to parse the command line." +DotNetCampus.CommandLine.Error.ParseValueFailed = "Failed to parse the command line value." +DotNetCampus.CommandLine.Error.CommandNotFound = "No command handler found. Please ensure that at least one command handler is registered by AddHandler(), especially a default command handler." +DotNetCampus.CommandLine.Error.CommandNotFoundForName = "No command handler found for command '{commandName:string}'. Please ensure that the command handler is registered by AddHandler()." diff --git a/src/DotNetCampus.CommandLine/Localizations/zh-hans.toml b/src/DotNetCampus.CommandLine/Localizations/zh-hans.toml new file mode 100644 index 00000000..e7d52931 --- /dev/null +++ b/src/DotNetCampus.CommandLine/Localizations/zh-hans.toml @@ -0,0 +1,35 @@ +# Help +DotNetCampus.CommandLine.Help.UsageHeader = "用法:" +DotNetCampus.CommandLine.Help.UsageOptions = "[选项]" +DotNetCampus.CommandLine.Help.UsageCommand = "<命令>" +DotNetCampus.CommandLine.Help.UsagePositionalArguments = "[位置参数]" +DotNetCampus.CommandLine.Help.CommandHeader = "命令:" +DotNetCampus.CommandLine.Help.OptionsHeader = "选项:" +DotNetCampus.CommandLine.Help.PositionalArgumentsHeader = "位置参数:" +DotNetCampus.CommandLine.Help.Required = "(必需)" +DotNetCampus.CommandLine.Help.HelpDescription = "显示帮助信息" + +# Parse - Value conversion errors +DotNetCampus.CommandLine.Parse.CannotConvertToNumber = "无法将 \"{value:string}\" 转换为数值。" +DotNetCampus.CommandLine.Parse.CannotConvertToChar = "无法将 \"{value:string}\" 转换为字符,因为它的长度不为 1。" +DotNetCampus.CommandLine.Parse.DictionaryCannotConvertToKeyValuePair = "字典包含多个元素,无法转换为 KeyValuePair。" +DotNetCampus.CommandLine.Parse.PropertyAssignmentNotSupported = "命令行属性赋值不受支持。" +DotNetCampus.CommandLine.Parse.CannotConvertToEnum = "无法将 \"{value:string}\" 转换为 {typeName:string} 枚举。" + +# Parse - Parsing diagnostic errors +DotNetCampus.CommandLine.Parse.OptionNotFound = "命令行对象 {commandObjectName:string} 没有任何属性的选项名为 {optionName:string}。参数列表:{arguments:string},索引 {index:string},参数 {argument:string}。" +DotNetCampus.CommandLine.Parse.OptionNotFoundInUrl = "命令行对象 {commandObjectName:string} 没有任何属性的选项名为 {optionName:string},请注意解析 URL 时不支持短选项参数。URL={url:string}" +DotNetCampus.CommandLine.Parse.OptionParseError = "命令行参数 {argument:string} 中不包含选项名称,解析失败。参数列表:{arguments:string},索引 {index:string}。" +DotNetCampus.CommandLine.Parse.OptionSeparatorNotSupported = "当前解析风格 {styleName:string} 不支持选项值分隔符 '{separator:string}',因此无法识别参数 {argument:string}。参数列表:{arguments:string},索引 {index:string}。" +DotNetCampus.CommandLine.Parse.MultiCharShortOptionNotSupported = "当前解析风格 {styleName:string} 不支持多字符短选项,因此无法识别参数 {argument:string}。参数列表:{arguments:string},索引 {index:string}。" +DotNetCampus.CommandLine.Parse.CombinationIsNotBoolean = "命令行对象 {commandObjectName:string} 中,选项 {optionName:string} 的类型不是布尔类型,因此不支持使用短布尔选项组合的方式来表示此选项。参数列表:{arguments:string},索引 {index:string},参数 {argument:string}。" +DotNetCampus.CommandLine.Parse.PositionalArgumentNotFound = "命令行对象 {commandObjectName:string} 位置参数范围不包含索引 {positionalIndex:string}。参数列表:{arguments:string},索引 {index:string},参数 {argument:string}。" +DotNetCampus.CommandLine.Parse.CannotParseAsBoolean = "无法将 {value:string} 解析为布尔值。参数列表:{arguments:string}。" +DotNetCampus.CommandLine.Parse.CannotParseAsDictionary = "无法将 {value:string} 解析为键值对。参数列表:{arguments:string}。" + +# Error - Exception default messages +DotNetCampus.CommandLine.Error.CommandLineError = "命令行机制发生错误,操作失败。" +DotNetCampus.CommandLine.Error.ParseFailed = "命令行解析失败。" +DotNetCampus.CommandLine.Error.ParseValueFailed = "命令行参数值解析失败。" +DotNetCampus.CommandLine.Error.CommandNotFound = "未找到命令处理器。请确保至少通过 AddHandler() 注册了一个命令处理器,尤其是默认命令处理器。" +DotNetCampus.CommandLine.Error.CommandNotFoundForName = "未找到命令 '{commandName:string}' 的处理器。请确保该命令处理器已通过 AddHandler() 注册。" diff --git a/src/DotNetCampus.CommandLine/MatchedCommand.cs b/src/DotNetCampus.CommandLine/MatchedCommand.cs new file mode 100644 index 00000000..830a4a5f --- /dev/null +++ b/src/DotNetCampus.CommandLine/MatchedCommand.cs @@ -0,0 +1,32 @@ +using DotNetCampus.Cli.Compiler; + +namespace DotNetCampus.Cli; + +/// +/// 用户输入的命令匹配到的已注册的命令。 +/// +/// 猜测的子命令。在匹配成功时,这就是已匹配到的子命令;匹配失败时,这是命令行第一个参数(可能是第一个子命令)。 +/// 如果已匹配成功,则此属性为已匹配的命令对象的元数据。 +/// 匹配到的命令类型。 +public readonly record struct MatchedCommand(string PossibleCommandNames, ICommandObjectMetadata? Metadata, MatchedCommandType Type); + +/// +/// 匹配到的命令类型。 +/// +public enum MatchedCommandType +{ + /// + /// 未知(未匹配到)。 + /// + Unknown, + + /// + /// 匹配到了默认命令。即没有匹配到任何子命令对象,且已注册了默认命令。 + /// + Default, + + /// + /// 匹配到了唯一的子命令对象。 + /// + Command, +} diff --git a/src/DotNetCampus.CommandLine/Utils/Handlers/TaskCommandHandler.cs b/src/DotNetCampus.CommandLine/Utils/Handlers/TaskCommandHandler.cs index 43f3acfa..47f2219c 100644 --- a/src/DotNetCampus.CommandLine/Utils/Handlers/TaskCommandHandler.cs +++ b/src/DotNetCampus.CommandLine/Utils/Handlers/TaskCommandHandler.cs @@ -11,6 +11,8 @@ public object Build(CommandRunningContext context) return factory.Build(context); } + public CommandHelpMetadata? GetHelp() => factory.GetHelp(); + public Task RunAsync(object createdCommandObject) { var instance = (ICommandHandler)createdCommandObject; @@ -28,6 +30,8 @@ public object Build(CommandRunningContext context) return factory.Build(context); } + public CommandHelpMetadata? GetHelp() => factory.GetHelp(); + public Task RunAsync(object createdCommandObject) { var instance = (T)createdCommandObject; @@ -46,6 +50,8 @@ public object Build(CommandRunningContext context) return factory.Build(context); } + public CommandHelpMetadata? GetHelp() => factory.GetHelp(); + public Task RunAsync(object createdCommandObject) { var instance = (T)createdCommandObject; @@ -64,6 +70,8 @@ public object Build(CommandRunningContext context) return factory.Build(context); } + public CommandHelpMetadata? GetHelp() => factory.GetHelp(); + public Task RunAsync(object createdCommandObject) { var instance = (T)createdCommandObject; @@ -88,6 +96,8 @@ public object Build(CommandRunningContext context) return factory.Build(context); } + public CommandHelpMetadata? GetHelp() => factory.GetHelp(); + public Task RunAsync(object createdCommandObject) { var instance = (T)createdCommandObject; diff --git a/src/DotNetCampus.CommandLine/Utils/Parsers/CommandLineParsingResult.cs b/src/DotNetCampus.CommandLine/Utils/Parsers/CommandLineParsingResult.cs index e44a6e95..58733f7f 100644 --- a/src/DotNetCampus.CommandLine/Utils/Parsers/CommandLineParsingResult.cs +++ b/src/DotNetCampus.CommandLine/Utils/Parsers/CommandLineParsingResult.cs @@ -1,5 +1,6 @@ using DotNetCampus.Cli.Compiler; using DotNetCampus.Cli.Exceptions; +using DotNetCampus.Cli.Localizations; namespace DotNetCampus.Cli.Utils.Parsers; @@ -138,8 +139,8 @@ public void ThrowIfError() 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("未知的命令行解析错误类型。"), + CommandLineParsingError.None => throw new CommandLineException("Unreachable: no error occurred during parsing."), + _ => throw new CommandLineException("Unreachable: unknown parsing error type."), }; } @@ -180,15 +181,15 @@ public static CommandLineParsingResult OptionalArgumentNotFound(CommandLine comm var message = reason switch { CommandLineParsingError.OptionalArgumentNotFound when isUrl => - $"命令行对象 {commandObjectName} 没有任何属性的选项名为 {optionName.ToString()},请注意解析 URL 时不支持短选项参数。URL={commandLine.ToRawString()}", + Lang.Current.DotNetCampus.CommandLine.Parse.OptionNotFoundInUrl.ToString(commandObjectName, optionName.ToString(), commandLine.ToRawString()), CommandLineParsingError.OptionalArgumentNotFound => - $"命令行对象 {commandObjectName} 没有任何属性的选项名为 {optionName.ToString()}。参数列表:{commandLine},索引 {index},参数 {commandLine.CommandLineArguments[index]}。", + Lang.Current.DotNetCampus.CommandLine.Parse.OptionNotFound.ToString(commandObjectName, optionName.ToString(), commandLine.ToString(), index.ToString(), commandLine.CommandLineArguments[index]), CommandLineParsingError.OptionalArgumentParseError => - $"命令行参数 {commandLine.CommandLineArguments[index]} 中不包含选项名称,解析失败。参数列表:{commandLine},索引 {index}。", + Lang.Current.DotNetCampus.CommandLine.Parse.OptionParseError.ToString(commandLine.CommandLineArguments[index], commandLine.ToString(), index.ToString()), CommandLineParsingError.OptionalArgumentSeparatorNotSupported => - $"当前解析风格 {commandLine.ParsingOptions.Style.Name} 不支持选项值分隔符 '{optionName[possibleSeparatorIndex]}',因此无法识别参数 {commandLine.CommandLineArguments[index]}。参数列表:{commandLine},索引 {index},参数 {commandLine.CommandLineArguments[index]}。", + Lang.Current.DotNetCampus.CommandLine.Parse.OptionSeparatorNotSupported.ToString(commandLine.ParsingOptions.Style.Name, optionName[possibleSeparatorIndex].ToString(), commandLine.CommandLineArguments[index], commandLine.ToString(), index.ToString()), CommandLineParsingError.MultiCharShortOptionalArgumentNotSupported => - $"当前解析风格 {commandLine.ParsingOptions.Style.Name} 不支持多字符短选项,因此无法识别参数 {commandLine.CommandLineArguments[index]}。参数列表:{commandLine},索引 {index},参数 {commandLine.CommandLineArguments[index]}。", + Lang.Current.DotNetCampus.CommandLine.Parse.MultiCharShortOptionNotSupported.ToString(commandLine.ParsingOptions.Style.Name, commandLine.CommandLineArguments[index], commandLine.ToString(), index.ToString()), _ => throw new CommandLineException("Unreachable code."), }; return new CommandLineParsingResult(reason, message); @@ -206,7 +207,7 @@ public static CommandLineParsingResult OptionalArgumentCombinationIsNotBoolean(C ReadOnlySpan optionName) { var message = - $"命令行对象 {commandObjectName} 中,选项 {optionName.ToString()} 的类型不是布尔类型,因此不支持使用短布尔选项组合的方式来表示此选项。参数列表:{commandLine},索引 {index},参数 {commandLine.CommandLineArguments[index]}。"; + Lang.Current.DotNetCampus.CommandLine.Parse.CombinationIsNotBoolean.ToString(optionName.ToString(), commandObjectName, commandLine.ToString(), index.ToString(), commandLine.CommandLineArguments[index]); return new CommandLineParsingResult(CommandLineParsingError.ArgumentCombinationIsNotBoolean, message); } @@ -219,7 +220,7 @@ public static CommandLineParsingResult OptionalArgumentCombinationIsNotBoolean(C /// 表示选项未找到的解析结果。 public static CommandLineParsingResult OptionalArgumentParseError(CommandLine commandLine, int index, string commandObjectName) { - var message = $"命令行参数 {commandLine.CommandLineArguments[index]} 中不包含选项名称,解析失败。参数列表:{commandLine},索引 {index}。"; + var message = Lang.Current.DotNetCampus.CommandLine.Parse.OptionParseError.ToString(commandLine.CommandLineArguments[index], commandLine.ToString(), index.ToString()); return new CommandLineParsingResult(CommandLineParsingError.OptionalArgumentParseError, message); } @@ -234,7 +235,7 @@ public static CommandLineParsingResult OptionalArgumentParseError(CommandLine co public static CommandLineParsingResult PositionalArgumentNotFound(CommandLine commandLine, int index, string commandObjectName, int positionalArgumentIndex) { var message = - $"命令行对象 {commandObjectName} 位置参数范围不包含索引 {positionalArgumentIndex}。参数列表:{commandLine},索引 {index},参数 {commandLine.CommandLineArguments[index]}。"; + Lang.Current.DotNetCampus.CommandLine.Parse.PositionalArgumentNotFound.ToString(commandObjectName, positionalArgumentIndex.ToString(), commandLine.ToString(), index.ToString(), commandLine.CommandLineArguments[index]); return new CommandLineParsingResult(CommandLineParsingError.PositionalArgumentNotFound, message); } @@ -246,7 +247,7 @@ public static CommandLineParsingResult PositionalArgumentNotFound(CommandLine co /// 表示无法将值解析为布尔值的解析结果。 public static CommandLineParsingResult BooleanValueParseError(CommandLine commandLine, ReadOnlySpan value) { - var message = $"无法将 {value.ToString()} 解析为布尔值。参数列表:{commandLine}。"; + var message = Lang.Current.DotNetCampus.CommandLine.Parse.CannotParseAsBoolean.ToString(value.ToString(), commandLine.ToString()); return new CommandLineParsingResult(CommandLineParsingError.BooleanValueParseError, message); } @@ -258,7 +259,7 @@ public static CommandLineParsingResult BooleanValueParseError(CommandLine comman /// 表示无法将值解析为键值对的解析结果。 public static CommandLineParsingResult DictionaryValueParseError(CommandLine commandLine, ReadOnlySpan value) { - var message = $"无法将 {value.ToString()} 解析为键值对。参数列表:{commandLine}。"; + var message = Lang.Current.DotNetCampus.CommandLine.Parse.CannotParseAsDictionary.ToString(value.ToString(), commandLine.ToString()); return new CommandLineParsingResult(CommandLineParsingError.DictionaryValueParseError, message); } } diff --git a/tests/DotNetCampus.CommandLine.Tests/Help/HelpDetectorTests.cs b/tests/DotNetCampus.CommandLine.Tests/Help/HelpDetectorTests.cs new file mode 100644 index 00000000..29953728 --- /dev/null +++ b/tests/DotNetCampus.CommandLine.Tests/Help/HelpDetectorTests.cs @@ -0,0 +1,105 @@ +using DotNetCampus.Cli.Help; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace DotNetCampus.Cli.Tests.Help; + +[TestClass] +public class HelpDetectorTests +{ + [TestMethod] + [DataRow(new[] { "--help" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] --help")] + [DataRow(new[] { "-h" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] -h")] + [DataRow(new[] { "/help" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] /help")] + [DataRow(new[] { "/h" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] /h")] + [DataRow(new[] { "/?" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] /?")] + [DataRow(new[] { "-?" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] -?")] + [DataRow(new[] { "--help" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] --help")] + [DataRow(new[] { "-h" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] -h")] + [DataRow(new[] { "--help" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] --help")] + [DataRow(new[] { "-h" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] -h")] + [DataRow(new[] { "/help" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] /help")] + [DataRow(new[] { "/h" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] /h")] + [DataRow(new[] { "/?" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] /?")] + [DataRow(new[] { "-?" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] -?")] + [DataRow(new[] { "foo", "--help" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] foo --help")] + [DataRow(new[] { "foo", "--help" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] foo --help")] + public void IsHelpRequested(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLineStyle = style.ToParsingOptions().Style; + + // Act + var result = HelpDetector.IsHelpRequested(args, commandLineStyle); + + // Assert + Assert.IsTrue(result); + } + + [TestMethod] + [DataRow(new[] { "--file" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] --file")] + [DataRow(new[] { "-f" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] -f")] + [DataRow(new[] { "help" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] help (no prefix)")] + [DataRow(new string[] { }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] empty")] + [DataRow(new[] { "--file" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] --file")] + [DataRow(new[] { "help" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] help (no prefix)")] + [DataRow(new[] { "--helper" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] --helper")] + [DataRow(new[] { "/file" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] /file")] + public void IsHelpRequested_NotTriggered(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLineStyle = style.ToParsingOptions().Style; + + // Act + var result = HelpDetector.IsHelpRequested(args, commandLineStyle); + + // Assert + Assert.IsFalse(result); + } + + [TestMethod] + [DataRow(new[] { "-vh" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] -vh")] + [DataRow(new[] { "-abh" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] -abh")] + [DataRow(new[] { "-ha" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] -ha")] + public void IsHelpRequested_ShortOptionCombination(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLineStyle = style.ToParsingOptions().Style; + + // Act + var result = HelpDetector.IsHelpRequested(args, commandLineStyle); + + // Assert + Assert.IsTrue(result); + } + + [TestMethod] + [DataRow(new[] { "--Help" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] --Help (case sensitive, not triggered)")] + [DataRow(new[] { "-H" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] -H (case sensitive, not triggered)")] + public void IsHelpRequested_CaseSensitive_NotTriggered(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLineStyle = style.ToParsingOptions().Style; + + // Act + var result = HelpDetector.IsHelpRequested(args, commandLineStyle); + + // Assert + Assert.IsFalse(result); + } + + [TestMethod] + [DataRow(new[] { "--Help" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] --Help (case insensitive, triggered)")] + [DataRow(new[] { "-H" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] -H (case insensitive, triggered)")] + [DataRow(new[] { "/HELP" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] /HELP (case insensitive, triggered)")] + public void IsHelpRequested_CaseInsensitive_Triggered(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLineStyle = style.ToParsingOptions().Style; + + // Act + var result = HelpDetector.IsHelpRequested(args, commandLineStyle); + + // Assert + Assert.IsTrue(result); + } +} diff --git a/tests/DotNetCampus.CommandLine.Tests/Help/HelpOutputTests.cs b/tests/DotNetCampus.CommandLine.Tests/Help/HelpOutputTests.cs new file mode 100644 index 00000000..f35b32ac --- /dev/null +++ b/tests/DotNetCampus.CommandLine.Tests/Help/HelpOutputTests.cs @@ -0,0 +1,256 @@ +using System; +using System.Collections.Generic; +using DotNetCampus.Cli.Compiler; +using DotNetCampus.Cli.Exceptions; +using DotNetCampus.Cli.Help; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace DotNetCampus.Cli.Tests.Help; + +[TestClass] +public class HelpOutputTests +{ + [TestMethod] + [DataRow(new[] { "--help" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] --help")] + [DataRow(new[] { "-h" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] -h")] + [DataRow(new[] { "--help" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] --help")] + [DataRow(new[] { "--help" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] --help")] + [DataRow(new[] { "/?" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] /?")] + public void HelpReturnsExitCode0(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var result = commandLine + .AddHelpHandler() + .AddHandler(_ => { }) + .Run(); + + // Assert + Assert.AreEqual(0, result.ExitCode); + } + + [TestMethod] + [DataRow(new[] { "--help" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] --help")] + [DataRow(new[] { "--help" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] --help")] + [DataRow(new[] { "--help" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] --help")] + [DataRow(new[] { "/?" }, TestCommandLineStyle.Windows, DisplayName = "[Windows] /?")] + public void HelpNotEnabled_ThrowsParseException(string[] args, TestCommandLineStyle style) + { + // Arrange + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act & Assert + Assert.ThrowsExactly(() => commandLine + .AddHandler(_ => { }) + .Run()); + } + + [TestMethod] + [DataRow(new[] { "sub", "--help" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] sub --help")] + [DataRow(new[] { "sub", "--help" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] sub --help")] + [DataRow(new[] { "sub", "--help" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] sub --help")] + public void HelpWithSubCommand_OutputContainsCommandName(string[] args, TestCommandLineStyle style) + { + // Arrange + string? helpText = null; + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + var result = commandLine + .AddHelpHandler(new HelpConfigurations + { + HelpMessageWriter = text => helpText = text, + }) + .AddHandler(_ => { }) + .AddHandler(_ => { }) + .Run(); + + // Assert + Assert.AreEqual(0, result.ExitCode); + Assert.IsNotNull(helpText); + Assert.IsTrue(helpText.Contains("sub"), $"Help text should contain command name 'sub'. Actual: {helpText}"); + Assert.IsTrue(helpText.Contains("Sub command description"), $"Help text should contain description. Actual: {helpText}"); + } + + [TestMethod] + [DataRow(new[] { "--help" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] --help")] + [DataRow(new[] { "--help" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] --help")] + [DataRow(new[] { "--help" }, TestCommandLineStyle.Gnu, DisplayName = "[Gnu] --help")] + public void HelpOutput_ContainsOptionNames(string[] args, TestCommandLineStyle style) + { + // Arrange + string? helpText = null; + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + commandLine + .AddHelpHandler(new HelpConfigurations + { + HelpMessageWriter = text => helpText = text, + }) + .AddHandler(_ => { }) + .Run(); + + // Assert + Assert.IsNotNull(helpText); + Assert.IsTrue(helpText.Contains("--output"), $"Help text should contain '--output'. Actual: {helpText}"); + Assert.IsTrue(helpText.Contains("-o"), $"Help text should contain '-o'. Actual: {helpText}"); + } + + [TestMethod] + [DataRow(new[] { "--help" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] --help")] + [DataRow(new[] { "--help" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] --help")] + public void HelpOutput_ContainsPositionalArgumentName(string[] args, TestCommandLineStyle style) + { + // Arrange + string? helpText = null; + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + commandLine + .AddHelpHandler(new HelpConfigurations + { + HelpMessageWriter = text => helpText = text, + }) + .AddHandler(_ => { }) + .Run(); + + // Assert + Assert.IsNotNull(helpText); + Assert.IsTrue(helpText.Contains("input_file"), $"Help text should contain positional argument name 'input_file'. Actual: {helpText}"); + } + + [TestMethod] + [DataRow(new[] { "--help" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] --help")] + [DataRow(new[] { "--help" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] --help")] + public void HelpOutput_Localization(string[] args, TestCommandLineStyle style) + { + // Arrange + string? helpText = null; + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + commandLine + .AddHelpHandler(new HelpConfigurations + { + HelpMessageWriter = text => helpText = text, + HelpTextLocalizer = key => key == "OptionDescription" ? "LOCALIZED_OPTION_DESCRIPTION" : key, + }) + .AddHandler(_ => { }) + .Run(); + + // Assert + Assert.IsNotNull(helpText); + Assert.IsTrue(helpText.Contains("LOCALIZED_OPTION_DESCRIPTION"), $"Help text should contain localized description. Actual: {helpText}"); + Assert.IsFalse(helpText.Contains("OptionDescription") && !helpText.Contains("LOCALIZED_OPTION_DESCRIPTION"), + "Help text should not contain raw key when localizer is provided."); + } + + [TestMethod] + [DataRow(new[] { "--help" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] --help")] + public void HelpOutput_CustomHelpHandler(string[] args, TestCommandLineStyle style) + { + // Arrange + MatchedCommand? capturedMatched = null; + ICommandObjectMetadata? capturedDefault = null; + IReadOnlyList? capturedSubCommands = null; + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + commandLine + .AddHelpHandler(new HelpConfigurations + { + HelpHandler = new TestHelpHandler((matched, defaultMetadata, subCommands) => + { + capturedMatched = matched; + capturedDefault = defaultMetadata; + capturedSubCommands = subCommands; + }), + }) + .AddHandler(_ => { }) + .AddHandler(_ => { }) + .Run(); + + // Assert + Assert.IsNotNull(capturedMatched); + Assert.IsNotNull(capturedDefault); + Assert.IsNotNull(capturedSubCommands); + Assert.IsTrue(capturedSubCommands!.Count > 0); + } + + [TestMethod] + [DataRow(new[] { "--help" }, TestCommandLineStyle.Flexible, DisplayName = "[Flexible] --help")] + [DataRow(new[] { "--help" }, TestCommandLineStyle.DotNet, DisplayName = "[DotNet] --help")] + public void HelpOutput_RequiredOptionMarked(string[] args, TestCommandLineStyle style) + { + // Arrange + string? helpText = null; + var commandLine = CommandLine.Parse(args, style.ToParsingOptions()); + + // Act + commandLine + .AddHelpHandler(new HelpConfigurations + { + HelpMessageWriter = text => helpText = text, + }) + .AddHandler(_ => { }) + .Run(); + + // Assert + Assert.IsNotNull(helpText); + Assert.IsTrue(helpText.Contains("--name"), $"Help text should contain '--name'. Actual: {helpText}"); + } + + #region Test Types + + public record DefaultOptions + { + [Value(0)] + public string? Value { get; set; } = "Default"; + } + + [Command("sub", Description = "Sub command description")] + public record SubCommandOptions + { + [Option('v', "verbose", Description = "Enable verbose output")] + public bool Verbose { get; set; } + } + + public record OptionsWithDescription + { + [Option('o', "output", Description = "The output path")] + public string? Output { get; set; } + } + + public record OptionsWithPositionalArg + { + [Value(0, Description = "The input file")] + public string? InputFile { get; set; } + } + + public record LocalizableOptions + { + [Option('n', "name", Description = "OptionDescription")] + public string? Name { get; set; } + } + + public record RequiredOptionOptions + { + [Option('n', "name", Description = "The name")] + public required string Name { get; init; } + } + + private class TestHelpHandler( + Action> callback) : IHelpHandler + { + public void Handle(MatchedCommand matchedCommand, ICommandObjectMetadata? defaultCommandMetadata, + IReadOnlyList subCommandMetadataList) + { + callback(matchedCommand, defaultCommandMetadata, subCommandMetadataList); + } + } + + #endregion +} diff --git a/tests/DotNetCampus.CommandLine.Tests/ParsingStyles/OptionBooleanValueTests.cs b/tests/DotNetCampus.CommandLine.Tests/ParsingStyles/OptionBooleanValueTests.cs index c5a7241b..bfcc43c7 100644 --- a/tests/DotNetCampus.CommandLine.Tests/ParsingStyles/OptionBooleanValueTests.cs +++ b/tests/DotNetCampus.CommandLine.Tests/ParsingStyles/OptionBooleanValueTests.cs @@ -226,7 +226,7 @@ public record TestCombinationOptions public record MultiCharShortOptions { - [Option("ab", "option-ab")] + [Option(["ab"], ["option-ab"])] public bool? OptionA { get; set; } [Option('b', "option-b")] diff --git a/tests/DotNetCampus.CommandLine.Tests/ParsingStyles/OptionValueSeparatorTests.cs b/tests/DotNetCampus.CommandLine.Tests/ParsingStyles/OptionValueSeparatorTests.cs index f3892e1f..145c8684 100644 --- a/tests/DotNetCampus.CommandLine.Tests/ParsingStyles/OptionValueSeparatorTests.cs +++ b/tests/DotNetCampus.CommandLine.Tests/ParsingStyles/OptionValueSeparatorTests.cs @@ -210,7 +210,7 @@ public record TestOptions public record MultiCharShortOptions { - [Option("ab", "option-ab")] + [Option(["ab"], ["option-ab"])] public string? OptionA { get; set; } [Option('b', "option-b")]