1209 lines
42 KiB
TypeScript
1209 lines
42 KiB
TypeScript
import * as vscode from "vscode";
|
|
import { basename, join, resolve } from "node:path";
|
|
import { existsSync } from "node:fs";
|
|
import { readFile, stat } from "node:fs/promises";
|
|
import {
|
|
CachedDirectoryWalker,
|
|
isContentRelevantPath,
|
|
isWatcherNoisePath,
|
|
} from "./indexer/fileScanner";
|
|
import { ModIndexer } from "./indexer/indexer";
|
|
import {
|
|
DocumentCache,
|
|
IncludeResolveCache,
|
|
IndexRecordsCache,
|
|
InvalidationsEpoch,
|
|
normKey,
|
|
} from "./indexer/caches";
|
|
import {
|
|
DiskRecordsCache,
|
|
diskCacheKey,
|
|
type DiskCacheRecord,
|
|
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 { RecordsSyncWorkspace } from "./indexer/referenceIndex";
|
|
import type { ModIndex, ParsedFile } from "./indexer/types";
|
|
import { readSettings, type ExtensionSettings } from "./settings";
|
|
import {
|
|
discoverProjects,
|
|
findProjectRootForFile,
|
|
findProjectRootUpward,
|
|
} from "./projectRoot";
|
|
import { t } from "./localize";
|
|
|
|
const REBUILD_DEBOUNCE_MS = 1500;
|
|
/** Log a disk-cache validation progress line every N validated records. */
|
|
const CACHE_PROGRESS_LOG_EVERY = 1000;
|
|
|
|
/** Local time `HH:mm:ss.mmm` prefix for output-channel log lines. */
|
|
function logTime(): string {
|
|
const d = new Date();
|
|
const pad = (n: number, len = 2) => String(n).padStart(len, "0");
|
|
return `[${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}]`;
|
|
}
|
|
|
|
/**
|
|
* Per-project state. Caches (document / records / include resolution / dir
|
|
* walker) are shared across projects because they are keyed by absolute
|
|
* path; builds are serialized through a global queue so the shared caches
|
|
* are never written concurrently.
|
|
*/
|
|
interface ProjectState {
|
|
/** Absolute project root (the directory carrying the marker). */
|
|
root: string;
|
|
index: ModIndex | null;
|
|
indexer: ModIndexer | null;
|
|
building: boolean;
|
|
dirty: boolean;
|
|
buildCount: number;
|
|
lastTrigger: string;
|
|
pendingTrigger: string | null;
|
|
rebuildTimer: ReturnType<typeof setTimeout> | null;
|
|
epoch: InvalidationsEpoch;
|
|
diskCache: DiskRecordsCache | null;
|
|
diskCacheStats: DiskCacheLoadStats;
|
|
diskSaved: boolean;
|
|
saving: Promise<void> | null;
|
|
}
|
|
|
|
function emptyDiskCacheStats(): DiskCacheLoadStats {
|
|
return {
|
|
fileExists: false,
|
|
keyMatched: false,
|
|
loaded: 0,
|
|
validated: 0,
|
|
dropped: 0,
|
|
loadMs: 0,
|
|
validateMs: 0,
|
|
};
|
|
}
|
|
|
|
export class ModWorkspace {
|
|
settings: ExtensionSettings;
|
|
/** Called whenever a new index snapshot is published (phase or final). */
|
|
onIndexUpdate?: () => void;
|
|
/** Called whenever a project rebuild starts (used for refresh safety nets). */
|
|
onBuildStart?: () => void;
|
|
|
|
private states = new Map<string, ProjectState>();
|
|
/** Caches owned here survive rebuilds and are shared by every project. */
|
|
private walker = new CachedDirectoryWalker();
|
|
private documentCache = new DocumentCache();
|
|
private recordsCache = new IndexRecordsCache();
|
|
private resolveCache = new IncludeResolveCache();
|
|
/** 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>>();
|
|
/** Global epoch: any published snapshot invalidates cached doc scopes. */
|
|
private indexEpochValue = 0;
|
|
/** file (normKey) -> project root (or null). Avoids per-keystroke fs walks. */
|
|
private rootForPathCache = new Map<string, string | null>();
|
|
/** Global serialized build queue (shared caches forbid concurrent builds). */
|
|
private buildChain: Promise<void> = Promise.resolve();
|
|
private output: vscode.OutputChannel;
|
|
/** Called whenever a new index snapshot is published (phase or final). */
|
|
private statusBar: vscode.StatusBarItem;
|
|
private context: vscode.ExtensionContext;
|
|
private storageDir: string | null;
|
|
private watchers: vscode.FileSystemWatcher[] = [];
|
|
|
|
constructor(context: vscode.ExtensionContext) {
|
|
this.context = context;
|
|
this.settings = readSettings();
|
|
this.storageDir = (context.storageUri ?? context.globalStorageUri)?.fsPath ?? null;
|
|
this.output = vscode.window.createOutputChannel("RA3 Mod XML");
|
|
this.statusBar = vscode.window.createStatusBarItem(
|
|
vscode.StatusBarAlignment.Left,
|
|
100,
|
|
);
|
|
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());
|
|
}),
|
|
);
|
|
}
|
|
|
|
/** True when at least one project has been discovered. */
|
|
isRa3Workspace(): boolean {
|
|
return this.states.size > 0;
|
|
}
|
|
|
|
/** Appends a line to the "RA3 Mod XML" output channel (debug/troubleshooting). */
|
|
log(message: string): void {
|
|
this.output.appendLine(`${logTime()} ${message}`);
|
|
}
|
|
|
|
/** True while any project rebuild is running (before its snapshot lands). */
|
|
get isBuilding(): boolean {
|
|
for (const st of this.states.values()) {
|
|
if (st.building) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/** Number of index builds performed for the active project in this session. */
|
|
get buildCount(): number {
|
|
return this.activeState()?.buildCount ?? 0;
|
|
}
|
|
|
|
/** Why the active project's last build started. */
|
|
get lastTrigger(): string {
|
|
return this.activeState()?.lastTrigger ?? "";
|
|
}
|
|
|
|
/** Active project root (fallback: first discovered project). */
|
|
get projectRoot(): string | null {
|
|
return this.activeState()?.root ?? this.states.values().next().value?.root ?? null;
|
|
}
|
|
|
|
/** Active project index (fallback: first discovered project). */
|
|
get index(): ModIndex | null {
|
|
return this.activeIndex();
|
|
}
|
|
|
|
/** Active project indexer (fallback: first discovered project). */
|
|
get indexer(): ModIndexer | null {
|
|
return this.activeIndexer();
|
|
}
|
|
|
|
/** All discovered project roots (normalized absolute paths). */
|
|
getProjectRoots(): string[] {
|
|
return [...this.states.values()].map((s) => s.root);
|
|
}
|
|
|
|
/** Project root for a document, or null when it does not belong to any. */
|
|
getProjectRootFor(document: vscode.TextDocument): string | null {
|
|
return this.stateForDocument(document)?.root ?? null;
|
|
}
|
|
|
|
/** Sync index lookup for a document (no lazy build side effects). */
|
|
indexForDocument(document: vscode.TextDocument): ModIndex | null {
|
|
const state = this.stateForDocument(document);
|
|
if (!state) return null;
|
|
this.ensureBuild(state, "feature-request");
|
|
return state.index;
|
|
}
|
|
|
|
/** Indexer that owns a file (fallback: active project's indexer). */
|
|
indexerForFile(file: string): ModIndexer | null {
|
|
const key = normKey(file);
|
|
for (const st of this.states.values()) {
|
|
if (st.indexer?.isIndexedFile(file) || st.index?.files.has(key)) {
|
|
return st.indexer;
|
|
}
|
|
}
|
|
return this.activeIndexer();
|
|
}
|
|
|
|
/** Index that owns a file (fallback: active project's index). */
|
|
indexForFile(file: string): ModIndex | null {
|
|
const key = normKey(file);
|
|
for (const st of this.states.values()) {
|
|
if (st.indexer?.isIndexedFile(file) || st.index?.files.has(key)) {
|
|
return st.index;
|
|
}
|
|
}
|
|
return this.activeIndex();
|
|
}
|
|
|
|
activeIndex(): ModIndex | null {
|
|
return this.activeState()?.index ?? null;
|
|
}
|
|
|
|
activeIndexer(): ModIndexer | null {
|
|
return this.activeState()?.indexer ?? null;
|
|
}
|
|
|
|
/**
|
|
* Discovers project roots from the current workspace folders and open
|
|
* documents, registers per-project state and starts the initial build(s):
|
|
* a single discovered project is indexed immediately (existing UX), while
|
|
* multiple projects are lazy — only the active editor's project starts.
|
|
*/
|
|
async initialize(): Promise<void> {
|
|
this.settings = readSettings();
|
|
this.refreshProjects();
|
|
if (!this.states.size) {
|
|
this.statusBar.hide();
|
|
this.updateContext();
|
|
return;
|
|
}
|
|
const builds: Promise<void>[] = [];
|
|
for (const st of this.states.values()) {
|
|
if (
|
|
!st.index &&
|
|
!st.building &&
|
|
(this.states.size === 1 || this.stateBelongsToActiveEditor(st))
|
|
) {
|
|
builds.push(this.rebuildFor(st, false, "initial"));
|
|
}
|
|
}
|
|
if (builds.length) {
|
|
await Promise.all(builds);
|
|
} else {
|
|
this.updateStatusBar();
|
|
}
|
|
}
|
|
|
|
/** Re-runs discovery after workspace folders changed. */
|
|
onWorkspaceFoldersChanged(): void {
|
|
this.refreshProjects();
|
|
for (const st of this.states.values()) {
|
|
this.maybeBuildInitial(st, "workspace-folders");
|
|
}
|
|
}
|
|
|
|
/** Registers the document's project (if any) and starts its lazy build. */
|
|
onDocumentOpened(document: vscode.TextDocument): void {
|
|
if (document.languageId !== "xml") return;
|
|
const root = this.resolveRootForFile(document.uri.fsPath);
|
|
if (root) {
|
|
const state = this.registerRoot(root);
|
|
if (state) this.ensureBuild(state, "doc-open");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Invalidates cached documents for a path (called by the file watcher and
|
|
* on document save), so the next rebuild re-reads it instead of trusting
|
|
* the cached copy. Marks every project's epoch: a shared file may belong
|
|
* to several projects and over-marking is only an extra stale flag.
|
|
*/
|
|
invalidate(path: string): void {
|
|
if (!path) return;
|
|
for (const st of this.states.values()) st.epoch.mark();
|
|
this.documentCache.invalidate(path);
|
|
this.recordsCache.invalidate(path);
|
|
}
|
|
|
|
/**
|
|
* Called when files are created or deleted: include-resolution results
|
|
* (which encode file existence) and cached root-for-path lookups are no
|
|
* longer trustworthy.
|
|
*/
|
|
invalidateExistence(): void {
|
|
for (const st of this.states.values()) st.epoch.mark();
|
|
this.resolveCache.clear();
|
|
this.rootForPathCache.clear();
|
|
}
|
|
|
|
/**
|
|
* Watches every project root plus the SDK / extra DATA roots for file
|
|
* changes. Create/delete events invalidate existence and schedule every
|
|
* project; content changes schedule only the projects whose index
|
|
* contains the file.
|
|
*/
|
|
private startWatching(): void {
|
|
this.disposeWatchers();
|
|
const roots = new Map<string, string>();
|
|
for (const st of this.states.values()) roots.set(normKey(st.root), st.root);
|
|
if (this.settings.sdkPath) roots.set(normKey(this.settings.sdkPath), this.settings.sdkPath);
|
|
for (const p of this.settings.additionalDataSearchPaths) {
|
|
roots.set(normKey(p), p);
|
|
}
|
|
for (const root of roots.values()) {
|
|
if (!existsSync(root)) continue;
|
|
try {
|
|
const watcher = vscode.workspace.createFileSystemWatcher(
|
|
new vscode.RelativePattern(root, "**/*"),
|
|
);
|
|
watcher.onDidCreate((uri) => {
|
|
if (isWatcherNoisePath(uri.fsPath)) return;
|
|
this.log(`[watcher-create] ${uri.fsPath}`);
|
|
this.handleWatcherEvent(uri.fsPath, "watcher-create", true);
|
|
});
|
|
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.log(`[watcher-change] ${uri.fsPath}`);
|
|
this.handleWatcherEvent(uri.fsPath, "watcher-change", false);
|
|
});
|
|
watcher.onDidDelete((uri) => {
|
|
if (isWatcherNoisePath(uri.fsPath)) return;
|
|
this.log(`[watcher-delete] ${uri.fsPath}`);
|
|
this.handleWatcherEvent(uri.fsPath, "watcher-delete", true);
|
|
});
|
|
this.watchers.push(watcher);
|
|
this.context.subscriptions.push(watcher);
|
|
} catch {
|
|
// The root may be temporarily unavailable (e.g. removable drive);
|
|
// indexing still works, just without watcher-based invalidation.
|
|
}
|
|
}
|
|
}
|
|
|
|
private disposeWatchers(): void {
|
|
for (const watcher of this.watchers) watcher.dispose();
|
|
this.watchers = [];
|
|
}
|
|
|
|
private handleWatcherEvent(
|
|
path: string,
|
|
trigger: string,
|
|
existenceChanged: boolean,
|
|
): void {
|
|
this.invalidate(path);
|
|
if (existenceChanged) {
|
|
this.invalidateExistence();
|
|
for (const st of this.states.values()) this.scheduleRebuildFor(st, trigger);
|
|
return;
|
|
}
|
|
const key = normKey(path);
|
|
for (const st of this.states.values()) {
|
|
if (st.indexer?.isIndexedFile(path) || st.index?.files.has(key)) {
|
|
this.scheduleRebuildFor(st, trigger);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** True when the path is part of any project's index (any build state). */
|
|
private isIndexedPath(fsPath: string): boolean {
|
|
const key = normKey(fsPath);
|
|
for (const st of this.states.values()) {
|
|
if (st.indexer?.isIndexedFile(fsPath)) return true;
|
|
if (st.index?.files.has(key)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
scheduleRebuild(reason = "unknown", document?: vscode.TextDocument): void {
|
|
const state = document ? this.stateForDocument(document) : this.activeState();
|
|
if (!state) return;
|
|
this.scheduleRebuildFor(state, reason);
|
|
}
|
|
|
|
/** Schedules a rebuild for every discovered project (settings changes). */
|
|
scheduleRebuildAll(reason = "unknown"): void {
|
|
for (const st of this.states.values()) this.scheduleRebuildFor(st, reason);
|
|
}
|
|
|
|
private scheduleRebuildFor(state: ProjectState, reason = "unknown"): void {
|
|
state.pendingTrigger = reason;
|
|
if (state.rebuildTimer) clearTimeout(state.rebuildTimer);
|
|
state.rebuildTimer = setTimeout(() => {
|
|
void this.rebuildFor(state, false, state.pendingTrigger ?? reason);
|
|
}, REBUILD_DEBOUNCE_MS);
|
|
}
|
|
|
|
/** Rebuilds a project through the serialized queue (active by default). */
|
|
rebuild(
|
|
force = false,
|
|
trigger = "unknown",
|
|
document?: vscode.TextDocument,
|
|
): Promise<void> {
|
|
const state = document ? this.stateForDocument(document) : this.activeState();
|
|
if (!state) return Promise.resolve();
|
|
return this.rebuildFor(state, force, trigger);
|
|
}
|
|
|
|
private rebuildFor(
|
|
state: ProjectState,
|
|
force: boolean,
|
|
trigger: string,
|
|
): Promise<void> {
|
|
if (state.building) {
|
|
state.dirty = true;
|
|
return Promise.resolve();
|
|
}
|
|
if (force) this.resolveCache.clear();
|
|
state.building = true;
|
|
state.buildCount++;
|
|
state.lastTrigger = trigger;
|
|
this.onBuildStart?.();
|
|
this.log(
|
|
`[build #${state.buildCount}] project=${state.root} trigger=${trigger} force=${force} start=${new Date().toISOString()}`,
|
|
);
|
|
this.settings = readSettings();
|
|
const epochAtStart = state.epoch.snapshot();
|
|
this.updateStatusBar();
|
|
return this.enqueue(() => this.runBuild(state, force, epochAtStart));
|
|
}
|
|
|
|
private enqueue<T>(task: () => Promise<T>): Promise<T> {
|
|
const run = this.buildChain.then(task);
|
|
this.buildChain = run.then(
|
|
() => undefined,
|
|
() => undefined,
|
|
);
|
|
return run;
|
|
}
|
|
|
|
private async runBuild(
|
|
state: ProjectState,
|
|
force: boolean,
|
|
epochAtStart: number,
|
|
): Promise<void> {
|
|
const wallStart = Date.now();
|
|
try {
|
|
// Cold start per project: seed the shared records cache from that
|
|
// project's on-disk cache so a fresh session does not re-read
|
|
// unchanged files (Corona: 2.6 GB of art assets). Stat validation is
|
|
// a prerequisite: entries are only trusted after their multi-signal
|
|
// stamp matches the current disk state, otherwise the fast build
|
|
// could publish an index built from stale records.
|
|
const pendingArt = await this.seedRecordsFromDisk(state);
|
|
this.statusBar.text = t("$(sync~spin) RA3 XML: indexing…");
|
|
this.statusBar.show();
|
|
const indexer = new ModIndexer({
|
|
projectDir: state.root,
|
|
sdkDir: this.settings.sdkPath,
|
|
builtmodsDirs: this.settings.builtmodsDirs,
|
|
indexSageXml: this.settings.indexSageXml,
|
|
additionalDataSearchPaths: this.settings.additionalDataSearchPaths,
|
|
walker: this.walker,
|
|
documentCache: this.documentCache,
|
|
recordsCache: this.recordsCache,
|
|
resolveCache: this.resolveCache,
|
|
// Trust cache entries unless the user explicitly asked for a full
|
|
// verification (ra3modxml.reindex).
|
|
trustUnchanged: !force,
|
|
});
|
|
state.indexer = indexer;
|
|
// 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(async (phaseIndex) => {
|
|
this.publishIndex(state, phaseIndex, epochAtStart);
|
|
// Phase A is published and usable; validate the remaining art
|
|
// records before phase B so it can trust the cache instead of
|
|
// re-scanning 2.6 GB of models.
|
|
if (!phaseIndex.complete && pendingArt.length) {
|
|
await this.validateAndSeedCache(state, pendingArt, t("art cache"));
|
|
}
|
|
});
|
|
this.publishIndex(state, finalIndex, epochAtStart);
|
|
this.log(
|
|
`[build #${state.buildCount}] project=${state.root} 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(state);
|
|
this.log(
|
|
`[build #${state.buildCount}] project=${state.root} wall time ${((Date.now() - wallStart) / 1000).toFixed(1)}s (cache load + validation + index build)`,
|
|
);
|
|
} catch (err) {
|
|
if (state.index) {
|
|
// Keep the last good snapshot (marked stale) instead of disabling
|
|
// the extension entirely; a later rebuild can recover.
|
|
state.index.stale = true;
|
|
this.statusBar.text = t("$(error) RA3 XML: indexing failed (stale index kept)");
|
|
this.statusBar.tooltip = err instanceof Error ? err.message : String(err);
|
|
this.onIndexUpdate?.();
|
|
} else {
|
|
this.statusBar.text = t("$(error) RA3 XML: indexing failed");
|
|
this.statusBar.tooltip = err instanceof Error ? err.message : String(err);
|
|
}
|
|
} finally {
|
|
state.building = false;
|
|
// The snapshot was published while `building` was still true, so the
|
|
// status bar still said "indexing". Refresh it now that the build is
|
|
// fully over.
|
|
this.updateStatusBar();
|
|
if (state.dirty) {
|
|
state.dirty = false;
|
|
void this.rebuildFor(state, false, `dirty-followup (${state.lastTrigger})`);
|
|
} else {
|
|
// 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 diskCacheIdentityFor(state: ProjectState): DiskCacheIdentity {
|
|
return {
|
|
projectDir: state.root,
|
|
sdkDir: this.settings.sdkPath,
|
|
indexSageXml: this.settings.indexSageXml,
|
|
additionalDataSearchPaths: this.settings.additionalDataSearchPaths,
|
|
builtmodsDirs: this.settings.builtmodsDirs,
|
|
};
|
|
}
|
|
|
|
private diskCachePathFor(identity: DiskCacheIdentity): string | null {
|
|
if (!this.storageDir) return null;
|
|
return join(this.storageDir, `index-records-v3-${diskCacheKey(identity)}.json.gz`);
|
|
}
|
|
|
|
/**
|
|
* Loads one project's disk cache. Full-XML records are stat-validated
|
|
* BEFORE the build (phase A depends on them); shallow art records are
|
|
* returned as "pending" and validated in the phase-A callback before
|
|
* phase B starts. Only validated entries are seeded, so the fast build
|
|
* never trusts unverified cache data.
|
|
*/
|
|
private async seedRecordsFromDisk(
|
|
state: ProjectState,
|
|
): Promise<DiskCacheRecord[]> {
|
|
if (state.diskCache) return [];
|
|
const identity = this.diskCacheIdentityFor(state);
|
|
const path = this.diskCachePathFor(identity);
|
|
if (!path) return [];
|
|
const diskCache = new DiskRecordsCache(path, identity);
|
|
state.diskCache = diskCache;
|
|
const { records, stats } = await diskCache.load();
|
|
state.diskCacheStats = stats;
|
|
state.diskSaved = false;
|
|
this.log(
|
|
`[disk-cache] project=${state.root} loaded ${records.length} records in ${(stats.loadMs / 1000).toFixed(1)}s (file=${stats.fileExists}, keyMatched=${stats.keyMatched})`,
|
|
);
|
|
if (!records.length || !stats.keyMatched) return [];
|
|
|
|
const full = records.filter((r) => r.kind !== "shallow");
|
|
const shallow = records.filter((r) => r.kind === "shallow");
|
|
if (!full.length) {
|
|
// No XML records to gate phase A on; validate everything up front.
|
|
await this.validateAndSeedCache(state, records, "cache");
|
|
return [];
|
|
}
|
|
await this.validateAndSeedCache(state, full, "cache");
|
|
// Pre-seed shallow records as unvalidated: phase A can register art
|
|
// files without statting 2.6 GB of models, and phase B only consumes
|
|
// them after `validateAndSeedCache` flips them to validated.
|
|
for (const rec of shallow) {
|
|
this.recordsCache.set(rec.key, {
|
|
stat: rec.stat,
|
|
records: rec.records,
|
|
kind: rec.kind,
|
|
contentHash: rec.contentHash,
|
|
validated: false,
|
|
});
|
|
}
|
|
if (shallow.length) {
|
|
this.log(
|
|
`[disk-cache] project=${state.root} pre-seeded ${shallow.length} art records (unvalidated) for deferred registration`,
|
|
);
|
|
}
|
|
return shallow;
|
|
}
|
|
|
|
/**
|
|
* Stat-validates a batch of cached records, seeds the matching ones and
|
|
* reports progress through the status bar / output channel. Multiple
|
|
* batches accumulate into `state.diskCacheStats` (e.g. XML first, then
|
|
* art before phase B).
|
|
*/
|
|
private async validateAndSeedCache(
|
|
state: ProjectState,
|
|
records: DiskCacheRecord[],
|
|
label: string,
|
|
): Promise<void> {
|
|
const total = records.length;
|
|
const start = Date.now();
|
|
let lastLogCount = 0;
|
|
const onProgress = (done: number): void => {
|
|
this.statusBar.text = t(
|
|
"$(sync~spin) RA3 XML: validating {0} {1}/{2}…",
|
|
label,
|
|
done,
|
|
total,
|
|
);
|
|
this.statusBar.show();
|
|
if (done - lastLogCount >= CACHE_PROGRESS_LOG_EVERY) {
|
|
lastLogCount = done;
|
|
this.log(
|
|
`[disk-cache] project=${state.root} validating ${label} ${done}/${total} in ${((Date.now() - start) / 1000).toFixed(1)}s`,
|
|
);
|
|
}
|
|
};
|
|
try {
|
|
const { stats: validationStats, kept, invalidKeys } =
|
|
await state.diskCache!.validate(records, onProgress);
|
|
state.diskCacheStats = {
|
|
...state.diskCacheStats,
|
|
validated: state.diskCacheStats.validated + validationStats.validated,
|
|
dropped: state.diskCacheStats.dropped + validationStats.dropped,
|
|
validateMs: state.diskCacheStats.validateMs + validationStats.validateMs,
|
|
};
|
|
for (const rec of kept) {
|
|
this.recordsCache.set(rec.key, {
|
|
stat: rec.stat,
|
|
records: rec.records,
|
|
kind: rec.kind,
|
|
contentHash: rec.contentHash,
|
|
validated: true,
|
|
});
|
|
}
|
|
for (const key of invalidKeys) {
|
|
this.recordsCache.invalidate(key);
|
|
}
|
|
this.log(
|
|
`[disk-cache] project=${state.root} validated ${label} ${validationStats.validated}/${total} records in ${(validationStats.validateMs / 1000).toFixed(1)}s (dropped=${validationStats.dropped})`,
|
|
);
|
|
if (invalidKeys.length) {
|
|
this.log(
|
|
`[disk-cache] project=${state.root} ${invalidKeys.length} ${label} entries out of date; they will be re-read during the build`,
|
|
);
|
|
}
|
|
} catch (err) {
|
|
this.log(
|
|
`[disk-cache] project=${state.root} ${label} validation failed: ${err instanceof Error ? err.message : String(err)}; affected entries will be re-read`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/** Persists a project's records after a successful build (best-effort). */
|
|
private saveRecordsToDisk(state: ProjectState): void {
|
|
if (!state.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 = state.saving ?? Promise.resolve();
|
|
const start = Date.now();
|
|
state.saving = prev
|
|
.then(async () => {
|
|
await state.diskCache!.save(entries);
|
|
state.diskSaved = true;
|
|
this.log(
|
|
`[disk-cache] project=${state.root} saved ${entries.length} records in ${(Date.now() - start) / 1000}s`,
|
|
);
|
|
})
|
|
.catch(() => {
|
|
// Disk persistence is best-effort; the in-memory cache still works.
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Clears every cache (in-memory + disk + directory walker) and starts
|
|
* forced rebuilds for every project (serialized by the build queue).
|
|
*/
|
|
clearCaches(): void {
|
|
this.localScopes.clear();
|
|
this.rootForPathCache.clear();
|
|
this.documentCache.clear();
|
|
this.recordsCache.clear();
|
|
this.resolveCache.clear();
|
|
this.walker.clear();
|
|
for (const st of this.states.values()) {
|
|
st.diskCacheStats = emptyDiskCacheStats();
|
|
st.diskSaved = false;
|
|
void st.diskCache?.clear();
|
|
void this.rebuildFor(st, true, "clear-cache");
|
|
}
|
|
}
|
|
|
|
/** Human-readable cache status for the `ra3modxml.showCacheReport` command. */
|
|
async cacheReport(): Promise<string> {
|
|
const lines: string[] = [t("RA3 Mod XML cache report")];
|
|
lines.push(t("Projects: {0}", this.states.size));
|
|
for (const st of this.states.values()) {
|
|
lines.push(t("Project: {0}", st.root));
|
|
lines.push(
|
|
t(" builds: #{0} (last trigger: {1})", st.buildCount, st.lastTrigger),
|
|
);
|
|
lines.push(t(" disk cache: {0}", st.diskCache?.path ?? t("not loaded")));
|
|
if (st.diskCache) {
|
|
const status = await st.diskCache.status();
|
|
lines.push(
|
|
t(
|
|
" file: {0}",
|
|
status?.exists
|
|
? t("{0} KB", (status.sizeBytes / 1024).toFixed(1))
|
|
: t("missing"),
|
|
),
|
|
);
|
|
lines.push(
|
|
t(
|
|
" last load: file={0} keyMatched={1} loaded={2} validated={3} dropped={4} (load {5}ms, validate {6}ms)",
|
|
st.diskCacheStats.fileExists,
|
|
st.diskCacheStats.keyMatched,
|
|
st.diskCacheStats.loaded,
|
|
st.diskCacheStats.validated,
|
|
st.diskCacheStats.dropped,
|
|
st.diskCacheStats.loadMs,
|
|
st.diskCacheStats.validateMs,
|
|
),
|
|
);
|
|
lines.push(
|
|
t(
|
|
" saved after last build: {0}",
|
|
st.diskSaved ? t("yes") : t("no"),
|
|
),
|
|
);
|
|
}
|
|
if (st.index) {
|
|
const s = st.index.stats;
|
|
lines.push(
|
|
t(
|
|
" last build: phase={0} assets={1} snapshotHits={2} snapshotFallbacks={3} recordsCacheHits={4} shallowCacheHits={5}",
|
|
s.phase,
|
|
s.assetCount,
|
|
s.snapshotHits,
|
|
s.snapshotFallbacks,
|
|
s.recordsCacheHits,
|
|
s.shallowCacheHits,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
lines.push(
|
|
t(
|
|
"Shared in-memory: {0} record entries · {1} documents ({2} elements) · {3} include resolutions",
|
|
this.recordsCache.size,
|
|
this.documentCache.size,
|
|
this.documentCache.elements,
|
|
this.resolveCache.size,
|
|
),
|
|
);
|
|
return lines.join("\n");
|
|
}
|
|
|
|
/**
|
|
* Publishes an index snapshot (intermediate phase or final) for one
|
|
* project. If files changed while the snapshot was built, it is marked
|
|
* stale; the dirty/rebuild mechanism converges shortly after.
|
|
*/
|
|
private publishIndex(
|
|
state: ProjectState,
|
|
index: ModIndex,
|
|
epochAtStart: number,
|
|
): void {
|
|
if (state.epoch.changedSince(epochAtStart)) index.stale = true;
|
|
state.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();
|
|
this.onIndexUpdate?.();
|
|
if (!index.complete) {
|
|
this.log(
|
|
`[build #${state.buildCount}] project=${state.root} phase A published in ${(index.stats.elapsedMs / 1000).toFixed(1)}s (${index.stats.assetCount} assets, ${index.stats.deferredArtFiles} art files pending)`,
|
|
);
|
|
}
|
|
}
|
|
|
|
private updateStatusBar(): void {
|
|
if (!this.states.size) {
|
|
this.statusBar.hide();
|
|
return;
|
|
}
|
|
const building = [...this.states.values()].find((s) => s.building);
|
|
if (building) {
|
|
this.statusBar.text = t("$(sync~spin) RA3 XML: indexing…");
|
|
this.statusBar.show();
|
|
return;
|
|
}
|
|
const st = this.activeState();
|
|
const idx = st?.index;
|
|
if (!idx || !st) {
|
|
this.statusBar.text = t(
|
|
"$(symbol-misc) RA3 XML: {0} project(s) — open a mod XML to index",
|
|
this.states.size,
|
|
);
|
|
this.statusBar.tooltip = [...this.states.values()]
|
|
.map((s) => s.root)
|
|
.join("\n");
|
|
this.statusBar.show();
|
|
return;
|
|
}
|
|
const s = idx.stats;
|
|
const stale = idx.stale ? ` ${t("(stale)")}` : "";
|
|
this.statusBar.text = t(
|
|
"$(symbol-misc) RA3 XML: {0} · {1} assets{2}",
|
|
basename(st.root),
|
|
formatCount(s.assetCount),
|
|
stale,
|
|
);
|
|
this.statusBar.tooltip = [
|
|
st.root,
|
|
t(
|
|
"{0} files indexed ({1} parsed, {2} art assets shallow-scanned, {3}s)",
|
|
s.indexedFiles,
|
|
s.parsedFiles,
|
|
s.shallowScannedFiles,
|
|
(s.elapsedMs / 1000).toFixed(1),
|
|
),
|
|
t(
|
|
"{0} assets ({1} from {2} manifests)",
|
|
s.assetCount,
|
|
s.manifestAssetCount,
|
|
s.manifestFiles,
|
|
),
|
|
t("{0} reference sites", s.referenceCount),
|
|
t(
|
|
"{0} defines, {1} streams, {2} include candidates",
|
|
s.defineCount,
|
|
s.streams,
|
|
s.sourceCandidates,
|
|
),
|
|
t("Phase: {0} · Complete: {1}{2}", s.phase, s.complete, stale),
|
|
t(
|
|
"Disk cache: load {0}s, validate {1}s ({2}/{3} ok)",
|
|
(st.diskCacheStats.loadMs / 1000).toFixed(1),
|
|
(st.diskCacheStats.validateMs / 1000).toFixed(1),
|
|
st.diskCacheStats.validated,
|
|
st.diskCacheStats.loaded,
|
|
),
|
|
].join("\n");
|
|
this.statusBar.show();
|
|
}
|
|
|
|
/**
|
|
* Search paths derived from the current settings for a document's project
|
|
* (active project when no document is given), usable even before the
|
|
* first index snapshot exists (include links / hover / diagnostics).
|
|
*/
|
|
searchPaths(document?: vscode.TextDocument): SearchPaths | null {
|
|
const state = document ? this.stateForDocument(document) : this.activeState();
|
|
if (!state) return null;
|
|
return buildSearchPaths(this.settings.sdkPath, state.root);
|
|
}
|
|
|
|
/**
|
|
* 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. Lazy projects
|
|
* are registered and their build started on first request.
|
|
*/
|
|
async getScope(document: vscode.TextDocument): Promise<DocumentScope> {
|
|
const state = this.stateForDocument(document);
|
|
if (!state) throw new Error("RA3 workspace root is not available");
|
|
this.ensureBuild(state, "doc-request");
|
|
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 this project's 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 (state.building) {
|
|
return this.buildCheapScope(document, state);
|
|
}
|
|
const pending = this.localScopeBuilds.get(key);
|
|
if (pending) return pending;
|
|
const versionAtStart = document.version;
|
|
const promise = this.buildScope(document, state)
|
|
.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;
|
|
}
|
|
|
|
/**
|
|
* Lightweight scope for CodeLens: parses only the current document and
|
|
* attaches the current global index. Unlike `getScope` it never follows
|
|
* the include chain or expands the logical tree, so refreshing counts
|
|
* after a snapshot is fast. CodeLens only renders top-level assets of the
|
|
* current document, which the cheap overlay already covers.
|
|
*/
|
|
async getCodeLensScope(document: vscode.TextDocument): Promise<DocumentScope> {
|
|
const state = this.stateForDocument(document);
|
|
if (!state) throw new Error("RA3 workspace root is not available");
|
|
this.ensureBuild(state, "doc-request");
|
|
return this.buildCheapScope(document, state);
|
|
}
|
|
|
|
private async buildScope(
|
|
document: vscode.TextDocument,
|
|
state: ProjectState,
|
|
): Promise<DocumentScope> {
|
|
const searchPaths =
|
|
this.searchPaths(document) ?? buildSearchPaths(this.settings.sdkPath, state.root);
|
|
const readRecords = async (path: string): Promise<ParsedFile | null> =>
|
|
state.indexer ? state.indexer.readDocument(path) : this.fallbackRead(path);
|
|
const readDom = async (path: string): Promise<ParsedFile | null> =>
|
|
state.indexer ? state.indexer.readDom(path) : this.fallbackRead(path);
|
|
const scope = await buildDocumentScope(
|
|
document.uri.fsPath,
|
|
document.getText(),
|
|
document.version,
|
|
{
|
|
projectDir: state.root,
|
|
sdkDir: this.settings.sdkPath,
|
|
searchPaths,
|
|
readRecords,
|
|
readDom,
|
|
},
|
|
);
|
|
scope.merged = withLocalOverlay(
|
|
state.index,
|
|
scope.overlay,
|
|
state.root,
|
|
this.settings.sdkPath,
|
|
);
|
|
return scope;
|
|
}
|
|
|
|
private async buildCheapScope(
|
|
document: vscode.TextDocument,
|
|
state: ProjectState,
|
|
): Promise<DocumentScope> {
|
|
const searchPaths =
|
|
this.searchPaths(document) ?? buildSearchPaths(this.settings.sdkPath, state.root);
|
|
const scope = await buildDocumentScope(
|
|
document.uri.fsPath,
|
|
document.getText(),
|
|
document.version,
|
|
{
|
|
projectDir: state.root,
|
|
sdkDir: this.settings.sdkPath,
|
|
searchPaths,
|
|
readRecords: async () => null,
|
|
readDom: async () => null,
|
|
},
|
|
);
|
|
scope.merged = withLocalOverlay(
|
|
state.index,
|
|
scope.overlay,
|
|
state.root,
|
|
this.settings.sdkPath,
|
|
);
|
|
return scope;
|
|
}
|
|
|
|
/**
|
|
* Fallback used before a project's first ModIndexer exists: 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, text),
|
|
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");
|
|
const parse = parseXml(text);
|
|
return {
|
|
file: { path, stat: null },
|
|
parse,
|
|
records: null,
|
|
lineMap: new LineMap(text),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Minimal workspace surface for the records-desync self-heal, routed to
|
|
* the document's own project (not the active one).
|
|
*/
|
|
recordsSyncSurfaceFor(document: vscode.TextDocument): RecordsSyncWorkspace {
|
|
const state = this.stateForDocument(document);
|
|
return {
|
|
get index() {
|
|
return state?.index ?? null;
|
|
},
|
|
invalidate: (path: string) => this.invalidate(path),
|
|
scheduleRebuild: (reason: string) => {
|
|
if (state) this.scheduleRebuildFor(state, reason);
|
|
},
|
|
};
|
|
}
|
|
|
|
// ── Discovery / project state ─────────────────────────────────────
|
|
|
|
/**
|
|
* Recomputes the desired project set from workspace folders + open XML
|
|
* documents, drops states that are no longer reachable, registers new
|
|
* ones and refreshes watchers / context / status bar.
|
|
*/
|
|
private refreshProjects(): void {
|
|
const desired = new Map<string, string>();
|
|
const addRoot = (root: string | null | undefined): void => {
|
|
if (root) desired.set(normKey(root), root);
|
|
};
|
|
for (const folder of vscode.workspace.workspaceFolders ?? []) {
|
|
addRoot(findProjectRootUpward(folder.uri.fsPath));
|
|
for (const root of discoverProjects(folder.uri.fsPath)) addRoot(root);
|
|
}
|
|
for (const doc of vscode.workspace.textDocuments ?? []) {
|
|
if (doc.languageId === "xml") addRoot(findProjectRootForFile(doc.uri.fsPath));
|
|
}
|
|
const active = vscode.window.activeTextEditor;
|
|
if (active?.document.languageId === "xml") {
|
|
addRoot(findProjectRootForFile(active.document.uri.fsPath));
|
|
}
|
|
for (const [key, st] of [...this.states]) {
|
|
if (!desired.has(key)) this.removeState(st);
|
|
}
|
|
for (const root of desired.values()) this.registerRoot(root);
|
|
this.rootForPathCache.clear();
|
|
this.startWatching();
|
|
this.updateContext();
|
|
this.updateStatusBar();
|
|
}
|
|
|
|
private maybeBuildInitial(state: ProjectState, trigger: string): void {
|
|
if (state.index || state.building) return;
|
|
if (this.states.size === 1 || this.stateBelongsToActiveEditor(state)) {
|
|
void this.rebuildFor(state, false, trigger);
|
|
}
|
|
}
|
|
|
|
private removeState(state: ProjectState): void {
|
|
if (state.rebuildTimer) clearTimeout(state.rebuildTimer);
|
|
this.states.delete(normKey(state.root));
|
|
}
|
|
|
|
private registerRoot(root: string): ProjectState | null {
|
|
const key = normKey(root);
|
|
const existing = this.states.get(key);
|
|
if (existing) return existing;
|
|
const state: ProjectState = {
|
|
root: resolve(root),
|
|
index: null,
|
|
indexer: null,
|
|
building: false,
|
|
dirty: false,
|
|
buildCount: 0,
|
|
lastTrigger: "registered",
|
|
pendingTrigger: null,
|
|
rebuildTimer: null,
|
|
epoch: new InvalidationsEpoch(),
|
|
diskCache: null,
|
|
diskCacheStats: emptyDiskCacheStats(),
|
|
diskSaved: false,
|
|
saving: null,
|
|
};
|
|
this.states.set(key, state);
|
|
this.startWatching();
|
|
this.updateContext();
|
|
this.updateStatusBar();
|
|
return state;
|
|
}
|
|
|
|
private ensureBuild(state: ProjectState, trigger: string): void {
|
|
if (state.index || state.building) return;
|
|
void this.rebuildFor(state, false, trigger);
|
|
}
|
|
|
|
private stateBelongsToActiveEditor(state: ProjectState): boolean {
|
|
const editor = vscode.window.activeTextEditor;
|
|
return editor ? isPathInside(editor.document.uri.fsPath, state.root) : false;
|
|
}
|
|
|
|
/**
|
|
* The project state for a document: its own nearest root when it belongs
|
|
* to a project (registering it on demand), otherwise the first discovered
|
|
* project (best-effort fallback, matching the old single-project behavior).
|
|
*/
|
|
private stateForDocument(document: vscode.TextDocument): ProjectState | null {
|
|
const file = document.uri.fsPath;
|
|
if (file && (document.uri.scheme == null || document.uri.scheme === "file")) {
|
|
const root = this.resolveRootForFile(file);
|
|
if (root) {
|
|
const state = this.registerRoot(root);
|
|
if (state) return state;
|
|
}
|
|
}
|
|
return this.states.values().next().value ?? null;
|
|
}
|
|
|
|
/** Active editor's project, falling back to the first discovered one. */
|
|
private activeState(): ProjectState | null {
|
|
const editor = vscode.window.activeTextEditor;
|
|
if (editor) {
|
|
const state = this.stateForDocument(editor.document);
|
|
if (state) return state;
|
|
}
|
|
return this.states.values().next().value ?? null;
|
|
}
|
|
|
|
/**
|
|
* Resolves a file's project root: cached containment in registered roots
|
|
* first (no fs), then upward discovery. The cache is cleared when files
|
|
* are created/deleted or workspace folders change.
|
|
*/
|
|
private resolveRootForFile(file: string): string | null {
|
|
if (!file) return null;
|
|
const key = normKey(file);
|
|
const cached = this.rootForPathCache.get(key);
|
|
if (cached !== undefined) return cached;
|
|
for (const st of this.states.values()) {
|
|
if (isPathInside(file, st.root)) {
|
|
this.rootForPathCache.set(key, st.root);
|
|
return st.root;
|
|
}
|
|
}
|
|
const root = findProjectRootForFile(file);
|
|
this.rootForPathCache.set(key, root);
|
|
return root;
|
|
}
|
|
|
|
private updateContext(): void {
|
|
void vscode.commands.executeCommand(
|
|
"setContext",
|
|
"ra3modxml.active",
|
|
this.states.size > 0,
|
|
);
|
|
}
|
|
|
|
dispose(): void {
|
|
for (const st of this.states.values()) {
|
|
if (st.rebuildTimer) clearTimeout(st.rebuildTimer);
|
|
}
|
|
this.disposeWatchers();
|
|
this.statusBar.dispose();
|
|
this.output.dispose();
|
|
}
|
|
}
|
|
|
|
function isPathInside(file: string, root: string): boolean {
|
|
const f = normKey(file);
|
|
const r = normKey(root);
|
|
return f === r || f.startsWith(r + "\\");
|
|
}
|
|
|
|
function formatCount(n: number): string {
|
|
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
|
|
}
|