-
Notifications
You must be signed in to change notification settings - Fork 349
Add a parent command property wrapper to gain access to parent state #802
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 10 commits
087fbd7
4a6db87
0a70a71
15eca88
b508e30
77cbdf3
8b3041d
df78564
4b15c4c
c938e1f
d913990
9e3dfb5
8500d0b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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. | ||
/// | ||
/// ```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 | ||
|
||
|
||
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*)" | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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,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" }) | ||
|
||
} | ||
|
||
func testRemainingDefaultImplicit() throws { | ||
AssertParseCommand(MyCommand.self, Plugin.self, ["my-plugin"]) { plugin in | ||
XCTAssertEqual(plugin.pluginName, "my-plugin") | ||
|
There was a problem hiding this comment.
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:There was a problem hiding this comment.
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.