diff --git a/src/System.CommandLine.ApiCompatibility.Tests/ApiCompatibilityApprovalTests.System_CommandLine_api_is_not_changed.approved.txt b/src/System.CommandLine.ApiCompatibility.Tests/ApiCompatibilityApprovalTests.System_CommandLine_api_is_not_changed.approved.txt index e16bab36bb..6bd02eb28d 100644 --- a/src/System.CommandLine.ApiCompatibility.Tests/ApiCompatibilityApprovalTests.System_CommandLine_api_is_not_changed.approved.txt +++ b/src/System.CommandLine.ApiCompatibility.Tests/ApiCompatibilityApprovalTests.System_CommandLine_api_is_not_changed.approved.txt @@ -100,6 +100,7 @@ public System.Collections.Generic.List> Validators { get; } public System.Type ValueType { get; } public System.Collections.Generic.IEnumerable GetCompletions(System.CommandLine.Completions.CompletionContext context) + public System.Object GetDefaultValue() public class Option : Option .ctor(System.String name, System.String[] aliases) public Func CustomParser { get; set; } @@ -128,6 +129,7 @@ public System.CommandLine.Parsing.OptionResult GetResult(Option option) public System.CommandLine.Parsing.DirectiveResult GetResult(Directive directive) public System.CommandLine.Parsing.SymbolResult GetResult(Symbol symbol) + public System.CommandLine.Parsing.SymbolResult GetResult(System.String name) public T GetValue(Argument argument) public T GetValue(Option option) public T GetValue(System.String name) @@ -178,48 +180,11 @@ System.CommandLine.Completions System.CommandLine.Help public class HelpAction : System.CommandLine.Invocation.SynchronousCommandLineAction .ctor() - public HelpBuilder Builder { get; set; } public System.Int32 Invoke(System.CommandLine.ParseResult parseResult) - public class HelpBuilder - .ctor(System.Int32 maxWidth = 2147483647) - public System.Int32 MaxWidth { get; } - public System.Void CustomizeLayout(System.Func>> getLayout) - public System.Void CustomizeSymbol(System.CommandLine.Symbol symbol, System.Func firstColumnText = null, System.Func secondColumnText = null, System.Func defaultValue = null) - public System.Void CustomizeSymbol(System.CommandLine.Symbol symbol, System.String firstColumnText = null, System.String secondColumnText = null, System.String defaultValue = null) - public TwoColumnHelpRow GetTwoColumnRow(System.CommandLine.Symbol symbol, HelpContext context) - public System.Void Write(HelpContext context) - public System.Void Write(System.CommandLine.Command command, System.IO.TextWriter writer) - public System.Void WriteColumns(System.Collections.Generic.IReadOnlyList items, HelpContext context) - static class Default - public static System.Func AdditionalArgumentsSection() - public static System.Func CommandArgumentsSection() - public static System.Func CommandUsageSection() - public static System.String GetArgumentDefaultValue(System.CommandLine.Argument argument) - public static System.String GetArgumentDescription(System.CommandLine.Argument argument) - public static System.String GetArgumentUsageLabel(System.CommandLine.Argument argument) - public static System.String GetCommandUsageLabel(System.CommandLine.Command symbol) - public static System.Collections.Generic.IEnumerable> GetLayout() - public static System.String GetOptionUsageLabel(System.CommandLine.Option symbol) - public static System.Func OptionsSection() - public static System.Func SubcommandsSection() - public static System.Func SynopsisSection() - public class HelpContext - .ctor(HelpBuilder helpBuilder, System.CommandLine.Command command, System.IO.TextWriter output, System.CommandLine.ParseResult parseResult = null) - public System.CommandLine.Command Command { get; } - public HelpBuilder HelpBuilder { get; } - public System.IO.TextWriter Output { get; } - public System.CommandLine.ParseResult ParseResult { get; } public class HelpOption : System.CommandLine.Option .ctor() .ctor(System.String name, System.String[] aliases) public System.CommandLine.Invocation.CommandLineAction Action { get; set; } - public class TwoColumnHelpRow, System.IEquatable - .ctor(System.String firstColumnText, System.String secondColumnText) - public System.String FirstColumnText { get; } - public System.String SecondColumnText { get; } - public System.Boolean Equals(System.Object obj) - public System.Boolean Equals(TwoColumnHelpRow other) - public System.Int32 GetHashCode() System.CommandLine.Invocation public abstract class AsynchronousCommandLineAction : CommandLineAction public System.Threading.Tasks.Task InvokeAsync(System.CommandLine.ParseResult parseResult, System.Threading.CancellationToken cancellationToken = null) diff --git a/src/System.CommandLine.Tests/GetValueByNameParserTests.cs b/src/System.CommandLine.Tests/GetValueByNameParserTests.cs index 9d5c1465b7..7a2a8721e7 100644 --- a/src/System.CommandLine.Tests/GetValueByNameParserTests.cs +++ b/src/System.CommandLine.Tests/GetValueByNameParserTests.cs @@ -194,6 +194,48 @@ public void When_an_option_and_argument_use_same_name_on_the_same_level_of_the_t .Where(ex => ex.Message == $"Command {command.Name} has more than one child named \"{sameName}\"."); } + [Fact] + public void When_options_use_same_name_on_different_levels_of_the_tree_no_exception_is_thrown() + { + const string sameName = "same"; + + RootCommand command = new() + { + new Command("left") + { + new Option(sameName) + }, + new Command("right") + { + new Option(sameName) + }, + }; + + command.Parse($"left {sameName} 1").GetValue(sameName).Should().Be(1); + command.Parse($"right {sameName} 2").GetValue(sameName).Should().Be(2); + } + + [Fact] + public void When_the_same_option_used_in_different_levels_of_the_tree_no_exception_is_thrown() + { + Option multipleParents = new("--int"); + + RootCommand command = new() + { + new Command("left") + { + multipleParents + }, + new Command("right") + { + multipleParents + }, + }; + + command.Parse($"left {multipleParents.Name} 1").GetValue(multipleParents.Name).Should().Be(1); + command.Parse($"right {multipleParents.Name} 2").GetValue(multipleParents.Name).Should().Be(2); + } + [Fact] public void When_an_option_and_argument_use_same_name_on_different_levels_of_the_tree_the_value_which_belongs_to_parsed_command_is_returned() { diff --git a/src/System.CommandLine.Tests/Help/CustomHelpAction.cs b/src/System.CommandLine.Tests/Help/CustomHelpAction.cs new file mode 100644 index 0000000000..cac66dc599 --- /dev/null +++ b/src/System.CommandLine.Tests/Help/CustomHelpAction.cs @@ -0,0 +1,35 @@ +using System.CommandLine.Invocation; + +namespace System.CommandLine.Help +{ + /// + /// Provides command line help. + /// + public sealed class CustomHelpAction : SynchronousCommandLineAction + { + private HelpBuilder? _builder; + + /// + /// Specifies an to be used to format help output when help is requested. + /// + internal HelpBuilder Builder + { + get => _builder ??= new HelpBuilder(Console.IsOutputRedirected ? int.MaxValue : Console.WindowWidth); + set => _builder = value ?? throw new ArgumentNullException(nameof(value)); + } + + /// + public override int Invoke(ParseResult parseResult) + { + var output = parseResult.Configuration.Output; + + var helpContext = new HelpContext(Builder, + parseResult.CommandResult.Command, + output); + + Builder.Write(helpContext); + + return 0; + } + } +} \ No newline at end of file diff --git a/src/System.CommandLine.Tests/Help/HelpBuilderExtensions.cs b/src/System.CommandLine.Tests/Help/HelpBuilderExtensions.cs index 202fb93453..6fbc826bd0 100644 --- a/src/System.CommandLine.Tests/Help/HelpBuilderExtensions.cs +++ b/src/System.CommandLine.Tests/Help/HelpBuilderExtensions.cs @@ -8,7 +8,7 @@ namespace System.CommandLine.Tests.Help { public static class HelpBuilderExtensions { - public static void Write( + internal static void Write( this HelpBuilder builder, Command command, TextWriter writer) => diff --git a/src/System.CommandLine.Tests/Help/HelpBuilderTests.Customization.cs b/src/System.CommandLine.Tests/Help/HelpBuilderTests.Customization.cs index 46b084551a..b2d9335ed3 100644 --- a/src/System.CommandLine.Tests/Help/HelpBuilderTests.Customization.cs +++ b/src/System.CommandLine.Tests/Help/HelpBuilderTests.Customization.cs @@ -94,7 +94,7 @@ public void Option_can_customize_first_column_text_based_on_parse_result() : optionBFirstColumnText); command.Options.Add(new HelpOption() { - Action = new HelpAction() + Action = new CustomHelpAction() { Builder = helpBuilder } @@ -140,7 +140,7 @@ public void Option_can_customize_second_column_text_based_on_parse_result() : optionBDescription); command.Options.Add(new HelpOption { - Action = new HelpAction + Action = new CustomHelpAction { Builder = helpBuilder } @@ -269,7 +269,7 @@ public void Option_can_fallback_to_default_when_customizing(bool conditionA, boo command.Options.Add(new HelpOption { - Action = new HelpAction + Action = new CustomHelpAction { Builder = helpBuilder } @@ -317,7 +317,7 @@ public void Argument_can_fallback_to_default_when_customizing( command.Options.Add(new HelpOption { - Action = new HelpAction + Action = new CustomHelpAction { Builder = helpBuilder } @@ -336,11 +336,20 @@ public void Individual_symbols_can_be_customized() var option = new Option("-x") { Description = "The default option description" }; var argument = new Argument("int-value") { Description = "The default argument description" }; - var rootCommand = new RootCommand + CustomHelpAction helpAction = new(); + helpAction.Builder.CustomizeSymbol(subcommand, secondColumnText: "The custom command description"); + helpAction.Builder.CustomizeSymbol(option, secondColumnText: "The custom option description"); + helpAction.Builder.CustomizeSymbol(argument, secondColumnText: "The custom argument description"); + + var rootCommand = new Command("name") { subcommand, option, argument, + new HelpOption() + { + Action = helpAction + } }; CommandLineConfiguration config = new(rootCommand) @@ -350,13 +359,6 @@ public void Individual_symbols_can_be_customized() ParseResult parseResult = rootCommand.Parse("-h", config); - if (parseResult.Action is HelpAction helpAction) - { - helpAction.Builder.CustomizeSymbol(subcommand, secondColumnText: "The custom command description"); - helpAction.Builder.CustomizeSymbol(option, secondColumnText: "The custom option description"); - helpAction.Builder.CustomizeSymbol(argument, secondColumnText: "The custom argument description"); - } - parseResult.Invoke(); config.Output @@ -370,18 +372,16 @@ public void Individual_symbols_can_be_customized() [Fact] public void Help_sections_can_be_replaced() { - CommandLineConfiguration config = new(new RootCommand()) + CustomHelpAction helpAction = new(); + helpAction.Builder.CustomizeLayout(CustomLayout); + + CommandLineConfiguration config = new(new Command("name") { new HelpOption() { Action = helpAction} }) { Output = new StringWriter() }; ParseResult parseResult = config.Parse("-h"); - if (parseResult.Action is HelpAction helpAction) - { - helpAction.Builder.CustomizeLayout(CustomLayout); - } - parseResult.Invoke(); config.Output.ToString().Should().Be($"one{NewLine}{NewLine}two{NewLine}{NewLine}three{NewLine}{NewLine}"); @@ -397,20 +397,18 @@ IEnumerable> CustomLayout(HelpContext _) [Fact] public void Help_sections_can_be_supplemented() { - CommandLineConfiguration config = new(new RootCommand("hello")) + CustomHelpAction helpAction = new(); + helpAction.Builder.CustomizeLayout(CustomLayout); + + CommandLineConfiguration config = new(new Command("hello") { new HelpOption() { Action = helpAction } }) { Output = new StringWriter(), }; - var defaultHelp = GetDefaultHelp(config.RootCommand); + var defaultHelp = GetDefaultHelp(new Command("hello")); ParseResult parseResult = config.Parse("-h"); - if (parseResult.Action is HelpAction helpAction) - { - helpAction.Builder.CustomizeLayout(CustomLayout); - } - parseResult.Invoke(); var output = config.Output.ToString(); @@ -444,7 +442,7 @@ public void Layout_can_be_composed_dynamically_based_on_context() commandWithCustomHelp }; - command.Options.OfType().Single().Action = new HelpAction + command.Options.OfType().Single().Action = new CustomHelpAction { Builder = helpBuilder }; @@ -480,7 +478,7 @@ public void Help_default_sections_can_be_wrapped() }, new HelpOption { - Action = new HelpAction + Action = new CustomHelpAction { Builder = new HelpBuilder(30) } @@ -509,19 +507,17 @@ public void Help_default_sections_can_be_wrapped() [Fact] public void Help_customized_sections_can_be_wrapped() { - CommandLineConfiguration config = new(new RootCommand()) + CustomHelpAction helpAction = new(); + helpAction.Builder = new HelpBuilder(10); + helpAction.Builder.CustomizeLayout(CustomLayout); + + CommandLineConfiguration config = new(new Command("name") { new HelpOption() { Action = helpAction } }) { Output = new StringWriter() }; ParseResult parseResult = config.Parse("-h"); - if (parseResult.Action is HelpAction helpAction) - { - helpAction.Builder = new HelpBuilder(10); - helpAction.Builder.CustomizeLayout(CustomLayout); - } - parseResult.Invoke(); string result = config.Output.ToString(); diff --git a/src/System.CommandLine.Tests/HelpOptionTests.cs b/src/System.CommandLine.Tests/HelpOptionTests.cs index 531a23d1fa..401656ce0c 100644 --- a/src/System.CommandLine.Tests/HelpOptionTests.cs +++ b/src/System.CommandLine.Tests/HelpOptionTests.cs @@ -3,6 +3,7 @@ using FluentAssertions; using System.CommandLine.Help; +using System.CommandLine.Invocation; using System.CommandLine.Tests.Utility; using System.IO; using System.Threading.Tasks; @@ -186,4 +187,95 @@ public async Task Help_option_with_custom_aliases_does_not_recognize_default_ali config.Output.ToString().Should().NotContain(helpAlias); } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void The_users_can_provide_usage_examples(bool subcommand) + { + HelpOption helpOption = new(); + helpOption.Action = new CustomizedHelpAction(helpOption); + + RootCommand rootCommand = new(); + rootCommand.Options.Clear(); + rootCommand.Options.Add(helpOption); + rootCommand.Subcommands.Add(new Command("subcommand") + { + new Option("-x") + { + Description = "An example option." + } + }); + + TextWriter output = new StringWriter(); + CommandLineConfiguration config = new(rootCommand) + { + Output = output + }; + + var result = subcommand ? config.Parse("subcommand -h") : config.Parse("-h"); + + result.Invoke(); + + if (subcommand) + { + output.ToString().Should().Contain(CustomizedHelpAction.CustomUsageText); + } + else + { + output.ToString().Should().NotContain(CustomizedHelpAction.CustomUsageText); + } + } + + [Fact] + public void The_users_can_print_help_output_of_a_subcommand() + { + const string RootDescription = "This is a custom root description."; + const string SubcommandDescription = "This is a custom subcommand description."; + RootCommand rootCommand = new(RootDescription); + Command subcommand = new("subcommand", SubcommandDescription) + { + new Option("-x") + { + Description = "An example option." + } + }; + rootCommand.Subcommands.Add(subcommand); + + TextWriter output = new StringWriter(); + CommandLineConfiguration config = new(subcommand) + { + Output = output + }; + + subcommand.Parse("--help", config).Invoke(); + + output.ToString().Should().Contain(SubcommandDescription); + output.ToString().Should().NotContain(RootDescription); + } + + private sealed class CustomizedHelpAction : SynchronousCommandLineAction + { + internal const string CustomUsageText = "This is custom command usage example."; + + private readonly HelpAction _helpAction; + + public CustomizedHelpAction(HelpOption helpOption) + { + _helpAction = (HelpAction)helpOption.Action; + } + + public override int Invoke(ParseResult parseResult) + { + _helpAction.Invoke(parseResult); + + if (parseResult.CommandResult.Command.Name == "subcommand") + { + var output = parseResult.Configuration.Output; + output.WriteLine(CustomUsageText); + } + + return 0; + } + } } \ No newline at end of file diff --git a/src/System.CommandLine.Tests/System.CommandLine.Tests.csproj b/src/System.CommandLine.Tests/System.CommandLine.Tests.csproj index 7569a272b6..db582f0b23 100644 --- a/src/System.CommandLine.Tests/System.CommandLine.Tests.csproj +++ b/src/System.CommandLine.Tests/System.CommandLine.Tests.csproj @@ -4,6 +4,7 @@ $(TargetFrameworkForNETSDK);$(NetFrameworkCurrent) false $(DefaultExcludesInProjectFolder);TestApps\** + $(NoWarn);CS8632 @@ -17,6 +18,11 @@ + + + + + diff --git a/src/System.CommandLine/EnumerableExtensions.cs b/src/System.CommandLine/EnumerableExtensions.cs index 2aecce2477..72018aaa38 100644 --- a/src/System.CommandLine/EnumerableExtensions.cs +++ b/src/System.CommandLine/EnumerableExtensions.cs @@ -34,18 +34,5 @@ internal static IEnumerable FlattenBreadthFirst( yield return current; } } - - internal static IEnumerable RecurseWhileNotNull( - this T? source, - Func next) - where T : class - { - while (source is not null) - { - yield return source; - - source = next(source); - } - } } } \ No newline at end of file diff --git a/src/System.CommandLine/Help/HelpOptionAction.cs b/src/System.CommandLine/Help/HelpAction.cs similarity index 87% rename from src/System.CommandLine/Help/HelpOptionAction.cs rename to src/System.CommandLine/Help/HelpAction.cs index 147b9b0981..7658ddeaad 100644 --- a/src/System.CommandLine/Help/HelpOptionAction.cs +++ b/src/System.CommandLine/Help/HelpAction.cs @@ -12,7 +12,7 @@ public sealed class HelpAction : SynchronousCommandLineAction /// /// Specifies an to be used to format help output when help is requested. /// - public HelpBuilder Builder + internal HelpBuilder Builder { get => _builder ??= new HelpBuilder(Console.IsOutputRedirected ? int.MaxValue : Console.WindowWidth); set => _builder = value ?? throw new ArgumentNullException(nameof(value)); @@ -25,8 +25,7 @@ public override int Invoke(ParseResult parseResult) var helpContext = new HelpContext(Builder, parseResult.CommandResult.Command, - output, - parseResult); + output); Builder.Write(helpContext); diff --git a/src/System.CommandLine/Help/HelpBuilder.Default.cs b/src/System.CommandLine/Help/HelpBuilder.Default.cs index 64591f9ec3..0325a44402 100644 --- a/src/System.CommandLine/Help/HelpBuilder.Default.cs +++ b/src/System.CommandLine/Help/HelpBuilder.Default.cs @@ -4,12 +4,11 @@ using System.Collections; using System.Collections.Generic; using System.CommandLine.Completions; -using System.CommandLine.Parsing; using System.Linq; namespace System.CommandLine.Help; -public partial class HelpBuilder +internal partial class HelpBuilder { /// /// Provides default formatting for help output. @@ -19,25 +18,23 @@ public static class Default /// /// Gets an argument's default value to be displayed in help. /// - /// The argument to get the default value for. - public static string GetArgumentDefaultValue(Argument argument) + /// The argument or option to get the default value for. + public static string GetArgumentDefaultValue(Symbol parameter) { - if (argument.HasDefaultValue) + return parameter switch { - if (argument.GetDefaultValue() is { } defaultValue) - { - if (defaultValue is IEnumerable enumerable and not string) - { - return string.Join("|", enumerable.OfType().ToArray()); - } - else - { - return defaultValue.ToString() ?? ""; - } - } - } + Argument argument => argument.HasDefaultValue ? ToString(argument.GetDefaultValue()) : "", + Option option => option.HasDefaultValue ? ToString(option.GetDefaultValue()) : "", + _ => throw new InvalidOperationException("Symbol must be an Argument or Option.") + }; - return string.Empty; + static string ToString(object? value) => value switch + { + null => string.Empty, + string str => str, + IEnumerable enumerable => string.Join("|", enumerable.Cast()), + _ => value.ToString() ?? string.Empty + }; } /// @@ -48,29 +45,39 @@ public static string GetArgumentDefaultValue(Argument argument) /// /// Gets the usage title for an argument (for example: <value>, typically used in the first column text in the arguments usage section, or within the synopsis. /// - public static string GetArgumentUsageLabel(Argument argument) + public static string GetArgumentUsageLabel(Symbol parameter) { - // Argument.HelpName is always first choice - if (!string.IsNullOrWhiteSpace(argument.HelpName)) + // By default Option.Name == Argument.Name, don't repeat it + return parameter switch { - return $"<{argument.HelpName}>"; - } - else if (!argument.IsBoolean() && argument.CompletionSources.Count > 0) + Argument argument => GetUsageLabel(argument.HelpName, argument.ValueType, argument.CompletionSources, argument) ?? $"<{argument.Name}>", + Option option => GetUsageLabel(option.HelpName, option.ValueType, option.CompletionSources, option) ?? "", + _ => throw new InvalidOperationException() + }; + + static string? GetUsageLabel(string? helpName, Type valueType, List>> completionSources, Symbol symbol) { - IEnumerable completions = argument - .GetCompletions(CompletionContext.Empty) - .Select(item => item.Label); + // Argument.HelpName is always first choice + if (!string.IsNullOrWhiteSpace(helpName)) + { + return $"<{helpName}>"; + } + else if (!(valueType == typeof(bool) || valueType == typeof(bool?)) && completionSources.Count > 0) + { + IEnumerable completions = symbol + .GetCompletions(CompletionContext.Empty) + .Select(item => item.Label); - string joined = string.Join("|", completions); + string joined = string.Join("|", completions); - if (!string.IsNullOrEmpty(joined)) - { - return $"<{joined}>"; + if (!string.IsNullOrEmpty(joined)) + { + return $"<{joined}>"; + } } - } - // By default Option.Name == Argument.Name, don't repeat it - return argument.FirstParent?.Symbol is not Option ? $"<{argument.Name}>" : ""; + return null; + } } /// @@ -79,13 +86,13 @@ public static string GetArgumentUsageLabel(Argument argument) /// The symbol to get a help item for. /// Text to display. public static string GetCommandUsageLabel(Command symbol) - => GetIdentifierSymbolUsageLabel(symbol, symbol._aliases); + => GetIdentifierSymbolUsageLabel(symbol, symbol.Aliases); /// public static string GetOptionUsageLabel(Option symbol) - => GetIdentifierSymbolUsageLabel(symbol, symbol._aliases); + => GetIdentifierSymbolUsageLabel(symbol, symbol.Aliases); - private static string GetIdentifierSymbolUsageLabel(Symbol symbol, AliasSet? aliasSet) + private static string GetIdentifierSymbolUsageLabel(Symbol symbol, ICollection? aliasSet) { var aliases = aliasSet is null ? new [] { symbol.Name } @@ -99,7 +106,7 @@ private static string GetIdentifierSymbolUsageLabel(Symbol symbol, AliasSet? ali var firstColumnText = string.Join(", ", aliases); - foreach (var argument in symbol.Arguments()) + foreach (var argument in symbol.GetParameters()) { if (!argument.Hidden) { @@ -185,18 +192,14 @@ public static Func OptionsSection() => { List optionRows = new(); bool addedHelpOption = false; - - if (ctx.Command.HasOptions) + foreach (Option option in ctx.Command.Options) { - foreach (Option option in ctx.Command.Options) + if (!option.Hidden) { - if (!option.Hidden) + optionRows.Add(ctx.HelpBuilder.GetTwoColumnRow(option, ctx)); + if (option is HelpOption) { - optionRows.Add(ctx.HelpBuilder.GetTwoColumnRow(option, ctx)); - if (option is HelpOption) - { - addedHelpOption = true; - } + addedHelpOption = true; } } } @@ -205,12 +208,11 @@ public static Func OptionsSection() => while (current is not null) { Command? parentCommand = null; - SymbolNode? parent = current.FirstParent; - while (parent is not null) + foreach (Symbol parent in current.Parents) { - if ((parentCommand = parent.Symbol as Command) is not null) + if ((parentCommand = parent as Command) is not null) { - if (parentCommand.HasOptions) + if (parentCommand.Options.Any()) { foreach (var option in parentCommand.Options) { @@ -227,7 +229,6 @@ public static Func OptionsSection() => break; } - parent = parent.Next; } current = parentCommand; } diff --git a/src/System.CommandLine/Help/HelpBuilder.cs b/src/System.CommandLine/Help/HelpBuilder.cs index 483a54ee05..2a035e24f4 100644 --- a/src/System.CommandLine/Help/HelpBuilder.cs +++ b/src/System.CommandLine/Help/HelpBuilder.cs @@ -11,7 +11,7 @@ namespace System.CommandLine.Help /// /// Formats output to be shown to users to describe how to use a command line tool. /// - public partial class HelpBuilder + internal partial class HelpBuilder { private const string Indent = " "; @@ -91,6 +91,30 @@ public void CustomizeLayout(Func + /// Specifies custom help details for a specific symbol. + /// + /// The symbol to customize the help details for. + /// A delegate to display the first help column (typically name and usage information). + /// A delegate to display second help column (typically the description). + /// The displayed default value for the symbol. + public void CustomizeSymbol( + Symbol symbol, + string? firstColumnText = null, + string? secondColumnText = null, + string? defaultValue = null) + { + CustomizeSymbol(symbol, _ => firstColumnText, _ => secondColumnText, _ => defaultValue); + } + + /// + /// Writes help output for the specified command. + /// + public void Write(Command command, TextWriter writer) + { + Write(new HelpContext(this, command, writer)); + } + private string GetUsage(Command command) { return string.Join(" ", GetUsageParts().Where(x => !string.IsNullOrWhiteSpace(x))); @@ -108,25 +132,25 @@ IEnumerable GetUsageParts() { if (!displayOptionTitle) { - displayOptionTitle = parentCommand.HasOptions && parentCommand.Options.Any(x => x.Recursive && !x.Hidden); + displayOptionTitle = parentCommand.Options.Any(x => x.Recursive && !x.Hidden); } yield return parentCommand.Name; - if (parentCommand.HasArguments) + if (parentCommand.Arguments.Any()) { yield return FormatArgumentUsage(parentCommand.Arguments); } } - var hasCommandWithHelp = command.HasSubcommands && command.Subcommands.Any(x => !x.Hidden); + var hasCommandWithHelp = command.Subcommands.Any(x => !x.Hidden); if (hasCommandWithHelp) { yield return LocalizationResources.HelpUsageCommand(); } - displayOptionTitle = displayOptionTitle || (command.HasOptions && command.Options.Any(x => !x.Hidden)); + displayOptionTitle = displayOptionTitle || (command.Options.Any(x => !x.Hidden)); if (displayOptionTitle) { @@ -441,8 +465,8 @@ TwoColumnHelpRow GetCommandArgumentRow(Argument argument) string GetSymbolDefaultValue(Symbol symbol) { - IList arguments = symbol.Arguments(); - var defaultArguments = arguments.Where(x => !x.Hidden && x.HasDefaultValue).ToArray(); + var arguments = symbol.GetParameters(); + var defaultArguments = arguments.Where(x => !x.Hidden && (x is Argument { HasDefaultValue: true } || x is Option { HasDefaultValue: true })).ToArray(); if (defaultArguments.Length == 0) return ""; @@ -455,13 +479,13 @@ string GetSymbolDefaultValue(Symbol symbol) private string GetArgumentDefaultValue( Symbol parent, - Argument argument, + Symbol parameter, bool displayArgumentName, HelpContext context) { string label = displayArgumentName ? LocalizationResources.HelpArgumentDefaultValueLabel() - : argument.Name; + : parameter.Name; string? displayedDefaultValue = null; @@ -472,14 +496,14 @@ private string GetArgumentDefaultValue( { displayedDefaultValue = parentDefaultValue; } - else if (_customizationsBySymbol.TryGetValue(argument, out customization) && + else if (_customizationsBySymbol.TryGetValue(parameter, out customization) && customization.GetDefaultValue?.Invoke(context) is { } ownDefaultValue) { displayedDefaultValue = ownDefaultValue; } } - displayedDefaultValue ??= Default.GetArgumentDefaultValue(argument); + displayedDefaultValue ??= Default.GetArgumentDefaultValue(parameter); if (string.IsNullOrWhiteSpace(displayedDefaultValue)) { diff --git a/src/System.CommandLine/Help/HelpBuilderExtensions.cs b/src/System.CommandLine/Help/HelpBuilderExtensions.cs index 992119a375..a130f13220 100644 --- a/src/System.CommandLine/Help/HelpBuilderExtensions.cs +++ b/src/System.CommandLine/Help/HelpBuilderExtensions.cs @@ -1,34 +1,60 @@ // Copyright (c) .NET Foundation and contributors. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -using System.IO; +using System.Collections.Generic; namespace System.CommandLine.Help { - public partial class HelpBuilder + internal static class HelpBuilderExtensions { - /// - /// Specifies custom help details for a specific symbol. - /// - /// The symbol to customize the help details for. - /// A delegate to display the first help column (typically name and usage information). - /// A delegate to display second help column (typically the description). - /// The displayed default value for the symbol. - public void CustomizeSymbol( - Symbol symbol, - string? firstColumnText = null, - string? secondColumnText = null, - string? defaultValue = null) + internal static IEnumerable GetParameters(this Symbol symbol) { - CustomizeSymbol(symbol, _ => firstColumnText, _ => secondColumnText, _ => defaultValue); + switch (symbol) + { + case Option option: + yield return option; + yield break; + case Command command: + foreach (var argument in command.Arguments) + { + yield return argument; + } + yield break; + case Argument argument: + yield return argument; + yield break; + default: + throw new NotSupportedException(); + } } - /// - /// Writes help output for the specified command. - /// - public void Write(Command command, TextWriter writer) + internal static (string? Prefix, string Alias) SplitPrefix(this string rawAlias) { - Write(new HelpContext(this, command, writer)); + if (rawAlias[0] == '/') + { + return ("/", rawAlias.Substring(1)); + } + else if (rawAlias[0] == '-') + { + if (rawAlias.Length > 1 && rawAlias[1] == '-') + { + return ("--", rawAlias.Substring(2)); + } + + return ("-", rawAlias.Substring(1)); + } + + return (null, rawAlias); + } + + internal static IEnumerable RecurseWhileNotNull(this T? source, Func next) where T : class + { + while (source is not null) + { + yield return source; + + source = next(source); + } } } } \ No newline at end of file diff --git a/src/System.CommandLine/Help/HelpContext.cs b/src/System.CommandLine/Help/HelpContext.cs index a9935941da..6162933843 100644 --- a/src/System.CommandLine/Help/HelpContext.cs +++ b/src/System.CommandLine/Help/HelpContext.cs @@ -8,22 +8,19 @@ namespace System.CommandLine.Help /// /// Supports formatting command line help. /// - public class HelpContext + internal class HelpContext { /// The current help builder. /// The command for which help is being formatted. /// A text writer to write output to. - /// The result of the current parse operation. public HelpContext( HelpBuilder helpBuilder, Command command, - TextWriter output, - ParseResult? parseResult = null) + TextWriter output) { HelpBuilder = helpBuilder ?? throw new ArgumentNullException(nameof(helpBuilder)); Command = command ?? throw new ArgumentNullException(nameof(command)); Output = output ?? throw new ArgumentNullException(nameof(output)); - ParseResult = parseResult ?? ParseResult.Empty(); } /// @@ -31,11 +28,6 @@ public HelpContext( /// public HelpBuilder HelpBuilder { get; } - /// - /// The result of the current parse operation. - /// - public ParseResult ParseResult { get; } - /// /// The command for which help is being formatted. /// diff --git a/src/System.CommandLine/Help/TwoColumnHelpRow.cs b/src/System.CommandLine/Help/TwoColumnHelpRow.cs index 361b0e7af9..fab0958337 100644 --- a/src/System.CommandLine/Help/TwoColumnHelpRow.cs +++ b/src/System.CommandLine/Help/TwoColumnHelpRow.cs @@ -8,7 +8,7 @@ namespace System.CommandLine.Help /// /// Provides details about an item to be formatted to output in order to display two-column command line help. /// - public class TwoColumnHelpRow : IEquatable + internal class TwoColumnHelpRow : IEquatable { /// The name and invocation details, typically displayed in the first help column. /// The description of a symbol, typically displayed in the second help column. diff --git a/src/System.CommandLine/Option.cs b/src/System.CommandLine/Option.cs index c6abaf3725..c9d81062e7 100644 --- a/src/System.CommandLine/Option.cs +++ b/src/System.CommandLine/Option.cs @@ -137,5 +137,11 @@ public override IEnumerable GetCompletions(CompletionContext con .OrderBy(item => item.SortText.IndexOfCaseInsensitive(context.WordToComplete)) .ThenBy(symbol => symbol.Label, StringComparer.OrdinalIgnoreCase); } + + /// + /// Gets the default value for the option. + /// + /// Returns the default value for the option, if defined. Null otherwise. + public object? GetDefaultValue() => Argument.GetDefaultValue(); } } diff --git a/src/System.CommandLine/ParseResult.cs b/src/System.CommandLine/ParseResult.cs index 92b209f93a..9b476782e0 100644 --- a/src/System.CommandLine/ParseResult.cs +++ b/src/System.CommandLine/ParseResult.cs @@ -175,6 +175,14 @@ CommandLineText is null public SymbolResult? GetResult(Symbol symbol) => _rootCommandResult.SymbolResultTree.TryGetValue(symbol, out SymbolResult? result) ? result : null; + /// + /// Finds a result for a symbol having the specified name anywhere in the parse tree. + /// + /// The name of the symbol for which to find a result. + /// An symbol result if the argument was matched by the parser or has a default value; otherwise, null. + public SymbolResult? GetResult(string name) => + _rootCommandResult.SymbolResultTree.GetResult(name); + /// /// Gets completions based on a given parse result. /// diff --git a/src/System.CommandLine/Parsing/StringExtensions.cs b/src/System.CommandLine/Parsing/StringExtensions.cs index 8749e7eef1..b1f7b63ad2 100644 --- a/src/System.CommandLine/Parsing/StringExtensions.cs +++ b/src/System.CommandLine/Parsing/StringExtensions.cs @@ -24,25 +24,6 @@ internal static int IndexOfCaseInsensitive( value, CompareOptions.OrdinalIgnoreCase); - internal static (string? Prefix, string Alias) SplitPrefix(this string rawAlias) - { - if (rawAlias[0] == '/') - { - return ("/", rawAlias.Substring(1)); - } - else if (rawAlias[0] == '-') - { - if (rawAlias.Length > 1 && rawAlias[1] == '-') - { - return ("--", rawAlias.Substring(2)); - } - - return ("-", rawAlias.Substring(1)); - } - - return (null, rawAlias); - } - // this method is not returning a Value Tuple or a dedicated type to avoid JITting internal static void Tokenize( this IReadOnlyList args, diff --git a/src/System.CommandLine/Parsing/SymbolResultTree.cs b/src/System.CommandLine/Parsing/SymbolResultTree.cs index e287b66494..4778c4093e 100644 --- a/src/System.CommandLine/Parsing/SymbolResultTree.cs +++ b/src/System.CommandLine/Parsing/SymbolResultTree.cs @@ -108,7 +108,7 @@ private void PopulateSymbolsByName(Command command) { for (var i = 0; i < command.Arguments.Count; i++) { - AddToSymbolsByName(command.Arguments[i]); + AddToSymbolsByName(command.Arguments[i], command); } } @@ -116,7 +116,7 @@ private void PopulateSymbolsByName(Command command) { for (var i = 0; i < command.Options.Count; i++) { - AddToSymbolsByName(command.Options[i]); + AddToSymbolsByName(command.Options[i], command); } } @@ -125,30 +125,35 @@ private void PopulateSymbolsByName(Command command) for (var i = 0; i < command.Subcommands.Count; i++) { var childCommand = command.Subcommands[i]; - AddToSymbolsByName(childCommand); + AddToSymbolsByName(childCommand, command); PopulateSymbolsByName(childCommand); } } - void AddToSymbolsByName(Symbol symbol) + void AddToSymbolsByName(Symbol symbol, Command parent) { if (_symbolsByName!.TryGetValue(symbol.Name, out var node)) { - if (symbol.Name == node.Symbol.Name && - symbol.FirstParent?.Symbol is { } parent && - parent == node.Symbol.FirstParent?.Symbol) + var current = node; + do { - throw new InvalidOperationException($"Command {parent.Name} has more than one child named \"{symbol.Name}\"."); - } - - _symbolsByName[symbol.Name] = new(symbol) + // The same symbol can be added to multiple commands and have multiple parents. + // We can't allow for name duplicates within the same command. + if (ReferenceEquals(current.Parent, parent)) + { + throw new InvalidOperationException($"Command {parent.Name} has more than one child named \"{symbol.Name}\"."); + } + current = current.Next; + } while (current is not null); + + _symbolsByName[symbol.Name] = new(symbol, parent) { Next = node }; } else { - _symbolsByName[symbol.Name] = new(symbol); + _symbolsByName[symbol.Name] = new(symbol, parent); } } } diff --git a/src/System.CommandLine/SymbolExtensions.cs b/src/System.CommandLine/SymbolExtensions.cs deleted file mode 100644 index e73f859638..0000000000 --- a/src/System.CommandLine/SymbolExtensions.cs +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) .NET Foundation and contributors. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. - -using System.Collections.Generic; - -namespace System.CommandLine -{ - /// - /// Provides extension methods for symbols. - /// - internal static class SymbolExtensions - { - internal static IList Arguments(this Symbol symbol) - { - switch (symbol) - { - case Option option: - return new[] - { - option.Argument - }; - case Command command: - return command.Arguments; - case Argument argument: - return new[] - { - argument - }; - default: - throw new NotSupportedException(); - } - } - } -} \ No newline at end of file diff --git a/src/System.CommandLine/SymbolNode.cs b/src/System.CommandLine/SymbolNode.cs index f76037fdb5..c415e3c868 100644 --- a/src/System.CommandLine/SymbolNode.cs +++ b/src/System.CommandLine/SymbolNode.cs @@ -5,10 +5,16 @@ namespace System.CommandLine { internal sealed class SymbolNode { - internal SymbolNode(Symbol symbol) => Symbol = symbol; + internal SymbolNode(Symbol symbol, Command? parent = null) + { + Symbol = symbol; + Parent = parent; + } internal Symbol Symbol { get; } + internal Command? Parent { get; } + internal SymbolNode? Next { get; set; } } } \ No newline at end of file