From 0e0e0dce789bfe6b0802f73badfe7925f90bf592 Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Fri, 17 Apr 2026 00:46:47 +0100 Subject: [PATCH 1/5] Add System.CommandLine.StaticCompletions project Imports the StaticCompletions library and test project from dotnet/sdk, converting the System.CommandLine dependency from a NuGet PackageReference to a ProjectReference. Tests run against a real System.CommandLine build via Verify.Xunit snapshots. --- Directory.Packages.props | 2 + System.CommandLine.sln | 30 ++ .../BashShellProviderTests.cs | 45 +++ .../Directory.Build.targets | 11 + .../GlobalUsings.cs | 6 + .../HelpExtensionsTests.cs | 47 +++ .../PowershellProviderTests.cs | 47 +++ ...CommandLine.StaticCompletions.Tests.csproj | 24 ++ .../VerifyConfiguration.cs | 29 ++ .../VerifyExtensions.cs | 18 + .../ZshShellProviderTests.cs | 89 +++++ ...oviderTests.GenericCompletions.verified.sh | 20 + ...sts.NestedSubcommandCompletion.verified.sh | 68 ++++ ...erTests.SimpleOptionCompletion.verified.sh | 20 + ...commandAndOptionInTopLevelList.verified.sh | 44 ++ ...viderTests.GenericCompletions.verified.ps1 | 31 ++ ...ts.NestedSubcommandCompletion.verified.ps1 | 45 +++ ...rTests.SimpleOptionCompletion.verified.ps1 | 32 ++ ...ommandAndOptionInTopLevelList.verified.ps1 | 39 ++ ...omStaticCompletionsGeneration.verified.zsh | 34 ++ ....DynamicCompletionsGeneration.verified.zsh | 44 ++ ...viderTests.GenericCompletions.verified.zsh | 104 +++++ .../CompletionsCommandDefinition.cs | 38 ++ .../CompletionsCommandParser.cs | 44 ++ ...pletionsGenerateScriptCommandDefinition.cs | 18 + .../DynamicSymbolExtensions.cs | 66 +++ .../HelpGenerationExtensions.cs | 115 ++++++ .../Resources/Strings.resx | 156 ++++++++ .../Resources/xlf/Strings.cs.xlf | 57 +++ .../Resources/xlf/Strings.de.xlf | 57 +++ .../Resources/xlf/Strings.es.xlf | 57 +++ .../Resources/xlf/Strings.fr.xlf | 57 +++ .../Resources/xlf/Strings.it.xlf | 57 +++ .../Resources/xlf/Strings.ja.xlf | 57 +++ .../Resources/xlf/Strings.ko.xlf | 57 +++ .../Resources/xlf/Strings.pl.xlf | 57 +++ .../Resources/xlf/Strings.pt-BR.xlf | 57 +++ .../Resources/xlf/Strings.ru.xlf | 57 +++ .../Resources/xlf/Strings.tr.xlf | 57 +++ .../Resources/xlf/Strings.zh-Hans.xlf | 57 +++ .../Resources/xlf/Strings.zh-Hant.xlf | 57 +++ .../ShellName.cs | 43 ++ ...ystem.CommandLine.StaticCompletions.csproj | 33 ++ .../shells/BashShellProvider.cs | 226 +++++++++++ .../shells/FishShellProvider.cs | 28 ++ .../shells/NuShellShellProvider.cs | 49 +++ .../shells/PowershellShellProvider.cs | 246 ++++++++++++ .../shells/ShellProvider.cs | 35 ++ .../shells/ZshShellProvider.cs | 377 ++++++++++++++++++ 49 files changed, 3044 insertions(+) create mode 100644 src/System.CommandLine.StaticCompletions.Tests/BashShellProviderTests.cs create mode 100644 src/System.CommandLine.StaticCompletions.Tests/Directory.Build.targets create mode 100644 src/System.CommandLine.StaticCompletions.Tests/GlobalUsings.cs create mode 100644 src/System.CommandLine.StaticCompletions.Tests/HelpExtensionsTests.cs create mode 100644 src/System.CommandLine.StaticCompletions.Tests/PowershellProviderTests.cs create mode 100644 src/System.CommandLine.StaticCompletions.Tests/System.CommandLine.StaticCompletions.Tests.csproj create mode 100644 src/System.CommandLine.StaticCompletions.Tests/VerifyConfiguration.cs create mode 100644 src/System.CommandLine.StaticCompletions.Tests/VerifyExtensions.cs create mode 100644 src/System.CommandLine.StaticCompletions.Tests/ZshShellProviderTests.cs create mode 100644 src/System.CommandLine.StaticCompletions.Tests/snapshots/bash/BashShellProviderTests.GenericCompletions.verified.sh create mode 100644 src/System.CommandLine.StaticCompletions.Tests/snapshots/bash/BashShellProviderTests.NestedSubcommandCompletion.verified.sh create mode 100644 src/System.CommandLine.StaticCompletions.Tests/snapshots/bash/BashShellProviderTests.SimpleOptionCompletion.verified.sh create mode 100644 src/System.CommandLine.StaticCompletions.Tests/snapshots/bash/BashShellProviderTests.SubcommandAndOptionInTopLevelList.verified.sh create mode 100644 src/System.CommandLine.StaticCompletions.Tests/snapshots/pwsh/PowershellProviderTests.GenericCompletions.verified.ps1 create mode 100644 src/System.CommandLine.StaticCompletions.Tests/snapshots/pwsh/PowershellProviderTests.NestedSubcommandCompletion.verified.ps1 create mode 100644 src/System.CommandLine.StaticCompletions.Tests/snapshots/pwsh/PowershellProviderTests.SimpleOptionCompletion.verified.ps1 create mode 100644 src/System.CommandLine.StaticCompletions.Tests/snapshots/pwsh/PowershellProviderTests.SubcommandAndOptionInTopLevelList.verified.ps1 create mode 100644 src/System.CommandLine.StaticCompletions.Tests/snapshots/zsh/ZshShellProviderTests.CustomStaticCompletionsGeneration.verified.zsh create mode 100644 src/System.CommandLine.StaticCompletions.Tests/snapshots/zsh/ZshShellProviderTests.DynamicCompletionsGeneration.verified.zsh create mode 100644 src/System.CommandLine.StaticCompletions.Tests/snapshots/zsh/ZshShellProviderTests.GenericCompletions.verified.zsh create mode 100644 src/System.CommandLine.StaticCompletions/CompletionsCommandDefinition.cs create mode 100644 src/System.CommandLine.StaticCompletions/CompletionsCommandParser.cs create mode 100644 src/System.CommandLine.StaticCompletions/CompletionsGenerateScriptCommandDefinition.cs create mode 100644 src/System.CommandLine.StaticCompletions/DynamicSymbolExtensions.cs create mode 100644 src/System.CommandLine.StaticCompletions/HelpGenerationExtensions.cs create mode 100644 src/System.CommandLine.StaticCompletions/Resources/Strings.resx create mode 100644 src/System.CommandLine.StaticCompletions/Resources/xlf/Strings.cs.xlf create mode 100644 src/System.CommandLine.StaticCompletions/Resources/xlf/Strings.de.xlf create mode 100644 src/System.CommandLine.StaticCompletions/Resources/xlf/Strings.es.xlf create mode 100644 src/System.CommandLine.StaticCompletions/Resources/xlf/Strings.fr.xlf create mode 100644 src/System.CommandLine.StaticCompletions/Resources/xlf/Strings.it.xlf create mode 100644 src/System.CommandLine.StaticCompletions/Resources/xlf/Strings.ja.xlf create mode 100644 src/System.CommandLine.StaticCompletions/Resources/xlf/Strings.ko.xlf create mode 100644 src/System.CommandLine.StaticCompletions/Resources/xlf/Strings.pl.xlf create mode 100644 src/System.CommandLine.StaticCompletions/Resources/xlf/Strings.pt-BR.xlf create mode 100644 src/System.CommandLine.StaticCompletions/Resources/xlf/Strings.ru.xlf create mode 100644 src/System.CommandLine.StaticCompletions/Resources/xlf/Strings.tr.xlf create mode 100644 src/System.CommandLine.StaticCompletions/Resources/xlf/Strings.zh-Hans.xlf create mode 100644 src/System.CommandLine.StaticCompletions/Resources/xlf/Strings.zh-Hant.xlf create mode 100644 src/System.CommandLine.StaticCompletions/ShellName.cs create mode 100644 src/System.CommandLine.StaticCompletions/System.CommandLine.StaticCompletions.csproj create mode 100644 src/System.CommandLine.StaticCompletions/shells/BashShellProvider.cs create mode 100644 src/System.CommandLine.StaticCompletions/shells/FishShellProvider.cs create mode 100644 src/System.CommandLine.StaticCompletions/shells/NuShellShellProvider.cs create mode 100644 src/System.CommandLine.StaticCompletions/shells/PowershellShellProvider.cs create mode 100644 src/System.CommandLine.StaticCompletions/shells/ShellProvider.cs create mode 100644 src/System.CommandLine.StaticCompletions/shells/ZshShellProvider.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index da1e624248..89021a0760 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -19,6 +19,8 @@ + + diff --git a/System.CommandLine.sln b/System.CommandLine.sln index 88ff8abd06..90e6e95f74 100644 --- a/System.CommandLine.sln +++ b/System.CommandLine.sln @@ -31,6 +31,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "dotnet-suggest.Tests", "src EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "System.CommandLine.ApiCompatibility.Tests", "src\System.CommandLine.ApiCompatibility.Tests\System.CommandLine.ApiCompatibility.Tests.csproj", "{A54EE328-D456-4BAF-A180-84E77E6409AC}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "System.CommandLine.StaticCompletions", "src\System.CommandLine.StaticCompletions\System.CommandLine.StaticCompletions.csproj", "{B1C3A2F4-5D6E-7F8A-9B0C-1D2E3F4A5B6C}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "System.CommandLine.StaticCompletions.Tests", "src\System.CommandLine.StaticCompletions.Tests\System.CommandLine.StaticCompletions.Tests.csproj", "{C2D3E4F5-6A7B-8C9D-0E1F-2A3B4C5D6E7F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -101,6 +105,30 @@ Global {A54EE328-D456-4BAF-A180-84E77E6409AC}.Release|x64.Build.0 = Release|Any CPU {A54EE328-D456-4BAF-A180-84E77E6409AC}.Release|x86.ActiveCfg = Release|Any CPU {A54EE328-D456-4BAF-A180-84E77E6409AC}.Release|x86.Build.0 = Release|Any CPU + {B1C3A2F4-5D6E-7F8A-9B0C-1D2E3F4A5B6C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B1C3A2F4-5D6E-7F8A-9B0C-1D2E3F4A5B6C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B1C3A2F4-5D6E-7F8A-9B0C-1D2E3F4A5B6C}.Debug|x64.ActiveCfg = Debug|Any CPU + {B1C3A2F4-5D6E-7F8A-9B0C-1D2E3F4A5B6C}.Debug|x64.Build.0 = Debug|Any CPU + {B1C3A2F4-5D6E-7F8A-9B0C-1D2E3F4A5B6C}.Debug|x86.ActiveCfg = Debug|Any CPU + {B1C3A2F4-5D6E-7F8A-9B0C-1D2E3F4A5B6C}.Debug|x86.Build.0 = Debug|Any CPU + {B1C3A2F4-5D6E-7F8A-9B0C-1D2E3F4A5B6C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B1C3A2F4-5D6E-7F8A-9B0C-1D2E3F4A5B6C}.Release|Any CPU.Build.0 = Release|Any CPU + {B1C3A2F4-5D6E-7F8A-9B0C-1D2E3F4A5B6C}.Release|x64.ActiveCfg = Release|Any CPU + {B1C3A2F4-5D6E-7F8A-9B0C-1D2E3F4A5B6C}.Release|x64.Build.0 = Release|Any CPU + {B1C3A2F4-5D6E-7F8A-9B0C-1D2E3F4A5B6C}.Release|x86.ActiveCfg = Release|Any CPU + {B1C3A2F4-5D6E-7F8A-9B0C-1D2E3F4A5B6C}.Release|x86.Build.0 = Release|Any CPU + {C2D3E4F5-6A7B-8C9D-0E1F-2A3B4C5D6E7F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C2D3E4F5-6A7B-8C9D-0E1F-2A3B4C5D6E7F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C2D3E4F5-6A7B-8C9D-0E1F-2A3B4C5D6E7F}.Debug|x64.ActiveCfg = Debug|Any CPU + {C2D3E4F5-6A7B-8C9D-0E1F-2A3B4C5D6E7F}.Debug|x64.Build.0 = Debug|Any CPU + {C2D3E4F5-6A7B-8C9D-0E1F-2A3B4C5D6E7F}.Debug|x86.ActiveCfg = Debug|Any CPU + {C2D3E4F5-6A7B-8C9D-0E1F-2A3B4C5D6E7F}.Debug|x86.Build.0 = Debug|Any CPU + {C2D3E4F5-6A7B-8C9D-0E1F-2A3B4C5D6E7F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C2D3E4F5-6A7B-8C9D-0E1F-2A3B4C5D6E7F}.Release|Any CPU.Build.0 = Release|Any CPU + {C2D3E4F5-6A7B-8C9D-0E1F-2A3B4C5D6E7F}.Release|x64.ActiveCfg = Release|Any CPU + {C2D3E4F5-6A7B-8C9D-0E1F-2A3B4C5D6E7F}.Release|x64.Build.0 = Release|Any CPU + {C2D3E4F5-6A7B-8C9D-0E1F-2A3B4C5D6E7F}.Release|x86.ActiveCfg = Release|Any CPU + {C2D3E4F5-6A7B-8C9D-0E1F-2A3B4C5D6E7F}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -111,6 +139,8 @@ Global {E23C760E-B826-4B4F-BE76-916D86BAD2DB} = {E5B1EC71-0FC4-4FAA-9C65-32D5016FBC45} {E41F0471-B14D-4FA0-9D8B-1E7750695AE9} = {E5B1EC71-0FC4-4FAA-9C65-32D5016FBC45} {A54EE328-D456-4BAF-A180-84E77E6409AC} = {E5B1EC71-0FC4-4FAA-9C65-32D5016FBC45} + {B1C3A2F4-5D6E-7F8A-9B0C-1D2E3F4A5B6C} = {E5B1EC71-0FC4-4FAA-9C65-32D5016FBC45} + {C2D3E4F5-6A7B-8C9D-0E1F-2A3B4C5D6E7F} = {E5B1EC71-0FC4-4FAA-9C65-32D5016FBC45} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {5C159F93-800B-49E7-9905-EE09F8B8434A} diff --git a/src/System.CommandLine.StaticCompletions.Tests/BashShellProviderTests.cs b/src/System.CommandLine.StaticCompletions.Tests/BashShellProviderTests.cs new file mode 100644 index 0000000000..9e368a5fbf --- /dev/null +++ b/src/System.CommandLine.StaticCompletions.Tests/BashShellProviderTests.cs @@ -0,0 +1,45 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +namespace System.CommandLine.StaticCompletions.Tests; + +using System.CommandLine.StaticCompletions.Shells; + +public class BashShellProviderTests(ITestOutputHelper log) +{ + private IShellProvider provider = new BashShellProvider(); + [Fact] + public async Task GenericCompletions() + { + await provider.Verify(new("mycommand"), log); + } + + [Fact] + public async Task SimpleOptionCompletion() + { + await provider.Verify(new("mycommand") { + new Option("--name") + }, log); + } + + [Fact] + public async Task SubcommandAndOptionInTopLevelList() + { + await provider.Verify(new("mycommand") { + new Option("--name"), + new Command("subcommand") + }, log); + } + + [Fact] + public async Task NestedSubcommandCompletion() + { + await provider.Verify(new("mycommand") { + new Command("subcommand") { + new Command("nested") + } + }, log); + } +} diff --git a/src/System.CommandLine.StaticCompletions.Tests/Directory.Build.targets b/src/System.CommandLine.StaticCompletions.Tests/Directory.Build.targets new file mode 100644 index 0000000000..3d8551bb94 --- /dev/null +++ b/src/System.CommandLine.StaticCompletions.Tests/Directory.Build.targets @@ -0,0 +1,11 @@ + + + + + + + Library + + + diff --git a/src/System.CommandLine.StaticCompletions.Tests/GlobalUsings.cs b/src/System.CommandLine.StaticCompletions.Tests/GlobalUsings.cs new file mode 100644 index 0000000000..7ad692d8b7 --- /dev/null +++ b/src/System.CommandLine.StaticCompletions.Tests/GlobalUsings.cs @@ -0,0 +1,6 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +global using Xunit; +global using Xunit.Abstractions; +global using FluentAssertions; diff --git a/src/System.CommandLine.StaticCompletions.Tests/HelpExtensionsTests.cs b/src/System.CommandLine.StaticCompletions.Tests/HelpExtensionsTests.cs new file mode 100644 index 0000000000..9cd8949bfb --- /dev/null +++ b/src/System.CommandLine.StaticCompletions.Tests/HelpExtensionsTests.cs @@ -0,0 +1,47 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +namespace System.CommandLine.StaticCompletions.Tests; + +using System.CommandLine.Help; +using System.CommandLine.StaticCompletions; + +public class HelpExtensionsTests +{ + [Fact] + public void HelpOptionOnlyShowsUsefulNames() + { + new HelpOption().Names().Should().BeEquivalentTo(["--help", "-h"]); + } + + [Fact] + public void OptionNamesListNameThenAliases() + { + new Option("--name", "-n", "--nombre").Names().Should().Equal(["--name", "-n", "--nombre"]); + } + + [Fact] + public void OptionsWithNoAliasesHaveOnlyOneName() + { + new Option("--name").Names().Should().Equal(["--name"]); + } + + [Fact] + public void HeirarchicalOptionsAreFlattened() + { + var parentCommand = new Command("parent"); + var childCommand = new Command("child"); + parentCommand.Subcommands.Add(childCommand); + parentCommand.Options.Add(new Option("--parent-global") { Recursive = true }); + parentCommand.Options.Add(new Option("--parent-local") { Recursive = false }); + parentCommand.Options.Add(new Option("--parent-global-but-hidden") { Recursive = true, Hidden = true }); + + childCommand.Options.Add(new Option("--child-local")); + childCommand.Options.Add(new Option("--child-hidden") { Hidden = true }); + + // note: no parent-local or parent-global-but-hidden options, and no locally hidden options + childCommand.HierarchicalOptions().Select(c => c.Name).Should().Equal(["--child-local", "--parent-global"]); + } +} diff --git a/src/System.CommandLine.StaticCompletions.Tests/PowershellProviderTests.cs b/src/System.CommandLine.StaticCompletions.Tests/PowershellProviderTests.cs new file mode 100644 index 0000000000..48c00bd72a --- /dev/null +++ b/src/System.CommandLine.StaticCompletions.Tests/PowershellProviderTests.cs @@ -0,0 +1,47 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +namespace System.CommandLine.StaticCompletions.Tests; + +using System.CommandLine.StaticCompletions.Shells; +using EmptyFiles; + +public class PowershellProviderTests(ITestOutputHelper log) +{ + private IShellProvider provider = new PowerShellShellProvider(); + + [Fact] + public async Task GenericCompletions() + { + await provider.Verify(new("mycommand"), log); + } + + [Fact] + public async Task SimpleOptionCompletion() + { + await provider.Verify(new("mycommand") { + new Option("--name") + }, log); + } + + [Fact] + public async Task SubcommandAndOptionInTopLevelList() + { + await provider.Verify(new("mycommand") { + new Option("--name"), + new Command("subcommand") + }, log); + } + + [Fact] + public async Task NestedSubcommandCompletion() + { + await provider.Verify(new("mycommand") { + new Command("subcommand") { + new Command("nested") + } + }, log); + } +} diff --git a/src/System.CommandLine.StaticCompletions.Tests/System.CommandLine.StaticCompletions.Tests.csproj b/src/System.CommandLine.StaticCompletions.Tests/System.CommandLine.StaticCompletions.Tests.csproj new file mode 100644 index 0000000000..b8f59c72f5 --- /dev/null +++ b/src/System.CommandLine.StaticCompletions.Tests/System.CommandLine.StaticCompletions.Tests.csproj @@ -0,0 +1,24 @@ + + + $(NetMinimum) + enable + false + true + + + + + + + + + + + + + + + + + + diff --git a/src/System.CommandLine.StaticCompletions.Tests/VerifyConfiguration.cs b/src/System.CommandLine.StaticCompletions.Tests/VerifyConfiguration.cs new file mode 100644 index 0000000000..4eeca67880 --- /dev/null +++ b/src/System.CommandLine.StaticCompletions.Tests/VerifyConfiguration.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +namespace System.CommandLine.StaticCompletions.Tests; + +using System.Runtime.CompilerServices; + +public static class VerifyConfiguration +{ + [ModuleInitializer] + public static void Initialize() + { + VerifyDiffPlex.Initialize(VerifyTests.DiffPlex.OutputType.Compact); + + if (Environment.GetEnvironmentVariable("CI") is string ci && ci.Equals("true", StringComparison.OrdinalIgnoreCase)) + { + Verifier.DerivePathInfo((sourceFile, projectDirectory, type, method) => new( + directory: Path.Combine(Environment.CurrentDirectory, "snapshots"), + typeName: type.Name, + methodName: method.Name) + ); + } + + EmptyFiles.FileExtensions.AddTextExtension("ps1"); + EmptyFiles.FileExtensions.AddTextExtension("nu"); + } +} diff --git a/src/System.CommandLine.StaticCompletions.Tests/VerifyExtensions.cs b/src/System.CommandLine.StaticCompletions.Tests/VerifyExtensions.cs new file mode 100644 index 0000000000..35fd7c4dde --- /dev/null +++ b/src/System.CommandLine.StaticCompletions.Tests/VerifyExtensions.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine.StaticCompletions.Shells; +using System.Runtime.CompilerServices; + +namespace System.CommandLine.StaticCompletions.Tests; + +public static class VerifyExtensions +{ + public static async Task Verify(this IShellProvider provider, Command command, ITestOutputHelper log, [CallerFilePath] string sourceFile = "") + { + var settings = new VerifySettings(); + settings.UseDirectory(Path.Combine("snapshots", provider.ArgumentName)); + var completions = provider.GenerateCompletions(command); + await Verifier.Verify(target: completions, extension: provider.Extension, settings: settings, sourceFile: sourceFile); + } +} diff --git a/src/System.CommandLine.StaticCompletions.Tests/ZshShellProviderTests.cs b/src/System.CommandLine.StaticCompletions.Tests/ZshShellProviderTests.cs new file mode 100644 index 0000000000..96bda10a9b --- /dev/null +++ b/src/System.CommandLine.StaticCompletions.Tests/ZshShellProviderTests.cs @@ -0,0 +1,89 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +namespace System.CommandLine.StaticCompletions.Tests; + +using System.CommandLine.Help; +using System.CommandLine.StaticCompletions.Shells; + +public class ZshShellProviderTests(ITestOutputHelper log) +{ + private IShellProvider _provider = new ZshShellProvider(); + + [Fact] + public async Task GenericCompletions() + { + Command command = new Command("my-app") { + new Option("-c") { + Arity = ArgumentArity.Zero, + Recursive = true + }, + new Option("-v") { + Arity = ArgumentArity.Zero + }, + new HelpOption(), + new Command("test", "Subcommand\nwith a second line") { + new Option("--debug", "-d") + { + Arity = ArgumentArity.Zero + } + }, + new Command("help", "Print this message or the help of the given subcommand(s)") { + new Command("test") + } + }; + await _provider.Verify(command, log); + } + + [Fact] + public async Task DynamicCompletionsGeneration() + { + var staticOption = new Option("--static") + { + IsDynamic = true + }; + staticOption.AcceptOnlyFromAmong("1", "2", "3"); + var dynamicArg = new Argument("--dynamic") + { + IsDynamic = true + }; + dynamicArg.CompletionSources.Add((context) => + { + return [ + new ("4"), + new ("5"), + new ("6") + ]; + }); + Command command = new Command("my-app") + { + staticOption, + dynamicArg + }; + await _provider.Verify(command, log); + } + + [Fact] + public async Task CustomStaticCompletionsGeneration() + { + var staticOption = new Option("--static"); + staticOption.AcceptOnlyFromAmong("1", "2", "3"); + var dynamicArg = new Argument("--dynamic"); + dynamicArg.CompletionSources.Add((context) => + { + return [ + new ("4"), + new ("5"), + new ("6") + ]; + }); + Command command = new Command("my-app") + { + staticOption, + dynamicArg + }; + await _provider.Verify(command, log); + } +} diff --git a/src/System.CommandLine.StaticCompletions.Tests/snapshots/bash/BashShellProviderTests.GenericCompletions.verified.sh b/src/System.CommandLine.StaticCompletions.Tests/snapshots/bash/BashShellProviderTests.GenericCompletions.verified.sh new file mode 100644 index 0000000000..f058e8a948 --- /dev/null +++ b/src/System.CommandLine.StaticCompletions.Tests/snapshots/bash/BashShellProviderTests.GenericCompletions.verified.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +_mycommand() { + + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[COMP_CWORD-1]}" + COMPREPLY=() + + opts="" + + if [[ $COMP_CWORD == "1" ]]; then + COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) + return + fi + + COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) +} + + + +complete -F _mycommand mycommand \ No newline at end of file diff --git a/src/System.CommandLine.StaticCompletions.Tests/snapshots/bash/BashShellProviderTests.NestedSubcommandCompletion.verified.sh b/src/System.CommandLine.StaticCompletions.Tests/snapshots/bash/BashShellProviderTests.NestedSubcommandCompletion.verified.sh new file mode 100644 index 0000000000..944892b500 --- /dev/null +++ b/src/System.CommandLine.StaticCompletions.Tests/snapshots/bash/BashShellProviderTests.NestedSubcommandCompletion.verified.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +_mycommand() { + + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[COMP_CWORD-1]}" + COMPREPLY=() + + opts="subcommand" + + if [[ $COMP_CWORD == "1" ]]; then + COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) + return + fi + + case ${COMP_WORDS[1]} in + (subcommand) + _mycommand_subcommand 2 + return + ;; + + esac + + COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) +} + +_mycommand_subcommand() { + + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[COMP_CWORD-1]}" + COMPREPLY=() + + opts="nested" + + if [[ $COMP_CWORD == "$1" ]]; then + COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) + return + fi + + case ${COMP_WORDS[$1]} in + (nested) + _mycommand_subcommand_nested $(($1+1)) + return + ;; + + esac + + COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) +} + +_mycommand_subcommand_nested() { + + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[COMP_CWORD-1]}" + COMPREPLY=() + + opts="" + + if [[ $COMP_CWORD == "$1" ]]; then + COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) + return + fi + + COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) +} + + + +complete -F _mycommand mycommand \ No newline at end of file diff --git a/src/System.CommandLine.StaticCompletions.Tests/snapshots/bash/BashShellProviderTests.SimpleOptionCompletion.verified.sh b/src/System.CommandLine.StaticCompletions.Tests/snapshots/bash/BashShellProviderTests.SimpleOptionCompletion.verified.sh new file mode 100644 index 0000000000..d8225ff8d3 --- /dev/null +++ b/src/System.CommandLine.StaticCompletions.Tests/snapshots/bash/BashShellProviderTests.SimpleOptionCompletion.verified.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +_mycommand() { + + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[COMP_CWORD-1]}" + COMPREPLY=() + + opts="--name" + + if [[ $COMP_CWORD == "1" ]]; then + COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) + return + fi + + COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) +} + + + +complete -F _mycommand mycommand \ No newline at end of file diff --git a/src/System.CommandLine.StaticCompletions.Tests/snapshots/bash/BashShellProviderTests.SubcommandAndOptionInTopLevelList.verified.sh b/src/System.CommandLine.StaticCompletions.Tests/snapshots/bash/BashShellProviderTests.SubcommandAndOptionInTopLevelList.verified.sh new file mode 100644 index 0000000000..6816b8eeeb --- /dev/null +++ b/src/System.CommandLine.StaticCompletions.Tests/snapshots/bash/BashShellProviderTests.SubcommandAndOptionInTopLevelList.verified.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +_mycommand() { + + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[COMP_CWORD-1]}" + COMPREPLY=() + + opts="subcommand --name" + + if [[ $COMP_CWORD == "1" ]]; then + COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) + return + fi + + case ${COMP_WORDS[1]} in + (subcommand) + _mycommand_subcommand 2 + return + ;; + + esac + + COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) +} + +_mycommand_subcommand() { + + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[COMP_CWORD-1]}" + COMPREPLY=() + + opts="" + + if [[ $COMP_CWORD == "$1" ]]; then + COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) + return + fi + + COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) +} + + + +complete -F _mycommand mycommand \ No newline at end of file diff --git a/src/System.CommandLine.StaticCompletions.Tests/snapshots/pwsh/PowershellProviderTests.GenericCompletions.verified.ps1 b/src/System.CommandLine.StaticCompletions.Tests/snapshots/pwsh/PowershellProviderTests.GenericCompletions.verified.ps1 new file mode 100644 index 0000000000..b1431d93c5 --- /dev/null +++ b/src/System.CommandLine.StaticCompletions.Tests/snapshots/pwsh/PowershellProviderTests.GenericCompletions.verified.ps1 @@ -0,0 +1,31 @@ +using namespace System.Management.Automation +using namespace System.Management.Automation.Language + +Register-ArgumentCompleter -Native -CommandName 'mycommand' -ScriptBlock { + param($wordToComplete, $commandAst, $cursorPosition) + + $commandElements = $commandAst.CommandElements + $command = @( + 'mycommand' + for ($i = 1; $i -lt $commandElements.Count; $i++) { + $element = $commandElements[$i] + if ($element -isnot [StringConstantExpressionAst] -or + $element.StringConstantType -ne [StringConstantType]::BareWord -or + $element.Value.StartsWith('-') -or + $element.Value -eq $wordToComplete) { + break + } + $element.Value + }) -join ';' + + $completions = @() + switch ($command) { + 'mycommand' { + $staticCompletions = @( + ) + $completions += $staticCompletions + break + } + } + $completions | Where-Object -FilterScript { $_.CompletionText -like "$wordToComplete*" } | Sort-Object -Property ListItemText +} diff --git a/src/System.CommandLine.StaticCompletions.Tests/snapshots/pwsh/PowershellProviderTests.NestedSubcommandCompletion.verified.ps1 b/src/System.CommandLine.StaticCompletions.Tests/snapshots/pwsh/PowershellProviderTests.NestedSubcommandCompletion.verified.ps1 new file mode 100644 index 0000000000..94e7ce9e8e --- /dev/null +++ b/src/System.CommandLine.StaticCompletions.Tests/snapshots/pwsh/PowershellProviderTests.NestedSubcommandCompletion.verified.ps1 @@ -0,0 +1,45 @@ +using namespace System.Management.Automation +using namespace System.Management.Automation.Language + +Register-ArgumentCompleter -Native -CommandName 'mycommand' -ScriptBlock { + param($wordToComplete, $commandAst, $cursorPosition) + + $commandElements = $commandAst.CommandElements + $command = @( + 'mycommand' + for ($i = 1; $i -lt $commandElements.Count; $i++) { + $element = $commandElements[$i] + if ($element -isnot [StringConstantExpressionAst] -or + $element.StringConstantType -ne [StringConstantType]::BareWord -or + $element.Value.StartsWith('-') -or + $element.Value -eq $wordToComplete) { + break + } + $element.Value + }) -join ';' + + $completions = @() + switch ($command) { + 'mycommand' { + $staticCompletions = @( + [CompletionResult]::new('subcommand', 'subcommand', [CompletionResultType]::ParameterValue, "subcommand") + ) + $completions += $staticCompletions + break + } + 'mycommand;subcommand' { + $staticCompletions = @( + [CompletionResult]::new('nested', 'nested', [CompletionResultType]::ParameterValue, "nested") + ) + $completions += $staticCompletions + break + } + 'mycommand;subcommand;nested' { + $staticCompletions = @( + ) + $completions += $staticCompletions + break + } + } + $completions | Where-Object -FilterScript { $_.CompletionText -like "$wordToComplete*" } | Sort-Object -Property ListItemText +} diff --git a/src/System.CommandLine.StaticCompletions.Tests/snapshots/pwsh/PowershellProviderTests.SimpleOptionCompletion.verified.ps1 b/src/System.CommandLine.StaticCompletions.Tests/snapshots/pwsh/PowershellProviderTests.SimpleOptionCompletion.verified.ps1 new file mode 100644 index 0000000000..fa6dc4ad99 --- /dev/null +++ b/src/System.CommandLine.StaticCompletions.Tests/snapshots/pwsh/PowershellProviderTests.SimpleOptionCompletion.verified.ps1 @@ -0,0 +1,32 @@ +using namespace System.Management.Automation +using namespace System.Management.Automation.Language + +Register-ArgumentCompleter -Native -CommandName 'mycommand' -ScriptBlock { + param($wordToComplete, $commandAst, $cursorPosition) + + $commandElements = $commandAst.CommandElements + $command = @( + 'mycommand' + for ($i = 1; $i -lt $commandElements.Count; $i++) { + $element = $commandElements[$i] + if ($element -isnot [StringConstantExpressionAst] -or + $element.StringConstantType -ne [StringConstantType]::BareWord -or + $element.Value.StartsWith('-') -or + $element.Value -eq $wordToComplete) { + break + } + $element.Value + }) -join ';' + + $completions = @() + switch ($command) { + 'mycommand' { + $staticCompletions = @( + [CompletionResult]::new('--name', '--name', [CompletionResultType]::ParameterName, "--name") + ) + $completions += $staticCompletions + break + } + } + $completions | Where-Object -FilterScript { $_.CompletionText -like "$wordToComplete*" } | Sort-Object -Property ListItemText +} diff --git a/src/System.CommandLine.StaticCompletions.Tests/snapshots/pwsh/PowershellProviderTests.SubcommandAndOptionInTopLevelList.verified.ps1 b/src/System.CommandLine.StaticCompletions.Tests/snapshots/pwsh/PowershellProviderTests.SubcommandAndOptionInTopLevelList.verified.ps1 new file mode 100644 index 0000000000..757523ec6c --- /dev/null +++ b/src/System.CommandLine.StaticCompletions.Tests/snapshots/pwsh/PowershellProviderTests.SubcommandAndOptionInTopLevelList.verified.ps1 @@ -0,0 +1,39 @@ +using namespace System.Management.Automation +using namespace System.Management.Automation.Language + +Register-ArgumentCompleter -Native -CommandName 'mycommand' -ScriptBlock { + param($wordToComplete, $commandAst, $cursorPosition) + + $commandElements = $commandAst.CommandElements + $command = @( + 'mycommand' + for ($i = 1; $i -lt $commandElements.Count; $i++) { + $element = $commandElements[$i] + if ($element -isnot [StringConstantExpressionAst] -or + $element.StringConstantType -ne [StringConstantType]::BareWord -or + $element.Value.StartsWith('-') -or + $element.Value -eq $wordToComplete) { + break + } + $element.Value + }) -join ';' + + $completions = @() + switch ($command) { + 'mycommand' { + $staticCompletions = @( + [CompletionResult]::new('--name', '--name', [CompletionResultType]::ParameterName, "--name") + [CompletionResult]::new('subcommand', 'subcommand', [CompletionResultType]::ParameterValue, "subcommand") + ) + $completions += $staticCompletions + break + } + 'mycommand;subcommand' { + $staticCompletions = @( + ) + $completions += $staticCompletions + break + } + } + $completions | Where-Object -FilterScript { $_.CompletionText -like "$wordToComplete*" } | Sort-Object -Property ListItemText +} diff --git a/src/System.CommandLine.StaticCompletions.Tests/snapshots/zsh/ZshShellProviderTests.CustomStaticCompletionsGeneration.verified.zsh b/src/System.CommandLine.StaticCompletions.Tests/snapshots/zsh/ZshShellProviderTests.CustomStaticCompletionsGeneration.verified.zsh new file mode 100644 index 0000000000..13b8d0d007 --- /dev/null +++ b/src/System.CommandLine.StaticCompletions.Tests/snapshots/zsh/ZshShellProviderTests.CustomStaticCompletionsGeneration.verified.zsh @@ -0,0 +1,34 @@ +#compdef my-app + +autoload -U is-at-least + +_my-app() { + typeset -A opt_args + typeset -a _arguments_options + local ret=1 + + if is-at-least 5.2; then + _arguments_options=(-s -S -C) + else + _arguments_options=(-s -C) + fi + + local context curcontext="$curcontext" state state_descr line + _arguments "${_arguments_options[@]}" : \ + '--static=[]: :((1\:"1" 2\:"2" 3\:"3" ))' \ + ':--dynamic:((4\:"4" 5\:"5" 6\:"6" ))' \ + && ret=0 + local original_args="my-app ${line[@]}" +} + +(( $+functions[_my-app_commands] )) || +_my-app_commands() { + local commands; commands=() + _describe -t commands 'my-app commands' commands "$@" +} + +if [ "$funcstack[1]" = "_my-app" ]; then + _my-app "$@" +else + compdef _my-app my-app +fi diff --git a/src/System.CommandLine.StaticCompletions.Tests/snapshots/zsh/ZshShellProviderTests.DynamicCompletionsGeneration.verified.zsh b/src/System.CommandLine.StaticCompletions.Tests/snapshots/zsh/ZshShellProviderTests.DynamicCompletionsGeneration.verified.zsh new file mode 100644 index 0000000000..9312e626ce --- /dev/null +++ b/src/System.CommandLine.StaticCompletions.Tests/snapshots/zsh/ZshShellProviderTests.DynamicCompletionsGeneration.verified.zsh @@ -0,0 +1,44 @@ +#compdef my-app + +autoload -U is-at-least + +_my-app() { + typeset -A opt_args + typeset -a _arguments_options + local ret=1 + + if is-at-least 5.2; then + _arguments_options=(-s -S -C) + else + _arguments_options=(-s -C) + fi + + local context curcontext="$curcontext" state state_descr line + _arguments "${_arguments_options[@]}" : \ + '--static=[]: :->dotnet_dynamic_complete' \ + ':--dynamic:->dotnet_dynamic_complete' \ + && ret=0 + case $state in + (dotnet_dynamic_complete) + local completions=() + local result=$(dotnet complete -- "${original_args[@]}") + for line in ${(f)result}; do + completions+=(${(q)line}) + done + _describe 'completions' $completions && ret=0 + ;; + esac + local original_args="my-app ${line[@]}" +} + +(( $+functions[_my-app_commands] )) || +_my-app_commands() { + local commands; commands=() + _describe -t commands 'my-app commands' commands "$@" +} + +if [ "$funcstack[1]" = "_my-app" ]; then + _my-app "$@" +else + compdef _my-app my-app +fi diff --git a/src/System.CommandLine.StaticCompletions.Tests/snapshots/zsh/ZshShellProviderTests.GenericCompletions.verified.zsh b/src/System.CommandLine.StaticCompletions.Tests/snapshots/zsh/ZshShellProviderTests.GenericCompletions.verified.zsh new file mode 100644 index 0000000000..01441fed29 --- /dev/null +++ b/src/System.CommandLine.StaticCompletions.Tests/snapshots/zsh/ZshShellProviderTests.GenericCompletions.verified.zsh @@ -0,0 +1,104 @@ +#compdef my-app + +autoload -U is-at-least + +_my-app() { + typeset -A opt_args + typeset -a _arguments_options + local ret=1 + + if is-at-least 5.2; then + _arguments_options=(-s -S -C) + else + _arguments_options=(-s -C) + fi + + local context curcontext="$curcontext" state state_descr line + _arguments "${_arguments_options[@]}" : \ + '-c[]' \ + '-v[]' \ + '--help[Show help and usage information]' \ + '-h[Show help and usage information]' \ + ":: :_my-app_commands" \ + "*::: :->my-app" \ + && ret=0 + local original_args="my-app ${line[@]}" + case $state in + (my-app) + words=($line[1] "${words[@]}") + (( CURRENT += 1 )) + curcontext="${curcontext%:*:*}:my-app-command-$line[1]:" + case $line[1] in + (test) + _arguments "${_arguments_options[@]}" : \ + '--debug[]' \ + '-d[]' \ + '-c[]' \ + '--help[Show help and usage information]' \ + '-h[Show help and usage information]' \ + && ret=0 + ;; + (help) + _arguments "${_arguments_options[@]}" : \ + '-c[]' \ + '--help[Show help and usage information]' \ + '-h[Show help and usage information]' \ + ":: :_my-app__help_commands" \ + "*::: :->help" \ + && ret=0 + case $state in + (help) + words=($line[1] "${words[@]}") + (( CURRENT += 1 )) + curcontext="${curcontext%:*:*}:my-app-help-command-$line[1]:" + case $line[1] in + (test) + _arguments "${_arguments_options[@]}" : \ + '-c[]' \ + '--help[Show help and usage information]' \ + '-h[Show help and usage information]' \ + && ret=0 + ;; + esac + ;; + esac + ;; + esac + ;; + esac +} + +(( $+functions[_my-app_commands] )) || +_my-app_commands() { + local commands; commands=( + 'test:Subcommand with a second line' \ + 'help:Print this message or the help of the given subcommand(s)' \ + ) + _describe -t commands 'my-app commands' commands "$@" +} + +(( $+functions[_my-app__test_commands] )) || +_my-app__test_commands() { + local commands; commands=() + _describe -t commands 'my-app test commands' commands "$@" +} + +(( $+functions[_my-app__help_commands] )) || +_my-app__help_commands() { + local commands; commands=( + 'test:' \ + ) + _describe -t commands 'my-app help commands' commands "$@" +} + +(( $+functions[_my-app__help__test_commands] )) || +_my-app__help__test_commands() { + local commands; commands=() + _describe -t commands 'my-app help test commands' commands "$@" +} + +if [ "$funcstack[1]" = "_my-app" ]; then + _my-app "$@" +else + compdef _my-app my-app +fi diff --git a/src/System.CommandLine.StaticCompletions/CompletionsCommandDefinition.cs b/src/System.CommandLine.StaticCompletions/CompletionsCommandDefinition.cs new file mode 100644 index 0000000000..2969e302e2 --- /dev/null +++ b/src/System.CommandLine.StaticCompletions/CompletionsCommandDefinition.cs @@ -0,0 +1,38 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine.StaticCompletions.Resources; + +namespace System.CommandLine.StaticCompletions; + +public sealed class CompletionsCommandDefinition : Command +{ + public readonly Argument ShellArgument = new("shell") + { + Description = Strings.CompletionsCommand_ShellArgument_Description, + Arity = ArgumentArity.ZeroOrOne, + DefaultValueFactory = _ => ShellNames.GetShellNameFromEnvironment() + }; + + public readonly CompletionsGenerateScriptCommandDefinition GenerateScriptCommand; + + public CompletionsCommandDefinition() + : base("completions", Strings.CompletionsCommand_Description) + { + Subcommands.Add(GenerateScriptCommand = new(this)); + + Validators.Add(argumentResult => + { + if (argumentResult.Tokens.Count == 0) + { + return; + } + + var singleToken = argumentResult.Tokens[0]; + if (!ShellNames.All.Contains(singleToken.Value)) + { + argumentResult.AddError(string.Format(Strings.ShellDiscovery_ShellNotSupported, singleToken.Value, string.Join(", ", ShellNames.All))); + } + }); + } +} diff --git a/src/System.CommandLine.StaticCompletions/CompletionsCommandParser.cs b/src/System.CommandLine.StaticCompletions/CompletionsCommandParser.cs new file mode 100644 index 0000000000..74d427f303 --- /dev/null +++ b/src/System.CommandLine.StaticCompletions/CompletionsCommandParser.cs @@ -0,0 +1,44 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine.Completions; +using System.CommandLine.StaticCompletions.Shells; +using System.Diagnostics; + +namespace System.CommandLine.StaticCompletions; + +public sealed class CompletionsCommandParser +{ + public static readonly IReadOnlyDictionary ShellProviders; + + static CompletionsCommandParser() + { + var providers = new IShellProvider[] + { + new BashShellProvider(), + new PowerShellShellProvider(), + new FishShellProvider(), + new ZshShellProvider(), + new NushellShellProvider() + }; + + Debug.Assert(providers.Select(provider => provider.ArgumentName).SequenceEqual(ShellNames.All)); + + ShellProviders = providers.ToDictionary(s => s.ArgumentName, StringComparer.OrdinalIgnoreCase); + } + + public static void ConfigureCommand(CompletionsCommandDefinition command) + { + command.ShellArgument.CompletionSources.Add(context => + ShellNames.All.Select(shellName => new CompletionItem(shellName, documentation: ShellProviders[shellName].HelpDescription))); + + command.GenerateScriptCommand.SetAction(args => + { + var shellName = args.GetValue(command.GenerateScriptCommand.ShellArgument) ?? throw new InvalidOperationException(); + var shell = ShellProviders[shellName]; + + var script = shell.GenerateCompletions(args.RootCommandResult.Command); + args.InvocationConfiguration.Output.Write(script); + }); + } +} diff --git a/src/System.CommandLine.StaticCompletions/CompletionsGenerateScriptCommandDefinition.cs b/src/System.CommandLine.StaticCompletions/CompletionsGenerateScriptCommandDefinition.cs new file mode 100644 index 0000000000..48f73a9fbe --- /dev/null +++ b/src/System.CommandLine.StaticCompletions/CompletionsGenerateScriptCommandDefinition.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine.StaticCompletions.Resources; + +namespace System.CommandLine.StaticCompletions; + +public sealed class CompletionsGenerateScriptCommandDefinition : Command +{ + public readonly Argument ShellArgument; + + public CompletionsGenerateScriptCommandDefinition(CompletionsCommandDefinition parent) + : base("script", Strings.GenerateCommand_Description) + { + Arguments.Add(ShellArgument = parent.ShellArgument); + } +} + diff --git a/src/System.CommandLine.StaticCompletions/DynamicSymbolExtensions.cs b/src/System.CommandLine.StaticCompletions/DynamicSymbolExtensions.cs new file mode 100644 index 0000000000..cd37cc1375 --- /dev/null +++ b/src/System.CommandLine.StaticCompletions/DynamicSymbolExtensions.cs @@ -0,0 +1,66 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.CommandLine.StaticCompletions; + +/// +/// Extensions for marking options or arguments require dynamic completions. Such symbols get special handling +/// in the static completion generation logic. +/// +public static class DynamicSymbolExtensions +{ + private static readonly object s_guard = new(); + + /// + /// The state that is used to track which symbols are dynamic. + /// + private static readonly Dictionary s_dynamicSymbols = []; + + extension(Option option) + { + /// + /// Indicates whether this option requires a dynamic call into the dotnet process to compute completions. + /// + public bool IsDynamic + { + get + { + lock (s_guard) + { + return s_dynamicSymbols.GetValueOrDefault(option, false); + } + } + set + { + lock (s_guard) + { + s_dynamicSymbols[option] = value; + } + } + } + } + + extension(Argument argument) + { + /// + /// Indicates whether this argument requires a dynamic call into the dotnet process to compute completions. + /// + public bool IsDynamic + { + get + { + lock (s_guard) + { + return s_dynamicSymbols.GetValueOrDefault(argument, false); + } + } + set + { + lock (s_guard) + { + s_dynamicSymbols[argument] = value; + } + } + } + } +} diff --git a/src/System.CommandLine.StaticCompletions/HelpGenerationExtensions.cs b/src/System.CommandLine.StaticCompletions/HelpGenerationExtensions.cs new file mode 100644 index 0000000000..193234dc60 --- /dev/null +++ b/src/System.CommandLine.StaticCompletions/HelpGenerationExtensions.cs @@ -0,0 +1,115 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.CommandLine.StaticCompletions; + +using System.CommandLine; + +public static class HelpExtensions +{ + /// + /// Create a unique shell function name for a command - these names should be + /// * distinct from the 'root' command's name (i.e. we should not generate the function name 'dotnet' for the binary 'dotnet') + /// * distinct based on 'path' to get to this function (hence the parentCommandNames) + /// + /// + /// The chain of commands to get to this command + /// + public static string FunctionName(this Command command, string[]? parentCommandNames = null) => parentCommandNames switch + { + null => "_" + command.Name, + [] => "_" + command.Name, + var names => "_" + string.Join('_', names) + "_" + command.Name + }; + + /// + /// Sanitizes a function name to be safe for bash + /// + /// + /// + public static string MakeSafeFunctionName(this string functionName) => functionName.Replace('-', '_'); + + /// + /// Get all names for an option, including the primary name and all aliases + /// + /// + /// + public static string[] Names(this Option option) + { + var (primary, aliases) = PrimaryNameAndAliases(option); + return aliases is null ? [primary] : [primary, .. aliases]; + } + + public static (string primary, string[]? aliases) PrimaryNameAndAliases(this Option option) + { + if (option.Aliases.Count == 0) + { + return (option.Name, null); + } + else if (option is System.CommandLine.Help.HelpOption) // some of the help aliases are truly horrible + { + return ("--help", ["-h"]); + } + else + { + return (option.Name, [.. option.Aliases]); + } + } + + /// + /// Get all names for a command, including the primary name and all aliases + /// + /// + /// + public static string[] Names(this Command command) + { + if (command.Aliases.Count == 0) + { + return [command.Name]; + } + else + { + return [command.Name, .. command.Aliases]; + } + } + + public static IEnumerable