diff --git a/packages/liquid-html-parser/fixtures/error-corpus.ts b/packages/liquid-html-parser/fixtures/error-corpus.ts new file mode 100644 index 000000000..8eb5429fa --- /dev/null +++ b/packages/liquid-html-parser/fixtures/error-corpus.ts @@ -0,0 +1,70 @@ +/* + * Adversarial error corpus for the resilient-parser overhead benchmark. + * + * Every `source` here is *invalid* Liquid/HTML: the strict `toLiquidHtmlAST` + * throws on each one, so these sources exist only to exercise the resilient + * path (`toResilientLiquidHtmlAST`), which recovers instead of throwing and + * surfaces one `LiquidErrorNode` per region it gives up on. The bench arm + * that consumes this corpus measures resync/recovery cost, not throughput on + * clean input. + * + * Shape is identical to `THEME_FILES` in `theme-bundle.ts` + * (`Array<{ path; source }>`) so the bench loop stays uniform across corpora. + * + * Contract (verified in C2): each `source` passed to + * `toResilientLiquidHtmlAST` returns a `DocumentNode` without throwing and + * contains at least one `LiquidErrorNode`. + */ + +/* + * A single orphan close tag followed by a valid variable output. The close + * has no matching open, so the resilient parser emits one error node, then + * resynchronizes on the next construct-open boundary and recovers the output. + * Repeating this unit forces the resync loop to fire once per unit — the + * "error every few tokens" density stress. + */ +const FREQUENT_ERROR_UNIT = "{% endfor %}{{ x }}{% endif %}{{ y }}"; + +/* + * A well-formed Liquid+HTML fragment the strict parser accepts as-is. Repeated + * many times it builds a large clean body; a single orphan close tag appended + * near EOF then costs exactly one recovery, proving tail recovery does not + * rescan the whole document. + */ +const CLEAN_UNIT = + '
{{ product.title }}' + + "{% if product.available %}{{ product.price }}{% endif %}" + + "
\n"; + +export const ERROR_FILES: Array<{ path: string; source: string }> = [ + { + /* Seeded verbatim from resilient.test.ts:150 — one orphan close tag. */ + path: "error-corpus/single-error.liquid", + source: "{% endfor %}", + }, + { + /* + * Seeded verbatim from resilient.test.ts:178 — two orphan closes with a + * valid output recovered between them (interleaved resync). + */ + path: "error-corpus/interleaved-resync.liquid", + source: "{% endfor %}{{ good }}{% endif %}", + }, + { + /* + * Net-new: an orphan close roughly every few tokens across a moderately + * long source, bounding worst-case resync frequency. 60 units yields 120 + * error nodes interleaved with 120 recovered outputs. + */ + path: "error-corpus/pathological-frequent.liquid", + source: FREQUENT_ERROR_UNIT.repeat(60), + }, + { + /* + * Net-new: a large valid body (150 clean units) with a single orphan + * close tag just before EOF — "parse a lot, then recover once". + */ + path: "error-corpus/large-clean-error-near-eof.liquid", + source: CLEAN_UNIT.repeat(150) + "{% endfor %}", + }, +]; diff --git a/packages/liquid-html-parser/src/ast.ts b/packages/liquid-html-parser/src/ast.ts index d95c2df13..4c8c91c97 100644 --- a/packages/liquid-html-parser/src/ast.ts +++ b/packages/liquid-html-parser/src/ast.ts @@ -49,7 +49,8 @@ export type LiquidHtmlNode = | LiquidDocParamNode | LiquidDocExampleNode | LiquidDocPromptNode - | LiquidDocDescriptionNode; + | LiquidDocDescriptionNode + | LiquidErrorNode; /** The root node of all LiquidHTML ASTs. */ export interface DocumentNode extends ASTNode { @@ -885,6 +886,18 @@ export interface LiquidDocPromptNode extends ASTNode { + /** The message of the caught parse error. */ + message: string; + /** The token-type name at the point the parse failed, when available. */ + found?: string; +} + export interface ASTNode { /** * The type of the node, as a string. diff --git a/packages/liquid-html-parser/src/document/resilient-parser.ts b/packages/liquid-html-parser/src/document/resilient-parser.ts new file mode 100644 index 000000000..a1811f285 --- /dev/null +++ b/packages/liquid-html-parser/src/document/resilient-parser.ts @@ -0,0 +1,103 @@ +import { DocumentParser } from './parser'; +import { TokenType } from './tokenizer'; +import { NodeTypes } from '../types'; +import { LiquidHTMLASTParsingError } from '../errors'; +import type { LiquidErrorNode, LiquidHtmlNode } from '../ast'; + +/* + * Token types that open a new top-level construct. Panic-mode recovery + * resynchronizes onto one of these so the next `super.parseNode()` call + * resumes on a real node boundary rather than mid-construct. EndOfInput is a + * member so recovery always has a boundary to stop on. + */ +export const RESYNC_TOKENS: ReadonlySet = new Set([ + TokenType.LiquidTagOpen, + TokenType.LiquidVariableOutputOpen, + TokenType.HtmlTagOpen, + TokenType.HtmlCloseTagOpen, + TokenType.HtmlCommentOpen, + TokenType.HtmlDoctypeOpen, + TokenType.EndOfInput, +]); + +/** Whether a token type opens a construct we can safely resume parsing on. */ +export function isResyncToken(type: TokenType): boolean { + return RESYNC_TOKENS.has(type); +} + +/* + * The readable name of a token type. TokenType is a string enum, so its value + * is already the name; this indirection keeps the call sites self-documenting. + */ +export function tokenTypeName(type: TokenType): string { + return type; +} + +/* + * Builds a LiquidErrorNode leaf covering a skipped region. Kept local to the + * resilient path — deliberately not in the frozen factories.ts — so the + * default parse has no way to construct it. + */ +export function makeLiquidErrorNode( + start: number, + end: number, + source: string, + message: string, + found?: string, +): LiquidErrorNode { + return { + type: NodeTypes.LiquidErrorNode, + position: { start, end }, + source, + message, + found, + }; +} + +/* + * Opt-in resilient parser. It behaves exactly like DocumentParser except that + * a structural parse failure — which the default parser throws on, aborting the + * whole parse — is caught here and turned into a LiquidErrorNode so parsing can + * continue. The strict/default DocumentParser is a different class reached by a + * different entry point and is left byte-identical. + */ +export class ResilientDocumentParser extends DocumentParser { + /* + * Wraps the polymorphic node parse. On a LiquidHTMLASTParsingError it emits a + * LiquidErrorNode covering the skipped region and resynchronizes onto the next + * construct-open boundary, so parsing continues and one document can surface + * several errors interleaved with the constructs it did recover. A forced + * >=1-token advance before the resync scan makes every recovery strictly + * advance the cursor, which guarantees the parseDocument loop terminates. + * Foreign (non-parse) errors are rethrown untouched. + */ + parseNode(): LiquidHtmlNode { + const startTok = this.peek(); + const startPos = this.getPosition(); + try { + return super.parseNode(); + } catch (e) { + if (!(e instanceof LiquidHTMLASTParsingError)) throw e; + const source = this.getSource(); + const found = tokenTypeName(this.peek().type); + /* + * Guarantee at least one token of progress before scanning. A failed + * parse can throw without having advanced the cursor (consume throws + * before its own increment), and if the offending token is itself a + * resync token the scan below would match it immediately and never move, + * re-trapping parseDocument in an infinite loop. Forcing one advance + * breaks that stall. + */ + if (this.getPosition() <= startPos) this.advance(); + /* + * Skip to the next construct-open boundary so the following + * super.parseNode() resumes on a real node start rather than mid- + * construct. isResyncToken includes EndOfInput, so this also stops + * cleanly at the end of the source. + */ + while (!this.isAtEnd() && !isResyncToken(this.peek().type)) this.advance(); + const end = this.peek().start; + return makeLiquidErrorNode(startTok.start, end, source, e.message, found); + } + } +} diff --git a/packages/liquid-html-parser/src/errors.ts b/packages/liquid-html-parser/src/errors.ts index bed0af4c8..2f97ed1a2 100644 --- a/packages/liquid-html-parser/src/errors.ts +++ b/packages/liquid-html-parser/src/errors.ts @@ -25,20 +25,32 @@ export class LiquidHTMLASTParsingError extends SyntaxError { this.unclosed = unclosed ?? null; const lc = lineColumn(source); - const start = lc.fromIndex(startIndex); - const end = lc.fromIndex(Math.min(endIndex, source.length - 1)); + + /* + * A parse can fail at a position that is out of the source's range - for + * example the end-of-input token that sits past the last character when a + * closing delimiter like "%}" is never found, or a -1 sentinel position. + * line-column returns null for an out-of-range index, and dereferencing + * that null while building `loc` below would throw a TypeError ("Cannot + * read properties of null") that masks the real syntax error. Clamp the + * indices into the valid range before the lookup so we always produce a + * real location. + */ + const lastIndex = Math.max(0, source.length - 1); + const start = lc.fromIndex(Math.min(Math.max(startIndex, 0), lastIndex)); + const end = lc.fromIndex(Math.min(Math.max(endIndex, 0), lastIndex)); // Plugging ourselves into @babel/code-frame since this is how // the babel parser can print where the parsing error occured. // https://github.com/prettier/prettier/blob/cd4a57b113177c105a7ceb94e71f3a5a53535b81/src/main/parser.js this.loc = { start: { - line: start!.line, - column: start!.col, + line: start?.line ?? 1, + column: start?.col ?? 1, }, end: { - line: end!.line, - column: end!.col, + line: end?.line ?? 1, + column: end?.col ?? 1, }, }; } diff --git a/packages/liquid-html-parser/src/index.ts b/packages/liquid-html-parser/src/index.ts index 5fd75e961..1cbc9f588 100644 --- a/packages/liquid-html-parser/src/index.ts +++ b/packages/liquid-html-parser/src/index.ts @@ -1,6 +1,7 @@ export * from './ast'; export * from './types'; export * from './errors'; +export { findErrorNodeAtOffset, toResilientLiquidAST, toResilientLiquidHtmlAST } from './resilient'; export { TAGS_WITHOUT_MARKUP, RAW_TAGS, VOID_ELEMENTS, BLOCKS } from './grammar'; export { getConditionalComment } from './conditional-comment'; export { tokenize, TokenType } from './document/tokenizer'; diff --git a/packages/liquid-html-parser/src/resilient.test.ts b/packages/liquid-html-parser/src/resilient.test.ts new file mode 100644 index 000000000..69204ca1c --- /dev/null +++ b/packages/liquid-html-parser/src/resilient.test.ts @@ -0,0 +1,267 @@ +import { describe, expect, it } from 'vitest'; +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { isLiquidHtmlNode, toLiquidAST, toLiquidHtmlAST, walk } from './ast'; +import type { DocumentNode, LiquidErrorNode, LiquidHtmlNode } from './ast'; +import { LiquidHTMLASTParsingError } from './errors'; +import { findErrorNodeAtOffset, toResilientLiquidAST, toResilientLiquidHtmlAST } from './resilient'; +import { NodeTypes } from './types'; + +/* + * Drop the `source`/`_source` strings before comparing two ASTs. Both parses + * receive the identical source argument, so those fields are equal by + * construction; stripping them keeps the structural comparison fast and loses + * nothing. Mirrors the strip step in parser-oracle.test.ts. + */ +function structural(ast: unknown): unknown { + return JSON.parse( + JSON.stringify(ast, (key, value) => + key === 'source' || key === '_source' ? undefined : value, + ), + ); +} + +function errorNodesOf(ast: DocumentNode): LiquidErrorNode[] { + const found: LiquidErrorNode[] = []; + walk(ast, (node) => { + if (node.type === NodeTypes.LiquidErrorNode) { + found.push(node as LiquidErrorNode); + } + }); + return found; +} + +/* + * Default-path neutrality proof (Gate-S1 / golden-neutrality gate). + * + * Resilient mode is a different class reached by a different function; on + * well-formed input its overridden `parseNode` never catches, so it must be + * byte-identical to the strict default path. These are the two proofs of that: + * a curated set of clean sources, and the whole on-disk clean fixture corpus. + */ +describe('resilient mode is inert on clean input', () => { + const cleanSources = [ + '', + 'plain text only', + '{{ product.title }}', + '{% assign x = 1 %}', + '{% if x %}a{% else %}b{% endif %}', + '{% for item in collection %}{{ item.title }}{% endfor %}', + '
{{ x }}
', + '{% render \'snippet\' %}', + '{% liquid\n assign y = 2\n echo y\n%}', + '{% comment %}hi{% endcomment %}', + 'text {{ a }} more {% if b %}{{ c }}{% endif %} end', + ]; + + for (const source of cleanSources) { + it(`toResilientLiquidHtmlAST deep-equals toLiquidHtmlAST for ${JSON.stringify(source)}`, () => { + expect(structural(toResilientLiquidHtmlAST(source))).toEqual( + structural(toLiquidHtmlAST(source)), + ); + }); + + it(`toResilientLiquidAST deep-equals toLiquidAST for ${JSON.stringify(source)}`, () => { + expect(structural(toResilientLiquidAST(source))).toEqual(structural(toLiquidAST(source))); + }); + } +}); + +const FIXTURES_DIR = resolve(__dirname, '..', 'fixtures', 'theme'); +const GOLDEN_HTML_AST_DIR = resolve(__dirname, '..', 'fixtures', 'golden-html-ast'); +const GOLDEN_LIQUID_AST_DIR = resolve(__dirname, '..', 'fixtures', 'golden-liquid-ast'); +const THEMES = ['base-theme', 'dawn', 'horizon']; + +/** Recursively find all .liquid files under a directory. */ +function findLiquidFiles(dir: string, prefix = ''): { path: string; fullPath: string }[] { + const results: { path: string; fullPath: string }[] = []; + if (!existsSync(dir)) return results; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const relPath = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + results.push(...findLiquidFiles(join(dir, entry.name), relPath)); + } else if (entry.name.endsWith('.liquid')) { + results.push({ path: relPath, fullPath: join(dir, entry.name) }); + } + } + return results; +} + +function goldenFileName(relativePath: string): string { + return relativePath.replace(/\//g, '-') + '.json'; +} + +const hasFixtures = existsSync(FIXTURES_DIR) && readdirSync(FIXTURES_DIR).length > 0; + +/* + * The clean corpus is exactly the files the oracle has a golden for: a golden + * exists only for a file the strict parser accepts, so those files are the + * proven-clean set. Resilient must reproduce the strict AST for each. + */ +describe.skipIf(!hasFixtures)('resilient mode is inert over the clean fixture corpus', () => { + for (const theme of THEMES) { + const themeDir = join(FIXTURES_DIR, theme); + const files = findLiquidFiles(themeDir); + const themeMissing = files.length === 0; + + describe.skipIf(themeMissing)(`toResilientLiquidHtmlAST === toLiquidHtmlAST - ${theme}`, () => { + for (const { path, fullPath } of files) { + const goldenPath = join(GOLDEN_HTML_AST_DIR, theme, goldenFileName(path)); + if (!existsSync(goldenPath)) continue; + + it(`matches strict for ${theme}/${path}`, () => { + const source = readFileSync(fullPath, 'utf-8'); + expect(structural(toResilientLiquidHtmlAST(source))).toEqual( + structural(toLiquidHtmlAST(source)), + ); + }); + } + }); + + describe.skipIf(themeMissing)(`toResilientLiquidAST === toLiquidAST - ${theme}`, () => { + for (const { path, fullPath } of files) { + const goldenPath = join(GOLDEN_LIQUID_AST_DIR, theme, goldenFileName(path)); + if (!existsSync(goldenPath)) continue; + + it(`matches strict for ${theme}/${path}`, () => { + const source = readFileSync(fullPath, 'utf-8'); + expect(structural(toResilientLiquidAST(source))).toEqual(structural(toLiquidAST(source))); + }); + } + }); + } +}); + +/* + * Positive-path resilient behavior. These consolidate the throwaway probes + * from B2 (error nodes emitted), B3 (panic-mode resync) and B5 (locator) into + * committed tests. + */ +describe('resilient mode surfaces parse errors as nodes', () => { + it('strict parse throws on a close tag with no matching open', () => { + expect(() => toLiquidHtmlAST('{% endfor %}')).toThrow(LiquidHTMLASTParsingError); + }); + + it('toResilientLiquidHtmlAST emits a LiquidErrorNode instead of throwing', () => { + const source = '{% endfor %}'; + const ast = toResilientLiquidHtmlAST(source); + + expect(ast.type).toBe(NodeTypes.Document); + expect(ast.children).toHaveLength(1); + + const node = ast.children[0] as LiquidErrorNode; + expect(node.type).toBe(NodeTypes.LiquidErrorNode); + expect(node.position).toEqual({ start: 0, end: 12 }); + expect(node.found).toBe('EndOfInput'); + expect(typeof node.message).toBe('string'); + expect(source.slice(node.position.start, node.position.end)).toBe('{% endfor %}'); + }); + + it('toResilientLiquidAST also recovers instead of throwing', () => { + const ast = toResilientLiquidAST('{% endfor %}'); + + expect(ast.type).toBe(NodeTypes.Document); + expect(errorNodesOf(ast)).toHaveLength(1); + }); +}); + +describe('resilient mode resynchronizes after an error (panic mode)', () => { + it('recovers valid nodes between multiple errors and terminates', () => { + const ast = toResilientLiquidHtmlAST('{% endfor %}{{ good }}{% endif %}'); + + expect(ast.children.map((child) => child.type)).toEqual([ + NodeTypes.LiquidErrorNode, + NodeTypes.LiquidVariableOutput, + NodeTypes.LiquidErrorNode, + ]); + + const errors = errorNodesOf(ast); + expect(errors.length).toBeGreaterThanOrEqual(2); + + // The construct between the two errors is recovered as a real node. + const recovered = ast.children[1]; + expect(recovered.type).toBe(NodeTypes.LiquidVariableOutput); + expect(recovered.position).toEqual({ start: 12, end: 22 }); + + // Errors resynced on the next construct-open boundary. + expect(ast.children[0].position).toEqual({ start: 0, end: 12 }); + expect(ast.children[2].position).toEqual({ start: 22, end: 33 }); + }); +}); + +describe('findErrorNodeAtOffset', () => { + const source = '{% endfor %} {{ good }} {% endif %}'; + const ast = toResilientLiquidHtmlAST(source); + + it('produces two error nodes spanning up to the next construct boundary', () => { + const errors = errorNodesOf(ast); + expect(errors).toHaveLength(2); + expect(errors[0].position).toEqual({ start: 0, end: 13 }); + expect(errors[1].position).toEqual({ start: 24, end: 35 }); + }); + + it('error nodes are first-class, walkable citizens', () => { + const errors = errorNodesOf(ast); + expect(isLiquidHtmlNode(errors[0])).toBe(true); + }); + + it('returns the error node the offset sits inside, with its ancestry', () => { + const result = findErrorNodeAtOffset(ast, 5); + expect(result).not.toBeNull(); + expect(result!.node.type).toBe(NodeTypes.LiquidErrorNode); + expect(result!.node.position).toEqual({ start: 0, end: 13 }); + expect(result!.ancestors[0].type).toBe(NodeTypes.Document); + }); + + it('resolves the second error span for an offset inside it', () => { + const result = findErrorNodeAtOffset(ast, 30); + expect(result).not.toBeNull(); + expect(result!.node.position).toEqual({ start: 24, end: 35 }); + }); + + it('returns null for an offset outside every error span', () => { + // Offset 17 is inside the recovered {{ good }}, not an error region. + expect(findErrorNodeAtOffset(ast, 17)).toBeNull(); + }); + + it('returns the deepest error node when spans nest', () => { + const src = 'x'.repeat(20); + const inner = { + type: NodeTypes.LiquidErrorNode, + position: { start: 5, end: 10 }, + source: src, + message: 'inner', + } as LiquidErrorNode; + const container = { + type: NodeTypes.HtmlElement, + position: { start: 2, end: 15 }, + source: src, + children: [inner], + } as unknown as LiquidHtmlNode; + const outer = { + type: NodeTypes.LiquidErrorNode, + position: { start: 0, end: 20 }, + source: src, + message: 'outer', + } as LiquidErrorNode; + const root = { + type: NodeTypes.Document, + name: '#document', + position: { start: 0, end: 20 }, + source: src, + _source: src, + children: [outer, container], + } as unknown as LiquidHtmlNode; + + const deep = findErrorNodeAtOffset(root, 7); + expect(deep!.node.message).toBe('inner'); + expect(deep!.ancestors.map((node) => node.type)).toEqual([ + NodeTypes.Document, + NodeTypes.HtmlElement, + ]); + + const shallow = findErrorNodeAtOffset(root, 1); + expect(shallow!.node.message).toBe('outer'); + expect(shallow!.ancestors.map((node) => node.type)).toEqual([NodeTypes.Document]); + }); +}); diff --git a/packages/liquid-html-parser/src/resilient.ts b/packages/liquid-html-parser/src/resilient.ts new file mode 100644 index 000000000..9dd5d1748 --- /dev/null +++ b/packages/liquid-html-parser/src/resilient.ts @@ -0,0 +1,138 @@ +/** + * Opt-in resilient entry points for the Liquid+HTML parser. + * + * These mirror `toLiquidAST` / `toLiquidHtmlAST` from `./ast` exactly, except + * they construct a `ResilientDocumentParser` instead of the base + * `DocumentParser`. A structural parse failure that would abort the default + * parse is instead caught and surfaced as a `LiquidErrorNode`, so the returned + * `DocumentNode` can carry several errors interleaved with the constructs the + * parser did recover. The default path is a different class reached by a + * different function and stays byte-identical. + */ + +import type { ASTBuildOptions, DocumentNode, LiquidErrorNode, LiquidHtmlNode } from './ast'; +import { walk } from './ast'; +import { ResilientDocumentParser } from './document/resilient-parser'; +import { tokenize } from './document/tokenizer'; +import { Environment } from './environment'; +import { NodeTypes } from './types'; + +/* + * Resilient variant of `toLiquidAST` (Liquid-only, `parseHtml: false`). + * Defaults `allowUnclosedDocumentNode: true` so an unterminated document is + * recovered rather than thrown on — the resilient contract. + */ +export function toResilientLiquidAST( + source: string, + options: ASTBuildOptions = { + allowUnclosedDocumentNode: true, + mode: 'tolerant', + }, +): DocumentNode { + const env = options.environment ?? Environment.default(); + const tokens = tokenize(source); + const parser = new ResilientDocumentParser( + tokens, + source, + env, + false, + options.allowUnclosedDocumentNode, + ); + return parser.parseDocument(); +} + +/* + * Resilient variant of `toLiquidHtmlAST` (Liquid+HTML, `parseHtml: true`). + * Defaults `allowUnclosedDocumentNode: true` so an unterminated document is + * recovered rather than thrown on — the resilient contract. + */ +export function toResilientLiquidHtmlAST( + source: string, + options: ASTBuildOptions = { + allowUnclosedDocumentNode: true, + mode: 'tolerant', + }, +): DocumentNode { + const env = options.environment ?? Environment.default(); + const tokens = tokenize(source); + const parser = new ResilientDocumentParser( + tokens, + source, + env, + true, + options.allowUnclosedDocumentNode, + ); + return parser.parseDocument(); +} + +/* + * Locate the error node the caret is sitting in. + * + * A resilient parse can leave several `LiquidErrorNode`s scattered through + * the tree, one per region the parser gave up on. Completion needs the one + * the caret is actually inside, so we return the *deepest* error node whose + * span contains `offset` — the most specific recovery point — paired with + * its ancestry. + * + * The result shape (`{ node, ancestors }`) mirrors `findCurrentNode` in the + * language server so a later phase can swap the completion context source + * with minimal churn. `null` means the caret is not inside any error region + * and the existing (non-error) path should handle it. + */ +export function findErrorNodeAtOffset( + ast: LiquidHtmlNode, + offset: number, +): { node: LiquidErrorNode; ancestors: LiquidHtmlNode[] } | null { + /* + * `walk` only hands each visited node its immediate parent, and it visits + * in post-order (parents after children), so the map is only complete once + * the traversal ends. We therefore record every parent link first, gather + * the error nodes that contain the offset, and resolve depth/ancestry from + * the finished map afterwards. + */ + const parentOf = new Map(); + const candidates: LiquidErrorNode[] = []; + + walk(ast, (node, parent) => { + parentOf.set(node, parent); + if ( + node.type === NodeTypes.LiquidErrorNode && + offset >= node.position.start && + offset <= node.position.end + ) { + candidates.push(node); + } + }); + + if (candidates.length === 0) { + return null; + } + + /* Ancestry, root→parent, by climbing the parent map from the node up. */ + const ancestorsOf = (node: LiquidHtmlNode): LiquidHtmlNode[] => { + const chain: LiquidHtmlNode[] = []; + let cursor = parentOf.get(node); + while (cursor !== undefined) { + chain.push(cursor); + cursor = parentOf.get(cursor); + } + return chain.reverse(); + }; + + /* + * Deepest wins: when error spans nest, the innermost node has the longest + * ancestor chain. First candidate wins ties, which is stable given walk's + * deterministic traversal order. + */ + let best = candidates[0]; + let bestDepth = ancestorsOf(best).length; + for (let i = 1; i < candidates.length; i++) { + const depth = ancestorsOf(candidates[i]).length; + if (depth > bestDepth) { + best = candidates[i]; + bestDepth = depth; + } + } + + return { node: best, ancestors: ancestorsOf(best) }; +} diff --git a/packages/liquid-html-parser/src/types.ts b/packages/liquid-html-parser/src/types.ts index 84c0a517e..9a984b538 100644 --- a/packages/liquid-html-parser/src/types.ts +++ b/packages/liquid-html-parser/src/types.ts @@ -52,6 +52,7 @@ export enum NodeTypes { LiquidDocParamNode = 'LiquidDocParamNode', LiquidDocExampleNode = 'LiquidDocExampleNode', LiquidDocPromptNode = 'LiquidDocPromptNode', + LiquidErrorNode = 'LiquidErrorNode', } // These are officially supported with special node types