Skip to content

Commit d9ea6c3

Browse files
EvanBaconclaude
andauthored
Add Swift Package helper methods and fix test runner issue (#45)
* Add Swift Package helper methods and fix test runner issue This PR addresses GitHub issue #31 by adding convenient helper methods for working with Swift packages, and fixes a pre-existing Bun test runner issue. ## New Helper Methods **PBXProject:** - `addPackageReference(ref)` - Add package reference to project - `getPackageReference(identifier)` - Find package by URL or path - `addRemoteSwiftPackage(opts)` - Create and add remote package - `addLocalSwiftPackage(opts)` - Create and add local package **PBXNativeTarget:** - `addSwiftPackageProduct(opts)` - Full wiring: creates product dep, adds to target, creates build file, adds to frameworks phase - `getSwiftPackageProductDependencies()` - Get all package deps - `removeSwiftPackageProduct(dep)` - Remove with cleanup **PBXBuildFile:** - `createFromProductRef(opts)` - Create build file for Swift packages ## Bug Fixes - Fixed `fileRef` to be optional in PBXBuildFile (required for SPM) - Fixed Bun test runner "export not found" error by using proper `export type` for AnyBuildPhase in index.ts - Fixed circular dependency in utils/paths.ts by importing directly from source files instead of index ## Tests Added comprehensive tests for all new functionality (50 new tests). Closes #31 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Add Swift Package Manager documentation to README Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Fix TypeScript errors in SwiftPackage tests Cast json.objects access to any for property access. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 86b6920 commit d9ea6c3

10 files changed

Lines changed: 745 additions & 20 deletions

File tree

README.md

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,100 @@ const outputPlist = settings.build(config);
484484
fs.writeFileSync("/path/to/WorkspaceSettings.xcsettings", outputPlist);
485485
```
486486

487+
## Swift Package Manager Support
488+
489+
Add Swift Package Manager dependencies to your Xcode projects with full wiring handled automatically.
490+
491+
### Adding Remote Packages
492+
493+
```ts
494+
import { XcodeProject } from "@bacons/xcode";
495+
496+
const project = XcodeProject.open("/path/to/project.pbxproj");
497+
const rootProject = project.rootObject;
498+
499+
// Add a remote Swift package to the project
500+
const packageRef = rootProject.addRemoteSwiftPackage({
501+
repositoryURL: "https://github.com/apple/swift-collections",
502+
requirement: {
503+
kind: "upToNextMajorVersion",
504+
minimumVersion: "1.0.0",
505+
},
506+
});
507+
508+
// Add the package product to a target
509+
const target = rootProject.getMainAppTarget("ios");
510+
const productDep = target.addSwiftPackageProduct({
511+
productName: "Collections",
512+
package: packageRef,
513+
});
514+
515+
// Save the project
516+
fs.writeFileSync("/path/to/project.pbxproj", build(project.toJSON()));
517+
```
518+
519+
### Adding Local Packages
520+
521+
```ts
522+
// Add a local Swift package (e.g., from a monorepo)
523+
const localPackage = rootProject.addLocalSwiftPackage({
524+
relativePath: "../Packages/MyFeature",
525+
});
526+
527+
// Add the product to your target
528+
target.addSwiftPackageProduct({
529+
productName: "MyFeature",
530+
package: localPackage,
531+
});
532+
```
533+
534+
### What Gets Wired Up
535+
536+
When you call `target.addSwiftPackageProduct()`, the following is handled automatically:
537+
538+
1. Creates `XCSwiftPackageProductDependency` and adds it to target's `packageProductDependencies`
539+
2. Creates `PBXBuildFile` with `productRef` pointing to the dependency
540+
3. Adds the build file to the target's Frameworks build phase
541+
542+
### Managing Package Dependencies
543+
544+
```ts
545+
// Get all package product dependencies for a target
546+
const deps = target.getSwiftPackageProductDependencies();
547+
548+
// Find an existing package by URL or path
549+
const existing = rootProject.getPackageReference(
550+
"https://github.com/apple/swift-collections"
551+
);
552+
553+
// Remove a package product from a target (cleans up build file too)
554+
target.removeSwiftPackageProduct(productDep);
555+
```
556+
557+
### Version Requirements
558+
559+
Remote packages support various version requirement types:
560+
561+
```ts
562+
// Up to next major version (e.g., 1.0.0 to 2.0.0)
563+
{ kind: "upToNextMajorVersion", minimumVersion: "1.0.0" }
564+
565+
// Up to next minor version (e.g., 1.2.0 to 1.3.0)
566+
{ kind: "upToNextMinorVersion", minimumVersion: "1.2.0" }
567+
568+
// Exact version
569+
{ kind: "exactVersion", version: "1.2.3" }
570+
571+
// Version range
572+
{ kind: "versionRange", minimumVersion: "1.0.0", maximumVersion: "2.0.0" }
573+
574+
// Branch
575+
{ kind: "branch", branch: "main" }
576+
577+
// Revision (commit hash)
578+
{ kind: "revision", revision: "abc123def456" }
579+
```
580+
487581
## Solution
488582

489583
- Uses a hand-optimized single-pass parser that is 11x faster than the legacy `xcode` package (which uses PEG.js).
@@ -521,6 +615,7 @@ We support the following types: `Object`, `Array`, `Data`, `String`. Notably, we
521615
- [ ] Import from other tools.
522616
- [ ] **XCUserData**: (`xcuserdata/<user>.xcuserdatad/`) Per-user schemes, breakpoints, UI state.
523617
- [x] **IDEWorkspaceChecks**: (`xcshareddata/IDEWorkspaceChecks.plist`) Workspace check state storage (e.g., 32-bit deprecation warning).
618+
- [x] **Swift Package Manager**: Add remote and local SPM dependencies with automatic wiring.
524619

525620
# Docs
526621

src/api/PBXBuildFile.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ export class PBXBuildFile extends AbstractObject<PBXBuildFileModel> {
2626
return object.isa === PBXBuildFile.isa;
2727
}
2828

29+
/**
30+
* Creates a PBXBuildFile with a fileRef for source files, resources, etc.
31+
*/
2932
static create(
3033
project: XcodeProject,
3134
opts: PickRequired<SansIsa<PBXBuildFileModel>, "fileRef">
@@ -36,6 +39,20 @@ export class PBXBuildFile extends AbstractObject<PBXBuildFileModel> {
3639
}) as PBXBuildFile;
3740
}
3841

42+
/**
43+
* Creates a PBXBuildFile with a productRef for Swift Package dependencies.
44+
* Use this instead of `create()` when linking a Swift Package product.
45+
*/
46+
static createFromProductRef(
47+
project: XcodeProject,
48+
opts: PickRequired<SansIsa<PBXBuildFileModel>, "productRef">
49+
) {
50+
return project.createModel<PBXBuildFileModel>({
51+
isa: PBXBuildFile.isa,
52+
...opts,
53+
} as PBXBuildFileModel) as PBXBuildFile;
54+
}
55+
3956
protected getObjectProps() {
4057
return {
4158
fileRef: String,

src/api/PBXNativeTarget.ts

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,17 @@ import {
1010
type AnyBuildPhase,
1111
} from "./PBXSourcesBuildPhase";
1212
import { PBXFileReference } from "./PBXFileReference";
13+
import { PBXBuildFile } from "./PBXBuildFile";
14+
import { XCSwiftPackageProductDependency } from "./XCSwiftPackageProductDependency";
1315
import type { PickRequired, SansIsa } from "./utils/util.types";
1416
import type { XcodeProject } from "./XcodeProject";
1517
import type { PBXBuildRule } from "./PBXBuildRule";
1618
import { PBXTargetDependency } from "./PBXTargetDependency";
1719
import type { XCConfigurationList } from "./XCConfigurationList";
18-
import type { XCSwiftPackageProductDependency } from "./XCSwiftPackageProductDependency";
1920
import type { PBXFileSystemSynchronizedRootGroup } from "./PBXFileSystemSynchronizedRootGroup";
2021
import { PBXContainerItemProxy } from "./PBXContainerItemProxy";
22+
import type { XCRemoteSwiftPackageReference } from "./XCRemoteSwiftPackageReference";
23+
import type { XCLocalSwiftPackageReference } from "./XCLocalSwiftPackageReference";
2124

2225
export type PBXNativeTargetModel = json.PBXNativeTarget<
2326
XCConfigurationList,
@@ -380,4 +383,105 @@ export class PBXNativeTarget extends AbstractTarget<PBXNativeTargetModel> {
380383
// Call parent which handles removing from PBXProject.targets array
381384
return super.removeFromProject();
382385
}
386+
387+
/**
388+
* Adds a Swift package product dependency to this target.
389+
* This handles the full wiring:
390+
* 1. Creates the XCSwiftPackageProductDependency
391+
* 2. Adds it to target's packageProductDependencies
392+
* 3. Creates a PBXBuildFile with productRef
393+
* 4. Adds the build file to the frameworks build phase
394+
*
395+
* Note: The package reference must already be added to the project via
396+
* `project.addPackageReference()`, `project.addRemoteSwiftPackage()`, or
397+
* `project.addLocalSwiftPackage()`.
398+
*
399+
* @param opts.productName Name of the product from the Swift package
400+
* @param opts.package The package reference (XCRemoteSwiftPackageReference or XCLocalSwiftPackageReference)
401+
* @returns The created XCSwiftPackageProductDependency
402+
*/
403+
addSwiftPackageProduct(opts: {
404+
productName: string;
405+
package?: XCRemoteSwiftPackageReference | XCLocalSwiftPackageReference;
406+
}): XCSwiftPackageProductDependency {
407+
const xcproj = this.getXcodeProject();
408+
409+
// Initialize packageProductDependencies if needed
410+
if (!this.props.packageProductDependencies) {
411+
this.props.packageProductDependencies = [];
412+
}
413+
414+
// Check if this product dependency already exists for this target
415+
const existing = this.props.packageProductDependencies.find(
416+
(dep) =>
417+
dep.props.productName === opts.productName &&
418+
dep.props.package?.uuid === opts.package?.uuid
419+
);
420+
if (existing) {
421+
return existing;
422+
}
423+
424+
// Create the product dependency
425+
const productDep = XCSwiftPackageProductDependency.create(xcproj, {
426+
productName: opts.productName,
427+
package: opts.package,
428+
});
429+
430+
// Add to target's packageProductDependencies
431+
this.props.packageProductDependencies.push(productDep);
432+
433+
// Create a build file with productRef pointing to the dependency
434+
const buildFile = PBXBuildFile.createFromProductRef(xcproj, {
435+
productRef: productDep,
436+
});
437+
438+
// Add the build file to the frameworks build phase
439+
this.getFrameworksBuildPhase().props.files.push(buildFile);
440+
441+
return productDep;
442+
}
443+
444+
/**
445+
* Gets all Swift package product dependencies for this target.
446+
*
447+
* @returns Array of XCSwiftPackageProductDependency objects
448+
*/
449+
getSwiftPackageProductDependencies(): XCSwiftPackageProductDependency[] {
450+
return this.props.packageProductDependencies ?? [];
451+
}
452+
453+
/**
454+
* Removes a Swift package product dependency from this target.
455+
* This handles removing from packageProductDependencies and the build file from the frameworks phase.
456+
*
457+
* @param productDep The product dependency to remove
458+
*/
459+
removeSwiftPackageProduct(productDep: XCSwiftPackageProductDependency): void {
460+
// Remove from packageProductDependencies
461+
if (this.props.packageProductDependencies) {
462+
const index = this.props.packageProductDependencies.findIndex(
463+
(dep) => dep.uuid === productDep.uuid
464+
);
465+
if (index !== -1) {
466+
this.props.packageProductDependencies.splice(index, 1);
467+
}
468+
}
469+
470+
// Find and remove the build file that references this product dependency
471+
const frameworksPhase = this.getBuildPhase(PBXFrameworksBuildPhase);
472+
if (frameworksPhase) {
473+
const buildFileIndex = frameworksPhase.props.files.findIndex(
474+
(file) => file.props.productRef?.uuid === productDep.uuid
475+
);
476+
if (buildFileIndex !== -1) {
477+
const buildFile = frameworksPhase.props.files[buildFileIndex];
478+
frameworksPhase.props.files.splice(buildFileIndex, 1);
479+
// Remove the build file from the project
480+
buildFile.removeFromProject();
481+
}
482+
}
483+
484+
// Remove the product dependency from the project
485+
productDep.removeFromProject();
486+
}
383487
}

src/api/PBXProject.ts

Lines changed: 106 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,15 @@ import * as json from "../json/types";
99
import { AbstractObject } from "./AbstractObject";
1010
import { PBXNativeTarget, PBXNativeTargetModel } from "./PBXNativeTarget";
1111
import { XCBuildConfiguration } from "./XCBuildConfiguration";
12+
import { XCRemoteSwiftPackageReference } from "./XCRemoteSwiftPackageReference";
13+
import { XCLocalSwiftPackageReference } from "./XCLocalSwiftPackageReference";
1214

1315
import type { PickRequired, SansIsa } from "./utils/util.types";
1416
import type { PBXGroup } from "./AbstractGroup";
1517
import type { XcodeProject } from "./XcodeProject";
1618
import type { PBXAggregateTarget } from "./PBXAggregateTarget";
1719
import type { PBXLegacyTarget } from "./PBXLegacyTarget";
1820
import type { XCConfigurationList } from "./XCConfigurationList";
19-
import type { XCRemoteSwiftPackageReference } from "./XCRemoteSwiftPackageReference";
20-
import type { XCLocalSwiftPackageReference } from "./XCLocalSwiftPackageReference";
2121

2222
export type PBXProjectModel = json.PBXProject<
2323
XCConfigurationList,
@@ -234,4 +234,108 @@ export class PBXProject extends AbstractObject<PBXProjectModel> {
234234
delete this.props.attributes.TargetAttributes[uuid];
235235
}
236236
}
237+
238+
/**
239+
* Adds a Swift package reference to the project if not already present.
240+
*
241+
* @param packageRef The package reference to add (XCRemoteSwiftPackageReference or XCLocalSwiftPackageReference)
242+
* @returns The package reference
243+
*/
244+
addPackageReference(
245+
packageRef: XCRemoteSwiftPackageReference | XCLocalSwiftPackageReference
246+
): XCRemoteSwiftPackageReference | XCLocalSwiftPackageReference {
247+
if (!this.props.packageReferences) {
248+
this.props.packageReferences = [];
249+
}
250+
251+
// Check if already added
252+
const existing = this.props.packageReferences.find(
253+
(ref) => ref.uuid === packageRef.uuid
254+
);
255+
if (existing) {
256+
return existing;
257+
}
258+
259+
this.props.packageReferences.push(packageRef);
260+
return packageRef;
261+
}
262+
263+
/**
264+
* Gets an existing package reference by repository URL (for remote) or relative path (for local).
265+
*
266+
* @param identifier The repository URL or relative path to search for
267+
* @returns The package reference if found, null otherwise
268+
*/
269+
getPackageReference(
270+
identifier: string
271+
): XCRemoteSwiftPackageReference | XCLocalSwiftPackageReference | null {
272+
if (!this.props.packageReferences) {
273+
return null;
274+
}
275+
276+
for (const ref of this.props.packageReferences) {
277+
if (
278+
XCRemoteSwiftPackageReference.is(ref) &&
279+
ref.props.repositoryURL === identifier
280+
) {
281+
return ref;
282+
}
283+
if (
284+
XCLocalSwiftPackageReference.is(ref) &&
285+
ref.props.relativePath === identifier
286+
) {
287+
return ref;
288+
}
289+
}
290+
291+
return null;
292+
}
293+
294+
/**
295+
* Creates and adds a remote Swift package reference to the project.
296+
*
297+
* @param opts Options for creating the remote package reference
298+
* @returns The created or existing package reference
299+
*/
300+
addRemoteSwiftPackage(
301+
opts: SansIsa<json.XCRemoteSwiftPackageReference>
302+
): XCRemoteSwiftPackageReference {
303+
// Check if package already exists
304+
if (opts.repositoryURL) {
305+
const existing = this.getPackageReference(opts.repositoryURL);
306+
if (existing && XCRemoteSwiftPackageReference.is(existing)) {
307+
return existing;
308+
}
309+
}
310+
311+
const packageRef = XCRemoteSwiftPackageReference.create(
312+
this.getXcodeProject(),
313+
opts
314+
);
315+
this.addPackageReference(packageRef);
316+
return packageRef;
317+
}
318+
319+
/**
320+
* Creates and adds a local Swift package reference to the project.
321+
*
322+
* @param opts Options for creating the local package reference
323+
* @returns The created or existing package reference
324+
*/
325+
addLocalSwiftPackage(
326+
opts: SansIsa<json.XCLocalSwiftPackageReference>
327+
): XCLocalSwiftPackageReference {
328+
// Check if package already exists
329+
const existing = this.getPackageReference(opts.relativePath);
330+
if (existing && XCLocalSwiftPackageReference.is(existing)) {
331+
return existing;
332+
}
333+
334+
const packageRef = XCLocalSwiftPackageReference.create(
335+
this.getXcodeProject(),
336+
opts
337+
);
338+
this.addPackageReference(packageRef);
339+
return packageRef;
340+
}
237341
}

0 commit comments

Comments
 (0)