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
+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;