graph-indexer 3.0.0 → 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.
- package/CHANGELOG.md +456 -0
- package/README.md +72 -15
- package/SECURITY.md +21 -4
- package/bin/graph-indexer.mjs +46 -5
- package/integrations/claude-code/.claude-plugin/plugin.json +3 -2
- package/integrations/claude-code/agents/code-structure.md +0 -3
- package/integrations/claude-code/skills/setup/SKILL.md +26 -0
- package/integrations/claude-code-local/.claude-plugin/plugin.json +9 -0
- package/integrations/{claude-code → claude-code-local}/.mcp.json +1 -1
- package/integrations/claude-code-local/README.md +10 -0
- package/integrations/claude-code-local/agents/code-structure.md +22 -0
- package/integrations/{claude-code → claude-code-local}/hooks/gi-hook.mjs +12 -6
- package/integrations/opencode/graph-indexer.js +13 -8
- package/package.json +2 -2
- package/src/cli/helper.mjs +15 -1
- package/src/cli/hook.mjs +140 -48
- package/src/cli/init.mjs +49 -17
- package/src/cli/resident.mjs +15 -5
- package/src/index/discover.mjs +3 -5
- package/src/index/indexer.mjs +77 -41
- package/src/index/modules.mjs +82 -7
- package/src/index/parse-worker.mjs +12 -0
- package/src/index/parsepool.mjs +73 -0
- package/src/index/resolver.mjs +371 -25
- package/src/mcp/server.mjs +16 -12
- package/src/mcp/tools.mjs +419 -26
- package/src/parse/arity.mjs +41 -3
- package/src/parse/extract.mjs +277 -46
- package/src/parse/lang/ecma.mjs +2 -0
- package/src/parse/lang/go.mjs +2 -0
- package/src/parse/lang/jvm.mjs +33 -0
- package/src/parse/lang/markdown.mjs +92 -0
- package/src/parse/lang/python.mjs +43 -0
- package/src/parse/lang/rust.mjs +215 -15
- package/src/parse/lang/sfc.mjs +125 -0
- package/src/parse/languages.mjs +9 -2
- package/src/query/check.mjs +55 -17
- package/src/query/facts.mjs +3 -2
- package/src/query/injection-spring.mjs +405 -0
- package/src/query/injection.mjs +173 -0
- package/src/query/intel.mjs +87 -22
- package/src/query/issue.mjs +156 -0
- package/src/query/orm.mjs +286 -0
- package/src/query/read.mjs +6 -1
- package/src/query/report.mjs +53 -0
- package/src/query/routes.mjs +142 -0
- package/src/query/showcase.mjs +59 -0
- package/src/query/testcmd.mjs +25 -14
- package/src/query/textsearch.mjs +29 -11
- package/src/search/search.mjs +85 -17
- package/src/search/semantic.mjs +201 -0
- package/src/search/vectors.mjs +122 -0
- package/src/store/db.mjs +22 -4
- package/src/util/git.mjs +30 -0
- package/src/util/paths.mjs +40 -1
- package/src/util/regex.mjs +2 -0
- package/vendor/grammars/README.md +47 -0
- package/vendor/grammars/kotlin.wasm.gz +0 -0
- package/vendor/grammars/manifest.json +4 -4
- /package/integrations/{claude-code → claude-code-local}/hooks/hooks.json +0 -0
package/src/index/indexer.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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(
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
package/src/index/modules.mjs
CHANGED
|
@@ -5,12 +5,13 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import fs from 'node:fs';
|
|
7
7
|
import path from 'node:path';
|
|
8
|
+
import { readRepoFile } from '../util/paths.mjs';
|
|
8
9
|
|
|
9
10
|
const ECMA_EXTS = ['.ts', '.tsx', '.d.ts', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.cts'];
|
|
10
11
|
|
|
11
|
-
function readJson(
|
|
12
|
+
function readJson(root, rel) {
|
|
12
13
|
try {
|
|
13
|
-
const text =
|
|
14
|
+
const text = readRepoFile(root, rel).replace(/\/\*[\s\S]*?\*\/|^\s*\/\/.*$/gm, '').replace(/,(\s*[}\]])/g, '$1');
|
|
14
15
|
return JSON.parse(text);
|
|
15
16
|
} catch { return null; }
|
|
16
17
|
}
|
|
@@ -53,7 +54,7 @@ export class ModuleResolver {
|
|
|
53
54
|
this.packages = new Map();
|
|
54
55
|
const pkgJsons = [...this.files].length ? this.#findManifests('package.json') : [];
|
|
55
56
|
for (const rel of pkgJsons) {
|
|
56
|
-
const j = readJson(
|
|
57
|
+
const j = readJson(this.root, rel);
|
|
57
58
|
if (j?.name) this.packages.set(j.name, { dir: path.posix.dirname(rel), json: j });
|
|
58
59
|
}
|
|
59
60
|
// tsconfig paths and baseUrl, per project: a file resolves through the nearest tsconfig.json
|
|
@@ -69,20 +70,74 @@ export class ModuleResolver {
|
|
|
69
70
|
const scope = this.#tsScope(rel);
|
|
70
71
|
if (scope) this.tsScopes.set(dir, scope);
|
|
71
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
|
+
}
|
|
72
99
|
// Go module path
|
|
73
100
|
this.goModules = [];
|
|
74
101
|
for (const rel of this.#findManifests('go.mod')) {
|
|
75
102
|
try {
|
|
76
|
-
const m = /^module\s+(\S+)/m.exec(
|
|
103
|
+
const m = /^module\s+(\S+)/m.exec(readRepoFile(this.root, rel) ?? '');
|
|
77
104
|
if (m) this.goModules.push({ module: m[1], dir: path.posix.dirname(rel) });
|
|
78
105
|
} catch { /* unreadable */ }
|
|
79
106
|
}
|
|
80
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);
|
|
81
136
|
}
|
|
82
137
|
|
|
83
138
|
/** compilerOptions of a tsconfig file, following relative `extends` (a child's paths replace its parent's). */
|
|
84
139
|
#tsOptions(rel, depth = 0) {
|
|
85
|
-
const j = readJson(
|
|
140
|
+
const j = readJson(this.root, rel);
|
|
86
141
|
if (!j || depth > 5) return null;
|
|
87
142
|
const dir = path.posix.dirname(rel);
|
|
88
143
|
let out = { paths: null, pathsDir: null, baseUrl: null };
|
|
@@ -177,7 +232,7 @@ export class ModuleResolver {
|
|
|
177
232
|
if (!source) return null;
|
|
178
233
|
const fromDir = path.posix.dirname(fromFile);
|
|
179
234
|
switch (lang) {
|
|
180
|
-
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);
|
|
181
236
|
case 'python': return this.#resolvePython(fromDir, source);
|
|
182
237
|
case 'go': return this.#resolveGo(source);
|
|
183
238
|
case 'c': case 'cpp': return this.#resolveInclude(fromDir, source);
|
|
@@ -246,7 +301,10 @@ export class ModuleResolver {
|
|
|
246
301
|
const rel = mod.replace(/\./g, '/');
|
|
247
302
|
const idx = this.#suffixIndex();
|
|
248
303
|
const cands = [...(idx.get(rel + '.py') ?? []), ...(idx.get(rel + '/__init__.py') ?? [])]
|
|
249
|
-
.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'); });
|
|
250
308
|
if (cands.length) {
|
|
251
309
|
cands.sort((a, b) => a.length - b.length);
|
|
252
310
|
const f = cands[0];
|
|
@@ -304,16 +362,33 @@ export class ModuleResolver {
|
|
|
304
362
|
if (segs[0] === 'crate') { baseDir = crateRoot; segs.shift(); }
|
|
305
363
|
else if (segs[0] === 'self') { baseDir = this.#rustModDir(fromFile); segs.shift(); }
|
|
306
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);
|
|
307
373
|
else return null; // external crate
|
|
308
374
|
for (let n = segs.length; n > 0; n--) {
|
|
309
375
|
const p = path.posix.join(baseDir, ...segs.slice(0, n));
|
|
310
376
|
if (this.files.has(p + '.rs')) return { file: p + '.rs' };
|
|
311
377
|
if (this.files.has(p + '/mod.rs')) return { file: p + '/mod.rs' };
|
|
312
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' };
|
|
313
381
|
const lib = ['lib.rs', 'main.rs', 'mod.rs'].map(f => path.posix.join(baseDir, f)).find(f => this.files.has(f));
|
|
314
382
|
return lib ? { file: lib } : null;
|
|
315
383
|
}
|
|
316
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
|
+
|
|
317
392
|
#rustCrateRoot(fromFile) {
|
|
318
393
|
let d = path.posix.dirname(fromFile);
|
|
319
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
|
+
}
|