This commit is contained in:
2026-08-04 14:28:26 +02:00
parent 44b2eaf273
commit 6794896ac7
37 changed files with 3263 additions and 195 deletions
+73 -16
View File
@@ -1,5 +1,5 @@
import * as vscode from "vscode";
import { parseXml, type XmlElement } from "../language/xmlParser";
import type { XmlElement } from "../language/xmlParser";
import {
analyzeContext,
splitListValuePrefix,
@@ -8,6 +8,11 @@ import {
import { resolveElementType } from "../language/typeContext";
import * as model from "../model/schemaModel";
import { isLocalReferenceAttribute } from "../indexer/refs";
import {
findContainingGameObject,
collectLocalIds,
type LogicalElement,
} from "../indexer/logicalTree";
import type { ModWorkspace } from "../workspace";
import type { ModIndex, AssetDef } from "../indexer/types";
@@ -21,11 +26,13 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
position: vscode.Position,
_token: vscode.CancellationToken,
): Promise<vscode.CompletionItem[]> {
if (!this.ws.isRa3Workspace()) return [];
const text = document.getText();
const offset = document.offsetAt(position);
const doc = parseXml(text);
const scope = await this.ws.getScope(document);
const doc = scope.expanded;
const ctx = analyzeContext(doc, text, offset);
const idx = this.ws.index;
const idx = scope.merged;
switch (ctx.kind) {
case "element-name":
@@ -33,9 +40,9 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
case "attribute-name":
return this.attributeNameItems(ctx, document, position);
case "attribute-value":
return idx ? this.valueItems(ctx, document, position, idx) : [];
return this.valueItems(ctx, document, position, idx);
case "content":
return idx ? this.contentItems(ctx, document, position, idx) : [];
return this.contentItems(ctx, document, position, idx);
default:
return [];
}
@@ -177,7 +184,7 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
ctx: CompletionContext,
document: vscode.TextDocument,
position: vscode.Position,
idx: ModIndex,
idx: ModIndex | null,
): vscode.CompletionItem[] {
const el = ctx.element;
const attr = ctx.attr;
@@ -232,6 +239,7 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
);
}
if (isInclude && attrName === "source") {
if (!idx) return [];
return this.includeSourceItems(idx, prefix, make);
}
if (attrName === "xai:joinaction" || attrName === "joinaction") {
@@ -242,16 +250,19 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
// inheritFrom: same element type first, then everything.
if (attrName === "inheritfrom") {
if (!idx) return [];
return this.assetIdItems(idx, el.name, null, prefix, make);
}
// `id` attributes are definitions and Poid attributes are pipeline-local
// references; offering global asset ids for them would be wrong.
if (attrInfo && isLocalReferenceAttribute(elType, attr.name)) {
return [];
if (attrName === "id") return [];
return this.localIdItems(el as LogicalElement, prefix, make);
}
if (attrInfo?.refType) {
if (!idx) return [];
return this.assetIdItems(idx, null, attrInfo.refType, prefix, make);
}
if (attrInfo?.enumValues?.length) {
@@ -264,7 +275,7 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
.filter((v) => v.startsWith(prefix.toLowerCase()))
.map((v) => make(v, vscode.CompletionItemKind.Value, "boolean"));
}
if (attrInfo?.allowsDefine) {
if (idx && attrInfo?.allowsDefine) {
return this.defineItems(idx, prefix, make);
}
return [];
@@ -304,8 +315,12 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
): vscode.CompletionItem[] {
const lower = prefix.toLowerCase();
const scored: { def: AssetDef; score: number }[] = [];
const seen = new Set<string>();
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;
let score = 3;
if (refType && model.isAssignableTo(def.type, refType)) score = 1;
@@ -315,6 +330,18 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
};
const targetType = selfType ?? refType;
const localAssets = idx.local?.assets;
const localById = idx.local?.assetsById;
if (localAssets || localById) {
if (!targetType) {
for (const list of localById!.values()) for (const d of list) consider(d);
} else {
for (const [typeName, byId] of localAssets!) {
if (!model.isAssignableTo(typeName, targetType)) continue;
for (const list of byId.values()) for (const d of list) consider(d);
}
}
}
if (!targetType) {
for (const list of idx.assetsById.values()) for (const d of list) consider(d);
} else {
@@ -343,24 +370,54 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
): vscode.CompletionItem[] {
const lower = prefix.replace(/^[=$]*/, "").toLowerCase();
const items: vscode.CompletionItem[] = [];
for (const [key, defs] of idx.defines) {
if (!key.includes(lower)) continue;
const def = defs[0];
const label = `$${def.name}`;
const item = make(label, vscode.CompletionItemKind.Constant, "Define", def.value);
item.insertText = label;
items.push(item);
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}`;
if (seen.has(dedupe)) continue;
seen.add(dedupe);
const label = `$${def.name}`;
const item = make(label, vscode.CompletionItemKind.Constant, "Define", def.value);
item.insertText = label;
items.push(item);
}
}
return items.slice(0, MAX_VALUE_ITEMS);
}
private localIdItems(
el: LogicalElement,
prefix: string,
make: (label: string, kind: vscode.CompletionItemKind, detail: string, doc?: string) => vscode.CompletionItem,
): vscode.CompletionItem[] {
const root = findContainingGameObject(el);
if (!root) return [];
const lower = prefix.toLowerCase();
const items: vscode.CompletionItem[] = [];
for (const { id } of collectLocalIds(root)) {
if (!id.toLowerCase().startsWith(lower)) continue;
items.push(
make(
id,
vscode.CompletionItemKind.Value,
"local module",
"Pipeline-local id in the enclosing GameObject (includes xi:include targets).",
),
);
}
return items;
}
// ── Element content ───────────────────────────────────────────────
private contentItems(
ctx: CompletionContext,
_document: vscode.TextDocument,
_position: vscode.Position,
_idx: ModIndex,
_idx: ModIndex | null,
): vscode.CompletionItem[] {
const el = ctx.element;
if (!el) return [];
+78 -25
View File
@@ -1,6 +1,6 @@
import * as vscode from "vscode";
import { dirname } from "node:path";
import { LineMap, parseXml, type XmlElement } from "../language/xmlParser";
import { LineMap, type XmlElement } from "../language/xmlParser";
import { resolveElementType } from "../language/typeContext";
import { resolveSource, buildSearchPaths } from "../indexer/includeResolver";
import * as model from "../model/schemaModel";
@@ -8,8 +8,11 @@ import type { ModWorkspace } from "../workspace";
import type { ModIndex } from "../indexer/types";
import {
isReferenceAttributeOfType,
mergeLocalAndGlobalDefs,
resolveReferenceTargetsForType,
} from "../indexer/refs";
import type { LogicalElement } from "../indexer/logicalTree";
import { scopePathKey } from "../indexer/localScope";
export class Ra3Diagnostics {
private collection: vscode.DiagnosticCollection;
@@ -19,15 +22,19 @@ export class Ra3Diagnostics {
}
async update(document: vscode.TextDocument): Promise<void> {
const idx = this.ws.index;
if (!idx) {
if (!this.ws.isRa3Workspace()) {
this.collection.set(document.uri, []);
return;
}
const scope = await this.ws.getScope(document);
const idx = scope.merged;
const text = document.getText();
const lineMap = new LineMap(text);
const doc = parseXml(text);
const doc = scope.parse;
const diags: vscode.Diagnostic[] = [];
// Reference/duplicate checks are provisional while the index is
// incomplete or stale: "not found" may be a false positive.
const provisional = idx ? !idx.complete || idx.stale === true : false;
for (const err of doc.errors) {
diags.push(
@@ -44,7 +51,15 @@ export class Ra3Diagnostics {
}
if (doc.root) {
this.checkElements(doc.root, doc, lineMap, idx, document, diags);
this.checkElements(
scope.expanded.root,
scope.expanded,
lineMap,
idx,
document,
diags,
provisional,
);
}
this.collection.set(document.uri, diags);
@@ -59,19 +74,29 @@ export class Ra3Diagnostics {
}
private checkElements(
root: XmlElement,
doc: { elements: XmlElement[] },
root: LogicalElement | null,
doc: { elements: LogicalElement[] },
lineMap: LineMap,
idx: ModIndex,
idx: ModIndex | null,
document: vscode.TextDocument,
diags: vscode.Diagnostic[],
provisional: boolean,
): void {
const settings = this.ws.settings;
const fileDuplicates = new Map<string, { line: number }>();
for (const el of doc.elements) {
// Only report diagnostics for nodes that belong to the document being
// edited. Nodes spliced in through xi:include keep their own source
// file and are diagnosed when that file is opened.
if (scopePathKey(el.sourceFile) !== scopePathKey(document.uri.fsPath)) {
continue;
}
const local = localName(el.name);
const isTopLevel = el.parent === root && !["Tags", "Includes", "Defines"].includes(local);
const isTopLevel =
root !== null &&
el.parent === root &&
!["Tags", "Includes", "Defines"].includes(local);
const range = tagRange(document, el);
// Top-level assets must have an id.
@@ -111,6 +136,7 @@ export class Ra3Diagnostics {
document,
idx,
diags,
provisional,
);
}
}
@@ -169,6 +195,7 @@ export class Ra3Diagnostics {
document,
idx,
diags,
provisional,
);
}
}
@@ -184,12 +211,17 @@ export class Ra3Diagnostics {
type: string,
id: string,
document: vscode.TextDocument,
idx: ModIndex,
idx: ModIndex | null,
diags: vscode.Diagnostic[],
provisional: boolean,
): void {
if (!idx) return;
const byType = idx.assets.get(type);
const defs = byType?.get(id.toLowerCase());
if (!defs || defs.length < 2) return;
const defs = mergeLocalAndGlobalDefs(
idx.local?.assets.get(type)?.get(id.toLowerCase()),
byType?.get(id.toLowerCase()),
);
if (defs.length < 2) return;
const self = defs.filter(
(d) =>
d.origin === "project" &&
@@ -211,7 +243,8 @@ export class Ra3Diagnostics {
diags.push(
this.diag(
range,
`Duplicate id "${id}" for <${type}> (also defined in ${other.file})`,
`Duplicate id "${id}" for <${type}> (also defined in ${other.file})` +
(provisional ? " (based on a partial index)" : ""),
vscode.DiagnosticSeverity.Error,
"duplicate-id",
),
@@ -225,8 +258,9 @@ export class Ra3Diagnostics {
value: string,
attr: { valueStart: number; valueEnd: number },
document: vscode.TextDocument,
idx: ModIndex,
idx: ModIndex | null,
diags: vscode.Diagnostic[],
provisional: boolean,
): void {
if (!value) return;
const range = new vscode.Range(
@@ -238,13 +272,19 @@ export class Ra3Diagnostics {
const defineRe = /\$([A-Za-z_][A-Za-z0-9_]*)/g;
let m: RegExpExecArray | null;
while ((m = defineRe.exec(value)) !== null) {
if (!idx.defines.has(m[1].toLowerCase())) {
if (
idx &&
!(idx.local?.defines.has(m[1].toLowerCase()) ??
idx.defines.has(m[1].toLowerCase()))
) {
const code = provisional ? "undefined-define-indexing" : "undefined-define";
diags.push(
this.diag(
range,
`Undefined define "$${m[1]}"`,
`Undefined define "$${m[1]}"` +
(provisional ? " (index incomplete — may be a false positive)" : ""),
vscode.DiagnosticSeverity.Warning,
"undefined-define",
code,
),
);
}
@@ -253,10 +293,13 @@ export class Ra3Diagnostics {
if (value.startsWith("$") || value.startsWith("=")) return;
const severity = this.ws.settings.reportUnresolvedReferences;
if (severity === "none") return;
if (!idx) return;
if (!isReferenceAttributeOfType(elType, attrName)) return;
const targets = resolveReferenceTargetsForType(idx, elType, attrName, value);
if (targets.length) return;
const anyDef = idx.assetsById.has(value.toLowerCase());
const anyDef =
(idx.local?.assetsById.has(value.toLowerCase()) ?? false) ||
idx.assetsById.has(value.toLowerCase());
const attrRef = model
.attributesOfType(elType)
.find((a) => a.name === attrName);
@@ -265,16 +308,20 @@ export class Ra3Diagnostics {
: attrRef?.isRef
? "of the expected declared type"
: "matching";
const code = provisional ? "unresolved-reference-indexing" : "unresolved-reference";
const baseMessage = anyDef
? `Reference "${value}" has no definition ${expected} (ids with the same name exist for other types)`
: `Unresolved reference "${value}" (not found in the current index)`;
diags.push(
this.diag(
range,
anyDef
? `Reference "${value}" has no definition ${expected} (ids with the same name exist for other types)`
: `Unresolved reference "${value}" (not found in the current index)`,
provisional
? `${baseMessage} (index incomplete — may be a false positive)`
: baseMessage,
severity === "warning"
? vscode.DiagnosticSeverity.Warning
: vscode.DiagnosticSeverity.Information,
"unresolved-reference",
code,
),
);
}
@@ -282,7 +329,7 @@ export class Ra3Diagnostics {
private checkInclude(
el: XmlElement,
document: vscode.TextDocument,
idx: ModIndex,
idx: ModIndex | null,
diags: vscode.Diagnostic[],
): void {
const typeAttr = el.attrs.find((a) => a.name === "type");
@@ -301,12 +348,18 @@ export class Ra3Diagnostics {
);
}
if (!sourceAttr?.hasValue) return;
const searchPaths = idx
? buildSearchPaths(idx.sdkDir, idx.projectDir)
: this.ws.searchPaths();
if (!searchPaths) return;
const resolved = resolveSource(
sourceAttr.value,
dirname(document.uri.fsPath),
buildSearchPaths(idx.sdkDir, idx.projectDir),
searchPaths,
);
if (!resolved.path && !idx.sourceCandidates.some((c) => c.source === sourceAttr.value)) {
const candidateHit =
idx?.sourceCandidates.some((c) => c.source === sourceAttr.value) ?? false;
if (!resolved.path && !candidateHit) {
diags.push(
this.diag(
new vscode.Range(
+62 -11
View File
@@ -1,13 +1,19 @@
import * as vscode from "vscode";
import { findElementAt, parseXml } from "../language/xmlParser";
import { findElementAt } from "../language/xmlParser";
import { resolveElementType } from "../language/typeContext";
import * as model from "../model/schemaModel";
import type { ModWorkspace } from "../workspace";
import type { ModIndex } from "../indexer/types";
import {
isLocalReferenceAttribute,
isReferenceAttributeOfType,
resolveReferenceTargetsForType,
} from "../indexer/refs";
import {
findContainingGameObject,
findLocalId,
type LogicalElement,
} from "../indexer/logicalTree";
import { scopePathKey, type DocumentScope } from "../indexer/localScope";
import { dirname } from "node:path";
import { buildSearchPaths, resolveSource } from "../indexer/includeResolver";
@@ -19,9 +25,10 @@ export class Ra3HoverProvider implements vscode.HoverProvider {
position: vscode.Position,
_token: vscode.CancellationToken,
): Promise<vscode.Hover | null> {
const text = document.getText();
if (!this.ws.isRa3Workspace()) return null;
const offset = document.offsetAt(position);
const doc = parseXml(text);
const scope = await this.ws.getScope(document);
const doc = scope.expanded;
const el = findElementAt(doc, offset);
if (!el) return null;
const elType = resolveElementType(el);
@@ -35,7 +42,7 @@ export class Ra3HoverProvider implements vscode.HoverProvider {
// Attribute value.
for (const attr of el.attrs) {
if (attr.hasValue && offset >= attr.valueStart && offset <= attr.valueEnd) {
return this.valueHover(el, elType, attr.name, attr.value, document, this.ws.index);
return this.valueHover(el, elType, attr.name, attr.value, document, scope);
}
}
// Element name.
@@ -116,14 +123,17 @@ export class Ra3HoverProvider implements vscode.HoverProvider {
attrName: string,
value: string,
document: vscode.TextDocument,
idx: ModIndex | null,
scope: DocumentScope,
): vscode.Hover | null {
const idx = scope.merged;
const md = new vscode.MarkdownString();
// $DEFINE reference.
const defineMatch = /\$([A-Za-z_][A-Za-z0-9_]*)/.exec(value);
if (defineMatch && idx) {
const defs = idx.defines.get(defineMatch[1].toLowerCase());
const defs =
idx.local?.defines.get(defineMatch[1].toLowerCase()) ??
idx.defines.get(defineMatch[1].toLowerCase());
if (defs?.length) {
const d = defs[0];
md.appendMarkdown(`**Define** \`$${d.name}\` \n`);
@@ -139,11 +149,14 @@ export class Ra3HoverProvider implements vscode.HoverProvider {
(el.name === "Include" && attrName === "source") ||
(el.name === "xi:include" && attrName === "href")
) {
const resolved = idx
const searchPaths = idx
? buildSearchPaths(idx.sdkDir, idx.projectDir)
: this.ws.searchPaths();
const resolved = searchPaths
? resolveSource(
value,
dirname(document.uri.fsPath),
buildSearchPaths(idx.sdkDir, idx.projectDir),
searchPaths,
).path
: null;
if (resolved) {
@@ -161,8 +174,26 @@ export class Ra3HoverProvider implements vscode.HoverProvider {
return new vscode.Hover(md);
}
// Pipeline-local (Poid) references: resolve inside the enclosing
// GameObject's logical subtree (including xi:include targets).
if (
isLocalReferenceAttribute(elType, attrName) &&
attrName.toLowerCase() !== "id"
) {
return this.localIdHover(scope, el as LogicalElement, value, document);
}
// Asset reference / inheritFrom.
if (idx) {
if (!idx) {
if (isReferenceAttributeOfType(elType, attrName)) {
md.appendMarkdown(
"Index is still building — references cannot be resolved yet.",
);
return new vscode.Hover(md);
}
return null;
}
{
if (!isReferenceAttributeOfType(elType, attrName)) return null;
const targets = resolveReferenceTargetsForType(idx, elType, attrName, value);
if (targets.length) {
@@ -191,8 +222,28 @@ export class Ra3HoverProvider implements vscode.HoverProvider {
);
return new vscode.Hover(md);
}
}
return null;
private localIdHover(
scope: DocumentScope,
el: LogicalElement,
value: string,
document: vscode.TextDocument,
): vscode.Hover | null {
const root = findContainingGameObject(el);
if (!root) return null;
const target = findLocalId(root, value);
if (!target) return null;
const idAttr = target.attrs.find((a) => a.name === "id");
if (!idAttr?.hasValue) return null;
const lineMap = scope.lineMaps.get(scopePathKey(target.sourceFile));
const line = lineMap ? lineMap.positionAt(idAttr.valueStart).line + 1 : 0;
const md = new vscode.MarkdownString();
md.appendMarkdown(`**Local pipeline id** \`${value}\` \n`);
md.appendCodeblock(`<${target.name}>`);
const rel = relativePath(document, target.sourceFile);
md.appendMarkdown(`Defined in \`${rel}:${line}\``);
return new vscode.Hover(md);
}
}
+113 -16
View File
@@ -7,7 +7,16 @@ import {
resolveSource,
type SearchPaths,
} from "../indexer/includeResolver";
import { resolveReferenceTargetsForType } from "../indexer/refs";
import {
isLocalReferenceAttribute,
resolveReferenceTargetsForType,
} from "../indexer/refs";
import {
findContainingGameObject,
findLocalId,
type LogicalElement,
} from "../indexer/logicalTree";
import { scopePathKey, type DocumentScope } from "../indexer/localScope";
import type { ModWorkspace } from "../workspace";
import type { AssetDef, ModIndex } from "../indexer/types";
@@ -25,11 +34,11 @@ export class Ra3DefinitionProvider implements vscode.DefinitionProvider {
position: vscode.Position,
_token: vscode.CancellationToken,
): Promise<vscode.Location | vscode.Location[] | null> {
const idx = this.ws.index;
if (!idx) return null;
const text = document.getText();
if (!this.ws.isRa3Workspace()) return null;
const scope = await this.ws.getScope(document);
const idx = scope.merged;
const offset = document.offsetAt(position);
const doc = parseXml(text);
const doc = scope.expanded;
const el = findElementAt(doc, offset);
if (!el) return null;
const elType = resolveElementType(el);
@@ -46,17 +55,31 @@ export class Ra3DefinitionProvider implements vscode.DefinitionProvider {
(el.name === "Include" && nameLower === "source") ||
(el.name === "include" && nameLower === "href")
) {
const resolved =
resolveSource(value, dirname(document.uri.fsPath), searchPathsFor(idx)).path ??
idx.sourceCandidates.find((c) => c.source === value)?.path ??
null;
return resolved
? new vscode.Location(vscode.Uri.file(resolved), new vscode.Position(0, 0))
const searchPaths = idx ? searchPathsFor(idx) : this.ws.searchPaths();
const resolved = searchPaths
? resolveSource(value, dirname(document.uri.fsPath), searchPaths).path
: null;
const fallback = idx?.sourceCandidates.find((c) => c.source === value)?.path;
const target = resolved ?? fallback ?? null;
return target
? new vscode.Location(vscode.Uri.file(target), new vscode.Position(0, 0))
: null;
}
// Asset reference / inheritFrom (filtered by the attribute's ref type).
if (!idx) return null;
if (value && !value.startsWith("$")) {
if (
isLocalReferenceAttribute(elType, attr.name) &&
nameLower !== "id"
) {
const local = this.localIdLocation(
scope,
el as LogicalElement,
value,
);
if (local) return local;
}
let targets = resolveReferenceTargetsForType(idx, elType, attr.name, value);
if (!targets.length) return null;
if (
@@ -67,13 +90,40 @@ export class Ra3DefinitionProvider implements vscode.DefinitionProvider {
}
const locations: vscode.Location[] = [];
for (const { def } of targets.slice(0, 8)) {
const loc = await assetDefLocation(this.ws, def, idx);
const loc = await assetDefLocation(this.ws, def, idx, scope, document);
if (loc) locations.push(loc);
}
return locations.length ? locations : null;
}
return null;
}
private localIdLocation(
scope: DocumentScope,
el: LogicalElement,
value: string,
): vscode.Location | null {
const root = findContainingGameObject(el);
if (!root) return null;
const target = findLocalId(root, value);
if (!target) return null;
const idAttr = target.attrs.find((a) => a.name === "id");
if (!idAttr?.hasValue) return null;
const lineMap = scope.lineMaps.get(scopePathKey(target.sourceFile));
if (!lineMap) {
return new vscode.Location(
vscode.Uri.file(target.sourceFile),
new vscode.Position(0, 0),
);
}
return new vscode.Location(
vscode.Uri.file(target.sourceFile),
new vscode.Range(
toVscodePosition(lineMap.positionAt(idAttr.valueStart)),
toVscodePosition(lineMap.positionAt(idAttr.valueEnd)),
),
);
}
}
/**
@@ -85,6 +135,8 @@ async function assetDefLocation(
ws: ModWorkspace,
def: AssetDef,
idx: ModIndex,
scope: DocumentScope,
currentDocument: vscode.TextDocument,
): Promise<vscode.Location | null> {
if (def.origin === "manifest") {
const src = def.manifestSource;
@@ -100,6 +152,11 @@ async function assetDefLocation(
return null;
}
if (scopePathKey(def.file) === scopePathKey(currentDocument.uri.fsPath)) {
const precise = locationInCurrentDocument(scope, def.id, currentDocument);
if (precise) return precise;
}
return (
(await locationInDocument(ws, def.file, def.id)) ??
new vscode.Location(
@@ -109,6 +166,36 @@ async function assetDefLocation(
);
}
function locationInCurrentDocument(
scope: DocumentScope,
id: string,
document: vscode.TextDocument,
): vscode.Location | null {
const el = scope.parse.elements.find((e) =>
e.attrs.some(
(a) => a.name === "id" && a.value.toLowerCase() === id.toLowerCase(),
),
);
if (!el) return null;
const idAttr = el.attrs.find((a) => a.name === "id");
if (idAttr?.hasValue) {
return new vscode.Location(
document.uri,
new vscode.Range(
document.positionAt(idAttr.valueStart),
document.positionAt(idAttr.valueEnd),
),
);
}
return new vscode.Location(
document.uri,
new vscode.Range(
document.positionAt(el.start),
document.positionAt(el.startTagEnd),
),
);
}
/**
* Finds the precise range of an asset definition inside an XML file: the id
* attribute value when present, otherwise the element start tag.
@@ -118,7 +205,9 @@ async function locationInDocument(
file: string,
id: string,
): Promise<vscode.Location | null> {
const parsed = await ws.indexer?.readDocument(file);
// readDom (not readDocument) guarantees a DOM even when the compact
// records cache already has an entry for the file.
const parsed = await ws.indexer?.readDom(file);
if (parsed?.parse && parsed.lineMap) {
const el = parsed.parse.elements.find(
(e) =>
@@ -156,12 +245,15 @@ function toVscodePosition(p: { line: number; character: number }): vscode.Positi
// ── Find all references ─────────────────────────────────────────────
export class Ra3ReferenceProvider implements vscode.ReferenceProvider {
constructor(private ws: ModWorkspace) {}
async provideReferences(
document: vscode.TextDocument,
position: vscode.Position,
_context: vscode.ReferenceContext,
_token: vscode.CancellationToken,
): Promise<vscode.Location[] | null> {
if (!this.ws.isRa3Workspace()) return null;
const text = document.getText();
const offset = document.offsetAt(position);
const doc = parseXml(text);
@@ -201,8 +293,10 @@ export class Ra3DocumentLinkProvider implements vscode.DocumentLinkProvider {
document: vscode.TextDocument,
_token: vscode.CancellationToken,
): Promise<vscode.DocumentLink[]> {
if (!this.ws.isRa3Workspace()) return [];
const idx = this.ws.index;
if (!idx) return [];
const searchPaths = idx ? searchPathsFor(idx) : this.ws.searchPaths();
if (!searchPaths) return [];
const text = document.getText();
const doc = parseXml(text);
const links: vscode.DocumentLink[] = [];
@@ -214,9 +308,9 @@ export class Ra3DocumentLinkProvider implements vscode.DocumentLinkProvider {
resolveSource(
srcAttr.value,
dirname(document.uri.fsPath),
searchPathsFor(idx),
searchPaths,
).path ??
idx.sourceCandidates.find((c) => c.source === srcAttr.value)?.path ??
idx?.sourceCandidates.find((c) => c.source === srcAttr.value)?.path ??
null;
if (!target) continue;
links.push(
@@ -236,10 +330,13 @@ export class Ra3DocumentLinkProvider implements vscode.DocumentLinkProvider {
// ── Document symbols (outline) ──────────────────────────────────────
export class Ra3DocumentSymbolProvider implements vscode.DocumentSymbolProvider {
constructor(private ws: ModWorkspace) {}
async provideDocumentSymbols(
document: vscode.TextDocument,
_token: vscode.CancellationToken,
): Promise<vscode.DocumentSymbol[]> {
if (!this.ws.isRa3Workspace()) return [];
const text = document.getText();
const doc = parseXml(text);
const root = doc.root;
+6
View File
@@ -1,6 +1,7 @@
import * as vscode from "vscode";
import { parseXml } from "../language/xmlParser";
import { buildSemanticTokenRanges } from "../language/semanticTokens";
import type { ModWorkspace } from "../workspace";
const TOKEN_TYPES = ["type", "property", "string"] as const;
@@ -20,10 +21,15 @@ export const RA3_SEMANTIC_TOKENS_LEGEND = new vscode.SemanticTokensLegend([
export class Ra3SemanticTokensProvider
implements vscode.DocumentSemanticTokensProvider
{
constructor(private ws: ModWorkspace) {}
async provideDocumentSemanticTokens(
document: vscode.TextDocument,
_token: vscode.CancellationToken,
): Promise<vscode.SemanticTokens> {
if (!this.ws.isRa3Workspace()) {
return new vscode.SemanticTokens(new Uint32Array(0));
}
const text = document.getText();
const doc = parseXml(text);
if (doc.errors.length === 0) {