diff --git a/README.md b/README.md index 0e640a8..8af86e0 100644 --- a/README.md +++ b/README.md @@ -277,6 +277,84 @@ Workspace file references use location specifiers: - `container:path` - Absolute container reference (rare) - `absolute:path` - Absolute file path +## XCConfig Support + +Parse and manipulate Xcode configuration files (`.xcconfig`). These files define build settings that can be shared across targets and configurations. + +### Low-level API + +```ts +import * as xcconfig from "@bacons/xcode/xcconfig"; +import fs from "fs"; + +// Parse an xcconfig string +const config = xcconfig.parse(` + #include "Base.xcconfig" + PRODUCT_NAME = MyApp + OTHER_LDFLAGS[sdk=iphoneos*] = -framework UIKit +`); + +// Parse from file (resolves #include directives) +const config = xcconfig.parseFile("/path/to/Project.xcconfig"); + +// Flatten build settings (merges includes, applies conditions) +const allSettings = xcconfig.flattenBuildSettings(config); + +// Filter by platform conditions +const iosSettings = xcconfig.flattenBuildSettings(config, { + sdk: "iphoneos", + arch: "arm64", + config: "Release", +}); + +// Serialize back to xcconfig format +const output = xcconfig.build(config); +fs.writeFileSync("/path/to/Project.xcconfig", output); +``` + +### Conditional Settings + +XCConfig supports conditional settings based on SDK, architecture, and configuration: + +``` +// SDK-specific settings +OTHER_LDFLAGS[sdk=iphoneos*] = -framework UIKit +OTHER_LDFLAGS[sdk=macosx*] = -framework AppKit + +// Architecture-specific settings +ARCHS[arch=arm64] = arm64 +ARCHS[arch=x86_64] = x86_64 + +// Configuration-specific settings +GCC_OPTIMIZATION_LEVEL[config=Debug] = 0 +GCC_OPTIMIZATION_LEVEL[config=Release] = s + +// Combined conditions +LIBRARY_SEARCH_PATHS[sdk=iphoneos*][arch=arm64] = /usr/lib/arm64 +``` + +### Include Directives + +``` +// Required include - throws if file not found +#include "Base.xcconfig" + +// Optional include - silently ignored if file not found +#include? "Optional.xcconfig" +``` + +### Variable Expansion + +XCConfig supports variable references and the `$(inherited)` keyword: + +``` +// Reference other settings +PRODUCT_BUNDLE_IDENTIFIER = $(BUNDLE_ID_PREFIX).$(PRODUCT_NAME:lower) + +// Inherit from included files +OTHER_LDFLAGS = $(inherited) -framework UIKit +``` + ## Solution - Uses a hand-optimized single-pass parser that is 11x faster than the legacy `xcode` package (which uses PEG.js). @@ -307,7 +385,7 @@ We support the following types: `Object`, `Array`, `Data`, `String`. Notably, we - [ ] Create robust xcode projects from scratch. - [ ] Skills. - [ ] Import from other tools. -- [ ] **XCConfig** Parsing: `.xcconfig` file parsing with `#include` support and build settings flattening. +- [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. diff --git a/package.json b/package.json index 86e1544..0dd870a 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,10 @@ "./workspace": { "types": "./build/workspace/index.d.ts", "default": "./build/workspace/index.js" + }, + "./xcconfig": { + "types": "./build/xcconfig/index.d.ts", + "default": "./build/xcconfig/index.js" } }, "files": [ diff --git a/src/api/XCBuildConfiguration.ts b/src/api/XCBuildConfiguration.ts index 463ef0f..34ea3b4 100644 --- a/src/api/XCBuildConfiguration.ts +++ b/src/api/XCBuildConfiguration.ts @@ -12,6 +12,8 @@ import type { SansIsa } from "./utils/util.types"; import type { XcodeProject } from "./XcodeProject"; import type { PBXFileReference } from "./PBXFileReference"; import { resolveXcodeBuildSetting } from "./utils/resolveBuildSettings"; +import * as xcconfig from "../xcconfig"; +import type { XCConfig, XCConfigFlattenOptions } from "../xcconfig"; const debug = require("debug")( "xcode:XCBuildConfiguration" @@ -170,6 +172,62 @@ export class XCBuildConfiguration extends AbstractObject" || sourceTree === "SOURCE_ROOT") { + return path.join(root, filePath); + } + if (sourceTree === "" || path.isAbsolute(filePath)) { + return filePath; + } + + return path.join(root, filePath); + } + + /** + * Parse and return the base configuration xcconfig file. + * @returns Parsed XCConfig or null if no base configuration is set or file doesn't exist. + */ + getBaseConfiguration(): XCConfig | null { + const filePath = this.getBaseConfigurationFilePath(); + if (!filePath || !fs.existsSync(filePath)) return null; + + try { + return xcconfig.parseFile(filePath); + } catch (error) { + debug("Failed to parse base configuration: %s", error); + return null; + } + } + + /** + * Get flattened build settings from the base configuration xcconfig file. + * Settings are merged from included files with current file taking precedence. + * + * @param options - Optional filtering by sdk/arch/config + * @returns Flattened key-value map of build settings, or empty object if no base config. + */ + getBaseConfigurationSettings( + options?: XCConfigFlattenOptions + ): Record { + const config = this.getBaseConfiguration(); + if (!config) return {}; + return xcconfig.flattenBuildSettings(config, options); + } } // https://opensource.apple.com/source/pb_makefiles/pb_makefiles-1005/platform-variables.make.auto.html diff --git a/src/xcconfig/__tests__/fixtures/Base.xcconfig b/src/xcconfig/__tests__/fixtures/Base.xcconfig new file mode 100644 index 0000000..7bcab2f --- /dev/null +++ b/src/xcconfig/__tests__/fixtures/Base.xcconfig @@ -0,0 +1,3 @@ +// Base xcconfig - root settings +BASE_SETTING = base_value +OVERRIDDEN_SETTING = from_base diff --git a/src/xcconfig/__tests__/fixtures/Children.xcconfig b/src/xcconfig/__tests__/fixtures/Children.xcconfig new file mode 100644 index 0000000..43eff75 --- /dev/null +++ b/src/xcconfig/__tests__/fixtures/Children.xcconfig @@ -0,0 +1,6 @@ +// Children xcconfig - includes Parent +#include "Parent.xcconfig" + +CHILD_SETTING = child_value +OVERRIDDEN_SETTING = from_child +INHERITED_SETTING = $(inherited) child_part diff --git a/src/xcconfig/__tests__/fixtures/Parent.xcconfig b/src/xcconfig/__tests__/fixtures/Parent.xcconfig new file mode 100644 index 0000000..d94e55b --- /dev/null +++ b/src/xcconfig/__tests__/fixtures/Parent.xcconfig @@ -0,0 +1,6 @@ +// Parent xcconfig - includes Base +#include "Base.xcconfig" + +PARENT_SETTING = parent_value +OVERRIDDEN_SETTING = from_parent +INHERITED_SETTING = parent_part diff --git a/src/xcconfig/__tests__/fixtures/conditional.xcconfig b/src/xcconfig/__tests__/fixtures/conditional.xcconfig new file mode 100644 index 0000000..1cce8b1 --- /dev/null +++ b/src/xcconfig/__tests__/fixtures/conditional.xcconfig @@ -0,0 +1,20 @@ +// Conditional settings based on SDK, architecture, and config +PRODUCT_NAME = MyApp +DEBUG_INFORMATION_FORMAT = dwarf-with-dsym + +// SDK-specific settings +OTHER_LDFLAGS[sdk=iphoneos*] = -framework UIKit +OTHER_LDFLAGS[sdk=iphonesimulator*] = -framework UIKit +OTHER_LDFLAGS[sdk=macosx*] = -framework AppKit + +// Architecture-specific settings +ARCHS[arch=arm64] = arm64 +ARCHS[arch=x86_64] = x86_64 + +// Combined conditions +LIBRARY_SEARCH_PATHS[sdk=iphoneos*][arch=arm64] = $(inherited) /usr/lib/arm64 +LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*][arch=x86_64] = $(inherited) /usr/lib/x86_64 + +// Config-specific settings +DEBUG_INFORMATION_FORMAT[config=Debug] = dwarf +DEBUG_INFORMATION_FORMAT[config=Release] = dwarf-with-dsym diff --git a/src/xcconfig/__tests__/fixtures/optional-include.xcconfig b/src/xcconfig/__tests__/fixtures/optional-include.xcconfig new file mode 100644 index 0000000..aeda826 --- /dev/null +++ b/src/xcconfig/__tests__/fixtures/optional-include.xcconfig @@ -0,0 +1,5 @@ +// Optional include test +#include? "NonExistent.xcconfig" +#include "Base.xcconfig" + +LOCAL_SETTING = local_value diff --git a/src/xcconfig/__tests__/fixtures/simple.xcconfig b/src/xcconfig/__tests__/fixtures/simple.xcconfig new file mode 100644 index 0000000..6029f95 --- /dev/null +++ b/src/xcconfig/__tests__/fixtures/simple.xcconfig @@ -0,0 +1,4 @@ +// Simple xcconfig file +PRODUCT_NAME = MyApp +PRODUCT_BUNDLE_IDENTIFIER = com.example.myapp +SWIFT_VERSION = 5.0 diff --git a/src/xcconfig/__tests__/xcconfig.test.ts b/src/xcconfig/__tests__/xcconfig.test.ts new file mode 100644 index 0000000..26abf0d --- /dev/null +++ b/src/xcconfig/__tests__/xcconfig.test.ts @@ -0,0 +1,379 @@ +import * as path from "node:path"; +import * as xcconfig from "../index"; + +const fixturesDir = path.join(__dirname, "fixtures"); + +describe("xcconfig", () => { + describe("parse", () => { + it("parses simple settings", () => { + const content = ` +PRODUCT_NAME = MyApp +PRODUCT_BUNDLE_IDENTIFIER = com.example.myapp + `; + + const config = xcconfig.parse(content); + + expect(config.buildSettings).toHaveLength(2); + expect(config.buildSettings[0]).toEqual({ + key: "PRODUCT_NAME", + value: "MyApp", + }); + expect(config.buildSettings[1]).toEqual({ + key: "PRODUCT_BUNDLE_IDENTIFIER", + value: "com.example.myapp", + }); + }); + + it("strips comments", () => { + const content = ` +// This is a comment +PRODUCT_NAME = MyApp // inline comment +// Another comment +SWIFT_VERSION = 5.0 + `; + + const config = xcconfig.parse(content); + + expect(config.buildSettings).toHaveLength(2); + expect(config.buildSettings[0].value).toBe("MyApp"); + expect(config.buildSettings[1].value).toBe("5.0"); + }); + + it("parses include directives", () => { + const content = ` +#include "Base.xcconfig" +#include? "Optional.xcconfig" +PRODUCT_NAME = MyApp + `; + + const config = xcconfig.parse(content); + + expect(config.includes).toHaveLength(2); + expect(config.includes[0].include).toEqual({ + path: "Base.xcconfig", + optional: false, + }); + expect(config.includes[1].include).toEqual({ + path: "Optional.xcconfig", + optional: true, + }); + }); + + it("parses conditional settings", () => { + const content = ` +OTHER_LDFLAGS[sdk=iphoneos*] = -framework UIKit +ARCHS[arch=arm64] = arm64 +DEBUG_FORMAT[config=Debug] = dwarf + `; + + const config = xcconfig.parse(content); + + expect(config.buildSettings).toHaveLength(3); + + expect(config.buildSettings[0]).toEqual({ + key: "OTHER_LDFLAGS", + value: "-framework UIKit", + conditions: [{ sdk: "iphoneos*" }], + }); + + expect(config.buildSettings[1]).toEqual({ + key: "ARCHS", + value: "arm64", + conditions: [{ arch: "arm64" }], + }); + + expect(config.buildSettings[2]).toEqual({ + key: "DEBUG_FORMAT", + value: "dwarf", + conditions: [{ config: "Debug" }], + }); + }); + + it("parses multiple conditions on same setting", () => { + const content = ` +LIBRARY_SEARCH_PATHS[sdk=iphoneos*][arch=arm64] = /usr/lib/arm64 + `; + + const config = xcconfig.parse(content); + + expect(config.buildSettings[0]).toEqual({ + key: "LIBRARY_SEARCH_PATHS", + value: "/usr/lib/arm64", + conditions: [{ sdk: "iphoneos*" }, { arch: "arm64" }], + }); + }); + + it("handles empty values", () => { + const content = ` +EMPTY_SETTING = +ANOTHER_EMPTY = + `; + + const config = xcconfig.parse(content); + + expect(config.buildSettings).toHaveLength(2); + expect(config.buildSettings[0].value).toBe(""); + expect(config.buildSettings[1].value).toBe(""); + }); + + it("preserves values with special characters", () => { + const content = ` +HEADER_SEARCH_PATHS = "\${PODS_ROOT}/Headers/Public" "\${SRCROOT}/include" +OTHER_LDFLAGS = $(inherited) -framework UIKit -lz + `; + + const config = xcconfig.parse(content); + + expect(config.buildSettings[0].value).toBe( + '"${PODS_ROOT}/Headers/Public" "${SRCROOT}/include"' + ); + expect(config.buildSettings[1].value).toBe( + "$(inherited) -framework UIKit -lz" + ); + }); + }); + + describe("parseFile", () => { + it("parses simple xcconfig file", () => { + const config = xcconfig.parseFile(path.join(fixturesDir, "simple.xcconfig")); + + expect(config.buildSettings.length).toBeGreaterThan(0); + expect(config.buildSettings.find((s) => s.key === "PRODUCT_NAME")?.value).toBe( + "MyApp" + ); + }); + + it("resolves includes", () => { + const config = xcconfig.parseFile(path.join(fixturesDir, "Children.xcconfig")); + + // Should have resolved Parent.xcconfig + expect(config.includes).toHaveLength(1); + expect(config.includes[0].config).toBeDefined(); + + // Parent should have resolved Base.xcconfig + expect(config.includes[0].config!.includes).toHaveLength(1); + expect(config.includes[0].config!.includes[0].config).toBeDefined(); + }); + + it("handles optional includes for missing files", () => { + const config = xcconfig.parseFile( + path.join(fixturesDir, "optional-include.xcconfig") + ); + + expect(config.includes).toHaveLength(2); + + // First include is optional and missing + expect(config.includes[0].include.optional).toBe(true); + expect(config.includes[0].config).toBeUndefined(); + + // Second include exists + expect(config.includes[1].include.optional).toBe(false); + expect(config.includes[1].config).toBeDefined(); + }); + + it("throws on missing required include", () => { + const content = `#include "NonExistent.xcconfig"`; + const tempPath = path.join(fixturesDir, "_temp_test.xcconfig"); + + require("fs").writeFileSync(tempPath, content); + + try { + expect(() => xcconfig.parseFile(tempPath)).toThrow(/Include file not found/); + } finally { + require("fs").unlinkSync(tempPath); + } + }); + + it("detects circular includes", () => { + const tempDir = fixturesDir; + const aPath = path.join(tempDir, "_circular_a.xcconfig"); + const bPath = path.join(tempDir, "_circular_b.xcconfig"); + + require("fs").writeFileSync(aPath, '#include "_circular_b.xcconfig"\n'); + require("fs").writeFileSync(bPath, '#include "_circular_a.xcconfig"\n'); + + try { + expect(() => xcconfig.parseFile(aPath)).toThrow(/Circular include detected/); + } finally { + require("fs").unlinkSync(aPath); + require("fs").unlinkSync(bPath); + } + }); + }); + + describe("build", () => { + it("serializes simple config", () => { + const config: xcconfig.XCConfig = { + includes: [], + buildSettings: [ + { key: "PRODUCT_NAME", value: "MyApp" }, + { key: "SWIFT_VERSION", value: "5.0" }, + ], + }; + + const output = xcconfig.build(config); + + expect(output).toBe("PRODUCT_NAME = MyApp\nSWIFT_VERSION = 5.0\n"); + }); + + it("serializes includes", () => { + const config: xcconfig.XCConfig = { + includes: [ + { + include: { path: "Base.xcconfig", optional: false }, + resolvedPath: "Base.xcconfig", + }, + { + include: { path: "Optional.xcconfig", optional: true }, + resolvedPath: "Optional.xcconfig", + }, + ], + buildSettings: [{ key: "PRODUCT_NAME", value: "MyApp" }], + }; + + const output = xcconfig.build(config); + + expect(output).toContain('#include "Base.xcconfig"'); + expect(output).toContain('#include? "Optional.xcconfig"'); + expect(output).toContain("PRODUCT_NAME = MyApp"); + }); + + it("serializes conditional settings", () => { + const config: xcconfig.XCConfig = { + includes: [], + buildSettings: [ + { + key: "OTHER_LDFLAGS", + value: "-framework UIKit", + conditions: [{ sdk: "iphoneos*" }], + }, + { + key: "LIBRARY_SEARCH_PATHS", + value: "/usr/lib", + conditions: [{ sdk: "iphoneos*" }, { arch: "arm64" }], + }, + ], + }; + + const output = xcconfig.build(config); + + expect(output).toContain("OTHER_LDFLAGS[sdk=iphoneos*] = -framework UIKit"); + expect(output).toContain( + "LIBRARY_SEARCH_PATHS[sdk=iphoneos*][arch=arm64] = /usr/lib" + ); + }); + + it("round-trips simple config", () => { + const original = `PRODUCT_NAME = MyApp +SWIFT_VERSION = 5.0 +`; + + const parsed = xcconfig.parse(original); + const output = xcconfig.build(parsed); + + expect(output).toBe(original); + }); + + it("round-trips conditional config", () => { + const original = `OTHER_LDFLAGS[sdk=iphoneos*] = -framework UIKit +ARCHS[arch=arm64] = arm64 +`; + + const parsed = xcconfig.parse(original); + const output = xcconfig.build(parsed); + + expect(output).toBe(original); + }); + }); + + describe("flattenBuildSettings", () => { + it("merges includes in order", () => { + const config = xcconfig.parseFile(path.join(fixturesDir, "Children.xcconfig")); + const settings = xcconfig.flattenBuildSettings(config); + + // Base setting from Base.xcconfig + expect(settings["BASE_SETTING"]).toBe("base_value"); + + // Parent setting from Parent.xcconfig + expect(settings["PARENT_SETTING"]).toBe("parent_value"); + + // Child setting from Children.xcconfig + expect(settings["CHILD_SETTING"]).toBe("child_value"); + + // Overridden setting should be from child (last wins) + expect(settings["OVERRIDDEN_SETTING"]).toBe("from_child"); + }); + + it("handles $(inherited)", () => { + const config = xcconfig.parseFile(path.join(fixturesDir, "Children.xcconfig")); + const settings = xcconfig.flattenBuildSettings(config); + + // INHERITED_SETTING should combine parent and child values + expect(settings["INHERITED_SETTING"]).toBe("parent_part child_part"); + }); + + it("filters by SDK condition", () => { + const config = xcconfig.parseFile(path.join(fixturesDir, "conditional.xcconfig")); + + const iosSettings = xcconfig.flattenBuildSettings(config, { + sdk: "iphoneos", + }); + expect(iosSettings["OTHER_LDFLAGS"]).toBe("-framework UIKit"); + + const macSettings = xcconfig.flattenBuildSettings(config, { + sdk: "macosx", + }); + expect(macSettings["OTHER_LDFLAGS"]).toBe("-framework AppKit"); + }); + + it("filters by arch condition", () => { + const config = xcconfig.parseFile(path.join(fixturesDir, "conditional.xcconfig")); + + const arm64Settings = xcconfig.flattenBuildSettings(config, { + arch: "arm64", + }); + expect(arm64Settings["ARCHS"]).toBe("arm64"); + + const x64Settings = xcconfig.flattenBuildSettings(config, { + arch: "x86_64", + }); + expect(x64Settings["ARCHS"]).toBe("x86_64"); + }); + + it("filters by config condition", () => { + const config = xcconfig.parseFile(path.join(fixturesDir, "conditional.xcconfig")); + + const debugSettings = xcconfig.flattenBuildSettings(config, { + config: "Debug", + }); + expect(debugSettings["DEBUG_INFORMATION_FORMAT"]).toBe("dwarf"); + + const releaseSettings = xcconfig.flattenBuildSettings(config, { + config: "Release", + }); + expect(releaseSettings["DEBUG_INFORMATION_FORMAT"]).toBe("dwarf-with-dsym"); + }); + + it("handles wildcard matching", () => { + const config = xcconfig.parseFile(path.join(fixturesDir, "conditional.xcconfig")); + + // iphoneos should match iphoneos* pattern + const settings = xcconfig.flattenBuildSettings(config, { + sdk: "iphoneos17.0", + }); + expect(settings["OTHER_LDFLAGS"]).toBe("-framework UIKit"); + }); + + it("handles combined conditions", () => { + const config = xcconfig.parseFile(path.join(fixturesDir, "conditional.xcconfig")); + + const settings = xcconfig.flattenBuildSettings(config, { + sdk: "iphoneos", + arch: "arm64", + }); + + // $(inherited) is replaced with empty string since there's no prior value + expect(settings["LIBRARY_SEARCH_PATHS"]).toBe(" /usr/lib/arm64"); + }); + }); +}); diff --git a/src/xcconfig/index.ts b/src/xcconfig/index.ts new file mode 100644 index 0000000..f62c771 --- /dev/null +++ b/src/xcconfig/index.ts @@ -0,0 +1,147 @@ +/** + * Low-level API for parsing and building Xcode build configuration files (.xcconfig). + * + * @example + * ```ts + * import * as xcconfig from "@bacons/xcode/xcconfig"; + * + * // Parse xcconfig content + * const config = xcconfig.parse(` + * #include "Base.xcconfig" + * PRODUCT_NAME = MyApp + * OTHER_LDFLAGS[sdk=iphoneos*] = -framework UIKit + * `); + * + * // Parse file with resolved includes + * const config = xcconfig.parseFile("./Project.xcconfig"); + * + * // Flatten build settings (merge includes, apply conditions) + * const settings = xcconfig.flattenBuildSettings(config, { + * sdk: "iphoneos", + * arch: "arm64", + * }); + * + * // Serialize back to xcconfig format + * const content = xcconfig.build(config); + * ``` + */ + +export { parse, parseFile } from "./parser"; +export { build } from "./writer"; +export * from "./types"; + +import type { + XCConfig, + XCConfigSetting, + XCConfigCondition, + XCConfigFlattenOptions, +} from "./types"; + +/** + * Flatten build settings from an xcconfig, merging includes and applying conditions. + * + * Settings are applied in order: + * 1. Included files (recursively, in order) + * 2. Settings in the current file + * + * Later settings override earlier ones. Settings with conditions are only + * included if they match the provided sdk/arch/config options. + * + * @param config - Parsed XCConfig + * @param options - Options for filtering and variable resolution + * @returns Flattened build settings as a key-value map + */ +export function flattenBuildSettings( + config: XCConfig, + options: XCConfigFlattenOptions = {} +): Record { + const settings: Record = {}; + + // Process includes first (they are overridden by local settings) + for (const include of config.includes) { + if (include.config) { + const includedSettings = flattenBuildSettings(include.config, options); + Object.assign(settings, includedSettings); + } + } + + // Process local settings + for (const setting of config.buildSettings) { + if (matchesConditions(setting.conditions, options)) { + const value = resolveInherited(setting.value, setting.key, settings); + settings[setting.key] = value; + } + } + + return settings; +} + +/** + * Check if a setting's conditions match the provided options. + * + * A conditional setting is only included if: + * 1. The options specify the relevant filter (sdk, arch, config) + * 2. The filter value matches the condition's pattern + * + * Settings without conditions always match. + */ +function matchesConditions( + conditions: XCConfigCondition[] | undefined, + options: XCConfigFlattenOptions +): boolean { + if (!conditions || conditions.length === 0) { + return true; + } + + // All conditions must be satisfied + return conditions.every((condition) => { + // If condition specifies sdk, options must also specify sdk and it must match + if (condition.sdk) { + if (!options.sdk) return false; + if (!matchWildcard(options.sdk, condition.sdk)) return false; + } + // If condition specifies arch, options must also specify arch and it must match + if (condition.arch) { + if (!options.arch) return false; + if (!matchWildcard(options.arch, condition.arch)) return false; + } + // If condition specifies config, options must also specify config and it must match + if (condition.config) { + if (!options.config) return false; + if (!matchWildcard(options.config, condition.config)) return false; + } + return true; + }); +} + +/** + * Match a value against a wildcard pattern. + * Supports * as wildcard for any characters. + */ +function matchWildcard(value: string, pattern: string): boolean { + // Convert wildcard pattern to regex + const regexPattern = pattern + .replace(/[.+^${}()|[\]\\]/g, "\\$&") // Escape regex special chars except * + .replace(/\*/g, ".*"); // Convert * to .* + + const regex = new RegExp(`^${regexPattern}$`, "i"); + return regex.test(value); +} + +/** + * Resolve $(inherited) in a value by prepending the existing value. + */ +function resolveInherited( + value: string, + key: string, + existingSettings: Record +): string { + const inheritedPattern = /\$\(inherited\)/gi; + + if (!inheritedPattern.test(value)) { + return value; + } + + const existingValue = existingSettings[key] ?? ""; + return value.replace(inheritedPattern, existingValue); +} diff --git a/src/xcconfig/parser.ts b/src/xcconfig/parser.ts new file mode 100644 index 0000000..957c6d9 --- /dev/null +++ b/src/xcconfig/parser.ts @@ -0,0 +1,192 @@ +/** + * Parser for Xcode build configuration files (.xcconfig) + * + * Uses regex-based parsing for the simple line-oriented format. + */ +import * as fs from "node:fs"; +import * as path from "node:path"; + +import type { + XCConfig, + XCConfigInclude, + XCConfigIncludeResolved, + XCConfigSetting, + XCConfigCondition, + XCConfigParseOptions, +} from "./types"; + +// Regex patterns for xcconfig parsing +const INCLUDE_REGEX = /^#include(\?)?\s*"(.+)"$/; +const SETTING_REGEX = /^([a-zA-Z_][a-zA-Z0-9_]*(?:\[[^\]]+\])*)\s*=\s*(.*)$/; +const CONDITION_REGEX = /\[([a-zA-Z]+)=([^\]]+)\]/g; +const COMMENT_REGEX = /\/\/.*/; + +/** + * Parse xcconfig content from a string. + * + * @param content - The xcconfig file content + * @returns Parsed XCConfig object (without resolved includes) + */ +export function parse(content: string): XCConfig { + const includes: XCConfigIncludeResolved[] = []; + const buildSettings: XCConfigSetting[] = []; + + const lines = content.split(/\r?\n/); + + for (const rawLine of lines) { + // Strip comments (// to end of line) + const line = rawLine.replace(COMMENT_REGEX, "").trim(); + + // Skip empty lines + if (!line) continue; + + // Check for include directive + const includeMatch = line.match(INCLUDE_REGEX); + if (includeMatch) { + const optional = includeMatch[1] === "?"; + const includePath = includeMatch[2]; + includes.push({ + include: { path: includePath, optional }, + resolvedPath: includePath, // Will be resolved properly in parseFile + config: undefined, + }); + continue; + } + + // Check for setting + const settingMatch = line.match(SETTING_REGEX); + if (settingMatch) { + const keyWithConditions = settingMatch[1]; + const value = settingMatch[2].trim(); + + // Extract conditions from key + const conditions = parseConditions(keyWithConditions); + const key = keyWithConditions.replace(CONDITION_REGEX, ""); + + buildSettings.push({ + key, + value, + conditions: conditions.length > 0 ? conditions : undefined, + }); + } + } + + return { includes, buildSettings }; +} + +/** + * Parse conditions from a key like "OTHER_LDFLAGS[sdk=iphoneos*][arch=arm64]" + */ +function parseConditions(keyWithConditions: string): XCConfigCondition[] { + const conditions: XCConfigCondition[] = []; + let match: RegExpExecArray | null; + const regex = new RegExp(CONDITION_REGEX); + + while ((match = regex.exec(keyWithConditions)) !== null) { + const conditionType = match[1].toLowerCase(); + const conditionValue = match[2]; + + const condition: XCConfigCondition = {}; + if (conditionType === "sdk") { + condition.sdk = conditionValue; + } else if (conditionType === "arch") { + condition.arch = conditionValue; + } else if (conditionType === "config") { + condition.config = conditionValue; + } + + if (Object.keys(condition).length > 0) { + conditions.push(condition); + } + } + + return conditions; +} + +/** + * Parse an xcconfig file and resolve includes. + * + * @param filePath - Path to the xcconfig file + * @param options - Parse options + * @returns Parsed XCConfig with resolved includes + */ +export function parseFile( + filePath: string, + options: XCConfigParseOptions = {} +): XCConfig { + const { + basePath = path.dirname(filePath), + resolveIncludes = true, + visitedPaths = new Set(), + } = options; + + const absolutePath = path.isAbsolute(filePath) + ? filePath + : path.resolve(basePath, filePath); + + // Cycle detection + if (visitedPaths.has(absolutePath)) { + throw new Error(`Circular include detected: ${absolutePath}`); + } + + const content = fs.readFileSync(absolutePath, "utf-8"); + const config = parse(content); + + if (!resolveIncludes) { + return config; + } + + // Track this path for cycle detection + const newVisitedPaths = new Set(visitedPaths); + newVisitedPaths.add(absolutePath); + + // Resolve includes + const resolvedIncludes: XCConfigIncludeResolved[] = []; + const fileDir = path.dirname(absolutePath); + + for (const includeEntry of config.includes) { + const includePath = includeEntry.include.path; + const resolvedPath = path.resolve(fileDir, includePath); + + try { + if (!fs.existsSync(resolvedPath)) { + if (includeEntry.include.optional) { + resolvedIncludes.push({ + include: includeEntry.include, + resolvedPath, + config: undefined, + }); + continue; + } + throw new Error(`Include file not found: ${resolvedPath}`); + } + + const includedConfig = parseFile(resolvedPath, { + basePath: path.dirname(resolvedPath), + resolveIncludes: true, + visitedPaths: newVisitedPaths, + }); + + resolvedIncludes.push({ + include: includeEntry.include, + resolvedPath, + config: includedConfig, + }); + } catch (error) { + if (includeEntry.include.optional) { + resolvedIncludes.push({ + include: includeEntry.include, + resolvedPath, + config: undefined, + }); + } else { + throw error; + } + } + } + + return { + includes: resolvedIncludes, + buildSettings: config.buildSettings, + }; +} diff --git a/src/xcconfig/types.ts b/src/xcconfig/types.ts new file mode 100644 index 0000000..cce6017 --- /dev/null +++ b/src/xcconfig/types.ts @@ -0,0 +1,96 @@ +/** + * TypeScript definitions for Xcode build configuration files (.xcconfig) + * + * XCConfig files are simple text files that define build settings for Xcode + * projects and targets. They support includes, conditional settings, and + * variable expansion. + */ + +/** + * Condition for conditional build settings. + * Settings can be conditioned on SDK, architecture, or configuration. + * + * Example: `OTHER_LDFLAGS[sdk=iphoneos*][arch=arm64] = -framework UIKit` + */ +export interface XCConfigCondition { + /** SDK condition, e.g., "iphoneos*", "macosx*" */ + sdk?: string; + /** Architecture condition, e.g., "arm64", "x86_64" */ + arch?: string; + /** Configuration condition, e.g., "Debug", "Release" */ + config?: string; +} + +/** + * A single build setting with optional conditions. + */ +export interface XCConfigSetting { + /** Setting key, e.g., "OTHER_LDFLAGS" */ + key: string; + /** Setting value, e.g., "-framework UIKit" */ + value: string; + /** Optional conditions for this setting */ + conditions?: XCConfigCondition[]; +} + +/** + * An include directive. + * + * Supports both required and optional includes: + * - `#include "path.xcconfig"` - Required include + * - `#include? "path.xcconfig"` - Optional include (no error if missing) + */ +export interface XCConfigInclude { + /** Path to the included xcconfig file */ + path: string; + /** Whether this is an optional include (#include? vs #include) */ + optional: boolean; +} + +/** + * A resolved include with parsed content. + */ +export interface XCConfigIncludeResolved { + /** The original include directive */ + include: XCConfigInclude; + /** Absolute resolved path to the file */ + resolvedPath: string; + /** Parsed config if successfully loaded, undefined if optional and missing */ + config?: XCConfig; +} + +/** + * Parsed xcconfig file. + */ +export interface XCConfig { + /** Include directives with resolved content */ + includes: XCConfigIncludeResolved[]; + /** Build settings defined in this file */ + buildSettings: XCConfigSetting[]; +} + +/** + * Options for parsing xcconfig files. + */ +export interface XCConfigParseOptions { + /** Base directory for resolving relative include paths */ + basePath?: string; + /** Whether to recursively parse included files (default: true) */ + resolveIncludes?: boolean; + /** Set of already-visited paths for cycle detection */ + visitedPaths?: Set; +} + +/** + * Options for flattening build settings. + */ +export interface XCConfigFlattenOptions { + /** SDK to filter conditions, e.g., "iphoneos" */ + sdk?: string; + /** Architecture to filter conditions, e.g., "arm64" */ + arch?: string; + /** Configuration to filter conditions, e.g., "Debug" */ + config?: string; + /** Resolver function for variable expansion */ + resolver?: (key: string) => string | undefined; +} diff --git a/src/xcconfig/writer.ts b/src/xcconfig/writer.ts new file mode 100644 index 0000000..c59f2e2 --- /dev/null +++ b/src/xcconfig/writer.ts @@ -0,0 +1,69 @@ +/** + * Writer for Xcode build configuration files (.xcconfig) + * + * Serializes XCConfig objects back to xcconfig format. + */ +import type { XCConfig, XCConfigSetting, XCConfigCondition } from "./types"; + +/** + * Build an xcconfig string from a typed XCConfig object. + * + * @param config - The XCConfig object to serialize + * @returns xcconfig file content as a string + */ +export function build(config: XCConfig): string { + const lines: string[] = []; + + // Write includes first + for (const include of config.includes) { + const optional = include.include.optional ? "?" : ""; + lines.push(`#include${optional} "${include.include.path}"`); + } + + // Add blank line between includes and settings if both exist + if (config.includes.length > 0 && config.buildSettings.length > 0) { + lines.push(""); + } + + // Write settings + for (const setting of config.buildSettings) { + lines.push(buildSetting(setting)); + } + + return lines.join("\n") + (lines.length > 0 ? "\n" : ""); +} + +/** + * Build a single setting line. + */ +function buildSetting(setting: XCConfigSetting): string { + let key = setting.key; + + // Append conditions + if (setting.conditions) { + for (const condition of setting.conditions) { + key += buildCondition(condition); + } + } + + return `${key} = ${setting.value}`; +} + +/** + * Build a condition string like "[sdk=iphoneos*]" + */ +function buildCondition(condition: XCConfigCondition): string { + const parts: string[] = []; + + if (condition.sdk) { + parts.push(`[sdk=${condition.sdk}]`); + } + if (condition.arch) { + parts.push(`[arch=${condition.arch}]`); + } + if (condition.config) { + parts.push(`[config=${condition.config}]`); + } + + return parts.join(""); +}