diff --git a/CHANGELOG.md b/CHANGELOG.md index 96ed1a524434..aacacd72b5b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/crates/oxide/src/extractor/pre_processors/clojure.rs b/crates/oxide/src/extractor/pre_processors/clojure.rs index 13331173434d..a494c12eb955 100644 --- a/crates/oxide/src/extractor/pre_processors/clojure.rs +++ b/crates/oxide/src/extractor/pre_processors/clojure.rs @@ -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 { let content = content @@ -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 { @@ -26,7 +35,10 @@ 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(), @@ -34,44 +46,71 @@ impl PreProcessor for Clojure { } } - // 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(); @@ -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); } @@ -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"], + ); } }