first commit

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