Skip to content

Commit 2104b1a

Browse files
authored
Add Fish completion generator (#226)
1 parent b905f5c commit 2104b1a

File tree

6 files changed

+247
-4
lines changed

6 files changed

+247
-4
lines changed

Documentation/07 Completion Scripts.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Generate customized completion scripts for your shell of choice.
44

55
## Generating and Installing Completion Scripts
66

7-
Command-line tools that you build with `ArgumentParser` include a built-in option for generating completion scripts, with support for Bash and Z shell. To generate completions, run your command with the `--generate-completion-script` flag to generate completions for the autodetected shell, or with a value to generate completions for a specific shell.
7+
Command-line tools that you build with `ArgumentParser` include a built-in option for generating completion scripts, with support for Bash, Z shell, and Fish. To generate completions, run your command with the `--generate-completion-script` flag to generate completions for the autodetected shell, or with a value to generate completions for a specific shell.
88

99
```
1010
$ example --generate-completion-script bash
@@ -54,6 +54,10 @@ Without `bash-completion`, you'll need to source the completion script directly.
5454
source ~/.bash_completions/example.bash
5555
```
5656

57+
### Installing Fish Completions
58+
59+
Copy the completion script to any path listed in the environment variable `$fish_completion_path`. For example, a typical location is `~/.config/fish/completions/your_script.fish`.
60+
5761
## Customizing Completions
5862

5963
`ArgumentParser` provides default completions for any types that it can. For example, an `@Option` property that is a `CaseIterable` type will automatically have the correct values as completion suggestions.

Sources/ArgumentParser/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
add_library(ArgumentParser
22
Completions/BashCompletionsGenerator.swift
33
Completions/CompletionsGenerator.swift
4+
Completions/FishCompletionsGenerator.swift
45
Completions/ZshCompletionsGenerator.swift
56

67
"Parsable Properties/Argument.swift"

Sources/ArgumentParser/Completions/CompletionsGenerator.swift

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ public struct CompletionShell: RawRepresentable, Hashable, CaseIterable {
2424
/// Creates a new instance from the given string.
2525
public init?(rawValue: String) {
2626
switch rawValue {
27-
case "zsh", "bash":
27+
case "zsh", "bash", "fish":
2828
self.rawValue = rawValue
2929
default:
3030
return nil
@@ -37,6 +37,9 @@ public struct CompletionShell: RawRepresentable, Hashable, CaseIterable {
3737
/// An instance representing `bash`.
3838
public static var bash: CompletionShell { CompletionShell(rawValue: "bash")! }
3939

40+
/// An instance representing `fish`.
41+
public static var fish: CompletionShell { CompletionShell(rawValue: "fish")! }
42+
4043
/// Returns an instance representing the current shell, if recognized.
4144
public static func autodetected() -> CompletionShell? {
4245
// FIXME: This retrieves the user's preferred shell, not necessarily the one currently in use.
@@ -47,7 +50,7 @@ public struct CompletionShell: RawRepresentable, Hashable, CaseIterable {
4750

4851
/// An array of all supported shells for completion scripts.
4952
public static var allCases: [CompletionShell] {
50-
[.zsh, .bash]
53+
[.zsh, .bash, .fish]
5154
}
5255
}
5356

@@ -82,6 +85,8 @@ struct CompletionsGenerator {
8285
return ZshCompletionsGenerator.generateCompletionScript(command)
8386
case .bash:
8487
return BashCompletionsGenerator.generateCompletionScript(command)
88+
case .fish:
89+
return FishCompletionsGenerator.generateCompletionScript(command)
8590
default:
8691
fatalError("Invalid CompletionShell: \(shell)")
8792
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
struct FishCompletionsGenerator {
2+
static func generateCompletionScript(_ type: ParsableCommand.Type) -> String {
3+
let programName = type._commandName
4+
let helper = """
5+
function __fish_\(programName)_using_command
6+
set cmd (commandline -opc)
7+
if [ (count $cmd) -eq (count $argv) ]
8+
for i in (seq (count $argv))
9+
if [ $cmd[$i] != $argv[$i] ]
10+
return 1
11+
end
12+
end
13+
return 0
14+
end
15+
return 1
16+
end
17+
18+
"""
19+
20+
let completions = generateCompletions(commandChain: [programName], [type])
21+
.joined(separator: "\n")
22+
23+
return helper + completions
24+
}
25+
26+
static func generateCompletions(commandChain: [String], _ commands: [ParsableCommand.Type])
27+
-> [String]
28+
{
29+
let type = commands.last!
30+
let isRootCommand = commands.count == 1
31+
let programName = commandChain[0]
32+
var subcommands = type.configuration.subcommands
33+
34+
if !subcommands.isEmpty {
35+
if isRootCommand {
36+
subcommands.append(HelpCommand.self)
37+
}
38+
}
39+
40+
let prefix = "complete -c \(programName) -n '__fish_\(programName)_using_command"
41+
/// We ask each suggestion to produce 2 pieces of information
42+
/// - Parameters
43+
/// - ancestors: a list of "ancestor" which must be present in the current shell buffer for
44+
/// this suggetion to be considered. This could be a combination of (nested)
45+
/// subcommands and flags.
46+
/// - suggestion: text for the actual suggestion
47+
/// - Returns: A completion expression
48+
func complete(ancestors: [String], suggestion: String) -> String {
49+
"\(prefix) \(ancestors.joined(separator: " "))' \(suggestion)"
50+
}
51+
52+
let subcommandCompletions = subcommands.map { (subcommand: ParsableCommand.Type) -> String in
53+
let escapedAbstract = subcommand.configuration.abstract.fishEscape()
54+
let suggestion = "-f -a '\(subcommand._commandName)' -d '\(escapedAbstract)'"
55+
return complete(ancestors: commandChain, suggestion: suggestion)
56+
}
57+
58+
let argumentCompletions = ArgumentSet(type)
59+
.flatMap { $0.argumentSegments(commandChain) }
60+
.map { complete(ancestors: $0, suggestion: $1) }
61+
62+
let completionsFromSubcommands = subcommands.flatMap { subcommand in
63+
generateCompletions(commandChain: commandChain + [subcommand._commandName], [subcommand])
64+
}
65+
66+
return argumentCompletions + subcommandCompletions + completionsFromSubcommands
67+
}
68+
}
69+
70+
extension String {
71+
fileprivate func fishEscape() -> String {
72+
self.replacingOccurrences(of: "'", with: #"\'"#)
73+
}
74+
}
75+
76+
extension Name {
77+
fileprivate var asFishSuggestion: String {
78+
switch self {
79+
case .long(let longName):
80+
return "-l \(longName)"
81+
case .short(let shortName):
82+
return "-s \(shortName)"
83+
case .longWithSingleDash(let dashedName):
84+
return "-o \(dashedName)"
85+
}
86+
}
87+
88+
fileprivate var asFormattedFlag: String {
89+
switch self {
90+
case .long(let longName):
91+
return "--\(longName)"
92+
case .short(let shortName):
93+
return "-\(shortName)"
94+
case .longWithSingleDash(let dashedName):
95+
return "-\(dashedName)"
96+
}
97+
}
98+
}
99+
100+
extension ArgumentDefinition {
101+
fileprivate func argumentSegments(_ commandChain: [String]) -> [([String], String)] {
102+
var results = [([String], String)]()
103+
var formattedFlags = [String]()
104+
var flags = [String]()
105+
switch self.kind {
106+
case .positional:
107+
break
108+
case .named(let names):
109+
flags = names.map { $0.asFishSuggestion }
110+
formattedFlags = names.map { $0.asFormattedFlag }
111+
if !flags.isEmpty {
112+
// add these flags to suggestions
113+
var suggestion = "-f\(isNullary ? "" : " -r") \(flags.joined(separator: " "))"
114+
if let abstract = help.help?.abstract, !abstract.isEmpty {
115+
suggestion += " -d '\(abstract.fishEscape())'"
116+
}
117+
118+
results.append((commandChain, suggestion))
119+
}
120+
}
121+
122+
if isNullary {
123+
return results
124+
}
125+
126+
// each flag alternative gets its own completion suggestion
127+
for flag in formattedFlags {
128+
let ancestors = commandChain + [flag]
129+
switch self.completion.kind {
130+
case .default:
131+
break
132+
case .list(let list):
133+
results.append((ancestors, "-f -k -a '\(list.joined(separator: " "))'"))
134+
case .file(let extensions):
135+
let pattern = "*.{\(extensions.joined(separator: ","))}"
136+
results.append((ancestors, "-f -a '(for i in \(pattern); echo $i;end)'"))
137+
case .directory:
138+
results.append((ancestors, "-f -a '(__fish_complete_directories)'"))
139+
case .shellCommand(let shellCommand):
140+
results.append((ancestors, "-f -a '(\(shellCommand))'"))
141+
case .custom:
142+
let program = commandChain[0]
143+
let subcommands = commandChain.dropFirst().joined(separator: " ")
144+
let suggestion = "-f -a '(command \(program) ---completion \(subcommands) -- --custom (commandline -opc)[1..-1])'"
145+
results.append((ancestors, suggestion))
146+
}
147+
}
148+
149+
return results
150+
}
151+
}

Tests/ArgumentParserExampleTests/MathExampleTests.swift

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,12 @@ extension MathExampleTests {
184184
AssertExecuteCommand(
185185
command: "math --generate-completion-script zsh",
186186
expected: zshCompletionScriptText)
187+
AssertExecuteCommand(
188+
command: "math --generate-completion-script=fish",
189+
expected: fishCompletionScriptText)
190+
AssertExecuteCommand(
191+
command: "math --generate-completion-script fish",
192+
expected: fishCompletionScriptText)
187193
}
188194

189195
func testMath_CustomCompletion() {
@@ -526,3 +532,42 @@ _custom_completion() {
526532
527533
_math
528534
"""
535+
536+
private let fishCompletionScriptText = """
537+
function __fish_math_using_command
538+
set cmd (commandline -opc)
539+
if [ (count $cmd) -eq (count $argv) ]
540+
for i in (seq (count $argv))
541+
if [ $cmd[$i] != $argv[$i] ]
542+
return 1
543+
end
544+
end
545+
return 0
546+
end
547+
return 1
548+
end
549+
complete -c math -n '__fish_math_using_command math' -f -a 'add' -d 'Print the sum of the values.'
550+
complete -c math -n '__fish_math_using_command math' -f -a 'multiply' -d 'Print the product of the values.'
551+
complete -c math -n '__fish_math_using_command math' -f -a 'stats' -d 'Calculate descriptive statistics.'
552+
complete -c math -n '__fish_math_using_command math' -f -a 'help' -d 'Show subcommand help information.'
553+
complete -c math -n '__fish_math_using_command math add' -f -l hex-output -s x -d 'Use hexadecimal notation for the result.'
554+
complete -c math -n '__fish_math_using_command math multiply' -f -l hex-output -s x -d 'Use hexadecimal notation for the result.'
555+
complete -c math -n '__fish_math_using_command math stats' -f -a 'average' -d 'Print the average of the values.'
556+
complete -c math -n '__fish_math_using_command math stats' -f -a 'stdev' -d 'Print the standard deviation of the values.'
557+
complete -c math -n '__fish_math_using_command math stats' -f -a 'quantiles' -d 'Print the quantiles of the values (TBD).'
558+
complete -c math -n '__fish_math_using_command math stats' -f -a 'help' -d 'Show subcommand help information.'
559+
complete -c math -n '__fish_math_using_command math stats average' -f -r -l kind -d 'The kind of average to provide.'
560+
complete -c math -n '__fish_math_using_command math stats average --kind' -f -k -a 'mean median mode'
561+
complete -c math -n '__fish_math_using_command math stats quantiles' -f -l test-success-exit-code
562+
complete -c math -n '__fish_math_using_command math stats quantiles' -f -l test-failure-exit-code
563+
complete -c math -n '__fish_math_using_command math stats quantiles' -f -l test-validation-exit-code
564+
complete -c math -n '__fish_math_using_command math stats quantiles' -f -r -l test-custom-exit-code
565+
complete -c math -n '__fish_math_using_command math stats quantiles' -f -r -l file
566+
complete -c math -n '__fish_math_using_command math stats quantiles --file' -f -a '(for i in *.{txt,md}; echo $i;end)'
567+
complete -c math -n '__fish_math_using_command math stats quantiles' -f -r -l directory
568+
complete -c math -n '__fish_math_using_command math stats quantiles --directory' -f -a '(__fish_complete_directories)'
569+
complete -c math -n '__fish_math_using_command math stats quantiles' -f -r -l shell
570+
complete -c math -n '__fish_math_using_command math stats quantiles --shell' -f -a '(head -100 /usr/share/dict/words | tail -50)'
571+
complete -c math -n '__fish_math_using_command math stats quantiles' -f -r -l custom
572+
complete -c math -n '__fish_math_using_command math stats quantiles --custom' -f -a '(command math ---completion stats quantiles -- --custom (commandline -opc)[1..-1])'
573+
"""

Tests/ArgumentParserUnitTests/CompletionScriptTests.swift

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,19 @@ extension CompletionScriptTests {
6969
let script3 = Base.completionScript(for: .bash)
7070
XCTAssertEqual(bashBaseCompletions, script3)
7171
}
72+
73+
func testBase_Fish() throws {
74+
let script1 = try CompletionsGenerator(command: Base.self, shell: .fish)
75+
.generateCompletionScript()
76+
XCTAssertEqual(fishBaseCompletions, script1)
77+
78+
let script2 = try CompletionsGenerator(command: Base.self, shellName: "fish")
79+
.generateCompletionScript()
80+
XCTAssertEqual(fishBaseCompletions, script2)
81+
82+
let script3 = Base.completionScript(for: .fish)
83+
XCTAssertEqual(fishBaseCompletions, script3)
84+
}
7285
}
7386

7487
extension CompletionScriptTests {
@@ -224,4 +237,28 @@ _custom_completion() {
224237
_escaped
225238
"""
226239

227-
240+
private let fishBaseCompletions = """
241+
function __fish_base_using_command
242+
set cmd (commandline -opc)
243+
if [ (count $cmd) -eq (count $argv) ]
244+
for i in (seq (count $argv))
245+
if [ $cmd[$i] != $argv[$i] ]
246+
return 1
247+
end
248+
end
249+
return 0
250+
end
251+
return 1
252+
end
253+
complete -c base -n '__fish_base_using_command base' -f -r -l name -d 'The user\\'s name.'
254+
complete -c base -n '__fish_base_using_command base' -f -r -l kind
255+
complete -c base -n '__fish_base_using_command base --kind' -f -k -a 'one two custom-three'
256+
complete -c base -n '__fish_base_using_command base' -f -r -l other-kind
257+
complete -c base -n '__fish_base_using_command base --other-kind' -f -k -a '1 2 3'
258+
complete -c base -n '__fish_base_using_command base' -f -r -l path1
259+
complete -c base -n '__fish_base_using_command base --path1' -f -a '(for i in *.{}; echo $i;end)'
260+
complete -c base -n '__fish_base_using_command base' -f -r -l path2
261+
complete -c base -n '__fish_base_using_command base --path2' -f -a '(for i in *.{}; echo $i;end)'
262+
complete -c base -n '__fish_base_using_command base' -f -r -l path3
263+
complete -c base -n '__fish_base_using_command base --path3' -f -k -a 'a b c'
264+
"""

0 commit comments

Comments
 (0)