Skip to content

Commit b774fdd

Browse files
obj-pclaude
andauthored
Fix SPM dependency linking and Xcode multi-scheme selection (#69, #70)
SPM: After swift build, archive each dependency target's .o files into lib<Dep>.a and pass -L <binPath> -l<Dep> so the bridge dylib can link against sibling targets and cross-package dependencies. SPM doesn't create static archives or emit autolink hints for library targets, so the build system has to stage them explicitly. Xcode: Add optional `scheme` parameter threaded from MCP tool schemas and CLI options through BuildSystemDetector into XcodeBuildSystem. When set, pickScheme() validates it against the project's scheme list. When unset, existing heuristics apply (single scheme auto-pick, then path-component matching). Ambiguous-target error now names the `scheme` parameter instead of suggesting `projectRoot`. Adds ToDoExtras (sibling target) and LocalDep (cross-package path dep) to the SPM example. The existing Tier 2 test now exercises dependency linking as a regression guard. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 26b2399 commit b774fdd

15 files changed

Lines changed: 442 additions & 17 deletions

File tree

Sources/PreviewsCLI/BuildHelpers.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,12 @@ func detectAndBuild(
1010
for fileURL: URL,
1111
projectRoot projectRootURL: URL?,
1212
platform: PreviewPlatform,
13+
scheme: String? = nil,
1314
logPrefix: String = ""
1415
) async throws -> BuildContext? {
1516
guard
1617
let buildSystem = try await BuildSystemDetector.detect(
17-
for: fileURL, projectRoot: projectRootURL
18+
for: fileURL, projectRoot: projectRootURL, scheme: scheme
1819
)
1920
else {
2021
return nil

Sources/PreviewsCLI/MCPServer.swift

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,12 @@ func configureMCPServer() async throws -> (Server, Compiler) {
136136
"Project root path (auto-detected if omitted). Enables importing project types from SPM packages, Bazel swift_library targets, or Xcode projects (.xcodeproj / .xcworkspace)."
137137
),
138138
]),
139+
"scheme": .object([
140+
"type": .string("string"),
141+
"description": .string(
142+
"Xcode scheme name (only used for .xcodeproj / .xcworkspace projects). Required when the project contains more than one scheme and none of them match the source file's directory."
143+
),
144+
]),
139145
"colorScheme": .object([
140146
"type": .string("string"),
141147
"enum": .array([.string("light"), .string("dark")]),
@@ -723,7 +729,14 @@ private func detectBuildContext(
723729
platform: PreviewPlatform
724730
) async throws -> BuildContext? {
725731
let projectRootURL = extractOptionalString("projectPath", from: params).map { URL(fileURLWithPath: $0) }
726-
return try await detectAndBuild(for: fileURL, projectRoot: projectRootURL, platform: platform, logPrefix: "MCP:")
732+
let scheme = extractOptionalString("scheme", from: params)
733+
return try await detectAndBuild(
734+
for: fileURL,
735+
projectRoot: projectRootURL,
736+
platform: platform,
737+
scheme: scheme,
738+
logPrefix: "MCP:"
739+
)
727740
}
728741

729742
private func handleSimulatorList() async throws -> CallTool.Result {

Sources/PreviewsCLI/RunCommand.swift

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ struct RunCommand: ParsableCommand {
2727
@Option(name: .long, help: "Project root path (auto-detected if omitted)")
2828
var project: String?
2929

30+
@Option(
31+
name: .long,
32+
help: "Xcode scheme name (only for .xcodeproj / .xcworkspace projects with multiple schemes)"
33+
)
34+
var scheme: String?
35+
3036
@Option(name: .long, help: "Simulator device UDID (for ios; auto-selects if omitted)")
3137
var device: String?
3238

@@ -64,13 +70,17 @@ struct RunCommand: ParsableCommand {
6470
let windowWidth = width
6571
let windowHeight = height
6672
let projectPath = project
73+
let schemeName = scheme
6774
let traits = PreviewTraits(colorScheme: colorScheme, dynamicTypeSize: dynamicTypeSize)
6875

6976
Task {
7077
do {
7178
let projectRootURL = projectPath.map { URL(fileURLWithPath: $0) }
7279
let buildContext = try await detectAndBuild(
73-
for: fileURL, projectRoot: projectRootURL, platform: .macOS)
80+
for: fileURL,
81+
projectRoot: projectRootURL,
82+
platform: .macOS,
83+
scheme: schemeName)
7484

7585
try await launchMacOSPreview(
7686
fileURL: fileURL,
@@ -92,14 +102,18 @@ struct RunCommand: ParsableCommand {
92102
let previewIndex = preview
93103
let deviceUDID = device
94104
let projectPath = project
105+
let schemeName = scheme
95106
let traits = PreviewTraits(colorScheme: colorScheme, dynamicTypeSize: dynamicTypeSize)
96107
let isHeadless = headless
97108

98109
Task {
99110
do {
100111
let projectRootURL = projectPath.map { URL(fileURLWithPath: $0) }
101112
let buildContext = try await detectAndBuild(
102-
for: fileURL, projectRoot: projectRootURL, platform: .iOS)
113+
for: fileURL,
114+
projectRoot: projectRootURL,
115+
platform: .iOS,
116+
scheme: schemeName)
103117

104118
try await launchIOSPreview(
105119
fileURL: fileURL,

Sources/PreviewsCLI/SnapshotCommand.swift

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ struct SnapshotCommand: ParsableCommand {
3232
@Option(name: .long, help: "Project root path (auto-detected if omitted)")
3333
var project: String?
3434

35+
@Option(
36+
name: .long,
37+
help: "Xcode scheme name (only for .xcodeproj / .xcworkspace projects with multiple schemes)"
38+
)
39+
var scheme: String?
40+
3541
@Option(name: .long, help: "Simulator device UDID (for ios; auto-selects if omitted)")
3642
var device: String?
3743

@@ -67,6 +73,7 @@ struct SnapshotCommand: ParsableCommand {
6773
let windowHeight = height
6874
let outputURL = URL(fileURLWithPath: output)
6975
let projectPath = project
76+
let schemeName = scheme
7077
let traits = PreviewTraits(colorScheme: colorScheme, dynamicTypeSize: dynamicTypeSize)
7178

7279
Task {
@@ -75,7 +82,11 @@ struct SnapshotCommand: ParsableCommand {
7582

7683
// Detect build system
7784
let projectRootURL = projectPath.map { URL(fileURLWithPath: $0) }
78-
let buildContext = try await detectAndBuild(for: fileURL, projectRoot: projectRootURL, platform: .macOS)
85+
let buildContext = try await detectAndBuild(
86+
for: fileURL,
87+
projectRoot: projectRootURL,
88+
platform: .macOS,
89+
scheme: schemeName)
7990

8091
let session = PreviewSession(
8192
sourceFile: fileURL,
@@ -134,6 +145,7 @@ struct SnapshotCommand: ParsableCommand {
134145
let outputURL = URL(fileURLWithPath: output)
135146
let deviceUDID = device
136147
let projectPath = project
148+
let schemeName = scheme
137149
let traits = PreviewTraits(colorScheme: colorScheme, dynamicTypeSize: dynamicTypeSize)
138150

139151
Task {
@@ -148,7 +160,10 @@ struct SnapshotCommand: ParsableCommand {
148160
// Detect build system
149161
let projectRootURL = projectPath.map { URL(fileURLWithPath: $0) }
150162
let buildContext = try await detectAndBuild(
151-
for: fileURL, projectRoot: projectRootURL, platform: .iOS)
163+
for: fileURL,
164+
projectRoot: projectRootURL,
165+
platform: .iOS,
166+
scheme: schemeName)
152167

153168
let session = IOSPreviewSession(
154169
sourceFile: fileURL,

Sources/PreviewsCore/BuildSystem.swift

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,13 @@ public enum BuildSystemDetector {
2020
/// - Parameters:
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.
23-
public static func detect(for sourceFile: URL, projectRoot: URL? = nil) async throws -> (any BuildSystem)? {
23+
/// - scheme: Optional Xcode scheme name. Only used when the detected build
24+
/// system is `XcodeBuildSystem`; ignored for SPM and Bazel.
25+
public static func detect(
26+
for sourceFile: URL,
27+
projectRoot: URL? = nil,
28+
scheme: String? = nil
29+
) async throws -> (any BuildSystem)? {
2430
// If an explicit project root is provided, detect which build system applies there
2531
if let projectRoot = projectRoot {
2632
let fm = FileManager.default
@@ -43,7 +49,10 @@ public enum BuildSystemDetector {
4349
// Xcode: enumerate directory for *.xcworkspace / *.xcodeproj (name varies)
4450
if let projectFile = XcodeBuildSystem.findXcodeProject(in: projectRoot) {
4551
return XcodeBuildSystem(
46-
projectRoot: projectRoot, sourceFile: sourceFile, projectFile: projectFile)
52+
projectRoot: projectRoot,
53+
sourceFile: sourceFile,
54+
projectFile: projectFile,
55+
requestedScheme: scheme)
4756
}
4857
return nil
4958
}
@@ -56,7 +65,7 @@ public enum BuildSystemDetector {
5665
return bazel
5766
}
5867
// Xcode (.xcworkspace / .xcodeproj)
59-
if let xcode = try await XcodeBuildSystem.detect(for: sourceFile) {
68+
if let xcode = try await XcodeBuildSystem.detect(for: sourceFile, scheme: scheme) {
6069
return xcode
6170
}
6271
return nil
@@ -69,6 +78,7 @@ public enum BuildSystemError: Error, LocalizedError {
6978
case targetNotFound(sourceFile: String, project: String)
7079
case missingArtifacts(String)
7180
case ambiguousTarget(sourceFile: String, candidates: [String])
81+
case unknownScheme(requested: String, candidates: [String])
7282

7383
public var errorDescription: String? {
7484
switch self {
@@ -80,7 +90,10 @@ public enum BuildSystemError: Error, LocalizedError {
8090
return "Build artifacts not found: \(msg)"
8191
case .ambiguousTarget(let file, let candidates):
8292
return
83-
"Multiple schemes found for \(file). Use projectRoot to disambiguate. Available schemes: \(candidates.joined(separator: ", "))"
93+
"Multiple schemes found for \(file) and none matched the source file's directory. Pass the `scheme` parameter to pick one. Available schemes: \(candidates.joined(separator: ", "))"
94+
case .unknownScheme(let requested, let candidates):
95+
return
96+
"Scheme '\(requested)' not found in project. Available schemes: \(candidates.joined(separator: ", "))"
8497
}
8598
}
8699
}

Sources/PreviewsCore/SPMBuildSystem.swift

Lines changed: 120 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,30 @@ public actor SPMBuildSystem: BuildSystem {
6161
)
6262
}
6363

64-
// 6. Build compiler flags
64+
// 6. Archive dependency targets into libDep.a files.
65+
// SPM leaves library targets as loose .o files under <Dep>.build/ instead
66+
// of creating .a archives, and .swiftmodule files don't carry autolink
67+
// hints (no -module-link-name), so we have to make the archives ourselves
68+
// and pass -l<Dep> explicitly below.
69+
let dependencyLibs = try await archiveDependencyTargets(
70+
binPath: binPath,
71+
consumerTargetName: targetName
72+
)
73+
74+
// 7. Build compiler flags
75+
// -I <Modules> resolves dependency .swiftmodule files at compile time
76+
// -L <binPath> library search path for the archives created above
77+
// -l<Dep> per-dependency archive (lazy archive linking means only
78+
// object files actually referenced get pulled in)
6579
var flags: [String] = [
6680
"-I", modulesDir.path,
6781
]
82+
if !dependencyLibs.isEmpty {
83+
flags += ["-L", binPath.path]
84+
for dep in dependencyLibs {
85+
flags += ["-l\(dep)"]
86+
}
87+
}
6888

6989
// Add C module include paths for targets with C shims
7090
let targetBuildDir = binPath.appendingPathComponent("\(targetName).build")
@@ -73,7 +93,7 @@ public actor SPMBuildSystem: BuildSystem {
7393
flags += ["-I", includeDir.path]
7494
}
7595

76-
// 7. Collect Tier 2 data: other source files in the target
96+
// 8. Collect Tier 2 data: other source files in the target
7797
let otherSourceFiles = try collectSourceFiles(
7898
targetName: targetName,
7999
in: description
@@ -157,6 +177,104 @@ public actor SPMBuildSystem: BuildSystem {
157177
return URL(fileURLWithPath: output)
158178
}
159179

180+
// MARK: - Private: Dependency Archives
181+
182+
/// Archive every non-consumer target's `.o` files into `<binPath>/lib<Target>.a`
183+
/// and return the list of target names (for use with `-l<Target>`).
184+
///
185+
/// SPM's `swift build` produces loose object files under `<binPath>/<Target>.build/`
186+
/// for each library target without creating a static archive, and doesn't emit
187+
/// autolink hints for them either. So the bridge compile can't discover or link
188+
/// dependency symbols on its own — we have to stage the archives ourselves.
189+
///
190+
/// Consumer target `.build/` is skipped because Tier 2 already recompiles its
191+
/// sources directly. All other sibling targets (and transitively-built external
192+
/// packages, which land in the same bin path) are archived.
193+
private func archiveDependencyTargets(
194+
binPath: URL,
195+
consumerTargetName: String
196+
) async throws -> [String] {
197+
let fm = FileManager.default
198+
guard
199+
let entries = try? fm.contentsOfDirectory(
200+
at: binPath,
201+
includingPropertiesForKeys: [.isDirectoryKey],
202+
options: [.skipsHiddenFiles]
203+
)
204+
else {
205+
return []
206+
}
207+
208+
let arPath = try await Self.resolvedArPath()
209+
var libs: [String] = []
210+
211+
for entry in entries {
212+
// We want `<binPath>/<Target>.build/` directories.
213+
let name = entry.lastPathComponent
214+
guard name.hasSuffix(".build") else { continue }
215+
var isDir: ObjCBool = false
216+
guard fm.fileExists(atPath: entry.path, isDirectory: &isDir), isDir.boolValue else {
217+
continue
218+
}
219+
220+
let targetName = String(name.dropLast(".build".count))
221+
// Skip the consumer target — Tier 2 compiles its sources directly, and
222+
// archiving them here would cause duplicate-symbol errors at link time.
223+
if targetName == consumerTargetName { continue }
224+
// Skip SPM's own plugin/support bundles if any.
225+
if targetName.hasPrefix("_") { continue }
226+
227+
// Collect .o files produced for this target.
228+
let objectFiles = collectObjectFiles(in: entry)
229+
guard !objectFiles.isEmpty else { continue }
230+
231+
let archivePath = binPath.appendingPathComponent("lib\(targetName).a")
232+
// `ar rcs` replaces any existing archive, so rebuilds stay consistent.
233+
try? fm.removeItem(at: archivePath)
234+
235+
var arArgs = ["rcs", archivePath.path]
236+
arArgs.append(contentsOf: objectFiles.map(\.path))
237+
let result = try await runAsync(arPath, arguments: arArgs)
238+
guard result.exitCode == 0 else {
239+
throw BuildSystemError.buildFailed(
240+
stderr: "ar failed for \(targetName): \(result.stderr)",
241+
exitCode: result.exitCode
242+
)
243+
}
244+
libs.append(targetName)
245+
}
246+
247+
return libs
248+
}
249+
250+
/// Recursively collect `.o` files under a target's build directory, including
251+
/// files named `Foo.swift.o` that swift build emits for Swift sources.
252+
private func collectObjectFiles(in directory: URL) -> [URL] {
253+
guard
254+
let enumerator = FileManager.default.enumerator(
255+
at: directory,
256+
includingPropertiesForKeys: nil,
257+
options: [.skipsHiddenFiles]
258+
)
259+
else {
260+
return []
261+
}
262+
var files: [URL] = []
263+
for case let url as URL in enumerator where url.pathExtension == "o" {
264+
files.append(url)
265+
}
266+
return files
267+
}
268+
269+
private static func resolvedArPath() async throws -> String {
270+
let output = try await runAsync(
271+
"/usr/bin/xcrun", arguments: ["--find", "ar"], discardStderr: true)
272+
guard output.exitCode == 0 else {
273+
throw BuildSystemError.missingArtifacts("Could not locate `ar` via xcrun")
274+
}
275+
return output.stdout
276+
}
277+
160278
// MARK: - Private: Source Files (Tier 2)
161279

162280
/// Collect all .swift source files in the target EXCEPT the preview file.

0 commit comments

Comments
 (0)