Files
Bassam (Sam) Khouri a78d98b92d Augment Option to support default as flag option (#830)
In some use cases, there is a need to have an option argument behave
like a flag.

This change introduced 4 new intialiazers to `Option` that accept a
`defaultAsFlag` value.

With the following usage:

```swift
struct Example: ParsableCommand {
    @Option(defaultAsFlag: "default", help: "Set output format.")
    var format: String?
    func run() {
        print("Format: \(format ?? "none")")
    }
}
```

The `defaultAsFlag` parameter creates a hybrid that supports both patterns:
  - **Flag behavior**: `--format` (sets format to "default")
  - **Option behavior**: `--format json` (sets format to "json")
  - **No usage**: format remains `nil`

As a user of the command line tool, the `--help` output clearly distinguishes
between the the hybrid and regular usages.

```
OPTIONS:
  --format [<format>]     Set output format. (default as flag: default)
````

Note the `(default as flag: ...)` text instead of regular `(default: ...)`,
and the optional value syntax `[<value>]` instead of required `<value>`.

Fixes: #829
2026-03-23 14:47:26 -05:00

524 lines
16 KiB
Swift

//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift Argument Parser open source project
//
// Copyright (c) 2020 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
//
//===----------------------------------------------------------------------===//
struct UsageGenerator {
var toolName: String
var definition: ArgumentSet
}
extension UsageGenerator {
init(definition: ArgumentSet) {
let toolName =
CommandLine._staticArguments[0]
.split(separator: "/").last.map(String.init) ?? "<command>"
self.init(toolName: toolName, definition: definition)
}
init(
toolName: String, parsable: ParsableArguments,
visibility: ArgumentVisibility, parent: InputKey?
) {
self.init(
toolName: toolName,
definition: ArgumentSet(
type(of: parsable), visibility: visibility, parent: parent))
}
init(toolName: String, definition: [ArgumentSet]) {
self.init(toolName: toolName, definition: ArgumentSet(sets: definition))
}
}
extension UsageGenerator {
/// The tool synopsis.
///
/// In `roff`.
var synopsis: String {
var options = Array(definition)
switch options.count {
case 0:
return toolName
case let x where x > 12:
// When we have too many options, keep required and positional arguments,
// but discard the rest.
options = options.filter {
$0.isPositional || !$0.help.options.contains(.isOptional)
}
// If there are between 1 and 12 options left, print them, otherwise print
// a simplified usage string.
if !options.isEmpty, options.count <= 12 {
let synopsis =
options
.map { $0.synopsis }
.joined(separator: " ")
return "\(toolName) [<options>] \(synopsis)"
}
return "\(toolName) <options>"
default:
let synopsis =
options
.map { $0.synopsis }
.joined(separator: " ")
return "\(toolName) \(synopsis)"
}
}
}
extension ArgumentDefinition {
var synopsisForHelp: String {
switch kind {
case .named:
let joinedSynopsisString = names
.partitioned
.map { $0.synopsisString }
.joined(separator: ", ")
switch update {
case .unary:
return "\(joinedSynopsisString) <\(valueName)>"
case .nullary:
return joinedSynopsisString
case .optionalUnary:
return "\(joinedSynopsisString) [<\(valueName)>]"
}
case .positional:
return "<\(valueName)>"
case .default:
return ""
}
}
var unadornedSynopsis: String {
switch kind {
case .named:
guard let name = names.preferredName else {
fatalError("preferredName cannot be nil for named arguments")
}
switch update {
case .unary:
return "\(name.synopsisString) <\(valueName)>"
case .nullary:
return name.synopsisString
case .optionalUnary:
return "\(name.synopsisString) [<\(valueName)>]"
}
case .positional:
return "<\(valueName)>"
case .default:
return ""
}
}
var synopsis: String {
var synopsis = unadornedSynopsis
if help.options.contains(.isRepeating) {
synopsis += " ..."
}
if help.options.contains(.isOptional) {
synopsis = "[\(synopsis)]"
}
if parsingStrategy == .postTerminator {
synopsis = "-- \(synopsis)"
}
return synopsis
}
}
extension ArgumentSet {
/// Will generate a descriptive help message if possible.
///
/// If no descriptive help message can be generated, `nil` will be returned.
///
/// - Parameter error: the parse error that occurred.
/// - Returns: An error description.
func errorDescription(error: Swift.Error) -> String? {
switch error {
case let parserError as ParserError:
return ErrorMessageGenerator(arguments: self, error: parserError)
.makeErrorMessage()
case let commandError as CommandError:
return ErrorMessageGenerator(
arguments: self, error: commandError.parserError
)
.makeErrorMessage()
default:
return nil
}
}
func helpDescription(error: Swift.Error) -> String? {
switch error {
case let parserError as ParserError:
return ErrorMessageGenerator(arguments: self, error: parserError)
.makeHelpMessage()
case let commandError as CommandError:
return ErrorMessageGenerator(
arguments: self, error: commandError.parserError
)
.makeHelpMessage()
default:
return nil
}
}
}
struct ErrorMessageGenerator {
var arguments: ArgumentSet
var error: ParserError
}
extension ErrorMessageGenerator {
func makeErrorMessage() -> String? {
switch error {
case .helpRequested, .versionRequested, .completionScriptRequested,
.completionScriptCustomResponse, .dumpHelpRequested:
return nil
case .unsupportedShell(let shell?):
return unsupportedShell(shell)
case .unsupportedShell:
return unsupportedAutodetectedShell
case .notImplemented:
return notImplementedMessage
case .invalidState:
return invalidState
case .unknownOption(let o, let n):
return unknownOptionMessage(origin: o, name: n)
case .missingValueForOption(let o, let n):
return missingValueForOptionMessage(origin: o, name: n)
case .missingValueOrUnknownCompositeOption(
let o, let shortName, let compositeName):
return missingValueOrUnknownCompositeOptionMessage(
origin: o, shortName: shortName, compositeName: compositeName)
case .unexpectedValueForOption(let o, let n, let v):
return unexpectedValueForOptionMessage(origin: o, name: n, value: v)
case .unexpectedExtraValues(let v):
return unexpectedExtraValuesMessage(values: v)
case .duplicateExclusiveValues(
let previous, let duplicate, originalInput: let arguments):
return duplicateExclusiveValues(
previous: previous, duplicate: duplicate, arguments: arguments)
case .noValue(forKey: let k):
return noValueMessage(key: k)
case .unableToParseValue(
let o, let n, let v, forKey: let k, originalError: let e):
return unableToParseValueMessage(
origin: o, name: n, value: v, key: k, error: e)
case .invalidOption(let str):
return "Invalid option: \(str)"
case .nonAlphanumericShortOption(let c):
return "Invalid option: -\(c)"
case .missingSubcommand:
return "Missing required subcommand."
case .userValidationError(let error):
return error.describe()
case .noArguments(let error):
switch error {
case let error as ParserError:
return ErrorMessageGenerator(arguments: self.arguments, error: error)
.makeErrorMessage()
default:
return error.describe()
}
case .notParentCommand(let parent):
return "Command '\(parent)' is not a parent of the current command."
}
}
func makeHelpMessage() -> String? {
switch error {
case .unableToParseValue(
let o, let n, let v, forKey: let k, originalError: let e):
return unableToParseHelpMessage(
origin: o, name: n, value: v, key: k, error: e)
case .missingValueForOption(_, let n):
return missingValueForOptionHelpMessage(name: n)
case .noValue(let k):
return noValueHelpMessage(key: k)
default:
return nil
}
}
}
extension ErrorMessageGenerator {
func arguments(for key: InputKey) -> [ArgumentDefinition] {
arguments
.filter { $0.help.keys.contains(key) }
}
func help(for key: InputKey) -> ArgumentDefinition.Help? {
arguments
.first { $0.help.keys.contains(key) }
.map { $0.help }
}
func valueName(for name: Name) -> String? {
arguments
.first { $0.names.contains(name) }
.map { $0.valueName }
}
}
extension ErrorMessageGenerator {
var notImplementedMessage: String {
"Internal error. Parsing command-line arguments hit unimplemented code path."
}
var invalidState: String {
"Internal error. Invalid state while parsing command-line arguments."
}
var unsupportedAutodetectedShell: String {
"""
Can't autodetect a supported shell.
Please use --generate-completion-script=<shell> with one of:
\(CompletionShell.allCases.map { $0.rawValue }.joined(separator: " "))
"""
}
func unsupportedShell(_ shell: String) -> String {
"""
Can't generate completion scripts for '\(shell)'.
Please use --generate-completion-script=<shell> with one of:
\(CompletionShell.allCases.map { $0.rawValue }.joined(separator: " "))
"""
}
func unknownOptionMessage(origin: InputOrigin.Element, name: Name) -> String {
if case .short = name {
return "Unknown option '\(name.synopsisString)'"
}
// An empirically derived magic number
let kSimilarityFloor = 4
let notShort: (Name) -> Bool = { (name: Name) in
switch name {
case .short: return false
case .long: return true
case .longWithSingleDash: return true
}
}
let suggestion =
arguments
.flatMap({ $0.names })
.filter({
$0.synopsisString.editDistance(to: name.synopsisString)
< kSimilarityFloor
}) // only include close enough suggestion
.filter(notShort) // exclude short option suggestions
.min(by: { lhs, rhs in // find the suggestion closest to the argument
lhs.synopsisString.editDistance(to: name.synopsisString)
< rhs.synopsisString.editDistance(to: name.synopsisString)
})
if let suggestion = suggestion {
return
"Unknown option '\(name.synopsisString)'. Did you mean '\(suggestion.synopsisString)'?"
}
return "Unknown option '\(name.synopsisString)'"
}
func missingValueForOptionMessage(origin: InputOrigin, name: Name) -> String {
if let valueName = valueName(for: name) {
return "Missing value for '\(name.synopsisString) <\(valueName)>'"
} else {
return "Missing value for '\(name.synopsisString)'"
}
}
func missingValueOrUnknownCompositeOptionMessage(
origin: InputOrigin,
shortName: Name,
compositeName: Name
) -> String {
let unknownOptionMessage = unknownOptionMessage(
origin: origin.firstElement,
name: compositeName)
let missingValueMessage = missingValueForOptionMessage(
origin: origin,
name: shortName)
return """
\(unknownOptionMessage)
or: \(missingValueMessage) in '\(compositeName.synopsisString)'
"""
}
func unexpectedValueForOptionMessage(
origin: InputOrigin.Element, name: Name, value: String
) -> String? {
"The option '\(name.synopsisString)' does not take any value, but '\(value)' was specified."
}
func unexpectedExtraValuesMessage(values: [(InputOrigin, String)]) -> String?
{
switch values.count {
case 0:
return nil
case 1:
// swift-format-ignore: NeverForceUnwrap
// We know that `values` is not empty.
return "Unexpected argument '\(values.first!.1)'"
default:
let v = values.map { $0.1 }.joined(separator: "', '")
return "\(values.count) unexpected arguments: '\(v)'"
}
}
func duplicateExclusiveValues(
previous: InputOrigin, duplicate: InputOrigin, arguments: [String]
) -> String? {
func elementString(_ origin: InputOrigin, _ arguments: [String]) -> String?
{
guard case .argumentIndex(let split) = origin.elements.first else {
return nil
}
var argument = "\'\(arguments[split.inputIndex.rawValue])\'"
if case .sub(let offsetIndex) = split.subIndex {
let stringIndex = argument.index(
argument.startIndex, offsetBy: offsetIndex + 2)
argument = "\'\(argument[stringIndex])\' in \(argument)"
}
return "flag \(argument)"
}
// Note that the RHS of these coalescing operators cannot be reached at this time.
let dupeString =
elementString(duplicate, arguments) ?? "position \(duplicate)"
let origString =
elementString(previous, arguments) ?? "position \(previous)"
//TODO: review this message once environment values are supported.
return
"Value to be set with \(dupeString) had already been set with \(origString)"
}
func noValueMessage(key: InputKey) -> String? {
let args = arguments(for: key)
let possibilities: [String] = args.compactMap {
$0.help.visibility.base == .default
? $0.nonOptional.synopsis
: nil
}
switch possibilities.count {
case 0:
return
"No value set for non-argument var \(key). Replace with a static variable, or let constant."
case 1:
// swift-format-ignore: NeverForceUnwrap
// We know that `possibilities` is not empty.
return "Missing expected argument '\(possibilities.first!)'"
default:
let p = possibilities.joined(separator: "', '")
return "Missing one of: '\(p)'"
}
}
func unableToParseHelpMessage(
origin: InputOrigin, name: Name?, value: String, key: InputKey,
error: Error?
) -> String {
guard let abstract = help(for: key)?.abstract else { return "" }
let valueName = arguments(for: key).first?.valueName
switch (name, valueName) {
case (let n?, let v?):
return "\(n.synopsisString) <\(v)> \(abstract)"
case (_, let v?):
return "<\(v)> \(abstract)"
case (_, _):
return ""
}
}
func missingValueForOptionHelpMessage(name: Name) -> String {
guard let arg = arguments.first(where: { $0.names.contains(name) }) else {
return ""
}
let help = arg.help.abstract
return "\(name.synopsisString) <\(arg.valueName)> \(help)"
}
func noValueHelpMessage(key: InputKey) -> String {
guard let abstract = help(for: key)?.abstract else { return "" }
guard let arg = arguments(for: key).first else { return "" }
if let synopsisString = arg.names.first?.synopsisString {
return "\(synopsisString) <\(arg.valueName)> \(abstract)"
}
return "<\(arg.valueName)> \(abstract)"
}
func unableToParseValueMessage(
origin: InputOrigin, name: Name?, value: String, key: InputKey,
error: Error?
) -> String {
let argumentValue = arguments(for: key).first
let valueName = argumentValue?.valueName
// We want to make the "best effort" in producing a custom error message.
// We favor `LocalizedError.errorDescription` and fall back to
// `CustomStringConvertible`. To opt in, return your custom error message
// as the `description` property of `CustomStringConvertible`.
let customErrorMessage: String
switch error {
case .some(let error):
customErrorMessage = ": " + error.describe()
case .none:
customErrorMessage = argumentValue?.formattedValueList ?? ""
}
switch (name, valueName) {
case (let n?, let v?):
return
"The value '\(value)' is invalid for '\(n.synopsisString) <\(v)>'\(customErrorMessage)"
case (_, let v?):
return "The value '\(value)' is invalid for '<\(v)>'\(customErrorMessage)"
case (let n?, _):
return
"The value '\(value)' is invalid for '\(n.synopsisString)'\(customErrorMessage)"
case (nil, nil):
return "The value '\(value)' is invalid.\(customErrorMessage)"
}
}
}
extension ArgumentDefinition {
fileprivate var formattedValueList: String {
if help.allValueStrings.isEmpty {
return ""
}
if help.allValueStrings.count < 6 {
let quotedValues = help.allValueStrings.map { "'\($0)'" }
let validList: String
if quotedValues.count <= 2 {
validList = quotedValues.joined(separator: " and ")
} else {
// swift-format-ignore: NeverForceUnwrap
// We know that `quotedValues` is not empty.
validList =
quotedValues.dropLast().joined(separator: ", ")
+ " or \(quotedValues.last!)"
}
return ". Please provide one of \(validList)."
} else {
let bulletValueList = help.allValueStrings.map { " - \($0)" }.joined(
separator: "\n")
return ". Please provide one of the following:\n\(bulletValueList)"
}
}
}