0.1.20
This commit is contained in:
+64
-10
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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). */
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user