Skip to content
Merged
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
94 changes: 94 additions & 0 deletions Sources/ArgumentParser/Parsable Properties/ParentCommand.swift
Original file line number Diff line number Diff line change
@@ -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<Value: ParsableCommand>: Decodable, ParsedWrapper {
internal var _parsedValue: Parsed<Value>

internal init(_parsedValue: Parsed<Value>) {
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*)"
}
}
}
1 change: 1 addition & 0 deletions Sources/ArgumentParser/Parsing/ParserError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions Sources/ArgumentParser/Usage/UsageGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}

Expand Down
20 changes: 20 additions & 0 deletions Sources/ArgumentParserTestHelpers/TestHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,26 @@ public func AssertParseCommand<A: ParsableCommand>(
}
}

// swift-format-ignore: AlwaysUseLowerCamelCase
public func AssertParseCommandErrorMessage<A: ParsableCommand>(
_ 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
//===----------------------------------------------------------------------===//

import ArgumentParserTestHelpers
import ArgumentParserToolInfo
import XCTest

@testable import ArgumentParser
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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")
Expand Down