graph-indexer 3.0.1 → 3.1.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.
Files changed (52) hide show
  1. package/CHANGELOG.md +404 -0
  2. package/README.md +61 -8
  3. package/bin/graph-indexer.mjs +46 -5
  4. package/integrations/claude-code/.claude-plugin/plugin.json +1 -1
  5. package/integrations/claude-code/agents/code-structure.md +0 -3
  6. package/integrations/claude-code/skills/setup/SKILL.md +26 -0
  7. package/integrations/claude-code-local/.claude-plugin/plugin.json +9 -0
  8. package/integrations/{claude-code → claude-code-local}/.mcp.json +1 -1
  9. package/integrations/claude-code-local/README.md +10 -0
  10. package/integrations/claude-code-local/agents/code-structure.md +22 -0
  11. package/integrations/{claude-code → claude-code-local}/hooks/gi-hook.mjs +3 -1
  12. package/package.json +1 -1
  13. package/src/cli/helper.mjs +15 -1
  14. package/src/cli/hook.mjs +123 -41
  15. package/src/cli/init.mjs +16 -1
  16. package/src/cli/resident.mjs +1 -1
  17. package/src/index/indexer.mjs +77 -41
  18. package/src/index/modules.mjs +76 -2
  19. package/src/index/parse-worker.mjs +12 -0
  20. package/src/index/parsepool.mjs +73 -0
  21. package/src/index/resolver.mjs +362 -23
  22. package/src/mcp/server.mjs +16 -12
  23. package/src/mcp/tools.mjs +417 -25
  24. package/src/parse/arity.mjs +39 -2
  25. package/src/parse/extract.mjs +269 -45
  26. package/src/parse/lang/ecma.mjs +2 -0
  27. package/src/parse/lang/go.mjs +2 -0
  28. package/src/parse/lang/jvm.mjs +33 -0
  29. package/src/parse/lang/markdown.mjs +92 -0
  30. package/src/parse/lang/python.mjs +43 -0
  31. package/src/parse/lang/rust.mjs +215 -15
  32. package/src/parse/lang/sfc.mjs +125 -0
  33. package/src/parse/languages.mjs +9 -2
  34. package/src/query/check.mjs +49 -13
  35. package/src/query/facts.mjs +3 -2
  36. package/src/query/injection-spring.mjs +405 -0
  37. package/src/query/injection.mjs +173 -0
  38. package/src/query/intel.mjs +76 -14
  39. package/src/query/issue.mjs +156 -0
  40. package/src/query/orm.mjs +286 -0
  41. package/src/query/read.mjs +6 -1
  42. package/src/query/report.mjs +53 -0
  43. package/src/query/routes.mjs +142 -0
  44. package/src/query/showcase.mjs +59 -0
  45. package/src/search/search.mjs +85 -17
  46. package/src/search/semantic.mjs +201 -0
  47. package/src/search/vectors.mjs +122 -0
  48. package/src/store/db.mjs +22 -4
  49. package/vendor/grammars/README.md +47 -0
  50. package/vendor/grammars/kotlin.wasm.gz +0 -0
  51. package/vendor/grammars/manifest.json +4 -4
  52. /package/integrations/{claude-code → claude-code-local}/hooks/hooks.json +0 -0
@@ -19,6 +19,7 @@ import { extractFile } from '../parse/extract.mjs';
19
19
  import { specForPath, LANGUAGES } from '../parse/languages.mjs';
20
20
  import { tokenString, codeTokens } from '../search/tokenize.mjs';
21
21
  import { indexFingerprint } from './fingerprint.mjs';
22
+ import { ParsePool, poolSize } from './parsepool.mjs';
22
23
 
23
24
  const SPECS = Object.fromEntries(LANGUAGES.map(l => [l.id, l]));
24
25
  const TYPE_KINDS = new Set(['class', 'interface', 'struct', 'enum', 'trait', 'type', 'object', 'module', 'impl']);
@@ -59,7 +60,7 @@ export class Indexer {
59
60
  this.resolveAll = s.getMeta('resolve_pending') === '1';
60
61
  const files = s.all('SELECT id, path, lang, package, is_test FROM files');
61
62
  for (const f of files) this.table.addFile({ id: f.id, path: f.path, lang: f.lang, package: f.package, isTest: !!f.is_test });
62
- for (const r of s.all('SELECT id, file_id, name, qname, kind, parent_id, owner, type, bases, exported, is_static, start_line, end_line FROM symbols')) {
63
+ for (const r of s.all('SELECT id, file_id, name, qname, kind, parent_id, owner, type, bases, decorators, exported, is_static, start_line, end_line FROM symbols')) {
63
64
  this.table.addSym(rowToSym(r));
64
65
  }
65
66
  // the interfaces a Go type satisfies implicitly are stored only as `~structural` edges
@@ -80,6 +81,7 @@ export class Indexer {
80
81
  for (const r of s.all('SELECT file_id, owner_qname, name, type FROM fields')) (fields.get(r.file_id) ?? fields.set(r.file_id, []).get(r.file_id)).push(r);
81
82
  for (const [fid, rows] of fields) this.table.setFieldTypes(fid, rows);
82
83
  this.modules = new ModuleResolver(this.root, files.map(f => f.path));
84
+ this.resolver.modules = this.modules;
83
85
  this.loaded = true;
84
86
  }
85
87
 
@@ -152,7 +154,7 @@ export class Indexer {
152
154
  for (const rel of removed) {
153
155
  const f = s.get('SELECT id FROM files WHERE path = ?', rel);
154
156
  if (!f) continue;
155
- for (const r of s.all('SELECT id FROM symbols WHERE file_id = ?', f.id)) { removedSymIds.push(r.id); s.run('DELETE FROM fts WHERE rowid = ?', r.id); }
157
+ for (const r of s.all('SELECT id FROM symbols WHERE file_id = ?', f.id)) { removedSymIds.push(r.id); s.run('DELETE FROM fts WHERE rowid = ?', r.id); s.run('DELETE FROM doc_fts WHERE rowid = ?', r.id); }
156
158
  s.run('DELETE FROM file_fts WHERE rowid = ?', f.id);
157
159
  for (const t of ['symbols', 'refs', 'imports', 'fields']) s.run(`DELETE FROM ${t} WHERE file_id = ?`, f.id);
158
160
  s.run('DELETE FROM files WHERE id = ?', f.id);
@@ -162,17 +164,20 @@ export class Indexer {
162
164
  });
163
165
  }
164
166
 
165
- // parse changed/new files (async extraction), write in batches
167
+ // parse changed/new files, write in batches; with many files a pool of threads parses the
168
+ // next batch while this one is written
166
169
  if (toParse.length || removed.length) s.setMeta('resolve_pending', '1');
167
170
  const BATCH = 200;
168
- let done = 0;
169
- for (let i = 0; i < toParse.length; i += BATCH) {
170
- const batch = toParse.slice(i, i + BATCH);
171
- const parsed = [];
171
+ const threads = poolSize(toParse.length);
172
+ const pool = threads ? new ParsePool(threads) : null;
173
+ // a file the parser could not read at all is one unreadable region, so no answer calls it complete
174
+ const failed = (e, rel, source) => { this.log(`parse failed for ${rel}: ${e.message}`); return { symbols: [], refs: [], imports: [], fields: [], fileInfo: null, errors: 1, errorLines: [[1, source.split('\n').length]] }; };
175
+ // read and hash a batch now; its extraction starts now with the pool, or when awaited without
176
+ const start = (batch) => {
177
+ const items = [];
172
178
  for (const item of batch) {
173
- const abs = path.join(this.root, item.rel);
174
179
  let source;
175
- try { source = fs.readFileSync(abs, 'utf8'); } catch { continue; }
180
+ try { source = fs.readFileSync(path.join(this.root, item.rel), 'utf8'); } catch { continue; }
176
181
  const hash = sha1(source);
177
182
  if (item.prev && item.prev.hash === hash) {
178
183
  s.run('UPDATE files SET size = ?, mtime_ms = ? WHERE id = ?', item.st.size, Math.floor(item.st.mtimeMs), item.prev.id);
@@ -181,26 +186,48 @@ export class Indexer {
181
186
  }
182
187
  const spec = specForPath(item.rel);
183
188
  if (!spec) continue;
184
- let extraction;
185
- if (looksMinified(source)) extraction = { symbols: [], refs: [], imports: [], fields: [], fileInfo: null, errors: 0 };
186
- else {
187
- try { extraction = await extractFile(spec, source, item.rel); }
188
- catch (e) { this.log(`parse failed for ${item.rel}: ${e.message}`); extraction = { symbols: [], refs: [], imports: [], fields: [], fileInfo: null, errors: 1 }; }
189
- }
190
- parsed.push({ ...item, source, hash, spec, extraction });
189
+ const minified = looksMinified(source);
190
+ const inline = () => extractFile(spec, source, item.rel).catch(e => failed(e, item.rel, source));
191
+ const job = minified ? null : pool ? pool.extract(item.rel, source).catch(inline) : null;
192
+ items.push({ ...item, source, hash, spec, minified, job });
191
193
  }
192
- s.tx(() => {
193
- for (const p of parsed) {
194
- const r = this.#writeFile(p);
195
- touchedFileIds.push(r.fileId);
196
- removedSymIds.push(...r.removedIds);
197
- for (const n of r.addedNames) addedNames.add(n);
198
- if (p.prev) changed++; else added++;
194
+ return items;
195
+ };
196
+ let done = 0;
197
+ try {
198
+ // with the pool, a few batches are in flight so no thread waits; sources held stay bounded
199
+ const ahead = pool ? 4 : 0;
200
+ const queued = [];
201
+ let started = 0;
202
+ const fill = () => { while (started < toParse.length && queued.length <= ahead) { queued.push(start(toParse.slice(started, started + BATCH))); started += BATCH; } };
203
+ for (let i = 0; i < toParse.length; i += BATCH) {
204
+ fill();
205
+ const items = queued.shift();
206
+ fill();
207
+ const parsed = [];
208
+ for (const it of items) {
209
+ let extraction;
210
+ if (it.minified) extraction = { symbols: [], refs: [], imports: [], fields: [], fileInfo: null, errors: 0 };
211
+ else if (it.job) extraction = await it.job;
212
+ else {
213
+ try { extraction = await extractFile(it.spec, it.source, it.rel); }
214
+ catch (e) { extraction = failed(e, it.rel, it.source); }
215
+ }
216
+ parsed.push({ rel: it.rel, st: it.st, prev: it.prev, source: it.source, hash: it.hash, spec: it.spec, extraction });
199
217
  }
200
- });
201
- done += batch.length;
202
- onProgress?.(done, toParse.length);
203
- }
218
+ s.tx(() => {
219
+ for (const p of parsed) {
220
+ const r = this.#writeFile(p);
221
+ touchedFileIds.push(r.fileId);
222
+ removedSymIds.push(...r.removedIds);
223
+ for (const n of r.addedNames) addedNames.add(n);
224
+ if (p.prev) changed++; else added++;
225
+ }
226
+ });
227
+ done += Math.min(BATCH, toParse.length - i);
228
+ onProgress?.(done, toParse.length);
229
+ }
230
+ } finally { await pool?.close(); }
204
231
 
205
232
  // resolve: refs of touched files + refs that pointed at removed symbols + refs that may bind to new names
206
233
  this.resolver.resetMemo();
@@ -235,7 +262,7 @@ export class Indexer {
235
262
 
236
263
  #writeFile({ rel, st, prev, source, hash, spec, extraction }) {
237
264
  const s = this.store;
238
- const { symbols, refs, imports, fields, fileInfo, errors } = extraction;
265
+ const { symbols, refs, imports, fields, fileInfo, errors, errorLines } = extraction;
239
266
  const lines = lineStarts(source);
240
267
  const isTest = spec.testFile?.test(rel) ? 1 : 0;
241
268
  const pkg = fileInfo?.package ?? null;
@@ -244,15 +271,15 @@ export class Indexer {
244
271
  if (prev) {
245
272
  fileId = prev.id;
246
273
  oldSyms = s.all('SELECT id, qname, kind, ordinal FROM symbols WHERE file_id = ?', fileId);
247
- for (const o of oldSyms) s.run('DELETE FROM fts WHERE rowid = ?', o.id);
274
+ for (const o of oldSyms) { s.run('DELETE FROM fts WHERE rowid = ?', o.id); s.run('DELETE FROM doc_fts WHERE rowid = ?', o.id); }
248
275
  s.run('DELETE FROM file_fts WHERE rowid = ?', fileId);
249
276
  for (const t of ['symbols', 'refs', 'imports', 'fields']) s.run(`DELETE FROM ${t} WHERE file_id = ?`, fileId);
250
- s.run('UPDATE files SET lang = ?, size = ?, mtime_ms = ?, hash = ?, lines = ?, package = ?, is_test = ?, parse_errors = ?, indexed_at = ? WHERE id = ?',
251
- spec.id, st.size, Math.floor(st.mtimeMs), hash, lines.length, pkg, isTest, errors, Date.now(), fileId);
277
+ s.run('UPDATE files SET lang = ?, size = ?, mtime_ms = ?, hash = ?, lines = ?, package = ?, is_test = ?, parse_errors = ?, parse_error_lines = ?, indexed_at = ? WHERE id = ?',
278
+ spec.id, st.size, Math.floor(st.mtimeMs), hash, lines.length, pkg, isTest, errors, errorLines?.length ? JSON.stringify(errorLines) : null, Date.now(), fileId);
252
279
  this.table.removeFile(fileId);
253
280
  } else {
254
- const r = s.run('INSERT INTO files (path, lang, size, mtime_ms, hash, lines, package, is_test, parse_errors, indexed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
255
- rel, spec.id, st.size, Math.floor(st.mtimeMs), hash, lines.length, pkg, isTest, errors, Date.now());
281
+ const r = s.run('INSERT INTO files (path, lang, size, mtime_ms, hash, lines, package, is_test, parse_errors, parse_error_lines, indexed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
282
+ rel, spec.id, st.size, Math.floor(st.mtimeMs), hash, lines.length, pkg, isTest, errors, errorLines?.length ? JSON.stringify(errorLines) : null, Date.now());
256
283
  fileId = Number(r.lastInsertRowid);
257
284
  }
258
285
  this.table.addFile({ id: fileId, path: rel, lang: spec.id, package: pkg, isTest: !!isTest });
@@ -281,10 +308,18 @@ export class Indexer {
281
308
  const id = reuse ?? Number(r.lastInsertRowid);
282
309
  ids[i] = id;
283
310
  if (reuse != null) reused.add(reuse); else addedNames.push(sym.name);
284
- this.table.addSym({ id, fileId, name: sym.name, qname: sym.qname, kind: sym.kind, parentId, owner: sym.owner, type: sym.type, bases: sym.bases ?? [], exported: !!sym.exported, isStatic: !!sym.isStatic, startLine: sym.startLine, endLine: sym.endLine });
311
+ this.table.addSym({ id, fileId, name: sym.name, qname: sym.qname, kind: sym.kind, parentId, owner: sym.owner, type: sym.type, bases: sym.bases ?? [], exported: !!sym.exported, isStatic: !!sym.isStatic, startLine: sym.startLine, endLine: sym.endLine, byRef: !!sym.decorators?.[0]?.startsWith('impl &') });
285
312
  }
286
- // FTS documents
287
- for (let i = 0; i < symbols.length; i++) {
313
+ // FTS documents; documentation goes to its own table and has no file document
314
+ if (spec.docs) {
315
+ for (let i = 0; i < symbols.length; i++) {
316
+ const sym = symbols[i];
317
+ const a = lines[sym.startLine - 1] ?? 0;
318
+ const b = sym.endLine >= lines.length ? source.length : lines[sym.endLine];
319
+ s.run('INSERT INTO doc_fts (rowid, name, body) VALUES (?, ?, ?)', ids[i], tokenString(sym.qname.replace(/ › /g, ' ')), codeTokens(source.slice(a, b), { maxTokens: BODY_TOKEN_CAP }).join(' '));
320
+ }
321
+ }
322
+ for (let i = 0; i < symbols.length && !spec.docs; i++) {
288
323
  const sym = symbols[i];
289
324
  let body;
290
325
  if (TYPE_KINDS.has(sym.kind)) {
@@ -305,16 +340,16 @@ export class Indexer {
305
340
  ids[i], tokenString(sym.name), tokenString(sym.qname), tokenString(sym.sig ?? ''), tokenString(sym.doc ?? ''), pathTokens, body);
306
341
  }
307
342
  // file document (hierarchical ranking: which files talk about the query at all)
308
- s.run('INSERT INTO file_fts (rowid, path, body) VALUES (?, ?, ?)', fileId, pathTokens, codeTokens(source, { maxTokens: FILE_TOKEN_CAP }).join(' '));
343
+ if (!spec.docs) s.run('INSERT INTO file_fts (rowid, path, body) VALUES (?, ?, ?)', fileId, pathTokens, codeTokens(source, { maxTokens: FILE_TOKEN_CAP }).join(' '));
309
344
  // refs (unresolved for now)
310
345
  for (const r of refs) {
311
- s.run('INSERT INTO refs (file_id, src_id, name, kind, line, col, recv, recv_type, argc) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
312
- fileId, r.symIdx >= 0 ? ids[r.symIdx] : null, r.name, r.kind, r.line, r.col, r.recv || null, r.recvType || null, r.argc ?? null);
346
+ s.run('INSERT INTO refs (file_id, src_id, name, kind, line, col, recv, recv_type, argc, arg_types) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
347
+ fileId, r.symIdx >= 0 ? ids[r.symIdx] : null, r.name, r.kind, r.line, r.col, r.recv || null, r.recvType || null, r.argc ?? null, r.argTypes ? JSON.stringify(r.argTypes) : null);
313
348
  }
314
349
  // imports (module resolution needs only the file list)
315
350
  const impRows = [];
316
351
  for (const imp of imports) {
317
- const target = this.modules.resolve(spec.id, rel, imp.source);
352
+ const target = this.modules.resolveImport(spec.id, rel, imp);
318
353
  let targetFileId = null;
319
354
  if (target?.file) targetFileId = s.get('SELECT id FROM files WHERE path = ?', target.file)?.id ?? null;
320
355
  const row = { source: imp.source ?? null, imported: imp.imported ?? null, local: imp.local ?? null, reexport: imp.reexport ?? null, wildcard: imp.wildcard ? 1 : 0, line: imp.line ?? null, target_file_id: targetFileId, target_dir: target?.dir ?? null };
@@ -446,7 +481,7 @@ export class Indexer {
446
481
  s.tx(() => {
447
482
  for (let i = 0; i < refIds.length; i += 900) {
448
483
  const chunk = refIds.slice(i, i + 900);
449
- const rows = s.all(`SELECT id, file_id, src_id, name, kind, recv, recv_type, argc FROM refs WHERE id IN (${chunk.map(() => '?').join(',')})`, ...chunk);
484
+ const rows = s.all(`SELECT id, file_id, src_id, name, kind, recv, recv_type, argc, arg_types FROM refs WHERE id IN (${chunk.map(() => '?').join(',')})`, ...chunk);
450
485
  for (const r of rows) {
451
486
  const res = this.resolver.resolve(r);
452
487
  if (res.id == null && (r.kind === 'value' || r.kind === 'read' || r.kind === 'type' || res.external)) { s.run('DELETE FROM refs WHERE id = ?', r.id); continue; }
@@ -465,6 +500,7 @@ function rowToSym(r) {
465
500
  return {
466
501
  id: r.id, fileId: r.file_id, name: r.name, qname: r.qname, kind: r.kind, parentId: r.parent_id ?? null, owner: r.owner ?? null,
467
502
  type: r.type ?? null, bases: r.bases ? JSON.parse(r.bases) : [], exported: !!r.exported, isStatic: !!r.is_static, startLine: r.start_line, endLine: r.end_line,
503
+ byRef: r.decorators ? /^\["impl &/.test(r.decorators) : false,
468
504
  };
469
505
  }
470
506
 
@@ -70,6 +70,32 @@ export class ModuleResolver {
70
70
  const scope = this.#tsScope(rel);
71
71
  if (scope) this.tsScopes.set(dir, scope);
72
72
  }
73
+ // package.json "imports" (subpath imports, `#/*` → `./src/*`): aliases of that package's files
74
+ for (const [, { dir, json }] of this.packages) {
75
+ const imp = json.imports;
76
+ if (!imp || typeof imp !== 'object') continue;
77
+ const at = (p) => path.posix.normalize(dir === '.' ? p : `${dir}/${p}`);
78
+ const target = (v) => typeof v === 'string' ? v : (v && typeof v === 'object' ? (v.types ?? v.import ?? v.default ?? v.node ?? null) : null);
79
+ const paths = Object.entries(imp).map(([pat, v]) => [pat, target(v)]).filter(([pat, t]) => typeof t === 'string' && t.startsWith('.') && pat.startsWith('#'))
80
+ .map(([pat, t]) => ({ pat, targets: [at(t)] }));
81
+ if (!paths.length) continue;
82
+ const scope = this.tsScopes.get(dir);
83
+ this.tsScopes.set(dir, scope ? { ...scope, paths: [...scope.paths, ...paths] } : { paths, baseUrl: null });
84
+ }
85
+ // SvelteKit: `$lib` (and `kit.alias`) map into the project; they live in a generated
86
+ // .svelte-kit/tsconfig.json that a repository does not carry, so read svelte.config itself
87
+ for (const rel of [...this.#findManifests('svelte.config.js'), ...this.#findManifests('svelte.config.ts')]) {
88
+ const dir = path.posix.dirname(rel), at = (p) => path.posix.normalize(dir === '.' ? p : `${dir}/${p}`);
89
+ let text = '';
90
+ try { text = fs.readFileSync(path.join(this.root, rel), 'utf8'); } catch { /* unreadable */ }
91
+ const lib = /\bfiles\s*:\s*\{[^}]*\blib\s*:\s*['"]([^'"]+)['"]/.exec(text)?.[1] ?? 'src/lib';
92
+ const aliases = [['$lib', lib]];
93
+ const block = /\balias\s*:\s*\{([^}]*)\}/.exec(text)?.[1] ?? '';
94
+ for (const m of block.matchAll(/['"]?([$@#~\w/-]+?)(?:\/\*)?['"]?\s*:\s*['"]([^'"]+?)(?:\/\*)?['"]/g)) aliases.push([m[1], m[2]]);
95
+ const paths = aliases.flatMap(([a, t]) => [{ pat: a, targets: [at(t)] }, { pat: `${a}/*`, targets: [`${at(t)}/*`] }]);
96
+ const scope = this.tsScopes.get(dir);
97
+ this.tsScopes.set(dir, scope ? { ...scope, paths: [...scope.paths, ...paths] } : { paths, baseUrl: null });
98
+ }
73
99
  // Go module path
74
100
  this.goModules = [];
75
101
  for (const rel of this.#findManifests('go.mod')) {
@@ -79,6 +105,34 @@ export class ModuleResolver {
79
105
  } catch { /* unreadable */ }
80
106
  }
81
107
  this.goModules.sort((a, b) => b.module.length - a.module.length);
108
+ // Rust crates of the repository (a workspace's members, or one package): the name other
109
+ // crates, tests and examples use for each library (`serde_json::Value`, `tokio::sync`)
110
+ this.rustCrates = new Map();
111
+ this.rustPackages = [];
112
+ for (const rel of this.#findManifests('Cargo.toml')) {
113
+ let text;
114
+ try { text = fs.readFileSync(path.join(this.root, rel), 'utf8'); } catch { continue; }
115
+ const dir = path.posix.dirname(rel), at = (p) => (dir === '.' ? p : `${dir}/${p}`);
116
+ if (/^\[package\]\s*$/m.test(text)) this.rustPackages.push(dir);
117
+ const table = (name) => { const m = new RegExp(`^\\[${name}\\]\\s*$([\\s\\S]*?)(?=^\\[|(?![\\s\\S]))`, 'm').exec(text); return m?.[1] ?? ''; };
118
+ const key = (body, k) => new RegExp(`^\\s*${k}\\s*=\\s*"([^"]+)"`, 'm').exec(body)?.[1];
119
+ const pkg = key(table('package'), 'name'), lib = table('lib');
120
+ const libFile = path.posix.normalize(at(key(lib, 'path') ?? 'src/lib.rs'));
121
+ if (!pkg || !this.files.has(libFile)) continue;
122
+ this.rustCrates.set((key(lib, 'name') ?? pkg).replace(/-/g, '_'), libFile);
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Resolve one import (`imp.source`, `imp.imported`). In Rust, `use a::b::c` resolves to the
128
+ * module `c` when c is a module (a file `c.rs` or `c/mod.rs`), not to the module that declares it.
129
+ */
130
+ resolveImport(lang, fromFile, imp) {
131
+ if (lang === 'rust' && imp.imported && !['self', '*', 'super', 'crate'].includes(imp.imported) && /^[a-z_][a-z0-9_]*$/.test(imp.imported)) {
132
+ const sub = this.resolve(lang, fromFile, imp.source ? `${imp.source}::${imp.imported}` : imp.imported);
133
+ if (sub?.file && new RegExp(`(^|/)${imp.imported}(\\.rs|/mod\\.rs)$`).test(sub.file)) return sub;
134
+ }
135
+ return this.resolve(lang, fromFile, imp.source);
82
136
  }
83
137
 
84
138
  /** compilerOptions of a tsconfig file, following relative `extends` (a child's paths replace its parent's). */
@@ -178,7 +232,7 @@ export class ModuleResolver {
178
232
  if (!source) return null;
179
233
  const fromDir = path.posix.dirname(fromFile);
180
234
  switch (lang) {
181
- case 'javascript': case 'typescript': case 'tsx': return this.#resolveEcma(fromDir, source);
235
+ case 'javascript': case 'typescript': case 'tsx': case 'vue': case 'svelte': return this.#resolveEcma(fromDir, source);
182
236
  case 'python': return this.#resolvePython(fromDir, source);
183
237
  case 'go': return this.#resolveGo(source);
184
238
  case 'c': case 'cpp': return this.#resolveInclude(fromDir, source);
@@ -247,7 +301,10 @@ export class ModuleResolver {
247
301
  const rel = mod.replace(/\./g, '/');
248
302
  const idx = this.#suffixIndex();
249
303
  const cands = [...(idx.get(rel + '.py') ?? []), ...(idx.get(rel + '/__init__.py') ?? [])]
250
- .filter(f => f === rel + '.py' || f === rel + '/__init__.py' || f.endsWith('/' + rel + '.py') || f.endsWith('/' + rel + '/__init__.py'));
304
+ .filter(f => f === rel + '.py' || f === rel + '/__init__.py' || f.endsWith('/' + rel + '.py') || f.endsWith('/' + rel + '/__init__.py'))
305
+ // an absolute import starts at an import root: `import json` is not `flask/json/`, since
306
+ // `flask/` is a package (it has an __init__.py) and only reaches it as `flask.json`
307
+ .filter(f => { const root = f.slice(0, f.length - (f.endsWith('/__init__.py') ? rel.length + 12 : rel.length + 3)).replace(/\/$/, ''); return !root || !this.files.has(root + '/__init__.py'); });
251
308
  if (cands.length) {
252
309
  cands.sort((a, b) => a.length - b.length);
253
310
  const f = cands[0];
@@ -305,16 +362,33 @@ export class ModuleResolver {
305
362
  if (segs[0] === 'crate') { baseDir = crateRoot; segs.shift(); }
306
363
  else if (segs[0] === 'self') { baseDir = this.#rustModDir(fromFile); segs.shift(); }
307
364
  else if (segs[0] === 'super') { baseDir = path.posix.dirname(this.#rustModDir(fromFile)); segs.shift(); while (segs[0] === 'super') { baseDir = path.posix.dirname(baseDir); segs.shift(); } }
365
+ else if (this.rustCrates?.has(segs[0])) {
366
+ // another crate of the repository, by name; its root module is its lib.rs
367
+ const lib = this.rustCrates.get(segs.shift());
368
+ if (!segs.length) return { file: lib };
369
+ baseDir = path.posix.dirname(lib);
370
+ }
371
+ // a child module of this one (2018 paths: `mod runtime; pub use runtime::Runtime;`)
372
+ else if (['.rs', '/mod.rs'].some(e => this.files.has(path.posix.join(this.#rustModDir(fromFile), segs[0]) + e))) baseDir = this.#rustModDir(fromFile);
308
373
  else return null; // external crate
309
374
  for (let n = segs.length; n > 0; n--) {
310
375
  const p = path.posix.join(baseDir, ...segs.slice(0, n));
311
376
  if (this.files.has(p + '.rs')) return { file: p + '.rs' };
312
377
  if (this.files.has(p + '/mod.rs')) return { file: p + '/mod.rs' };
313
378
  }
379
+ // the module itself: `dir/mod.rs`, or `dir.rs` next to its directory (2018 layout)
380
+ if (this.files.has(baseDir + '.rs') && !['lib.rs', 'main.rs', 'mod.rs'].some(f => this.files.has(path.posix.join(baseDir, f)))) return { file: baseDir + '.rs' };
314
381
  const lib = ['lib.rs', 'main.rs', 'mod.rs'].map(f => path.posix.join(baseDir, f)).find(f => this.files.has(f));
315
382
  return lib ? { file: lib } : null;
316
383
  }
317
384
 
385
+ /** The directory of the Cargo package a file belongs to (its nearest `Cargo.toml` with a `[package]`), or null. */
386
+ rustPackageOf(file) {
387
+ let best = null;
388
+ for (const d of this.rustPackages ?? []) if ((d === '.' || file.startsWith(d + '/')) && (best == null || d.length > best.length)) best = d;
389
+ return best;
390
+ }
391
+
318
392
  #rustCrateRoot(fromFile) {
319
393
  let d = path.posix.dirname(fromFile);
320
394
  while (d && d !== '.') {
@@ -0,0 +1,12 @@
1
+ /** A parsing thread of the ParsePool: extracts one file per message (src/index/parsepool.mjs). */
2
+ import { parentPort } from 'node:worker_threads';
3
+ import { extractFile } from '../parse/extract.mjs';
4
+ import { specForPath } from '../parse/languages.mjs';
5
+
6
+ parentPort.on('message', async ({ id, rel, source }) => {
7
+ try {
8
+ parentPort.postMessage({ id, extraction: await extractFile(specForPath(rel), source, rel) });
9
+ } catch (e) {
10
+ parentPort.postMessage({ id, error: e.message });
11
+ }
12
+ });
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Parsing on several threads. Extraction (tree-sitter parse + queries) is most of a full index's
3
+ * time and each file is independent, so a pool of worker threads parses while the main thread
4
+ * writes the previous batch to SQLite. Each worker loads the WASM runtime and the grammars it meets
5
+ * once; results come back by structured clone, the same objects `extractFile` returns.
6
+ *
7
+ * A file the pool fails on is parsed again inline by the caller, so a thread error never loses it.
8
+ * Only worth it for many files: small updates (a watcher event, a query-time check) parse inline.
9
+ * GRAPH_INDEXER_WORKERS sets the number of threads (0 turns the pool off).
10
+ */
11
+ import os from 'node:os';
12
+ import { Worker } from 'node:worker_threads';
13
+
14
+ const WORKER = new URL('./parse-worker.mjs', import.meta.url);
15
+ export const POOL_MIN_FILES = 48;
16
+
17
+ /** Threads to use for `n` files to parse: 0 when parsing inline is as fast. */
18
+ export function poolSize(n) {
19
+ const env = process.env.GRAPH_INDEXER_WORKERS;
20
+ const want = env != null && env !== '' ? Math.max(0, Number(env) | 0) : Math.min(8, Math.max(0, (os.availableParallelism?.() ?? os.cpus().length) - 1));
21
+ if (n < POOL_MIN_FILES || want < 1) return 0;
22
+ return Math.min(want, Math.ceil(n / 16));
23
+ }
24
+
25
+ export class ParsePool {
26
+ constructor(size) {
27
+ this.workers = [];
28
+ this.idle = [];
29
+ this.queue = [];
30
+ this.pending = new Map();
31
+ this.nextId = 0;
32
+ for (let i = 0; i < size; i++) {
33
+ const w = new Worker(WORKER);
34
+ w.on('message', ({ id, extraction, error }) => {
35
+ const p = this.pending.get(id);
36
+ this.pending.delete(id);
37
+ if (error) p.reject(new Error(error)); else p.resolve(extraction);
38
+ this.#next(w);
39
+ });
40
+ // a thread that dies takes the pool with it: every pending and later file is parsed inline
41
+ w.on('error', (e) => this.#break(e));
42
+ w.on('exit', (code) => { if (code !== 0 && !this.closing) this.#break(new Error(`parse thread exited with code ${code}`)); });
43
+ this.workers.push(w);
44
+ this.idle.push(w);
45
+ }
46
+ }
47
+
48
+ /** Extraction of one file, as `extractFile(specForPath(rel), source, rel)` would return it. */
49
+ extract(rel, source) {
50
+ if (this.broken) return Promise.reject(this.broken);
51
+ return new Promise((resolve, reject) => {
52
+ const id = this.nextId++;
53
+ this.pending.set(id, { resolve, reject });
54
+ const task = { id, rel, source };
55
+ const w = this.idle.pop();
56
+ if (w) w.postMessage(task); else this.queue.push(task);
57
+ });
58
+ }
59
+
60
+ #next(w) {
61
+ const task = this.queue.shift();
62
+ if (task) w.postMessage(task); else this.idle.push(w);
63
+ }
64
+
65
+ #break(e) {
66
+ this.broken ??= e;
67
+ for (const p of this.pending.values()) p.reject(e);
68
+ this.pending.clear();
69
+ this.queue = [];
70
+ }
71
+
72
+ async close() { this.closing = true; await Promise.all(this.workers.map(w => w.terminate())); this.workers = []; }
73
+ }