diff --git a/.gitignore b/.gitignore index 664051dea..076a4ea19 100644 --- a/.gitignore +++ b/.gitignore @@ -90,4 +90,5 @@ Thumbs.db *.received.* -node_modules \ No newline at end of file +node_modules +*.txt.bak \ No newline at end of file diff --git a/src/Spectre.Console.Cli/CommandApp.cs b/src/Spectre.Console.Cli/CommandApp.cs index 4882fc0c3..b3c98073a 100644 --- a/src/Spectre.Console.Cli/CommandApp.cs +++ b/src/Spectre.Console.Cli/CommandApp.cs @@ -76,6 +76,7 @@ public async Task RunAsync(IEnumerable args) cli.AddCommand(CliConstants.Commands.Version); cli.AddCommand(CliConstants.Commands.XmlDoc); cli.AddCommand(CliConstants.Commands.Explain); + cli.AddCommand(CliConstants.Commands.Complete); }); _executed = true; diff --git a/src/Spectre.Console.Cli/Completion/CommandParameterMatcher.cs b/src/Spectre.Console.Cli/Completion/CommandParameterMatcher.cs new file mode 100644 index 000000000..d9771880d --- /dev/null +++ b/src/Spectre.Console.Cli/Completion/CommandParameterMatcher.cs @@ -0,0 +1,112 @@ +using System.Linq.Expressions; + +namespace Spectre.Console.Cli.Completion; + +/* + Usage: + return new CommandParameterMatcher() + .Add(x => x.Legs, (prefix) => + { + if (prefix.Length != 0) + { + return CompletionResult.Result(FindNextEvenNumber(prefix)).WithPreventDefault(); + } + + return CompletionResult.Result("16").WithPreventDefault(); + }) + .Add(x => x.Teeth, (prefix) => + { + if (prefix.Length != 0) + { + return CompletionResult.Result(FindNextEvenNumber(prefix)).WithPreventDefault(); + } + + return CompletionResult.Result("32").WithPreventDefault(); + }) + .Match(parameter, prefix); + */ + +public class CommandParameterMatcher + where T : CommandSettings +{ + private readonly List<(PropertyInfo Property, Func Func)> _completers; + + public CommandParameterMatcher() + { + _completers = new(); + } + + private CommandParameterMatcher(IEnumerable<(PropertyInfo, Func)>? completers) + { + _completers = completers?.ToList() ?? new(); + } + + public ICompletionResult Match(ICommandParameterInfo parameter, string prefix) + { + var property = _completers.FirstOrDefault(x => x.Property.Name == parameter.PropertyName); + if (property.Property == null) + { + return CompletionResult.None(); + } + + return property.Func(prefix); + } + + public CommandParameterMatcher Add(Expression> property, Func completer) + { + var parameter = PropertyOf(property); + _completers.Add((parameter, completer)); + return this; + } + + public static CommandParameterMatcher Create(Dictionary>, Func> completers) + { + var result = new List<(PropertyInfo, Func)>(); + + foreach (var completer in completers) + { + var parameter = PropertyOf(completer.Key); + result.Add((parameter, completer.Value)); + } + + return new CommandParameterMatcher(result); + } + + // params create + public static CommandParameterMatcher Create(params (Expression>, Func)[] completers) + { + var result = new List<(PropertyInfo, Func)>(); + foreach (var (key, value) in completers) + { + var parameter = PropertyOf(key); + result.Add((parameter, value)); + } + + return new CommandParameterMatcher(result); + } + + + + private static PropertyInfo PropertyOf(LambdaExpression methodExpression) + { + var body = RemoveConvert(methodExpression.Body); + var prop = (MemberExpression)body; + return (PropertyInfo)prop.Member; + } + + private static Expression RemoveConvert(Expression expression) + { + while ( + expression != null + && ( + expression.NodeType == ExpressionType.Convert + || expression.NodeType == ExpressionType.ConvertChecked + ) + ) + { + expression = RemoveConvert(((UnaryExpression)expression).Operand); + } + + return expression; + } +} \ No newline at end of file diff --git a/src/Spectre.Console.Cli/Completion/CompletionResult.cs b/src/Spectre.Console.Cli/Completion/CompletionResult.cs new file mode 100644 index 000000000..931d10928 --- /dev/null +++ b/src/Spectre.Console.Cli/Completion/CompletionResult.cs @@ -0,0 +1,65 @@ +namespace Spectre.Console.Cli.Completion; + +public class CompletionResult : ICompletionResult +{ + public bool PreventDefault { get; } + public IEnumerable Suggestions { get; } = Enumerable.Empty(); + + internal bool IsGenerated { get; private set; } + + public CompletionResult() + { + } + + public CompletionResult(IEnumerable suggestions, bool preventDefault = false) + { + Suggestions = suggestions ?? throw new ArgumentNullException(nameof(suggestions)); + PreventDefault = preventDefault; + } + + public CompletionResult(ICompletionResult result) + { + Suggestions = result.Suggestions; + PreventDefault = result.PreventDefault; + } + + public CompletionResult WithSuggestions(IEnumerable suggestions) + { + return new(suggestions, PreventDefault); + } + + /// + /// Disables completions, that are automatically generated + /// + /// + /// + public CompletionResult WithPreventDefault(bool preventDefault = true) + { + return new(Suggestions, preventDefault); + } + + public CompletionResult WithGeneratedSuggestions() + { + return new(Suggestions, PreventDefault) { IsGenerated = true }; + } + + public static implicit operator CompletionResult(string[] suggestions) + { + return new(suggestions); + } + + public static implicit operator CompletionResult(string suggestion) + { + return new(new[] { suggestion }); + } + + public static CompletionResult None() + { + return new(); + } + + public static CompletionResult Result(params string[] suggestions) + { + return new(suggestions); + } +} diff --git a/src/Spectre.Console.Cli/Completion/ICommandParameterCompleter.cs b/src/Spectre.Console.Cli/Completion/ICommandParameterCompleter.cs new file mode 100644 index 000000000..f2f8eb766 --- /dev/null +++ b/src/Spectre.Console.Cli/Completion/ICommandParameterCompleter.cs @@ -0,0 +1,14 @@ +using System.Linq.Expressions; + +namespace Spectre.Console.Cli.Completion; + +public interface ICommandParameterCompleter +{ + ICompletionResult GetSuggestions(ICommandParameterInfo parameter, string? prefix); +} + +public interface ICompletionResult +{ + bool PreventDefault { get; } + IEnumerable Suggestions { get; } +} diff --git a/src/Spectre.Console.Cli/ICommandParameterInfo.cs b/src/Spectre.Console.Cli/ICommandParameterInfo.cs index b6483eff4..e69c5f490 100644 --- a/src/Spectre.Console.Cli/ICommandParameterInfo.cs +++ b/src/Spectre.Console.Cli/ICommandParameterInfo.cs @@ -21,4 +21,4 @@ public interface ICommandParameterInfo /// /// The description. public string? Description { get; } -} \ 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 new file mode 100644 index 000000000..3414a22e4 --- /dev/null +++ b/src/Spectre.Console.Cli/Internal/Commands/CompleteCommand.cs @@ -0,0 +1,260 @@ +namespace Spectre.Console.Cli; + +[Description("Generates a list of completion options for the given command.")] +[SuppressMessage("Performance", "CA1812: Avoid uninstantiated internal classes")] +internal sealed class CompleteCommand : Command +{ + private readonly CommandModel _model; + private readonly ITypeResolver _typeResolver; + private readonly IAnsiConsole _writer; + private readonly IConfiguration _configuration; + + public CompleteCommand + ( + IConfiguration configuration, + CommandModel model + , ITypeResolver typeResolver + ) + { + _model = model ?? throw new ArgumentNullException(nameof(model)); + _typeResolver = typeResolver; + _writer = configuration.Settings.Console.GetConsole(); + _configuration = configuration; + } + + public sealed class Settings : CommandSettings + { + public Settings(string? commandToComplete) + { + CommandToComplete = commandToComplete; + } + + [CommandArgument(0, "[commandToComplete]")] + public string? CommandToComplete { get; } + } + + public override int Execute([NotNull] CommandContext context, [NotNull] Settings settings) + { + foreach (var completion in GetCompletions(_model, settings)) + { + _writer.WriteLine(completion, Style.Plain); + } + + return 0; + } + + private string[] GetCompletions(CommandModel model, Settings settings) + { + // Get all command elements and skip the application name at the start. + var commandElements = settings.CommandToComplete? + .TrimStart('"') + .TrimEnd('"') + .Split(' ').Skip(1).ToArray(); + + // Return early if the only thing we got was "". + if (commandElements == null || + (commandElements.Count() == 1 && + string.IsNullOrEmpty(commandElements.First()))) + { + return model.Commands.Where(cmd => !cmd.IsHidden) + .Select(c => c.Name) + .ToArray(); + } + + // Parse the command elements to get an abstract syntax tree and some context + CommandTreeParserResult? parsedResult = null; + var parser = new CommandTreeParser(model, _configuration.Settings.CaseSensitivity); + var context = string.Empty; + var partialElement = string.Empty; + try + { + parsedResult = parser.Parse(commandElements); + context = commandElements.Last(); + if (string.IsNullOrEmpty(context)) + { + // Because we support netstandard2.0, we can't use SkipLast, since it's not supported. Also negative indexes are a no go. + // There probably is a more elegant way to get this result that I just can't see now. + // context = commandElements.SkipLast(1).Last(); + context = commandElements.ToArray()[commandElements.Count() - 2]; + } + } + catch (CommandParseException) + { + // Assume that it's because the last commandElement was not complete, and omit that one. + var strippedCommandElements = commandElements.Take(commandElements.Count() - 1); + if (strippedCommandElements.Any()) + { + parsedResult = parser.Parse(strippedCommandElements); + context = strippedCommandElements.Last(); + partialElement = commandElements.Last().ToLowerInvariant(); + } + } + + + // Return command options based on our current context, filtered on any partial element we found. + // If partial element = "", the StartsWith will return all options. + CommandInfo parent; + if (parsedResult?.Tree == null) + { + return model.Commands.Where(cmd => !cmd.IsHidden) + .Select(c => c.Name) + .Where(n => n.StartsWith(partialElement)) + .ToArray(); + } + else + { + // The Tree does not natively support walking or visiting, so we need to search it manually. + parent = FindContextInTree(parsedResult.Tree, context); + } + + // No idea why this fixes test 7: Parameters + parent ??= parsedResult.Tree.Command; + + CompletionResult childCommands = parent.Children.Where(cmd => !cmd.IsHidden) + .Select(c => c.Name) + .Where(n => partialElement == string.Empty || n.StartsWith(partialElement)) + .ToArray(); + + childCommands = childCommands.WithGeneratedSuggestions(); + + var parameters = GetParameters(parent, partialElement); + var arguments = GetCommandArguments(parent, parsedResult.Tree.Mapped, commandElements); + + var allResults = parameters.Concat(arguments).Append(childCommands).ToArray(); + + if (allResults.Any(n => n.PreventDefault)) + { + // Only return non-generated suggestions + return allResults + .Where(s => !s.IsGenerated) + .SelectMany(s => s.Suggestions) + .Distinct() + .ToArray(); + } + + return allResults + //Prefer manual suggestions over generated ones + .OrderBy(s => s.IsGenerated) + .SelectMany(s => s.Suggestions) + .Distinct() + .ToArray(); + } + + private List GetCommandArguments(CommandInfo parent, List mapped, string[] args) + { + // Trailing space: The first empty parameter should be completed + // No trailing space: The last parameter should be completed + var hasTrailingSpace = args.LastOrDefault() == string.Empty; + var lastIsCommandArgument = mapped.LastOrDefault()?.Parameter is CommandArgument; + + if (!hasTrailingSpace) + { + if(!lastIsCommandArgument) + { + return new List(); + } + + var lastMap = mapped.Last(); + var lastArgument = lastMap.Parameter as CommandArgument; + var completions = CompleteCommandArgument(lastArgument, lastMap.Value); + if (completions == null) + { + return new List(); + } + + if (completions.Suggestions.Any() || completions.PreventDefault) + { + return new List { new(completions) }; + } + } + + var result = new List(); + foreach (var parameter in mapped) + { + if (!string.IsNullOrEmpty(parameter.Value)) + { + continue; + } + + if (parameter.Parameter is not CommandArgument commandArgumentParameter) + { + continue; + } + + var completions = CompleteCommandArgument(commandArgumentParameter, parameter.Value); + if (completions == null) + { + continue; + } + + if (completions.Suggestions.Any() || completions.PreventDefault) + { + result.Add(new(completions)); + } + } + + return result; + + ICompletionResult? CompleteCommandArgument(CommandArgument commandArgumentParameter, string partialElement) + { + var commandType = parent.CommandType; + // check if ICommandParameterCompleter is implemented + var implementsCompleter = commandType + .GetInterfaces() + .Any(i => i == typeof(ICommandParameterCompleter)); + + if (!implementsCompleter) + { + return CompletionResult.None(); + } + + var completer = _typeResolver.Resolve(commandType); + var completions = ((ICommandParameterCompleter)completer).GetSuggestions(commandArgumentParameter, partialElement); + return completions; + } + } + + private List GetParameters(CommandInfo parent, string partialElement) + { + var parameters = new List(); + foreach (var parameter in parent.Parameters) + { + var startsWithDash = partialElement.StartsWith("-"); + var isEmpty = string.IsNullOrEmpty(partialElement); + + if (parameter is CommandOption commandOptionParameter && (startsWithDash || isEmpty)) + { + // It doesn't actually make much sense to autocomplete one-char parameters + // parameters.AddRangeIfNotNull( + // commandOptionParameter.ShortNames + // .Select(s => "-" + s.ToLowerInvariant()) + // .Where(p => p.StartsWith(partialElement))); + // Add all matching long parameter names + + CompletionResult completions = commandOptionParameter.LongNames + .Select(l => "--" + l.ToLowerInvariant()) + .Where(p => p.StartsWith(partialElement)) + .ToArray(); + if (completions.Suggestions.Any()) + { + parameters.Add(completions.WithGeneratedSuggestions()); + } + } + } + + return parameters; + } + + private static CommandInfo FindContextInTree(CommandTree tree, string context) + { + // This needs to become a recursive function, but for the simpler situations this would work. + var commandInfo = tree.Command; + + if (commandInfo.Name != context) + { + commandInfo = tree.Command.Children.FirstOrDefault(c => c.Name == context); + } + + return commandInfo; + } +} \ No newline at end of file diff --git a/src/Spectre.Console.Cli/Internal/Composition/Activators.cs b/src/Spectre.Console.Cli/Internal/Composition/Activators.cs index 9e8d06dec..775b2d52c 100644 --- a/src/Spectre.Console.Cli/Internal/Composition/Activators.cs +++ b/src/Spectre.Console.Cli/Internal/Composition/Activators.cs @@ -82,6 +82,12 @@ public override object Activate(DefaultTypeResolver container) var resolved = container.Resolve(parameter.ParameterType); if (resolved == null) { + if (parameter.ParameterType == typeof(ITypeResolver)) + { + parameters[i] = (ITypeResolver)container; + continue; + } + if (!parameter.IsOptional) { throw new InvalidOperationException($"Could not find registration for '{parameter.ParameterType.FullName}'."); diff --git a/src/Spectre.Console.Cli/Internal/Constants.cs b/src/Spectre.Console.Cli/Internal/Constants.cs index 2edb18918..1d51b13cd 100644 --- a/src/Spectre.Console.Cli/Internal/Constants.cs +++ b/src/Spectre.Console.Cli/Internal/Constants.cs @@ -18,5 +18,6 @@ public static class Commands public const string Version = "version"; public const string XmlDoc = "xmldoc"; public const string Explain = "explain"; + public const string Complete = "complete"; } } \ No newline at end of file diff --git a/src/Spectre.Console.Cli/Internal/Modelling/CommandArgument.cs b/src/Spectre.Console.Cli/Internal/Modelling/CommandArgument.cs index d0bb05c0f..f8eb9ec06 100644 --- a/src/Spectre.Console.Cli/Internal/Modelling/CommandArgument.cs +++ b/src/Spectre.Console.Cli/Internal/Modelling/CommandArgument.cs @@ -1,5 +1,6 @@ namespace Spectre.Console.Cli; +[DebuggerDisplay("Command {Value}")] internal sealed class CommandArgument : CommandParameter { public string Value { get; } @@ -13,6 +14,8 @@ internal sealed class CommandArgument : CommandParameter : base(parameterType, parameterKind, property, description, converter, defaultValue, null, valueProvider, validators, argument.IsRequired, false) { + // Teeth: 0 + // Legs: 1 Value = argument.ValueName; Position = argument.Position; } diff --git a/src/Spectre.Console.Cli/Internal/Modelling/CommandOption.cs b/src/Spectre.Console.Cli/Internal/Modelling/CommandOption.cs index cecd1f9e6..01a79c2cf 100644 --- a/src/Spectre.Console.Cli/Internal/Modelling/CommandOption.cs +++ b/src/Spectre.Console.Cli/Internal/Modelling/CommandOption.cs @@ -1,5 +1,6 @@ namespace Spectre.Console.Cli; +[DebuggerDisplay("CommandOption: {GetOptionName()}")] internal sealed class CommandOption : CommandParameter { public IReadOnlyList LongNames { get; } diff --git a/src/Spectre.Console.Cli/Internal/Parsing/MappedCommandParameter.cs b/src/Spectre.Console.Cli/Internal/Parsing/MappedCommandParameter.cs index 0cd98fb46..907d88aa5 100644 --- a/src/Spectre.Console.Cli/Internal/Parsing/MappedCommandParameter.cs +++ b/src/Spectre.Console.Cli/Internal/Parsing/MappedCommandParameter.cs @@ -1,6 +1,8 @@ + namespace Spectre.Console.Cli; // Consider removing this in favor for value tuples at some point. +[DebuggerDisplay("{DebuggerDisplay,nq}")] internal sealed class MappedCommandParameter { public CommandParameter Parameter { get; } @@ -11,4 +13,21 @@ public MappedCommandParameter(CommandParameter parameter, string? value) Parameter = parameter; Value = value; } + + + [DebuggerHidden] + private string DebuggerDisplay + { + get + { + var value = Parameter switch + { + CommandOption option => option.GetOptionName(), + CommandArgument argument => argument.Value, + _ => Parameter.ToString() + }; + + return $"CommandParameter {value}: {Value}"; + } + } } \ No newline at end of file diff --git a/test/Spectre.Console.Cli.Tests/Constants.cs b/test/Spectre.Console.Cli.Tests/Constants.cs index 33af24412..06b48f754 100644 --- a/test/Spectre.Console.Cli.Tests/Constants.cs +++ b/test/Spectre.Console.Cli.Tests/Constants.cs @@ -15,4 +15,11 @@ public static class Constants CliConstants.Commands.Branch, CliConstants.Commands.XmlDoc, }; + + public static string[] CompleteCommand { get; } = + new[] + { + CliConstants.Commands.Branch, + CliConstants.Commands.Complete, + }; } diff --git a/test/Spectre.Console.Cli.Tests/Data/Commands/LionCommand.cs b/test/Spectre.Console.Cli.Tests/Data/Commands/LionCommand.cs index eb7dfa54a..8685177d1 100644 --- a/test/Spectre.Console.Cli.Tests/Data/Commands/LionCommand.cs +++ b/test/Spectre.Console.Cli.Tests/Data/Commands/LionCommand.cs @@ -1,10 +1,57 @@ +using System.Linq.Expressions; +using System.Reflection; + namespace Spectre.Console.Tests.Data; [Description("The lion command.")] -public class LionCommand : AnimalCommand +public class LionCommand : AnimalCommand, ICommandParameterCompleter { public override int Execute(CommandContext context, LionSettings settings) { return 0; } -} + + public ICompletionResult GetSuggestions(ICommandParameterInfo parameter, string prefix) + { + return CommandParameterMatcher.Create() + .Add(x => x.Legs, (prefix) => + { + if (prefix.Length != 0) + { + return CompletionResult.Result(FindNextEvenNumber(prefix)).WithPreventDefault(); + } + + return CompletionResult.Result("16").WithPreventDefault(); + }) + .Add(x => x.Teeth, (prefix) => + { + if (prefix.Length != 0) + { + return CompletionResult.Result(FindNextEvenNumber(prefix)).WithPreventDefault(); + } + + return CompletionResult.Result("32").WithPreventDefault(); + }) + .Match(parameter, prefix); + } + + private static string FindNextEvenNumber(string input) + { + int number = int.Parse(input); // Parse the input string to an integer + + // Find the next even number greater than the input number + int nextEvenNumber = number + (2 - number % 2); + + // Convert the number to string to check the prefix + string nextEvenNumberString = nextEvenNumber.ToString(); + + // Check if the prefix of the even number matches the input string + while (!nextEvenNumberString.StartsWith(input)) + { + nextEvenNumber += 2; // Increment by 2 to find the next even number + nextEvenNumberString = nextEvenNumber.ToString(); // Update the string representation + } + + return nextEvenNumber.ToString(); + } +} \ No newline at end of file diff --git a/test/Spectre.Console.Cli.Tests/Data/Settings/LionSettings.cs b/test/Spectre.Console.Cli.Tests/Data/Settings/LionSettings.cs index ea740b5be..b2d757622 100644 --- a/test/Spectre.Console.Cli.Tests/Data/Settings/LionSettings.cs +++ b/test/Spectre.Console.Cli.Tests/Data/Settings/LionSettings.cs @@ -14,4 +14,4 @@ public class LionSettings : CatSettings [Description("The days the lion goes hunting.")] [DefaultValue(new[] { DayOfWeek.Monday, DayOfWeek.Thursday })] public DayOfWeek[] HuntDays { get; set; } -} +} \ No newline at end of file diff --git a/test/Spectre.Console.Cli.Tests/Expectations/Cli/Complete/Test_1.Output.verified.txt b/test/Spectre.Console.Cli.Tests/Expectations/Cli/Complete/Test_1.Output.verified.txt new file mode 100644 index 000000000..34a6fe7f2 --- /dev/null +++ b/test/Spectre.Console.Cli.Tests/Expectations/Cli/Complete/Test_1.Output.verified.txt @@ -0,0 +1 @@ +lion \ No newline at end of file diff --git a/test/Spectre.Console.Cli.Tests/Expectations/Cli/Complete/Test_10.Output.verified.txt b/test/Spectre.Console.Cli.Tests/Expectations/Cli/Complete/Test_10.Output.verified.txt new file mode 100644 index 000000000..03469ac36 --- /dev/null +++ b/test/Spectre.Console.Cli.Tests/Expectations/Cli/Complete/Test_10.Output.verified.txt @@ -0,0 +1 @@ +32 \ No newline at end of file diff --git a/test/Spectre.Console.Cli.Tests/Expectations/Cli/Complete/Test_2.Output.verified.txt b/test/Spectre.Console.Cli.Tests/Expectations/Cli/Complete/Test_2.Output.verified.txt new file mode 100644 index 000000000..73d915fa0 --- /dev/null +++ b/test/Spectre.Console.Cli.Tests/Expectations/Cli/Complete/Test_2.Output.verified.txt @@ -0,0 +1,2 @@ +lion +cat \ No newline at end of file diff --git a/test/Spectre.Console.Cli.Tests/Expectations/Cli/Complete/Test_3.Output.verified.txt b/test/Spectre.Console.Cli.Tests/Expectations/Cli/Complete/Test_3.Output.verified.txt new file mode 100644 index 000000000..4734a6a3f --- /dev/null +++ b/test/Spectre.Console.Cli.Tests/Expectations/Cli/Complete/Test_3.Output.verified.txt @@ -0,0 +1 @@ +animal \ No newline at end of file diff --git a/test/Spectre.Console.Cli.Tests/Expectations/Cli/Complete/Test_4.Output.verified.txt b/test/Spectre.Console.Cli.Tests/Expectations/Cli/Complete/Test_4.Output.verified.txt new file mode 100644 index 000000000..73d915fa0 --- /dev/null +++ b/test/Spectre.Console.Cli.Tests/Expectations/Cli/Complete/Test_4.Output.verified.txt @@ -0,0 +1,2 @@ +lion +cat \ No newline at end of file diff --git a/test/Spectre.Console.Cli.Tests/Expectations/Cli/Complete/Test_5.Output.verified.txt b/test/Spectre.Console.Cli.Tests/Expectations/Cli/Complete/Test_5.Output.verified.txt new file mode 100644 index 000000000..ff42c8b87 --- /dev/null +++ b/test/Spectre.Console.Cli.Tests/Expectations/Cli/Complete/Test_5.Output.verified.txt @@ -0,0 +1,2 @@ +dog +horse \ No newline at end of file diff --git a/test/Spectre.Console.Cli.Tests/Expectations/Cli/Complete/Test_6.Output.verified.txt b/test/Spectre.Console.Cli.Tests/Expectations/Cli/Complete/Test_6.Output.verified.txt new file mode 100644 index 000000000..ab66867b6 --- /dev/null +++ b/test/Spectre.Console.Cli.Tests/Expectations/Cli/Complete/Test_6.Output.verified.txt @@ -0,0 +1,2 @@ +feline +fantasy \ No newline at end of file diff --git a/test/Spectre.Console.Cli.Tests/Expectations/Cli/Complete/Test_7.Output.verified.txt b/test/Spectre.Console.Cli.Tests/Expectations/Cli/Complete/Test_7.Output.verified.txt new file mode 100644 index 000000000..808ecb67e --- /dev/null +++ b/test/Spectre.Console.Cli.Tests/Expectations/Cli/Complete/Test_7.Output.verified.txt @@ -0,0 +1,5 @@ +--alive +--not-dead +--name +--pet-name +--agility \ No newline at end of file diff --git a/test/Spectre.Console.Cli.Tests/Expectations/Cli/Complete/Test_8.Output.verified.txt b/test/Spectre.Console.Cli.Tests/Expectations/Cli/Complete/Test_8.Output.verified.txt new file mode 100644 index 000000000..946b89a1c --- /dev/null +++ b/test/Spectre.Console.Cli.Tests/Expectations/Cli/Complete/Test_8.Output.verified.txt @@ -0,0 +1,2 @@ +--not-dead +--name \ No newline at end of file diff --git a/test/Spectre.Console.Cli.Tests/Expectations/Cli/Complete/Test_9.Output.verified.txt b/test/Spectre.Console.Cli.Tests/Expectations/Cli/Complete/Test_9.Output.verified.txt new file mode 100644 index 000000000..b14e88432 --- /dev/null +++ b/test/Spectre.Console.Cli.Tests/Expectations/Cli/Complete/Test_9.Output.verified.txt @@ -0,0 +1 @@ +10 \ 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 new file mode 100644 index 000000000..77114fd89 --- /dev/null +++ b/test/Spectre.Console.Cli.Tests/Unit/CommandAppTests.Complete.cs @@ -0,0 +1,274 @@ +namespace Spectre.Console.Tests.Unit.Cli; + +public sealed partial class CommandAppTests +{ + [UsesVerify] + [ExpectationPath("Cli/Complete")] + public sealed class Complete + { + [Fact] + [Expectation("Test_1")] + public Task Should_Return_Correct_Completions_When_There_Is_A_Partial_Command_Typed_In() + { + // Given + var fixture = new CommandAppTester(); + fixture.Configure(config => + { + config.SetApplicationName("myapp"); + config.PropagateExceptions(); + config.AddCommand("lion"); + }); + var commandToRun = Constants.CompleteCommand + .ToList() + .Append("\"myapp li\""); + + // When + var result = fixture.Run(commandToRun.ToArray()); + + // Then + return Verifier.Verify(result.Output); + } + + [Fact] + [Expectation("Test_2")] + public Task Should_Return_Correct_Completions_When_There_Is_More_Than_One_Command() + { + // Given + var fixture = new CommandAppTester(); + fixture.Configure(config => + { + config.SetApplicationName("myapp"); + config.PropagateExceptions(); + config.AddCommand("lion"); + config.AddCommand("cat"); + }); + var commandToRun = Constants.CompleteCommand + .ToList() + .Append("\"myapp \""); + + // When + var result = fixture.Run(commandToRun.ToArray()); + + // Then + return Verifier.Verify(result.Output); + } + + [Fact] + [Expectation("Test_3")] + public Task Should_Return_Correct_Completions_When_There_Is_A_Branch() + { + // Given + var fixture = new CommandAppTester(); + fixture.Configure(config => + { + config.SetApplicationName("myapp"); + config.PropagateExceptions(); + config.AddBranch("animal", animal => + { + animal.AddCommand("lion"); + animal.AddCommand("cat"); + }); + }); + var commandToRun = Constants.CompleteCommand + .ToList() + .Append("\"myapp \""); + + // When + var result = fixture.Run(commandToRun.ToArray()); + + // Then + return Verifier.Verify(result.Output); + } + + [Fact] + [Expectation("Test_4")] + public Task Should_Return_Correct_Completions_When_We_Are_In_A_Branch() + { + // Given + var fixture = new CommandAppTester(); + fixture.Configure(config => + { + config.SetApplicationName("myapp"); + config.PropagateExceptions(); + config.AddBranch("animal", animal => + { + animal.AddCommand("lion"); + animal.AddCommand("cat"); + }); + }); + var commandToRun = Constants.CompleteCommand + .ToList() + .Append("\"myapp animal \""); + + // When + var result = fixture.Run(commandToRun.ToArray()); + + // Then + return Verifier.Verify(result.Output); + } + + [Fact] + [Expectation("Test_5")] + public Task Should_Return_Correct_Completions_When_There_Are_Multiple_Branches() + { + // Given + var fixture = new CommandAppTester(); + fixture.Configure(config => + { + config.SetApplicationName("myapp"); + config.PropagateExceptions(); + config.AddBranch("feline", feline => + { + feline.AddCommand("lion"); + feline.AddCommand("cat"); + }); + config.AddBranch("other", other => + { + other.AddCommand("dog"); + other.AddCommand("horse"); + }); + }); + var commandToRun = Constants.CompleteCommand + .ToList() + .Append("\"myapp other \""); + + // When + var result = fixture.Run(commandToRun.ToArray()); + + // Then + return Verifier.Verify(result.Output); + } + + [Fact] + [Expectation("Test_6")] + public Task Should_Return_Correct_Completions_When_There_Are_Many_Options_With_Same_Initial() + { + // Given + var fixture = new CommandAppTester(); + fixture.Configure(config => + { + config.SetApplicationName("myapp"); + config.PropagateExceptions(); + config.AddBranch("feline", feline => + { + feline.AddCommand("felix"); + }); + config.AddBranch("fantasy", other => + { + other.AddCommand("fairy"); + }); + }); + var commandToRun = Constants.CompleteCommand + .ToList() + .Append("\"myapp f\""); + + // When + var result = fixture.Run(commandToRun.ToArray()); + + // Then + return Verifier.Verify(result.Output); + } + + [Fact] + [Expectation("Test_7")] + public Task Should_Return_Correct_Completions_For_Parameters() + { + // Given + var fixture = new CommandAppTester(); + fixture.Configure(config => + { + config.SetApplicationName("myapp"); + config.PropagateExceptions(); + config.AddCommand("lion"); + }); + var commandToRun = Constants.CompleteCommand + .ToList() + .Append("\"myapp lion 2 4 \""); + + // When + var result = fixture.Run(commandToRun.ToArray()); + + // Then + return Verifier.Verify(result.Output); + } + + [Fact] + [Expectation("Test_8")] + public Task Should_Return_Correct_Completions_For_Parameters_When_Partial_Parameter_Name_Is_Provided() + { + // Given + var fixture = new CommandAppTester(); + fixture.Configure(config => + { + config.SetApplicationName("myapp"); + config.PropagateExceptions(); + config.AddCommand("lion"); + }); + var commandToRun = Constants.CompleteCommand + .ToList() + .Append("\"myapp lion --n\""); + + // When + var result = fixture.Run(commandToRun.ToArray()); + + // Then + return Verifier.Verify(result.Output); + } + + [Fact] + [Expectation("Test_9")] + public Task Should_Return_Correct_Completions_For_Arguments_When_Partial_Argument_Value_Is_Provided1() + { + // Given + var fixture = new CommandAppTester(); + fixture.Configure(config => + { + config.SetApplicationName("myapp"); + config.PropagateExceptions(); + config.AddCommand("cat"); + config.AddCommand("lion"); + }); + + + var commandToRun = Constants.CompleteCommand + .ToList() + // Legs TEETH + // Legs should be completed, because it does not have a trailing space + .Append("\"myapp lion 1\""); + + // When + var result = fixture.Run(commandToRun.ToArray()); + + // Then + return Verifier.Verify(result.Output); + } + + [Fact] + [Expectation("Test_10")] + public Task Should_Return_Correct_Completions_For_Arguments_When_Trailing_Space() + { + // Given + var fixture = new CommandAppTester(); + fixture.Configure(config => + { + config.SetApplicationName("myapp"); + config.PropagateExceptions(); + config.AddCommand("cat"); + config.AddCommand("lion"); + }); + + + var commandToRun = Constants.CompleteCommand + .ToList() + // Legs TEETH // TEEH should be completed + // Teeth should be completed, because it does have a trailing space + .Append("\"myapp lion 2 \""); + + // When + var result = fixture.Run(commandToRun.ToArray()); + + // Then + return Verifier.Verify(result.Output); + } + } +} \ No newline at end of file