This commit is contained in:
2026-08-04 14:28:26 +02:00
parent 44b2eaf273
commit 6794896ac7
37 changed files with 3263 additions and 195 deletions
+43 -7
View File
@@ -44,7 +44,7 @@ export function activate(context: vscode.ExtensionContext): void {
context.subscriptions.push(
vscode.languages.registerReferenceProvider(
XML_SELECTOR,
new Ra3ReferenceProvider(),
new Ra3ReferenceProvider(ws),
),
);
context.subscriptions.push(
@@ -56,19 +56,26 @@ export function activate(context: vscode.ExtensionContext): void {
context.subscriptions.push(
vscode.languages.registerDocumentSymbolProvider(
XML_SELECTOR,
new Ra3DocumentSymbolProvider(),
new Ra3DocumentSymbolProvider(ws),
),
);
context.subscriptions.push(
vscode.languages.registerDocumentSemanticTokensProvider(
XML_SELECTOR,
new Ra3SemanticTokensProvider(),
new Ra3SemanticTokensProvider(ws),
RA3_SEMANTIC_TOKENS_LEGEND,
),
);
const diagnostics = new Ra3Diagnostics(ws);
context.subscriptions.push(diagnostics);
// Refresh diagnostics for every open XML document whenever a new index
// snapshot is published (XML phase, art phase, stale/final rebuild).
ws.onIndexUpdate = () => {
for (const doc of vscode.workspace.textDocuments) {
if (doc.languageId === "xml") void diagnostics.update(doc);
}
};
const diagnosticTimers = new Map<string, ReturnType<typeof setTimeout>>();
const scheduleDiagnostics = (doc: vscode.TextDocument) => {
@@ -110,7 +117,7 @@ export function activate(context: vscode.ExtensionContext): void {
vscode.workspace.onDidSaveTextDocument((doc) => {
if (doc.languageId !== "xml") return;
ws.invalidate(doc.uri.fsPath);
ws.scheduleRebuild();
ws.scheduleRebuild("save");
void diagnostics.update(doc);
}),
);
@@ -120,31 +127,60 @@ export function activate(context: vscode.ExtensionContext): void {
// Search paths / builtmods locations may have changed: cached include
// resolutions and manifest lookups are no longer valid.
ws.invalidateExistence();
ws.scheduleRebuild();
ws.scheduleRebuild("config");
}
}),
);
context.subscriptions.push(
vscode.commands.registerCommand("ra3modxml.reindex", () => ws.rebuild(true)),
vscode.commands.registerCommand(
"ra3modxml.reindex",
() => void ws.rebuild(true, "reindex-command"),
),
);
context.subscriptions.push(
vscode.commands.registerCommand("ra3modxml.clearCache", () => {
ws.clearCaches();
void vscode.window.showInformationMessage(
"RA3 Mod XML: caches cleared; rebuilding from scratch…",
);
}),
);
context.subscriptions.push(
vscode.commands.registerCommand("ra3modxml.showCacheReport", async () => {
void vscode.window.showInformationMessage(await ws.cacheReport(), {
modal: false,
});
}),
);
context.subscriptions.push(
vscode.commands.registerCommand("ra3modxml.openIndexReport", () => {
const idx = ws.index;
if (!idx) {
if (ws.isBuilding) {
void vscode.window.showInformationMessage(
"RA3 Mod XML: index is still building — check the status bar. " +
"Most features become available after the XML phase.",
);
return;
}
void vscode.window.showInformationMessage(
"RA3 Mod XML: no index available. Open a workspace that contains Data/Mod.xml.",
);
return;
}
const s = idx.stats;
const stale = idx.stale ? " (stale)" : "";
void vscode.window.showInformationMessage(
`RA3 Mod XML index\n` +
`Project: ${s.projectDir}\n` +
`Files: ${s.indexedFiles} (${s.parsedFiles} parsed, ${s.shallowScannedFiles} shallow-scanned, ${s.shallowCacheHits + s.recordsCacheHits} cache hits)\n` +
`Assets: ${s.assetCount} (${s.manifestAssetCount} from ${s.manifestFiles} manifests)\n` +
`Defines: ${s.defineCount} · Streams: ${s.streams} · Candidates: ${s.sourceCandidates}\n` +
`Indexed in ${(s.elapsedMs / 1000).toFixed(1)}s`,
`Phase: ${s.phase} · Complete: ${s.complete}${stale}\n` +
`Build #${ws.buildCount} (trigger: ${ws.lastTrigger})\n` +
`Indexed in ${(s.elapsedMs / 1000).toFixed(1)}s\n` +
`XML walk: ${(s.walkMs / 1000).toFixed(1)}s · Candidates: ${(s.candidatesMs / 1000).toFixed(1)}s · Art scan: ${(s.artScanMs / 1000).toFixed(1)}s`,
{ modal: false },
);
}),
+73 -16
View File
@@ -1,5 +1,5 @@
import * as vscode from "vscode";
import { parseXml, type XmlElement } from "../language/xmlParser";
import type { XmlElement } from "../language/xmlParser";
import {
analyzeContext,
splitListValuePrefix,
@@ -8,6 +8,11 @@ import {
import { resolveElementType } from "../language/typeContext";
import * as model from "../model/schemaModel";
import { isLocalReferenceAttribute } from "../indexer/refs";
import {
findContainingGameObject,
collectLocalIds,
type LogicalElement,
} from "../indexer/logicalTree";
import type { ModWorkspace } from "../workspace";
import type { ModIndex, AssetDef } from "../indexer/types";
@@ -21,11 +26,13 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
position: vscode.Position,
_token: vscode.CancellationToken,
): Promise<vscode.CompletionItem[]> {
if (!this.ws.isRa3Workspace()) return [];
const text = document.getText();
const offset = document.offsetAt(position);
const doc = parseXml(text);
const scope = await this.ws.getScope(document);
const doc = scope.expanded;
const ctx = analyzeContext(doc, text, offset);
const idx = this.ws.index;
const idx = scope.merged;
switch (ctx.kind) {
case "element-name":
@@ -33,9 +40,9 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
case "attribute-name":
return this.attributeNameItems(ctx, document, position);
case "attribute-value":
return idx ? this.valueItems(ctx, document, position, idx) : [];
return this.valueItems(ctx, document, position, idx);
case "content":
return idx ? this.contentItems(ctx, document, position, idx) : [];
return this.contentItems(ctx, document, position, idx);
default:
return [];
}
@@ -177,7 +184,7 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
ctx: CompletionContext,
document: vscode.TextDocument,
position: vscode.Position,
idx: ModIndex,
idx: ModIndex | null,
): vscode.CompletionItem[] {
const el = ctx.element;
const attr = ctx.attr;
@@ -232,6 +239,7 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
);
}
if (isInclude && attrName === "source") {
if (!idx) return [];
return this.includeSourceItems(idx, prefix, make);
}
if (attrName === "xai:joinaction" || attrName === "joinaction") {
@@ -242,16 +250,19 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
// inheritFrom: same element type first, then everything.
if (attrName === "inheritfrom") {
if (!idx) return [];
return this.assetIdItems(idx, el.name, null, prefix, make);
}
// `id` attributes are definitions and Poid attributes are pipeline-local
// references; offering global asset ids for them would be wrong.
if (attrInfo && isLocalReferenceAttribute(elType, attr.name)) {
return [];
if (attrName === "id") return [];
return this.localIdItems(el as LogicalElement, prefix, make);
}
if (attrInfo?.refType) {
if (!idx) return [];
return this.assetIdItems(idx, null, attrInfo.refType, prefix, make);
}
if (attrInfo?.enumValues?.length) {
@@ -264,7 +275,7 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
.filter((v) => v.startsWith(prefix.toLowerCase()))
.map((v) => make(v, vscode.CompletionItemKind.Value, "boolean"));
}
if (attrInfo?.allowsDefine) {
if (idx && attrInfo?.allowsDefine) {
return this.defineItems(idx, prefix, make);
}
return [];
@@ -304,8 +315,12 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
): vscode.CompletionItem[] {
const lower = prefix.toLowerCase();
const scored: { def: AssetDef; score: number }[] = [];
const seen = new Set<string>();
const consider = (def: AssetDef) => {
const key = `${def.type}:${def.id.toLowerCase()}:${def.file}:${def.line}`;
if (seen.has(key)) return;
seen.add(key);
if (!def.id.toLowerCase().startsWith(lower)) return;
let score = 3;
if (refType && model.isAssignableTo(def.type, refType)) score = 1;
@@ -315,6 +330,18 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
};
const targetType = selfType ?? refType;
const localAssets = idx.local?.assets;
const localById = idx.local?.assetsById;
if (localAssets || localById) {
if (!targetType) {
for (const list of localById!.values()) for (const d of list) consider(d);
} else {
for (const [typeName, byId] of localAssets!) {
if (!model.isAssignableTo(typeName, targetType)) continue;
for (const list of byId.values()) for (const d of list) consider(d);
}
}
}
if (!targetType) {
for (const list of idx.assetsById.values()) for (const d of list) consider(d);
} else {
@@ -343,24 +370,54 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
): vscode.CompletionItem[] {
const lower = prefix.replace(/^[=$]*/, "").toLowerCase();
const items: vscode.CompletionItem[] = [];
for (const [key, defs] of idx.defines) {
if (!key.includes(lower)) continue;
const def = defs[0];
const label = `$${def.name}`;
const item = make(label, vscode.CompletionItemKind.Constant, "Define", def.value);
item.insertText = label;
items.push(item);
const seen = new Set<string>();
for (const defines of [idx.local?.defines, idx.defines]) {
if (!defines) continue;
for (const [key, defs] of defines) {
if (!key.includes(lower)) continue;
const def = defs[0];
const dedupe = `${def.name.toLowerCase()}:${def.file}:${def.line}`;
if (seen.has(dedupe)) continue;
seen.add(dedupe);
const label = `$${def.name}`;
const item = make(label, vscode.CompletionItemKind.Constant, "Define", def.value);
item.insertText = label;
items.push(item);
}
}
return items.slice(0, MAX_VALUE_ITEMS);
}
private localIdItems(
el: LogicalElement,
prefix: string,
make: (label: string, kind: vscode.CompletionItemKind, detail: string, doc?: string) => vscode.CompletionItem,
): vscode.CompletionItem[] {
const root = findContainingGameObject(el);
if (!root) return [];
const lower = prefix.toLowerCase();
const items: vscode.CompletionItem[] = [];
for (const { id } of collectLocalIds(root)) {
if (!id.toLowerCase().startsWith(lower)) continue;
items.push(
make(
id,
vscode.CompletionItemKind.Value,
"local module",
"Pipeline-local id in the enclosing GameObject (includes xi:include targets).",
),
);
}
return items;
}
// ── Element content ───────────────────────────────────────────────
private contentItems(
ctx: CompletionContext,
_document: vscode.TextDocument,
_position: vscode.Position,
_idx: ModIndex,
_idx: ModIndex | null,
): vscode.CompletionItem[] {
const el = ctx.element;
if (!el) return [];
+78 -25
View File
@@ -1,6 +1,6 @@
import * as vscode from "vscode";
import { dirname } from "node:path";
import { LineMap, parseXml, type XmlElement } from "../language/xmlParser";
import { LineMap, type XmlElement } from "../language/xmlParser";
import { resolveElementType } from "../language/typeContext";
import { resolveSource, buildSearchPaths } from "../indexer/includeResolver";
import * as model from "../model/schemaModel";
@@ -8,8 +8,11 @@ import type { ModWorkspace } from "../workspace";
import type { ModIndex } from "../indexer/types";
import {
isReferenceAttributeOfType,
mergeLocalAndGlobalDefs,
resolveReferenceTargetsForType,
} from "../indexer/refs";
import type { LogicalElement } from "../indexer/logicalTree";
import { scopePathKey } from "../indexer/localScope";
export class Ra3Diagnostics {
private collection: vscode.DiagnosticCollection;
@@ -19,15 +22,19 @@ export class Ra3Diagnostics {
}
async update(document: vscode.TextDocument): Promise<void> {
const idx = this.ws.index;
if (!idx) {
if (!this.ws.isRa3Workspace()) {
this.collection.set(document.uri, []);
return;
}
const scope = await this.ws.getScope(document);
const idx = scope.merged;
const text = document.getText();
const lineMap = new LineMap(text);
const doc = parseXml(text);
const doc = scope.parse;
const diags: vscode.Diagnostic[] = [];
// Reference/duplicate checks are provisional while the index is
// incomplete or stale: "not found" may be a false positive.
const provisional = idx ? !idx.complete || idx.stale === true : false;
for (const err of doc.errors) {
diags.push(
@@ -44,7 +51,15 @@ export class Ra3Diagnostics {
}
if (doc.root) {
this.checkElements(doc.root, doc, lineMap, idx, document, diags);
this.checkElements(
scope.expanded.root,
scope.expanded,
lineMap,
idx,
document,
diags,
provisional,
);
}
this.collection.set(document.uri, diags);
@@ -59,19 +74,29 @@ export class Ra3Diagnostics {
}
private checkElements(
root: XmlElement,
doc: { elements: XmlElement[] },
root: LogicalElement | null,
doc: { elements: LogicalElement[] },
lineMap: LineMap,
idx: ModIndex,
idx: ModIndex | null,
document: vscode.TextDocument,
diags: vscode.Diagnostic[],
provisional: boolean,
): void {
const settings = this.ws.settings;
const fileDuplicates = new Map<string, { line: number }>();
for (const el of doc.elements) {
// Only report diagnostics for nodes that belong to the document being
// edited. Nodes spliced in through xi:include keep their own source
// file and are diagnosed when that file is opened.
if (scopePathKey(el.sourceFile) !== scopePathKey(document.uri.fsPath)) {
continue;
}
const local = localName(el.name);
const isTopLevel = el.parent === root && !["Tags", "Includes", "Defines"].includes(local);
const isTopLevel =
root !== null &&
el.parent === root &&
!["Tags", "Includes", "Defines"].includes(local);
const range = tagRange(document, el);
// Top-level assets must have an id.
@@ -111,6 +136,7 @@ export class Ra3Diagnostics {
document,
idx,
diags,
provisional,
);
}
}
@@ -169,6 +195,7 @@ export class Ra3Diagnostics {
document,
idx,
diags,
provisional,
);
}
}
@@ -184,12 +211,17 @@ export class Ra3Diagnostics {
type: string,
id: string,
document: vscode.TextDocument,
idx: ModIndex,
idx: ModIndex | null,
diags: vscode.Diagnostic[],
provisional: boolean,
): void {
if (!idx) return;
const byType = idx.assets.get(type);
const defs = byType?.get(id.toLowerCase());
if (!defs || defs.length < 2) return;
const defs = mergeLocalAndGlobalDefs(
idx.local?.assets.get(type)?.get(id.toLowerCase()),
byType?.get(id.toLowerCase()),
);
if (defs.length < 2) return;
const self = defs.filter(
(d) =>
d.origin === "project" &&
@@ -211,7 +243,8 @@ export class Ra3Diagnostics {
diags.push(
this.diag(
range,
`Duplicate id "${id}" for <${type}> (also defined in ${other.file})`,
`Duplicate id "${id}" for <${type}> (also defined in ${other.file})` +
(provisional ? " (based on a partial index)" : ""),
vscode.DiagnosticSeverity.Error,
"duplicate-id",
),
@@ -225,8 +258,9 @@ export class Ra3Diagnostics {
value: string,
attr: { valueStart: number; valueEnd: number },
document: vscode.TextDocument,
idx: ModIndex,
idx: ModIndex | null,
diags: vscode.Diagnostic[],
provisional: boolean,
): void {
if (!value) return;
const range = new vscode.Range(
@@ -238,13 +272,19 @@ export class Ra3Diagnostics {
const defineRe = /\$([A-Za-z_][A-Za-z0-9_]*)/g;
let m: RegExpExecArray | null;
while ((m = defineRe.exec(value)) !== null) {
if (!idx.defines.has(m[1].toLowerCase())) {
if (
idx &&
!(idx.local?.defines.has(m[1].toLowerCase()) ??
idx.defines.has(m[1].toLowerCase()))
) {
const code = provisional ? "undefined-define-indexing" : "undefined-define";
diags.push(
this.diag(
range,
`Undefined define "$${m[1]}"`,
`Undefined define "$${m[1]}"` +
(provisional ? " (index incomplete — may be a false positive)" : ""),
vscode.DiagnosticSeverity.Warning,
"undefined-define",
code,
),
);
}
@@ -253,10 +293,13 @@ export class Ra3Diagnostics {
if (value.startsWith("$") || value.startsWith("=")) return;
const severity = this.ws.settings.reportUnresolvedReferences;
if (severity === "none") return;
if (!idx) return;
if (!isReferenceAttributeOfType(elType, attrName)) return;
const targets = resolveReferenceTargetsForType(idx, elType, attrName, value);
if (targets.length) return;
const anyDef = idx.assetsById.has(value.toLowerCase());
const anyDef =
(idx.local?.assetsById.has(value.toLowerCase()) ?? false) ||
idx.assetsById.has(value.toLowerCase());
const attrRef = model
.attributesOfType(elType)
.find((a) => a.name === attrName);
@@ -265,16 +308,20 @@ export class Ra3Diagnostics {
: attrRef?.isRef
? "of the expected declared type"
: "matching";
const code = provisional ? "unresolved-reference-indexing" : "unresolved-reference";
const baseMessage = anyDef
? `Reference "${value}" has no definition ${expected} (ids with the same name exist for other types)`
: `Unresolved reference "${value}" (not found in the current index)`;
diags.push(
this.diag(
range,
anyDef
? `Reference "${value}" has no definition ${expected} (ids with the same name exist for other types)`
: `Unresolved reference "${value}" (not found in the current index)`,
provisional
? `${baseMessage} (index incomplete — may be a false positive)`
: baseMessage,
severity === "warning"
? vscode.DiagnosticSeverity.Warning
: vscode.DiagnosticSeverity.Information,
"unresolved-reference",
code,
),
);
}
@@ -282,7 +329,7 @@ export class Ra3Diagnostics {
private checkInclude(
el: XmlElement,
document: vscode.TextDocument,
idx: ModIndex,
idx: ModIndex | null,
diags: vscode.Diagnostic[],
): void {
const typeAttr = el.attrs.find((a) => a.name === "type");
@@ -301,12 +348,18 @@ export class Ra3Diagnostics {
);
}
if (!sourceAttr?.hasValue) return;
const searchPaths = idx
? buildSearchPaths(idx.sdkDir, idx.projectDir)
: this.ws.searchPaths();
if (!searchPaths) return;
const resolved = resolveSource(
sourceAttr.value,
dirname(document.uri.fsPath),
buildSearchPaths(idx.sdkDir, idx.projectDir),
searchPaths,
);
if (!resolved.path && !idx.sourceCandidates.some((c) => c.source === sourceAttr.value)) {
const candidateHit =
idx?.sourceCandidates.some((c) => c.source === sourceAttr.value) ?? false;
if (!resolved.path && !candidateHit) {
diags.push(
this.diag(
new vscode.Range(
+62 -11
View File
@@ -1,13 +1,19 @@
import * as vscode from "vscode";
import { findElementAt, parseXml } from "../language/xmlParser";
import { findElementAt } from "../language/xmlParser";
import { resolveElementType } from "../language/typeContext";
import * as model from "../model/schemaModel";
import type { ModWorkspace } from "../workspace";
import type { ModIndex } from "../indexer/types";
import {
isLocalReferenceAttribute,
isReferenceAttributeOfType,
resolveReferenceTargetsForType,
} from "../indexer/refs";
import {
findContainingGameObject,
findLocalId,
type LogicalElement,
} from "../indexer/logicalTree";
import { scopePathKey, type DocumentScope } from "../indexer/localScope";
import { dirname } from "node:path";
import { buildSearchPaths, resolveSource } from "../indexer/includeResolver";
@@ -19,9 +25,10 @@ export class Ra3HoverProvider implements vscode.HoverProvider {
position: vscode.Position,
_token: vscode.CancellationToken,
): Promise<vscode.Hover | null> {
const text = document.getText();
if (!this.ws.isRa3Workspace()) return null;
const offset = document.offsetAt(position);
const doc = parseXml(text);
const scope = await this.ws.getScope(document);
const doc = scope.expanded;
const el = findElementAt(doc, offset);
if (!el) return null;
const elType = resolveElementType(el);
@@ -35,7 +42,7 @@ export class Ra3HoverProvider implements vscode.HoverProvider {
// Attribute value.
for (const attr of el.attrs) {
if (attr.hasValue && offset >= attr.valueStart && offset <= attr.valueEnd) {
return this.valueHover(el, elType, attr.name, attr.value, document, this.ws.index);
return this.valueHover(el, elType, attr.name, attr.value, document, scope);
}
}
// Element name.
@@ -116,14 +123,17 @@ export class Ra3HoverProvider implements vscode.HoverProvider {
attrName: string,
value: string,
document: vscode.TextDocument,
idx: ModIndex | null,
scope: DocumentScope,
): vscode.Hover | null {
const idx = scope.merged;
const md = new vscode.MarkdownString();
// $DEFINE reference.
const defineMatch = /\$([A-Za-z_][A-Za-z0-9_]*)/.exec(value);
if (defineMatch && idx) {
const defs = idx.defines.get(defineMatch[1].toLowerCase());
const defs =
idx.local?.defines.get(defineMatch[1].toLowerCase()) ??
idx.defines.get(defineMatch[1].toLowerCase());
if (defs?.length) {
const d = defs[0];
md.appendMarkdown(`**Define** \`$${d.name}\` \n`);
@@ -139,11 +149,14 @@ export class Ra3HoverProvider implements vscode.HoverProvider {
(el.name === "Include" && attrName === "source") ||
(el.name === "xi:include" && attrName === "href")
) {
const resolved = idx
const searchPaths = idx
? buildSearchPaths(idx.sdkDir, idx.projectDir)
: this.ws.searchPaths();
const resolved = searchPaths
? resolveSource(
value,
dirname(document.uri.fsPath),
buildSearchPaths(idx.sdkDir, idx.projectDir),
searchPaths,
).path
: null;
if (resolved) {
@@ -161,8 +174,26 @@ export class Ra3HoverProvider implements vscode.HoverProvider {
return new vscode.Hover(md);
}
// Pipeline-local (Poid) references: resolve inside the enclosing
// GameObject's logical subtree (including xi:include targets).
if (
isLocalReferenceAttribute(elType, attrName) &&
attrName.toLowerCase() !== "id"
) {
return this.localIdHover(scope, el as LogicalElement, value, document);
}
// Asset reference / inheritFrom.
if (idx) {
if (!idx) {
if (isReferenceAttributeOfType(elType, attrName)) {
md.appendMarkdown(
"Index is still building — references cannot be resolved yet.",
);
return new vscode.Hover(md);
}
return null;
}
{
if (!isReferenceAttributeOfType(elType, attrName)) return null;
const targets = resolveReferenceTargetsForType(idx, elType, attrName, value);
if (targets.length) {
@@ -191,8 +222,28 @@ export class Ra3HoverProvider implements vscode.HoverProvider {
);
return new vscode.Hover(md);
}
}
return null;
private localIdHover(
scope: DocumentScope,
el: LogicalElement,
value: string,
document: vscode.TextDocument,
): vscode.Hover | null {
const root = findContainingGameObject(el);
if (!root) return null;
const target = findLocalId(root, value);
if (!target) return null;
const idAttr = target.attrs.find((a) => a.name === "id");
if (!idAttr?.hasValue) return null;
const lineMap = scope.lineMaps.get(scopePathKey(target.sourceFile));
const line = lineMap ? lineMap.positionAt(idAttr.valueStart).line + 1 : 0;
const md = new vscode.MarkdownString();
md.appendMarkdown(`**Local pipeline id** \`${value}\` \n`);
md.appendCodeblock(`<${target.name}>`);
const rel = relativePath(document, target.sourceFile);
md.appendMarkdown(`Defined in \`${rel}:${line}\``);
return new vscode.Hover(md);
}
}
+113 -16
View File
@@ -7,7 +7,16 @@ import {
resolveSource,
type SearchPaths,
} from "../indexer/includeResolver";
import { resolveReferenceTargetsForType } from "../indexer/refs";
import {
isLocalReferenceAttribute,
resolveReferenceTargetsForType,
} from "../indexer/refs";
import {
findContainingGameObject,
findLocalId,
type LogicalElement,
} from "../indexer/logicalTree";
import { scopePathKey, type DocumentScope } from "../indexer/localScope";
import type { ModWorkspace } from "../workspace";
import type { AssetDef, ModIndex } from "../indexer/types";
@@ -25,11 +34,11 @@ export class Ra3DefinitionProvider implements vscode.DefinitionProvider {
position: vscode.Position,
_token: vscode.CancellationToken,
): Promise<vscode.Location | vscode.Location[] | null> {
const idx = this.ws.index;
if (!idx) return null;
const text = document.getText();
if (!this.ws.isRa3Workspace()) return null;
const scope = await this.ws.getScope(document);
const idx = scope.merged;
const offset = document.offsetAt(position);
const doc = parseXml(text);
const doc = scope.expanded;
const el = findElementAt(doc, offset);
if (!el) return null;
const elType = resolveElementType(el);
@@ -46,17 +55,31 @@ export class Ra3DefinitionProvider implements vscode.DefinitionProvider {
(el.name === "Include" && nameLower === "source") ||
(el.name === "include" && nameLower === "href")
) {
const resolved =
resolveSource(value, dirname(document.uri.fsPath), searchPathsFor(idx)).path ??
idx.sourceCandidates.find((c) => c.source === value)?.path ??
null;
return resolved
? new vscode.Location(vscode.Uri.file(resolved), new vscode.Position(0, 0))
const searchPaths = idx ? searchPathsFor(idx) : this.ws.searchPaths();
const resolved = searchPaths
? resolveSource(value, dirname(document.uri.fsPath), searchPaths).path
: null;
const fallback = idx?.sourceCandidates.find((c) => c.source === value)?.path;
const target = resolved ?? fallback ?? null;
return target
? new vscode.Location(vscode.Uri.file(target), new vscode.Position(0, 0))
: null;
}
// Asset reference / inheritFrom (filtered by the attribute's ref type).
if (!idx) return null;
if (value && !value.startsWith("$")) {
if (
isLocalReferenceAttribute(elType, attr.name) &&
nameLower !== "id"
) {
const local = this.localIdLocation(
scope,
el as LogicalElement,
value,
);
if (local) return local;
}
let targets = resolveReferenceTargetsForType(idx, elType, attr.name, value);
if (!targets.length) return null;
if (
@@ -67,13 +90,40 @@ export class Ra3DefinitionProvider implements vscode.DefinitionProvider {
}
const locations: vscode.Location[] = [];
for (const { def } of targets.slice(0, 8)) {
const loc = await assetDefLocation(this.ws, def, idx);
const loc = await assetDefLocation(this.ws, def, idx, scope, document);
if (loc) locations.push(loc);
}
return locations.length ? locations : null;
}
return null;
}
private localIdLocation(
scope: DocumentScope,
el: LogicalElement,
value: string,
): vscode.Location | null {
const root = findContainingGameObject(el);
if (!root) return null;
const target = findLocalId(root, value);
if (!target) return null;
const idAttr = target.attrs.find((a) => a.name === "id");
if (!idAttr?.hasValue) return null;
const lineMap = scope.lineMaps.get(scopePathKey(target.sourceFile));
if (!lineMap) {
return new vscode.Location(
vscode.Uri.file(target.sourceFile),
new vscode.Position(0, 0),
);
}
return new vscode.Location(
vscode.Uri.file(target.sourceFile),
new vscode.Range(
toVscodePosition(lineMap.positionAt(idAttr.valueStart)),
toVscodePosition(lineMap.positionAt(idAttr.valueEnd)),
),
);
}
}
/**
@@ -85,6 +135,8 @@ async function assetDefLocation(
ws: ModWorkspace,
def: AssetDef,
idx: ModIndex,
scope: DocumentScope,
currentDocument: vscode.TextDocument,
): Promise<vscode.Location | null> {
if (def.origin === "manifest") {
const src = def.manifestSource;
@@ -100,6 +152,11 @@ async function assetDefLocation(
return null;
}
if (scopePathKey(def.file) === scopePathKey(currentDocument.uri.fsPath)) {
const precise = locationInCurrentDocument(scope, def.id, currentDocument);
if (precise) return precise;
}
return (
(await locationInDocument(ws, def.file, def.id)) ??
new vscode.Location(
@@ -109,6 +166,36 @@ async function assetDefLocation(
);
}
function locationInCurrentDocument(
scope: DocumentScope,
id: string,
document: vscode.TextDocument,
): vscode.Location | null {
const el = scope.parse.elements.find((e) =>
e.attrs.some(
(a) => a.name === "id" && a.value.toLowerCase() === id.toLowerCase(),
),
);
if (!el) return null;
const idAttr = el.attrs.find((a) => a.name === "id");
if (idAttr?.hasValue) {
return new vscode.Location(
document.uri,
new vscode.Range(
document.positionAt(idAttr.valueStart),
document.positionAt(idAttr.valueEnd),
),
);
}
return new vscode.Location(
document.uri,
new vscode.Range(
document.positionAt(el.start),
document.positionAt(el.startTagEnd),
),
);
}
/**
* Finds the precise range of an asset definition inside an XML file: the id
* attribute value when present, otherwise the element start tag.
@@ -118,7 +205,9 @@ async function locationInDocument(
file: string,
id: string,
): Promise<vscode.Location | null> {
const parsed = await ws.indexer?.readDocument(file);
// readDom (not readDocument) guarantees a DOM even when the compact
// records cache already has an entry for the file.
const parsed = await ws.indexer?.readDom(file);
if (parsed?.parse && parsed.lineMap) {
const el = parsed.parse.elements.find(
(e) =>
@@ -156,12 +245,15 @@ function toVscodePosition(p: { line: number; character: number }): vscode.Positi
// ── Find all references ─────────────────────────────────────────────
export class Ra3ReferenceProvider implements vscode.ReferenceProvider {
constructor(private ws: ModWorkspace) {}
async provideReferences(
document: vscode.TextDocument,
position: vscode.Position,
_context: vscode.ReferenceContext,
_token: vscode.CancellationToken,
): Promise<vscode.Location[] | null> {
if (!this.ws.isRa3Workspace()) return null;
const text = document.getText();
const offset = document.offsetAt(position);
const doc = parseXml(text);
@@ -201,8 +293,10 @@ export class Ra3DocumentLinkProvider implements vscode.DocumentLinkProvider {
document: vscode.TextDocument,
_token: vscode.CancellationToken,
): Promise<vscode.DocumentLink[]> {
if (!this.ws.isRa3Workspace()) return [];
const idx = this.ws.index;
if (!idx) return [];
const searchPaths = idx ? searchPathsFor(idx) : this.ws.searchPaths();
if (!searchPaths) return [];
const text = document.getText();
const doc = parseXml(text);
const links: vscode.DocumentLink[] = [];
@@ -214,9 +308,9 @@ export class Ra3DocumentLinkProvider implements vscode.DocumentLinkProvider {
resolveSource(
srcAttr.value,
dirname(document.uri.fsPath),
searchPathsFor(idx),
searchPaths,
).path ??
idx.sourceCandidates.find((c) => c.source === srcAttr.value)?.path ??
idx?.sourceCandidates.find((c) => c.source === srcAttr.value)?.path ??
null;
if (!target) continue;
links.push(
@@ -236,10 +330,13 @@ export class Ra3DocumentLinkProvider implements vscode.DocumentLinkProvider {
// ── Document symbols (outline) ──────────────────────────────────────
export class Ra3DocumentSymbolProvider implements vscode.DocumentSymbolProvider {
constructor(private ws: ModWorkspace) {}
async provideDocumentSymbols(
document: vscode.TextDocument,
_token: vscode.CancellationToken,
): Promise<vscode.DocumentSymbol[]> {
if (!this.ws.isRa3Workspace()) return [];
const text = document.getText();
const doc = parseXml(text);
const root = doc.root;
+6
View File
@@ -1,6 +1,7 @@
import * as vscode from "vscode";
import { parseXml } from "../language/xmlParser";
import { buildSemanticTokenRanges } from "../language/semanticTokens";
import type { ModWorkspace } from "../workspace";
const TOKEN_TYPES = ["type", "property", "string"] as const;
@@ -20,10 +21,15 @@ export const RA3_SEMANTIC_TOKENS_LEGEND = new vscode.SemanticTokensLegend([
export class Ra3SemanticTokensProvider
implements vscode.DocumentSemanticTokensProvider
{
constructor(private ws: ModWorkspace) {}
async provideDocumentSemanticTokens(
document: vscode.TextDocument,
_token: vscode.CancellationToken,
): Promise<vscode.SemanticTokens> {
if (!this.ws.isRa3Workspace()) {
return new vscode.SemanticTokens(new Uint32Array(0));
}
const text = document.getText();
const doc = parseXml(text);
if (doc.errors.length === 0) {
+37
View File
@@ -146,6 +146,11 @@ export class IndexRecordsCache {
return this.map.size;
}
/** Iterates [normalized key, entry] pairs (used by disk persistence). */
entries(): IterableIterator<[string, IndexRecordsCacheEntry]> {
return this.map.entries();
}
set(path: string, entry: IndexRecordsCacheEntry): void {
const key = normKey(path);
this.map.delete(key);
@@ -224,3 +229,35 @@ export class IncludeResolveCache {
return this.map.size;
}
}
/**
* Monotonic counter for workspace-level invalidations.
*
* A build captures `snapshot()` when it starts; if `changedSince()` is true
* when a phase snapshot is about to be published, files may have changed
* mid-build, so the published index is marked stale (and the workspace's
* dirty/rebuild mechanism converges to fresh data shortly after).
*/
export class InvalidationsEpoch {
private value = 0;
/** Records a new invalidation (content, creation or deletion). */
mark(): void {
this.value++;
}
/** Returns the current epoch value. */
snapshot(): number {
return this.value;
}
/** True when at least one invalidation happened since `epoch`. */
changedSince(epoch: number): boolean {
return this.value !== epoch;
}
/** Current epoch value (read-only accessor). */
get current(): number {
return this.value;
}
}
+208
View File
@@ -0,0 +1,208 @@
/**
* On-disk persistence for per-file index records.
*
* The records cache (top-level assets / defines / includes / xi:include with
* line numbers) is tiny compared to the source corpus (Corona: ~9k files,
* ~10 MB in memory), but rebuilding it from scratch means reading ~2.6 GB of
* art assets again. Persisting it makes a cold start cost a stat validation
* pass (~seconds on SSD, a few to tens of seconds on a mechanical drive)
* instead of a full rebuild.
*
* Correctness model (layered):
* - every cached record stores a multi-signal stamp
* `{ size, mtimeMs, birthtimeMs, ctimeMs }`;
* - on load, each file is stat-validated (no content reads); mismatches and
* missing files are dropped and re-read during the build;
* - during a session the file watcher invalidates entries precisely;
* - `ra3modxml.reindex` / `ra3modxml.clearCache` remain the final authority.
*
* The file is gzip-compressed JSON written atomically (temp + rename), keyed
* by project identity + settings so stale caches are ignored automatically.
*
* Pure TypeScript: no vscode dependency.
*/
import { createHash } from "node:crypto";
import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import { gzip, gunzip } from "node:zlib";
import { promisify } from "node:util";
import type { IndexRecordsCacheEntry } from "./caches";
import type { IndexRecords } from "./records";
import type { IndexedFile } from "./types";
const gzipAsync = promisify(gzip);
const gunzipAsync = promisify(gunzip);
export const DISK_CACHE_VERSION = 1;
/** How many stat validations run concurrently on load. */
const VALIDATE_CONCURRENCY = 32;
/** Settings that change what the index contains; a mismatch ignores the cache. */
export interface DiskCacheIdentity {
projectDir: string;
sdkDir: string;
indexSageXml: boolean;
additionalDataSearchPaths: string[];
builtmodsDirs: string[];
}
export interface DiskCacheRecord {
/** Normalized cache key (see `normKey`). */
key: string;
stat: NonNullable<IndexedFile["stat"]>;
records: IndexRecords;
kind: "full" | "shallow";
}
interface DiskCacheFile {
version: number;
key: string;
savedAt: string;
records: DiskCacheRecord[];
}
export interface DiskCacheLoadStats {
fileExists: boolean;
keyMatched: boolean;
/** Records stored in the file. */
loaded: number;
/** Records whose stat still matches (kept). */
validated: number;
/** Records dropped because the file changed, moved or was deleted. */
dropped: number;
}
export function diskCacheKey(identity: DiskCacheIdentity): string {
return createHash("sha256")
.update(JSON.stringify(identity))
.digest("hex")
.slice(0, 16);
}
export class DiskRecordsCache {
constructor(
private readonly filePath: string,
private readonly identity: DiskCacheIdentity,
) {}
get path(): string {
return this.filePath;
}
/**
* Loads and stat-validates the cache. Returns the kept records plus load
* statistics; missing/corrupt/key-mismatched caches yield an empty result
* instead of an error.
*/
async loadValidated(): Promise<{
records: DiskCacheRecord[];
stats: DiskCacheLoadStats;
}> {
const stats: DiskCacheLoadStats = {
fileExists: false,
keyMatched: false,
loaded: 0,
validated: 0,
dropped: 0,
};
let raw: DiskCacheFile | null = null;
try {
const buf = await readFile(this.filePath);
stats.fileExists = true;
const text = (await gunzipAsync(buf)).toString("utf8");
const parsed = JSON.parse(text);
if (
parsed &&
parsed.version === DISK_CACHE_VERSION &&
parsed.key === diskCacheKey(this.identity) &&
Array.isArray(parsed.records)
) {
raw = parsed as DiskCacheFile;
}
} catch {
// Missing or corrupt cache: fall through with an empty result.
}
if (!raw) return { records: [], stats };
stats.keyMatched = true;
stats.loaded = raw.records.length;
const kept: DiskCacheRecord[] = [];
for (let i = 0; i < raw.records.length; i += VALIDATE_CONCURRENCY) {
const chunk = raw.records.slice(i, i + VALIDATE_CONCURRENCY);
const results = await Promise.all(
chunk.map(async (rec): Promise<DiskCacheRecord | null> => {
try {
const s = await stat(rec.key);
if (
s.isFile() &&
s.size === rec.stat.size &&
s.mtimeMs === rec.stat.mtimeMs &&
s.birthtimeMs === rec.stat.birthtimeMs &&
s.ctimeMs === rec.stat.ctimeMs
) {
return rec;
}
} catch {
// File missing or inaccessible.
}
return null;
}),
);
for (const r of results) {
if (r) {
kept.push(r);
stats.validated++;
} else {
stats.dropped++;
}
}
}
return { records: kept, stats };
}
/** Writes the current records cache atomically (temp file + rename). */
async save(
entries: Iterable<[string, IndexRecordsCacheEntry]>,
): Promise<void> {
const records: DiskCacheRecord[] = [];
for (const [key, entry] of entries) {
if (!entry.stat) continue;
records.push({
key,
stat: entry.stat,
records: entry.records,
kind: entry.kind,
});
}
const payload: DiskCacheFile = {
version: DISK_CACHE_VERSION,
key: diskCacheKey(this.identity),
savedAt: new Date().toISOString(),
records,
};
const buf = await gzipAsync(Buffer.from(JSON.stringify(payload), "utf8"));
await mkdir(dirname(this.filePath), { recursive: true });
const tmp = `${this.filePath}.tmp`;
await writeFile(tmp, buf);
await rename(tmp, this.filePath);
}
/** Deletes the cache file (used by the clear-cache command). */
async clear(): Promise<void> {
try {
await rm(this.filePath, { force: true });
} catch {
// Best effort.
}
}
async status(): Promise<{ exists: boolean; sizeBytes: number }> {
try {
const s = await stat(this.filePath);
return { exists: s.isFile(), sizeBytes: s.size };
} catch {
return { exists: false, sizeBytes: 0 };
}
}
}
+117
View File
@@ -0,0 +1,117 @@
/**
* Lazy file-existence snapshot for include resolution.
*
* `resolveSource` performs synchronous `statSync` existence checks against
* every search base; a cold Corona build does ~110k of them (tens of seconds
* on a mechanical drive). Instead, existence is answered by reading the
* candidate's **parent directory** once (`readdir`, no per-file stat) and
* caching the entry set for the rest of the build. Only directories that are
* actually queried are ever listed, so a cold build pays a handful of
* readdir calls instead of an upfront recursive enumeration of every search
* root (which measurably slowed the XML phase).
*
* Correctness: the workspace clears `IncludeResolveCache` on file
* create/delete; each rebuild creates a fresh snapshot, and the debounced
* rebuild triggered by the watcher converges if anything changed mid-build.
*
* Pure TypeScript: no vscode dependency.
*/
import { existsSync, readdirSync } from "node:fs";
import { basename, dirname, parse, resolve, sep } from "node:path";
import { normKey } from "./caches";
import type { SearchPaths } from "./includeResolver";
/**
* Answers file-existence questions from lazily read directory listings.
* `has()` is authoritative for paths inside the roots and returns null for
* paths the snapshot does not cover (the caller falls back to `statSync`).
*/
export class ExistenceSnapshot {
private roots: string[] = [];
/** parent dir (normalized) -> lowercased file names, or null (no dir). */
private dirCache = new Map<string, Set<string> | null>();
/** Existence answers served from cached directory listings. */
hits = 0;
/** Paths outside the snapshot that required a statSync fallback. */
fallbacks = 0;
constructor(roots: string[]) {
// Roots and lookups must use the same normalization (case-insensitive on
// Windows), otherwise `startsWith` misses due to case differences.
this.roots = roots.map((r) => {
const n = normKey(r);
return n.endsWith(sep) ? n : n + sep;
});
}
/** true/false when covered by the snapshot, null when not covered. */
has(absPath: string): boolean | null {
const parentKey = normKey(dirname(absPath));
if (!this.isCovered(parentKey)) {
this.fallbacks++;
return null;
}
let entries = this.dirCache.get(parentKey);
if (entries === undefined) {
entries = listDirEntries(parentKey);
this.dirCache.set(parentKey, entries);
}
this.hits++;
return entries ? entries.has(basename(absPath).toLowerCase()) : false;
}
private isCovered(dirKey: string): boolean {
const key = dirKey.endsWith(sep) ? dirKey : dirKey + sep;
for (const root of this.roots) {
if (key.startsWith(root)) return true;
}
return false;
}
}
function listDirEntries(dir: string): Set<string> | null {
try {
const out = new Set<string>();
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (entry.isFile()) out.add(entry.name.toLowerCase());
}
return out;
} catch {
return null;
}
}
/**
* True when `dir` is a filesystem root (e.g. "C:\" or "/"). Such roots are
* never treated as search bases (they may contain the whole disk).
*/
export function isDriveRoot(dir: string): boolean {
const resolved = resolve(dir);
return parse(resolved).root === resolved;
}
/**
* Builds the snapshot root list from the search bases: drive roots and
* missing directories are skipped, and a root covered by a broader root is
* dropped (e.g. `sdkDir` covers `sdkDir/SageXml`). No directory is listed
* here; listings happen lazily per queried parent directory.
*/
export function buildExistenceSnapshot(searchPaths: SearchPaths): ExistenceSnapshot {
const candidates = [
...searchPaths.DATA,
...searchPaths.ART,
...searchPaths.AUDIO,
].map((r) => resolve(r));
candidates.sort((a, b) => a.length - b.length);
const roots: string[] = [];
for (const root of candidates) {
if (isDriveRoot(root)) continue;
if (!existsSync(root)) continue;
const normalized = normKey(root);
if (roots.some((r) => normalized.startsWith(r + sep))) continue;
roots.push(normalized);
}
return new ExistenceSnapshot(roots);
}
+32
View File
@@ -61,6 +61,38 @@ export class CachedDirectoryWalker implements FileWalker {
}
}
/**
* True for paths whose changes can never affect the index: `.git` internals
* touched by background fetch/maintenance, and transient temp/backup files
* created by editors or other extensions (`UnitCrate.xml.git`, `*.tmp`,
* `*.lock`, `file~`, `.#file`, ...). Such events are ignored by the file
* watcher instead of triggering rebuilds.
*/
export function isWatcherNoisePath(fsPath: string): boolean {
const segments = fsPath.split(/[\\/]/);
if (segments.some((seg) => seg.toLowerCase() === ".git")) return true;
const base = segments[segments.length - 1] ?? "";
const lower = base.toLowerCase();
const noiseSuffixes = [".git", ".tmp", ".lock", "~", ".swp", ".bak", ".orig"];
if (noiseSuffixes.some((suffix) => lower.endsWith(suffix))) return true;
return lower.startsWith(".#") || lower.startsWith(".~");
}
/**
* True for files whose *content* participates in the index: XML documents
* and art-asset XML (.w3x). Reasonable text formats in RA3 mods are `.xml`,
* `.w3x` and `.lua`; lua is not indexed yet, and compiled manifests are
* binary `*.manifest` (there is no `.manifestxml` source format). Files
* already in the index with other extensions (e.g. sniffed XML) are handled
* separately via `ModIndexer.isIndexedFile`. Content changes to binary art
* (e.g. textures, `.w3d`) cannot change index records, so they do not need
* to trigger a rebuild.
*/
export function isContentRelevantPath(fsPath: string): boolean {
const ext = extname(fsPath).toLowerCase();
return ext === ".xml" || ext === ".w3x";
}
/**
* Builds the candidate list for Include/@source completion from a set of
* search directories. For DATA directories only *.xml files are listed; for
+25 -7
View File
@@ -8,6 +8,7 @@
import { join, resolve, normalize, isAbsolute } from "node:path";
import { statSync } from "node:fs";
import type { ExistenceSnapshot } from "./existence";
export type IncludeKind = "all" | "instance" | "reference";
export type SourcePrefix = "DATA" | "ART" | "AUDIO" | null;
@@ -99,41 +100,58 @@ export function resolveSource(
source: string,
currentDir: string | null,
searchPaths: SearchPaths,
existence?: ExistenceSnapshot,
): ResolveResult {
const raw = source.trim().replace(/\\/g, "/");
const { prefix, rest } = splitPrefix(raw);
if (prefix) {
const bases = searchPaths[prefix] ?? [];
const direct = findInBases(rest, bases);
const direct = findInBases(rest, bases, existence);
if (direct) return { path: direct, prefix, raw };
if (prefix === "ART" && !rest.includes("/")) {
const two = rest.slice(0, 2).toLowerCase();
const prefixed = findInBases(`${two}/${rest}`, bases);
const prefixed = findInBases(`${two}/${rest}`, bases, existence);
if (prefixed) return { path: prefixed, prefix, raw };
}
return { path: null, prefix, raw };
}
if (currentDir && isAbsolute(rest)) {
return { path: fileExists(rest) ? rest : null, prefix: null, raw };
return {
path: fileExists(rest, existence) ? rest : null,
prefix: null,
raw,
};
}
if (currentDir) {
const candidate = resolve(currentDir, rest);
return { path: fileExists(candidate) ? candidate : null, prefix: null, raw };
return {
path: fileExists(candidate, existence) ? candidate : null,
prefix: null,
raw,
};
}
return { path: null, prefix: null, raw };
}
function findInBases(relPath: string, bases: string[]): string | null {
function findInBases(
relPath: string,
bases: string[],
existence?: ExistenceSnapshot,
): string | null {
for (const base of bases) {
const candidate = normalize(resolve(base, relPath));
if (fileExists(candidate)) return candidate;
if (fileExists(candidate, existence)) return candidate;
}
return null;
}
function fileExists(path: string): boolean {
function fileExists(path: string, existence?: ExistenceSnapshot): boolean {
if (existence) {
const known = existence.has(path);
if (known !== null) return known;
}
try {
return statSync(path).isFile();
} catch {
+203 -52
View File
@@ -18,7 +18,6 @@ import {
LineMap,
parseXml,
stripBom,
type XmlDocument,
type XmlElement,
} from "../language/xmlParser";
import {
@@ -28,6 +27,11 @@ import {
type ResolveResult,
type SearchPaths,
} from "./includeResolver";
import {
buildExistenceSnapshot,
type ExistenceSnapshot,
} from "./existence";
import { findXPointerContainer, localName } from "./xpointer";
import {
deriveAssetId,
deriveAssetType,
@@ -59,10 +63,11 @@ const MAX_DEPTH = 300;
/** Files above this size are never parsed (safety against binary blobs). */
const MAX_PARSE_BYTES = 4 * 1024 * 1024;
/**
* Fully parsed XML documents. `.xml` / `.manifestxml` files are small enough
* that a full DOM is affordable.
* Fully parsed XML documents. `.xml` files are small enough that a full DOM
* is affordable. (Compiled manifests are binary `*.manifest` files parsed by
* `manifestParser`; there is no `.manifestxml` source format.)
*/
const FULL_XML_EXTENSIONS = new Set([".xml", ".manifestxml"]);
const FULL_XML_EXTENSIONS = new Set([".xml"]);
/**
* XML documents whose top-level structure is all the index needs (art-asset
* files exported by modeling tools, e.g. .w3x). They are shallow-scanned so
@@ -79,6 +84,8 @@ export class ModIndexer {
private docs: DocumentCache;
private recordsCache: IndexRecordsCache;
private resolveCache: IncludeResolveCache;
/** Directory-based file existence snapshot (avoids cold statSync storms). */
private existence: ExistenceSnapshot | null = null;
private scanCounters = {
shallowScannedFiles: 0,
shallowCacheHits: 0,
@@ -86,7 +93,19 @@ export class ModIndexer {
resolveCacheHits: 0,
resolveCalls: 0,
};
private phase = { candidatesMs: 0, walkMs: 0 };
private timings = { candidatesMs: 0, walkMs: 0, artScanMs: 0 };
/**
* Phase A ("xml") registers art-asset XML files (`.w3x` and sniffed XML)
* without reading their content; the queue is drained by phase B ("art"),
* which shallow-scans them and walks any includes they contain.
*/
private deferArtScan = false;
private artQueue: {
path: string;
stream: StreamInfo;
depth: number;
viaInstance: boolean;
}[] = [];
private assets = new Map<string, Map<string, AssetDef[]>>();
private assetsById = new Map<string, AssetDef[]>();
private defines = new Map<string, DefineDef[]>();
@@ -111,8 +130,7 @@ export class ModIndexer {
/**
* Returns a document for indexing/navigation:
* - `.xml` / `.manifestxml` files are fully parsed (bounded by
* MAX_PARSE_BYTES);
* - `.xml` files are fully parsed (bounded by MAX_PARSE_BYTES);
* - `.w3x` (and unknown-extension files whose content looks like XML) are
* shallow-scanned, so huge model files never become a DOM;
* - everything else is registered as a file but never parsed.
@@ -120,7 +138,10 @@ export class ModIndexer {
* Cached entries are reused when the file stat is unchanged, which lets a
* workspace-owned cache survive rebuilds.
*/
async readDocument(path: string): Promise<ParsedFile | null> {
async readDocument(
path: string,
opts?: { deferArt?: boolean },
): Promise<ParsedFile | null> {
const key = normKey(path);
const trust =
this.opts.trustUnchanged === true && !this.opts.changedFiles?.has(key);
@@ -143,29 +164,71 @@ export class ModIndexer {
try {
const st = await stat(path);
const rec = this.recordsCache.get(key);
if (rec?.stat && rec.stat.mtimeMs === st.mtimeMs && rec.stat.size === st.size) {
if (
rec?.stat &&
rec.stat.mtimeMs === st.mtimeMs &&
rec.stat.size === st.size &&
rec.stat.birthtimeMs === st.birthtimeMs &&
rec.stat.ctimeMs === st.ctimeMs
) {
return this.recordsParsed(path, rec);
}
const hit = this.docs.get(key);
if (
hit?.file.stat &&
hit.file.stat.mtimeMs === st.mtimeMs &&
hit.file.stat.size === st.size
hit.file.stat.size === st.size &&
hit.file.stat.birthtimeMs === st.birthtimeMs &&
hit.file.stat.ctimeMs === st.ctimeMs
) {
this.files.set(key, hit.file);
return hit;
}
const mode = await this.detectXmlMode(path);
if (mode === "shallow") return this.scanShallow(path, st);
if (mode === "shallow") {
// Phase A: register the art file, defer the shallow scan to phase B.
if (opts?.deferArt) {
const file: IndexedFile = {
path: resolve(path),
stat: {
mtimeMs: st.mtimeMs,
size: st.size,
birthtimeMs: st.birthtimeMs,
ctimeMs: st.ctimeMs,
},
};
// Deliberately NOT stored in the DocumentCache: a deferred entry
// has no records, and a later phase-B read must re-scan it.
this.files.set(key, file);
return { file, parse: null, records: null, lineMap: null, deferredArt: true };
}
return this.scanShallow(path, st);
}
if (mode === "binary") {
const file: IndexedFile = { path: resolve(path), stat: { mtimeMs: st.mtimeMs, size: st.size } };
const file: IndexedFile = {
path: resolve(path),
stat: {
mtimeMs: st.mtimeMs,
size: st.size,
birthtimeMs: st.birthtimeMs,
ctimeMs: st.ctimeMs,
},
};
const parsed: ParsedFile = { file, parse: null, records: null, lineMap: null };
this.docs.set(parsed);
this.files.set(key, file);
return parsed;
}
if (st.size > MAX_PARSE_BYTES) {
const file: IndexedFile = { path: resolve(path), stat: { mtimeMs: st.mtimeMs, size: st.size } };
const file: IndexedFile = {
path: resolve(path),
stat: {
mtimeMs: st.mtimeMs,
size: st.size,
birthtimeMs: st.birthtimeMs,
ctimeMs: st.ctimeMs,
},
};
const parsed: ParsedFile = { file, parse: null, records: null, lineMap: null };
this.docs.set(parsed);
this.files.set(key, file);
@@ -176,7 +239,15 @@ export class ModIndexer {
const parse = parseXml(text);
const records = extractIndexRecords(parse, lineMap);
const parsed: ParsedFile = {
file: { path: resolve(path), stat: { mtimeMs: st.mtimeMs, size: st.size } },
file: {
path: resolve(path),
stat: {
mtimeMs: st.mtimeMs,
size: st.size,
birthtimeMs: st.birthtimeMs,
ctimeMs: st.ctimeMs,
},
},
parse,
records,
lineMap,
@@ -211,7 +282,15 @@ export class ModIndexer {
const lineMap = new LineMap(text);
const records = recordsFromShallow(scanXmlShallow(text), lineMap);
const parsed: ParsedFile = {
file: { path: resolve(path), stat: { mtimeMs: st.mtimeMs, size: st.size } },
file: {
path: resolve(path),
stat: {
mtimeMs: st.mtimeMs,
size: st.size,
birthtimeMs: st.birthtimeMs,
ctimeMs: st.ctimeMs,
},
},
parse: null,
records,
lineMap: null,
@@ -235,11 +314,11 @@ export class ModIndexer {
}
/**
* Reads a document and guarantees a DOM parse tree. Used only for
* root-level <xi:include> xpointer selection (rare), where the target's
* container children are needed.
* Reads a document and guarantees a DOM parse tree. Used for root-level
* <xi:include> xpointer selection (rare) and by the document-local scope
* (logical include expansion + precise definition locations).
*/
private async readDom(path: string): Promise<ParsedFile | null> {
async readDom(path: string): Promise<ParsedFile | null> {
const key = normKey(path);
const cached = this.docs.get(key);
if (cached?.parse?.root) {
@@ -253,7 +332,9 @@ export class ModIndexer {
hit?.parse?.root &&
hit.file.stat &&
hit.file.stat.mtimeMs === st.mtimeMs &&
hit.file.stat.size === st.size
hit.file.stat.size === st.size &&
hit.file.stat.birthtimeMs === st.birthtimeMs &&
hit.file.stat.ctimeMs === st.ctimeMs
) {
this.files.set(key, hit.file);
return hit;
@@ -264,7 +345,15 @@ export class ModIndexer {
const parse = parseXml(text);
const records = extractIndexRecords(parse, lineMap);
const parsed: ParsedFile = {
file: { path: resolve(path), stat: { mtimeMs: st.mtimeMs, size: st.size } },
file: {
path: resolve(path),
stat: {
mtimeMs: st.mtimeMs,
size: st.size,
birthtimeMs: st.birthtimeMs,
ctimeMs: st.ctimeMs,
},
},
parse,
records,
lineMap,
@@ -299,7 +388,12 @@ export class ModIndexer {
return hit;
}
this.scanCounters.resolveCalls++;
const result = resolveSource(source, currentDir, this.searchPaths);
const result = resolveSource(
source,
currentDir,
this.searchPaths,
this.existence ?? undefined,
);
this.resolveCache.set(key, result);
return result;
}
@@ -323,14 +417,26 @@ export class ModIndexer {
return this.docs.get(path);
}
async build(): Promise<ModIndex> {
/** True when the file is part of the current build's index. */
isIndexedFile(path: string): boolean {
return this.files.has(normKey(path));
}
async build(onPhase?: (index: ModIndex) => void | Promise<void>): Promise<ModIndex> {
const start = Date.now();
// Root list only; directories are listed lazily on first query, so the
// XML phase does not pay an upfront recursive enumeration of the SDK.
this.existence = buildExistenceSnapshot(this.searchPaths);
const projectData = await findCaseInsensitiveDir(join(this.opts.projectDir, "Data"));
const additionalMaps = projectData
? await findCaseInsensitiveDir(join(projectData, "additionalmaps"))
: null;
// ── Streams ──
// Phase A: walk the include graph without reading art-asset content.
// `.w3x` (and sniffed XML) files are registered and queued; their
// top-level assets and nested includes are processed in phase B.
this.deferArtScan = true;
const walkStart = Date.now();
const staticEntry = projectData ? join(projectData, "Mod.xml") : null;
if (staticEntry) {
@@ -360,7 +466,7 @@ export class ModIndexer {
await this.walk(entry, "all", stream, 0);
}
}
this.phase.walkMs = Date.now() - walkStart;
this.timings.walkMs = Date.now() - walkStart;
// ── Source completion candidates ──
const candidatesStart = Date.now();
@@ -408,45 +514,94 @@ export class ModIndexer {
...sdkRootCandidates,
...this.sourceCandidates,
]);
this.phase.candidatesMs = Date.now() - candidatesStart;
this.timings.candidatesMs = Date.now() - candidatesStart;
// Publish the XML phase as an immutable snapshot: features get usable
// completions/navigation/diagnostics for XML + manifest data immediately,
// while the slow art scan continues in the background.
const xmlPhase = this.snapshotIndex("xml", false, start);
if (onPhase) await onPhase(xmlPhase);
// ── Phase B: art assets ──
this.deferArtScan = false;
const artStart = Date.now();
while (this.artQueue.length) {
const entry = this.artQueue.shift()!;
const parsed = await this.readDocument(entry.path);
if (parsed?.records) {
await this.applyRecords(parsed, entry.stream, entry.depth, entry.viaInstance);
}
}
this.timings.artScanMs = Date.now() - artStart;
return this.snapshotIndex("art", true, start);
}
/**
* Produces a copy of the current index state. Phase-A snapshots must be
* immutable: phase B keeps mutating the live maps after the snapshot has
* been handed to features, so every nested map/array is cloned here.
*/
private snapshotIndex(
phase: "xml" | "art",
complete: boolean,
startedAt: number,
): ModIndex {
const manifestAssetCount = [...this.manifests.values()].reduce(
(sum, m) => sum + m.assets.length,
0,
);
const assets = new Map<string, Map<string, AssetDef[]>>();
for (const [type, byId] of this.assets) {
const copied = new Map<string, AssetDef[]>();
for (const [id, defs] of byId) copied.set(id, defs.slice());
assets.set(type, copied);
}
const assetsById = new Map<string, AssetDef[]>();
for (const [id, defs] of this.assetsById) assetsById.set(id, defs.slice());
const defines = new Map<string, DefineDef[]>();
for (const [name, defs] of this.defines) defines.set(name, defs.slice());
return {
projectDir: resolve(this.opts.projectDir),
sdkDir: resolve(this.opts.sdkDir),
assets: this.assets,
assetsById: this.assetsById,
defines: this.defines,
files: this.files,
streams: this.streams,
manifests: this.manifests,
sourceCandidates: this.sourceCandidates,
diagnostics: this.diagnostics,
complete,
phase,
assets,
assetsById,
defines,
files: new Map(this.files),
streams: this.streams.map((s) => ({ ...s, files: new Set(s.files) })),
manifests: new Map(this.manifests),
sourceCandidates: this.sourceCandidates.slice(),
diagnostics: this.diagnostics.slice(),
stats: {
projectDir: resolve(this.opts.projectDir),
sdkDir: resolve(this.opts.sdkDir),
phase,
complete,
indexedFiles: this.files.size,
parsedFiles: [...this.files.values()].filter(
(f) => f.stat != null && f.stat.size <= MAX_PARSE_BYTES,
).length,
shallowScannedFiles: this.scanCounters.shallowScannedFiles,
deferredArtFiles: this.artQueue.length,
shallowCacheHits: this.scanCounters.shallowCacheHits,
recordsCacheHits: this.scanCounters.recordsCacheHits,
resolveCacheHits: this.scanCounters.resolveCacheHits,
resolveCalls: this.scanCounters.resolveCalls,
candidatesMs: this.phase.candidatesMs,
walkMs: this.phase.walkMs,
snapshotHits: this.existence?.hits ?? 0,
snapshotFallbacks: this.existence?.fallbacks ?? 0,
candidatesMs: this.timings.candidatesMs,
walkMs: this.timings.walkMs,
artScanMs: this.timings.artScanMs,
assetCount: [...this.assets.values()].reduce((sum, byId) => sum + byId.size, 0),
defineCount: this.defines.size,
manifestFiles: this.manifests.size,
manifestAssetCount,
streams: this.streams.length,
sourceCandidates: this.sourceCandidates.length,
elapsedMs: Date.now() - start,
elapsedMs: Date.now() - startedAt,
},
};
}
@@ -483,9 +638,19 @@ export class ModIndexer {
// readDocument returns compact index records for every indexable XML
// document (full parse or shallow scan), or a bare file registration
// for binary / unparseable targets.
const parsed = await this.readDocument(path);
// for binary / unparseable targets. During phase A, art-asset XML files
// are registered and queued instead of scanned (deferredArt).
const parsed = await this.readDocument(path, this.deferArtScan ? { deferArt: true } : undefined);
if (!parsed) return;
if (parsed.deferredArt) {
this.artQueue.push({
path: parsed.file.path,
stream,
depth,
viaInstance: mode === "instance",
});
return;
}
if (parsed.records) {
await this.applyRecords(parsed, stream, depth, mode === "instance");
return;
@@ -722,11 +887,6 @@ export class ModIndexer {
// ── Module-level helpers ─────────────────────────────────────────────
function localName(tag: string): string {
const idx = tag.lastIndexOf(":");
return idx >= 0 ? tag.slice(idx + 1) : tag;
}
function lineOf(parsed: ParsedFile, offset: number): number {
if (!parsed.lineMap) return 0;
return parsed.lineMap.positionAt(offset).line + 1;
@@ -774,15 +934,6 @@ async function findCaseInsensitiveDir(dir: string): Promise<string | null> {
}
}
function findXPointerContainer(doc: XmlDocument, xpointer: string): XmlElement | null {
// Supports the form used by the mods:
// xmlns(n=uri:ea.com:eala:asset) xpointer(/n:ElementName/child::*)
const m = /xpointer\(\/\w+:(\w+)\/child::\*\)/.exec(xpointer);
if (!m) return null;
const name = m[1];
return doc.elements.find((el) => localName(el.name) === name) ?? null;
}
/** Keeps the first candidate for each case-insensitive source string. */
function dedupeSourceCandidates(candidates: SourceCandidate[]): SourceCandidate[] {
const seen = new Set<string>();
+265
View File
@@ -0,0 +1,265 @@
import { dirname, resolve } from "node:path";
import { LineMap, parseXml, type XmlDocument } from "../language/xmlParser";
import { extractIndexRecords } from "./records";
import { resolveSource, type SearchPaths } from "./includeResolver";
import { expandDocument, type LogicalDocument } from "./logicalTree";
import type {
AssetDef,
DefineDef,
LocalOverlay,
ModIndex,
ParsedFile,
} from "./types";
export interface LocalScopeContext {
projectDir: string;
sdkDir: string;
searchPaths: SearchPaths;
/** Reads a file's compact index records (full parse or shallow scan). */
readRecords(path: string): Promise<ParsedFile | null>;
/** Reads a file and guarantees a DOM parse tree. */
readDom(path: string): Promise<ParsedFile | null>;
}
/**
* Everything the features need for the currently open document: the original
* parse, the expanded logical tree, per-source line maps, the local overlay
* and the overlay-aware index for lookups.
*/
export interface DocumentScope {
uri: string;
version: number;
parse: XmlDocument;
lineMap: LineMap;
expanded: LogicalDocument;
/** scopePathKey(file) -> line map (current file + expanded include targets). */
lineMaps: Map<string, LineMap>;
/** Local assets/defines from the document itself and its include chain. */
overlay: LocalOverlay;
/** Global index with `local` attached (or a minimal standalone index). */
merged: ModIndex | null;
}
/** Normalized key used for source-file identity / line-map lookup. */
export function scopePathKey(path: string): string {
return path.replace(/\\/g, "/").toLowerCase();
}
/**
* Builds the document scope for the current (possibly unsaved) text:
* - a local overlay of assets/defines reachable from this file;
* - a logical tree with supported xi:include targets spliced in place.
*/
export async function buildDocumentScope(
uri: string,
text: string,
version: number,
ctx: LocalScopeContext,
): Promise<DocumentScope> {
const lineMap = new LineMap(text);
const parse = parseXml(text);
const builder = new OverlayBuilder(ctx);
await builder.addEntry(uri, parse, lineMap);
const expanded = await expandDocument(uri, parse, {
resolve: (source, currentDir) =>
resolveSource(source, currentDir, ctx.searchPaths).path,
readDom: async (path) => {
const parsed = await ctx.readDom(path);
return parsed?.parse && parsed.lineMap
? { parse: parsed.parse, lineMap: parsed.lineMap }
: null;
},
});
const lineMaps = new Map<string, LineMap>();
lineMaps.set(scopePathKey(uri), lineMap);
for (const [path, lm] of builder.lineMaps) {
if (!lineMaps.has(path)) lineMaps.set(path, lm);
}
return {
uri,
version,
parse,
lineMap,
expanded,
lineMaps,
overlay: builder.overlay,
merged: null,
};
}
/**
* Attaches a document-local overlay to a global index without copying the
* global maps. When there is no global index yet, returns a minimal standalone
* index so the local chain alone can serve completions / references.
*/
export function withLocalOverlay(
global: ModIndex | null,
overlay: LocalOverlay,
projectDir: string,
sdkDir: string,
): ModIndex {
if (!global) {
return {
projectDir,
sdkDir,
complete: false,
phase: "xml",
assets: new Map(),
assetsById: new Map(),
defines: new Map(),
files: new Map(),
streams: [],
manifests: new Map(),
sourceCandidates: [],
diagnostics: [],
stats: {
projectDir,
sdkDir,
phase: "xml",
complete: false,
indexedFiles: 0,
parsedFiles: 0,
shallowScannedFiles: 0,
deferredArtFiles: 0,
shallowCacheHits: 0,
recordsCacheHits: 0,
resolveCacheHits: 0,
resolveCalls: 0,
snapshotHits: 0,
snapshotFallbacks: 0,
candidatesMs: 0,
walkMs: 0,
artScanMs: 0,
assetCount: 0,
defineCount: 0,
manifestFiles: 0,
manifestAssetCount: 0,
streams: 0,
sourceCandidates: 0,
elapsedMs: 0,
},
local: overlay,
};
}
return { ...global, local: overlay };
}
const MAX_LOCAL_DEPTH = 64;
class OverlayBuilder {
readonly overlay: LocalOverlay = {
assets: new Map(),
assetsById: new Map(),
defines: new Map(),
};
readonly lineMaps = new Map<string, LineMap>();
private visited = new Set<string>();
constructor(private ctx: LocalScopeContext) {}
async addEntry(
path: string,
parse: XmlDocument,
lineMap: LineMap,
): Promise<void> {
this.lineMaps.set(scopePathKey(path), lineMap);
await this.addParsed({
file: { path: resolve(path), stat: null },
parse,
records: extractIndexRecords(parse, lineMap),
lineMap,
}, 0);
}
async addFile(path: string, depth: number): Promise<void> {
if (depth > MAX_LOCAL_DEPTH) return;
const parsed = await this.ctx.readRecords(path);
if (parsed) await this.addParsed(parsed, depth);
}
private async addParsed(parsed: ParsedFile, depth: number): Promise<void> {
const path = parsed.file.path;
const key = scopePathKey(path);
if (!parsed.records || this.visited.has(key)) return;
this.visited.add(key);
if (parsed.lineMap) this.lineMaps.set(key, parsed.lineMap);
const origin = this.originOf(path);
for (const asset of parsed.records.assets) {
this.addAsset({
type: asset.type,
id: asset.id,
file: path,
line: asset.line,
origin,
stream: "local",
});
}
for (const define of parsed.records.defines) {
const entry: DefineDef = {
name: define.name,
value: define.value,
file: path,
line: define.line,
origin,
};
const arr = this.overlay.defines.get(define.name.toLowerCase());
if (arr) arr.push(entry);
else this.overlay.defines.set(define.name.toLowerCase(), [entry]);
}
for (const inc of parsed.records.includes) {
const resolved = resolveSource(inc.source, dirname(path), this.ctx.searchPaths);
if (!resolved.path) continue;
if (inc.type === "all" || inc.type === "instance") {
await this.addFile(resolved.path, depth + 1);
}
// type="reference" points at compiled manifests; their assets are
// provided by the global index, so the local text overlay skips them.
}
for (const xi of [
...parsed.records.nestedXiIncludes,
...parsed.records.rootXiIncludes,
]) {
const resolved = resolveSource(xi.href, dirname(path), this.ctx.searchPaths);
if (resolved.path) await this.addFile(resolved.path, depth + 1);
}
}
private addAsset(def: AssetDef): void {
const typeKey = def.type;
const idKey = def.id.toLowerCase();
let byId = this.overlay.assets.get(typeKey);
if (!byId) {
byId = new Map();
this.overlay.assets.set(typeKey, byId);
}
const arr = byId.get(idKey);
if (arr) {
if (arr.some((a) => a.file === def.file && a.line === def.line)) return;
arr.push(def);
} else {
byId.set(idKey, [def]);
}
const all = this.overlay.assetsById.get(idKey);
if (all) {
if (all.some((a) => a.file === def.file && a.line === def.line)) return;
all.push(def);
} else {
this.overlay.assetsById.set(idKey, [def]);
}
}
private originOf(path: string): "project" | "sdk" | "manifest" {
const p = resolve(path).toLowerCase();
const project = resolve(this.ctx.projectDir).toLowerCase();
const sdk = resolve(this.ctx.sdkDir).toLowerCase();
if (p.startsWith(project + "\\")) return "project";
if (sdk && p.startsWith(sdk + "\\")) return "sdk";
return "project";
}
}
+249
View File
@@ -0,0 +1,249 @@
import { dirname } from "node:path";
import type {
LineMap,
XmlDocument,
XmlElement,
XmlParseError,
} from "../language/xmlParser";
import { resolveElementType } from "../language/typeContext";
import { isAssignableTo } from "../model/schemaModel";
import { findXPointerContainer, localName } from "./xpointer";
/**
* A logical document is the parsed tree of the currently open file with
* supported `xi:include` targets spliced in place. Every node keeps its
* original source file and offsets so diagnostics / hover / navigation can
* map back to the real file.
*/
export interface LogicalElement extends Omit<XmlElement, "parent" | "children"> {
parent: LogicalElement | null;
children: LogicalElement[];
sourceFile: string;
}
export interface LogicalDocument {
root: LogicalElement | null;
elements: LogicalElement[];
/** Mirrors XmlDocument so existing helpers (findElementAt etc.) work. */
errors: XmlParseError[];
declarationEnd: number;
}
export interface ExpandContext {
/** Resolves an xi:include href (BAB search order). */
resolve(source: string, currentDir: string): string | null;
/** Reads a target and guarantees a DOM parse tree. */
readDom(path: string): Promise<{ parse: XmlDocument; lineMap: LineMap } | null>;
/** Include-depth guard. Defaults to 64. */
maxDepth?: number;
}
const DEFAULT_MAX_DEPTH = 64;
/**
* Builds the logical document for `entryPath` by replacing supported
* `xi:include` elements with their selected target children.
*
* The original xi:include node stays in `elements` (so hover keeps working)
* but is removed from the logical child list; its selected content is spliced
* in as siblings. Nodes are shallow-cloned shells with a rebuilt parent/child
* chain, so cached parse trees in the indexer are never mutated.
*/
export async function expandDocument(
entryPath: string,
parse: XmlDocument,
ctx: ExpandContext,
): Promise<LogicalDocument> {
const elements: LogicalElement[] = [];
const map = new Map<XmlElement, LogicalElement>();
// Pre-create a logical shell for every original element (including orphan
// nodes produced by the parser's unterminated-quote recovery). Parent
// pointers follow the original tree so type context is preserved.
for (const orig of parse.elements) {
const parent = orig.parent ? map.get(orig.parent) ?? null : null;
const clone = cloneNode(orig, parent, entryPath);
map.set(orig, clone);
elements.push(clone);
}
// Cycle guard is a recursion stack, not a global visited set: the same
// fragment may legitimately be included under several parents.
const stack = new Set<string>();
const root = parse.root ? map.get(parse.root) ?? null : null;
// Traverse from the real root plus any parser-recovery orphans (elements
// whose parent is null but are not the root).
const roots = new Set<XmlElement>();
if (parse.root) roots.add(parse.root);
for (const orig of parse.elements) {
if (orig !== parse.root && orig.parent === null) roots.add(orig);
}
for (const origRoot of roots) {
const logicalRoot = map.get(origRoot)!;
await expandChildren(
origRoot,
logicalRoot,
entryPath,
0,
ctx,
elements,
stack,
map,
);
}
return { root, elements, errors: parse.errors, declarationEnd: parse.declarationEnd };
}
function cloneNode(
el: XmlElement,
parent: LogicalElement | null,
sourceFile: string,
): LogicalElement {
return { ...el, parent, children: [], sourceFile };
}
async function expandChildren(
origParent: XmlElement,
logicalParent: LogicalElement,
file: string,
depth: number,
ctx: ExpandContext,
elements: LogicalElement[],
stack: Set<string>,
map?: Map<XmlElement, LogicalElement>,
): Promise<void> {
for (const child of origParent.children) {
await handleChild(child, logicalParent, file, depth, ctx, elements, stack, map);
}
}
async function handleChild(
orig: XmlElement,
logicalParent: LogicalElement,
file: string,
depth: number,
ctx: ExpandContext,
elements: LogicalElement[],
stack: Set<string>,
map?: Map<XmlElement, LogicalElement>,
): Promise<void> {
const isXi = orig.name.toLowerCase().startsWith("xi:") &&
localName(orig.name).toLowerCase() === "include";
if (isXi) {
// Keep the xi:include itself discoverable (hover), but let its selected
// content replace it in the logical child list.
if (!map?.has(orig)) elements.push(cloneNode(orig, logicalParent, file));
await expandXi(orig, logicalParent, file, depth, ctx, elements, stack);
return;
}
let clone = map?.get(orig);
if (!clone) {
clone = cloneNode(orig, logicalParent, file);
elements.push(clone);
}
logicalParent.children.push(clone);
await expandChildren(orig, clone, file, depth + 1, ctx, elements, stack, map);
}
async function expandXi(
xi: XmlElement,
logicalParent: LogicalElement,
parentFile: string,
depth: number,
ctx: ExpandContext,
elements: LogicalElement[],
stack: Set<string>,
): Promise<void> {
const href = xi.attrs.find((a) => a.name === "href")?.value;
if (!href) return;
const resolved = ctx.resolve(href, dirname(parentFile));
if (!resolved) return;
const key = normPath(resolved);
if (stack.has(key)) return; // include cycle
if (depth > (ctx.maxDepth ?? DEFAULT_MAX_DEPTH)) return;
stack.add(key);
try {
const target = await ctx.readDom(resolved);
if (!target?.parse?.root) return;
const xpointer = xi.attrs.find((a) => a.name === "xpointer")?.value ?? "";
const selected = xpointer
? findXPointerContainer(target.parse, xpointer)?.children ?? []
: target.parse.root.children;
for (const sel of selected) {
await handleChild(sel, logicalParent, resolved, depth + 1, ctx, elements, stack);
}
} finally {
stack.delete(key);
}
}
/** True when a logical element's resolved XSD type is a GameObject. */
export function isGameObjectElement(el: LogicalElement): boolean {
const type = resolveElementType(el);
return type != null && isAssignableTo(type, "GameObject");
}
/** Nearest ancestor whose resolved type is a GameObject (or subclass). */
export function findContainingGameObject(
el: LogicalElement,
): LogicalElement | null {
let cur = el.parent;
while (cur) {
if (isGameObjectElement(cur)) return cur;
cur = cur.parent;
}
return null;
}
export interface LocalIdInfo {
id: string;
el: LogicalElement;
}
/**
* Collects every `id` defined inside a GameObject subtree (including modules
* spliced in through xi:include). These are the candidates for Poid-typed
* pipeline-local references such as AttachModuleId / ModuleId.
*/
export function collectLocalIds(root: LogicalElement): LocalIdInfo[] {
const out: LocalIdInfo[] = [];
const seen = new Set<string>();
const stack: LogicalElement[] = [root];
while (stack.length) {
const el = stack.pop()!;
const idAttr = el.attrs.find((a) => a.name === "id");
if (idAttr?.hasValue) {
const key = idAttr.value.toLowerCase();
if (!seen.has(key)) {
seen.add(key);
out.push({ id: idAttr.value, el });
}
}
for (const child of el.children) stack.push(child);
}
return out;
}
/** Finds an id inside a GameObject subtree (case-insensitive). */
export function findLocalId(
root: LogicalElement,
id: string,
): LogicalElement | null {
const wanted = id.toLowerCase();
const stack: LogicalElement[] = [root];
while (stack.length) {
const el = stack.pop()!;
const idAttr = el.attrs.find((a) => a.name === "id");
if (idAttr?.hasValue && idAttr.value.toLowerCase() === wanted) return el;
for (const child of el.children) stack.push(child);
}
return null;
}
function normPath(path: string): string {
return path.replace(/\\/g, "/").toLowerCase();
}
+27 -2
View File
@@ -96,8 +96,11 @@ export function resolveReferenceTargetsForType(
attrName: string,
id: string,
): ReferenceTarget[] {
const defs = idx.assetsById.get(id.toLowerCase());
if (!defs?.length) return [];
const defs = mergeLocalAndGlobalDefs(
idx.local?.assetsById.get(id.toLowerCase()),
idx.assetsById.get(id.toLowerCase()),
);
if (!defs.length) return [];
const nameLower = attrName.toLowerCase();
let refType: string | null = null;
@@ -127,3 +130,25 @@ export function resolveReferenceTargetsForType(
targets.sort((a, b) => a.score - b.score || a.def.id.localeCompare(b.def.id));
return targets;
}
/**
* Merges document-local definitions with the global index, keeping local
* entries first and de-duplicating definitions that exist in both.
*/
export function mergeLocalAndGlobalDefs(
local: readonly AssetDef[] | undefined,
global: readonly AssetDef[] | undefined,
): AssetDef[] {
const seen = new Set<string>();
const out: AssetDef[] = [];
for (const list of [local, global]) {
if (!list) continue;
for (const def of list) {
const key = `${def.type}\u0000${def.id.toLowerCase()}\u0000${def.file}\u0000${def.line}`;
if (seen.has(key)) continue;
seen.add(key);
out.push(def);
}
}
return out;
}
+64 -1
View File
@@ -32,9 +32,36 @@ export interface DefineDef {
origin: AssetOrigin;
}
/**
* Document-local overlay produced by `localScope.ts`. It contains assets /
* defines reachable from the currently open document (its own text plus its
* include chain), even when that file is not part of any global stream.
*
* The overlay is attached to a `ModIndex` as `local` rather than merged into
* the global maps, so large indexes (Corona: ~65k assets) are never copied
* on every keystroke. Lookup helpers consult `local` first.
*/
export interface LocalOverlay {
/** type -> id -> definitions. */
assets: Map<string, Map<string, AssetDef[]>>;
/** id -> definitions across all types. */
assetsById: Map<string, AssetDef[]>;
/** `$NAME` -> definitions. */
defines: Map<string, DefineDef[]>;
}
export interface IndexedFile {
path: string;
stat: { mtimeMs: number; size: number } | null;
/**
* Multi-signal file stamp used to validate cached entries without
* re-reading content: size, last-write time, creation time and change
* time. Creation/change time catch tools that rewrite a file while
* preserving its mtime (e.g. temp-file + rename exporters) — important on
* removable drives where mtime resolution can be coarse (FAT32: 2 s).
*/
stat:
| { mtimeMs: number; size: number; birthtimeMs: number; ctimeMs: number }
| null;
}
export interface StreamInfo {
@@ -65,10 +92,16 @@ export interface IndexerDiagnostic {
export interface IndexStats {
projectDir: string;
sdkDir: string;
/** Last finished phase ("xml" or "art"). */
phase: "xml" | "art";
/** Whether the index is fully complete (art assets included). */
complete: boolean;
indexedFiles: number;
parsedFiles: number;
/** Art-asset documents indexed via shallow scan (no DOM tree). */
shallowScannedFiles: number;
/** Art files registered during the XML phase, scanned later in phase B. */
deferredArtFiles: number;
/** Shallow scans served from the persistent cache (unchanged files). */
shallowCacheHits: number;
/** Parsed XML files served from the persistent records cache. */
@@ -77,10 +110,16 @@ export interface IndexStats {
resolveCacheHits: number;
/** Include/xi:include resolutions performed during this build. */
resolveCalls: number;
/** Existence checks answered by the directory snapshot (no statSync). */
snapshotHits: number;
/** Existence checks outside the snapshot that fell back to statSync. */
snapshotFallbacks: number;
/** Time spent enumerating Include source candidates (ms). */
candidatesMs: number;
/** Time spent walking the include graph (ms). */
walkMs: number;
/** Time spent shallow-scanning deferred art assets (ms). */
artScanMs: number;
assetCount: number;
defineCount: number;
manifestFiles: number;
@@ -93,6 +132,19 @@ export interface IndexStats {
export interface ModIndex {
projectDir: string;
sdkDir: string;
/**
* True when every indexing phase (including the art-asset shallow scan)
* has finished. Features use this to decide whether unresolved references
* are final errors or provisional "may be a false positive" diagnostics.
*/
complete: boolean;
/** Last finished phase: "xml" (XML + manifests) or "art" (final). */
phase: "xml" | "art";
/**
* True when files changed while this snapshot was being built, so some
* entries may be stale. A follow-up rebuild is scheduled by the workspace.
*/
stale?: boolean;
/** type -> id -> definitions (project + sdk + manifest, deduplicated). */
assets: Map<string, Map<string, AssetDef[]>>;
/** id -> definitions across all types. */
@@ -108,6 +160,12 @@ export interface ModIndex {
/** Problems found while indexing (unresolved includes, cycles, ...). */
diagnostics: IndexerDiagnostic[];
stats: IndexStats;
/**
* Document-local overlay (when the index was obtained through the
* workspace's `getScope` / `getIndex` path). Optional so plain indexer
* snapshots remain overlay-free.
*/
local?: LocalOverlay;
}
export interface IndexOptions {
@@ -160,4 +218,9 @@ export interface ParsedFile {
*/
records: IndexRecords | null;
lineMap: LineMap | null;
/**
* True when the file is an art-asset XML that was only registered during
* the XML phase (its shallow scan is deferred to the art phase).
*/
deferredArt?: boolean;
}
+24
View File
@@ -0,0 +1,24 @@
import type { XmlDocument, XmlElement } from "../language/xmlParser";
/** Lowercases nothing; returns the part after the last ":" in a tag name. */
export function localName(tag: string): string {
const idx = tag.lastIndexOf(":");
return idx >= 0 ? tag.slice(idx + 1) : tag;
}
/**
* Resolves the xpointer subset used by real RA3 mods:
* xmlns(n=uri:ea.com:eala:asset) xpointer(/n:ElementName/child::*)
*
* Returns the container element whose children are selected by the xpointer,
* or null when the form is unsupported / the container is missing.
*/
export function findXPointerContainer(
doc: XmlDocument,
xpointer: string,
): XmlElement | null {
const m = /xpointer\(\/\w+:(\w+)\/child::\*\)/.exec(xpointer);
if (!m) return null;
const name = m[1];
return doc.elements.find((el) => localName(el.name) === name) ?? null;
}
+435 -23
View File
@@ -1,10 +1,35 @@
import * as vscode from "vscode";
import { existsSync } from "node:fs";
import { join, dirname } from "node:path";
import { CachedDirectoryWalker } from "./indexer/fileScanner";
import { readFile, stat } from "node:fs/promises";
import { join, dirname, resolve } from "node:path";
import {
CachedDirectoryWalker,
isContentRelevantPath,
isWatcherNoisePath,
} from "./indexer/fileScanner";
import { ModIndexer } from "./indexer/indexer";
import { DocumentCache, IncludeResolveCache, IndexRecordsCache } from "./indexer/caches";
import type { ModIndex } from "./indexer/types";
import {
DocumentCache,
IncludeResolveCache,
IndexRecordsCache,
InvalidationsEpoch,
normKey,
} from "./indexer/caches";
import {
DiskRecordsCache,
diskCacheKey,
type DiskCacheIdentity,
type DiskCacheLoadStats,
} from "./indexer/diskCache";
import { buildSearchPaths, type SearchPaths } from "./indexer/includeResolver";
import { extractIndexRecords } from "./indexer/records";
import { LineMap, parseXml, stripBom } from "./language/xmlParser";
import {
buildDocumentScope,
withLocalOverlay,
type DocumentScope,
} from "./indexer/localScope";
import type { ModIndex, ParsedFile } from "./indexer/types";
import { readSettings, type ExtensionSettings } from "./settings";
const REBUILD_DEBOUNCE_MS = 1500;
@@ -22,6 +47,39 @@ export class ModWorkspace {
private documentCache = new DocumentCache();
private recordsCache = new IndexRecordsCache();
private resolveCache = new IncludeResolveCache();
/**
* Monotonic invalidation counter. A build captures the epoch when it
* starts; snapshots published after any invalidation are marked stale so
* features can tell users "this index may be slightly out of date" while
* the follow-up rebuild converges.
*/
private epoch = new InvalidationsEpoch();
/** On-disk records cache (cold-start acceleration). */
private diskCachePath: string | null = null;
private diskCache: DiskRecordsCache | null = null;
private diskCacheStats: DiskCacheLoadStats = {
fileExists: false,
keyMatched: false,
loaded: 0,
validated: 0,
dropped: 0,
};
private diskSaved = false;
private saving: Promise<void> | null = null;
/** Document-local scopes (parse + expanded tree + overlay), per open doc. */
private localScopes = new Map<
string,
{ version: number; indexEpoch: number; scope: DocumentScope }
>();
private localScopeBuilds = new Map<string, Promise<DocumentScope>>();
private indexEpochValue = 0;
/** Diagnostics: how many builds ran and what triggered the last one. */
private buildCountValue = 0;
private lastBuildTrigger = "initial";
private pendingTrigger: string | null = null;
private output: vscode.OutputChannel;
/** Called whenever a new index snapshot is published (phase or final). */
onIndexUpdate?: () => void;
private context: vscode.ExtensionContext;
private watchers: vscode.FileSystemWatcher[] = [];
private statusBar: vscode.StatusBarItem;
@@ -32,6 +90,11 @@ export class ModWorkspace {
constructor(context: vscode.ExtensionContext) {
this.context = context;
this.settings = readSettings();
const storageUri = context.storageUri ?? context.globalStorageUri;
if (storageUri) {
this.diskCachePath = join(storageUri.fsPath, "index-records-v1.json.gz");
}
this.output = vscode.window.createOutputChannel("RA3 Mod XML");
this.statusBar = vscode.window.createStatusBarItem(
vscode.StatusBarAlignment.Left,
100,
@@ -39,12 +102,32 @@ export class ModWorkspace {
this.statusBar.name = "RA3 Mod XML";
this.statusBar.command = "ra3modxml.openIndexReport";
context.subscriptions.push(this.statusBar);
context.subscriptions.push(
vscode.workspace.onDidCloseTextDocument((document) => {
this.localScopes.delete(document.uri.toString());
}),
);
}
isRa3Workspace(): boolean {
return this.projectRoot != null;
}
/** True while a rebuild is running (before any snapshot is published). */
get isBuilding(): boolean {
return this.building;
}
/** Number of index builds performed in this session. */
get buildCount(): number {
return this.buildCountValue;
}
/** Why the last build started ("initial", "save", "watcher-*", ...). */
get lastTrigger(): string {
return this.lastBuildTrigger;
}
detectProjectRoot(): string | null {
const folders = vscode.workspace.workspaceFolders;
if (!folders?.length) return null;
@@ -64,7 +147,7 @@ export class ModWorkspace {
this.startWatching();
this.statusBar.text = "$(sync~spin) RA3 XML: indexing…";
this.statusBar.show();
await this.rebuild();
await this.rebuild(false, "initial");
}
/**
@@ -74,6 +157,7 @@ export class ModWorkspace {
*/
invalidate(path: string): void {
if (!path) return;
this.epoch.mark();
this.documentCache.invalidate(path);
this.recordsCache.invalidate(path);
}
@@ -83,6 +167,7 @@ export class ModWorkspace {
* (which encode file existence) are no longer trustworthy.
*/
invalidateExistence(): void {
this.epoch.mark();
this.resolveCache.clear();
}
@@ -107,13 +192,32 @@ export class ModWorkspace {
new vscode.RelativePattern(root, "**/*"),
);
watcher.onDidCreate((uri) => {
if (isWatcherNoisePath(uri.fsPath)) return;
this.output.appendLine(`[watcher-create] ${uri.fsPath}`);
this.invalidate(uri.fsPath);
this.invalidateExistence();
this.scheduleRebuild("watcher-create");
});
watcher.onDidChange((uri) => {
if (isWatcherNoisePath(uri.fsPath)) return;
// Content changes only matter for files that can change index
// records (XML-ish documents); textures/binary art changes do not.
if (
!isContentRelevantPath(uri.fsPath) &&
!this.isIndexedPath(uri.fsPath)
) {
return;
}
this.output.appendLine(`[watcher-change] ${uri.fsPath}`);
this.invalidate(uri.fsPath);
this.scheduleRebuild("watcher-change");
});
watcher.onDidChange((uri) => this.invalidate(uri.fsPath));
watcher.onDidDelete((uri) => {
if (isWatcherNoisePath(uri.fsPath)) return;
this.output.appendLine(`[watcher-delete] ${uri.fsPath}`);
this.invalidate(uri.fsPath);
this.invalidateExistence();
this.scheduleRebuild("watcher-delete");
});
this.watchers.push(watcher);
this.context.subscriptions.push(watcher);
@@ -124,15 +228,22 @@ export class ModWorkspace {
}
}
scheduleRebuild(): void {
/** True when the path is part of the current index (any build state). */
private isIndexedPath(fsPath: string): boolean {
if (this.indexer?.isIndexedFile(fsPath)) return true;
return this.index?.files.has(normKey(fsPath)) ?? false;
}
scheduleRebuild(reason = "unknown"): void {
if (!this.projectRoot) return;
this.pendingTrigger = reason;
if (this.rebuildTimer) clearTimeout(this.rebuildTimer);
this.rebuildTimer = setTimeout(() => {
void this.rebuild();
void this.rebuild(false, this.pendingTrigger ?? reason);
}, REBUILD_DEBOUNCE_MS);
}
async rebuild(force = false): Promise<void> {
async rebuild(force = false, trigger = "unknown"): Promise<void> {
if (!this.projectRoot) return;
if (this.building) {
this.dirty = true;
@@ -140,8 +251,18 @@ export class ModWorkspace {
}
if (force) this.resolveCache.clear();
this.building = true;
this.buildCountValue++;
this.lastBuildTrigger = trigger;
this.output.appendLine(
`[build #${this.buildCountValue}] trigger=${trigger} force=${force} start=${new Date().toISOString()}`,
);
this.settings = readSettings();
const epochAtStart = this.epoch.snapshot();
try {
// Cold start: seed the records cache from disk (stat-validated) so a
// fresh session does not re-read unchanged files (Corona: 2.6 GB of
// art assets) just because the in-memory caches are empty.
await this.seedRecordsFromDisk();
this.statusBar.text = "$(sync~spin) RA3 XML: indexing…";
const indexer = new ModIndexer({
projectDir: this.projectRoot,
@@ -157,30 +278,320 @@ export class ModWorkspace {
// verification (ra3modxml.reindex).
trustUnchanged: !force,
});
const started = Date.now();
this.index = await indexer.build();
this.indexer = indexer;
const secs = ((Date.now() - started) / 1000).toFixed(1);
const s = this.index.stats;
this.statusBar.text = `$(symbol-misc) RA3 XML: ${formatCount(s.assetCount)} assets`;
this.statusBar.tooltip =
`${s.projectDir}\n` +
`${s.indexedFiles} files indexed (${s.parsedFiles} parsed, ${s.shallowScannedFiles} art assets shallow-scanned, ${secs}s)\n` +
`${s.assetCount} assets (${s.manifestAssetCount} from ${s.manifestFiles} manifests)\n` +
`${s.defineCount} defines, ${s.streams} streams, ${s.sourceCandidates} include candidates`;
// The XML phase is published as soon as it is ready, so completion /
// navigation / diagnostics work while the art scan continues.
const finalIndex = await indexer.build((phaseIndex) => {
this.publishIndex(phaseIndex, epochAtStart);
});
this.publishIndex(finalIndex, epochAtStart);
this.output.appendLine(
`[build #${this.buildCountValue}] done in ${(finalIndex.stats.elapsedMs / 1000).toFixed(1)}s (phase=${finalIndex.phase}, assets=${finalIndex.stats.assetCount}, stale=${finalIndex.stale === true}, walk=${(finalIndex.stats.walkMs / 1000).toFixed(1)}s, candidates=${(finalIndex.stats.candidatesMs / 1000).toFixed(1)}s, art=${(finalIndex.stats.artScanMs / 1000).toFixed(1)}s)`,
);
this.saveRecordsToDisk();
} catch (err) {
this.index = null;
this.statusBar.text = "$(error) RA3 XML: indexing failed";
this.statusBar.tooltip = err instanceof Error ? err.message : String(err);
if (this.index) {
// Keep the last good snapshot (marked stale) instead of disabling the
// extension entirely; a later rebuild can recover.
this.index.stale = true;
this.statusBar.text = "$(error) RA3 XML: indexing failed (stale index kept)";
this.statusBar.tooltip = err instanceof Error ? err.message : String(err);
this.onIndexUpdate?.();
} else {
this.statusBar.text = "$(error) RA3 XML: indexing failed";
this.statusBar.tooltip = err instanceof Error ? err.message : String(err);
}
} finally {
this.building = false;
if (this.dirty) {
this.dirty = false;
void this.rebuild();
void this.rebuild(false, `dirty-followup (${this.lastBuildTrigger})`);
} else if (this.index) {
// Build is fully over: refresh diagnostics with full local scopes
// (the snapshot published while `building` was still true only got
// cheap parse-only scopes).
this.onIndexUpdate?.();
}
}
}
private diskCacheIdentity(): DiskCacheIdentity | null {
if (!this.projectRoot) return null;
return {
projectDir: this.projectRoot,
sdkDir: this.settings.sdkPath,
indexSageXml: this.settings.indexSageXml,
additionalDataSearchPaths: this.settings.additionalDataSearchPaths,
builtmodsDirs: this.settings.builtmodsDirs,
};
}
/** Loads + stat-validates the disk cache into the records cache once. */
private async seedRecordsFromDisk(): Promise<void> {
if (this.recordsCache.size > 0) return;
const identity = this.diskCacheIdentity();
if (!this.diskCachePath || !identity) return;
this.diskCache = new DiskRecordsCache(this.diskCachePath, identity);
this.statusBar.text = "$(sync~spin) RA3 XML: validating cache…";
const { records, stats } = await this.diskCache.loadValidated();
this.diskCacheStats = stats;
this.diskSaved = false;
for (const rec of records) {
this.recordsCache.set(rec.key, {
stat: rec.stat,
records: rec.records,
kind: rec.kind,
});
}
}
/** Persists the records cache after a successful build (best-effort). */
private saveRecordsToDisk(): void {
if (!this.diskCache) return;
// Snapshot the entries now: the save runs in the background while the
// next rebuild may already be mutating the live cache.
const entries = [...this.recordsCache.entries()];
const prev = this.saving ?? Promise.resolve();
this.saving = prev
.then(async () => {
await this.diskCache!.save(entries);
this.diskSaved = true;
})
.catch(() => {
// Disk persistence is best-effort; the in-memory cache still works.
});
}
/**
* Clears every cache (in-memory + disk + directory walker) and starts a
* full forced rebuild. Used by the `ra3modxml.clearCache` command.
*/
clearCaches(): void {
this.localScopes.clear();
this.documentCache.clear();
this.recordsCache.clear();
this.resolveCache.clear();
this.walker.clear();
this.diskCacheStats = {
fileExists: false,
keyMatched: false,
loaded: 0,
validated: 0,
dropped: 0,
};
this.diskSaved = false;
void this.diskCache?.clear();
void this.rebuild(true, "clear-cache");
}
/** Human-readable cache status for the `ra3modxml.showCacheReport` command. */
async cacheReport(): Promise<string> {
const lines: string[] = ["RA3 Mod XML cache report"];
lines.push(`Disk cache: ${this.diskCachePath ?? "not available"}`);
if (this.diskCachePath) {
const status = await this.diskCache?.status();
lines.push(
` file: ${status?.exists ? `${(status.sizeBytes / 1024).toFixed(1)} KB` : "missing"}`,
);
const identity = this.diskCacheIdentity();
lines.push(` identity key: ${identity ? diskCacheKey(identity) : "-"}`);
lines.push(
` last load: file=${this.diskCacheStats.fileExists} keyMatched=${this.diskCacheStats.keyMatched} loaded=${this.diskCacheStats.loaded} validated=${this.diskCacheStats.validated} dropped=${this.diskCacheStats.dropped}`,
);
lines.push(` saved after last build: ${this.diskSaved}`);
}
lines.push(
`In-memory: ${this.recordsCache.size} record entries · ${this.documentCache.size} documents (${this.documentCache.elements} elements) · ${this.resolveCache.size} include resolutions`,
);
lines.push(`Builds: #${this.buildCount} (last trigger: ${this.lastBuildTrigger})`);
if (this.index) {
const s = this.index.stats;
lines.push(
`Last build: snapshotHits=${s.snapshotHits} snapshotFallbacks=${s.snapshotFallbacks} recordsCacheHits=${s.recordsCacheHits} shallowCacheHits=${s.shallowCacheHits}`,
);
}
return lines.join("\n");
}
/**
* Publishes an index snapshot (intermediate phase or final). If any file
* was invalidated while the snapshot was being built, it is marked stale;
* the dirty/rebuild mechanism converges shortly after.
*/
private publishIndex(index: ModIndex, epochAtStart: number): void {
if (this.epoch.changedSince(epochAtStart)) index.stale = true;
this.index = index;
this.indexEpochValue++;
// The merged index attached to a document scope changes with every
// published snapshot, so cached scopes are rebuilt lazily on next use.
this.localScopes.clear();
this.updateStatusBar(index);
this.onIndexUpdate?.();
if (!index.complete) {
this.output.appendLine(
`[build #${this.buildCountValue}] phase A published in ${(index.stats.elapsedMs / 1000).toFixed(1)}s (${index.stats.assetCount} assets, ${index.stats.deferredArtFiles} art files pending)`,
);
}
}
private updateStatusBar(idx: ModIndex): void {
const s = idx.stats;
const stale = idx.stale ? " (stale)" : "";
if (!idx.complete) {
this.statusBar.text = `$(sync~spin) RA3 XML: XML indexed, scanning art…${stale}`;
} else {
this.statusBar.text = `$(symbol-misc) RA3 XML: ${formatCount(s.assetCount)} assets${stale}`;
}
this.statusBar.tooltip =
`${s.projectDir}\n` +
`${s.indexedFiles} files indexed (${s.parsedFiles} parsed, ${s.shallowScannedFiles} art assets shallow-scanned, ${(s.elapsedMs / 1000).toFixed(1)}s)\n` +
`${s.assetCount} assets (${s.manifestAssetCount} from ${s.manifestFiles} manifests)\n` +
`${s.defineCount} defines, ${s.streams} streams, ${s.sourceCandidates} include candidates\n` +
`Phase: ${s.phase} · Complete: ${s.complete}${stale}`;
}
/**
* Search paths derived from the current settings, usable even before the
* first index snapshot exists (include links / hover / diagnostics).
*/
searchPaths(): SearchPaths | null {
if (!this.projectRoot) return null;
return buildSearchPaths(this.settings.sdkPath, this.projectRoot);
}
/**
* Returns the document scope for the current text: original parse, expanded
* logical tree, local overlay and overlay-aware merged index. Cached by
* URI + document version + global index epoch.
*/
async getScope(document: vscode.TextDocument): Promise<DocumentScope> {
const key = document.uri.toString();
const cached = this.localScopes.get(key);
if (
cached &&
cached.version === document.version &&
cached.indexEpoch === this.indexEpochValue
) {
return cached.scope;
}
// While a rebuild is running, avoid competing with the indexer for disk
// I/O: serve a parse-only scope (current file + XSD context, no include
// chain / logical expansion). The published snapshot clears this cache,
// so the next provider call after the build gets the full local scope.
if (this.building) {
return this.buildCheapScope(document);
}
const pending = this.localScopeBuilds.get(key);
if (pending) return pending;
const versionAtStart = document.version;
const promise = this.buildScope(document)
.then((scope) => {
this.localScopes.set(key, {
version: versionAtStart,
indexEpoch: this.indexEpochValue,
scope,
});
return scope;
})
.finally(() => {
this.localScopeBuilds.delete(key);
});
this.localScopeBuilds.set(key, promise);
return promise;
}
/**
* Returns the global index with this document's local overlay attached, or
* a minimal local-only index while the global index is still building.
*/
async getIndex(document: vscode.TextDocument): Promise<ModIndex | null> {
if (!this.isRa3Workspace()) return null;
return (await this.getScope(document)).merged;
}
private async buildScope(
document: vscode.TextDocument,
): Promise<DocumentScope> {
const projectRoot = this.projectRoot;
if (!projectRoot) throw new Error("RA3 workspace root is not available");
const searchPaths =
this.searchPaths() ?? buildSearchPaths(this.settings.sdkPath, projectRoot);
const readRecords = async (path: string): Promise<ParsedFile | null> =>
this.indexer ? this.indexer.readDocument(path) : this.fallbackRead(path);
const readDom = async (path: string): Promise<ParsedFile | null> =>
this.indexer ? this.indexer.readDom(path) : this.fallbackRead(path);
const scope = await buildDocumentScope(
document.uri.fsPath,
document.getText(),
document.version,
{
projectDir: projectRoot,
sdkDir: this.settings.sdkPath,
searchPaths,
readRecords,
readDom,
},
);
scope.merged = withLocalOverlay(
this.index,
scope.overlay,
projectRoot,
this.settings.sdkPath,
);
return scope;
}
private async buildCheapScope(
document: vscode.TextDocument,
): Promise<DocumentScope> {
const projectRoot = this.projectRoot;
if (!projectRoot) throw new Error("RA3 workspace root is not available");
const searchPaths =
this.searchPaths() ?? buildSearchPaths(this.settings.sdkPath, projectRoot);
const scope = await buildDocumentScope(
document.uri.fsPath,
document.getText(),
document.version,
{
projectDir: projectRoot,
sdkDir: this.settings.sdkPath,
searchPaths,
readRecords: async () => null,
readDom: async () => null,
},
);
scope.merged = withLocalOverlay(
this.index,
scope.overlay,
projectRoot,
this.settings.sdkPath,
);
return scope;
}
/**
* Fallback used before the first ModIndexer exists (e.g. during initial
* activation): parses an XML file directly so the document-local scope can
* still follow small include chains.
*/
private async fallbackRead(path: string): Promise<ParsedFile | null> {
try {
const st = await stat(path);
if (st.size > 4 * 1024 * 1024) return null;
const text = stripBom(await readFile(path, "utf8"));
const lineMap = new LineMap(text);
const parse = parseXml(text);
return {
file: { path: resolve(path), stat: null },
parse,
records: extractIndexRecords(parse, lineMap),
lineMap,
};
} catch {
return null;
}
}
/** Parses the (possibly unsaved) in-memory text of the active document. */
async parseText(path: string, text: string) {
const { parseXml, LineMap } = await import("./language/xmlParser");
@@ -196,6 +607,7 @@ export class ModWorkspace {
dispose(): void {
if (this.rebuildTimer) clearTimeout(this.rebuildTimer);
this.statusBar.dispose();
this.output.dispose();
}
}