diff --git a/src/Spectre.Console.Cli/CommandOfT.cs b/src/Spectre.Console.Cli/CommandOfT.cs index e2be58ef1..db6c89efb 100644 --- a/src/Spectre.Console.Cli/CommandOfT.cs +++ b/src/Spectre.Console.Cli/CommandOfT.cs @@ -1,3 +1,5 @@ +using Spectre.Console.Cli.Completion; + namespace Spectre.Console.Cli; /// @@ -45,4 +47,15 @@ Task ICommand.Execute(CommandContext context, TSettings settings { return Task.FromResult(Execute(context, settings)); } + + /// + /// Gets a new suggestion matcher for this command. + /// + /// A suggestion matcher. + public virtual AsyncCommandParameterMatcher AsyncSuggestionMatcher => new(); + + /// + /// Gets a new suggestion matcher for this command. + /// + public virtual CommandParameterMatcher SuggestionMatcher => new(); } \ No newline at end of file diff --git a/src/Spectre.Console.Cli/Completion/CompletionResultExtensions.cs b/src/Spectre.Console.Cli/Completion/CompletionResultExtensions.cs index c9c727b49..939a31b01 100644 --- a/src/Spectre.Console.Cli/Completion/CompletionResultExtensions.cs +++ b/src/Spectre.Console.Cli/Completion/CompletionResultExtensions.cs @@ -1,9 +1,9 @@ namespace Spectre.Console.Cli.Completion; /// -/// Extensions for . +/// Extensions for Completions /// -public static class CompletionResultExtensions +public static class CompletionExtensions { /// /// Disables completions, that are automatically generated. @@ -16,4 +16,41 @@ public static async Task WithPreventDefault(this Task + /// Creates a new suggestion matcher for this command. + /// + /// A suggestion matcher. + /// The command. + /// The settings type. + /// The command type. + [SuppressMessage("Roslynator", "RCS1175:Unused 'this' parameter.", Justification = "Extension method")] + [SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "Extension method")] + public static CommandParameterMatcher CreateMatcher(this TCommand command) + where TSettings : CommandSettings + where TCommand : Command, IAsyncCommandCompletable + { + return new(); + } + + /// + /// Creates a new suggestion matcher for this command. + /// + /// The settings type. + /// The command type. + /// A suggestion matcher. + /// The command. + [SuppressMessage("Roslynator", "RCS1175:Unused 'this' parameter.", Justification = "Extension method")] + [SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "Extension method")] + public static AsyncCommandParameterMatcher CreateAsyncMatcher(this TCommand command) + where TSettings : CommandSettings + where TCommand : Command, IAsyncCommandCompletable + { + return new(); + } + */ +} +#pragma warning restore S125 // Sections of code should not be commented out \ No newline at end of file diff --git a/src/Spectre.Console.Cli/Completion/CompletionSuggestionsAttribute.cs b/src/Spectre.Console.Cli/Completion/CompletionSuggestionsAttribute.cs new file mode 100644 index 000000000..a6dd1a75f --- /dev/null +++ b/src/Spectre.Console.Cli/Completion/CompletionSuggestionsAttribute.cs @@ -0,0 +1,12 @@ +namespace Spectre.Console.Cli.Completion; + +[System.AttributeUsage(AttributeTargets.Property, Inherited = true, AllowMultiple = true)] +internal sealed class CompletionSuggestionsAttribute : Attribute +{ + public string[] Suggestions { get; } + + public CompletionSuggestionsAttribute(params string[] suggestions) + { + Suggestions = suggestions; + } +} \ No newline at end of file diff --git a/src/Spectre.Console.Cli/Internal/Commands/CompleteCommand.cs b/src/Spectre.Console.Cli/Internal/Commands/CompleteCommand.cs index d9ed83648..0c44fd5f7 100644 --- a/src/Spectre.Console.Cli/Internal/Commands/CompleteCommand.cs +++ b/src/Spectre.Console.Cli/Internal/Commands/CompleteCommand.cs @@ -129,7 +129,7 @@ private async Task GetCompletionsAsync(CommandModel model, string comm { return model.Commands.Where(cmd => !cmd.IsHidden) .Select(c => c.Name) - .Where(n => n.StartsWith(partialElement)) + .Where(n => n.StartsWith(partialElement, StringComparison.OrdinalIgnoreCase)) .ToArray(); } @@ -192,7 +192,7 @@ private static CompletionResult GetSuggestions(string partialElement, IEnumerabl { return commands.Where(cmd => !cmd.IsHidden) .Select(c => c.Name) - .Where(n => string.IsNullOrEmpty(partialElement) || n.StartsWith(partialElement)) + .Where(n => string.IsNullOrEmpty(partialElement) || n.StartsWith(partialElement, StringComparison.OrdinalIgnoreCase)) .ToArray(); } @@ -240,11 +240,13 @@ private async Task> GetCommandArgumentsAsync(CommandInfo if (!hasTrailingSpace) { - if (lastMap?.Parameter is not CommandArgument lastArgument) + if (lastMap?.Parameter is null) { return new List(); } + var lastArgument = lastMap?.Parameter; + var completions = await CompleteCommandOption(parent, lastArgument, lastMap.Value); if (completions == null) { @@ -265,29 +267,54 @@ private async Task> GetCommandArgumentsAsync(CommandInfo continue; } - if (parameter.Parameter is ICommandParameterInfo commandArgumentParameter) + if (parameter.Parameter is null) { - var completions = await CompleteCommandOption(parent, commandArgumentParameter, parameter.Value); - if (completions == null) - { - continue; - } + continue; + } - if (completions.Suggestions.Any() || completions.PreventDefault) - { - result.Add(new(completions)); - break; - } + //var valuesViaAttributes = parameter.Parameter.Property.GetCustomAttributes(); + //if (valuesViaAttributes?.Any() == true) + //{ + // var values = valuesViaAttributes + // .Where(x => x.Suggestions != null) + // .SelectMany(x => x.Suggestions); + + // result.Add(new(values, true)); + // continue; + //} + + var completions = await CompleteCommandOption(parent, parameter.Parameter, parameter.Value); + if (completions == null) + { + continue; + } + + if (completions.Suggestions.Any() || completions.PreventDefault) + { + result.Add(new(completions)); + break; } } return result; } - private async Task CompleteCommandOption(CommandInfo parent, ICommandParameterInfo commandArgumentParameter, string? partialElement) + private async Task CompleteCommandOption(CommandInfo parent, CommandParameter parameter, string? partialElement) { partialElement ??= string.Empty; + var valuesViaAttributes = parameter.Property.GetCustomAttributes(); + if (valuesViaAttributes?.Any() == true) + { + var values = valuesViaAttributes + .Where(x => x.Suggestions != null) + .SelectMany(x => x.Suggestions) + .Where(x => string.IsNullOrEmpty(partialElement) || x.StartsWith(partialElement, StringComparison.OrdinalIgnoreCase)) + ; + + return new(values, true); + } + var commandType = parent.CommandType; if (commandType == null) { @@ -304,12 +331,12 @@ private async Task> GetCommandArgumentsAsync(CommandInfo var completer = _typeResolver.Resolve(commandType); if (completer is IAsyncCommandCompletable typedAsyncCompleter) { - return await typedAsyncCompleter.GetSuggestionsAsync(commandArgumentParameter, partialElement); + return await typedAsyncCompleter.GetSuggestionsAsync(parameter, partialElement); } if (completer is ICommandCompletable typedCompleter) { - return typedCompleter.GetSuggestions(commandArgumentParameter, partialElement); + return typedCompleter.GetSuggestions(parameter, partialElement); } return CompletionResult.None(); diff --git a/test/Spectre.Console.Cli.Tests/Data/Commands/LionCommand.cs b/test/Spectre.Console.Cli.Tests/Data/Commands/LionCommand.cs index 9b0203801..f900f1513 100644 --- a/test/Spectre.Console.Cli.Tests/Data/Commands/LionCommand.cs +++ b/test/Spectre.Console.Cli.Tests/Data/Commands/LionCommand.cs @@ -3,42 +3,16 @@ namespace Spectre.Console.Tests.Data; [Description("The lion command.")] -public class LionCommand : AnimalCommand, ICommandCompletable, IAsyncCommandCompletable +public class LionCommand : AnimalCommand, IAsyncCommandCompletable { public override int Execute(CommandContext context, LionSettings settings) { return 0; } - public CompletionResult GetSuggestions(ICommandParameterInfo parameter, string prefix) - { - return new CommandParameterMatcher() - .Add(x => x.Legs, (prefix) => - { - if (prefix.Length != 0) - { - return FindNextEvenNumber(prefix); - } - - return "16"; - }) - .Add(x => x.Teeth, (prefix) => - { - if (prefix.Length != 0) - { - return FindNextEvenNumber(prefix); - } - - return "32"; - }) - .Add(x => x.Name, _ => "Angelika") - .Match(parameter, prefix) - .WithPreventDefault(); - } - public async Task GetSuggestionsAsync(ICommandParameterInfo parameter, string prefix) { - return await new AsyncCommandParameterMatcher() + return await AsyncSuggestionMatcher .Add(x => x.Legs, (prefix) => { if (prefix.Length != 0) diff --git a/test/Spectre.Console.Cli.Tests/Data/Commands/UserAddCommand.cs b/test/Spectre.Console.Cli.Tests/Data/Commands/UserAddCommand.cs new file mode 100644 index 000000000..be36aa01c --- /dev/null +++ b/test/Spectre.Console.Cli.Tests/Data/Commands/UserAddCommand.cs @@ -0,0 +1,26 @@ +using Spectre.Console.Cli.Completion; + +namespace Spectre.Console.Cli.Tests.Data.Commands; + +// mycommand user add [name] --age [age] +internal class UserAddSettings : CommandSettings +{ + [CommandArgument(0, "")] + [Description("The name of the user.")] + [CompletionSuggestions("Angelika", "Arnold", "Bernd", "Cloud", "Jonas")] + public string Name { get; set; } + + [CommandOption("-a|--age ")] + [Description("The age of the user.")] + [CompletionSuggestions("10", "15", "20", "30")] + public int Age { get; set; } +} + +[Description("The user command.")] +internal class UserAddCommand : Command +{ + public override int Execute(CommandContext context, UserAddSettings settings) + { + return 0; + } +} \ No newline at end of file diff --git a/test/Spectre.Console.Cli.Tests/Expectations/Cli/Complete/Test_23.Output.verified.txt b/test/Spectre.Console.Cli.Tests/Expectations/Cli/Complete/Test_23.Output.verified.txt new file mode 100644 index 000000000..1d8c08815 --- /dev/null +++ b/test/Spectre.Console.Cli.Tests/Expectations/Cli/Complete/Test_23.Output.verified.txt @@ -0,0 +1,2 @@ +10 +15 \ No newline at end of file diff --git a/test/Spectre.Console.Cli.Tests/Expectations/Cli/Complete/Test_24.Output.verified.txt b/test/Spectre.Console.Cli.Tests/Expectations/Cli/Complete/Test_24.Output.verified.txt new file mode 100644 index 000000000..43107b6c4 --- /dev/null +++ b/test/Spectre.Console.Cli.Tests/Expectations/Cli/Complete/Test_24.Output.verified.txt @@ -0,0 +1,4 @@ +10 +15 +20 +30 \ No newline at end of file diff --git a/test/Spectre.Console.Cli.Tests/Expectations/Cli/Complete/Test_25.Output.verified.txt b/test/Spectre.Console.Cli.Tests/Expectations/Cli/Complete/Test_25.Output.verified.txt new file mode 100644 index 000000000..813d4e309 --- /dev/null +++ b/test/Spectre.Console.Cli.Tests/Expectations/Cli/Complete/Test_25.Output.verified.txt @@ -0,0 +1,5 @@ +Angelika +Arnold +Bernd +Cloud +Jonas \ No newline at end of file diff --git a/test/Spectre.Console.Cli.Tests/Expectations/Cli/Complete/Test_26.Output.verified.txt b/test/Spectre.Console.Cli.Tests/Expectations/Cli/Complete/Test_26.Output.verified.txt new file mode 100644 index 000000000..ee960346b --- /dev/null +++ b/test/Spectre.Console.Cli.Tests/Expectations/Cli/Complete/Test_26.Output.verified.txt @@ -0,0 +1,2 @@ +Angelika +Arnold \ No newline at end of file diff --git a/test/Spectre.Console.Cli.Tests/Unit/CommandAppTests.Complete.cs b/test/Spectre.Console.Cli.Tests/Unit/CommandAppTests.Complete.cs index e40272211..aa15b6430 100644 --- a/test/Spectre.Console.Cli.Tests/Unit/CommandAppTests.Complete.cs +++ b/test/Spectre.Console.Cli.Tests/Unit/CommandAppTests.Complete.cs @@ -1,3 +1,5 @@ +using Spectre.Console.Cli.Tests.Data.Commands; + namespace Spectre.Console.Tests.Unit.Cli; public sealed partial class CommandAppTests @@ -641,5 +643,109 @@ public Task Should_Handle_Positions3() // Then return Verifier.Verify(result.Output); } + + [Fact] + [Expectation("Test_23")] + public Task CompletionSuggestionsAttribute_Should_Suggest_Option_Values_Starting_With_Partial() + { + // Given + var fixture = new CommandAppTester(); + fixture.Configure(config => + { + config.SetApplicationName("myapp"); + config.PropagateExceptions(); + + config.AddBranch("user", feline => + { + feline.AddCommand("add"); + }); + }); + var commandToRun = Constants.CompleteCommand + .Append("\"myapp user add angel --age 1\"") + ; + // When + var result = fixture.Run(commandToRun.ToArray()); + + // Then + return Verifier.Verify(result.Output); + } + + [Fact] + [Expectation("Test_24")] + public Task CompletionSuggestionsAttribute_Should_Suggest_Option_Values() + { + // Given + var fixture = new CommandAppTester(); + fixture.Configure(config => + { + config.SetApplicationName("myapp"); + config.PropagateExceptions(); + + config.AddBranch("user", feline => + { + feline.AddCommand("add"); + }); + }); + var commandToRun = Constants.CompleteCommand + .Append("\"myapp user add angel --age \"") + ; + // When + var result = fixture.Run(commandToRun.ToArray()); + + // Then + return Verifier.Verify(result.Output); + } + + [Fact] + [Expectation("Test_25")] + public Task CompletionSuggestionsAttribute_Should_Suggest_Argument_Values() + { + // Given + var fixture = new CommandAppTester(); + fixture.Configure(config => + { + config.SetApplicationName("myapp"); + config.PropagateExceptions(); + + config.AddBranch("user", feline => + { + feline.AddCommand("add"); + }); + }); + var commandToRun = Constants.CompleteCommand + .Append("\"myapp user add \"") + ; + // When + var result = fixture.Run(commandToRun.ToArray()); + + // Then + return Verifier.Verify(result.Output); + } + + [Fact] + [Expectation("Test_26")] + public Task CompletionSuggestionsAttribute_Should_Suggest_Argument_Values_With_Partial() + { + // Given + var fixture = new CommandAppTester(); + fixture.Configure(config => + { + config.SetApplicationName("myapp"); + config.PropagateExceptions(); + + config.AddBranch("user", feline => + { + feline.AddCommand("add"); + }); + }); + var commandToRun = Constants.CompleteCommand + .Append("\"myapp user add a\"") + ; + // When + var result = fixture.Run(commandToRun.ToArray()); + + // Then + return Verifier.Verify(result.Output); + } } } \ No newline at end of file