From 4c10e303bf013aa7edfd57277f05f3d28736f7ce Mon Sep 17 00:00:00 2001 From: evanbacon Date: Fri, 27 Feb 2026 12:38:57 -0800 Subject: [PATCH] Add XCSharedData support for breakpoints and workspace settings Implements unified access to xcshareddata directories containing: - Breakpoints (Breakpoints_v2.xcbkptlist) - file, symbolic, exception breakpoints - Workspace settings (WorkspaceSettings.xcsettings) - build system, derived data, previews - Integration with existing scheme support New modules: - src/breakpoints/ - XML parser/writer for breakpoint lists - src/settings/ - Plist parser/writer for workspace settings - src/api/XCSharedData.ts - High-level API unifying schemes, breakpoints, settings Adds getSharedData() method to XcodeProject and XCWorkspace for easy access. Co-Authored-By: Claude Opus 4.5 --- README.md | 116 +++++- package.json | 8 + src/api/XCSharedData.ts | 341 ++++++++++++++++++ src/api/XCWorkspace.ts | 31 ++ src/api/XcodeProject.ts | 29 ++ src/api/__tests__/XCSharedData.test.ts | 310 ++++++++++++++++ src/api/index.ts | 1 + src/breakpoints/__tests__/breakpoints.test.ts | 225 ++++++++++++ .../fixtures/Breakpoints_v2.xcbkptlist | 79 ++++ src/breakpoints/index.ts | 29 ++ src/breakpoints/parser.ts | 203 +++++++++++ src/breakpoints/types.ts | 143 ++++++++ src/breakpoints/writer.ts | 316 ++++++++++++++++ .../fixtures/WorkspaceSettings.xcsettings | 16 + src/settings/__tests__/settings.test.ts | 110 ++++++ src/settings/index.ts | 22 ++ src/settings/parser.ts | 55 +++ src/settings/types.ts | 35 ++ src/settings/writer.ts | 54 +++ 19 files changed, 2117 insertions(+), 6 deletions(-) create mode 100644 src/api/XCSharedData.ts create mode 100644 src/api/__tests__/XCSharedData.test.ts create mode 100644 src/breakpoints/__tests__/breakpoints.test.ts create mode 100644 src/breakpoints/__tests__/fixtures/Breakpoints_v2.xcbkptlist create mode 100644 src/breakpoints/index.ts create mode 100644 src/breakpoints/parser.ts create mode 100644 src/breakpoints/types.ts create mode 100644 src/breakpoints/writer.ts create mode 100644 src/settings/__tests__/fixtures/WorkspaceSettings.xcsettings create mode 100644 src/settings/__tests__/settings.test.ts create mode 100644 src/settings/index.ts create mode 100644 src/settings/parser.ts create mode 100644 src/settings/types.ts create mode 100644 src/settings/writer.ts diff --git a/README.md b/README.md index 8af86e0..387151c 100644 --- a/README.md +++ b/README.md @@ -355,6 +355,111 @@ PRODUCT_BUNDLE_IDENTIFIER = $(BUNDLE_ID_PREFIX).$(PRODUCT_NAME:lower) OTHER_LDFLAGS = $(inherited) -framework UIKit ``` +## XCSharedData Support + +Access and manipulate shared data directories (`xcshareddata`) which contain schemes, breakpoints, and workspace settings that are intended for version control. + +### High-level API + +```ts +import { XcodeProject, XCSharedData } from "@bacons/xcode"; + +// Get shared data from a project +const project = XcodeProject.open("/path/to/project.pbxproj"); +const sharedData = project.getSharedData(); + +// Access schemes +const schemes = sharedData.getSchemes(); +const appScheme = sharedData.getScheme("App"); + +// Access breakpoints +if (sharedData.breakpoints) { + console.log(sharedData.breakpoints.breakpoints?.length); +} + +// Access workspace settings +if (sharedData.workspaceSettings) { + console.log(sharedData.workspaceSettings.PreviewsEnabled); +} + +// Modify and save +sharedData.workspaceSettings = { + PreviewsEnabled: true, + IDEWorkspaceSharedSettings_AutocreateContextsIfNeeded: false, +}; +sharedData.save(); +``` + +### Breakpoints API + +Parse and build Xcode breakpoint files (`Breakpoints_v2.xcbkptlist`): + +```ts +import * as breakpoints from "@bacons/xcode/breakpoints"; +import fs from "fs"; + +// Parse breakpoint file +const xml = fs.readFileSync( + "/path/to/xcshareddata/xcdebugger/Breakpoints_v2.xcbkptlist", + "utf-8", +); +const list = breakpoints.parse(xml); + +// Access breakpoints +for (const bp of list.breakpoints ?? []) { + console.log(bp.breakpointExtensionID); // "Xcode.Breakpoint.FileBreakpoint" + console.log(bp.breakpointContent?.filePath); + console.log(bp.breakpointContent?.startingLineNumber); +} + +// Add a new breakpoint +list.breakpoints?.push({ + breakpointExtensionID: "Xcode.Breakpoint.FileBreakpoint", + breakpointContent: { + uuid: "new-uuid", + shouldBeEnabled: true, + filePath: "MyApp/ViewController.swift", + startingLineNumber: "42", + endingLineNumber: "42", + actions: [ + { + actionExtensionID: "Xcode.BreakpointAction.DebuggerCommand", + actionContent: { consoleCommand: "po self" }, + }, + ], + }, +}); + +// Serialize back to XML +const outputXml = breakpoints.build(list); +``` + +### Workspace Settings API + +Parse and build workspace settings files (`WorkspaceSettings.xcsettings`): + +```ts +import * as settings from "@bacons/xcode/settings"; +import fs from "fs"; + +// Parse settings file +const plist = fs.readFileSync( + "/path/to/xcshareddata/WorkspaceSettings.xcsettings", + "utf-8", +); +const config = settings.parse(plist); + +console.log(config.BuildSystemType); // "Original" or "New" +console.log(config.PreviewsEnabled); // true/false + +// Modify and save +config.PreviewsEnabled = true; +config.IDEWorkspaceSharedSettings_AutocreateContextsIfNeeded = false; + +const outputPlist = settings.build(config); +fs.writeFileSync("/path/to/WorkspaceSettings.xcsettings", outputPlist); +``` + ## Solution - Uses a hand-optimized single-pass parser that is 11x faster than the legacy `xcode` package (which uses PEG.js). @@ -382,15 +487,14 @@ We support the following types: `Object`, `Array`, `Data`, `String`. Notably, we - [x] xcscheme support. - [x] Benchmarks (`bun run bench`). - [x] xcworkspace support. +- [x] **XCConfig** Parsing: `.xcconfig` file parsing with `#include` support and build settings flattening. +- [x] **XCSharedData**: Shared project data directory (schemes, breakpoints, workspace settings). +- [x] **XCSchemeManagement**: Scheme ordering, visibility, and management plist. +- [x] **WorkspaceSettings**: (`xcshareddata/WorkspaceSettings.xcsettings`) Derived data location, build system version, auto-create schemes setting. +- [x] **XCBreakpointList**: (`xcshareddata/xcdebugger/Breakpoints_v2.xcbkptlist`) Shared debugger breakpoints (file, symbolic, exception breakpoints). - [ ] Create robust xcode projects from scratch. - [ ] Skills. - [ ] Import from other tools. -- [x] **XCConfig** Parsing: `.xcconfig` file parsing with `#include` support and build settings flattening. -- [ ] **XCSharedData**: Shared project data directory (schemes, breakpoints, workspace settings). -- [ ] **XCSchemeManagement**: Scheme ordering, visibility, and management plist. Controls which schemes appear and in what order in Xcode. -- [ ] **XCUserData**: User-specific data (breakpoints, UI state). Useful for tooling that manages user preferences. -- [ ] **WorkspaceSettings**: (`xcshareddata/WorkspaceSettings.xcsettings`) Derived data location, build system version, auto-create schemes setting. -- [ ] **XCBreakpointList**: (`xcshareddata/xcdebugger/Breakpoints_v2.xcbkptlist`) Shared debugger breakpoints (file, symbolic, exception breakpoints) - [ ] **XCUserData**: (`xcuserdata/.xcuserdatad/`) Per-user schemes, breakpoints, UI state. - [ ] **IDEWorkspaceChecks**: (`xcshareddata/IDEWorkspaceChecks.plist`) "Trust this project" flag that suppresses Xcode warning. diff --git a/package.json b/package.json index 0dd870a..39daee4 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,14 @@ "./xcconfig": { "types": "./build/xcconfig/index.d.ts", "default": "./build/xcconfig/index.js" + }, + "./breakpoints": { + "types": "./build/breakpoints/index.d.ts", + "default": "./build/breakpoints/index.js" + }, + "./settings": { + "types": "./build/settings/index.d.ts", + "default": "./build/settings/index.js" } }, "files": [ diff --git a/src/api/XCSharedData.ts b/src/api/XCSharedData.ts new file mode 100644 index 0000000..be14900 --- /dev/null +++ b/src/api/XCSharedData.ts @@ -0,0 +1,341 @@ +/** + * High-level API for Xcode shared data directories (xcshareddata). + * + * Provides unified access to schemes, breakpoints, and workspace settings + * stored in xcshareddata directories of .xcodeproj or .xcworkspace bundles. + */ +import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from "fs"; +import path from "path"; + +import * as breakpoints from "../breakpoints"; +import * as settings from "../settings"; +import * as scheme from "../scheme"; +import type { XCBreakpointList } from "../breakpoints/types"; +import type { WorkspaceSettings } from "../settings/types"; +import type { XCSchemeManagement } from "../scheme/types"; +import { XCScheme } from "./XCScheme"; + +/** + * High-level class for working with Xcode shared data directories. + * + * Shared data includes: + * - Schemes (xcschemes/*.xcscheme) + * - Scheme management (xcschemes/xcschememanagement.plist) + * - Breakpoints (xcdebugger/Breakpoints_v2.xcbkptlist) + * - Workspace settings (WorkspaceSettings.xcsettings) + */ +export class XCSharedData { + /** Path to the xcshareddata directory (may be undefined for new instances) */ + filePath?: string; + + /** Cached breakpoints data */ + private _breakpoints?: XCBreakpointList; + private _breakpointsLoaded = false; + + /** Cached workspace settings data */ + private _workspaceSettings?: WorkspaceSettings; + private _workspaceSettingsLoaded = false; + + /** Cached scheme management data */ + private _schemeManagement?: XCSchemeManagement; + private _schemeManagementLoaded = false; + + private constructor(filePath?: string) { + this.filePath = filePath; + } + + /** + * Open an xcshareddata directory. + * + * @param sharedDataPath Path to the xcshareddata directory + */ + static open(sharedDataPath: string): XCSharedData { + if (!existsSync(sharedDataPath)) { + throw new Error(`Shared data directory does not exist: ${sharedDataPath}`); + } + return new XCSharedData(sharedDataPath); + } + + /** + * Create a new XCSharedData instance. + */ + static create(): XCSharedData { + return new XCSharedData(); + } + + // ============================================================================ + // Schemes + // ============================================================================ + + /** + * Get the path to the xcschemes directory. + */ + getSchemesDir(): string | undefined { + if (!this.filePath) return undefined; + return path.join(this.filePath, "xcschemes"); + } + + /** + * Get all shared schemes. + */ + getSchemes(): XCScheme[] { + const schemesDir = this.getSchemesDir(); + if (!schemesDir || !existsSync(schemesDir)) { + return []; + } + + const files = readdirSync(schemesDir); + return files + .filter((f) => f.endsWith(".xcscheme")) + .map((f) => XCScheme.open(path.join(schemesDir, f))); + } + + /** + * Get a scheme by name. + */ + getScheme(name: string): XCScheme | null { + const schemesDir = this.getSchemesDir(); + if (!schemesDir) return null; + + const schemePath = path.join(schemesDir, `${name}.xcscheme`); + if (!existsSync(schemePath)) return null; + + return XCScheme.open(schemePath); + } + + /** + * Save a scheme to the schemes directory. + */ + saveScheme(xcscheme: XCScheme): void { + if (!this.filePath) { + throw new Error("Cannot save scheme: no file path set for XCSharedData"); + } + + const schemesDir = path.join(this.filePath, "xcschemes"); + if (!existsSync(schemesDir)) { + mkdirSync(schemesDir, { recursive: true }); + } + + const schemePath = path.join(schemesDir, `${xcscheme.name}.xcscheme`); + xcscheme.save(schemePath); + } + + /** + * Get or load scheme management data. + */ + get schemeManagement(): XCSchemeManagement | undefined { + if (this._schemeManagementLoaded) { + return this._schemeManagement; + } + + this._schemeManagementLoaded = true; + + if (!this.filePath) return undefined; + + const managementPath = path.join( + this.filePath, + "xcschemes", + "xcschememanagement.plist" + ); + + if (!existsSync(managementPath)) { + return undefined; + } + + const plistContent = readFileSync(managementPath, "utf-8"); + this._schemeManagement = scheme.parseManagement(plistContent); + return this._schemeManagement; + } + + /** + * Set scheme management data. + */ + set schemeManagement(value: XCSchemeManagement | undefined) { + this._schemeManagement = value; + this._schemeManagementLoaded = true; + } + + /** + * Save scheme management to disk. + */ + saveSchemeManagement(): void { + if (!this.filePath) { + throw new Error( + "Cannot save scheme management: no file path set for XCSharedData" + ); + } + + const schemesDir = path.join(this.filePath, "xcschemes"); + if (!existsSync(schemesDir)) { + mkdirSync(schemesDir, { recursive: true }); + } + + const managementPath = path.join(schemesDir, "xcschememanagement.plist"); + + if (this._schemeManagement) { + const plistContent = scheme.buildManagement(this._schemeManagement); + writeFileSync(managementPath, plistContent, "utf-8"); + } + } + + // ============================================================================ + // Breakpoints + // ============================================================================ + + /** + * Get the path to the breakpoints file. + */ + getBreakpointsPath(): string | undefined { + if (!this.filePath) return undefined; + return path.join(this.filePath, "xcdebugger", "Breakpoints_v2.xcbkptlist"); + } + + /** + * Get or load breakpoints data. + */ + get breakpoints(): XCBreakpointList | undefined { + if (this._breakpointsLoaded) { + return this._breakpoints; + } + + this._breakpointsLoaded = true; + + const breakpointsPath = this.getBreakpointsPath(); + if (!breakpointsPath || !existsSync(breakpointsPath)) { + return undefined; + } + + const xml = readFileSync(breakpointsPath, "utf-8"); + this._breakpoints = breakpoints.parse(xml); + return this._breakpoints; + } + + /** + * Set breakpoints data. + */ + set breakpoints(value: XCBreakpointList | undefined) { + this._breakpoints = value; + this._breakpointsLoaded = true; + } + + /** + * Save breakpoints to disk. + */ + saveBreakpoints(): void { + if (!this.filePath) { + throw new Error( + "Cannot save breakpoints: no file path set for XCSharedData" + ); + } + + const debuggerDir = path.join(this.filePath, "xcdebugger"); + if (!existsSync(debuggerDir)) { + mkdirSync(debuggerDir, { recursive: true }); + } + + const breakpointsPath = path.join(debuggerDir, "Breakpoints_v2.xcbkptlist"); + + if (this._breakpoints) { + const xml = breakpoints.build(this._breakpoints); + writeFileSync(breakpointsPath, xml, "utf-8"); + } + } + + // ============================================================================ + // Workspace Settings + // ============================================================================ + + /** + * Get the path to the workspace settings file. + */ + getWorkspaceSettingsPath(): string | undefined { + if (!this.filePath) return undefined; + return path.join(this.filePath, "WorkspaceSettings.xcsettings"); + } + + /** + * Get or load workspace settings. + */ + get workspaceSettings(): WorkspaceSettings | undefined { + if (this._workspaceSettingsLoaded) { + return this._workspaceSettings; + } + + this._workspaceSettingsLoaded = true; + + const settingsPath = this.getWorkspaceSettingsPath(); + if (!settingsPath || !existsSync(settingsPath)) { + return undefined; + } + + const plistContent = readFileSync(settingsPath, "utf-8"); + this._workspaceSettings = settings.parse(plistContent); + return this._workspaceSettings; + } + + /** + * Set workspace settings. + */ + set workspaceSettings(value: WorkspaceSettings | undefined) { + this._workspaceSettings = value; + this._workspaceSettingsLoaded = true; + } + + /** + * Save workspace settings to disk. + */ + saveWorkspaceSettings(): void { + if (!this.filePath) { + throw new Error( + "Cannot save workspace settings: no file path set for XCSharedData" + ); + } + + if (!existsSync(this.filePath)) { + mkdirSync(this.filePath, { recursive: true }); + } + + const settingsPath = path.join(this.filePath, "WorkspaceSettings.xcsettings"); + + if (this._workspaceSettings) { + const plistContent = settings.build(this._workspaceSettings); + writeFileSync(settingsPath, plistContent, "utf-8"); + } + } + + // ============================================================================ + // Save All + // ============================================================================ + + /** + * Save all modified data to disk. + * + * @param dirPath Optional path to save to. If not provided, uses this.filePath. + */ + save(dirPath?: string): void { + const targetPath = dirPath ?? this.filePath; + if (!targetPath) { + throw new Error( + "No file path specified. Either provide a path or set this.filePath." + ); + } + + this.filePath = targetPath; + + if (!existsSync(targetPath)) { + mkdirSync(targetPath, { recursive: true }); + } + + if (this._breakpointsLoaded && this._breakpoints) { + this.saveBreakpoints(); + } + + if (this._workspaceSettingsLoaded && this._workspaceSettings) { + this.saveWorkspaceSettings(); + } + + if (this._schemeManagementLoaded && this._schemeManagement) { + this.saveSchemeManagement(); + } + } +} diff --git a/src/api/XCWorkspace.ts b/src/api/XCWorkspace.ts index 4b464fb..be471f3 100644 --- a/src/api/XCWorkspace.ts +++ b/src/api/XCWorkspace.ts @@ -8,6 +8,7 @@ import path from "path"; import * as workspace from "../workspace"; import type { XCWorkspace as WorkspaceData, FileRef, Group } from "../workspace/types"; +import { XCSharedData } from "./XCSharedData"; /** * High-level class for working with Xcode workspace files. @@ -250,4 +251,34 @@ export class XCWorkspace { return paths; } + + // ============================================================================ + // Shared Data Methods + // ============================================================================ + + /** + * Get the path to the xcshareddata directory. + */ + getSharedDataDir(): string | undefined { + if (!this.filePath) return undefined; + return path.join(this.filePath, "xcshareddata"); + } + + /** + * Get the XCSharedData instance for this workspace. + * + * @returns XCSharedData for accessing schemes, breakpoints, and settings + */ + getSharedData(): XCSharedData { + const sharedDataDir = this.getSharedDataDir(); + if (sharedDataDir && existsSync(sharedDataDir)) { + return XCSharedData.open(sharedDataDir); + } + // Create a new instance with the path set + const sharedData = XCSharedData.create(); + if (sharedDataDir) { + sharedData.filePath = sharedDataDir; + } + return sharedData; + } } diff --git a/src/api/XcodeProject.ts b/src/api/XcodeProject.ts index adbc1e3..745f019 100644 --- a/src/api/XcodeProject.ts +++ b/src/api/XcodeProject.ts @@ -4,6 +4,7 @@ import path from "path"; import crypto from "crypto"; import { XCScheme, createBuildableReference } from "./XCScheme"; +import { XCSharedData } from "./XCSharedData"; import { parse } from "../json"; import * as json from "../json/types"; @@ -521,6 +522,34 @@ export class XcodeProject extends Map { ); } + // ============================================================================ + // Shared Data Methods + // ============================================================================ + + /** + * Get the path to the xcshareddata directory. + */ + getSharedDataDir(): string { + const projectDir = path.dirname(this.filePath); + return path.join(projectDir, "xcshareddata"); + } + + /** + * Get the XCSharedData instance for this project. + * + * @returns XCSharedData for accessing schemes, breakpoints, and settings + */ + getSharedData(): XCSharedData { + const sharedDataDir = this.getSharedDataDir(); + if (existsSync(sharedDataDir)) { + return XCSharedData.open(sharedDataDir); + } + // Create a new instance with the path set + const sharedData = XCSharedData.create(); + sharedData.filePath = sharedDataDir; + return sharedData; + } + toJSON(): json.XcodeProject { const json: json.XcodeProject = { archiveVersion: this.archiveVersion, diff --git a/src/api/__tests__/XCSharedData.test.ts b/src/api/__tests__/XCSharedData.test.ts new file mode 100644 index 0000000..070ba3a --- /dev/null +++ b/src/api/__tests__/XCSharedData.test.ts @@ -0,0 +1,310 @@ +import { existsSync, mkdirSync, writeFileSync, rmSync, readFileSync } from "fs"; +import path from "path"; +import tempy from "tempy"; + +import { XCSharedData } from "../XCSharedData"; +import { XCScheme } from "../XCScheme"; + +describe("XCSharedData", () => { + let tempDir: string; + let sharedDataDir: string; + + beforeEach(() => { + tempDir = tempy.directory(); + sharedDataDir = path.join(tempDir, "xcshareddata"); + mkdirSync(sharedDataDir, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(tempDir)) { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + describe("open", () => { + it("opens an existing xcshareddata directory", () => { + const sharedData = XCSharedData.open(sharedDataDir); + + expect(sharedData).toBeDefined(); + expect(sharedData.filePath).toBe(sharedDataDir); + }); + + it("throws when directory does not exist", () => { + expect(() => { + XCSharedData.open("/nonexistent/path"); + }).toThrow("Shared data directory does not exist"); + }); + }); + + describe("create", () => { + it("creates a new XCSharedData instance", () => { + const sharedData = XCSharedData.create(); + + expect(sharedData).toBeDefined(); + expect(sharedData.filePath).toBeUndefined(); + }); + }); + + describe("schemes", () => { + it("returns empty array when no schemes exist", () => { + const sharedData = XCSharedData.open(sharedDataDir); + + expect(sharedData.getSchemes()).toEqual([]); + }); + + it("returns schemes when they exist", () => { + // Create schemes directory and a scheme file + const schemesDir = path.join(sharedDataDir, "xcschemes"); + mkdirSync(schemesDir, { recursive: true }); + + const schemeXml = ` + + + + +`; + writeFileSync(path.join(schemesDir, "TestScheme.xcscheme"), schemeXml); + + const sharedData = XCSharedData.open(sharedDataDir); + const schemes = sharedData.getSchemes(); + + expect(schemes).toHaveLength(1); + expect(schemes[0].name).toBe("TestScheme"); + }); + + it("gets a scheme by name", () => { + const schemesDir = path.join(sharedDataDir, "xcschemes"); + mkdirSync(schemesDir, { recursive: true }); + + const schemeXml = ` + + + + +`; + writeFileSync(path.join(schemesDir, "MyApp.xcscheme"), schemeXml); + + const sharedData = XCSharedData.open(sharedDataDir); + const scheme = sharedData.getScheme("MyApp"); + + expect(scheme).not.toBeNull(); + expect(scheme!.name).toBe("MyApp"); + }); + + it("returns null for non-existent scheme", () => { + const sharedData = XCSharedData.open(sharedDataDir); + + expect(sharedData.getScheme("NonExistent")).toBeNull(); + }); + + it("saves a scheme", () => { + const sharedData = XCSharedData.open(sharedDataDir); + const scheme = XCScheme.create("NewScheme"); + + sharedData.saveScheme(scheme); + + const schemePath = path.join( + sharedDataDir, + "xcschemes", + "NewScheme.xcscheme" + ); + expect(existsSync(schemePath)).toBe(true); + }); + }); + + describe("breakpoints", () => { + it("returns undefined when no breakpoints file exists", () => { + const sharedData = XCSharedData.open(sharedDataDir); + + expect(sharedData.breakpoints).toBeUndefined(); + }); + + it("loads breakpoints when file exists", () => { + const debuggerDir = path.join(sharedDataDir, "xcdebugger"); + mkdirSync(debuggerDir, { recursive: true }); + + const breakpointsXml = ` + + + + +`; + writeFileSync( + path.join(debuggerDir, "Breakpoints_v2.xcbkptlist"), + breakpointsXml + ); + + const sharedData = XCSharedData.open(sharedDataDir); + + expect(sharedData.breakpoints).toBeDefined(); + expect(sharedData.breakpoints!.uuid).toBe("TEST-UUID"); + }); + + it("saves breakpoints", () => { + const sharedData = XCSharedData.open(sharedDataDir); + sharedData.breakpoints = { + uuid: "NEW-UUID", + type: "1", + version: "2.0", + breakpoints: [], + }; + + sharedData.saveBreakpoints(); + + const breakpointsPath = path.join( + sharedDataDir, + "xcdebugger", + "Breakpoints_v2.xcbkptlist" + ); + expect(existsSync(breakpointsPath)).toBe(true); + + const content = readFileSync(breakpointsPath, "utf-8"); + expect(content).toContain('uuid = "NEW-UUID"'); + }); + }); + + describe("workspaceSettings", () => { + it("returns undefined when no settings file exists", () => { + const sharedData = XCSharedData.open(sharedDataDir); + + expect(sharedData.workspaceSettings).toBeUndefined(); + }); + + it("loads workspace settings when file exists", () => { + const settingsPlist = ` + + + + PreviewsEnabled + + +`; + writeFileSync( + path.join(sharedDataDir, "WorkspaceSettings.xcsettings"), + settingsPlist + ); + + const sharedData = XCSharedData.open(sharedDataDir); + + expect(sharedData.workspaceSettings).toBeDefined(); + expect(sharedData.workspaceSettings!.PreviewsEnabled).toBe(true); + }); + + it("saves workspace settings", () => { + const sharedData = XCSharedData.open(sharedDataDir); + sharedData.workspaceSettings = { + PreviewsEnabled: false, + IDEWorkspaceSharedSettings_AutocreateContextsIfNeeded: true, + }; + + sharedData.saveWorkspaceSettings(); + + const settingsPath = path.join( + sharedDataDir, + "WorkspaceSettings.xcsettings" + ); + expect(existsSync(settingsPath)).toBe(true); + + const content = readFileSync(settingsPath, "utf-8"); + expect(content).toContain("PreviewsEnabled"); + expect(content).toContain(""); + }); + }); + + describe("save", () => { + it("saves all modified data", () => { + const sharedData = XCSharedData.create(); + sharedData.filePath = sharedDataDir; + + sharedData.breakpoints = { + uuid: "SAVE-TEST", + type: "1", + version: "2.0", + breakpoints: [], + }; + + sharedData.workspaceSettings = { + PreviewsEnabled: true, + }; + + sharedData.save(); + + expect( + existsSync( + path.join(sharedDataDir, "xcdebugger", "Breakpoints_v2.xcbkptlist") + ) + ).toBe(true); + expect( + existsSync(path.join(sharedDataDir, "WorkspaceSettings.xcsettings")) + ).toBe(true); + }); + + it("saves to a new path when provided", () => { + const newDir = path.join(tempDir, "new-xcshareddata"); + + const sharedData = XCSharedData.create(); + sharedData.breakpoints = { + uuid: "NEW-PATH-TEST", + type: "1", + version: "2.0", + breakpoints: [], + }; + + sharedData.save(newDir); + + expect(sharedData.filePath).toBe(newDir); + expect( + existsSync(path.join(newDir, "xcdebugger", "Breakpoints_v2.xcbkptlist")) + ).toBe(true); + }); + + it("throws when no path is set", () => { + const sharedData = XCSharedData.create(); + sharedData.breakpoints = { + uuid: "TEST", + type: "1", + version: "2.0", + breakpoints: [], + }; + + expect(() => { + sharedData.save(); + }).toThrow("No file path specified"); + }); + }); + + describe("lazy loading", () => { + it("only loads breakpoints once", () => { + const debuggerDir = path.join(sharedDataDir, "xcdebugger"); + mkdirSync(debuggerDir, { recursive: true }); + + const breakpointsXml = ` + + + + +`; + writeFileSync( + path.join(debuggerDir, "Breakpoints_v2.xcbkptlist"), + breakpointsXml + ); + + const sharedData = XCSharedData.open(sharedDataDir); + + // Access twice + const first = sharedData.breakpoints; + const second = sharedData.breakpoints; + + // Should be the same object + expect(first).toBe(second); + }); + }); +}); diff --git a/src/api/index.ts b/src/api/index.ts index a0b33b8..80bbdb8 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -34,4 +34,5 @@ export { XCLocalSwiftPackageReference } from "./XCLocalSwiftPackageReference"; export { XCSwiftPackageProductDependency } from "./XCSwiftPackageProductDependency"; export { XCVersionGroup } from "./XCVersionGroup"; export { XCScheme, createBuildableReference } from "./XCScheme"; +export { XCSharedData } from "./XCSharedData"; export { XCWorkspace } from "./XCWorkspace"; diff --git a/src/breakpoints/__tests__/breakpoints.test.ts b/src/breakpoints/__tests__/breakpoints.test.ts new file mode 100644 index 0000000..29c4d75 --- /dev/null +++ b/src/breakpoints/__tests__/breakpoints.test.ts @@ -0,0 +1,225 @@ +import { readFileSync } from "fs"; +import path from "path"; + +import { parse, build } from "../index"; +import type { XCBreakpointList } from "../types"; + +const fixturesDir = path.join(__dirname, "fixtures"); + +function readFixture(name: string): string { + return readFileSync(path.join(fixturesDir, name), "utf-8"); +} + +describe("breakpoints parser", () => { + describe("parse", () => { + it("parses Breakpoints_v2.xcbkptlist", () => { + const xml = readFixture("Breakpoints_v2.xcbkptlist"); + const list = parse(xml); + + expect(list.uuid).toBe("CC12345D-E789-4ABC-DEF0-123456789012"); + expect(list.type).toBe("1"); + expect(list.version).toBe("2.0"); + expect(list.breakpoints).toHaveLength(4); + }); + + it("parses file breakpoint", () => { + const xml = readFixture("Breakpoints_v2.xcbkptlist"); + const list = parse(xml); + + const fileBreakpoint = list.breakpoints![0]; + expect(fileBreakpoint.breakpointExtensionID).toBe( + "Xcode.Breakpoint.FileBreakpoint" + ); + + const content = fileBreakpoint.breakpointContent!; + expect(content.uuid).toBe("AA12345B-C678-9DEF-0123-456789ABCDEF"); + expect(content.shouldBeEnabled).toBe(true); + expect(content.ignoreCount).toBe(0); + expect(content.continueAfterRunningActions).toBe(false); + expect(content.filePath).toBe("MyApp/ViewController.swift"); + expect(content.startingLineNumber).toBe("42"); + expect(content.endingLineNumber).toBe("42"); + expect(content.landmarkName).toBe("viewDidLoad()"); + expect(content.landmarkType).toBe("7"); + }); + + it("parses breakpoint with condition and actions", () => { + const xml = readFixture("Breakpoints_v2.xcbkptlist"); + const list = parse(xml); + + const breakpoint = list.breakpoints![1]; + const content = breakpoint.breakpointContent!; + + expect(content.shouldBeEnabled).toBe(false); + expect(content.ignoreCount).toBe(5); + expect(content.continueAfterRunningActions).toBe(true); + expect(content.condition).toBe("count > 10"); + + // Actions + expect(content.actions).toHaveLength(2); + + // Debugger command action + const debugAction = content.actions![0]; + expect(debugAction.actionExtensionID).toBe( + "Xcode.BreakpointAction.DebuggerCommand" + ); + expect(debugAction.actionContent?.consoleCommand).toBe("po self"); + + // Log action + const logAction = content.actions![1]; + expect(logAction.actionExtensionID).toBe("Xcode.BreakpointAction.Log"); + expect(logAction.actionContent?.message).toBe( + "Hit breakpoint at fetchData" + ); + expect(logAction.actionContent?.conveyanceType).toBe("0"); + }); + + it("parses symbolic breakpoint", () => { + const xml = readFixture("Breakpoints_v2.xcbkptlist"); + const list = parse(xml); + + const symbolicBreakpoint = list.breakpoints![2]; + expect(symbolicBreakpoint.breakpointExtensionID).toBe( + "Xcode.Breakpoint.SymbolicBreakpoint" + ); + + const content = symbolicBreakpoint.breakpointContent!; + expect(content.symbolName).toBe("objc_exception_throw"); + expect(content.moduleName).toBe(""); + }); + + it("parses exception breakpoint", () => { + const xml = readFixture("Breakpoints_v2.xcbkptlist"); + const list = parse(xml); + + const exceptionBreakpoint = list.breakpoints![3]; + expect(exceptionBreakpoint.breakpointExtensionID).toBe( + "Xcode.Breakpoint.ExceptionBreakpoint" + ); + + const content = exceptionBreakpoint.breakpointContent!; + expect(content.scope).toBe("0"); + expect(content.stopOnStyle).toBe("0"); + expect(content.exceptionType).toBe("0"); + }); + }); + + describe("build", () => { + it("builds a basic breakpoint list", () => { + const list: XCBreakpointList = { + uuid: "TEST-UUID-1234", + type: "1", + version: "2.0", + breakpoints: [ + { + breakpointExtensionID: "Xcode.Breakpoint.FileBreakpoint", + breakpointContent: { + uuid: "BP-UUID-5678", + shouldBeEnabled: true, + ignoreCount: 0, + continueAfterRunningActions: false, + filePath: "MyApp/Main.swift", + startingLineNumber: "10", + endingLineNumber: "10", + }, + }, + ], + }; + + const xml = build(list); + + expect(xml).toContain(''); + expect(xml).toContain(""); + expect(xml).toContain( + 'BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint"' + ); + expect(xml).toContain('shouldBeEnabled = "Yes"'); + expect(xml).toContain('filePath = "MyApp/Main.swift"'); + }); + + it("builds breakpoint with actions", () => { + const list: XCBreakpointList = { + uuid: "TEST-UUID", + type: "1", + version: "2.0", + breakpoints: [ + { + breakpointExtensionID: "Xcode.Breakpoint.FileBreakpoint", + breakpointContent: { + uuid: "BP-UUID", + shouldBeEnabled: true, + actions: [ + { + actionExtensionID: "Xcode.BreakpointAction.DebuggerCommand", + actionContent: { + consoleCommand: "po myVar", + }, + }, + ], + }, + }, + ], + }; + + const xml = build(list); + + expect(xml).toContain(""); + expect(xml).toContain( + 'ActionExtensionID = "Xcode.BreakpointAction.DebuggerCommand"' + ); + expect(xml).toContain('consoleCommand = "po myVar"'); + expect(xml).toContain(""); + }); + + it("escapes XML special characters", () => { + const list: XCBreakpointList = { + uuid: "TEST", + type: "1", + version: "2.0", + breakpoints: [ + { + breakpointExtensionID: "Xcode.Breakpoint.FileBreakpoint", + breakpointContent: { + filePath: "Path/With&Chars.swift", + condition: 'name == "test"', + }, + }, + ], + }; + + const xml = build(list); + + expect(xml).toContain( + 'filePath = "Path/With<Special>&Chars.swift"' + ); + expect(xml).toContain('condition = "name == "test""'); + }); + }); + + describe("round-trip", () => { + it("round-trips Breakpoints_v2.xcbkptlist", () => { + const xml = readFixture("Breakpoints_v2.xcbkptlist"); + const list = parse(xml); + const rebuilt = build(list); + const reparsed = parse(rebuilt); + + // Deep equality check on the parsed structures + expect(reparsed).toEqual(list); + }); + }); + + describe("parsing stability", () => { + it("produces identical results on multiple parses", () => { + const xml = readFixture("Breakpoints_v2.xcbkptlist"); + const parses = Array.from({ length: 5 }, () => parse(xml)); + + for (let i = 1; i < parses.length; i++) { + expect(parses[i]).toEqual(parses[0]); + } + }); + }); +}); diff --git a/src/breakpoints/__tests__/fixtures/Breakpoints_v2.xcbkptlist b/src/breakpoints/__tests__/fixtures/Breakpoints_v2.xcbkptlist new file mode 100644 index 0000000..a6e00f2 --- /dev/null +++ b/src/breakpoints/__tests__/fixtures/Breakpoints_v2.xcbkptlist @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/breakpoints/index.ts b/src/breakpoints/index.ts new file mode 100644 index 0000000..72d0885 --- /dev/null +++ b/src/breakpoints/index.ts @@ -0,0 +1,29 @@ +/** + * Low-level API for parsing and building Xcode breakpoint list files (Breakpoints_v2.xcbkptlist). + * + * @example + * ```ts + * import * as breakpoints from "@bacons/xcode/breakpoints"; + * + * // Parse a breakpoint list file + * const list = breakpoints.parse(xml); + * + * // Add a new breakpoint + * list.breakpoints?.push({ + * breakpointExtensionID: "Xcode.Breakpoint.FileBreakpoint", + * breakpointContent: { + * shouldBeEnabled: true, + * filePath: "path/to/file.swift", + * startingLineNumber: "42", + * endingLineNumber: "42", + * }, + * }); + * + * // Serialize back to XML + * const xml = breakpoints.build(list); + * ``` + */ + +export { parse } from "./parser"; +export { build } from "./writer"; +export * from "./types"; diff --git a/src/breakpoints/parser.ts b/src/breakpoints/parser.ts new file mode 100644 index 0000000..81fa2e3 --- /dev/null +++ b/src/breakpoints/parser.ts @@ -0,0 +1,203 @@ +/** + * Parser for Xcode breakpoint list files (Breakpoints_v2.xcbkptlist) + * + * Uses @xmldom/xmldom to parse XML into TypeScript objects. + */ +import { DOMParser } from "@xmldom/xmldom"; + +import type { + XCBreakpointList, + BreakpointProxy, + BreakpointContent, + BreakpointActionProxy, + BreakpointActionContent, + BreakpointLocation, +} from "./types"; + +/** + * Parse a breakpoint list XML string into a typed XCBreakpointList object. + */ +export function parse(xml: string): XCBreakpointList { + const parser = new DOMParser(); + const doc = parser.parseFromString(xml, "text/xml"); + const bucketEl = doc.documentElement; + + if (!bucketEl || bucketEl.tagName !== "Bucket") { + throw new Error( + "Invalid breakpoint list file: root element must be " + ); + } + + return parseBucket(bucketEl); +} + +function parseBucket(el: Element): XCBreakpointList { + const list: XCBreakpointList = {}; + + list.uuid = getAttr(el, "uuid"); + list.type = getAttr(el, "type"); + list.version = getAttr(el, "version"); + + const breakpointsEl = getChildElement(el, "Breakpoints"); + if (breakpointsEl) { + list.breakpoints = getChildElements(breakpointsEl, "BreakpointProxy").map( + parseBreakpointProxy + ); + } + + return list; +} + +function parseBreakpointProxy(el: Element): BreakpointProxy { + const proxy: BreakpointProxy = { + breakpointExtensionID: getAttr(el, "BreakpointExtensionID") || "", + }; + + const contentEl = getChildElement(el, "BreakpointContent"); + if (contentEl) { + proxy.breakpointContent = parseBreakpointContent(contentEl); + } + + return proxy; +} + +function parseBreakpointContent(el: Element): BreakpointContent { + const content: BreakpointContent = {}; + + content.uuid = getAttr(el, "uuid"); + content.shouldBeEnabled = getBoolAttr(el, "shouldBeEnabled"); + content.ignoreCount = getIntAttr(el, "ignoreCount"); + content.continueAfterRunningActions = getBoolAttr( + el, + "continueAfterRunningActions" + ); + content.filePath = getAttr(el, "filePath"); + content.startingColumnNumber = getAttr(el, "startingColumnNumber"); + content.endingColumnNumber = getAttr(el, "endingColumnNumber"); + content.startingLineNumber = getAttr(el, "startingLineNumber"); + content.endingLineNumber = getAttr(el, "endingLineNumber"); + content.landmarkName = getAttr(el, "landmarkName"); + content.landmarkType = getAttr(el, "landmarkType"); + content.condition = getAttr(el, "condition"); + content.scope = getAttr(el, "scope"); + content.symbolName = getAttr(el, "symbolName"); + content.moduleName = getAttr(el, "moduleName"); + content.exceptionType = getAttr(el, "exceptionType"); + content.stopOnStyle = getAttr(el, "stopOnStyle"); + + const actionsEl = getChildElement(el, "Actions"); + if (actionsEl) { + content.actions = getChildElements(actionsEl, "BreakpointActionProxy").map( + parseBreakpointActionProxy + ); + } + + const locationsEl = getChildElement(el, "Locations"); + if (locationsEl) { + content.locations = getChildElements( + locationsEl, + "BreakpointLocationProxy" + ).map(parseBreakpointLocation); + } + + return content; +} + +function parseBreakpointActionProxy(el: Element): BreakpointActionProxy { + const proxy: BreakpointActionProxy = { + actionExtensionID: getAttr(el, "ActionExtensionID") || "", + }; + + const contentEl = getChildElement(el, "ActionContent"); + if (contentEl) { + proxy.actionContent = parseBreakpointActionContent(contentEl); + } + + return proxy; +} + +function parseBreakpointActionContent(el: Element): BreakpointActionContent { + const content: BreakpointActionContent = {}; + + content.consoleCommand = getAttr(el, "consoleCommand"); + content.message = getAttr(el, "message"); + content.conveyanceType = getAttr(el, "conveyanceType"); + content.shellCommand = getAttr(el, "shellCommand"); + content.shellArguments = getAttr(el, "shellArguments"); + content.waitUntilDone = getBoolAttr(el, "waitUntilDone"); + content.script = getAttr(el, "script"); + content.soundName = getAttr(el, "soundName"); + + return content; +} + +function parseBreakpointLocation(el: Element): BreakpointLocation { + // Handle the wrapper element (BreakpointLocationProxy) + const locationContentEl = getChildElement(el, "BreakpointLocationContent"); + const targetEl = locationContentEl || el; + + const location: BreakpointLocation = {}; + + location.uuid = getAttr(targetEl, "uuid"); + location.shouldBeEnabled = getBoolAttr(targetEl, "shouldBeEnabled"); + location.ignoreCount = getIntAttr(targetEl, "ignoreCount"); + location.continueAfterRunningActions = getBoolAttr( + targetEl, + "continueAfterRunningActions" + ); + location.symbolName = getAttr(targetEl, "symbolName"); + location.moduleName = getAttr(targetEl, "moduleName"); + location.urlString = getAttr(targetEl, "urlString"); + location.startingLineNumber = getAttr(targetEl, "startingLineNumber"); + location.endingLineNumber = getAttr(targetEl, "endingLineNumber"); + location.startingColumnNumber = getAttr(targetEl, "startingColumnNumber"); + location.endingColumnNumber = getAttr(targetEl, "endingColumnNumber"); + + return location; +} + +// ============================================================================ +// 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 getIntAttr(el: Element, name: string): number | undefined { + const value = el.getAttribute(name); + if (value === null || value === undefined) return undefined; + const num = parseInt(value, 10); + return isNaN(num) ? undefined : num; +} + +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/breakpoints/types.ts b/src/breakpoints/types.ts new file mode 100644 index 0000000..279e854 --- /dev/null +++ b/src/breakpoints/types.ts @@ -0,0 +1,143 @@ +/** + * TypeScript definitions for Xcode breakpoint list files (Breakpoints_v2.xcbkptlist) + * + * These XML files store shared breakpoints in xcshareddata directories. + */ + +/** Root breakpoint list container */ +export interface XCBreakpointList { + /** UUID for the breakpoint bucket */ + uuid?: string; + /** Bucket type (usually "1") */ + type?: string; + /** Format version (e.g., "2.0") */ + version?: string; + /** List of breakpoint proxies */ + breakpoints?: BreakpointProxy[]; +} + +/** Wrapper for a single breakpoint */ +export interface BreakpointProxy { + /** Extension ID identifying the breakpoint type */ + breakpointExtensionID: BreakpointType; + /** The actual breakpoint content */ + breakpointContent?: BreakpointContent; +} + +/** Breakpoint type identifiers */ +export type BreakpointType = + | "Xcode.Breakpoint.FileBreakpoint" + | "Xcode.Breakpoint.SymbolicBreakpoint" + | "Xcode.Breakpoint.ExceptionBreakpoint" + | "Xcode.Breakpoint.SwiftErrorBreakpoint" + | "Xcode.Breakpoint.OpenGLErrorBreakpoint" + | "Xcode.Breakpoint.IDETestFailureBreakpoint" + | "Xcode.Breakpoint.RuntimeIssueBreakpoint" + | "Xcode.Breakpoint.ConstraintErrorBreakpoint" + | string; // Allow custom breakpoint types + +/** Detailed breakpoint configuration */ +export interface BreakpointContent { + /** Unique identifier for this breakpoint */ + uuid?: string; + /** Whether the breakpoint is enabled */ + shouldBeEnabled?: boolean; + /** Number of times to ignore before breaking */ + ignoreCount?: number; + /** Whether to continue after running actions */ + continueAfterRunningActions?: boolean; + /** File path (for file breakpoints) */ + filePath?: string; + /** Starting column number */ + startingColumnNumber?: string; + /** Ending column number */ + endingColumnNumber?: string; + /** Starting line number */ + startingLineNumber?: string; + /** Ending line number */ + endingLineNumber?: string; + /** Name of the function/method containing the breakpoint */ + landmarkName?: string; + /** Type of landmark (7 = method, 9 = function) */ + landmarkType?: string; + /** Condition expression */ + condition?: string; + /** Scope (for symbolic breakpoints) */ + scope?: string; + /** Symbol name (for symbolic breakpoints) */ + symbolName?: string; + /** Module name (for symbolic breakpoints) */ + moduleName?: string; + /** Exception type (for exception breakpoints): "0" = Objective-C, "1" = C++ */ + exceptionType?: string; + /** Stop on style: "0" = throw, "1" = catch */ + stopOnStyle?: string; + /** Breakpoint actions */ + actions?: BreakpointActionProxy[]; + /** Locations for resolved breakpoints */ + locations?: BreakpointLocation[]; +} + +/** Wrapper for a breakpoint action */ +export interface BreakpointActionProxy { + /** Action type identifier */ + actionExtensionID: BreakpointActionType; + /** Action content */ + actionContent?: BreakpointActionContent; +} + +/** Action type identifiers */ +export type BreakpointActionType = + | "Xcode.BreakpointAction.DebuggerCommand" + | "Xcode.BreakpointAction.Log" + | "Xcode.BreakpointAction.ShellCommand" + | "Xcode.BreakpointAction.Sound" + | "Xcode.BreakpointAction.AppleScript" + | "Xcode.BreakpointAction.GraphicsTrace" + | string; // Allow custom action types + +/** Action content details */ +export interface BreakpointActionContent { + /** Debugger command to execute */ + consoleCommand?: string; + /** Message to log */ + message?: string; + /** Whether to speak the log message */ + conveyanceType?: string; + /** Shell command path */ + shellCommand?: string; + /** Arguments for shell command */ + shellArguments?: string; + /** Wait until done before continuing */ + waitUntilDone?: boolean; + /** AppleScript text */ + script?: string; + /** Sound name */ + soundName?: string; +} + +/** Location where breakpoint is resolved */ +export interface BreakpointLocation { + /** UUID for this location */ + uuid?: string; + /** Whether this location should be enabled */ + shouldBeEnabled?: boolean; + /** Ignore count for this location */ + ignoreCount?: number; + /** Continue after running actions */ + continueAfterRunningActions?: boolean; + /** Symbol name at this location */ + symbolName?: string; + /** Module name at this location */ + moduleName?: string; + /** URL path */ + urlString?: string; + /** Starting line number */ + startingLineNumber?: string; + /** Ending line number */ + endingLineNumber?: string; + /** Starting column number */ + startingColumnNumber?: string; + /** Ending column number */ + endingColumnNumber?: string; +} diff --git a/src/breakpoints/writer.ts b/src/breakpoints/writer.ts new file mode 100644 index 0000000..f6c2b80 --- /dev/null +++ b/src/breakpoints/writer.ts @@ -0,0 +1,316 @@ +/** + * Writer for Xcode breakpoint list files (Breakpoints_v2.xcbkptlist) + * + * Serializes TypeScript objects back to XML format. + */ +import type { + XCBreakpointList, + BreakpointProxy, + BreakpointContent, + BreakpointActionProxy, + BreakpointActionContent, + BreakpointLocation, +} from "./types"; + +/** + * Build a breakpoint list XML string from a typed XCBreakpointList object. + */ +export function build(list: XCBreakpointList): string { + const lines: string[] = []; + + lines.push(''); + lines.push(" ` ${a}`)); + lines[lines.length - 1] += ">"; + + if (list.breakpoints && list.breakpoints.length > 0) { + lines.push(`${getIndent(1)}`); + for (const bp of list.breakpoints) { + lines.push(...buildBreakpointProxy(bp, 2)); + } + lines.push(`${getIndent(1)}`); + } + + lines.push(""); + + return lines.join("\n") + "\n"; +} + +function buildBucketAttributes(list: XCBreakpointList): string[] { + const attrs: string[] = []; + + if (list.uuid !== undefined) { + attrs.push(`uuid = "${list.uuid}"`); + } + if (list.type !== undefined) { + attrs.push(`type = "${list.type}"`); + } + if (list.version !== undefined) { + attrs.push(`version = "${list.version}"`); + } + + return attrs; +} + +function buildBreakpointProxy(proxy: BreakpointProxy, depth: number): string[] { + const lines: string[] = []; + const indent = getIndent(depth); + + lines.push(`${indent}` + ); + + if (proxy.breakpointContent) { + lines.push(...buildBreakpointContent(proxy.breakpointContent, depth + 1)); + } + + lines.push(`${indent}`); + + return lines; +} + +function buildBreakpointContent( + content: BreakpointContent, + depth: number +): string[] { + const lines: string[] = []; + const indent = getIndent(depth); + const attrs: string[] = []; + + if (content.uuid !== undefined) { + attrs.push(`uuid = "${content.uuid}"`); + } + if (content.shouldBeEnabled !== undefined) { + attrs.push(`shouldBeEnabled = "${boolToString(content.shouldBeEnabled)}"`); + } + if (content.ignoreCount !== undefined) { + attrs.push(`ignoreCount = "${content.ignoreCount}"`); + } + if (content.continueAfterRunningActions !== undefined) { + attrs.push( + `continueAfterRunningActions = "${boolToString( + content.continueAfterRunningActions + )}"` + ); + } + if (content.filePath !== undefined) { + attrs.push(`filePath = "${escapeXml(content.filePath)}"`); + } + if (content.startingColumnNumber !== undefined) { + attrs.push(`startingColumnNumber = "${content.startingColumnNumber}"`); + } + if (content.endingColumnNumber !== undefined) { + attrs.push(`endingColumnNumber = "${content.endingColumnNumber}"`); + } + if (content.startingLineNumber !== undefined) { + attrs.push(`startingLineNumber = "${content.startingLineNumber}"`); + } + if (content.endingLineNumber !== undefined) { + attrs.push(`endingLineNumber = "${content.endingLineNumber}"`); + } + if (content.landmarkName !== undefined) { + attrs.push(`landmarkName = "${escapeXml(content.landmarkName)}"`); + } + if (content.landmarkType !== undefined) { + attrs.push(`landmarkType = "${content.landmarkType}"`); + } + if (content.condition !== undefined) { + attrs.push(`condition = "${escapeXml(content.condition)}"`); + } + if (content.scope !== undefined) { + attrs.push(`scope = "${content.scope}"`); + } + if (content.symbolName !== undefined) { + attrs.push(`symbolName = "${escapeXml(content.symbolName)}"`); + } + if (content.moduleName !== undefined) { + attrs.push(`moduleName = "${escapeXml(content.moduleName)}"`); + } + if (content.exceptionType !== undefined) { + attrs.push(`exceptionType = "${content.exceptionType}"`); + } + if (content.stopOnStyle !== undefined) { + attrs.push(`stopOnStyle = "${content.stopOnStyle}"`); + } + + lines.push(`${indent} 0) || + (content.locations && content.locations.length > 0); + + if (hasChildren) { + lines[lines.length - 1] += ">"; + + if (content.actions && content.actions.length > 0) { + lines.push(`${getIndent(depth + 1)}`); + for (const action of content.actions) { + lines.push(...buildBreakpointActionProxy(action, depth + 2)); + } + lines.push(`${getIndent(depth + 1)}`); + } + + if (content.locations && content.locations.length > 0) { + lines.push(`${getIndent(depth + 1)}`); + for (const loc of content.locations) { + lines.push(...buildBreakpointLocation(loc, depth + 2)); + } + lines.push(`${getIndent(depth + 1)}`); + } + + lines.push(`${indent}`); + } else { + lines[lines.length - 1] += ">"; + lines.push(`${indent}`); + } + + return lines; +} + +function buildBreakpointActionProxy( + proxy: BreakpointActionProxy, + depth: number +): string[] { + const lines: string[] = []; + const indent = getIndent(depth); + + lines.push(`${indent}`); + + if (proxy.actionContent) { + lines.push(...buildBreakpointActionContent(proxy.actionContent, depth + 1)); + } + + lines.push(`${indent}`); + + return lines; +} + +function buildBreakpointActionContent( + content: BreakpointActionContent, + depth: number +): string[] { + const lines: string[] = []; + const indent = getIndent(depth); + const attrs: string[] = []; + + if (content.consoleCommand !== undefined) { + attrs.push(`consoleCommand = "${escapeXml(content.consoleCommand)}"`); + } + if (content.message !== undefined) { + attrs.push(`message = "${escapeXml(content.message)}"`); + } + if (content.conveyanceType !== undefined) { + attrs.push(`conveyanceType = "${content.conveyanceType}"`); + } + if (content.shellCommand !== undefined) { + attrs.push(`shellCommand = "${escapeXml(content.shellCommand)}"`); + } + if (content.shellArguments !== undefined) { + attrs.push(`shellArguments = "${escapeXml(content.shellArguments)}"`); + } + if (content.waitUntilDone !== undefined) { + attrs.push(`waitUntilDone = "${boolToString(content.waitUntilDone)}"`); + } + if (content.script !== undefined) { + attrs.push(`script = "${escapeXml(content.script)}"`); + } + if (content.soundName !== undefined) { + attrs.push(`soundName = "${escapeXml(content.soundName)}"`); + } + + lines.push(`${indent}`); + + return lines; +} + +function buildBreakpointLocation( + location: BreakpointLocation, + depth: number +): string[] { + const lines: string[] = []; + const indent = getIndent(depth); + const attrs: string[] = []; + + if (location.uuid !== undefined) { + attrs.push(`uuid = "${location.uuid}"`); + } + if (location.shouldBeEnabled !== undefined) { + attrs.push( + `shouldBeEnabled = "${boolToString(location.shouldBeEnabled)}"` + ); + } + if (location.ignoreCount !== undefined) { + attrs.push(`ignoreCount = "${location.ignoreCount}"`); + } + if (location.continueAfterRunningActions !== undefined) { + attrs.push( + `continueAfterRunningActions = "${boolToString( + location.continueAfterRunningActions + )}"` + ); + } + if (location.symbolName !== undefined) { + attrs.push(`symbolName = "${escapeXml(location.symbolName)}"`); + } + if (location.moduleName !== undefined) { + attrs.push(`moduleName = "${escapeXml(location.moduleName)}"`); + } + if (location.urlString !== undefined) { + attrs.push(`urlString = "${escapeXml(location.urlString)}"`); + } + if (location.startingLineNumber !== undefined) { + attrs.push(`startingLineNumber = "${location.startingLineNumber}"`); + } + if (location.endingLineNumber !== undefined) { + attrs.push(`endingLineNumber = "${location.endingLineNumber}"`); + } + if (location.startingColumnNumber !== undefined) { + attrs.push(`startingColumnNumber = "${location.startingColumnNumber}"`); + } + if (location.endingColumnNumber !== undefined) { + attrs.push(`endingColumnNumber = "${location.endingColumnNumber}"`); + } + + lines.push(`${indent}`); + 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, """); +} diff --git a/src/settings/__tests__/fixtures/WorkspaceSettings.xcsettings b/src/settings/__tests__/fixtures/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..31691b2 --- /dev/null +++ b/src/settings/__tests__/fixtures/WorkspaceSettings.xcsettings @@ -0,0 +1,16 @@ + + + + + BuildSystemType + Original + DerivedDataLocationStyle + WorkspaceRelativePath + IDEWorkspaceSharedSettings_AutocreateContextsIfNeeded + + PreviewsEnabled + + LiveSourceIssuesEnabled + + + diff --git a/src/settings/__tests__/settings.test.ts b/src/settings/__tests__/settings.test.ts new file mode 100644 index 0000000..b4f4fdc --- /dev/null +++ b/src/settings/__tests__/settings.test.ts @@ -0,0 +1,110 @@ +import { readFileSync } from "fs"; +import path from "path"; + +import { parse, build } from "../index"; +import type { WorkspaceSettings } from "../types"; + +const fixturesDir = path.join(__dirname, "fixtures"); + +function readFixture(name: string): string { + return readFileSync(path.join(fixturesDir, name), "utf-8"); +} + +describe("settings parser", () => { + describe("parse", () => { + it("parses WorkspaceSettings.xcsettings", () => { + const plistStr = readFixture("WorkspaceSettings.xcsettings"); + const settings = parse(plistStr); + + expect(settings.BuildSystemType).toBe("Original"); + expect(settings.DerivedDataLocationStyle).toBe("WorkspaceRelativePath"); + expect(settings.IDEWorkspaceSharedSettings_AutocreateContextsIfNeeded).toBe(false); + expect(settings.PreviewsEnabled).toBe(true); + expect(settings.LiveSourceIssuesEnabled).toBe(true); + }); + + it("parses empty settings", () => { + const plistStr = ` + + + + +`; + + const settings = parse(plistStr); + + expect(settings.BuildSystemType).toBeUndefined(); + expect(settings.PreviewsEnabled).toBeUndefined(); + }); + }); + + describe("build", () => { + it("builds workspace settings", () => { + const settings: WorkspaceSettings = { + BuildSystemType: "New", + DerivedDataLocationStyle: "Default", + IDEWorkspaceSharedSettings_AutocreateContextsIfNeeded: true, + PreviewsEnabled: false, + }; + + const plistStr = build(settings); + + expect(plistStr).toContain("BuildSystemType"); + expect(plistStr).toContain("New"); + expect(plistStr).toContain("DerivedDataLocationStyle"); + expect(plistStr).toContain("Default"); + expect(plistStr).toContain( + "IDEWorkspaceSharedSettings_AutocreateContextsIfNeeded" + ); + expect(plistStr).toContain(""); + expect(plistStr).toContain("PreviewsEnabled"); + expect(plistStr).toContain(""); + }); + + it("builds empty settings", () => { + const settings: WorkspaceSettings = {}; + + const plistStr = build(settings); + + expect(plistStr).toContain(""); + }); + + it("builds settings with custom derived data location", () => { + const settings: WorkspaceSettings = { + DerivedDataLocationStyle: "CustomLocation", + DerivedDataCustomLocation: "/Users/me/DerivedData", + }; + + const plistStr = build(settings); + + expect(plistStr).toContain("DerivedDataLocationStyle"); + expect(plistStr).toContain("CustomLocation"); + expect(plistStr).toContain("DerivedDataCustomLocation"); + expect(plistStr).toContain("/Users/me/DerivedData"); + }); + }); + + describe("round-trip", () => { + it("round-trips WorkspaceSettings.xcsettings", () => { + const plistStr = readFixture("WorkspaceSettings.xcsettings"); + const settings = parse(plistStr); + const rebuilt = build(settings); + const reparsed = parse(rebuilt); + + expect(reparsed).toEqual(settings); + }); + }); + + describe("parsing stability", () => { + it("produces identical results on multiple parses", () => { + const plistStr = readFixture("WorkspaceSettings.xcsettings"); + const parses = Array.from({ length: 5 }, () => parse(plistStr)); + + for (let i = 1; i < parses.length; i++) { + expect(parses[i]).toEqual(parses[0]); + } + }); + }); +}); diff --git a/src/settings/index.ts b/src/settings/index.ts new file mode 100644 index 0000000..1c49dfd --- /dev/null +++ b/src/settings/index.ts @@ -0,0 +1,22 @@ +/** + * Low-level API for parsing and building Xcode workspace settings files (WorkspaceSettings.xcsettings). + * + * @example + * ```ts + * import * as settings from "@bacons/xcode/settings"; + * + * // Parse a workspace settings file + * const config = settings.parse(plistString); + * + * // Modify settings + * config.PreviewsEnabled = true; + * config.IDEWorkspaceSharedSettings_AutocreateContextsIfNeeded = false; + * + * // Serialize back to plist + * const output = settings.build(config); + * ``` + */ + +export { parse } from "./parser"; +export { build } from "./writer"; +export * from "./types"; diff --git a/src/settings/parser.ts b/src/settings/parser.ts new file mode 100644 index 0000000..f229d4a --- /dev/null +++ b/src/settings/parser.ts @@ -0,0 +1,55 @@ +/** + * Parser for Xcode workspace settings files (WorkspaceSettings.xcsettings) + * + * Uses @expo/plist to parse plist into TypeScript objects. + */ +import plist from "@expo/plist"; + +import type { WorkspaceSettings } from "./types"; + +/** + * Parse a workspace settings plist string into a typed object. + */ +export function parse(plistString: string): WorkspaceSettings { + const parsed = plist.parse(plistString) as Record; + + const result: WorkspaceSettings = {}; + + if (typeof parsed.BuildSystemType === "string") { + result.BuildSystemType = parsed.BuildSystemType as WorkspaceSettings["BuildSystemType"]; + } + + if (typeof parsed.DerivedDataLocationStyle === "string") { + result.DerivedDataLocationStyle = parsed.DerivedDataLocationStyle as WorkspaceSettings["DerivedDataLocationStyle"]; + } + + if (typeof parsed.DerivedDataCustomLocation === "string") { + result.DerivedDataCustomLocation = parsed.DerivedDataCustomLocation; + } + + if (typeof parsed.IDEWorkspaceSharedSettings_AutocreateContextsIfNeeded === "boolean") { + result.IDEWorkspaceSharedSettings_AutocreateContextsIfNeeded = parsed.IDEWorkspaceSharedSettings_AutocreateContextsIfNeeded; + } + + if (typeof parsed.PreviewsEnabled === "boolean") { + result.PreviewsEnabled = parsed.PreviewsEnabled; + } + + if (typeof parsed.BuildLocationStyle === "string") { + result.BuildLocationStyle = parsed.BuildLocationStyle as WorkspaceSettings["BuildLocationStyle"]; + } + + if (typeof parsed.LiveSourceIssuesEnabled === "boolean") { + result.LiveSourceIssuesEnabled = parsed.LiveSourceIssuesEnabled; + } + + if (typeof parsed.GatherCoverageData === "boolean") { + result.GatherCoverageData = parsed.GatherCoverageData; + } + + if (typeof parsed.IDEIndexEnableInWorkspace === "boolean") { + result.IDEIndexEnableInWorkspace = parsed.IDEIndexEnableInWorkspace; + } + + return result; +} diff --git a/src/settings/types.ts b/src/settings/types.ts new file mode 100644 index 0000000..1732fcc --- /dev/null +++ b/src/settings/types.ts @@ -0,0 +1,35 @@ +/** + * TypeScript definitions for Xcode workspace settings files (WorkspaceSettings.xcsettings) + * + * These plist files store workspace configuration in xcshareddata directories. + */ + +/** Workspace settings configuration */ +export interface WorkspaceSettings { + /** Build system type: "Original" (legacy) or "New" (modern, default) */ + BuildSystemType?: "Original" | "New"; + + /** Derived data location style: "Default", "WorkspaceRelativePath", or "CustomLocation" */ + DerivedDataLocationStyle?: "Default" | "WorkspaceRelativePath" | "CustomLocation"; + + /** Custom derived data location path (when DerivedDataLocationStyle is "CustomLocation") */ + DerivedDataCustomLocation?: string; + + /** Whether to auto-create schemes for targets */ + IDEWorkspaceSharedSettings_AutocreateContextsIfNeeded?: boolean; + + /** Whether Xcode previews are enabled */ + PreviewsEnabled?: boolean; + + /** Build location type (use project settings, derived data, etc.) */ + BuildLocationStyle?: "UseAppPreferences" | "UseNewBuildSystem" | "UsePerConfigurationBuildLocations" | "UseTargetSettings"; + + /** Live issues enabled */ + LiveSourceIssuesEnabled?: boolean; + + /** Code coverage enabled for test targets */ + GatherCoverageData?: boolean; + + /** Index while building for workspace */ + IDEIndexEnableInWorkspace?: boolean; +} diff --git a/src/settings/writer.ts b/src/settings/writer.ts new file mode 100644 index 0000000..7371331 --- /dev/null +++ b/src/settings/writer.ts @@ -0,0 +1,54 @@ +/** + * Writer for Xcode workspace settings files (WorkspaceSettings.xcsettings) + * + * Uses @expo/plist to serialize TypeScript objects to plist format. + */ +import plist from "@expo/plist"; + +import type { WorkspaceSettings } from "./types"; + +/** + * Build a workspace settings plist string from a typed object. + */ +export function build(settings: WorkspaceSettings): string { + const obj: Record = {}; + + if (settings.BuildSystemType !== undefined) { + obj.BuildSystemType = settings.BuildSystemType; + } + + if (settings.DerivedDataLocationStyle !== undefined) { + obj.DerivedDataLocationStyle = settings.DerivedDataLocationStyle; + } + + if (settings.DerivedDataCustomLocation !== undefined) { + obj.DerivedDataCustomLocation = settings.DerivedDataCustomLocation; + } + + if (settings.IDEWorkspaceSharedSettings_AutocreateContextsIfNeeded !== undefined) { + obj.IDEWorkspaceSharedSettings_AutocreateContextsIfNeeded = + settings.IDEWorkspaceSharedSettings_AutocreateContextsIfNeeded; + } + + if (settings.PreviewsEnabled !== undefined) { + obj.PreviewsEnabled = settings.PreviewsEnabled; + } + + if (settings.BuildLocationStyle !== undefined) { + obj.BuildLocationStyle = settings.BuildLocationStyle; + } + + if (settings.LiveSourceIssuesEnabled !== undefined) { + obj.LiveSourceIssuesEnabled = settings.LiveSourceIssuesEnabled; + } + + if (settings.GatherCoverageData !== undefined) { + obj.GatherCoverageData = settings.GatherCoverageData; + } + + if (settings.IDEIndexEnableInWorkspace !== undefined) { + obj.IDEIndexEnableInWorkspace = settings.IDEIndexEnableInWorkspace; + } + + return plist.build(obj); +}