Skip to content
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

Add API for titling an option group #492

Merged
merged 8 commits into from Sep 26, 2022
Merged
Expand Up @@ -74,7 +74,7 @@ OPTIONS:
-h, --help Show help information.
```

### Controlling Argument Visibility
## Controlling Argument Visibility

You can specify the visibility of any argument, option, or flag.

Expand Down Expand Up @@ -141,3 +141,56 @@ OPTIONS:
Use advanced security. (experimental)
-h, --help Show help information.
```

## Grouping Arguments in the Help Screen

When you provide a title in an `@OptionGroup` declaration, that type's
properties are grouped together under your title in the help screen.
For example, this command bundles similar arguments together under a
"Build Options" title:

```swift
struct BuildOptions: ParsableArguments {
@Option(help: "A setting to pass to the compiler.")
var compilerSetting: [String] = []

@Option(help: "A setting to pass to the linker.")
var linkerSetting: [String] = []
}

struct Example: ParsableCommand {
@Argument(help: "The input file to process.")
var inputFile: String

@Flag(help: "Show extra output.")
var verbose: Bool = false

@Option(help: "The path to a configuration file.")
var configFile: String?

@OptionGroup(title: "Build Options")
var buildOptions: BuildOptions
}
```

This grouping is reflected in the command's help screen:

```
% example --help
USAGE: example <input-file> [--verbose] [--config-file <config-file>] [--compiler-setting <compiler-setting> ...] [--linker-setting <linker-setting> ...]

ARGUMENTS:
<input-file> The input file to process.

BUILD OPTIONS:
--compiler-setting <compiler-setting>
A setting to pass to the compiler.
--linker-setting <linker-setting>
A setting to pass to the linker.

OPTIONS:
--verbose Show extra output.
--config-file <config-file>
The path to a configuration file.
-h, --help Show help information.
```
36 changes: 33 additions & 3 deletions Sources/ArgumentParser/Parsable Properties/OptionGroup.swift
Expand Up @@ -36,6 +36,9 @@ public struct OptionGroup<Value: ParsableArguments>: Decodable, ParsedWrapper {
// FIXME: Adding this property works around the crasher described in
// https://github.com/apple/swift-argument-parser/issues/338
internal var _dummy: Bool = false

/// The title to use in the help screen for this option group.
public var title: String = ""

internal init(_parsedValue: Parsed<Value>) {
self._parsedValue = _parsedValue
Expand All @@ -62,12 +65,27 @@ public struct OptionGroup<Value: ParsableArguments>: Decodable, ParsedWrapper {
}

/// Creates a property that represents another parsable type, using the
/// specified visibility.
public init(visibility: ArgumentVisibility = .default) {
/// specified title and visibility.
///
/// - Parameters:
/// - title: A title for grouping this option group's members in your
/// command's help screen. If `title` is empty, the members will be
/// displayed alongside the other arguments, flags, and options declared
/// by your command.
/// - visibility: The visibility to use for the entire option group.
public init(
title: String = "",
visibility: ArgumentVisibility = .default
) {
self.init(_parsedValue: .init { parentKey in
return ArgumentSet(Value.self, visibility: .private, parent: .key(parentKey))
var args = ArgumentSet(Value.self, visibility: .private, parent: .key(parentKey))
args.content.withEach {
$0.help.parentTitle = title
}
return args
})
self._visibility = visibility
self.title = title
}

/// The value presented by this property wrapper.
Expand Down Expand Up @@ -111,3 +129,15 @@ extension OptionGroup {
self.init(visibility: .default)
}
}

// MARK: Deprecated

extension OptionGroup {
@_disfavoredOverload
@available(*, deprecated, renamed: "init(title:visibility:)")
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we need this still, SAP isn't ABI stable and the above initializer is source compatible?

Copy link
Contributor

Choose a reason for hiding this comment

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

eh, well very very minimally source breaking if someone ever used a point free version of the function: OptionGroup.init(visibility:)I really doubt this is the case though.

public init(
visibility _visibility: ArgumentVisibility = .default
) {
self.init(title: "", visibility: _visibility)
}
}
2 changes: 2 additions & 0 deletions Sources/ArgumentParser/Parsing/ArgumentDefinition.swift
Expand Up @@ -54,6 +54,7 @@ struct ArgumentDefinition {
var discussion: String
var valueName: String
var visibility: ArgumentVisibility
var parentTitle: String

init(
allValues: [String],
Expand All @@ -72,6 +73,7 @@ struct ArgumentDefinition {
self.discussion = help?.discussion ?? ""
self.valueName = help?.valueName ?? ""
self.visibility = help?.visibility ?? .default
self.parentTitle = ""
}
}

Expand Down
1 change: 1 addition & 0 deletions Sources/ArgumentParser/Usage/DumpHelpGenerator.swift
Expand Up @@ -126,6 +126,7 @@ fileprivate extension ArgumentInfoV0 {
self.init(
kind: kind,
shouldDisplay: argument.help.visibility.base == .default,
sectionTitle: argument.help.parentTitle.nonEmpty,
isOptional: argument.help.options.contains(.isOptional),
isRepeating: argument.help.options.contains(.isRepeating),
names: argument.names.map(ArgumentInfoV0.NameInfoV0.init),
Expand Down
25 changes: 23 additions & 2 deletions Sources/ArgumentParser/Usage/HelpGenerator.swift
Expand Up @@ -51,6 +51,7 @@ internal struct HelpGenerator {
case positionalArguments
case subcommands
case options
case title(String)

var description: String {
switch self {
Expand All @@ -60,6 +61,8 @@ internal struct HelpGenerator {
return "Subcommands"
case .options:
return "Options"
case .title(let name):
return name
}
}
}
Expand Down Expand Up @@ -136,6 +139,10 @@ internal struct HelpGenerator {

var positionalElements: [Section.Element] = []
var optionElements: [Section.Element] = []

// Simulate an ordered dictionary using a dictionary and array for ordering.
var titledSections: [String: [Section.Element]] = [:]
var sectionTitles: [String] = []
Copy link
Contributor

Choose a reason for hiding this comment

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

is this needed? It seems like a duplicate of titledSections.keys

Copy link
Contributor

Choose a reason for hiding this comment

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

NVM this is so they are sorted. A comment about this would be nice!


/// Start with a full slice of the ArgumentSet so we can peel off one or
/// more elements at a time.
Expand Down Expand Up @@ -183,9 +190,15 @@ internal struct HelpGenerator {
}

let element = Section.Element(label: synopsis, abstract: description, discussion: arg.help.discussion)
if case .positional = arg.kind {
switch (arg.kind, arg.help.parentTitle) {
case (_, let sectionTitle) where !sectionTitle.isEmpty:
if !titledSections.keys.contains(sectionTitle) {
sectionTitles.append(sectionTitle)
}
titledSections[sectionTitle, default: []].append(element)
case (.positional, _):
positionalElements.append(element)
} else {
default:
optionElements.append(element)
}
}
Expand All @@ -203,8 +216,16 @@ internal struct HelpGenerator {
abstract: command.configuration.abstract)
}

// Combine the compiled groups in this order:
// - arguments
// - named sections
// - options/flags
// - subcommands
Comment on lines +219 to +223
Copy link
Member Author

Choose a reason for hiding this comment

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

@rauhul @ethan-kusters Does this seem like the correct order?

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah this makes sense to me!

return [
Section(header: .positionalArguments, elements: positionalElements),
] + sectionTitles.map { name in
Section(header: .title(name), elements: titledSections[name, default: []])
} + [
Section(header: .options, elements: optionElements),
Section(header: .subcommands, elements: subcommandElements),
]
Expand Down
10 changes: 10 additions & 0 deletions Sources/ArgumentParser/Utilities/CollectionExtensions.swift
Expand Up @@ -14,3 +14,13 @@ extension Collection {
isEmpty ? replacement() : self
}
}

extension MutableCollection {
mutating func withEach(_ body: (inout Element) throws -> Void) rethrows {
var i = startIndex
while i < endIndex {
try body(&self[i])
formIndex(after: &i)
}
}
}
5 changes: 4 additions & 1 deletion Sources/ArgumentParser/Utilities/StringExtensions.swift
Expand Up @@ -132,7 +132,6 @@ extension StringProtocol where SubSequence == Substring {
/// // 3
/// "bar".editDistance(to: "baz")
/// // 1

func editDistance(to target: String) -> Int {
let rows = self.count
let columns = target.count
Expand Down Expand Up @@ -239,4 +238,8 @@ extension StringProtocol where SubSequence == Substring {
guard lines.count == 2 else { return lines.joined(separator: "") }
return "\(lines[0])\n\(lines[1].indentingEachLine(by: n))"
}

var nonEmpty: Self? {
isEmpty ? nil : self
}
}
6 changes: 6 additions & 0 deletions Sources/ArgumentParserToolInfo/ToolInfo.swift
Expand Up @@ -122,6 +122,9 @@ public struct ArgumentInfoV0: Codable, Hashable {

/// Argument should appear in help displays.
public var shouldDisplay: Bool
/// Custom name of argument's section.
public var sectionTitle: String?

/// Argument can be omitted.
public var isOptional: Bool
/// Argument can be specified multiple times.
Expand All @@ -147,6 +150,7 @@ public struct ArgumentInfoV0: Codable, Hashable {
public init(
kind: KindV0,
shouldDisplay: Bool,
sectionTitle: String?,
isOptional: Bool,
isRepeating: Bool,
names: [NameInfoV0]?,
Expand All @@ -160,6 +164,8 @@ public struct ArgumentInfoV0: Codable, Hashable {
self.kind = kind

self.shouldDisplay = shouldDisplay
self.sectionTitle = sectionTitle

self.isOptional = isOptional
self.isRepeating = isRepeating

Expand Down
1 change: 1 addition & 0 deletions Tests/ArgumentParserUnitTests/CMakeLists.txt
Expand Up @@ -4,6 +4,7 @@ add_library(UnitTests
HelpGenerationTests.swift
HelpGenerationTests+AtArgument.swift
HelpGenerationTests+AtOption.swift
HelpGenerationTests+GroupName.swift
NameSpecificationTests.swift
SplitArgumentTests.swift
StringSnakeCaseTests.swift
Expand Down
83 changes: 83 additions & 0 deletions Tests/ArgumentParserUnitTests/DumpHelpGenerationTests.swift
Expand Up @@ -50,10 +50,24 @@ extension DumpHelpGenerationTests {
var argWithDefaultValue: Int = 1
}

struct Options: ParsableArguments {
@Flag var verbose = false
@Option var name: String
}

struct B: ParsableCommand {
@OptionGroup(title: "Other")
var options: Options
}

public func testDumpA() throws {
try AssertDump(for: A.self, equals: Self.aDumpText)
}

public func testDumpB() throws {
try AssertDump(for: B.self, equals: Self.bDumpText)
}

public func testDumpExampleCommands() throws {
struct TestCase {
let expected: String
Expand Down Expand Up @@ -233,6 +247,75 @@ extension DumpHelpGenerationTests {
}
"""

static let bDumpText: String = """
{
"command" : {
"arguments" : [
{
"isOptional" : true,
"isRepeating" : false,
"kind" : "flag",
"names" : [
{
"kind" : "long",
"name" : "verbose"
}
],
"preferredName" : {
"kind" : "long",
"name" : "verbose"
},
"sectionTitle" : "Other",
"shouldDisplay" : true,
"valueName" : "verbose"
},
{
"isOptional" : false,
"isRepeating" : false,
"kind" : "option",
"names" : [
{
"kind" : "long",
"name" : "name"
}
],
"preferredName" : {
"kind" : "long",
"name" : "name"
},
"sectionTitle" : "Other",
"shouldDisplay" : true,
"valueName" : "name"
},
{
"abstract" : "Show help information.",
"isOptional" : true,
"isRepeating" : false,
"kind" : "flag",
"names" : [
{
"kind" : "short",
"name" : "h"
},
{
"kind" : "long",
"name" : "help"
}
],
"preferredName" : {
"kind" : "long",
"name" : "help"
},
"shouldDisplay" : true,
"valueName" : "help"
}
],
"commandName" : "b"
},
"serializationVersion" : 0
}
"""

static let mathDumpText: String = """
{
"command" : {
Expand Down