diff --git a/Examples/math/Math.swift b/Examples/math/Math.swift index b4160dda..b5191c57 100644 --- a/Examples/math/Math.swift +++ b/Examples/math/Math.swift @@ -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 diff --git a/Sources/ArgumentParser/Parsable Properties/NameSpecification.swift b/Sources/ArgumentParser/Parsable Properties/NameSpecification.swift index 724dfab1..4379ff4e 100644 --- a/Sources/ArgumentParser/Parsable Properties/NameSpecification.swift +++ b/Sources/ArgumentParser/Parsable Properties/NameSpecification.swift @@ -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 @@ -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(_ sequence: S) where S: Sequence, Element == S.Element { @@ -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. @@ -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)) } } } @@ -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") } } } diff --git a/Sources/ArgumentParser/Utilities/StringExtensions.swift b/Sources/ArgumentParser/Utilities/StringExtensions.swift index 0e207672..09522889 100644 --- a/Sources/ArgumentParser/Utilities/StringExtensions.swift +++ b/Sources/ArgumentParser/Utilities/StringExtensions.swift @@ -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 + } + } +} diff --git a/Tests/ArgumentParserUnitTests/NameSpecificationTests.swift b/Tests/ArgumentParserUnitTests/NameSpecificationTests.swift index 08a7df1b..600e4637 100644 --- a/Tests/ArgumentParserUnitTests/NameSpecificationTests.swift +++ b/Tests/ArgumentParserUnitTests/NameSpecificationTests.swift @@ -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") @@ -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() { @@ -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, []) } } @@ -111,6 +127,19 @@ private func Assert( } } +// 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 { @@ -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"]) + } }