/**
* Xcode Project Parser
* Parses .xcodeproj/project.pbxproj files to extract project information
*/
import * as fs from "fs/promises";
import * as path from "path";
import plist from "plist";
export interface XcodeTarget {
id: string;
name: string;
productType: string;
buildConfigurationList: string;
buildPhases: string[];
dependencies: string[];
}
export interface XcodeBuildConfiguration {
id: string;
name: string;
buildSettings: Record<string, string>;
}
export interface XcodeProject {
path: string;
name: string;
targets: XcodeTarget[];
buildConfigurations: XcodeBuildConfiguration[];
rootGroup: string;
files: string[];
}
/**
* Parse the pbxproj file format (OpenStep plist variant)
* This is a simplified parser that handles the most common cases
*/
function parsePbxproj(content: string): Record<string, any> {
// The pbxproj format is similar to an old-style plist
// We'll do a simplified parse
const result: Record<string, any> = {};
// Extract archiveVersion
const archiveMatch = content.match(/archiveVersion\s*=\s*(\d+)/);
if (archiveMatch) result.archiveVersion = archiveMatch[1];
// Extract objectVersion
const objectMatch = content.match(/objectVersion\s*=\s*(\d+)/);
if (objectMatch) result.objectVersion = objectMatch[1];
// Extract rootObject
const rootMatch = content.match(/rootObject\s*=\s*([A-F0-9]+)/);
if (rootMatch) result.rootObject = rootMatch[1];
// Extract objects section
const objectsMatch = content.match(/objects\s*=\s*\{([\s\S]*)\};\s*rootObject/);
if (objectsMatch) {
result.objects = parseObjects(objectsMatch[1]);
}
return result;
}
/**
* Parse the objects dictionary from pbxproj
*/
function parseObjects(content: string): Record<string, any> {
const objects: Record<string, any> = {};
// Match object entries: ID = { ... }; (handles nested braces)
const objectRegex = /([A-F0-9]{24})\s*(?:\/\*[^*]*\*\/)?\s*=\s*\{((?:[^{}]|\{(?:[^{}]|\{[^{}]*\})*\})*)\}/g;
let match;
while ((match = objectRegex.exec(content)) !== null) {
const id = match[1];
const body = match[2];
const obj: Record<string, any> = { id };
// Extract isa (type)
const isaMatch = body.match(/isa\s*=\s*(\w+)/);
if (isaMatch) obj.isa = isaMatch[1];
// Extract name
const nameMatch = body.match(/name\s*=\s*"?([^";]+)"?\s*;/);
if (nameMatch) obj.name = nameMatch[1].trim();
// Extract productName
const productNameMatch = body.match(/productName\s*=\s*"?([^";]+)"?\s*;/);
if (productNameMatch) obj.productName = productNameMatch[1].trim();
// Extract productType
const productTypeMatch = body.match(/productType\s*=\s*"([^"]+)"/);
if (productTypeMatch) obj.productType = productTypeMatch[1];
// Extract buildConfigurationList
const buildConfigMatch = body.match(/buildConfigurationList\s*=\s*([A-F0-9]+)/);
if (buildConfigMatch) obj.buildConfigurationList = buildConfigMatch[1];
// Extract buildPhases array
const buildPhasesMatch = body.match(/buildPhases\s*=\s*\(([^)]*)\)/);
if (buildPhasesMatch) {
obj.buildPhases = buildPhasesMatch[1]
.split(",")
.map((s) => s.trim().replace(/\/\*.*\*\//, "").trim())
.filter((s) => s.length > 0);
}
// Extract dependencies array
const depsMatch = body.match(/dependencies\s*=\s*\(([^)]*)\)/);
if (depsMatch) {
obj.dependencies = depsMatch[1]
.split(",")
.map((s) => s.trim().replace(/\/\*.*\*\//, "").trim())
.filter((s) => s.length > 0);
}
// Extract buildSettings
const settingsMatch = body.match(/buildSettings\s*=\s*\{([^}]+)\}/);
if (settingsMatch) {
obj.buildSettings = {};
const settingsRegex = /(\w+)\s*=\s*"?([^";]+)"?\s*;/g;
let settingMatch;
while ((settingMatch = settingsRegex.exec(settingsMatch[1])) !== null) {
obj.buildSettings[settingMatch[1]] = settingMatch[2].trim();
}
}
objects[id] = obj;
}
return objects;
}
/**
* Parse an Xcode project at the given path
*/
export async function parseXcodeProject(projectPath: string): Promise<XcodeProject> {
const pbxprojPath = path.join(projectPath, "project.pbxproj");
// Verify it's a valid project
try {
await fs.access(pbxprojPath);
} catch {
throw new Error(`Invalid Xcode project: ${projectPath} (missing project.pbxproj)`);
}
const content = await fs.readFile(pbxprojPath, "utf-8");
const parsed = parsePbxproj(content);
const objects = parsed.objects || {};
// Find all native targets
const targets: XcodeTarget[] = [];
const buildConfigurations: XcodeBuildConfiguration[] = [];
const files: string[] = [];
for (const [id, obj] of Object.entries(objects) as [string, any][]) {
if (obj.isa === "PBXNativeTarget") {
targets.push({
id,
name: obj.name || obj.productName || "Unknown",
productType: obj.productType || "unknown",
buildConfigurationList: obj.buildConfigurationList || "",
buildPhases: obj.buildPhases || [],
dependencies: obj.dependencies || [],
});
}
if (obj.isa === "XCBuildConfiguration") {
buildConfigurations.push({
id,
name: obj.name || "Unknown",
buildSettings: obj.buildSettings || {},
});
}
if (obj.isa === "PBXFileReference") {
// Extract path from object body
const pathMatch = content.match(new RegExp(`${id}[^}]+path\\s*=\\s*"?([^";]+)"?\\s*;`));
const fileName = obj.name || (pathMatch ? pathMatch[1].trim() : null);
if (fileName) files.push(fileName);
}
}
// Get project name from path
const projectName = path.basename(projectPath, ".xcodeproj");
return {
path: projectPath,
name: projectName,
targets,
buildConfigurations,
rootGroup: parsed.rootObject || "",
files,
};
}
/**
* Find all Xcode projects in a directory
*/
export async function findXcodeProjects(directory: string): Promise<string[]> {
const projects: string[] = [];
async function scanDir(dir: string, depth: number = 0): Promise<void> {
if (depth > 5) return; // Limit recursion depth
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name.endsWith(".xcodeproj")) {
projects.push(fullPath);
} else if (
!entry.name.startsWith(".") &&
!entry.name.includes("node_modules") &&
!entry.name.includes("Pods") &&
!entry.name.includes("build") &&
!entry.name.includes("DerivedData")
) {
await scanDir(fullPath, depth + 1);
}
}
}
} catch {
// Ignore permission errors
}
}
await scanDir(directory);
return projects;
}
/**
* Find all schemes for a project
*/
export async function findSchemes(projectPath: string): Promise<string[]> {
const schemes: string[] = [];
// Check xcshareddata/xcschemes
const sharedSchemesPath = path.join(projectPath, "xcshareddata", "xcschemes");
try {
const entries = await fs.readdir(sharedSchemesPath);
for (const entry of entries) {
if (entry.endsWith(".xcscheme")) {
schemes.push(entry.replace(".xcscheme", ""));
}
}
} catch {
// No shared schemes
}
// Check xcuserdata for user schemes
const userDataPath = path.join(projectPath, "xcuserdata");
try {
const users = await fs.readdir(userDataPath);
for (const user of users) {
const userSchemesPath = path.join(userDataPath, user, "xcschemes");
try {
const entries = await fs.readdir(userSchemesPath);
for (const entry of entries) {
if (entry.endsWith(".xcscheme") && !schemes.includes(entry.replace(".xcscheme", ""))) {
schemes.push(entry.replace(".xcscheme", ""));
}
}
} catch {
// No user schemes
}
}
} catch {
// No user data
}
return schemes;
}