Skip to content

Commit fcc1978

Browse files
obj-pclaude
andauthored
Add Bazel build system with Tier 2 hot-reload (#29)
* Add Bazel build system integration with Tier 2 hot-reload - BazelBuildSystem: detect Bazel projects, query targets, build, locate .swiftmodule artifacts, collect source files for Tier 2 compilation - BuildSystemDetector: detect Bazel projects via package/project markers when explicit projectRoot is provided or via directory walk - Fix iOS simulator platform flag to use @apple_support (bzlmod name) - Update .mcp.json to include mise shims on PATH for bazel resolution - Update .mise.toml postinstall to symlink bazelisk as bazel - Update README with Tier 2 hot-reload test steps (literal + cross-file) - Add unit tests for package detection, label construction, and detector Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Simplify BazelBuildSystem: consolidate process execution, fix marker detection - Replace findSwiftModule tuple return with findCompilerFlags returning [String] - Add shared runBazel helper, eliminating duplicated exit-code checking - Make projectMarkers internal so BuildSystemDetector references it directly - BuildSystemDetector only checks project-root markers (not package markers) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 1b615ca commit fcc1978

7 files changed

Lines changed: 507 additions & 22 deletions

File tree

.mcp.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22
"mcpServers": {
33
"previewsmcp": {
44
"command": ".build/debug/previewsmcp",
5-
"args": ["serve"]
5+
"args": ["serve"],
6+
"env": {
7+
"PATH": "${HOME}/.local/share/mise/shims:${PATH}"
8+
}
69
}
710
}
811
}

Sources/PreviewsCLI/MCPServer.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ func configureMCPServer() async throws -> (Server, Compiler) {
116116
"projectPath": .object([
117117
"type": .string("string"),
118118
"description": .string(
119-
"Project root path (auto-detected if omitted). Enables importing project types from SPM packages."
119+
"Project root path (auto-detected if omitted). Enables importing project types from SPM packages or Bazel swift_library targets."
120120
),
121121
]),
122122
]),
Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
import Foundation
2+
3+
/// Bazel build system integration for rules_swift projects.
4+
public actor BazelBuildSystem: BuildSystem {
5+
public nonisolated let projectRoot: URL
6+
private let sourceFile: URL
7+
8+
public init(projectRoot: URL, sourceFile: URL) {
9+
self.projectRoot = projectRoot
10+
self.sourceFile = sourceFile.standardizedFileURL
11+
}
12+
13+
// MARK: - Detection
14+
15+
/// Marker files that indicate a Bazel project root.
16+
static let projectMarkers = ["MODULE.bazel", "WORKSPACE.bazel", "WORKSPACE"]
17+
18+
/// Marker files that indicate a Bazel package directory.
19+
static let packageMarkers = ["BUILD.bazel", "BUILD"]
20+
21+
public static func detect(for sourceFile: URL) async throws -> BazelBuildSystem? {
22+
var dir = sourceFile.deletingLastPathComponent().standardizedFileURL
23+
let root = URL(fileURLWithPath: "/")
24+
25+
while dir.path != root.path {
26+
for marker in projectMarkers {
27+
let markerFile = dir.appendingPathComponent(marker)
28+
if FileManager.default.fileExists(atPath: markerFile.path) {
29+
// Verify bazel is actually available
30+
guard await isBazelAvailable(in: dir) else { return nil }
31+
return BazelBuildSystem(
32+
projectRoot: dir, sourceFile: sourceFile.standardizedFileURL)
33+
}
34+
}
35+
dir = dir.deletingLastPathComponent()
36+
}
37+
return nil
38+
}
39+
40+
/// Check if bazel is available by running `bazel version`.
41+
private static func isBazelAvailable(in directory: URL) async -> Bool {
42+
do {
43+
let output = try await runAsync(
44+
"/usr/bin/env", arguments: ["bazel", "version"],
45+
workingDirectory: directory, discardStderr: true)
46+
return output.exitCode == 0
47+
} catch {
48+
return false
49+
}
50+
}
51+
52+
// MARK: - Build
53+
54+
public func build(platform: PreviewPlatform) async throws -> BuildContext {
55+
// 1. Find the Bazel package and owning target
56+
let packagePath = try findBazelPackage(for: sourceFile)
57+
let sourceLabel = buildSourceLabel(packagePath: packagePath, sourceFile: sourceFile)
58+
let target = try await findOwningTarget(packagePath: packagePath, sourceLabel: sourceLabel)
59+
60+
// 2. Get module name
61+
let moduleName = try await queryModuleName(target: target)
62+
63+
// 3. Build the target
64+
try await runBazelBuild(target: target, platform: platform)
65+
66+
// 4. Locate the .swiftmodule
67+
let compilerFlags = try await findCompilerFlags(
68+
target: target, moduleName: moduleName, platform: platform)
69+
70+
// 5. Collect source files for Tier 2
71+
let sourceFiles = try await collectSourceFiles(target: target)
72+
73+
return BuildContext(
74+
moduleName: moduleName,
75+
compilerFlags: compilerFlags,
76+
projectRoot: projectRoot,
77+
targetName: moduleName,
78+
sourceFiles: sourceFiles
79+
)
80+
}
81+
82+
// MARK: - Private: Package Detection
83+
84+
/// Walk up from the source file to find the nearest BUILD.bazel or BUILD file.
85+
/// Returns the package path relative to the project root.
86+
func findBazelPackage(for file: URL) throws -> String {
87+
var dir = file.deletingLastPathComponent().standardizedFileURL
88+
let rootPath = projectRoot.standardizedFileURL.path
89+
90+
while dir.path.hasPrefix(rootPath) {
91+
for marker in Self.packageMarkers {
92+
let markerFile = dir.appendingPathComponent(marker)
93+
if FileManager.default.fileExists(atPath: markerFile.path) {
94+
// Package path is relative to project root
95+
let relativePath = String(dir.path.dropFirst(rootPath.count))
96+
.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
97+
return relativePath
98+
}
99+
}
100+
dir = dir.deletingLastPathComponent()
101+
}
102+
103+
throw BuildSystemError.targetNotFound(
104+
sourceFile: file.lastPathComponent,
105+
project: projectRoot.lastPathComponent
106+
)
107+
}
108+
109+
/// Construct a Bazel label for a source file within a package.
110+
/// E.g., packagePath="Sources/ToDo", sourceFile=".../ToDoView.swift" → "//Sources/ToDo:ToDoView.swift"
111+
nonisolated func buildSourceLabel(packagePath: String, sourceFile: URL) -> String {
112+
let packageDir = projectRoot.appendingPathComponent(packagePath).standardizedFileURL
113+
let filePath = sourceFile.standardizedFileURL.path
114+
let packageDirPath = packageDir.path
115+
116+
// The file component is the relative path from the package directory
117+
let fileComponent: String
118+
if filePath.hasPrefix(packageDirPath + "/") {
119+
fileComponent = String(filePath.dropFirst(packageDirPath.count + 1))
120+
} else {
121+
fileComponent = sourceFile.lastPathComponent
122+
}
123+
124+
return "//\(packagePath):\(fileComponent)"
125+
}
126+
127+
// MARK: - Private: Target Discovery
128+
129+
/// Find the swift_library target that owns the source file using a package-scoped query.
130+
private func findOwningTarget(packagePath: String, sourceLabel: String) async throws -> String {
131+
let query =
132+
"kind(\"swift_library\", rdeps(//\(packagePath):all, \(sourceLabel)))"
133+
134+
let output = try await runBazelQuery(query)
135+
let targets = output.split(separator: "\n").map(String.init)
136+
137+
guard let target = targets.first else {
138+
throw BuildSystemError.targetNotFound(
139+
sourceFile: sourceFile.lastPathComponent,
140+
project: projectRoot.lastPathComponent
141+
)
142+
}
143+
144+
return target
145+
}
146+
147+
// MARK: - Private: Module Name
148+
149+
/// Query the module_name attribute of a target. Falls back to the target name.
150+
private func queryModuleName(target: String) async throws -> String {
151+
let output = try await runBazelQuery("\(target)", outputFormat: "build")
152+
153+
// Parse module_name from the build output: module_name = "ToDo",
154+
if let range = output.range(of: #"module_name\s*=\s*"([^"]+)""#, options: .regularExpression) {
155+
let match = output[range]
156+
// Extract the quoted value
157+
if let quoteStart = match.range(of: "\""),
158+
let quoteEnd = match.range(of: "\"", options: .backwards, range: quoteStart.upperBound..<match.endIndex)
159+
{
160+
return String(match[quoteStart.upperBound..<quoteEnd.lowerBound])
161+
}
162+
}
163+
164+
// Fall back to target name (last component after ":")
165+
if let colonIndex = target.lastIndex(of: ":") {
166+
return String(target[target.index(after: colonIndex)...])
167+
}
168+
169+
// Last resort: use the last path component
170+
return target.split(separator: "/").last.map(String.init) ?? target
171+
}
172+
173+
// MARK: - Private: Build
174+
175+
private func runBazelBuild(target: String, platform: PreviewPlatform) async throws {
176+
var args = ["bazel", "build", target]
177+
args += platformFlags(for: platform)
178+
try await runBazel(args)
179+
}
180+
181+
// MARK: - Private: Artifact Discovery
182+
183+
/// Locate the .swiftmodule and return compiler flags (`-I <dir>`).
184+
private func findCompilerFlags(
185+
target: String, moduleName: String, platform: PreviewPlatform
186+
) async throws -> [String] {
187+
var args = ["bazel", "cquery", "--output=files", target]
188+
args += platformFlags(for: platform)
189+
190+
let output = try await runBazel(args, discardStderr: true)
191+
192+
// Find the .swiftmodule file in the output
193+
let files = output.split(separator: "\n").map(String.init)
194+
let swiftmoduleFile = files.first { $0.hasSuffix(".swiftmodule") }
195+
196+
if let swiftmodulePath = swiftmoduleFile {
197+
// Resolve to absolute path (cquery may return relative paths from execroot)
198+
let absolutePath: URL
199+
if swiftmodulePath.hasPrefix("/") {
200+
absolutePath = URL(fileURLWithPath: swiftmodulePath)
201+
} else {
202+
absolutePath = projectRoot.appendingPathComponent(swiftmodulePath)
203+
}
204+
return ["-I", absolutePath.deletingLastPathComponent().path]
205+
}
206+
207+
// Fallback: try bazel-bin symlink + package path
208+
let bazelBin = projectRoot.appendingPathComponent("bazel-bin")
209+
let moduleDir = bazelBin.appendingPathComponent(
210+
target.replacingOccurrences(of: "//", with: "")
211+
.split(separator: ":").first.map(String.init) ?? "")
212+
let swiftmodule = moduleDir.appendingPathComponent("\(moduleName).swiftmodule")
213+
214+
guard FileManager.default.fileExists(atPath: swiftmodule.path) else {
215+
throw BuildSystemError.missingArtifacts(
216+
"Expected \(moduleName).swiftmodule in bazel-bin output for \(target)")
217+
}
218+
219+
return ["-I", moduleDir.path]
220+
}
221+
222+
// MARK: - Private: Source Files (Tier 2)
223+
224+
/// Collect all source files for the target, excluding the preview file.
225+
private func collectSourceFiles(target: String) async throws -> [URL]? {
226+
let output: String
227+
do {
228+
output = try await runBazelQuery("labels(srcs, \(target))")
229+
} catch {
230+
// If query fails, fall back to Tier 1 (no source files)
231+
return nil
232+
}
233+
234+
let labels = output.split(separator: "\n").map(String.init)
235+
var sourceFiles: [URL] = []
236+
237+
for label in labels {
238+
guard let path = labelToPath(label) else { continue }
239+
let url = projectRoot.appendingPathComponent(path).standardizedFileURL
240+
// Exclude the preview file
241+
if url.path != sourceFile.path {
242+
sourceFiles.append(url)
243+
}
244+
}
245+
246+
return sourceFiles.isEmpty ? nil : sourceFiles
247+
}
248+
249+
/// Convert a Bazel label like "//Sources/ToDo:Item.swift" to a relative path "Sources/ToDo/Item.swift".
250+
nonisolated func labelToPath(_ label: String) -> String? {
251+
var label = label
252+
// Strip leading "//" or "@//"
253+
if let atSlashRange = label.range(of: "@//") {
254+
label = String(label[atSlashRange.upperBound...])
255+
} else if label.hasPrefix("//") {
256+
label = String(label.dropFirst(2))
257+
} else {
258+
return nil
259+
}
260+
261+
// Split on ":" — package:file
262+
guard let colonIndex = label.firstIndex(of: ":") else { return nil }
263+
264+
let packagePath = String(label[label.startIndex..<colonIndex])
265+
let fileName = String(label[label.index(after: colonIndex)...])
266+
267+
if packagePath.isEmpty {
268+
return fileName
269+
}
270+
return "\(packagePath)/\(fileName)"
271+
}
272+
273+
// MARK: - Private: Platform Flags
274+
275+
private func platformFlags(for platform: PreviewPlatform) -> [String] {
276+
switch platform {
277+
case .macOS:
278+
return []
279+
case .iOSSimulator:
280+
return ["--platforms=@apple_support//platforms:ios_sim_arm64"]
281+
}
282+
}
283+
284+
// MARK: - Private: Process Execution
285+
286+
private func runBazelQuery(_ query: String, outputFormat: String? = nil) async throws -> String {
287+
var args = ["bazel", "query", query]
288+
if let format = outputFormat {
289+
args += ["--output=\(format)"]
290+
}
291+
return try await runBazel(args, discardStderr: true)
292+
}
293+
294+
/// Run a bazel command via `/usr/bin/env`, check exit code, and return stdout.
295+
@discardableResult
296+
private func runBazel(
297+
_ arguments: [String], discardStderr: Bool = false
298+
) async throws -> String {
299+
let output = try await runAsync(
300+
"/usr/bin/env", arguments: arguments,
301+
workingDirectory: projectRoot, discardStderr: discardStderr)
302+
guard output.exitCode == 0 else {
303+
throw BuildSystemError.buildFailed(
304+
stderr: output.stderr.isEmpty ? output.stdout : output.stderr,
305+
exitCode: output.exitCode
306+
)
307+
}
308+
return output.stdout
309+
}
310+
}

Sources/PreviewsCore/BuildSystem.swift

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,28 @@ public enum BuildSystemDetector {
2121
/// - sourceFile: The Swift source file to detect the build system for.
2222
/// - projectRoot: If provided, use this as the project root instead of auto-detecting.
2323
public static func detect(for sourceFile: URL, projectRoot: URL? = nil) async throws -> (any BuildSystem)? {
24-
// If an explicit project root is provided, try to create a build system at that root directly
24+
// If an explicit project root is provided, detect which build system applies there
2525
if let projectRoot = projectRoot {
26-
return SPMBuildSystem(projectRoot: projectRoot, sourceFile: sourceFile)
26+
let fm = FileManager.default
27+
if fm.fileExists(atPath: projectRoot.appendingPathComponent("Package.swift").path) {
28+
return SPMBuildSystem(projectRoot: projectRoot, sourceFile: sourceFile)
29+
}
30+
for marker in BazelBuildSystem.projectMarkers {
31+
if fm.fileExists(atPath: projectRoot.appendingPathComponent(marker).path) {
32+
return BazelBuildSystem(projectRoot: projectRoot, sourceFile: sourceFile)
33+
}
34+
}
35+
return nil
2736
}
2837
// SPM first (most common for Swift-only projects)
2938
if let spm = try await SPMBuildSystem.detect(for: sourceFile) {
3039
return spm
3140
}
32-
// Future: XcodeBuildSystem, BazelBuildSystem
41+
// Bazel (rules_swift projects)
42+
if let bazel = try await BazelBuildSystem.detect(for: sourceFile) {
43+
return bazel
44+
}
45+
// Future: XcodeBuildSystem
3346
return nil
3447
}
3548
}

0 commit comments

Comments
 (0)