Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed

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

## [4.1.11] - 2025-06-26

Expand Down
134 changes: 102 additions & 32 deletions crates/oxide/src/extractor/pre_processors/clojure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ use bstr::ByteSlice;
#[derive(Debug, Default)]
pub struct Clojure;

#[inline]
fn is_keyword_character(byte: u8) -> bool {
matches!(
byte,
b'+' | b'-' | b'/' | b'*' | b'_' | b'#' | b'.' | b':' | b'?'
) | byte.is_ascii_alphanumeric()
}

impl PreProcessor for Clojure {
fn process(&self, content: &[u8]) -> Vec<u8> {
let content = content
Expand All @@ -18,6 +26,7 @@ impl PreProcessor for Clojure {
match cursor.curr {
// Consume strings as-is
b'"' => {
result[cursor.pos] = b' ';
cursor.advance();

while cursor.pos < len {
Expand All @@ -26,52 +35,82 @@ impl PreProcessor for Clojure {
b'\\' => cursor.advance_twice(),

// End of the string
b'"' => break,
b'"' => {
result[cursor.pos] = b' ';
break;
}

// Everything else is valid
_ => cursor.advance(),
};
}
}

// Consume comments as-is until the end of the line.
// Discard line comments until the end of the line.
// Comments start with `;;`
b';' if matches!(cursor.next, b';') => {
while cursor.pos < len && cursor.curr != b'\n' {
result[cursor.pos] = b' ';
cursor.advance();
}
}

// A `.` surrounded by digits is a decimal number, so we don't want to replace it.
//
// E.g.:
// ```
// gap-1.5
// ^
// ``
b'.' if cursor.prev.is_ascii_digit() && cursor.next.is_ascii_digit() => {
// Consume keyword until a terminating character is reached.
b':' => {
result[cursor.pos] = b' ';
cursor.advance();

// Keep the `.` as-is
}
while cursor.pos < len {
match cursor.curr {
// A `.` surrounded by digits is a decimal number, so we don't want to replace it.
//
// E.g.:
// ```
// gap-1.5
// ^
// ```
b'.' if cursor.prev.is_ascii_digit()
&& cursor.next.is_ascii_digit() =>
{
// Keep the `.` as-is
}
// A `.` not surrounded by digits denotes the start of a new class name in a
// dot-delimited keyword.
//
// E.g.:
// ```
// flex.gap-1.5
// ^
// ```
b'.' => {
result[cursor.pos] = b' ';
}
// End of keyword.
_ if !is_keyword_character(cursor.curr) => {
result[cursor.pos] = b' ';
break;
}

// A `:` surrounded by letters denotes a variant. Keep as is.
//
// E.g.:
// ```
// lg:pr-6"
// ^
// ``
b':' if cursor.prev.is_ascii_alphanumeric() && cursor.next.is_ascii_alphanumeric() => {
// Consume everything else.
_ => {}
};

// Keep the `:` as-is
cursor.advance();
}
}

b':' | b'.' => {
// Aggressively discard everything else, reducing false positives and preventing
// characters surrounding keywords from producing false negatives.
// E.g.:
// ```
// (when condition :bg-white)
// ^
// ```
// A ')' is never a valid part of a keyword, but will nonetheless prevent 'bg-white'
// from being extracted if not discarded.
_ => {
result[cursor.pos] = b' ';
}

// Consume everything else
_ => {}
};

cursor.advance();
Expand All @@ -92,19 +131,23 @@ mod tests {
(":div.flex-1.flex-2", " div flex-1 flex-2"),
(
":.flex-3.flex-4 ;defaults to div",
" flex-3 flex-4 ;defaults to div",
" flex-3 flex-4 ",
),
("{:class :flex-5.flex-6", "{ flex-5 flex-6"),
(r#"{:class "flex-7 flex-8"}"#, r#"{ "flex-7 flex-8"}"#),
("{:class :flex-5.flex-6", " flex-5 flex-6"),
(r#"{:class "flex-7 flex-8"}"#, r#" flex-7 flex-8 "#),
(
r#"{:class ["flex-9" :flex-10]}"#,
r#"{ ["flex-9" flex-10]}"#,
r#" flex-9 flex-10 "#,
),
(
r#"(dom/div {:class "flex-11 flex-12"})"#,
r#"(dom/div { "flex-11 flex-12"})"#,
r#" flex-11 flex-12 "#,
),
("(dom/div :.flex-13.flex-14", " flex-13 flex-14"),
(
r#"[:div#hello.bg-white.pr-1.5 {:class ["grid grid-cols-[auto,1fr] grid-rows-2"]}]"#,
r#" div#hello bg-white pr-1.5 grid grid-cols-[auto,1fr] grid-rows-2 "#,
),
("(dom/div :.flex-13.flex-14", "(dom/div flex-13 flex-14"),
] {
Clojure::test(input, expected);
}
Expand Down Expand Up @@ -198,8 +241,35 @@ mod tests {
($ :div {:class [:flex :first:lg:pr-6 :first:2xl:pl-6 :group-hover/2:2xs:pt-6]} …)

:.hover:bg-white

[:div#hello.bg-white.pr-1.5]
"#;

Clojure::test_extract_contains(
input,
vec![
"flex",
"first:lg:pr-6",
"first:2xl:pl-6",
"group-hover/2:2xs:pt-6",
"hover:bg-white",
"bg-white",
"pr-1.5",
],
);
}

// https://github.com/tailwindlabs/tailwindcss/issues/18344
#[test]
fn test_noninterference_of_parens_on_keywords() {
let input = r#"
(get props :y-padding :py-5)
($ :div {:class [:flex.pr-1.5 (if condition :bg-white :bg-black)]})
"#;

Clojure::test_extract_contains(input, vec!["flex", "first:lg:pr-6", "first:2xl:pl-6", "group-hover/2:2xs:pt-6", "hover:bg-white"]);
Clojure::test_extract_contains(
input,
vec!["py-5", "flex", "pr-1.5", "bg-white", "bg-black"],
);
}
}