@swiftbrowser/xcodeproj 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +89 -2
- package/dist/find.d.ts +9 -0
- package/dist/find.js +43 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +12 -0
- package/dist/pbxproj.d.ts +67 -0
- package/dist/pbxproj.js +287 -0
- package/dist/plist.d.ts +15 -0
- package/dist/plist.js +147 -0
- package/dist/project.d.ts +157 -0
- package/dist/project.js +578 -0
- package/package.json +31 -4
package/README.md
CHANGED
|
@@ -1,3 +1,90 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @swiftbrowser/xcodeproj
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Reads Xcode projects without Xcode: a pure-TypeScript parser for `project.pbxproj`
|
|
4
|
+
(Node built-ins only) that answers the questions a CLI has about an iOS app:
|
|
5
|
+
which targets exist, which Swift files and asset catalogs they compile, and what
|
|
6
|
+
their build settings are.
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import { findXcodeProjects, readXcodeProject } from '@swiftbrowser/xcodeproj';
|
|
10
|
+
|
|
11
|
+
const [xcodeproj] = findXcodeProjects(process.cwd()); // ./*.xcodeproj, else one level down
|
|
12
|
+
const project = readXcodeProject(xcodeproj);
|
|
13
|
+
const [app] = project.appTargets(); // targets whose product is an application
|
|
14
|
+
|
|
15
|
+
app.name; // 'GuessTheFlag'
|
|
16
|
+
app.productName; // PRODUCT_NAME with $(TARGET_NAME) resolved
|
|
17
|
+
app.bundleIdentifier; // PRODUCT_BUNDLE_IDENTIFIER
|
|
18
|
+
app.displayName; // INFOPLIST_KEY_CFBundleDisplayName, else CFBundleDisplayName from INFOPLIST_FILE
|
|
19
|
+
app.appIconName; // ASSETCATALOG_COMPILER_APPICON_NAME
|
|
20
|
+
app.swiftVersion; // SWIFT_VERSION
|
|
21
|
+
app.deploymentTarget; // IPHONEOS_DEPLOYMENT_TARGET
|
|
22
|
+
app.sources; // absolute paths of the .swift files, in navigator order
|
|
23
|
+
app.models; // the Core ML models (.mlmodel, .mlpackage) it compiles
|
|
24
|
+
app.resources; // absolute paths of the Resources build phase entries
|
|
25
|
+
app.assetCatalogs; // the *.xcassets among them (and in synchronized folders)
|
|
26
|
+
app.dependencies; // names of target dependencies
|
|
27
|
+
app.packageProducts; // Swift package products the target links
|
|
28
|
+
app.packageDependencies; // the same, each with its package (URL and version rule, or local path)
|
|
29
|
+
project.packages; // the project's Swift package references
|
|
30
|
+
app.settings('Release'); // merged build settings for a configuration
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## API
|
|
34
|
+
|
|
35
|
+
- `readXcodeProject(xcodeprojPath)` parses `<xcodeprojPath>/project.pbxproj` and
|
|
36
|
+
returns an `XcodeProject`: `path`, `root` (the directory containing the
|
|
37
|
+
bundle, what `$(SRCROOT)` means), `objectVersion`, `targets` (every
|
|
38
|
+
`PBXNativeTarget`), `packages` (its `XCRemoteSwiftPackageReference`s, with
|
|
39
|
+
the dependency rule, and `XCLocalSwiftPackageReference`s, with the absolute
|
|
40
|
+
path; each with SwiftPM's identity, see `packageIdentity`) and `appTargets()`.
|
|
41
|
+
- `parsePbxproj(text)` returns the raw object graph: `{ objectVersion,
|
|
42
|
+
rootObject, objects }`, where each object has an `isa` plus arbitrary fields
|
|
43
|
+
(strings, arrays, nested dictionaries). `parseOldStylePlist(text)` parses any
|
|
44
|
+
old-style property list fragment.
|
|
45
|
+
- `xcodeProjectFromDocument(doc, xcodeprojPath)` interprets an already parsed
|
|
46
|
+
document as the project at that path.
|
|
47
|
+
- `findXcodeProjects(dir)` lists the `*.xcodeproj` bundles directly in `dir`,
|
|
48
|
+
else one level down (sorted), skipping `Pods/`, `Carthage/`, `DerivedData/`,
|
|
49
|
+
`.build/`, `node_modules/` and dot-directories.
|
|
50
|
+
- `parseXmlPlist(text)` is the minimal XML plist reader used for `Info.plist`.
|
|
51
|
+
|
|
52
|
+
## How paths are resolved
|
|
53
|
+
|
|
54
|
+
A file's absolute path is the project root plus the chain of group `path`s
|
|
55
|
+
leading to it, following each object's `sourceTree`: `<group>` is relative to
|
|
56
|
+
the parent group, `SOURCE_ROOT` to the root, `<absolute>` is absolute; others
|
|
57
|
+
(`BUILT_PRODUCTS_DIR`, `SDKROOT`, ...) fall back to the root. Groups without a
|
|
58
|
+
`path` contribute nothing. Build phase files are listed in the order of the
|
|
59
|
+
group tree (what the Xcode navigator shows), not the arbitrary order of the
|
|
60
|
+
build phase. Paths are resolved exactly as written; a project whose group `path`
|
|
61
|
+
differs from the folder on disk only in letter case works on macOS but not on a
|
|
62
|
+
case-sensitive filesystem.
|
|
63
|
+
|
|
64
|
+
Xcode 16 "synchronized folders" (`PBXFileSystemSynchronizedRootGroup`
|
|
65
|
+
referenced from a target's `fileSystemSynchronizedGroups`) are read from disk:
|
|
66
|
+
every `*.swift` under the folder (recursive, sorted) is a source of the target
|
|
67
|
+
and every `*.xcassets` an asset catalog, minus the paths listed in the target's
|
|
68
|
+
`PBXFileSystemSynchronizedBuildFileExceptionSet.membershipExceptions`. A folder
|
|
69
|
+
missing on disk contributes nothing.
|
|
70
|
+
|
|
71
|
+
## Build settings
|
|
72
|
+
|
|
73
|
+
`target.settings(configuration?)` merges the project-level `buildSettings` of
|
|
74
|
+
the named `XCBuildConfiguration` with the target-level ones on top. The default
|
|
75
|
+
configuration is `Debug` when the target has one, else the configuration list's
|
|
76
|
+
`defaultConfigurationName`. Array-valued settings are space-joined.
|
|
77
|
+
`$(TARGET_NAME)`, `$(PRODUCT_NAME)`, `$(SRCROOT)` and `$(PROJECT_DIR)` (also in
|
|
78
|
+
`${...}` form) are expanded; every other `$(...)` reference, including
|
|
79
|
+
`$(inherited)` and modifiers such as `$(X:rfc1034identifier)`, is left as is.
|
|
80
|
+
|
|
81
|
+
## Not handled
|
|
82
|
+
|
|
83
|
+
- `.xcconfig` files referenced by `baseConfigurationReference` are not read, so
|
|
84
|
+
settings defined only there are absent.
|
|
85
|
+
- Binary `Info.plist` files are skipped (only XML plists are read).
|
|
86
|
+
- `PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet` (Xcode 16
|
|
87
|
+
per-build-phase exceptions) and `PBXBuildRule`s are ignored.
|
|
88
|
+
- Workspaces (`.xcworkspace`), referenced projects (`PBXReferenceProxy`) and
|
|
89
|
+
`PBXAggregateTarget`/`PBXLegacyTarget` are not modelled; only
|
|
90
|
+
`PBXNativeTarget`s appear in `targets`.
|
package/dist/find.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locating `.xcodeproj` bundles on disk, for a CLI run from a project folder.
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* The `.xcodeproj` bundles directly in `dir`, as absolute paths (sorted). When
|
|
6
|
+
* there are none, looks one level down, skipping `Pods/`, `Carthage/`,
|
|
7
|
+
* `DerivedData/`, `.build/`, `node_modules/` and dot-directories.
|
|
8
|
+
*/
|
|
9
|
+
export declare function findXcodeProjects(dir: string): string[];
|
package/dist/find.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locating `.xcodeproj` bundles on disk, for a CLI run from a project folder.
|
|
3
|
+
*/
|
|
4
|
+
import * as fs from 'node:fs';
|
|
5
|
+
import * as path from 'node:path';
|
|
6
|
+
/** Directories whose `.xcodeproj`s are generated or third party, never the user's app. */
|
|
7
|
+
const IGNORED_DIRECTORIES = new Set(['Pods', 'Carthage', 'DerivedData', '.build', 'node_modules']);
|
|
8
|
+
/**
|
|
9
|
+
* The `.xcodeproj` bundles directly in `dir`, as absolute paths (sorted). When
|
|
10
|
+
* there are none, looks one level down, skipping `Pods/`, `Carthage/`,
|
|
11
|
+
* `DerivedData/`, `.build/`, `node_modules/` and dot-directories.
|
|
12
|
+
*/
|
|
13
|
+
export function findXcodeProjects(dir) {
|
|
14
|
+
const absolute = path.resolve(dir);
|
|
15
|
+
const direct = xcodeProjectsIn(absolute);
|
|
16
|
+
if (direct.length > 0)
|
|
17
|
+
return direct;
|
|
18
|
+
const nested = [];
|
|
19
|
+
for (const entry of readDirectories(absolute)) {
|
|
20
|
+
if (IGNORED_DIRECTORIES.has(entry) || entry.startsWith('.'))
|
|
21
|
+
continue;
|
|
22
|
+
nested.push(...xcodeProjectsIn(path.join(absolute, entry)));
|
|
23
|
+
}
|
|
24
|
+
return nested.sort();
|
|
25
|
+
}
|
|
26
|
+
function xcodeProjectsIn(dir) {
|
|
27
|
+
return readDirectories(dir)
|
|
28
|
+
.filter((name) => name.endsWith('.xcodeproj'))
|
|
29
|
+
.sort()
|
|
30
|
+
.map((name) => path.join(dir, name));
|
|
31
|
+
}
|
|
32
|
+
/** Names of the subdirectories of `dir` (empty when it cannot be read). */
|
|
33
|
+
function readDirectories(dir) {
|
|
34
|
+
try {
|
|
35
|
+
return fs
|
|
36
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
37
|
+
.filter((entry) => entry.isDirectory())
|
|
38
|
+
.map((entry) => entry.name);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @swiftbrowser/xcodeproj: reads Xcode projects without Xcode.
|
|
3
|
+
*
|
|
4
|
+
* - `readXcodeProject(path)` interprets `<path>/project.pbxproj` as targets
|
|
5
|
+
* with resolved sources, asset catalogs and build settings.
|
|
6
|
+
* - `parsePbxproj(text)` gives the raw object graph.
|
|
7
|
+
* - `findXcodeProjects(dir)` locates `.xcodeproj` bundles.
|
|
8
|
+
*/
|
|
9
|
+
export { parsePbxproj, parseOldStylePlist, tokenizePbxproj, PbxprojSyntaxError, type PbxValue, type PbxDict, type PbxObject, type PbxprojDocument, } from './pbxproj.js';
|
|
10
|
+
export { readXcodeProject, xcodeProjectFromDocument, expandVariables, packageIdentity, APPLICATION_PRODUCT_TYPE, type XcodeProject, type XcodeTarget, type SwiftPackageReference, type SwiftPackageRequirement, type SwiftPackageProductDependency, } from './project.js';
|
|
11
|
+
export { findXcodeProjects } from './find.js';
|
|
12
|
+
export { parseXmlPlist, type PlistValue, type PlistDict } from './plist.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @swiftbrowser/xcodeproj: reads Xcode projects without Xcode.
|
|
3
|
+
*
|
|
4
|
+
* - `readXcodeProject(path)` interprets `<path>/project.pbxproj` as targets
|
|
5
|
+
* with resolved sources, asset catalogs and build settings.
|
|
6
|
+
* - `parsePbxproj(text)` gives the raw object graph.
|
|
7
|
+
* - `findXcodeProjects(dir)` locates `.xcodeproj` bundles.
|
|
8
|
+
*/
|
|
9
|
+
export { parsePbxproj, parseOldStylePlist, tokenizePbxproj, PbxprojSyntaxError, } from './pbxproj.js';
|
|
10
|
+
export { readXcodeProject, xcodeProjectFromDocument, expandVariables, packageIdentity, APPLICATION_PRODUCT_TYPE, } from './project.js';
|
|
11
|
+
export { findXcodeProjects } from './find.js';
|
|
12
|
+
export { parseXmlPlist } from './plist.js';
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parser for `project.pbxproj`, the file inside every `.xcodeproj` bundle.
|
|
3
|
+
*
|
|
4
|
+
* The format is the old-style ASCII (NeXTSTEP) property list:
|
|
5
|
+
*
|
|
6
|
+
* // !$*UTF8*$!
|
|
7
|
+
* {
|
|
8
|
+
* archiveVersion = 1;
|
|
9
|
+
* objectVersion = 56;
|
|
10
|
+
* objects = {
|
|
11
|
+
* 518D77712AD7303000E0BF20 (comment: ContentView.swift) = {isa = PBXFileReference; path = ContentView.swift; sourceTree = "<group>"; };
|
|
12
|
+
* ...
|
|
13
|
+
* };
|
|
14
|
+
* rootObject = 518D77662AD7303000E0BF20 (comment: Project object);
|
|
15
|
+
* }
|
|
16
|
+
*
|
|
17
|
+
* Dictionaries are `{ key = value; }`, arrays are `( a, b, )`, and every scalar is
|
|
18
|
+
* a string: either an unquoted word (letters, digits, `_ . / $ - + :` ...) or a
|
|
19
|
+
* double-quoted string with backslash escapes. Block comments (slash-star) and
|
|
20
|
+
* line comments (double slash) may appear anywhere; Xcode puts a block comment
|
|
21
|
+
* with the object's name after every object id, as sketched above. The parser
|
|
22
|
+
* is tolerant of trailing commas and of a missing `;` before `}`.
|
|
23
|
+
*
|
|
24
|
+
* This module only builds the raw object graph; `project.ts` interprets it.
|
|
25
|
+
*/
|
|
26
|
+
/** A pbxproj value: a string, an array of values or a dictionary of values. */
|
|
27
|
+
export type PbxValue = string | PbxValue[] | PbxDict;
|
|
28
|
+
export interface PbxDict {
|
|
29
|
+
[key: string]: PbxValue;
|
|
30
|
+
}
|
|
31
|
+
/** One entry of `objects`: every object has an `isa` class name plus arbitrary fields. */
|
|
32
|
+
export interface PbxObject extends PbxDict {
|
|
33
|
+
isa: string;
|
|
34
|
+
}
|
|
35
|
+
/** The parsed file. `objects` is keyed by object id (usually 24 hex digits). */
|
|
36
|
+
export interface PbxprojDocument {
|
|
37
|
+
archiveVersion: number;
|
|
38
|
+
objectVersion: number;
|
|
39
|
+
rootObject: string;
|
|
40
|
+
objects: Record<string, PbxObject>;
|
|
41
|
+
}
|
|
42
|
+
/** Thrown for malformed input; `offset` is the character index where parsing failed. */
|
|
43
|
+
export declare class PbxprojSyntaxError extends Error {
|
|
44
|
+
readonly offset: number;
|
|
45
|
+
readonly line: number;
|
|
46
|
+
readonly column: number;
|
|
47
|
+
constructor(message: string, offset: number, line: number, column: number);
|
|
48
|
+
}
|
|
49
|
+
type TokenKind = '{' | '}' | '(' | ')' | '=' | ';' | ',' | 'string' | 'eof';
|
|
50
|
+
interface Token {
|
|
51
|
+
kind: TokenKind;
|
|
52
|
+
/** The string value (unescaped) for `string` tokens; the punctuation otherwise. */
|
|
53
|
+
value: string;
|
|
54
|
+
/** Character offset of the token's first character, for error messages. */
|
|
55
|
+
offset: number;
|
|
56
|
+
}
|
|
57
|
+
/** Breaks the text into tokens, dropping comments. */
|
|
58
|
+
export declare function tokenizePbxproj(text: string): Token[];
|
|
59
|
+
/**
|
|
60
|
+
* Parses any old-style property list text into its value tree. `parsePbxproj`
|
|
61
|
+
* is the usual entry point; this one is handy for fragments and tests.
|
|
62
|
+
*/
|
|
63
|
+
export declare function parseOldStylePlist(text: string): PbxValue;
|
|
64
|
+
/** Parses the text of a `project.pbxproj` file into its raw object graph. */
|
|
65
|
+
export declare function parsePbxproj(text: string): PbxprojDocument;
|
|
66
|
+
export declare function isDict(value: PbxValue | undefined): value is PbxDict;
|
|
67
|
+
export {};
|
package/dist/pbxproj.js
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parser for `project.pbxproj`, the file inside every `.xcodeproj` bundle.
|
|
3
|
+
*
|
|
4
|
+
* The format is the old-style ASCII (NeXTSTEP) property list:
|
|
5
|
+
*
|
|
6
|
+
* // !$*UTF8*$!
|
|
7
|
+
* {
|
|
8
|
+
* archiveVersion = 1;
|
|
9
|
+
* objectVersion = 56;
|
|
10
|
+
* objects = {
|
|
11
|
+
* 518D77712AD7303000E0BF20 (comment: ContentView.swift) = {isa = PBXFileReference; path = ContentView.swift; sourceTree = "<group>"; };
|
|
12
|
+
* ...
|
|
13
|
+
* };
|
|
14
|
+
* rootObject = 518D77662AD7303000E0BF20 (comment: Project object);
|
|
15
|
+
* }
|
|
16
|
+
*
|
|
17
|
+
* Dictionaries are `{ key = value; }`, arrays are `( a, b, )`, and every scalar is
|
|
18
|
+
* a string: either an unquoted word (letters, digits, `_ . / $ - + :` ...) or a
|
|
19
|
+
* double-quoted string with backslash escapes. Block comments (slash-star) and
|
|
20
|
+
* line comments (double slash) may appear anywhere; Xcode puts a block comment
|
|
21
|
+
* with the object's name after every object id, as sketched above. The parser
|
|
22
|
+
* is tolerant of trailing commas and of a missing `;` before `}`.
|
|
23
|
+
*
|
|
24
|
+
* This module only builds the raw object graph; `project.ts` interprets it.
|
|
25
|
+
*/
|
|
26
|
+
/** Thrown for malformed input; `offset` is the character index where parsing failed. */
|
|
27
|
+
export class PbxprojSyntaxError extends Error {
|
|
28
|
+
offset;
|
|
29
|
+
line;
|
|
30
|
+
column;
|
|
31
|
+
constructor(message, offset, line, column) {
|
|
32
|
+
super(`${message} (line ${line}, column ${column})`);
|
|
33
|
+
this.offset = offset;
|
|
34
|
+
this.line = line;
|
|
35
|
+
this.column = column;
|
|
36
|
+
this.name = 'PbxprojSyntaxError';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const PUNCTUATION = new Set(['{', '}', '(', ')', '=', ';', ',']);
|
|
40
|
+
/** Characters that end an unquoted word (besides whitespace and comment starts). */
|
|
41
|
+
const WORD_TERMINATORS = new Set([...PUNCTUATION, '"']);
|
|
42
|
+
function isWhitespace(ch) {
|
|
43
|
+
return ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r' || ch === '\f' || ch === '\v';
|
|
44
|
+
}
|
|
45
|
+
/** Escape sequences recognised inside quoted strings (besides `\\uXXXX`, octal and the quote/backslash themselves). */
|
|
46
|
+
const SIMPLE_ESCAPES = {
|
|
47
|
+
n: '\n',
|
|
48
|
+
t: '\t',
|
|
49
|
+
r: '\r',
|
|
50
|
+
a: '\x07',
|
|
51
|
+
b: '\b',
|
|
52
|
+
f: '\f',
|
|
53
|
+
v: '\v',
|
|
54
|
+
'"': '"',
|
|
55
|
+
"'": "'",
|
|
56
|
+
'\\': '\\',
|
|
57
|
+
};
|
|
58
|
+
/** Breaks the text into tokens, dropping comments. */
|
|
59
|
+
export function tokenizePbxproj(text) {
|
|
60
|
+
const tokens = [];
|
|
61
|
+
const length = text.length;
|
|
62
|
+
let i = 0;
|
|
63
|
+
// A function declaration (not an arrow) so TypeScript treats calls as assertions.
|
|
64
|
+
function fail(message, offset) {
|
|
65
|
+
throw syntaxError(text, message, offset);
|
|
66
|
+
}
|
|
67
|
+
while (i < length) {
|
|
68
|
+
const ch = text[i];
|
|
69
|
+
if (isWhitespace(ch)) {
|
|
70
|
+
i++;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
// Comments: `/* ... */` and `// ...` to end of line.
|
|
74
|
+
if (ch === '/' && text[i + 1] === '*') {
|
|
75
|
+
const end = text.indexOf('*/', i + 2);
|
|
76
|
+
if (end < 0)
|
|
77
|
+
fail('Unterminated block comment', i);
|
|
78
|
+
i = end + 2;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (ch === '/' && text[i + 1] === '/') {
|
|
82
|
+
const end = text.indexOf('\n', i + 2);
|
|
83
|
+
i = end < 0 ? length : end + 1;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (PUNCTUATION.has(ch)) {
|
|
87
|
+
tokens.push({ kind: ch, value: ch, offset: i });
|
|
88
|
+
i++;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (ch === '"') {
|
|
92
|
+
const start = i;
|
|
93
|
+
i++;
|
|
94
|
+
let value = '';
|
|
95
|
+
for (;;) {
|
|
96
|
+
if (i >= length)
|
|
97
|
+
fail('Unterminated string', start);
|
|
98
|
+
const c = text[i];
|
|
99
|
+
if (c === '"') {
|
|
100
|
+
i++;
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
if (c !== '\\') {
|
|
104
|
+
value += c;
|
|
105
|
+
i++;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
// Backslash escape.
|
|
109
|
+
const e = text[i + 1];
|
|
110
|
+
if (e === undefined)
|
|
111
|
+
fail('Unterminated string', start);
|
|
112
|
+
if (e === 'U' || e === 'u') {
|
|
113
|
+
// `\Uxxxx`: up to four hex digits.
|
|
114
|
+
const hex = /^[0-9a-fA-F]{1,4}/.exec(text.slice(i + 2, i + 6));
|
|
115
|
+
if (!hex)
|
|
116
|
+
fail('Invalid \\U escape', i);
|
|
117
|
+
value += String.fromCharCode(parseInt(hex[0], 16));
|
|
118
|
+
i += 2 + hex[0].length;
|
|
119
|
+
}
|
|
120
|
+
else if (e >= '0' && e <= '7') {
|
|
121
|
+
// `\ooo`: up to three octal digits.
|
|
122
|
+
const oct = /^[0-7]{1,3}/.exec(text.slice(i + 1, i + 4));
|
|
123
|
+
value += String.fromCharCode(parseInt(oct[0], 8));
|
|
124
|
+
i += 1 + oct[0].length;
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
// Unknown escapes keep the character (`\x` -> `x`), like Foundation does.
|
|
128
|
+
value += SIMPLE_ESCAPES[e] ?? e;
|
|
129
|
+
i += 2;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
tokens.push({ kind: 'string', value, offset: start });
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
// Unquoted word: runs until whitespace, punctuation, a quote or a comment start.
|
|
136
|
+
const start = i;
|
|
137
|
+
while (i < length) {
|
|
138
|
+
const c = text[i];
|
|
139
|
+
if (isWhitespace(c) || WORD_TERMINATORS.has(c))
|
|
140
|
+
break;
|
|
141
|
+
if (c === '/' && (text[i + 1] === '*' || text[i + 1] === '/'))
|
|
142
|
+
break;
|
|
143
|
+
i++;
|
|
144
|
+
}
|
|
145
|
+
if (i === start)
|
|
146
|
+
fail(`Unexpected character ${JSON.stringify(ch)}`, i);
|
|
147
|
+
tokens.push({ kind: 'string', value: text.slice(start, i), offset: start });
|
|
148
|
+
}
|
|
149
|
+
tokens.push({ kind: 'eof', value: '', offset: length });
|
|
150
|
+
return tokens;
|
|
151
|
+
}
|
|
152
|
+
function syntaxError(text, message, offset) {
|
|
153
|
+
let line = 1;
|
|
154
|
+
let lineStart = 0;
|
|
155
|
+
for (let i = 0; i < offset && i < text.length; i++) {
|
|
156
|
+
if (text[i] === '\n') {
|
|
157
|
+
line++;
|
|
158
|
+
lineStart = i + 1;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return new PbxprojSyntaxError(message, offset, line, offset - lineStart + 1);
|
|
162
|
+
}
|
|
163
|
+
// ----- Parser --------------------------------------------------------------
|
|
164
|
+
class Parser {
|
|
165
|
+
text;
|
|
166
|
+
tokens;
|
|
167
|
+
pos = 0;
|
|
168
|
+
constructor(text, tokens) {
|
|
169
|
+
this.text = text;
|
|
170
|
+
this.tokens = tokens;
|
|
171
|
+
}
|
|
172
|
+
peek() {
|
|
173
|
+
return this.tokens[this.pos];
|
|
174
|
+
}
|
|
175
|
+
next() {
|
|
176
|
+
const token = this.tokens[this.pos];
|
|
177
|
+
if (token.kind !== 'eof')
|
|
178
|
+
this.pos++;
|
|
179
|
+
return token;
|
|
180
|
+
}
|
|
181
|
+
fail(message, token = this.peek()) {
|
|
182
|
+
const found = token.kind === 'eof' ? 'end of input' : JSON.stringify(token.value);
|
|
183
|
+
throw syntaxError(this.text, `${message}, found ${found}`, token.offset);
|
|
184
|
+
}
|
|
185
|
+
expect(kind) {
|
|
186
|
+
const token = this.peek();
|
|
187
|
+
if (token.kind !== kind)
|
|
188
|
+
this.fail(`Expected ${kind}`);
|
|
189
|
+
return this.next();
|
|
190
|
+
}
|
|
191
|
+
/** Parses the whole document: one top-level value followed by end of input. */
|
|
192
|
+
parseDocument() {
|
|
193
|
+
const value = this.parseValue();
|
|
194
|
+
if (this.peek().kind !== 'eof')
|
|
195
|
+
this.fail('Expected end of input');
|
|
196
|
+
return value;
|
|
197
|
+
}
|
|
198
|
+
parseValue() {
|
|
199
|
+
const token = this.peek();
|
|
200
|
+
switch (token.kind) {
|
|
201
|
+
case '{':
|
|
202
|
+
return this.parseDict();
|
|
203
|
+
case '(':
|
|
204
|
+
return this.parseArray();
|
|
205
|
+
case 'string':
|
|
206
|
+
return this.next().value;
|
|
207
|
+
default:
|
|
208
|
+
return this.fail('Expected a value');
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
parseDict() {
|
|
212
|
+
this.expect('{');
|
|
213
|
+
const dict = {};
|
|
214
|
+
while (this.peek().kind !== '}') {
|
|
215
|
+
if (this.peek().kind === 'eof')
|
|
216
|
+
this.fail('Unterminated dictionary');
|
|
217
|
+
const key = this.expect('string').value;
|
|
218
|
+
this.expect('=');
|
|
219
|
+
dict[key] = this.parseValue();
|
|
220
|
+
// The `;` after an entry is required by Xcode but we accept a missing one before `}`.
|
|
221
|
+
if (this.peek().kind === ';')
|
|
222
|
+
this.next();
|
|
223
|
+
else if (this.peek().kind === 'eof')
|
|
224
|
+
this.fail('Unterminated dictionary');
|
|
225
|
+
else if (this.peek().kind !== '}')
|
|
226
|
+
this.fail('Expected ; after dictionary entry');
|
|
227
|
+
}
|
|
228
|
+
this.expect('}');
|
|
229
|
+
return dict;
|
|
230
|
+
}
|
|
231
|
+
parseArray() {
|
|
232
|
+
this.expect('(');
|
|
233
|
+
const items = [];
|
|
234
|
+
while (this.peek().kind !== ')') {
|
|
235
|
+
if (this.peek().kind === 'eof')
|
|
236
|
+
this.fail('Unterminated array');
|
|
237
|
+
items.push(this.parseValue());
|
|
238
|
+
// Commas separate items; a trailing comma is the norm. A missing comma is
|
|
239
|
+
// tolerated since values are self-delimiting.
|
|
240
|
+
if (this.peek().kind === ',')
|
|
241
|
+
this.next();
|
|
242
|
+
}
|
|
243
|
+
this.expect(')');
|
|
244
|
+
return items;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Parses any old-style property list text into its value tree. `parsePbxproj`
|
|
249
|
+
* is the usual entry point; this one is handy for fragments and tests.
|
|
250
|
+
*/
|
|
251
|
+
export function parseOldStylePlist(text) {
|
|
252
|
+
return new Parser(text, tokenizePbxproj(text)).parseDocument();
|
|
253
|
+
}
|
|
254
|
+
/** Parses the text of a `project.pbxproj` file into its raw object graph. */
|
|
255
|
+
export function parsePbxproj(text) {
|
|
256
|
+
const value = parseOldStylePlist(text);
|
|
257
|
+
if (!isDict(value))
|
|
258
|
+
throw new Error('project.pbxproj: top-level value is not a dictionary');
|
|
259
|
+
const objectsValue = value['objects'];
|
|
260
|
+
if (!isDict(objectsValue))
|
|
261
|
+
throw new Error('project.pbxproj: missing `objects` dictionary');
|
|
262
|
+
const objects = {};
|
|
263
|
+
for (const [id, object] of Object.entries(objectsValue)) {
|
|
264
|
+
if (!isDict(object) || typeof object['isa'] !== 'string') {
|
|
265
|
+
throw new Error(`project.pbxproj: object ${id} has no isa`);
|
|
266
|
+
}
|
|
267
|
+
objects[id] = object;
|
|
268
|
+
}
|
|
269
|
+
const rootObject = value['rootObject'];
|
|
270
|
+
if (typeof rootObject !== 'string')
|
|
271
|
+
throw new Error('project.pbxproj: missing `rootObject`');
|
|
272
|
+
return {
|
|
273
|
+
archiveVersion: toInteger(value['archiveVersion'], 1),
|
|
274
|
+
objectVersion: toInteger(value['objectVersion'], 0),
|
|
275
|
+
rootObject,
|
|
276
|
+
objects,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
export function isDict(value) {
|
|
280
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
281
|
+
}
|
|
282
|
+
function toInteger(value, fallback) {
|
|
283
|
+
if (typeof value !== 'string')
|
|
284
|
+
return fallback;
|
|
285
|
+
const n = Number.parseInt(value, 10);
|
|
286
|
+
return Number.isNaN(n) ? fallback : n;
|
|
287
|
+
}
|
package/dist/plist.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal reader for XML property lists (`Info.plist`).
|
|
3
|
+
*
|
|
4
|
+
* Only what the project reader needs: the top-level `<dict>` with string,
|
|
5
|
+
* integer, real, boolean, date, data, array and nested dict values. Binary
|
|
6
|
+
* plists (`bplist00`) and the old-style ASCII format are not handled here;
|
|
7
|
+
* `parseXmlPlist` returns `undefined` for anything it cannot read, and the
|
|
8
|
+
* caller treats that as "no Info.plist values available".
|
|
9
|
+
*/
|
|
10
|
+
export type PlistValue = string | number | boolean | PlistValue[] | PlistDict;
|
|
11
|
+
export interface PlistDict {
|
|
12
|
+
[key: string]: PlistValue;
|
|
13
|
+
}
|
|
14
|
+
/** Parses an XML plist. Returns `undefined` when the text is not an XML plist. */
|
|
15
|
+
export declare function parseXmlPlist(text: string): PlistValue | undefined;
|