From 2b497e14563f20d7a94a229229b5e8b282264b17 Mon Sep 17 00:00:00 2001 From: Brian Foshee Date: Tue, 7 Jul 2026 17:59:00 -0400 Subject: [PATCH 1/3] Skip class strings containing template syntax MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since fc7d701 the default finder regex captures class attributes that embed template-language tags (previously the restrictive character class made these attributes non-matches). split_class_tokens then treats whitespace inside the embedded code as a class boundary, so sorting moves tokens across quote and tag boundaries — corrupting ERB ternaries into invalid Ruby or silently changing which classes a branch applies (#140), and reformatting Ruby string interpolation (#124). Leave a matched class string untouched when it contains a template opening delimiter (<%, Arc { ) } +/// Opening delimiters of template-language tags (ERB/EJS `<% %>`, PHP ``, +/// Handlebars/Jinja/Liquid `{{ }}` and `{% %}`, Ruby string interpolation `#{ }`). +/// Class strings containing embedded code can't be safely split on whitespace — +/// sorting them would move tokens across code boundaries and corrupt the template. +const TEMPLATE_DELIMITERS: [&str; 5] = ["<%", " bool { + TEMPLATE_DELIMITERS + .iter() + .any(|delimiter| class_string.contains(delimiter)) +} + fn split_class_tokens(class_string: &str) -> Vec<&str> { let mut tokens = Vec::new(); let mut start = None; @@ -521,6 +538,36 @@ mod tests { r#"

"# ; "makes no change to elements without class string" )] + #[test_case( + &RUSTYWIND_DEFAULT, + r#"
"#, + r#"
"# + ; "makes no change to class string that is a single erb ternary" + )] + #[test_case( + &RUSTYWIND_DEFAULT, + r#" rounded-md">"#, + r#" rounded-md">"# + ; "makes no change to class string with erb tag between static classes" + )] + #[test_case( + &RUSTYWIND_DEFAULT, + r#""#, + r#""# + ; "makes no change to class string with static classes before an erb ternary" + )] + #[test_case( + &RUSTYWIND_DEFAULT, + r##"
"##, + r##"
"## + ; "makes no change to class string with ruby string interpolation" + )] + #[test_case( + &RUSTYWIND_DEFAULT, + r#"
"#, + r#"
"# + ; "makes no change to class string with mustache style interpolation" + )] fn test_sort_file_contents(app: &RustyWind, input: &str, output: &str) { assert_eq!(app.sort_file_contents(input), output); } From fd6a0ec34eb7f99ec7dd9f03fc14203e17ce7da2 Mon Sep 17 00:00:00 2001 From: Brian Foshee Date: Tue, 7 Jul 2026 18:15:19 -0400 Subject: [PATCH 2/3] Document template-syntax guard scope Note on sort_classes that it expects plain class names (the template-syntax guard lives in sort_file_contents), and add a TODO for tokenizing template tags as opaque units so surrounding static classes can still be sorted. --- rustywind-core/src/app.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/rustywind-core/src/app.rs b/rustywind-core/src/app.rs index 07d7f52..c447d89 100644 --- a/rustywind-core/src/app.rs +++ b/rustywind-core/src/app.rs @@ -95,6 +95,11 @@ impl RustyWind { /// Given a [&str] of whitespace-separated classes, returns a [String] of sorted classes. /// Does not preserve whitespace. + /// + /// Expects plain class names only: the template-syntax guard lives in + /// [`Self::sort_file_contents`], so passing a string containing embedded + /// template code (e.g. `<%= ... %>`) will split it on whitespace like any + /// other classes. pub fn sort_classes(&self, class_string: &str) -> String { let extracted_classes = self.unwrap_wrapped_classes(class_string); @@ -291,6 +296,8 @@ fn prefixed_pattern_sorter(tailwind_prefix: &str) -> Arc { /// Handlebars/Jinja/Liquid `{{ }}` and `{% %}`, Ruby string interpolation `#{ }`). /// Class strings containing embedded code can't be safely split on whitespace — /// sorting them would move tokens across code boundaries and corrupt the template. +// TODO: instead of skipping the whole class string, tokenize template tags as +// opaque units so the static classes around them can still be sorted. const TEMPLATE_DELIMITERS: [&str; 5] = ["<%", " bool { From b3acfafd7d19facb6916cf5c423e6170b3b26f6f Mon Sep 17 00:00:00 2001 From: Brian Foshee Date: Tue, 7 Jul 2026 18:29:59 -0400 Subject: [PATCH 3/3] Expand template delimiters to closers and JS interpolation Add closing delimiters (%>, ?>, }}, %}) so a regex match split by a quote inside template code is still skipped when the fragment only contains the tail of a tag, and add ${ for JS template-literal interpolation (e.g. Lit-style class="p-4 ${cond ? 'a b' : 'c'}"), which the default regex captures and would corrupt the same way as ERB. --- rustywind-core/src/app.rs | 45 ++++++++++++++++++++++++++++++++++----- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/rustywind-core/src/app.rs b/rustywind-core/src/app.rs index c447d89..9e94363 100644 --- a/rustywind-core/src/app.rs +++ b/rustywind-core/src/app.rs @@ -292,13 +292,17 @@ fn prefixed_pattern_sorter(tailwind_prefix: &str) -> Arc { ) } -/// Opening delimiters of template-language tags (ERB/EJS `<% %>`, PHP ``, -/// Handlebars/Jinja/Liquid `{{ }}` and `{% %}`, Ruby string interpolation `#{ }`). -/// Class strings containing embedded code can't be safely split on whitespace — -/// sorting them would move tokens across code boundaries and corrupt the template. +/// Delimiters of template-language tags (ERB/EJS `<% %>`, PHP ``, +/// Handlebars/Jinja/Liquid `{{ }}` and `{% %}`, Ruby string interpolation `#{ }`, +/// JS template literals `${ }`). Class strings containing embedded code can't be +/// safely split on whitespace — sorting them would move tokens across code +/// boundaries and corrupt the template. Closing delimiters are included because +/// a quote inside the template code can split the regex match, leaving a +/// fragment that contains only the tail of a tag. // TODO: instead of skipping the whole class string, tokenize template tags as // opaque units so the static classes around them can still be sorted. -const TEMPLATE_DELIMITERS: [&str; 5] = ["<%", "", "", "{{", "}}", "{%", "%}", "#{", "${"]; fn contains_template_syntax(class_string: &str) -> bool { TEMPLATE_DELIMITERS @@ -306,6 +310,31 @@ fn contains_template_syntax(class_string: &str) -> bool { .any(|delimiter| class_string.contains(delimiter)) } +#[cfg(test)] +mod template_syntax_tests { + use super::contains_template_syntax; + + #[test] + fn detects_closing_delimiter_fragments() { + // a quote inside template code can split the regex match, leaving a + // fragment with only the tail of a tag + assert!(contains_template_syntax("a' %> flex p-4")); + assert!(contains_template_syntax("arg'}} p-4")); + assert!(contains_template_syntax("x %} m-4")); + assert!(contains_template_syntax("y ?> m-4")); + } + + #[test] + fn ignores_plain_class_strings() { + assert!(!contains_template_syntax( + "flex p-4 max-w-[min(100%, 500px)]" + )); + assert!(!contains_template_syntax( + "[&>*]:p-4 [@supports(display:grid)]:grid" + )); + } +} + fn split_class_tokens(class_string: &str) -> Vec<&str> { let mut tokens = Vec::new(); let mut start = None; @@ -575,6 +604,12 @@ mod tests { r#"
"# ; "makes no change to class string with mustache style interpolation" )] + #[test_case( + &RUSTYWIND_DEFAULT, + r#"html`
`"#, + r#"html`
`"# + ; "makes no change to class string with js template literal interpolation" + )] fn test_sort_file_contents(app: &RustyWind, input: &str, output: &str) { assert_eq!(app.sort_file_contents(input), output); }