diff --git a/Directory.Packages.props b/Directory.Packages.props
index f207c44e8c..760c23f155 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -20,6 +20,8 @@
+
+
-
\ No newline at end of file
+
diff --git a/NuGet.config b/NuGet.config
index 9f4127a7b2..bea52b0212 100644
--- a/NuGet.config
+++ b/NuGet.config
@@ -9,4 +9,8 @@
+
+
+
+
diff --git a/System.CommandLine.slnx b/System.CommandLine.slnx
index 35ee0c6b40..a6919d89a7 100644
--- a/System.CommandLine.slnx
+++ b/System.CommandLine.slnx
@@ -20,6 +20,8 @@
+
+
diff --git a/eng/Version.Details.props b/eng/Version.Details.props
index 9aa683035a..b1be1bd3c8 100644
--- a/eng/Version.Details.props
+++ b/eng/Version.Details.props
@@ -6,7 +6,7 @@ This file should be imported by eng/Versions.props
- 11.0.0-beta.26319.105
+ 11.0.0-beta.26356.103
diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml
index 6db651440d..3b3bb669ab 100644
--- a/eng/Version.Details.xml
+++ b/eng/Version.Details.xml
@@ -1,12 +1,12 @@
-
+
-
+
https://github.com/dotnet/dotnet
- b756a8d8a0e64905e16c81911bb09a8110bc3395
+ a67d575054ff3bcb905ea6699734d0ed8816aa2b
diff --git a/eng/Versions.props b/eng/Versions.props
index 28fdab9f45..85d4357712 100644
--- a/eng/Versions.props
+++ b/eng/Versions.props
@@ -5,7 +5,7 @@
3.0.0
preview
- 6
+ 7
diff --git a/eng/common/core-templates/job/helix-job-monitor.yml b/eng/common/core-templates/job/helix-job-monitor.yml
index a8162c5116..96287e55a1 100644
--- a/eng/common/core-templates/job/helix-job-monitor.yml
+++ b/eng/common/core-templates/job/helix-job-monitor.yml
@@ -57,6 +57,14 @@ parameters:
type: number
default: 30
+# When 'true' (the default), Helix work items that exit 0 but have failed AzDO test results
+# are treated as failed: they count toward the monitor's exit code and are resubmitted by a
+# later invocation's retry pass. Set to 'false' to fall back to exit-code-only outcomes.
+# Forwarded as --fail-on-failed-tests.
+- name: failWorkItemsWithFailedTests
+ type: boolean
+ default: true
+
# Advanced: optional pipeline artifact (produced earlier in this run) that contains the tool
# nupkg. When set, the artifact is downloaded and the tool is installed from the nupkg into
# a local tool-path; this bypasses the repo's .config/dotnet-tools.json manifest and is
@@ -170,6 +178,7 @@ jobs:
toolArgs=(
--helix-base-uri '${{ parameters.helixBaseUri }}'
--polling-interval-seconds '${{ parameters.pollingIntervalSeconds }}'
+ --fail-on-failed-tests '${{ parameters.failWorkItemsWithFailedTests }}'
--max-wait-minutes "$((${{ parameters.timeoutInMinutes }} - 5))" # Set the tool's timeout slightly lower than the Azure DevOps job timeout to allow it to exit gracefully.
--stage-name '$(System.StageName)'
)
diff --git a/eng/common/core-templates/steps/send-to-helix.yml b/eng/common/core-templates/steps/send-to-helix.yml
index 68fa739c4a..ec7a200039 100644
--- a/eng/common/core-templates/steps/send-to-helix.yml
+++ b/eng/common/core-templates/steps/send-to-helix.yml
@@ -10,6 +10,7 @@ parameters:
HelixConfiguration: '' # optional -- additional property attached to a job
HelixPreCommands: '' # optional -- commands to run before Helix work item execution
HelixPostCommands: '' # optional -- commands to run after Helix work item execution
+ UseHelixMonitor: false # optional -- true will submit Helix jobs configured for the standalone Helix Job Monitor (results are reported/waited on out-of-band; this step will not wait, and WaitForWorkItemCompletion will be overridden)
WorkItemDirectory: '' # optional -- a payload directory to zip up and send to Helix; requires WorkItemCommand; incompatible with XUnitProjects
WorkItemCommand: '' # optional -- a command to execute on the payload; requires WorkItemDirectory; incompatible with XUnitProjects
WorkItemTimeout: '' # optional -- a timeout in TimeSpan.Parse-ready value (e.g. 00:02:00) for the work item command; requires WorkItemDirectory; incompatible with XUnitProjects
@@ -31,7 +32,15 @@ parameters:
continueOnError: false # optional -- determines whether to continue the build if the step errors; defaults to false
steps:
- - powershell: 'powershell "$env:BUILD_SOURCESDIRECTORY\eng\common\msbuild.ps1 $env:BUILD_SOURCESDIRECTORY/${{ parameters.HelixProjectPath }} /restore /p:TreatWarningsAsErrors=false ${{ parameters.HelixProjectArguments }} /t:Test /bl:$env:BUILD_SOURCESDIRECTORY\artifacts\log\$env:BuildConfig\SendToHelix.binlog"'
+ - powershell: >
+ $(Build.SourcesDirectory)\eng\common\msbuild.ps1
+ $(Build.SourcesDirectory)/${{ parameters.HelixProjectPath }}
+ /restore
+ /p:TreatWarningsAsErrors=false
+ /p:EnableHelixJobMonitor=${{ parameters.UseHelixMonitor }}
+ ${{ parameters.HelixProjectArguments }}
+ /t:Test
+ /bl:$(Build.SourcesDirectory)/artifacts/log/$(_BuildConfig)/SendToHelix.binlog
displayName: ${{ parameters.DisplayNamePrefix }} (Windows)
env:
BuildConfig: $(_BuildConfig)
@@ -61,7 +70,15 @@ steps:
SYSTEM_ACCESSTOKEN: $(System.AccessToken)
condition: and(${{ parameters.condition }}, eq(variables['Agent.Os'], 'Windows_NT'))
continueOnError: ${{ parameters.continueOnError }}
- - script: $BUILD_SOURCESDIRECTORY/eng/common/msbuild.sh $BUILD_SOURCESDIRECTORY/${{ parameters.HelixProjectPath }} /restore /p:TreatWarningsAsErrors=false ${{ parameters.HelixProjectArguments }} /t:Test /bl:$BUILD_SOURCESDIRECTORY/artifacts/log/$BuildConfig/SendToHelix.binlog
+ - script: >
+ $(Build.SourcesDirectory)/eng/common/msbuild.sh
+ $(Build.SourcesDirectory)/${{ parameters.HelixProjectPath }}
+ /restore
+ /p:TreatWarningsAsErrors=false
+ /p:EnableHelixJobMonitor=${{ parameters.UseHelixMonitor }}
+ ${{ parameters.HelixProjectArguments }}
+ /t:Test
+ /bl:$(Build.SourcesDirectory)/artifacts/log/$(_BuildConfig)/SendToHelix.binlog
displayName: ${{ parameters.DisplayNamePrefix }} (Unix)
env:
BuildConfig: $(_BuildConfig)
@@ -91,3 +108,4 @@ steps:
SYSTEM_ACCESSTOKEN: $(System.AccessToken)
condition: and(${{ parameters.condition }}, ne(variables['Agent.Os'], 'Windows_NT'))
continueOnError: ${{ parameters.continueOnError }}
+
diff --git a/eng/common/cross/build-rootfs.sh b/eng/common/cross/build-rootfs.sh
index 273cae651a..3150ccac6f 100755
--- a/eng/common/cross/build-rootfs.sh
+++ b/eng/common/cross/build-rootfs.sh
@@ -88,8 +88,9 @@ __FreeBSDPackages+=" terminfo-db"
__OpenBSDVersion="7.8"
__OpenBSDPackages="heimdal-libs"
__OpenBSDPackages+=" icu4c"
-__OpenBSDPackages+=" inotify-tools"
+__OpenBSDPackages+=" libinotify"
__OpenBSDPackages+=" openssl"
+__OpenBSDPackages+=" e2fsprogs"
__IllumosPackages="icu"
__IllumosPackages+=" mit-krb5"
diff --git a/eng/common/cross/install-debs.py b/eng/common/cross/install-debs.py
index 20ca770a1e..1d1dfabf7d 100755
--- a/eng/common/cross/install-debs.py
+++ b/eng/common/cross/install-debs.py
@@ -121,10 +121,14 @@ async def fetch_release_file(session, mirror, suite, keyring):
await download_file(session, release_gpg_url, release_gpg_file.name)
print("Verifying signature of Release with Release.gpg.")
- verify_command = ["gpg"]
+ # Use gpgv rather than gpg for verification. gpgv verifies a detached
+ # signature against a fixed keyring without involving gpg-agent or
+ # keyboxd, which makes it robust on hosts running GnuPG 2.4+ (e.g. Azure
+ # Linux) where "gpg --keyring" routes through keyboxd and can fail.
+ verify_command = ["gpgv"]
if keyring:
verify_command += ["--keyring", keyring]
- verify_command += ["--verify", release_gpg_file.name, release_file.name]
+ verify_command += [release_gpg_file.name, release_file.name]
result = subprocess.run(verify_command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if result.returncode != 0:
diff --git a/eng/common/cross/toolchain.cmake b/eng/common/cross/toolchain.cmake
index 99d6dfe82d..ead7fe3ef2 100644
--- a/eng/common/cross/toolchain.cmake
+++ b/eng/common/cross/toolchain.cmake
@@ -59,9 +59,9 @@ elseif(TARGET_ARCH_NAME STREQUAL "arm64")
set(TIZEN_TOOLCHAIN "aarch64-tizen-linux-gnu")
endif()
elseif(FREEBSD)
- set(triple "aarch64-unknown-freebsd12")
+ set(TOOLCHAIN "aarch64-unknown-freebsd14")
elseif(OPENBSD)
- set(triple "aarch64-unknown-openbsd")
+ set(TOOLCHAIN "aarch64-unknown-openbsd")
endif()
elseif(TARGET_ARCH_NAME STREQUAL "armel")
set(CMAKE_SYSTEM_PROCESSOR armv7l)
@@ -117,9 +117,9 @@ elseif(TARGET_ARCH_NAME STREQUAL "x64")
set(TIZEN_TOOLCHAIN "x86_64-tizen-linux-gnu")
endif()
elseif(FREEBSD)
- set(triple "x86_64-unknown-freebsd12")
+ set(TOOLCHAIN "x86_64-unknown-freebsd14")
elseif(OPENBSD)
- set(triple "x86_64-unknown-openbsd")
+ set(TOOLCHAIN "x86_64-unknown-openbsd")
elseif(ILLUMOS)
set(TOOLCHAIN "x86_64-illumos")
elseif(HAIKU)
@@ -160,8 +160,6 @@ if(TIZEN)
find_toolchain_dir("${CROSS_ROOTFS}/usr/lib64/gcc/${TIZEN_TOOLCHAIN}")
endif()
- message(STATUS "TIZEN_TOOLCHAIN_PATH set to: ${TIZEN_TOOLCHAIN_PATH}")
-
include_directories(SYSTEM ${TIZEN_TOOLCHAIN_PATH}/include/c++)
include_directories(SYSTEM ${TIZEN_TOOLCHAIN_PATH}/include/c++/${TIZEN_TOOLCHAIN})
endif()
@@ -206,9 +204,9 @@ if(ANDROID)
include(${CROSS_ROOTFS}/../build/cmake/android.toolchain.cmake)
elseif(FREEBSD OR OPENBSD)
# we cross-compile by instructing clang
- set(CMAKE_C_COMPILER_TARGET ${triple})
- set(CMAKE_CXX_COMPILER_TARGET ${triple})
- set(CMAKE_ASM_COMPILER_TARGET ${triple})
+ set(CMAKE_C_COMPILER_TARGET ${TOOLCHAIN})
+ set(CMAKE_CXX_COMPILER_TARGET ${TOOLCHAIN})
+ set(CMAKE_ASM_COMPILER_TARGET ${TOOLCHAIN})
set(CMAKE_SYSROOT "${CROSS_ROOTFS}")
set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -fuse-ld=lld")
set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -fuse-ld=lld")
diff --git a/eng/common/native/NativeAotSupported.props b/eng/common/native/NativeAotSupported.props
index 559a666392..cdff9ef036 100644
--- a/eng/common/native/NativeAotSupported.props
+++ b/eng/common/native/NativeAotSupported.props
@@ -13,6 +13,8 @@
<_NativeAotSupportedArch Condition="
'$(TargetArchitecture)' != 'wasm' and
+ '$(TargetArchitecture)' != 's390x' and
+ '$(TargetArchitecture)' != 'ppc64le' and
('$(TargetArchitecture)' != 'x86' or '$(TargetOS)' == 'windows')
">true
diff --git a/eng/common/tools.ps1 b/eng/common/tools.ps1
index de32a6da37..261def92b9 100644
--- a/eng/common/tools.ps1
+++ b/eng/common/tools.ps1
@@ -741,13 +741,6 @@ function MSBuild() {
Write-PipelineTelemetryError -Category 'Build' -Message 'Binary log must be enabled in CI build, or explicitly opted-out from with the -excludeCIBinarylog switch.'
ExitWithExitCode 1
}
-
- # Node reuse must be disabled in CI builds unless explicitly opted in via MSBUILD_NODEREUSE_ENABLED.
- # Internal testing only; this env var will be replaced with a switch (https://github.com/dotnet/arcade/issues/17013) and must not be depended on.
- if ($nodeReuse -and $env:MSBUILD_NODEREUSE_ENABLED -ne "1") {
- Write-PipelineTelemetryError -Category 'Build' -Message 'Node reuse must be disabled in CI build.'
- ExitWithExitCode 1
- }
}
$buildTool = InitializeBuildTool
diff --git a/eng/common/tools.sh b/eng/common/tools.sh
index 05f9edd0aa..8d1a6b4610 100755
--- a/eng/common/tools.sh
+++ b/eng/common/tools.sh
@@ -423,7 +423,7 @@ function InitializeToolset {
if [[ -z "$nuget_config" ]]; then
# Search for any variation of nuget.config in the RepoRoot
local found_config
- found_config=$(find "$repo_root" -maxdepth 1 -type f -iname "nuget.config" -print -quit)
+ found_config=$(find "$repo_root" -maxdepth 1 -type f -iname nuget.config | head -n 1)
if [[ -n "$found_config" ]]; then
nuget_config="$found_config"
@@ -466,7 +466,8 @@ function ExitWithExitCode {
function StopProcesses {
echo "Killing running build processes..."
pkill -9 "dotnet" || true
- pkill -9 "vbcscompiler" || true
+ pkill -9 -i -x VBCSCompiler || true
+ pkill -9 -i -x MSBuild || true
return 0
}
@@ -493,14 +494,7 @@ function DotNet {
function MSBuild {
if [[ "$ci" == true ]]; then
if [[ "$binary_log" != true && "$exclude_ci_binary_log" != true ]]; then
- Write-PipelineTelemetryError -category 'Build' "Binary log must be enabled in CI build, or explicitly opted-out from with the -noBinaryLog switch."
- ExitWithExitCode 1
- fi
-
- # Node reuse must be disabled in CI builds unless explicitly opted in via MSBUILD_NODEREUSE_ENABLED.
- # Internal testing only; this env var will be replaced with a switch (https://github.com/dotnet/arcade/issues/17013) and must not be depended on.
- if [[ "$node_reuse" == true && "${MSBUILD_NODEREUSE_ENABLED:-}" != "1" ]]; then
- Write-PipelineTelemetryError -category 'Build' "Node reuse must be disabled in CI build."
+ Write-PipelineTelemetryError -category 'Build' "Binary log must be enabled in CI build, or explicitly opted-out from with the --excludeCIBinarylog switch."
ExitWithExitCode 1
fi
fi
diff --git a/global.json b/global.json
index 3232f810d5..ae4153aec2 100644
--- a/global.json
+++ b/global.json
@@ -1,11 +1,11 @@
{
"sdk": {
- "version": "11.0.100-preview.5.26227.104",
+ "version": "11.0.100-preview.5.26302.115",
"allowPrerelease": true,
"rollForward": "major"
},
"tools": {
- "dotnet": "11.0.100-preview.5.26227.104",
+ "dotnet": "11.0.100-preview.5.26302.115",
"runtimes": {
"dotnet": [
"10.0.2"
@@ -13,6 +13,6 @@
}
},
"msbuild-sdks": {
- "Microsoft.DotNet.Arcade.Sdk": "11.0.0-beta.26319.105"
+ "Microsoft.DotNet.Arcade.Sdk": "11.0.0-beta.26356.103"
}
}
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/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..7843087772
--- /dev/null
+++ b/src/System.CommandLine.StaticCompletions.Tests/System.CommandLine.StaticCompletions.Tests.csproj
@@ -0,0 +1,23 @@
+
+
+
+ $(NetMinimum)
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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