diff --git a/crates/biome_html_parser/src/lexer/mod.rs b/crates/biome_html_parser/src/lexer/mod.rs
index 4c799f15aa6c..b8294abe2e62 100644
--- a/crates/biome_html_parser/src/lexer/mod.rs
+++ b/crates/biome_html_parser/src/lexer/mod.rs
@@ -88,6 +88,16 @@ impl<'src> HtmlLexer<'src> {
}
}
+ fn consume_token_attribute_value(&mut self, current: u8) -> HtmlSyntaxKind {
+ match current {
+ b'\n' | b'\r' | b'\t' | b' ' => self.consume_newline_or_whitespaces(),
+ b'<' => self.consume_byte(T![<]),
+ b'>' => self.consume_byte(T![>]),
+ b'\'' | b'"' => self.consume_string_literal(current),
+ _ => self.consume_unquoted_string_literal(),
+ }
+ }
+
/// Bumps the current byte and creates a lexed token of the passed in kind.
#[inline]
fn consume_byte(&mut self, tok: HtmlSyntaxKind) -> HtmlSyntaxKind {
@@ -233,6 +243,41 @@ impl<'src> HtmlLexer<'src> {
}
}
+ /// Consume an attribute value that is not quoted.
+ ///
+ /// See: https://html.spec.whatwg.org/#attributes-2 under "Unquoted attribute value syntax"
+ fn consume_unquoted_string_literal(&mut self) -> HtmlSyntaxKind {
+ let mut content_started = false;
+ let mut encountered_invalid = false;
+ while let Some(current) = self.current_byte() {
+ match current {
+ // these characters safely terminate an unquoted attribute value
+ b'\n' | b'\r' | b'\t' | b' ' | b'>' => break,
+ // these characters are absolutely invalid in an unquoted attribute value
+ b'?' | b'\'' | b'"' | b'=' | b'<' | b'`' => {
+ encountered_invalid = true;
+ break;
+ }
+ _ if current.is_ascii() => {
+ self.advance(1);
+ content_started = true;
+ }
+ _ => break,
+ }
+ }
+
+ if content_started && !encountered_invalid {
+ HTML_STRING_LITERAL
+ } else {
+ let char = self.current_char_unchecked();
+ self.push_diagnostic(ParseDiagnostic::new(
+ "Unexpected character in unquoted attribute value",
+ self.text_position()..self.text_position() + char.text_len(),
+ ));
+ self.consume_unexpected_character()
+ }
+ }
+
fn consume_l_angle(&mut self) -> HtmlSyntaxKind {
self.assert_byte(b'<');
@@ -385,6 +430,7 @@ impl<'src> Lexer<'src> for HtmlLexer<'src> {
Some(current) => match context {
HtmlLexContext::Regular => self.consume_token(current),
HtmlLexContext::OutsideTag => self.consume_token_outside_tag(current),
+ HtmlLexContext::AttributeValue => self.consume_token_attribute_value(current),
},
None => EOF,
}
diff --git a/crates/biome_html_parser/src/lexer/tests.rs b/crates/biome_html_parser/src/lexer/tests.rs
index 7940a7e575b5..e5ecdfd921e6 100644
--- a/crates/biome_html_parser/src/lexer/tests.rs
+++ b/crates/biome_html_parser/src/lexer/tests.rs
@@ -250,3 +250,38 @@ fn html_text_spaces_with_lines() {
HTML_LITERAL: 18,
}
}
+
+#[test]
+fn unquoted_attribute_value_1() {
+ assert_lex! {
+ HtmlLexContext::AttributeValue,
+ "value",
+ HTML_STRING_LITERAL: 5,
+ }
+}
+
+#[test]
+fn unquoted_attribute_value_2() {
+ assert_lex! {
+ HtmlLexContext::AttributeValue,
+ "value value\tvalue\n",
+ HTML_STRING_LITERAL: 5,
+ WHITESPACE: 1,
+ HTML_STRING_LITERAL: 5,
+ WHITESPACE: 1,
+ HTML_STRING_LITERAL: 5,
+ NEWLINE: 1,
+ }
+}
+
+#[test]
+fn unquoted_attribute_value_invalid_chars() {
+ assert_lex! {
+ HtmlLexContext::AttributeValue,
+ "?<='\"`",
+ ERROR_TOKEN: 1,
+ L_ANGLE: 1,
+ ERROR_TOKEN: 1,
+ ERROR_TOKEN: 3,
+ }
+}
diff --git a/crates/biome_html_parser/src/syntax/mod.rs b/crates/biome_html_parser/src/syntax/mod.rs
index dc776b8c1a62..bdad6c7ddf2b 100644
--- a/crates/biome_html_parser/src/syntax/mod.rs
+++ b/crates/biome_html_parser/src/syntax/mod.rs
@@ -210,7 +210,7 @@ fn parse_literal(p: &mut HtmlParser) -> ParsedSyntax {
Present(m.complete(p, HTML_NAME))
}
-fn parse_string_literal(p: &mut HtmlParser) -> ParsedSyntax {
+fn parse_attribute_string_literal(p: &mut HtmlParser) -> ParsedSyntax {
if !p.at(HTML_STRING_LITERAL) {
return Absent;
}
@@ -226,7 +226,7 @@ fn parse_attribute_initializer(p: &mut HtmlParser) -> ParsedSyntax {
return Absent;
}
let m = p.start();
- p.bump(T![=]);
- parse_string_literal(p).or_add_diagnostic(p, expected_initializer);
+ p.bump_with_context(T![=], HtmlLexContext::AttributeValue);
+ parse_attribute_string_literal(p).or_add_diagnostic(p, expected_initializer);
Present(m.complete(p, HTML_ATTRIBUTE_INITIALIZER_CLAUSE))
}
diff --git a/crates/biome_html_parser/src/token_source.rs b/crates/biome_html_parser/src/token_source.rs
index 9c8d6b809ad9..336f0b8ccd6a 100644
--- a/crates/biome_html_parser/src/token_source.rs
+++ b/crates/biome_html_parser/src/token_source.rs
@@ -23,6 +23,10 @@ pub(crate) enum HtmlLexContext {
///
/// The exeptions being `<` which indicates the start of a tag, and `>` which is invalid syntax if not preceeded with a `<`.
OutsideTag,
+ /// When the parser encounters a `=` token (the beginning of the attribute initializer clause), it switches to this context.
+ ///
+ /// This is because attribute values can start and end with a `"` or `'` character, or be unquoted, and the lexer needs to know to start lexing a string literal.
+ AttributeValue,
}
impl LexContext for HtmlLexContext {
diff --git a/crates/biome_html_parser/tests/html_specs/error/attributes/invalid-unqouted-value1.html b/crates/biome_html_parser/tests/html_specs/error/attributes/invalid-unqouted-value1.html
new file mode 100644
index 000000000000..56609257ea95
--- /dev/null
+++ b/crates/biome_html_parser/tests/html_specs/error/attributes/invalid-unqouted-value1.html
@@ -0,0 +1,4 @@
+
diff --git a/crates/biome_html_parser/tests/html_specs/error/attributes/invalid-unqouted-value1.html.snap b/crates/biome_html_parser/tests/html_specs/error/attributes/invalid-unqouted-value1.html.snap
new file mode 100644
index 000000000000..ff3240e8dcbe
--- /dev/null
+++ b/crates/biome_html_parser/tests/html_specs/error/attributes/invalid-unqouted-value1.html.snap
@@ -0,0 +1,251 @@
+---
+source: crates/biome_html_parser/tests/spec_test.rs
+expression: snapshot
+---
+## Input
+
+```html
+
+
+```
+
+
+## AST
+
+```
+HtmlRoot {
+ bom_token: missing (optional),
+ directive: missing (optional),
+ html: HtmlElement {
+ opening_element: HtmlOpeningElement {
+ l_angle_token: L_ANGLE@0..1 "<" [] [],
+ name: HtmlName {
+ value_token: HTML_LITERAL@1..4 "div" [] [],
+ },
+ attributes: HtmlAttributeList [],
+ r_angle_token: R_ANGLE@4..5 ">" [] [],
+ },
+ children: HtmlElementList [
+ HtmlBogusElement {
+ items: [
+ HtmlBogus {
+ items: [
+ L_ANGLE@5..8 "<" [Newline("\n"), Whitespace("\t")] [],
+ HtmlName {
+ value_token: HTML_LITERAL@8..12 "div" [] [Whitespace(" ")],
+ },
+ HtmlBogus {
+ items: [
+ HtmlAttribute {
+ name: HtmlName {
+ value_token: HTML_LITERAL@12..17 "class" [] [],
+ },
+ initializer: HtmlAttributeInitializerClause {
+ eq_token: EQ@17..18 "=" [] [],
+ value: missing (required),
+ },
+ },
+ HtmlBogusElement {
+ items: [
+ ERROR_TOKEN@18..20 "=" [] [Whitespace(" ")],
+ ],
+ },
+ ],
+ },
+ R_ANGLE@20..21 ">" [] [],
+ ],
+ },
+ HtmlElementList [
+ HtmlContent {
+ value_token: HTML_LITERAL@21..24 "foo" [] [],
+ },
+ ],
+ HtmlClosingElement {
+ l_angle_token: L_ANGLE@24..25 "<" [] [],
+ slash_token: SLASH@25..26 "/" [] [],
+ name: HtmlName {
+ value_token: HTML_LITERAL@26..29 "div" [] [],
+ },
+ r_angle_token: R_ANGLE@29..30 ">" [] [],
+ },
+ ],
+ },
+ HtmlBogusElement {
+ items: [
+ HtmlBogus {
+ items: [
+ L_ANGLE@30..33 "<" [Newline("\n"), Whitespace("\t")] [],
+ HtmlName {
+ value_token: HTML_LITERAL@33..37 "div" [] [Whitespace(" ")],
+ },
+ HtmlBogus {
+ items: [
+ HtmlAttribute {
+ name: HtmlName {
+ value_token: HTML_LITERAL@37..42 "class" [] [],
+ },
+ initializer: HtmlAttributeInitializerClause {
+ eq_token: EQ@42..43 "=" [] [],
+ value: missing (required),
+ },
+ },
+ HtmlBogusElement {
+ items: [
+ ERROR_TOKEN@43..45 "?" [] [Whitespace(" ")],
+ ],
+ },
+ ],
+ },
+ R_ANGLE@45..46 ">" [] [],
+ ],
+ },
+ HtmlElementList [
+ HtmlContent {
+ value_token: HTML_LITERAL@46..49 "foo" [] [],
+ },
+ ],
+ HtmlClosingElement {
+ l_angle_token: L_ANGLE@49..50 "<" [] [],
+ slash_token: SLASH@50..51 "/" [] [],
+ name: HtmlName {
+ value_token: HTML_LITERAL@51..54 "div" [] [],
+ },
+ r_angle_token: R_ANGLE@54..55 ">" [] [],
+ },
+ ],
+ },
+ ],
+ closing_element: HtmlClosingElement {
+ l_angle_token: L_ANGLE@55..57 "<" [Newline("\n")] [],
+ slash_token: SLASH@57..58 "/" [] [],
+ name: HtmlName {
+ value_token: HTML_LITERAL@58..61 "div" [] [],
+ },
+ r_angle_token: R_ANGLE@61..62 ">" [] [],
+ },
+ },
+ eof_token: EOF@62..63 "" [Newline("\n")] [],
+}
+```
+
+## CST
+
+```
+0: HTML_ROOT@0..63
+ 0: (empty)
+ 1: (empty)
+ 2: HTML_ELEMENT@0..62
+ 0: HTML_OPENING_ELEMENT@0..5
+ 0: L_ANGLE@0..1 "<" [] []
+ 1: HTML_NAME@1..4
+ 0: HTML_LITERAL@1..4 "div" [] []
+ 2: HTML_ATTRIBUTE_LIST@4..4
+ 3: R_ANGLE@4..5 ">" [] []
+ 1: HTML_ELEMENT_LIST@5..55
+ 0: HTML_BOGUS_ELEMENT@5..30
+ 0: HTML_BOGUS@5..21
+ 0: L_ANGLE@5..8 "<" [Newline("\n"), Whitespace("\t")] []
+ 1: HTML_NAME@8..12
+ 0: HTML_LITERAL@8..12 "div" [] [Whitespace(" ")]
+ 2: HTML_BOGUS@12..20
+ 0: HTML_ATTRIBUTE@12..18
+ 0: HTML_NAME@12..17
+ 0: HTML_LITERAL@12..17 "class" [] []
+ 1: HTML_ATTRIBUTE_INITIALIZER_CLAUSE@17..18
+ 0: EQ@17..18 "=" [] []
+ 1: (empty)
+ 1: HTML_BOGUS_ELEMENT@18..20
+ 0: ERROR_TOKEN@18..20 "=" [] [Whitespace(" ")]
+ 3: R_ANGLE@20..21 ">" [] []
+ 1: HTML_ELEMENT_LIST@21..24
+ 0: HTML_CONTENT@21..24
+ 0: HTML_LITERAL@21..24 "foo" [] []
+ 2: HTML_CLOSING_ELEMENT@24..30
+ 0: L_ANGLE@24..25 "<" [] []
+ 1: SLASH@25..26 "/" [] []
+ 2: HTML_NAME@26..29
+ 0: HTML_LITERAL@26..29 "div" [] []
+ 3: R_ANGLE@29..30 ">" [] []
+ 1: HTML_BOGUS_ELEMENT@30..55
+ 0: HTML_BOGUS@30..46
+ 0: L_ANGLE@30..33 "<" [Newline("\n"), Whitespace("\t")] []
+ 1: HTML_NAME@33..37
+ 0: HTML_LITERAL@33..37 "div" [] [Whitespace(" ")]
+ 2: HTML_BOGUS@37..45
+ 0: HTML_ATTRIBUTE@37..43
+ 0: HTML_NAME@37..42
+ 0: HTML_LITERAL@37..42 "class" [] []
+ 1: HTML_ATTRIBUTE_INITIALIZER_CLAUSE@42..43
+ 0: EQ@42..43 "=" [] []
+ 1: (empty)
+ 1: HTML_BOGUS_ELEMENT@43..45
+ 0: ERROR_TOKEN@43..45 "?" [] [Whitespace(" ")]
+ 3: R_ANGLE@45..46 ">" [] []
+ 1: HTML_ELEMENT_LIST@46..49
+ 0: HTML_CONTENT@46..49
+ 0: HTML_LITERAL@46..49 "foo" [] []
+ 2: HTML_CLOSING_ELEMENT@49..55
+ 0: L_ANGLE@49..50 "<" [] []
+ 1: SLASH@50..51 "/" [] []
+ 2: HTML_NAME@51..54
+ 0: HTML_LITERAL@51..54 "div" [] []
+ 3: R_ANGLE@54..55 ">" [] []
+ 2: HTML_CLOSING_ELEMENT@55..62
+ 0: L_ANGLE@55..57 "<" [Newline("\n")] []
+ 1: SLASH@57..58 "/" [] []
+ 2: HTML_NAME@58..61
+ 0: HTML_LITERAL@58..61 "div" [] []
+ 3: R_ANGLE@61..62 ">" [] []
+ 3: EOF@62..63 "" [Newline("\n")] []
+
+```
+
+## Diagnostics
+
+```
+invalid-unqouted-value1.html:2:13 parse ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+ × Unexpected character in unquoted attribute value
+
+ 1 │
+ > 2 │
foo
+ │ ^
+ 3 │
foo
+ 4 │
+
+invalid-unqouted-value1.html:2:13 parse ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+ × Unexpected character `=`
+
+ 1 │
+ > 2 │
foo
+ │ ^
+ 3 │
foo
+ 4 │
+
+invalid-unqouted-value1.html:3:13 parse ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+ × Unexpected character in unquoted attribute value
+
+ 1 │
+ 2 │
foo
+ > 3 │
foo
+ │ ^
+ 4 │
+ 5 │
+
+invalid-unqouted-value1.html:3:13 parse ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+ × Unexpected character `?`
+
+ 1 │
+ 2 │
foo
+ > 3 │
foo
+ │ ^
+ 4 │
+ 5 │
+
+```
diff --git a/crates/biome_html_parser/tests/html_specs/error/attributes/invalid-unquoted-value2.html b/crates/biome_html_parser/tests/html_specs/error/attributes/invalid-unquoted-value2.html
new file mode 100644
index 000000000000..fe61e92c4303
--- /dev/null
+++ b/crates/biome_html_parser/tests/html_specs/error/attributes/invalid-unquoted-value2.html
@@ -0,0 +1,4 @@
+
diff --git a/crates/biome_html_parser/tests/html_specs/error/attributes/invalid-unquoted-value2.html.snap b/crates/biome_html_parser/tests/html_specs/error/attributes/invalid-unquoted-value2.html.snap
new file mode 100644
index 000000000000..46cdb81333ee
--- /dev/null
+++ b/crates/biome_html_parser/tests/html_specs/error/attributes/invalid-unquoted-value2.html.snap
@@ -0,0 +1,293 @@
+---
+source: crates/biome_html_parser/tests/spec_test.rs
+expression: snapshot
+---
+## Input
+
+```html
+
+
+```
+
+
+## AST
+
+```
+HtmlRoot {
+ bom_token: missing (optional),
+ directive: missing (optional),
+ html: HtmlElement {
+ opening_element: HtmlOpeningElement {
+ l_angle_token: L_ANGLE@0..1 "<" [] [],
+ name: HtmlName {
+ value_token: HTML_LITERAL@1..4 "div" [] [],
+ },
+ attributes: HtmlAttributeList [],
+ r_angle_token: R_ANGLE@4..5 ">" [] [],
+ },
+ children: HtmlElementList [
+ HtmlBogusElement {
+ items: [
+ HtmlBogus {
+ items: [
+ L_ANGLE@5..8 "<" [Newline("\n"), Whitespace("\t")] [],
+ HtmlName {
+ value_token: HTML_LITERAL@8..12 "div" [] [Whitespace(" ")],
+ },
+ HtmlBogus {
+ items: [
+ HtmlAttribute {
+ name: HtmlName {
+ value_token: HTML_LITERAL@12..17 "class" [] [],
+ },
+ initializer: HtmlAttributeInitializerClause {
+ eq_token: EQ@17..18 "=" [] [],
+ value: missing (required),
+ },
+ },
+ HtmlBogusElement {
+ items: [
+ ERROR_TOKEN@18..22 "foo\"" [] [],
+ HTML_LITERAL@22..26 "bar" [] [Whitespace(" ")],
+ ],
+ },
+ ],
+ },
+ R_ANGLE@26..27 ">" [] [],
+ ],
+ },
+ HtmlElementList [
+ HtmlContent {
+ value_token: HTML_LITERAL@27..30 "foo" [] [],
+ },
+ ],
+ HtmlClosingElement {
+ l_angle_token: L_ANGLE@30..31 "<" [] [],
+ slash_token: SLASH@31..32 "/" [] [],
+ name: HtmlName {
+ value_token: HTML_LITERAL@32..35 "div" [] [],
+ },
+ r_angle_token: R_ANGLE@35..36 ">" [] [],
+ },
+ ],
+ },
+ HtmlBogusElement {
+ items: [
+ HtmlBogus {
+ items: [
+ L_ANGLE@36..39 "<" [Newline("\n"), Whitespace("\t")] [],
+ HtmlName {
+ value_token: HTML_LITERAL@39..43 "div" [] [Whitespace(" ")],
+ },
+ HtmlBogus {
+ items: [
+ HtmlAttribute {
+ name: HtmlName {
+ value_token: HTML_LITERAL@43..48 "class" [] [],
+ },
+ initializer: HtmlAttributeInitializerClause {
+ eq_token: EQ@48..49 "=" [] [],
+ value: missing (required),
+ },
+ },
+ HtmlBogusElement {
+ items: [
+ ERROR_TOKEN@49..53 "foo'" [] [],
+ HTML_LITERAL@53..57 "bar" [] [Whitespace(" ")],
+ ],
+ },
+ ],
+ },
+ R_ANGLE@57..58 ">" [] [],
+ ],
+ },
+ HtmlElementList [
+ HtmlContent {
+ value_token: HTML_LITERAL@58..61 "foo" [] [],
+ },
+ ],
+ HtmlClosingElement {
+ l_angle_token: L_ANGLE@61..62 "<" [] [],
+ slash_token: SLASH@62..63 "/" [] [],
+ name: HtmlName {
+ value_token: HTML_LITERAL@63..66 "div" [] [],
+ },
+ r_angle_token: R_ANGLE@66..67 ">" [] [],
+ },
+ ],
+ },
+ ],
+ closing_element: HtmlClosingElement {
+ l_angle_token: L_ANGLE@67..69 "<" [Newline("\n")] [],
+ slash_token: SLASH@69..70 "/" [] [],
+ name: HtmlName {
+ value_token: HTML_LITERAL@70..73 "div" [] [],
+ },
+ r_angle_token: R_ANGLE@73..74 ">" [] [],
+ },
+ },
+ eof_token: EOF@74..75 "" [Newline("\n")] [],
+}
+```
+
+## CST
+
+```
+0: HTML_ROOT@0..75
+ 0: (empty)
+ 1: (empty)
+ 2: HTML_ELEMENT@0..74
+ 0: HTML_OPENING_ELEMENT@0..5
+ 0: L_ANGLE@0..1 "<" [] []
+ 1: HTML_NAME@1..4
+ 0: HTML_LITERAL@1..4 "div" [] []
+ 2: HTML_ATTRIBUTE_LIST@4..4
+ 3: R_ANGLE@4..5 ">" [] []
+ 1: HTML_ELEMENT_LIST@5..67
+ 0: HTML_BOGUS_ELEMENT@5..36
+ 0: HTML_BOGUS@5..27
+ 0: L_ANGLE@5..8 "<" [Newline("\n"), Whitespace("\t")] []
+ 1: HTML_NAME@8..12
+ 0: HTML_LITERAL@8..12 "div" [] [Whitespace(" ")]
+ 2: HTML_BOGUS@12..26
+ 0: HTML_ATTRIBUTE@12..18
+ 0: HTML_NAME@12..17
+ 0: HTML_LITERAL@12..17 "class" [] []
+ 1: HTML_ATTRIBUTE_INITIALIZER_CLAUSE@17..18
+ 0: EQ@17..18 "=" [] []
+ 1: (empty)
+ 1: HTML_BOGUS_ELEMENT@18..26
+ 0: ERROR_TOKEN@18..22 "foo\"" [] []
+ 1: HTML_LITERAL@22..26 "bar" [] [Whitespace(" ")]
+ 3: R_ANGLE@26..27 ">" [] []
+ 1: HTML_ELEMENT_LIST@27..30
+ 0: HTML_CONTENT@27..30
+ 0: HTML_LITERAL@27..30 "foo" [] []
+ 2: HTML_CLOSING_ELEMENT@30..36
+ 0: L_ANGLE@30..31 "<" [] []
+ 1: SLASH@31..32 "/" [] []
+ 2: HTML_NAME@32..35
+ 0: HTML_LITERAL@32..35 "div" [] []
+ 3: R_ANGLE@35..36 ">" [] []
+ 1: HTML_BOGUS_ELEMENT@36..67
+ 0: HTML_BOGUS@36..58
+ 0: L_ANGLE@36..39 "<" [Newline("\n"), Whitespace("\t")] []
+ 1: HTML_NAME@39..43
+ 0: HTML_LITERAL@39..43 "div" [] [Whitespace(" ")]
+ 2: HTML_BOGUS@43..57
+ 0: HTML_ATTRIBUTE@43..49
+ 0: HTML_NAME@43..48
+ 0: HTML_LITERAL@43..48 "class" [] []
+ 1: HTML_ATTRIBUTE_INITIALIZER_CLAUSE@48..49
+ 0: EQ@48..49 "=" [] []
+ 1: (empty)
+ 1: HTML_BOGUS_ELEMENT@49..57
+ 0: ERROR_TOKEN@49..53 "foo'" [] []
+ 1: HTML_LITERAL@53..57 "bar" [] [Whitespace(" ")]
+ 3: R_ANGLE@57..58 ">" [] []
+ 1: HTML_ELEMENT_LIST@58..61
+ 0: HTML_CONTENT@58..61
+ 0: HTML_LITERAL@58..61 "foo" [] []
+ 2: HTML_CLOSING_ELEMENT@61..67
+ 0: L_ANGLE@61..62 "<" [] []
+ 1: SLASH@62..63 "/" [] []
+ 2: HTML_NAME@63..66
+ 0: HTML_LITERAL@63..66 "div" [] []
+ 3: R_ANGLE@66..67 ">" [] []
+ 2: HTML_CLOSING_ELEMENT@67..74
+ 0: L_ANGLE@67..69 "<" [Newline("\n")] []
+ 1: SLASH@69..70 "/" [] []
+ 2: HTML_NAME@70..73
+ 0: HTML_LITERAL@70..73 "div" [] []
+ 3: R_ANGLE@73..74 ">" [] []
+ 3: EOF@74..75 "" [Newline("\n")] []
+
+```
+
+## Diagnostics
+
+```
+invalid-unquoted-value2.html:2:13 parse ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+ × Expected an initializer but instead found 'foo"'.
+
+ 1 │
+ > 2 │
foo
+ │ ^^^^
+ 3 │
foo
+ 4 │
+
+ i Expected an initializer here.
+
+ 1 │
+ > 2 │
foo
+ │ ^^^^
+ 3 │
foo
+ 4 │
+
+invalid-unquoted-value2.html:2:16 parse ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+ × Unexpected character in unquoted attribute value
+
+ 1 │
+ > 2 │
foo
+ │ ^
+ 3 │
foo
+ 4 │
+
+invalid-unquoted-value2.html:2:16 parse ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+ × Unexpected character `"`
+
+ 1 │
+ > 2 │
foo
+ │ ^
+ 3 │
foo
+ 4 │
+
+invalid-unquoted-value2.html:3:13 parse ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+ × Expected an initializer but instead found 'foo''.
+
+ 1 │
+ 2 │
foo
+ > 3 │
foo
+ │ ^^^^
+ 4 │
+ 5 │
+
+ i Expected an initializer here.
+
+ 1 │
+ 2 │
foo
+ > 3 │
foo
+ │ ^^^^
+ 4 │
+ 5 │
+
+invalid-unquoted-value2.html:3:16 parse ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+ × Unexpected character in unquoted attribute value
+
+ 1 │
+ 2 │
foo
+ > 3 │
foo
+ │ ^
+ 4 │
+ 5 │
+
+invalid-unquoted-value2.html:3:16 parse ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+ × Unexpected character `'`
+
+ 1 │
+ 2 │
foo
+ > 3 │
foo
+ │ ^
+ 4 │
+ 5 │
+
+```
diff --git a/crates/biome_html_parser/tests/html_specs/ok/attributes/attributes-unquoted.html b/crates/biome_html_parser/tests/html_specs/ok/attributes/attributes-unquoted.html
new file mode 100644
index 000000000000..c2251dfca82a
--- /dev/null
+++ b/crates/biome_html_parser/tests/html_specs/ok/attributes/attributes-unquoted.html
@@ -0,0 +1,9 @@
+
+
bar
+
bar
+
bar
+
bar
+
bar
+
+
+
diff --git a/crates/biome_html_parser/tests/html_specs/ok/attributes/attributes-unquoted.html.snap b/crates/biome_html_parser/tests/html_specs/ok/attributes/attributes-unquoted.html.snap
new file mode 100644
index 000000000000..2d88f21b6b7d
--- /dev/null
+++ b/crates/biome_html_parser/tests/html_specs/ok/attributes/attributes-unquoted.html.snap
@@ -0,0 +1,433 @@
+---
+source: crates/biome_html_parser/tests/spec_test.rs
+expression: snapshot
+---
+## Input
+
+```html
+
+
bar
+
bar
+
bar
+
bar
+
bar
+
+
+
+
+```
+
+
+## AST
+
+```
+HtmlRoot {
+ bom_token: missing (optional),
+ directive: missing (optional),
+ html: HtmlElement {
+ opening_element: HtmlOpeningElement {
+ l_angle_token: L_ANGLE@0..1 "<" [] [],
+ name: HtmlName {
+ value_token: HTML_LITERAL@1..4 "div" [] [],
+ },
+ attributes: HtmlAttributeList [],
+ r_angle_token: R_ANGLE@4..5 ">" [] [],
+ },
+ children: HtmlElementList [
+ HtmlElement {
+ opening_element: HtmlOpeningElement {
+ l_angle_token: L_ANGLE@5..8 "<" [Newline("\n"), Whitespace("\t")] [],
+ name: HtmlName {
+ value_token: HTML_LITERAL@8..12 "div" [] [Whitespace(" ")],
+ },
+ attributes: HtmlAttributeList [
+ HtmlAttribute {
+ name: HtmlName {
+ value_token: HTML_LITERAL@12..16 "data" [] [],
+ },
+ initializer: HtmlAttributeInitializerClause {
+ eq_token: EQ@16..17 "=" [] [],
+ value: HtmlString {
+ value_token: HTML_STRING_LITERAL@17..20 "foo" [] [],
+ },
+ },
+ },
+ ],
+ r_angle_token: R_ANGLE@20..21 ">" [] [],
+ },
+ children: HtmlElementList [
+ HtmlContent {
+ value_token: HTML_LITERAL@21..24 "bar" [] [],
+ },
+ ],
+ closing_element: HtmlClosingElement {
+ l_angle_token: L_ANGLE@24..25 "<" [] [],
+ slash_token: SLASH@25..26 "/" [] [],
+ name: HtmlName {
+ value_token: HTML_LITERAL@26..29 "div" [] [],
+ },
+ r_angle_token: R_ANGLE@29..30 ">" [] [],
+ },
+ },
+ HtmlElement {
+ opening_element: HtmlOpeningElement {
+ l_angle_token: L_ANGLE@30..33 "<" [Newline("\n"), Whitespace("\t")] [],
+ name: HtmlName {
+ value_token: HTML_LITERAL@33..37 "div" [] [Whitespace(" ")],
+ },
+ attributes: HtmlAttributeList [
+ HtmlAttribute {
+ name: HtmlName {
+ value_token: HTML_LITERAL@37..41 "data" [] [],
+ },
+ initializer: HtmlAttributeInitializerClause {
+ eq_token: EQ@41..42 "=" [] [],
+ value: HtmlString {
+ value_token: HTML_STRING_LITERAL@42..46 "foo" [] [Whitespace(" ")],
+ },
+ },
+ },
+ ],
+ r_angle_token: R_ANGLE@46..47 ">" [] [],
+ },
+ children: HtmlElementList [
+ HtmlContent {
+ value_token: HTML_LITERAL@47..50 "bar" [] [],
+ },
+ ],
+ closing_element: HtmlClosingElement {
+ l_angle_token: L_ANGLE@50..51 "<" [] [],
+ slash_token: SLASH@51..52 "/" [] [],
+ name: HtmlName {
+ value_token: HTML_LITERAL@52..55 "div" [] [],
+ },
+ r_angle_token: R_ANGLE@55..56 ">" [] [],
+ },
+ },
+ HtmlElement {
+ opening_element: HtmlOpeningElement {
+ l_angle_token: L_ANGLE@56..59 "<" [Newline("\n"), Whitespace("\t")] [],
+ name: HtmlName {
+ value_token: HTML_LITERAL@59..63 "div" [] [Whitespace(" ")],
+ },
+ attributes: HtmlAttributeList [
+ HtmlAttribute {
+ name: HtmlName {
+ value_token: HTML_LITERAL@63..68 "data" [] [Whitespace(" ")],
+ },
+ initializer: HtmlAttributeInitializerClause {
+ eq_token: EQ@68..70 "=" [] [Whitespace(" ")],
+ value: HtmlString {
+ value_token: HTML_STRING_LITERAL@70..74 "foo" [] [Whitespace(" ")],
+ },
+ },
+ },
+ ],
+ r_angle_token: R_ANGLE@74..75 ">" [] [],
+ },
+ children: HtmlElementList [
+ HtmlContent {
+ value_token: HTML_LITERAL@75..78 "bar" [] [],
+ },
+ ],
+ closing_element: HtmlClosingElement {
+ l_angle_token: L_ANGLE@78..79 "<" [] [],
+ slash_token: SLASH@79..80 "/" [] [],
+ name: HtmlName {
+ value_token: HTML_LITERAL@80..83 "div" [] [],
+ },
+ r_angle_token: R_ANGLE@83..84 ">" [] [],
+ },
+ },
+ HtmlElement {
+ opening_element: HtmlOpeningElement {
+ l_angle_token: L_ANGLE@84..87 "<" [Newline("\n"), Whitespace("\t")] [],
+ name: HtmlName {
+ value_token: HTML_LITERAL@87..91 "div" [] [Whitespace(" ")],
+ },
+ attributes: HtmlAttributeList [
+ HtmlAttribute {
+ name: HtmlName {
+ value_token: HTML_LITERAL@91..95 "data" [] [],
+ },
+ initializer: HtmlAttributeInitializerClause {
+ eq_token: EQ@95..97 "=" [] [Whitespace("\t")],
+ value: HtmlString {
+ value_token: HTML_STRING_LITERAL@97..101 "foo" [] [Whitespace(" ")],
+ },
+ },
+ },
+ ],
+ r_angle_token: R_ANGLE@101..102 ">" [] [],
+ },
+ children: HtmlElementList [
+ HtmlContent {
+ value_token: HTML_LITERAL@102..105 "bar" [] [],
+ },
+ ],
+ closing_element: HtmlClosingElement {
+ l_angle_token: L_ANGLE@105..106 "<" [] [],
+ slash_token: SLASH@106..107 "/" [] [],
+ name: HtmlName {
+ value_token: HTML_LITERAL@107..110 "div" [] [],
+ },
+ r_angle_token: R_ANGLE@110..111 ">" [] [],
+ },
+ },
+ HtmlElement {
+ opening_element: HtmlOpeningElement {
+ l_angle_token: L_ANGLE@111..114 "<" [Newline("\n"), Whitespace("\t")] [],
+ name: HtmlName {
+ value_token: HTML_LITERAL@114..118 "div" [] [Whitespace(" ")],
+ },
+ attributes: HtmlAttributeList [
+ HtmlAttribute {
+ name: HtmlName {
+ value_token: HTML_LITERAL@118..122 "data" [] [],
+ },
+ initializer: HtmlAttributeInitializerClause {
+ eq_token: EQ@122..124 "=" [] [Whitespace("\t")],
+ value: HtmlString {
+ value_token: HTML_STRING_LITERAL@124..128 "foo" [] [Whitespace("\t")],
+ },
+ },
+ },
+ ],
+ r_angle_token: R_ANGLE@128..129 ">" [] [],
+ },
+ children: HtmlElementList [
+ HtmlContent {
+ value_token: HTML_LITERAL@129..132 "bar" [] [],
+ },
+ ],
+ closing_element: HtmlClosingElement {
+ l_angle_token: L_ANGLE@132..133 "<" [] [],
+ slash_token: SLASH@133..134 "/" [] [],
+ name: HtmlName {
+ value_token: HTML_LITERAL@134..137 "div" [] [],
+ },
+ r_angle_token: R_ANGLE@137..138 ">" [] [],
+ },
+ },
+ HtmlSelfClosingElement {
+ l_angle_token: L_ANGLE@138..141 "<" [Newline("\n"), Whitespace("\t")] [],
+ name: HtmlName {
+ value_token: HTML_LITERAL@141..145 "img" [] [Whitespace(" ")],
+ },
+ attributes: HtmlAttributeList [
+ HtmlAttribute {
+ name: HtmlName {
+ value_token: HTML_LITERAL@145..149 "data" [] [],
+ },
+ initializer: HtmlAttributeInitializerClause {
+ eq_token: EQ@149..150 "=" [] [],
+ value: HtmlString {
+ value_token: HTML_STRING_LITERAL@150..154 "foo/" [] [],
+ },
+ },
+ },
+ ],
+ slash_token: missing (optional),
+ r_angle_token: R_ANGLE@154..155 ">" [] [],
+ },
+ HtmlSelfClosingElement {
+ l_angle_token: L_ANGLE@155..158 "<" [Newline("\n"), Whitespace("\t")] [],
+ name: HtmlName {
+ value_token: HTML_LITERAL@158..162 "img" [] [Whitespace(" ")],
+ },
+ attributes: HtmlAttributeList [
+ HtmlAttribute {
+ name: HtmlName {
+ value_token: HTML_LITERAL@162..166 "data" [] [],
+ },
+ initializer: HtmlAttributeInitializerClause {
+ eq_token: EQ@166..167 "=" [] [],
+ value: HtmlString {
+ value_token: HTML_STRING_LITERAL@167..171 "foo" [] [Whitespace(" ")],
+ },
+ },
+ },
+ ],
+ slash_token: SLASH@171..172 "/" [] [],
+ r_angle_token: R_ANGLE@172..173 ">" [] [],
+ },
+ ],
+ closing_element: HtmlClosingElement {
+ l_angle_token: L_ANGLE@173..175 "<" [Newline("\n")] [],
+ slash_token: SLASH@175..176 "/" [] [],
+ name: HtmlName {
+ value_token: HTML_LITERAL@176..179 "div" [] [],
+ },
+ r_angle_token: R_ANGLE@179..180 ">" [] [],
+ },
+ },
+ eof_token: EOF@180..181 "" [Newline("\n")] [],
+}
+```
+
+## CST
+
+```
+0: HTML_ROOT@0..181
+ 0: (empty)
+ 1: (empty)
+ 2: HTML_ELEMENT@0..180
+ 0: HTML_OPENING_ELEMENT@0..5
+ 0: L_ANGLE@0..1 "<" [] []
+ 1: HTML_NAME@1..4
+ 0: HTML_LITERAL@1..4 "div" [] []
+ 2: HTML_ATTRIBUTE_LIST@4..4
+ 3: R_ANGLE@4..5 ">" [] []
+ 1: HTML_ELEMENT_LIST@5..173
+ 0: HTML_ELEMENT@5..30
+ 0: HTML_OPENING_ELEMENT@5..21
+ 0: L_ANGLE@5..8 "<" [Newline("\n"), Whitespace("\t")] []
+ 1: HTML_NAME@8..12
+ 0: HTML_LITERAL@8..12 "div" [] [Whitespace(" ")]
+ 2: HTML_ATTRIBUTE_LIST@12..20
+ 0: HTML_ATTRIBUTE@12..20
+ 0: HTML_NAME@12..16
+ 0: HTML_LITERAL@12..16 "data" [] []
+ 1: HTML_ATTRIBUTE_INITIALIZER_CLAUSE@16..20
+ 0: EQ@16..17 "=" [] []
+ 1: HTML_STRING@17..20
+ 0: HTML_STRING_LITERAL@17..20 "foo" [] []
+ 3: R_ANGLE@20..21 ">" [] []
+ 1: HTML_ELEMENT_LIST@21..24
+ 0: HTML_CONTENT@21..24
+ 0: HTML_LITERAL@21..24 "bar" [] []
+ 2: HTML_CLOSING_ELEMENT@24..30
+ 0: L_ANGLE@24..25 "<" [] []
+ 1: SLASH@25..26 "/" [] []
+ 2: HTML_NAME@26..29
+ 0: HTML_LITERAL@26..29 "div" [] []
+ 3: R_ANGLE@29..30 ">" [] []
+ 1: HTML_ELEMENT@30..56
+ 0: HTML_OPENING_ELEMENT@30..47
+ 0: L_ANGLE@30..33 "<" [Newline("\n"), Whitespace("\t")] []
+ 1: HTML_NAME@33..37
+ 0: HTML_LITERAL@33..37 "div" [] [Whitespace(" ")]
+ 2: HTML_ATTRIBUTE_LIST@37..46
+ 0: HTML_ATTRIBUTE@37..46
+ 0: HTML_NAME@37..41
+ 0: HTML_LITERAL@37..41 "data" [] []
+ 1: HTML_ATTRIBUTE_INITIALIZER_CLAUSE@41..46
+ 0: EQ@41..42 "=" [] []
+ 1: HTML_STRING@42..46
+ 0: HTML_STRING_LITERAL@42..46 "foo" [] [Whitespace(" ")]
+ 3: R_ANGLE@46..47 ">" [] []
+ 1: HTML_ELEMENT_LIST@47..50
+ 0: HTML_CONTENT@47..50
+ 0: HTML_LITERAL@47..50 "bar" [] []
+ 2: HTML_CLOSING_ELEMENT@50..56
+ 0: L_ANGLE@50..51 "<" [] []
+ 1: SLASH@51..52 "/" [] []
+ 2: HTML_NAME@52..55
+ 0: HTML_LITERAL@52..55 "div" [] []
+ 3: R_ANGLE@55..56 ">" [] []
+ 2: HTML_ELEMENT@56..84
+ 0: HTML_OPENING_ELEMENT@56..75
+ 0: L_ANGLE@56..59 "<" [Newline("\n"), Whitespace("\t")] []
+ 1: HTML_NAME@59..63
+ 0: HTML_LITERAL@59..63 "div" [] [Whitespace(" ")]
+ 2: HTML_ATTRIBUTE_LIST@63..74
+ 0: HTML_ATTRIBUTE@63..74
+ 0: HTML_NAME@63..68
+ 0: HTML_LITERAL@63..68 "data" [] [Whitespace(" ")]
+ 1: HTML_ATTRIBUTE_INITIALIZER_CLAUSE@68..74
+ 0: EQ@68..70 "=" [] [Whitespace(" ")]
+ 1: HTML_STRING@70..74
+ 0: HTML_STRING_LITERAL@70..74 "foo" [] [Whitespace(" ")]
+ 3: R_ANGLE@74..75 ">" [] []
+ 1: HTML_ELEMENT_LIST@75..78
+ 0: HTML_CONTENT@75..78
+ 0: HTML_LITERAL@75..78 "bar" [] []
+ 2: HTML_CLOSING_ELEMENT@78..84
+ 0: L_ANGLE@78..79 "<" [] []
+ 1: SLASH@79..80 "/" [] []
+ 2: HTML_NAME@80..83
+ 0: HTML_LITERAL@80..83 "div" [] []
+ 3: R_ANGLE@83..84 ">" [] []
+ 3: HTML_ELEMENT@84..111
+ 0: HTML_OPENING_ELEMENT@84..102
+ 0: L_ANGLE@84..87 "<" [Newline("\n"), Whitespace("\t")] []
+ 1: HTML_NAME@87..91
+ 0: HTML_LITERAL@87..91 "div" [] [Whitespace(" ")]
+ 2: HTML_ATTRIBUTE_LIST@91..101
+ 0: HTML_ATTRIBUTE@91..101
+ 0: HTML_NAME@91..95
+ 0: HTML_LITERAL@91..95 "data" [] []
+ 1: HTML_ATTRIBUTE_INITIALIZER_CLAUSE@95..101
+ 0: EQ@95..97 "=" [] [Whitespace("\t")]
+ 1: HTML_STRING@97..101
+ 0: HTML_STRING_LITERAL@97..101 "foo" [] [Whitespace(" ")]
+ 3: R_ANGLE@101..102 ">" [] []
+ 1: HTML_ELEMENT_LIST@102..105
+ 0: HTML_CONTENT@102..105
+ 0: HTML_LITERAL@102..105 "bar" [] []
+ 2: HTML_CLOSING_ELEMENT@105..111
+ 0: L_ANGLE@105..106 "<" [] []
+ 1: SLASH@106..107 "/" [] []
+ 2: HTML_NAME@107..110
+ 0: HTML_LITERAL@107..110 "div" [] []
+ 3: R_ANGLE@110..111 ">" [] []
+ 4: HTML_ELEMENT@111..138
+ 0: HTML_OPENING_ELEMENT@111..129
+ 0: L_ANGLE@111..114 "<" [Newline("\n"), Whitespace("\t")] []
+ 1: HTML_NAME@114..118
+ 0: HTML_LITERAL@114..118 "div" [] [Whitespace(" ")]
+ 2: HTML_ATTRIBUTE_LIST@118..128
+ 0: HTML_ATTRIBUTE@118..128
+ 0: HTML_NAME@118..122
+ 0: HTML_LITERAL@118..122 "data" [] []
+ 1: HTML_ATTRIBUTE_INITIALIZER_CLAUSE@122..128
+ 0: EQ@122..124 "=" [] [Whitespace("\t")]
+ 1: HTML_STRING@124..128
+ 0: HTML_STRING_LITERAL@124..128 "foo" [] [Whitespace("\t")]
+ 3: R_ANGLE@128..129 ">" [] []
+ 1: HTML_ELEMENT_LIST@129..132
+ 0: HTML_CONTENT@129..132
+ 0: HTML_LITERAL@129..132 "bar" [] []
+ 2: HTML_CLOSING_ELEMENT@132..138
+ 0: L_ANGLE@132..133 "<" [] []
+ 1: SLASH@133..134 "/" [] []
+ 2: HTML_NAME@134..137
+ 0: HTML_LITERAL@134..137 "div" [] []
+ 3: R_ANGLE@137..138 ">" [] []
+ 5: HTML_SELF_CLOSING_ELEMENT@138..155
+ 0: L_ANGLE@138..141 "<" [Newline("\n"), Whitespace("\t")] []
+ 1: HTML_NAME@141..145
+ 0: HTML_LITERAL@141..145 "img" [] [Whitespace(" ")]
+ 2: HTML_ATTRIBUTE_LIST@145..154
+ 0: HTML_ATTRIBUTE@145..154
+ 0: HTML_NAME@145..149
+ 0: HTML_LITERAL@145..149 "data" [] []
+ 1: HTML_ATTRIBUTE_INITIALIZER_CLAUSE@149..154
+ 0: EQ@149..150 "=" [] []
+ 1: HTML_STRING@150..154
+ 0: HTML_STRING_LITERAL@150..154 "foo/" [] []
+ 3: (empty)
+ 4: R_ANGLE@154..155 ">" [] []
+ 6: HTML_SELF_CLOSING_ELEMENT@155..173
+ 0: L_ANGLE@155..158 "<" [Newline("\n"), Whitespace("\t")] []
+ 1: HTML_NAME@158..162
+ 0: HTML_LITERAL@158..162 "img" [] [Whitespace(" ")]
+ 2: HTML_ATTRIBUTE_LIST@162..171
+ 0: HTML_ATTRIBUTE@162..171
+ 0: HTML_NAME@162..166
+ 0: HTML_LITERAL@162..166 "data" [] []
+ 1: HTML_ATTRIBUTE_INITIALIZER_CLAUSE@166..171
+ 0: EQ@166..167 "=" [] []
+ 1: HTML_STRING@167..171
+ 0: HTML_STRING_LITERAL@167..171 "foo" [] [Whitespace(" ")]
+ 3: SLASH@171..172 "/" [] []
+ 4: R_ANGLE@172..173 ">" [] []
+ 2: HTML_CLOSING_ELEMENT@173..180
+ 0: L_ANGLE@173..175 "<" [Newline("\n")] []
+ 1: SLASH@175..176 "/" [] []
+ 2: HTML_NAME@176..179
+ 0: HTML_LITERAL@176..179 "div" [] []
+ 3: R_ANGLE@179..180 ">" [] []
+ 3: EOF@180..181 "" [Newline("\n")] []
+
+```