@swiftbrowser/xcodeproj 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,85 @@
1
- # Temporary Holding Version
1
+ # @swiftbrowser/xcodeproj
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Reads Xcode projects without Xcode: a pure-TypeScript parser for `project.pbxproj`
4
+ (Node built-ins only) that answers the questions a CLI has about an iOS app:
5
+ which targets exist, which Swift files and asset catalogs they compile, and what
6
+ their build settings are.
7
+
8
+ ```ts
9
+ import { findXcodeProjects, readXcodeProject } from '@swiftbrowser/xcodeproj';
10
+
11
+ const [xcodeproj] = findXcodeProjects(process.cwd()); // ./*.xcodeproj, else one level down
12
+ const project = readXcodeProject(xcodeproj);
13
+ const [app] = project.appTargets(); // targets whose product is an application
14
+
15
+ app.name; // 'GuessTheFlag'
16
+ app.productName; // PRODUCT_NAME with $(TARGET_NAME) resolved
17
+ app.bundleIdentifier; // PRODUCT_BUNDLE_IDENTIFIER
18
+ app.displayName; // INFOPLIST_KEY_CFBundleDisplayName, else CFBundleDisplayName from INFOPLIST_FILE
19
+ app.appIconName; // ASSETCATALOG_COMPILER_APPICON_NAME
20
+ app.swiftVersion; // SWIFT_VERSION
21
+ app.deploymentTarget; // IPHONEOS_DEPLOYMENT_TARGET
22
+ app.sources; // absolute paths of the .swift files, in navigator order
23
+ app.resources; // absolute paths of the Resources build phase entries
24
+ app.assetCatalogs; // the *.xcassets among them (and in synchronized folders)
25
+ app.dependencies; // names of target dependencies
26
+ app.packageProducts; // Swift package products the target links
27
+ app.settings('Release'); // merged build settings for a configuration
28
+ ```
29
+
30
+ ## API
31
+
32
+ - `readXcodeProject(xcodeprojPath)` parses `<xcodeprojPath>/project.pbxproj` and
33
+ returns an `XcodeProject`: `path`, `root` (the directory containing the
34
+ bundle, what `$(SRCROOT)` means), `objectVersion`, `targets` (every
35
+ `PBXNativeTarget`) and `appTargets()`.
36
+ - `parsePbxproj(text)` returns the raw object graph: `{ objectVersion,
37
+ rootObject, objects }`, where each object has an `isa` plus arbitrary fields
38
+ (strings, arrays, nested dictionaries). `parseOldStylePlist(text)` parses any
39
+ old-style property list fragment.
40
+ - `xcodeProjectFromDocument(doc, xcodeprojPath)` interprets an already parsed
41
+ document as the project at that path.
42
+ - `findXcodeProjects(dir)` lists the `*.xcodeproj` bundles directly in `dir`,
43
+ else one level down (sorted), skipping `Pods/`, `Carthage/`, `DerivedData/`,
44
+ `.build/`, `node_modules/` and dot-directories.
45
+ - `parseXmlPlist(text)` is the minimal XML plist reader used for `Info.plist`.
46
+
47
+ ## How paths are resolved
48
+
49
+ A file's absolute path is the project root plus the chain of group `path`s
50
+ leading to it, following each object's `sourceTree`: `<group>` is relative to
51
+ the parent group, `SOURCE_ROOT` to the root, `<absolute>` is absolute; others
52
+ (`BUILT_PRODUCTS_DIR`, `SDKROOT`, ...) fall back to the root. Groups without a
53
+ `path` contribute nothing. Build phase files are listed in the order of the
54
+ group tree (what the Xcode navigator shows), not the arbitrary order of the
55
+ build phase. Paths are resolved exactly as written; a project whose group `path`
56
+ differs from the folder on disk only in letter case works on macOS but not on a
57
+ case-sensitive filesystem.
58
+
59
+ Xcode 16 "synchronized folders" (`PBXFileSystemSynchronizedRootGroup`
60
+ referenced from a target's `fileSystemSynchronizedGroups`) are read from disk:
61
+ every `*.swift` under the folder (recursive, sorted) is a source of the target
62
+ and every `*.xcassets` an asset catalog, minus the paths listed in the target's
63
+ `PBXFileSystemSynchronizedBuildFileExceptionSet.membershipExceptions`. A folder
64
+ missing on disk contributes nothing.
65
+
66
+ ## Build settings
67
+
68
+ `target.settings(configuration?)` merges the project-level `buildSettings` of
69
+ the named `XCBuildConfiguration` with the target-level ones on top. The default
70
+ configuration is `Debug` when the target has one, else the configuration list's
71
+ `defaultConfigurationName`. Array-valued settings are space-joined.
72
+ `$(TARGET_NAME)`, `$(PRODUCT_NAME)`, `$(SRCROOT)` and `$(PROJECT_DIR)` (also in
73
+ `${...}` form) are expanded; every other `$(...)` reference, including
74
+ `$(inherited)` and modifiers such as `$(X:rfc1034identifier)`, is left as is.
75
+
76
+ ## Not handled
77
+
78
+ - `.xcconfig` files referenced by `baseConfigurationReference` are not read, so
79
+ settings defined only there are absent.
80
+ - Binary `Info.plist` files are skipped (only XML plists are read).
81
+ - `PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet` (Xcode 16
82
+ per-build-phase exceptions) and `PBXBuildRule`s are ignored.
83
+ - Workspaces (`.xcworkspace`), referenced projects (`PBXReferenceProxy`) and
84
+ `PBXAggregateTarget`/`PBXLegacyTarget` are not modelled; only
85
+ `PBXNativeTarget`s appear in `targets`.
package/dist/find.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Locating `.xcodeproj` bundles on disk, for a CLI run from a project folder.
3
+ */
4
+ /**
5
+ * The `.xcodeproj` bundles directly in `dir`, as absolute paths (sorted). When
6
+ * there are none, looks one level down, skipping `Pods/`, `Carthage/`,
7
+ * `DerivedData/`, `.build/`, `node_modules/` and dot-directories.
8
+ */
9
+ export declare function findXcodeProjects(dir: string): string[];
package/dist/find.js ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Locating `.xcodeproj` bundles on disk, for a CLI run from a project folder.
3
+ */
4
+ import * as fs from 'node:fs';
5
+ import * as path from 'node:path';
6
+ /** Directories whose `.xcodeproj`s are generated or third party, never the user's app. */
7
+ const IGNORED_DIRECTORIES = new Set(['Pods', 'Carthage', 'DerivedData', '.build', 'node_modules']);
8
+ /**
9
+ * The `.xcodeproj` bundles directly in `dir`, as absolute paths (sorted). When
10
+ * there are none, looks one level down, skipping `Pods/`, `Carthage/`,
11
+ * `DerivedData/`, `.build/`, `node_modules/` and dot-directories.
12
+ */
13
+ export function findXcodeProjects(dir) {
14
+ const absolute = path.resolve(dir);
15
+ const direct = xcodeProjectsIn(absolute);
16
+ if (direct.length > 0)
17
+ return direct;
18
+ const nested = [];
19
+ for (const entry of readDirectories(absolute)) {
20
+ if (IGNORED_DIRECTORIES.has(entry) || entry.startsWith('.'))
21
+ continue;
22
+ nested.push(...xcodeProjectsIn(path.join(absolute, entry)));
23
+ }
24
+ return nested.sort();
25
+ }
26
+ function xcodeProjectsIn(dir) {
27
+ return readDirectories(dir)
28
+ .filter((name) => name.endsWith('.xcodeproj'))
29
+ .sort()
30
+ .map((name) => path.join(dir, name));
31
+ }
32
+ /** Names of the subdirectories of `dir` (empty when it cannot be read). */
33
+ function readDirectories(dir) {
34
+ try {
35
+ return fs
36
+ .readdirSync(dir, { withFileTypes: true })
37
+ .filter((entry) => entry.isDirectory())
38
+ .map((entry) => entry.name);
39
+ }
40
+ catch {
41
+ return [];
42
+ }
43
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @swiftbrowser/xcodeproj: reads Xcode projects without Xcode.
3
+ *
4
+ * - `readXcodeProject(path)` interprets `<path>/project.pbxproj` as targets
5
+ * with resolved sources, asset catalogs and build settings.
6
+ * - `parsePbxproj(text)` gives the raw object graph.
7
+ * - `findXcodeProjects(dir)` locates `.xcodeproj` bundles.
8
+ */
9
+ export { parsePbxproj, parseOldStylePlist, tokenizePbxproj, PbxprojSyntaxError, type PbxValue, type PbxDict, type PbxObject, type PbxprojDocument, } from './pbxproj.js';
10
+ export { readXcodeProject, xcodeProjectFromDocument, expandVariables, APPLICATION_PRODUCT_TYPE, type XcodeProject, type XcodeTarget, } from './project.js';
11
+ export { findXcodeProjects } from './find.js';
12
+ export { parseXmlPlist, type PlistValue, type PlistDict } from './plist.js';
package/dist/index.js ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @swiftbrowser/xcodeproj: reads Xcode projects without Xcode.
3
+ *
4
+ * - `readXcodeProject(path)` interprets `<path>/project.pbxproj` as targets
5
+ * with resolved sources, asset catalogs and build settings.
6
+ * - `parsePbxproj(text)` gives the raw object graph.
7
+ * - `findXcodeProjects(dir)` locates `.xcodeproj` bundles.
8
+ */
9
+ export { parsePbxproj, parseOldStylePlist, tokenizePbxproj, PbxprojSyntaxError, } from './pbxproj.js';
10
+ export { readXcodeProject, xcodeProjectFromDocument, expandVariables, APPLICATION_PRODUCT_TYPE, } from './project.js';
11
+ export { findXcodeProjects } from './find.js';
12
+ export { parseXmlPlist } from './plist.js';
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Parser for `project.pbxproj`, the file inside every `.xcodeproj` bundle.
3
+ *
4
+ * The format is the old-style ASCII (NeXTSTEP) property list:
5
+ *
6
+ * // !$*UTF8*$!
7
+ * {
8
+ * archiveVersion = 1;
9
+ * objectVersion = 56;
10
+ * objects = {
11
+ * 518D77712AD7303000E0BF20 (comment: ContentView.swift) = {isa = PBXFileReference; path = ContentView.swift; sourceTree = "<group>"; };
12
+ * ...
13
+ * };
14
+ * rootObject = 518D77662AD7303000E0BF20 (comment: Project object);
15
+ * }
16
+ *
17
+ * Dictionaries are `{ key = value; }`, arrays are `( a, b, )`, and every scalar is
18
+ * a string: either an unquoted word (letters, digits, `_ . / $ - + :` ...) or a
19
+ * double-quoted string with backslash escapes. Block comments (slash-star) and
20
+ * line comments (double slash) may appear anywhere; Xcode puts a block comment
21
+ * with the object's name after every object id, as sketched above. The parser
22
+ * is tolerant of trailing commas and of a missing `;` before `}`.
23
+ *
24
+ * This module only builds the raw object graph; `project.ts` interprets it.
25
+ */
26
+ /** A pbxproj value: a string, an array of values or a dictionary of values. */
27
+ export type PbxValue = string | PbxValue[] | PbxDict;
28
+ export interface PbxDict {
29
+ [key: string]: PbxValue;
30
+ }
31
+ /** One entry of `objects`: every object has an `isa` class name plus arbitrary fields. */
32
+ export interface PbxObject extends PbxDict {
33
+ isa: string;
34
+ }
35
+ /** The parsed file. `objects` is keyed by object id (usually 24 hex digits). */
36
+ export interface PbxprojDocument {
37
+ archiveVersion: number;
38
+ objectVersion: number;
39
+ rootObject: string;
40
+ objects: Record<string, PbxObject>;
41
+ }
42
+ /** Thrown for malformed input; `offset` is the character index where parsing failed. */
43
+ export declare class PbxprojSyntaxError extends Error {
44
+ readonly offset: number;
45
+ readonly line: number;
46
+ readonly column: number;
47
+ constructor(message: string, offset: number, line: number, column: number);
48
+ }
49
+ type TokenKind = '{' | '}' | '(' | ')' | '=' | ';' | ',' | 'string' | 'eof';
50
+ interface Token {
51
+ kind: TokenKind;
52
+ /** The string value (unescaped) for `string` tokens; the punctuation otherwise. */
53
+ value: string;
54
+ /** Character offset of the token's first character, for error messages. */
55
+ offset: number;
56
+ }
57
+ /** Breaks the text into tokens, dropping comments. */
58
+ export declare function tokenizePbxproj(text: string): Token[];
59
+ /**
60
+ * Parses any old-style property list text into its value tree. `parsePbxproj`
61
+ * is the usual entry point; this one is handy for fragments and tests.
62
+ */
63
+ export declare function parseOldStylePlist(text: string): PbxValue;
64
+ /** Parses the text of a `project.pbxproj` file into its raw object graph. */
65
+ export declare function parsePbxproj(text: string): PbxprojDocument;
66
+ export declare function isDict(value: PbxValue | undefined): value is PbxDict;
67
+ export {};
@@ -0,0 +1,287 @@
1
+ /**
2
+ * Parser for `project.pbxproj`, the file inside every `.xcodeproj` bundle.
3
+ *
4
+ * The format is the old-style ASCII (NeXTSTEP) property list:
5
+ *
6
+ * // !$*UTF8*$!
7
+ * {
8
+ * archiveVersion = 1;
9
+ * objectVersion = 56;
10
+ * objects = {
11
+ * 518D77712AD7303000E0BF20 (comment: ContentView.swift) = {isa = PBXFileReference; path = ContentView.swift; sourceTree = "<group>"; };
12
+ * ...
13
+ * };
14
+ * rootObject = 518D77662AD7303000E0BF20 (comment: Project object);
15
+ * }
16
+ *
17
+ * Dictionaries are `{ key = value; }`, arrays are `( a, b, )`, and every scalar is
18
+ * a string: either an unquoted word (letters, digits, `_ . / $ - + :` ...) or a
19
+ * double-quoted string with backslash escapes. Block comments (slash-star) and
20
+ * line comments (double slash) may appear anywhere; Xcode puts a block comment
21
+ * with the object's name after every object id, as sketched above. The parser
22
+ * is tolerant of trailing commas and of a missing `;` before `}`.
23
+ *
24
+ * This module only builds the raw object graph; `project.ts` interprets it.
25
+ */
26
+ /** Thrown for malformed input; `offset` is the character index where parsing failed. */
27
+ export class PbxprojSyntaxError extends Error {
28
+ offset;
29
+ line;
30
+ column;
31
+ constructor(message, offset, line, column) {
32
+ super(`${message} (line ${line}, column ${column})`);
33
+ this.offset = offset;
34
+ this.line = line;
35
+ this.column = column;
36
+ this.name = 'PbxprojSyntaxError';
37
+ }
38
+ }
39
+ const PUNCTUATION = new Set(['{', '}', '(', ')', '=', ';', ',']);
40
+ /** Characters that end an unquoted word (besides whitespace and comment starts). */
41
+ const WORD_TERMINATORS = new Set([...PUNCTUATION, '"']);
42
+ function isWhitespace(ch) {
43
+ return ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r' || ch === '\f' || ch === '\v';
44
+ }
45
+ /** Escape sequences recognised inside quoted strings (besides `\\uXXXX`, octal and the quote/backslash themselves). */
46
+ const SIMPLE_ESCAPES = {
47
+ n: '\n',
48
+ t: '\t',
49
+ r: '\r',
50
+ a: '\x07',
51
+ b: '\b',
52
+ f: '\f',
53
+ v: '\v',
54
+ '"': '"',
55
+ "'": "'",
56
+ '\\': '\\',
57
+ };
58
+ /** Breaks the text into tokens, dropping comments. */
59
+ export function tokenizePbxproj(text) {
60
+ const tokens = [];
61
+ const length = text.length;
62
+ let i = 0;
63
+ // A function declaration (not an arrow) so TypeScript treats calls as assertions.
64
+ function fail(message, offset) {
65
+ throw syntaxError(text, message, offset);
66
+ }
67
+ while (i < length) {
68
+ const ch = text[i];
69
+ if (isWhitespace(ch)) {
70
+ i++;
71
+ continue;
72
+ }
73
+ // Comments: `/* ... */` and `// ...` to end of line.
74
+ if (ch === '/' && text[i + 1] === '*') {
75
+ const end = text.indexOf('*/', i + 2);
76
+ if (end < 0)
77
+ fail('Unterminated block comment', i);
78
+ i = end + 2;
79
+ continue;
80
+ }
81
+ if (ch === '/' && text[i + 1] === '/') {
82
+ const end = text.indexOf('\n', i + 2);
83
+ i = end < 0 ? length : end + 1;
84
+ continue;
85
+ }
86
+ if (PUNCTUATION.has(ch)) {
87
+ tokens.push({ kind: ch, value: ch, offset: i });
88
+ i++;
89
+ continue;
90
+ }
91
+ if (ch === '"') {
92
+ const start = i;
93
+ i++;
94
+ let value = '';
95
+ for (;;) {
96
+ if (i >= length)
97
+ fail('Unterminated string', start);
98
+ const c = text[i];
99
+ if (c === '"') {
100
+ i++;
101
+ break;
102
+ }
103
+ if (c !== '\\') {
104
+ value += c;
105
+ i++;
106
+ continue;
107
+ }
108
+ // Backslash escape.
109
+ const e = text[i + 1];
110
+ if (e === undefined)
111
+ fail('Unterminated string', start);
112
+ if (e === 'U' || e === 'u') {
113
+ // `\Uxxxx`: up to four hex digits.
114
+ const hex = /^[0-9a-fA-F]{1,4}/.exec(text.slice(i + 2, i + 6));
115
+ if (!hex)
116
+ fail('Invalid \\U escape', i);
117
+ value += String.fromCharCode(parseInt(hex[0], 16));
118
+ i += 2 + hex[0].length;
119
+ }
120
+ else if (e >= '0' && e <= '7') {
121
+ // `\ooo`: up to three octal digits.
122
+ const oct = /^[0-7]{1,3}/.exec(text.slice(i + 1, i + 4));
123
+ value += String.fromCharCode(parseInt(oct[0], 8));
124
+ i += 1 + oct[0].length;
125
+ }
126
+ else {
127
+ // Unknown escapes keep the character (`\x` -> `x`), like Foundation does.
128
+ value += SIMPLE_ESCAPES[e] ?? e;
129
+ i += 2;
130
+ }
131
+ }
132
+ tokens.push({ kind: 'string', value, offset: start });
133
+ continue;
134
+ }
135
+ // Unquoted word: runs until whitespace, punctuation, a quote or a comment start.
136
+ const start = i;
137
+ while (i < length) {
138
+ const c = text[i];
139
+ if (isWhitespace(c) || WORD_TERMINATORS.has(c))
140
+ break;
141
+ if (c === '/' && (text[i + 1] === '*' || text[i + 1] === '/'))
142
+ break;
143
+ i++;
144
+ }
145
+ if (i === start)
146
+ fail(`Unexpected character ${JSON.stringify(ch)}`, i);
147
+ tokens.push({ kind: 'string', value: text.slice(start, i), offset: start });
148
+ }
149
+ tokens.push({ kind: 'eof', value: '', offset: length });
150
+ return tokens;
151
+ }
152
+ function syntaxError(text, message, offset) {
153
+ let line = 1;
154
+ let lineStart = 0;
155
+ for (let i = 0; i < offset && i < text.length; i++) {
156
+ if (text[i] === '\n') {
157
+ line++;
158
+ lineStart = i + 1;
159
+ }
160
+ }
161
+ return new PbxprojSyntaxError(message, offset, line, offset - lineStart + 1);
162
+ }
163
+ // ----- Parser --------------------------------------------------------------
164
+ class Parser {
165
+ text;
166
+ tokens;
167
+ pos = 0;
168
+ constructor(text, tokens) {
169
+ this.text = text;
170
+ this.tokens = tokens;
171
+ }
172
+ peek() {
173
+ return this.tokens[this.pos];
174
+ }
175
+ next() {
176
+ const token = this.tokens[this.pos];
177
+ if (token.kind !== 'eof')
178
+ this.pos++;
179
+ return token;
180
+ }
181
+ fail(message, token = this.peek()) {
182
+ const found = token.kind === 'eof' ? 'end of input' : JSON.stringify(token.value);
183
+ throw syntaxError(this.text, `${message}, found ${found}`, token.offset);
184
+ }
185
+ expect(kind) {
186
+ const token = this.peek();
187
+ if (token.kind !== kind)
188
+ this.fail(`Expected ${kind}`);
189
+ return this.next();
190
+ }
191
+ /** Parses the whole document: one top-level value followed by end of input. */
192
+ parseDocument() {
193
+ const value = this.parseValue();
194
+ if (this.peek().kind !== 'eof')
195
+ this.fail('Expected end of input');
196
+ return value;
197
+ }
198
+ parseValue() {
199
+ const token = this.peek();
200
+ switch (token.kind) {
201
+ case '{':
202
+ return this.parseDict();
203
+ case '(':
204
+ return this.parseArray();
205
+ case 'string':
206
+ return this.next().value;
207
+ default:
208
+ return this.fail('Expected a value');
209
+ }
210
+ }
211
+ parseDict() {
212
+ this.expect('{');
213
+ const dict = {};
214
+ while (this.peek().kind !== '}') {
215
+ if (this.peek().kind === 'eof')
216
+ this.fail('Unterminated dictionary');
217
+ const key = this.expect('string').value;
218
+ this.expect('=');
219
+ dict[key] = this.parseValue();
220
+ // The `;` after an entry is required by Xcode but we accept a missing one before `}`.
221
+ if (this.peek().kind === ';')
222
+ this.next();
223
+ else if (this.peek().kind === 'eof')
224
+ this.fail('Unterminated dictionary');
225
+ else if (this.peek().kind !== '}')
226
+ this.fail('Expected ; after dictionary entry');
227
+ }
228
+ this.expect('}');
229
+ return dict;
230
+ }
231
+ parseArray() {
232
+ this.expect('(');
233
+ const items = [];
234
+ while (this.peek().kind !== ')') {
235
+ if (this.peek().kind === 'eof')
236
+ this.fail('Unterminated array');
237
+ items.push(this.parseValue());
238
+ // Commas separate items; a trailing comma is the norm. A missing comma is
239
+ // tolerated since values are self-delimiting.
240
+ if (this.peek().kind === ',')
241
+ this.next();
242
+ }
243
+ this.expect(')');
244
+ return items;
245
+ }
246
+ }
247
+ /**
248
+ * Parses any old-style property list text into its value tree. `parsePbxproj`
249
+ * is the usual entry point; this one is handy for fragments and tests.
250
+ */
251
+ export function parseOldStylePlist(text) {
252
+ return new Parser(text, tokenizePbxproj(text)).parseDocument();
253
+ }
254
+ /** Parses the text of a `project.pbxproj` file into its raw object graph. */
255
+ export function parsePbxproj(text) {
256
+ const value = parseOldStylePlist(text);
257
+ if (!isDict(value))
258
+ throw new Error('project.pbxproj: top-level value is not a dictionary');
259
+ const objectsValue = value['objects'];
260
+ if (!isDict(objectsValue))
261
+ throw new Error('project.pbxproj: missing `objects` dictionary');
262
+ const objects = {};
263
+ for (const [id, object] of Object.entries(objectsValue)) {
264
+ if (!isDict(object) || typeof object['isa'] !== 'string') {
265
+ throw new Error(`project.pbxproj: object ${id} has no isa`);
266
+ }
267
+ objects[id] = object;
268
+ }
269
+ const rootObject = value['rootObject'];
270
+ if (typeof rootObject !== 'string')
271
+ throw new Error('project.pbxproj: missing `rootObject`');
272
+ return {
273
+ archiveVersion: toInteger(value['archiveVersion'], 1),
274
+ objectVersion: toInteger(value['objectVersion'], 0),
275
+ rootObject,
276
+ objects,
277
+ };
278
+ }
279
+ export function isDict(value) {
280
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
281
+ }
282
+ function toInteger(value, fallback) {
283
+ if (typeof value !== 'string')
284
+ return fallback;
285
+ const n = Number.parseInt(value, 10);
286
+ return Number.isNaN(n) ? fallback : n;
287
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Minimal reader for XML property lists (`Info.plist`).
3
+ *
4
+ * Only what the project reader needs: the top-level `<dict>` with string,
5
+ * integer, real, boolean, date, data, array and nested dict values. Binary
6
+ * plists (`bplist00`) and the old-style ASCII format are not handled here;
7
+ * `parseXmlPlist` returns `undefined` for anything it cannot read, and the
8
+ * caller treats that as "no Info.plist values available".
9
+ */
10
+ export type PlistValue = string | number | boolean | PlistValue[] | PlistDict;
11
+ export interface PlistDict {
12
+ [key: string]: PlistValue;
13
+ }
14
+ /** Parses an XML plist. Returns `undefined` when the text is not an XML plist. */
15
+ export declare function parseXmlPlist(text: string): PlistValue | undefined;
package/dist/plist.js ADDED
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Minimal reader for XML property lists (`Info.plist`).
3
+ *
4
+ * Only what the project reader needs: the top-level `<dict>` with string,
5
+ * integer, real, boolean, date, data, array and nested dict values. Binary
6
+ * plists (`bplist00`) and the old-style ASCII format are not handled here;
7
+ * `parseXmlPlist` returns `undefined` for anything it cannot read, and the
8
+ * caller treats that as "no Info.plist values available".
9
+ */
10
+ /** Parses an XML plist. Returns `undefined` when the text is not an XML plist. */
11
+ export function parseXmlPlist(text) {
12
+ if (!/<plist[\s>]/.test(text))
13
+ return undefined;
14
+ const reader = new XmlPlistReader(text);
15
+ try {
16
+ return reader.read();
17
+ }
18
+ catch {
19
+ return undefined;
20
+ }
21
+ }
22
+ class XmlPlistReader {
23
+ text;
24
+ pos = 0;
25
+ constructor(text) {
26
+ this.text = text;
27
+ }
28
+ read() {
29
+ const plist = this.nextTag();
30
+ if (!plist || plist.name !== 'plist')
31
+ throw new Error('not a plist');
32
+ if (plist.selfClosing)
33
+ return undefined;
34
+ const value = this.readValue();
35
+ this.expectClosing('plist');
36
+ return value;
37
+ }
38
+ /** Reads the next opening tag (skipping the prolog, DOCTYPE, comments and whitespace). */
39
+ nextTag() {
40
+ for (;;) {
41
+ const lt = this.text.indexOf('<', this.pos);
42
+ if (lt < 0)
43
+ return undefined;
44
+ if (this.text.startsWith('<?', lt)) {
45
+ this.pos = this.text.indexOf('?>', lt) + 2;
46
+ continue;
47
+ }
48
+ if (this.text.startsWith('<!--', lt)) {
49
+ this.pos = this.text.indexOf('-->', lt) + 3;
50
+ continue;
51
+ }
52
+ if (this.text.startsWith('<!', lt)) {
53
+ this.pos = this.text.indexOf('>', lt) + 1;
54
+ continue;
55
+ }
56
+ const gt = this.text.indexOf('>', lt);
57
+ if (gt < 0)
58
+ throw new Error('unterminated tag');
59
+ let inside = this.text.slice(lt + 1, gt).trim();
60
+ const selfClosing = inside.endsWith('/');
61
+ if (selfClosing)
62
+ inside = inside.slice(0, -1).trim();
63
+ const name = inside.split(/\s/, 1)[0] ?? '';
64
+ this.pos = gt + 1;
65
+ return { name, selfClosing, end: gt + 1 };
66
+ }
67
+ }
68
+ /** Peeks: is the next tag the closing `</name>`? Consumes it when so. */
69
+ atClosing(name) {
70
+ const rest = this.text.slice(this.pos);
71
+ const match = /^\s*<\/\s*([^\s>]+)\s*>/.exec(rest);
72
+ if (!match || match[1] !== name)
73
+ return false;
74
+ this.pos += match[0].length;
75
+ return true;
76
+ }
77
+ expectClosing(name) {
78
+ if (!this.atClosing(name))
79
+ throw new Error(`expected </${name}>`);
80
+ }
81
+ /** Reads the text content up to `</name>` (entities decoded). */
82
+ readText(name) {
83
+ const close = this.text.indexOf(`</${name}>`, this.pos);
84
+ if (close < 0)
85
+ throw new Error(`expected </${name}>`);
86
+ const raw = this.text.slice(this.pos, close);
87
+ this.pos = close + name.length + 3;
88
+ return decodeEntities(raw);
89
+ }
90
+ readValue() {
91
+ const tag = this.nextTag();
92
+ if (!tag)
93
+ throw new Error('expected a value');
94
+ switch (tag.name) {
95
+ case 'string':
96
+ case 'date':
97
+ case 'data':
98
+ return tag.selfClosing ? '' : this.readText(tag.name);
99
+ case 'integer':
100
+ case 'real':
101
+ return tag.selfClosing ? 0 : Number(this.readText(tag.name).trim());
102
+ case 'true':
103
+ return true;
104
+ case 'false':
105
+ return false;
106
+ case 'array': {
107
+ const items = [];
108
+ if (tag.selfClosing)
109
+ return items;
110
+ while (!this.atClosing('array'))
111
+ items.push(this.readValue());
112
+ return items;
113
+ }
114
+ case 'dict': {
115
+ const dict = {};
116
+ if (tag.selfClosing)
117
+ return dict;
118
+ while (!this.atClosing('dict')) {
119
+ const keyTag = this.nextTag();
120
+ if (!keyTag || keyTag.name !== 'key')
121
+ throw new Error('expected <key>');
122
+ const key = keyTag.selfClosing ? '' : this.readText('key');
123
+ dict[key] = this.readValue();
124
+ }
125
+ return dict;
126
+ }
127
+ default:
128
+ throw new Error(`unexpected <${tag.name}>`);
129
+ }
130
+ }
131
+ }
132
+ const NAMED_ENTITIES = {
133
+ amp: '&',
134
+ lt: '<',
135
+ gt: '>',
136
+ quot: '"',
137
+ apos: "'",
138
+ };
139
+ function decodeEntities(text) {
140
+ return text.replace(/&(#x[0-9a-fA-F]+|#\d+|[a-zA-Z]+);/g, (whole, body) => {
141
+ if (body.startsWith('#x'))
142
+ return String.fromCodePoint(parseInt(body.slice(2), 16));
143
+ if (body.startsWith('#'))
144
+ return String.fromCodePoint(parseInt(body.slice(1), 10));
145
+ return NAMED_ENTITIES[body] ?? whole;
146
+ });
147
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Interprets a parsed `project.pbxproj` (pbxproj.ts) as targets with resolved
3
+ * file paths and build settings, so a CLI can find an iOS app's Swift sources,
4
+ * asset catalogs and settings without Xcode.
5
+ *
6
+ * Object graph in brief: the `rootObject` is a `PBXProject` with `targets`
7
+ * (`PBXNativeTarget`), a `mainGroup` (the tree of `PBXGroup`s and
8
+ * `PBXFileReference`s) and a `buildConfigurationList`. Each target has
9
+ * `buildPhases` (`PBXSourcesBuildPhase`, `PBXResourcesBuildPhase`, ...) whose
10
+ * `files` are `PBXBuildFile`s pointing at a `fileRef`, plus its own
11
+ * `buildConfigurationList` (`XCConfigurationList` -> `XCBuildConfiguration`
12
+ * with `buildSettings`). Xcode 16 projects can instead point a target at
13
+ * `PBXFileSystemSynchronizedRootGroup`s: folders whose contents are members of
14
+ * the target implicitly, minus per-target exception sets.
15
+ */
16
+ import { type PbxprojDocument } from './pbxproj.js';
17
+ export declare const APPLICATION_PRODUCT_TYPE = "com.apple.product-type.application";
18
+ export interface XcodeProject {
19
+ /** Absolute path of the `.xcodeproj` bundle. */
20
+ path: string;
21
+ /** Directory containing the `.xcodeproj`: the source root, what `$(SRCROOT)` means. */
22
+ root: string;
23
+ objectVersion: number;
24
+ /** Every `PBXNativeTarget`, in project order. */
25
+ targets: XcodeTarget[];
26
+ /** The targets whose product is an application (`com.apple.product-type.application`). */
27
+ appTargets(): XcodeTarget[];
28
+ }
29
+ export interface XcodeTarget {
30
+ name: string;
31
+ /** e.g. `com.apple.product-type.application`. */
32
+ productType: string;
33
+ /** `PRODUCT_NAME` with `$(TARGET_NAME)` resolved; falls back to the target name. */
34
+ productName: string;
35
+ /** `PRODUCT_BUNDLE_IDENTIFIER`. */
36
+ bundleIdentifier?: string;
37
+ /**
38
+ * `INFOPLIST_KEY_CFBundleDisplayName`, else `CFBundleDisplayName` from the
39
+ * `INFOPLIST_FILE` when that file exists and is an XML plist.
40
+ */
41
+ displayName?: string;
42
+ /** `ASSETCATALOG_COMPILER_APPICON_NAME`. */
43
+ appIconName?: string;
44
+ /** `SWIFT_VERSION`. */
45
+ swiftVersion?: string;
46
+ /** `IPHONEOS_DEPLOYMENT_TARGET`. */
47
+ deploymentTarget?: string;
48
+ /**
49
+ * Absolute paths of the `.swift` files compiled by the target: the Sources
50
+ * build phase in project (navigator) order, then the files of synchronized
51
+ * folders (sorted).
52
+ */
53
+ sources: string[];
54
+ /** Absolute paths of the files and folders in the Resources build phase, in project (navigator) order. */
55
+ resources: string[];
56
+ /** Absolute paths of the `*.xcassets` among the resources and in synchronized folders. */
57
+ assetCatalogs: string[];
58
+ /** Names of the targets this one depends on (`PBXTargetDependency`). */
59
+ dependencies: string[];
60
+ /** Swift package products the target links (`XCSwiftPackageProductDependency.productName`). */
61
+ packageProducts: string[];
62
+ /**
63
+ * Merged build settings for a configuration: project-level settings first,
64
+ * target-level settings on top. `configuration` defaults to the target's
65
+ * default configuration: `Debug` when it has one, else the configuration
66
+ * list's `defaultConfigurationName`, else the first configuration.
67
+ * `$(TARGET_NAME)`, `$(PRODUCT_NAME)`, `$(SRCROOT)` and `$(PROJECT_DIR)` are
68
+ * expanded; other `$(...)` references are left as they are. An `.xcconfig`
69
+ * referenced by `baseConfigurationReference` is not read.
70
+ */
71
+ settings(configuration?: string): Record<string, string>;
72
+ }
73
+ /** Reads `<xcodeprojPath>/project.pbxproj` and interprets it. */
74
+ export declare function readXcodeProject(xcodeprojPath: string): XcodeProject;
75
+ /**
76
+ * Interprets an already parsed document as the project at `xcodeprojPath`
77
+ * (which need not exist; its parent directory is the source root).
78
+ */
79
+ export declare function xcodeProjectFromDocument(doc: PbxprojDocument, xcodeprojPath: string): XcodeProject;
80
+ /**
81
+ * Replaces `$(NAME)` and `${NAME}` for the names in `variables`; other
82
+ * references (`$(inherited)`, `$(SDKROOT)`, ...) are kept verbatim.
83
+ */
84
+ export declare function expandVariables(value: string, variables: Record<string, string>): string;
@@ -0,0 +1,417 @@
1
+ /**
2
+ * Interprets a parsed `project.pbxproj` (pbxproj.ts) as targets with resolved
3
+ * file paths and build settings, so a CLI can find an iOS app's Swift sources,
4
+ * asset catalogs and settings without Xcode.
5
+ *
6
+ * Object graph in brief: the `rootObject` is a `PBXProject` with `targets`
7
+ * (`PBXNativeTarget`), a `mainGroup` (the tree of `PBXGroup`s and
8
+ * `PBXFileReference`s) and a `buildConfigurationList`. Each target has
9
+ * `buildPhases` (`PBXSourcesBuildPhase`, `PBXResourcesBuildPhase`, ...) whose
10
+ * `files` are `PBXBuildFile`s pointing at a `fileRef`, plus its own
11
+ * `buildConfigurationList` (`XCConfigurationList` -> `XCBuildConfiguration`
12
+ * with `buildSettings`). Xcode 16 projects can instead point a target at
13
+ * `PBXFileSystemSynchronizedRootGroup`s: folders whose contents are members of
14
+ * the target implicitly, minus per-target exception sets.
15
+ */
16
+ import * as fs from 'node:fs';
17
+ import * as path from 'node:path';
18
+ import { isDict, parsePbxproj } from './pbxproj.js';
19
+ import { parseXmlPlist } from './plist.js';
20
+ export const APPLICATION_PRODUCT_TYPE = 'com.apple.product-type.application';
21
+ /** Reads `<xcodeprojPath>/project.pbxproj` and interprets it. */
22
+ export function readXcodeProject(xcodeprojPath) {
23
+ const absolute = path.resolve(xcodeprojPath);
24
+ const text = fs.readFileSync(path.join(absolute, 'project.pbxproj'), 'utf8');
25
+ return xcodeProjectFromDocument(parsePbxproj(text), absolute);
26
+ }
27
+ /**
28
+ * Interprets an already parsed document as the project at `xcodeprojPath`
29
+ * (which need not exist; its parent directory is the source root).
30
+ */
31
+ export function xcodeProjectFromDocument(doc, xcodeprojPath) {
32
+ return new ProjectReader(doc, path.resolve(xcodeprojPath)).read();
33
+ }
34
+ // ----- Field access helpers -------------------------------------------------
35
+ function str(value) {
36
+ return typeof value === 'string' ? value : undefined;
37
+ }
38
+ /** The string items of an array value (anything else yields no items). */
39
+ function strings(value) {
40
+ return Array.isArray(value) ? value.filter((item) => typeof item === 'string') : [];
41
+ }
42
+ /** A build setting as one string: arrays (`GCC_PREPROCESSOR_DEFINITIONS`) are space-joined. */
43
+ function settingString(value) {
44
+ if (typeof value === 'string')
45
+ return value;
46
+ if (Array.isArray(value))
47
+ return strings(value).join(' ');
48
+ return undefined;
49
+ }
50
+ const SYNCHRONIZED_GROUP_ISAS = new Set(['PBXFileSystemSynchronizedRootGroup']);
51
+ const GROUP_ISAS = new Set(['PBXGroup', 'PBXVariantGroup', 'XCVersionGroup', ...SYNCHRONIZED_GROUP_ISAS]);
52
+ // ----- Reader -----------------------------------------------------------------
53
+ class ProjectReader {
54
+ doc;
55
+ xcodeprojPath;
56
+ objects;
57
+ project;
58
+ /** Source root: the directory containing the `.xcodeproj`. */
59
+ root;
60
+ /** Directory the main group resolves to: `root` plus the project's `projectDirPath` (normally empty). */
61
+ mainGroupDir;
62
+ /** Group id of every group's and file reference's parent, for `<group>`-relative paths. */
63
+ parentOf = new Map();
64
+ /**
65
+ * Position of every group and file reference in a depth-first walk of the main
66
+ * group: the order the Xcode navigator shows. Build phases list their files
67
+ * in creation order, which is meaningless, so we sort them by this instead.
68
+ */
69
+ navigatorIndex = new Map();
70
+ resolvedPaths = new Map();
71
+ constructor(doc, xcodeprojPath) {
72
+ this.doc = doc;
73
+ this.xcodeprojPath = xcodeprojPath;
74
+ this.objects = doc.objects;
75
+ const project = this.objects[doc.rootObject];
76
+ if (!project || project.isa !== 'PBXProject') {
77
+ throw new Error(`project.pbxproj: rootObject ${doc.rootObject} is not a PBXProject`);
78
+ }
79
+ this.project = project;
80
+ this.root = path.dirname(this.xcodeprojPath);
81
+ this.mainGroupDir = path.resolve(this.root, str(project['projectDirPath']) ?? '');
82
+ for (const [id, object] of Object.entries(this.objects)) {
83
+ if (!GROUP_ISAS.has(object.isa))
84
+ continue;
85
+ for (const child of strings(object['children']))
86
+ this.parentOf.set(child, id);
87
+ }
88
+ const mainGroup = str(project['mainGroup']);
89
+ if (mainGroup)
90
+ this.indexGroup(mainGroup);
91
+ }
92
+ indexGroup(id) {
93
+ if (this.navigatorIndex.has(id))
94
+ return; // cyclic or shared child
95
+ this.navigatorIndex.set(id, this.navigatorIndex.size);
96
+ const object = this.objects[id];
97
+ if (object && GROUP_ISAS.has(object.isa)) {
98
+ for (const child of strings(object['children']))
99
+ this.indexGroup(child);
100
+ }
101
+ }
102
+ read() {
103
+ const targets = [];
104
+ for (const id of strings(this.project['targets'])) {
105
+ const target = this.objects[id];
106
+ if (target?.isa === 'PBXNativeTarget')
107
+ targets.push(this.readTarget(id, target));
108
+ }
109
+ return {
110
+ path: this.xcodeprojPath,
111
+ root: this.root,
112
+ objectVersion: this.doc.objectVersion,
113
+ targets,
114
+ appTargets: () => targets.filter((target) => target.productType === APPLICATION_PRODUCT_TYPE),
115
+ };
116
+ }
117
+ // --- Paths ---
118
+ /**
119
+ * Absolute path of a file reference or group, following `sourceTree`:
120
+ * `<group>` is relative to the parent group's directory, `SOURCE_ROOT` to the
121
+ * source root, `<absolute>` is absolute; anything else (`BUILT_PRODUCTS_DIR`,
122
+ * `SDKROOT`, `DEVELOPER_DIR`) falls back to the source root. Groups without a
123
+ * `path` resolve to their parent's directory.
124
+ */
125
+ resolvePath(id) {
126
+ if (this.resolvedPaths.has(id))
127
+ return this.resolvedPaths.get(id);
128
+ this.resolvedPaths.set(id, undefined); // guards against cyclic groups
129
+ const object = this.objects[id];
130
+ if (!object)
131
+ return undefined;
132
+ const relative = str(object['path']);
133
+ let base;
134
+ switch (str(object['sourceTree']) ?? '<group>') {
135
+ case '<group>': {
136
+ const parent = this.parentOf.get(id);
137
+ base = (parent ? this.resolvePath(parent) : undefined) ?? this.mainGroupDir;
138
+ break;
139
+ }
140
+ case '<absolute>':
141
+ base = '/';
142
+ break;
143
+ case 'SOURCE_ROOT':
144
+ default:
145
+ base = this.root;
146
+ break;
147
+ }
148
+ const resolved = relative ? path.resolve(base, relative) : base;
149
+ this.resolvedPaths.set(id, resolved);
150
+ return resolved;
151
+ }
152
+ /**
153
+ * Absolute paths of the files a build phase's entry refers to. A
154
+ * `PBXVariantGroup` (localized resources) contributes each of its variants.
155
+ */
156
+ buildFilePaths(buildFileId) {
157
+ const buildFile = this.objects[buildFileId];
158
+ const fileRef = str(buildFile?.['fileRef']);
159
+ if (!fileRef)
160
+ return [];
161
+ const ref = this.objects[fileRef];
162
+ if (!ref)
163
+ return [];
164
+ if (ref.isa === 'PBXVariantGroup') {
165
+ return strings(ref['children'])
166
+ .map((child) => this.resolvePath(child))
167
+ .filter((p) => p !== undefined);
168
+ }
169
+ const resolved = this.resolvePath(fileRef);
170
+ return resolved ? [resolved] : [];
171
+ }
172
+ /** The files of the target's build phases with the given `isa`, in navigator order. */
173
+ buildPhaseFiles(target, isa) {
174
+ const buildFileIds = [];
175
+ for (const phaseId of strings(target['buildPhases'])) {
176
+ const phase = this.objects[phaseId];
177
+ if (phase?.isa === isa)
178
+ buildFileIds.push(...strings(phase['files']));
179
+ }
180
+ const position = (buildFileId) => {
181
+ const fileRef = str(this.objects[buildFileId]?.['fileRef']);
182
+ return (fileRef ? this.navigatorIndex.get(fileRef) : undefined) ?? Number.MAX_SAFE_INTEGER;
183
+ };
184
+ return buildFileIds
185
+ .map((id, order) => ({ id, order, position: position(id) }))
186
+ .sort((a, b) => a.position - b.position || a.order - b.order)
187
+ .flatMap((entry) => this.buildFilePaths(entry.id));
188
+ }
189
+ // --- Synchronized folders (Xcode 16) ---
190
+ /**
191
+ * The `.swift` files and `.xcassets` folders under the target's synchronized
192
+ * folders, read from disk (sorted, recursive), minus the target's membership
193
+ * exceptions. A folder missing on disk contributes nothing.
194
+ */
195
+ synchronizedMembers(targetId, target) {
196
+ const sources = [];
197
+ const assetCatalogs = [];
198
+ for (const groupId of strings(target['fileSystemSynchronizedGroups'])) {
199
+ const group = this.objects[groupId];
200
+ if (!group || !SYNCHRONIZED_GROUP_ISAS.has(group.isa))
201
+ continue;
202
+ const dir = this.resolvePath(groupId);
203
+ if (!dir)
204
+ continue;
205
+ const excluded = new Set();
206
+ for (const exceptionId of strings(group['exceptions'])) {
207
+ const exceptions = this.objects[exceptionId];
208
+ if (exceptions?.isa !== 'PBXFileSystemSynchronizedBuildFileExceptionSet')
209
+ continue;
210
+ if (str(exceptions['target']) !== targetId)
211
+ continue;
212
+ for (const relative of strings(exceptions['membershipExceptions']))
213
+ excluded.add(path.resolve(dir, relative));
214
+ }
215
+ walkSynchronizedFolder(dir, (entry) => {
216
+ if (excluded.has(entry))
217
+ return false;
218
+ if (entry.endsWith('.swift'))
219
+ sources.push(entry);
220
+ else if (entry.endsWith('.xcassets'))
221
+ assetCatalogs.push(entry);
222
+ return true;
223
+ });
224
+ }
225
+ return { sources, assetCatalogs };
226
+ }
227
+ // --- Build settings ---
228
+ configurations(listId) {
229
+ const list = listId ? this.objects[listId] : undefined;
230
+ if (list?.isa !== 'XCConfigurationList')
231
+ return [];
232
+ return strings(list['buildConfigurations'])
233
+ .map((id) => this.objects[id])
234
+ .filter((config) => config?.isa === 'XCBuildConfiguration');
235
+ }
236
+ defaultConfigurationName(target) {
237
+ const listId = str(target['buildConfigurationList']);
238
+ const names = this.configurations(listId).map((config) => str(config['name']));
239
+ if (names.includes('Debug'))
240
+ return 'Debug';
241
+ const list = listId ? this.objects[listId] : undefined;
242
+ return str(list?.['defaultConfigurationName']) ?? names[0];
243
+ }
244
+ /** The `buildSettings` of the configuration named `name` in a configuration list. */
245
+ buildSettings(listId, name) {
246
+ const config = this.configurations(listId).find((candidate) => str(candidate['name']) === name);
247
+ const settings = config?.['buildSettings'];
248
+ return isDict(settings) ? settings : {};
249
+ }
250
+ /** Project-level then target-level settings for a configuration, as raw strings. */
251
+ rawSettings(target, configuration) {
252
+ const merged = {};
253
+ const layers = [
254
+ this.buildSettings(str(this.project['buildConfigurationList']), configuration),
255
+ this.buildSettings(str(target['buildConfigurationList']), configuration),
256
+ ];
257
+ for (const layer of layers) {
258
+ for (const [key, value] of Object.entries(layer)) {
259
+ const text = settingString(value);
260
+ if (text !== undefined)
261
+ merged[key] = text;
262
+ }
263
+ }
264
+ return merged;
265
+ }
266
+ /**
267
+ * Expands the variables this package knows about in every value. `PRODUCT_NAME`
268
+ * is expanded first (with `$(TARGET_NAME)` only) because the other values may
269
+ * refer to it.
270
+ */
271
+ expandSettings(targetName, raw) {
272
+ const base = {
273
+ TARGET_NAME: targetName,
274
+ SRCROOT: this.root,
275
+ PROJECT_DIR: this.root,
276
+ };
277
+ const productName = expandVariables(raw['PRODUCT_NAME'] ?? '$(TARGET_NAME)', base) || targetName;
278
+ const variables = { ...base, PRODUCT_NAME: productName };
279
+ const settings = {};
280
+ for (const [key, value] of Object.entries(raw))
281
+ settings[key] = expandVariables(value, variables);
282
+ return settings;
283
+ }
284
+ // --- Targets ---
285
+ readTarget(targetId, target) {
286
+ const name = str(target['name']) ?? str(target['productName']) ?? targetId;
287
+ const defaultConfiguration = this.defaultConfigurationName(target);
288
+ const settingsCache = new Map();
289
+ const settings = (configuration = defaultConfiguration ?? '') => {
290
+ let result = settingsCache.get(configuration);
291
+ if (!result) {
292
+ result = this.expandSettings(name, this.rawSettings(target, configuration));
293
+ settingsCache.set(configuration, result);
294
+ }
295
+ return result;
296
+ };
297
+ const defaults = settings();
298
+ const phaseSources = this.buildPhaseFiles(target, 'PBXSourcesBuildPhase').filter((p) => p.endsWith('.swift'));
299
+ const resources = this.buildPhaseFiles(target, 'PBXResourcesBuildPhase');
300
+ const synchronized = this.synchronizedMembers(targetId, target);
301
+ const productName = defaults['PRODUCT_NAME'] || str(target['productName']) || name;
302
+ const result = {
303
+ name,
304
+ productType: str(target['productType']) ?? '',
305
+ productName,
306
+ sources: [...phaseSources, ...synchronized.sources],
307
+ resources,
308
+ assetCatalogs: [...resources.filter((p) => p.endsWith('.xcassets')), ...synchronized.assetCatalogs],
309
+ dependencies: this.dependencyNames(target),
310
+ packageProducts: this.packageProductNames(target),
311
+ settings,
312
+ };
313
+ const bundleIdentifier = defaults['PRODUCT_BUNDLE_IDENTIFIER'];
314
+ if (bundleIdentifier)
315
+ result.bundleIdentifier = bundleIdentifier;
316
+ const displayName = defaults['INFOPLIST_KEY_CFBundleDisplayName'] ?? this.infoPlistDisplayName(defaults['INFOPLIST_FILE']);
317
+ if (displayName)
318
+ result.displayName = displayName;
319
+ if (defaults['ASSETCATALOG_COMPILER_APPICON_NAME'])
320
+ result.appIconName = defaults['ASSETCATALOG_COMPILER_APPICON_NAME'];
321
+ if (defaults['SWIFT_VERSION'])
322
+ result.swiftVersion = defaults['SWIFT_VERSION'];
323
+ if (defaults['IPHONEOS_DEPLOYMENT_TARGET'])
324
+ result.deploymentTarget = defaults['IPHONEOS_DEPLOYMENT_TARGET'];
325
+ return result;
326
+ }
327
+ /** `CFBundleDisplayName` from an `INFOPLIST_FILE` (relative to the source root) when it is a readable XML plist. */
328
+ infoPlistDisplayName(infoPlistFile) {
329
+ if (!infoPlistFile)
330
+ return undefined;
331
+ let text;
332
+ try {
333
+ text = fs.readFileSync(path.resolve(this.root, infoPlistFile), 'utf8');
334
+ }
335
+ catch {
336
+ return undefined;
337
+ }
338
+ const plist = parseXmlPlist(text);
339
+ if (typeof plist !== 'object' || plist === null || Array.isArray(plist))
340
+ return undefined;
341
+ const displayName = plist['CFBundleDisplayName'];
342
+ return typeof displayName === 'string' && displayName !== '' ? displayName : undefined;
343
+ }
344
+ /** Names of the target dependencies: the referenced target's name, else the dependency's own `name`. */
345
+ dependencyNames(target) {
346
+ const names = [];
347
+ for (const dependencyId of strings(target['dependencies'])) {
348
+ const dependency = this.objects[dependencyId];
349
+ if (dependency?.isa !== 'PBXTargetDependency')
350
+ continue;
351
+ const dependedId = str(dependency['target']);
352
+ const depended = dependedId ? this.objects[dependedId] : undefined;
353
+ const name = str(depended?.['name']) ?? str(dependency['name']) ?? this.packageProductName(str(dependency['productRef']));
354
+ if (name)
355
+ names.push(name);
356
+ }
357
+ return names;
358
+ }
359
+ packageProductName(productRefId) {
360
+ const product = productRefId ? this.objects[productRefId] : undefined;
361
+ return product?.isa === 'XCSwiftPackageProductDependency' ? str(product['productName']) : undefined;
362
+ }
363
+ /**
364
+ * Swift package products the target links: its `packageProductDependencies`,
365
+ * plus any `productRef` of a build file in its Frameworks phase (older projects).
366
+ */
367
+ packageProductNames(target) {
368
+ const names = new Set();
369
+ for (const id of strings(target['packageProductDependencies'])) {
370
+ const name = this.packageProductName(id);
371
+ if (name)
372
+ names.add(name);
373
+ }
374
+ for (const phaseId of strings(target['buildPhases'])) {
375
+ const phase = this.objects[phaseId];
376
+ if (phase?.isa !== 'PBXFrameworksBuildPhase')
377
+ continue;
378
+ for (const buildFileId of strings(phase['files'])) {
379
+ const name = this.packageProductName(str(this.objects[buildFileId]?.['productRef']));
380
+ if (name)
381
+ names.add(name);
382
+ }
383
+ }
384
+ return [...names];
385
+ }
386
+ }
387
+ // ----- Helpers ----------------------------------------------------------------
388
+ /**
389
+ * Replaces `$(NAME)` and `${NAME}` for the names in `variables`; other
390
+ * references (`$(inherited)`, `$(SDKROOT)`, ...) are kept verbatim.
391
+ */
392
+ export function expandVariables(value, variables) {
393
+ return value.replace(/\$[({]([A-Za-z_][A-Za-z0-9_]*)[)}]/g, (whole, name) => variables[name] ?? whole);
394
+ }
395
+ /**
396
+ * Visits every entry under `dir` in sorted order, depth first. `visit` returns
397
+ * whether to descend into a directory; `.xcassets` bundles and dot-entries are
398
+ * never descended into. Unreadable directories are skipped.
399
+ */
400
+ function walkSynchronizedFolder(dir, visit) {
401
+ let entries;
402
+ try {
403
+ entries = fs.readdirSync(dir, { withFileTypes: true });
404
+ }
405
+ catch {
406
+ return;
407
+ }
408
+ entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
409
+ for (const entry of entries) {
410
+ if (entry.name.startsWith('.'))
411
+ continue;
412
+ const full = path.join(dir, entry.name);
413
+ const descend = visit(full);
414
+ if (descend && entry.isDirectory() && !entry.name.endsWith('.xcassets'))
415
+ walkSynchronizedFolder(full, visit);
416
+ }
417
+ }
package/package.json CHANGED
@@ -1,6 +1,33 @@
1
1
  {
2
2
  "name": "@swiftbrowser/xcodeproj",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Reads Xcode projects (project.pbxproj): app targets, their Swift sources, asset catalogs and build settings. Pure TypeScript, no Xcode needed.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/dabbott/SwiftBrowser.git",
8
+ "directory": "packages/xcodeproj"
9
+ },
10
+ "type": "module",
11
+ "main": "./dist/index.js",
12
+ "types": "./dist/index.d.ts",
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "default": "./dist/index.js"
17
+ }
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "README.md"
22
+ ],
23
+ "scripts": {
24
+ "typecheck": "tsc --noEmit -p tsconfig.json",
25
+ "test:unit": "vitest run",
26
+ "build": "tsc -p tsconfig.build.json"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^26.6.4",
30
+ "typescript": "^5.9.2",
31
+ "vitest": "^5.0.3"
32
+ }
33
+ }