Skip to content

Make NameSpecification and its element ExpressibleByStringLiteral #745

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

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
80 changes: 80 additions & 0 deletions Sources/ArgumentParser/Parsable Properties/NameSpecification.swift
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,70 @@ 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) {
guard !string.isEmpty else {
self = [
.invalidLiteral(
literal: string,
message: "Can't use the empty string as a name.")
]
return
}

self.elements = string.split(separator: " ").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 +245,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 +280,8 @@ extension FlagInversion {
let modifiedElement = NameSpecification.Element.customLong(
modifiedName, withSingleDash: withSingleDash)
return modifiedElement.name(for: key)
case .invalidLiteral:
fatalError("Invalid literals are diagnosed previously")
}
}
}
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
}
}
}
101 changes: 101 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,76 @@ 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_literalFailures() {
// Empty string
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"])
}
}