// Stage-4 tolerant XML reader for the bounded corpus malformation set. Never throws. // Mirrors Java XmlForgivingReader: maps an element's child elements, text, AND attributes // into the field map, and handles self-closing tags (). Must not index-out-of-range // on a leading close tag. // // Representation: // - text-only element, no attributes → its trimmed text (string) — unchanged // - self-closing / attributes-only element → a record of attribute name→value ("" when none) // - element with child elements (± attrs) → a record merging attributes + child entries // (a child element wins a name collision) // - element with text AND attributes → a record of the attributes plus the body text // under TEXT_KEY (a scalar consumer unwraps it) // - repeated sibling tags → an array (unchanged) /** Reserved key holding an element's own text content when the element is represented as a * record (because it also carries attributes). '#' is not a legal XML name char, so it never * collides with a real attribute or child-element name. */ export const TEXT_KEY = "#text"; function quote(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } /** Find first regex match at index >= `from` (emulates Java Matcher.find(int)). */ function matchFrom(source: string, flags: string, text: string, from: number): RegExpExecArray | null { const g = new RegExp(source, flags.includes("g") ? flags : flags + "g"); g.lastIndex = from; return g.exec(text); } // tag name + everything up to the closing '>' (attributes and/or a trailing '/' for a // self-closing tag). Non-greedy so the first '>' closes the open tag. const OPEN_TAG_SRC = "<([A-Za-z_][A-Za-z0-9_]*)([^>]*?)>"; // one attribute: name = "double" | 'single' | bareword. const ATTR_SRC = "([A-Za-z_:][A-Za-z0-9_:.\\-]*)\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)'|([^\\s/>]+))"; export function readXml(span: string | null | undefined, caseInsensitive: boolean): Record { const out: Record = {}; if (span == null || span.trim().length === 0) return out; const gt = span.indexOf(">"); if (gt < 0) return out; const rootEnd = span.lastIndexOf("....`). Used for `ExtractOptions.rootless` * responses. Leading/trailing non-element text is ignored. Never throws. Mirrors Java readRootless. */ export function readXmlRootless( text: string | null | undefined, caseInsensitive: boolean, ): Record { const out: Record = {}; if (text == null || text.trim().length === 0) return out; parseChildren(text, caseInsensitive, out); return out; } function parseChildren(inner: string, ci: boolean, out: Record): void { const flags = ci ? "i" : ""; let pos = 0; for (;;) { const m = matchFrom(OPEN_TAG_SRC, flags, inner, pos); if (m == null) break; const tag = m[1] ?? ""; const key = ci ? tag.toLowerCase() : tag; let rawAttrs = (m[2] ?? "").trim(); const selfClosing = rawAttrs.endsWith("/"); if (selfClosing) rawAttrs = rawAttrs.slice(0, -1).trim(); const attrs = parseAttrs(rawAttrs, ci); if (selfClosing) { accumulate(out, key, Object.keys(attrs).length === 0 ? "" : attrs); pos = m.index + m[0].length; continue; } const contentStart = m.index + m[0].length; const closeRe = ``; const close = matchFrom(closeRe, flags, inner, contentStart); let contentEnd: number; let next: number; if (close != null) { contentEnd = close.index; next = close.index + close[0].length; } else { // unclosed tag: extract content up to the next sibling open tag. const sib = matchFrom(OPEN_TAG_SRC, flags, inner, contentStart); if (sib != null) { // When the unclosed element's content begins IMMEDIATELY with a child open tag // (no leading text), that child was almost certainly meant to be NESTED, not a // sibling — a common LLM malformation is dropping the parent's close tag while // still emitting a real child element (e.g. text). Absorb the // remainder of this span as the unclosed element's content so the child nests // under it. When there IS leading text before the first child tag (e.g. hi..), // keep the sibling split — the leading text is the unclosed element's body and the // following tag is its sibling. Mirrors Java XmlForgivingReader. const noLeadingText = inner.substring(contentStart, sib.index).trim().length === 0; if (noLeadingText) { contentEnd = inner.length; next = inner.length; } else { contentEnd = sib.index; next = contentEnd; } } else { contentEnd = inner.length; next = inner.length; } } const content = inner.substring(contentStart, contentEnd); accumulate(out, key, combine(attrs, content, ci)); pos = next; } } /** Combine an element's attributes with its body (nested children or plain text). */ function combine(attrs: Record, content: string, ci: boolean): unknown { if (content.includes("<")) { const nested: Record = {}; parseChildren(content, ci, nested); if (Object.keys(nested).length > 0) { // attributes first; a child element wins a name collision return { ...attrs, ...nested }; } } return textValue(attrs, content); } function textValue(attrs: Record, content: string): unknown { const text = content.trim(); if (Object.keys(attrs).length === 0) return text; return { ...attrs, [TEXT_KEY]: text }; } function parseAttrs(rawAttrs: string, ci: boolean): Record { const attrs: Record = {}; if (rawAttrs.length === 0) return attrs; const re = new RegExp(ATTR_SRC, "g"); let a: RegExpExecArray | null; while ((a = re.exec(rawAttrs)) != null) { const rawName = a[1]; if (rawName === undefined) continue; // group 1 is mandatory in a match; guards strict TS const name = ci ? rawName.toLowerCase() : rawName; const val = a[2] ?? a[3] ?? a[4] ?? ""; if (!Object.prototype.hasOwnProperty.call(attrs, name)) attrs[name] = val; } return attrs; } function accumulate(out: Record, key: string, value: unknown): void { if (!Object.prototype.hasOwnProperty.call(out, key)) { out[key] = value; return; } const existing = out[key]; if (Array.isArray(existing)) { existing.push(value); } else { out[key] = [existing, value]; } }