Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
91 changes: 91 additions & 0 deletions Sources/ArgumentParser/Parsable Properties/ParentCommand.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
//===----------------------------------------------------------------------===//
//
// 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 parent command wrapper to gain access to a parent command's state.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be useful to compare/contrast with @OptionGroup here. Something like:

Suggested change
/// Use the parent command wrapper to gain access to a parent command's state.
/// 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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I've incorporated this additional text in the latest commit.

///
/// ```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 var _visibility: ArgumentVisibility
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this property?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This property isn't needed. The visibility is always effectively hidden for parent command references. It's removed now.


internal init(_parsedValue: Parsed<Value>) {
self._parsedValue = _parsedValue
self._visibility = .default
}

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 {
// TODO produce a specialized error in the case where the parent is not in fact a parent of this command
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,53 @@ 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])
// It's there in the parent
XCTAssertNotNil(toolInfo.command.arguments!.first { $0.valueName == "foo" })
let childInfo = toolInfo.command.subcommands!.first { cmd in
cmd.commandName == "child"
}
// It's not there in the child subcommand
XCTAssertNil(childInfo!.arguments!.first { $0.valueName == "foo" })
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you leave out the force unwraps here? The output is a better when there's a test failure rather than a trap during the test.

}

func testRemainingDefaultImplicit() throws {
AssertParseCommand(MyCommand.self, Plugin.self, ["my-plugin"]) { plugin in
XCTAssertEqual(plugin.pluginName, "my-plugin")
Expand Down