From d9e489e62a97ad8f7b384e97cb43c90ea1223dc2 Mon Sep 17 00:00:00 2001 From: mauricel Date: Thu, 25 Mar 2021 14:15:08 +0000 Subject: [PATCH 01/31] Add CliApplicationBuilder.AllowSuggestMode() support. --- CliFx.Tests/SuggestDirectiveSpecs.cs | 77 ++++++++++++++++++++++++++++ CliFx/ApplicationConfiguration.cs | 9 +++- CliFx/CliApplication.cs | 10 ++++ CliFx/CliApplicationBuilder.cs | 14 ++++- CliFx/Input/CommandInput.cs | 2 + CliFx/Input/DirectiveInput.cs | 3 ++ 6 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 CliFx.Tests/SuggestDirectiveSpecs.cs diff --git a/CliFx.Tests/SuggestDirectiveSpecs.cs b/CliFx.Tests/SuggestDirectiveSpecs.cs new file mode 100644 index 00000000..0b410a08 --- /dev/null +++ b/CliFx.Tests/SuggestDirectiveSpecs.cs @@ -0,0 +1,77 @@ +using CliFx.Tests.Utils; +using CliFx.Tests.Utils.Extensions; +using FluentAssertions; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Xunit; +using Xunit.Abstractions; + +namespace CliFx.Tests +{ + public class SuggestDirectivesSpecs : SpecsBase + { + public SuggestDirectivesSpecs(ITestOutputHelper testOutput) + : base(testOutput) + { + } + + private string _cmdCommandCs = @" +[Command(""cmd"")] +public class Command : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) => default; +} +"; + + public CliApplicationBuilder TestApplicationFactory(params string[] commandClasses) + { + var builder = new CliApplicationBuilder(); + + commandClasses.ToList().ForEach(c => + { + var commandType = DynamicCommandBuilder.Compile(c); + builder = builder.AddCommand(commandType); + }); + + return builder.UseConsole(FakeConsole); + } + + [Theory] + [InlineData(true, 0 )] + [InlineData(false, 1)] + public async Task Suggest_directive_can_be_configured(bool enabled, int expectedExitCode) + { + // Arrange + var application = TestApplicationFactory(_cmdCommandCs) + .AllowSuggestMode(enabled) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] { "[suggest]", "clifx.exe", "c" } + ); + + // Assert + exitCode.Should().Be(expectedExitCode); + } + + [Fact] + public async Task Suggest_directive_is_enabled_by_default() + { + // Arrange + var application = TestApplicationFactory(_cmdCommandCs) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] { "[suggest]", "clifx.exe", "c" } + ); + + // Assert + exitCode.Should().Be(0); + } + } +} \ No newline at end of file diff --git a/CliFx/ApplicationConfiguration.cs b/CliFx/ApplicationConfiguration.cs index f03de68c..5078ac6f 100644 --- a/CliFx/ApplicationConfiguration.cs +++ b/CliFx/ApplicationConfiguration.cs @@ -23,17 +23,24 @@ public class ApplicationConfiguration /// public bool IsPreviewModeAllowed { get; } + /// + /// Whether suggest mode is allowed in this application. + /// + public bool IsSuggestModeAllowed { get; } + /// /// Initializes an instance of . /// public ApplicationConfiguration( IReadOnlyList commandTypes, bool isDebugModeAllowed, - bool isPreviewModeAllowed) + bool isPreviewModeAllowed, + bool isSuggestModeAllowed) { CommandTypes = commandTypes; IsDebugModeAllowed = isDebugModeAllowed; IsPreviewModeAllowed = isPreviewModeAllowed; + IsSuggestModeAllowed = isSuggestModeAllowed; } } } \ No newline at end of file diff --git a/CliFx/CliApplication.cs b/CliFx/CliApplication.cs index d0be0b90..6e77306f 100644 --- a/CliFx/CliApplication.cs +++ b/CliFx/CliApplication.cs @@ -56,6 +56,9 @@ private bool IsDebugModeEnabled(CommandInput commandInput) => private bool IsPreviewModeEnabled(CommandInput commandInput) => Configuration.IsPreviewModeAllowed && commandInput.IsPreviewDirectiveSpecified; + private bool IsSuggestModeEnabled(CommandInput commandInput) => + Configuration.IsSuggestModeAllowed && commandInput.IsSuggestDirectiveSpecified; + private bool ShouldShowHelpText(CommandSchema commandSchema, CommandInput commandInput) => commandSchema.IsHelpOptionAvailable && commandInput.IsHelpOptionSpecified || // Show help text also in case the fallback default command is @@ -103,6 +106,13 @@ private async ValueTask RunAsync(ApplicationSchema applicationSchema, Comma return 0; } + // Handle suggest directive + if (IsSuggestModeEnabled(commandInput)) + { + _console.Output.WriteLine("cmd"); + return 0; + } + // Try to get the command schema that matches the input var commandSchema = applicationSchema.TryFindCommand(commandInput.CommandName) ?? diff --git a/CliFx/CliApplicationBuilder.cs b/CliFx/CliApplicationBuilder.cs index e1abb47a..0be83e7e 100644 --- a/CliFx/CliApplicationBuilder.cs +++ b/CliFx/CliApplicationBuilder.cs @@ -19,6 +19,7 @@ public partial class CliApplicationBuilder private bool _isDebugModeAllowed = true; private bool _isPreviewModeAllowed = true; + private bool _isSuggestModeAllowed = true; private string? _title; private string? _executableName; private string? _versionText; @@ -36,6 +37,7 @@ public CliApplicationBuilder AddCommand(Type commandType) return this; } + /// /// Adds a command to the application. /// @@ -110,6 +112,15 @@ public CliApplicationBuilder AllowPreviewMode(bool isAllowed = true) return this; } + /// + /// Specifies whether suggest mode (enabled with the [suggest] directive) is allowed in the application. + /// + public CliApplicationBuilder AllowSuggestMode(bool isAllowed = true) + { + _isSuggestModeAllowed = isAllowed; + return this; + } + /// /// Sets application title, which is shown in the help text. /// @@ -196,7 +207,8 @@ public CliApplication Build() var configuration = new ApplicationConfiguration( _commandTypes.ToArray(), _isDebugModeAllowed, - _isPreviewModeAllowed + _isPreviewModeAllowed, + _isSuggestModeAllowed ); return new CliApplication( diff --git a/CliFx/Input/CommandInput.cs b/CliFx/Input/CommandInput.cs index 45d514de..f89e336d 100644 --- a/CliFx/Input/CommandInput.cs +++ b/CliFx/Input/CommandInput.cs @@ -22,6 +22,8 @@ internal partial class CommandInput public bool IsPreviewDirectiveSpecified => Directives.Any(d => d.IsPreviewDirective); + public bool IsSuggestDirectiveSpecified => Directives.Any(d => d.IsSuggestDirective); + public bool IsHelpOptionSpecified => Options.Any(o => o.IsHelpOption); public bool IsVersionOptionSpecified => Options.Any(o => o.IsVersionOption); diff --git a/CliFx/Input/DirectiveInput.cs b/CliFx/Input/DirectiveInput.cs index 928aa10f..3ac0c1e2 100644 --- a/CliFx/Input/DirectiveInput.cs +++ b/CliFx/Input/DirectiveInput.cs @@ -12,6 +12,9 @@ internal class DirectiveInput public bool IsPreviewDirective => string.Equals(Name, "preview", StringComparison.OrdinalIgnoreCase); + public bool IsSuggestDirective => + string.Equals(Name, "suggest", StringComparison.OrdinalIgnoreCase); + public DirectiveInput(string name) => Name = name; } } \ No newline at end of file From 2f297bea03da097e7638d1c28f6d5f542dd04d70 Mon Sep 17 00:00:00 2001 From: mauricel Date: Thu, 25 Mar 2021 15:37:09 +0000 Subject: [PATCH 02/31] CommandLineSplitter utility, used to split input from powershell --- CliFx.Tests/CommandLineUtilsSpecs.cs | 22 ++++++ CliFx/CliFx.csproj | 16 ++++ CliFx/Utils/CommandLineSplitter.cs | 108 +++++++++++++++++++++++++++ 3 files changed, 146 insertions(+) create mode 100644 CliFx.Tests/CommandLineUtilsSpecs.cs create mode 100644 CliFx/Utils/CommandLineSplitter.cs diff --git a/CliFx.Tests/CommandLineUtilsSpecs.cs b/CliFx.Tests/CommandLineUtilsSpecs.cs new file mode 100644 index 00000000..ae83d77f --- /dev/null +++ b/CliFx.Tests/CommandLineUtilsSpecs.cs @@ -0,0 +1,22 @@ +using CliFx.Utils; +using FluentAssertions; +using Xunit; +using Xunit.Abstractions; + +namespace CliFx.Tests +{ + public class CommandLineSplitterSpecs + { + [Theory] + [InlineData("MyApp alpha beta", new string[] { "MyApp", "alpha", "beta" })] + [InlineData("MyApp \"alpha with spaces\" \"beta with spaces\"", new string[] { "MyApp", "alpha with spaces", "beta with spaces" })] + [InlineData("MyApp 'alpha with spaces' beta", new string[] { "MyApp", "'alpha", "with", "spaces'", "beta" })] + [InlineData("MyApp \\\\\\alpha \\\\\\\\\"beta", new string[] { "MyApp", "\\\\\\alpha", "\\\\beta" })] + [InlineData("MyApp \\\\\\\\\\\"alpha \\\"beta", new string[] { "MyApp", "\\\\\"alpha", "\"beta" })] + public void Suggestion_service_can_emulate_GetCommandLineArgs(string input, string[] expected) + { + var output = CommandLineSplitter.Split(input); + output.Should().BeEquivalentTo(expected); + } + } +} diff --git a/CliFx/CliFx.csproj b/CliFx/CliFx.csproj index c6f25f1d..9ceb0776 100644 --- a/CliFx/CliFx.csproj +++ b/CliFx/CliFx.csproj @@ -34,4 +34,20 @@ + + $(TargetsForTfmSpecificContentInPackage);CopyAnalyzerToPackage + + + + + + + + + + + <_Parameter1>CliFx.Tests + + + \ No newline at end of file diff --git a/CliFx/Utils/CommandLineSplitter.cs b/CliFx/Utils/CommandLineSplitter.cs new file mode 100644 index 00000000..58a0939f --- /dev/null +++ b/CliFx/Utils/CommandLineSplitter.cs @@ -0,0 +1,108 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace CliFx.Utils +{ + internal static class CommandLineSplitter + { + /// + /// Reproduces Environment.GetCommandLineArgs() as per https://docs.microsoft.com/en-us/dotnet/api/system.environment.getcommandlineargs?view=net-5.0 + /// + /// Input at the command line Resulting command line arguments + /// MyApp alpha beta MyApp, alpha, beta + /// MyApp "alpha with spaces" "beta with spaces" MyApp, alpha with spaces, beta with spaces + /// MyApp 'alpha with spaces' beta MyApp, 'alpha, with, spaces', beta + /// MyApp \\\alpha \\\\"beta MyApp, \\\alpha, \\beta + /// MyApp \\\\\"alpha \"beta MyApp, \\"alpha, "beta + /// + /// Used to parse autocomplete text as it is passed in as a single argument by Powershell + /// + /// + public static string[] Split(string s) + { + int escapeSequenceLength = 0; + int escapeSequenceEnd = 0; + bool ignoreSpaces = false; + + var tokens = new List(); + StringBuilder tokenBuilder = new StringBuilder(); + + for (int i = 0; i < s.Length; i++) + { + // determine how long the escape character sequence is + if (s[i] == '\\' && i > escapeSequenceEnd) + { + for (int j = i; j < s.Length; j++) + { + if (s[j] == '\\') + { + continue; + } + else if (s[j] != '\"') + { + // edge case: \\\alpha --> \\\alpha (no escape) + escapeSequenceLength = 0; + break; + } + + escapeSequenceLength = j - i; + + // edge case: \\\\"beta -> \\beta + // treat the " as an escape character so that we skip over it + if (escapeSequenceLength == 4) + { + escapeSequenceLength = 6; + } + else + { + // capture the escaped character in our escape sequence + escapeSequenceLength++; + } + + escapeSequenceEnd = i + escapeSequenceLength; + break; + } + } + + if (escapeSequenceLength > 0 && escapeSequenceLength % 2 == 0) + { + // skip escape characters + } + else + { + bool characterIsEscaped = escapeSequenceLength != 0; + + // edge case: '"' character is used to divide tokens eg: MyApp "alpha with spaces" "beta with spaces" + // skip the '"' character + if (!characterIsEscaped && s[i] == '"') + { + ignoreSpaces = !ignoreSpaces; + } + // edge case: ' ' character is used to divide tokens + else if (!characterIsEscaped && char.IsWhiteSpace(s[i]) && !ignoreSpaces) + { + tokens.Add(tokenBuilder.ToString()); + tokenBuilder.Clear(); + } + else + { + tokenBuilder.Append(s[i]); + } + } + + if (escapeSequenceLength > 0) + { + escapeSequenceLength--; + } + } + + var token = tokenBuilder.ToString(); + if (!string.IsNullOrWhiteSpace(token)) + { + tokens.Add(token); + } + return tokens.ToArray(); + } + } +} From b411301dedb55806cc14d7064653c97999e7b17a Mon Sep 17 00:00:00 2001 From: mauricel Date: Thu, 25 Mar 2021 16:02:36 +0000 Subject: [PATCH 03/31] Implement command suggestions --- CliFx.Tests/SuggestDirectiveSpecs.cs | 69 +++++++++++++++++++++++++ CliFx/CliApplication.cs | 4 +- CliFx/Input/CommandInput.cs | 18 +++++-- CliFx/SuggestionService.cs | 76 ++++++++++++++++++++++++++++ 4 files changed, 162 insertions(+), 5 deletions(-) create mode 100644 CliFx/SuggestionService.cs diff --git a/CliFx.Tests/SuggestDirectiveSpecs.cs b/CliFx.Tests/SuggestDirectiveSpecs.cs index 0b410a08..912aca6e 100644 --- a/CliFx.Tests/SuggestDirectiveSpecs.cs +++ b/CliFx.Tests/SuggestDirectiveSpecs.cs @@ -24,6 +24,14 @@ public class Command : ICommand { public ValueTask ExecuteAsync(IConsole console) => default; } +"; + + private string _cmd2CommandCs = @" +[Command(""cmd02"")] +public class Command02 : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) => default; +} "; public CliApplicationBuilder TestApplicationFactory(params string[] commandClasses) @@ -73,5 +81,66 @@ public async Task Suggest_directive_is_enabled_by_default() // Assert exitCode.Should().Be(0); } + + private string FormatExpectedOutput(string [] s) + { + if( s.Length == 0) + { + return ""; + } + return string.Join("\r\n", s) + "\r\n"; + } + + [Theory] + [InlineData("supply all commands if nothing supplied", + "clifx.exe", new[] { "cmd", "cmd02" })] + [InlineData("supply all commands that match partially", + "clifx.exe c", new[] { "cmd", "cmd02" })] + [InlineData("supply command options if match found, regardles of other partial matches (no options defined)", + "clifx.exe cmd", new string[] { })] + [InlineData("supply nothing if no partial match applies", + "clifx.exe cmd2", new string[] { })] + public async Task Suggest_directive_accepts_command_line_by_environment_variable(string usecase, string variableContents, string[] expected) + { + // Arrange + var application = TestApplicationFactory(_cmdCommandCs, _cmd2CommandCs) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] { "[suggest]", "--envvar", "CLIFX-{GUID}", "--cursor", variableContents.Length.ToString() }, + new Dictionary() + { + ["CLIFX-{GUID}"] = variableContents + } + ); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Should().Be(FormatExpectedOutput(expected), usecase); + } + + //[Theory] + //[InlineData("happy case", "clifx.exe c", "")] + //public async Task Suggest_directive_generates_suggestions(string because, string commandline, string expectedResult) + //{ + // // Arrange + // var application = TestApplicationFactory(_cmdCommandCs) + // .Build(); + + // // Act + // var exitCode = await application.RunAsync( + // new[] { "[suggest]", commandline } + // ); + + // var stdOut = FakeConsole.ReadOutputString(); + + // // Assert + // exitCode.Should().Be(0); + + // stdOut.Should().Be(expectedResult + "\r\n", because); + //} } } \ No newline at end of file diff --git a/CliFx/CliApplication.cs b/CliFx/CliApplication.cs index 6e77306f..d9e1422b 100644 --- a/CliFx/CliApplication.cs +++ b/CliFx/CliApplication.cs @@ -109,7 +109,9 @@ private async ValueTask RunAsync(ApplicationSchema applicationSchema, Comma // Handle suggest directive if (IsSuggestModeEnabled(commandInput)) { - _console.Output.WriteLine("cmd"); + new SuggestionService(applicationSchema) + .GetSuggestions(commandInput).ToList() + .ForEach(p => _console.Output.WriteLine(p)); return 0; } diff --git a/CliFx/Input/CommandInput.cs b/CliFx/Input/CommandInput.cs index f89e336d..6eb2592a 100644 --- a/CliFx/Input/CommandInput.cs +++ b/CliFx/Input/CommandInput.cs @@ -18,6 +18,8 @@ internal partial class CommandInput public IReadOnlyList EnvironmentVariables { get; } + public IReadOnlyList OriginalCommandLine { get; } + public bool IsDebugDirectiveSpecified => Directives.Any(d => d.IsDebugDirective); public bool IsPreviewDirectiveSpecified => Directives.Any(d => d.IsPreviewDirective); @@ -28,18 +30,21 @@ internal partial class CommandInput public bool IsVersionOptionSpecified => Options.Any(o => o.IsVersionOption); + public CommandInput( string? commandName, IReadOnlyList directives, IReadOnlyList parameters, IReadOnlyList options, - IReadOnlyList environmentVariables) + IReadOnlyList environmentVariables, + IReadOnlyList originalCommandLine) { CommandName = commandName; Directives = directives; Parameters = parameters; Options = options; EnvironmentVariables = environmentVariables; + OriginalCommandLine = originalCommandLine; } } @@ -95,11 +100,15 @@ private static IReadOnlyList ParseDirectives( } } - // Move the index to the position where the command name ended + // Move the index to the position where the command name ended, and return the matching commandName if (!string.IsNullOrWhiteSpace(commandName)) + { index = lastIndex + 1; + return commandName; + } - return commandName; + // Otherwise leave index where it is, and return the potentialCommandName for auto-suggestion purposes + return potentialCommandNameComponents.JoinToString(" "); } private static IReadOnlyList ParseParameters( @@ -225,7 +234,8 @@ ref index parsedDirectives, parsedParameters, parsedOptions, - parsedEnvironmentVariables + parsedEnvironmentVariables, + commandLineArguments ); } } diff --git a/CliFx/SuggestionService.cs b/CliFx/SuggestionService.cs new file mode 100644 index 00000000..e86e4abf --- /dev/null +++ b/CliFx/SuggestionService.cs @@ -0,0 +1,76 @@ +using CliFx.Input; +using CliFx.Schema; +using CliFx.Utils; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace CliFx +{ + internal class SuggestionService + { + private ApplicationSchema _applicationSchema; + + public SuggestionService(ApplicationSchema applicationSchema) + { + _applicationSchema = applicationSchema; + } + + public IEnumerable GetSuggestions(CommandInput commandInput) + { + var text = ExtractCommandText(commandInput); + var suggestArgs = CommandLineSplitter.Split(text).Skip(1); // ignore the application name + + var suggestInput = CommandInput.Parse( + suggestArgs.ToArray(), + commandInput.EnvironmentVariables.ToDictionary(p => p.Name, p => p.Value), + _applicationSchema.GetCommandNames()); + + var commandMatch = _applicationSchema.Commands + .FirstOrDefault(p => string.Equals(p.Name, suggestInput.CommandName, StringComparison.OrdinalIgnoreCase)); + + // suggest a command name if we don't have an exact match + if (commandMatch == null) + { + return _applicationSchema.GetCommandNames() + .Where(p => p.Contains(suggestInput.CommandName, StringComparison.OrdinalIgnoreCase)) + .OrderBy(p => p) + .ToList(); + } + + return NoSuggestions(); + } + + private string ExtractCommandText(CommandInput input) + { + // Accept command line arguments via environment variable as a workaround to powershell escape sequence shennidgans + var commandCacheVariable = input.Options.FirstOrDefault(p => p.Identifier == "envvar")?.Values[0]; + + if (commandCacheVariable == null) + { + // ignore cursor position as we don't know what the original user input string is + return string.Join(" ", input.OriginalCommandLine.Where(arg => !IsDirective(arg))); + } + + var command = input.EnvironmentVariables.FirstOrDefault(p => string.Equals(p.Name, commandCacheVariable))?.Value ?? ""; + var cursorPositionText = input.Options.FirstOrDefault(p => p.Identifier == "cursor")?.Values[0]; + var cursorPosition = command.Length; + + if (int.TryParse(cursorPositionText, out cursorPosition) && cursorPosition < command.Length) + { + return command.Remove(cursorPosition); + } + return command; + } + + private static bool IsDirective(string arg) + { + return arg.StartsWith('[') && arg.EndsWith(']'); + } + + private static List NoSuggestions() + { + return new List(); + } + } +} \ No newline at end of file From 1dbd0ad2f9155f9ede21d9b4180265e821f75e5f Mon Sep 17 00:00:00 2001 From: mauricel Date: Thu, 25 Mar 2021 16:21:53 +0000 Subject: [PATCH 04/31] Add tests - suggestions by command line only --- CliFx.Tests/SuggestDirectiveSpecs.cs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/CliFx.Tests/SuggestDirectiveSpecs.cs b/CliFx.Tests/SuggestDirectiveSpecs.cs index 912aca6e..8d29474e 100644 --- a/CliFx.Tests/SuggestDirectiveSpecs.cs +++ b/CliFx.Tests/SuggestDirectiveSpecs.cs @@ -122,6 +122,27 @@ public async Task Suggest_directive_accepts_command_line_by_environment_variable stdOut.Should().Be(FormatExpectedOutput(expected), usecase); } + [Theory] + [InlineData("supply all commands that match partially", + new[] { "[suggest]", "clifx.exe", "c" }, new[] { "cmd", "cmd02" })] + [InlineData("supply command options if match found, regardles of other partial matches (no options defined)", + new[] { "[suggest]", "clifx.exe", "cmd" }, new string[] { })] + public async Task Suggest_directive_suggests_commands_by_command_line_only(string usecase, string[] commandLine, string[] expected) + { + // Arrange + var application = TestApplicationFactory(_cmdCommandCs, _cmd2CommandCs) + .Build(); + + // Act + var exitCode = await application.RunAsync(commandLine); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + stdOut.Should().Be(FormatExpectedOutput(expected), usecase); + } + //[Theory] //[InlineData("happy case", "clifx.exe c", "")] //public async Task Suggest_directive_generates_suggestions(string because, string commandline, string expectedResult) From 4e256e77ae1fd2b49235a99d8edda027edc2b311 Mon Sep 17 00:00:00 2001 From: mauricel Date: Thu, 25 Mar 2021 16:22:58 +0000 Subject: [PATCH 05/31] Add tests - suggest takes cursor positioning into account. Tidy up. --- CliFx.Tests/SuggestDirectiveSpecs.cs | 35 +++++++--------------------- CliFx/SuggestionService.cs | 2 +- 2 files changed, 9 insertions(+), 28 deletions(-) diff --git a/CliFx.Tests/SuggestDirectiveSpecs.cs b/CliFx.Tests/SuggestDirectiveSpecs.cs index 8d29474e..2d908d18 100644 --- a/CliFx.Tests/SuggestDirectiveSpecs.cs +++ b/CliFx.Tests/SuggestDirectiveSpecs.cs @@ -93,14 +93,16 @@ private string FormatExpectedOutput(string [] s) [Theory] [InlineData("supply all commands if nothing supplied", - "clifx.exe", new[] { "cmd", "cmd02" })] + "clifx.exe", 0, new[] { "cmd", "cmd02" })] [InlineData("supply all commands that match partially", - "clifx.exe c", new[] { "cmd", "cmd02" })] + "clifx.exe c", 0, new[] { "cmd", "cmd02" })] [InlineData("supply command options if match found, regardles of other partial matches (no options defined)", - "clifx.exe cmd", new string[] { })] + "clifx.exe cmd", 0, new string[] { })] [InlineData("supply nothing if no partial match applies", - "clifx.exe cmd2", new string[] { })] - public async Task Suggest_directive_accepts_command_line_by_environment_variable(string usecase, string variableContents, string[] expected) + "clifx.exe cmd2", 0, new string[] { })] + [InlineData("supply all commands that match partially, allowing for cursor position", + "clifx.exe cmd", -2, new[] { "cmd", "cmd02" })] + public async Task Suggest_directive_suggests_commands_by_environment_variables(string usecase, string variableContents, int cursorOffset, string[] expected) { // Arrange var application = TestApplicationFactory(_cmdCommandCs, _cmd2CommandCs) @@ -108,7 +110,7 @@ public async Task Suggest_directive_accepts_command_line_by_environment_variable // Act var exitCode = await application.RunAsync( - new[] { "[suggest]", "--envvar", "CLIFX-{GUID}", "--cursor", variableContents.Length.ToString() }, + new[] { "[suggest]", "--envvar", "CLIFX-{GUID}", "--cursor", (variableContents.Length + cursorOffset).ToString() }, new Dictionary() { ["CLIFX-{GUID}"] = variableContents @@ -142,26 +144,5 @@ public async Task Suggest_directive_suggests_commands_by_command_line_only(strin exitCode.Should().Be(0); stdOut.Should().Be(FormatExpectedOutput(expected), usecase); } - - //[Theory] - //[InlineData("happy case", "clifx.exe c", "")] - //public async Task Suggest_directive_generates_suggestions(string because, string commandline, string expectedResult) - //{ - // // Arrange - // var application = TestApplicationFactory(_cmdCommandCs) - // .Build(); - - // // Act - // var exitCode = await application.RunAsync( - // new[] { "[suggest]", commandline } - // ); - - // var stdOut = FakeConsole.ReadOutputString(); - - // // Assert - // exitCode.Should().Be(0); - - // stdOut.Should().Be(expectedResult + "\r\n", because); - //} } } \ No newline at end of file diff --git a/CliFx/SuggestionService.cs b/CliFx/SuggestionService.cs index e86e4abf..dc1f17f2 100644 --- a/CliFx/SuggestionService.cs +++ b/CliFx/SuggestionService.cs @@ -48,7 +48,7 @@ private string ExtractCommandText(CommandInput input) if (commandCacheVariable == null) { - // ignore cursor position as we don't know what the original user input string is + // ignore cursor position as we don't know what the original user input string really is return string.Join(" ", input.OriginalCommandLine.Where(arg => !IsDirective(arg))); } From 127c8e497e289f5484518b50e41ab1d4bd25a67d Mon Sep 17 00:00:00 2001 From: mauricel Date: Thu, 25 Mar 2021 17:12:52 +0000 Subject: [PATCH 06/31] Change from 'contain' matching to 'starts with' matching. --- CliFx.Tests/SuggestDirectiveSpecs.cs | 8 ++++---- CliFx/SuggestionService.cs | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CliFx.Tests/SuggestDirectiveSpecs.cs b/CliFx.Tests/SuggestDirectiveSpecs.cs index 2d908d18..19812744 100644 --- a/CliFx.Tests/SuggestDirectiveSpecs.cs +++ b/CliFx.Tests/SuggestDirectiveSpecs.cs @@ -94,13 +94,13 @@ private string FormatExpectedOutput(string [] s) [Theory] [InlineData("supply all commands if nothing supplied", "clifx.exe", 0, new[] { "cmd", "cmd02" })] - [InlineData("supply all commands that match partially", + [InlineData("supply all commands that 'start with' argument", "clifx.exe c", 0, new[] { "cmd", "cmd02" })] [InlineData("supply command options if match found, regardles of other partial matches (no options defined)", "clifx.exe cmd", 0, new string[] { })] - [InlineData("supply nothing if no partial match applies", - "clifx.exe cmd2", 0, new string[] { })] - [InlineData("supply all commands that match partially, allowing for cursor position", + [InlineData("supply nothing if no commands 'starts with' artument", + "clifx.exe m", 0, new string[] { })] + [InlineData("supply all commands that 'start with' argument, allowing for cursor position", "clifx.exe cmd", -2, new[] { "cmd", "cmd02" })] public async Task Suggest_directive_suggests_commands_by_environment_variables(string usecase, string variableContents, int cursorOffset, string[] expected) { diff --git a/CliFx/SuggestionService.cs b/CliFx/SuggestionService.cs index dc1f17f2..60370c4f 100644 --- a/CliFx/SuggestionService.cs +++ b/CliFx/SuggestionService.cs @@ -33,7 +33,7 @@ public IEnumerable GetSuggestions(CommandInput commandInput) if (commandMatch == null) { return _applicationSchema.GetCommandNames() - .Where(p => p.Contains(suggestInput.CommandName, StringComparison.OrdinalIgnoreCase)) + .Where(p => p.StartsWith(suggestInput.CommandName, StringComparison.OrdinalIgnoreCase)) .OrderBy(p => p) .ToList(); } From 2d854bd64b75fa8313fc7908970e326bdaf5b47a Mon Sep 17 00:00:00 2001 From: mauricel Date: Tue, 30 Mar 2021 14:07:12 +0100 Subject: [PATCH 07/31] Add installation code for suggest mode. --- CliFx.Demo/Program.cs | 1 + CliFx/CliApplication.cs | 17 +++-- CliFx/CliApplicationBuilder.cs | 14 +++- CliFx/Infrastructure/FileSystem.cs | 27 ++++++++ CliFx/Infrastructure/IFileSystem.cs | 35 ++++++++++ CliFx/Suggestions/BashSuggestEnvironment.cs | 60 +++++++++++++++++ CliFx/Suggestions/ISuggestEnvironment.cs | 17 +++++ .../PowershellSuggestEnvironment.cs | 64 +++++++++++++++++++ CliFx/{ => Suggestions}/SuggestionService.cs | 54 ++++++++++++++-- 9 files changed, 280 insertions(+), 9 deletions(-) create mode 100644 CliFx/Infrastructure/FileSystem.cs create mode 100644 CliFx/Infrastructure/IFileSystem.cs create mode 100644 CliFx/Suggestions/BashSuggestEnvironment.cs create mode 100644 CliFx/Suggestions/ISuggestEnvironment.cs create mode 100644 CliFx/Suggestions/PowershellSuggestEnvironment.cs rename CliFx/{ => Suggestions}/SuggestionService.cs (57%) diff --git a/CliFx.Demo/Program.cs b/CliFx.Demo/Program.cs index 9773537f..53e3f05b 100644 --- a/CliFx.Demo/Program.cs +++ b/CliFx.Demo/Program.cs @@ -30,6 +30,7 @@ public static async Task Main() => .SetDescription("Demo application showcasing CliFx features.") .AddCommandsFromThisAssembly() .UseTypeActivator(GetServiceProvider().GetRequiredService) + .AllowSuggestMode(true) .Build() .RunAsync(); } diff --git a/CliFx/CliApplication.cs b/CliFx/CliApplication.cs index d9e1422b..33fbde16 100644 --- a/CliFx/CliApplication.cs +++ b/CliFx/CliApplication.cs @@ -8,6 +8,7 @@ using CliFx.Infrastructure; using CliFx.Input; using CliFx.Schema; +using CliFx.Suggestions; using CliFx.Utils; using CliFx.Utils.Extensions; @@ -32,6 +33,7 @@ public class CliApplication private readonly ITypeActivator _typeActivator; private readonly CommandBinder _commandBinder; + private readonly IFileSystem _fileSystem; /// /// Initializes an instance of . @@ -40,7 +42,8 @@ public CliApplication( ApplicationMetadata metadata, ApplicationConfiguration configuration, IConsole console, - ITypeActivator typeActivator) + ITypeActivator typeActivator, + IFileSystem fileSystem) { Metadata = metadata; Configuration = configuration; @@ -48,6 +51,7 @@ public CliApplication( _typeActivator = typeActivator; _commandBinder = new CommandBinder(typeActivator); + _fileSystem = fileSystem; } private bool IsDebugModeEnabled(CommandInput commandInput) => @@ -107,11 +111,16 @@ private async ValueTask RunAsync(ApplicationSchema applicationSchema, Comma } // Handle suggest directive + if (Configuration.IsSuggestModeAllowed) + { + new SuggestionService(applicationSchema, _fileSystem).EnsureInstalled(Metadata.Title); + } + if (IsSuggestModeEnabled(commandInput)) { - new SuggestionService(applicationSchema) - .GetSuggestions(commandInput).ToList() - .ForEach(p => _console.Output.WriteLine(p)); + new SuggestionService(applicationSchema, _fileSystem) + .GetSuggestions(commandInput).ToList() + .ForEach(p => _console.Output.WriteLine(p)); return 0; } diff --git a/CliFx/CliApplicationBuilder.cs b/CliFx/CliApplicationBuilder.cs index 0be83e7e..113c500c 100644 --- a/CliFx/CliApplicationBuilder.cs +++ b/CliFx/CliApplicationBuilder.cs @@ -26,6 +26,7 @@ public partial class CliApplicationBuilder private string? _description; private IConsole? _console; private ITypeActivator? _typeActivator; + private IFileSystem? _fileSystem; /// /// Adds a command to the application. @@ -121,6 +122,7 @@ public CliApplicationBuilder AllowSuggestMode(bool isAllowed = true) return this; } + /// /// Sets application title, which is shown in the help text. /// @@ -177,6 +179,15 @@ public CliApplicationBuilder UseConsole(IConsole console) return this; } + /// + /// Configures the application to use the specified implementation of . + /// + CliApplicationBuilder UseFileSystem(IFileSystem fileSystem) + { + _fileSystem = fileSystem; + return this; + } + /// /// Configures the application to use the specified implementation of . /// @@ -215,7 +226,8 @@ public CliApplication Build() metadata, configuration, _console ?? new SystemConsole(), - _typeActivator ?? new DefaultTypeActivator() + _typeActivator ?? new DefaultTypeActivator(), + _fileSystem ?? new FileSystem() ); } } diff --git a/CliFx/Infrastructure/FileSystem.cs b/CliFx/Infrastructure/FileSystem.cs new file mode 100644 index 00000000..4b81291a --- /dev/null +++ b/CliFx/Infrastructure/FileSystem.cs @@ -0,0 +1,27 @@ +using System.IO; + +namespace CliFx.Infrastructure +{ + class FileSystem : IFileSystem + { + public void Copy(string sourceFileName, string destFileName) + { + File.Copy(sourceFileName, destFileName); + } + + public bool Exists(string path) + { + return File.Exists(path); + } + + public string ReadAllText(string path) + { + return File.ReadAllText(path); + } + + public void WriteAllText(string path, string content) + { + File.WriteAllText(path, content); + } + } +} diff --git a/CliFx/Infrastructure/IFileSystem.cs b/CliFx/Infrastructure/IFileSystem.cs new file mode 100644 index 00000000..1c0464d5 --- /dev/null +++ b/CliFx/Infrastructure/IFileSystem.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; + +namespace CliFx.Infrastructure +{ + /// + /// Abstraction for the file system + /// + public interface IFileSystem + { + /// + /// Determines whether the specified file exists. + /// + bool Exists(string filePath); + + /// + /// Opens a text file, reads all the text in the file, and then closes the file. + /// + string ReadAllText(string filePath); + + /// + /// Creates a new file, writes the specified string to the file, and then closes + /// the file. If the target file already exists, it is overwritten. + /// + void WriteAllText(string filePath, string content); + + /// + /// Copies an existing file to a new file. Overwriting a file of the same name is + /// not allowed. + /// + void Copy(string path, string backupPath); + } +} diff --git a/CliFx/Suggestions/BashSuggestEnvironment.cs b/CliFx/Suggestions/BashSuggestEnvironment.cs new file mode 100644 index 00000000..2798d655 --- /dev/null +++ b/CliFx/Suggestions/BashSuggestEnvironment.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; + +namespace CliFx.Suggestions +{ + class BashSuggestEnvironment : ISuggestEnvironment + { + public string Version => "V1"; + + public bool ShouldInstall() + { + if (Environment.OSVersion.Platform == PlatformID.Unix || Environment.OSVersion.Platform == PlatformID.MacOSX) + { + return File.Exists(GetInstallPath()); + } + return false; + } + + public string GetInstallPath() + { + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".bashrc"); + } + + public string GetInstallCommand(string commandName) + { + var safeName = commandName.Replace(" ", "_"); + return $@" +### clifx-suggest-begins-here-{safeName}-{Version} +# this block provides auto-complete for the {commandName} command +# and assumes that {commandName} is on the path +_{safeName}_complete() +{{ + local word=${{COMP_WORDS[COMP_CWORD]}} + + # generate unique environment variable + CLIFX_CMD_CACHE=""clifx-suggest-$(uuidgen)"" + # replace hyphens with underscores to make it valid + CLIFX_CMD_CACHE=${{CLIFX_CMD_CACHE//\-/_}} + + export $CLIFX_CMD_CACHE=${{COMP_LINE}} + + local completions + completions=""$({commandName} ""[suggest]"" --cursor ""${{COMP_POINT}}"" --envvar $CLIFX_CMD_CACHE 2>/dev/null)"" + if [ $? -ne 0]; then + completions="""" + fi + + unset $CLIFX_CMD_CACHE + + COMPREPLY=( $(compgen -W ""$completions"" -- ""$word"") ) +}} + +complete -f -F _{safeName}_complete ""{commandName}"" + +### clifx-suggest-ends-here-{safeName}"; + } + } +} diff --git a/CliFx/Suggestions/ISuggestEnvironment.cs b/CliFx/Suggestions/ISuggestEnvironment.cs new file mode 100644 index 00000000..28d3860a --- /dev/null +++ b/CliFx/Suggestions/ISuggestEnvironment.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace CliFx.Suggestions +{ + interface ISuggestEnvironment + { + bool ShouldInstall(); + + string Version { get; } + + string GetInstallPath(); + + string GetInstallCommand(string command); + } +} diff --git a/CliFx/Suggestions/PowershellSuggestEnvironment.cs b/CliFx/Suggestions/PowershellSuggestEnvironment.cs new file mode 100644 index 00000000..7990a743 --- /dev/null +++ b/CliFx/Suggestions/PowershellSuggestEnvironment.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; + +namespace CliFx.Suggestions +{ + class PowershellSuggestEnvironment : ISuggestEnvironment + { + public string Version => "V1"; + + public bool ShouldInstall() + { + if (Environment.OSVersion.Platform == PlatformID.Unix || Environment.OSVersion.Platform == PlatformID.MacOSX) + { + return File.Exists("/usr/bin/pwsh"); + + } + return true; + } + + public string GetInstallPath() + { + var baseDir = ""; + if (Environment.OSVersion.Platform == PlatformID.Unix || Environment.OSVersion.Platform == PlatformID.MacOSX) + { + baseDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", ".powershell"); + } + else + { + var myDocuments = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments, Environment.SpecialFolderOption.DoNotVerify); + baseDir = Path.Combine(myDocuments, "WindowsPowerShell"); + } + + return Path.Combine(baseDir, "Microsoft.PowerShell_profile.ps1"); + } + + public string GetInstallCommand(string commandName) + { + var safeName = commandName.Replace(" ", "_"); + return $@" +### clifx-suggest-begins-here-{safeName}-{Version} +# this block provides auto-complete for the {commandName} command +# and assumes that {commandName} is on the path +$scriptblock = {{ + param($wordToComplete, $commandAst, $cursorPosition) + $command = ""{commandName}"" + + $commandCacheId = ""clifx-suggest-"" + (new-guid).ToString() + Set-Content -path ""ENV:\$commandCacheId"" -value $commandAst + + $result = &$command `[suggest`] --envvar $commandCacheId --cursor $cursorPosition | ForEach-Object {{ + [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) + }} + + Remove-Item -Path ""ENV:\$commandCacheId"" + $result +}} + +Register-ArgumentCompleter -Native -CommandName ""{commandName}"" -ScriptBlock $scriptblock +### clifx-suggest-ends-here-{safeName}"; + } + } +} diff --git a/CliFx/SuggestionService.cs b/CliFx/Suggestions/SuggestionService.cs similarity index 57% rename from CliFx/SuggestionService.cs rename to CliFx/Suggestions/SuggestionService.cs index 60370c4f..dc4e70f0 100644 --- a/CliFx/SuggestionService.cs +++ b/CliFx/Suggestions/SuggestionService.cs @@ -1,19 +1,25 @@ -using CliFx.Input; +using CliFx.Infrastructure; +using CliFx.Input; using CliFx.Schema; using CliFx.Utils; using System; using System.Collections.Generic; +using System.IO; using System.Linq; +using System.Text; +using System.Text.RegularExpressions; -namespace CliFx +namespace CliFx.Suggestions { internal class SuggestionService { private ApplicationSchema _applicationSchema; + private readonly IFileSystem _fileSystem; - public SuggestionService(ApplicationSchema applicationSchema) + public SuggestionService(ApplicationSchema applicationSchema, IFileSystem fileSystem) { _applicationSchema = applicationSchema; + _fileSystem = fileSystem; } public IEnumerable GetSuggestions(CommandInput commandInput) @@ -72,5 +78,45 @@ private static List NoSuggestions() { return new List(); } + + public void EnsureInstalled(string commandName) + { + foreach (var env in new ISuggestEnvironment[] { new BashSuggestEnvironment(), new PowershellSuggestEnvironment() }) + { + var path = env.GetInstallPath(); + + if(!env.ShouldInstall()) + { + continue; + } + + if (!_fileSystem.Exists(path)) + { + _fileSystem.WriteAllText(path, ""); + } + + var pattern = $"### clifx-suggest-begins-here-{Regex.Escape(commandName)}-{env.Version}.*### clifx-suggest-ends-here-{Regex.Escape(commandName)}"; + var script = _fileSystem.ReadAllText(path); + var match = Regex.Match(script, pattern, RegexOptions.Singleline); + if (match.Success) + { + continue; + } + + var uninstallPattern = $"### clifx-suggest-begins-here-{Regex.Escape(commandName)}.*### clifx-suggest-ends-here-{Regex.Escape(commandName)}"; + var sb = new StringBuilder(Regex.Replace(script, uninstallPattern, "", RegexOptions.Singleline)); + sb.AppendLine(env.GetInstallCommand(commandName)); + + // move backup to temp folder for OS to delete eventually (just in case something really bad happens) + var tempFile = Path.GetFileName(path); + var tempExtension = Path.GetExtension(tempFile) + $".backup_{DateTime.UtcNow.ToFileTime()}"; + tempFile = Path.ChangeExtension(tempFile, tempExtension); + var backupPath = Path.Combine(Path.GetTempPath(), tempFile); + + _fileSystem.Copy(path, backupPath); + _fileSystem.WriteAllText(path, sb.ToString()); + } + } } -} \ No newline at end of file +} + From db3b9565a68729ba29b0d3c7038b1da16ba9ad95 Mon Sep 17 00:00:00 2001 From: mauricel Date: Tue, 30 Mar 2021 14:54:51 +0100 Subject: [PATCH 08/31] Fix tests - disable suggest mode by default. --- CliFx.Tests/SpecsBase.cs | 2 ++ CliFx.Tests/SuggestDirectiveSpecs.cs | 9 ++++--- CliFx/CliApplicationBuilder.cs | 4 ++-- CliFx/Infrastructure/FakeFileSystem.cs | 33 ++++++++++++++++++++++++++ CliFx/Infrastructure/NullFileSystem.cs | 27 +++++++++++++++++++++ 5 files changed, 70 insertions(+), 5 deletions(-) create mode 100644 CliFx/Infrastructure/FakeFileSystem.cs create mode 100644 CliFx/Infrastructure/NullFileSystem.cs diff --git a/CliFx.Tests/SpecsBase.cs b/CliFx.Tests/SpecsBase.cs index 035b6b9d..ed6d4185 100644 --- a/CliFx.Tests/SpecsBase.cs +++ b/CliFx.Tests/SpecsBase.cs @@ -11,6 +11,8 @@ public abstract class SpecsBase : IDisposable public FakeInMemoryConsole FakeConsole { get; } = new(); + public NullFileSystem NullFileSystem { get; } = new(); + protected SpecsBase(ITestOutputHelper testOutput) => TestOutput = testOutput; diff --git a/CliFx.Tests/SuggestDirectiveSpecs.cs b/CliFx.Tests/SuggestDirectiveSpecs.cs index 19812744..6acd722a 100644 --- a/CliFx.Tests/SuggestDirectiveSpecs.cs +++ b/CliFx.Tests/SuggestDirectiveSpecs.cs @@ -44,7 +44,8 @@ public CliApplicationBuilder TestApplicationFactory(params string[] commandClass builder = builder.AddCommand(commandType); }); - return builder.UseConsole(FakeConsole); + return builder.UseConsole(FakeConsole) + .UseFileSystem(NullFileSystem); } [Theory] @@ -67,7 +68,7 @@ public async Task Suggest_directive_can_be_configured(bool enabled, int expected } [Fact] - public async Task Suggest_directive_is_enabled_by_default() + public async Task Suggest_directive_is_disabled_by_default() { // Arrange var application = TestApplicationFactory(_cmdCommandCs) @@ -79,7 +80,7 @@ public async Task Suggest_directive_is_enabled_by_default() ); // Assert - exitCode.Should().Be(0); + exitCode.Should().Be(1); } private string FormatExpectedOutput(string [] s) @@ -106,6 +107,7 @@ public async Task Suggest_directive_suggests_commands_by_environment_variables(s { // Arrange var application = TestApplicationFactory(_cmdCommandCs, _cmd2CommandCs) + .AllowSuggestMode() .Build(); // Act @@ -133,6 +135,7 @@ public async Task Suggest_directive_suggests_commands_by_command_line_only(strin { // Arrange var application = TestApplicationFactory(_cmdCommandCs, _cmd2CommandCs) + .AllowSuggestMode() .Build(); // Act diff --git a/CliFx/CliApplicationBuilder.cs b/CliFx/CliApplicationBuilder.cs index 113c500c..e5b92bbe 100644 --- a/CliFx/CliApplicationBuilder.cs +++ b/CliFx/CliApplicationBuilder.cs @@ -19,7 +19,7 @@ public partial class CliApplicationBuilder private bool _isDebugModeAllowed = true; private bool _isPreviewModeAllowed = true; - private bool _isSuggestModeAllowed = true; + private bool _isSuggestModeAllowed = false; private string? _title; private string? _executableName; private string? _versionText; @@ -182,7 +182,7 @@ public CliApplicationBuilder UseConsole(IConsole console) /// /// Configures the application to use the specified implementation of . /// - CliApplicationBuilder UseFileSystem(IFileSystem fileSystem) + public CliApplicationBuilder UseFileSystem(IFileSystem fileSystem) { _fileSystem = fileSystem; return this; diff --git a/CliFx/Infrastructure/FakeFileSystem.cs b/CliFx/Infrastructure/FakeFileSystem.cs new file mode 100644 index 00000000..9e0451dc --- /dev/null +++ b/CliFx/Infrastructure/FakeFileSystem.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace CliFx.Infrastructure +{ + public class FakeSystem : IFileSystem + { + public Dictionary Files => new Dictionary(); + + public void Copy(string sourceFileName, string destFileName) + { + Files[destFileName] = Files[sourceFileName]; + } + + public bool Exists(string path) + { + return Files.ContainsKey(path); + } + + public string ReadAllText(string path) + { + return Files[path]; + } + + public void WriteAllText(string path, string content) + { + Files[path] = content; + } + } + + +} diff --git a/CliFx/Infrastructure/NullFileSystem.cs b/CliFx/Infrastructure/NullFileSystem.cs new file mode 100644 index 00000000..8e7a1713 --- /dev/null +++ b/CliFx/Infrastructure/NullFileSystem.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace CliFx.Infrastructure +{ + public class NullFileSystem : IFileSystem + { + public void Copy(string sourceFileName, string destFileName) + { + } + + public bool Exists(string path) + { + return false; + } + + public string ReadAllText(string path) + { + return ""; + } + + public void WriteAllText(string path, string content) + { + } + } +} From 8d77dac168136de8883e98d9d2a4a4464a8fe108 Mon Sep 17 00:00:00 2001 From: mauricel Date: Tue, 30 Mar 2021 16:03:42 +0100 Subject: [PATCH 09/31] Fix assertions that rely on new lines. --- CliFx.Tests/SuggestDirectiveSpecs.cs | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/CliFx.Tests/SuggestDirectiveSpecs.cs b/CliFx.Tests/SuggestDirectiveSpecs.cs index 6acd722a..5529ee64 100644 --- a/CliFx.Tests/SuggestDirectiveSpecs.cs +++ b/CliFx.Tests/SuggestDirectiveSpecs.cs @@ -37,7 +37,7 @@ public class Command02 : ICommand public CliApplicationBuilder TestApplicationFactory(params string[] commandClasses) { var builder = new CliApplicationBuilder(); - + commandClasses.ToList().ForEach(c => { var commandType = DynamicCommandBuilder.Compile(c); @@ -49,7 +49,7 @@ public CliApplicationBuilder TestApplicationFactory(params string[] commandClass } [Theory] - [InlineData(true, 0 )] + [InlineData(true, 0)] [InlineData(false, 1)] public async Task Suggest_directive_can_be_configured(bool enabled, int expectedExitCode) { @@ -83,17 +83,8 @@ public async Task Suggest_directive_is_disabled_by_default() exitCode.Should().Be(1); } - private string FormatExpectedOutput(string [] s) - { - if( s.Length == 0) - { - return ""; - } - return string.Join("\r\n", s) + "\r\n"; - } - [Theory] - [InlineData("supply all commands if nothing supplied", + [InlineData("supply all commands if nothing supplied", "clifx.exe", 0, new[] { "cmd", "cmd02" })] [InlineData("supply all commands that 'start with' argument", "clifx.exe c", 0, new[] { "cmd", "cmd02" })] @@ -120,10 +111,13 @@ public async Task Suggest_directive_suggests_commands_by_environment_variables(s ); var stdOut = FakeConsole.ReadOutputString(); - + // Assert exitCode.Should().Be(0); - stdOut.Should().Be(FormatExpectedOutput(expected), usecase); + + stdOut.Split(null) + .Where(p => !string.IsNullOrWhiteSpace(p)) + .Should().BeEquivalentTo(expected, usecase); } [Theory] @@ -145,7 +139,10 @@ public async Task Suggest_directive_suggests_commands_by_command_line_only(strin // Assert exitCode.Should().Be(0); - stdOut.Should().Be(FormatExpectedOutput(expected), usecase); + + stdOut.Split(null) + .Where(p => !string.IsNullOrWhiteSpace(p)) + .Should().BeEquivalentTo(expected, usecase); } } } \ No newline at end of file From 96b0a0188c5c0f652c6f261db6bed341dde79c85 Mon Sep 17 00:00:00 2001 From: mauricel Date: Tue, 30 Mar 2021 16:11:57 +0100 Subject: [PATCH 10/31] Remove compile warnings --- CliFx/Infrastructure/FakeFileSystem.cs | 7 ++++++- CliFx/Infrastructure/NullFileSystem.cs | 5 +++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CliFx/Infrastructure/FakeFileSystem.cs b/CliFx/Infrastructure/FakeFileSystem.cs index 9e0451dc..7ca53bd0 100644 --- a/CliFx/Infrastructure/FakeFileSystem.cs +++ b/CliFx/Infrastructure/FakeFileSystem.cs @@ -4,6 +4,11 @@ namespace CliFx.Infrastructure { + +#pragma warning disable 1591 + /// + /// A mock for IFileSystem + /// public class FakeSystem : IFileSystem { public Dictionary Files => new Dictionary(); @@ -29,5 +34,5 @@ public void WriteAllText(string path, string content) } } - +#pragma warning restore 1591 } diff --git a/CliFx/Infrastructure/NullFileSystem.cs b/CliFx/Infrastructure/NullFileSystem.cs index 8e7a1713..36f170b3 100644 --- a/CliFx/Infrastructure/NullFileSystem.cs +++ b/CliFx/Infrastructure/NullFileSystem.cs @@ -4,6 +4,10 @@ namespace CliFx.Infrastructure { +#pragma warning disable 1591 + /// + /// Null Pattern implementation of IFileSystem. Does nothing, returns false. + /// public class NullFileSystem : IFileSystem { public void Copy(string sourceFileName, string destFileName) @@ -24,4 +28,5 @@ public void WriteAllText(string path, string content) { } } +#pragma warning restore 1591 } From 170b0a3b8bb03d77285e9cb30485ab21281c3aa2 Mon Sep 17 00:00:00 2001 From: mauricel Date: Tue, 30 Mar 2021 16:20:55 +0100 Subject: [PATCH 11/31] Fix potential suggest install issue -- whitespace in command name not not handled consistently. --- CliFx/Suggestions/BashSuggestEnvironment.cs | 4 ++-- CliFx/Suggestions/PowershellSuggestEnvironment.cs | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/CliFx/Suggestions/BashSuggestEnvironment.cs b/CliFx/Suggestions/BashSuggestEnvironment.cs index 2798d655..02664f68 100644 --- a/CliFx/Suggestions/BashSuggestEnvironment.cs +++ b/CliFx/Suggestions/BashSuggestEnvironment.cs @@ -27,7 +27,7 @@ public string GetInstallCommand(string commandName) { var safeName = commandName.Replace(" ", "_"); return $@" -### clifx-suggest-begins-here-{safeName}-{Version} +### clifx-suggest-begins-here-{commandName}-{Version} # this block provides auto-complete for the {commandName} command # and assumes that {commandName} is on the path _{safeName}_complete() @@ -52,7 +52,7 @@ local completions COMPREPLY=( $(compgen -W ""$completions"" -- ""$word"") ) }} -complete -f -F _{safeName}_complete ""{commandName}"" +complete -f -F _{commandName}_complete ""{commandName}"" ### clifx-suggest-ends-here-{safeName}"; } diff --git a/CliFx/Suggestions/PowershellSuggestEnvironment.cs b/CliFx/Suggestions/PowershellSuggestEnvironment.cs index 7990a743..07d982de 100644 --- a/CliFx/Suggestions/PowershellSuggestEnvironment.cs +++ b/CliFx/Suggestions/PowershellSuggestEnvironment.cs @@ -37,9 +37,8 @@ public string GetInstallPath() public string GetInstallCommand(string commandName) { - var safeName = commandName.Replace(" ", "_"); return $@" -### clifx-suggest-begins-here-{safeName}-{Version} +### clifx-suggest-begins-here-{commandName}-{Version} # this block provides auto-complete for the {commandName} command # and assumes that {commandName} is on the path $scriptblock = {{ @@ -58,7 +57,7 @@ public string GetInstallCommand(string commandName) }} Register-ArgumentCompleter -Native -CommandName ""{commandName}"" -ScriptBlock $scriptblock -### clifx-suggest-ends-here-{safeName}"; +### clifx-suggest-ends-here-{commandName}"; } } } From 319ea9ebe9d214bfc884557777d250a59b9d4510 Mon Sep 17 00:00:00 2001 From: mauricel Date: Tue, 30 Mar 2021 18:39:00 +0100 Subject: [PATCH 12/31] Attempt to fix issues with OS detection. --- CliFx/CliApplication.cs | 4 +- CliFx/Infrastructure/FakeFileSystem.cs | 12 +++- CliFx/Infrastructure/FileSystem.cs | 16 +++++- CliFx/Infrastructure/IFileSystem.cs | 4 +- CliFx/Infrastructure/NullFileSystem.cs | 7 ++- ...ggestEnvironment.cs => BashEnvironment.cs} | 16 +++--- CliFx/Suggestions/ISuggestEnvironment.cs | 4 +- ...nvironment.cs => PowershellEnvironment.cs} | 39 ++++++------- CliFx/Suggestions/ShellHookInstaller.cs | 55 +++++++++++++++++++ CliFx/Suggestions/SuggestionService.cs | 45 ++------------- 10 files changed, 117 insertions(+), 85 deletions(-) rename CliFx/Suggestions/{BashSuggestEnvironment.cs => BashEnvironment.cs} (81%) rename CliFx/Suggestions/{PowershellSuggestEnvironment.cs => PowershellEnvironment.cs} (54%) create mode 100644 CliFx/Suggestions/ShellHookInstaller.cs diff --git a/CliFx/CliApplication.cs b/CliFx/CliApplication.cs index 33fbde16..c26a0213 100644 --- a/CliFx/CliApplication.cs +++ b/CliFx/CliApplication.cs @@ -113,12 +113,12 @@ private async ValueTask RunAsync(ApplicationSchema applicationSchema, Comma // Handle suggest directive if (Configuration.IsSuggestModeAllowed) { - new SuggestionService(applicationSchema, _fileSystem).EnsureInstalled(Metadata.Title); + new ShellHookInstaller(_fileSystem).EnsureInstalled(Metadata.Title); } if (IsSuggestModeEnabled(commandInput)) { - new SuggestionService(applicationSchema, _fileSystem) + new SuggestionService(applicationSchema, _fileSystem, commandInput.EnvironmentVariables) .GetSuggestions(commandInput).ToList() .ForEach(p => _console.Output.WriteLine(p)); return 0; diff --git a/CliFx/Infrastructure/FakeFileSystem.cs b/CliFx/Infrastructure/FakeFileSystem.cs index 7ca53bd0..1c1226e6 100644 --- a/CliFx/Infrastructure/FakeFileSystem.cs +++ b/CliFx/Infrastructure/FakeFileSystem.cs @@ -23,12 +23,18 @@ public bool Exists(string path) return Files.ContainsKey(path); } - public string ReadAllText(string path) + public bool TryReadText(string path, out string content) { - return Files[path]; + if( Files.ContainsKey(path)) + { + content = Files[path]; + return true; + } + content = ""; + return false; } - public void WriteAllText(string path, string content) + public void WriteText(string path, string content) { Files[path] = content; } diff --git a/CliFx/Infrastructure/FileSystem.cs b/CliFx/Infrastructure/FileSystem.cs index 4b81291a..e86369f8 100644 --- a/CliFx/Infrastructure/FileSystem.cs +++ b/CliFx/Infrastructure/FileSystem.cs @@ -14,13 +14,23 @@ public bool Exists(string path) return File.Exists(path); } - public string ReadAllText(string path) + public bool TryReadText(string path, out string text) { - return File.ReadAllText(path); + if (!File.Exists(path)) + { + text = ""; + return false; + } + text = File.ReadAllText(path); + return true; } - public void WriteAllText(string path, string content) + public void WriteText(string path, string content) { + if (!Directory.Exists(Path.GetDirectoryName(path))) + { + Directory.CreateDirectory(path); + } File.WriteAllText(path, content); } } diff --git a/CliFx/Infrastructure/IFileSystem.cs b/CliFx/Infrastructure/IFileSystem.cs index 1c0464d5..1c10bdae 100644 --- a/CliFx/Infrastructure/IFileSystem.cs +++ b/CliFx/Infrastructure/IFileSystem.cs @@ -18,13 +18,13 @@ public interface IFileSystem /// /// Opens a text file, reads all the text in the file, and then closes the file. /// - string ReadAllText(string filePath); + bool TryReadText(string filePath, out string content); /// /// Creates a new file, writes the specified string to the file, and then closes /// the file. If the target file already exists, it is overwritten. /// - void WriteAllText(string filePath, string content); + void WriteText(string filePath, string content); /// /// Copies an existing file to a new file. Overwriting a file of the same name is diff --git a/CliFx/Infrastructure/NullFileSystem.cs b/CliFx/Infrastructure/NullFileSystem.cs index 36f170b3..9e053e47 100644 --- a/CliFx/Infrastructure/NullFileSystem.cs +++ b/CliFx/Infrastructure/NullFileSystem.cs @@ -19,12 +19,13 @@ public bool Exists(string path) return false; } - public string ReadAllText(string path) + public bool TryReadText(string path, out string content) { - return ""; + content = ""; + return false; } - public void WriteAllText(string path, string content) + public void WriteText(string path, string content) { } } diff --git a/CliFx/Suggestions/BashSuggestEnvironment.cs b/CliFx/Suggestions/BashEnvironment.cs similarity index 81% rename from CliFx/Suggestions/BashSuggestEnvironment.cs rename to CliFx/Suggestions/BashEnvironment.cs index 02664f68..84b5e6e4 100644 --- a/CliFx/Suggestions/BashSuggestEnvironment.cs +++ b/CliFx/Suggestions/BashEnvironment.cs @@ -1,21 +1,21 @@ -using System; +using CliFx.Infrastructure; +using CliFx.Input; +using System; using System.Collections.Generic; using System.IO; +using System.Linq; +using System.Runtime.InteropServices; using System.Text; namespace CliFx.Suggestions { - class BashSuggestEnvironment : ISuggestEnvironment + class BashEnvironment : ISuggestEnvironment { public string Version => "V1"; public bool ShouldInstall() { - if (Environment.OSVersion.Platform == PlatformID.Unix || Environment.OSVersion.Platform == PlatformID.MacOSX) - { - return File.Exists(GetInstallPath()); - } - return false; + return RuntimeInformation.IsOSPlatform(OSPlatform.Linux) || RuntimeInformation.IsOSPlatform(OSPlatform.OSX); } public string GetInstallPath() @@ -55,6 +55,6 @@ local completions complete -f -F _{commandName}_complete ""{commandName}"" ### clifx-suggest-ends-here-{safeName}"; - } + } } } diff --git a/CliFx/Suggestions/ISuggestEnvironment.cs b/CliFx/Suggestions/ISuggestEnvironment.cs index 28d3860a..66e912c2 100644 --- a/CliFx/Suggestions/ISuggestEnvironment.cs +++ b/CliFx/Suggestions/ISuggestEnvironment.cs @@ -1,4 +1,6 @@ -using System; +using CliFx.Infrastructure; +using CliFx.Input; +using System; using System.Collections.Generic; using System.Text; diff --git a/CliFx/Suggestions/PowershellSuggestEnvironment.cs b/CliFx/Suggestions/PowershellEnvironment.cs similarity index 54% rename from CliFx/Suggestions/PowershellSuggestEnvironment.cs rename to CliFx/Suggestions/PowershellEnvironment.cs index 07d982de..8d9be7bf 100644 --- a/CliFx/Suggestions/PowershellSuggestEnvironment.cs +++ b/CliFx/Suggestions/PowershellEnvironment.cs @@ -1,38 +1,33 @@ -using System; +using CliFx.Infrastructure; +using CliFx.Input; +using System; using System.Collections.Generic; using System.IO; +using System.Linq; +using System.Runtime.InteropServices; using System.Text; namespace CliFx.Suggestions { - class PowershellSuggestEnvironment : ISuggestEnvironment + + /// + /// Known issue: always triggers in Ubuntu on Windows. + /// + class PowershellEnvironment : ISuggestEnvironment { public string Version => "V1"; - + public bool ShouldInstall() { - if (Environment.OSVersion.Platform == PlatformID.Unix || Environment.OSVersion.Platform == PlatformID.MacOSX) - { - return File.Exists("/usr/bin/pwsh"); - - } - return true; + return RuntimeInformation.IsOSPlatform(OSPlatform.Windows); } - public string GetInstallPath() + public virtual string GetInstallPath() { - var baseDir = ""; - if (Environment.OSVersion.Platform == PlatformID.Unix || Environment.OSVersion.Platform == PlatformID.MacOSX) - { - baseDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", ".powershell"); - } - else - { - var myDocuments = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments, Environment.SpecialFolderOption.DoNotVerify); - baseDir = Path.Combine(myDocuments, "WindowsPowerShell"); - } - - return Path.Combine(baseDir, "Microsoft.PowerShell_profile.ps1"); + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments, Environment.SpecialFolderOption.DoNotVerify), + "WindowsPowerShell", + "Microsoft.PowerShell_profile.ps1"); } public string GetInstallCommand(string commandName) diff --git a/CliFx/Suggestions/ShellHookInstaller.cs b/CliFx/Suggestions/ShellHookInstaller.cs new file mode 100644 index 00000000..99c85c45 --- /dev/null +++ b/CliFx/Suggestions/ShellHookInstaller.cs @@ -0,0 +1,55 @@ +using CliFx.Infrastructure; +using CliFx.Input; +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Text.RegularExpressions; + +namespace CliFx.Suggestions +{ + class ShellHookInstaller + { + private readonly IFileSystem _fileSystem; + + public ShellHookInstaller(IFileSystem fileSystem) + { + _fileSystem = fileSystem; + } + public void EnsureInstalled(string commandName) + { + foreach (var env in new ISuggestEnvironment[] { new BashEnvironment(), new PowershellEnvironment() }) + { + if (!env.ShouldInstall()) + { + continue; + } + + var path = env.GetInstallPath(); + + var pattern = $"### clifx-suggest-begins-here-{Regex.Escape(commandName)}-{env.Version}.*### clifx-suggest-ends-here-{Regex.Escape(commandName)}"; + + string script = ""; + _fileSystem.TryReadText(path, out script); + var match = Regex.Match(script, pattern, RegexOptions.Singleline); + if (match.Success) + { + continue; + } + + var uninstallPattern = $"### clifx-suggest-begins-here-{Regex.Escape(commandName)}.*### clifx-suggest-ends-here-{Regex.Escape(commandName)}"; + var sb = new StringBuilder(Regex.Replace(script, uninstallPattern, "", RegexOptions.Singleline)); + sb.AppendLine(env.GetInstallCommand(commandName)); + + // backup to temp folder for OS to delete eventually (just in case something really bad happens) + var tempFile = Path.GetFileName(path); + var tempExtension = Path.GetExtension(tempFile) + $".backup_{DateTime.UtcNow.ToFileTime()}"; + tempFile = Path.ChangeExtension(tempFile, tempExtension); + var backupPath = Path.Combine(Path.GetTempPath(), tempFile); + + _fileSystem.Copy(path, backupPath); + _fileSystem.WriteText(path, sb.ToString()); + } + } + } +} diff --git a/CliFx/Suggestions/SuggestionService.cs b/CliFx/Suggestions/SuggestionService.cs index dc4e70f0..207b448a 100644 --- a/CliFx/Suggestions/SuggestionService.cs +++ b/CliFx/Suggestions/SuggestionService.cs @@ -15,11 +15,13 @@ internal class SuggestionService { private ApplicationSchema _applicationSchema; private readonly IFileSystem _fileSystem; + private readonly IReadOnlyList _environmentVariableInputs; - public SuggestionService(ApplicationSchema applicationSchema, IFileSystem fileSystem) + public SuggestionService(ApplicationSchema applicationSchema, IFileSystem fileSystem, IReadOnlyList environmentVariableInputs) { _applicationSchema = applicationSchema; _fileSystem = fileSystem; + _environmentVariableInputs = environmentVariableInputs; } public IEnumerable GetSuggestions(CommandInput commandInput) @@ -29,7 +31,7 @@ public IEnumerable GetSuggestions(CommandInput commandInput) var suggestInput = CommandInput.Parse( suggestArgs.ToArray(), - commandInput.EnvironmentVariables.ToDictionary(p => p.Name, p => p.Value), + _environmentVariableInputs.ToDictionary(p => p.Name, p => p.Value), _applicationSchema.GetCommandNames()); var commandMatch = _applicationSchema.Commands @@ -78,45 +80,6 @@ private static List NoSuggestions() { return new List(); } - - public void EnsureInstalled(string commandName) - { - foreach (var env in new ISuggestEnvironment[] { new BashSuggestEnvironment(), new PowershellSuggestEnvironment() }) - { - var path = env.GetInstallPath(); - - if(!env.ShouldInstall()) - { - continue; - } - - if (!_fileSystem.Exists(path)) - { - _fileSystem.WriteAllText(path, ""); - } - - var pattern = $"### clifx-suggest-begins-here-{Regex.Escape(commandName)}-{env.Version}.*### clifx-suggest-ends-here-{Regex.Escape(commandName)}"; - var script = _fileSystem.ReadAllText(path); - var match = Regex.Match(script, pattern, RegexOptions.Singleline); - if (match.Success) - { - continue; - } - - var uninstallPattern = $"### clifx-suggest-begins-here-{Regex.Escape(commandName)}.*### clifx-suggest-ends-here-{Regex.Escape(commandName)}"; - var sb = new StringBuilder(Regex.Replace(script, uninstallPattern, "", RegexOptions.Singleline)); - sb.AppendLine(env.GetInstallCommand(commandName)); - - // move backup to temp folder for OS to delete eventually (just in case something really bad happens) - var tempFile = Path.GetFileName(path); - var tempExtension = Path.GetExtension(tempFile) + $".backup_{DateTime.UtcNow.ToFileTime()}"; - tempFile = Path.ChangeExtension(tempFile, tempExtension); - var backupPath = Path.Combine(Path.GetTempPath(), tempFile); - - _fileSystem.Copy(path, backupPath); - _fileSystem.WriteAllText(path, sb.ToString()); - } - } } } From 19733ff6c5cd89e68bc5213e4206c2e7cc49f82c Mon Sep 17 00:00:00 2001 From: mauricel Date: Wed, 31 Mar 2021 13:00:52 +0100 Subject: [PATCH 13/31] Ignore false issues around OS detection. Applications running in Linux subsystem for Windows behave weirdly when application is in a windows mount (eg /mnt/c...). --- CliFx/CliApplication.cs | 3 +-- CliFx/Infrastructure/FileSystem.cs | 4 ++-- CliFx/Infrastructure/IFileSystem.cs | 9 ++++---- CliFx/Suggestions/BashEnvironment.cs | 5 +---- CliFx/Suggestions/ISuggestEnvironment.cs | 4 ++-- ...taller.cs => SuggestShellHookInstaller.cs} | 12 ++++++----- .../Suggestions/UnixPowershellEnvironment.cs | 21 +++++++++++++++++++ ...ent.cs => WindowsPowershellEnvironment.cs} | 13 +++--------- 8 files changed, 42 insertions(+), 29 deletions(-) rename CliFx/Suggestions/{ShellHookInstaller.cs => SuggestShellHookInstaller.cs} (85%) create mode 100644 CliFx/Suggestions/UnixPowershellEnvironment.cs rename CliFx/Suggestions/{PowershellEnvironment.cs => WindowsPowershellEnvironment.cs} (84%) diff --git a/CliFx/CliApplication.cs b/CliFx/CliApplication.cs index c26a0213..2431a02f 100644 --- a/CliFx/CliApplication.cs +++ b/CliFx/CliApplication.cs @@ -113,9 +113,8 @@ private async ValueTask RunAsync(ApplicationSchema applicationSchema, Comma // Handle suggest directive if (Configuration.IsSuggestModeAllowed) { - new ShellHookInstaller(_fileSystem).EnsureInstalled(Metadata.Title); + new SuggestShellHookInstaller(_fileSystem).Install(Metadata.Title); } - if (IsSuggestModeEnabled(commandInput)) { new SuggestionService(applicationSchema, _fileSystem, commandInput.EnvironmentVariables) diff --git a/CliFx/Infrastructure/FileSystem.cs b/CliFx/Infrastructure/FileSystem.cs index e86369f8..454ab48f 100644 --- a/CliFx/Infrastructure/FileSystem.cs +++ b/CliFx/Infrastructure/FileSystem.cs @@ -2,7 +2,7 @@ namespace CliFx.Infrastructure { - class FileSystem : IFileSystem + internal class FileSystem : IFileSystem { public void Copy(string sourceFileName, string destFileName) { @@ -11,7 +11,7 @@ public void Copy(string sourceFileName, string destFileName) public bool Exists(string path) { - return File.Exists(path); + return new FileInfo(path).Exists; } public bool TryReadText(string path, out string text) diff --git a/CliFx/Infrastructure/IFileSystem.cs b/CliFx/Infrastructure/IFileSystem.cs index 1c10bdae..54ba8841 100644 --- a/CliFx/Infrastructure/IFileSystem.cs +++ b/CliFx/Infrastructure/IFileSystem.cs @@ -11,18 +11,19 @@ namespace CliFx.Infrastructure public interface IFileSystem { /// - /// Determines whether the specified file exists. + /// Determines whether the specified file exists. + /// Doesn't work in Linux Subsystem for Windows unless application if the application resides on a windows mount (eg /mnt/c/...) /// bool Exists(string filePath); /// - /// Opens a text file, reads all the text in the file, and then closes the file. + /// Attempts to open a text file, reads all the text in the file, and then closes the file. /// bool TryReadText(string filePath, out string content); /// - /// Creates a new file, writes the specified string to the file, and then closes - /// the file. If the target file already exists, it is overwritten. + /// Creates a new file (and any intermediate directories required), writes the specified string to the file, + /// and then closes the file. If the target file already exists, it is overwritten. /// void WriteText(string filePath, string content); diff --git a/CliFx/Suggestions/BashEnvironment.cs b/CliFx/Suggestions/BashEnvironment.cs index 84b5e6e4..28deeb64 100644 --- a/CliFx/Suggestions/BashEnvironment.cs +++ b/CliFx/Suggestions/BashEnvironment.cs @@ -13,10 +13,7 @@ class BashEnvironment : ISuggestEnvironment { public string Version => "V1"; - public bool ShouldInstall() - { - return RuntimeInformation.IsOSPlatform(OSPlatform.Linux) || RuntimeInformation.IsOSPlatform(OSPlatform.OSX); - } + public string[] ShellPaths => new[] { @"/usr/bin/bash" }; public string GetInstallPath() { diff --git a/CliFx/Suggestions/ISuggestEnvironment.cs b/CliFx/Suggestions/ISuggestEnvironment.cs index 66e912c2..43e595d9 100644 --- a/CliFx/Suggestions/ISuggestEnvironment.cs +++ b/CliFx/Suggestions/ISuggestEnvironment.cs @@ -8,10 +8,10 @@ namespace CliFx.Suggestions { interface ISuggestEnvironment { - bool ShouldInstall(); - string Version { get; } + string[] ShellPaths { get; } + string GetInstallPath(); string GetInstallCommand(string command); diff --git a/CliFx/Suggestions/ShellHookInstaller.cs b/CliFx/Suggestions/SuggestShellHookInstaller.cs similarity index 85% rename from CliFx/Suggestions/ShellHookInstaller.cs rename to CliFx/Suggestions/SuggestShellHookInstaller.cs index 99c85c45..241747ec 100644 --- a/CliFx/Suggestions/ShellHookInstaller.cs +++ b/CliFx/Suggestions/SuggestShellHookInstaller.cs @@ -3,24 +3,26 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Text; using System.Text.RegularExpressions; namespace CliFx.Suggestions { - class ShellHookInstaller + class SuggestShellHookInstaller { private readonly IFileSystem _fileSystem; - public ShellHookInstaller(IFileSystem fileSystem) + public SuggestShellHookInstaller(IFileSystem fileSystem) { _fileSystem = fileSystem; } - public void EnsureInstalled(string commandName) + + public void Install(string commandName) { - foreach (var env in new ISuggestEnvironment[] { new BashEnvironment(), new PowershellEnvironment() }) + foreach (var env in new ISuggestEnvironment[] { new BashEnvironment(), new WindowsPowershellEnvironment(), new UnixPowershellEnvironment() }) { - if (!env.ShouldInstall()) + if( !env.ShellPaths.Any(p=> _fileSystem.Exists(p))) { continue; } diff --git a/CliFx/Suggestions/UnixPowershellEnvironment.cs b/CliFx/Suggestions/UnixPowershellEnvironment.cs new file mode 100644 index 00000000..cac3d95e --- /dev/null +++ b/CliFx/Suggestions/UnixPowershellEnvironment.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; + +namespace CliFx.Suggestions +{ + class UnixPowershellEnvironment : WindowsPowershellEnvironment + { + public override string[] ShellPaths => new[] { @"/usr/bin/pwsh" }; + + public override string GetInstallPath() + { + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".config", + "powershell", + "Microsoft.PowerShell_profile.ps1"); + } + } +} diff --git a/CliFx/Suggestions/PowershellEnvironment.cs b/CliFx/Suggestions/WindowsPowershellEnvironment.cs similarity index 84% rename from CliFx/Suggestions/PowershellEnvironment.cs rename to CliFx/Suggestions/WindowsPowershellEnvironment.cs index 8d9be7bf..2b9e83bb 100644 --- a/CliFx/Suggestions/PowershellEnvironment.cs +++ b/CliFx/Suggestions/WindowsPowershellEnvironment.cs @@ -9,18 +9,11 @@ namespace CliFx.Suggestions { - - /// - /// Known issue: always triggers in Ubuntu on Windows. - /// - class PowershellEnvironment : ISuggestEnvironment + class WindowsPowershellEnvironment : ISuggestEnvironment { public string Version => "V1"; - public bool ShouldInstall() - { - return RuntimeInformation.IsOSPlatform(OSPlatform.Windows); - } + public virtual string[] ShellPaths => new[] { @"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe" }; public virtual string GetInstallPath() { @@ -53,6 +46,6 @@ public string GetInstallCommand(string commandName) Register-ArgumentCompleter -Native -CommandName ""{commandName}"" -ScriptBlock $scriptblock ### clifx-suggest-ends-here-{commandName}"; - } + } } } From 02fc91ec5714a979b19d6696c6362790a2eba28d Mon Sep 17 00:00:00 2001 From: mauricel Date: Wed, 31 Mar 2021 14:45:16 +0100 Subject: [PATCH 14/31] Refactor and fix directory creation issues when directory tree does not exist. --- CliFx/Infrastructure/FileSystem.cs | 13 +++++++++---- CliFx/Infrastructure/IFileSystem.cs | 7 +------ CliFx/Suggestions/BashEnvironment.cs | 9 +++------ CliFx/Suggestions/ISuggestEnvironment.cs | 12 +++--------- CliFx/Suggestions/SuggestShellHookInstaller.cs | 18 ++++++++---------- CliFx/Suggestions/UnixPowershellEnvironment.cs | 12 +++--------- .../WindowsPowershellEnvironment.cs | 15 +++------------ 7 files changed, 30 insertions(+), 56 deletions(-) diff --git a/CliFx/Infrastructure/FileSystem.cs b/CliFx/Infrastructure/FileSystem.cs index 454ab48f..563d4a5f 100644 --- a/CliFx/Infrastructure/FileSystem.cs +++ b/CliFx/Infrastructure/FileSystem.cs @@ -1,4 +1,5 @@ -using System.IO; +using System; +using System.IO; namespace CliFx.Infrastructure { @@ -6,7 +7,10 @@ internal class FileSystem : IFileSystem { public void Copy(string sourceFileName, string destFileName) { - File.Copy(sourceFileName, destFileName); + if( File.Exists(sourceFileName)) + { + File.Copy(sourceFileName, destFileName); + } } public bool Exists(string path) @@ -27,9 +31,10 @@ public bool TryReadText(string path, out string text) public void WriteText(string path, string content) { - if (!Directory.Exists(Path.GetDirectoryName(path))) + var directory = Path.GetDirectoryName(path); + if (!Directory.Exists(directory)) { - Directory.CreateDirectory(path); + Directory.CreateDirectory(directory); } File.WriteAllText(path, content); } diff --git a/CliFx/Infrastructure/IFileSystem.cs b/CliFx/Infrastructure/IFileSystem.cs index 54ba8841..532136bb 100644 --- a/CliFx/Infrastructure/IFileSystem.cs +++ b/CliFx/Infrastructure/IFileSystem.cs @@ -1,9 +1,4 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Text; - -namespace CliFx.Infrastructure +namespace CliFx.Infrastructure { /// /// Abstraction for the file system diff --git a/CliFx/Suggestions/BashEnvironment.cs b/CliFx/Suggestions/BashEnvironment.cs index 28deeb64..26435f8a 100644 --- a/CliFx/Suggestions/BashEnvironment.cs +++ b/CliFx/Suggestions/BashEnvironment.cs @@ -13,12 +13,9 @@ class BashEnvironment : ISuggestEnvironment { public string Version => "V1"; - public string[] ShellPaths => new[] { @"/usr/bin/bash" }; + public string[] SupportedShellPaths => new[] { @"/usr/bin/bash" }; - public string GetInstallPath() - { - return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".bashrc"); - } + public string InstallPath => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".bashrc"); public string GetInstallCommand(string commandName) { @@ -52,6 +49,6 @@ local completions complete -f -F _{commandName}_complete ""{commandName}"" ### clifx-suggest-ends-here-{safeName}"; - } + } } } diff --git a/CliFx/Suggestions/ISuggestEnvironment.cs b/CliFx/Suggestions/ISuggestEnvironment.cs index 43e595d9..95382f59 100644 --- a/CliFx/Suggestions/ISuggestEnvironment.cs +++ b/CliFx/Suggestions/ISuggestEnvironment.cs @@ -1,18 +1,12 @@ -using CliFx.Infrastructure; -using CliFx.Input; -using System; -using System.Collections.Generic; -using System.Text; - -namespace CliFx.Suggestions +namespace CliFx.Suggestions { interface ISuggestEnvironment { string Version { get; } - string[] ShellPaths { get; } + string[] SupportedShellPaths { get; } - string GetInstallPath(); + string InstallPath { get; } string GetInstallCommand(string command); } diff --git a/CliFx/Suggestions/SuggestShellHookInstaller.cs b/CliFx/Suggestions/SuggestShellHookInstaller.cs index 241747ec..94bacef2 100644 --- a/CliFx/Suggestions/SuggestShellHookInstaller.cs +++ b/CliFx/Suggestions/SuggestShellHookInstaller.cs @@ -1,7 +1,5 @@ using CliFx.Infrastructure; -using CliFx.Input; using System; -using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; @@ -20,15 +18,15 @@ public SuggestShellHookInstaller(IFileSystem fileSystem) public void Install(string commandName) { - foreach (var env in new ISuggestEnvironment[] { new BashEnvironment(), new WindowsPowershellEnvironment(), new UnixPowershellEnvironment() }) - { - if( !env.ShellPaths.Any(p=> _fileSystem.Exists(p))) - { - continue; - } + var detectedEnvironments = new ISuggestEnvironment[] { + new BashEnvironment(), + new WindowsPowershellEnvironment(), + new UnixPowershellEnvironment() } + .Where(env => env.SupportedShellPaths.Any(p => _fileSystem.Exists(p) )); - var path = env.GetInstallPath(); - + foreach (var env in detectedEnvironments) + { + var path = env.InstallPath; var pattern = $"### clifx-suggest-begins-here-{Regex.Escape(commandName)}-{env.Version}.*### clifx-suggest-ends-here-{Regex.Escape(commandName)}"; string script = ""; diff --git a/CliFx/Suggestions/UnixPowershellEnvironment.cs b/CliFx/Suggestions/UnixPowershellEnvironment.cs index cac3d95e..443c4bd7 100644 --- a/CliFx/Suggestions/UnixPowershellEnvironment.cs +++ b/CliFx/Suggestions/UnixPowershellEnvironment.cs @@ -1,21 +1,15 @@ using System; -using System.Collections.Generic; using System.IO; -using System.Text; namespace CliFx.Suggestions { class UnixPowershellEnvironment : WindowsPowershellEnvironment { - public override string[] ShellPaths => new[] { @"/usr/bin/pwsh" }; + public override string[] SupportedShellPaths => new[] { @"/usr/bin/pwsh" }; - public override string GetInstallPath() - { - return Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".config", + public override string InstallPath => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData, Environment.SpecialFolderOption.DoNotVerify), "powershell", "Microsoft.PowerShell_profile.ps1"); - } } } diff --git a/CliFx/Suggestions/WindowsPowershellEnvironment.cs b/CliFx/Suggestions/WindowsPowershellEnvironment.cs index 2b9e83bb..87c8d9e8 100644 --- a/CliFx/Suggestions/WindowsPowershellEnvironment.cs +++ b/CliFx/Suggestions/WindowsPowershellEnvironment.cs @@ -1,11 +1,5 @@ -using CliFx.Infrastructure; -using CliFx.Input; -using System; -using System.Collections.Generic; +using System; using System.IO; -using System.Linq; -using System.Runtime.InteropServices; -using System.Text; namespace CliFx.Suggestions { @@ -13,15 +7,12 @@ class WindowsPowershellEnvironment : ISuggestEnvironment { public string Version => "V1"; - public virtual string[] ShellPaths => new[] { @"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe" }; + public virtual string[] SupportedShellPaths => new[] { @"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe" }; - public virtual string GetInstallPath() - { - return Path.Combine( + public virtual string InstallPath => Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments, Environment.SpecialFolderOption.DoNotVerify), "WindowsPowerShell", "Microsoft.PowerShell_profile.ps1"); - } public string GetInstallCommand(string commandName) { From 6973912a87c6139df901962dcd1716531b9502df Mon Sep 17 00:00:00 2001 From: mauricel Date: Wed, 31 Mar 2021 16:50:06 +0100 Subject: [PATCH 15/31] Fix line ending issue causing syntax errors in unix shell hooks. --- CliFx/Suggestions/BashEnvironment.cs | 2 +- .../Suggestions/UnixPowershellEnvironment.cs | 36 +++++++++++-------- .../WindowsPowershellEnvironment.cs | 2 +- 3 files changed, 23 insertions(+), 17 deletions(-) diff --git a/CliFx/Suggestions/BashEnvironment.cs b/CliFx/Suggestions/BashEnvironment.cs index 26435f8a..78537081 100644 --- a/CliFx/Suggestions/BashEnvironment.cs +++ b/CliFx/Suggestions/BashEnvironment.cs @@ -48,7 +48,7 @@ local completions complete -f -F _{commandName}_complete ""{commandName}"" -### clifx-suggest-ends-here-{safeName}"; +### clifx-suggest-ends-here-{safeName}".Replace("\r", ""); } } } diff --git a/CliFx/Suggestions/UnixPowershellEnvironment.cs b/CliFx/Suggestions/UnixPowershellEnvironment.cs index 443c4bd7..842175f1 100644 --- a/CliFx/Suggestions/UnixPowershellEnvironment.cs +++ b/CliFx/Suggestions/UnixPowershellEnvironment.cs @@ -1,15 +1,21 @@ -using System; -using System.IO; - -namespace CliFx.Suggestions -{ - class UnixPowershellEnvironment : WindowsPowershellEnvironment - { - public override string[] SupportedShellPaths => new[] { @"/usr/bin/pwsh" }; - - public override string InstallPath => Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData, Environment.SpecialFolderOption.DoNotVerify), - "powershell", - "Microsoft.PowerShell_profile.ps1"); - } -} +using System; +using System.IO; +using System.Text; + +namespace CliFx.Suggestions +{ + class UnixPowershellEnvironment : WindowsPowershellEnvironment + { + public override string[] SupportedShellPaths => new[] { @"/usr/bin/pwsh" }; + + public override string InstallPath => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData, Environment.SpecialFolderOption.DoNotVerify), + "powershell", + "Microsoft.PowerShell_profile.ps1"); + + public override string GetInstallCommand(string commandName) + { + return base.GetInstallCommand(commandName).Replace("\r", ""); + } + } +} diff --git a/CliFx/Suggestions/WindowsPowershellEnvironment.cs b/CliFx/Suggestions/WindowsPowershellEnvironment.cs index 87c8d9e8..e7015abe 100644 --- a/CliFx/Suggestions/WindowsPowershellEnvironment.cs +++ b/CliFx/Suggestions/WindowsPowershellEnvironment.cs @@ -14,7 +14,7 @@ class WindowsPowershellEnvironment : ISuggestEnvironment "WindowsPowerShell", "Microsoft.PowerShell_profile.ps1"); - public string GetInstallCommand(string commandName) + public virtual string GetInstallCommand(string commandName) { return $@" ### clifx-suggest-begins-here-{commandName}-{Version} From cffd1c83d10c2172017f1b3b9f654118db965d7f Mon Sep 17 00:00:00 2001 From: mauricel Date: Wed, 31 Mar 2021 17:20:13 +0100 Subject: [PATCH 16/31] Fix bash autocomplete hook. --- CliFx/Suggestions/BashEnvironment.cs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/CliFx/Suggestions/BashEnvironment.cs b/CliFx/Suggestions/BashEnvironment.cs index 78537081..f2e6fb4b 100644 --- a/CliFx/Suggestions/BashEnvironment.cs +++ b/CliFx/Suggestions/BashEnvironment.cs @@ -6,7 +6,8 @@ using System.Linq; using System.Runtime.InteropServices; using System.Text; - +using System.Text.RegularExpressions; + namespace CliFx.Suggestions { class BashEnvironment : ISuggestEnvironment @@ -19,7 +20,7 @@ class BashEnvironment : ISuggestEnvironment public string GetInstallCommand(string commandName) { - var safeName = commandName.Replace(" ", "_"); + var safeName = new Regex("[^a-zA-Z0-9]").Replace(commandName, ""); return $@" ### clifx-suggest-begins-here-{commandName}-{Version} # this block provides auto-complete for the {commandName} command @@ -37,7 +38,7 @@ public string GetInstallCommand(string commandName) local completions completions=""$({commandName} ""[suggest]"" --cursor ""${{COMP_POINT}}"" --envvar $CLIFX_CMD_CACHE 2>/dev/null)"" - if [ $? -ne 0]; then + if [ $? -ne 0 ]; then completions="""" fi @@ -46,9 +47,9 @@ local completions COMPREPLY=( $(compgen -W ""$completions"" -- ""$word"") ) }} -complete -f -F _{commandName}_complete ""{commandName}"" +complete -f -F _{safeName}_complete ""{commandName}"" -### clifx-suggest-ends-here-{safeName}".Replace("\r", ""); +### clifx-suggest-ends-here-{commandName}".Replace("\r", ""); } } } From a5a3e6eccb74a44f5d78693d2a8225d37795b779 Mon Sep 17 00:00:00 2001 From: mauricel Date: Wed, 31 Mar 2021 19:04:32 +0100 Subject: [PATCH 17/31] Add suggest hook installation tests. --- CliFx.Tests/SuggestHookInstallSpecs.cs | 82 +++++++++++++++++++ CliFx/Infrastructure/FakeFileSystem.cs | 11 ++- CliFx/Suggestions/BashEnvironment.cs | 104 ++++++++++++------------- 3 files changed, 142 insertions(+), 55 deletions(-) create mode 100644 CliFx.Tests/SuggestHookInstallSpecs.cs diff --git a/CliFx.Tests/SuggestHookInstallSpecs.cs b/CliFx.Tests/SuggestHookInstallSpecs.cs new file mode 100644 index 00000000..4173b537 --- /dev/null +++ b/CliFx.Tests/SuggestHookInstallSpecs.cs @@ -0,0 +1,82 @@ +using CliFx.Infrastructure; +using FluentAssertions; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Xunit; +using Xunit.Abstractions; + +namespace CliFx.Tests +{ + public class SuggestHookInstallerSpecs : SpecsBase + { + private FakeFileSystem FakeFileSystem => new (); + + public SuggestHookInstallerSpecs(ITestOutputHelper testOutput) + : base(testOutput) + { + } + + public CliApplicationBuilder TestApplicationFactory(params string[] commandClasses) + { + var builder = new CliApplicationBuilder(); + return builder.UseConsole(FakeConsole); + } + + [Theory] + [InlineData("/usr/bin/bash", ".bashrc")] + [InlineData("/usr/bin/pwsh", "Microsoft.PowerShell_profile.ps1")] + [InlineData(@"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe", "Microsoft.PowerShell_profile.ps1")] + public async Task Suggest_hook_is_added_when_suggestions_are_allowed(string shellPath, string expectedHookScript) + { + // Arrange + var fileSystem = new FakeFileSystem(); + fileSystem.Files[shellPath] = "stub shell interpeter"; + + var application = TestApplicationFactory() + .AllowSuggestMode(true) + .UseFileSystem(fileSystem) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new string[] { } + ); + + // Assert + exitCode.Should().Be(0); + fileSystem.FilePaths.Count(p => p.Contains(expectedHookScript)) + .Should().Be(1, "expect to see hook script"); + + var hookScript = fileSystem.Files.Where(p => p.Key.Contains(expectedHookScript)).First().Value; + + hookScript.Should().Contain($"### clifx-suggest-begins-here-{application.Metadata.Title}"); + hookScript.Should().Contain($"### clifx-suggest-ends-here-{application.Metadata.Title}"); + + } + + [Fact] + public async Task Suggest_hook_is_not_installed_when_suggestions_arent_allowed() + { + // Arrange + var fileSystem = new FakeFileSystem(); + fileSystem.Files["/usr/bin/bash"] = "stub shell interpeter"; + + var application = TestApplicationFactory() + .AllowSuggestMode(false) + .UseFileSystem(fileSystem) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new string[] { } + ); + + // Assert + exitCode.Should().Be(0); + fileSystem.FilePaths.Count(p => p.Contains(".bashrc")).Should().Be(0); + } + } +} diff --git a/CliFx/Infrastructure/FakeFileSystem.cs b/CliFx/Infrastructure/FakeFileSystem.cs index 1c1226e6..306115aa 100644 --- a/CliFx/Infrastructure/FakeFileSystem.cs +++ b/CliFx/Infrastructure/FakeFileSystem.cs @@ -9,13 +9,18 @@ namespace CliFx.Infrastructure /// /// A mock for IFileSystem /// - public class FakeSystem : IFileSystem + public class FakeFileSystem : IFileSystem { - public Dictionary Files => new Dictionary(); + public Dictionary Files = new Dictionary(); + + public IEnumerable FilePaths => Files.Keys; public void Copy(string sourceFileName, string destFileName) { - Files[destFileName] = Files[sourceFileName]; + if( Files.ContainsKey(sourceFileName)) + { + Files[destFileName] = Files[sourceFileName]; + } } public bool Exists(string path) diff --git a/CliFx/Suggestions/BashEnvironment.cs b/CliFx/Suggestions/BashEnvironment.cs index f2e6fb4b..6ff4a8c4 100644 --- a/CliFx/Suggestions/BashEnvironment.cs +++ b/CliFx/Suggestions/BashEnvironment.cs @@ -1,55 +1,55 @@ -using CliFx.Infrastructure; -using CliFx.Input; -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Runtime.InteropServices; -using System.Text; +using CliFx.Infrastructure; +using CliFx.Input; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; using System.Text.RegularExpressions; -namespace CliFx.Suggestions -{ - class BashEnvironment : ISuggestEnvironment - { - public string Version => "V1"; - - public string[] SupportedShellPaths => new[] { @"/usr/bin/bash" }; - - public string InstallPath => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".bashrc"); - - public string GetInstallCommand(string commandName) - { +namespace CliFx.Suggestions +{ + class BashEnvironment : ISuggestEnvironment + { + public string Version => "V1"; + + public string[] SupportedShellPaths => new[] { @"/usr/bin/bash" }; + + public string InstallPath => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".bashrc"); + + public string GetInstallCommand(string commandName) + { var safeName = new Regex("[^a-zA-Z0-9]").Replace(commandName, ""); - return $@" -### clifx-suggest-begins-here-{commandName}-{Version} -# this block provides auto-complete for the {commandName} command -# and assumes that {commandName} is on the path -_{safeName}_complete() -{{ - local word=${{COMP_WORDS[COMP_CWORD]}} - - # generate unique environment variable - CLIFX_CMD_CACHE=""clifx-suggest-$(uuidgen)"" - # replace hyphens with underscores to make it valid - CLIFX_CMD_CACHE=${{CLIFX_CMD_CACHE//\-/_}} - - export $CLIFX_CMD_CACHE=${{COMP_LINE}} - - local completions - completions=""$({commandName} ""[suggest]"" --cursor ""${{COMP_POINT}}"" --envvar $CLIFX_CMD_CACHE 2>/dev/null)"" - if [ $? -ne 0 ]; then - completions="""" - fi - - unset $CLIFX_CMD_CACHE - - COMPREPLY=( $(compgen -W ""$completions"" -- ""$word"") ) -}} - -complete -f -F _{safeName}_complete ""{commandName}"" - -### clifx-suggest-ends-here-{commandName}".Replace("\r", ""); - } - } -} + return $@" +### clifx-suggest-begins-here-{commandName}-{Version} +# this block provides auto-complete for the {commandName} command +# and assumes that {commandName} is on the path +_{safeName}_complete() +{{ + local word=${{COMP_WORDS[COMP_CWORD]}} + + # generate unique environment variable + CLIFX_CMD_CACHE=""clifx-suggest-$(uuidgen)"" + # replace hyphens with underscores to make it valid + CLIFX_CMD_CACHE=${{CLIFX_CMD_CACHE//\-/_}} + + export $CLIFX_CMD_CACHE=${{COMP_LINE}} + + local completions + completions=""$({commandName} ""[suggest]"" --cursor ""${{COMP_POINT}}"" --envvar $CLIFX_CMD_CACHE 2>/dev/null)"" + if [ $? -ne 0 ]; then + completions="""" + fi + + unset $CLIFX_CMD_CACHE + + COMPREPLY=( $(compgen -W ""$completions"" -- ""$word"") ) +}} + +complete -f -F _{safeName}_complete ""{commandName}"" + +### clifx-suggest-ends-here-{commandName}".Replace("\r", ""); + } + } +} From cc472886d234c880879713e8ee81108666feb996 Mon Sep 17 00:00:00 2001 From: mauricel Date: Mon, 12 Apr 2021 15:56:28 +0100 Subject: [PATCH 18/31] Remove auto-install of suggest hooks. Hooks can be installed by user with [suggest] --install command line. --- CliFx.Tests/SuggestHookInstallSpecs.cs | 12 +++++----- CliFx/CliApplication.cs | 32 ++++++++++++++------------ CliFx/Suggestions/SuggestionService.cs | 5 ++++ 3 files changed, 28 insertions(+), 21 deletions(-) diff --git a/CliFx.Tests/SuggestHookInstallSpecs.cs b/CliFx.Tests/SuggestHookInstallSpecs.cs index 4173b537..0063a8f7 100644 --- a/CliFx.Tests/SuggestHookInstallSpecs.cs +++ b/CliFx.Tests/SuggestHookInstallSpecs.cs @@ -29,7 +29,7 @@ public CliApplicationBuilder TestApplicationFactory(params string[] commandClass [InlineData("/usr/bin/bash", ".bashrc")] [InlineData("/usr/bin/pwsh", "Microsoft.PowerShell_profile.ps1")] [InlineData(@"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe", "Microsoft.PowerShell_profile.ps1")] - public async Task Suggest_hook_is_added_when_suggestions_are_allowed(string shellPath, string expectedHookScript) + public async Task Suggest_hook_is_added_when_install_parameter_is_provided(string shellPath, string expectedHookScript) { // Arrange var fileSystem = new FakeFileSystem(); @@ -38,11 +38,11 @@ public async Task Suggest_hook_is_added_when_suggestions_are_allowed(string shel var application = TestApplicationFactory() .AllowSuggestMode(true) .UseFileSystem(fileSystem) - .Build(); + .Build(); // Act var exitCode = await application.RunAsync( - new string[] { } + new[] { "[suggest]", "--install" } ); // Assert @@ -58,20 +58,20 @@ public async Task Suggest_hook_is_added_when_suggestions_are_allowed(string shel } [Fact] - public async Task Suggest_hook_is_not_installed_when_suggestions_arent_allowed() + public async Task Suggest_hook_is_not_installed_by_default() { // Arrange var fileSystem = new FakeFileSystem(); fileSystem.Files["/usr/bin/bash"] = "stub shell interpeter"; var application = TestApplicationFactory() - .AllowSuggestMode(false) + .AllowSuggestMode(true) .UseFileSystem(fileSystem) .Build(); // Act var exitCode = await application.RunAsync( - new string[] { } + new[] { "[suggest]" } ); // Assert diff --git a/CliFx/CliApplication.cs b/CliFx/CliApplication.cs index 2431a02f..80c66780 100644 --- a/CliFx/CliApplication.cs +++ b/CliFx/CliApplication.cs @@ -64,9 +64,9 @@ private bool IsSuggestModeEnabled(CommandInput commandInput) => Configuration.IsSuggestModeAllowed && commandInput.IsSuggestDirectiveSpecified; private bool ShouldShowHelpText(CommandSchema commandSchema, CommandInput commandInput) => - commandSchema.IsHelpOptionAvailable && commandInput.IsHelpOptionSpecified || - // Show help text also in case the fallback default command is - // executed without any arguments. + commandSchema.IsHelpOptionAvailable && commandInput.IsHelpOptionSpecified || + // Show help text also in case the fallback default command is + // executed without any arguments. commandSchema == FallbackDefaultCommand.Schema && string.IsNullOrWhiteSpace(commandInput.CommandName) && !commandInput.Parameters.Any() && @@ -110,16 +110,18 @@ private async ValueTask RunAsync(ApplicationSchema applicationSchema, Comma return 0; } - // Handle suggest directive - if (Configuration.IsSuggestModeAllowed) - { - new SuggestShellHookInstaller(_fileSystem).Install(Metadata.Title); - } + // Handle suggest directive if (IsSuggestModeEnabled(commandInput)) { - new SuggestionService(applicationSchema, _fileSystem, commandInput.EnvironmentVariables) - .GetSuggestions(commandInput).ToList() - .ForEach(p => _console.Output.WriteLine(p)); + var suggestionService = new SuggestionService(applicationSchema, _fileSystem, commandInput.EnvironmentVariables); + + if (suggestionService.ShouldInstallHooks(commandInput)) + { + new SuggestShellHookInstaller(_fileSystem).Install(Metadata.Title); + } + + suggestionService.GetSuggestions(commandInput).ToList() + .ForEach(p => _console.Output.WriteLine(p)); return 0; } @@ -132,7 +134,7 @@ private async ValueTask RunAsync(ApplicationSchema applicationSchema, Comma // Activate command instance var commandInstance = commandSchema == FallbackDefaultCommand.Schema ? new FallbackDefaultCommand() // bypass activator - : (ICommand) _typeActivator.CreateInstance(commandSchema.Type); + : (ICommand)_typeActivator.CreateInstance(commandSchema.Type); // Assemble help context var helpContext = new HelpContext( @@ -235,9 +237,9 @@ public async ValueTask RunAsync( /// reports them to the console. /// public async ValueTask RunAsync(IReadOnlyList commandLineArguments) => await RunAsync( - commandLineArguments, - // Use case-sensitive comparison because environment variables are - // case-sensitive on Linux and macOS (but not on Windows). + commandLineArguments, + // Use case-sensitive comparison because environment variables are + // case-sensitive on Linux and macOS (but not on Windows). Environment .GetEnvironmentVariables() .ToDictionary(StringComparer.Ordinal) diff --git a/CliFx/Suggestions/SuggestionService.cs b/CliFx/Suggestions/SuggestionService.cs index 207b448a..1964fd5d 100644 --- a/CliFx/Suggestions/SuggestionService.cs +++ b/CliFx/Suggestions/SuggestionService.cs @@ -24,6 +24,11 @@ public SuggestionService(ApplicationSchema applicationSchema, IFileSystem fileSy _environmentVariableInputs = environmentVariableInputs; } + public bool ShouldInstallHooks(CommandInput commandInput) + { + return commandInput.Options.Any(p => p.Identifier == "install"); + } + public IEnumerable GetSuggestions(CommandInput commandInput) { var text = ExtractCommandText(commandInput); From a7f467fa278885699e581b04c737907fa4e55b4a Mon Sep 17 00:00:00 2001 From: mauricel Date: Mon, 12 Apr 2021 16:01:53 +0100 Subject: [PATCH 19/31] Ensure any backups created during installation don't get auto-deleted. --- CliFx/Infrastructure/FileSystem.cs | 5 +- CliFx/Infrastructure/IFileSystem.cs | 1 - .../Suggestions/SuggestShellHookInstaller.cs | 111 +++++++++--------- 3 files changed, 57 insertions(+), 60 deletions(-) diff --git a/CliFx/Infrastructure/FileSystem.cs b/CliFx/Infrastructure/FileSystem.cs index 563d4a5f..1ce7103c 100644 --- a/CliFx/Infrastructure/FileSystem.cs +++ b/CliFx/Infrastructure/FileSystem.cs @@ -7,10 +7,7 @@ internal class FileSystem : IFileSystem { public void Copy(string sourceFileName, string destFileName) { - if( File.Exists(sourceFileName)) - { - File.Copy(sourceFileName, destFileName); - } + File.Copy(sourceFileName, destFileName); } public bool Exists(string path) diff --git a/CliFx/Infrastructure/IFileSystem.cs b/CliFx/Infrastructure/IFileSystem.cs index 532136bb..e7d7ecc7 100644 --- a/CliFx/Infrastructure/IFileSystem.cs +++ b/CliFx/Infrastructure/IFileSystem.cs @@ -7,7 +7,6 @@ public interface IFileSystem { /// /// Determines whether the specified file exists. - /// Doesn't work in Linux Subsystem for Windows unless application if the application resides on a windows mount (eg /mnt/c/...) /// bool Exists(string filePath); diff --git a/CliFx/Suggestions/SuggestShellHookInstaller.cs b/CliFx/Suggestions/SuggestShellHookInstaller.cs index 94bacef2..ca38d321 100644 --- a/CliFx/Suggestions/SuggestShellHookInstaller.cs +++ b/CliFx/Suggestions/SuggestShellHookInstaller.cs @@ -1,55 +1,56 @@ -using CliFx.Infrastructure; -using System; -using System.IO; -using System.Linq; -using System.Text; -using System.Text.RegularExpressions; - -namespace CliFx.Suggestions -{ - class SuggestShellHookInstaller - { - private readonly IFileSystem _fileSystem; - - public SuggestShellHookInstaller(IFileSystem fileSystem) - { - _fileSystem = fileSystem; - } - - public void Install(string commandName) - { - var detectedEnvironments = new ISuggestEnvironment[] { - new BashEnvironment(), - new WindowsPowershellEnvironment(), - new UnixPowershellEnvironment() } - .Where(env => env.SupportedShellPaths.Any(p => _fileSystem.Exists(p) )); - - foreach (var env in detectedEnvironments) - { - var path = env.InstallPath; - var pattern = $"### clifx-suggest-begins-here-{Regex.Escape(commandName)}-{env.Version}.*### clifx-suggest-ends-here-{Regex.Escape(commandName)}"; - - string script = ""; - _fileSystem.TryReadText(path, out script); - var match = Regex.Match(script, pattern, RegexOptions.Singleline); - if (match.Success) - { - continue; - } - - var uninstallPattern = $"### clifx-suggest-begins-here-{Regex.Escape(commandName)}.*### clifx-suggest-ends-here-{Regex.Escape(commandName)}"; - var sb = new StringBuilder(Regex.Replace(script, uninstallPattern, "", RegexOptions.Singleline)); - sb.AppendLine(env.GetInstallCommand(commandName)); - - // backup to temp folder for OS to delete eventually (just in case something really bad happens) - var tempFile = Path.GetFileName(path); - var tempExtension = Path.GetExtension(tempFile) + $".backup_{DateTime.UtcNow.ToFileTime()}"; - tempFile = Path.ChangeExtension(tempFile, tempExtension); - var backupPath = Path.Combine(Path.GetTempPath(), tempFile); - - _fileSystem.Copy(path, backupPath); - _fileSystem.WriteText(path, sb.ToString()); - } - } - } -} +using CliFx.Infrastructure; +using System; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; + +namespace CliFx.Suggestions +{ + class SuggestShellHookInstaller + { + private readonly IFileSystem _fileSystem; + + public SuggestShellHookInstaller(IFileSystem fileSystem) + { + _fileSystem = fileSystem; + } + + public void Install(string commandName) + { + var detectedEnvironments = new ISuggestEnvironment[] { + new BashEnvironment(), + new WindowsPowershellEnvironment(), + new UnixPowershellEnvironment() } + .Where(env => env.SupportedShellPaths.Any(p => _fileSystem.Exists(p) )); + + foreach (var env in detectedEnvironments) + { + var path = env.InstallPath; + var pattern = $"### clifx-suggest-begins-here-{Regex.Escape(commandName)}-{env.Version}.*### clifx-suggest-ends-here-{Regex.Escape(commandName)}"; + + string script = ""; + _fileSystem.TryReadText(path, out script); + var match = Regex.Match(script, pattern, RegexOptions.Singleline); + if (match.Success) + { + continue; + } + + var uninstallPattern = $"### clifx-suggest-begins-here-{Regex.Escape(commandName)}.*### clifx-suggest-ends-here-{Regex.Escape(commandName)}"; + var sb = new StringBuilder(Regex.Replace(script, uninstallPattern, "", RegexOptions.Singleline)); + sb.AppendLine(env.GetInstallCommand(commandName)); + + // create backup in case something bad happens + var backupExtension = Path.GetExtension(path) + $".backup_{DateTime.UtcNow.ToFileTime()}"; + var backupPath = Path.ChangeExtension(path, backupExtension); + + if (_fileSystem.Exists(path)) + { + _fileSystem.Copy(path, backupPath); + } + _fileSystem.WriteText(path, sb.ToString()); + } + } + } +} From 7f0fe4d4995a2cc9c49705c736e63782bed64bc4 Mon Sep 17 00:00:00 2001 From: mauricel Date: Mon, 12 Apr 2021 17:53:49 +0100 Subject: [PATCH 20/31] Normalise line endings --- CliFx/Suggestions/SuggestionService.cs | 174 ++++++++++++------------- 1 file changed, 87 insertions(+), 87 deletions(-) diff --git a/CliFx/Suggestions/SuggestionService.cs b/CliFx/Suggestions/SuggestionService.cs index 1964fd5d..fe7eea8d 100644 --- a/CliFx/Suggestions/SuggestionService.cs +++ b/CliFx/Suggestions/SuggestionService.cs @@ -1,90 +1,90 @@ -using CliFx.Infrastructure; -using CliFx.Input; -using CliFx.Schema; -using CliFx.Utils; -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text; -using System.Text.RegularExpressions; - -namespace CliFx.Suggestions -{ - internal class SuggestionService - { - private ApplicationSchema _applicationSchema; - private readonly IFileSystem _fileSystem; - private readonly IReadOnlyList _environmentVariableInputs; - - public SuggestionService(ApplicationSchema applicationSchema, IFileSystem fileSystem, IReadOnlyList environmentVariableInputs) - { - _applicationSchema = applicationSchema; - _fileSystem = fileSystem; - _environmentVariableInputs = environmentVariableInputs; - } - +using CliFx.Infrastructure; +using CliFx.Input; +using CliFx.Schema; +using CliFx.Utils; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; + +namespace CliFx.Suggestions +{ + internal class SuggestionService + { + private ApplicationSchema _applicationSchema; + private readonly IFileSystem _fileSystem; + private readonly IReadOnlyList _environmentVariableInputs; + + public SuggestionService(ApplicationSchema applicationSchema, IFileSystem fileSystem, IReadOnlyList environmentVariableInputs) + { + _applicationSchema = applicationSchema; + _fileSystem = fileSystem; + _environmentVariableInputs = environmentVariableInputs; + } + public bool ShouldInstallHooks(CommandInput commandInput) { return commandInput.Options.Any(p => p.Identifier == "install"); - } - - public IEnumerable GetSuggestions(CommandInput commandInput) - { - var text = ExtractCommandText(commandInput); - var suggestArgs = CommandLineSplitter.Split(text).Skip(1); // ignore the application name - - var suggestInput = CommandInput.Parse( - suggestArgs.ToArray(), - _environmentVariableInputs.ToDictionary(p => p.Name, p => p.Value), - _applicationSchema.GetCommandNames()); - - var commandMatch = _applicationSchema.Commands - .FirstOrDefault(p => string.Equals(p.Name, suggestInput.CommandName, StringComparison.OrdinalIgnoreCase)); - - // suggest a command name if we don't have an exact match - if (commandMatch == null) - { - return _applicationSchema.GetCommandNames() - .Where(p => p.StartsWith(suggestInput.CommandName, StringComparison.OrdinalIgnoreCase)) - .OrderBy(p => p) - .ToList(); - } - - return NoSuggestions(); - } - - private string ExtractCommandText(CommandInput input) - { - // Accept command line arguments via environment variable as a workaround to powershell escape sequence shennidgans - var commandCacheVariable = input.Options.FirstOrDefault(p => p.Identifier == "envvar")?.Values[0]; - - if (commandCacheVariable == null) - { - // ignore cursor position as we don't know what the original user input string really is - return string.Join(" ", input.OriginalCommandLine.Where(arg => !IsDirective(arg))); - } - - var command = input.EnvironmentVariables.FirstOrDefault(p => string.Equals(p.Name, commandCacheVariable))?.Value ?? ""; - var cursorPositionText = input.Options.FirstOrDefault(p => p.Identifier == "cursor")?.Values[0]; - var cursorPosition = command.Length; - - if (int.TryParse(cursorPositionText, out cursorPosition) && cursorPosition < command.Length) - { - return command.Remove(cursorPosition); - } - return command; - } - - private static bool IsDirective(string arg) - { - return arg.StartsWith('[') && arg.EndsWith(']'); - } - - private static List NoSuggestions() - { - return new List(); - } - } -} - + } + + public IEnumerable GetSuggestions(CommandInput commandInput) + { + var text = ExtractCommandText(commandInput); + var suggestArgs = CommandLineSplitter.Split(text).Skip(1); // ignore the application name + + var suggestInput = CommandInput.Parse( + suggestArgs.ToArray(), + _environmentVariableInputs.ToDictionary(p => p.Name, p => p.Value), + _applicationSchema.GetCommandNames()); + + var commandMatch = _applicationSchema.Commands + .FirstOrDefault(p => string.Equals(p.Name, suggestInput.CommandName, StringComparison.OrdinalIgnoreCase)); + + // suggest a command name if we don't have an exact match + if (commandMatch == null) + { + return _applicationSchema.GetCommandNames() + .Where(p => p.StartsWith(suggestInput.CommandName, StringComparison.OrdinalIgnoreCase)) + .OrderBy(p => p) + .ToList(); + } + + return NoSuggestions(); + } + + private string ExtractCommandText(CommandInput input) + { + // Accept command line arguments via environment variable as a workaround to powershell escape sequence shennidgans + var commandCacheVariable = input.Options.FirstOrDefault(p => p.Identifier == "envvar")?.Values[0]; + + if (commandCacheVariable == null) + { + // ignore cursor position as we don't know what the original user input string really is + return string.Join(" ", input.OriginalCommandLine.Where(arg => !IsDirective(arg))); + } + + var command = input.EnvironmentVariables.FirstOrDefault(p => string.Equals(p.Name, commandCacheVariable))?.Value ?? ""; + var cursorPositionText = input.Options.FirstOrDefault(p => p.Identifier == "cursor")?.Values[0]; + var cursorPosition = command.Length; + + if (int.TryParse(cursorPositionText, out cursorPosition) && cursorPosition < command.Length) + { + return command.Remove(cursorPosition); + } + return command; + } + + private static bool IsDirective(string arg) + { + return arg.StartsWith('[') && arg.EndsWith(']'); + } + + private static List NoSuggestions() + { + return new List(); + } + } +} + From e67a9c273af252a2afa5dc33ef9343f3b271a021 Mon Sep 17 00:00:00 2001 From: mauricel Date: Mon, 12 Apr 2021 22:40:29 +0100 Subject: [PATCH 21/31] Implement options autosuggestion. --- CliFx.Tests/SuggestDirectiveSpecs.cs | 53 ++++++++++++++++++++++++++ CliFx/Input/CommandInput.cs | 10 +++-- CliFx/Input/OptionInput.cs | 6 ++- CliFx/Suggestions/SuggestionService.cs | 32 ++++++++++++++++ 4 files changed, 97 insertions(+), 4 deletions(-) diff --git a/CliFx.Tests/SuggestDirectiveSpecs.cs b/CliFx.Tests/SuggestDirectiveSpecs.cs index 5529ee64..1392ccae 100644 --- a/CliFx.Tests/SuggestDirectiveSpecs.cs +++ b/CliFx.Tests/SuggestDirectiveSpecs.cs @@ -144,5 +144,58 @@ public async Task Suggest_directive_suggests_commands_by_command_line_only(strin .Where(p => !string.IsNullOrWhiteSpace(p)) .Should().BeEquivalentTo(expected, usecase); } + + [Theory] + [InlineData("suggest all option names", + "clifx.exe opt --", 0, new[] { "--help", "--opt", "--opt01", "--opt02" })] + [InlineData("suggest all option names beginning with prefix", + "clifx.exe opt --opt0", 0, new[] { "--opt01", "--opt02" })] + [InlineData("suggest all option names and aliases", + "clifx.exe opt -", 0, new[] { "-1", "-2", "-h", "-o", "--help", "--opt", "--opt01", "--opt02" })] + [InlineData("don't suggest additional aliases because it doesn't feel right even if it is valid?", + "clifx.exe opt -1", 0, new string[] { })] + [InlineData("don't suggest for exact matches", + "clifx.exe opt --opt01", 0, new string[] { })] + public async Task Suggest_directive_suggests_options(string usecase, string variableContents, int cursorOffset, string[] expected) + { + // Arrange + var optCommandCs = @" +[Command(""opt"")] +public class OptionCommand : ICommand +{ + [CommandOption(""opt"", 'o')] + public string Option { get; set; } = """"; + + [CommandOption(""opt01"", '1')] + public string Option01 { get; set; } = """"; + + [CommandOption(""opt02"", '2')] + public string Option02 { get; set; } = """"; + + public ValueTask ExecuteAsync(IConsole console) => default; +} +"; + var application = TestApplicationFactory(optCommandCs) + .AllowSuggestMode() + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] { "[suggest]", "--envvar", "CLIFX-{GUID}", "--cursor", (variableContents.Length + cursorOffset).ToString() }, + new Dictionary() + { + ["CLIFX-{GUID}"] = variableContents + } + ); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + + stdOut.Split(null) + .Where(p => !string.IsNullOrWhiteSpace(p)) + .Should().BeEquivalentTo(expected, usecase); + } } } \ No newline at end of file diff --git a/CliFx/Input/CommandInput.cs b/CliFx/Input/CommandInput.cs index 6eb2592a..d2a14601 100644 --- a/CliFx/Input/CommandInput.cs +++ b/CliFx/Input/CommandInput.cs @@ -150,6 +150,7 @@ private static IReadOnlyList ParseOptions( var lastOptionIdentifier = default(string?); var lastOptionValues = new List(); + var lastText = ""; // Consume and group all remaining arguments into options for (; index < commandLineArguments.Count; index++) @@ -163,8 +164,9 @@ private static IReadOnlyList ParseOptions( { // Flush previous if (!string.IsNullOrWhiteSpace(lastOptionIdentifier)) - result.Add(new OptionInput(lastOptionIdentifier, lastOptionValues)); + result.Add(new OptionInput(lastOptionIdentifier, lastOptionValues, lastText)); + lastText = argument; lastOptionIdentifier = argument.Substring(2); lastOptionValues = new List(); } @@ -177,8 +179,9 @@ private static IReadOnlyList ParseOptions( { // Flush previous if (!string.IsNullOrWhiteSpace(lastOptionIdentifier)) - result.Add(new OptionInput(lastOptionIdentifier, lastOptionValues)); + result.Add(new OptionInput(lastOptionIdentifier, lastOptionValues, lastText)); + lastText = argument; lastOptionIdentifier = alias.AsString(); lastOptionValues = new List(); } @@ -186,13 +189,14 @@ private static IReadOnlyList ParseOptions( // Value else if (!string.IsNullOrWhiteSpace(lastOptionIdentifier)) { + lastText = $"{lastText} {argument}"; lastOptionValues.Add(argument); } } // Flush last option if (!string.IsNullOrWhiteSpace(lastOptionIdentifier)) - result.Add(new OptionInput(lastOptionIdentifier, lastOptionValues)); + result.Add(new OptionInput(lastOptionIdentifier, lastOptionValues, lastText)); return result; } diff --git a/CliFx/Input/OptionInput.cs b/CliFx/Input/OptionInput.cs index 38b1c2dc..2ebd9fd7 100644 --- a/CliFx/Input/OptionInput.cs +++ b/CliFx/Input/OptionInput.cs @@ -9,18 +9,22 @@ internal class OptionInput public IReadOnlyList Values { get; } + public string RawText { get; } + public bool IsHelpOption => OptionSchema.HelpOption.MatchesIdentifier(Identifier); public bool IsVersionOption => OptionSchema.VersionOption.MatchesIdentifier(Identifier); - public OptionInput(string identifier, IReadOnlyList values) + public OptionInput(string identifier, IReadOnlyList values, string rawText) { Identifier = identifier; Values = values; + RawText = rawText; } + public string GetFormattedIdentifier() => Identifier switch { {Length: >= 2} => "--" + Identifier, diff --git a/CliFx/Suggestions/SuggestionService.cs b/CliFx/Suggestions/SuggestionService.cs index fe7eea8d..4d54ec95 100644 --- a/CliFx/Suggestions/SuggestionService.cs +++ b/CliFx/Suggestions/SuggestionService.cs @@ -51,6 +51,38 @@ public IEnumerable GetSuggestions(CommandInput commandInput) .ToList(); } + // handle options for the command we found + var option = suggestInput.Options.LastOrDefault(); + if (option != null) + { + if( commandMatch.Options.Any(p => p.MatchesIdentifier(option.Identifier))) + { + // Don't return any suggestions for exact option matches + return NoSuggestions(); + } + + if (option.RawText.StartsWith("--")) + { + return commandMatch.Options + .Where(p => p.Name != null && p.Name.StartsWith(option.Identifier, StringComparison.OrdinalIgnoreCase)) + .Select(p => $"--{p.Name}"); + } + return NoSuggestions(); + } + + // the parser returns a parameter for "--" or "-" + var lastParameter = suggestInput.Parameters.LastOrDefault(); + if (lastParameter?.Value == "--") + { + return commandMatch.Options.OrderBy(p => p.Name).Select(p => $"--{p.Name}"); + } + + if (lastParameter?.Value == "-") + { + return commandMatch.Options.OrderBy(p => p.ShortName).Select(p => $"-{p.ShortName}") + .Concat(commandMatch.Options.Select(p => $"--{p.Name}")); + } + return NoSuggestions(); } From 09108a3eba7f8753623ec7fb4ac04a7ae5b95598 Mon Sep 17 00:00:00 2001 From: mauricel Date: Tue, 13 Apr 2021 09:50:32 +0100 Subject: [PATCH 22/31] Fix bug: suggest now knows the difference between ShortNames and Names when searching for an exact options match. --- CliFx.Tests/SuggestDirectiveSpecs.cs | 2 ++ CliFx/Suggestions/SuggestionService.cs | 29 +++++++++++++++----------- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/CliFx.Tests/SuggestDirectiveSpecs.cs b/CliFx.Tests/SuggestDirectiveSpecs.cs index 1392ccae..6dfbd6a8 100644 --- a/CliFx.Tests/SuggestDirectiveSpecs.cs +++ b/CliFx.Tests/SuggestDirectiveSpecs.cs @@ -150,6 +150,8 @@ public async Task Suggest_directive_suggests_commands_by_command_line_only(strin "clifx.exe opt --", 0, new[] { "--help", "--opt", "--opt01", "--opt02" })] [InlineData("suggest all option names beginning with prefix", "clifx.exe opt --opt0", 0, new[] { "--opt01", "--opt02" })] + [InlineData("suggest all option names beginning with prefix that also match short names", + "clifx.exe opt --o", 0, new[] { "--opt", "--opt01", "--opt02" })] [InlineData("suggest all option names and aliases", "clifx.exe opt -", 0, new[] { "-1", "-2", "-h", "-o", "--help", "--opt", "--opt01", "--opt02" })] [InlineData("don't suggest additional aliases because it doesn't feel right even if it is valid?", diff --git a/CliFx/Suggestions/SuggestionService.cs b/CliFx/Suggestions/SuggestionService.cs index 4d54ec95..2b4a8d8c 100644 --- a/CliFx/Suggestions/SuggestionService.cs +++ b/CliFx/Suggestions/SuggestionService.cs @@ -39,11 +39,11 @@ public IEnumerable GetSuggestions(CommandInput commandInput) _environmentVariableInputs.ToDictionary(p => p.Name, p => p.Value), _applicationSchema.GetCommandNames()); - var commandMatch = _applicationSchema.Commands + var commandSchema = _applicationSchema.Commands .FirstOrDefault(p => string.Equals(p.Name, suggestInput.CommandName, StringComparison.OrdinalIgnoreCase)); // suggest a command name if we don't have an exact match - if (commandMatch == null) + if (commandSchema == null) { return _applicationSchema.GetCommandNames() .Where(p => p.StartsWith(suggestInput.CommandName, StringComparison.OrdinalIgnoreCase)) @@ -52,19 +52,24 @@ public IEnumerable GetSuggestions(CommandInput commandInput) } // handle options for the command we found - var option = suggestInput.Options.LastOrDefault(); - if (option != null) + var optionInput = suggestInput.Options.LastOrDefault(); + if (optionInput != null) { - if( commandMatch.Options.Any(p => p.MatchesIdentifier(option.Identifier))) + bool exactOptionMatchFound = commandSchema.Options.Any(p => + + string.Equals($"--{p.Name}", optionInput.RawText, StringComparison.OrdinalIgnoreCase) || + string.Equals($"-{p.ShortName}", optionInput.RawText) + ); + + if (exactOptionMatchFound) { - // Don't return any suggestions for exact option matches return NoSuggestions(); } - if (option.RawText.StartsWith("--")) + if (optionInput.RawText.StartsWith("--")) { - return commandMatch.Options - .Where(p => p.Name != null && p.Name.StartsWith(option.Identifier, StringComparison.OrdinalIgnoreCase)) + return commandSchema.Options + .Where(p => p.Name != null && p.Name.StartsWith(optionInput.Identifier, StringComparison.OrdinalIgnoreCase)) .Select(p => $"--{p.Name}"); } return NoSuggestions(); @@ -74,13 +79,13 @@ public IEnumerable GetSuggestions(CommandInput commandInput) var lastParameter = suggestInput.Parameters.LastOrDefault(); if (lastParameter?.Value == "--") { - return commandMatch.Options.OrderBy(p => p.Name).Select(p => $"--{p.Name}"); + return commandSchema.Options.OrderBy(p => p.Name).Select(p => $"--{p.Name}"); } if (lastParameter?.Value == "-") { - return commandMatch.Options.OrderBy(p => p.ShortName).Select(p => $"-{p.ShortName}") - .Concat(commandMatch.Options.Select(p => $"--{p.Name}")); + return commandSchema.Options.OrderBy(p => p.ShortName).Select(p => $"-{p.ShortName}") + .Concat(commandSchema.Options.Select(p => $"--{p.Name}")); } return NoSuggestions(); From 8cd92d6e019a723f6c4cb2c4cedd4c6d275ade8b Mon Sep 17 00:00:00 2001 From: mauricel Date: Tue, 13 Apr 2021 13:55:09 +0100 Subject: [PATCH 23/31] Implement parameter suggestions. --- CliFx.Tests/SuggestDirectiveSpecs.cs | 68 ++++ CliFx/CliApplication.cs | 509 +++++++++++++------------ CliFx/Suggestions/SuggestionService.cs | 86 +++-- 3 files changed, 387 insertions(+), 276 deletions(-) diff --git a/CliFx.Tests/SuggestDirectiveSpecs.cs b/CliFx.Tests/SuggestDirectiveSpecs.cs index 6dfbd6a8..3ab2ca25 100644 --- a/CliFx.Tests/SuggestDirectiveSpecs.cs +++ b/CliFx.Tests/SuggestDirectiveSpecs.cs @@ -199,5 +199,73 @@ public class OptionCommand : ICommand .Where(p => !string.IsNullOrWhiteSpace(p)) .Should().BeEquivalentTo(expected, usecase); } + + + [Theory] + [InlineData("don't suggest parameters that don't have a sensible suggestion", + "clifx.exe cmd x", 0, new string[] { })] + [InlineData("suggest parameters where valid values are present", + "clifx.exe cmd x Re", 0, new[] { "Red", "RedOrange" })] + [InlineData("don't suggest parameters where complete values are present", + "clifx.exe cmd x Red", 0, new string[] { })] + [InlineData("suggest for non-scalar parameters", + "clifx.exe cmd x Red R", 0, new[] { "Red", "RedOrange" })] + [InlineData("suggest options when parameter present", + "clifx.exe cmd x --opt0", 0, new[] { "--opt01", "--opt02" })] + public async Task Suggest_directive_suggests_parameters(string usecase, string variableContents, int cursorOffset, string[] expected) + { + // Arrange + var optCommandCs = @" +public enum TestColor +{ + Red, RedOrange, Green, Blue +} + +[Command(""cmd"")] +public class ParameterCommand : ICommand +{ + [CommandParameter(0, Name = ""param"")] + public string Parameter { get; set; } = """"; + + [CommandParameter(1, Name = ""color"")] + public TestColor Color { get; set; } + + [CommandParameter(2, Name = ""hue"")] + public IReadOnlyList Hue { get; set;} + + [CommandOption(""opt"", 'o')] + public string Option { get; set; } = """"; + + [CommandOption(""opt01"", '1')] + public string Option01 { get; set; } = """"; + + [CommandOption(""opt02"", '2')] + public string Option02 { get; set; } = """"; + + public ValueTask ExecuteAsync(IConsole console) => default; +} +"; + var application = TestApplicationFactory(optCommandCs) + .AllowSuggestMode() + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] { "[suggest]", "--envvar", "CLIFX-{GUID}", "--cursor", (variableContents.Length + cursorOffset).ToString() }, + new Dictionary() + { + ["CLIFX-{GUID}"] = variableContents + } + ); + + var stdOut = FakeConsole.ReadOutputString(); + + // Assert + exitCode.Should().Be(0); + + stdOut.Split(null) + .Where(p => !string.IsNullOrWhiteSpace(p)) + .Should().BeEquivalentTo(expected, usecase); + } } } \ No newline at end of file diff --git a/CliFx/CliApplication.cs b/CliFx/CliApplication.cs index 80c66780..3b655377 100644 --- a/CliFx/CliApplication.cs +++ b/CliFx/CliApplication.cs @@ -1,263 +1,264 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Threading.Tasks; -using CliFx.Exceptions; -using CliFx.Formatting; -using CliFx.Infrastructure; -using CliFx.Input; -using CliFx.Schema; -using CliFx.Suggestions; -using CliFx.Utils; -using CliFx.Utils.Extensions; - -namespace CliFx -{ - /// - /// Command line application facade. - /// - public class CliApplication - { - /// - /// Application metadata. - /// - public ApplicationMetadata Metadata { get; } - - /// - /// Application configuration. - /// - public ApplicationConfiguration Configuration { get; } - - private readonly IConsole _console; - private readonly ITypeActivator _typeActivator; - - private readonly CommandBinder _commandBinder; - private readonly IFileSystem _fileSystem; - - /// - /// Initializes an instance of . - /// - public CliApplication( - ApplicationMetadata metadata, - ApplicationConfiguration configuration, - IConsole console, - ITypeActivator typeActivator, - IFileSystem fileSystem) - { - Metadata = metadata; - Configuration = configuration; - _console = console; - _typeActivator = typeActivator; - - _commandBinder = new CommandBinder(typeActivator); - _fileSystem = fileSystem; - } - - private bool IsDebugModeEnabled(CommandInput commandInput) => - Configuration.IsDebugModeAllowed && commandInput.IsDebugDirectiveSpecified; - - private bool IsPreviewModeEnabled(CommandInput commandInput) => - Configuration.IsPreviewModeAllowed && commandInput.IsPreviewDirectiveSpecified; - - private bool IsSuggestModeEnabled(CommandInput commandInput) => - Configuration.IsSuggestModeAllowed && commandInput.IsSuggestDirectiveSpecified; - - private bool ShouldShowHelpText(CommandSchema commandSchema, CommandInput commandInput) => +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using CliFx.Exceptions; +using CliFx.Formatting; +using CliFx.Infrastructure; +using CliFx.Input; +using CliFx.Schema; +using CliFx.Suggestions; +using CliFx.Utils; +using CliFx.Utils.Extensions; + +namespace CliFx +{ + /// + /// Command line application facade. + /// + public class CliApplication + { + /// + /// Application metadata. + /// + public ApplicationMetadata Metadata { get; } + + /// + /// Application configuration. + /// + public ApplicationConfiguration Configuration { get; } + + private readonly IConsole _console; + private readonly ITypeActivator _typeActivator; + + private readonly CommandBinder _commandBinder; + private readonly IFileSystem _fileSystem; + + /// + /// Initializes an instance of . + /// + public CliApplication( + ApplicationMetadata metadata, + ApplicationConfiguration configuration, + IConsole console, + ITypeActivator typeActivator, + IFileSystem fileSystem) + { + Metadata = metadata; + Configuration = configuration; + _console = console; + _typeActivator = typeActivator; + + _commandBinder = new CommandBinder(typeActivator); + _fileSystem = fileSystem; + } + + private bool IsDebugModeEnabled(CommandInput commandInput) => + Configuration.IsDebugModeAllowed && commandInput.IsDebugDirectiveSpecified; + + private bool IsPreviewModeEnabled(CommandInput commandInput) => + Configuration.IsPreviewModeAllowed && commandInput.IsPreviewDirectiveSpecified; + + private bool IsSuggestModeEnabled(CommandInput commandInput) => + Configuration.IsSuggestModeAllowed && commandInput.IsSuggestDirectiveSpecified; + + private bool ShouldShowHelpText(CommandSchema commandSchema, CommandInput commandInput) => commandSchema.IsHelpOptionAvailable && commandInput.IsHelpOptionSpecified || // Show help text also in case the fallback default command is // executed without any arguments. - commandSchema == FallbackDefaultCommand.Schema && - string.IsNullOrWhiteSpace(commandInput.CommandName) && - !commandInput.Parameters.Any() && - !commandInput.Options.Any(); - - private bool ShouldShowVersionText(CommandSchema commandSchema, CommandInput commandInput) => - commandSchema.IsVersionOptionAvailable && commandInput.IsVersionOptionSpecified; - - private async ValueTask PromptDebuggerAsync() - { - using (_console.WithForegroundColor(ConsoleColor.Green)) - { - var processId = ProcessEx.GetCurrentProcessId(); - - _console.Output.WriteLine( - $"Attach debugger to PID {processId} to continue." - ); - } - - // Try to also launch debugger ourselves (only works if VS is installed) - Debugger.Launch(); - - while (!Debugger.IsAttached) - { - await Task.Delay(100); - } - } - - private async ValueTask RunAsync(ApplicationSchema applicationSchema, CommandInput commandInput) - { - // Handle debug directive - if (IsDebugModeEnabled(commandInput)) - { - await PromptDebuggerAsync(); - } - - // Handle preview directive - if (IsPreviewModeEnabled(commandInput)) - { - _console.Output.WriteCommandInput(commandInput); - return 0; - } - - // Handle suggest directive - if (IsSuggestModeEnabled(commandInput)) - { - var suggestionService = new SuggestionService(applicationSchema, _fileSystem, commandInput.EnvironmentVariables); - + commandSchema == FallbackDefaultCommand.Schema && + string.IsNullOrWhiteSpace(commandInput.CommandName) && + !commandInput.Parameters.Any() && + !commandInput.Options.Any(); + + private bool ShouldShowVersionText(CommandSchema commandSchema, CommandInput commandInput) => + commandSchema.IsVersionOptionAvailable && commandInput.IsVersionOptionSpecified; + + private async ValueTask PromptDebuggerAsync() + { + using (_console.WithForegroundColor(ConsoleColor.Green)) + { + var processId = ProcessEx.GetCurrentProcessId(); + + _console.Output.WriteLine( + $"Attach debugger to PID {processId} to continue." + ); + } + + // Try to also launch debugger ourselves (only works if VS is installed) + Debugger.Launch(); + + while (!Debugger.IsAttached) + { + await Task.Delay(100); + } + } + + private async ValueTask RunAsync(ApplicationSchema applicationSchema, CommandInput commandInput) + { + // Handle debug directive + if (IsDebugModeEnabled(commandInput)) + { + await PromptDebuggerAsync(); + } + + // Handle preview directive + if (IsPreviewModeEnabled(commandInput)) + { + _console.Output.WriteCommandInput(commandInput); + return 0; + } + + // Handle suggest directive + if (IsSuggestModeEnabled(commandInput)) + { + var suggestionService = new SuggestionService(applicationSchema, _fileSystem, commandInput.EnvironmentVariables); + if (suggestionService.ShouldInstallHooks(commandInput)) { new SuggestShellHookInstaller(_fileSystem).Install(Metadata.Title); - } - - suggestionService.GetSuggestions(commandInput).ToList() - .ForEach(p => _console.Output.WriteLine(p)); - return 0; - } - - // Try to get the command schema that matches the input - var commandSchema = - applicationSchema.TryFindCommand(commandInput.CommandName) ?? - applicationSchema.TryFindDefaultCommand() ?? - FallbackDefaultCommand.Schema; - - // Activate command instance - var commandInstance = commandSchema == FallbackDefaultCommand.Schema - ? new FallbackDefaultCommand() // bypass activator - : (ICommand)_typeActivator.CreateInstance(commandSchema.Type); - - // Assemble help context - var helpContext = new HelpContext( - Metadata, - applicationSchema, - commandSchema, - commandSchema.GetValues(commandInstance) - ); - - // Handle help option - if (ShouldShowHelpText(commandSchema, commandInput)) - { - _console.Output.WriteHelpText(helpContext); - return 0; - } - - // Handle version option - if (ShouldShowVersionText(commandSchema, commandInput)) - { - _console.Output.WriteLine(Metadata.Version); - return 0; - } - - // Starting from this point, we may produce exceptions that are meant for the - // end user of the application (i.e. invalid input, command exception, etc). - // Catch these exceptions here, print them to the console, and don't let them - // propagate further. - try - { - // Bind and execute command - _commandBinder.Bind(commandInput, commandSchema, commandInstance); - await commandInstance.ExecuteAsync(_console); - - return 0; - } - catch (CliFxException ex) - { - _console.Error.WriteException(ex); - - if (ex.ShowHelp) - { - _console.Output.WriteLine(); - _console.Output.WriteHelpText(helpContext); - } - - return ex.ExitCode; - } - } - - /// - /// Runs the application with the specified command line arguments and environment variables. - /// Returns an exit code which indicates whether the application completed successfully. - /// - /// - /// When running WITHOUT debugger (i.e. in production), this method swallows all exceptions and - /// reports them to the console. - /// - public async ValueTask RunAsync( - IReadOnlyList commandLineArguments, - IReadOnlyDictionary environmentVariables) - { - try - { - // Console colors may have already been overriden by the parent process, - // so we need to reset it to make sure that everything we write looks properly. - _console.ResetColor(); - - var applicationSchema = ApplicationSchema.Resolve(Configuration.CommandTypes); - - var commandInput = CommandInput.Parse( - commandLineArguments, - environmentVariables, - applicationSchema.GetCommandNames() - ); - - return await RunAsync(applicationSchema, commandInput); - } - // To prevent the app from showing the annoying troubleshooting dialog on Windows, - // we handle all exceptions ourselves and print them to the console. - // - // We only want to do that if the app is running in production, which we infer - // based on whether a debugger is attached to the process. - // - // When not running in production, we want the IDE to show exceptions to the - // developer, so we don't swallow them in that case. - catch (Exception ex) when (!Debugger.IsAttached) - { - _console.Error.WriteException(ex); - return 1; - } - } - - /// - /// Runs the application with the specified command line arguments. - /// Environment variables are resolved automatically. - /// Returns an exit code which indicates whether the application completed successfully. - /// - /// - /// When running WITHOUT debugger (i.e. in production), this method swallows all exceptions and - /// reports them to the console. - /// - public async ValueTask RunAsync(IReadOnlyList commandLineArguments) => await RunAsync( + } + + suggestionService.GetSuggestions(commandInput) + .OrderBy(p=>p).ToList() + .ForEach(p => _console.Output.WriteLine(p)); + return 0; + } + + // Try to get the command schema that matches the input + var commandSchema = + applicationSchema.TryFindCommand(commandInput.CommandName) ?? + applicationSchema.TryFindDefaultCommand() ?? + FallbackDefaultCommand.Schema; + + // Activate command instance + var commandInstance = commandSchema == FallbackDefaultCommand.Schema + ? new FallbackDefaultCommand() // bypass activator + : (ICommand)_typeActivator.CreateInstance(commandSchema.Type); + + // Assemble help context + var helpContext = new HelpContext( + Metadata, + applicationSchema, + commandSchema, + commandSchema.GetValues(commandInstance) + ); + + // Handle help option + if (ShouldShowHelpText(commandSchema, commandInput)) + { + _console.Output.WriteHelpText(helpContext); + return 0; + } + + // Handle version option + if (ShouldShowVersionText(commandSchema, commandInput)) + { + _console.Output.WriteLine(Metadata.Version); + return 0; + } + + // Starting from this point, we may produce exceptions that are meant for the + // end user of the application (i.e. invalid input, command exception, etc). + // Catch these exceptions here, print them to the console, and don't let them + // propagate further. + try + { + // Bind and execute command + _commandBinder.Bind(commandInput, commandSchema, commandInstance); + await commandInstance.ExecuteAsync(_console); + + return 0; + } + catch (CliFxException ex) + { + _console.Error.WriteException(ex); + + if (ex.ShowHelp) + { + _console.Output.WriteLine(); + _console.Output.WriteHelpText(helpContext); + } + + return ex.ExitCode; + } + } + + /// + /// Runs the application with the specified command line arguments and environment variables. + /// Returns an exit code which indicates whether the application completed successfully. + /// + /// + /// When running WITHOUT debugger (i.e. in production), this method swallows all exceptions and + /// reports them to the console. + /// + public async ValueTask RunAsync( + IReadOnlyList commandLineArguments, + IReadOnlyDictionary environmentVariables) + { + try + { + // Console colors may have already been overriden by the parent process, + // so we need to reset it to make sure that everything we write looks properly. + _console.ResetColor(); + + var applicationSchema = ApplicationSchema.Resolve(Configuration.CommandTypes); + + var commandInput = CommandInput.Parse( + commandLineArguments, + environmentVariables, + applicationSchema.GetCommandNames() + ); + + return await RunAsync(applicationSchema, commandInput); + } + // To prevent the app from showing the annoying troubleshooting dialog on Windows, + // we handle all exceptions ourselves and print them to the console. + // + // We only want to do that if the app is running in production, which we infer + // based on whether a debugger is attached to the process. + // + // When not running in production, we want the IDE to show exceptions to the + // developer, so we don't swallow them in that case. + catch (Exception ex) when (!Debugger.IsAttached) + { + _console.Error.WriteException(ex); + return 1; + } + } + + /// + /// Runs the application with the specified command line arguments. + /// Environment variables are resolved automatically. + /// Returns an exit code which indicates whether the application completed successfully. + /// + /// + /// When running WITHOUT debugger (i.e. in production), this method swallows all exceptions and + /// reports them to the console. + /// + public async ValueTask RunAsync(IReadOnlyList commandLineArguments) => await RunAsync( commandLineArguments, // Use case-sensitive comparison because environment variables are // case-sensitive on Linux and macOS (but not on Windows). - Environment - .GetEnvironmentVariables() - .ToDictionary(StringComparer.Ordinal) - ); - - /// - /// Runs the application. - /// Command line arguments and environment variables are resolved automatically. - /// Returns an exit code which indicates whether the application completed successfully. - /// - /// - /// When running WITHOUT debugger (i.e. in production), this method swallows all exceptions and - /// reports them to the console. - /// - public async ValueTask RunAsync() => await RunAsync( - Environment.GetCommandLineArgs() - .Skip(1) // first element is the file path - .ToArray() - ); - } -} + Environment + .GetEnvironmentVariables() + .ToDictionary(StringComparer.Ordinal) + ); + + /// + /// Runs the application. + /// Command line arguments and environment variables are resolved automatically. + /// Returns an exit code which indicates whether the application completed successfully. + /// + /// + /// When running WITHOUT debugger (i.e. in production), this method swallows all exceptions and + /// reports them to the console. + /// + public async ValueTask RunAsync() => await RunAsync( + Environment.GetCommandLineArgs() + .Skip(1) // first element is the file path + .ToArray() + ); + } +} diff --git a/CliFx/Suggestions/SuggestionService.cs b/CliFx/Suggestions/SuggestionService.cs index 2b4a8d8c..e46168d4 100644 --- a/CliFx/Suggestions/SuggestionService.cs +++ b/CliFx/Suggestions/SuggestionService.cs @@ -47,47 +47,89 @@ public IEnumerable GetSuggestions(CommandInput commandInput) { return _applicationSchema.GetCommandNames() .Where(p => p.StartsWith(suggestInput.CommandName, StringComparison.OrdinalIgnoreCase)) - .OrderBy(p => p) .ToList(); } - // handle options for the command we found + // prioritise option suggestions over parameter suggestions, as there might be an + // unlimited suggestions for parameters where a series of parameters is expected + // edge case: CommandInput.Parse() returns a parameter for "--" or "-", so get this case out of the way first. + var lastParameter = suggestInput.Parameters.LastOrDefault(); + if (lastParameter?.Value == "--") + { + return commandSchema.Options.Select(p => $"--{p.Name}"); + } + + if (lastParameter?.Value == "-") + { + return commandSchema.Options.Select(p => $"-{p.ShortName}").Concat(commandSchema.Options.Select(p => $"--{p.Name}")); + } + var optionInput = suggestInput.Options.LastOrDefault(); if (optionInput != null) { - bool exactOptionMatchFound = commandSchema.Options.Any(p => - - string.Equals($"--{p.Name}", optionInput.RawText, StringComparison.OrdinalIgnoreCase) || - string.Equals($"-{p.ShortName}", optionInput.RawText) - ); + return ProvideSuggestionsForOptionInputs(commandSchema, optionInput); + } - if (exactOptionMatchFound) + // provide parameter suggestions + try + { + var parameterBindings = GetParameterBindings(suggestInput.Parameters, commandSchema.Parameters); + if (lastParameter != null) { - return NoSuggestions(); - } + var schema = parameterBindings[lastParameter]; - if (optionInput.RawText.StartsWith("--")) - { - return commandSchema.Options - .Where(p => p.Name != null && p.Name.StartsWith(optionInput.Identifier, StringComparison.OrdinalIgnoreCase)) - .Select(p => $"--{p.Name}"); + var validParameterValues = schema.Property.GetValidValues() + .Select(p => p == null ? "" : p.ToString()) + .Where(p => p.StartsWith(lastParameter.Value, StringComparison.OrdinalIgnoreCase)); + + if (validParameterValues.Any(p => string.Equals(p, lastParameter.Value, StringComparison.OrdinalIgnoreCase))) + { + return NoSuggestions(); + } + + return validParameterValues; } + } + catch (InvalidOperationException) + { + // parameters outnumber schemas, no way to make any suggestions. return NoSuggestions(); } - // the parser returns a parameter for "--" or "-" - var lastParameter = suggestInput.Parameters.LastOrDefault(); - if (lastParameter?.Value == "--") + return NoSuggestions(); + } + + private Dictionary GetParameterBindings(IReadOnlyList inputs, IReadOnlyList schemas) + { + var queue = new Queue(schemas.OrderBy(p => p.Order)); + var dictionary = new Dictionary(); + + foreach (var input in inputs) { - return commandSchema.Options.OrderBy(p => p.Name).Select(p => $"--{p.Name}"); + var schema = queue.Peek().Property.IsScalar() ? queue.Dequeue() : queue.Peek(); + dictionary.Add(input, schema); } - if (lastParameter?.Value == "-") + return dictionary; + } + + private static IEnumerable ProvideSuggestionsForOptionInputs(CommandSchema commandSchema, OptionInput optionInput) + { + bool exactOptionMatchFound = commandSchema.Options.Any( + p => string.Equals($"--{p.Name}", optionInput.RawText, StringComparison.OrdinalIgnoreCase) + || string.Equals($"-{p.ShortName}", optionInput.RawText)); + + if (exactOptionMatchFound) { - return commandSchema.Options.OrderBy(p => p.ShortName).Select(p => $"-{p.ShortName}") - .Concat(commandSchema.Options.Select(p => $"--{p.Name}")); + return NoSuggestions(); } + if (optionInput.RawText.StartsWith("--")) + { + return commandSchema.Options + .Where(p => p.Name != null && p.Name.StartsWith(optionInput.Identifier, StringComparison.OrdinalIgnoreCase)) + .Select(p => $"--{p.Name}"); + } return NoSuggestions(); } From 0af03e012a78d9ca79d04f7e2db6ef49edab2b5d Mon Sep 17 00:00:00 2001 From: mauricel Date: Tue, 13 Apr 2021 14:30:39 +0100 Subject: [PATCH 24/31] Update readme with usage documentation for [suggest] mode. --- Readme.md | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/Readme.md b/Readme.md index 18ee1269..6e8a9038 100644 --- a/Readme.md +++ b/Readme.md @@ -700,6 +700,85 @@ test Environment variables can be configured for options of non-scalar types as well. In such case, the values of the environment variable will be split by `Path.PathSeparator` (`;` on Windows, `:` on Linux). +### Suggest mode + +Suggest mode provides command-line autocompletion for Powershell and Bash. By default, it is disabled, but it can be enabled as follows: + +```csharp +var app = new CliApplicationBuilder() + .AddCommandsFromThisAssembly() + .AllowSuggestMode(true) // allow suggest mode + .Build(); +``` + +Once enabled, your shell must be configured to use suggest mode as follows: + +1. Add your application to the PATH +2. Add a snippet to your shell's to instruct your shell in how to generate auto-completions. This can be done as follows. + + ``` sh + > cmd [suggest] --install + ``` + +For Powershell terminals, code will be appended to the file at the $PROFILE location. A backup is made to the same location prior to modification. The snippet below is provided for users who would prefer to make the change manually. + +``` powershell +### clifx-suggest-begins-here-CliFx.Demo-V1 +# this block provides auto-complete for the CliFx.Demo command +# and assumes that CliFx.Demo is on the path +$scriptblock = { + param($wordToComplete, $commandAst, $cursorPosition) + $command = "CliFx.Demo" + + $commandCacheId = "clifx-suggest-" + (new-guid).ToString() + Set-Content -path "ENV:\$commandCacheId" -value $commandAst + + $result = &$command `[suggest`] --envvar $commandCacheId --cursor $cursorPosition | ForEach-Object { + [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) + } + + Remove-Item -Path "ENV:\$commandCacheId" + $result +} + +Register-ArgumentCompleter -Native -CommandName "CliFx.Demo" -ScriptBlock $scriptblock +### clifx-suggest-ends-here-CliFx.Demo + +``` +For Bash terminals, code will be appended to the ~/.bashrc file. A backup is made to the same location prior to modification. The snippet below is provided for users who would prefer to make the change manually. + +``` bash +### clifx-suggest-begins-here-CliFx.Demo-V1 +# this block provides auto-complete for the CliFx.Demo command +# and assumes that CliFx.Demo is on the path +_CliFxDemo_complete() +{ +local word=${COMP_WORDS[COMP_CWORD]} + +# generate unique environment variable +CLIFX_CMD_CACHE="clifx-suggest-$(uuidgen)" +# replace hyphens with underscores to make it valid +CLIFX_CMD_CACHE=${CLIFX_CMD_CACHE//\-/_} + +export $CLIFX_CMD_CACHE=${COMP_LINE} + +local completions +completions="$(CliFx.Demo "[suggest]" --cursor "${COMP_POINT}" --envvar $CLIFX_CMD_CACHE 2>/dev/null)" +if [ $? -ne 0 ]; then + completions="" +fi + +unset $CLIFX_CMD_CACHE + +COMPREPLY=( $(compgen -W "$completions" -- "$word") ) +} + +complete -f -F _CliFxDemo_complete "CliFx.Demo" + +### clifx-suggest-ends-here-CliFx.Demo +``` + + ## Etymology CliFx is made out of "Cli" for "Command Line Interface" and "Fx" for "Framework". It's pronounced as "cliff ex". From a01564e56a9de5f69fe3b8f08be5f561485fadbe Mon Sep 17 00:00:00 2001 From: mauricel Date: Tue, 13 Apr 2021 14:33:25 +0100 Subject: [PATCH 25/31] Don't provide suggestions when installing. --- CliFx/CliApplication.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/CliFx/CliApplication.cs b/CliFx/CliApplication.cs index 3b655377..526d10b8 100644 --- a/CliFx/CliApplication.cs +++ b/CliFx/CliApplication.cs @@ -118,6 +118,7 @@ private async ValueTask RunAsync(ApplicationSchema applicationSchema, Comma if (suggestionService.ShouldInstallHooks(commandInput)) { new SuggestShellHookInstaller(_fileSystem).Install(Metadata.Title); + return 0; } suggestionService.GetSuggestions(commandInput) From 49997e4301638cfb27257f659969f247b08d9223 Mon Sep 17 00:00:00 2001 From: mauricel Date: Tue, 13 Apr 2021 15:00:42 +0100 Subject: [PATCH 26/31] Workaround for github action issue -- unable to retrieve nuget dependencies: https://github.com/actions/virtual-environments/issues/3038 --- NuGet.Config | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 NuGet.Config diff --git a/NuGet.Config b/NuGet.Config new file mode 100644 index 00000000..2c705390 --- /dev/null +++ b/NuGet.Config @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file From 2ea950ddf0c365c7c3d5188d99fc179534200a3e Mon Sep 17 00:00:00 2001 From: mauricel Date: Tue, 13 Apr 2021 15:57:40 +0100 Subject: [PATCH 27/31] Fix bug: child command suggestions are now supplied. --- CliFx.Tests/SuggestDirectiveSpecs.cs | 27 +++++++++++++++++++++----- CliFx/CliApplication.cs | 3 ++- CliFx/Input/CommandInput.cs | 10 ++++++---- CliFx/Suggestions/SuggestionService.cs | 15 ++++++++++---- 4 files changed, 41 insertions(+), 14 deletions(-) diff --git a/CliFx.Tests/SuggestDirectiveSpecs.cs b/CliFx.Tests/SuggestDirectiveSpecs.cs index 3ab2ca25..dc8881c9 100644 --- a/CliFx.Tests/SuggestDirectiveSpecs.cs +++ b/CliFx.Tests/SuggestDirectiveSpecs.cs @@ -32,6 +32,22 @@ public class Command02 : ICommand { public ValueTask ExecuteAsync(IConsole console) => default; } +"; + + private string _parentCommandCs = @" +[Command(""parent"")] +public class ParentCommand : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) => default; +} +"; + + private string _childCommandCs = @" +[Command(""parent list"")] +public class ParentCommand : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) => default; +} "; public CliApplicationBuilder TestApplicationFactory(params string[] commandClasses) @@ -85,19 +101,21 @@ public async Task Suggest_directive_is_disabled_by_default() [Theory] [InlineData("supply all commands if nothing supplied", - "clifx.exe", 0, new[] { "cmd", "cmd02" })] + "clifx.exe", 0, new[] { "cmd", "cmd02", "parent", "parent list" })] [InlineData("supply all commands that 'start with' argument", "clifx.exe c", 0, new[] { "cmd", "cmd02" })] [InlineData("supply command options if match found, regardles of other partial matches (no options defined)", "clifx.exe cmd", 0, new string[] { })] - [InlineData("supply nothing if no commands 'starts with' artument", + [InlineData("supply nothing if no commands 'starts with' argument", "clifx.exe m", 0, new string[] { })] + [InlineData("supply completions of partial child commands", + "clifx.exe parent l", 0, new[] { "list" })] [InlineData("supply all commands that 'start with' argument, allowing for cursor position", "clifx.exe cmd", -2, new[] { "cmd", "cmd02" })] public async Task Suggest_directive_suggests_commands_by_environment_variables(string usecase, string variableContents, int cursorOffset, string[] expected) { // Arrange - var application = TestApplicationFactory(_cmdCommandCs, _cmd2CommandCs) + var application = TestApplicationFactory(_cmdCommandCs, _cmd2CommandCs, _parentCommandCs, _childCommandCs) .AllowSuggestMode() .Build(); @@ -115,8 +133,7 @@ public async Task Suggest_directive_suggests_commands_by_environment_variables(s // Assert exitCode.Should().Be(0); - stdOut.Split(null) - .Where(p => !string.IsNullOrWhiteSpace(p)) + stdOut.Split(new[] { Environment.NewLine }, StringSplitOptions.RemoveEmptyEntries) .Should().BeEquivalentTo(expected, usecase); } diff --git a/CliFx/CliApplication.cs b/CliFx/CliApplication.cs index 526d10b8..e2087375 100644 --- a/CliFx/CliApplication.cs +++ b/CliFx/CliApplication.cs @@ -209,7 +209,8 @@ public async ValueTask RunAsync( var commandInput = CommandInput.Parse( commandLineArguments, environmentVariables, - applicationSchema.GetCommandNames() + applicationSchema.GetCommandNames(), + false ); return await RunAsync(applicationSchema, commandInput); diff --git a/CliFx/Input/CommandInput.cs b/CliFx/Input/CommandInput.cs index d2a14601..e28f284d 100644 --- a/CliFx/Input/CommandInput.cs +++ b/CliFx/Input/CommandInput.cs @@ -75,7 +75,7 @@ private static IReadOnlyList ParseDirectives( private static string? ParseCommandName( IReadOnlyList commandLineArguments, ISet commandNames, - ref int index) + ref int index, bool suggestMode) { var potentialCommandNameComponents = new List(); var commandName = default(string?); @@ -91,7 +91,8 @@ private static IReadOnlyList ParseDirectives( potentialCommandNameComponents.Add(argument); var potentialCommandName = potentialCommandNameComponents.JoinToString(" "); - if (commandNames.Contains(potentialCommandName)) + if (commandNames.Contains(potentialCommandName) || + suggestMode && commandNames.Any(p => p.StartsWith(potentialCommandName, StringComparison.OrdinalIgnoreCase))) { // Record the position but continue the loop in case // we find a longer (more specific) match. @@ -204,7 +205,8 @@ private static IReadOnlyList ParseOptions( public static CommandInput Parse( IReadOnlyList commandLineArguments, IReadOnlyDictionary environmentVariables, - IReadOnlyList availableCommandNames) + IReadOnlyList availableCommandNames, + bool suggestMode) { var index = 0; @@ -216,7 +218,7 @@ ref index var parsedCommandName = ParseCommandName( commandLineArguments, availableCommandNames.ToHashSet(StringComparer.OrdinalIgnoreCase), - ref index + ref index, suggestMode ); var parsedParameters = ParseParameters( diff --git a/CliFx/Suggestions/SuggestionService.cs b/CliFx/Suggestions/SuggestionService.cs index e46168d4..15c71802 100644 --- a/CliFx/Suggestions/SuggestionService.cs +++ b/CliFx/Suggestions/SuggestionService.cs @@ -37,7 +37,7 @@ public IEnumerable GetSuggestions(CommandInput commandInput) var suggestInput = CommandInput.Parse( suggestArgs.ToArray(), _environmentVariableInputs.ToDictionary(p => p.Name, p => p.Value), - _applicationSchema.GetCommandNames()); + _applicationSchema.GetCommandNames(), true); var commandSchema = _applicationSchema.Commands .FirstOrDefault(p => string.Equals(p.Name, suggestInput.CommandName, StringComparison.OrdinalIgnoreCase)); @@ -45,9 +45,16 @@ public IEnumerable GetSuggestions(CommandInput commandInput) // suggest a command name if we don't have an exact match if (commandSchema == null) { - return _applicationSchema.GetCommandNames() - .Where(p => p.StartsWith(suggestInput.CommandName, StringComparison.OrdinalIgnoreCase)) - .ToList(); + // handle completions of incomplete child command names + // where the remaining segment of the command name must be supplied (and not the complete command name) + var segments = _applicationSchema.GetCommandNames() + .Where(p => p.StartsWith(suggestInput.CommandName, StringComparison.OrdinalIgnoreCase)) + .Select(p => p.Split()); + + var inputSegments = suggestInput.CommandName?.Split() ?? new string[] { }; + var completeSegementCount = Math.Max(0, inputSegments.Count() -1 ); + + return segments.Select(p => string.Join(" ", p.Skip(completeSegementCount).ToArray())); } // prioritise option suggestions over parameter suggestions, as there might be an From 7750827bdfa91034166757594d8330227f279b91 Mon Sep 17 00:00:00 2001 From: mauricel Date: Tue, 13 Apr 2021 18:39:10 +0100 Subject: [PATCH 28/31] Fix bug: bash option auto-completion. --- CliFx/Suggestions/BashEnvironment.cs | 2 +- CliFx/Suggestions/SuggestionService.cs | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CliFx/Suggestions/BashEnvironment.cs b/CliFx/Suggestions/BashEnvironment.cs index 6ff4a8c4..ffa61764 100644 --- a/CliFx/Suggestions/BashEnvironment.cs +++ b/CliFx/Suggestions/BashEnvironment.cs @@ -34,7 +34,7 @@ public string GetInstallCommand(string commandName) # replace hyphens with underscores to make it valid CLIFX_CMD_CACHE=${{CLIFX_CMD_CACHE//\-/_}} - export $CLIFX_CMD_CACHE=${{COMP_LINE}} + export $CLIFX_CMD_CACHE=""${{COMP_LINE}}"" local completions completions=""$({commandName} ""[suggest]"" --cursor ""${{COMP_POINT}}"" --envvar $CLIFX_CMD_CACHE 2>/dev/null)"" diff --git a/CliFx/Suggestions/SuggestionService.cs b/CliFx/Suggestions/SuggestionService.cs index 15c71802..2322be75 100644 --- a/CliFx/Suggestions/SuggestionService.cs +++ b/CliFx/Suggestions/SuggestionService.cs @@ -52,9 +52,10 @@ public IEnumerable GetSuggestions(CommandInput commandInput) .Select(p => p.Split()); var inputSegments = suggestInput.CommandName?.Split() ?? new string[] { }; - var completeSegementCount = Math.Max(0, inputSegments.Count() -1 ); + var completeSegementCount = Math.Max(0, inputSegments.Count() - 1); - return segments.Select(p => string.Join(" ", p.Skip(completeSegementCount).ToArray())); + var result = segments.Select(p => string.Join(" ", p.Skip(completeSegementCount).ToArray() )); + return result; } // prioritise option suggestions over parameter suggestions, as there might be an From 265ec198a6902c8a36715d1550b3b4c5ae0426f3 Mon Sep 17 00:00:00 2001 From: mauricel Date: Tue, 13 Apr 2021 18:49:37 +0100 Subject: [PATCH 29/31] Update [suggest] scripts in Readme.md --- Readme.md | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/Readme.md b/Readme.md index 6e8a9038..0793dc28 100644 --- a/Readme.md +++ b/Readme.md @@ -727,7 +727,7 @@ For Powershell terminals, code will be appended to the file at the $PROFILE loca # this block provides auto-complete for the CliFx.Demo command # and assumes that CliFx.Demo is on the path $scriptblock = { - param($wordToComplete, $commandAst, $cursorPosition) + param($wordToComplete, $commandAst, $cursorPosition) $command = "CliFx.Demo" $commandCacheId = "clifx-suggest-" + (new-guid).ToString() @@ -753,24 +753,24 @@ For Bash terminals, code will be appended to the ~/.bashrc file. A backup is mad # and assumes that CliFx.Demo is on the path _CliFxDemo_complete() { -local word=${COMP_WORDS[COMP_CWORD]} + local word=${COMP_WORDS[COMP_CWORD]} -# generate unique environment variable -CLIFX_CMD_CACHE="clifx-suggest-$(uuidgen)" -# replace hyphens with underscores to make it valid -CLIFX_CMD_CACHE=${CLIFX_CMD_CACHE//\-/_} + # generate unique environment variable + CLIFX_CMD_CACHE="clifx-suggest-$(uuidgen)" + # replace hyphens with underscores to make it valid + CLIFX_CMD_CACHE=${CLIFX_CMD_CACHE//\-/_} -export $CLIFX_CMD_CACHE=${COMP_LINE} + export $CLIFX_CMD_CACHE="${COMP_LINE}" -local completions -completions="$(CliFx.Demo "[suggest]" --cursor "${COMP_POINT}" --envvar $CLIFX_CMD_CACHE 2>/dev/null)" -if [ $? -ne 0 ]; then + local completions + completions="$(CliFx.Demo "[suggest]" --cursor "${COMP_POINT}" --envvar $CLIFX_CMD_CACHE 2>/dev/null)" + if [ $? -ne 0 ]; then completions="" -fi + fi -unset $CLIFX_CMD_CACHE + unset $CLIFX_CMD_CACHE -COMPREPLY=( $(compgen -W "$completions" -- "$word") ) + COMPREPLY=( $(compgen -W "$completions" -- "$word") ) } complete -f -F _CliFxDemo_complete "CliFx.Demo" From 357307da5e44dcc68b9b810d8cd4e1add918981c Mon Sep 17 00:00:00 2001 From: mauricel Date: Tue, 13 Apr 2021 20:13:15 +0100 Subject: [PATCH 30/31] Fix bug: ensure space characters do not delimit autocompletions in bash. --- CliFx/Suggestions/BashEnvironment.cs | 2 +- CliFx/Suggestions/SuggestionService.cs | 2 +- Readme.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CliFx/Suggestions/BashEnvironment.cs b/CliFx/Suggestions/BashEnvironment.cs index ffa61764..b61e7037 100644 --- a/CliFx/Suggestions/BashEnvironment.cs +++ b/CliFx/Suggestions/BashEnvironment.cs @@ -28,7 +28,7 @@ public string GetInstallCommand(string commandName) _{safeName}_complete() {{ local word=${{COMP_WORDS[COMP_CWORD]}} - + local IFS=$'\n' # generate unique environment variable CLIFX_CMD_CACHE=""clifx-suggest-$(uuidgen)"" # replace hyphens with underscores to make it valid diff --git a/CliFx/Suggestions/SuggestionService.cs b/CliFx/Suggestions/SuggestionService.cs index 2322be75..e17f26b6 100644 --- a/CliFx/Suggestions/SuggestionService.cs +++ b/CliFx/Suggestions/SuggestionService.cs @@ -54,7 +54,7 @@ public IEnumerable GetSuggestions(CommandInput commandInput) var inputSegments = suggestInput.CommandName?.Split() ?? new string[] { }; var completeSegementCount = Math.Max(0, inputSegments.Count() - 1); - var result = segments.Select(p => string.Join(" ", p.Skip(completeSegementCount).ToArray() )); + var result = segments.Select(p => string.Join(" ", p.Skip(completeSegementCount).ToArray())); return result; } diff --git a/Readme.md b/Readme.md index 0793dc28..5674f468 100644 --- a/Readme.md +++ b/Readme.md @@ -754,7 +754,7 @@ For Bash terminals, code will be appended to the ~/.bashrc file. A backup is mad _CliFxDemo_complete() { local word=${COMP_WORDS[COMP_CWORD]} - + local IFS=$'\n' # generate unique environment variable CLIFX_CMD_CACHE="clifx-suggest-$(uuidgen)" # replace hyphens with underscores to make it valid From d615e0c27a4007823dda602966c5826241eccd3c Mon Sep 17 00:00:00 2001 From: mauricel Date: Mon, 19 Apr 2021 19:01:52 +0100 Subject: [PATCH 31/31] Add design documentation. --- CliFx/Suggestions/Design.md | 272 ++++++++++++++++++++++++++++++++++++ 1 file changed, 272 insertions(+) create mode 100644 CliFx/Suggestions/Design.md diff --git a/CliFx/Suggestions/Design.md b/CliFx/Suggestions/Design.md new file mode 100644 index 00000000..6b651e60 --- /dev/null +++ b/CliFx/Suggestions/Design.md @@ -0,0 +1,272 @@ +# Introduction + +[CliFx](https://github.com/Tyrrrz/CliFx) was developed by [Alexey Golub](https://github.com/Tyrrrz) as a framework for the creation of rich and robust command line applications. + +A CliFx appilcation has a command line syntax as follows. + +``` ps +PS C:\> cli.exe [command/subcommand] -option(alias) [optionValue] --option(named) [optionValue] +``` + +Commands are possible actions that the program can perform. Commands and subcommands provide a way of orgranising actions into sensible groups. For example: + +``` ps +PS C:\> cli.exe book # command, retrieves a book with title <title> +PS C:\> cli.exe book add <title> # subcommand, add a book with title <title> +PS C:\> cli.exe book remove <title> # subcommand, add a book with title <title> +PS C:\> cli.exe book list # subcommand, add a book with title <title> +``` + +In the example above, `<title>` is a parameter. There may be any number of parameters, separated by whitespace, and are identified by the order in which they appear. Parameters are be string-initalizable; for instance, Enumerations are accepted from their string or numeric values. + +Options always follow parameters, and are delimited with either the - or -- characters. A shorter option `alias` abbreviates the option with a single character (eg `-a`). Aliases can be combined to form a sequence: `-abc` is equivalent to `-a -b -c`. + +# Auto completion Feature Description + +The auto-completion feature provides useful suggestions to users of the application. Supported types are: + +* commands/sub commands +* Enumeration parameters +* fully qualified option names (delimited by --) +* individual aliases (delimited by -). No attempt to suggest single aliases in the same token (eg -abc). + +Suggestions are given for only one type at a time; ie. commands and parameters are not suggested together. Suggestions are provided as follows: + +* Provides suggestions that **start with** the terms provided. +* Don't provide other completions if there's already a match. +* Be case insensitive when matching provided terms. + + +# User Experience +Auto-completions must be enabled by user as follows: + +1. Ensure that the executable is on the path +2. Modify the terminal's user profile, adding a hook that calls CliFx for suggestions. +3. Reload terminal profile. + +Users may use CliFx to install the suggestion hook automatically (step 2), or they do all three steps manually. Once installed, the user experience is varies depending on the terminal in use: + +### Powershell (windows): +* tab to cycle through auto-completions +* control-space to select from auto-completions +```ps +# when: user presses tab for auto-complete +PS C:\> cli.exe <tab> +# then: auto-complete fills in the very first match +PS C:\> cli.exe book +# when: user continues to press tab... +PS C:\> cli.exe book<tab> +# then: auto-complete cycles through each match +PS C:\> cli.exe book add +PS C:\> cli.exe book list +PS C:\> cli.exe book remove + +# when: the user presses control-space for auto-complete +PS C:\> cli.exe <ctrl-space> +# then: the user is presented with a series of options to select from: +PS C:\> cli.exe book +book book add book list book remove +``` + +### Powershell (ubuntu): +* tab to view first auto-completion +* tab twice to see possible auto-completions +```ps +# when: user presses tab for auto-complete +PS C:\> cli.exe <tab> +# then: auto-complete fills in the very first match +PS C:\> cli.exe book +# when: user continues to press tab... +PS C:\> cli.exe book<tab> +# then: the user is presented with all options +book book add book list book remove +PS C:\> cli.exe book +``` + +### Bash (ubuntu): +* tab to view first auto-completion +* tab twice to see possible auto-completions +```bash +# when: user presses tab for auto-complete +mauricel@DESKTOP: ~/$ cli <tab> +# then: auto-complete fills in the very first match +mauricel@DESKTOP: ~/$ cli book +# when: user continues to press tab... +mauricel@DESKTOP: ~/$ cli book<tab> +# then: the user is presented with all options +book book add book list book remove ...plus any files present +mauricel@DESKTOP: ~/$ cli +``` + +# Architecture + +With CliFx, the auto-complete feature is comprised of the following components + +* terminal hook. This is typically an addition to a user's profile. The hook + * identifies the command to trigger upon + * extracts cursor position and command line arguments + * calls CliFx to determine the suggestions to provide suggestions +* CliFx implements the `[suggest]` directive, which is called as follows: + +```PS +# for CliFx.Demo book add title_of_book + +CliFx.Demo.exe [suggest] CliFx.Demo book add title_of_book +# completion suggestions provided, one per line, in standard output +``` +* The command line, and the cursor position can also be supplied by environment variable for more accurate results. + +```PS +# for CliFx.Demo book add title_of_book + +CliFx.Demo.exe [suggest] CliFx.Demo --envar <name_of_variable> --cursor <index_of_cursor> +# completion suggestions provided, one per line, in standard output +``` + +## Configuration + +The `[suggest]` directive (or suggest mode) is configured with CliApplicationBuilder as follows. For now, it is disabled by default. + +```cs +new CliApplicationBuilder() + // other configuration here. + .AllowSuggestMode(true) + .Build() + .RunAsync(); +``` + +## Installation +Installation consists of updating user profile to invoke CliFx suggest mode. Installation can be invoked by user on a per-user basis as follows: + +```PS +cli [suggest] --install +# powershell refresh +& $profile + +# bash refresh +source ~/.bashrc +``` +The following environments are supported + +* Windows Powershell, detected by the presence of "C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe" +* Ubuntu Powershell, detected by the presence of "/usr/bin/pwsh" +* Ubuntu Bash, detected by the presence of "/usr/bin/bash" + +Platform specific behaviour (eg. File.Exists()) is very different in a Ubuntu WSL environment depending on whether the executable is an .exe or a linux executable, and the location of the executable (ie /mnt/c/src vs ~/src). In short, platform detection does not work unless the appropriate executable is run in a WSL environment. + +Installation works by adding text to the appropriate profile file. To support future upgrade scenarios, and to guarrantee idempotency, the text is sandwitched between the following identifiers: + +``` +### clifx-suggest-begins-here-{commandName}-{scriptVersion} +<script goes here> +### clifx-suggest-ends-here-{commandName)"; +``` + +A .backup file is always created at the profile location. + +## Suggest Mode Workflow + +1. `[suggest]` directive detected by `CommandInput` +2. `CliApplication` checks for --install option. + 1. `SuggestShellHookInstaller` detects available environments, modifies profiles. + 2. End. +3. `CliApplication` retrieves suggestions from `SuggestionService` + 1. `SuggestionService` builds the raw user input from information supplied to `[suggest]` mode. See ExtractCommandText(). + 2. `SuggestionService` splits the user input into arguments. + 3. `CommandInput.Parse()` parses the arguments, producing a `CommandInput` + 4. The parse information in `CommandInput` is used to create suggestions. +4. `CliApplication` outputs suggestions to standard output. + + +# Design Considerations + +### Cursor positioning and argument delimination + +The command line is supplied to `CommandInput` as an array of strings. The raw command line is not made available via the .NET libraries, posing the following challenges: + +1. How to behave when the cursor in the command line is not always at the end of the string. + +```ps +PS C:\> cmd.exe book a + # ^ cursor might be here - auto completion should be for "boo" +``` +2. How to handle difficult Powershell escaping + +Consider the following application: +```cs + +public static void Main() +{ + foreach(var arg in Environment.GetCommandLineArgs()) + { + Console.WriteLine(arg); + } +} + +``` +Consider the following powershell behaviour +```ps + +PS C:\> cli a b "a b" 'a b' '"a b"' "`"a b`"" +cli +a +b +a b +a b +a b # expected "a b" (quotes inclusive)? +a b # expected "a b" (quotes inclusive)? +``` + +The behaviour gets stranger when the following auto-completion hook is considered: + +```ps +$scriptblock = { + param($wordToComplete, $commandAst, $cursorPosition) + $command = "CliFx.Demo" + + $result = &$command `[suggest`] $commandAst # note: $commandAst passed as single parameter + Set-content -path C:\temp\log.txt -value $result +} + +Register-ArgumentCompleter -Native -CommandName "CliFx.Demo" -ScriptBlock $scriptblock +``` + +We get the following output: + +```ps +CliFx.Demo +[suggest] +CliFx.Demo a b a +b 'a b' 'a +b' `a b`" +``` + +Observations: +1. Text passed to the `[suggest]` directive is split inconsistently when passed via the powershell hook. +2. Cursor positioning seems difficult to predict in relation to the rest of the supplied terms. Note that CliFx is supplied with command line arguments after processing for whitespace. I couldn't find a platform indepdenant way of extracting the raw command line string. + +Workaround: the terminal hooks capture the command string as an environment variable. The name of the environment variable and cursor position is passed to the `[suggest]` directive as options. The hooks restrict the scope of the environment variable to the terminal process tree and the variable is removed after usage. + +The command line is then trimmed to cursor length, split into arguments using [System.Environment.GetCommandLineArgs()](https://docs.microsoft.com/en-us/dotnet/api/system.environment.getcommandlineargs?view=net-5.0) rules. See `CliFx.Utils.CommandLineSplitter`. + +There may be some further issues with powershell escape characters, however what is presented currently should be sufficient given the nature of the feature. + +### Powershell: whitepace handling in sub-commands +Powershell completions in Windows do not handle the sub command white-space well. For example, `book a` is autocompleted to `book book add`. As such, `SuggestionService.GetSuggestions()` has been written to: + +* not to provide suggestions when the terms already match +* provide completions to terms already provided. For the example above, where `book a` returns `add`. + +Though this was not explored, it may have implications if changing from a **starts with** to a **contains** strategy. + +# Known Issues + +### Bash auto-completion does not suggest child commands in empty directories + +``` +Given: a Bash shell, working in an empty directory +When: user types CliFx.Demo <tab><tab> +Then: suggestions are [book] instead of [book, book add, book list, book remove] +Note: cannot reproduce issue if 'book list' is renamed to 'boom' +``` +Futher exploration of the bash auto-completion hooks is required. \ No newline at end of file