From b603444ca644ab7990356af6517b22d0fc305e5d Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Fri, 20 Mar 2026 23:02:51 -0400 Subject: [PATCH 1/4] Add Xcode build system integration with Tier 2 hot-reload (#16) - XcodeBuildSystem: detect .xcodeproj, build via xcodebuild, parse build settings, collect source files from OutputFileMap.json (Tier 2) with Tier 1 fallback. Uses -F (framework search path) for compiler flags. - Fix Tier 1 module name bug: bridge dylib now uses PreviewBridge_ to avoid "file is part of module X; ignoring import" self-import error. - Fail with ambiguousTarget error when multiple schemes exist and none match the source file path, listing available schemes for the user. - Register in BuildSystemDetector (priority: SPM > Bazel > Xcode) - Add 14 unit tests for detection, scheme picking, build settings parsing, search paths parsing, OutputFileMap collection, and detector priority. Co-Authored-By: Claude Opus 4.6 (1M context) --- Sources/PreviewsCore/BuildSystem.swift | 14 +- Sources/PreviewsCore/PreviewSession.swift | 6 +- Sources/PreviewsCore/XcodeBuildSystem.swift | 288 ++++++++++++++++++ .../PreviewsCoreTests/BuildSystemTests.swift | 230 ++++++++++++++ docs/build-system-integration.md | 4 +- examples/xcode/README.md | 6 +- 6 files changed, 540 insertions(+), 8 deletions(-) create mode 100644 Sources/PreviewsCore/XcodeBuildSystem.swift diff --git a/Sources/PreviewsCore/BuildSystem.swift b/Sources/PreviewsCore/BuildSystem.swift index 394b348..76eabd9 100644 --- a/Sources/PreviewsCore/BuildSystem.swift +++ b/Sources/PreviewsCore/BuildSystem.swift @@ -32,6 +32,11 @@ public enum BuildSystemDetector { return BazelBuildSystem(projectRoot: projectRoot, sourceFile: sourceFile) } } + // Xcode: enumerate directory for *.xcodeproj (name varies) + if let xcodeproj = XcodeBuildSystem.findXcodeproj(in: projectRoot) { + return XcodeBuildSystem( + projectRoot: projectRoot, sourceFile: sourceFile, xcodeproj: xcodeproj) + } return nil } // SPM first (most common for Swift-only projects) @@ -42,7 +47,10 @@ public enum BuildSystemDetector { if let bazel = try await BazelBuildSystem.detect(for: sourceFile) { return bazel } - // Future: XcodeBuildSystem + // Xcode (.xcodeproj) + if let xcode = try await XcodeBuildSystem.detect(for: sourceFile) { + return xcode + } return nil } } @@ -52,6 +60,7 @@ public enum BuildSystemError: Error, LocalizedError { case buildFailed(stderr: String, exitCode: Int32) case targetNotFound(sourceFile: String, project: String) case missingArtifacts(String) + case ambiguousTarget(sourceFile: String, candidates: [String]) public var errorDescription: String? { switch self { @@ -61,6 +70,9 @@ public enum BuildSystemError: Error, LocalizedError { return "Could not determine which target contains \(file) in \(project)" case .missingArtifacts(let msg): return "Build artifacts not found: \(msg)" + case .ambiguousTarget(let file, let candidates): + return + "Multiple schemes found for \(file). Use projectRoot to disambiguate. Available schemes: \(candidates.joined(separator: ", "))" } } } diff --git a/Sources/PreviewsCore/PreviewSession.swift b/Sources/PreviewsCore/PreviewSession.swift index 06fc160..ae10b7a 100644 --- a/Sources/PreviewsCore/PreviewSession.swift +++ b/Sources/PreviewsCore/PreviewSession.swift @@ -76,8 +76,12 @@ public actor PreviewSession { compiledSource = result.source literals = result.literals additionalSourceFiles = srcFiles + moduleName = ctx.moduleName } else { // Tier 1: bridge-only (no literal hot-reload) + // Use a different module name for the bridge dylib to avoid + // "file is part of module X; ignoring import" when swiftc sees + // both -module-name X and @testable import X in the same file. compiledSource = BridgeGenerator.generateBridgeOnlySource( moduleName: ctx.moduleName, closureBody: preview.closureBody, @@ -85,8 +89,8 @@ public actor PreviewSession { ) literals = [] additionalSourceFiles = [] + moduleName = "PreviewBridge_\(ctx.moduleName)" } - moduleName = ctx.moduleName extraFlags = ctx.compilerFlags } else { // Standalone mode: existing behavior diff --git a/Sources/PreviewsCore/XcodeBuildSystem.swift b/Sources/PreviewsCore/XcodeBuildSystem.swift new file mode 100644 index 0000000..742fbf0 --- /dev/null +++ b/Sources/PreviewsCore/XcodeBuildSystem.swift @@ -0,0 +1,288 @@ +import Foundation + +/// Xcode build system integration for .xcodeproj projects. +public actor XcodeBuildSystem: BuildSystem { + public nonisolated let projectRoot: URL + private let sourceFile: URL + private let xcodeproj: URL + + public init(projectRoot: URL, sourceFile: URL, xcodeproj: URL) { + self.projectRoot = projectRoot + self.sourceFile = sourceFile.standardizedFileURL + self.xcodeproj = xcodeproj + } + + // MARK: - Detection + + public static func detect(for sourceFile: URL) async throws -> XcodeBuildSystem? { + var dir = sourceFile.deletingLastPathComponent().standardizedFileURL + let root = URL(fileURLWithPath: "/") + + while dir.path != root.path { + if let xcodeproj = findXcodeproj(in: dir) { + guard await isXcodebuildAvailable() else { return nil } + return XcodeBuildSystem( + projectRoot: dir, sourceFile: sourceFile.standardizedFileURL, + xcodeproj: xcodeproj) + } + dir = dir.deletingLastPathComponent() + } + return nil + } + + /// Find a .xcodeproj directory in the given directory. + static func findXcodeproj(in directory: URL) -> URL? { + guard + let contents = try? FileManager.default.contentsOfDirectory( + at: directory, includingPropertiesForKeys: nil) + else { return nil } + return contents.first { $0.pathExtension == "xcodeproj" } + } + + private static func isXcodebuildAvailable() async -> Bool { + do { + let output = try await runAsync( + "/usr/bin/xcrun", arguments: ["--find", "xcodebuild"], discardStderr: true) + return output.exitCode == 0 + } catch { + return false + } + } + + // MARK: - Build + + public func build(platform: PreviewPlatform) async throws -> BuildContext { + // 1. List schemes and pick one + let projectInfo = try await listSchemes() + let scheme = try pickScheme(from: projectInfo) + + // 2. Build the project (must happen before getBuildSettings so DerivedData is populated) + try await runBuild(scheme: scheme, platform: platform) + + // 3. Get build settings (post-build so all paths are valid) + let settings = try await getBuildSettings(scheme: scheme, platform: platform) + + let moduleName = + settings["PRODUCT_MODULE_NAME"] ?? settings["TARGET_NAME"] ?? scheme + let targetName = settings["TARGET_NAME"] ?? scheme + + // 4. Verify framework exists + guard let builtProductsDir = settings["BUILT_PRODUCTS_DIR"] else { + throw BuildSystemError.missingArtifacts( + "BUILT_PRODUCTS_DIR not found in build settings for scheme \(scheme)") + } + + let frameworkPath = URL(fileURLWithPath: builtProductsDir) + .appendingPathComponent("\(moduleName).framework") + guard FileManager.default.fileExists(atPath: frameworkPath.path) else { + throw BuildSystemError.missingArtifacts( + "Expected \(moduleName).framework at \(builtProductsDir)") + } + + // 5. Collect source files for Tier 2 (from OutputFileMap.json) + let sourceFiles = collectSourceFiles(settings: settings, targetName: targetName) + + // 6. Build compiler flags + let compilerFlags = buildCompilerFlags(settings: settings) + + return BuildContext( + moduleName: moduleName, + compilerFlags: compilerFlags, + projectRoot: projectRoot, + targetName: targetName, + sourceFiles: sourceFiles + ) + } + + // MARK: - Private: Scheme Discovery + + struct ProjectInfo: Decodable { + let project: ProjectDetails + struct ProjectDetails: Decodable { + let schemes: [String] + let targets: [String] + } + } + + private func listSchemes() async throws -> ProjectInfo { + let output = try await runXcodebuild( + "-project", xcodeproj.path, "-list", "-json") + guard let data = output.data(using: .utf8) else { + throw BuildSystemError.missingArtifacts( + "Could not parse xcodebuild -list output") + } + return try JSONDecoder().decode(ProjectInfo.self, from: data) + } + + func pickScheme(from info: ProjectInfo) throws -> String { + let schemes = info.project.schemes + guard !schemes.isEmpty else { + throw BuildSystemError.targetNotFound( + sourceFile: sourceFile.lastPathComponent, + project: xcodeproj.lastPathComponent) + } + if schemes.count == 1 { return schemes[0] } + + // Try to match a scheme name to a directory component in the source file path + let pathComponents = Set(sourceFile.pathComponents) + if let match = schemes.first(where: { pathComponents.contains($0) }) { + return match + } + + throw BuildSystemError.ambiguousTarget( + sourceFile: sourceFile.lastPathComponent, + candidates: schemes) + } + + // MARK: - Private: Build Settings + + private func getBuildSettings( + scheme: String, platform: PreviewPlatform + ) async throws -> [String: String] { + let destination = destinationString(for: platform) + let output = try await runXcodebuild( + "-project", xcodeproj.path, + "-scheme", scheme, + "-showBuildSettings", + "-destination", destination) + return Self.parseBuildSettings(output) + } + + static func parseBuildSettings(_ output: String) -> [String: String] { + var settings: [String: String] = [:] + for line in output.split(separator: "\n", omittingEmptySubsequences: false) { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard let equalsRange = trimmed.range(of: " = ") else { continue } + let key = String(trimmed[trimmed.startIndex.. [URL]? { + // OutputFileMap lives at /arm64/-OutputFileMap.json + guard let objectFileDir = settings["OBJECT_FILE_DIR_normal"] else { return nil } + + let outputFileMapPath = URL(fileURLWithPath: objectFileDir) + .appendingPathComponent("arm64") + .appendingPathComponent("\(targetName)-OutputFileMap.json") + + guard let data = try? Data(contentsOf: outputFileMapPath), + let map = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return nil } + + // Keys are absolute source file paths; "" is module-level metadata (skip it) + var sourceFiles: [URL] = [] + for key in map.keys { + guard !key.isEmpty, key.hasSuffix(".swift") else { continue } + let url = URL(fileURLWithPath: key).standardizedFileURL + if url.path != sourceFile.path { + sourceFiles.append(url) + } + } + + return sourceFiles.isEmpty ? nil : sourceFiles + } + + // MARK: - Private: Compiler Flags + + private func buildCompilerFlags(settings: [String: String]) -> [String] { + var flags: [String] = [] + + // Framework search path for the target's own framework + if let builtProductsDir = settings["BUILT_PRODUCTS_DIR"] { + flags += ["-F", builtProductsDir] + } + + // Additional framework search paths for dependencies + if let searchPaths = settings["FRAMEWORK_SEARCH_PATHS"] { + for path in Self.parseSearchPaths(searchPaths) { + if !flags.contains(path) { + flags += ["-F", path] + } + } + } + + return flags + } + + /// Parse space-separated paths from xcodebuild, handling quoted paths and $(inherited). + static func parseSearchPaths(_ value: String) -> [String] { + var paths: [String] = [] + var current = "" + var inQuote = false + + for char in value { + if char == "\"" { + inQuote.toggle() + } else if char == " " && !inQuote { + let trimmed = current.trimmingCharacters(in: .whitespaces) + if !trimmed.isEmpty && trimmed != "$(inherited)" { + paths.append(trimmed) + } + current = "" + } else { + current.append(char) + } + } + + let trimmed = current.trimmingCharacters(in: .whitespaces) + if !trimmed.isEmpty && trimmed != "$(inherited)" { + paths.append(trimmed) + } + + return paths + } + + // MARK: - Private: Platform + + private func destinationString(for platform: PreviewPlatform) -> String { + switch platform { + case .macOS: + return "platform=macOS" + case .iOSSimulator: + return "generic/platform=iOS Simulator" + } + } + + // MARK: - Private: Process Execution + + @discardableResult + private func runXcodebuild(_ args: String...) async throws -> String { + try await runXcodebuild(args: args) + } + + @discardableResult + private func runXcodebuild(args: [String]) async throws -> String { + let fullArgs = ["xcodebuild"] + args + let output = try await runAsync( + "/usr/bin/env", arguments: fullArgs, + workingDirectory: projectRoot) + guard output.exitCode == 0 else { + throw BuildSystemError.buildFailed( + stderr: output.stderr.isEmpty ? output.stdout : output.stderr, + exitCode: output.exitCode) + } + return output.stdout + } +} diff --git a/Tests/PreviewsCoreTests/BuildSystemTests.swift b/Tests/PreviewsCoreTests/BuildSystemTests.swift index b99acfd..51097bc 100644 --- a/Tests/PreviewsCoreTests/BuildSystemTests.swift +++ b/Tests/PreviewsCoreTests/BuildSystemTests.swift @@ -359,4 +359,234 @@ struct BuildSystemTests { ) #expect(tier2.supportsTier2) } + + // MARK: - XcodeBuildSystem.detect + + @Test("XcodeBuildSystem detects .xcodeproj walking up directories") + func detectXcodeProject() async throws { + let tmpDir = FileManager.default.temporaryDirectory + .appendingPathComponent("previewsmcp-test-\(UUID().uuidString)") + let sourcesDir = tmpDir.appendingPathComponent("Sources/MyTarget") + try FileManager.default.createDirectory(at: sourcesDir, withIntermediateDirectories: true) + + let xcodeproj = tmpDir.appendingPathComponent("MyApp.xcodeproj") + try FileManager.default.createDirectory(at: xcodeproj, withIntermediateDirectories: true) + + let sourceFile = sourcesDir.appendingPathComponent("MyView.swift") + try "import SwiftUI".write(to: sourceFile, atomically: true, encoding: .utf8) + + defer { try? FileManager.default.removeItem(at: tmpDir) } + + let xcode = XcodeBuildSystem( + projectRoot: tmpDir, sourceFile: sourceFile, xcodeproj: xcodeproj) + #expect(xcode.projectRoot.path == tmpDir.standardizedFileURL.path) + } + + @Test("XcodeBuildSystem findXcodeproj ignores .xcworkspace") + func findXcodeprojIgnoresWorkspace() async throws { + let tmpDir = FileManager.default.temporaryDirectory + .appendingPathComponent("previewsmcp-test-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true) + + // Only a workspace, no xcodeproj + let workspace = tmpDir.appendingPathComponent("MyApp.xcworkspace") + try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true) + + defer { try? FileManager.default.removeItem(at: tmpDir) } + + let result = XcodeBuildSystem.findXcodeproj(in: tmpDir) + #expect(result == nil) + } + + @Test("XcodeBuildSystem findXcodeproj finds .xcodeproj in directory") + func findXcodeprojFindsProject() async throws { + let tmpDir = FileManager.default.temporaryDirectory + .appendingPathComponent("previewsmcp-test-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true) + + let xcodeproj = tmpDir.appendingPathComponent("ToDo.xcodeproj") + try FileManager.default.createDirectory(at: xcodeproj, withIntermediateDirectories: true) + + defer { try? FileManager.default.removeItem(at: tmpDir) } + + let result = XcodeBuildSystem.findXcodeproj(in: tmpDir) + #expect(result != nil) + #expect(result?.lastPathComponent == "ToDo.xcodeproj") + } + + // MARK: - XcodeBuildSystem scheme picking + + @Test("XcodeBuildSystem picks single scheme") + func pickSingleScheme() async throws { + let xcode = XcodeBuildSystem( + projectRoot: URL(fileURLWithPath: "/tmp"), + sourceFile: URL(fileURLWithPath: "/tmp/Sources/ToDo/View.swift"), + xcodeproj: URL(fileURLWithPath: "/tmp/App.xcodeproj")) + + let info = XcodeBuildSystem.ProjectInfo( + project: .init(schemes: ["MyApp"], targets: ["MyApp"])) + let scheme = try await xcode.pickScheme(from: info) + #expect(scheme == "MyApp") + } + + @Test("XcodeBuildSystem picks scheme matching path component") + func pickSchemeMatchingPath() async throws { + let xcode = XcodeBuildSystem( + projectRoot: URL(fileURLWithPath: "/tmp"), + sourceFile: URL(fileURLWithPath: "/tmp/Sources/FeatureB/View.swift"), + xcodeproj: URL(fileURLWithPath: "/tmp/App.xcodeproj")) + + let info = XcodeBuildSystem.ProjectInfo( + project: .init(schemes: ["FeatureA", "FeatureB", "FeatureC"], targets: [])) + let scheme = try await xcode.pickScheme(from: info) + #expect(scheme == "FeatureB") + } + + @Test("XcodeBuildSystem throws ambiguousTarget when no scheme matches") + func pickSchemeThrowsWhenAmbiguous() async throws { + let xcode = XcodeBuildSystem( + projectRoot: URL(fileURLWithPath: "/tmp"), + sourceFile: URL(fileURLWithPath: "/tmp/Sources/Unknown/View.swift"), + xcodeproj: URL(fileURLWithPath: "/tmp/App.xcodeproj")) + + let info = XcodeBuildSystem.ProjectInfo( + project: .init(schemes: ["Alpha", "Beta"], targets: [])) + await #expect(throws: BuildSystemError.self) { + try await xcode.pickScheme(from: info) + } + } + + // MARK: - XcodeBuildSystem build settings parsing + + @Test("XcodeBuildSystem parses build settings output") + func parseBuildSettings() { + let output = """ + Build settings for action build and target ToDo: + BUILT_PRODUCTS_DIR = /Users/dev/DerivedData/ToDo/Build/Products/Debug + PRODUCT_MODULE_NAME = ToDo + TARGET_NAME = ToDo + OBJECT_FILE_DIR_normal = /Users/dev/DerivedData/ToDo/Build/Intermediates.noindex/ToDo.build/Debug/ToDo.build/Objects-normal + FRAMEWORK_SEARCH_PATHS = /Users/dev/DerivedData/ToDo/Build/Products/Debug + SWIFT_VERSION = 6.0 + """ + let settings = XcodeBuildSystem.parseBuildSettings(output) + #expect(settings["BUILT_PRODUCTS_DIR"] == "/Users/dev/DerivedData/ToDo/Build/Products/Debug") + #expect(settings["PRODUCT_MODULE_NAME"] == "ToDo") + #expect(settings["TARGET_NAME"] == "ToDo") + #expect(settings["OBJECT_FILE_DIR_normal"] != nil) + #expect(settings["SWIFT_VERSION"] == "6.0") + } + + // MARK: - XcodeBuildSystem search paths parsing + + @Test("XcodeBuildSystem parses space-separated search paths") + func parseSearchPaths() { + let paths = XcodeBuildSystem.parseSearchPaths( + "/path/one /path/two $(inherited)") + #expect(paths == ["/path/one", "/path/two"]) + } + + @Test("XcodeBuildSystem parses quoted search paths") + func parseQuotedSearchPaths() { + let paths = XcodeBuildSystem.parseSearchPaths( + "\"/path/with spaces\" /normal/path") + #expect(paths == ["/path/with spaces", "/normal/path"]) + } + + // MARK: - XcodeBuildSystem source file collection + + @Test("XcodeBuildSystem collects source files from OutputFileMap") + func collectSourceFilesFromOutputFileMap() async throws { + let tmpDir = FileManager.default.temporaryDirectory + .appendingPathComponent("previewsmcp-test-\(UUID().uuidString)") + let objectDir = tmpDir.appendingPathComponent("Objects-normal/arm64") + try FileManager.default.createDirectory(at: objectDir, withIntermediateDirectories: true) + + let previewFile = "/project/Sources/ToDo/ToDoView.swift" + let otherFile = "/project/Sources/ToDo/Item.swift" + + // Create a mock OutputFileMap.json + let outputFileMap: [String: Any] = [ + "": ["diagnostics": "/path/to/diag"], + previewFile: ["object": "/path/to/ToDoView.o"], + otherFile: ["object": "/path/to/Item.o"], + ] + let data = try JSONSerialization.data(withJSONObject: outputFileMap) + try data.write(to: objectDir.appendingPathComponent("ToDo-OutputFileMap.json")) + + defer { try? FileManager.default.removeItem(at: tmpDir) } + + let xcode = XcodeBuildSystem( + projectRoot: URL(fileURLWithPath: "/project"), + sourceFile: URL(fileURLWithPath: previewFile), + xcodeproj: URL(fileURLWithPath: "/project/App.xcodeproj")) + + let settings: [String: String] = [ + "OBJECT_FILE_DIR_normal": tmpDir.appendingPathComponent("Objects-normal").path, + "TARGET_NAME": "ToDo", + ] + + let files = await xcode.collectSourceFiles(settings: settings, targetName: "ToDo") + #expect(files != nil) + #expect(files?.count == 1) + #expect(files?.first?.lastPathComponent == "Item.swift") + } + + @Test("XcodeBuildSystem returns nil when OutputFileMap is missing") + func collectSourceFilesReturnsNilWhenMissing() async throws { + let xcode = XcodeBuildSystem( + projectRoot: URL(fileURLWithPath: "/tmp"), + sourceFile: URL(fileURLWithPath: "/tmp/View.swift"), + xcodeproj: URL(fileURLWithPath: "/tmp/App.xcodeproj")) + + let settings: [String: String] = [ + "OBJECT_FILE_DIR_normal": "/nonexistent/path", + "TARGET_NAME": "App", + ] + + let files = await xcode.collectSourceFiles(settings: settings, targetName: "App") + #expect(files == nil) + } + + // MARK: - BuildSystemDetector with Xcode + + @Test("BuildSystemDetector prefers SPM over Xcode when both exist") + func detectorPrefersSPMOverXcode() async throws { + let tmpDir = FileManager.default.temporaryDirectory + .appendingPathComponent("previewsmcp-test-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true) + + try "// swift-tools-version: 6.0".write( + to: tmpDir.appendingPathComponent("Package.swift"), atomically: true, encoding: .utf8) + let xcodeproj = tmpDir.appendingPathComponent("MyApp.xcodeproj") + try FileManager.default.createDirectory(at: xcodeproj, withIntermediateDirectories: true) + + let sourceFile = tmpDir.appendingPathComponent("main.swift") + try "import SwiftUI".write(to: sourceFile, atomically: true, encoding: .utf8) + + defer { try? FileManager.default.removeItem(at: tmpDir) } + + let buildSystem = try await BuildSystemDetector.detect( + for: sourceFile, projectRoot: tmpDir) + #expect(buildSystem is SPMBuildSystem) + } + + @Test("BuildSystemDetector returns XcodeBuildSystem for explicit Xcode project root") + func detectorReturnsXcodeForExplicitRoot() async throws { + let tmpDir = FileManager.default.temporaryDirectory + .appendingPathComponent("previewsmcp-test-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true) + + let xcodeproj = tmpDir.appendingPathComponent("MyApp.xcodeproj") + try FileManager.default.createDirectory(at: xcodeproj, withIntermediateDirectories: true) + + let sourceFile = tmpDir.appendingPathComponent("main.swift") + try "import SwiftUI".write(to: sourceFile, atomically: true, encoding: .utf8) + + defer { try? FileManager.default.removeItem(at: tmpDir) } + + let buildSystem = try await BuildSystemDetector.detect( + for: sourceFile, projectRoot: tmpDir) + #expect(buildSystem is XcodeBuildSystem) + } } diff --git a/docs/build-system-integration.md b/docs/build-system-integration.md index abaff54..d48f9a4 100644 --- a/docs/build-system-integration.md +++ b/docs/build-system-integration.md @@ -88,8 +88,8 @@ Detection order is defined in `BuildSystemDetector`: public enum BuildSystemDetector { public static func detect(for sourceFile: URL) async throws -> (any BuildSystem)? { if let spm = try await SPMBuildSystem.detect(for: sourceFile) { return spm } - // Future: if let xcode = try await XcodeBuildSystem.detect(for: sourceFile) { return xcode } - // Future: if let bazel = try await BazelBuildSystem.detect(for: sourceFile) { return bazel } + if let bazel = try await BazelBuildSystem.detect(for: sourceFile) { return bazel } + if let xcode = try await XcodeBuildSystem.detect(for: sourceFile) { return xcode } return nil } } diff --git a/examples/xcode/README.md b/examples/xcode/README.md index f29b09c..c319ae2 100644 --- a/examples/xcode/README.md +++ b/examples/xcode/README.md @@ -47,8 +47,6 @@ Build outputs land in DerivedData — the `.swiftmodule` and object files that P ## Integration Test Prompt -> **Note:** This test requires `XcodeBuildSystem` (not yet implemented). The prompt below is aspirational — `preview_start` will not detect the `.xcodeproj` until #16 is complete. - Use this prompt to test PreviewsMCP's Xcode integration end-to-end: ``` @@ -86,8 +84,8 @@ The example project is at examples/xcode/ relative to the PreviewsMCP repo root. | Aspect | SPM | Xcode | Bazel | |--------|-----|-------|-------| -| Compilation tier | Tier 2 (source compilation) | TBD (Tier 1 or 2) | Tier 1 (bridge-only) | -| Hot-reload | Literal + cross-file (automatic) | TBD | Requires manual `bazel build` | +| Compilation tier | Tier 2 (source compilation) | Tier 2 (source compilation, Tier 1 fallback) | Tier 2 (source compilation) | +| Hot-reload | Literal + cross-file (automatic) | Literal + cross-file (automatic) | Literal + cross-file (automatic) | | Detection marker | `Package.swift` | `.xcodeproj` | `BUILD.bazel` / `MODULE.bazel` | | Artifact location | `.build//debug/Modules/` | `DerivedData/.../.swiftmodule` | `bazel-bin/Sources/ToDo/` | | Project generator | N/A | XcodeGen (`project.yml`) | N/A | From adf63005639f281b2c2c9a4cfd6b4f15722a8784 Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Fri, 20 Mar 2026 23:07:48 -0400 Subject: [PATCH 2/4] Address review feedback: simplify and fix multi-target parsing - parseBuildSettings: stop at second "Build settings for" header to avoid conflating settings from multiple targets in the same scheme - Remove unused ProjectInfo.targets field - Verify BUILT_PRODUCTS_DIR exists instead of assuming .framework product type (supports app targets, static libraries, etc.) - Simplify parseSearchPaths to basic split + quote strip + filter - Use Set for deduplicating framework search paths - Rename misleading test, add multi-target parsing test Co-Authored-By: Claude Opus 4.6 (1M context) --- Sources/PreviewsCore/XcodeBuildSystem.swift | 50 +++++++------------ .../PreviewsCoreTests/BuildSystemTests.swift | 32 +++++++++--- 2 files changed, 42 insertions(+), 40 deletions(-) diff --git a/Sources/PreviewsCore/XcodeBuildSystem.swift b/Sources/PreviewsCore/XcodeBuildSystem.swift index 742fbf0..0254cab 100644 --- a/Sources/PreviewsCore/XcodeBuildSystem.swift +++ b/Sources/PreviewsCore/XcodeBuildSystem.swift @@ -66,17 +66,15 @@ public actor XcodeBuildSystem: BuildSystem { settings["PRODUCT_MODULE_NAME"] ?? settings["TARGET_NAME"] ?? scheme let targetName = settings["TARGET_NAME"] ?? scheme - // 4. Verify framework exists + // 4. Verify build products exist guard let builtProductsDir = settings["BUILT_PRODUCTS_DIR"] else { throw BuildSystemError.missingArtifacts( "BUILT_PRODUCTS_DIR not found in build settings for scheme \(scheme)") } - let frameworkPath = URL(fileURLWithPath: builtProductsDir) - .appendingPathComponent("\(moduleName).framework") - guard FileManager.default.fileExists(atPath: frameworkPath.path) else { + guard FileManager.default.fileExists(atPath: builtProductsDir) else { throw BuildSystemError.missingArtifacts( - "Expected \(moduleName).framework at \(builtProductsDir)") + "Build products directory not found at \(builtProductsDir)") } // 5. Collect source files for Tier 2 (from OutputFileMap.json) @@ -100,7 +98,6 @@ public actor XcodeBuildSystem: BuildSystem { let project: ProjectDetails struct ProjectDetails: Decodable { let schemes: [String] - let targets: [String] } } @@ -148,10 +145,18 @@ public actor XcodeBuildSystem: BuildSystem { return Self.parseBuildSettings(output) } + /// Parse build settings from xcodebuild output. + /// Only parses the first target's settings (stops at the next "Build settings for" header). static func parseBuildSettings(_ output: String) -> [String: String] { var settings: [String: String] = [:] + var foundFirstTarget = false for line in output.split(separator: "\n", omittingEmptySubsequences: false) { let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("Build settings for") { + if foundFirstTarget { break } + foundFirstTarget = true + continue + } guard let equalsRange = trimmed.range(of: " = ") else { continue } let key = String(trimmed[trimmed.startIndex.. [String] { var flags: [String] = [] + var seenPaths: Set = [] // Framework search path for the target's own framework if let builtProductsDir = settings["BUILT_PRODUCTS_DIR"] { flags += ["-F", builtProductsDir] + seenPaths.insert(builtProductsDir) } // Additional framework search paths for dependencies if let searchPaths = settings["FRAMEWORK_SEARCH_PATHS"] { for path in Self.parseSearchPaths(searchPaths) { - if !flags.contains(path) { + if seenPaths.insert(path).inserted { flags += ["-F", path] } } @@ -226,32 +233,11 @@ public actor XcodeBuildSystem: BuildSystem { return flags } - /// Parse space-separated paths from xcodebuild, handling quoted paths and $(inherited). + /// Parse space-separated paths from xcodebuild, filtering out $(inherited) and quotes. static func parseSearchPaths(_ value: String) -> [String] { - var paths: [String] = [] - var current = "" - var inQuote = false - - for char in value { - if char == "\"" { - inQuote.toggle() - } else if char == " " && !inQuote { - let trimmed = current.trimmingCharacters(in: .whitespaces) - if !trimmed.isEmpty && trimmed != "$(inherited)" { - paths.append(trimmed) - } - current = "" - } else { - current.append(char) - } - } - - let trimmed = current.trimmingCharacters(in: .whitespaces) - if !trimmed.isEmpty && trimmed != "$(inherited)" { - paths.append(trimmed) - } - - return paths + value.components(separatedBy: " ") + .map { $0.trimmingCharacters(in: CharacterSet(charactersIn: "\"")) } + .filter { !$0.isEmpty && $0 != "$(inherited)" } } // MARK: - Private: Platform diff --git a/Tests/PreviewsCoreTests/BuildSystemTests.swift b/Tests/PreviewsCoreTests/BuildSystemTests.swift index 51097bc..6356c62 100644 --- a/Tests/PreviewsCoreTests/BuildSystemTests.swift +++ b/Tests/PreviewsCoreTests/BuildSystemTests.swift @@ -362,8 +362,8 @@ struct BuildSystemTests { // MARK: - XcodeBuildSystem.detect - @Test("XcodeBuildSystem detects .xcodeproj walking up directories") - func detectXcodeProject() async throws { + @Test("XcodeBuildSystem stores projectRoot from init") + func xcodeProjectInit() async throws { let tmpDir = FileManager.default.temporaryDirectory .appendingPathComponent("previewsmcp-test-\(UUID().uuidString)") let sourcesDir = tmpDir.appendingPathComponent("Sources/MyTarget") @@ -424,7 +424,7 @@ struct BuildSystemTests { xcodeproj: URL(fileURLWithPath: "/tmp/App.xcodeproj")) let info = XcodeBuildSystem.ProjectInfo( - project: .init(schemes: ["MyApp"], targets: ["MyApp"])) + project: .init(schemes: ["MyApp"])) let scheme = try await xcode.pickScheme(from: info) #expect(scheme == "MyApp") } @@ -437,7 +437,7 @@ struct BuildSystemTests { xcodeproj: URL(fileURLWithPath: "/tmp/App.xcodeproj")) let info = XcodeBuildSystem.ProjectInfo( - project: .init(schemes: ["FeatureA", "FeatureB", "FeatureC"], targets: [])) + project: .init(schemes: ["FeatureA", "FeatureB", "FeatureC"])) let scheme = try await xcode.pickScheme(from: info) #expect(scheme == "FeatureB") } @@ -450,7 +450,7 @@ struct BuildSystemTests { xcodeproj: URL(fileURLWithPath: "/tmp/App.xcodeproj")) let info = XcodeBuildSystem.ProjectInfo( - project: .init(schemes: ["Alpha", "Beta"], targets: [])) + project: .init(schemes: ["Alpha", "Beta"])) await #expect(throws: BuildSystemError.self) { try await xcode.pickScheme(from: info) } @@ -477,6 +477,22 @@ struct BuildSystemTests { #expect(settings["SWIFT_VERSION"] == "6.0") } + @Test("XcodeBuildSystem parseBuildSettings stops at second target") + func parseBuildSettingsMultiTarget() { + let output = """ + Build settings for action build and target ToDo: + PRODUCT_MODULE_NAME = ToDo + TARGET_NAME = ToDo + + Build settings for action build and target ToDoTests: + PRODUCT_MODULE_NAME = ToDoTests + TARGET_NAME = ToDoTests + """ + let settings = XcodeBuildSystem.parseBuildSettings(output) + #expect(settings["PRODUCT_MODULE_NAME"] == "ToDo") + #expect(settings["TARGET_NAME"] == "ToDo") + } + // MARK: - XcodeBuildSystem search paths parsing @Test("XcodeBuildSystem parses space-separated search paths") @@ -486,11 +502,11 @@ struct BuildSystemTests { #expect(paths == ["/path/one", "/path/two"]) } - @Test("XcodeBuildSystem parses quoted search paths") + @Test("XcodeBuildSystem parses search paths stripping quotes") func parseQuotedSearchPaths() { let paths = XcodeBuildSystem.parseSearchPaths( - "\"/path/with spaces\" /normal/path") - #expect(paths == ["/path/with spaces", "/normal/path"]) + "\"/path/to/libs\" /normal/path") + #expect(paths == ["/path/to/libs", "/normal/path"]) } // MARK: - XcodeBuildSystem source file collection From b673e487bc893b823a8bf47aac6ff3806f760011 Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Fri, 20 Mar 2026 23:32:27 -0400 Subject: [PATCH 3/4] Rename examples/xcode to examples/xcodeproj Anticipates future examples/xcworkspace directory for workspace support (#34). Updates all references in skills, READMEs, and docs. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/bootstrap/SKILL.md | 2 +- .claude/skills/integration-test/SKILL.md | 8 ++++++++ examples/README.md | 2 +- examples/bazel/README.md | 5 +++-- examples/{xcode => xcodeproj}/.gitignore | 0 examples/{xcode => xcodeproj}/Mintfile | 0 examples/{xcode => xcodeproj}/README.md | 10 +++++----- examples/{xcode => xcodeproj}/Sources/ToDo/Item.swift | 0 .../{xcode => xcodeproj}/Sources/ToDo/ToDoView.swift | 0 examples/{xcode => xcodeproj}/project.yml | 0 10 files changed, 18 insertions(+), 9 deletions(-) rename examples/{xcode => xcodeproj}/.gitignore (100%) rename examples/{xcode => xcodeproj}/Mintfile (100%) rename examples/{xcode => xcodeproj}/README.md (88%) rename examples/{xcode => xcodeproj}/Sources/ToDo/Item.swift (100%) rename examples/{xcode => xcodeproj}/Sources/ToDo/ToDoView.swift (100%) rename examples/{xcode => xcodeproj}/project.yml (100%) diff --git a/.claude/skills/bootstrap/SKILL.md b/.claude/skills/bootstrap/SKILL.md index 969b266..0302069 100644 --- a/.claude/skills/bootstrap/SKILL.md +++ b/.claude/skills/bootstrap/SKILL.md @@ -16,7 +16,7 @@ Bootstrap the PreviewsMCP development environment. 4. **Bazel example (optional).** If the user is working on the Bazel example, also set up bazelisk: run `cd examples/bazel && mise install` (requires mise) or `brew install bazelisk`. Verify with `cd examples/bazel && bazel build //Sources/ToDo`. -5. **Xcode example (optional).** If the user is working on the Xcode example, install Mint and XcodeGen: run `brew install mint && cd examples/xcode && mint bootstrap`. Generate the project with `mint run xcodegen generate`. Verify with `xcodebuild build -project ToDo.xcodeproj -scheme ToDo -destination 'platform=macOS'`. +5. **Xcode example (optional).** If the user is working on the Xcode example, install Mint and XcodeGen: run `brew install mint && cd examples/xcodeproj && mint bootstrap`. Generate the project with `mint run xcodegen generate`. Verify with `xcodebuild build -project ToDo.xcodeproj -scheme ToDo -destination 'platform=macOS'`. 6. **Verify setup.** Run `swift-format --version` and `swift --version` to confirm tool availability. Report the installed versions. diff --git a/.claude/skills/integration-test/SKILL.md b/.claude/skills/integration-test/SKILL.md index 5779e0d..9db5bf2 100644 --- a/.claude/skills/integration-test/SKILL.md +++ b/.claude/skills/integration-test/SKILL.md @@ -23,6 +23,14 @@ Run integration tests for PreviewsMCP example projects. 5. **Report results.** For each example, report pass/fail per test step. Summarize at the end. +## Project path guidance + +The example projects are nested inside the PreviewsMCP repo, which has its own `Package.swift`. Without an explicit `projectPath`, auto-detection walks up from the source file, finds the repo root SPM package first, and fails. **Always pass `projectPath` pointing to the example directory** (e.g., `examples/bazel/` or `examples/xcodeproj/`) when calling `preview_start` for Bazel or Xcode examples. The SPM example does not need this because its `Package.swift` is the closest one found. + +## Trust mise configs (worktrees) + +If running in a git worktree, the Bazel example's `.mise.toml` has a different absolute path than in the main repo, so mise will refuse to load it. Run `mise trust examples/bazel/.mise.toml` before testing the Bazel example to ensure the `bazel` shim works. + ## Touch interaction guidance When using `preview_touch` based on `preview_elements` frames: diff --git a/examples/README.md b/examples/README.md index db5e1e8..865d4a8 100644 --- a/examples/README.md +++ b/examples/README.md @@ -5,7 +5,7 @@ Each directory contains a minimal project for a specific build system, used for | Directory | Build System | Status | |-----------|-------------|--------| | `spm/` | Swift Package Manager | Implemented | -| `xcode/` | Xcode (.xcodeproj via XcodeGen) | Example project | +| `xcodeproj/` | Xcode (.xcodeproj via XcodeGen) | Example project | | `bazel/` | Bazel | Example project | See each project's `README.md` for integration test instructions. diff --git a/examples/bazel/README.md b/examples/bazel/README.md index 407ebe3..f951898 100644 --- a/examples/bazel/README.md +++ b/examples/bazel/README.md @@ -46,15 +46,16 @@ The example project is at examples/bazel/ relative to the PreviewsMCP repo root. ### 1. Setup - Build previewsmcp: `swift build` from the PreviewsMCP root +- Trust mise configs (worktrees): If running in a git worktree, the Bazel example's `.mise.toml` has a different absolute path than in the main repo, so mise will refuse to load it. Run `mise trust examples/bazel/.mise.toml` before testing the Bazel example to ensure the `bazel` shim works. ### 2. Basic rendering (macOS) -- Use preview_start on examples/bazel/Sources/ToDo/ToDoView.swift +- Use preview_start on examples/bazel/Sources/ToDo/ToDoView.swift with projectPath set to examples/bazel/ (required — without it, auto-detection finds the repo root Package.swift and fails) - Take a snapshot — verify it shows "My Items" nav title with a summary card section and 8 item rows - The first item ("Design UI") should have a filled checkmark; others should have empty circles - The first summary card (blue "Progress") should show "1/8" with "7 remaining" ### 3. Interaction (iOS simulator) -- Use preview_start with platform "ios-simulator" on the same file +- Use preview_start with platform "ios-simulator" on the same file (keep projectPath set to examples/bazel/) - Use preview_elements to get element frames for accurate tap coordinates - Tap an uncompleted item (e.g. "Write code") — verify its checkmark changes to filled - Tap the "Show Completed" toggle — verify completed items are hidden diff --git a/examples/xcode/.gitignore b/examples/xcodeproj/.gitignore similarity index 100% rename from examples/xcode/.gitignore rename to examples/xcodeproj/.gitignore diff --git a/examples/xcode/Mintfile b/examples/xcodeproj/Mintfile similarity index 100% rename from examples/xcode/Mintfile rename to examples/xcodeproj/Mintfile diff --git a/examples/xcode/README.md b/examples/xcodeproj/README.md similarity index 88% rename from examples/xcode/README.md rename to examples/xcodeproj/README.md index c319ae2..67e9abd 100644 --- a/examples/xcode/README.md +++ b/examples/xcodeproj/README.md @@ -27,7 +27,7 @@ mint bootstrap # installs XcodeGen from Mintfile ## Setup ```bash -cd examples/xcode +cd examples/xcodeproj mint run xcodegen generate ``` @@ -51,21 +51,21 @@ Use this prompt to test PreviewsMCP's Xcode integration end-to-end: ``` Run the following integration test for PreviewsMCP's Xcode build system support. -The example project is at examples/xcode/ relative to the PreviewsMCP repo root. +The example project is at examples/xcodeproj/ relative to the PreviewsMCP repo root. ### 1. Setup - Build previewsmcp: `swift build` from the PreviewsMCP root -- Generate the Xcode project: `cd examples/xcode && mint run xcodegen generate` +- Generate the Xcode project: `cd examples/xcodeproj && mint run xcodegen generate` - Build the Xcode project: `xcodebuild build -project ToDo.xcodeproj -scheme ToDo -destination 'platform=macOS'` ### 2. Basic rendering (macOS) -- Use preview_start on examples/xcode/Sources/ToDo/ToDoView.swift +- Use preview_start on examples/xcodeproj/Sources/ToDo/ToDoView.swift with projectPath set to examples/xcodeproj/ (required — without it, auto-detection finds the repo root Package.swift and fails) - Take a snapshot — verify it shows "My Items" nav title with a summary card section and 8 item rows - The first item ("Design UI") should have a filled checkmark; others should have empty circles - The first summary card (blue "Progress") should show "1/8" with "7 remaining" ### 3. Interaction (iOS simulator) -- Use preview_start with platform "ios-simulator" on the same file +- Use preview_start with platform "ios-simulator" on the same file (keep projectPath set to examples/xcodeproj/) - Use preview_elements to get element frames for accurate tap coordinates - Tap an uncompleted item (e.g. "Write code") — verify its checkmark changes to filled - Tap the "Show Completed" toggle — verify completed items are hidden diff --git a/examples/xcode/Sources/ToDo/Item.swift b/examples/xcodeproj/Sources/ToDo/Item.swift similarity index 100% rename from examples/xcode/Sources/ToDo/Item.swift rename to examples/xcodeproj/Sources/ToDo/Item.swift diff --git a/examples/xcode/Sources/ToDo/ToDoView.swift b/examples/xcodeproj/Sources/ToDo/ToDoView.swift similarity index 100% rename from examples/xcode/Sources/ToDo/ToDoView.swift rename to examples/xcodeproj/Sources/ToDo/ToDoView.swift diff --git a/examples/xcode/project.yml b/examples/xcodeproj/project.yml similarity index 100% rename from examples/xcode/project.yml rename to examples/xcodeproj/project.yml From 4d540b10b310e28151a0f50ffbe985ad6df76b24 Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Fri, 20 Mar 2026 23:42:35 -0400 Subject: [PATCH 4/4] Update integration test skill --- .claude/skills/integration-test/SKILL.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.claude/skills/integration-test/SKILL.md b/.claude/skills/integration-test/SKILL.md index 9db5bf2..62b1234 100644 --- a/.claude/skills/integration-test/SKILL.md +++ b/.claude/skills/integration-test/SKILL.md @@ -17,11 +17,9 @@ Run integration tests for PreviewsMCP example projects. 2. **Build PreviewsMCP.** Run `swift build` from the repo root. If the build fails, stop and report the error. -3. **Trust mise configs (worktrees).** If running in a git worktree, the Bazel example's `.mise.toml` has a different absolute path than in the main repo, so mise will refuse to load it. Run `mise trust examples/bazel/.mise.toml` before testing the Bazel example to ensure the `bazel` shim works. +3. **For each example**, read its `README.md` and follow the "Integration Test Prompt" section. The README contains the exact steps to execute, including which MCP tools to call and what to verify. -4. **For each example**, read its `README.md` and follow the "Integration Test Prompt" section. The README contains the exact steps to execute, including which MCP tools to call and what to verify. - -5. **Report results.** For each example, report pass/fail per test step. Summarize at the end. +4. **Report results.** For each example, report pass/fail per test step. Summarize at the end. ## Project path guidance