@swiftbrowser/xcodeproj 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,10 +20,13 @@ app.appIconName; // ASSETCATALOG_COMPILER_APPICON_NAME
20
20
  app.swiftVersion; // SWIFT_VERSION
21
21
  app.deploymentTarget; // IPHONEOS_DEPLOYMENT_TARGET
22
22
  app.sources; // absolute paths of the .swift files, in navigator order
23
+ app.models; // the Core ML models (.mlmodel, .mlpackage) it compiles
23
24
  app.resources; // absolute paths of the Resources build phase entries
24
25
  app.assetCatalogs; // the *.xcassets among them (and in synchronized folders)
25
26
  app.dependencies; // names of target dependencies
26
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
27
30
  app.settings('Release'); // merged build settings for a configuration
28
31
  ```
29
32
 
@@ -32,7 +35,9 @@ app.settings('Release'); // merged build settings for a configuration
32
35
  - `readXcodeProject(xcodeprojPath)` parses `<xcodeprojPath>/project.pbxproj` and
33
36
  returns an `XcodeProject`: `path`, `root` (the directory containing the
34
37
  bundle, what `$(SRCROOT)` means), `objectVersion`, `targets` (every
35
- `PBXNativeTarget`) and `appTargets()`.
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()`.
36
41
  - `parsePbxproj(text)` returns the raw object graph: `{ objectVersion,
37
42
  rootObject, objects }`, where each object has an `isa` plus arbitrary fields
38
43
  (strings, arrays, nested dictionaries). `parseOldStylePlist(text)` parses any
package/dist/index.d.ts CHANGED
@@ -7,6 +7,6 @@
7
7
  * - `findXcodeProjects(dir)` locates `.xcodeproj` bundles.
8
8
  */
9
9
  export { parsePbxproj, parseOldStylePlist, tokenizePbxproj, PbxprojSyntaxError, type PbxValue, type PbxDict, type PbxObject, type PbxprojDocument, } from './pbxproj.js';
10
- export { readXcodeProject, xcodeProjectFromDocument, expandVariables, APPLICATION_PRODUCT_TYPE, type XcodeProject, type XcodeTarget, } from './project.js';
10
+ export { readXcodeProject, xcodeProjectFromDocument, expandVariables, packageIdentity, APPLICATION_PRODUCT_TYPE, type XcodeProject, type XcodeTarget, type SwiftPackageReference, type SwiftPackageRequirement, type SwiftPackageProductDependency, } from './project.js';
11
11
  export { findXcodeProjects } from './find.js';
12
12
  export { parseXmlPlist, type PlistValue, type PlistDict } from './plist.js';
package/dist/index.js CHANGED
@@ -7,6 +7,6 @@
7
7
  * - `findXcodeProjects(dir)` locates `.xcodeproj` bundles.
8
8
  */
9
9
  export { parsePbxproj, parseOldStylePlist, tokenizePbxproj, PbxprojSyntaxError, } from './pbxproj.js';
10
- export { readXcodeProject, xcodeProjectFromDocument, expandVariables, APPLICATION_PRODUCT_TYPE, } from './project.js';
10
+ export { readXcodeProject, xcodeProjectFromDocument, expandVariables, packageIdentity, APPLICATION_PRODUCT_TYPE, } from './project.js';
11
11
  export { findXcodeProjects } from './find.js';
12
12
  export { parseXmlPlist } from './plist.js';
package/dist/project.d.ts CHANGED
@@ -21,6 +21,12 @@ export interface XcodeProject {
21
21
  /** Directory containing the `.xcodeproj`: the source root, what `$(SRCROOT)` means. */
22
22
  root: string;
23
23
  objectVersion: number;
24
+ /**
25
+ * The Swift packages the project references (`PBXProject.packageReferences`):
26
+ * remote ones (`XCRemoteSwiftPackageReference`, with Xcode's dependency rule)
27
+ * and local ones (`XCLocalSwiftPackageReference`), in project order.
28
+ */
29
+ packages: SwiftPackageReference[];
24
30
  /** Every `PBXNativeTarget`, in project order. */
25
31
  targets: XcodeTarget[];
26
32
  /** The targets whose product is an application (`com.apple.product-type.application`). */
@@ -51,14 +57,33 @@ export interface XcodeTarget {
51
57
  * folders (sorted).
52
58
  */
53
59
  sources: string[];
60
+ /**
61
+ * Absolute paths of the Core ML models the target compiles (`.mlmodel`
62
+ * files, `.mlpackage` bundles): the Sources build phase in project order,
63
+ * then those in synchronized folders. Xcode generates a Swift class for each.
64
+ */
65
+ models: string[];
54
66
  /** Absolute paths of the files and folders in the Resources build phase, in project (navigator) order. */
55
67
  resources: string[];
56
68
  /** Absolute paths of the `*.xcassets` among the resources and in synchronized folders. */
57
69
  assetCatalogs: string[];
70
+ /**
71
+ * Absolute paths of the target's localized string sources: string catalogs
72
+ * (`*.xcstrings`) and `.strings` / `.stringsdict` files (in `<language>.lproj`
73
+ * folders), among the resources and in synchronized folders. Xcode compiles
74
+ * them into the app's string tables.
75
+ */
76
+ localizations: string[];
58
77
  /** Names of the targets this one depends on (`PBXTargetDependency`). */
59
78
  dependencies: string[];
60
79
  /** Swift package products the target links (`XCSwiftPackageProductDependency.productName`). */
61
80
  packageProducts: string[];
81
+ /**
82
+ * The same products with the package each comes from, when the project
83
+ * records it (`XCSwiftPackageProductDependency.package`; a local package
84
+ * added as a folder reference by older Xcodes has none).
85
+ */
86
+ packageDependencies: SwiftPackageProductDependency[];
62
87
  /**
63
88
  * Merged build settings for a configuration: project-level settings first,
64
89
  * target-level settings on top. `configuration` defaults to the target's
@@ -70,6 +95,54 @@ export interface XcodeTarget {
70
95
  */
71
96
  settings(configuration?: string): Record<string, string>;
72
97
  }
98
+ /**
99
+ * The version rule of a remote package, as Xcode's "Dependency Rule" stores
100
+ * it in `XCRemoteSwiftPackageReference.requirement`.
101
+ */
102
+ export type SwiftPackageRequirement = {
103
+ kind: 'upToNextMajorVersion';
104
+ minimumVersion: string;
105
+ } | {
106
+ kind: 'upToNextMinorVersion';
107
+ minimumVersion: string;
108
+ } | {
109
+ kind: 'exactVersion';
110
+ version: string;
111
+ } | {
112
+ kind: 'versionRange';
113
+ minimumVersion: string;
114
+ maximumVersion: string;
115
+ } | {
116
+ kind: 'branch';
117
+ branch: string;
118
+ } | {
119
+ kind: 'revision';
120
+ revision: string;
121
+ };
122
+ /**
123
+ * A Swift package the project references. `identity` is SwiftPM's name for
124
+ * it: the last path component of the URL (or folder), without `.git`,
125
+ * lower-cased; it is what `.product(name:package:)` names.
126
+ */
127
+ export type SwiftPackageReference = {
128
+ kind: 'remote';
129
+ url: string;
130
+ identity: string;
131
+ /** Absent when the project's rule has a kind this reader does not know. */
132
+ requirement?: SwiftPackageRequirement;
133
+ } | {
134
+ kind: 'local';
135
+ /** Absolute path of the package folder (`relativePath` from the source root). */
136
+ path: string;
137
+ identity: string;
138
+ };
139
+ /** A package product a target links, with its package when the project says. */
140
+ export interface SwiftPackageProductDependency {
141
+ product: string;
142
+ package?: SwiftPackageReference;
143
+ }
144
+ /** SwiftPM's identity of a package at a URL or path: its last component, without `.git`, lower-cased. */
145
+ export declare function packageIdentity(location: string): string;
73
146
  /** Reads `<xcodeprojPath>/project.pbxproj` and interprets it. */
74
147
  export declare function readXcodeProject(xcodeprojPath: string): XcodeProject;
75
148
  /**
package/dist/project.js CHANGED
@@ -18,6 +18,11 @@ import * as path from 'node:path';
18
18
  import { isDict, parsePbxproj } from './pbxproj.js';
19
19
  import { parseXmlPlist } from './plist.js';
20
20
  export const APPLICATION_PRODUCT_TYPE = 'com.apple.product-type.application';
21
+ /** SwiftPM's identity of a package at a URL or path: its last component, without `.git`, lower-cased. */
22
+ export function packageIdentity(location) {
23
+ const last = location.replace(/[/\\]+$/, '').split(/[/\\:]/).pop() ?? location;
24
+ return last.replace(/\.git$/i, '').toLowerCase();
25
+ }
21
26
  /** Reads `<xcodeprojPath>/project.pbxproj` and interprets it. */
22
27
  export function readXcodeProject(xcodeprojPath) {
23
28
  const absolute = path.resolve(xcodeprojPath);
@@ -106,10 +111,14 @@ class ProjectReader {
106
111
  if (target?.isa === 'PBXNativeTarget')
107
112
  targets.push(this.readTarget(id, target));
108
113
  }
114
+ const packages = strings(this.project['packageReferences'])
115
+ .map((id) => this.packageReference(id))
116
+ .filter((reference) => reference !== undefined);
109
117
  return {
110
118
  path: this.xcodeprojPath,
111
119
  root: this.root,
112
120
  objectVersion: this.doc.objectVersion,
121
+ packages,
113
122
  targets,
114
123
  appTargets: () => targets.filter((target) => target.productType === APPLICATION_PRODUCT_TYPE),
115
124
  };
@@ -188,13 +197,16 @@ class ProjectReader {
188
197
  }
189
198
  // --- Synchronized folders (Xcode 16) ---
190
199
  /**
191
- * The `.swift` files and `.xcassets` folders under the target's synchronized
192
- * folders, read from disk (sorted, recursive), minus the target's membership
193
- * exceptions. A folder missing on disk contributes nothing.
200
+ * The `.swift` files, Core ML models and `.xcassets` folders under the
201
+ * target's synchronized folders, read from disk (sorted, recursive), minus
202
+ * the target's membership exceptions. A folder missing on disk contributes
203
+ * nothing.
194
204
  */
195
205
  synchronizedMembers(targetId, target) {
196
206
  const sources = [];
207
+ const models = [];
197
208
  const assetCatalogs = [];
209
+ const localizations = [];
198
210
  for (const groupId of strings(target['fileSystemSynchronizedGroups'])) {
199
211
  const group = this.objects[groupId];
200
212
  if (!group || !SYNCHRONIZED_GROUP_ISAS.has(group.isa))
@@ -217,12 +229,16 @@ class ProjectReader {
217
229
  return false;
218
230
  if (entry.endsWith('.swift'))
219
231
  sources.push(entry);
232
+ else if (isCoreMLModel(entry))
233
+ models.push(entry);
220
234
  else if (entry.endsWith('.xcassets'))
221
235
  assetCatalogs.push(entry);
236
+ else if (isLocalization(entry))
237
+ localizations.push(entry);
222
238
  return true;
223
239
  });
224
240
  }
225
- return { sources, assetCatalogs };
241
+ return { sources, models, assetCatalogs, localizations };
226
242
  }
227
243
  // --- Build settings ---
228
244
  configurations(listId) {
@@ -295,7 +311,8 @@ class ProjectReader {
295
311
  return result;
296
312
  };
297
313
  const defaults = settings();
298
- const phaseSources = this.buildPhaseFiles(target, 'PBXSourcesBuildPhase').filter((p) => p.endsWith('.swift'));
314
+ const phaseFiles = this.buildPhaseFiles(target, 'PBXSourcesBuildPhase');
315
+ const phaseSources = phaseFiles.filter((p) => p.endsWith('.swift'));
299
316
  const resources = this.buildPhaseFiles(target, 'PBXResourcesBuildPhase');
300
317
  const synchronized = this.synchronizedMembers(targetId, target);
301
318
  const productName = defaults['PRODUCT_NAME'] || str(target['productName']) || name;
@@ -304,10 +321,13 @@ class ProjectReader {
304
321
  productType: str(target['productType']) ?? '',
305
322
  productName,
306
323
  sources: [...phaseSources, ...synchronized.sources],
324
+ models: [...phaseFiles.filter(isCoreMLModel), ...synchronized.models],
307
325
  resources,
308
326
  assetCatalogs: [...resources.filter((p) => p.endsWith('.xcassets')), ...synchronized.assetCatalogs],
327
+ localizations: [...localizationFiles(resources), ...synchronized.localizations],
309
328
  dependencies: this.dependencyNames(target),
310
329
  packageProducts: this.packageProductNames(target),
330
+ packageDependencies: this.packageProductDependencies(target),
311
331
  settings,
312
332
  };
313
333
  const bundleIdentifier = defaults['PRODUCT_BUNDLE_IDENTIFIER'];
@@ -383,6 +403,124 @@ class ProjectReader {
383
403
  }
384
404
  return [...names];
385
405
  }
406
+ /** `packageProductNames` with the package of each product, when recorded. */
407
+ packageProductDependencies(target) {
408
+ const seen = new Set();
409
+ const out = [];
410
+ const add = (productRefId) => {
411
+ const product = productRefId ? this.objects[productRefId] : undefined;
412
+ if (product?.isa !== 'XCSwiftPackageProductDependency')
413
+ return;
414
+ const name = str(product['productName']);
415
+ if (!name || seen.has(name))
416
+ return;
417
+ seen.add(name);
418
+ const reference = this.packageReference(str(product['package'])) ?? this.folderPackage(name);
419
+ out.push(reference ? { product: name, package: reference } : { product: name });
420
+ };
421
+ for (const id of strings(target['packageProductDependencies']))
422
+ add(id);
423
+ for (const phaseId of strings(target['buildPhases'])) {
424
+ const phase = this.objects[phaseId];
425
+ if (phase?.isa !== 'PBXFrameworksBuildPhase')
426
+ continue;
427
+ for (const buildFileId of strings(phase['files']))
428
+ add(str(this.objects[buildFileId]?.['productRef']));
429
+ }
430
+ return out;
431
+ }
432
+ /**
433
+ * Local packages added to the project as folders (how Xcode added a local
434
+ * package before `XCLocalSwiftPackageReference`, and still does when one is
435
+ * dragged in): a file reference to a folder holding a `Package.swift`. The
436
+ * product dependency then names no package, so the package is the folder
437
+ * whose manifest declares a library of that name (read from the manifest's
438
+ * text: `.library(name: "…"`), or, failing that, the folder of that name.
439
+ */
440
+ folderPackagesByProduct;
441
+ folderPackage(product) {
442
+ if (!this.folderPackagesByProduct) {
443
+ const byProduct = new Map();
444
+ const byFolder = new Map();
445
+ for (const [id, object] of Object.entries(this.objects)) {
446
+ if (object.isa !== 'PBXFileReference')
447
+ continue;
448
+ const type = str(object['lastKnownFileType']) ?? str(object['explicitFileType']);
449
+ if (type !== undefined && type !== 'wrapper' && type !== 'folder')
450
+ continue;
451
+ const dir = this.resolvePath(id);
452
+ if (!dir)
453
+ continue;
454
+ let manifest;
455
+ try {
456
+ manifest = fs.readFileSync(path.join(dir, 'Package.swift'), 'utf8');
457
+ }
458
+ catch {
459
+ continue;
460
+ }
461
+ const reference = { kind: 'local', path: dir, identity: packageIdentity(dir) };
462
+ byFolder.set(path.basename(dir), reference);
463
+ for (const match of manifest.matchAll(/\.library\(\s*name:\s*"([^"]+)"/g)) {
464
+ if (!byProduct.has(match[1]))
465
+ byProduct.set(match[1], reference);
466
+ }
467
+ }
468
+ for (const [name, reference] of byFolder)
469
+ if (!byProduct.has(name))
470
+ byProduct.set(name, reference);
471
+ this.folderPackagesByProduct = byProduct;
472
+ }
473
+ return this.folderPackagesByProduct.get(product);
474
+ }
475
+ /** An `XCRemoteSwiftPackageReference` or `XCLocalSwiftPackageReference`. */
476
+ packageReference(id) {
477
+ const object = id ? this.objects[id] : undefined;
478
+ if (object?.isa === 'XCRemoteSwiftPackageReference') {
479
+ const url = str(object['repositoryURL']);
480
+ if (!url)
481
+ return undefined;
482
+ const requirement = packageRequirement(object['requirement']);
483
+ return requirement ? { kind: 'remote', url, identity: packageIdentity(url), requirement } : { kind: 'remote', url, identity: packageIdentity(url) };
484
+ }
485
+ if (object?.isa === 'XCLocalSwiftPackageReference') {
486
+ const relative = str(object['relativePath']);
487
+ if (!relative)
488
+ return undefined;
489
+ const absolute = path.resolve(this.root, relative);
490
+ return { kind: 'local', path: absolute, identity: packageIdentity(absolute) };
491
+ }
492
+ return undefined;
493
+ }
494
+ }
495
+ /** Xcode's dependency rule; undefined for a kind it does not spell out here. */
496
+ function packageRequirement(value) {
497
+ if (!isDict(value))
498
+ return undefined;
499
+ const minimumVersion = str(value['minimumVersion']);
500
+ switch (str(value['kind'])) {
501
+ case 'upToNextMajorVersion':
502
+ return minimumVersion ? { kind: 'upToNextMajorVersion', minimumVersion } : undefined;
503
+ case 'upToNextMinorVersion':
504
+ return minimumVersion ? { kind: 'upToNextMinorVersion', minimumVersion } : undefined;
505
+ case 'exactVersion': {
506
+ const version = str(value['version']);
507
+ return version ? { kind: 'exactVersion', version } : undefined;
508
+ }
509
+ case 'versionRange': {
510
+ const maximumVersion = str(value['maximumVersion']);
511
+ return minimumVersion && maximumVersion ? { kind: 'versionRange', minimumVersion, maximumVersion } : undefined;
512
+ }
513
+ case 'branch': {
514
+ const branch = str(value['branch']);
515
+ return branch ? { kind: 'branch', branch } : undefined;
516
+ }
517
+ case 'revision': {
518
+ const revision = str(value['revision']);
519
+ return revision ? { kind: 'revision', revision } : undefined;
520
+ }
521
+ default:
522
+ return undefined;
523
+ }
386
524
  }
387
525
  // ----- Helpers ----------------------------------------------------------------
388
526
  /**
@@ -397,6 +535,25 @@ export function expandVariables(value, variables) {
397
535
  * whether to descend into a directory; `.xcassets` bundles and dot-entries are
398
536
  * never descended into. Unreadable directories are skipped.
399
537
  */
538
+ /** A string catalog, or a `.strings` / `.stringsdict` table. */
539
+ function isLocalization(file) {
540
+ return /\.(xcstrings|strings|stringsdict)$/.test(file);
541
+ }
542
+ /** The localization files among resources: catalogs and tables, and the tables in `.lproj` folders. */
543
+ function localizationFiles(resources) {
544
+ const out = [];
545
+ for (const resource of resources) {
546
+ if (isLocalization(resource)) {
547
+ out.push(resource);
548
+ }
549
+ else if (resource.endsWith('.lproj') && fs.existsSync(resource) && fs.statSync(resource).isDirectory()) {
550
+ for (const name of fs.readdirSync(resource).sort())
551
+ if (isLocalization(name))
552
+ out.push(path.join(resource, name));
553
+ }
554
+ }
555
+ return out;
556
+ }
400
557
  function walkSynchronizedFolder(dir, visit) {
401
558
  let entries;
402
559
  try {
@@ -411,7 +568,11 @@ function walkSynchronizedFolder(dir, visit) {
411
568
  continue;
412
569
  const full = path.join(dir, entry.name);
413
570
  const descend = visit(full);
414
- if (descend && entry.isDirectory() && !entry.name.endsWith('.xcassets'))
571
+ if (descend && entry.isDirectory() && !entry.name.endsWith('.xcassets') && !entry.name.endsWith('.mlpackage'))
415
572
  walkSynchronizedFolder(full, visit);
416
573
  }
417
574
  }
575
+ /** A Core ML model Xcode compiles into a class: an `.mlmodel` file or an `.mlpackage` bundle. */
576
+ function isCoreMLModel(file) {
577
+ return file.endsWith('.mlmodel') || file.endsWith('.mlpackage');
578
+ }
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@swiftbrowser/xcodeproj",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Reads Xcode projects (project.pbxproj): app targets, their Swift sources, asset catalogs and build settings. Pure TypeScript, no Xcode needed.",
5
5
  "repository": {
6
6
  "type": "git",
7
- "url": "git+https://github.com/dabbott/SwiftBrowser.git",
7
+ "url": "git+https://github.com/noya-app/SwiftBrowser.git",
8
8
  "directory": "packages/xcodeproj"
9
9
  },
10
10
  "type": "module",