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
17 changes: 10 additions & 7 deletions previewsmcp/PreviewsCore/XcodeBuildSystem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -371,21 +371,24 @@ public actor XcodeBuildSystem: BuildSystem {

/// Xcode pre-processing for the shared normalizer: a scheme can build a
/// platform variant the preview environment cannot host (Mac Catalyst
/// under the macOS agent), and the captured `-target`/`-sdk` would
/// otherwise win over Compiler's injection. Keep the captured pair only
/// when the triple matches the preview platform family.
/// under the macOS agent, a foreign-arch slice of a fat build), and the
/// captured `-target`/`-sdk` would otherwise win over Compiler's
/// injection. Keep the captured pair only when the triple matches the
/// preview platform family on the arch the JIT agent runs as.
static func stripForeignTargetTriple(
_ args: [String], platform: PreviewPlatform
_ args: [String], platform: PreviewPlatform,
hostArch: String = XcodeBuildSystem.hostArch
) -> [String] {
guard
let index = args.firstIndex(of: "-target"), index + 1 < args.count
else { return args }
let triple = args[index + 1]
let compatible =
let family =
switch platform {
case .macOS: triple.contains("apple-macos")
case .iOS: triple.contains("simulator")
}
let compatible = family && triple.hasPrefix("\(hostArch)-")
guard !compatible else { return args }
var result = args
result.removeSubrange(index ... index + 1)
Expand Down Expand Up @@ -583,11 +586,11 @@ public actor XcodeBuildSystem: BuildSystem {
/// 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
// OutputFileMap lives at <OBJECT_FILE_DIR_normal>/<arch>/<Target>-OutputFileMap.json
guard let objectFileDir = settings["OBJECT_FILE_DIR_normal"] else { return nil }

let outputFileMapPath = URL(fileURLWithPath: objectFileDir)
.appendingPathComponent("arm64")
.appendingPathComponent(Self.hostArch)
.appendingPathComponent("\(targetName)-OutputFileMap.json")

guard let data = try? Data(contentsOf: outputFileMapPath),
Expand Down
48 changes: 33 additions & 15 deletions previewsmcp/PreviewsCore/XcodeCommandCapture.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,28 +23,48 @@ enum XcodeCommandCapture {

/// Parse a build log for the module's compile command. Nil when the log
/// has no matching SwiftDriver invocation (null build, or a build system
/// that does not log one). The target's C/ObjC objects are deliberately
/// that does not log one). A generic-destination build compiles every
/// ARCHS slice and logs one invocation per arch; only the host-arch
/// slice produces code the JIT agent can execute, so that invocation
/// wins over log order. The target's C/ObjC objects are deliberately
/// not read from the log — an incremental build only logs CompileC for
/// changed sources — they come from the objects directory on disk.
static func parse(log: String, moduleName: String) -> CapturedCommand? {
static func parse(
log: String, moduleName: String,
hostArch: String = XcodeBuildSystem.hostArch
) -> CapturedCommand? {
var fallback: [String]?
for rawLine in log.split(separator: "\n", omittingEmptySubsequences: true) {
let line = rawLine.trimmingCharacters(in: .whitespaces)
guard let marker = driverMarkers.first(where: line.contains) else { continue }
let argvText = String(line[line.range(of: marker)!.upperBound...])
let tokens = tokenizeShellEscaped(argvText)
guard tokens.count > 1, moduleTokenMatches(tokens, moduleName) else { continue }
var args: [String] = []
var swiftSources: [String] = []
for token in tokens.dropFirst() {
if token.hasPrefix("@"), token.hasSuffix(".SwiftFileList") {
swiftSources = responseFileLines(String(token.dropFirst()))
} else {
args.append(token)
}
if value(after: "-target", in: tokens)?.hasPrefix("\(hostArch)-") ?? true {
return capture(fromTokens: tokens)
}
return CapturedCommand(arguments: args, swiftSources: swiftSources)
if fallback == nil { fallback = tokens }
}
return nil
return fallback.map(capture(fromTokens:))
}

private static func capture(fromTokens tokens: [String]) -> CapturedCommand {
var args: [String] = []
var swiftSources: [String] = []
for token in tokens.dropFirst() {
if token.hasPrefix("@"), token.hasSuffix(".SwiftFileList") {
swiftSources = responseFileLines(String(token.dropFirst()))
} else {
args.append(token)
}
}
return CapturedCommand(arguments: args, swiftSources: swiftSources)
}

private static func value(after flag: String, in tokens: [String]) -> String? {
guard let index = tokens.firstIndex(of: flag), index + 1 < tokens.count
else { return nil }
return tokens[index + 1]
}

/// True when the log came from a build system that logs SwiftDriver
Expand Down Expand Up @@ -159,9 +179,7 @@ enum XcodeCommandCapture {
}

private static func moduleTokenMatches(_ tokens: [String], _ moduleName: String) -> Bool {
guard let index = tokens.firstIndex(of: "-module-name"), index + 1 < tokens.count
else { return false }
return tokens[index + 1] == moduleName
value(after: "-module-name", in: tokens) == moduleName
}

private static func responseFileLines(_ path: String) -> [String] {
Expand Down
119 changes: 119 additions & 0 deletions previewsmcp/Tests/PreviewsCoreTests/CompileCaptureTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,48 @@ struct XcodeCommandCaptureTests {
#expect(!XcodeCommandCapture.logsDriverInvocations("SwiftCompile bazel-out/x"))
}

@Test("a fat build's log yields the host-arch driver invocation, either order")
func fatBuildPrefersHostArch() throws {
let log = """
builtin-SwiftDriver -- /t/swiftc -module-name App -target x86_64-apple-ios26.3-simulator -DDEBUG
builtin-SwiftDriver -- /t/swiftc -module-name App -target arm64-apple-ios26.3-simulator -DDEBUG
"""
let captured = try #require(
XcodeCommandCapture.parse(log: log, moduleName: "App", hostArch: "arm64")
)
#expect(captured.arguments.contains("arm64-apple-ios26.3-simulator"))
#expect(!captured.arguments.contains("x86_64-apple-ios26.3-simulator"))
let intel = try #require(
XcodeCommandCapture.parse(log: log, moduleName: "App", hostArch: "x86_64")
)
#expect(intel.arguments.contains("x86_64-apple-ios26.3-simulator"))
}

@Test("a foreign-arch-only log still captures the first match")
func foreignArchOnlyFallsBack() throws {
let log = """
builtin-SwiftDriver -- /t/swiftc -module-name App -target x86_64-apple-ios26.3-simulator -DFIRST
builtin-SwiftDriver -- /t/swiftc -module-name App -target x86_64-apple-ios26.3-simulator -DSECOND
"""
let captured = try #require(
XcodeCommandCapture.parse(log: log, moduleName: "App", hostArch: "arm64")
)
#expect(captured.arguments.contains("-DFIRST"))
#expect(!captured.arguments.contains("-DSECOND"))
}

@Test("the default hostArch is the arch the agent runs as")
func defaultHostArchWiring() throws {
let host = XcodeBuildSystem.hostArch
let foreign = host == "arm64" ? "x86_64" : "arm64"
let log = """
builtin-SwiftDriver -- /t/swiftc -module-name App -target \(foreign)-apple-ios26.3-simulator
builtin-SwiftDriver -- /t/swiftc -module-name App -target \(host)-apple-ios26.3-simulator
"""
let captured = try #require(XcodeCommandCapture.parse(log: log, moduleName: "App"))
#expect(captured.arguments.contains("\(host)-apple-ios26.3-simulator"))
}

@Test("escaped spaces in paths survive tokenizing")
func escapedSpaces() {
let tokens = XcodeCommandCapture.tokenizeShellEscaped(
Expand Down Expand Up @@ -356,6 +398,83 @@ struct XcodeCommandCaptureTests {
}
}

@Suite("XcodeBuildSystem.stripForeignTargetTriple")
struct StripForeignTargetTripleTests {
private func args(triple: String) -> [String] {
["-module-name", "App", "-target", triple, "-sdk", "/sdk/iPhoneSimulator", "-DDEBUG"]
}

@Test("host-arch triples for the preview platform family are kept")
func hostTriplesKept() {
let simArgs = args(triple: "arm64-apple-ios26.3-simulator")
#expect(
XcodeBuildSystem.stripForeignTargetTriple(
simArgs, platform: .iOS, hostArch: "arm64"
) == simArgs
)
let macArgs = args(triple: "arm64-apple-macos26.0")
#expect(
XcodeBuildSystem.stripForeignTargetTriple(
macArgs, platform: .macOS, hostArch: "arm64"
) == macArgs
)
}

@Test("a foreign-arch simulator triple is stripped with its -sdk")
func foreignArchStripped() {
let stripped = XcodeBuildSystem.stripForeignTargetTriple(
args(triple: "x86_64-apple-ios26.3-simulator"), platform: .iOS, hostArch: "arm64"
)
#expect(stripped == ["-module-name", "App", "-DDEBUG"])
}

@Test("a foreign-arch macOS triple is stripped")
func foreignArchMacStripped() {
let stripped = XcodeBuildSystem.stripForeignTargetTriple(
args(triple: "x86_64-apple-macos26.0"), platform: .macOS, hostArch: "arm64"
)
#expect(stripped == ["-module-name", "App", "-DDEBUG"])
}

@Test("a foreign-family triple (Catalyst under iOS) is stripped")
func foreignFamilyStripped() {
let stripped = XcodeBuildSystem.stripForeignTargetTriple(
args(triple: "arm64-apple-ios26.3-macabi"), platform: .iOS, hostArch: "arm64"
)
#expect(stripped == ["-module-name", "App", "-DDEBUG"])
}

@Test("args without -target pass through unchanged, -sdk retained")
func noTargetPassthrough() {
let input = ["-module-name", "App", "-sdk", "/sdk/iPhoneSimulator", "-DDEBUG"]
#expect(
XcodeBuildSystem.stripForeignTargetTriple(
input, platform: .iOS, hostArch: "arm64"
) == input
)
}

@Test("a foreign triple with -sdk before -target strips both pairs")
func sdkBeforeTargetStripped() {
let stripped = XcodeBuildSystem.stripForeignTargetTriple(
[
"-module-name", "App", "-sdk", "/sdk/iPhoneSimulator",
"-target", "x86_64-apple-ios26.3-simulator", "-DDEBUG",
],
platform: .iOS, hostArch: "arm64"
)
#expect(stripped == ["-module-name", "App", "-DDEBUG"])
}

@Test("the default hostArch keeps the host triple for the platform family")
func defaultHostArchKept() {
let hostArgs = args(triple: "\(XcodeBuildSystem.hostArch)-apple-ios26.3-simulator")
#expect(
XcodeBuildSystem.stripForeignTargetTriple(hostArgs, platform: .iOS) == hostArgs
)
}
}

@Suite("BazelCommandCapture")
struct BazelCommandCaptureTests {
private let jsonProto = """
Expand Down