rork-xcode 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/dist/index.js ADDED
@@ -0,0 +1,1376 @@
1
+ //#region src/comments.ts
2
+ /**
3
+ * Narrows a value to a dictionary.
4
+ *
5
+ * Arrays and `Uint8Array` data are objects at runtime too, so a bare
6
+ * `typeof` check is not enough.
7
+ */
8
+ function isDictionary(value) {
9
+ return typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Uint8Array);
10
+ }
11
+ /**
12
+ * Returns the value when it is a string, and `undefined` otherwise.
13
+ *
14
+ * Field access on parsed documents goes through this because any field of
15
+ * an untrusted document can hold any value type.
16
+ */
17
+ function asString(value) {
18
+ return typeof value === "string" ? value : void 0;
19
+ }
20
+ /**
21
+ * Records `id → info` for every string in `ids` not yet present, keeping
22
+ * the first occurrence in document order.
23
+ */
24
+ function indexFirst(index, ids, info) {
25
+ if (!Array.isArray(ids)) return;
26
+ for (const id of ids) if (typeof id === "string" && !index.has(id)) index.set(id, info);
27
+ }
28
+ /**
29
+ * Derives the display name of a build phase from its isa:
30
+ * `PBXSourcesBuildPhase` becomes `Sources`. Returns `undefined` for isa
31
+ * names outside the `PBX…BuildPhase` pattern.
32
+ */
33
+ function defaultBuildPhaseName(isa) {
34
+ if (isa.startsWith("PBX") && isa.endsWith("BuildPhase")) return isa.slice(3, isa.length - 10);
35
+ }
36
+ /**
37
+ * Extracts the repository name for a Swift package reference comment.
38
+ *
39
+ * GitHub URLs reduce to their last path segment without the `.git` suffix;
40
+ * anything else is used verbatim, which matches how Xcode renders unknown
41
+ * hosts.
42
+ */
43
+ function repoNameFromUrl(repoUrl) {
44
+ for (const prefix of ["https://github.com/", "http://github.com/"]) if (repoUrl.startsWith(prefix)) {
45
+ const last = repoUrl.slice(prefix.length).split("/").at(-1) ?? "";
46
+ const name = last.endsWith(".git") ? last.slice(0, -4) : last;
47
+ if (name.length > 0) return name;
48
+ }
49
+ return repoUrl;
50
+ }
51
+ /**
52
+ * Builds the uuid-to-comment map for a parsed project document.
53
+ *
54
+ * Objects that should render without a comment (unnamed groups) map to the
55
+ * empty string; uuids absent from the map are not references at all.
56
+ * Derivation is linear over the object graph: reverse indexes are built in
57
+ * one pass, and every object's comment is computed once and cached.
58
+ */
59
+ function createReferenceComments(root) {
60
+ const cache = /* @__PURE__ */ new Map();
61
+ const inProgress = /* @__PURE__ */ new Set();
62
+ const objectsValue = isDictionary(root) ? root["objects"] : void 0;
63
+ if (!isDictionary(objectsValue)) return cache;
64
+ const objects = objectsValue;
65
+ const fileToPhase = /* @__PURE__ */ new Map();
66
+ const configurationListOwners = /* @__PURE__ */ new Map();
67
+ const proxyRemoteInfoByPortal = /* @__PURE__ */ new Map();
68
+ const syncGroupByExceptionSet = /* @__PURE__ */ new Map();
69
+ const targetByBuildPhase = /* @__PURE__ */ new Map();
70
+ for (const ownerId of Object.keys(objects)) {
71
+ const owner = objects[ownerId];
72
+ if (!isDictionary(owner)) continue;
73
+ const isa = asString(owner["isa"]) ?? "";
74
+ if (isa.endsWith("BuildPhase")) indexFirst(fileToPhase, owner["files"], {
75
+ isa,
76
+ name: asString(owner["name"])
77
+ });
78
+ else if (isa === "PBXContainerItemProxy") {
79
+ const portal = asString(owner["containerPortal"]);
80
+ const remoteInfo = asString(owner["remoteInfo"]);
81
+ if (portal != null && remoteInfo != null && !proxyRemoteInfoByPortal.has(portal)) proxyRemoteInfoByPortal.set(portal, remoteInfo);
82
+ }
83
+ indexFirst(syncGroupByExceptionSet, owner["exceptions"], owner);
84
+ indexFirst(targetByBuildPhase, owner["buildPhases"], owner);
85
+ const listId = asString(owner["buildConfigurationList"]);
86
+ if (listId != null && !configurationListOwners.has(listId)) configurationListOwners.set(listId, [ownerId, owner]);
87
+ }
88
+ /**
89
+ * The name an object displays as when nothing more specific applies:
90
+ * `name`, then `productName`, then `path`, then its isa.
91
+ */
92
+ const defaultName = (object, isa) => asString(object["name"]) ?? asString(object["productName"]) ?? asString(object["path"]) ?? isa;
93
+ /**
94
+ * The display name of the synchronized folder an exception set belongs
95
+ * to, found through the sync group whose `exceptions` array lists it.
96
+ */
97
+ const exceptionSetFolderName = (id) => {
98
+ const group = syncGroupByExceptionSet.get(id);
99
+ return group == null ? void 0 : asString(group["name"]) ?? asString(group["path"]);
100
+ };
101
+ /**
102
+ * Comment for a `PBXFileSystemSynchronizedBuildFileExceptionSet`,
103
+ * matching current Xcode: `Exceptions for "clip" folder in "clip"
104
+ * target`. Falls back to the isa when the folder or target cannot be
105
+ * resolved (older documents and hand-edited graphs).
106
+ */
107
+ const buildFileExceptionSetComment = (id, set) => {
108
+ const folder = exceptionSetFolderName(id);
109
+ const targetId = asString(set["target"]);
110
+ const target = targetId == null ? void 0 : objects[targetId];
111
+ if (folder == null || !isDictionary(target)) return;
112
+ const targetName = asString(target["name"]) ?? asString(target["productName"]) ?? asString(target["path"]);
113
+ return targetName == null ? void 0 : `Exceptions for "${folder}" folder in "${targetName}" target`;
114
+ };
115
+ /**
116
+ * Comment for a `PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet`,
117
+ * matching current Xcode: `Exceptions for "Tophat" folder in "CopyFiles"
118
+ * phase from "Tophat" target`. Falls back to the isa when any of the
119
+ * three names cannot be resolved.
120
+ */
121
+ const membershipExceptionSetComment = (id, set) => {
122
+ const folder = exceptionSetFolderName(id);
123
+ const phaseId = asString(set["buildPhase"]);
124
+ const phase = phaseId == null ? void 0 : objects[phaseId];
125
+ if (folder == null || phaseId == null || !isDictionary(phase)) return;
126
+ const phaseName = asString(phase["name"]) ?? defaultBuildPhaseName(asString(phase["isa"]) ?? "");
127
+ const target = targetByBuildPhase.get(phaseId);
128
+ const targetName = target == null ? void 0 : asString(target["name"]) ?? asString(target["productName"]) ?? asString(target["path"]);
129
+ if (phaseName == null || targetName == null) return;
130
+ return `Exceptions for "${folder}" folder in "${phaseName}" phase from "${targetName}" target`;
131
+ };
132
+ /**
133
+ * Comment for a `PBXBuildFile`: the referenced file's own comment plus
134
+ * the phase it belongs to, e.g. `AppDelegate.swift in Sources`.
135
+ */
136
+ const buildFileComment = (id, buildFile) => {
137
+ const phase = fileToPhase.get(id);
138
+ const phaseName = phase == null ? "[missing build phase]" : phase.name ?? defaultBuildPhaseName(phase.isa) ?? "";
139
+ const refId = asString(buildFile["fileRef"]) ?? asString(buildFile["productRef"]);
140
+ const referenced = refId == null ? void 0 : objects[refId];
141
+ return `${(refId != null && isDictionary(referenced) ? commentFor(refId, referenced) : void 0) ?? "(null)"} in ${phaseName}`;
142
+ };
143
+ /**
144
+ * Comment for an `XCConfigurationList`, naming the target or project that
145
+ * owns it, e.g. `Build configuration list for PBXNativeTarget "App"`.
146
+ */
147
+ const configurationListComment = (id) => {
148
+ const ownerEntry = configurationListOwners.get(id);
149
+ if (ownerEntry == null) return "Build configuration list for [unknown]";
150
+ const [ownerId, owner] = ownerEntry;
151
+ const isa = asString(owner["isa"]) ?? "";
152
+ const ownName = asString(owner["name"]) ?? asString(owner["path"]) ?? asString(owner["productName"]);
153
+ if (ownName != null) return `Build configuration list for ${isa} "${ownName}"`;
154
+ const targets = owner["targets"];
155
+ if (Array.isArray(targets)) {
156
+ const firstTargetId = targets.find((target) => typeof target === "string");
157
+ const firstTarget = firstTargetId == null ? void 0 : objects[firstTargetId];
158
+ if (isDictionary(firstTarget)) {
159
+ const targetName = asString(firstTarget["productName"]) ?? asString(firstTarget["name"]);
160
+ if (targetName != null) return `Build configuration list for ${isa} "${targetName}"`;
161
+ }
162
+ }
163
+ const remoteInfo = proxyRemoteInfoByPortal.get(ownerId);
164
+ if (remoteInfo != null) return `Build configuration list for ${isa} "${remoteInfo}"`;
165
+ return `Build configuration list for ${isa}`;
166
+ };
167
+ /**
168
+ * Derives (and caches) the comment for one object, dispatching on its
169
+ * isa. Returns `undefined` for objects with no isa and for re-entrant
170
+ * lookups on a reference cycle.
171
+ */
172
+ const commentFor = (id, object) => {
173
+ const cached = cache.get(id);
174
+ if (cached != null) return cached;
175
+ const isa = asString(object["isa"]);
176
+ if (isa == null) return;
177
+ if (inProgress.has(id)) return;
178
+ inProgress.add(id);
179
+ let comment;
180
+ if (isa === "PBXBuildFile") comment = buildFileComment(id, object);
181
+ else if (isa === "XCConfigurationList") comment = configurationListComment(id);
182
+ else if (isa === "XCRemoteSwiftPackageReference") {
183
+ const repoUrl = asString(object["repositoryURL"]);
184
+ comment = repoUrl == null ? isa : `${isa} "${repoNameFromUrl(repoUrl)}"`;
185
+ } else if (isa === "XCLocalSwiftPackageReference") {
186
+ const relativePath = asString(object["relativePath"]);
187
+ comment = relativePath == null ? isa : `${isa} "${relativePath}"`;
188
+ } else if (isa === "PBXProject") comment = "Project object";
189
+ else if (isa === "PBXFileSystemSynchronizedBuildFileExceptionSet") comment = buildFileExceptionSetComment(id, object) ?? isa;
190
+ else if (isa === "PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet") comment = membershipExceptionSetComment(id, object) ?? isa;
191
+ else if (isa === "PBXTargetDependency") comment = isa;
192
+ else if (isa.endsWith("BuildPhase")) comment = asString(object["name"]) ?? defaultBuildPhaseName(isa) ?? "";
193
+ else if (isa === "PBXGroup" && asString(object["name"]) == null && asString(object["path"]) == null) comment = "";
194
+ else comment = defaultName(object, isa);
195
+ inProgress.delete(id);
196
+ cache.set(id, comment);
197
+ return comment;
198
+ };
199
+ for (const id of Object.keys(objects)) {
200
+ const object = objects[id];
201
+ if (isDictionary(object)) commentFor(id, object);
202
+ }
203
+ return cache;
204
+ }
205
+ //#endregion
206
+ //#region src/errors.ts
207
+ /**
208
+ * Error types raised by this library.
209
+ *
210
+ * Both error classes are exported so callers can distinguish "the document is
211
+ * malformed" ({@link PbxprojParseError}) from "this value cannot be written"
212
+ * ({@link PbxprojBuildError}) and report precise context for each.
213
+ *
214
+ * @module
215
+ */
216
+ /** UTF-16 code unit of `\n`, used to count lines when reporting a failure. */
217
+ const LINE_FEED = 10;
218
+ /**
219
+ * Converts a source offset into a position.
220
+ *
221
+ * Runs only when an error is actually thrown, so parsing never pays for line
222
+ * tracking on the happy path.
223
+ */
224
+ function positionAt(source, offset) {
225
+ let line = 1;
226
+ let lineStart = 0;
227
+ for (let i = 0; i < offset && i < source.length; i++) if (source.charCodeAt(i) === LINE_FEED) {
228
+ line++;
229
+ lineStart = i + 1;
230
+ }
231
+ return {
232
+ offset,
233
+ line,
234
+ column: offset - lineStart + 1
235
+ };
236
+ }
237
+ /**
238
+ * Thrown when the source text is not a well-formed OpenStep-style property
239
+ * list (the format of `project.pbxproj` files).
240
+ *
241
+ * The message always embeds the line and column of the failure, and the same
242
+ * information is available in structured form on {@link position} for
243
+ * programmatic use.
244
+ */
245
+ var PbxprojParseError = class extends Error {
246
+ /** Where in the source text parsing failed. */
247
+ position;
248
+ /**
249
+ * @param message Failure description without location; the location is
250
+ * appended automatically.
251
+ * @param source Full source text, used to compute the position.
252
+ * @param offset Character offset of the failure inside `source`.
253
+ */
254
+ constructor(message, source, offset) {
255
+ const position = positionAt(source, offset);
256
+ super(`${message} (line ${position.line}, column ${position.column})`);
257
+ this.name = "PbxprojParseError";
258
+ this.position = position;
259
+ }
260
+ };
261
+ /**
262
+ * Thrown when a value cannot be represented in a `project.pbxproj` document.
263
+ *
264
+ * Raised for `null`, `undefined`, booleans, bigints, functions, symbols,
265
+ * class instances, and non-finite numbers. The format itself has no boolean
266
+ * or null notation (Xcode models booleans as the strings `YES`/`NO`), so
267
+ * rejecting them loudly beats writing a value Xcode would misread. The
268
+ * {@link path} pinpoints the offending value inside the input, which matters
269
+ * when serializing a project with thousands of objects.
270
+ */
271
+ var PbxprojBuildError = class extends Error {
272
+ /** Path to the offending value from the root, e.g. `$.objects.13B07F86.name`. */
273
+ path;
274
+ /**
275
+ * @param message Failure description without location; the value path is
276
+ * appended automatically.
277
+ * @param path Path to the offending value from the root, `$`.
278
+ */
279
+ constructor(message, path) {
280
+ super(`${message} (at ${path})`);
281
+ this.name = "PbxprojBuildError";
282
+ this.path = path;
283
+ }
284
+ };
285
+ //#endregion
286
+ //#region src/quotes.ts
287
+ /**
288
+ * Quoting and escaping rules for `project.pbxproj` output.
289
+ *
290
+ * The unquoted-safe alphabet is deliberately narrower than what the parser
291
+ * accepts: Xcode itself quotes values containing `-`, so emitting them
292
+ * unquoted would produce documents Xcode rewrites on next save.
293
+ *
294
+ * @module
295
+ */
296
+ /**
297
+ * The writer's unquoted-safe alphabet. The `+` also rejects the empty
298
+ * string, which must render as `""`.
299
+ */
300
+ const UNQUOTED_SAFE_PATTERN = /^[A-Za-z0-9_$/:.]+$/u;
301
+ /**
302
+ * Whether the value can render without quotes: every character is in the
303
+ * unquoted-safe alphabet and the string is not empty.
304
+ *
305
+ * Quoting decisions scan every string a document carries, and many of those
306
+ * strings are substring slices of the source text. The regex engine flattens
307
+ * and scans them in bulk, which measures faster here than a per-character
308
+ * `charCodeAt` loop.
309
+ */
310
+ function isSafeUnquoted(value) {
311
+ return UNQUOTED_SAFE_PATTERN.test(value);
312
+ }
313
+ /**
314
+ * Whether the string contains characters that require escape sequences
315
+ * inside a quoted string: control characters, `"`, `\`, or DEL.
316
+ */
317
+ function needsEscaping(value) {
318
+ for (let i = 0; i < value.length; i++) {
319
+ const code = value.charCodeAt(i);
320
+ if (code < 32 || code === 34 || code === 92 || code === 127) return true;
321
+ }
322
+ return false;
323
+ }
324
+ /**
325
+ * Escapes special characters for a quoted string: the named C-style escapes
326
+ * plus `\Uxxxx` for remaining control characters.
327
+ */
328
+ function escapeString(value) {
329
+ let result = "";
330
+ for (const ch of value) switch (ch) {
331
+ case "\x07":
332
+ result += "\\a";
333
+ break;
334
+ case "\b":
335
+ result += "\\b";
336
+ break;
337
+ case "\f":
338
+ result += "\\f";
339
+ break;
340
+ case "\r":
341
+ result += "\\r";
342
+ break;
343
+ case " ":
344
+ result += "\\t";
345
+ break;
346
+ case "\v":
347
+ result += "\\v";
348
+ break;
349
+ case "\n":
350
+ result += "\\n";
351
+ break;
352
+ case "\"":
353
+ result += "\\\"";
354
+ break;
355
+ case "\\":
356
+ result += "\\\\";
357
+ break;
358
+ default: {
359
+ const code = ch.charCodeAt(0);
360
+ if (code < 32 || code === 127) result += `\\U${code.toString(16).padStart(4, "0")}`;
361
+ else result += ch;
362
+ }
363
+ }
364
+ return result;
365
+ }
366
+ /**
367
+ * Renders a string value with quotes exactly when required.
368
+ *
369
+ * Safe literals stay bare, everything else is wrapped in double quotes, and
370
+ * escape processing runs only when an escapable character is present.
371
+ */
372
+ function ensureQuotes(value) {
373
+ if (isSafeUnquoted(value)) return value;
374
+ if (!needsEscaping(value)) return `"${value}"`;
375
+ return `"${escapeString(value)}"`;
376
+ }
377
+ /**
378
+ * Renders binary data as an uppercase hex run: `<DEADBEEF>`.
379
+ */
380
+ function formatData(data) {
381
+ let hex = "";
382
+ for (const byte of data) hex += byte.toString(16).padStart(2, "0").toUpperCase();
383
+ return `<${hex}>`;
384
+ }
385
+ //#endregion
386
+ //#region src/build.ts
387
+ /**
388
+ * Serializer producing the exact layout Xcode writes.
389
+ *
390
+ * The layout rules that matter for diffability against Xcode's own output:
391
+ *
392
+ * - the `// !$*UTF8*$!` marker on the first line;
393
+ * - tab indentation, one level per nesting depth;
394
+ * - the root `objects` dictionary grouped into `/* Begin <isa> section *​/`
395
+ * blocks, sections ordered by isa and entries ordered by uuid;
396
+ * - `PBXBuildFile` and `PBXFileReference` entries rendered on a single line;
397
+ * - reference comments (`13B07F86… /* AppDelegate.swift in Sources *​/`)
398
+ * derived from the object graph.
399
+ *
400
+ * Numbers render exactly as JavaScript formats them; the version-like
401
+ * settings Xcode writes with a trailing zero (`SWIFT_VERSION = 5.0`) arrive
402
+ * from the parser as strings and round-trip verbatim, so no reformatting
403
+ * heuristic is needed or applied.
404
+ *
405
+ * @module
406
+ */
407
+ /** The encoding marker Xcode writes on the first line of every document. */
408
+ const SHEBANG = "// !$*UTF8*$!";
409
+ /**
410
+ * Creates the error for a `NaN` or infinite number at the given value path.
411
+ */
412
+ function nonFiniteNumber(value, path) {
413
+ return new PbxprojBuildError(`Cannot serialize non-finite number ${String(value)}`, path);
414
+ }
415
+ /**
416
+ * Creates the error for a value outside the pbxproj value model (`null`,
417
+ * booleans, bigints, functions, symbols, class instances) at the given
418
+ * value path.
419
+ */
420
+ function invalidValue(value, path) {
421
+ return new PbxprojBuildError(`Cannot serialize a ${value === null ? "null" : typeof value} value; the pbxproj format carries strings, numbers, data, arrays, and dictionaries`, path);
422
+ }
423
+ /**
424
+ * Defuses `*​/` sequences inside derived comment text.
425
+ *
426
+ * Comments derive from document fields (names, paths), so a crafted value
427
+ * containing `*​/` could otherwise terminate the comment early and corrupt
428
+ * the surrounding document.
429
+ */
430
+ function sanitizeComment(comment) {
431
+ return comment.includes("*/") ? comment.replaceAll("*/", "* /") : comment;
432
+ }
433
+ /**
434
+ * Indentation strings by depth, extended on demand.
435
+ *
436
+ * Sharing the strings avoids a `"\t".repeat(depth)` allocation on every
437
+ * line of output.
438
+ */
439
+ const INDENTS = [""];
440
+ /**
441
+ * Returns the shared indentation string for a nesting depth.
442
+ */
443
+ function indentString(depth) {
444
+ const cached = INDENTS[depth];
445
+ if (cached != null) return cached;
446
+ let known = INDENTS.at(-1);
447
+ while (INDENTS.length <= depth) {
448
+ known += " ";
449
+ INDENTS.push(known);
450
+ }
451
+ return known;
452
+ }
453
+ /**
454
+ * Serialization state for one {@link buildPbxproj} call.
455
+ *
456
+ * The `write*` methods append directly to the output string; the `render*`
457
+ * methods return fragments for the caller to place. Output accumulates by
458
+ * appending, because engines represent growing strings as ropes: appends
459
+ * stay cheap where template interpolation would allocate an intermediate
460
+ * string per line.
461
+ */
462
+ var Writer = class {
463
+ /** The document text accumulated so far. */
464
+ out = "";
465
+ /** Current nesting depth; one tab per level. */
466
+ indent = 0;
467
+ /** Display comment per referenced uuid, derived once from the object graph. */
468
+ comments;
469
+ /**
470
+ * Rendered string values by input string: `id /* comment *​/` for
471
+ * referenced uuids, quoted text for everything else. Referenced objects
472
+ * render at least twice (their section entry plus each referencing site)
473
+ * and build settings repeat across configurations, so most renders are
474
+ * cache hits. The map lives for one build call only.
475
+ */
476
+ renderedReferences = /* @__PURE__ */ new Map();
477
+ /**
478
+ * Quoting decisions for dictionary keys, which draw from a small repeated
479
+ * vocabulary (`isa`, `fileRef`, build-setting names).
480
+ */
481
+ quotedKeys = /* @__PURE__ */ new Map();
482
+ /**
483
+ * Serializes the whole document eagerly; read it back with
484
+ * {@link toString}.
485
+ *
486
+ * @param root The document root dictionary.
487
+ */
488
+ constructor(root) {
489
+ this.comments = createReferenceComments(root);
490
+ this.out = `${SHEBANG}\n`;
491
+ this.writeLine("{");
492
+ this.indent++;
493
+ this.writeObjectBody(root, true, "$");
494
+ this.indent--;
495
+ this.writeLine("}");
496
+ }
497
+ /**
498
+ * Returns the serialized document text.
499
+ */
500
+ toString() {
501
+ return this.out;
502
+ }
503
+ /**
504
+ * Appends the indentation for the current depth.
505
+ */
506
+ writeIndent() {
507
+ this.out += indentString(this.indent);
508
+ }
509
+ /**
510
+ * Appends one indented line followed by a newline.
511
+ */
512
+ writeLine(text) {
513
+ this.out += `${indentString(this.indent)}${text}\n`;
514
+ }
515
+ /**
516
+ * Renders a string as a uuid reference with its display comment, or as a
517
+ * plain quoted value when no comment is derived for it.
518
+ *
519
+ * Most calls hit the cache, and the writers call this for every key and
520
+ * reference, so the method body stays small enough for the engine to
521
+ * inline; the miss path lives in {@link renderReferenceUncached}.
522
+ */
523
+ renderReference(id) {
524
+ const cached = this.renderedReferences.get(id);
525
+ if (cached != null) return cached;
526
+ return this.renderReferenceUncached(id);
527
+ }
528
+ /**
529
+ * Renders and caches one reference on its first occurrence. Annotated ids
530
+ * are quoted too when the format requires it: Xcode ids never need
531
+ * quotes, but object keys in hand-written documents can.
532
+ */
533
+ renderReferenceUncached(id) {
534
+ const comment = this.comments.get(id);
535
+ const quoted = ensureQuotes(id);
536
+ const rendered = comment != null && comment.length > 0 ? `${quoted} /* ${sanitizeComment(comment)} */` : quoted;
537
+ this.renderedReferences.set(id, rendered);
538
+ return rendered;
539
+ }
540
+ /**
541
+ * Renders a dictionary key with quotes when the format requires them,
542
+ * memoized across the document. As with {@link renderReference}, the
543
+ * cache-hit path stays small and the miss path is a separate method.
544
+ */
545
+ renderKey(key) {
546
+ const cached = this.quotedKeys.get(key);
547
+ if (cached != null) return cached;
548
+ return this.renderKeyUncached(key);
549
+ }
550
+ /**
551
+ * Quotes and caches one dictionary key on its first occurrence.
552
+ */
553
+ renderKeyUncached(key) {
554
+ const quoted = ensureQuotes(key);
555
+ this.quotedKeys.set(key, quoted);
556
+ return quoted;
557
+ }
558
+ /**
559
+ * Renders a string value in its key's context.
560
+ *
561
+ * `remoteGlobalIDString` and `TestTargetID` hold uuids of objects in
562
+ * another container; annotating them with this container's comments would
563
+ * be wrong, so they render bare.
564
+ */
565
+ renderStringValue(key, value) {
566
+ if (key === "remoteGlobalIDString" || key === "TestTargetID") return ensureQuotes(value);
567
+ return this.renderReference(value);
568
+ }
569
+ /**
570
+ * Appends the entries of a dictionary, one `key = value;` line each.
571
+ *
572
+ * Value paths (`$.objects.AA….name`) exist for error messages and are
573
+ * only constructed in the branches that recurse or throw; the flat string
574
+ * and number lines that dominate documents skip the concatenation.
575
+ *
576
+ * @param object The dictionary whose entries to write.
577
+ * @param isBase Whether this is the document root, where the `objects`
578
+ * dictionary gets section grouping and empty dictionaries render
579
+ * multi-line.
580
+ * @param path Value path of `object`, for error messages.
581
+ */
582
+ writeObjectBody(object, isBase, path) {
583
+ for (const key of Object.keys(object)) {
584
+ const value = object[key];
585
+ if (typeof value === "string") {
586
+ this.out += indentString(this.indent);
587
+ this.out += this.renderKey(key);
588
+ this.out += " = ";
589
+ this.out += this.renderStringValue(key, value);
590
+ this.out += ";\n";
591
+ } else if (typeof value === "number") {
592
+ if (!Number.isFinite(value)) throw nonFiniteNumber(value, `${path}.${key}`);
593
+ this.out += indentString(this.indent);
594
+ this.out += this.renderKey(key);
595
+ this.out += " = ";
596
+ this.out += String(value);
597
+ this.out += ";\n";
598
+ } else if (value instanceof Uint8Array) this.writeLine(`${this.renderKey(key)} = ${formatData(value)};`);
599
+ else if (Array.isArray(value)) this.writeArray(key, value, `${path}.${key}`);
600
+ else if (isDictionary(value)) {
601
+ if (!isBase && Object.keys(value).length === 0) {
602
+ this.writeLine(`${this.renderKey(key)} = {};`);
603
+ continue;
604
+ }
605
+ this.writeLine(`${this.renderKey(key)} = {`);
606
+ this.indent++;
607
+ if (isBase && key === "objects") this.writeObjectsSections(value, `${path}.${key}`);
608
+ else this.writeObjectBody(value, false, `${path}.${key}`);
609
+ this.indent--;
610
+ this.writeLine("};");
611
+ } else throw invalidValue(value, `${path}.${key}`);
612
+ }
613
+ }
614
+ /**
615
+ * Appends the root `objects` dictionary grouped into per-isa sections.
616
+ *
617
+ * Sections are ordered by isa name and entries within a section by uuid,
618
+ * exactly as Xcode sorts them.
619
+ */
620
+ writeObjectsSections(objects, path) {
621
+ const byIsa = /* @__PURE__ */ new Map();
622
+ for (const id of Object.keys(objects)) {
623
+ const object = objects[id];
624
+ if (!isDictionary(object)) throw invalidValue(object, `${path}.${id}`);
625
+ const isaValue = object["isa"];
626
+ const isa = typeof isaValue === "string" ? isaValue : "Unknown";
627
+ const entries = byIsa.get(isa);
628
+ if (entries == null) byIsa.set(isa, [[id, object]]);
629
+ else entries.push([id, object]);
630
+ }
631
+ for (const isa of [...byIsa.keys()].toSorted()) {
632
+ this.out += `\n/* Begin ${sanitizeComment(isa)} section */\n`;
633
+ const entries = (byIsa.get(isa) ?? []).toSorted(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
634
+ for (const [id, object] of entries) {
635
+ this.writeIndent();
636
+ if (isa === "PBXBuildFile" || isa === "PBXFileReference") {
637
+ let text = this.renderInlineObject(id, object, `${path}.${id}`);
638
+ if (text.endsWith(" ")) text = text.slice(0, -1);
639
+ this.out += text;
640
+ this.out += "\n";
641
+ } else {
642
+ this.out += `${this.renderReference(id)} = {\n`;
643
+ this.indent++;
644
+ this.writeObjectBody(object, false, `${path}.${id}`);
645
+ this.indent--;
646
+ this.writeLine("};");
647
+ }
648
+ }
649
+ this.out += `/* End ${sanitizeComment(isa)} section */\n`;
650
+ }
651
+ }
652
+ /**
653
+ * Renders one object as a single `uuid /* comment *​/ = {isa = …; };`
654
+ * fragment, recursing into nested dictionaries inline.
655
+ */
656
+ renderInlineObject(key, object, path) {
657
+ let text = `${this.renderReference(key)} = {`;
658
+ for (const innerKey of Object.keys(object)) {
659
+ const value = object[innerKey];
660
+ if (typeof value === "string") text += `${this.renderKey(innerKey)} = ${this.renderStringValue(innerKey, value)}; `;
661
+ else if (typeof value === "number") {
662
+ if (!Number.isFinite(value)) throw nonFiniteNumber(value, `${path}.${innerKey}`);
663
+ text += `${this.renderKey(innerKey)} = ${String(value)}; `;
664
+ } else if (value instanceof Uint8Array) text += `${this.renderKey(innerKey)} = ${formatData(value)}; `;
665
+ else if (Array.isArray(value)) {
666
+ text += `${this.renderKey(innerKey)} = (`;
667
+ for (let index = 0; index < value.length; index++) {
668
+ const item = value[index];
669
+ if (typeof item === "string") text += `${ensureQuotes(item)}, `;
670
+ else if (typeof item === "number" && Number.isFinite(item)) text += `${String(item)}, `;
671
+ else throw typeof item === "number" ? nonFiniteNumber(item, `${path}.${innerKey}[${index}]`) : invalidValue(item, `${path}.${innerKey}[${index}]`);
672
+ }
673
+ text += "); ";
674
+ } else if (isDictionary(value)) text += this.renderInlineObject(innerKey, value, `${path}.${innerKey}`);
675
+ else throw invalidValue(value, `${path}.${innerKey}`);
676
+ }
677
+ text += "}; ";
678
+ return text;
679
+ }
680
+ /**
681
+ * Appends a `key = ( item, … );` array with one indented line per item,
682
+ * the layout Xcode uses for every multi-line list.
683
+ */
684
+ writeArray(key, items, path) {
685
+ this.writeLine(`${this.renderKey(key)} = (`);
686
+ this.indent++;
687
+ for (let index = 0; index < items.length; index++) {
688
+ const item = items[index];
689
+ if (typeof item === "string") {
690
+ this.out += indentString(this.indent);
691
+ this.out += this.renderReference(item);
692
+ this.out += ",\n";
693
+ } else if (typeof item === "number") {
694
+ if (!Number.isFinite(item)) throw nonFiniteNumber(item, `${path}[${index}]`);
695
+ this.writeLine(`${String(item)},`);
696
+ } else if (item instanceof Uint8Array) this.writeLine(`${formatData(item)},`);
697
+ else if (isDictionary(item)) {
698
+ this.writeLine("{");
699
+ this.indent++;
700
+ this.writeObjectBody(item, false, `${path}[${index}]`);
701
+ this.indent--;
702
+ this.writeLine("},");
703
+ } else throw invalidValue(item, `${path}[${index}]`);
704
+ }
705
+ this.indent--;
706
+ this.writeLine(");");
707
+ }
708
+ };
709
+ /**
710
+ * Serializes a project document to `project.pbxproj` text.
711
+ *
712
+ * The input is the same shape {@link parsePbxproj} produces; see the module
713
+ * documentation of `types.ts` for the value model. Output is stable: two
714
+ * calls with semantically equal documents produce identical text, and the
715
+ * layout matches what Xcode itself writes so diffs stay minimal.
716
+ *
717
+ * @param root The document root. Real project documents carry `objects`,
718
+ * `rootObject`, and the version fields, but any dictionary serializes.
719
+ * @returns The document text, terminated by a newline.
720
+ * @throws PbxprojBuildError when a value has no pbxproj representation:
721
+ * `null`, `undefined`, booleans, bigints, functions, symbols, class
722
+ * instances, or non-finite numbers. The error names the path of the
723
+ * offending value.
724
+ */
725
+ function buildPbxproj(root) {
726
+ if (!isDictionary(root)) throw new PbxprojBuildError("The document root must be a dictionary", "$");
727
+ return new Writer(root).toString();
728
+ }
729
+ //#endregion
730
+ //#region src/escape.ts
731
+ /**
732
+ * Escape handling for quoted strings.
733
+ *
734
+ * OpenStep-style property lists inherit their escape syntax from NeXTSTEP:
735
+ * C-style character escapes, `\Uxxxx` Unicode escapes, and octal escapes
736
+ * whose values above 0x7F select characters from the NeXTSTEP character set
737
+ * rather than Latin-1.
738
+ *
739
+ * @module
740
+ */
741
+ /**
742
+ * NeXTSTEP character set for byte values 0x80-0xFF, indexed by `byte - 0x80`.
743
+ *
744
+ * Octal escapes are how pre-Unicode NeXTSTEP text encoded non-ASCII
745
+ * characters; mapping them through this table is what makes `\341` decode to
746
+ * `Æ` instead of the Latin-1 `á`. Values are Unicode code points, per the
747
+ * published NEXTSTEP.TXT vendor mapping in the Unicode Character Database.
748
+ */
749
+ const NEXT_STEP_MAPPINGS = [
750
+ 160,
751
+ 192,
752
+ 193,
753
+ 194,
754
+ 195,
755
+ 196,
756
+ 197,
757
+ 199,
758
+ 200,
759
+ 201,
760
+ 202,
761
+ 203,
762
+ 204,
763
+ 205,
764
+ 206,
765
+ 207,
766
+ 208,
767
+ 209,
768
+ 210,
769
+ 211,
770
+ 212,
771
+ 213,
772
+ 214,
773
+ 217,
774
+ 218,
775
+ 219,
776
+ 220,
777
+ 221,
778
+ 222,
779
+ 181,
780
+ 215,
781
+ 247,
782
+ 169,
783
+ 161,
784
+ 162,
785
+ 163,
786
+ 8260,
787
+ 165,
788
+ 402,
789
+ 167,
790
+ 164,
791
+ 8217,
792
+ 8220,
793
+ 171,
794
+ 8249,
795
+ 8250,
796
+ 64257,
797
+ 64258,
798
+ 174,
799
+ 8211,
800
+ 8224,
801
+ 8225,
802
+ 183,
803
+ 166,
804
+ 182,
805
+ 8226,
806
+ 8218,
807
+ 8222,
808
+ 8221,
809
+ 187,
810
+ 8230,
811
+ 8240,
812
+ 172,
813
+ 191,
814
+ 185,
815
+ 715,
816
+ 180,
817
+ 710,
818
+ 732,
819
+ 175,
820
+ 728,
821
+ 729,
822
+ 168,
823
+ 178,
824
+ 730,
825
+ 184,
826
+ 179,
827
+ 733,
828
+ 731,
829
+ 711,
830
+ 8212,
831
+ 177,
832
+ 188,
833
+ 189,
834
+ 190,
835
+ 224,
836
+ 225,
837
+ 226,
838
+ 227,
839
+ 228,
840
+ 229,
841
+ 231,
842
+ 232,
843
+ 233,
844
+ 234,
845
+ 235,
846
+ 236,
847
+ 198,
848
+ 237,
849
+ 170,
850
+ 238,
851
+ 239,
852
+ 240,
853
+ 241,
854
+ 321,
855
+ 216,
856
+ 338,
857
+ 186,
858
+ 242,
859
+ 243,
860
+ 244,
861
+ 245,
862
+ 246,
863
+ 230,
864
+ 249,
865
+ 250,
866
+ 251,
867
+ 305,
868
+ 252,
869
+ 253,
870
+ 322,
871
+ 248,
872
+ 339,
873
+ 223,
874
+ 254,
875
+ 255,
876
+ 65533,
877
+ 65533
878
+ ];
879
+ /**
880
+ * Maps an octal escape value to its Unicode code point.
881
+ *
882
+ * Values below 0x80 are ASCII and pass through; values in 0x80-0xFF select
883
+ * from the NeXTSTEP character set.
884
+ */
885
+ function nextStepToUnicode(code) {
886
+ if (code < 128 || code > 255) return code;
887
+ return NEXT_STEP_MAPPINGS[code - 128] ?? code;
888
+ }
889
+ const CODE_ZERO$1 = 48;
890
+ const CODE_SEVEN = 55;
891
+ const CODE_BACKSLASH$1 = 92;
892
+ /**
893
+ * Whether the code unit is an ASCII hexadecimal digit (`0-9A-Fa-f`).
894
+ */
895
+ function isHexDigit(code) {
896
+ return code >= 48 && code <= 57 || code >= 65 && code <= 70 || code >= 97 && code <= 102;
897
+ }
898
+ /**
899
+ * Single-character escapes and their decoded text. An escaped line break
900
+ * decodes to a newline, like the C escape it mirrors.
901
+ */
902
+ const SIMPLE_ESCAPES = {
903
+ a: "\x07",
904
+ b: "\b",
905
+ f: "\f",
906
+ n: "\n",
907
+ r: "\r",
908
+ t: " ",
909
+ v: "\v",
910
+ "\"": "\"",
911
+ "'": "'",
912
+ "\\": "\\",
913
+ "\n": "\n"
914
+ };
915
+ /**
916
+ * Decodes a `\Uxxxx` escape whose backslash sits at `index`. Returns the
917
+ * decoded text and the index after the escape, or `undefined` when the
918
+ * sequence is not exactly four hex digits.
919
+ */
920
+ function readUnicodeEscape(input, index) {
921
+ const hex = input.slice(index + 2, index + 6);
922
+ if (hex.length !== 4) return;
923
+ for (let i = 0; i < 4; i++) if (!isHexDigit(hex.charCodeAt(i))) return;
924
+ const code = Number.parseInt(hex, 16);
925
+ return [code < 55296 || code > 57343 ? String.fromCharCode(code) : "", index + 6];
926
+ }
927
+ /**
928
+ * Decodes a `\NNN` octal escape (1-3 digits) whose backslash sits at
929
+ * `index`, mapping values at or above 0x80 through the NeXTSTEP character
930
+ * set. Returns the decoded text and the index after the escape.
931
+ */
932
+ function readOctalEscape(input, index) {
933
+ let end = index + 1;
934
+ while (end < input.length && end < index + 4) {
935
+ const code = input.charCodeAt(end);
936
+ if (code < CODE_ZERO$1 || code > CODE_SEVEN) break;
937
+ end++;
938
+ }
939
+ const octal = Number.parseInt(input.slice(index + 1, end), 8);
940
+ return [String.fromCharCode(nextStepToUnicode(octal)), end];
941
+ }
942
+ /**
943
+ * Decodes one escape sequence whose backslash sits at `index`. Returns the
944
+ * decoded text and the index after the sequence. Unknown escapes preserve
945
+ * both characters, the lenient behavior needed to read files written by
946
+ * tools with sloppier escaping than Xcode's.
947
+ */
948
+ function decodeEscape(input, index) {
949
+ const next = input[index + 1];
950
+ const simple = SIMPLE_ESCAPES[next];
951
+ if (simple != null) return [simple, index + 2];
952
+ if (next === "U") return readUnicodeEscape(input, index) ?? ["\\", index + 1];
953
+ const code = next.charCodeAt(0);
954
+ if (code >= CODE_ZERO$1 && code <= CODE_SEVEN) return readOctalEscape(input, index);
955
+ return [`\\${next}`, index + 2];
956
+ }
957
+ /**
958
+ * Processes escape sequences in a quoted string (quotes already stripped).
959
+ *
960
+ * Handles the standard escapes (`\a \b \f \n \r \t \v \" \' \\` and an
961
+ * escaped line break), `\Uxxxx` Unicode escapes (exactly 4 hex digits), and
962
+ * `\NNN` octal escapes (1-3 digits, values at or above 0x80 mapped through
963
+ * the NeXTSTEP character set).
964
+ */
965
+ function unescapeString(input) {
966
+ const length = input.length;
967
+ let result = "";
968
+ let i = 0;
969
+ while (i < length) if (input.charCodeAt(i) === CODE_BACKSLASH$1 && i + 1 < length) {
970
+ const [text, next] = decodeEscape(input, i);
971
+ result += text;
972
+ i = next;
973
+ } else {
974
+ result += input[i];
975
+ i += 1;
976
+ }
977
+ return result;
978
+ }
979
+ //#endregion
980
+ //#region src/parse.ts
981
+ /**
982
+ * Single-pass recursive-descent parser for `project.pbxproj` files.
983
+ *
984
+ * The grammar is the OpenStep-style property list Xcode reads and writes:
985
+ * `{ key = value; ... }` dictionaries, `( item, ... )` arrays, quoted and
986
+ * unquoted strings, `<hex>` data runs, and `//` and `/* *​/` comments as
987
+ * insignificant trivia.
988
+ *
989
+ * @module
990
+ */
991
+ /**
992
+ * Characters allowed in unquoted string literals: `[A-Za-z0-9_$/:.-]`.
993
+ *
994
+ * A 256-entry lookup table keyed by code unit keeps classification to a
995
+ * single array read in the hot loop. Non-ASCII units index past the table
996
+ * and read `undefined`, which is correctly falsy, because non-ASCII text
997
+ * only appears inside quoted strings.
998
+ */
999
+ const IS_LITERAL_CHAR = (() => {
1000
+ const table = /* @__PURE__ */ new Uint8Array(256);
1001
+ for (let i = 97; i <= 122; i++) table[i] = 1;
1002
+ for (let i = 65; i <= 90; i++) table[i] = 1;
1003
+ for (let i = 48; i <= 57; i++) table[i] = 1;
1004
+ for (const ch of "_$/:.-") table[ch.charCodeAt(0)] = 1;
1005
+ return table;
1006
+ })();
1007
+ const CODE_TAB = 9;
1008
+ const CODE_LINE_FEED = 10;
1009
+ const CODE_CARRIAGE_RETURN = 13;
1010
+ const CODE_SPACE = 32;
1011
+ const CODE_QUOTE = 34;
1012
+ const CODE_SINGLE_QUOTE = 39;
1013
+ const CODE_OPEN_PAREN = 40;
1014
+ const CODE_CLOSE_PAREN = 41;
1015
+ const CODE_ASTERISK = 42;
1016
+ const CODE_COMMA = 44;
1017
+ const CODE_MINUS = 45;
1018
+ const CODE_DOT = 46;
1019
+ const CODE_SLASH = 47;
1020
+ const CODE_ZERO = 48;
1021
+ const CODE_NINE = 57;
1022
+ const CODE_SEMICOLON = 59;
1023
+ const CODE_LESS_THAN = 60;
1024
+ const CODE_EQUALS = 61;
1025
+ const CODE_GREATER_THAN = 62;
1026
+ const CODE_BACKSLASH = 92;
1027
+ const CODE_OPEN_BRACE = 123;
1028
+ const CODE_CLOSE_BRACE = 125;
1029
+ /**
1030
+ * Whitespace classification as a 256-entry table.
1031
+ *
1032
+ * This is the single hottest check in the scanner, so it compiles to one
1033
+ * array read instead of four comparisons.
1034
+ */
1035
+ const IS_WHITESPACE = (() => {
1036
+ const table = /* @__PURE__ */ new Uint8Array(256);
1037
+ table[CODE_SPACE] = 1;
1038
+ table[CODE_TAB] = 1;
1039
+ table[CODE_CARRIAGE_RETURN] = 1;
1040
+ table[CODE_LINE_FEED] = 1;
1041
+ return table;
1042
+ })();
1043
+ /**
1044
+ * Whether the code unit is an ASCII decimal digit (`0-9`).
1045
+ */
1046
+ function isDigit(code) {
1047
+ return code >= CODE_ZERO && code <= CODE_NINE;
1048
+ }
1049
+ /**
1050
+ * Scanner state and grammar productions for one parse call.
1051
+ *
1052
+ * The parser holds a single cursor into the source string and advances it
1053
+ * through the `read*` and `parse*` methods; there is no separate tokenizer
1054
+ * stage and no token objects.
1055
+ */
1056
+ var Parser = class {
1057
+ /** Source text of the document being parsed. */
1058
+ input;
1059
+ /** Cursor position as a UTF-16 code unit offset into {@link input}. */
1060
+ pos = 0;
1061
+ /**
1062
+ * Offset of an unterminated block comment the trivia scanner consumed, or
1063
+ * -1. Recording it instead of throwing keeps the trivia scanner free of
1064
+ * failure branches; see {@link fail}.
1065
+ */
1066
+ unterminatedCommentAt = -1;
1067
+ /**
1068
+ * @param input Source text of the document.
1069
+ */
1070
+ constructor(input) {
1071
+ this.input = input;
1072
+ }
1073
+ /**
1074
+ * Throws a {@link PbxprojParseError} carrying the line and column of the
1075
+ * failure.
1076
+ *
1077
+ * An unterminated block comment swallows the rest of the input, so any
1078
+ * failure raised after one (always some end-of-input error) is a symptom;
1079
+ * the comment itself is reported instead. Content after the root value is
1080
+ * never scanned, so a trailing unterminated comment still parses, as
1081
+ * Apple's parser accepts it too.
1082
+ *
1083
+ * @param message Failure description without location.
1084
+ * @param offset Offset of the failure; defaults to the current cursor.
1085
+ */
1086
+ fail(message, offset = this.pos) {
1087
+ if (this.unterminatedCommentAt !== -1) throw new PbxprojParseError("Unterminated block comment", this.input, this.unterminatedCommentAt);
1088
+ throw new PbxprojParseError(message, this.input, offset);
1089
+ }
1090
+ /**
1091
+ * Skips whitespace and `//` / `/* *​/` comments in bulk.
1092
+ *
1093
+ * Most trivia gaps are pure whitespace, so this hot method is only the
1094
+ * whitespace loop plus one slash check, small enough for the engine to
1095
+ * inline into the parse loops. Gaps containing comments continue in
1096
+ * {@link skipCommentedTrivia}.
1097
+ */
1098
+ skipTrivia() {
1099
+ const input = this.input;
1100
+ const length = input.length;
1101
+ let pos = this.pos;
1102
+ while (pos < length && IS_WHITESPACE[input.charCodeAt(pos)] === 1) pos++;
1103
+ if (pos < length && input.charCodeAt(pos) === CODE_SLASH) pos = this.skipCommentedTrivia(pos);
1104
+ this.pos = pos;
1105
+ }
1106
+ /**
1107
+ * Continues a trivia scan whose cursor sits on a `/`, consuming comments
1108
+ * and any whitespace between them until significant content follows.
1109
+ * Returns the position of that content (`pos` unchanged when the slash
1110
+ * does not open a comment, since `/` also starts unquoted path literals).
1111
+ *
1112
+ * Comment bodies are jumped over with `indexOf` rather than scanned per
1113
+ * character. Reference comments make up a sizable share of a canonical
1114
+ * document's bytes, and `indexOf` uses the engine's vectorized search. An
1115
+ * unterminated block comment consumes the rest of the input and records
1116
+ * its offset for {@link fail}, keeping throw sites off the scanner paths.
1117
+ */
1118
+ skipCommentedTrivia(pos) {
1119
+ const input = this.input;
1120
+ const length = input.length;
1121
+ for (;;) {
1122
+ const next = input.charCodeAt(pos + 1);
1123
+ if (next === CODE_SLASH) {
1124
+ const lineEnd = input.indexOf("\n", pos + 2);
1125
+ pos = lineEnd === -1 ? length : lineEnd;
1126
+ } else if (next === CODE_ASTERISK) {
1127
+ const commentEnd = input.indexOf("*/", pos + 2);
1128
+ if (commentEnd === -1) {
1129
+ this.unterminatedCommentAt = pos;
1130
+ return length;
1131
+ }
1132
+ pos = commentEnd + 2;
1133
+ } else return pos;
1134
+ while (pos < length && IS_WHITESPACE[input.charCodeAt(pos)] === 1) pos++;
1135
+ if (pos >= length || input.charCodeAt(pos) !== CODE_SLASH) return pos;
1136
+ }
1137
+ }
1138
+ /**
1139
+ * Returns the next significant code unit without consuming it, or -1 at
1140
+ * end of input. Trivia before it is consumed.
1141
+ */
1142
+ peek() {
1143
+ this.skipTrivia();
1144
+ return this.pos < this.input.length ? this.input.charCodeAt(this.pos) : -1;
1145
+ }
1146
+ /**
1147
+ * Consumes the expected code unit or fails with a message naming it.
1148
+ *
1149
+ * @param code The expected UTF-16 code unit.
1150
+ * @param description How the character reads in the error message.
1151
+ */
1152
+ expect(code, description) {
1153
+ if (this.input.charCodeAt(this.pos) === code) {
1154
+ this.pos++;
1155
+ return;
1156
+ }
1157
+ this.skipTrivia();
1158
+ if (this.pos < this.input.length && this.input.charCodeAt(this.pos) === code) {
1159
+ this.pos++;
1160
+ return;
1161
+ }
1162
+ const found = this.pos < this.input.length ? `'${this.input[this.pos]}'` : "end of input";
1163
+ this.fail(`Expected '${description}' but found ${found}`);
1164
+ }
1165
+ /**
1166
+ * Reads an unquoted literal run and returns it as text.
1167
+ *
1168
+ * The caller guarantees the cursor is on at least one literal character.
1169
+ */
1170
+ readLiteral() {
1171
+ const input = this.input;
1172
+ const length = input.length;
1173
+ const start = this.pos;
1174
+ let pos = start;
1175
+ while (pos < length && IS_LITERAL_CHAR[input.charCodeAt(pos)] === 1) pos++;
1176
+ this.pos = pos;
1177
+ return input.slice(start, pos);
1178
+ }
1179
+ /**
1180
+ * Reads a quoted string; the opening quote is at the current position.
1181
+ *
1182
+ * The scan tracks whether any escape sequence occurred: unescaped strings
1183
+ * (the overwhelming majority) return as a direct slice, and only escaped
1184
+ * ones pay for {@link unescapeString}.
1185
+ */
1186
+ readQuotedString() {
1187
+ const input = this.input;
1188
+ const length = input.length;
1189
+ const quote = input.charCodeAt(this.pos);
1190
+ const start = ++this.pos;
1191
+ let hasEscape = false;
1192
+ let end = start;
1193
+ while (end < length) {
1194
+ const code = input.charCodeAt(end);
1195
+ if (code === quote) break;
1196
+ if (code === CODE_BACKSLASH) {
1197
+ hasEscape = true;
1198
+ end += 2;
1199
+ } else end += 1;
1200
+ }
1201
+ if (end >= length) this.fail("Unterminated string", start - 1);
1202
+ const raw = input.slice(start, end);
1203
+ this.pos = end + 1;
1204
+ return hasEscape ? unescapeString(raw) : raw;
1205
+ }
1206
+ /**
1207
+ * Reads a `<hex bytes>` data run into a `Uint8Array`; the `<` is at the
1208
+ * current position.
1209
+ *
1210
+ * Whitespace between digits is allowed (Xcode writes `<AB CD>`), and the
1211
+ * digit count must be even: Apple's parser rejects odd counts, and
1212
+ * padding would guess a byte.
1213
+ */
1214
+ readData() {
1215
+ const input = this.input;
1216
+ const length = input.length;
1217
+ const start = ++this.pos;
1218
+ while (this.pos < length && input.charCodeAt(this.pos) !== CODE_GREATER_THAN) this.pos++;
1219
+ if (this.pos >= length) this.fail("Unterminated data run", start - 1);
1220
+ let hex = "";
1221
+ for (let i = start; i < this.pos; i++) {
1222
+ const code = input.charCodeAt(i);
1223
+ if (isHexDigit(code)) hex += input[i];
1224
+ else if (IS_WHITESPACE[code] !== 1) this.fail(`Invalid character '${input[i]}' in data run`, i);
1225
+ }
1226
+ this.pos++;
1227
+ if (hex.length % 2 !== 0) this.fail("Data run has an odd number of hex digits", start);
1228
+ const bytes = new Uint8Array(hex.length / 2);
1229
+ for (let i = 0; i < hex.length; i += 2) bytes[i >> 1] = Number.parseInt(hex.slice(i, i + 2), 16);
1230
+ return bytes;
1231
+ }
1232
+ /**
1233
+ * Parses the document root: a dictionary or an array.
1234
+ */
1235
+ parseDocument() {
1236
+ const code = this.peek();
1237
+ if (code === CODE_OPEN_BRACE) return this.parseObject();
1238
+ if (code === CODE_OPEN_PAREN) return this.parseArray();
1239
+ if (code === -1) this.fail("Empty input");
1240
+ this.fail(`Expected '{' or '(' at the start of the document but found '${this.input[this.pos]}'`);
1241
+ }
1242
+ /**
1243
+ * Parses a `{ key = value; ... }` dictionary; the `{` is at the current
1244
+ * position. Keys may be quoted or unquoted, and every entry requires the
1245
+ * `=` and terminating `;`.
1246
+ */
1247
+ parseObject() {
1248
+ const input = this.input;
1249
+ const length = input.length;
1250
+ this.pos++;
1251
+ const result = {};
1252
+ for (;;) {
1253
+ this.skipTrivia();
1254
+ if (this.pos >= length) this.fail("Unterminated dictionary");
1255
+ const code = input.charCodeAt(this.pos);
1256
+ if (code === CODE_CLOSE_BRACE) {
1257
+ this.pos++;
1258
+ return result;
1259
+ }
1260
+ let key;
1261
+ if (code === CODE_QUOTE || code === CODE_SINGLE_QUOTE) key = this.readQuotedString();
1262
+ else if (IS_LITERAL_CHAR[code] === 1) key = this.readLiteral();
1263
+ else this.fail(`Expected a key but found '${input[this.pos]}'`);
1264
+ this.expect(CODE_EQUALS, "=");
1265
+ const value = this.parseValue();
1266
+ this.expect(CODE_SEMICOLON, ";");
1267
+ if (key === "__proto__") Object.defineProperty(result, key, {
1268
+ value,
1269
+ writable: true,
1270
+ enumerable: true,
1271
+ configurable: true
1272
+ });
1273
+ else result[key] = value;
1274
+ }
1275
+ }
1276
+ /**
1277
+ * Parses a `( item, item, ... )` array; the `(` is at the current
1278
+ * position. A trailing comma before `)` is allowed; Xcode writes one
1279
+ * after every item.
1280
+ */
1281
+ parseArray() {
1282
+ const input = this.input;
1283
+ const length = input.length;
1284
+ this.pos++;
1285
+ const items = [];
1286
+ for (;;) {
1287
+ this.skipTrivia();
1288
+ if (this.pos >= length) this.fail("Unterminated array");
1289
+ if (input.charCodeAt(this.pos) === CODE_CLOSE_PAREN) {
1290
+ this.pos++;
1291
+ return items;
1292
+ }
1293
+ items.push(this.parseValueAtCursor());
1294
+ const next = this.peek();
1295
+ if (next === CODE_COMMA) this.pos++;
1296
+ else if (next !== CODE_CLOSE_PAREN) this.fail(next === -1 ? "Unterminated array" : "Expected ',' or ')' after an array item");
1297
+ }
1298
+ }
1299
+ /**
1300
+ * Parses any value after consuming leading trivia.
1301
+ */
1302
+ parseValue() {
1303
+ this.skipTrivia();
1304
+ if (this.pos >= this.input.length) this.fail("Expected a value but found end of input");
1305
+ return this.parseValueAtCursor();
1306
+ }
1307
+ /**
1308
+ * Parses the value starting exactly at the cursor, dispatching on its
1309
+ * first character. The caller has already skipped trivia and checked
1310
+ * bounds, so the loops that call this in sequence skip one redundant
1311
+ * trivia scan per element.
1312
+ */
1313
+ parseValueAtCursor() {
1314
+ const code = this.input.charCodeAt(this.pos);
1315
+ if (code === CODE_OPEN_BRACE) return this.parseObject();
1316
+ if (code === CODE_OPEN_PAREN) return this.parseArray();
1317
+ if (code === CODE_QUOTE || code === CODE_SINGLE_QUOTE) return this.readQuotedString();
1318
+ if (IS_LITERAL_CHAR[code] === 1) return interpretLiteral(this.readLiteral());
1319
+ if (code === CODE_LESS_THAN) return this.readData();
1320
+ this.fail(`Expected a value but found '${this.input[this.pos]}'`);
1321
+ }
1322
+ };
1323
+ /**
1324
+ * Decides whether an unquoted literal is a number or a string.
1325
+ *
1326
+ * One loop with an early exit: the first character outside `[0-9.]` (after
1327
+ * an optional leading `-`) settles the token as a string, so the
1328
+ * 24-character identifiers that dominate project documents are classified
1329
+ * within their first few characters.
1330
+ *
1331
+ * Numeric-looking candidates convert under a single print-back rule: the
1332
+ * literal becomes a number exactly when the number formats back to the
1333
+ * identical text. Any literal the conversion would reshape stays a string,
1334
+ * so a parse and build cycle cannot change a single byte of any scalar.
1335
+ * That covers leading zeros like `0755`, trailing-zero decimals like `5.0`,
1336
+ * bare-dot decimals like `.5`, negative zero, and digit runs beyond double
1337
+ * precision. See the module documentation of `types.ts` for the value
1338
+ * model.
1339
+ */
1340
+ function interpretLiteral(literal) {
1341
+ const first = literal.charCodeAt(0);
1342
+ if (!isDigit(first) && first !== CODE_DOT && first !== CODE_MINUS) return literal;
1343
+ const digitsStart = first === CODE_MINUS ? 1 : 0;
1344
+ let dots = 0;
1345
+ let integer = 0;
1346
+ for (let i = digitsStart; i < literal.length; i++) {
1347
+ const code = literal.charCodeAt(i);
1348
+ if (isDigit(code)) integer = integer * 10 + (code - CODE_ZERO);
1349
+ else if (code === CODE_DOT && dots === 0) dots = 1;
1350
+ else return literal;
1351
+ }
1352
+ if (dots === 0 && literal.length - digitsStart <= 15) {
1353
+ if ((literal.charCodeAt(digitsStart) !== CODE_ZERO || literal.length - digitsStart === 1) && !(integer === 0 && digitsStart === 1)) return digitsStart === 1 ? -integer : integer;
1354
+ }
1355
+ const value = Number(literal);
1356
+ return String(value) === literal ? value : literal;
1357
+ }
1358
+ /**
1359
+ * Parses a `project.pbxproj` document into JavaScript values.
1360
+ *
1361
+ * Accepts the leading `// !$*UTF8*$!` marker and any other comments as
1362
+ * trivia. Content after the root value is ignored. See the module
1363
+ * documentation of `types.ts` for how source shapes map to JavaScript
1364
+ * values.
1365
+ *
1366
+ * @param text Source text of the document.
1367
+ * @returns The document's root value. For real project files this is the
1368
+ * root dictionary with `objects`, `rootObject`, and version fields.
1369
+ * @throws PbxprojParseError when the document is malformed; the error
1370
+ * carries the line and column of the failure.
1371
+ */
1372
+ function parsePbxproj(text) {
1373
+ return new Parser(text).parseDocument();
1374
+ }
1375
+ //#endregion
1376
+ export { PbxprojBuildError, PbxprojParseError, buildPbxproj, parsePbxproj };