Skip to content

Commit 4d4b99a

Browse files
wagenetclaude
andcommitted
fix(html_parser): improve error recovery for missing }}}
When a triple-stash expression is missing its closing }}}, the parser now attempts to resynchronize by consuming tokens until it finds }}} or reaches a safe boundary (EOF or <). This prevents the error from cascading and affecting subsequent parsing. Test: Added missing_triple_closing.html error test case 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent ce1070a commit 4d4b99a

File tree

3 files changed

+312
-1
lines changed

3 files changed

+312
-1
lines changed

crates/biome_html_parser/src/syntax/mod.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -656,8 +656,21 @@ fn parse_triple_stash_expression(p: &mut HtmlParser) -> ParsedSyntax {
656656
if p.at(R_TRIPLE_CURLY) {
657657
p.bump_with_context(R_TRIPLE_CURLY, HtmlLexContext::Regular);
658658
} else {
659-
// TODO: Add proper error handling for missing closing }}}
659+
// Missing closing }}}, emit error and try to recover
660660
p.error(p.err_builder("Expected closing }}}", p.cur_range()));
661+
662+
// Try to resynchronize: consume tokens until we find }}} or reach a safe boundary
663+
while !p.at(R_TRIPLE_CURLY) && !p.at(EOF) && !p.at(T![<]) {
664+
p.bump_any();
665+
}
666+
667+
// If we found }}}, consume it
668+
if p.at(R_TRIPLE_CURLY) {
669+
p.bump_with_context(R_TRIPLE_CURLY, HtmlLexContext::Regular);
670+
} else {
671+
// Reached EOF or safe boundary, switch context back to Regular
672+
// (Note: We don't bump here since we're at a boundary token)
673+
}
661674
}
662675

663676
Present(m.complete(p, GLIMMER_TRIPLE_STASH_EXPRESSION))
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<div>
2+
{{{unsafe content
3+
<span>Next element</span>
4+
</div>
5+
6+
<div>
7+
{{{unsafe content}}}
8+
<span>Valid</span>
9+
</div>
Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
---
2+
source: crates/biome_html_parser/tests/spec_test.rs
3+
expression: snapshot
4+
---
5+
## Input
6+
7+
```html
8+
<div>
9+
{{{unsafe content
10+
<span>Next element</span>
11+
</div>
12+
13+
<div>
14+
{{{unsafe content}}}
15+
<span>Valid</span>
16+
</div>
17+
18+
```
19+
20+
21+
## AST
22+
23+
```
24+
HtmlRoot {
25+
bom_token: missing (optional),
26+
frontmatter: missing (optional),
27+
directive: missing (optional),
28+
html: HtmlElementList [
29+
HtmlBogusElement {
30+
items: [
31+
HtmlOpeningElement {
32+
l_angle_token: L_ANGLE@0..1 "<" [] [],
33+
name: HtmlTagName {
34+
value_token: HTML_LITERAL@1..4 "div" [] [],
35+
},
36+
attributes: HtmlAttributeList [],
37+
r_angle_token: R_ANGLE@4..5 ">" [] [],
38+
},
39+
HtmlElementList [
40+
HtmlBogusElement {
41+
items: [
42+
L_TRIPLE_CURLY@5..11 "{{{" [Newline("\n"), Whitespace(" ")] [],
43+
GlimmerPath {
44+
at_token_token: missing (optional),
45+
segments: GlimmerPathSegmentList [
46+
GlimmerPathSegment {
47+
value_token_token: IDENT@11..18 "unsafe" [] [Whitespace(" ")],
48+
},
49+
],
50+
},
51+
GlimmerArgumentList [
52+
GlimmerPositionalArgument {
53+
value: GlimmerPath {
54+
at_token_token: missing (optional),
55+
segments: GlimmerPathSegmentList [
56+
GlimmerPathSegment {
57+
value_token_token: IDENT@18..25 "content" [] [],
58+
},
59+
],
60+
},
61+
},
62+
],
63+
ERROR_TOKEN@25..29 "<" [Newline("\n"), Whitespace(" ")] [],
64+
HTML_LITERAL@29..46 "span>Next element" [] [],
65+
],
66+
},
67+
],
68+
HtmlBogusElement {
69+
items: [
70+
L_ANGLE@46..47 "<" [] [],
71+
SLASH@47..48 "/" [] [],
72+
HtmlTagName {
73+
value_token: HTML_LITERAL@48..52 "span" [] [],
74+
},
75+
R_ANGLE@52..53 ">" [] [],
76+
],
77+
},
78+
HtmlElementList [],
79+
HtmlClosingElement {
80+
l_angle_token: L_ANGLE@53..55 "<" [Newline("\n")] [],
81+
slash_token: SLASH@55..56 "/" [] [],
82+
name: HtmlTagName {
83+
value_token: HTML_LITERAL@56..59 "div" [] [],
84+
},
85+
r_angle_token: R_ANGLE@59..60 ">" [] [],
86+
},
87+
],
88+
},
89+
HtmlElement {
90+
opening_element: HtmlOpeningElement {
91+
l_angle_token: L_ANGLE@60..63 "<" [Newline("\n"), Newline("\n")] [],
92+
name: HtmlTagName {
93+
value_token: HTML_LITERAL@63..66 "div" [] [],
94+
},
95+
attributes: HtmlAttributeList [],
96+
r_angle_token: R_ANGLE@66..67 ">" [] [],
97+
},
98+
children: HtmlElementList [
99+
GlimmerTripleStashExpression {
100+
l_curly3_token_token: L_TRIPLE_CURLY@67..73 "{{{" [Newline("\n"), Whitespace(" ")] [],
101+
path: GlimmerPath {
102+
at_token_token: missing (optional),
103+
segments: GlimmerPathSegmentList [
104+
GlimmerPathSegment {
105+
value_token_token: IDENT@73..80 "unsafe" [] [Whitespace(" ")],
106+
},
107+
],
108+
},
109+
arguments: GlimmerArgumentList [
110+
GlimmerPositionalArgument {
111+
value: GlimmerPath {
112+
at_token_token: missing (optional),
113+
segments: GlimmerPathSegmentList [
114+
GlimmerPathSegment {
115+
value_token_token: IDENT@80..87 "content" [] [],
116+
},
117+
],
118+
},
119+
},
120+
],
121+
r_curly3_token_token: R_TRIPLE_CURLY@87..90 "}}}" [] [],
122+
},
123+
HtmlElement {
124+
opening_element: HtmlOpeningElement {
125+
l_angle_token: L_ANGLE@90..94 "<" [Newline("\n"), Whitespace(" ")] [],
126+
name: HtmlTagName {
127+
value_token: HTML_LITERAL@94..98 "span" [] [],
128+
},
129+
attributes: HtmlAttributeList [],
130+
r_angle_token: R_ANGLE@98..99 ">" [] [],
131+
},
132+
children: HtmlElementList [
133+
HtmlContent {
134+
value_token: HTML_LITERAL@99..104 "Valid" [] [],
135+
},
136+
],
137+
closing_element: HtmlClosingElement {
138+
l_angle_token: L_ANGLE@104..105 "<" [] [],
139+
slash_token: SLASH@105..106 "/" [] [],
140+
name: HtmlTagName {
141+
value_token: HTML_LITERAL@106..110 "span" [] [],
142+
},
143+
r_angle_token: R_ANGLE@110..111 ">" [] [],
144+
},
145+
},
146+
],
147+
closing_element: HtmlClosingElement {
148+
l_angle_token: L_ANGLE@111..113 "<" [Newline("\n")] [],
149+
slash_token: SLASH@113..114 "/" [] [],
150+
name: HtmlTagName {
151+
value_token: HTML_LITERAL@114..117 "div" [] [],
152+
},
153+
r_angle_token: R_ANGLE@117..118 ">" [] [],
154+
},
155+
},
156+
],
157+
eof_token: EOF@118..119 "" [Newline("\n")] [],
158+
}
159+
```
160+
161+
## CST
162+
163+
```
164+
165+
0: (empty)
166+
1: (empty)
167+
2: (empty)
168+
169+
170+
171+
0: [email protected] "<" [] []
172+
173+
0: [email protected] "div" [] []
174+
175+
3: [email protected] ">" [] []
176+
177+
178+
0: [email protected] "{{{" [Newline("\n"), Whitespace(" ")] []
179+
1: GLIMMER_PATH@11..18
180+
0: (empty)
181+
1: GLIMMER_PATH_SEGMENT_LIST@11..18
182+
0: GLIMMER_PATH_SEGMENT@11..18
183+
0: IDENT@11..18 "unsafe" [] [Whitespace(" ")]
184+
2: GLIMMER_ARGUMENT_LIST@18..25
185+
0: GLIMMER_POSITIONAL_ARGUMENT@18..25
186+
0: GLIMMER_PATH@18..25
187+
0: (empty)
188+
1: GLIMMER_PATH_SEGMENT_LIST@18..25
189+
0: GLIMMER_PATH_SEGMENT@18..25
190+
0: IDENT@18..25 "content" [] []
191+
3: ERROR_TOKEN@25..29 "<" [Newline("\n"), Whitespace(" ")] []
192+
4: HTML_LITERAL@29..46 "span>Next element" [] []
193+
2: HTML_BOGUS_ELEMENT@46..53
194+
0: L_ANGLE@46..47 "<" [] []
195+
1: SLASH@47..48 "/" [] []
196+
2: HTML_TAG_NAME@48..52
197+
0: HTML_LITERAL@48..52 "span" [] []
198+
3: R_ANGLE@52..53 ">" [] []
199+
3: HTML_ELEMENT_LIST@53..53
200+
4: HTML_CLOSING_ELEMENT@53..60
201+
0: L_ANGLE@53..55 "<" [Newline("\n")] []
202+
1: SLASH@55..56 "/" [] []
203+
2: HTML_TAG_NAME@56..59
204+
0: HTML_LITERAL@56..59 "div" [] []
205+
3: R_ANGLE@59..60 ">" [] []
206+
1: HTML_ELEMENT@60..118
207+
0: HTML_OPENING_ELEMENT@60..67
208+
0: L_ANGLE@60..63 "<" [Newline("\n"), Newline("\n")] []
209+
1: HTML_TAG_NAME@63..66
210+
0: HTML_LITERAL@63..66 "div" [] []
211+
2: HTML_ATTRIBUTE_LIST@66..66
212+
3: R_ANGLE@66..67 ">" [] []
213+
1: HTML_ELEMENT_LIST@67..111
214+
0: GLIMMER_TRIPLE_STASH_EXPRESSION@67..90
215+
0: L_TRIPLE_CURLY@67..73 "{{{" [Newline("\n"), Whitespace(" ")] []
216+
1: GLIMMER_PATH@73..80
217+
0: (empty)
218+
1: GLIMMER_PATH_SEGMENT_LIST@73..80
219+
0: GLIMMER_PATH_SEGMENT@73..80
220+
0: IDENT@73..80 "unsafe" [] [Whitespace(" ")]
221+
2: GLIMMER_ARGUMENT_LIST@80..87
222+
0: GLIMMER_POSITIONAL_ARGUMENT@80..87
223+
0: GLIMMER_PATH@80..87
224+
0: (empty)
225+
1: GLIMMER_PATH_SEGMENT_LIST@80..87
226+
0: GLIMMER_PATH_SEGMENT@80..87
227+
0: IDENT@80..87 "content" [] []
228+
3: R_TRIPLE_CURLY@87..90 "}}}" [] []
229+
1: HTML_ELEMENT@90..111
230+
0: HTML_OPENING_ELEMENT@90..99
231+
0: L_ANGLE@90..94 "<" [Newline("\n"), Whitespace(" ")] []
232+
1: HTML_TAG_NAME@94..98
233+
0: HTML_LITERAL@94..98 "span" [] []
234+
2: HTML_ATTRIBUTE_LIST@98..98
235+
3: R_ANGLE@98..99 ">" [] []
236+
1: HTML_ELEMENT_LIST@99..104
237+
0: HTML_CONTENT@99..104
238+
0: HTML_LITERAL@99..104 "Valid" [] []
239+
2: HTML_CLOSING_ELEMENT@104..111
240+
0: L_ANGLE@104..105 "<" [] []
241+
1: SLASH@105..106 "/" [] []
242+
2: HTML_TAG_NAME@106..110
243+
0: HTML_LITERAL@106..110 "span" [] []
244+
3: R_ANGLE@110..111 ">" [] []
245+
2: HTML_CLOSING_ELEMENT@111..118
246+
0: L_ANGLE@111..113 "<" [Newline("\n")] []
247+
1: SLASH@113..114 "/" [] []
248+
2: HTML_TAG_NAME@114..117
249+
0: HTML_LITERAL@114..117 "div" [] []
250+
3: R_ANGLE@117..118 ">" [] []
251+
4: EOF@118..119 "" [Newline("\n")] []
252+
253+
```
254+
255+
## Diagnostics
256+
257+
```
258+
missing_triple_closing.html:3:3 parse ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
259+
260+
× Unexpected character `<`
261+
262+
1 │ <div>
263+
2 │ {{{unsafe content
264+
> 3 │ <span>Next element</span>
265+
│ ^
266+
4 │ </div>
267+
5 │
268+
269+
missing_triple_closing.html:3:21 parse ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
270+
271+
× Expected a matching closing tag but instead found '</span>'.
272+
273+
1 │ <div>
274+
2 │ {{{unsafe content
275+
> 3 │ <span>Next element</span>
276+
^^^^^^^
277+
4</div>
278+
5
279+
280+
i Expected a matching closing tag here.
281+
282+
1 │ <div>
283+
2 │ {{{unsafe content
284+
> 3 │ <span>Next element</span>
285+
│ ^^^^^^^
286+
4 │ </div>
287+
5 │
288+
289+
```

0 commit comments

Comments
 (0)