支持 W3X

This commit is contained in:
2026-08-02 14:32:18 +02:00
parent 45fa2b9a27
commit 4cc07b9abc
22 changed files with 1052 additions and 79 deletions
+94
View File
@@ -0,0 +1,94 @@
/**
* Caches shared by the indexer.
*
* The workspace owns one instance of each cache and passes them into every
* ModIndexer, so a rebuild (which creates a fresh indexer) does not re-read
* files whose stat (mtime/size) is unchanged. This is what makes indexing
* large art-asset corpora (Corona: ~3800 .w3x files, 2.6 GB) practical:
* after the first build, save-triggered rebuilds only re-scan files that
* actually changed.
*/
import { resolve } from "node:path";
import type { ParsedFile } from "./types";
/** Case-insensitive absolute path key. */
export function normKey(path: string): string {
return resolve(path).toLowerCase();
}
/**
* LRU cache for fully parsed XML documents. 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();
}
}
/**
* Cache for shallow-scanned art-asset documents (.w3x and content-sniffed
* XML files). The retained records are tiny (top-level assets, includes,
* defines) compared to a full DOM, so the capacity can be much larger.
*/
export class ShallowScanCache {
private map = new Map<string, ParsedFile>();
constructor(private capacity = 8192) {}
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();
}
}
+14 -3
View File
@@ -1,7 +1,18 @@
import { readdir, stat } from "node:fs/promises";
import { join, relative, resolve } from "node:path";
import { extname, join, relative, resolve } from "node:path";
import type { FileWalker, SourceCandidate } from "./types";
/**
* Extensions offered as Include/@source candidates in DATA directories:
* regular XML plus art-asset XML (.w3x) that mods include via W3X.xml hubs.
* ART/AUDIO directories already list every file.
*/
const DATA_CANDIDATE_EXTENSIONS = new Set([".xml", ".w3x"]);
function isDataCandidate(path: string): boolean {
return DATA_CANDIDATE_EXTENSIONS.has(extname(path).toLowerCase());
}
/**
* Recursive file list walker with a simple directory-mtime cache. Used to
* enumerate candidate files for Include/@source completion. Directory scans
@@ -79,13 +90,13 @@ export async function collectSourceCandidates(
for (const dir of dataDirs) {
const files = await walker.listFiles(dir);
for (const f of files) {
if (!f.toLowerCase().endsWith(".xml")) continue;
if (!isDataCandidate(f)) 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);
if (isDataCandidate(f)) add(f, projectDataDir, null);
}
}
}
+253 -65
View File
@@ -11,9 +11,15 @@
* outside the extension.
*/
import { readFile, readdir, stat } from "node:fs/promises";
import { open, 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 {
LineMap,
parseXml,
stripBom,
type XmlDocument,
type XmlElement,
} from "../language/xmlParser";
import {
buildSearchPaths,
manifestPathForReference,
@@ -28,6 +34,8 @@ import {
} from "./manifestParser";
import { canonicalTypeName } from "../model/schemaModel";
import { collectSourceCandidates } from "./fileScanner";
import { DocumentCache, ShallowScanCache, normKey } from "./caches";
import { scanXmlShallow } from "./shallowScan";
import type {
AssetDef,
DefineDef,
@@ -42,54 +50,27 @@ import type {
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.
* Fully parsed XML documents. `.xml` / `.manifestxml` files are small enough
* that a full DOM is affordable.
*/
export class DocumentCache {
private map = new Map<string, ParsedFile>();
const FULL_XML_EXTENSIONS = new Set([".xml", ".manifestxml"]);
/**
* XML documents whose top-level structure is all the index needs (art-asset
* files exported by modeling tools, e.g. .w3x). They are shallow-scanned so
* multi-megabyte vertex/triangle payloads never become a DOM.
*/
const SHALLOW_XML_EXTENSIONS = new Set([".w3x"]);
/** Bytes peeked when deciding whether an unknown extension is XML text. */
const SNIFF_BYTES = 512;
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();
}
}
type XmlMode = "full" | "shallow" | "binary";
export class ModIndexer {
private searchPaths: SearchPaths;
private docs = new DocumentCache();
private docs: DocumentCache;
private shallowDocs: ShallowScanCache;
private scanCounters = { shallowScannedFiles: 0, shallowCacheHits: 0 };
private assets = new Map<string, Map<string, AssetDef[]>>();
private assetsById = new Map<string, AssetDef[]>();
private defines = new Map<string, DefineDef[]>();
@@ -106,42 +87,124 @@ export class ModIndexer {
this.searchPaths = buildSearchPaths(opts.sdkDir, opts.projectDir, {
DATA: opts.additionalDataSearchPaths,
});
// Caches may be owned by the workspace so they survive rebuilds.
this.docs = opts.documentCache ?? new DocumentCache();
this.shallowDocs = opts.shallowCache ?? new ShallowScanCache();
}
/** Re-reads and caches a document; null when unreadable. */
/**
* Returns a document for indexing/navigation:
* - `.xml` / `.manifestxml` files are fully parsed (bounded by
* MAX_PARSE_BYTES);
* - `.w3x` (and unknown-extension files whose content looks like XML) are
* shallow-scanned, so huge model files never become a DOM;
* - everything else is registered as a file but never parsed.
*
* Cached entries are reused when the file stat is unchanged, which lets a
* workspace-owned cache survive rebuilds.
*/
async readDocument(path: string): Promise<ParsedFile | null> {
const hit = this.docs.get(path);
if (hit) return hit;
const key = normKey(path);
const mode = await this.detectXmlMode(path);
if (mode === "shallow") return this.scanShallow(path);
if (mode === "binary") {
try {
const st = await stat(path);
const file: IndexedFile = { path: resolve(path), stat: { mtimeMs: st.mtimeMs, size: st.size } };
this.files.set(key, file);
return { file, parse: null, shallow: null, lineMap: null };
} catch {
const file: IndexedFile = { path: resolve(path), stat: null };
this.files.set(key, file);
return { file, parse: null, shallow: null, lineMap: null };
}
}
try {
const [st, text] = await Promise.all([stat(path), readFile(path, "utf8")]);
const st = await stat(path);
const hit = this.docs.get(key);
if (
hit?.file.stat &&
hit.file.stat.mtimeMs === st.mtimeMs &&
hit.file.stat.size === st.size
) {
this.files.set(key, hit.file);
return hit;
}
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 };
const parsed: ParsedFile = { file, parse: null, shallow: null, lineMap: null };
this.docs.set(parsed);
this.files.set(normKey(parsed.file.path), file);
this.files.set(key, file);
return parsed;
}
const text = stripBom(await readFile(path, "utf8"));
const parse = parseXml(text);
const parsed: ParsedFile = {
file: { path: resolve(path), stat: { mtimeMs: st.mtimeMs, size: st.size } },
parse,
shallow: null,
lineMap: new LineMap(text),
};
this.docs.set(parsed);
this.files.set(normKey(parsed.file.path), parsed.file);
this.files.set(key, parsed.file);
return parsed;
} catch {
const parsed: ParsedFile = {
file: { path: resolve(path), stat: null },
parse: null,
shallow: null,
lineMap: null,
};
this.docs.set(parsed);
this.files.set(normKey(parsed.file.path), parsed.file);
this.files.set(key, parsed.file);
return parsed;
}
}
/**
* Shallow-scans a large art-asset XML document (no DOM built) and caches
* the top-level records. Cache hits are counted separately so tests and
* the index report can verify that rebuilds skip unchanged files.
*/
private async scanShallow(path: string): Promise<ParsedFile | null> {
const key = normKey(path);
try {
const st = await stat(path);
const hit = this.shallowDocs.get(key);
if (
hit?.file.stat &&
hit.file.stat.mtimeMs === st.mtimeMs &&
hit.file.stat.size === st.size
) {
this.scanCounters.shallowCacheHits++;
this.files.set(key, hit.file);
return hit;
}
const text = stripBom(await readFile(path, "utf8"));
const shallow = scanXmlShallow(text);
const parsed: ParsedFile = {
file: { path: resolve(path), stat: { mtimeMs: st.mtimeMs, size: st.size } },
parse: null,
shallow,
lineMap: new LineMap(text),
};
this.shallowDocs.set(parsed);
this.files.set(key, parsed.file);
this.scanCounters.shallowScannedFiles++;
return parsed;
} catch {
return null;
}
}
/** Decides how a resolved include target should be consumed. */
private async detectXmlMode(path: string): Promise<XmlMode> {
const ext = extname(path).toLowerCase();
if (FULL_XML_EXTENSIONS.has(ext)) return "full";
if (SHALLOW_XML_EXTENSIONS.has(ext)) return "shallow";
return (await looksLikeXml(path)) ? "shallow" : "binary";
}
/** Returns the cached parse if present (does not read from disk). */
cachedDocument(path: string): ParsedFile | undefined {
return this.docs.get(path);
@@ -253,6 +316,8 @@ export class ModIndexer {
parsedFiles: [...this.files.values()].filter(
(f) => f.stat != null && f.stat.size <= MAX_PARSE_BYTES,
).length,
shallowScannedFiles: this.scanCounters.shallowScannedFiles,
shallowCacheHits: this.scanCounters.shallowCacheHits,
assetCount: [...this.assets.values()].reduce((sum, byId) => sum + byId.size, 0),
defineCount: this.defines.size,
manifestFiles: this.manifests.size,
@@ -294,11 +359,15 @@ export class ModIndexer {
stream.files.add(key);
// Binary assets (w3x/dds/...) are referenced but never parsed.
if (!isXmlPath(path)) return;
// readDocument handles every mode: full XML parse, shallow scan for
// art-asset XML (.w3x / content-sniffed), or binary registration only.
const parsed = await this.readDocument(path);
if (!parsed?.parse?.root) return;
if (!parsed) return;
if (parsed.shallow) {
await this.walkShallow(parsed, stream, depth, mode === "instance");
return;
}
if (!parsed.parse?.root) return;
const root = parsed.parse.root;
for (const child of root.children) {
@@ -366,12 +435,12 @@ export class ModIndexer {
const manifestPath = manifestPathForReference(source, this.opts.builtmodsDirs);
if (manifestPath) {
const loaded = await this.loadManifest(manifestPath, stream.name);
if (!loaded && isXmlPath(resolved.path)) {
if (!loaded) {
// The manifest could not be parsed (missing/invalid): fall back
// to the placeholder XML so its content is still available.
// to the placeholder target so its content is still available.
await this.walk(resolved.path, "instance", stream, depth + 1);
}
} else if (isXmlPath(resolved.path)) {
} else {
// reference to a real XML file: treat its assets as available
await this.walk(resolved.path, "instance", stream, depth + 1);
}
@@ -400,7 +469,95 @@ export class ModIndexer {
continue;
}
stream.files.add(normKey(resolved.path));
if (isXmlPath(resolved.path)) {
if ((await this.detectXmlMode(resolved.path)) !== "binary") {
await this.walk(resolved.path, "all", stream, depth + 1);
}
}
}
/**
* Consumes a shallow-scanned art-asset document: top-level assets,
* top-level <Includes>, <Defines> and nested <xi:include> targets.
*/
private async walkShallow(
parsed: ParsedFile,
stream: StreamInfo,
depth: number,
viaInstance: boolean,
): Promise<void> {
const scan = parsed.shallow;
if (!scan) return;
const file = parsed.file.path;
for (const asset of scan.assets) {
this.addAsset({
type: asset.name,
id: asset.id,
file,
line: lineOf(parsed, asset.idValueStart),
origin: this.originOf(file),
stream: stream.name,
viaInstance,
});
}
for (const define of scan.defines) {
const entry: DefineDef = {
name: define.name,
value: define.value,
file,
line: lineOf(parsed, define.start),
origin: this.originOf(file),
};
const arr = this.defines.get(define.name.toLowerCase());
if (arr) arr.push(entry);
else this.defines.set(define.name.toLowerCase(), [entry]);
}
for (const inc of scan.includes) {
const resolved = resolveSource(inc.source, dirname(file), this.searchPaths);
if (!resolved.path) {
this.diagnostics.push({
file,
line: lineOf(parsed, inc.start),
message: `Include target not found: ${inc.source}`,
severity: "warning",
code: "include-not-found",
});
continue;
}
if (inc.type === "all" || inc.type === "instance") {
await this.walk(
resolved.path,
inc.type === "all" ? "all" : "instance",
stream,
depth + 1,
);
} else if (inc.type === "reference") {
const manifestPath = manifestPathForReference(inc.source, this.opts.builtmodsDirs);
if (manifestPath) {
const loaded = await this.loadManifest(manifestPath, stream.name);
if (!loaded) await this.walk(resolved.path, "instance", stream, depth + 1);
} else {
await this.walk(resolved.path, "instance", stream, depth + 1);
}
}
}
for (const xi of scan.xiIncludes) {
const resolved = resolveSource(xi.href, dirname(file), this.searchPaths);
if (!resolved.path) {
this.diagnostics.push({
file,
line: lineOf(parsed, xi.start),
message: `xi:include target not found: ${xi.href}`,
severity: "warning",
code: "include-not-found",
});
continue;
}
stream.files.add(normKey(resolved.path));
if ((await this.detectXmlMode(resolved.path)) !== "binary") {
await this.walk(resolved.path, "all", stream, depth + 1);
}
}
@@ -425,9 +582,17 @@ export class ModIndexer {
});
return;
}
if (!isXmlPath(resolved.path)) return;
if ((await this.detectXmlMode(resolved.path)) === "binary") return;
const target = await this.readDocument(resolved.path);
if (!target?.parse?.root) return;
if (!target) return;
if (target.shallow) {
// Shallow-scanned targets have no tree to select xpointer children
// from; index their top-level content as a whole.
stream.files.add(normKey(target.file.path));
await this.walk(target.file.path, "all", stream, depth + 1);
return;
}
if (!target.parse?.root) return;
const xpointer = xi.attrs.find((a) => a.name === "xpointer")?.value ?? "";
let candidates: XmlElement[];
@@ -551,9 +716,32 @@ function lineOf(parsed: ParsedFile, offset: number): number {
return parsed.lineMap.positionAt(offset).line + 1;
}
function isXmlPath(path: string): boolean {
const ext = extname(path).toLowerCase();
return XML_EXTENSIONS.has(ext);
/**
* Content sniffing for include targets with unknown extensions (e.g. art
* formats beyond .w3x): a small header that starts with "<" after an
* optional UTF-8 BOM / whitespace and contains no NUL bytes is treated as
* XML text; anything else is a binary asset (registered, never parsed).
*/
async function looksLikeXml(path: string): Promise<boolean> {
try {
const fh = await open(path, "r");
try {
const buf = Buffer.alloc(SNIFF_BYTES);
const { bytesRead } = await fh.read(buf, 0, SNIFF_BYTES, 0);
const head = buf.subarray(0, bytesRead);
if (head.includes(0)) return false;
let i = 0;
if (head[0] === 0xef && head[1] === 0xbb && head[2] === 0xbf) i = 3;
while (i < head.length && (head[i] === 0x20 || head[i] === 0x09 || head[i] === 0x0a || head[i] === 0x0d)) {
i++;
}
return i < head.length && head[i] === 0x3c; // "<"
} finally {
await fh.close();
}
} catch {
return false;
}
}
async function findCaseInsensitiveDir(dir: string): Promise<string | null> {
+268
View File
@@ -0,0 +1,268 @@
/**
* Shallow XML scanner for large art-asset documents (e.g. .w3x).
*
* Full XML parsing builds an element tree whose memory footprint is roughly
* 17x the source text (measured on Corona model files). Model exports like
* <W3DMesh> contain hundreds of thousands of tiny numeric elements
* (Vertices/V, Triangles/T, ...) that the extension never needs: the index
* only consumes top-level asset definitions (name + id), top-level
* <Includes>, nested <xi:include> targets and <Defines> constants.
*
* This scanner performs a single linear pass without building child nodes,
* so multi-megabyte model files can be indexed in linear time with ~zero
* retained memory.
*
* Pure TypeScript: no vscode dependency, reusable outside the extension.
*/
export interface ShallowAssetRecord {
/** Top-level element name, e.g. "W3DContainer". */
name: string;
/** Value of the id attribute. */
id: string;
/** Offset of the first id value character. */
idValueStart: number;
/** Offset one past the last id value character. */
idValueEnd: number;
/** Offset of the element's "<". */
start: number;
/** Offset one past the ">" of the start tag. */
startTagEnd: number;
}
export interface ShallowIncludeRecord {
/** "all" | "instance" | "reference", or null when absent/unknown. */
type: "all" | "instance" | "reference" | null;
source: string;
/** Offset of the <Include> element. */
start: number;
}
export interface ShallowXiIncludeRecord {
href: string;
xpointer: string | null;
/** Offset of the <xi:include> element. */
start: number;
}
export interface ShallowDefineRecord {
name: string;
value: string;
/** Offset of the <Define> element. */
start: number;
}
export interface ShallowScanError {
message: string;
offset: number;
}
export interface ShallowDocument {
assets: ShallowAssetRecord[];
includes: ShallowIncludeRecord[];
xiIncludes: ShallowXiIncludeRecord[];
defines: ShallowDefineRecord[];
errors: ShallowScanError[];
}
interface AttrHit {
value: string;
valueStart: number;
valueEnd: number;
}
/**
* Matches a complete XML tag while respecting quoted attribute values, so a
* ">" or "/" inside a value never terminates the tag early.
*/
const TAG_RE = /<(?:"[^"]*"|'[^']*'|[^'"<>])*>/g;
export function scanXmlShallow(text: string): ShallowDocument {
const errors: ShallowScanError[] = [];
const assets: ShallowAssetRecord[] = [];
const includes: ShallowIncludeRecord[] = [];
const xiIncludes: ShallowXiIncludeRecord[] = [];
const defines: ShallowDefineRecord[] = [];
// Depth of the currently open element stack. The document root opens at
// depth 0 -> 1, so its direct children open when depth === 1.
let depth = 0;
let inIncludes = false;
let inDefines = false;
let i = 0;
while (i < text.length) {
const lt = text.indexOf("<", i);
if (lt < 0) break;
// Non-element constructs: comments, CDATA, DOCTYPE and processing
// instructions are skipped whole so their content never looks like tags.
if (text.startsWith("<!--", lt)) {
const close = text.indexOf("-->", lt + 4);
if (close < 0) {
errors.push({ message: "Unterminated comment", offset: lt });
break;
}
i = close + 3;
continue;
}
if (text.startsWith("<![CDATA[", lt)) {
const close = text.indexOf("]]>", lt + 9);
if (close < 0) {
errors.push({ message: "Unterminated CDATA section", offset: lt });
break;
}
i = close + 3;
continue;
}
if (text.startsWith("<!", lt)) {
const close = text.indexOf(">", lt + 2);
if (close < 0) {
errors.push({ message: "Unterminated DOCTYPE", offset: lt });
break;
}
i = close + 1;
continue;
}
if (text.startsWith("<?", lt)) {
const close = text.indexOf("?>", lt + 2);
if (close < 0) {
errors.push({ message: "Unterminated processing instruction", offset: lt });
break;
}
i = close + 2;
continue;
}
TAG_RE.lastIndex = lt;
const m = TAG_RE.exec(text);
if (!m) {
errors.push({ message: "Unterminated tag", offset: lt });
break;
}
const tag = m[0];
const gt = m.index + tag.length - 1;
const inner = tag.slice(1, -1);
const closing = inner.startsWith("/");
const selfClosing = !closing && /\/\s*$/.test(inner);
const body = closing ? inner.slice(1) : inner;
let nameEnd = 0;
while (nameEnd < body.length && !/[\s/>]/.test(body[nameEnd])) nameEnd++;
const name = body.slice(0, nameEnd);
const base = lt + 1;
if (!closing) {
// Top-level elements (direct children of the root).
if (depth === 1) {
inIncludes = name === "Includes";
inDefines = name === "Defines";
const idAttr = findAttr(inner, base, "id");
if (idAttr && idAttr.value) {
assets.push({
name,
id: idAttr.value,
idValueStart: idAttr.valueStart,
idValueEnd: idAttr.valueEnd,
start: lt,
startTagEnd: gt + 1,
});
}
}
// <Include> entries inside the top-level <Includes> block.
if (inIncludes && depth === 2 && name === "Include") {
const type = findAttr(inner, base, "type");
const source = findAttr(inner, base, "source");
if (source?.value) {
includes.push({
type:
type?.value === "all" || type?.value === "instance" || type?.value === "reference"
? type.value
: null,
source: source.value,
start: lt,
});
}
}
// <Define> entries inside the top-level <Defines> block.
if (inDefines && depth === 2 && name === "Define") {
const nameAttr = findAttr(inner, base, "name");
const valueAttr = findAttr(inner, base, "value");
if (nameAttr?.value) {
defines.push({ name: nameAttr.value, value: valueAttr?.value ?? "", start: lt });
}
}
// Nested <xi:include> (or any *:include) anywhere in the document.
if (localName(name) === "include") {
const href = findAttr(inner, base, "href");
const xpointer = findAttr(inner, base, "xpointer");
if (href?.value) {
xiIncludes.push({
href: href.value,
xpointer: xpointer?.value ?? null,
start: lt,
});
}
}
if (!selfClosing) depth++;
} else {
depth--;
}
i = gt + 1;
}
return { assets, includes, xiIncludes, defines, errors };
}
/**
* Scans the attributes of a tag (its inner text, without "<" and ">") for
* the first occurrence of `want` and returns its value with absolute offsets
* (base = offset one past the "<").
*/
function findAttr(inner: string, base: number, want: string): AttrHit | null {
let i = 0;
while (i < inner.length) {
while (i < inner.length && /\s/.test(inner[i])) i++;
// Self-closing marker or end of tag: no more attributes.
if (i >= inner.length || inner[i] === "/" || inner[i] === ">") break;
const nameStart = i;
while (i < inner.length && !/[\s=/>]/.test(inner[i])) i++;
const name = inner.slice(nameStart, i);
while (i < inner.length && /\s/.test(inner[i])) i++;
if (inner[i] === "=") {
i++;
while (i < inner.length && /\s/.test(inner[i])) i++;
const q = inner[i];
if (q === '"' || q === "'") {
const vStart = i + 1;
const vEnd = inner.indexOf(q, vStart);
if (vEnd < 0) {
return name === want ? { value: "", valueStart: -1, valueEnd: -1 } : null;
}
if (name === want) {
return {
value: inner.slice(vStart, vEnd),
valueStart: base + vStart,
valueEnd: base + vEnd,
};
}
i = vEnd + 1;
continue;
}
// Unquoted value (tolerated).
const vs = i;
while (i < inner.length && !/[\s>]/.test(inner[i])) i++;
if (name === want) {
return { value: inner.slice(vs, i), valueStart: base + vs, valueEnd: base + i };
}
continue;
}
// Attribute without a value (rare but tolerated).
if (name === want) return { value: "", valueStart: -1, valueEnd: -1 };
}
return null;
}
function localName(name: string): string {
const idx = name.lastIndexOf(":");
return idx >= 0 ? name.slice(idx + 1) : name;
}
+15
View File
@@ -1,6 +1,8 @@
import type { XmlDocument } from "../language/xmlParser";
import type { ManifestInfo } from "./manifestParser";
import type { LineMap } from "../language/xmlParser";
import type { ShallowDocument } from "./shallowScan";
import type { DocumentCache, ShallowScanCache } from "./caches";
export type AssetOrigin = "project" | "sdk" | "manifest";
@@ -65,6 +67,10 @@ export interface IndexStats {
sdkDir: string;
indexedFiles: number;
parsedFiles: number;
/** Art-asset documents indexed via shallow scan (no DOM tree). */
shallowScannedFiles: number;
/** Shallow scans served from the persistent cache (unchanged files). */
shallowCacheHits: number;
assetCount: number;
defineCount: number;
manifestFiles: number;
@@ -102,6 +108,10 @@ export interface IndexOptions {
additionalDataSearchPaths: string[];
/** Directory walker used to enumerate files for source completion. */
walker: FileWalker;
/** Optional parse-tree cache shared across rebuilds (owned by the workspace). */
documentCache?: DocumentCache;
/** Optional shallow-scan cache shared across rebuilds (owned by the workspace). */
shallowCache?: ShallowScanCache;
}
export interface FileWalker {
@@ -121,5 +131,10 @@ export interface ParseCache {
export interface ParsedFile {
file: IndexedFile;
parse: XmlDocument | null;
/**
* Shallow records for large art-asset XML documents (.w3x etc.); null for
* fully parsed XML and for binary files.
*/
shallow: ShallowDocument | null;
lineMap: LineMap | null;
}