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")]