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>
This commit is contained in:
Jordan Pittman 2025-07-02 16:01:13 -04:00 • committed by GitHub
parent 3cab801761
commit 8c8033017d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 161 additions and 6 deletions

View file

@ -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

View file

@ -1058,10 +1058,7 @@ mod tests {
#[test]
fn test_leptos_rs_view_class_colon_syntax() {
for (input, expected) in [
(
r#"<div class:px-6=true>"#,
vec!["class", "px-6"],
),
(r#"<div class:px-6=true>"#, vec!["class", "px-6"]),
(
r#"view! { <div class:px-6=true> }"#,
vec!["class", "px-6", "view!"],

View file

@ -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 {

View file

@ -0,0 +1,154 @@
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<u8> {
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)
~W"c29 c30"
~W'c31 c32'
"#;
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",
"c29",
"c30",
"c31",
"c32",
],
);
}
}

View file

@ -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::*;

View file

@ -482,6 +482,7 @@ pub fn pre_process_input(content: &[u8], extension: &str) -> Vec<u8> {
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),