Skip to content

Commit

Permalink
Add API for titling an option group (#492)
Browse files Browse the repository at this point in the history
This change lets you provide a title for option groups, which is used
when generating the help screen. Titled option groups, when they exist,
are placed between the ARGUMENTS and OPTIONS section of the help.
Multiple option groups with the same title are coalesced into a single
group.

For example, this command declaration:

    struct Extras: ParsableArguments {
      @Flag(help: "Print extra output while processing.")
      var verbose: Bool = false

      @Flag(help: "Include details no one asked for.")
      var oversharing: Bool = false
    }

    @main
    struct Example: ParsableCommand {
      @OptionGroup(title: "Extras")
      var extras: Extras

      @argument var name: String?
      @option var title: String?
    }

yields this help screen:

    USAGE: example [--verbose] [--oversharing] [<name>] [--title <title>]

    ARGUMENTS:
      <name>

    EXTRAS:
      --verbose               Print extra output while processing.
      --oversharing           Include details no one asked for.

    OPTIONS:
      --title <title>
      -h, --help              Show help information.
  • Loading branch information
natecook1000 committed Sep 26, 2022
1 parent d88d4de commit b80fb05
Show file tree
Hide file tree
Showing 11 changed files with 588 additions and 7 deletions.
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:)")
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] = []

/// 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
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

0 comments on commit b80fb05

Please sign in to comment.