first commit
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user