diff --git a/.changepacks/changepack_log_tailwind_classes.json b/.changepacks/changepack_log_tailwind_classes.json new file mode 100644 index 000000000..3e2f64256 --- /dev/null +++ b/.changepacks/changepack_log_tailwind_classes.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Tailwind classes in a className: each class compiles on its own and every class Tailwind does not define (custom, plugin and analytics classes) stays as written, where any recognized class used to rebuild the whole className without them and could rewrite part of another class; a class touching a template interpolation (icon-${name}) is left as written, and template classNames no longer get spaces inserted around interpolations. A class whose variant is not understood stays as written instead of applying unconditionally; variants follow Tailwind v4: stacked variants compose (hover:focus: matches the element itself), hover is guarded by @media (hover: hover), group-*/peer-* use the group/peer marker classes (and group-hover/name), and data-*, aria-*, has-*, nth-*, *, **, supports-[…] and [&…]/[@media …] variants compile. Utilities emit all their declarations (size-*, rounded-t-*, truncate, sr-only, not-sr-only, text-sm with its line height, text-sm/6, leading-*), translate/rotate/scale/skew compose through Tailwind's individual properties and registered variables (@property) with negative values kept negative, and arbitrary values follow Tailwind's grammar: spaces for underscores except in url() and var() names, \\_ for an underscore, spaces around math operators, type hints, [property:value] and (--variable) shorthands. Words that only share a utility's prefix (select-wrapper, order-summary, cursor-foo) are no longer compiled", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/bun.lock b/bun.lock index a6f7b10d1..906ce66d6 100644 --- a/bun.lock +++ b/bun.lock @@ -489,11 +489,11 @@ }, "bindings/devup-ui-wasm": { "name": "@devup-ui/wasm", - "version": "1.0.82", + "version": "1.0.83", }, "packages/bun-plugin": { "name": "@devup-ui/bun-plugin", - "version": "1.0.21", + "version": "1.0.22", "dependencies": { "@devup-ui/plugin-utils": "workspace:^", "@devup-ui/wasm": "workspace:^", @@ -508,7 +508,7 @@ }, "packages/components": { "name": "@devup-ui/components", - "version": "0.1.59", + "version": "0.1.60", "dependencies": { "@devup-ui/react": "workspace:^", "clsx": "^2.1", @@ -535,7 +535,7 @@ }, "packages/eslint-plugin": { "name": "@devup-ui/eslint-plugin", - "version": "1.0.21", + "version": "1.0.22", "dependencies": { "@typescript-eslint/utils": "^8.70", "typescript-eslint": "^8.70", @@ -552,7 +552,7 @@ }, "packages/next-plugin": { "name": "@devup-ui/next-plugin", - "version": "1.0.89", + "version": "1.0.90", "dependencies": { "@devup-ui/plugin-utils": "workspace:^", "@devup-ui/wasm": "workspace:^", @@ -570,7 +570,7 @@ }, "packages/plugin-utils": { "name": "@devup-ui/plugin-utils", - "version": "1.0.16", + "version": "1.0.17", "devDependencies": { "typescript": "^7.0.2", }, @@ -611,7 +611,7 @@ }, "packages/rsbuild-plugin": { "name": "@devup-ui/rsbuild-plugin", - "version": "1.0.66", + "version": "1.0.67", "dependencies": { "@devup-ui/plugin-utils": "workspace:^", "@devup-ui/wasm": "workspace:^", @@ -627,7 +627,7 @@ }, "packages/vite-plugin": { "name": "@devup-ui/vite-plugin", - "version": "1.0.72", + "version": "1.0.73", "dependencies": { "@devup-ui/plugin-utils": "workspace:^", "@devup-ui/wasm": "workspace:^", @@ -642,7 +642,7 @@ }, "packages/webpack-plugin": { "name": "@devup-ui/webpack-plugin", - "version": "1.0.70", + "version": "1.0.71", "dependencies": { "@devup-ui/plugin-utils": "workspace:^", "@devup-ui/wasm": "workspace:^", diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 96c6ca430..a5853d5b5 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -14553,6 +14553,92 @@ const Button = styled.button({ bg: 'red' }) )); } + // Each Tailwind class becomes the classes of its styles; every other class, + // and every class that runs into an interpolation, stays as written + #[rstest] + #[case( + r#""#, + r#"
"# + )] + #[case(r#""#, r#"
"#)] + #[case( + r#""#, + r#"
"# + )] + #[case( + "", + "
" + )] + #[case( + r"", + r"
" + )] + #[case( + "", + r#"
"# + )] + #[case( + r#""#, + r#"
"# + )] + #[case( + r#""#, + r#"
"# + )] + #[case( + r#""#, + r#"
"# + )] + #[case( + r#""#, + r#"
"# + )] + #[case(r#""#, r#"
"#)] + #[case( + "", + "-
" + )] + #[case( + r#""#, + r#"-
"# + )] + #[case( + r#""#, + r#"-
"# + )] + #[case( + r#""#, + r#"-
"# + )] + #[case( + "", + "-
" + )] + #[case("", "-
")] + #[serial] + fn test_tailwind_keeps_other_classes(#[case] jsx: &str, #[case] expected: &str) { + reset_class_map(); + reset_file_map(); + let output = extract( + "test.tsx", + &format!("import {{Box}} from '@devup-ui/core'\n{jsx}\n"), + ExtractOption { + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), + single_css: true, + import_main_css: false, + import_aliases: HashMap::new(), + }, + ) + .unwrap(); + // A leading `-` marks code that needs no stylesheet + let expected = expected.strip_prefix('-').map_or_else( + || format!("import \"@devup-ui/core/devup-ui.css\";\n{expected};\n"), + |code| format!("{code};\n"), + ); + assert_eq!(output.code, expected); + } + #[test] #[serial] fn test_tailwind_classname_extraction() { diff --git a/libs/extractor/src/prop_modify_utils.rs b/libs/extractor/src/prop_modify_utils.rs index 7076a2e68..b8b1ce7da 100644 --- a/libs/extractor/src/prop_modify_utils.rs +++ b/libs/extractor/src/prop_modify_utils.rs @@ -1,10 +1,9 @@ use crate::extract_style::ExtractStyleProperty; +use crate::extract_style::extract_css::ExtractCss; use crate::extract_style::style_property::StyleProperty; use crate::gen_class_name::gen_class_names; use crate::gen_style::gen_styles; -use crate::tailwind::{ - TailwindClass, has_tailwind_classes, parse_single_class, parse_tailwind_to_styles, -}; +use crate::tailwind::{PROPERTY_RULES, PROPERTY_RULES_FILE, parse_class}; use crate::utils::{get_str_by_property_key, merge_object_expressions}; use crate::{ExtractStyleProp, ExtractStyleValue}; use oxc_allocator::{CloneIn, FromIn, GetAllocator}; @@ -16,8 +15,6 @@ use oxc_ast::ast::{ }; use oxc_ast::builder::AstBuilder; use oxc_span::SPAN; -use rustc_hash::FxHashMap; -use std::borrow::Cow; /// Combine two optional className expressions into a conditional expression. /// `condition ? con_expr : alt_expr`, falling back to `""` for the missing branch. @@ -313,26 +310,23 @@ pub fn get_class_name_expression<'a>( spread_props: &[Expression<'a>], filename: Option<&str>, ) -> (Option>, Vec) { - // Extract Tailwind styles from static className strings and generate class names - let (tailwind_styles, tailwind_class_expr) = - extract_tailwind_from_class_name(ast_builder, class_name_prop, style_order, filename); - - // Determine the className expression to use: - // - If we extracted Tailwind styles, use generated class names (replace original) - // - Otherwise, preserve the original className - let class_name_to_use = if let Some(tailwind_class_expr) = tailwind_class_expr { - // Tailwind className → replaced with generated class names. A rebuilt - // `cond && "a"` still evaluates to `false`, which React would render as - // `class="false"`, so it needs the same falsy guard as a passthrough. - Some(convert_class_name(ast_builder, &tailwind_class_expr)) - } else { - // Non-Tailwind className → keep original - class_name_prop - .as_ref() - .map(|class_name| convert_class_name(ast_builder, class_name)) + let mut tailwind = TailwindClassName { + style_order, + filename, + styles: Vec::new(), + property_rules: false, }; + let compiled = class_name_prop + .as_ref() + .and_then(|class_name| tailwind.compile_expression(ast_builder, class_name)); + // A rebuilt `cond && "a"` still evaluates to `false`, which React would + // render as `class="false"`, so it needs the same falsy guard as a passthrough. + let class_name_to_use = compiled + .as_ref() + .or(class_name_prop.as_ref()) + .map(|class_name| convert_class_name(ast_builder, class_name)); - // Merge class names: [tailwind/original class names] + [devup-ui component styles] + // Merge class names: [className] + [devup-ui component styles] let mut class_expressions = Vec::with_capacity(2 + spread_props.len()); if let Some(class_name) = class_name_to_use { class_expressions.push(class_name); @@ -361,294 +355,206 @@ pub fn get_class_name_expression<'a>( } let expression = merge_string_expressions(ast_builder, &class_expressions); - (expression, tailwind_styles) + (expression, tailwind.into_styles()) } -/// Apply `style_order` to all `ExtractStyleValue` items -fn apply_style_order_to_styles(styles: &mut [ExtractStyleValue], style_order: Option) { - if let Some(order) = style_order { - for style in styles.iter_mut() { - style.set_style_order(order); - } - } +/// Compiles the Tailwind classes of a className: each becomes the classes of +/// its styles, and every other class stays as written +struct TailwindClassName<'f> { + style_order: Option, + filename: Option<&'f str>, + styles: Vec, + /// Whether a compiled class uses the custom properties Tailwind registers + property_rules: bool, } -/// Extract Tailwind CSS styles from a static className string and generate devup-ui class names -/// Returns (extracted styles for CSS generation, generated class names expression) -fn extract_tailwind_from_class_name<'a>( - ast_builder: &AstBuilder<'a>, - class_name_prop: &Option>, - style_order: Option, - filename: Option<&str>, -) -> (Vec, Option>) { - // Extract from static string literals - if let Some(Expression::StringLiteral(literal)) = class_name_prop { - let class_str = literal.value.as_str(); - if has_tailwind_classes(class_str) { - let mut tailwind_styles = parse_tailwind_to_styles(class_str); - if !tailwind_styles.is_empty() { - // Apply style_order to all extracted Tailwind styles - apply_style_order_to_styles(&mut tailwind_styles, style_order); - - // Move ExtractStyleValue into ExtractStyleProp::Static for gen_class_names, - // then recover the same values afterward without deep-cloning each style. - let mut tailwind_style_props: Vec = tailwind_styles - .into_iter() - .map(ExtractStyleProp::Static) - .collect(); - - // Generate devup-ui class names for the Tailwind styles - let class_names_expr = gen_class_names( - ast_builder, - &mut tailwind_style_props, - style_order, - filename, - ); - - // Tailwind className styles are always `Static`, for which - // `into_extract` yields exactly `vec![style]`. Flattening through it - // keeps that hot path allocation-equivalent while staying total, so - // there is no unreachable arm to carve out of coverage. - let tailwind_styles = tailwind_style_props - .into_iter() - .flat_map(ExtractStyleProp::into_extract) - .collect(); - - return (tailwind_styles, class_names_expr); - } +impl TailwindClassName<'_> { + fn into_styles(self) -> Vec { + let mut styles = self.styles; + if self.property_rules { + styles.push(ExtractStyleValue::Css(ExtractCss { + css: PROPERTY_RULES.to_string(), + file: PROPERTY_RULES_FILE.to_string(), + })); } - } - - // Extract from any expression that can still carry static class strings: - // `` `${cond ? 'text-red' : 'text-blue'} p-4` ``, `cond ? 'p-4' : 'p-8'`, `cond && 'p-4'`. - if let Some(expression) = class_name_prop { - let mut all_classes = String::new(); - extract_classes_from_expression(expression, &mut all_classes); - if has_tailwind_classes(&all_classes) { - // Single pass over every class: parse ONCE, then build both the - // `Tailwind class → generated class name` mapping and the styles vec for - // CSS generation together. The previous code called - // `build_tailwind_class_mapping` and then `parse_tailwind_to_styles`, which - // re-parsed (and re-allocated a `TailwindClass` + `ExtractStaticStyle` for) - // every class a second time. Merging them keeps the mapping, the collected - // styles, and the ordered `extract()` side effects byte-identical while - // halving the per-class parse/allocate work. - let mut class_mapping: FxHashMap = FxHashMap::default(); - // Upper bound: at most one style per whitespace-separated class (the same - // presize `parse_tailwind_to_styles` used), so the vec never grow-reallocs. - let mut tailwind_styles: Vec = - Vec::with_capacity(all_classes.bytes().filter(u8::is_ascii_whitespace).count() + 1); - for class in all_classes.split_whitespace() { - if let Some(mut static_style) = parse_single_class(class) - .as_ref() - .and_then(TailwindClass::to_static_style) - { - if let Some(order) = style_order { - static_style.style_order = Some(order); + styles + } + + /// `text` with its Tailwind classes compiled, or `None` when it has none. A + /// class running into an end that continues in an interpolation is not + /// whole, so it stays as written. + fn compile_text(&mut self, text: &str, open_start: bool, open_end: bool) -> Option { + let mut compiled = String::with_capacity(text.len()); + let mut written = 0; + let mut start = 0; + // Class names are separated by ASCII whitespace, one byte each + for class in text.split(|c: char| c.is_ascii_whitespace()) { + let end = start + class.len(); + let whole = (!open_start || start > 0) && (!open_end || end < text.len()); + if let Some(tailwind) = whole.then(|| parse_class(class)).flatten() { + compiled.push_str(&text[written..start]); + self.property_rules |= tailwind.uses_properties(); + let mut separator = ""; + for mut style in tailwind.styles() { + if let Some(order) = self.style_order { + style.style_order = Some(order); } - // `ExtractStaticStyle::extract` always yields a `ClassName`, so this - // records the exact mapping entry the two-pass version produced. - if let StyleProperty::ClassName(generated) = static_style.extract(filename) { - class_mapping.insert(class.to_string(), generated); - } - tailwind_styles.push(ExtractStyleValue::Static(static_style)); + let (StyleProperty::ClassName(name) + | StyleProperty::Variable { + class_name: name, .. + }) = style.extract(self.filename); + compiled.push_str(separator); + compiled.push_str(&name); + separator = " "; + self.styles.push(ExtractStyleValue::Static(style)); } + written = end; } - - if !class_mapping.is_empty() { - // Build the same expression back with replaced class names - let new_expression = rebuild_expression_with_mapping_unsorted( + start = end + 1; + } + (written > 0).then(|| { + compiled.push_str(&text[written..]); + compiled + }) + } + + /// `expression` with the Tailwind classes of its strings compiled, or + /// `None` when they have none + fn compile_expression<'a>( + &mut self, + ast_builder: &AstBuilder<'a>, + expression: &Expression<'a>, + ) -> Option> { + match expression { + Expression::StringLiteral(literal) => { + let compiled = self.compile_text(&literal.value, false, false)?; + Some(Expression::new_string_literal( + SPAN, + Str::from_in(&compiled, ast_builder.allocator()), + None, ast_builder, - expression, - &class_mapping, - ); - - return (tailwind_styles, Some(new_expression)); + )) + } + Expression::TemplateLiteral(template) => self.compile_template(ast_builder, template), + Expression::ConditionalExpression(conditional) => { + let consequent = self.compile_expression(ast_builder, &conditional.consequent); + let alternate = self.compile_expression(ast_builder, &conditional.alternate); + if consequent.is_none() && alternate.is_none() { + return None; + } + Some(Expression::new_conditional_expression( + conditional.span, + conditional.test.clone_in(ast_builder.allocator()), + compiled_or(ast_builder, consequent, &conditional.consequent), + compiled_or(ast_builder, alternate, &conditional.alternate), + ast_builder, + )) } + Expression::LogicalExpression(logical) => { + let left = self.compile_expression(ast_builder, &logical.left); + let right = self.compile_expression(ast_builder, &logical.right); + if left.is_none() && right.is_none() { + return None; + } + Some(Expression::new_logical_expression( + logical.span, + compiled_or(ast_builder, left, &logical.left), + logical.operator, + compiled_or(ast_builder, right, &logical.right), + ast_builder, + )) + } + Expression::ParenthesizedExpression(parenthesized) => { + let inner = self.compile_expression(ast_builder, &parenthesized.expression)?; + Some(Expression::new_parenthesized_expression( + parenthesized.span, + inner, + ast_builder, + )) + } + // Variables, calls and the like only hold their classes at runtime + _ => None, } } - (Vec::new(), None) -} - -/// Rebuild a template literal, replacing Tailwind classes with generated class names -fn rebuild_expression_with_mapping_unsorted<'a>( - ast_builder: &AstBuilder<'a>, - expression: &Expression<'a>, - class_mapping: &FxHashMap, -) -> Expression<'a> { - // Sort the mapping ONCE by key length descending (avoids partial replacements, - // e.g. "text-3xl" before "text-3") and reuse the sorted slice for every quasi - // and nested expression instead of re-sorting per call. - let mut sorted_classes: Vec<(&String, &String)> = class_mapping.iter().collect(); - sorted_classes.sort_by_key(|(k, _)| std::cmp::Reverse(k.len())); - rebuild_expression_with_mapping(ast_builder, expression, &sorted_classes) -} - -/// Rebuild a template literal using a pre-sorted class mapping slice. -fn rebuild_template_literal_with_sorted<'a>( - ast_builder: &AstBuilder<'a>, - template: &oxc_ast::ast::TemplateLiteral<'a>, - sorted_classes: &[(&String, &String)], -) -> Expression<'a> { - // Rebuild quasis with replaced class names - let new_quasis = template.quasis.iter().map(|quasi| { - let raw = quasi.value.raw.as_str(); - let replaced = replace_classes_in_string(raw, sorted_classes); - let cooked = quasi.value.cooked.as_ref().map(|c| { - let replaced_cooked = replace_classes_in_string(c.as_str(), sorted_classes); - Str::from_in(&replaced_cooked, ast_builder.allocator()) - }); - TemplateElement::new( - quasi.span, - TemplateElementValue { - raw: Str::from_in(&replaced, ast_builder.allocator()), - cooked, - }, - quasi.tail, - ast_builder, - ) - }); - - // Rebuild expressions with replaced class names - let new_expressions = template - .expressions - .iter() - .map(|expr| rebuild_expression_with_mapping(ast_builder, expr, sorted_classes)); - - Expression::new_template_literal( - template.span, - oxc_allocator::Vec::from_iter_in(new_quasis, ast_builder), - oxc_allocator::Vec::from_iter_in(new_expressions, ast_builder), - ast_builder, - ) -} - -/// Replace Tailwind class names in a string with generated class names. -/// -/// `sorted_classes` MUST already be sorted by key length descending so longer -/// class names are replaced before their prefixes (e.g. "text-3xl" before "text-3"). -fn replace_classes_in_string(s: &str, sorted_classes: &[(&String, &String)]) -> String { - let mut result = Cow::Borrowed(s); - for (tailwind_class, generated_class) in sorted_classes { - // `str::replace` already scans the whole string internally and allocates a - // fresh `String` only on a match, so the previous standalone `contains` - // pre-scan was a redundant second scan of the same content. Compute the - // replacement once and only re-own the `Cow` when it actually changed - // (length differs, or same-length differing contents). Byte-identical. - let replaced = result.replace(tailwind_class.as_str(), generated_class); - if replaced.len() != result.len() || replaced != *result { - result = Cow::Owned(replaced); + fn compile_template<'a>( + &mut self, + ast_builder: &AstBuilder<'a>, + template: &TemplateLiteral<'a>, + ) -> Option> { + let last = template.quasis.len() - 1; + let quasis: Vec> = template + .quasis + .iter() + .enumerate() + .map(|(index, quasi)| { + // The text on each side of an interpolation runs into it + self.compile_text(quasi.value.cooked.as_ref()?, index > 0, index < last) + }) + .collect(); + let expressions: Vec>> = template + .expressions + .iter() + .map(|expression| self.compile_expression(ast_builder, expression)) + .collect(); + if quasis.iter().all(Option::is_none) && expressions.iter().all(Option::is_none) { + return None; } + let quasis = template + .quasis + .iter() + .zip(quasis) + .map(|(quasi, compiled)| match compiled { + Some(cooked) => TemplateElement::new( + quasi.span, + TemplateElementValue { + raw: Str::from_in(&template_raw(&cooked), ast_builder.allocator()), + cooked: Some(Str::from_in(&cooked, ast_builder.allocator())), + }, + quasi.tail, + ast_builder, + ), + None => quasi.clone_in(ast_builder.allocator()), + }); + let expressions = template + .expressions + .iter() + .zip(expressions) + .map(|(expression, compiled)| compiled_or(ast_builder, compiled, expression)); + Some(Expression::new_template_literal( + template.span, + oxc_allocator::Vec::from_iter_in(quasis, ast_builder), + oxc_allocator::Vec::from_iter_in(expressions, ast_builder), + ast_builder, + )) } - result.into_owned() } -/// Rebuild an expression, replacing Tailwind classes in string literals -fn rebuild_expression_with_mapping<'a>( +/// `compiled`, or a copy of `original` when nothing in it compiled +fn compiled_or<'a>( ast_builder: &AstBuilder<'a>, - expr: &Expression<'a>, - sorted_classes: &[(&String, &String)], + compiled: Option>, + original: &Expression<'a>, ) -> Expression<'a> { - match expr { - Expression::StringLiteral(lit) => { - let replaced = replace_classes_in_string(lit.value.as_str(), sorted_classes); - Expression::new_string_literal( - SPAN, - Str::from_in(&replaced, ast_builder.allocator()), - None, - ast_builder, - ) - } - Expression::ConditionalExpression(cond) => { - let consequent = - rebuild_expression_with_mapping(ast_builder, &cond.consequent, sorted_classes); - let alternate = - rebuild_expression_with_mapping(ast_builder, &cond.alternate, sorted_classes); - Expression::new_conditional_expression( - cond.span, - cond.test.clone_in(ast_builder.allocator()), - consequent, - alternate, - ast_builder, - ) - } - Expression::LogicalExpression(logic) => { - let left = rebuild_expression_with_mapping(ast_builder, &logic.left, sorted_classes); - let right = rebuild_expression_with_mapping(ast_builder, &logic.right, sorted_classes); - Expression::new_logical_expression(logic.span, left, logic.operator, right, ast_builder) - } - Expression::ParenthesizedExpression(paren) => { - let inner = - rebuild_expression_with_mapping(ast_builder, &paren.expression, sorted_classes); - Expression::new_parenthesized_expression(paren.span, inner, ast_builder) - } - Expression::TemplateLiteral(inner_template) => { - rebuild_template_literal_with_sorted(ast_builder, inner_template, sorted_classes) - } - // For other expressions (variables, etc.), keep as-is - _ => expr.clone_in(ast_builder.allocator()), - } + compiled.unwrap_or_else(|| original.clone_in(ast_builder.allocator())) } -/// Extract all class name strings from a template literal, including from conditional expressions -fn extract_all_classes_from_template_literal(template: &oxc_ast::ast::TemplateLiteral) -> String { - let mut classes = String::new(); - - // Extract from quasis (static parts of template literal) - for quasi in &template.quasis { - let raw = quasi.value.raw.as_str(); - push_class_segment(&mut classes, raw.trim()); - } - - // Extract from expressions (dynamic parts) - for expr in &template.expressions { - extract_classes_from_expression(expr, &mut classes); - } - - classes -} - -fn push_class_segment(classes: &mut String, value: &str) { - if value.is_empty() { - return; - } - if !classes.is_empty() { - classes.push(' '); - } - classes.push_str(value); -} - -/// Recursively extract class name strings from an expression -fn extract_classes_from_expression(expr: &Expression, classes: &mut String) { - match expr { - // Direct string literal: 'text-red-500' - Expression::StringLiteral(lit) => { - let value = lit.value.as_str().trim(); - push_class_segment(classes, value); - } - // Ternary/conditional: cond ? 'text-red' : 'text-blue' - Expression::ConditionalExpression(cond) => { - extract_classes_from_expression(&cond.consequent, classes); - extract_classes_from_expression(&cond.alternate, classes); - } - // Logical OR: value || 'fallback' - Expression::LogicalExpression(logic) => { - extract_classes_from_expression(&logic.left, classes); - extract_classes_from_expression(&logic.right, classes); - } - // Parenthesized expression: (expr) - Expression::ParenthesizedExpression(paren) => { - extract_classes_from_expression(&paren.expression, classes); - } - // Template literal inside expression - Expression::TemplateLiteral(inner_template) => { - let inner_classes = extract_all_classes_from_template_literal(inner_template); - push_class_segment(classes, &inner_classes); +/// The raw text of a template literal part whose value is `cooked` +fn template_raw(cooked: &str) -> String { + let mut raw = String::with_capacity(cooked.len()); + let mut chars = cooked.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '\\' | '`' => { + raw.push('\\'); + raw.push(c); + } + '$' if chars.peek() == Some(&'{') => raw.push_str("\\$"), + '\r' => raw.push_str("\\r"), + c => raw.push(c), } - // Other expressions (variables, function calls, etc.) - skip, can't extract statically - _ => {} } + raw } /// Whether the object `spread` gives may hold `key`: an object literal holds @@ -719,107 +625,76 @@ fn merge_string_expressions<'a>( return Some(expression.clone_in(ast_builder.allocator())); } - let mut string_literals: std::vec::Vec = vec![]; - let mut other_expressions = vec![]; - let mut prev_str = String::new(); - for ex in expressions { - if let Expression::StringLiteral(literal) = ex { - // Reuse the `prev_str` buffer instead of allocating a fresh String via - // `format!` each iteration. `prev_str` is only ever built here from trimmed - // pieces, so it never carries leading whitespace; trim only its trailing end. - let trimmed_len = prev_str.trim_end().len(); - prev_str.truncate(trimmed_len); - let target = literal.value.trim(); - if !prev_str.is_empty() { - prev_str.push(' '); + let mut list = ClassList { + texts: vec![String::new()], + interpolations: Vec::new(), + }; + for expression in expressions { + match expression { + Expression::StringLiteral(literal) => { + let classes = literal.value.trim(); + if !classes.is_empty() { + list.separate(); + list.text().push_str(classes); + } } - prev_str.push_str(target); - } else if let Expression::TemplateLiteral(template) = ex { - for (idx, q) in template.quasis.iter().enumerate() { - let target_prev = prev_str.trim(); - let target = q.value.raw.trim(); - if idx < template.quasis.len() - 1 { - string_literals.push(format!( - "{}{}{}{}{}", - if !other_expressions.is_empty() || idx > 0 { - " " - } else { - "" - }, - target_prev, - if target_prev.is_empty() { "" } else { " " }, - target, - if !target.is_empty() && !target.ends_with("typo-") { - " " - } else { - "" + Expression::TemplateLiteral(template) => { + let last = template.quasis.len() - 1; + for (index, quasi) in template.quasis.iter().enumerate() { + let text = quasi + .value + .cooked + .as_ref() + .map_or(quasi.value.raw.as_str(), |cooked| cooked.as_str()); + let classes = text.trim(); + if index == 0 { + if !classes.is_empty() || last > 0 { + list.separate(); } - )); - } else { - // Reuse the existing heap buffer instead of dropping it and - // allocating a fresh String: one fewer allocation per template - // quasi. Output stays byte-identical (same trimmed contents). - prev_str.clear(); - prev_str.push_str(q.value.raw.trim()); + } else if text.starts_with(|c: char| c.is_ascii_whitespace()) + && (!classes.is_empty() || index < last) + { + list.text().push(' '); + } + list.text().push_str(classes); + if index < last { + // Text running into an interpolation stays attached to + // it, as in `icon-${name}` + if !classes.is_empty() && text.ends_with(|c: char| c.is_ascii_whitespace()) + { + list.text().push(' '); + } + list.interpolate( + template.expressions[index].clone_in(ast_builder.allocator()), + ); + } } } - other_expressions.extend(template.expressions.clone_in(ast_builder.allocator())); - } else { - let target_prev = prev_str.trim(); - string_literals.push(format!( - "{}{}{}", - if other_expressions.is_empty() { - "" - } else { - " " - }, - target_prev, - if target_prev.is_empty() { "" } else { " " } - )); - other_expressions.push(ex.clone_in(ast_builder.allocator())); - // Reuse the backing capacity instead of allocating a fresh empty - // String; `clear()` keeps the buffer, byte-identical behavior. - prev_str.clear(); - } - } - { - let tail = prev_str.trim(); - if tail.is_empty() { - string_literals.push(String::new()); - } else { - let mut buf = String::with_capacity(tail.len() + 1); - buf.push(' '); - buf.push_str(tail); - string_literals.push(buf); + _ => { + list.separate(); + list.interpolate(expression.clone_in(ast_builder.allocator())); + } } } - if other_expressions.is_empty() { - // Concatenate the already-owned fragments into one presized buffer instead - // of `join("")`, which allocates a fresh Vec-backed buffer internally after - // summing lengths. Same summed-capacity, same byte order, then `.trim()` — - // byte-identical to `string_literals.join("")`. - let mut merged = String::with_capacity(string_literals.iter().map(String::len).sum()); - for frag in &string_literals { - merged.push_str(frag); - } + + if list.interpolations.is_empty() { return Some(Expression::new_string_literal( SPAN, - Str::from_in(merged.trim(), ast_builder.allocator()), + Str::from_in(list.texts[0].trim(), ast_builder.allocator()), None, ast_builder, )); } - - let q = oxc_allocator::Vec::from_iter_in( - string_literals.iter().enumerate().map(|(idx, s)| { - let tail = idx == string_literals.len() - 1; + let last = list.texts.len() - 1; + let quasis = oxc_allocator::Vec::from_iter_in( + list.texts.iter().enumerate().map(|(index, text)| { TemplateElement::new( SPAN, TemplateElementValue { - raw: Str::from_in(s, ast_builder.allocator()), - cooked: None, + raw: Str::from_in(&template_raw(text), ast_builder.allocator()), + cooked: Some(Str::from_in(text, ast_builder.allocator())), }, - tail, + index == last, ast_builder, ) }), @@ -827,17 +702,40 @@ fn merge_string_expressions<'a>( ); Some(Expression::new_template_literal( SPAN, - q, - oxc_allocator::Vec::from_iter_in( - other_expressions - .into_iter() - .map(|ex| ex.clone_in(ast_builder.allocator())), - ast_builder, - ), + quasis, + oxc_allocator::Vec::from_iter_in(list.interpolations, ast_builder), ast_builder, )) } +/// A className merged from lists of classes, each separated from the next by +/// a space +struct ClassList<'a> { + /// The text before each interpolation, and after the last + texts: Vec, + interpolations: Vec>, +} + +impl<'a> ClassList<'a> { + fn text(&mut self) -> &mut String { + let last = self.texts.len() - 1; + &mut self.texts[last] + } + + /// A space before the next list, when anything precedes it + fn separate(&mut self) { + let empty = self.interpolations.is_empty() && self.texts[0].is_empty(); + if !empty && !self.text().ends_with(' ') { + self.text().push(' '); + } + } + + fn interpolate(&mut self, expression: Expression<'a>) { + self.interpolations.push(expression); + self.texts.push(String::new()); + } +} + pub fn convert_class_name<'a>( ast_builder: &AstBuilder<'a>, class_name: &Expression<'a>, @@ -955,31 +853,9 @@ pub fn convert_style_vars<'a>( #[allow(clippy::expect_used, clippy::unwrap_used)] mod tests { use super::*; - use crate::extract_style::{ - extract_dynamic_style::ExtractDynamicStyle, extract_static_style::ExtractStaticStyle, - }; use crate::utils::expression_to_code; use oxc_allocator::Allocator; - #[test] - fn test_apply_style_order_to_all_styles() { - let mut styles = [ - ExtractStyleValue::Static(ExtractStaticStyle::new("color", "red", 0, None)), - ExtractStyleValue::Dynamic(ExtractDynamicStyle::new("padding", 0, "size", None)), - ]; - - apply_style_order_to_styles(&mut styles, Some(7)); - - let ExtractStyleValue::Static(static_style) = &styles[0] else { - panic!("expected static style"); - }; - let ExtractStyleValue::Dynamic(dynamic_style) = &styles[1] else { - panic!("expected dynamic style"); - }; - assert_eq!(static_style.style_order(), Some(7)); - assert_eq!(dynamic_style.style_order(), Some(7)); - } - #[test] fn test_merge_string_expressions_builds_template() { let allocator = Allocator::default(); diff --git a/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_complex.snap b/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_complex.snap index 1d47576b2..833afb951 100644 --- a/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_complex.snap +++ b/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_complex.snap @@ -39,9 +39,15 @@ expression: sort_styles(styles) value: "0 10px 15px -3px rgb(0 0 0 / .1),0 4px 6px -4px rgb(0 0 0 / .1)", level: 0, selector: Some( - Selector( - "&:hover", - ), + At { + kind: Media, + query: "(hover:hover)", + selector: Some( + "&:hover", + ), + outer: [], + file: None, + }, ), style_order: None, layer: None, diff --git a/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_variants.snap b/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_variants.snap index 4c4efe70d..1513c6081 100644 --- a/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_variants.snap +++ b/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_variants.snap @@ -9,9 +9,15 @@ expression: sort_styles(styles) value: "#3B82F6", level: 0, selector: Some( - Selector( - "&:hover", - ), + At { + kind: Media, + query: "(hover:hover)", + selector: Some( + "&:hover", + ), + outer: [], + file: None, + }, ), style_order: None, layer: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_classname_with_variants.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_classname_with_variants.snap index e55ce0f5f..494738fe9 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_classname_with_variants.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_classname_with_variants.snap @@ -10,9 +10,15 @@ ToBTreeSet { value: "#3B82F6", level: 0, selector: Some( - Selector( - "&:hover", - ), + At { + kind: Media, + query: "(hover:hover)", + selector: Some( + "&:hover", + ), + outer: [], + file: None, + }, ), style_order: None, layer: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_full_integration.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_full_integration.snap index 0c7d9440d..5e6e6069a 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_full_integration.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_full_integration.snap @@ -4,6 +4,16 @@ expression: "ToBTreeSet::from(extract(\"components/Card.tsx\",\nr#\"import {Box, --- ToBTreeSet { styles: { + Static( + ExtractStaticStyle { + property: "--tw-leading", + value: "1.625", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), Static( ExtractStaticStyle { property: "align-items", @@ -108,9 +118,15 @@ ToBTreeSet { value: "0 20px 25px -5px rgb(0 0 0 / .1),0 8px 10px -6px rgb(0 0 0 / .1)", level: 0, selector: Some( - Selector( - "&:hover", - ), + At { + kind: Media, + query: "(hover:hover)", + selector: Some( + "&:hover", + ), + outer: [], + file: None, + }, ), style_order: None, layer: None, @@ -350,6 +366,36 @@ ToBTreeSet { layer: None, }, ), + Static( + ExtractStaticStyle { + property: "line-height", + value: "var(--tw-leading,calc(1.25 / .875))", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "line-height", + value: "var(--tw-leading,calc(1.75 / 1.125))", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "line-height", + value: "var(--tw-leading,calc(1.75 / 1.25))", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), Static( ExtractStaticStyle { property: "margin-top", @@ -410,6 +456,12 @@ ToBTreeSet { layer: None, }, ), + Css( + ExtractCss { + css: "@property --tw-translate-x{syntax:\"*\";inherits:false;initial-value:0}@property --tw-translate-y{syntax:\"*\";inherits:false;initial-value:0}@property --tw-scale-x{syntax:\"*\";inherits:false;initial-value:1}@property --tw-scale-y{syntax:\"*\";inherits:false;initial-value:1}@property --tw-scale-z{syntax:\"*\";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:\"*\";inherits:false}@property --tw-rotate-y{syntax:\"*\";inherits:false}@property --tw-rotate-z{syntax:\"*\";inherits:false}@property --tw-skew-x{syntax:\"*\";inherits:false}@property --tw-skew-y{syntax:\"*\";inherits:false}@property --tw-leading{syntax:\"*\";inherits:false}@property --tw-content{syntax:\"*\";inherits:false;initial-value:\"\"}@property --tw-scroll-snap-strictness{syntax:\"*\";inherits:false;initial-value:proximity}", + file: "@devup-ui/tailwind", + }, + ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n// Card component with comprehensive Tailwind usage\nexport const Card = () =>
\n
\n
\n A\n
\n
\n Title\n Subtitle\n
\n
\n
\n \n Content goes here with multiple Tailwind utilities combined.\n \n
\n
;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n// Card component with comprehensive Tailwind usage\nexport const Card = () =>
\n
\n
\n A\n
\n
\n Title\n Subtitle\n
\n
\n
\n \n Content goes here with multiple Tailwind utilities combined.\n \n
\n
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_mixed_with_devup_responsive.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_mixed_with_devup_responsive.snap index ed64853b3..757687d59 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_mixed_with_devup_responsive.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_mixed_with_devup_responsive.snap @@ -50,9 +50,15 @@ ToBTreeSet { value: "0 20px 25px -5px rgb(0 0 0 / .1),0 8px 10px -6px rgb(0 0 0 / .1)", level: 0, selector: Some( - Selector( - "&:hover", - ), + At { + kind: Media, + query: "(hover:hover)", + selector: Some( + "&:hover", + ), + outer: [], + file: None, + }, ), style_order: None, layer: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_negative_values.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_negative_values.snap index 6fbafc753..b318297c2 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_negative_values.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_negative_values.snap @@ -4,6 +4,16 @@ expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@dev --- ToBTreeSet { styles: { + Static( + ExtractStaticStyle { + property: "--tw-translate-x", + value: "-50%", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), Static( ExtractStaticStyle { property: "margin", @@ -16,14 +26,20 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "transform", - value: "translateX(-50%)", + property: "translate", + value: "var(--tw-translate-x) var(--tw-translate-y)", level: 0, selector: None, style_order: None, layer: None, }, ), + Css( + ExtractCss { + css: "@property --tw-translate-x{syntax:\"*\";inherits:false;initial-value:0}@property --tw-translate-y{syntax:\"*\";inherits:false;initial-value:0}@property --tw-scale-x{syntax:\"*\";inherits:false;initial-value:1}@property --tw-scale-y{syntax:\"*\";inherits:false;initial-value:1}@property --tw-scale-z{syntax:\"*\";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:\"*\";inherits:false}@property --tw-rotate-y{syntax:\"*\";inherits:false}@property --tw-rotate-z{syntax:\"*\";inherits:false}@property --tw-skew-x{syntax:\"*\";inherits:false}@property --tw-skew-y{syntax:\"*\";inherits:false}@property --tw-leading{syntax:\"*\";inherits:false}@property --tw-content{syntax:\"*\";inherits:false;initial-value:\"\"}@property --tw-scroll-snap-strictness{syntax:\"*\";inherits:false;initial-value:proximity}", + file: "@devup-ui/tailwind", + }, + ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_with_all_variant_types.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_with_all_variant_types.snap index b116c3f90..2c1b7dcdc 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_with_all_variant_types.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_with_all_variant_types.snap @@ -4,6 +4,48 @@ expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@dev --- ToBTreeSet { styles: { + Static( + ExtractStaticStyle { + property: "--tw-scale-x", + value: "95%", + level: 0, + selector: Some( + Selector( + "&:active", + ), + ), + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "--tw-scale-y", + value: "95%", + level: 0, + selector: Some( + Selector( + "&:active", + ), + ), + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "--tw-scale-z", + value: "95%", + level: 0, + selector: Some( + Selector( + "&:active", + ), + ), + style_order: None, + layer: None, + }, + ), Static( ExtractStaticStyle { property: "background-color", @@ -24,9 +66,15 @@ ToBTreeSet { value: "#1F2937", level: 0, selector: Some( - Selector( - ":root[data-theme=dark]:hover &", - ), + At { + kind: Media, + query: "(hover:hover)", + selector: Some( + ":root[data-theme=dark] &:hover", + ), + outer: [], + file: None, + }, ), style_order: None, layer: None, @@ -38,9 +86,15 @@ ToBTreeSet { value: "#3B82F6", level: 0, selector: Some( - Selector( - "&:hover", - ), + At { + kind: Media, + query: "(hover:hover)", + selector: Some( + "&:hover", + ), + outer: [], + file: None, + }, ), style_order: None, layer: None, @@ -136,8 +190,8 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "transform", - value: "scale(.95)", + property: "scale", + value: "var(--tw-scale-x) var(--tw-scale-y)", level: 0, selector: Some( Selector( @@ -148,6 +202,12 @@ ToBTreeSet { layer: None, }, ), + Css( + ExtractCss { + css: "@property --tw-translate-x{syntax:\"*\";inherits:false;initial-value:0}@property --tw-translate-y{syntax:\"*\";inherits:false;initial-value:0}@property --tw-scale-x{syntax:\"*\";inherits:false;initial-value:1}@property --tw-scale-y{syntax:\"*\";inherits:false;initial-value:1}@property --tw-scale-z{syntax:\"*\";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:\"*\";inherits:false}@property --tw-rotate-y{syntax:\"*\";inherits:false}@property --tw-rotate-z{syntax:\"*\";inherits:false}@property --tw-skew-x{syntax:\"*\";inherits:false}@property --tw-skew-y{syntax:\"*\";inherits:false}@property --tw-leading{syntax:\"*\";inherits:false}@property --tw-content{syntax:\"*\";inherits:false;initial-value:\"\"}@property --tw-scroll-snap-strictness{syntax:\"*\";inherits:false;initial-value:proximity}", + file: "@devup-ui/tailwind", + }, + ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_with_conditional_template_literal.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_with_conditional_template_literal.snap index ff2b5b952..0a19f6804 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_with_conditional_template_literal.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_with_conditional_template_literal.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n\n hello\n\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr\"import {Box} from '@devup-ui/core'\n\n hello\n\n\",\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -34,6 +34,16 @@ ToBTreeSet { layer: None, }, ), + Static( + ExtractStaticStyle { + property: "line-height", + value: "var(--tw-leading,calc(2.25 / 1.875))", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), Static( ExtractStaticStyle { property: "padding-right", @@ -44,6 +54,12 @@ ToBTreeSet { layer: None, }, ), + Css( + ExtractCss { + css: "@property --tw-translate-x{syntax:\"*\";inherits:false;initial-value:0}@property --tw-translate-y{syntax:\"*\";inherits:false;initial-value:0}@property --tw-scale-x{syntax:\"*\";inherits:false;initial-value:1}@property --tw-scale-y{syntax:\"*\";inherits:false;initial-value:1}@property --tw-scale-z{syntax:\"*\";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:\"*\";inherits:false}@property --tw-rotate-y{syntax:\"*\";inherits:false}@property --tw-rotate-z{syntax:\"*\";inherits:false}@property --tw-skew-x{syntax:\"*\";inherits:false}@property --tw-skew-y{syntax:\"*\";inherits:false}@property --tw-leading{syntax:\"*\";inherits:false}@property --tw-content{syntax:\"*\";inherits:false;initial-value:\"\"}@property --tw-scroll-snap-strictness{syntax:\"*\";inherits:false;initial-value:proximity}", + file: "@devup-ui/tailwind", + }, + ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n
\n hello\n
;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n
\n hello\n
;\n", } diff --git a/libs/extractor/src/tailwind.rs b/libs/extractor/src/tailwind.rs index 70643aa7c..d683b5b7c 100644 --- a/libs/extractor/src/tailwind.rs +++ b/libs/extractor/src/tailwind.rs @@ -1,7 +1,9 @@ -//! Tailwind CSS class parser for devup-ui extraction +//! Tailwind CSS classes in a `className`, compiled to devup-ui styles //! -//! This module parses Tailwind CSS class strings and converts them to -//! `ExtractStyleValue` objects for integration with the devup-ui extraction system. +//! A class compiles only when every part of it is understood: its variants +//! (`md:`, `hover:`, `data-[state=open]:`, `[&>*]:` …) and its utility, which +//! becomes the declarations Tailwind CSS v4 writes for it. Any other class is +//! left as written, for whatever else defines it. // The nested if-let pattern is intentional for readability in parsing code. // Using if-let chains would make the code harder to read and modify. @@ -9,22 +11,19 @@ use std::borrow::Cow; -use css::style_selector::StyleSelector; +use css::{ + at_rule::split_at_rule_key, + style_selector::{AtRuleKind, StyleSelector}, +}; use phf::{phf_map, phf_set}; -use crate::extract_style::{ - extract_static_style::ExtractStaticStyle, extract_style_value::ExtractStyleValue, -}; +use crate::extract_style::extract_static_style::ExtractStaticStyle; + +/// A declaration: property and value +pub type Declaration = (Cow<'static, str>, Cow<'static, str>); -/// Responsive breakpoint levels matching devup-ui convention -/// 0 = base (no prefix) -/// 1 = sm (640px) -/// 2 = md (768px) -/// 3 = lg (1024px) -/// 4 = xl (1280px) -/// 5 = 2xl (1536px) -/// -/// Map of responsive prefix to level +/// Breakpoint level of each responsive variant; the breakpoints themselves +/// come from the devup theme static RESPONSIVE_PREFIX_MAP: phf::Map<&'static str, u8> = phf_map! { "sm" => 1, "md" => 2, @@ -33,363 +32,371 @@ static RESPONSIVE_PREFIX_MAP: phf::Map<&'static str, u8> = phf_map! { "2xl" => 5, }; -/// Variant prefixes that map to CSS pseudo-classes/selectors -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum TailwindVariant { - Hover, - Focus, - FocusVisible, - FocusWithin, - Active, - Visited, - Disabled, - Enabled, - Checked, - Indeterminate, - Default, - Required, - Valid, - Invalid, - InRange, - OutOfRange, - PlaceholderShown, - Autofill, - ReadOnly, - FirstChild, - LastChild, - OnlyChild, - OddChild, - EvenChild, - FirstOfType, - LastOfType, - OnlyOfType, - Empty, - Target, - Open, - Dark, - Placeholder, - Before, - After, - Selection, - Marker, - FirstLetter, - FirstLine, - Backdrop, - File, - GroupHover, - GroupFocus, - GroupActive, - GroupDisabled, - PeerHover, - PeerFocus, - PeerActive, - PeerDisabled, - PeerChecked, - PeerInvalid, - Print, - Screen, - Portrait, - Landscape, - MotionReduce, - MotionSafe, - ContrastMore, - ContrastLess, - ForcedColors, - Rtl, - Ltr, +/// Media query Tailwind guards hover styles with, so that a tap on a touch +/// screen does not leave them applied +const HOVER_QUERY: &str = "(hover: hover)"; + +/// Pseudo-class variants, which `group-*`, `peer-*` and `has-*` also take +static STATE_VARIANTS: phf::Map<&'static str, &'static str> = phf_map! { + "first" => "&:first-child", + "last" => "&:last-child", + "only" => "&:only-child", + "odd" => "&:nth-child(odd)", + "even" => "&:nth-child(even)", + "first-of-type" => "&:first-of-type", + "last-of-type" => "&:last-of-type", + "only-of-type" => "&:only-of-type", + "visited" => "&:visited", + "target" => "&:target", + "open" => "&:is([open], :popover-open, :open)", + "default" => "&:default", + "checked" => "&:checked", + "indeterminate" => "&:indeterminate", + "placeholder-shown" => "&:placeholder-shown", + "autofill" => "&:autofill", + "optional" => "&:optional", + "required" => "&:required", + "valid" => "&:valid", + "invalid" => "&:invalid", + "user-valid" => "&:user-valid", + "user-invalid" => "&:user-invalid", + "in-range" => "&:in-range", + "out-of-range" => "&:out-of-range", + "read-only" => "&:read-only", + "empty" => "&:empty", + "focus-within" => "&:focus-within", + "hover" => "&:hover", + "focus" => "&:focus", + "focus-visible" => "&:focus-visible", + "active" => "&:active", + "enabled" => "&:enabled", + "disabled" => "&:disabled", + "inert" => "&:is([inert], [inert] *)", +}; + +/// Pseudo-element variants. A selector list holding a pseudo-element some +/// browser does not know is dropped whole there, so each gets a rule of its own. +static PSEUDO_ELEMENT_VARIANTS: phf::Map<&'static str, &'static [&'static str]> = phf_map! { + "before" => &["&::before"], + "after" => &["&::after"], + "placeholder" => &["&::placeholder"], + "file" => &["&::file-selector-button"], + "backdrop" => &["&::backdrop"], + "first-letter" => &["&::first-letter"], + "first-line" => &["&::first-line"], + "details-content" => &["&::details-content"], + "selection" => &["& *::selection", "&::selection"], + "marker" => &[ + "& *::marker", + "&::marker", + "& *::-webkit-details-marker", + "&::-webkit-details-marker", + ], +}; + +/// Other selector variants +static SELECTOR_VARIANTS: phf::Map<&'static str, &'static str> = phf_map! { + // The devup theme picks the color scheme: `prefers-color-scheme` until a + // theme is set on the root + "dark" => ":root[data-theme=dark] &", + "rtl" => "&:where(:dir(rtl), [dir=\"rtl\"], [dir=\"rtl\"] *)", + "ltr" => "&:where(:dir(ltr), [dir=\"ltr\"], [dir=\"ltr\"] *)", + "*" => ":is(& > *)", + "**" => ":is(& *)", +}; + +/// Media query variants +static MEDIA_VARIANTS: phf::Map<&'static str, &'static str> = phf_map! { + "motion-safe" => "(prefers-reduced-motion: no-preference)", + "motion-reduce" => "(prefers-reduced-motion: reduce)", + "contrast-more" => "(prefers-contrast: more)", + "contrast-less" => "(prefers-contrast: less)", + "portrait" => "(orientation: portrait)", + "landscape" => "(orientation: landscape)", + "print" => "print", + "forced-colors" => "(forced-colors: active)", + "inverted-colors" => "(inverted-colors: inverted)", + "noscript" => "(scripting: none)", + "pointer-fine" => "(pointer: fine)", + "pointer-coarse" => "(pointer: coarse)", + "pointer-none" => "(pointer: none)", + "any-pointer-fine" => "(any-pointer: fine)", + "any-pointer-coarse" => "(any-pointer: coarse)", + "any-pointer-none" => "(any-pointer: none)", +}; + +/// The boolean ARIA attributes `aria-*` variants check for `true` +static ARIA_VARIANTS: phf::Set<&'static str> = phf_set! { + "busy", + "checked", + "disabled", + "expanded", + "hidden", + "pressed", + "readonly", + "required", + "selected", +}; + +/// The custom properties Tailwind registers, so that each element starts from +/// their initial value instead of inheriting its parent's +pub const PROPERTY_RULES: &str = concat!( + "@property --tw-translate-x{syntax:\"*\";inherits:false;initial-value:0}", + "@property --tw-translate-y{syntax:\"*\";inherits:false;initial-value:0}", + "@property --tw-scale-x{syntax:\"*\";inherits:false;initial-value:1}", + "@property --tw-scale-y{syntax:\"*\";inherits:false;initial-value:1}", + "@property --tw-scale-z{syntax:\"*\";inherits:false;initial-value:1}", + "@property --tw-rotate-x{syntax:\"*\";inherits:false}", + "@property --tw-rotate-y{syntax:\"*\";inherits:false}", + "@property --tw-rotate-z{syntax:\"*\";inherits:false}", + "@property --tw-skew-x{syntax:\"*\";inherits:false}", + "@property --tw-skew-y{syntax:\"*\";inherits:false}", + "@property --tw-leading{syntax:\"*\";inherits:false}", + "@property --tw-content{syntax:\"*\";inherits:false;initial-value:\"\"}", + "@property --tw-scroll-snap-strictness{syntax:\"*\";inherits:false;initial-value:proximity}", +); + +/// The stylesheet keeps [`PROPERTY_RULES`] under this name, which no source +/// file has, so that no file's update removes them +pub const PROPERTY_RULES_FILE: &str = "@devup-ui/tailwind"; + +/// What one variant adds to the condition a class applies under +#[derive(Default)] +struct Variant { + /// Breakpoint level + level: u8, + /// Selectors the element matches, any one of them; `&` is the element + selectors: Vec>, + /// At-rule the rule goes in + at_rule: Option<(AtRuleKind, Cow<'static, str>)>, + /// `::before`/`::after`, which only render with `content` + content: bool, } -impl TailwindVariant { - /// Convert variant to `StyleSelector` - pub fn to_selector(self) -> StyleSelector { - match self { - TailwindVariant::Hover => StyleSelector::Selector("&:hover".to_string()), - TailwindVariant::Focus => StyleSelector::Selector("&:focus".to_string()), - TailwindVariant::FocusVisible => StyleSelector::Selector("&:focus-visible".to_string()), - TailwindVariant::FocusWithin => StyleSelector::Selector("&:focus-within".to_string()), - TailwindVariant::Active => StyleSelector::Selector("&:active".to_string()), - TailwindVariant::Visited => StyleSelector::Selector("&:visited".to_string()), - TailwindVariant::Disabled => StyleSelector::Selector("&:disabled".to_string()), - TailwindVariant::Enabled => StyleSelector::Selector("&:enabled".to_string()), - TailwindVariant::Checked => StyleSelector::Selector("&:checked".to_string()), - TailwindVariant::Indeterminate => { - StyleSelector::Selector("&:indeterminate".to_string()) - } - TailwindVariant::Default => StyleSelector::Selector("&:default".to_string()), - TailwindVariant::Required => StyleSelector::Selector("&:required".to_string()), - TailwindVariant::Valid => StyleSelector::Selector("&:valid".to_string()), - TailwindVariant::Invalid => StyleSelector::Selector("&:invalid".to_string()), - TailwindVariant::InRange => StyleSelector::Selector("&:in-range".to_string()), - TailwindVariant::OutOfRange => StyleSelector::Selector("&:out-of-range".to_string()), - TailwindVariant::PlaceholderShown => { - StyleSelector::Selector("&:placeholder-shown".to_string()) - } - TailwindVariant::Autofill => StyleSelector::Selector("&:autofill".to_string()), - TailwindVariant::ReadOnly => StyleSelector::Selector("&:read-only".to_string()), - TailwindVariant::FirstChild => StyleSelector::Selector("&:first-child".to_string()), - TailwindVariant::LastChild => StyleSelector::Selector("&:last-child".to_string()), - TailwindVariant::OnlyChild => StyleSelector::Selector("&:only-child".to_string()), - TailwindVariant::OddChild => StyleSelector::Selector("&:nth-child(odd)".to_string()), - TailwindVariant::EvenChild => StyleSelector::Selector("&:nth-child(even)".to_string()), - TailwindVariant::FirstOfType => StyleSelector::Selector("&:first-of-type".to_string()), - TailwindVariant::LastOfType => StyleSelector::Selector("&:last-of-type".to_string()), - TailwindVariant::OnlyOfType => StyleSelector::Selector("&:only-of-type".to_string()), - TailwindVariant::Empty => StyleSelector::Selector("&:empty".to_string()), - TailwindVariant::Target => StyleSelector::Selector("&:target".to_string()), - TailwindVariant::Open => StyleSelector::Selector("&[open]".to_string()), - TailwindVariant::Dark => { - StyleSelector::Selector(":root[data-theme=dark] &".to_string()) - } - TailwindVariant::Placeholder => StyleSelector::Selector("&::placeholder".to_string()), - TailwindVariant::Before => StyleSelector::Selector("&::before".to_string()), - TailwindVariant::After => StyleSelector::Selector("&::after".to_string()), - TailwindVariant::Selection => StyleSelector::Selector("&::selection".to_string()), - TailwindVariant::Marker => StyleSelector::Selector("&::marker".to_string()), - TailwindVariant::FirstLetter => StyleSelector::Selector("&::first-letter".to_string()), - TailwindVariant::FirstLine => StyleSelector::Selector("&::first-line".to_string()), - TailwindVariant::Backdrop => StyleSelector::Selector("&::backdrop".to_string()), - TailwindVariant::File => StyleSelector::Selector("&::file-selector-button".to_string()), - // Group variants emit a single `:is()` selector matching both the - // legacy `role="group"` attribute (deprecated, removal planned for v2) - // and the new `data-group` attribute. `:is()` adopts the highest- - // specificity argument, so specificity equals the prior `*[role=group]` - // form. Keep these in sync with `StyleSelector::from("group-*")` in - // `libs/css/src/style_selector.rs`. - TailwindVariant::GroupHover => { - StyleSelector::Selector(":is([role=group],[data-group]):hover &".to_string()) - } - TailwindVariant::GroupFocus => { - StyleSelector::Selector(":is([role=group],[data-group]):focus &".to_string()) - } - TailwindVariant::GroupActive => { - StyleSelector::Selector(":is([role=group],[data-group]):active &".to_string()) - } - TailwindVariant::GroupDisabled => { - StyleSelector::Selector(":is([role=group],[data-group]):disabled &".to_string()) - } - TailwindVariant::PeerHover => StyleSelector::Selector(".peer:hover ~ &".to_string()), - TailwindVariant::PeerFocus => StyleSelector::Selector(".peer:focus ~ &".to_string()), - TailwindVariant::PeerActive => StyleSelector::Selector(".peer:active ~ &".to_string()), - TailwindVariant::PeerDisabled => { - StyleSelector::Selector(".peer:disabled ~ &".to_string()) - } - TailwindVariant::PeerChecked => { - StyleSelector::Selector(".peer:checked ~ &".to_string()) - } - TailwindVariant::PeerInvalid => { - StyleSelector::Selector(".peer:invalid ~ &".to_string()) - } - // Media variants share the `_print`/`_motionReduce`/… prop table. - TailwindVariant::Print => StyleSelector::from("print"), - TailwindVariant::Screen => StyleSelector::from("screen"), - TailwindVariant::Portrait => StyleSelector::from("portrait"), - TailwindVariant::Landscape => StyleSelector::from("landscape"), - TailwindVariant::MotionReduce => StyleSelector::from("motion-reduce"), - TailwindVariant::MotionSafe => StyleSelector::from("motion-safe"), - TailwindVariant::ContrastMore => StyleSelector::from("contrast-more"), - TailwindVariant::ContrastLess => StyleSelector::from("contrast-less"), - TailwindVariant::ForcedColors => StyleSelector::from("forced-colors"), - TailwindVariant::Rtl => StyleSelector::Selector("[dir=rtl] &".to_string()), - TailwindVariant::Ltr => StyleSelector::Selector("[dir=ltr] &".to_string()), +impl Variant { + fn selector(selector: impl Into>) -> Self { + Self { + selectors: vec![selector.into()], + ..Self::default() + } + } + + fn at_rule(kind: AtRuleKind, query: impl Into>) -> Self { + Self { + at_rule: Some((kind, query.into())), + ..Self::default() } } - /// Parse variant from string prefix - pub fn from_prefix(prefix: &str) -> Option { - match prefix { - "hover" => Some(TailwindVariant::Hover), - "focus" => Some(TailwindVariant::Focus), - "focus-visible" => Some(TailwindVariant::FocusVisible), - "focus-within" => Some(TailwindVariant::FocusWithin), - "active" => Some(TailwindVariant::Active), - "visited" => Some(TailwindVariant::Visited), - "disabled" => Some(TailwindVariant::Disabled), - "enabled" => Some(TailwindVariant::Enabled), - "checked" => Some(TailwindVariant::Checked), - "indeterminate" => Some(TailwindVariant::Indeterminate), - "default" => Some(TailwindVariant::Default), - "required" => Some(TailwindVariant::Required), - "valid" => Some(TailwindVariant::Valid), - "invalid" => Some(TailwindVariant::Invalid), - "in-range" => Some(TailwindVariant::InRange), - "out-of-range" => Some(TailwindVariant::OutOfRange), - "placeholder-shown" => Some(TailwindVariant::PlaceholderShown), - "autofill" => Some(TailwindVariant::Autofill), - "read-only" => Some(TailwindVariant::ReadOnly), - "first" => Some(TailwindVariant::FirstChild), - "last" => Some(TailwindVariant::LastChild), - "only" => Some(TailwindVariant::OnlyChild), - "odd" => Some(TailwindVariant::OddChild), - "even" => Some(TailwindVariant::EvenChild), - "first-of-type" => Some(TailwindVariant::FirstOfType), - "last-of-type" => Some(TailwindVariant::LastOfType), - "only-of-type" => Some(TailwindVariant::OnlyOfType), - "empty" => Some(TailwindVariant::Empty), - "target" => Some(TailwindVariant::Target), - "open" => Some(TailwindVariant::Open), - "dark" => Some(TailwindVariant::Dark), - "placeholder" => Some(TailwindVariant::Placeholder), - "before" => Some(TailwindVariant::Before), - "after" => Some(TailwindVariant::After), - "selection" => Some(TailwindVariant::Selection), - "marker" => Some(TailwindVariant::Marker), - "first-letter" => Some(TailwindVariant::FirstLetter), - "first-line" => Some(TailwindVariant::FirstLine), - "backdrop" => Some(TailwindVariant::Backdrop), - "file" => Some(TailwindVariant::File), - "group-hover" => Some(TailwindVariant::GroupHover), - "group-focus" => Some(TailwindVariant::GroupFocus), - "group-active" => Some(TailwindVariant::GroupActive), - "group-disabled" => Some(TailwindVariant::GroupDisabled), - "peer-hover" => Some(TailwindVariant::PeerHover), - "peer-focus" => Some(TailwindVariant::PeerFocus), - "peer-active" => Some(TailwindVariant::PeerActive), - "peer-disabled" => Some(TailwindVariant::PeerDisabled), - "peer-checked" => Some(TailwindVariant::PeerChecked), - "peer-invalid" => Some(TailwindVariant::PeerInvalid), - "print" => Some(TailwindVariant::Print), - "screen" => Some(TailwindVariant::Screen), - "portrait" => Some(TailwindVariant::Portrait), - "landscape" => Some(TailwindVariant::Landscape), - "motion-reduce" => Some(TailwindVariant::MotionReduce), - "motion-safe" => Some(TailwindVariant::MotionSafe), - "contrast-more" => Some(TailwindVariant::ContrastMore), - "contrast-less" => Some(TailwindVariant::ContrastLess), - "forced-colors" => Some(TailwindVariant::ForcedColors), - "rtl" => Some(TailwindVariant::Rtl), - "ltr" => Some(TailwindVariant::Ltr), - _ => None, + /// A state the element is in, hovering only on devices that can hover + fn state(selector: Cow<'static, str>, hover: bool) -> Self { + Self { + selectors: vec![selector], + at_rule: hover.then_some((AtRuleKind::Media, Cow::Borrowed(HOVER_QUERY))), + ..Self::default() } } } -/// Parsed Tailwind class with all components +/// A Tailwind class, compiled #[derive(Debug, Clone, PartialEq, Eq)] pub struct TailwindClass { - /// Responsive level (0=base, 1=sm, 2=md, 3=lg, 4=xl, 5=2xl) - pub responsive: u8, - /// Variants/modifiers applied - pub variants: Vec, - /// CSS property name - pub property: &'static str, - /// CSS value - pub value: Cow<'static, str>, - /// Whether this is a negative value - pub negative: bool, + /// Breakpoint level + pub level: u8, + /// The conditions the declarations apply under, one rule each + pub conditions: Vec>, + /// The declarations, in the order Tailwind writes them + pub declarations: Vec, } -/// Remove every non-overlapping occurrence of `needle` from `haystack` in place. -/// -/// Behaves exactly like `*haystack = haystack.replace(needle, "")` (left-to-right, -/// non-overlapping) but mutates the existing buffer instead of allocating a new -/// `String`. `needle` must be non-empty. -fn remove_all_substr(haystack: &mut String, needle: &str) { - debug_assert_ne!(needle, ""); - let mut search_from = 0; - while let Some(rel) = haystack[search_from..].find(needle) { - let at = search_from + rel; - haystack.replace_range(at..at + needle.len(), ""); - // Continue scanning from where the removed text was (non-overlapping, - // matching `str::replace`'s left-to-right semantics). - search_from = at; +impl TailwindClass { + /// Every declaration under every condition + pub fn styles(&self) -> impl Iterator + '_ { + self.conditions.iter().flat_map(move |condition| { + self.declarations.iter().map(move |(property, value)| { + ExtractStaticStyle::new(property, value, self.level, condition.clone()) + }) + }) + } + + /// Whether the declarations use the custom properties of [`PROPERTY_RULES`] + #[must_use] + pub fn uses_properties(&self) -> bool { + self.declarations + .iter() + .any(|(property, value)| property.starts_with("--tw-") || value.contains("var(--tw-")) } } -impl TailwindClass { - /// Convert to `ExtractStaticStyle`; `None` when the variants' media - /// conditions exclude each other (e.g. `print:screen:`). - pub fn to_static_style(&self) -> Option { - // For transform property, negative is already incorporated into the value - // (e.g., translateX(-1rem)), so don't add prefix again. Only the negative - // branch needs an owned String for the `-` prefix; the common non-negative - // path borrows `self.value` directly, dropping one allocation per class. - let value: Cow = if self.negative && self.property != "transform" { - let mut v = String::with_capacity(self.value.len() + 1); - v.push('-'); - v.push_str(&self.value); - Cow::Owned(v) - } else { - Cow::Borrowed(self.value.as_ref()) - }; +/// The variant a prefix such as `hover` or `md` stands for +fn variant(name: &str) -> Option { + if let Some(&level) = RESPONSIVE_PREFIX_MAP.get(name) { + return Some(Variant { + level, + ..Variant::default() + }); + } + if let Some(&query) = MEDIA_VARIANTS.get(name) { + return Some(Variant::at_rule(AtRuleKind::Media, query)); + } + if let Some(&selectors) = PSEUDO_ELEMENT_VARIANTS.get(name) { + return Some(Variant { + selectors: selectors.iter().copied().map(Cow::Borrowed).collect(), + content: matches!(name, "before" | "after"), + ..Variant::default() + }); + } + if let Some(&selector) = SELECTOR_VARIANTS.get(name) { + return Some(Variant::selector(selector)); + } + if let Some(selector) = state_selector(name) { + return Some(Variant::state(selector, name == "hover")); + } + if let Some(state) = name.strip_prefix("group-") { + return relation_variant(state, "group", " *"); + } + if let Some(state) = name.strip_prefix("peer-") { + return relation_variant(state, "peer", " ~ *"); + } + if let Some(condition) = name.strip_prefix("supports-") { + return supports_query(bracketed(condition)?) + .map(|query| Variant::at_rule(AtRuleKind::Supports, query)); + } + arbitrary_variant(bracketed(name)?) +} - let selector = if self.variants.is_empty() { - None - } else { - // Combine multiple variants into a single selector - Some(self.combine_selectors()?) +/// The selector of a state the element is in, which `group-*`, `peer-*` and +/// `has-*` look for on another element +fn state_selector(name: &str) -> Option> { + if let Some(&selector) = STATE_VARIANTS.get(name) { + return Some(Cow::Borrowed(selector)); + } + if let Some(attribute) = name.strip_prefix("aria-") { + if ARIA_VARIANTS.contains(attribute) { + return Some(Cow::Owned(format!("&[aria-{attribute}=\"true\"]"))); + } + return attribute_selector("aria-", bracketed(attribute)?); + } + if let Some(attribute) = name.strip_prefix("data-") { + return match bracketed(attribute) { + Some(inner) => attribute_selector("data-", inner), + None => is_name(attribute).then(|| Cow::Owned(format!("&[data-{attribute}]"))), }; - - Some(ExtractStaticStyle::new( - self.property, - value.as_ref(), - self.responsive, - selector, - )) } + if let Some(position) = name.strip_prefix("nth-") { + return nth_selector(position); + } + if let Some(inner) = name.strip_prefix("has-") { + let inner = match bracketed(inner) { + Some(selector) if !selector.contains('&') => { + format!(":is({})", decode_underscores(selector)) + } + Some(_) => return None, + // Hovering a descendant is not guarded like hovering the element + None if inner == "hover" || inner.starts_with("has-") => return None, + None => state_selector(inner)?.replacen('&', "", 1), + }; + return Some(Cow::Owned(format!("&:has({inner})"))); + } + None +} - /// Combine multiple variant selectors; `None` when they can never match together. - fn combine_selectors(&self) -> Option { - if self.variants.len() == 1 { - return Some(self.variants[0].to_selector()); - } +/// `group-*`/`peer-*`: the state of an ancestor marked `group`, or of an earlier +/// sibling marked `peer`, the marker named in `group-hover/name` +fn relation_variant(state: &str, marker: &str, relation: &str) -> Option { + let (state, name) = match state.rsplit_once('/') { + Some((state, name)) if is_name(name) => (state, Some(name)), + Some(_) => return None, + None => (state, None), + }; + let selector = state_selector(state)?; + let mut element = format!(":where(.{marker}"); + if let Some(name) = name { + element.push_str("\\/"); + element.push_str(name); + } + element.push(')'); + Some(Variant::state( + Cow::Owned(format!( + "&:is({}{relation})", + selector.replacen('&', &element, 1) + )), + state == "hover", + )) +} - // For multiple variants, combine them - // e.g., dark:hover: becomes :root[data-theme=dark] &:hover - let mut selector_str = String::new(); - let mut at_rules = vec![]; - - for variant in &self.variants { - let sel = variant.to_selector(); - match sel { - StyleSelector::Selector(s) => { - if selector_str.is_empty() { - selector_str = s; - } else { - // Combine selectors in place, byte-identical to the previous - // `format!("{}{}", selector_str.replace(" &", ""), s.replace('&', ""))` - // form but without the two throwaway `String`s and the `format!` - // buffer: drop all " &" from the accumulator in place, then append - // `s` with every '&' skipped. - remove_all_substr(&mut selector_str, " &"); - for part in s.split('&') { - selector_str.push_str(part); - } - // `remove_all_substr` above stripped every " &" and each - // appended `part` comes from `s.split('&')`, so NO '&' char - // remains in the accumulator here — hence `contains(" &")` - // is provably always false and the only live check is the - // trailing-space one. Output stays byte-identical (locked by - // `test_combine_selectors_byte_identical_to_prior_impl`). - if !selector_str.ends_with(" &") { - selector_str.push_str(" &"); - } - } - } - StyleSelector::At { kind, query, .. } => at_rules.push((kind, query)), - // SAFETY: TailwindVariant::to_selector() never produces Global. - // This arm exists only for exhaustive matching. If reached, it indicates - // a bug where a new TailwindVariant was added that produces Global. - StyleSelector::Global(_, _) => { - unreachable!("TailwindVariant should not produce Global selector") - } +/// `[name=value]` of an `aria-[…]`/`data-[…]` variant, the value quoted like +/// Tailwind quotes it +fn attribute_selector(prefix: &str, inner: &str) -> Option> { + let inner = decode_underscores(inner); + let selector = match inner.split_once('=') { + Some((name, value)) if is_name(name) && !value.is_empty() => { + if is_quoted(value) { + format!("&[{prefix}{name}={value}]") + } else if value.contains(['"', '\'', '\\', ']']) { + return None; + } else { + format!("&[{prefix}{name}=\"{value}\"]") } } + None if is_name(&inner) => format!("&[{prefix}{inner}]"), + _ => return None, + }; + Some(Cow::Owned(selector)) +} - // Every media variant applies, so fold them all in order (`print:motion-reduce:` - // becomes `@media print and (prefers-reduced-motion:reduce)`). - let mut selector = - (!selector_str.is_empty()).then_some(StyleSelector::Selector(selector_str)); - for (kind, query) in at_rules { - selector = Some(StyleSelector::nest_at_rule( - selector.as_ref(), - kind, - &query, - )?); - } - selector +/// `nth-3`, `nth-last-3`, `nth-of-type-3`, `nth-last-of-type-3` and `nth-[…]` +fn nth_selector(position: &str) -> Option> { + let (pseudo, position) = if let Some(position) = position.strip_prefix("last-of-type-") { + ("nth-last-of-type", position) + } else if let Some(position) = position.strip_prefix("of-type-") { + ("nth-of-type", position) + } else if let Some(position) = position.strip_prefix("last-") { + ("nth-last-child", position) + } else { + ("nth-child", position) + }; + let position = match bracketed(position) { + Some(inner) => decode_underscores(inner), + None if position.parse::().is_ok_and(|n| n > 0) => position.to_string(), + None => return None, + }; + Some(Cow::Owned(format!("&:{pseudo}({position})"))) +} + +/// The condition of `supports-[…]`: a declaration such as `display:grid`, or a +/// parenthesized condition +fn supports_query(inner: &str) -> Option { + let condition = decode_underscores(inner); + if condition.starts_with('(') { + Some(condition) + } else if condition.contains(':') { + Some(format!("({condition})")) + } else { + None } } +/// A `[…]` variant: selectors with `&` for the element, or an at-rule +fn arbitrary_variant(inner: &str) -> Option { + let text = decode_underscores(inner); + if text.starts_with('@') { + let (kind, query) = split_at_rule_key(&text)?; + return Some(Variant::at_rule(kind, query.to_string())); + } + let selectors: Vec> = split_top_level(&text, ',') + .into_iter() + .map(|selector| Cow::Owned(selector.trim().to_string())) + .collect(); + selectors + .iter() + .all(|selector| selector.contains('&')) + .then_some(Variant { + selectors, + ..Variant::default() + }) +} + /// Tailwind color values static TAILWIND_COLORS: phf::Map<&'static str, &'static str> = phf_map! { // Inherit/Current/Transparent @@ -739,16 +746,16 @@ static SPACING_SCALE: phf::Map<&'static str, &'static str> = phf_map! { "fit" => "fit-content", }; -/// Font size scale +/// Font size scale: size and the line height relative to it static FONT_SIZE_SCALE: phf::Map<&'static str, (&'static str, &'static str)> = phf_map! { - "xs" => ("0.75rem", "1rem"), - "sm" => ("0.875rem", "1.25rem"), - "base" => ("1rem", "1.5rem"), - "lg" => ("1.125rem", "1.75rem"), - "xl" => ("1.25rem", "1.75rem"), - "2xl" => ("1.5rem", "2rem"), - "3xl" => ("1.875rem", "2.25rem"), - "4xl" => ("2.25rem", "2.5rem"), + "xs" => ("0.75rem", "calc(1 / 0.75)"), + "sm" => ("0.875rem", "calc(1.25 / 0.875)"), + "base" => ("1rem", "calc(1.5 / 1)"), + "lg" => ("1.125rem", "calc(1.75 / 1.125)"), + "xl" => ("1.25rem", "calc(1.75 / 1.25)"), + "2xl" => ("1.5rem", "calc(2 / 1.5)"), + "3xl" => ("1.875rem", "calc(2.25 / 1.875)"), + "4xl" => ("2.25rem", "calc(2.5 / 2.25)"), "5xl" => ("3rem", "1"), "6xl" => ("3.75rem", "1"), "7xl" => ("4.5rem", "1"), @@ -860,454 +867,1064 @@ static EASE_SCALE: phf::Map<&'static str, &'static str> = phf_map! { "in-out" => "cubic-bezier(0.4, 0, 0.2, 1)", }; -/// Check if a string contains Tailwind classes -pub fn has_tailwind_classes(class_str: &str) -> bool { - // Simple heuristic: if it looks like a Tailwind class pattern - for part in class_str.split_whitespace() { - if is_likely_tailwind_class(part) { - return true; +/// The class compiled, or `None` when it is not a Tailwind class understood in +/// full, which must then stay in the className as written +#[must_use] +pub fn parse_class(class: &str) -> Option { + let mut parts = split_top_level(class, ':'); + let mut declarations = utility(parts.pop()?)?; + let mut level = 0; + let mut selectors = vec![String::from("&")]; + let mut at_rules = Vec::new(); + let mut content = false; + // Variants nest left to right, the first one outermost + for name in parts { + let variant = variant(name)?; + level = level.max(variant.level); + if !variant.selectors.is_empty() { + selectors = selectors + .iter() + .flat_map(|outer| { + variant + .selectors + .iter() + .map(move |selector| selector.replace('&', outer)) + }) + .collect(); } + at_rules.extend(variant.at_rule); + content |= variant.content; + } + if content + && !declarations + .iter() + .any(|(property, _)| property == "content") + { + declarations.insert( + 0, + (Cow::Borrowed("content"), Cow::Borrowed("var(--tw-content)")), + ); } - false + let conditions = selectors + .into_iter() + .map(|selector| { + let mut condition = (selector != "&").then_some(StyleSelector::Selector(selector)); + for (kind, query) in &at_rules { + // `None` when the media queries can never all match + condition = Some(StyleSelector::nest_at_rule( + condition.as_ref(), + *kind, + query, + )?); + } + Some(condition) + }) + .collect::>>()?; + Some(TailwindClass { + level, + conditions, + declarations, + }) } -/// Common Tailwind prefixes -static TAILWIND_PREFIXES: &[&str] = &[ - "bg-", - "text-", - "font-", - "p-", - "px-", - "py-", - "pt-", - "pr-", - "pb-", - "pl-", - "m-", - "mx-", - "my-", - "mt-", - "mr-", - "mb-", - "ml-", - "w-", - "h-", - "min-w-", - "max-w-", - "min-h-", - "max-h-", - "flex", - "grid", - "block", - "inline", - "hidden", - "absolute", - "relative", - "fixed", - "sticky", - "top-", - "right-", - "bottom-", - "left-", - "inset-", - "z-", - "opacity-", - "rounded", - "border", - "shadow", - "gap-", - "space-", - "items-", - "justify-", - "content-", - "self-", - "order-", - "col-", - "row-", - "overflow-", - "object-", - "aspect-", - "transition", - "duration-", - "ease-", - "delay-", - "animate-", - "cursor-", - "select-", - "resize", - "appearance-", - "outline", - "ring", - "fill-", - "stroke-", - "sr-", - "not-sr-", - "container", - "columns-", - "break-", - "decoration-", - "underline", - "overline", - "line-through", - "no-underline", - "uppercase", - "lowercase", - "capitalize", - "normal-case", - "truncate", - "leading-", - "tracking-", - "list-", - "align-", - "whitespace-", - "hyphens-", - "blur", - "brightness-", - "contrast-", - "grayscale", - "invert", - "saturate-", - "sepia", - "drop-shadow", - "backdrop-", - "scale-", - "rotate-", - "translate-", - "skew-", - "origin-", - "accent-", - "caret-", - "scroll-", - "snap-", - "touch-", - "will-change-", - "table", - "clear-", - "float-", - "isolate", - "isolation-", - "mix-blend-", - "bg-blend-", - "divide-", - "place-", - "grow", - "shrink", - "basis-", -]; +/// Properties a leading `-` negates +static NEGATABLE_PROPERTIES: phf::Set<&'static str> = phf_set! { + "margin", + "margin-inline", + "margin-block", + "margin-top", + "margin-right", + "margin-bottom", + "margin-left", + "margin-inline-start", + "margin-inline-end", + "inset", + "inset-inline", + "inset-block", + "top", + "right", + "bottom", + "left", + "z-index", + "order", + "letter-spacing", + "scroll-margin", +}; -/// Exact matches for utility classes without values -static EXACT_TAILWIND_CLASSES: phf::Set<&'static str> = phf_set! { - "flex", - "inline-flex", - "grid", - "inline-grid", - "block", - "inline-block", - "inline", - "contents", - "flow-root", - "hidden", - "invisible", - "visible", - "collapse", - "absolute", - "relative", - "fixed", - "sticky", - "static", - "isolate", - "isolation-auto", - "container", - "truncate", - "uppercase", - "lowercase", - "capitalize", - "normal-case", - "italic", - "not-italic", - "underline", - "overline", - "line-through", - "no-underline", - "antialiased", - "subpixel-antialiased", - "ordinal", - "slashed-zero", - "lining-nums", - "oldstyle-nums", - "proportional-nums", - "tabular-nums", - "diagonal-fractions", - "stacked-fractions", - "sr-only", - "not-sr-only", - "resize", - "resize-none", - "resize-y", - "resize-x", - "transition", - "transition-none", - "transition-all", - "transition-colors", - "transition-opacity", - "transition-shadow", - "transition-transform", - "animate-none", - "animate-spin", - "animate-ping", - "animate-pulse", - "animate-bounce", - "grayscale", - "grayscale-0", - "invert", - "invert-0", - "sepia", - "sepia-0", - "backdrop-blur", - "backdrop-blur-none", - "backdrop-grayscale", - "backdrop-grayscale-0", - "backdrop-invert", - "backdrop-invert-0", - "backdrop-sepia", - "backdrop-sepia-0", - "table", - "table-caption", - "table-cell", - "table-column", - "table-column-group", - "table-footer-group", - "table-header-group", - "table-row-group", - "table-row", - "border-collapse", - "border-separate", - "grow", - "grow-0", - "shrink", - "shrink-0", - "rounded", - "rounded-none", - "rounded-sm", - "rounded-md", - "rounded-lg", - "rounded-xl", - "rounded-2xl", - "rounded-3xl", - "rounded-full", - "border", - "border-0", - "border-2", - "border-4", - "border-8", - "shadow", - "shadow-sm", - "shadow-md", - "shadow-lg", - "shadow-xl", - "shadow-2xl", - "shadow-inner", - "shadow-none", - "outline", - "outline-none", - "outline-dashed", - "outline-dotted", - "outline-double", - "ring", - "ring-0", - "ring-1", - "ring-2", - "ring-4", - "ring-8", - "ring-inset", - "blur", - "blur-none", - "blur-sm", - "blur-md", - "blur-lg", - "blur-xl", - "blur-2xl", - "blur-3xl", +/// The declarations of a utility, which a leading `-` negates +fn utility(name: &str) -> Option> { + let (negative, name) = name + .strip_prefix('-') + .map_or((false, name), |name| (true, name)); + if name.starts_with('[') { + return if negative { + None + } else { + arbitrary_property(name) + }; + } + if let Some((root, value, variable)) = split_arbitrary(name) { + return arbitrary_utility(root, value, variable, negative); + } + if let Some(declarations) = compound_utility(name, negative) { + return Some(declarations); + } + let (property, value) = parse_utility(name)?; + let value = if negative { + negate(property, &value)? + } else { + value + }; + Some(vec![(Cow::Borrowed(property), value)]) +} + +/// `value` negated, for the properties Tailwind lets a `-` negate +fn negate(property: &str, value: &str) -> Option> { + (NEGATABLE_PROPERTIES.contains(property) + && value.starts_with(|c: char| c.is_ascii_digit() || c == '.')) + .then(|| Cow::Owned(format!("-{value}"))) +} + +/// `[property:value]` +fn arbitrary_property(name: &str) -> Option> { + let (property, value) = bracketed(name)?.split_once(':')?; + if !is_property_name(property) { + return None; + } + let value = decode_arbitrary_value(value); + (!value.trim().is_empty()).then(|| vec![(Cow::Owned(property.to_string()), Cow::Owned(value))]) +} + +/// `root-[value]`, or `root-(--variable)` for `var(--variable)` +fn split_arbitrary(name: &str) -> Option<(&str, &str, bool)> { + let (open, variable) = match name.as_bytes().last()? { + b']' => ('[', false), + b')' => ('(', true), + _ => return None, + }; + let start = name.find(open)?; + let root = name[..start].strip_suffix('-')?; + let value = &name[start + 1..name.len() - 1]; + (!root.is_empty() && !value.is_empty() && is_balanced(value)).then_some((root, value, variable)) +} + +/// Roots a leading `-` negates with an arbitrary value +static NEGATABLE_ROOTS: phf::Set<&'static str> = phf_set! { + "m", "mx", "my", "mt", "mr", "mb", "ml", "ms", "me", + "inset", "inset-x", "inset-y", "start", "end", "top", "right", "bottom", "left", + "z", "order", "tracking", "indent", + "scroll-m", "scroll-mx", "scroll-my", "scroll-mt", "scroll-mr", "scroll-mb", "scroll-ml", + "scroll-ms", "scroll-me", + "translate", "translate-x", "translate-y", "rotate", "scale", "scale-x", "scale-y", + "skew", "skew-x", "skew-y", }; -/// Check if a single class looks like a Tailwind utility -fn is_likely_tailwind_class(class: &str) -> bool { - // Strip any responsive/variant prefixes - let class = class - .split(':') - .next_back() - .unwrap_or(class) - .trim_start_matches('-'); +/// Data types a `[type:value]`/`(type:--variable)` hint names +static DATA_TYPES: phf::Set<&'static str> = phf_set! { + "color", "length", "percentage", "number", "integer", "url", "image", "position", + "bg-size", "line-width", "family-name", "generic-name", "absolute-size", "relative-size", + "angle", "vector", +}; - if EXACT_TAILWIND_CLASSES.contains(class) { - return true; +/// The declarations of `root-[value]`/`root-(--variable)` +fn arbitrary_utility( + root: &str, + value: &str, + variable: bool, + negative: bool, +) -> Option> { + let (hint, value) = match value.split_once(':') { + Some((hint, value)) if DATA_TYPES.contains(hint) => (Some(hint), value), + // A type Tailwind does not know + Some((hint, _)) + if hint + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte == b'-') => + { + return None; + } + _ => (None, value), + }; + let mut value = if variable { + if !value.starts_with("--") { + return None; + } + format!("var({value})") + } else { + decode_arbitrary_value(value) + }; + if value.trim().is_empty() { + return None; } + if negative { + if !NEGATABLE_ROOTS.contains(root) { + return None; + } + value = format!("calc({value} * -1)"); + } + arbitrary_declarations(root, hint, value) +} - for prefix in TAILWIND_PREFIXES { - if let Some(value_part) = class.strip_prefix(prefix) { - // For prefixes that end with '-', validate the value part - if prefix.ends_with('-') { - if is_valid_tailwind_value(value_part) { - return true; - } +/// Roots whose arbitrary value goes to one property +static ARBITRARY_PROPERTIES: phf::Map<&'static str, &'static str> = phf_map! { + "w" => "width", + "h" => "height", + "min-w" => "min-width", + "max-w" => "max-width", + "min-h" => "min-height", + "max-h" => "max-height", + "p" => "padding", + "px" => "padding-inline", + "py" => "padding-block", + "pt" => "padding-top", + "pr" => "padding-right", + "pb" => "padding-bottom", + "pl" => "padding-left", + "ps" => "padding-inline-start", + "pe" => "padding-inline-end", + "m" => "margin", + "mx" => "margin-inline", + "my" => "margin-block", + "mt" => "margin-top", + "mr" => "margin-right", + "mb" => "margin-bottom", + "ml" => "margin-left", + "ms" => "margin-inline-start", + "me" => "margin-inline-end", + "inset" => "inset", + "inset-x" => "inset-inline", + "inset-y" => "inset-block", + "start" => "inset-inline-start", + "end" => "inset-inline-end", + "top" => "top", + "right" => "right", + "bottom" => "bottom", + "left" => "left", + "gap" => "gap", + "gap-x" => "column-gap", + "gap-y" => "row-gap", + "z" => "z-index", + "order" => "order", + "opacity" => "opacity", + "aspect" => "aspect-ratio", + "columns" => "columns", + "basis" => "flex-basis", + "flex" => "flex", + "grid-cols" => "grid-template-columns", + "grid-rows" => "grid-template-rows", + "col" => "grid-column", + "row" => "grid-row", + "col-start" => "grid-column-start", + "col-end" => "grid-column-end", + "row-start" => "grid-row-start", + "row-end" => "grid-row-end", + "auto-cols" => "grid-auto-columns", + "auto-rows" => "grid-auto-rows", + "tracking" => "letter-spacing", + "indent" => "text-indent", + "underline-offset" => "text-underline-offset", + "duration" => "transition-duration", + "delay" => "transition-delay", + "ease" => "transition-timing-function", + "origin" => "transform-origin", + "rotate" => "rotate", + "scale" => "scale", + "cursor" => "cursor", + "list" => "list-style-type", + "will-change" => "will-change", + "object" => "object-position", + "scroll-m" => "scroll-margin", + "scroll-mx" => "scroll-margin-inline", + "scroll-my" => "scroll-margin-block", + "scroll-mt" => "scroll-margin-top", + "scroll-mr" => "scroll-margin-right", + "scroll-mb" => "scroll-margin-bottom", + "scroll-ml" => "scroll-margin-left", + "scroll-ms" => "scroll-margin-inline-start", + "scroll-me" => "scroll-margin-inline-end", + "scroll-p" => "scroll-padding", + "scroll-px" => "scroll-padding-inline", + "scroll-py" => "scroll-padding-block", + "scroll-pt" => "scroll-padding-top", + "scroll-pr" => "scroll-padding-right", + "scroll-pb" => "scroll-padding-bottom", + "scroll-pl" => "scroll-padding-left", + "scroll-ps" => "scroll-padding-inline-start", + "scroll-pe" => "scroll-padding-inline-end", + "fill" => "fill", + "accent" => "accent-color", + "caret" => "caret-color", +}; + +/// Corners each `rounded-*` side rounds +static RADIUS_SIDES: phf::Map<&'static str, &'static [&'static str]> = phf_map! { + "" => &["border-radius"], + "t" => &["border-top-left-radius", "border-top-right-radius"], + "r" => &["border-top-right-radius", "border-bottom-right-radius"], + "b" => &["border-bottom-right-radius", "border-bottom-left-radius"], + "l" => &["border-top-left-radius", "border-bottom-left-radius"], + "s" => &["border-start-start-radius", "border-end-start-radius"], + "e" => &["border-start-end-radius", "border-end-end-radius"], + "tl" => &["border-top-left-radius"], + "tr" => &["border-top-right-radius"], + "br" => &["border-bottom-right-radius"], + "bl" => &["border-bottom-left-radius"], + "ss" => &["border-start-start-radius"], + "se" => &["border-start-end-radius"], + "ee" => &["border-end-end-radius"], + "es" => &["border-end-start-radius"], +}; + +/// Width and color property of each `border-*` side +static BORDER_SIDES: phf::Map<&'static str, (&'static str, &'static str)> = phf_map! { + "" => ("border-width", "border-color"), + "x" => ("border-inline-width", "border-inline-color"), + "y" => ("border-block-width", "border-block-color"), + "t" => ("border-top-width", "border-top-color"), + "r" => ("border-right-width", "border-right-color"), + "b" => ("border-bottom-width", "border-bottom-color"), + "l" => ("border-left-width", "border-left-color"), + "s" => ("border-inline-start-width", "border-inline-start-color"), + "e" => ("border-inline-end-width", "border-inline-end-color"), +}; + +/// `translate` reading the translation of each axis +const TRANSLATE: &str = "var(--tw-translate-x) var(--tw-translate-y)"; +/// `scale` reading the scale of each axis +const SCALE: &str = "var(--tw-scale-x) var(--tw-scale-y)"; +/// `transform` composing the rotations and skews no other property takes +const TRANSFORM: &str = "var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)"; + +/// What an arbitrary value is, for the roots that take several kinds +#[derive(Clone, Copy, PartialEq, Eq)] +enum ValueType { + Length, + Number, + Image, + Color, + Position, + Size, + Family, +} + +/// The type a hint names, or that the value has +fn value_type(hint: Option<&str>, value: &str) -> Option { + Some(match hint { + Some("length" | "percentage" | "line-width" | "absolute-size" | "relative-size") => { + ValueType::Length + } + Some("number" | "integer") => ValueType::Number, + Some("image" | "url") => ValueType::Image, + Some("color") => ValueType::Color, + Some("position") => ValueType::Position, + Some("bg-size") => ValueType::Size, + Some("family-name" | "generic-name") => ValueType::Family, + Some(_) => return None, + None if is_number(value) => ValueType::Number, + None if is_length(value) => ValueType::Length, + None if is_image(value) => ValueType::Image, + // Tailwind reads anything else, such as `var(--x)`, as a color + None => ValueType::Color, + }) +} + +/// The declarations of an arbitrary value under `root` +fn arbitrary_declarations( + root: &str, + hint: Option<&str>, + value: String, +) -> Option> { + if let Some(&property) = ARBITRARY_PROPERTIES.get(root) { + return Some(vec![(Cow::Borrowed(property), Cow::Owned(value))]); + } + if let Some(side) = root.strip_prefix("rounded") { + let side = if side.is_empty() { + side + } else { + side.strip_prefix('-')? + }; + return Some(each(RADIUS_SIDES.get(side)?, &value)); + } + match root { + "size" => return Some(each(&["width", "height"], &value)), + "col-span" | "row-span" => { + let property = if root == "col-span" { + "grid-column" } else { - // Prefix without dash (like "flex", "grid") - exact prefix match is enough - return true; - } + "grid-row" + }; + return Some(vec![( + Cow::Borrowed(property), + Cow::Owned(format!("span {value} / span {value}")), + )]); + } + "content" => { + return Some(vec![ + (Cow::Borrowed("--tw-content"), Cow::Owned(value)), + (Cow::Borrowed("content"), Cow::Borrowed("var(--tw-content)")), + ]); } + "leading" => return Some(each(&["--tw-leading", "line-height"], &value)), + "translate" | "translate-x" | "translate-y" => { + return Some(axes(root, "translate", &value, TRANSLATE)); + } + "scale-x" | "scale-y" => return Some(axes(root, "scale", &value, SCALE)), + "skew" | "skew-x" | "skew-y" => return Some(skew(root, &value)), + "blur" | "brightness" | "contrast" | "saturate" => { + return Some(vec![function("filter", root, &value)]); + } + "backdrop-blur" => return Some(vec![function("backdrop-filter", "blur", &value)]), + _ => {} } + let value_type = value_type(hint, &value)?; + let property = if let Some(side) = root.strip_prefix("border") { + let side = if side.is_empty() { + side + } else { + side.strip_prefix('-')? + }; + let &(width, color) = BORDER_SIDES.get(side)?; + let line_widths = hint.is_none() + && value + .split_ascii_whitespace() + .all(|part| is_length(part) || matches!(part, "thin" | "medium" | "thick")); + if line_widths || value_type == ValueType::Length { + width + } else if value_type == ValueType::Color { + color + } else { + return None; + } + } else { + match (root, value_type) { + ("text", ValueType::Length) => "font-size", + ("text", ValueType::Color) + if hint.is_none() && FONT_SIZE_KEYWORDS.contains(value.as_str()) => + { + "font-size" + } + ("text", ValueType::Color) => "color", + ("bg", ValueType::Image) => "background-image", + ("bg", ValueType::Color) => "background-color", + ("bg", ValueType::Position) => "background-position", + // `bg-[length:…]` sizes the background; a bare length is ambiguous + ("bg", ValueType::Size | ValueType::Length) if hint.is_some() => "background-size", + ("outline", ValueType::Length) => "outline-width", + ("outline", ValueType::Color) => "outline-color", + ("stroke", ValueType::Length | ValueType::Number) => "stroke-width", + ("stroke", ValueType::Color) => "stroke", + ("decoration", ValueType::Length) => "text-decoration-thickness", + ("decoration", ValueType::Color) => "text-decoration-color", + ("font", ValueType::Number) => "font-weight", + ("font", ValueType::Color | ValueType::Family) => "font-family", + _ => return None, + } + }; + Some(vec![(Cow::Borrowed(property), Cow::Owned(value))]) +} - // Check for arbitrary value syntax - if class.contains('[') && class.contains(']') { - return true; - } +/// `value` for every property in `properties` +fn each(properties: &[&'static str], value: &str) -> Vec { + properties + .iter() + .map(|&property| (Cow::Borrowed(property), Cow::Owned(value.to_string()))) + .collect() +} - false +/// `property: name(value)` +fn function(property: &'static str, name: &str, value: &str) -> Declaration { + ( + Cow::Borrowed(property), + Cow::Owned(format!("{name}({value})")), + ) } -/// Exact-match value keywords for Tailwind utilities -static TAILWIND_VALUE_KEYWORDS: phf::Set<&'static str> = phf_set! { - "auto", - "full", - "screen", - "min", - "max", - "fit", - "px", - "none", - "inherit", - "current", - "transparent", - "black", - "white", -}; +/// `value` on the axes `root` names, each in its `--tw--` +/// variable, which `property` reads as `composite` +fn axes(root: &str, name: &'static str, value: &str, composite: &'static str) -> Vec { + let axes: &[&str] = match root.strip_prefix(name) { + Some("-x") => &["x"], + Some("-y") => &["y"], + _ => &["x", "y"], + }; + let mut declarations: Vec = axes + .iter() + .map(|axis| { + ( + Cow::Owned(format!("--tw-{name}-{axis}")), + Cow::Owned(value.to_string()), + ) + }) + .collect(); + declarations.push((Cow::Borrowed(name), Cow::Borrowed(composite))); + declarations +} + +/// The skew `root` names by `angle`, composed into `transform` +fn skew(root: &str, angle: &str) -> Vec { + let axes: &[(&str, &str)] = match root { + "skew-x" => &[("x", "X")], + "skew-y" => &[("y", "Y")], + _ => &[("x", "X"), ("y", "Y")], + }; + let mut declarations: Vec = axes + .iter() + .map(|(axis, function)| { + ( + Cow::Owned(format!("--tw-skew-{axis}")), + Cow::Owned(format!("skew{function}({angle})")), + ) + }) + .collect(); + declarations.push((Cow::Borrowed("transform"), Cow::Borrowed(TRANSFORM))); + declarations +} -/// Exact-match size suffixes for Tailwind utilities -static TAILWIND_SIZE_KEYWORDS: phf::Set<&'static str> = phf_set! { - "xs", "sm", "md", "lg", "xl", "2xl", "3xl", "4xl", "5xl", "6xl", "7xl", +/// Line height of each `leading-*` name +static LEADING_SCALE: phf::Map<&'static str, &'static str> = phf_map! { + "none" => "1", + "tight" => "1.25", + "snug" => "1.375", + "normal" => "1.5", + "relaxed" => "1.625", + "loose" => "2", }; -/// Tailwind color-family names. Loop-invariant lookup set: promoted from a -/// per-call `[&str; 22]` stack array + linear `strip_prefix` scan to a -/// module-level `phf::Set` for an O(1) first-segment `contains`. -static TAILWIND_COLOR_NAMES: phf::Set<&'static str> = phf_set! { - "slate", "gray", "zinc", "neutral", "stone", "red", "orange", "amber", "yellow", "lime", - "green", "emerald", "teal", "cyan", "sky", "blue", "indigo", "violet", "purple", "fuchsia", - "pink", "rose", +/// Font-size keywords `text-[…]` takes for a size +static FONT_SIZE_KEYWORDS: phf::Set<&'static str> = phf_set! { + "xx-small", "x-small", "small", "medium", "large", "x-large", "xx-large", "xxx-large", + "larger", "smaller", }; -/// Check if a value part looks like a valid Tailwind value -fn is_valid_tailwind_value(value: &str) -> bool { - if value.is_empty() { - return false; +/// Utilities Tailwind writes with several declarations, and those a leading +/// `-` negates in a way of their own +fn compound_utility(name: &str, negative: bool) -> Option> { + if let Some(declarations) = transform_utility(name, negative) { + return Some(declarations); + } + if negative { + return None; } + let fixed: &[(&'static str, &'static str)] = match name { + "truncate" => &[ + ("overflow", "hidden"), + ("text-overflow", "ellipsis"), + ("white-space", "nowrap"), + ], + "sr-only" => &[ + ("position", "absolute"), + ("width", "1px"), + ("height", "1px"), + ("padding", "0"), + ("margin", "-1px"), + ("overflow", "hidden"), + ("clip-path", "inset(50%)"), + ("white-space", "nowrap"), + ("border-width", "0"), + ], + "not-sr-only" => &[ + ("position", "static"), + ("width", "auto"), + ("height", "auto"), + ("padding", "0"), + ("margin", "0"), + ("overflow", "visible"), + ("clip-path", "none"), + ("white-space", "normal"), + ], + _ => &[], + }; + if !fixed.is_empty() { + return Some( + fixed + .iter() + .map(|&(property, value)| (Cow::Borrowed(property), Cow::Borrowed(value))) + .collect(), + ); + } + if let Some(size) = name.strip_prefix("size-") { + return Some(each(&["width", "height"], sizing_value(size)?)); + } + if let Some(declarations) = radius_utility(name) { + return Some(declarations); + } + if let Some(size) = name.strip_prefix("text-") { + if let Some(declarations) = font_size_utility(size) { + return Some(declarations); + } + } + if let Some(leading) = name.strip_prefix("leading-") { + let value = line_height_value(leading)?; + return Some(vec![ + (Cow::Borrowed("--tw-leading"), value.clone()), + (Cow::Borrowed("line-height"), value), + ]); + } + None +} - // Ordered cheapest-and-most-common first so the dominant numeric/color - // utilities short-circuit before the two phf keyword probes. Every branch - // is a side-effect-free predicate returning `true`, so reordering cannot - // change the boolean result for any input. +/// A spacing value `size-*` and `translate-*` take +fn sizing_value(size: &str) -> Option<&'static str> { + if matches!(size, "screen" | "svw" | "lvw" | "dvw") { + return None; + } + SPACING_SCALE.get(size).copied() +} - // Arbitrary value syntax [...] - if value.starts_with('[') && value.ends_with(']') { - return true; +/// `rounded`, `rounded-lg`, `rounded-t`, `rounded-t-lg`, … +fn radius_utility(name: &str) -> Option> { + let rest = name.strip_prefix("rounded")?; + let (side, size) = if rest.is_empty() { + ("", "") + } else { + let rest = rest.strip_prefix('-')?; + match rest.split_once('-') { + Some((side, size)) if RADIUS_SIDES.contains_key(side) => (side, size), + _ if RADIUS_SIDES.contains_key(rest) => (rest, ""), + _ => ("", rest), + } + }; + let value = BORDER_RADIUS_SCALE.get(size)?; + Some(each(RADIUS_SIDES.get(side)?, value)) +} + +/// `text-sm`, with the line height of the size unless `text-sm/6` sets one +fn font_size_utility(name: &str) -> Option> { + let (size, line_height) = match name.split_once('/') { + Some((size, line_height)) => (size, Some(line_height_value(line_height)?)), + None => (name, None), + }; + let &(font_size, default_line_height) = FONT_SIZE_SCALE.get(size)?; + // `leading-*` sets `--tw-leading`, which wins over the size's line height + let line_height = line_height + .unwrap_or_else(|| Cow::Owned(format!("var(--tw-leading, {default_line_height})"))); + Some(vec![ + (Cow::Borrowed("font-size"), Cow::Borrowed(font_size)), + (Cow::Borrowed("line-height"), line_height), + ]) +} + +/// The line height of `leading-*` and of the `/…` after a font size +fn line_height_value(name: &str) -> Option> { + if let Some(&value) = LEADING_SCALE.get(name) { + return Some(Cow::Borrowed(value)); } + if let Some(inner) = bracketed(name) { + let value = decode_arbitrary_value(inner); + return (!value.trim().is_empty()).then_some(Cow::Owned(value)); + } + if let Some(variable) = name + .strip_prefix('(') + .and_then(|name| name.strip_suffix(')')) + { + return variable + .starts_with("--") + .then(|| Cow::Owned(format!("var({variable})"))); + } + if is_number(name) { + return SPACING_SCALE.get(name).map(|&value| Cow::Borrowed(value)); + } + None +} - // Numeric values (including decimals like 0.5, 1.5) — the dominant case - // (`p-4`→`4`, `z-10`→`10`). - // Safe: `value.is_empty()` returns early above, so `value` has at least one char. - if value.starts_with(|c: char| c.is_ascii_digit()) { - return true; +/// Translate, rotate, scale and skew, which Tailwind writes to properties of +/// their own so that they compose +fn transform_utility(name: &str, negative: bool) -> Option> { + let sign = if negative { "-" } else { "" }; + if let Some(rest) = name.strip_prefix("translate-") { + let (root, size) = axis_root("translate", rest); + if matches!(size, "auto" | "min" | "max" | "fit") { + return None; + } + let value = format!("{sign}{}", sizing_value(size)?); + return Some(axes(root, "translate", &value, TRANSLATE)); + } + if let Some(angle) = name.strip_prefix("rotate-") { + return is_number(angle).then(|| { + vec![( + Cow::Borrowed("rotate"), + Cow::Owned(format!("{sign}{angle}deg")), + )] + }); + } + if let Some(rest) = name.strip_prefix("scale-") { + let (root, amount) = axis_root("scale", rest); + if !is_integer(amount) { + return None; + } + let value = format!("{sign}{amount}%"); + if root == "scale" { + let mut declarations = each(&["--tw-scale-x", "--tw-scale-y", "--tw-scale-z"], &value); + declarations.push((Cow::Borrowed("scale"), Cow::Borrowed(SCALE))); + return Some(declarations); + } + return Some(axes(root, "scale", &value, SCALE)); } + if let Some(rest) = name.strip_prefix("skew-") { + let (root, angle) = axis_root("skew", rest); + return is_number(angle).then(|| skew(root, &format!("{sign}{angle}deg"))); + } + None +} - // Color names with shade (e.g., red-500, blue-100). Every Tailwind color - // token is `` or `-`, so an O(1) first-segment lookup - // reproduces the old `` / `-…` prefix scan byte-for-byte. - let base = value.split('-').next().unwrap_or(value); - if TAILWIND_COLOR_NAMES.contains(base) { - return true; +/// The root `name-x`/`name-y`/`name` that `rest` starts with, and its value +fn axis_root<'a>(name: &str, rest: &'a str) -> (&'static str, &'a str) { + match name { + "translate" => { + if let Some(value) = rest.strip_prefix("x-") { + ("translate-x", value) + } else if let Some(value) = rest.strip_prefix("y-") { + ("translate-y", value) + } else { + ("translate", rest) + } + } + "scale" => { + if let Some(value) = rest.strip_prefix("x-") { + ("scale-x", value) + } else if let Some(value) = rest.strip_prefix("y-") { + ("scale-y", value) + } else { + ("scale", rest) + } + } + _ => { + if let Some(value) = rest.strip_prefix("x-") { + ("skew-x", value) + } else if let Some(value) = rest.strip_prefix("y-") { + ("skew-y", value) + } else { + ("skew", rest) + } + } } +} - // Common keywords - if TAILWIND_VALUE_KEYWORDS.contains(value) { - return true; +/// `_` read as a space and `\_` as `_`, as Tailwind reads arbitrary values +fn decode_underscores(value: &str) -> String { + let mut decoded = String::with_capacity(value.len()); + let mut chars = value.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '\\' if chars.peek() == Some(&'_') => { + chars.next(); + decoded.push('_'); + } + '_' => decoded.push(' '), + c => decoded.push(c), + } } + decoded +} - // Size suffixes (xs, sm, md, lg, xl, 2xl, etc.) - if TAILWIND_SIZE_KEYWORDS.contains(value) { - return true; +/// An arbitrary value as CSS, the way Tailwind decodes it: underscores are +/// spaces except in `url()` and in the name `var()` reads, and the operators +/// in math functions get the spaces CSS requires around them +fn decode_arbitrary_value(value: &str) -> String { + if !value.contains('(') { + return decode_underscores(value); + } + let mut decoded = String::with_capacity(value.len()); + let mut index = 0; + while index < value.len() { + index += decode_arguments(&value[index..], &mut decoded); + if index < value.len() { + // A `)` closing nothing + decoded.push(')'); + index += 1; + } } + add_whitespace_around_math_operators(&decoded) +} - // Fraction values (1/2, 1/3, 2/3, etc.) - if let Some((numerator, denominator)) = value.split_once('/') { - if !denominator.contains('/') - && numerator.chars().all(|c| c.is_ascii_digit()) - && denominator.chars().all(|c| c.is_ascii_digit()) - { - return true; +/// Decodes `value` into `decoded` up to the `)` closing the function it is in, +/// returning the bytes read +fn decode_arguments(value: &str, decoded: &mut String) -> usize { + let mut index = 0; + while let Some(c) = value[index..].chars().next() { + match c { + ')' => return index, + '(' => { + let name = function_name(&value[..index]); + decoded.push('('); + index += 1; + if name == "url" || name.ends_with("_url") { + let end = closing_paren(&value[index..]); + decoded.push_str(&value[index..index + end]); + index += end; + } else { + if matches!(name, "var" | "theme") + || name.ends_with("_var") + || name.ends_with("_theme") + { + // The first argument names a custom property + let end = value[index..] + .find([',', ')']) + .unwrap_or(value.len() - index); + let argument = &value[index..index + end]; + if !argument.contains('(') { + decoded.push_str(&argument.replace("\\_", "_")); + index += end; + } + } + index += decode_arguments(&value[index..], decoded); + } + if value[index..].starts_with(')') { + decoded.push(')'); + index += 1; + } + } + '\\' if value[index + 1..].starts_with('_') => { + decoded.push('_'); + index += 2; + } + '_' => { + decoded.push(' '); + index += 1; + } + c => { + decoded.push(c); + index += c.len_utf8(); + } } } + index +} - false +/// The name of the function whose `(` follows `before` +fn function_name(before: &str) -> &str { + let start = before + .rfind(|c: char| !(c.is_ascii_alphanumeric() || c == '-' || c == '_')) + .map_or(0, |index| index + 1); + &before[start..] } -/// Parse a className string into a list of `ExtractStyleValue` -pub fn parse_tailwind_to_styles(class_str: &str) -> Vec { - // Upper bound: at most one style per whitespace-separated class, so - // presizing removes the grow-realloc chain. Use a cheap single byte scan - // (whitespace-byte count + 1) instead of the heavier `split_whitespace` - // state machine just to size the buffer. - let mut styles = - Vec::with_capacity(class_str.bytes().filter(u8::is_ascii_whitespace).count() + 1); - - for class in class_str.split_whitespace() { - if let Some(static_style) = parse_single_class(class) - .as_ref() - .and_then(TailwindClass::to_static_style) - { - styles.push(ExtractStyleValue::Static(static_style)); +/// The index of the `)` closing the parentheses `value` is in, or its length +fn closing_paren(value: &str) -> usize { + let mut depth = 0usize; + for (index, byte) in value.bytes().enumerate() { + match byte { + b'(' => depth += 1, + b')' if depth == 0 => return index, + b')' => depth -= 1, + _ => {} + } + } + value.len() +} + +/// Math functions whose operators need spaces around them +const MATH_FUNCTIONS: [&str; 19] = [ + "calc", "min", "max", "clamp", "mod", "rem", "sin", "cos", "tan", "asin", "acos", "atan", + "atan2", "pow", "sqrt", "hypot", "log", "exp", "round", +]; + +/// `calc(100%-2rem)` as `calc(100% - 2rem)`, ported from Tailwind +fn add_whitespace_around_math_operators(input: &str) -> String { + if !MATH_FUNCTIONS + .iter() + .any(|function| input.contains(function)) + { + return input.to_string(); + } + let chars: Vec = input.chars().collect(); + let mut result = String::with_capacity(input.len() + 8); + // Whether each open parenthesis is in a math function, innermost last + let mut formattable: Vec = Vec::new(); + let mut value_end = None; + let mut last_value_end = None; + for (index, &c) in chars.iter().enumerate() { + // A number, and then its unit + if c.is_ascii_digit() || (value_end.is_some() && (c == '%' || c.is_ascii_alphabetic())) { + value_end = Some(index); + } else { + last_value_end = value_end; + value_end = None; + } + let in_math = formattable.last().copied().unwrap_or(false); + match c { + '(' => { + result.push(c); + let start = chars[..index] + .iter() + .rposition(|c| !(c.is_ascii_digit() || c.is_ascii_lowercase())) + .map_or(0, |position| position + 1); + let name: String = chars[start..index].iter().collect(); + formattable + .push(MATH_FUNCTIONS.contains(&name.as_str()) || (in_math && name.is_empty())); + } + ')' => { + result.push(c); + formattable.pop(); + } + ',' if in_math => result.push_str(", "), + ' ' if in_math && result.ends_with(' ') => {} + '+' | '*' | '/' | '-' if in_math => { + let mut before = result.trim_end().chars().rev(); + let previous = before.next(); + let before_previous = before.next(); + let next = chars.get(index + 1).copied(); + if matches!(previous, Some('e' | 'E')) + && before_previous.is_some_and(|c| c.is_ascii_digit()) + { + // A scientific notation exponent, such as `1e-3` + result.push(c); + } else if matches!(previous, Some('+' | '*' | '/' | '-' | '(' | ',')) { + // The sign of an operand + result.push(c); + } else if chars[index - 1] == ' ' { + result.push(c); + result.push(' '); + } else if previous.is_some_and(|c| c.is_ascii_digit() || c == ')') + || next.is_some_and(|c| { + c.is_ascii_digit() || matches!(c, '(' | '+' | '*' | '/' | '-') + }) + || last_value_end.is_some_and(|end| end + 1 == index) + { + result.push(' '); + result.push(c); + result.push(' '); + } else { + result.push(c); + } + } + c => result.push(c), } } + result +} - styles +/// `value` split at each top-level `separator`, outside brackets and parentheses +fn split_top_level(value: &str, separator: char) -> Vec<&str> { + let mut parts = Vec::new(); + let mut depth = 0usize; + let mut start = 0; + for (index, c) in value.char_indices() { + match c { + '[' | '(' => depth += 1, + ']' | ')' => depth = depth.saturating_sub(1), + c if c == separator && depth == 0 => { + parts.push(&value[start..index]); + start = index + c.len_utf8(); + } + _ => {} + } + } + parts.push(&value[start..]); + parts } -/// Parse a single Tailwind class string -pub fn parse_single_class(class: &str) -> Option { - let mut remaining = class; - let mut responsive_level: u8 = 0; - let mut variants: Vec = Vec::new(); +/// What `[…]` holds +fn bracketed(value: &str) -> Option<&str> { + let inner = value.strip_prefix('[')?.strip_suffix(']')?; + (!inner.is_empty() && is_balanced(inner)).then_some(inner) +} - // Handle negative prefix at the start - let negative = remaining.starts_with('-'); - if negative { - remaining = &remaining[1..]; +/// Whether the brackets and parentheses of `value` all close, in order +fn is_balanced(value: &str) -> bool { + let mut open = Vec::new(); + for c in value.chars() { + match c { + '[' | '(' => open.push(c), + ']' if open.pop() != Some('[') => return false, + ')' if open.pop() != Some('(') => return false, + _ => {} + } } + open.is_empty() +} - // Parse prefixes (responsive and variants) - while let Some(colon_pos) = remaining.find(':') { - let prefix = &remaining[..colon_pos]; +/// A name such as `active` in `data-active` or `item` in `group/item` +fn is_name(value: &str) -> bool { + !value.is_empty() + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) +} - // Check if it's a responsive prefix - if let Some(&level) = RESPONSIVE_PREFIX_MAP.get(prefix) { - responsive_level = level; - } else if let Some(variant) = TailwindVariant::from_prefix(prefix) { - variants.push(variant); - } else { - // Unknown prefix, might be an arbitrary variant - // For now, skip unknown prefixes +/// A quoted string +fn is_quoted(value: &str) -> bool { + value.len() >= 2 + && ((value.starts_with('"') && value.ends_with('"')) + || (value.starts_with('\'') && value.ends_with('\''))) +} + +/// A CSS property name, custom or not +fn is_property_name(value: &str) -> bool { + match value.strip_prefix("--") { + Some(name) => is_name(name), + None => { + value + .trim_start_matches('-') + .starts_with(|c: char| c.is_ascii_lowercase()) + && value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte == b'-') } + } +} - remaining = &remaining[colon_pos + 1..]; +/// A plain number such as `600` or `1.5` +fn is_number(value: &str) -> bool { + value.parse::().is_ok() + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || byte == b'.') +} + +/// A plain integer such as `110` +fn is_integer(value: &str) -> bool { + !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit()) +} + +/// Units a length takes +static LENGTH_UNITS: phf::Set<&'static str> = phf_set! { + "%", "px", "rem", "em", "ex", "ch", "cap", "ic", "lh", "rlh", "rex", "rch", "rcap", + "ric", "vw", "vh", "vi", "vb", "vmin", "vmax", "svw", "svh", "svi", "svb", "svmin", "svmax", + "lvw", "lvh", "lvi", "lvb", "lvmin", "lvmax", "dvw", "dvh", "dvi", "dvb", "dvmin", "dvmax", + "cqw", "cqh", "cqi", "cqb", "cqmin", "cqmax", "cm", "mm", "q", "in", "pt", "pc", +}; + +/// One or more lengths, such as `2rem`, `0`, `calc(…)` or `1px 2px` +fn is_length(value: &str) -> bool { + if MATH_FUNCTIONS.iter().any(|function| { + value + .strip_prefix(function) + .is_some_and(|rest| rest.starts_with('(')) + }) { + return true; } + let mut parts = value.split_ascii_whitespace().peekable(); + parts.peek().is_some() + && parts.all(|part| { + let number = part.trim_start_matches(['-', '+']); + let unit_start = number + .find(|c: char| !(c.is_ascii_digit() || c == '.')) + .unwrap_or(number.len()); + part == "0" + || (unit_start > 0 + && number[..unit_start].parse::().is_ok() + && LENGTH_UNITS.contains(number[unit_start..].to_ascii_lowercase().as_str())) + }) +} - // Now parse the utility class - parse_utility(remaining, negative).map(|(property, value)| TailwindClass { - responsive: responsive_level, - variants, - property, - value, - negative, - }) +/// An image such as `url(…)` or a gradient +fn is_image(value: &str) -> bool { + [ + "url(", + "image(", + "image-set(", + "cross-fade(", + "element(", + "linear-gradient(", + "radial-gradient(", + "conic-gradient(", + "repeating-linear-gradient(", + "repeating-radial-gradient(", + "repeating-conic-gradient(", + ] + .iter() + .any(|function| value.starts_with(function)) } type ParsedUtility = (&'static str, Cow<'static, str>); @@ -1316,13 +1933,8 @@ fn tw(property: &'static str, value: impl Into>) -> ParsedUtil (property, value.into()) } -/// Parse a utility class (without prefixes) into property and value -fn parse_utility(class: &str, is_negative: bool) -> Option<(&'static str, Cow<'static, str>)> { - // Handle arbitrary values first - if let Some(result) = parse_arbitrary_value(class) { - return Some(result); - } - +/// The one declaration of a utility from the tables below +fn parse_utility(class: &str) -> Option { // Layout utilities if let Some(result) = parse_layout_utility(class) { return Some(result); @@ -1334,7 +1946,7 @@ fn parse_utility(class: &str, is_negative: bool) -> Option<(&'static str, Cow<'s } // Spacing (padding, margin) - if let Some(result) = parse_spacing_utility(class, is_negative) { + if let Some(result) = parse_spacing_utility(class) { return Some(result); } @@ -1373,8 +1985,8 @@ fn parse_utility(class: &str, is_negative: bool) -> Option<(&'static str, Cow<'s return Some(result); } - // Transforms - if let Some(result) = parse_transform_utility(class, is_negative) { + // Transform origin + if let Some(result) = parse_transform_utility(class) { return Some(result); } @@ -1384,105 +1996,7 @@ fn parse_utility(class: &str, is_negative: bool) -> Option<(&'static str, Cow<'s } // SVG - if let Some(result) = parse_svg_utility(class) { - return Some(result); - } - - // Accessibility - if let Some(result) = parse_accessibility_utility(class) { - return Some(result); - } - - None -} - -/// Parse arbitrary value syntax: class-[value] -fn parse_arbitrary_value(class: &str) -> Option<(&'static str, Cow<'static, str>)> { - if !class.contains('[') { - return None; - } - - let bracket_start = class.find('[')?; - let bracket_end = class.rfind(']')?; - - if bracket_end <= bracket_start { - return None; - } - - let prefix = &class[..bracket_start]; - let value = &class[bracket_start + 1..bracket_end]; - - // Replace underscores with spaces in arbitrary values - let value = value.replace('_', " "); - - match prefix { - "w-" => Some(tw("width", value)), - "h-" => Some(tw("height", value)), - "min-w-" => Some(tw("min-width", value)), - "max-w-" => Some(tw("max-width", value)), - "min-h-" => Some(tw("min-height", value)), - "max-h-" => Some(tw("max-height", value)), - "p-" => Some(tw("padding", value)), - "px-" => Some(tw("padding-inline", value)), - "py-" => Some(tw("padding-block", value)), - "pt-" => Some(tw("padding-top", value)), - "pr-" => Some(tw("padding-right", value)), - "pb-" => Some(tw("padding-bottom", value)), - "pl-" => Some(tw("padding-left", value)), - "m-" => Some(tw("margin", value)), - "mx-" => Some(tw("margin-inline", value)), - "my-" => Some(tw("margin-block", value)), - "mt-" => Some(tw("margin-top", value)), - "mr-" => Some(tw("margin-right", value)), - "mb-" => Some(tw("margin-bottom", value)), - "ml-" => Some(tw("margin-left", value)), - "top-" => Some(tw("top", value)), - "right-" => Some(tw("right", value)), - "bottom-" => Some(tw("bottom", value)), - "left-" => Some(tw("left", value)), - "inset-" => Some(tw("inset", value)), - "inset-x-" => Some(tw("inset-inline", value)), - "inset-y-" => Some(tw("inset-block", value)), - "gap-" => Some(tw("gap", value)), - "gap-x-" => Some(tw("column-gap", value)), - "gap-y-" => Some(tw("row-gap", value)), - "text-" => Some(tw("color", value)), - "bg-" => Some(tw("background-color", value)), - "border-" => Some(tw("border-color", value)), - "rounded-" => Some(tw("border-radius", value)), - "opacity-" => Some(tw("opacity", value)), - "z-" => Some(tw("z-index", value)), - "font-" => Some(tw("font-family", value)), - "tracking-" => Some(tw("letter-spacing", value)), - "leading-" => Some(tw("line-height", value)), - "duration-" => Some(tw("transition-duration", value)), - "delay-" => Some(tw("transition-delay", value)), - "scale-" => Some(tw("transform", format!("scale({value})"))), - "rotate-" => Some(tw("transform", format!("rotate({value})"))), - "translate-x-" => Some(tw("transform", format!("translateX({value})"))), - "translate-y-" => Some(tw("transform", format!("translateY({value})"))), - "skew-x-" => Some(tw("transform", format!("skewX({value})"))), - "skew-y-" => Some(tw("transform", format!("skewY({value})"))), - "aspect-" => Some(tw("aspect-ratio", value)), - "columns-" => Some(tw("columns", value)), - "grid-cols-" => Some(tw( - "grid-template-columns", - format!("repeat({value}, minmax(0, 1fr))"), - )), - "grid-rows-" => Some(tw( - "grid-template-rows", - format!("repeat({value}, minmax(0, 1fr))"), - )), - "col-span-" => Some(tw("grid-column", format!("span {value} / span {value}"))), - "row-span-" => Some(tw("grid-row", format!("span {value} / span {value}"))), - "basis-" => Some(tw("flex-basis", value)), - "blur-" => Some(tw("filter", format!("blur({value})"))), - "brightness-" => Some(tw("filter", format!("brightness({value})"))), - "contrast-" => Some(tw("filter", format!("contrast({value})"))), - "saturate-" => Some(tw("filter", format!("saturate({value})"))), - "backdrop-blur-" => Some(tw("backdrop-filter", format!("blur({value})"))), - _ => None, - } + parse_svg_utility(class) } /// Parse layout utilities (display, position, visibility, etc.) @@ -1599,7 +2113,13 @@ fn parse_layout_utility(class: &str) -> Option<(&'static str, Cow<'static, str>) "auto" => Cow::Borrowed("auto"), "square" => Cow::Borrowed("1 / 1"), "video" => Cow::Borrowed("16 / 9"), - v => Cow::Owned(v.replace('-', " / ")), + v => { + let (width, height) = v.split_once('/')?; + if !is_integer(width) || !is_integer(height) { + return None; + } + Cow::Owned(v.to_string()) + } }; return Some(tw("aspect-ratio", value)); } @@ -1621,7 +2141,8 @@ fn parse_layout_utility(class: &str) -> Option<(&'static str, Cow<'static, str>) "5xl" => Cow::Borrowed("64rem"), "6xl" => Cow::Borrowed("72rem"), "7xl" => Cow::Borrowed("80rem"), - v => Cow::Owned(v.to_string()), + v if is_integer(v) => Cow::Owned(v.to_string()), + _ => return None, }; return Some(tw("columns", value)); } @@ -1869,7 +2390,9 @@ fn parse_flex_grid_utility(class: &str) -> Option<(&'static str, Cow<'static, st // Order with number if let Some(rest) = class.strip_prefix("order-") { - return Some(tw("order", Cow::Owned(rest.to_string()))); + if is_integer(rest) { + return Some(tw("order", Cow::Owned(rest.to_string()))); + } } // Grid cols @@ -1901,12 +2424,16 @@ fn parse_flex_grid_utility(class: &str) -> Option<(&'static str, Cow<'static, st // Col start if let Some(rest) = class.strip_prefix("col-start-") { - return Some(tw("grid-column-start", Cow::Owned(rest.to_string()))); + if is_integer(rest) { + return Some(tw("grid-column-start", Cow::Owned(rest.to_string()))); + } } // Col end if let Some(rest) = class.strip_prefix("col-end-") { - return Some(tw("grid-column-end", Cow::Owned(rest.to_string()))); + if is_integer(rest) { + return Some(tw("grid-column-end", Cow::Owned(rest.to_string()))); + } } // Row span @@ -1918,12 +2445,16 @@ fn parse_flex_grid_utility(class: &str) -> Option<(&'static str, Cow<'static, st // Row start if let Some(rest) = class.strip_prefix("row-start-") { - return Some(tw("grid-row-start", Cow::Owned(rest.to_string()))); + if is_integer(rest) { + return Some(tw("grid-row-start", Cow::Owned(rest.to_string()))); + } } // Row end if let Some(rest) = class.strip_prefix("row-end-") { - return Some(tw("grid-row-end", Cow::Owned(rest.to_string()))); + if is_integer(rest) { + return Some(tw("grid-row-end", Cow::Owned(rest.to_string()))); + } } // Gap @@ -1949,13 +2480,7 @@ fn parse_flex_grid_utility(class: &str) -> Option<(&'static str, Cow<'static, st } /// Parse spacing utilities (padding, margin, space) -fn parse_spacing_utility( - class: &str, - _is_negative: bool, -) -> Option<(&'static str, Cow<'static, str>)> { - // Note: is_negative is handled at a higher level in TailwindClass::to_static_style() - // We don't apply the negative sign here to avoid double-negation - +fn parse_spacing_utility(class: &str) -> Option<(&'static str, Cow<'static, str>)> { // Padding if let Some(rest) = class.strip_prefix("px-") { if let Some(&value) = SPACING_SCALE.get(rest) { @@ -2200,15 +2725,6 @@ fn parse_sizing_utility(class: &str) -> Option<(&'static str, Cow<'static, str>) return Some(tw("max-height", value)); } - // Size (width and height) - if let Some(rest) = class.strip_prefix("size-") { - if let Some(&value) = SPACING_SCALE.get(rest) { - // This should set both width and height - // For simplicity, we'll use the width shorthand and handle height separately - return Some(tw("width", Cow::Borrowed(value))); - } - } - None } @@ -2241,17 +2757,11 @@ fn parse_typography_utility(class: &str) -> Option<(&'static str, Cow<'static, s _ => {} } - // Font size + // Text color (font sizes are `compound_utility`'s) if let Some(rest) = class.strip_prefix("text-") { - // First check if it's a color if let Some(&color) = TAILWIND_COLORS.get(rest) { return Some(tw("color", Cow::Borrowed(color))); } - // Then check if it's a font size - if let Some(&(size, _line_height)) = FONT_SIZE_SCALE.get(rest) { - // Return font-size (line-height would need separate handling) - return Some(tw("font-size", Cow::Borrowed(size))); - } // Text alignment match rest { "left" => return Some(tw("text-align", Cow::Borrowed("left"))), @@ -2300,9 +2810,6 @@ fn parse_typography_utility(class: &str) -> Option<(&'static str, Cow<'static, s // Text overflow match class { - "truncate" => { - return Some(tw("text-overflow", Cow::Borrowed("ellipsis"))); - } "text-ellipsis" => return Some(tw("text-overflow", Cow::Borrowed("ellipsis"))), "text-clip" => return Some(tw("text-overflow", Cow::Borrowed("clip"))), _ => {} @@ -2319,7 +2826,11 @@ fn parse_typography_utility(class: &str) -> Option<(&'static str, Cow<'static, s // Whitespace if let Some(rest) = class.strip_prefix("whitespace-") { - return Some(tw("white-space", Cow::Owned(rest.to_string()))); + return matches!( + rest, + "normal" | "nowrap" | "pre" | "pre-line" | "pre-wrap" | "break-spaces" + ) + .then(|| tw("white-space", Cow::Owned(rest.to_string()))); } // Word break @@ -2333,43 +2844,22 @@ fn parse_typography_utility(class: &str) -> Option<(&'static str, Cow<'static, s // Hyphens if let Some(rest) = class.strip_prefix("hyphens-") { - return Some(tw("hyphens", Cow::Owned(rest.to_string()))); + return matches!(rest, "none" | "manual" | "auto") + .then(|| tw("hyphens", Cow::Owned(rest.to_string()))); } // Letter spacing if let Some(rest) = class.strip_prefix("tracking-") { let value = match rest { - "tighter" => Cow::Borrowed("-0.05em"), - "tight" => Cow::Borrowed("-0.025em"), - "normal" => Cow::Borrowed("0em"), - "wide" => Cow::Borrowed("0.025em"), - "wider" => Cow::Borrowed("0.05em"), - "widest" => Cow::Borrowed("0.1em"), - _ => Cow::Owned(rest.to_string()), - }; - return Some(tw("letter-spacing", value)); - } - - // Line height - if let Some(rest) = class.strip_prefix("leading-") { - let value = match rest { - "none" => Cow::Borrowed("1"), - "tight" => Cow::Borrowed("1.25"), - "snug" => Cow::Borrowed("1.375"), - "normal" => Cow::Borrowed("1.5"), - "relaxed" => Cow::Borrowed("1.625"), - "loose" => Cow::Borrowed("2"), - "3" => Cow::Borrowed(".75rem"), - "4" => Cow::Borrowed("1rem"), - "5" => Cow::Borrowed("1.25rem"), - "6" => Cow::Borrowed("1.5rem"), - "7" => Cow::Borrowed("1.75rem"), - "8" => Cow::Borrowed("2rem"), - "9" => Cow::Borrowed("2.25rem"), - "10" => Cow::Borrowed("2.5rem"), - _ => Cow::Owned(rest.to_string()), + "tighter" => "-0.05em", + "tight" => "-0.025em", + "normal" => "0em", + "wide" => "0.025em", + "wider" => "0.05em", + "widest" => "0.1em", + _ => return None, }; - return Some(tw("line-height", value)); + return Some(tw("letter-spacing", Cow::Borrowed(value))); } // List style type @@ -2386,7 +2876,11 @@ fn parse_typography_utility(class: &str) -> Option<(&'static str, Cow<'static, s // Vertical align if let Some(rest) = class.strip_prefix("align-") { - return Some(tw("vertical-align", Cow::Owned(rest.to_string()))); + return matches!( + rest, + "baseline" | "top" | "middle" | "bottom" | "text-top" | "text-bottom" | "sub" | "super" + ) + .then(|| tw("vertical-align", Cow::Owned(rest.to_string()))); } // Content @@ -2507,59 +3001,7 @@ fn parse_background_utility(class: &str) -> Option<(&'static str, Cow<'static, s /// Parse border utilities fn parse_border_utility(class: &str) -> Option<(&'static str, Cow<'static, str>)> { - // Border radius - if let Some(rest) = class.strip_prefix("rounded-") { - // Specific corners - if let Some(corner) = rest.strip_prefix("t-") { - if let Some(&value) = BORDER_RADIUS_SCALE.get(corner) { - return Some(tw("border-top-left-radius", Cow::Borrowed(value))); - } - } - if let Some(corner) = rest.strip_prefix("r-") { - if let Some(&value) = BORDER_RADIUS_SCALE.get(corner) { - return Some(tw("border-top-right-radius", Cow::Borrowed(value))); - } - } - if let Some(corner) = rest.strip_prefix("b-") { - if let Some(&value) = BORDER_RADIUS_SCALE.get(corner) { - return Some(tw("border-bottom-right-radius", Cow::Borrowed(value))); - } - } - if let Some(corner) = rest.strip_prefix("l-") { - if let Some(&value) = BORDER_RADIUS_SCALE.get(corner) { - return Some(tw("border-bottom-left-radius", Cow::Borrowed(value))); - } - } - if let Some(corner) = rest.strip_prefix("tl-") { - if let Some(&value) = BORDER_RADIUS_SCALE.get(corner) { - return Some(tw("border-top-left-radius", Cow::Borrowed(value))); - } - } - if let Some(corner) = rest.strip_prefix("tr-") { - if let Some(&value) = BORDER_RADIUS_SCALE.get(corner) { - return Some(tw("border-top-right-radius", Cow::Borrowed(value))); - } - } - if let Some(corner) = rest.strip_prefix("br-") { - if let Some(&value) = BORDER_RADIUS_SCALE.get(corner) { - return Some(tw("border-bottom-right-radius", Cow::Borrowed(value))); - } - } - if let Some(corner) = rest.strip_prefix("bl-") { - if let Some(&value) = BORDER_RADIUS_SCALE.get(corner) { - return Some(tw("border-bottom-left-radius", Cow::Borrowed(value))); - } - } - if let Some(&value) = BORDER_RADIUS_SCALE.get(rest) { - return Some(tw("border-radius", Cow::Borrowed(value))); - } - } - - // Standalone "rounded" (without suffix) - note: "rounded-*" variants are handled - // via BORDER_RADIUS_SCALE lookup above in the strip_prefix("rounded-") branch - if class == "rounded" { - return Some(tw("border-radius", Cow::Borrowed("0.25rem"))); - } + // Border radius is compound_utility's // Border width if let Some(rest) = class.strip_prefix("border-") { @@ -2759,17 +3201,27 @@ fn parse_effects_utility(class: &str) -> Option<(&'static str, Cow<'static, str> // Mix blend mode if let Some(rest) = class.strip_prefix("mix-blend-") { - return Some(tw("mix-blend-mode", Cow::Owned(rest.to_string()))); + return (BLEND_MODES.contains(rest) || matches!(rest, "plus-darker" | "plus-lighter")) + .then(|| tw("mix-blend-mode", Cow::Owned(rest.to_string()))); } // Background blend mode if let Some(rest) = class.strip_prefix("bg-blend-") { - return Some(tw("background-blend-mode", Cow::Owned(rest.to_string()))); + return BLEND_MODES + .contains(rest) + .then(|| tw("background-blend-mode", Cow::Owned(rest.to_string()))); } None } +/// Blend modes `mix-blend-*` and `bg-blend-*` take +static BLEND_MODES: phf::Set<&'static str> = phf_set! { + "normal", "multiply", "screen", "overlay", "darken", "lighten", "color-dodge", "color-burn", + "hard-light", "soft-light", "difference", "exclusion", "hue", "saturation", "color", + "luminosity", +}; + /// Parse filter utilities (blur, brightness, contrast, etc.) fn parse_filter_utility(class: &str) -> Option<(&'static str, Cow<'static, str>)> { // Blur @@ -3073,86 +3525,9 @@ fn parse_transition_utility(class: &str) -> Option<(&'static str, Cow<'static, s None } -/// Parse transform utilities (scale, rotate, translate, skew) -fn parse_transform_utility( - class: &str, - is_negative: bool, -) -> Option<(&'static str, Cow<'static, str>)> { - // Scale - if let Some(rest) = class.strip_prefix("scale-x-") { - let value = parse_scale_value(rest)?; - return Some(tw("transform", format!("scaleX({value})"))); - } - if let Some(rest) = class.strip_prefix("scale-y-") { - let value = parse_scale_value(rest)?; - return Some(tw("transform", format!("scaleY({value})"))); - } - if let Some(rest) = class.strip_prefix("scale-") { - let value = parse_scale_value(rest)?; - return Some(tw("transform", format!("scale({value})"))); - } - - // Rotate - if let Some(rest) = class.strip_prefix("rotate-") { - let value = match rest { - "0" => Cow::Borrowed("0deg"), - "1" => Cow::Borrowed("1deg"), - "2" => Cow::Borrowed("2deg"), - "3" => Cow::Borrowed("3deg"), - "6" => Cow::Borrowed("6deg"), - "12" => Cow::Borrowed("12deg"), - "45" => Cow::Borrowed("45deg"), - "90" => Cow::Borrowed("90deg"), - "180" => Cow::Borrowed("180deg"), - _ => return None, - }; - let neg_prefix = if is_negative { "-" } else { "" }; - return Some(tw("transform", format!("rotate({neg_prefix}{value})"))); - } - - // Translate - if let Some(rest) = class.strip_prefix("translate-x-") { - if let Some(&value) = SPACING_SCALE.get(rest) { - let neg_prefix = if is_negative { "-" } else { "" }; - return Some(tw("transform", format!("translateX({neg_prefix}{value})"))); - } - } - if let Some(rest) = class.strip_prefix("translate-y-") { - if let Some(&value) = SPACING_SCALE.get(rest) { - let neg_prefix = if is_negative { "-" } else { "" }; - return Some(tw("transform", format!("translateY({neg_prefix}{value})"))); - } - } - - // Skew - if let Some(rest) = class.strip_prefix("skew-x-") { - let value = match rest { - "0" => Cow::Borrowed("0deg"), - "1" => Cow::Borrowed("1deg"), - "2" => Cow::Borrowed("2deg"), - "3" => Cow::Borrowed("3deg"), - "6" => Cow::Borrowed("6deg"), - "12" => Cow::Borrowed("12deg"), - _ => return None, - }; - let neg_prefix = if is_negative { "-" } else { "" }; - return Some(tw("transform", format!("skewX({neg_prefix}{value})"))); - } - if let Some(rest) = class.strip_prefix("skew-y-") { - let value = match rest { - "0" => Cow::Borrowed("0deg"), - "1" => Cow::Borrowed("1deg"), - "2" => Cow::Borrowed("2deg"), - "3" => Cow::Borrowed("3deg"), - "6" => Cow::Borrowed("6deg"), - "12" => Cow::Borrowed("12deg"), - _ => return None, - }; - let neg_prefix = if is_negative { "-" } else { "" }; - return Some(tw("transform", format!("skewY({neg_prefix}{value})"))); - } - - // Transform origin +/// Parse transform origin utilities (translate, rotate, scale and skew are +/// `transform_utility`'s) +fn parse_transform_utility(class: &str) -> Option<(&'static str, Cow<'static, str>)> { if let Some(rest) = class.strip_prefix("origin-") { let value = match rest { "center" => Cow::Borrowed("center"), @@ -3172,12 +3547,6 @@ fn parse_transform_utility( None } -/// Parse scale value (50 -> 0.5, 100 -> 1, 150 -> 1.5) -fn parse_scale_value(s: &str) -> Option { - let n: u32 = s.parse().ok()?; - Some(f64::from(n) / 100.0) -} - /// Parse interactivity utilities (cursor, pointer-events, resize, etc.) fn parse_interactivity_utility(class: &str) -> Option<(&'static str, Cow<'static, str>)> { // Accent color @@ -3199,7 +3568,9 @@ fn parse_interactivity_utility(class: &str) -> Option<(&'static str, Cow<'static // Cursor if let Some(rest) = class.strip_prefix("cursor-") { - return Some(tw("cursor", Cow::Owned(rest.to_string()))); + return CURSORS + .contains(rest) + .then(|| tw("cursor", Cow::Owned(rest.to_string()))); } // Caret color @@ -3211,7 +3582,8 @@ fn parse_interactivity_utility(class: &str) -> Option<(&'static str, Cow<'static // Pointer events if let Some(rest) = class.strip_prefix("pointer-events-") { - return Some(tw("pointer-events", Cow::Owned(rest.to_string()))); + return matches!(rest, "none" | "auto") + .then(|| tw("pointer-events", Cow::Owned(rest.to_string()))); } // Resize @@ -3290,12 +3662,26 @@ fn parse_interactivity_utility(class: &str) -> Option<(&'static str, Cow<'static // Touch action if let Some(rest) = class.strip_prefix("touch-") { - return Some(tw("touch-action", Cow::Owned(rest.to_string()))); + return matches!( + rest, + "auto" + | "none" + | "pan-x" + | "pan-left" + | "pan-right" + | "pan-y" + | "pan-up" + | "pan-down" + | "pinch-zoom" + | "manipulation" + ) + .then(|| tw("touch-action", Cow::Owned(rest.to_string()))); } // User select if let Some(rest) = class.strip_prefix("select-") { - return Some(tw("user-select", Cow::Owned(rest.to_string()))); + return matches!(rest, "none" | "text" | "all" | "auto") + .then(|| tw("user-select", Cow::Owned(rest.to_string()))); } // Will change @@ -3346,23 +3732,20 @@ fn parse_svg_utility(class: &str) -> Option<(&'static str, Cow<'static, str>)> { None } -/// Parse accessibility utilities (screen reader only) -fn parse_accessibility_utility(class: &str) -> Option<(&'static str, Cow<'static, str>)> { - match class { - "sr-only" => { - // This utility requires multiple CSS properties - // We'll return the most important one - Some(tw("position", Cow::Borrowed("absolute"))) - } - "not-sr-only" => Some(tw("position", Cow::Borrowed("static"))), - _ => None, - } -} +/// Cursors `cursor-*` takes +static CURSORS: phf::Set<&'static str> = phf_set! { + "auto", "default", "pointer", "wait", "text", "move", "help", "not-allowed", "none", + "context-menu", "progress", "cell", "crosshair", "vertical-text", "alias", "copy", "no-drop", + "grab", "grabbing", "all-scroll", "col-resize", "row-resize", "n-resize", "e-resize", + "s-resize", "w-resize", "ne-resize", "nw-resize", "se-resize", "sw-resize", "ew-resize", + "ns-resize", "nesw-resize", "nwse-resize", "zoom-in", "zoom-out", +}; #[cfg(test)] #[allow(clippy::expect_used, clippy::unwrap_used)] mod tests { use super::*; + use crate::extract_style::extract_style_value::ExtractStyleValue; use css::class_map::reset_class_map; use css::file_map::reset_file_map; use insta::assert_debug_snapshot; @@ -3375,27 +3758,67 @@ mod tests { styles.into_iter().collect() } - #[test] - fn test_has_tailwind_classes_basic() { - assert!(has_tailwind_classes("bg-red-500 text-white")); - assert!(has_tailwind_classes("p-4 m-2")); - assert!(has_tailwind_classes("flex items-center")); - assert!(!has_tailwind_classes("my-custom-class")); - assert!(!has_tailwind_classes("")); - } - - #[test] - fn test_has_tailwind_classes_with_responsive() { - assert!(has_tailwind_classes("sm:bg-blue-500")); - assert!(has_tailwind_classes("md:flex lg:hidden")); - assert!(has_tailwind_classes("hover:bg-red-500")); - } - - #[test] - fn test_has_tailwind_classes_with_arbitrary() { - assert!(has_tailwind_classes("w-[100px]")); - assert!(has_tailwind_classes("text-[#ff0000]")); - assert!(has_tailwind_classes("p-[calc(100%-20px)]")); + /// The styles of every class in `classes` that compiles + fn parse_tailwind_to_styles(classes: &str) -> Vec { + classes + .split_whitespace() + .filter_map(parse_class) + .flat_map(|class| { + class + .styles() + .map(ExtractStyleValue::Static) + .collect::>() + }) + .collect() + } + + /// The declarations `class` compiles to, `None` when it stays as written + fn declarations(class: &str) -> Option> { + parse_class(class).map(|class| { + class + .declarations + .into_iter() + .map(|(property, value)| (property.into_owned(), value.into_owned())) + .collect() + }) + } + + /// What `class` applies under: its level and each condition, written out + fn conditions(class: &str) -> Option<(u8, Vec)> { + parse_class(class).map(|class| { + ( + class.level, + class + .conditions + .iter() + .map(|condition| { + condition + .as_ref() + .map_or_else(String::new, ToString::to_string) + }) + .collect(), + ) + }) + } + + struct Single { + property: String, + value: String, + } + + /// The one declaration of a single-declaration utility + fn parse_single_class(class: &str) -> Option { + let mut declarations = declarations(class)?; + assert_eq!(declarations.len(), 1, "{class}: {declarations:?}"); + let (property, value) = declarations.remove(0); + Some(Single { property, value }) + } + + fn owned(declarations: &[(&str, &str)]) -> Vec<(String, String)> { + declarations + .iter() + .map(|(property, value)| ((*property).to_string(), (*value).to_string())) + .collect() } #[rstest] @@ -3521,8 +3944,6 @@ mod tests { )] #[case("font-bold", "font-weight", "700")] #[case("font-normal", "font-weight", "400")] - #[case("text-sm", "font-size", "0.875rem")] - #[case("text-xl", "font-size", "1.25rem")] #[case("text-center", "text-align", "center")] #[case("italic", "font-style", "italic")] #[case("underline", "text-decoration-line", "underline")] @@ -3595,68 +4016,6 @@ mod tests { assert_eq!(parsed.value, expected_value); } - #[test] - fn test_parse_responsive_prefix() { - let parsed = parse_single_class("sm:bg-red-500").expect("Should parse"); - assert_eq!(parsed.responsive, 1); - assert_eq!(parsed.property, "background-color"); - assert_eq!(parsed.value, "#ef4444"); - - let parsed = parse_single_class("md:p-4").expect("Should parse"); - assert_eq!(parsed.responsive, 2); - assert_eq!(parsed.property, "padding"); - assert_eq!(parsed.value, "1rem"); - - let parsed = parse_single_class("lg:flex").expect("Should parse"); - assert_eq!(parsed.responsive, 3); - assert_eq!(parsed.property, "display"); - assert_eq!(parsed.value, "flex"); - - let parsed = parse_single_class("xl:hidden").expect("Should parse"); - assert_eq!(parsed.responsive, 4); - assert_eq!(parsed.property, "display"); - assert_eq!(parsed.value, "none"); - - let parsed = parse_single_class("2xl:w-full").expect("Should parse"); - assert_eq!(parsed.responsive, 5); - assert_eq!(parsed.property, "width"); - assert_eq!(parsed.value, "100%"); - } - - #[test] - fn test_parse_variant_hover() { - let parsed = parse_single_class("hover:bg-blue-500").expect("Should parse"); - assert_eq!(parsed.variants.len(), 1); - assert_eq!(parsed.variants[0], TailwindVariant::Hover); - assert_eq!(parsed.property, "background-color"); - assert_eq!(parsed.value, "#3b82f6"); - } - - #[test] - fn test_parse_variant_focus() { - let parsed = parse_single_class("focus:outline-none").expect("Should parse"); - assert_eq!(parsed.variants.len(), 1); - assert_eq!(parsed.variants[0], TailwindVariant::Focus); - } - - #[test] - fn test_parse_variant_dark() { - let parsed = parse_single_class("dark:bg-gray-800").expect("Should parse"); - assert_eq!(parsed.variants.len(), 1); - assert_eq!(parsed.variants[0], TailwindVariant::Dark); - assert_eq!(parsed.property, "background-color"); - assert_eq!(parsed.value, "#1f2937"); - } - - #[test] - fn test_parse_combined_responsive_variant() { - let parsed = parse_single_class("sm:hover:bg-red-500").expect("Should parse"); - assert_eq!(parsed.responsive, 1); - assert_eq!(parsed.variants.len(), 1); - assert_eq!(parsed.variants[0], TailwindVariant::Hover); - assert_eq!(parsed.property, "background-color"); - } - #[test] fn test_parse_arbitrary_width() { let parsed = parse_single_class("w-[100px]").expect("Should parse"); @@ -3675,78 +4034,7 @@ mod tests { fn test_parse_arbitrary_calc() { let parsed = parse_single_class("w-[calc(100%-20px)]").expect("Should parse"); assert_eq!(parsed.property, "width"); - assert_eq!(parsed.value, "calc(100%-20px)"); - } - - #[test] - fn test_parse_negative_margin() { - let parsed = parse_single_class("-m-4").expect("Should parse"); - assert_eq!(parsed.property, "margin"); - assert_eq!(parsed.value, "1rem"); - assert!(parsed.negative); - - let static_style = parsed.to_static_style().expect("style"); - assert_eq!(static_style.value(), "-1rem"); - } - - #[test] - fn test_parse_negative_translate() { - let parsed = parse_single_class("-translate-x-4").expect("Should parse"); - assert_eq!(parsed.property, "transform"); - assert_eq!(parsed.value, "translateX(-1rem)"); - } - - #[test] - fn test_variant_to_selector() { - assert_eq!( - TailwindVariant::Hover.to_selector(), - StyleSelector::Selector("&:hover".to_string()) - ); - assert_eq!( - TailwindVariant::Focus.to_selector(), - StyleSelector::Selector("&:focus".to_string()) - ); - assert_eq!( - TailwindVariant::Dark.to_selector(), - StyleSelector::Selector(":root[data-theme=dark] &".to_string()) - ); - assert_eq!( - TailwindVariant::GroupHover.to_selector(), - StyleSelector::Selector(":is([role=group],[data-group]):hover &".to_string()) - ); - } - - #[test] - fn test_to_static_style() { - let parsed = parse_single_class("bg-red-500").expect("Should parse"); - let static_style = parsed.to_static_style().expect("style"); - - assert_eq!(static_style.property(), "background-color"); - // ExtractStaticStyle::new() uses optimize_value() which uppercases hex colors - assert_eq!(static_style.value(), "#EF4444"); - assert_eq!(static_style.level(), 0); - assert!(static_style.selector().is_none()); - } - - #[test] - fn test_to_static_style_with_responsive() { - let parsed = parse_single_class("md:p-4").expect("Should parse"); - let static_style = parsed.to_static_style().expect("style"); - - assert_eq!(static_style.property(), "padding"); - assert_eq!(static_style.value(), "1rem"); - assert_eq!(static_style.level(), 2); - } - - #[test] - fn test_to_static_style_with_variant() { - let parsed = parse_single_class("hover:bg-blue-500").expect("Should parse"); - let static_style = parsed.to_static_style().expect("style"); - - assert_eq!(static_style.property(), "background-color"); - assert!(static_style.selector().is_some()); - let selector = static_style.selector().unwrap(); - assert_eq!(selector.to_string(), "&:hover"); + assert_eq!(parsed.value, "calc(100% - 20px)"); } #[test] @@ -3826,21 +4114,6 @@ mod tests { assert_eq!(parsed.property, "transition-timing-function"); } - #[test] - fn test_parse_transform_utilities() { - let parsed = parse_single_class("scale-50").expect("Should parse"); - assert_eq!(parsed.property, "transform"); - assert_eq!(parsed.value, "scale(0.5)"); - - let parsed = parse_single_class("rotate-45").expect("Should parse"); - assert_eq!(parsed.property, "transform"); - assert_eq!(parsed.value, "rotate(45deg)"); - - let parsed = parse_single_class("translate-x-4").expect("Should parse"); - assert_eq!(parsed.property, "transform"); - assert_eq!(parsed.value, "translateX(1rem)"); - } - #[test] fn test_parse_filter_utilities() { let parsed = parse_single_class("blur").expect("Should parse"); @@ -3851,417 +4124,57 @@ mod tests { assert_eq!(parsed.property, "filter"); assert_eq!(parsed.value, "blur(16px)"); - let parsed = parse_single_class("grayscale").expect("Should parse"); - assert_eq!(parsed.property, "filter"); - assert_eq!(parsed.value, "grayscale(100%)"); - } - - #[test] - fn test_parse_interactivity_utilities() { - let parsed = parse_single_class("cursor-pointer").expect("Should parse"); - assert_eq!(parsed.property, "cursor"); - assert_eq!(parsed.value, "pointer"); - - let parsed = parse_single_class("select-none").expect("Should parse"); - assert_eq!(parsed.property, "user-select"); - assert_eq!(parsed.value, "none"); - - let parsed = parse_single_class("pointer-events-none").expect("Should parse"); - assert_eq!(parsed.property, "pointer-events"); - assert_eq!(parsed.value, "none"); - } - - #[test] - fn test_parse_svg_utilities() { - let parsed = parse_single_class("fill-red-500").expect("Should parse"); - assert_eq!(parsed.property, "fill"); - assert_eq!(parsed.value, "#ef4444"); - - let parsed = parse_single_class("stroke-black").expect("Should parse"); - assert_eq!(parsed.property, "stroke"); - assert_eq!(parsed.value, "#000"); - - let parsed = parse_single_class("stroke-2").expect("Should parse"); - assert_eq!(parsed.property, "stroke-width"); - assert_eq!(parsed.value, "2"); - } - - #[test] - fn test_peer_variants() { - let parsed = parse_single_class("peer-hover:bg-blue-500").expect("Should parse"); - assert_eq!(parsed.variants.len(), 1); - assert_eq!(parsed.variants[0], TailwindVariant::PeerHover); - - let selector = parsed.variants[0].to_selector(); - assert_eq!(selector.to_string(), ".peer:hover ~ &"); - } - - #[test] - fn test_group_variants() { - let parsed = parse_single_class("group-hover:bg-blue-500").expect("Should parse"); - assert_eq!(parsed.variants.len(), 1); - assert_eq!(parsed.variants[0], TailwindVariant::GroupHover); - - let selector = parsed.variants[0].to_selector(); - assert_eq!( - selector.to_string(), - ":is([role=group],[data-group]):hover &" - ); - } - - #[test] - fn test_media_variants() { - let parsed = parse_single_class("print:hidden").expect("Should parse"); - assert_eq!(parsed.variants.len(), 1); - assert_eq!(parsed.variants[0], TailwindVariant::Print); - - let selector = parsed.variants[0].to_selector(); - if let StyleSelector::At { kind, query, .. } = selector { - assert_eq!(kind, css::style_selector::AtRuleKind::Media); - assert_eq!(query, "print"); - } else { - panic!("Expected At selector"); - } - } - - #[test] - fn test_accessibility_utilities() { - let parsed = parse_single_class("sr-only").expect("Should parse"); - assert_eq!(parsed.property, "position"); - assert_eq!(parsed.value, "absolute"); - } - - #[test] - fn test_empty_string() { - let styles = parse_tailwind_to_styles(""); - assert_eq!(styles, vec![]); - } - - #[test] - fn test_unknown_class() { - let result = parse_single_class("unknown-class"); - assert!(result.is_none()); - } - - #[test] - fn test_is_likely_tailwind_class_exact_matches() { - assert!(is_likely_tailwind_class("flex")); - assert!(is_likely_tailwind_class("grid")); - assert!(is_likely_tailwind_class("hidden")); - assert!(is_likely_tailwind_class("absolute")); - assert!(is_likely_tailwind_class("truncate")); - assert!(is_likely_tailwind_class("sr-only")); - } - - // ==================== WAVE 1: TailwindVariant Tests ==================== - - // Wave 1.1: Pseudo-class variant selectors (lines 104-131) - #[rstest] - #[case(TailwindVariant::FocusVisible, "&:focus-visible")] - #[case(TailwindVariant::FocusWithin, "&:focus-within")] - #[case(TailwindVariant::Visited, "&:visited")] - #[case(TailwindVariant::Enabled, "&:enabled")] - #[case(TailwindVariant::Checked, "&:checked")] - #[case(TailwindVariant::Indeterminate, "&:indeterminate")] - #[case(TailwindVariant::Default, "&:default")] - #[case(TailwindVariant::Required, "&:required")] - #[case(TailwindVariant::Valid, "&:valid")] - #[case(TailwindVariant::Invalid, "&:invalid")] - #[case(TailwindVariant::InRange, "&:in-range")] - #[case(TailwindVariant::OutOfRange, "&:out-of-range")] - #[case(TailwindVariant::PlaceholderShown, "&:placeholder-shown")] - #[case(TailwindVariant::Autofill, "&:autofill")] - #[case(TailwindVariant::ReadOnly, "&:read-only")] - #[case(TailwindVariant::FirstChild, "&:first-child")] - #[case(TailwindVariant::LastChild, "&:last-child")] - #[case(TailwindVariant::OnlyChild, "&:only-child")] - #[case(TailwindVariant::OddChild, "&:nth-child(odd)")] - #[case(TailwindVariant::EvenChild, "&:nth-child(even)")] - #[case(TailwindVariant::FirstOfType, "&:first-of-type")] - #[case(TailwindVariant::LastOfType, "&:last-of-type")] - #[case(TailwindVariant::OnlyOfType, "&:only-of-type")] - #[case(TailwindVariant::Empty, "&:empty")] - #[case(TailwindVariant::Target, "&:target")] - #[case(TailwindVariant::Open, "&[open]")] - fn test_variant_to_selector_pseudo_classes( - #[case] variant: TailwindVariant, - #[case] expected: &str, - ) { - assert_eq!( - variant.to_selector(), - StyleSelector::Selector(expected.to_string()) - ); - } - - // Wave 1.2: Pseudo-element variant selectors (lines 133-141) - #[rstest] - #[case(TailwindVariant::Placeholder, "&::placeholder")] - #[case(TailwindVariant::Before, "&::before")] - #[case(TailwindVariant::After, "&::after")] - #[case(TailwindVariant::Selection, "&::selection")] - #[case(TailwindVariant::Marker, "&::marker")] - #[case(TailwindVariant::FirstLetter, "&::first-letter")] - #[case(TailwindVariant::FirstLine, "&::first-line")] - #[case(TailwindVariant::Backdrop, "&::backdrop")] - #[case(TailwindVariant::File, "&::file-selector-button")] - fn test_variant_to_selector_pseudo_elements( - #[case] variant: TailwindVariant, - #[case] expected: &str, - ) { - assert_eq!( - variant.to_selector(), - StyleSelector::Selector(expected.to_string()) - ); - } - - // Wave 1.3: Group/Peer variant selectors (lines 143-151) - #[rstest] - #[case(TailwindVariant::GroupFocus, ":is([role=group],[data-group]):focus &")] - #[case( - TailwindVariant::GroupActive, - ":is([role=group],[data-group]):active &" - )] - #[case( - TailwindVariant::GroupDisabled, - ":is([role=group],[data-group]):disabled &" - )] - #[case(TailwindVariant::PeerFocus, ".peer:focus ~ &")] - #[case(TailwindVariant::PeerActive, ".peer:active ~ &")] - #[case(TailwindVariant::PeerDisabled, ".peer:disabled ~ &")] - #[case(TailwindVariant::PeerChecked, ".peer:checked ~ &")] - #[case(TailwindVariant::PeerInvalid, ".peer:invalid ~ &")] - fn test_variant_to_selector_group_peer( - #[case] variant: TailwindVariant, - #[case] expected: &str, - ) { - assert_eq!( - variant.to_selector(), - StyleSelector::Selector(expected.to_string()) - ); - } - - // Wave 1.4: Media variant selectors (lines 153-162) - #[rstest] - #[case(TailwindVariant::Print, "print")] - #[case(TailwindVariant::Screen, "screen")] - #[case(TailwindVariant::Portrait, "(orientation:portrait)")] - #[case(TailwindVariant::Landscape, "(orientation:landscape)")] - #[case(TailwindVariant::MotionReduce, "(prefers-reduced-motion:reduce)")] - #[case(TailwindVariant::MotionSafe, "(prefers-reduced-motion:no-preference)")] - #[case(TailwindVariant::ContrastMore, "(prefers-contrast:more)")] - #[case(TailwindVariant::ContrastLess, "(prefers-contrast:less)")] - #[case(TailwindVariant::ForcedColors, "(forced-colors:active)")] - fn test_variant_to_selector_media_queries( - #[case] variant: TailwindVariant, - #[case] expected_query: &str, - ) { - if let StyleSelector::At { kind, query, .. } = variant.to_selector() { - assert_eq!(kind, css::style_selector::AtRuleKind::Media); - assert_eq!(query, expected_query); - } else { - panic!("Expected At selector for {variant:?}"); - } - } - - // Wave 1.4 continued: Direction variants (lines 161-162) - #[rstest] - #[case(TailwindVariant::Rtl, "[dir=rtl] &")] - #[case(TailwindVariant::Ltr, "[dir=ltr] &")] - fn test_variant_to_selector_direction( - #[case] variant: TailwindVariant, - #[case] expected: &str, - ) { - assert_eq!( - variant.to_selector(), - StyleSelector::Selector(expected.to_string()) - ); - } - - // Wave 1.4: from_prefix tests for untested variants (lines 220-230) - #[rstest] - #[case("screen", TailwindVariant::Screen)] - #[case("portrait", TailwindVariant::Portrait)] - #[case("landscape", TailwindVariant::Landscape)] - #[case("motion-reduce", TailwindVariant::MotionReduce)] - #[case("motion-safe", TailwindVariant::MotionSafe)] - #[case("contrast-more", TailwindVariant::ContrastMore)] - #[case("contrast-less", TailwindVariant::ContrastLess)] - #[case("forced-colors", TailwindVariant::ForcedColors)] - #[case("rtl", TailwindVariant::Rtl)] - #[case("ltr", TailwindVariant::Ltr)] - fn test_from_prefix_media_variants(#[case] prefix: &str, #[case] expected: TailwindVariant) { - assert_eq!(TailwindVariant::from_prefix(prefix), Some(expected)); - } - - // ==================== WAVE 2: Edge Cases & Arbitrary Values ==================== - - // Wave 2.1: combine_selectors with At-rule (lines 292-295) - #[test] - fn test_combine_selectors_at_rule_with_hover() { - let parsed = parse_single_class("print:hover:bg-blue-500").expect("Should parse"); - assert_eq!(parsed.variants.len(), 2); - assert_eq!(parsed.variants[0], TailwindVariant::Print); - assert_eq!(parsed.variants[1], TailwindVariant::Hover); - - let static_style = parsed.to_static_style().expect("style"); - let selector = static_style.selector().expect("Should have selector"); - // Should combine into At rule with nested hover selector - if let StyleSelector::At { - kind, - query, - selector: nested, - .. - } = selector - { - assert_eq!(*kind, css::style_selector::AtRuleKind::Media); - assert_eq!(query, "print"); - assert!(nested.is_some()); - } else { - panic!("Expected At selector"); - } - } - - #[rstest] - #[case( - "print:motion-reduce:hidden", - Some("@media print and (prefers-reduced-motion:reduce)") - )] - #[case( - "motion-reduce:landscape:hover:hidden", - Some("@media(prefers-reduced-motion:reduce)and (orientation:landscape) &:hover") - )] - #[case("print:screen:hidden", None)] - fn test_combine_selectors_folds_every_media_variant( - #[case] class: &str, - #[case] expected: Option<&str>, - ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!( - parsed - .to_static_style() - .and_then(|style| style.selector().map(ToString::to_string)), - expected.map(str::to_string) - ); - } - - #[test] - fn test_remove_all_substr_matches_str_replace() { - for (haystack, needle) in [ - (":root[data-theme=dark] &", " &"), - (".peer:hover ~ & &", " &"), - (" & & &", " &"), - ("no-match-here", " &"), - ("", " &"), - (" &", " &"), - (":is([role=group],[data-group]):hover &", " &"), - ] { - let mut buf = haystack.to_string(); - remove_all_substr(&mut buf, needle); - assert_eq!(buf, haystack.replace(needle, ""), "haystack={haystack:?}"); - } + let parsed = parse_single_class("grayscale").expect("Should parse"); + assert_eq!(parsed.property, "filter"); + assert_eq!(parsed.value, "grayscale(100%)"); } #[test] - fn test_combine_selectors_byte_identical_to_prior_impl() { - // Oracle: the exact previous implementation of the multi-variant join loop. - fn old_combine(variants: &[TailwindVariant]) -> String { - let mut selector_str = String::new(); - for variant in variants { - if let StyleSelector::Selector(s) = variant.to_selector() { - if selector_str.is_empty() { - selector_str = s; - } else { - selector_str = - format!("{}{}", selector_str.replace(" &", ""), s.replace('&', "")); - if !selector_str.contains(" &") && !selector_str.ends_with(" &") { - selector_str.push_str(" &"); - } - } - } - } - selector_str - } + fn test_parse_interactivity_utilities() { + let parsed = parse_single_class("cursor-pointer").expect("Should parse"); + assert_eq!(parsed.property, "cursor"); + assert_eq!(parsed.value, "pointer"); - // A representative spread of &-prefixed, &-suffixed and multi-segment selectors - // in several orderings — exercises every branch of the in-place rewrite. - let combos: &[&[TailwindVariant]] = &[ - &[TailwindVariant::Dark, TailwindVariant::Hover], - &[TailwindVariant::Hover, TailwindVariant::Dark], - &[TailwindVariant::GroupHover, TailwindVariant::Focus], - &[TailwindVariant::PeerHover, TailwindVariant::Active], - &[ - TailwindVariant::Hover, - TailwindVariant::Focus, - TailwindVariant::Dark, - ], - &[ - TailwindVariant::Dark, - TailwindVariant::GroupHover, - TailwindVariant::Before, - ], - &[TailwindVariant::Before, TailwindVariant::After], - &[ - TailwindVariant::PeerHover, - TailwindVariant::GroupFocus, - TailwindVariant::Hover, - ], - ]; - - for variants in combos { - let cls = TailwindClass { - responsive: 0, - variants: variants.to_vec(), - property: "color", - value: Cow::Borrowed("red"), - negative: false, - }; - let expected = old_combine(variants); - match cls.combine_selectors().expect("selector") { - StyleSelector::Selector(actual) => { - assert_eq!(actual, expected, "variants={variants:?}"); - } - other => panic!("expected Selector, got {other:?} for {variants:?}"), - } - } - } + let parsed = parse_single_class("select-none").expect("Should parse"); + assert_eq!(parsed.property, "user-select"); + assert_eq!(parsed.value, "none"); - // Wave 2.2: is_valid_tailwind_value edge cases (lines 816, 825, 859, 864-866) - #[test] - fn test_has_tailwind_classes_arbitrary_syntax() { - // Line 816: arbitrary value syntax detection - assert!(has_tailwind_classes("w-[100px]")); - assert!(has_tailwind_classes("bg-[#ff0000]")); - assert!(has_tailwind_classes("p-[calc(100%-20px)]")); + let parsed = parse_single_class("pointer-events-none").expect("Should parse"); + assert_eq!(parsed.property, "pointer-events"); + assert_eq!(parsed.value, "none"); } #[test] - fn test_is_valid_tailwind_value_empty() { - // Line 825: empty value should return false - assert!(!is_valid_tailwind_value("")); + fn test_parse_svg_utilities() { + let parsed = parse_single_class("fill-red-500").expect("Should parse"); + assert_eq!(parsed.property, "fill"); + assert_eq!(parsed.value, "#ef4444"); + + let parsed = parse_single_class("stroke-black").expect("Should parse"); + assert_eq!(parsed.property, "stroke"); + assert_eq!(parsed.value, "#000"); + + let parsed = parse_single_class("stroke-2").expect("Should parse"); + assert_eq!(parsed.property, "stroke-width"); + assert_eq!(parsed.value, "2"); } #[test] - fn test_is_valid_tailwind_value_size_keywords() { - // Line 859: size keywords - assert!(is_valid_tailwind_value("xs")); - assert!(is_valid_tailwind_value("sm")); - assert!(is_valid_tailwind_value("md")); - assert!(is_valid_tailwind_value("lg")); - assert!(is_valid_tailwind_value("xl")); - assert!(is_valid_tailwind_value("2xl")); - assert!(is_valid_tailwind_value("3xl")); + fn test_empty_string() { + let styles = parse_tailwind_to_styles(""); + assert_eq!(styles, vec![]); } #[test] - fn test_is_valid_tailwind_value_fractions() { - // Lines 864-866: fraction values - assert!(is_valid_tailwind_value("1/2")); - assert!(is_valid_tailwind_value("1/3")); - assert!(is_valid_tailwind_value("2/3")); - assert!(is_valid_tailwind_value("1/4")); - assert!(is_valid_tailwind_value("3/4")); + fn test_unknown_class() { + let result = parse_single_class("unknown-class"); + assert!(result.is_none()); } + // ==================== WAVE 1: TailwindVariant Tests ==================== + + // ==================== WAVE 2: Edge Cases & Arbitrary Values ==================== + // Wave 2.3: parse_arbitrary_value extended tests (lines 1057-1084) #[rstest] #[case("border-[#ff0000]", "border-color", "#ff0000")] @@ -4269,7 +4182,6 @@ mod tests { #[case("z-[999]", "z-index", "999")] #[case("font-[Arial]", "font-family", "Arial")] #[case("tracking-[0.2em]", "letter-spacing", "0.2em")] - #[case("leading-[2]", "line-height", "2")] #[case("duration-[500ms]", "transition-duration", "500ms")] #[case("delay-[200ms]", "transition-delay", "200ms")] #[case("aspect-[16/9]", "aspect-ratio", "16/9")] @@ -4286,33 +4198,37 @@ mod tests { } #[rstest] - #[case("scale-[1.5]", "transform", "scale(1.5)")] - #[case("rotate-[30deg]", "transform", "rotate(30deg)")] - #[case("translate-x-[50px]", "transform", "translateX(50px)")] - #[case("translate-y-[50px]", "transform", "translateY(50px)")] - #[case("skew-x-[10deg]", "transform", "skewX(10deg)")] - #[case("skew-y-[10deg]", "transform", "skewY(10deg)")] + #[case("scale-[1.5]", &[("scale", "1.5")])] + #[case("rotate-[30deg]", &[("rotate", "30deg")])] + #[case("translate-x-[50px]", &[("--tw-translate-x", "50px"), ("translate", TRANSLATE)])] + #[case("translate-y-[50px]", &[("--tw-translate-y", "50px"), ("translate", TRANSLATE)])] + #[case( + "translate-[50px]", + &[("--tw-translate-x", "50px"), ("--tw-translate-y", "50px"), ("translate", TRANSLATE)] + )] + #[case("scale-x-[2]", &[("--tw-scale-x", "2"), ("scale", SCALE)])] + #[case("skew-x-[10deg]", &[("--tw-skew-x", "skewX(10deg)"), ("transform", TRANSFORM)])] + #[case("skew-y-[10deg]", &[("--tw-skew-y", "skewY(10deg)"), ("transform", TRANSFORM)])] + #[case( + "skew-[10deg]", + &[ + ("--tw-skew-x", "skewX(10deg)"), + ("--tw-skew-y", "skewY(10deg)"), + ("transform", TRANSFORM), + ] + )] + #[case("-translate-x-[13px]", &[("--tw-translate-x", "calc(13px * -1)"), ("translate", TRANSLATE)])] + #[case("-skew-y-[6deg]", &[("--tw-skew-y", "skewY(calc(6deg * -1))"), ("transform", TRANSFORM)])] fn test_parse_arbitrary_transform_values( #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, + #[case] expected: &[(&str, &str)], ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); + assert_eq!(declarations(class), Some(owned(expected))); } #[rstest] - #[case( - "grid-cols-[200px_1fr]", - "grid-template-columns", - "repeat(200px 1fr, minmax(0, 1fr))" - )] - #[case( - "grid-rows-[auto_1fr]", - "grid-template-rows", - "repeat(auto 1fr, minmax(0, 1fr))" - )] + #[case("grid-cols-[200px_1fr]", "grid-template-columns", "200px 1fr")] + #[case("grid-rows-[auto_1fr]", "grid-template-rows", "auto 1fr")] #[case("col-span-[2]", "grid-column", "span 2 / span 2")] #[case("row-span-[3]", "grid-row", "span 3 / span 3")] fn test_parse_arbitrary_grid_values( @@ -4576,29 +4492,32 @@ mod tests { assert_eq!(parsed.value, expected_value); } + // `leading-*` also sets `--tw-leading`, which wins over a font size's line height #[rstest] - #[case("leading-none", "line-height", "1")] - #[case("leading-tight", "line-height", "1.25")] - #[case("leading-snug", "line-height", "1.375")] - #[case("leading-normal", "line-height", "1.5")] - #[case("leading-relaxed", "line-height", "1.625")] - #[case("leading-loose", "line-height", "2")] - #[case("leading-3", "line-height", ".75rem")] - #[case("leading-4", "line-height", "1rem")] - #[case("leading-5", "line-height", "1.25rem")] - #[case("leading-6", "line-height", "1.5rem")] - #[case("leading-7", "line-height", "1.75rem")] - #[case("leading-8", "line-height", "2rem")] - #[case("leading-9", "line-height", "2.25rem")] - #[case("leading-10", "line-height", "2.5rem")] - fn test_parse_leading_utilities( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); + #[case("leading-none", "1")] + #[case("leading-tight", "1.25")] + #[case("leading-snug", "1.375")] + #[case("leading-normal", "1.5")] + #[case("leading-relaxed", "1.625")] + #[case("leading-loose", "2")] + #[case("leading-3", "0.75rem")] + #[case("leading-4", "1rem")] + #[case("leading-5", "1.25rem")] + #[case("leading-6", "1.5rem")] + #[case("leading-7", "1.75rem")] + #[case("leading-8", "2rem")] + #[case("leading-9", "2.25rem")] + #[case("leading-10", "2.5rem")] + #[case("leading-[2]", "2")] + #[case("leading-(--l)", "var(--l)")] + fn test_parse_leading_utilities(#[case] class: &str, #[case] expected_value: &str) { + assert_eq!( + declarations(class), + Some(owned(&[ + ("--tw-leading", expected_value), + ("line-height", expected_value) + ])) + ); } // Wave 4.4: List styles & alignment (lines 1947-1965) @@ -4757,24 +4676,27 @@ mod tests { assert_eq!(parsed.value, expected_value); } - // Wave 5.3: Border corners/sides (lines 2081-2189) + // Sides round both of their corners #[rstest] - #[case("rounded-t-lg", "border-top-left-radius", "0.5rem")] - #[case("rounded-r-lg", "border-top-right-radius", "0.5rem")] - #[case("rounded-b-lg", "border-bottom-right-radius", "0.5rem")] - #[case("rounded-l-lg", "border-bottom-left-radius", "0.5rem")] - #[case("rounded-tl-lg", "border-top-left-radius", "0.5rem")] - #[case("rounded-tr-lg", "border-top-right-radius", "0.5rem")] - #[case("rounded-br-lg", "border-bottom-right-radius", "0.5rem")] - #[case("rounded-bl-lg", "border-bottom-left-radius", "0.5rem")] - fn test_parse_border_radius_corners( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); + #[case("rounded-t-lg", &[("border-top-left-radius", "0.5rem"), ("border-top-right-radius", "0.5rem")])] + #[case("rounded-r-lg", &[("border-top-right-radius", "0.5rem"), ("border-bottom-right-radius", "0.5rem")])] + #[case("rounded-b-lg", &[("border-bottom-right-radius", "0.5rem"), ("border-bottom-left-radius", "0.5rem")])] + #[case("rounded-l-lg", &[("border-top-left-radius", "0.5rem"), ("border-bottom-left-radius", "0.5rem")])] + #[case("rounded-s-lg", &[("border-start-start-radius", "0.5rem"), ("border-end-start-radius", "0.5rem")])] + #[case("rounded-e-lg", &[("border-start-end-radius", "0.5rem"), ("border-end-end-radius", "0.5rem")])] + #[case("rounded-t", &[("border-top-left-radius", "0.25rem"), ("border-top-right-radius", "0.25rem")])] + #[case("rounded-tl-lg", &[("border-top-left-radius", "0.5rem")])] + #[case("rounded-tr-lg", &[("border-top-right-radius", "0.5rem")])] + #[case("rounded-br-lg", &[("border-bottom-right-radius", "0.5rem")])] + #[case("rounded-bl-lg", &[("border-bottom-left-radius", "0.5rem")])] + #[case("rounded-ss-lg", &[("border-start-start-radius", "0.5rem")])] + #[case("rounded-se-lg", &[("border-start-end-radius", "0.5rem")])] + #[case("rounded-ee-lg", &[("border-end-end-radius", "0.5rem")])] + #[case("rounded-es-lg", &[("border-end-start-radius", "0.5rem")])] + #[case("rounded-t-[3px]", &[("border-top-left-radius", "3px"), ("border-top-right-radius", "3px")])] + #[case("rounded-[3px]", &[("border-radius", "3px")])] + fn test_parse_border_radius_corners(#[case] class: &str, #[case] expected: &[(&str, &str)]) { + assert_eq!(declarations(class), Some(owned(expected))); } #[rstest] @@ -5109,81 +5031,132 @@ mod tests { assert_eq!(parsed.value, expected_value); } - // Wave 5.8: Transforms (lines 2630-2716) #[rstest] - #[case("scale-x-0", "transform", "scaleX(0)")] - #[case("scale-x-50", "transform", "scaleX(0.5)")] - #[case("scale-x-100", "transform", "scaleX(1)")] - #[case("scale-x-150", "transform", "scaleX(1.5)")] - #[case("scale-y-0", "transform", "scaleY(0)")] - #[case("scale-y-50", "transform", "scaleY(0.5)")] - #[case("scale-y-100", "transform", "scaleY(1)")] - #[case("scale-y-150", "transform", "scaleY(1.5)")] + #[case("scale-x-0", "--tw-scale-x", "0%")] + #[case("scale-x-50", "--tw-scale-x", "50%")] + #[case("scale-x-100", "--tw-scale-x", "100%")] + #[case("scale-x-150", "--tw-scale-x", "150%")] + #[case("scale-y-0", "--tw-scale-y", "0%")] + #[case("scale-y-50", "--tw-scale-y", "50%")] + #[case("scale-y-100", "--tw-scale-y", "100%")] + #[case("scale-y-150", "--tw-scale-y", "150%")] + #[case("-scale-x-50", "--tw-scale-x", "-50%")] fn test_parse_scale_axis_utilities( #[case] class: &str, #[case] expected_prop: &str, #[case] expected_value: &str, ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); + assert_eq!( + declarations(class), + Some(owned(&[(expected_prop, expected_value), ("scale", SCALE)])) + ); } #[rstest] - #[case("rotate-0", "transform", "rotate(0deg)")] - #[case("rotate-1", "transform", "rotate(1deg)")] - #[case("rotate-2", "transform", "rotate(2deg)")] - #[case("rotate-3", "transform", "rotate(3deg)")] - #[case("rotate-6", "transform", "rotate(6deg)")] - #[case("rotate-12", "transform", "rotate(12deg)")] - #[case("rotate-90", "transform", "rotate(90deg)")] - #[case("rotate-180", "transform", "rotate(180deg)")] - fn test_parse_rotate_utilities( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); + #[case("scale-110", "110%")] + #[case("-scale-110", "-110%")] + fn test_parse_scale_utilities(#[case] class: &str, #[case] expected_value: &str) { + assert_eq!( + declarations(class), + Some(owned(&[ + ("--tw-scale-x", expected_value), + ("--tw-scale-y", expected_value), + ("--tw-scale-z", expected_value), + ("scale", SCALE), + ])) + ); + } + + #[rstest] + #[case("rotate-0", "0deg")] + #[case("rotate-1", "1deg")] + #[case("rotate-2", "2deg")] + #[case("rotate-3", "3deg")] + #[case("rotate-6", "6deg")] + #[case("rotate-12", "12deg")] + #[case("rotate-90", "90deg")] + #[case("rotate-180", "180deg")] + #[case("rotate-15", "15deg")] + #[case("-rotate-45", "-45deg")] + fn test_parse_rotate_utilities(#[case] class: &str, #[case] expected_value: &str) { + assert_eq!( + declarations(class), + Some(owned(&[("rotate", expected_value)])) + ); } #[rstest] - #[case("translate-y-4", "transform", "translateY(1rem)")] - #[case("translate-y-px", "transform", "translateY(1px)")] - #[case("translate-y-full", "transform", "translateY(100%)")] - #[case("translate-y-1/2", "transform", "translateY(50%)")] + #[case("translate-y-4", "--tw-translate-y", "1rem")] + #[case("translate-y-px", "--tw-translate-y", "1px")] + #[case("translate-y-full", "--tw-translate-y", "100%")] + #[case("translate-y-1/2", "--tw-translate-y", "50%")] + #[case("translate-x-4", "--tw-translate-x", "1rem")] + #[case("-translate-x-4", "--tw-translate-x", "-1rem")] + #[case("-translate-x-1/2", "--tw-translate-x", "-50%")] fn test_parse_translate_y_utilities( #[case] class: &str, #[case] expected_prop: &str, #[case] expected_value: &str, ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); + assert_eq!( + declarations(class), + Some(owned(&[ + (expected_prop, expected_value), + ("translate", TRANSLATE) + ])) + ); + } + + #[test] + fn test_parse_translate_both_axes() { + assert_eq!( + declarations("translate-2"), + Some(owned(&[ + ("--tw-translate-x", "0.5rem"), + ("--tw-translate-y", "0.5rem"), + ("translate", TRANSLATE), + ])) + ); } #[rstest] - #[case("skew-x-0", "transform", "skewX(0deg)")] - #[case("skew-x-1", "transform", "skewX(1deg)")] - #[case("skew-x-2", "transform", "skewX(2deg)")] - #[case("skew-x-3", "transform", "skewX(3deg)")] - #[case("skew-x-6", "transform", "skewX(6deg)")] - #[case("skew-x-12", "transform", "skewX(12deg)")] - #[case("skew-y-0", "transform", "skewY(0deg)")] - #[case("skew-y-1", "transform", "skewY(1deg)")] - #[case("skew-y-2", "transform", "skewY(2deg)")] - #[case("skew-y-3", "transform", "skewY(3deg)")] - #[case("skew-y-6", "transform", "skewY(6deg)")] - #[case("skew-y-12", "transform", "skewY(12deg)")] + #[case("skew-x-0", "--tw-skew-x", "skewX(0deg)")] + #[case("skew-x-1", "--tw-skew-x", "skewX(1deg)")] + #[case("skew-x-2", "--tw-skew-x", "skewX(2deg)")] + #[case("skew-x-3", "--tw-skew-x", "skewX(3deg)")] + #[case("skew-x-6", "--tw-skew-x", "skewX(6deg)")] + #[case("skew-x-12", "--tw-skew-x", "skewX(12deg)")] + #[case("skew-y-0", "--tw-skew-y", "skewY(0deg)")] + #[case("skew-y-1", "--tw-skew-y", "skewY(1deg)")] + #[case("skew-y-2", "--tw-skew-y", "skewY(2deg)")] + #[case("skew-y-3", "--tw-skew-y", "skewY(3deg)")] + #[case("skew-y-6", "--tw-skew-y", "skewY(6deg)")] + #[case("skew-y-12", "--tw-skew-y", "skewY(12deg)")] + #[case("-skew-y-6", "--tw-skew-y", "skewY(-6deg)")] fn test_parse_skew_utilities( #[case] class: &str, #[case] expected_prop: &str, #[case] expected_value: &str, ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); + assert_eq!( + declarations(class), + Some(owned(&[ + (expected_prop, expected_value), + ("transform", TRANSFORM) + ])) + ); + } + + #[test] + fn test_parse_skew_both_axes() { + assert_eq!( + declarations("skew-3"), + Some(owned(&[ + ("--tw-skew-x", "skewX(3deg)"), + ("--tw-skew-y", "skewY(3deg)"), + ("transform", TRANSFORM), + ])) + ); } #[rstest] @@ -5316,46 +5289,11 @@ mod tests { // WAVE 6: Coverage Gap Tests - Remaining Uncovered Lines // ============================================================================ - // Wave 6.1: TailwindVariant::from_prefix() returning None (line 230) - #[test] - fn test_tailwind_variant_unknown_prefix_returns_none() { - assert_eq!(TailwindVariant::from_prefix("unknown-prefix"), None); - assert_eq!(TailwindVariant::from_prefix("nonexistent"), None); - assert_eq!(TailwindVariant::from_prefix("foo-bar"), None); - assert_eq!(TailwindVariant::from_prefix("xyz"), None); - assert_eq!(TailwindVariant::from_prefix(""), None); - } - - // Wave 6.2: parse_arbitrary_value edge cases (lines 1015, 1084) - #[test] - fn test_parse_arbitrary_value_malformed_brackets() { - // Test bracket_end <= bracket_start case (line 1015) - let result = parse_arbitrary_value("]["); - assert!(result.is_none()); - - let result = parse_arbitrary_value("w-]["); - assert!(result.is_none()); - } - - #[test] - fn test_parse_arbitrary_value_unknown_prefix() { - // Test unknown prefix fallback (line 1084) - let result = parse_arbitrary_value("unknown-[value]"); - assert!(result.is_none()); - - let result = parse_arbitrary_value("foo-[bar]"); - assert!(result.is_none()); - - let result = parse_arbitrary_value("xyz-[123px]"); - assert!(result.is_none()); - } - - // Wave 6.3: Custom aspect-ratio (line 1202) #[rstest] - #[case("aspect-4-3", "aspect-ratio", "4 / 3")] - #[case("aspect-16-10", "aspect-ratio", "16 / 10")] - #[case("aspect-21-9", "aspect-ratio", "21 / 9")] - #[case("aspect-3-2", "aspect-ratio", "3 / 2")] + #[case("aspect-4/3", "aspect-ratio", "4/3")] + #[case("aspect-16/10", "aspect-ratio", "16/10")] + #[case("aspect-21/9", "aspect-ratio", "21/9")] + #[case("aspect-3/2", "aspect-ratio", "3/2")] fn test_parse_custom_aspect_ratio( #[case] class: &str, #[case] expected_prop: &str, @@ -5455,16 +5393,16 @@ mod tests { assert_eq!(parsed.value, expected_value); } - #[rstest] - #[case("truncate", "text-overflow", "ellipsis")] - fn test_parse_truncate( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse truncate"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); + #[test] + fn test_parse_truncate() { + assert_eq!( + declarations("truncate"), + Some(owned(&[ + ("overflow", "hidden"), + ("text-overflow", "ellipsis"), + ("white-space", "nowrap"), + ])) + ); } #[rstest] @@ -5484,31 +5422,23 @@ mod tests { assert_eq!(parsed.value, expected_value); } - // Wave 6.8: Tracking/leading fallbacks (lines 1918, 1940) + // Only Tailwind's names compile; any other word is someone else's class #[rstest] - #[case("tracking-custom", "letter-spacing", "custom")] - #[case("tracking-0.5em", "letter-spacing", "0.5em")] - fn test_parse_tracking_fallback( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse tracking fallback"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); + #[case("tracking-custom")] + #[case("tracking-0.5em")] + fn test_parse_tracking_fallback(#[case] class: &str) { + assert_eq!(declarations(class), None); } - #[rstest] - #[case("leading-custom", "line-height", "custom")] - #[case("leading-1.5", "line-height", "1.5")] - fn test_parse_leading_fallback( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse leading fallback"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); + #[test] + fn test_parse_leading_fallback() { + assert_eq!( + declarations("leading-1.5"), + Some(owned(&[ + ("--tw-leading", "0.375rem"), + ("line-height", "0.375rem") + ])) + ); } // Wave 6.9: List style fallback (line 1953) @@ -5739,29 +5669,23 @@ mod tests { assert!(result.is_none()); } - // Wave 6.18: Transform fallbacks (lines 2654-2714) #[test] fn test_rotate_unknown_value_returns_none() { - let result = parse_single_class("rotate-unknown"); - assert!(result.is_none()); - let result = parse_single_class("rotate-999"); - assert!(result.is_none()); + assert!(parse_single_class("rotate-unknown").is_none()); + // 3D rotation is not compiled + assert!(parse_single_class("rotate-x-45").is_none()); } #[test] fn test_skew_x_unknown_value_returns_none() { - let result = parse_single_class("skew-x-unknown"); - assert!(result.is_none()); - let result = parse_single_class("skew-x-99"); - assert!(result.is_none()); + assert!(parse_single_class("skew-x-unknown").is_none()); + assert!(parse_single_class("skew-x-1px").is_none()); } #[test] fn test_skew_y_unknown_value_returns_none() { - let result = parse_single_class("skew-y-unknown"); - assert!(result.is_none()); - let result = parse_single_class("skew-y-99"); - assert!(result.is_none()); + assert!(parse_single_class("skew-y-unknown").is_none()); + assert!(parse_single_class("skew-y-1px").is_none()); } #[test] @@ -5811,34 +5735,6 @@ mod tests { // WAVE 7: Additional Coverage Gap Tests // ============================================================================ - // Wave 7.1: is_likely_tailwind_class arbitrary value syntax (line 816) - #[rstest] - #[case("w-[100px]")] - #[case("h-[50vh]")] - #[case("bg-[#ff0000]")] - #[case("text-[1.5rem]")] - #[case("grid-cols-[1fr_2fr]")] - #[case("p-[calc(100%-20px)]")] - fn test_is_likely_tailwind_class_arbitrary_syntax(#[case] class: &str) { - assert!(is_likely_tailwind_class(class)); - } - - // Wave 7.2: is_valid_tailwind_value fraction values (lines 864-866) - #[rstest] - #[case("1/2", true)] - #[case("2/3", true)] - #[case("3/4", true)] - #[case("5/6", true)] - #[case("11/12", true)] - #[case("a/b", false)] - #[case("foo/bar", false)] - fn test_is_valid_tailwind_value_fractions_extended( - #[case] value: &str, - #[case] expected: bool, - ) { - assert_eq!(is_valid_tailwind_value(value), expected); - } - // Wave 7.3: min-w/max-w/min-h/max-h/size with SPACING_SCALE values (lines 1682, 1719, 1760, 1783, 1797) #[rstest] #[case("min-w-4", "min-width", "1rem")] @@ -5902,19 +5798,21 @@ mod tests { } #[rstest] - #[case("size-4", "width", "1rem")] - #[case("size-8", "width", "2rem")] - #[case("size-12", "width", "3rem")] - #[case("size-px", "width", "1px")] - #[case("size-0.5", "width", "0.125rem")] - fn test_parse_size_spacing_scale( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse size spacing"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); + #[case("size-4", "1rem")] + #[case("size-8", "2rem")] + #[case("size-12", "3rem")] + #[case("size-px", "1px")] + #[case("size-0.5", "0.125rem")] + #[case("size-full", "100%")] + #[case("size-[3px]", "3px")] + fn test_parse_size_spacing_scale(#[case] class: &str, #[case] expected_value: &str) { + assert_eq!( + declarations(class), + Some(owned(&[ + ("width", expected_value), + ("height", expected_value) + ])) + ); } // Wave 7.4: text- prefix fallback when not font-size or text-align (line 1833) @@ -6066,30 +5964,6 @@ mod tests { // WAVE 8: Coverage Gap Tests for Lines 816, 866, 295 // ============================================================================ - // Wave 8.1: has_tailwind_classes with arbitrary CSS syntax (line 816) - // Classes with [...] that don't match any prefix trigger the arbitrary check - // Note: Classes with ':' get split (variant prefix removal), so avoid them - #[rstest] - #[case("custom-[value]")] - #[case("xyz-[test]")] - #[case("my-class-[10px]")] - #[case("foo-[bar]")] - fn test_has_tailwind_classes_arbitrary_css_syntax(#[case] class: &str) { - assert!(has_tailwind_classes(class)); - } - - // Wave 8.2: is_likely_tailwind_class with pure arbitrary syntax (line 816) - // These classes have brackets but don't match any standard prefix - // The check at line 816 triggers when a class has [...] but doesn't match prefixes - #[rstest] - #[case("custom-[value]")] - #[case("xyz-[100px]")] - #[case("my-[test]")] - #[case("unknown-[arbitrary]")] - fn test_is_likely_tailwind_class_pure_arbitrary_syntax(#[case] class: &str) { - assert!(is_likely_tailwind_class(class)); - } - // Wave 8.3: parse_tailwind_to_styles integration for rounded variants (via BORDER_RADIUS_SCALE) #[test] #[serial] @@ -6114,16 +5988,531 @@ mod tests { assert_eq!(styles.len(), 5); } - // Wave 8.5: is_valid_tailwind_value fraction edge case (line 866) - // Edge case where value starts with '/' - empty first part is vacuously all-digits + // ==================== Variants (Tailwind v4) ==================== + + #[rstest] + #[case("first:p-4", "&:first-child")] + #[case("last:p-4", "&:last-child")] + #[case("only:p-4", "&:only-child")] + #[case("odd:p-4", "&:nth-child(odd)")] + #[case("even:p-4", "&:nth-child(even)")] + #[case("first-of-type:p-4", "&:first-of-type")] + #[case("last-of-type:p-4", "&:last-of-type")] + #[case("only-of-type:p-4", "&:only-of-type")] + #[case("visited:p-4", "&:visited")] + #[case("target:p-4", "&:target")] + #[case("open:p-4", "&:is([open], :popover-open, :open)")] + #[case("default:p-4", "&:default")] + #[case("checked:p-4", "&:checked")] + #[case("indeterminate:p-4", "&:indeterminate")] + #[case("placeholder-shown:p-4", "&:placeholder-shown")] + #[case("autofill:p-4", "&:autofill")] + #[case("optional:p-4", "&:optional")] + #[case("required:p-4", "&:required")] + #[case("valid:p-4", "&:valid")] + #[case("invalid:p-4", "&:invalid")] + #[case("user-valid:p-4", "&:user-valid")] + #[case("user-invalid:p-4", "&:user-invalid")] + #[case("in-range:p-4", "&:in-range")] + #[case("out-of-range:p-4", "&:out-of-range")] + #[case("read-only:p-4", "&:read-only")] + #[case("empty:p-4", "&:empty")] + #[case("focus-within:p-4", "&:focus-within")] + #[case("focus:p-4", "&:focus")] + #[case("focus-visible:p-4", "&:focus-visible")] + #[case("active:p-4", "&:active")] + #[case("enabled:p-4", "&:enabled")] + #[case("disabled:p-4", "&:disabled")] + #[case("inert:p-4", "&:is([inert], [inert] *)")] + #[case("placeholder:p-4", "&::placeholder")] + #[case("file:p-4", "&::file-selector-button")] + #[case("backdrop:p-4", "&::backdrop")] + #[case("first-letter:p-4", "&::first-letter")] + #[case("first-line:p-4", "&::first-line")] + #[case("details-content:p-4", "&::details-content")] + #[case("dark:p-4", ":root[data-theme=dark] &")] + #[case("rtl:p-4", "&:where(:dir(rtl), [dir=\"rtl\"], [dir=\"rtl\"] *)")] + #[case("ltr:p-4", "&:where(:dir(ltr), [dir=\"ltr\"], [dir=\"ltr\"] *)")] + #[case("*:p-4", ":is(& > *)")] + #[case("**:p-4", ":is(& *)")] + #[case("aria-checked:p-4", "&[aria-checked=\"true\"]")] + #[case("aria-[sort=ascending]:p-4", "&[aria-sort=\"ascending\"]")] + #[case("aria-[a=b_c]:p-4", "&[aria-a=\"b c\"]")] + #[case("aria-[label]:p-4", "&[aria-label]")] + #[case("data-active:p-4", "&[data-active]")] + #[case("data-[state=open]:p-4", "&[data-state=\"open\"]")] + #[case("data-[state='open']:p-4", "&[data-state='open']")] + #[case("nth-3:p-4", "&:nth-child(3)")] + #[case("nth-last-3:p-4", "&:nth-last-child(3)")] + #[case("nth-of-type-3:p-4", "&:nth-of-type(3)")] + #[case("nth-last-of-type-3:p-4", "&:nth-last-of-type(3)")] + #[case("nth-[2n+1_of_.x]:p-4", "&:nth-child(2n+1 of .x)")] + #[case("has-[img]:p-4", "&:has(:is(img))")] + #[case("has-[.x_y]:p-4", "&:has(:is(.x y))")] + #[case("has-checked:p-4", "&:has(:checked)")] + #[case("has-aria-checked:p-4", "&:has([aria-checked=\"true\"])")] + #[case("group-focus:p-4", "&:is(:where(.group):focus *)")] + #[case("group-first:p-4", "&:is(:where(.group):first-child *)")] + #[case("group-data-active:p-4", "&:is(:where(.group)[data-active] *)")] + #[case("group-has-checked:p-4", "&:is(:where(.group):has(:checked) *)")] + #[case("group-focus/item:p-4", "&:is(:where(.group\\/item):focus *)")] + #[case("peer-checked:p-4", "&:is(:where(.peer):checked ~ *)")] + #[case( + "peer-aria-checked:p-4", + "&:is(:where(.peer)[aria-checked=\"true\"] ~ *)" + )] + #[case( + "peer-placeholder-shown:p-4", + "&:is(:where(.peer):placeholder-shown ~ *)" + )] + #[case("[&>*]:p-4", "&>*")] + #[case("[&_p]:p-4", "& p")] + #[case("[.dark_&]:p-4", ".dark &")] + #[case("[&:nth-child(3)]:p-4", "&:nth-child(3)")] + #[case("hover:focus:p-4", "@media(hover:hover) &:hover:focus")] + #[case("*:hover:p-4", "@media(hover:hover) :is(& > *):hover")] + #[case("hover:[&>*]:p-4", "@media(hover:hover) &:hover>*")] + #[case("[&>*]:hover:p-4", "@media(hover:hover) &>*:hover")] + #[case( + "group-hover:focus:p-4", + "@media(hover:hover) &:is(:where(.group):hover *):focus" + )] + #[case("dark:hover:p-4", "@media(hover:hover) :root[data-theme=dark] &:hover")] + #[case("hover:dark:p-4", "@media(hover:hover) :root[data-theme=dark] &:hover")] + #[case("before:hover:p-4", "@media(hover:hover) &::before:hover")] + fn test_selector_variants(#[case] class: &str, #[case] expected: &str) { + assert_eq!(conditions(class), Some((0, vec![expected.to_string()]))); + } + + #[rstest] + #[case("hover:p-4", 0, &["@media(hover:hover) &:hover"])] + #[case("group-hover:p-4", 0, &["@media(hover:hover) &:is(:where(.group):hover *)"])] + #[case("group-hover/item:p-4", 0, &["@media(hover:hover) &:is(:where(.group\\/item):hover *)"])] + #[case("peer-hover:p-4", 0, &["@media(hover:hover) &:is(:where(.peer):hover ~ *)"])] + #[case("motion-safe:p-4", 0, &["@media(prefers-reduced-motion:no-preference)"])] + #[case("motion-reduce:p-4", 0, &["@media(prefers-reduced-motion:reduce)"])] + #[case("contrast-more:p-4", 0, &["@media(prefers-contrast:more)"])] + #[case("contrast-less:p-4", 0, &["@media(prefers-contrast:less)"])] + #[case("portrait:p-4", 0, &["@media(orientation:portrait)"])] + #[case("landscape:p-4", 0, &["@media(orientation:landscape)"])] + #[case("print:p-4", 0, &["@media print"])] + #[case("forced-colors:p-4", 0, &["@media(forced-colors:active)"])] + #[case("inverted-colors:p-4", 0, &["@media(inverted-colors:inverted)"])] + #[case("noscript:p-4", 0, &["@media(scripting:none)"])] + #[case("pointer-fine:p-4", 0, &["@media(pointer:fine)"])] + #[case("pointer-coarse:p-4", 0, &["@media(pointer:coarse)"])] + #[case("pointer-none:p-4", 0, &["@media(pointer:none)"])] + #[case("any-pointer-fine:p-4", 0, &["@media(any-pointer:fine)"])] + #[case("any-pointer-coarse:p-4", 0, &["@media(any-pointer:coarse)"])] + #[case("any-pointer-none:p-4", 0, &["@media(any-pointer:none)"])] + #[case("supports-[display:grid]:p-4", 0, &["@supports(display:grid)"])] + #[case("supports-[(display:grid)]:p-4", 0, &["@supports(display:grid)"])] + #[case("[@media(width>=10px)]:p-4", 0, &["@media(width>=10px)"])] + #[case("[@supports(display:grid)]:p-4", 0, &["@supports(display:grid)"])] + #[case("sm:p-4", 1, &[""])] + #[case("md:p-4", 2, &[""])] + #[case("lg:p-4", 3, &[""])] + #[case("xl:p-4", 4, &[""])] + #[case("2xl:p-4", 5, &[""])] + #[case("sm:md:p-4", 2, &[""])] + #[case("md:hover:p-4", 2, &["@media(hover:hover) &:hover"])] + #[case("hover:md:p-4", 2, &["@media(hover:hover) &:hover"])] + #[case("selection:p-4", 0, &["& *::selection", "&::selection"])] + #[case( + "marker:p-4", + 0, + &["& *::marker", "&::marker", "& *::-webkit-details-marker", "&::-webkit-details-marker"] + )] + #[case("md:selection:p-4", 2, &["& *::selection", "&::selection"])] + #[case("[&_p,&_li]:p-4", 0, &["& p", "& li"])] + #[case("[&_p,&_li]:hover:p-4", 0, &["@media(hover:hover) & p:hover", "@media(hover:hover) & li:hover"])] + fn test_variant_conditions(#[case] class: &str, #[case] level: u8, #[case] expected: &[&str]) { + assert_eq!( + conditions(class), + Some((level, expected.iter().map(ToString::to_string).collect())) + ); + } + + #[test] + fn test_stacked_media_variants() { + let (_, conditions) = conditions("print:hover:p-4").unwrap(); + assert_eq!(conditions.len(), 1); + assert!(conditions[0].starts_with("@media print"), "{conditions:?}"); + assert!(conditions[0].contains("(hover:hover)"), "{conditions:?}"); + assert!(conditions[0].ends_with("&:hover"), "{conditions:?}"); + } + + // `::before`/`::after` only render with `content`, which Tailwind adds + #[test] + fn test_pseudo_element_content() { + assert_eq!( + declarations("before:block"), + Some(owned(&[ + ("content", "var(--tw-content)"), + ("display", "block") + ])) + ); + assert_eq!( + declarations("after:content-['x']"), + Some(owned(&[ + ("--tw-content", "'x'"), + ("content", "var(--tw-content)") + ])) + ); + assert_eq!( + declarations("placeholder:block"), + Some(owned(&[("display", "block")])) + ); + } + + // A class whose variants or utility are not understood in full stays as written #[rstest] - #[case("/2", true)] - #[case("/12", true)] - #[case("/123", true)] - fn test_is_valid_tailwind_value_slash_prefix_fraction( - #[case] value: &str, - #[case] expected: bool, + #[case("custom")] + #[case("prose")] + #[case("my-p-4-class")] + #[case("analytics-hook")] + #[case("container")] + #[case("group")] + #[case("peer")] + #[case("group/item")] + #[case("")] + #[case(":p-4")] + #[case("hover:")] + #[case("hover::p-4")] + #[case("not-hover:p-4")] + #[case("in-focus:p-4")] + #[case("max-md:p-4")] + #[case("min-[700px]:p-4")] + #[case("@sm:p-4")] + #[case("@container:p-4")] + #[case("starting:p-4")] + #[case("screen:p-4")] + #[case("unknown:p-4")] + #[case("[.x]:p-4")] + #[case("[@foo_x]:p-4")] + #[case("[&]]:p-4")] + #[case("supports-grid:p-4")] + #[case("supports-[grid]:p-4")] + #[case("group-[.x]:p-4")] + #[case("group-unknown:p-4")] + #[case("group-hover/a.b:p-4")] + #[case("peer-hover/:p-4")] + #[case("has-hover:p-4")] + #[case("has-has-[x]:p-4")] + #[case("has-[&>p]:p-4")] + #[case("has-unknown:p-4")] + #[case("aria-foo:p-4")] + #[case("aria-[a~=b]:p-4")] + #[case("data-[]:p-4")] + #[case("data-[a=]:p-4")] + #[case("data-[a=b\"c]:p-4")] + #[case("data-[a_b]:p-4")] + #[case("data-a.b:p-4")] + #[case("nth-0:p-4")] + #[case("nth-x:p-4")] + #[case("print:[@media_screen]:p-4")] + #[case("p-4!")] + #[case("!p-4")] + #[case("bg-red-500/50")] + #[case("text-red-500/6")] + #[case("text-sm/x")] + #[case("text-sm/[_]")] + #[case("text-sm/(x)")] + #[case("select-wrapper")] + #[case("order-summary")] + #[case("cursor-foo")] + #[case("align-left")] + #[case("whitespace-x")] + #[case("hyphens-x")] + #[case("pointer-events-x")] + #[case("touch-x")] + #[case("mix-blend-x")] + #[case("bg-blend-plus-lighter")] + #[case("col-start-x")] + #[case("col-end-x")] + #[case("row-start-x")] + #[case("row-end-x")] + #[case("columns-x")] + #[case("aspect-x")] + #[case("aspect-4-3")] + #[case("aspect-a/3")] + #[case("leading-custom")] + #[case("leading-(x)")] + #[case("-p-4")] + #[case("-m-auto")] + #[case("-order-first")] + #[case("-size-4")] + #[case("-truncate")] + #[case("-[color:red]")] + #[case("-p-[4px]")] + #[case("--[1px]")] + #[case("[Color:red]")] + #[case("[--x.y:1]")] + #[case("[color:_]")] + #[case("[colorred]")] + #[case("[color:red")] + #[case("w-[]")] + #[case("w-]")] + #[case("w[1px]")] + #[case("foo-[1px]")] + #[case("w-(x)")] + #[case("w-[_]")] + #[case("bg-[size:cover]")] + #[case("text-[2]")] + #[case("text-[angle:3deg]")] + #[case("bg-[10px]")] + #[case("border-[3]")] + #[case("border-[url(x)]")] + #[case("borderx-[3px]")] + #[case("border-z-[3px]")] + #[case("roundedx-[3px]")] + #[case("rounded-z-[3px]")] + #[case("rounded-huge")] + #[case("roundedx")] + #[case("font-[2px]")] + #[case("outline-[url(x)]")] + #[case("size-screen")] + #[case("translate-x-auto")] + #[case("translate-x-screen")] + #[case("translate-x")] + #[case("scale-1.5")] + #[case("skew-x")] + #[case("rotate-x-45")] + fn test_preserved_classes(#[case] class: &str) { + assert_eq!(parse_class(class), None, "{class}"); + } + + // ==================== Utilities with several declarations ==================== + + #[rstest] + #[case("text-xs", "0.75rem", "var(--tw-leading, calc(1 / 0.75))")] + #[case("text-sm", "0.875rem", "var(--tw-leading, calc(1.25 / 0.875))")] + #[case("text-base", "1rem", "var(--tw-leading, calc(1.5 / 1))")] + #[case("text-xl", "1.25rem", "var(--tw-leading, calc(1.75 / 1.25))")] + #[case("text-9xl", "8rem", "var(--tw-leading, 1)")] + #[case("text-sm/6", "0.875rem", "1.5rem")] + #[case("text-sm/[1.7]", "0.875rem", "1.7")] + #[case("text-sm/tight", "0.875rem", "1.25")] + #[case("text-sm/(--lh)", "0.875rem", "var(--lh)")] + fn test_font_size_utilities( + #[case] class: &str, + #[case] font_size: &str, + #[case] line_height: &str, ) { - assert_eq!(is_valid_tailwind_value(value), expected); + assert_eq!( + declarations(class), + Some(owned(&[ + ("font-size", font_size), + ("line-height", line_height) + ])) + ); + } + + #[test] + fn test_screen_reader_utilities() { + assert_eq!( + declarations("sr-only"), + Some(owned(&[ + ("position", "absolute"), + ("width", "1px"), + ("height", "1px"), + ("padding", "0"), + ("margin", "-1px"), + ("overflow", "hidden"), + ("clip-path", "inset(50%)"), + ("white-space", "nowrap"), + ("border-width", "0"), + ])) + ); + assert_eq!( + declarations("not-sr-only"), + Some(owned(&[ + ("position", "static"), + ("width", "auto"), + ("height", "auto"), + ("padding", "0"), + ("margin", "0"), + ("overflow", "visible"), + ("clip-path", "none"), + ("white-space", "normal"), + ])) + ); + } + + #[rstest] + #[case("-m-4", "margin", "-1rem")] + #[case("-mt-4", "margin-top", "-1rem")] + #[case("-inset-x-4", "inset-inline", "-1rem")] + #[case("-top-2", "top", "-0.5rem")] + #[case("-z-10", "z-index", "-10")] + #[case("-order-1", "order", "-1")] + #[case("-tracking-wide", "letter-spacing", "-0.025em")] + #[case("-scroll-m-4", "scroll-margin", "-1rem")] + #[case("-m-[4px]", "margin", "calc(4px * -1)")] + #[case("-top-[3px]", "top", "calc(3px * -1)")] + #[case("-z-[3]", "z-index", "calc(3 * -1)")] + #[case("-rotate-[30deg]", "rotate", "calc(30deg * -1)")] + #[case("mt-[-3px]", "margin-top", "-3px")] + fn test_negative_utilities(#[case] class: &str, #[case] property: &str, #[case] value: &str) { + assert_eq!(declarations(class), Some(owned(&[(property, value)]))); + } + + // ==================== Arbitrary values ==================== + + #[rstest] + #[case("[mask-type:luminance]", "mask-type", "luminance")] + #[case("[--my_var:1px_2px]", "--my_var", "1px 2px")] + #[case("[margin:calc(1px+2px)]", "margin", "calc(1px + 2px)")] + #[case("[-webkit-line-clamp:3]", "-webkit-line-clamp", "3")] + #[case("bg-(--my-color)", "background-color", "var(--my-color)")] + #[case("bg-(image:--x)", "background-image", "var(--x)")] + #[case("text-(length:--my-size)", "font-size", "var(--my-size)")] + #[case("text-(--x)", "color", "var(--x)")] + #[case("w-(--x,10px)", "width", "var(--x,10px)")] + #[case("p-(length:--x)", "padding", "var(--x)")] + #[case("text-[2rem]", "font-size", "2rem")] + #[case("text-[1.5em]", "font-size", "1.5em")] + #[case("text-[large]", "font-size", "large")] + #[case("text-[calc(1rem+2px)]", "font-size", "calc(1rem + 2px)")] + #[case("text-[length:var(--x)]", "font-size", "var(--x)")] + #[case("text-[percentage:50%]", "font-size", "50%")] + #[case("text-[absolute-size:large]", "font-size", "large")] + #[case("text-[#fff]", "color", "#fff")] + #[case("text-[var(--x)]", "color", "var(--x)")] + #[case("text-[color:var(--x)]", "color", "var(--x)")] + #[case("bg-[url(/a_b.png)]", "background-image", "url(/a_b.png)")] + #[case("bg-[url('/a_b.png')]", "background-image", "url('/a_b.png')")] + #[case( + "bg-[linear-gradient(red,blue)]", + "background-image", + "linear-gradient(red,blue)" + )] + #[case("bg-[image:var(--x)]", "background-image", "var(--x)")] + #[case("bg-[url:var(--x)]", "background-image", "var(--x)")] + #[case("bg-[#123456]", "background-color", "#123456")] + #[case("bg-[var(--x)]", "background-color", "var(--x)")] + #[case("bg-[length:200px_100px]", "background-size", "200px 100px")] + #[case("bg-[bg-size:cover]", "background-size", "cover")] + #[case("bg-[position:top]", "background-position", "top")] + #[case("border-[3px]", "border-width", "3px")] + #[case("border-[2px_4px]", "border-width", "2px 4px")] + #[case("border-[thin]", "border-width", "thin")] + #[case("border-[0]", "border-width", "0")] + #[case("border-[length:var(--x)]", "border-width", "var(--x)")] + #[case("border-[line-width:var(--x)]", "border-width", "var(--x)")] + #[case("border-[#fff]", "border-color", "#fff")] + #[case("border-[var(--x)]", "border-color", "var(--x)")] + #[case("border-t-[3px]", "border-top-width", "3px")] + #[case("border-t-[red]", "border-top-color", "red")] + #[case("border-x-[1px]", "border-inline-width", "1px")] + #[case("border-s-[red]", "border-inline-start-color", "red")] + #[case("outline-[3px]", "outline-width", "3px")] + #[case("outline-[red]", "outline-color", "red")] + #[case("stroke-[3px]", "stroke-width", "3px")] + #[case("stroke-[2]", "stroke-width", "2")] + #[case("stroke-[red]", "stroke", "red")] + #[case("decoration-[3px]", "text-decoration-thickness", "3px")] + #[case("decoration-[red]", "text-decoration-color", "red")] + #[case("font-[600]", "font-weight", "600")] + #[case("font-[number:var(--w)]", "font-weight", "var(--w)")] + #[case("font-[Inter_Var]", "font-family", "Inter Var")] + #[case("font-[family-name:var(--f)]", "font-family", "var(--f)")] + #[case("blur-[2px]", "filter", "blur(2px)")] + #[case("backdrop-blur-[2px]", "backdrop-filter", "blur(2px)")] + #[case("w-[calc(100%_-_2rem)]", "width", "calc(100% - 2rem)")] + #[case("m-[var(--x_y)]", "margin", "var(--x_y)")] + #[case( + "grid-cols-[repeat(2,minmax(0,1fr))]", + "grid-template-columns", + "repeat(2,minmax(0,1fr))" + )] + #[case("col-[1/3]", "grid-column", "1/3")] + #[case("row-[span_2]", "grid-row", "span 2")] + #[case("origin-[33%_75%]", "transform-origin", "33% 75%")] + #[case("object-[25%_75%]", "object-position", "25% 75%")] + fn test_arbitrary_utilities(#[case] class: &str, #[case] property: &str, #[case] value: &str) { + assert_eq!(declarations(class), Some(owned(&[(property, value)]))); + } + + #[rstest] + #[case("content-['a_b']", "'a b'")] + #[case("content-['hello\\_world']", "'hello_world'")] + #[case("content-[attr(data-a_b)]", "attr(data-a b)")] + fn test_arbitrary_content(#[case] class: &str, #[case] value: &str) { + assert_eq!( + declarations(class), + Some(owned(&[ + ("--tw-content", value), + ("content", "var(--tw-content)") + ])) + ); + } + + // Values decoded like Tailwind does, checked against Tailwind CSS 4.3.3 + #[rstest] + #[case("calc(100%-2rem)", "calc(100% - 2rem)")] + #[case("calc(1rem+2px)", "calc(1rem + 2px)")] + #[case("calc(2*3px)", "calc(2 * 3px)")] + #[case("calc(10px/2)", "calc(10px / 2)")] + #[case("min(10px,2rem)", "min(10px, 2rem)")] + #[case("max(10px,calc(1rem-2px))", "max(10px, calc(1rem - 2px))")] + #[case("clamp(1rem,2vw+1px,3rem)", "clamp(1rem, 2vw + 1px, 3rem)")] + #[case("calc(100%-var(--x))", "calc(100% - var(--x))")] + #[case("calc(var(--a)*-1)", "calc(var(--a) * -1)")] + #[case("calc(-1*2px)", "calc(-1 * 2px)")] + #[case("calc(1e-3px+1px)", "calc(1e-3px + 1px)")] + #[case("calc(100%_-_2rem)", "calc(100% - 2rem)")] + #[case("calc(1px_+2px)", "calc(1px + 2px)")] + #[case("calc((1px+2px)*3)", "calc((1px + 2px) * 3)")] + #[case("calc(1px--2px)", "calc(1px - -2px)")] + #[case("calc(a-b)", "calc(a-b)")] + #[case("var(--a_b,calc(1px+2px))", "var(--a_b,calc(1px + 2px))")] + #[case("var(calc(1px+2px))", "var(calc(1px + 2px))")] + #[case("theme(spacing_4)", "theme(spacing_4)")] + #[case("x_var(--a_b)", "x var(--a_b)")] + #[case("x_theme(--a_b)", "x theme(--a_b)")] + #[case("x_url(a_b)", "x url(a_b)")] + #[case("repeat(2,minmax(0,1fr))", "repeat(2,minmax(0,1fr))")] + #[case("url(/a_b.png)", "url(/a_b.png)")] + #[case("url(a_b", "url(a_b")] + #[case("attr(data-a_b)", "attr(data-a b)")] + #[case("a_(b)_c", "a (b) c")] + #[case("a)b_c", "a)b c")] + #[case("a)b_(c)", "a)b (c)")] + #[case("attr(a\\_b)", "attr(a_b)")] + #[case("url(a(b)_c)", "url(a(b)_c)")] + #[case("'a_b'", "'a b'")] + #[case("'hello\\_world'", "'hello_world'")] + #[case("한_글", "한 글")] + fn test_decode_arbitrary_value(#[case] value: &str, #[case] expected: &str) { + assert_eq!(decode_arbitrary_value(value), expected); + } + + #[test] + fn test_balanced_brackets() { + assert!(is_balanced("[a](b)")); + assert!(!is_balanced("(]")); + assert!(!is_balanced("[)")); + assert!(!is_balanced("(")); + } + + #[test] + fn test_styles_and_properties() { + let class = parse_class("md:hover:p-4").unwrap(); + let styles: Vec = class.styles().collect(); + assert_eq!(styles.len(), 1); + assert_eq!(styles[0].property(), "padding"); + assert_eq!(styles[0].value(), "1rem"); + assert_eq!(styles[0].level(), 2); + assert_eq!( + styles[0].selector().map(ToString::to_string).as_deref(), + Some("@media(hover:hover) &:hover") + ); + assert!(!class.uses_properties()); + + let class = parse_class("selection:translate-x-4").unwrap(); + assert_eq!(class.styles().count(), 4); + assert!(class.uses_properties()); + assert!(parse_class("before:block").unwrap().uses_properties()); } } diff --git a/libs/sheet/src/lib.rs b/libs/sheet/src/lib.rs index 9a989df8f..3f3ea2732 100644 --- a/libs/sheet/src/lib.rs +++ b/libs/sheet/src/lib.rs @@ -3680,6 +3680,61 @@ mod tests { assert!(!css.contains("color:red"), "{css}"); } + #[test] + #[serial] + fn test_tailwind_classes_css() { + reset_class_map(); + reset_file_map(); + let mut sheet = StyleSheet::default(); + for (file, code) in [ + ( + "a.tsx", + "import {Box} from '@devup-ui/core'\n", + ), + ( + "b.tsx", + "import {Box} from '@devup-ui/core'\n", + ), + ] { + let output = extract( + file, + code, + ExtractOption { + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), + single_css: true, + import_main_css: false, + import_aliases: std::collections::HashMap::new(), + }, + ) + .unwrap(); + sheet.update_styles(&output.styles, file, true); + } + let css = sheet.create_css(None, false); + // The registrations are written once however many files use them + assert_eq!( + css.matches("@property --tw-translate-x{").count(), + 1, + "{css}" + ); + assert!( + css.contains("@property --tw-content{syntax:\"*\";inherits:false;initial-value:\"\"}"), + "{css}" + ); + for rule in [ + ".a{--tw-translate-x:1rem}", + ".d{--tw-translate-y:.5rem}", + ".b{translate:var(--tw-translate-x) var(--tw-translate-y)}", + ".g{font-size:.875rem}", + ".h{line-height:var(--tw-leading,calc(1.25 / .875))}", + ".e::before{content:var(--tw-content)}", + ".f::before{display:inline-block}", + "@media(hover:hover){.c:hover:focus{margin-top:1rem}}", + ] { + assert!(css.contains(rule), "{rule} in {css}"); + } + } + #[test] #[serial] fn test_dynamic_base_style_updates_base_sheet() {