Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/skills/bootstrap/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
12 changes: 9 additions & 3 deletions .claude/skills/integration-test/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,17 @@ 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.
4. **Report results.** For each example, report pass/fail per test step. Summarize at the end.

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

Expand Down
14 changes: 13 additions & 1 deletion Sources/PreviewsCore/BuildSystem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
}
}
Expand All @@ -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 {
Expand All @@ -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: ", "))"
}
}
}
6 changes: 5 additions & 1 deletion Sources/PreviewsCore/PreviewSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,17 +76,21 @@ 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,
platform: platform
)
literals = []
additionalSourceFiles = []
moduleName = "PreviewBridge_\(ctx.moduleName)"
}
moduleName = ctx.moduleName
extraFlags = ctx.compilerFlags
} else {
// Standalone mode: existing behavior
Expand Down
274 changes: 274 additions & 0 deletions Sources/PreviewsCore/XcodeBuildSystem.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,274 @@
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 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)")
}

guard FileManager.default.fileExists(atPath: builtProductsDir) else {
throw BuildSystemError.missingArtifacts(
"Build products directory not found 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]
}
}

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)
}

/// 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..<equalsRange.lowerBound])
.trimmingCharacters(in: .whitespaces)
let value = String(trimmed[equalsRange.upperBound...])
.trimmingCharacters(in: .whitespaces)
settings[key] = value
}
return settings
}

// MARK: - Private: Build

private func runBuild(scheme: String, platform: PreviewPlatform) async throws {
let destination = destinationString(for: platform)
try await runXcodebuild(
"build",
"-project", xcodeproj.path,
"-scheme", scheme,
"-configuration", "Debug",
"-destination", destination,
"-quiet")
}

// MARK: - Private: Source Files (Tier 2)

/// Collect source files from the OutputFileMap.json produced by xcodebuild.
/// Returns nil if the file doesn't exist (falls back to Tier 1).
func collectSourceFiles(settings: [String: String], targetName: String) -> [URL]? {
// OutputFileMap lives at <OBJECT_FILE_DIR_normal>/arm64/<Target>-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] = []
var seenPaths: Set<String> = []

// 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 seenPaths.insert(path).inserted {
flags += ["-F", path]
}
}
}

return flags
}

/// Parse space-separated paths from xcodebuild, filtering out $(inherited) and quotes.
static func parseSearchPaths(_ value: String) -> [String] {
value.components(separatedBy: " ")
.map { $0.trimmingCharacters(in: CharacterSet(charactersIn: "\"")) }
.filter { !$0.isEmpty && $0 != "$(inherited)" }
}

// 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
}
}
Loading
Loading