@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.
- package/CHANGELOG.md +89 -0
- package/LICENSE +21 -0
- package/README.md +199 -2
- package/docs/internals.md +245 -0
- package/extensions/unreal/index.ts +696 -0
- package/extensions/unreal/lib/cache.ts +190 -0
- package/extensions/unreal/lib/compiledb.ts +491 -0
- package/extensions/unreal/lib/descriptors.ts +631 -0
- package/extensions/unreal/lib/engine-resolver.ts +443 -0
- package/extensions/unreal/lib/pi-tools.ts +452 -0
- package/extensions/unreal/lib/plugin-resolver.ts +305 -0
- package/extensions/unreal/lib/query.ts +272 -0
- package/extensions/unreal/lib/registry.ts +208 -0
- package/extensions/unreal/lib/scan.ts +510 -0
- package/extensions/unreal/lib/store.ts +1490 -0
- package/extensions/unreal/lib/tools.ts +1074 -0
- package/extensions/unreal/lib/ubt.ts +380 -0
- package/extensions/unreal/lib/util.ts +210 -0
- package/package.json +60 -4
- package/prompts/unreal-context.md +36 -0
- package/skills/ue-engine-resolution/SKILL.md +129 -0
|
@@ -0,0 +1,1074 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool layer: turns the resolver/index/query libraries into the calls the `unreal_*` tools make.
|
|
3
|
+
*
|
|
4
|
+
* Everything here is a plain function (no pi types) so `tmp/smoke.mjs` can exercise the same code
|
|
5
|
+
* paths the tools do. Two invariants matter for how the tools behave:
|
|
6
|
+
*
|
|
7
|
+
* 1. **Indexing is opt-in and scoped.** A definitions index over the project + its plugins is a
|
|
8
|
+
* few seconds; one over `Engine/Source/{Runtime,Editor,Developer}` is minutes. The engine index
|
|
9
|
+
* is only built when the caller asks for `scope: "engine"` / `"all"`, and says so in `notes`.
|
|
10
|
+
* 2. **Module attribution prefers UBT's own answer.** When Query JSON exists, `dirToModule` decides
|
|
11
|
+
* which module owns a directory; `*.Build.cs` location is the fallback (`meta.source`).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { parseUproject, type EngineVersion } from "./descriptors.ts";
|
|
15
|
+
import { cacheDir } from "./cache.ts";
|
|
16
|
+
import { buildPluginIndex, resolveProjectPlugins, type PluginReferenceResult, type PluginSummary } from "./plugin-resolver.ts";
|
|
17
|
+
import { findProjectFile, resolveEngineForProject, type ResolverDeps } from "./engine-resolver.ts";
|
|
18
|
+
import { ensureQueryJson, pickTarget, queryCacheState, humanAge, type CachePaths, type TargetChoice } from "./query.ts";
|
|
19
|
+
import {
|
|
20
|
+
assembleRoots,
|
|
21
|
+
buildHeaderIndex,
|
|
22
|
+
buildIndex,
|
|
23
|
+
contextFor,
|
|
24
|
+
findFileBySuffix,
|
|
25
|
+
indexKeyFor,
|
|
26
|
+
listFiles,
|
|
27
|
+
headerKeyFor,
|
|
28
|
+
listHeaderIndexes,
|
|
29
|
+
listIndexes,
|
|
30
|
+
listModules,
|
|
31
|
+
listSymbols,
|
|
32
|
+
loadHeaderIndex,
|
|
33
|
+
lookupHeaderPaths,
|
|
34
|
+
lookupSymbol,
|
|
35
|
+
moduleFromBuildCs,
|
|
36
|
+
nameTokens,
|
|
37
|
+
pickHeaderIndex,
|
|
38
|
+
pickIndex,
|
|
39
|
+
readDefines,
|
|
40
|
+
rootKindsNote,
|
|
41
|
+
suggestFileNames,
|
|
42
|
+
suggestHeaderPaths,
|
|
43
|
+
symbolsInFile,
|
|
44
|
+
type ContextUnit,
|
|
45
|
+
type HeaderIndex,
|
|
46
|
+
type HeaderRow,
|
|
47
|
+
type IndexState,
|
|
48
|
+
type LookupResult,
|
|
49
|
+
type IndexScope,
|
|
50
|
+
type ScanRoot,
|
|
51
|
+
DEFAULT_ENGINE_AREAS,
|
|
52
|
+
} from "./store.ts";
|
|
53
|
+
import { hostPlatform, parseModuleReferences, parseTargetDetails, readQueryJson, type UbtModuleRefs, type UbtModuleSettings } from "./ubt.ts";
|
|
54
|
+
import { discoverTargets, pickDefaultTarget } from "./ubt.ts";
|
|
55
|
+
import { clangdCheck, ensureCompileDb } from "./compiledb.ts";
|
|
56
|
+
import { baseName, joinPath, normalizePath, realFs, type FsAdapter } from "./util.ts";
|
|
57
|
+
|
|
58
|
+
/** Where the project, its engine, and its enabled plugins live. */
|
|
59
|
+
export interface Workspace {
|
|
60
|
+
cwd: string;
|
|
61
|
+
projectPath?: string;
|
|
62
|
+
projectDir?: string;
|
|
63
|
+
projectName?: string;
|
|
64
|
+
engineRoot?: string;
|
|
65
|
+
engineVersion?: EngineVersion;
|
|
66
|
+
/** Directories of plugins the `.uproject` enables, used as index roots. */
|
|
67
|
+
pluginDirs: string[];
|
|
68
|
+
/**
|
|
69
|
+
* Declared module names per plugin root dir (from each `.uplugin`). A module directory takes the
|
|
70
|
+
* name of its `*.Build.cs` file, which is how `Engine/Plugins/Animation/PoseSearch/Source/Runtime`
|
|
71
|
+
* is module `PoseSearch` and not the directory leaf `Runtime`.
|
|
72
|
+
*/
|
|
73
|
+
pluginModuleNames: Record<string, string[]>;
|
|
74
|
+
/** Every `.uproject` reference with its resolved descriptor — the source of 'is this plugin available'. */
|
|
75
|
+
pluginRefs: PluginReferenceResult[];
|
|
76
|
+
/** Every plugin descriptor the scan found, including plugins the `.uproject` does not reference. */
|
|
77
|
+
plugins: PluginSummary[];
|
|
78
|
+
notes: string[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function resolveWorkspace(cwd: string, fs: FsAdapter = realFs, deps: ResolverDeps = {}): Promise<Workspace> {
|
|
82
|
+
const notes: string[] = [];
|
|
83
|
+
const workspace: Workspace = { cwd: normalizePath(cwd), pluginDirs: [], pluginModuleNames: {}, pluginRefs: [], plugins: [], notes };
|
|
84
|
+
|
|
85
|
+
const projectPath = findProjectFile(workspace.cwd, fs, 4);
|
|
86
|
+
if (!projectPath) {
|
|
87
|
+
notes.push("no *.uproject found within 4 levels of the working directory");
|
|
88
|
+
return workspace;
|
|
89
|
+
}
|
|
90
|
+
workspace.projectPath = projectPath;
|
|
91
|
+
workspace.projectDir = normalizePath(projectPath.slice(0, projectPath.length - baseName(projectPath).length).replace(/\/$/, ""));
|
|
92
|
+
workspace.projectName = baseName(projectPath).replace(/\.uproject$/i, "");
|
|
93
|
+
|
|
94
|
+
const resolution = await resolveEngineForProject(projectPath, { fs, ...deps });
|
|
95
|
+
if (resolution.status === "resolved" && resolution.engineRoot) {
|
|
96
|
+
workspace.engineRoot = resolution.engineRoot;
|
|
97
|
+
workspace.engineVersion = resolution.version;
|
|
98
|
+
notes.push(`engine ${resolution.version?.raw ?? resolution.identifier ?? "?"} at ${resolution.engineRoot} (${resolution.source}, ${resolution.installedBuild ? "installed" : "source"} build)`);
|
|
99
|
+
} else {
|
|
100
|
+
notes.push(`engine ${resolution.status}: ${resolution.diagnostics[0] ?? "see unreal_resolve_engine"}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const descriptor = parseUproject(projectPath, fs);
|
|
105
|
+
const index = await buildPluginIndex({
|
|
106
|
+
engineRoot: workspace.engineRoot,
|
|
107
|
+
projectDir: workspace.projectDir,
|
|
108
|
+
additionalPluginDirectories: descriptor.additionalPluginDirectories,
|
|
109
|
+
additionalRootDirectories: descriptor.additionalRootDirectories,
|
|
110
|
+
fs,
|
|
111
|
+
});
|
|
112
|
+
const refs = resolveProjectPlugins(descriptor, index, workspace.engineVersion);
|
|
113
|
+
workspace.pluginRefs = refs;
|
|
114
|
+
workspace.plugins = index.entries;
|
|
115
|
+
workspace.pluginDirs = refs.filter((ref) => ref.plugin).map((ref) => normalizePath(ref.plugin!.dir));
|
|
116
|
+
for (const entry of index.entries) {
|
|
117
|
+
const names = entry.modules.map((module) => module.moduleName).filter(Boolean);
|
|
118
|
+
if (names.length) workspace.pluginModuleNames[normalizePath(entry.dir)] = names;
|
|
119
|
+
}
|
|
120
|
+
const missing = refs.filter((ref) => ref.status === "missing").map((ref) => ref.name);
|
|
121
|
+
if (missing.length) notes.push(`plugin references with no descriptor: ${missing.join(", ")}`);
|
|
122
|
+
notes.push(`${workspace.pluginDirs.length} enabled plugin reference(s) resolved for index roots`);
|
|
123
|
+
} catch (error) {
|
|
124
|
+
notes.push(`plugin resolution failed: ${(error as Error).message}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return workspace;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export type Scope = "project" | "engine" | "all";
|
|
131
|
+
export type Mode = "definitions" | "full";
|
|
132
|
+
|
|
133
|
+
export interface IndexRequest {
|
|
134
|
+
workspace: Workspace;
|
|
135
|
+
mode: Mode;
|
|
136
|
+
scope: Exclude<Scope, "all">;
|
|
137
|
+
target?: string;
|
|
138
|
+
platform?: string;
|
|
139
|
+
configuration?: string;
|
|
140
|
+
/** Query JSON paths for `mode: "full"`; obtained from `ensureQueryJson`. */
|
|
141
|
+
queryFiles?: CachePaths;
|
|
142
|
+
refresh?: boolean;
|
|
143
|
+
engineAreas?: string[];
|
|
144
|
+
/** Default false: a lookup never blocks on a minutes-long engine scan. Only `unreal_index` opts in. */
|
|
145
|
+
allowEngineBuild?: boolean;
|
|
146
|
+
onProgress?: (message: string) => void;
|
|
147
|
+
fs?: FsAdapter;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface IndexHandle {
|
|
151
|
+
key: string;
|
|
152
|
+
index?: IndexState;
|
|
153
|
+
built: boolean;
|
|
154
|
+
durationMs?: number;
|
|
155
|
+
warnings: string[];
|
|
156
|
+
notes: string[];
|
|
157
|
+
roots: ScanRoot[];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Load a matching index, building it when absent (or when `refresh` is set). */
|
|
161
|
+
export async function ensureIndex(request: IndexRequest): Promise<IndexHandle> {
|
|
162
|
+
const fs = request.fs ?? realFs;
|
|
163
|
+
const workspace = request.workspace;
|
|
164
|
+
const engineScope = request.scope === "engine";
|
|
165
|
+
const areas = engineScope ? (request.engineAreas?.length ? request.engineAreas : DEFAULT_ENGINE_AREAS) : undefined;
|
|
166
|
+
const roots = assembleRoots({
|
|
167
|
+
projectRoot: engineScope ? undefined : workspace.projectDir,
|
|
168
|
+
// Engine-scope indexes carry the project's enabled plugin roots too. `Engine/Source/<area>` alone
|
|
169
|
+
// answers questions about the engine's own types and nothing else: Mover, PoseSearch,
|
|
170
|
+
// PhysicsControl, BlendStack, SmartObjects and StateTree are all plugin-shipped, and the plugin
|
|
171
|
+
// roots are already resolved, so adding them costs no extra scan of things we already know about.
|
|
172
|
+
pluginDirs: workspace.pluginDirs,
|
|
173
|
+
pluginModuleNames: workspace.pluginModuleNames,
|
|
174
|
+
engineRoot: engineScope ? workspace.engineRoot : undefined,
|
|
175
|
+
includeEngine: engineScope,
|
|
176
|
+
engineAreas: areas,
|
|
177
|
+
fs,
|
|
178
|
+
});
|
|
179
|
+
const scope: IndexScope = engineScope ? "Engine" : "Project";
|
|
180
|
+
const rootDirs = roots.roots.map((root) => root.dir);
|
|
181
|
+
const coverage = { projectRoot: workspace.projectDir, engineRoot: workspace.engineRoot };
|
|
182
|
+
const notes = [...roots.notes];
|
|
183
|
+
if (!roots.roots.length) {
|
|
184
|
+
return { key: "none", built: false, warnings: [`no C++ module roots for scope '${request.scope}'`], notes, roots: [] };
|
|
185
|
+
}
|
|
186
|
+
notes.push(`covers ${rootKindsNote(rootDirs, coverage)}`);
|
|
187
|
+
|
|
188
|
+
const key = indexKeyFor({ mode: request.mode, roots: rootDirs, scope, target: request.target, platform: request.platform, configuration: request.configuration });
|
|
189
|
+
if (!request.refresh) {
|
|
190
|
+
const existing = pickIndex({ key, mode: request.mode, scope, projectRoot: engineScope ? undefined : workspace.projectDir, engineRoot: engineScope ? workspace.engineRoot : undefined, roots: rootDirs }).index;
|
|
191
|
+
if (existing) {
|
|
192
|
+
notes.push(`using cached index ${existing.key} (${existing.meta.counts.symbols} symbols, ${existing.meta.counts.files} files, ${humanAge(Date.now() - Date.parse(existing.meta.generatedAt))} old)`);
|
|
193
|
+
const stale = indexCoverageNote(existing);
|
|
194
|
+
if (stale) notes.push(stale);
|
|
195
|
+
return { key, index: existing, built: false, warnings: [], notes, roots: roots.roots };
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (engineScope && !request.allowEngineBuild) {
|
|
200
|
+
const joined = (areas ?? DEFAULT_ENGINE_AREAS).join(", ");
|
|
201
|
+
notes.push(`no engine index for ${joined}. Building one scans every .h/.cpp in those areas plus ${workspace.pluginDirs.length} enabled plugin root(s) (tens of thousands of files): minutes the first time, cached afterwards.`);
|
|
202
|
+
notes.push(`call unreal_index with scope "engine" (engineAreas narrows the scan, e.g. ["Runtime"]; add ["Plugins"] for every engine plugin's sources, ["Plugins/Animation/PhysicsControl"] for one)`);
|
|
203
|
+
notes.push('unreal_index action "headers" answers "does this header exist in this engine version" in seconds without a content scan');
|
|
204
|
+
return { key, built: false, warnings: [], notes, roots: roots.roots };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const scopeNote = request.scope === "engine" ? `indexing Engine/Source (${(areas ?? DEFAULT_ENGINE_AREAS).join(", ")}) and ${workspace.pluginDirs.length} enabled plugin tree(s); the first engine index takes minutes, then it is cached` : "indexing project and plugin sources";
|
|
208
|
+
notes.push(scopeNote);
|
|
209
|
+
request.onProgress?.(scopeNote);
|
|
210
|
+
const started = Date.now();
|
|
211
|
+
const build = await buildIndex({
|
|
212
|
+
key,
|
|
213
|
+
mode: request.mode,
|
|
214
|
+
roots: roots.roots,
|
|
215
|
+
engineRoot: workspace.engineRoot,
|
|
216
|
+
projectRoot: workspace.projectDir,
|
|
217
|
+
projectName: workspace.projectName,
|
|
218
|
+
target: request.target,
|
|
219
|
+
platform: request.platform,
|
|
220
|
+
configuration: request.configuration,
|
|
221
|
+
areas,
|
|
222
|
+
scope,
|
|
223
|
+
queryFiles: request.queryFiles,
|
|
224
|
+
});
|
|
225
|
+
const warnings = [...build.warnings];
|
|
226
|
+
const loaded = pickIndex({ key, mode: request.mode }).index;
|
|
227
|
+
if (!loaded) warnings.push(`index ${key} was written but could not be reloaded; check ${cacheDir(fs)}`);
|
|
228
|
+
const seconds = ((Date.now() - started) / 1000).toFixed(1);
|
|
229
|
+
notes.push(`built ${key}: ${build.counts.symbols} symbols from ${build.counts.files} files in ${seconds}s (module attribution: ${build.source})`);
|
|
230
|
+
return { key, index: loaded, built: true, durationMs: Date.now() - started, warnings, notes, roots: roots.roots };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Coverage of an already-built index, read from its own meta — the line a warm cache must print. */
|
|
234
|
+
export function indexCoverageNote(state: IndexState): string {
|
|
235
|
+
const kinds = Object.entries(state.meta.rootKinds ?? {})
|
|
236
|
+
.map(([kind, count]) => `${count} ${kind} root(s)`)
|
|
237
|
+
.join(", ");
|
|
238
|
+
return `index ${state.key} covers ${kinds || `${state.meta.roots.length} root(s)`}${state.meta.areas?.length ? ` (engineAreas ${state.meta.areas.join(", ")})` : ""}`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** What an index actually scanned, in root-kind vocabulary — the only honest form of a 0-row answer. */
|
|
242
|
+
export interface Coverage {
|
|
243
|
+
key: string;
|
|
244
|
+
scope: Scope;
|
|
245
|
+
kinds: string;
|
|
246
|
+
areas: string[];
|
|
247
|
+
roots: number;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function coverageOf(handle: IndexHandle, workspace: Workspace, scope: Scope, areas?: string[]): Coverage {
|
|
251
|
+
return {
|
|
252
|
+
key: handle.key,
|
|
253
|
+
scope,
|
|
254
|
+
kinds: rootKindsNote(handle.roots.map((root) => root.dir), { projectRoot: workspace.projectDir, engineRoot: workspace.engineRoot }),
|
|
255
|
+
areas: areas ?? [],
|
|
256
|
+
roots: handle.roots.length,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Engine plugins present on disk that the `.uproject` does not reference. They are compiled by the
|
|
262
|
+
* target graph (their modules show up in `TargetModuleReferences`), and they are loaded when
|
|
263
|
+
* `EnabledByDefault` is true — but they are not index roots, so a symbol lookup cannot see them.
|
|
264
|
+
* This is the coverage hole behind every "the engine has no such type" answer.
|
|
265
|
+
*/
|
|
266
|
+
export function unreferencedEnginePlugins(workspace: Workspace): { enginePlugins: number; referenced: number; names: string[] } {
|
|
267
|
+
const referenced = new Set(workspace.pluginRefs.map((ref) => ref.name.toLowerCase()));
|
|
268
|
+
const seen = new Map<string, true>();
|
|
269
|
+
for (const plugin of workspace.plugins) {
|
|
270
|
+
if (plugin.source === "Engine" && !referenced.has(plugin.name.toLowerCase())) seen.set(plugin.name, true);
|
|
271
|
+
}
|
|
272
|
+
return { enginePlugins: workspace.plugins.filter((plugin) => plugin.source === "Engine").length, referenced: referenced.size, names: [...seen.keys()].sort() };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function coverageLines(coverage: Coverage[]): string[] {
|
|
276
|
+
return coverage.map((entry) => `scanned ${entry.scope} index ${entry.key}: ${entry.kinds}${entry.areas.length ? `; engineAreas ${entry.areas.join(", ")}` : ""}${entry.roots === 0 ? " (nothing scanned)" : ""}`);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Header index for a resolved engine, if one is already built. Never built here: lookups report, they do not scan. */
|
|
280
|
+
export function headerIndexForEngine(engineRoot?: string): HeaderIndex | undefined {
|
|
281
|
+
if (!engineRoot) return undefined;
|
|
282
|
+
const wanted = normalizePath(engineRoot).toLowerCase();
|
|
283
|
+
return listHeaderIndexes().find((header) => header.healthy && normalizePath(header.meta.engineRoot ?? "").toLowerCase() === wanted);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** The `engineAreas` dial that scans exactly this plugin: its path under Engine/Plugins. */
|
|
287
|
+
function pluginAreaLabel(workspace: Workspace, plugin: PluginSummary): string {
|
|
288
|
+
const pluginsRoot = normalizePath(joinPath(workspace.engineRoot ?? "", "Engine/Plugins"));
|
|
289
|
+
const dir = normalizePath(plugin.dir);
|
|
290
|
+
if (dir.toLowerCase().startsWith(`${pluginsRoot.toLowerCase()}/`)) return `Plugins/${dir.slice(pluginsRoot.length + 1)}`;
|
|
291
|
+
return `Plugins/<Category>/${plugin.name}`;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Header index for this project's own sources and plugins, if one is already built. */
|
|
295
|
+
export function headerIndexForProject(workspace: Workspace): HeaderIndex | undefined {
|
|
296
|
+
const roots = projectHeaderRoots(workspace);
|
|
297
|
+
if (!roots.length) return undefined;
|
|
298
|
+
return pickHeaderIndex(roots);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function isUnder(path: string, dir?: string): boolean {
|
|
302
|
+
if (!dir) return false;
|
|
303
|
+
const p = normalizePath(path).toLowerCase();
|
|
304
|
+
const d = normalizePath(dir).toLowerCase();
|
|
305
|
+
return p === d || p.startsWith(`${d}/`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Plugin descriptor that owns a file path, by root prefix (project plugins beat engine ones by path shape anyway). */
|
|
309
|
+
export function pluginForPath(workspace: Workspace, file: string): PluginSummary | undefined {
|
|
310
|
+
for (const plugin of workspace.plugins) {
|
|
311
|
+
if (isUnder(file, plugin.dir)) return plugin;
|
|
312
|
+
}
|
|
313
|
+
return undefined;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Roots a path-only header index needs: engine source areas + every engine plugin's sources. */
|
|
317
|
+
export function engineHeaderRoots(workspace: Workspace, areas?: string[]): ScanRoot[] {
|
|
318
|
+
if (!workspace.engineRoot) return [];
|
|
319
|
+
const engineAreas = areas?.length ? areas : ["Runtime", "Plugins"];
|
|
320
|
+
return assembleRoots({ engineRoot: workspace.engineRoot, includeEngine: true, engineAreas, pluginModuleNames: workspace.pluginModuleNames, fs: realFs }).roots;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Roots a path-only header index needs for the project side: its `Source` tree and its plugins' sources. */
|
|
324
|
+
export function projectHeaderRoots(workspace: Workspace, fs: FsAdapter = realFs): string[] {
|
|
325
|
+
const roots = assembleRoots({ projectRoot: workspace.projectDir, pluginDirs: workspace.pluginDirs, pluginModuleNames: workspace.pluginModuleNames, fs }).roots;
|
|
326
|
+
return roots.map((root) => root.dir);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export interface HeaderAnswer {
|
|
330
|
+
name: string;
|
|
331
|
+
exists: boolean;
|
|
332
|
+
found: { file: string; module: string; kind: string; match: string; plugin?: string; pluginSource?: PluginSummary["source"]; enabledByDefault?: boolean; referencedInUproject?: boolean; inTargetGraphHint?: string }[];
|
|
333
|
+
candidates: { file: string; module: string }[];
|
|
334
|
+
indexKey?: string;
|
|
335
|
+
rootsScanned: string;
|
|
336
|
+
notes: string[];
|
|
337
|
+
warnings: string[];
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* "Does this header exist in THIS engine version?" — a path question, answered from the header index.
|
|
342
|
+
*
|
|
343
|
+
* A definitions index cannot answer it: absence of a symbol row is a statement about the roots that
|
|
344
|
+
* were scanned, not about the engine. This answers with file paths only, and labels them as file
|
|
345
|
+
* names, never as types.
|
|
346
|
+
*/
|
|
347
|
+
export function headerLookup(input: { name: string; workspace: Workspace; header?: HeaderIndex; limit?: number }): HeaderAnswer {
|
|
348
|
+
const name = input.name.trim();
|
|
349
|
+
const notes: string[] = [];
|
|
350
|
+
const warnings: string[] = [];
|
|
351
|
+
const workspace = input.workspace;
|
|
352
|
+
const header = input.header;
|
|
353
|
+
if (!header) {
|
|
354
|
+
const engine = workspace.engineRoot ? "scope engine (Engine/Source areas + every Engine/Plugins source)" : "the project's own sources";
|
|
355
|
+
return {
|
|
356
|
+
name,
|
|
357
|
+
exists: false,
|
|
358
|
+
found: [],
|
|
359
|
+
candidates: [],
|
|
360
|
+
rootsScanned: "none",
|
|
361
|
+
notes: [...notes, `no header index for ${engine}; build one with unreal_index action "headers" (walks file paths only: seconds, no content scan, cached under the pi cache)`],
|
|
362
|
+
warnings,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const referenced = new Set(workspace.pluginRefs.map((ref) => ref.name.toLowerCase()));
|
|
367
|
+
const decorate = (row: HeaderRow) => {
|
|
368
|
+
const plugin = pluginForPath(workspace, row.file);
|
|
369
|
+
return {
|
|
370
|
+
file: row.file,
|
|
371
|
+
module: row.module,
|
|
372
|
+
kind: row.kind,
|
|
373
|
+
match: row.match,
|
|
374
|
+
plugin: plugin?.name,
|
|
375
|
+
pluginSource: plugin?.source,
|
|
376
|
+
enabledByDefault: plugin?.enabledByDefault,
|
|
377
|
+
referencedInUproject: plugin ? referenced.has(plugin.name.toLowerCase()) : undefined,
|
|
378
|
+
};
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
const exact = lookupHeaderPaths(header, name, { limit: input.limit ?? 12 });
|
|
382
|
+
const candidates = exact.length ? [] : suggestHeaderPaths(header, name, { limit: 8 });
|
|
383
|
+
const rootsScanned = rootKindsNote(header.meta.roots, { projectRoot: header.meta.projectRoot, engineRoot: header.meta.engineRoot });
|
|
384
|
+
notes.push(`header index ${header.key} (${header.meta.files} header paths, ${rootsScanned})`);
|
|
385
|
+
if (!exact.length) {
|
|
386
|
+
notes.push(`'${name}' is not a file path in this engine index`);
|
|
387
|
+
if (candidates.length) notes.push(` closest real file names (files, not types):\n${candidates.map((row) => ` ${row.file} [${row.module}]`).join("\n")}`);
|
|
388
|
+
warnings.push(`'${name}' was not found; the answer is about file paths, not about C++ types`);
|
|
389
|
+
}
|
|
390
|
+
return { name, exists: exact.length > 0, found: exact.map(decorate), candidates: candidates.map((row) => ({ file: row.file, module: row.module })), indexKey: header.key, rootsScanned, notes, warnings };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Notes a 0-row answer must carry: what was scanned, what was not, and the exact next call. */
|
|
394
|
+
export function zeroResultNotes(input: { query: string; scope: Scope; workspace: Workspace; coverage: Coverage[]; engineAreas?: string[]; indexes: IndexState[]; header?: HeaderIndex }): string[] {
|
|
395
|
+
const notes: string[] = [`no definitions matched '${input.query}' in scope '${input.scope}'`];
|
|
396
|
+
notes.push(...coverageLines(input.coverage));
|
|
397
|
+
|
|
398
|
+
const wantsEngine = input.scope === "engine" || input.scope === "all";
|
|
399
|
+
if (input.workspace.engineRoot) {
|
|
400
|
+
const gap = unreferencedEnginePlugins(input.workspace);
|
|
401
|
+
if (wantsEngine) {
|
|
402
|
+
notes.push(`engine plugins on disk: ${gap.enginePlugins}, referenced by this .uproject: ${gap.referenced}; the engine index scans referenced plugins only`);
|
|
403
|
+
notes.push(`a plugin the project does not reference is not in any index root: pass engineAreas ["Plugins"] (every Engine/Plugins source, ~2.8x the Runtime scan) or ["Plugins/<Category>/<Plugin>"] for one plugin`);
|
|
404
|
+
const near = rankNamesByQuery(gap.names, input.query);
|
|
405
|
+
if (near.length) notes.push(`unreferenced engine plugins whose name matches the query: ${near.slice(0, 6).join(", ")}${gap.names.length > 6 ? ` (of ${gap.names.length} unreferenced engine plugins with sources here)` : ""}`);
|
|
406
|
+
}
|
|
407
|
+
notes.push(input.header ? `header index ${input.header.key} covers ${rootKindsNote(input.header.meta.roots, { engineRoot: input.header.meta.engineRoot })}` : `asking whether a header exists is a path question, not a symbol question: unreal_index action "headers" (path walk only, seconds) answers it from ${input.workspace.engineRoot}/Engine/Plugins and Engine/Source`);
|
|
408
|
+
}
|
|
409
|
+
if (!wantsEngine) {
|
|
410
|
+
notes.push("a project index contains the project and its enabled plugins only; engine sources are not scanned: pass scope \"engine\" (first build takes minutes) or scope \"all\"");
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const suggestions = new Map<string, { file: string; module: string }>();
|
|
414
|
+
for (const index of input.indexes) {
|
|
415
|
+
for (const row of suggestFileNames(index, input.query, { limit: 4 })) {
|
|
416
|
+
if (!suggestions.has(row.file)) suggestions.set(row.file, { file: row.file, module: row.module });
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
if (suggestions.size) {
|
|
420
|
+
notes.push(`closest indexed FILE names (file names, not types — they are not proof a type exists):\n${[...suggestions.values()].slice(0, 6).map((row) => ` ${row.file} [${row.module}]`).join("\n")}`);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
notes.push(`next: unreal_symbol query '${input.query.replace(/\.(h|hpp|hxx|inl)$/i, "")}' scope "all" prefix true, or unreal_index action "headers" name "${input.query}"`);
|
|
424
|
+
return notes;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Names ranked by how many of the query's name tokens they contain — the gap list must answer *this* question. */
|
|
428
|
+
function rankNamesByQuery(names: string[], query: string): string[] {
|
|
429
|
+
const tokens = nameTokens(baseName(query).replace(/\.(h|hpp|hxx|inl|cpp|cc|c)$/i, ""));
|
|
430
|
+
if (!tokens.length) return names;
|
|
431
|
+
const scored = names.map((name) => {
|
|
432
|
+
const mine = new Set(nameTokens(name));
|
|
433
|
+
let hits = 0;
|
|
434
|
+
for (const token of tokens) if (mine.has(token)) hits++;
|
|
435
|
+
return { name, hits };
|
|
436
|
+
});
|
|
437
|
+
return scored.filter((entry) => entry.hits > 0).sort((a, b) => b.hits - a.hits || a.name.localeCompare(b.name)).map((entry) => entry.name);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export interface SymbolLookupInput {
|
|
441
|
+
cwd: string;
|
|
442
|
+
query: string;
|
|
443
|
+
scope?: Scope;
|
|
444
|
+
limit?: number;
|
|
445
|
+
prefix?: boolean;
|
|
446
|
+
substring?: boolean;
|
|
447
|
+
module?: string;
|
|
448
|
+
includeEngine?: boolean;
|
|
449
|
+
/** Engine areas for the engine index: `"Runtime"`, `"Runtime/Core"`, `"Editor"`, or the plugin dials `"Plugins"` / `"Plugins/Animation/PhysicsControl"`. Default `DEFAULT_ENGINE_AREAS` (`["Runtime"]`). */
|
|
450
|
+
engineAreas?: string[];
|
|
451
|
+
/** Opt in to a minutes-long first engine build. Default false: the tool says how, it never stalls. */
|
|
452
|
+
allowEngineBuild?: boolean;
|
|
453
|
+
onProgress?: (message: string) => void;
|
|
454
|
+
fs?: FsAdapter;
|
|
455
|
+
deps?: ResolverDeps;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Definitions lookup. Project + plugin index by default; engine index only when asked. */
|
|
459
|
+
export async function symbolLookup(input: SymbolLookupInput) {
|
|
460
|
+
const fs = input.fs ?? realFs;
|
|
461
|
+
const notes: string[] = [];
|
|
462
|
+
const warnings: string[] = [];
|
|
463
|
+
const workspace = await resolveWorkspace(input.cwd, fs, input.deps);
|
|
464
|
+
const scope: Scope = input.scope ?? (input.includeEngine ? "all" : "project");
|
|
465
|
+
const limit = input.limit ?? 25;
|
|
466
|
+
const indexes: { key: string; scope: Scope; defs: number; files: number; generatedAt: number }[] = [];
|
|
467
|
+
const results: LookupResult[] = [];
|
|
468
|
+
const coverage: Coverage[] = [];
|
|
469
|
+
const searched: IndexState[] = [];
|
|
470
|
+
|
|
471
|
+
const project = scope === "engine" ? undefined : await ensureIndex({ workspace, mode: "definitions", scope: "project", onProgress: input.onProgress, fs });
|
|
472
|
+
if (project?.index) {
|
|
473
|
+
indexes.push({ key: project.key, scope: "project", defs: project.index.meta.counts.symbols, files: project.index.meta.counts.files, generatedAt: Date.parse(project.index.meta.generatedAt) });
|
|
474
|
+
searched.push(project.index);
|
|
475
|
+
results.push(...lookupSymbol(project.index, input.query, { limit, prefix: input.prefix, substring: input.substring, module: input.module }));
|
|
476
|
+
}
|
|
477
|
+
notes.push(...(project?.notes ?? []));
|
|
478
|
+
warnings.push(...(project?.warnings ?? []));
|
|
479
|
+
if (project) coverage.push(coverageOf(project, workspace, "project"));
|
|
480
|
+
if (scope === "engine") notes.push("scope 'engine' searches the engine index only: Engine/Source areas plus the plugin roots this .uproject enables");
|
|
481
|
+
|
|
482
|
+
const wantsEngine = scope === "engine" || scope === "all";
|
|
483
|
+
if (wantsEngine) {
|
|
484
|
+
if (!workspace.engineRoot) warnings.push("no engine resolved, so the engine index was skipped");
|
|
485
|
+
else {
|
|
486
|
+
const engine = await ensureIndex({ workspace, mode: "definitions", scope: "engine", engineAreas: input.engineAreas, allowEngineBuild: input.allowEngineBuild, onProgress: input.onProgress, fs });
|
|
487
|
+
if (engine.index) {
|
|
488
|
+
indexes.push({ key: engine.key, scope: "engine", defs: engine.index.meta.counts.symbols, files: engine.index.meta.counts.files, generatedAt: Date.parse(engine.index.meta.generatedAt) });
|
|
489
|
+
searched.push(engine.index);
|
|
490
|
+
results.push(...lookupSymbol(engine.index, input.query, { limit, prefix: input.prefix, substring: input.substring, module: input.module }));
|
|
491
|
+
}
|
|
492
|
+
notes.push(...engine.notes);
|
|
493
|
+
warnings.push(...engine.warnings);
|
|
494
|
+
coverage.push(coverageOf(engine, workspace, "engine", input.engineAreas));
|
|
495
|
+
}
|
|
496
|
+
} else {
|
|
497
|
+
const engineIndexes = listIndexes().filter((state) => !!state.meta.engineRoot && !state.meta.projectRoot);
|
|
498
|
+
if (engineIndexes.length) {
|
|
499
|
+
const best = engineIndexes[0];
|
|
500
|
+
notes.push(`engine index cached (${best?.key}; ${best ? rootKindsNote(best.meta.roots, { engineRoot: best.meta.engineRoot }) : "roots unknown"}${best?.meta.areas?.length ? `; areas ${best.meta.areas.join(", ")}` : ""}) — pass scope 'all' to search it`);
|
|
501
|
+
} else {
|
|
502
|
+
notes.push("engine index not built — pass scope 'engine' or 'all' for engine symbols (first build takes minutes; engineAreas [\"Plugins\"] adds every engine plugin's sources)");
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// A plugin root appears in both the project index and the engine index, so `all` sees the same
|
|
507
|
+
// declaration twice. Same symbol/file/line is one fact: keep the first, drop the echo.
|
|
508
|
+
const unique = new Map<string, LookupResult>();
|
|
509
|
+
for (const row of results) {
|
|
510
|
+
const id = `${row.symbol}\u0000${row.file}\u0000${row.line}`;
|
|
511
|
+
const kept = unique.get(id);
|
|
512
|
+
if (!kept || row.rank < kept.rank) unique.set(id, row);
|
|
513
|
+
}
|
|
514
|
+
results.length = 0;
|
|
515
|
+
results.push(...unique.values());
|
|
516
|
+
|
|
517
|
+
// Rank first, kind second: sorting by kind/name is what made a plugin forward declaration beat
|
|
518
|
+
// the engine's real `UCLASS` row and then get cut by the limit.
|
|
519
|
+
results.sort((a, b) => a.rank - b.rank || Number(b.marker.includes("forward")) - Number(a.marker.includes("forward")) || a.kind.localeCompare(b.kind) || a.symbol.localeCompare(b.symbol));
|
|
520
|
+
const total = results.length;
|
|
521
|
+
if (!total) {
|
|
522
|
+
notes.push(...zeroResultNotes({ query: input.query, scope, workspace, coverage, engineAreas: input.engineAreas, indexes: searched, header: wantsEngine ? headerIndexForEngine(workspace.engineRoot) : headerIndexForProject(workspace) }));
|
|
523
|
+
}
|
|
524
|
+
return { query: input.query, scope, total, results: results.slice(0, limit), indexes, coverage, notes, warnings, workspace: { projectPath: workspace.projectPath, engineRoot: workspace.engineRoot } };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
export interface SymbolListInput {
|
|
528
|
+
cwd: string;
|
|
529
|
+
prefix: string;
|
|
530
|
+
scope?: Scope;
|
|
531
|
+
kind?: string;
|
|
532
|
+
module?: string;
|
|
533
|
+
limit?: number;
|
|
534
|
+
engineAreas?: string[];
|
|
535
|
+
allowEngineBuild?: boolean;
|
|
536
|
+
fs?: FsAdapter;
|
|
537
|
+
deps?: ResolverDeps;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** Prefix listing — cheap, and the way to discover `FX`/`U`/`A` families. */
|
|
541
|
+
export async function symbolList(input: SymbolListInput) {
|
|
542
|
+
const fs = input.fs ?? realFs;
|
|
543
|
+
const workspace = await resolveWorkspace(input.cwd, fs, input.deps);
|
|
544
|
+
const scope: Scope = input.scope ?? "project";
|
|
545
|
+
const notes: string[] = [];
|
|
546
|
+
const project = scope === "engine" ? undefined : await ensureIndex({ workspace, mode: "definitions", scope: "project", engineAreas: input.engineAreas, fs });
|
|
547
|
+
const handles: IndexHandle[] = [];
|
|
548
|
+
if (project) handles.push(project);
|
|
549
|
+
if (scope !== "project" && workspace.engineRoot) handles.push(await ensureIndex({ workspace, mode: "definitions", scope: "engine", engineAreas: input.engineAreas, allowEngineBuild: input.allowEngineBuild, fs }));
|
|
550
|
+
|
|
551
|
+
const symbols: ReturnType<typeof listSymbols> = [];
|
|
552
|
+
const seen = new Set<string>();
|
|
553
|
+
for (const handle of handles) {
|
|
554
|
+
if (!handle.index) continue;
|
|
555
|
+
for (const row of listSymbols(handle.index, input.prefix, { limit: input.limit ?? 50, kind: input.kind, module: input.module })) {
|
|
556
|
+
const id = `${row.symbol}\u0000${row.file}\u0000${row.line}`;
|
|
557
|
+
if (seen.has(id)) continue;
|
|
558
|
+
seen.add(id);
|
|
559
|
+
symbols.push(row);
|
|
560
|
+
}
|
|
561
|
+
notes.push(...handle.notes);
|
|
562
|
+
}
|
|
563
|
+
symbols.sort((a, b) => a.symbol.localeCompare(b.symbol));
|
|
564
|
+
return { prefix: input.prefix, scope, total: symbols.length, symbols: symbols.slice(0, input.limit ?? 50), notes, indexes: handles.map((handle) => handle.key) };
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** Scope by path shape. Engine plugins are engine sources; project plugins are project sources. */
|
|
568
|
+
export function scopeForPath(file: string, workspace: Workspace): Exclude<Scope, "all"> {
|
|
569
|
+
const p = normalizePath(file).toLowerCase();
|
|
570
|
+
if (workspace.engineRoot) {
|
|
571
|
+
const er = normalizePath(workspace.engineRoot).toLowerCase();
|
|
572
|
+
if (p.startsWith(`${er}/engine/plugins/`) || p.startsWith(`${er}/engine/source/`)) return "engine";
|
|
573
|
+
}
|
|
574
|
+
// An engine-shaped path with no resolved engine root: answer it as engine, say why it failed.
|
|
575
|
+
if (/\/engine\/plugins\/[^/]+\/source\//.test(p) || /\/engine\/source\//.test(p)) return "engine";
|
|
576
|
+
return "project";
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
export interface FileContextInput {
|
|
580
|
+
cwd: string;
|
|
581
|
+
file: string;
|
|
582
|
+
mode?: Mode;
|
|
583
|
+
allowEngineBuild?: boolean;
|
|
584
|
+
includeDefines?: boolean;
|
|
585
|
+
limit?: number;
|
|
586
|
+
engineAreas?: string[];
|
|
587
|
+
refresh?: boolean;
|
|
588
|
+
onProgress?: (message: string) => void;
|
|
589
|
+
fs?: FsAdapter;
|
|
590
|
+
deps?: ResolverDeps;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/** One file, everything needed to reason about compiling it. */
|
|
594
|
+
export async function fileContext(input: FileContextInput) {
|
|
595
|
+
const fs = input.fs ?? realFs;
|
|
596
|
+
const notes: string[] = [];
|
|
597
|
+
const warnings: string[] = [];
|
|
598
|
+
const workspace = await resolveWorkspace(input.cwd, fs, input.deps);
|
|
599
|
+
let mode: Mode = input.mode ?? "definitions";
|
|
600
|
+
let file = normalizePath(input.file);
|
|
601
|
+
if (!/^([a-z]:)?[\/]/i.test(file)) {
|
|
602
|
+
for (const base of [input.cwd, workspace.projectDir]) {
|
|
603
|
+
if (!base) continue;
|
|
604
|
+
const candidate = normalizePath(joinPath(base, file));
|
|
605
|
+
if (fs.exists(candidate)) {
|
|
606
|
+
notes.push(`resolved relative path to ${candidate}`);
|
|
607
|
+
file = candidate;
|
|
608
|
+
break;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
let queryFiles: CachePaths | undefined;
|
|
614
|
+
let choice: TargetChoice | undefined;
|
|
615
|
+
if (mode === "full") {
|
|
616
|
+
choice = pickTarget({ cwd: input.cwd, engineRoot: workspace.engineRoot, fs });
|
|
617
|
+
const ensured = await ensureQueryJson({ cacheRoot: cacheDir(fs), choice, engineRoot: workspace.engineRoot, fs, onStdout: input.onProgress });
|
|
618
|
+
queryFiles = ensured.source === "missing" ? undefined : ensured.paths;
|
|
619
|
+
notes.push(...ensured.notes);
|
|
620
|
+
warnings.push(...ensured.warnings);
|
|
621
|
+
if (!queryFiles) {
|
|
622
|
+
mode = "definitions";
|
|
623
|
+
warnings.push("falling back to definitions mode: UnrealBuildTool Query produced no JSON");
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const scope: Exclude<Scope, "all"> = scopeForPath(file, workspace);
|
|
628
|
+
const handle = await ensureIndex({ workspace, mode, scope, target: choice?.target, platform: choice?.platform, configuration: choice?.configuration, queryFiles, refresh: input.refresh, allowEngineBuild: input.allowEngineBuild, engineAreas: input.engineAreas, onProgress: input.onProgress, fs });
|
|
629
|
+
notes.push(...handle.notes);
|
|
630
|
+
warnings.push(...handle.warnings);
|
|
631
|
+
if (!handle.index) return { file, mode, notes, warnings, error: `no index available for ${scope} sources` };
|
|
632
|
+
|
|
633
|
+
let context = contextFor(handle.index, file, { limit: input.limit ?? 40, queryFiles: queryFiles ?? handle.index.meta.queryFiles });
|
|
634
|
+
if (!context) {
|
|
635
|
+
// A bare file name is the common case; resolve it against what the index actually holds.
|
|
636
|
+
const matches = findFileBySuffix(handle.index, baseName(file), 6);
|
|
637
|
+
if (matches.length === 1) {
|
|
638
|
+
file = matches[0]!;
|
|
639
|
+
notes.push(`resolved '${input.file}' to ${file} via the index`);
|
|
640
|
+
context = contextFor(handle.index, file, { limit: input.limit ?? 40, queryFiles: queryFiles ?? handle.index.meta.queryFiles });
|
|
641
|
+
} else if (matches.length > 1) {
|
|
642
|
+
return { file, mode, notes: [...notes, `multiple indexed files match '${baseName(file)}': ${matches.join(", ")}`], warnings, error: "ambiguous file name; pass a path with more directories" };
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
if (!context) {
|
|
646
|
+
const other = mode === "full" ? "definitions" : "full";
|
|
647
|
+
const fallback = pickIndex({ mode: other as Mode, projectRoot: workspace.projectDir, engineRoot: workspace.engineRoot }).index;
|
|
648
|
+
if (fallback) {
|
|
649
|
+
const fromOther = contextFor(fallback, file, { limit: input.limit ?? 40, queryFiles: fallback.meta.queryFiles });
|
|
650
|
+
if (fromOther) {
|
|
651
|
+
notes.push(`file found in the ${other}-mode index ${fallback.key}, not ${mode}`);
|
|
652
|
+
return { file, mode: other, module: moduleFromBuildCs(file), context: fromOther, symbols: symbolsInFile(fallback, file, input.limit ?? 40), defines: input.includeDefines ? fromOther.defines : undefined, notes, warnings };
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
// The path question is answerable even when the symbol question is not: a header can exist in a
|
|
656
|
+
// plugin this project never indexed, and 'not in index' must not read as 'does not exist'.
|
|
657
|
+
const header = scope === "engine" ? headerIndexForEngine(workspace.engineRoot) : headerIndexForProject(workspace);
|
|
658
|
+
if (header) {
|
|
659
|
+
const rows = lookupHeaderPaths(header, baseName(file), { limit: 6 });
|
|
660
|
+
const hit = rows.find((row) => row.file.toLowerCase() === file.toLowerCase());
|
|
661
|
+
if (hit) {
|
|
662
|
+
const plugin = pluginForPath(workspace, hit.file);
|
|
663
|
+
const referenced = plugin ? workspace.pluginRefs.some((ref) => ref.name.toLowerCase() === plugin.name.toLowerCase()) : false;
|
|
664
|
+
notes.push(`file exists in header index ${header.key} (${hit.kind}, module ${hit.module}) but is not in any symbol-index root`);
|
|
665
|
+
if (plugin) {
|
|
666
|
+
notes.push(`owning plugin ${plugin.name} [${plugin.source}] · EnabledByDefault ${plugin.enabledByDefault} · .uproject reference ${referenced ? "yes" : "no"}`);
|
|
667
|
+
notes.push(`compile C++ from it: unreal_index action build scope engine engineAreas ["${pluginAreaLabel(workspace, plugin)}"] (one plugin's sources), then re-run this call`);
|
|
668
|
+
}
|
|
669
|
+
return { file, mode, module: hit.module, indexKey: header.key, headerOnly: true, headerKind: hit.kind, pluginName: plugin?.name, pluginSource: plugin?.source, enabledByDefault: plugin?.enabledByDefault, referencedInUproject: referenced, notes, warnings };
|
|
670
|
+
}
|
|
671
|
+
notes.push(`header index ${header.key} (${header.meta.files} header paths) has no file named '${baseName(file)}'`);
|
|
672
|
+
}
|
|
673
|
+
return { file, mode, notes, warnings, error: `${file} is not in index ${handle.key} (${indexCoverageNote(handle.index)}). It may be outside the scanned module roots, in ThirdParty, or a generated header in Intermediate/. unreal_index action "headers" answers whether the path exists in this engine version.` };
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const symbols = symbolsInFile(handle.index, file, input.limit ?? 40);
|
|
677
|
+
const defines = input.includeDefines ? context.defines : undefined;
|
|
678
|
+
if (!context.includePaths?.length) notes.push("no include paths in this mode — build mode 'full' (unreal_index, mode: \"full\") for includePaths and the forced-included defines file");
|
|
679
|
+
notes.push(`module attribution: ${handle.index.meta.source === "ubt" ? "UnrealBuildTool Query dirToModule" : "*.Build.cs location (run a full-mode index for UBT's own answer)"}`);
|
|
680
|
+
return {
|
|
681
|
+
file,
|
|
682
|
+
mode,
|
|
683
|
+
module: context.module || moduleFromBuildCs(file),
|
|
684
|
+
indexKey: handle.key,
|
|
685
|
+
symbols,
|
|
686
|
+
compile: context,
|
|
687
|
+
defines,
|
|
688
|
+
notes,
|
|
689
|
+
warnings,
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
export interface ModuleAvailability {
|
|
694
|
+
module: string;
|
|
695
|
+
target: string;
|
|
696
|
+
inTargetGraph: boolean;
|
|
697
|
+
directory?: string;
|
|
698
|
+
type?: string;
|
|
699
|
+
plugin?: string;
|
|
700
|
+
pluginSource?: PluginSummary["source"];
|
|
701
|
+
enabledByDefault?: boolean;
|
|
702
|
+
referencedInUproject: boolean;
|
|
703
|
+
notes: string[];
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Is this module usable here? Two independent facts the `.uproject` alone cannot answer:
|
|
708
|
+
* `TargetModuleReferences` says whether the target's graph compiles the module at all, and the plugin
|
|
709
|
+
* descriptor says who owns it and whether it loads without a reference (`EnabledByDefault`).
|
|
710
|
+
*/
|
|
711
|
+
export function moduleAvailability(input: { workspace: Workspace; moduleRefs: Map<string, UbtModuleRefs>; moduleName: string; target: string }): ModuleAvailability {
|
|
712
|
+
const { workspace, moduleRefs, moduleName, target } = input;
|
|
713
|
+
const refs = moduleRefs.get(moduleName);
|
|
714
|
+
const plugin = (refs?.moduleDirectory ? workspace.plugins.find((entry) => isUnder(refs.moduleDirectory, entry.dir)) : undefined) ?? workspace.plugins.find((entry) => entry.modules.some((module) => module.moduleName === moduleName));
|
|
715
|
+
const declared = plugin?.modules.find((module) => module.moduleName === moduleName);
|
|
716
|
+
const referenced = plugin ? workspace.pluginRefs.some((ref) => ref.name.toLowerCase() === plugin.name.toLowerCase()) : false;
|
|
717
|
+
const type = refs?.type || declared?.type;
|
|
718
|
+
const notes: string[] = [` in ${target} module graph: ${refs ? `yes (${refs.moduleDirectory})` : "no — TargetModuleReferences has no entry for it"}`];
|
|
719
|
+
if (plugin) {
|
|
720
|
+
notes.push(` owning plugin ${plugin.name} [${plugin.source}] · declared module Type ${type ?? "unset"}`);
|
|
721
|
+
notes.push(` .uproject reference: ${plugin.source === "Engine" ? (referenced ? "referenced" : "NOT referenced") : "not an engine-plugin reference"}`);
|
|
722
|
+
if (!referenced) notes.push(plugin.enabledByDefault ? " EnabledByDefault true: it loads with no .uproject reference" : " EnabledByDefault false: runtime use needs a .uproject reference with Enabled true");
|
|
723
|
+
} else {
|
|
724
|
+
notes.push(" no .uplugin declares this module (engine source module, project module, or a name the graph does not contain)");
|
|
725
|
+
}
|
|
726
|
+
return { module: moduleName, target, inTargetGraph: Boolean(refs), directory: refs?.moduleDirectory, type, plugin: plugin?.name, pluginSource: plugin?.source, enabledByDefault: plugin?.enabledByDefault, referencedInUproject: referenced, notes };
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
export interface BuildContextInput {
|
|
730
|
+
cwd: string;
|
|
731
|
+
target?: string;
|
|
732
|
+
platform?: string;
|
|
733
|
+
configuration?: string;
|
|
734
|
+
module?: string;
|
|
735
|
+
file?: string;
|
|
736
|
+
includeDefines?: boolean;
|
|
737
|
+
refresh?: boolean;
|
|
738
|
+
limit?: number;
|
|
739
|
+
engineAreas?: string[];
|
|
740
|
+
onProgress?: (message: string) => void;
|
|
741
|
+
fs?: FsAdapter;
|
|
742
|
+
deps?: ResolverDeps;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* The compile environment UnrealBuildTool actually uses: target identity, defines, standard,
|
|
747
|
+
* SDK, include paths, and module dependencies — from Query JSON, cached in the pi cache.
|
|
748
|
+
*/
|
|
749
|
+
export async function buildContext(input: BuildContextInput) {
|
|
750
|
+
const fs = input.fs ?? realFs;
|
|
751
|
+
const workspace = await resolveWorkspace(input.cwd, fs, input.deps);
|
|
752
|
+
if (!workspace.engineRoot) return { error: "no engine resolved for this project; run unreal_resolve_engine", notes: workspace.notes };
|
|
753
|
+
|
|
754
|
+
const root = cacheDir(fs);
|
|
755
|
+
const choice = pickTarget({ cwd: input.cwd, target: input.target, platform: input.platform, configuration: input.configuration, engineRoot: workspace.engineRoot, fs });
|
|
756
|
+
const before = queryCacheState(root, choice, fs);
|
|
757
|
+
const notes: string[] = [`target ${choice.target} ${choice.platform} ${choice.configuration} (derived from ${choice.source})`];
|
|
758
|
+
const warnings: string[] = [...before.notes];
|
|
759
|
+
|
|
760
|
+
const ensured = await ensureQueryJson({ cacheRoot: root, choice, engineRoot: workspace.engineRoot, refresh: input.refresh, fs, onStdout: input.onProgress });
|
|
761
|
+
notes.push(...ensured.notes);
|
|
762
|
+
warnings.push(...ensured.warnings);
|
|
763
|
+
if (!ensured.paths) return { target: choice, notes, warnings, error: "UnrealBuildTool Query produced no JSON; inspect the notes for the spawn failure" };
|
|
764
|
+
|
|
765
|
+
const details = parseTargetDetails(readQueryJson(ensured.paths.targetDetails));
|
|
766
|
+
const moduleRefs: Map<string, UbtModuleRefs> = parseModuleReferences(readQueryJson(ensured.paths.moduleReferences));
|
|
767
|
+
|
|
768
|
+
const wanted = input.module ?? (input.file ? moduleFromBuildCs(normalizePath(input.file)) : undefined) ?? workspace.projectName;
|
|
769
|
+
const refs = wanted ? moduleRefs.get(wanted) : undefined;
|
|
770
|
+
const settings = refs ? details.settings.get(refs.moduleDirectory) : undefined;
|
|
771
|
+
if (!refs) notes.push(`no Query entry for module '${wanted ?? ""}'; pass 'module' — ${moduleRefs.size} modules in this target's graph`);
|
|
772
|
+
else if (!settings) notes.push(`module '${wanted}' has module references but no compile settings entry in TargetDetails`);
|
|
773
|
+
|
|
774
|
+
const availability = wanted ? moduleAvailability({ workspace, moduleRefs, moduleName: wanted, target: `${choice.target} ${choice.platform} ${choice.configuration}` }) : undefined;
|
|
775
|
+
if (availability) notes.push(...availability.notes);
|
|
776
|
+
|
|
777
|
+
const headers = refs ? [...refs.public, ...refs.private, ...refs.publicInclude, ...refs.privateInclude] : [];
|
|
778
|
+
if (refs && !settings) notes.push("compile settings (includePaths, standard, forced includes) live in TargetDetails; this target's TargetDetails entry for the module was empty");
|
|
779
|
+
const handle = await ensureIndex({ workspace, mode: "definitions", scope: "project", engineAreas: input.engineAreas, fs });
|
|
780
|
+
const indexModules = handle.index ? listModules(handle.index, { limit: input.limit ?? 40 }) : [];
|
|
781
|
+
|
|
782
|
+
return {
|
|
783
|
+
target: choice,
|
|
784
|
+
queryCache: { targetDetails: ensured.paths.targetDetails, moduleReferences: ensured.paths.moduleReferences, source: ensured.source, durationMs: ensured.durationsMs },
|
|
785
|
+
isTestTarget: details.isTestTarget,
|
|
786
|
+
launch: details.launch,
|
|
787
|
+
module: refs ? { name: wanted, directory: refs.moduleDirectory, dependencies: refs.public.length + refs.private.length, publicHeaders: refs.public.length, privateHeaders: refs.private.length, publicInclude: refs.publicInclude, privateInclude: refs.privateInclude, dynamicallyLoaded: refs.dynamicallyLoaded, circular: refs.circular, standard: settings?.standard, windowsSdkVersion: settings?.windowsSdkVersion, compilerPath: settings?.compilerPath, includePaths: settings?.includePaths.length ?? 0, forcedIncludes: settings?.forcedIncludes ?? [], definesFile: settings?.definesFile } : undefined,
|
|
788
|
+
availability,
|
|
789
|
+
headerCount: headers.length,
|
|
790
|
+
defines: input.includeDefines ? settings?.defines ?? [] : undefined,
|
|
791
|
+
modulesInTarget: [...moduleRefs.keys()].sort().slice(0, input.limit ?? 40),
|
|
792
|
+
projectModules: indexModules,
|
|
793
|
+
modulesTotal: moduleRefs.size,
|
|
794
|
+
targetModules: details.dirModules.length,
|
|
795
|
+
notes,
|
|
796
|
+
warnings,
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/** What is cached right now: symbol indexes, header indexes and Query JSON, with ages, coverage and costs. */
|
|
801
|
+
export function indexStatus(cwd: string, fs: FsAdapter = realFs) {
|
|
802
|
+
const root = cacheDir(fs);
|
|
803
|
+
const indexes = listIndexes().map((state) => ({
|
|
804
|
+
key: state.key,
|
|
805
|
+
mode: state.meta.mode,
|
|
806
|
+
generatedAt: state.meta.generatedAt,
|
|
807
|
+
ageMs: Date.now() - Date.parse(state.meta.generatedAt),
|
|
808
|
+
defs: state.meta.counts.symbols,
|
|
809
|
+
files: state.meta.counts.files,
|
|
810
|
+
defsByKind: state.meta.defsByKind,
|
|
811
|
+
source: state.meta.source,
|
|
812
|
+
target: state.meta.target,
|
|
813
|
+
scope: state.scope,
|
|
814
|
+
roots: state.meta.roots.length,
|
|
815
|
+
rootKinds: state.meta.rootKinds,
|
|
816
|
+
coverage: rootKindsNote(state.meta.roots, { projectRoot: state.meta.projectRoot, engineRoot: state.meta.engineRoot }),
|
|
817
|
+
areas: state.meta.areas,
|
|
818
|
+
sizeMb: Math.round(state.meta.sizeBytes / 1024 / 1024),
|
|
819
|
+
issues: state.issues,
|
|
820
|
+
}));
|
|
821
|
+
const headers = listHeaderIndexes().map((header) => ({
|
|
822
|
+
key: header.key,
|
|
823
|
+
generatedAt: header.meta.generatedAt,
|
|
824
|
+
ageMs: header.ageMs,
|
|
825
|
+
files: header.meta.files,
|
|
826
|
+
roots: header.meta.roots.length,
|
|
827
|
+
rootKinds: header.meta.rootKinds,
|
|
828
|
+
coverage: rootKindsNote(header.meta.roots, { projectRoot: header.meta.projectRoot, engineRoot: header.meta.engineRoot }),
|
|
829
|
+
areas: header.meta.areas,
|
|
830
|
+
sizeMb: Math.round(header.meta.sizeBytes / 1024 / 1024),
|
|
831
|
+
issues: header.issues,
|
|
832
|
+
}));
|
|
833
|
+
const choice = pickTarget({ cwd, fs });
|
|
834
|
+
const query = queryCacheState(root, choice, fs);
|
|
835
|
+
const notes = [
|
|
836
|
+
"engine scope covers Engine/Source areas + the project's enabled plugin roots; engine plugins the .uproject does not reference need engineAreas [\"Plugins\"] (~2.8x the Runtime scan) or [\"Plugins/<Category>/<Plugin>\"] (one plugin)",
|
|
837
|
+
"an index key encodes its root set, so a key that is not listed here is not what the last lookup searched — check the rootKinds line before trusting a 0",
|
|
838
|
+
];
|
|
839
|
+
return { cacheDir: root, indexes, headers, notes, query: { target: choice, usable: query.usable, targetDetails: query.targetDetails, moduleReferences: query.moduleReferences, notes: query.notes } };
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
export { hostPlatform };
|
|
843
|
+
|
|
844
|
+
export interface IndexBuildInput {
|
|
845
|
+
cwd: string;
|
|
846
|
+
scope: Exclude<Scope, "all">;
|
|
847
|
+
mode: Mode;
|
|
848
|
+
engineAreas?: string[];
|
|
849
|
+
target?: string;
|
|
850
|
+
platform?: string;
|
|
851
|
+
configuration?: string;
|
|
852
|
+
refresh?: boolean;
|
|
853
|
+
onProgress?: (message: string) => void;
|
|
854
|
+
fs?: FsAdapter;
|
|
855
|
+
deps?: ResolverDeps;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Build (or rebuild) one symbol index. This is the only entry point that opts in to a minutes-long
|
|
860
|
+
* Engine/Source scan — lookups report the cost and hand back, they never stall an agent.
|
|
861
|
+
*/
|
|
862
|
+
export async function indexBuild(input: IndexBuildInput) {
|
|
863
|
+
const fs = input.fs ?? realFs;
|
|
864
|
+
const workspace = await resolveWorkspace(input.cwd, fs, input.deps);
|
|
865
|
+
const notes: string[] = [];
|
|
866
|
+
let queryFiles: CachePaths | undefined;
|
|
867
|
+
|
|
868
|
+
if (input.mode === "full") {
|
|
869
|
+
if (!workspace.engineRoot) return { key: "none", built: false, symbols: 0, files: 0, durationMs: 0, roots: 0, notes: [...workspace.notes, "no engine resolved, so UnrealBuildTool Query cannot run (run unreal_resolve_engine)"] };
|
|
870
|
+
const choice = pickTarget({ cwd: input.cwd, target: input.target, platform: input.platform, configuration: input.configuration, engineRoot: workspace.engineRoot, fs });
|
|
871
|
+
const ensured = await ensureQueryJson({ cacheRoot: cacheDir(fs), choice, engineRoot: workspace.engineRoot, refresh: input.refresh, fs, onStdout: input.onProgress });
|
|
872
|
+
notes.push(...ensured.notes);
|
|
873
|
+
queryFiles = ensured.paths;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
const handle = await ensureIndex({
|
|
877
|
+
workspace,
|
|
878
|
+
mode: input.mode,
|
|
879
|
+
scope: input.scope,
|
|
880
|
+
engineAreas: input.engineAreas,
|
|
881
|
+
allowEngineBuild: true,
|
|
882
|
+
refresh: input.refresh,
|
|
883
|
+
target: input.target,
|
|
884
|
+
platform: input.platform,
|
|
885
|
+
configuration: input.configuration,
|
|
886
|
+
queryFiles,
|
|
887
|
+
onProgress: input.onProgress,
|
|
888
|
+
fs,
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
return {
|
|
892
|
+
key: handle.key,
|
|
893
|
+
built: handle.built,
|
|
894
|
+
mode: input.mode,
|
|
895
|
+
scope: input.scope,
|
|
896
|
+
engineAreas: input.engineAreas ?? DEFAULT_ENGINE_AREAS,
|
|
897
|
+
symbols: handle.index?.meta.counts.symbols ?? 0,
|
|
898
|
+
files: handle.index?.meta.counts.files ?? 0,
|
|
899
|
+
modules: handle.index?.meta.counts.modules ?? 0,
|
|
900
|
+
defsByKind: handle.index?.meta.defsByKind,
|
|
901
|
+
durationMs: handle.durationMs,
|
|
902
|
+
roots: handle.roots.length,
|
|
903
|
+
indexDir: handle.index?.dir,
|
|
904
|
+
notes: [...workspace.notes, ...handle.notes, ...notes],
|
|
905
|
+
warnings: handle.warnings,
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/* ─────────────────────────────────────────────── header (path-only) index */
|
|
910
|
+
|
|
911
|
+
export interface HeaderReportInput {
|
|
912
|
+
cwd: string;
|
|
913
|
+
/** Header name or path to answer, e.g. `"PhysicsControlComponent.h"` or `"Mover/Public/MoverTypes.h"`. */
|
|
914
|
+
name?: string;
|
|
915
|
+
/** Which side to index. Default `all` (engine plugin sources + engine sources + project sources). */
|
|
916
|
+
scope?: Exclude<Scope, "all"> | "all";
|
|
917
|
+
/** Build when absent. Default false: a lookup reports the cost, it never scans on its own. */
|
|
918
|
+
build?: boolean;
|
|
919
|
+
/** Engine areas to walk. Default `["Runtime", "Plugins"]` — the whole plugin tree is the point of this index. */
|
|
920
|
+
engineAreas?: string[];
|
|
921
|
+
refresh?: boolean;
|
|
922
|
+
limit?: number;
|
|
923
|
+
fs?: FsAdapter;
|
|
924
|
+
deps?: ResolverDeps;
|
|
925
|
+
onProgress?: (message: string) => void;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* The 'does this header exist in THIS engine version' answer.
|
|
930
|
+
*
|
|
931
|
+
* A definitions index cannot give it: absence of a symbol row is a statement about the roots that were
|
|
932
|
+
* scanned. This is a path walk (no content read), cached under the pi cache, and it names the plugin
|
|
933
|
+
* that owns each hit plus the two availability facts a `.uproject` reader needs — `EnabledByDefault`
|
|
934
|
+
* and whether the project references the plugin at all.
|
|
935
|
+
*/
|
|
936
|
+
export async function headerReport(input: HeaderReportInput) {
|
|
937
|
+
const fs = input.fs ?? realFs;
|
|
938
|
+
const workspace = await resolveWorkspace(input.cwd, fs, input.deps);
|
|
939
|
+
const scope = input.scope ?? "all";
|
|
940
|
+
const notes: string[] = [...workspace.notes];
|
|
941
|
+
const warnings: string[] = [];
|
|
942
|
+
|
|
943
|
+
const roots: ScanRoot[] = [];
|
|
944
|
+
if (scope === "all" || scope === "engine") roots.push(...engineHeaderRoots(workspace, input.engineAreas));
|
|
945
|
+
if (scope === "all" || scope === "project") roots.push(...assembleRoots({ projectRoot: workspace.projectDir, pluginDirs: workspace.pluginDirs, pluginModuleNames: workspace.pluginModuleNames, fs }).roots);
|
|
946
|
+
const dirs = roots.map((root) => root.dir);
|
|
947
|
+
if (!dirs.length) {
|
|
948
|
+
notes.push("no C++ roots to walk: no project `Source` tree and no resolved engine");
|
|
949
|
+
return { built: false, exists: false, name: input.name, roots: 0, files: 0, durationMs: 0, rootKinds: "none", found: [], candidates: [], notes, warnings };
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
const kinds = rootKindsNote(dirs, { projectRoot: workspace.projectDir, engineRoot: workspace.engineRoot });
|
|
953
|
+
let header = pickHeaderIndex(dirs);
|
|
954
|
+
if (header && !input.refresh) {
|
|
955
|
+
notes.push(`using cached header index ${header.key} (${header.meta.files} header paths, ${header.ageMs ? humanAge(header.ageMs) : "fresh"} old)`);
|
|
956
|
+
} else if (!header && !input.build) {
|
|
957
|
+
notes.push(`no header index for ${kinds}. Build it with unreal_index action "headers" — a path walk only (no content read): seconds, cached afterwards, ~52,000 plugin headers on a UE 5.8 install.`);
|
|
958
|
+
notes.push(` engineAreas default here is ["Runtime", "Plugins"]: every Engine/Plugins source tree, which is where Mover, PoseSearch, PhysicsControl, BlendStack, SmartObjects and StateTree live`);
|
|
959
|
+
return { built: false, exists: false, name: input.name, roots: dirs.length, files: 0, durationMs: 0, rootKinds: kinds, found: [], candidates: [], notes, warnings };
|
|
960
|
+
} else {
|
|
961
|
+
const key = headerKeyFor({ roots: dirs, label: scope === "project" ? workspace.projectName : workspace.engineRoot ? baseName(workspace.engineRoot) : "index" });
|
|
962
|
+
const areaNote = `walking header paths for ${kinds}`;
|
|
963
|
+
notes.push(areaNote);
|
|
964
|
+
input.onProgress?.(areaNote);
|
|
965
|
+
const built = await buildHeaderIndex({ key, roots, engineRoot: workspace.engineRoot, projectRoot: workspace.projectDir, areas: input.engineAreas ?? (scope === "project" ? undefined : ["Runtime", "Plugins"]) });
|
|
966
|
+
const loaded = loadHeaderIndex(built.key);
|
|
967
|
+
if (!loaded) warnings.push(`header index ${built.key} was written but could not be reloaded`);
|
|
968
|
+
header = loaded;
|
|
969
|
+
notes.push(`built ${built.key}: ${built.files} header paths in ${(built.durationMs / 1000).toFixed(1)}s`);
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
if (!input.name || !header) {
|
|
973
|
+
return { built: true, name: input.name, key: header?.key, files: header?.meta.files ?? 0, roots: dirs.length, durationMs: 0, rootKinds: kinds, exists: header ? undefined : false, found: [], candidates: [], notes, warnings };
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
const answer = headerLookup({ name: input.name, workspace, header, limit: input.limit });
|
|
977
|
+
notes.push(...answer.notes);
|
|
978
|
+
warnings.push(...answer.warnings);
|
|
979
|
+
return {
|
|
980
|
+
built: true,
|
|
981
|
+
key: header.key,
|
|
982
|
+
files: header.meta.files,
|
|
983
|
+
roots: dirs.length,
|
|
984
|
+
rootKinds: kinds,
|
|
985
|
+
name: input.name,
|
|
986
|
+
exists: answer.exists,
|
|
987
|
+
found: answer.found,
|
|
988
|
+
candidates: answer.candidates,
|
|
989
|
+
notes,
|
|
990
|
+
warnings,
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/* ────────────────────────────────────────────────────────────── compile database */
|
|
995
|
+
|
|
996
|
+
/** Default clangd scope: the project, its enabled plugins, and (with 'engine') all of Engine/Source AND Engine/Plugins sources. */
|
|
997
|
+
export function compileDbScope(input: { projectDir?: string; pluginDirs?: string[]; engineRoot?: string; scope: "project" | "project+engine" }): string[] {
|
|
998
|
+
const include = [input.projectDir, ...(input.pluginDirs ?? [])].filter((dir): dir is string => Boolean(dir)).map(normalizePath);
|
|
999
|
+
// Engine plugins ship most of the types a gameplay project overrides (Mover, PoseSearch, PhysicsControl,
|
|
1000
|
+
// SmartObjects, …). Listing only Engine/Source leaves their .cpp bodies out of the database, which is
|
|
1001
|
+
// the same coverage hole the engine symbol scope had.
|
|
1002
|
+
if (input.scope === "project+engine" && input.engineRoot) {
|
|
1003
|
+
include.push(normalizePath(joinPath(input.engineRoot, "Engine/Source")));
|
|
1004
|
+
include.push(normalizePath(joinPath(input.engineRoot, "Engine/Plugins")));
|
|
1005
|
+
}
|
|
1006
|
+
return include;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
export interface CompileDbInput {
|
|
1010
|
+
cwd: string;
|
|
1011
|
+
/** 'project' = project + enabled plugin sources. 'project+engine' adds all of Engine/Source. */
|
|
1012
|
+
scope?: "project" | "project+engine";
|
|
1013
|
+
/** Explicit include prefixes; overrides 'scope'. */
|
|
1014
|
+
include?: string[];
|
|
1015
|
+
target?: string;
|
|
1016
|
+
platform?: string;
|
|
1017
|
+
configuration?: string;
|
|
1018
|
+
refresh?: boolean;
|
|
1019
|
+
/** Run `clangd --check` on this file after generating (acceptance gate). */
|
|
1020
|
+
check?: string;
|
|
1021
|
+
timeoutMs?: number;
|
|
1022
|
+
onProgress?: (message: string) => void;
|
|
1023
|
+
fs?: FsAdapter;
|
|
1024
|
+
deps?: ResolverDeps;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/**
|
|
1028
|
+
* Generate and normalize UBT's clangd database, then optionally prove it with `clangd --check`.
|
|
1029
|
+
* Generation costs minutes (UBT regenerates the makefile and runs UHT), and the engine-wide scope
|
|
1030
|
+
* is ~1 GB: this is the expensive path, and only the explicit tool call reaches it.
|
|
1031
|
+
*/
|
|
1032
|
+
export async function compileDbReport(input: CompileDbInput) {
|
|
1033
|
+
const fs = input.fs ?? realFs;
|
|
1034
|
+
const workspace = await resolveWorkspace(input.cwd, fs, input.deps);
|
|
1035
|
+
const scope = input.scope ?? "project";
|
|
1036
|
+
|
|
1037
|
+
if (!workspace.engineRoot) {
|
|
1038
|
+
return { ok: false, notes: [...workspace.notes, "no engine resolved, so UnrealBuildTool cannot run (see unreal_resolve_engine)"], warnings: [] as string[], entries: 0, seconds: 0, reused: false };
|
|
1039
|
+
}
|
|
1040
|
+
const target = input.target ?? pickDefaultTarget(discoverTargets({ projectDir: workspace.projectDir, engineRoot: workspace.engineRoot, fs }), workspace.projectName)?.name;
|
|
1041
|
+
if (!target) {
|
|
1042
|
+
return { ok: false, notes: [...workspace.notes, "no *.Target.cs found, so the compile database has no target to generate for"], warnings: [] as string[], entries: 0, seconds: 0, reused: false };
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
const include = input.include?.length ? input.include.map(normalizePath) : compileDbScope({ projectDir: workspace.projectDir, pluginDirs: workspace.pluginDirs, engineRoot: workspace.engineRoot, scope });
|
|
1046
|
+
const ensured = await ensureCompileDb({
|
|
1047
|
+
engineRoot: workspace.engineRoot,
|
|
1048
|
+
projectPath: workspace.projectPath,
|
|
1049
|
+
target,
|
|
1050
|
+
platform: input.platform,
|
|
1051
|
+
configuration: input.configuration,
|
|
1052
|
+
include,
|
|
1053
|
+
dropHeaders: true,
|
|
1054
|
+
refresh: input.refresh,
|
|
1055
|
+
timeoutMs: input.timeoutMs,
|
|
1056
|
+
onProgress: input.onProgress,
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1059
|
+
const notes: string[] = [
|
|
1060
|
+
`target ${target} (${hostPlatform()})`,
|
|
1061
|
+
`scope: ${ensured.scope.join(", ")}`,
|
|
1062
|
+
ensured.reused ? `reused ${ensured.path}` : `generated in ${ensured.seconds.toFixed(1)}s (raw ${ensured.rawPath ? baseName(ensured.rawPath) : "?"})`,
|
|
1063
|
+
`normalized: ${ensured.stats.kept} entries (dropped ${ensured.stats.droppedPch} PCH units, ${ensured.stats.droppedHeaders} headers, ${ensured.stats.droppedSwitches} switches, expanded ${ensured.stats.expandedRsp} response files)`,
|
|
1064
|
+
"point clangd at this directory with --compile-commands-dir, or copy the path into the project's .clangd; the database encodes this machine's paths, so keep it out of git",
|
|
1065
|
+
];
|
|
1066
|
+
let check: { ok: boolean; clangd?: string; summary: string[]; notes: string[] } | undefined;
|
|
1067
|
+
if (input.check) {
|
|
1068
|
+
const file = input.check.includes(":") || input.check.startsWith("/") || /^[a-z]:/i.test(input.check) ? input.check : normalizePath(joinPath(input.cwd, input.check));
|
|
1069
|
+
const ran = await clangdCheck({ file, dbDir: ensured.path.replace(/\/compile_commands\.json$/, ""), timeoutMs: input.timeoutMs });
|
|
1070
|
+
check = { ok: ran.ok, clangd: ran.clangd, summary: ran.summary, notes: ran.notes };
|
|
1071
|
+
notes.push(...ran.notes.map((note) => `check: ${note}`));
|
|
1072
|
+
}
|
|
1073
|
+
return { ok: ensured.entries > 0, target, platform: input.platform ?? hostPlatform(), configuration: input.configuration ?? "Development", path: ensured.path, rawPath: ensured.rawPath, entries: ensured.entries, seconds: ensured.seconds, reused: ensured.reused, stats: ensured.stats, scope, check, notes: [...workspace.notes, ...notes], warnings: ensured.warnings };
|
|
1074
|
+
}
|