Skip to content

Commit b8a5f85

Browse files
lazergRobinMalfait
andauthored
Detect classes in Ruby percent literals using angle brackets or custom delimiters (#20387)
## Summary Fixes #20386. Ruby lets you close a `%w`/`%W` literal with any non-alphanumeric character, but the pre-processor only recognized `[`, `(`, `{`, `#` and a space. So `%w<bg-green-400>` and `%w|bg-blue-400|` were skipped and the classes inside them never made it into the output. The boundary lookup now maps `<` to `>` alongside the other paired delimiters, and falls back to treating any other non-alphanumeric, non-whitespace character as its own closing delimiter. ## Test plan Added pre-processor and extraction cases for `%w<…>`, `%w|…|`, `%w:…:` and `%w!…!` in `crates/oxide/src/extractor/pre_processors/ruby.rs`. They fail on `main` and pass with this change. ``` cargo test -p tailwindcss-oxide ``` --------- Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
1 parent 3524b45 commit b8a5f85

5 files changed

Lines changed: 345 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2121
- Canonicalization: convert arbitrary breakpoint and container query variants to named equivalents (e.g. `max-[64rem]``max-lg`) ([#20380](https://github.com/tailwindlabs/tailwindcss/pull/20380))
2222
- Prevent `@tailwindcss/vite` from crashing on every edit under Vite's experimental `bundledDev` mode ([#20379](https://github.com/tailwindlabs/tailwindcss/pull/20379))
2323
- Ensure `@tailwindcss/oxide` falls back to WASM on platforms without native bindings ([#20383](https://github.com/tailwindlabs/tailwindcss/pull/20383))
24+
- Detect classes in Ruby percent literals using angle brackets or custom delimiters (e.g. `%w<flex>`, `%w|flex|`), including in Slim and Haml templates ([#20387](https://github.com/tailwindlabs/tailwindcss/pull/20387))
2425

2526
## [4.3.3] - 2026-07-16
2627

crates/oxide/src/extractor/bracket_stack.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ impl BracketStack {
2525
b'(' => b')',
2626
b'[' => b']',
2727
b'{' => b'}',
28+
b'<' => b'>',
2829
_ => std::hint::unreachable_unchecked(),
2930
};
3031
}

crates/oxide/src/extractor/pre_processors/haml.rs

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,81 @@ impl PreProcessor for Haml {
203203
}
204204
}
205205

206+
// Handle Ruby syntax with `%w[]` arrays embedded in Haml attribute hashes. E.g.:
207+
//
208+
// ```haml
209+
// %div{class: %w[bg-blue-500 w-10 h-10]}
210+
// ```
211+
//
212+
// A `%` that follows a value is not a percent literal. E.g.: the `50%w` in
213+
// `hit rate 50%w.`
214+
b'%' if matches!(cursor.next(), b'w' | b'W')
215+
&& !cursor.prev().is_ascii_alphanumeric()
216+
&& !matches!(cursor.prev(), b'_' | b')' | b']' | b'}') =>
217+
{
218+
// Boundary characters
219+
let (open, close) = match cursor.input.get(cursor.pos + 2) {
220+
Some(b'[') => (b'[', b']'),
221+
Some(b'(') => (b'(', b')'),
222+
Some(b'{') => (b'{', b'}'),
223+
Some(b'<') => (b'<', b'>'),
224+
225+
// Any other ASCII punctuation can be used as a custom delimiter
226+
Some(&c) if c.is_ascii_punctuation() => (c, c),
227+
228+
// Everything else is not a valid delimiter
229+
_ => {
230+
cursor.advance();
231+
continue;
232+
}
233+
};
234+
235+
result[cursor.pos] = b' '; // Replace `%`
236+
cursor.advance();
237+
result[cursor.pos] = b' '; // Replace `w`
238+
cursor.advance();
239+
result[cursor.pos] = b' '; // Replace the opening delimiter
240+
cursor.advance();
241+
242+
// Paired delimiters can be nested as long as they are balanced. E.g.:
243+
// `%w[foo[bar]baz]` produces a flat array.
244+
let mut depth = 1_usize;
245+
246+
while cursor.pos < len {
247+
match cursor.curr() {
248+
// Skip escaped characters, unless the backslash is the delimiter
249+
// itself
250+
b'\\' if close != b'\\' => {
251+
// Use backslash to embed spaces in the strings.
252+
if cursor.next() == b' ' {
253+
result[cursor.pos] = b' ';
254+
}
255+
256+
cursor.advance();
257+
}
258+
259+
// Start of a nested delimiter pair
260+
c if c == open && open != close => depth += 1,
261+
262+
// Closing delimiter
263+
c if c == close => {
264+
depth -= 1;
265+
266+
// End of the literal, replace the closing delimiter with a space
267+
if depth == 0 {
268+
result[cursor.pos] = b' ';
269+
break;
270+
}
271+
}
272+
273+
// Everything else is valid content
274+
_ => {}
275+
}
276+
277+
cursor.advance();
278+
}
279+
}
280+
206281
// Replace following characters with spaces if they are not inside of brackets
207282
b'#' | b'=' if bracket_stack.is_empty() => {
208283
result[cursor.pos] = b' ';
@@ -424,6 +499,81 @@ mod tests {
424499
);
425500
}
426501

502+
// https://github.com/tailwindlabs/tailwindcss/issues/20386
503+
#[test]
504+
fn test_embedded_ruby_percent_w_delimiters() {
505+
for (input, expected) in [
506+
// %w[…] in an attribute hash
507+
(
508+
"%div{class: %w[flex px-2.5]}",
509+
"%div class: flex px-2.5 ",
510+
),
511+
// %w<…>
512+
(
513+
"%div{class: %w<flex px-2.5>}",
514+
"%div class: flex px-2.5 ",
515+
),
516+
// Nested `<…>` does not end the literal
517+
(
518+
"%div{class: %w<flex <nested> px-2.5>}",
519+
"%div class: flex <nested> px-2.5 ",
520+
),
521+
// Custom delimiters
522+
(
523+
"%div{class: %w|flex px-2.5|}",
524+
"%div class: flex px-2.5 ",
525+
),
526+
(
527+
"%div{class: %W!flex px-2.5!}",
528+
"%div class: flex px-2.5 ",
529+
),
530+
(
531+
"%div{class: %w#text-sm leading-6#}",
532+
"%div class: text-sm leading-6 ",
533+
),
534+
(
535+
"%div{class: %w=italic tracking-wide=}",
536+
"%div class: italic tracking-wide ",
537+
),
538+
// Nested paired delimiters stay balanced inside the literal
539+
(
540+
"%div{class: %w[content-['[hello]'] p-4]}",
541+
"%div class: content-['[hello]'] p-4 ",
542+
),
543+
// Escaped spaces embed a space in a single array element
544+
(
545+
r#"%div{class: %w[foo\ bar baz-1]}"#,
546+
r#"%div class: foo bar baz-1 "#,
547+
),
548+
// A `%` that follows a value is not a percent literal
549+
("%p hit rate 50%w.", "%p hit rate 50%w "),
550+
] {
551+
Haml::test(input, expected);
552+
}
553+
554+
let input = r#"
555+
%div{class: %w[bg-blue-500 w-10 h-10]}
556+
%div{class: %w<flex px-2.5>}
557+
%div{class: %w|underline font-bold|}
558+
- classes = %w<mt-4 grid>
559+
"#;
560+
561+
Haml::test_extract_contains(
562+
input,
563+
vec![
564+
"bg-blue-500",
565+
"w-10",
566+
"h-10",
567+
"flex",
568+
"px-2.5",
569+
"underline",
570+
"font-bold",
571+
"mt-4",
572+
"grid",
573+
],
574+
);
575+
}
576+
427577
// https://github.com/tailwindlabs/tailwindcss/pull/17051#issuecomment-2711181352
428578
#[test]
429579
fn test_haml_full_file_17051() {

crates/oxide/src/extractor/pre_processors/ruby.rs

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -158,15 +158,29 @@ impl PreProcessor for Ruby {
158158
continue;
159159
}
160160

161+
// A `%` that follows a value is a modulo operation, not a percent literal. E.g.: the
162+
// `50%w` in `hit rate 50%w.`
163+
if cursor.prev().is_ascii_alphanumeric()
164+
|| matches!(cursor.prev(), b'_' | b')' | b']' | b'}')
165+
{
166+
cursor.advance();
167+
continue;
168+
}
169+
161170
cursor.advance_twice();
162171

163172
// Boundary character
164173
let boundary = match cursor.curr() {
165174
b'[' => b']',
166175
b'(' => b')',
167176
b'{' => b'}',
168-
b'#' => b'#',
177+
b'<' => b'>',
169178
b' ' => b'\n',
179+
180+
// Any other ASCII punctuation can be used as a custom delimiter
181+
c if c.is_ascii_punctuation() => c,
182+
183+
// Everything else is not a valid delimiter
170184
_ => {
171185
cursor.advance();
172186
continue;
@@ -183,8 +197,8 @@ impl PreProcessor for Ruby {
183197

184198
while cursor.pos < len {
185199
match cursor.curr() {
186-
// Skip escaped characters
187-
b'\\' => {
200+
// Skip escaped characters, unless the backslash is the delimiter itself
201+
b'\\' if boundary != b'\\' => {
188202
// Use backslash to embed spaces in the strings.
189203
if cursor.next() == b' ' {
190204
result[cursor.pos] = b' ';
@@ -198,6 +212,11 @@ impl PreProcessor for Ruby {
198212
bracket_stack.push(cursor.curr());
199213
}
200214

215+
// Start of a nested `<…>`, which Ruby allows inside a `%w<…>` literal
216+
b'<' if boundary == b'>' => {
217+
bracket_stack.push(cursor.curr());
218+
}
219+
201220
// End of a nested bracket
202221
b']' | b')' | b'}' if !bracket_stack.is_empty() => {
203222
if !bracket_stack.pop(cursor.curr()) {
@@ -206,6 +225,14 @@ impl PreProcessor for Ruby {
206225
}
207226
}
208227

228+
// End of a nested `<…>`
229+
b'>' if boundary == b'>' && !bracket_stack.is_empty() => {
230+
if !bracket_stack.pop(cursor.curr()) {
231+
// Unbalanced
232+
cursor.advance();
233+
}
234+
}
235+
209236
// End of the pattern, replace the boundary character with a space
210237
_ if cursor.curr() == boundary => {
211238
if boundary != b'\n' {
@@ -253,6 +280,24 @@ mod tests {
253280
"%w(flex data-[state=pending]:bg-(--my-color) flex-col)",
254281
"%w flex data-[state=pending]:bg-(--my-color) flex-col ",
255282
),
283+
// %w<…>
284+
("%w<flex px-2.5>", "%w flex px-2.5 "),
285+
(
286+
"%w<flex data-[state=pending]:bg-(--my-color) flex-col>",
287+
"%w flex data-[state=pending]:bg-(--my-color) flex-col ",
288+
),
289+
// Nested `<…>` does not end the literal
290+
("%w<flex <nested> px-2.5>", "%w flex <nested> px-2.5 "),
291+
// %w|…|, %w:…:, %w!…!
292+
("%w|flex px-2.5|", "%w flex px-2.5 "),
293+
("%w:flex px-2.5:", "%w flex px-2.5 "),
294+
("%w!flex px-2.5!", "%w flex px-2.5 "),
295+
(r#"%w\flex px-2.5\"#, r#"%w flex px-2.5 "#),
296+
// A `%` that follows a value is a modulo operation, not a percent literal
297+
(
298+
"hit rate 50%w.\n%w[flex px-2.5]",
299+
"hit rate 50%w.\n%w flex px-2.5 ",
300+
),
256301

257302
// %w …\n
258303
("%w flex px-2.5\n", "%w flex px-2.5\n"),
@@ -330,6 +375,20 @@ mod tests {
330375
"%w(flex data-[state=pending]:bg-(--my-color) flex-col)",
331376
vec!["flex", "data-[state=pending]:bg-(--my-color)", "flex-col"],
332377
),
378+
// %w<…>
379+
("%w<flex px-2.5>", vec!["flex", "px-2.5"]),
380+
("%w<px-2.5 flex>", vec!["flex", "px-2.5"]),
381+
("%w<2xl:flex>", vec!["2xl:flex"]),
382+
(
383+
"%w<flex data-[state=pending]:bg-(--my-color) flex-col>",
384+
vec!["flex", "data-[state=pending]:bg-(--my-color)", "flex-col"],
385+
),
386+
// Nested `<…>` does not end the literal
387+
("%w<flex <nested> px-2.5>", vec!["flex", "px-2.5"]),
388+
// %w|…|, %w:…:, %w!…!
389+
("%w|flex px-2.5|", vec!["flex", "px-2.5"]),
390+
("%w:flex px-2.5:", vec!["flex", "px-2.5"]),
391+
("%w!flex px-2.5!", vec!["flex", "px-2.5"]),
333392

334393
(
335394
"# test\n# test\n# {ActiveRecord::Base#save!}[rdoc-ref:Persistence#save!]\n%w[flex px-2.5]",

0 commit comments

Comments
 (0)