Skip to content
Merged
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
11 changes: 8 additions & 3 deletions Sources/ArgumentParser/Parsing/SplitArguments.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,19 @@ enum ParsedArgument: Equatable, CustomStringConvertible {

init<S: StringProtocol>(_ str: S) where S.SubSequence == Substring {
let indexOfEqualSign = str.firstIndex(of: "=") ?? str.endIndex
let hasEqualSign = indexOfEqualSign != str.endIndex
let (baseName, value) = (
str[..<indexOfEqualSign], str[indexOfEqualSign...].dropFirst()
)
let name = Name(baseName)
// Treat the presence of '=' as authoritative: `--opt=` should produce
// `.nameWithValue(.long("opt"), "")`, preserving the user's explicit
// empty-string intent and leaving the following token positional.
// This matches the existing behaviour for short options (`-o=`).
self =
value.isEmpty
? .name(name)
: .nameWithValue(name, String(value))
hasEqualSign || !value.isEmpty
? .nameWithValue(name, String(value))
: .name(name)
}

/// An array of short arguments and their indices in the original base
Expand Down
52 changes: 52 additions & 0 deletions Tests/ArgumentParserEndToEndTests/EqualsEndToEndTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,55 @@ extension EqualsEndToEndTests {
}
}
}

// MARK: Long option with empty value (issue #958)

private struct LongOptionWithFile: ParsableArguments {
@Option(name: .long) var out: String
@Argument var file: String
}

private struct LongOptionWithOptionalString: ParsableArguments {
@Option(name: [.short, .long]) var name: String?
@Argument var file: String
}

// https://github.com/apple/swift-argument-parser/issues/958
extension EqualsEndToEndTests {
/// `--out=` must accept an explicit empty-string value.
///
/// The following positional `file.txt` must remain a positional argument.
@Test func longOptionEmptyValueDoesNotConsumePositional() throws {
expectParse(LongOptionWithFile.self, ["--out=", "file.txt"]) { parsed in
#expect(parsed.out == "")
#expect(parsed.file == "file.txt")
}
}

/// `--out=value` (non-empty) must behave as before.
@Test func longOptionNonEmptyValueUnchanged() throws {
expectParse(LongOptionWithFile.self, ["--out=output.txt", "file.txt"]) {
parsed in
#expect(parsed.out == "output.txt")
#expect(parsed.file == "file.txt")
}
}

/// `--out` (no `=`) followed by value token must still work.
@Test func longOptionSeparateValueUnchanged() throws {
expectParse(LongOptionWithFile.self, ["--out", "output.txt", "file.txt"]) {
parsed in
#expect(parsed.out == "output.txt")
#expect(parsed.file == "file.txt")
}
}

/// Short option `-o=` must similarly keep its existing empty-value behaviour.
@Test func shortOptionEmptyValueConsistent() throws {
expectParse(LongOptionWithOptionalString.self, ["-n=", "file.txt"]) {
parsed in
#expect(parsed.name == "")
#expect(parsed.file == "file.txt")
}
}
}
56 changes: 56 additions & 0 deletions Tests/ArgumentParserUnitTests/SplitArgumentTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -735,3 +735,59 @@ extension SplitArgumentTests {
#expect(valueB.1 == "bar")
}
}

// MARK: - Long option with empty value (issue #958)

// https://github.com/apple/swift-argument-parser/issues/958
extension SplitArgumentTests {
/// `--opt=` must parse as `.nameWithValue(.long("opt"), "")`.
///
/// Before the fix it collapsed to `.name(.long("opt"))` and silently
/// consumed the next positional token as the option value.
@Test func longOptionWithEmptyValue() async throws {
let sut = try SplitArguments(arguments: ["--out="])

#expect(sut.elements.count == 1)
try expectIndexEqual(sut, at: 0, inputIndex: 0, subIndex: .complete)
try expectElementEqual(
sut, at: 0, .option(.nameWithValue(.long("out"), ""))
)

#expect(sut.originalInput.count == 1)
#expect(sut.originalInput == ["--out="])
}

/// Short option: `-o=` already produced `.nameWithValue`; verify it still does.
@Test func shortOptionWithEmptyValue() async throws {
let sut = try SplitArguments(arguments: ["-o="])

#expect(sut.elements.count == 1)
try expectIndexEqual(sut, at: 0, inputIndex: 0, subIndex: .complete)
try expectElementEqual(
sut, at: 0, .option(.nameWithValue(.short("o"), ""))
)

#expect(sut.originalInput.count == 1)
#expect(sut.originalInput == ["-o="])
}

/// `--opt=value` (non-empty) must still work as before.
@Test func longOptionWithNonEmptyValueUnchanged() async throws {
let sut = try SplitArguments(arguments: ["--out=file.txt"])

#expect(sut.elements.count == 1)
try expectIndexEqual(sut, at: 0, inputIndex: 0, subIndex: .complete)
try expectElementEqual(
sut, at: 0, .option(.nameWithValue(.long("out"), "file.txt"))
)
}

/// `--opt` (no `=` at all) must still produce a bare `.name`.
@Test func longOptionWithoutEqualsSign() async throws {
let sut = try SplitArguments(arguments: ["--out"])

#expect(sut.elements.count == 1)
try expectIndexEqual(sut, at: 0, inputIndex: 0, subIndex: .complete)
try expectElementEqual(sut, at: 0, .option(.name(.long("out"))))
}
}