|
| 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 | +} |
0 commit comments