From 25f7ccff8062d90db79264f3dca14f0bd91ecd13 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 6 Jan 2026 12:46:14 +0100 Subject: [PATCH] Fix class extraction for Rails' strict locals (#19525) Fixes: #19481 This PR improves the Ruby extractor to better handle strict locals. We recently introduced skipping comments in the Ruby extractor (PR #19243 for #19239) by ignoring comments that start with `#` until the end of the line. Strict locals are implemented like this: ```ruby <%# locals: (css: "text-amber-600") %> ``` Notice the `#` after the `<%`, we considered this a comment and ignored it. This PR changes that behavior slightly where we skip comments that are preceded by `%`. This means that `<%# anything here _will_ be scanned %>`. This should solve the strict locals case, and normal comments will still be skipped. We can be more strict in the future if needed, but I think that this should be a good solution for both scenarios. ### Test plan 1. Added a test to ensure we extract candidates in strict locals 2. Added a regression test for issue #19239 where we introduced skipping comments in the Ruby extractor 3. Other existing tests are still passing We can also verify the extracted candidates: (it's subtle, but you can see that the class is being extracted now) image --- CHANGELOG.md | 1 + .../extractor/pre_processors/pre_processor.rs | 30 +++++++++++- .../src/extractor/pre_processors/ruby.rs | 47 ++++++++++++++++++- 3 files changed, 75 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf75d28e3..5adb9f7f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Allow whitespace around `@source inline()` argument ([#19461](https://github.com/tailwindlabs/tailwindcss/pull/19461)) - CLI: Emit comment when source maps are saved to files ([#19447](https://github.com/tailwindlabs/tailwindcss/pull/19447)) - Detect utilities when containing capital letters followed by numbers ([#19465](https://github.com/tailwindlabs/tailwindcss/pull/19465)) +- Fix class extraction for Rails' strict locals ([#19525](https://github.com/tailwindlabs/tailwindcss/pull/19525)) ### Added diff --git a/crates/oxide/src/extractor/pre_processors/pre_processor.rs b/crates/oxide/src/extractor/pre_processors/pre_processor.rs index 8d42e744a..8e02b8f69 100644 --- a/crates/oxide/src/extractor/pre_processors/pre_processor.rs +++ b/crates/oxide/src/extractor/pre_processors/pre_processor.rs @@ -25,7 +25,33 @@ pub trait PreProcessor: Sized + Default { } #[cfg(test)] - fn test_extract_contains(input: &str, items: Vec<&str>) { + fn test_extract_exact(input: &str, expected: Vec<&str>) { + use crate::extractor::{Extracted, Extractor}; + + let input = input.as_bytes(); + + let processor = Self::default(); + let transformed = processor.process(input); + + let extracted = Extractor::new(&transformed).extract(); + + // Extract all candidates and css variables. + let candidates = extracted + .iter() + .filter_map(|x| match x { + Extracted::Candidate(bytes) => std::str::from_utf8(bytes).ok(), + Extracted::CssVariable(bytes) => std::str::from_utf8(bytes).ok(), + }) + .collect::>(); + + if candidates != expected { + dbg!(&candidates, &expected); + panic!("Extracted candidates do not match expected candidates"); + } + } + + #[cfg(test)] + fn test_extract_contains(input: &str, expected: Vec<&str>) { use crate::extractor::{Extracted, Extractor}; let input = input.as_bytes(); @@ -46,7 +72,7 @@ pub trait PreProcessor: Sized + Default { // Ensure all items are present in the candidates. let mut missing = vec![]; - for item in &items { + for item in &expected { if !candidates.contains(item) { missing.push(item); } diff --git a/crates/oxide/src/extractor/pre_processors/ruby.rs b/crates/oxide/src/extractor/pre_processors/ruby.rs index c94bd945f..89ac1ee7b 100644 --- a/crates/oxide/src/extractor/pre_processors/ruby.rs +++ b/crates/oxide/src/extractor/pre_processors/ruby.rs @@ -119,7 +119,11 @@ impl PreProcessor for Ruby { } // Replace comments in Ruby files - b'#' => { + // + // Except for strict locals, these are defined in a `<%# locals: … %>`. Checking if + // the comment is preceded by a `%` should be enough without having to perform more + // parsing logic. Worst case we _do_ scan a few comments. + b'#' if !matches!(cursor.prev, b'%') => { result[cursor.pos] = b' '; cursor.advance(); @@ -382,4 +386,45 @@ mod tests { "#; Ruby::test_extract_contains(input, vec!["z-1", "z-2", "z-3"]); } + + // https://github.com/tailwindlabs/tailwindcss/issues/19239 + #[test] + fn test_skip_comments() { + let input = r#" + # From activerecord-8.1.1/lib/active_record/errors.rb:147 + # Rails uses RDoc cross-reference syntax in inline documentation: + # {ActiveRecord::Base#save!}[rdoc-ref:Persistence#save!] + "#; + + // Nothing should be extracted from comments, so expect an empty array. + Ruby::test_extract_exact(input, vec![]); + } + + // https://github.com/tailwindlabs/tailwindcss/issues/19481 + #[test] + fn test_strict_locals() { + // Strict locals are defined in a `<%# locals: … %>`, but the `#` looks like a comment + // which we should not ignore in this case. + let input = r#" + <%# locals: (css: "text-amber-600") %> + <% more_css = "text-sky-500" %> + +

+ In a partial +

+ +

+ In a partial using explicit local variables +

+ +

+ In a partial using explicit local variables +

+ "#; + + Ruby::test_extract_contains( + input, + vec!["text-amber-600", "text-sky-500", "text-green-500"], + ); + } }