first commit

This commit is contained in:
2026-08-01 14:00:17 +02:00
commit 130f8b4c1d
60 changed files with 9324 additions and 0 deletions
+376
View File
@@ -0,0 +1,376 @@
import * as vscode from "vscode";
import { parseXml, type XmlElement } from "../language/xmlParser";
import { analyzeContext, type CompletionContext } from "../language/context";
import { resolveElementType } from "../language/typeContext";
import * as model from "../model/schemaModel";
import type { ModWorkspace } from "../workspace";
import type { ModIndex, AssetDef } from "../indexer/types";
const MAX_VALUE_ITEMS = 400;
export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
constructor(private ws: ModWorkspace) {}
async provideCompletionItems(
document: vscode.TextDocument,
position: vscode.Position,
_token: vscode.CancellationToken,
): Promise<vscode.CompletionItem[]> {
const text = document.getText();
const offset = document.offsetAt(position);
const doc = parseXml(text);
const ctx = analyzeContext(doc, text, offset);
const idx = this.ws.index;
switch (ctx.kind) {
case "element-name":
return this.elementNameItems(ctx, document, position);
case "attribute-name":
return this.attributeNameItems(ctx, document, position);
case "attribute-value":
return idx ? this.valueItems(ctx, document, position, idx) : [];
case "content":
return idx ? this.contentItems(ctx, document, position, idx) : [];
default:
return [];
}
}
// ── Element name ──────────────────────────────────────────────────
private elementNameItems(
ctx: CompletionContext,
document: vscode.TextDocument,
position: vscode.Position,
): vscode.CompletionItem[] {
if (ctx.closing) return [];
const parent = ctx.element?.parent ?? null;
const names = this.childrenOf(parent);
if (!names.length) return [];
const start = ctx.element
? document.offsetAt(
document.positionAt(ctx.element.start + (ctx.closing ? 2 : 1)),
)
: document.offsetAt(position);
const range = new vscode.Range(document.positionAt(start), position);
const items: vscode.CompletionItem[] = [];
for (const child of names) {
const item = new vscode.CompletionItem(child.name, vscode.CompletionItemKind.Field);
item.range = range;
const type = model.elementTypeName(child.name);
const info = type ? model.typeInfo(type) : undefined;
const docText =
child.doc ||
(info?.kind === "complex" ? info.doc : "") ||
(type ? `Type: ${type}` : "");
item.documentation = docText ? new vscode.MarkdownString(docText) : undefined;
item.detail = type ? `RA3 XML · ${type}` : "RA3 XML";
item.insertText = this.elementSnippet(child.name, type);
items.push(item);
}
return items;
}
private childrenOf(
parent: XmlElement | null,
): { name: string; type: string | null; doc: string }[] {
if (!parent) {
return [
{ name: "AssetDeclaration", type: null, doc: "Root element of every RA3 asset file" },
];
}
const parentType = resolveElementType(parent);
const children = parentType
? model.childrenOfType(parentType)
: model.childrenOfElement(parent.name);
if (children.length) {
return children.map((c) => ({ name: c.name, type: c.type, doc: c.doc }));
}
return [];
}
private elementSnippet(name: string, type: string | null): vscode.SnippetString {
if (model.isTopLevelElement(name)) {
return new vscode.SnippetString(`<${name} id="$1">\n\t$0\n</${name}>`);
}
const info = type ? model.typeInfo(type) : undefined;
const hasChildren = info?.kind === "complex" && info.children.length > 0;
if (hasChildren) {
return new vscode.SnippetString(`<${name}>\n\t$0\n</${name}>`);
}
return new vscode.SnippetString(`<${name} />`);
}
// ── Attribute name ────────────────────────────────────────────────
private attributeNameItems(
ctx: CompletionContext,
document: vscode.TextDocument,
position: vscode.Position,
): vscode.CompletionItem[] {
const el = ctx.element;
if (!el) return [];
const elType = resolveElementType(el);
const attrs = model.attributesOfType(elType);
const used = new Set(ctx.existingAttrs.map((a) => a.toLowerCase()));
const items: vscode.CompletionItem[] = [];
const wordStart = findAttributeWordStart(document, position, el);
const range = new vscode.Range(document.positionAt(wordStart), position);
for (const attr of attrs) {
if (used.has(attr.name.toLowerCase())) continue;
const item = new vscode.CompletionItem(attr.name, vscode.CompletionItemKind.Property);
item.range = range;
item.sortText = attr.required ? "0" + attr.name : "1" + attr.name;
const md = new vscode.MarkdownString();
if (attr.doc) md.appendMarkdown(attr.doc + "\n\n");
if (attr.required) md.appendMarkdown(`**Required** \n`);
if (attr.refType) md.appendMarkdown(`References: \`${attr.refType}\` \n`);
if (attr.enumValues.length)
md.appendMarkdown(`Values: ${attr.enumValues.join(", ")} \n`);
if (attr.default != null) md.appendMarkdown(`Default: \`${attr.default}\` \n`);
md.appendMarkdown(`Type: \`${attr.type ?? "string"}\``);
item.documentation = md;
if (attr.required) {
item.insertText = attr.name === "id" ? 'id="$1"' : `${attr.name}="$1"`;
} else {
item.insertText = `${attr.name}="$1"`;
}
item.command = {
command: "editor.action.triggerSuggest",
title: "Suggest attribute values",
};
items.push(item);
}
// Namespace/instance helpers.
if (!used.has("xai:joinaction")) {
const j = new vscode.CompletionItem("xai:joinAction", vscode.CompletionItemKind.Property);
j.range = range;
j.insertText = 'xai:joinAction="$1"';
j.detail = "Instance join action";
j.documentation = new vscode.MarkdownString(
"Controls how this element merges with the inherited definition: `Replace` or `Remove`.",
);
items.push(j);
}
if (!used.has("xmlns:xai")) {
const ns = new vscode.CompletionItem("xmlns:xai", vscode.CompletionItemKind.Property);
ns.range = range;
ns.insertText = 'xmlns:xai="uri:ea.com:eala:asset:instance"';
ns.detail = "xai namespace";
items.push(ns);
}
return items;
}
// ── Attribute value ───────────────────────────────────────────────
private valueItems(
ctx: CompletionContext,
document: vscode.TextDocument,
position: vscode.Position,
idx: ModIndex,
): vscode.CompletionItem[] {
const el = ctx.element;
const attr = ctx.attr;
if (!el || !attr) return [];
const prefix = ctx.valuePrefix;
const endOffset = attr.quoteEnd > attr.valueEnd ? attr.valueEnd : document.offsetAt(position);
const valueRange = new vscode.Range(
document.positionAt(attr.valueStart),
document.positionAt(Math.max(attr.valueStart, endOffset)),
);
const make = (
label: string,
kind: vscode.CompletionItemKind,
detail: string,
doc?: string,
) => {
const item = new vscode.CompletionItem(label, kind);
item.range = valueRange;
item.insertText = label;
item.detail = detail;
if (doc) item.documentation = new vscode.MarkdownString(doc);
return item;
};
const isInclude = el.name === "Include";
const attrName = attr.name.toLowerCase();
// Include type / source
if (isInclude && attrName === "type") {
return ["reference", "instance", "all"].map((v) =>
make(v, vscode.CompletionItemKind.EnumMember, "Include type"),
);
}
if (isInclude && attrName === "source") {
return this.includeSourceItems(idx, prefix, make);
}
if (attrName === "xai:joinaction" || attrName === "joinaction") {
return ["Replace", "Remove"].map((v) =>
make(v, vscode.CompletionItemKind.EnumMember, "xai:joinAction"),
);
}
const elType = resolveElementType(el);
const attrInfo = model
.attributesOfType(elType)
.find((a) => a.name.toLowerCase() === attrName);
// inheritFrom: same element type first, then everything.
if (attrName === "inheritfrom") {
return this.assetIdItems(idx, el.name, null, prefix, make);
}
if (attrInfo?.refType) {
return this.assetIdItems(idx, null, attrInfo.refType, prefix, make);
}
if (attrInfo?.enumValues?.length) {
return attrInfo.enumValues
.filter((v) => v.toLowerCase().startsWith(prefix.toLowerCase()))
.map((v) => make(v, vscode.CompletionItemKind.EnumMember, attrInfo.type ?? "enum"));
}
if (attrInfo?.isBoolean) {
return ["true", "false"]
.filter((v) => v.startsWith(prefix.toLowerCase()))
.map((v) => make(v, vscode.CompletionItemKind.Value, "boolean"));
}
if (attrInfo?.allowsDefine) {
return this.defineItems(idx, prefix, make);
}
return [];
}
private includeSourceItems(
idx: ModIndex,
prefix: string,
make: (label: string, kind: vscode.CompletionItemKind, detail: string, doc?: string) => vscode.CompletionItem,
): vscode.CompletionItem[] {
const lower = prefix.toLowerCase();
const candidates = idx.sourceCandidates
.filter((c) => c.source.toLowerCase().includes(lower))
.slice(0, MAX_VALUE_ITEMS);
const priority: Record<string, number> = { "": 0, DATA: 1, ART: 2, AUDIO: 3 };
candidates.sort(
(a, b) =>
(priority[a.prefix ?? ""] ?? 4) - (priority[b.prefix ?? ""] ?? 4) ||
a.source.localeCompare(b.source),
);
return candidates.map((c) => {
const item = make(c.source, vscode.CompletionItemKind.File, "Include source");
item.detail = c.path;
item.documentation = new vscode.MarkdownString(
`\`${c.prefix ?? "relative"}\` · ${c.path}`,
);
return item;
});
}
private assetIdItems(
idx: ModIndex,
selfType: string | null,
refType: string | null,
prefix: string,
make: (label: string, kind: vscode.CompletionItemKind, detail: string, doc?: string) => vscode.CompletionItem,
): vscode.CompletionItem[] {
const lower = prefix.toLowerCase();
const scored: { def: AssetDef; score: number }[] = [];
const consider = (def: AssetDef) => {
if (!def.id.toLowerCase().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;
scored.push({ def, score });
};
const targetType = selfType ?? refType;
if (!targetType) {
for (const list of idx.assetsById.values()) for (const d of list) consider(d);
} else {
for (const [typeName, byId] of idx.assets) {
if (!model.isAssignableTo(typeName, targetType)) continue;
for (const list of byId.values()) for (const d of list) consider(d);
}
}
scored.sort((a, b) => a.score - b.score || a.def.id.localeCompare(b.def.id));
return scored.slice(0, MAX_VALUE_ITEMS).map(({ def }) => {
const origin = def.origin === "manifest" ? `manifest (${def.manifestSource ?? ""})` : def.origin;
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}`);
return make(def.id, vscode.CompletionItemKind.Value, `${def.type} · ${origin}`, doc.value);
});
}
private defineItems(
idx: ModIndex,
prefix: string,
make: (label: string, kind: vscode.CompletionItemKind, detail: string, doc?: string) => vscode.CompletionItem,
): 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);
}
return items.slice(0, MAX_VALUE_ITEMS);
}
// ── Element content ───────────────────────────────────────────────
private contentItems(
ctx: CompletionContext,
_document: vscode.TextDocument,
_position: vscode.Position,
_idx: ModIndex,
): vscode.CompletionItem[] {
const el = ctx.element;
if (!el) return [];
// Reuse element-name suggestions with a plain replacement range.
const elType = resolveElementType(el);
const names = elType ? model.childrenOfType(elType) : model.childrenOfElement(el.name);
const items: vscode.CompletionItem[] = [];
for (const child of names) {
const item = new vscode.CompletionItem(child.name, vscode.CompletionItemKind.Field);
item.insertText = this.elementSnippet(child.name, child.type);
const type = child.type;
const info = type ? model.typeInfo(type) : undefined;
item.detail = type ? `RA3 XML · ${type}` : "RA3 XML";
const doc = child.doc || (info?.kind === "complex" ? info.doc : "");
if (doc) item.documentation = new vscode.MarkdownString(doc);
items.push(item);
}
return items;
}
}
function findAttributeWordStart(
document: vscode.TextDocument,
position: vscode.Position,
el: { start: number },
): number {
const offset = document.offsetAt(position);
const tagStart = el.start;
let i = offset;
const text = document.getText();
while (i > tagStart) {
const c = text[i - 1];
if (/[\s=<>"/]/.test(c)) break;
i--;
}
return i;
}
+339
View File
@@ -0,0 +1,339 @@
import * as vscode from "vscode";
import { dirname } from "node:path";
import { LineMap, parseXml, type XmlElement } from "../language/xmlParser";
import { resolveElementType } from "../language/typeContext";
import { resolveSource, buildSearchPaths } from "../indexer/includeResolver";
import * as model from "../model/schemaModel";
import type { ModWorkspace } from "../workspace";
import type { ModIndex } from "../indexer/types";
import {
isReferenceAttributeOfType,
resolveReferenceTargetsForType,
} from "../indexer/refs";
export class Ra3Diagnostics {
private collection: vscode.DiagnosticCollection;
constructor(private ws: ModWorkspace) {
this.collection = vscode.languages.createDiagnosticCollection("ra3modxml");
}
async update(document: vscode.TextDocument): Promise<void> {
const idx = this.ws.index;
if (!idx) {
this.collection.set(document.uri, []);
return;
}
const text = document.getText();
const lineMap = new LineMap(text);
const doc = parseXml(text);
const diags: vscode.Diagnostic[] = [];
for (const err of doc.errors) {
diags.push(
this.diag(
new vscode.Range(
new vscode.Position(err.line, err.character),
new vscode.Position(err.line, err.character + 1),
),
err.message,
vscode.DiagnosticSeverity.Error,
"xml-syntax",
),
);
}
if (doc.root) {
this.checkElements(doc.root, doc, lineMap, idx, document, diags);
}
this.collection.set(document.uri, diags);
}
clear(uri: vscode.Uri): void {
this.collection.delete(uri);
}
dispose(): void {
this.collection.dispose();
}
private checkElements(
root: XmlElement,
doc: { elements: XmlElement[] },
lineMap: LineMap,
idx: ModIndex,
document: vscode.TextDocument,
diags: vscode.Diagnostic[],
): void {
const settings = this.ws.settings;
const fileDuplicates = new Map<string, { line: number }>();
for (const el of doc.elements) {
const local = localName(el.name);
const isTopLevel = el.parent === root && !["Tags", "Includes", "Defines"].includes(local);
const range = tagRange(document, el);
// Top-level assets must have an id.
if (isTopLevel) {
const idAttr = el.attrs.find((a) => a.name === "id");
if (!idAttr || !idAttr.value) {
diags.push(
this.diag(
range,
`Top-level asset <${local}> requires an id attribute`,
vscode.DiagnosticSeverity.Error,
"missing-id",
),
);
} else {
const key = `${local.toLowerCase()}:${idAttr.value.toLowerCase()}`;
const prev = fileDuplicates.get(key);
if (prev) {
const where = new vscode.Range(
document.positionAt(idAttr.valueStart),
document.positionAt(idAttr.valueEnd),
);
diags.push(
this.diag(
where,
`Duplicate id "${idAttr.value}" for <${local}> (also defined on line ${prev.line})`,
vscode.DiagnosticSeverity.Error,
"duplicate-id",
),
);
} else {
fileDuplicates.set(key, { line: lineMap.positionAt(el.start).line + 1 });
}
this.checkCrossFileDuplicate(
local,
idAttr.value,
document,
idx,
diags,
);
}
}
// Unknown element.
if (settings.diagnoseUnknownElements && !el.name.startsWith("xi:")) {
const knownType = model.elementTypeName(local);
if (!knownType) {
diags.push(
this.diag(
range,
`Unknown element <${local}> (not in the RA3 XSD model)`,
vscode.DiagnosticSeverity.Warning,
"unknown-element",
),
);
}
}
// Attributes.
const elType = resolveElementType(el);
const knownAttrs = model.attributesOfType(elType);
const knownNames = new Set(knownAttrs.map((a) => a.name));
for (const attr of el.attrs) {
const aName = attr.name;
if (aName.startsWith("xmlns") || aName.startsWith("xai:") || aName.startsWith("xi:")) {
continue;
}
if (settings.diagnoseUnknownElements && !knownNames.has(aName)) {
diags.push(
this.diag(
new vscode.Range(
document.positionAt(attr.nameStart),
document.positionAt(attr.nameEnd),
),
`Unknown attribute "${aName}" for <${local}>`,
vscode.DiagnosticSeverity.Warning,
"unknown-attribute",
),
);
}
if (!attr.hasValue) continue;
this.checkValueReferences(
elType,
attr.name,
attr.value,
attr,
document,
idx,
diags,
);
}
// Include-specific checks.
if (local === "Include") {
this.checkInclude(el, document, idx, diags);
}
}
}
private checkCrossFileDuplicate(
type: string,
id: string,
document: vscode.TextDocument,
idx: ModIndex,
diags: vscode.Diagnostic[],
): void {
const byType = idx.assets.get(type);
const defs = byType?.get(id.toLowerCase());
if (!defs || defs.length < 2) return;
const self = defs.filter(
(d) =>
d.origin === "project" &&
!d.viaInstance &&
d.file.toLowerCase() === document.uri.fsPath.toLowerCase(),
);
if (!self.length) return;
const others = defs.filter(
(d) =>
d.origin === "project" &&
!d.viaInstance &&
d.file.toLowerCase() !== document.uri.fsPath.toLowerCase() &&
d.stream === self[0].stream,
);
for (const other of others) {
const range = self[0].line > 0
? new vscode.Range(new vscode.Position(self[0].line - 1, 0), new vscode.Position(self[0].line - 1, 1))
: new vscode.Range(0, 0, 0, 1);
diags.push(
this.diag(
range,
`Duplicate id "${id}" for <${type}> (also defined in ${other.file})`,
vscode.DiagnosticSeverity.Error,
"duplicate-id",
),
);
}
}
private checkValueReferences(
elType: string | null,
attrName: string,
value: string,
attr: { valueStart: number; valueEnd: number },
document: vscode.TextDocument,
idx: ModIndex,
diags: vscode.Diagnostic[],
): void {
if (!value) return;
const range = new vscode.Range(
document.positionAt(attr.valueStart),
document.positionAt(attr.valueEnd),
);
// Undefined $DEFINE references.
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())) {
diags.push(
this.diag(
range,
`Undefined define "$${m[1]}"`,
vscode.DiagnosticSeverity.Warning,
"undefined-define",
),
);
}
}
if (value.startsWith("$") || value.startsWith("=")) return;
const severity = this.ws.settings.reportUnresolvedReferences;
if (severity === "none") 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 attrRef = model
.attributesOfType(elType)
.find((a) => a.name === attrName);
const expected = attrRef?.refType
? `of type \`${attrRef.refType}\``
: attrRef?.isRef
? "of the expected declared type"
: "matching";
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)`,
severity === "warning"
? vscode.DiagnosticSeverity.Warning
: vscode.DiagnosticSeverity.Information,
"unresolved-reference",
),
);
}
private checkInclude(
el: XmlElement,
document: vscode.TextDocument,
idx: ModIndex,
diags: vscode.Diagnostic[],
): void {
const typeAttr = el.attrs.find((a) => a.name === "type");
const sourceAttr = el.attrs.find((a) => a.name === "source");
if (typeAttr?.hasValue && !["reference", "instance", "all"].includes(typeAttr.value)) {
diags.push(
this.diag(
new vscode.Range(
document.positionAt(typeAttr.valueStart),
document.positionAt(typeAttr.valueEnd),
),
`Invalid Include type "${typeAttr.value}" (expected reference, instance or all)`,
vscode.DiagnosticSeverity.Error,
"include-type",
),
);
}
if (!sourceAttr?.hasValue) return;
const resolved = resolveSource(
sourceAttr.value,
dirname(document.uri.fsPath),
buildSearchPaths(idx.sdkDir, idx.projectDir),
);
if (!resolved.path && !idx.sourceCandidates.some((c) => c.source === sourceAttr.value)) {
diags.push(
this.diag(
new vscode.Range(
document.positionAt(sourceAttr.valueStart),
document.positionAt(sourceAttr.valueEnd),
),
`Include target not found: ${sourceAttr.value}`,
vscode.DiagnosticSeverity.Warning,
"include-not-found",
),
);
}
}
private diag(
range: vscode.Range,
message: string,
severity: vscode.DiagnosticSeverity,
code: string,
): vscode.Diagnostic {
const d = new vscode.Diagnostic(range, message, severity);
d.code = code;
d.source = "RA3 Mod XML";
return d;
}
}
function localName(tag: string): string {
const idx = tag.lastIndexOf(":");
return idx >= 0 ? tag.slice(idx + 1) : tag;
}
function tagRange(document: vscode.TextDocument, el: XmlElement): vscode.Range {
return new vscode.Range(
document.positionAt(el.start),
document.positionAt(el.startTagEnd),
);
}
+185
View File
@@ -0,0 +1,185 @@
import * as vscode from "vscode";
import { findElementAt, parseXml } 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 {
isReferenceAttributeOfType,
resolveReferenceTargetsForType,
} from "../indexer/refs";
import { dirname } from "node:path";
import { buildSearchPaths, resolveSource } from "../indexer/includeResolver";
export class Ra3HoverProvider implements vscode.HoverProvider {
constructor(private ws: ModWorkspace) {}
async provideHover(
document: vscode.TextDocument,
position: vscode.Position,
_token: vscode.CancellationToken,
): Promise<vscode.Hover | null> {
const text = document.getText();
const offset = document.offsetAt(position);
const doc = parseXml(text);
const el = findElementAt(doc, offset);
if (!el) return null;
const elType = resolveElementType(el);
// Attribute name.
for (const attr of el.attrs) {
if (offset >= attr.nameStart && offset <= attr.nameEnd) {
return this.attributeHover(elType, attr.name);
}
}
// 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);
}
}
// Element name.
const nameStart = el.start + 1;
if (offset >= nameStart && offset <= nameStart + el.name.length) {
return this.elementHover(el.name);
}
return null;
}
private elementHover(name: string): vscode.Hover | null {
const type = model.elementTypeName(name);
const info = type ? model.typeInfo(type) : undefined;
const md = new vscode.MarkdownString();
md.appendCodeblock(`<${name}>`, "xml");
if (model.isTopLevelElement(name)) md.appendMarkdown(`**Top-level asset element** \n`);
if (info?.kind === "complex") {
if (info.doc) md.appendMarkdown(`${info.doc} \n`);
md.appendMarkdown(
`Attributes: ${info.attributes.length} · Children: ${info.children.length} \n`,
);
if (info.base) md.appendMarkdown(`Extends: \`${info.base}\``);
} else if (info?.kind === "simple") {
md.appendMarkdown(`Simple type: \`${type}\``);
} else if (type) {
md.appendMarkdown(`Type: \`${type}\``);
} else {
md.appendMarkdown("Not found in the bundled XSD model.");
}
return new vscode.Hover(md);
}
private attributeHover(elementType: string | null, attrName: string): vscode.Hover | null {
const attrs = model.attributesOfType(elementType);
const attr = attrs.find((a) => a.name === attrName);
const md = new vscode.MarkdownString();
md.appendCodeblock(`${attrName}=""`, "xml");
if (!attr) {
if (/^(xmlns|xai:)/.test(attrName)) {
md.appendMarkdown(`Namespace/instance attribute.`);
return new vscode.Hover(md);
}
md.appendMarkdown("Unknown attribute for this element.");
return new vscode.Hover(md);
}
if (attr.doc) md.appendMarkdown(`${attr.doc} \n`);
if (attr.required) md.appendMarkdown(`**Required** \n`);
if (attr.refType) md.appendMarkdown(`References assets of type \`${attr.refType}\` \n`);
if (attr.enumValues.length)
md.appendMarkdown(`Values: \`${attr.enumValues.join("`, `")}\` \n`);
if (attr.default != null) md.appendMarkdown(`Default: \`${attr.default}\` \n`);
if (attr.allowsDefine) md.appendMarkdown(`May use \`$DEFINE\` constants \n`);
md.appendMarkdown(`Type: \`${attr.type ?? "string"}\``);
return new vscode.Hover(md);
}
private valueHover(
el: { name: string },
elType: string | null,
attrName: string,
value: string,
document: vscode.TextDocument,
idx: ModIndex | null,
): vscode.Hover | null {
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());
if (defs?.length) {
const d = defs[0];
md.appendMarkdown(`**Define** \`$${d.name}\` \n`);
md.appendCodeblock(d.value);
const rel = relativePath(document, d.file);
md.appendMarkdown(`Defined in \`${rel}:${d.line}\``);
return new vscode.Hover(md);
}
}
// Include source.
if (el.name === "Include" && attrName === "source") {
const resolved = idx
? resolveSource(
value,
dirname(document.uri.fsPath),
buildSearchPaths(idx.sdkDir, idx.projectDir),
).path
: null;
if (resolved) {
md.appendMarkdown(`**Include source** \n`);
md.appendCodeblock(resolved);
return new vscode.Hover(md);
}
const cand = idx?.sourceCandidates.find((c) => c.source === value);
if (cand) {
md.appendMarkdown(`**Include source** \n`);
md.appendCodeblock(cand.path);
return new vscode.Hover(md);
}
md.appendMarkdown(`Include source: \`${value}\` (not in candidate index)`);
return new vscode.Hover(md);
}
// Asset reference / inheritFrom.
if (idx) {
if (!isReferenceAttributeOfType(elType, attrName)) return null;
const targets = resolveReferenceTargetsForType(idx, elType, attrName, value);
if (targets.length) {
const md2 = new vscode.MarkdownString();
md2.appendMarkdown(`**${targets.length} definition${targets.length > 1 ? "s" : ""}** \n`);
for (const { def: d } of targets.slice(0, 8)) {
const loc =
d.origin === "manifest"
? `manifest \`${d.manifestSource ?? d.file}\``
: `\`${relativePath(document, d.file)}:${d.line}\``;
md2.appendMarkdown(`- \`${d.type}\` · ${loc} \n`);
}
return new vscode.Hover(md2);
}
const attrRef = model
.attributesOfType(elType)
.find((a) => a.name === attrName);
const expected = attrRef?.refType
? ` of type \`${attrRef.refType}\``
: attrRef?.isRef
? " of the expected declared type"
: "";
md.appendMarkdown(
`No matching definition${expected} in the current index` +
" (may exist in a compiled manifest or vanilla data).",
);
return new vscode.Hover(md);
}
return null;
}
}
function relativePath(document: vscode.TextDocument, abs: string): string {
const root = vscode.workspace.getWorkspaceFolder(document.uri)?.uri.fsPath;
if (!root) return abs;
const rel = abs.toLowerCase().startsWith(root.toLowerCase())
? abs.slice(root.length + 1)
: abs;
return rel;
}
+330
View File
@@ -0,0 +1,330 @@
import * as vscode from "vscode";
import { dirname } from "node:path";
import { findElementAt, parseXml } from "../language/xmlParser";
import { resolveElementType } from "../language/typeContext";
import {
buildSearchPaths,
resolveSource,
type SearchPaths,
} from "../indexer/includeResolver";
import { resolveReferenceTargetsForType } from "../indexer/refs";
import type { ModWorkspace } from "../workspace";
import type { AssetDef, ModIndex } from "../indexer/types";
function searchPathsFor(idx: ModIndex): SearchPaths {
return buildSearchPaths(idx.sdkDir, idx.projectDir);
}
// ── Go to definition ────────────────────────────────────────────────
export class Ra3DefinitionProvider implements vscode.DefinitionProvider {
constructor(private ws: ModWorkspace) {}
async provideDefinition(
document: vscode.TextDocument,
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();
const offset = document.offsetAt(position);
const doc = parseXml(text);
const el = findElementAt(doc, offset);
if (!el) return null;
const elType = resolveElementType(el);
const attr = el.attrs.find(
(a) => a.hasValue && offset >= a.valueStart && offset <= a.valueEnd,
);
if (!attr) return null;
const value = attr.value;
const nameLower = attr.name.toLowerCase();
// Include source / xi:include href -> open the file.
if (
(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))
: null;
}
// Asset reference / inheritFrom (filtered by the attribute's ref type).
if (value && !value.startsWith("$")) {
let targets = resolveReferenceTargetsForType(idx, elType, attr.name, value);
if (!targets.length) return null;
if (
this.ws.settings.definitionMode === "project-only" &&
targets.some((t) => t.def.origin === "project")
) {
targets = targets.filter((t) => t.def.origin === "project");
}
const locations: vscode.Location[] = [];
for (const { def } of targets.slice(0, 8)) {
const loc = await assetDefLocation(this.ws, def, idx);
if (loc) locations.push(loc);
}
return locations.length ? locations : null;
}
return null;
}
}
/**
* Builds a location for an asset definition. For XML sources the location is
* the precise range of the id attribute value (or the element start tag when
* the id value cannot be located), falling back to the recorded line.
*/
async function assetDefLocation(
ws: ModWorkspace,
def: AssetDef,
idx: ModIndex,
): Promise<vscode.Location | null> {
if (def.origin === "manifest") {
const src = def.manifestSource;
if (src?.toUpperCase().startsWith("DATA:")) {
const resolved = resolveSource(src, null, searchPathsFor(idx)).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.
const precise = await locationInDocument(ws, resolved, def.id);
return precise ?? new vscode.Location(vscode.Uri.file(resolved), new vscode.Position(0, 0));
}
}
return null;
}
return (
(await locationInDocument(ws, def.file, def.id)) ??
new vscode.Location(
vscode.Uri.file(def.file),
new vscode.Position(Math.max(0, def.line - 1), 0),
)
);
}
/**
* Finds the precise range of an asset definition inside an XML file: the id
* attribute value when present, otherwise the element start tag.
*/
async function locationInDocument(
ws: ModWorkspace,
file: string,
id: string,
): Promise<vscode.Location | null> {
const parsed = await ws.indexer?.readDocument(file);
if (parsed?.parse && parsed.lineMap) {
const el = parsed.parse.elements.find(
(e) =>
e.attrs.some(
(a) => a.name === "id" && a.value.toLowerCase() === id.toLowerCase(),
),
);
if (el) {
const idAttr = el.attrs.find((a) => a.name === "id");
if (idAttr?.hasValue) {
return new vscode.Location(
vscode.Uri.file(file),
new vscode.Range(
toVscodePosition(parsed.lineMap.positionAt(idAttr.valueStart)),
toVscodePosition(parsed.lineMap.positionAt(idAttr.valueEnd)),
),
);
}
return new vscode.Location(
vscode.Uri.file(file),
new vscode.Range(
toVscodePosition(parsed.lineMap.positionAt(el.start)),
toVscodePosition(parsed.lineMap.positionAt(el.startTagEnd)),
),
);
}
}
return null;
}
function toVscodePosition(p: { line: number; character: number }): vscode.Position {
return new vscode.Position(p.line, p.character);
}
// ── Find all references ─────────────────────────────────────────────
export class Ra3ReferenceProvider implements vscode.ReferenceProvider {
async provideReferences(
document: vscode.TextDocument,
position: vscode.Position,
_context: vscode.ReferenceContext,
_token: vscode.CancellationToken,
): Promise<vscode.Location[] | null> {
const text = document.getText();
const offset = document.offsetAt(position);
const doc = parseXml(text);
const el = findElementAt(doc, offset);
if (!el) return null;
const attr = el.attrs.find(
(a) =>
(a.hasValue && offset >= a.valueStart && offset <= a.valueEnd) ||
(offset >= a.nameStart && offset <= a.nameEnd),
);
if (!attr?.hasValue) return null;
const id = attr.value;
if (!id || id.startsWith("$")) return null;
const locations: vscode.Location[] = [];
const pattern = `["']${escapeRegExp(id)}["']`;
await findTextInWorkspace(
{ pattern, isRegExp: true },
{ include: "**/*.xml", maxResults: 2000 },
(result: { uri: vscode.Uri; matches: { range: vscode.Range }[] }) => {
if (!result.uri) return;
for (const m of result.matches) {
locations.push(new vscode.Location(result.uri, m.range));
}
},
);
return locations.length ? locations : null;
}
}
// ── Document links (ctrl+click on includes) ─────────────────────────
export class Ra3DocumentLinkProvider implements vscode.DocumentLinkProvider {
constructor(private ws: ModWorkspace) {}
async provideDocumentLinks(
document: vscode.TextDocument,
_token: vscode.CancellationToken,
): Promise<vscode.DocumentLink[]> {
const idx = this.ws.index;
if (!idx) return [];
const text = document.getText();
const doc = parseXml(text);
const links: vscode.DocumentLink[] = [];
for (const el of doc.elements) {
if (el.name !== "Include" && el.name !== "include") continue;
const srcAttr = el.attrs.find((a) => a.name === "source" || a.name === "href");
if (!srcAttr?.hasValue) continue;
const target =
resolveSource(
srcAttr.value,
dirname(document.uri.fsPath),
searchPathsFor(idx),
).path ??
idx.sourceCandidates.find((c) => c.source === srcAttr.value)?.path ??
null;
if (!target) continue;
links.push(
new vscode.DocumentLink(
new vscode.Range(
document.positionAt(srcAttr.valueStart),
document.positionAt(srcAttr.valueEnd),
),
vscode.Uri.file(target),
),
);
}
return links;
}
}
// ── Document symbols (outline) ──────────────────────────────────────
export class Ra3DocumentSymbolProvider implements vscode.DocumentSymbolProvider {
async provideDocumentSymbols(
document: vscode.TextDocument,
_token: vscode.CancellationToken,
): Promise<vscode.DocumentSymbol[]> {
const text = document.getText();
const doc = parseXml(text);
const root = doc.root;
if (!root) return [];
const symbols: vscode.DocumentSymbol[] = [];
for (const child of root.children) {
const local = localName(child.name);
if (local === "Tags" || local === "Includes" || local === "Defines") continue;
const idAttr = child.attrs.find((a) => a.name === "id");
const label = idAttr ? `${local} ${idAttr.value}` : local;
const fullRange = new vscode.Range(
document.positionAt(child.start),
document.positionAt(child.end),
);
const selectionRange = new vscode.Range(
document.positionAt(child.start),
document.positionAt(child.startTagEnd),
);
symbols.push(
new vscode.DocumentSymbol(
label,
"",
vscode.SymbolKind.Class,
fullRange,
selectionRange,
),
);
}
for (const child of root.children) {
if (localName(child.name) !== "Defines") continue;
for (const define of child.children) {
if (localName(define.name) !== "Define") continue;
const name = define.attrs.find((a) => a.name === "name")?.value;
if (!name) continue;
const range = new vscode.Range(
document.positionAt(define.start),
document.positionAt(define.end),
);
symbols.push(
new vscode.DocumentSymbol(`$${name}`, "Define", vscode.SymbolKind.Constant, range, range),
);
}
}
return symbols;
}
}
function localName(tag: string): string {
const idx = tag.lastIndexOf(":");
return idx >= 0 ? tag.slice(idx + 1) : tag;
}
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
* `workspace.findTextInFiles` is a stable VS Code API (since 1.66) but is
* missing from the published typings, so we declare the subset we need and
* call it via a safe cast.
*/
interface TextSearchQuery {
pattern: string;
isRegExp?: boolean;
isCaseSensitive?: boolean;
isWordMatch?: boolean;
}
interface TextSearchOptions {
include?: string;
exclude?: string;
maxResults?: number;
}
function findTextInWorkspace(
query: TextSearchQuery,
options: TextSearchOptions,
callback: (result: { uri: vscode.Uri; matches: { range: vscode.Range }[] }) => void,
): Promise<void> {
const api = vscode.workspace as unknown as {
findTextInFiles(
query: TextSearchQuery,
options: TextSearchOptions,
callback: (result: { uri: vscode.Uri; matches: { range: vscode.Range }[] }) => void,
): Promise<unknown>;
};
return api.findTextInFiles(query, options, callback).then(() => undefined);
}