@mjasnikovs/pi-task 0.39.5 → 0.40.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 +18 -3
- package/dist/task/context-attribution.js +18 -6
- package/dist/task/external-context.d.ts +8 -1
- package/dist/task/external-context.js +52 -4
- package/dist/task/phases.js +2 -1
- package/dist/task/prompts.d.ts +7 -1
- package/dist/task/prompts.js +14 -4
- package/dist/workers/docs-cache.js +50 -3
- package/dist/workers/docs-chunk.d.ts +6 -3
- package/dist/workers/docs-chunk.js +8 -5
- package/dist/workers/docs-core.d.ts +27 -3
- package/dist/workers/docs-core.js +104 -41
- package/dist/workers/docs-ecosystems.d.ts +173 -0
- package/dist/workers/docs-ecosystems.js +449 -0
- package/dist/workers/docs-index.d.ts +2 -1
- package/dist/workers/docs-index.js +55 -27
- package/dist/workers/docs-project.d.ts +10 -0
- package/dist/workers/docs-project.js +86 -24
- package/dist/workers/docs-resolve.d.ts +6 -1
- package/dist/workers/docs-resolve.js +4 -3
- package/dist/workers/docs-retrieve.d.ts +2 -0
- package/dist/workers/docs-retrieve.js +11 -11
- package/dist/workers/eco-cargo.d.ts +115 -0
- package/dist/workers/eco-cargo.js +793 -0
- package/dist/workers/eco-hackage.d.ts +93 -0
- package/dist/workers/eco-hackage.js +508 -0
- package/dist/workers/npm-version.d.ts +5 -3
- package/dist/workers/npm-version.js +6 -4
- package/dist/workers/pi-worker-docs.d.ts +18 -4
- package/dist/workers/pi-worker-docs.js +57 -19
- package/dist/workers/research-cache.d.ts +2 -13
- package/dist/workers/research-cache.js +22 -46
- package/dist/workers/shared.d.ts +16 -5
- package/dist/workers/shared.js +0 -0
- 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
|
+
}
|