diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index de2a2df6..0da7380f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,6 +21,8 @@ jobs: dotnet-version: | 6.0.x 7.0.x + - name: dotnet-suggest + run: dotnet tool install -g dotnet-suggest - name: Restore dependencies run: dotnet restore - name: Build diff --git a/CommandDotNet.DocExamples/DocExamplesDefaultTestConfig.cs b/CommandDotNet.DocExamples/DocExamplesDefaultTestConfig.cs index 1f88f17f..3005e40e 100644 --- a/CommandDotNet.DocExamples/DocExamplesDefaultTestConfig.cs +++ b/CommandDotNet.DocExamples/DocExamplesDefaultTestConfig.cs @@ -8,7 +8,7 @@ public class DocExamplesDefaultTestConfig : IDefaultTestConfig public TestConfig Default => new() { AppInfoOverride = new AppInfo( - false, false, false, + false, false, false, false, typeof(DocExamplesDefaultTestConfig).Assembly, "doc-examples.dll", "doc-examples.dll", "1.1.1.1") }; diff --git a/CommandDotNet.Example/Commands/DotnetSuggest.cs b/CommandDotNet.Example/Commands/DotnetSuggest.cs new file mode 100644 index 00000000..378f9167 --- /dev/null +++ b/CommandDotNet.Example/Commands/DotnetSuggest.cs @@ -0,0 +1,48 @@ +using System; +using System.CommandLine.Suggest; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using CommandDotNet.Builders; +using CommandDotNet.DotnetSuggest; + +namespace CommandDotNet.Example.Commands; + +[Command(Description = "(Un)registers this sample app with `dotnet suggest` to provide auto complete")] +public class DotnetSuggest +{ + public void Register(IConsole console, IEnvironment environment) + { + var registered = DotnetTools.EnsureRegisteredWithDotnetSuggest(environment, out var results, console); + var appInfo = AppInfo.Instance; + console.WriteLine(registered + ? results is null + ? appInfo.IsGlobalTool + ? "Already registered. Global tools are registered by default." + : "Already registered" + : $"Succeeded:{Environment.NewLine}{results.ToString(new Indent(depth: 1), skipOutputs: true)}" + : $"Failed:{Environment.NewLine}{results!.ToString(new Indent(depth: 1), skipOutputs: true)}"); + } + + public async Task Unregister(IConsole console) + { + if (AppInfo.Instance.IsGlobalTool) + { + console.WriteLine("This is a global tool. Global tools are registered by default and cannot be unregistered."); + } + + var path = new FileSuggestionRegistration().RegistrationConfigurationFilePath; + var lines = await File.ReadAllLinesAsync(path); + var newLines = lines.Where(l => !l.StartsWith(AppInfo.Instance.FilePath)).ToArray(); + + if (lines.Length == newLines.Length) + { + console.WriteLine("Not registered with dotnet-suggest"); + } + else + { + await File.WriteAllLinesAsync(path, newLines); + console.WriteLine("Unregistered with dotnet-suggest"); + } + } +} \ No newline at end of file diff --git a/CommandDotNet.Example/Examples.cs b/CommandDotNet.Example/Examples.cs index c165a494..47494d61 100644 --- a/CommandDotNet.Example/Examples.cs +++ b/CommandDotNet.Example/Examples.cs @@ -55,5 +55,8 @@ public void StartSession( [Subcommand] public Commands.Prompts Prompts { get; set; } = null!; + + [Subcommand] + public Commands.DotnetSuggest DotnetSuggest { get; set; } = null!; } } diff --git a/CommandDotNet.Example/Program.cs b/CommandDotNet.Example/Program.cs index 66279f65..7b48a672 100644 --- a/CommandDotNet.Example/Program.cs +++ b/CommandDotNet.Example/Program.cs @@ -23,6 +23,7 @@ public static AppRunner GetAppRunner(NameValueCollection? appConfigSettings = nu appConfigSettings ??= new NameValueCollection(); return new AppRunner(appNameForTests is null ? null : new AppSettings{Help = {UsageAppName = appNameForTests}}) .UseDefaultMiddleware() + .UseSuggestDirective_Experimental() .UseCommandLogger() .UseNameCasing(Case.KebabCase) .UsePrompter() diff --git a/CommandDotNet.TestTools/TestEnvironment.cs b/CommandDotNet.TestTools/TestEnvironment.cs index de990d22..8b312402 100644 --- a/CommandDotNet.TestTools/TestEnvironment.cs +++ b/CommandDotNet.TestTools/TestEnvironment.cs @@ -2,6 +2,7 @@ using System.Collections; using System.Collections.Generic; using System.Linq; +using CommandDotNet.Extensions; using CommandDotNet.Tokens; namespace CommandDotNet.TestTools @@ -11,7 +12,7 @@ namespace CommandDotNet.TestTools public class TestEnvironment : IEnvironment { public string[]? CommandLineArgs; - public Dictionary EnvVar = new(); + public Dictionary> EnvVarByTarget = new(); public Action? OnExit; public Action<(string? message, Exception? exception)>? OnFailFast; public Func? OnExpandEnvironmentVariables; @@ -56,14 +57,24 @@ OnExpandEnvironmentVariables is null ? Environment.ExpandEnvironmentVariables(name) : OnExpandEnvironmentVariables(name); - public string? GetEnvironmentVariable(string variable, EnvironmentVariableTarget? target = null) => - EnvVar.GetValueOrDefault(variable); + public string? GetEnvironmentVariable(string variable, EnvironmentVariableTarget? target = null) => + target is not null + ? EnvVarByTarget.GetValueOrDefault(target.Value)?.GetValueOrDefault(variable) + : (EnvVarByTarget.GetValueOrDefault(EnvironmentVariableTarget.Process) + ?? EnvVarByTarget.GetValueOrDefault(EnvironmentVariableTarget.User) + ?? EnvVarByTarget.GetValueOrDefault(EnvironmentVariableTarget.Machine)) + ?.GetValueOrDefault(variable); - public IDictionary GetEnvironmentVariables() => EnvVar; + public IDictionary GetEnvironmentVariables() => EnvVarByTarget + .SelectMany(d => d.Value) + .ToDictionary(d => d.Key, d => d.Value); public void SetEnvironmentVariables(string variable, string? value, EnvironmentVariableTarget? target) { - EnvVar[variable] = value; + target ??= EnvironmentVariableTarget.Process; + var vars = EnvVarByTarget.GetOrAdd(target.Value, + key => new Dictionary()); + vars[variable] = value; } } } \ No newline at end of file diff --git a/CommandDotNet.Tests/CmdNetDefaultTestConfig.cs b/CommandDotNet.Tests/CmdNetDefaultTestConfig.cs index c8148978..c0aee7f1 100644 --- a/CommandDotNet.Tests/CmdNetDefaultTestConfig.cs +++ b/CommandDotNet.Tests/CmdNetDefaultTestConfig.cs @@ -9,7 +9,7 @@ public class CmdNetDefaultTestConfig : IDefaultTestConfig { OnError = {Print = {ConsoleOutput = true, CommandContext = true}}, AppInfoOverride = new AppInfo( - false, false, false, + false, false, false, false, typeof(CmdNetDefaultTestConfig).Assembly, "testhost.dll", "testhost.dll", "1.1.1.1") }; diff --git a/CommandDotNet.Tests/CommandDotNet.NewerReleasesAlerts/NewReleaseAlertOnGitHubTests.cs b/CommandDotNet.Tests/CommandDotNet.NewerReleasesAlerts/NewReleaseAlertOnGitHubTests.cs index 0334c4f9..a28864e9 100644 --- a/CommandDotNet.Tests/CommandDotNet.NewerReleasesAlerts/NewReleaseAlertOnGitHubTests.cs +++ b/CommandDotNet.Tests/CommandDotNet.NewerReleasesAlerts/NewReleaseAlertOnGitHubTests.cs @@ -99,7 +99,7 @@ public void Do() } private static AppInfo BuildAppInfo(string version) => new( - false, false, false, + false, false, false, false, typeof(NewReleaseAlertOnGitHubTests).Assembly, "blah", version); public static string BuildGitHubApiResponse(string version) => diff --git a/CommandDotNet.Tests/FeatureTests/SuggestDirective/DotNetSuggestSync.cs b/CommandDotNet.Tests/FeatureTests/SuggestDirective/DotNetSuggestSync.cs new file mode 100644 index 00000000..bc424ba4 --- /dev/null +++ b/CommandDotNet.Tests/FeatureTests/SuggestDirective/DotNetSuggestSync.cs @@ -0,0 +1,58 @@ +using System; +using System.IO; +using System.Net.Http; +using System.Threading.Tasks; +using FluentAssertions; +using Xunit; + +namespace CommandDotNet.Tests.FeatureTests.SuggestDirective; + +public class DotNetSuggestSync +{ + private const string RepoRoot = "https://raw.githubusercontent.com/dotnet/command-line-api/main/src"; + + [Fact(Skip = "unskip to run")] + public async Task Sync() + { + var client = new HttpClient(); + await SyncFile(client, "DotnetProfileDirectory.cs"); + await SyncFile(client, "FileSuggestionRegistration.cs", + text => + { + var fileField = "private readonly string _registrationConfigurationFilePath;"; + text.Should().Contain(fileField); + text = text.Replace(fileField, + $"{fileField}{Environment.NewLine} " + + "public string RegistrationConfigurationFilePath => _registrationConfigurationFilePath;"); + text = text.Replace(" : ISuggestionRegistration", ""); + return text; + }); + await SyncFile(client, "RegistrationPair.cs"); + } + + private static async Task SyncFile(HttpClient client, string fileName, Func? alter = null) + { + var source = $"https://raw.githubusercontent.com/dotnet/command-line-api/main/src/System.CommandLine.Suggest/{fileName}"; + var fileContent = await client.GetStringAsync(source); + if (alter is not null) + { + fileContent = alter(fileContent); + } + + fileContent = fileContent.Replace("namespace System.CommandLine.Suggest", + @"#pragma warning disable CS8600 +#pragma warning disable CS8603 +#pragma warning disable CS8625 +// ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract +// ReSharper disable CheckNamespace + +namespace System.CommandLine.Suggest"); + + fileContent = $@"// copied from: {source} +// via: {nameof(DotNetSuggestSync)} test class + +{fileContent}"; + + await File.WriteAllTextAsync($"../../../../CommandDotNet/DotNetSuggest/System.CommandLine.Suggest/{fileName}", fileContent); + } +} \ No newline at end of file diff --git a/CommandDotNet.Tests/FeatureTests/SuggestDirective/SuggestDirectiveRegistrationTests.cs b/CommandDotNet.Tests/FeatureTests/SuggestDirective/SuggestDirectiveRegistrationTests.cs new file mode 100644 index 00000000..35ce1cd8 --- /dev/null +++ b/CommandDotNet.Tests/FeatureTests/SuggestDirective/SuggestDirectiveRegistrationTests.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using System.CommandLine.Suggest; +using System.IO; +using System.Linq; +using CommandDotNet.Builders; +using CommandDotNet.DotnetSuggest; +using CommandDotNet.TestTools; +using CommandDotNet.TestTools.Scenarios; +using FluentAssertions; +using Xunit; +using Xunit.Abstractions; + +namespace CommandDotNet.Tests.FeatureTests.SuggestDirective; + +public class SuggestDirectiveRegistrationTests +{ + private readonly ITestOutputHelper _output; + private readonly string _filePath = Path.Join(nameof(SuggestDirectiveRegistrationTests), "suggest-test.exe"); + + public SuggestDirectiveRegistrationTests(ITestOutputHelper output) + { + _output = output; + Ambient.Output = output; + } + + [Theory] + [InlineData("[suggest]", RegistrationStrategy.None, null, false, false)] + [InlineData("", RegistrationStrategy.None, null, false, false)] + [InlineData("[suggest]", RegistrationStrategy.EnsureOnEveryRun, null, false, false)] + [InlineData("", RegistrationStrategy.EnsureOnEveryRun, null, false, true)] + [InlineData("[suggest]", RegistrationStrategy.UseRegistrationDirective, "sug", false, false)] + [InlineData("", RegistrationStrategy.UseRegistrationDirective, "sug", false, false)] + [InlineData("[sug]", RegistrationStrategy.UseRegistrationDirective, "sug", false, true)] + [InlineData("[sug]", RegistrationStrategy.None, "sug", false, false)] + [InlineData("", RegistrationStrategy.EnsureOnEveryRun, null, true, false)] + [InlineData("[sug]", RegistrationStrategy.UseRegistrationDirective, "sug", true, false)] + public void Suggest_can_register_with_Dotnet_Suggest(string args, RegistrationStrategy strategy, string? directive, bool isGlobal, bool shouldRegister) + { + #region ensure test cases are not misconfigured and summarize a few of the rules + if (strategy == RegistrationStrategy.None) + { + shouldRegister.Should().Be(false, "should never register unless ensureRegisteredWithDotnetSuggest=true"); + } + if(args.StartsWith("[suggest]")) + { + shouldRegister.Should().Be(false, "should never register when providing suggestions"); + } + if(isGlobal) + { + shouldRegister.Should().Be(false, "should never register when app is global tool"); + } + #endregion + + var result = new AppRunner() + .UseDefaultMiddleware() + .UseCommandLogger() + .UseSuggestDirective_Experimental(strategy, directive!) + .UseTestEnv(new ()) + .Verify(new Scenario + { + When = {Args = args}, + Then = { Output = args.StartsWith("[sug") ? null : "lala" } + }, + config: TestConfig.Default.Where(a => a.AppInfoOverride = BuildAppInfo(isGlobal))); + + result.ExitCode.Should().Be(0); + + ConfirmPathEnvVar(shouldRegister, result); + + ConfirmRegistration(shouldRegister); + } + + private void ConfirmPathEnvVar(bool shouldRegister, AppRunnerResult result) + { + var testEnvironment = (TestEnvironment) result.CommandContext.Environment; + var userEnvVars = testEnvironment.EnvVarByTarget.GetValueOrDefault(EnvironmentVariableTarget.User); + if (shouldRegister) + { + userEnvVars.Should().NotBeNull(); + var pathEnvVar = userEnvVars!.GetValueOrDefault("PATH"); + pathEnvVar.Should().NotBeNull(); + pathEnvVar.Should().Contain(_filePath); + } + else + { + userEnvVars?.GetValueOrDefault("PATH")?.Should().NotContain(_filePath); + } + } + + private static void ConfirmRegistration(bool shouldRegister) + { + var path = new FileSuggestionRegistration().RegistrationConfigurationFilePath; + if (File.Exists(path)) + { + var lines = File.ReadAllLines(path); + + // _output.WriteLine($"contents of {path}"); + // lines.ForEach(l => _output.WriteLine(l)); + // + // contents of /Users/{user}/.dotnet-suggest-registration.txt + // SuggestDirectiveRegistrationTests/suggest-test.exe + + var cleanedLines = lines.Where(l => !l.StartsWith(nameof(SuggestDirectiveRegistrationTests))).ToArray(); + File.WriteAllLines(path, cleanedLines); + + if (shouldRegister) + { + lines.Length.Should().NotBe(cleanedLines.Length); + } + else + { + lines.Length.Should().Be(cleanedLines.Length); + } + } + } + + private AppInfo BuildAppInfo(bool isGlobalTool) + { + return new( + false, false, false, isGlobalTool, GetType().Assembly, + _filePath, Path.GetFileName(_filePath)); + } + + public class App + { + [DefaultCommand] + public void Do(IConsole console) => console.WriteLine("lala"); + } +} \ No newline at end of file diff --git a/CommandDotNet.Tests/FeatureTests/SuggestDirective/SuggestDirectiveTests.cs b/CommandDotNet.Tests/FeatureTests/SuggestDirective/SuggestDirectiveTests.cs new file mode 100644 index 00000000..8875e699 --- /dev/null +++ b/CommandDotNet.Tests/FeatureTests/SuggestDirective/SuggestDirectiveTests.cs @@ -0,0 +1,129 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using CommandDotNet.Tests.FeatureTests.Suggestions; +using CommandDotNet.TestTools.Scenarios; +using Xunit; +using Xunit.Abstractions; +using static System.Environment; + +namespace CommandDotNet.Tests.FeatureTests.SuggestDirective; + +public class SuggestDirectiveTests +{ + public SuggestDirectiveTests(ITestOutputHelper output) => Ambient.Output = output; + + /* Test list: + * - operands + * - extra operand + * - spaces + * - after argument + * - after partial ** + * - FileInfo + * - file names + * - DirectoryInfo + * - directory names + * - after argument separator + * ? - how to know if after arg separator vs looking for options? + * - response files + * - file names + * - directory names + * - clubbed options + * + * - check position argument + * ? - is this used commonly? The tests in System.CommandLine all + * seem to have the position as the end of the string. + * + * - check feature list for other considerations + */ + + [SuppressMessage("Usage", "xUnit1026:Theory methods should use all of their parameters")] + + [Theory] + [InlineData( + "command - includes subcommands, options and next operand allowed values", + "", "--togo/nClosed/nOpened/nOrder/nReserve")] + [InlineData("command - invalid name", "Blah", "")] + [InlineData("command - partial name", "Or", "Order")] + [InlineData("subcommand", "Order ", "--juices/n--water/nBreakfast/nDinner/nLunch")] + [InlineData("subcommand - not returned when not available", "Opened", "--togo")] + [InlineData("option - show allowed values", "Reserve --meal", "Breakfast/nDinner/nLunch")] + [InlineData("option - option prefix shows only options 1", "Order --", "--juices/n--water")] + [InlineData("option - option prefix shows only options 2", "Order -", "--juices/n--water")] + [InlineData("option - option prefix shows only options 3", "Order /", "/juices/n/water")] + [InlineData("option - does not show already provided option", "Order --water --", "--juices")] + [InlineData("option - does not show already provided option using short name", "Order -w --", "--juices")] + [InlineData("option - partial name", "Order --jui", "--juices")] + [InlineData("option - partial name with backslash", "Order /jui", "/juices")] + [InlineData("option - partial allowed value", "Reserve --meal Br", "Breakfast")] + [InlineData("option - trailing space", "Order --juices", "Apple/nBanana/nCherry")] + [InlineData("operand - partial allowed value", "Op", "Opened")] + [InlineData("typo before request for autocompletion 1", "Or --jui", "", 1)] + [InlineData("typo before request for autocompletion 2", "Reserv --meal Br", "", 1)] + [InlineData("typo before request for autocompletion 3", "Reserve --mea Br", "", 1)] + public void Suggest(string scenario, string input, string expected, int exitCode = 0) + { + new AppRunner() + .UseSuggestDirective_Experimental() + .Verify(new Scenario + { + When = { Args = $"[suggest] {input}"}, + Then = + { + Output = NewLine == "/n" ? expected : expected.Replace("/n", NewLine), + ExitCode = exitCode + } + }); + } + + [Fact] + public void Suggest_works_with_default_middleware() + { + var expected = "--togo/n--version/nClosed/nOpened/nOrder/nReserve"; + new AppRunner() + .UseSuggestDirective_Experimental() + .UseDefaultMiddleware() + .UseCommandLogger() + .Verify(new Scenario + { + When = {Args = "[suggest]"}, + Then = + { + Output = NewLine == "/n" ? expected : expected.Replace("/n", NewLine) + } + }); + } + + public class DinerApp + { + public enum Status{ Opened, Closed } + + public enum PartySize{one,two,three,four,five,six,seven,eight,nine,ten} + + public Task Interceptor(InterceptorExecutionDelegate next, IConsole console, [Option] bool togo) + { + console.WriteLine("DinerApp.Interceptor"); + return Task.FromResult(0); + } + + [DefaultCommand] + public void Default(IConsole console, Status status) + { + console.WriteLine("DinerApp.Default"); + } + + public void Reserve(IConsole console, + [Operand] PartySize partySize, [Operand] string name, + [Operand] DateOnly date, [Operand] TimeOnly time, [Option] Meal meal) + { + console.WriteLine("DinerApp.Reserve"); + } + + public void Order(IConsole console, + [Operand] Meal meal, [Operand] Main main, [Operand] Vegetable vegetable, [Operand] Fruit fruit, + [Option('w')] bool water, [Option] Fruit juices) + { + console.WriteLine("DinerApp.Order"); + } + } +} \ No newline at end of file diff --git a/CommandDotNet.Tests/FeatureTests/Suggestions/CafeApp.cs b/CommandDotNet.Tests/FeatureTests/Suggestions/CafeApp.cs index c9961ea7..b2781eba 100644 --- a/CommandDotNet.Tests/FeatureTests/Suggestions/CafeApp.cs +++ b/CommandDotNet.Tests/FeatureTests/Suggestions/CafeApp.cs @@ -5,18 +5,16 @@ namespace CommandDotNet.Tests.FeatureTests.Suggestions public enum Fruit { Apple, Banana, Cherry } public enum Vegetable { Asparagus, Broccoli, Carrot } + public enum Main { Chicken, Steak, Fish, Veggie} public enum Meal { Breakfast, Lunch, Dinner } public class CafeApp { - public void Eat(IConsole console, + public void Eat( [Operand] Meal meal, [Option] Vegetable vegetable, [Option] Fruit fruit) { - console.Out.WriteLine($"{nameof(meal)} :{meal}"); - console.Out.WriteLine($"{nameof(fruit)} :{fruit}"); - console.Out.WriteLine($"{nameof(vegetable)}:{vegetable}"); } } } \ No newline at end of file diff --git a/CommandDotNet/AppRunnerConfigExtensions.cs b/CommandDotNet/AppRunnerConfigExtensions.cs index 6aef2702..a9e5dfca 100644 --- a/CommandDotNet/AppRunnerConfigExtensions.cs +++ b/CommandDotNet/AppRunnerConfigExtensions.cs @@ -6,6 +6,7 @@ using CommandDotNet.Builders.ArgumentDefaults; using CommandDotNet.ClassModeling.Definitions; using CommandDotNet.Diagnostics; +using CommandDotNet.DotnetSuggest; using CommandDotNet.Execution; using CommandDotNet.Localization; using CommandDotNet.Parsing.Typos; @@ -72,6 +73,19 @@ static void Register(bool exclude, string paramName, Action register) return appRunner; } + /// + /// Registers the [suggest] directive, to use in conjunction with System.CommandLine's dotnet-suggest. + /// see https://learn.microsoft.com/en-us/dotnet/standard/commandline/tab-completion + /// + /// + /// the registration strategy to use + /// must be specified when using + /// + public static AppRunner UseSuggestDirective_Experimental(this AppRunner appRunner, + RegistrationStrategy registrationStrategy = RegistrationStrategy.None, + string directiveName = "enable-tab-completion") => + SuggestDirectiveMiddleware.UseSuggestDirective_Experimental(appRunner, registrationStrategy, directiveName); + /// When an invalid arguments is entered, suggests context based alternatives public static AppRunner UseTypoSuggestions(this AppRunner appRunner, int maxSuggestionCount = 5) { diff --git a/CommandDotNet/Builders/AppInfo.cs b/CommandDotNet/Builders/AppInfo.cs index d77b3c4a..04168ee3 100644 --- a/CommandDotNet/Builders/AppInfo.cs +++ b/CommandDotNet/Builders/AppInfo.cs @@ -2,6 +2,7 @@ using System.Diagnostics; using System.IO; using System.Reflection; +using CommandDotNet.DotnetSuggest; using CommandDotNet.Extensions; using CommandDotNet.Logging; @@ -43,6 +44,9 @@ public static IDisposable SetResolver(Func appInfoResolver) /// True if the application's filename ends with .exe public bool IsExe { get; } + /// True if the application is located in the global dotnet tool directory + public bool IsGlobalTool { get; } + /// True if published as a self-contained single executable public bool IsSelfContainedExe { get; } @@ -60,8 +64,21 @@ public static IDisposable SetResolver(Func appInfoResolver) public string? Version => _version ??= GetVersion(Instance.EntryAssembly); + // second ctor to avoid breaking change public AppInfo( bool isExe, bool isSelfContainedExe, bool isRunViaDotNetExe, + bool isGlobalTool, + Assembly entryAssembly, + string filePath, string fileName, + string? version = null) + : this(isExe, isSelfContainedExe, isRunViaDotNetExe, entryAssembly, filePath, fileName, version) + { + IsGlobalTool = isGlobalTool; + } + + [Obsolete("Use ctor with isGlobalTool param")] + public AppInfo( + bool isExe, bool isSelfContainedExe, bool isRunViaDotNetExe, Assembly entryAssembly, string filePath, string fileName, string? version = null) @@ -108,14 +125,20 @@ private static AppInfo BuildAppInfo() var isRunViaDotNetExe = false; var isSelfContainedExe = false; var isExe = false; - if (mainModuleFileName != null) + var isGlobalTool = false; + if (mainModuleFileName is not null) { // osx uses 'dotnet' instead of 'dotnet.exe' - if (!(isRunViaDotNetExe = mainModuleFileName.Equals("dotnet.exe") || mainModuleFileName.Equals("dotnet"))) + isRunViaDotNetExe = mainModuleFileName.Equals("dotnet.exe") || mainModuleFileName.Equals("dotnet"); + if (!isRunViaDotNetExe) { var entryAssemblyFileNameWithoutExt = Path.GetFileNameWithoutExtension(entryAssemblyFileName); isSelfContainedExe = isExe = mainModuleFileName.EndsWith($"{entryAssemblyFileNameWithoutExt}.exe"); } + + var globalToolsDirectory = DotnetTools.GlobalToolDirectory; + isGlobalTool = globalToolsDirectory is not null + && mainModuleFilePath!.StartsWith(globalToolsDirectory); } isExe = isExe || entryAssemblyFileName.EndsWith("exe"); @@ -129,16 +152,17 @@ private static AppInfo BuildAppInfo() Log.Debug($" {nameof(FileName)}={fileName} " + $"{nameof(IsRunViaDotNetExe)}={isRunViaDotNetExe} " + $"{nameof(IsSelfContainedExe)}={isSelfContainedExe} " + + $"{nameof(IsGlobalTool)}={isGlobalTool} " + $"{nameof(FilePath)}={filePath}"); - return new AppInfo(isExe, isSelfContainedExe, isRunViaDotNetExe, entryAssembly, filePath!, fileName); + return new AppInfo(isExe, isSelfContainedExe, isRunViaDotNetExe, isGlobalTool, entryAssembly, filePath!, fileName); } private static string? GetVersion(Assembly hostAssembly) => // thanks Spectre console for figuring this out https://github.com/spectreconsole/spectre.console/issues/242 hostAssembly.GetCustomAttribute()?.InformationalVersion ?? "?"; - public object Clone() => new AppInfo(IsExe, IsSelfContainedExe, IsRunViaDotNetExe, EntryAssembly, FilePath, FileName, _version); + public object Clone() => new AppInfo(IsExe, IsSelfContainedExe, IsRunViaDotNetExe, IsGlobalTool, EntryAssembly, FilePath, FileName, _version); public override string ToString() => ToString(new Indent()); diff --git a/CommandDotNet/CommandDotNet.csproj b/CommandDotNet/CommandDotNet.csproj index 30f41beb..b9d917e1 100644 --- a/CommandDotNet/CommandDotNet.csproj +++ b/CommandDotNet/CommandDotNet.csproj @@ -27,4 +27,7 @@ + + + \ No newline at end of file diff --git a/CommandDotNet/DotnetSuggest/DotnetTools.cs b/CommandDotNet/DotnetSuggest/DotnetTools.cs new file mode 100644 index 00000000..b9ef8089 --- /dev/null +++ b/CommandDotNet/DotnetSuggest/DotnetTools.cs @@ -0,0 +1,60 @@ +using System; +using System.CommandLine.Suggest; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using CommandDotNet.Builders; + +namespace CommandDotNet.DotnetSuggest; + +public static class DotnetTools +{ + // for example, see + // https://github.com/dotnet/command-line-api/blob/main/src/System.CommandLine.Suggest/GlobalToolsSuggestionRegistration.cs + public static string? GlobalToolDirectory => + DotnetProfileDirectory.TryGet(out string directory) + ? Path.Combine(directory, "tools") + : null; + + public static bool EnsureRegisteredWithDotnetSuggest(IEnvironment environment, + [NotNullWhen(false)] out ExternalCommand? results, IConsole? console = null) + { + // see System.CommandLine's CommandLineBuilderExtensions.RegisterWithDotnetSuggest for reference + + var appInfo = AppInfo.Instance; + results = null; + + if (appInfo.IsGlobalTool) + { + // already registered with DotnetSuggest see System.CommandLine's GlobalToolsSuggestionRegistration + return true; + } + + var reg = new FileSuggestionRegistration().FindRegistration(new FileInfo(appInfo.FilePath)); + if (reg is not null) + { + // already registered with DotnetSuggest + return true; + } + + EnsurePathEnvVarIsSetForOsShellScript(environment, appInfo); + + results = ExternalCommand.Run("dotnet-suggest", + $"register --command-path \"{appInfo.FilePath}\" " + + $"--suggestion-command \"{Path.GetFileNameWithoutExtension(appInfo.FileName)}\"", + console); + return results.Succeeded; + } + + private static void EnsurePathEnvVarIsSetForOsShellScript(IEnvironment environment, AppInfo appInfo) + { + var path = environment.GetEnvironmentVariable("PATH") ?? ""; + var directoryName = Path.GetDirectoryName(appInfo.FilePath)!; + if (!path.Contains(directoryName)) + { + path += path.IsNullOrWhitespace() + ? appInfo.FilePath + : Path.PathSeparator + appInfo.FilePath; + environment.SetEnvironmentVariables("PATH", path, EnvironmentVariableTarget.User); + } + } +} \ No newline at end of file diff --git a/CommandDotNet/DotnetSuggest/SuggestDirectiveMiddleware.cs b/CommandDotNet/DotnetSuggest/SuggestDirectiveMiddleware.cs new file mode 100644 index 00000000..fb29f737 --- /dev/null +++ b/CommandDotNet/DotnetSuggest/SuggestDirectiveMiddleware.cs @@ -0,0 +1,173 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using CommandDotNet.Directives; +using CommandDotNet.Execution; +using CommandDotNet.Extensions; +using CommandDotNet.Logging; +using CommandDotNet.Parsing; +using static System.Environment; + +namespace CommandDotNet.DotnetSuggest; + +public enum RegistrationStrategy +{ + None, + UseRegistrationDirective, + EnsureOnEveryRun +} + +internal static class SuggestDirectiveMiddleware +{ + internal static AppRunner UseSuggestDirective_Experimental(this AppRunner appRunner, + RegistrationStrategy registrationStrategy, string directiveName) + { + return appRunner.Configure(c => + { + c.Services.Add(new Options(registrationStrategy, directiveName)); + c.UseMiddleware(SuggestDirective, MiddlewareSteps.AutoSuggest.Directive); + }); + } + + private record Options(RegistrationStrategy RegistrationStrategy, string DirectiveName); + + private static Task SuggestDirective(CommandContext ctx, ExecutionDelegate next) + { + var options = ctx.Services.GetOrThrow(); + + if (options.RegistrationStrategy == RegistrationStrategy.UseRegistrationDirective + && ctx.Tokens.TryGetDirective(options.DirectiveName, out var _)) + { + var success = TryRegister(ctx, true); + return success ? ExitCodes.Success : ExitCodes.Error; + } + + if (!ctx.Tokens.TryGetDirective("suggest", out var _)) + { + // only ensure registration check registration when running + if (options.RegistrationStrategy == RegistrationStrategy.EnsureOnEveryRun && ctx.Original.Args.IsEmpty()) + { + TryRegister(ctx, false); + } + return next(ctx); + } + + // do not show help for suggest directive + ctx.ShowHelpOnExit = false; + + // var parts = value.Split(':'); + // int position = parts.Length == 1 ? 0 : int.Parse(parts[1]); + + // single dash could be valid for negative numbers + // but probably not for someone looking for auto-suggest + var lastToken = ctx.Original.Args.LastOrDefault(); + var lookingForOptions = lastToken is not null + && (lastToken.StartsWith("-") || lastToken.StartsWith("/")); + var useBackslash = lastToken?.StartsWith("/") ?? false; + + if (ctx.ParseResult!.ParseError == null) + { + var command = ctx.ParseResult.TargetCommand; + var suggestions = ToOptionSuggestions(command, useBackslash); + + if (!lookingForOptions) + { + if (ctx.ParseResult.NextAvailableOperand is not null) + { + suggestions = Concat(suggestions, ctx.ParseResult.NextAvailableOperand.AllowedValues); + } + + suggestions = Concat(suggestions, ToSubcommandSuggestions(command, ctx.ParseResult)); + } + + Report(ctx.Console, suggestions); + + return ExitCodes.Success; + } + + if (ctx.ParseResult.TokensEvaluatedCount < ctx.Tokens.Arguments.Count) + { + // The errors could be the result of typos for arguments earlier in the provided args list. + // Returning hints for those arguments would not give the user the results they are expecting. + // In those cases, it's best to return no suggestions like other apps do. + return ExitCodes.Error; + } + + switch (ctx.ParseResult.ParseError) + { + case UnrecognizedOptionParseError unrecognizedOption: + Report(ctx.Console, + ToOptionSuggestions(unrecognizedOption.Command, useBackslash), + unrecognizedOption.Token.Value); + break; + case UnrecognizedArgumentParseError unrecognizedArgument: + // TODO: include allowed values for first operand (what is the scenario?) + Report(ctx.Console, + ToSubcommandSuggestions(unrecognizedArgument.Command, ctx.ParseResult), + unrecognizedArgument.Token.Value); + break; + case NotAllowedValueParseError notAllowedValue: + var suggestions = lookingForOptions + ? ToOptionSuggestions(notAllowedValue.Command, useBackslash) + : Concat(notAllowedValue.Argument.AllowedValues, + ToSubcommandSuggestions(notAllowedValue.Command, ctx.ParseResult)); + Report(ctx.Console, suggestions, notAllowedValue.Token.Value); + break; + case MissingOptionValueParseError missingOptionValueParseError: + Report(ctx.Console, + missingOptionValueParseError.Option.AllowedValues); + break; + default: + ctx.Console.WriteLine($"unhandled parser error {ctx.ParseResult.ParseError.GetType().Name} {ctx.ParseResult.ParseError.Message}"); + break; + } + + return ExitCodes.Success; + } + + private static bool TryRegister(CommandContext ctx, bool outputToConsole) + { + ILog log = LogProvider.GetCurrentClassLogger(); + var success = DotnetTools.EnsureRegisteredWithDotnetSuggest(ctx.Environment, out var results, outputToConsole ? ctx.Console : null); + if (!success) + { + if (outputToConsole) + { + ctx.Console.WriteLine($"Failed to register with Dotnet Suggest.{NewLine}{results!.ToString(new Indent(depth: 1), skipOutputs: true)}"); + } + else + { + log.Warn($"Failed to register with Dotnet Suggest.{NewLine}{results!.ToString(new Indent(depth: 1))}"); + } + } + + return success; + } + + private static IEnumerable ToSubcommandSuggestions(Command command, + ParseResult parseResult) => + parseResult.IsCommandIdentified + ? Enumerable.Empty() + : command.Subcommands.SelectMany(c => c.Aliases); + + private static IEnumerable ToOptionSuggestions(Command command, bool useBackslash) => + command.Options + .Where(o => !o.Hidden) + .Where(o => o.Arity.AllowsMany() || !o.HasValueFromInput()) + .SelectMany(o => o.Aliases.Where(a => a.Length > 1)) + .Select(a => useBackslash ? $"/{a}" : $"--{a}"); + + private static IEnumerable Concat(params IEnumerable[] suggestions) => + suggestions.Aggregate( + Enumerable.Empty(), + (current, s) => current.Concat(s)); + + private static void Report(IConsole console, IEnumerable suggestions, string? root = null) + { + if (root is not null) + { + suggestions = suggestions.Where(s => s.StartsWith(root)); + } + suggestions.OrderBy(s => s).ForEach(console.WriteLine); + } +} \ No newline at end of file diff --git a/CommandDotNet/DotnetSuggest/System.CommandLine.Suggest/DotnetProfileDirectory.cs b/CommandDotNet/DotnetSuggest/System.CommandLine.Suggest/DotnetProfileDirectory.cs new file mode 100644 index 00000000..62182e2b --- /dev/null +++ b/CommandDotNet/DotnetSuggest/System.CommandLine.Suggest/DotnetProfileDirectory.cs @@ -0,0 +1,43 @@ +// copied from: https://raw.githubusercontent.com/dotnet/command-line-api/main/src/System.CommandLine.Suggest/DotnetProfileDirectory.cs +// via: DotNetSuggestSync test class + +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.IO; +using System.Runtime.InteropServices; + +#pragma warning disable CS8600 +#pragma warning disable CS8603 +#pragma warning disable CS8625 +// ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract +// ReSharper disable CheckNamespace + +namespace System.CommandLine.Suggest +{ + public static class DotnetProfileDirectory + { + private const string DotnetHomeVariableName = "DOTNET_CLI_HOME"; + private const string DotnetProfileDirectoryName = ".dotnet"; + + private static string PlatformHomeVariableName => + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "USERPROFILE" : "HOME"; + + public static bool TryGet(out string dotnetProfileDirectory) + { + dotnetProfileDirectory = null; + var home = Environment.GetEnvironmentVariable(DotnetHomeVariableName); + if (string.IsNullOrEmpty(home)) + { + home = Environment.GetEnvironmentVariable(PlatformHomeVariableName); + if (string.IsNullOrEmpty(home)) + { + return false; + } + } + + dotnetProfileDirectory = Path.Combine(home, DotnetProfileDirectoryName); + return true; + } + } +} diff --git a/CommandDotNet/DotnetSuggest/System.CommandLine.Suggest/FileSuggestionRegistration.cs b/CommandDotNet/DotnetSuggest/System.CommandLine.Suggest/FileSuggestionRegistration.cs new file mode 100644 index 00000000..403cc863 --- /dev/null +++ b/CommandDotNet/DotnetSuggest/System.CommandLine.Suggest/FileSuggestionRegistration.cs @@ -0,0 +1,111 @@ +// copied from: https://raw.githubusercontent.com/dotnet/command-line-api/main/src/System.CommandLine.Suggest/FileSuggestionRegistration.cs +// via: DotNetSuggestSync test class + +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Collections.Generic; +using System.IO; +using System.Text; +using static System.Environment; + +#pragma warning disable CS8600 +#pragma warning disable CS8603 +#pragma warning disable CS8625 +// ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract +// ReSharper disable CheckNamespace + +namespace System.CommandLine.Suggest +{ + public class FileSuggestionRegistration + { + private const string RegistrationFileName = ".dotnet-suggest-registration.txt"; + private const string TestDirectoryOverride = "INTERNAL_TEST_DOTNET_SUGGEST_HOME"; + private readonly string _registrationConfigurationFilePath; + public string RegistrationConfigurationFilePath => _registrationConfigurationFilePath; + + public FileSuggestionRegistration(string registrationsConfigurationFilePath = null) + { + if (!string.IsNullOrWhiteSpace(registrationsConfigurationFilePath)) + { + _registrationConfigurationFilePath = registrationsConfigurationFilePath; + return; + } + + var testDirectoryOverride = GetEnvironmentVariable(TestDirectoryOverride); + if (!string.IsNullOrWhiteSpace(testDirectoryOverride)) + { + _registrationConfigurationFilePath = Path.Combine(testDirectoryOverride, RegistrationFileName); + return; + } + + var userProfile = GetFolderPath(SpecialFolder.UserProfile); + + _registrationConfigurationFilePath = Path.Combine(userProfile, RegistrationFileName); + } + + public Registration FindRegistration(FileInfo soughtExecutable) + { + if (soughtExecutable == null) + { + return null; + } + + if (_registrationConfigurationFilePath == null + || !File.Exists(_registrationConfigurationFilePath)) + { + return null; + } + + string completionTarget = null; + using (var sr = new StreamReader(_registrationConfigurationFilePath, Encoding.UTF8)) + { + while (sr.ReadLine() is string line) + { + if (line.StartsWith(soughtExecutable.FullName, StringComparison.OrdinalIgnoreCase)) + { + completionTarget = line; + } + } + } + + if (completionTarget is null) + { + // Completion provider not found! + return null; + } + + return new Registration(completionTarget); + } + + public IEnumerable FindAllRegistrations() + { + var allRegistration = new List(); + + if (_registrationConfigurationFilePath != null && File.Exists(_registrationConfigurationFilePath)) + { + using (var sr = new StreamReader(_registrationConfigurationFilePath, Encoding.UTF8)) + { + string line; + while ((line = sr.ReadLine()) != null) + { + if (!string.IsNullOrWhiteSpace(line)) + { + allRegistration.Add(new Registration(line.Trim())); + } + } + } + } + + return allRegistration; + } + + public void AddSuggestionRegistration(Registration registration) + { + using (var writer = new StreamWriter(_registrationConfigurationFilePath, true)) + { + writer.WriteLine(registration.ExecutablePath); + } + } + } +} diff --git a/CommandDotNet/DotnetSuggest/System.CommandLine.Suggest/RegistrationPair.cs b/CommandDotNet/DotnetSuggest/System.CommandLine.Suggest/RegistrationPair.cs new file mode 100644 index 00000000..15f9bcaa --- /dev/null +++ b/CommandDotNet/DotnetSuggest/System.CommandLine.Suggest/RegistrationPair.cs @@ -0,0 +1,21 @@ +// copied from: https://raw.githubusercontent.com/dotnet/command-line-api/main/src/System.CommandLine.Suggest/RegistrationPair.cs +// via: DotNetSuggestSync test class + +#pragma warning disable CS8600 +#pragma warning disable CS8603 +#pragma warning disable CS8625 +// ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract +// ReSharper disable CheckNamespace + +namespace System.CommandLine.Suggest +{ + public class Registration + { + public Registration(string executablePath) + { + ExecutablePath = executablePath ?? throw new ArgumentNullException(nameof(executablePath)); + } + + public string ExecutablePath { get; } + } +} diff --git a/CommandDotNet/Execution/MiddlewareSteps.cs b/CommandDotNet/Execution/MiddlewareSteps.cs index 3a575937..839a296a 100644 --- a/CommandDotNet/Execution/MiddlewareSteps.cs +++ b/CommandDotNet/Execution/MiddlewareSteps.cs @@ -37,12 +37,20 @@ public static class DependencyResolver public static MiddlewareStep ParseInput { get; } = new(MiddlewareStages.ParseInput, 0); + public static class AutoSuggest + { + /// + /// Runs after to suggest next possible argument or value + /// + public static MiddlewareStep Directive { get; } = ParseInput + 1000; + } + /// /// Runs after to respond to parse errors /// - public static MiddlewareStep TypoSuggest { get; } = ParseInput + 1000; + public static MiddlewareStep TypoSuggest { get; } = AutoSuggest.Directive + 1000; - public static MiddlewareStep AssembleInvocationPipeline { get; } = ParseInput + 2000; + public static MiddlewareStep AssembleInvocationPipeline { get; } = TypoSuggest + 1000; /// Runs before to ensure default values are included in the help output public static MiddlewareStep Version { get; } = Help.CheckIfShouldShowHelp - 2000; diff --git a/CommandDotNet/ExternalCommand.cs b/CommandDotNet/ExternalCommand.cs new file mode 100644 index 00000000..70f262aa --- /dev/null +++ b/CommandDotNet/ExternalCommand.cs @@ -0,0 +1,101 @@ +using System; +using System.Diagnostics; +using System.Text; +using CommandDotNet.Diagnostics; +using static System.Environment; + +namespace CommandDotNet +{ + /// + /// Note: Our cheap and cheerful CliWrap.
+ /// This class exists to avoid external dependencies for core features.
+ /// Checkout CliWrap if you need a more features than are exposed here. + ///
+ public class ExternalCommand + { + private readonly StringBuilder _stdOut = new(); + private readonly StringBuilder _stdErr = new(); + + private readonly IConsole? _console; + + public string FileName { get; } + public string Arguments { get; } + public int? ExitCode { get; private set; } + public Exception? Error { get; private set; } + + public bool Succeeded => Error is null && ExitCode is 0; + + public TimeSpan Elapsed { get; set; } + + public string StandardOutput => _stdOut.ToString(); + public string StandardError => _stdErr.ToString(); + + public static ExternalCommand Run(string fileName, string arguments, IConsole? console = null) => + new ExternalCommand(fileName, arguments, console).Run(); + + private ExternalCommand(string fileName, string arguments, IConsole? console = null) + { + FileName = fileName ?? throw new ArgumentNullException(nameof(fileName)); + Arguments = arguments ?? throw new ArgumentNullException(nameof(arguments)); + _console = console; + } + + private ExternalCommand Run() + { + using var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = FileName, + Arguments = Arguments, + RedirectStandardError = true, + RedirectStandardOutput = true + } + }; + + process.OutputDataReceived += (sender, args) => _stdOut.Append(args.Data); + process.ErrorDataReceived += (sender, args) => _stdErr.Append(args.Data); + if (_console != null) + { + process.OutputDataReceived += (sender, args) => _console.Out.Write(args.Data); + process.ErrorDataReceived += (sender, args) => _console.Error.Write(args.Data); + } + + try + { + var sw = Stopwatch.StartNew(); + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + process.WaitForExit(); + ExitCode = process.ExitCode; + Elapsed = sw.Elapsed; + } + catch (Exception e) + { + Error = e; + } + + return this; + } + + public string ToString(Indent indent) => ToString(indent, false); + + public string ToString(Indent indent, bool skipOutputs) + { + indent = indent.Increment(); + var ouptut = $"{nameof(ExternalCommand)}:{NewLine}" + + $"{indent}Command: {FileName}{NewLine}" + + $"{indent}Arguments: {Arguments}{NewLine}" + + $"{indent}ExitCode: {ExitCode}{NewLine}" + + $"{indent}Succeeded: {Succeeded}{NewLine}" + + $"{indent}Elapsed: {Elapsed}{NewLine}" + + $"{indent}Error: {Error?.Print(indent, includeProperties: true, includeData: true)}{NewLine}"; + return skipOutputs + ? ouptut + : ouptut + + $"{indent}Standard Output: {StandardOutput}{NewLine}" + + $"{indent}Error Output: {StandardError}{NewLine}"; + } + } +} \ No newline at end of file diff --git a/CommandDotNet/Parsing/CommandParser.OperandQueue.cs b/CommandDotNet/Parsing/CommandParser.OperandQueue.cs index 92027bd5..fcf09653 100644 --- a/CommandDotNet/Parsing/CommandParser.OperandQueue.cs +++ b/CommandDotNet/Parsing/CommandParser.OperandQueue.cs @@ -19,7 +19,7 @@ public OperandQueue(IEnumerable operands) public bool TryDequeue([NotNullWhen(true)] out Operand? operand) { operand = Dequeue(); - return operand is { }; + return operand is not null; } public Operand? Dequeue() diff --git a/CommandDotNet/Parsing/CommandParser.ParseContext.cs b/CommandDotNet/Parsing/CommandParser.ParseContext.cs index e5b57b3b..6088d21a 100644 --- a/CommandDotNet/Parsing/CommandParser.ParseContext.cs +++ b/CommandDotNet/Parsing/CommandParser.ParseContext.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using CommandDotNet.Extensions; using CommandDotNet.Tokens; namespace CommandDotNet.Parsing @@ -32,6 +33,7 @@ public Command Command } } + public int TokensEvaluated { get; set; } public bool SubcommandsAreAllowed { get; private set; } = true; @@ -70,6 +72,20 @@ public void ClearOption() } public void CommandArgumentParsed() => SubcommandsAreAllowed = false; + + public ParseResult ToParseResult() => + ParserError is null + ? new ParseResult( + Command, + RemainingOperands.ToReadOnlyCollection(), + CommandContext.Tokens.Separated, + Operands.Dequeue(), + TokensEvaluated, + !SubcommandsAreAllowed) + : new ParseResult(ParserError, + Operands.Dequeue(), + TokensEvaluated, + !SubcommandsAreAllowed); } } } \ No newline at end of file diff --git a/CommandDotNet/Parsing/CommandParser.cs b/CommandDotNet/Parsing/CommandParser.cs index f149ff53..53bf50c2 100644 --- a/CommandDotNet/Parsing/CommandParser.cs +++ b/CommandDotNet/Parsing/CommandParser.cs @@ -18,10 +18,7 @@ internal static Task ParseInputMiddleware(CommandContext commandContext, Ex : null; var parseContext = new ParseContext(commandContext, new Queue(commandContext.Tokens.Arguments), separator); ParseCommand(commandContext, parseContext); - if (parseContext.ParserError is { }) - { - commandContext.ParseResult = new ParseResult(parseContext.ParserError); - } + commandContext.ParseResult = parseContext.ToParseResult(); return next(commandContext); } @@ -29,6 +26,7 @@ private static void ParseCommand(CommandContext commandContext, ParseContext par { foreach (var token in commandContext.Tokens.Arguments) { + parseContext.TokensEvaluated++; switch (token.TokenType) { case TokenType.Argument: @@ -68,11 +66,6 @@ private static void ParseCommand(CommandContext commandContext, ParseContext par .TakeWhile(_ => parseContext.ParserError is null) .ForEach(t => ParseValue(parseContext, t, operandsOnly:true)); } - - commandContext.ParseResult = new ParseResult( - parseContext.Command, - parseContext.RemainingOperands.ToReadOnlyCollection(), - commandContext.Tokens.Separated); } private static void ParseValue(ParseContext parseContext, Token token, bool operandsOnly = false) @@ -150,7 +143,8 @@ private static bool TryParseOption(ParseContext parseContext, Token token) if (node is Command) { var suggestion = parseContext.CommandContext.Original.Args.ToCsv(" ").Replace(token.RawValue, optionName); - parseContext.ParserError = new UnrecognizedArgumentParseError(parseContext.Command, token, optionPrefix, + parseContext.ParserError = new UnrecognizedArgumentParseError( + parseContext.Command, token, optionPrefix, null, Resources.A.Parse_Intended_command_instead_of_option(token.RawValue, optionName, suggestion)); return true; } @@ -275,10 +269,11 @@ private static void ParseOperand(ParseContext parseContext, Token token) { if (parseContext.Operands.TryDequeue(out var operand)) { - var currentOperand = operand!; - if (ValueIsAllowed(parseContext, currentOperand, token)) + // do not combine with the above if statement + // ValueIsAllowed will set the ParserError if the value is not allowed + if (ValueIsAllowed(parseContext, operand, token)) { - currentOperand + operand .GetAlreadyParsedValues() .Add(new ValueFromToken(token.Value, token, null)); parseContext.CommandArgumentParsed(); @@ -291,7 +286,8 @@ private static void ParseOperand(ParseContext parseContext, Token token) } else { - parseContext.ParserError = new UnrecognizedArgumentParseError(parseContext.Command, token, null, + parseContext.ParserError = new UnrecognizedArgumentParseError( + parseContext.Command, token, null, operand, Resources.A.Parse_Unrecognized_command_or_argument(token.RawValue)); } } diff --git a/CommandDotNet/Parsing/ParseResult.cs b/CommandDotNet/Parsing/ParseResult.cs index f7ea8c94..63352d12 100644 --- a/CommandDotNet/Parsing/ParseResult.cs +++ b/CommandDotNet/Parsing/ParseResult.cs @@ -14,6 +14,23 @@ public class ParseResult : IIndentableToString /// The command that addressed by the command line arguments public Command TargetCommand { get; } + /// + /// The next operand that could receive a value. + /// If the operand is a list value, it may already have values assigned. + /// + public Operand? NextAvailableOperand { get; } + + /// + /// The count of tokens evaluated. + /// If there was an error evaluating the token, it's count is included. + /// + public int TokensEvaluatedCount { get; } + + /// + /// True if a command was identified. No subcommands could be targets at this point. + /// + public bool IsCommandIdentified { get; } + /// /// If extra operands were provided and is true, /// The extra operands will be stored in the collection. @@ -43,16 +60,28 @@ public bool HelpWasRequested() => public ParseResult(Command command, IReadOnlyCollection remainingOperands, - IReadOnlyCollection separatedArguments) + IReadOnlyCollection separatedArguments, + Operand? nextAvailableOperand, + int tokensEvaluatedCount, + bool isCommandIdentified) { TargetCommand = command ?? throw new ArgumentNullException(nameof(command)); RemainingOperands = remainingOperands.ToArgsArray(); SeparatedArguments = separatedArguments.ToArgsArray(); + NextAvailableOperand = nextAvailableOperand; + TokensEvaluatedCount = tokensEvaluatedCount; + IsCommandIdentified = isCommandIdentified; } - public ParseResult(IParseError error) + public ParseResult(IParseError error, + Operand? nextAvailableOperand, + int tokensEvaluatedCount, + bool isCommandIdentified) { ParseError = error ?? throw new ArgumentNullException(nameof(error)); + NextAvailableOperand = nextAvailableOperand; + TokensEvaluatedCount = tokensEvaluatedCount; + IsCommandIdentified = isCommandIdentified; TargetCommand = error.Command; RemainingOperands = Array.Empty(); SeparatedArguments = Array.Empty(); @@ -69,7 +98,9 @@ public string ToString(Indent indent) $"{indent}{nameof(TargetCommand)}:{TargetCommand}{NewLine}" + $"{indent}{nameof(RemainingOperands)}:{RemainingOperands.ToCsv()}{NewLine}" + $"{indent}{nameof(SeparatedArguments)}:{SeparatedArguments.ToCsv()}{NewLine}" + - $"{indent}{nameof(ParseError)}:{ParseError?.Message}"; + $"{indent}{nameof(NextAvailableOperand)}:{NextAvailableOperand}{NewLine}" + + $"{indent}{nameof(ParseError)}:" + + (ParseError is null ? null : $"<{ParseError.GetType().Name}> {ParseError.Message}"); } } } \ No newline at end of file diff --git a/CommandDotNet/Parsing/UnrecognizedArgumentParseError.cs b/CommandDotNet/Parsing/UnrecognizedArgumentParseError.cs index 635d109b..502e8407 100644 --- a/CommandDotNet/Parsing/UnrecognizedArgumentParseError.cs +++ b/CommandDotNet/Parsing/UnrecognizedArgumentParseError.cs @@ -13,12 +13,15 @@ public class UnrecognizedArgumentParseError: IParseError public Command Command { get; } public Token Token { get; } public string? OptionPrefix { get; } + public Operand? NextOperand { get; } - public UnrecognizedArgumentParseError(Command command, Token token, string? optionPrefix, string message) + public UnrecognizedArgumentParseError(Command command, Token token, string? optionPrefix, Operand? nextOperand, + string message) { Command = command ?? throw new ArgumentNullException(nameof(command)); Token = token ?? throw new ArgumentNullException(nameof(token)); OptionPrefix = optionPrefix; + NextOperand = nextOperand; Message = message ?? throw new ArgumentNullException(nameof(message)); } } diff --git a/CommandDotNet/Parsing/UnrecognizedOptionParseError.cs b/CommandDotNet/Parsing/UnrecognizedOptionParseError.cs index b9562152..6252786c 100644 --- a/CommandDotNet/Parsing/UnrecognizedOptionParseError.cs +++ b/CommandDotNet/Parsing/UnrecognizedOptionParseError.cs @@ -9,7 +9,7 @@ namespace CommandDotNet.Parsing public class UnrecognizedOptionParseError : UnrecognizedArgumentParseError { public UnrecognizedOptionParseError(Command command, Token token, string optionPrefix, string? message = null) - : base(command, token, optionPrefix, message ?? Resources.A.Parse_Unrecognized_option(token.RawValue)) + : base(command, token, optionPrefix, null, message ?? Resources.A.Parse_Unrecognized_option(token.RawValue)) { } }