Skip to content

Commit 8c80330

Browse files
Detect classes inside Elixir charlist, word list, and string sigils (#18432)
Fixes #18431 The `~W(…)` syntax in Elixir is a _sigil_. It's basically another way to write strings, arrays of strings, etc… The syntax lets you use a handful of surrounding brackets like `~W(…)`, `~W[…]`, `~W{…}`, `~W"…"`, etc… to let you write lists without necessarily having to escape characters. In v3 our extractor was able to pick these up but in v4 Oxide does not. I've added a preprocessor for Elixir files so we can modify the code before our main extractor sees it. Now things like this: `~W(text-white bg-gray-600)` will get turned into ` ~W text-white bg-gray-600 ` which can easily be processed by our extractor. The sigils we support are: - `~s` / `~S` (strings) - `~w` / `~W` (word lists) - `~c` / `~C` (charlists) We're specifically detecting the use of `(…)`, `[…]`, and `{…}` as using quotes already works today. --------- Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
1 parent 3cab801 commit 8c80330

File tree

6 files changed

+161
-6
lines changed

6 files changed

+161
-6
lines changed

CHANGELOG.md

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

1212
- Don't consider the global important state in `@apply` ([#18404](https://github.com/tailwindlabs/tailwindcss/pull/18404))
1313
- Fix trailing `)` from interfering with extraction in Clojure keywords ([#18345](https://github.com/tailwindlabs/tailwindcss/pull/18345))
14+
- Detect classes inside Elixir charlist, word list, and string sigils ([#18432](https://github.com/tailwindlabs/tailwindcss/pull/18432))
1415

1516
## [4.1.11] - 2025-06-26
1617

crates/oxide/src/extractor/mod.rs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1058,10 +1058,7 @@ mod tests {
10581058
#[test]
10591059
fn test_leptos_rs_view_class_colon_syntax() {
10601060
for (input, expected) in [
1061-
(
1062-
r#"<div class:px-6=true>"#,
1063-
vec!["class", "px-6"],
1064-
),
1061+
(r#"<div class:px-6=true>"#, vec!["class", "px-6"]),
10651062
(
10661063
r#"view! { <div class:px-6=true> }"#,
10671064
vec!["class", "px-6", "view!"],

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ pub struct Clojure;
1616
/// can simplify this list quite a bit.
1717
#[inline]
1818
fn is_keyword_character(byte: u8) -> bool {
19-
return matches!(
19+
(matches!(
2020
byte,
2121
b'!' | b'#' | b'%' | b'*' | b'+' | b'-' | b'.' | b'/' | b':' | b'_'
22-
) | byte.is_ascii_alphanumeric();
22+
) | byte.is_ascii_alphanumeric())
2323
}
2424

2525
impl PreProcessor for Clojure {
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
use crate::cursor;
2+
use crate::extractor::bracket_stack::BracketStack;
3+
use crate::extractor::pre_processors::pre_processor::PreProcessor;
4+
5+
#[derive(Debug, Default)]
6+
pub struct Elixir;
7+
8+
impl PreProcessor for Elixir {
9+
fn process(&self, content: &[u8]) -> Vec<u8> {
10+
let mut cursor = cursor::Cursor::new(content);
11+
let mut result = content.to_vec();
12+
let mut bracket_stack = BracketStack::default();
13+
14+
while cursor.pos < content.len() {
15+
// Look for a sigil marker
16+
if cursor.curr != b'~' {
17+
cursor.advance();
18+
continue;
19+
}
20+
21+
// Scan charlists, strings, and wordlists
22+
if !matches!(cursor.next, b'c' | b'C' | b's' | b'S' | b'w' | b'W') {
23+
cursor.advance();
24+
continue;
25+
}
26+
27+
cursor.advance_twice();
28+
29+
// Match the opening for a sigil
30+
if !matches!(cursor.curr, b'(' | b'[' | b'{') {
31+
continue;
32+
}
33+
34+
// Replace the opening bracket with a space
35+
result[cursor.pos] = b' ';
36+
37+
// Scan until we find a balanced closing one and replace it too
38+
bracket_stack.push(cursor.curr);
39+
40+
while cursor.pos < content.len() {
41+
cursor.advance();
42+
43+
match cursor.curr {
44+
// Escaped character, skip ahead to the next character
45+
b'\\' => cursor.advance_twice(),
46+
b'(' | b'[' | b'{' => {
47+
bracket_stack.push(cursor.curr);
48+
}
49+
b')' | b']' | b'}' if !bracket_stack.is_empty() => {
50+
bracket_stack.pop(cursor.curr);
51+
52+
if bracket_stack.is_empty() {
53+
// Replace the closing bracket with a space
54+
result[cursor.pos] = b' ';
55+
break;
56+
}
57+
}
58+
_ => {}
59+
}
60+
}
61+
}
62+
63+
result
64+
}
65+
}
66+
67+
#[cfg(test)]
68+
mod tests {
69+
use super::Elixir;
70+
use crate::extractor::pre_processors::pre_processor::PreProcessor;
71+
72+
#[test]
73+
fn test_elixir_pre_processor() {
74+
for (input, expected) in [
75+
// Simple sigils
76+
("~W(flex underline)", "~W flex underline "),
77+
("~W[flex underline]", "~W flex underline "),
78+
("~W{flex underline}", "~W flex underline "),
79+
// Sigils with nested brackets
80+
(
81+
"~W(text-(--my-color) bg-(--my-color))",
82+
"~W text-(--my-color) bg-(--my-color) ",
83+
),
84+
("~W[text-[red] bg-[red]]", "~W text-[red] bg-[red] "),
85+
// Word sigils with modifiers
86+
("~W(flex underline)a", "~W flex underline a"),
87+
("~W(flex underline)c", "~W flex underline c"),
88+
("~W(flex underline)s", "~W flex underline s"),
89+
// Other sigil types
90+
("~w(flex underline)", "~w flex underline "),
91+
("~c(flex underline)", "~c flex underline "),
92+
("~C(flex underline)", "~C flex underline "),
93+
("~s(flex underline)", "~s flex underline "),
94+
("~S(flex underline)", "~S flex underline "),
95+
] {
96+
Elixir::test(input, expected);
97+
}
98+
}
99+
100+
#[test]
101+
fn test_extract_candidates() {
102+
let input = r#"
103+
~W(c1 c2)
104+
~W[c3 c4]
105+
~W{c5 c6}
106+
~W(text-(--c7) bg-(--c8))
107+
~W[text-[c9] bg-[c10]]
108+
~W(c13 c14)a
109+
~W(c15 c16)c
110+
~W(c17 c18)s
111+
~w(c19 c20)
112+
~c(c21 c22)
113+
~C(c23 c24)
114+
~s(c25 c26)
115+
~S(c27 c28)
116+
~W"c29 c30"
117+
~W'c31 c32'
118+
"#;
119+
120+
Elixir::test_extract_contains(
121+
input,
122+
vec![
123+
"c1",
124+
"c2",
125+
"c3",
126+
"c4",
127+
"c5",
128+
"c6",
129+
"text-(--c7)",
130+
"bg-(--c8)",
131+
"c13",
132+
"c14",
133+
"c15",
134+
"c16",
135+
"c17",
136+
"c18",
137+
"c19",
138+
"c20",
139+
"c21",
140+
"c22",
141+
"c23",
142+
"c24",
143+
"c25",
144+
"c26",
145+
"c27",
146+
"c28",
147+
"c29",
148+
"c30",
149+
"c31",
150+
"c32",
151+
],
152+
);
153+
}
154+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
pub mod clojure;
2+
pub mod elixir;
23
pub mod haml;
34
pub mod json;
45
pub mod pre_processor;
@@ -10,6 +11,7 @@ pub mod svelte;
1011
pub mod vue;
1112

1213
pub use clojure::*;
14+
pub use elixir::*;
1315
pub use haml::*;
1416
pub use json::*;
1517
pub use pre_processor::*;

crates/oxide/src/scanner/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,7 @@ pub fn pre_process_input(content: &[u8], extension: &str) -> Vec<u8> {
482482

483483
match extension {
484484
"clj" | "cljs" | "cljc" => Clojure.process(content),
485+
"heex" | "eex" | "ex" | "exs" => Elixir.process(content),
485486
"cshtml" | "razor" => Razor.process(content),
486487
"haml" => Haml.process(content),
487488
"json" => Json.process(content),

0 commit comments

Comments
 (0)