From b9edd5dd8a9e157645e7f3ee98665811c4b5fd99 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 2 Jul 2025 11:31:28 -0400 Subject: [PATCH 1/6] Add Elixir pre-processor --- .../src/extractor/pre_processors/elixir.rs | 150 ++++++++++++++++++ .../oxide/src/extractor/pre_processors/mod.rs | 2 + crates/oxide/src/scanner/mod.rs | 1 + 3 files changed, 153 insertions(+) create mode 100644 crates/oxide/src/extractor/pre_processors/elixir.rs diff --git a/crates/oxide/src/extractor/pre_processors/elixir.rs b/crates/oxide/src/extractor/pre_processors/elixir.rs new file mode 100644 index 000000000000..aae4e4c4285a --- /dev/null +++ b/crates/oxide/src/extractor/pre_processors/elixir.rs @@ -0,0 +1,150 @@ +use std::str::from_utf8; + +use crate::cursor; +use crate::extractor::bracket_stack::BracketStack; +use crate::extractor::pre_processors::pre_processor::PreProcessor; + +#[derive(Debug, Default)] +pub struct Elixir; + +impl PreProcessor for Elixir { + fn process(&self, content: &[u8]) -> Vec { + let mut cursor = cursor::Cursor::new(&content); + let mut result = content.to_vec(); + let mut bracket_stack = BracketStack::default(); + + while cursor.pos < content.len() { + // Look for a sigil marker + if cursor.curr != b'~' { + cursor.advance(); + continue; + } + + // Scan charlists, strings, and wordlists + if !matches!(cursor.next, b'c' | b'C' | b's' | b'S' | b'w' | b'W') { + cursor.advance(); + continue; + } + + cursor.advance_twice(); + + // Match the opening for a sigil + if !matches!(cursor.curr, b'(' | b'[' | b'{') { + continue; + } + + // Replace the opening bracket with a space + result[cursor.pos] = b' '; + + // Scan until we find a balanced closing one and replace it too + bracket_stack.push(cursor.curr); + + while cursor.pos < content.len() { + cursor.advance(); + + match cursor.curr { + // Escaped character, skip ahead to the next character + b'\\' => cursor.advance_twice(), + b'(' | b'[' | b'{' => { + bracket_stack.push(cursor.curr); + } + b')' | b']' | b'}' if !bracket_stack.is_empty() => { + bracket_stack.pop(cursor.curr); + + if bracket_stack.is_empty() { + // Replace the closing bracket with a space + result[cursor.pos] = b' '; + break; + } + } + _ => {} + } + } + } + + result + } +} + +#[cfg(test)] +mod tests { + use super::Elixir; + use crate::extractor::pre_processors::pre_processor::PreProcessor; + + #[test] + fn test_elixir_pre_processor() { + for (input, expected) in [ + // Simple sigils + ("~W(flex underline)", "~W flex underline "), + ("~W[flex underline]", "~W flex underline "), + ("~W{flex underline}", "~W flex underline "), + // Sigils with nested brackets + ( + "~W(text-(--my-color) bg-(--my-color))", + "~W text-(--my-color) bg-(--my-color) ", + ), + ("~W[text-[red] bg-[red]]", "~W text-[red] bg-[red] "), + // Word sigils with modifiers + ("~W(flex underline)a", "~W flex underline a"), + ("~W(flex underline)c", "~W flex underline c"), + ("~W(flex underline)s", "~W flex underline s"), + // Other sigil types + ("~w(flex underline)", "~w flex underline "), + ("~c(flex underline)", "~c flex underline "), + ("~C(flex underline)", "~C flex underline "), + ("~s(flex underline)", "~s flex underline "), + ("~S(flex underline)", "~S flex underline "), + ] { + Elixir::test(input, expected); + } + } + + #[test] + fn test_extract_candidates() { + let input = r#" + ~W(c1 c2) + ~W[c3 c4] + ~W{c5 c6} + ~W(text-(--c7) bg-(--c8)) + ~W[text-[c9] bg-[c10]] + ~W(c13 c14)a + ~W(c15 c16)c + ~W(c17 c18)s + ~w(c19 c20) + ~c(c21 c22) + ~C(c23 c24) + ~s(c25 c26) + ~S(c27 c28) + "#; + + Elixir::test_extract_contains( + input, + vec![ + "c1", + "c2", + "c3", + "c4", + "c5", + "c6", + "text-(--c7)", + "bg-(--c8)", + "c13", + "c14", + "c15", + "c16", + "c17", + "c18", + "c19", + "c20", + "c21", + "c22", + "c23", + "c24", + "c25", + "c26", + "c27", + "c28", + ], + ); + } +} diff --git a/crates/oxide/src/extractor/pre_processors/mod.rs b/crates/oxide/src/extractor/pre_processors/mod.rs index 4609f10b9359..7435c349c4d7 100644 --- a/crates/oxide/src/extractor/pre_processors/mod.rs +++ b/crates/oxide/src/extractor/pre_processors/mod.rs @@ -1,4 +1,5 @@ pub mod clojure; +pub mod elixir; pub mod haml; pub mod json; pub mod pre_processor; @@ -10,6 +11,7 @@ pub mod svelte; pub mod vue; pub use clojure::*; +pub use elixir::*; pub use haml::*; pub use json::*; pub use pre_processor::*; diff --git a/crates/oxide/src/scanner/mod.rs b/crates/oxide/src/scanner/mod.rs index 0bd2b6574b3c..0d2fceba60bb 100644 --- a/crates/oxide/src/scanner/mod.rs +++ b/crates/oxide/src/scanner/mod.rs @@ -482,6 +482,7 @@ pub fn pre_process_input(content: &[u8], extension: &str) -> Vec { match extension { "clj" | "cljs" | "cljc" => Clojure.process(content), + "heex" | "eex" | "ex" | "exs" => Elixir.process(content), "cshtml" | "razor" => Razor.process(content), "haml" => Haml.process(content), "json" => Json.process(content), From fcc6722473cbb3c6e67510cd8bde2a7c44985b56 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 2 Jul 2025 11:31:33 -0400 Subject: [PATCH 2/6] Run `cargo fmt` --- crates/oxide/src/extractor/mod.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/oxide/src/extractor/mod.rs b/crates/oxide/src/extractor/mod.rs index 33a3651f630e..add0de4acaca 100644 --- a/crates/oxide/src/extractor/mod.rs +++ b/crates/oxide/src/extractor/mod.rs @@ -1058,10 +1058,7 @@ mod tests { #[test] fn test_leptos_rs_view_class_colon_syntax() { for (input, expected) in [ - ( - r#"
"#, - vec!["class", "px-6"], - ), + (r#"
"#, vec!["class", "px-6"]), ( r#"view! {
}"#, vec!["class", "px-6", "view!"], From 666c81f5d1b8e3ef39870bd44d4694c522bcd5b8 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 2 Jul 2025 11:34:06 -0400 Subject: [PATCH 3/6] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index aacacd72b5b5..eeb8441b4b72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Don't consider the global important state in `@apply` ([#18404](https://github.com/tailwindlabs/tailwindcss/pull/18404)) - Fix trailing `)` from interfering with extraction in Clojure keywords ([#18345](https://github.com/tailwindlabs/tailwindcss/pull/18345)) +- Detect classes inside Elixir charlist, word list, and string sigils ([#18432](https://github.com/tailwindlabs/tailwindcss/pull/18432)) ## [4.1.11] - 2025-06-26 From aea72d987bdd0765f357508f542c96c8b2cff3c1 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 2 Jul 2025 15:34:58 -0400 Subject: [PATCH 4/6] Apply suggestions from code review Co-authored-by: Robin Malfait --- crates/oxide/src/extractor/pre_processors/elixir.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/oxide/src/extractor/pre_processors/elixir.rs b/crates/oxide/src/extractor/pre_processors/elixir.rs index aae4e4c4285a..189d70eed60d 100644 --- a/crates/oxide/src/extractor/pre_processors/elixir.rs +++ b/crates/oxide/src/extractor/pre_processors/elixir.rs @@ -1,5 +1,3 @@ -use std::str::from_utf8; - use crate::cursor; use crate::extractor::bracket_stack::BracketStack; use crate::extractor::pre_processors::pre_processor::PreProcessor; @@ -9,7 +7,7 @@ pub struct Elixir; impl PreProcessor for Elixir { fn process(&self, content: &[u8]) -> Vec { - let mut cursor = cursor::Cursor::new(&content); + let mut cursor = cursor::Cursor::new(content); let mut result = content.to_vec(); let mut bracket_stack = BracketStack::default(); From 21196edb5468c0d853eb1eee466dd4dc6711050c Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 2 Jul 2025 15:35:43 -0400 Subject: [PATCH 5/6] Run `cargo clippy` --- crates/oxide/src/extractor/pre_processors/clojure.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/oxide/src/extractor/pre_processors/clojure.rs b/crates/oxide/src/extractor/pre_processors/clojure.rs index c6a458139a9b..c53afd552b19 100644 --- a/crates/oxide/src/extractor/pre_processors/clojure.rs +++ b/crates/oxide/src/extractor/pre_processors/clojure.rs @@ -16,10 +16,10 @@ pub struct Clojure; /// can simplify this list quite a bit. #[inline] fn is_keyword_character(byte: u8) -> bool { - return matches!( + (matches!( byte, b'!' | b'#' | b'%' | b'*' | b'+' | b'-' | b'.' | b'/' | b':' | b'_' - ) | byte.is_ascii_alphanumeric(); + ) | byte.is_ascii_alphanumeric()) } impl PreProcessor for Clojure { From bc29cd85fc85f8cd329878b1145a4070ba0bb9fd Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 2 Jul 2025 15:44:57 -0400 Subject: [PATCH 6/6] Add tests --- crates/oxide/src/extractor/pre_processors/elixir.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/oxide/src/extractor/pre_processors/elixir.rs b/crates/oxide/src/extractor/pre_processors/elixir.rs index 189d70eed60d..87b89a2a9dda 100644 --- a/crates/oxide/src/extractor/pre_processors/elixir.rs +++ b/crates/oxide/src/extractor/pre_processors/elixir.rs @@ -113,6 +113,8 @@ mod tests { ~C(c23 c24) ~s(c25 c26) ~S(c27 c28) + ~W"c29 c30" + ~W'c31 c32' "#; Elixir::test_extract_contains( @@ -142,6 +144,10 @@ mod tests { "c26", "c27", "c28", + "c29", + "c30", + "c31", + "c32", ], ); }