From 84a722ed244e08c8608a1bcf6008c2ba5d8aa271 Mon Sep 17 00:00:00 2001 From: Gustavo Medori Date: Thu, 5 Feb 2026 16:05:59 -0700 Subject: [PATCH 1/4] Add some logic to configurationForEntryPoint(from:) to filter by tags using a special `tag:` prefix --- .../Testing/ABI/EntryPoints/EntryPoint.swift | 35 +++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/Sources/Testing/ABI/EntryPoints/EntryPoint.swift b/Sources/Testing/ABI/EntryPoints/EntryPoint.swift index 6d61c2556..d58f5993a 100644 --- a/Sources/Testing/ABI/EntryPoints/EntryPoint.swift +++ b/Sources/Testing/ABI/EntryPoints/EntryPoint.swift @@ -633,8 +633,39 @@ public func configurationForEntryPoint(from args: __CommandLineArguments_v0) thr return try Configuration.TestFilter(membership: membership, matchingAnyOf: regexes) } - filters.append(try testFilter(forRegularExpressions: args.filter, label: "--filter", membership: .including)) - filters.append(try testFilter(forRegularExpressions: args.skip, label: "--skip", membership: .excluding)) + + // Extract any filters or skips without the `tag:` prefix; those will be treated as normal regexes. + let tagPrefix = "tag:" + let escapedTagPrefix = "tag\\:" + var nonTagFilterRegexes: [String] = [] + var nonTagSkipRegexes: [String] = [] + + for var filter in args.filter ?? [] { + if filter.hasPrefix(tagPrefix) { + filters.append(Configuration.TestFilter(includingAnyOf: [Tag(userProvidedStringValue: String(filter.dropFirst(4)))])) + } else { + // If we run into the escaped tag prefix, we need to to remove the escape character before adding it as a regex filter + if filter.hasPrefix(escapedTagPrefix) { + filter.replaceSubrange(escapedTagPrefix.startIndex.. Date: Fri, 6 Feb 2026 10:39:57 -0700 Subject: [PATCH 2/4] Update the testFilters to contain all the logic for processing test filters regardless of type, regex or tag --- .../Testing/ABI/EntryPoints/EntryPoint.swift | 75 ++++++++++--------- 1 file changed, 40 insertions(+), 35 deletions(-) diff --git a/Sources/Testing/ABI/EntryPoints/EntryPoint.swift b/Sources/Testing/ABI/EntryPoints/EntryPoint.swift index d58f5993a..365f95b11 100644 --- a/Sources/Testing/ABI/EntryPoints/EntryPoint.swift +++ b/Sources/Testing/ABI/EntryPoints/EntryPoint.swift @@ -624,48 +624,53 @@ public func configurationForEntryPoint(from args: __CommandLineArguments_v0) thr // Filtering var filters = [Configuration.TestFilter]() - func testFilter(forRegularExpressions regexes: [String]?, label: String, membership: Configuration.TestFilter.Membership) throws -> Configuration.TestFilter { - guard let regexes, !regexes.isEmpty else { - // Return early if empty, even though the `reduce` logic below can handle - // this case, in order to avoid the `#available` guard. - return .unfiltered + func testFilters(forOptionArguments optionArguments: [String]?, label: String, membership: Configuration.TestFilter.Membership) throws -> [Configuration.TestFilter] { + + // Filters will come in two flavors: those with `tag:` as a prefix, and + // those without. We split them into two collections, taking care to handle + // an escaped colon, treating it as a pseudo-operator. + let tagPrefix = "tag:" + let escapedTagPrefix = #"tag\:"# + var tags = [Tag]() + var regexes = [String]() + + // Loop through all the option arguments, separating tags from regex filters + for var optionArg in optionArguments ?? [] { + if optionArg.hasPrefix(tagPrefix) { + // Running into the `tag:` prefix means we should strip it and use the + // actual tag name the user has provided + let tagStringWithoutPrefix = String(optionArg.dropFirst(tagPrefix.count)) + tags.append(Tag(userProvidedStringValue: tagStringWithoutPrefix)) + } else { + // If we run into the escaped tag prefix, the user has indicated they + // want to us to treat it as a regex filter. We need to to unescape it + // before adding it as a regex filter + if optionArg.hasPrefix(escapedTagPrefix) { + optionArg.replaceSubrange(escapedTagPrefix.startIndex.. Date: Thu, 12 Feb 2026 18:30:40 -0700 Subject: [PATCH 3/4] Add tests coverage for new `tag:` prefix behavior for both `--filter` and `--skip` --- Tests/TestingTests/SwiftPMTests.swift | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/Tests/TestingTests/SwiftPMTests.swift b/Tests/TestingTests/SwiftPMTests.swift index 75e07a5d1..afa4f80d8 100644 --- a/Tests/TestingTests/SwiftPMTests.swift +++ b/Tests/TestingTests/SwiftPMTests.swift @@ -20,6 +20,10 @@ private func configurationForEntryPoint(withArguments args: [String]) throws -> return try configurationForEntryPoint(from: args) } +private extension Tag { + @Tag static var testTag: Self +} + /// Reads event stream output from the provided file matching event stream /// version `V`. private func decodedEventStreamRecords(fromPath filePath: String) throws -> [ABI.Record] { @@ -116,6 +120,18 @@ struct SwiftPMTests { #expect(!planTests.contains(test2)) } + + @Test("--filter argument with tag: prefix") + func filterByTag() async throws { + let configuration = try configurationForEntryPoint(withArguments: ["PATH", "--filter", "tag:testTag"]) + let test1 = Test(.tags(.testTag), name: "hello") {} + let test2 = Test(name: "goodbye") {} + let plan = await Runner.Plan(tests: [test1, test2], configuration: configuration) + let planTests = plan.steps.map(\.test) + #expect(planTests.contains(test1)) + #expect(!planTests.contains(test2)) + } + @Test("Multiple --filter arguments") func multipleFilter() async throws { let configuration = try configurationForEntryPoint(withArguments: ["PATH", "--filter", "hello", "--filter", "sorry"]) @@ -159,6 +175,17 @@ struct SwiftPMTests { #expect(planTests.contains(test2)) } + @Test("--skip argument with tag: prefix") + func skipByTag() async throws { + let configuration = try configurationForEntryPoint(withArguments: ["PATH", "--skip", "tag:testTag"]) + let test1 = Test(.tags(.testTag), name: "hello") {} + let test2 = Test(name: "goodbye") {} + let plan = await Runner.Plan(tests: [test1, test2], configuration: configuration) + let planTests = plan.steps.map(\.test) + #expect(!planTests.contains(test1)) + #expect(planTests.contains(test2)) + } + @Test("--filter or --skip argument as last argument") func filterOrSkipAsLast() async throws { _ = try configurationForEntryPoint(withArguments: ["PATH", "--filter"]) From 07b97d264e28b7da40ab8bfa717e814b0c75ea28 Mon Sep 17 00:00:00 2001 From: Gustavo Medori Date: Fri, 13 Feb 2026 00:10:44 -0700 Subject: [PATCH 4/4] Update comment regarding #available check --- Sources/Testing/ABI/EntryPoints/EntryPoint.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Sources/Testing/ABI/EntryPoints/EntryPoint.swift b/Sources/Testing/ABI/EntryPoints/EntryPoint.swift index 365f95b11..579cede97 100644 --- a/Sources/Testing/ABI/EntryPoints/EntryPoint.swift +++ b/Sources/Testing/ABI/EntryPoints/EntryPoint.swift @@ -661,8 +661,9 @@ public func configurationForEntryPoint(from args: __CommandLineArguments_v0) thr } guard !regexes.isEmpty else { - // Return early with just the tag filter, even though the `reduce` logic - // below can handle this case, in order to avoid the `#available` guard. + // Return early with just the tag filter, otherwise we try to match + // against an empty array of regular expressions which is _not_ + // equivalent to `.unfiltered`. return [tagFilter] }