@mjasnikovs/pi-task 0.39.5 → 0.40.1

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.
Files changed (35) hide show
  1. package/README.md +24 -9
  2. package/dist/task/context-attribution.js +18 -6
  3. package/dist/task/external-context.d.ts +8 -1
  4. package/dist/task/external-context.js +52 -4
  5. package/dist/task/phases.js +2 -1
  6. package/dist/task/prompts.d.ts +7 -1
  7. package/dist/task/prompts.js +14 -4
  8. package/dist/workers/docs-cache.js +50 -3
  9. package/dist/workers/docs-chunk.d.ts +6 -3
  10. package/dist/workers/docs-chunk.js +8 -5
  11. package/dist/workers/docs-core.d.ts +27 -3
  12. package/dist/workers/docs-core.js +104 -41
  13. package/dist/workers/docs-ecosystems.d.ts +173 -0
  14. package/dist/workers/docs-ecosystems.js +449 -0
  15. package/dist/workers/docs-index.d.ts +2 -1
  16. package/dist/workers/docs-index.js +55 -27
  17. package/dist/workers/docs-project.d.ts +10 -0
  18. package/dist/workers/docs-project.js +86 -24
  19. package/dist/workers/docs-resolve.d.ts +6 -1
  20. package/dist/workers/docs-resolve.js +4 -3
  21. package/dist/workers/docs-retrieve.d.ts +2 -0
  22. package/dist/workers/docs-retrieve.js +11 -11
  23. package/dist/workers/eco-cargo.d.ts +115 -0
  24. package/dist/workers/eco-cargo.js +793 -0
  25. package/dist/workers/eco-hackage.d.ts +93 -0
  26. package/dist/workers/eco-hackage.js +508 -0
  27. package/dist/workers/npm-version.d.ts +5 -3
  28. package/dist/workers/npm-version.js +6 -4
  29. package/dist/workers/pi-worker-docs.d.ts +18 -4
  30. package/dist/workers/pi-worker-docs.js +57 -19
  31. package/dist/workers/research-cache.d.ts +2 -13
  32. package/dist/workers/research-cache.js +22 -46
  33. package/dist/workers/shared.d.ts +16 -5
  34. package/dist/workers/shared.js +0 -0
  35. package/package.json +1 -1
@@ -0,0 +1,793 @@
1
+ /**
2
+ * eco-cargo — reading Rust crates for the docs Worker tool.
3
+ *
4
+ * Rust ships no declarations file, so the documented surface has to be cut out
5
+ * of `.rs` source: doc comments, attributes and public item heads kept, function
6
+ * bodies and private items dropped. That is what `surface` below does, and it is
7
+ * why this row needs code where the npm row needed none.
8
+ *
9
+ * Nothing here parses TOML. `Cargo.lock` is a generated file with a fixed
10
+ * `[[package]]` shape, and a line reader over it costs one small function where
11
+ * a TOML dependency would cost a dependency.
12
+ */
13
+ import * as fs from 'node:fs';
14
+ import * as path from 'node:path';
15
+ import { ResolveError } from './docs-resolve.js';
16
+ /** crates.io rejects a request with no User-Agent naming the caller. */
17
+ const CRATES_UA = 'pi-task (github.com/mjasnikovs/pi-task)';
18
+ const CRATES_API = 'https://crates.io/api/v1/crates';
19
+ const CRATES_DL = 'https://static.crates.io/crates';
20
+ /** A crate name is written with either separator and means the same crate. */
21
+ function canonical(name) {
22
+ return name.replace(/-/g, '_');
23
+ }
24
+ export function isValidCrateName(name) {
25
+ return /^[A-Za-z0-9_-]+(?:::[A-Za-z0-9_:]+)?$/.test(name);
26
+ }
27
+ /** `serde_json::Value` is a path into `serde_json`; the crate is what installs. */
28
+ export function crateOf(name) {
29
+ return name.split('::')[0];
30
+ }
31
+ /** Compare two `major.minor.patch[-pre][+build]` strings numerically, newest last. */
32
+ function compareVersions(a, b) {
33
+ const parts = (v) => v
34
+ .split(/[-+]/)[0]
35
+ .split('.')
36
+ .map(n => Number(n) || 0);
37
+ const [pa, pb] = [parts(a), parts(b)];
38
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
39
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
40
+ if (d !== 0)
41
+ return d;
42
+ }
43
+ return 0;
44
+ }
45
+ /** Every version of `name` recorded in a `Cargo.lock`, oldest first. */
46
+ export function lockVersions(lockText, name) {
47
+ const want = canonical(name);
48
+ const found = [];
49
+ let current = null;
50
+ for (const raw of lockText.split('\n')) {
51
+ const line = raw.trim();
52
+ if (line === '[[package]]') {
53
+ current = null;
54
+ continue;
55
+ }
56
+ const nameMatch = /^name\s*=\s*"([^"]+)"$/.exec(line);
57
+ if (nameMatch) {
58
+ current = canonical(nameMatch[1]) === want ? want : null;
59
+ continue;
60
+ }
61
+ const versionMatch = /^version\s*=\s*"([^"]+)"$/.exec(line);
62
+ if (versionMatch && current)
63
+ found.push(versionMatch[1]);
64
+ }
65
+ return found.sort(compareVersions);
66
+ }
67
+ /** Directories that never hold a project's own manifest. */
68
+ export const SKIP_DIRS = new Set(['node_modules', '.git', 'target', 'dist', 'build']);
69
+ /** Anything that marks `cwd` as the root of a project rather than a scratch directory. */
70
+ const ROOT_MARKERS = ['.git', 'package.json', 'Cargo.toml', 'cabal.project'];
71
+ /**
72
+ * Immediate child directories of `cwd`, for the one-level-down manifest scan —
73
+ * and EMPTY unless `cwd` is itself a project root.
74
+ *
75
+ * Without that guard the scan reaches into any directory that merely happens to
76
+ * contain projects. `/tmp` is the case that bites: a single unrelated checkout
77
+ * under it would make every lookup run from `/tmp` believe it was in that
78
+ * ecosystem.
79
+ */
80
+ export function childDirs(cwd) {
81
+ if (!ROOT_MARKERS.some(m => fs.existsSync(path.join(cwd, m))))
82
+ return [];
83
+ try {
84
+ return fs
85
+ .readdirSync(cwd, { withFileTypes: true })
86
+ .filter(e => e.isDirectory() && !SKIP_DIRS.has(e.name) && !e.name.startsWith('.'))
87
+ .map(e => path.join(cwd, e.name));
88
+ }
89
+ catch {
90
+ return [];
91
+ }
92
+ }
93
+ /**
94
+ * The `Cargo.lock` governing `cwd`: cargo's own upward walk first, then ONE level
95
+ * down. The step down is the Tauri shape — `package.json` at the repo root and
96
+ * the crate under `src-tauri/` — where the upward walk from the root finds
97
+ * nothing at all.
98
+ */
99
+ export function findLock(cwd) {
100
+ return findAtOrAbove(cwd, 'Cargo.lock');
101
+ }
102
+ /**
103
+ * The first `<dir>/<name>` at or above `cwd`, checking each ancestor's immediate
104
+ * children too.
105
+ *
106
+ * The sideways step is the Tauri shape: `package.json` at the repo root and the
107
+ * crate under `src-tauri/`. Checking children of `cwd` alone is not enough —
108
+ * from the frontend directory `src/`, the crate is in a SIBLING, so the scan has
109
+ * to happen at every level on the way up or the whole project reads as npm-only.
110
+ */
111
+ export function findAtOrAbove(cwd, ...rel) {
112
+ let dir = cwd;
113
+ while (true) {
114
+ const here = path.join(dir, ...rel);
115
+ if (fs.existsSync(here))
116
+ return here;
117
+ for (const child of childDirs(dir)) {
118
+ const candidate = path.join(child, ...rel);
119
+ if (fs.existsSync(candidate))
120
+ return candidate;
121
+ }
122
+ const up = path.dirname(dir);
123
+ if (up === dir)
124
+ return null;
125
+ dir = up;
126
+ }
127
+ }
128
+ /**
129
+ * The version the project pins `name` to. Several is not an error — a workspace
130
+ * legitimately holds two majors of one crate — and the NEWEST is taken, which
131
+ * the answer then states in its `Per <name>@<version>:` header.
132
+ */
133
+ export function lockedVersion(name, cwd) {
134
+ const lock = findLock(cwd);
135
+ if (!lock)
136
+ return null;
137
+ let text;
138
+ try {
139
+ text = fs.readFileSync(lock, 'utf8');
140
+ }
141
+ catch {
142
+ return null;
143
+ }
144
+ const versions = lockVersions(text, crateOf(name));
145
+ return versions.length ? versions[versions.length - 1] : null;
146
+ }
147
+ /**
148
+ * Every crate the lock pins, name to version. Cargo has already resolved these,
149
+ * so unlike an npm range these are exact — and a `cargo update` that moves one
150
+ * is what a cached answer about it has to be dropped on.
151
+ */
152
+ export function lockedDeps(cwd) {
153
+ const lock = findLock(cwd);
154
+ if (!lock)
155
+ return undefined;
156
+ let text;
157
+ try {
158
+ text = fs.readFileSync(lock, 'utf8');
159
+ }
160
+ catch {
161
+ return undefined;
162
+ }
163
+ const out = {};
164
+ let name = null;
165
+ for (const raw of text.split('\n')) {
166
+ const line = raw.trim();
167
+ if (line === '[[package]]') {
168
+ name = null;
169
+ continue;
170
+ }
171
+ const nameMatch = /^name\s*=\s*"([^"]+)"$/.exec(line);
172
+ if (nameMatch) {
173
+ name = nameMatch[1];
174
+ continue;
175
+ }
176
+ const versionMatch = /^version\s*=\s*"([^"]+)"$/.exec(line);
177
+ if (versionMatch && name) {
178
+ // Under BOTH spellings. `lockedVersion` canonicalises `-`/`_` before
179
+ // matching, so a map keyed only on the lockfile's literal name gives a
180
+ // different answer to the same question for `tokio-util` vs
181
+ // `tokio_util` — and the freshness check silently keeps the entry.
182
+ for (const key of new Set([name, canonical(name)])) {
183
+ const existing = out[key];
184
+ if (!existing || compareVersions(versionMatch[1], existing) > 0) {
185
+ out[key] = versionMatch[1];
186
+ }
187
+ }
188
+ }
189
+ }
190
+ return out;
191
+ }
192
+ /** Every `<name>-<version>` directory under a cargo registry checkout root. */
193
+ function registryRoots(cargoHome) {
194
+ const base = path.join(cargoHome, 'registry', 'src');
195
+ try {
196
+ return fs
197
+ .readdirSync(base, { withFileTypes: true })
198
+ .filter(e => e.isDirectory())
199
+ .map(e => path.join(base, e.name));
200
+ }
201
+ catch {
202
+ return [];
203
+ }
204
+ }
205
+ /**
206
+ * `<name>-<version>`, split at the first dash a full `x.y.z` follows.
207
+ *
208
+ * Neither greediness alone works. Splitting at the LAST dash cuts a prerelease in
209
+ * half (`clap-4.0.0-rc.1` → version `rc.1`); splitting at the first dash ANY digit
210
+ * follows cuts the name in half (`md-5-0.10.6` → name `md`), and md-5, sha-1 and
211
+ * utf-8 are all real crates. Requiring three numeric components is what tells the
212
+ * two apart, and it keeps `toml-0.9.12+spec-1.1.0` whole as build metadata.
213
+ */
214
+ const CHECKOUT_DIR_RE = /^(.*?)-(\d+\.\d+\.\d+(?:[-+][^\s]*)?)$/;
215
+ /**
216
+ * Find a crate's checkout directory. Only the NAME half is canonicalised — a
217
+ * crate is written with either separator, but `canonical()` over the whole
218
+ * string turns `tiny-crate-0.1.0` into `tiny_crate_0.1.0` and matches nothing.
219
+ */
220
+ function findSourceDir(roots, crate, version) {
221
+ const want = canonical(crate);
222
+ let best = null;
223
+ for (const root of roots) {
224
+ let entries;
225
+ try {
226
+ entries = fs.readdirSync(root, { withFileTypes: true });
227
+ }
228
+ catch {
229
+ continue;
230
+ }
231
+ for (const entry of entries) {
232
+ if (!entry.isDirectory())
233
+ continue;
234
+ const parts = CHECKOUT_DIR_RE.exec(entry.name);
235
+ if (!parts)
236
+ continue;
237
+ if (canonical(parts[1]) !== want)
238
+ continue;
239
+ const found = parts[2];
240
+ if (version !== undefined && found !== version)
241
+ continue;
242
+ if (!best || compareVersions(found, best.version) > 0) {
243
+ best = {
244
+ dir: path.join(root, entry.name),
245
+ // The DIRECTORY spells the registry's own name. Reporting the
246
+ // caller's spelling instead would give `tiny_crate` and
247
+ // `tiny-crate` two cache rows for one crate.
248
+ name: parts[1],
249
+ version: found
250
+ };
251
+ }
252
+ }
253
+ }
254
+ return best;
255
+ }
256
+ function readmeIn(root) {
257
+ for (const name of ['README.md', 'readme.md', 'README.markdown']) {
258
+ const abs = path.join(root, name);
259
+ if (fs.existsSync(abs))
260
+ return abs;
261
+ }
262
+ const declared = /^readme\s*=\s*"([^"]+)"$/m.exec(safeRead(path.join(root, 'Cargo.toml')) ?? '');
263
+ if (declared) {
264
+ const abs = path.join(root, declared[1]);
265
+ if (fs.existsSync(abs))
266
+ return abs;
267
+ }
268
+ return null;
269
+ }
270
+ function safeRead(file) {
271
+ try {
272
+ return fs.readFileSync(file, 'utf8');
273
+ }
274
+ catch {
275
+ return null;
276
+ }
277
+ }
278
+ /** `src/lib.rs` is the crate's public root; a binary-only crate has `src/main.rs`. */
279
+ function entryIn(root) {
280
+ for (const rel of ['src/lib.rs', 'src/main.rs']) {
281
+ const abs = path.join(root, ...rel.split('/'));
282
+ if (fs.existsSync(abs))
283
+ return abs;
284
+ }
285
+ return null;
286
+ }
287
+ /**
288
+ * Find a crate's source on disk.
289
+ *
290
+ * The lock is consulted first, so a project reading `tokio` gets the `tokio` it
291
+ * builds against rather than the newest copy the machine happens to hold. With
292
+ * no lock in sight — which is where the post-fetch re-resolve arrives, since it
293
+ * is handed the download directory — the newest extracted copy wins.
294
+ */
295
+ export function resolveCrate(name, cwd, dirs) {
296
+ if (!isValidCrateName(name)) {
297
+ throw new ResolveError('invalid_name', `Invalid crate name: "${name}"`);
298
+ }
299
+ const crate = crateOf(name);
300
+ const pinned = lockedVersion(crate, cwd);
301
+ const fetched = path.join(dirs.modulesDir, 'cargo');
302
+ const roots = [...registryRoots(dirs.cargoHome), fetched];
303
+ // A pin that is not on disk is not_installed, NOT an invitation to answer from
304
+ // whatever copy another checkout left behind. Nothing marks that substitution,
305
+ // so the version banner stays empty and the swap reaches the model silently.
306
+ const found = pinned ? findSourceDir(roots, crate, pinned) : findSourceDir(roots, crate);
307
+ if (!found) {
308
+ throw new ResolveError('not_installed', `Crate "${crate}" has no ${pinned ? `v${pinned} ` : ''}source checkout under `
309
+ + `${dirs.cargoHome} or ${fetched}. Run \`cargo fetch\` in the project and retry.`);
310
+ }
311
+ return {
312
+ ecosystem: 'cargo',
313
+ name: found.name,
314
+ version: found.version,
315
+ root: found.dir,
316
+ entry: entryIn(found.dir),
317
+ readme: readmeIn(found.dir)
318
+ };
319
+ }
320
+ /** The newest published version of a crate, or null on any failure. */
321
+ export async function cratesLatest(name, fetchFn, signal) {
322
+ const crate = crateOf(name);
323
+ try {
324
+ const response = await fetchFn(`${CRATES_API}/${encodeURIComponent(crate)}`, {
325
+ headers: { 'user-agent': CRATES_UA, accept: 'application/json' },
326
+ ...(signal ? { signal } : {})
327
+ });
328
+ if (!response.ok)
329
+ return null;
330
+ const body = (await response.json());
331
+ const latest = body.crate?.max_stable_version ?? body.crate?.newest_version;
332
+ if (typeof latest !== 'string' || latest.length === 0)
333
+ return null;
334
+ // The registry's OWN spelling. crates.io's API normalises `-` and `_`, but
335
+ // static.crates.io does not — it answers 403 for the path that does not
336
+ // match what was published, and `use tokio_util::…` is how Rust source
337
+ // spells `tokio-util`.
338
+ const registryName = body.crate?.name ?? body.crate?.id;
339
+ const versions = body.versions ?? [];
340
+ const recent = versions
341
+ .map(v => v.num)
342
+ .filter((n) => typeof n === 'string')
343
+ .slice(0, 10);
344
+ const publishedAt = versions.find(v => v.num === latest)?.created_at;
345
+ return {
346
+ pkg: typeof registryName === 'string' && registryName ? registryName : crate,
347
+ latest,
348
+ recent,
349
+ ...(typeof publishedAt === 'string' ? { publishedAt } : {})
350
+ };
351
+ }
352
+ catch {
353
+ return null;
354
+ }
355
+ }
356
+ export function crateTarballUrl(name, version) {
357
+ const crate = crateOf(name);
358
+ return `${CRATES_DL}/${crate}/${crate}-${version}.crate`;
359
+ }
360
+ // ── surface extraction ──────────────────────────────────────────────────────
361
+ /**
362
+ * Where a Rust declaration begins, so a chunk never splits a signature. Column 0
363
+ * only: `rustSurface` INDENTS the members of an impl or a trait, so an `^\s*`
364
+ * anchor cut every method into its own chunk — an orphan signature with no
365
+ * receiver type, carrying the next method's doc comment.
366
+ */
367
+ export const CARGO_DECL_SPLIT_RE = /^(?:#\[[^\n]*\]\s*)*(?:pub\s+)?(?:async\s+|unsafe\s+|const\s+|extern\s+)*(?:fn|struct|enum|union|trait|type|impl|mod|const|static)\b/m;
368
+ // `macro_rules!` carries its own terminator, so it sits OUTSIDE the `\b` — a word
369
+ // boundary after `!` requires a word character next, and what follows is a space.
370
+ const ITEM_HEAD_RE = /^(?:pub(?:\s*\([^)]*\))?\s+)?(?:default\s+|async\s+|unsafe\s+|const\s+|extern\s+"[^"]*"\s+|extern\s+)*((?:fn|struct|enum|union|trait|impl|mod|type|const|static|use)\b|macro_rules!)/;
371
+ /** A `pub(crate)`/`pub(super)` item is not part of the crate's public surface. */
372
+ function isPublic(head) {
373
+ if (!/^pub\b/.test(head))
374
+ return false;
375
+ const restricted = /^pub\s*\(\s*(crate|super|self|in\s)/.exec(head);
376
+ return restricted === null;
377
+ }
378
+ /**
379
+ * Split one nesting level of Rust source into items.
380
+ *
381
+ * Braces are counted with strings, chars, comments and lifetimes skipped: `"{"`
382
+ * and `'{'` both appear in real source, and a scanner that counts them never
383
+ * finds the end of the item it is in.
384
+ */
385
+ export function splitRustItems(src) {
386
+ const items = [];
387
+ let i = 0;
388
+ let pendingStart = 0;
389
+ let itemStart = -1;
390
+ let depth = 0;
391
+ let headEnd = -1;
392
+ const flush = (end, bodyStart, bodyEnd) => {
393
+ // With no brace the item ended at its `;`, which is not part of the head.
394
+ const head = src.slice(itemStart, headEnd < 0 ? end - 1 : headEnd).trim();
395
+ if (head) {
396
+ items.push({
397
+ pending: src.slice(pendingStart, itemStart).trim(),
398
+ head,
399
+ body: bodyStart < 0 ? null : src.slice(bodyStart, bodyEnd)
400
+ });
401
+ }
402
+ pendingStart = end;
403
+ itemStart = -1;
404
+ headEnd = -1;
405
+ };
406
+ while (i < src.length) {
407
+ const c = src[i];
408
+ if (c === '/' && src[i + 1] === '/') {
409
+ const end = src.indexOf('\n', i);
410
+ i = end < 0 ? src.length : end + 1;
411
+ continue;
412
+ }
413
+ if (c === '/' && src[i + 1] === '*') {
414
+ const end = src.indexOf('*/', i + 2);
415
+ i = end < 0 ? src.length : end + 2;
416
+ continue;
417
+ }
418
+ // `r#"…"#` ends only at a quote followed by the same number of hashes, so
419
+ // a `"` INSIDE one is ordinary text. Treating it as a delimiter re-opens
420
+ // the scanner, desynchronises brace depth, and swallows the rest of the
421
+ // file — Tauri's whole WebviewBuilder surface disappears that way.
422
+ const raw = rawStringEnd(src, i);
423
+ if (raw >= 0) {
424
+ i = raw;
425
+ continue;
426
+ }
427
+ if (c === '"') {
428
+ i = skipString(src, i);
429
+ continue;
430
+ }
431
+ if (c === "'" && isCharLiteral(src, i)) {
432
+ i = skipChar(src, i);
433
+ continue;
434
+ }
435
+ // An attribute belongs to the item BELOW it, so it stays in the pending
436
+ // preamble rather than opening the item — otherwise the `[` starts the
437
+ // head and the item stops looking like a declaration at all.
438
+ if (depth === 0 && itemStart < 0 && c === '#') {
439
+ i = skipAttribute(src, i);
440
+ continue;
441
+ }
442
+ if (depth === 0 && itemStart < 0 && !/\s/.test(c)) {
443
+ itemStart = i;
444
+ }
445
+ if (c === '{') {
446
+ if (depth === 0 && itemStart >= 0 && headEnd < 0)
447
+ headEnd = i;
448
+ depth++;
449
+ i++;
450
+ continue;
451
+ }
452
+ if (c === '}') {
453
+ depth--;
454
+ if (depth === 0 && itemStart >= 0) {
455
+ flush(i + 1, headEnd + 1, i);
456
+ i++;
457
+ continue;
458
+ }
459
+ i++;
460
+ continue;
461
+ }
462
+ if (c === ';' && depth === 0 && itemStart >= 0) {
463
+ flush(i + 1, -1, -1);
464
+ i++;
465
+ continue;
466
+ }
467
+ i++;
468
+ }
469
+ return items;
470
+ }
471
+ /** Step over a whole `#[...]` / `#![...]` attribute, brackets balanced. */
472
+ function skipAttribute(src, i) {
473
+ let j = src[i + 1] === '!' ? i + 2 : i + 1;
474
+ if (src[j] !== '[')
475
+ return i + 1;
476
+ let depth = 0;
477
+ while (j < src.length) {
478
+ if (src[j] === '[')
479
+ depth++;
480
+ else if (src[j] === ']') {
481
+ depth--;
482
+ if (depth === 0)
483
+ return j + 1;
484
+ }
485
+ else if (src[j] === '"') {
486
+ j = skipString(src, j) - 1;
487
+ }
488
+ else {
489
+ const raw = rawStringEnd(src, j);
490
+ if (raw >= 0)
491
+ j = raw - 1;
492
+ }
493
+ j++;
494
+ }
495
+ return src.length;
496
+ }
497
+ /**
498
+ * End index of a raw or byte string starting at `i`, or -1 when one does not.
499
+ * Handles `r"…"`, `r#"…"#`, `r##"…"##`, and the `b`-prefixed byte forms.
500
+ */
501
+ function rawStringEnd(src, i) {
502
+ let j = i;
503
+ if (src[j] === 'b')
504
+ j++;
505
+ if (src[j] !== 'r')
506
+ return -1;
507
+ // A preceding identifier character means this is a name, not a prefix.
508
+ if (i > 0 && /[A-Za-z0-9_]/.test(src[i - 1]))
509
+ return -1;
510
+ j++;
511
+ let hashes = 0;
512
+ while (src[j] === '#') {
513
+ hashes++;
514
+ j++;
515
+ }
516
+ if (src[j] !== '"')
517
+ return -1;
518
+ const close = `"${'#'.repeat(hashes)}`;
519
+ const end = src.indexOf(close, j + 1);
520
+ return end < 0 ? src.length : end + close.length;
521
+ }
522
+ function skipString(src, i) {
523
+ let j = i + 1;
524
+ while (j < src.length) {
525
+ if (src[j] === '\\') {
526
+ j += 2;
527
+ continue;
528
+ }
529
+ if (src[j] === '"')
530
+ return j + 1;
531
+ j++;
532
+ }
533
+ return src.length;
534
+ }
535
+ /** `'a` is a lifetime; `'x'` and `'\n'` are char literals. */
536
+ function isCharLiteral(src, i) {
537
+ if (src[i + 1] === '\\')
538
+ return true;
539
+ return src[i + 2] === "'";
540
+ }
541
+ function skipChar(src, i) {
542
+ let j = i + 1;
543
+ while (j < src.length) {
544
+ if (src[j] === '\\') {
545
+ j += 2;
546
+ continue;
547
+ }
548
+ if (src[j] === "'")
549
+ return j + 1;
550
+ j++;
551
+ }
552
+ return src.length;
553
+ }
554
+ /**
555
+ * Only `///` and attributes survive as an item's preamble. `//!` documents the
556
+ * MODULE, and `rustSurface` emits those once at the top — keeping them here as
557
+ * well printed every file's module doc twice, and three times inside a `mod`.
558
+ */
559
+ function keptPreamble(pending) {
560
+ return pending
561
+ .split('\n')
562
+ .map(l => l.trim())
563
+ .filter(l => l.startsWith('///') || l.startsWith('#['))
564
+ .join('\n');
565
+ }
566
+ /**
567
+ * This module's own `//!` docs: the ones outside every brace.
568
+ *
569
+ * Not a leading RUN — a crate root usually opens with `#![allow(…)]` blocks and
570
+ * only then documents itself, and tokio's 19 KB of module docs sit below four of
571
+ * them. Not the whole file either, or a nested `mod`'s docs are hoisted to the
572
+ * crate root and printed again inside the module.
573
+ */
574
+ function moduleDocOf(src) {
575
+ const kept = [];
576
+ let depth = 0;
577
+ for (const raw of src.split('\n')) {
578
+ const line = raw.trim();
579
+ if (depth === 0 && line.startsWith('//!')) {
580
+ kept.push(line);
581
+ continue;
582
+ }
583
+ // Comment lines are skipped whole: a `//` line may hold an unbalanced
584
+ // brace, and only real code should move the depth.
585
+ if (line.startsWith('//'))
586
+ continue;
587
+ for (const c of raw) {
588
+ if (c === '{')
589
+ depth++;
590
+ else if (c === '}')
591
+ depth--;
592
+ }
593
+ }
594
+ return kept.join('\n');
595
+ }
596
+ /** The type an `impl` block is FOR: after `for` when present, else after `impl`. */
597
+ function implTarget(head) {
598
+ const after = / for\s+([^\s{<]+)/.exec(head) ?? /^impl(?:\s*<[^>]*>)?\s+([^\s{<]+)/.exec(head);
599
+ if (!after)
600
+ return null;
601
+ const parts = after[1].split('::');
602
+ return parts[parts.length - 1] || null;
603
+ }
604
+ /** Type names this block declares WITHOUT `pub`. An impl for one of them is not API. */
605
+ function privateTypeNames(items) {
606
+ const out = new Set();
607
+ for (const item of items) {
608
+ const m = /^(?:pub(?:\s*\([^)]*\))?\s+)?(?:struct|enum|union|trait|type)\s+([A-Za-z_][\w]*)/.exec(item.head);
609
+ if (m && !isPublic(item.head))
610
+ out.add(m[1]);
611
+ }
612
+ return out;
613
+ }
614
+ const BODY_KINDS = new Set(['struct', 'enum', 'union', 'trait', 'impl', 'mod', 'extern']);
615
+ /**
616
+ * `use` braces hold a LIST, not a block. `splitRustItems` hands back the text
617
+ * before the brace and the text inside it, so a re-export has to be put back
618
+ * together — otherwise `pub use crate::runtime::{Runtime, Builder};` is emitted
619
+ * as `pub use crate::runtime::;`, which names nothing and is not even Rust. A
620
+ * third of the `pub use` lines across a real registry are braced, and in most
621
+ * crates `pub use` in lib.rs IS the public API.
622
+ */
623
+ const LIST_KINDS = new Set(['use']);
624
+ /**
625
+ * Reduce Rust source to its public API surface.
626
+ *
627
+ * Function bodies go — they are the bulk of the file and answer no question the
628
+ * docs tool is asked. Everything a caller can name stays: the item head, its doc
629
+ * comment, its attributes, and for a struct or enum its fields and variants.
630
+ *
631
+ * Inside a `trait` every member is public by definition, so the `pub` test is
632
+ * suspended there; anywhere else a bare or `pub(crate)` item is dropped.
633
+ */
634
+ export function rustSurface(src, insideTrait = false, topLevel = true) {
635
+ const out = [];
636
+ const items = splitRustItems(src);
637
+ const privateTypes = privateTypeNames(items);
638
+ // `//!` documents the module, not the item under it, so it survives whether or
639
+ // not the first item does — and only at the top, never again per nested block.
640
+ // Only the `//!` lines before the first item belong to THIS module. Scanning
641
+ // the whole text hoists a nested `mod`'s doc to the crate root and prints it
642
+ // again inside the module.
643
+ const moduleDoc = moduleDocOf(src);
644
+ if (topLevel && moduleDoc)
645
+ out.push(moduleDoc);
646
+ for (const item of items) {
647
+ const headMatch = ITEM_HEAD_RE.exec(item.head);
648
+ if (!headMatch)
649
+ continue;
650
+ const kind = headMatch[1];
651
+ // A `#[macro_export]` macro IS the crate's API — `anyhow::bail!`,
652
+ // `serde_json::json!`. Dropping every macro answered "no such thing"
653
+ // for 714 exported macros across a third of the crates on this box.
654
+ if (kind === 'macro_rules!') {
655
+ if (!/#\[macro_export\]/.test(item.pending))
656
+ continue;
657
+ const preamble = keptPreamble(item.pending);
658
+ out.push(`${preamble ? `${preamble}\n` : ''}${item.head.replace(/\s+/g, ' ').trim()} `
659
+ + '{ /* macro arms elided */ }');
660
+ continue;
661
+ }
662
+ // An `impl` on a type this module keeps private is not reachable from
663
+ // outside it, so publishing its constructor invites a call that cannot
664
+ // compile.
665
+ const target = kind === 'impl' ? implTarget(item.head) : null;
666
+ if (kind === 'impl' && target && privateTypes.has(target))
667
+ continue;
668
+ const keep = insideTrait || kind === 'impl' || isPublic(item.head);
669
+ if (!keep)
670
+ continue;
671
+ const preamble = keptPreamble(item.pending);
672
+ const head = item.head.replace(/\s+/g, ' ').trim();
673
+ const lead = preamble ? `${preamble}\n` : '';
674
+ if (item.body !== null && LIST_KINDS.has(kind)) {
675
+ out.push(`${lead}${head}{${item.body.replace(/\s+/g, ' ').trim()}};`);
676
+ continue;
677
+ }
678
+ if (item.body === null || kind === 'fn') {
679
+ out.push(`${lead}${head};`);
680
+ continue;
681
+ }
682
+ if (BODY_KINDS.has(kind)) {
683
+ // Every method of a trait, and of an impl OF a trait, is public by
684
+ // definition — the `pub` keyword is not written there.
685
+ const membersArePublic = kind === 'trait' || / for /.test(item.head);
686
+ const inner = kind === 'struct' || kind === 'union' ? fieldsOf(item.body, true)
687
+ : kind === 'enum' ? fieldsOf(item.body, false)
688
+ : rustSurface(item.body, membersArePublic);
689
+ out.push(inner ? `${lead}${head} {\n${indent(inner)}\n}` : `${lead}${head} {}`);
690
+ continue;
691
+ }
692
+ out.push(`${lead}${head};`);
693
+ }
694
+ return out.join('\n\n');
695
+ }
696
+ /**
697
+ * A struct's fields or an enum's variants.
698
+ *
699
+ * Split on top-level COMMAS across the whole body, not line by line. A field
700
+ * whose type wraps (`pub map: HashMap<\n String,\n u8,\n>`) or a struct variant
701
+ * (`A { x: u8 }`) spans several lines, and a per-line splitter cuts it at the
702
+ * newline and emits `pub map: HashMap<` — a type that does not exist.
703
+ *
704
+ * A struct field is public only when `isPublic` says so, which rejects
705
+ * `pub(crate)`; an enum variant carries no keyword and is always public.
706
+ */
707
+ function fieldsOf(body, requirePub) {
708
+ const out = [];
709
+ let doc = [];
710
+ for (const field of splitFields(body)) {
711
+ if (field.doc.length && (!requirePub || isPublic(field.text)))
712
+ doc = field.doc;
713
+ else if (field.doc.length)
714
+ doc = [];
715
+ if (requirePub && !isPublic(field.text)) {
716
+ doc = [];
717
+ continue;
718
+ }
719
+ out.push(...doc, `${field.text},`);
720
+ doc = [];
721
+ }
722
+ return out.join('\n').trim();
723
+ }
724
+ const OPENERS = { '<': '>', '(': ')', '[': ']', '{': '}' };
725
+ /**
726
+ * Cut a field or variant list at its top-level commas.
727
+ *
728
+ * Depth is tracked over `<>()[]{}` so `HashMap<String, u8>` stays one field, and
729
+ * `->` is skipped because a return arrow is not a closing generic — counting it
730
+ * drives the depth negative and the rest of the list stops splitting.
731
+ */
732
+ function splitFields(body) {
733
+ const out = [];
734
+ let doc = [];
735
+ let buf = '';
736
+ let depth = 0;
737
+ const flush = () => {
738
+ const text = buf.replace(/\s+/g, ' ').trim();
739
+ buf = '';
740
+ if (!text) {
741
+ doc = [];
742
+ return;
743
+ }
744
+ out.push({ doc, text });
745
+ doc = [];
746
+ };
747
+ for (const raw of body.split('\n')) {
748
+ const line = raw.trim();
749
+ if (depth === 0 && buf.trim() === '') {
750
+ if (line.startsWith('///')) {
751
+ doc.push(line);
752
+ continue;
753
+ }
754
+ if (line.startsWith('//') || line.startsWith('#['))
755
+ continue;
756
+ }
757
+ for (let i = 0; i < line.length; i++) {
758
+ const c = line[i];
759
+ if (OPENERS[c])
760
+ depth++;
761
+ else if (c === '>' && line[i - 1] !== '-')
762
+ depth--;
763
+ else if (c === ')' || c === ']' || c === '}')
764
+ depth--;
765
+ else if (c === ',' && depth === 0) {
766
+ flush();
767
+ continue;
768
+ }
769
+ buf += c;
770
+ }
771
+ buf += '\n';
772
+ }
773
+ flush();
774
+ return out;
775
+ }
776
+ function indent(s) {
777
+ return s
778
+ .split('\n')
779
+ .map(l => (l.length ? ` ${l}` : l))
780
+ .join('\n');
781
+ }
782
+ export function isRustFile(name) {
783
+ return name.endsWith('.rs');
784
+ }
785
+ /** The `[package] name` of a cargo project, for labelling its own source. */
786
+ export function cargoProjectName(cwd) {
787
+ const text = safeRead(path.join(cwd, 'Cargo.toml'));
788
+ if (!text)
789
+ return null;
790
+ const section = /\[package\]([\s\S]*?)(?:\n\[|$)/.exec(text);
791
+ const match = /^\s*name\s*=\s*"([^"]+)"/m.exec(section?.[1] ?? '');
792
+ return match ? match[1] : null;
793
+ }