graph-indexer 3.0.1 → 3.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +456 -0
- package/README.md +61 -8
- package/SECURITY.md +35 -20
- package/bin/graph-indexer.mjs +46 -5
- package/integrations/claude-code/.claude-plugin/plugin.json +1 -1
- 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 +15 -4
- package/integrations/opencode/graph-indexer.js +10 -3
- package/package.json +1 -1
- package/src/cli/helper.mjs +15 -1
- package/src/cli/hook.mjs +124 -40
- package/src/cli/init.mjs +32 -5
- package/src/cli/resident.mjs +19 -4
- package/src/index/discover.mjs +52 -30
- package/src/index/indexer.mjs +78 -42
- package/src/index/modules.mjs +75 -2
- package/src/index/parse-worker.mjs +12 -0
- package/src/index/parsepool.mjs +73 -0
- package/src/index/resolver.mjs +362 -23
- package/src/mcp/server.mjs +43 -13
- package/src/mcp/tools.mjs +418 -26
- package/src/parse/arity.mjs +39 -2
- package/src/parse/extract.mjs +269 -45
- 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 +126 -0
- package/src/parse/languages.mjs +9 -2
- package/src/query/check.mjs +49 -13
- package/src/query/facts.mjs +3 -2
- package/src/query/injection-spring.mjs +406 -0
- package/src/query/injection.mjs +174 -0
- package/src/query/intel.mjs +85 -15
- package/src/query/issue.mjs +156 -0
- package/src/query/orm.mjs +288 -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/textsearch.mjs +30 -9
- 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 +33 -4
- package/src/util/git.mjs +60 -11
- package/src/util/paths.mjs +10 -3
- 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
|
|
|
@@ -96,7 +98,7 @@ export class Indexer {
|
|
|
96
98
|
const toParse = [];
|
|
97
99
|
for (const rel of files) {
|
|
98
100
|
const st = safeStat(path.join(this.root, rel));
|
|
99
|
-
if (!st || st.size > MAX_FILE_BYTES) { if (known.has(rel)) removed.push(rel); continue; }
|
|
101
|
+
if (!st || !st.isFile() || st.size > MAX_FILE_BYTES) { if (known.has(rel)) removed.push(rel); continue; }
|
|
100
102
|
const k = known.get(rel);
|
|
101
103
|
if (k && k.size === st.size && k.mtime_ms === Math.floor(st.mtimeMs)) continue;
|
|
102
104
|
toParse.push({ rel, st, prev: k ?? null });
|
|
@@ -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
|
@@ -70,6 +70,31 @@ 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
|
+
const text = readRepoFile(this.root, rel) ?? '';
|
|
90
|
+
const lib = /\bfiles\s*:\s*\{[^}]*\blib\s*:\s*['"]([^'"]+)['"]/.exec(text)?.[1] ?? 'src/lib';
|
|
91
|
+
const aliases = [['$lib', lib]];
|
|
92
|
+
const block = /\balias\s*:\s*\{([^}]*)\}/.exec(text)?.[1] ?? '';
|
|
93
|
+
for (const m of block.matchAll(/['"]?([$@#~\w/-]+?)(?:\/\*)?['"]?\s*:\s*['"]([^'"]+?)(?:\/\*)?['"]/g)) aliases.push([m[1], m[2]]);
|
|
94
|
+
const paths = aliases.flatMap(([a, t]) => [{ pat: a, targets: [at(t)] }, { pat: `${a}/*`, targets: [`${at(t)}/*`] }]);
|
|
95
|
+
const scope = this.tsScopes.get(dir);
|
|
96
|
+
this.tsScopes.set(dir, scope ? { ...scope, paths: [...scope.paths, ...paths] } : { paths, baseUrl: null });
|
|
97
|
+
}
|
|
73
98
|
// Go module path
|
|
74
99
|
this.goModules = [];
|
|
75
100
|
for (const rel of this.#findManifests('go.mod')) {
|
|
@@ -79,6 +104,34 @@ export class ModuleResolver {
|
|
|
79
104
|
} catch { /* unreadable */ }
|
|
80
105
|
}
|
|
81
106
|
this.goModules.sort((a, b) => b.module.length - a.module.length);
|
|
107
|
+
// Rust crates of the repository (a workspace's members, or one package): the name other
|
|
108
|
+
// crates, tests and examples use for each library (`serde_json::Value`, `tokio::sync`)
|
|
109
|
+
this.rustCrates = new Map();
|
|
110
|
+
this.rustPackages = [];
|
|
111
|
+
for (const rel of this.#findManifests('Cargo.toml')) {
|
|
112
|
+
const text = readRepoFile(this.root, rel);
|
|
113
|
+
if (text == null) continue;
|
|
114
|
+
const dir = path.posix.dirname(rel), at = (p) => (dir === '.' ? p : `${dir}/${p}`);
|
|
115
|
+
if (/^\[package\]\s*$/m.test(text)) this.rustPackages.push(dir);
|
|
116
|
+
const table = (name) => { const m = new RegExp(`^\\[${name}\\]\\s*$([\\s\\S]*?)(?=^\\[|(?![\\s\\S]))`, 'm').exec(text); return m?.[1] ?? ''; };
|
|
117
|
+
const key = (body, k) => new RegExp(`^\\s*${k}\\s*=\\s*"([^"]+)"`, 'm').exec(body)?.[1];
|
|
118
|
+
const pkg = key(table('package'), 'name'), lib = table('lib');
|
|
119
|
+
const libFile = path.posix.normalize(at(key(lib, 'path') ?? 'src/lib.rs'));
|
|
120
|
+
if (!pkg || !this.files.has(libFile)) continue;
|
|
121
|
+
this.rustCrates.set((key(lib, 'name') ?? pkg).replace(/-/g, '_'), libFile);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Resolve one import (`imp.source`, `imp.imported`). In Rust, `use a::b::c` resolves to the
|
|
127
|
+
* module `c` when c is a module (a file `c.rs` or `c/mod.rs`), not to the module that declares it.
|
|
128
|
+
*/
|
|
129
|
+
resolveImport(lang, fromFile, imp) {
|
|
130
|
+
if (lang === 'rust' && imp.imported && !['self', '*', 'super', 'crate'].includes(imp.imported) && /^[a-z_][a-z0-9_]*$/.test(imp.imported)) {
|
|
131
|
+
const sub = this.resolve(lang, fromFile, imp.source ? `${imp.source}::${imp.imported}` : imp.imported);
|
|
132
|
+
if (sub?.file && new RegExp(`(^|/)${imp.imported}(\\.rs|/mod\\.rs)$`).test(sub.file)) return sub;
|
|
133
|
+
}
|
|
134
|
+
return this.resolve(lang, fromFile, imp.source);
|
|
82
135
|
}
|
|
83
136
|
|
|
84
137
|
/** compilerOptions of a tsconfig file, following relative `extends` (a child's paths replace its parent's). */
|
|
@@ -178,7 +231,7 @@ export class ModuleResolver {
|
|
|
178
231
|
if (!source) return null;
|
|
179
232
|
const fromDir = path.posix.dirname(fromFile);
|
|
180
233
|
switch (lang) {
|
|
181
|
-
case 'javascript': case 'typescript': case 'tsx': return this.#resolveEcma(fromDir, source);
|
|
234
|
+
case 'javascript': case 'typescript': case 'tsx': case 'vue': case 'svelte': return this.#resolveEcma(fromDir, source);
|
|
182
235
|
case 'python': return this.#resolvePython(fromDir, source);
|
|
183
236
|
case 'go': return this.#resolveGo(source);
|
|
184
237
|
case 'c': case 'cpp': return this.#resolveInclude(fromDir, source);
|
|
@@ -247,7 +300,10 @@ export class ModuleResolver {
|
|
|
247
300
|
const rel = mod.replace(/\./g, '/');
|
|
248
301
|
const idx = this.#suffixIndex();
|
|
249
302
|
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'))
|
|
303
|
+
.filter(f => f === rel + '.py' || f === rel + '/__init__.py' || f.endsWith('/' + rel + '.py') || f.endsWith('/' + rel + '/__init__.py'))
|
|
304
|
+
// an absolute import starts at an import root: `import json` is not `flask/json/`, since
|
|
305
|
+
// `flask/` is a package (it has an __init__.py) and only reaches it as `flask.json`
|
|
306
|
+
.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
307
|
if (cands.length) {
|
|
252
308
|
cands.sort((a, b) => a.length - b.length);
|
|
253
309
|
const f = cands[0];
|
|
@@ -305,16 +361,33 @@ export class ModuleResolver {
|
|
|
305
361
|
if (segs[0] === 'crate') { baseDir = crateRoot; segs.shift(); }
|
|
306
362
|
else if (segs[0] === 'self') { baseDir = this.#rustModDir(fromFile); segs.shift(); }
|
|
307
363
|
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(); } }
|
|
364
|
+
else if (this.rustCrates?.has(segs[0])) {
|
|
365
|
+
// another crate of the repository, by name; its root module is its lib.rs
|
|
366
|
+
const lib = this.rustCrates.get(segs.shift());
|
|
367
|
+
if (!segs.length) return { file: lib };
|
|
368
|
+
baseDir = path.posix.dirname(lib);
|
|
369
|
+
}
|
|
370
|
+
// a child module of this one (2018 paths: `mod runtime; pub use runtime::Runtime;`)
|
|
371
|
+
else if (['.rs', '/mod.rs'].some(e => this.files.has(path.posix.join(this.#rustModDir(fromFile), segs[0]) + e))) baseDir = this.#rustModDir(fromFile);
|
|
308
372
|
else return null; // external crate
|
|
309
373
|
for (let n = segs.length; n > 0; n--) {
|
|
310
374
|
const p = path.posix.join(baseDir, ...segs.slice(0, n));
|
|
311
375
|
if (this.files.has(p + '.rs')) return { file: p + '.rs' };
|
|
312
376
|
if (this.files.has(p + '/mod.rs')) return { file: p + '/mod.rs' };
|
|
313
377
|
}
|
|
378
|
+
// the module itself: `dir/mod.rs`, or `dir.rs` next to its directory (2018 layout)
|
|
379
|
+
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
380
|
const lib = ['lib.rs', 'main.rs', 'mod.rs'].map(f => path.posix.join(baseDir, f)).find(f => this.files.has(f));
|
|
315
381
|
return lib ? { file: lib } : null;
|
|
316
382
|
}
|
|
317
383
|
|
|
384
|
+
/** The directory of the Cargo package a file belongs to (its nearest `Cargo.toml` with a `[package]`), or null. */
|
|
385
|
+
rustPackageOf(file) {
|
|
386
|
+
let best = null;
|
|
387
|
+
for (const d of this.rustPackages ?? []) if ((d === '.' || file.startsWith(d + '/')) && (best == null || d.length > best.length)) best = d;
|
|
388
|
+
return best;
|
|
389
|
+
}
|
|
390
|
+
|
|
318
391
|
#rustCrateRoot(fromFile) {
|
|
319
392
|
let d = path.posix.dirname(fromFile);
|
|
320
393
|
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
|
+
}
|