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
52 changes: 51 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,46 @@ The package offers two main APIs:
3. **Reference Resolution**: Objects maintain references to each other via UUIDs
4. **Type Safety**: Full TypeScript coverage with strict compiler options

### The `getObjectProps()` Pattern

Each object class defines `getObjectProps()` to declare which properties contain UUID references:

```typescript
protected getObjectProps() {
return {
buildConfigurationList: String, // Single object reference
dependencies: [String], // Array of object references
buildPhases: [String],
};
}
```

This powers several automatic behaviors in `AbstractObject`:
- **Inflation**: UUIDs are automatically resolved to object instances
- **Serialization**: Objects are deflated back to UUIDs in `toJSON()`
- **Reference tracking**: `isReferencing(uuid)` and `removeReference(uuid)` work automatically

When adding new object types or properties, define them in `getObjectProps()` to get this behavior for free. Only override `isReferencing`/`removeReference` if you need custom logic (e.g., `PBXProject` removes from `TargetAttributes`).

### Implementing `removeFromProject()`

When implementing cascade deletion (removing an object and its children):

1. **Check exclusive ownership**: Before removing a child, verify no other object uses it
2. **Beware of display groups**: Objects like `productReference` or `fileSystemSynchronizedGroups` may be in a PBXGroup for display but still belong to one target
3. **For target-owned objects**: Check if any OTHER target references the object, not just if it has any referrers

```typescript
// Good: Check if another target uses this product reference
const usedByOtherTarget = [...project.values()].some(
(obj) => PBXNativeTarget.is(obj) && obj.uuid !== this.uuid &&
obj.props.productReference?.uuid === this.props.productReference?.uuid
);
if (!usedByOtherTarget) {
this.props.productReference.removeFromProject();
}
```

### Entry Points

- `src/index.ts` - Exports the high-level API
Expand All @@ -55,4 +95,14 @@ Tests are located in `__tests__/` directories throughout the codebase:
- `src/json/__tests__/` - JSON parser tests with various pbxproj formats
- Fixtures in `src/json/__tests__/fixtures/` contain real-world project files

The test suite uses Jest with TypeScript support and includes extensive fixtures from various Xcode project types (React Native, Swift, CocoaPods, etc.).
The test suite uses Jest with TypeScript support and includes extensive fixtures from various Xcode project types (React Native, Swift, CocoaPods, etc.).

### Fixture Naming

New fixtures should follow the pattern `NNN-description.pbxproj` (e.g., `009-expo-app-clip.pbxproj`). Add new fixtures to the `fixtures` array in `src/json/__tests__/json.test.ts` for parsing validation against `plutil`.

### JSON Tests

In `json.test.ts`, fixtures are added to two arrays:
- `fixtures` - Tests parsing correctness by comparing against macOS `plutil` output
- `inOutFixtures` - Tests round-trip (parse → build → should equal original). Only add here if the fixture round-trips perfectly; some fixtures have known formatting differences.
11 changes: 0 additions & 11 deletions src/api/AbstractGroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,17 +148,6 @@ export class AbstractGroup<
return getParents(this);
}

isReferencing(uuid: string): boolean {
return !!this.props.children.find((child) => child.uuid === uuid);
}

removeReference(uuid: string) {
const index = this.props.children.findIndex((child) => child.uuid === uuid);
if (index !== -1) {
this.props.children.splice(index, 1);
}
}

getPath(): string {
throw new Error("TODO");
}
Expand Down
27 changes: 25 additions & 2 deletions src/api/AbstractObject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,14 @@ export abstract class AbstractObject<

/** @returns `true` if the provided UUID is used somewhere in the props. */
isReferencing(uuid: string): boolean {
for (const key of Object.keys(this.getObjectProps()) as (keyof TJSON)[]) {
const value = this.props[key];
if (Array.isArray(value)) {
if (value.some((item: any) => item?.uuid === uuid)) return true;
} else if (value && typeof value === "object" && "uuid" in value) {
if ((value as any).uuid === uuid) return true;
}
}
return false;
}

Expand Down Expand Up @@ -200,8 +208,23 @@ export abstract class AbstractObject<
return json as TJSON;
}

/** abstract method for removing a UUID from any props that might be referencing it. */
removeReference(uuid: string) {}
/** Removes a UUID from any props that reference it. */
removeReference(uuid: string) {
for (const key of Object.keys(this.getObjectProps()) as (keyof TJSON)[]) {
const value = this.props[key];
if (Array.isArray(value)) {
const index = value.findIndex((item: any) => item?.uuid === uuid);
if (index !== -1) {
value.splice(index, 1);
}
} else if (value && typeof value === "object" && "uuid" in value) {
if ((value as any).uuid === uuid) {
// @ts-expect-error
this.props[key] = undefined;
}
}
}
}

removeFromProject() {
this.getXcodeProject().delete(this.uuid);
Expand Down
9 changes: 0 additions & 9 deletions src/api/AbstractTarget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,15 +119,6 @@ export class AbstractTarget<
return v as InstanceType<TBuildPhase> | null;
}

isReferencing(uuid: string): boolean {
if (this.props.buildConfigurationList.uuid === uuid) return true;
if (this.props.dependencies.some((dep) => dep.uuid === uuid)) return true;
if (this.props.buildPhases.some((phase) => phase.uuid === uuid))
return true;

return false;
}

protected getObjectProps(): any {
return {
buildConfigurationList: String,
Expand Down
13 changes: 13 additions & 0 deletions src/api/PBXFileSystemSynchronizedRootGroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,17 @@ export class PBXFileSystemSynchronizedRootGroup extends AbstractObject<PBXFileSy
exceptions: [String],
};
}

removeFromProject() {
// Remove all exceptions that are only referenced by this group
if (this.props.exceptions) {
for (const exception of [...this.props.exceptions]) {
const referrers = exception.getReferrers();
if (referrers.length === 1 && referrers[0].uuid === this.uuid) {
exception.removeFromProject();
}
}
}
return super.removeFromProject();
}
}
111 changes: 95 additions & 16 deletions src/api/PBXNativeTarget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import type { XCConfigurationList } from "./XCConfigurationList";
import type { XCSwiftPackageProductDependency } from "./XCSwiftPackageProductDependency";
import type { PBXFileSystemSynchronizedRootGroup } from "./PBXFileSystemSynchronizedRootGroup";
import { PBXContainerItemProxy } from "./PBXContainerItemProxy";
import type { PBXFileSystemSynchronizedBuildFileExceptionSet } from "./PBXFileSystemSynchronizedBuildFileExceptionSet";

export type PBXNativeTargetModel = json.PBXNativeTarget<
XCConfigurationList,
Expand Down Expand Up @@ -53,21 +52,6 @@ export class PBXNativeTarget extends AbstractTarget<PBXNativeTargetModel> {
}) as PBXNativeTarget;
}

isReferencing(uuid: string): boolean {
if (this.props.buildRules.some((rule) => rule.uuid === uuid)) return true;
if (this.props.packageProductDependencies?.some((dep) => dep.uuid === uuid))
return true;
if (this.props.productReference?.uuid === uuid) return true;
if (
this.props.fileSystemSynchronizedGroups?.some(
(group) => group.uuid === uuid
)
)
return true;

return super.isReferencing(uuid);
}

/** @returns the `PBXFrameworksBuildPhase` or creates one if there is none. Only one can exist. */
getFrameworksBuildPhase() {
return (
Expand Down Expand Up @@ -267,4 +251,99 @@ export class PBXNativeTarget extends AbstractTarget<PBXNativeTargetModel> {
fileSystemSynchronizedGroups: [String],
};
}

/**
* Removes this target from the project along with all of its exclusively-owned children.
* Children that are shared with other targets (e.g., shared build phases) are preserved.
*/
removeFromProject() {
const project = this.getXcodeProject();

// Helper to check if an object is only referenced by this target
const isExclusivelyOwnedByThisTarget = (obj: { getReferrers(): { uuid: string }[] }) => {
const referrers = obj.getReferrers();
return referrers.length === 1 && referrers[0].uuid === this.uuid;
};

// Remove build phases that are only referenced by this target
for (const phase of [...this.props.buildPhases]) {
if (isExclusivelyOwnedByThisTarget(phase)) {
phase.removeFromProject();
}
}

// Remove build rules that are only referenced by this target
for (const rule of [...this.props.buildRules]) {
if (isExclusivelyOwnedByThisTarget(rule)) {
rule.removeFromProject();
}
}

// Remove the build configuration list (it will cascade to configurations)
if (isExclusivelyOwnedByThisTarget(this.props.buildConfigurationList)) {
this.props.buildConfigurationList.removeFromProject();
}

// Remove dependencies (PBXTargetDependency objects that this target depends on)
for (const dep of [...this.props.dependencies]) {
if (isExclusivelyOwnedByThisTarget(dep)) {
dep.removeFromProject();
}
}

// Remove file system synchronized groups
// Check if any OTHER target uses this group (not just any referrer, since groups can be in PBXGroups too)
if (this.props.fileSystemSynchronizedGroups) {
for (const group of [...this.props.fileSystemSynchronizedGroups]) {
const groupUsedByOtherTarget = [...project.values()].some(
(obj) =>
PBXNativeTarget.is(obj) &&
obj.uuid !== this.uuid &&
obj.props.fileSystemSynchronizedGroups?.some(
(g) => g.uuid === group.uuid
)
);
if (!groupUsedByOtherTarget) {
group.removeFromProject();
}
}
}

// Remove package product dependencies
if (this.props.packageProductDependencies) {
for (const dep of [...this.props.packageProductDependencies]) {
if (isExclusivelyOwnedByThisTarget(dep)) {
dep.removeFromProject();
}
}
}

// Remove the product reference (the .app, .framework, etc. file reference)
// Check if any OTHER target uses this as their productReference
if (this.props.productReference) {
const productRefUsedByOtherTarget = [...project.values()].some(
(obj) =>
PBXNativeTarget.is(obj) &&
obj.uuid !== this.uuid &&
obj.props.productReference?.uuid === this.props.productReference?.uuid
);
if (!productRefUsedByOtherTarget) {
this.props.productReference.removeFromProject();
}
}

// Find and remove any PBXTargetDependency objects from OTHER targets that depend on THIS target
for (const [, obj] of project.entries()) {
if (
PBXTargetDependency.is(obj) &&
(obj.props.target?.uuid === this.uuid ||
obj.props.targetProxy?.props.remoteGlobalIDString === this.uuid)
) {
obj.removeFromProject();
}
}

// Call parent which handles removing from PBXProject.targets array
return super.removeFromProject();
}
}
16 changes: 6 additions & 10 deletions src/api/PBXProject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,16 +226,12 @@ export class PBXProject extends AbstractObject<PBXProjectModel> {
return target;
}

isReferencing(uuid: string): boolean {
if (
[
this.props.mainGroup.uuid,
this.props.buildConfigurationList.uuid,
this.props.productRefGroup?.uuid,
].includes(uuid)
) {
return true;
removeReference(uuid: string) {
super.removeReference(uuid);

// Also remove from TargetAttributes if present
if (this.props.attributes?.TargetAttributes?.[uuid]) {
delete this.props.attributes.TargetAttributes[uuid];
}
return !!this.props.targets.find((target) => target.uuid === uuid);
}
}
11 changes: 11 additions & 0 deletions src/api/PBXTargetDependency.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,15 @@ export class PBXTargetDependency extends AbstractObject<PBXTargetDependencyModel
super.getDisplayName()
);
}

removeFromProject() {
// Remove the target proxy if it's only referenced by this dependency
if (this.props.targetProxy) {
const referrers = this.props.targetProxy.getReferrers();
if (referrers.length === 1 && referrers[0].uuid === this.uuid) {
this.props.targetProxy.removeFromProject();
}
}
return super.removeFromProject();
}
}
28 changes: 15 additions & 13 deletions src/api/XCConfigurationList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,19 +54,6 @@ export class XCConfigurationList extends AbstractObject<XCConfigurationListModel
return config;
}

removeReference(uuid: string) {
const index = this.props.buildConfigurations.findIndex(
(child) => child.uuid === uuid
);
if (index !== -1) {
this.props.buildConfigurations.splice(index, 1);
}
}

isReferencing(uuid: string): boolean {
return this.props.buildConfigurations.some((child) => child.uuid === uuid);
}

/** Set a build setting on all build configurations. */
setBuildSetting<TSetting extends keyof json.BuildSettings>(
key: TSetting,
Expand All @@ -91,4 +78,19 @@ export class XCConfigurationList extends AbstractObject<XCConfigurationListModel
) {
return this.getDefaultConfiguration().props.buildSettings[key];
}

removeFromProject() {
// Remove all build configurations that are only referenced by this list
for (const config of [...this.props.buildConfigurations]) {
const referrers = config.getReferrers();
// Only remove if this config list is the only referrer
if (
referrers.length === 1 &&
referrers[0].uuid === this.uuid
) {
config.removeFromProject();
}
}
return super.removeFromProject();
}
}
Loading