From ae30d48c6950954c55af61613c64c4d02bbf603b Mon Sep 17 00:00:00 2001 From: Peter Meyers Date: Wed, 25 Feb 2026 12:58:46 -0500 Subject: [PATCH] Honor exact Bazel filters without workspace-wide target discovery --- README.md | 2 +- Sources/Frontend/Commands/ScanCommand.swift | 2 +- .../ProjectDrivers/BazelProjectDriver.swift | 43 ++++++++++++++++++- 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 17d3ad87a..732ee166c 100644 --- a/README.md +++ b/README.md @@ -458,7 +458,7 @@ By default, Periphery looks for the index store at `.build/debug/index/store`. T bazel run @periphery -- scan --bazel ``` -The `--bazel` option enables Bazel mode, which provides seamless integration with your project. It works by querying your project to identify all top-level targets, generating a hidden implementation of the [scan](https://github.com/peripheryapp/periphery/blob/master/bazel/rules.bzl) rule, and then invoking `bazel run`. You can filter the top-level targets with the `--bazel-filter ` option, where `` will be passed as the first argument to Bazel's [filter](https://bazel.build/query/language#filter) operator. The generated query can be seen in the console with the `--verbose` option. +The `--bazel` option enables Bazel mode, which provides seamless integration with your project. It works by querying your project to identify all top-level targets, generating a hidden implementation of the [scan](https://github.com/peripheryapp/periphery/blob/master/bazel/rules.bzl) rule, and then invoking `bazel run`. You can filter the top-level targets with the `--bazel-filter ` option, where `` will be passed as the first argument to Bazel's [filter](https://bazel.build/query/language#filter) operator. When the filter is an exact single target label match such as `//:app$`, Periphery skips the discovery query and uses that label directly. More complex regex filters continue to use the full workspace query. The generated query can be seen in the console with the `--verbose` option. > [!TIP] > By default, Periphery passes `--check_visibility=false` to `bazel run` to simplify integration, since the generated scan target references your project's targets which may not otherwise be visible. However, disabling visibility checking can invalidate Bazel's analysis cache, resulting in slower subsequent builds. diff --git a/Sources/Frontend/Commands/ScanCommand.swift b/Sources/Frontend/Commands/ScanCommand.swift index 8ac9efbb5..2ec02e514 100644 --- a/Sources/Frontend/Commands/ScanCommand.swift +++ b/Sources/Frontend/Commands/ScanCommand.swift @@ -153,7 +153,7 @@ struct ScanCommand: ParsableCommand { @Flag(help: "Enable Bazel project mode") var bazel: Bool = defaultConfiguration.$bazel.defaultValue - @Option(help: "Filter pattern applied to the Bazel top-level targets query") + @Option(help: "Filter pattern applied to the Bazel top-level targets query. Exact single-target label filters skip target discovery and use that label directly.") var bazelFilter: String? @Option(help: "Path to a global index store populated by Bazel. If provided, will be used instead of individual module stores.") diff --git a/Sources/ProjectDrivers/BazelProjectDriver.swift b/Sources/ProjectDrivers/BazelProjectDriver.swift index b0695d32e..f70f7cc6f 100644 --- a/Sources/ProjectDrivers/BazelProjectDriver.swift +++ b/Sources/ProjectDrivers/BazelProjectDriver.swift @@ -129,7 +129,12 @@ public final class BazelProjectDriver: ProjectDriver { // MARK: - Private private func queryTargets() throws -> [String] { - try shell + if let filter = configuration.bazelFilter, + let exactTargetLabels = exactTargetLabels(filter: filter) { + return exactTargetLabels + } + + return try shell .exec(["bazel", "query", "\"\(query)\""]) .split(separator: "\n") .map { "\"@@\($0)\"" } @@ -145,4 +150,40 @@ public final class BazelProjectDriver: ProjectDriver { return query } + + private func exactTargetLabels(filter: String) -> [String]? { + guard let label = exactQueryRoot(filter: filter) else { + return nil + } + + return ["\"@@\(label)\""] + } + + private func exactQueryRoot(filter: String) -> String? { + var pattern = filter.trimmingCharacters(in: .whitespacesAndNewlines) + guard !pattern.isEmpty else { + return nil + } + + guard pattern.last == "$" else { + return nil + } + + if pattern.first == "^" { + pattern.removeFirst() + } + + pattern.removeLast() + + let disallowedCharacters = CharacterSet(charactersIn: "^$.*+?[](){}|\\") + guard pattern.rangeOfCharacter(from: disallowedCharacters) == nil else { + return nil + } + + guard pattern.hasPrefix("//"), !pattern.contains("...") else { + return nil + } + + return pattern + } }