Skip to content

Commit 42585ad

Browse files
authored
Add 2 index arguments to custom shell completion calls (#763)
They indicate to the Swift custom completion function: 1. the word for which completions are being requested. 2. the location of the cursor within that word.
1 parent cb5670a commit 42585ad

22 files changed

+362
-61
lines changed

Examples/math/Math.swift

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -198,10 +198,17 @@ extension Math.Statistics {
198198
var oneOfFour: String?
199199

200200
@Argument(
201-
completion: .custom { _ in ["alabaster", "breakfast", "crunch", "crash"] }
201+
completion: .custom { _, _, _ in
202+
["alabaster", "breakfast", "crunch", "crash"]
203+
}
202204
)
203205
var customArg: String?
204206

207+
@Argument(
208+
completion: .custom { _ in ["alabaster", "breakfast", "crunch", "crash"] }
209+
)
210+
var customDeprecatedArg: String?
211+
205212
@Argument(help: "A group of floating-point values to operate on.")
206213
var values: [Double] = []
207214

@@ -222,12 +229,16 @@ extension Math.Statistics {
222229
var directory: String?
223230

224231
@Option(
225-
completion: .shellCommand("head -100 /usr/share/dict/words | tail -50"))
232+
completion: .shellCommand("head -100 /usr/share/dict/words | tail -50")
233+
)
226234
var shell: String?
227235

228236
@Option(completion: .custom(customCompletion))
229237
var custom: String?
230238

239+
@Option(completion: .custom(customDeprecatedCompletion))
240+
var customDeprecated: String?
241+
231242
func validate() throws {
232243
if testSuccessExitCode {
233244
throw ExitCode.success
@@ -248,7 +259,13 @@ extension Math.Statistics {
248259
}
249260
}
250261

251-
func customCompletion(_ s: [String]) -> [String] {
262+
func customCompletion(_ s: [String], _: Int, _: Int) -> [String] {
263+
(s.last ?? "").starts(with: "a")
264+
? ["aardvark", "aaaaalbert"]
265+
: ["hello", "helicopter", "heliotrope"]
266+
}
267+
268+
func customDeprecatedCompletion(_ s: [String]) -> [String] {
252269
(s.last ?? "").starts(with: "a")
253270
? ["aardvark", "aaaaalbert"]
254271
: ["hello", "helicopter", "heliotrope"]

Sources/ArgumentParser/Completions/BashCompletionsGenerator.swift

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,22 @@ extension [ParsableCommand.Type] {
2121
return """
2222
#!/bin/bash
2323
24+
\(cursorIndexInCurrentWordFunctionName)() {
25+
local remaining="${COMP_LINE}"
26+
27+
local word
28+
for word in "${COMP_WORDS[@]::COMP_CWORD}"; do
29+
remaining="${remaining##*([[:space:]])"${word}"*([[:space:]])}"
30+
done
31+
32+
local -ir index="$((COMP_POINT - ${#COMP_LINE} + ${#remaining}))"
33+
if [[ "${index}" -le 0 ]]; then
34+
printf 0
35+
else
36+
printf %s "${index}"
37+
fi
38+
}
39+
2440
# positional arguments:
2541
#
2642
# - 1: the current (sub)command's count of positional arguments
@@ -365,6 +381,16 @@ extension [ParsableCommand.Type] {
365381
"""
366382

367383
case .custom:
384+
// Generate a call back into the command to retrieve a completions list
385+
return """
386+
\(addCompletionsFunctionName) -W\
387+
"$(\(customCompleteFunctionName) \(arg.customCompletionCall(self))\
388+
"${COMP_CWORD}"\
389+
"$(\(cursorIndexInCurrentWordFunctionName))")"
390+
391+
"""
392+
393+
case .customDeprecated:
368394
// Generate a call back into the command to retrieve a completions list
369395
return """
370396
\(addCompletionsFunctionName) -W\
@@ -374,6 +400,10 @@ extension [ParsableCommand.Type] {
374400
}
375401
}
376402

403+
private var cursorIndexInCurrentWordFunctionName: String {
404+
"_\(prefix(1).completionFunctionName().shellEscapeForVariableName())_cursor_index_in_current_word"
405+
}
406+
377407
private var offerFlagsOptionsFunctionName: String {
378408
"_\(prefix(1).completionFunctionName().shellEscapeForVariableName())_offer_flags_options"
379409
}

Sources/ArgumentParser/Completions/FishCompletionsGenerator.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,15 @@ extension [ParsableCommand.Type] {
224224
case .shellCommand(let shellCommand):
225225
results += ["-\(r)fka '(\(shellCommand))'"]
226226
case .custom:
227+
results += [
228+
"""
229+
-\(r)fka '(\
230+
\(customCompletionFunctionName) \(arg.customCompletionCall(self)) \
231+
(count (\(tokensFunctionName) -pc)) (\(tokensFunctionName) -tC)\
232+
)'
233+
"""
234+
]
235+
case .customDeprecated:
227236
results += [
228237
"""
229238
-\(r)fka '(\(customCompletionFunctionName) \(arg.customCompletionCall(self)))'

Sources/ArgumentParser/Completions/ZshCompletionsGenerator.swift

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,20 @@ extension [ParsableCommand.Type] {
2828
2929
\(customCompleteFunctionName)() {
3030
local -a completions
31-
completions=("${(@f)"$("${@}")"}")
31+
completions=("${(@f)"$("${command_name}" "${@}" "${command_line[@]}")"}")
3232
if [[ "${#completions[@]}" -gt 1 ]]; then
3333
\(completeFunctionName) "${completions[@]:0:-1}"
3434
fi
3535
}
3636
37+
\(cursorIndexInCurrentWordFunctionName)() {
38+
if [[ -z "${QIPREFIX}${IPREFIX}${PREFIX}" ]]; then
39+
printf 0
40+
else
41+
printf %s "${#${(z)LBUFFER}[-1]}"
42+
fi
43+
}
44+
3745
\(completionFunctions)\
3846
\(completionFunctionName())
3947
"""
@@ -107,6 +115,7 @@ extension [ParsableCommand.Type] {
107115
108116
local -r command_name="${words[1]}"
109117
local -ar command_line=("${words[@]}")
118+
local -ir current_word_index="$((CURRENT - 1))"
110119
111120
112121
"""
@@ -192,7 +201,13 @@ extension [ParsableCommand.Type] {
192201

193202
case .custom:
194203
return (
195-
"{\(customCompleteFunctionName) \"${command_name}\" \(arg.customCompletionCall(self)) \"${command_line[@]}\"}",
204+
"{\(customCompleteFunctionName) \(arg.customCompletionCall(self)) \"${current_word_index}\" \"$(\(cursorIndexInCurrentWordFunctionName))\"}",
205+
nil
206+
)
207+
208+
case .customDeprecated:
209+
return (
210+
"{\(customCompleteFunctionName) \(arg.customCompletionCall(self))}",
196211
nil
197212
)
198213
}
@@ -218,6 +233,10 @@ extension [ParsableCommand.Type] {
218233
// Precondition: first is guaranteed to be non-empty
219234
"__\(first!._commandName)_custom_complete"
220235
}
236+
237+
private var cursorIndexInCurrentWordFunctionName: String {
238+
"__\(first?._commandName ?? "")_cursor_index_in_current_word"
239+
}
221240
}
222241

223242
extension String {

Sources/ArgumentParser/Parsable Properties/CompletionKind.swift

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ public struct CompletionKind {
4040
case file(extensions: [String])
4141
case directory
4242
case shellCommand(String)
43-
case custom(@Sendable ([String]) -> [String])
43+
case custom(@Sendable ([String], Int, Int) -> [String])
44+
case customDeprecated(@Sendable ([String]) -> [String])
4445
}
4546

4647
internal var kind: Kind
@@ -125,6 +126,12 @@ public struct CompletionKind {
125126
/// passed to Swift as `"abc\\""def"` (i.e. the Swift String's contents would
126127
/// include all 4 of the double quotes and the 2 consecutive backslashes).
127128
///
129+
/// The first of the two `Int` arguments is the 0-based index of the word
130+
/// for which completions are being requested within the given `[String]`.
131+
///
132+
/// The second of the two `Int` arguments is the 0-based index of the shell
133+
/// cursor within the word for which completions are being requested.
134+
///
128135
/// ### bash
129136
///
130137
/// In bash 3-, a process substitution (`<(…)`) in the command line prevents
@@ -151,15 +158,40 @@ public struct CompletionKind {
151158
/// example, the shell word `"abc\\""def"` would be passed to Swift as
152159
/// `abc\def`. This is fixed in fish 4+.
153160
///
161+
/// In fish 3-, the cursor index is provided based on the verbatim word, not
162+
/// based on the unquoted word, so it can be inconsistent with the unquoted
163+
/// word that is supplied to Swift. This problem does not exist in fish 4+.
164+
///
154165
/// ### zsh
155166
///
156167
/// In zsh, redirects (both their symbol and source/target) are omitted.
168+
///
169+
/// In zsh, if the cursor is between a backslash and the character that it
170+
/// escapes, the shell cursor index will be indicated as after the escaped
171+
/// character, not as after the backslash.
157172
@preconcurrency
158173
public static func custom(
159-
_ completion: @Sendable @escaping ([String]) -> [String]
174+
_ completion: @Sendable @escaping ([String], Int, Int) -> [String]
160175
) -> CompletionKind {
161176
CompletionKind(kind: .custom(completion))
162177
}
178+
179+
/// Deprecated; only kept for backwards compatibility.
180+
///
181+
/// The same as `custom(@Sendable @escaping ([String], Int, Int) -> [String])`,
182+
/// except that index arguments are not supplied.
183+
@preconcurrency
184+
@available(
185+
*,
186+
deprecated,
187+
message:
188+
"Provide a three-parameter closure instead. See custom(@Sendable @escaping ([String], Int, Int) -> [String])."
189+
)
190+
public static func custom(
191+
_ completion: @Sendable @escaping ([String]) -> [String]
192+
) -> CompletionKind {
193+
CompletionKind(kind: .customDeprecated(completion))
194+
}
163195
}
164196

165197
extension CompletionKind: Sendable {}

Sources/ArgumentParser/Parsing/CommandParser.swift

Lines changed: 55 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -373,7 +373,12 @@ extension CommandParser {
373373
func handleCustomCompletion(_ arguments: [String]) throws {
374374
// Completion functions use a custom format:
375375
//
376-
// <command> ---completion [<subcommand> ...] -- <argument-name> [<completion-text>]
376+
// <command> ---completion [<subcommand> ...] -- <argument-name> <argument-index> <cursor-index> [<argument> ...]
377+
//
378+
// <argument-index> is the 0-based index of the <argument> for which completions are being requested.
379+
//
380+
// <cursor-index> is the 0-based index of the character within the <argument> before which the cursor is located.
381+
// For an <argument> whose length is n, if the cursor is after the last element, <cursor-index> will be set to n.
377382
//
378383
// The triple-dash prefix makes '---completion' invalid syntax for regular
379384
// arguments, so it's safe to use for this internal purpose.
@@ -395,35 +400,38 @@ extension CommandParser {
395400
guard let argToMatch = args.popFirst() else {
396401
throw ParserError.invalidState
397402
}
398-
// Completion text is optional here
399-
let completionValues = Array(args)
400403

401404
// Generate the argument set and parse the argument to find in the set
402405
let argset = ArgumentSet(current.element, visibility: .private, parent: nil)
403406
guard let parsedArgument = try parseIndividualArg(argToMatch, at: 0).first
404407
else { throw ParserError.invalidState }
405408

406-
// Look up the specified argument and retrieve its custom completion function
407-
let completionFunction: ([String]) -> [String]
408-
409+
// Look up the specified argument, then retrieve & run its custom completion function
409410
switch parsedArgument.value {
410411
case .option(let parsed):
411-
guard let matchedArgument = argset.first(matching: parsed),
412-
case .custom(let f) = matchedArgument.completion.kind
413-
else { throw ParserError.invalidState }
414-
completionFunction = f
412+
guard let matchedArgument = argset.first(matching: parsed) else {
413+
throw ParserError.invalidState
414+
}
415+
try customComplete(matchedArgument, forArguments: Array(args))
415416

416417
case .value(let str):
417-
guard let key = InputKey(fullPathString: str),
418-
let matchedArgument = argset.firstPositional(withKey: key),
419-
case .custom(let f) = matchedArgument.completion.kind
420-
else { throw ParserError.invalidState }
421-
completionFunction = f
418+
guard
419+
let key = InputKey(fullPathString: str),
420+
let matchedArgument = argset.firstPositional(withKey: key)
421+
else {
422+
throw ParserError.invalidState
423+
}
424+
try customComplete(matchedArgument, forArguments: Array(args))
422425

423426
case .terminator:
424427
throw ParserError.invalidState
425428
}
429+
}
426430

431+
private func customComplete(
432+
_ argument: ArgumentDefinition,
433+
forArguments args: [String]
434+
) throws {
427435
let environment = ProcessInfo.processInfo.environment
428436
if let completionShellName = environment[
429437
CompletionShell.shellEnvironmentVariableName]
@@ -436,10 +444,38 @@ extension CommandParser {
436444
$0 = environment[CompletionShell.shellVersionEnvironmentVariableName]
437445
}
438446

447+
let completions: [String]
448+
switch argument.completion.kind {
449+
case .custom(let complete):
450+
var args = args.dropFirst(0)
451+
guard
452+
let s = args.popFirst(),
453+
let completingArgumentIndex = Int(s)
454+
else {
455+
throw ParserError.invalidState
456+
}
457+
458+
guard
459+
let s = args.popFirst(),
460+
let cursorIndexWithinCompletingArgument = Int(s)
461+
else {
462+
throw ParserError.invalidState
463+
}
464+
465+
completions = complete(
466+
Array(args),
467+
completingArgumentIndex,
468+
cursorIndexWithinCompletingArgument
469+
)
470+
case .customDeprecated(let complete):
471+
completions = complete(args)
472+
default:
473+
throw ParserError.invalidState
474+
}
475+
439476
// Parsing and retrieval successful! We don't want to continue with any
440477
// other parsing here, so after printing the result of the completion
441478
// function, exit with a success code.
442-
let completions = completionFunction(completionValues)
443479
throw ParserError.completionScriptCustomResponse(
444480
CompletionShell.requesting?.format(completions: completions)
445481
?? completions.joined(separator: "\n")
@@ -472,9 +508,9 @@ extension CommandParser {
472508
return result
473509
}
474510

475-
func commandStack(for subcommand: ParsableCommand.Type)
476-
-> [ParsableCommand.Type]
477-
{
511+
func commandStack(
512+
for subcommand: ParsableCommand.Type
513+
) -> [ParsableCommand.Type] {
478514
let path = commandTree.path(to: subcommand)
479515
return path.isEmpty
480516
? [commandTree.element]

Sources/ArgumentParser/Usage/DumpHelpGenerator.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,8 @@ extension ArgumentInfoV0.CompletionKindV0 {
224224
self = .shellCommand(command: command)
225225
case .custom(_):
226226
self = .custom
227+
case .customDeprecated(_):
228+
self = .customDeprecated
227229
}
228230
}
229231
}

Sources/ArgumentParserToolInfo/ToolInfo.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,8 +149,11 @@ public struct ArgumentInfoV0: Codable, Hashable {
149149
case directory
150150
/// Call the given shell command to generate completions.
151151
case shellCommand(command: String)
152-
/// Generate completions using the given closure.
152+
/// Generate completions using the given closure including index arguments.
153153
case custom
154+
/// Generate completions using the given closure without index arguments.
155+
@available(*, deprecated, message: "Use custom instead.")
156+
case customDeprecated
154157
}
155158

156159
/// Kind of argument the ArgumentInfo describes.

0 commit comments

Comments
 (0)