Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .unacceptablelanguageignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
# Ignore this file which references the "master" branch of another repo
Tools/generate-manual/MDoc/MDocMacro.swift

# POSIX process cleanup uses the os.kill API.
Tests/ArgumentParserExampleTests/CompletionTests/test_completions.py
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ var package = Package(
name: "ArgumentParserExampleTests",
dependencies: ["ArgumentParserTestHelpers"],
exclude: ["Snapshots"],
resources: [.copy("CountLinesTest.txt")]),
resources: [.copy("CountLinesTest.txt"), .copy("CompletionTests")]),
.testTarget(
name: "ArgumentParserGenerateDoccReferenceTests",
dependencies: ["ArgumentParserTestHelpers"],
Expand Down
40 changes: 22 additions & 18 deletions Sources/ArgumentParser/Completions/BashCompletionsGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -209,22 +209,23 @@ extension CommandInfoV0 {
declareTopLevelArray = ""
}

let positionalArguments = positionalArguments

let arguments = arguments ?? []
let arguments = completionArguments
let positionalArguments = arguments.filter {
$0.argument.kind == .positional
}

let flags = arguments.filter { $0.kind == .flag }
let options = arguments.filter { $0.kind == .option }
let flags = arguments.filter { $0.argument.kind == .flag }
let options = arguments.filter { $0.argument.kind == .option }
if !flags.flatMap(\.completionWords).isEmpty
|| !options.flatMap(\.completionWords).isEmpty
{
result += """
\(declareTopLevelArray)repeating_flags=(\(flags.filter(\.isRepeating).flatMap(\.completionWords).joined(separator: " ")))
\(declareTopLevelArray)non_repeating_flags=(\(flags.filter { !$0.isRepeating }.flatMap(\.completionWords).joined(separator: " ")))
\(declareTopLevelArray)repeating_options=(\(options.filter(\.isRepeating).flatMap(\.completionWords).joined(separator: " ")))
\(declareTopLevelArray)non_repeating_options=(\(options.filter { !$0.isRepeating }.flatMap(\.completionWords).joined(separator: " ")))
\(declareTopLevelArray)repeating_flags=(\(flags.filter(\.argument.isRepeating).flatMap(\.completionWords).joined(separator: " ")))
\(declareTopLevelArray)non_repeating_flags=(\(flags.filter { !$0.argument.isRepeating }.flatMap(\.completionWords).joined(separator: " ")))
\(declareTopLevelArray)repeating_options=(\(options.filter(\.argument.isRepeating).flatMap(\.completionWords).joined(separator: " ")))
\(declareTopLevelArray)non_repeating_options=(\(options.filter { !$0.argument.isRepeating }.flatMap(\.completionWords).joined(separator: " ")))
\(offerFlagsOptionsFunctionName) \
\(positionalArguments.contains { $0.isRepeating } ? -1 : positionalArguments.count)
\(positionalArguments.contains { $0.argument.isRepeating } ? -1 : positionalArguments.count)

"""
}
Expand All @@ -233,7 +234,7 @@ extension CommandInfoV0 {
// If there aren't any, skip the case block altogether.
let optionHandlers =
options.compactMap { arg in
guard arg.kind != .flag else { return nil }
guard arg.argument.kind != .flag else { return nil }
let words = arg.completionWords
guard !words.isEmpty else { return nil }
return """
Expand Down Expand Up @@ -263,7 +264,7 @@ extension CommandInfoV0 {
return nil as String?
}

if arg.isRepeating {
if arg.argument.isRepeating {
encounteredRepeatingPositional = true
}

Expand Down Expand Up @@ -327,7 +328,10 @@ extension CommandInfoV0 {
}

/// Returns the completions that can follow the given argument's `--name`.
private func valueCompletion(_ arg: ArgumentInfoV0) -> String {
private func valueCompletion(_ completionArgument: CompletionArgument)
-> String
{
let arg = completionArgument.argument
switch arg.completionKind {
case .none:
return ""
Expand Down Expand Up @@ -369,7 +373,7 @@ extension CommandInfoV0 {
case .custom, .customAsync:
return """
\(addCompletionsFunctionName) -W\
"$(\(customCompleteFunctionName) \(arg.commonCustomCompletionCall(command: self))\
"$(\(customCompleteFunctionName) \(arg.commonCustomCompletionCall(command: completionArgument.command))\
"${COMP_CWORD}"\
"$(\(cursorIndexInCurrentWordFunctionName))")"

Expand All @@ -378,7 +382,7 @@ extension CommandInfoV0 {
case .customDeprecated:
return """
\(addCompletionsFunctionName) -W\
"$(\(customCompleteFunctionName) \(arg.commonCustomCompletionCall(command: self)))"
"$(\(customCompleteFunctionName) \(arg.commonCustomCompletionCall(command: completionArgument.command)))"

"""
}
Expand All @@ -401,11 +405,11 @@ extension CommandInfoV0 {
}
}

extension ArgumentInfoV0 {
extension CommandInfoV0.CompletionArgument {
/// Returns the different completion names for this argument.
fileprivate var completionWords: [String] {
shouldDisplay
? (names ?? []).map { $0.commonCompletionSynopsisString() }
argument.shouldDisplay
? (argument.names ?? []).map { $0.commonCompletionSynopsisString() }
: []
}
}
38 changes: 38 additions & 0 deletions Sources/ArgumentParser/Completions/CompletionsGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,11 @@ extension String {
}

extension CommandInfoV0 {
struct CompletionArgument {
var argument: ArgumentInfoV0
var command: CommandInfoV0
}

var commandContext: [String] {
(superCommands ?? []) + [commandName]
}
Expand All @@ -231,6 +236,39 @@ extension CommandInfoV0 {
(arguments ?? []).filter { $0.kind == .positional }
}

var completionArguments: [CompletionArgument] {
var result = (arguments ?? []).map {
CompletionArgument(argument: $0, command: self)
}
var names = Set(result.flatMap { $0.argument.names ?? [] })

guard
let defaultSubcommand,
let defaultCommand = subcommands?.first(where: {
$0.commandName == defaultSubcommand
})
else { return result }

for var completionArgument in defaultCommand.completionArguments {
guard let argumentNames = completionArgument.argument.names else {
continue
}

let uniqueNames = argumentNames.filter { names.insert($0).inserted }
guard !uniqueNames.isEmpty else { continue }

completionArgument.argument.names = uniqueNames
if let preferredName = completionArgument.argument.preferredName,
!uniqueNames.contains(preferredName)
{
completionArgument.argument.preferredName = uniqueNames.first
}
result.append(completionArgument)
}

return result
}

var completionFunctionName: String {
"_" + commandContext.joined(separator: "_")
}
Expand Down
31 changes: 19 additions & 12 deletions Sources/ArgumentParser/Completions/FishCompletionsGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ extension CommandInfoV0 {
case '\(commandName)'
\(parseSubcommandFunctionName) \(positionalArgumentCountArguments) \(
completableArguments
.compactMap(\.optionSpec)
.compactMap { $0.argument.optionSpec }
.map { "'\($0.fishEscapeForSingleQuotedString())'" }
.joined(separator: separator)
)\(
Expand All @@ -128,7 +128,8 @@ extension CommandInfoV0 {
var positionalComparison = "-eq"
let argumentCompletions =
completableArguments
.compactMap { arg in
.compactMap { completionArgument in
let arg = completionArgument.argument
if arg.kind == .positional {
guard positionalComparison == "-eq" else {
return nil as String?
Expand All @@ -152,7 +153,7 @@ extension CommandInfoV0 {
\(shouldOfferCompletionsForFlagsOrOptionsFunctionName) "\(commandContext.joined(separator: separator))"\
\((arg.isRepeating ? [] : arg.names ?? []).map { " \($0.name)" }.sorted().joined())
"""
)' \(argumentSegments(arg).joined(separator: separator))
)' \(argumentSegments(completionArgument).joined(separator: separator))
"""
}

Expand All @@ -169,14 +170,18 @@ extension CommandInfoV0 {
+ subcommands.flatMap(\.completions)
}

private var completableArguments: [ArgumentInfoV0] {
(arguments ?? []).filter { arg in
arg.shouldDisplay
&& (arg.completionKind != nil || arg.names?.isEmpty == false)
private var completableArguments: [CompletionArgument] {
completionArguments.filter { completionArgument in
completionArgument.argument.shouldDisplay
&& (completionArgument.argument.completionKind != nil
|| completionArgument.argument.names?.isEmpty == false)
}
}

private func argumentSegments(_ arg: ArgumentInfoV0) -> [String] {
private func argumentSegments(
_ completionArgument: CompletionArgument
) -> [String] {
let arg = completionArgument.argument
var results: [String] = []
if let names = arg.names, !names.isEmpty {
results += names.map(\.asCompleteArgument)
Expand Down Expand Up @@ -226,21 +231,23 @@ extension CommandInfoV0 {
case .custom, .customAsync:
"""
-\(r)fka '(\
\(customCompletionFunctionName) \(arg.commonCustomCompletionCall(command: self)) \
\(customCompletionFunctionName) \(arg.commonCustomCompletionCall(command: completionArgument.command)) \
(count (\(tokensFunctionName) -pc)) (\(tokensFunctionName) -tC)\
)'
"""
case .customDeprecated:
"-\(r)fka '(\(customCompletionFunctionName) \(arg.commonCustomCompletionCall(command: self)))'"
"-\(r)fka '(\(customCompletionFunctionName) \(arg.commonCustomCompletionCall(command: completionArgument.command)))'"
}
completions.map { results.append($0) }
return results
}

var positionalArgumentCountArguments: String {
let positionalArguments = positionalArguments
let positionalArguments = completionArguments.filter {
$0.argument.kind == .positional
}
return """
\(positionalArguments.contains(where: { $0.isRepeating }) ? "-r " : "")\(positionalArguments.count)
\(positionalArguments.contains(where: { $0.argument.isRepeating }) ? "-r " : "")\(positionalArguments.count)
"""
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,9 @@ extension CommandInfoV0 {
let functionName = completionFunctionName

var repeatingPositionalIndicator = ""
let argumentSpecsAndSetupScripts = (arguments ?? []).compactMap { arg in
let argumentSpecsAndSetupScripts = completionArguments.compactMap {
completionArgument in
let arg = completionArgument.argument
guard arg.shouldDisplay else {
return nil as (argumentSpec: String, setupScript: String?)?
}
Expand Down Expand Up @@ -98,7 +100,8 @@ extension CommandInfoV0 {

switch arg.kind {
case .option, .positional:
let (argumentAction, setupScript) = argumentActionAndSetupScript(arg)
let (argumentAction, setupScript) = argumentActionAndSetupScript(
completionArgument)
return (
"'\(line):\(arg.valueName?.zshEscapeForSingleQuotedOptionSpec() ?? ""):\(argumentAction)'",
setupScript
Expand Down Expand Up @@ -185,8 +188,9 @@ extension CommandInfoV0 {

/// Returns the zsh "action" for an argument completion string.
private func argumentActionAndSetupScript(
_ arg: ArgumentInfoV0
_ completionArgument: CompletionArgument
) -> (argumentAction: String, setupScript: String?) {
let arg = completionArgument.argument
switch arg.completionKind {
case .none:
return ("", nil)
Expand Down Expand Up @@ -218,13 +222,13 @@ extension CommandInfoV0 {

case .custom, .customAsync:
return (
"{\(customCompleteFunctionName) \(arg.commonCustomCompletionCall(command: self)) \"${current_word_index}\" \"$(\(cursorIndexInCurrentWordFunctionName))\"}",
"{\(customCompleteFunctionName) \(arg.commonCustomCompletionCall(command: completionArgument.command)) \"${current_word_index}\" \"$(\(cursorIndexInCurrentWordFunctionName))\"}",
nil
)

case .customDeprecated:
return (
"{\(customCompleteFunctionName) \(arg.commonCustomCompletionCall(command: self))}",
"{\(customCompleteFunctionName) \(arg.commonCustomCompletionCall(command: completionArgument.command))}",
nil
)
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/ArgumentParser/Usage/DumpHelpGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ extension CommandInfoV0 {
}

let defaultSubcommand = command.configuration.defaultSubcommand?
.configuration.commandName
._commandName
let subcommands = command.configuration.subcommands
.map { subcommand -> CommandInfoV0 in
var commandStack = commandStack
Expand Down
82 changes: 82 additions & 0 deletions Tests/ArgumentParserExampleTests/CompletionExampleTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift Argument Parser open source project
//
// Copyright (c) 2026 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
//
//===----------------------------------------------------------------------===//

#if os(macOS) || os(Linux)
import Foundation
import Testing

private final class CompletionBundleMarker {}

struct CompletionExampleTests {
enum Shell: String, CaseIterable {
case bash, zsh, fish

static var available: [Self] {
allCases.filter {
CompletionExampleTests.isExecutableAvailable($0.rawValue)
}
}
}

static func isExecutableAvailable(_ name: String) -> Bool {
(ProcessInfo.processInfo.environment["PATH"] ?? "")
.split(separator: ":")
.contains {
FileManager.default.isExecutableFile(atPath: "\($0)/\(name)")
}
}

@Test
func pythonEnabled() throws {
if !Self.isExecutableAvailable("python3") {
print("Skipping interactive completion tests: python3 not available.")
}
}

@Test(arguments: Shell.allCases)
func shellAvailable(shell: Shell) throws {
if !Self.isExecutableAvailable(shell.rawValue) {
print(
"Skipping interactive completion tests for '\(shell.rawValue)': not available."
)
}
}

@Test(
.enabled(if: isExecutableAvailable("python3")),
arguments: Shell.available, 0..<3
)
func interactiveCompletion(shell: Shell, fixture: Int) throws {
let bundleURL = Bundle(for: CompletionBundleMarker.self).bundleURL
let binaryDirectory =
bundleURL.lastPathComponent.hasSuffix("xctest")
? bundleURL.deletingLastPathComponent() : bundleURL
let script = try #require(
Bundle.module.url(
forResource: "test_completions", withExtension: "py",
subdirectory: "CompletionTests"))
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
process.arguments = [
"python3", script.path, "--bin-dir", binaryDirectory.path,
"--shell", shell.rawValue, "--case", String(fixture),
]
let output = Pipe()
process.standardOutput = output
process.standardError = output
try process.run()
let data = output.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
let transcript = String(decoding: data, as: UTF8.self)
#expect(process.terminationStatus == 0, "\(transcript)")
}
}
#endif
Loading
Loading