diff --git a/Sources/ArgumentParser/Parsable Properties/FlagOption.swift b/Sources/ArgumentParser/Parsable Properties/FlagOption.swift new file mode 100644 index 00000000..49d63875 --- /dev/null +++ b/Sources/ArgumentParser/Parsable Properties/FlagOption.swift @@ -0,0 +1,308 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Argument Parser open source project +// +// Copyright (c) 2025 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 +// +//===----------------------------------------------------------------------===// + +/// A property wrapper that represents a command-line argument that can work as both a flag and an option. +/// +/// Use the `@FlagOption` wrapper to define a property that can be used in two ways: +/// 1. As a flag (without a value): `--show-bin-path` - uses the default value +/// 2. As an option (with a value): `--show-bin-path json` - uses the provided value +/// +/// This provides backward compatibility for flags while allowing optional values. +/// +/// For example, the following program declares a flag-option that defaults to "text" +/// when used as a flag, but accepts custom values when used as an option: +/// +/// ```swift +/// @main +/// struct Tool: ParsableCommand { +/// @FlagOption(defaultValue: "text") +/// var showBinPath: String +/// +/// mutating func run() { +/// print("Format: \(showBinPath)") +/// } +/// } +/// ``` +/// +/// This allows both usage patterns: +/// - `tool --show-bin-path` outputs "Format: text" +/// - `tool --show-bin-path json` outputs "Format: json" +/// +@propertyWrapper +public struct FlagOption: Decodable, ParsedWrapper { + internal var _parsedValue: Parsed + + internal init(_parsedValue: Parsed) { + self._parsedValue = _parsedValue + } + + public init(from _decoder: Decoder) throws { + try self.init(_decoder: _decoder) + } + + /// This initializer works around a quirk of property wrappers, where the + /// compiler will not see no-argument initializers in extensions. + @available( + *, unavailable, + message: "A default value must be provided for FlagOption." + ) + public init() { + fatalError("unavailable") + } + + /// The value presented by this property wrapper. + public var wrappedValue: Value { + get { + switch _parsedValue { + case .value(let v): + return v + case .definition: + configurationFailure(directlyInitializedError) + } + } + set { + _parsedValue = .value(newValue) + } + } +} + +extension FlagOption: CustomStringConvertible { + public var description: String { + switch _parsedValue { + case .value(let v): + return String(describing: v) + case .definition: + return "FlagOption(*definition*)" + } + } +} + +extension FlagOption: Sendable where Value: Sendable {} +extension FlagOption: DecodableParsedWrapper where Value: Decodable {} + +// MARK: - @FlagOption T: ExpressibleByArgument Initializers + +extension FlagOption where Value: ExpressibleByArgument { + /// Creates a flag-option property with a default value that is used when + /// the argument is specified as a flag without a value. + /// + /// - Parameters: + /// - defaultValue: The value to use when the argument is specified as a flag + /// - wrappedValue: The initial value for the property + /// - name: A specification for what names are allowed for this flag-option + /// - help: Information about how to use this flag-option + /// - completion: The type of command-line completion provided for this option + public init( + defaultValue: Value, + wrappedValue: Value, + name: NameSpecification = .long, + help: ArgumentHelp? = nil, + completion: CompletionKind? = nil + ) { + self.init( + _parsedValue: .init { key in + let arg = ArgumentDefinition( + container: Bare.self, + key: key, + kind: .name(key: key, specification: name), + help: .init( + help?.abstract ?? "", + discussion: help?.discussion, + valueName: help?.valueName, + visibility: help?.visibility ?? .default, + argumentType: Value.self + ), + parsingStrategy: .scanningForValue, + initial: wrappedValue, + completion: completion) + + return ArgumentSet(arg) + }) + } + + /// Creates a flag-option property with a default value. + /// + /// - Parameters: + /// - defaultValue: The value to use when the argument is specified as a flag + /// - name: A specification for what names are allowed for this flag-option + /// - help: Information about how to use this flag-option + /// - completion: The type of command-line completion provided for this option + public init( + defaultValue: Value, + name: NameSpecification = .long, + help: ArgumentHelp? = nil, + completion: CompletionKind? = nil + ) { + self.init( + _parsedValue: .init { key in + let arg = ArgumentDefinition( + container: Bare.self, + key: key, + kind: .name(key: key, specification: name), + help: .init( + help?.abstract ?? "", + discussion: help?.discussion, + valueName: help?.valueName, + visibility: help?.visibility ?? .default, + argumentType: Value.self + ), + parsingStrategy: .scanningForValue, + initial: nil, + completion: completion) + + return ArgumentSet(arg) + }) + } +} + +// MARK: - @FlagOption T Initializers (with transform) + +extension FlagOption { + /// Creates a flag-option property with a transform closure and default value. + /// + /// - Parameters: + /// - defaultValue: The value to use when the argument is specified as a flag + /// - wrappedValue: The initial value for the property + /// - name: A specification for what names are allowed for this flag-option + /// - help: Information about how to use this flag-option + /// - completion: The type of command-line completion provided for this option + /// - transform: A closure that converts a string into this property's type + @preconcurrency + public init( + defaultValue: Value, + wrappedValue: Value, + name: NameSpecification = .long, + help: ArgumentHelp? = nil, + completion: CompletionKind? = nil, + transform: @Sendable @escaping (String) throws -> Value + ) { + self.init( + _parsedValue: .init { key in + let arg = ArgumentDefinition( + container: Bare.self, + key: key, + kind: .name(key: key, specification: name), + help: help, + parsingStrategy: .scanningForValue, + transform: transform, + initial: wrappedValue, + completion: completion) + + return ArgumentSet(arg) + }) + } + + /// Creates a flag-option property with a transform closure and default value. + /// + /// - Parameters: + /// - defaultValue: The value to use when the argument is specified as a flag + /// - name: A specification for what names are allowed for this flag-option + /// - help: Information about how to use this flag-option + /// - completion: The type of command-line completion provided for this option + /// - transform: A closure that converts a string into this property's type + @preconcurrency + public init( + defaultValue: Value, + name: NameSpecification = .long, + help: ArgumentHelp? = nil, + completion: CompletionKind? = nil, + transform: @Sendable @escaping (String) throws -> Value + ) { + self.init( + _parsedValue: .init { key in + let arg = ArgumentDefinition( + container: Bare.self, + key: key, + kind: .name(key: key, specification: name), + help: help, + parsingStrategy: .scanningForValue, + transform: transform, + initial: nil, + completion: completion) + + return ArgumentSet(arg) + }) + } +} + +// MARK: - @FlagOption Optional Initializers + +extension FlagOption { + /// Creates an optional flag-option property with a default value. + /// + /// - Parameters: + /// - defaultValue: The value to use when the argument is specified as a flag + /// - wrappedValue: The initial value for the property + /// - name: A specification for what names are allowed for this flag-option + /// - help: Information about how to use this flag-option + /// - completion: The type of command-line completion provided for this option + public init( + defaultValue: T, + wrappedValue: T?, + name: NameSpecification = .long, + help: ArgumentHelp? = nil, + completion: CompletionKind? = nil + ) where T: ExpressibleByArgument, Value == T? { + self.init( + _parsedValue: .init { key in + let arg = ArgumentDefinition( + container: Optional.self, + key: key, + kind: .name(key: key, specification: name), + help: .init( + help?.abstract ?? "", + discussion: help?.discussion, + valueName: help?.valueName, + visibility: help?.visibility ?? .default, + argumentType: T.self + ), + parsingStrategy: .scanningForValue, + initial: wrappedValue, + completion: completion) + + return ArgumentSet(arg) + }) + } + + /// Creates an optional flag-option property with a default value. + /// + /// - Parameters: + /// - defaultValue: The value to use when the argument is specified as a flag + /// - name: A specification for what names are allowed for this flag-option + /// - help: Information about how to use this flag-option + /// - completion: The type of command-line completion provided for this option + public init( + defaultValue: T, + name: NameSpecification = .long, + help: ArgumentHelp? = nil, + completion: CompletionKind? = nil + ) where T: ExpressibleByArgument, Value == T? { + self.init( + _parsedValue: .init { key in + let arg = ArgumentDefinition( + container: Optional.self, + key: key, + kind: .name(key: key, specification: name), + help: .init( + help?.abstract ?? "", + discussion: help?.discussion, + valueName: help?.valueName, + visibility: help?.visibility ?? .default, + argumentType: T.self + ), + parsingStrategy: .scanningForValue, + initial: nil, + completion: completion) + + return ArgumentSet(arg) + }) + } +} diff --git a/Tests/ArgumentParserEndToEndTests/FlagOptionHybridEndToEndTests.swift b/Tests/ArgumentParserEndToEndTests/FlagOptionHybridEndToEndTests.swift new file mode 100644 index 00000000..bc66ae7b --- /dev/null +++ b/Tests/ArgumentParserEndToEndTests/FlagOptionHybridEndToEndTests.swift @@ -0,0 +1,366 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Argument Parser open source project +// +// Copyright (c) 2025 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 +// +//===----------------------------------------------------------------------===// + +import ArgumentParser +import ArgumentParserTestHelpers +import Testing + +@Suite("FlagOption Hybrid End-to-End Tests") +struct FlagOptionHybridEndToEndTests { +} + +// MARK: - Basic FlagOption functionality + +private struct BasicFlagOption: ParsableCommand { + @FlagOption(defaultValue: "text") + var showBinPath: String +} + +extension FlagOptionHybridEndToEndTests { + @Test("Basic FlagOption - no argument") + func basicFlagOption_noArgument() throws { + AssertParse(BasicFlagOption.self, []) { options in + #expect(options.showBinPath == "text") + } + } + + @Test("Basic FlagOption - as flag") + func basicFlagOption_asFlag() throws { + AssertParse(BasicFlagOption.self, ["--show-bin-path"]) { options in + #expect(options.showBinPath == "text") + } + } + + @Test("Basic FlagOption - as option") + func basicFlagOption_asOption() throws { + AssertParse(BasicFlagOption.self, ["--show-bin-path", "json"]) { options in + #expect(options.showBinPath == "json") + } + } + + @Test("Basic FlagOption - as option with equals") + func basicFlagOption_asOptionWithEquals() throws { + AssertParse(BasicFlagOption.self, ["--show-bin-path=xml"]) { options in + #expect(options.showBinPath == "xml") + } + } +} + +// MARK: - Required FlagOption + +private struct RequiredFlagOption: ParsableCommand { + @FlagOption(defaultValue: "default") + var format: String +} + +extension FlagOptionHybridEndToEndTests { + @Test("Required FlagOption - as flag") + func requiredFlagOption_asFlag() throws { + AssertParse(RequiredFlagOption.self, ["--format"]) { options in + #expect(options.format == "default") + } + } + + @Test("Required FlagOption - as option") + func requiredFlagOption_asOption() throws { + AssertParse(RequiredFlagOption.self, ["--format", "json"]) { options in + #expect(options.format == "json") + } + } + + @Test("Required FlagOption - missing") + func requiredFlagOption_missing() throws { + #expect(throws: (any Error).self) { + try RequiredFlagOption.parse([]) + } + } +} + +// MARK: - Optional FlagOption + +private struct OptionalFlagOption: ParsableCommand { + @FlagOption(defaultValue: "auto") + var verbose: String? +} + +extension FlagOptionHybridEndToEndTests { + @Test("Optional FlagOption - not provided") + func optionalFlagOption_notProvided() throws { + AssertParse(OptionalFlagOption.self, []) { options in + #expect(options.verbose == nil) + } + } + + @Test("Optional FlagOption - as flag") + func optionalFlagOption_asFlag() throws { + AssertParse(OptionalFlagOption.self, ["--verbose"]) { options in + #expect(options.verbose == "auto") + } + } + + @Test("Optional FlagOption - as option") + func optionalFlagOption_asOption() throws { + AssertParse(OptionalFlagOption.self, ["--verbose", "detailed"]) { options in + #expect(options.verbose == "detailed") + } + } +} + +// MARK: - FlagOption with custom names + +private struct CustomNameFlagOption: ParsableCommand { + @FlagOption(defaultValue: "table", name: .customLong("output-format")) + var format: String +} + +extension FlagOptionHybridEndToEndTests { + @Test("Custom name FlagOption - as flag") + func customNameFlagOption_asFlag() throws { + AssertParse(CustomNameFlagOption.self, ["--output-format"]) { options in + #expect(options.format == "table") + } + } + + @Test("Custom name FlagOption - as option") + func customNameFlagOption_asOption() throws { + AssertParse( + CustomNameFlagOption.self, + ["--output-format", "json"], + ) { options in + #expect(options.format == "json") + } + } +} + +// MARK: - FlagOption with short and long names + +private struct ShortLongFlagOption: ParsableCommand { + @FlagOption(defaultValue: "normal", name: .shortAndLong) + var verbosity: String +} + +extension FlagOptionHybridEndToEndTests { + @Test("Short/Long FlagOption - long as flag") + func shortLongFlagOption_longAsFlag() throws { + AssertParse(ShortLongFlagOption.self, ["--verbosity"]) { options in + #expect(options.verbosity == "normal") + } + } + + @Test("Short/Long FlagOption - short as flag") + func shortLongFlagOption_shortAsFlag() throws { + AssertParse(ShortLongFlagOption.self, ["-v"]) { options in + #expect(options.verbosity == "normal") + } + } + + @Test("Short/Long FlagOption - long as option") + func shortLongFlagOption_longAsOption() throws { + AssertParse(ShortLongFlagOption.self, ["--verbosity", "debug"]) { options in + #expect(options.verbosity == "debug") + } + } + + @Test("Short/Long FlagOption - short as option") + func shortLongFlagOption_shortAsOption() throws { + AssertParse(ShortLongFlagOption.self, ["-v", "quiet"]) { options in + #expect(options.verbosity == "quiet") + } + } +} + +// MARK: - FlagOption with transform + +private struct TransformFlagOption: ParsableCommand { + @FlagOption(defaultValue: "info", transform: { $0.lowercased() }) + var logLevel: String +} + +extension FlagOptionHybridEndToEndTests { + @Test("Transform FlagOption - as flag") + func transformFlagOption_asFlag() throws { + AssertParse(TransformFlagOption.self, ["--log-level"]) { options in + #expect(options.logLevel == "info") + } + } + + @Test("Transform FlagOption - as option") + func transformFlagOption_asOption() throws { + AssertParse(TransformFlagOption.self, ["--log-level", "ERROR"]) { options in + #expect(options.logLevel == "error") + } + } +} + +// MARK: - Multiple FlagOptions + +private struct MultipleFlagOptions: ParsableCommand { + @FlagOption(defaultValue: "text") + var showBinPath: String + + @FlagOption(defaultValue: "normal") + var verbosity: String + + @Flag + var dryRun: Bool = false +} + +extension FlagOptionHybridEndToEndTests { + @Test("Multiple FlagOptions - mixed usage") + func multipleFlagOptions_mixed() throws { + AssertParse( + MultipleFlagOptions.self, + ["--show-bin-path", "--verbosity", "debug", "--dry-run"], + ) { options in + #expect(options.showBinPath == "text") + #expect(options.verbosity == "debug") + #expect(options.dryRun == true) + } + } + + @Test("Multiple FlagOptions - all as options") + func multipleFlagOptions_allAsOptions() throws { + AssertParse( + MultipleFlagOptions.self, + ["--show-bin-path", "json", "--verbosity", "quiet"], + ) { options in + #expect(options.showBinPath == "json") + #expect(options.verbosity == "quiet") + #expect(options.dryRun == false) + } + } +} + +// MARK: - Edge cases and error handling + +private struct EdgeCaseFlagOption: ParsableCommand { + @FlagOption(defaultValue: "default") + var option: String +} + +extension FlagOptionHybridEndToEndTests { + @Test("Edge cases - flag followed by another flag") + func edgeCases_flagFollowedByAnotherFlag() throws { + AssertParse(EdgeCaseFlagOption.self, ["--option", "--help"]) { options in + // Should treat --help as the value for --option, not as a separate flag + #expect(options.option == "--help") + } + } + + @Test("Edge cases - flag at end") + func edgeCases_flagAtEnd() throws { + AssertParse(EdgeCaseFlagOption.self, ["--option"]) { options in + #expect(options.option == "default") + } + } +} + +// MARK: - Integer FlagOption + +private struct IntegerFlagOption: ParsableCommand { + @FlagOption(defaultValue: 1) + var count: Int +} + +extension FlagOptionHybridEndToEndTests { + @Test("Integer FlagOption - as flag") + func integerFlagOption_asFlag() throws { + AssertParse(IntegerFlagOption.self, ["--count"]) { options in + #expect(options.count == 1) + } + } + + @Test("Integer FlagOption - as option") + func integerFlagOption_asOption() throws { + AssertParse(IntegerFlagOption.self, ["--count", "5"]) { options in + #expect(options.count == 5) + } + } + + @Test("Integer FlagOption - invalid value") + func integerFlagOption_invalidValue() throws { + #expect(throws: (any Error).self) { + try IntegerFlagOption.parse(["--count", "invalid"]) + } + } +} + +// MARK: - Boolean enum FlagOption + +enum BooleanMode: String, CaseIterable, ExpressibleByArgument { + case on = "on" + case off = "off" + case auto = "auto" +} + +private struct EnumFlagOption: ParsableCommand { + @FlagOption(defaultValue: .auto) + var mode: BooleanMode +} + +extension FlagOptionHybridEndToEndTests { + @Test("Enum FlagOption - as flag") + func enumFlagOption_asFlag() throws { + AssertParse(EnumFlagOption.self, ["--mode"]) { options in + #expect(options.mode == .auto) + } + } + + @Test("Enum FlagOption - as option") + func enumFlagOption_asOption() throws { + AssertParse(EnumFlagOption.self, ["--mode", "on"]) { options in + #expect(options.mode == .on) + } + } + + @Test("Enum FlagOption - invalid enum") + func enumFlagOption_invalidEnum() throws { + #expect(throws: (any Error).self) { + try EnumFlagOption.parse(["--mode", "invalid"]) + } + } +} + +// MARK: - Backward compatibility with existing Flag behavior + +private struct BackwardCompatibility: ParsableCommand { + @Flag + var regularFlag: Bool = false + + @FlagOption(defaultValue: "text") + var hybridFlag: String + + @Option + var regularOption: String = "default" +} + +extension FlagOptionHybridEndToEndTests { + @Test("Backward compatibility - mixed usage") + func backwardCompatibility_mixedUsage() throws { + AssertParse( + BackwardCompatibility.self, + ["--regular-flag", "--hybrid-flag", "json", "--regular-option", "value"], + ) { options in + #expect(options.regularFlag == true) + #expect(options.hybridFlag == "json") + #expect(options.regularOption == "value") + } + } + + @Test("Backward compatibility - hybrid as flag") + func backwardCompatibility_hybridAsFlag() throws { + AssertParse(BackwardCompatibility.self, ["--hybrid-flag"]) { options in + #expect(options.regularFlag == false) + #expect(options.hybridFlag == "text") + #expect(options.regularOption == "default") + } + } +} diff --git a/Tests/ArgumentParserUnitTests/FlagOptionCompletionTests.swift b/Tests/ArgumentParserUnitTests/FlagOptionCompletionTests.swift new file mode 100644 index 00000000..e7853ef8 --- /dev/null +++ b/Tests/ArgumentParserUnitTests/FlagOptionCompletionTests.swift @@ -0,0 +1,407 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Argument Parser open source project +// +// Copyright (c) 2025 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 +// +//===----------------------------------------------------------------------===// + +import ArgumentParserTestHelpers +import Testing + +@testable import ArgumentParser + +@Suite("FlagOption Completion Tests") +struct FlagOptionCompletionTests { +} + +private func completionCandidates(prefix: String) -> [String] { + switch CompletionShell.requesting { + case CompletionShell.bash: + return ["\(prefix)1_bash", "\(prefix)2_bash", "\(prefix)3_bash"] + case CompletionShell.fish: + return ["\(prefix)1_fish", "\(prefix)2_fish", "\(prefix)3_fish"] + case CompletionShell.zsh: + return ["\(prefix)1_zsh", "\(prefix)2_zsh", "\(prefix)3_zsh"] + default: + return [] + } +} + +private func asyncCompletionCandidates(prefix: String) async -> [String] { + completionCandidates(prefix: prefix) +} + +// MARK: - Basic FlagOption Completion Tests + +extension FlagOptionCompletionTests { + struct BasicFlagOptionCompletion: ParsableCommand { + @FlagOption( + defaultValue: "text", + help: "Output format", + completion: .list(["json", "text", "yaml"]) + ) + var format: String + } + + @Test func basicCompletionGeneration() throws { + #if !os(Windows) && !os(WASI) + let script = try CompletionsGenerator( + command: BasicFlagOptionCompletion.self, + shell: .bash + ).generateCompletionScript() + + #expect(script.contains("--format")) + #expect(script.contains("json")) + #expect(script.contains("text")) + #expect(script.contains("yaml")) + #endif + } + + @Test func bashCompletionScript() throws { + #if !os(Windows) && !os(WASI) + let script = BasicFlagOptionCompletion.completionScript(for: .bash) + + #expect(script.contains("--format")) + #expect(script.contains("json")) + #expect(script.contains("text")) + #expect(script.contains("yaml")) + #endif + } + + @Test func zshCompletionScript() throws { + #if !os(Windows) && !os(WASI) + let script = BasicFlagOptionCompletion.completionScript(for: .zsh) + + #expect(script.contains("--format")) + #expect(script.contains("json")) + #expect(script.contains("text")) + #expect(script.contains("yaml")) + #endif + } + + @Test func fishCompletionScript() throws { + #if !os(Windows) && !os(WASI) + let script = BasicFlagOptionCompletion.completionScript(for: .fish) + + #expect(script.contains("--format")) + #expect(script.contains("json")) + #expect(script.contains("text")) + #expect(script.contains("yaml")) + #endif + } +} + +// MARK: - Custom Completion Tests + +extension FlagOptionCompletionTests { + struct CustomCompletionFlagOption: ParsableCommand { + @FlagOption( + defaultValue: "default", + help: "Custom completion", + completion: .custom { _, _, _ in completionCandidates(prefix: "custom") } + ) + var customOption: String + + @FlagOption( + defaultValue: "async", + help: "Async completion", + completion: .custom { _, _, _ in + await asyncCompletionCandidates(prefix: "async") + } + ) + var asyncOption: String + } + + func assertCustomFlagOptionCompletion( + _ arg: String, + shell: CompletionShell, + prefix: String = "" + ) throws { + #if !os(Windows) && !os(WASI) + do { + Platform.Environment[.shellName, as: CompletionShell.self] = shell + defer { Platform.Environment[.shellName] = nil } + _ = try CustomCompletionFlagOption.parse([ + "---completion", "--", arg, "0", "0", + ]) + #expect(Bool(false), "Should have thrown completion error") + } catch let error as CommandError { + guard case .completionScriptCustomResponse(let output) = error.parserError + else { + throw error + } + + let expectedCompletions = [ + "\(prefix)1_\(shell.rawValue)", + "\(prefix)2_\(shell.rawValue)", + "\(prefix)3_\(shell.rawValue)", + ] + + let expected = shell.format(completions: expectedCompletions) + #expect(output == expected) + } + #endif + } + + @Test func customCompletionBash() throws { + #if !os(Windows) && !os(WASI) + try assertCustomFlagOptionCompletion( + "--custom-option", shell: .bash, prefix: "custom") + try assertCustomFlagOptionCompletion( + "--async-option", shell: .bash, prefix: "async") + #endif + } + + @Test func customCompletionZsh() throws { + #if !os(Windows) && !os(WASI) + try assertCustomFlagOptionCompletion( + "--custom-option", shell: .zsh, prefix: "custom") + try assertCustomFlagOptionCompletion( + "--async-option", shell: .zsh, prefix: "async") + #endif + } + + @Test func customCompletionFish() throws { + #if !os(Windows) && !os(WASI) + try assertCustomFlagOptionCompletion( + "--custom-option", shell: .fish, prefix: "custom") + try assertCustomFlagOptionCompletion( + "--async-option", shell: .fish, prefix: "async") + #endif + } +} + +// MARK: - File Completion Tests + +extension FlagOptionCompletionTests { + struct FileExtensions: ExpressibleByArgument { + var path: String + + init?(argument: String) { + self.path = argument + } + + static var defaultCompletionKind: CompletionKind { + .file(extensions: ["swift", "txt", "json"]) + } + } + + struct FileCompletionFlagOption: ParsableCommand { + @FlagOption( + defaultValue: FileExtensions(argument: "default.txt")!, + help: "Any file", + completion: .file() + ) + var anyFile: FileExtensions + + @FlagOption( + defaultValue: FileExtensions(argument: "config.json")!, + help: "Config file", + completion: .file(extensions: ["json", "yaml"]) + ) + var configFile: FileExtensions + + @FlagOption( + defaultValue: "/tmp", + help: "Directory path", + completion: .directory + ) + var directory: String + } + + @Test func fileCompletionGeneration() throws { + #if !os(Windows) && !os(WASI) + let script = try CompletionsGenerator( + command: FileCompletionFlagOption.self, + shell: .bash + ).generateCompletionScript() + + #expect(script.contains("--any-file")) + #expect(script.contains("--config-file")) + #expect(script.contains("--directory")) + #endif + } + + @Test func fileCompletionWithExtensions() throws { + #if !os(Windows) && !os(WASI) + let script = try CompletionsGenerator( + command: FileCompletionFlagOption.self, + shell: .zsh + ).generateCompletionScript() + + // Should handle file completion for FlagOption + #expect(script.contains("--config-file")) + #expect(script.contains("--directory")) + #endif + } +} + +// MARK: - Mixed Completion Types Tests + +extension FlagOptionCompletionTests { + struct MixedCompletionTypes: ParsableCommand { + @FlagOption( + defaultValue: "info", + help: "Log level", + completion: .list(["debug", "info", "warn", "error"]) + ) + var logLevel: String + + @Option( + help: "Regular option with file completion", + completion: .file(extensions: ["txt"]) + ) + var inputFile: String? + + @FlagOption( + defaultValue: "output.json", + help: "Output file", + completion: .custom { _, _, _ in + ["output.json", "output.yaml", "output.xml"] + } + ) + var output: String + + @Flag(help: "Enable verbose mode") + var verbose: Bool = false + } + + @Test func mixedCompletionTypesGeneration() throws { + #if !os(Windows) && !os(WASI) + let bashScript = try CompletionsGenerator( + command: MixedCompletionTypes.self, + shell: .bash + ).generateCompletionScript() + + // Should contain FlagOption arguments + #expect(bashScript.contains("--log-level")) + #expect(bashScript.contains("--output")) + // Should contain regular Option arguments + #expect(bashScript.contains("--input-file")) + // Should contain Flag arguments + #expect(bashScript.contains("--verbose")) + + // Should contain completion values for list completion + #expect(bashScript.contains("debug")) + #expect(bashScript.contains("info")) + #expect(bashScript.contains("warn")) + #expect(bashScript.contains("error")) + #endif + } + + func assertMixedCompletion( + _ arg: String, + shell: CompletionShell, + expectedValues: [String] + ) throws { + #if !os(Windows) && !os(WASI) + do { + Platform.Environment[.shellName, as: CompletionShell.self] = shell + defer { Platform.Environment[.shellName] = nil } + _ = try MixedCompletionTypes.parse(["---completion", "--", arg, "0", "0"]) + #expect(Bool(false), "Should have thrown completion error") + } catch let error as CommandError { + guard case .completionScriptCustomResponse(let output) = error.parserError + else { + throw error + } + + let expected = shell.format(completions: expectedValues) + #expect(output == expected) + } + #endif + } + + @Test func mixedCustomCompletionBash() throws { + #if !os(Windows) && !os(WASI) + try assertMixedCompletion( + "--output", + shell: .bash, + expectedValues: ["output.json", "output.yaml", "output.xml"] + ) + #endif + } +} + +// MARK: - Transform with Completion Tests + +extension FlagOptionCompletionTests { + struct TransformWithCompletion: ParsableCommand { + @FlagOption( + defaultValue: 1, + help: "Priority level", + completion: .list(["1", "2", "3", "5", "10"]), + transform: { Int($0) ?? 1 } + ) + var priority: Int + + @FlagOption( + defaultValue: true, + help: "Enable feature", + completion: .list(["true", "false", "yes", "no", "1", "0"]), + transform: { + switch $0.lowercased() { + case "true", "yes", "1": return true + case "false", "no", "0": return false + default: return Bool($0) ?? true + } + } + ) + var enabled: Bool + } + + @Test func transformWithCompletionGeneration() throws { + #if !os(Windows) && !os(WASI) + let script = try CompletionsGenerator( + command: TransformWithCompletion.self, + shell: .bash + ).generateCompletionScript() + + #expect(script.contains("--priority")) + #expect(script.contains("--enabled")) + + // Should contain completion values even with transform + #expect(script.contains("\"1\"")) + #expect(script.contains("\"2\"")) + #expect(script.contains("\"3\"")) + #expect(script.contains("\"5\"")) + #expect(script.contains("\"10\"")) + + #expect(script.contains("true")) + #expect(script.contains("false")) + #expect(script.contains("yes")) + #expect(script.contains("no")) + #endif + } +} + +// MARK: - Optional FlagOption Completion Tests + +extension FlagOptionCompletionTests { + struct OptionalFlagOptionCompletion: ParsableCommand { + @FlagOption( + defaultValue: "default", + help: "Optional flag option", + completion: .list(["option1", "option2", "option3"]) + ) + var optional: String? + } + + @Test func optionalFlagOptionCompletionGeneration() throws { + #if !os(Windows) && !os(WASI) + let script = try CompletionsGenerator( + command: OptionalFlagOptionCompletion.self, + shell: .zsh + ).generateCompletionScript() + + #expect(script.contains("--optional")) + #expect(script.contains("option1")) + #expect(script.contains("option2")) + #expect(script.contains("option3")) + #endif + } +} diff --git a/Tests/ArgumentParserUnitTests/FlagOptionHelpGenerationTests.swift b/Tests/ArgumentParserUnitTests/FlagOptionHelpGenerationTests.swift new file mode 100644 index 00000000..3522fe2c --- /dev/null +++ b/Tests/ArgumentParserUnitTests/FlagOptionHelpGenerationTests.swift @@ -0,0 +1,375 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Argument Parser open source project +// +// Copyright (c) 2025 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 +// +//===----------------------------------------------------------------------===// + +import ArgumentParserTestHelpers +import Testing + +@testable import ArgumentParser + +@Suite("FlagOption Help Generation Tests") +struct FlagOptionHelpGenerationTests { +} + +// MARK: - Basic FlagOption Help Tests + +extension FlagOptionHelpGenerationTests { + struct BasicFlagOption: ParsableArguments { + @FlagOption(defaultValue: "text", help: "Show binary path format") + var showBinPath: String + } + + @Test func basicFlagOptionHelp() { + AssertHelp( + .default, for: BasicFlagOption.self, + equals: """ + USAGE: basic-flag-option [--show-bin-path] [--show-bin-path ] + + OPTIONS: + --show-bin-path [] + Show binary path format (default: text) + -h, --help Show help information. + + """) + } + + struct MultipleFlagOptions: ParsableArguments { + @FlagOption(defaultValue: "json", help: "Output format") + var format: String + + @FlagOption(defaultValue: true, help: "Enable verbose mode") + var verbose: Bool + + @Option(help: "Your name") + var name: String? + } + + @Test func multipleFlagOptionsHelp() { + AssertHelp( + .default, for: MultipleFlagOptions.self, + equals: """ + USAGE: multiple-flag-options [--format] [--format ] [--verbose] [--verbose ] [--name ] + + OPTIONS: + --format [] Output format (default: json) + --verbose [] Enable verbose mode (default: true) + --name Your name + -h, --help Show help information. + + """) + } +} + +// MARK: - Custom Names and Help Text + +extension FlagOptionHelpGenerationTests { + struct CustomNameFlagOption: ParsableArguments { + @FlagOption( + defaultValue: "table", + name: [.customLong("output"), .customShort("o")], + help: "Specify output format" + ) + var outputFormat: String + } + + @Test func customNameFlagOptionHelp() { + AssertHelp( + .default, for: CustomNameFlagOption.self, + equals: """ + USAGE: custom-name-flag-option [-o] [-o ] + + OPTIONS: + -o, --output [] Specify output format (default: table) + -h, --help Show help information. + + """) + } + + struct FlagOptionWithDiscussion: ParsableArguments { + @FlagOption( + defaultValue: "info", + help: ArgumentHelp( + "Set logging level.", + discussion: + "When used as a flag, defaults to 'info'. When used as an option, specify the exact level.", + ) + ) + var logLevel: String + } + + @Test func flagOptionWithDiscussionHelp() { + AssertHelp( + .default, for: FlagOptionWithDiscussion.self, + equals: """ + USAGE: flag-option-with-discussion [--log-level] [--log-level ] + + OPTIONS: + --log-level [] + Set logging level. (default: info) + When used as a flag, defaults to 'info'. When used as an option, + specify the exact level. + -h, --help Show help information. + + """) + } +} + +// MARK: - Transform and Optional Types + +extension FlagOptionHelpGenerationTests { + struct TransformFlagOption: ParsableArguments { + @FlagOption( + defaultValue: 1, + help: "Set priority level", + transform: { Int($0) ?? 1 } + ) + var priority: Int + } + + @Test func transformFlagOptionHelp() { + AssertHelp( + .default, for: TransformFlagOption.self, + equals: """ + USAGE: transform-flag-option [--priority] [--priority ] + + OPTIONS: + --priority [] Set priority level (default: 1) + -h, --help Show help information. + + """) + } + + struct OptionalFlagOption: ParsableArguments { + @FlagOption(defaultValue: "warn", help: "Set log level") + var logLevel: String? + } + + @Test func optionalFlagOptionHelp() { + AssertHelp( + .default, for: OptionalFlagOption.self, + equals: """ + USAGE: optional-flag-option [--log-level] [--log-level ] + + OPTIONS: + --log-level [] + Set log level (default: warn) + -h, --help Show help information. + + """) + } +} + +// MARK: - Enumerable Types + +extension FlagOptionHelpGenerationTests { + enum LogLevel: String, CaseIterable, ExpressibleByArgument { + case debug, info, warn, error + + var defaultValueDescription: String { + switch self { + case .debug: return "Show detailed debugging information" + case .info: return "Show general information" + case .warn: return "Show warnings and errors" + case .error: return "Show only errors" + } + } + } + + struct EnumerableFlagOption: ParsableArguments { + @FlagOption(defaultValue: .info, help: "Set logging level") + var logLevel: LogLevel + } + + @Test func enumerableFlagOptionHelp() { + AssertHelp( + .default, for: EnumerableFlagOption.self, + equals: """ + USAGE: enumerable-flag-option [--log-level] [--log-level ] + + OPTIONS: + --log-level [] + Set logging level (default: info) + debug - Show detailed debugging information + info - Show general information + warn - Show warnings and errors + error - Show only errors + -h, --help Show help information. + + """) + } +} + +// MARK: - Hidden and Private Visibility + +extension FlagOptionHelpGenerationTests { + struct HiddenFlagOption: ParsableArguments { + @FlagOption(defaultValue: "debug", help: "Debug mode") + var debug: String + + @FlagOption(defaultValue: "secret", help: .hidden) + var hiddenOption: String + + @FlagOption(defaultValue: "private", help: .private) + var privateOption: String + } + + @Test func hiddenFlagOptionHelp() { + AssertHelp( + .default, for: HiddenFlagOption.self, + equals: """ + USAGE: hidden-flag-option [--debug] [--debug ] + + OPTIONS: + --debug [] Debug mode (default: debug) + -h, --help Show help information. + + """) + + AssertHelp( + .hidden, for: HiddenFlagOption.self, + equals: """ + USAGE: hidden-flag-option [--debug] [--debug ] [--hidden-option] [--hidden-option ] + + OPTIONS: + --debug [] Debug mode (default: debug) + --hidden-option [] + (default: secret) + -h, --help Show help information. + + """) + } +} + +// MARK: - Mixed with Regular Options and Flags + +extension FlagOptionHelpGenerationTests { + struct MixedArguments: ParsableArguments { + @Flag(help: "Enable verbose output") + var verbose: Bool = false + + @FlagOption(defaultValue: "json", help: "Output format") + var format: String + + @Option(help: "Input file path") + var input: String? + + @Argument(help: "Output file path") + var output: String? + } + + @Test func mixedArgumentsHelp() { + AssertHelp( + .default, for: MixedArguments.self, + equals: """ + USAGE: mixed-arguments [--verbose] [--format] [--format ] [--input ] [] + + ARGUMENTS: + Output file path + + OPTIONS: + --verbose Enable verbose output + --format [] Output format (default: json) + --input Input file path + -h, --help Show help information. + + """) + } +} + +// MARK: - Edge Cases + +extension FlagOptionHelpGenerationTests { + struct EmptyDefaultFlagOption: ParsableArguments { + @FlagOption(defaultValue: "", help: "Empty default value") + var empty: String + } + + @Test func emptyDefaultFlagOptionHelp() { + AssertHelp( + .default, for: EmptyDefaultFlagOption.self, + equals: """ + USAGE: empty-default-flag-option [--empty] [--empty ] + + OPTIONS: + --empty [] Empty default value + -h, --help Show help information. + + """) + } + + struct LongNameFlagOption: ParsableArguments { + @FlagOption( + defaultValue: "value", + help: + "This is a very long help text that should wrap properly when displayed in the help output and test the formatting capabilities of the help generator." + ) + var veryLongOptionNameThatShouldStillDisplayProperly: String + } + + @Test func longNameFlagOptionHelp() { + AssertHelp( + .default, + for: LongNameFlagOption.self, + columns: 100, + equals: """ + USAGE: long-name-flag-option [--very-long-option-name-that-should-still-display-properly] [--very-long-option-name-that-should-still-display-properly ] + + OPTIONS: + --very-long-option-name-that-should-still-display-properly [] + This is a very long help text that should wrap + properly when displayed in the help output and test the + formatting capabilities of the help generator. + (default: value) + -h, --help Show help information. + + """) + } +} + +// MARK: - Boolean FlagOption + +extension FlagOptionHelpGenerationTests { + struct BooleanFlagOption: ParsableArguments { + @FlagOption(defaultValue: false, help: "Enable debug mode") + var debug: Bool + } + + @Test func booleanFlagOptionHelp() { + AssertHelp( + .default, for: BooleanFlagOption.self, + equals: """ + USAGE: boolean-flag-option [--debug] [--debug ] + + OPTIONS: + --debug [] Enable debug mode (default: false) + -h, --help Show help information. + + """) + } + + struct BooleanTrueFlagOption: ParsableArguments { + @FlagOption(defaultValue: true, help: "Disable compression") + var noCompress: Bool + } + + @Test func booleanTrueFlagOptionHelp() { + AssertHelp( + .default, for: BooleanTrueFlagOption.self, + equals: """ + USAGE: boolean-true-flag-option [--no-compress] [--no-compress ] + + OPTIONS: + --no-compress [] + Disable compression (default: true) + -h, --help Show help information. + + """) + } +}