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/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..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..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..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/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..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..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