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 | null; epoch: InvalidationsEpoch; diskCache: DiskRecordsCache | null; diskCacheStats: DiskCacheLoadStats; diskSaved: boolean; saving: Promise | 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(); /** 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>(); /** 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(); /** Global serialized build queue (shared caches forbid concurrent builds). */ private buildChain: Promise = 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 { this.settings = readSettings(); this.refreshProjects(); if (!this.states.size) { this.statusBar.hide(); this.updateContext(); return; } const builds: Promise[] = []; 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(); 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 { 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 { 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(task: () => Promise): Promise { const run = this.buildChain.then(task); this.buildChain = run.then( () => undefined, () => undefined, ); return run; } private async runBuild( state: ProjectState, force: boolean, epochAtStart: number, ): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 { const searchPaths = this.searchPaths(document) ?? buildSearchPaths(this.settings.sdkPath, state.root); const readRecords = async (path: string): Promise => state.indexer ? state.indexer.readDocument(path) : this.fallbackRead(path); const readDom = async (path: string): Promise => 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 { 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 { 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(); 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); }