@aeonsparx/pi-unreal 0.0.0-stage → 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.
@@ -0,0 +1,510 @@
1
+ /**
2
+ * Compiler-free C++ definition scan.
3
+ *
4
+ * What clangd/IntelliSense give you (hover, call hierarchy, cross-references) needs a compiler and a
5
+ * compile database. What an agent needs most of the time — "where is this class, function, enum,
6
+ * delegate, or property defined, and in which module?" — is a text fact, so this is a single-pass
7
+ * line scanner over engine, project, and plugin source trees. Measured cost: ~18.5k files (Engine/
8
+ * Source/Runtime) in 2.7 s in Python, so engine-wide lands in tens of seconds, against the ~2 h it
9
+ * takes clangd to index the same tree with a generated compile database.
10
+ *
11
+ * It is a heuristic scanner, and the tools say so in their descriptions. UHT macros are the
12
+ * reliable part (`UCLASS`/`USTRUCT`/`UENUM`/`UPROPERTY`/`UFUNCTION` and the `DECLARE_*DELEGATE`
13
+ * family are engine-greppable invariants); plain `class`/`struct`/`enum`/`function` rows come from
14
+ * line patterns and can miss a declaration split across lines in an unusual format. Prefix and
15
+ * substring matching make a miss visible (a `prefixHits` count is always reported), and
16
+ * `unreal_clangd` is the enrichment path for hover/call-hierarchy on a box that has clangd.
17
+ *
18
+ * Skips `Intermediate/`, `Binaries/`, `Saved/`, `.git/` and `*.generated.h` (UHT output: metadata
19
+ * and thunk declarations, not authoring sites), and `ThirdParty/` by default — the engine's
20
+ * vendored code is not the answer to "where is the engine feature", and it is the bulk of the tree.
21
+ */
22
+
23
+ import { readdir, readFile } from "node:fs/promises";
24
+ import { type Dirent } from "node:fs";
25
+ import { joinPath, normalizePath } from "./util.ts";
26
+
27
+ export type DefKind = "class" | "struct" | "enum" | "union" | "function" | "delegate" | "property" | "alias";
28
+
29
+ /** One definition row. `module` is the module that owns `file`, resolved from UBT's dirToModule. */
30
+ export interface SymbolDef {
31
+ symbol: string;
32
+ kind: DefKind;
33
+ /** Definition site, as a path relative to the scan root that contains it (forward slashes). */
34
+ file: string;
35
+ line: number;
36
+ module: string;
37
+ /** Engine module, project module, plugin module, or "" when UBT did not map the directory. */
38
+ scope: "Engine" | "Project" | "Plugin" | "";
39
+ /** `"UCLASS spec=ClassGroup=Render"` for macro rows; `spec=UPROPERTY`/`spec=UFUNCTION` for property/function rows. */
40
+ marker: string;
41
+ }
42
+
43
+ export interface ScanRoot {
44
+ /** Absolute directory to walk (a module's `Source/`, an engine `Source/`, a plugin dir). */
45
+ dir: string;
46
+ scope: "Engine" | "Project" | "Plugin";
47
+ /** Module owning this root; used when UBT's dirToModule has no entry for a subdirectory. */
48
+ module?: string;
49
+ }
50
+
51
+ export interface ScanOptions {
52
+ /** Directories never descended into (matched case-insensitively against the directory name). */
53
+ skipDirs?: string[];
54
+ /** Scan `<root>/ThirdParty` trees. Off by default (vendored code, ~half the engine tree). */
55
+ includeThirdParty?: boolean;
56
+ /** Files above this size are skipped (generated/bundled blobs). Default 1 MB. */
57
+ maxFileBytes?: number;
58
+ concurrency?: number;
59
+ /** Progress hook: fires per root with the running file count. */
60
+ onProgress?: (done: number, root: string) => void;
61
+ }
62
+
63
+ export interface ScanStats {
64
+ scannedFiles: number;
65
+ scannedBytes: number;
66
+ skippedDirs: number;
67
+ skippedLarge: number;
68
+ defs: number;
69
+ defsByKind: Record<string, number>;
70
+ /** Files whose content produced no rows at all (mostly headers of pure forward declarations). */
71
+ emptyFiles: number;
72
+ durationMs: number;
73
+ }
74
+
75
+ const DEFAULT_SKIP_DIRS = [
76
+ "Intermediate",
77
+ "Binaries",
78
+ "Saved",
79
+ "DerivedDataCache",
80
+ ".git",
81
+ ".vs",
82
+ ".idea",
83
+ "out",
84
+ "obj",
85
+ "node_modules",
86
+ "ThirdParty",
87
+ "Platforms",
88
+ ];
89
+
90
+ /** UE's own sourceFileExtensions (ShaderCompileWorker/UBT conventions), minus `.cs` (C#). */
91
+ const SCAN_EXT = [".h", ".hpp", ".hxx", ".inl", ".cpp", ".cc", ".cxx", ".c", ".mm", ".cpp20", ".template", ".inc", ".ush", ".usf"];
92
+
93
+ const DEFAULT_MAX_FILE_BYTES = 1024 * 1024;
94
+ const DEFAULT_CONCURRENCY = 24;
95
+
96
+ /* ──────────────────────────────────────────────────────────────────────────── patterns */
97
+
98
+ /** `UCLASS(...)`, `USTRUCT()`, `UENUM(BlueprintType)` … — the declaration name comes on a following line. */
99
+ const RE_MACRO = /^\s*(UCLASS|USTRUCT|UENUM|UINTERFACE|UCLASS_ABSTRACT|UINTERFACE_BLUEPRINTTYPE)\b(.*)$/;
100
+ /** `class ENGINE_API UAnimInstance : public X` / `struct FFoo final : public Bar`. */
101
+ const RE_RECORD = /\b(class|struct)\s+([A-Z][A-Za-z0-9_]*_?API\s+)?(final\s+)?([A-Za-z_][A-Za-z0-9_:]*)\b/g;
102
+ /**
103
+ * What may follow a record name for the line to be a declaration of that type at all: a base list,
104
+ * a body, a forward semicolon, `final`, a template head, a specifier attribute, or end of line
105
+ * (UE writes the brace on the next line). `virtual bool SerializeNewActor(FArchive& Ar, class
106
+ * AActor *& Actor)` matches RE_RECORD and declares nothing — it *uses* the type, and a scanner that
107
+ * records it claims a definition site the compiler knows nothing about. This check is what keeps
108
+ * `unreal_symbol AActor` pointing at `GameFramework/Actor.h`.
109
+ */
110
+ const RE_RECORD_TAIL = /^\s*(?::|\{|\[\[|;|final\b|override\b|<|$)/;
111
+ const RE_ENUM = /\benum\s+(class\s+|struct\s+)?([A-Z][A-Za-z0-9_]*_?API\s+)?([A-Za-z_][A-Za-z0-9_:]*)\b/;
112
+ const RE_UNION = /\bunion\s+(?:[A-Z][A-Z0-9_]*_API\s+)?([A-Za-z_][A-Za-z0-9_]*)\b/;
113
+ /** Forward declarations: `class UTexture;` — cheap to record, tells the agent where the real header is not. */
114
+ const RE_FORWARD = /^\s*(?:friend\s+)?(class|struct)\s+([A-Za-z_][A-Za-z0-9_]*)\s*(;|\{)/;
115
+ /** `DECLARE_DELEGATE`, `DECLARE_MULTICAST_DELEGATE_TwoParams`, `DECLARE_DYNAMIC_MULTICAST_DELEGATE_FullParams`, `DECLARE_..._SCRIPT_DELEGATE` … */
116
+ const RE_DELEGATE = /\b(DECLARE_(?:SCRIPT_)?(?:DYNAMIC_)?(?:MULTICAST_)?(?:DELEGATE|FUNCTION)[A-Za-z_]*)\s*\(([^)]*)\)\s*[{;]?/;
117
+ /** `UPROPERTY(...)`, `UFUNCTION(BlueprintCallable, ...)`, `UENUM BlueprintType` specifiers on their own line. */
118
+ const RE_SPECIFIER = /^\s*(UPROPERTY|UFUNCTION|UMETA|UPARAM|UDELEGATE)\b/;
119
+ /**
120
+ * Function signature detector. A regex alone gets this wrong in both directions: UE puts the opening
121
+ * brace of a definition on the next line (Allman), so a pattern anchored on `{`/`;` misses every
122
+ * `void UFoo::Bar(float X)` definition, while a pattern loose enough to accept end-of-line also
123
+ * accepts `UE_LOG(...)`, `Foo::Bar();` calls and `const FVector V = Compute(...)` locals. The rules
124
+ * below are what makes the rows trustworthy:
125
+ * - the text before the first `(` is `returnType [Qualifier::] name`, i.e. at least two tokens
126
+ * (a bare `Foo::Bar()` call has one, so it is rejected), and carries no `=`;
127
+ * - the text after the matching `)` is qualifiers (`const`, `override`, `final`, `noexcept`,
128
+ * `[[attr]]`, `= 0`) and at most one `{`, and contains no `(`;
129
+ * - control flow, preprocessor, `operator`, and template lines are excluded.
130
+ */
131
+ const RE_HEAD = /^(?<type>(?:(?:const|static\s+constexpr|static|constexpr|virtual|explicit|inline|friend|extern|thread_local|mutable|noescape)\s+)*[A-Za-z_][A-Za-z0-9_:<>,&*\s]*?)\s+(?:(?<qual>[A-Za-z_][A-Za-z0-9_]*)::)*(?<name>[A-Za-z_~][A-Za-z0-9_]*)$/;
132
+ const RE_TAIL = /^(?:\s*(?:const|override|final|noexcept|volatile|\[\[.*?\]\]|=\s*0))*[^(){}]*\{?\s*$/;
133
+ const RE_ALIAS = /^\s*(using\s+([A-Za-z_][A-Za-z0-9_]*)\s*=|typedef\s+[^;]+\s+([A-Za-z_][A-Za-z0-9_]*)\s*;)/;
134
+
135
+ /** Lines that look like a function signature but are control flow, attributes, or macro calls. */
136
+ const NOT_A_FUNCTION = /^\s*(if|for|while|switch|return|else|do|catch|case|default|delete|new|throw|co_await|co_return|co_yield|static_assert|template|operator|__|elif|endif|ifdef|ifndef|pragma|define|include|using)\b/;
137
+ const FUNCTION_NAME_BLACKLIST = new Set(["if", "for", "while", "switch", "return", "else", "do", "catch", "case", "default", "new", "delete", "throw", "sizeof", "alignof", "decltype", "static_cast", "dynamic_cast", "const_cast", "reinterpret_cast", "operator", "template", "typename", "public", "private", "protected", "signals", "Q_OBJECT"]);
138
+
139
+ /* ──────────────────────────────────────────────────────────────────────────── text scan */
140
+
141
+ export interface IncludeRef {
142
+ /** Including file, relative to its scan root. */
143
+ file: string;
144
+ /** Included name as written, without quotes/angles. */
145
+ target: string;
146
+ angled: boolean;
147
+ line: number;
148
+ }
149
+
150
+ const RE_INCLUDE = /^\s*#\s*include\s*["<]([^">]+)[">]/;
151
+
152
+ /**
153
+ * Scan one source file. `file` is the display path (relative to the scan root), `module`/`scope` come
154
+ * from the caller's dirToModule resolution. Returns rows in file order, deduplicated by
155
+ * symbol+kind+line.
156
+ */
157
+ export function scanText(text: string, file: string, module: string, scope: SymbolDef["scope"]): { defs: SymbolDef[]; includes: IncludeRef[] } {
158
+ const defs: SymbolDef[] = [];
159
+ const includes: IncludeRef[] = [];
160
+ const seen = new Set<string>();
161
+ const lines = text.split("\n");
162
+ /** `UCLASS(...)`/`UENUM(...)` awaiting the declaration line that names it. */
163
+ let pendingMacro: { name: string; spec: string; line: number } | null = null;
164
+ /** `UPROPERTY(...)`/`UFUNCTION(...)` awaiting the member declaration. */
165
+ let pendingSpec: { name: string; line: number } | null = null;
166
+ let inBlockComment = false;
167
+
168
+ /** Open type bodies, innermost last: members are recorded as `Owner:Member`. */
169
+ const typeStack: { name: string; depth: number }[] = [];
170
+ let depth = 0;
171
+ const innerType = () => (typeStack.length ? (typeStack[typeStack.length - 1]?.name ?? "") : "");
172
+
173
+ const emit = (symbol: string, kind: DefKind, line: number, marker: string) => {
174
+ const raw = symbol.replace(/<[\s\S]*$/, "").trim();
175
+ // Single-character members are real UE (`double F = 0.0;` in LpCharacterTypes.h:94 is the
176
+ // forward angle bound). The length guard exists to drop macro noise, so it must not apply to
177
+ // the kinds that legitimately have one-letter names.
178
+ if (raw.length < 2 && kind !== "property" && kind !== "function") return;
179
+ if (/^[A-Z][A-Z0-9_]*_API$/.test(raw)) return; // `class ENGINE_API Foo` -> the macro is not a name
180
+ if (/[(),<>]/.test(raw)) return;
181
+ const owner = kind === "function" || kind === "property" ? innerType() : "";
182
+ const name = owner && !raw.includes(":") ? `${owner}:${raw}` : raw;
183
+ const key = `${name}\u0000${kind}\u0000${line}`;
184
+ if (seen.has(key)) return;
185
+ seen.add(key);
186
+ defs.push({ symbol: name, kind, file, line, module, scope, marker });
187
+ };
188
+
189
+ /** A type declaration whose body opens on this line or the next owns the members that follow. */
190
+ const pushType = (name: string) => {
191
+ if (name && name.length > 1 && !/[(),<>]/.test(name)) typeStack.push({ name, depth: depth + 1 });
192
+ };
193
+
194
+ for (let i = 0; i < lines.length; i++) {
195
+ const raw = lines[i] ?? "";
196
+ const t = raw.trim();
197
+ if (!t) continue;
198
+
199
+ if (inBlockComment) {
200
+ if (t.includes("*/")) inBlockComment = false;
201
+ continue;
202
+ }
203
+ if (t.startsWith("//")) continue;
204
+ if (t.startsWith("/*")) {
205
+ if (!t.includes("*/")) inBlockComment = true;
206
+ continue;
207
+ }
208
+
209
+ const line = i + 1;
210
+
211
+ // Brace accounting is crude (string literals with braces are rare in declarations) and is
212
+ // only used to know which type body a member declaration sits inside.
213
+ depth += (t.match(/\{/g) ?? []).length - (t.match(/\}/g) ?? []).length;
214
+ while (typeStack.length && (typeStack[typeStack.length - 1]?.depth ?? 0) > depth) typeStack.pop();
215
+
216
+ const inc = RE_INCLUDE.exec(t);
217
+ if (inc?.[1]) {
218
+ const target = inc[1];
219
+ if (/\.(h|hpp|hxx|inl)$/i.test(target)) includes.push({ file, target, angled: !t.includes('"'), line });
220
+ continue;
221
+ }
222
+
223
+ // UHT specifiers on their own line bind to the declaration that follows.
224
+ const spec = RE_SPECIFIER.exec(t);
225
+ if (spec?.[1]) {
226
+ pendingSpec = { name: spec[1], line };
227
+ // `UPROPERTY() inline` continuations are handled by the property/function patterns below.
228
+ continue;
229
+ }
230
+
231
+ // UHT class/struct/enum macros: name arrives on this line or one of the next few.
232
+ const macro = RE_MACRO.exec(t);
233
+ if (macro?.[1]) {
234
+ const specText = (macro[2] ?? "").replace(/\s+/g, " ").trim();
235
+ pendingMacro = { name: macro[1], spec: specText ? `spec=${specText.slice(0, 120)}` : "", line };
236
+ const onLine = recordNames(t);
237
+ if (onLine.length) {
238
+ for (const n of onLine) {
239
+ emit(n.name, n.kind, line, macroMarker(macro[1], macro[2] ?? ""));
240
+ pushType(n.name);
241
+ }
242
+ pendingMacro = null;
243
+ }
244
+ continue;
245
+ }
246
+
247
+ const markerFromMacro = () => {
248
+ if (!pendingMacro) return "";
249
+ const marker = macroMarker(pendingMacro.name, pendingMacro.spec);
250
+ pendingMacro = null;
251
+ return marker;
252
+ };
253
+
254
+ const delegate = RE_DELEGATE.exec(t);
255
+ if (delegate?.[1]) {
256
+ // The name's position depends on the macro shape, not on its position in the list:
257
+ // DECLARE_DELEGATE(Name), DECLARE_DELEGATE_OneParam(Name, Type) -> first argument
258
+ // DECLARE_DELEGATE_RetVal(Ret, Name), _RetVal_OneParam(Ret, Name, Type) -> second
259
+ // Taking the last argument names the *parameter type* of a one-param delegate
260
+ // (`DECLARE_DELEGATE_OneParam(FOnAnimationEvent, class UAnimInstance*)` -> UAnimInstance).
261
+ const macro = delegate[1];
262
+ const parts = (delegate[2] ?? "").split(",").map((part) => part.trim()).filter(Boolean);
263
+ const bare = (value: string) => (/^[A-Za-z_][A-Za-z0-9_]*$/.test(value) ? value : "");
264
+ const slots = /_?Ret\s*Val/i.test(macro) ? [parts[1] ?? "", parts[0] ?? ""] : [parts[0] ?? "", parts[parts.length - 1] ?? ""];
265
+ const name = bare(slots[0] ?? "") || bare(slots[1] ?? "");
266
+ if (name) {
267
+ emit(name, "delegate", line, t.slice(0, 60));
268
+ continue;
269
+ }
270
+ }
271
+
272
+ const forward = RE_FORWARD.exec(t);
273
+ if (forward?.[2] && /;\s*$/.test(t) && !/\bclass\b[^;]*\{/.test(t)) {
274
+ emit(forward[2], forward[1] === "class" ? "class" : "struct", line, "forward");
275
+ continue;
276
+ }
277
+
278
+ const union = RE_UNION.exec(t);
279
+ if (union?.[1]) {
280
+ emit(union[1], "union", line, markerFromMacro());
281
+ continue;
282
+ }
283
+
284
+ const enumMatch = RE_ENUM.exec(t);
285
+ if (enumMatch?.[3] && !/\benumerated\b/.test(t)) {
286
+ emit(enumMatch[3], "enum", line, markerFromMacro());
287
+ continue;
288
+ }
289
+
290
+ const records = recordNames(t);
291
+ if (records.length) {
292
+ const marker = markerFromMacro();
293
+ const forwardOnly = /;\s*$/.test(t) && !t.includes("{");
294
+ for (const rec of records) {
295
+ emit(rec.name, rec.kind, line, forwardOnly && !marker ? "forward" : marker);
296
+ if (!forwardOnly) pushType(rec.name);
297
+ }
298
+ continue;
299
+ }
300
+
301
+ // Member declarations carry meaning only under a UPROPERTY/UFUNCTION specifier, which is what
302
+ // makes them worth recording: it is the blueprint-visible surface of a module.
303
+ if (pendingSpec) {
304
+ const specifier = pendingSpec;
305
+ pendingSpec = null;
306
+ if (specifier.name === "UPROPERTY") {
307
+ const member = memberName(t);
308
+ if (member) {
309
+ emit(member, "property", line, "spec=UPROPERTY");
310
+ continue;
311
+ }
312
+ }
313
+ }
314
+
315
+ const fn = detectFunction(t, /\.(cpp|cc|cxx|c|mm)$/i.test(file));
316
+ if (fn) {
317
+ const qualified = /([A-Za-z_][A-Za-z0-9_]*)::\s*([A-Za-z_][A-Za-z0-9_]*)\s*\(/.exec(t);
318
+ emit(qualified?.[1] && qualified[2] === fn.name ? `${qualified[1]}:${fn.name}` : fn.name, "function", line, fn.marker);
319
+ continue;
320
+ }
321
+
322
+ const alias = RE_ALIAS.exec(t);
323
+ if (alias?.[1]) {
324
+ emit(alias[2] ?? alias[3] ?? "", "alias", line, alias[1].trim().slice(0, 40));
325
+ }
326
+ }
327
+
328
+ return { defs, includes };
329
+ }
330
+
331
+ /** See RE_HEAD/RE_TAIL. Returns the callee name and a `def|decl [qualifiers]` marker. */
332
+ function detectFunction(line: string, isSource: boolean): { name: string; marker: string } | undefined {
333
+ const open = line.indexOf("(");
334
+ if (open < 3) return undefined;
335
+ const head = line.slice(0, open).trimEnd();
336
+ if (!head || head.includes("=") || head.includes("{") || head.includes(")") || head.includes("~")) return undefined;
337
+ if (NOT_A_FUNCTION.test(line) || line.startsWith("#") || line.startsWith("@")) return undefined;
338
+ if (line.includes("= delete") || line.includes("= default")) return undefined;
339
+
340
+ const close = findCloseParen(line, open);
341
+ if (close < 0) return undefined;
342
+ const tail = line.slice(close + 1);
343
+ if (tail.includes("(") || !RE_TAIL.test(tail)) return undefined;
344
+ const params = line.slice(open + 1, close);
345
+ if (params.includes("{") || params.includes(")")) return undefined;
346
+
347
+ const m = RE_HEAD.exec(head);
348
+ if (!m) return undefined;
349
+ const groups = m.groups as { type?: string; qual?: string; name?: string };
350
+ const name = groups.name ?? "";
351
+ // At least one token of return type before the (optional) qualifier chain, so bare calls are out.
352
+ if (!groups.type || !groups.type.trim()) return undefined;
353
+ if (!name || FUNCTION_NAME_BLACKLIST.has(name)) return undefined;
354
+ if (name.startsWith("operator")) return undefined;
355
+
356
+ const qualifiers: string[] = tail.match(/const|override|noexcept|final|=\s*0/g) ?? [];
357
+ if (/=\s*0/.test(tail)) qualifiers.push("pure virtual");
358
+ const marker = [isSource ? "def" : "decl", ...qualifiers.map((q) => (q.includes("0") ? "pure virtual" : q))].filter(Boolean).join(" ");
359
+ return { name, marker };
360
+ }
361
+
362
+ /** Index of the `)` matching `open`, or -1 when the signature spans lines (params continue below). */
363
+ function findCloseParen(line: string, open: number): number {
364
+ let depth = 0;
365
+ for (let i = open; i < line.length; i++) {
366
+ const ch = line[i];
367
+ if (ch === "(") depth++;
368
+ else if (ch === ")") {
369
+ depth--;
370
+ if (depth === 0) return i;
371
+ }
372
+ }
373
+ return -1;
374
+ }
375
+
376
+ function macroMarker(macroName: string, specText: string): string {
377
+ const spec = specText.startsWith("(") ? specText.slice(0, 120) : specText.slice(0, 120);
378
+ return spec ? `${macroName} ${spec}` : macroName;
379
+ }
380
+
381
+ /** Class/struct names declared on a line, ignoring keywords and API-macro tokens. */
382
+ function recordNames(line: string): { name: string; kind: "class" | "struct" }[] {
383
+ const out: { name: string; kind: "class" | "struct" }[] = [];
384
+ RE_RECORD.lastIndex = 0;
385
+ let match: RegExpExecArray | null;
386
+ while ((match = RE_RECORD.exec(line))) {
387
+ const name = match[4];
388
+ if (!name || FUNCTION_NAME_BLACKLIST.has(name) || name === "class" || name === "struct") continue;
389
+ if (name.includes("::")) continue;
390
+ if (!RE_RECORD_TAIL.test(line.slice(match.index + match[0].length))) continue;
391
+ out.push({ name, kind: match[1] === "class" ? "class" : "struct" });
392
+ }
393
+ return out;
394
+ }
395
+
396
+ /** `Type Name;` / `Type Name = X;` / `uint8 bFlag:1;` — the member name under a UPROPERTY. */
397
+ function memberName(line: string): string | undefined {
398
+ const m = /^\s*(?:\[\[.*?\]\]\s*)?(?:mutable\s+|TObjectPtr<[^>]*>\s+|[A-Za-z_][A-Za-z0-9_:<>,\s&\*]*?)\s+([A-Za-z_][A-Za-z0-9_]*)(?:\s*:\s*\d+)?\s*(?:=|;|\[)/.exec(line.trim());
399
+ return m?.[1];
400
+ }
401
+
402
+ /* ──────────────────────────────────────────────────────────────────────────── tree walk */
403
+
404
+ interface DiscoveredFile {
405
+ abs: string;
406
+ root: ScanRoot;
407
+ }
408
+
409
+ /**
410
+ * Collect scannable files under `roots`. Depth-first with `readdir(withFileTypes)`; symlinked
411
+ * directories are skipped so a plugin tree that symlinks back into the engine cannot cycle.
412
+ */
413
+ export async function collectSourceFiles(roots: ScanRoot[], opts: ScanOptions = {}): Promise<DiscoveredFile[]> {
414
+ const skip = new Set((opts.skipDirs ?? DEFAULT_SKIP_DIRS).map((name) => name.toLowerCase()));
415
+ if (opts.includeThirdParty) skip.delete("thirdparty");
416
+ const out: DiscoveredFile[] = [];
417
+ const stack: string[] = [];
418
+
419
+ for (const root of roots) {
420
+ stack.push(root.dir);
421
+ while (stack.length) {
422
+ const dir = stack.pop()!;
423
+ let entries: Dirent[];
424
+ try {
425
+ entries = await readdir(dir, { withFileTypes: true });
426
+ } catch {
427
+ continue; // missing Source/ for a plugin is normal (Blueprint-only, content-only plugins)
428
+ }
429
+ for (const entry of entries) {
430
+ const name = entry.name;
431
+ if (entry.isSymbolicLink()) continue;
432
+ const abs = normalizePath(joinPath(dir, name));
433
+ if (entry.isDirectory()) {
434
+ if (skip.has(name.toLowerCase())) continue;
435
+ stack.push(abs);
436
+ continue;
437
+ }
438
+ if (!entry.isFile()) continue;
439
+ if (/\.generated\.h$/i.test(name)) continue; // UHT output: metadata and thunks, not authoring sites
440
+ const lower = name.toLowerCase();
441
+ if (!SCAN_EXT.some((ext) => lower.endsWith(ext))) continue;
442
+ out.push({ abs, root });
443
+ }
444
+ }
445
+ }
446
+ return out;
447
+ }
448
+
449
+ /**
450
+ * Scan `roots` and return definition rows plus `#include` edges. `resolveModule` maps an absolute
451
+ * file to the module that owns it — supplied by the index (UBT's dirToModule), so the scanner stays
452
+ * dependency-free and testable on a text fixture.
453
+ */
454
+ export async function scanSources(
455
+ roots: ScanRoot[],
456
+ resolveModule: (file: string, root: ScanRoot) => string,
457
+ opts: ScanOptions = {},
458
+ ): Promise<{ defs: SymbolDef[]; includes: IncludeRef[]; roots: ScanRoot[]; stats: ScanStats }> {
459
+ const started = Date.now();
460
+ const maxFileBytes = opts.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES;
461
+ const concurrency = Math.max(1, Math.min(opts.concurrency ?? DEFAULT_CONCURRENCY, 128));
462
+ const files = await collectSourceFiles(roots, opts);
463
+ const defs: SymbolDef[] = [];
464
+ const includes: IncludeRef[] = [];
465
+ const defsByKind: Record<string, number> = {};
466
+ let scannedFiles = 0;
467
+ let scannedBytes = 0;
468
+ let skippedLarge = 0;
469
+ let emptyFiles = 0;
470
+ let done = 0;
471
+
472
+ const queue = files.values();
473
+ const workers = Array.from({ length: concurrency }, async () => {
474
+ for (let next = queue.next(); ; next = queue.next()) {
475
+ const entry = (await next).value as DiscoveredFile | undefined;
476
+ if (!entry) break;
477
+ let buffer: Buffer;
478
+ try {
479
+ buffer = await readFile(entry.abs);
480
+ } catch {
481
+ continue; // unreadable file (locked by a running editor build): skip, do not fail the scan
482
+ }
483
+ done++;
484
+ if (buffer.length > maxFileBytes) {
485
+ skippedLarge++;
486
+ continue;
487
+ }
488
+ scannedFiles++;
489
+ scannedBytes += buffer.length;
490
+ const module = resolveModule(entry.abs, entry.root);
491
+ const scanned = scanText(buffer.toString("utf8"), entry.abs, module, entry.root.scope);
492
+ if (!scanned.defs.length) emptyFiles++;
493
+ for (const def of scanned.defs) {
494
+ defsByKind[def.kind] = (defsByKind[def.kind] ?? 0) + 1;
495
+ }
496
+ defs.push(...scanned.defs);
497
+ includes.push(...scanned.includes);
498
+ opts.onProgress?.(done, entry.root.dir);
499
+ }
500
+ });
501
+ await Promise.all(workers);
502
+
503
+ defs.sort((a, b) => (a.symbol < b.symbol ? -1 : a.symbol > b.symbol ? 1 : a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line));
504
+ return {
505
+ defs,
506
+ includes,
507
+ roots,
508
+ stats: { scannedFiles, scannedBytes, skippedDirs: 0, skippedLarge, defs: defs.length, defsByKind, emptyFiles, durationMs: Date.now() - started },
509
+ };
510
+ }