-
Notifications
You must be signed in to change notification settings - Fork 85
Add resilient liquid-html parsing #1255
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
karreiro
wants to merge
1
commit into
plhp/3-adapt
Choose a base branch
from
plhp/4-resilient
base: plhp/3-adapt
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 = | ||
| '<div class="card">{{ product.title }}' + | ||
| "{% if product.available %}<span>{{ product.price }}</span>{% endif %}" + | ||
| "</div>\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 %}", | ||
| }, | ||
| ]; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
103 changes: 103 additions & 0 deletions
103
packages/liquid-html-parser/src/document/resilient-parser.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<TokenType> = 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); | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why do we have to do this now?