Skip to content

fix(parse/html/vue): parse dynamic slot directives that contain quotes#8856

Merged
dyc3 merged 1 commit intomainfrom
dyc3/parse-vue-dynamic-slots
Jan 24, 2026
Merged

fix(parse/html/vue): parse dynamic slot directives that contain quotes#8856
dyc3 merged 1 commit intomainfrom
dyc3/parse-vue-dynamic-slots

Conversation

@dyc3
Copy link
Contributor

@dyc3 dyc3 commented Jan 24, 2026

Summary

This PR lets the HTML parser parse v-slot directives that have more complex expressions inside them: #[`${episode.id}-cell`]="{ row }"

Primarily generated by gpt-5.2-codex, but added a couple more test cases myself.

fixes #8710

Test Plan

added snapshots

Docs

@changeset-bot
Copy link

changeset-bot bot commented Jan 24, 2026

🦋 Changeset detected

Latest commit: 368c9ee

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 13 packages
Name Type
@biomejs/biome Patch
@biomejs/cli-win32-x64 Patch
@biomejs/cli-win32-arm64 Patch
@biomejs/cli-darwin-x64 Patch
@biomejs/cli-darwin-arm64 Patch
@biomejs/cli-linux-x64 Patch
@biomejs/cli-linux-arm64 Patch
@biomejs/cli-linux-x64-musl Patch
@biomejs/cli-linux-arm64-musl Patch
@biomejs/wasm-web Patch
@biomejs/wasm-bundler Patch
@biomejs/wasm-nodejs Patch
@biomejs/backend-jsonrpc Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions github-actions bot added A-Parser Area: parser L-HTML Language: HTML and super languages labels Jan 24, 2026
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 24, 2026

Walkthrough

Adds a new lexing context VueDirectiveArgument and implements consume_token_vue_directive_argument in the HTML lexer to scan bracketed Vue directive arguments while tracking nested brackets and quote boundaries. The Vue parser is updated to switch to this context when encountering [ for dynamic arguments. Tests covering dynamic slot arguments (including template-literal keys) are added and a changeset entry for a patch release is included.

Possibly related PRs

Suggested labels

D-Vue

Suggested reviewers

  • chansuke
  • ematipico
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: fixing Vue dynamic slot directive parsing to handle quotes/template literals.
Description check ✅ Passed The description clearly explains the problem (parsing v-slot directives with complex expressions) and references issue #8710 with test snapshots added.
Linked Issues check ✅ Passed The PR fully addresses issue #8710 by implementing lexing context handling for Vue directive arguments containing quotes and template literals.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing Vue dynamic slot parsing: new lexer context, token handling, test cases, and changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In @.changeset/fair-ligers-smell.md:
- Line 5: Update the issue reference in the changeset content: replace the
incorrect "[`#8803`]" with the correct "[`#8710`]" in the sentence "Fixed
[`#8803`](https://github.com/biomejs/biome/issues/8803): Biome now parses Vue
dynamic slot shorthand arguments..." so the changeset points to issue `#8710`
instead of `#8803`.

@dyc3 dyc3 force-pushed the dyc3/parse-vue-dynamic-slots branch from 35c39b3 to 48b8dc0 Compare January 24, 2026 16:41
Copy link
Member

@ematipico ematipico left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Damn, these are very complex cases... 🤣

@dyc3 dyc3 force-pushed the dyc3/parse-vue-dynamic-slots branch from 48b8dc0 to 368c9ee Compare January 24, 2026 17:45
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@crates/biome_html_parser/src/lexer/mod.rs`:
- Around line 158-192: The quote state in consume_token_vue_directive_argument
is toggled blindly by QuotesSeen::check_byte, so escaped quotes like \" or \'
break tracking and let a ] inside a quoted string close the argument; update
QuotesSeen::check_byte (or add an escape-aware layer in
consume_token_vue_directive_argument using current_byte/peek_previous logic) to
ignore quote bytes that are escaped (handle backslash escaping, including
escaped backslashes) while preserving normal toggling otherwise, and add a
regression test for HtmlLexContext::VueDirectiveArgument that includes escaped
quotes inside brackets to ensure the parser does not prematurely close on ].

Comment on lines +158 to +192
/// Consume a token in the [HtmlLexContext::VueDirectiveArgument] context.
fn consume_token_vue_directive_argument(&mut self) -> HtmlSyntaxKind {
let start = self.text_position();
let mut brackets_stack = 0;
let mut quotes_seen = QuotesSeen::new();

while let Some(byte) = self.current_byte() {
quotes_seen.check_byte(byte);
let char = biome_unicode_table::lookup_byte(byte);
use biome_unicode_table::Dispatch::*;

if quotes_seen.is_empty() {
match char {
BTO => {
brackets_stack += 1;
}
BTC => {
if brackets_stack == 0 {
break;
}
brackets_stack -= 1;
}
_ => {}
}
}

self.advance_byte_or_char(byte);
}

if self.text_position() != start {
HTML_LITERAL
} else {
ERROR_TOKEN
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

❓ Verification inconclusive

Script executed:

# Find and inspect the QuotesSeen implementation
rg -A 10 "struct QuotesSeen" crates/biome_html_parser/src/

Repository: biomejs/biome


Repository: biomejs/biome
Exit code: 0

stdout:

crates/biome_html_parser/src/lexer/mod.rs:struct QuotesSeen {
crates/biome_html_parser/src/lexer/mod.rs-    single: u16,
crates/biome_html_parser/src/lexer/mod.rs-    double: u16,
crates/biome_html_parser/src/lexer/mod.rs-    template: u16,
crates/biome_html_parser/src/lexer/mod.rs-    inside_comment: bool,
crates/biome_html_parser/src/lexer/mod.rs-    prev_byte: Option<u8>,
crates/biome_html_parser/src/lexer/mod.rs-}
crates/biome_html_parser/src/lexer/mod.rs-
crates/biome_html_parser/src/lexer/mod.rs-impl QuotesSeen {
crates/biome_html_parser/src/lexer/mod.rs-    fn new() -> Self {
crates/biome_html_parser/src/lexer/mod.rs-        Self {

Script executed:

# Check the full consume_token_vue_directive_argument function and surrounding context
cat -n crates/biome_html_parser/src/lexer/mod.rs | sed -n '150,200p'

Repository: biomejs/biome


Repository: biomejs/biome
Exit code: 0

stdout:

   150	            }
   151	            _ if (self.current_kind != T![<] && is_attribute_name_byte_vue(current)) => {
   152	                self.consume_identifier(current, IdentifierContext::Vue)
   153	            }
   154	            _ => self.consume_unexpected_character(),
   155	        }
   156	    }
   157	
   158	    /// Consume a token in the [HtmlLexContext::VueDirectiveArgument] context.
   159	    fn consume_token_vue_directive_argument(&mut self) -> HtmlSyntaxKind {
   160	        let start = self.text_position();
   161	        let mut brackets_stack = 0;
   162	        let mut quotes_seen = QuotesSeen::new();
   163	
   164	        while let Some(byte) = self.current_byte() {
   165	            quotes_seen.check_byte(byte);
   166	            let char = biome_unicode_table::lookup_byte(byte);
   167	            use biome_unicode_table::Dispatch::*;
   168	
   169	            if quotes_seen.is_empty() {
   170	                match char {
   171	                    BTO => {
   172	                        brackets_stack += 1;
   173	                    }
   174	                    BTC => {
   175	                        if brackets_stack == 0 {
   176	                            break;
   177	                        }
   178	                        brackets_stack -= 1;
   179	                    }
   180	                    _ => {}
   181	                }
   182	            }
   183	
   184	            self.advance_byte_or_char(byte);
   185	        }
   186	
   187	        if self.text_position() != start {
   188	            HTML_LITERAL
   189	        } else {
   190	            ERROR_TOKEN
   191	        }
   192	    }
   193	
   194	    /// Consume a token in the [HtmlLexContext::Regular] context.
   195	    fn consume_token(&mut self, current: u8) -> HtmlSyntaxKind {
   196	        match current {
   197	            b'\n' | b'\r' | b'\t' | b' ' => self.consume_newline_or_whitespaces(),
   198	            b'!' if self.current() == T![<] => self.consume_byte(T![!]),
   199	            b'/' if self.current() == T![<] => self.consume_byte(T![/]),
   200	            b',' if self.current() == T![<] => self.consume_byte(T![,]),

Script executed:

# Search for tests related to Vue directives
find crates/biome_html_parser -name "*.rs" -type f | xargs grep -l "vue_directive\|VueDirective" | head -10

Repository: biomejs/biome


Repository: biomejs/biome
Exit code: 0

stdout:

crates/biome_html_parser/src/lexer/mod.rs
crates/biome_html_parser/src/syntax/mod.rs
crates/biome_html_parser/src/syntax/parse_error.rs
crates/biome_html_parser/src/syntax/vue.rs
crates/biome_html_parser/src/token_source.rs

Script executed:

# Look for escape handling in the lexer
rg -i "escape\|backslash\|\\\\" crates/biome_html_parser/src/lexer/ -A 2 -B 2

Repository: biomejs/biome


Repository: biomejs/biome
Exit code: 1


Script executed:

# Get the full QuotesSeen impl block, especially check_byte method
rg -A 50 "impl QuotesSeen" crates/biome_html_parser/src/lexer/mod.rs | head -80

Repository: biomejs/biome


Repository: biomejs/biome
Exit code: 0

stdout:

impl QuotesSeen {
    fn new() -> Self {
        Self {
            single: 0,
            double: 0,
            template: 0,
            inside_comment: false,
            prev_byte: None,
        }
    }

    /// It checks the given byte. If it's a quote, it's tracked
    fn check_byte(&mut self, byte: u8) {
        // Check for comment exit first
        if self.inside_comment {
            if byte == b'\n' {
                // Exit single-line comment
                self.inside_comment = false;
            } else if self.prev_byte == Some(b'*') && byte == b'/' {
                // Exit multi-line comment
                self.inside_comment = false;
            }
            self.prev_byte = Some(byte);
            return; // Don't track quotes inside comments
        }

        // Check for comment entry - but only if we're not inside quotes
        if self.prev_byte == Some(b'/')
            && (byte == b'/' || byte == b'*')
            && self.single == 0
            && self.double == 0
            && self.template == 0
        {
            self.inside_comment = true;
            self.prev_byte = Some(byte);
            return;
        }

        // Normal quote tracking
        match byte {
            b'"' => self.track_double(),
            b'\'' => self.track_single(),
            b'`' => self.track_template(),
            _ => {}
        }

        self.prev_byte = Some(byte);
    }

    /// It adds a single quote if single quotes are zero and the others are greater than zero. It removes it otherwise
    fn track_single(&mut self) {

Script executed:

# Search for Vue directive tests
find crates/biome_html_parser/tests -type f -name "*.rs" 2>/dev/null | xargs grep -l "vue\|directive" 2>/dev/null || find crates -name "*vue*.rs" -path "*/tests/*"

Repository: biomejs/biome


Repository: biomejs/biome
Exit code: 0

stdout:

crates/biome_html_parser/tests/spec_test.rs
crates/biome_html_parser/tests/spec_tests.rs

Script executed:

# Also check if there are snapshot/fixture tests for Vue
find crates/biome_html_parser -name "*.html" -o -name "*vue*" | grep -i test

Repository: biomejs/biome


Repository: biomejs/biome
Exit code: 0

stdout:

crates/biome_html_parser/tests/html_specs/error/attributes/invalid-unquoted-value1.html
crates/biome_html_parser/tests/html_specs/error/attributes/invalid-unquoted-value2.html
crates/biome_html_parser/tests/html_specs/error/attributes/missing-initializer.html
crates/biome_html_parser/tests/html_specs/error/element/br-with-end.html
crates/biome_html_parser/tests/html_specs/error/element/child-no-tag-name.html
crates/biome_html_parser/tests/html_specs/error/element/missing-close-tag-2.html
crates/biome_html_parser/tests/html_specs/error/element/missing-close-tag.html
crates/biome_html_parser/tests/html_specs/error/element/missing-element-name.html
crates/biome_html_parser/tests/html_specs/error/element/solo-no-tag-name.html
crates/biome_html_parser/tests/html_specs/error/frontmatter.html
crates/biome_html_parser/tests/html_specs/error/frontmatter_bogus.html
crates/biome_html_parser/tests/html_specs/error/interpolation-attributes.html
crates/biome_html_parser/tests/html_specs/error/interpolation.html
crates/biome_html_parser/tests/html_specs/error/template-langs/django/issue-5450.html
crates/biome_html_parser/tests/html_specs/error/vue
crates/biome_html_parser/tests/html_specs/error/vue/invalid-v-bind-shorthand.vue
crates/biome_html_parser/tests/html_specs/error/vue/invalid-v-bind-shorthand.vue.snap
crates/biome_html_parser/tests/html_specs/error/vue/invalid-v-on-shorthand.vue
crates/biome_html_parser/tests/html_specs/error/vue/invalid-v-on-shorthand.vue.snap
crates/biome_html_parser/tests/html_specs/error/vue/invalid-v-slot-shorthand.vue
crates/biome_html_parser/tests/html_specs/error/vue/invalid-v-slot-shorthand.vue.snap
crates/biome_html_parser/tests/html_specs/error/vue/vue_unclosed_expression.vue
crates/biome_html_parser/tests/html_specs/error/vue/vue_unclosed_expression.vue.snap
crates/biome_html_parser/tests/html_specs/ok/attributes/attributes-unquoted.html
crates/biome_html_parser/tests/html_specs/ok/attributes/attributes.html
crates/biome_html_parser/tests/html_specs/ok/attributes/multiline-attribute-value.html
crates/biome_html_parser/tests/html_specs/ok/attributes/multiline-attributes.html
crates/biome_html_parser/tests/html_specs/ok/bom.html
crates/biome_html_parser/tests/html_specs/ok/cdata.html
crates/biome_html_parser/tests/html_specs/ok/comment-inline.html
crates/biome_html_parser/tests/html_specs/ok/comment.html
crates/biome_html_parser/tests/html_specs/ok/comment2.html
crates/biome_html_parser/tests/html_specs/ok/compat/non-spec-compliant-tag-name.html
crates/biome_html_parser/tests/html_specs/ok/content-blocks.html
crates/biome_html_parser/tests/html_specs/ok/doctype/basic.html
crates/biome_html_parser/tests/html_specs/ok/doctype/long-legacy1.html
crates/biome_html_parser/tests/html_specs/ok/doctype/long-legacy2.html
crates/biome_html_parser/tests/html_specs/ok/doctype/long-legacy3.html
crates/biome_html_parser/tests/html_specs/ok/doctype/minimal.html
crates/biome_html_parser/tests/html_specs/ok/element.html
crates/biome_html_parser/tests/html_specs/ok/element_list.html
crates/biome_html_parser/tests/html_specs/ok/element_list2.html
crates/biome_html_parser/tests/html_specs/ok/embedded-languages/script-basic.html
crates/biome_html_parser/tests/html_specs/ok/embedded-languages/script-empty.html
crates/biome_html_parser/tests/html_specs/ok/embedded-languages/script-in-string.html
crates/biome_html_parser/tests/html_specs/ok/embedded-languages/script-less-than.html
crates/biome_html_parser/tests/html_specs/ok/hello-world.html
crates/biome_html_parser/tests/html_specs/ok/no-end-tags/br-in-span.html
crates/biome_html_parser/tests/html_specs/ok/no-end-tags/br.html
crates/biome_html_parser/tests/html_specs/ok/no-end-tags/casing.html
crates/biome_html_parser/tests/html_specs/ok/no-end-tags/meta.html
crates/biome_html_parser/tests/html_specs/ok/no-end-tags/param.html
crates/biome_html_parser/tests/html_specs/ok/no-end-tags/wbr.html
crates/biome_html_parser/tests/html_specs/ok/ok.html
crates/biome_html_parser/tests/html_specs/ok/ok2.html
crates/biome_html_parser/tests/html_specs/ok/quote-in-child.html
crates/biome_html_parser/tests/html_specs/ok/special-chars.html
crates/biome_html_parser/tests/html_specs/ok/text_keywords.html
crates/biome_html_parser/tests/html_specs/ok/vue
crates/biome_html_parser/tests/html_specs/ok/vue/component.vue
crates/biome_html_parser/tests/html_specs/ok/vue/component.vue.snap
crates/biome_html_parser/tests/html_specs/ok/vue/event-with-colon.vue
crates/biome_html_parser/tests/html_specs/ok/vue/event-with-colon.vue.snap
crates/biome_html_parser/tests/html_specs/ok/vue/generic-directives/arg-modifiers-no-value.vue
crates/biome_html_parser/tests/html_specs/ok/vue/generic-directives/arg-modifiers-no-value.vue.snap
crates/biome_html_parser/tests/html_specs/ok/vue/generic-directives/arg-no-modifiers-no-value.vue
crates/biome_html_parser/tests/html_specs/ok/vue/generic-directives/arg-no-modifiers-no-value.vue.snap
crates/biome_html_parser/tests/html_specs/ok/vue/generic-directives/directive-only.vue
crates/biome_html_parser/tests/html_specs/ok/vue/generic-directives/directive-only.vue.snap
crates/biome_html_parser/tests/html_specs/ok/vue/generic-directives/modifiers-no-arg-no-value.vue
crates/biome_html_parser/tests/html_specs/ok/vue/generic-directives/modifiers-no-arg-no-value.vue.snap
crates/biome_html_parser/tests/html_specs/ok/vue/interpolation.vue
crates/biome_html_parser/tests/html_specs/ok/vue/interpolation.vue.snap
crates/biome_html_parser/tests/html_specs/ok/vue/issue-8174.vue
crates/biome_html_parser/tests/html_specs/ok/vue/issue-8174.vue.snap
crates/biome_html_parser/tests/html_specs/ok/vue/issue-8765.vue
crates/biome_html_parser/tests/html_specs/ok/vue/issue-8765.vue.snap
crates/biome_html_parser/tests/html_specs/ok/vue/modifier.vue
crates/biome_html_parser/tests/html_specs/ok/vue/modifier.vue.snap
crates/biome_html_parser/tests/html_specs/ok/vue/modifiers-all-variants.vue
crates/biome_html_parser/tests/html_specs/ok/vue/modifiers-all-variants.vue.snap
crates/biome_html_parser/tests/html_specs/ok/vue/multiple_expressions.vue
crates/biome_html_parser/tests/html_specs/ok/vue/multiple_expressions.vue.snap
crates/biome_html_parser/tests/html_specs/ok/vue/v-bind-dynamic.vue
crates/biome_html_parser/tests/html_specs/ok/vue/v-bind-dynamic.vue.snap
crates/biome_html_parser/tests/html_specs/ok/vue/v-bind-mixed.vue
crates/biome_html_parser/tests/html_specs/ok/vue/v-bind-mixed.vue.snap
crates/biome_html_parser/tests/html_specs/ok/vue/v-bind-shorthand.vue
crates/biome_html_parser/tests/html_specs/ok/vue/v-bind-shorthand.vue.snap
crates/biome_html_parser/tests/html_specs/ok/vue/v-bind.vue
crates/biome_html_parser/tests/html_specs/ok/vue/v-bind.vue.snap
crates/biome_html_parser/tests/html_specs/ok/vue/v-dynamic-chains.vue
crates/biome_html_parser/tests/html_specs/ok/vue/v-dynamic-chains.vue.snap
crates/biome_html_parser/tests/html_specs/ok/vue/v-else-if.vue
crates/biome_html_parser/tests/html_specs/ok/vue/v-else-if.vue.snap
crates/biome_html_parser/tests/html_specs/ok/vue/v-else.vue
crates/biome_html_parser/tests/html_specs/ok/vue/v-else.vue.snap
crates/biome_html_parser/tests/html_specs/ok/vue/v-for.vue
crates/biome_html_parser/tests/html_specs/ok/vue/v-for.vue.snap
crates/biome_html_parser/tests/html_specs/ok/vue/v-html-text.vue
crates/biome_html_parser/tests/html_specs/ok/vue/v-html-text.vue.snap
crates/biome_html_parser/tests/html_specs/ok/vue/v-if.vue
crates/biome_html_parser/tests/html_specs/ok/vue/v-if.vue.snap
crates/biome_html_parser/tests/html_specs/ok/vue/v-mixed-complex.vue
crates/biome_html_parser/tests/html_specs/ok/vue/v-mixed-complex.vue.snap
crates/biome_html_parser/tests/html_specs/ok/vue/v-model-mixed.vue
crates/biome_html_parser/tests/html_specs/ok/vue/v-model-mixed.vue.snap
crates/biome_html_parser/tests/html_specs/ok/vue/v-on-mixed.vue
crates/biome_html_parser/tests/html_specs/ok/vue/v-on-mixed.vue.snap
crates/biome_html_parser/tests/html_specs/ok/vue/v-on-shorthand.vue
crates/biome_html_parser/tests/html_specs/ok/vue/v-on-shorthand.vue.snap
crates/biome_html_parser/tests/html_specs/ok/vue/v-slot-shorthand.vue
crates/biome_html_parser/tests/html_specs/ok/vue/v-slot-shorthand.vue.snap
crates/biome_html_parser/tests/html_specs/ok/vue/vue_expressions.vue
crates/biome_html_parser/tests/html_specs/ok/vue/vue_expressions.vue.snap
crates/biome_html_parser/tests/html_specs/ok/vue/vue_expressions_escaped.vue
crates/biome_html_parser/tests/html_specs/ok/vue/vue_expressions_escaped.vue.snap
crates/biome_html_parser/tests/html_specs/ok/vue/dynamic-slot-arg.vue
crates/biome_html_parser/tests/html_specs/ok/vue/dynamic-slot-arg.vue.snap

Script executed:

# Check the vue_expressions_escaped.vue test
cat crates/biome_html_parser/tests/html_specs/ok/vue/vue_expressions_escaped.vue

Repository: biomejs/biome


Repository: biomejs/biome
Exit code: 0

stdout:

<template>
	\{\{ xyz \}\}
</template>

Script executed:

# Check the full QuotesSeen implementation, specifically the tracking methods
rg -A 100 "fn track_single" crates/biome_html_parser/src/lexer/mod.rs | head -60

Repository: biomejs/biome


Repository: biomejs/biome
Exit code: 0

stdout:

    fn track_single(&mut self) {
        if (self.single == 0 && (self.double > 0 || self.template > 0))
            || (self.single == 0 && self.double == 0 && self.template == 0)
        {
            self.single.add_assign(1);
        } else {
            self.single = self.single.saturating_sub(1);
        }
    }
    /// It adds a double quote if double quotes are zero and the others are greater than zero. It removes it otherwise
    fn track_double(&mut self) {
        if (self.double == 0 && (self.single > 0 || self.template > 0))
            || (self.double == 0 && self.single == 0 && self.template == 0)
        {
            self.double.add_assign(1);
        } else {
            self.double = self.double.saturating_sub(1);
        }
    }

    /// It adds a template quote if template quotes are zero and the others are greater than zero. It removes it otherwise
    fn track_template(&mut self) {
        if (self.template == 0 && (self.single > 0 || self.double > 0))
            || (self.template == 0 && self.single == 0 && self.double == 0)
        {
            self.template.add_assign(1);
        } else {
            self.template = self.template.saturating_sub(1);
        }
    }

    fn is_empty(&self) -> bool {
        self.single == 0 && self.double == 0 && self.template == 0
    }
}

#[cfg(test)]
mod quotes_seen {
    use crate::lexer::QuotesSeen;

    fn track(source: &str, quotes_seen: &mut QuotesSeen) {
        for char in source.as_bytes() {
            quotes_seen.check_byte(*char);
        }
    }

    #[test]
    fn is_not_empty() {
        let source = r#"'"`"#;
        let mut quotes_seen = QuotesSeen::new();
        track(source, &mut quotes_seen);
        assert!(!quotes_seen.is_empty());

        let source = r#"`'""'"#;
        let mut quotes_seen = QuotesSeen::new();
        track(source, &mut quotes_seen);
        assert!(!quotes_seen.is_empty());
    }
    #[test]
    fn is_empty() {

Script executed:

# Look for any escape handling elsewhere in consume_token_vue_directive_argument or related code
rg -B 5 -A 15 "consume_token_vue_directive_argument" crates/biome_html_parser/src/lexer/mod.rs | tail -40

Repository: biomejs/biome


Repository: biomejs/biome
Exit code: 0

stdout:


    /// Consume a token in the [HtmlLexContext::VueDirectiveArgument] context.
    fn consume_token_vue_directive_argument(&mut self) -> HtmlSyntaxKind {
        let start = self.text_position();
        let mut brackets_stack = 0;
        let mut quotes_seen = QuotesSeen::new();

        while let Some(byte) = self.current_byte() {
            quotes_seen.check_byte(byte);
            let char = biome_unicode_table::lookup_byte(byte);
            use biome_unicode_table::Dispatch::*;

            if quotes_seen.is_empty() {
                match char {
                    BTO => {
                        brackets_stack += 1;
                    }
                    BTC => {
--
                Some(current) => match context {
                    HtmlLexContext::Regular => self.consume_token(current),
                    HtmlLexContext::InsideTag => self.consume_token_inside_tag(current),
                    HtmlLexContext::InsideTagVue => self.consume_token_inside_tag_vue(current),
                    HtmlLexContext::VueDirectiveArgument => {
                        self.consume_token_vue_directive_argument()
                    }
                    HtmlLexContext::AttributeValue => self.consume_token_attribute_value(current),
                    HtmlLexContext::Doctype => self.consume_token_doctype(current),
                    HtmlLexContext::EmbeddedLanguage(lang) => {
                        self.consume_token_embedded_language(current, lang, context)
                    }
                    HtmlLexContext::TextExpression(kind) => match kind {
                        TextExpressionKind::Double => self.consume_double_text_expression(current),
                        TextExpressionKind::Single => self.consume_single_text_expression(),
                    },
                    HtmlLexContext::RestrictedSingleExpression(kind) => {
                        self.consume_restricted_single_text_expression(kind)
                    }
                    HtmlLexContext::CdataSection => self.consume_inside_cdata(current),
                    HtmlLexContext::AstroFencedCodeBlock => {

Script executed:

# Look for Vue directive argument tests with special characters
find crates/biome_html_parser/tests/html_specs -name "*.vue" -type f -exec grep -l "\[" {} \; | head -5

Repository: biomejs/biome


Repository: biomejs/biome
Exit code: 0

stdout:

crates/biome_html_parser/tests/html_specs/ok/vue/v-bind-dynamic.vue
crates/biome_html_parser/tests/html_specs/ok/vue/v-bind-mixed.vue
crates/biome_html_parser/tests/html_specs/ok/vue/v-dynamic-chains.vue
crates/biome_html_parser/tests/html_specs/ok/vue/v-else-if.vue
crates/biome_html_parser/tests/html_specs/ok/vue/v-html-text.vue

stderr:

find: 'grep' terminated by signal 13
find: 'grep' terminated by signal 13
find: 'grep' terminated by signal 13
find: 'grep' terminated by signal 13
find: 'grep' terminated by signal 13

Script executed:

# Check a Vue test file that likely has dynamic arguments
cat crates/biome_html_parser/tests/html_specs/ok/vue/v-bind-dynamic.vue

Repository: biomejs/biome


Repository: biomejs/biome
Exit code: 0

stdout:

<template>
	<Foo :[key]="foo" />
</template>

Script executed:

# Search for any backslash handling in Vue context
rg "backslash|escape.*quote|quote.*escape" crates/biome_html_parser/src/ -i

Repository: biomejs/biome


Repository: biomejs/biome
Exit code: 0

stdout:

crates/biome_html_parser/src/lexer/mod.rs:    /// Consumes a quoted string literal token, handling escaped characters and unicode sequences.
crates/biome_html_parser/src/lexer/mod.rs:                        // Handle escaped `'` but only if this is a end quote string.
crates/biome_html_parser/src/lexer/mod.rs:                        // Handle escaped `'` but only if this is a end quote string.

Script executed:

# Check if there are any existing issues or TODOs about escape handling
rg "TODO|FIXME|BUG" crates/biome_html_parser/src/lexer/mod.rs -A 2

Repository: biomejs/biome


Repository: biomejs/biome
Exit code: 0

stdout:

    AS_KW, ATTACH_KW, AWAIT_KW, CATCH_KW, COMMENT, CONST_KW, DEBUG_KW, DOCTYPE_KW, EACH_KW,
    ELSE_KW, EOF, ERROR_TOKEN, HTML_KW, HTML_LITERAL, HTML_STRING_LITERAL, IDENT, IF_KW, KEY_KW,
    NEWLINE, RENDER_KW, SNIPPET_KW, THEN_KW, TOMBSTONE, UNICODE_BOM, WHITESPACE,
--
            b"debug" => DEBUG_KW,
            b"attach" => ATTACH_KW,
            b"const" => CONST_KW,

Script executed:

# Check for escaped character handling mentioned in the comments
rg -B 5 -A 10 "Handle escaped" crates/biome_html_parser/src/lexer/mod.rs

Repository: biomejs/biome


Repository: biomejs/biome
Exit code: 0

stdout:

                    self.advance(1);

                    match self.current_byte() {
                        Some(b'\n' | b'\r') => self.advance(1),

                        // Handle escaped `'` but only if this is a end quote string.
                        Some(b'\'') if quote == b'\'' => {
                            self.advance(1);
                        }

                        // Handle escaped `'` but only if this is a end quote string.
                        Some(b'"') if quote == b'"' => {
                            self.advance(1);
                        }

                        Some(b'u') => match (self.consume_unicode_escape(), state) {
                            (Ok(_), _) => {}
                            (Err(err), LexStringState::InString) => {
                                self.diagnostics.push(err);
                                state = LexStringState::InvalidEscapeSequence;
                            }

Script executed:

# Look for the consume_double_text_expression or similar to see how escapes are handled elsewhere
rg -A 30 "fn consume_double_text_expression" crates/biome_html_parser/src/lexer/mod.rs | head -50

Repository: biomejs/biome


Repository: biomejs/biome
Exit code: 0

stdout:

    fn consume_double_text_expression(&mut self, current: u8) -> HtmlSyntaxKind {
        match current {
            b'}' if self.at_closing_double_text_expression() => {
                self.consume_r_double_text_expression()
            }
            b'<' => self.consume_byte(T![<]),
            _ => {
                while let Some(current) = self.current_byte() {
                    match current {
                        b'}' if self.at_closing_double_text_expression() => break,
                        _ => {
                            self.advance(1);
                        }
                    }
                }
                HTML_LITERAL
            }
        }
    }

    /// Consumes tokens within a single text expression ('{...}') while tracking nested
    /// brackets until the matching closing bracket is found.
    fn consume_single_text_expression(&mut self) -> HtmlSyntaxKind {
        let mut brackets_stack = 0;
        while let Some(current) = self.current_byte() {
            match current {
                b'}' => {
                    if brackets_stack == 0 {
                        break;
                    } else {
                        brackets_stack -= 1;

Script executed:

# Check Vue spec or documentation on escaped quotes in directive arguments
find crates/biome_html_parser -name "*.rs" -type f -exec grep -l "directive.*argument\|VueDirectiveArgument" {} \; | xargs grep -B 5 -A 5 "escape" 2>/dev/null | head -40

Repository: biomejs/biome


Repository: biomejs/biome
Exit code: 0

stdout:

crates/biome_html_parser/src/lexer/mod.rs-                {
crates/biome_html_parser/src/lexer/mod.rs-                    self.consume_l_angle()
crates/biome_html_parser/src/lexer/mod.rs-                } else {
crates/biome_html_parser/src/lexer/mod.rs-                    self.push_diagnostic(
crates/biome_html_parser/src/lexer/mod.rs-                        ParseDiagnostic::new(
crates/biome_html_parser/src/lexer/mod.rs:                            "Unescaped `<` bracket character. Expected a tag or escaped character.",
crates/biome_html_parser/src/lexer/mod.rs-                            self.text_position()..self.text_position() + TextSize::from(1),
crates/biome_html_parser/src/lexer/mod.rs-                        )
crates/biome_html_parser/src/lexer/mod.rs:                        .with_hint("Replace this character with `&lt;` to escape it."),
crates/biome_html_parser/src/lexer/mod.rs-                    );
crates/biome_html_parser/src/lexer/mod.rs-                    self.consume_byte(HTML_LITERAL)
crates/biome_html_parser/src/lexer/mod.rs-                }
crates/biome_html_parser/src/lexer/mod.rs-            }
crates/biome_html_parser/src/lexer/mod.rs-            _ if is_at_start_identifier(current) => self
--
crates/biome_html_parser/src/lexer/mod.rs-        }
crates/biome_html_parser/src/lexer/mod.rs-
crates/biome_html_parser/src/lexer/mod.rs-        HTML_LITERAL
crates/biome_html_parser/src/lexer/mod.rs-    }
crates/biome_html_parser/src/lexer/mod.rs-
crates/biome_html_parser/src/lexer/mod.rs:    /// Consumes a quoted string literal token, handling escaped characters and unicode sequences.
crates/biome_html_parser/src/lexer/mod.rs:    /// Returns ERROR_TOKEN if the string is not properly terminated or contains invalid escapes.
crates/biome_html_parser/src/lexer/mod.rs-    fn consume_string_literal(&mut self, quote: u8) -> HtmlSyntaxKind {
crates/biome_html_parser/src/lexer/mod.rs-        self.assert_current_char_boundary();
crates/biome_html_parser/src/lexer/mod.rs-        let start = self.text_position();
crates/biome_html_parser/src/lexer/mod.rs-
crates/biome_html_parser/src/lexer/mod.rs-        self.advance(1); // Skip over the quote
--
crates/biome_html_parser/src/lexer/mod.rs-                    self.advance(1);
crates/biome_html_parser/src/lexer/mod.rs-
crates/biome_html_parser/src/lexer/mod.rs-                    match self.current_byte() {
crates/biome_html_parser/src/lexer/mod.rs-                        Some(b'\n' | b'\r') => self.advance(1),
crates/biome_html_parser/src/lexer/mod.rs-
crates/biome_html_parser/src/lexer/mod.rs:                        // Handle escaped `'` but only if this is a end quote string.
crates/biome_html_parser/src/lexer/mod.rs-                        Some(b'\'') if quote == b'\'' => {
crates/biome_html_parser/src/lexer/mod.rs-                            self.advance(1);
crates/biome_html_parser/src/lexer/mod.rs-                        }
crates/biome_html_parser/src/lexer/mod.rs-
crates/biome_html_parser/src/lexer/mod.rs:                        // Handle escaped `'` but only if this is a end quote string.
crates/biome_html_parser/src/lexer/mod.rs-                        Some(b'"') if quote == b'"' => {

Script executed:

# Check if there are any tests for Vue directive arguments with escaped characters
find crates/biome_html_parser/tests -name "*.vue" -type f -exec grep -H "\\\[" {} \;

Repository: biomejs/biome


Repository: biomejs/biome
Exit code: 0

stderr:

grep: Invalid regular expression
grep: Invalid regular expression
grep: Invalid regular expression
grep: Invalid regular expression
grep: Invalid regular expression
grep: Invalid regular expression
grep: Invalid regular expression
grep: Invalid regular expression
grep: Invalid regular expression
grep: Invalid regular expression
grep: Invalid regular expression
grep: Invalid regular expression
grep: Invalid regular expression
grep: Invalid regular expression
grep: Invalid regular expression
grep: Invalid regular expression
grep: Invalid regular expression
grep: Invalid regular expression
grep: Invalid regular expression
grep: Invalid regular expression
grep: Invalid regular expression
grep: Invalid regular expression
grep: Invalid regular expression
grep: Invalid regular expression
grep: Invalid regular expression
grep: Invalid regular expression
grep: Invalid regular expression
grep: Invalid regular expression
grep: Invalid regular expression
grep: Invalid regular expression
grep: Invalid regular expression
grep: Invalid regular expression
grep: Invalid regular expression
grep: Invalid regular expression

Script executed:

# Look at one of the dynamic argument tests more carefully
cat crates/biome_html_parser/tests/html_specs/ok/vue/v-bind-dynamic.vue.snap

Repository: biomejs/biome


Repository: biomejs/biome
Exit code: 0

stdout:

---
source: crates/biome_html_parser/tests/spec_test.rs
expression: snapshot
---
## Input

```vue
<template>
	<Foo :[key]="foo" />
</template>

```


## AST

```
HtmlRoot {
    bom_token: missing (optional),
    frontmatter: missing (optional),
    directive: missing (optional),
    html: HtmlElementList [
        HtmlElement {
            opening_element: HtmlOpeningElement {
                l_angle_token: L_ANGLE@0..1 "<" [] [],
                name: HtmlTagName {
                    value_token: HTML_LITERAL@1..9 "template" [] [],
                },
                attributes: HtmlAttributeList [],
                r_angle_token: R_ANGLE@9..10 ">" [] [],
            },
            children: HtmlElementList [
                HtmlSelfClosingElement {
                    l_angle_token: L_ANGLE@10..13 "<" [Newline("\n"), Whitespace("\t")] [],
                    name: HtmlTagName {
                        value_token: HTML_LITERAL@13..17 "Foo" [] [Whitespace(" ")],
                    },
                    attributes: HtmlAttributeList [
                        VueVBindShorthandDirective {
                            arg: VueDirectiveArgument {
                                colon_token: COLON@17..18 ":" [] [],
                                arg: VueDynamicArgument {
                                    l_brack_token: L_BRACKET@18..19 "[" [] [],
                                    name_token: HTML_LITERAL@19..22 "key" [] [],
                                    r_brack_token: R_BRACKET@22..23 "]" [] [],
                                },
                            },
                            modifiers: VueModifierList [],
                            initializer: HtmlAttributeInitializerClause {
                                eq_token: EQ@23..24 "=" [] [],
                                value: HtmlString {
                                    value_token: HTML_STRING_LITERAL@24..30 "\"foo\"" [] [Whitespace(" ")],
                                },
                            },
                        },
                    ],
                    slash_token: SLASH@30..31 "/" [] [],
                    r_angle_token: R_ANGLE@31..32 ">" [] [],
                },
            ],
            closing_element: HtmlClosingElement {
                l_angle_token: L_ANGLE@32..34 "<" [Newline("\n")] [],
                slash_token: SLASH@34..35 "/" [] [],
                name: HtmlTagName {
                    value_token: HTML_LITERAL@35..43 "template" [] [],
                },
                r_angle_token: R_ANGLE@43..44 ">" [] [],
            },
        },
    ],
    eof_token: EOF@44..45 "" [Newline("\n")] [],
}
```

## CST

```
0: HTML_ROOT@0..45
  0: (empty)
  1: (empty)
  2: (empty)
  3: HTML_ELEMENT_LIST@0..44
    0: HTML_ELEMENT@0..44
      0: HTML_OPENING_ELEMENT@0..10
        0: L_ANGLE@0..1 "<" [] []
        1: HTML_TAG_NAME@1..9
          0: HTML_LITERAL@1..9 "template" [] []
        2: HTML_ATTRIBUTE_LIST@9..9
        3: R_ANGLE@9..10 ">" [] []
      1: HTML_ELEMENT_LIST@10..32
        0: HTML_SELF_CLOSING_ELEMENT@10..32
          0: L_ANGLE@10..13 "<" [Newline("\n"), Whitespace("\t")] []
          1: HTML_TAG_NAME@13..17
            0: HTML_LITERAL@13..17 "Foo" [] [Whitespace(" ")]
          2: HTML_ATTRIBUTE_LIST@17..30
            0: VUE_V_BIND_SHORTHAND_DIRECTIVE@17..30
              0: VUE_DIRECTIVE_ARGUMENT@17..23
                0: COLON@17..18 ":" [] []
                1: VUE_DYNAMIC_ARGUMENT@18..23
                  0: L_BRACKET@18..19 "[" [] []
                  1: HTML_LITERAL@19..22 "key" [] []
                  2: R_BRACKET@22..23 "]" [] []
              1: VUE_MODIFIER_LIST@23..23
              2: HTML_ATTRIBUTE_INITIALIZER_CLAUSE@23..30
                0: EQ@23..24 "=" [] []
                1: HTML_STRING@24..30
                  0: HTML_STRING_LITERAL@24..30 "\"foo\"" [] [Whitespace(" ")]
          3: SLASH@30..31 "/" [] []
          4: R_ANGLE@31..32 ">" [] []
      2: HTML_CLOSING_ELEMENT@32..44
        0: L_ANGLE@32..34 "<" [Newline("\n")] []
        1: SLASH@34..35 "/" [] []
        2: HTML_TAG_NAME@35..43
          0: HTML_LITERAL@35..43 "template" [] []
        3: R_ANGLE@43..44 ">" [] []
  4: EOF@44..45 "" [Newline("\n")] []

```

Script executed:

# Check the full QuotesSeen test to see if escapes are tested
rg -A 50 "#\[cfg\(test\)\]" crates/biome_html_parser/src/lexer/mod.rs | grep -A 50 "mod quotes_seen"

Repository: biomejs/biome


Repository: biomejs/biome
Exit code: 0

stdout:

mod quotes_seen {
    use crate::lexer::QuotesSeen;

    fn track(source: &str, quotes_seen: &mut QuotesSeen) {
        for char in source.as_bytes() {
            quotes_seen.check_byte(*char);
        }
    }

    #[test]
    fn is_not_empty() {
        let source = r#"'"`"#;
        let mut quotes_seen = QuotesSeen::new();
        track(source, &mut quotes_seen);
        assert!(!quotes_seen.is_empty());

        let source = r#"`'""'"#;
        let mut quotes_seen = QuotesSeen::new();
        track(source, &mut quotes_seen);
        assert!(!quotes_seen.is_empty());
    }
    #[test]
    fn is_empty() {
        let source = r#" '"``"' "#;
        let mut quotes_seen = QuotesSeen::new();
        track(source, &mut quotes_seen);
        assert!(quotes_seen.is_empty());

        let source = r#"// Don't want to use any of this? Delete everything in this file, the `assets`, `components`, and `layouts` directories, and start fresh."#;
        let mut quotes_seen = QuotesSeen::new();
        track(source, &mut quotes_seen);
        assert!(quotes_seen.is_empty());
    }

    #[test]
    fn empty_inside_comments() {
        let source = r#"// Don't want to use any of this? Delete everything in this file, the `assets`, `components`, and `layouts` directories, and start fresh."#;
        let mut quotes_seen = QuotesSeen::new();
        track(source, &mut quotes_seen);
        assert!(quotes_seen.is_empty());
    }

    #[test]
    fn empty_with_comments_outside_comments_1() {
        let source = r#"// Don't want to use any of this? Delete everything in this file, the `assets`, `components`, and `layouts` directories, and start fresh.
const f = "something" "#;
        let mut quotes_seen = QuotesSeen::new();
        track(source, &mut quotes_seen);
        assert!(quotes_seen.is_empty());
    }

Handle escaped quotes in directive arguments.

QuotesSeen toggles quote state on every quote byte without escape awareness, so \" or \' inside a directive argument would prematurely end quote tracking and allow ] inside a string literal to close the argument early. Add escape-aware quote tracking to check_byte() or add a regression test documenting the intended behaviour.

🤖 Prompt for AI Agents
In `@crates/biome_html_parser/src/lexer/mod.rs` around lines 158 - 192, The quote
state in consume_token_vue_directive_argument is toggled blindly by
QuotesSeen::check_byte, so escaped quotes like \" or \' break tracking and let a
] inside a quoted string close the argument; update QuotesSeen::check_byte (or
add an escape-aware layer in consume_token_vue_directive_argument using
current_byte/peek_previous logic) to ignore quote bytes that are escaped (handle
backslash escaping, including escaped backslashes) while preserving normal
toggling otherwise, and add a regression test for
HtmlLexContext::VueDirectiveArgument that includes escaped quotes inside
brackets to ensure the parser does not prematurely close on ].

@dyc3 dyc3 merged commit 85f81f9 into main Jan 24, 2026
13 checks passed
@dyc3 dyc3 deleted the dyc3/parse-vue-dynamic-slots branch January 24, 2026 18:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-Parser Area: parser L-HTML Language: HTML and super languages

Projects

None yet

Development

Successfully merging this pull request may close these issues.

📝 Vue custom slot with template literal cause parse error

2 participants