diff --git a/Sources/ArgumentParser/Parsable Properties/ParentCommand.swift b/Sources/ArgumentParser/Parsable Properties/ParentCommand.swift new file mode 100644 index 000000000..308da9e8e --- /dev/null +++ b/Sources/ArgumentParser/Parsable Properties/ParentCommand.swift @@ -0,0 +1,94 @@ +//===----------------------------------------------------------------------===// +// +// 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 wrapper that adds a reference to a parent command. +/// +/// Use the `@ParentCommand` wrapper to gain access to a parent command's state. +/// +/// The arguments, options, and flags in a `@ParentCommand` type are omitted from +/// the help screen for the including child command, and only appear in the parent's +/// help screen. To include the help in both screens, use the ``OptionGroup`` +/// wrapper instead. +/// +/// +/// ```swift +/// struct SuperCommand: ParsableCommand { +/// static let configuration = CommandConfiguration( +/// subcommands: [SubCommand.self] +/// ) +/// +/// @Flag(name: .shortAndLong) +/// var verbose: Bool = false +/// } +/// +/// struct SubCommand: ParsableCommand { +/// @ParentCommand var parent: SuperCommand +/// +/// mutating func run() throws { +/// if self.parent.verbose { +/// print("Verbose") +/// } +/// } +/// } +/// ``` +@propertyWrapper +public struct ParentCommand: Decodable, ParsedWrapper { + internal var _parsedValue: Parsed + + internal init(_parsedValue: Parsed) { + self._parsedValue = _parsedValue + } + + public init(from _decoder: Decoder) throws { + if let d = _decoder as? SingleValueDecoder, + let value = try? d.previousValue(Value.self) + { + self.init(_parsedValue: .value(value)) + } else { + throw ParserError.notParentCommand("\(Value.self)") + } + } + + public init() { + self.init( + _parsedValue: .init { _ in + .init() + } + ) + } + + public var wrappedValue: Value { + get { + switch _parsedValue { + case .value(let v): + return v + case .definition: + configurationFailure(directlyInitializedError) + } + } + set { + _parsedValue = .value(newValue) + } + } +} + +extension ParentCommand: Sendable where Value: Sendable {} + +extension ParentCommand: CustomStringConvertible { + public var description: String { + switch _parsedValue { + case .value(let v): + return String(describing: v) + case .definition: + return "ParentCommand(*definition*)" + } + } +} diff --git a/Sources/ArgumentParser/Parsing/ParserError.swift b/Sources/ArgumentParser/Parsing/ParserError.swift index 35bb25436..dd0c0e1b2 100644 --- a/Sources/ArgumentParser/Parsing/ParserError.swift +++ b/Sources/ArgumentParser/Parsing/ParserError.swift @@ -38,6 +38,7 @@ enum ParserError: Error { case missingSubcommand case userValidationError(Error) case noArguments(Error) + case notParentCommand(String) } /// These are errors used internally to the parsing, and will not be exposed to the help generation. diff --git a/Sources/ArgumentParser/Usage/UsageGenerator.swift b/Sources/ArgumentParser/Usage/UsageGenerator.swift index 154ac90bf..5adc83fe6 100644 --- a/Sources/ArgumentParser/Usage/UsageGenerator.swift +++ b/Sources/ArgumentParser/Usage/UsageGenerator.swift @@ -226,6 +226,8 @@ extension ErrorMessageGenerator { default: return error.describe() } + case .notParentCommand(let parent): + return "Command '\(parent)' is not a parent of the current command." } } diff --git a/Sources/ArgumentParserTestHelpers/TestHelpers.swift b/Sources/ArgumentParserTestHelpers/TestHelpers.swift index 91a1eadd4..bb8a65752 100644 --- a/Sources/ArgumentParserTestHelpers/TestHelpers.swift +++ b/Sources/ArgumentParserTestHelpers/TestHelpers.swift @@ -154,6 +154,26 @@ public func AssertParseCommand( } } +// swift-format-ignore: AlwaysUseLowerCamelCase +public func AssertParseCommandErrorMessage( + _ rootCommand: ParsableCommand.Type, _ type: A.Type, _ arguments: [String], + _ errorMessage: String, + file: StaticString = #filePath, line: UInt = #line +) { + do { + let command = try rootCommand.parseAsRoot(arguments) + guard (command as? A) != nil else { + XCTFail( + "Command is of unexpected type: \(command)", file: (file), line: line) + return + } + XCTFail("Parsing as root should have failed.", file: file, line: line) + } catch { + let message = rootCommand.message(for: error) + XCTAssertEqual(message, errorMessage, file: file, line: line) + } +} + // swift-format-ignore: AlwaysUseLowerCamelCase public func AssertEqualStrings( actual: String, diff --git a/Tests/ArgumentParserEndToEndTests/DefaultSubcommandEndToEndTests.swift b/Tests/ArgumentParserEndToEndTests/DefaultSubcommandEndToEndTests.swift index e0a4afa30..18c593372 100644 --- a/Tests/ArgumentParserEndToEndTests/DefaultSubcommandEndToEndTests.swift +++ b/Tests/ArgumentParserEndToEndTests/DefaultSubcommandEndToEndTests.swift @@ -10,6 +10,7 @@ //===----------------------------------------------------------------------===// import ArgumentParserTestHelpers +import ArgumentParserToolInfo import XCTest @testable import ArgumentParser @@ -75,10 +76,14 @@ extension DefaultSubcommandEndToEndTests { extension DefaultSubcommandEndToEndTests { fileprivate struct MyCommand: ParsableCommand { static let configuration = CommandConfiguration( - subcommands: [Plugin.self, NonDefault.self, Other.self], + subcommands: [ + Plugin.self, NonDefault.self, Other.self, Child.self, BadParent.self, + ], defaultSubcommand: Plugin.self ) + @Option var foo: String? + @OptionGroup var options: CommonOptions } @@ -110,6 +115,83 @@ extension DefaultSubcommandEndToEndTests { @OptionGroup var options: CommonOptions } + fileprivate struct Child: ParsableCommand { + @ParentCommand var parent: MyCommand + } + + fileprivate struct BadParent: ParsableCommand { + @ParentCommand var notMyParent: Other + } + + func testAccessToParent() throws { + AssertParseCommand( + MyCommand.self, Child.self, ["--verbose", "--foo=bar", "child"] + ) { child in + XCTAssertEqual(child.parent.foo, "bar") + XCTAssertEqual(child.parent.options.verbose, true) + } + } + + func testNotMyParent() throws { + AssertParseCommandErrorMessage( + MyCommand.self, BadParent.self, ["--verbose", "bad-parent"], + "Command 'Other' is not a parent of the current command.") + } + + func testNotLeakingParentOptions() throws { + // Verify that the help for the child command doesn't leak the parent command's options in the help + let childHelp = MyCommand.message(for: CleanExit.helpRequest(Child.self)) + XCTAssertEqual( + childHelp, + """ + USAGE: my-command child + + OPTIONS: + -h, --help Show help information. + + """) + + // Now check that the foo option doesn't leak into the JSON dump + let toolInfo = ToolInfoV0(commandStack: [MyCommand.self.asCommand]) + + let arguments = toolInfo.command.arguments + guard let arguments else { + XCTFail( + "MyCommand is expected to have a top-level command arguments in its tool info" + ) + return + } + + let subcommands = toolInfo.command.subcommands + guard let subcommands else { + XCTFail( + "MyCommand is expected to have a top-level command arguments in its tool info" + ) + return + } + + // The foo option is present int he parent + XCTAssertNotNil(arguments.first { $0.valueName == "foo" }) + + let childInfo = subcommands.first { cmd in + cmd.commandName == "child" + } + + guard let childInfo else { + XCTFail("The child subcommand is expected to be present in the tool info") + return + } + + guard let childArguments = childInfo.arguments else { + XCTFail( + "The child subcommand is expected to have arguments in the tool info") + return + } + + // It's not there in the child subcommand + XCTAssertNil(childArguments.first { $0.valueName == "foo" }) + } + func testRemainingDefaultImplicit() throws { AssertParseCommand(MyCommand.self, Plugin.self, ["my-plugin"]) { plugin in XCTAssertEqual(plugin.pluginName, "my-plugin")