polycast-cli 0.2.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/dist/scan.js CHANGED
@@ -1,74 +1,647 @@
1
- import { readFileSync, readdirSync, statSync } from "node:fs";
1
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
2
2
  import path from "node:path";
3
- const SKIP_DIRS = new Set([
3
+ const DEFAULT_SKIP_DIRS = [
4
4
  "node_modules", "Pods", ".build", "build", "DerivedData", ".git",
5
5
  ".swiftpm", "Carthage",
6
- ]);
7
- export const DEFAULT_SCAN_CALLS = ["Text", "PText", "Polycast.string"];
8
- /** Build the call matcher from default + user-configured component names.
9
- * Plain names ("AppText") match constructor/function calls; names starting
10
- * with "." (".navigationTitle") match method calls. */
11
- export function buildCallMatcher(extra = []) {
12
- const names = [...new Set([...DEFAULT_SCAN_CALLS, ...extra])];
13
- const ctors = names.filter((n) => !n.startsWith("."))
14
- .map((n) => n.replace(/[.\\]/g, "\\$&"));
15
- const methods = names.filter((n) => n.startsWith("."))
16
- .map((n) => n.slice(1).replace(/[.\\]/g, "\\$&"));
17
- const parts = [];
18
- if (ctors.length)
19
- parts.push(`(?<![A-Za-z0-9_.])(?:${ctors.join("|")})`);
20
- if (methods.length)
21
- parts.push(`\\.(?:${methods.join("|")})`);
22
- // Optional first-argument label: ContinueButton(title: "…") — the label
23
- // is captured so scanFile can skip non-display ones (verbatim:, id:, …).
24
- return new RegExp(`(?:${parts.join("|")})\\(\\s*(?:([A-Za-z_][A-Za-z0-9_]*)\\s*:\\s*)?"`, "g");
25
- }
6
+ ];
7
+ export const DEFAULT_SCAN_CALLS = [
8
+ "Text", "PText", "Polycast.string", "NSLocalizedString",
9
+ ".navigationTitle", ".accessibilityLabel", ".accessibilityHint",
10
+ ".accessibilityValue", "WindowGroup", "CommandMenu", "LocalizedStringKey",
11
+ ];
12
+ /** `.prop = "literal"` assignment targets captured by default. */
13
+ export const DEFAULT_ASSIGN_PROPS = [
14
+ "title", "body", "text", "subtitle", "message", "alertMessage",
15
+ "localizedReason", "localizedFallbackTitle", "placeholder",
16
+ ];
26
17
  /** First-argument labels that mark a NON-display string. */
27
18
  const SKIP_LABELS = new Set([
28
19
  "verbatim", "systemImage", "id", "tag", "key", "icon", "imageName",
29
20
  "named", "systemName", "url", "rawValue", "identifier",
30
21
  ]);
31
- /** Parse a Swift string literal starting AFTER the opening quote.
32
- * Interpolations (`\(…)`, balanced) become %@. Returns null for
33
- * multiline/unterminated literals. */
34
- function parseLiteral(src, start) {
35
- let key = "";
22
+ /** Only Polycast's own calls treat `, count:` as a CLDR plural marker —
23
+ * custom components may have unrelated count: params (badge counts etc.),
24
+ * and a plural entry without a numeric specifier breaks Xcode's catalog
25
+ * compile. */
26
+ const PLURAL_CAPABLE = new Set(["Text", "PText", "Polycast.string"]);
27
+ const OP_CHARS = new Set([..."-+*/%<>=!&|^~?"]);
28
+ const PUNCT_CHARS = new Set([..."()[]{},:;.@#$\\"]);
29
+ function isIdentStart(ch) {
30
+ return (ch >= "a" && ch <= "z") || (ch >= "A" && ch <= "Z") || ch === "_";
31
+ }
32
+ function isIdentChar(ch) {
33
+ return isIdentStart(ch) || (ch >= "0" && ch <= "9");
34
+ }
35
+ function isRawStringStart(src, i) {
36
+ let j = i;
37
+ while (src[j] === "#")
38
+ j++;
39
+ return src[j] === '"';
40
+ }
41
+ function skipBlockComment(src, i) {
42
+ // i points just past the opening "/*"; block comments nest in Swift.
43
+ let depth = 1;
44
+ const n = src.length;
45
+ while (i < n && depth > 0) {
46
+ if (src[i] === "/" && src[i + 1] === "*") {
47
+ depth++;
48
+ i += 2;
49
+ }
50
+ else if (src[i] === "*" && src[i + 1] === "/") {
51
+ depth--;
52
+ i += 2;
53
+ }
54
+ else
55
+ i++;
56
+ }
57
+ return i;
58
+ }
59
+ /** Decode one escape sequence; `e` points at the char after `\` (+ #s).
60
+ * Returns [decoded text, chars consumed starting at e]. */
61
+ function decodeEscape(src, e) {
62
+ const c = src[e];
63
+ switch (c) {
64
+ case "n": return ["\n", 1];
65
+ case "t": return ["\t", 1];
66
+ case "r": return ["\r", 1];
67
+ case "0": return ["\0", 1];
68
+ case "\\": return ["\\", 1];
69
+ case '"': return ['"', 1];
70
+ case "'": return ["'", 1];
71
+ case "u": {
72
+ if (src[e + 1] === "{") {
73
+ const close = src.indexOf("}", e + 2);
74
+ const hex = close > 0 ? src.slice(e + 2, close) : "";
75
+ if (hex.length >= 1 && hex.length <= 8 && /^[0-9a-fA-F]+$/.test(hex)) {
76
+ try {
77
+ return [String.fromCodePoint(parseInt(hex, 16)), close - e + 1];
78
+ }
79
+ catch { /* invalid scalar — fall through */ }
80
+ }
81
+ }
82
+ return ["u", 1];
83
+ }
84
+ default: return [c ?? "", 1];
85
+ }
86
+ }
87
+ /** Skip a balanced `\(…)` interpolation body; `i` points just past the
88
+ * opening paren. String-literal and comment aware, so a ")" inside a
89
+ * nested string (e.g. `\(x, specifier: "%.1f")`) can't end it early. */
90
+ function skipInterpolation(src, i) {
91
+ const n = src.length;
92
+ let depth = 1;
93
+ while (i < n && depth > 0) {
94
+ const ch = src[i];
95
+ if (ch === '"' || (ch === "#" && isRawStringStart(src, i))) {
96
+ i = parseString(src, i).end;
97
+ continue;
98
+ }
99
+ if (ch === "/" && src[i + 1] === "/") {
100
+ while (i < n && src[i] !== "\n")
101
+ i++;
102
+ continue;
103
+ }
104
+ if (ch === "/" && src[i + 1] === "*") {
105
+ i = skipBlockComment(src, i + 2);
106
+ continue;
107
+ }
108
+ if (ch === "(")
109
+ depth++;
110
+ else if (ch === ")")
111
+ depth--;
112
+ i++;
113
+ }
114
+ return i;
115
+ }
116
+ /** Parse any Swift string literal starting at `start` (at the first `#`
117
+ * for raw strings, else at the opening quote). */
118
+ function parseString(src, start) {
36
119
  let i = start;
37
- while (i < src.length) {
120
+ let pounds = 0;
121
+ while (src[i] === "#") {
122
+ pounds++;
123
+ i++;
124
+ }
125
+ const hashes = "#".repeat(pounds);
126
+ const multiline = src.startsWith('"""', i);
127
+ i += multiline ? 3 : 1;
128
+ return multiline
129
+ ? parseMultilineString(src, i, pounds, hashes)
130
+ : parseSingleLineString(src, i, pounds, hashes);
131
+ }
132
+ function parseSingleLineString(src, i, pounds, hashes) {
133
+ const n = src.length;
134
+ const parts = [];
135
+ let lit = "";
136
+ while (i < n) {
38
137
  const ch = src[i];
39
- if (ch === '"')
40
- return { key, end: i + 1 };
41
138
  if (ch === "\n")
139
+ break; // unterminated — tolerate, end at the newline
140
+ if (ch === '"' && src.startsWith(hashes, i + 1)) {
141
+ i += 1 + pounds;
142
+ break;
143
+ }
144
+ // In a raw string, `\` only escapes when followed by the matching #s.
145
+ if (ch === "\\" && src.startsWith(hashes, i + 1)) {
146
+ const e = i + 1 + pounds;
147
+ if (src[e] === "(") {
148
+ parts.push(lit, null);
149
+ lit = "";
150
+ i = skipInterpolation(src, e + 1);
151
+ continue;
152
+ }
153
+ const [decoded, len] = decodeEscape(src, e);
154
+ lit += decoded;
155
+ i = e + len;
156
+ continue;
157
+ }
158
+ lit += ch;
159
+ i++;
160
+ }
161
+ parts.push(lit);
162
+ return { parts, end: i };
163
+ }
164
+ function parseMultilineString(src, i, pounds, hashes) {
165
+ const n = src.length;
166
+ // Pass 1: locate the closing delimiter (escape/interpolation aware).
167
+ let close = n;
168
+ {
169
+ let j = i;
170
+ while (j < n) {
171
+ const ch = src[j];
172
+ if (ch === "\\" && src.startsWith(hashes, j + 1)) {
173
+ const e = j + 1 + pounds;
174
+ j = src[e] === "(" ? skipInterpolation(src, e + 1) : e + 1;
175
+ continue;
176
+ }
177
+ if (ch === '"' && src.startsWith('""' + hashes, j + 1)) {
178
+ close = j;
179
+ break;
180
+ }
181
+ j++;
182
+ }
183
+ }
184
+ const end = Math.min(close + 3 + pounds, n);
185
+ // Swift strips the closing delimiter's indentation from every line and
186
+ // drops the newlines adjacent to the delimiters.
187
+ const lastNL = src.lastIndexOf("\n", close - 1);
188
+ let indent = "";
189
+ let contentEnd = close;
190
+ if (lastNL >= i) {
191
+ const maybe = src.slice(lastNL + 1, close);
192
+ if (/^[ \t]*$/.test(maybe)) {
193
+ indent = maybe;
194
+ contentEnd = lastNL;
195
+ }
196
+ }
197
+ let contentStart = i;
198
+ const firstNL = src.indexOf("\n", i);
199
+ if (firstNL !== -1 && firstNL < contentEnd)
200
+ contentStart = firstNL + 1;
201
+ // Pass 2: decode content.
202
+ const parts = [];
203
+ let lit = "";
204
+ let j = contentStart;
205
+ let atLineStart = true;
206
+ while (j < contentEnd) {
207
+ if (atLineStart) {
208
+ atLineStart = false;
209
+ if (indent.length > 0 && src.startsWith(indent, j))
210
+ j += indent.length;
211
+ continue;
212
+ }
213
+ const ch = src[j];
214
+ if (ch === "\\" && src.startsWith(hashes, j + 1)) {
215
+ const e = j + 1 + pounds;
216
+ const esc = src[e];
217
+ if (esc === "(") {
218
+ parts.push(lit, null);
219
+ lit = "";
220
+ j = skipInterpolation(src, e + 1);
221
+ continue;
222
+ }
223
+ if (esc === "\n" || (esc === "\r" && src[e + 1] === "\n")) {
224
+ // line continuation: `\` at end of line eats the newline
225
+ j = esc === "\n" ? e + 1 : e + 2;
226
+ atLineStart = true;
227
+ continue;
228
+ }
229
+ const [decoded, len] = decodeEscape(src, e);
230
+ lit += decoded;
231
+ j = e + len;
232
+ continue;
233
+ }
234
+ if (ch === "\r") {
235
+ j++;
236
+ continue;
237
+ }
238
+ if (ch === "\n") {
239
+ lit += "\n";
240
+ j++;
241
+ atLineStart = true;
242
+ continue;
243
+ }
244
+ lit += ch;
245
+ j++;
246
+ }
247
+ parts.push(lit);
248
+ return { parts, end };
249
+ }
250
+ function lex(src) {
251
+ const toks = [];
252
+ const n = src.length;
253
+ let i = 0;
254
+ while (i < n) {
255
+ const ch = src[i];
256
+ if (ch === " " || ch === "\t" || ch === "\r" || ch === "\n") {
257
+ i++;
258
+ continue;
259
+ }
260
+ if (ch === "/" && src[i + 1] === "/") {
261
+ while (i < n && src[i] !== "\n")
262
+ i++;
263
+ continue;
264
+ }
265
+ if (ch === "/" && src[i + 1] === "*") {
266
+ i = skipBlockComment(src, i + 2);
267
+ continue;
268
+ }
269
+ if (ch === '"' || (ch === "#" && isRawStringStart(src, i))) {
270
+ const s = parseString(src, i);
271
+ toks.push({ t: "str", parts: s.parts });
272
+ i = s.end;
273
+ continue;
274
+ }
275
+ if (isIdentStart(ch)) {
276
+ let j = i + 1;
277
+ while (j < n && isIdentChar(src[j]))
278
+ j++;
279
+ toks.push({ t: "id", v: src.slice(i, j) });
280
+ i = j;
281
+ continue;
282
+ }
283
+ if (ch === "`") { // escaped identifier: `default`
284
+ let j = i + 1;
285
+ while (j < n && src[j] !== "`" && src[j] !== "\n")
286
+ j++;
287
+ toks.push({ t: "id", v: src.slice(i + 1, j) });
288
+ i = j + 1;
289
+ continue;
290
+ }
291
+ if (ch >= "0" && ch <= "9") {
292
+ let j = i + 1;
293
+ while (j < n && (isIdentChar(src[j]) || (src[j] === "." && (src[j + 1] ?? "") >= "0" && (src[j + 1] ?? "") <= "9")))
294
+ j++;
295
+ toks.push({ t: "num" });
296
+ i = j;
297
+ continue;
298
+ }
299
+ if (OP_CHARS.has(ch)) {
300
+ let j = i;
301
+ while (j < n && OP_CHARS.has(src[j]) &&
302
+ !(src[j] === "/" && (src[j + 1] === "/" || src[j + 1] === "*")))
303
+ j++;
304
+ if (j > i) {
305
+ toks.push({ t: "op", v: src.slice(i, j) });
306
+ i = j;
307
+ continue;
308
+ }
309
+ }
310
+ if (PUNCT_CHARS.has(ch)) {
311
+ toks.push({ t: "p", v: ch });
312
+ i++;
313
+ continue;
314
+ }
315
+ i++; // anything else (unicode operators, emoji in code) is inert
316
+ }
317
+ return toks;
318
+ }
319
+ /** Fold adjacent `"a" + "b"` literal concatenations (possibly across
320
+ * newlines) into one string token, chains included — the runtime sees a
321
+ * single string, so the catalog must too. */
322
+ function foldConcat(toks) {
323
+ const out = [];
324
+ for (const t of toks) {
325
+ const plus = out[out.length - 1];
326
+ const left = out[out.length - 2];
327
+ if (t.t === "str" && plus?.t === "op" && plus.v === "+" && left?.t === "str") {
328
+ out.pop();
329
+ left.parts = [...left.parts, ...t.parts];
330
+ continue;
331
+ }
332
+ out.push(t);
333
+ }
334
+ return out;
335
+ }
336
+ // ---------------------------------------------------------------------------
337
+ // Key building + classification
338
+ // ---------------------------------------------------------------------------
339
+ /** Build the catalog key from decoded parts. Interpolations render as %@;
340
+ * literal "%" doubles to "%%" ONLY when at least one interpolation exists
341
+ * (parity with PolycastFormatString at runtime). */
342
+ function keyFromParts(parts) {
343
+ const hasInterp = parts.some((p) => p === null);
344
+ let out = "";
345
+ for (const p of parts)
346
+ out += p === null ? "%@" : hasInterp ? p.replace(/%/g, "%%") : p;
347
+ return out;
348
+ }
349
+ const SPECIFIER_RE = /%(?:\d+\$)?[-+#0']*\d*(?:\.\d+)?(?:hh|h|ll|l|q|z|t|L)?[@diouxXeEfgGaAcspSCF]/g;
350
+ const JOINER_RE = /[@·–—%°/:]/;
351
+ /** Keep letterful keys as normal strings. Letterless keys survive only as
352
+ * `structural` joiners: >=2 format specifiers, or >=1 specifier plus a
353
+ * joiner character. Empty / pure-emoji / single-specifier-only keys drop
354
+ * (not translatable; they also break Xcode's catalog symbol generation). */
355
+ function classifyKey(key) {
356
+ if (key.trim().length === 0)
357
+ return { keep: false, structural: false };
358
+ const guarded = key.replace(/%%/g, "\uE000\uE000"); // protect escaped percents
359
+ const specifiers = guarded.match(SPECIFIER_RE)?.length ?? 0;
360
+ const residual = guarded.replace(SPECIFIER_RE, "").replace(/\uE000/g, "%");
361
+ if (/\p{L}/u.test(residual))
362
+ return { keep: true, structural: false };
363
+ if (specifiers >= 2 || (specifiers >= 1 && JOINER_RE.test(residual))) {
364
+ return { keep: true, structural: true };
365
+ }
366
+ return { keep: false, structural: false };
367
+ }
368
+ function compileRegistry(extra) {
369
+ const names = [...new Set([...DEFAULT_SCAN_CALLS, ...extra])];
370
+ const ctors = [];
371
+ const methods = new Set();
372
+ const labels = new Set();
373
+ for (const n of names) {
374
+ if (n.startsWith("."))
375
+ methods.add(n.slice(1));
376
+ else if (n.endsWith(":"))
377
+ labels.add(n.slice(0, -1));
378
+ else
379
+ ctors.push({
380
+ name: n,
381
+ segs: n.split("."),
382
+ firstOnly: n === "NSLocalizedString",
383
+ requiredLabel: undefined,
384
+ pluralCapable: PLURAL_CAPABLE.has(n),
385
+ });
386
+ }
387
+ // Foundation localized-string ctors: only the `localized:` argument is a
388
+ // key — String(format:…) / String(describing:…) must never be captured.
389
+ for (const n of ["String", "AttributedString"]) {
390
+ if (!ctors.some((c) => c.name === n)) {
391
+ ctors.push({ name: n, segs: [n], firstOnly: false, requiredLabel: "localized", pluralCapable: false });
392
+ }
393
+ }
394
+ return { ctors, methods, labels };
395
+ }
396
+ function tokIs(t, kind, v) {
397
+ if (t === undefined)
398
+ return false;
399
+ return (t.t === "p" || t.t === "op") && t.t === kind && t.v === v;
400
+ }
401
+ function tokId(t) {
402
+ return t !== undefined && t.t === "id" ? t.v : undefined;
403
+ }
404
+ /** Match a registered call name at token j. Handles dotted names
405
+ * ("Polycast.string"), a `SwiftUI.` qualifier (marked native), and a
406
+ * trailing `.init`. Rejects other qualified accesses (Foo.Text) — and
407
+ * `RichText(` can never match `Text` because identifiers are whole
408
+ * tokens. */
409
+ function matchCtor(toks, j, spec) {
410
+ if (tokId(toks[j]) !== spec.segs[0])
411
+ return null;
412
+ let native = false;
413
+ const prev = toks[j - 1];
414
+ if (prev !== undefined && prev.t === "p" && prev.v === ".") {
415
+ if (tokId(toks[j - 2]) !== "SwiftUI" || tokIs(toks[j - 3], "p", "."))
42
416
  return null;
43
- if (ch === "\\") {
44
- const next = src[i + 1];
45
- if (next === "(") { // interpolation — swallow balanced parens
46
- let depth = 1;
47
- i += 2;
48
- while (i < src.length && depth > 0) {
49
- if (src[i] === "(")
50
- depth++;
51
- else if (src[i] === ")")
52
- depth--;
53
- i++;
417
+ native = true;
418
+ }
419
+ let k = j + 1;
420
+ for (let s = 1; s < spec.segs.length; s++) {
421
+ if (!tokIs(toks[k], "p", ".") || tokId(toks[k + 1]) !== spec.segs[s])
422
+ return null;
423
+ k += 2;
424
+ }
425
+ if (tokIs(toks[k], "p", ".") && tokId(toks[k + 1]) === "init")
426
+ k += 2; // Text.init(
427
+ if (!tokIs(toks[k], "p", "("))
428
+ return null;
429
+ return { open: k, native };
430
+ }
431
+ const GENERIC_POLICY = { firstOnly: false, requiredLabel: undefined, pluralCapable: false };
432
+ /** The label immediately preceding a literal (`label: "…"`), if any.
433
+ * `ident :` counts as an argument label ONLY when the identifier itself
434
+ * follows `(` or `,` — in `cond ? ident : "literal"` the identifier is a
435
+ * ternary operand, not a label, and the literal must stay capturable
436
+ * (variable names like `key`/`id`/`url` collide with SKIP_LABELS). */
437
+ function literalLabel(toks, j) {
438
+ if (!tokIs(toks[j - 1], "p", ":"))
439
+ return undefined;
440
+ const label = tokId(toks[j - 2]);
441
+ if (label === undefined)
442
+ return undefined;
443
+ if (!tokIs(toks[j - 3], "p", "(") && !tokIs(toks[j - 3], "p", ","))
444
+ return undefined;
445
+ return label;
446
+ }
447
+ /** Capture every top-level string literal in the balanced argument list
448
+ * starting after the "(" at `open`. Literals inside nested closures
449
+ * `{ … }` or subscripts/collection literals `[ … ]` are not display
450
+ * strings for this call and are skipped; so are literals inside a NESTED
451
+ * call's parens (identifier right before `(`) — they are that call's
452
+ * arguments, which the runtime can never match against this key (a
453
+ * registered nested call gets its own region via runCapture; pure
454
+ * grouping parens don't count). Literals glued to `+` with a non-literal
455
+ * operand are unfoldable concatenation fragments and are skipped too
456
+ * (foldConcat already merged literal+literal). SKIP-labelled literals are
457
+ * skipped; a `comment:`-labelled literal becomes the translator note.
458
+ * Required-label policies (String(localized:…)) accept the labelled
459
+ * literal itself plus expression-position literals after the label
460
+ * (`String(localized: flag ? "Alpha" : "Beta")` captures both). */
461
+ function scanCallRegion(toks, open, policy, native, emit) {
462
+ let depth = 1;
463
+ let brace = 0;
464
+ let bracket = 0;
465
+ let nestedCall = 0;
466
+ const parenIsCall = [];
467
+ let requiredSeen = false;
468
+ let comment;
469
+ const hits = [];
470
+ for (let j = open + 1; j < toks.length && depth > 0; j++) {
471
+ const t = toks[j];
472
+ if (t.t === "p") {
473
+ if (t.v === "(") {
474
+ depth++;
475
+ const isCall = tokId(toks[j - 1]) !== undefined;
476
+ parenIsCall.push(isCall);
477
+ if (isCall)
478
+ nestedCall++;
479
+ }
480
+ else if (t.v === ")") {
481
+ depth--;
482
+ if (depth > 0 && parenIsCall.pop() === true)
483
+ nestedCall--;
484
+ }
485
+ else if (t.v === "{")
486
+ brace++;
487
+ else if (t.v === "}")
488
+ brace = brace > 0 ? brace - 1 : 0;
489
+ else if (t.v === "[")
490
+ bracket++;
491
+ else if (t.v === "]")
492
+ bracket = bracket > 0 ? bracket - 1 : 0;
493
+ continue;
494
+ }
495
+ if (t.t === "id") {
496
+ // `localized:` seen in argument-label position at this call's top
497
+ // level → expression-position literals after it are the key.
498
+ if (policy.requiredLabel !== undefined && t.v === policy.requiredLabel &&
499
+ depth === 1 && brace === 0 && bracket === 0 &&
500
+ tokIs(toks[j + 1], "p", ":") &&
501
+ (tokIs(toks[j - 1], "p", "(") || tokIs(toks[j - 1], "p", ","))) {
502
+ requiredSeen = true;
503
+ }
504
+ continue;
505
+ }
506
+ if (t.t !== "str" || brace > 0 || bracket > 0 || nestedCall > 0)
507
+ continue;
508
+ if (tokIs(toks[j - 1], "op", "+") || tokIs(toks[j + 1], "op", "+"))
509
+ continue;
510
+ const label = literalLabel(toks, j);
511
+ if (label === "comment") {
512
+ const c = keyFromParts(t.parts);
513
+ if (c.trim().length > 0 && comment === undefined)
514
+ comment = c;
515
+ continue;
516
+ }
517
+ if (label !== undefined && SKIP_LABELS.has(label))
518
+ continue;
519
+ if (policy.requiredLabel !== undefined && label !== policy.requiredLabel &&
520
+ !(label === undefined && requiredSeen))
521
+ continue;
522
+ if (policy.firstOnly && hits.length > 0)
523
+ continue;
524
+ // `, count:` right after the call's first literal marks a CLDR plural.
525
+ const plural = policy.pluralCapable && hits.length === 0 &&
526
+ tokIs(toks[j + 1], "p", ",") && tokId(toks[j + 2]) === "count" && tokIs(toks[j + 3], "p", ":");
527
+ hits.push({ parts: t.parts, kind: plural ? "plural" : "simple" });
528
+ }
529
+ for (const h of hits)
530
+ emit({ key: keyFromParts(h.parts), kind: h.kind, comment, native });
531
+ }
532
+ function runCapture(toks, reg, assignProps, emit) {
533
+ for (let j = 0; j < toks.length; j++) {
534
+ const t = toks[j];
535
+ if (t.t === "id") {
536
+ // Registered calls (incl. SwiftUI.-qualified and .init spellings).
537
+ for (const spec of reg.ctors) {
538
+ const m = matchCtor(toks, j, spec);
539
+ if (m !== null) {
540
+ scanCallRegion(toks, m.open, spec, m.native, emit);
541
+ break;
54
542
  }
55
- key += "%@";
543
+ }
544
+ // "title:"-style registered labels, any argument position. Never
545
+ // after a dot (that's a member access, e.g. `case .title:`).
546
+ if (reg.labels.has(t.v) && tokIs(toks[j + 1], "p", ":") && !tokIs(toks[j - 1], "p", ".")) {
547
+ const s = toks[j + 2];
548
+ if (s?.t === "str")
549
+ emit({ key: keyFromParts(s.parts), kind: "simple", comment: undefined, native: false });
550
+ }
551
+ continue;
552
+ }
553
+ if (t.t === "p" && t.v === ".") {
554
+ const name = tokId(toks[j + 1]);
555
+ if (name === undefined)
56
556
  continue;
557
+ // Method-style: view.navigationTitle("…")
558
+ if (reg.methods.has(name) && tokIs(toks[j + 2], "p", "(")) {
559
+ scanCallRegion(toks, j + 2, GENERIC_POLICY, false, emit);
560
+ }
561
+ // Assignment shape: label.text = "…" (never `==` — that's one op token)
562
+ if (assignProps.has(name) && tokIs(toks[j + 2], "op", "=")) {
563
+ const s = toks[j + 3];
564
+ if (s?.t === "str")
565
+ emit({ key: keyFromParts(s.parts), kind: "simple", comment: undefined, native: false });
57
566
  }
58
- key += next === "n" ? "\n" : next === "t" ? "\t" : next ?? "";
59
- i += 2;
60
567
  continue;
61
568
  }
62
- key += ch;
63
- i++;
569
+ // Typed defaults: `: String = "…"` / `: String? = "…"` (vars, lets,
570
+ // and function default parameters alike).
571
+ if (t.t === "p" && t.v === ":" && tokId(toks[j + 1]) === "String") {
572
+ let m = j + 2;
573
+ if (tokIs(toks[m], "op", "?"))
574
+ m++;
575
+ if (tokIs(toks[m], "op", "=") || (m === j + 2 && tokIs(toks[m], "op", "?="))) {
576
+ const s = toks[m + 1];
577
+ if (s?.t === "str")
578
+ emit({ key: keyFromParts(s.parts), kind: "simple", comment: undefined, native: false });
579
+ }
580
+ }
64
581
  }
65
- return null;
66
582
  }
67
- export function scanSwiftSources(root, extraCalls = []) {
68
- const matcher = buildCallMatcher(extraCalls);
583
+ // ---------------------------------------------------------------------------
584
+ // File-system walk
585
+ // ---------------------------------------------------------------------------
586
+ /** Best-effort discovery of local SPM path dependencies:
587
+ * `.package(path: "…")` (optionally with a leading `name:`) in the root's
588
+ * Package.swift. */
589
+ function packagePathDeps(root) {
590
+ const manifest = path.join(root, "Package.swift");
591
+ if (!existsSync(manifest))
592
+ return [];
593
+ let src;
594
+ try {
595
+ src = readFileSync(manifest, "utf8");
596
+ }
597
+ catch {
598
+ return [];
599
+ }
600
+ const deps = [];
601
+ for (const m of src.matchAll(/\.package\s*\(\s*(?:name\s*:\s*"[^"]*"\s*,\s*)?path\s*:\s*"([^"]+)"/g)) {
602
+ deps.push(path.resolve(root, m[1]));
603
+ }
604
+ return deps;
605
+ }
606
+ export function scanSwiftSourcesDetailed(root, opts = {}) {
607
+ const mainRoot = path.resolve(root);
608
+ const reg = compileRegistry(opts.extraCalls ?? []);
609
+ const assignProps = new Set(opts.assignProps ?? DEFAULT_ASSIGN_PROPS);
610
+ const maxDepth = opts.maxDepth ?? 12;
611
+ const skipDirs = new Set(opts.skipDirs ?? DEFAULT_SKIP_DIRS);
612
+ const candidates = [mainRoot];
613
+ for (const r of opts.extraRoots ?? [])
614
+ candidates.push(path.resolve(mainRoot, r));
615
+ candidates.push(...packagePathDeps(mainRoot));
69
616
  const found = new Map();
617
+ const stats = { filesScanned: 0, dirsSkipped: [], rootsScanned: [] };
618
+ const seenFiles = new Set();
619
+ const emitFor = (file) => (c) => {
620
+ const cls = classifyKey(c.key);
621
+ if (!cls.keep)
622
+ return;
623
+ const prev = found.get(c.key);
624
+ if (prev === undefined) {
625
+ const s = { key: c.key, kind: c.kind, file: path.relative(mainRoot, file) };
626
+ if (c.comment !== undefined)
627
+ s.comment = c.comment;
628
+ if (cls.structural)
629
+ s.structural = true;
630
+ if (c.native)
631
+ s.native = true;
632
+ found.set(c.key, s);
633
+ return;
634
+ }
635
+ if (prev.kind === "simple" && c.kind === "plural")
636
+ prev.kind = "plural";
637
+ if (prev.comment === undefined && c.comment !== undefined)
638
+ prev.comment = c.comment;
639
+ // `native` only survives when EVERY occurrence is SwiftUI-qualified.
640
+ if (prev.native === true && !c.native)
641
+ delete prev.native;
642
+ };
70
643
  const walk = (dir, depth) => {
71
- if (depth > 8)
644
+ if (depth > maxDepth)
72
645
  return;
73
646
  let entries;
74
647
  try {
@@ -78,9 +651,15 @@ export function scanSwiftSources(root, extraCalls = []) {
78
651
  return;
79
652
  }
80
653
  for (const name of entries) {
81
- if (name.startsWith(".") || SKIP_DIRS.has(name))
82
- continue;
83
654
  const full = path.join(dir, name);
655
+ if (name.startsWith(".") || skipDirs.has(name)) {
656
+ try {
657
+ if (statSync(full).isDirectory())
658
+ stats.dirsSkipped.push(full);
659
+ }
660
+ catch { /* ignore */ }
661
+ continue;
662
+ }
84
663
  let st;
85
664
  try {
86
665
  st = statSync(full);
@@ -88,97 +667,51 @@ export function scanSwiftSources(root, extraCalls = []) {
88
667
  catch {
89
668
  continue;
90
669
  }
91
- if (st.isDirectory())
670
+ if (st.isDirectory()) {
92
671
  walk(full, depth + 1);
93
- else if (name.endsWith(".swift"))
94
- scanFile(full, root, found, matcher);
95
- }
96
- };
97
- walk(root, 0);
98
- return [...found.values()].sort((a, b) => (a.key < b.key ? -1 : 1));
99
- }
100
- /** Blank out line and block comments (string-literal aware) so doc
101
- * examples like `Text("…")` never get captured. Nested blocks handled. */
102
- export function stripComments(src) {
103
- const out = [...src];
104
- let i = 0, inString = false, block = 0;
105
- while (i < src.length) {
106
- const ch = src[i], next = src[i + 1];
107
- if (block > 0) {
108
- if (ch === "/" && next === "*") {
109
- block++;
110
- out[i] = out[i + 1] = " ";
111
- i += 2;
112
672
  continue;
113
673
  }
114
- if (ch === "*" && next === "/") {
115
- block--;
116
- out[i] = out[i + 1] = " ";
117
- i += 2;
674
+ if (!name.endsWith(".swift") || seenFiles.has(full))
675
+ continue;
676
+ seenFiles.add(full);
677
+ if (opts.fileFilter !== undefined && !opts.fileFilter(path.relative(mainRoot, full)))
118
678
  continue;
679
+ let src;
680
+ try {
681
+ src = readFileSync(full, "utf8");
119
682
  }
120
- if (ch !== "\n")
121
- out[i] = " ";
122
- i++;
123
- continue;
124
- }
125
- if (inString) {
126
- if (ch === "\\") {
127
- i += 2;
683
+ catch {
128
684
  continue;
129
685
  }
130
- if (ch === '"')
131
- inString = false;
132
- i++;
133
- continue;
686
+ stats.filesScanned++;
687
+ try {
688
+ runCapture(foldConcat(lex(src)), reg, assignProps, emitFor(full));
689
+ }
690
+ catch { /* a scanner must never fail the push — skip pathological files */ }
134
691
  }
135
- if (ch === '"') {
136
- inString = true;
137
- i++;
692
+ };
693
+ const seenRoots = new Set();
694
+ for (const r of candidates) {
695
+ if (seenRoots.has(r))
138
696
  continue;
697
+ seenRoots.add(r);
698
+ let st;
699
+ try {
700
+ st = statSync(r);
139
701
  }
140
- if (ch === "/" && next === "/") {
141
- while (i < src.length && src[i] !== "\n") {
142
- out[i] = " ";
143
- i++;
144
- }
702
+ catch {
145
703
  continue;
146
704
  }
147
- if (ch === "/" && next === "*") {
148
- block = 1;
149
- out[i] = out[i + 1] = " ";
150
- i += 2;
705
+ if (!st.isDirectory())
151
706
  continue;
152
- }
153
- i++;
707
+ stats.rootsScanned.push(r);
708
+ walk(r, 0);
154
709
  }
155
- return out.join("");
710
+ const strings = [...found.values()].sort((a, b) => (a.key < b.key ? -1 : 1));
711
+ return { strings, stats };
156
712
  }
157
- function scanFile(file, root, found, matcher) {
158
- const src = stripComments(readFileSync(file, "utf8"));
159
- for (const m of src.matchAll(matcher)) {
160
- if (m[1] && SKIP_LABELS.has(m[1]))
161
- continue;
162
- const literalStart = m.index + m[0].length;
163
- const parsed = parseLiteral(src, literalStart);
164
- if (!parsed || parsed.key.trim().length === 0)
165
- continue;
166
- // Not translatable content: emoji, separators, SF Symbol names,
167
- // format-only strings — anything without a letter. (Also what makes
168
- // Xcode's catalog symbol generation choke.)
169
- if (!/\p{L}/u.test(parsed.key.replace(/%[@a-zA-Z]+/g, "")))
170
- continue;
171
- // `, count:` marks a CLDR plural — but only on Polycast's own calls.
172
- // Custom components (scanCalls) may have unrelated count: params
173
- // (badge counts etc.), and a plural entry without a numeric format
174
- // specifier breaks Xcode's catalog compile.
175
- const callee = m[0].replace(/\(.*$/s, "").replace(/^\./, "");
176
- const pluralCapable = DEFAULT_SCAN_CALLS.includes(callee);
177
- const kind = pluralCapable && /^\s*,\s*count\s*:/.test(src.slice(parsed.end)) ? "plural" : "simple";
178
- const existing = found.get(parsed.key);
179
- if (!existing || (existing.kind === "simple" && kind === "plural")) {
180
- found.set(parsed.key, { key: parsed.key, kind, file: path.relative(root, file) });
181
- }
182
- }
713
+ /** Back-compat wrapper same shape the CLI has always consumed. */
714
+ export function scanSwiftSources(root, extraCalls = []) {
715
+ return scanSwiftSourcesDetailed(root, { extraCalls }).strings;
183
716
  }
184
717
  //# sourceMappingURL=scan.js.map