Skip to content

Commit 05cfc38

Browse files
authored
Add a parent command property wrapper to gain access to parent state (#802)
* Add a parent command property wrapper to gain access to parent state * Add test for parent command option leakage into the child command help. * Add check for parent options leaking into the child JSON dump
1 parent d1ddac8 commit 05cfc38

File tree

5 files changed

+200
-1
lines changed

5 files changed

+200
-1
lines changed
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift Argument Parser open source project
4+
//
5+
// Copyright (c) 2025 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See https://swift.org/LICENSE.txt for license information
9+
//
10+
//===----------------------------------------------------------------------===//
11+
12+
/// A wrapper that adds a reference to a parent command.
13+
///
14+
/// Use the `@ParentCommand` wrapper to gain access to a parent command's state.
15+
///
16+
/// The arguments, options, and flags in a `@ParentCommand` type are omitted from
17+
/// the help screen for the including child command, and only appear in the parent's
18+
/// help screen. To include the help in both screens, use the ``OptionGroup``
19+
/// wrapper instead.
20+
///
21+
///
22+
/// ```swift
23+
/// struct SuperCommand: ParsableCommand {
24+
/// static let configuration = CommandConfiguration(
25+
/// subcommands: [SubCommand.self]
26+
/// )
27+
///
28+
/// @Flag(name: .shortAndLong)
29+
/// var verbose: Bool = false
30+
/// }
31+
///
32+
/// struct SubCommand: ParsableCommand {
33+
/// @ParentCommand var parent: SuperCommand
34+
///
35+
/// mutating func run() throws {
36+
/// if self.parent.verbose {
37+
/// print("Verbose")
38+
/// }
39+
/// }
40+
/// }
41+
/// ```
42+
@propertyWrapper
43+
public struct ParentCommand<Value: ParsableCommand>: Decodable, ParsedWrapper {
44+
internal var _parsedValue: Parsed<Value>
45+
46+
internal init(_parsedValue: Parsed<Value>) {
47+
self._parsedValue = _parsedValue
48+
}
49+
50+
public init(from _decoder: Decoder) throws {
51+
if let d = _decoder as? SingleValueDecoder,
52+
let value = try? d.previousValue(Value.self)
53+
{
54+
self.init(_parsedValue: .value(value))
55+
} else {
56+
throw ParserError.notParentCommand("\(Value.self)")
57+
}
58+
}
59+
60+
public init() {
61+
self.init(
62+
_parsedValue: .init { _ in
63+
.init()
64+
}
65+
)
66+
}
67+
68+
public var wrappedValue: Value {
69+
get {
70+
switch _parsedValue {
71+
case .value(let v):
72+
return v
73+
case .definition:
74+
configurationFailure(directlyInitializedError)
75+
}
76+
}
77+
set {
78+
_parsedValue = .value(newValue)
79+
}
80+
}
81+
}
82+
83+
extension ParentCommand: Sendable where Value: Sendable {}
84+
85+
extension ParentCommand: CustomStringConvertible {
86+
public var description: String {
87+
switch _parsedValue {
88+
case .value(let v):
89+
return String(describing: v)
90+
case .definition:
91+
return "ParentCommand(*definition*)"
92+
}
93+
}
94+
}

Sources/ArgumentParser/Parsing/ParserError.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ enum ParserError: Error {
3838
case missingSubcommand
3939
case userValidationError(Error)
4040
case noArguments(Error)
41+
case notParentCommand(String)
4142
}
4243

4344
/// These are errors used internally to the parsing, and will not be exposed to the help generation.

Sources/ArgumentParser/Usage/UsageGenerator.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,8 @@ extension ErrorMessageGenerator {
226226
default:
227227
return error.describe()
228228
}
229+
case .notParentCommand(let parent):
230+
return "Command '\(parent)' is not a parent of the current command."
229231
}
230232
}
231233

Sources/ArgumentParserTestHelpers/TestHelpers.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,26 @@ public func AssertParseCommand<A: ParsableCommand>(
154154
}
155155
}
156156

157+
// swift-format-ignore: AlwaysUseLowerCamelCase
158+
public func AssertParseCommandErrorMessage<A: ParsableCommand>(
159+
_ rootCommand: ParsableCommand.Type, _ type: A.Type, _ arguments: [String],
160+
_ errorMessage: String,
161+
file: StaticString = #filePath, line: UInt = #line
162+
) {
163+
do {
164+
let command = try rootCommand.parseAsRoot(arguments)
165+
guard (command as? A) != nil else {
166+
XCTFail(
167+
"Command is of unexpected type: \(command)", file: (file), line: line)
168+
return
169+
}
170+
XCTFail("Parsing as root should have failed.", file: file, line: line)
171+
} catch {
172+
let message = rootCommand.message(for: error)
173+
XCTAssertEqual(message, errorMessage, file: file, line: line)
174+
}
175+
}
176+
157177
// swift-format-ignore: AlwaysUseLowerCamelCase
158178
public func AssertEqualStrings(
159179
actual: String,

Tests/ArgumentParserEndToEndTests/DefaultSubcommandEndToEndTests.swift

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
//===----------------------------------------------------------------------===//
1111

1212
import ArgumentParserTestHelpers
13+
import ArgumentParserToolInfo
1314
import XCTest
1415

1516
@testable import ArgumentParser
@@ -75,10 +76,14 @@ extension DefaultSubcommandEndToEndTests {
7576
extension DefaultSubcommandEndToEndTests {
7677
fileprivate struct MyCommand: ParsableCommand {
7778
static let configuration = CommandConfiguration(
78-
subcommands: [Plugin.self, NonDefault.self, Other.self],
79+
subcommands: [
80+
Plugin.self, NonDefault.self, Other.self, Child.self, BadParent.self,
81+
],
7982
defaultSubcommand: Plugin.self
8083
)
8184

85+
@Option var foo: String?
86+
8287
@OptionGroup
8388
var options: CommonOptions
8489
}
@@ -110,6 +115,83 @@ extension DefaultSubcommandEndToEndTests {
110115
@OptionGroup var options: CommonOptions
111116
}
112117

118+
fileprivate struct Child: ParsableCommand {
119+
@ParentCommand var parent: MyCommand
120+
}
121+
122+
fileprivate struct BadParent: ParsableCommand {
123+
@ParentCommand var notMyParent: Other
124+
}
125+
126+
func testAccessToParent() throws {
127+
AssertParseCommand(
128+
MyCommand.self, Child.self, ["--verbose", "--foo=bar", "child"]
129+
) { child in
130+
XCTAssertEqual(child.parent.foo, "bar")
131+
XCTAssertEqual(child.parent.options.verbose, true)
132+
}
133+
}
134+
135+
func testNotMyParent() throws {
136+
AssertParseCommandErrorMessage(
137+
MyCommand.self, BadParent.self, ["--verbose", "bad-parent"],
138+
"Command 'Other' is not a parent of the current command.")
139+
}
140+
141+
func testNotLeakingParentOptions() throws {
142+
// Verify that the help for the child command doesn't leak the parent command's options in the help
143+
let childHelp = MyCommand.message(for: CleanExit.helpRequest(Child.self))
144+
XCTAssertEqual(
145+
childHelp,
146+
"""
147+
USAGE: my-command child
148+
149+
OPTIONS:
150+
-h, --help Show help information.
151+
152+
""")
153+
154+
// Now check that the foo option doesn't leak into the JSON dump
155+
let toolInfo = ToolInfoV0(commandStack: [MyCommand.self.asCommand])
156+
157+
let arguments = toolInfo.command.arguments
158+
guard let arguments else {
159+
XCTFail(
160+
"MyCommand is expected to have a top-level command arguments in its tool info"
161+
)
162+
return
163+
}
164+
165+
let subcommands = toolInfo.command.subcommands
166+
guard let subcommands else {
167+
XCTFail(
168+
"MyCommand is expected to have a top-level command arguments in its tool info"
169+
)
170+
return
171+
}
172+
173+
// The foo option is present int he parent
174+
XCTAssertNotNil(arguments.first { $0.valueName == "foo" })
175+
176+
let childInfo = subcommands.first { cmd in
177+
cmd.commandName == "child"
178+
}
179+
180+
guard let childInfo else {
181+
XCTFail("The child subcommand is expected to be present in the tool info")
182+
return
183+
}
184+
185+
guard let childArguments = childInfo.arguments else {
186+
XCTFail(
187+
"The child subcommand is expected to have arguments in the tool info")
188+
return
189+
}
190+
191+
// It's not there in the child subcommand
192+
XCTAssertNil(childArguments.first { $0.valueName == "foo" })
193+
}
194+
113195
func testRemainingDefaultImplicit() throws {
114196
AssertParseCommand(MyCommand.self, Plugin.self, ["my-plugin"]) { plugin in
115197
XCTAssertEqual(plugin.pluginName, "my-plugin")

0 commit comments

Comments
 (0)