Skip to content

Commit ec562e5

Browse files
authored
Support async custom completion closures (#782)
Signed-off-by: Ross Goldberg <484615+rgoldberg@users.noreply.github.com>
1 parent 0104c39 commit ec562e5

File tree

8 files changed

+123
-30
lines changed

8 files changed

+123
-30
lines changed

Sources/ArgumentParser/Completions/BashCompletionsGenerator.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -380,7 +380,7 @@ extension [ParsableCommand.Type] {
380380
381381
"""
382382

383-
case .custom:
383+
case .custom, .customAsync:
384384
// Generate a call back into the command to retrieve a completions list
385385
return """
386386
\(addCompletionsFunctionName) -W\

Sources/ArgumentParser/Completions/FishCompletionsGenerator.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,7 @@ extension [ParsableCommand.Type] {
223223
results += ["-\(r)fa '(\(completeDirectoriesFunctionName))'"]
224224
case .shellCommand(let shellCommand):
225225
results += ["-\(r)fka '(\(shellCommand))'"]
226-
case .custom:
226+
case .custom, .customAsync:
227227
results += [
228228
"""
229229
-\(r)fka '(\

Sources/ArgumentParser/Completions/ZshCompletionsGenerator.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ extension [ParsableCommand.Type] {
204204
nil
205205
)
206206

207-
case .custom:
207+
case .custom, .customAsync:
208208
return (
209209
"{\(customCompleteFunctionName) \(arg.customCompletionCall(self)) \"${current_word_index}\" \"$(\(cursorIndexInCurrentWordFunctionName))\"}",
210210
nil

Sources/ArgumentParser/Parsable Properties/CompletionKind.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ public struct CompletionKind {
4141
case directory
4242
case shellCommand(String)
4343
case custom(@Sendable ([String], Int, String) -> [String])
44+
case customAsync(@Sendable ([String], Int, String) async -> [String])
4445
case customDeprecated(@Sendable ([String]) -> [String])
4546
}
4647

@@ -176,6 +177,17 @@ public struct CompletionKind {
176177
CompletionKind(kind: .custom(completion))
177178
}
178179

180+
/// Generate completions using the given async closure.
181+
///
182+
/// The same as `custom(@Sendable @escaping ([String], Int, String) -> [String])`,
183+
/// except that the closure is asynchronous.
184+
@available(macOS 10.15, macCatalyst 13, iOS 13, tvOS 13, watchOS 6, *)
185+
public static func custom(
186+
_ completion: @Sendable @escaping ([String], Int, String) async -> [String]
187+
) -> CompletionKind {
188+
CompletionKind(kind: .customAsync(completion))
189+
}
190+
179191
/// Deprecated; only kept for backwards compatibility.
180192
///
181193
/// The same as `custom(@Sendable @escaping ([String], Int, String) -> [String])`,

Sources/ArgumentParser/Parsing/CommandParser.swift

Lines changed: 93 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,12 @@
1010
//===----------------------------------------------------------------------===//
1111

1212
#if swift(>=6.0)
13+
@preconcurrency private import class Dispatch.DispatchSemaphore
14+
internal import class Foundation.NSLock
1315
internal import class Foundation.ProcessInfo
1416
#else
17+
@preconcurrency import class Dispatch.DispatchSemaphore
18+
import class Foundation.NSLock
1519
import class Foundation.ProcessInfo
1620
#endif
1721

@@ -447,37 +451,20 @@ extension CommandParser {
447451
let completions: [String]
448452
switch argument.completion.kind {
449453
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 arg = args.popFirst(),
460-
let cursorIndexWithinCompletingArgument = Int(arg)
461-
else {
462-
throw ParserError.invalidState
463-
}
464-
465-
let completingPrefix: String
466-
if let completingArgument = args.last {
467-
completingPrefix = String(
468-
completingArgument.prefix(cursorIndexWithinCompletingArgument)
469-
)
470-
} else if cursorIndexWithinCompletingArgument == 0 {
471-
completingPrefix = ""
472-
} else {
473-
throw ParserError.invalidState
474-
}
475-
454+
let (args, completingArgumentIndex, completingPrefix) =
455+
try parseCustomCompletionArguments(from: args)
476456
completions = complete(
477-
Array(args),
457+
args,
478458
completingArgumentIndex,
479459
completingPrefix
480460
)
461+
case .customAsync(let complete):
462+
if #available(macOS 10.15, macCatalyst 13, iOS 13, tvOS 13, watchOS 6, *)
463+
{
464+
completions = try asyncCustomCompletions(from: args, complete: complete)
465+
} else {
466+
throw ParserError.invalidState
467+
}
481468
case .customDeprecated(let complete):
482469
completions = complete(args)
483470
default:
@@ -494,6 +481,85 @@ extension CommandParser {
494481
}
495482
}
496483

484+
private func parseCustomCompletionArguments(
485+
from args: [String]
486+
) throws -> ([String], Int, String) {
487+
var args = args.dropFirst(0)
488+
guard
489+
let s = args.popFirst(),
490+
let completingArgumentIndex = Int(s)
491+
else {
492+
throw ParserError.invalidState
493+
}
494+
495+
guard
496+
let arg = args.popFirst(),
497+
let cursorIndexWithinCompletingArgument = Int(arg)
498+
else {
499+
throw ParserError.invalidState
500+
}
501+
502+
let completingPrefix: String
503+
if let completingArgument = args.last {
504+
completingPrefix = String(
505+
completingArgument.prefix(cursorIndexWithinCompletingArgument)
506+
)
507+
} else if cursorIndexWithinCompletingArgument == 0 {
508+
completingPrefix = ""
509+
} else {
510+
throw ParserError.invalidState
511+
}
512+
513+
return (Array(args), completingArgumentIndex, completingPrefix)
514+
}
515+
516+
@available(macOS 10.15, macCatalyst 13, iOS 13, tvOS 13, watchOS 6, *)
517+
private func asyncCustomCompletions(
518+
from args: [String],
519+
complete: @escaping @Sendable ([String], Int, String) async -> [String]
520+
) throws -> [String] {
521+
let (args, completingArgumentIndex, completingPrefix) =
522+
try parseCustomCompletionArguments(from: args)
523+
524+
let completionsBox = SendableBox<[String]>([])
525+
let semaphore = DispatchSemaphore(value: 0)
526+
527+
Task {
528+
completionsBox.value = await complete(
529+
args,
530+
completingArgumentIndex,
531+
completingPrefix
532+
)
533+
semaphore.signal()
534+
}
535+
536+
semaphore.wait()
537+
return completionsBox.value
538+
}
539+
540+
// Helper class to make values sendable across concurrency boundaries
541+
private final class SendableBox<T>: @unchecked Sendable {
542+
private let lock = NSLock()
543+
private var _value: T
544+
545+
init(_ value: T) {
546+
self._value = value
547+
}
548+
549+
var value: T {
550+
get {
551+
lock.lock()
552+
defer { lock.unlock() }
553+
return _value
554+
}
555+
set {
556+
lock.lock()
557+
defer { lock.unlock() }
558+
_value = newValue
559+
}
560+
}
561+
}
562+
497563
// MARK: Building Command Stacks
498564

499565
extension CommandParser {

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 .customAsync(_):
228+
self = .customAsync
227229
case .customDeprecated(_):
228230
self = .customDeprecated
229231
}

Sources/ArgumentParserToolInfo/ToolInfo.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,8 @@ public struct ArgumentInfoV0: Codable, Hashable {
151151
case shellCommand(command: String)
152152
/// Generate completions using the given three-parameter closure.
153153
case custom
154+
/// Generate completions using the given async three-parameter closure.
155+
case customAsync
154156
/// Generate completions using the given one-parameter closure.
155157
@available(*, deprecated, message: "Use custom instead.")
156158
case customDeprecated

Tests/ArgumentParserUnitTests/CompletionScriptTests.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ private func candidates(prefix: String) -> [String] {
2727
}
2828
}
2929

30+
private func candidatesAsync(prefix: String) async -> [String] {
31+
candidates(prefix: prefix)
32+
}
33+
3034
final class CompletionScriptTests: XCTestCase {}
3135

3236
// swift-format-ignore: AlwaysUseLowerCamelCase
@@ -168,6 +172,11 @@ extension CompletionScriptTests {
168172
@Argument(completion: .custom { _, _, _ in candidates(prefix: "h") })
169173
var four: String
170174
}
175+
176+
@Argument(
177+
completion: .custom { _, _, _ in await candidatesAsync(prefix: "j") }
178+
)
179+
var five: String
171180
}
172181

173182
func assertCustomCompletion(
@@ -217,6 +226,8 @@ extension CompletionScriptTests {
217226
"-z", shell: shell, prefix: "g", file: file, line: line)
218227
try assertCustomCompletion(
219228
"nested.four", shell: shell, prefix: "h", file: file, line: line)
229+
try assertCustomCompletion(
230+
"five", shell: shell, prefix: "j", file: file, line: line)
220231

221232
XCTAssertThrowsError(
222233
try assertCustomCompletion("--bad", shell: shell, file: file, line: line))

0 commit comments

Comments
 (0)