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
+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;
}