Ensure strings in Pug and Slim templates are handled correctly (#17000)

This PR fixes an issue where strings in the Pug and Slim pre-processor
were handled using the `string_machine`. However, the `string_machine`
is not for strings inside of Tailwind CSS classes which means that
whitespace is invalid.

This means that parts of the code that _are_ inside strings will not be
inside strings and parts of the code that are not inside strings will be
part of a potential string. This is a bit confusing to wrap your head
around, but here is a visual representation of the problem:

```
.join(' ')
        ^  3. start of new string, which means that the `)` _could_ be part of a string if a new `'` occurs later.
       ^   2. whitespace is not allowed, stop string
      ^    1. start of string
```

Fixes: #16998

# Test plan

1. Added new test
2. Existing tests still pass
3. Added a simple test helper to make sure that we can extract the
correct candidates _after_ pre-processing
This commit is contained in:
Robin Malfait 2025-03-06 17:24:00 +01:00 committed by GitHub
parent 3d0606b82d
commit 85c6e04f44
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 103 additions and 8 deletions

View File

@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Ensure utilities are sorted based on their actual property order ([#16995](https://github.com/tailwindlabs/tailwindcss/pull/16995))
- Ensure strings in Pug and Slim templates are handled correctly ([#17000](https://github.com/tailwindlabs/tailwindcss/pull/17000))
## [4.0.11] - 2025-03-06

View File

@ -25,4 +25,38 @@ pub trait PreProcessor: Sized + Default {
assert_eq!(actual, expected);
}
#[cfg(test)]
fn test_extract_contains(input: &str, items: Vec<&str>) {
use crate::extractor::{Extracted, Extractor};
let input = input.as_bytes();
let processor = Self::default();
let transformed = processor.process(input);
let extracted = Extractor::new(&transformed).extract();
// Extract all candidates and css variables.
let candidates = extracted
.iter()
.filter_map(|x| match x {
Extracted::Candidate(bytes) => std::str::from_utf8(bytes).ok(),
Extracted::CssVariable(bytes) => std::str::from_utf8(bytes).ok(),
})
.collect::<Vec<_>>();
// Ensure all items are present in the candidates.
let mut missing = vec![];
for item in &items {
if !candidates.contains(item) {
missing.push(item);
}
}
if !missing.is_empty() {
dbg!(&candidates, &missing);
panic!("Missing some items");
}
}
}

View File

@ -1,8 +1,6 @@
use crate::cursor;
use crate::extractor::bracket_stack::BracketStack;
use crate::extractor::machine::Machine;
use crate::extractor::pre_processors::pre_processor::PreProcessor;
use crate::StringMachine;
#[derive(Debug, Default)]
pub struct Pug;
@ -12,14 +10,29 @@ impl PreProcessor for Pug {
let len = content.len();
let mut result = content.to_vec();
let mut cursor = cursor::Cursor::new(content);
let mut string_machine = StringMachine;
let mut bracket_stack = BracketStack::default();
while cursor.pos < len {
match cursor.curr {
// Consume strings as-is
b'\'' | b'"' => {
string_machine.next(&mut cursor);
let len = cursor.input.len();
let end_char = cursor.curr;
cursor.advance();
while cursor.pos < len {
match cursor.curr {
// Escaped character, skip ahead to the next character
b'\\' => cursor.advance_twice(),
// End of the string
b'\'' | b'"' if cursor.curr == end_char => break,
// Everything else is valid
_ => cursor.advance(),
};
}
}
// Replace dots with spaces

View File

@ -1,8 +1,6 @@
use crate::cursor;
use crate::extractor::bracket_stack::BracketStack;
use crate::extractor::machine::Machine;
use crate::extractor::pre_processors::pre_processor::PreProcessor;
use crate::StringMachine;
#[derive(Debug, Default)]
pub struct Slim;
@ -12,14 +10,29 @@ impl PreProcessor for Slim {
let len = content.len();
let mut result = content.to_vec();
let mut cursor = cursor::Cursor::new(content);
let mut string_machine = StringMachine;
let mut bracket_stack = BracketStack::default();
while cursor.pos < len {
match cursor.curr {
// Consume strings as-is
b'\'' | b'"' => {
string_machine.next(&mut cursor);
let len = cursor.input.len();
let end_char = cursor.curr;
cursor.advance();
while cursor.pos < len {
match cursor.curr {
// Escaped character, skip ahead to the next character
b'\\' => cursor.advance_twice(),
// End of the string
b'\'' | b'"' if cursor.curr == end_char => break,
// Everything else is valid
_ => cursor.advance(),
};
}
}
// Replace dots with spaces
@ -103,8 +116,42 @@ mod tests {
),
// Nested brackets, with "invalid" syntax but valid due to nesting
("content-['50[]']", "content-['50[]']"),
// Escaped string
("content-['a\'b\'c\'']", "content-['a\'b\'c\'']"),
] {
Slim::test(input, expected);
}
}
#[test]
fn test_nested_slim_syntax() {
let input = r#"
.text-black[
data-controller= ['foo', ('bar' if rand.positive?)].join(' ')
]
.bg-green-300
| BLACK on GREEN - OK
.bg-red-300[
data-foo= 42
]
| Should be BLACK on RED - FAIL
"#;
let expected = r#"
text-black
data-controller= ['foo', ('bar' if rand.positive?)].join(' ')
]
bg-green-300
| BLACK on GREEN - OK
bg-red-300
data-foo= 42
]
| Should be BLACK on RED - FAIL
"#;
Slim::test(input, expected);
Slim::test_extract_contains(input, vec!["text-black", "bg-green-300", "bg-red-300"]);
}
}