Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Autocompletion #98

Closed
wants to merge 31 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
d9e489e
Add CliApplicationBuilder.AllowSuggestMode() support.
mauricel Mar 25, 2021
2f297be
CommandLineSplitter utility, used to split input from powershell
mauricel Mar 25, 2021
b411301
Implement command suggestions
mauricel Mar 25, 2021
1dbd0ad
Add tests - suggestions by command line only
mauricel Mar 25, 2021
4e256e7
Add tests - suggest takes cursor positioning into account. Tidy up.
mauricel Mar 25, 2021
127c8e4
Change from 'contain' matching to 'starts with' matching.
mauricel Mar 25, 2021
2d854bd
Add installation code for suggest mode.
mauricel Mar 30, 2021
db3b956
Fix tests - disable suggest mode by default.
mauricel Mar 30, 2021
8d77dac
Fix assertions that rely on new lines.
mauricel Mar 30, 2021
96b0a01
Remove compile warnings
mauricel Mar 30, 2021
170b0a3
Fix potential suggest install issue -- whitespace in command name not…
mauricel Mar 30, 2021
319ea9e
Attempt to fix issues with OS detection.
mauricel Mar 30, 2021
19733ff
Ignore false issues around OS detection. Applications running in Linu…
mauricel Mar 31, 2021
02fc91e
Refactor and fix directory creation issues when directory tree does n…
mauricel Mar 31, 2021
6973912
Fix line ending issue causing syntax errors in unix shell hooks.
mauricel Mar 31, 2021
cffd1c8
Fix bash autocomplete hook.
mauricel Mar 31, 2021
a5a3e6e
Add suggest hook installation tests.
mauricel Mar 31, 2021
cc47288
Remove auto-install of suggest hooks. Hooks can be installed by user …
mauricel Apr 12, 2021
a7f467f
Ensure any backups created during installation don't get auto-deleted.
mauricel Apr 12, 2021
7f0fe4d
Normalise line endings
mauricel Apr 12, 2021
e67a9c2
Implement options autosuggestion.
mauricel Apr 12, 2021
09108a3
Fix bug: suggest now knows the difference between ShortNames and Name…
mauricel Apr 13, 2021
8cd92d6
Implement parameter suggestions.
mauricel Apr 13, 2021
0af03e0
Update readme with usage documentation for [suggest] mode.
mauricel Apr 13, 2021
a01564e
Don't provide suggestions when installing.
mauricel Apr 13, 2021
49997e4
Workaround for github action issue -- unable to retrieve nuget depend…
mauricel Apr 13, 2021
2ea950d
Fix bug: child command suggestions are now supplied.
mauricel Apr 13, 2021
7750827
Fix bug: bash option auto-completion.
mauricel Apr 13, 2021
265ec19
Update [suggest] scripts in Readme.md
mauricel Apr 13, 2021
357307d
Fix bug: ensure space characters do not delimit autocompletions in bash.
mauricel Apr 13, 2021
d615e0c
Add design documentation.
mauricel Apr 19, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CliFx.Demo/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public static async Task<int> Main() =>
.SetDescription("Demo application showcasing CliFx features.")
.AddCommandsFromThisAssembly()
.UseTypeActivator(GetServiceProvider().GetRequiredService)
.AllowSuggestMode(true)
.Build()
.RunAsync();
}
Expand Down
22 changes: 22 additions & 0 deletions CliFx.Tests/CommandLineUtilsSpecs.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
2 changes: 2 additions & 0 deletions CliFx.Tests/SpecsBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
288 changes: 288 additions & 0 deletions CliFx.Tests/SuggestDirectiveSpecs.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,288 @@
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;
}
";

private string _cmd2CommandCs = @"
[Command(""cmd02"")]
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;
}
";
Comment on lines +21 to +51
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's keep the commands local to the tests. This was the intention behind compiling them like this, otherwise we'd just be able to use regular classes instead :)

Code duplication is fine, logical isolation is more important. I don't want the tests to have shared context.

To compile multiple commands at once, you can use DynamicCommandBuilder.CompileMany(...).


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)
.UseFileSystem(NullFileSystem);
}

[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_disabled_by_default()
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is "off by default" a behavior we want? I mean, the user will still have to enable suggest mode in their terminal regardless, right?

{
// Arrange
var application = TestApplicationFactory(_cmdCommandCs)
.Build();

// Act
var exitCode = await application.RunAsync(
new[] { "[suggest]", "clifx.exe", "c" }
);

// Assert
exitCode.Should().Be(1);
}

[Theory]
[InlineData("supply all commands if nothing supplied",
"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' 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)
Comment on lines +103 to +115
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's split this into separate tests instead of using InlineData. This is very unreadable in the current state in my opinion.

{
// Arrange
var application = TestApplicationFactory(_cmdCommandCs, _cmd2CommandCs, _parentCommandCs, _childCommandCs)
.AllowSuggestMode()
.Build();

// Act
var exitCode = await application.RunAsync(
new[] { "[suggest]", "--envvar", "CLIFX-{GUID}", "--cursor", (variableContents.Length + cursorOffset).ToString() },
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need a dynamic environment variable or can it always be the same? It's set per-process so there shouldn't be conflicts, right?

new Dictionary<string, string>()
{
["CLIFX-{GUID}"] = variableContents
}
);

var stdOut = FakeConsole.ReadOutputString();

// Assert
exitCode.Should().Be(0);

stdOut.Split(new[] { Environment.NewLine }, StringSplitOptions.RemoveEmptyEntries)
.Should().BeEquivalentTo(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)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do I understand correctly that [suggest] works with both arguments provided via environment variable and through command line? Do we need both?

{
// Arrange
var application = TestApplicationFactory(_cmdCommandCs, _cmd2CommandCs)
.AllowSuggestMode()
.Build();

// Act
var exitCode = await application.RunAsync(commandLine);

var stdOut = FakeConsole.ReadOutputString();

// Assert
exitCode.Should().Be(0);

stdOut.Split(null)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does Split(null) do?

.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 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?",
"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<string, string>()
{
["CLIFX-{GUID}"] = variableContents
}
);

var stdOut = FakeConsole.ReadOutputString();

// Assert
exitCode.Should().Be(0);

stdOut.Split(null)
.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<TestColor> 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<string, string>()
{
["CLIFX-{GUID}"] = variableContents
}
);

var stdOut = FakeConsole.ReadOutputString();

// Assert
exitCode.Should().Be(0);

stdOut.Split(null)
.Where(p => !string.IsNullOrWhiteSpace(p))
.Should().BeEquivalentTo(expected, usecase);
}
}
}
Loading