Skip to content
2 changes: 1 addition & 1 deletion Examples/math/Math.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ struct Math: ParsableCommand {

struct Options: ParsableArguments {
@Flag(
name: [.customLong("hex-output"), .customShort("x")],
name: "--hex-output -x",
help: "Use hexadecimal notation for the result.")
var hexadecimalOutput = false

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public struct NameSpecification: ExpressibleByArrayLiteral {
case customLong(_ name: String, withSingleDash: Bool)
case short
case customShort(_ char: Character, allowingJoined: Bool)
case invalidLiteral(literal: String, message: String)
}

internal var base: Representation
Expand Down Expand Up @@ -78,7 +79,16 @@ public struct NameSpecification: ExpressibleByArrayLiteral {
) -> Element {
self.init(base: .customShort(char, allowingJoined: allowingJoined))
}

/// An invalid literal, for a later diagnostic.
internal static func invalidLiteral(
literal str: String,
message: String
) -> Element {
self.init(base: .invalidLiteral(literal: str, message: message))
}
}

var elements: [Element]

public init<S>(_ sequence: S) where S: Sequence, Element == S.Element {
Expand All @@ -92,6 +102,71 @@ public struct NameSpecification: ExpressibleByArrayLiteral {

extension NameSpecification: Sendable {}

extension NameSpecification.Element:
ExpressibleByStringLiteral, ExpressibleByStringInterpolation
{
public init(stringLiteral string: String) {
// Check for spaces
guard !string.contains(where: { $0 == " " }) else {
self = .invalidLiteral(
literal: string,
message: "Can't use spaces in a name.")
return
}
// Check for non-ascii chars
guard string.allSatisfy({ $0.isValidForName }) else {
self = .invalidLiteral(
literal: string,
message: "Must use only letters, numbers, underscores, or dashes.")
return
}

let dashPrefixCount = string.prefix(while: { $0 == "-" }).count
switch (dashPrefixCount, string.count) {
case (0, _):
self = .invalidLiteral(
literal: string,
message: "Need one or two prefix dashes.")
case (1, 1), (2, 2):
self = .invalidLiteral(
literal: string,
message: "Need at least one character after the dash prefix.")
case (1, 2):
// swift-format-ignore: NeverForceUnwrap
// The case match validates the length.
self = .customShort(string.dropFirst().first!)
case (1, _):
self = .customLong(String(string.dropFirst()), withSingleDash: true)
case (2, _):
self = .customLong(String(string.dropFirst(2)))
default:
self = .invalidLiteral(
literal: string,
message: "Can't have more than a two-dash prefix.")
}
}
}

extension NameSpecification:
ExpressibleByStringLiteral, ExpressibleByStringInterpolation
{
public init(stringLiteral string: String) {
let parts = string.split(separator: " ")
guard !parts.isEmpty else {
self = [
.invalidLiteral(
literal: string,
message: "Can't use the empty string as a name.")
]
return
}

self.elements = parts.map {
Element(stringLiteral: String($0))
}
}
}

extension NameSpecification {
/// Use the property's name converted to lowercase with words separated by
/// hyphens.
Expand Down Expand Up @@ -171,6 +246,10 @@ extension NameSpecification.Element {
: .long(name)
case .customShort(let name, let allowingJoined):
return .short(name, allowingJoined: allowingJoined)
case .invalidLiteral(let literal, let message):
configurationFailure(
"Invalid literal name '\(literal)' for property '\(key.name)': \(message)"
.wrapped(to: 70))
}
}
}
Expand Down Expand Up @@ -202,6 +281,10 @@ extension FlagInversion {
let modifiedElement = NameSpecification.Element.customLong(
modifiedName, withSingleDash: withSingleDash)
return modifiedElement.name(for: key)
case .invalidLiteral(let literal, let message):
configurationFailure(
"Invalid literal name '\(literal)' for property '\(key.name)': \(message)"
.wrapped(to: 70))
}
}
}
Expand Down
21 changes: 21 additions & 0 deletions Sources/ArgumentParser/Utilities/StringExtensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -256,3 +256,24 @@ extension StringProtocol where SubSequence == Substring {
isEmpty ? nil : self
}
}

extension Character {
/// Returns a Boolean value indicating whether this character is valid for the
/// command-line name of an option or flag.
///
/// Only ASCII letters, numbers, dashes, and the underscore are valid name
/// characters.
var isValidForName: Bool {
guard isASCII, let firstScalar = unicodeScalars.first else { return false }
switch firstScalar.value {
case 0x41...0x5A, // uppercase
0x61...0x7A, // lowercase
0x30...0x39, // numbers
0x5F, // underscore
0x2D: // dash
return true
default:
return false
}
}
}
117 changes: 117 additions & 0 deletions Tests/ArgumentParserUnitTests/NameSpecificationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ extension NameSpecificationTests {
func testFlagNames_withNoPrefix() {
let key = InputKey(name: "index", parent: nil)

XCTAssertEqual(
FlagInversion.prefixedNo.enableDisableNamePair(
for: key, name: .long
).1, [.long("no-index")])
XCTAssertEqual(
FlagInversion.prefixedNo.enableDisableNamePair(
for: key, name: .customLong("foo")
Expand All @@ -37,6 +41,12 @@ extension NameSpecificationTests {
FlagInversion.prefixedNo.enableDisableNamePair(
for: key, name: .customLong("fooBarBaz")
).1, [.long("noFooBarBaz")])

// Short names don't work in combination
XCTAssertEqual(
FlagInversion.prefixedNo.enableDisableNamePair(
for: key, name: .short
).1, [])
}

func testFlagNames_withEnableDisablePrefix() {
Expand Down Expand Up @@ -83,6 +93,12 @@ extension NameSpecificationTests {
FlagInversion.prefixedEnableDisable.enableDisableNamePair(
for: key, name: .customLong("fooBarBaz")
).1, [.long("disableFooBarBaz")])

// Short names don't work in combination
XCTAssertEqual(
FlagInversion.prefixedEnableDisable.enableDisableNamePair(
for: key, name: .short
).1, [])
}
}

Expand Down Expand Up @@ -111,6 +127,19 @@ private func Assert<N>(
}
}

// swift-format-ignore: AlwaysUseLowerCamelCase
private func AssertInvalid(
nameSpecification: NameSpecification,
file: StaticString = #filePath,
line: UInt = #line
) {
XCTAssert(
nameSpecification.elements.contains(where: {
if case .invalidLiteral = $0.base { return true } else { return false }
}), "Expected invalid name.",
file: file, line: line)
}

// swift-format-ignore: AlwaysUseLowerCamelCase
// https://github.com/apple/swift-argument-parser/issues/710
extension NameSpecificationTests {
Expand Down Expand Up @@ -144,4 +173,92 @@ extension NameSpecificationTests {
nameSpecification: .customLong("baz", withSingleDash: true), key: "foo",
makeNames: [.longWithSingleDash("baz")])
}

func testMakeNames_shortLiteral() {
Assert(nameSpecification: "-x", key: "foo", makeNames: [.short("x")])
Assert(nameSpecification: ["-x"], key: "foo", makeNames: [.short("x")])
}

func testMakeNames_longLiteral() {
Assert(nameSpecification: "--foo", key: "foo", makeNames: [.long("foo")])
Assert(nameSpecification: ["--foo"], key: "foo", makeNames: [.long("foo")])
Assert(
nameSpecification: "--foo-bar-baz", key: "foo",
makeNames: [.long("foo-bar-baz")])
Assert(
nameSpecification: "--fooBarBAZ", key: "foo",
makeNames: [.long("fooBarBAZ")])
}

func testMakeNames_longWithSingleDashLiteral() {
Assert(
nameSpecification: "-foo", key: "foo",
makeNames: [.longWithSingleDash("foo")])
Assert(
nameSpecification: ["-foo"], key: "foo",
makeNames: [.longWithSingleDash("foo")])
Assert(
nameSpecification: "-foo-bar-baz", key: "foo",
makeNames: [.longWithSingleDash("foo-bar-baz")])
Assert(
nameSpecification: "-fooBarBAZ", key: "foo",
makeNames: [.longWithSingleDash("fooBarBAZ")])
}

func testMakeNames_combinedLiteral() {
Assert(
nameSpecification: "-x -y --zilch", key: "foo",
makeNames: [.short("x"), .short("y"), .long("zilch")])
Assert(
nameSpecification: " -x -y ", key: "foo",
makeNames: [.short("x"), .short("y")])
Assert(
nameSpecification: ["-x", "-y", "--zilch"], key: "foo",
makeNames: [.short("x"), .short("y"), .long("zilch")])
}

func testMakeNames_interpolations() {
let x = "x"
Assert(nameSpecification: ["-\(x)"], key: "foo", makeNames: [.short("x")])
Assert(nameSpecification: ["--\(x)"], key: "foo", makeNames: [.long("x")])
Assert(
nameSpecification: ["-\(x)\(x)\(x)"], key: "foo",
makeNames: [.longWithSingleDash("xxx")])
Assert(nameSpecification: "-\(x)", key: "foo", makeNames: [.short("x")])
Assert(nameSpecification: "--\(x)", key: "foo", makeNames: [.long("x")])
Assert(
nameSpecification: "-\(x)\(x)\(x)", key: "foo",
makeNames: [.longWithSingleDash("xxx")])
}

func testMakeNames_literalFailures() {
// Empty string and whitespace-only
AssertInvalid(nameSpecification: "")
AssertInvalid(nameSpecification: " ")
AssertInvalid(nameSpecification: " ")
// No dash prefix
AssertInvalid(nameSpecification: "x")
// Dash prefix only
AssertInvalid(nameSpecification: "-")
AssertInvalid(nameSpecification: "--")
AssertInvalid(nameSpecification: "---")
// Triple dash
AssertInvalid(nameSpecification: "---x")
// Invalid characters
AssertInvalid(nameSpecification: "--café")
AssertInvalid(nameSpecification: "--c!f!")

// Repeating as elements
AssertInvalid(nameSpecification: [""])
AssertInvalid(nameSpecification: ["x"])
AssertInvalid(nameSpecification: ["-"])
AssertInvalid(nameSpecification: ["--"])
AssertInvalid(nameSpecification: ["---"])
AssertInvalid(nameSpecification: ["---x"])
AssertInvalid(nameSpecification: ["--café"])

// Spaces in _elements_, not the top level literal
AssertInvalid(nameSpecification: ["-x -y -z"])
AssertInvalid(nameSpecification: ["-x", "-y", " -z"])
}
}
Loading