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/LICENSE +201 -0
- package/README.md +124 -0
- package/dist/index.d.ts +155 -0
- package/dist/index.js +1376 -0
- package/package.json +74 -0
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 };
|