From 43751f842635216cb6126d10f9ebebd96e6eb84a Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Sun, 5 Oct 2025 11:03:05 -0400 Subject: [PATCH 1/5] Support command-line arguments specified as `--foo=bar`. SwiftPM and `swift test` use Swift Argument Parser which allows developers to specify arguments of the form `--foo=bar`. Our bare-bones argument parser doesn't currently recognize that pattern, which means that the developer could write `--foo=bar` but get the wrong behavior. This PR adds support for that pattern by changing how we parse command-line arguments to allow for both `--foo bar` and `--foo=bar`. --- .../Testing/ABI/EntryPoints/EntryPoint.swift | 82 ++++++++++++------- Tests/TestingTests/SwiftPMTests.swift | 21 +++++ 2 files changed, 72 insertions(+), 31 deletions(-) diff --git a/Sources/Testing/ABI/EntryPoints/EntryPoint.swift b/Sources/Testing/ABI/EntryPoints/EntryPoint.swift index 727d91632..26b0ab6b5 100644 --- a/Sources/Testing/ABI/EntryPoints/EntryPoint.swift +++ b/Sources/Testing/ABI/EntryPoints/EntryPoint.swift @@ -352,6 +352,36 @@ extension __CommandLineArguments_v0: Codable { } } +extension RandomAccessCollection { + /// Get the value of the command line argument named at the given index. + /// + /// - Parameters: + /// - key: The key or name of the argument, e.g. `"--attachments-path"`. + /// + /// - Returns: The value of the argument named by `key`. If no value is + /// available, returns `nil`. + /// + /// This function handles arguments of the form `--key value` and + /// `--key=value`. Other argument syntaxes are not supported. + fileprivate func argumentValue(forKey key: String) -> String? { + if let index = firstIndex(of: key) { + let nextIndex = self.index(after: index) + if nextIndex < endIndex { + return self[nextIndex] + } + } else { + // Find an element equal to something like "--foo=bar" and split it. + let prefix = "\(key)=" + let index = self.firstIndex { $0.hasPrefix(prefix) } + if let index, case let key = self[index], let equalsIndex = key.firstIndex(of: "=") { + return String(key[equalsIndex...].dropFirst()) + } + } + + return nil + } +} + /// Initialize this instance given a sequence of command-line arguments passed /// from Swift Package Manager. /// @@ -366,10 +396,6 @@ func parseCommandLineArguments(from args: [String]) throws -> __CommandLineArgum // Do not consider the executable path AKA argv[0]. let args = args.dropFirst() - func isLastArgument(at index: [String].Index) -> Bool { - args.index(after: index) >= args.endIndex - } - #if !SWT_NO_FILE_IO #if canImport(Foundation) // Configuration for the test run passed in as a JSON file (experimental) @@ -379,9 +405,7 @@ func parseCommandLineArguments(from args: [String]) throws -> __CommandLineArgum // NOTE: While the output event stream is opened later, it is necessary to // open the configuration file early (here) in order to correctly construct // the resulting __CommandLineArguments_v0 instance. - if let configurationIndex = args.firstIndex(of: "--configuration-path") ?? args.firstIndex(of: "--experimental-configuration-path"), - !isLastArgument(at: configurationIndex) { - let path = args[args.index(after: configurationIndex)] + if let path = args.argumentValue(forKey: "--configuration-path") ?? args.argumentValue(forKey: "--experimental-configuration-path") { let file = try FileHandle(forReadingAtPath: path) let configurationJSON = try file.readToEnd() result = try configurationJSON.withUnsafeBufferPointer { configurationJSON in @@ -394,24 +418,22 @@ func parseCommandLineArguments(from args: [String]) throws -> __CommandLineArgum } // Event stream output - if let eventOutputIndex = args.firstIndex(of: "--event-stream-output-path") ?? args.firstIndex(of: "--experimental-event-stream-output"), - !isLastArgument(at: eventOutputIndex) { - result.eventStreamOutputPath = args[args.index(after: eventOutputIndex)] + if let path = args.argumentValue(forKey: "--event-stream-output-path") ?? args.argumentValue(forKey: "--experimental-event-stream-output") { + result.eventStreamOutputPath = path } + // Event stream version do { - var eventOutputVersionIndex: Array.Index? + var versionString: String? var allowExperimental = false - eventOutputVersionIndex = args.firstIndex(of: "--event-stream-version") - if eventOutputVersionIndex == nil { - eventOutputVersionIndex = args.firstIndex(of: "--experimental-event-stream-version") - if eventOutputVersionIndex != nil { + versionString = args.argumentValue(forKey: "--event-stream-version") + if versionString == nil { + versionString = args.argumentValue(forKey: "--experimental-event-stream-version") + if versionString != nil { allowExperimental = true } } - if let eventOutputVersionIndex, !isLastArgument(at: eventOutputVersionIndex) { - let versionString = args[args.index(after: eventOutputVersionIndex)] - + if let versionString { // If the caller specified a version that could not be parsed, treat it as // an invalid argument. guard let eventStreamVersion = VersionNumber(versionString) else { @@ -432,14 +454,13 @@ func parseCommandLineArguments(from args: [String]) throws -> __CommandLineArgum #endif // XML output - if let xunitOutputIndex = args.firstIndex(of: "--xunit-output"), !isLastArgument(at: xunitOutputIndex) { - result.xunitOutput = args[args.index(after: xunitOutputIndex)] + if let xunitOutputPath = args.argumentValue(forKey: "--xunit-output") { + result.xunitOutput = xunitOutputPath } // Attachment output - if let attachmentsPathIndex = args.firstIndex(of: "--attachments-path") ?? args.firstIndex(of: "--experimental-attachments-path"), - !isLastArgument(at: attachmentsPathIndex) { - result.attachmentsPath = args[args.index(after: attachmentsPathIndex)] + if let attachmentsPath = args.argumentValue(forKey: "--attachments-path") ?? args.argumentValue(forKey: "--experimental-attachments-path") { + result.attachmentsPath = attachmentsPath } #endif @@ -457,13 +478,12 @@ func parseCommandLineArguments(from args: [String]) throws -> __CommandLineArgum } // Whether or not to symbolicate backtraces in the event stream. - if let symbolicateBacktracesIndex = args.firstIndex(of: "--symbolicate-backtraces"), !isLastArgument(at: symbolicateBacktracesIndex) { - result.symbolicateBacktraces = args[args.index(after: symbolicateBacktracesIndex)] + if let symbolicateBacktraces = args.argumentValue(forKey: "--symbolicate-backtraces") { + result.symbolicateBacktraces = symbolicateBacktraces } // Verbosity - if let verbosityIndex = args.firstIndex(of: "--verbosity"), !isLastArgument(at: verbosityIndex), - let verbosity = Int(args[args.index(after: verbosityIndex)]) { + if let verbosity = args.argumentValue(forKey: "--verbosity").flatMap(Int.init) { result.verbosity = verbosity } if args.contains("--verbose") || args.contains("-v") { @@ -492,11 +512,11 @@ func parseCommandLineArguments(from args: [String]) throws -> __CommandLineArgum } // Set up the iteration policy for the test run. - if let repetitionsIndex = args.firstIndex(of: "--repetitions"), !isLastArgument(at: repetitionsIndex) { - result.repetitions = Int(args[args.index(after: repetitionsIndex)]) + if let repetitions = args.argumentValue(forKey: "--repetitions").flatMap(Int.init) { + result.repetitions = repetitions } - if let repeatUntilIndex = args.firstIndex(of: "--repeat-until"), !isLastArgument(at: repeatUntilIndex) { - result.repeatUntil = args[args.index(after: repeatUntilIndex)] + if let repeatUntil = args.argumentValue(forKey: "--repeat-until") { + result.repeatUntil = repeatUntil } return result diff --git a/Tests/TestingTests/SwiftPMTests.swift b/Tests/TestingTests/SwiftPMTests.swift index 4543d3932..b8481975e 100644 --- a/Tests/TestingTests/SwiftPMTests.swift +++ b/Tests/TestingTests/SwiftPMTests.swift @@ -492,4 +492,25 @@ struct SwiftPMTests { let args = try parseCommandLineArguments(from: ["PATH", "--verbosity", "12345"]) #expect(args.verbosity == 12345) } + + @Test("--foo=bar form") + func equalsSignForm() throws { + // We can split the string and parse the result correctly. + do { + let args = try parseCommandLineArguments(from: ["PATH", "--verbosity=12345"]) + #expect(args.verbosity == 12345) + } + + // We don't overrun the string and correctly handle empty values. + do { + let args = try parseCommandLineArguments(from: ["PATH", "--xunit-output="]) + #expect(args.xunitOutput == "") + } + + // We split at the first equals-sign. + do { + let args = try parseCommandLineArguments(from: ["PATH", "--xunit-output=abc=123"]) + #expect(args.xunitOutput == "abc=123") + } + } } From 8f593bee31f3b400796ab2b8b528e910e5236052 Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Mon, 6 Oct 2025 11:16:30 -0400 Subject: [PATCH 2/5] Fix crash when --filter is the last argument --- .../Testing/ABI/EntryPoints/EntryPoint.swift | 21 ++++++++++++------- Tests/TestingTests/SwiftPMTests.swift | 8 +++++++ 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/Sources/Testing/ABI/EntryPoints/EntryPoint.swift b/Sources/Testing/ABI/EntryPoints/EntryPoint.swift index 26b0ab6b5..ee9fc6f31 100644 --- a/Sources/Testing/ABI/EntryPoints/EntryPoint.swift +++ b/Sources/Testing/ABI/EntryPoints/EntryPoint.swift @@ -357,14 +357,22 @@ extension RandomAccessCollection { /// /// - Parameters: /// - key: The key or name of the argument, e.g. `"--attachments-path"`. + /// - index: Optionally, the index where `key` should be found. /// /// - Returns: The value of the argument named by `key`. If no value is /// available, returns `nil`. /// /// This function handles arguments of the form `--key value` and /// `--key=value`. Other argument syntaxes are not supported. - fileprivate func argumentValue(forKey key: String) -> String? { - if let index = firstIndex(of: key) { + fileprivate func argumentValue(forKey key: String, at index: Index? = nil) -> String? { + guard let index else { + return indices.lazy + .compactMap { argumentValue(forKey: key, at: $0) } + .first + } + + let element = self[index] + if element == key { let nextIndex = self.index(after: index) if nextIndex < endIndex { return self[nextIndex] @@ -372,9 +380,8 @@ extension RandomAccessCollection { } else { // Find an element equal to something like "--foo=bar" and split it. let prefix = "\(key)=" - let index = self.firstIndex { $0.hasPrefix(prefix) } - if let index, case let key = self[index], let equalsIndex = key.firstIndex(of: "=") { - return String(key[equalsIndex...].dropFirst()) + if element.hasPrefix(prefix), let equalsIndex = element.firstIndex(of: "=") { + return String(element[equalsIndex...].dropFirst()) } } @@ -498,9 +505,7 @@ func parseCommandLineArguments(from args: [String]) throws -> __CommandLineArgum // Filtering func filterValues(forArgumentsWithLabel label: String) -> [String] { - args.indices.lazy - .filter { args[$0] == label && $0 < args.endIndex } - .map { args[args.index(after: $0)] } + args.indices.compactMap { args.argumentValue(forKey: label, at: $0) } } let filter = filterValues(forArgumentsWithLabel: "--filter") if !filter.isEmpty { diff --git a/Tests/TestingTests/SwiftPMTests.swift b/Tests/TestingTests/SwiftPMTests.swift index b8481975e..0d6ae841b 100644 --- a/Tests/TestingTests/SwiftPMTests.swift +++ b/Tests/TestingTests/SwiftPMTests.swift @@ -90,6 +90,7 @@ struct SwiftPMTests { @available(_regexAPI, *) func filter() async throws { let configuration = try configurationForEntryPoint(withArguments: ["PATH", "--filter", "hello"]) + print(configuration) let test1 = Test(name: "hello") {} let test2 = Test(name: "goodbye") {} let plan = await Runner.Plan(tests: [test1, test2], configuration: configuration) @@ -145,6 +146,13 @@ struct SwiftPMTests { #expect(planTests.contains(test2)) } + @Test("--filter or --skip argument as last argument") + @available(_regexAPI, *) + func filterOrSkipAsLast() async throws { + _ = try configurationForEntryPoint(withArguments: ["PATH", "--filter"]) + _ = try configurationForEntryPoint(withArguments: ["PATH", "--skip"]) + } + @Test(".hidden trait", .tags(.traitRelated)) func hidden() async throws { let configuration = try configurationForEntryPoint(withArguments: ["PATH"]) From d5c17c19ed70f61a86cbd7e4793a2f24880572d2 Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Mon, 6 Oct 2025 11:36:03 -0400 Subject: [PATCH 3/5] Fix up comment --- Sources/Testing/ABI/EntryPoints/EntryPoint.swift | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Sources/Testing/ABI/EntryPoints/EntryPoint.swift b/Sources/Testing/ABI/EntryPoints/EntryPoint.swift index ee9fc6f31..41507d5fe 100644 --- a/Sources/Testing/ABI/EntryPoints/EntryPoint.swift +++ b/Sources/Testing/ABI/EntryPoints/EntryPoint.swift @@ -353,14 +353,16 @@ extension __CommandLineArguments_v0: Codable { } extension RandomAccessCollection { - /// Get the value of the command line argument named at the given index. + /// Get the value of the command line argument with the given name. /// /// - Parameters: /// - key: The key or name of the argument, e.g. `"--attachments-path"`. - /// - index: Optionally, the index where `key` should be found. + /// - index: The index where `key` should be found, or `nil` to search the + /// entire collection. /// - /// - Returns: The value of the argument named by `key`. If no value is - /// available, returns `nil`. + /// - Returns: The value of the argument named by `key` at `index`. If no + /// value is available, or if `index` is not `nil` and the argument at + /// `index` is not named `key`, returns `nil`. /// /// This function handles arguments of the form `--key value` and /// `--key=value`. Other argument syntaxes are not supported. From b500ac3f674c2293a976394ff2bfa8ff8c13010e Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Mon, 6 Oct 2025 11:44:03 -0400 Subject: [PATCH 4/5] key -> label for consistency --- .../Testing/ABI/EntryPoints/EntryPoint.swift | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/Sources/Testing/ABI/EntryPoints/EntryPoint.swift b/Sources/Testing/ABI/EntryPoints/EntryPoint.swift index 41507d5fe..f4f1a751c 100644 --- a/Sources/Testing/ABI/EntryPoints/EntryPoint.swift +++ b/Sources/Testing/ABI/EntryPoints/EntryPoint.swift @@ -356,32 +356,32 @@ extension RandomAccessCollection { /// Get the value of the command line argument with the given name. /// /// - Parameters: - /// - key: The key or name of the argument, e.g. `"--attachments-path"`. - /// - index: The index where `key` should be found, or `nil` to search the + /// - label: The label or name of the argument, e.g. `"--attachments-path"`. + /// - index: The index where `label` should be found, or `nil` to search the /// entire collection. /// - /// - Returns: The value of the argument named by `key` at `index`. If no + /// - Returns: The value of the argument named by `label` at `index`. If no /// value is available, or if `index` is not `nil` and the argument at - /// `index` is not named `key`, returns `nil`. + /// `index` is not named `label`, returns `nil`. /// - /// This function handles arguments of the form `--key value` and - /// `--key=value`. Other argument syntaxes are not supported. - fileprivate func argumentValue(forKey key: String, at index: Index? = nil) -> String? { + /// This function handles arguments of the form `--label value` and + /// `--label=value`. Other argument syntaxes are not supported. + fileprivate func argumentValue(forLabel label: String, at index: Index? = nil) -> String? { guard let index else { return indices.lazy - .compactMap { argumentValue(forKey: key, at: $0) } + .compactMap { argumentValue(forLabel: label, at: $0) } .first } let element = self[index] - if element == key { + if element == label { let nextIndex = self.index(after: index) if nextIndex < endIndex { return self[nextIndex] } } else { // Find an element equal to something like "--foo=bar" and split it. - let prefix = "\(key)=" + let prefix = "\(label)=" if element.hasPrefix(prefix), let equalsIndex = element.firstIndex(of: "=") { return String(element[equalsIndex...].dropFirst()) } @@ -414,7 +414,7 @@ func parseCommandLineArguments(from args: [String]) throws -> __CommandLineArgum // NOTE: While the output event stream is opened later, it is necessary to // open the configuration file early (here) in order to correctly construct // the resulting __CommandLineArguments_v0 instance. - if let path = args.argumentValue(forKey: "--configuration-path") ?? args.argumentValue(forKey: "--experimental-configuration-path") { + if let path = args.argumentValue(forLabel: "--configuration-path") ?? args.argumentValue(forLabel: "--experimental-configuration-path") { let file = try FileHandle(forReadingAtPath: path) let configurationJSON = try file.readToEnd() result = try configurationJSON.withUnsafeBufferPointer { configurationJSON in @@ -427,7 +427,7 @@ func parseCommandLineArguments(from args: [String]) throws -> __CommandLineArgum } // Event stream output - if let path = args.argumentValue(forKey: "--event-stream-output-path") ?? args.argumentValue(forKey: "--experimental-event-stream-output") { + if let path = args.argumentValue(forLabel: "--event-stream-output-path") ?? args.argumentValue(forLabel: "--experimental-event-stream-output") { result.eventStreamOutputPath = path } @@ -435,9 +435,9 @@ func parseCommandLineArguments(from args: [String]) throws -> __CommandLineArgum do { var versionString: String? var allowExperimental = false - versionString = args.argumentValue(forKey: "--event-stream-version") + versionString = args.argumentValue(forLabel: "--event-stream-version") if versionString == nil { - versionString = args.argumentValue(forKey: "--experimental-event-stream-version") + versionString = args.argumentValue(forLabel: "--experimental-event-stream-version") if versionString != nil { allowExperimental = true } @@ -463,12 +463,12 @@ func parseCommandLineArguments(from args: [String]) throws -> __CommandLineArgum #endif // XML output - if let xunitOutputPath = args.argumentValue(forKey: "--xunit-output") { + if let xunitOutputPath = args.argumentValue(forLabel: "--xunit-output") { result.xunitOutput = xunitOutputPath } // Attachment output - if let attachmentsPath = args.argumentValue(forKey: "--attachments-path") ?? args.argumentValue(forKey: "--experimental-attachments-path") { + if let attachmentsPath = args.argumentValue(forLabel: "--attachments-path") ?? args.argumentValue(forLabel: "--experimental-attachments-path") { result.attachmentsPath = attachmentsPath } #endif @@ -487,12 +487,12 @@ func parseCommandLineArguments(from args: [String]) throws -> __CommandLineArgum } // Whether or not to symbolicate backtraces in the event stream. - if let symbolicateBacktraces = args.argumentValue(forKey: "--symbolicate-backtraces") { + if let symbolicateBacktraces = args.argumentValue(forLabel: "--symbolicate-backtraces") { result.symbolicateBacktraces = symbolicateBacktraces } // Verbosity - if let verbosity = args.argumentValue(forKey: "--verbosity").flatMap(Int.init) { + if let verbosity = args.argumentValue(forLabel: "--verbosity").flatMap(Int.init) { result.verbosity = verbosity } if args.contains("--verbose") || args.contains("-v") { @@ -507,7 +507,7 @@ func parseCommandLineArguments(from args: [String]) throws -> __CommandLineArgum // Filtering func filterValues(forArgumentsWithLabel label: String) -> [String] { - args.indices.compactMap { args.argumentValue(forKey: label, at: $0) } + args.indices.compactMap { args.argumentValue(forLabel: label, at: $0) } } let filter = filterValues(forArgumentsWithLabel: "--filter") if !filter.isEmpty { @@ -519,10 +519,10 @@ func parseCommandLineArguments(from args: [String]) throws -> __CommandLineArgum } // Set up the iteration policy for the test run. - if let repetitions = args.argumentValue(forKey: "--repetitions").flatMap(Int.init) { + if let repetitions = args.argumentValue(forLabel: "--repetitions").flatMap(Int.init) { result.repetitions = repetitions } - if let repeatUntil = args.argumentValue(forKey: "--repeat-until") { + if let repeatUntil = args.argumentValue(forLabel: "--repeat-until") { result.repeatUntil = repeatUntil } From 3260e837f8a05c4b955bc2706e5201656096a2d9 Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Mon, 6 Oct 2025 14:25:21 -0400 Subject: [PATCH 5/5] Stray print() --- Tests/TestingTests/SwiftPMTests.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Tests/TestingTests/SwiftPMTests.swift b/Tests/TestingTests/SwiftPMTests.swift index 0d6ae841b..4668fbb25 100644 --- a/Tests/TestingTests/SwiftPMTests.swift +++ b/Tests/TestingTests/SwiftPMTests.swift @@ -90,7 +90,6 @@ struct SwiftPMTests { @available(_regexAPI, *) func filter() async throws { let configuration = try configurationForEntryPoint(withArguments: ["PATH", "--filter", "hello"]) - print(configuration) let test1 = Test(name: "hello") {} let test2 = Test(name: "goodbye") {} let plan = await Runner.Plan(tests: [test1, test2], configuration: configuration)