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..67e39ea5dd
--- /dev/null
+++ b/src/System.CommandLine.StaticCompletions.Tests/BashShellProviderTests.cs
@@ -0,0 +1,53 @@
+// 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);
+ }
+
+ [Fact]
+ public async Task DynamicCompletionsGeneration()
+ {
+ var dynamicOption = new Option("--name") { IsDynamic = true };
+ var dynamicArg = new Argument("target") { IsDynamic = true };
+ await provider.Verify(new("mycommand") { dynamicOption, dynamicArg }, log);
+ }
+}
diff --git a/src/System.CommandLine.StaticCompletions.Tests/CompletionsCommandParserTests.cs b/src/System.CommandLine.StaticCompletions.Tests/CompletionsCommandParserTests.cs
new file mode 100644
index 0000000000..9f56e9c4c0
--- /dev/null
+++ b/src/System.CommandLine.StaticCompletions.Tests/CompletionsCommandParserTests.cs
@@ -0,0 +1,76 @@
+// 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.Completions;
+
+public class CompletionsCommandParserTests
+{
+ private static RootCommand CreateRootCommandWithCompletions(bool withDynamicSymbol)
+ {
+ var rootCommand = new RootCommand("my test app");
+ if (withDynamicSymbol)
+ {
+ rootCommand.Options.Add(new Option("--name") { IsDynamic = true });
+ }
+
+ var completionsCommand = new CompletionsCommandDefinition();
+ CompletionsCommandParser.ConfigureCommand(completionsCommand);
+ rootCommand.Subcommands.Add(completionsCommand);
+ return rootCommand;
+ }
+
+ private static (int exitCode, string output, string error) GenerateScript(RootCommand rootCommand)
+ {
+ var output = new StringWriter();
+ var error = new StringWriter();
+ var exitCode = rootCommand.Parse(["completions", "script", "bash"]).Invoke(new InvocationConfiguration
+ {
+ Output = output,
+ Error = error
+ });
+ return (exitCode, output.ToString(), error.ToString());
+ }
+
+ [Fact]
+ public void NoWarningWhenSuggestDirectiveIsEnabled()
+ {
+ var rootCommand = CreateRootCommandWithCompletions(withDynamicSymbol: true);
+
+ var (exitCode, output, error) = GenerateScript(rootCommand);
+
+ exitCode.Should().Be(0);
+ output.Should().NotBeEmpty();
+ error.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void WarnsWhenSuggestDirectiveIsDisabledAndDynamicSymbolsArePresent()
+ {
+ var rootCommand = CreateRootCommandWithCompletions(withDynamicSymbol: true);
+ rootCommand.Directives.Remove(rootCommand.Directives.OfType().Single());
+
+ var (exitCode, output, error) = GenerateScript(rootCommand);
+
+ // the script is still generated, but the user is warned that dynamic completions won't resolve
+ exitCode.Should().Be(0);
+ output.Should().NotBeEmpty();
+ error.Should().Contain("[suggest]");
+ }
+
+ [Fact]
+ public void NoWarningWhenSuggestDirectiveIsDisabledButNoDynamicSymbolsArePresent()
+ {
+ var rootCommand = CreateRootCommandWithCompletions(withDynamicSymbol: false);
+ rootCommand.Directives.Remove(rootCommand.Directives.OfType().Single());
+
+ var (exitCode, output, error) = GenerateScript(rootCommand);
+
+ exitCode.Should().Be(0);
+ output.Should().NotBeEmpty();
+ error.Should().BeEmpty();
+ }
+}
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/FishShellProviderTests.cs b/src/System.CommandLine.StaticCompletions.Tests/FishShellProviderTests.cs
new file mode 100644
index 0000000000..464b796034
--- /dev/null
+++ b/src/System.CommandLine.StaticCompletions.Tests/FishShellProviderTests.cs
@@ -0,0 +1,118 @@
+// 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 FishShellProviderTests(ITestOutputHelper log)
+{
+ private IShellProvider provider = new FishShellProvider();
+
+ [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);
+ }
+
+ [Fact]
+ public async Task DynamicCompletionsGeneration()
+ {
+ var dynamicOption = new Option("--name") { IsDynamic = true };
+ var dynamicArg = new Argument("target") { IsDynamic = true };
+ await provider.Verify(new("mycommand") { dynamicOption, dynamicArg }, log);
+ }
+
+ [Fact]
+ public async Task StaticOptionValues()
+ {
+ var staticOption = new Option("--verbosity");
+ staticOption.AcceptOnlyFromAmong("quiet", "minimal", "normal", "detailed", "diagnostic");
+ await provider.Verify(new("mycommand") {
+ staticOption
+ }, log);
+ }
+
+ [Fact]
+ public async Task BoundedMultiValueOption()
+ {
+ var multiOption = new Option("--sources")
+ {
+ Arity = new ArgumentArity(1, 3)
+ };
+ multiOption.AcceptOnlyFromAmong("src1", "src2", "src3", "src4");
+ await provider.Verify(new("mycommand") {
+ multiOption,
+ new Command("subcommand")
+ }, log);
+ }
+
+ [Fact]
+ public async Task UnboundedMultiValueOption()
+ {
+ var unboundedOption = new Option("--items")
+ {
+ Arity = ArgumentArity.ZeroOrMore
+ };
+ unboundedOption.AcceptOnlyFromAmong("a", "b", "c");
+ await provider.Verify(new("mycommand") {
+ unboundedOption,
+ new Option("--name"),
+ new Command("subcommand")
+ }, log);
+ }
+
+ [Fact]
+ public async Task MixedArityOptions()
+ {
+ var singleOption = new Option("--config");
+ singleOption.AcceptOnlyFromAmong("debug", "release");
+
+ var multiOption = new Option("--framework", "-f")
+ {
+ Arity = new ArgumentArity(1, 3)
+ };
+ multiOption.AcceptOnlyFromAmong("net8.0", "net9.0", "net10.0");
+
+ var unboundedOption = new Option("--sources")
+ {
+ Arity = ArgumentArity.OneOrMore
+ };
+
+ await provider.Verify(new("mycommand") {
+ singleOption,
+ multiOption,
+ unboundedOption,
+ new Command("build")
+ }, log);
+ }
+}
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/NushellShellProviderTests.cs b/src/System.CommandLine.StaticCompletions.Tests/NushellShellProviderTests.cs
new file mode 100644
index 0000000000..87b141350b
--- /dev/null
+++ b/src/System.CommandLine.StaticCompletions.Tests/NushellShellProviderTests.cs
@@ -0,0 +1,19 @@
+// 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 NushellShellProviderTests(ITestOutputHelper log)
+{
+ private IShellProvider provider = new NushellShellProvider();
+
+ [Fact]
+ public async Task GenericCompletions()
+ {
+ await provider.Verify(new("mycommand"), log);
+ }
+}
diff --git a/src/System.CommandLine.StaticCompletions.Tests/PowershellProviderTests.cs b/src/System.CommandLine.StaticCompletions.Tests/PowershellProviderTests.cs
new file mode 100644
index 0000000000..2a6f2f1de8
--- /dev/null
+++ b/src/System.CommandLine.StaticCompletions.Tests/PowershellProviderTests.cs
@@ -0,0 +1,54 @@
+// 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);
+ }
+
+ [Fact]
+ public async Task DynamicCompletionsGeneration()
+ {
+ var dynamicArg = new Argument("target") { IsDynamic = true };
+ await provider.Verify(new("mycommand") { dynamicArg }, 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..71dd56b1d2
--- /dev/null
+++ b/src/System.CommandLine.StaticCompletions.Tests/VerifyConfiguration.cs
@@ -0,0 +1,30 @@
+// 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");
+ EmptyFiles.FileExtensions.AddTextExtension("fish");
+ }
+}
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..7a87859715
--- /dev/null
+++ b/src/System.CommandLine.StaticCompletions.Tests/ZshShellProviderTests.cs
@@ -0,0 +1,90 @@
+// 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.DynamicCompletionsGeneration.verified.sh b/src/System.CommandLine.StaticCompletions.Tests/snapshots/bash/BashShellProviderTests.DynamicCompletionsGeneration.verified.sh
new file mode 100644
index 0000000000..2fed5f5868
--- /dev/null
+++ b/src/System.CommandLine.StaticCompletions.Tests/snapshots/bash/BashShellProviderTests.DynamicCompletionsGeneration.verified.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+_mycommand() {
+
+ cur="${COMP_WORDS[COMP_CWORD]}"
+ prev="${COMP_WORDS[COMP_CWORD-1]}"
+ COMPREPLY=()
+
+ opts="--name"
+ opts="$opts $(${COMP_WORDS[0]} "[suggest:${COMP_POINT}]" "${COMP_LINE}" 2>/dev/null | tr '\n' ' ')"
+
+ if [[ $COMP_CWORD == "1" ]]; then
+ COMPREPLY=( $(compgen -W "$opts" -- "$cur") )
+ return
+ fi
+
+ case $prev in
+ --name)
+ COMPREPLY=( $(compgen -W "(${COMP_WORDS[0]} "[suggest:${COMP_POINT}]" "${COMP_LINE}" 2>/dev/null | tr '\n' ' ')" -- "$cur") )
+ return
+ ;;
+ esac
+
+ 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.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/fish/FishShellProviderTests.BoundedMultiValueOption.verified.fish b/src/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.BoundedMultiValueOption.verified.fish
new file mode 100644
index 0000000000..b33eebee90
--- /dev/null
+++ b/src/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.BoundedMultiValueOption.verified.fish
@@ -0,0 +1,67 @@
+# fish completions for mycommand
+
+function _mycommand
+ set -l tokens (commandline -opc)
+
+ set -l state 0
+ set -l i 2
+ while test $i -le (count $tokens)
+ set -l word $tokens[$i]
+ switch $state
+ case 0
+ switch $word
+ case subcommand
+ set state 1
+ case --sources
+ set -l skip_max 3
+ set -l skipped 0
+ while test $skipped -lt $skip_max -a (math $i + 1) -le (count $tokens)
+ set -l next $tokens[(math $i + 1)]
+ if string match -q -- '-*' $next
+ break
+ end
+ set i (math $i + 1)
+ set skipped (math $skipped + 1)
+ end
+ end
+ end
+ set i (math $i + 1)
+ end
+
+ set -l opt_index 0
+ if test (count $tokens) -ge 2
+ for j in (seq (count $tokens) -1 2)
+ if string match -q -- '-*' $tokens[$j]
+ set opt_index $j
+ break
+ end
+ end
+ end
+
+ if test $opt_index -gt 0
+ set -l opt $tokens[$opt_index]
+ set -l values_after (math (count $tokens) - $opt_index)
+ switch $state
+ case 0
+ switch $opt
+ case --sources
+ if test $values_after -lt 3
+ printf '%s\n' 'src1'
+ printf '%s\n' 'src2'
+ printf '%s\n' 'src3'
+ printf '%s\n' 'src4'
+ return
+ end
+ end
+ end
+ end
+
+ switch $state
+ case 0
+ printf '%s\n' 'subcommand'
+ printf '%s\n' '--sources'
+ case 1
+ end
+end
+
+complete -c mycommand -f -a '(_mycommand)'
diff --git a/src/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.DynamicCompletionsGeneration.verified.fish b/src/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.DynamicCompletionsGeneration.verified.fish
new file mode 100644
index 0000000000..abdc1e9431
--- /dev/null
+++ b/src/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.DynamicCompletionsGeneration.verified.fish
@@ -0,0 +1,52 @@
+# fish completions for mycommand
+
+function _mycommand
+ set -l tokens (commandline -opc)
+
+ set -l state 0
+ set -l i 2
+ while test $i -le (count $tokens)
+ set -l word $tokens[$i]
+ switch $state
+ case 0
+ switch $word
+ case --name
+ set i (math $i + 1)
+ end
+ end
+ set i (math $i + 1)
+ end
+
+ set -l opt_index 0
+ if test (count $tokens) -ge 2
+ for j in (seq (count $tokens) -1 2)
+ if string match -q -- '-*' $tokens[$j]
+ set opt_index $j
+ break
+ end
+ end
+ end
+
+ if test $opt_index -gt 0
+ set -l opt $tokens[$opt_index]
+ set -l values_after (math (count $tokens) - $opt_index)
+ switch $state
+ case 0
+ switch $opt
+ case --name
+ if test $values_after -lt 1
+ command $tokens[1] "[suggest:"(commandline -C)"]" (commandline -cp) 2>/dev/null
+ return
+ end
+ end
+ end
+ end
+
+ switch $state
+ case 0
+ printf '%s\n' '--name'
+ command $tokens[1] "[suggest:"(commandline -C)"]" (commandline -cp) 2>/dev/null
+ end
+end
+
+complete -c mycommand -f -a '(_mycommand)'
diff --git a/src/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.GenericCompletions.verified.fish b/src/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.GenericCompletions.verified.fish
new file mode 100644
index 0000000000..c86386e997
--- /dev/null
+++ b/src/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.GenericCompletions.verified.fish
@@ -0,0 +1,20 @@
+# fish completions for mycommand
+
+function _mycommand
+ set -l tokens (commandline -opc)
+
+ set -l state 0
+ set -l i 2
+ while test $i -le (count $tokens)
+ set -l word $tokens[$i]
+ switch $state
+ end
+ set i (math $i + 1)
+ end
+
+ switch $state
+ case 0
+ end
+end
+
+complete -c mycommand -f -a '(_mycommand)'
diff --git a/src/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.MixedArityOptions.verified.fish b/src/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.MixedArityOptions.verified.fish
new file mode 100644
index 0000000000..a5728a17ce
--- /dev/null
+++ b/src/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.MixedArityOptions.verified.fish
@@ -0,0 +1,87 @@
+# fish completions for mycommand
+
+function _mycommand
+ set -l tokens (commandline -opc)
+
+ set -l state 0
+ set -l i 2
+ while test $i -le (count $tokens)
+ set -l word $tokens[$i]
+ switch $state
+ case 0
+ switch $word
+ case build
+ set state 1
+ case --config
+ set i (math $i + 1)
+ case --framework -f
+ set -l skip_max 3
+ set -l skipped 0
+ while test $skipped -lt $skip_max -a (math $i + 1) -le (count $tokens)
+ set -l next $tokens[(math $i + 1)]
+ if string match -q -- '-*' $next
+ break
+ end
+ set i (math $i + 1)
+ set skipped (math $skipped + 1)
+ end
+ case --sources
+ while test (math $i + 1) -le (count $tokens)
+ set -l next $tokens[(math $i + 1)]
+ if string match -q -- '-*' $next
+ break
+ end
+ set i (math $i + 1)
+ end
+ end
+ end
+ set i (math $i + 1)
+ end
+
+ set -l opt_index 0
+ if test (count $tokens) -ge 2
+ for j in (seq (count $tokens) -1 2)
+ if string match -q -- '-*' $tokens[$j]
+ set opt_index $j
+ break
+ end
+ end
+ end
+
+ if test $opt_index -gt 0
+ set -l opt $tokens[$opt_index]
+ set -l values_after (math (count $tokens) - $opt_index)
+ switch $state
+ case 0
+ switch $opt
+ case --config
+ if test $values_after -lt 1
+ printf '%s\n' 'debug'
+ printf '%s\n' 'release'
+ return
+ end
+ case --framework -f
+ if test $values_after -lt 3
+ printf '%s\n' 'net10.0'
+ printf '%s\n' 'net8.0'
+ printf '%s\n' 'net9.0'
+ return
+ end
+ case --sources
+ return
+ end
+ end
+ end
+
+ switch $state
+ case 0
+ printf '%s\n' 'build'
+ printf '%s\n' '--config'
+ printf '%s\n' '--framework'
+ printf '%s\n' '-f'
+ printf '%s\n' '--sources'
+ case 1
+ end
+end
+
+complete -c mycommand -f -a '(_mycommand)'
diff --git a/src/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.NestedSubcommandCompletion.verified.fish b/src/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.NestedSubcommandCompletion.verified.fish
new file mode 100644
index 0000000000..400d43cf29
--- /dev/null
+++ b/src/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.NestedSubcommandCompletion.verified.fish
@@ -0,0 +1,34 @@
+# fish completions for mycommand
+
+function _mycommand
+ set -l tokens (commandline -opc)
+
+ set -l state 0
+ set -l i 2
+ while test $i -le (count $tokens)
+ set -l word $tokens[$i]
+ switch $state
+ case 0
+ switch $word
+ case subcommand
+ set state 1
+ end
+ case 1
+ switch $word
+ case nested
+ set state 2
+ end
+ end
+ set i (math $i + 1)
+ end
+
+ switch $state
+ case 0
+ printf '%s\n' 'subcommand'
+ case 1
+ printf '%s\n' 'nested'
+ case 2
+ end
+end
+
+complete -c mycommand -f -a '(_mycommand)'
diff --git a/src/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.SimpleOptionCompletion.verified.fish b/src/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.SimpleOptionCompletion.verified.fish
new file mode 100644
index 0000000000..68d8ab21e6
--- /dev/null
+++ b/src/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.SimpleOptionCompletion.verified.fish
@@ -0,0 +1,50 @@
+# fish completions for mycommand
+
+function _mycommand
+ set -l tokens (commandline -opc)
+
+ set -l state 0
+ set -l i 2
+ while test $i -le (count $tokens)
+ set -l word $tokens[$i]
+ switch $state
+ case 0
+ switch $word
+ case --name
+ set i (math $i + 1)
+ end
+ end
+ set i (math $i + 1)
+ end
+
+ set -l opt_index 0
+ if test (count $tokens) -ge 2
+ for j in (seq (count $tokens) -1 2)
+ if string match -q -- '-*' $tokens[$j]
+ set opt_index $j
+ break
+ end
+ end
+ end
+
+ if test $opt_index -gt 0
+ set -l opt $tokens[$opt_index]
+ set -l values_after (math (count $tokens) - $opt_index)
+ switch $state
+ case 0
+ switch $opt
+ case --name
+ if test $values_after -lt 1
+ return
+ end
+ end
+ end
+ end
+
+ switch $state
+ case 0
+ printf '%s\n' '--name'
+ end
+end
+
+complete -c mycommand -f -a '(_mycommand)'
diff --git a/src/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.StaticOptionValues.verified.fish b/src/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.StaticOptionValues.verified.fish
new file mode 100644
index 0000000000..98a413a449
--- /dev/null
+++ b/src/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.StaticOptionValues.verified.fish
@@ -0,0 +1,55 @@
+# fish completions for mycommand
+
+function _mycommand
+ set -l tokens (commandline -opc)
+
+ set -l state 0
+ set -l i 2
+ while test $i -le (count $tokens)
+ set -l word $tokens[$i]
+ switch $state
+ case 0
+ switch $word
+ case --verbosity
+ set i (math $i + 1)
+ end
+ end
+ set i (math $i + 1)
+ end
+
+ set -l opt_index 0
+ if test (count $tokens) -ge 2
+ for j in (seq (count $tokens) -1 2)
+ if string match -q -- '-*' $tokens[$j]
+ set opt_index $j
+ break
+ end
+ end
+ end
+
+ if test $opt_index -gt 0
+ set -l opt $tokens[$opt_index]
+ set -l values_after (math (count $tokens) - $opt_index)
+ switch $state
+ case 0
+ switch $opt
+ case --verbosity
+ if test $values_after -lt 1
+ printf '%s\n' 'detailed'
+ printf '%s\n' 'diagnostic'
+ printf '%s\n' 'minimal'
+ printf '%s\n' 'normal'
+ printf '%s\n' 'quiet'
+ return
+ end
+ end
+ end
+ end
+
+ switch $state
+ case 0
+ printf '%s\n' '--verbosity'
+ end
+end
+
+complete -c mycommand -f -a '(_mycommand)'
diff --git a/src/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.SubcommandAndOptionInTopLevelList.verified.fish b/src/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.SubcommandAndOptionInTopLevelList.verified.fish
new file mode 100644
index 0000000000..0936b3b28c
--- /dev/null
+++ b/src/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.SubcommandAndOptionInTopLevelList.verified.fish
@@ -0,0 +1,54 @@
+# fish completions for mycommand
+
+function _mycommand
+ set -l tokens (commandline -opc)
+
+ set -l state 0
+ set -l i 2
+ while test $i -le (count $tokens)
+ set -l word $tokens[$i]
+ switch $state
+ case 0
+ switch $word
+ case subcommand
+ set state 1
+ case --name
+ set i (math $i + 1)
+ end
+ end
+ set i (math $i + 1)
+ end
+
+ set -l opt_index 0
+ if test (count $tokens) -ge 2
+ for j in (seq (count $tokens) -1 2)
+ if string match -q -- '-*' $tokens[$j]
+ set opt_index $j
+ break
+ end
+ end
+ end
+
+ if test $opt_index -gt 0
+ set -l opt $tokens[$opt_index]
+ set -l values_after (math (count $tokens) - $opt_index)
+ switch $state
+ case 0
+ switch $opt
+ case --name
+ if test $values_after -lt 1
+ return
+ end
+ end
+ end
+ end
+
+ switch $state
+ case 0
+ printf '%s\n' 'subcommand'
+ printf '%s\n' '--name'
+ case 1
+ end
+end
+
+complete -c mycommand -f -a '(_mycommand)'
diff --git a/src/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.UnboundedMultiValueOption.verified.fish b/src/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.UnboundedMultiValueOption.verified.fish
new file mode 100644
index 0000000000..92f17b003b
--- /dev/null
+++ b/src/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.UnboundedMultiValueOption.verified.fish
@@ -0,0 +1,68 @@
+# fish completions for mycommand
+
+function _mycommand
+ set -l tokens (commandline -opc)
+
+ set -l state 0
+ set -l i 2
+ while test $i -le (count $tokens)
+ set -l word $tokens[$i]
+ switch $state
+ case 0
+ switch $word
+ case subcommand
+ set state 1
+ case --name
+ set i (math $i + 1)
+ case --items
+ while test (math $i + 1) -le (count $tokens)
+ set -l next $tokens[(math $i + 1)]
+ if string match -q -- '-*' $next
+ break
+ end
+ set i (math $i + 1)
+ end
+ end
+ end
+ set i (math $i + 1)
+ end
+
+ set -l opt_index 0
+ if test (count $tokens) -ge 2
+ for j in (seq (count $tokens) -1 2)
+ if string match -q -- '-*' $tokens[$j]
+ set opt_index $j
+ break
+ end
+ end
+ end
+
+ if test $opt_index -gt 0
+ set -l opt $tokens[$opt_index]
+ set -l values_after (math (count $tokens) - $opt_index)
+ switch $state
+ case 0
+ switch $opt
+ case --items
+ printf '%s\n' 'a'
+ printf '%s\n' 'b'
+ printf '%s\n' 'c'
+ return
+ case --name
+ if test $values_after -lt 1
+ return
+ end
+ end
+ end
+ end
+
+ switch $state
+ case 0
+ printf '%s\n' 'subcommand'
+ printf '%s\n' '--items'
+ printf '%s\n' '--name'
+ case 1
+ end
+end
+
+complete -c mycommand -f -a '(_mycommand)'
diff --git a/src/System.CommandLine.StaticCompletions.Tests/snapshots/nushell/NushellShellProviderTests.GenericCompletions.verified.nu b/src/System.CommandLine.StaticCompletions.Tests/snapshots/nushell/NushellShellProviderTests.GenericCompletions.verified.nu
new file mode 100644
index 0000000000..6f382adacc
--- /dev/null
+++ b/src/System.CommandLine.StaticCompletions.Tests/snapshots/nushell/NushellShellProviderTests.GenericCompletions.verified.nu
@@ -0,0 +1,10 @@
+# nushell completions for mycommand
+# save this file and `source` it from your nushell config
+
+def "nu-complete mycommand" [context: string] {
+ ^mycommand $"[suggest:($context | str length)]" $context | lines
+}
+
+export extern "mycommand" [
+ ...command: string@"nu-complete mycommand"
+]
\ No newline at end of file
diff --git a/src/System.CommandLine.StaticCompletions.Tests/snapshots/pwsh/PowershellProviderTests.DynamicCompletionsGeneration.verified.ps1 b/src/System.CommandLine.StaticCompletions.Tests/snapshots/pwsh/PowershellProviderTests.DynamicCompletionsGeneration.verified.ps1
new file mode 100644
index 0000000000..531b2b0511
--- /dev/null
+++ b/src/System.CommandLine.StaticCompletions.Tests/snapshots/pwsh/PowershellProviderTests.DynamicCompletionsGeneration.verified.ps1
@@ -0,0 +1,35 @@
+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
+ $text = $commandAst.ToString()
+ $suggestResults = @(& 'mycommand' "[suggest:$cursorPosition]" $text) | Where-Object { $_ -NotMatch "^-|^/" }
+ $dynamicCompletions = $suggestResults | Foreach-Object { [CompletionResult]::new($_, $_, [CompletionResultType]::ParameterValue, $_) }
+ $completions += $dynamicCompletions
+ break
+ }
+ }
+ $completions | Where-Object -FilterScript { $_.CompletionText -like "$wordToComplete*" } | Sort-Object -Property ListItemText
+}
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..596c9e5660
--- /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=[]: :->suggest' \
+ ':--dynamic:->suggest' \
+ && ret=0
+ case $state in
+ (suggest)
+ local completions=()
+ local result=$(my-app "[suggest:${#original_args}]" "${original_args}" 2>/dev/null)
+ 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..522b088fac
--- /dev/null
+++ b/src/System.CommandLine.StaticCompletions/CompletionsCommandParser.cs
@@ -0,0 +1,62 @@
+// 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 rootCommand = args.RootCommandResult.Command;
+
+ // the generated scripts resolve dynamic completions by calling back into the application
+ // via the [suggest] directive - if that directive has been removed from the root command,
+ // those parts of the script will silently do nothing, so let the user know at generation time.
+ if (HasDynamicSymbols(rootCommand) && !HasSuggestDirective(rootCommand))
+ {
+ args.InvocationConfiguration.Error.WriteLine(Resources.Strings.GenerateCommand_SuggestDirectiveDisabledWarning);
+ }
+
+ var script = shell.GenerateCompletions(rootCommand);
+ args.InvocationConfiguration.Output.Write(script);
+ });
+ }
+
+ private static bool HasSuggestDirective(Command command) =>
+ command is RootCommand root && root.Directives.OfType().Any();
+
+ private static bool HasDynamicSymbols(Command command) =>
+ command.Options.Any(o => !o.Hidden && o.IsDynamic) ||
+ command.Arguments.Any(a => !a.Hidden && a.IsDynamic) ||
+ command.Subcommands.Where(c => !c.Hidden).Any(HasDynamicSymbols);
+}
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..47c152a844
--- /dev/null
+++ b/src/System.CommandLine.StaticCompletions/DynamicSymbolExtensions.cs
@@ -0,0 +1,68 @@
+// 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 back into the application itself
+ /// (via the built-in [suggest] directive) to compute completions at completion time.
+ ///
+ 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 back into the application itself
+ /// (via the built-in [suggest] directive) to compute completions at completion time.
+ ///
+ 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