Skip to content
Open
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ node_modules
.vercel
build/

.DS_Store
.DS_Store
package-lock.json
136 changes: 127 additions & 9 deletions src/api/XcodeProject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { readFileSync } from "fs";
import path from "path";
import crypto from "crypto";

import { parse } from "../json";
import { parse, parseOptimized } from "../json";
import * as json from "../json/types";
import { AbstractObject } from "./AbstractObject";

Expand Down Expand Up @@ -212,10 +212,61 @@ export class XcodeProject extends Map<json.UUID, AnyModel> {
return new XcodeProject(filePath, json);
}

constructor(public filePath: string, props: Partial<json.XcodeProject>) {
/**
* Optimized open method for large projects
* @param filePath -- path to a `pbxproj` file
* @param options -- optimization options
*/
static openLazy(filePath: string, options: {
skipFullInflation?: boolean;
progressCallback?: (message: string) => void;
} = {}) {
const { skipFullInflation = true, progressCallback } = options;

progressCallback?.('Reading file...');
console.time('πŸ“ File read');
const contents = readFileSync(filePath, "utf8");
console.timeEnd('πŸ“ File read');

progressCallback?.('Parsing JSON...');
console.time('πŸ” JSON parsing');
const fileSizeMB = contents.length / 1024 / 1024;
let json;

if (fileSizeMB > 5) {
// Use optimized parser for large files
json = parseOptimized(contents, {
progressCallback: (processed, total, stage, memoryMB) => {
progressCallback?.(`${stage}: ${processed}/${total}${memoryMB ? ` (${memoryMB}MB)` : ''}`);
}
});
} else {
json = parse(contents);
}
console.timeEnd('πŸ” JSON parsing');

const objectCount = Object.keys(json.objects || {}).length;
console.log(`πŸ“Š Found ${objectCount.toLocaleString()} objects`);

progressCallback?.('Creating project...');
console.time('πŸ—οΈ Project creation');
const project = new XcodeProject(filePath, json, { skipFullInflation });
console.timeEnd('πŸ—οΈ Project creation');

return project;
}

constructor(
public filePath: string,
props: Partial<json.XcodeProject>,
options: { skipFullInflation?: boolean } = {}
) {
super();

const json = JSON.parse(JSON.stringify(props));
const { skipFullInflation = false } = options;

// Optimize: avoid deep clone for large projects
const json = skipFullInflation ? props : JSON.parse(JSON.stringify(props));
assert(json.objects, "objects is required");
assert(json.rootObject, "rootObject is required");

Expand All @@ -228,9 +279,19 @@ export class XcodeProject extends Map<json.UUID, AnyModel> {
assertRootObject(json.rootObject, json.objects?.[json.rootObject]);

// Inflate the root object.
console.time('🌱 Root object inflation');
this.rootObject = this.getObject(json.rootObject);
// This should never be needed in a compliant project.
this.ensureAllObjectsInflated();
console.timeEnd('🌱 Root object inflation');

// Skip full inflation for large projects
if (!skipFullInflation) {
console.time('🌳 Full object inflation');
this.ensureAllObjectsInflated();
console.timeEnd('🌳 Full object inflation');
} else {
const remainingCount = Object.keys(this.internalJsonObjects).length;
console.log(`⏭️ Skipping full inflation of ${remainingCount.toLocaleString()} objects (lazy mode)`);
}
}

/** The directory containing the `*.xcodeproj/project.pbxproj` file, e.g. `/ios/` in React Native. */
Expand Down Expand Up @@ -285,14 +346,71 @@ export class XcodeProject extends Map<json.UUID, AnyModel> {
// This method exists for sanity
if (Object.keys(this.internalJsonObjects).length === 0) return;

debug(
"inflating unreferenced objects: %o",
Object.keys(this.internalJsonObjects)
);
const remaining = Object.keys(this.internalJsonObjects).length;
debug("inflating unreferenced objects: %o", Object.keys(this.internalJsonObjects));

let processed = 0;
while (Object.keys(this.internalJsonObjects).length > 0) {
const uuid = Object.keys(this.internalJsonObjects)[0];
this.getObject(uuid);
processed++;

// Progress for large batches
if (remaining > 1000 && processed % 500 === 0) {
console.log(` βš™οΈ Inflated ${processed}/${remaining} objects...`);
}
}
}

/**
* Manually trigger full inflation of all objects (for lazy-loaded projects)
*/
forceFullInflation(progressCallback?: (processed: number, total: number) => void) {
const remaining = Object.keys(this.internalJsonObjects).length;
if (remaining === 0) {
console.log('βœ… All objects already inflated');
return;
}

console.log(`πŸ”„ Force inflating ${remaining.toLocaleString()} remaining objects...`);
console.time('🌳 Full inflation');

let processed = 0;
while (Object.keys(this.internalJsonObjects).length > 0) {
const uuid = Object.keys(this.internalJsonObjects)[0];
this.getObject(uuid);
processed++;

if (progressCallback && processed % 100 === 0) {
progressCallback(processed, remaining);
}
}

console.timeEnd('🌳 Full inflation');
console.log('βœ… Full inflation completed');
}

/**
* Get project statistics without full inflation
*/
getQuickStats() {
const totalObjects = this.size + Object.keys(this.internalJsonObjects).length;
const inflatedObjects = this.size;
const uninflatedObjects = Object.keys(this.internalJsonObjects).length;

return {
totalObjects,
inflatedObjects,
uninflatedObjects,
inflationPercentage: ((inflatedObjects / totalObjects) * 100).toFixed(1)
};
}

/**
* Get uninflated objects for analysis (read-only access)
*/
getUninflatedObjects(): Readonly<Record<json.UUID, json.AbstractObject<any>>> {
return this.internalJsonObjects;
}

createModel<TProps extends json.AbstractObject<any>>(opts: TProps) {
Expand Down
190 changes: 190 additions & 0 deletions src/api/__tests__/optimized-xcode-project.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
/**
* Tests for optimized XcodeProject functionality
*
* These tests verify that the enhanced XcodeProject class:
* - Supports lazy loading with openLazy()
* - Provides performance statistics via getQuickStats()
* - Maintains backward compatibility with original open()
* - Handles large projects efficiently
*/

import path from 'path';
import { XcodeProject } from '../XcodeProject';

const FIXTURES_DIR = path.join(__dirname, '../../json/__tests__/fixtures');
const SMALL_FIXTURE = path.join(FIXTURES_DIR, 'project.pbxproj');
const MEDIUM_FIXTURE = path.join(FIXTURES_DIR, 'AFNetworking.pbxproj');

describe('Optimized XcodeProject', () => {
describe('openLazy', () => {
it('should open project with lazy loading', () => {
const project = XcodeProject.openLazy(SMALL_FIXTURE, {
skipFullInflation: true
});

expect(project).toBeDefined();
expect(project.rootObject).toBeDefined();
expect(project.filePath).toBe(SMALL_FIXTURE);
});

it('should handle progress callbacks', () => {
const progressCallback = jest.fn();

const project = XcodeProject.openLazy(SMALL_FIXTURE, {
skipFullInflation: true,
progressCallback
});

expect(project).toBeDefined();
expect(progressCallback).toHaveBeenCalled();
});

it('should work with larger files', () => {
const project = XcodeProject.openLazy(MEDIUM_FIXTURE, {
skipFullInflation: true
});

expect(project).toBeDefined();
expect(project.rootObject).toBeDefined();

const stats = project.getQuickStats();
expect(stats.totalObjects).toBeGreaterThan(0);
});
});

describe('getQuickStats', () => {
it('should provide project statistics', () => {
const project = XcodeProject.openLazy(SMALL_FIXTURE, {
skipFullInflation: true
});

const stats = project.getQuickStats();

expect(stats.totalObjects).toBeGreaterThan(0);
expect(stats.inflatedObjects).toBeGreaterThanOrEqual(0);
expect(stats.uninflatedObjects).toBeGreaterThanOrEqual(0);
expect(stats.inflationPercentage).toBeDefined();
expect(parseFloat(stats.inflationPercentage)).toBeGreaterThanOrEqual(0);
expect(parseFloat(stats.inflationPercentage)).toBeLessThanOrEqual(100);
});
});

describe('getUninflatedObjects', () => {
it('should provide access to uninflated objects', () => {
const project = XcodeProject.openLazy(SMALL_FIXTURE, {
skipFullInflation: true
});

const uninflated = project.getUninflatedObjects();

expect(uninflated).toBeDefined();
expect(typeof uninflated).toBe('object');
});
});

describe('forceFullInflation', () => {
it('should inflate remaining objects', () => {
const project = XcodeProject.openLazy(MEDIUM_FIXTURE, {
skipFullInflation: true
});

const statsBefore = project.getQuickStats();

// Only test if there are uninflated objects
if (statsBefore.uninflatedObjects > 0) {
project.forceFullInflation();

const statsAfter = project.getQuickStats();
expect(statsAfter.uninflatedObjects).toBe(0);
expect(statsAfter.inflationPercentage).toBe('100.0');
}
});

it('should handle progress callback during inflation', () => {
const project = XcodeProject.openLazy(MEDIUM_FIXTURE, {
skipFullInflation: true
});

const progressCallback = jest.fn();

project.forceFullInflation(progressCallback);

// Progress callback may or may not be called depending on remaining objects
expect(progressCallback).toHaveBeenCalledTimes(expect.any(Number));
});
});

describe('Integration with Enhanced Parsing', () => {
it('should work with optimized parsing for medium files', () => {
// This tests the integration where openLazy automatically uses parseOptimized
const project = XcodeProject.openLazy(MEDIUM_FIXTURE, {
skipFullInflation: true
});

expect(project).toBeDefined();
expect(project.rootObject).toBeDefined();

// Should have loaded the main structure
const mainGroup = project.rootObject.props.mainGroup;
expect(mainGroup).toBeDefined();
expect(mainGroup.getDisplayName()).toBeDefined();
});

it('should preserve all core functionality', () => {
const project = XcodeProject.openLazy(SMALL_FIXTURE, {
skipFullInflation: true
});

// Test core API still works
expect(project.archiveVersion).toBeDefined();
expect(project.objectVersion).toBeDefined();
expect(project.rootObject).toBeDefined();
expect(project.getProjectRoot()).toBeDefined();

// Test that we can still access objects
const rootObject = project.rootObject;
expect(rootObject.props).toBeDefined();
expect(rootObject.props.mainGroup).toBeDefined();
});
});

describe('Backward Compatibility', () => {
it('original open method should still work', () => {
const project = XcodeProject.open(SMALL_FIXTURE);

expect(project).toBeDefined();
expect(project.rootObject).toBeDefined();
expect(project.size).toBeGreaterThan(0);
});

it('openLazy should be compatible with original open results', () => {
const originalProject = XcodeProject.open(SMALL_FIXTURE);
const lazyProject = XcodeProject.openLazy(SMALL_FIXTURE, {
skipFullInflation: false // Full inflation for comparison
});

expect(lazyProject.archiveVersion).toBe(originalProject.archiveVersion);
expect(lazyProject.objectVersion).toBe(originalProject.objectVersion);
expect(lazyProject.rootObject.uuid).toBe(originalProject.rootObject.uuid);
});
});

describe('Memory Management', () => {
it('lazy loading should use less initial memory', () => {
// This test is informational - memory usage can vary
if (global.gc) global.gc();
const startMemory = process.memoryUsage().heapUsed;

const project = XcodeProject.openLazy(MEDIUM_FIXTURE, {
skipFullInflation: true
});

const endMemory = process.memoryUsage().heapUsed;
const memoryIncrease = (endMemory - startMemory) / 1024 / 1024; // MB

// Should use reasonable memory (this is more of a smoke test)
expect(memoryIncrease).toBeLessThan(100); // Less than 100MB
expect(project).toBeDefined();
});
});
});
Loading