From 54d159e82f4b6346b5d787f13edc3dacf03ef50c Mon Sep 17 00:00:00 2001 From: walterlv Date: Sun, 17 May 2026 22:34:29 +0800 Subject: [PATCH 01/17] =?UTF-8?q?=E8=AE=BE=E8=AE=A1=20--help=20=E6=9C=BA?= =?UTF-8?q?=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/help-design.md | 560 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 560 insertions(+) create mode 100644 docs/help-design.md diff --git a/docs/help-design.md b/docs/help-design.md new file mode 100644 index 0000000..eeed6f6 --- /dev/null +++ b/docs/help-design.md @@ -0,0 +1,560 @@ +# 内置帮助支持设计 + +本文档描述 DotNetCampus.CommandLine 内置帮助支持的设计方案。这是一份设计文档,不包含代码实现。 + +## 背景 + +DotNetCampus.CommandLine 提供了丰富的命令行解析能力,支持多风格、多命令、子命令、委托处理等多种使用模式。但在实际使用中,用户需要一种标准方式来了解当前程序支持哪些命令和选项。常见的做法是在命令行中传入 `--help` 或 `-h` 等参数来获取帮助信息。 + +过去,框架曾提供 `AddStandardHandlers()` 方法来自动注册 `--help` 和 `--version` 处理逻辑。但该方法的行为不符合大多数开发者的预期,已被标记为 `[Obsolete]` 并从代码库中移除。当前版本中,帮助功能需要完全由使用方自行实现,缺乏统一的内置支持。 + +本设计的目标是在保持框架现有架构风格的前提下,为 DotNetCampus.CommandLine 提供可选的内置帮助支持。 + +## 目标与非目标 + +### 目标 + +- 提供风格感知的内置帮助检测,在 `DotNet`、`Gnu`、`Flexible`、`Windows`、`Posix` 等风格下自动识别对应的帮助写法。 +- 提供 `AddHelpHandler()` 扩展方法,允许用户按需启用帮助输出。 +- 根帮助应展示默认命令的参数信息(当且仅当存在默认命令时);命令帮助展示该命令的选项信息。 +- 通过源生成器扩展现有 `Metadata` 类型,使其携带帮助所需的描述信息。 +- 保持无反射、AOT 安全的设计原则。 +- 输出文本的描述来源限定为 `CommandLineAttribute.Description` 属性,这是当前唯一的信息源。 +- `CommandRunner` 应在正常命令匹配之前检测帮助请求,而不是通过 fallback 机制。 + +### 非目标 + +- **不支持 `-help` 写法**。理由详见下文。 +- **不包含 help 子命令**。本文档仅讨论内置的 `--help` 风格支持,不涉及 `myapp help` 这种子命令形式。子命令形式的帮助可留作后续独立设计。 +- **不支持 XML 文档注释作为帮助信息来源**。`Description` 是当前唯一的信息源,XML 文档注释不在本设计范围内。 +- **不支持本地化**。本设计不涉及多语言的描述文本切换。如后续需要本地化支持,应另案处理。 +- **不修改现有的选项匹配或命令匹配逻辑**。帮助检测是一个独立的先行检查步骤,不干扰常规解析路径。 +- **不产生隐式的控制台输出**。所有帮助文本的输出都要求用户显式调用 `AddHelpHandler()` 后才生效。 + +## 用户侧 API 设计 + +用户通过链式调用中的 `AddHelpHandler()` 方法来启用内置帮助支持: + +```csharp +// 启用帮助支持 +var commandLine = CommandLine.Parse(args) + .AddHandler(options => { /* 处理 add 命令 */ }) + .AddHandler(options => { /* 处理 remove 命令 */ }) + .AddHelpHandler() // <-- 启用内置帮助 + .Run(); +``` + +`AddHelpHandler()` 是一个扩展方法,内部调用 `CommandRunner` 的专用方法以注册帮助元数据。它返回 `IAsyncCommandRunnerBuilder` 以支持继续链式调用。 + +```csharp +// 更复杂的调用链 +var commandLine = CommandLine.Parse(args) + .AddHandler() + .AddHelpHandler() + .ForState(myState).AddHandler() + .RunAsync(); +``` + +`AddHelpHandler()` 在框架层面的作用包括: + +1. 在 `CommandRunner` 上注册一个内部帮助标记,告诉运行器在运行前检查帮助请求。 +2. 源生成器在生 `Metadata` 时额外实现一个帮助元数据接口,携带描述信息。 +3. 当检测到帮助请求时,运行器使用已注册的所有处理器的元数据来构建帮助文本。 + +如果用户没有调用 `AddHelpHandler()`,则 `--help`、`-h` 等参数会被当做普通选项处理,不会触发任何帮助输出。这是设计红线:**框架不会在用户未显式选择的情况下向控制台写入任何内容**。 + +## 按风格区分的帮助写法支持 + +不同的命令行风格对选项前缀、大小写敏感性、分隔符有不同的规则。内置帮助检测必须感知这些差异,才能正确识别用户的帮助意图。 + +下表列出了各风格下支持的帮助写法: + +| 风格 | `--help` | `-h` | `-?` | `/?` | `/help` | `/h` | `-help` | +| -------- | -------- | ------ | ------ | ------ | ------- | ------ | ------- | +| DotNet | 支持 | 支持 | 不支持 | 不支持 | 不支持 | 不支持 | 不支持 | +| Gnu | 支持 | 支持 | 不支持 | 不支持 | 不支持 | 不支持 | 不支持 | +| Flexible | 支持 | 支持 | 支持 | 支持 | 支持 | 支持¹ | 不支持 | +| Windows | 不支持 | 不支持 | 支持 | 支持 | 支持 | 支持¹ | 不支持 | +| Posix | 不支持 | 支持 | 不支持 | 不支持 | 不支持 | 不支持 | 不支持 | +| URL | 不支持 | 不支持 | 不支持 | 不支持 | 不支持 | 不支持 | 不支持 | + +> ¹ `/h` 作为兼容别名支持,前提是其实现不引入额外的复杂度。它并非本设计的主要推荐写法,只在现有前缀与短选项规则能够自然覆盖时顺带支持。 + +### 各风格的匹配细节 + +**DotNet 风格**: +- 使用 `--help` 作为长选项,大小写敏感。这是 .NET CLI 的标准用法。 +- 使用 `-h` 作为短选项。DotNet 风格支持多字符短选项(`-tl`),因此 `-h` 是安全的单字符短选项。 +- 访问值分隔符支持 `:`、`=` 和空格,但帮助选项是布尔型,不需要携带值。 + +**Gnu 风格**: +- 使用 `--help` 作为长选项,大小写敏感,符合 GNU 标准惯例。 +- 使用 `-h` 作为短选项。注意 GNU 风格支持短选项组合(`-abc` 等价于 `-a -b -c`),因此 `-h` 可能被组合使用(如 `-hv`)。帮助检测应在解析器完成短选项拆分后,对拆分出的单个短字符进行匹配。 + +**Flexible 风格**: +- 支持最多的写法变体,因为它兼容所有前缀(`--`、`-`、`/`)。 +- 大小写不敏感,因此 `--Help`、`-H`、`/HELP` 均可匹配。 +- `/?` 和 `-?` 也在支持范围内,因为 Flexible 风格天然支持短选项和 `/` 前缀。 + +**Windows 风格**: +- 核心支持 `/?`(传统 Windows 帮助写法)和 `-?`。 +- 支持 `/help` 作为显式帮助写法。 +- 由于 Windows 风格接受 `/` 或 `-` 作为前缀,大小写不敏感,这些写法的各种大小写变体均能匹配。 +- `/h` 作为一个单字符短选项,在 Windows 风格下可作为兼容性别名支持,但不作为主要推荐写法。 + +**Posix 风格**: +- 仅支持短选项,不支持长选项,因此只支持 `-h`。 +- 支持短选项组合,因此 `-h` 可能被组合使用。 + +**URL 风格**: +- 不适用于帮助场景。URL 风格通常用于程序间通信的 deep link,不涉及用户交互的帮助输出。 + +## 为什么不支持某些写法 + +### `-help` 不被支持 + +`-help`(单短横线后跟完整单词 "help")在多个风格中与现有规则冲突: + +1. **与短选项组合规则冲突**。在 Gnu 和 Posix 风格中,`-abc` 被解析为三个短选项 `-a -b -c`。`-help` 在这样的风格下会被拆解为 `-h -e -l -p`,而非当作一个名为 "help" 的长选项。如果特例化处理,将破坏短选项组合语法的可预测性。 + +2. **与多字符短选项规则冲突**。在 DotNet 和 Windows 风格中,`-abc` 被视为一个名为 "abc" 的多字符短选项。`-help` 在这些风格下确实会被当作一个整体,但问题是 "help" 不是一个普通选项名,而是一个内置语义关键词。多字符短选项本身已经用于用户定义的选项名(如 `-tl` 作为 `--terminal-logger` 的短选项),增加内置关键词的特殊匹配会引入解析器的额外复杂性。 + +3. **风格间行为不统一**。`-help` 在 Gnu 风格下会被拆成 `-h -e -l -p`,在 DotNet 风格下会被当作一个整体。这种不一致对用户和实现都是困惑的来源。维护风格间行为一致性的代价太高。 + +4. **破坏用户的选项命名空间**。用户可能恰好定义了一个名为 `-help` 的多字符短选项,或者恰好定义了 `-h`、`-e`、`-l`、`-p` 等短选项。内置的 `-help` 检测会与这些用户选项产生意外的交互。 + +基于以上原因,`-help` 在本设计中不被支持。用户应使用 `--help`(双短横线,所有支持长选项的风格均认可)或对应风格认可的其他写法(`-h`、`/?` 等)。 + +### `help` 子命令不被包含在本设计中 + +`myapp help` 形式的子命令具有独立的语义和实现路径。它涉及命令名的特殊注册、与现有 `AddHandler` 机制的交互、以及对用户自定义的 `help` 命令的冲突处理。这些复杂度需要单独的设计文档来处理,不在本文档的范围内。 + +## 帮助请求的检测与命令定位 + +### 检测时机 + +帮助请求的检测发生在 `CommandRunner.RunAsync()` 的开头,在所有正常的命令匹配和参数解析之前。这是一个独立的先行检查步骤。 + +伪流程如下: + +``` +RunAsync() +├── 步骤 1: 检查帮助请求 +│ ├── 如果调用了 AddHelpHandler(),扫描原始参数 +│ ├── 匹配到帮助写法 → 进入帮助输出流程 +│ └── 未匹配到帮助写法 → 继续正常流程 +├── 步骤 2: 正常的命令匹配 (现有的 MatchCommandObject) +└── 步骤 3: 命令构建和执行 +``` + +这个顺序保证了: + +- 帮助请求不会被用户的命令处理器意外拦截。 +- 即使解析器后续可能遇到错误,帮助请求也能优先响应。 +- 帮助输出的行为不依赖 fallback 机制,路径清晰可控。 + +### 检测算法 + +检测算法需要考虑风格的差异: + +1. 提取原始命令行参数 `args`。 +2. 根据 `CommandLineStyle` 确定当前风格支持的帮助写法集合。 +3. 对每个参数检查是否匹配帮助写法集合中的任意一项。 +4. 匹配时需要考虑大小写敏感性(`CaseSensitive` 属性)和前缀规则。 + +对于短选项组合(Gnu、Posix),检测需要在组合拆分之后进行。例如 `-hv` 在 GNU 风格下应被拆分为 `-h` 和 `-v`,其中 `-h` 触发帮助。 + +``` +检测算法: +if (!_hasHelpHandler) + return; // 没有注册帮助处理器,不做任何事 + +var helpSpellings = GetHelpSpellingsForStyle(currentStyle); +foreach (var arg in commandLineArgs) +{ + if (MatchHelpSpelling(arg, helpSpellings, currentStyle)) + { + _isHelpRequested = true; + break; + } +} +``` + +### 命令定位 + +当帮助请求被检测到后,系统需要确定用户想知道哪个命令的帮助。判断逻辑如下: + +1. 如果命令行中除了帮助写法之外没有其他参数,或者没有任何命令名,则输出**根帮助**。 +2. 如果命令行中除了帮助写法之外还包含一个命令名称(如 `myapp add --help`),则输出该命令的命令帮助。 +3. 如果命令行中包含多级子命令(如 `myapp remote add --help`),则输出最深层的子命令帮助。 + +命令位置的解析复用在 `CommandRunner` 中已实现的命令前缀匹配逻辑来获取候选命令名。检测到帮助请求后,从参数列表中移除帮助写法对应的参数,用剩余参数执行命令匹配,然后根据匹配结果决定输出哪种帮助。 + +``` +命令定位算法: +// 从原始参数中过滤掉帮助写法 +var remainingArgs = FilterOutHelpSpellings(args, helpSpellings); + +// 使用剩余参数匹配命令 +var matchedCommand = MatchCommand(remainingArgs); + +if (matchedCommand == null) +{ + // 没有任何命令匹配 → 输出根帮助 + ShowRootHelp(); +} +else +{ + // 匹配到命令 → 输出该命令的帮助 + ShowCommandHelp(matchedCommand); +} +``` + +## 根帮助与命令帮助的内容范围 + +### 根帮助的内容 + +**根的适用范围**:当用户在命令行中只输入了帮助写法(如 `myapp --help`),或者输入了帮助写法但没有匹配到任何命令时,输出根帮助。 + +根帮助展示的内容: + +| 内容项 | 说明 | 条件 | +| ------------------ | -------------------------------------------------------------------- | ------------ | +| 程序描述 | 顶层类型的 `Description`(如果 `Command` 特性标记了描述) | 有默认命令时 | +| 用法行 | `usage: <程序名> [options]` 或 `usage: <程序名> [options]` | 总是显示 | +| 默认命令的选项列表 | 如果存在默认命令,展示其所有选项及其 `Description` | 有默认命令时 | +| 已注册的命令列表 | 列出所有已注册的非默认命令及其 `Description` | 有命令时 | + +如果程序没有默认命令,也没有注册任何命令,那么根帮助仅显示程序名和一条提示信息,指示当前没有注册任何命令。 + +### 命令帮助的内容 + +**命令的适用范围**:当用户在命令名称后输入了帮助写法(如 `myapp add --help` 或 `myapp remote add --help`),输出该命令的帮助。 + +命令帮助展示的内容: + +| 内容项 | 说明 | +| ------------ | ------------------------------------------------------------------ | +| 命令名称 | 完整的命令名称(包括所有级别的子命令,如 `remote add`) | +| 命令描述 | `[Command]` 特性上的 `Description` 属性 | +| 用法行 | `usage: <程序名> [options]` | +| 选项列表 | 该命令类型中所有标记了 `[Option]` 的属性,包含短名称、长名称和描述 | +| 位置参数列表 | 该命令类型中所有标记了 `[Value]` 的属性,包含索引和描述 | +| 子命令提示 | 如果有子命令也注册到了相同的命名空间,列出子命令 | + +### 选项列表的格式化 + +选项列表的每条记录包含: + +- 短名称(如果有):如 `-h` +- 长名称:如 `--help` +- 是否必需:`required` 标记的属性 +- 类型提示:布尔型、数值型、字符串型等 +- 描述:`[Option]` 特性上的 `Description` 属性 + +示例输出: + +``` +选项: + -c, --count (必需) 测试循环的次数 + -n, --test-name 测试名称 + -d, --detail-level 详细级别 + --debug 是否启用调试模式 +``` + +## 源生成器与元数据设计 + +### 现有元数据机制的回顾 + +当前的源生成器(`ModelBuilderGenerator`)为每个命令类型生成一个 `*Builder` 类,其中包含: + +- `CommandNameGroup` 静态字段:携带命令的名称信息(Ordinal 和 PascalCase 两种命名法)。 +- `Metadata` 内部嵌套类:实现了 `ICommandObjectMetadata` 接口,提供 `Build(CommandRunningContext)` 方法。 +- 解析相关的成员方法(`MatchLongOption`、`MatchShortOption` 等)。 + +当前的 `ICommandObjectMetadata` 接口: + +```csharp +public interface ICommandObjectMetadata +{ + object Build(CommandRunningContext context); +} +``` + +### 扩展 Metadata 以携带帮助信息 + +为了支持帮助输出,需要在生成的 `Metadata` 上增加一个帮助元数据接口。新增的接口不同于 `ICommandObjectMetadata`,它将被专门的帮助提供者使用,而不是被命令执行流程使用。 + +新的接口定义(位于 `DotNetCampus.Cli.Compiler` 命名空间): + +```csharp +/// +/// 提供命令的帮助信息,由源生成器生成。 +/// +public interface IHelpProvider +{ + /// + /// 命令的名称(如 "add" 或 "remote add")。 + /// + string? CommandName { get; } + + /// + /// 命令的描述。 + /// + string? Description { get; } + + /// + /// 获取此命令的选项帮助信息列表。 + /// + IReadOnlyList Options { get; } + + /// + /// 获取此命令的位置参数帮助信息列表。 + /// + IReadOnlyList PositionalArguments { get; } +} + +/// +/// 单个选项的帮助信息。 +/// +public readonly record struct OptionHelpInfo +{ + public string? ShortName { get; init; } + public string? LongName { get; init; } + public string? Description { get; init; } + public bool IsRequired { get; init; } + public OptionValueType ValueType { get; init; } +} + +/// +/// 单个位置参数的帮助信息。 +/// +public readonly record struct ValueHelpInfo +{ + public int Index { get; init; } + public int? Count { get; init; } + public string? Description { get; init; } + public bool IsRequired { get; init; } +} +``` + +生成的 `Metadata` 类型将同时实现 `ICommandObjectMetadata` 和 `IHelpProvider`: + +```csharp +// 生成后的 Metadata 类型 +public sealed class Metadata : global::DotNetCampus.Cli.Compiler.ICommandObjectMetadata, + global::DotNetCampus.Cli.Compiler.IHelpProvider +{ + // 原有的 Build 方法 + public object Build(global::DotNetCampus.Cli.Compiler.CommandRunningContext context) + { + return new global::MyNamespace.MyCommandBuilder().Build(context); + } + + // 新增的帮助接口实现 + public string? CommandName => "add"; + public string? Description => "添加一个新项目"; + + public global::System.Collections.Generic.IReadOnlyList Options + => [new() { ShortName = "n", LongName = "name", Description = "项目名称", IsRequired = true, ValueType = global::DotNetCampus.Cli.Compiler.OptionValueType.Normal }]; + + public global::System.Collections.Generic.IReadOnlyList PositionalArguments + => [new() { Index = 0, Description = "要添加的文件路径", IsRequired = true }]; +} +``` + +### 源生成器的修改 + +在 `ModelBuilderGenerator` 中,生成 `Metadata` 类型时需要额外执行以下操作: + +1. 读取 `[Command]` 特性上的 `Description` 值:从 `CommandAttribute.Description` 获取命令描述。 +2. 读取每个 `[Option]` 特性上的 `Description` 值:从 `OptionAttribute.Description` 获取选项描述。 +3. 读取每个 `[Value]` 特性上的 `Description` 值:从 `ValueAttribute.Description` 获取位置参数描述。 +4. 根据命令名称的 kebab-case 形式和 PascalCase 形式,确定 `CommandName` 的返回值(取 Ordinal 形式)。 +5. 判断选项是否为 `required`,以及值的类型。 + +对于命令名称、属性类型、`required` 等信息,现有的 `CommandObjectGeneratingModel` 和 `PropertyGeneratingModel` 已经提供了基础元数据;但 `Description` 目前尚未进入生成模型,因此需要先扩展这些模型,再在生成代码时将其烘焙到 `IHelpProvider` 的实现中。 + +关键修改点: + +``` +ModelBuilderGenerator.Execute 中 + └── GenerateCommandObjectCreatorCode 中 + └── 在生成 Metadata 类型时 + ├── 添加 IHelpProvider 接口声明 + ├── 生成 CommandName 属性(从 CommandNames 获取) + ├── 生成 Description 属性(从模型中的 Description 获取) + ├── 生成 Options 属性(遍历 OptionProperties) + └── 生成 PositionalArguments 属性(遍历 PositionalArgumentProperties) +``` + +### 运行时注册 + +`AddHelpHandler()` 的调用通过拦截器机制实现,与现有的 `AddHandler()` 模式相同。`AddHelpHandler()` 扩展方法触发拦截器,拦截器生成一段代码,该代码在 `CommandRunner` 上注册帮助元数据收集器。 + +`CommandRunner` 内部新增一个字段来持有帮助元数据: + +```csharp +// CommandRunner 新增的字段 +private HelpHandler? _helpHandler; +``` + +帮助处理器内部持有所有已注册命令的 `IHelpProvider` 引用列表。当 `AddHandler()` 被调用时,如果对应的 `Metadata` 实现了 `IHelpProvider`,帮助处理器会自动收集它。 + +## 运行时执行流程 + +完整的帮助响应流程如下: + +``` +用户输入: myapp add --help + +1. CommandLine.Parse(args) → 创建 CommandLine 实例 +2. .AddHandler(...) → 注册 AddOptions 的命令元数据 +3. .AddHelpHandler() → 注册帮助处理器,收集所有 IHelpProvider +4. .Run() / .RunAsync() + │ + ├── 4.1 CommandRunner.RunAsync() + │ │ + │ ├── 4.2 帮助检测阶段 + │ │ ├── 检查是否注册了帮助处理器 → 是 + │ │ ├── 遍历原始参数,匹配帮助写法 + │ │ ├── 匹配到 "--help" + │ │ ├── 过滤掉 "--help",剩余参数为 ["add"] + │ │ └── 调用命令匹配,匹配到 "add" 命令 + │ │ + │ ├── 4.3 帮助输出阶段 + │ │ ├── 找到 "add" 命令对应的 IHelpProvider + │ │ ├── 查询该命令的 Options、PositionalArguments 等 + │ │ ├── 格式化帮助文本 + │ │ ├── 输出到 Console.Out + │ │ └── 返回退出码 0 + │ │ + │ └── 4.4 (未匹配帮助时的正常路径) + │ └── 执行现有的 MatchCommandObject → Build → Run 流程 + │ + └── 4.5 返回 CommandRunningResult +``` + +对于没有命令名的情况(根帮助): + +``` +用户输入: myapp --help + +4.2 帮助检测 → 匹配 "--help" +4.3 过滤后剩余参数为空 + → 命令匹配返回 null(或返回默认命令的 metadata) + → 如果存在默认命令,输出含有默认命令选项的根帮助 + → 如果不存在默认命令,输出仅含命令列表的根帮助 +``` + +## 输出格式建议 + +帮助文本的格式设计遵循以下原则: + +1. 保持简洁,不产生过多的控制台行数。 +2. 信息的组织结构清晰可扫描。 +3. 优先采用常见的 CLI 帮助格式(类 `git --help` 风格),降低用户的学习成本。 + +### 根帮助输出格式 + +``` +<程序名> <版本信息(如有)> + + + +用法: <程序名> [选项] [命令] +用法: <程序名> <命令> [选项] + +选项: + -h, --help 显示帮助信息 + +命令: + add 添加一个新项目 + remove 删除一个项目 + remote add 添加一个远程仓库 +``` + +如果没有默认命令,选项部分只显示 `-h, --help`。如果也没有注册任何命令,则显示一条提示信息。 + +### 命令帮助输出格式 + +``` +<命令名> - <命令描述> + +用法: <程序名> <命令> [选项] [参数] + +选项: + -n, --name (必需) 项目名称 + -p, --path 项目路径 + -f, --format 输出格式 + -v, --verbose 显示详细信息 + +位置参数: + 0 输入文件路径 (必需) + 1.. 输出文件路径 (可选) +``` + +### 格式化策略 + +- 短名称和长名称在同一行展示,用逗号分隔。 +- 选项名右对齐到固定的列宽,描述从固定的列宽开始。 +- 类型提示放在尖括号中,如 ``、``、``。 +- 如果选项没有短名称,对应的位置留空或对齐调整。 +- 当描述文本超长时,应换行并在下一行对齐到描述起始列。 +- 输出使用 `Console.Out`,而不是 `Console.Error`,因为帮助信息是正常的程序输出。 + +这些格式仅作为参考,具体实现允许调整间距、对齐方式和装饰字符。 + +## 兼容性、性能与红线 + +### 兼容性 + +- `AddHelpHandler()` 是完全新增的 API,不与现有的任何 API 冲突。 +- 所有新增的接口(`IHelpProvider`、`OptionHelpInfo`、`ValueHelpInfo`)位于 `DotNetCampus.Cli.Compiler` 命名空间,与 `ICommandObjectMetadata` 同级。 +- 现有的 `Metadata` 类型可以直接扩展为同时实现 `IHelpProvider`。即使用户没有调用 `AddHelpHandler()`,这些帮助元数据也只是保持未使用状态,不会改变正常的命令执行路径。 +- `AddStandardHandlers()` 已被移除,不存在新旧 API 冲突问题。 + +### 性能 + +- 帮助检测仅在注册了 `AddHelpHandler()` 时才会执行额外的扫描。未注册时路径与现有代码一致,无性能损失。 +- 帮助检测的扫描是 O(n) 的,其中 n 是参数个数,通常很小(个位数到十位数)。 +- `IHelpProvider` 的实现由源生成器生成,不涉及反射。`Options` 和 `PositionalArguments` 属性返回的集合是编译期确定的 `new[]` 数组,分配很小。 +- 帮助文本的格式化仅在检测到帮助请求时发生,不影响正常执行路径的性能。 + +### 红线 + +- **无反射**:所有帮助元数据通过源生成器在编译期确定,运行时不使用 `Type.GetProperties()`、`Attribute.GetCustomAttributes()` 等反射 API。 +- **AOT 安全**:源生成器生成所有需要的代码,不存在动态代码生成或 JIT 依赖。`IHelpProvider` 的实现是具体的已编译类型,适合 NativeAOT 部署。 +- **无隐式输出**:`CommandRunner` 不会自动输出帮助信息。只有在用户显式调用 `AddHelpHandler()` 后,框架才有权限向 `Console.Out` 写入内容。 +- **无 fallback 依赖**:帮助检测是独立的先行步骤,不依赖 `_fallback` 机制或异常处理的回退。 +- **无 `-help` 支持**:无论何种风格,`-help` 都不是内置帮助的合法写法,不提供例外处理。 + +### 生成策略 + +为了确保设计简单、稳定,并与现有的按类型生成 `Metadata` 的架构保持一致,推荐采用“总是生成,按需使用”的策略: + +- 源生成器始终为所有 `Metadata` 生成 `IHelpProvider` 实现。 +- 运行时仅当用户调用了 `AddHelpHandler()` 时,`CommandRunner` 才会读取这些帮助元数据。 +- 如果用户没有调用 `AddHelpHandler()`,这些元数据保持未使用状态,但不会改变任何已有行为。 + +这样做的好处是: + +1. 不需要让源生成器跨调用点分析链式调用中是否最终出现了 `AddHelpHandler()`。 +2. 不需要为了减少少量未使用代码而显著提升生成器复杂度。 +3. 与现有的“每个命令类型生成一个 `Metadata` 类型”的模式最一致。 + +## 结论 + +本文档提出了 DotNetCampus.CommandLine 内置帮助支持的设计方案,核心要点如下: + +1. **用户选择加入**:通过 `AddHelpHandler()` 显式启用,不产生隐式输出。 +2. **风格感知**:根据当前 `CommandLineStyle` 自动识别对应的帮助写法(`--help`、`-h`、`/?` 等),不同风格有不同的匹配规则。 +3. **拒绝 `-help`**:该写法与短选项组合及多字符短选项规则冲突,不被支持。 +4. **无 help 子命令**:子命令形式的帮助留待独立设计。 +5. **先行检测**:帮助检测在 `CommandRunner.RunAsync()` 的开头执行,不影响正常命令匹配流程。 +6. **源生成器驱动**:利用现有 `ModelBuilderGenerator` 的管道,扩展 `Metadata` 类型以同时实现 `IHelpProvider` 接口,携带描述信息。 +7. **Description 作为唯一源**:使用 `CommandLineAttribute.Description` 属性,不涉及 XML 文档注释。 +8. **无反射、AOT 安全**:所有帮助元数据在编译期确定。 +9. **本地化与 XML 文档注释**:明确排除在本文档范围之外,留待后续设计。 + +该方案遵循 DotNetCampus.CommandLine 现有的架构风格:拦截器拦截调用、源生成器生成代码、零反射零 AOT 开销、风格矩阵驱动的解析行为。帮助支持以一种可选、可预测的方式融入到现有框架中。 From 34f66be28f604e8fa5cb139f8470a924dd65476d Mon Sep 17 00:00:00 2001 From: walterlv Date: Tue, 19 May 2026 10:02:04 +0800 Subject: [PATCH 02/17] =?UTF-8?q?=E7=AE=80=E5=8C=96=20OptionAttribute=20?= =?UTF-8?q?=E7=9A=84=E6=9E=84=E9=80=A0=E5=87=BD=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Compiler/OptionAttribute.cs | 53 +++---------------- .../ParsingStyles/OptionBooleanValueTests.cs | 2 +- .../OptionValueSeparatorTests.cs | 2 +- 3 files changed, 9 insertions(+), 48 deletions(-) diff --git a/src/DotNetCampus.CommandLine/Compiler/OptionAttribute.cs b/src/DotNetCampus.CommandLine/Compiler/OptionAttribute.cs index b6fbfe9..83d8969 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; diff --git a/tests/DotNetCampus.CommandLine.Tests/ParsingStyles/OptionBooleanValueTests.cs b/tests/DotNetCampus.CommandLine.Tests/ParsingStyles/OptionBooleanValueTests.cs index c5a7241..bfcc43c 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 f3892e1..145c868 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")] From 8941de313065fae4c762ee067ff2f357bdddca29 Mon Sep 17 00:00:00 2001 From: walterlv Date: Tue, 19 May 2026 10:49:15 +0800 Subject: [PATCH 03/17] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20help=20=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1=E4=B8=AD=E7=9A=84=E5=B0=8F=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/help-design.md | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/docs/help-design.md b/docs/help-design.md index eeed6f6..db1e864 100644 --- a/docs/help-design.md +++ b/docs/help-design.md @@ -28,6 +28,7 @@ DotNetCampus.CommandLine 提供了丰富的命令行解析能力,支持多风 - **不包含 help 子命令**。本文档仅讨论内置的 `--help` 风格支持,不涉及 `myapp help` 这种子命令形式。子命令形式的帮助可留作后续独立设计。 - **不支持 XML 文档注释作为帮助信息来源**。`Description` 是当前唯一的信息源,XML 文档注释不在本设计范围内。 - **不支持本地化**。本设计不涉及多语言的描述文本切换。如后续需要本地化支持,应另案处理。 +- **不展示选项的默认值**。框架在属性未赋值时的回退行为(如空字符串、空集合、`default(T)` 等)是内部的兜底逻辑,并非用户在业务上有意义的默认值声明。在没有提供显式声明默认值的机制之前,帮助信息不展示默认值,以免对 CLI 使用者造成误导。 - **不修改现有的选项匹配或命令匹配逻辑**。帮助检测是一个独立的先行检查步骤,不干扰常规解析路径。 - **不产生隐式的控制台输出**。所有帮助文本的输出都要求用户显式调用 `AddHelpHandler()` 后才生效。 @@ -246,12 +247,14 @@ else 选项列表的每条记录包含: -- 短名称(如果有):如 `-h` -- 长名称:如 `--help` +- 短名称(如果有多个则全部列出):如 `-h`、`-n` +- 长名称(如果有多个则全部列出):如 `--help`、`--name` - 是否必需:`required` 标记的属性 - 类型提示:布尔型、数值型、字符串型等 - 描述:`[Option]` 特性上的 `Description` 属性 +注意:由于 `OptionAttribute` 支持为一个选项声明多个短名称和多个长名称(如 `[Option(new[] { "n", "N" }, new[] { "name", "file-name" })]`),帮助信息必须完整呈现所有别名,而不是仅取第一个。 + 示例输出: ``` @@ -319,8 +322,8 @@ public interface IHelpProvider /// public readonly record struct OptionHelpInfo { - public string? ShortName { get; init; } - public string? LongName { get; init; } + public IReadOnlyList ShortNames { get; init; } + public IReadOnlyList LongNames { get; init; } public string? Description { get; init; } public bool IsRequired { get; init; } public OptionValueType ValueType { get; init; } @@ -356,7 +359,7 @@ public sealed class Metadata : global::DotNetCampus.Cli.Compiler.ICommandObjectM public string? Description => "添加一个新项目"; public global::System.Collections.Generic.IReadOnlyList Options - => [new() { ShortName = "n", LongName = "name", Description = "项目名称", IsRequired = true, ValueType = global::DotNetCampus.Cli.Compiler.OptionValueType.Normal }]; + => [new() { ShortNames = ["n"], LongNames = ["name"], Description = "项目名称", IsRequired = true, ValueType = global::DotNetCampus.Cli.Compiler.OptionValueType.Normal }]; public global::System.Collections.Generic.IReadOnlyList PositionalArguments => [new() { Index = 0, Description = "要添加的文件路径", IsRequired = true }]; @@ -390,16 +393,16 @@ ModelBuilderGenerator.Execute 中 ### 运行时注册 -`AddHelpHandler()` 的调用通过拦截器机制实现,与现有的 `AddHandler()` 模式相同。`AddHelpHandler()` 扩展方法触发拦截器,拦截器生成一段代码,该代码在 `CommandRunner` 上注册帮助元数据收集器。 +`AddHelpHandler()` 不需要通过拦截器实现——它不涉及泛型类型参数的编译期解析,可以作为普通扩展方法直接在运行时工作。调用 `AddHelpHandler()` 只是在 `CommandRunner` 上设置一个标记,表示帮助功能已启用。 -`CommandRunner` 内部新增一个字段来持有帮助元数据: +`CommandRunner` 内部新增一个字段来持有帮助状态: ```csharp // CommandRunner 新增的字段 -private HelpHandler? _helpHandler; +private bool _helpEnabled; ``` -帮助处理器内部持有所有已注册命令的 `IHelpProvider` 引用列表。当 `AddHandler()` 被调用时,如果对应的 `Metadata` 实现了 `IHelpProvider`,帮助处理器会自动收集它。 +帮助元数据的收集发生在 `RunAsync()` 内部:当帮助功能已启用且检测到帮助请求后,运行器才遍历所有已注册的 `ICommandObjectMetadata`,将其中实现了 `IHelpProvider` 的实例收集起来用于输出。这意味着无论 `AddHelpHandler()` 出现在调用链的什么位置——在所有 `AddHandler` 之前、之间或之后——行为都是一致的。 ## 运行时执行流程 @@ -410,15 +413,16 @@ private HelpHandler? _helpHandler; 1. CommandLine.Parse(args) → 创建 CommandLine 实例 2. .AddHandler(...) → 注册 AddOptions 的命令元数据 -3. .AddHelpHandler() → 注册帮助处理器,收集所有 IHelpProvider +3. .AddHelpHandler() → 设置帮助启用标记 4. .Run() / .RunAsync() │ ├── 4.1 CommandRunner.RunAsync() │ │ │ ├── 4.2 帮助检测阶段 - │ │ ├── 检查是否注册了帮助处理器 → 是 + │ │ ├── 检查帮助是否已启用 → 是 │ │ ├── 遍历原始参数,匹配帮助写法 │ │ ├── 匹配到 "--help" + │ │ ├── 收集所有已注册 metadata 中的 IHelpProvider │ │ ├── 过滤掉 "--help",剩余参数为 ["add"] │ │ └── 调用命令匹配,匹配到 "add" 命令 │ │ From 83761e342e79a5d93c0a81a45eaf094905c03135 Mon Sep 17 00:00:00 2001 From: walterlv Date: Tue, 19 May 2026 11:19:08 +0800 Subject: [PATCH 04/17] =?UTF-8?q?=E8=B0=83=E6=95=B4=E5=B8=AE=E5=8A=A9?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E7=9A=84=E5=AE=9E=E7=8E=B0=EF=BC=88=E6=96=B9?= =?UTF-8?q?=E6=A1=88=E8=AE=BE=E8=AE=A1=E9=98=B6=E6=AE=B5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/help-design.md | 120 ++++++++++++++++++++++++++------------------ 1 file changed, 70 insertions(+), 50 deletions(-) diff --git a/docs/help-design.md b/docs/help-design.md index db1e864..3f77e4f 100644 --- a/docs/help-design.md +++ b/docs/help-design.md @@ -284,37 +284,55 @@ public interface ICommandObjectMetadata } ``` -### 扩展 Metadata 以携带帮助信息 +本设计将在此接口上新增 `GetHelp()` 方法(详见下文)。 -为了支持帮助输出,需要在生成的 `Metadata` 上增加一个帮助元数据接口。新增的接口不同于 `ICommandObjectMetadata`,它将被专门的帮助提供者使用,而不是被命令执行流程使用。 +### 扩展 ICommandObjectMetadata 以携带帮助信息 -新的接口定义(位于 `DotNetCampus.Cli.Compiler` 命名空间): +为了支持帮助输出,在 `ICommandObjectMetadata` 接口上直接新增一个 `GetHelp()` 方法。所有由源生成器生成的 `Metadata` 类型天然具备帮助信息,因此不需要引入额外的接口——帮助元数据是 `Metadata` 的固有能力,而非一个可选的附加契约。 + +对于框架内部提供的少量特殊 `ICommandObjectMetadata` 实现(如委托包装器),其 `GetHelp()` 应委托给内部持有的原始 metadata,或在无法提供时返回 `null`。 + +扩展后的接口定义: + +```csharp +public interface ICommandObjectMetadata +{ + object Build(CommandRunningContext context); + + /// + /// 获取此命令的帮助元数据。如果无法提供帮助信息,则返回 。 + /// + CommandHelpMetadata? GetHelp(); +} +``` + +`CommandHelpMetadata` 是一个纯数据类型,所有属性均为 `required init`,由源生成器在 `GetHelp()` 方法体中通过 `new CommandHelpMetadata { ... }` 一次性构造。这保证了 `Metadata` 类型本身不持有任何字段(符合现有的无字段约束),同时调用方拿到的是一个完整的、不可变的数据对象: ```csharp /// -/// 提供命令的帮助信息,由源生成器生成。 +/// 命令的帮助元数据。 /// -public interface IHelpProvider +public sealed class CommandHelpMetadata { /// - /// 命令的名称(如 "add" 或 "remote add")。 + /// 命令的名称(如 "add" 或 "remote add")。没有命令名称时为 。 /// - string? CommandName { get; } + public required string? CommandName { get; init; } /// /// 命令的描述。 /// - string? Description { get; } + public required string? Description { get; init; } /// - /// 获取此命令的选项帮助信息列表。 + /// 此命令的选项帮助信息列表。没有选项时为空集合。 /// - IReadOnlyList Options { get; } + public required IReadOnlyList Options { get; init; } /// - /// 获取此命令的位置参数帮助信息列表。 + /// 此命令的位置参数帮助信息列表。没有位置参数时为空集合。 /// - IReadOnlyList PositionalArguments { get; } + public required IReadOnlyList PositionalArguments { get; init; } } /// @@ -322,11 +340,11 @@ public interface IHelpProvider /// public readonly record struct OptionHelpInfo { - public IReadOnlyList ShortNames { get; init; } - public IReadOnlyList LongNames { get; init; } - public string? Description { get; init; } - public bool IsRequired { get; init; } - public OptionValueType ValueType { get; init; } + public required IReadOnlyList ShortNames { get; init; } + public required IReadOnlyList LongNames { get; init; } + public required string? Description { get; init; } + public required bool IsRequired { get; init; } + public required OptionValueType ValueType { get; init; } } /// @@ -334,35 +352,34 @@ public readonly record struct OptionHelpInfo /// public readonly record struct ValueHelpInfo { - public int Index { get; init; } - public int? Count { get; init; } - public string? Description { get; init; } - public bool IsRequired { get; init; } + public required int Index { get; init; } + public required int? Count { get; init; } + public required string? Description { get; init; } + public required bool IsRequired { get; init; } } ``` -生成的 `Metadata` 类型将同时实现 `ICommandObjectMetadata` 和 `IHelpProvider`: +生成的 `Metadata` 类型示例: ```csharp // 生成后的 Metadata 类型 -public sealed class Metadata : global::DotNetCampus.Cli.Compiler.ICommandObjectMetadata, - global::DotNetCampus.Cli.Compiler.IHelpProvider +public sealed class Metadata : global::DotNetCampus.Cli.Compiler.ICommandObjectMetadata { - // 原有的 Build 方法 public object Build(global::DotNetCampus.Cli.Compiler.CommandRunningContext context) { return new global::MyNamespace.MyCommandBuilder().Build(context); } - // 新增的帮助接口实现 - public string? CommandName => "add"; - public string? Description => "添加一个新项目"; - - public global::System.Collections.Generic.IReadOnlyList Options - => [new() { ShortNames = ["n"], LongNames = ["name"], Description = "项目名称", IsRequired = true, ValueType = global::DotNetCampus.Cli.Compiler.OptionValueType.Normal }]; - - public global::System.Collections.Generic.IReadOnlyList PositionalArguments - => [new() { Index = 0, Description = "要添加的文件路径", IsRequired = true }]; + public global::DotNetCampus.Cli.Compiler.CommandHelpMetadata? GetHelp() + { + return new global::DotNetCampus.Cli.Compiler.CommandHelpMetadata + { + CommandName = "add", + Description = "添加一个新项目", + Options = [new() { ShortNames = ["n"], LongNames = ["name"], Description = "项目名称", IsRequired = true, ValueType = global::DotNetCampus.Cli.Compiler.OptionValueType.Normal }], + PositionalArguments = [new() { Index = 0, Count = 1, Description = "要添加的文件路径", IsRequired = true }], + }; + } } ``` @@ -375,8 +392,9 @@ public sealed class Metadata : global::DotNetCampus.Cli.Compiler.ICommandObjectM 3. 读取每个 `[Value]` 特性上的 `Description` 值:从 `ValueAttribute.Description` 获取位置参数描述。 4. 根据命令名称的 kebab-case 形式和 PascalCase 形式,确定 `CommandName` 的返回值(取 Ordinal 形式)。 5. 判断选项是否为 `required`,以及值的类型。 +6. 收集每个 `[Option]` 的完整短名称列表和长名称列表(而非仅取第一个)。 -对于命令名称、属性类型、`required` 等信息,现有的 `CommandObjectGeneratingModel` 和 `PropertyGeneratingModel` 已经提供了基础元数据;但 `Description` 目前尚未进入生成模型,因此需要先扩展这些模型,再在生成代码时将其烘焙到 `IHelpProvider` 的实现中。 +对于命令名称、属性类型、`required` 等信息,现有的 `CommandObjectGeneratingModel` 和 `PropertyGeneratingModel` 已经提供了基础元数据;但 `Description` 目前尚未进入生成模型,因此需要先扩展这些模型,再在生成代码时将其烘焙到 `GetHelp()` 的返回值中。 关键修改点: @@ -384,11 +402,11 @@ public sealed class Metadata : global::DotNetCampus.Cli.Compiler.ICommandObjectM ModelBuilderGenerator.Execute 中 └── GenerateCommandObjectCreatorCode 中 └── 在生成 Metadata 类型时 - ├── 添加 IHelpProvider 接口声明 - ├── 生成 CommandName 属性(从 CommandNames 获取) - ├── 生成 Description 属性(从模型中的 Description 获取) - ├── 生成 Options 属性(遍历 OptionProperties) - └── 生成 PositionalArguments 属性(遍历 PositionalArgumentProperties) + └── 生成 GetHelp() 方法,返回 new CommandHelpMetadata + ├── CommandName(从 CommandNames 获取) + ├── Description(从模型中的 Description 获取) + ├── Options(遍历 OptionProperties,收集所有 ShortNames 和 LongNames) + └── PositionalArguments(遍历 PositionalArgumentProperties) ``` ### 运行时注册 @@ -402,7 +420,7 @@ ModelBuilderGenerator.Execute 中 private bool _helpEnabled; ``` -帮助元数据的收集发生在 `RunAsync()` 内部:当帮助功能已启用且检测到帮助请求后,运行器才遍历所有已注册的 `ICommandObjectMetadata`,将其中实现了 `IHelpProvider` 的实例收集起来用于输出。这意味着无论 `AddHelpHandler()` 出现在调用链的什么位置——在所有 `AddHandler` 之前、之间或之后——行为都是一致的。 +帮助元数据的收集发生在 `RunAsync()` 内部:当帮助功能已启用且检测到帮助请求后,运行器遍历所有已注册的 `ICommandObjectMetadata`,调用其 `GetHelp()` 方法收集非 `null` 的帮助元数据用于输出。这意味着无论 `AddHelpHandler()` 出现在调用链的什么位置——在所有 `AddHandler` 之前、之间或之后——行为都是一致的。 ## 运行时执行流程 @@ -514,32 +532,34 @@ private bool _helpEnabled; ### 兼容性 - `AddHelpHandler()` 是完全新增的 API,不与现有的任何 API 冲突。 -- 所有新增的接口(`IHelpProvider`、`OptionHelpInfo`、`ValueHelpInfo`)位于 `DotNetCampus.Cli.Compiler` 命名空间,与 `ICommandObjectMetadata` 同级。 -- 现有的 `Metadata` 类型可以直接扩展为同时实现 `IHelpProvider`。即使用户没有调用 `AddHelpHandler()`,这些帮助元数据也只是保持未使用状态,不会改变正常的命令执行路径。 +- `GetHelp()` 方法直接新增到 `ICommandObjectMetadata` 接口上。由于当前含有此接口的版本尚在预览阶段,不存在兼容负担。 +- 新增的数据类型(`CommandHelpMetadata`、`OptionHelpInfo`、`ValueHelpInfo`)位于 `DotNetCampus.Cli.Compiler` 命名空间,与 `ICommandObjectMetadata` 同级。 +- 即使用户没有调用 `AddHelpHandler()`,`GetHelp()` 方法也只是不会被调用,不会改变正常的命令执行路径。 - `AddStandardHandlers()` 已被移除,不存在新旧 API 冲突问题。 ### 性能 - 帮助检测仅在注册了 `AddHelpHandler()` 时才会执行额外的扫描。未注册时路径与现有代码一致,无性能损失。 - 帮助检测的扫描是 O(n) 的,其中 n 是参数个数,通常很小(个位数到十位数)。 -- `IHelpProvider` 的实现由源生成器生成,不涉及反射。`Options` 和 `PositionalArguments` 属性返回的集合是编译期确定的 `new[]` 数组,分配很小。 +- `GetHelp()` 由源生成器生成,不涉及反射。其内部构造的集合是编译期确定的字面量,分配很小;且仅在检测到帮助请求后才会被调用。 - 帮助文本的格式化仅在检测到帮助请求时发生,不影响正常执行路径的性能。 ### 红线 - **无反射**:所有帮助元数据通过源生成器在编译期确定,运行时不使用 `Type.GetProperties()`、`Attribute.GetCustomAttributes()` 等反射 API。 -- **AOT 安全**:源生成器生成所有需要的代码,不存在动态代码生成或 JIT 依赖。`IHelpProvider` 的实现是具体的已编译类型,适合 NativeAOT 部署。 +- **AOT 安全**:源生成器生成所有需要的代码,不存在动态代码生成或 JIT 依赖。`GetHelp()` 的实现是具体的已编译方法,适合 NativeAOT 部署。 - **无隐式输出**:`CommandRunner` 不会自动输出帮助信息。只有在用户显式调用 `AddHelpHandler()` 后,框架才有权限向 `Console.Out` 写入内容。 - **无 fallback 依赖**:帮助检测是独立的先行步骤,不依赖 `_fallback` 机制或异常处理的回退。 - **无 `-help` 支持**:无论何种风格,`-help` 都不是内置帮助的合法写法,不提供例外处理。 +- **Metadata 无字段约束**:`GetHelp()` 方法体中直接 `new CommandHelpMetadata { ... }` 返回,不在 `Metadata` 上引入任何实例字段。 ### 生成策略 -为了确保设计简单、稳定,并与现有的按类型生成 `Metadata` 的架构保持一致,推荐采用“总是生成,按需使用”的策略: +为了确保设计简单、稳定,并与现有的按类型生成 `Metadata` 的架构保持一致,推荐采用”总是生成,按需使用”的策略: -- 源生成器始终为所有 `Metadata` 生成 `IHelpProvider` 实现。 -- 运行时仅当用户调用了 `AddHelpHandler()` 时,`CommandRunner` 才会读取这些帮助元数据。 -- 如果用户没有调用 `AddHelpHandler()`,这些元数据保持未使用状态,但不会改变任何已有行为。 +- 源生成器始终为所有 `Metadata` 生成 `GetHelp()` 方法实现。 +- 运行时仅当用户调用了 `AddHelpHandler()` 时,`CommandRunner` 才会调用 `GetHelp()` 读取帮助元数据。 +- 如果用户没有调用 `AddHelpHandler()`,`GetHelp()` 不会被调用,不会改变任何已有行为。 这样做的好处是: @@ -556,7 +576,7 @@ private bool _helpEnabled; 3. **拒绝 `-help`**:该写法与短选项组合及多字符短选项规则冲突,不被支持。 4. **无 help 子命令**:子命令形式的帮助留待独立设计。 5. **先行检测**:帮助检测在 `CommandRunner.RunAsync()` 的开头执行,不影响正常命令匹配流程。 -6. **源生成器驱动**:利用现有 `ModelBuilderGenerator` 的管道,扩展 `Metadata` 类型以同时实现 `IHelpProvider` 接口,携带描述信息。 +6. **源生成器驱动**:利用现有 `ModelBuilderGenerator` 的管道,在 `Metadata` 类型上生成 `GetHelp()` 方法,携带描述信息。 7. **Description 作为唯一源**:使用 `CommandLineAttribute.Description` 属性,不涉及 XML 文档注释。 8. **无反射、AOT 安全**:所有帮助元数据在编译期确定。 9. **本地化与 XML 文档注释**:明确排除在本文档范围之外,留待后续设计。 From 3adf2b1ab73b2e48fc4b526faedf819a2418a577 Mon Sep 17 00:00:00 2001 From: walterlv Date: Thu, 21 May 2026 15:56:31 +0800 Subject: [PATCH 05/17] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E5=B8=AE=E5=8A=A9?= =?UTF-8?q?=EF=BC=88=E5=8F=A4=E6=B3=95=E7=BC=96=E7=A8=8B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Directory.Packages.props | 1 + .../Program.cs | 6 + .../Generators/ModelBuilderGenerator.cs | 73 ++- .../ModelProviding/CommandModelProvider.cs | 3 + .../Models/CommandObjectGeneratingModel.cs | 2 + ...OptionalArgumentPropertyGeneratingModel.cs | 8 + ...sitionalArgumentPropertyGeneratingModel.cs | 4 + .../CommandLineExceptionHandler.cs | 2 + src/DotNetCampus.CommandLine/CommandRunner.cs | 52 +- .../CommandRunnerBuilderExtensions.cs | 69 +++ .../Compiler/CommandHelpMetadata.cs | 94 ++++ .../Compiler/ICommandObjectMetadata.cs | 5 + .../Compiler/OptionAttribute.cs | 9 + .../DotNetCampus.CommandLine.csproj | 3 +- .../Help/HelpDetector.cs | 116 +++++ .../Help/HelpHandler.cs | 478 ++++++++++++++++++ .../Help/IHelpHandler.cs | 20 + .../HelpConfigurations.cs | 40 ++ .../Localizations/Lang.cs | 8 + .../Localizations/en.toml | 9 + .../Localizations/zh-hans.toml | 9 + .../MatchedCommand.cs | 32 ++ .../Utils/Handlers/TaskCommandHandler.cs | 10 + .../Help/AddHelpHandlerTests.cs | 224 ++++++++ 24 files changed, 1266 insertions(+), 11 deletions(-) create mode 100644 src/DotNetCampus.CommandLine/Compiler/CommandHelpMetadata.cs create mode 100644 src/DotNetCampus.CommandLine/Help/HelpDetector.cs create mode 100644 src/DotNetCampus.CommandLine/Help/HelpHandler.cs create mode 100644 src/DotNetCampus.CommandLine/Help/IHelpHandler.cs create mode 100644 src/DotNetCampus.CommandLine/HelpConfigurations.cs create mode 100644 src/DotNetCampus.CommandLine/Localizations/Lang.cs create mode 100644 src/DotNetCampus.CommandLine/Localizations/en.toml create mode 100644 src/DotNetCampus.CommandLine/Localizations/zh-hans.toml create mode 100644 src/DotNetCampus.CommandLine/MatchedCommand.cs create mode 100644 tests/DotNetCampus.CommandLine.Tests/Help/AddHelpHandlerTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 2194a75..e893bc0 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,6 +5,7 @@ + diff --git a/samples/DotNetCampus.CommandLine.Sample/Program.cs b/samples/DotNetCampus.CommandLine.Sample/Program.cs index 9178e4f..a6974f1 100644 --- a/samples/DotNetCampus.CommandLine.Sample/Program.cs +++ b/samples/DotNetCampus.CommandLine.Sample/Program.cs @@ -10,6 +10,12 @@ class Program { static void Main(string[] args) { + _ = CommandLine.Parse(args) + .AddHandler(o => { }) + .AddHelpHandler() + .Run(); + return; + #if !Benchmark // 第一次运行,排除类型初始化的影响,只测试代码执行性能。 // 注释掉这句话,可以: diff --git a/src/DotNetCampus.CommandLine.Analyzer/Generators/ModelBuilderGenerator.cs b/src/DotNetCampus.CommandLine.Analyzer/Generators/ModelBuilderGenerator.cs index 323d61c..7bc2a25 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 af33093..2238ca6 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 7b0e65b..4d62701 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 e11a60e..1b3362a 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 bd01913..eb139d9 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 9fc0f5a..b1b1983 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 b14c1c2..415e0e6 100644 --- a/src/DotNetCampus.CommandLine/CommandRunner.cs +++ b/src/DotNetCampus.CommandLine/CommandRunner.cs @@ -1,7 +1,9 @@ +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.Utils.Parsers; namespace DotNetCampus.Cli; @@ -20,6 +22,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 +86,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) + string.IsNullOrEmpty(matched.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); + : $"No command handler found for command '{matched.PossibleCommandNames}'. Please ensure that the command handler is registered by AddHandler().", + matched.PossibleCommandNames); } var context = new CommandRunningContext @@ -109,7 +122,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 +136,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 +144,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 +218,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 1529f2e..0efd0e8 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 0000000..08a5c8d --- /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 0bd4909..2e32eed 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 83d8969..12e90eb 100644 --- a/src/DotNetCampus.CommandLine/Compiler/OptionAttribute.cs +++ b/src/DotNetCampus.CommandLine/Compiler/OptionAttribute.cs @@ -114,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/DotNetCampus.CommandLine.csproj b/src/DotNetCampus.CommandLine/DotNetCampus.CommandLine.csproj index 078d0d9..55d426e 100644 --- a/src/DotNetCampus.CommandLine/DotNetCampus.CommandLine.csproj +++ b/src/DotNetCampus.CommandLine/DotNetCampus.CommandLine.csproj @@ -7,7 +7,7 @@ We add net5.0 to support OperatingSystem.IsWindows()/IsLinux()/IsMacOS(). We add netcoreapp3.1 to support basic string methods and ValueTask. --> - net8.0;net6.0;net5.0;netcoreapp3.1;netstandard2.0 + net8.0;net6.0;net5.0;netcoreapp3.1 enable DotNetCampus.CommandLine true @@ -37,6 +37,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/DotNetCampus.CommandLine/Help/HelpDetector.cs b/src/DotNetCampus.CommandLine/Help/HelpDetector.cs new file mode 100644 index 0000000..cf78652 --- /dev/null +++ b/src/DotNetCampus.CommandLine/Help/HelpDetector.cs @@ -0,0 +1,116 @@ +namespace DotNetCampus.Cli.Help; + +/// +/// 根据命令行风格检测帮助请求。 +/// +internal static class HelpDetector +{ + /// + /// 检测命令行参数中是否包含帮助请求。 + /// + public static bool IsHelpRequested(IReadOnlyList args, CommandLineStyle style) + { + if (style.Name == "Url") + { + return false; + } + + foreach (var arg in args) + { + if (IsHelpArg(arg, style)) + { + return true; + } + } + return false; + } + + /// + /// 从参数列表中过滤掉帮助写法,返回剩余参数。 + /// + public static List FilterOutHelpArgs(IReadOnlyList args, CommandLineStyle style) + { + var result = new List(args.Count); + foreach (var arg in args) + { + if (!IsHelpArg(arg, style)) + { + result.Add(arg); + } + } + return result; + } + + private static bool IsHelpArg(string arg, 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 && arg.Equals("--help", comparison)) + { + return true; + } + } + + // -h (DotNet, Gnu, Posix, Flexible) + if (prefix is CommandOptionPrefix.DoubleDash or CommandOptionPrefix.Any) + { + if (style.SupportsShortOption) + { + if (arg.Equals("-h", comparison)) + { + return true; + } + + // 短选项组合 (Gnu, Posix): -hxxx contains -h + if (style.SupportsShortOptionCombination && arg.Length > 2 && arg[0] == '-' && arg[1] != '-') + { + var chars = arg.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 (arg.Equals("/help", comparison)) + { + return true; + } + if (arg.Equals("/h", comparison)) + { + return true; + } + if (arg.Equals("/?", StringComparison.Ordinal)) + { + return true; + } + } + + // -? (Flexible, Windows) + if (prefix is CommandOptionPrefix.SlashOrDash or CommandOptionPrefix.Any) + { + if (arg.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 0000000..c1f5a6e --- /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 sub in subCommandMetadataList) + { + var len = sub.CommandName!.Length; + if (len <= maxColumnWidth && len > columnWidth) + { + columnWidth = len; + } + } + + foreach (var subCommandMetadata in subCommandMetadataList) + { + var name = subCommandMetadata.CommandName!; + var prefix = $" {name}"; + + if (name.Length > maxColumnWidth) + { + builder.AppendLine(prefix); + if (subCommandMetadata.Description is { } description) + { + builder.Append(new string(' ', columnWidth + 4)); + builder.AppendLine(ResolveLocalization(description)); + } + } + else + { + builder.Append(prefix.PadRight(columnWidth + 4)); + if (subCommandMetadata.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 pos in defaultCommandMetadata.PositionalArguments) + { + var len = pos.Name.Length + 2; // 2 = [ + ] + if (len <= maxColumnWidth && len > columnWidth) + { + columnWidth = len; + } + } + + 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 sb = new StringBuilder(); + var first = true; + foreach (var shortName in option.ShortNames) + { + if (!first) sb.Append('|'); + sb.Append('-'); + sb.Append(shortName); + first = false; + } + foreach (var longName in option.LongNames) + { + if (!first) sb.Append('|'); + sb.Append("--"); + sb.Append(longName); + first = false; + } + + var valuePlaceholder = GetValuePlaceholder(option); + if (valuePlaceholder is not null) + { + sb.Append(' '); + sb.Append(valuePlaceholder); + } + + return sb.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 0000000..96bc783 --- /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 0000000..1ead411 --- /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 0000000..7fceb2b --- /dev/null +++ b/src/DotNetCampus.CommandLine/Localizations/Lang.cs @@ -0,0 +1,8 @@ +using dotnetCampus.Localizations; + +namespace DotNetCampus.Cli.Localizations; + +[LocalizedConfiguration(Default = "en", EnsureKeysIdentical = true, SupportsNotification = false)] +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 0000000..831a714 --- /dev/null +++ b/src/DotNetCampus.CommandLine/Localizations/en.toml @@ -0,0 +1,9 @@ +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" diff --git a/src/DotNetCampus.CommandLine/Localizations/zh-hans.toml b/src/DotNetCampus.CommandLine/Localizations/zh-hans.toml new file mode 100644 index 0000000..29cb50d --- /dev/null +++ b/src/DotNetCampus.CommandLine/Localizations/zh-hans.toml @@ -0,0 +1,9 @@ +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 = "显示帮助信息" diff --git a/src/DotNetCampus.CommandLine/MatchedCommand.cs b/src/DotNetCampus.CommandLine/MatchedCommand.cs new file mode 100644 index 0000000..830a4a5 --- /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 43f3acf..47f2219 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/tests/DotNetCampus.CommandLine.Tests/Help/AddHelpHandlerTests.cs b/tests/DotNetCampus.CommandLine.Tests/Help/AddHelpHandlerTests.cs new file mode 100644 index 0000000..4cf7e0c --- /dev/null +++ b/tests/DotNetCampus.CommandLine.Tests/Help/AddHelpHandlerTests.cs @@ -0,0 +1,224 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using DotNetCampus.Cli.Compiler; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace DotNetCampus.Cli.Tests.Help; + +[TestClass] +public class AddHelpHandlerTests +{ + [TestMethod] + public void AddHelpHandler_WithHelp_ReturnsExitCode0() + { + var writer = new StringWriter(); + Console.SetOut(writer); + try + { + var result = CommandLine.Parse(["--help"]) + .AddHandler(o => { }) + .AddHelpHandler() + .Run(); + Assert.AreEqual(0, result.ExitCode); + } + finally + { + Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true }); + } + } + + [TestMethod] + public void AddHelpHandler_WithHelp_WritesToConsoleOut() + { + var writer = new StringWriter(); + Console.SetOut(writer); + try + { + CommandLine.Parse(["--help"]) + .AddHandler(o => { }) + .AddHelpHandler() + .Run(); + var output = writer.ToString(); + Assert.IsTrue(output.Contains("用法:")); + } + finally + { + Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true }); + } + } + + [TestMethod] + public void AddHelpHandler_WithoutHelp_RunsNormally() + { + var handled = false; + var result = CommandLine.Parse(["-n", "test"]) + .AddHandler(o => handled = true) + .AddHelpHandler() + .Run(); + Assert.IsTrue(handled); + } + + [TestMethod] + public void WithoutAddHelpHandler_HelpArgIsNormal() + { + // 没有调用 AddHelpHandler(),--help 被当作普通选项 + // 这里期望正常解析流程不做帮助检测 + var handled = false; + var result = CommandLine.Parse(["--help"], CommandLineParsingOptions.Flexible) + .AddHandler(o => handled = true) + .Run(); + Assert.IsTrue(handled); + } + + [TestMethod] + public void AddHelpHandler_CommandSpecificHelp() + { + var writer = new StringWriter(); + Console.SetOut(writer); + try + { + CommandLine.Parse(["sub", "--help"]) + .AddHandler(o => { }) + .AddHandler(o => { }) + .AddHelpHandler() + .Run(); + var output = writer.ToString(); + Assert.IsTrue(output.Contains("sub")); + } + finally + { + Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true }); + } + } + + [TestMethod] + public void AddHelpHandler_RootHelp_ListsAllCommands() + { + var writer = new StringWriter(); + Console.SetOut(writer); + try + { + CommandLine.Parse(["--help"]) + .AddHandler(o => { }) + .AddHandler(o => { }) + .AddHelpHandler() + .Run(); + var output = writer.ToString(); + Assert.IsTrue(output.Contains("命令:")); + Assert.IsTrue(output.Contains("sub")); + Assert.IsTrue(output.Contains("another")); + } + finally + { + Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true }); + } + } + + [TestMethod] + public void AddHelpHandler_OnAsyncBuilder() + { + var writer = new StringWriter(); + Console.SetOut(writer); + try + { + var result = CommandLine.Parse(["--help"]) + .AddHandler() + .AddHelpHandler() + .RunAsync().Result; + Assert.AreEqual(0, result.ExitCode); + } + finally + { + Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true }); + } + } + + [TestMethod] + public void AddHelpHandler_OnCommandLineDirectly() + { + var writer = new StringWriter(); + Console.SetOut(writer); + try + { + var result = CommandLine.Parse(["--help"]) + .AddHelpHandler() + .AddHandler(o => { }) + .Run(); + Assert.AreEqual(0, result.ExitCode); + } + finally + { + Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true }); + } + } + + [TestMethod] + public void AddHelpHandler_DotNetStyle_DashH() + { + var writer = new StringWriter(); + Console.SetOut(writer); + try + { + var result = CommandLine.Parse(["-h"], CommandLineParsingOptions.DotNet) + .AddHandler(o => { }) + .AddHelpHandler() + .Run(); + Assert.AreEqual(0, result.ExitCode); + Assert.IsTrue(writer.ToString().Contains("用法:")); + } + finally + { + Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true }); + } + } + + [TestMethod] + public void AddHelpHandler_WindowsStyle_SlashQuestion() + { + var writer = new StringWriter(); + Console.SetOut(writer); + try + { + var result = CommandLine.Parse(["/?"], CommandLineParsingOptions.Windows) + .AddHandler(o => { }) + .AddHelpHandler() + .Run(); + Assert.AreEqual(0, result.ExitCode); + Assert.IsTrue(writer.ToString().Contains("用法:")); + } + finally + { + Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true }); + } + } + + public record SimpleOptions + { + [Option('n', "name", Description = "名称")] + public string? Name { get; init; } + } + + [Command("sub", Description = "子命令")] + public record SubOptions + { + [Option("count", Description = "次数")] + public int Count { get; init; } + } + + [Command("another", Description = "另一个子命令")] + public record AnotherSubOptions + { + [Option("flag")] + public bool Flag { get; init; } + } + + [Command] + public class SimpleHandler : ICommandHandler + { + public Task RunAsync() + { + return Task.FromResult(0); + } + } +} From 0aaa645d1e10e5fd26ff93476e6225c6c395a993 Mon Sep 17 00:00:00 2001 From: walterlv Date: Fri, 22 May 2026 16:06:10 +0800 Subject: [PATCH 06/17] =?UTF-8?q?=E9=87=8D=E6=96=B0=E6=95=B4=E7=90=86?= =?UTF-8?q?=E7=A4=BA=E4=BE=8B=E9=A1=B9=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ConvertHandler.cs | 41 +++++ .../DefaultOptions.cs | 22 ++- .../EditHandler.cs | 40 +++++ .../Fakes/OptionsParser.cs | 97 ---------- .../Legacy/BenchmarkHandler.cs | 100 +++++++++++ .../Options.cs => Legacy/LegacyOptions.cs} | 16 +- .../Legacy/LegacyOptionsParser.cs | 95 ++++++++++ .../LegacyVerbOptions.cs} | 18 +- .../Program.cs | 167 ++---------------- .../SampleOptions.cs | 21 --- .../UrlOpenHandler.cs | 35 ++++ 11 files changed, 360 insertions(+), 292 deletions(-) create mode 100644 samples/DotNetCampus.CommandLine.Sample/ConvertHandler.cs create mode 100644 samples/DotNetCampus.CommandLine.Sample/EditHandler.cs delete mode 100644 samples/DotNetCampus.CommandLine.Sample/Fakes/OptionsParser.cs create mode 100644 samples/DotNetCampus.CommandLine.Sample/Legacy/BenchmarkHandler.cs rename samples/DotNetCampus.CommandLine.Sample/{Fakes/Options.cs => Legacy/LegacyOptions.cs} (89%) create mode 100644 samples/DotNetCampus.CommandLine.Sample/Legacy/LegacyOptionsParser.cs rename samples/DotNetCampus.CommandLine.Sample/{Fakes/VerbOptions.cs => Legacy/LegacyVerbOptions.cs} (80%) delete mode 100644 samples/DotNetCampus.CommandLine.Sample/SampleOptions.cs create mode 100644 samples/DotNetCampus.CommandLine.Sample/UrlOpenHandler.cs diff --git a/samples/DotNetCampus.CommandLine.Sample/ConvertHandler.cs b/samples/DotNetCampus.CommandLine.Sample/ConvertHandler.cs new file mode 100644 index 0000000..72275d7 --- /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 d928110..0749dfc 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 0000000..7c36ee0 --- /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 94c66aa..0000000 --- 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 0000000..4797c87 --- /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 08f7e2d..b4f55ad 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 0000000..29f282a --- /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 27db29a..2d6d87b 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 a6974f1..bbf7129 100644 --- a/samples/DotNetCampus.CommandLine.Sample/Program.cs +++ b/samples/DotNetCampus.CommandLine.Sample/Program.cs @@ -1,159 +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) { - _ = CommandLine.Parse(args) - .AddHandler(o => { }) - .AddHelpHandler() - .Run(); - return; - -#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("| ------- | ------- | ---------- | ----------- |"); - + var appState = new AppState { - 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++) + AppName = "DotNetCampus.CommandLine.Sample", + }; + + await CommandLine.Parse(args, CommandLineParsingOptions.Flexible) + .AddHandler(o => o.Run()) + .AddHandler() + .AddHandler() + .AddHandler() + .AddHelpHandler(new HelpConfigurations { - _ = oldCommandLine.As(new OptionsParser()); - } - stopwatch.Stop(); - Console.Write($"{stopwatch.ElapsedMilliseconds.ToString(),7} ms | "); - stopwatch.Restart(); - for (var i = 0; i < testCount; i++) - { - _ = oldCommandLine.As(); - } - stopwatch.Stop(); - Console.WriteLine($"{stopwatch.ElapsedMilliseconds.ToString(),8} ms |"); - } - { - Console.Write("| 4.x | "); - stopwatch.Restart(); - for (var i = 0; i < testCount; i++) - { - _ = CommandLine.Parse(args, parsingOptions); - } - 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++) - { - _ = 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 86fbcdc..0000000 --- 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 0000000..edc100f --- /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; } +} From 78b42ed8966100c9e289a2402f2817a62661df2e Mon Sep 17 00:00:00 2001 From: walterlv Date: Sun, 24 May 2026 12:17:53 +0800 Subject: [PATCH 07/17] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=A4=9A=E8=AF=AD?= =?UTF-8?q?=E8=A8=80=E5=BA=93=EF=BC=8C=E4=BB=A5=E6=94=AF=E6=8C=81=E5=86=85?= =?UTF-8?q?=E5=B5=8C=E5=A4=9A=E8=AF=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Directory.Packages.props | 4 ++-- .../DotNetCampus.CommandLine.csproj | 2 +- src/DotNetCampus.CommandLine/Localizations/Lang.cs | 8 ++++++-- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index e893bc0..87a311a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,7 +5,7 @@ - + @@ -18,4 +18,4 @@ - \ No newline at end of file + diff --git a/src/DotNetCampus.CommandLine/DotNetCampus.CommandLine.csproj b/src/DotNetCampus.CommandLine/DotNetCampus.CommandLine.csproj index 55d426e..38160e1 100644 --- a/src/DotNetCampus.CommandLine/DotNetCampus.CommandLine.csproj +++ b/src/DotNetCampus.CommandLine/DotNetCampus.CommandLine.csproj @@ -7,7 +7,7 @@ We add net5.0 to support OperatingSystem.IsWindows()/IsLinux()/IsMacOS(). We add netcoreapp3.1 to support basic string methods and ValueTask. --> - net8.0;net6.0;net5.0;netcoreapp3.1 + net8.0;net6.0;net5.0;netcoreapp3.1;netstandard2.0 enable DotNetCampus.CommandLine true diff --git a/src/DotNetCampus.CommandLine/Localizations/Lang.cs b/src/DotNetCampus.CommandLine/Localizations/Lang.cs index 7fceb2b..a5bab0c 100644 --- a/src/DotNetCampus.CommandLine/Localizations/Lang.cs +++ b/src/DotNetCampus.CommandLine/Localizations/Lang.cs @@ -1,8 +1,12 @@ -using dotnetCampus.Localizations; +using DotNetCampus.Localizations; namespace DotNetCampus.Cli.Localizations; -[LocalizedConfiguration(Default = "en", EnsureKeysIdentical = true, SupportsNotification = false)] +[LocalizedConfiguration(Default = "en", + EnsureKeysIdentical = true, + DependencyMode = DependencyMode.NestedSource, + GenerationMode = GenerationMode.Compiled, + NotificationMode = NotificationMode.InitOnly)] internal partial class Lang { } From 8622fda1210816f48bb9287d46ae229cd7d1fd14 Mon Sep 17 00:00:00 2001 From: walterlv Date: Sun, 24 May 2026 13:08:54 +0800 Subject: [PATCH 08/17] =?UTF-8?q?=E5=91=BD=E4=BB=A4=E8=A1=8C=E5=BA=93?= =?UTF-8?q?=E5=BC=82=E5=B8=B8=E4=BF=A1=E6=81=AF=E6=9C=AC=E5=9C=B0=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/DotNetCampus.CommandLine/CommandRunner.cs | 5 ++-- .../Compiler/PropertyAssignments.cs | 11 ++++---- .../Exceptions/CommandLineException.cs | 6 +++-- .../Exceptions/CommandLineParseException.cs | 7 ++--- .../Localizations/en.toml | 26 +++++++++++++++++++ .../Localizations/zh-hans.toml | 26 +++++++++++++++++++ .../Utils/Parsers/CommandLineParsingResult.cs | 25 +++++++++--------- 7 files changed, 82 insertions(+), 24 deletions(-) diff --git a/src/DotNetCampus.CommandLine/CommandRunner.cs b/src/DotNetCampus.CommandLine/CommandRunner.cs index 415e0e6..beaf4be 100644 --- a/src/DotNetCampus.CommandLine/CommandRunner.cs +++ b/src/DotNetCampus.CommandLine/CommandRunner.cs @@ -4,6 +4,7 @@ using DotNetCampus.Cli.Compiler; using DotNetCampus.Cli.Exceptions; using DotNetCampus.Cli.Help; +using DotNetCampus.Cli.Localizations; using DotNetCampus.Cli.Utils.Parsers; namespace DotNetCampus.Cli; @@ -102,8 +103,8 @@ public Task RunAsync() { throw new CommandNameNotFoundException( string.IsNullOrEmpty(matched.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 '{matched.PossibleCommandNames}'. Please ensure that the command handler is registered by AddHandler().", + ? Lang.Current.DotNetCampus.CommandLine.Error.CommandNotFound.ToString() + : Lang.Current.DotNetCampus.CommandLine.Error.CommandNotFoundForName.ToString(matched.PossibleCommandNames), matched.PossibleCommandNames); } diff --git a/src/DotNetCampus.CommandLine/Compiler/PropertyAssignments.cs b/src/DotNetCampus.CommandLine/Compiler/PropertyAssignments.cs index 49c8e34..a6ae7f3 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/Exceptions/CommandLineException.cs b/src/DotNetCampus.CommandLine/Exceptions/CommandLineException.cs index c6f9505..1676108 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 dfcfe59..a03a5c2 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/Localizations/en.toml b/src/DotNetCampus.CommandLine/Localizations/en.toml index 831a714..491545f 100644 --- a/src/DotNetCampus.CommandLine/Localizations/en.toml +++ b/src/DotNetCampus.CommandLine/Localizations/en.toml @@ -1,3 +1,4 @@ +# Help DotNetCampus.CommandLine.Help.UsageHeader = "Usage: " DotNetCampus.CommandLine.Help.UsageOptions = "[options]" DotNetCampus.CommandLine.Help.UsageCommand = "" @@ -7,3 +8,28 @@ 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 index 29cb50d..e7d5293 100644 --- a/src/DotNetCampus.CommandLine/Localizations/zh-hans.toml +++ b/src/DotNetCampus.CommandLine/Localizations/zh-hans.toml @@ -1,3 +1,4 @@ +# Help DotNetCampus.CommandLine.Help.UsageHeader = "用法:" DotNetCampus.CommandLine.Help.UsageOptions = "[选项]" DotNetCampus.CommandLine.Help.UsageCommand = "<命令>" @@ -7,3 +8,28 @@ 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/Utils/Parsers/CommandLineParsingResult.cs b/src/DotNetCampus.CommandLine/Utils/Parsers/CommandLineParsingResult.cs index e44a6e9..58733f7 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); } } From 29bafb5ea891ebd244be43110a7ab103322ebb9f Mon Sep 17 00:00:00 2001 From: walterlv Date: Sun, 24 May 2026 13:09:25 +0800 Subject: [PATCH 09/17] =?UTF-8?q?=E6=96=B0=E7=9A=84=E5=91=BD=E4=BB=A4?= =?UTF-8?q?=E8=A1=8C=E5=BA=93=E5=B7=B2=E7=BB=8F=E5=8F=AF=E4=BB=A5=E4=B8=8D?= =?UTF-8?q?=E9=9C=80=E8=A6=81=E4=BE=9D=E8=B5=96=E5=BA=93=E6=9C=AC=E8=BA=AB?= =?UTF-8?q?=E4=BA=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/DotNetCampus.CommandLine/DotNetCampus.CommandLine.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/DotNetCampus.CommandLine/DotNetCampus.CommandLine.csproj b/src/DotNetCampus.CommandLine/DotNetCampus.CommandLine.csproj index 38160e1..0bfc361 100644 --- a/src/DotNetCampus.CommandLine/DotNetCampus.CommandLine.csproj +++ b/src/DotNetCampus.CommandLine/DotNetCampus.CommandLine.csproj @@ -37,7 +37,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive From e4b6bc0f2c5f04a2be8f6f795975a2e4d82070bc Mon Sep 17 00:00:00 2001 From: walterlv Date: Sun, 24 May 2026 20:13:21 +0800 Subject: [PATCH 10/17] =?UTF-8?q?=E5=8D=87=E7=BA=A7=20.NET=20SDK?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dotnet-build.yml | 2 +- .github/workflows/nuget-tag-publish.yml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/dotnet-build.yml b/.github/workflows/dotnet-build.yml index 12bfa2e..e00887b 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 801818e..cda9e15 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 From fee8413372bc82be4d734b28ffdcef27d77704f3 Mon Sep 17 00:00:00 2001 From: walterlv Date: Sun, 24 May 2026 20:23:06 +0800 Subject: [PATCH 11/17] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E4=BB=A5=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E9=94=99=E8=AF=AF=E7=9A=84=E5=A4=9A=E8=AF=AD=E8=A8=80?= =?UTF-8?q?=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 87a311a..7ccae25 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,7 +5,7 @@ - + From 7983d61a5719fee5a548f6d227b939663b6b801c Mon Sep 17 00:00:00 2001 From: walterlv Date: Sun, 24 May 2026 20:28:48 +0800 Subject: [PATCH 12/17] =?UTF-8?q?=E5=88=A0=E9=99=A4=E6=B2=A1=E5=A5=BD?= =?UTF-8?q?=E5=A5=BD=E5=86=99=E7=9A=84=20Help=20=E5=8D=95=E5=85=83?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Help/AddHelpHandlerTests.cs | 224 ------------------ 1 file changed, 224 deletions(-) delete mode 100644 tests/DotNetCampus.CommandLine.Tests/Help/AddHelpHandlerTests.cs diff --git a/tests/DotNetCampus.CommandLine.Tests/Help/AddHelpHandlerTests.cs b/tests/DotNetCampus.CommandLine.Tests/Help/AddHelpHandlerTests.cs deleted file mode 100644 index 4cf7e0c..0000000 --- a/tests/DotNetCampus.CommandLine.Tests/Help/AddHelpHandlerTests.cs +++ /dev/null @@ -1,224 +0,0 @@ -using System; -using System.IO; -using System.Threading.Tasks; -using DotNetCampus.Cli.Compiler; -using Microsoft.VisualStudio.TestTools.UnitTesting; - -namespace DotNetCampus.Cli.Tests.Help; - -[TestClass] -public class AddHelpHandlerTests -{ - [TestMethod] - public void AddHelpHandler_WithHelp_ReturnsExitCode0() - { - var writer = new StringWriter(); - Console.SetOut(writer); - try - { - var result = CommandLine.Parse(["--help"]) - .AddHandler(o => { }) - .AddHelpHandler() - .Run(); - Assert.AreEqual(0, result.ExitCode); - } - finally - { - Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true }); - } - } - - [TestMethod] - public void AddHelpHandler_WithHelp_WritesToConsoleOut() - { - var writer = new StringWriter(); - Console.SetOut(writer); - try - { - CommandLine.Parse(["--help"]) - .AddHandler(o => { }) - .AddHelpHandler() - .Run(); - var output = writer.ToString(); - Assert.IsTrue(output.Contains("用法:")); - } - finally - { - Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true }); - } - } - - [TestMethod] - public void AddHelpHandler_WithoutHelp_RunsNormally() - { - var handled = false; - var result = CommandLine.Parse(["-n", "test"]) - .AddHandler(o => handled = true) - .AddHelpHandler() - .Run(); - Assert.IsTrue(handled); - } - - [TestMethod] - public void WithoutAddHelpHandler_HelpArgIsNormal() - { - // 没有调用 AddHelpHandler(),--help 被当作普通选项 - // 这里期望正常解析流程不做帮助检测 - var handled = false; - var result = CommandLine.Parse(["--help"], CommandLineParsingOptions.Flexible) - .AddHandler(o => handled = true) - .Run(); - Assert.IsTrue(handled); - } - - [TestMethod] - public void AddHelpHandler_CommandSpecificHelp() - { - var writer = new StringWriter(); - Console.SetOut(writer); - try - { - CommandLine.Parse(["sub", "--help"]) - .AddHandler(o => { }) - .AddHandler(o => { }) - .AddHelpHandler() - .Run(); - var output = writer.ToString(); - Assert.IsTrue(output.Contains("sub")); - } - finally - { - Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true }); - } - } - - [TestMethod] - public void AddHelpHandler_RootHelp_ListsAllCommands() - { - var writer = new StringWriter(); - Console.SetOut(writer); - try - { - CommandLine.Parse(["--help"]) - .AddHandler(o => { }) - .AddHandler(o => { }) - .AddHelpHandler() - .Run(); - var output = writer.ToString(); - Assert.IsTrue(output.Contains("命令:")); - Assert.IsTrue(output.Contains("sub")); - Assert.IsTrue(output.Contains("another")); - } - finally - { - Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true }); - } - } - - [TestMethod] - public void AddHelpHandler_OnAsyncBuilder() - { - var writer = new StringWriter(); - Console.SetOut(writer); - try - { - var result = CommandLine.Parse(["--help"]) - .AddHandler() - .AddHelpHandler() - .RunAsync().Result; - Assert.AreEqual(0, result.ExitCode); - } - finally - { - Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true }); - } - } - - [TestMethod] - public void AddHelpHandler_OnCommandLineDirectly() - { - var writer = new StringWriter(); - Console.SetOut(writer); - try - { - var result = CommandLine.Parse(["--help"]) - .AddHelpHandler() - .AddHandler(o => { }) - .Run(); - Assert.AreEqual(0, result.ExitCode); - } - finally - { - Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true }); - } - } - - [TestMethod] - public void AddHelpHandler_DotNetStyle_DashH() - { - var writer = new StringWriter(); - Console.SetOut(writer); - try - { - var result = CommandLine.Parse(["-h"], CommandLineParsingOptions.DotNet) - .AddHandler(o => { }) - .AddHelpHandler() - .Run(); - Assert.AreEqual(0, result.ExitCode); - Assert.IsTrue(writer.ToString().Contains("用法:")); - } - finally - { - Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true }); - } - } - - [TestMethod] - public void AddHelpHandler_WindowsStyle_SlashQuestion() - { - var writer = new StringWriter(); - Console.SetOut(writer); - try - { - var result = CommandLine.Parse(["/?"], CommandLineParsingOptions.Windows) - .AddHandler(o => { }) - .AddHelpHandler() - .Run(); - Assert.AreEqual(0, result.ExitCode); - Assert.IsTrue(writer.ToString().Contains("用法:")); - } - finally - { - Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true }); - } - } - - public record SimpleOptions - { - [Option('n', "name", Description = "名称")] - public string? Name { get; init; } - } - - [Command("sub", Description = "子命令")] - public record SubOptions - { - [Option("count", Description = "次数")] - public int Count { get; init; } - } - - [Command("another", Description = "另一个子命令")] - public record AnotherSubOptions - { - [Option("flag")] - public bool Flag { get; init; } - } - - [Command] - public class SimpleHandler : ICommandHandler - { - public Task RunAsync() - { - return Task.FromResult(0); - } - } -} From 19e5e1243b70fc0e8809566a1dcd57e977b8147b Mon Sep 17 00:00:00 2001 From: walterlv Date: Sun, 24 May 2026 20:30:23 +0800 Subject: [PATCH 13/17] =?UTF-8?q?=E5=88=A0=E9=99=A4=E8=AE=BE=E8=AE=A1?= =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=8C=E5=90=8E=E9=9D=A2=E7=9B=B4=E6=8E=A5?= =?UTF-8?q?=E8=A1=A5=E4=BD=BF=E7=94=A8=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/help-design.md | 584 -------------------------------------------- 1 file changed, 584 deletions(-) delete mode 100644 docs/help-design.md diff --git a/docs/help-design.md b/docs/help-design.md deleted file mode 100644 index 3f77e4f..0000000 --- a/docs/help-design.md +++ /dev/null @@ -1,584 +0,0 @@ -# 内置帮助支持设计 - -本文档描述 DotNetCampus.CommandLine 内置帮助支持的设计方案。这是一份设计文档,不包含代码实现。 - -## 背景 - -DotNetCampus.CommandLine 提供了丰富的命令行解析能力,支持多风格、多命令、子命令、委托处理等多种使用模式。但在实际使用中,用户需要一种标准方式来了解当前程序支持哪些命令和选项。常见的做法是在命令行中传入 `--help` 或 `-h` 等参数来获取帮助信息。 - -过去,框架曾提供 `AddStandardHandlers()` 方法来自动注册 `--help` 和 `--version` 处理逻辑。但该方法的行为不符合大多数开发者的预期,已被标记为 `[Obsolete]` 并从代码库中移除。当前版本中,帮助功能需要完全由使用方自行实现,缺乏统一的内置支持。 - -本设计的目标是在保持框架现有架构风格的前提下,为 DotNetCampus.CommandLine 提供可选的内置帮助支持。 - -## 目标与非目标 - -### 目标 - -- 提供风格感知的内置帮助检测,在 `DotNet`、`Gnu`、`Flexible`、`Windows`、`Posix` 等风格下自动识别对应的帮助写法。 -- 提供 `AddHelpHandler()` 扩展方法,允许用户按需启用帮助输出。 -- 根帮助应展示默认命令的参数信息(当且仅当存在默认命令时);命令帮助展示该命令的选项信息。 -- 通过源生成器扩展现有 `Metadata` 类型,使其携带帮助所需的描述信息。 -- 保持无反射、AOT 安全的设计原则。 -- 输出文本的描述来源限定为 `CommandLineAttribute.Description` 属性,这是当前唯一的信息源。 -- `CommandRunner` 应在正常命令匹配之前检测帮助请求,而不是通过 fallback 机制。 - -### 非目标 - -- **不支持 `-help` 写法**。理由详见下文。 -- **不包含 help 子命令**。本文档仅讨论内置的 `--help` 风格支持,不涉及 `myapp help` 这种子命令形式。子命令形式的帮助可留作后续独立设计。 -- **不支持 XML 文档注释作为帮助信息来源**。`Description` 是当前唯一的信息源,XML 文档注释不在本设计范围内。 -- **不支持本地化**。本设计不涉及多语言的描述文本切换。如后续需要本地化支持,应另案处理。 -- **不展示选项的默认值**。框架在属性未赋值时的回退行为(如空字符串、空集合、`default(T)` 等)是内部的兜底逻辑,并非用户在业务上有意义的默认值声明。在没有提供显式声明默认值的机制之前,帮助信息不展示默认值,以免对 CLI 使用者造成误导。 -- **不修改现有的选项匹配或命令匹配逻辑**。帮助检测是一个独立的先行检查步骤,不干扰常规解析路径。 -- **不产生隐式的控制台输出**。所有帮助文本的输出都要求用户显式调用 `AddHelpHandler()` 后才生效。 - -## 用户侧 API 设计 - -用户通过链式调用中的 `AddHelpHandler()` 方法来启用内置帮助支持: - -```csharp -// 启用帮助支持 -var commandLine = CommandLine.Parse(args) - .AddHandler(options => { /* 处理 add 命令 */ }) - .AddHandler(options => { /* 处理 remove 命令 */ }) - .AddHelpHandler() // <-- 启用内置帮助 - .Run(); -``` - -`AddHelpHandler()` 是一个扩展方法,内部调用 `CommandRunner` 的专用方法以注册帮助元数据。它返回 `IAsyncCommandRunnerBuilder` 以支持继续链式调用。 - -```csharp -// 更复杂的调用链 -var commandLine = CommandLine.Parse(args) - .AddHandler() - .AddHelpHandler() - .ForState(myState).AddHandler() - .RunAsync(); -``` - -`AddHelpHandler()` 在框架层面的作用包括: - -1. 在 `CommandRunner` 上注册一个内部帮助标记,告诉运行器在运行前检查帮助请求。 -2. 源生成器在生 `Metadata` 时额外实现一个帮助元数据接口,携带描述信息。 -3. 当检测到帮助请求时,运行器使用已注册的所有处理器的元数据来构建帮助文本。 - -如果用户没有调用 `AddHelpHandler()`,则 `--help`、`-h` 等参数会被当做普通选项处理,不会触发任何帮助输出。这是设计红线:**框架不会在用户未显式选择的情况下向控制台写入任何内容**。 - -## 按风格区分的帮助写法支持 - -不同的命令行风格对选项前缀、大小写敏感性、分隔符有不同的规则。内置帮助检测必须感知这些差异,才能正确识别用户的帮助意图。 - -下表列出了各风格下支持的帮助写法: - -| 风格 | `--help` | `-h` | `-?` | `/?` | `/help` | `/h` | `-help` | -| -------- | -------- | ------ | ------ | ------ | ------- | ------ | ------- | -| DotNet | 支持 | 支持 | 不支持 | 不支持 | 不支持 | 不支持 | 不支持 | -| Gnu | 支持 | 支持 | 不支持 | 不支持 | 不支持 | 不支持 | 不支持 | -| Flexible | 支持 | 支持 | 支持 | 支持 | 支持 | 支持¹ | 不支持 | -| Windows | 不支持 | 不支持 | 支持 | 支持 | 支持 | 支持¹ | 不支持 | -| Posix | 不支持 | 支持 | 不支持 | 不支持 | 不支持 | 不支持 | 不支持 | -| URL | 不支持 | 不支持 | 不支持 | 不支持 | 不支持 | 不支持 | 不支持 | - -> ¹ `/h` 作为兼容别名支持,前提是其实现不引入额外的复杂度。它并非本设计的主要推荐写法,只在现有前缀与短选项规则能够自然覆盖时顺带支持。 - -### 各风格的匹配细节 - -**DotNet 风格**: -- 使用 `--help` 作为长选项,大小写敏感。这是 .NET CLI 的标准用法。 -- 使用 `-h` 作为短选项。DotNet 风格支持多字符短选项(`-tl`),因此 `-h` 是安全的单字符短选项。 -- 访问值分隔符支持 `:`、`=` 和空格,但帮助选项是布尔型,不需要携带值。 - -**Gnu 风格**: -- 使用 `--help` 作为长选项,大小写敏感,符合 GNU 标准惯例。 -- 使用 `-h` 作为短选项。注意 GNU 风格支持短选项组合(`-abc` 等价于 `-a -b -c`),因此 `-h` 可能被组合使用(如 `-hv`)。帮助检测应在解析器完成短选项拆分后,对拆分出的单个短字符进行匹配。 - -**Flexible 风格**: -- 支持最多的写法变体,因为它兼容所有前缀(`--`、`-`、`/`)。 -- 大小写不敏感,因此 `--Help`、`-H`、`/HELP` 均可匹配。 -- `/?` 和 `-?` 也在支持范围内,因为 Flexible 风格天然支持短选项和 `/` 前缀。 - -**Windows 风格**: -- 核心支持 `/?`(传统 Windows 帮助写法)和 `-?`。 -- 支持 `/help` 作为显式帮助写法。 -- 由于 Windows 风格接受 `/` 或 `-` 作为前缀,大小写不敏感,这些写法的各种大小写变体均能匹配。 -- `/h` 作为一个单字符短选项,在 Windows 风格下可作为兼容性别名支持,但不作为主要推荐写法。 - -**Posix 风格**: -- 仅支持短选项,不支持长选项,因此只支持 `-h`。 -- 支持短选项组合,因此 `-h` 可能被组合使用。 - -**URL 风格**: -- 不适用于帮助场景。URL 风格通常用于程序间通信的 deep link,不涉及用户交互的帮助输出。 - -## 为什么不支持某些写法 - -### `-help` 不被支持 - -`-help`(单短横线后跟完整单词 "help")在多个风格中与现有规则冲突: - -1. **与短选项组合规则冲突**。在 Gnu 和 Posix 风格中,`-abc` 被解析为三个短选项 `-a -b -c`。`-help` 在这样的风格下会被拆解为 `-h -e -l -p`,而非当作一个名为 "help" 的长选项。如果特例化处理,将破坏短选项组合语法的可预测性。 - -2. **与多字符短选项规则冲突**。在 DotNet 和 Windows 风格中,`-abc` 被视为一个名为 "abc" 的多字符短选项。`-help` 在这些风格下确实会被当作一个整体,但问题是 "help" 不是一个普通选项名,而是一个内置语义关键词。多字符短选项本身已经用于用户定义的选项名(如 `-tl` 作为 `--terminal-logger` 的短选项),增加内置关键词的特殊匹配会引入解析器的额外复杂性。 - -3. **风格间行为不统一**。`-help` 在 Gnu 风格下会被拆成 `-h -e -l -p`,在 DotNet 风格下会被当作一个整体。这种不一致对用户和实现都是困惑的来源。维护风格间行为一致性的代价太高。 - -4. **破坏用户的选项命名空间**。用户可能恰好定义了一个名为 `-help` 的多字符短选项,或者恰好定义了 `-h`、`-e`、`-l`、`-p` 等短选项。内置的 `-help` 检测会与这些用户选项产生意外的交互。 - -基于以上原因,`-help` 在本设计中不被支持。用户应使用 `--help`(双短横线,所有支持长选项的风格均认可)或对应风格认可的其他写法(`-h`、`/?` 等)。 - -### `help` 子命令不被包含在本设计中 - -`myapp help` 形式的子命令具有独立的语义和实现路径。它涉及命令名的特殊注册、与现有 `AddHandler` 机制的交互、以及对用户自定义的 `help` 命令的冲突处理。这些复杂度需要单独的设计文档来处理,不在本文档的范围内。 - -## 帮助请求的检测与命令定位 - -### 检测时机 - -帮助请求的检测发生在 `CommandRunner.RunAsync()` 的开头,在所有正常的命令匹配和参数解析之前。这是一个独立的先行检查步骤。 - -伪流程如下: - -``` -RunAsync() -├── 步骤 1: 检查帮助请求 -│ ├── 如果调用了 AddHelpHandler(),扫描原始参数 -│ ├── 匹配到帮助写法 → 进入帮助输出流程 -│ └── 未匹配到帮助写法 → 继续正常流程 -├── 步骤 2: 正常的命令匹配 (现有的 MatchCommandObject) -└── 步骤 3: 命令构建和执行 -``` - -这个顺序保证了: - -- 帮助请求不会被用户的命令处理器意外拦截。 -- 即使解析器后续可能遇到错误,帮助请求也能优先响应。 -- 帮助输出的行为不依赖 fallback 机制,路径清晰可控。 - -### 检测算法 - -检测算法需要考虑风格的差异: - -1. 提取原始命令行参数 `args`。 -2. 根据 `CommandLineStyle` 确定当前风格支持的帮助写法集合。 -3. 对每个参数检查是否匹配帮助写法集合中的任意一项。 -4. 匹配时需要考虑大小写敏感性(`CaseSensitive` 属性)和前缀规则。 - -对于短选项组合(Gnu、Posix),检测需要在组合拆分之后进行。例如 `-hv` 在 GNU 风格下应被拆分为 `-h` 和 `-v`,其中 `-h` 触发帮助。 - -``` -检测算法: -if (!_hasHelpHandler) - return; // 没有注册帮助处理器,不做任何事 - -var helpSpellings = GetHelpSpellingsForStyle(currentStyle); -foreach (var arg in commandLineArgs) -{ - if (MatchHelpSpelling(arg, helpSpellings, currentStyle)) - { - _isHelpRequested = true; - break; - } -} -``` - -### 命令定位 - -当帮助请求被检测到后,系统需要确定用户想知道哪个命令的帮助。判断逻辑如下: - -1. 如果命令行中除了帮助写法之外没有其他参数,或者没有任何命令名,则输出**根帮助**。 -2. 如果命令行中除了帮助写法之外还包含一个命令名称(如 `myapp add --help`),则输出该命令的命令帮助。 -3. 如果命令行中包含多级子命令(如 `myapp remote add --help`),则输出最深层的子命令帮助。 - -命令位置的解析复用在 `CommandRunner` 中已实现的命令前缀匹配逻辑来获取候选命令名。检测到帮助请求后,从参数列表中移除帮助写法对应的参数,用剩余参数执行命令匹配,然后根据匹配结果决定输出哪种帮助。 - -``` -命令定位算法: -// 从原始参数中过滤掉帮助写法 -var remainingArgs = FilterOutHelpSpellings(args, helpSpellings); - -// 使用剩余参数匹配命令 -var matchedCommand = MatchCommand(remainingArgs); - -if (matchedCommand == null) -{ - // 没有任何命令匹配 → 输出根帮助 - ShowRootHelp(); -} -else -{ - // 匹配到命令 → 输出该命令的帮助 - ShowCommandHelp(matchedCommand); -} -``` - -## 根帮助与命令帮助的内容范围 - -### 根帮助的内容 - -**根的适用范围**:当用户在命令行中只输入了帮助写法(如 `myapp --help`),或者输入了帮助写法但没有匹配到任何命令时,输出根帮助。 - -根帮助展示的内容: - -| 内容项 | 说明 | 条件 | -| ------------------ | -------------------------------------------------------------------- | ------------ | -| 程序描述 | 顶层类型的 `Description`(如果 `Command` 特性标记了描述) | 有默认命令时 | -| 用法行 | `usage: <程序名> [options]` 或 `usage: <程序名> [options]` | 总是显示 | -| 默认命令的选项列表 | 如果存在默认命令,展示其所有选项及其 `Description` | 有默认命令时 | -| 已注册的命令列表 | 列出所有已注册的非默认命令及其 `Description` | 有命令时 | - -如果程序没有默认命令,也没有注册任何命令,那么根帮助仅显示程序名和一条提示信息,指示当前没有注册任何命令。 - -### 命令帮助的内容 - -**命令的适用范围**:当用户在命令名称后输入了帮助写法(如 `myapp add --help` 或 `myapp remote add --help`),输出该命令的帮助。 - -命令帮助展示的内容: - -| 内容项 | 说明 | -| ------------ | ------------------------------------------------------------------ | -| 命令名称 | 完整的命令名称(包括所有级别的子命令,如 `remote add`) | -| 命令描述 | `[Command]` 特性上的 `Description` 属性 | -| 用法行 | `usage: <程序名> [options]` | -| 选项列表 | 该命令类型中所有标记了 `[Option]` 的属性,包含短名称、长名称和描述 | -| 位置参数列表 | 该命令类型中所有标记了 `[Value]` 的属性,包含索引和描述 | -| 子命令提示 | 如果有子命令也注册到了相同的命名空间,列出子命令 | - -### 选项列表的格式化 - -选项列表的每条记录包含: - -- 短名称(如果有多个则全部列出):如 `-h`、`-n` -- 长名称(如果有多个则全部列出):如 `--help`、`--name` -- 是否必需:`required` 标记的属性 -- 类型提示:布尔型、数值型、字符串型等 -- 描述:`[Option]` 特性上的 `Description` 属性 - -注意:由于 `OptionAttribute` 支持为一个选项声明多个短名称和多个长名称(如 `[Option(new[] { "n", "N" }, new[] { "name", "file-name" })]`),帮助信息必须完整呈现所有别名,而不是仅取第一个。 - -示例输出: - -``` -选项: - -c, --count (必需) 测试循环的次数 - -n, --test-name 测试名称 - -d, --detail-level 详细级别 - --debug 是否启用调试模式 -``` - -## 源生成器与元数据设计 - -### 现有元数据机制的回顾 - -当前的源生成器(`ModelBuilderGenerator`)为每个命令类型生成一个 `*Builder` 类,其中包含: - -- `CommandNameGroup` 静态字段:携带命令的名称信息(Ordinal 和 PascalCase 两种命名法)。 -- `Metadata` 内部嵌套类:实现了 `ICommandObjectMetadata` 接口,提供 `Build(CommandRunningContext)` 方法。 -- 解析相关的成员方法(`MatchLongOption`、`MatchShortOption` 等)。 - -当前的 `ICommandObjectMetadata` 接口: - -```csharp -public interface ICommandObjectMetadata -{ - object Build(CommandRunningContext context); -} -``` - -本设计将在此接口上新增 `GetHelp()` 方法(详见下文)。 - -### 扩展 ICommandObjectMetadata 以携带帮助信息 - -为了支持帮助输出,在 `ICommandObjectMetadata` 接口上直接新增一个 `GetHelp()` 方法。所有由源生成器生成的 `Metadata` 类型天然具备帮助信息,因此不需要引入额外的接口——帮助元数据是 `Metadata` 的固有能力,而非一个可选的附加契约。 - -对于框架内部提供的少量特殊 `ICommandObjectMetadata` 实现(如委托包装器),其 `GetHelp()` 应委托给内部持有的原始 metadata,或在无法提供时返回 `null`。 - -扩展后的接口定义: - -```csharp -public interface ICommandObjectMetadata -{ - object Build(CommandRunningContext context); - - /// - /// 获取此命令的帮助元数据。如果无法提供帮助信息,则返回 。 - /// - CommandHelpMetadata? GetHelp(); -} -``` - -`CommandHelpMetadata` 是一个纯数据类型,所有属性均为 `required init`,由源生成器在 `GetHelp()` 方法体中通过 `new CommandHelpMetadata { ... }` 一次性构造。这保证了 `Metadata` 类型本身不持有任何字段(符合现有的无字段约束),同时调用方拿到的是一个完整的、不可变的数据对象: - -```csharp -/// -/// 命令的帮助元数据。 -/// -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 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? Description { get; init; } - public required bool IsRequired { get; init; } -} -``` - -生成的 `Metadata` 类型示例: - -```csharp -// 生成后的 Metadata 类型 -public sealed class Metadata : global::DotNetCampus.Cli.Compiler.ICommandObjectMetadata -{ - public object Build(global::DotNetCampus.Cli.Compiler.CommandRunningContext context) - { - return new global::MyNamespace.MyCommandBuilder().Build(context); - } - - public global::DotNetCampus.Cli.Compiler.CommandHelpMetadata? GetHelp() - { - return new global::DotNetCampus.Cli.Compiler.CommandHelpMetadata - { - CommandName = "add", - Description = "添加一个新项目", - Options = [new() { ShortNames = ["n"], LongNames = ["name"], Description = "项目名称", IsRequired = true, ValueType = global::DotNetCampus.Cli.Compiler.OptionValueType.Normal }], - PositionalArguments = [new() { Index = 0, Count = 1, Description = "要添加的文件路径", IsRequired = true }], - }; - } -} -``` - -### 源生成器的修改 - -在 `ModelBuilderGenerator` 中,生成 `Metadata` 类型时需要额外执行以下操作: - -1. 读取 `[Command]` 特性上的 `Description` 值:从 `CommandAttribute.Description` 获取命令描述。 -2. 读取每个 `[Option]` 特性上的 `Description` 值:从 `OptionAttribute.Description` 获取选项描述。 -3. 读取每个 `[Value]` 特性上的 `Description` 值:从 `ValueAttribute.Description` 获取位置参数描述。 -4. 根据命令名称的 kebab-case 形式和 PascalCase 形式,确定 `CommandName` 的返回值(取 Ordinal 形式)。 -5. 判断选项是否为 `required`,以及值的类型。 -6. 收集每个 `[Option]` 的完整短名称列表和长名称列表(而非仅取第一个)。 - -对于命令名称、属性类型、`required` 等信息,现有的 `CommandObjectGeneratingModel` 和 `PropertyGeneratingModel` 已经提供了基础元数据;但 `Description` 目前尚未进入生成模型,因此需要先扩展这些模型,再在生成代码时将其烘焙到 `GetHelp()` 的返回值中。 - -关键修改点: - -``` -ModelBuilderGenerator.Execute 中 - └── GenerateCommandObjectCreatorCode 中 - └── 在生成 Metadata 类型时 - └── 生成 GetHelp() 方法,返回 new CommandHelpMetadata - ├── CommandName(从 CommandNames 获取) - ├── Description(从模型中的 Description 获取) - ├── Options(遍历 OptionProperties,收集所有 ShortNames 和 LongNames) - └── PositionalArguments(遍历 PositionalArgumentProperties) -``` - -### 运行时注册 - -`AddHelpHandler()` 不需要通过拦截器实现——它不涉及泛型类型参数的编译期解析,可以作为普通扩展方法直接在运行时工作。调用 `AddHelpHandler()` 只是在 `CommandRunner` 上设置一个标记,表示帮助功能已启用。 - -`CommandRunner` 内部新增一个字段来持有帮助状态: - -```csharp -// CommandRunner 新增的字段 -private bool _helpEnabled; -``` - -帮助元数据的收集发生在 `RunAsync()` 内部:当帮助功能已启用且检测到帮助请求后,运行器遍历所有已注册的 `ICommandObjectMetadata`,调用其 `GetHelp()` 方法收集非 `null` 的帮助元数据用于输出。这意味着无论 `AddHelpHandler()` 出现在调用链的什么位置——在所有 `AddHandler` 之前、之间或之后——行为都是一致的。 - -## 运行时执行流程 - -完整的帮助响应流程如下: - -``` -用户输入: myapp add --help - -1. CommandLine.Parse(args) → 创建 CommandLine 实例 -2. .AddHandler(...) → 注册 AddOptions 的命令元数据 -3. .AddHelpHandler() → 设置帮助启用标记 -4. .Run() / .RunAsync() - │ - ├── 4.1 CommandRunner.RunAsync() - │ │ - │ ├── 4.2 帮助检测阶段 - │ │ ├── 检查帮助是否已启用 → 是 - │ │ ├── 遍历原始参数,匹配帮助写法 - │ │ ├── 匹配到 "--help" - │ │ ├── 收集所有已注册 metadata 中的 IHelpProvider - │ │ ├── 过滤掉 "--help",剩余参数为 ["add"] - │ │ └── 调用命令匹配,匹配到 "add" 命令 - │ │ - │ ├── 4.3 帮助输出阶段 - │ │ ├── 找到 "add" 命令对应的 IHelpProvider - │ │ ├── 查询该命令的 Options、PositionalArguments 等 - │ │ ├── 格式化帮助文本 - │ │ ├── 输出到 Console.Out - │ │ └── 返回退出码 0 - │ │ - │ └── 4.4 (未匹配帮助时的正常路径) - │ └── 执行现有的 MatchCommandObject → Build → Run 流程 - │ - └── 4.5 返回 CommandRunningResult -``` - -对于没有命令名的情况(根帮助): - -``` -用户输入: myapp --help - -4.2 帮助检测 → 匹配 "--help" -4.3 过滤后剩余参数为空 - → 命令匹配返回 null(或返回默认命令的 metadata) - → 如果存在默认命令,输出含有默认命令选项的根帮助 - → 如果不存在默认命令,输出仅含命令列表的根帮助 -``` - -## 输出格式建议 - -帮助文本的格式设计遵循以下原则: - -1. 保持简洁,不产生过多的控制台行数。 -2. 信息的组织结构清晰可扫描。 -3. 优先采用常见的 CLI 帮助格式(类 `git --help` 风格),降低用户的学习成本。 - -### 根帮助输出格式 - -``` -<程序名> <版本信息(如有)> - - - -用法: <程序名> [选项] [命令] -用法: <程序名> <命令> [选项] - -选项: - -h, --help 显示帮助信息 - -命令: - add 添加一个新项目 - remove 删除一个项目 - remote add 添加一个远程仓库 -``` - -如果没有默认命令,选项部分只显示 `-h, --help`。如果也没有注册任何命令,则显示一条提示信息。 - -### 命令帮助输出格式 - -``` -<命令名> - <命令描述> - -用法: <程序名> <命令> [选项] [参数] - -选项: - -n, --name (必需) 项目名称 - -p, --path 项目路径 - -f, --format 输出格式 - -v, --verbose 显示详细信息 - -位置参数: - 0 输入文件路径 (必需) - 1.. 输出文件路径 (可选) -``` - -### 格式化策略 - -- 短名称和长名称在同一行展示,用逗号分隔。 -- 选项名右对齐到固定的列宽,描述从固定的列宽开始。 -- 类型提示放在尖括号中,如 ``、``、``。 -- 如果选项没有短名称,对应的位置留空或对齐调整。 -- 当描述文本超长时,应换行并在下一行对齐到描述起始列。 -- 输出使用 `Console.Out`,而不是 `Console.Error`,因为帮助信息是正常的程序输出。 - -这些格式仅作为参考,具体实现允许调整间距、对齐方式和装饰字符。 - -## 兼容性、性能与红线 - -### 兼容性 - -- `AddHelpHandler()` 是完全新增的 API,不与现有的任何 API 冲突。 -- `GetHelp()` 方法直接新增到 `ICommandObjectMetadata` 接口上。由于当前含有此接口的版本尚在预览阶段,不存在兼容负担。 -- 新增的数据类型(`CommandHelpMetadata`、`OptionHelpInfo`、`ValueHelpInfo`)位于 `DotNetCampus.Cli.Compiler` 命名空间,与 `ICommandObjectMetadata` 同级。 -- 即使用户没有调用 `AddHelpHandler()`,`GetHelp()` 方法也只是不会被调用,不会改变正常的命令执行路径。 -- `AddStandardHandlers()` 已被移除,不存在新旧 API 冲突问题。 - -### 性能 - -- 帮助检测仅在注册了 `AddHelpHandler()` 时才会执行额外的扫描。未注册时路径与现有代码一致,无性能损失。 -- 帮助检测的扫描是 O(n) 的,其中 n 是参数个数,通常很小(个位数到十位数)。 -- `GetHelp()` 由源生成器生成,不涉及反射。其内部构造的集合是编译期确定的字面量,分配很小;且仅在检测到帮助请求后才会被调用。 -- 帮助文本的格式化仅在检测到帮助请求时发生,不影响正常执行路径的性能。 - -### 红线 - -- **无反射**:所有帮助元数据通过源生成器在编译期确定,运行时不使用 `Type.GetProperties()`、`Attribute.GetCustomAttributes()` 等反射 API。 -- **AOT 安全**:源生成器生成所有需要的代码,不存在动态代码生成或 JIT 依赖。`GetHelp()` 的实现是具体的已编译方法,适合 NativeAOT 部署。 -- **无隐式输出**:`CommandRunner` 不会自动输出帮助信息。只有在用户显式调用 `AddHelpHandler()` 后,框架才有权限向 `Console.Out` 写入内容。 -- **无 fallback 依赖**:帮助检测是独立的先行步骤,不依赖 `_fallback` 机制或异常处理的回退。 -- **无 `-help` 支持**:无论何种风格,`-help` 都不是内置帮助的合法写法,不提供例外处理。 -- **Metadata 无字段约束**:`GetHelp()` 方法体中直接 `new CommandHelpMetadata { ... }` 返回,不在 `Metadata` 上引入任何实例字段。 - -### 生成策略 - -为了确保设计简单、稳定,并与现有的按类型生成 `Metadata` 的架构保持一致,推荐采用”总是生成,按需使用”的策略: - -- 源生成器始终为所有 `Metadata` 生成 `GetHelp()` 方法实现。 -- 运行时仅当用户调用了 `AddHelpHandler()` 时,`CommandRunner` 才会调用 `GetHelp()` 读取帮助元数据。 -- 如果用户没有调用 `AddHelpHandler()`,`GetHelp()` 不会被调用,不会改变任何已有行为。 - -这样做的好处是: - -1. 不需要让源生成器跨调用点分析链式调用中是否最终出现了 `AddHelpHandler()`。 -2. 不需要为了减少少量未使用代码而显著提升生成器复杂度。 -3. 与现有的“每个命令类型生成一个 `Metadata` 类型”的模式最一致。 - -## 结论 - -本文档提出了 DotNetCampus.CommandLine 内置帮助支持的设计方案,核心要点如下: - -1. **用户选择加入**:通过 `AddHelpHandler()` 显式启用,不产生隐式输出。 -2. **风格感知**:根据当前 `CommandLineStyle` 自动识别对应的帮助写法(`--help`、`-h`、`/?` 等),不同风格有不同的匹配规则。 -3. **拒绝 `-help`**:该写法与短选项组合及多字符短选项规则冲突,不被支持。 -4. **无 help 子命令**:子命令形式的帮助留待独立设计。 -5. **先行检测**:帮助检测在 `CommandRunner.RunAsync()` 的开头执行,不影响正常命令匹配流程。 -6. **源生成器驱动**:利用现有 `ModelBuilderGenerator` 的管道,在 `Metadata` 类型上生成 `GetHelp()` 方法,携带描述信息。 -7. **Description 作为唯一源**:使用 `CommandLineAttribute.Description` 属性,不涉及 XML 文档注释。 -8. **无反射、AOT 安全**:所有帮助元数据在编译期确定。 -9. **本地化与 XML 文档注释**:明确排除在本文档范围之外,留待后续设计。 - -该方案遵循 DotNetCampus.CommandLine 现有的架构风格:拦截器拦截调用、源生成器生成代码、零反射零 AOT 开销、风格矩阵驱动的解析行为。帮助支持以一种可选、可预测的方式融入到现有框架中。 From 569d1013a86575dd12d420379ec04911ce94a76c Mon Sep 17 00:00:00 2001 From: walterlv Date: Sun, 24 May 2026 20:48:16 +0800 Subject: [PATCH 14/17] =?UTF-8?q?=E6=96=87=E6=A1=A3=E5=8A=A0=E4=B8=8A?= =?UTF-8?q?=E5=B8=AE=E5=8A=A9=E9=83=A8=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/README.md | 115 +++++++++++++++++++++++++++++++++++++++++ docs/zh-hans/README.md | 115 +++++++++++++++++++++++++++++++++++++++++ docs/zh-hant/README.md | 115 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 345 insertions(+) diff --git a/docs/en/README.md b/docs/en/README.md index 70c6408..75326ff 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 ac34784..dc4df12 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 beda4a2..b9e17cd 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 協議字串: From d93a97ef33b5e9f5ca6c6485c407760e8959cdd8 Mon Sep 17 00:00:00 2001 From: walterlv Date: Sun, 24 May 2026 21:11:14 +0800 Subject: [PATCH 15/17] =?UTF-8?q?=E5=8A=A0=E5=85=A5=20help=20=E5=8D=95?= =?UTF-8?q?=E5=85=83=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Help/HelpDetector.cs | 20 +- .../Help/HelpDetectorTests.cs | 105 +++++++ .../Help/HelpOutputTests.cs | 256 ++++++++++++++++++ 3 files changed, 363 insertions(+), 18 deletions(-) create mode 100644 tests/DotNetCampus.CommandLine.Tests/Help/HelpDetectorTests.cs create mode 100644 tests/DotNetCampus.CommandLine.Tests/Help/HelpOutputTests.cs diff --git a/src/DotNetCampus.CommandLine/Help/HelpDetector.cs b/src/DotNetCampus.CommandLine/Help/HelpDetector.cs index cf78652..c33ee7a 100644 --- a/src/DotNetCampus.CommandLine/Help/HelpDetector.cs +++ b/src/DotNetCampus.CommandLine/Help/HelpDetector.cs @@ -17,7 +17,7 @@ public static bool IsHelpRequested(IReadOnlyList args, CommandLineStyle foreach (var arg in args) { - if (IsHelpArg(arg, style)) + if (IsHelpOption(arg, style)) { return true; } @@ -25,23 +25,7 @@ public static bool IsHelpRequested(IReadOnlyList args, CommandLineStyle return false; } - /// - /// 从参数列表中过滤掉帮助写法,返回剩余参数。 - /// - public static List FilterOutHelpArgs(IReadOnlyList args, CommandLineStyle style) - { - var result = new List(args.Count); - foreach (var arg in args) - { - if (!IsHelpArg(arg, style)) - { - result.Add(arg); - } - } - return result; - } - - private static bool IsHelpArg(string arg, CommandLineStyle style) + private static bool IsHelpOption(string arg, CommandLineStyle style) { var comparison = style.CaseSensitive ? StringComparison.Ordinal diff --git a/tests/DotNetCampus.CommandLine.Tests/Help/HelpDetectorTests.cs b/tests/DotNetCampus.CommandLine.Tests/Help/HelpDetectorTests.cs new file mode 100644 index 0000000..2995372 --- /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 0000000..35bacc8 --- /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 arg 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 +} From 7172916fb39354afe3caac6663baff2a21163887 Mon Sep 17 00:00:00 2001 From: walterlv Date: Sun, 24 May 2026 21:26:11 +0800 Subject: [PATCH 16/17] =?UTF-8?q?=E4=B8=8D=E7=94=A8=E7=BC=A9=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/DotNetCampus.CommandLine.Tests/Help/HelpOutputTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/DotNetCampus.CommandLine.Tests/Help/HelpOutputTests.cs b/tests/DotNetCampus.CommandLine.Tests/Help/HelpOutputTests.cs index 35bacc8..f35b32a 100644 --- a/tests/DotNetCampus.CommandLine.Tests/Help/HelpOutputTests.cs +++ b/tests/DotNetCampus.CommandLine.Tests/Help/HelpOutputTests.cs @@ -119,7 +119,7 @@ public void HelpOutput_ContainsPositionalArgumentName(string[] args, TestCommand // Assert Assert.IsNotNull(helpText); - Assert.IsTrue(helpText.Contains("input_file"), $"Help text should contain positional arg name 'input_file'. Actual: {helpText}"); + Assert.IsTrue(helpText.Contains("input_file"), $"Help text should contain positional argument name 'input_file'. Actual: {helpText}"); } [TestMethod] From e3b158381b61fbf393563cca73d3d89788d0a5e3 Mon Sep 17 00:00:00 2001 From: walterlv Date: Sun, 24 May 2026 21:32:32 +0800 Subject: [PATCH 17/17] =?UTF-8?q?=E5=8E=BB=E9=99=A4=E7=BC=A9=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Help/HelpDetector.cs | 22 +++++----- .../Help/HelpHandler.cs | 44 +++++++++---------- 2 files changed, 33 insertions(+), 33 deletions(-) diff --git a/src/DotNetCampus.CommandLine/Help/HelpDetector.cs b/src/DotNetCampus.CommandLine/Help/HelpDetector.cs index c33ee7a..8573b2f 100644 --- a/src/DotNetCampus.CommandLine/Help/HelpDetector.cs +++ b/src/DotNetCampus.CommandLine/Help/HelpDetector.cs @@ -15,9 +15,9 @@ public static bool IsHelpRequested(IReadOnlyList args, CommandLineStyle return false; } - foreach (var arg in args) + foreach (var argument in args) { - if (IsHelpOption(arg, style)) + if (IsHelpOption(argument, style)) { return true; } @@ -25,7 +25,7 @@ public static bool IsHelpRequested(IReadOnlyList args, CommandLineStyle return false; } - private static bool IsHelpOption(string arg, CommandLineStyle style) + private static bool IsHelpOption(string argument, CommandLineStyle style) { var comparison = style.CaseSensitive ? StringComparison.Ordinal @@ -36,7 +36,7 @@ private static bool IsHelpOption(string arg, CommandLineStyle style) // --help (DotNet, Gnu, Flexible) if (prefix is CommandOptionPrefix.DoubleDash or CommandOptionPrefix.Any) { - if (style.SupportsLongOption && arg.Equals("--help", comparison)) + if (style.SupportsLongOption && argument.Equals("--help", comparison)) { return true; } @@ -47,15 +47,15 @@ private static bool IsHelpOption(string arg, CommandLineStyle style) { if (style.SupportsShortOption) { - if (arg.Equals("-h", comparison)) + if (argument.Equals("-h", comparison)) { return true; } // 短选项组合 (Gnu, Posix): -hxxx contains -h - if (style.SupportsShortOptionCombination && arg.Length > 2 && arg[0] == '-' && arg[1] != '-') + if (style.SupportsShortOptionCombination && argument.Length > 2 && argument[0] == '-' && argument[1] != '-') { - var chars = arg.AsSpan(1); + var chars = argument.AsSpan(1); foreach (var c in chars) { if (c == 'h' || (!style.CaseSensitive && (c == 'H'))) @@ -70,15 +70,15 @@ private static bool IsHelpOption(string arg, CommandLineStyle style) // /help, /h (Flexible, Windows) if (prefix is CommandOptionPrefix.Slash or CommandOptionPrefix.SlashOrDash or CommandOptionPrefix.Any) { - if (arg.Equals("/help", comparison)) + if (argument.Equals("/help", comparison)) { return true; } - if (arg.Equals("/h", comparison)) + if (argument.Equals("/h", comparison)) { return true; } - if (arg.Equals("/?", StringComparison.Ordinal)) + if (argument.Equals("/?", StringComparison.Ordinal)) { return true; } @@ -87,7 +87,7 @@ private static bool IsHelpOption(string arg, CommandLineStyle style) // -? (Flexible, Windows) if (prefix is CommandOptionPrefix.SlashOrDash or CommandOptionPrefix.Any) { - if (arg.Equals("-?", StringComparison.Ordinal)) + if (argument.Equals("-?", StringComparison.Ordinal)) { return true; } diff --git a/src/DotNetCampus.CommandLine/Help/HelpHandler.cs b/src/DotNetCampus.CommandLine/Help/HelpHandler.cs index c1f5a6e..5192ebc 100644 --- a/src/DotNetCampus.CommandLine/Help/HelpHandler.cs +++ b/src/DotNetCampus.CommandLine/Help/HelpHandler.cs @@ -235,24 +235,24 @@ protected virtual bool BuildCommands(StringBuilder builder, IReadOnlyList columnWidth) + var nameLength = metadata.CommandName!.Length; + if (nameLength <= maxColumnWidth && nameLength > columnWidth) { - columnWidth = len; + columnWidth = nameLength; } } - foreach (var subCommandMetadata in subCommandMetadataList) + foreach (var metadata in subCommandMetadataList) { - var name = subCommandMetadata.CommandName!; + var name = metadata.CommandName!; var prefix = $" {name}"; if (name.Length > maxColumnWidth) { builder.AppendLine(prefix); - if (subCommandMetadata.Description is { } description) + if (metadata.Description is { } description) { builder.Append(new string(' ', columnWidth + 4)); builder.AppendLine(ResolveLocalization(description)); @@ -261,7 +261,7 @@ protected virtual bool BuildCommands(StringBuilder builder, IReadOnlyList columnWidth) + var nameLength = positionalArgument.Name.Length + 2; // 2 = [ + ] + if (nameLength <= maxColumnWidth && nameLength > columnWidth) { - columnWidth = len; + columnWidth = nameLength; } } @@ -398,31 +398,31 @@ protected virtual string ResolveLocalization(string rawText) private string FormatOptionName(OptionHelpInfo option) { - var sb = new StringBuilder(); + var builder = new StringBuilder(); var first = true; foreach (var shortName in option.ShortNames) { - if (!first) sb.Append('|'); - sb.Append('-'); - sb.Append(shortName); + if (!first) builder.Append('|'); + builder.Append('-'); + builder.Append(shortName); first = false; } foreach (var longName in option.LongNames) { - if (!first) sb.Append('|'); - sb.Append("--"); - sb.Append(longName); + if (!first) builder.Append('|'); + builder.Append("--"); + builder.Append(longName); first = false; } var valuePlaceholder = GetValuePlaceholder(option); if (valuePlaceholder is not null) { - sb.Append(' '); - sb.Append(valuePlaceholder); + builder.Append(' '); + builder.Append(valuePlaceholder); } - return sb.ToString(); + return builder.ToString(); } private static string? GetValuePlaceholder(OptionHelpInfo option)