diff --git a/README.md b/README.md index 702553c..fe3434b 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # `@bacons/xcode` -> This project is a ~~_work in progress_ / _proof of concept_~~ seemingly spec compliant `pbxproj` parser. The API is subject to breaking changes. +Very fast and well-typed parser for Xcode project files (`.pbxproj`). ``` -yarn add @bacons/xcode +bun install @bacons/xcode ``` Here is a diagram of the grammar used for parsing: @@ -105,6 +105,80 @@ const file = PBXBuildFile.create(project, { // The file and fileRef will now be injected in the pbxproj `objects` dict. ``` +## XCScheme Support + +Parse and manipulate Xcode scheme files (`.xcscheme`). Schemes define how targets are built, run, tested, profiled, analyzed, and archived. + +### Low-level API + +```ts +import * as scheme from "@bacons/xcode/scheme"; +import fs from "fs"; + +// Parse an xcscheme file +const xml = fs.readFileSync("/path/to/Project.xcodeproj/xcshareddata/xcschemes/App.xcscheme", "utf-8"); +const xcscheme = scheme.parse(xml); + +// Modify the scheme +xcscheme.buildAction.parallelizeBuildables = true; +xcscheme.launchAction.environmentVariables = [ + { key: "DEBUG", value: "1", isEnabled: true } +]; + +// Serialize back to XML +const outputXml = scheme.build(xcscheme); +fs.writeFileSync("/path/to/App.xcscheme", outputXml); +``` + +### High-level API + +```ts +import { XcodeProject, XCScheme } from "@bacons/xcode"; + +// Load project and get schemes +const project = XcodeProject.open("/path/to/project.pbxproj"); +const schemes = project.getSchemes(); +const appScheme = project.getScheme("App"); + +// Create a new scheme +const newScheme = XCScheme.create("MyNewScheme"); +newScheme.addBuildTarget({ + buildableIdentifier: "primary", + blueprintIdentifier: "ABC123", // Target UUID + buildableName: "App.app", + blueprintName: "App", + referencedContainer: "container:Project.xcodeproj", +}); +newScheme.setLaunchEnvironmentVariable("API_URL", "https://api.example.com"); +newScheme.addLaunchArgument("-verbose"); + +// Save to disk +project.saveScheme(newScheme); + +// Create scheme for an existing target +const mainTarget = project.rootObject.props.targets[0]; +const targetScheme = project.createSchemeForTarget(mainTarget); +project.saveScheme(targetScheme); +``` + +### Scheme Management + +Parse and build `xcschememanagement.plist` files that control scheme visibility and ordering: + +```ts +import * as scheme from "@bacons/xcode/scheme"; +import fs from "fs"; + +const plist = fs.readFileSync("/path/to/xcschememanagement.plist", "utf-8"); +const management = scheme.parseManagement(plist); + +// Check scheme visibility +console.log(management.SchemeUserState?.["App.xcscheme"]?.isShown); + +// Rebuild plist +const output = scheme.buildManagement(management); +``` + ## Solution - Unlike the [xcode](https://www.npmjs.com/package/xcode) package which uses PEG.js, this implementation uses [Chevrotain](https://chevrotain.io/). @@ -130,8 +204,12 @@ We support the following types: `Object`, `Array`, `Data`, `String`. Notably, we - [x] Generating UUIDs. - [x] Reference-type API. - [x] Build setting parsing. +- [x] xcscheme support. - [ ] xcworkspace support. -- [ ] Docs. +- [ ] Benchmarks. +- [ ] Create robust xcode projects from scratch. +- [ ] Skills. +- [ ] Import from other tools. # Docs diff --git a/package.json b/package.json index a87d56e..0c60b79 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,9 @@ "files": [ "build", "json.js", - "json.d.ts" + "json.d.ts", + "scheme.js", + "scheme.d.ts" ], "dependencies": { "@expo/plist": "^0.0.18", diff --git a/scheme.d.ts b/scheme.d.ts new file mode 100644 index 0000000..119095e --- /dev/null +++ b/scheme.d.ts @@ -0,0 +1 @@ +export * from "./build/scheme"; diff --git a/scheme.js b/scheme.js new file mode 100644 index 0000000..9e88623 --- /dev/null +++ b/scheme.js @@ -0,0 +1 @@ +module.exports = require("./build/scheme"); diff --git a/src/api/XCScheme.ts b/src/api/XCScheme.ts new file mode 100644 index 0000000..d2bb2ca --- /dev/null +++ b/src/api/XCScheme.ts @@ -0,0 +1,365 @@ +/** + * High-level API for Xcode scheme files (.xcscheme). + * + * Provides convenient methods for creating and manipulating schemes. + */ +import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs"; +import path from "path"; + +import * as scheme from "../scheme"; +import type { + XCScheme as SchemeData, + BuildableReference, + BuildActionEntry, +} from "../scheme/types"; +import type { PBXNativeTarget } from "./PBXNativeTarget"; +import type { XcodeProject } from "./XcodeProject"; + +/** Build configurations for a target in BuildAction */ +export interface BuildFor { + running?: boolean; + testing?: boolean; + profiling?: boolean; + archiving?: boolean; + analyzing?: boolean; +} + +const DEFAULT_BUILD_FOR: Required = { + running: true, + testing: true, + profiling: true, + archiving: true, + analyzing: true, +}; + +/** + * High-level class for working with Xcode scheme files. + */ +export class XCScheme { + /** The scheme name (derived from filename) */ + name: string; + + /** The parsed scheme data */ + props: SchemeData; + + /** Path to the .xcscheme file (may be undefined for new schemes) */ + filePath?: string; + + private constructor(name: string, props: SchemeData, filePath?: string) { + this.name = name; + this.props = props; + this.filePath = filePath; + } + + /** + * Load a scheme from a file. + */ + static open(filePath: string): XCScheme { + const xml = readFileSync(filePath, "utf-8"); + const props = scheme.parse(xml); + const name = path.basename(filePath, ".xcscheme"); + return new XCScheme(name, props, filePath); + } + + /** + * Create a new empty scheme. + */ + static create(name: string, props?: Partial): XCScheme { + const defaultProps: SchemeData = { + version: "1.7", + buildAction: { + parallelizeBuildables: true, + buildImplicitDependencies: true, + entries: [], + }, + testAction: { + buildConfiguration: "Debug", + selectedDebuggerIdentifier: "Xcode.DebuggerFoundation.Debugger.LLDB", + selectedLauncherIdentifier: "Xcode.DebuggerFoundation.Launcher.LLDB", + shouldUseLaunchSchemeArgsEnv: true, + testables: [], + }, + launchAction: { + buildConfiguration: "Debug", + selectedDebuggerIdentifier: "Xcode.DebuggerFoundation.Debugger.LLDB", + selectedLauncherIdentifier: "Xcode.DebuggerFoundation.Launcher.LLDB", + launchStyle: "0", + useCustomWorkingDirectory: false, + ignoresPersistentStateOnLaunch: false, + debugDocumentVersioning: true, + debugServiceExtension: "internal", + allowLocationSimulation: true, + }, + profileAction: { + buildConfiguration: "Release", + shouldUseLaunchSchemeArgsEnv: true, + savedToolIdentifier: "", + useCustomWorkingDirectory: false, + debugDocumentVersioning: true, + }, + analyzeAction: { + buildConfiguration: "Debug", + }, + archiveAction: { + buildConfiguration: "Release", + revealArchiveInOrganizer: true, + }, + }; + + // Merge provided props with defaults + const mergedProps = { ...defaultProps }; + if (props) { + Object.assign(mergedProps, props); + } + + return new XCScheme(name, mergedProps); + } + + /** + * Create a scheme for a target with sensible defaults. + */ + static createForTarget( + target: PBXNativeTarget, + name?: string, + projectPath?: string + ): XCScheme { + const schemeName = name ?? target.props.name; + const containerPath = projectPath + ? `container:${path.basename(projectPath)}` + : `container:${path.basename(target.getXcodeProject().filePath, "/project.pbxproj")}.xcodeproj`; + + const buildableRef = createBuildableReference(target, containerPath); + + const xcscheme = XCScheme.create(schemeName); + + // Add build target + xcscheme.addBuildTarget(buildableRef); + + // Set launch target + xcscheme.setLaunchTarget(buildableRef); + + return xcscheme; + } + + /** + * Save the scheme to disk. + * + * @param filePath Optional path to save to. If not provided, uses this.filePath. + */ + save(filePath?: string): void { + const targetPath = filePath ?? this.filePath; + if (!targetPath) { + throw new Error( + "No file path specified. Either provide a path or set this.filePath." + ); + } + + // Ensure directory exists + const dir = path.dirname(targetPath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + const xml = scheme.build(this.props); + writeFileSync(targetPath, xml, "utf-8"); + this.filePath = targetPath; + } + + /** + * Get the XML representation of the scheme. + */ + toXML(): string { + return scheme.build(this.props); + } + + /** + * Get the buildable reference for a target UUID from this scheme. + */ + getBuildableReference(targetUuid: string): BuildableReference | null { + const entries = this.props.buildAction?.entries ?? []; + for (const entry of entries) { + if (entry.buildableReference?.blueprintIdentifier === targetUuid) { + return entry.buildableReference; + } + } + return null; + } + + /** + * Add a target to the build action. + */ + addBuildTarget(ref: BuildableReference, buildFor?: BuildFor): void { + if (!this.props.buildAction) { + this.props.buildAction = { + parallelizeBuildables: true, + buildImplicitDependencies: true, + entries: [], + }; + } + + if (!this.props.buildAction.entries) { + this.props.buildAction.entries = []; + } + + // Check if already added + const existing = this.props.buildAction.entries.find( + (e) => e.buildableReference?.blueprintIdentifier === ref.blueprintIdentifier + ); + if (existing) { + return; + } + + const config = { ...DEFAULT_BUILD_FOR, ...buildFor }; + + const entry: BuildActionEntry = { + buildForRunning: config.running, + buildForTesting: config.testing, + buildForProfiling: config.profiling, + buildForArchiving: config.archiving, + buildForAnalyzing: config.analyzing, + buildableReference: ref, + }; + + this.props.buildAction.entries.push(entry); + } + + /** + * Add a testable reference to the test action. + */ + addTestTarget(ref: BuildableReference): void { + if (!this.props.testAction) { + this.props.testAction = { + buildConfiguration: "Debug", + testables: [], + }; + } + + if (!this.props.testAction.testables) { + this.props.testAction.testables = []; + } + + // Check if already added + const existing = this.props.testAction.testables.find( + (t) => t.buildableReference?.blueprintIdentifier === ref.blueprintIdentifier + ); + if (existing) { + return; + } + + this.props.testAction.testables.push({ + skipped: false, + buildableReference: ref, + }); + } + + /** + * Set the launch target for the scheme. + */ + setLaunchTarget(ref: BuildableReference): void { + if (!this.props.launchAction) { + this.props.launchAction = { + buildConfiguration: "Debug", + }; + } + + this.props.launchAction.buildableProductRunnable = { + runnableDebuggingMode: "0", + buildableReference: ref, + }; + + // Also set for profile action + if (!this.props.profileAction) { + this.props.profileAction = { + buildConfiguration: "Release", + }; + } + + this.props.profileAction.buildableProductRunnable = { + runnableDebuggingMode: "0", + buildableReference: ref, + }; + } + + /** + * Set an environment variable for the launch action. + */ + setLaunchEnvironmentVariable( + key: string, + value: string, + isEnabled = true + ): void { + if (!this.props.launchAction) { + this.props.launchAction = { + buildConfiguration: "Debug", + }; + } + + if (!this.props.launchAction.environmentVariables) { + this.props.launchAction.environmentVariables = []; + } + + // Update if exists + const existing = this.props.launchAction.environmentVariables.find( + (v) => v.key === key + ); + if (existing) { + existing.value = value; + existing.isEnabled = isEnabled; + return; + } + + this.props.launchAction.environmentVariables.push({ + key, + value, + isEnabled, + }); + } + + /** + * Add a command line argument to the launch action. + */ + addLaunchArgument(argument: string, isEnabled = true): void { + if (!this.props.launchAction) { + this.props.launchAction = { + buildConfiguration: "Debug", + }; + } + + if (!this.props.launchAction.commandLineArguments) { + this.props.launchAction.commandLineArguments = []; + } + + // Check if already added + const existing = this.props.launchAction.commandLineArguments.find( + (a) => a.argument === argument + ); + if (existing) { + existing.isEnabled = isEnabled; + return; + } + + this.props.launchAction.commandLineArguments.push({ + argument, + isEnabled, + }); + } +} + +/** + * Helper to create a BuildableReference from a PBXNativeTarget. + */ +export function createBuildableReference( + target: PBXNativeTarget, + referencedContainer: string +): BuildableReference { + const productName = + target.props.productReference?.getDisplayName() ?? `${target.props.name}.app`; + + return { + buildableIdentifier: "primary", + blueprintIdentifier: target.uuid, + buildableName: productName, + blueprintName: target.props.name, + referencedContainer, + }; +} diff --git a/src/api/XcodeProject.ts b/src/api/XcodeProject.ts index 6467c1e..adbc1e3 100644 --- a/src/api/XcodeProject.ts +++ b/src/api/XcodeProject.ts @@ -1,8 +1,10 @@ import assert from "assert"; -import { readFileSync } from "fs"; +import { readFileSync, readdirSync, existsSync, unlinkSync, rmdirSync } from "fs"; import path from "path"; import crypto from "crypto"; +import { XCScheme, createBuildableReference } from "./XCScheme"; + import { parse } from "../json"; import * as json from "../json/types"; import { AbstractObject } from "./AbstractObject"; @@ -349,6 +351,176 @@ export class XcodeProject extends Map { ); } + // ============================================================================ + // Scheme Methods + // ============================================================================ + + /** + * Get the directory containing shared schemes. + */ + getSharedSchemesDir(): string { + const projectDir = path.dirname(this.filePath); + return path.join(projectDir, "xcshareddata", "xcschemes"); + } + + /** + * Get the directory containing user schemes for a specific user. + */ + getUserSchemesDir(username: string): string { + const projectDir = path.dirname(this.filePath); + return path.join( + projectDir, + "xcuserdata", + `${username}.xcuserdatad`, + "xcschemes" + ); + } + + /** + * Get all schemes (shared + user) for this project. + * + * @param options.includeUser Whether to include user schemes (requires username) + * @param options.username Username for user schemes + */ + getSchemes(options?: { + includeUser?: boolean; + username?: string; + }): XCScheme[] { + const schemes: XCScheme[] = []; + + // Get shared schemes + const sharedDir = this.getSharedSchemesDir(); + if (existsSync(sharedDir)) { + const files = readdirSync(sharedDir).filter((f) => + f.endsWith(".xcscheme") + ); + for (const file of files) { + schemes.push(XCScheme.open(path.join(sharedDir, file))); + } + } + + // Get user schemes if requested + if (options?.includeUser && options?.username) { + const userDir = this.getUserSchemesDir(options.username); + if (existsSync(userDir)) { + const files = readdirSync(userDir).filter((f) => + f.endsWith(".xcscheme") + ); + for (const file of files) { + schemes.push(XCScheme.open(path.join(userDir, file))); + } + } + } + + return schemes; + } + + /** + * Get a scheme by name. + * + * @param name The scheme name (without .xcscheme extension) + * @param options.username Optional username to check user schemes + */ + getScheme( + name: string, + options?: { username?: string } + ): XCScheme | null { + const fileName = name.endsWith(".xcscheme") ? name : `${name}.xcscheme`; + + // Check shared schemes first + const sharedPath = path.join(this.getSharedSchemesDir(), fileName); + if (existsSync(sharedPath)) { + return XCScheme.open(sharedPath); + } + + // Check user schemes if username provided + if (options?.username) { + const userPath = path.join( + this.getUserSchemesDir(options.username), + fileName + ); + if (existsSync(userPath)) { + return XCScheme.open(userPath); + } + } + + return null; + } + + /** + * Save a scheme to disk. + * + * @param scheme The scheme to save + * @param options.shared Whether to save as shared scheme (default: true) + * @param options.username Required if shared=false + */ + saveScheme( + scheme: XCScheme, + options?: { shared?: boolean; username?: string } + ): void { + const isShared = options?.shared ?? true; + const fileName = `${scheme.name}.xcscheme`; + + let targetDir: string; + if (isShared) { + targetDir = this.getSharedSchemesDir(); + } else { + if (!options?.username) { + throw new Error("username is required when saving a user scheme"); + } + targetDir = this.getUserSchemesDir(options.username); + } + + const targetPath = path.join(targetDir, fileName); + scheme.save(targetPath); + } + + /** + * Delete a scheme. + * + * @param name The scheme name (without .xcscheme extension) + * @param options.shared Whether to delete from shared schemes (default: true) + * @param options.username Required if shared=false + */ + deleteScheme( + name: string, + options?: { shared?: boolean; username?: string } + ): void { + const isShared = options?.shared ?? true; + const fileName = `${name}.xcscheme`; + + let targetDir: string; + if (isShared) { + targetDir = this.getSharedSchemesDir(); + } else { + if (!options?.username) { + throw new Error("username is required when deleting a user scheme"); + } + targetDir = this.getUserSchemesDir(options.username); + } + + const targetPath = path.join(targetDir, fileName); + if (existsSync(targetPath)) { + unlinkSync(targetPath); + } + } + + /** + * Create a basic scheme for a target. + * + * @param target The target to create a scheme for + * @param name Optional custom scheme name (defaults to target name) + */ + createSchemeForTarget(target: PBXNativeTarget, name?: string): XCScheme { + const projectDir = path.dirname(this.filePath); + const projectName = path.basename(projectDir); + return XCScheme.createForTarget( + target, + name, + `${projectName}` + ); + } + toJSON(): json.XcodeProject { const json: json.XcodeProject = { archiveVersion: this.archiveVersion, diff --git a/src/api/__tests__/XCScheme.test.ts b/src/api/__tests__/XCScheme.test.ts new file mode 100644 index 0000000..744a67f --- /dev/null +++ b/src/api/__tests__/XCScheme.test.ts @@ -0,0 +1,409 @@ +import path from "path"; +import { mkdtempSync, writeFileSync, mkdirSync, existsSync, readFileSync, rmSync } from "fs"; +import os from "os"; + +import { XcodeProject } from "../XcodeProject"; +import { XCScheme, createBuildableReference } from "../XCScheme"; +import * as scheme from "../../scheme"; + +// Use the existing pbxproj fixture +const pbxprojFixturePath = path.join( + __dirname, + "../../json/__tests__/fixtures/project-rni.pbxproj" +); + +describe("XCScheme", () => { + describe("static methods", () => { + it("creates an empty scheme", () => { + const xcscheme = XCScheme.create("MyScheme"); + + expect(xcscheme.name).toBe("MyScheme"); + expect(xcscheme.props.version).toBe("1.7"); + expect(xcscheme.props.buildAction).toBeDefined(); + expect(xcscheme.props.testAction).toBeDefined(); + expect(xcscheme.props.launchAction).toBeDefined(); + expect(xcscheme.props.profileAction).toBeDefined(); + expect(xcscheme.props.analyzeAction).toBeDefined(); + expect(xcscheme.props.archiveAction).toBeDefined(); + }); + + it("creates a scheme with custom props", () => { + const xcscheme = XCScheme.create("CustomScheme", { + version: "2.0", + buildAction: { + parallelizeBuildables: false, + }, + }); + + expect(xcscheme.props.version).toBe("2.0"); + expect(xcscheme.props.buildAction?.parallelizeBuildables).toBe(false); + }); + + it("opens a scheme file", () => { + const fixturePath = path.join( + __dirname, + "../../scheme/__tests__/fixtures/iOS.xcscheme" + ); + const xcscheme = XCScheme.open(fixturePath); + + expect(xcscheme.name).toBe("iOS"); + expect(xcscheme.props.version).toBe("2.0"); + expect(xcscheme.filePath).toBe(fixturePath); + }); + }); + + describe("instance methods", () => { + it("adds a build target", () => { + const xcscheme = XCScheme.create("TestScheme"); + const ref = { + buildableIdentifier: "primary", + blueprintIdentifier: "ABC123", + buildableName: "App.app", + blueprintName: "App", + referencedContainer: "container:Project.xcodeproj", + }; + + xcscheme.addBuildTarget(ref); + + expect(xcscheme.props.buildAction?.entries).toHaveLength(1); + expect(xcscheme.props.buildAction?.entries![0].buildableReference).toEqual( + ref + ); + expect(xcscheme.props.buildAction?.entries![0].buildForRunning).toBe(true); + expect(xcscheme.props.buildAction?.entries![0].buildForTesting).toBe(true); + }); + + it("adds a build target with custom build for settings", () => { + const xcscheme = XCScheme.create("TestScheme"); + const ref = { + buildableIdentifier: "primary", + blueprintIdentifier: "ABC123", + buildableName: "Tests.xctest", + blueprintName: "Tests", + referencedContainer: "container:Project.xcodeproj", + }; + + xcscheme.addBuildTarget(ref, { + running: false, + testing: true, + profiling: false, + archiving: false, + analyzing: false, + }); + + expect(xcscheme.props.buildAction?.entries![0].buildForRunning).toBe( + false + ); + expect(xcscheme.props.buildAction?.entries![0].buildForTesting).toBe(true); + }); + + it("does not duplicate build targets", () => { + const xcscheme = XCScheme.create("TestScheme"); + const ref = { + buildableIdentifier: "primary", + blueprintIdentifier: "ABC123", + buildableName: "App.app", + blueprintName: "App", + referencedContainer: "container:Project.xcodeproj", + }; + + xcscheme.addBuildTarget(ref); + xcscheme.addBuildTarget(ref); + + expect(xcscheme.props.buildAction?.entries).toHaveLength(1); + }); + + it("adds a test target", () => { + const xcscheme = XCScheme.create("TestScheme"); + const ref = { + buildableIdentifier: "primary", + blueprintIdentifier: "DEF456", + buildableName: "Tests.xctest", + blueprintName: "Tests", + referencedContainer: "container:Project.xcodeproj", + }; + + xcscheme.addTestTarget(ref); + + expect(xcscheme.props.testAction?.testables).toHaveLength(1); + expect( + xcscheme.props.testAction?.testables![0].buildableReference + ).toEqual(ref); + expect(xcscheme.props.testAction?.testables![0].skipped).toBe(false); + }); + + it("sets the launch target", () => { + const xcscheme = XCScheme.create("TestScheme"); + const ref = { + buildableIdentifier: "primary", + blueprintIdentifier: "ABC123", + buildableName: "App.app", + blueprintName: "App", + referencedContainer: "container:Project.xcodeproj", + }; + + xcscheme.setLaunchTarget(ref); + + expect( + xcscheme.props.launchAction?.buildableProductRunnable?.buildableReference + ).toEqual(ref); + expect( + xcscheme.props.profileAction?.buildableProductRunnable?.buildableReference + ).toEqual(ref); + }); + + it("sets environment variables", () => { + const xcscheme = XCScheme.create("TestScheme"); + + xcscheme.setLaunchEnvironmentVariable("API_URL", "https://api.example.com"); + xcscheme.setLaunchEnvironmentVariable("DEBUG", "1", false); + + expect(xcscheme.props.launchAction?.environmentVariables).toHaveLength(2); + expect(xcscheme.props.launchAction?.environmentVariables![0]).toEqual({ + key: "API_URL", + value: "https://api.example.com", + isEnabled: true, + }); + expect(xcscheme.props.launchAction?.environmentVariables![1]).toEqual({ + key: "DEBUG", + value: "1", + isEnabled: false, + }); + }); + + it("updates existing environment variables", () => { + const xcscheme = XCScheme.create("TestScheme"); + + xcscheme.setLaunchEnvironmentVariable("API_URL", "https://old.com"); + xcscheme.setLaunchEnvironmentVariable("API_URL", "https://new.com"); + + expect(xcscheme.props.launchAction?.environmentVariables).toHaveLength(1); + expect(xcscheme.props.launchAction?.environmentVariables![0].value).toBe( + "https://new.com" + ); + }); + + it("adds launch arguments", () => { + const xcscheme = XCScheme.create("TestScheme"); + + xcscheme.addLaunchArgument("-verbose"); + xcscheme.addLaunchArgument("-debug", false); + + expect(xcscheme.props.launchAction?.commandLineArguments).toHaveLength(2); + expect(xcscheme.props.launchAction?.commandLineArguments![0]).toEqual({ + argument: "-verbose", + isEnabled: true, + }); + }); + + it("gets buildable reference by UUID", () => { + const xcscheme = XCScheme.create("TestScheme"); + const ref = { + buildableIdentifier: "primary", + blueprintIdentifier: "ABC123", + buildableName: "App.app", + blueprintName: "App", + referencedContainer: "container:Project.xcodeproj", + }; + + xcscheme.addBuildTarget(ref); + + expect(xcscheme.getBuildableReference("ABC123")).toEqual(ref); + expect(xcscheme.getBuildableReference("nonexistent")).toBeNull(); + }); + + it("converts to XML", () => { + const xcscheme = XCScheme.create("TestScheme"); + const xml = xcscheme.toXML(); + + expect(xml).toContain(''); + expect(xml).toContain(" { + let tmpDir: string; + + beforeEach(() => { + tmpDir = mkdtempSync(path.join(os.tmpdir(), "xcscheme-test-")); + }); + + afterEach(() => { + if (existsSync(tmpDir)) { + rmSync(tmpDir, { recursive: true }); + } + }); + + it("saves a scheme to disk", () => { + const xcscheme = XCScheme.create("SaveTest"); + const savePath = path.join(tmpDir, "SaveTest.xcscheme"); + + xcscheme.save(savePath); + + expect(existsSync(savePath)).toBe(true); + expect(xcscheme.filePath).toBe(savePath); + + const content = readFileSync(savePath, "utf-8"); + expect(content).toContain(" { + const xcscheme = XCScheme.create("DeepSave"); + const savePath = path.join( + tmpDir, + "deeply", + "nested", + "dir", + "DeepSave.xcscheme" + ); + + xcscheme.save(savePath); + + expect(existsSync(savePath)).toBe(true); + }); + }); +}); + +describe("XcodeProject scheme methods", () => { + let tmpDir: string; + let projectDir: string; + let project: XcodeProject; + + beforeEach(() => { + // Create a temporary directory structure mimicking an .xcodeproj + tmpDir = mkdtempSync(path.join(os.tmpdir(), "xcode-project-test-")); + projectDir = path.join(tmpDir, "Test.xcodeproj"); + mkdirSync(projectDir, { recursive: true }); + + // Copy the pbxproj fixture + const pbxprojContent = readFileSync(pbxprojFixturePath, "utf-8"); + writeFileSync(path.join(projectDir, "project.pbxproj"), pbxprojContent); + + // Create shared schemes directory with a test scheme + const sharedSchemesDir = path.join( + projectDir, + "xcshareddata", + "xcschemes" + ); + mkdirSync(sharedSchemesDir, { recursive: true }); + + const testScheme = XCScheme.create("TestScheme"); + testScheme.save(path.join(sharedSchemesDir, "TestScheme.xcscheme")); + + // Open the project + project = XcodeProject.open(path.join(projectDir, "project.pbxproj")); + }); + + afterEach(() => { + if (existsSync(tmpDir)) { + rmSync(tmpDir, { recursive: true }); + } + }); + + it("gets shared schemes directory", () => { + const dir = project.getSharedSchemesDir(); + expect(dir).toContain("xcshareddata"); + expect(dir).toContain("xcschemes"); + }); + + it("gets user schemes directory", () => { + const dir = project.getUserSchemesDir("testuser"); + expect(dir).toContain("xcuserdata"); + expect(dir).toContain("testuser.xcuserdatad"); + expect(dir).toContain("xcschemes"); + }); + + it("gets all schemes", () => { + const schemes = project.getSchemes(); + expect(schemes.length).toBeGreaterThanOrEqual(1); + expect(schemes.some((s) => s.name === "TestScheme")).toBe(true); + }); + + it("gets a scheme by name", () => { + const s = project.getScheme("TestScheme"); + expect(s).not.toBeNull(); + expect(s!.name).toBe("TestScheme"); + }); + + it("returns null for non-existent scheme", () => { + const s = project.getScheme("NonExistent"); + expect(s).toBeNull(); + }); + + it("saves a scheme", () => { + const newScheme = XCScheme.create("NewScheme"); + project.saveScheme(newScheme); + + const savedPath = path.join( + project.getSharedSchemesDir(), + "NewScheme.xcscheme" + ); + expect(existsSync(savedPath)).toBe(true); + + // Verify we can get it back + const retrieved = project.getScheme("NewScheme"); + expect(retrieved).not.toBeNull(); + expect(retrieved!.name).toBe("NewScheme"); + }); + + it("saves a user scheme", () => { + const userScheme = XCScheme.create("UserScheme"); + project.saveScheme(userScheme, { shared: false, username: "developer" }); + + const savedPath = path.join( + project.getUserSchemesDir("developer"), + "UserScheme.xcscheme" + ); + expect(existsSync(savedPath)).toBe(true); + }); + + it("deletes a scheme", () => { + // First verify it exists + expect(project.getScheme("TestScheme")).not.toBeNull(); + + project.deleteScheme("TestScheme"); + + expect(project.getScheme("TestScheme")).toBeNull(); + }); + + it("creates a scheme for a target", () => { + const targets = project.rootObject.props.targets; + const mainTarget = targets.find((t) => "productType" in t.props && t.props.productType === "com.apple.product-type.application") as import("../PBXNativeTarget").PBXNativeTarget | undefined; + expect(mainTarget).toBeDefined(); + + const targetScheme = project.createSchemeForTarget(mainTarget!); + + expect(targetScheme.name).toBe(mainTarget!.props.name); + expect(targetScheme.props.buildAction?.entries).toHaveLength(1); + expect( + targetScheme.props.buildAction?.entries![0].buildableReference + ?.blueprintIdentifier + ).toBe(mainTarget!.uuid); + expect( + targetScheme.props.launchAction?.buildableProductRunnable?.buildableReference + ).toBeDefined(); + }); +}); + +describe("createBuildableReference", () => { + it("creates a buildable reference from a target", () => { + const projectPath = path.join( + __dirname, + "../../json/__tests__/fixtures/project-rni.pbxproj" + ); + const project = XcodeProject.open(projectPath); + const targets = project.rootObject.props.targets; + const mainTarget = targets.find( + (t) => "productType" in t.props && t.props.productType === "com.apple.product-type.application" + ) as import("../PBXNativeTarget").PBXNativeTarget | undefined; + expect(mainTarget).toBeDefined(); + + const ref = createBuildableReference(mainTarget!, "container:Test.xcodeproj"); + + expect(ref.buildableIdentifier).toBe("primary"); + expect(ref.blueprintIdentifier).toBe(mainTarget!.uuid); + expect(ref.blueprintName).toBe(mainTarget!.props.name); + expect(ref.referencedContainer).toBe("container:Test.xcodeproj"); + }); +}); diff --git a/src/api/index.ts b/src/api/index.ts index 947c358..ecf7784 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -33,3 +33,4 @@ export { XCRemoteSwiftPackageReference } from "./XCRemoteSwiftPackageReference"; export { XCLocalSwiftPackageReference } from "./XCLocalSwiftPackageReference"; export { XCSwiftPackageProductDependency } from "./XCSwiftPackageProductDependency"; export { XCVersionGroup } from "./XCVersionGroup"; +export { XCScheme, createBuildableReference } from "./XCScheme"; diff --git a/src/scheme/__tests__/fixtures/AppClip.xcscheme b/src/scheme/__tests__/fixtures/AppClip.xcscheme new file mode 100644 index 0000000..0fcd6b5 --- /dev/null +++ b/src/scheme/__tests__/fixtures/AppClip.xcscheme @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/scheme/__tests__/fixtures/BuildArchitectures.xcscheme b/src/scheme/__tests__/fixtures/BuildArchitectures.xcscheme new file mode 100644 index 0000000..675e26d --- /dev/null +++ b/src/scheme/__tests__/fixtures/BuildArchitectures.xcscheme @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/scheme/__tests__/fixtures/MinimalInformation.xcscheme b/src/scheme/__tests__/fixtures/MinimalInformation.xcscheme new file mode 100644 index 0000000..5212cb3 --- /dev/null +++ b/src/scheme/__tests__/fixtures/MinimalInformation.xcscheme @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/scheme/__tests__/fixtures/NoBlueprintID.xcscheme b/src/scheme/__tests__/fixtures/NoBlueprintID.xcscheme new file mode 100644 index 0000000..ef67cbf --- /dev/null +++ b/src/scheme/__tests__/fixtures/NoBlueprintID.xcscheme @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/scheme/__tests__/fixtures/RunPostActionsOnFailure.xcscheme b/src/scheme/__tests__/fixtures/RunPostActionsOnFailure.xcscheme new file mode 100644 index 0000000..995928c --- /dev/null +++ b/src/scheme/__tests__/fixtures/RunPostActionsOnFailure.xcscheme @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/scheme/__tests__/fixtures/RunnableWithoutBuildableReference.xcscheme b/src/scheme/__tests__/fixtures/RunnableWithoutBuildableReference.xcscheme new file mode 100644 index 0000000..edc29a3 --- /dev/null +++ b/src/scheme/__tests__/fixtures/RunnableWithoutBuildableReference.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/scheme/__tests__/fixtures/WatchApp.xcscheme b/src/scheme/__tests__/fixtures/WatchApp.xcscheme new file mode 100644 index 0000000..8be6e20 --- /dev/null +++ b/src/scheme/__tests__/fixtures/WatchApp.xcscheme @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/scheme/__tests__/fixtures/iOS.xcscheme b/src/scheme/__tests__/fixtures/iOS.xcscheme new file mode 100644 index 0000000..5ec7356 --- /dev/null +++ b/src/scheme/__tests__/fixtures/iOS.xcscheme @@ -0,0 +1,251 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/scheme/__tests__/fixtures/xcschememanagement.plist b/src/scheme/__tests__/fixtures/xcschememanagement.plist new file mode 100644 index 0000000..fa9e030 --- /dev/null +++ b/src/scheme/__tests__/fixtures/xcschememanagement.plist @@ -0,0 +1,48 @@ + + + + + SchemeUserState + + App.xcscheme + + isShown + + orderHint + 0 + + Test 0.xcscheme + + orderHint + 3 + + Test 1.xcscheme + + isShown + + orderHint + 4 + + Tuist.xcscheme_^#shared#^_ + + isShown + + orderHint + 1 + + XcodeProj.xcscheme + + orderHint + 2 + + + SuppressBuildableAutocreation + + E525238B16245A900012E2BA + + primary + + + + + diff --git a/src/scheme/__tests__/scheme.test.ts b/src/scheme/__tests__/scheme.test.ts new file mode 100644 index 0000000..1b49068 --- /dev/null +++ b/src/scheme/__tests__/scheme.test.ts @@ -0,0 +1,685 @@ +import { readFileSync } from "fs"; +import path from "path"; + +import { parse, build, parseManagement, buildManagement } from "../index"; +import type { XCScheme } from "../types"; + +const fixturesDir = path.join(__dirname, "fixtures"); + +function readFixture(name: string): string { + return readFileSync(path.join(fixturesDir, name), "utf-8"); +} + +describe("scheme parser", () => { + describe("parse", () => { + it("parses MinimalInformation.xcscheme", () => { + const xml = readFixture("MinimalInformation.xcscheme"); + const scheme = parse(xml); + + expect(scheme.version).toBe("1.3"); + expect(scheme.lastUpgradeVersion).toBeUndefined(); + + // Build action + expect(scheme.buildAction).toBeDefined(); + expect(scheme.buildAction!.entries).toHaveLength(1); + expect(scheme.buildAction!.entries![0].buildForRunning).toBe(false); + expect(scheme.buildAction!.entries![0].buildForTesting).toBe(true); + expect( + scheme.buildAction!.entries![0].buildableReference?.blueprintIdentifier + ).toBe("651BA89E1E459798004EAFE5"); + expect( + scheme.buildAction!.entries![0].buildableReference?.buildableName + ).toBe("ais.xctest"); + + // Test action + expect(scheme.testAction).toBeDefined(); + expect(scheme.testAction!.buildConfiguration).toBe("Debug"); + expect(scheme.testAction!.testables).toHaveLength(1); + expect( + scheme.testAction!.testables![0].buildableReference?.buildableName + ).toBe("ais.xctest"); + + // Launch action with environment variables + expect(scheme.launchAction).toBeDefined(); + expect(scheme.launchAction!.useCustomWorkingDirectory).toBe(false); + expect(scheme.launchAction!.environmentVariables).toHaveLength(1); + expect(scheme.launchAction!.environmentVariables![0].key).toBe( + "AI_TEST_MODE" + ); + expect(scheme.launchAction!.environmentVariables![0].value).toBe( + "integration" + ); + expect(scheme.launchAction!.environmentVariables![0].isEnabled).toBe( + true + ); + }); + + it("parses iOS.xcscheme with full structure", () => { + const xml = readFixture("iOS.xcscheme"); + const scheme = parse(xml); + + expect(scheme.version).toBe("2.0"); + expect(scheme.lastUpgradeVersion).toBe("0830"); + + // Build action with pre/post actions + expect(scheme.buildAction!.parallelizeBuildables).toBe(true); + expect(scheme.buildAction!.buildImplicitDependencies).toBe(true); + expect(scheme.buildAction!.preActions).toHaveLength(1); + expect(scheme.buildAction!.preActions![0].actionContent?.title).toBe( + "Build Pre-action" + ); + expect(scheme.buildAction!.preActions![0].actionContent?.scriptText).toBe( + "echo prebuild" + ); + expect(scheme.buildAction!.postActions).toHaveLength(1); + expect( + scheme.buildAction!.postActions![0].actionContent?.shellToInvoke + ).toBe("/bin/sh"); + + // Build action entry + expect(scheme.buildAction!.entries).toHaveLength(1); + const entry = scheme.buildAction!.entries![0]; + expect(entry.buildForTesting).toBe(true); + expect(entry.buildForRunning).toBe(true); + expect(entry.buildForProfiling).toBe(true); + expect(entry.buildForArchiving).toBe(true); + expect(entry.buildForAnalyzing).toBe(true); + expect(entry.buildableReference?.blueprintName).toBe("iOS"); + expect(entry.buildableReference?.referencedContainer).toBe( + "container:Project.xcodeproj" + ); + + // Test action + expect(scheme.testAction!.codeCoverageEnabled).toBe(true); + expect(scheme.testAction!.onlyGenerateCoverageForSpecifiedTargets).toBe( + true + ); + expect(scheme.testAction!.preferredScreenCaptureFormat).toBe( + "screenshots" + ); + expect(scheme.testAction!.testPlans).toHaveLength(1); + expect(scheme.testAction!.testPlans![0].reference).toBe( + "container:iOS.xctestplan" + ); + expect(scheme.testAction!.testPlans![0].default).toBe(true); + expect(scheme.testAction!.preActions).toHaveLength(2); + expect(scheme.testAction!.postActions).toHaveLength(2); + expect(scheme.testAction!.commandLineArguments).toHaveLength(1); + expect(scheme.testAction!.commandLineArguments![0].argument).toBe( + "MyTestArgument" + ); + expect(scheme.testAction!.macroExpansion?.blueprintName).toBe("iOS"); + + // Launch action + expect(scheme.launchAction!.launchStyle).toBe("2"); + expect(scheme.launchAction!.debugServiceExtension).toBe("internal"); + expect(scheme.launchAction!.customLaunchCommand).toBe("custom command"); + expect(scheme.launchAction!.buildableProductRunnable).toBeDefined(); + expect( + scheme.launchAction!.buildableProductRunnable?.runnableDebuggingMode + ).toBe("0"); + expect(scheme.launchAction!.locationScenarioReference).toBeDefined(); + expect( + scheme.launchAction!.locationScenarioReference?.identifier + ).toBe( + "com.apple.dt.IDEFoundation.CurrentLocationScenarioIdentifier" + ); + expect( + scheme.launchAction!.storeKitConfigurationFileReference?.identifier + ).toBe("../../Configuration.storekit"); + + // Profile action + expect(scheme.profileAction!.buildConfiguration).toBe("Release"); + expect(scheme.profileAction!.postActions).toHaveLength(1); + + // Analyze action + expect(scheme.analyzeAction!.buildConfiguration).toBe("Debug"); + + // Archive action + expect(scheme.archiveAction!.buildConfiguration).toBe("Release"); + expect(scheme.archiveAction!.customArchiveName).toBe("TestName"); + expect(scheme.archiveAction!.revealArchiveInOrganizer).toBe(true); + }); + + it("parses AppClip.xcscheme with app clip features", () => { + const xml = readFixture("AppClip.xcscheme"); + const scheme = parse(xml); + + expect(scheme.lastUpgradeVersion).toBe("1600"); + expect(scheme.version).toBe("1.7"); + + expect(scheme.buildAction!.buildArchitectures).toBe("Automatic"); + + expect(scheme.launchAction!.appClipInvocationURLString).toBe( + "https://example.com/" + ); + expect(scheme.profileAction!.appClipInvocationURLString).toBe( + "https://example.com/" + ); + expect(scheme.testAction!.shouldAutocreateTestPlan).toBe(true); + }); + + it("parses RunPostActionsOnFailure.xcscheme", () => { + const xml = readFixture("RunPostActionsOnFailure.xcscheme"); + const scheme = parse(xml); + + expect(scheme.buildAction!.runPostActionsOnFailure).toBe(true); + }); + + it("parses RunnableWithoutBuildableReference.xcscheme with RemoteRunnable", () => { + const xml = readFixture("RunnableWithoutBuildableReference.xcscheme"); + const scheme = parse(xml); + + expect(scheme.launchAction!.remoteRunnable).toBeDefined(); + expect(scheme.launchAction!.remoteRunnable?.runnableDebuggingMode).toBe( + "1" + ); + expect(scheme.launchAction!.remoteRunnable?.bundleIdentifier).toBe( + "me.ava.Ava-Staging" + ); + expect(scheme.launchAction!.remoteRunnable?.remotePath).toBe( + "/var/containers/Bundle/Application/018F0933-05E8-4359-9955-39E0523C4246/Ava.app" + ); + + expect(scheme.launchAction!.askForAppToLaunch).toBe(true); + expect(scheme.launchAction!.launchAutomaticallySubstyle).toBe("2"); + expect(scheme.launchAction!.customWorkingDirectory).toBe( + "/customWorkingDirectory" + ); + + expect(scheme.profileAction!.askForAppToLaunch).toBe(true); + expect(scheme.profileAction!.launchAutomaticallySubstyle).toBe("2"); + }); + + it("parses NoBlueprintID.xcscheme without blueprintIdentifier", () => { + const xml = readFixture("NoBlueprintID.xcscheme"); + const scheme = parse(xml); + + expect(scheme).toBeDefined(); + // Scheme with missing blueprint identifier should still parse + const entry = scheme.buildAction?.entries?.[0]; + expect(entry?.buildableReference).toBeDefined(); + // blueprintIdentifier should be empty string when missing + expect(entry?.buildableReference?.blueprintIdentifier).toBe(""); + }); + + it("parses BuildArchitectures.xcscheme with buildArchitectures", () => { + const xml = readFixture("BuildArchitectures.xcscheme"); + const scheme = parse(xml); + + expect(scheme.buildAction?.buildArchitectures).toBe("Automatic"); + }); + + it("parses WatchApp.xcscheme with watch extension RemoteRunnable", () => { + const xml = readFixture("WatchApp.xcscheme"); + const scheme = parse(xml); + + expect(scheme.launchAction?.remoteRunnable).toBeDefined(); + expect(scheme.launchAction?.remoteRunnable?.bundleIdentifier).toBe( + "com.apple.Carousel" + ); + expect(scheme.launchAction?.remoteRunnable?.runnableDebuggingMode).toBe( + "2" + ); + expect(scheme.launchAction?.remoteRunnable?.remotePath).toBe( + "/AppWithExtensions" + ); + }); + + it("parses multiple build action entries", () => { + const xml = readFixture("RunnableWithoutBuildableReference.xcscheme"); + const scheme = parse(xml); + + expect(scheme.buildAction?.entries).toHaveLength(2); + + // First entry - app target + expect(scheme.buildAction?.entries?.[0].buildForRunning).toBe(true); + expect(scheme.buildAction?.entries?.[0].buildForTesting).toBe(true); + expect(scheme.buildAction?.entries?.[0].buildableReference?.buildableName).toBe( + "Ava.app" + ); + + // Second entry - test target + expect(scheme.buildAction?.entries?.[1].buildForRunning).toBe(false); + expect(scheme.buildAction?.entries?.[1].buildForTesting).toBe(true); + expect(scheme.buildAction?.entries?.[1].buildableReference?.buildableName).toBe( + "AvaTests.xctest" + ); + }); + + it("parses macroExpansion in launchAction", () => { + const xml = readFixture("RunnableWithoutBuildableReference.xcscheme"); + const scheme = parse(xml); + + expect(scheme.launchAction?.macroExpansion).toBeDefined(); + expect(scheme.launchAction?.macroExpansion?.blueprintIdentifier).toBe( + "FE7C11D21B6DB70D0041DF02" + ); + expect(scheme.launchAction?.macroExpansion?.buildableName).toBe("Ava.app"); + }); + + it("parses environmentBuildable in pre/post actions", () => { + const xml = readFixture("iOS.xcscheme"); + const scheme = parse(xml); + + // LaunchAction pre-action has environmentBuildable + const preAction = scheme.launchAction?.preActions?.[0]; + expect(preAction?.actionContent?.environmentBuildable).toBeDefined(); + expect( + preAction?.actionContent?.environmentBuildable?.blueprintIdentifier + ).toBe("23766C111EAA3484007A9026"); + expect(preAction?.actionContent?.environmentBuildable?.buildableName).toBe( + "iOS.app" + ); + + // LaunchAction post-action has environmentBuildable + const postAction = scheme.launchAction?.postActions?.[0]; + expect(postAction?.actionContent?.environmentBuildable).toBeDefined(); + expect( + postAction?.actionContent?.environmentBuildable?.blueprintIdentifier + ).toBe("23766C251EAA3484007A9026"); + }); + }); + + describe("parsing stability", () => { + it("produces identical results on multiple parses", () => { + const xml = readFixture("iOS.xcscheme"); + const parses = Array.from({ length: 5 }, () => parse(xml)); + + // All parses should be identical + for (let i = 1; i < parses.length; i++) { + expect(parses[i]).toEqual(parses[0]); + } + }); + }); + + describe("build", () => { + it("builds a basic scheme", () => { + const scheme: XCScheme = { + version: "1.3", + buildAction: { + entries: [ + { + buildForRunning: true, + buildForTesting: true, + buildableReference: { + buildableIdentifier: "primary", + blueprintIdentifier: "ABC123", + buildableName: "App.app", + blueprintName: "App", + referencedContainer: "container:App.xcodeproj", + }, + }, + ], + }, + testAction: { + buildConfiguration: "Debug", + }, + launchAction: { + buildConfiguration: "Debug", + }, + profileAction: { + buildConfiguration: "Release", + }, + analyzeAction: { + buildConfiguration: "Debug", + }, + archiveAction: { + buildConfiguration: "Release", + revealArchiveInOrganizer: true, + }, + }; + + const xml = build(scheme); + + expect(xml).toContain(''); + expect(xml).toContain(""); + expect(xml).toContain(""); + expect(xml).toContain('BlueprintIdentifier = "ABC123"'); + expect(xml).toContain('BuildableName = "App.app"'); + expect(xml).toContain('buildConfiguration = "Debug"'); + expect(xml).toContain('revealArchiveInOrganizer = "YES"'); + }); + + it("builds scheme with environment variables", () => { + const scheme: XCScheme = { + version: "1.3", + launchAction: { + environmentVariables: [ + { key: "API_URL", value: "https://api.example.com", isEnabled: true }, + { key: "DEBUG", value: "1", isEnabled: false }, + ], + }, + }; + + const xml = build(scheme); + + expect(xml).toContain(""); + expect(xml).toContain('key = "API_URL"'); + expect(xml).toContain('value = "https://api.example.com"'); + expect(xml).toContain('isEnabled = "YES"'); + expect(xml).toContain('key = "DEBUG"'); + expect(xml).toContain('isEnabled = "NO"'); + }); + + it("builds scheme with pre/post actions", () => { + const scheme: XCScheme = { + version: "2.0", + buildAction: { + preActions: [ + { + actionType: + "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction", + actionContent: { + title: "Pre-build", + scriptText: "echo prebuild", + }, + }, + ], + postActions: [ + { + actionType: + "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction", + actionContent: { + title: "Post-build", + scriptText: "echo postbuild", + shellToInvoke: "/bin/zsh", + }, + }, + ], + }, + }; + + const xml = build(scheme); + + expect(xml).toContain(""); + expect(xml).toContain('title = "Pre-build"'); + expect(xml).toContain('scriptText = "echo prebuild"'); + expect(xml).toContain(""); + expect(xml).toContain('title = "Post-build"'); + expect(xml).toContain('shellToInvoke = "/bin/zsh"'); + }); + + it("escapes XML special characters", () => { + const scheme: XCScheme = { + version: "1.3", + buildAction: { + entries: [ + { + buildableReference: { + buildableIdentifier: "primary", + blueprintIdentifier: "ABC123", + buildableName: "Test & Demo.app", + blueprintName: "Test ", + referencedContainer: 'container:"Test".xcodeproj', + }, + }, + ], + }, + }; + + const xml = build(scheme); + + expect(xml).toContain('BuildableName = "Test & Demo.app"'); + expect(xml).toContain('BlueprintName = "Test <Demo>"'); + expect(xml).toContain( + 'ReferencedContainer = "container:"Test".xcodeproj"' + ); + }); + }); + + describe("round-trip", () => { + const fixtures = [ + "MinimalInformation.xcscheme", + "AppClip.xcscheme", + "RunPostActionsOnFailure.xcscheme", + "RunnableWithoutBuildableReference.xcscheme", + "BuildArchitectures.xcscheme", + ]; + + for (const fixture of fixtures) { + it(`round-trips ${fixture}`, () => { + const xml = readFixture(fixture); + const scheme = parse(xml); + const rebuilt = build(scheme); + const reparsed = parse(rebuilt); + + // Deep equality check on the parsed structures + expect(reparsed).toEqual(scheme); + }); + } + }); + + describe("build additional scenarios", () => { + it("builds scheme with RemoteRunnable", () => { + const scheme: XCScheme = { + version: "2.0", + launchAction: { + buildConfiguration: "Debug", + remoteRunnable: { + runnableDebuggingMode: "1", + bundleIdentifier: "com.example.app", + remotePath: "/var/containers/Bundle/Application/App.app", + buildableReference: { + buildableIdentifier: "primary", + blueprintIdentifier: "ABC123", + buildableName: "App.app", + blueprintName: "App", + referencedContainer: "container:App.xcodeproj", + }, + }, + }, + }; + + const xml = build(scheme); + + expect(xml).toContain(" { + const scheme: XCScheme = { + version: "1.7", + testAction: { + buildConfiguration: "Debug", + testables: [ + { + skipped: false, + buildableReference: { + buildableIdentifier: "primary", + blueprintIdentifier: "TEST123", + buildableName: "AppTests.xctest", + blueprintName: "AppTests", + referencedContainer: "container:App.xcodeproj", + }, + }, + ], + }, + }; + + const xml = build(scheme); + + expect(xml).toContain(""); + expect(xml).toContain(" { + const scheme: XCScheme = { + version: "1.7", + launchAction: { + buildConfiguration: "Debug", + commandLineArguments: [ + { argument: "-verbose", isEnabled: true }, + { argument: "-debug", isEnabled: false }, + ], + }, + }; + + const xml = build(scheme); + + expect(xml).toContain(""); + expect(xml).toContain('argument = "-verbose"'); + expect(xml).toContain('isEnabled = "YES"'); + expect(xml).toContain('argument = "-debug"'); + expect(xml).toContain('isEnabled = "NO"'); + }); + + it("builds scheme with macroExpansion", () => { + const scheme: XCScheme = { + version: "2.0", + launchAction: { + buildConfiguration: "Debug", + macroExpansion: { + buildableIdentifier: "primary", + blueprintIdentifier: "ABC123", + buildableName: "App.app", + blueprintName: "App", + referencedContainer: "container:App.xcodeproj", + }, + }, + }; + + const xml = build(scheme); + + expect(xml).toContain(""); + expect(xml).toContain(""); + }); + + it("builds scheme with test plans", () => { + const scheme: XCScheme = { + version: "2.0", + testAction: { + buildConfiguration: "Debug", + testPlans: [ + { reference: "container:MyTests.xctestplan", default: true }, + { reference: "container:Integration.xctestplan", default: false }, + ], + }, + }; + + const xml = build(scheme); + + expect(xml).toContain(""); + expect(xml).toContain(" { + const scheme: XCScheme = { + version: "2.0", + launchAction: { + buildConfiguration: "Debug", + locationScenarioReference: { + identifier: "com.apple.dt.IDEFoundation.CurrentLocationScenarioIdentifier", + referenceType: "1", + }, + }, + }; + + const xml = build(scheme); + + expect(xml).toContain(" { + const scheme: XCScheme = { + version: "2.0", + launchAction: { + buildConfiguration: "Debug", + storeKitConfigurationFileReference: { + identifier: "../../Configuration.storekit", + }, + }, + }; + + const xml = build(scheme); + + expect(xml).toContain(" { + describe("parseManagement", () => { + it("parses xcschememanagement.plist", () => { + const plistStr = readFixture("xcschememanagement.plist"); + const management = parseManagement(plistStr); + + expect(management.SchemeUserState).toBeDefined(); + expect(management.SchemeUserState!["App.xcscheme"]).toEqual({ + isShown: false, + orderHint: 0, + }); + expect(management.SchemeUserState!["Test 0.xcscheme"]).toEqual({ + orderHint: 3, + }); + expect( + management.SchemeUserState!["Tuist.xcscheme_^#shared#^_"] + ).toEqual({ + isShown: true, + orderHint: 1, + }); + + expect(management.SuppressBuildableAutocreation).toBeDefined(); + expect( + management.SuppressBuildableAutocreation!["E525238B16245A900012E2BA"] + ).toEqual({ + primary: true, + }); + }); + }); + + describe("buildManagement", () => { + it("builds xcschememanagement.plist", () => { + const management = { + SchemeUserState: { + "App.xcscheme": { isShown: false, orderHint: 0 }, + "Test.xcscheme": { orderHint: 1 }, + }, + SuppressBuildableAutocreation: { + ABC123: { primary: true }, + }, + }; + + const plistStr = buildManagement(management); + + expect(plistStr).toContain("SchemeUserState"); + expect(plistStr).toContain("App.xcscheme"); + expect(plistStr).toContain("isShown"); + expect(plistStr).toContain(""); + expect(plistStr).toContain("orderHint"); + expect(plistStr).toContain("0"); + expect(plistStr).toContain("SuppressBuildableAutocreation"); + expect(plistStr).toContain("ABC123"); + expect(plistStr).toContain("primary"); + expect(plistStr).toContain(""); + }); + }); + + describe("round-trip", () => { + it("round-trips xcschememanagement.plist", () => { + const plistStr = readFixture("xcschememanagement.plist"); + const management = parseManagement(plistStr); + const rebuilt = buildManagement(management); + const reparsed = parseManagement(rebuilt); + + expect(reparsed).toEqual(management); + }); + }); +}); diff --git a/src/scheme/index.ts b/src/scheme/index.ts new file mode 100644 index 0000000..193e04f --- /dev/null +++ b/src/scheme/index.ts @@ -0,0 +1,22 @@ +/** + * Low-level API for parsing and building Xcode scheme files (.xcscheme). + * + * @example + * ```ts + * import * as scheme from "@bacons/xcode/scheme"; + * + * // Parse an xcscheme file + * const xcscheme = scheme.parse(xml); + * + * // Modify the scheme + * xcscheme.buildAction.parallelizeBuildables = true; + * + * // Serialize back to XML + * const xml = scheme.build(xcscheme); + * ``` + */ + +export { parse } from "./parser"; +export { build } from "./writer"; +export { parseManagement, buildManagement } from "./management"; +export * from "./types"; diff --git a/src/scheme/management.ts b/src/scheme/management.ts new file mode 100644 index 0000000..141db00 --- /dev/null +++ b/src/scheme/management.ts @@ -0,0 +1,92 @@ +/** + * Parser and writer for xcschememanagement.plist files. + * + * These files control scheme visibility and ordering in Xcode's scheme selector. + */ +import plist from "@expo/plist"; + +import type { + XCSchemeManagement, + SchemeUserState, + SuppressBuildableAutocreation, +} from "./types"; + +/** + * Parse an xcschememanagement.plist string into a typed object. + */ +export function parseManagement(plistString: string): XCSchemeManagement { + const parsed = plist.parse(plistString) as Record; + + const result: XCSchemeManagement = {}; + + if (parsed.SchemeUserState) { + result.SchemeUserState = {}; + const userState = parsed.SchemeUserState as Record< + string, + Record + >; + + for (const [key, value] of Object.entries(userState)) { + result.SchemeUserState[key] = parseSchemeUserState(value); + } + } + + if (parsed.SuppressBuildableAutocreation) { + result.SuppressBuildableAutocreation = {}; + const suppress = parsed.SuppressBuildableAutocreation as Record< + string, + Record + >; + + for (const [key, value] of Object.entries(suppress)) { + result.SuppressBuildableAutocreation[key] = + parseSuppressBuildableAutocreation(value); + } + } + + return result; +} + +function parseSchemeUserState( + data: Record +): SchemeUserState { + const state: SchemeUserState = {}; + + if (typeof data.isShown === "boolean") { + state.isShown = data.isShown; + } + if (typeof data.orderHint === "number") { + state.orderHint = data.orderHint; + } + + return state; +} + +function parseSuppressBuildableAutocreation( + data: Record +): SuppressBuildableAutocreation { + const suppress: SuppressBuildableAutocreation = {}; + + if (typeof data.primary === "boolean") { + suppress.primary = data.primary; + } + + return suppress; +} + +/** + * Build an xcschememanagement.plist string from a typed object. + */ +export function buildManagement(management: XCSchemeManagement): string { + const obj: Record = {}; + + if (management.SchemeUserState) { + obj.SchemeUserState = management.SchemeUserState; + } + + if (management.SuppressBuildableAutocreation) { + obj.SuppressBuildableAutocreation = management.SuppressBuildableAutocreation; + } + + return plist.build(obj); +} diff --git a/src/scheme/parser.ts b/src/scheme/parser.ts new file mode 100644 index 0000000..2714c4c --- /dev/null +++ b/src/scheme/parser.ts @@ -0,0 +1,509 @@ +/** + * Parser for Xcode scheme files (.xcscheme) + * + * Uses @xmldom/xmldom to parse XML into TypeScript objects. + */ +import { DOMParser } from "@xmldom/xmldom"; + +import type { + XCScheme, + BuildAction, + BuildActionEntry, + BuildableReference, + ExecutionAction, + ActionContent, + TestAction, + TestableReference, + TestPlanReference, + LaunchAction, + BuildableProductRunnable, + RemoteRunnable, + LocationScenarioReference, + StoreKitConfigurationFileReference, + ProfileAction, + AnalyzeAction, + ArchiveAction, + CommandLineArgument, + EnvironmentVariable, + SkippedTest, + SelectedTest, +} from "./types"; + +/** + * Parse an xcscheme XML string into a typed XCScheme object. + */ +export function parse(xml: string): XCScheme { + const parser = new DOMParser(); + const doc = parser.parseFromString(xml, "text/xml"); + const schemeEl = doc.documentElement; + + if (!schemeEl || schemeEl.tagName !== "Scheme") { + throw new Error("Invalid xcscheme file: root element must be "); + } + + return parseScheme(schemeEl); +} + +function parseScheme(el: Element): XCScheme { + const scheme: XCScheme = {}; + + scheme.version = getAttr(el, "version"); + scheme.lastUpgradeVersion = getAttr(el, "LastUpgradeVersion"); + + const buildActionEl = getChildElement(el, "BuildAction"); + if (buildActionEl) { + scheme.buildAction = parseBuildAction(buildActionEl); + } + + const testActionEl = getChildElement(el, "TestAction"); + if (testActionEl) { + scheme.testAction = parseTestAction(testActionEl); + } + + const launchActionEl = getChildElement(el, "LaunchAction"); + if (launchActionEl) { + scheme.launchAction = parseLaunchAction(launchActionEl); + } + + const profileActionEl = getChildElement(el, "ProfileAction"); + if (profileActionEl) { + scheme.profileAction = parseProfileAction(profileActionEl); + } + + const analyzeActionEl = getChildElement(el, "AnalyzeAction"); + if (analyzeActionEl) { + scheme.analyzeAction = parseAnalyzeAction(analyzeActionEl); + } + + const archiveActionEl = getChildElement(el, "ArchiveAction"); + if (archiveActionEl) { + scheme.archiveAction = parseArchiveAction(archiveActionEl); + } + + return scheme; +} + +function parseBuildAction(el: Element): BuildAction { + const action: BuildAction = {}; + + action.parallelizeBuildables = getBoolAttr(el, "parallelizeBuildables"); + action.buildImplicitDependencies = getBoolAttr( + el, + "buildImplicitDependencies" + ); + action.runPostActionsOnFailure = getBoolAttr(el, "runPostActionsOnFailure"); + action.buildArchitectures = getAttr(el, "buildArchitectures"); + + const entriesEl = getChildElement(el, "BuildActionEntries"); + if (entriesEl) { + action.entries = getChildElements(entriesEl, "BuildActionEntry").map( + parseBuildActionEntry + ); + } + + action.preActions = parseExecutionActions(el, "PreActions"); + action.postActions = parseExecutionActions(el, "PostActions"); + + return action; +} + +function parseBuildActionEntry(el: Element): BuildActionEntry { + const entry: BuildActionEntry = {}; + + entry.buildForRunning = getBoolAttr(el, "buildForRunning"); + entry.buildForTesting = getBoolAttr(el, "buildForTesting"); + entry.buildForProfiling = getBoolAttr(el, "buildForProfiling"); + entry.buildForArchiving = getBoolAttr(el, "buildForArchiving"); + entry.buildForAnalyzing = getBoolAttr(el, "buildForAnalyzing"); + + const refEl = getChildElement(el, "BuildableReference"); + if (refEl) { + entry.buildableReference = parseBuildableReference(refEl); + } + + return entry; +} + +function parseBuildableReference(el: Element): BuildableReference { + return { + buildableIdentifier: getAttr(el, "BuildableIdentifier") || "primary", + blueprintIdentifier: getAttr(el, "BlueprintIdentifier") || "", + buildableName: getAttr(el, "BuildableName") || "", + blueprintName: getAttr(el, "BlueprintName") || "", + referencedContainer: getAttr(el, "ReferencedContainer") || "", + }; +} + +function parseExecutionActions( + parent: Element, + containerName: string +): ExecutionAction[] | undefined { + const containerEl = getChildElement(parent, containerName); + if (!containerEl) return undefined; + + const actions = getChildElements(containerEl, "ExecutionAction"); + if (actions.length === 0) return undefined; + + return actions.map(parseExecutionAction); +} + +function parseExecutionAction(el: Element): ExecutionAction { + const action: ExecutionAction = {}; + + action.actionType = getAttr(el, "ActionType"); + + const contentEl = getChildElement(el, "ActionContent"); + if (contentEl) { + action.actionContent = parseActionContent(contentEl); + } + + return action; +} + +function parseActionContent(el: Element): ActionContent { + const content: ActionContent = {}; + + content.title = getAttr(el, "title"); + content.scriptText = getAttr(el, "scriptText"); + content.shellToInvoke = getAttr(el, "shellToInvoke"); + + const envBuildableEl = getChildElement(el, "EnvironmentBuildable"); + if (envBuildableEl) { + const refEl = getChildElement(envBuildableEl, "BuildableReference"); + if (refEl) { + content.environmentBuildable = parseBuildableReference(refEl); + } + } + + return content; +} + +function parseTestAction(el: Element): TestAction { + const action: TestAction = {}; + + action.buildConfiguration = getAttr(el, "buildConfiguration"); + action.selectedDebuggerIdentifier = getAttr(el, "selectedDebuggerIdentifier"); + action.selectedLauncherIdentifier = getAttr(el, "selectedLauncherIdentifier"); + action.shouldUseLaunchSchemeArgsEnv = getBoolAttr( + el, + "shouldUseLaunchSchemeArgsEnv" + ); + action.shouldAutocreateTestPlan = getBoolAttr(el, "shouldAutocreateTestPlan"); + action.preferredScreenCaptureFormat = getAttr( + el, + "preferredScreenCaptureFormat" + ); + action.codeCoverageEnabled = getBoolAttr(el, "codeCoverageEnabled"); + action.onlyGenerateCoverageForSpecifiedTargets = getBoolAttr( + el, + "onlyGenerateCoverageForSpecifiedTargets" + ); + + const testablesEl = getChildElement(el, "Testables"); + if (testablesEl) { + action.testables = getChildElements(testablesEl, "TestableReference").map( + parseTestableReference + ); + } + + const testPlansEl = getChildElement(el, "TestPlans"); + if (testPlansEl) { + action.testPlans = getChildElements(testPlansEl, "TestPlanReference").map( + parseTestPlanReference + ); + } + + const macroExpansionEl = getChildElement(el, "MacroExpansion"); + if (macroExpansionEl) { + const refEl = getChildElement(macroExpansionEl, "BuildableReference"); + if (refEl) { + action.macroExpansion = parseBuildableReference(refEl); + } + } + + action.commandLineArguments = parseCommandLineArguments(el); + action.environmentVariables = parseEnvironmentVariables(el); + action.preActions = parseExecutionActions(el, "PreActions"); + action.postActions = parseExecutionActions(el, "PostActions"); + + return action; +} + +function parseTestableReference(el: Element): TestableReference { + const ref: TestableReference = {}; + + ref.skipped = getBoolAttr(el, "skipped"); + ref.parallelizable = getBoolAttr(el, "parallelizable"); + ref.useTestSelectionWhitelist = getBoolAttr(el, "useTestSelectionWhitelist"); + + const buildableRefEl = getChildElement(el, "BuildableReference"); + if (buildableRefEl) { + ref.buildableReference = parseBuildableReference(buildableRefEl); + } + + const skippedTestsEl = getChildElement(el, "SkippedTests"); + if (skippedTestsEl) { + ref.skippedTests = getChildElements(skippedTestsEl, "Test").map( + (testEl): SkippedTest => ({ + identifier: getAttr(testEl, "Identifier") || "", + }) + ); + } + + const selectedTestsEl = getChildElement(el, "SelectedTests"); + if (selectedTestsEl) { + ref.selectedTests = getChildElements(selectedTestsEl, "Test").map( + (testEl): SelectedTest => ({ + identifier: getAttr(testEl, "Identifier") || "", + }) + ); + } + + return ref; +} + +function parseTestPlanReference(el: Element): TestPlanReference { + return { + reference: getAttr(el, "reference") || "", + default: getBoolAttr(el, "default"), + }; +} + +function parseLaunchAction(el: Element): LaunchAction { + const action: LaunchAction = {}; + + action.buildConfiguration = getAttr(el, "buildConfiguration"); + action.selectedDebuggerIdentifier = getAttr(el, "selectedDebuggerIdentifier"); + action.selectedLauncherIdentifier = getAttr(el, "selectedLauncherIdentifier"); + action.launchStyle = getAttr(el, "launchStyle"); + action.useCustomWorkingDirectory = getBoolAttr( + el, + "useCustomWorkingDirectory" + ); + action.customWorkingDirectory = getAttr(el, "customWorkingDirectory"); + action.ignoresPersistentStateOnLaunch = getBoolAttr( + el, + "ignoresPersistentStateOnLaunch" + ); + action.debugDocumentVersioning = getBoolAttr(el, "debugDocumentVersioning"); + action.debugServiceExtension = getAttr(el, "debugServiceExtension"); + action.allowLocationSimulation = getBoolAttr(el, "allowLocationSimulation"); + action.customLaunchCommand = getAttr(el, "customLaunchCommand"); + action.appClipInvocationURLString = getAttr(el, "appClipInvocationURLString"); + action.askForAppToLaunch = getBoolAttr(el, "askForAppToLaunch"); + action.launchAutomaticallySubstyle = getAttr( + el, + "launchAutomaticallySubstyle" + ); + + const runnableEl = getChildElement(el, "BuildableProductRunnable"); + if (runnableEl) { + action.buildableProductRunnable = parseBuildableProductRunnable(runnableEl); + } + + const remoteRunnableEl = getChildElement(el, "RemoteRunnable"); + if (remoteRunnableEl) { + action.remoteRunnable = parseRemoteRunnable(remoteRunnableEl); + } + + const macroExpansionEl = getChildElement(el, "MacroExpansion"); + if (macroExpansionEl) { + const refEl = getChildElement(macroExpansionEl, "BuildableReference"); + if (refEl) { + action.macroExpansion = parseBuildableReference(refEl); + } + } + + const locationEl = getChildElement(el, "LocationScenarioReference"); + if (locationEl) { + action.locationScenarioReference = parseLocationScenarioReference( + locationEl + ); + } + + const storeKitEl = getChildElement(el, "StoreKitConfigurationFileReference"); + if (storeKitEl) { + action.storeKitConfigurationFileReference = + parseStoreKitConfigurationFileReference(storeKitEl); + } + + action.commandLineArguments = parseCommandLineArguments(el); + action.environmentVariables = parseEnvironmentVariables(el); + action.preActions = parseExecutionActions(el, "PreActions"); + action.postActions = parseExecutionActions(el, "PostActions"); + + return action; +} + +function parseBuildableProductRunnable(el: Element): BuildableProductRunnable { + const runnable: BuildableProductRunnable = {}; + + runnable.runnableDebuggingMode = getAttr(el, "runnableDebuggingMode"); + + const refEl = getChildElement(el, "BuildableReference"); + if (refEl) { + runnable.buildableReference = parseBuildableReference(refEl); + } + + return runnable; +} + +function parseRemoteRunnable(el: Element): RemoteRunnable { + const runnable: RemoteRunnable = {}; + + runnable.runnableDebuggingMode = getAttr(el, "runnableDebuggingMode"); + runnable.bundleIdentifier = getAttr(el, "BundleIdentifier"); + runnable.remotePath = getAttr(el, "RemotePath"); + + const refEl = getChildElement(el, "BuildableReference"); + if (refEl) { + runnable.buildableReference = parseBuildableReference(refEl); + } + + return runnable; +} + +function parseLocationScenarioReference(el: Element): LocationScenarioReference { + return { + identifier: getAttr(el, "identifier"), + referenceType: getAttr(el, "referenceType"), + }; +} + +function parseStoreKitConfigurationFileReference( + el: Element +): StoreKitConfigurationFileReference { + return { + identifier: getAttr(el, "identifier"), + }; +} + +function parseProfileAction(el: Element): ProfileAction { + const action: ProfileAction = {}; + + action.buildConfiguration = getAttr(el, "buildConfiguration"); + action.shouldUseLaunchSchemeArgsEnv = getBoolAttr( + el, + "shouldUseLaunchSchemeArgsEnv" + ); + action.savedToolIdentifier = getAttr(el, "savedToolIdentifier"); + action.useCustomWorkingDirectory = getBoolAttr( + el, + "useCustomWorkingDirectory" + ); + action.debugDocumentVersioning = getBoolAttr(el, "debugDocumentVersioning"); + action.askForAppToLaunch = getBoolAttr(el, "askForAppToLaunch"); + action.launchAutomaticallySubstyle = getAttr( + el, + "launchAutomaticallySubstyle" + ); + action.appClipInvocationURLString = getAttr(el, "appClipInvocationURLString"); + + const runnableEl = getChildElement(el, "BuildableProductRunnable"); + if (runnableEl) { + action.buildableProductRunnable = parseBuildableProductRunnable(runnableEl); + } + + action.commandLineArguments = parseCommandLineArguments(el); + action.environmentVariables = parseEnvironmentVariables(el); + action.preActions = parseExecutionActions(el, "PreActions"); + action.postActions = parseExecutionActions(el, "PostActions"); + + return action; +} + +function parseAnalyzeAction(el: Element): AnalyzeAction { + const action: AnalyzeAction = {}; + + action.buildConfiguration = getAttr(el, "buildConfiguration"); + action.preActions = parseExecutionActions(el, "PreActions"); + action.postActions = parseExecutionActions(el, "PostActions"); + + return action; +} + +function parseArchiveAction(el: Element): ArchiveAction { + const action: ArchiveAction = {}; + + action.buildConfiguration = getAttr(el, "buildConfiguration"); + action.customArchiveName = getAttr(el, "customArchiveName"); + action.revealArchiveInOrganizer = getBoolAttr(el, "revealArchiveInOrganizer"); + action.preActions = parseExecutionActions(el, "PreActions"); + action.postActions = parseExecutionActions(el, "PostActions"); + + return action; +} + +function parseCommandLineArguments( + el: Element +): CommandLineArgument[] | undefined { + const argsEl = getChildElement(el, "CommandLineArguments"); + if (!argsEl) return undefined; + + const args = getChildElements(argsEl, "CommandLineArgument"); + if (args.length === 0) return undefined; + + return args.map( + (argEl): CommandLineArgument => ({ + argument: getAttr(argEl, "argument") || "", + isEnabled: getBoolAttr(argEl, "isEnabled"), + }) + ); +} + +function parseEnvironmentVariables( + el: Element +): EnvironmentVariable[] | undefined { + const varsEl = getChildElement(el, "EnvironmentVariables"); + if (!varsEl) return undefined; + + const vars = getChildElements(varsEl, "EnvironmentVariable"); + if (vars.length === 0) return undefined; + + return vars.map( + (varEl): EnvironmentVariable => ({ + key: getAttr(varEl, "key") || "", + value: getAttr(varEl, "value") || "", + isEnabled: getBoolAttr(varEl, "isEnabled"), + }) + ); +} + +// ============================================================================ +// Helper functions +// ============================================================================ + +function getAttr(el: Element, name: string): string | undefined { + if (!el.hasAttribute(name)) return undefined; + return el.getAttribute(name) ?? undefined; +} + +function getBoolAttr(el: Element, name: string): boolean | undefined { + const value = el.getAttribute(name); + if (value === "YES" || value === "Yes") return true; + if (value === "NO" || value === "No") return false; + return undefined; +} + +function getChildElement(parent: Element, tagName: string): Element | null { + const children = parent.childNodes; + for (let i = 0; i < children.length; i++) { + const child = children[i]; + if (child.nodeType === 1 && (child as Element).tagName === tagName) { + return child as Element; + } + } + return null; +} + +function getChildElements(parent: Element, tagName: string): Element[] { + const results: Element[] = []; + const children = parent.childNodes; + for (let i = 0; i < children.length; i++) { + const child = children[i]; + if (child.nodeType === 1 && (child as Element).tagName === tagName) { + results.push(child as Element); + } + } + return results; +} diff --git a/src/scheme/types.ts b/src/scheme/types.ts new file mode 100644 index 0000000..22dcf54 --- /dev/null +++ b/src/scheme/types.ts @@ -0,0 +1,389 @@ +/** + * TypeScript definitions for Xcode scheme files (.xcscheme) + * + * XCScheme files are XML documents that define how targets are built, run, + * tested, profiled, analyzed, and archived in Xcode. + */ + +/** Root scheme object */ +export interface XCScheme { + /** Scheme version, e.g., "1.3", "1.7", "2.0" */ + version?: string; + /** Last Xcode version that upgraded the scheme, e.g., "0830", "1300" */ + lastUpgradeVersion?: string; + /** Build action configuration */ + buildAction?: BuildAction; + /** Test action configuration */ + testAction?: TestAction; + /** Launch/run action configuration */ + launchAction?: LaunchAction; + /** Profile (Instruments) action configuration */ + profileAction?: ProfileAction; + /** Analyze action configuration */ + analyzeAction?: AnalyzeAction; + /** Archive action configuration */ + archiveAction?: ArchiveAction; +} + +/** + * Reference to a buildable target. + * Links scheme actions to specific targets in a project. + */ +export interface BuildableReference { + /** Usually "primary" */ + buildableIdentifier: string; + /** UUID linking to PBXNativeTarget in the project file */ + blueprintIdentifier: string; + /** The product name, e.g., "App.app", "AppTests.xctest" */ + buildableName: string; + /** Target name in the project */ + blueprintName: string; + /** Container reference, e.g., "container:Project.xcodeproj" */ + referencedContainer: string; +} + +/** + * Build action configuration. + * Defines what targets are built and in what configuration. + */ +export interface BuildAction { + /** Whether to build targets in parallel */ + parallelizeBuildables?: boolean; + /** Whether to build implicit dependencies */ + buildImplicitDependencies?: boolean; + /** Whether to run post actions even on build failure */ + runPostActionsOnFailure?: boolean; + /** Build architectures setting: "Automatic", "Native", "Standard" */ + buildArchitectures?: string; + /** List of targets to build */ + entries?: BuildActionEntry[]; + /** Scripts to run before build */ + preActions?: ExecutionAction[]; + /** Scripts to run after build */ + postActions?: ExecutionAction[]; +} + +/** + * Entry in the build action specifying a target and build configurations. + */ +export interface BuildActionEntry { + /** Build this target for running */ + buildForRunning?: boolean; + /** Build this target for testing */ + buildForTesting?: boolean; + /** Build this target for profiling */ + buildForProfiling?: boolean; + /** Build this target for archiving */ + buildForArchiving?: boolean; + /** Build this target for analyzing */ + buildForAnalyzing?: boolean; + /** Reference to the target */ + buildableReference?: BuildableReference; +} + +/** + * Execution action (pre/post action script). + */ +export interface ExecutionAction { + /** Action type, usually "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction" */ + actionType?: string; + /** Action content with script details */ + actionContent?: ActionContent; +} + +/** + * Content of an execution action. + */ +export interface ActionContent { + /** Display title for the action */ + title?: string; + /** Shell script text to execute */ + scriptText?: string; + /** Shell to invoke, e.g., "/bin/sh" */ + shellToInvoke?: string; + /** Target to use for build settings in script environment */ + environmentBuildable?: BuildableReference; +} + +/** + * Test action configuration. + */ +export interface TestAction { + /** Build configuration to use, e.g., "Debug" */ + buildConfiguration?: string; + /** Debugger identifier */ + selectedDebuggerIdentifier?: string; + /** Launcher identifier */ + selectedLauncherIdentifier?: string; + /** Whether to use launch scheme args/env */ + shouldUseLaunchSchemeArgsEnv?: boolean; + /** Whether to auto-create test plan */ + shouldAutocreateTestPlan?: boolean; + /** Preferred screen capture format: "screenshots" or "screenRecordings" */ + preferredScreenCaptureFormat?: string; + /** Enable code coverage */ + codeCoverageEnabled?: boolean; + /** Only generate coverage for specified targets */ + onlyGenerateCoverageForSpecifiedTargets?: boolean; + /** Test targets */ + testables?: TestableReference[]; + /** Test plans */ + testPlans?: TestPlanReference[]; + /** Target for macro expansion */ + macroExpansion?: BuildableReference; + /** Command line arguments */ + commandLineArguments?: CommandLineArgument[]; + /** Environment variables */ + environmentVariables?: EnvironmentVariable[]; + /** Scripts to run before testing */ + preActions?: ExecutionAction[]; + /** Scripts to run after testing */ + postActions?: ExecutionAction[]; +} + +/** + * Reference to a test target. + */ +export interface TestableReference { + /** Whether this testable is skipped */ + skipped?: boolean; + /** Parallelizable flag */ + parallelizable?: boolean; + /** Use test selection whitelist */ + useTestSelectionWhitelist?: boolean; + /** Reference to the test target */ + buildableReference?: BuildableReference; + /** Skipped tests */ + skippedTests?: SkippedTest[]; + /** Selected tests */ + selectedTests?: SelectedTest[]; +} + +/** Skipped test identifier */ +export interface SkippedTest { + identifier: string; +} + +/** Selected test identifier */ +export interface SelectedTest { + identifier: string; +} + +/** + * Reference to a test plan. + */ +export interface TestPlanReference { + /** Reference path, e.g., "container:iOS.xctestplan" */ + reference: string; + /** Whether this is the default test plan */ + default?: boolean; +} + +/** + * Launch action configuration. + */ +export interface LaunchAction { + /** Build configuration to use */ + buildConfiguration?: string; + /** Debugger identifier */ + selectedDebuggerIdentifier?: string; + /** Launcher identifier */ + selectedLauncherIdentifier?: string; + /** Launch style: "0" = auto, "1" = wait for launch, "2" = custom */ + launchStyle?: string; + /** Whether to use custom working directory */ + useCustomWorkingDirectory?: boolean; + /** Custom working directory path */ + customWorkingDirectory?: string; + /** Ignore persistent state on launch */ + ignoresPersistentStateOnLaunch?: boolean; + /** Debug document versioning */ + debugDocumentVersioning?: boolean; + /** Debug service extension */ + debugServiceExtension?: string; + /** Allow location simulation */ + allowLocationSimulation?: boolean; + /** Custom launch command */ + customLaunchCommand?: string; + /** App clip invocation URL */ + appClipInvocationURLString?: string; + /** Ask for app to launch */ + askForAppToLaunch?: boolean; + /** Launch automatically substyle: "2" = watch extension */ + launchAutomaticallySubstyle?: string; + /** Runnable target */ + buildableProductRunnable?: BuildableProductRunnable; + /** Remote runnable for device testing */ + remoteRunnable?: RemoteRunnable; + /** Target for macro expansion */ + macroExpansion?: BuildableReference; + /** Location scenario reference */ + locationScenarioReference?: LocationScenarioReference; + /** StoreKit configuration file reference */ + storeKitConfigurationFileReference?: StoreKitConfigurationFileReference; + /** Command line arguments */ + commandLineArguments?: CommandLineArgument[]; + /** Environment variables */ + environmentVariables?: EnvironmentVariable[]; + /** Scripts to run before launch */ + preActions?: ExecutionAction[]; + /** Scripts to run after launch */ + postActions?: ExecutionAction[]; +} + +/** + * Runnable product reference. + */ +export interface BuildableProductRunnable { + /** Debugging mode: "0" = normal, "1" = remote */ + runnableDebuggingMode?: string; + /** Reference to the buildable target */ + buildableReference?: BuildableReference; +} + +/** + * Remote runnable for device testing without build. + */ +export interface RemoteRunnable { + /** Debugging mode */ + runnableDebuggingMode?: string; + /** Bundle identifier of the app */ + bundleIdentifier?: string; + /** Remote path to the app on device */ + remotePath?: string; + /** Reference to the buildable target */ + buildableReference?: BuildableReference; +} + +/** + * Location scenario reference for location simulation. + */ +export interface LocationScenarioReference { + /** Scenario identifier */ + identifier?: string; + /** Reference type */ + referenceType?: string; +} + +/** + * StoreKit configuration file reference. + */ +export interface StoreKitConfigurationFileReference { + /** Path to StoreKit configuration file */ + identifier?: string; +} + +/** + * Profile action configuration. + */ +export interface ProfileAction { + /** Build configuration to use, typically "Release" */ + buildConfiguration?: string; + /** Whether to use launch scheme args/env */ + shouldUseLaunchSchemeArgsEnv?: boolean; + /** Saved tool identifier */ + savedToolIdentifier?: string; + /** Whether to use custom working directory */ + useCustomWorkingDirectory?: boolean; + /** Debug document versioning */ + debugDocumentVersioning?: boolean; + /** Ask for app to launch */ + askForAppToLaunch?: boolean; + /** Launch automatically substyle */ + launchAutomaticallySubstyle?: string; + /** App clip invocation URL */ + appClipInvocationURLString?: string; + /** Runnable target */ + buildableProductRunnable?: BuildableProductRunnable; + /** Command line arguments */ + commandLineArguments?: CommandLineArgument[]; + /** Environment variables */ + environmentVariables?: EnvironmentVariable[]; + /** Scripts to run before profiling */ + preActions?: ExecutionAction[]; + /** Scripts to run after profiling */ + postActions?: ExecutionAction[]; +} + +/** + * Analyze action configuration. + */ +export interface AnalyzeAction { + /** Build configuration to use */ + buildConfiguration?: string; + /** Scripts to run before analyzing */ + preActions?: ExecutionAction[]; + /** Scripts to run after analyzing */ + postActions?: ExecutionAction[]; +} + +/** + * Archive action configuration. + */ +export interface ArchiveAction { + /** Build configuration to use, typically "Release" */ + buildConfiguration?: string; + /** Custom archive name */ + customArchiveName?: string; + /** Reveal archive in Organizer after archiving */ + revealArchiveInOrganizer?: boolean; + /** Scripts to run before archiving */ + preActions?: ExecutionAction[]; + /** Scripts to run after archiving */ + postActions?: ExecutionAction[]; +} + +/** + * Command line argument for launch/test actions. + */ +export interface CommandLineArgument { + /** The argument string */ + argument: string; + /** Whether this argument is enabled */ + isEnabled?: boolean; +} + +/** + * Environment variable for launch/test actions. + */ +export interface EnvironmentVariable { + /** Variable name */ + key: string; + /** Variable value */ + value: string; + /** Whether this variable is enabled */ + isEnabled?: boolean; +} + +// ============================================================================ +// XCSchemeManagement types (xcschememanagement.plist) +// ============================================================================ + +/** + * Root object for xcschememanagement.plist files. + * Manages scheme visibility and ordering in Xcode. + */ +export interface XCSchemeManagement { + /** State for each scheme (visibility and order) */ + SchemeUserState?: Record; + /** Suppress automatic scheme creation for targets */ + SuppressBuildableAutocreation?: Record; +} + +/** + * User state for a scheme. + */ +export interface SchemeUserState { + /** Whether the scheme is shown in the scheme selector */ + isShown?: boolean; + /** Order hint for sorting schemes */ + orderHint?: number; +} + +/** + * Settings to suppress automatic scheme creation for a target. + */ +export interface SuppressBuildableAutocreation { + /** Whether to suppress primary scheme autocreation */ + primary?: boolean; +} diff --git a/src/scheme/writer.ts b/src/scheme/writer.ts new file mode 100644 index 0000000..b460d9e --- /dev/null +++ b/src/scheme/writer.ts @@ -0,0 +1,886 @@ +/** + * Writer for Xcode scheme files (.xcscheme) + * + * Serializes TypeScript objects back to XML format. + */ +import type { + XCScheme, + BuildAction, + BuildActionEntry, + BuildableReference, + ExecutionAction, + ActionContent, + TestAction, + TestableReference, + TestPlanReference, + LaunchAction, + BuildableProductRunnable, + RemoteRunnable, + LocationScenarioReference, + StoreKitConfigurationFileReference, + ProfileAction, + AnalyzeAction, + ArchiveAction, + CommandLineArgument, + EnvironmentVariable, +} from "./types"; + +/** + * Build an xcscheme XML string from a typed XCScheme object. + */ +export function build(scheme: XCScheme): string { + const lines: string[] = []; + + lines.push(''); + lines.push(" ` ${a}`)); + lines[lines.length - 1] += ">"; + + if (scheme.buildAction) { + lines.push(...buildBuildAction(scheme.buildAction, 1)); + } + + if (scheme.testAction) { + lines.push(...buildTestAction(scheme.testAction, 1)); + } + + if (scheme.launchAction) { + lines.push(...buildLaunchAction(scheme.launchAction, 1)); + } + + if (scheme.profileAction) { + lines.push(...buildProfileAction(scheme.profileAction, 1)); + } + + if (scheme.analyzeAction) { + lines.push(...buildAnalyzeAction(scheme.analyzeAction, 1)); + } + + if (scheme.archiveAction) { + lines.push(...buildArchiveAction(scheme.archiveAction, 1)); + } + + lines.push(""); + + return lines.join("\n") + "\n"; +} + +function buildSchemeAttributes(scheme: XCScheme): string[] { + const attrs: string[] = []; + + if (scheme.lastUpgradeVersion !== undefined) { + attrs.push(`LastUpgradeVersion = "${scheme.lastUpgradeVersion}"`); + } + if (scheme.version !== undefined) { + attrs.push(`version = "${scheme.version}"`); + } + + return attrs; +} + +function buildBuildAction(action: BuildAction, depth: number): string[] { + const lines: string[] = []; + const indent = getIndent(depth); + const attrs: string[] = []; + + if (action.parallelizeBuildables !== undefined) { + attrs.push( + `parallelizeBuildables = "${boolToString(action.parallelizeBuildables)}"` + ); + } + if (action.buildImplicitDependencies !== undefined) { + attrs.push( + `buildImplicitDependencies = "${boolToString( + action.buildImplicitDependencies + )}"` + ); + } + if (action.runPostActionsOnFailure !== undefined) { + attrs.push( + `runPostActionsOnFailure = "${boolToString( + action.runPostActionsOnFailure + )}"` + ); + } + if (action.buildArchitectures !== undefined) { + attrs.push(`buildArchitectures = "${action.buildArchitectures}"`); + } + + lines.push(`${indent} 0) { + lines.push(...buildExecutionActions("PreActions", action.preActions, depth + 1)); + } + + if (action.postActions && action.postActions.length > 0) { + lines.push(...buildExecutionActions("PostActions", action.postActions, depth + 1)); + } + + if (action.entries && action.entries.length > 0) { + lines.push(`${getIndent(depth + 1)}`); + for (const entry of action.entries) { + lines.push(...buildBuildActionEntry(entry, depth + 2)); + } + lines.push(`${getIndent(depth + 1)}`); + } + + lines.push(`${indent}`); + + return lines; +} + +function buildBuildActionEntry( + entry: BuildActionEntry, + depth: number +): string[] { + const lines: string[] = []; + const indent = getIndent(depth); + const attrs: string[] = []; + + if (entry.buildForTesting !== undefined) { + attrs.push(`buildForTesting = "${boolToString(entry.buildForTesting)}"`); + } + if (entry.buildForRunning !== undefined) { + attrs.push(`buildForRunning = "${boolToString(entry.buildForRunning)}"`); + } + if (entry.buildForProfiling !== undefined) { + attrs.push(`buildForProfiling = "${boolToString(entry.buildForProfiling)}"`); + } + if (entry.buildForArchiving !== undefined) { + attrs.push(`buildForArchiving = "${boolToString(entry.buildForArchiving)}"`); + } + if (entry.buildForAnalyzing !== undefined) { + attrs.push(`buildForAnalyzing = "${boolToString(entry.buildForAnalyzing)}"`); + } + + lines.push(`${indent}`); + + return lines; +} + +function buildBuildableReference( + ref: BuildableReference, + depth: number +): string[] { + const lines: string[] = []; + const indent = getIndent(depth); + + lines.push(`${indent}` + ); + lines.push(`${indent}`); + + return lines; +} + +function buildExecutionActions( + containerName: string, + actions: ExecutionAction[], + depth: number +): string[] { + const lines: string[] = []; + const indent = getIndent(depth); + + lines.push(`${indent}<${containerName}>`); + + for (const action of actions) { + lines.push(...buildExecutionAction(action, depth + 1)); + } + + lines.push(`${indent}`); + + return lines; +} + +function buildExecutionAction(action: ExecutionAction, depth: number): string[] { + const lines: string[] = []; + const indent = getIndent(depth); + + lines.push(`${indent}`); + } else { + lines[lines.length - 1] += ">"; + } + + if (action.actionContent) { + lines.push(...buildActionContent(action.actionContent, depth + 1)); + } + + lines.push(`${indent}`); + + return lines; +} + +function buildActionContent(content: ActionContent, depth: number): string[] { + const lines: string[] = []; + const indent = getIndent(depth); + const attrs: string[] = []; + + if (content.title !== undefined) { + attrs.push(`title = "${escapeXml(content.title)}"`); + } + if (content.scriptText !== undefined) { + attrs.push(`scriptText = "${escapeXml(content.scriptText)}"`); + } + if (content.shellToInvoke !== undefined) { + attrs.push(`shellToInvoke = "${content.shellToInvoke}"`); + } + + lines.push(`${indent}`); + lines.push(...buildBuildableReference(content.environmentBuildable, depth + 2)); + lines.push(`${getIndent(depth + 1)}`); + lines.push(`${indent}`); + } else { + lines[lines.length - 1] += ">"; + lines.push(`${indent}`); + } + + return lines; +} + +function buildTestAction(action: TestAction, depth: number): string[] { + const lines: string[] = []; + const indent = getIndent(depth); + const attrs: string[] = []; + + if (action.buildConfiguration !== undefined) { + attrs.push(`buildConfiguration = "${action.buildConfiguration}"`); + } + if (action.selectedDebuggerIdentifier !== undefined) { + attrs.push( + `selectedDebuggerIdentifier = "${action.selectedDebuggerIdentifier}"` + ); + } + if (action.selectedLauncherIdentifier !== undefined) { + attrs.push( + `selectedLauncherIdentifier = "${action.selectedLauncherIdentifier}"` + ); + } + if (action.shouldUseLaunchSchemeArgsEnv !== undefined) { + attrs.push( + `shouldUseLaunchSchemeArgsEnv = "${boolToString( + action.shouldUseLaunchSchemeArgsEnv + )}"` + ); + } + if (action.shouldAutocreateTestPlan !== undefined) { + attrs.push( + `shouldAutocreateTestPlan = "${boolToString( + action.shouldAutocreateTestPlan + )}"` + ); + } + if (action.preferredScreenCaptureFormat !== undefined) { + attrs.push( + `preferredScreenCaptureFormat = "${action.preferredScreenCaptureFormat}"` + ); + } + if (action.codeCoverageEnabled !== undefined) { + attrs.push( + `codeCoverageEnabled = "${boolToString(action.codeCoverageEnabled)}"` + ); + } + if (action.onlyGenerateCoverageForSpecifiedTargets !== undefined) { + attrs.push( + `onlyGenerateCoverageForSpecifiedTargets = "${boolToString( + action.onlyGenerateCoverageForSpecifiedTargets + )}"` + ); + } + + lines.push(`${indent} 0) { + lines.push(...buildExecutionActions("PreActions", action.preActions, depth + 1)); + } + + if (action.postActions && action.postActions.length > 0) { + lines.push(...buildExecutionActions("PostActions", action.postActions, depth + 1)); + } + + if (action.testPlans && action.testPlans.length > 0) { + lines.push(`${getIndent(depth + 1)}`); + for (const plan of action.testPlans) { + lines.push(...buildTestPlanReference(plan, depth + 2)); + } + lines.push(`${getIndent(depth + 1)}`); + } + + if (action.macroExpansion) { + lines.push(`${getIndent(depth + 1)}`); + lines.push(...buildBuildableReference(action.macroExpansion, depth + 2)); + lines.push(`${getIndent(depth + 1)}`); + } + + if (action.testables && action.testables.length > 0) { + lines.push(`${getIndent(depth + 1)}`); + for (const testable of action.testables) { + lines.push(...buildTestableReference(testable, depth + 2)); + } + lines.push(`${getIndent(depth + 1)}`); + } else if (action.testables !== undefined) { + // Output empty Testables if explicitly set + lines.push(`${getIndent(depth + 1)}`); + lines.push(`${getIndent(depth + 1)}`); + } + + if (action.commandLineArguments && action.commandLineArguments.length > 0) { + lines.push(...buildCommandLineArguments(action.commandLineArguments, depth + 1)); + } + + if (action.environmentVariables && action.environmentVariables.length > 0) { + lines.push(...buildEnvironmentVariables(action.environmentVariables, depth + 1)); + } + + lines.push(`${indent}`); + + return lines; +} + +function buildTestableReference( + ref: TestableReference, + depth: number +): string[] { + const lines: string[] = []; + const indent = getIndent(depth); + const attrs: string[] = []; + + if (ref.skipped !== undefined) { + attrs.push(`skipped = "${boolToString(ref.skipped)}"`); + } + if (ref.parallelizable !== undefined) { + attrs.push(`parallelizable = "${boolToString(ref.parallelizable)}"`); + } + if (ref.useTestSelectionWhitelist !== undefined) { + attrs.push( + `useTestSelectionWhitelist = "${boolToString( + ref.useTestSelectionWhitelist + )}"` + ); + } + + lines.push(`${indent} 0) { + lines.push(`${getIndent(depth + 1)}`); + for (const test of ref.skippedTests) { + lines.push(`${getIndent(depth + 2)}`); + lines.push(`${getIndent(depth + 2)}`); + } + lines.push(`${getIndent(depth + 1)}`); + } + + if (ref.selectedTests && ref.selectedTests.length > 0) { + lines.push(`${getIndent(depth + 1)}`); + for (const test of ref.selectedTests) { + lines.push(`${getIndent(depth + 2)}`); + lines.push(`${getIndent(depth + 2)}`); + } + lines.push(`${getIndent(depth + 1)}`); + } + + lines.push(`${indent}`); + + return lines; +} + +function buildTestPlanReference(ref: TestPlanReference, depth: number): string[] { + const lines: string[] = []; + const indent = getIndent(depth); + + lines.push(`${indent}`); + lines.push(`${indent}`); + + return lines; +} + +function buildLaunchAction(action: LaunchAction, depth: number): string[] { + const lines: string[] = []; + const indent = getIndent(depth); + const attrs: string[] = []; + + if (action.buildConfiguration !== undefined) { + attrs.push(`buildConfiguration = "${action.buildConfiguration}"`); + } + if (action.appClipInvocationURLString !== undefined) { + attrs.push( + `appClipInvocationURLString = "${escapeXml(action.appClipInvocationURLString)}"` + ); + } + if (action.selectedDebuggerIdentifier !== undefined) { + attrs.push( + `selectedDebuggerIdentifier = "${action.selectedDebuggerIdentifier}"` + ); + } + if (action.selectedLauncherIdentifier !== undefined) { + attrs.push( + `selectedLauncherIdentifier = "${action.selectedLauncherIdentifier}"` + ); + } + if (action.launchStyle !== undefined) { + attrs.push(`launchStyle = "${action.launchStyle}"`); + } + if (action.askForAppToLaunch !== undefined) { + attrs.push( + `askForAppToLaunch = "${action.askForAppToLaunch ? "Yes" : "No"}"` + ); + } + if (action.useCustomWorkingDirectory !== undefined) { + attrs.push( + `useCustomWorkingDirectory = "${boolToString( + action.useCustomWorkingDirectory + )}"` + ); + } + if (action.customWorkingDirectory !== undefined) { + attrs.push(`customWorkingDirectory = "${action.customWorkingDirectory}"`); + } + if (action.ignoresPersistentStateOnLaunch !== undefined) { + attrs.push( + `ignoresPersistentStateOnLaunch = "${boolToString( + action.ignoresPersistentStateOnLaunch + )}"` + ); + } + if (action.debugDocumentVersioning !== undefined) { + attrs.push( + `debugDocumentVersioning = "${boolToString( + action.debugDocumentVersioning + )}"` + ); + } + if (action.debugServiceExtension !== undefined) { + attrs.push(`debugServiceExtension = "${action.debugServiceExtension}"`); + } + if (action.allowLocationSimulation !== undefined) { + attrs.push( + `allowLocationSimulation = "${boolToString( + action.allowLocationSimulation + )}"` + ); + } + if (action.customLaunchCommand !== undefined) { + attrs.push(`customLaunchCommand = "${escapeXml(action.customLaunchCommand)}"`); + } + if (action.launchAutomaticallySubstyle !== undefined) { + attrs.push( + `launchAutomaticallySubstyle = "${action.launchAutomaticallySubstyle}"` + ); + } + + lines.push(`${indent} 0) { + lines.push(...buildExecutionActions("PreActions", action.preActions, depth + 1)); + } + + if (action.postActions && action.postActions.length > 0) { + lines.push(...buildExecutionActions("PostActions", action.postActions, depth + 1)); + } + + if (action.buildableProductRunnable) { + lines.push( + ...buildBuildableProductRunnable(action.buildableProductRunnable, depth + 1) + ); + } + + if (action.remoteRunnable) { + lines.push(...buildRemoteRunnable(action.remoteRunnable, depth + 1)); + } + + if (action.macroExpansion) { + lines.push(`${getIndent(depth + 1)}`); + lines.push(...buildBuildableReference(action.macroExpansion, depth + 2)); + lines.push(`${getIndent(depth + 1)}`); + } + + if (action.locationScenarioReference) { + lines.push( + ...buildLocationScenarioReference(action.locationScenarioReference, depth + 1) + ); + } + + if (action.commandLineArguments && action.commandLineArguments.length > 0) { + lines.push(...buildCommandLineArguments(action.commandLineArguments, depth + 1)); + } + + if (action.environmentVariables && action.environmentVariables.length > 0) { + lines.push(...buildEnvironmentVariables(action.environmentVariables, depth + 1)); + } + + if (action.storeKitConfigurationFileReference) { + lines.push( + ...buildStoreKitConfigurationFileReference( + action.storeKitConfigurationFileReference, + depth + 1 + ) + ); + } + + lines.push(`${indent}`); + + return lines; +} + +function buildBuildableProductRunnable( + runnable: BuildableProductRunnable, + depth: number +): string[] { + const lines: string[] = []; + const indent = getIndent(depth); + + lines.push(`${indent}` + ); + } else { + lines[lines.length - 1] += ">"; + } + + if (runnable.buildableReference) { + lines.push(...buildBuildableReference(runnable.buildableReference, depth + 1)); + } + + lines.push(`${indent}`); + + return lines; +} + +function buildRemoteRunnable(runnable: RemoteRunnable, depth: number): string[] { + const lines: string[] = []; + const indent = getIndent(depth); + const attrs: string[] = []; + + if (runnable.runnableDebuggingMode !== undefined) { + attrs.push(`runnableDebuggingMode = "${runnable.runnableDebuggingMode}"`); + } + if (runnable.bundleIdentifier !== undefined) { + attrs.push(`BundleIdentifier = "${runnable.bundleIdentifier}"`); + } + if (runnable.remotePath !== undefined) { + attrs.push(`RemotePath = "${runnable.remotePath}"`); + } + + lines.push(`${indent}`); + + return lines; +} + +function buildLocationScenarioReference( + ref: LocationScenarioReference, + depth: number +): string[] { + const lines: string[] = []; + const indent = getIndent(depth); + + lines.push(`${indent}`); + } else { + lines[lines.length - 1] += ">"; + } + lines.push(`${indent}`); + + return lines; +} + +function buildStoreKitConfigurationFileReference( + ref: StoreKitConfigurationFileReference, + depth: number +): string[] { + const lines: string[] = []; + const indent = getIndent(depth); + + lines.push(`${indent}`); + } else { + lines[lines.length - 1] += ">"; + } + lines.push(`${indent}`); + + return lines; +} + +function buildProfileAction(action: ProfileAction, depth: number): string[] { + const lines: string[] = []; + const indent = getIndent(depth); + const attrs: string[] = []; + + if (action.askForAppToLaunch !== undefined) { + attrs.push( + `askForAppToLaunch = "${action.askForAppToLaunch ? "YES" : "NO"}"` + ); + } + if (action.buildConfiguration !== undefined) { + attrs.push(`buildConfiguration = "${action.buildConfiguration}"`); + } + if (action.appClipInvocationURLString !== undefined) { + attrs.push( + `appClipInvocationURLString = "${escapeXml(action.appClipInvocationURLString)}"` + ); + } + if (action.shouldUseLaunchSchemeArgsEnv !== undefined) { + attrs.push( + `shouldUseLaunchSchemeArgsEnv = "${boolToString( + action.shouldUseLaunchSchemeArgsEnv + )}"` + ); + } + if (action.savedToolIdentifier !== undefined) { + attrs.push(`savedToolIdentifier = "${action.savedToolIdentifier}"`); + } + if (action.useCustomWorkingDirectory !== undefined) { + attrs.push( + `useCustomWorkingDirectory = "${boolToString( + action.useCustomWorkingDirectory + )}"` + ); + } + if (action.debugDocumentVersioning !== undefined) { + attrs.push( + `debugDocumentVersioning = "${boolToString( + action.debugDocumentVersioning + )}"` + ); + } + if (action.launchAutomaticallySubstyle !== undefined) { + attrs.push( + `launchAutomaticallySubstyle = "${action.launchAutomaticallySubstyle}"` + ); + } + + lines.push(`${indent} 0) { + lines.push(...buildExecutionActions("PreActions", action.preActions, depth + 1)); + } + + if (action.postActions && action.postActions.length > 0) { + lines.push(...buildExecutionActions("PostActions", action.postActions, depth + 1)); + } + + if (action.buildableProductRunnable) { + lines.push( + ...buildBuildableProductRunnable(action.buildableProductRunnable, depth + 1) + ); + } + + if (action.commandLineArguments && action.commandLineArguments.length > 0) { + lines.push(...buildCommandLineArguments(action.commandLineArguments, depth + 1)); + } + + if (action.environmentVariables && action.environmentVariables.length > 0) { + lines.push(...buildEnvironmentVariables(action.environmentVariables, depth + 1)); + } + + lines.push(`${indent}`); + + return lines; +} + +function buildAnalyzeAction(action: AnalyzeAction, depth: number): string[] { + const lines: string[] = []; + const indent = getIndent(depth); + + lines.push(`${indent}`); + } else { + lines[lines.length - 1] += ">"; + } + + if (action.preActions && action.preActions.length > 0) { + lines.push(...buildExecutionActions("PreActions", action.preActions, depth + 1)); + } + + if (action.postActions && action.postActions.length > 0) { + lines.push(...buildExecutionActions("PostActions", action.postActions, depth + 1)); + } + + lines.push(`${indent}`); + + return lines; +} + +function buildArchiveAction(action: ArchiveAction, depth: number): string[] { + const lines: string[] = []; + const indent = getIndent(depth); + const attrs: string[] = []; + + if (action.buildConfiguration !== undefined) { + attrs.push(`buildConfiguration = "${action.buildConfiguration}"`); + } + if (action.customArchiveName !== undefined) { + attrs.push(`customArchiveName = "${escapeXml(action.customArchiveName)}"`); + } + if (action.revealArchiveInOrganizer !== undefined) { + attrs.push( + `revealArchiveInOrganizer = "${boolToString( + action.revealArchiveInOrganizer + )}"` + ); + } + + lines.push(`${indent} 0) { + lines.push(...buildExecutionActions("PreActions", action.preActions, depth + 1)); + } + + if (action.postActions && action.postActions.length > 0) { + lines.push(...buildExecutionActions("PostActions", action.postActions, depth + 1)); + } + + lines.push(`${indent}`); + + return lines; +} + +function buildCommandLineArguments( + args: CommandLineArgument[], + depth: number +): string[] { + const lines: string[] = []; + const indent = getIndent(depth); + + lines.push(`${indent}`); + + for (const arg of args) { + lines.push(`${getIndent(depth + 1)}` + ); + } else { + lines[lines.length - 1] += ">"; + } + lines.push(`${getIndent(depth + 1)}`); + } + + lines.push(`${indent}`); + + return lines; +} + +function buildEnvironmentVariables( + vars: EnvironmentVariable[], + depth: number +): string[] { + const lines: string[] = []; + const indent = getIndent(depth); + + lines.push(`${indent}`); + + for (const v of vars) { + lines.push(`${getIndent(depth + 1)}` + ); + } else { + lines[lines.length - 1] += ">"; + } + lines.push(`${getIndent(depth + 1)}`); + } + + lines.push(`${indent}`); + + return lines; +} + +// ============================================================================ +// Helper functions +// ============================================================================ + +function getIndent(depth: number): string { + return " ".repeat(depth); +} + +function boolToString(value: boolean): string { + return value ? "YES" : "NO"; +} + +function escapeXml(str: string): string { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +}