From e47b70512c7ec8bcc744fd1e799eed6a2f7079ff Mon Sep 17 00:00:00 2001 From: Galileo927 <1134271093@qq.com> Date: Mon, 18 May 2026 00:49:58 +0800 Subject: [PATCH 1/2] Include hidden arguments in completion scripts --- .../BashCompletionsGenerator.swift | 8 +- .../Completions/CompletionsGenerator.swift | 15 +++- .../Usage/DumpHelpGenerator.swift | 37 ++++++-- .../CompletionScriptTests.swift | 87 +++++++++++++++++++ .../Snapshots/testBase_Bash().bash | 2 +- .../Snapshots/testBase_Fish().fish | 3 +- .../Snapshots/testBase_Zsh().zsh | 1 + 7 files changed, 139 insertions(+), 14 deletions(-) diff --git a/Sources/ArgumentParser/Completions/BashCompletionsGenerator.swift b/Sources/ArgumentParser/Completions/BashCompletionsGenerator.swift index 9709b6222..71a2d656b 100644 --- a/Sources/ArgumentParser/Completions/BashCompletionsGenerator.swift +++ b/Sources/ArgumentParser/Completions/BashCompletionsGenerator.swift @@ -263,15 +263,21 @@ extension CommandInfoV0 { return nil as String? } + let positionPattern = arg.isRepeating ? "*" : position.description + if arg.isRepeating { encounteredRepeatingPositional = true } + guard arg.shouldDisplay else { + return nil + } + let completion = valueCompletion(arg) return completion.isEmpty ? nil : """ - \(encounteredRepeatingPositional ? "*" : position.description)) + \(positionPattern)) \(completion.indentingEachLine(by: 8))\ return ;; diff --git a/Sources/ArgumentParser/Completions/CompletionsGenerator.swift b/Sources/ArgumentParser/Completions/CompletionsGenerator.swift index 5328e798c..e2d94303c 100644 --- a/Sources/ArgumentParser/Completions/CompletionsGenerator.swift +++ b/Sources/ArgumentParser/Completions/CompletionsGenerator.swift @@ -124,11 +124,20 @@ struct CompletionsGenerator { CompletionShell._requesting.withLock { $0 = shell } switch shell { case .zsh: - return ToolInfoV0(commandStack: [command]).zshCompletionScript + return ToolInfoV0( + commandStack: [command], + includeHiddenArguments: true + ).zshCompletionScript case .bash: - return ToolInfoV0(commandStack: [command]).bashCompletionScript + return ToolInfoV0( + commandStack: [command], + includeHiddenArguments: true + ).bashCompletionScript case .fish: - return ToolInfoV0(commandStack: [command]).fishCompletionScript + return ToolInfoV0( + commandStack: [command], + includeHiddenArguments: true + ).fishCompletionScript default: fatalError("Invalid CompletionShell: \(shell)") } diff --git a/Sources/ArgumentParser/Usage/DumpHelpGenerator.swift b/Sources/ArgumentParser/Usage/DumpHelpGenerator.swift index 002d564f5..957f8f7f9 100644 --- a/Sources/ArgumentParser/Usage/DumpHelpGenerator.swift +++ b/Sources/ArgumentParser/Usage/DumpHelpGenerator.swift @@ -43,19 +43,30 @@ extension BidirectionalCollection where Element == ParsableCommand.Type { } extension ToolInfoV0 { - init(commandStack: [ParsableCommand.Type]) { - self.init(command: CommandInfoV0(commandStack: commandStack)) + init( + commandStack: [ParsableCommand.Type], + includeHiddenArguments: Bool = false + ) { + self.init( + command: CommandInfoV0( + commandStack: commandStack, + includeHiddenArguments: includeHiddenArguments)) // FIXME: This is a hack to inject the help command into the tool info // instead we should try to lift this into the parseable command tree self.command.subcommands = (self.command.subcommands ?? []) + [ - CommandInfoV0(commandStack: commandStack + [HelpCommand.self]) + CommandInfoV0( + commandStack: commandStack + [HelpCommand.self], + includeHiddenArguments: includeHiddenArguments) ] } } extension CommandInfoV0 { - fileprivate init(commandStack: [ParsableCommand.Type]) { + fileprivate init( + commandStack: [ParsableCommand.Type], + includeHiddenArguments: Bool + ) { guard let command = commandStack.last else { preconditionFailure("commandStack must not be empty") } @@ -72,12 +83,18 @@ extension CommandInfoV0 { .map { subcommand -> CommandInfoV0 in var commandStack = commandStack commandStack.append(subcommand) - return CommandInfoV0(commandStack: commandStack) + return CommandInfoV0( + commandStack: commandStack, + includeHiddenArguments: includeHiddenArguments) } let arguments = commandStack .allArguments() - .compactMap(ArgumentInfoV0.init) + .compactMap { + ArgumentInfoV0( + argument: $0, + includeHiddenArguments: includeHiddenArguments) + } self = CommandInfoV0( superCommands: superCommands, @@ -93,7 +110,10 @@ extension CommandInfoV0 { } extension ArgumentInfoV0 { - fileprivate init?(argument: ArgumentDefinition) { + fileprivate init?( + argument: ArgumentDefinition, + includeHiddenArguments: Bool + ) { guard let kind = ArgumentInfoV0.KindV0(argument: argument) else { return nil } @@ -114,7 +134,8 @@ extension ArgumentInfoV0 { self.init( kind: kind, - shouldDisplay: argument.help.visibility.base == .default, + shouldDisplay: argument.help.visibility.isAtLeastAsVisible( + as: includeHiddenArguments ? .hidden : .default), sectionTitle: argument.help.parentTitle.nonEmpty, isOptional: argument.help.options.contains(.isOptional), isRepeating: argument.help.options.contains(.isRepeating), diff --git a/Tests/ArgumentParserUnitTests/CompletionScriptTests.swift b/Tests/ArgumentParserUnitTests/CompletionScriptTests.swift index 890d9b88b..4c7b21826 100644 --- a/Tests/ArgumentParserUnitTests/CompletionScriptTests.swift +++ b/Tests/ArgumentParserUnitTests/CompletionScriptTests.swift @@ -152,6 +152,93 @@ extension CompletionScriptTests { let script3 = Base.completionScript(for: .fish) try assertSnapshot(actual: script3, extension: "fish") } + + struct Visibility: ParsableCommand { + @Flag(help: ArgumentHelp("Hidden flag.", visibility: .hidden)) + var hiddenFlag = false + + @Flag(help: ArgumentHelp("Private flag.", visibility: .private)) + var privateFlag = false + + @Option( + help: ArgumentHelp("Hidden option.", visibility: .hidden), + completion: .list(["hidden-option-value"])) + var hiddenOption: String? + + @Option( + help: ArgumentHelp("Private option.", visibility: .private), + completion: .list(["private-option-value"])) + var privateOption: String? + + @Argument( + help: ArgumentHelp("Hidden argument.", visibility: .hidden), + completion: .list(["hidden-argument-value"])) + var hiddenArgument: String? + + @Argument( + help: ArgumentHelp("Private argument.", visibility: .private), + completion: .list(["private-argument-value"])) + var privateArgument: String? + } + + func testHiddenAndPrivateVisibility_Bash() throws { + try assertHiddenAndPrivateVisibility(in: .bash) + } + + func testHiddenAndPrivateVisibility_Fish() throws { + try assertHiddenAndPrivateVisibility(in: .fish) + } + + func testHiddenAndPrivateVisibility_Zsh() throws { + try assertHiddenAndPrivateVisibility(in: .zsh) + } + + private func assertHiddenAndPrivateVisibility( + in shell: CompletionShell, + file: StaticString = #filePath, + line: UInt = #line + ) throws { + let script = try CompletionsGenerator(command: Visibility.self, shell: shell) + .generateCompletionScript() + + let (expectedNames, unexpectedNames): ([String], [String]) = + switch shell { + case .bash, .zsh: + ( + ["--hidden-flag", "--hidden-option"], + ["--private-flag", "--private-option"] + ) + case .fish: + ( + ["-l 'hidden-flag'", "-l 'hidden-option'"], + ["-l 'private-flag'", "-l 'private-option'"] + ) + default: + ([], []) + } + + for expected in [ + "hidden-option-value", + "hidden-argument-value", + ] + expectedNames { + XCTAssertTrue( + script.contains(expected), + "\(shell.rawValue) completion script is missing \(expected)", + file: file, + line: line) + } + + for unexpected in [ + "private-option-value", + "private-argument-value", + ] + unexpectedNames { + XCTAssertFalse( + script.contains(unexpected), + "\(shell.rawValue) completion script includes \(unexpected)", + file: file, + line: line) + } + } } extension CompletionScriptTests { diff --git a/Tests/ArgumentParserUnitTests/Snapshots/testBase_Bash().bash b/Tests/ArgumentParserUnitTests/Snapshots/testBase_Bash().bash index 95b84438d..fb1cdc3ed 100644 --- a/Tests/ArgumentParserUnitTests/Snapshots/testBase_Bash().bash +++ b/Tests/ArgumentParserUnitTests/Snapshots/testBase_Bash().bash @@ -163,7 +163,7 @@ _base-test() { local -a unparsed_words=("${COMP_WORDS[@]:1:${COMP_CWORD}}") local -a repeating_flags=(--kind-counter) - local -a non_repeating_flags=(--one --two --custom-three -h --help) + local -a non_repeating_flags=(--verbose --one --two --custom-three -h --help) local -a repeating_options=(--rep1 -r --rep2) local -a non_repeating_options=(--name --kind --other-kind --path1 --path2 --path3) __base-test_offer_flags_options 2 diff --git a/Tests/ArgumentParserUnitTests/Snapshots/testBase_Fish().fish b/Tests/ArgumentParserUnitTests/Snapshots/testBase_Fish().fish index df9e309b8..a8a2ab953 100644 --- a/Tests/ArgumentParserUnitTests/Snapshots/testBase_Fish().fish +++ b/Tests/ArgumentParserUnitTests/Snapshots/testBase_Fish().fish @@ -27,7 +27,7 @@ function __base-test_parse_tokens -S switch $unparsed_tokens[1] case 'base-test' - __base-test_parse_subcommand 2 'name=' 'kind=' 'other-kind=' 'path1=' 'path2=' 'path3=' 'one' 'two' 'custom-three' 'kind-counter' 'rep1=+' 'r/rep2=+' 'h/help' + __base-test_parse_subcommand 2 'name=' 'kind=' 'other-kind=' 'path1=' 'path2=' 'path3=' 'verbose' 'one' 'two' 'custom-three' 'kind-counter' 'rep1=+' 'r/rep2=+' 'h/help' switch $unparsed_tokens[1] case 'sub-command' __base-test_parse_subcommand 0 'h/help' @@ -101,6 +101,7 @@ complete -c 'base-test' -n '__base-test_should_offer_completions_for_flags_or_op complete -c 'base-test' -n '__base-test_should_offer_completions_for_flags_or_options "base-test" path1' -l 'path1' -rF complete -c 'base-test' -n '__base-test_should_offer_completions_for_flags_or_options "base-test" path2' -l 'path2' -rF complete -c 'base-test' -n '__base-test_should_offer_completions_for_flags_or_options "base-test" path3' -l 'path3' -rfka 'c1_fish c2_fish c3_fish' +complete -c 'base-test' -n '__base-test_should_offer_completions_for_flags_or_options "base-test" verbose' -l 'verbose' complete -c 'base-test' -n '__base-test_should_offer_completions_for_flags_or_options "base-test" one' -l 'one' complete -c 'base-test' -n '__base-test_should_offer_completions_for_flags_or_options "base-test" two' -l 'two' complete -c 'base-test' -n '__base-test_should_offer_completions_for_flags_or_options "base-test" custom-three' -l 'custom-three' diff --git a/Tests/ArgumentParserUnitTests/Snapshots/testBase_Zsh().zsh b/Tests/ArgumentParserUnitTests/Snapshots/testBase_Zsh().zsh index 6d8eb1aa8..58f648ab5 100644 --- a/Tests/ArgumentParserUnitTests/Snapshots/testBase_Zsh().zsh +++ b/Tests/ArgumentParserUnitTests/Snapshots/testBase_Zsh().zsh @@ -50,6 +50,7 @@ _base-test() { '--path1:path1:_files' '--path2:path2:_files' '--path3:path3:{__base-test_complete "${___path3[@]}"}' + '--verbose' '--one' '--two' '--custom-three' From 01be2285798076076b5b58a3dc9b34fdfc0d7022 Mon Sep 17 00:00:00 2001 From: Galileo927 <1134271093@qq.com> Date: Mon, 18 May 2026 01:30:18 +0800 Subject: [PATCH 2/2] Preserve positional slots for private arguments --- .../FishCompletionsGenerator.swift | 17 +++++--- .../Completions/ZshCompletionsGenerator.swift | 5 ++- .../CompletionScriptTests.swift | 41 ++++++++++++++----- 3 files changed, 47 insertions(+), 16 deletions(-) diff --git a/Sources/ArgumentParser/Completions/FishCompletionsGenerator.swift b/Sources/ArgumentParser/Completions/FishCompletionsGenerator.swift index 4f07f0b2d..e49aaa135 100644 --- a/Sources/ArgumentParser/Completions/FishCompletionsGenerator.swift +++ b/Sources/ArgumentParser/Completions/FishCompletionsGenerator.swift @@ -143,8 +143,9 @@ extension CommandInfoV0 { var positionalIndex = 0 var repeatingPositionalComparison = "" + let completableArguments = self.completableArguments let argumentCompletions = - completableArguments + (arguments ?? []) .compactMap { arg in if arg.kind == .positional { guard repeatingPositionalComparison.isEmpty else { @@ -154,16 +155,22 @@ extension CommandInfoV0 { if arg.isRepeating { repeatingPositionalComparison = " -ge" } + + positionalIndex += 1 + guard arg.shouldDisplay else { + return nil + } + } + + guard completableArguments.contains(arg) else { + return nil } return """ \(prefix)\( arg.kind == .positional ? """ - \(shouldOfferCompletionsForPositionalFunctionName) "\(commandContext.joined(separator: separator))" \({ - positionalIndex += 1 - return "\(positionalIndex)\(repeatingPositionalComparison)" - }()) + \(shouldOfferCompletionsForPositionalFunctionName) "\(commandContext.joined(separator: separator))" \(positionalIndex)\(repeatingPositionalComparison) """ : """ \(shouldOfferCompletionsForFlagsOrOptionsFunctionName) "\(commandContext.joined(separator: separator))"\ diff --git a/Sources/ArgumentParser/Completions/ZshCompletionsGenerator.swift b/Sources/ArgumentParser/Completions/ZshCompletionsGenerator.swift index 29329f958..836f1d9f8 100644 --- a/Sources/ArgumentParser/Completions/ZshCompletionsGenerator.swift +++ b/Sources/ArgumentParser/Completions/ZshCompletionsGenerator.swift @@ -62,7 +62,7 @@ extension CommandInfoV0 { var repeatingPositionalIndicator = "" let argumentSpecsAndSetupScripts = (arguments ?? []).compactMap { arg in - guard arg.shouldDisplay else { + guard arg.shouldDisplay || arg.kind == .positional else { return nil as (argumentSpec: String, setupScript: String?)? } @@ -78,6 +78,9 @@ extension CommandInfoV0 { repeatingPositionalIndicator = "*" } line = repeatingPositionalIndicator + guard arg.shouldDisplay else { + return ("'\(line)::'", nil) + } case 1: // swift-format-ignore: NeverForceUnwrap // Preconditions: names has exactly one element. diff --git a/Tests/ArgumentParserUnitTests/CompletionScriptTests.swift b/Tests/ArgumentParserUnitTests/CompletionScriptTests.swift index 4c7b21826..46b627180 100644 --- a/Tests/ArgumentParserUnitTests/CompletionScriptTests.swift +++ b/Tests/ArgumentParserUnitTests/CompletionScriptTests.swift @@ -170,15 +170,15 @@ extension CompletionScriptTests { completion: .list(["private-option-value"])) var privateOption: String? - @Argument( - help: ArgumentHelp("Hidden argument.", visibility: .hidden), - completion: .list(["hidden-argument-value"])) - var hiddenArgument: String? - @Argument( help: ArgumentHelp("Private argument.", visibility: .private), completion: .list(["private-argument-value"])) var privateArgument: String? + + @Argument( + help: ArgumentHelp("Hidden argument.", visibility: .hidden), + completion: .list(["hidden-argument-value"])) + var hiddenArgument: String? } func testHiddenAndPrivateVisibility_Bash() throws { @@ -201,25 +201,46 @@ extension CompletionScriptTests { let script = try CompletionsGenerator(command: Visibility.self, shell: shell) .generateCompletionScript() - let (expectedNames, unexpectedNames): ([String], [String]) = + let (expectedNames, unexpectedNames, expectedHiddenArgument): ( + [String], + [String], + String + ) = switch shell { - case .bash, .zsh: + case .bash: + ( + ["--hidden-flag", "--hidden-option"], + ["--private-flag", "--private-option"], + """ + 2) + __visibility_add_completions -W 'hidden-argument-value' + """ + ) + case .zsh: ( ["--hidden-flag", "--hidden-option"], - ["--private-flag", "--private-option"] + ["--private-flag", "--private-option"], + """ + '::' + ':hidden-argument:{__visibility_complete "${_hidden_argument[@]}"}' + """ ) case .fish: ( ["-l 'hidden-flag'", "-l 'hidden-option'"], - ["-l 'private-flag'", "-l 'private-option'"] + ["-l 'private-flag'", "-l 'private-option'"], + """ + __visibility_should_offer_completions_for_positional "visibility" 2' -fka 'hidden-argument-value' + """ ) default: - ([], []) + ([], [], "") } for expected in [ "hidden-option-value", "hidden-argument-value", + expectedHiddenArgument, ] + expectedNames { XCTAssertTrue( script.contains(expected),