Repository: apple/swift-argument-parser (swift)

Issue:
Indicate version of shell requesting completions
A `SAP_SHELL_VERSION` environment variable should be exported by each generated shell completion script.

Its value will be the version of the shell requesting completions. e.g., bash could be `3.2.57`, `5.2.37`, etc.; zsh could be `5.3`, 5.9`, etc.; fish could be `3.7.1`, etc.

 It will only be available at completion time, not at script-generation time, so it will only be useful for `shellCommand` or `custom` `CompletionKind`s. `shellCommand` is written to the script at generation time, but the commands in its `String` argument can use `${SAP_SHELL_VERSION}`, while the function argument for `custom` is run during completion, so `${SAP_SHELL_VERSION}` will be used to set the following while while a custom completion function is running (otherwise, it will be set to `nil`):

```swift
public struct CompletionShell: … {
  …
  public internal(set) static var requestingVersion: String?
  …
}
```

This will allow custom completions to provide better completions (older shell versions might not support newer completion formats / shell commands or builtins), and will also allow them to know when they will receive different data due to shell bugs / limitations limited to certain shell versions (e.g., various issues in the fish `commandline` command will be fixed in the forthcoming fish 4.0.0, so output will be different for 4.0.0+ & prior versions).

Pull request description:
Add singleton to indicate which shell version is requesting completion candidates.

Create a `String?` singleton named `CompletionShell.requestingVersion` that indicates which shell version is requesting completion candidates.

It will be set to the correct value while a Swift custom completion function is executing to offer completions for a word from a command line (e.g., while `customCompletion` from `@Option(completion: .custom(customCompletion))` executes). Otherwise, it will be set to `nil`.

The requesting shell version is communicated to the Swift app via an environment variable named `SAP_SHELL_VERSION`, which is exported by each of the generated completion scripts.

Improve some nearby DocC.

Resolve #689

Replace this paragraph with a description of your changes and rationale. Provide links to an existing issue or external references/discussions, if appropriate.

### Checklist
- [X] I've added at least one test that validates that my change is working, if appropriate
- [X] I've followed the code style of the rest of the project
- [X] I've read the [Contribution Guidelines](https://github.com/apple/swift-argument-parser/blob/main/CONTRIBUTING.md)
- [X] I've updated the documentation if necessary

Patch:
diff --git a/Sources/ArgumentParser/Completions/BashCompletionsGenerator.swift b/Sources/ArgumentParser/Completions/BashCompletionsGenerator.swift
index 8d5d8aed1..974f0c03c 100644
--- a/Sources/ArgumentParser/Completions/BashCompletionsGenerator.swift
+++ b/Sources/ArgumentParser/Completions/BashCompletionsGenerator.swift
@@ -57,7 +57,9 @@ struct BashCompletionsGenerator {
     // that other command functions don't need.
     if isRootCommand {
       result += """
-        export \(CompletionShell.environmentVariableName)=bash
+        export \(CompletionShell.shellEnvironmentVariableName)=bash
+        \(CompletionShell.shellVersionEnvironmentVariableName)="$(IFS='.'; printf %s "${BASH_VERSINFO[*]}")"
+        export \(CompletionShell.shellVersionEnvironmentVariableName)
         cur="${COMP_WORDS[COMP_CWORD]}"
         prev="${COMP_WORDS[COMP_CWORD-1]}"
         COMPREPLY=()
diff --git a/Sources/ArgumentParser/Completions/CompletionsGenerator.swift b/Sources/ArgumentParser/Completions/CompletionsGenerator.swift
index f233a8c95..bfff62405 100644
--- a/Sources/ArgumentParser/Completions/CompletionsGenerator.swift
+++ b/Sources/ArgumentParser/Completions/CompletionsGenerator.swift
@@ -43,18 +43,32 @@ public struct CompletionShell: RawRepresentable, Hashable, CaseIterable {
   }
 
   /// While generating a shell completion script or while a Swift custom completion
-  /// function is executing to offer completions for a word from a command line
-  /// (e.g., when `customCompletion` from `@Option(completion: .custom(customCompletion))`
+  /// function is executing to offer completions for a word from a command line (e.g.,
+  /// while `customCompletion` from `@Option(completion: .custom(customCompletion))`
   /// executes), an instance representing the shell for which completions will
   /// be or are being requested, respectively. Otherwise `nil`.
   public internal(set) static var requesting: CompletionShell?
 
+  /// While a Swift custom completion function is executing to offer completions
+  /// for a word from a command line (e.g., while `customCompletion` from
+  /// `@Option(completion: .custom(customCompletion))` executes), a `String`
+  /// representing the version of the shell for which completions are being
+  /// requested. Otherwise `nil`.
+  public internal(set) static var requestingVersion: String?
+
   /// The name of the environment variable whose value is the name of the shell
   /// for which completions are being requested from a custom completion
   /// handler.
   ///
   /// The environment variable is set in generated completion scripts.
-  static let environmentVariableName = "SAP_SHELL"
+  static let shellEnvironmentVariableName = "SAP_SHELL"
+
+  /// The name of the environment variable whose value is the version of the
+  /// shell for which completions are being requested from a custom completion
+  /// handler.
+  ///
+  /// The environment variable is set in generated completion scripts.
+  static let shellVersionEnvironmentVariableName = "SAP_SHELL_VERSION"
 }
 
 struct CompletionsGenerator {
@@ -81,7 +95,7 @@ struct CompletionsGenerator {
     }
   }
   
-  /// Generates a Bash completion script for this generators shell and command..
+  /// Generates a shell completion script for this generator's shell and command.
   func generateCompletionScript() -> String {
     CompletionShell.requesting = shell
     switch shell {
diff --git a/Sources/ArgumentParser/Completions/FishCompletionsGenerator.swift b/Sources/ArgumentParser/Completions/FishCompletionsGenerator.swift
index 206b6ac4b..f35117b90 100644
--- a/Sources/ArgumentParser/Completions/FishCompletionsGenerator.swift
+++ b/Sources/ArgumentParser/Completions/FishCompletionsGenerator.swift
@@ -147,7 +147,8 @@ extension FishCompletionsGenerator {
     let preprocessorFunctionName = preprocessorFunctionName(commandName: commandName)
     return """
     function \(functionName)
-        set -gx \(CompletionShell.environmentVariableName) fish
+        set -gx \(CompletionShell.shellEnvironmentVariableName) fish
+        set -gx \(CompletionShell.shellVersionEnvironmentVariableName) "$FISH_VERSION"
         set -l currentCommands (\(preprocessorFunctionName) (commandline -opc))
         set -l expectedCommands (string split \"\(separator)\" $argv[1])
         set -l subcommands (string split \"\(separator)\" $argv[2])
diff --git a/Sources/ArgumentParser/Completions/ZshCompletionsGenerator.swift b/Sources/ArgumentParser/Completions/ZshCompletionsGenerator.swift
index 7cf57c0b9..3fbc89b4a 100644
--- a/Sources/ArgumentParser/Completions/ZshCompletionsGenerator.swift
+++ b/Sources/ArgumentParser/Completions/ZshCompletionsGenerator.swift
@@ -83,7 +83,12 @@ struct ZshCompletionsGenerator {
     }
     
     let functionText = """
-      \(functionName)() {\(isRootCommand ? "\n    export \(CompletionShell.environmentVariableName)=zsh" : "")
+      \(functionName)() {\(isRootCommand ? """
+
+          export \(CompletionShell.shellEnvironmentVariableName)=zsh
+          \(CompletionShell.shellVersionEnvironmentVariableName)="$(builtin emulate zsh -c 'printf %s "${ZSH_VERSION}"')"
+          export \(CompletionShell.shellVersionEnvironmentVariableName)
+      """ : "")
           integer ret=1
           local -a args
           args+=(
diff --git a/Sources/ArgumentParser/Documentation.docc/Articles/CustomizingCompletions.md b/Sources/ArgumentParser/Documentation.docc/Articles/CustomizingCompletions.md
index 336b95900..269b425b7 100644
--- a/Sources/ArgumentParser/Documentation.docc/Articles/CustomizingCompletions.md
+++ b/Sources/ArgumentParser/Documentation.docc/Articles/CustomizingCompletions.md
@@ -63,13 +63,19 @@ In this example, when a user requests completions for the `--target` option, the
 
 ### Configuring Completion Candidates per Shell
 
-The shells supported for parameter completion all have different completion candidate formats,
-as well as their own different syntaxes and built-in commands.
+The shells supported for word completion all have different completion candidate formats, as
+well as their own different syntaxes and built-in commands.
 
 The `CompletionShell.requesting` singleton (of type `CompletionShell?`) can be read to determine
 which shell is requesting completion candidates when evaluating functions that either provide
 arguments to a `CompletionKind` creation function, or that are themselves arguments to a
-`CompletionKind` creation function. e.g.:
+`CompletionKind` creation function.
+
+The `CompletionShell.requestingVersion` singleton (of type `String?`) can be read to determine
+the version of the shell that is requesting completion candidates when evaluating functions that
+are themselves arguments to a `CompletionKind` creation function.
+
+e.g.:
 
 ```swift
 struct Tool {
@@ -96,10 +102,15 @@ func generateCommandPerShell() -> String {
 }
 
 /// Runs during completion while user is typing command line to use your tool
+/// Note that the `Version` struct is not included in Swift Argument Parser
 func generateCompletionCandidatesPerShell(_ arguments: [String]) -> [String] {
     switch CompletionShell.requesting {
     case CompletionShell.bash:
-        return ["A:in:bash:syntax", "B:in:bash:syntax", "C:in:bash:syntax"]
+        if Version(CompletionShell.requestingVersion).major >= 4 {
+            return ["A:in:bash4+:syntax", "B:in:bash4+:syntax", "C:in:bash4+:syntax"]
+        } else {
+            return ["A:in:bash:syntax", "B:in:bash:syntax", "C:in:bash:syntax"]
+        }
     case CompletionShell.fish:
         return ["A:in:fish:syntax", "B:in:bash:syntax", "C:in:bash:syntax"]
     case CompletionShell.zsh:
diff --git a/Sources/ArgumentParser/Parsing/CommandParser.swift b/Sources/ArgumentParser/Parsing/CommandParser.swift
index 29c66fa5b..d92b119ca 100644
--- a/Sources/ArgumentParser/Parsing/CommandParser.swift
+++ b/Sources/ArgumentParser/Parsing/CommandParser.swift
@@ -361,10 +361,12 @@ extension CommandParser {
       throw ParserError.invalidState
     }
     
-    if let completionShellName = ProcessInfo.processInfo.environment[CompletionShell.environmentVariableName] {
+    if let completionShellName = ProcessInfo.processInfo.environment[CompletionShell.shellEnvironmentVariableName] {
       CompletionShell.requesting = CompletionShell(rawValue: completionShellName)
     }
 
+    CompletionShell.requestingVersion = ProcessInfo.processInfo.environment[CompletionShell.shellVersionEnvironmentVariableName]
+
     // Parsing and retrieval successful! We don't want to continue with any
     // other parsing here, so after printing the result of the completion
     // function, exit with a success code.


Test patch:
diff --git a/Tests/ArgumentParserExampleTests/MathExampleTests.swift b/Tests/ArgumentParserExampleTests/MathExampleTests.swift
index 744331e75..ba191f928 100644
--- a/Tests/ArgumentParserExampleTests/MathExampleTests.swift
+++ b/Tests/ArgumentParserExampleTests/MathExampleTests.swift
@@ -242,6 +242,8 @@ private let bashCompletionScriptText = """
 
 _math() {
     export SAP_SHELL=bash
+    SAP_SHELL_VERSION="$(IFS='.'; printf %s "${BASH_VERSINFO[*]}")"
+    export SAP_SHELL_VERSION
     cur="${COMP_WORDS[COMP_CWORD]}"
     prev="${COMP_WORDS[COMP_CWORD-1]}"
     COMPREPLY=()
@@ -397,6 +399,8 @@ typeset -A opt_args
 
 _math() {
     export SAP_SHELL=zsh
+    SAP_SHELL_VERSION="$(builtin emulate zsh -c 'printf %s "${ZSH_VERSION}"')"
+    export SAP_SHELL_VERSION
     integer ret=1
     local -a args
     args+=(
@@ -586,6 +590,7 @@ end
 
 function _swift_math_using_command
     set -gx SAP_SHELL fish
+    set -gx SAP_SHELL_VERSION "$FISH_VERSION"
     set -l currentCommands (_swift_math_preprocessor (commandline -opc))
     set -l expectedCommands (string split \" \" $argv[1])
     set -l subcommands (string split \" \" $argv[2])
diff --git a/Tests/ArgumentParserUnitTests/CompletionScriptTests.swift b/Tests/ArgumentParserUnitTests/CompletionScriptTests.swift
index 8f1fc0472..0a6f4eadc 100644
--- a/Tests/ArgumentParserUnitTests/CompletionScriptTests.swift
+++ b/Tests/ArgumentParserUnitTests/CompletionScriptTests.swift
@@ -178,6 +178,8 @@ typeset -A opt_args
 
 _base-test() {
     export SAP_SHELL=zsh
+    SAP_SHELL_VERSION="$(builtin emulate zsh -c 'printf %s "${ZSH_VERSION}"')"
+    export SAP_SHELL_VERSION
     integer ret=1
     local -a args
     args+=(
@@ -260,6 +262,8 @@ private let bashBaseCompletions = """
 
 _base_test() {
     export SAP_SHELL=bash
+    SAP_SHELL_VERSION="$(IFS='.'; printf %s "${BASH_VERSINFO[*]}")"
+    export SAP_SHELL_VERSION
     cur="${COMP_WORDS[COMP_CWORD]}"
     prev="${COMP_WORDS[COMP_CWORD-1]}"
     COMPREPLY=()
@@ -353,6 +357,8 @@ typeset -A opt_args
 
 _escaped-command() {
     export SAP_SHELL=zsh
+    SAP_SHELL_VERSION="$(builtin emulate zsh -c 'printf %s "${ZSH_VERSION}"')"
+    export SAP_SHELL_VERSION
     integer ret=1
     local -a args
     args+=(
@@ -389,6 +395,7 @@ end
 
 function _swift_base-test_using_command
     set -gx SAP_SHELL fish
+    set -gx SAP_SHELL_VERSION "$FISH_VERSION"
     set -l currentCommands (_swift_base-test_preprocessor (commandline -opc))
     set -l expectedCommands (string split " " $argv[1])
     set -l subcommands (string split " " $argv[2])
@@ -491,6 +498,8 @@ typeset -A opt_args
 
 _parent() {
     export SAP_SHELL=zsh
+    SAP_SHELL_VERSION="$(builtin emulate zsh -c 'printf %s "${ZSH_VERSION}"')"
+    export SAP_SHELL_VERSION
     integer ret=1
     local -a args
     args+=(
@@ -515,6 +524,8 @@ let bashHiddenCompletion = """
 
 _parent() {
     export SAP_SHELL=bash
+    SAP_SHELL_VERSION="$(IFS='.'; printf %s "${BASH_VERSINFO[*]}")"
+    export SAP_SHELL_VERSION
     cur="${COMP_WORDS[COMP_CWORD]}"
     prev="${COMP_WORDS[COMP_CWORD-1]}"
     COMPREPLY=()
@@ -545,6 +556,7 @@ end
 
 function _swift_parent_using_command
     set -gx SAP_SHELL fish
+    set -gx SAP_SHELL_VERSION "$FISH_VERSION"
     set -l currentCommands (_swift_parent_preprocessor (commandline -opc))
     set -l expectedCommands (string split " " $argv[1])
     set -l subcommands (string split " " $argv[2])
diff --git a/Tests/ArgumentParserUnitTests/RequestingCompletionScriptTests.swift b/Tests/ArgumentParserUnitTests/RequestingCompletionScriptTests.swift
index 024e4fc09..88e3d373f 100644
--- a/Tests/ArgumentParserUnitTests/RequestingCompletionScriptTests.swift
+++ b/Tests/ArgumentParserUnitTests/RequestingCompletionScriptTests.swift
@@ -216,6 +216,8 @@ typeset -A opt_args
 
 _base-test() {
     export SAP_SHELL=zsh
+    SAP_SHELL_VERSION="$(builtin emulate zsh -c 'printf %s "${ZSH_VERSION}"')"
+    export SAP_SHELL_VERSION
     integer ret=1
     local -a args
     args+=(
@@ -298,6 +300,8 @@ let bashRequestingBaseCompletions = """
 
 _base_test() {
     export SAP_SHELL=bash
+    SAP_SHELL_VERSION="$(IFS='.'; printf %s "${BASH_VERSINFO[*]}")"
+    export SAP_SHELL_VERSION
     cur="${COMP_WORDS[COMP_CWORD]}"
     prev="${COMP_WORDS[COMP_CWORD-1]}"
     COMPREPLY=()
@@ -391,6 +395,8 @@ typeset -A opt_args
 
 _escaped-command() {
     export SAP_SHELL=zsh
+    SAP_SHELL_VERSION="$(builtin emulate zsh -c 'printf %s "${ZSH_VERSION}"')"
+    export SAP_SHELL_VERSION
     integer ret=1
     local -a args
     args+=(
@@ -427,6 +433,7 @@ end
 
 function _swift_base-test_using_command
     set -gx SAP_SHELL fish
+    set -gx SAP_SHELL_VERSION "$FISH_VERSION"
     set -l currentCommands (_swift_base-test_preprocessor (commandline -opc))
     set -l expectedCommands (string split " " $argv[1])
     set -l subcommands (string split " " $argv[2])
@@ -529,6 +536,8 @@ typeset -A opt_args
 
 _parent() {
     export SAP_SHELL=zsh
+    SAP_SHELL_VERSION="$(builtin emulate zsh -c 'printf %s "${ZSH_VERSION}"')"
+    export SAP_SHELL_VERSION
     integer ret=1
     local -a args
     args+=(
@@ -553,6 +562,8 @@ let bashRequestingHiddenCompletion = """
 
 _parent() {
     export SAP_SHELL=bash
+    SAP_SHELL_VERSION="$(IFS='.'; printf %s "${BASH_VERSINFO[*]}")"
+    export SAP_SHELL_VERSION
     cur="${COMP_WORDS[COMP_CWORD]}"
     prev="${COMP_WORDS[COMP_CWORD-1]}"
     COMPREPLY=()
@@ -583,6 +594,7 @@ end
 
 function _swift_parent_using_command
     set -gx SAP_SHELL fish
+    set -gx SAP_SHELL_VERSION "$FISH_VERSION"
     set -l currentCommands (_swift_parent_preprocessor (commandline -opc))
     set -l expectedCommands (string split " " $argv[1])
     set -l subcommands (string split " " $argv[2])