/** * 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 * 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 * , nested targets and 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 element. */ start: number; } export interface ShallowXiIncludeRecord { href: string; xpointer: string | null; /** Offset of the element. */ start: number; } export interface ShallowDefineRecord { name: string; value: string; /** Offset of the element. */ start: number; } export interface ShallowScanError { message: string; offset: number; } export interface ShallowDocument { assets: ShallowAssetRecord[]; includes: ShallowIncludeRecord[]; /** elements that are direct children of the root. */ rootXiIncludes: ShallowXiIncludeRecord[]; /** elements nested anywhere else in the document. */ nestedXiIncludes: 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 rootXiIncludes: ShallowXiIncludeRecord[] = []; const nestedXiIncludes: 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 + 4); if (close < 0) { errors.push({ message: "Unterminated comment", offset: lt }); break; } i = close + 3; continue; } if (text.startsWith("", lt + 9); if (close < 0) { errors.push({ message: "Unterminated CDATA section", offset: lt }); break; } i = close + 3; continue; } if (text.startsWith("", lt + 2); if (close < 0) { errors.push({ message: "Unterminated DOCTYPE", offset: lt }); break; } i = close + 1; continue; } if (text.startsWith("", 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, }); } } // entries inside the top-level 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, }); } } // entries inside the top-level 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 (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) { const rec: ShallowXiIncludeRecord = { href: href.value, xpointer: xpointer?.value ?? null, start: lt, }; if (depth === 1) rootXiIncludes.push(rec); else nestedXiIncludes.push(rec); } } if (!selfClosing) depth++; } else { depth--; } i = gt + 1; } return { assets, includes, rootXiIncludes, nestedXiIncludes, 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; }