This commit is contained in:
2026-08-11 02:20:06 +02:00
parent 36eaaafa01
commit dbc2c99d8d
45 changed files with 3904 additions and 661 deletions
+64 -10
View File
@@ -1,5 +1,6 @@
import * as vscode from "vscode";
import { ModWorkspace } from "./workspace";
import { SdkSetup } from "./sdkSetup";
import { Ra3CompletionProvider } from "./features/completion";
import { Ra3HoverProvider } from "./features/hover";
import {
@@ -21,9 +22,12 @@ import {
} from "./features/semanticTokens";
const XML_SELECTOR: vscode.DocumentSelector = [{ language: "xml" }];
/** Safety-net refresh interval while a rebuild is running. */
const CODELENS_RETRY_INTERVAL_MS = 2000;
export function activate(context: vscode.ExtensionContext): void {
const ws = new ModWorkspace(context);
const sdkSetup = new SdkSetup(context, () => ws);
context.subscriptions.push(ws);
context.subscriptions.push(
@@ -66,12 +70,36 @@ export function activate(context: vscode.ExtensionContext): void {
new Ra3DocumentSymbolProvider(ws),
),
);
const codeLensProvider = new Ra3CodeLensProvider(ws);
context.subscriptions.push(
vscode.languages.registerCodeLensProvider(
XML_SELECTOR,
new Ra3CodeLensProvider(ws),
),
vscode.languages.registerCodeLensProvider(XML_SELECTOR, codeLensProvider),
);
// Safety net: while a rebuild is running, re-fire the CodeLens refresh
// every 2s. VS Code sometimes coalesces/skips a single refresh event, so
// the phase-A snapshot may not repaint until the final one; periodic
// refreshes (bounded by the build duration) make the early counts appear.
let codeLensRetryTimer: ReturnType<typeof setInterval> | null = null;
const startCodeLensRetry = (): void => {
if (codeLensRetryTimer) return;
codeLensRetryTimer = setInterval(() => {
if (!ws.isBuilding) {
if (codeLensRetryTimer) {
clearInterval(codeLensRetryTimer);
codeLensRetryTimer = null;
ws.log("[codelens] retry stopped (build finished)");
}
return;
}
codeLensProvider.refresh();
}, CODELENS_RETRY_INTERVAL_MS);
ws.log("[codelens] retry started");
};
ws.onBuildStart = startCodeLensRetry;
context.subscriptions.push({
dispose: () => {
if (codeLensRetryTimer) clearInterval(codeLensRetryTimer);
},
});
context.subscriptions.push(
vscode.languages.registerDocumentSemanticTokensProvider(
XML_SELECTOR,
@@ -85,7 +113,15 @@ export function activate(context: vscode.ExtensionContext): void {
// Refresh diagnostics for every open XML document whenever a new index
// snapshot is published (XML phase, art phase, stale/final rebuild).
ws.onIndexUpdate = () => {
codeLensProvider.resetSuppressionLog();
codeLensProvider.refresh();
void vscode.commands.executeCommand("editor.action.codeLens.refresh");
const idx = ws.activeIndex();
if (idx) {
ws.log(
`[codelens] refresh (project=${idx.stats.projectDir}, phase=${idx.phase}, assets=${idx.stats.assetCount}, complete=${idx.complete}, stale=${idx.stale === true})`,
);
}
for (const doc of vscode.workspace.textDocuments) {
if (doc.languageId === "xml") void diagnostics.update(doc);
}
@@ -113,7 +149,17 @@ export function activate(context: vscode.ExtensionContext): void {
);
context.subscriptions.push(
vscode.workspace.onDidOpenTextDocument((doc) => {
if (doc.languageId === "xml") void diagnostics.update(doc);
if (doc.languageId === "xml") {
ws.onDocumentOpened(doc);
void sdkSetup.evaluate(ws);
void diagnostics.update(doc);
}
}),
);
context.subscriptions.push(
vscode.workspace.onDidChangeWorkspaceFolders(() => {
ws.onWorkspaceFoldersChanged();
void sdkSetup.evaluate(ws);
}),
);
context.subscriptions.push(
@@ -131,7 +177,7 @@ export function activate(context: vscode.ExtensionContext): void {
vscode.workspace.onDidSaveTextDocument((doc) => {
if (doc.languageId !== "xml") return;
ws.invalidate(doc.uri.fsPath);
ws.scheduleRebuild("save");
ws.scheduleRebuild("save", doc);
void diagnostics.update(doc);
}),
);
@@ -141,7 +187,8 @@ 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("config");
ws.scheduleRebuildAll("config");
void sdkSetup.evaluate(ws);
}
}),
);
@@ -169,7 +216,7 @@ export function activate(context: vscode.ExtensionContext): void {
);
context.subscriptions.push(
vscode.commands.registerCommand("ra3modxml.openIndexReport", () => {
const idx = ws.index;
const idx = ws.activeIndex();
if (!idx) {
if (ws.isBuilding) {
void vscode.window.showInformationMessage(
@@ -178,8 +225,14 @@ export function activate(context: vscode.ExtensionContext): void {
);
return;
}
if (ws.getProjectRoots().length) {
void vscode.window.showInformationMessage(
"RA3 Mod XML: no index for the active project yet — open a mod XML document to start indexing.",
);
return;
}
void vscode.window.showInformationMessage(
"RA3 Mod XML: no index available. Open a workspace that contains Data/Mod.xml.",
"RA3 Mod XML: no index available. Open a workspace that contains Data/Mod.xml, Data/additionalmaps/mapmetadata_*.xml or a mod folder.",
);
return;
}
@@ -220,7 +273,8 @@ export function activate(context: vscode.ExtensionContext): void {
),
);
void ws.initialize();
void sdkSetup.evaluate(ws);
void ws.initialize().then(() => void sdkSetup.evaluate(ws));
}
export function deactivate(): void {
+73 -14
View File
@@ -2,11 +2,13 @@ import * as vscode from "vscode";
import { LineMap, parseXml } from "../language/xmlParser";
import { resolveElementType } from "../language/typeContext";
import { isReferenceTargetType } from "../indexer/refs";
import { scheduleRebuildIfRecordsDesync } from "../indexer/referenceIndex";
import type { ModIndex } from "../indexer/types";
import {
referenceSitesForDefinition,
scheduleRebuildIfRecordsDesync,
} from "../indexer/referenceIndex";
import type { ShowReferencesArgs } from "./references";
collectReferenceSites,
definitionsForReference,
type ShowReferencesArgs,
} from "./references";
import type { ModWorkspace } from "../workspace";
/** Never build a DOM for huge files just to show counts (w3x safety). */
@@ -21,18 +23,65 @@ const MAX_CODELENS_TEXT = 4 * 1024 * 1024;
* signal users can click to inspect an unused asset.
*/
export class Ra3CodeLensProvider implements vscode.CodeLensProvider {
private changeEmitter = new vscode.EventEmitter<void>();
readonly onDidChangeCodeLenses = this.changeEmitter.event;
/** URIs for which "no global snapshot yet" has already been logged. */
private suppressedLogged = new Set<string>();
constructor(private ws: ModWorkspace) {}
provideCodeLenses(
/** Tells VS Code to re-query lenses (used after index snapshots). */
refresh(): void {
this.changeEmitter.fire();
}
/** Called when a new snapshot is published; allows re-logging suppression. */
resetSuppressionLog(): void {
this.suppressedLogged.clear();
}
async provideCodeLenses(
document: vscode.TextDocument,
_token: vscode.CancellationToken,
): vscode.CodeLens[] {
): Promise<vscode.CodeLens[]> {
if (!this.ws.isRa3Workspace()) return [];
const idx = this.ws.index;
const startedAt = Date.now();
const uri = document.uri.toString();
let idx: ModIndex | null = null;
try {
idx = (await this.ws.getCodeLensScope(document)).merged;
} catch (err) {
this.ws.log(
`[codelens] scope error for ${uri}: ${err instanceof Error ? err.message : String(err)}`,
);
return [];
}
if (!idx) return [];
// Before the first global snapshot exists the merged index is a
// local-only index (stats.indexedFiles === 0) with no real references.
// Rendering "0 references" then would be misleading, so wait until a
// snapshot is published. Once a snapshot exists, "0" is meaningful and
// must still be displayed for reference-target types.
if (!idx.complete && idx.stats.indexedFiles === 0) {
if (!this.suppressedLogged.has(uri)) {
this.suppressedLogged.add(uri);
this.ws.log(
`[codelens] suppressed for ${uri} (no global snapshot yet)`,
);
}
return [];
}
const text = document.getText();
if (text.length > MAX_CODELENS_TEXT) return [];
scheduleRebuildIfRecordsDesync(this.ws, document);
if (text.length > MAX_CODELENS_TEXT) {
this.ws.log(
`[codelens] skipped for ${uri} (${text.length} bytes > ${MAX_CODELENS_TEXT})`,
);
return [];
}
scheduleRebuildIfRecordsDesync(
this.ws.recordsSyncSurfaceFor(document),
document,
);
const doc = parseXml(text);
const root = doc.root;
if (!root) return [];
@@ -49,12 +98,16 @@ export class Ra3CodeLensProvider implements vscode.CodeLensProvider {
const id = idAttr.value;
const line = lineMap.positionAt(idAttr.valueStart).line + 1;
const count = referenceSitesForDefinition(idx, {
type: local,
// Same definition union as Find All References: document-local
// overlay + every same-id definition in the global index. This keeps
// the lens count and the references peek consistent even when the
// file itself is not part of the global include graph.
const defs = definitionsForReference(idx, {
id,
file: document.uri.fsPath,
line,
}).length;
refType: null,
selfType: null,
});
const count = collectReferenceSites(idx, defs).length;
const range = new vscode.Range(
document.positionAt(child.start),
document.positionAt(child.startTagEnd),
@@ -80,6 +133,12 @@ export class Ra3CodeLensProvider implements vscode.CodeLensProvider {
}),
);
}
const elapsed = Date.now() - startedAt;
if (elapsed > 250) {
this.ws.log(
`[codelens] slow provider for ${uri}: ${lenses.length} lenses in ${elapsed}ms`,
);
}
return lenses;
}
}
+81 -35
View File
@@ -454,20 +454,41 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
make: (label: string, kind: vscode.CompletionItemKind, detail: string, doc?: string) => vscode.CompletionItem,
): vscode.CompletionItem[] | vscode.CompletionList<vscode.CompletionItem> {
const lower = prefix.toLowerCase();
const scored: { def: AssetDef; score: number }[] = [];
// Deduplicate by id: the same asset can be defined in several places at
// once (current file's local overlay + global index, project XML +
// compiled manifest, or an override). Showing one completion entry per
// id is enough; the other definitions are listed in the documentation.
// Definitions are still de-duplicated by (type, id, file, line) so the
// same record found through both local and global maps is not repeated
// inside a single entry either.
const seen = new Set<string>();
const byId = new Map<
string,
{ best: { def: AssetDef; score: number }; extras: AssetDef[] }
>();
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;
const defKey = `${def.type}:${def.id.toLowerCase()}:${def.file}:${def.line}`;
if (seen.has(defKey)) return;
seen.add(defKey);
const idKey = def.id.toLowerCase();
if (!idKey.startsWith(lower)) return;
let score = 3;
if (refType && model.isAssignableTo(def.type, refType)) score = 1;
if (selfType && model.isAssignableTo(def.type, selfType)) score = 0;
if (def.origin === "project") score -= 0.2;
if (def.stream === "local") score -= 0.4;
scored.push({ def, score });
const entry = byId.get(idKey);
if (!entry) {
byId.set(idKey, { best: { def, score }, extras: [] });
return;
}
if (score < entry.best.score) {
entry.extras.push(entry.best.def);
entry.best = { def, score };
} else {
entry.extras.push(def);
}
};
const targetType = selfType ?? refType;
@@ -492,17 +513,28 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
}
}
const top = topScoredDefs(scored, MAX_VALUE_ITEMS);
const entries = [...byId.values()];
const top = topScoredDefs(
entries.map((e) => e.best),
MAX_VALUE_ITEMS,
);
const items = top.map(({ def }) => {
const origin = def.origin === "manifest" ? `manifest (${def.manifestSource ?? ""})` : def.origin;
const originLabel = (d: AssetDef) =>
d.origin === "manifest" ? `manifest (${d.manifestSource ?? ""})` : d.origin;
const origin = originLabel(def);
const doc = new vscode.MarkdownString();
doc.appendCodeblock(def.id);
doc.appendMarkdown(`**Type**: ${def.type} \n`);
if (def.manifestSource) doc.appendMarkdown(`**Source**: ${def.manifestSource} \n`);
doc.appendMarkdown(`**Origin**: ${origin}`);
for (const extra of byId.get(def.id.toLowerCase())?.extras ?? []) {
doc.appendMarkdown(
`\n\nAlso defined as **${extra.type}** · ${originLabel(extra)}`,
);
}
return make(def.id, vscode.CompletionItemKind.Value, `${def.type} · ${origin}`, doc.value);
});
return this.limitItems(items, scored.length);
return this.limitItems(items, byId.size);
}
private defineItems(
@@ -512,13 +544,16 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
): vscode.CompletionItem[] | vscode.CompletionList<vscode.CompletionItem> {
const lower = prefix.replace(/^[=$]*/, "").toLowerCase();
const items: vscode.CompletionItem[] = [];
// The same define can be visible through both the local overlay and the
// global index; show one entry per name (local definitions win because
// they are iterated first).
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}`;
const dedupe = def.name.toLowerCase();
if (seen.has(dedupe)) continue;
seen.add(dedupe);
const label = `$${def.name}`;
@@ -816,9 +851,17 @@ function attributeInsertLayout(
const wordStart = findAttributeWordStart(text, offset, el.start);
const attrs = el.attrs;
const complete = attrs.filter((a) => a.hasValue);
const last = complete.length ? complete[complete.length - 1] : null;
// Only attributes that end before the cursor decide whether the completed
// attribute is already on its own line. The tag's last complete attribute
// may still be AFTER the cursor when the user inserts a new attribute in
// the middle of a one-per-line tag; using it here would wrongly re-wrap.
const beforeCursor = complete.filter((a) => attributeEndOffset(a) <= offset);
const last = beforeCursor.length ? beforeCursor[beforeCursor.length - 1] : null;
const lastEnd = last ? attributeEndOffset(last) : -1;
const alreadyOnNewLine = lastEnd >= 0 && text.slice(lastEnd, offset).includes("\n");
const alreadyOnNewLine =
lastEnd >= 0
? text.slice(lastEnd, offset).includes("\n")
: text.slice(el.start + 1 + el.name.length, offset).includes("\n");
// Canonical indent anchor: the first complete attribute that starts on its
// own line. Fall back to the last complete attribute for inline elements.
@@ -838,31 +881,34 @@ function attributeInsertLayout(
? text.slice(0, anchor.nameStart).match(/[ \t]*$/)?.[0] ?? ""
: "";
if (!onePerLine) {
if (alreadyOnNewLine) {
// Inline-style file, but the user started a new line: keep whatever
// indentation they already typed.
return { rangeStart: wordStart, prefix: "" };
}
const needsSpace = wordStart > el.start + 1 && !/\s/.test(text[wordStart - 1]);
return { rangeStart: wordStart, prefix: needsSpace ? " " : "" };
}
if (alreadyOnNewLine) {
const lineStart = text.lastIndexOf("\n", offset - 1) + 1;
return { rangeStart: lineStart, prefix: indent };
// The attribute being completed is already on its own line: never insert
// another newline. In one-per-line files align with the canonical indent;
// in inline files keep whatever indentation the user already typed.
if (onePerLine) {
const lineStart = text.lastIndexOf("\n", offset - 1) + 1;
return { rangeStart: lineStart, prefix: indent };
}
return { rangeStart: wordStart, prefix: "" };
}
// Insert on a new line. The editor adds the current line's indentation to
// the new line, so we must NOT embed our own indent here (it would
// compound). If whitespace was typed between the previous attribute and
// the cursor (e.g. a space used to trigger the suggestion popup), consume
// it so it does not linger as a trailing space.
const wsStart =
lastEnd >= 0 &&
wordStart > lastEnd &&
/^[ \t]*$/.test(text.slice(lastEnd, wordStart))
? lastEnd
: wordStart;
return { rangeStart: wsStart, prefix: "\n" };
// The cursor sits on the same line as the element name or a complete
// attribute: the completed attribute would be the second one on that line.
if (onePerLine) {
// Insert on a new line. The editor adds the current line's indentation
// to the new line, so we must NOT embed our own indent here (it would
// compound). If whitespace was typed between the previous attribute and
// the cursor (e.g. a space used to trigger the suggestion popup), consume
// it so it does not linger as a trailing space.
const wsStart =
lastEnd >= 0 &&
wordStart > lastEnd &&
/^[ \t]*$/.test(text.slice(lastEnd, wordStart))
? lastEnd
: wordStart;
return { rangeStart: wsStart, prefix: "\n" };
}
const needsSpace = wordStart > el.start + 1 && !/\s/.test(text[wordStart - 1]);
return { rangeStart: wordStart, prefix: needsSpace ? " " : "" };
}
function attributeEndOffset(attr: XmlAttribute): number {
+18 -1
View File
@@ -3,6 +3,7 @@ import { dirname } from "node:path";
import { LineMap, type XmlElement } from "../language/xmlParser";
import { resolveElementType } from "../language/typeContext";
import { resolveSource, buildSearchPaths } from "../indexer/includeResolver";
import { validateSdkPath } from "../sdk";
import * as model from "../model/schemaModel";
import type { ModWorkspace } from "../workspace";
import type { ModIndex } from "../indexer/types";
@@ -18,11 +19,22 @@ import { scopePathKey } from "../indexer/localScope";
export class Ra3Diagnostics {
private collection: vscode.DiagnosticCollection;
private sdkCache: { path: string; unusable: boolean } | null = null;
constructor(private ws: ModWorkspace) {
this.collection = vscode.languages.createDiagnosticCollection("ra3modxml");
}
/** True when the SDK is missing or not an SDK root (project-only mode). */
private sdkUnusable(): boolean {
const path = this.ws.settings.sdkPath;
if (this.sdkCache?.path === path) return this.sdkCache.unusable;
const status = validateSdkPath(path).status;
const unusable = status === "missing" || status === "not-sdk";
this.sdkCache = { path, unusable };
return unusable;
}
async update(document: vscode.TextDocument): Promise<void> {
if (!this.ws.isRa3Workspace()) {
this.collection.set(document.uri, []);
@@ -429,7 +441,7 @@ export class Ra3Diagnostics {
if (!sourceAttr?.hasValue) return;
const searchPaths = idx
? buildSearchPaths(idx.sdkDir, idx.projectDir)
: this.ws.searchPaths();
: this.ws.searchPaths(document);
if (!searchPaths) return;
const resolved = resolveSource(
sourceAttr.value,
@@ -439,6 +451,11 @@ export class Ra3Diagnostics {
const candidateHit =
idx?.sourceCandidates.some((c) => c.source === sourceAttr.value) ?? false;
if (!resolved.path && !candidateHit) {
// Without a usable SDK, prefixed includes are expected to be missing;
// report one project-level hint instead of warning on every line.
if (this.sdkUnusable() && /^(DATA|ART|AUDIO):/i.test(sourceAttr.value.trim())) {
return;
}
diags.push(
this.diag(
new vscode.Range(
+1 -1
View File
@@ -155,7 +155,7 @@ export class Ra3HoverProvider implements vscode.HoverProvider {
) {
const searchPaths = idx
? buildSearchPaths(idx.sdkDir, idx.projectDir)
: this.ws.searchPaths();
: this.ws.searchPaths(document);
const resolved = searchPaths
? resolveSource(
value,
+23 -6
View File
@@ -4,6 +4,7 @@ import { findElementAt, parseXml, textContentTokenAt } from "../language/xmlPars
import { resolveElementType } from "../language/typeContext";
import {
buildSearchPaths,
buildVanillaSearchPaths,
resolveSource,
type SearchPaths,
} from "../indexer/includeResolver";
@@ -71,7 +72,9 @@ export class Ra3DefinitionProvider implements vscode.DefinitionProvider {
(el.name === "Include" && nameLower === "source") ||
(el.name === "include" && nameLower === "href")
) {
const searchPaths = idx ? searchPathsFor(idx) : this.ws.searchPaths();
const searchPaths = idx
? searchPathsFor(idx)
: this.ws.searchPaths(document);
const resolved = searchPaths
? resolveSource(value, dirname(document.uri.fsPath), searchPaths).path
: null;
@@ -178,11 +181,23 @@ async function assetDefLocation(
}
if (def.origin === "manifest") {
const src = def.manifestSource;
if (src?.toUpperCase().startsWith("DATA:")) {
const resolved = resolveSource(src, null, searchPathsFor(idx)).path;
if (src) {
// manifestSource is a path recorded by the vanilla build, not an
// Include path in the current mod. Resolve it with SDK-only search
// paths so a mod file shadowing the same DATA: path cannot hijack the
// jump (e.g. mod Data/globaldata/weapon.xml vs SageXml/...). If the SDK
// source is missing (user removed/renamed a SageXml file), keep the
// definition manifest-only instead of opening the wrong file.
const resolved = resolveSource(
src,
null,
buildVanillaSearchPaths(idx.sdkDir),
).path;
if (resolved) {
// The recorded source file is XML (e.g. SageXml) when available:
// jump to the precise definition inside it, not just the file.
// jump to the precise definition inside it. If the file was modified
// and no longer contains the id, fall back to opening the file at the
// top rather than inventing a precise location.
const precise = await locationInDocument(ws, resolved, def.id);
return precise ?? new vscode.Location(vscode.Uri.file(resolved), new vscode.Position(0, 0));
}
@@ -305,8 +320,10 @@ export class Ra3DocumentLinkProvider implements vscode.DocumentLinkProvider {
_token: vscode.CancellationToken,
): Promise<vscode.DocumentLink[]> {
if (!this.ws.isRa3Workspace()) return [];
const idx = this.ws.index;
const searchPaths = idx ? searchPathsFor(idx) : this.ws.searchPaths();
const idx = this.ws.indexForDocument(document) ?? this.ws.activeIndex();
const searchPaths = idx
? searchPathsFor(idx)
: this.ws.searchPaths(document);
if (!searchPaths) return [];
const text = document.getText();
const doc = parseXml(text);
+20 -10
View File
@@ -134,7 +134,9 @@ export async function sitesToLocations(
const locations: vscode.Location[] = [];
for (const [file, fileSites] of byFile) {
const parsed = await ws.indexer?.readDom(file);
const parsed = await (ws.indexerForFile(file) ?? ws.activeIndexer())?.readDom(
file,
);
const lineMap = parsed?.lineMap ?? null;
for (const site of fileSites) {
if (lineMap) {
@@ -179,7 +181,7 @@ export async function findReferenceLocations(
position: vscode.Position,
): Promise<vscode.Location[] | null> {
if (!ws.isRa3Workspace()) return null;
scheduleRebuildIfRecordsDesync(ws, document);
scheduleRebuildIfRecordsDesync(ws.recordsSyncSurfaceFor(document), document);
const scope = await ws.getScope(document);
const idx = scope.merged;
if (!idx) return null;
@@ -207,16 +209,24 @@ export async function showReferencesForDef(
ws: ModWorkspace,
args: ShowReferencesArgs,
): Promise<void> {
const idx = ws.index;
const doc = vscode.workspace.textDocuments.find(
(d) => d.uri.toString() === args.uri.toString(),
);
if (!doc) return;
let idx: ModIndex | null = null;
try {
idx = (await ws.getCodeLensScope(doc)).merged;
} catch {
return;
}
if (!idx) return;
const def: AssetDef = {
type: args.type,
// Same definition union as the lens count / Find All References.
const defs = definitionsForReference(idx, {
id: args.id,
file: args.file,
line: args.line,
origin: "project",
};
const sites = referenceSitesForDef(idx, def);
refType: null,
selfType: null,
});
const sites = collectReferenceSites(idx, defs);
const locations = await sitesToLocations(ws, sites);
await vscode.commands.executeCommand(
"editor.action.showReferences",
+2 -2
View File
@@ -23,13 +23,13 @@ export async function findUnreferencedAssets(
ws: ModWorkspace,
args?: { type?: string },
): Promise<void> {
if (!ws.isRa3Workspace() || !ws.index) {
const idx = ws.activeIndex();
if (!ws.isRa3Workspace() || !idx) {
void vscode.window.showInformationMessage(
"RA3 Mod XML: no index available yet.",
);
return;
}
const idx = ws.index;
const byType = unreferencedByType(idx);
let type = args?.type;
+8
View File
@@ -148,6 +148,14 @@ export interface IndexRecordsCacheEntry {
* produced before this field existed.
*/
contentHash?: string;
/**
* False when the entry was seeded from disk but its stat has not been
* checked against the current disk yet. Such entries may only be used
* for deferred art registration during phase A; the indexer must not
* consume their records until `validated` is true (set by the stat pass
* or by a build that re-read the file).
*/
validated?: boolean;
}
/**
+85 -22
View File
@@ -11,8 +11,10 @@
* 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;
* - a cold start seeds the in-memory cache immediately (`load`) and runs the
* stat pass in the background (`validate`, no content reads); mismatches
* and missing files are invalidated and re-read by a follow-up rebuild
* (the workspace's stale/dirty mechanism converges);
* - during a session the file watcher invalidates entries precisely;
* - `ra3modxml.reindex` / `ra3modxml.clearCache` remain the final authority.
*
@@ -80,6 +82,22 @@ export interface DiskCacheLoadStats {
validated: number;
/** Records dropped because the file changed, moved or was deleted. */
dropped: number;
/** Milliseconds spent reading / decompressing / parsing the cache file. */
loadMs: number;
/** Milliseconds spent stat-validating cached entries. */
validateMs: number;
}
function emptyLoadStats(): DiskCacheLoadStats {
return {
fileExists: false,
keyMatched: false,
loaded: 0,
validated: 0,
dropped: 0,
loadMs: 0,
validateMs: 0,
};
}
export function diskCacheKey(identity: DiskCacheIdentity): string {
@@ -100,21 +118,18 @@ export class DiskRecordsCache {
}
/**
* 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.
* Loads the cache file without validating entries. This is fast (read +
* gunzip + JSON parse) so a cold start can seed the in-memory records
* cache immediately and let stat validation run in the background.
* Missing/corrupt/key-mismatched caches yield an empty result instead of
* an error.
*/
async loadValidated(): Promise<{
async load(): Promise<{
records: DiskCacheRecord[];
stats: DiskCacheLoadStats;
}> {
const stats: DiskCacheLoadStats = {
fileExists: false,
keyMatched: false,
loaded: 0,
validated: 0,
dropped: 0,
};
const start = Date.now();
const stats = emptyLoadStats();
let raw: DiskCacheFile | null = null;
try {
const buf = await readFile(this.filePath);
@@ -132,15 +147,37 @@ export class DiskRecordsCache {
} catch {
// Missing or corrupt cache: fall through with an empty result.
}
stats.loadMs = Date.now() - start;
if (!raw) return { records: [], stats };
stats.keyMatched = true;
stats.loaded = raw.records.length;
return { records: raw.records, stats };
}
/**
* Stat-validates cached records. Returns the entries that still match
* plus the keys that must be re-read (missing / changed / moved).
*/
async validate(
records: DiskCacheRecord[],
onProgress?: (validatedCount: number, total: number) => void,
): Promise<{
stats: DiskCacheLoadStats;
kept: DiskCacheRecord[];
invalidKeys: string[];
}> {
const start = Date.now();
const stats = emptyLoadStats();
stats.fileExists = true;
stats.keyMatched = true;
stats.loaded = 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 invalidKeys: string[] = [];
for (let i = 0; i < records.length; i += VALIDATE_CONCURRENCY) {
const chunk = records.slice(i, i + VALIDATE_CONCURRENCY);
const results = await Promise.all(
chunk.map(async (rec): Promise<DiskCacheRecord | null> => {
chunk.map(async (rec, index): Promise<{ rec: DiskCacheRecord | null; index: number }> => {
try {
const s = await stat(rec.key);
if (
@@ -150,24 +187,50 @@ export class DiskRecordsCache {
s.birthtimeMs === rec.stat.birthtimeMs &&
s.ctimeMs === rec.stat.ctimeMs
) {
return rec;
return { rec, index };
}
} catch {
// File missing or inaccessible.
}
return null;
return { rec: null, index };
}),
);
for (const r of results) {
if (r) {
kept.push(r);
for (const { rec, index } of results) {
if (rec) {
kept.push(rec);
stats.validated++;
} else {
stats.dropped++;
invalidKeys.push(chunk[index].key);
}
}
onProgress?.(stats.validated, records.length);
}
return { records: kept, stats };
stats.validateMs = Date.now() - start;
return { stats, kept, invalidKeys };
}
/**
* Loads and stat-validates the cache (blocking validation). Used by
* tests and kept as a convenience; the workspace normally prefers
* `load()` + background `validate()`.
*/
async loadValidated(): Promise<{
records: DiskCacheRecord[];
stats: DiskCacheLoadStats;
}> {
const { records, stats } = await this.load();
if (!records.length) return { records, stats };
const validation = await this.validate(records);
return {
records: validation.kept,
stats: {
...stats,
validated: validation.stats.validated,
dropped: validation.stats.dropped,
validateMs: validation.stats.validateMs,
},
};
}
/** Writes the current records cache atomically (temp file + rename). */
+36 -9
View File
@@ -44,39 +44,66 @@ export function buildSearchPaths(
): SearchPaths {
const modParentPath = resolve(projectDir, "..");
const modGranParent = resolve(modParentPath, "..");
const sdk = sdkDir && sdkDir.trim() ? resolve(sdkDir) : "";
const sdkItems = (items: string[]): string[] => (sdk ? items : []);
return {
DATA: [
sdkDir,
...sdkItems([sdk]),
modGranParent,
join(projectDir, "Data"),
join(sdkDir, "Mods"),
...sdkItems([join(sdk, "Mods")]),
modParentPath,
join(sdkDir, "SageXml"),
...sdkItems([join(sdk, "SageXml")]),
...(extra?.DATA ?? []),
],
ART: [
sdkDir,
...sdkItems([sdk]),
modGranParent,
join(projectDir, "Art1"),
join(projectDir, "Art"),
join(sdkDir, "Mods"),
...sdkItems([join(sdk, "Mods")]),
modParentPath,
join(sdkDir, "Art"),
...sdkItems([join(sdk, "Art")]),
...(extra?.ART ?? []),
],
AUDIO: [
sdkDir,
...sdkItems([sdk]),
modGranParent,
join(projectDir, "Audio1"),
join(projectDir, "Audio"),
join(sdkDir, "Mods"),
...sdkItems([join(sdk, "Mods")]),
modParentPath,
join(sdkDir, "Audio"),
...sdkItems([join(sdk, "Audio")]),
...(extra?.AUDIO ?? []),
],
};
}
/**
* Search paths used to resolve a `manifestSource` back to the original
* vanilla SDK source file.
*
* `manifestSource` records where the asset came from when the vanilla
* manifest was compiled; it is not an Include path that should be resolved
* with the current mod's BAB search order. If a mod shadows the same DATA:
* path (for example `Data/globaldata/weapon.xml` exists in both the mod and
* `SageXml`), the manifest definition must still point at the SageXml file.
*
* DATA/ART/AUDIO are resolved against the SDK root first (matching the
* vanilla BAB `/data "/art" /audio` order), then against the corresponding
* SDK source folder. ART/AUDIO source files are not shipped for most assets,
* so those resolutions usually return null and callers fall back to
* manifest-only behavior.
*/
export function buildVanillaSearchPaths(sdkDir: string): SearchPaths {
const sdk = sdkDir && sdkDir.trim() ? resolve(sdkDir) : "";
return {
DATA: sdk ? [sdk, join(sdk, "SageXml")] : [],
ART: sdk ? [sdk, join(sdk, "Art")] : [],
AUDIO: sdk ? [sdk, join(sdk, "Audio")] : [],
};
}
function splitPrefix(source: string): { prefix: SourcePrefix; rest: string } {
for (const prefix of PREFIXES) {
if (source.toUpperCase().startsWith(`${prefix}:`)) {
+72 -5
View File
@@ -27,6 +27,7 @@ import {
type ResolveResult,
type SearchPaths,
} from "./includeResolver";
import { validateSdkPath } from "../sdk";
import {
buildExistenceSnapshot,
type ExistenceSnapshot,
@@ -140,11 +141,17 @@ export class ModIndexer {
private visitedAll = new Set<string>();
private visitedInstance = new Set<string>();
private manifestAssetKeys = new Set<string>();
/** True when the SDK is missing/not an SDK: SDK-only includes are suppressed. */
private sdkUnusable: boolean;
private suppressedSdkIncludeCount = 0;
constructor(private opts: IndexOptions) {
this.searchPaths = buildSearchPaths(opts.sdkDir, opts.projectDir, {
DATA: opts.additionalDataSearchPaths,
});
const sdkStatus = validateSdkPath(opts.sdkDir);
this.sdkUnusable =
sdkStatus.status === "missing" || sdkStatus.status === "not-sdk";
// Caches may be owned by the workspace so they survive rebuilds.
this.docs = opts.documentCache ?? new DocumentCache();
this.recordsCache = opts.recordsCache ?? new IndexRecordsCache();
@@ -175,7 +182,27 @@ export class ModIndexer {
// ~2.6 GB of art assets on a mechanical drive).
if (trust) {
const rec = this.recordsCache.get(key);
if (rec) return this.recordsParsed(path, rec);
if (rec) {
if (rec.validated === false) {
// Seeded from disk but not stat-validated yet. During phase A an
// art file only needs registration (no content), so reuse the
// cached stamp; its records are consumed only after validation.
if (opts?.deferArt && rec.kind === "shallow" && rec.stat) {
const file: IndexedFile = { path: resolve(path), stat: rec.stat };
this.files.set(key, file);
return {
file,
parse: null,
records: null,
lineMap: null,
deferredArt: true,
};
}
// Fall through: the stat-verifying path below checks this entry.
} else {
return this.recordsParsed(path, rec);
}
}
const cached = this.docs.get(key);
if (cached) {
this.files.set(key, cached.file);
@@ -194,6 +221,7 @@ export class ModIndexer {
rec.stat.birthtimeMs === st.birthtimeMs &&
rec.stat.ctimeMs === st.ctimeMs
) {
rec.validated = true;
// Force rebuilds (Re-index workspace) verify full-XML content even
// when every stat signal matches: external drives (FAT32/exFAT) can
// rewrite a file with the same size and coarse timestamps.
@@ -456,6 +484,7 @@ export class ModIndexer {
async build(onPhase?: (index: ModIndex) => void | Promise<void>): Promise<ModIndex> {
const start = Date.now();
this.buildRecords.clear();
this.suppressedSdkIncludeCount = 0;
// 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);
@@ -499,6 +528,17 @@ export class ModIndexer {
}
}
this.timings.walkMs = Date.now() - walkStart;
if (this.suppressedSdkIncludeCount > 0) {
this.diagnostics.push({
file:
staticEntry ?? join(this.opts.projectDir, "Data"),
line: 0,
message:
"SDK path is not configured or invalid; DATA:/ART:/AUDIO: includes are not resolved (set ra3modxml.sdkPath).",
severity: "information",
code: "sdk-not-configured",
});
}
// ── Source completion candidates ──
const candidatesStart = Date.now();
@@ -533,9 +573,17 @@ export class ModIndexer {
// global.xml, audio.xml placeholders) but only its shallow XML files are
// relevant. These candidates take precedence over same-named files found
// deeper in the search paths (e.g. SageXml/Static.xml).
const sdkRootXml = (await readdir(this.opts.sdkDir)).filter(
(f) => f.toLowerCase().endsWith(".xml"),
);
let sdkRootXml: string[] = [];
if (this.opts.sdkDir) {
try {
sdkRootXml = (await readdir(this.opts.sdkDir)).filter(
(f) => f.toLowerCase().endsWith(".xml"),
);
} catch {
// Missing/inaccessible SDK root: run in project-only mode. All other
// SDK search roots already degrade to empty lists.
}
}
const sdkRootCandidates: SourceCandidate[] = sdkRootXml.map((f) => ({
source: `DATA:${f}`,
path: resolve(this.opts.sdkDir, f),
@@ -778,6 +826,10 @@ export class ModIndexer {
for (const inc of records.includes) {
const resolved = this.resolveCached(inc.source, dirname(file));
if (!resolved.path) {
if (this.shouldSuppressMissingInclude(inc.source)) {
this.suppressedSdkIncludeCount++;
continue;
}
this.diagnostics.push({
file,
line: inc.line,
@@ -808,6 +860,10 @@ export class ModIndexer {
for (const xi of records.nestedXiIncludes) {
const resolved = this.resolveCached(xi.href, dirname(file));
if (!resolved.path) {
if (this.shouldSuppressMissingInclude(xi.href)) {
this.suppressedSdkIncludeCount++;
continue;
}
this.diagnostics.push({
file,
line: xi.line,
@@ -839,6 +895,10 @@ export class ModIndexer {
): Promise<void> {
const resolved = this.resolveCached(xi.href, dirname(parentFile));
if (!resolved.path) {
if (this.shouldSuppressMissingInclude(xi.href)) {
this.suppressedSdkIncludeCount++;
return;
}
this.diagnostics.push({
file: parentFile,
line: xi.line,
@@ -930,12 +990,19 @@ export class ModIndexer {
private originOf(path: string): "project" | "sdk" {
const p = resolve(path).toLowerCase();
const project = resolve(this.opts.projectDir).toLowerCase();
const sdk = resolve(this.opts.sdkDir).toLowerCase();
const sdk = this.opts.sdkDir
? resolve(this.opts.sdkDir).toLowerCase()
: "";
if (p.startsWith(project + "\\")) return "project";
if (sdk && p.startsWith(sdk + "\\")) return "sdk";
return "project";
}
/** DATA:/ART:/AUDIO: misses are expected when no usable SDK is configured. */
private shouldSuppressMissingInclude(source: string): boolean {
return this.sdkUnusable && /^(DATA|ART|AUDIO):/i.test(source.trim());
}
private addAsset(def: AssetDef): void {
// Keep the original case: type names are matched against the XSD model.
const typeKey = def.type;
+1 -1
View File
@@ -261,7 +261,7 @@ class OverlayBuilder {
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();
const sdk = this.ctx.sdkDir ? resolve(this.ctx.sdkDir).toLowerCase() : "";
if (p.startsWith(project + "\\")) return "project";
if (sdk && p.startsWith(sdk + "\\")) return "sdk";
return "project";
+2
View File
@@ -2,6 +2,8 @@
* Parser for SAGE `.manifest` files, ported from OpenSAGE
* (src/OpenSage.Game/Data/StreamFS/ManifestFile.cs, commit d45d361).
*
* Licensed under LGPL-3.0 (derived from OpenSAGE); see LICENSE.
*
* The manifest is a binary index produced by BinaryAssetBuilder: every asset
* compiled into a stream is listed with hashed type/instance ids, an offset
* into the asset-name string buffer, and an optional source file name.
+10 -7
View File
@@ -18,7 +18,7 @@ import {
isReferenceTargetType,
type ReferenceLookup,
} from "./refs";
import { buildSearchPaths, resolveSource } from "./includeResolver";
import { buildVanillaSearchPaths, resolveSource } from "./includeResolver";
import { normKey, recordsHash } from "./caches";
import { LineMap, parseXml } from "../language/xmlParser";
import type { AssetDef, ModIndex, ReferenceSite } from "./types";
@@ -90,10 +90,13 @@ function normFileKey(path: string): string {
*
* Besides the definition's own reverse-index bucket, this unions the sites
* of manifest definitions that map back to the same XML source file via
* `manifestSource`. A manifest asset with a resolvable SageXml source is
* semantically the same asset as that XML definition, so references to it
* should show up on the source file's CodeLens too (Find All References
* already sees them because it unions every same-id/type definition).
* `manifestSource`. `manifestSource` is resolved with the SDK-only search
* paths (not the current mod's BAB order), so a mod file shadowing the same
* DATA: path is never mistaken for the vanilla source. A manifest asset with
* a resolvable SageXml source is semantically the same asset as that XML
* definition, so references to it should show up on the source file's
* CodeLens too (Find All References already sees them because it unions
* every same-id/type definition).
*/
export function referenceSitesForDefinition(
idx: ModIndex,
@@ -104,13 +107,13 @@ export function referenceSitesForDefinition(
if (!byId?.length) return sites;
const defFile = normFileKey(def.file);
const searchPaths = buildSearchPaths(idx.sdkDir, idx.projectDir);
const vanillaPaths = buildVanillaSearchPaths(idx.sdkDir);
const seen = new Set(
sites.map((s) => `${s.file}\u0000${s.start}\u0000${s.end}\u0000${s.kind}`),
);
for (const other of byId) {
if (other.origin !== "manifest" || !other.manifestSource) continue;
const resolved = resolveSource(other.manifestSource, null, searchPaths).path;
const resolved = resolveSource(other.manifestSource, null, vanillaPaths).path;
if (!resolved || normFileKey(resolved) !== defFile) continue;
for (const site of referenceSitesForDef(idx, other)) {
const key = `${site.file}\u0000${site.start}\u0000${site.end}\u0000${site.kind}`;
+6 -1
View File
@@ -20,7 +20,12 @@ export interface AssetDef {
viaInstance?: boolean;
/** Manifest path for origin === "manifest". */
manifest?: string;
/** Source file recorded inside a manifest (e.g. "DATA:globaldata/armor.xml"). */
/**
* Source file recorded inside a manifest (e.g. "DATA:globaldata/armor.xml").
* This is a path from the vanilla build, so callers resolve it with the
* SDK-only search paths (`buildVanillaSearchPaths`), never with the current
* mod's BAB include order.
*/
manifestSource?: string;
}
+177
View File
@@ -0,0 +1,177 @@
/**
* Mod project root discovery for RA3 Mod XML.
*
* Pure TypeScript (no vscode dependency) so the detection rules can be unit
* tested and reused by other tools.
*
* A project root is any directory containing one of the markers the mod
* compiler (defaultscript.cs) actually consumes:
* - `Data/Mod.xml` (static data entry)
* - `Data/additionalmaps/mapmetadata_*.xml` (global data entries)
* - `*.babproj` (mod SDK project file)
*
* Discovery works in three directions:
* - upward from a folder (the workspace folder may be `Data` or a deep
* subfolder of a mod);
* - upward from a file (single-file opens without a workspace folder);
* - shallow downward from a container folder (a folder that contains
* several sibling mods).
*/
import { dirname, join, resolve } from "node:path";
import { readdirSync } from "node:fs";
export const DEFAULT_MAX_UPWARD_DEPTH = 12;
export const DEFAULT_MAX_DOWNWARD_DEPTH = 3;
export type ProjectMarkerKind = "mod" | "babproj" | "mapmetadata";
/** Directories that never contain a mod root themselves. */
const SKIP_DIRECTORY_NAMES = new Set([
"data",
"art",
"art1",
"audio",
"audio1",
"builtmods",
"builtmods-quantum",
"sageml",
"schemas",
"xsd",
"hlsl",
"node_modules",
"packages",
"dist",
"out",
"bin",
"obj",
".git",
".vs",
".vscode",
]);
/**
* Returns the marker kind found directly under `dir`, or null when `dir` is
* not a mod project root. `Data`/`mapmetadata` lookups are case-insensitive.
*/
export function projectMarkerKind(dir: string): ProjectMarkerKind | null {
const data = findCaseInsensitiveDir(dir, "Data");
if (data && hasFileIgnoreCase(data, ["Mod.xml"])) return "mod";
const entries = readDirNames(dir);
if (entries?.some((e) => e.toLowerCase().endsWith(".babproj"))) {
return "babproj";
}
if (data && hasMapMetadata(data)) return "mapmetadata";
return null;
}
/** True when `dir` is a mod project root (any marker). */
export function isProjectRoot(dir: string): boolean {
return projectMarkerKind(dir) != null;
}
/**
* Walks upward from `startDir` (up to `maxDepth` ancestors) and returns the
* nearest directory that carries a project marker, or null.
*/
export function findProjectRootUpward(
startDir: string,
maxDepth = DEFAULT_MAX_UPWARD_DEPTH,
): string | null {
let dir = resolve(startDir);
for (let i = 0; i < maxDepth; i++) {
if (isProjectRoot(dir)) return dir;
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
return null;
}
/** Upward discovery starting from a file's directory (single-file opens). */
export function findProjectRootForFile(
file: string,
maxDepth = DEFAULT_MAX_UPWARD_DEPTH,
): string | null {
return findProjectRootUpward(dirname(resolve(file)), maxDepth);
}
/**
* Shallow downward discovery for a workspace folder that contains one or
* more mods (e.g. the SDK `Mods` folder or a personal mods container).
*
* Descends at most `maxDepth` levels, never descends into known non-mod
* directories, and stops descending once a directory is itself a project
* root (a root's own `Data`/`Art` subtrees are never project containers).
* Results are de-duplicated by normalized path.
*/
export function discoverProjects(
folder: string,
maxDepth = DEFAULT_MAX_DOWNWARD_DEPTH,
): string[] {
const out: string[] = [];
const seen = new Set<string>();
const visit = (dir: string, depth: number): void => {
if (depth > maxDepth) return;
if (isProjectRoot(dir)) {
const key = normKey(dir);
if (!seen.has(key)) {
seen.add(key);
out.push(resolve(dir));
}
return;
}
let entries;
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (SKIP_DIRECTORY_NAMES.has(entry.name.toLowerCase())) continue;
visit(join(dir, entry.name), depth + 1);
}
};
visit(resolve(folder), 0);
return out;
}
function normKey(path: string): string {
return resolve(path).toLowerCase();
}
function readDirNames(dir: string): string[] | null {
try {
return readdirSync(dir);
} catch {
return null;
}
}
/** Case-insensitive child directory lookup under `parent`. */
function findCaseInsensitiveDir(parent: string, wanted: string): string | null {
const entries = readDirNames(parent);
if (!entries) return null;
const hit = entries.find(
(e) => e.toLowerCase() === wanted.toLowerCase(),
);
return hit ? join(parent, hit) : null;
}
/** True when `dir` contains any of `names` (case-insensitive file names). */
function hasFileIgnoreCase(dir: string, names: string[]): boolean {
const entries = readDirNames(dir);
if (!entries) return false;
const lower = new Set(entries.map((e) => e.toLowerCase()));
return names.some((n) => lower.has(n.toLowerCase()));
}
/** True when `dataDir/additionalmaps` contains a mapmetadata_*.xml file. */
function hasMapMetadata(dataDir: string): boolean {
const maps = findCaseInsensitiveDir(dataDir, "additionalmaps");
if (!maps) return false;
const entries = readDirNames(maps);
if (!entries) return false;
return entries.some((e) => /^mapmetadata_.*\.xml$/i.test(e));
}
+179
View File
@@ -0,0 +1,179 @@
/**
* SDK path normalization, validation and registry-based detection.
*
* Pure TypeScript (no vscode dependency) so the rules can be unit tested and
* reused by the indexer.
*
* The registry keys mirror what the SDK's own build script
* (`defaultscript.cs` initialize()) reads: the uninstall entry's
* InstallLocation, first in the 64-bit view and then under Wow6432Node.
* The installer path is only a hint - every candidate is validated against
* the actual SDK layout before being offered to the user.
*/
import { execFile } from "node:child_process";
import { readdirSync, statSync } from "node:fs";
import { join, resolve } from "node:path";
export type SdkValidationStatus = "ok" | "partial" | "not-sdk" | "missing";
export interface SdkValidation {
/** Resolved absolute path, or "" when nothing was configured. */
path: string;
status: SdkValidationStatus;
/** Human-readable relative paths that failed validation. */
missing: string[];
}
/** Uninstall entries queried by the SDK installer (same GUIDs as defaultscript.cs). */
export const SDK_REGISTRY_KEYS = [
"HKEY_LOCAL_MACHINE\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{F6A3F605-7B10-4939-8D3D-4594332C1649}",
"HKEY_LOCAL_MACHINE\\Software\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{F6A3F605-7B10-4939-8D3D-4594332C1649}",
] as const;
/**
* The one required marker that identifies an RA3 Mod SDK root. The extension
* bundles its own schema model, but this file is the most distinctive SDK
* layout item (and is what `npm run generate-model` consumes).
*/
const SDK_ROOT_MARKER = ["Schemas", "xsd", "CnC3Types.xsd"] as const;
/**
* Functional items used by the extension. Missing ones degrade specific
* features (manifests, vanilla sources, SDK-side search paths), so they are
* reported as "partial" instead of rejecting the root outright.
*/
const SDK_FUNCTIONAL_ITEMS: { rel: readonly string[] }[] = [
{ rel: ["builtmods"] },
{ rel: ["SageXml"] },
{ rel: ["Mods"] },
{ rel: ["Static.xml"] },
{ rel: ["Global.xml"] },
{ rel: ["Audio.xml"] },
];
/**
* Trims quotes/whitespace and resolves to an absolute path. Returns "" for
* an empty value so callers can treat it as "no SDK configured".
*/
export function normalizeSdkPath(raw: string): string {
if (!raw) return "";
let p = String(raw).trim();
if (
p.length >= 2 &&
((p.startsWith('"') && p.endsWith('"')) ||
(p.startsWith("'") && p.endsWith("'")))
) {
p = p.slice(1, -1).trim();
}
return p ? resolve(p) : "";
}
/**
* Validates a configured/offered SDK path.
*
* - `missing`: nothing configured, or the path does not exist.
* - `not-sdk`: exists, but lacks the SDK root marker.
* - `partial`: is an SDK root, but some extension-relevant items are absent.
* - `ok`: every checked item exists.
*/
export function validateSdkPath(raw: string): SdkValidation {
const path = normalizeSdkPath(raw);
if (!path) return { path: "", status: "missing", missing: [] };
if (!isDirectory(path)) return { path, status: "missing", missing: [] };
if (!hasNestedIgnoreCase(path, SDK_ROOT_MARKER)) {
return {
path,
status: "not-sdk",
missing: [SDK_ROOT_MARKER.join("/")],
};
}
const missing: string[] = [];
for (const item of SDK_FUNCTIONAL_ITEMS) {
if (!hasNestedIgnoreCase(path, item.rel)) {
missing.push(item.rel.join("/"));
}
}
return {
path,
status: missing.length ? "partial" : "ok",
missing,
};
}
/**
* Reads InstallLocation from one registry key via `reg.exe` (Windows only).
* Returns null when the key/value is absent or the query fails.
*/
export async function readRegistryValue(
key: string,
valueName = "InstallLocation",
timeoutMs = 3000,
): Promise<string | null> {
if (process.platform !== "win32") return null;
try {
const stdout = await new Promise<string>((resolveValue, reject) => {
execFile(
"reg",
["query", key, "/v", valueName],
{ timeout: timeoutMs, windowsHide: true },
(err, stdout, _stderr) => {
if (err) reject(err);
else resolveValue(stdout);
},
);
});
return parseRegistryInstallLocation(stdout);
} catch {
return null;
}
}
/** Extracts the InstallLocation value from `reg.exe query` output. */
export function parseRegistryInstallLocation(stdout: string): string | null {
for (const line of stdout.split(/\r?\n/)) {
const m = line.match(/^\s*InstallLocation\s+REG_[A-Z_]+\s+(.+?)\s*$/i);
if (m?.[1]) return m[1].trim();
}
return null;
}
/** Queries both registry views in the same order defaultscript.cs uses. */
export async function detectSdkPathFromRegistry(): Promise<string | null> {
for (const key of SDK_REGISTRY_KEYS) {
const value = await readRegistryValue(key);
if (value?.trim()) return value.trim();
}
return null;
}
function isDirectory(path: string): boolean {
try {
return statSync(path).isDirectory();
} catch {
return false;
}
}
function readDirNames(dir: string): string[] | null {
try {
return readdirSync(dir);
} catch {
return null;
}
}
function hasNestedIgnoreCase(root: string, rel: readonly string[]): boolean {
let dir = root;
for (let i = 0; i < rel.length - 1; i++) {
const names = readDirNames(dir);
if (!names) return false;
const hit = names.find((n) => n.toLowerCase() === rel[i].toLowerCase());
if (!hit) return false;
dir = join(dir, hit);
}
const names = readDirNames(dir);
if (!names) return false;
const wanted = rel[rel.length - 1].toLowerCase();
return names.some((n) => n.toLowerCase() === wanted);
}
+168
View File
@@ -0,0 +1,168 @@
import * as vscode from "vscode";
import type { ModWorkspace } from "./workspace";
import {
detectSdkPathFromRegistry,
validateSdkPath,
type SdkValidation,
} from "./sdk";
/**
* Non-intrusive SDK path guidance: a status-bar hint plus a one-time prompt
* (per session). The prompt prefers a validated registry-detected path, then
* falls back to a folder picker. Clearing `ra3modxml.sdkPath` explicitly is
* treated as "intentionally disabled" and never re-prompts.
*/
export class SdkSetup {
private readonly statusBar: vscode.StatusBarItem;
private promptAttempted = false;
constructor(
context: vscode.ExtensionContext,
private readonly getWs: () => ModWorkspace | null,
) {
this.statusBar = vscode.window.createStatusBarItem(
vscode.StatusBarAlignment.Left,
99,
);
this.statusBar.name = "RA3 Mod XML SDK";
this.statusBar.command = "ra3modxml.configureSdkPath";
context.subscriptions.push(this.statusBar);
context.subscriptions.push(
vscode.commands.registerCommand("ra3modxml.configureSdkPath", () => {
const ws = this.getWs();
if (!ws) {
void vscode.window.showInformationMessage(
"RA3 Mod XML: 打开 RA3 Mod 项目后即可配置 SDK 路径。",
);
return;
}
void this.runSetup();
}),
);
}
async evaluate(ws: ModWorkspace): Promise<void> {
if (!ws.isRa3Workspace()) {
this.statusBar.hide();
return;
}
const config = vscode.workspace.getConfiguration("ra3modxml");
const raw = config.get<string>("sdkPath", "");
const explicit = isExplicitlyConfigured(config);
const validation = validateSdkPath(raw);
if (validation.status === "ok") {
this.statusBar.hide();
return;
}
// An explicit empty value means "no SDK, project-only mode" - never nag.
if (!raw && explicit) {
this.statusBar.hide();
return;
}
this.statusBar.text = statusBarText(validation);
this.statusBar.tooltip = describeSdkValidation(validation);
this.statusBar.show();
if (!this.promptAttempted) {
this.promptAttempted = true;
await this.runSetup();
}
}
private async runSetup(): Promise<void> {
const detected = await detectSdkPathFromRegistry();
const detectedValidation = detected ? validateSdkPath(detected) : null;
if (
detectedValidation &&
(detectedValidation.status === "ok" ||
detectedValidation.status === "partial")
) {
const pick = await vscode.window.showWarningMessage(
`RA3 Mod XML 未找到有效的 SDK 路径。检测到已安装的 SDK:${detectedValidation.path}`,
"使用检测到的路径",
"手动选择…",
"暂时不用",
);
if (pick === "使用检测到的路径") {
await applySdkPath(detectedValidation.path);
} else if (pick === "手动选择…") {
await this.chooseAndApply();
}
return;
}
const pick = await vscode.window.showWarningMessage(
"RA3 Mod XML 需要 RA3 Mod SDK 路径才能启用原版数据、manifest 与跨文件补全/跳转功能。未设置时插件将以项目模式运行。",
"选择 SDK 文件夹…",
"暂时不用",
);
if (pick === "选择 SDK 文件夹…") await this.chooseAndApply();
}
private async chooseAndApply(): Promise<void> {
const picked = await vscode.window.showOpenDialog({
canSelectFiles: false,
canSelectFolders: true,
canSelectMany: false,
openLabel: "选择 SDK 根目录",
title: "选择 RA3 Mod SDK 根目录(应包含 Schemas/xsd/CnC3Types.xsd",
});
const dir = picked?.[0]?.fsPath;
if (!dir) return;
const validation = validateSdkPath(dir);
if (validation.status === "missing" || validation.status === "not-sdk") {
void vscode.window.showErrorMessage(
`所选目录不是可用的 RA3 Mod SDK(缺少 ${
validation.missing.join("、") || "该目录"
})。请重新选择。`,
);
return;
}
await applySdkPath(validation.path);
}
}
function statusBarText(validation: SdkValidation): string {
if (validation.status === "missing") {
return validation.path
? "$(warning) RA3 XML: SDK 路径不存在"
: "$(warning) RA3 XML: 未设置 SDK";
}
if (validation.status === "not-sdk") return "$(warning) RA3 XML: SDK 路径无效";
return "$(warning) RA3 XML: SDK 不完整";
}
function describeSdkValidation(validation: SdkValidation): string {
if (validation.status === "missing") {
return validation.path
? `ra3modxml.sdkPath 指向的目录不存在:${validation.path}。点击重新设置;或将 ra3modxml.sdkPath 清空以禁用原版数据功能。`
: "未配置 RA3 Mod SDK 路径。点击设置;或将 ra3modxml.sdkPath 清空以禁用原版数据功能。";
}
if (validation.status === "not-sdk") {
return "ra3modxml.sdkPath 指向的目录不是 RA3 Mod SDK 根目录(缺少 Schemas/xsd/CnC3Types.xsd)。点击重新设置。";
}
return `RA3 Mod SDK 缺少:${validation.missing.join("、")}。manifest / 原版源码 / SDK 搜索路径等功能不可用。`;
}
function isExplicitlyConfigured(
config: vscode.WorkspaceConfiguration,
): boolean {
const info = config.inspect<string>("sdkPath");
return !!(
info &&
(info.globalValue !== undefined ||
info.workspaceValue !== undefined ||
info.workspaceFolderValue !== undefined)
);
}
async function applySdkPath(path: string): Promise<void> {
await vscode.workspace
.getConfiguration("ra3modxml")
.update("sdkPath", path, vscode.ConfigurationTarget.Global);
void vscode.window.showInformationMessage(
`RA3 Mod XML: SDK 路径已设置为 ${path},正在重建索引…`,
);
}
+5 -2
View File
@@ -1,5 +1,6 @@
import * as vscode from "vscode";
import { join } from "node:path";
import { normalizeSdkPath } from "./sdk";
export interface ExtensionSettings {
sdkPath: string;
@@ -13,7 +14,7 @@ export interface ExtensionSettings {
export function readSettings(): ExtensionSettings {
const cfg = vscode.workspace.getConfiguration("ra3modxml");
const sdkPath = cfg.get<string>("sdkPath", "C:\\Apps\\RA3-MODSDK-X");
const sdkPath = normalizeSdkPath(cfg.get<string>("sdkPath", ""));
return {
sdkPath,
indexSageXml: cfg.get<boolean>("indexSageXml", true),
@@ -27,6 +28,8 @@ export function readSettings(): ExtensionSettings {
"all",
) as ExtensionSettings["definitionMode"],
additionalDataSearchPaths: cfg.get<string[]>("additionalDataSearchPaths", []),
builtmodsDirs: [join(sdkPath, "builtmods"), join(sdkPath, "builtmods-quantum")],
builtmodsDirs: sdkPath
? [join(sdkPath, "builtmods"), join(sdkPath, "builtmods-quantum")]
: [],
};
}
+755 -263
View File
File diff suppressed because it is too large Load Diff