Don't let quotes in Pug regex literals hide the end of an attribute list (#20572)

This is a follow-up to
https://github.com/tailwindlabs/tailwindcss/pull/20547.

That PR tracks strings inside Pug attribute lists, so that brackets in
values like `a(title="a (b")` are ignored. But a quote that isn't a
string delimiter, like the `"` in a regex such as
`input(pattern=/"/.source)`, opened a string that never closed. That
string hid the `)` of the attribute list. The list stayed open, so dots
in class shorthands on later lines were not turned into spaces, and
classes like `underline` in `span.underline.font-bold` were not
extracted.

```pug
input(pattern=/"/.source)
span.underline.font-bold km
```

## Solution

Only template literals can span lines in JavaScript. So when a `'` or
`"` string reaches the end of its line without a closing quote, it was
not a string. In that case, we go back to just after the quote and scan
the rest of the line again, with quotes as plain text. Each line is
scanned at most twice, so this stays linear.

Detecting regex literals directly needs a JavaScript tokenizer, because
a `/` can also be a division. This approach also covers other unpaired
quotes, like `// don't cache` in a `script.` block.

The regression is not released yet, so there is no changelog entry: the
existing entry of https://github.com/tailwindlabs/tailwindcss/pull/20547
covers it.

## Test plan

Added cases to `test_unbalanced_brackets_in_quoted_attribute_values` in
`pug.rs`. They fail without this change.

```
cargo test -p tailwindcss-oxide
```
This commit is contained in:
Robin Malfait authored and GitHub committed 2026-10-14 19:07:28 +02:00
1 parent 2cf0e87e9a
commit 2ce6652f1c
1 file changed
+38 -5
@@ -47,6 +47,13 @@ impl PreProcessor for Pug {
// ```
let mut quote: Option<u8> = None;
// The position of the opening quote of the string we are in.
let mut quote_start = 0;
// Whether quotes are text until the end of the line, because a string on this line didn't
// end. E.g.: the `"` in a regex like `/"/`.
let mut quotes_are_text = false;
while cursor.pos < len {
if let Some(q) = quote {
match cursor.curr() {
@@ -54,11 +61,15 @@ impl PreProcessor for Pug {
b'\\' => cursor.advance(),
c if c == q => quote = None,
// Only template literals can span multiple lines. Ending other strings at the
// end of the line ensures that an unpaired quote can't hide the rest of the
// file. E.g.: `// don't cache` in a `script.` block.
b'\n' if q != b'`' => {
// Only template literals can span multiple lines, so this quote didn't start
// a string. E.g.: `// don't cache` in a `script.` block, or a regex like
// `/"/`. Scan the rest of the line again, with quotes as text, so that an
// unpaired quote can't hide brackets like the `)` of the attribute list. The
// end of the input also ends the line.
c if q != b'`' && (c == b'\n' || cursor.pos + 1 == len) => {
quote = None;
quotes_are_text = true;
cursor.move_to(quote_start + 1);
continue;
}
@@ -94,8 +105,9 @@ impl PreProcessor for Pug {
in_block_expansion_whitespace = false;
}
b'"' | b'\'' | b'`' if in_attributes => {
b'"' | b'\'' | b'`' if in_attributes && !quotes_are_text => {
quote = Some(cursor.curr());
quote_start = cursor.pos;
}
// Whitespace ends the tag part of the line, unless it follows a `:` for block
@@ -188,6 +200,10 @@ impl PreProcessor for Pug {
in_block_expansion_whitespace = false;
}
if cursor.curr() == b'\n' {
quotes_are_text = false;
}
cursor.advance();
}
@@ -410,6 +426,23 @@ mod tests {
"#;
Pug::test_extract_contains(input, vec!["underline", "font-bold"]);
// A quote in a regex is not a string, so it does not hide the `)` of the attribute list
let input = r#"
input(pattern=/"/.source)
span.underline.font-bold km
"#;
Pug::test_extract_contains(input, vec!["underline", "font-bold"]);
let input = r#"
input(pattern=/"/.source title="x")
span.underline.font-bold km
"#;
Pug::test_extract_contains(input, vec!["underline", "font-bold"]);
// Also on the last line, without a trailing newline
let input = r#"div(data-pattern=/"/.source): span.underline.font-bold"#;
Pug::test_extract_contains(input, vec!["underline", "font-bold"]);
// A `'` or `"` string can't span lines, so an unpaired quote does not hide the next lines
let input = r#"
script.