first commit
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
import * as vscode from "vscode";
|
||||
import { ModWorkspace } from "./workspace";
|
||||
import { Ra3CompletionProvider } from "./features/completion";
|
||||
import { Ra3HoverProvider } from "./features/hover";
|
||||
import {
|
||||
Ra3DefinitionProvider,
|
||||
Ra3DocumentLinkProvider,
|
||||
Ra3DocumentSymbolProvider,
|
||||
Ra3ReferenceProvider,
|
||||
} from "./features/navigation";
|
||||
import { Ra3Diagnostics } from "./features/diagnostics";
|
||||
|
||||
const XML_SELECTOR: vscode.DocumentSelector = [{ language: "xml" }];
|
||||
|
||||
export function activate(context: vscode.ExtensionContext): void {
|
||||
const ws = new ModWorkspace(context);
|
||||
context.subscriptions.push(ws);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.languages.registerCompletionItemProvider(
|
||||
XML_SELECTOR,
|
||||
new Ra3CompletionProvider(ws),
|
||||
"<",
|
||||
'"',
|
||||
"=",
|
||||
":",
|
||||
".",
|
||||
"/",
|
||||
),
|
||||
);
|
||||
context.subscriptions.push(
|
||||
vscode.languages.registerHoverProvider(XML_SELECTOR, new Ra3HoverProvider(ws)),
|
||||
);
|
||||
context.subscriptions.push(
|
||||
vscode.languages.registerDefinitionProvider(
|
||||
XML_SELECTOR,
|
||||
new Ra3DefinitionProvider(ws),
|
||||
),
|
||||
);
|
||||
context.subscriptions.push(
|
||||
vscode.languages.registerReferenceProvider(
|
||||
XML_SELECTOR,
|
||||
new Ra3ReferenceProvider(),
|
||||
),
|
||||
);
|
||||
context.subscriptions.push(
|
||||
vscode.languages.registerDocumentLinkProvider(
|
||||
XML_SELECTOR,
|
||||
new Ra3DocumentLinkProvider(ws),
|
||||
),
|
||||
);
|
||||
context.subscriptions.push(
|
||||
vscode.languages.registerDocumentSymbolProvider(
|
||||
XML_SELECTOR,
|
||||
new Ra3DocumentSymbolProvider(),
|
||||
),
|
||||
);
|
||||
|
||||
const diagnostics = new Ra3Diagnostics(ws);
|
||||
context.subscriptions.push(diagnostics);
|
||||
|
||||
const diagnosticTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const scheduleDiagnostics = (doc: vscode.TextDocument) => {
|
||||
if (doc.languageId !== "xml") return;
|
||||
const key = doc.uri.toString();
|
||||
const existing = diagnosticTimers.get(key);
|
||||
if (existing) clearTimeout(existing);
|
||||
diagnosticTimers.set(
|
||||
key,
|
||||
setTimeout(() => {
|
||||
diagnosticTimers.delete(key);
|
||||
void diagnostics.update(doc);
|
||||
}, 500),
|
||||
);
|
||||
};
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.workspace.onDidChangeTextDocument((e) => {
|
||||
scheduleDiagnostics(e.document);
|
||||
}),
|
||||
);
|
||||
context.subscriptions.push(
|
||||
vscode.workspace.onDidOpenTextDocument((doc) => {
|
||||
if (doc.languageId === "xml") void diagnostics.update(doc);
|
||||
}),
|
||||
);
|
||||
context.subscriptions.push(
|
||||
vscode.window.onDidChangeActiveTextEditor((editor) => {
|
||||
if (editor?.document.languageId === "xml") void diagnostics.update(editor.document);
|
||||
}),
|
||||
);
|
||||
context.subscriptions.push(
|
||||
vscode.workspace.onDidCloseTextDocument((doc) => {
|
||||
diagnostics.clear(doc.uri);
|
||||
}),
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.workspace.onDidSaveTextDocument((doc) => {
|
||||
if (doc.languageId !== "xml") return;
|
||||
ws.scheduleRebuild();
|
||||
void diagnostics.update(doc);
|
||||
}),
|
||||
);
|
||||
context.subscriptions.push(
|
||||
vscode.workspace.onDidChangeConfiguration((e) => {
|
||||
if (e.affectsConfiguration("ra3modxml")) ws.scheduleRebuild();
|
||||
}),
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("ra3modxml.reindex", () => ws.rebuild()),
|
||||
);
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("ra3modxml.openIndexReport", () => {
|
||||
const idx = ws.index;
|
||||
if (!idx) {
|
||||
void vscode.window.showInformationMessage(
|
||||
"RA3 Mod XML: no index available. Open a workspace that contains Data/Mod.xml.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const s = idx.stats;
|
||||
void vscode.window.showInformationMessage(
|
||||
`RA3 Mod XML index\n` +
|
||||
`Project: ${s.projectDir}\n` +
|
||||
`Files: ${s.indexedFiles} (${s.parsedFiles} parsed)\n` +
|
||||
`Assets: ${s.assetCount} (${s.manifestAssetCount} from ${s.manifestFiles} manifests)\n` +
|
||||
`Defines: ${s.defineCount} · Streams: ${s.streams} · Candidates: ${s.sourceCandidates}\n` +
|
||||
`Indexed in ${(s.elapsedMs / 1000).toFixed(1)}s`,
|
||||
{ modal: false },
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
void ws.initialize();
|
||||
}
|
||||
|
||||
export function deactivate(): void {
|
||||
// All subscriptions are disposed by VS Code.
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { readdir, stat } from "node:fs/promises";
|
||||
import { join, relative, resolve } from "node:path";
|
||||
import type { FileWalker, SourceCandidate } from "./types";
|
||||
|
||||
/**
|
||||
* Recursive file list walker with a simple directory-mtime cache. Used to
|
||||
* enumerate candidate files for Include/@source completion. Directory scans
|
||||
* outside the workspace (e.g. the SDK) are cached until the directory mtime
|
||||
* changes.
|
||||
*/
|
||||
export class CachedDirectoryWalker implements FileWalker {
|
||||
private cache = new Map<string, { mtimeMs: number; files: string[] }>();
|
||||
|
||||
async listFiles(dir: string): Promise<string[]> {
|
||||
const key = dir.toLowerCase();
|
||||
try {
|
||||
const dirStat = await stat(dir);
|
||||
const cached = this.cache.get(key);
|
||||
if (cached && cached.mtimeMs === dirStat.mtimeMs) {
|
||||
return cached.files;
|
||||
}
|
||||
const files: string[] = [];
|
||||
await this.walk(dir, files);
|
||||
this.cache.set(key, { mtimeMs: dirStat.mtimeMs, files });
|
||||
return files;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async walk(dir: string, out: string[]): Promise<void> {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await this.walk(full, out);
|
||||
} else if (entry.isFile()) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the candidate list for Include/@source completion from a set of
|
||||
* search directories. For DATA directories only *.xml files are listed; for
|
||||
* ART/AUDIO directories every file is listed (includes commonly point at
|
||||
* .w3x/.dds/.mpf files there).
|
||||
*/
|
||||
export async function collectSourceCandidates(
|
||||
walker: FileWalker,
|
||||
dataDirs: string[],
|
||||
artDirs: string[],
|
||||
audioDirs: string[],
|
||||
projectDataDir: string,
|
||||
): Promise<SourceCandidate[]> {
|
||||
const out: SourceCandidate[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const add = (path: string, baseDir: string, prefix: "DATA" | "ART" | "AUDIO" | null) => {
|
||||
const rel = relative(baseDir, path).replace(/\\/g, "/");
|
||||
if (rel.startsWith("..")) return;
|
||||
const source = prefix ? `${prefix}:${rel}` : rel;
|
||||
const key = `${prefix ?? ""}:${source.toLowerCase()}`;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
out.push({ source, path: resolve(path), prefix, baseDir: resolve(baseDir) });
|
||||
};
|
||||
|
||||
for (const dir of dataDirs) {
|
||||
const files = await walker.listFiles(dir);
|
||||
for (const f of files) {
|
||||
if (!f.toLowerCase().endsWith(".xml")) continue;
|
||||
add(f, dir, "DATA");
|
||||
}
|
||||
if (samePath(dir, projectDataDir)) {
|
||||
// Also offer project-relative paths (what Mod.xml itself uses).
|
||||
for (const f of files) {
|
||||
if (f.toLowerCase().endsWith(".xml")) add(f, projectDataDir, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const dir of artDirs) {
|
||||
for (const f of await walker.listFiles(dir)) {
|
||||
add(f, dir, "ART");
|
||||
}
|
||||
}
|
||||
for (const dir of audioDirs) {
|
||||
for (const f of await walker.listFiles(dir)) {
|
||||
add(f, dir, "AUDIO");
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function samePath(a: string, b: string): boolean {
|
||||
return resolve(a).toLowerCase() === resolve(b).toLowerCase();
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Include path resolution for RA3 Mod XML, ported from the reference
|
||||
* implementation in check_duplicate_ids.py.
|
||||
*
|
||||
* Pure TypeScript: no vscode dependency, so the module can be reused outside
|
||||
* the extension (e.g. by search/analysis tools).
|
||||
*/
|
||||
|
||||
import { join, resolve, normalize, isAbsolute } from "node:path";
|
||||
import { statSync } from "node:fs";
|
||||
|
||||
export type IncludeKind = "all" | "instance" | "reference";
|
||||
export type SourcePrefix = "DATA" | "ART" | "AUDIO" | null;
|
||||
|
||||
export interface SearchPaths {
|
||||
DATA: string[];
|
||||
ART: string[];
|
||||
AUDIO: string[];
|
||||
}
|
||||
|
||||
export interface ResolveResult {
|
||||
path: string | null;
|
||||
prefix: SourcePrefix;
|
||||
raw: string;
|
||||
}
|
||||
|
||||
const PREFIXES: Exclude<SourcePrefix, null>[] = ["DATA", "ART", "AUDIO"];
|
||||
|
||||
/**
|
||||
* Builds the search path lists used by the SDK compiler:
|
||||
* (from defaultscript.cs getIncludePaths(), where "." is the SDK root):
|
||||
* DATA: sdk -> modGranParent -> project/Data -> sdk/Mods -> modParentPath -> sdk/SageXml
|
||||
* ART: sdk -> modGranParent -> project/Art1 -> project/Art -> sdk/Mods -> modParentPath -> sdk/Art
|
||||
* AUDIO: sdk -> modGranParent -> project/Audio1 -> project/Audio -> sdk/Mods -> modParentPath -> sdk/Audio
|
||||
*
|
||||
* `extra` directories (from user settings) are appended after the defaults
|
||||
* for their matching prefix.
|
||||
*/
|
||||
export function buildSearchPaths(
|
||||
sdkDir: string,
|
||||
projectDir: string,
|
||||
extra?: Partial<Record<"DATA" | "ART" | "AUDIO", string[]>>,
|
||||
): SearchPaths {
|
||||
const modParentPath = resolve(projectDir, "..");
|
||||
const modGranParent = resolve(modParentPath, "..");
|
||||
return {
|
||||
DATA: [
|
||||
sdkDir,
|
||||
modGranParent,
|
||||
join(projectDir, "Data"),
|
||||
join(sdkDir, "Mods"),
|
||||
modParentPath,
|
||||
join(sdkDir, "SageXml"),
|
||||
...(extra?.DATA ?? []),
|
||||
],
|
||||
ART: [
|
||||
sdkDir,
|
||||
modGranParent,
|
||||
join(projectDir, "Art1"),
|
||||
join(projectDir, "Art"),
|
||||
join(sdkDir, "Mods"),
|
||||
modParentPath,
|
||||
join(sdkDir, "Art"),
|
||||
...(extra?.ART ?? []),
|
||||
],
|
||||
AUDIO: [
|
||||
sdkDir,
|
||||
modGranParent,
|
||||
join(projectDir, "Audio1"),
|
||||
join(projectDir, "Audio"),
|
||||
join(sdkDir, "Mods"),
|
||||
modParentPath,
|
||||
join(sdkDir, "Audio"),
|
||||
...(extra?.AUDIO ?? []),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function splitPrefix(source: string): { prefix: SourcePrefix; rest: string } {
|
||||
for (const prefix of PREFIXES) {
|
||||
if (source.toUpperCase().startsWith(`${prefix}:`)) {
|
||||
return { prefix, rest: source.slice(prefix.length + 1).replace(/^[/\\]+/, "") };
|
||||
}
|
||||
}
|
||||
return { prefix: null, rest: source.replace(/^[/\\]+/, "") };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves an Include/@source (or xi:include/@href) to an absolute file path,
|
||||
* or null when not found.
|
||||
*
|
||||
* - DATA:/ART:/AUDIO: prefixes are resolved against the corresponding search
|
||||
* paths, in order.
|
||||
* - ART: paths without a directory separator also try the 2-letter prefix
|
||||
* subdirectory (e.g. JUAntiShip -> ju/JUAntiShip).
|
||||
* - Paths without a prefix are resolved relative to the including file.
|
||||
*/
|
||||
export function resolveSource(
|
||||
source: string,
|
||||
currentDir: string | null,
|
||||
searchPaths: SearchPaths,
|
||||
): ResolveResult {
|
||||
const raw = source.trim().replace(/\\/g, "/");
|
||||
const { prefix, rest } = splitPrefix(raw);
|
||||
|
||||
if (prefix) {
|
||||
const bases = searchPaths[prefix] ?? [];
|
||||
const direct = findInBases(rest, bases);
|
||||
if (direct) return { path: direct, prefix, raw };
|
||||
if (prefix === "ART" && !rest.includes("/")) {
|
||||
const two = rest.slice(0, 2).toLowerCase();
|
||||
const prefixed = findInBases(`${two}/${rest}`, bases);
|
||||
if (prefixed) return { path: prefixed, prefix, raw };
|
||||
}
|
||||
return { path: null, prefix, raw };
|
||||
}
|
||||
|
||||
if (currentDir && isAbsolute(rest)) {
|
||||
return { path: fileExists(rest) ? rest : null, prefix: null, raw };
|
||||
}
|
||||
if (currentDir) {
|
||||
const candidate = resolve(currentDir, rest);
|
||||
return { path: fileExists(candidate) ? candidate : null, prefix: null, raw };
|
||||
}
|
||||
return { path: null, prefix: null, raw };
|
||||
}
|
||||
|
||||
function findInBases(relPath: string, bases: string[]): string | null {
|
||||
for (const base of bases) {
|
||||
const candidate = normalize(resolve(base, relPath));
|
||||
if (fileExists(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function fileExists(path: string): boolean {
|
||||
try {
|
||||
return statSync(path).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* For `<Include type="reference" source="DATA:static.xml">`, returns the
|
||||
* compiled manifest file that backs the placeholder, when present in one of
|
||||
* the builtmods directories. The placeholder file name maps to
|
||||
* `<name>.manifest` (e.g. static.xml -> static.manifest).
|
||||
*/
|
||||
export function manifestPathForReference(
|
||||
source: string,
|
||||
builtmodsDirs: string[],
|
||||
): string | null {
|
||||
const base = basenameWithoutExt(stripPrefix(source).replace(/\\/g, "/"));
|
||||
if (!base) return null;
|
||||
for (const dir of builtmodsDirs) {
|
||||
const candidate = join(dir, `${base}.manifest`);
|
||||
try {
|
||||
if (statSync(candidate).isFile()) return candidate;
|
||||
} catch {
|
||||
// continue
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function basenameWithoutExt(path: string): string {
|
||||
const idx = path.lastIndexOf("/");
|
||||
const file = idx >= 0 ? path.slice(idx + 1) : path;
|
||||
const dot = file.lastIndexOf(".");
|
||||
return dot > 0 ? file.slice(0, dot) : file;
|
||||
}
|
||||
|
||||
function stripPrefix(source: string): string {
|
||||
const idx = source.indexOf(":");
|
||||
return idx >= 0 ? source.slice(idx + 1) : source;
|
||||
}
|
||||
@@ -0,0 +1,593 @@
|
||||
/**
|
||||
* Workspace indexer for RA3 Mod XML.
|
||||
*
|
||||
* Walks the include graph from Data/Mod.xml (static stream) and
|
||||
* Data/additionalmaps/mapmetadata_*.xml (global streams), collects asset
|
||||
* definitions, `$DEFINE` constants, resolved `reference` includes (parsed
|
||||
* from compiled .manifest files) and a file-name index used for
|
||||
* Include/@source completion.
|
||||
*
|
||||
* Pure TypeScript (no vscode dependency) so the indexing core can be reused
|
||||
* outside the extension.
|
||||
*/
|
||||
|
||||
import { readFile, readdir, stat } from "node:fs/promises";
|
||||
import { basename, dirname, extname, join, resolve } from "node:path";
|
||||
import { LineMap, parseXml, type XmlDocument, type XmlElement } from "../language/xmlParser";
|
||||
import {
|
||||
buildSearchPaths,
|
||||
manifestPathForReference,
|
||||
resolveSource,
|
||||
type SearchPaths,
|
||||
} from "./includeResolver";
|
||||
import {
|
||||
deriveAssetId,
|
||||
deriveAssetType,
|
||||
parseManifest,
|
||||
type ManifestInfo,
|
||||
} from "./manifestParser";
|
||||
import { canonicalTypeName } from "../model/schemaModel";
|
||||
import { collectSourceCandidates } from "./fileScanner";
|
||||
import type {
|
||||
AssetDef,
|
||||
DefineDef,
|
||||
IndexOptions,
|
||||
IndexedFile,
|
||||
ModIndex,
|
||||
ParsedFile,
|
||||
SourceCandidate,
|
||||
StreamInfo,
|
||||
} from "./types";
|
||||
|
||||
const MAX_DEPTH = 300;
|
||||
/** Files above this size are never parsed (safety against binary blobs). */
|
||||
const MAX_PARSE_BYTES = 4 * 1024 * 1024;
|
||||
/** Only these extensions are treated as XML documents. */
|
||||
const XML_EXTENSIONS = new Set([".xml", ".manifestxml"]);
|
||||
|
||||
function normKey(path: string): string {
|
||||
return resolve(path).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* LRU cache for parsed documents. The parse trees of huge mods can be
|
||||
* memory-heavy, so only a bounded number of recent documents is retained;
|
||||
* evicted entries are re-read from disk on demand.
|
||||
*/
|
||||
export class DocumentCache {
|
||||
private map = new Map<string, ParsedFile>();
|
||||
|
||||
constructor(private capacity = 64) {}
|
||||
|
||||
get(path: string): ParsedFile | undefined {
|
||||
const key = normKey(path);
|
||||
const hit = this.map.get(key);
|
||||
if (!hit) return undefined;
|
||||
this.map.delete(key);
|
||||
this.map.set(key, hit);
|
||||
return hit;
|
||||
}
|
||||
|
||||
set(parsed: ParsedFile): void {
|
||||
const key = normKey(parsed.file.path);
|
||||
this.map.delete(key);
|
||||
this.map.set(key, parsed);
|
||||
if (this.map.size > this.capacity) {
|
||||
const oldest = this.map.keys().next().value;
|
||||
if (oldest !== undefined) this.map.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
invalidate(path: string): void {
|
||||
this.map.delete(normKey(path));
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.map.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export class ModIndexer {
|
||||
private searchPaths: SearchPaths;
|
||||
private docs = new DocumentCache();
|
||||
private assets = new Map<string, Map<string, AssetDef[]>>();
|
||||
private assetsById = new Map<string, AssetDef[]>();
|
||||
private defines = new Map<string, DefineDef[]>();
|
||||
private files = new Map<string, IndexedFile>();
|
||||
private streams: StreamInfo[] = [];
|
||||
private manifests = new Map<string, ManifestInfo>();
|
||||
private sourceCandidates: SourceCandidate[] = [];
|
||||
private diagnostics: ModIndex["diagnostics"] = [];
|
||||
private visitedAll = new Set<string>();
|
||||
private visitedInstance = new Set<string>();
|
||||
private manifestAssetKeys = new Set<string>();
|
||||
|
||||
constructor(private opts: IndexOptions) {
|
||||
this.searchPaths = buildSearchPaths(opts.sdkDir, opts.projectDir, {
|
||||
DATA: opts.additionalDataSearchPaths,
|
||||
});
|
||||
}
|
||||
|
||||
/** Re-reads and caches a document; null when unreadable. */
|
||||
async readDocument(path: string): Promise<ParsedFile | null> {
|
||||
const hit = this.docs.get(path);
|
||||
if (hit) return hit;
|
||||
try {
|
||||
const [st, text] = await Promise.all([stat(path), readFile(path, "utf8")]);
|
||||
if (st.size > MAX_PARSE_BYTES) {
|
||||
const file: IndexedFile = { path: resolve(path), stat: { mtimeMs: st.mtimeMs, size: st.size } };
|
||||
const parsed: ParsedFile = { file, parse: null, lineMap: null };
|
||||
this.docs.set(parsed);
|
||||
this.files.set(normKey(parsed.file.path), file);
|
||||
return parsed;
|
||||
}
|
||||
const parse = parseXml(text);
|
||||
const parsed: ParsedFile = {
|
||||
file: { path: resolve(path), stat: { mtimeMs: st.mtimeMs, size: st.size } },
|
||||
parse,
|
||||
lineMap: new LineMap(text),
|
||||
};
|
||||
this.docs.set(parsed);
|
||||
this.files.set(normKey(parsed.file.path), parsed.file);
|
||||
return parsed;
|
||||
} catch {
|
||||
const parsed: ParsedFile = {
|
||||
file: { path: resolve(path), stat: null },
|
||||
parse: null,
|
||||
lineMap: null,
|
||||
};
|
||||
this.docs.set(parsed);
|
||||
this.files.set(normKey(parsed.file.path), parsed.file);
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the cached parse if present (does not read from disk). */
|
||||
cachedDocument(path: string): ParsedFile | undefined {
|
||||
return this.docs.get(path);
|
||||
}
|
||||
|
||||
async build(): Promise<ModIndex> {
|
||||
const start = Date.now();
|
||||
const projectData = await findCaseInsensitiveDir(join(this.opts.projectDir, "Data"));
|
||||
const additionalMaps = projectData
|
||||
? await findCaseInsensitiveDir(join(projectData, "additionalmaps"))
|
||||
: null;
|
||||
|
||||
// ── Streams ──
|
||||
const staticEntry = projectData ? join(projectData, "Mod.xml") : null;
|
||||
if (staticEntry) {
|
||||
const stream: StreamInfo = { name: "static", entry: staticEntry, files: new Set() };
|
||||
this.streams.push(stream);
|
||||
await this.walk(staticEntry, "all", stream, 0);
|
||||
}
|
||||
|
||||
if (additionalMaps) {
|
||||
let entries: string[] = [];
|
||||
try {
|
||||
entries = await readdir(additionalMaps);
|
||||
} catch {
|
||||
entries = [];
|
||||
}
|
||||
const metadataFiles = entries
|
||||
.filter((f) => /^mapmetadata_.*\.xml$/i.test(f))
|
||||
.sort();
|
||||
for (const f of metadataFiles) {
|
||||
const entry = join(additionalMaps, f);
|
||||
const stream: StreamInfo = {
|
||||
name: `global:${basename(f, ".xml")}`,
|
||||
entry,
|
||||
files: new Set(),
|
||||
};
|
||||
this.streams.push(stream);
|
||||
await this.walk(entry, "all", stream, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Source completion candidates ──
|
||||
const dataDirs = [
|
||||
projectData ?? join(this.opts.projectDir, "Data"),
|
||||
join(this.opts.sdkDir, "SageXml"),
|
||||
...this.opts.additionalDataSearchPaths,
|
||||
];
|
||||
if (!this.opts.indexSageXml) {
|
||||
const sage = join(this.opts.sdkDir, "SageXml");
|
||||
const idx = dataDirs.findIndex((d) => normKey(d) === normKey(sage));
|
||||
if (idx >= 0) dataDirs.splice(idx, 1);
|
||||
}
|
||||
const artDirs = [
|
||||
join(this.opts.projectDir, "Art1"),
|
||||
join(this.opts.projectDir, "Art"),
|
||||
join(this.opts.sdkDir, "Art"),
|
||||
];
|
||||
const audioDirs = [
|
||||
join(this.opts.projectDir, "Audio1"),
|
||||
join(this.opts.projectDir, "Audio"),
|
||||
join(this.opts.sdkDir, "Audio"),
|
||||
];
|
||||
this.sourceCandidates = await collectSourceCandidates(
|
||||
this.opts.walker,
|
||||
dataDirs,
|
||||
artDirs,
|
||||
audioDirs,
|
||||
projectData ?? join(this.opts.projectDir, "Data"),
|
||||
);
|
||||
// The SDK root itself is the first DATA: search base (static.xml,
|
||||
// global.xml, audio.xml placeholders) but only its shallow XML files are
|
||||
// relevant. These candidates take precedence over same-named files found
|
||||
// deeper in the search paths (e.g. SageXml/Static.xml).
|
||||
const sdkRootXml = (await readdir(this.opts.sdkDir)).filter(
|
||||
(f) => f.toLowerCase().endsWith(".xml"),
|
||||
);
|
||||
const sdkRootCandidates: SourceCandidate[] = sdkRootXml.map((f) => ({
|
||||
source: `DATA:${f}`,
|
||||
path: resolve(this.opts.sdkDir, f),
|
||||
prefix: "DATA",
|
||||
baseDir: resolve(this.opts.sdkDir),
|
||||
}));
|
||||
this.sourceCandidates = dedupeSourceCandidates([
|
||||
...sdkRootCandidates,
|
||||
...this.sourceCandidates,
|
||||
]);
|
||||
|
||||
const manifestAssetCount = [...this.manifests.values()].reduce(
|
||||
(sum, m) => sum + m.assets.length,
|
||||
0,
|
||||
);
|
||||
|
||||
return {
|
||||
projectDir: resolve(this.opts.projectDir),
|
||||
sdkDir: resolve(this.opts.sdkDir),
|
||||
assets: this.assets,
|
||||
assetsById: this.assetsById,
|
||||
defines: this.defines,
|
||||
files: this.files,
|
||||
streams: this.streams,
|
||||
manifests: this.manifests,
|
||||
sourceCandidates: this.sourceCandidates,
|
||||
diagnostics: this.diagnostics,
|
||||
stats: {
|
||||
projectDir: resolve(this.opts.projectDir),
|
||||
sdkDir: resolve(this.opts.sdkDir),
|
||||
indexedFiles: this.files.size,
|
||||
parsedFiles: [...this.files.values()].filter(
|
||||
(f) => f.stat != null && f.stat.size <= MAX_PARSE_BYTES,
|
||||
).length,
|
||||
assetCount: [...this.assets.values()].reduce((sum, byId) => sum + byId.size, 0),
|
||||
defineCount: this.defines.size,
|
||||
manifestFiles: this.manifests.size,
|
||||
manifestAssetCount,
|
||||
streams: this.streams.length,
|
||||
sourceCandidates: this.sourceCandidates.length,
|
||||
elapsedMs: Date.now() - start,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Include walk ──────────────────────────────────────────────────
|
||||
|
||||
private async walk(
|
||||
path: string,
|
||||
mode: "all" | "instance",
|
||||
stream: StreamInfo,
|
||||
depth: number,
|
||||
): Promise<void> {
|
||||
const key = normKey(path);
|
||||
if (depth > MAX_DEPTH) {
|
||||
this.diagnostics.push({
|
||||
file: path,
|
||||
line: 0,
|
||||
message: "Include depth exceeded - possible include cycle",
|
||||
severity: "warning",
|
||||
code: "include-cycle",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (mode === "all") {
|
||||
if (this.visitedAll.has(key)) return;
|
||||
this.visitedAll.add(key);
|
||||
} else {
|
||||
if (this.visitedInstance.has(key)) return;
|
||||
this.visitedInstance.add(key);
|
||||
if (this.visitedAll.has(key)) return;
|
||||
}
|
||||
|
||||
stream.files.add(key);
|
||||
|
||||
// Binary assets (w3x/dds/...) are referenced but never parsed.
|
||||
if (!isXmlPath(path)) return;
|
||||
|
||||
const parsed = await this.readDocument(path);
|
||||
if (!parsed?.parse?.root) return;
|
||||
const root = parsed.parse.root;
|
||||
|
||||
for (const child of root.children) {
|
||||
const local = localName(child.name);
|
||||
if (local === "Tags" || local === "Includes" || local === "Defines") continue;
|
||||
if (local === "include") {
|
||||
await this.handleXiInclude(child, parsed, stream, depth);
|
||||
continue;
|
||||
}
|
||||
const idAttr = child.attrs.find((a) => a.name === "id");
|
||||
if (idAttr) {
|
||||
this.addAsset({
|
||||
type: local,
|
||||
id: idAttr.value,
|
||||
file: parsed.file.path,
|
||||
line: lineOf(parsed, idAttr.valueStart),
|
||||
origin: this.originOf(parsed.file.path),
|
||||
stream: stream.name,
|
||||
viaInstance: mode === "instance",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
const value = define.attrs.find((a) => a.name === "value")?.value;
|
||||
if (!name) continue;
|
||||
const entry: DefineDef = {
|
||||
name,
|
||||
value: value ?? "",
|
||||
file: parsed.file.path,
|
||||
line: lineOf(parsed, define.start),
|
||||
origin: this.originOf(parsed.file.path),
|
||||
};
|
||||
const arr = this.defines.get(name.toLowerCase());
|
||||
if (arr) arr.push(entry);
|
||||
else this.defines.set(name.toLowerCase(), [entry]);
|
||||
}
|
||||
}
|
||||
|
||||
const includesElem = root.children.find((c) => localName(c.name) === "Includes");
|
||||
if (includesElem) {
|
||||
for (const inc of includesElem.children) {
|
||||
if (localName(inc.name) !== "Include") continue;
|
||||
const type = inc.attrs.find((a) => a.name === "type")?.value;
|
||||
const source = inc.attrs.find((a) => a.name === "source")?.value;
|
||||
if (!source) continue;
|
||||
const resolved = resolveSource(source, dirname(parsed.file.path), this.searchPaths);
|
||||
if (!resolved.path) {
|
||||
this.diagnostics.push({
|
||||
file: parsed.file.path,
|
||||
line: lineOf(parsed, inc.start),
|
||||
message: `Include target not found: ${source}`,
|
||||
severity: "warning",
|
||||
code: "include-not-found",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (type === "all" || type === "instance") {
|
||||
await this.walk(resolved.path, type === "all" ? "all" : "instance", stream, depth + 1);
|
||||
} else if (type === "reference") {
|
||||
const manifestPath = manifestPathForReference(source, this.opts.builtmodsDirs);
|
||||
if (manifestPath) {
|
||||
const loaded = await this.loadManifest(manifestPath, stream.name);
|
||||
if (!loaded && isXmlPath(resolved.path)) {
|
||||
// The manifest could not be parsed (missing/invalid): fall back
|
||||
// to the placeholder XML so its content is still available.
|
||||
await this.walk(resolved.path, "instance", stream, depth + 1);
|
||||
}
|
||||
} else if (isXmlPath(resolved.path)) {
|
||||
// reference to a real XML file: treat its assets as available
|
||||
await this.walk(resolved.path, "instance", stream, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Nested <xi:include> anywhere in the tree (not just under the root):
|
||||
// the target content is inlined into the parent element. We make the
|
||||
// target file available and surface missing targets instead of ignoring
|
||||
// them silently.
|
||||
for (const el of parsed.parse.elements) {
|
||||
if (localName(el.name) !== "include") continue;
|
||||
if (el.parent === root) continue; // already handled in the loop above
|
||||
const href = el.attrs.find((a) => a.name === "href")?.value;
|
||||
if (!href) continue;
|
||||
const resolved = resolveSource(href, dirname(parsed.file.path), this.searchPaths);
|
||||
if (!resolved.path) {
|
||||
this.diagnostics.push({
|
||||
file: parsed.file.path,
|
||||
line: lineOf(parsed, el.start),
|
||||
message: `xi:include target not found: ${href}`,
|
||||
severity: "warning",
|
||||
code: "include-not-found",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
stream.files.add(normKey(resolved.path));
|
||||
if (isXmlPath(resolved.path)) {
|
||||
await this.walk(resolved.path, "all", stream, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async handleXiInclude(
|
||||
xi: XmlElement,
|
||||
parent: ParsedFile,
|
||||
stream: StreamInfo,
|
||||
depth: number,
|
||||
): Promise<void> {
|
||||
const href = xi.attrs.find((a) => a.name === "href")?.value;
|
||||
if (!href) return;
|
||||
const resolved = resolveSource(href, dirname(parent.file.path), this.searchPaths);
|
||||
if (!resolved.path) {
|
||||
this.diagnostics.push({
|
||||
file: parent.file.path,
|
||||
line: lineOf(parent, xi.start),
|
||||
message: `xi:include target not found: ${href}`,
|
||||
severity: "warning",
|
||||
code: "include-not-found",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!isXmlPath(resolved.path)) return;
|
||||
const target = await this.readDocument(resolved.path);
|
||||
if (!target?.parse?.root) return;
|
||||
|
||||
const xpointer = xi.attrs.find((a) => a.name === "xpointer")?.value ?? "";
|
||||
let candidates: XmlElement[];
|
||||
if (xpointer) {
|
||||
const container = findXPointerContainer(target.parse, xpointer);
|
||||
candidates = container ? container.children : [];
|
||||
} else {
|
||||
candidates = target.parse.root.children;
|
||||
}
|
||||
for (const el of candidates) {
|
||||
const local = localName(el.name);
|
||||
if (local === "Tags" || local === "Includes" || local === "Defines") continue;
|
||||
const idAttr = el.attrs.find((a) => a.name === "id");
|
||||
if (idAttr) {
|
||||
this.addAsset({
|
||||
type: local,
|
||||
id: idAttr.value,
|
||||
file: target.file.path,
|
||||
line: lineOf(target, idAttr.valueStart),
|
||||
origin: this.originOf(target.file.path),
|
||||
stream: stream.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
stream.files.add(normKey(target.file.path));
|
||||
await this.walk(target.file.path, "all", stream, depth + 1);
|
||||
}
|
||||
|
||||
// ── Manifest loading ──────────────────────────────────────────────
|
||||
|
||||
/** Returns true when the manifest was parsed successfully. */
|
||||
private async loadManifest(path: string, streamName: string): Promise<boolean> {
|
||||
const key = normKey(path);
|
||||
let info = this.manifests.get(key);
|
||||
if (!info) {
|
||||
try {
|
||||
const data = await readFile(path);
|
||||
const buffer = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
||||
info = parseManifest(buffer);
|
||||
} catch {
|
||||
this.diagnostics.push({
|
||||
file: path,
|
||||
line: 0,
|
||||
message: "Manifest file could not be read",
|
||||
severity: "warning",
|
||||
code: "manifest-read-error",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
this.manifests.set(key, info);
|
||||
}
|
||||
if (info.error) return false;
|
||||
for (const asset of info.assets) {
|
||||
if (!asset.name) continue;
|
||||
const id = deriveAssetId(asset.name);
|
||||
const assetKey = `${asset.typeId}:${id.toLowerCase()}`;
|
||||
if (this.manifestAssetKeys.has(assetKey)) continue;
|
||||
this.manifestAssetKeys.add(assetKey);
|
||||
this.addAsset({
|
||||
type:
|
||||
canonicalTypeName(deriveAssetType(asset.typeName, asset.name)) ??
|
||||
`#${asset.typeId.toString(16)}`,
|
||||
id,
|
||||
file: path,
|
||||
line: 0,
|
||||
origin: "manifest",
|
||||
stream: streamName,
|
||||
manifest: path,
|
||||
manifestSource: asset.sourceFileName,
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
private originOf(path: string): "project" | "sdk" {
|
||||
const p = resolve(path).toLowerCase();
|
||||
const project = resolve(this.opts.projectDir).toLowerCase();
|
||||
const sdk = resolve(this.opts.sdkDir).toLowerCase();
|
||||
if (p.startsWith(project + "\\")) return "project";
|
||||
if (sdk && p.startsWith(sdk + "\\")) return "sdk";
|
||||
return "project";
|
||||
}
|
||||
|
||||
private addAsset(def: AssetDef): void {
|
||||
// Keep the original case: type names are matched against the XSD model.
|
||||
const typeKey = def.type;
|
||||
const idKey = def.id.toLowerCase();
|
||||
let byId = this.assets.get(typeKey);
|
||||
if (!byId) {
|
||||
byId = new Map();
|
||||
this.assets.set(typeKey, byId);
|
||||
}
|
||||
const arr = byId.get(idKey);
|
||||
if (arr) {
|
||||
if (arr.some((a) => a.file === def.file && a.line === def.line)) return;
|
||||
arr.push(def);
|
||||
} else {
|
||||
byId.set(idKey, [def]);
|
||||
}
|
||||
const all = this.assetsById.get(idKey);
|
||||
if (all) {
|
||||
if (all.some((a) => a.file === def.file && a.line === def.line)) return;
|
||||
all.push(def);
|
||||
} else {
|
||||
this.assetsById.set(idKey, [def]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Module-level helpers ─────────────────────────────────────────────
|
||||
|
||||
function localName(tag: string): string {
|
||||
const idx = tag.lastIndexOf(":");
|
||||
return idx >= 0 ? tag.slice(idx + 1) : tag;
|
||||
}
|
||||
|
||||
function lineOf(parsed: ParsedFile, offset: number): number {
|
||||
if (!parsed.lineMap) return 0;
|
||||
return parsed.lineMap.positionAt(offset).line + 1;
|
||||
}
|
||||
|
||||
function isXmlPath(path: string): boolean {
|
||||
const ext = extname(path).toLowerCase();
|
||||
return XML_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
async function findCaseInsensitiveDir(dir: string): Promise<string | null> {
|
||||
const parent = dirname(dir);
|
||||
const wanted = basename(dir);
|
||||
try {
|
||||
const entries = await readdir(parent, { withFileTypes: true });
|
||||
const hit = entries.find(
|
||||
(e) => e.isDirectory() && e.name.toLowerCase() === wanted.toLowerCase(),
|
||||
);
|
||||
return hit ? join(parent, hit.name) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function findXPointerContainer(doc: XmlDocument, xpointer: string): XmlElement | null {
|
||||
// Supports the form used by the mods:
|
||||
// xmlns(n=uri:ea.com:eala:asset) xpointer(/n:ElementName/child::*)
|
||||
const m = /xpointer\(\/\w+:(\w+)\/child::\*\)/.exec(xpointer);
|
||||
if (!m) return null;
|
||||
const name = m[1];
|
||||
return doc.elements.find((el) => localName(el.name) === name) ?? null;
|
||||
}
|
||||
|
||||
/** Keeps the first candidate for each case-insensitive source string. */
|
||||
function dedupeSourceCandidates(candidates: SourceCandidate[]): SourceCandidate[] {
|
||||
const seen = new Set<string>();
|
||||
const out: SourceCandidate[] = [];
|
||||
for (const c of candidates) {
|
||||
const key = c.source.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(c);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* Parser for SAGE `.manifest` files, ported from OpenSAGE
|
||||
* (src/OpenSage.Game/Data/StreamFS/ManifestFile.cs, commit d45d361).
|
||||
*
|
||||
* The manifest is a binary index produced by BinaryAssetBuilder: every asset
|
||||
* compiled into a stream is listed with hashed type/instance ids, an offset
|
||||
* into the asset-name string buffer, and an optional source file name.
|
||||
* Parsing it lets the extension treat `reference` includes (static.xml,
|
||||
* global.xml, audio.xml) as real, searchable asset pools.
|
||||
*/
|
||||
|
||||
import { assetTypeNameFromHash } from "../model/schemaModel";
|
||||
|
||||
export interface ManifestAsset {
|
||||
typeId: number;
|
||||
typeName: string | null;
|
||||
name: string;
|
||||
sourceFileName: string;
|
||||
}
|
||||
|
||||
export interface ManifestReferenceEntry {
|
||||
referenceType: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface ManifestInfo {
|
||||
version: number;
|
||||
isBigEndian: boolean;
|
||||
isLinked: boolean;
|
||||
assetCount: number;
|
||||
assets: ManifestAsset[];
|
||||
manifestReferences: ManifestReferenceEntry[];
|
||||
/** Present when the file could not be parsed. */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives an asset type from its manifest name. BAB stores manifest assets
|
||||
* as "TypeName:Id" (e.g. "PlayerTemplate:Allies"), so the part before the
|
||||
* first colon is the type even when the TypeId hash is unknown to the
|
||||
* bundled AssetType table. Asset ids cannot contain ":" (InstanceId pattern),
|
||||
* so the split is unambiguous.
|
||||
*/
|
||||
export function deriveAssetType(typeName: string | null, assetName: string): string | null {
|
||||
if (typeName) return typeName;
|
||||
const idx = assetName.indexOf(":");
|
||||
if (idx <= 0) return null;
|
||||
const prefix = assetName.slice(0, idx);
|
||||
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(prefix) ? prefix : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the referenceable asset id from a manifest name. BAB stores
|
||||
* manifest names as "TypeName:InstanceId" where the instance id may itself
|
||||
* carry a subtype prefix for art assets, e.g.
|
||||
* W3dContainer:W3DContainer:AUANTIVEHICLEVEHICLETECH1_SKN
|
||||
* The referenceable id is the last colon-separated segment (asset ids cannot
|
||||
* contain ":" per the InstanceId pattern).
|
||||
*/
|
||||
export function deriveAssetId(assetName: string): string {
|
||||
const idx = assetName.lastIndexOf(":");
|
||||
return idx >= 0 ? assetName.slice(idx + 1) : assetName;
|
||||
}
|
||||
|
||||
class Cursor {
|
||||
private pos = 0;
|
||||
constructor(private buf: Uint8Array) {}
|
||||
|
||||
get position(): number {
|
||||
return this.pos;
|
||||
}
|
||||
|
||||
byte(): number {
|
||||
if (this.pos >= this.buf.length) throw new Error("Unexpected end of file");
|
||||
return this.buf[this.pos++];
|
||||
}
|
||||
|
||||
uint16(): number {
|
||||
const a = this.byte();
|
||||
const b = this.byte();
|
||||
return a | (b << 8);
|
||||
}
|
||||
|
||||
uint32(): number {
|
||||
const a = this.byte();
|
||||
const b = this.byte();
|
||||
const c = this.byte();
|
||||
const d = this.byte();
|
||||
return ((a | (b << 8) | (c << 16) | (d << 24)) >>> 0);
|
||||
}
|
||||
|
||||
skip(n: number): void {
|
||||
this.pos += n;
|
||||
if (this.pos > this.buf.length) throw new Error("Unexpected end of file");
|
||||
}
|
||||
|
||||
nullTerminatedString(): string {
|
||||
const start = this.pos;
|
||||
while (this.pos < this.buf.length && this.buf[this.pos] !== 0) {
|
||||
this.pos++;
|
||||
}
|
||||
const end = this.pos;
|
||||
if (this.pos < this.buf.length) this.pos++; // consume NUL
|
||||
return decodeAscii(this.buf, start, end);
|
||||
}
|
||||
|
||||
readNameBuffer(endPosition: number): Map<number, string> {
|
||||
const names = new Map<number, string>();
|
||||
let nameOffset = 0;
|
||||
while (this.pos < endPosition) {
|
||||
const start = this.pos;
|
||||
names.set(nameOffset, this.nullTerminatedString());
|
||||
nameOffset += this.pos - start;
|
||||
}
|
||||
return names;
|
||||
}
|
||||
}
|
||||
|
||||
function decodeAscii(buf: Uint8Array, start: number, end: number): string {
|
||||
// Asset ids are ASCII; anything else decodes with replacement chars.
|
||||
let out = "";
|
||||
for (let i = start; i < end; i++) {
|
||||
out += String.fromCharCode(buf[i]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseManifest(buffer: Uint8Array): ManifestInfo {
|
||||
try {
|
||||
return parseManifestInner(buffer);
|
||||
} catch (err) {
|
||||
return {
|
||||
version: 0,
|
||||
isBigEndian: false,
|
||||
isLinked: false,
|
||||
assetCount: 0,
|
||||
assets: [],
|
||||
manifestReferences: [],
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function parseManifestInner(buffer: Uint8Array): ManifestInfo {
|
||||
const cursor = new Cursor(buffer);
|
||||
|
||||
const testValue = cursor.uint32();
|
||||
let version: number;
|
||||
let isBigEndian: boolean;
|
||||
let isLinked: boolean;
|
||||
|
||||
if (testValue === 0) {
|
||||
version = cursor.uint16();
|
||||
if (version !== 7) throw new Error(`Unsupported manifest version ${version}`);
|
||||
isBigEndian = readBooleanChecked(cursor);
|
||||
isLinked = readBooleanChecked(cursor);
|
||||
} else {
|
||||
cursor.skip(-4);
|
||||
isBigEndian = readBooleanChecked(cursor);
|
||||
isLinked = readBooleanChecked(cursor);
|
||||
version = cursor.uint16();
|
||||
if (version !== 5 && version !== 6) {
|
||||
throw new Error(`Unsupported manifest version ${version}`);
|
||||
}
|
||||
}
|
||||
|
||||
const streamChecksum = cursor.uint32();
|
||||
const allTypesHash = cursor.uint32();
|
||||
const assetCount = cursor.uint32();
|
||||
const totalInstanceDataSize = cursor.uint32();
|
||||
const maxInstanceChunkSize = cursor.uint32();
|
||||
const maxRelocationChunkSize = cursor.uint32();
|
||||
const maxImportsChunkSize = cursor.uint32();
|
||||
const assetReferenceBufferSize = cursor.uint32();
|
||||
const referencedManifestNameBufferSize = cursor.uint32();
|
||||
const assetNameBufferSize = cursor.uint32();
|
||||
const sourceFileNameBufferSize = cursor.uint32();
|
||||
|
||||
void streamChecksum;
|
||||
void allTypesHash;
|
||||
void totalInstanceDataSize;
|
||||
void maxInstanceChunkSize;
|
||||
void maxRelocationChunkSize;
|
||||
void maxImportsChunkSize;
|
||||
|
||||
interface RawEntry {
|
||||
typeId: number;
|
||||
nameOffset: number;
|
||||
sourceFileNameOffset: number;
|
||||
}
|
||||
const rawEntries: RawEntry[] = [];
|
||||
for (let i = 0; i < assetCount; i++) {
|
||||
const typeId = cursor.uint32();
|
||||
cursor.uint32(); // instanceId
|
||||
cursor.uint32(); // typeHash
|
||||
cursor.uint32(); // instanceHash
|
||||
cursor.uint32(); // assetReferenceOffset
|
||||
cursor.uint32(); // assetReferenceCount
|
||||
const nameOffset = cursor.uint32();
|
||||
const sourceFileNameOffset = cursor.uint32();
|
||||
cursor.uint32(); // instanceDataSize
|
||||
cursor.uint32(); // relocationDataSize
|
||||
cursor.uint32(); // importsDataSize
|
||||
if (version >= 6) {
|
||||
cursor.byte(); // isTokenized
|
||||
cursor.skip(3);
|
||||
}
|
||||
rawEntries.push({
|
||||
typeId,
|
||||
nameOffset,
|
||||
sourceFileNameOffset,
|
||||
});
|
||||
}
|
||||
|
||||
// Asset references buffer (not needed for completions).
|
||||
cursor.skip(assetReferenceBufferSize);
|
||||
|
||||
// Referenced manifest names.
|
||||
const manifestRefsEnd = cursor.position + referencedManifestNameBufferSize;
|
||||
const manifestReferences: ManifestReferenceEntry[] = [];
|
||||
while (cursor.position < manifestRefsEnd) {
|
||||
const referenceType = cursor.byte();
|
||||
const path = cursor.nullTerminatedString();
|
||||
manifestReferences.push({ referenceType, path });
|
||||
}
|
||||
|
||||
// Asset names.
|
||||
const assetNamesEnd = cursor.position + assetNameBufferSize;
|
||||
const assetNames = cursor.readNameBuffer(assetNamesEnd);
|
||||
|
||||
// Source file names.
|
||||
const sourceNamesEnd = cursor.position + sourceFileNameBufferSize;
|
||||
const sourceNames = cursor.readNameBuffer(sourceNamesEnd);
|
||||
|
||||
const assets: ManifestAsset[] = rawEntries.map((entry) => ({
|
||||
typeId: entry.typeId,
|
||||
typeName: assetTypeNameFromHash(entry.typeId) ?? null,
|
||||
name: assetNames.get(entry.nameOffset) ?? "",
|
||||
sourceFileName: sourceNames.get(entry.sourceFileNameOffset) ?? "",
|
||||
}));
|
||||
|
||||
return {
|
||||
version,
|
||||
isBigEndian,
|
||||
isLinked,
|
||||
assetCount,
|
||||
assets,
|
||||
manifestReferences,
|
||||
};
|
||||
}
|
||||
|
||||
function readBooleanChecked(cursor: Cursor): boolean {
|
||||
const value = cursor.byte();
|
||||
if (value === 0) return false;
|
||||
if (value === 1) return true;
|
||||
throw new Error(`Invalid boolean byte ${value}`);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
attributesOfType,
|
||||
elementTypeName,
|
||||
isAssignableTo,
|
||||
} from "../model/schemaModel";
|
||||
import type { AssetDef, ModIndex } from "./types";
|
||||
|
||||
export interface ReferenceTarget {
|
||||
def: AssetDef;
|
||||
score: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when an attribute is a typed reference: either the instance
|
||||
* inheritance attribute `inheritFrom`, or an XSD attribute whose simple type
|
||||
* carries an `xas:refType`. Enumerations and file paths (e.g.
|
||||
* `Include/@type`, `Include/@source`) are not references.
|
||||
*/
|
||||
export function isReferenceAttribute(elementName: string, attrName: string): boolean {
|
||||
return isReferenceAttributeOfType(elementTypeName(elementName), attrName);
|
||||
}
|
||||
|
||||
/** Same check, but driven by a resolved XSD type name. */
|
||||
export function isReferenceAttributeOfType(
|
||||
typeName: string | null,
|
||||
attrName: string,
|
||||
): boolean {
|
||||
if (attrName.toLowerCase() === "inheritfrom") return true;
|
||||
const attr = attributesOfType(typeName).find((a) => a.name === attrName);
|
||||
return attr != null && (attr.refType != null || attr.isRef);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the definitions a reference attribute value should point to.
|
||||
*
|
||||
* The result is strictly filtered by the attribute's reference type (from the
|
||||
* XSD model) so that an id shared by several asset types only resolves to the
|
||||
* matching definition (e.g. `Weapon="X"` jumps to the WeaponTemplate with
|
||||
* id "X", never to a GameObject that happens to share the id).
|
||||
*
|
||||
* Returns [] when the attribute is not a typed reference or nothing matches.
|
||||
*/
|
||||
export function resolveReferenceTargets(
|
||||
idx: ModIndex,
|
||||
elementType: string,
|
||||
attrName: string,
|
||||
id: string,
|
||||
): ReferenceTarget[] {
|
||||
return resolveReferenceTargetsForType(
|
||||
idx,
|
||||
elementTypeName(elementType),
|
||||
attrName,
|
||||
id,
|
||||
);
|
||||
}
|
||||
|
||||
/** Same resolution, driven by a resolved XSD type name. */
|
||||
export function resolveReferenceTargetsForType(
|
||||
idx: ModIndex,
|
||||
typeName: string | null,
|
||||
attrName: string,
|
||||
id: string,
|
||||
): ReferenceTarget[] {
|
||||
const defs = idx.assetsById.get(id.toLowerCase());
|
||||
if (!defs?.length) return [];
|
||||
|
||||
const nameLower = attrName.toLowerCase();
|
||||
let refType: string | null = null;
|
||||
let selfType: string | null = null;
|
||||
|
||||
if (nameLower === "inheritfrom") {
|
||||
selfType = typeName;
|
||||
} else {
|
||||
const attr = attributesOfType(typeName).find((a) => a.name === attrName);
|
||||
if (!attr || (!attr.refType && !attr.isRef)) return [];
|
||||
refType = attr.refType;
|
||||
}
|
||||
|
||||
const targets: ReferenceTarget[] = [];
|
||||
for (const def of defs) {
|
||||
if (refType && !isAssignableTo(def.type, refType)) continue;
|
||||
if (selfType && !isAssignableTo(def.type, selfType)) continue;
|
||||
let score = 3;
|
||||
if (def.origin === "project") score = 0;
|
||||
else if (def.origin === "sdk") score = 1;
|
||||
else score = 2;
|
||||
targets.push({ def, score });
|
||||
}
|
||||
targets.sort((a, b) => a.score - b.score || a.def.id.localeCompare(b.def.id));
|
||||
return targets;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { XmlDocument } from "../language/xmlParser";
|
||||
import type { ManifestInfo } from "./manifestParser";
|
||||
import type { LineMap } from "../language/xmlParser";
|
||||
|
||||
export type AssetOrigin = "project" | "sdk" | "manifest";
|
||||
|
||||
export interface AssetDef {
|
||||
type: string;
|
||||
id: string;
|
||||
/** Absolute path of the defining file (for manifests: the manifest path). */
|
||||
file: string;
|
||||
/** 1-based line of the id attribute (or 0 when unknown, e.g. manifests). */
|
||||
line: number;
|
||||
origin: AssetOrigin;
|
||||
/** "static" or "global:<name>" when the asset comes from a stream. */
|
||||
stream?: string;
|
||||
/** Set for assets that are only reachable through `instance` includes. */
|
||||
viaInstance?: boolean;
|
||||
/** Manifest path for origin === "manifest". */
|
||||
manifest?: string;
|
||||
/** Source file recorded inside a manifest (e.g. "DATA:globaldata/armor.xml"). */
|
||||
manifestSource?: string;
|
||||
}
|
||||
|
||||
export interface DefineDef {
|
||||
name: string;
|
||||
value: string;
|
||||
file: string;
|
||||
line: number;
|
||||
origin: AssetOrigin;
|
||||
}
|
||||
|
||||
export interface IndexedFile {
|
||||
path: string;
|
||||
stat: { mtimeMs: number; size: number } | null;
|
||||
}
|
||||
|
||||
export interface StreamInfo {
|
||||
name: string;
|
||||
entry: string;
|
||||
files: Set<string>;
|
||||
}
|
||||
|
||||
export interface SourceCandidate {
|
||||
/** Suggested value for Include/@source. */
|
||||
source: string;
|
||||
/** Absolute path the candidate resolves to. */
|
||||
path: string;
|
||||
/** "DATA" | "ART" | "AUDIO" | null (relative). */
|
||||
prefix: "DATA" | "ART" | "AUDIO" | null;
|
||||
/** Directory that acts as the root of the relative path. */
|
||||
baseDir: string;
|
||||
}
|
||||
|
||||
export interface IndexerDiagnostic {
|
||||
file: string;
|
||||
line: number;
|
||||
message: string;
|
||||
severity: "warning" | "error" | "information";
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface IndexStats {
|
||||
projectDir: string;
|
||||
sdkDir: string;
|
||||
indexedFiles: number;
|
||||
parsedFiles: number;
|
||||
assetCount: number;
|
||||
defineCount: number;
|
||||
manifestFiles: number;
|
||||
manifestAssetCount: number;
|
||||
streams: number;
|
||||
sourceCandidates: number;
|
||||
elapsedMs: number;
|
||||
}
|
||||
|
||||
export interface ModIndex {
|
||||
projectDir: string;
|
||||
sdkDir: string;
|
||||
/** type -> id -> definitions (project + sdk + manifest, deduplicated). */
|
||||
assets: Map<string, Map<string, AssetDef[]>>;
|
||||
/** id -> definitions across all types. */
|
||||
assetsById: Map<string, AssetDef[]>;
|
||||
/** `$NAME` -> definitions. */
|
||||
defines: Map<string, DefineDef[]>;
|
||||
/** absolute path -> file record (only files touched by the include walk). */
|
||||
files: Map<string, IndexedFile>;
|
||||
streams: StreamInfo[];
|
||||
manifests: Map<string, ManifestInfo>;
|
||||
/** Files suggested for Include/@source completion. */
|
||||
sourceCandidates: SourceCandidate[];
|
||||
/** Problems found while indexing (unresolved includes, cycles, ...). */
|
||||
diagnostics: IndexerDiagnostic[];
|
||||
stats: IndexStats;
|
||||
}
|
||||
|
||||
export interface IndexOptions {
|
||||
projectDir: string;
|
||||
sdkDir: string;
|
||||
builtmodsDirs: string[];
|
||||
indexSageXml: boolean;
|
||||
additionalDataSearchPaths: string[];
|
||||
/** Directory walker used to enumerate files for source completion. */
|
||||
walker: FileWalker;
|
||||
}
|
||||
|
||||
export interface FileWalker {
|
||||
/** Recursively lists files under a directory. Cached by the caller. */
|
||||
listFiles(dir: string): Promise<string[]>;
|
||||
}
|
||||
|
||||
export interface ParseCache {
|
||||
get(path: string): IndexedFile | undefined;
|
||||
set(file: IndexedFile): void;
|
||||
clear(): void;
|
||||
/** Removes the cached entry for a path. */
|
||||
invalidate(path: string): void;
|
||||
}
|
||||
|
||||
/** A parsed file plus its line map, produced on demand. */
|
||||
export interface ParsedFile {
|
||||
file: IndexedFile;
|
||||
parse: XmlDocument | null;
|
||||
lineMap: LineMap | null;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import type { XmlAttribute, XmlDocument, XmlElement } from "./xmlParser";
|
||||
|
||||
export type ContextKind =
|
||||
| "element-name"
|
||||
| "attribute-name"
|
||||
| "attribute-value"
|
||||
| "content"
|
||||
| "none";
|
||||
|
||||
export interface CompletionContext {
|
||||
kind: ContextKind;
|
||||
/** The element whose start tag the cursor is in (or whose content). */
|
||||
element: XmlElement | null;
|
||||
/** Set when the cursor is inside a closing tag name ("</..."). */
|
||||
closing: boolean;
|
||||
/** The attribute being edited (attribute-value context). */
|
||||
attr: XmlAttribute | null;
|
||||
/** Text between the opening quote and the cursor. */
|
||||
valuePrefix: string;
|
||||
/** Names of attributes already present on the element. */
|
||||
existingAttrs: string[];
|
||||
}
|
||||
|
||||
export function analyzeContext(
|
||||
doc: XmlDocument,
|
||||
text: string,
|
||||
offset: number,
|
||||
): CompletionContext {
|
||||
// Find the innermost element whose span contains the offset.
|
||||
let container: XmlElement | null = null;
|
||||
for (const el of doc.elements) {
|
||||
if (el.end < 0) continue;
|
||||
if (offset >= el.start && offset <= el.end) {
|
||||
if (!container || el.depth > container.depth) container = el;
|
||||
}
|
||||
}
|
||||
|
||||
if (!container) return empty("none");
|
||||
|
||||
// Inside the start tag of the element.
|
||||
if (offset >= container.start && offset <= container.startTagEnd) {
|
||||
return analyzeStartTag(container, text, offset);
|
||||
}
|
||||
|
||||
// Otherwise the cursor is in element content.
|
||||
return {
|
||||
kind: "content",
|
||||
element: container,
|
||||
closing: false,
|
||||
attr: null,
|
||||
valuePrefix: "",
|
||||
existingAttrs: [],
|
||||
};
|
||||
}
|
||||
|
||||
function analyzeStartTag(
|
||||
el: XmlElement,
|
||||
text: string,
|
||||
offset: number,
|
||||
): CompletionContext {
|
||||
const closing = text.startsWith("</", el.start);
|
||||
const nameStart = el.start + (closing ? 2 : 1);
|
||||
const nameEnd = nameStart + el.name.length;
|
||||
const existingAttrs = el.attrs.map((a) => a.name);
|
||||
|
||||
if (offset <= nameEnd) {
|
||||
return {
|
||||
kind: "element-name",
|
||||
element: el,
|
||||
closing,
|
||||
attr: null,
|
||||
valuePrefix: "",
|
||||
existingAttrs,
|
||||
};
|
||||
}
|
||||
|
||||
// Inside an attribute value?
|
||||
for (const attr of el.attrs) {
|
||||
if (attr.hasValue && offset >= attr.quoteStart && offset <= attr.quoteEnd) {
|
||||
const start = attr.valueStart;
|
||||
const prefix = offset > start ? text.slice(start, offset) : "";
|
||||
return {
|
||||
kind: "attribute-value",
|
||||
element: el,
|
||||
closing: false,
|
||||
attr,
|
||||
valuePrefix: prefix,
|
||||
existingAttrs,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Right after "=" (no quotes yet) or between attributes.
|
||||
const before = text.slice(el.start, offset);
|
||||
const trimmed = before.replace(/\s+$/, "");
|
||||
if (trimmed.endsWith("=")) {
|
||||
return {
|
||||
kind: "attribute-value",
|
||||
element: el,
|
||||
closing: false,
|
||||
attr: lastAttrOf(el),
|
||||
valuePrefix: "",
|
||||
existingAttrs,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "attribute-name",
|
||||
element: el,
|
||||
closing: false,
|
||||
attr: null,
|
||||
valuePrefix: "",
|
||||
existingAttrs,
|
||||
};
|
||||
}
|
||||
|
||||
function lastAttrOf(el: XmlElement): XmlAttribute | null {
|
||||
return el.attrs.length ? el.attrs[el.attrs.length - 1] : null;
|
||||
}
|
||||
|
||||
function empty(kind: ContextKind): CompletionContext {
|
||||
return {
|
||||
kind,
|
||||
element: null,
|
||||
closing: false,
|
||||
attr: null,
|
||||
valuePrefix: "",
|
||||
existingAttrs: [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { childTypeOf, elementTypeName } from "../model/schemaModel";
|
||||
import type { XmlElement } from "./xmlParser";
|
||||
|
||||
/**
|
||||
* Resolves the XSD type of an element from the parsed document tree by
|
||||
* walking up to the root and applying context-aware child lookups at every
|
||||
* level. Falls back to the global element->type map when the parent chain
|
||||
* does not declare the child.
|
||||
*/
|
||||
export function resolveElementType(el: XmlElement): string | null {
|
||||
if (!el.parent) {
|
||||
return elementTypeName(el.name);
|
||||
}
|
||||
const parentType = resolveElementType(el.parent);
|
||||
return childTypeOf(parentType, el.name) ?? elementTypeName(el.name);
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
/**
|
||||
* Lightweight XML parser with source offsets.
|
||||
*
|
||||
* The extension needs exact positions of tags, attributes and values for
|
||||
* completions, hover, navigation and diagnostics. fast-xml-parser does not
|
||||
* provide offsets, so we use this small purpose-built parser instead. It is
|
||||
* deliberately tolerant: malformed documents still produce a partial tree
|
||||
* plus a list of errors, so completion keeps working while typing.
|
||||
*/
|
||||
|
||||
export interface XmlAttribute {
|
||||
name: string;
|
||||
value: string;
|
||||
/** Offset of the first character of the name. */
|
||||
nameStart: number;
|
||||
/** Offset one past the last character of the name. */
|
||||
nameEnd: number;
|
||||
/** Offset of the first value character (after the opening quote). */
|
||||
valueStart: number;
|
||||
/** Offset one past the last value character (before the closing quote). */
|
||||
valueEnd: number;
|
||||
/** Offset of the opening quote. */
|
||||
quoteStart: number;
|
||||
/** Offset one past the closing quote. */
|
||||
quoteEnd: number;
|
||||
hasValue: boolean;
|
||||
/** True when the value is delimited with double quotes. */
|
||||
doubleQuoted: boolean;
|
||||
}
|
||||
|
||||
export interface XmlElement {
|
||||
name: string;
|
||||
attrs: XmlAttribute[];
|
||||
children: XmlElement[];
|
||||
parent: XmlElement | null;
|
||||
/** Offset of "<". */
|
||||
start: number;
|
||||
/** Offset one past the ">" of the start tag. */
|
||||
startTagEnd: number;
|
||||
/** Offset one past the end of the whole element (closing tag or "/>"). */
|
||||
end: number;
|
||||
selfClosing: boolean;
|
||||
/** Offset of "</" of the closing tag, or -1 when self-closing. */
|
||||
closeTagStart: number;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
export interface XmlParseError {
|
||||
message: string;
|
||||
offset: number;
|
||||
line: number;
|
||||
character: number;
|
||||
}
|
||||
|
||||
export interface XmlDocument {
|
||||
root: XmlElement | null;
|
||||
/** All elements in document order (including the root). */
|
||||
elements: XmlElement[];
|
||||
errors: XmlParseError[];
|
||||
/** Offset one past "?>" of the XML declaration, or 0. */
|
||||
declarationEnd: number;
|
||||
}
|
||||
|
||||
export interface Position {
|
||||
line: number;
|
||||
character: number;
|
||||
}
|
||||
|
||||
/** Precomputes line start offsets for offset <-> position conversion. */
|
||||
export class LineMap {
|
||||
private lineStarts: number[] = [0];
|
||||
|
||||
constructor(text: string) {
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
if (text.charCodeAt(i) === 10) {
|
||||
this.lineStarts.push(i + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
positionAt(offset: number): Position {
|
||||
let lo = 0;
|
||||
let hi = this.lineStarts.length - 1;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi + 1) >> 1;
|
||||
if (this.lineStarts[mid] <= offset) {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
return { line: lo, character: offset - this.lineStarts[lo] };
|
||||
}
|
||||
|
||||
lineStart(line: number): number {
|
||||
if (line < 0) return 0;
|
||||
if (line >= this.lineStarts.length) return this.lineStarts[this.lineStarts.length - 1];
|
||||
return this.lineStarts[line];
|
||||
}
|
||||
}
|
||||
|
||||
interface RawTag {
|
||||
name: string;
|
||||
selfClosing: boolean;
|
||||
start: number;
|
||||
contentStart: number;
|
||||
contentEnd: number;
|
||||
end: number;
|
||||
attrs: XmlAttribute[];
|
||||
}
|
||||
|
||||
const NAME_RE = /[A-Za-z_][\w:.-]*/y;
|
||||
|
||||
function parseTag(content: string, contentStart: number): RawTag {
|
||||
const base = contentStart;
|
||||
let j = 0;
|
||||
while (j < content.length && /\s/.test(content[j])) {
|
||||
j++;
|
||||
}
|
||||
let name: string;
|
||||
NAME_RE.lastIndex = j;
|
||||
const m = NAME_RE.exec(content);
|
||||
if (!m) {
|
||||
name = "";
|
||||
} else {
|
||||
name = m[0];
|
||||
}
|
||||
const attrs: XmlAttribute[] = [];
|
||||
let i = m ? m.index + name.length : j;
|
||||
let selfClosing = false;
|
||||
|
||||
while (i < content.length) {
|
||||
// skip whitespace
|
||||
while (i < content.length && /\s/.test(content[i])) {
|
||||
i++;
|
||||
}
|
||||
if (i >= content.length) break;
|
||||
const c = content[i];
|
||||
// The tag content excludes the terminating ">", so a bare "/" (outside
|
||||
// quotes) can only be the self-closing marker: "<name .../>".
|
||||
if (c === "/") {
|
||||
selfClosing = true;
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
if (c === ">") {
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
// attribute name
|
||||
const attrNameStart = i;
|
||||
while (i < content.length && !/[\s=/>]/.test(content[i])) {
|
||||
i++;
|
||||
}
|
||||
const attrName = content.slice(attrNameStart, i);
|
||||
const nameEnd = base + i;
|
||||
while (i < content.length && /\s/.test(content[i])) {
|
||||
i++;
|
||||
}
|
||||
let hasValue = false;
|
||||
let value = "";
|
||||
let valueStart = -1;
|
||||
let valueEnd = -1;
|
||||
let quoteStart = -1;
|
||||
let quoteEnd = -1;
|
||||
let doubleQuoted = true;
|
||||
if (content[i] === "=") {
|
||||
i++;
|
||||
while (i < content.length && /\s/.test(content[i])) {
|
||||
i++;
|
||||
}
|
||||
const q = content[i];
|
||||
if (q === '"' || q === "'") {
|
||||
doubleQuoted = q === '"';
|
||||
hasValue = true;
|
||||
quoteStart = base + i;
|
||||
i++;
|
||||
const valueStartLocal = i;
|
||||
while (i < content.length && content[i] !== q) {
|
||||
i++;
|
||||
}
|
||||
valueStart = base + valueStartLocal;
|
||||
valueEnd = base + i;
|
||||
value = content.slice(valueStartLocal, i);
|
||||
if (content[i] === q) {
|
||||
i++;
|
||||
quoteEnd = base + i;
|
||||
}
|
||||
} else {
|
||||
// unquoted value - tolerate
|
||||
const vs = i;
|
||||
while (i < content.length && !/[\s>]/.test(content[i])) {
|
||||
i++;
|
||||
}
|
||||
value = content.slice(vs, i);
|
||||
hasValue = true;
|
||||
valueStart = base + vs;
|
||||
valueEnd = base + i;
|
||||
quoteStart = valueStart;
|
||||
quoteEnd = valueEnd;
|
||||
}
|
||||
}
|
||||
attrs.push({
|
||||
name: attrName,
|
||||
value,
|
||||
nameStart: base + attrNameStart,
|
||||
nameEnd,
|
||||
valueStart,
|
||||
valueEnd,
|
||||
quoteStart,
|
||||
quoteEnd,
|
||||
hasValue,
|
||||
doubleQuoted,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
selfClosing,
|
||||
start: base - 1,
|
||||
contentStart: base,
|
||||
contentEnd: base + i,
|
||||
end: base + i,
|
||||
attrs,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseXml(text: string): XmlDocument {
|
||||
const lineMap = new LineMap(text);
|
||||
const errors: XmlParseError[] = [];
|
||||
const elements: XmlElement[] = [];
|
||||
const stack: XmlElement[] = [];
|
||||
let root: XmlElement | null = null;
|
||||
let declarationEnd = 0;
|
||||
let i = 0;
|
||||
const n = text.length;
|
||||
|
||||
const err = (message: string, offset: number) => {
|
||||
const pos = lineMap.positionAt(offset);
|
||||
errors.push({ message, offset, line: pos.line, character: pos.character });
|
||||
};
|
||||
|
||||
while (i < n) {
|
||||
const lt = text.indexOf("<", i);
|
||||
if (lt < 0) break;
|
||||
if (lt > i && stack.length === 0 && errors.length === 0) {
|
||||
// text before the root element - ignore unless it is non-whitespace
|
||||
const between = text.slice(i, lt);
|
||||
if (between.trim() !== "") {
|
||||
err("Content is not allowed before the root element", i);
|
||||
}
|
||||
}
|
||||
i = lt;
|
||||
|
||||
// comment
|
||||
if (text.startsWith("<!--", i)) {
|
||||
const close = text.indexOf("-->", i + 4);
|
||||
if (close < 0) {
|
||||
err("Unterminated comment", i);
|
||||
break;
|
||||
}
|
||||
i = close + 3;
|
||||
continue;
|
||||
}
|
||||
// CDATA
|
||||
if (text.startsWith("<![CDATA[", i)) {
|
||||
const close = text.indexOf("]]>", i + 9);
|
||||
if (close < 0) {
|
||||
err("Unterminated CDATA section", i);
|
||||
break;
|
||||
}
|
||||
i = close + 3;
|
||||
continue;
|
||||
}
|
||||
// DOCTYPE
|
||||
if (text.startsWith("<!DOCTYPE", i) || text.startsWith("<!doctype", i)) {
|
||||
const close = text.indexOf(">", i);
|
||||
if (close < 0) {
|
||||
err("Unterminated DOCTYPE", i);
|
||||
break;
|
||||
}
|
||||
i = close + 1;
|
||||
continue;
|
||||
}
|
||||
// processing instruction / declaration
|
||||
if (text.startsWith("<?", i)) {
|
||||
const close = text.indexOf("?>", i + 2);
|
||||
if (close < 0) {
|
||||
err("Unterminated processing instruction", i);
|
||||
break;
|
||||
}
|
||||
if (i === 0 && /^<\?xml\s/i.test(text.slice(i, close + 2))) {
|
||||
declarationEnd = close + 2;
|
||||
}
|
||||
i = close + 2;
|
||||
continue;
|
||||
}
|
||||
// closing tag
|
||||
if (text.startsWith("</", i)) {
|
||||
const gt = text.indexOf(">", i + 2);
|
||||
if (gt < 0) {
|
||||
err("Unterminated closing tag", i);
|
||||
break;
|
||||
}
|
||||
const name = text.slice(i + 2, gt).trim();
|
||||
const top = stack[stack.length - 1];
|
||||
if (!top) {
|
||||
err(`Unexpected closing tag </${name}>`, i);
|
||||
} else if (top.name !== name) {
|
||||
err(`Mismatched closing tag: expected </${top.name}>, found </${name}>`, i);
|
||||
// recover: find the matching element on the stack if possible
|
||||
let idx = stack.length - 1;
|
||||
while (idx >= 0 && stack[idx].name !== name) idx--;
|
||||
if (idx >= 0) {
|
||||
const closingCount = stack.length - 1 - idx;
|
||||
for (let k = 0; k < closingCount; k++) {
|
||||
const el = stack.pop()!;
|
||||
el.end = gt + 1;
|
||||
el.closeTagStart = i;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const el = stack.pop()!;
|
||||
el.end = gt + 1;
|
||||
el.closeTagStart = i;
|
||||
}
|
||||
i = gt + 1;
|
||||
continue;
|
||||
}
|
||||
// opening tag
|
||||
if (text[i + 1] === "!" || text[i + 1] === "?") {
|
||||
err("Malformed markup", i);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
const gt = findTagEnd(text, i + 1);
|
||||
if (gt < 0) {
|
||||
err("Unterminated start tag", i);
|
||||
const content = text.slice(i + 1);
|
||||
const raw = parseTag(content, i + 1);
|
||||
if (raw.name) {
|
||||
const el = buildElement(raw, stack.length);
|
||||
elements.push(el);
|
||||
root = root ?? el;
|
||||
stack.push(el);
|
||||
}
|
||||
break;
|
||||
}
|
||||
const content = text.slice(i + 1, gt);
|
||||
const raw = parseTag(content, i + 1);
|
||||
raw.end = gt + 1;
|
||||
const el = buildElement(raw, stack.length);
|
||||
elements.push(el);
|
||||
if (stack.length === 0) {
|
||||
root = root ?? el;
|
||||
} else {
|
||||
const parent = stack[stack.length - 1];
|
||||
parent.children.push(el);
|
||||
el.parent = parent;
|
||||
}
|
||||
if (!raw.selfClosing) {
|
||||
stack.push(el);
|
||||
}
|
||||
i = gt + 1;
|
||||
}
|
||||
|
||||
if (stack.length > 0) {
|
||||
for (const el of stack) {
|
||||
const pos = lineMap.positionAt(el.start);
|
||||
errors.push({
|
||||
message: `Element <${el.name}> is never closed`,
|
||||
offset: el.start,
|
||||
line: pos.line,
|
||||
character: pos.character,
|
||||
});
|
||||
el.end = n;
|
||||
}
|
||||
}
|
||||
|
||||
return { root, elements, errors, declarationEnd };
|
||||
}
|
||||
|
||||
function findTagEnd(text: string, from: number): number {
|
||||
let i = from;
|
||||
let quote: string | null = null;
|
||||
while (i < text.length) {
|
||||
const c = text[i];
|
||||
if (quote) {
|
||||
if (c === quote) quote = null;
|
||||
} else if (c === '"' || c === "'") {
|
||||
quote = c;
|
||||
} else if (c === ">") {
|
||||
return i;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function buildElement(raw: RawTag, depth: number): XmlElement {
|
||||
return {
|
||||
name: raw.name,
|
||||
attrs: raw.attrs,
|
||||
children: [],
|
||||
parent: null,
|
||||
start: raw.start,
|
||||
startTagEnd: raw.end,
|
||||
end: raw.selfClosing ? raw.end : -1,
|
||||
selfClosing: raw.selfClosing,
|
||||
closeTagStart: -1,
|
||||
depth,
|
||||
};
|
||||
}
|
||||
|
||||
/** Returns the innermost element whose span contains `offset`. */
|
||||
export function findElementAt(doc: XmlDocument, offset: number): XmlElement | null {
|
||||
let best: XmlElement | null = null;
|
||||
for (const el of doc.elements) {
|
||||
if (el.end < 0) continue;
|
||||
if (offset >= el.start && offset <= el.end) {
|
||||
if (!best || el.depth > best.depth) {
|
||||
best = el;
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Finds an element by name that contains the offset (including its start tag). */
|
||||
export function findOpenTagElementAt(doc: XmlDocument, offset: number): XmlElement | null {
|
||||
const el = findElementAt(doc, offset);
|
||||
if (!el) return null;
|
||||
// When the cursor is inside the start tag itself, `el` is already the
|
||||
// innermost candidate. If the cursor is before the element's start, use
|
||||
// the parent.
|
||||
if (offset >= el.start && offset <= el.startTagEnd) return el;
|
||||
return el;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"version": 1,
|
||||
"source": "OpenSAGE src/OpenSage.Game/Data/StreamFS/AssetType.cs",
|
||||
"count": 79,
|
||||
"types": {
|
||||
"299416263": "AudioLod",
|
||||
"315614191": "LocalBuildListMonitor",
|
||||
"354222972": "GameLodPreset",
|
||||
"376113229": "AudioFile",
|
||||
"400896388": "CrowdResponse",
|
||||
"439207783": "TheaterOfWarTemplate",
|
||||
"504350110": "InGameUIPlayerPowerCommandSlots",
|
||||
"530081230": "IntelDB",
|
||||
"531798225": "StaticGameLod",
|
||||
"534008255": "LargeGroupAudioMap",
|
||||
"565855655": "ImageSequence",
|
||||
"568797146": "Texture",
|
||||
"607123156": "AIStrategicStateDefinition",
|
||||
"608742960": "W3dAnimation",
|
||||
"610186489": "InGameUIVoiceChatCommandSlots",
|
||||
"680780553": "Environment",
|
||||
"686292351": "FXParticleSystemTemplate",
|
||||
"726253425": "Achievement",
|
||||
"741706624": "MpGameRules",
|
||||
"819131716": "RadiusCursorLibrary",
|
||||
"866546168": "InGameUISettings",
|
||||
"926814458": "AITargetingHeuristic",
|
||||
"962203606": "InGameUILookAtCommandSlots",
|
||||
"980180622": "ArmorTemplate",
|
||||
"1345252658": "OnlineChatColors",
|
||||
"1350608344": "MappableKey",
|
||||
"1443425905": "AudioSettings",
|
||||
"1447728787": "VideoEventList",
|
||||
"1449288224": "PackedTextureImage",
|
||||
"1482556238": "CampaignTemplate",
|
||||
"1628705662": "InGameUIGroupSelectionCommandSlots",
|
||||
"1641540160": "W3dHierarchy",
|
||||
"1713477273": "PlayerPowerButtonTemplateStore",
|
||||
"1874610847": "ExperienceLevelTemplate",
|
||||
"1883691512": "MusicTrack",
|
||||
"2007776008": "TargetingInTurretArcCompare",
|
||||
"2070603733": "MiscAudio",
|
||||
"2101756272": "LogicCommand",
|
||||
"2178440954": "SpecialPowerTemplate",
|
||||
"2219670431": "AudioEvent",
|
||||
"2254974584": "FXList",
|
||||
"2359666647": "TargetingDistanceCompare",
|
||||
"2384988189": "MultiplayerColor",
|
||||
"2421413379": "UnitOverlayIconSettings",
|
||||
"2430161325": "Weather",
|
||||
"2458866148": "InGameUIFixedElementHotKeySlotMap",
|
||||
"2467477932": "OnDemandTexture",
|
||||
"2486173485": "GameObject",
|
||||
"2496977262": "WeaponTemplate",
|
||||
"2525284163": "StanceTemplate",
|
||||
"2525492603": "Mouse",
|
||||
"2565744451": "InGameUISideBarCommandSlots",
|
||||
"2745675575": "Multisound",
|
||||
"2800139175": "HotKeySlot",
|
||||
"2811124014": "InGameUIUnitAbilityCommandSlots",
|
||||
"2812698028": "InGameUITacticalCommandSlots",
|
||||
"2893598307": "AIBudgetStateDefinition",
|
||||
"2901356964": "DynamicGameLod",
|
||||
"2905958645": "DamageFX",
|
||||
"3188107749": "TargetingCompareList",
|
||||
"3266421346": "W3dMesh",
|
||||
"3319822471": "AttributeModifier",
|
||||
"3477794083": "MissionTemplate",
|
||||
"3558134211": "DialogEvent",
|
||||
"3587539190": "ArmyDefinition",
|
||||
"3604279694": "AIPersonalityDefinition",
|
||||
"3614134471": "UnitTypeIcon",
|
||||
"3650896041": "PhaseEffect",
|
||||
"3741098742": "DefaultHotKeys",
|
||||
"3786401627": "UpgradeTemplate",
|
||||
"3810008068": "W3dCollisionBox",
|
||||
"3899542881": "ObjectCreationList",
|
||||
"3928762264": "AmbientStream",
|
||||
"3959844197": "LogicCommandSet",
|
||||
"3971904488": "SkirmishOpeningMove",
|
||||
"3972178387": "LocomotorTemplate",
|
||||
"4042295058": "W3dContainer",
|
||||
"4157475773": "ShadowMap",
|
||||
"4262364347": "OnDemandTextureImage"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,214 @@
|
||||
import schemaModel from "./schema-model.json";
|
||||
import assetTypes from "./asset-types.json";
|
||||
|
||||
export interface ChildInfo {
|
||||
name: string;
|
||||
type: string | null;
|
||||
min: number;
|
||||
max: number; // -1 = unbounded
|
||||
doc: string;
|
||||
}
|
||||
|
||||
export interface AttributeInfo {
|
||||
name: string;
|
||||
required: boolean;
|
||||
default: string | null;
|
||||
doc: string;
|
||||
kind: string;
|
||||
type: string | null;
|
||||
refType: string | null;
|
||||
/** True for reference-typed attributes whose simple type has no refType. */
|
||||
isRef: boolean;
|
||||
enumValues: string[];
|
||||
allowsDefine: boolean;
|
||||
isBoolean: boolean;
|
||||
base: string | null;
|
||||
}
|
||||
|
||||
export interface ComplexTypeInfo {
|
||||
kind: "complex";
|
||||
children: ChildInfo[];
|
||||
attributes: AttributeInfo[];
|
||||
base: string | null;
|
||||
doc: string;
|
||||
}
|
||||
|
||||
export interface SimpleTypeInfo {
|
||||
kind: "simple";
|
||||
base: string | null;
|
||||
refType: string | null;
|
||||
isRef: boolean;
|
||||
enumValues: string[];
|
||||
allowsDefine: boolean;
|
||||
doc: string;
|
||||
}
|
||||
|
||||
export type TypeInfo = ComplexTypeInfo | SimpleTypeInfo;
|
||||
|
||||
interface RawModel {
|
||||
version: number;
|
||||
rootXsd: string;
|
||||
topLevelElements: string[];
|
||||
elements: Record<string, { type: string | null; doc: string }>;
|
||||
types: Record<string, TypeInfo>;
|
||||
subTypesOf: Record<string, string[]>;
|
||||
}
|
||||
|
||||
const model = schemaModel as unknown as RawModel;
|
||||
|
||||
/** Lowercase type name -> canonical (XSD) type name. */
|
||||
const typeNameIndex = new Map<string, string>();
|
||||
for (const name of Object.keys(model.types)) {
|
||||
const lower = name.toLowerCase();
|
||||
if (!typeNameIndex.has(lower)) typeNameIndex.set(lower, name);
|
||||
}
|
||||
|
||||
/** Resolves a possibly-mis-cased type name to its canonical XSD spelling. */
|
||||
export function canonicalTypeName(name: string | null): string | null {
|
||||
if (!name) return null;
|
||||
return typeNameIndex.get(name.toLowerCase()) ?? name;
|
||||
}
|
||||
|
||||
/** element name -> type name, collected from every complex type's children. */
|
||||
const elementToType = new Map<string, string>();
|
||||
for (const type of Object.values(model.types)) {
|
||||
if (type.kind !== "complex") continue;
|
||||
for (const child of type.children) {
|
||||
if (!elementToType.has(child.name)) {
|
||||
elementToType.set(child.name, child.type ?? "");
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [name, info] of Object.entries(model.elements)) {
|
||||
elementToType.set(name, info.type ?? "");
|
||||
}
|
||||
|
||||
export const modelMeta = {
|
||||
rootXsd: model.rootXsd,
|
||||
topLevelElementCount: model.topLevelElements.length,
|
||||
typeCount: Object.keys(model.types).length,
|
||||
};
|
||||
|
||||
export function topLevelElements(): string[] {
|
||||
return model.topLevelElements;
|
||||
}
|
||||
|
||||
export function isTopLevelElement(name: string): boolean {
|
||||
return model.topLevelElements.includes(name);
|
||||
}
|
||||
|
||||
export function typeInfo(name: string): TypeInfo | undefined {
|
||||
return model.types[name];
|
||||
}
|
||||
|
||||
export function elementTypeName(name: string): string | null {
|
||||
const t = elementToType.get(name);
|
||||
return t ? t : null;
|
||||
}
|
||||
|
||||
export function childrenOfElement(name: string): ChildInfo[] {
|
||||
const type = elementTypeName(name);
|
||||
if (!type) return [];
|
||||
return childrenOfType(type);
|
||||
}
|
||||
|
||||
export function childrenOfType(typeName: string | null): ChildInfo[] {
|
||||
if (!typeName) return [];
|
||||
const info = model.types[canonicalTypeName(typeName) ?? typeName];
|
||||
return info && info.kind === "complex" ? info.children : [];
|
||||
}
|
||||
|
||||
export function attributesOfElement(name: string): AttributeInfo[] {
|
||||
const type = elementTypeName(name);
|
||||
if (!type) return [];
|
||||
return attributesOfType(type);
|
||||
}
|
||||
|
||||
export function attributesOfType(typeName: string | null): AttributeInfo[] {
|
||||
if (!typeName) return [];
|
||||
const info = model.types[canonicalTypeName(typeName) ?? typeName];
|
||||
return info && info.kind === "complex" ? info.attributes : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the type of a child element inside a KNOWN parent type, or null
|
||||
* when the parent type is unknown or the child is not declared there.
|
||||
*/
|
||||
export function childTypeOf(
|
||||
parentTypeName: string | null,
|
||||
childName: string,
|
||||
): string | null {
|
||||
if (!parentTypeName) return null;
|
||||
const info = model.types[canonicalTypeName(parentTypeName) ?? parentTypeName];
|
||||
if (info?.kind !== "complex") return null;
|
||||
return info.children.find((c) => c.name === childName)?.type ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context-aware element type resolution: prefers the child declaration inside
|
||||
* the parent element's type, falling back to the global element map. Same
|
||||
* element names used in different parents (e.g. <Weapon> under a weapon slot
|
||||
* vs. a plain reference) therefore resolve to their contextually correct type.
|
||||
*/
|
||||
export function elementTypeIn(
|
||||
parentElementName: string | null,
|
||||
childName: string,
|
||||
): string | null {
|
||||
if (parentElementName) {
|
||||
const parentType = elementTypeName(parentElementName);
|
||||
const typed = childTypeOf(parentType, childName);
|
||||
if (typed) return typed;
|
||||
}
|
||||
return elementTypeName(childName);
|
||||
}
|
||||
|
||||
export function typeDoc(name: string): string {
|
||||
const info = model.types[name];
|
||||
return info?.doc ?? "";
|
||||
}
|
||||
|
||||
/** The type itself plus all ancestors (nearest first). */
|
||||
export function typeChain(name: string): string[] {
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
let cur: string | null = canonicalTypeName(name);
|
||||
while (cur && !seen.has(cur)) {
|
||||
seen.add(cur);
|
||||
out.push(cur);
|
||||
const info: TypeInfo | undefined = model.types[cur];
|
||||
cur = info && "base" in info && info.base ? canonicalTypeName(info.base) : null;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when an asset of type `actualType` satisfies a reference to `refType`.
|
||||
* Falls back to exact name matching; unknown types only match exactly.
|
||||
*/
|
||||
export function isAssignableTo(actualType: string, refType: string | null): boolean {
|
||||
if (!refType) return true;
|
||||
const actual = canonicalTypeName(actualType) ?? actualType;
|
||||
const ref = canonicalTypeName(refType) ?? refType;
|
||||
if (actual === ref) return true;
|
||||
return typeChain(actual).includes(ref);
|
||||
}
|
||||
|
||||
/** Maps a manifest TypeId hash to a type name, when known. */
|
||||
export function assetTypeNameFromHash(hash: number): string | undefined {
|
||||
return (assetTypes as { types: Record<string, string> }).types[hash];
|
||||
}
|
||||
|
||||
export function assetTypeHashCount(): number {
|
||||
return (assetTypes as { count: number }).count ?? 0;
|
||||
}
|
||||
|
||||
/** Elements that are structurally relevant everywhere. */
|
||||
export const STRUCTURAL_ELEMENTS = [
|
||||
"AssetDeclaration",
|
||||
"Includes",
|
||||
"Include",
|
||||
"Tags",
|
||||
"Tag",
|
||||
"Defines",
|
||||
"Define",
|
||||
];
|
||||
@@ -0,0 +1,32 @@
|
||||
import * as vscode from "vscode";
|
||||
import { join } from "node:path";
|
||||
|
||||
export interface ExtensionSettings {
|
||||
sdkPath: string;
|
||||
indexSageXml: boolean;
|
||||
reportUnresolvedReferences: "warning" | "information" | "none";
|
||||
diagnoseUnknownElements: boolean;
|
||||
definitionMode: "all" | "project-only";
|
||||
additionalDataSearchPaths: string[];
|
||||
builtmodsDirs: string[];
|
||||
}
|
||||
|
||||
export function readSettings(): ExtensionSettings {
|
||||
const cfg = vscode.workspace.getConfiguration("ra3modxml");
|
||||
const sdkPath = cfg.get<string>("sdkPath", "C:\\Apps\\RA3-MODSDK-X");
|
||||
return {
|
||||
sdkPath,
|
||||
indexSageXml: cfg.get<boolean>("indexSageXml", true),
|
||||
reportUnresolvedReferences: cfg.get<string>(
|
||||
"reportUnresolvedReferences",
|
||||
"warning",
|
||||
) as ExtensionSettings["reportUnresolvedReferences"],
|
||||
diagnoseUnknownElements: cfg.get<boolean>("diagnoseUnknownElements", true),
|
||||
definitionMode: cfg.get<string>(
|
||||
"definitionMode",
|
||||
"all",
|
||||
) as ExtensionSettings["definitionMode"],
|
||||
additionalDataSearchPaths: cfg.get<string[]>("additionalDataSearchPaths", []),
|
||||
builtmodsDirs: [join(sdkPath, "builtmods"), join(sdkPath, "builtmods-quantum")],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import * as vscode from "vscode";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { CachedDirectoryWalker } from "./indexer/fileScanner";
|
||||
import { ModIndexer } from "./indexer/indexer";
|
||||
import type { ModIndex } from "./indexer/types";
|
||||
import { readSettings, type ExtensionSettings } from "./settings";
|
||||
|
||||
const REBUILD_DEBOUNCE_MS = 1500;
|
||||
|
||||
export class ModWorkspace {
|
||||
index: ModIndex | null = null;
|
||||
indexer: ModIndexer | null = null;
|
||||
projectRoot: string | null = null;
|
||||
settings: ExtensionSettings;
|
||||
|
||||
private walker = new CachedDirectoryWalker();
|
||||
private statusBar: vscode.StatusBarItem;
|
||||
private rebuildTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private building = false;
|
||||
private dirty = false;
|
||||
|
||||
constructor(context: vscode.ExtensionContext) {
|
||||
this.settings = readSettings();
|
||||
this.statusBar = vscode.window.createStatusBarItem(
|
||||
vscode.StatusBarAlignment.Left,
|
||||
100,
|
||||
);
|
||||
this.statusBar.name = "RA3 Mod XML";
|
||||
this.statusBar.command = "ra3modxml.openIndexReport";
|
||||
context.subscriptions.push(this.statusBar);
|
||||
}
|
||||
|
||||
isRa3Workspace(): boolean {
|
||||
return this.projectRoot != null;
|
||||
}
|
||||
|
||||
detectProjectRoot(): string | null {
|
||||
const folders = vscode.workspace.workspaceFolders;
|
||||
if (!folders?.length) return null;
|
||||
for (const folder of folders) {
|
||||
const found = findProjectRoot(folder.uri.fsPath);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
this.projectRoot = this.detectProjectRoot();
|
||||
if (!this.projectRoot) {
|
||||
this.statusBar.hide();
|
||||
return;
|
||||
}
|
||||
this.statusBar.text = "$(sync~spin) RA3 XML: indexing…";
|
||||
this.statusBar.show();
|
||||
await this.rebuild();
|
||||
}
|
||||
|
||||
scheduleRebuild(): void {
|
||||
if (!this.projectRoot) return;
|
||||
if (this.rebuildTimer) clearTimeout(this.rebuildTimer);
|
||||
this.rebuildTimer = setTimeout(() => {
|
||||
void this.rebuild();
|
||||
}, REBUILD_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
async rebuild(): Promise<void> {
|
||||
if (!this.projectRoot) return;
|
||||
if (this.building) {
|
||||
this.dirty = true;
|
||||
return;
|
||||
}
|
||||
this.building = true;
|
||||
this.settings = readSettings();
|
||||
try {
|
||||
this.statusBar.text = "$(sync~spin) RA3 XML: indexing…";
|
||||
const indexer = new ModIndexer({
|
||||
projectDir: this.projectRoot,
|
||||
sdkDir: this.settings.sdkPath,
|
||||
builtmodsDirs: this.settings.builtmodsDirs,
|
||||
indexSageXml: this.settings.indexSageXml,
|
||||
additionalDataSearchPaths: this.settings.additionalDataSearchPaths,
|
||||
walker: this.walker,
|
||||
});
|
||||
const started = Date.now();
|
||||
this.index = await indexer.build();
|
||||
this.indexer = indexer;
|
||||
const secs = ((Date.now() - started) / 1000).toFixed(1);
|
||||
const s = this.index.stats;
|
||||
this.statusBar.text = `$(symbol-misc) RA3 XML: ${formatCount(s.assetCount)} assets`;
|
||||
this.statusBar.tooltip =
|
||||
`${s.projectDir}\n` +
|
||||
`${s.indexedFiles} files indexed (${secs}s)\n` +
|
||||
`${s.assetCount} assets (${s.manifestAssetCount} from ${s.manifestFiles} manifests)\n` +
|
||||
`${s.defineCount} defines, ${s.streams} streams, ${s.sourceCandidates} include candidates`;
|
||||
} catch (err) {
|
||||
this.index = null;
|
||||
this.statusBar.text = "$(error) RA3 XML: indexing failed";
|
||||
this.statusBar.tooltip = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
this.building = false;
|
||||
if (this.dirty) {
|
||||
this.dirty = false;
|
||||
void this.rebuild();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Parses the (possibly unsaved) in-memory text of the active document. */
|
||||
async parseText(path: string, text: string) {
|
||||
const { parseXml, LineMap } = await import("./language/xmlParser");
|
||||
const parse = parseXml(text);
|
||||
return {
|
||||
file: { path, stat: null },
|
||||
parse,
|
||||
lineMap: new LineMap(text),
|
||||
};
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.rebuildTimer) clearTimeout(this.rebuildTimer);
|
||||
this.statusBar.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
function findProjectRoot(startDir: string): string | null {
|
||||
let dir = startDir;
|
||||
// Guard against walking above reasonable roots.
|
||||
for (let i = 0; i < 12; i++) {
|
||||
try {
|
||||
if (existsSync(join(dir, "Data", "Mod.xml"))) return dir;
|
||||
if (existsSync(join(dir, "mod.babproj"))) return dir;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const parent = dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatCount(n: number): string {
|
||||
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
|
||||
}
|
||||
Reference in New Issue
Block a user