Skip to content

Commit d38554d

Browse files
authored
Fix HAML extraction with embedded Ruby (#17846)
This PR fixes and improves the HAML extractor by ensuring that whenever we detect lines or blocks of Ruby code (`-`, `=` and `~`) characters at the beginning of the line, we treat them as Ruby code. Fixes: #17813 ## Test Plan 1. Existing tests pass 2. Changed 1 existing test which embedded Ruby syntax 3. Added a dedicated test to ensure the HAML file in the linked issue is parsed correctly Running this in the internal extractor tool you can see that the `w-[12px]`, `w-[16px]`, `h-[12px]`, and `h-[16px]` are properly extracted. Note: the `mr-12px` is also extracted, but not highlighted because this is not a valid Tailwind CSS class. <img width="1816" alt="image" src="https://github.com/user-attachments/assets/fc5929ca-bc71-47d2-b21b-7abeec86f54d" />
1 parent 473f024 commit d38554d

File tree

10 files changed

+682
-159
lines changed

10 files changed

+682
-159
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414
### Fixed
1515

1616
- Ensure negative arbitrary `scale` values generate negative values ([#17831](https://github.com/tailwindlabs/tailwindcss/pull/17831))
17+
- Fix HAML extraction with embedded Ruby ([#17846](https://github.com/tailwindlabs/tailwindcss/pull/17846))
1718

1819
## [4.1.5] - 2025-04-30
1920

Cargo.lock

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/oxide/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,4 @@ regex = "1.11.1"
2323
[dev-dependencies]
2424
tempfile = "3.13.0"
2525
pretty_assertions = "1.4.1"
26+
unicode-width = "0.2.0"

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

Lines changed: 260 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ use crate::extractor::bracket_stack::BracketStack;
33
use crate::extractor::machine::{Machine, MachineState};
44
use crate::extractor::pre_processors::pre_processor::PreProcessor;
55
use crate::extractor::variant_machine::VariantMachine;
6+
use crate::scanner::pre_process_input;
7+
use bstr::ByteVec;
68

79
#[derive(Debug, Default)]
810
pub struct Haml;
@@ -14,8 +16,153 @@ impl PreProcessor for Haml {
1416
let mut cursor = cursor::Cursor::new(content);
1517
let mut bracket_stack = BracketStack::default();
1618

19+
// Haml Comments: -#
20+
// https://haml.info/docs/yardoc/file.REFERENCE.html#ruby-evaluation
21+
//
22+
// > The hyphen followed immediately by the pound sign signifies a silent comment. Any text
23+
// > following this isn’t rendered in the resulting document at all.
24+
//
25+
// ```haml
26+
// %p foo
27+
// -# This is a comment
28+
// %p bar
29+
// ```
30+
//
31+
// > You can also nest text beneath a silent comment. None of this text will be rendered.
32+
//
33+
// ```haml
34+
// %p foo
35+
// -#
36+
// This won't be displayed
37+
// Nor will this
38+
// Nor will this.
39+
// %p bar
40+
// ```
41+
//
42+
// Ruby Evaluation
43+
// https://haml.info/docs/yardoc/file.REFERENCE.html#ruby-evaluation
44+
//
45+
// When any of the following characters are the first non-whitespace character on the line,
46+
// then the line is treated as Ruby code:
47+
//
48+
// - Inserting Ruby: =
49+
// https://haml.info/docs/yardoc/file.REFERENCE.html#inserting_ruby
50+
//
51+
// ```haml
52+
// %p
53+
// = ['hi', 'there', 'reader!'].join " "
54+
// = "yo"
55+
// ```
56+
//
57+
// - Running Ruby: -
58+
// https://haml.info/docs/yardoc/file.REFERENCE.html#running-ruby--
59+
//
60+
// ```haml
61+
// - foo = "hello"
62+
// - foo << " there"
63+
// - foo << " you!"
64+
// %p= foo
65+
// ```
66+
//
67+
// - Whitespace Preservation: ~
68+
// https://haml.info/docs/yardoc/file.REFERENCE.html#tilde
69+
//
70+
// > ~ works just like =, except that it runs Haml::Helpers.preserve on its input.
71+
//
72+
// ```haml
73+
// ~ "Foo\n<pre>Bar\nBaz</pre>"
74+
// ```
75+
//
76+
// Important note:
77+
//
78+
// > A line of Ruby code can be stretched over multiple lines as long as each line but the
79+
// > last ends with a comma.
80+
//
81+
// ```haml
82+
// - links = {:home => "/",
83+
// :docs => "/docs",
84+
// :about => "/about"}
85+
// ```
86+
//
87+
// Ruby Blocks:
88+
// https://haml.info/docs/yardoc/file.REFERENCE.html#ruby-blocks
89+
//
90+
// > Ruby blocks, like XHTML tags, don’t need to be explicitly closed in Haml. Rather,
91+
// > they’re automatically closed, based on indentation. A block begins whenever the
92+
// > indentation is increased after a Ruby evaluation command. It ends when the indentation
93+
// > decreases (as long as it’s not an else clause or something similar).
94+
//
95+
// ```haml
96+
// - (42...47).each do |i|
97+
// %p= i
98+
// %p See, I can count!
99+
// ```
100+
//
101+
let mut last_newline_position = 0;
102+
17103
while cursor.pos < len {
18104
match cursor.curr {
105+
// Escape the next character
106+
b'\\' => {
107+
cursor.advance_twice();
108+
continue;
109+
}
110+
111+
// Track the last newline position
112+
b'\n' => {
113+
last_newline_position = cursor.pos;
114+
cursor.advance();
115+
continue;
116+
}
117+
118+
// Skip HAML comments. `-#`
119+
b'-' if cursor.input[last_newline_position..cursor.pos]
120+
.iter()
121+
.all(u8::is_ascii_whitespace)
122+
&& matches!(cursor.next, b'#') =>
123+
{
124+
// Just consume the comment
125+
let updated_last_newline_position =
126+
self.skip_indented_block(&mut cursor, last_newline_position);
127+
128+
// Override the last known newline position
129+
last_newline_position = updated_last_newline_position;
130+
}
131+
132+
// Skip HTML comments. `/`
133+
b'/' if cursor.input[last_newline_position..cursor.pos]
134+
.iter()
135+
.all(u8::is_ascii_whitespace) =>
136+
{
137+
// Just consume the comment
138+
let updated_last_newline_position =
139+
self.skip_indented_block(&mut cursor, last_newline_position);
140+
141+
// Override the last known newline position
142+
last_newline_position = updated_last_newline_position;
143+
}
144+
145+
// Ruby evaluation
146+
b'-' | b'=' | b'~'
147+
if cursor.input[last_newline_position..cursor.pos]
148+
.iter()
149+
.all(u8::is_ascii_whitespace) =>
150+
{
151+
let mut start = cursor.pos;
152+
let end = self.skip_indented_block(&mut cursor, last_newline_position);
153+
154+
// Increment start with 1 character to skip the `=` or `-` character
155+
start += 1;
156+
157+
let ruby_code = &cursor.input[start..end];
158+
159+
// Override the last known newline position
160+
last_newline_position = end;
161+
162+
let replaced = pre_process_input(ruby_code, "rb");
163+
result.replace_range(start..end, replaced);
164+
}
165+
19166
// Only replace `.` with a space if it's not surrounded by numbers. E.g.:
20167
//
21168
// ```diff
@@ -89,6 +236,107 @@ impl PreProcessor for Haml {
89236
}
90237
}
91238

239+
impl Haml {
240+
fn skip_indented_block(
241+
&self,
242+
cursor: &mut cursor::Cursor,
243+
last_known_newline_position: usize,
244+
) -> usize {
245+
let len = cursor.input.len();
246+
247+
// Special case: if the first character of the block is `=`, then newlines are only allowed
248+
// _if_ the last character of the previous line is a comma `,`.
249+
//
250+
// https://haml.info/docs/yardoc/file.REFERENCE.html#inserting_ruby
251+
//
252+
// > A line of Ruby code can be stretched over multiple lines as long as each line but the
253+
// > last ends with a comma. For example:
254+
//
255+
// ```haml
256+
// = link_to_remote "Add to cart",
257+
// :url => { :action => "add", :id => product.id },
258+
// :update => { :success => "cart", :failure => "error" }
259+
// ```
260+
let evaluation_type = cursor.curr;
261+
262+
let block_indentation_level = cursor
263+
.pos
264+
.saturating_sub(last_known_newline_position)
265+
.saturating_sub(1); /* The newline itself */
266+
267+
let mut last_newline_position = last_known_newline_position;
268+
269+
// Consume until the end of the line first
270+
while cursor.pos < len && cursor.curr != b'\n' {
271+
cursor.advance();
272+
}
273+
274+
// Block is already done, aka just a line
275+
if evaluation_type == b'=' && cursor.prev != b',' {
276+
return cursor.pos;
277+
}
278+
279+
'outer: while cursor.pos < len {
280+
match cursor.curr {
281+
// Escape the next character
282+
b'\\' => {
283+
cursor.advance_twice();
284+
continue;
285+
}
286+
287+
// Track the last newline position
288+
b'\n' => {
289+
last_newline_position = cursor.pos;
290+
291+
// We are done with this block
292+
if evaluation_type == b'=' && cursor.prev != b',' {
293+
break;
294+
}
295+
296+
cursor.advance();
297+
continue;
298+
}
299+
300+
// Skip whitespace and compute the indentation level
301+
x if x.is_ascii_whitespace() => {
302+
// Find first non-whitespace character
303+
while cursor.pos < len && cursor.curr.is_ascii_whitespace() {
304+
if cursor.curr == b'\n' {
305+
last_newline_position = cursor.pos;
306+
307+
if evaluation_type == b'=' && cursor.prev != b',' {
308+
// We are done with this block
309+
break 'outer;
310+
}
311+
}
312+
313+
cursor.advance();
314+
}
315+
316+
let indentation = cursor
317+
.pos
318+
.saturating_sub(last_newline_position)
319+
.saturating_sub(1); /* The newline itself */
320+
if indentation < block_indentation_level {
321+
// We are done with this block
322+
break;
323+
}
324+
}
325+
326+
// Not whitespace, end of block
327+
_ => break,
328+
};
329+
330+
cursor.advance();
331+
}
332+
333+
// Move the cursor to the last newline position
334+
cursor.move_to(last_newline_position);
335+
336+
last_newline_position
337+
}
338+
}
339+
92340
#[cfg(test)]
93341
mod tests {
94342
use super::Haml;
@@ -173,10 +421,18 @@ mod tests {
173421

174422
// https://github.com/tailwindlabs/tailwindcss/pull/17051#issuecomment-2711181352
175423
#[test]
176-
fn test_haml_full_file() {
177-
let processed = Haml.process(include_bytes!("./test-fixtures/haml/src-1.haml"));
178-
let actual = std::str::from_utf8(&processed).unwrap();
179-
let expected = include_str!("./test-fixtures/haml/dst-1.haml");
424+
fn test_haml_full_file_17051() {
425+
let actual = Haml::extract_annotated(include_bytes!("./test-fixtures/haml/src-17051.haml"));
426+
let expected = include_str!("./test-fixtures/haml/dst-17051.haml");
427+
428+
assert_eq!(actual, expected);
429+
}
430+
431+
// https://github.com/tailwindlabs/tailwindcss/issues/17813
432+
#[test]
433+
fn test_haml_full_file_17813() {
434+
let actual = Haml::extract_annotated(include_bytes!("./test-fixtures/haml/src-17813.haml"));
435+
let expected = include_str!("./test-fixtures/haml/dst-17813.haml");
180436

181437
assert_eq!(actual, expected);
182438
}

0 commit comments

Comments
 (0)