first commit
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
import { readdir, stat } from "node:fs/promises";
|
||||
import { join, relative, resolve } from "node:path";
|
||||
import type { FileWalker, SourceCandidate } from "./types";
|
||||
|
||||
/**
|
||||
* Recursive file list walker with a simple directory-mtime cache. Used to
|
||||
* enumerate candidate files for Include/@source completion. Directory scans
|
||||
* outside the workspace (e.g. the SDK) are cached until the directory mtime
|
||||
* changes.
|
||||
*/
|
||||
export class CachedDirectoryWalker implements FileWalker {
|
||||
private cache = new Map<string, { mtimeMs: number; files: string[] }>();
|
||||
|
||||
async listFiles(dir: string): Promise<string[]> {
|
||||
const key = dir.toLowerCase();
|
||||
try {
|
||||
const dirStat = await stat(dir);
|
||||
const cached = this.cache.get(key);
|
||||
if (cached && cached.mtimeMs === dirStat.mtimeMs) {
|
||||
return cached.files;
|
||||
}
|
||||
const files: string[] = [];
|
||||
await this.walk(dir, files);
|
||||
this.cache.set(key, { mtimeMs: dirStat.mtimeMs, files });
|
||||
return files;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async walk(dir: string, out: string[]): Promise<void> {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await this.walk(full, out);
|
||||
} else if (entry.isFile()) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the candidate list for Include/@source completion from a set of
|
||||
* search directories. For DATA directories only *.xml files are listed; for
|
||||
* ART/AUDIO directories every file is listed (includes commonly point at
|
||||
* .w3x/.dds/.mpf files there).
|
||||
*/
|
||||
export async function collectSourceCandidates(
|
||||
walker: FileWalker,
|
||||
dataDirs: string[],
|
||||
artDirs: string[],
|
||||
audioDirs: string[],
|
||||
projectDataDir: string,
|
||||
): Promise<SourceCandidate[]> {
|
||||
const out: SourceCandidate[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const add = (path: string, baseDir: string, prefix: "DATA" | "ART" | "AUDIO" | null) => {
|
||||
const rel = relative(baseDir, path).replace(/\\/g, "/");
|
||||
if (rel.startsWith("..")) return;
|
||||
const source = prefix ? `${prefix}:${rel}` : rel;
|
||||
const key = `${prefix ?? ""}:${source.toLowerCase()}`;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
out.push({ source, path: resolve(path), prefix, baseDir: resolve(baseDir) });
|
||||
};
|
||||
|
||||
for (const dir of dataDirs) {
|
||||
const files = await walker.listFiles(dir);
|
||||
for (const f of files) {
|
||||
if (!f.toLowerCase().endsWith(".xml")) continue;
|
||||
add(f, dir, "DATA");
|
||||
}
|
||||
if (samePath(dir, projectDataDir)) {
|
||||
// Also offer project-relative paths (what Mod.xml itself uses).
|
||||
for (const f of files) {
|
||||
if (f.toLowerCase().endsWith(".xml")) add(f, projectDataDir, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const dir of artDirs) {
|
||||
for (const f of await walker.listFiles(dir)) {
|
||||
add(f, dir, "ART");
|
||||
}
|
||||
}
|
||||
for (const dir of audioDirs) {
|
||||
for (const f of await walker.listFiles(dir)) {
|
||||
add(f, dir, "AUDIO");
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function samePath(a: string, b: string): boolean {
|
||||
return resolve(a).toLowerCase() === resolve(b).toLowerCase();
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Include path resolution for RA3 Mod XML, ported from the reference
|
||||
* implementation in check_duplicate_ids.py.
|
||||
*
|
||||
* Pure TypeScript: no vscode dependency, so the module can be reused outside
|
||||
* the extension (e.g. by search/analysis tools).
|
||||
*/
|
||||
|
||||
import { join, resolve, normalize, isAbsolute } from "node:path";
|
||||
import { statSync } from "node:fs";
|
||||
|
||||
export type IncludeKind = "all" | "instance" | "reference";
|
||||
export type SourcePrefix = "DATA" | "ART" | "AUDIO" | null;
|
||||
|
||||
export interface SearchPaths {
|
||||
DATA: string[];
|
||||
ART: string[];
|
||||
AUDIO: string[];
|
||||
}
|
||||
|
||||
export interface ResolveResult {
|
||||
path: string | null;
|
||||
prefix: SourcePrefix;
|
||||
raw: string;
|
||||
}
|
||||
|
||||
const PREFIXES: Exclude<SourcePrefix, null>[] = ["DATA", "ART", "AUDIO"];
|
||||
|
||||
/**
|
||||
* Builds the search path lists used by the SDK compiler:
|
||||
* (from defaultscript.cs getIncludePaths(), where "." is the SDK root):
|
||||
* DATA: sdk -> modGranParent -> project/Data -> sdk/Mods -> modParentPath -> sdk/SageXml
|
||||
* ART: sdk -> modGranParent -> project/Art1 -> project/Art -> sdk/Mods -> modParentPath -> sdk/Art
|
||||
* AUDIO: sdk -> modGranParent -> project/Audio1 -> project/Audio -> sdk/Mods -> modParentPath -> sdk/Audio
|
||||
*
|
||||
* `extra` directories (from user settings) are appended after the defaults
|
||||
* for their matching prefix.
|
||||
*/
|
||||
export function buildSearchPaths(
|
||||
sdkDir: string,
|
||||
projectDir: string,
|
||||
extra?: Partial<Record<"DATA" | "ART" | "AUDIO", string[]>>,
|
||||
): SearchPaths {
|
||||
const modParentPath = resolve(projectDir, "..");
|
||||
const modGranParent = resolve(modParentPath, "..");
|
||||
return {
|
||||
DATA: [
|
||||
sdkDir,
|
||||
modGranParent,
|
||||
join(projectDir, "Data"),
|
||||
join(sdkDir, "Mods"),
|
||||
modParentPath,
|
||||
join(sdkDir, "SageXml"),
|
||||
...(extra?.DATA ?? []),
|
||||
],
|
||||
ART: [
|
||||
sdkDir,
|
||||
modGranParent,
|
||||
join(projectDir, "Art1"),
|
||||
join(projectDir, "Art"),
|
||||
join(sdkDir, "Mods"),
|
||||
modParentPath,
|
||||
join(sdkDir, "Art"),
|
||||
...(extra?.ART ?? []),
|
||||
],
|
||||
AUDIO: [
|
||||
sdkDir,
|
||||
modGranParent,
|
||||
join(projectDir, "Audio1"),
|
||||
join(projectDir, "Audio"),
|
||||
join(sdkDir, "Mods"),
|
||||
modParentPath,
|
||||
join(sdkDir, "Audio"),
|
||||
...(extra?.AUDIO ?? []),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function splitPrefix(source: string): { prefix: SourcePrefix; rest: string } {
|
||||
for (const prefix of PREFIXES) {
|
||||
if (source.toUpperCase().startsWith(`${prefix}:`)) {
|
||||
return { prefix, rest: source.slice(prefix.length + 1).replace(/^[/\\]+/, "") };
|
||||
}
|
||||
}
|
||||
return { prefix: null, rest: source.replace(/^[/\\]+/, "") };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves an Include/@source (or xi:include/@href) to an absolute file path,
|
||||
* or null when not found.
|
||||
*
|
||||
* - DATA:/ART:/AUDIO: prefixes are resolved against the corresponding search
|
||||
* paths, in order.
|
||||
* - ART: paths without a directory separator also try the 2-letter prefix
|
||||
* subdirectory (e.g. JUAntiShip -> ju/JUAntiShip).
|
||||
* - Paths without a prefix are resolved relative to the including file.
|
||||
*/
|
||||
export function resolveSource(
|
||||
source: string,
|
||||
currentDir: string | null,
|
||||
searchPaths: SearchPaths,
|
||||
): ResolveResult {
|
||||
const raw = source.trim().replace(/\\/g, "/");
|
||||
const { prefix, rest } = splitPrefix(raw);
|
||||
|
||||
if (prefix) {
|
||||
const bases = searchPaths[prefix] ?? [];
|
||||
const direct = findInBases(rest, bases);
|
||||
if (direct) return { path: direct, prefix, raw };
|
||||
if (prefix === "ART" && !rest.includes("/")) {
|
||||
const two = rest.slice(0, 2).toLowerCase();
|
||||
const prefixed = findInBases(`${two}/${rest}`, bases);
|
||||
if (prefixed) return { path: prefixed, prefix, raw };
|
||||
}
|
||||
return { path: null, prefix, raw };
|
||||
}
|
||||
|
||||
if (currentDir && isAbsolute(rest)) {
|
||||
return { path: fileExists(rest) ? rest : null, prefix: null, raw };
|
||||
}
|
||||
if (currentDir) {
|
||||
const candidate = resolve(currentDir, rest);
|
||||
return { path: fileExists(candidate) ? candidate : null, prefix: null, raw };
|
||||
}
|
||||
return { path: null, prefix: null, raw };
|
||||
}
|
||||
|
||||
function findInBases(relPath: string, bases: string[]): string | null {
|
||||
for (const base of bases) {
|
||||
const candidate = normalize(resolve(base, relPath));
|
||||
if (fileExists(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function fileExists(path: string): boolean {
|
||||
try {
|
||||
return statSync(path).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* For `<Include type="reference" source="DATA:static.xml">`, returns the
|
||||
* compiled manifest file that backs the placeholder, when present in one of
|
||||
* the builtmods directories. The placeholder file name maps to
|
||||
* `<name>.manifest` (e.g. static.xml -> static.manifest).
|
||||
*/
|
||||
export function manifestPathForReference(
|
||||
source: string,
|
||||
builtmodsDirs: string[],
|
||||
): string | null {
|
||||
const base = basenameWithoutExt(stripPrefix(source).replace(/\\/g, "/"));
|
||||
if (!base) return null;
|
||||
for (const dir of builtmodsDirs) {
|
||||
const candidate = join(dir, `${base}.manifest`);
|
||||
try {
|
||||
if (statSync(candidate).isFile()) return candidate;
|
||||
} catch {
|
||||
// continue
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function basenameWithoutExt(path: string): string {
|
||||
const idx = path.lastIndexOf("/");
|
||||
const file = idx >= 0 ? path.slice(idx + 1) : path;
|
||||
const dot = file.lastIndexOf(".");
|
||||
return dot > 0 ? file.slice(0, dot) : file;
|
||||
}
|
||||
|
||||
function stripPrefix(source: string): string {
|
||||
const idx = source.indexOf(":");
|
||||
return idx >= 0 ? source.slice(idx + 1) : source;
|
||||
}
|
||||
@@ -0,0 +1,593 @@
|
||||
/**
|
||||
* Workspace indexer for RA3 Mod XML.
|
||||
*
|
||||
* Walks the include graph from Data/Mod.xml (static stream) and
|
||||
* Data/additionalmaps/mapmetadata_*.xml (global streams), collects asset
|
||||
* definitions, `$DEFINE` constants, resolved `reference` includes (parsed
|
||||
* from compiled .manifest files) and a file-name index used for
|
||||
* Include/@source completion.
|
||||
*
|
||||
* Pure TypeScript (no vscode dependency) so the indexing core can be reused
|
||||
* outside the extension.
|
||||
*/
|
||||
|
||||
import { readFile, readdir, stat } from "node:fs/promises";
|
||||
import { basename, dirname, extname, join, resolve } from "node:path";
|
||||
import { LineMap, parseXml, type XmlDocument, type XmlElement } from "../language/xmlParser";
|
||||
import {
|
||||
buildSearchPaths,
|
||||
manifestPathForReference,
|
||||
resolveSource,
|
||||
type SearchPaths,
|
||||
} from "./includeResolver";
|
||||
import {
|
||||
deriveAssetId,
|
||||
deriveAssetType,
|
||||
parseManifest,
|
||||
type ManifestInfo,
|
||||
} from "./manifestParser";
|
||||
import { canonicalTypeName } from "../model/schemaModel";
|
||||
import { collectSourceCandidates } from "./fileScanner";
|
||||
import type {
|
||||
AssetDef,
|
||||
DefineDef,
|
||||
IndexOptions,
|
||||
IndexedFile,
|
||||
ModIndex,
|
||||
ParsedFile,
|
||||
SourceCandidate,
|
||||
StreamInfo,
|
||||
} from "./types";
|
||||
|
||||
const MAX_DEPTH = 300;
|
||||
/** Files above this size are never parsed (safety against binary blobs). */
|
||||
const MAX_PARSE_BYTES = 4 * 1024 * 1024;
|
||||
/** Only these extensions are treated as XML documents. */
|
||||
const XML_EXTENSIONS = new Set([".xml", ".manifestxml"]);
|
||||
|
||||
function normKey(path: string): string {
|
||||
return resolve(path).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* LRU cache for parsed documents. The parse trees of huge mods can be
|
||||
* memory-heavy, so only a bounded number of recent documents is retained;
|
||||
* evicted entries are re-read from disk on demand.
|
||||
*/
|
||||
export class DocumentCache {
|
||||
private map = new Map<string, ParsedFile>();
|
||||
|
||||
constructor(private capacity = 64) {}
|
||||
|
||||
get(path: string): ParsedFile | undefined {
|
||||
const key = normKey(path);
|
||||
const hit = this.map.get(key);
|
||||
if (!hit) return undefined;
|
||||
this.map.delete(key);
|
||||
this.map.set(key, hit);
|
||||
return hit;
|
||||
}
|
||||
|
||||
set(parsed: ParsedFile): void {
|
||||
const key = normKey(parsed.file.path);
|
||||
this.map.delete(key);
|
||||
this.map.set(key, parsed);
|
||||
if (this.map.size > this.capacity) {
|
||||
const oldest = this.map.keys().next().value;
|
||||
if (oldest !== undefined) this.map.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
invalidate(path: string): void {
|
||||
this.map.delete(normKey(path));
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.map.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export class ModIndexer {
|
||||
private searchPaths: SearchPaths;
|
||||
private docs = new DocumentCache();
|
||||
private assets = new Map<string, Map<string, AssetDef[]>>();
|
||||
private assetsById = new Map<string, AssetDef[]>();
|
||||
private defines = new Map<string, DefineDef[]>();
|
||||
private files = new Map<string, IndexedFile>();
|
||||
private streams: StreamInfo[] = [];
|
||||
private manifests = new Map<string, ManifestInfo>();
|
||||
private sourceCandidates: SourceCandidate[] = [];
|
||||
private diagnostics: ModIndex["diagnostics"] = [];
|
||||
private visitedAll = new Set<string>();
|
||||
private visitedInstance = new Set<string>();
|
||||
private manifestAssetKeys = new Set<string>();
|
||||
|
||||
constructor(private opts: IndexOptions) {
|
||||
this.searchPaths = buildSearchPaths(opts.sdkDir, opts.projectDir, {
|
||||
DATA: opts.additionalDataSearchPaths,
|
||||
});
|
||||
}
|
||||
|
||||
/** Re-reads and caches a document; null when unreadable. */
|
||||
async readDocument(path: string): Promise<ParsedFile | null> {
|
||||
const hit = this.docs.get(path);
|
||||
if (hit) return hit;
|
||||
try {
|
||||
const [st, text] = await Promise.all([stat(path), readFile(path, "utf8")]);
|
||||
if (st.size > MAX_PARSE_BYTES) {
|
||||
const file: IndexedFile = { path: resolve(path), stat: { mtimeMs: st.mtimeMs, size: st.size } };
|
||||
const parsed: ParsedFile = { file, parse: null, lineMap: null };
|
||||
this.docs.set(parsed);
|
||||
this.files.set(normKey(parsed.file.path), file);
|
||||
return parsed;
|
||||
}
|
||||
const parse = parseXml(text);
|
||||
const parsed: ParsedFile = {
|
||||
file: { path: resolve(path), stat: { mtimeMs: st.mtimeMs, size: st.size } },
|
||||
parse,
|
||||
lineMap: new LineMap(text),
|
||||
};
|
||||
this.docs.set(parsed);
|
||||
this.files.set(normKey(parsed.file.path), parsed.file);
|
||||
return parsed;
|
||||
} catch {
|
||||
const parsed: ParsedFile = {
|
||||
file: { path: resolve(path), stat: null },
|
||||
parse: null,
|
||||
lineMap: null,
|
||||
};
|
||||
this.docs.set(parsed);
|
||||
this.files.set(normKey(parsed.file.path), parsed.file);
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the cached parse if present (does not read from disk). */
|
||||
cachedDocument(path: string): ParsedFile | undefined {
|
||||
return this.docs.get(path);
|
||||
}
|
||||
|
||||
async build(): Promise<ModIndex> {
|
||||
const start = Date.now();
|
||||
const projectData = await findCaseInsensitiveDir(join(this.opts.projectDir, "Data"));
|
||||
const additionalMaps = projectData
|
||||
? await findCaseInsensitiveDir(join(projectData, "additionalmaps"))
|
||||
: null;
|
||||
|
||||
// ── Streams ──
|
||||
const staticEntry = projectData ? join(projectData, "Mod.xml") : null;
|
||||
if (staticEntry) {
|
||||
const stream: StreamInfo = { name: "static", entry: staticEntry, files: new Set() };
|
||||
this.streams.push(stream);
|
||||
await this.walk(staticEntry, "all", stream, 0);
|
||||
}
|
||||
|
||||
if (additionalMaps) {
|
||||
let entries: string[] = [];
|
||||
try {
|
||||
entries = await readdir(additionalMaps);
|
||||
} catch {
|
||||
entries = [];
|
||||
}
|
||||
const metadataFiles = entries
|
||||
.filter((f) => /^mapmetadata_.*\.xml$/i.test(f))
|
||||
.sort();
|
||||
for (const f of metadataFiles) {
|
||||
const entry = join(additionalMaps, f);
|
||||
const stream: StreamInfo = {
|
||||
name: `global:${basename(f, ".xml")}`,
|
||||
entry,
|
||||
files: new Set(),
|
||||
};
|
||||
this.streams.push(stream);
|
||||
await this.walk(entry, "all", stream, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Source completion candidates ──
|
||||
const dataDirs = [
|
||||
projectData ?? join(this.opts.projectDir, "Data"),
|
||||
join(this.opts.sdkDir, "SageXml"),
|
||||
...this.opts.additionalDataSearchPaths,
|
||||
];
|
||||
if (!this.opts.indexSageXml) {
|
||||
const sage = join(this.opts.sdkDir, "SageXml");
|
||||
const idx = dataDirs.findIndex((d) => normKey(d) === normKey(sage));
|
||||
if (idx >= 0) dataDirs.splice(idx, 1);
|
||||
}
|
||||
const artDirs = [
|
||||
join(this.opts.projectDir, "Art1"),
|
||||
join(this.opts.projectDir, "Art"),
|
||||
join(this.opts.sdkDir, "Art"),
|
||||
];
|
||||
const audioDirs = [
|
||||
join(this.opts.projectDir, "Audio1"),
|
||||
join(this.opts.projectDir, "Audio"),
|
||||
join(this.opts.sdkDir, "Audio"),
|
||||
];
|
||||
this.sourceCandidates = await collectSourceCandidates(
|
||||
this.opts.walker,
|
||||
dataDirs,
|
||||
artDirs,
|
||||
audioDirs,
|
||||
projectData ?? join(this.opts.projectDir, "Data"),
|
||||
);
|
||||
// The SDK root itself is the first DATA: search base (static.xml,
|
||||
// global.xml, audio.xml placeholders) but only its shallow XML files are
|
||||
// relevant. These candidates take precedence over same-named files found
|
||||
// deeper in the search paths (e.g. SageXml/Static.xml).
|
||||
const sdkRootXml = (await readdir(this.opts.sdkDir)).filter(
|
||||
(f) => f.toLowerCase().endsWith(".xml"),
|
||||
);
|
||||
const sdkRootCandidates: SourceCandidate[] = sdkRootXml.map((f) => ({
|
||||
source: `DATA:${f}`,
|
||||
path: resolve(this.opts.sdkDir, f),
|
||||
prefix: "DATA",
|
||||
baseDir: resolve(this.opts.sdkDir),
|
||||
}));
|
||||
this.sourceCandidates = dedupeSourceCandidates([
|
||||
...sdkRootCandidates,
|
||||
...this.sourceCandidates,
|
||||
]);
|
||||
|
||||
const manifestAssetCount = [...this.manifests.values()].reduce(
|
||||
(sum, m) => sum + m.assets.length,
|
||||
0,
|
||||
);
|
||||
|
||||
return {
|
||||
projectDir: resolve(this.opts.projectDir),
|
||||
sdkDir: resolve(this.opts.sdkDir),
|
||||
assets: this.assets,
|
||||
assetsById: this.assetsById,
|
||||
defines: this.defines,
|
||||
files: this.files,
|
||||
streams: this.streams,
|
||||
manifests: this.manifests,
|
||||
sourceCandidates: this.sourceCandidates,
|
||||
diagnostics: this.diagnostics,
|
||||
stats: {
|
||||
projectDir: resolve(this.opts.projectDir),
|
||||
sdkDir: resolve(this.opts.sdkDir),
|
||||
indexedFiles: this.files.size,
|
||||
parsedFiles: [...this.files.values()].filter(
|
||||
(f) => f.stat != null && f.stat.size <= MAX_PARSE_BYTES,
|
||||
).length,
|
||||
assetCount: [...this.assets.values()].reduce((sum, byId) => sum + byId.size, 0),
|
||||
defineCount: this.defines.size,
|
||||
manifestFiles: this.manifests.size,
|
||||
manifestAssetCount,
|
||||
streams: this.streams.length,
|
||||
sourceCandidates: this.sourceCandidates.length,
|
||||
elapsedMs: Date.now() - start,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Include walk ──────────────────────────────────────────────────
|
||||
|
||||
private async walk(
|
||||
path: string,
|
||||
mode: "all" | "instance",
|
||||
stream: StreamInfo,
|
||||
depth: number,
|
||||
): Promise<void> {
|
||||
const key = normKey(path);
|
||||
if (depth > MAX_DEPTH) {
|
||||
this.diagnostics.push({
|
||||
file: path,
|
||||
line: 0,
|
||||
message: "Include depth exceeded - possible include cycle",
|
||||
severity: "warning",
|
||||
code: "include-cycle",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (mode === "all") {
|
||||
if (this.visitedAll.has(key)) return;
|
||||
this.visitedAll.add(key);
|
||||
} else {
|
||||
if (this.visitedInstance.has(key)) return;
|
||||
this.visitedInstance.add(key);
|
||||
if (this.visitedAll.has(key)) return;
|
||||
}
|
||||
|
||||
stream.files.add(key);
|
||||
|
||||
// Binary assets (w3x/dds/...) are referenced but never parsed.
|
||||
if (!isXmlPath(path)) return;
|
||||
|
||||
const parsed = await this.readDocument(path);
|
||||
if (!parsed?.parse?.root) return;
|
||||
const root = parsed.parse.root;
|
||||
|
||||
for (const child of root.children) {
|
||||
const local = localName(child.name);
|
||||
if (local === "Tags" || local === "Includes" || local === "Defines") continue;
|
||||
if (local === "include") {
|
||||
await this.handleXiInclude(child, parsed, stream, depth);
|
||||
continue;
|
||||
}
|
||||
const idAttr = child.attrs.find((a) => a.name === "id");
|
||||
if (idAttr) {
|
||||
this.addAsset({
|
||||
type: local,
|
||||
id: idAttr.value,
|
||||
file: parsed.file.path,
|
||||
line: lineOf(parsed, idAttr.valueStart),
|
||||
origin: this.originOf(parsed.file.path),
|
||||
stream: stream.name,
|
||||
viaInstance: mode === "instance",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const child of root.children) {
|
||||
if (localName(child.name) !== "Defines") continue;
|
||||
for (const define of child.children) {
|
||||
if (localName(define.name) !== "Define") continue;
|
||||
const name = define.attrs.find((a) => a.name === "name")?.value;
|
||||
const value = define.attrs.find((a) => a.name === "value")?.value;
|
||||
if (!name) continue;
|
||||
const entry: DefineDef = {
|
||||
name,
|
||||
value: value ?? "",
|
||||
file: parsed.file.path,
|
||||
line: lineOf(parsed, define.start),
|
||||
origin: this.originOf(parsed.file.path),
|
||||
};
|
||||
const arr = this.defines.get(name.toLowerCase());
|
||||
if (arr) arr.push(entry);
|
||||
else this.defines.set(name.toLowerCase(), [entry]);
|
||||
}
|
||||
}
|
||||
|
||||
const includesElem = root.children.find((c) => localName(c.name) === "Includes");
|
||||
if (includesElem) {
|
||||
for (const inc of includesElem.children) {
|
||||
if (localName(inc.name) !== "Include") continue;
|
||||
const type = inc.attrs.find((a) => a.name === "type")?.value;
|
||||
const source = inc.attrs.find((a) => a.name === "source")?.value;
|
||||
if (!source) continue;
|
||||
const resolved = resolveSource(source, dirname(parsed.file.path), this.searchPaths);
|
||||
if (!resolved.path) {
|
||||
this.diagnostics.push({
|
||||
file: parsed.file.path,
|
||||
line: lineOf(parsed, inc.start),
|
||||
message: `Include target not found: ${source}`,
|
||||
severity: "warning",
|
||||
code: "include-not-found",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (type === "all" || type === "instance") {
|
||||
await this.walk(resolved.path, type === "all" ? "all" : "instance", stream, depth + 1);
|
||||
} else if (type === "reference") {
|
||||
const manifestPath = manifestPathForReference(source, this.opts.builtmodsDirs);
|
||||
if (manifestPath) {
|
||||
const loaded = await this.loadManifest(manifestPath, stream.name);
|
||||
if (!loaded && isXmlPath(resolved.path)) {
|
||||
// The manifest could not be parsed (missing/invalid): fall back
|
||||
// to the placeholder XML so its content is still available.
|
||||
await this.walk(resolved.path, "instance", stream, depth + 1);
|
||||
}
|
||||
} else if (isXmlPath(resolved.path)) {
|
||||
// reference to a real XML file: treat its assets as available
|
||||
await this.walk(resolved.path, "instance", stream, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Nested <xi:include> anywhere in the tree (not just under the root):
|
||||
// the target content is inlined into the parent element. We make the
|
||||
// target file available and surface missing targets instead of ignoring
|
||||
// them silently.
|
||||
for (const el of parsed.parse.elements) {
|
||||
if (localName(el.name) !== "include") continue;
|
||||
if (el.parent === root) continue; // already handled in the loop above
|
||||
const href = el.attrs.find((a) => a.name === "href")?.value;
|
||||
if (!href) continue;
|
||||
const resolved = resolveSource(href, dirname(parsed.file.path), this.searchPaths);
|
||||
if (!resolved.path) {
|
||||
this.diagnostics.push({
|
||||
file: parsed.file.path,
|
||||
line: lineOf(parsed, el.start),
|
||||
message: `xi:include target not found: ${href}`,
|
||||
severity: "warning",
|
||||
code: "include-not-found",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
stream.files.add(normKey(resolved.path));
|
||||
if (isXmlPath(resolved.path)) {
|
||||
await this.walk(resolved.path, "all", stream, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async handleXiInclude(
|
||||
xi: XmlElement,
|
||||
parent: ParsedFile,
|
||||
stream: StreamInfo,
|
||||
depth: number,
|
||||
): Promise<void> {
|
||||
const href = xi.attrs.find((a) => a.name === "href")?.value;
|
||||
if (!href) return;
|
||||
const resolved = resolveSource(href, dirname(parent.file.path), this.searchPaths);
|
||||
if (!resolved.path) {
|
||||
this.diagnostics.push({
|
||||
file: parent.file.path,
|
||||
line: lineOf(parent, xi.start),
|
||||
message: `xi:include target not found: ${href}`,
|
||||
severity: "warning",
|
||||
code: "include-not-found",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!isXmlPath(resolved.path)) return;
|
||||
const target = await this.readDocument(resolved.path);
|
||||
if (!target?.parse?.root) return;
|
||||
|
||||
const xpointer = xi.attrs.find((a) => a.name === "xpointer")?.value ?? "";
|
||||
let candidates: XmlElement[];
|
||||
if (xpointer) {
|
||||
const container = findXPointerContainer(target.parse, xpointer);
|
||||
candidates = container ? container.children : [];
|
||||
} else {
|
||||
candidates = target.parse.root.children;
|
||||
}
|
||||
for (const el of candidates) {
|
||||
const local = localName(el.name);
|
||||
if (local === "Tags" || local === "Includes" || local === "Defines") continue;
|
||||
const idAttr = el.attrs.find((a) => a.name === "id");
|
||||
if (idAttr) {
|
||||
this.addAsset({
|
||||
type: local,
|
||||
id: idAttr.value,
|
||||
file: target.file.path,
|
||||
line: lineOf(target, idAttr.valueStart),
|
||||
origin: this.originOf(target.file.path),
|
||||
stream: stream.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
stream.files.add(normKey(target.file.path));
|
||||
await this.walk(target.file.path, "all", stream, depth + 1);
|
||||
}
|
||||
|
||||
// ── Manifest loading ──────────────────────────────────────────────
|
||||
|
||||
/** Returns true when the manifest was parsed successfully. */
|
||||
private async loadManifest(path: string, streamName: string): Promise<boolean> {
|
||||
const key = normKey(path);
|
||||
let info = this.manifests.get(key);
|
||||
if (!info) {
|
||||
try {
|
||||
const data = await readFile(path);
|
||||
const buffer = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
||||
info = parseManifest(buffer);
|
||||
} catch {
|
||||
this.diagnostics.push({
|
||||
file: path,
|
||||
line: 0,
|
||||
message: "Manifest file could not be read",
|
||||
severity: "warning",
|
||||
code: "manifest-read-error",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
this.manifests.set(key, info);
|
||||
}
|
||||
if (info.error) return false;
|
||||
for (const asset of info.assets) {
|
||||
if (!asset.name) continue;
|
||||
const id = deriveAssetId(asset.name);
|
||||
const assetKey = `${asset.typeId}:${id.toLowerCase()}`;
|
||||
if (this.manifestAssetKeys.has(assetKey)) continue;
|
||||
this.manifestAssetKeys.add(assetKey);
|
||||
this.addAsset({
|
||||
type:
|
||||
canonicalTypeName(deriveAssetType(asset.typeName, asset.name)) ??
|
||||
`#${asset.typeId.toString(16)}`,
|
||||
id,
|
||||
file: path,
|
||||
line: 0,
|
||||
origin: "manifest",
|
||||
stream: streamName,
|
||||
manifest: path,
|
||||
manifestSource: asset.sourceFileName,
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
private originOf(path: string): "project" | "sdk" {
|
||||
const p = resolve(path).toLowerCase();
|
||||
const project = resolve(this.opts.projectDir).toLowerCase();
|
||||
const sdk = resolve(this.opts.sdkDir).toLowerCase();
|
||||
if (p.startsWith(project + "\\")) return "project";
|
||||
if (sdk && p.startsWith(sdk + "\\")) return "sdk";
|
||||
return "project";
|
||||
}
|
||||
|
||||
private addAsset(def: AssetDef): void {
|
||||
// Keep the original case: type names are matched against the XSD model.
|
||||
const typeKey = def.type;
|
||||
const idKey = def.id.toLowerCase();
|
||||
let byId = this.assets.get(typeKey);
|
||||
if (!byId) {
|
||||
byId = new Map();
|
||||
this.assets.set(typeKey, byId);
|
||||
}
|
||||
const arr = byId.get(idKey);
|
||||
if (arr) {
|
||||
if (arr.some((a) => a.file === def.file && a.line === def.line)) return;
|
||||
arr.push(def);
|
||||
} else {
|
||||
byId.set(idKey, [def]);
|
||||
}
|
||||
const all = this.assetsById.get(idKey);
|
||||
if (all) {
|
||||
if (all.some((a) => a.file === def.file && a.line === def.line)) return;
|
||||
all.push(def);
|
||||
} else {
|
||||
this.assetsById.set(idKey, [def]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Module-level helpers ─────────────────────────────────────────────
|
||||
|
||||
function localName(tag: string): string {
|
||||
const idx = tag.lastIndexOf(":");
|
||||
return idx >= 0 ? tag.slice(idx + 1) : tag;
|
||||
}
|
||||
|
||||
function lineOf(parsed: ParsedFile, offset: number): number {
|
||||
if (!parsed.lineMap) return 0;
|
||||
return parsed.lineMap.positionAt(offset).line + 1;
|
||||
}
|
||||
|
||||
function isXmlPath(path: string): boolean {
|
||||
const ext = extname(path).toLowerCase();
|
||||
return XML_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
async function findCaseInsensitiveDir(dir: string): Promise<string | null> {
|
||||
const parent = dirname(dir);
|
||||
const wanted = basename(dir);
|
||||
try {
|
||||
const entries = await readdir(parent, { withFileTypes: true });
|
||||
const hit = entries.find(
|
||||
(e) => e.isDirectory() && e.name.toLowerCase() === wanted.toLowerCase(),
|
||||
);
|
||||
return hit ? join(parent, hit.name) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function findXPointerContainer(doc: XmlDocument, xpointer: string): XmlElement | null {
|
||||
// Supports the form used by the mods:
|
||||
// xmlns(n=uri:ea.com:eala:asset) xpointer(/n:ElementName/child::*)
|
||||
const m = /xpointer\(\/\w+:(\w+)\/child::\*\)/.exec(xpointer);
|
||||
if (!m) return null;
|
||||
const name = m[1];
|
||||
return doc.elements.find((el) => localName(el.name) === name) ?? null;
|
||||
}
|
||||
|
||||
/** Keeps the first candidate for each case-insensitive source string. */
|
||||
function dedupeSourceCandidates(candidates: SourceCandidate[]): SourceCandidate[] {
|
||||
const seen = new Set<string>();
|
||||
const out: SourceCandidate[] = [];
|
||||
for (const c of candidates) {
|
||||
const key = c.source.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(c);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* Parser for SAGE `.manifest` files, ported from OpenSAGE
|
||||
* (src/OpenSage.Game/Data/StreamFS/ManifestFile.cs, commit d45d361).
|
||||
*
|
||||
* The manifest is a binary index produced by BinaryAssetBuilder: every asset
|
||||
* compiled into a stream is listed with hashed type/instance ids, an offset
|
||||
* into the asset-name string buffer, and an optional source file name.
|
||||
* Parsing it lets the extension treat `reference` includes (static.xml,
|
||||
* global.xml, audio.xml) as real, searchable asset pools.
|
||||
*/
|
||||
|
||||
import { assetTypeNameFromHash } from "../model/schemaModel";
|
||||
|
||||
export interface ManifestAsset {
|
||||
typeId: number;
|
||||
typeName: string | null;
|
||||
name: string;
|
||||
sourceFileName: string;
|
||||
}
|
||||
|
||||
export interface ManifestReferenceEntry {
|
||||
referenceType: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface ManifestInfo {
|
||||
version: number;
|
||||
isBigEndian: boolean;
|
||||
isLinked: boolean;
|
||||
assetCount: number;
|
||||
assets: ManifestAsset[];
|
||||
manifestReferences: ManifestReferenceEntry[];
|
||||
/** Present when the file could not be parsed. */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives an asset type from its manifest name. BAB stores manifest assets
|
||||
* as "TypeName:Id" (e.g. "PlayerTemplate:Allies"), so the part before the
|
||||
* first colon is the type even when the TypeId hash is unknown to the
|
||||
* bundled AssetType table. Asset ids cannot contain ":" (InstanceId pattern),
|
||||
* so the split is unambiguous.
|
||||
*/
|
||||
export function deriveAssetType(typeName: string | null, assetName: string): string | null {
|
||||
if (typeName) return typeName;
|
||||
const idx = assetName.indexOf(":");
|
||||
if (idx <= 0) return null;
|
||||
const prefix = assetName.slice(0, idx);
|
||||
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(prefix) ? prefix : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the referenceable asset id from a manifest name. BAB stores
|
||||
* manifest names as "TypeName:InstanceId" where the instance id may itself
|
||||
* carry a subtype prefix for art assets, e.g.
|
||||
* W3dContainer:W3DContainer:AUANTIVEHICLEVEHICLETECH1_SKN
|
||||
* The referenceable id is the last colon-separated segment (asset ids cannot
|
||||
* contain ":" per the InstanceId pattern).
|
||||
*/
|
||||
export function deriveAssetId(assetName: string): string {
|
||||
const idx = assetName.lastIndexOf(":");
|
||||
return idx >= 0 ? assetName.slice(idx + 1) : assetName;
|
||||
}
|
||||
|
||||
class Cursor {
|
||||
private pos = 0;
|
||||
constructor(private buf: Uint8Array) {}
|
||||
|
||||
get position(): number {
|
||||
return this.pos;
|
||||
}
|
||||
|
||||
byte(): number {
|
||||
if (this.pos >= this.buf.length) throw new Error("Unexpected end of file");
|
||||
return this.buf[this.pos++];
|
||||
}
|
||||
|
||||
uint16(): number {
|
||||
const a = this.byte();
|
||||
const b = this.byte();
|
||||
return a | (b << 8);
|
||||
}
|
||||
|
||||
uint32(): number {
|
||||
const a = this.byte();
|
||||
const b = this.byte();
|
||||
const c = this.byte();
|
||||
const d = this.byte();
|
||||
return ((a | (b << 8) | (c << 16) | (d << 24)) >>> 0);
|
||||
}
|
||||
|
||||
skip(n: number): void {
|
||||
this.pos += n;
|
||||
if (this.pos > this.buf.length) throw new Error("Unexpected end of file");
|
||||
}
|
||||
|
||||
nullTerminatedString(): string {
|
||||
const start = this.pos;
|
||||
while (this.pos < this.buf.length && this.buf[this.pos] !== 0) {
|
||||
this.pos++;
|
||||
}
|
||||
const end = this.pos;
|
||||
if (this.pos < this.buf.length) this.pos++; // consume NUL
|
||||
return decodeAscii(this.buf, start, end);
|
||||
}
|
||||
|
||||
readNameBuffer(endPosition: number): Map<number, string> {
|
||||
const names = new Map<number, string>();
|
||||
let nameOffset = 0;
|
||||
while (this.pos < endPosition) {
|
||||
const start = this.pos;
|
||||
names.set(nameOffset, this.nullTerminatedString());
|
||||
nameOffset += this.pos - start;
|
||||
}
|
||||
return names;
|
||||
}
|
||||
}
|
||||
|
||||
function decodeAscii(buf: Uint8Array, start: number, end: number): string {
|
||||
// Asset ids are ASCII; anything else decodes with replacement chars.
|
||||
let out = "";
|
||||
for (let i = start; i < end; i++) {
|
||||
out += String.fromCharCode(buf[i]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseManifest(buffer: Uint8Array): ManifestInfo {
|
||||
try {
|
||||
return parseManifestInner(buffer);
|
||||
} catch (err) {
|
||||
return {
|
||||
version: 0,
|
||||
isBigEndian: false,
|
||||
isLinked: false,
|
||||
assetCount: 0,
|
||||
assets: [],
|
||||
manifestReferences: [],
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function parseManifestInner(buffer: Uint8Array): ManifestInfo {
|
||||
const cursor = new Cursor(buffer);
|
||||
|
||||
const testValue = cursor.uint32();
|
||||
let version: number;
|
||||
let isBigEndian: boolean;
|
||||
let isLinked: boolean;
|
||||
|
||||
if (testValue === 0) {
|
||||
version = cursor.uint16();
|
||||
if (version !== 7) throw new Error(`Unsupported manifest version ${version}`);
|
||||
isBigEndian = readBooleanChecked(cursor);
|
||||
isLinked = readBooleanChecked(cursor);
|
||||
} else {
|
||||
cursor.skip(-4);
|
||||
isBigEndian = readBooleanChecked(cursor);
|
||||
isLinked = readBooleanChecked(cursor);
|
||||
version = cursor.uint16();
|
||||
if (version !== 5 && version !== 6) {
|
||||
throw new Error(`Unsupported manifest version ${version}`);
|
||||
}
|
||||
}
|
||||
|
||||
const streamChecksum = cursor.uint32();
|
||||
const allTypesHash = cursor.uint32();
|
||||
const assetCount = cursor.uint32();
|
||||
const totalInstanceDataSize = cursor.uint32();
|
||||
const maxInstanceChunkSize = cursor.uint32();
|
||||
const maxRelocationChunkSize = cursor.uint32();
|
||||
const maxImportsChunkSize = cursor.uint32();
|
||||
const assetReferenceBufferSize = cursor.uint32();
|
||||
const referencedManifestNameBufferSize = cursor.uint32();
|
||||
const assetNameBufferSize = cursor.uint32();
|
||||
const sourceFileNameBufferSize = cursor.uint32();
|
||||
|
||||
void streamChecksum;
|
||||
void allTypesHash;
|
||||
void totalInstanceDataSize;
|
||||
void maxInstanceChunkSize;
|
||||
void maxRelocationChunkSize;
|
||||
void maxImportsChunkSize;
|
||||
|
||||
interface RawEntry {
|
||||
typeId: number;
|
||||
nameOffset: number;
|
||||
sourceFileNameOffset: number;
|
||||
}
|
||||
const rawEntries: RawEntry[] = [];
|
||||
for (let i = 0; i < assetCount; i++) {
|
||||
const typeId = cursor.uint32();
|
||||
cursor.uint32(); // instanceId
|
||||
cursor.uint32(); // typeHash
|
||||
cursor.uint32(); // instanceHash
|
||||
cursor.uint32(); // assetReferenceOffset
|
||||
cursor.uint32(); // assetReferenceCount
|
||||
const nameOffset = cursor.uint32();
|
||||
const sourceFileNameOffset = cursor.uint32();
|
||||
cursor.uint32(); // instanceDataSize
|
||||
cursor.uint32(); // relocationDataSize
|
||||
cursor.uint32(); // importsDataSize
|
||||
if (version >= 6) {
|
||||
cursor.byte(); // isTokenized
|
||||
cursor.skip(3);
|
||||
}
|
||||
rawEntries.push({
|
||||
typeId,
|
||||
nameOffset,
|
||||
sourceFileNameOffset,
|
||||
});
|
||||
}
|
||||
|
||||
// Asset references buffer (not needed for completions).
|
||||
cursor.skip(assetReferenceBufferSize);
|
||||
|
||||
// Referenced manifest names.
|
||||
const manifestRefsEnd = cursor.position + referencedManifestNameBufferSize;
|
||||
const manifestReferences: ManifestReferenceEntry[] = [];
|
||||
while (cursor.position < manifestRefsEnd) {
|
||||
const referenceType = cursor.byte();
|
||||
const path = cursor.nullTerminatedString();
|
||||
manifestReferences.push({ referenceType, path });
|
||||
}
|
||||
|
||||
// Asset names.
|
||||
const assetNamesEnd = cursor.position + assetNameBufferSize;
|
||||
const assetNames = cursor.readNameBuffer(assetNamesEnd);
|
||||
|
||||
// Source file names.
|
||||
const sourceNamesEnd = cursor.position + sourceFileNameBufferSize;
|
||||
const sourceNames = cursor.readNameBuffer(sourceNamesEnd);
|
||||
|
||||
const assets: ManifestAsset[] = rawEntries.map((entry) => ({
|
||||
typeId: entry.typeId,
|
||||
typeName: assetTypeNameFromHash(entry.typeId) ?? null,
|
||||
name: assetNames.get(entry.nameOffset) ?? "",
|
||||
sourceFileName: sourceNames.get(entry.sourceFileNameOffset) ?? "",
|
||||
}));
|
||||
|
||||
return {
|
||||
version,
|
||||
isBigEndian,
|
||||
isLinked,
|
||||
assetCount,
|
||||
assets,
|
||||
manifestReferences,
|
||||
};
|
||||
}
|
||||
|
||||
function readBooleanChecked(cursor: Cursor): boolean {
|
||||
const value = cursor.byte();
|
||||
if (value === 0) return false;
|
||||
if (value === 1) return true;
|
||||
throw new Error(`Invalid boolean byte ${value}`);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
attributesOfType,
|
||||
elementTypeName,
|
||||
isAssignableTo,
|
||||
} from "../model/schemaModel";
|
||||
import type { AssetDef, ModIndex } from "./types";
|
||||
|
||||
export interface ReferenceTarget {
|
||||
def: AssetDef;
|
||||
score: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when an attribute is a typed reference: either the instance
|
||||
* inheritance attribute `inheritFrom`, or an XSD attribute whose simple type
|
||||
* carries an `xas:refType`. Enumerations and file paths (e.g.
|
||||
* `Include/@type`, `Include/@source`) are not references.
|
||||
*/
|
||||
export function isReferenceAttribute(elementName: string, attrName: string): boolean {
|
||||
return isReferenceAttributeOfType(elementTypeName(elementName), attrName);
|
||||
}
|
||||
|
||||
/** Same check, but driven by a resolved XSD type name. */
|
||||
export function isReferenceAttributeOfType(
|
||||
typeName: string | null,
|
||||
attrName: string,
|
||||
): boolean {
|
||||
if (attrName.toLowerCase() === "inheritfrom") return true;
|
||||
const attr = attributesOfType(typeName).find((a) => a.name === attrName);
|
||||
return attr != null && (attr.refType != null || attr.isRef);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the definitions a reference attribute value should point to.
|
||||
*
|
||||
* The result is strictly filtered by the attribute's reference type (from the
|
||||
* XSD model) so that an id shared by several asset types only resolves to the
|
||||
* matching definition (e.g. `Weapon="X"` jumps to the WeaponTemplate with
|
||||
* id "X", never to a GameObject that happens to share the id).
|
||||
*
|
||||
* Returns [] when the attribute is not a typed reference or nothing matches.
|
||||
*/
|
||||
export function resolveReferenceTargets(
|
||||
idx: ModIndex,
|
||||
elementType: string,
|
||||
attrName: string,
|
||||
id: string,
|
||||
): ReferenceTarget[] {
|
||||
return resolveReferenceTargetsForType(
|
||||
idx,
|
||||
elementTypeName(elementType),
|
||||
attrName,
|
||||
id,
|
||||
);
|
||||
}
|
||||
|
||||
/** Same resolution, driven by a resolved XSD type name. */
|
||||
export function resolveReferenceTargetsForType(
|
||||
idx: ModIndex,
|
||||
typeName: string | null,
|
||||
attrName: string,
|
||||
id: string,
|
||||
): ReferenceTarget[] {
|
||||
const defs = idx.assetsById.get(id.toLowerCase());
|
||||
if (!defs?.length) return [];
|
||||
|
||||
const nameLower = attrName.toLowerCase();
|
||||
let refType: string | null = null;
|
||||
let selfType: string | null = null;
|
||||
|
||||
if (nameLower === "inheritfrom") {
|
||||
selfType = typeName;
|
||||
} else {
|
||||
const attr = attributesOfType(typeName).find((a) => a.name === attrName);
|
||||
if (!attr || (!attr.refType && !attr.isRef)) return [];
|
||||
refType = attr.refType;
|
||||
}
|
||||
|
||||
const targets: ReferenceTarget[] = [];
|
||||
for (const def of defs) {
|
||||
if (refType && !isAssignableTo(def.type, refType)) continue;
|
||||
if (selfType && !isAssignableTo(def.type, selfType)) continue;
|
||||
let score = 3;
|
||||
if (def.origin === "project") score = 0;
|
||||
else if (def.origin === "sdk") score = 1;
|
||||
else score = 2;
|
||||
targets.push({ def, score });
|
||||
}
|
||||
targets.sort((a, b) => a.score - b.score || a.def.id.localeCompare(b.def.id));
|
||||
return targets;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { XmlDocument } from "../language/xmlParser";
|
||||
import type { ManifestInfo } from "./manifestParser";
|
||||
import type { LineMap } from "../language/xmlParser";
|
||||
|
||||
export type AssetOrigin = "project" | "sdk" | "manifest";
|
||||
|
||||
export interface AssetDef {
|
||||
type: string;
|
||||
id: string;
|
||||
/** Absolute path of the defining file (for manifests: the manifest path). */
|
||||
file: string;
|
||||
/** 1-based line of the id attribute (or 0 when unknown, e.g. manifests). */
|
||||
line: number;
|
||||
origin: AssetOrigin;
|
||||
/** "static" or "global:<name>" when the asset comes from a stream. */
|
||||
stream?: string;
|
||||
/** Set for assets that are only reachable through `instance` includes. */
|
||||
viaInstance?: boolean;
|
||||
/** Manifest path for origin === "manifest". */
|
||||
manifest?: string;
|
||||
/** Source file recorded inside a manifest (e.g. "DATA:globaldata/armor.xml"). */
|
||||
manifestSource?: string;
|
||||
}
|
||||
|
||||
export interface DefineDef {
|
||||
name: string;
|
||||
value: string;
|
||||
file: string;
|
||||
line: number;
|
||||
origin: AssetOrigin;
|
||||
}
|
||||
|
||||
export interface IndexedFile {
|
||||
path: string;
|
||||
stat: { mtimeMs: number; size: number } | null;
|
||||
}
|
||||
|
||||
export interface StreamInfo {
|
||||
name: string;
|
||||
entry: string;
|
||||
files: Set<string>;
|
||||
}
|
||||
|
||||
export interface SourceCandidate {
|
||||
/** Suggested value for Include/@source. */
|
||||
source: string;
|
||||
/** Absolute path the candidate resolves to. */
|
||||
path: string;
|
||||
/** "DATA" | "ART" | "AUDIO" | null (relative). */
|
||||
prefix: "DATA" | "ART" | "AUDIO" | null;
|
||||
/** Directory that acts as the root of the relative path. */
|
||||
baseDir: string;
|
||||
}
|
||||
|
||||
export interface IndexerDiagnostic {
|
||||
file: string;
|
||||
line: number;
|
||||
message: string;
|
||||
severity: "warning" | "error" | "information";
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface IndexStats {
|
||||
projectDir: string;
|
||||
sdkDir: string;
|
||||
indexedFiles: number;
|
||||
parsedFiles: number;
|
||||
assetCount: number;
|
||||
defineCount: number;
|
||||
manifestFiles: number;
|
||||
manifestAssetCount: number;
|
||||
streams: number;
|
||||
sourceCandidates: number;
|
||||
elapsedMs: number;
|
||||
}
|
||||
|
||||
export interface ModIndex {
|
||||
projectDir: string;
|
||||
sdkDir: string;
|
||||
/** type -> id -> definitions (project + sdk + manifest, deduplicated). */
|
||||
assets: Map<string, Map<string, AssetDef[]>>;
|
||||
/** id -> definitions across all types. */
|
||||
assetsById: Map<string, AssetDef[]>;
|
||||
/** `$NAME` -> definitions. */
|
||||
defines: Map<string, DefineDef[]>;
|
||||
/** absolute path -> file record (only files touched by the include walk). */
|
||||
files: Map<string, IndexedFile>;
|
||||
streams: StreamInfo[];
|
||||
manifests: Map<string, ManifestInfo>;
|
||||
/** Files suggested for Include/@source completion. */
|
||||
sourceCandidates: SourceCandidate[];
|
||||
/** Problems found while indexing (unresolved includes, cycles, ...). */
|
||||
diagnostics: IndexerDiagnostic[];
|
||||
stats: IndexStats;
|
||||
}
|
||||
|
||||
export interface IndexOptions {
|
||||
projectDir: string;
|
||||
sdkDir: string;
|
||||
builtmodsDirs: string[];
|
||||
indexSageXml: boolean;
|
||||
additionalDataSearchPaths: string[];
|
||||
/** Directory walker used to enumerate files for source completion. */
|
||||
walker: FileWalker;
|
||||
}
|
||||
|
||||
export interface FileWalker {
|
||||
/** Recursively lists files under a directory. Cached by the caller. */
|
||||
listFiles(dir: string): Promise<string[]>;
|
||||
}
|
||||
|
||||
export interface ParseCache {
|
||||
get(path: string): IndexedFile | undefined;
|
||||
set(file: IndexedFile): void;
|
||||
clear(): void;
|
||||
/** Removes the cached entry for a path. */
|
||||
invalidate(path: string): void;
|
||||
}
|
||||
|
||||
/** A parsed file plus its line map, produced on demand. */
|
||||
export interface ParsedFile {
|
||||
file: IndexedFile;
|
||||
parse: XmlDocument | null;
|
||||
lineMap: LineMap | null;
|
||||
}
|
||||
Reference in New Issue
Block a user