@aeonsparx/pi-unreal 1.0.0 → 1.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 +89 -0
- package/README.md +85 -88
- package/docs/internals.md +66 -5
- package/extensions/unreal/index.ts +18 -4
- package/extensions/unreal/lib/pi-tools.ts +70 -20
- package/extensions/unreal/lib/store.ts +633 -43
- package/extensions/unreal/lib/tools.ts +491 -31
- package/extensions/unreal/lib/ubt.ts +7 -0
- package/package.json +2 -1
- package/prompts/unreal-context.md +10 -0
- package/skills/ue-engine-resolution/SKILL.md +38 -5
|
@@ -23,19 +23,32 @@
|
|
|
23
23
|
* for the project, one per target) and the answer depends on which one was read.
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
-
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
26
|
+
import { closeSync, createReadStream, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
|
|
27
|
+
import { StringDecoder } from "node:string_decoder";
|
|
28
|
+
import { createInterface } from "node:readline";
|
|
27
29
|
import { cacheDir } from "./cache.ts";
|
|
28
30
|
import { normalizePath, joinPath, shortHash, baseName, realFs, type FsAdapter } from "./util.ts";
|
|
29
31
|
import { collectSourceFiles, scanText, type IncludeRef, type ScanRoot, type SymbolDef } from "./scan.ts";
|
|
30
32
|
import { parseModuleReferences, parseTargetDetails, readQueryJson, type UbtModuleRefs, type UbtModuleSettings } from "./ubt.ts";
|
|
31
33
|
|
|
32
|
-
|
|
34
|
+
/**
|
|
35
|
+
* v6: `meta.rootKinds` (what an index actually covers, by root kind) and `meta.areas`, and engine-scope
|
|
36
|
+
* indexes now include the project's enabled plugin roots, so engine-scope keys change and v5 engine
|
|
37
|
+
* indexes are not reused. v5 was the TSV layout change; older indexes are unreadable by design.
|
|
38
|
+
*/
|
|
39
|
+
export const INDEX_VERSION = 6;
|
|
33
40
|
|
|
34
41
|
/**
|
|
35
|
-
* Default engine scan. `Runtime` is where gameplay types live (AActor,
|
|
36
|
-
* UGameModeBase)
|
|
37
|
-
*
|
|
38
|
-
*
|
|
42
|
+
* Default engine scan. `Runtime` is where the **engine's own** gameplay types live (AActor,
|
|
43
|
+
* UAnimInstance, UGameModeBase): measured 6,212 files / 126,350 definitions in 34 s on a UE 5.8
|
|
44
|
+
* install. `Editor` and `Developer` roughly triple that, so they are opt-in through `engineAreas`.
|
|
45
|
+
*
|
|
46
|
+
* It is NOT the whole engine: most UE-facing types shipped as engine *plugins* (Mover, PoseSearch,
|
|
47
|
+
* PhysicsControl, BlendStack, SmartObjects, StateTree, GameplayAbilities, GameplayStateTree, …) live
|
|
48
|
+
* under Engine/Plugins/<category>/<plugin>/Source/<module>, which `"Runtime"` does not touch. Engine-scope
|
|
49
|
+
* indexes therefore add the plugin roots the project enables (free — already computed), and
|
|
50
|
+
* `engineAreas: ["Plugins"]` (or `["Plugins/Animation/PhysicsControl"]`) dials in the rest. Measured on
|
|
51
|
+
* UE 5.8: Engine/Source/Runtime is 18,681 scannable files, all plugin sources are 52,568 (~2.8x).
|
|
39
52
|
*/
|
|
40
53
|
export const DEFAULT_ENGINE_AREAS = ["Runtime"];
|
|
41
54
|
|
|
@@ -61,6 +74,10 @@ export interface IndexMeta {
|
|
|
61
74
|
configuration?: string;
|
|
62
75
|
counts: { files: number; symbols: number; includes: number; modules: number };
|
|
63
76
|
defsByKind: Record<string, number>;
|
|
77
|
+
/** Root provenance by kind — the coverage claim a 0-row answer is bounded by (see `classifyRoot`). */
|
|
78
|
+
rootKinds?: Record<string, number>;
|
|
79
|
+
/** `engineAreas` the engine half was scanned with, when an engine half exists. */
|
|
80
|
+
areas?: string[];
|
|
64
81
|
warnings: string[];
|
|
65
82
|
sizeBytes: number;
|
|
66
83
|
defsPath: string;
|
|
@@ -98,6 +115,44 @@ export interface IndexBuild {
|
|
|
98
115
|
|
|
99
116
|
export type DefRow = { symbol: string; kind: string; file: string; line: number; module: string; scope: string; marker: string };
|
|
100
117
|
|
|
118
|
+
/* ──────────────────────────────────────────────────────────────────── root kinds */
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* What kind of source a scan root is. This is the vocabulary every coverage claim is written in:
|
|
122
|
+
* `unreal_symbol scope: "engine"` answers for `engine-source` + `engine-plugin` roots, and nothing
|
|
123
|
+
* else — a 0-row answer is only a statement about the roots that were scanned.
|
|
124
|
+
*/
|
|
125
|
+
export type RootKind = "project" | "engine-plugin" | "engine-source" | "other-plugin";
|
|
126
|
+
|
|
127
|
+
export function classifyRoot(root: string, options: { projectRoot?: string; engineRoot?: string } = {}): RootKind {
|
|
128
|
+
const r = normalizePath(root).toLowerCase();
|
|
129
|
+
const project = options.projectRoot ? normalizePath(options.projectRoot).toLowerCase() : undefined;
|
|
130
|
+
const engine = options.engineRoot ? normalizePath(options.engineRoot).toLowerCase() : undefined;
|
|
131
|
+
if (project && (r === project || r.startsWith(`${project}/`))) return "project";
|
|
132
|
+
if (engine && r.startsWith(`${engine}/`)) {
|
|
133
|
+
if (r.includes("/engine/plugins/")) return "engine-plugin";
|
|
134
|
+
if (r.includes("/engine/source/")) return "engine-source";
|
|
135
|
+
return "other-plugin";
|
|
136
|
+
}
|
|
137
|
+
return "other-plugin";
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Root counts by kind, most numerous first. Drives index-status coverage lines and 0-row caveats. */
|
|
141
|
+
export function summarizeRootKinds(roots: string[], options: { projectRoot?: string; engineRoot?: string } = {}): { kind: RootKind; count: number }[] {
|
|
142
|
+
const counts = new Map<RootKind, number>();
|
|
143
|
+
for (const root of roots) {
|
|
144
|
+
const kind = classifyRoot(root, options);
|
|
145
|
+
counts.set(kind, (counts.get(kind) ?? 0) + 1);
|
|
146
|
+
}
|
|
147
|
+
return [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([kind, count]) => ({ kind, count }));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** One-line coverage phrase, e.g. `1 project root, 62 engine-plugin roots`. */
|
|
151
|
+
export function rootKindsNote(roots: string[], options: { projectRoot?: string; engineRoot?: string } = {}): string {
|
|
152
|
+
const parts = summarizeRootKinds(roots, options).map((entry) => `${entry.count} ${entry.kind} root(s)`);
|
|
153
|
+
return parts.length ? parts.join(", ") : "no roots";
|
|
154
|
+
}
|
|
155
|
+
|
|
101
156
|
/**
|
|
102
157
|
* Provenance of a set of scan roots. Plugin module directories inside an installed engine tree count as
|
|
103
158
|
* project-side (`.../Engine/Plugins/...`), so a project + plugins index classifies as `Project` and an
|
|
@@ -156,6 +211,109 @@ function byProbe(a: DefRow, b: DefRow): number {
|
|
|
156
211
|
return a.line - b.line;
|
|
157
212
|
}
|
|
158
213
|
|
|
214
|
+
/** Rows per sorted shard. 40 000 rows ≈ 5 MB of live objects; a whole-Plugins scan spills ~43. */
|
|
215
|
+
const SHARD_ROWS = 40_000;
|
|
216
|
+
|
|
217
|
+
/** One def row in `defs.tsv` cell order (shared by the shard writer and the merge). */
|
|
218
|
+
function defLine(row: DefRow): string {
|
|
219
|
+
return [esc(row.symbol), row.kind, esc(row.file), row.line, esc(row.module), esc(row.scope), esc(row.marker)].join("\t");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Plain-string key that reproduces `byProbe` order: probe(symbol), file, then line (zero-padded). */
|
|
223
|
+
function defSortKey(line: string): string {
|
|
224
|
+
const cells = line.split("\t");
|
|
225
|
+
return `${probe(unesc(cells[0] ?? ""))}\t${unesc(cells[2] ?? "")}\t${String(Number(cells[3]) || 0).padStart(12, "0")}`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Buffered append-only TSV writer (a whole file is never built as one string). */
|
|
229
|
+
class TsvStreamWriter {
|
|
230
|
+
private fd: number;
|
|
231
|
+
private lines: string[] = [];
|
|
232
|
+
bytes = 0;
|
|
233
|
+
|
|
234
|
+
constructor(readonly path: string) {
|
|
235
|
+
mkdirSync(baseDirOf(path), { recursive: true });
|
|
236
|
+
this.fd = openSync(path, "w");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
write(line: string): void {
|
|
240
|
+
this.lines.push(line);
|
|
241
|
+
if (this.lines.length >= 8192) this.flush();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private flush(): void {
|
|
245
|
+
if (!this.lines.length) return;
|
|
246
|
+
const buffer = Buffer.from(`${this.lines.join("\n")}\n`, "utf8");
|
|
247
|
+
this.lines = [];
|
|
248
|
+
let offset = 0;
|
|
249
|
+
while (offset < buffer.length) offset += writeSync(this.fd, buffer, offset, buffer.length - offset);
|
|
250
|
+
this.bytes += buffer.length;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
close(): void {
|
|
254
|
+
this.flush();
|
|
255
|
+
closeSync(this.fd);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* K-way merge of sorted shard files into one sorted TSV stream.
|
|
261
|
+
*
|
|
262
|
+
* Each shard is read lazily (one readline iterator per shard, one live line each), so memory is
|
|
263
|
+
* O(shards), and a min-heap over the shard heads keeps the merge at O(rows · log shards).
|
|
264
|
+
*/
|
|
265
|
+
async function mergeSortedShards(shardPaths: string[], out: TsvStreamWriter): Promise<void> {
|
|
266
|
+
const streams = shardPaths.map((path) => createInterface({ input: createReadStream(path), crlfDelay: Infinity })[Symbol.asyncIterator]());
|
|
267
|
+
const heads: ({ key: string; line: string } | undefined)[] = streams.map(() => undefined);
|
|
268
|
+
const alive: number[] = [];
|
|
269
|
+
const pull = async (shard: number): Promise<void> => {
|
|
270
|
+
const next = await streams[shard]!.next();
|
|
271
|
+
heads[shard] = next.done || !next.value ? undefined : { key: defSortKey(next.value), line: next.value };
|
|
272
|
+
};
|
|
273
|
+
const keyAt = (slot: number) => heads[alive[slot]!]!.key;
|
|
274
|
+
const swap = (a: number, b: number) => {
|
|
275
|
+
const tmp = alive[a]!;
|
|
276
|
+
alive[a] = alive[b]!;
|
|
277
|
+
alive[b] = tmp;
|
|
278
|
+
};
|
|
279
|
+
const siftDown = (from: number) => {
|
|
280
|
+
let i = from;
|
|
281
|
+
for (;;) {
|
|
282
|
+
const left = 2 * i + 1;
|
|
283
|
+
const right = left + 1;
|
|
284
|
+
let best = i;
|
|
285
|
+
if (left < alive.length && keyAt(left) < keyAt(best)) best = left;
|
|
286
|
+
if (right < alive.length && keyAt(right) < keyAt(best)) best = right;
|
|
287
|
+
if (best === i) return;
|
|
288
|
+
swap(i, best);
|
|
289
|
+
i = best;
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
for (let shard = 0; shard < streams.length; shard++) {
|
|
293
|
+
await pull(shard);
|
|
294
|
+
if (heads[shard]) alive.push(shard);
|
|
295
|
+
}
|
|
296
|
+
for (let slot = (alive.length >> 1) - 1; slot >= 0; slot--) siftDown(slot);
|
|
297
|
+
while (alive.length) {
|
|
298
|
+
const shard = alive[0]!;
|
|
299
|
+
const head = heads[shard]!;
|
|
300
|
+
out.write(head.line);
|
|
301
|
+
await pull(shard);
|
|
302
|
+
if (!heads[shard]) {
|
|
303
|
+
// This shard is exhausted: move the last live shard to the root slot and shrink the heap.
|
|
304
|
+
alive[0] = alive[alive.length - 1]!;
|
|
305
|
+
alive.pop();
|
|
306
|
+
}
|
|
307
|
+
if (alive.length) siftDown(0);
|
|
308
|
+
}
|
|
309
|
+
for (const stream of streams) await stream.return?.(undefined);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function baseDirOf(path: string): string {
|
|
313
|
+
const cut = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
|
|
314
|
+
return cut <= 0 ? path : path.slice(0, cut);
|
|
315
|
+
}
|
|
316
|
+
|
|
159
317
|
export async function buildIndex(input: {
|
|
160
318
|
key: string;
|
|
161
319
|
mode: "definitions" | "full";
|
|
@@ -168,9 +326,16 @@ export async function buildIndex(input: {
|
|
|
168
326
|
platform?: string;
|
|
169
327
|
configuration?: string;
|
|
170
328
|
queryFiles?: { targetDetails: string; moduleReferences: string };
|
|
329
|
+
/** Recorded verbatim in `meta.areas` so status can state what an engine index scanned. */
|
|
330
|
+
areas?: string[];
|
|
171
331
|
/** Where the ScanRoot provenance comes from; `deriveScope(roots)` when omitted. */
|
|
172
332
|
scope?: IndexScope;
|
|
173
333
|
concurrency?: number;
|
|
334
|
+
/**
|
|
335
|
+
* Rows per sorted shard (default `SHARD_ROWS`). Exposed for tests: a whole-Plugins scan spills ~43
|
|
336
|
+
* shards and 1.24 M rows, and a merge bug is invisible at the production size.
|
|
337
|
+
*/
|
|
338
|
+
shardRows?: number;
|
|
174
339
|
}): Promise<IndexBuild> {
|
|
175
340
|
const started = Date.now();
|
|
176
341
|
const dir = indexDir(input.key);
|
|
@@ -187,11 +352,41 @@ export async function buildIndex(input: {
|
|
|
187
352
|
return moduleFromBuildCs(file);
|
|
188
353
|
};
|
|
189
354
|
|
|
355
|
+
const rootDirs = input.roots.map((root) => root.dir);
|
|
356
|
+
const rootKinds: Record<string, number> = {};
|
|
357
|
+
for (const entry of summarizeRootKinds(rootDirs, { projectRoot: input.projectRoot, engineRoot: input.engineRoot })) {
|
|
358
|
+
rootKinds[entry.kind] = entry.count;
|
|
359
|
+
}
|
|
360
|
+
|
|
190
361
|
const files = await collectSourceFiles(input.roots, { concurrency: input.concurrency });
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
362
|
+
// Streaming write: a whole-Plugins engine index is ~1.7 M definition rows (~300 MB of TSV), and
|
|
363
|
+
// accumulating them in an array before one giant sort was the OOM. Rows are sorted per shard and
|
|
364
|
+
// the shards are merged, so peak memory is one shard, and defs.tsv is byte-identical to a sorted
|
|
365
|
+
// single-pass write.
|
|
366
|
+
const shardDir = joinPath(dir, "shards");
|
|
367
|
+
const shardRows = Math.max(1, input.shardRows ?? SHARD_ROWS);
|
|
368
|
+
rmSync(shardDir, { recursive: true, force: true });
|
|
369
|
+
mkdirSync(shardDir, { recursive: true });
|
|
370
|
+
const shardPaths: string[] = [];
|
|
371
|
+
let batch: DefRow[] = [];
|
|
372
|
+
let rowTotal = 0;
|
|
373
|
+
const flushBatch = () => {
|
|
374
|
+
if (!batch.length) return;
|
|
375
|
+
batch.sort(byProbe);
|
|
376
|
+
const shardPath = joinPath(shardDir, `shard-${String(shardPaths.length).padStart(4, "0")}.tsv`);
|
|
377
|
+
const shard = new TsvStreamWriter(shardPath);
|
|
378
|
+
for (const row of batch) shard.write(defLine(row));
|
|
379
|
+
shard.close();
|
|
380
|
+
shardPaths.push(shardPath);
|
|
381
|
+
rowTotal += batch.length;
|
|
382
|
+
batch = [];
|
|
383
|
+
};
|
|
384
|
+
const filesOut = new TsvStreamWriter(joinPath(dir, "files.tsv"));
|
|
385
|
+
filesOut.write("file\tmodule\tkind\tsymbols");
|
|
386
|
+
const includesOut = input.mode === "full" ? new TsvStreamWriter(joinPath(dir, "includes.tsv")) : undefined;
|
|
387
|
+
includesOut?.write("file\tmodule\tincludes\tsymbols");
|
|
388
|
+
let fileTotal = 0;
|
|
389
|
+
let includeTotal = 0;
|
|
195
390
|
const modules = new Set<string>();
|
|
196
391
|
const defsByKind: Record<string, number> = {};
|
|
197
392
|
|
|
@@ -206,19 +401,26 @@ export async function buildIndex(input: {
|
|
|
206
401
|
const { defs, includes } = scanText(text, entry.abs, module, entry.root.scope);
|
|
207
402
|
modules.add(module);
|
|
208
403
|
for (const def of defs) {
|
|
209
|
-
|
|
404
|
+
batch.push({ symbol: def.symbol, kind: def.kind, file: def.file, line: def.line, module: def.module, scope: def.scope, marker: def.marker });
|
|
210
405
|
defsByKind[def.kind] = (defsByKind[def.kind] ?? 0) + 1;
|
|
211
406
|
}
|
|
212
|
-
|
|
213
|
-
|
|
407
|
+
if (batch.length >= shardRows) flushBatch();
|
|
408
|
+
fileTotal++;
|
|
409
|
+
filesOut.write(`${esc(entry.abs)}\t${esc(module)}\t${fileKindLabel(entry.abs)}\t${defs.length}`);
|
|
410
|
+
if (includesOut) {
|
|
411
|
+
includeTotal++;
|
|
412
|
+
includesOut.write(`${esc(entry.abs)}\t${esc(module)}\t${includes.map((inc) => (inc.angled ? `<${inc.target}>` : `"${inc.target}"`)).join(",")}\t${defs.map((def) => def.symbol).join(",")}`);
|
|
413
|
+
}
|
|
214
414
|
}
|
|
415
|
+
flushBatch();
|
|
416
|
+
filesOut.close();
|
|
417
|
+
includesOut?.close();
|
|
215
418
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
if (input.mode === "full") writeTextAtomic(joinPath(dir, "includes.tsv"), `file\tmodule\tincludes\tsymbols\n${includeRows.join("\n")}\n`);
|
|
419
|
+
const defsOut = new TsvStreamWriter(joinPath(dir, "defs.tsv"));
|
|
420
|
+
defsOut.write(TSV_HEADER.join("\t"));
|
|
421
|
+
await mergeSortedShards(shardPaths, defsOut);
|
|
422
|
+
defsOut.close();
|
|
423
|
+
rmSync(shardDir, { recursive: true, force: true });
|
|
222
424
|
|
|
223
425
|
const meta: IndexMeta = {
|
|
224
426
|
version: INDEX_VERSION,
|
|
@@ -235,10 +437,12 @@ export async function buildIndex(input: {
|
|
|
235
437
|
target: input.target,
|
|
236
438
|
platform: input.platform,
|
|
237
439
|
configuration: input.configuration,
|
|
238
|
-
counts: { files:
|
|
440
|
+
counts: { files: fileTotal, symbols: rowTotal, includes: includeTotal, modules: modules.size },
|
|
239
441
|
defsByKind,
|
|
442
|
+
rootKinds,
|
|
443
|
+
areas: input.areas,
|
|
240
444
|
warnings,
|
|
241
|
-
sizeBytes:
|
|
445
|
+
sizeBytes: defsOut.bytes,
|
|
242
446
|
defsPath: normalizePath(joinPath(dir, "defs.tsv")),
|
|
243
447
|
filesPath: normalizePath(joinPath(dir, "files.tsv")),
|
|
244
448
|
includesPath: input.mode === "full" ? normalizePath(joinPath(dir, "includes.tsv")) : undefined,
|
|
@@ -253,6 +457,10 @@ export async function buildIndex(input: {
|
|
|
253
457
|
* Re-scan only the files a caller names (an agent that just edited three files), merging rows into
|
|
254
458
|
* the existing TSV. Kept as a whole-file rewrite: 69 MB rewritten in ~1 s is cheaper than a
|
|
255
459
|
* patchable index format, and the result is always consistent.
|
|
460
|
+
*
|
|
461
|
+
* Memory-bound by design: `readDefRows` materializes the whole table. Safe for project and
|
|
462
|
+
* engine-area indexes; do not run it against a whole-`Engine/Plugins` index (~1.24 M rows, ~300 MB),
|
|
463
|
+
* which is why no tool exposes it.
|
|
256
464
|
*/
|
|
257
465
|
export async function patchIndex(index: IndexState, files: string[], resolveModule: (file: string) => { module: string; scope: IndexScope }): Promise<{ updated: number; added: number; removed: number }> {
|
|
258
466
|
const rows = readDefRows(index);
|
|
@@ -298,10 +506,17 @@ function lineOffsets(path: string): { offsets: number[]; size: number } {
|
|
|
298
506
|
if (cached && cached.size === size) return cached;
|
|
299
507
|
const fd = openSync(path, "r");
|
|
300
508
|
try {
|
|
301
|
-
|
|
302
|
-
|
|
509
|
+
// Chunked: a whole-Plugins defs.tsv is ~300 MB, and reading it into one Buffer to count
|
|
510
|
+
// newlines is a 300 MB allocation on the first lookup against it.
|
|
511
|
+
const buffer = Buffer.alloc(1 << 22);
|
|
303
512
|
const offsets: number[] = [0];
|
|
304
|
-
|
|
513
|
+
let position = 0;
|
|
514
|
+
for (;;) {
|
|
515
|
+
const read = readSync(fd, buffer, 0, buffer.length, position);
|
|
516
|
+
if (read <= 0) break;
|
|
517
|
+
for (let i = 0; i < read; i++) if (buffer[i] === 0x0a) offsets.push(position + i + 1);
|
|
518
|
+
position += read;
|
|
519
|
+
}
|
|
305
520
|
const entry = { offsets, size };
|
|
306
521
|
lineIndexCache.set(path, entry);
|
|
307
522
|
return entry;
|
|
@@ -348,6 +563,63 @@ function readDefRows(index: IndexState): DefRow[] {
|
|
|
348
563
|
return readTsv(readFileSync(index.meta.defsPath, "utf8"), 3) as unknown as DefRow[];
|
|
349
564
|
}
|
|
350
565
|
|
|
566
|
+
/**
|
|
567
|
+
* Stream one TSV file line by line, 4 MB at a time, and stop when a callback returns false.
|
|
568
|
+
*
|
|
569
|
+
* Every whole-file pass used to call `readDefRows`, which materializes a 76 MB index as one string
|
|
570
|
+
* and then as an array of row objects. On the engine + every-plugin scope (~1.7 M rows, ~300 MB)
|
|
571
|
+
* that is what died in the allocator, so the linear passes stream instead: bounded memory, and the
|
|
572
|
+
* file is never fully resident.
|
|
573
|
+
*/
|
|
574
|
+
export function forEachTsvLine(path: string, onCells: (cells: string[]) => boolean | void): void {
|
|
575
|
+
const decoder = new StringDecoder("utf8");
|
|
576
|
+
const fd = openSync(path, "r");
|
|
577
|
+
const buffer = Buffer.alloc(1 << 22);
|
|
578
|
+
let pending = "";
|
|
579
|
+
let start = 0;
|
|
580
|
+
try {
|
|
581
|
+
for (;;) {
|
|
582
|
+
const read = readSync(fd, buffer, 0, buffer.length, null);
|
|
583
|
+
if (read <= 0) break;
|
|
584
|
+
pending += decoder.write(buffer.subarray(0, read));
|
|
585
|
+
let nl = pending.indexOf("\n", start);
|
|
586
|
+
while (nl >= 0) {
|
|
587
|
+
const line = pending.slice(start, nl);
|
|
588
|
+
start = nl + 1;
|
|
589
|
+
if (line.length && onCells(line.split("\t")) === false) return;
|
|
590
|
+
nl = pending.indexOf("\n", start);
|
|
591
|
+
}
|
|
592
|
+
if (start > (1 << 21)) {
|
|
593
|
+
pending = pending.slice(start);
|
|
594
|
+
start = 0;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
pending += decoder.end();
|
|
598
|
+
const tail = pending.slice(start);
|
|
599
|
+
if (tail.length) onCells(tail.split("\t"));
|
|
600
|
+
} finally {
|
|
601
|
+
closeSync(fd);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function defRowFromCells(cells: string[]): DefRow | undefined {
|
|
606
|
+
if (cells.length < 7) return undefined;
|
|
607
|
+
return { symbol: unesc(cells[0] ?? ""), kind: cells[1] ?? "", file: unesc(cells[2] ?? ""), line: Number(cells[3]), module: unesc(cells[4] ?? ""), scope: unesc(cells[5] ?? ""), marker: unesc(cells[6] ?? "") };
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** Definition rows in index order (header skipped). Return false from the callback to stop early. */
|
|
611
|
+
function forEachDefRow(index: IndexState, onRow: (row: DefRow) => boolean | void): void {
|
|
612
|
+
let first = true;
|
|
613
|
+
forEachTsvLine(index.meta.defsPath, (cells) => {
|
|
614
|
+
if (first) {
|
|
615
|
+
first = false;
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
const row = defRowFromCells(cells);
|
|
619
|
+
if (row) return onRow(row);
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
|
|
351
623
|
function readFileRows(index: IndexState): { file: string; module: string; kind: string; symbols: number }[] {
|
|
352
624
|
return readTsv(readFileSync(index.meta.filesPath, "utf8"), 3) as unknown as { file: string; module: string; kind: string; symbols: number }[];
|
|
353
625
|
}
|
|
@@ -457,11 +729,11 @@ export function lookupSymbol(index: IndexState, query: string, options: { limit?
|
|
|
457
729
|
// Pass 5: substring, opt-in. A linear pass over the TSV (~0.3 s for the 69 MB engine index) that
|
|
458
730
|
// is only reached when every indexed pass missed, so the cost is paid by misses, not by hits.
|
|
459
731
|
if (!out.length && options.substring) {
|
|
460
|
-
|
|
461
|
-
if (!probe(row.symbol).includes(needle))
|
|
732
|
+
forEachDefRow(index, (row) => {
|
|
733
|
+
if (!probe(row.symbol).includes(needle)) return;
|
|
462
734
|
accept(row, 3);
|
|
463
|
-
|
|
464
|
-
}
|
|
735
|
+
return out.length < limit;
|
|
736
|
+
});
|
|
465
737
|
}
|
|
466
738
|
return out.sort((a, b) => a.rank - b.rank || a.scope.localeCompare(b.scope) || a.symbol.localeCompare(b.symbol)).slice(0, limit);
|
|
467
739
|
}
|
|
@@ -486,10 +758,10 @@ export function symbolsInFile(index: IndexState, file: string, limit: number): D
|
|
|
486
758
|
const target = probe(file);
|
|
487
759
|
const path = defPath(index);
|
|
488
760
|
const out: DefRow[] = [];
|
|
489
|
-
|
|
761
|
+
forEachDefRow(index, (row) => {
|
|
490
762
|
if (probe(row.file) === target) out.push(row);
|
|
491
|
-
|
|
492
|
-
}
|
|
763
|
+
return out.length < limit;
|
|
764
|
+
});
|
|
493
765
|
return out;
|
|
494
766
|
}
|
|
495
767
|
|
|
@@ -509,7 +781,9 @@ export interface ModuleInfo {
|
|
|
509
781
|
export function listModules(index: IndexState, options: { limit?: number } = {}): ModuleInfo[] {
|
|
510
782
|
const rows = readFileRows(index);
|
|
511
783
|
const scopes = new Map<string, string>();
|
|
512
|
-
|
|
784
|
+
forEachDefRow(index, (row) => {
|
|
785
|
+
if (!scopes.has(row.module) && row.scope) scopes.set(row.module, row.scope);
|
|
786
|
+
});
|
|
513
787
|
const map = new Map<string, ModuleInfo>();
|
|
514
788
|
for (const row of rows) {
|
|
515
789
|
const directory = row.file.split("/").slice(0, -1).join("/");
|
|
@@ -547,6 +821,51 @@ export function listFiles(index: IndexState, module: string, options: { kind?: s
|
|
|
547
821
|
.slice(0, limit);
|
|
548
822
|
}
|
|
549
823
|
|
|
824
|
+
/**
|
|
825
|
+
* Real **file names** whose basename shares a token with `query` — the answer to "this header does not
|
|
826
|
+
* exist, what does" (§3 of the field findings). These are file names, never type names: a 0-row symbol
|
|
827
|
+
* lookup must not turn into a fuzzy type suggestion, so callers label them as files and say so.
|
|
828
|
+
*
|
|
829
|
+
* Tokenisation splits camelCase and separators: `MoverState` → `mover, state`; a candidate scores by
|
|
830
|
+
* how many of the query's tokens it contains, so `MoverTypes.h` and `MoverSimulationTypes.h` outrank
|
|
831
|
+
* `MoverEditor.h`.
|
|
832
|
+
*/
|
|
833
|
+
export function suggestFileNames(index: IndexState, query: string, options: { limit?: number; module?: string } = {}): { file: string; module: string; score: number; tokens: string[] }[] {
|
|
834
|
+
const tokens = nameTokens(baseName(query).replace(/\.(h|hpp|hxx|inl|cpp|cc|c|md)$/i, ""));
|
|
835
|
+
if (!tokens.length) return [];
|
|
836
|
+
const limit = options.limit ?? 8;
|
|
837
|
+
const module = options.module?.toLowerCase();
|
|
838
|
+
const out: { file: string; module: string; score: number; tokens: string[] }[] = [];
|
|
839
|
+
for (const row of readFileRows(index)) {
|
|
840
|
+
if (module && row.module.toLowerCase() !== module) continue;
|
|
841
|
+
const stem = baseName(row.file).replace(/\.(h|hpp|hxx|inl|cpp|cc|c)$/i, "");
|
|
842
|
+
const mine = nameTokens(stem);
|
|
843
|
+
let score = 0;
|
|
844
|
+
for (const token of tokens) if (mine.includes(token)) score++;
|
|
845
|
+
if (!score) continue;
|
|
846
|
+
/* Two tie-breakers that matter when a name is invented: a row containing *every* query token is
|
|
847
|
+
* closer than one sharing a single generic token, and the public header of a module is the file
|
|
848
|
+
* an engineer is meant to include (a Private/Logging.h is not the answer to a question about a
|
|
849
|
+
* component). */
|
|
850
|
+
if (score === tokens.length) score += 2;
|
|
851
|
+
if (fileKindLabel(row.file) === "public-header") score += 1;
|
|
852
|
+
out.push({ file: row.file, module: row.module, score, tokens: mine });
|
|
853
|
+
}
|
|
854
|
+
out.sort((a, b) => b.score - a.score || baseName(a.file).length - baseName(b.file).length || a.file.localeCompare(b.file));
|
|
855
|
+
return out.slice(0, limit);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/** `BlendStackAnimInstance` → `blend, stack, anim, instance` (the vocabulary suggestions are scored with). */
|
|
859
|
+
export function nameTokens(value: string): string[] {
|
|
860
|
+
return (
|
|
861
|
+
value
|
|
862
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
863
|
+
.split(/[^A-Za-z0-9]+/)
|
|
864
|
+
.map((token) => token.toLowerCase())
|
|
865
|
+
.filter((token) => token.length > 1) as string[]
|
|
866
|
+
);
|
|
867
|
+
}
|
|
868
|
+
|
|
550
869
|
/* ──────────────────────────────────────────────────────────────────────── compile unit */
|
|
551
870
|
|
|
552
871
|
export interface ContextUnit {
|
|
@@ -768,6 +1087,226 @@ export function pickIndex(filter: { key?: string; mode?: "definitions" | "full";
|
|
|
768
1087
|
return { index: pool[0], candidates: pool, notes };
|
|
769
1088
|
}
|
|
770
1089
|
|
|
1090
|
+
/* ────────────────────────────────────────────────────────── header (path-only) index */
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* A second, much cheaper index: **file paths only**, never file contents.
|
|
1094
|
+
*
|
|
1095
|
+
* It exists because the highest-value question in a UE session is "does this header/type exist in
|
|
1096
|
+
* *this engine version*?", and a definitions index cannot answer a no — absence from a partially
|
|
1097
|
+
* scanned root set is not absence from the engine. Walking the tree and recording paths is a few
|
|
1098
|
+
* seconds (measured: 52,568 plugin sources + 18,681 Runtime files in ~1 s of walking, no reads), so
|
|
1099
|
+
* "exists / does not exist, here is the exact path" becomes an answer the tools can actually give.
|
|
1100
|
+
*
|
|
1101
|
+
* Built only by an explicit `unreal_index action: "headers"` — never implicitly by a lookup, and
|
|
1102
|
+
* never as a side effect of a symbol query.
|
|
1103
|
+
*/
|
|
1104
|
+
export const HEADER_VERSION = 1;
|
|
1105
|
+
|
|
1106
|
+
/** Header extensions the path index records (the things an `#include` line can name). */
|
|
1107
|
+
const HEADER_SCAN_EXT = [".h", ".hpp", ".hxx", ".inl"];
|
|
1108
|
+
|
|
1109
|
+
export interface HeaderIndexMeta {
|
|
1110
|
+
version: number;
|
|
1111
|
+
key: string;
|
|
1112
|
+
generatedAt: string;
|
|
1113
|
+
roots: string[];
|
|
1114
|
+
engineRoot?: string;
|
|
1115
|
+
projectRoot?: string;
|
|
1116
|
+
areas?: string[];
|
|
1117
|
+
files: number;
|
|
1118
|
+
/** Root provenance by kind, same vocabulary as `IndexMeta.rootKinds`. */
|
|
1119
|
+
rootKinds?: Record<string, number>;
|
|
1120
|
+
path: string;
|
|
1121
|
+
sizeBytes: number;
|
|
1122
|
+
warnings: string[];
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
export interface HeaderIndex {
|
|
1126
|
+
key: string;
|
|
1127
|
+
dir: string;
|
|
1128
|
+
meta: HeaderIndexMeta;
|
|
1129
|
+
ageMs: number;
|
|
1130
|
+
healthy: boolean;
|
|
1131
|
+
issues: string[];
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
export function headersRoot(): string {
|
|
1135
|
+
return normalizePath(joinPath(cacheDir(), "headers"));
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
/** Path-only index key: the root set (engine identity is part of the roots, so it is implied). */
|
|
1139
|
+
export function headerKeyFor(input: { roots: string[]; label?: string }): string {
|
|
1140
|
+
const roots = input.roots.map((root) => normalizePath(root).toLowerCase()).sort();
|
|
1141
|
+
const fingerprint = shortHash(["headers", ...roots].join("|"));
|
|
1142
|
+
const label = (input.label ?? roots[0] ?? "index").split("/").filter(Boolean).pop() ?? "index";
|
|
1143
|
+
return `hdr-${label.replace(/[^a-z0-9]/gi, "")}-${fingerprint}`;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
export async function buildHeaderIndex(input: {
|
|
1147
|
+
key?: string;
|
|
1148
|
+
roots: ScanRoot[];
|
|
1149
|
+
engineRoot?: string;
|
|
1150
|
+
projectRoot?: string;
|
|
1151
|
+
areas?: string[];
|
|
1152
|
+
concurrency?: number;
|
|
1153
|
+
}): Promise<{ key: string; dir: string; files: number; durationMs: number; meta: HeaderIndexMeta; built: boolean }> {
|
|
1154
|
+
const started = Date.now();
|
|
1155
|
+
const dirs = input.roots.map((root) => normalizePath(root.dir));
|
|
1156
|
+
const key = input.key ?? headerKeyFor({ roots: dirs });
|
|
1157
|
+
const dir = normalizePath(joinPath(headersRoot(), key));
|
|
1158
|
+
mkdirSync(dir, { recursive: true });
|
|
1159
|
+
const warnings: string[] = [];
|
|
1160
|
+
|
|
1161
|
+
const discovered = await collectSourceFiles(input.roots, { concurrency: input.concurrency });
|
|
1162
|
+
const rootKinds: Record<string, number> = {};
|
|
1163
|
+
for (const entry of summarizeRootKinds(dirs, { projectRoot: input.projectRoot, engineRoot: input.engineRoot })) {
|
|
1164
|
+
rootKinds[entry.kind] = entry.count;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
const seen = new Set<string>();
|
|
1168
|
+
const rows: string[] = [];
|
|
1169
|
+
for (const entry of discovered) {
|
|
1170
|
+
const lower = entry.abs.toLowerCase();
|
|
1171
|
+
if (!HEADER_SCAN_EXT.some((ext) => lower.endsWith(ext))) continue;
|
|
1172
|
+
if (seen.has(lower)) continue;
|
|
1173
|
+
seen.add(lower);
|
|
1174
|
+
const module = entry.root.module ?? moduleFromBuildCs(entry.abs);
|
|
1175
|
+
rows.push(`${esc(entry.abs)}\t${esc(module)}\t${fileKindLabel(entry.abs)}`);
|
|
1176
|
+
}
|
|
1177
|
+
rows.sort((a, b) => a.localeCompare(b));
|
|
1178
|
+
|
|
1179
|
+
const tsv = `file\tmodule\tkind\n${rows.join("\n")}\n`;
|
|
1180
|
+
const path = normalizePath(joinPath(dir, "headers.tsv"));
|
|
1181
|
+
writeTextAtomic(path, tsv);
|
|
1182
|
+
|
|
1183
|
+
const meta: HeaderIndexMeta = {
|
|
1184
|
+
version: HEADER_VERSION,
|
|
1185
|
+
key,
|
|
1186
|
+
generatedAt: new Date().toISOString(),
|
|
1187
|
+
roots: dirs,
|
|
1188
|
+
engineRoot: input.engineRoot ? normalizePath(input.engineRoot) : undefined,
|
|
1189
|
+
projectRoot: input.projectRoot ? normalizePath(input.projectRoot) : undefined,
|
|
1190
|
+
areas: input.areas,
|
|
1191
|
+
files: rows.length,
|
|
1192
|
+
rootKinds,
|
|
1193
|
+
path,
|
|
1194
|
+
sizeBytes: Buffer.byteLength(tsv, "utf8"),
|
|
1195
|
+
warnings,
|
|
1196
|
+
};
|
|
1197
|
+
writeJsonAtomic(joinPath(dir, "meta.json"), meta);
|
|
1198
|
+
return { key, dir, files: rows.length, durationMs: Date.now() - started, meta, built: true };
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
export function listHeaderIndexes(): HeaderIndex[] {
|
|
1202
|
+
const root = headersRoot();
|
|
1203
|
+
if (!existsSync(root)) return [];
|
|
1204
|
+
const out: HeaderIndex[] = [];
|
|
1205
|
+
for (const key of readdirSync(root)) {
|
|
1206
|
+
const meta = readJson<HeaderIndexMeta>(joinPath(root, key, "meta.json"));
|
|
1207
|
+
if (!meta || !meta.path) continue;
|
|
1208
|
+
const issues: string[] = [];
|
|
1209
|
+
if (meta.version !== HEADER_VERSION) issues.push(`format v${meta.version}, current v${HEADER_VERSION}`);
|
|
1210
|
+
if (!existsSync(meta.path)) issues.push("headers.tsv missing");
|
|
1211
|
+
for (const r of meta.roots ?? []) if (!existsSync(r)) issues.push(`root no longer present: ${r}`);
|
|
1212
|
+
let ageMs = 0;
|
|
1213
|
+
try {
|
|
1214
|
+
ageMs = Date.now() - statSync(joinPath(root, key)).mtimeMs;
|
|
1215
|
+
} catch {
|
|
1216
|
+
/* unreadable directory: report age 0 */
|
|
1217
|
+
}
|
|
1218
|
+
out.push({ key, dir: normalizePath(joinPath(root, key)), meta, ageMs, healthy: issues.length === 0, issues });
|
|
1219
|
+
}
|
|
1220
|
+
return out.sort((a, b) => b.ageMs - a.ageMs);
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
export function loadHeaderIndex(key: string): HeaderIndex | undefined {
|
|
1224
|
+
const dir = normalizePath(joinPath(headersRoot(), key));
|
|
1225
|
+
const meta = readJson<HeaderIndexMeta>(joinPath(dir, "meta.json"));
|
|
1226
|
+
if (!meta) return undefined;
|
|
1227
|
+
let ageMs = 0;
|
|
1228
|
+
try {
|
|
1229
|
+
ageMs = Date.now() - statSync(dir).mtimeMs;
|
|
1230
|
+
} catch {
|
|
1231
|
+
/* caller reports the missing directory */
|
|
1232
|
+
}
|
|
1233
|
+
return { key, dir, meta, ageMs, healthy: meta.version === HEADER_VERSION && existsSync(meta.path), issues: meta.version === HEADER_VERSION ? [] : [`format v${meta.version}, current v${HEADER_VERSION}`] };
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
/** The newest healthy path index over exactly these roots. Never builds anything. */
|
|
1237
|
+
export function pickHeaderIndex(roots: string[]): HeaderIndex | undefined {
|
|
1238
|
+
const want = rootsSignature(roots);
|
|
1239
|
+
return listHeaderIndexes().find((state) => state.healthy && rootsSignature(state.meta.roots ?? []) === want);
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
export interface HeaderRow {
|
|
1243
|
+
file: string;
|
|
1244
|
+
module: string;
|
|
1245
|
+
kind: string;
|
|
1246
|
+
/** `exact` = that file name exists; `suffix` = a path suffix match; `token` = shares name tokens. */
|
|
1247
|
+
match: "exact" | "suffix" | "token";
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
function readHeaderRows(index: HeaderIndex): { file: string; module: string; kind: string }[] {
|
|
1251
|
+
const text = readFileSync(index.meta.path, "utf8");
|
|
1252
|
+
return readTsv(text) as unknown as { file: string; module: string; kind: string }[];
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
/**
|
|
1256
|
+
* "Does this header exist?" — exact file-name match first, then path suffix, then name-token overlap.
|
|
1257
|
+
* Token hits are labelled `token`: they are *related files*, not the file asked for, and callers must
|
|
1258
|
+
* present them that way (an invented name must not be quietly reinterpreted as a real one).
|
|
1259
|
+
*/
|
|
1260
|
+
export function lookupHeaderPaths(index: HeaderIndex, name: string, options: { limit?: number } = {}): HeaderRow[] {
|
|
1261
|
+
const raw = normalizePath(name).trim();
|
|
1262
|
+
const leafRaw = baseName(raw).replace(/\.(h|hpp|hxx|inl)$/i, "");
|
|
1263
|
+
const leaf = baseName(raw).toLowerCase();
|
|
1264
|
+
const stem = leaf.replace(/\.(h|hpp|hxx|inl)$/i, "");
|
|
1265
|
+
/* Tokenise the cased name: `PoseSearchResultTypes` → pose, search, result, types. A lowered stem
|
|
1266
|
+
* would tokenise as one long token and match nothing. */
|
|
1267
|
+
const queryTokens = nameTokens(leafRaw);
|
|
1268
|
+
const limit = options.limit ?? 12;
|
|
1269
|
+
const out: HeaderRow[] = [];
|
|
1270
|
+
for (const row of readHeaderRows(index)) {
|
|
1271
|
+
const rowLeaf = baseName(row.file).toLowerCase();
|
|
1272
|
+
const rowLower = row.file.toLowerCase();
|
|
1273
|
+
let match: HeaderRow["match"] | undefined;
|
|
1274
|
+
if (rowLeaf === leaf || rowLeaf === `${stem}.h`) match = "exact";
|
|
1275
|
+
else if (raw.includes("/") && rowLower.endsWith(leaf)) match = "suffix";
|
|
1276
|
+
else if (queryTokens.length > 1) {
|
|
1277
|
+
const rowTokens = nameTokens(rowLeaf.replace(/\.(h|hpp|hxx|inl)$/i, ""));
|
|
1278
|
+
let hits = 0;
|
|
1279
|
+
for (const token of queryTokens) if (rowTokens.includes(token)) hits++;
|
|
1280
|
+
if (hits === queryTokens.length) match = "token";
|
|
1281
|
+
}
|
|
1282
|
+
if (!match) continue;
|
|
1283
|
+
out.push({ file: row.file, module: row.module, kind: row.kind, match });
|
|
1284
|
+
}
|
|
1285
|
+
const order = { exact: 0, suffix: 1, token: 2 } as const;
|
|
1286
|
+
out.sort((a, b) => order[a.match] - order[b.match] || a.file.localeCompare(b.file));
|
|
1287
|
+
return out.slice(0, limit);
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
/** Real header names related to `name` (for the "this header does not exist, here is what does" answer). */
|
|
1291
|
+
export function suggestHeaderPaths(index: HeaderIndex, name: string, options: { limit?: number } = {}): HeaderRow[] {
|
|
1292
|
+
const stem = baseName(name).replace(/\.(h|hpp|hxx|inl)$/i, "");
|
|
1293
|
+
const queryTokens = nameTokens(stem);
|
|
1294
|
+
if (!queryTokens.length) return [];
|
|
1295
|
+
const limit = options.limit ?? 8;
|
|
1296
|
+
const out: { row: HeaderRow; score: number }[] = [];
|
|
1297
|
+
for (const row of readHeaderRows(index)) {
|
|
1298
|
+
const rowTokens = nameTokens(baseName(row.file).replace(/\.(h|hpp|hxx|inl)$/i, ""));
|
|
1299
|
+
let score = 0;
|
|
1300
|
+
for (const token of queryTokens) if (rowTokens.includes(token)) score++;
|
|
1301
|
+
if (!score) continue;
|
|
1302
|
+
if (score === queryTokens.length) score += 2;
|
|
1303
|
+
if (row.kind === "public-header") score += 1;
|
|
1304
|
+
out.push({ row: { ...row, match: "token" }, score });
|
|
1305
|
+
}
|
|
1306
|
+
out.sort((a, b) => b.score - a.score || baseName(a.row.file).length - baseName(b.row.file).length || a.row.file.localeCompare(b.row.file));
|
|
1307
|
+
return out.slice(0, limit).map((entry) => entry.row);
|
|
1308
|
+
}
|
|
1309
|
+
|
|
771
1310
|
/* ──────────────────────────────────────────────────────────────────────── helpers */
|
|
772
1311
|
|
|
773
1312
|
const fileKindLabel = (file: string) => (/\/public\//i.test(file) ? "public-header" : /\.(cpp|cc|c|mm|cpp20)$/i.test(file) ? "source" : /\/internal\//i.test(file) ? "internal-header" : "header");
|
|
@@ -804,12 +1343,27 @@ function writeJsonAtomic(path: string, value: unknown): void {
|
|
|
804
1343
|
writeTextAtomic(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
805
1344
|
}
|
|
806
1345
|
|
|
807
|
-
/**
|
|
808
|
-
|
|
1346
|
+
/**
|
|
1347
|
+
* Module attribution without Query data: the nearest `*.Build.cs` above the file.
|
|
1348
|
+
*
|
|
1349
|
+
* The **module file name is the module name** — UBT names a module after its `X.Build.cs`, not after
|
|
1350
|
+
* the directory. `Engine/Plugins/Animation/PoseSearch/Source/Runtime/PoseSearch.Build.cs` is module
|
|
1351
|
+
* `PoseSearch` in a directory leaf-named `Runtime`, and `Runtime` is a leaf reused by 10 other modules
|
|
1352
|
+
* in the 5.8 tree (`Source` 20x, `Runtime` 10x, `Editor` 9x, `Module` 5x out of 1,079). Naming the
|
|
1353
|
+
* leaf is what made `unreal_symbol module: "PoseSearch"` return 0 rows in a definitions index while
|
|
1354
|
+
* `unreal_context module: "PoseSearch"` (UBT data) returned the truth.
|
|
1355
|
+
*
|
|
1356
|
+
* `moduleNames` (from a `.uplugin`'s Modules[].Name) breaks ties when one directory owns several
|
|
1357
|
+
* `*.Build.cs` files: prefer the declared name, then the leaf, then the shortest.
|
|
1358
|
+
*/
|
|
1359
|
+
export function moduleFromBuildCs(file: string, moduleNames?: string[]): string {
|
|
809
1360
|
const parts = normalizePath(file).split("/");
|
|
810
1361
|
for (let i = parts.length - 2; i >= 0; i--) {
|
|
811
|
-
const
|
|
812
|
-
|
|
1362
|
+
const dir = parts.slice(0, i + 1).join("/");
|
|
1363
|
+
const leaf = parts[i] ?? "";
|
|
1364
|
+
const names = buildCsNames(dir);
|
|
1365
|
+
if (!names.length) continue;
|
|
1366
|
+
return pickModuleName(names, leaf, moduleNames);
|
|
813
1367
|
}
|
|
814
1368
|
const marker = parts.indexOf("Source");
|
|
815
1369
|
const candidate = marker >= 0 ? parts[marker + 1] : undefined;
|
|
@@ -817,6 +1371,30 @@ export function moduleFromBuildCs(file: string): string {
|
|
|
817
1371
|
return parts[parts.length - 2] ?? "root";
|
|
818
1372
|
}
|
|
819
1373
|
|
|
1374
|
+
/** Module names owned by `dir`: basenames of its `*.Build.cs` files (empty when it owns none). */
|
|
1375
|
+
export function buildCsNames(dir: string): string[] {
|
|
1376
|
+
const here = normalizePath(dir);
|
|
1377
|
+
let names: string[];
|
|
1378
|
+
try {
|
|
1379
|
+
names = readdirSync(here);
|
|
1380
|
+
} catch {
|
|
1381
|
+
return [];
|
|
1382
|
+
}
|
|
1383
|
+
return names.filter((entry) => entry.endsWith(".Build.cs")).map((entry) => entry.slice(0, -".Build.cs".length));
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
/** Choose the module name a directory's `*.Build.cs` files imply (see `moduleFromBuildCs`). */
|
|
1387
|
+
export function pickModuleName(names: string[], leaf: string, moduleNames?: string[]): string {
|
|
1388
|
+
if (names.length === 0) return leaf;
|
|
1389
|
+
if (names.length === 1) return names[0]!;
|
|
1390
|
+
if (moduleNames?.length) {
|
|
1391
|
+
const declared = names.filter((name) => moduleNames.includes(name)).sort((a, b) => a.length - b.length);
|
|
1392
|
+
if (declared.length) return declared[0]!;
|
|
1393
|
+
}
|
|
1394
|
+
if (names.includes(leaf)) return leaf;
|
|
1395
|
+
return [...names].sort((a, b) => a.length - b.length || a.localeCompare(b))[0]!;
|
|
1396
|
+
}
|
|
1397
|
+
|
|
820
1398
|
/**
|
|
821
1399
|
* Scan roots for an index build. A "root" is one module directory (the directory holding a
|
|
822
1400
|
* `*.Build.cs`), because that is the unit UBT compiles and the unit a symbol's module belongs to.
|
|
@@ -831,8 +1409,16 @@ export interface RootOptions {
|
|
|
831
1409
|
/** Plugin directories (absolute), e.g. from `PluginSummary.dir`. */
|
|
832
1410
|
pluginDirs?: string[];
|
|
833
1411
|
includeEngine?: boolean;
|
|
834
|
-
/**
|
|
1412
|
+
/**
|
|
1413
|
+
* What to scan on the engine side. Two shapes, both rooted at the engine install:
|
|
1414
|
+
* - an `Engine/Source` area: `"Runtime"`, `"Editor"`, `"Runtime/Core"`, `"Runtime/CoreUObject"`;
|
|
1415
|
+
* - an engine-plugin area, prefixed `Plugins`: `"Plugins"` (every plugin under `Engine/Plugins`,
|
|
1416
|
+
* ~2.8x the Runtime scan) or a subtree `"Plugins/Animation/PhysicsControl"`.
|
|
1417
|
+
* Default `DEFAULT_ENGINE_AREAS` (`["Runtime"]`).
|
|
1418
|
+
*/
|
|
835
1419
|
engineAreas?: string[];
|
|
1420
|
+
/** Declared module names per plugin root dir (from its `.uplugin`), used to name modules correctly. */
|
|
1421
|
+
pluginModuleNames?: Record<string, string[]>;
|
|
836
1422
|
fs?: FsAdapter;
|
|
837
1423
|
}
|
|
838
1424
|
|
|
@@ -848,11 +1434,12 @@ const SKIP_DIR_NAMES = new Set(["Intermediate", "Binaries", "Saved", ".git", ".v
|
|
|
848
1434
|
* modules at `Runtime/Core`, `Runtime/Engine/Classes`... and areas are addressable as
|
|
849
1435
|
* `engineAreas: ["Runtime/Core"]`, so the search must recurse (depth-capped, build-system dirs skipped).
|
|
850
1436
|
*/
|
|
851
|
-
function moduleDirsUnder(dir: string, scope: ScanRoot["scope"], fs: FsAdapter, maxDepth = 3, depth = 0): ScanRoot[] {
|
|
1437
|
+
function moduleDirsUnder(dir: string, scope: ScanRoot["scope"], fs: FsAdapter, maxDepth = 3, depth = 0, moduleNames?: string[]): ScanRoot[] {
|
|
852
1438
|
const roots: ScanRoot[] = [];
|
|
853
1439
|
const here = normalizePath(dir);
|
|
854
|
-
|
|
855
|
-
|
|
1440
|
+
const names = buildCsNames(here);
|
|
1441
|
+
if (names.length) {
|
|
1442
|
+
roots.push({ dir: here, scope, module: pickModuleName(names, baseName(here), moduleNames) });
|
|
856
1443
|
return roots; // a module's own tree never contains another module
|
|
857
1444
|
}
|
|
858
1445
|
if (depth >= maxDepth) return roots;
|
|
@@ -860,7 +1447,7 @@ function moduleDirsUnder(dir: string, scope: ScanRoot["scope"], fs: FsAdapter, m
|
|
|
860
1447
|
const abs = joinPath(here, child);
|
|
861
1448
|
if (!fs.isDirectory(abs)) continue;
|
|
862
1449
|
if (SKIP_DIR_NAMES.has(child)) continue;
|
|
863
|
-
roots.push(...moduleDirsUnder(abs, scope, fs, maxDepth, depth + 1));
|
|
1450
|
+
roots.push(...moduleDirsUnder(abs, scope, fs, maxDepth, depth + 1, moduleNames));
|
|
864
1451
|
}
|
|
865
1452
|
return roots;
|
|
866
1453
|
}
|
|
@@ -879,7 +1466,8 @@ export function assembleRoots(options: RootOptions): { roots: ScanRoot[]; notes:
|
|
|
879
1466
|
|
|
880
1467
|
for (const pluginDir of options.pluginDirs ?? []) {
|
|
881
1468
|
const abs = normalizePath(pluginDir);
|
|
882
|
-
const
|
|
1469
|
+
const moduleNames = options.pluginModuleNames?.[abs];
|
|
1470
|
+
const found = moduleDirsUnder(joinPath(abs, "Source"), "Plugin", fs, 3, 0, moduleNames);
|
|
883
1471
|
roots.push(...found);
|
|
884
1472
|
if (!found.length) notes.push(`plugin ${baseName(abs)}: no C++ module roots`);
|
|
885
1473
|
}
|
|
@@ -888,7 +1476,9 @@ export function assembleRoots(options: RootOptions): { roots: ScanRoot[]; notes:
|
|
|
888
1476
|
const engineRoot = normalizePath(options.engineRoot);
|
|
889
1477
|
const areas = options.engineAreas?.length ? options.engineAreas : DEFAULT_ENGINE_AREAS;
|
|
890
1478
|
for (const area of areas) {
|
|
891
|
-
|
|
1479
|
+
// "Plugins/..." addresses Engine/Plugins; anything else is an Engine/Source area.
|
|
1480
|
+
const pluginArea = /^plugins(\/|$)/i.test(area);
|
|
1481
|
+
const found = moduleDirsUnder(joinPath(engineRoot, pluginArea ? "Engine" : "Engine/Source", area), "Engine", fs, pluginArea ? 6 : 4);
|
|
892
1482
|
roots.push(...found);
|
|
893
1483
|
notes.push(`engine ${area}: ${found.length} module root(s)`);
|
|
894
1484
|
}
|