From 0edb2118e4621866905b8cbca2d8cc57b3f344ac Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sat, 3 Oct 2026 22:22:47 +0900 Subject: [PATCH 01/12] fix(extractor): compile or reject function-local style objects, keep ClassNames cx calls, read style functions of props Refs #690, #695 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../changepack_log_local_style_objects.json | 7 + libs/extractor/src/css_utils.rs | 11 +- .../extractor/extract_style_from_styled.rs | 189 +++++++++ libs/extractor/src/import_alias_visit.rs | 14 + libs/extractor/src/imported_constants.rs | 121 +++++- libs/extractor/src/lib.rs | 123 +++++- .../extractor__tests__changed_constants.snap | 2 +- ...on_class_names_keeps_calls_as_classes.snap | 9 + ..._names_reports_what_it_cannot_compile.snap | 9 +- ...__style_objects_declared_in_functions.snap | 86 +++++ ...lared_in_functions_of_other_libraries.snap | 50 +++ ...the_props_and_arrays_under_conditions.snap | 102 +++++ ...led_object_form_theme_interpolation-2.snap | 18 + libs/extractor/src/utils.rs | 10 +- libs/extractor/src/visit.rs | 360 ++++++++++++++---- 15 files changed, 1014 insertions(+), 97 deletions(-) create mode 100644 .changepacks/changepack_log_local_style_objects.json create mode 100644 libs/extractor/src/snapshots/extractor__tests__emotion_class_names_keeps_calls_as_classes.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__style_objects_declared_in_functions.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__style_objects_declared_in_functions_of_other_libraries.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__styled_functions_of_the_props_and_arrays_under_conditions.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__styled_object_form_theme_interpolation-2.snap diff --git a/.changepacks/changepack_log_local_style_objects.json b/.changepacks/changepack_log_local_style_objects.json new file mode 100644 index 000000000..439e30dc2 --- /dev/null +++ b/.changepacks/changepack_log_local_style_objects.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "A style object declared with const inside a function and passed to css(), styled(), globalCss(), keyframes(), a style prop or a spread (also through the Emotion, styled-components and vanilla-extract aliases) now compiles when every entry is written out and the object is only read as styles, instead of leaving the object where the class belongs; the build errors, at the binding, when the object is also read elsewhere (where code may change it), and at the call when it holds values only the runtime gives. Emotion's ClassNames cx keeps a function call's result as a class that stays as written, as @emotion/css cx does. css() and styled() compose an array of rule objects under a condition. A style function of the props (styled.div((props) => ({ ... })), and as a later argument) compiles: each value reading the props becomes a CSS variable the component sets (a number is a px length unless the property is unitless), theme reads (including theme.a[2]) become var(--a-2), and a condition chooses per value. New build errors: styled(...).attrs(...) chained after the styles (chain .attrs before them), a style function of the props that does not give one rule object at once with every entry written out, and a style object declared in a function that is also read elsewhere", + "date": "2026-10-01T00:00:00.000Z" +} \ No newline at end of file diff --git a/libs/extractor/src/css_utils.rs b/libs/extractor/src/css_utils.rs index ab3239a64..de8628708 100644 --- a/libs/extractor/src/css_utils.rs +++ b/libs/extractor/src/css_utils.rs @@ -74,7 +74,16 @@ pub(crate) fn theme_var_reference(expr: &Expression<'_>) -> Option { loop { match cursor { Expression::StaticMemberExpression(member) => { - path.push(member.property.name.as_str()); + path.push(member.property.name.to_string()); + cursor = &member.object; + } + Expression::ComputedMemberExpression(member) => { + path.push(match &member.expression { + Expression::StringLiteral(key) => key.value.to_string(), + key => { + crate::utils::js_number_literal(key).map(crate::utils::js_number_string)? + } + }); cursor = &member.object; } Expression::Identifier(ident) => { diff --git a/libs/extractor/src/extractor/extract_style_from_styled.rs b/libs/extractor/src/extractor/extract_style_from_styled.rs index c1ceb13df..fa72b2a5d 100644 --- a/libs/extractor/src/extractor/extract_style_from_styled.rs +++ b/libs/extractor/src/extractor/extract_style_from_styled.rs @@ -31,6 +31,180 @@ use oxc_ast::{ use oxc_span::{GetSpan, SPAN}; use oxc_syntax::operator::{BinaryOperator, LogicalOperator, UnaryOperator}; +const PROPS_FUNCTION: &str = "a style function of the props must give one rule object at once, with every entry written out, as `(props) => ({ color: props.color })`"; + +/// The rules `style`, an argument of a styled component, gives, with each +/// value a function of the props written as the value of a CSS variable the +/// component sets: a function giving rules becomes the rules with each value +/// read from the props as such a function, so a condition chooses per value. +/// `Err` holds a function the build cannot read so. +fn rules_reading_props<'a>( + ast_builder: &AstBuilder<'a>, + style: &mut Expression<'a>, +) -> Result<(), Expression<'a>> { + use oxc_allocator::TakeIn; + + if matches!( + unwrap_syntax_only(style), + Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_) + ) { + let code = style.clone_in(ast_builder.allocator()); + let (params, returned) = crate::css_prop::render_function(style) + .ok_or_else(|| code.clone_in(ast_builder.allocator()))?; + let params = params.clone_in_with_semantic_ids(ast_builder.allocator()); + let mut rules = returned.take_in(ast_builder); + let Expression::ObjectExpression(object) = unwrap_syntax_only_mut(&mut rules) else { + return Err(code); + }; + props_leaves(ast_builder, object, ¶ms).ok_or(code)?; + *style = rules; + } + if let Expression::ObjectExpression(object) = unwrap_syntax_only_mut(style) { + call_with_props(ast_builder, object); + } + Ok(()) +} + +/// Whether `value` is a literal, which reads the same given any props +fn is_written_out(value: &Expression<'_>) -> bool { + let value = unwrap_syntax_only(value); + crate::utils::get_string_by_literal_expression(value).is_some() + || crate::utils::js_number_literal(value).is_some() + || matches!( + value, + Expression::BooleanLiteral(_) | Expression::NullLiteral(_) + ) +} + +/// The values of `object` that read the props, each as a function taking the +/// parameters `params` of the function that gave the rules +fn props_leaves<'a>( + ast_builder: &AstBuilder<'a>, + object: &mut oxc_ast::ast::ObjectExpression<'a>, + params: &FormalParameters<'a>, +) -> Option<()> { + use oxc_allocator::TakeIn; + + for property in &mut object.properties { + let ObjectPropertyKind::ObjectProperty(property) = property else { + return None; + }; + if property.computed || property.method || property.kind != oxc_ast::ast::PropertyKind::Init + { + return None; + } + let key = crate::utils::get_str_by_property_key(&property.key)?; + match unwrap_syntax_only_mut(&mut property.value) { + Expression::ObjectExpression(inner) => props_leaves(ast_builder, inner, params)?, + Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_) => {} + value if is_written_out(value) => {} + _ => { + let leaf = property.value.take_in(ast_builder); + property.value = props_function(ast_builder, params, leaf, &key); + } + } + } + Some(()) +} + +/// `(params) => leaf`: a number is a `px` length, as Emotion reads it, unless +/// the property takes numbers as they are or the function reads the theme +fn props_function<'a>( + ast_builder: &AstBuilder<'a>, + params: &FormalParameters<'a>, + leaf: Expression<'a>, + key: &str, +) -> Expression<'a> { + let function = |body: Expression<'a>| { + Expression::new_arrow_function_expression( + SPAN, + false, + None::>>, + oxc_allocator::Box::new_in( + params.clone_in_with_semantic_ids(ast_builder.allocator()), + ast_builder, + ), + None::>>, + body.into(), + ast_builder, + ) + }; + let plain = function(leaf.clone_in_with_semantic_ids(ast_builder.allocator())); + if crate::utils::keeps_bare_number(key) + || crate::css_utils::theme_var_reference(&plain).is_some() + { + return plain; + } + let value = || identifier(ast_builder, "__devupValue"); + let is_number = Expression::new_binary_expression( + SPAN, + Expression::new_unary_expression(SPAN, UnaryOperator::Typeof, value(), ast_builder), + BinaryOperator::StrictEquality, + Expression::new_string_literal(SPAN, "number", None, ast_builder), + ast_builder, + ); + let length = Expression::new_binary_expression( + SPAN, + value(), + BinaryOperator::Addition, + Expression::new_string_literal(SPAN, "px", None, ast_builder), + ast_builder, + ); + let length = Expression::new_conditional_expression( + SPAN, + Expression::new_logical_expression( + SPAN, + is_number, + LogicalOperator::And, + Expression::new_binary_expression( + SPAN, + value(), + BinaryOperator::StrictInequality, + Expression::new_numeric_literal( + SPAN, + 0.0, + None, + oxc_syntax::number::NumberBase::Decimal, + ast_builder, + ), + ast_builder, + ), + ast_builder, + ), + length, + value(), + ast_builder, + ); + let length = named_arrow(ast_builder, "__devupValue", length); + function(wrap_direct_call(ast_builder, &length, &[leaf])) +} + +/// The values of `object` that are functions of the props written as the +/// result of calling them with the props the component renders, which the +/// styles read as the values of CSS variables; a function reading the theme +/// stays, as the styles read it as a variable of the theme +fn call_with_props<'a>( + ast_builder: &AstBuilder<'a>, + object: &mut oxc_ast::ast::ObjectExpression<'a>, +) { + for property in &mut object.properties { + let ObjectPropertyKind::ObjectProperty(property) = property else { + continue; + }; + match unwrap_syntax_only_mut(&mut property.value) { + Expression::ObjectExpression(inner) => call_with_props(ast_builder, inner), + function @ (Expression::ArrowFunctionExpression(_) + | Expression::FunctionExpression(_)) + if crate::css_utils::theme_var_reference(function).is_none() => + { + *function = + wrap_direct_call(ast_builder, function, &[identifier(ast_builder, "rest")]); + } + _ => {} + } + } +} + /// The binding a styled component reads the component it renders through, when /// only the runtime gives that component const STYLED_BASE: &str = "DevupStyled"; @@ -508,6 +682,21 @@ pub fn extract_style_from_styled<'a>( if let Expression::CallExpression(call) = expression && extract_base_tag_and_class_name(ast_builder, &call.callee, imports).is_some() { + for argument in &mut call.arguments { + if let Some(rules) = argument.as_expression_mut() + && let Err(code) = rules_reading_props(ast_builder, rules) + { + errors.push(( + code.span().start, + build_time_error("styled", &readable_code(&code), PROPS_FUNCTION), + )); + *rules = Expression::new_object_expression( + SPAN, + oxc_allocator::Vec::new_in(ast_builder), + ast_builder, + ); + } + } match style_arguments(ast_builder, &call.arguments) { Some(StyleArguments { classes, rules }) => { call.arguments = diff --git a/libs/extractor/src/import_alias_visit.rs b/libs/extractor/src/import_alias_visit.rs index 83f55d76d..3e2dc79f1 100644 --- a/libs/extractor/src/import_alias_visit.rs +++ b/libs/extractor/src/import_alias_visit.rs @@ -117,6 +117,20 @@ impl LibraryNumbers<'_> { } Expression::LogicalExpression(logical) => self.pixelify(&logical.right), Expression::ParenthesizedExpression(inner) => self.pixelify(&inner.expression), + Expression::ArrowFunctionExpression(arrow) => { + let rules = match &arrow.body { + ArrowFunctionBody::FunctionBody(body) => returned(body), + body => body.as_expression(), + }; + if let Some(rules) = rules { + self.pixelify(rules); + } + } + Expression::FunctionExpression(function) => { + if let Some(rules) = function.body.as_deref().and_then(returned) { + self.pixelify(rules); + } + } rules => self.marks.push(Self::mark(rules)), } } diff --git a/libs/extractor/src/imported_constants.rs b/libs/extractor/src/imported_constants.rs index 05e0ddad0..9454252ee 100644 --- a/libs/extractor/src/imported_constants.rs +++ b/libs/extractor/src/imported_constants.rs @@ -165,6 +165,82 @@ pub(crate) struct Inlined { pub css_styles: FxHashMap>, pub unknown: Unknown, pub changed: Changed, + /// Style objects declared below the top level that styles read, which + /// code elsewhere reads too, so the build cannot tell they are unchanged + pub errors: Vec<(u32, String)>, +} + +/// A `const` below the top level of the module holding an object or array +/// literal that is all written out, which styles read as they read top-level +/// constants when nothing else reads it +struct LocalConstant { + name: String, + /// Where its binding is written + binding: u32, + value: Constant, +} + +/// The `const` declarations holding object or array literals all written out +#[derive(Default)] +struct LocalConstants { + found: Vec, +} + +/// `expression` as the value it writes out, when every part of it is written +/// out as a literal +fn written_out(expression: &Expression<'_>) -> Option { + match expression { + Expression::StringLiteral(literal) => Some(Constant::String(literal.value.to_string())), + Expression::BooleanLiteral(literal) => Some(Constant::Bool(literal.value)), + Expression::NullLiteral(_) => Some(Constant::Null), + Expression::ParenthesizedExpression(inner) => written_out(&inner.expression), + Expression::ArrayExpression(array) => array + .elements + .iter() + .map(|element| written_out(element.as_expression()?)) + .collect::>>() + .map(|values| Constant::Array(Rc::new(values))), + Expression::ObjectExpression(object) => object + .properties + .iter() + .map(|property| { + let ObjectPropertyKind::ObjectProperty(property) = property else { + return None; + }; + if property.computed + || property.method + || property.kind != oxc_ast::ast::PropertyKind::Init + { + return None; + } + let key = crate::utils::get_str_by_property_key(&property.key)?; + Some((key.to_string(), written_out(&property.value)?)) + }) + .collect::>>() + .map(|entries| Constant::Record(Rc::new(entries))), + expression => crate::utils::js_number_literal(expression).map(Constant::Number), + } +} + +impl<'a> Visit<'a> for LocalConstants { + fn visit_variable_declaration(&mut self, declaration: &oxc_ast::ast::VariableDeclaration<'a>) { + if declaration.kind == VariableDeclarationKind::Const { + for declarator in &declaration.declarations { + if let oxc_ast::ast::BindingPattern::BindingIdentifier(binding) = &declarator.id + && let Some(init) = &declarator.init + && let Some(value) = written_out(init) + && matches!(value, Constant::Record(_) | Constant::Array(_)) + { + self.found.push(LocalConstant { + name: binding.name.to_string(), + binding: binding.span.start, + value, + }); + } + } + } + walk::walk_variable_declaration(self, declaration); + } } /// Bindings styles read that hold an object or array code changes, whole or @@ -364,6 +440,11 @@ pub(crate) fn inline_constants<'a>( if read.names.is_empty() { return Inlined::default(); } + let mut locals = LocalConstants::default(); + locals.visit_program(program); + locals + .found + .retain(|local| read.names.contains(&local.name)); let mut modules = Modules { resolver, option, @@ -439,7 +520,10 @@ pub(crate) fn inline_constants<'a>( // Scoping is only worth building when a style reads a name that may // hold a constant let reads_math = read.names.contains("Math") && !scope.binds("Math"); - if !reads_math && !read.names.iter().any(|name| scope.binds(name)) { + if !reads_math + && locals.found.is_empty() + && !read.names.iter().any(|name| scope.binds(name)) + { return Inlined::default(); } let scoping = SemanticBuilder::new() @@ -500,8 +584,21 @@ pub(crate) fn inline_constants<'a>( (scoping, reads_math) }; inlined.dependencies = modules.exports.into_keys().collect(); + let mut declared = Vec::new(); + for symbol in scoping.symbol_ids() { + if scoping.symbol_scope_id(symbol) != scoping.root_scope_id() + && scoping.symbol_flags(symbol).is_const_variable() + && let Some(local) = locals + .found + .iter() + .find(|local| local.binding == scoping.symbol_span(symbol).start) + { + symbols.insert(symbol, local.value.clone()); + declared.push((symbol, local)); + } + } if !symbols.is_empty() || reads_math { - Inline { + let mut inline = Inline { ast_builder, scoping: &scoping, symbols: &symbols, @@ -512,9 +609,18 @@ pub(crate) fn inline_constants<'a>( styles: false, px: false, marks: px, + replaced: FxHashMap::default(), class_names: Vec::new(), + }; + inline.visit_program(program); + for (symbol, local) in declared { + let read = inline.replaced.get(&symbol).copied().unwrap_or(0); + if read > 0 && read < scoping.get_resolved_reference_ids(symbol).len() { + inlined + .errors + .push((local.binding, crate::utils::local_style_error(&local.name))); + } } - .visit_program(program); } inlined } @@ -1817,6 +1923,9 @@ struct Inline<'s, 'a> { /// Where a constant stands as a value or rules of a library reading /// numbers as `px` lengths marks: &'s crate::import_alias_visit::PxMarks, + /// How often a style read each binding below the top level, by what it + /// held + replaced: FxHashMap, /// The names the `` child functions around take `css` and /// `cx` by class_names: Vec, @@ -2088,6 +2197,12 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { { let span = expression.span(); let marked = self.marks.contains(&(span.start, span.end)); + if let Expression::Identifier(identifier) = &*expression + && let Some(reference) = identifier.reference_id.get() + && let Some(symbol) = self.scoping.get_reference(reference).symbol_id() + { + *self.replaced.entry(symbol).or_default() += 1; + } *expression = literal; if marked { px_number(self.ast_builder, expression); diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 06a866d2c..4a7500b22 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -451,6 +451,7 @@ fn extract_source( ); visitor.import_stylex(inlined.stylex_vars, inlined.stylex_themes); visitor.import_css(inlined.css_styles); + visitor.errors.extend(inlined.errors); visitor.unknown_bindings(&inlined.unknown); visitor.changed_bindings(inlined.changed.clone()); visitor.takes_css_prop(css_prop); @@ -18241,16 +18242,14 @@ const Themed = styled('div')({ color: (p) => p.theme.brand });" ))); reset_class_map(); reset_file_map(); - assert_eq!( + assert_debug_snapshot!(ToBTreeSet::from( extract( "test.tsx", "import { styled } from '@devup-ui/react';\nconst Plain = styled('span')({ color: (p) => p.color });", ExtractOption::default(), ) - .unwrap_err() - .to_string(), - "test.tsx:2:39: `styled()` cannot use `(p) => p.color` at build time: its styles must be an object literal or a constant object, or be computed from constants" - ); + .unwrap() + )); } #[test] @@ -21533,7 +21532,8 @@ export const App = () => {({ theme }) => };", "export const App = () => {({ css, theme }) => };", "export const App = () => {({ css }) => };", - "export const App = () => {({ cx }) => };", + "export const App = () => {({ css }) => };", + "export const App = () => {({ cx }) => };", "export const App = ({ s }) => {({ cx }) => };", "export const App = () => {({ cx }) => };", "export const App = ({ s }) => {({ css }) => };", @@ -21676,4 +21676,115 @@ export const App = () => = [ + "export function f() { const inner = { color: 'red' }; return css(inner); }", + "export function f() { const a = { color: 'red' }; const b = { color: 'blue', margin: 1 }; return css(a, b); }", + "export function f(on) { const a = { color: 'red' }; return css(on && a, { margin: 1 }); }", + "export function f() { const inner = { color: 'red' }; console.log(inner); return css(inner); }", + "export function f() { const inner = { color: 'red' }; inner.color = 'blue'; return css(inner); }", + "export function f(c) { const inner = { color: c }; return css(inner); }", + "export function f() { const parts = [{ color: 'red' }, { margin: 1 }]; return css(parts); }", + "export function f() { const inner = { color: 'red' }; return css(...[inner]); }", + "export function f() { const inner = { color: 'red' }; return styled.div(inner); }", + "export function f() { const inner = { color: 'red' }; return styled('div')(inner); }", + "export function f(c) { const inner = { color: c }; return styled.div(inner); }", + "export function f() { const inner = { body: { color: 'red' } }; return globalCss(inner); }", + "export function f(c) { const inner = { body: { color: c } }; return globalCss(inner); }", + "export function f() { const inner = { from: { opacity: 0 }, to: { opacity: 1 } }; return keyframes(inner); }", + "export function f(c) { const inner = { from: { opacity: c } }; return keyframes(inner); }", + "export function f() { const inner = { color: 'red' }; return ; }", + "export function f() { const inner = { color: 'red' }; return ; }", + "export function f() { const inner = { color: 'red' }; return ; }", + "export function f(c) { const inner = { color: c }; return ; }", + "export function f() { const inner = { color: 'red' }; const cls = 'x'; return css(inner, cls); }", + ] + .iter() + .map(|case| format!("{devup}{case}")) + .collect(); + let borrowed: Vec<&str> = cases.iter().map(String::as_str).collect(); + assert_debug_snapshot!(library_outputs(&borrowed, SIZE_MODULES)); + } + + #[test] + #[serial] + fn test_style_objects_declared_in_functions_of_other_libraries() { + assert_debug_snapshot!(library_outputs( + &[ + "import { css } from '@emotion/react';\nexport function f() { const inner = { color: 'red', padding: 8 }; return css(inner); }", + "import { css } from '@emotion/react';\nexport function f(c) { const inner = { color: c }; return css(inner); }", + "import styled from '@emotion/styled';\nexport function f() { const inner = { color: 'red' }; return styled.div(inner); }", + "import styled from '@emotion/styled';\nexport function f(c) { const inner = { color: c }; return styled.div(inner); }", + "import styled, { css } from 'styled-components';\nexport function f() { const inner = { color: 'red' }; return styled.div(inner); }", + "import styled, { css } from 'styled-components';\nexport function f() { const inner = { color: 'red' }; return css(inner); }", + "import { style } from '@vanilla-extract/css';\nexport function f() { const inner = { color: 'red' }; return style(inner); }", + "import { style } from '@vanilla-extract/css';\nexport function f(c) { const inner = { color: c }; return style(inner); }", + "import { Global } from '@emotion/react';\nexport function f() { const inner = { body: { margin: 8 } }; return ; }", + "import { ClassNames } from '@emotion/react';\nexport function f() { const inner = { color: 'red' }; return {({ css }) => }; }", + "import { css } from '@emotion/react';\nexport function f() { const inner = { color: 'red' }; return
; }", + ], + SIZE_MODULES + )); + } + + #[test] + #[serial] + fn test_emotion_class_names_keeps_calls_as_classes() { + let import = "import { ClassNames } from '@emotion/react';\n"; + let cases = [ + "export const App = () => {({ cx }) => };", + "export const App = ({ on }) => {({ css, cx }) => };", + "export const App = ({ o }) => {({ cx }) => };", + ]; + let outputs: Vec = cases + .iter() + .map(|code| { + reset_class_map(); + reset_file_map(); + match extract("test.tsx", &format!("{import}{code}"), emotion_option()) { + Ok(output) => format!("{:?}", ToBTreeSet::from(output)), + Err(error) => format!("Error: {error}"), + } + }) + .collect(); + assert_debug_snapshot!(outputs); + } + + #[test] + #[serial] + fn test_styled_functions_of_the_props_and_arrays_under_conditions() { + assert_debug_snapshot!(library_outputs( + &[ + "import styled from '@emotion/styled';\nexport const A = styled.div(function (p) { return { margin: 8, color: p.c }; });", + "import styled from '@emotion/styled';\nexport const A = styled.div(({ theme }) => ({ color: theme['colors'].primary, margin: theme.space[2], padding: theme.space[0] }));", + "import styled from '@emotion/styled';\nexport const A = styled.div((p) => ({ color: () => 'x', margin: p.m }));", + "import styled, { css } from 'styled-components';\nexport const A = styled.div((p) => ({ color: p.color, padding: 8, lineHeight: p.lh, '&:hover': { margin: p.m } }));\nexport const B = styled('div')({ width: (p) => p.w });", + "import styled from '@emotion/styled';\nexport const A = styled.div((p) => { const x = p.x; return { color: x }; });", + "import styled from '@emotion/styled';\nexport const A = styled.div((p) => ({ ...p.rules, color: 'red' }));", + "import styled from '@emotion/styled';\nexport const A = styled.div((p) => ({ [p.key]: 1 }));", + "import styled from '@emotion/styled';\nexport const A = styled.div(async (p) => ({ color: p.color }));", + "import styled from '@emotion/styled';\nexport const A = styled.div`color: red;`.attrs({ id: 'x' });\nexport const B = styled.div.attrs({ id: 'x' })({ color: 'red' });\nexport const C = styled('div').attrs({ id: 'x' })({ color: 'red' });", + "import styled from '@emotion/styled';\nexport const B = (on, off) => styled.div(on ? [{ color: 'red' }, { margin: 1 }] : [{ color: 'blue' }], off && [{ width: 2 }]);", + "import styled from '@emotion/styled';\nexport const B = (on) => styled.div(on ? [{ color: 'red' }] : null, [on && [{ margin: 1 }]]);", + "import styled from '@emotion/styled';\nexport const B = (get) => styled.div(get() && [{ color: 'red' }]);", + "import { css } from '@emotion/react';\nexport const a = (on) => css(on ? [{ color: 'red' }] : [{ color: 'blue' }, { margin: 1 }], [on || [{ width: 2 }]]);", + "import styled from '@emotion/styled';\nexport const A = styled.div({ color: (p) => p.color, padding: 8, '&:hover': { color: (p) => p.h } });", + "import styled from '@emotion/styled';\nexport const A = styled.div({ color: ({ theme }) => theme.colors.primary, margin: ({ theme }) => theme.space[2] });", + "import styled from '@emotion/styled';\nexport const A = styled.div({ color: (p) => p.on ? 'red' : 'blue' });", + "import { css } from '@emotion/react';\nexport const a = (cond) => css(cond && [{ color: 'red' }, { margin: 1 }], { color: 'blue' });", + "import styled from '@emotion/styled';\nexport const A = styled.div(({ on }) => on && [{ color: 'red' }]);\nexport const B = (on) => styled.div(on && [{ color: 'red' }, { margin: 1 }]);", + "import styled from '@emotion/styled';\nexport const A = styled.div({ color: 'red' }).attrs({ id: 'x' });", + "import styled from '@emotion/styled';\nexport const A = styled.div((props) => ({ color: props.color, padding: 8 }));", + "import styled from '@emotion/styled';\nexport const A = styled.div({ margin: 1 }, (props) => ({ color: props.color, width: props.w }));", + "import styled from '@emotion/styled';\nexport const A = styled.div`color: ${(p) => p.color}; padding: ${(p) => p.pad}px; background: ${({ theme }) => theme.bg}`;", + "import styled from '@emotion/styled';\nexport const A = styled.div((p) => ({ color: p.on ? 'red' : 'blue', margin: p.m, '&:hover': { color: p.h } }));", + "import styled from '@emotion/styled';\nexport const A = styled.div(({ theme }) => ({ color: theme.colors.primary, margin: theme.space[2] }));", + ], + SIZE_MODULES + )); + } } diff --git a/libs/extractor/src/snapshots/extractor__tests__changed_constants.snap b/libs/extractor/src/snapshots/extractor__tests__changed_constants.snap index 99505d10d..a9899cabf 100644 --- a/libs/extractor/src/snapshots/extractor__tests__changed_constants.snap +++ b/libs/extractor/src/snapshots/extractor__tests__changed_constants.snap @@ -5,7 +5,7 @@ expression: outputs [ "Error: /src/App.tsx:4:22: `css()` cannot use `base` at build time: its styles must be an object literal or a constant object, or be computed from constants\n/src/App.tsx:3:1: `base` is changed here, so the build cannot read it as a constant", "Error: /src/App.tsx:5:18: `css()` cannot use `base.p` at build time: its values must be literals, theme tokens or constants, or be computed from them\n/src/App.tsx:3:15: `base` is changed here, so the build cannot read it as a constant", - "Error: /src/App.tsx:4:23: `` cannot use `base` at build time: its styles must be an object literal or a constant object, or be computed from constants\n/src/App.tsx:5:29: `styled()` cannot use `base` at build time: its styles must be an object literal or a constant object, or be computed from constants\n/src/App.tsx:6:22: `css()` cannot use `x ? base : null` at build time: its styles must be an object literal or a constant object, or be computed from constants\n/src/App.tsx:7:18: Cannot compose `x ? null : [y && base]` at build time: each style must be a rule object, a class, or a condition choosing between them\n/src/App.tsx:7:22: `css()` cannot use `x ? null : [y && base]` at build time: its styles must be an object literal or a constant object, or be computed from constants\n/src/App.tsx:8:22: `css()` cannot use `...[base]` at build time: its styles must be an object literal or a constant object, or be computed from constants\n/src/App.tsx:3:10: `base` is handed here to code that may change it, so the build cannot read it as a constant", + "Error: /src/App.tsx:4:23: `` cannot use `base` at build time: its styles must be an object literal or a constant object, or be computed from constants\n/src/App.tsx:5:29: `styled()` cannot use `base` at build time: its styles must be an object literal or a constant object, or be computed from constants\n/src/App.tsx:6:22: `css()` cannot use `x ? base : null` at build time: its styles must be an object literal or a constant object, or be computed from constants\n/src/App.tsx:7:22: `css()` cannot use `x ? null : [y && base]` at build time: its styles must be an object literal or a constant object, or be computed from constants\n/src/App.tsx:8:22: `css()` cannot use `...[base]` at build time: its styles must be an object literal or a constant object, or be computed from constants\n/src/App.tsx:3:10: `base` is handed here to code that may change it, so the build cannot read it as a constant", "Error: /src/App.tsx:6:18: `css()` cannot use `baseline` at build time: its values must be literals, theme tokens or constants, or be computed from them", "import \"@devup-ui/react/devup-ui-0.css\";\nconst colors = { primary: \"red\" };\nconst hover = darken(colors.primary);\nexport const a =
;\n", "import \"@devup-ui/react/devup-ui-0.css\";\nconst colors = { primary: \"red\" };\nexport const theme = { colors };\nexport const a =
;\n", diff --git a/libs/extractor/src/snapshots/extractor__tests__emotion_class_names_keeps_calls_as_classes.snap b/libs/extractor/src/snapshots/extractor__tests__emotion_class_names_keeps_calls_as_classes.snap new file mode 100644 index 000000000..ac15ef076 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__emotion_class_names_keeps_calls_as_classes.snap @@ -0,0 +1,9 @@ +--- +source: libs/extractor/src/lib.rs +expression: outputs +--- +[ + "ToBTreeSet { styles: {}, code: \"export const App = () => ;\\n\" }", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"blue\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport const App = ({ on }) => ;\\n\" }", + "ToBTreeSet { styles: {}, code: \"export const App = ({ o }) => ;\\n\" }", +] diff --git a/libs/extractor/src/snapshots/extractor__tests__emotion_class_names_reports_what_it_cannot_compile.snap b/libs/extractor/src/snapshots/extractor__tests__emotion_class_names_reports_what_it_cannot_compile.snap index 796b3ae06..a9300815d 100644 --- a/libs/extractor/src/snapshots/extractor__tests__emotion_class_names_reports_what_it_cannot_compile.snap +++ b/libs/extractor/src/snapshots/extractor__tests__emotion_class_names_reports_what_it_cannot_compile.snap @@ -23,12 +23,13 @@ expression: errors "test.tsx:2:64: `` cannot use `theme` at build time: it may read the theme only as `theme.a.b` in a value, which becomes the CSS variable `var(--a-b)` the `ThemeProvider` sets", "test.tsx:2:73: `css()` cannot use `theme.space(2)` at build time: its values must be literals, theme tokens or constants, or be computed from them\ntest.tsx:2:87: `` cannot use `theme.space(2)` at build time: it may read the theme only as `theme.a.b` in a value, which becomes the CSS variable `var(--a-b)` the `ThemeProvider` sets", "test.tsx:2:60: `` cannot use `css` at build time: the `css` and `cx` its child function takes can only be called, as the build compiles each call", - "test.tsx:2:65: `` cannot use `cx(getClass())` at build time: `css` and `cx` compose only style objects, CSS text, classes, calls of them, or arrays or conditions of these", + "test.tsx:2:66: `` cannot use `css(getClass())` at build time: `css` composes only style objects, CSS text, classes, calls of `css` and `cx`, or arrays or conditions of these, and `cx` also classes a function call gives, which stay as written", + "test.tsx:2:65: `` cannot use `cx(new Klass())` at build time: `css` composes only style objects, CSS text, classes, calls of `css` and `cx`, or arrays or conditions of these, and `cx` also classes a function call gives, which stay as written", "test.tsx:2:78: `` cannot use `s` at build time: an object `cx` takes must give each class a condition, as `{ name: condition }`", "test.tsx:2:75: `` cannot use `(function() { return true; })` at build time: an object `cx` takes must give each class a condition, as `{ name: condition }`", - "test.tsx:2:71: `` cannot use `css(...s)` at build time: `css` and `cx` compose only style objects, CSS text, classes, calls of them, or arrays or conditions of these", + "test.tsx:2:71: `` cannot use `css(...s)` at build time: `css` composes only style objects, CSS text, classes, calls of `css` and `cx`, or arrays or conditions of these, and `cx` also classes a function call gives, which stay as written", "test.tsx:2:87: `` cannot use `v` at build time: a mixin must stand outside nested rules, where the parts it composes with can be split", "test.tsx:2:71: `css()` cannot use `v` at build time: its values must be literals, theme tokens or constants, or be computed from them", - "test.tsx:2:76: `` cannot use `cx(on && cx(css({ color: \"red\" }), \"x\"))` at build time: `css` and `cx` compose only style objects, CSS text, classes, calls of them, or arrays or conditions of these", - "test.tsx:2:81: `` cannot use `cx(on && cx(off ? css({ color: \"red\" }) : null))` at build time: `css` and `cx` compose only style objects, CSS text, classes, calls of them, or arrays or conditions of these", + "test.tsx:2:76: `` cannot use `cx(on && cx(css({ color: \"red\" }), \"x\"))` at build time: `css` composes only style objects, CSS text, classes, calls of `css` and `cx`, or arrays or conditions of these, and `cx` also classes a function call gives, which stay as written", + "test.tsx:2:81: `` cannot use `cx(on && cx(off ? css({ color: \"red\" }) : null))` at build time: `css` composes only style objects, CSS text, classes, calls of `css` and `cx`, or arrays or conditions of these, and `cx` also classes a function call gives, which stay as written", ] diff --git a/libs/extractor/src/snapshots/extractor__tests__style_objects_declared_in_functions.snap b/libs/extractor/src/snapshots/extractor__tests__style_objects_declared_in_functions.snap new file mode 100644 index 000000000..6afe04078 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__style_objects_declared_in_functions.snap @@ -0,0 +1,86 @@ +--- +source: libs/extractor/src/lib.rs +expression: "library_outputs(&borrowed, SIZE_MODULES)" +--- +[ + ( + "import { css, styled, globalCss, keyframes, Box } from '@devup-ui/react';\nexport function f() { const inner = { color: 'red' }; return css(inner); }", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport function f() {\\n\\tconst inner = { color: \\\"red\\\" };\\n\\treturn \\\"a\\\";\\n}\\n\" }", + ), + ( + "import { css, styled, globalCss, keyframes, Box } from '@devup-ui/react';\nexport function f() { const a = { color: 'red' }; const b = { color: 'blue', margin: 1 }; return css(a, b); }", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"blue\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"margin\", value: \"4px\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport function f() {\\n\\tconst a = { color: \\\"red\\\" };\\n\\tconst b = {\\n\\t\\tcolor: \\\"blue\\\",\\n\\t\\tmargin: 1\\n\\t};\\n\\treturn \\\"a b\\\";\\n}\\n\" }", + ), + ( + "import { css, styled, globalCss, keyframes, Box } from '@devup-ui/react';\nexport function f(on) { const a = { color: 'red' }; return css(on && a, { margin: 1 }); }", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"margin\", value: \"4px\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport function f(on) {\\n\\tconst a = { color: \\\"red\\\" };\\n\\treturn `b ${on ? \\\"a\\\" : \\\"\\\"}`;\\n}\\n\" }", + ), + ( + "import { css, styled, globalCss, keyframes, Box } from '@devup-ui/react';\nexport function f() { const inner = { color: 'red' }; console.log(inner); return css(inner); }", + "Error: /src/App.tsx:2:29: Cannot read `inner` as styles at build time: it is also read elsewhere, where code may change it, so use it only as styles, or declare it with `const` at the top level of the module", + ), + ( + "import { css, styled, globalCss, keyframes, Box } from '@devup-ui/react';\nexport function f() { const inner = { color: 'red' }; inner.color = 'blue'; return css(inner); }", + "Error: /src/App.tsx:2:29: Cannot read `inner` as styles at build time: it is also read elsewhere, where code may change it, so use it only as styles, or declare it with `const` at the top level of the module", + ), + ( + "import { css, styled, globalCss, keyframes, Box } from '@devup-ui/react';\nexport function f(c) { const inner = { color: c }; return css(inner); }", + "Error: /src/App.tsx:2:63: `css()` cannot use `inner` at build time: a style object it composes must be written in it, or declared with `const` at the top level of the module, where the build reads it", + ), + ( + "import { css, styled, globalCss, keyframes, Box } from '@devup-ui/react';\nexport function f() { const parts = [{ color: 'red' }, { margin: 1 }]; return css(parts); }", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"margin\", value: \"4px\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport function f() {\\n\\tconst parts = [{ color: \\\"red\\\" }, { margin: 1 }];\\n\\treturn \\\"a b\\\";\\n}\\n\" }", + ), + ( + "import { css, styled, globalCss, keyframes, Box } from '@devup-ui/react';\nexport function f() { const inner = { color: 'red' }; return css(...[inner]); }", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport function f() {\\n\\tconst inner = { color: \\\"red\\\" };\\n\\treturn \\\"a\\\";\\n}\\n\" }", + ), + ( + "import { css, styled, globalCss, keyframes, Box } from '@devup-ui/react';\nexport function f() { const inner = { color: 'red' }; return styled.div(inner); }", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nimport { forwardRef as __devupForwardRef } from \\\"react\\\";\\nexport function f() {\\n\\tconst inner = { color: \\\"red\\\" };\\n\\treturn __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \\\"div\\\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\\\"a\\\", className].filter(Boolean).join(\\\" \\\")} style={style} />)({\\n\\t\\t...__devupRefProps,\\n\\t\\tref: __devupRef\\n\\t}));\\n}\\n\" }", + ), + ( + "import { css, styled, globalCss, keyframes, Box } from '@devup-ui/react';\nexport function f() { const inner = { color: 'red' }; return styled('div')(inner); }", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nimport { forwardRef as __devupForwardRef } from \\\"react\\\";\\nexport function f() {\\n\\tconst inner = { color: \\\"red\\\" };\\n\\treturn __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \\\"div\\\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\\\"a\\\", className].filter(Boolean).join(\\\" \\\")} style={style} />)({\\n\\t\\t...__devupRefProps,\\n\\t\\tref: __devupRef\\n\\t}));\\n}\\n\" }", + ), + ( + "import { css, styled, globalCss, keyframes, Box } from '@devup-ui/react';\nexport function f(c) { const inner = { color: c }; return styled.div(inner); }", + "Error: /src/App.tsx:2:70: `styled()` cannot use `inner` at build time: a style object it composes must be written in it, or declared with `const` at the top level of the module, where the build reads it", + ), + ( + "import { css, styled, globalCss, keyframes, Box } from '@devup-ui/react';\nexport function f() { const inner = { body: { color: 'red' } }; return globalCss(inner); }", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: Some(Global(\"body\", \"/src/App.tsx\")), style_order: Some(0), layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport function f() {\\n\\tconst inner = { body: { color: \\\"red\\\" } };\\n\\treturn void 0;\\n}\\n\" }", + ), + ( + "import { css, styled, globalCss, keyframes, Box } from '@devup-ui/react';\nexport function f(c) { const inner = { body: { color: c } }; return globalCss(inner); }", + "Error: /src/App.tsx:2:69: `globalCss()` cannot use `inner` at build time: its values must be literals, theme tokens or constants, or be computed from them\n/src/App.tsx:2:79: `globalCss()` cannot use `inner` at build time: a style object it composes must be written in it, or declared with `const` at the top level of the module, where the build reads it", + ), + ( + "import { css, styled, globalCss, keyframes, Box } from '@devup-ui/react';\nexport function f() { const inner = { from: { opacity: 0 }, to: { opacity: 1 } }; return keyframes(inner); }", + "ToBTreeSet { styles: {Keyframes(ExtractKeyframes { keyframes: {\"from\": [ExtractStaticStyle { property: \"opacity\", value: \"0\", level: 0, selector: None, style_order: None, layer: None }], \"to\": [ExtractStaticStyle { property: \"opacity\", value: \"1\", level: 0, selector: None, style_order: None, layer: None }]} })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport function f() {\\n\\tconst inner = {\\n\\t\\tfrom: { opacity: 0 },\\n\\t\\tto: { opacity: 1 }\\n\\t};\\n\\treturn \\\"a\\\";\\n}\\n\" }", + ), + ( + "import { css, styled, globalCss, keyframes, Box } from '@devup-ui/react';\nexport function f(c) { const inner = { from: { opacity: c } }; return keyframes(inner); }", + "Error: /src/App.tsx:2:71: `keyframes()` cannot use `inner` at build time: its values must be literals, theme tokens or constants, or be computed from them\n/src/App.tsx:2:81: `keyframes()` cannot use `inner` at build time: a style object it composes must be written in it, or declared with `const` at the top level of the module, where the build reads it", + ), + ( + "import { css, styled, globalCss, keyframes, Box } from '@devup-ui/react';\nexport function f() { const inner = { color: 'red' }; return ; }", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport function f() {\\n\\tconst inner = { color: \\\"red\\\" };\\n\\treturn
;\\n}\\n\" }", + ), + ( + "import { css, styled, globalCss, keyframes, Box } from '@devup-ui/react';\nexport function f() { const inner = { color: 'red' }; return ; }", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: Some(Selector(\"&:hover\")), style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport function f() {\\n\\tconst inner = { color: \\\"red\\\" };\\n\\treturn
;\\n}\\n\" }", + ), + ( + "import { css, styled, globalCss, keyframes, Box } from '@devup-ui/react';\nexport function f() { const inner = { color: 'red' }; return ; }", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: Some(Selector(\"&:hover\")), style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport function f() {\\n\\tconst inner = { color: \\\"red\\\" };\\n\\treturn
;\\n}\\n\" }", + ), + ( + "import { css, styled, globalCss, keyframes, Box } from '@devup-ui/react';\nexport function f(c) { const inner = { color: c }; return ; }", + "ToBTreeSet { styles: {}, code: \"export function f(c) {\\n\\tconst inner = { color: c };\\n\\treturn
;\\n}\\n\" }", + ), + ( + "import { css, styled, globalCss, keyframes, Box } from '@devup-ui/react';\nexport function f() { const inner = { color: 'red' }; const cls = 'x'; return css(inner, cls); }", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport function f() {\\n\\tconst inner = { color: \\\"red\\\" };\\n\\tconst cls = \\\"x\\\";\\n\\treturn `a ${cls}`;\\n}\\n\" }", + ), +] diff --git a/libs/extractor/src/snapshots/extractor__tests__style_objects_declared_in_functions_of_other_libraries.snap b/libs/extractor/src/snapshots/extractor__tests__style_objects_declared_in_functions_of_other_libraries.snap new file mode 100644 index 000000000..c31f6ff3c --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__style_objects_declared_in_functions_of_other_libraries.snap @@ -0,0 +1,50 @@ +--- +source: libs/extractor/src/lib.rs +expression: "library_outputs(&[\"import { css } from '@emotion/react';\\nexport function f() { const inner = { color: 'red', padding: 8 }; return css(inner); }\",\n\"import { css } from '@emotion/react';\\nexport function f(c) { const inner = { color: c }; return css(inner); }\",\n\"import styled from '@emotion/styled';\\nexport function f() { const inner = { color: 'red' }; return styled.div(inner); }\",\n\"import styled from '@emotion/styled';\\nexport function f(c) { const inner = { color: c }; return styled.div(inner); }\",\n\"import styled, { css } from 'styled-components';\\nexport function f() { const inner = { color: 'red' }; return styled.div(inner); }\",\n\"import styled, { css } from 'styled-components';\\nexport function f() { const inner = { color: 'red' }; return css(inner); }\",\n\"import { style } from '@vanilla-extract/css';\\nexport function f() { const inner = { color: 'red' }; return style(inner); }\",\n\"import { style } from '@vanilla-extract/css';\\nexport function f(c) { const inner = { color: c }; return style(inner); }\",\n\"import { Global } from '@emotion/react';\\nexport function f() { const inner = { body: { margin: 8 } }; return ; }\",\n\"import { ClassNames } from '@emotion/react';\\nexport function f() { const inner = { color: 'red' }; return {({ css }) => }; }\",\n\"import { css } from '@emotion/react';\\nexport function f() { const inner = { color: 'red' }; return
; }\",],\nSIZE_MODULES)" +--- +[ + ( + "import { css } from '@emotion/react';\nexport function f() { const inner = { color: 'red', padding: 8 }; return css(inner); }", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"padding\", value: \"8px\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport function f() {\\n\\tconst inner = {\\n\\t\\tcolor: \\\"red\\\",\\n\\t\\tpadding: 8\\n\\t};\\n\\treturn \\\"a b\\\";\\n}\\n\" }", + ), + ( + "import { css } from '@emotion/react';\nexport function f(c) { const inner = { color: c }; return css(inner); }", + "Error: /src/App.tsx:2:63: `css()` cannot use `inner` at build time: a style object it composes must be written in it, or declared with `const` at the top level of the module, where the build reads it", + ), + ( + "import styled from '@emotion/styled';\nexport function f() { const inner = { color: 'red' }; return styled.div(inner); }", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nimport { forwardRef as __devupForwardRef } from \\\"react\\\";\\nexport function f() {\\n\\tconst inner = { color: \\\"red\\\" };\\n\\treturn __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \\\"div\\\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\\\"a\\\", className].filter(Boolean).join(\\\" \\\")} style={style} />)({\\n\\t\\t...__devupRefProps,\\n\\t\\tref: __devupRef\\n\\t}));\\n}\\n\" }", + ), + ( + "import styled from '@emotion/styled';\nexport function f(c) { const inner = { color: c }; return styled.div(inner); }", + "Error: /src/App.tsx:2:70: `styled()` cannot use `inner` at build time: a style object it composes must be written in it, or declared with `const` at the top level of the module, where the build reads it", + ), + ( + "import styled, { css } from 'styled-components';\nexport function f() { const inner = { color: 'red' }; return styled.div(inner); }", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nimport { forwardRef as __devupForwardRef } from \\\"react\\\";\\nexport function f() {\\n\\tconst inner = { color: \\\"red\\\" };\\n\\treturn __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \\\"div\\\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\\\"a\\\", className].filter(Boolean).join(\\\" \\\")} style={style} />)({\\n\\t\\t...__devupRefProps,\\n\\t\\tref: __devupRef\\n\\t}));\\n}\\n\" }", + ), + ( + "import styled, { css } from 'styled-components';\nexport function f() { const inner = { color: 'red' }; return css(inner); }", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport function f() {\\n\\tconst inner = { color: \\\"red\\\" };\\n\\treturn \\\"a\\\";\\n}\\n\" }", + ), + ( + "import { style } from '@vanilla-extract/css';\nexport function f() { const inner = { color: 'red' }; return style(inner); }", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport function f() {\\n\\tconst inner = { color: \\\"red\\\" };\\n\\treturn \\\"a\\\";\\n}\\n\" }", + ), + ( + "import { style } from '@vanilla-extract/css';\nexport function f(c) { const inner = { color: c }; return style(inner); }", + "Error: /src/App.tsx:2:65: `css()` cannot use `inner` at build time: a style object it composes must be written in it, or declared with `const` at the top level of the module, where the build reads it", + ), + ( + "import { Global } from '@emotion/react';\nexport function f() { const inner = { body: { margin: 8 } }; return ; }", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"margin\", value: \"8px\", level: 0, selector: Some(Global(\"body\", \"/src/App.tsx\")), style_order: Some(0), layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nimport { Global } from \\\"@devup-ui/react/compat\\\";\\nexport function f() {\\n\\tconst inner = { body: { margin: 8 } };\\n\\treturn ;\\n}\\n\" }", + ), + ( + "import { ClassNames } from '@emotion/react';\nexport function f() { const inner = { color: 'red' }; return {({ css }) => }; }", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport function f() {\\n\\tconst inner = { color: \\\"red\\\" };\\n\\treturn ;\\n}\\n\" }", + ), + ( + "import { css } from '@emotion/react';\nexport function f() { const inner = { color: 'red' }; return
; }", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport function f() {\\n\\tconst inner = { color: \\\"red\\\" };\\n\\treturn
;\\n}\\n\" }", + ), +] diff --git a/libs/extractor/src/snapshots/extractor__tests__styled_functions_of_the_props_and_arrays_under_conditions.snap b/libs/extractor/src/snapshots/extractor__tests__styled_functions_of_the_props_and_arrays_under_conditions.snap new file mode 100644 index 000000000..feed264fa --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__styled_functions_of_the_props_and_arrays_under_conditions.snap @@ -0,0 +1,102 @@ +--- +source: libs/extractor/src/lib.rs +expression: "library_outputs(&[\"import styled from '@emotion/styled';\\nexport const A = styled.div(function (p) { return { margin: 8, color: p.c }; });\",\n\"import styled from '@emotion/styled';\\nexport const A = styled.div(({ theme }) => ({ color: theme['colors'].primary, margin: theme.space[2], padding: theme.space[0] }));\",\n\"import styled from '@emotion/styled';\\nexport const A = styled.div((p) => ({ color: () => 'x', margin: p.m }));\",\n\"import styled, { css } from 'styled-components';\\nexport const A = styled.div((p) => ({ color: p.color, padding: 8, lineHeight: p.lh, '&:hover': { margin: p.m } }));\\nexport const B = styled('div')({ width: (p) => p.w });\",\n\"import styled from '@emotion/styled';\\nexport const A = styled.div((p) => { const x = p.x; return { color: x }; });\",\n\"import styled from '@emotion/styled';\\nexport const A = styled.div((p) => ({ ...p.rules, color: 'red' }));\",\n\"import styled from '@emotion/styled';\\nexport const A = styled.div((p) => ({ [p.key]: 1 }));\",\n\"import styled from '@emotion/styled';\\nexport const A = styled.div(async (p) => ({ color: p.color }));\",\n\"import styled from '@emotion/styled';\\nexport const A = styled.div`color: red;`.attrs({ id: 'x' });\\nexport const B = styled.div.attrs({ id: 'x' })({ color: 'red' });\\nexport const C = styled('div').attrs({ id: 'x' })({ color: 'red' });\",\n\"import styled from '@emotion/styled';\\nexport const B = (on, off) => styled.div(on ? [{ color: 'red' }, { margin: 1 }] : [{ color: 'blue' }], off && [{ width: 2 }]);\",\n\"import styled from '@emotion/styled';\\nexport const B = (on) => styled.div(on ? [{ color: 'red' }] : null, [on && [{ margin: 1 }]]);\",\n\"import styled from '@emotion/styled';\\nexport const B = (get) => styled.div(get() && [{ color: 'red' }]);\",\n\"import { css } from '@emotion/react';\\nexport const a = (on) => css(on ? [{ color: 'red' }] : [{ color: 'blue' }, { margin: 1 }], [on || [{ width: 2 }]]);\",\n\"import styled from '@emotion/styled';\\nexport const A = styled.div({ color: (p) => p.color, padding: 8, '&:hover': { color: (p) => p.h } });\",\n\"import styled from '@emotion/styled';\\nexport const A = styled.div({ color: ({ theme }) => theme.colors.primary, margin: ({ theme }) => theme.space[2] });\",\n\"import styled from '@emotion/styled';\\nexport const A = styled.div({ color: (p) => p.on ? 'red' : 'blue' });\",\n\"import { css } from '@emotion/react';\\nexport const a = (cond) => css(cond && [{ color: 'red' }, { margin: 1 }], { color: 'blue' });\",\n\"import styled from '@emotion/styled';\\nexport const A = styled.div(({ on }) => on && [{ color: 'red' }]);\\nexport const B = (on) => styled.div(on && [{ color: 'red' }, { margin: 1 }]);\",\n\"import styled from '@emotion/styled';\\nexport const A = styled.div({ color: 'red' }).attrs({ id: 'x' });\",\n\"import styled from '@emotion/styled';\\nexport const A = styled.div((props) => ({ color: props.color, padding: 8 }));\",\n\"import styled from '@emotion/styled';\\nexport const A = styled.div({ margin: 1 }, (props) => ({ color: props.color, width: props.w }));\",\n\"import styled from '@emotion/styled';\\nexport const A = styled.div`color: ${(p) => p.color}; padding: ${(p) => p.pad}px; background: ${({ theme }) => theme.bg}`;\",\n\"import styled from '@emotion/styled';\\nexport const A = styled.div((p) => ({ color: p.on ? 'red' : 'blue', margin: p.m, '&:hover': { color: p.h } }));\",\n\"import styled from '@emotion/styled';\\nexport const A = styled.div(({ theme }) => ({ color: theme.colors.primary, margin: theme.space[2] }));\",],\nSIZE_MODULES)" +--- +[ + ( + "import styled from '@emotion/styled';\nexport const A = styled.div(function (p) { return { margin: 8, color: p.c }; });", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"margin\", value: \"8px\", level: 0, selector: None, style_order: None, layer: None }), Dynamic(ExtractDynamicStyle { property: \"color\", level: 0, identifier: \"(p=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(p.c))(rest)\", selector: None, style_order: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nimport { forwardRef as __devupForwardRef } from \\\"react\\\";\\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \\\"div\\\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\\\"a b\\\", className].filter(Boolean).join(\\\" \\\")} style={{\\n\\t...{ \\\"--c\\\": (p=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(p.c))(rest) },\\n\\t...style\\n}} />)({\\n\\t...__devupRefProps,\\n\\tref: __devupRef\\n}));\\n\" }", + ), + ( + "import styled from '@emotion/styled';\nexport const A = styled.div(({ theme }) => ({ color: theme['colors'].primary, margin: theme.space[2], padding: theme.space[0] }));", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"var(--colors-primary)\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"margin\", value: \"var(--space-2)\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"padding\", value: \"var(--space-0)\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nimport { forwardRef as __devupForwardRef } from \\\"react\\\";\\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \\\"div\\\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\\\"a b c\\\", className].filter(Boolean).join(\\\" \\\")} style={style} />)({\\n\\t...__devupRefProps,\\n\\tref: __devupRef\\n}));\\n\" }", + ), + ( + "import styled from '@emotion/styled';\nexport const A = styled.div((p) => ({ color: () => 'x', margin: p.m }));", + "ToBTreeSet { styles: {Dynamic(ExtractDynamicStyle { property: \"color\", level: 0, identifier: \"(()=>`x`)(rest)\", selector: None, style_order: None }), Dynamic(ExtractDynamicStyle { property: \"margin\", level: 0, identifier: \"(p=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(p.m))(rest)\", selector: None, style_order: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nimport { forwardRef as __devupForwardRef } from \\\"react\\\";\\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \\\"div\\\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\\\"a c\\\", className].filter(Boolean).join(\\\" \\\")} style={{\\n\\t...{\\n\\t\\t\\\"--b\\\": (()=>`x`)(rest),\\n\\t\\t\\\"--d\\\": (p=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(p.m))(rest)\\n\\t},\\n\\t...style\\n}} />)({\\n\\t...__devupRefProps,\\n\\tref: __devupRef\\n}));\\n\" }", + ), + ( + "import styled, { css } from 'styled-components';\nexport const A = styled.div((p) => ({ color: p.color, padding: 8, lineHeight: p.lh, '&:hover': { margin: p.m } }));\nexport const B = styled('div')({ width: (p) => p.w });", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"padding\", value: \"8px\", level: 0, selector: None, style_order: None, layer: None }), Dynamic(ExtractDynamicStyle { property: \"color\", level: 0, identifier: \"(p=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(p.color))(rest)\", selector: None, style_order: None }), Dynamic(ExtractDynamicStyle { property: \"line-height\", level: 0, identifier: \"(p=>p.lh)(rest)\", selector: None, style_order: None }), Dynamic(ExtractDynamicStyle { property: \"margin\", level: 0, identifier: \"(p=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(p.m))(rest)\", selector: Some(Selector(\"&:hover\")), style_order: None }), Dynamic(ExtractDynamicStyle { property: \"width\", level: 0, identifier: \"(p=>p.w)(rest)\", selector: None, style_order: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nimport { forwardRef as __devupForwardRef } from \\\"react\\\";\\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \\\"div\\\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\\\"a c d f\\\", className].filter(Boolean).join(\\\" \\\")} style={{\\n\\t...{\\n\\t\\t\\\"--b\\\": (p=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(p.color))(rest),\\n\\t\\t\\\"--e\\\": (p=>p.lh)(rest),\\n\\t\\t\\\"--g\\\": (p=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(p.m))(rest)\\n\\t},\\n\\t...style\\n}} />)({\\n\\t...__devupRefProps,\\n\\tref: __devupRef\\n}));\\nexport const B = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \\\"div\\\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\\\"h\\\", className].filter(Boolean).join(\\\" \\\")} style={{\\n\\t...{ \\\"--i\\\": (p=>p.w)(rest) },\\n\\t...style\\n}} />)({\\n\\t...__devupRefProps,\\n\\tref: __devupRef\\n}));\\n\" }", + ), + ( + "import styled from '@emotion/styled';\nexport const A = styled.div((p) => { const x = p.x; return { color: x }; });", + "Error: /src/App.tsx:2:29: `styled()` cannot use `(p) => { const x = p.x; return { color: x }; }` at build time: a style function of the props must give one rule object at once, with every entry written out, as `(props) => ({ color: props.color })`", + ), + ( + "import styled from '@emotion/styled';\nexport const A = styled.div((p) => ({ ...p.rules, color: 'red' }));", + "Error: /src/App.tsx:2:29: `styled()` cannot use `(p) => ({ ...p.rules, color: \"red\" })` at build time: a style function of the props must give one rule object at once, with every entry written out, as `(props) => ({ color: props.color })`", + ), + ( + "import styled from '@emotion/styled';\nexport const A = styled.div((p) => ({ [p.key]: 1 }));", + "Error: /src/App.tsx:2:29: `styled()` cannot use `(p) => ({ [p.key]: 1 })` at build time: a style function of the props must give one rule object at once, with every entry written out, as `(props) => ({ color: props.color })`", + ), + ( + "import styled from '@emotion/styled';\nexport const A = styled.div(async (p) => ({ color: p.color }));", + "Error: /src/App.tsx:2:29: `styled()` cannot use `async (p) => ({ color: p.color })` at build time: a style function of the props must give one rule object at once, with every entry written out, as `(props) => ({ color: props.color })`", + ), + ( + "import styled from '@emotion/styled';\nexport const A = styled.div`color: red;`.attrs({ id: 'x' });\nexport const B = styled.div.attrs({ id: 'x' })({ color: 'red' });\nexport const C = styled('div').attrs({ id: 'x' })({ color: 'red' });", + "Error: /src/App.tsx:2:18: `styled()` cannot use `styled.div`color: red;`.attrs({ id: \"x\" })` at build time: chain `.attrs` before the styles, as `styled.div.attrs({ ... })({ ... })`, which neither Emotion nor styled-components take after them", + ), + ( + "import styled from '@emotion/styled';\nexport const B = (on, off) => styled.div(on ? [{ color: 'red' }, { margin: 1 }] : [{ color: 'blue' }], off && [{ width: 2 }]);", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"blue\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"margin\", value: \"1px\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"width\", value: \"2px\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nimport { forwardRef as __devupForwardRef } from \\\"react\\\";\\nexport const B = (on, off) => __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \\\"div\\\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[`${on ? \\\"a\\\" : \\\"\\\"} ${on ? on ? \\\"b\\\" : \\\"\\\" : \\\"c\\\"} ${off ? \\\"d\\\" : \\\"\\\"}`, className].filter(Boolean).join(\\\" \\\")} style={style} />)({\\n\\t...__devupRefProps,\\n\\tref: __devupRef\\n}));\\n\" }", + ), + ( + "import styled from '@emotion/styled';\nexport const B = (on) => styled.div(on ? [{ color: 'red' }] : null, [on && [{ margin: 1 }]]);", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"margin\", value: \"1px\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nimport { forwardRef as __devupForwardRef } from \\\"react\\\";\\nexport const B = (on) => __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \\\"div\\\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[`${on ? \\\"a\\\" : \\\"\\\"} ${on ? \\\"b\\\" : \\\"\\\"}`, className].filter(Boolean).join(\\\" \\\")} style={style} />)({\\n\\t...__devupRefProps,\\n\\tref: __devupRef\\n}));\\n\" }", + ), + ( + "import styled from '@emotion/styled';\nexport const B = (get) => styled.div(get() && [{ color: 'red' }]);", + "Error: /src/App.tsx:2:27: Cannot compose `get() && [{ color: \"red\" }]` at build time: each style must be a rule object, a class, or a condition choosing between them", + ), + ( + "import { css } from '@emotion/react';\nexport const a = (on) => css(on ? [{ color: 'red' }] : [{ color: 'blue' }, { margin: 1 }], [on || [{ width: 2 }]]);", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"blue\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"margin\", value: \"1px\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"width\", value: \"2px\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport const a = (on) => `${on ? typeof on === \\\"string\\\" ? on : \\\"\\\" : \\\"\\\"} ${`${on ? \\\"a\\\" : \\\"b\\\"} ${on ? \\\"\\\" : \\\"c\\\"} ${on ? \\\"\\\" : \\\"d\\\"}`}`;\\n\" }", + ), + ( + "import styled from '@emotion/styled';\nexport const A = styled.div({ color: (p) => p.color, padding: 8, '&:hover': { color: (p) => p.h } });", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"padding\", value: \"8px\", level: 0, selector: None, style_order: None, layer: None }), Dynamic(ExtractDynamicStyle { property: \"color\", level: 0, identifier: \"(p=>p.color)(rest)\", selector: None, style_order: None }), Dynamic(ExtractDynamicStyle { property: \"color\", level: 0, identifier: \"(p=>p.h)(rest)\", selector: Some(Selector(\"&:hover\")), style_order: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nimport { forwardRef as __devupForwardRef } from \\\"react\\\";\\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \\\"div\\\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\\\"a c d\\\", className].filter(Boolean).join(\\\" \\\")} style={{\\n\\t...{\\n\\t\\t\\\"--b\\\": (p=>p.color)(rest),\\n\\t\\t\\\"--e\\\": (p=>p.h)(rest)\\n\\t},\\n\\t...style\\n}} />)({\\n\\t...__devupRefProps,\\n\\tref: __devupRef\\n}));\\n\" }", + ), + ( + "import styled from '@emotion/styled';\nexport const A = styled.div({ color: ({ theme }) => theme.colors.primary, margin: ({ theme }) => theme.space[2] });", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"var(--colors-primary)\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"margin\", value: \"var(--space-2)\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nimport { forwardRef as __devupForwardRef } from \\\"react\\\";\\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \\\"div\\\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\\\"a b\\\", className].filter(Boolean).join(\\\" \\\")} style={style} />)({\\n\\t...__devupRefProps,\\n\\tref: __devupRef\\n}));\\n\" }", + ), + ( + "import styled from '@emotion/styled';\nexport const A = styled.div({ color: (p) => p.on ? 'red' : 'blue' });", + "ToBTreeSet { styles: {Dynamic(ExtractDynamicStyle { property: \"color\", level: 0, identifier: \"(p=>p.on?`red`:`blue`)(rest)\", selector: None, style_order: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nimport { forwardRef as __devupForwardRef } from \\\"react\\\";\\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \\\"div\\\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\\\"a\\\", className].filter(Boolean).join(\\\" \\\")} style={{\\n\\t...{ \\\"--b\\\": (p=>p.on?`red`:`blue`)(rest) },\\n\\t...style\\n}} />)({\\n\\t...__devupRefProps,\\n\\tref: __devupRef\\n}));\\n\" }", + ), + ( + "import { css } from '@emotion/react';\nexport const a = (cond) => css(cond && [{ color: 'red' }, { margin: 1 }], { color: 'blue' });", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"blue\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"margin\", value: \"1px\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport const a = (cond) => `b ${cond ? \\\"a\\\" : \\\"\\\"}`;\\n\" }", + ), + ( + "import styled from '@emotion/styled';\nexport const A = styled.div(({ on }) => on && [{ color: 'red' }]);\nexport const B = (on) => styled.div(on && [{ color: 'red' }, { margin: 1 }]);", + "Error: /src/App.tsx:2:29: `styled()` cannot use `({ on }) => on && [{ color: \"red\" }]` at build time: a style function of the props must give one rule object at once, with every entry written out, as `(props) => ({ color: props.color })`", + ), + ( + "import styled from '@emotion/styled';\nexport const A = styled.div({ color: 'red' }).attrs({ id: 'x' });", + "Error: /src/App.tsx:2:18: `styled()` cannot use `styled.div({ color: \"red\" }).attrs({ id: \"x\" })` at build time: chain `.attrs` before the styles, as `styled.div.attrs({ ... })({ ... })`, which neither Emotion nor styled-components take after them", + ), + ( + "import styled from '@emotion/styled';\nexport const A = styled.div((props) => ({ color: props.color, padding: 8 }));", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"padding\", value: \"8px\", level: 0, selector: None, style_order: None, layer: None }), Dynamic(ExtractDynamicStyle { property: \"color\", level: 0, identifier: \"(props=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(props.color))(rest)\", selector: None, style_order: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nimport { forwardRef as __devupForwardRef } from \\\"react\\\";\\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \\\"div\\\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\\\"a c\\\", className].filter(Boolean).join(\\\" \\\")} style={{\\n\\t...{ \\\"--b\\\": (props=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(props.color))(rest) },\\n\\t...style\\n}} />)({\\n\\t...__devupRefProps,\\n\\tref: __devupRef\\n}));\\n\" }", + ), + ( + "import styled from '@emotion/styled';\nexport const A = styled.div({ margin: 1 }, (props) => ({ color: props.color, width: props.w }));", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"margin\", value: \"1px\", level: 0, selector: None, style_order: None, layer: None }), Dynamic(ExtractDynamicStyle { property: \"color\", level: 0, identifier: \"(props=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(props.color))(rest)\", selector: None, style_order: None }), Dynamic(ExtractDynamicStyle { property: \"width\", level: 0, identifier: \"(props=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(props.w))(rest)\", selector: None, style_order: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nimport { forwardRef as __devupForwardRef } from \\\"react\\\";\\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \\\"div\\\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\\\"a b d\\\", className].filter(Boolean).join(\\\" \\\")} style={{\\n\\t...{\\n\\t\\t\\\"--c\\\": (props=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(props.color))(rest),\\n\\t\\t\\\"--e\\\": (props=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(props.w))(rest)\\n\\t},\\n\\t...style\\n}} />)({\\n\\t...__devupRefProps,\\n\\tref: __devupRef\\n}));\\n\" }", + ), + ( + "import styled from '@emotion/styled';\nexport const A = styled.div`color: ${(p) => p.color}; padding: ${(p) => p.pad}px; background: ${({ theme }) => theme.bg}`;", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"background\", value: \"var(--bg)\", level: 0, selector: None, style_order: None, layer: None }), Dynamic(ExtractDynamicStyle { property: \"color\", level: 0, identifier: \"(p=>p.color)(rest)\", selector: None, style_order: None }), Dynamic(ExtractDynamicStyle { property: \"padding\", level: 0, identifier: \"`${(p=>p.pad)(rest)}px`\", selector: None, style_order: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nimport { forwardRef as __devupForwardRef } from \\\"react\\\";\\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \\\"div\\\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\\\"a c e\\\", className].filter(Boolean).join(\\\" \\\")} style={{\\n\\t...{\\n\\t\\t\\\"--b\\\": `${(p=>p.pad)(rest)}px`,\\n\\t\\t\\\"--d\\\": (p=>p.color)(rest)\\n\\t},\\n\\t...style\\n}} />)({\\n\\t...__devupRefProps,\\n\\tref: __devupRef\\n}));\\n\" }", + ), + ( + "import styled from '@emotion/styled';\nexport const A = styled.div((p) => ({ color: p.on ? 'red' : 'blue', margin: p.m, '&:hover': { color: p.h } }));", + "ToBTreeSet { styles: {Dynamic(ExtractDynamicStyle { property: \"color\", level: 0, identifier: \"(p=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(p.h))(rest)\", selector: Some(Selector(\"&:hover\")), style_order: None }), Dynamic(ExtractDynamicStyle { property: \"color\", level: 0, identifier: \"(p=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(p.on?`red`:`blue`))(rest)\", selector: None, style_order: None }), Dynamic(ExtractDynamicStyle { property: \"margin\", level: 0, identifier: \"(p=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(p.m))(rest)\", selector: None, style_order: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nimport { forwardRef as __devupForwardRef } from \\\"react\\\";\\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \\\"div\\\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\\\"a c e\\\", className].filter(Boolean).join(\\\" \\\")} style={{\\n\\t...{\\n\\t\\t\\\"--b\\\": (p=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(p.on?`red`:`blue`))(rest),\\n\\t\\t\\\"--d\\\": (p=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(p.m))(rest),\\n\\t\\t\\\"--f\\\": (p=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(p.h))(rest)\\n\\t},\\n\\t...style\\n}} />)({\\n\\t...__devupRefProps,\\n\\tref: __devupRef\\n}));\\n\" }", + ), + ( + "import styled from '@emotion/styled';\nexport const A = styled.div(({ theme }) => ({ color: theme.colors.primary, margin: theme.space[2] }));", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"var(--colors-primary)\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"margin\", value: \"var(--space-2)\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nimport { forwardRef as __devupForwardRef } from \\\"react\\\";\\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \\\"div\\\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\\\"a b\\\", className].filter(Boolean).join(\\\" \\\")} style={style} />)({\\n\\t...__devupRefProps,\\n\\tref: __devupRef\\n}));\\n\" }", + ), +] diff --git a/libs/extractor/src/snapshots/extractor__tests__styled_object_form_theme_interpolation-2.snap b/libs/extractor/src/snapshots/extractor__tests__styled_object_form_theme_interpolation-2.snap new file mode 100644 index 000000000..a272ec484 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__styled_object_form_theme_interpolation-2.snap @@ -0,0 +1,18 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract(\"test.tsx\",\n\"import { styled } from '@devup-ui/react';\\nconst Plain = styled('span')({ color: (p) => p.color });\",\nExtractOption::default(),).unwrap())" +--- +ToBTreeSet { + styles: { + Dynamic( + ExtractDynamicStyle { + property: "color", + level: 0, + identifier: "(p=>p.color)(rest)", + selector: None, + style_order: None, + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui-0.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nconst Plain = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"span\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\"a-a\", className].filter(Boolean).join(\" \")} style={{\n\t...{ \"--a\": (p=>p.color)(rest) },\n\t...style\n}} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n", +} diff --git a/libs/extractor/src/utils.rs b/libs/extractor/src/utils.rs index e5fb825c0..56f316dee 100644 --- a/libs/extractor/src/utils.rs +++ b/libs/extractor/src/utils.rs @@ -963,6 +963,14 @@ pub(super) fn css_prop_error(element: &str, code: &str, requirement: &str) -> St pub(super) const CSS_PROP_VALUE: &str = "it must be a style object, CSS text, a class `css()` gives, or a function of the theme giving one, or an array or condition of them"; +/// A style object declared below the top level that styles read, and code +/// elsewhere reads too +pub(super) fn local_style_error(name: &str) -> String { + format!( + "Cannot read `{name}` as styles at build time: it is also read elsewhere, where code may change it, so use it only as styles, or declare it with `const` at the top level of the module" + ) +} + pub(super) const LOCAL_STYLES: &str = "a style object it composes must be written in it, or declared with `const` at the top level of the module, where the build reads it"; pub(super) const CLASS_NAMES_CHILD: &str = @@ -970,7 +978,7 @@ pub(super) const CLASS_NAMES_CHILD: &str = pub(super) const CLASS_NAMES_CALL: &str = "the `css` and `cx` its child function takes can only be called, as the build compiles each call"; -pub(super) const CLASS_NAMES_PART: &str = "`css` and `cx` compose only style objects, CSS text, classes, calls of them, or arrays or conditions of these"; +pub(super) const CLASS_NAMES_PART: &str = "`css` composes only style objects, CSS text, classes, calls of `css` and `cx`, or arrays or conditions of these, and `cx` also classes a function call gives, which stay as written"; pub(super) const CLASS_NAMES_CLASS_MAP: &str = "an object `cx` takes must give each class a condition, as `{ name: condition }`"; diff --git a/libs/extractor/src/visit.rs b/libs/extractor/src/visit.rs index f4ae87dd7..0011e723f 100644 --- a/libs/extractor/src/visit.rs +++ b/libs/extractor/src/visit.rs @@ -118,6 +118,33 @@ struct JsxCss<'a> { renders: bool, } +/// Whether `expression` is a condition choosing an array of styles, which +/// compose one after another rather than set the breakpoints of a value +fn composes_array_under_condition(expression: &Expression<'_>) -> bool { + match unwrap_syntax_only(expression) { + Expression::ArrayExpression(array) => array.elements.iter().any(|element| { + element + .as_expression() + .is_some_and(composes_array_under_condition) + }), + Expression::ConditionalExpression(conditional) => { + [&conditional.consequent, &conditional.alternate] + .into_iter() + .any(|side| { + matches!(unwrap_syntax_only(side), Expression::ArrayExpression(_)) + || composes_array_under_condition(side) + }) + } + Expression::LogicalExpression(logical) => { + [&logical.left, &logical.right].into_iter().any(|side| { + matches!(unwrap_syntax_only(side), Expression::ArrayExpression(_)) + || composes_array_under_condition(side) + }) + } + _ => false, + } +} + /// `false ?? right` is `false`, which composes nothing fn coalesce_keeps_left(logical: &oxc_ast::ast::LogicalExpression<'_>) -> bool { logical.operator == LogicalOperator::Coalesce @@ -283,6 +310,9 @@ pub struct DevupVisitor<'a> { /// `css` prop cannot compose as a class, besides the top-level constants /// the build reads in its place local_styles: FxHashSet, + /// The bindings below the top level of the module holding an object or + /// array, which no style reads as the styles it holds + local_objects: FxHashSet, } /// Whether `declarator` only aliases what the build compiles away @@ -404,9 +434,9 @@ impl<'a> DevupVisitor<'a> { }) .collect(); if !flattened - && !arguments - .iter() - .any(|argument| self.reads_known_styles(argument)) + && !arguments.iter().any(|argument| { + self.reads_known_styles(argument) || composes_array_under_condition(argument) + }) { return None; } @@ -416,6 +446,7 @@ impl<'a> DevupVisitor<'a> { } self.unknown_arguments("css", &call.arguments); self.changed_arguments("css", &call.arguments); + self.local_arguments("css", &call.arguments); let (result, known) = self.composed_class(call.span.start, parts); if let Some(known) = known { self.css_styles = Some((call.span.start, known)); @@ -423,6 +454,137 @@ impl<'a> DevupVisitor<'a> { Some(result) } + /// Whether `expression` builds a styled component: `styled.div(...)`, + /// `styled("div")(...)`, `styled("div", {...})` or their CSS text forms + fn is_styled_call(&self, expression: &Expression<'a>) -> bool { + let (tag_or_call, argument_count) = match expression { + Expression::TaggedTemplateExpression(tag) => (Some(&tag.tag), 0), + Expression::CallExpression(call) => (Some(&call.callee), call.arguments.len()), + _ => (None, 0), + }; + let is_styled_name = |name: &str| self.styled_imports.contains(name); + + let Some(tag_or_call) = tag_or_call.map(unwrap_syntax_only) else { + return false; + }; + if let Expression::StaticMemberExpression(member) = tag_or_call { + matches!(&member.object, Expression::Identifier(ident) if is_styled_name(ident.name.as_str())) + } else if let Expression::CallExpression(call) = tag_or_call { + matches!(&call.callee, Expression::Identifier(ident) if is_styled_name(ident.name.as_str())) + } else if let Expression::Identifier(ident) = tag_or_call { + // styled("div", { ... }) puts the tag in the arguments, so the callee is + // the bare identifier. One argument is the curried creator `styled("div")`, + // which only becomes a component once its result is called. + is_styled_name(ident.name.as_str()) && argument_count == 2 + } else { + false + } + } + + /// `part` of what a styled component composes, with each array a condition + /// chooses written as a condition on each of its parts, which the styles + /// of a styled component read one by one. The conditions are read again + /// for each part, so they must give the same value each time. + fn distribute_conditions(&self, part: &mut Expression<'a>) { + let clone = |expression: &Expression<'a>| expression.clone_in(self.ast.allocator()); + let parts_of = |expression: &Expression<'a>| match unwrap_syntax_only(expression) { + Expression::ArrayExpression(array) => array + .elements + .iter() + .map(|element| element.as_expression().map(clone)) + .collect::>>(), + _ => None, + }; + let is_empty = |expression: &Expression<'a>| { + matches!( + unwrap_syntax_only(expression), + Expression::NullLiteral(_) | Expression::BooleanLiteral(_) + ) + }; + let null = || Expression::new_null_literal(SPAN, &self.ast); + let elements: Vec> = match unwrap_syntax_only_mut(part) { + Expression::ArrayExpression(array) => { + for element in &mut array.elements { + if let Some(element) = element.as_expression_mut() { + self.distribute_conditions(element); + } + } + return; + } + Expression::LogicalExpression(logical) + if logical.operator == LogicalOperator::And + && is_pure(&logical.left) + && let Some(parts) = parts_of(&logical.right) => + { + parts + .into_iter() + .map(|part| { + Expression::new_logical_expression( + SPAN, + clone(&logical.left), + LogicalOperator::And, + part, + &self.ast, + ) + }) + .collect() + } + Expression::ConditionalExpression(conditional) + if is_pure(&conditional.test) + && let Some(chosen) = parts_of(&conditional.consequent) + .or_else(|| is_empty(&conditional.consequent).then(Vec::new)) + && let Some(otherwise) = parts_of(&conditional.alternate) + .or_else(|| is_empty(&conditional.alternate).then(Vec::new)) => + { + let choose = |part, chosen: bool| { + let (consequent, alternate) = if chosen { + (part, null()) + } else { + (null(), part) + }; + Expression::new_conditional_expression( + SPAN, + clone(&conditional.test), + consequent, + alternate, + &self.ast, + ) + }; + chosen + .into_iter() + .map(|part| choose(part, true)) + .chain(otherwise.into_iter().map(|part| choose(part, false))) + .collect() + } + _ => return, + }; + *part = Expression::new_array_expression( + SPAN, + oxc_allocator::Vec::from_iter_in(elements.into_iter().map(Into::into), &self.ast), + &self.ast, + ); + self.distribute_conditions(part); + } + + /// Report `attrs` chained after the styles of a styled component, which + /// neither Emotion nor styled-components take + fn chained_attrs(&mut self, expression: &Expression<'a>) { + if let Expression::CallExpression(call) = expression + && let Expression::StaticMemberExpression(member) = unwrap_syntax_only(&call.callee) + && member.property.name == "attrs" + && self.is_styled_call(unwrap_syntax_only(&member.object)) + { + self.errors.push(( + call.span.start, + build_time_error( + "styled", + &readable_code(expression), + "chain `.attrs` before the styles, as `styled.div.attrs({ ... })({ ... })`, which neither Emotion nor styled-components take after them", + ), + )); + } + } + /// Whether `callee` is a call of `css()` fn is_css(&self, callee: &Expression<'a>) -> bool { self.util_type(callee) @@ -742,6 +904,13 @@ impl<'a> DevupVisitor<'a> { self.folded_side(expression, text) } Expression::ArrayExpression(_) => self.folded_side(expression, text), + // What a call gives the `cx` of `` is a class the build + // does not know, which stays as written + Expression::CallExpression(_) + if text == Text::Classes && !self.class_names_scope.is_empty() => + { + Some(KnownSide::Class(code())) + } _ => None, } } @@ -877,6 +1046,7 @@ impl<'a> DevupVisitor<'a> { css_prop: CssProp::Off, compiled_css_prop: false, local_styles: FxHashSet::default(), + local_objects: FxHashSet::default(), } } @@ -1233,6 +1403,36 @@ impl<'a> DevupVisitor<'a> { } } + /// Whether `part` reads a binding `local_objects` holds + fn reads_local_objects(&self, part: &Expression<'a>) -> bool { + self.style_values + .symbol(part) + .is_some_and(|symbol| self.local_objects.contains(&symbol)) + } + + /// Report the arguments of `api` that read an object or array declared + /// below the top level, which the build did not read as styles: it would + /// be taken for a class + fn local_arguments(&mut self, api: &str, arguments: &[Argument<'a>]) { + if self.local_objects.is_empty() { + return; + } + let mut errors = Vec::new(); + for argument in arguments { + let expression = match argument { + Argument::SpreadElement(spread) => &spread.argument, + argument => argument.to_expression(), + }; + if let Some(part) = self.css_part(expression, &Self::reads_local_objects) { + errors.push(( + part.span().start, + build_time_error(api, &readable_code(part), LOCAL_STYLES), + )); + } + } + self.errors.extend(errors); + } + /// Whether `part` reads a binding `local_styles` holds fn reads_local_styles(&self, part: &Expression<'a>) -> bool { let mut root = part; @@ -1652,6 +1852,7 @@ impl<'a> DevupVisitor<'a> { let offset = call.span.start; self.unknown_arguments("css", &call.arguments); self.changed_arguments("css", &call.arguments); + self.local_arguments("css", &call.arguments); let mut parts = Vec::new(); *it = if self.known_parts(it, &mut parts, Text::Classes).is_some() { self.composed_class(offset, parts).0 @@ -2490,6 +2691,16 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { if let Some(error) = forward_error { self.errors.push(error); } + self.chained_attrs(it); + if self.is_styled_call(it) + && let Expression::CallExpression(call) = it + { + for argument in &mut call.arguments { + if let Some(part) = argument.as_expression_mut() { + self.distribute_conditions(part); + } + } + } let flattened = match it { Expression::CallExpression(call) if self.is_css(&call.callee) => call .arguments @@ -2512,86 +2723,53 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { walk_expression(self, it); // Handle styled function calls - if !self.styled_imports.is_empty() { - let (tag_or_call, argument_count) = match it { - Expression::TaggedTemplateExpression(tag) => (Some(&tag.tag), 0), - Expression::CallExpression(call) => (Some(&call.callee), call.arguments.len()), - _ => (None, 0), - }; - let is_styled_name = |name: &str| self.styled_imports.contains(name); - - let is_styled = if let Some(tag_or_call) = tag_or_call.map(unwrap_syntax_only) { - if let Expression::StaticMemberExpression(member) = tag_or_call { - if let Expression::Identifier(ident) = &member.object { - is_styled_name(ident.name.as_str()) - } else { - false - } - } else if let Expression::CallExpression(call) = tag_or_call { - if let Expression::Identifier(ident) = &call.callee { - is_styled_name(ident.name.as_str()) - } else { - false - } - } else if let Expression::Identifier(ident) = tag_or_call { - // styled("div", { ... }) puts the tag in the arguments, so the callee is - // the bare identifier. One argument is the curried creator `styled("div")`, - // which only becomes a component once its result is called. - is_styled_name(ident.name.as_str()) && argument_count == 2 - } else { - false - } + if self.is_styled_call(it) { + self.style_values.read_in(&self.ast, it); + if let Expression::CallExpression(call) = &*it { + self.unknown_arguments("styled", &call.arguments); + self.changed_arguments("styled", &call.arguments); + self.local_arguments("styled", &call.arguments); + } + let inherited = extended(it) + .and_then(|base| self.style_values.symbol(base)) + .and_then(|symbol| self.styled_definitions.get(&symbol)) + .filter(|definition| definition.extendable()); + let start = it.span().start; + let marker = self + .pending_marker + .take_if(|(at, _)| *at == start) + .map(|(_, marker)| marker); + let StyledExtraction { + result, + expression, + errors, + definition, + } = extract_style_from_styled( + &self.ast, + it, + Naming { + split_filename: self.split_filename.as_deref(), + marker: marker.as_deref(), + }, + &self.imports, + &attrs, + inherited, + forward, + ); + self.errors.extend(errors); + self.styles.extend( + result + .styles + .into_iter() + .flat_map(ExtractStyleProp::into_extract), + ); + *it = if definition.is_some() { + self.forwards_refs = true; + forward_ref(&self.ast, expression) } else { - false + expression }; - - if is_styled { - self.style_values.read_in(&self.ast, it); - if let Expression::CallExpression(call) = &*it { - self.unknown_arguments("styled", &call.arguments); - self.changed_arguments("styled", &call.arguments); - } - let inherited = extended(it) - .and_then(|base| self.style_values.symbol(base)) - .and_then(|symbol| self.styled_definitions.get(&symbol)) - .filter(|definition| definition.extendable()); - let start = it.span().start; - let marker = self - .pending_marker - .take_if(|(at, _)| *at == start) - .map(|(_, marker)| marker); - let StyledExtraction { - result, - expression, - errors, - definition, - } = extract_style_from_styled( - &self.ast, - it, - Naming { - split_filename: self.split_filename.as_deref(), - marker: marker.as_deref(), - }, - &self.imports, - &attrs, - inherited, - forward, - ); - self.errors.extend(errors); - self.styles.extend( - result - .styles - .into_iter() - .flat_map(ExtractStyleProp::into_extract), - ); - *it = if definition.is_some() { - self.forwards_refs = true; - forward_ref(&self.ast, expression) - } else { - expression - }; - self.pending_styled = definition.map(|definition| (start, definition)); - } + self.pending_styled = definition.map(|definition| (start, definition)); } // `Component.withComponent(target)` on a styled component the file @@ -3031,6 +3209,14 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { } let offset = call.span.start; let is_css = matches!(util_type.as_ref(), UtilType::Css); + self.local_arguments( + match util_type.as_ref() { + UtilType::Css => "css", + UtilType::Keyframes => "keyframes", + UtilType::GlobalCss | UtilType::GlobalCssComponent => "globalCss", + }, + &call.arguments, + ); if is_css { self.unknown_arguments("css", &call.arguments); self.changed_arguments("css", &call.arguments); @@ -3665,6 +3851,18 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { self.local_styles.insert(symbol); } + if matches!( + it.init.as_ref().map(unwrap_syntax_only), + Some(Expression::ObjectExpression(_) | Expression::ArrayExpression(_)) + ) && let Some(symbol) = it + .id + .get_binding_identifier() + .and_then(|id| id.symbol_id.get()) + .filter(|symbol| self.style_values.is_local(*symbol)) + { + self.local_objects.insert(symbol); + } + let marked = styled_binding .filter(|symbol| self.selected_components.contains(symbol)) .zip(start) From 734fd16fe6ea7c8f60edb07572b23829707e3696 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sat, 3 Oct 2026 23:18:51 +0900 Subject: [PATCH 02/12] test(extractor): cover parenthesized function-local style objects Refs #690, #695 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/lib.rs | 1 + ...extractor__tests__style_objects_declared_in_functions.snap | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 4a7500b22..6b7144ec2 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -21702,6 +21702,7 @@ export const App = () => ; }", "export function f(c) { const inner = { color: c }; return ; }", "export function f() { const inner = { color: 'red' }; const cls = 'x'; return css(inner, cls); }", + "export function f() { const inner = ({ color: ('red') }); return css(inner); }", ] .iter() .map(|case| format!("{devup}{case}")) diff --git a/libs/extractor/src/snapshots/extractor__tests__style_objects_declared_in_functions.snap b/libs/extractor/src/snapshots/extractor__tests__style_objects_declared_in_functions.snap index 6afe04078..ef8ad132e 100644 --- a/libs/extractor/src/snapshots/extractor__tests__style_objects_declared_in_functions.snap +++ b/libs/extractor/src/snapshots/extractor__tests__style_objects_declared_in_functions.snap @@ -83,4 +83,8 @@ expression: "library_outputs(&borrowed, SIZE_MODULES)" "import { css, styled, globalCss, keyframes, Box } from '@devup-ui/react';\nexport function f() { const inner = { color: 'red' }; const cls = 'x'; return css(inner, cls); }", "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport function f() {\\n\\tconst inner = { color: \\\"red\\\" };\\n\\tconst cls = \\\"x\\\";\\n\\treturn `a ${cls}`;\\n}\\n\" }", ), + ( + "import { css, styled, globalCss, keyframes, Box } from '@devup-ui/react';\nexport function f() { const inner = ({ color: ('red') }); return css(inner); }", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport function f() {\\n\\tconst inner = { color: \\\"red\\\" };\\n\\treturn \\\"a\\\";\\n}\\n\" }", + ), ] From c0a88f2de6bef0c68f0819c72d52b9253cd3e32f Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 4 Oct 2026 18:48:08 +0900 Subject: [PATCH 03/12] fix(extractor): preserve numeric units on logical rule operands Refs #690 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/import_alias_visit.rs | 34 +++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/libs/extractor/src/import_alias_visit.rs b/libs/extractor/src/import_alias_visit.rs index 3e2dc79f1..321e25ef2 100644 --- a/libs/extractor/src/import_alias_visit.rs +++ b/libs/extractor/src/import_alias_visit.rs @@ -115,7 +115,12 @@ impl LibraryNumbers<'_> { self.pixelify(&conditional.consequent); self.pixelify(&conditional.alternate); } - Expression::LogicalExpression(logical) => self.pixelify(&logical.right), + Expression::LogicalExpression(logical) => { + if logical.operator != LogicalOperator::And { + self.pixelify(&logical.left); + } + self.pixelify(&logical.right); + } Expression::ParenthesizedExpression(inner) => self.pixelify(&inner.expression), Expression::ArrowFunctionExpression(arrow) => { let rules = match &arrow.body { @@ -1046,6 +1051,33 @@ export const A = () => <>
({{ padding: 3, lineHeight: 2, margin: 0, vars: {{ '--raw': 5 }}, _hover: {{ padding: 6 }}, '@layer': {{ base: {{ padding: 7 }} }} }}) {operator} {{ padding: 4 }});" + ); + // When + let transformed = transform_import_aliases( + &source, + "logical-rules.tsx", + "@devup-ui/react", + &emotion_alias(), + ); + // Then + let expected = format!( + "import {{ styled }} from '@devup-ui/react'; styled.div(p => ({{ padding: {padding}, lineHeight: 2, margin: 0, vars: {{ '--raw': 5 }}, _hover: {{ padding: {hover} }}, '@layer': {{ base: {{ padding: {layer} }} }} }}) {operator} {{ padding: \"4px\" }});" + ); + assert_eq!(transformed, expected); + } + #[test] fn test_vanilla_extract_numbers_become_px_outside_stylesheets() { let code = r"import { style as s, globalStyle, keyframes, styleVariants } from '@vanilla-extract/css' From aaae7acf94f86e19c17ff2a2c0ae065cbd017b13 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 4 Oct 2026 19:01:15 +0900 Subject: [PATCH 04/12] fix(extractor): close build-time composition and styled capture pipelines Refs #688, #689, #690, #695, #685 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../src/bound_callback_alias_tests.rs | 91 ++ libs/extractor/src/bound_callback_captures.rs | 51 + .../src/bound_callback_export_tests.rs | 206 ++++ .../src/bound_callback_numeric_tests.rs | 258 +++++ libs/extractor/src/bound_style_callbacks.rs | 255 +++++ .../src/bound_style_callbacks_tests.rs | 248 +++++ .../src/capture_public_exports_w27.rs | 67 ++ .../src/composition_coverage_tests.rs | 172 ++++ .../src/css_composition_rest_tests.rs | 405 ++++++++ libs/extractor/src/css_utils.rs | 69 +- .../component_layer_coverage_tests.rs | 130 +++ .../src/css_utils/component_layer_objects.rs | 169 ++++ .../css_utils/component_layer_policy_tests.rs | 267 ++++++ .../src/css_utils/component_layer_tests.rs | 214 +++++ .../src/css_utils/component_layers.rs | 141 +++ libs/extractor/src/css_utils/layer_blocks.rs | 44 + libs/extractor/src/css_utils/layer_names.rs | 159 ++++ .../extract_style_from_expression.rs | 8 +- .../extractor/extract_style_from_styled.rs | 168 +++- .../extractor/styled_choice_test_support.rs | 108 +++ .../src/extractor/styled_mixin_context.rs | 135 +++ .../src/extractor/styled_rule_choices.rs | 242 +++++ .../src/extractor/styled_rule_parts.rs | 146 +++ .../extractor/styled_template_composition.rs | 218 +++++ .../src/extractor/styled_template_parts.rs | 170 ++++ .../src/extractor/w27_coverage_tests.rs | 165 ++++ .../w27_props_rule_choices_regressions.rs | 249 +++++ .../extractor/w27_props_rule_choices_tests.rs | 288 ++++++ .../extractor/w27_styled_choice_coverage.rs | 195 ++++ .../w27_styled_composition_coverage.rs | 114 +++ .../extractor/w27_styled_context_coverage.rs | 157 ++++ .../w27_styled_known_mixins_regressions.rs | 257 +++++ .../w27_styled_known_mixins_tests.rs | 253 +++++ .../extractor/w27_styled_template_coverage.rs | 177 ++++ libs/extractor/src/import_alias_visit.rs | 17 +- libs/extractor/src/imported_constants.rs | 390 +++++++- libs/extractor/src/imported_styled.rs | 878 +++++++++++++++++- .../src/imported_styled_coverage_tests.rs | 55 ++ libs/extractor/src/lib.rs | 2 + libs/extractor/src/local_capture_reads_w27.rs | 184 ++++ .../src/local_exact_constants_w27_tests.rs | 448 +++++++++ libs/extractor/src/local_rule_shapes_w27.rs | 280 ++++++ .../extractor/src/local_shape_captures_w27.rs | 77 ++ .../src/local_shape_composition_w27_tests.rs | 205 ++++ .../src/local_shape_hygiene_w27_tests.rs | 255 +++++ .../src/numeric_context_w27_tests.rs | 308 ++++++ ...ected_by_the_class_their_module_gives.snap | 2 +- ...d_cannot_read_stay_runtime_components.snap | 2 +- ...tests__emotion_css_prop_in_every_form.snap | 14 +- ...s_prop_reports_what_it_cannot_compile.snap | 2 +- ...on_css_text_composes_mixins_by_styles.snap | 4 +- ...__style_objects_declared_in_functions.snap | 10 +- ...lared_in_functions_of_other_libraries.snap | 6 +- ...the_props_and_arrays_under_conditions.snap | 14 +- ...actor__tests__template_interpolations.snap | 2 +- libs/extractor/src/style_values.rs | 94 +- libs/extractor/src/visit.rs | 365 ++++++-- 57 files changed, 9372 insertions(+), 238 deletions(-) create mode 100644 libs/extractor/src/bound_callback_alias_tests.rs create mode 100644 libs/extractor/src/bound_callback_captures.rs create mode 100644 libs/extractor/src/bound_callback_export_tests.rs create mode 100644 libs/extractor/src/bound_callback_numeric_tests.rs create mode 100644 libs/extractor/src/bound_style_callbacks.rs create mode 100644 libs/extractor/src/bound_style_callbacks_tests.rs create mode 100644 libs/extractor/src/capture_public_exports_w27.rs create mode 100644 libs/extractor/src/composition_coverage_tests.rs create mode 100644 libs/extractor/src/css_composition_rest_tests.rs create mode 100644 libs/extractor/src/css_utils/component_layer_coverage_tests.rs create mode 100644 libs/extractor/src/css_utils/component_layer_objects.rs create mode 100644 libs/extractor/src/css_utils/component_layer_policy_tests.rs create mode 100644 libs/extractor/src/css_utils/component_layer_tests.rs create mode 100644 libs/extractor/src/css_utils/component_layers.rs create mode 100644 libs/extractor/src/css_utils/layer_blocks.rs create mode 100644 libs/extractor/src/css_utils/layer_names.rs create mode 100644 libs/extractor/src/extractor/styled_choice_test_support.rs create mode 100644 libs/extractor/src/extractor/styled_mixin_context.rs create mode 100644 libs/extractor/src/extractor/styled_rule_choices.rs create mode 100644 libs/extractor/src/extractor/styled_rule_parts.rs create mode 100644 libs/extractor/src/extractor/styled_template_composition.rs create mode 100644 libs/extractor/src/extractor/styled_template_parts.rs create mode 100644 libs/extractor/src/extractor/w27_coverage_tests.rs create mode 100644 libs/extractor/src/extractor/w27_props_rule_choices_regressions.rs create mode 100644 libs/extractor/src/extractor/w27_props_rule_choices_tests.rs create mode 100644 libs/extractor/src/extractor/w27_styled_choice_coverage.rs create mode 100644 libs/extractor/src/extractor/w27_styled_composition_coverage.rs create mode 100644 libs/extractor/src/extractor/w27_styled_context_coverage.rs create mode 100644 libs/extractor/src/extractor/w27_styled_known_mixins_regressions.rs create mode 100644 libs/extractor/src/extractor/w27_styled_known_mixins_tests.rs create mode 100644 libs/extractor/src/extractor/w27_styled_template_coverage.rs create mode 100644 libs/extractor/src/imported_styled_coverage_tests.rs create mode 100644 libs/extractor/src/local_capture_reads_w27.rs create mode 100644 libs/extractor/src/local_exact_constants_w27_tests.rs create mode 100644 libs/extractor/src/local_rule_shapes_w27.rs create mode 100644 libs/extractor/src/local_shape_captures_w27.rs create mode 100644 libs/extractor/src/local_shape_composition_w27_tests.rs create mode 100644 libs/extractor/src/local_shape_hygiene_w27_tests.rs create mode 100644 libs/extractor/src/numeric_context_w27_tests.rs diff --git a/libs/extractor/src/bound_callback_alias_tests.rs b/libs/extractor/src/bound_callback_alias_tests.rs new file mode 100644 index 000000000..ae69c9bc2 --- /dev/null +++ b/libs/extractor/src/bound_callback_alias_tests.rs @@ -0,0 +1,91 @@ +use std::collections::HashMap; + +use crate::{ExtractOption, ExtractStyleValue, ImportAlias, extract}; +use css::{class_map::reset_class_map, file_map::reset_file_map}; +use rstest::rstest; +use serial_test::serial; + +#[path = "bound_callback_export_tests.rs"] +mod exports; + +#[path = "bound_callback_numeric_tests.rs"] +mod numeric; + +#[rstest] +#[case("p => ({'@layer': {base: {color:p.color}}})", true)] +#[case("function(p) { return {'@layer': {base: {color:p.color}}}; }", true)] +#[case("p => ({'@layer': {inherit: {color:p.color}}})", false)] +#[case( + "function(p) { return {'@layer': {inherit: {color:p.color}}}; }", + false +)] +#[serial] +fn compiles_callback_layer_qa_sources( + #[case] callback: &str, + #[case] valid: bool, +) -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let source = format!( + "import styled from '@emotion/styled';\nconst rules = {callback}; export const Choice = styled.div(rules);" + ); + let option = ExtractOption { + import_aliases: HashMap::from([( + "@emotion/styled".into(), + ImportAlias::DefaultToNamed("styled".into()), + )]), + ..ExtractOption::default() + }; + // When + let result = extract("callback-layer.tsx", &source, option); + // Then + if valid { + let output = result?; + assert!(output.styles.iter().any(|style| matches!(style, ExtractStyleValue::Dynamic(style) if style.layer.as_deref() == Some("base")))); + assert!(output.code.contains("\"--"), "{}", output.code); + } else { + let Err(error) = result else { + panic!("expected invalid layer error"); + }; + let error = error.to_string(); + let line = source + .lines() + .nth(1) + .ok_or("missing callback source line")?; + let column = line.find("inherit").ok_or("missing inherit key")? + 1; + assert!( + error.starts_with(&format!("callback-layer.tsx:2:{column}:")), + "{error}" + ); + assert!(error.contains("@layer inherit"), "{error}"); + } + Ok(()) +} + +#[rstest] +#[case("import * as ui from '@devup-ui/react';", "ui.styled.div")] +#[case("import {styled as make} from '@devup-ui/react';", "make.div")] +#[serial] +fn resolves_semantic_style_imports( + #[case] import: &str, + #[case] callee: &str, +) -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let source = format!( + "{import} const rules = p => ({{color:p.color}}); export const C = {callee}(rules);" + ); + // When + let output = extract("callback.tsx", &source, ExtractOption::default())?; + // Then + assert!( + output + .styles + .iter() + .any(|style| matches!(style, ExtractStyleValue::Dynamic(_))), + "{output:?}" + ); + Ok(()) +} diff --git a/libs/extractor/src/bound_callback_captures.rs b/libs/extractor/src/bound_callback_captures.rs new file mode 100644 index 000000000..f9046203e --- /dev/null +++ b/libs/extractor/src/bound_callback_captures.rs @@ -0,0 +1,51 @@ +use oxc_ast::{ast::Expression, ast::IdentifierReference}; +use oxc_ast_visit::Visit; +use oxc_semantic::Scoping; +use oxc_span::{GetSpan, Span}; +use oxc_syntax::symbol::SymbolId; +use rustc_hash::FxHashSet; + +pub(super) fn collect( + expression: &Expression<'_>, + scoping: &Scoping, +) -> Option> { + struct Captures<'s> { + scoping: &'s Scoping, + span: Span, + symbols: FxHashSet, + safe: bool, + } + impl<'a> Visit<'a> for Captures<'_> { + fn visit_identifier_reference(&mut self, identifier: &IdentifierReference<'a>) { + match identifier + .reference_id + .get() + .and_then(|id| self.scoping.get_reference(id).symbol_id()) + { + Some(symbol) + if !self + .span + .contains_inclusive(self.scoping.symbol_span(symbol)) => + { + self.symbols.insert(symbol); + } + None if super::local_capture_reads::generated_name(&identifier.name) + || self.scoping.symbol_ids().any(|symbol| { + self.scoping.symbol_name(symbol) == identifier.name.as_str() + }) => + { + self.safe = false; + } + _ => {} + } + } + } + let mut collector = Captures { + scoping, + span: expression.span(), + symbols: FxHashSet::default(), + safe: true, + }; + collector.visit_expression(expression); + collector.safe.then_some(collector.symbols) +} diff --git a/libs/extractor/src/bound_callback_export_tests.rs b/libs/extractor/src/bound_callback_export_tests.rs new file mode 100644 index 000000000..3be224972 --- /dev/null +++ b/libs/extractor/src/bound_callback_export_tests.rs @@ -0,0 +1,206 @@ +use super::*; +use oxc_allocator::{Allocator, CloneIn, TakeIn}; +use oxc_ast::ast::{Declaration, Expression, JSXAttributeValue, Statement}; +use oxc_ast_visit::{VisitMut, walk_mut}; +use oxc_span::SourceType; + +fn declared_names(declaration: &Declaration<'_>) -> Vec { + match declaration { + Declaration::VariableDeclaration(variable) => variable + .declarations + .iter() + .flat_map(|declarator| declarator.id.get_binding_identifiers()) + .map(|binding| binding.name.to_string()) + .collect(), + declaration => declaration + .id() + .iter() + .map(|id| id.name.to_string()) + .collect(), + } +} + +#[rstest] +#[case("export const color = 'red';", "color", "color")] +#[case( + "export function getColor() { return 'red'; }", + "getColor()", + "getColor" +)] +#[case("export let color = 'red';", "color", "color")] +#[case("export var color = 'red';", "color", "color")] +#[case("export const color = 'red', other = 'blue';", "color", "color,other")] +#[case("const color = 'red'; export {color};", "color", "color")] +#[case( + "const color = 'red'; export {color as publicColor};", + "color", + "publicColor" +)] +#[case( + "export default function getColor() { return 'red'; }", + "getColor()", + "default" +)] +#[case("export const color = 'red';", "p.color", "color")] +#[serial] +fn public_names_survive_when_callback_captures_exports( + #[case] declaration: &str, + #[case] value: &str, + #[case] names: &str, +) -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let source = format!( + "import {{styled}} from '@devup-ui/react'; {declaration} const rules = p => ({{color:{value}}}); export const Choice = styled.div(rules);" + ); + // When + let output = extract("export-capture.tsx", &source, ExtractOption::default())?; + // Then + let allocator = Allocator::default(); + let parsed = oxc_parser::Parser::new(&allocator, &output.code, SourceType::tsx()).parse(); + assert_eq!(parsed.diagnostics.len(), 0); + let mut exports = Vec::new(); + for statement in &parsed.program.body { + match statement { + Statement::ExportDeclaration(export) => { + exports.extend(declared_names(&export.declaration)); + } + Statement::ExportNamedDeclaration(export) => exports.extend( + export + .specifiers + .iter() + .map(|specifier| specifier.exported.name().to_string()), + ), + Statement::ExportDefaultDeclaration(_) => exports.push("default".into()), + _ => {} + } + } + exports.sort(); + let mut expected: Vec<_> = names.split(',').chain(["Choice"]).collect(); + expected.sort_unstable(); + assert_eq!(exports, expected, "{}", output.code); + Ok(()) +} + +/// Observe the generated element's style values using the same JSX-to-Boa +/// adapter as the capture hygiene tests, with getters for the parsed exports. +fn consumer_values(code: &str) -> Result> { + struct RenderStyle<'a>(&'a Allocator); + impl<'a> VisitMut<'a> for RenderStyle<'a> { + fn visit_expression(&mut self, expression: &mut Expression<'a>) { + if let Expression::JSXElement(element) = expression { + let value = element + .opening_element + .attributes + .iter() + .find_map(|attribute| { + let attribute = attribute.as_attribute()?; + attribute + .name + .as_identifier() + .filter(|name| name.name == "style")?; + match attribute.value.as_ref()? { + JSXAttributeValue::ExpressionContainer(container) => { + container.expression.as_expression() + } + JSXAttributeValue::StringLiteral(_) + | JSXAttributeValue::Element(_) + | JSXAttributeValue::Fragment(_) => None, + } + }); + assert!(value.is_some(), "expected generated style object"); + if let Some(value) = value { + *expression = value.clone_in(self.0); + } + return; + } + walk_mut::walk_expression(self, expression); + } + } + let allocator = Allocator::default(); + let mut parsed = oxc_parser::Parser::new(&allocator, code, SourceType::tsx()).parse(); + assert_eq!(parsed.diagnostics.len(), 0); + RenderStyle(&allocator).visit_program(&mut parsed.program); + let mut getters = Vec::new(); + let mut body = oxc_allocator::Vec::new_in(&&allocator); + for statement in parsed.program.body { + match statement { + Statement::ImportDeclaration(_) => {} + Statement::ExportDeclaration(mut export) => { + getters.extend( + declared_names(&export.declaration) + .iter() + .map(|name| format!("get {name}() {{ return {name}; }}")), + ); + body.push(Statement::from(export.declaration.take_in(&&allocator))); + } + Statement::ExportNamedDeclaration(export) => { + getters.extend(export.specifiers.iter().map(|specifier| { + format!( + "get {}() {{ return {}; }}", + specifier.exported.name(), + specifier.local.name() + ) + })); + } + statement => body.push(statement), + } + } + parsed.program.body = body; + let compiled = oxc_codegen::Codegen::new().build(&parsed.program).code; + let script = format!( + "const __devupForwardRef = fn => props => fn(props, undefined); {compiled}\nconst consumer = {{{}}}; const before = Object.values(consumer.Choice({{}})); consumer.update(); JSON.stringify([consumer.color, before, Object.values(consumer.Choice({{}}))]);", + getters.join(",") + ); + let json = boa_engine::Context::default() + .eval(boa_engine::Source::from_bytes(&script)) + .map_err(|error| format!("consumer evaluation failed: {error}\n{script}"))? + .as_string() + .ok_or("expected JSON")? + .to_std_string_escaped(); + Ok(serde_json::from_str(&json)?) +} + +#[rstest] +#[case("
")] +#[case("
")] +#[case("
")] +#[case("
")] +#[case("
")] +fn consumer_oracle_when_generated_style_is_missing_rejects(#[case] jsx: &str) { + // Given + let code = format!("const Choice = () => {jsx};"); + // When + let result = std::panic::catch_unwind(|| consumer_values(&code)); + // Then + assert!(result.is_err(), "missing style must fail before evaluation"); +} + +#[rstest] +#[case("export let color = 'red';", "color")] +#[case("let color = 'red'; export {color};", "color")] +#[case( + "export let color = 'red'; function getColor() { return color; }", + "getColor()" +)] +#[serial] +fn consumer_and_rendered_values_stay_live_when_user_scopes_shadow_capture( + #[case] declaration: &str, + #[case] value: &str, +) -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let source = format!( + "import {{styled}} from '@devup-ui/react'; {declaration} export function update() {{ color = 'blue'; }} const rules = p => ({{color:{value}}}); function make(color) {{ return styled.div(rules); }} export const Choice = make('green');" + ); + // When + let output = extract("export-live.tsx", &source, ExtractOption::default())?; + // Then + assert_eq!( + consumer_values(&output.code)?, + serde_json::json!(["blue", ["red"], ["blue"]]) + ); + Ok(()) +} diff --git a/libs/extractor/src/bound_callback_numeric_tests.rs b/libs/extractor/src/bound_callback_numeric_tests.rs new file mode 100644 index 000000000..169e833f1 --- /dev/null +++ b/libs/extractor/src/bound_callback_numeric_tests.rs @@ -0,0 +1,258 @@ +use super::*; +use crate::extract_style::extract_static_style::ExtractStaticStyle; + +fn option() -> ExtractOption { + ExtractOption { + import_aliases: HashMap::from([ + ( + "@emotion/styled".into(), + ImportAlias::DefaultToNamed("styled".into()), + ), + ( + "styled-components".into(), + ImportAlias::DefaultToNamed("styled".into()), + ), + ]), + ..ExtractOption::default() + } +} + +#[rstest] +#[case(("import styled from '@emotion/styled';", "3px"))] +#[case(("import styled from 'styled-components';", "3px"))] +#[case(("import {styled} from '@devup-ui/react';", "12px"))] +#[serial] +fn literal_units_match_inline_when_callbacks_are_bound( + #[case] library: (&str, &str), + #[values(false, true)] bound: bool, + #[values(false, true)] function: bool, +) -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let (import, padding) = library; + let rules = "{padding:3,p:4,lineHeight:2,margin:0,vars:{'--raw':5},_hover:{padding:3},'@layer':{base:{padding:3}}}"; + let callback = if function { + format!("function(p) {{ return {rules}; }}") + } else { + format!("p => ({rules})") + }; + let site = if bound { + format!("const rules = {callback}; export const Choice = styled.div(rules);") + } else { + format!("export const Choice = styled.div({callback});") + }; + // When + let output = extract("callback-units.tsx", &format!("{import} {site}"), option())?; + // Then + let values: Vec<_> = output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Static(style) => { + Some((style.property.as_str(), style.value.as_str())) + } + _ => None, + }) + .collect(); + assert_eq!( + values + .iter() + .filter(|(key, value)| *key == "padding" && *value == padding) + .count(), + 3, + "{output:?}" + ); + assert!(values.contains(&("padding", "16px")), "{output:?}"); + assert!(values.contains(&("line-height", "2")), "{output:?}"); + assert!(values.contains(&("margin", "0")), "{output:?}"); + assert!(values.contains(&("--raw", "5")), "{output:?}"); + Ok(()) +} + +#[rstest] +#[case(("import styled from '@emotion/styled';", "3px", "4px"))] +#[case(("import styled from 'styled-components';", "3px", "4px"))] +#[case(("import {styled} from '@devup-ui/react';", "12px", "16px"))] +#[serial] +fn selected_numeric_rules_when_callbacks_short_circuit_keep_library_units( + #[case] library: (&str, &str, &str), + #[values("||", "??", "&&", "condition")] choice: &str, + #[values(false, true)] bound: bool, +) -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let (import, left_padding, right_padding) = library; + let left = "({padding:3,lineHeight:2,margin:0,vars:{'--raw':5}})"; + let right = "{padding:4,lineHeight:6,margin:0,vars:{'--raw':9}}"; + let rules = if choice == "condition" { + format!("p.input.padding === 3 && {right}") + } else { + format!("{left} {choice} {right}") + }; + let callback = format!("p => ({rules})"); + let site = if bound { + format!("const rules={callback};export const Choice=styled.div(rules);") + } else { + format!("export const Choice=styled.div({callback});") + }; + // When + let output = extract( + "callback-logical-units.tsx", + &format!("{import}{site}"), + ExtractOption { + single_css: true, + ..option() + }, + )?; + // Then: evaluate the generated class selection, not just the emitted atoms. + let classes = output + .code + .split_once("className={") + .ok_or("generated className")? + .1 + .split_once("} style=") + .ok_or("generated style attribute")? + .0; + let atoms = output + .styles + .iter() + .map(|style| { + style + .extract(None) + .ok_or("logical numeric atom has a class identity") + .map(|class| (class.to_string(), style)) + }) + .collect::, _>>()?; + for input in [3, 4] { + let script = + format!("const rest={{input:{{padding:{input}}}}};const className='';({classes});"); + let mut context = boa_engine::Context::default(); + let names = context + .eval(boa_engine::Source::from_bytes(&script))? + .to_string(&mut context)? + .to_std_string_escaped(); + let mut selected: Vec<_> = atoms + .iter() + .filter(|(class, _)| names.split_whitespace().any(|name| name == class)) + .map(|(_, style)| (*style).clone()) + .collect(); + selected.sort_unstable(); + let declarations = if choice == "condition" && input == 4 { + vec![] + } else if choice == "&&" || choice == "condition" { + vec![ + ("--raw", "9"), + ("line-height", "6"), + ("margin", "0"), + ("padding", right_padding), + ] + } else { + vec![ + ("--raw", "5"), + ("line-height", "2"), + ("margin", "0"), + ("padding", left_padding), + ] + }; + let mut expected: Vec<_> = declarations + .into_iter() + .map(|(property, value)| { + ExtractStyleValue::Static(ExtractStaticStyle::new(property, value, 0, None)) + }) + .collect(); + expected.sort_unstable(); + assert_eq!( + selected, expected, + "{choice}, input={input}: {}", + output.code + ); + } + Ok(()) +} + +#[rstest] +#[case(("native.div(rules)", "emotion.div(rules)"))] +#[case(("emotion.div(rules)", "native.div(rules)"))] +#[serial] +fn use_site_units_stay_independent_when_one_callback_serves_both_libraries( + #[case] sites: (&str, &str), + #[values("p => ({ padding: 3 })", "function(p) {return { padding: 3 };}")] callback: &str, +) -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let (first, last) = sites; + let source = format!( + "import emotion from '@emotion/styled'; import {{styled as native}} from '@devup-ui/react'; const rules={callback}; export const First={first}; export const Last={last};" + ); + // When + let output = extract("callback-mixed.tsx", &source, option())?; + // Then + let mut paddings: Vec<_> = output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Static(style) if style.property == "padding" => { + Some(style.value.as_str()) + } + _ => None, + }) + .collect(); + paddings.sort_unstable(); + assert_eq!(paddings, vec!["12px", "3px"], "{output:?}"); + Ok(()) +} + +#[rstest] +#[case("import styled from '@emotion/styled';")] +#[case("import {styled} from '@devup-ui/react';")] +#[serial] +fn runtime_values_and_call_inputs_stay_ordinary_when_callback_is_bound( + #[case] import: &str, + #[values("3", "0", "'3'", "'auto'")] input: &str, + #[values(false, true)] bound: bool, +) -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let callback = "({lineHeight,raw,input={ size: 3, nested: { size: 4 } }}) => ({padding:consume(input,{ size: 3, nested: { size: 4 } }),lineHeight,vars:{'--raw':raw}})"; + let site = if bound { + format!("const rules={callback};export const Choice=styled.div(rules);") + } else { + format!("export const Choice=styled.div({callback});") + }; + // When + let output = extract("callback-values.tsx", &format!("{import}{site}"), option())?; + // Then + let variables = output + .code + .split_once("style={") + .ok_or("missing style")? + .1 + .split_once("} />") + .ok_or("missing JSX end")? + .0; + let script = format!( + "const seen=[]; const rest={{lineHeight:2,raw:7}};const style={{}};function consume(data,explicit){{seen.push(data,explicit);return {input};}}const variables={variables};JSON.stringify([seen,Object.values(variables)]);" + ); + let json = boa_engine::Context::default() + .eval(boa_engine::Source::from_bytes(&script)) + .map_err(|error| format!("generated callback failed: {error}\n{script}"))? + .as_string() + .ok_or("expected JSON")? + .to_std_string_escaped(); + let padding = match input { + "3" => serde_json::json!("3px"), + "0" => serde_json::json!(0), + "'3'" => serde_json::json!("3"), + "'auto'" => serde_json::json!("auto"), + _ => unreachable!(), + }; + assert_eq!( + serde_json::from_str::(&json)?, + serde_json::json!([[{"size":3,"nested":{"size":4}},{"size":3,"nested":{"size":4}}], [padding,2,7]]) + ); + Ok(()) +} diff --git a/libs/extractor/src/bound_style_callbacks.rs b/libs/extractor/src/bound_style_callbacks.rs new file mode 100644 index 000000000..c41645ba0 --- /dev/null +++ b/libs/extractor/src/bound_style_callbacks.rs @@ -0,0 +1,255 @@ +use oxc_allocator::{Address, CloneIn, GetAddress, GetAllocator}; +use oxc_ast::{ + ast::{ + BindingPattern, CallExpression, Declaration, ExportDeclaration, Expression, + ImportDeclaration, ImportDeclarationSpecifier, Program, TaggedTemplateExpression, + VariableDeclaration, VariableDeclarationKind, + }, + builder::AstBuilder, +}; +use oxc_ast_visit::{Visit, VisitMut, walk, walk_mut}; +use oxc_semantic::Scoping; +use oxc_span::{GetSpan, Span}; +use oxc_syntax::{reference::ReferenceId, symbol::SymbolId}; +use rustc_hash::{FxHashMap, FxHashSet}; + +struct Callback<'a> { + expression: Expression<'a>, + constant: bool, +} + +pub(super) struct Rewritten { + pub captures: FxHashSet, + pub errors: Vec<(u32, String)>, + pub sites: Vec<(Span, Address)>, +} + +struct Sites<'s, 'a> { + builder: &'s AstBuilder<'a>, + scoping: &'s Scoping, + styled: FxHashSet, + namespaces: FxHashSet, + package: &'s str, + callbacks: FxHashMap>, + reads: FxHashMap>, +} + +impl<'a> Sites<'_, 'a> { + fn root(&self, expression: &Expression<'a>) -> bool { + match crate::utils::unwrap_syntax_only(expression) { + Expression::Identifier(identifier) => identifier + .reference_id + .get() + .and_then(|id| self.scoping.get_reference(id).symbol_id()) + .is_some_and(|symbol| self.styled.contains(&symbol)), + Expression::StaticMemberExpression(member) => { + self.root(&member.object) + || (member.property.name == "styled" + && matches!(&member.object, Expression::Identifier(identifier) + if identifier.reference_id.get() + .and_then(|id| self.scoping.get_reference(id).symbol_id()) + .is_some_and(|symbol| self.namespaces.contains(&symbol)))) + } + Expression::CallExpression(call) => self.root(&call.callee), + _ => false, + } + } + + fn read(&mut self, expression: &Expression<'a>) { + if let Expression::Identifier(identifier) = crate::utils::unwrap_syntax_only(expression) + && let Some(reference) = identifier.reference_id.get() + && let Some(symbol) = self.scoping.get_reference(reference).symbol_id() + { + self.reads + .entry(symbol) + .or_default() + .push((reference, identifier.span.start)); + } + } +} + +impl<'a> Visit<'a> for Sites<'_, 'a> { + fn visit_import_declaration(&mut self, import: &ImportDeclaration<'a>) { + if !import.source.value.starts_with(self.package) { + return; + } + for specifier in import.specifiers.iter().flatten() { + if let Some(symbol) = specifier.local().symbol_id.get() { + match specifier { + ImportDeclarationSpecifier::ImportSpecifier(specifier) + if specifier.imported.name() == "styled" => + { + self.styled.insert(symbol); + } + ImportDeclarationSpecifier::ImportNamespaceSpecifier(_) + | ImportDeclarationSpecifier::ImportDefaultSpecifier(_) => { + self.namespaces.insert(symbol); + } + ImportDeclarationSpecifier::ImportSpecifier(_) => {} + } + } + } + } + + fn visit_variable_declaration(&mut self, declaration: &VariableDeclaration<'a>) { + for declarator in &declaration.declarations { + if let BindingPattern::BindingIdentifier(binding) = &declarator.id + && let Some(init) = &declarator.init + && let Some(symbol) = binding.symbol_id.get() + && matches!( + crate::utils::unwrap_syntax_only(init), + Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_) + ) + { + self.callbacks.insert( + symbol, + Callback { + expression: crate::utils::unwrap_syntax_only(init) + .clone_in_with_semantic_ids(self.builder.allocator()), + constant: declaration.kind == VariableDeclarationKind::Const, + }, + ); + } + } + walk::walk_variable_declaration(self, declaration); + } + + fn visit_export_declaration(&mut self, export: &ExportDeclaration<'a>) { + walk::walk_export_declaration(self, export); + if let Declaration::VariableDeclaration(declaration) = &export.declaration { + for declarator in &declaration.declarations { + if let BindingPattern::BindingIdentifier(binding) = &declarator.id + && let Some(callback) = binding + .symbol_id + .get() + .and_then(|symbol| self.callbacks.get_mut(&symbol)) + { + callback.constant = false; + } + } + } + } + + fn visit_call_expression(&mut self, call: &CallExpression<'a>) { + if self.root(&call.callee) { + let start = match crate::utils::unwrap_syntax_only(&call.callee) { + Expression::Identifier(_) => Some(1), + Expression::StaticMemberExpression(member) + if matches!(member.property.name.as_str(), "attrs" | "withConfig") => + { + None + } + _ => Some(0), + }; + if let Some(start) = start { + for argument in call.arguments.iter().skip(start) { + if let Some(expression) = argument.as_expression() { + self.read(expression); + } + } + } + } + walk::walk_call_expression(self, call); + } + + fn visit_tagged_template_expression(&mut self, tagged: &TaggedTemplateExpression<'a>) { + if self.root(&tagged.tag) { + for expression in &tagged.quasi.expressions { + self.read(expression); + } + } + walk::walk_tagged_template_expression(self, tagged); + } +} + +/// Clone read-only callbacks only at styled rule sites; preserve semantic IDs +/// so captures can be renamed before lowering turns references into source. +pub(super) fn rewrite<'a>( + builder: &AstBuilder<'a>, + program: &mut Program<'a>, + scoping: &Scoping, + package: &str, +) -> Rewritten { + let mut sites = Sites { + builder, + scoping, + package, + styled: FxHashSet::default(), + namespaces: FxHashSet::default(), + callbacks: FxHashMap::default(), + reads: FxHashMap::default(), + }; + sites.visit_program(program); + let deferred = super::local_capture_reads::deferred_reads(program, scoping); + let mut replacements = FxHashMap::default(); + let mut captures = FxHashSet::default(); + let mut errors = Vec::new(); + for (symbol, callback) in &sites.callbacks { + let Some(reads) = sites.reads.get(symbol) else { + continue; + }; + let references = scoping.get_resolved_reference_ids(*symbol); + let safe = callback.constant + && references.len() == reads.len() + && references + .iter() + .all(|id| !scoping.get_reference(*id).is_write()) + && reads.iter().all(|(id, start)| { + deferred.contains(id) || *start >= callback.expression.span().end + }); + if !safe { + errors.push(( + scoping.symbol_span(*symbol).start, + crate::utils::local_style_error(scoping.symbol_name(*symbol)), + )); + continue; + } + let Some(captured) = super::bound_callback_captures::collect(&callback.expression, scoping) + else { + errors.push(( + scoping.symbol_span(*symbol).start, + crate::utils::local_style_error(scoping.symbol_name(*symbol)), + )); + continue; + }; + captures.extend(captured); + for (reference, _) in reads { + replacements.insert(*reference, &callback.expression); + } + } + let mut rewrite = Rewrite { + builder, + replacements, + sites: Vec::new(), + }; + rewrite.visit_program(program); + errors.sort_unstable_by_key(|(start, _)| *start); + Rewritten { + captures, + errors, + sites: rewrite.sites, + } +} + +struct Rewrite<'s, 'a> { + builder: &'s AstBuilder<'a>, + replacements: FxHashMap>, + sites: Vec<(Span, Address)>, +} + +impl<'a> VisitMut<'a> for Rewrite<'_, 'a> { + fn visit_expression(&mut self, expression: &mut Expression<'a>) { + if let Expression::Identifier(identifier) = expression + && let Some(callback) = identifier + .reference_id + .get() + .and_then(|id| self.replacements.get(&id)) + { + let span = identifier.span; + *expression = callback.clone_in_with_semantic_ids(self.builder.allocator()); + self.sites.push((span, expression.address())); + return; + } + walk_mut::walk_expression(self, expression); + } +} diff --git a/libs/extractor/src/bound_style_callbacks_tests.rs b/libs/extractor/src/bound_style_callbacks_tests.rs new file mode 100644 index 000000000..6cd959096 --- /dev/null +++ b/libs/extractor/src/bound_style_callbacks_tests.rs @@ -0,0 +1,248 @@ +use crate::{ExtractOption, ExtractStyleValue, extract}; +use css::{class_map::reset_class_map, file_map::reset_file_map}; +use rstest::rstest; +use serial_test::serial; + +fn compile(source: &str) -> Result> { + extract("callback.tsx", source, ExtractOption::default()) +} + +#[rstest] +#[case("p => ({'@layer': {base: {color: p.color}}})", false)] +#[case("function(p) { return {'@layer': {base: {color: p.color}}}; }", false)] +#[case("p => ({'@layer': {base: {color: p.color}}})", true)] +#[case("function(p) { return {'@layer': {base: {color: p.color}}}; }", true)] +#[case("({color = 'red'}) => ({'@layer': {base: {color}}})", true)] +#[case("(p = {}) => ({'@layer': {base: {color: p.color}}})", false)] +#[serial] +fn compiles_bound_callback_like_inline( + #[case] callback: &str, + #[case] local: bool, +) -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let body = format!("const rules = {callback}; return styled.div(rules);"); + let body = if local { + format!("export function make() {{ {body} }}") + } else { + format!("const rules = {callback}; export const C = styled.div(rules);") + }; + let source = format!("import {{styled}} from '@devup-ui/react';\n{body}"); + // When + let output = compile(&source)?; + // Then + assert!( + output + .styles + .iter() + .any(|style| matches!(style, ExtractStyleValue::Dynamic(style) if style.layer.as_deref() == Some("base"))), + "{output:?}" + ); + assert!(output.code.contains("\"--"), "{}", output.code); + assert!( + !output.code.contains("styled.div(rules)"), + "{}", + output.code + ); + Ok(()) +} + +#[rstest] +#[case("p => ({'@layer': {inherit: {color: p.color}}})")] +#[case("function(p) { return {'@layer': {inherit: {color: p.color}}}; }")] +#[serial] +fn reports_original_invalid_layer_key( + #[case] callback: &str, +) -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let source = format!( + "import {{styled}} from '@devup-ui/react';\nconst rules = {callback};\nexport const C = styled.div(rules);" + ); + // When + let error = compile(&source) + .err() + .ok_or("expected invalid layer error")? + .to_string(); + // Then + assert!(error.starts_with("callback.tsx:2:"), "{error}"); + assert!(error.contains("@layer inherit"), "{error}"); + Ok(()) +} + +#[rstest] +#[case("let rules = p => ({color:p.color}); export const C = styled.div(rules);")] +#[case( + "const rules = p => ({color:p.color}); rules = p => ({color:'red'}); export const C = styled.div(rules);" +)] +#[case("const rules = p => ({color:p.color}); consume(rules); export const C = styled.div(rules);")] +#[case("export const C = styled.div(rules); const rules = p => ({color:p.color});")] +#[case("const rules = p => unknown(p); export const C = styled.div(rules);")] +#[case("const rules = async p => ({color:p.color}); export const C = styled.div(rules);")] +#[case("export const rules = p => ({color:p.color}); export const C = styled.div(rules);")] +#[case( + "export function make() { const rules = p => { const x = p.color; return { color:x}; }; return styled.div(rules); }" +)] +#[case("const rules = p => ({ color:rest}); export const C = styled.div(rules);")] +#[case( + "const rules = p => ({ color:missing}); export function make(missing) { return styled.div(rules); }" +)] +#[serial] +fn rejects_unsafe_bound_callbacks(#[case] body: &str) -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let source = format!("import {{styled}} from '@devup-ui/react';\n{body}"); + // When + let error = compile(&source) + .err() + .ok_or("expected unsafe callback error")? + .to_string(); + // Then + assert!(error.starts_with("callback.tsx:2:"), "{error}"); + Ok(()) +} + +#[test] +#[serial] +fn preserves_application_arguments_inside_callback() -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let source = "import {styled} from '@devup-ui/react'; const rules = p => ({color: consume(p.color)}); export const C = styled.div(rules);"; + // When + let output = compile(source)?; + // Then + assert!(output.code.contains("consume(p.color)"), "{}", output.code); + assert!( + output + .styles + .iter() + .any(|style| matches!(style, ExtractStyleValue::Dynamic(_))) + ); + Ok(()) +} + +#[rstest] +#[case( + "const rules = p => ({color:p.color}); export function make() { const rules = p => ({color:'blue'}); return styled.div(rules); }", + "blue" +)] +#[serial] +fn resolves_lexical_constants( + #[case] body: &str, + #[case] expected: &str, +) -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let source = format!("import {{styled}} from '@devup-ui/react'; {body}"); + // When + let output = compile(&source)?; + // Then + assert!(output.styles.iter().any(|style| matches!(style, ExtractStyleValue::Static(style) if style.property == "color" && style.value == expected)), "{output:?}"); + Ok(()) +} + +#[rstest] +#[case("rest")] +#[case("style")] +#[case("__devupValue")] +#[case("color")] +#[serial] +fn preserves_capture_across_generated_and_user_shadowing( + #[case] name: &str, +) -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let source = format!( + "import {{styled}} from '@devup-ui/react'; export function outer({name}) {{ const rules = p => ({{color:{name}}}); return function inner({name}) {{ return styled.div(rules); }}; }}" + ); + // When + let output = compile(&source)?; + // Then + assert!( + output + .code + .contains(&format!("function outer(__devup_{name}")), + "{}", + output.code + ); + assert!( + output.code.contains(&format!("function inner({name})")), + "{}", + output.code + ); + assert!(output.styles.iter().any(|style| matches!(style, ExtractStyleValue::Dynamic(style) if style.identifier().contains(&format!("__devup_{name}"))))); + Ok(()) +} + +#[test] +#[serial] +fn permits_deferred_outer_read_before_its_declaration() -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let source = "import {styled} from '@devup-ui/react'; export function make() { const rules = p => ({ color:later}); const later = getColor(); return styled.div(rules); }"; + // When + let output = compile(source)?; + // Then + assert!(output.styles.iter().any(|style| matches!(style, ExtractStyleValue::Dynamic(style) if style.identifier().contains("__devup_later"))), "{output:?}"); + Ok(()) +} + +#[rstest] +#[case("export const C = styled.div` ${rules} `;")] +#[case("export const C = styled('div')(rules);")] +#[serial] +fn resolves_callback_at_styled_rule_sites( + #[case] use_site: &str, +) -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let source = format!( + "import {{styled}} from '@devup-ui/react'; const rules = p => ({{color:p.color}}); {use_site}" + ); + // When + let output = compile(&source)?; + // Then + assert!( + output + .styles + .iter() + .any(|style| matches!(style, ExtractStyleValue::Dynamic(_))), + "{output:?}" + ); + Ok(()) +} + +#[test] +#[serial] +fn leaves_ordinary_function_props_and_application_data_unchanged() +-> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let source = "import {styled, Box} from '@devup-ui/react'; const rules = p => ({color:p.color}); export const data = consume(rules); export const button = ; export const C = styled.div({color:p => consume({width:2}, p.color)});"; + // When + let output = compile(source)?; + // Then + assert!(output.code.contains("consume(rules)"), "{}", output.code); + assert!( + output.code.contains(&format!("onClick={{{}}}", "rules")), + "{}", + output.code + ); + assert!( + output + .code + .contains(&format!("consume({{width:{}}},p.color)", 2)), + "{}", + output.code + ); + Ok(()) +} diff --git a/libs/extractor/src/capture_public_exports_w27.rs b/libs/extractor/src/capture_public_exports_w27.rs new file mode 100644 index 000000000..f228e3324 --- /dev/null +++ b/libs/extractor/src/capture_public_exports_w27.rs @@ -0,0 +1,67 @@ +use oxc_allocator::{GetAllocator, TakeIn, Vec}; +use oxc_ast::ast::{ + BindingIdentifier, Declaration, ExportSpecifier, IdentifierName, IdentifierReference, + ImportOrExportKind, ModuleExportName, Program, Statement, +}; + +/// Separate renamed declarations from their public names with live export +/// specifiers, moving the original AST to retain semantic IDs and source spans. +pub(super) fn preserve<'a>(program: &mut Program<'a>, rename: &super::Rename<'_, 'a>) { + let mut body = Vec::with_capacity_in(program.body.len(), rename.builder); + for statement in program.body.take_in(rename.builder) { + let Statement::ExportDeclaration(mut export) = statement else { + body.push(statement); + continue; + }; + let bindings: std::vec::Vec<&BindingIdentifier<'a>> = match &export.declaration { + Declaration::VariableDeclaration(variable) => variable + .declarations + .iter() + .flat_map(|declarator| declarator.id.get_binding_identifiers()) + .collect(), + declaration => declaration.id().into_iter().collect(), + }; + if !bindings.iter().any(|binding| { + binding + .symbol_id + .get() + .is_some_and(|symbol| rename.aliases.contains_key(&symbol)) + }) { + body.push(Statement::ExportDeclaration(export)); + continue; + } + let specifiers = Vec::from_iter_in( + bindings.iter().map(|binding| { + let local = binding + .symbol_id + .get() + .and_then(|symbol| rename.aliases.get(&symbol)) + .map_or(binding.name.as_str(), String::as_str); + ExportSpecifier::new( + binding.span, + ModuleExportName::IdentifierReference(IdentifierReference::new( + binding.span, + rename.builder.allocator().alloc_str(local), + rename.builder, + )), + ModuleExportName::IdentifierName(IdentifierName::new( + binding.span, + binding.name, + rename.builder, + )), + ImportOrExportKind::Value, + rename.builder, + ) + }), + rename.builder, + ); + body.push(Statement::from(export.declaration.take_in(rename.builder))); + body.push(Statement::new_export_named_declaration( + export.span, + specifiers, + ImportOrExportKind::Value, + rename.builder, + )); + } + program.body = body; +} diff --git a/libs/extractor/src/composition_coverage_tests.rs b/libs/extractor/src/composition_coverage_tests.rs new file mode 100644 index 000000000..7881e57fb --- /dev/null +++ b/libs/extractor/src/composition_coverage_tests.rs @@ -0,0 +1,172 @@ +use super::{DevupVisitor, KnownPart, Text}; +use crate::ExtractStyleValue; +use crate::extract_style::extract_static_style::ExtractStaticStyle; +use crate::style_values::{StyleValue, StyleValues}; +use oxc_allocator::Allocator; +use oxc_ast::ast::Expression; +use oxc_parser::Parser; +use oxc_semantic::SemanticBuilder; +use oxc_span::SourceType; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case("opaque", false)] +#[case("external", true)] +#[serial] +fn template_composition_when_only_opaque_classes_remain_preserves_their_form( + #[case] name: &str, + #[case] literal: bool, +) -> Result<(), Box> { + // Given + let allocator = Allocator::default(); + let source = format!("css`${{known}}${{{name}}}`"); + let mut visitor = + DevupVisitor::new(&allocator, "coverage.tsx", "@devup-ui/react", vec![], None); + let combined = format!("const known = ''; const external = ''; {source};"); + let parsed = Parser::new(&allocator, &combined, SourceType::tsx()).parse(); + let scoping = SemanticBuilder::new() + .build(&parsed.program) + .semantic + .into_scoping(); + visitor.style_values = StyleValues::new(scoping); + let known = visitor + .style_values + .root_symbol("known") + .ok_or("missing known binding")?; + let external = visitor + .style_values + .root_symbol("external") + .ok_or("missing external binding")?; + visitor + .style_values + .insert(known, StyleValue::Class(String::new(), Some(vec![]))); + visitor + .style_values + .insert(external, StyleValue::Class("external".into(), None)); + let oxc_ast::ast::Statement::ExpressionStatement(statement) = + parsed.program.body.last().ok_or("missing expression")? + else { + return Err("expected expression fixture".into()); + }; + let Expression::TaggedTemplateExpression(tag) = &statement.expression else { + return Err("expected tag".into()); + }; + // When + let result = visitor + .compose_template(tag, false) + .ok_or("composition was not recognized")?; + // Then + assert_eq!(matches!(result, Expression::StringLiteral(_)), literal); + assert_eq!( + crate::utils::readable_code(&result), + if literal { "\"external\"" } else { "opaque" } + ); + assert_eq!(visitor.styles.len(), 0); + Ok(()) +} + +#[rstest] +#[case("css(known, { color: 'blue' })")] +#[case("css(flag ? known : null, { color: 'blue' })")] +#[serial] +fn known_call_when_later_rules_replace_all_choices_records_exact_inline_metadata( + #[case] call: &str, +) -> Result<(), Box> { + // Given + let allocator = Allocator::default(); + let source = format!("const known = ''; {call};"); + let parsed = Parser::new(&allocator, &source, SourceType::tsx()).parse(); + let scoping = SemanticBuilder::new() + .build(&parsed.program) + .semantic + .into_scoping(); + let known = scoping + .get_root_binding("known".into()) + .ok_or("missing known binding")?; + let mut visitor = + DevupVisitor::new(&allocator, "coverage.tsx", "@devup-ui/react", vec![], None); + visitor.style_values = StyleValues::new(scoping); + visitor.style_values.insert( + known, + StyleValue::Class( + "known".into(), + Some(vec![ExtractStyleValue::Static(ExtractStaticStyle::new( + "color", "red", 0, None, + ))]), + ), + ); + let oxc_ast::ast::Statement::ExpressionStatement(statement) = + parsed.program.body.last().ok_or("missing expression")? + else { + return Err("expected call fixture".into()); + }; + let Expression::CallExpression(call) = &statement.expression else { + return Err("expected call".into()); + }; + // When + let result = visitor + .compose_known_styles(call, false) + .ok_or("known call was unreadable")?; + // Then + let expected = vec![ExtractStyleValue::Static(ExtractStaticStyle::new( + "color", "blue", 0, None, + ))]; + assert_eq!( + visitor.css_styles, + Some((call.span.start, expected.clone())) + ); + assert_eq!( + visitor.inline_css_styles.get(&call.span.start), + Some(&expected) + ); + assert!( + matches!(result, Expression::StringLiteral(literal) if literal.span.start == call.span.start) + ); + Ok(()) +} + +#[test] +#[serial] +fn known_array_when_it_contains_holes_skips_them_without_losing_metadata() +-> Result<(), Box> { + // Given + let allocator = Allocator::default(); + let parsed = Parser::new( + &allocator, + "const known = ''; [, known];", + SourceType::tsx(), + ) + .parse(); + let scoping = SemanticBuilder::new() + .build(&parsed.program) + .semantic + .into_scoping(); + let known = scoping + .get_root_binding("known".into()) + .ok_or("missing known binding")?; + let style = ExtractStyleValue::Static(ExtractStaticStyle::new("color", "red", 0, None)); + let mut visitor = + DevupVisitor::new(&allocator, "coverage.tsx", "@devup-ui/react", vec![], None); + visitor.style_values = StyleValues::new(scoping); + visitor.style_values.insert( + known, + StyleValue::Class("known".into(), Some(vec![style.clone()])), + ); + let oxc_ast::ast::Statement::ExpressionStatement(statement) = + parsed.program.body.last().ok_or("missing expression")? + else { + return Err("expected array fixture".into()); + }; + let mut parts = Vec::new(); + // When + visitor + .known_parts(&statement.expression, &mut parts, Text::Classes) + .ok_or("array was unreadable")?; + // Then + assert_eq!(parts.len(), 1); + assert!( + matches!(&parts[0], KnownPart::Styles(styles) if matches!(&styles[..], [super::KnownStyles::Known(values)] if values == &[style])) + ); + Ok(()) +} diff --git a/libs/extractor/src/css_composition_rest_tests.rs b/libs/extractor/src/css_composition_rest_tests.rs new file mode 100644 index 000000000..26a82142b --- /dev/null +++ b/libs/extractor/src/css_composition_rest_tests.rs @@ -0,0 +1,405 @@ +use crate::extract_style::ExtractStyleProperty; +use crate::extract_style::extract_static_style::ExtractStaticStyle; +use crate::extract_style::style_property::StyleProperty; +use crate::{ExtractOption, ExtractOutput, ExtractStyleValue, ImportAlias, extract}; +use oxc_allocator::Allocator; +use oxc_ast::ast::Expression; +use oxc_parser::Parser; +use oxc_span::SourceType; +use rstest::rstest; +use serial_test::serial; +use std::collections::HashMap; + +fn compile(body: &str) -> ExtractOutput { + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + extract( + "composition.tsx", + &format!("import {{ css }} from '@devup-ui/react';\n{body}"), + ExtractOption { + single_css: true, + ..ExtractOption::default() + }, + ) + .unwrap_or_else(|error| panic!("{body}: {error}")) +} + +fn result_styles(output: &ExtractOutput, condition: bool) -> Vec { + fn classes(expression: &Expression<'_>, condition: bool) -> String { + match expression { + Expression::StringLiteral(literal) => literal.value.to_string(), + Expression::ConditionalExpression(choice) => classes( + if condition { + &choice.consequent + } else { + &choice.alternate + }, + condition, + ), + Expression::TemplateLiteral(template) => { + let mut result = String::new(); + for (index, quasi) in template.quasis.iter().enumerate() { + result.push_str(quasi.value.raw.as_str()); + if let Some(expression) = template.expressions.get(index) { + result.push_str(&classes(expression, condition)); + } + } + result + } + _ => panic!("unexpected compiled classes: {expression:?}"), + } + } + let source = output + .code + .split("const result = ") + .nth(1) + .unwrap_or_else(|| panic!("missing result: {}", output.code)); + let source = source.trim().trim_end_matches(';'); + let allocator = Allocator::default(); + let expression = Parser::new(&allocator, source, SourceType::tsx()) + .parse_expression() + .unwrap_or_else(|error| panic!("{source}: {error:?}")); + let classes = classes(&expression, condition); + let mut styles: Vec<_> = output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Static(style) => match style.extract(None) { + StyleProperty::ClassName(name) + if classes.split_whitespace().any(|class| class == name) => + { + Some(style.clone()) + } + _ => None, + }, + _ => None, + }) + .collect(); + styles.sort(); + styles +} + +#[rstest] +#[case("css(css`color: red; padding: 4px;`, { color: 'blue', m: 2 })", "blue")] +#[case("css({ color: 'blue', m: 2 }, css`color: red; padding: 4px;`)", "red")] +#[case( + "css`color: blue; margin: 8px; ${css`color: red; padding: 4px;`}`", + "red" +)] +#[case( + "css`${css`color: red; padding: 4px;`} color: blue; margin: 8px;`", + "blue" +)] +#[case( + "css({ color: 'blue', m: 2 }, css(...[{ color: 'red', p: 1 }]))", + "red" +)] +#[case( + "css(css(...[{ color: 'red', p: 1 }]), { color: 'blue', m: 2 })", + "blue" +)] +#[case( + "css({ color: 'blue', m: 2 }, css(...[, ...[{ color: 'red', p: 1 }], null]))", + "red" +)] +#[case( + "css`color: blue; margin: 8px; ${css(...[{ color: 'red', p: 1 }])}`", + "red" +)] +#[test] +#[serial] +fn later_styles_win_when_inline_parts_compose(#[case] expression: &str, #[case] color: &str) { + let source = format!("export const result = {expression};"); + let output = compile(&source); + let styles = result_styles(&output, true); + let declarations: Vec<_> = styles + .iter() + .map(|style| (style.property.as_str(), style.value.as_str())) + .collect(); + assert_eq!( + declarations, + vec![("color", color), ("margin", "8px"), ("padding", "4px")] + ); + assert!(!output.code.contains("css("), "{}", output.code); + assert!(!output.code.contains("css`"), "{}", output.code); +} + +#[rstest] +#[case( + "css({ color: 'green', m: 2 }, flag ? css`color: red; padding: 4px;` : css`color: blue; padding: 4px;`)", + "red", + "blue" +)] +#[case( + "css(flag && css`color: red; padding: 4px;`, { color: 'blue', m: 2 })", + "blue", + "blue" +)] +#[case( + "css`color: green; margin: 8px; ${flag ? css`color: red;` : css`color: blue;`}`", + "red", + "blue" +)] +#[case( + "css(css`color: red; ${flag && css`color: green;`}`, { color: 'blue', m: 2 })", + "blue", + "blue" +)] +#[case( + "css({ color: 'blue', m: 2 }, css`color: red; ${flag && css`color: green;`}`)", + "green", + "red" +)] +#[test] +#[serial] +fn choices_compose_when_inline_templates_are_conditional( + #[case] expression: &str, + #[case] yes: &str, + #[case] no: &str, +) { + let output = compile(&format!("export const result = {expression};")); + for (condition, color) in [(true, yes), (false, no)] { + let styles = result_styles(&output, condition); + let colors: Vec<_> = styles + .iter() + .filter(|style| style.property == "color") + .map(|style| style.value.as_str()) + .collect(); + assert_eq!(colors, vec![color]); + assert!( + styles + .iter() + .any(|style| style.property == "margin" && style.value == "8px") + ); + } +} + +#[test] +#[serial] +fn spread_arrays_compose_when_constants_are_exact() { + let output = compile( + "const first = [{ color: 'red', p: 1 }];\nconst parts = [...first, , { color: 'green' }];\nexport const result = css(css(...parts), { color: 'blue', m: 2 });", + ); + let styles = result_styles(&output, true); + assert_eq!( + styles + .iter() + .map(|style| (style.property.as_str(), style.value.as_str())) + .collect::>(), + vec![("color", "blue"), ("margin", "8px"), ("padding", "4px")] + ); +} + +#[test] +#[serial] +fn external_literals_stay_classes_when_their_text_matches_generated_classes() { + let output = compile( + "const base = css`color: red;`;\nexport const result = css('a', css`color: blue;`);", + ); + assert!(output.code.contains("\"a "), "{}", output.code); +} + +#[rstest] +#[case( + "css(css`color: red; &:hover { color: orange; }`, { color: 'blue', _focus: { color: 'green' } })" +)] +#[case( + "css({ color: 'blue', _focus: { color: 'green' } }, css`color: red; &:hover { color: orange; }`)" +)] +#[test] +#[serial] +fn selectors_stay_distinct_when_inline_templates_compose(#[case] expression: &str) { + let output = compile(&format!("export const result = {expression};")); + let styles = result_styles(&output, true); + assert_eq!(styles.len(), 3); + for (selector, value) in [(":hover", "orange"), (":focus", "green")] { + assert!( + styles.iter().any(|style| style.value == value + && style + .selector + .as_ref() + .is_some_and(|actual| actual.to_string().contains(selector))), + "{styles:?}" + ); + } +} + +#[test] +#[serial] +fn units_stay_library_specific_when_nested_spreads_compose() { + let option = ExtractOption { + single_css: true, + import_aliases: HashMap::from([("@emotion/react".to_string(), ImportAlias::NamedToNamed)]), + ..ExtractOption::default() + }; + let output = extract("composition.tsx", "import { css } from '@emotion/react';\nexport const result = css(css(...[{ padding: 2, p: 3, m: 3, opacity: 0.5 }]), { padding: 5 });", option).unwrap_or_else(|error| panic!("{error}")); + let styles = result_styles(&output, true); + assert_eq!( + styles + .iter() + .map(|style| (style.property.as_str(), style.value.as_str())) + .collect::>(), + vec![("margin", "12px"), ("opacity", ".5"), ("padding", "5px")] + ); +} + +#[test] +#[serial] +fn breakpoints_stay_distinct_when_nested_spreads_override_base_styles() { + let output = compile( + "export const result = css(css(...[{ color: ['red', null, 'green'] }]), css`color: blue;`);", + ); + let styles = result_styles(&output, true); + assert_eq!( + styles + .iter() + .map(|style| (style.value.as_str(), style.level)) + .collect::>(), + vec![("blue", 0), ("green", 2)] + ); +} + +#[test] +#[serial] +fn layers_stay_distinct_when_nested_spreads_override_matching_rules() { + use oxc_ast_visit::VisitMut; + let allocator = Allocator::default(); + let mut program = Parser::new(&allocator, "import { css } from '@devup-ui/react';\nimport { first, second, theme } from './layers';\nexport const result = css(css(...[first, theme]), second);", SourceType::tsx()).parse().program; + let mut visitor = crate::visit::DevupVisitor::new( + &allocator, + "composition.tsx", + "@devup-ui/react", + vec![], + None, + ); + visitor.import_css( + [ + ("first", "red", "base"), + ("second", "blue", "base"), + ("theme", "green", "theme"), + ] + .into_iter() + .map(|(name, value, layer)| { + ( + name.to_string(), + vec![ExtractStyleValue::Static( + ExtractStaticStyle::new_with_layer( + "color", + value, + 0, + None, + Some(layer.to_string()), + ), + )], + ) + }) + .collect(), + ); + visitor.visit_program(&mut program); + assert_eq!(visitor.errors, vec![]); + let output = ExtractOutput { + styles: visitor.styles, + code: oxc_codegen::Codegen::new().build(&program).code, + map: None, + css_file: None, + dependencies: vec![], + }; + let styles = result_styles(&output, true); + assert_eq!( + styles + .iter() + .map(|style| (style.value.as_str(), style.layer.as_deref())) + .collect::>(), + vec![("blue", Some("base")), ("green", Some("theme"))] + ); +} + +#[rstest] +#[case("css(css`color: ${runtime};`, { m: 2 })")] +#[case("css`color: blue; ${css`&${runtime} { color: red; }`}`")] +#[case("css({ color: 'red' }, css(...[...rest]))")] +#[test] +#[serial] +fn runtime_only_parts_fail_when_the_build_cannot_read_styles(#[case] expression: &str) { + let source = + format!("import {{ css }} from '@devup-ui/react';\nexport const result = {expression};"); + let error = extract("composition.tsx", &source, ExtractOption::default()) + .err() + .unwrap_or_else(|| panic!("expected build error: {source}")) + .to_string(); + assert!(error.starts_with("composition.tsx:2:"), "{error}"); +} + +#[rstest] +#[case("css(...[first, second])", "blue")] +#[case("css(...[second, first])", "red")] +#[case("css(...[, ...[first, , second], ,])", "blue")] +#[case("css(...[, ...[second, , first], ,])", "red")] +#[case("css(first, ...parts)", "blue")] +#[case("css(second, ...reverse)", "red")] +#[case("css(...[css(...[first]), second])", "blue")] +#[case("css(...[second, css(...[first])])", "red")] +#[case("css(...[first], second)", "blue")] +#[case("css(second, ...[first])", "red")] +#[test] +#[serial] +fn later_styles_win_when_known_classes_are_argument_spreads( + #[case] expression: &str, + #[case] color: &str, +) { + // Given + let source = format!( + "const first = css({{ color: 'red', p: 1 }});\nconst second = css({{ color: 'blue', m: 2 }});\nconst parts = [...[{{ color: 'red', p: 1 }}], , {{ color: 'blue', m: 2 }}];\nconst reverse = [...[{{ color: 'blue', m: 2 }}], , {{ color: 'red', p: 1 }}];\nexport const result = {expression};" + ); + // When + let output = compile(&source); + // Then + assert_eq!( + result_styles(&output, true) + .iter() + .map(|style| (style.property.as_str(), style.value.as_str())) + .collect::>(), + vec![("color", color), ("margin", "8px"), ("padding", "4px")] + ); +} + +#[test] +#[serial] +fn frame_selectors_survive_when_known_classes_are_argument_spreads() { + // Given + let source = "import { styled } from '@devup-ui/react';\nconst Frame = styled('div', { p: 1 });\nconst first = css`${Frame} { color: red; padding: 4px; }`;\nconst second = css`${Frame} { color: blue; }`;\nexport const result = css(...[first, second]);"; + // When + let output = compile(source); + let styles = result_styles(&output, true); + // Then + assert_eq!( + styles + .iter() + .map(|style| (style.property.as_str(), style.value.as_str())) + .collect::>(), + vec![("color", "blue"), ("padding", "4px")] + ); + assert!(styles.iter().all(|style| style.selector.is_some())); + assert_eq!(styles[0].selector, styles[1].selector); +} + +#[rstest] +#[case("css({ color: 'red' }, css(...rest))", "composition.tsx:2:45:")] +#[case("css`color: red; ${css(...rest)}`", "composition.tsx:2:41:")] +#[case("css(...rest)", "composition.tsx:2:23:")] +#[case("css(...[...rest])", "composition.tsx:2:23:")] +#[test] +#[serial] +fn unknown_spreads_fail_at_the_nested_call_when_arrays_are_runtime_only( + #[case] expression: &str, + #[case] location: &str, +) { + let source = + format!("import {{ css }} from '@devup-ui/react';\nexport const result = {expression};"); + let error = extract("composition.tsx", &source, ExtractOption::default()) + .err() + .unwrap_or_else(|| panic!("expected build error: {source}")) + .to_string(); + assert!(error.starts_with(location), "{error}"); + assert!(error.contains("...rest"), "{error}"); +} diff --git a/libs/extractor/src/css_utils.rs b/libs/extractor/src/css_utils.rs index de8628708..e30956fbd 100644 --- a/libs/extractor/src/css_utils.rs +++ b/libs/extractor/src/css_utils.rs @@ -21,6 +21,18 @@ use oxc_ast::ast::Expression; use oxc_ast::ast::{TemplateElement, TemplateLiteral}; use oxc_ast::builder::AstBuilder; +mod component_layer_objects; +#[cfg(test)] +mod component_layer_policy_tests; +#[cfg(test)] +mod component_layer_tests; +mod component_layers; +mod layer_blocks; +mod layer_names; +pub(crate) use component_layer_objects::object_layer_errors; +pub(crate) use component_layers::{expression_layer_errors, nest_layer, template_layer_errors}; +pub(crate) use layer_names::parse as parse_layer_name; + use crate::extract_style::{ extract_dynamic_style::ExtractDynamicStyle, extract_static_style::ExtractStaticStyle, extract_style_value::ExtractStyleValue, @@ -420,12 +432,15 @@ pub fn css_to_style_template( } static_value.push_str(&value[cursor..]); // Create a new static style with the evaluated value - styles.push(CssToStyleResult::Static(ExtractStaticStyle::new( - style.property(), - &static_value, - style.level(), - style.selector().cloned(), - ))); + styles.push(CssToStyleResult::Static( + ExtractStaticStyle::new_with_layer( + style.property(), + &static_value, + style.level(), + style.selector().cloned(), + style.layer.clone(), + ), + )); } else { // Not all expressions are literals - need to create dynamic style // Check if value is just a placeholder (no surrounding text) @@ -437,12 +452,7 @@ pub fn css_to_style_template( // Value is just the expression - use expression code directly let identifier = dynamic_expr_code(&css.expressions[*idx], &shared_allocator); - styles.push(CssToStyleResult::Dynamic(ExtractDynamicStyle::new( - style.property(), - style.level(), - &identifier, - style.selector().cloned(), - ))); + styles.push(component_layers::dynamic_from(&style, &identifier)); } else { // Value has surrounding text - need to create template literal // Reconstruct the template literal by replacing placeholders with ${expr} syntax @@ -511,12 +521,7 @@ pub fn css_to_style_template( // Wrap in template literal backticks let final_identifier = format!("`{template_literal}`"); - styles.push(CssToStyleResult::Dynamic(ExtractDynamicStyle::new( - style.property(), - style.level(), - &final_identifier, - style.selector().cloned(), - ))); + styles.push(component_layers::dynamic_from(&style, &final_identifier)); } } } @@ -535,7 +540,7 @@ pub fn css_to_style( selector: &Option, ) -> Vec { let mut styles = vec![]; - collect_css_block(&rm_css_comment(css), level, selector, &mut styles); + collect_css_block(css, level, selector, &mut styles); // A single declaration (or none) is trivially ordered, so skip the comparison // sort's setup entirely for the very common single-property case. The multi-source @@ -557,15 +562,19 @@ fn collect_css_block( styles: &mut Vec, ) { let mut rest = css; - while let Some(open) = rest.find('{') { + while let Some((open, _)) = + layer_blocks::boundaries(rest).find(|(_, character)| *character == '{') + { let head = &rest[..open]; - let (declarations, prelude) = head.rsplit_once(';').unwrap_or(("", head)); + let (declarations, prelude) = layer_blocks::boundaries(head) + .filter(|(_, character)| *character == ';') + .last() + .map_or(("", head), |(at, _)| (&head[..at], &head[at + 1..])); styles.extend(css_to_style_block(declarations, level, selector)); let body_start = open + 1; let mut depth = 1usize; - let body_end = rest[body_start..] - .char_indices() + let body_end = layer_blocks::boundaries(&rest[body_start..]) .find_map(|(index, c)| { match c { '{' => depth += 1, @@ -576,10 +585,16 @@ fn collect_css_block( }) .unwrap_or(rest.len()); let body = &rest[body_start..body_end]; - let prelude = prelude.trim(); + let prelude = component_layers::trim_trivia(prelude).trim_end(); if prelude.is_empty() || prelude == "&" { collect_css_block(body, level, selector, styles); - } else if let Some(nested) = nest_prelude(selector.as_ref(), prelude) { + } else if let Some(layer) = component_layers::named_layer(prelude) { + let from = styles.len(); + collect_css_block(body, level, selector, styles); + for style in &mut styles[from..] { + nest_layer(&layer, &mut style.layer); + } + } else if let Some(nested) = nest_prelude(selector.as_ref(), &rm_css_comment(prelude)) { collect_css_block(body, level, &Some(nested), styles); } rest = rest.get(body_end + 1..).unwrap_or_default(); @@ -1681,9 +1696,7 @@ mod tests { "color: red;;invalid;display: block;", vec![("color", "red", None), ("display", "block", None)] )] - // Blocks that can never apply produce nothing: unknown at-rules and - // mutually exclusive media types. - #[case("@layer base { color: red; } @media print { @media screen { color: red; } }", vec![])] + #[case("@layer base { color: red; } @media print { @media screen { color: red; } }", vec![("color", "red", None)])] fn test_css_to_style( #[case] input: &str, #[case] expected: Vec<(&str, &str, Option)>, diff --git a/libs/extractor/src/css_utils/component_layer_coverage_tests.rs b/libs/extractor/src/css_utils/component_layer_coverage_tests.rs new file mode 100644 index 000000000..afa821aa4 --- /dev/null +++ b/libs/extractor/src/css_utils/component_layer_coverage_tests.rs @@ -0,0 +1,130 @@ +use oxc_allocator::Allocator; +use oxc_parser::Parser; +use oxc_span::SourceType; +use rstest::rstest; + +#[rstest] +#[case("[ , '@layer base, theme;', enabled && '@layer {color:red;}' ]", 2)] +#[case("enabled && '@layer base, theme;'", 1)] +#[case("enabled ? '@layer base, theme;' : '@layer {color:red;}'", 2)] +#[case("[null, false, runtime, '@layer base {color:red;}']", 0)] +fn layer_policy_when_nested_in_rule_choices_checks_every_readable_branch( + #[case] source: &str, + #[case] count: usize, +) -> Result<(), Box> { + // Given + let allocator = Allocator::default(); + let expression = Parser::new(&allocator, source, SourceType::tsx()) + .parse_expression() + .map_err(|error| format!("{error:?}"))?; + // When + let errors = super::expression_layer_errors(&expression, "css"); + // Then + assert_eq!(errors.len(), count); + for (offset, error) in errors { + assert!( + source + .get(usize::try_from(offset)?..) + .is_some_and(|tail| tail.starts_with('\'')) + ); + assert!(error.contains("globalCss"), "{error}"); + } + Ok(()) +} + +#[test] +fn layer_escape_when_hex_ends_in_crlf_consumes_both_whitespace_characters() { + // Given + let source = "\\62\r\nase.reset"; + // When + let name = crate::css_utils::parse_layer_name(source); + // Then + assert_eq!(name.as_deref(), Some("base.reset")); +} + +#[rstest] +#[case("p => ({'@layer': {inherit: {color: p.color}}})")] +#[case("p => { return {'@layer': {inherit: {color: p.color}}}; }")] +#[case("function(p) { return {'@layer': {inherit: {color: p.color}}}; }")] +#[case("p => [{'@layer': {inherit: {color: p.color}}}]")] +#[case("p => p.active ? {'@layer': {inherit: {color: p.color}}} : {}")] +#[case("p => ({'@layer': {inherit: {color: p.color}}}) || {}")] +#[case("p => ({} || {'@layer': {inherit: {color: p.color}}})")] +#[case("p => ({'@layer': {inherit: {color: p.color}}}) ?? {}")] +#[case("p => ({} ?? {'@layer': {inherit: {color: p.color}}})")] +#[serial_test::serial] +fn returned_layers_when_invalid_report_original_key_coordinates( + #[case] callback: &str, + #[values(false, true)] local: bool, +) -> Result<(), Box> { + // Given + let source = if local { + format!( + "import {{ styled }} from '@devup-ui/react';\nexport function make() {{\n return styled.div({callback});\n}}" + ) + } else { + format!( + "import {{ styled }} from '@devup-ui/react';\nexport const result = styled.div({callback});" + ) + }; + let offset = source.find("inherit").ok_or("missing fixture key")?; + let prefix = &source[..offset]; + let line = prefix.bytes().filter(|byte| *byte == b'\n').count() + 1; + let column = prefix + .rsplit('\n') + .next() + .ok_or("missing fixture line")? + .chars() + .count() + + 1; + // When + let error = crate::extract( + "returned-layer.tsx", + &source, + crate::ExtractOption::default(), + ) + .err() + .ok_or("expected invalid layer error")? + .to_string(); + // Then + assert!( + error.lines().any(|error| error + .starts_with(&format!("returned-layer.tsx:{line}:{column}:")) + && error.contains("@layer inherit")), + "{error}" + ); + assert!( + error.contains("@layer inherit") && error.contains("name the layer"), + "{error}" + ); + Ok(()) +} + +#[rstest] +#[case("getWidth({'@layer': {inherit: 1}, padding: 3})")] +#[case("new Width({'@layer': {inherit: 1}, padding: 3})")] +#[serial_test::serial] +fn value_calls_when_arguments_look_like_layers_preserve_application_data( + #[case] value: &str, +) -> Result<(), Box> { + // Given + let source = format!( + "import {{ styled }} from '@devup-ui/react';\nexport const result = styled.div({{ width: {value} }});" + ); + // When + let output = crate::extract("data-layer.tsx", &source, crate::ExtractOption::default())?; + // Then + let compact: String = output + .code + .chars() + .filter(|character| !character.is_whitespace()) + .collect(); + assert!( + compact.contains("inherit:1") && compact.contains("padding:3"), + "{}", + output.code + ); + assert!(!compact.contains("padding:\"3px\""), "{}", output.code); + assert!(output.styles.iter().any(|style| matches!(style, crate::ExtractStyleValue::Dynamic(style) if style.property() == "width"))); + Ok(()) +} diff --git a/libs/extractor/src/css_utils/component_layer_objects.rs b/libs/extractor/src/css_utils/component_layer_objects.rs new file mode 100644 index 000000000..d20c95828 --- /dev/null +++ b/libs/extractor/src/css_utils/component_layer_objects.rs @@ -0,0 +1,169 @@ +use super::component_layers::LAYER_NAME_REQUIREMENT; +use crate::utils::{ + build_time_error, get_string_by_property_key, readable_code, unwrap_syntax_only, +}; +use oxc_ast::ast::{ArrowFunctionBody, Expression, ObjectPropertyKind}; +use oxc_span::GetSpan; +use oxc_syntax::operator::LogicalOperator; + +pub(crate) fn object_layer_errors(expression: &Expression<'_>, api: &str) -> Vec<(u32, String)> { + match unwrap_syntax_only(expression) { + Expression::CallExpression(call) => call + .arguments + .iter() + .filter_map(|argument| argument.as_expression()) + .flat_map(|part| rule_layer_errors(part, api)) + .collect(), + expression => rule_layer_errors(expression, api), + } +} + +/// Validate rule values without treating application-call arguments as CSS. +fn rule_layer_errors(expression: &Expression<'_>, api: &str) -> Vec<(u32, String)> { + match unwrap_syntax_only(expression) { + Expression::ObjectExpression(object) => { + let mut errors = Vec::new(); + for property in &object.properties { + let ObjectPropertyKind::ObjectProperty(property) = property else { + continue; + }; + if property + .key + .static_name() + .is_some_and(|name| name == "@layer") + { + if let Expression::ObjectExpression(layers) = + unwrap_syntax_only(&property.value) + { + for entry in &layers.properties { + let ObjectPropertyKind::ObjectProperty(entry) = entry else { + continue; + }; + let name = get_string_by_property_key(&entry.key); + if name.as_deref().and_then(super::parse_layer_name).is_none() { + let code = name.unwrap_or_else(|| { + entry + .key + .as_expression() + .map_or_else(String::new, readable_code) + }); + errors.push(( + entry.key.span().start, + build_time_error( + api, + &format!("@layer {code}"), + LAYER_NAME_REQUIREMENT, + ), + )); + } + errors.extend(match unwrap_syntax_only(&entry.value) { + Expression::StringLiteral(_) | Expression::TemplateLiteral(_) => { + super::expression_layer_errors(&entry.value, api) + } + value => rule_layer_errors(value, api), + }); + } + } + } else { + errors.extend(rule_layer_errors(&property.value, api)); + } + } + errors + } + Expression::ArrayExpression(array) => array + .elements + .iter() + .filter_map(|element| element.as_expression()) + .flat_map(|part| rule_layer_errors(part, api)) + .collect(), + Expression::ConditionalExpression(choice) => [&choice.consequent, &choice.alternate] + .into_iter() + .flat_map(|part| rule_layer_errors(part, api)) + .collect(), + Expression::LogicalExpression(logical) => { + let mut errors = match logical.operator { + LogicalOperator::And => vec![], + LogicalOperator::Or | LogicalOperator::Coalesce => { + rule_layer_errors(&logical.left, api) + } + }; + errors.extend(rule_layer_errors(&logical.right, api)); + errors + } + Expression::ArrowFunctionExpression(arrow) if !arrow.r#async => { + let returned = match &arrow.body { + ArrowFunctionBody::FunctionBody(body) => crate::css_prop::returned(body), + body => body.as_expression(), + }; + returned.map_or_else(Vec::new, |rules| rule_layer_errors(rules, api)) + } + Expression::FunctionExpression(function) if !function.r#async && !function.generator => { + function + .body + .as_deref() + .and_then(crate::css_prop::returned) + .map_or_else(Vec::new, |rules| rule_layer_errors(rules, api)) + } + _ => vec![], + } +} + +#[cfg(test)] +mod tests { + use oxc_allocator::Allocator; + use oxc_parser::Parser; + use oxc_span::SourceType; + use rstest::rstest; + + #[rstest] + #[case("styled.div(p => ({'@layer': {inherit: {color: p.color}}}))", 1)] + #[case( + "styled.div(function(p) { return {'@layer': {inherit: {color: p.color}}}; })", + 1 + )] + #[case( + "css({'@layer': {base: {width: getWidth({'@layer': {inherit: 1}, padding: 3})}}})", + 0 + )] + #[case("css({width: new Width({'@layer': {inherit: 1}, padding: 3})})", 0)] + #[case("styled.div(p => ({'@layer': {inherit: {color: p.color}}}) || {})", 1)] + #[case("styled.div(p => ({} ?? {'@layer': {inherit: {color: p.color}}}))", 1)] + #[case("styled.div(p => ({'@layer': {inherit: {color: p.color}}}) ?? {})", 1)] + #[case("styled.div(p => ({} || {'@layer': {inherit: {color: p.color}}}))", 1)] + #[case("styled.div(p => ({'@layer': {inherit: 1}}) && {})", 0)] + #[case( + "styled.div(p => p.active && {'@layer': {inherit: {color: p.color}}})", + 1 + )] + #[case( + "styled.div(p => [ , p.active ? {'@layer': {inherit: {color: p.color}}} : {}])", + 1 + )] + #[case("styled.div(async p => ({'@layer': {inherit: {color: p.color}}}))", 0)] + #[case( + "styled.div(function* (p) { return {'@layer': {inherit: {color: p.color}}}; })", + 0 + )] + #[case( + "styled.div(p => { sideEffect(); return {'@layer': {inherit: {color: p.color}}}; })", + 0 + )] + fn object_policy_when_rules_return_or_call_preserves_the_boundary( + #[case] source: &str, + #[case] count: usize, + ) -> Result<(), Box> { + // Given + let allocator = Allocator::default(); + let expression = Parser::new(&allocator, source, SourceType::tsx()) + .parse_expression() + .map_err(|error| format!("{error:?}"))?; + // When + let errors = super::object_layer_errors(&expression, "styled"); + // Then + assert_eq!(errors.len(), count, "{errors:?}"); + for (offset, _) in errors { + assert!(source[usize::try_from(offset)?..].starts_with("inherit")); + } + Ok(()) + } +} diff --git a/libs/extractor/src/css_utils/component_layer_policy_tests.rs b/libs/extractor/src/css_utils/component_layer_policy_tests.rs new file mode 100644 index 000000000..ba1fc2282 --- /dev/null +++ b/libs/extractor/src/css_utils/component_layer_policy_tests.rs @@ -0,0 +1,267 @@ +use crate::{ExtractOption, ImportAlias, extract}; +use rstest::rstest; +use serial_test::serial; +use std::collections::HashMap; + +#[rstest] +#[case("base.reset", "base.reset")] +#[case("한글.é", "한글.é")] +#[case("-foo.--bar", "-foo.--bar")] +#[case(r"\62 ase", "base")] +#[case(r"\31 23", r"\31 23")] +#[case(r"a\.b", r"a\.b")] +#[case(r"a\ b", r"a\ b")] +#[case(r"\-", r"\-")] +#[case(r"\0 ", "�")] +#[case(r"\110000 ", "�")] +#[case(" base/**/.reset ", "base.reset")] +#[case("default", "default")] +#[case("framework.default", "framework.default")] +#[case(r"base.\72 eset", "base.reset")] +#[case(r"\62 ase.reset", "base.reset")] +#[test] +fn names_are_canonical_when_css_identifiers_use_unicode_or_escapes( + #[case] source: &str, + #[case] expected: &str, +) { + assert_eq!(super::parse_layer_name(source).as_deref(), Some(expected)); +} + +#[rstest] +#[case("")] +#[case(" ")] +#[case("-")] +#[case("1base")] +#[case("base..reset")] +#[case("base.")] +#[case("base reset")] +#[case("base,reset")] +#[case("foo@bar")] +#[case("inherit")] +#[case("INITIAL")] +#[case("base.unset")] +#[case("ReVeRt-LaYeR")] +#[case("base . reset")] +#[case("base. reset")] +#[case("base .reset")] +#[case("base\t.reset")] +#[case("base.\nreset")] +#[case("base /**/ .reset")] +#[case("base./**/ reset")] +#[case(r"\69 nitial")] +#[case("a\\")] +#[case("a\\\n")] +#[test] +fn names_are_rejected_when_layer_identifiers_are_invalid_or_reserved(#[case] source: &str) { + assert_eq!(super::parse_layer_name(source), None); +} + +fn component_source(api: &str, styles: &str) -> String { + let imports = "import { css, styled } from '@devup-ui/react';\nimport { ClassNames } from '@emotion/react';\n"; + let css = if styles.starts_with('`') { + format!("css{styles}") + } else { + format!("css({styles})") + }; + let expression = match api { + "css" => css, + "styled" if styles.starts_with('`') => format!("styled.div{styles}"), + "styled" => format!("styled.div({styles})"), + "prop" => format!("
"), + "classnames" => { + format!("{{({{css}}) =>
}}") + } + _ => panic!("unsupported fixture API"), + }; + format!("{imports}export const result = {expression};") +} + +#[rstest] +#[case("")] +#[case("inherit")] +#[case("base..reset")] +#[case("123")] +#[case("base .reset")] +#[case("base. reset")] +#[test] +#[serial] +fn object_names_fail_at_their_keys_when_component_layers_are_invalid( + #[values("css", "styled", "prop", "classnames")] api: &str, + #[case] name: &str, +) { + let key = serde_json::to_string(name).unwrap_or_else(|error| panic!("{error}")); + let source = component_source( + api, + &format!("{{ '@layer': {{ {key}: {{ color: 'red' }} }} }}"), + ); + let option = ExtractOption { + import_aliases: HashMap::from([("@emotion/react".to_string(), ImportAlias::NamedToNamed)]), + ..ExtractOption::default() + }; + let error = extract("layer.tsx", &source, option) + .err() + .unwrap_or_else(|| panic!("expected invalid-layer error: {source}")) + .to_string(); + assert!(error.starts_with("layer.tsx:3:"), "{error}"); + assert!( + error.contains("name the layer") && error.contains("globalCss"), + "{error}" + ); +} + +#[rstest] +#[case("")] +#[case("initial")] +#[case("base..reset")] +#[case("base,theme")] +#[case("base .reset")] +#[case("base. reset")] +#[test] +#[serial] +fn text_names_fail_at_their_blocks_when_component_layers_are_invalid( + #[values("css", "styled", "prop", "classnames")] api: &str, + #[case] name: &str, +) { + let source = component_source(api, &format!("`@layer {name} {{ color: red; }}`")); + let option = ExtractOption { + import_aliases: HashMap::from([("@emotion/react".to_string(), ImportAlias::NamedToNamed)]), + ..ExtractOption::default() + }; + let error = extract("layer.tsx", &source, option) + .err() + .unwrap_or_else(|| panic!("expected invalid-layer error: {source}")) + .to_string(); + assert!(error.starts_with("layer.tsx:3:"), "{error}"); + assert!( + error.contains("name the layer") && error.contains("globalCss"), + "{error}" + ); +} + +#[rstest] +#[case(r"@layer a\{b { color: red; }", r"a\{b")] +#[case(r"@layer/**/base { color: red; }", "base")] +#[case(r"/* before */ @layer base/**/.reset { color: red; }", "base.reset")] +#[test] +fn escaped_layer_names_keep_rules_when_escapes_contain_css_delimiters( + #[case] text: &str, + #[case] layer: &str, +) { + let styles = super::css_to_style(text, 0, &None); + assert_eq!( + styles + .iter() + .map(|style| (style.layer.as_deref(), style.value.as_str())) + .collect::>(), + vec![(Some(layer), "red")] + ); +} + +#[test] +#[serial] +fn anonymous_blocks_fail_when_the_layer_keyword_touches_the_brace() { + let source = + "import { css } from '@devup-ui/react';\nexport const result = css`@layer{color:red;}`;"; + let error = extract("layer.tsx", source, ExtractOption::default()) + .err() + .unwrap_or_else(|| panic!("expected anonymous-layer error")) + .to_string(); + assert!(error.starts_with("layer.tsx:2:27:"), "{error}"); + assert!( + error.contains("name the layer") && error.contains("globalCss"), + "{error}" + ); +} + +#[test] +#[serial] +fn equivalent_names_compose_when_text_escapes_match_object_identifiers() { + let source = r"import { css } from '@devup-ui/react'; +export const result = css({ '@layer': { base: { color: 'red' } } }, css`@layer \62 ase { color: blue; }`);"; + let output = extract( + "layer.tsx", + source, + ExtractOption { + single_css: true, + ..ExtractOption::default() + }, + ) + .unwrap_or_else(|error| panic!("{error}")); + let values: Vec<_> = output + .styles + .iter() + .filter_map(|style| match style { + crate::ExtractStyleValue::Static(style) => { + Some((style.layer.as_deref(), style.value.as_str())) + } + _ => None, + }) + .collect(); + assert_eq!(values, vec![(Some("base"), "blue")]); +} + +#[test] +#[serial] +fn global_layers_stay_accepted_when_they_are_anonymous_or_declare_order() { + let source = "import { globalCss } from '@devup-ui/react';\nglobalCss`@layer base, theme; @layer { body { color: red; } }`;"; + let output = extract("layer.tsx", source, ExtractOption::default()) + .unwrap_or_else(|error| panic!("{error}")); + assert!(output.styles.iter().any(|style| matches!(style, crate::ExtractStyleValue::Css(style) if style.css.contains("@layer") && style.css.contains("red")))); +} + +#[rstest] +#[case("`@LAYER default { color: red; }`", "default")] +#[case("`@LaYeR framework.default { color: red; }`", "framework.default")] +#[case("`@LAYER Base { color: red; }`", "Base")] +#[case("{ '@layer': { default: { color: 'red' } } }", "default")] +#[case( + "{ '@layer': { 'framework.default': { color: 'red' } } }", + "framework.default" +)] +#[test] +#[serial] +fn default_layers_keep_metadata_when_component_apis_read_text_or_objects( + #[values("css", "styled", "prop", "classnames")] api: &str, + #[case] styles: &str, + #[case] layer: &str, +) { + let source = component_source(api, styles); + let option = ExtractOption { + import_aliases: HashMap::from([("@emotion/react".to_string(), ImportAlias::NamedToNamed)]), + ..ExtractOption::default() + }; + let output = extract("layer.tsx", &source, option).unwrap_or_else(|error| panic!("{error}")); + let values: Vec<_> = output + .styles + .iter() + .filter_map(|style| match style { + crate::ExtractStyleValue::Static(style) => { + Some((style.layer.as_deref(), style.value.as_str())) + } + _ => None, + }) + .collect(); + assert_eq!(values, vec![(Some(layer), "red")]); +} + +#[rstest] +#[test] +#[serial] +fn uppercase_layer_order_fails_when_component_apis_read_statements( + #[values("css", "styled", "prop", "classnames")] api: &str, +) { + let source = component_source(api, "`@LAYER base, theme;`"); + let option = ExtractOption { + import_aliases: HashMap::from([("@emotion/react".to_string(), ImportAlias::NamedToNamed)]), + ..ExtractOption::default() + }; + let error = extract("layer.tsx", &source, option) + .err() + .unwrap_or_else(|| panic!("expected layer-order error: {source}")) + .to_string(); + assert!(error.starts_with("layer.tsx:3:"), "{error}"); + assert!( + error.contains("@LAYER base, theme;") && error.contains("globalCss"), + "{error}" + ); +} diff --git a/libs/extractor/src/css_utils/component_layer_tests.rs b/libs/extractor/src/css_utils/component_layer_tests.rs new file mode 100644 index 000000000..53371bdf2 --- /dev/null +++ b/libs/extractor/src/css_utils/component_layer_tests.rs @@ -0,0 +1,214 @@ +use super::{CssToStyleResult, css_to_style, css_to_style_template}; +use crate::{ExtractOption, ExtractStyleValue, ImportAlias, extract}; +use oxc_allocator::Allocator; +use oxc_ast::ast::Expression; +use oxc_parser::Parser; +use oxc_span::SourceType; +use rstest::rstest; +use serial_test::serial; +use std::collections::HashMap; + +#[test] +fn layer_metadata_nests_when_blocks_wrap_selectors_and_media() { + let text = + "@layer base { @layer reset { @media print { &:hover { color: red; } } } } color: blue;"; + let styles = css_to_style(text, 2, &None); + assert_eq!(styles.len(), 2); + let red = styles + .iter() + .find(|style| style.value == "red") + .unwrap_or_else(|| panic!("missing layered declaration: {styles:?}")); + assert_eq!(red.layer.as_deref(), Some("base.reset")); + assert_eq!(red.level, 2); + assert!(red.selector.as_ref().is_some_and(|selector| { + let text = selector.to_string(); + text.contains("print") && text.contains(":hover") + })); + assert!( + styles + .iter() + .any(|style| style.value == "blue" && style.layer.is_none()) + ); +} + +#[rstest] +#[case("`@layer base { color: ${'red'}; }`", "red")] +#[case("`@layer base { color: ${runtime.color}; }`", "runtime.color")] +#[case("`@layer base { width: ${runtime.width}px; }`", "`${runtime.width}px`")] +#[test] +fn layer_metadata_survives_when_template_values_are_rebuilt( + #[case] source: &str, + #[case] value: &str, +) { + let allocator = Allocator::default(); + let expression = Parser::new(&allocator, source, SourceType::tsx()) + .parse_expression() + .unwrap_or_else(|error| panic!("{error:?}")); + let Expression::TemplateLiteral(template) = expression else { + panic!("expected template") + }; + let parsed = css_to_style_template(&template, 0, &None); + assert_eq!(parsed.styles.len(), 1); + match &parsed.styles[0] { + CssToStyleResult::Static(style) => { + assert_eq!(style.layer.as_deref(), Some("base")); + assert_eq!(style.value, value); + } + CssToStyleResult::Dynamic(style) => { + assert_eq!(style.layer(), Some("base")); + assert_eq!(style.identifier(), value); + } + } +} + +#[rstest] +#[case( + "import { css } from '@devup-ui/react';\nexport const result = css`@layer base { color: red; }`;" +)] +#[case( + "import { styled } from '@devup-ui/react';\nexport const result = styled.div`@layer base { color: red; }`;" +)] +#[case( + "import { css } from '@emotion/react';\nexport const result =
;" +)] +#[case( + "import { ClassNames } from '@emotion/react';\nexport const result = {({css}) =>
};" +)] +#[test] +#[serial] +fn declarations_are_retained_when_component_apis_read_layer_text(#[case] source: &str) { + let option = ExtractOption { + single_css: true, + import_aliases: HashMap::from([("@emotion/react".to_string(), ImportAlias::NamedToNamed)]), + ..ExtractOption::default() + }; + let output = extract("layer.tsx", source, option).unwrap_or_else(|error| panic!("{error}")); + let layers: Vec<_> = output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Static(style) => Some(( + style.property.as_str(), + style.value.as_str(), + style.layer.as_deref(), + )), + _ => None, + }) + .collect(); + assert_eq!(layers, vec![("color", "red", Some("base"))]); +} + +#[rstest] +#[case( + "import { css } from '@devup-ui/react';\nexport const result = css`color: red;\n @layer base, theme;\n color: blue;`;" +)] +#[case( + "import { css } from '@devup-ui/react';\nexport const result = css(css`color: red;\n @layer base, theme;`, { m: 2 });" +)] +#[case( + "import { css } from '@devup-ui/react';\nexport const result = css`color: red;\n @layer base, theme; ${css`color: green;`}`;" +)] +#[case( + "import { css } from '@emotion/react';\nexport const result =
;" +)] +#[case( + "import { ClassNames } from '@emotion/react';\nexport const result = {({css}) =>
};" +)] +#[case( + "import { styled } from '@devup-ui/react';\nexport const result = styled.div`color: red;\n @layer base, theme;`;" +)] +#[test] +#[serial] +fn layer_order_is_rejected_at_its_source_when_component_apis_read_statements(#[case] source: &str) { + let option = ExtractOption { + import_aliases: HashMap::from([("@emotion/react".to_string(), ImportAlias::NamedToNamed)]), + ..ExtractOption::default() + }; + let error = extract("layer.tsx", source, option) + .err() + .unwrap_or_else(|| panic!("expected component layer-order error: {source}")); + let error = error.to_string(); + assert!(error.starts_with("layer.tsx:3:2:"), "{error}"); + assert!(error.contains("@layer base, theme;"), "{error}"); + assert!(error.contains("globalCss"), "{error}"); +} + +#[rstest] +#[case("`color: red; /* @layer a, b; */ content: '@layer a, b;';`")] +#[case("`@layer base { color: red; }`")] +#[test] +fn validation_ignores_order_text_when_it_is_not_a_layer_statement(#[case] source: &str) { + let allocator = Allocator::default(); + let expression = Parser::new(&allocator, source, SourceType::tsx()) + .parse_expression() + .unwrap_or_else(|error| panic!("{error:?}")); + assert_eq!(super::expression_layer_errors(&expression, "css"), vec![]); +} + +#[test] +fn validation_locates_statements_when_comments_precede_them() { + let source = "`/* before */ @layer base, theme;`"; + let allocator = Allocator::default(); + let expression = Parser::new(&allocator, source, SourceType::tsx()) + .parse_expression() + .unwrap_or_else(|error| panic!("{error:?}")); + let errors = super::expression_layer_errors(&expression, "css"); + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].0, 14); +} + +#[rstest] +#[case( + "css(css`@layer base { color: red; } @layer theme { color: green; }`, css`@layer base { color: blue; }`)", + "blue" +)] +#[case( + "css(css`@layer base { color: blue; }`, css`@layer base { color: red; } @layer theme { color: green; }`)", + "red" +)] +#[test] +#[serial] +fn later_declarations_win_only_in_the_same_layer_when_templates_compose( + #[case] expression: &str, + #[case] color: &str, +) { + use crate::extract_style::{ExtractStyleProperty, style_property::StyleProperty}; + let source = + format!("import {{ css }} from '@devup-ui/react';\nexport const result = {expression};"); + let output = extract( + "layer.tsx", + &source, + ExtractOption { + single_css: true, + ..ExtractOption::default() + }, + ) + .unwrap_or_else(|error| panic!("{error}")); + let classes = output + .code + .split("const result = ") + .nth(1) + .unwrap_or_else(|| panic!("missing result: {}", output.code)); + let classes: String = serde_json::from_str(classes.trim().trim_end_matches(';')) + .unwrap_or_else(|error| panic!("{error}: {classes}")); + let mut layers: Vec<_> = output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Static(style) => match style.extract(None) { + StyleProperty::ClassName(name) + if classes.split_whitespace().any(|class| class == name) => + { + Some((style.layer.as_deref(), style.value.as_str())) + } + _ => None, + }, + _ => None, + }) + .collect(); + layers.sort(); + assert_eq!( + layers, + vec![(Some("base"), color), (Some("theme"), "green")] + ); +} diff --git a/libs/extractor/src/css_utils/component_layers.rs b/libs/extractor/src/css_utils/component_layers.rs new file mode 100644 index 000000000..1c69daf63 --- /dev/null +++ b/libs/extractor/src/css_utils/component_layers.rs @@ -0,0 +1,141 @@ +use super::CssToStyleResult; +use crate::extract_style::{ + extract_dynamic_style::ExtractDynamicStyle, extract_static_style::ExtractStaticStyle, +}; +use crate::utils::{build_time_error, get_string_by_literal_expression, readable_code}; +use oxc_ast::ast::{Expression, TemplateLiteral}; + +pub(crate) const LAYER_ORDER_REQUIREMENT: &str = "layer order statements belong in `globalCss`, not component styles; declare the order with `globalCss` and use named `@layer` blocks here"; +pub(super) const LAYER_NAME_REQUIREMENT: &str = "name the layer, for example `@layer components { ... }`: component layer names must be dot-separated CSS identifiers, not CSS-wide reserved keywords; declare layer order with `globalCss`"; + +pub(crate) fn template_layer_errors( + template: &TemplateLiteral<'_>, + api: &str, +) -> Vec<(u32, String)> { + let mut text = String::new(); + let mut source = Vec::new(); + for (index, quasi) in template.quasis.iter().enumerate() { + source.push((text.len(), quasi.span.start)); + text.push_str(&quasi.value.raw); + if let Some(expression) = template.expressions.get(index) { + text.push_str(&get_string_by_literal_expression(expression).map_or_else( + || format!("${{{}}}", readable_code(expression)), + std::borrow::Cow::into_owned, + )); + } + } + statement_errors(&text, api, |offset| { + source + .iter() + .rev() + .find(|(start, _)| *start <= offset) + .and_then(|(start, original)| { + u32::try_from(offset - start) + .ok() + .and_then(|delta| original.checked_add(delta)) + }) + .unwrap_or(template.span.start) + }) +} + +pub(crate) fn expression_layer_errors( + expression: &Expression<'_>, + api: &str, +) -> Vec<(u32, String)> { + match crate::utils::unwrap_syntax_only(expression) { + Expression::TemplateLiteral(template) => template_layer_errors(template, api), + Expression::StringLiteral(literal) => { + statement_errors(&literal.value, api, |_| literal.span.start) + } + Expression::ArrayExpression(array) => array + .elements + .iter() + .filter_map(|element| element.as_expression()) + .flat_map(|part| expression_layer_errors(part, api)) + .collect(), + Expression::ConditionalExpression(choice) => [&choice.consequent, &choice.alternate] + .into_iter() + .flat_map(|part| expression_layer_errors(part, api)) + .collect(), + Expression::LogicalExpression(logical) => expression_layer_errors(&logical.right, api), + Expression::ObjectExpression(_) => super::object_layer_errors(expression, api), + _ => vec![], + } +} + +fn statement_errors(text: &str, api: &str, offset: impl Fn(usize) -> u32) -> Vec<(u32, String)> { + let mut errors = Vec::new(); + let mut from = 0; + for (index, character) in super::layer_blocks::boundaries(text) { + let head = &text[from..index]; + let trimmed = trim_trivia(head); + if let Some(name) = layer_prelude(trimmed) { + let requirement = match character { + ';' => Some(LAYER_ORDER_REQUIREMENT), + '{' if super::parse_layer_name(name).is_none() => Some(LAYER_NAME_REQUIREMENT), + _ => None, + }; + if let Some(requirement) = requirement { + errors.push(( + offset(from + head.len() - trimmed.len()), + build_time_error(api, &format!("{trimmed}{character}"), requirement), + )); + } + } + from = index + character.len_utf8(); + } + errors +} + +pub(super) fn trim_trivia(mut text: &str) -> &str { + loop { + text = text.trim_start(); + match text + .strip_prefix("/*") + .and_then(|comment| comment.find("*/").map(|end| &comment[end + 2..])) + { + Some(rest) => text = rest, + None => return text, + } + } +} + +pub(super) fn named_layer(prelude: &str) -> Option { + super::parse_layer_name(layer_prelude(prelude)?) +} + +fn layer_prelude(prelude: &str) -> Option<&str> { + if !prelude + .get(..6) + .is_some_and(|keyword| keyword.eq_ignore_ascii_case("@layer")) + { + return None; + } + prelude.get(6..).filter(|rest| { + rest.is_empty() + || rest.starts_with([' ', '\t', '\n', '\r', '\u{c}']) + || rest.starts_with("/*") + }) +} + +pub(crate) fn nest_layer(layer: &str, inner: &mut Option) { + *inner = Some(match inner.take() { + Some(inner) => format!("{layer}.{inner}"), + None => layer.to_string(), + }); +} + +pub(super) fn dynamic_from(style: &ExtractStaticStyle, identifier: &str) -> CssToStyleResult { + let mut dynamic = ExtractDynamicStyle::new( + style.property(), + style.level(), + identifier, + style.selector().cloned(), + ); + dynamic.layer.clone_from(&style.layer); + CssToStyleResult::Dynamic(dynamic) +} + +#[cfg(test)] +#[path = "component_layer_coverage_tests.rs"] +mod coverage_tests; diff --git a/libs/extractor/src/css_utils/layer_blocks.rs b/libs/extractor/src/css_utils/layer_blocks.rs new file mode 100644 index 000000000..59c37aaf2 --- /dev/null +++ b/libs/extractor/src/css_utils/layer_blocks.rs @@ -0,0 +1,44 @@ +pub(super) fn boundaries(text: &str) -> impl Iterator + '_ { + let mut characters = text.char_indices().peekable(); + let mut quote = None; + let mut escaped = false; + let mut comment = false; + let mut parentheses = 0usize; + std::iter::from_fn(move || { + while let Some((index, character)) = characters.next() { + if comment { + if character == '*' && characters.peek().is_some_and(|(_, next)| *next == '/') { + characters.next(); + comment = false; + } + continue; + } + if escaped { + escaped = false; + continue; + } + if character == '\\' { + escaped = true; + continue; + } + if let Some(end) = quote { + if character == end { + quote = None; + } + continue; + } + match character { + '/' if characters.peek().is_some_and(|(_, next)| *next == '*') => { + characters.next(); + comment = true; + } + '\'' | '"' => quote = Some(character), + '(' => parentheses += 1, + ')' => parentheses = parentheses.saturating_sub(1), + ';' | '{' | '}' if parentheses == 0 => return Some((index, character)), + _ => {} + } + } + None + }) +} diff --git a/libs/extractor/src/css_utils/layer_names.rs b/libs/extractor/src/css_utils/layer_names.rs new file mode 100644 index 000000000..a0cfefdb1 --- /dev/null +++ b/libs/extractor/src/css_utils/layer_names.rs @@ -0,0 +1,159 @@ +use std::iter::Peekable; +use std::str::Chars; + +const fn name_start(character: char) -> bool { + character.is_ascii_alphabetic() || character == '_' || character >= '\u{80}' +} + +fn escape(characters: &mut Peekable>) -> Option { + let first = characters.next()?; + if matches!(first, '\n' | '\r' | '\u{c}') { + return None; + } + if let Some(mut value) = first.to_digit(16) { + for _ in 1..6 { + let Some(digit) = characters + .peek() + .and_then(|character| character.to_digit(16)) + else { + break; + }; + characters.next(); + value = value * 16 + digit; + } + if characters + .peek() + .is_some_and(|character| matches!(character, ' ' | '\t' | '\n' | '\r' | '\u{c}')) + { + let whitespace = characters.next(); + if whitespace == Some('\r') && characters.peek() == Some(&'\n') { + characters.next(); + } + } + Some( + char::from_u32(value) + .filter(|character| *character != '\0') + .unwrap_or('\u{fffd}'), + ) + } else { + Some(first) + } +} + +fn identifier(characters: &mut Peekable>) -> Option { + let first = characters.next()?; + let mut decoded = String::new(); + match first { + '\\' => decoded.push(escape(characters)?), + '-' => { + if !characters + .peek() + .is_some_and(|next| name_start(*next) || matches!(next, '-' | '\\')) + { + return None; + } + decoded.push(first); + } + character if name_start(character) => decoded.push(character), + _ => return None, + } + while let Some(character) = characters.peek().copied() { + if character == '\\' { + characters.next(); + decoded.push(escape(characters)?); + } else if name_start(character) || character.is_ascii_digit() || character == '-' { + characters.next(); + decoded.push(character); + } else { + break; + } + } + if ["initial", "inherit", "unset", "revert", "revert-layer"] + .iter() + .any(|reserved| decoded.eq_ignore_ascii_case(reserved)) + { + return None; + } + Some(serialize(&decoded)) +} + +fn serialize(decoded: &str) -> String { + let mut result = String::new(); + let lone_hyphen = decoded == "-"; + let leading_hyphen = decoded.starts_with('-'); + for (index, character) in decoded.chars().enumerate() { + if character.is_ascii_digit() && (index == 0 || index == 1 && leading_hyphen) + || character <= '\u{1f}' + || character == '\u{7f}' + { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut bytes = [0; 4]; + character.encode_utf8(&mut bytes); + let value = bytes[0]; + result.push('\\'); + if value >= 16 { + result.push(char::from(HEX[usize::from(value >> 4)])); + } + result.push(char::from(HEX[usize::from(value & 15)])); + result.push(' '); + } else if name_start(character) + || character.is_ascii_digit() + || character == '-' && !lone_hyphen + { + result.push(character); + } else { + result.push('\\'); + result.push(character); + } + } + result +} + +fn skip_trivia(characters: &mut Peekable>, whitespace: bool) -> Option<()> { + loop { + while whitespace + && characters + .peek() + .is_some_and(|character| matches!(character, ' ' | '\t' | '\n' | '\r' | '\u{c}')) + { + characters.next(); + } + if characters.peek() != Some(&'/') { + return Some(()); + } + let mut comment = characters.clone(); + comment.next(); + if comment.next() != Some('*') { + return Some(()); + } + loop { + if comment.next()? == '*' && comment.peek() == Some(&'/') { + comment.next(); + *characters = comment; + break; + } + } + } +} + +/// Canonical CSS serialization keeps escaped dots within an identifier distinct +/// from dots separating nested layers, while equivalent escapes share metadata. +pub(crate) fn parse(name: &str) -> Option { + let mut characters = name.chars().peekable(); + let mut parts = Vec::new(); + skip_trivia(&mut characters, true)?; + loop { + skip_trivia(&mut characters, false)?; + parts.push(identifier(&mut characters)?); + skip_trivia(&mut characters, false)?; + match characters.next() { + Some('.') => {} + None => return Some(parts.join(".")), + Some(' ' | '\t' | '\n' | '\r' | '\u{c}') => { + skip_trivia(&mut characters, true)?; + return characters.next().is_none().then(|| parts.join(".")); + } + Some(_) => return None, + } + } +} diff --git a/libs/extractor/src/extractor/extract_style_from_expression.rs b/libs/extractor/src/extractor/extract_style_from_expression.rs index 0b4b43d40..909aa839e 100644 --- a/libs/extractor/src/extractor/extract_style_from_expression.rs +++ b/libs/extractor/src/extractor/extract_style_from_expression.rs @@ -435,6 +435,7 @@ pub fn extract_style_from_expression<'a>( literal_handling, ) .styles; + let layer = crate::css_utils::parse_layer_name(&layer).unwrap_or(layer); place_in_layer(&mut styles, &layer); props.extend(styles); } @@ -1046,12 +1047,7 @@ pub fn extract_style_from_expression<'a>( /// Put every static declaration of `props` in `layer`, nesting the layer one /// already sits in pub(crate) fn place_in_layer(props: &mut [ExtractStyleProp<'_>], layer: &str) { - let nest = |inner: &mut Option| { - *inner = Some(match inner.take() { - Some(inner) => format!("{layer}.{inner}"), - None => layer.to_string(), - }); - }; + let nest = |inner: &mut Option| crate::css_utils::nest_layer(layer, inner); for prop in props { match prop { ExtractStyleProp::Static(ExtractStyleValue::Static(style)) => nest(&mut style.layer), diff --git a/libs/extractor/src/extractor/extract_style_from_styled.rs b/libs/extractor/src/extractor/extract_style_from_styled.rs index d50b62950..72c2645eb 100644 --- a/libs/extractor/src/extractor/extract_style_from_styled.rs +++ b/libs/extractor/src/extractor/extract_style_from_styled.rs @@ -3,7 +3,6 @@ use rustc_hash::FxHashMap; use crate::{ ExtractStyleProp, component::ExportVariableKind, - css_utils::{TemplateStyles, css_to_style_template}, extract_style::extract_style_value::ExtractStyleValue, extractor::{ ExtractResult, @@ -11,12 +10,12 @@ use crate::{ }, gen_class_name::{gen_class_names, merge_expression_for_class_name}, gen_style::gen_styles, - styled_reads::{Forward, Reads, withheld}, + styled_reads::{Forward, Reads, withheld as props_withheld}, utils::{ STYLE_OBJECT, StyleArguments, build_time_error, call_with_values, expression_to_code, merge_object_expressions, readable_code, reads_directly, style_arguments, - uncomposable_error, unplaced_error, unreadable_styles, unwrap_syntax_only, - unwrap_syntax_only_mut, wrap_array_filter, wrap_direct_call, + uncomposable_error, unreadable_styles, unwrap_syntax_only, unwrap_syntax_only_mut, + wrap_array_filter, wrap_direct_call, }, }; use oxc_allocator::{CloneIn, FromIn, GetAllocator}; @@ -34,6 +33,15 @@ use oxc_syntax::operator::{BinaryOperator, LogicalOperator, UnaryOperator}; const PROPS_FUNCTION: &str = "a style function of the props must give one rule object at once, with every entry written out, as `(props) => ({ color: props.color })`"; +#[path = "styled_rule_choices.rs"] +mod rule_choices; + +#[path = "styled_template_composition.rs"] +mod template_composition; + +#[path = "styled_rule_parts.rs"] +mod rule_parts; + /// The rules `style`, an argument of a styled component, gives, with each /// value a function of the props written as the value of a CSS variable the /// component sets: a function giving rules becomes the rules with each value @@ -42,6 +50,7 @@ const PROPS_FUNCTION: &str = "a style function of the props must give one rule o fn rules_reading_props<'a>( ast_builder: &AstBuilder<'a>, style: &mut Expression<'a>, + bindings: &StyledBindings<'_>, ) -> Result<(), Expression<'a>> { use oxc_allocator::TakeIn; @@ -54,10 +63,9 @@ fn rules_reading_props<'a>( .ok_or_else(|| code.clone_in(ast_builder.allocator()))?; let params = params.clone_in_with_semantic_ids(ast_builder.allocator()); let mut rules = returned.take_in(ast_builder); - let Expression::ObjectExpression(object) = unwrap_syntax_only_mut(&mut rules) else { - return Err(code); - }; - props_leaves(ast_builder, object, ¶ms).ok_or(code)?; + rules = rule_choices::normalize(ast_builder, &rules, bindings) + .map_err(|_| code.clone_in(ast_builder.allocator()))?; + props_rules(ast_builder, &mut rules, ¶ms).ok_or(code)?; *style = rules; } if let Expression::ObjectExpression(object) = unwrap_syntax_only_mut(style) { @@ -66,6 +74,35 @@ fn rules_reading_props<'a>( Ok(()) } +/// Written rule branches lowered independently, so an omitted property keeps +/// the atom preceding it rather than setting an absent CSS variable. +fn props_rules<'a>( + ast_builder: &AstBuilder<'a>, + rules: &mut Expression<'a>, + params: &FormalParameters<'a>, +) -> Option<()> { + use oxc_allocator::TakeIn; + + let rules = unwrap_syntax_only_mut(rules); + match rules { + Expression::ObjectExpression(object) => { + props_leaves(ast_builder, object, params)?; + call_with_props(ast_builder, object); + } + Expression::ConditionalExpression(conditional) => { + props_rules(ast_builder, &mut conditional.consequent, params)?; + props_rules(ast_builder, &mut conditional.alternate, params)?; + let test = conditional.test.take_in(ast_builder); + let function = props_function(ast_builder, params, test, "opacity"); + conditional.test = + wrap_direct_call(ast_builder, &function, &[identifier(ast_builder, "rest")]); + } + Expression::NullLiteral(_) => {} + _ => return None, + } + Some(()) +} + /// Whether `value` is a literal, which reads the same given any props fn is_written_out(value: &Expression<'_>) -> bool { let value = unwrap_syntax_only(value); @@ -268,12 +305,41 @@ pub struct Naming<'s> { pub markers: &'s [String], } +/// The bindings styled factories and their CSS mixins read in this module. +pub struct StyledBindings<'s> { + pub imports: &'s FxHashMap, + pub values: &'s crate::style_values::StyleValues, + pub inline_css: &'s FxHashMap>, +} + +impl StyledBindings<'_> { + fn styles(&self, expression: &Expression<'_>) -> Option<&[ExtractStyleValue]> { + self.values + .styles(unwrap_syntax_only(expression)) + .or_else(|| { + self.inline_css + .get(&expression.span().start) + .map(Vec::as_slice) + }) + } +} + /// Whether a styled component renders a tag, which takes only valid /// attributes, rather than a component fn renders_tag(name: &str, bound: Option<&Expression<'_>>) -> bool { bound.is_none() && name.starts_with(|c: char| c.is_ascii_lowercase()) && !name.contains('.') } +/// The shared legacy whitelist includes `on`, but a native tag cannot take +/// that styling flag as a boolean attribute. Explicit forwarding stays intact. +fn withheld(reads: &Reads, renders_tag: bool, forward: Option<&Forward>) -> Vec { + let mut names = props_withheld(reads, renders_tag, forward); + if renders_tag && forward.is_none() && reads.names.iter().any(|name| name == "on") { + names.push("on".to_string()); + } + names +} + /// `own` applying after `inherited`: a prop passes only when both pass it fn combine_forward(inherited: Option<&Forward>, own: Option) -> Option { match (inherited.cloned(), own) { @@ -723,11 +789,12 @@ pub fn extract_style_from_styled<'a>( ast_builder: &AstBuilder<'a>, expression: &mut Expression<'a>, naming: Naming<'_>, - imports: &FxHashMap, + bindings: StyledBindings<'_>, attrs: &[Expression<'a>], inherited: Option<&StyledDefinition<'a>>, forward: Option, ) -> StyledExtraction<'a> { + let imports = bindings.imports; let Naming { split_filename, markers, @@ -738,13 +805,21 @@ pub fn extract_style_from_styled<'a>( reads.read_in(attr); } let mut composed_classes = Vec::new(); + let mut composed_rules = None; let mut errors = Vec::new(); + errors.extend(crate::css_utils::object_layer_errors(expression, "styled")); if let Expression::CallExpression(call) = expression && extract_base_tag_and_class_name(ast_builder, &call.callee, imports).is_some() { + for argument in &call.arguments { + reads.read_in(match argument { + Argument::SpreadElement(spread) => &spread.argument, + argument => argument.to_expression(), + }); + } for argument in &mut call.arguments { if let Some(rules) = argument.as_expression_mut() - && let Err(code) = rules_reading_props(ast_builder, rules) + && let Err(code) = rules_reading_props(ast_builder, rules, &bindings) { errors.push(( code.span().start, @@ -763,6 +838,21 @@ pub fn extract_style_from_styled<'a>( oxc_allocator::Vec::from_array_in([Argument::from(rules)], ast_builder); composed_classes = classes; } + None if !reads_directly(&call.arguments) + && let Some((rules, classes)) = + rule_parts::compose(ast_builder, &call.arguments) => + { + composed_rules = Some(rules); + composed_classes = classes; + call.arguments = oxc_allocator::Vec::from_array_in( + [Argument::from(Expression::new_object_expression( + SPAN, + oxc_allocator::Vec::new_in(ast_builder), + ast_builder, + ))], + ast_builder, + ); + } None if !reads_directly(&call.arguments) => { errors.push((call.span.start, uncomposable_error(&call.arguments))); call.arguments = oxc_allocator::Vec::from_array_in( @@ -781,6 +871,9 @@ pub fn extract_style_from_styled<'a>( expression && let Some(mut base) = extract_base_tag_and_class_name(ast_builder, &tag.tag, imports) { + errors.extend(crate::css_utils::template_layer_errors( + &tag.quasi, "styled", + )); // Case 1: styled.div`css` or styled("div")`css` // Check if tag is styled.div or styled(...) // Extract CSS from template literal @@ -788,19 +881,12 @@ pub fn extract_style_from_styled<'a>( for interpolation in &tag.quasi.expressions { reads.read_in(interpolation); } - let TemplateStyles { - styles, + let template_composition::TemplateComposition { + styles: own, statements, - unplaced, - } = css_to_style_template(&tag.quasi, 0, &None); - for index in unplaced { - let expression = &tag.quasi.expressions[index]; - errors.push((expression.span().start, unplaced_error(expression))); - } - let own: Vec> = styles - .into_iter() - .map(|ex| ExtractStyleProp::Static(ex.into())) - .collect(); + errors: template_errors, + } = template_composition::compose(ast_builder, &tag.quasi, &bindings); + errors.extend(template_errors); let defaults = base.styles.take(); let base = base.extending(ast_builder, inherited); let mut props_styles = compose_styles(ast_builder, inherited, defaults, own); @@ -888,18 +974,20 @@ pub fn extract_style_from_styled<'a>( style_vars, props, .. - } = extract_style_from_expression( - ast_builder, - None, - if let Argument::SpreadElement(spread) = &mut call.arguments[style_index] { - &mut spread.argument - } else { - call.arguments[style_index].to_expression_mut() - }, - 0, - &None, - LiteralHandling::ExpandResponsiveThemeToken, - ); + } = composed_rules.unwrap_or_else(|| { + extract_style_from_expression( + ast_builder, + None, + if let Argument::SpreadElement(spread) = &mut call.arguments[style_index] { + &mut spread.argument + } else { + call.arguments[style_index].to_expression_mut() + }, + 0, + &None, + LiteralHandling::ExpandResponsiveThemeToken, + ) + }); let mut unreadable = Vec::new(); unreadable_styles(&styles, true, &mut unreadable); errors.extend( @@ -1016,7 +1104,7 @@ fn style_expressions<'p, 'a>(prop: &'p ExtractStyleProp<'a>) -> Vec<&'p Expressi /// declare, the props, and what every module reads the same fn closed(code: impl Iterator) -> bool { let source = code - .map(|code| ["(", &code, ");\n"].concat()) + .map(|code| ["(", crate::css_utils::rm_last_semi_colon(&code), ");\n"].concat()) .collect::(); let allocator = oxc_allocator::Allocator::default(); let ParserReturn { @@ -1806,6 +1894,14 @@ fn create_styled_component<'a>( ) } +#[cfg(test)] +#[path = "w27_props_rule_choices_tests.rs"] +mod w27_props_rule_choices_tests; + +#[cfg(test)] +#[path = "w27_styled_known_mixins_tests.rs"] +mod w27_styled_known_mixins_tests; + #[cfg(test)] #[allow(clippy::expect_used, clippy::unwrap_used)] mod tests { @@ -1865,6 +1961,8 @@ mod tests { "((p) => p.on)(rest)" ])); assert!(closed_code(&["(p: Props) => p.x"])); + assert!(closed_code(&["({ id: `known` });", "((p) => p.on)(rest);"])); + assert!(!closed_code(&["({ onClick: handler });"])); assert!(!closed_code(&["(p) => scale(p.x)"])); assert!(!closed_code(&["(p) => p.x", "window.mode"])); assert!(!closed_code(&["@#$"])); diff --git a/libs/extractor/src/extractor/styled_choice_test_support.rs b/libs/extractor/src/extractor/styled_choice_test_support.rs new file mode 100644 index 000000000..1e7a6b895 --- /dev/null +++ b/libs/extractor/src/extractor/styled_choice_test_support.rs @@ -0,0 +1,108 @@ +use crate::extract_style::ExtractStyleProperty; +use crate::extract_style::style_property::StyleProperty; +use crate::{ExtractOutput, ExtractStyleValue}; +use oxc_ast::ast::{JSXAttribute, JSXAttributeValue}; +use oxc_ast_visit::Visit; + +#[derive(Default)] +struct Classes(String); + +impl<'a> Visit<'a> for Classes { + fn visit_jsx_attribute(&mut self, attribute: &JSXAttribute<'a>) { + if attribute + .name + .as_identifier() + .is_some_and(|name| name.name == "className") + && let Some(JSXAttributeValue::ExpressionContainer(value)) = &attribute.value + && let Some(expression) = value.expression.as_expression() + { + self.0 = + crate::css_utils::rm_last_semi_colon(&crate::utils::expression_to_code(expression)) + .to_string(); + } + } +} + +pub(crate) fn active(output: &ExtractOutput, props: &str) -> Vec { + let allocator = oxc_allocator::Allocator::default(); + let parsed = + oxc_parser::Parser::new(&allocator, &output.code, oxc_span::SourceType::tsx()).parse(); + assert_eq!(parsed.diagnostics.len(), 0); + let mut classes = Classes::default(); + classes.visit_program(&parsed.program); + assert_ne!(classes.0, "", "expected generated className expression"); + let names = evaluate(&classes.0, props); + output + .styles + .iter() + .filter(|value| { + let class = match value { + ExtractStyleValue::Static(style) => style.extract(None).to_string(), + ExtractStyleValue::Dynamic(style) => { + let property = style.extract(None); + assert!( + matches!(property, StyleProperty::Variable { .. }), + "dynamic atom requires a variable" + ); + match property { + StyleProperty::Variable { class_name, .. } + | StyleProperty::ClassName(class_name) => class_name, + } + } + other => panic!("expected static atom, got {other:?}"), + }; + names.split_whitespace().any(|name| name == class) + }) + .cloned() + .collect() +} + +pub(crate) fn evaluate(expression: &str, props: &str) -> String { + let code = format!("const rest = {props}; const className = ''; ({expression});"); + let mut context = boa_engine::Context::default(); + context + .eval(boa_engine::Source::from_bytes(&code)) + .unwrap_or_else(|error| panic!("{error}: {code}")) + .to_string(&mut context) + .unwrap_or_else(|error| panic!("{error}")) + .to_std_string_escaped() +} + +pub(crate) fn declarations(styles: &[ExtractStyleValue]) -> Vec<(String, String)> { + let mut declarations: Vec<_> = styles + .iter() + .map(|value| match value { + ExtractStyleValue::Static(style) => { + (style.property().to_string(), style.value().to_string()) + } + other => panic!("expected static atom, got {other:?}"), + }) + .collect(); + declarations.sort(); + declarations +} + +#[rstest::rstest] +#[case("
")] +#[case("
")] +#[case("
")] +#[case("
")] +fn active_oracle_when_generated_class_expression_is_missing_rejects( + #[case] jsx: &str, +) -> Result<(), Box> { + // Given + let mut output = crate::extract( + "oracle.tsx", + "const value = 1;", + crate::ExtractOption::default(), + )?; + output.code = format!("const Choice = () => {jsx};"); + // When + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| active(&output, "{}"))); + // Then + assert!( + result.is_err(), + "missing classes must not become empty styles" + ); + Ok(()) +} diff --git a/libs/extractor/src/extractor/styled_mixin_context.rs b/libs/extractor/src/extractor/styled_mixin_context.rs new file mode 100644 index 000000000..7cfcf6ed8 --- /dev/null +++ b/libs/extractor/src/extractor/styled_mixin_context.rs @@ -0,0 +1,135 @@ +use crate::extract_style::{ + extract_dynamic_style::ExtractDynamicStyle, extract_static_style::ExtractStaticStyle, +}; +use crate::{ExtractStyleProp, ExtractStyleValue}; +use css::style_selector::StyleSelector; + +pub(super) enum ContextError { + Never, + Opaque, +} + +#[cfg(test)] +#[path = "w27_styled_context_coverage.rs"] +mod coverage_tests; + +pub(super) fn contextualize<'a>( + props: Vec>, + context: &[String], +) -> Result>, ContextError> { + if context.is_empty() { + return Ok(props); + } + let text = format!( + "{}--devupContext:0;{}", + context.concat(), + "}".repeat(context.len()) + ); + let parsed = crate::css_utils::css_to_style(&text, 0, &None); + let Some(parent) = parsed.first() else { + return Ok(Vec::new()); + }; + props.into_iter().map(|prop| place(prop, parent)).collect() +} + +fn nest( + parent: Option<&StyleSelector>, + child: Option<&StyleSelector>, +) -> Result, ContextError> { + let mut selector = parent.cloned(); + match child { + None => {} + Some(StyleSelector::Selector(child)) => { + selector = Some(StyleSelector::nest_selector(selector.as_ref(), child)); + } + Some(StyleSelector::At { + outer, + kind, + query, + selector: child, + .. + }) => { + for rule in outer { + selector = Some( + StyleSelector::nest_at_rule(selector.as_ref(), rule.kind, &rule.query) + .ok_or(ContextError::Never)?, + ); + } + selector = Some( + StyleSelector::nest_at_rule(selector.as_ref(), *kind, query) + .ok_or(ContextError::Never)?, + ); + if let Some(child) = child { + selector = Some(StyleSelector::nest_selector(selector.as_ref(), child)); + } + } + Some(StyleSelector::Global(_, _)) => return Err(ContextError::Opaque), + } + Ok(selector) +} + +fn place<'a>( + prop: ExtractStyleProp<'a>, + parent: &ExtractStaticStyle, +) -> Result, ContextError> { + match prop { + ExtractStyleProp::Static(ExtractStyleValue::Static(mut style)) => { + style.selector = match nest(parent.selector(), style.selector()) { + Ok(selector) => selector, + Err(ContextError::Never) => return Ok(ExtractStyleProp::StaticArray(Vec::new())), + Err(ContextError::Opaque) => return Err(ContextError::Opaque), + }; + if let Some(layer) = parent.layer() { + crate::css_utils::nest_layer(layer, &mut style.layer); + } + Ok(ExtractStyleProp::Static(ExtractStyleValue::Static(style))) + } + ExtractStyleProp::Static(ExtractStyleValue::Dynamic(style)) => { + let selector = match nest(parent.selector(), style.selector()) { + Ok(selector) => selector, + Err(ContextError::Never) => return Ok(ExtractStyleProp::StaticArray(Vec::new())), + Err(ContextError::Opaque) => return Err(ContextError::Opaque), + }; + let identifier = if style.important() { + format!("{} !important", style.identifier()) + } else { + style.identifier().to_string() + }; + let mut nested = + ExtractDynamicStyle::new(style.property(), style.level(), &identifier, selector); + nested.layer = style.layer().map(str::to_string); + if let Some(layer) = parent.layer() { + crate::css_utils::nest_layer(layer, &mut nested.layer); + } + let mut value = ExtractStyleValue::Dynamic(nested); + if let Some(order) = style.style_order() { + value.set_style_order(order); + } + Ok(ExtractStyleProp::Static(value)) + } + ExtractStyleProp::StaticArray(props) => Ok(ExtractStyleProp::StaticArray( + props + .into_iter() + .map(|prop| place(prop, parent)) + .collect::>()?, + )), + ExtractStyleProp::Conditional { + condition, + consequent, + alternate, + } => Ok(ExtractStyleProp::Conditional { + condition, + consequent: consequent + .map(|prop| place(*prop, parent).map(Box::new)) + .transpose()?, + alternate: alternate + .map(|prop| place(*prop, parent).map(Box::new)) + .transpose()?, + }), + ExtractStyleProp::Static(_) + | ExtractStyleProp::Expression { .. } + | ExtractStyleProp::Enum { .. } + | ExtractStyleProp::MemberExpression { .. } + | ExtractStyleProp::Unreadable { .. } => Err(ContextError::Opaque), + } +} diff --git a/libs/extractor/src/extractor/styled_rule_choices.rs b/libs/extractor/src/extractor/styled_rule_choices.rs new file mode 100644 index 000000000..0dccc96db --- /dev/null +++ b/libs/extractor/src/extractor/styled_rule_choices.rs @@ -0,0 +1,242 @@ +use oxc_allocator::{CloneIn, GetAllocator}; +use oxc_ast::{ast::Expression, builder::AstBuilder}; +use oxc_span::{GetSpan, SPAN}; +use oxc_syntax::operator::{BinaryOperator, LogicalOperator, UnaryOperator}; + +use super::StyledBindings; +use crate::utils::{is_pure, unwrap_syntax_only}; + +#[cfg(test)] +#[path = "w27_styled_choice_coverage.rs"] +mod coverage_tests; + +enum Choice<'a> { + Rules { + value: Expression<'a>, + truthy: bool, + }, + Scalar { + value: Expression<'a>, + truthy: Option, + nullish: Option, + }, + Branch { + test: Expression<'a>, + yes: Box>, + no: Box>, + }, +} + +struct ShortCircuit<'s, 'a, 'b> { + operator: LogicalOperator, + right: &'s Expression<'a>, + bindings: &'s StyledBindings<'b>, +} + +pub(super) enum RuleError { + Opaque, + BoundUndefined, +} + +pub(super) fn normalize<'a>( + ast: &AstBuilder<'a>, + value: &Expression<'a>, + bindings: &StyledBindings<'_>, +) -> Result, RuleError> { + emit(ast, read(ast, value, bindings)?).ok_or(RuleError::Opaque) +} + +fn read<'a>( + ast: &AstBuilder<'a>, + value: &Expression<'a>, + bindings: &StyledBindings<'_>, +) -> Result, RuleError> { + let value = unwrap_syntax_only(value); + if let Some(styles) = bindings.styles(value) { + return Ok(Choice::Rules { + value: value.clone_in_with_semantic_ids(ast.allocator()), + truthy: !styles.is_empty(), + }); + } + match value { + Expression::ObjectExpression(_) => Ok(Choice::Rules { + value: value.clone_in_with_semantic_ids(ast.allocator()), + truthy: true, + }), + Expression::ConditionalExpression(branch) if is_pure(&branch.test) => Ok(Choice::Branch { + test: branch.test.clone_in_with_semantic_ids(ast.allocator()), + yes: Box::new(read(ast, &branch.consequent, bindings)?), + no: Box::new(read(ast, &branch.alternate, bindings)?), + }), + Expression::LogicalExpression(logical) => { + let left = read(ast, &logical.left, bindings)?; + select( + ast, + left, + &ShortCircuit { + operator: logical.operator, + right: &logical.right, + bindings, + }, + ) + } + Expression::NullLiteral(_) => Ok(scalar(ast, value, (Some(false), Some(true)))), + Expression::UnaryExpression(unary) + if unary.operator == UnaryOperator::Void && is_pure(&unary.argument) => + { + Ok(scalar(ast, value, (Some(false), Some(true)))) + } + Expression::BooleanLiteral(literal) => { + Ok(scalar(ast, value, (Some(literal.value), Some(false)))) + } + Expression::Identifier(identifier) if identifier.name == "undefined" => { + if bindings.values.symbol(value).is_some() { + Err(RuleError::BoundUndefined) + } else if identifier.reference_id.get().is_some() { + Ok(scalar(ast, value, (Some(false), Some(true)))) + } else { + Err(RuleError::Opaque) + } + } + Expression::NumericLiteral(literal) => Ok(scalar( + ast, + value, + ( + Some(literal.value != 0.0 && !literal.value.is_nan()), + Some(false), + ), + )), + Expression::StringLiteral(literal) => Ok(scalar( + ast, + value, + (Some(!literal.value.is_empty()), Some(false)), + )), + value if is_pure(value) => Ok(scalar(ast, value, (None, None))), + _ => Err(RuleError::Opaque), + } +} + +fn scalar<'a>( + ast: &AstBuilder<'a>, + value: &Expression<'a>, + facts: (Option, Option), +) -> Choice<'a> { + let (truthy, nullish) = facts; + Choice::Scalar { + value: value.clone_in_with_semantic_ids(ast.allocator()), + truthy, + nullish, + } +} + +fn select<'a>( + ast: &AstBuilder<'a>, + left: Choice<'a>, + next: &ShortCircuit<'_, 'a, '_>, +) -> Result, RuleError> { + let right = || read(ast, next.right, next.bindings); + match left { + Choice::Branch { test, yes, no } => Ok(Choice::Branch { + test, + yes: Box::new(select(ast, *yes, next)?), + no: Box::new(select(ast, *no, next)?), + }), + left @ Choice::Rules { truthy, .. } => match next.operator { + LogicalOperator::And if truthy => right(), + LogicalOperator::Or if !truthy => right(), + LogicalOperator::And | LogicalOperator::Or | LogicalOperator::Coalesce => Ok(left), + }, + Choice::Scalar { + value, + truthy, + nullish, + } => { + let selected = match next.operator { + LogicalOperator::And => truthy.map(|truthy| !truthy), + LogicalOperator::Or => truthy, + LogicalOperator::Coalesce => nullish.map(|nullish| !nullish), + }; + let left = scalar(ast, &value, (truthy, nullish)); + match selected { + Some(true) => Ok(left), + Some(false) => right(), + None => { + let (test, yes, no) = match next.operator { + LogicalOperator::And => ( + value.clone_in_with_semantic_ids(ast.allocator()), + right()?, + scalar(ast, &value, (Some(false), nullish)), + ), + LogicalOperator::Or => ( + value.clone_in_with_semantic_ids(ast.allocator()), + scalar(ast, &value, (Some(true), Some(false))), + right()?, + ), + LogicalOperator::Coalesce => ( + is_nullish(ast, &value), + right()?, + scalar(ast, &value, (truthy, Some(false))), + ), + }; + Ok(Choice::Branch { + test, + yes: Box::new(yes), + no: Box::new(no), + }) + } + } + } + } +} + +fn is_nullish<'a>(ast: &AstBuilder<'a>, value: &Expression<'a>) -> Expression<'a> { + let compare = |right| { + Expression::new_binary_expression( + SPAN, + value.clone_in_with_semantic_ids(ast.allocator()), + BinaryOperator::StrictEquality, + right, + ast, + ) + }; + let undefined = Expression::new_unary_expression( + SPAN, + UnaryOperator::Void, + Expression::new_numeric_literal( + SPAN, + 0.0, + None, + oxc_syntax::number::NumberBase::Decimal, + ast, + ), + ast, + ); + Expression::new_logical_expression( + SPAN, + compare(Expression::new_null_literal(SPAN, ast)), + LogicalOperator::Or, + compare(undefined), + ast, + ) +} + +fn emit<'a>(ast: &AstBuilder<'a>, choice: Choice<'a>) -> Option> { + match choice { + Choice::Rules { value, .. } => Some(value), + Choice::Scalar { + value, + truthy, + nullish, + } => (truthy == Some(false) + || nullish == Some(true) + || matches!(value, Expression::BooleanLiteral(_))) + .then(|| Expression::new_null_literal(value.span(), ast)), + Choice::Branch { test, yes, no } => Some(Expression::new_conditional_expression( + test.span(), + test, + emit(ast, *yes)?, + emit(ast, *no)?, + ast, + )), + } +} diff --git a/libs/extractor/src/extractor/styled_rule_parts.rs b/libs/extractor/src/extractor/styled_rule_parts.rs new file mode 100644 index 000000000..441a72de7 --- /dev/null +++ b/libs/extractor/src/extractor/styled_rule_parts.rs @@ -0,0 +1,146 @@ +use super::{ExtractResult, LiteralHandling, extract_style_from_expression}; +use crate::{ + composition::Composition, + utils::{StyleArguments, style_arguments, unwrap_syntax_only}, +}; +use oxc_ast::{ + ast::{Argument, Expression}, + builder::AstBuilder, +}; + +pub(super) fn compose<'a>( + ast: &AstBuilder<'a>, + arguments: &[Argument<'a>], +) -> Option<(ExtractResult<'a>, Vec>)> { + let mut composition = Composition::default(); + let mut classes = Vec::new(); + let mut result = ExtractResult::default(); + for argument in arguments { + let mut rules = match argument.as_expression() { + Some(rules) if written(rules) => rules.clone_in_with_semantic_ids(ast.allocator()), + _ => { + let StyleArguments { + classes: own, + rules, + } = style_arguments(ast, std::slice::from_ref(argument))?; + classes.extend(own); + rules + } + }; + let part = extract_style_from_expression( + ast, + None, + &mut rules, + 0, + &None, + LiteralHandling::ExpandResponsiveThemeToken, + ); + composition.apply(ast, part.styles); + result.style_order = part.style_order.or(result.style_order); + result.style_vars = part.style_vars.or(result.style_vars); + result.props = part.props.or(result.props); + } + result.styles = composition.into_props(); + Some((result, classes)) +} + +fn written(rules: &Expression<'_>) -> bool { + match unwrap_syntax_only(rules) { + Expression::ObjectExpression(_) + | Expression::NullLiteral(_) + | Expression::BooleanLiteral(_) => true, + Expression::Identifier(identifier) => identifier.name == "undefined", + Expression::ConditionalExpression(branch) => { + written(&branch.consequent) && written(&branch.alternate) + } + _ => false, + } +} + +use oxc_allocator::{CloneIn, GetAllocator}; + +#[cfg(test)] +mod coverage_tests { + use super::*; + use crate::extract_style::{ExtractStyleProperty, extract_static_style::ExtractStaticStyle}; + + #[test] + fn undefined_part_when_next_to_written_choice_adds_no_style_or_class() + -> Result<(), Box> { + // Given + let allocator = oxc_allocator::Allocator::default(); + let ast = AstBuilder::new(&allocator); + let parsed = oxc_parser::Parser::new( + &allocator, + "parts(undefined, on ? { color: 'red' } : null)", + oxc_span::SourceType::tsx(), + ) + .parse_expression() + .map_err(|error| format!("failed to parse valid call: {error:?}"))?; + let Expression::CallExpression(call) = parsed else { + panic!("expected call") + }; + // When + let (result, classes) = + compose(&ast, &call.arguments).ok_or("written parts were unreadable")?; + // Then + assert_eq!(classes.len(), 0); + assert!(matches!( + result.styles.as_slice(), + [crate::ExtractStyleProp::Conditional { .. }] + )); + Ok(()) + } + + #[rstest::rstest] + #[case("true", "blue")] + #[case("false", "red")] + #[serial_test::serial] + fn opaque_class_when_paired_with_written_choice_preserves_selected_atom( + #[case] on: &str, + #[case] expected: &str, + ) -> Result<(), Box> { + // Given + let allocator = oxc_allocator::Allocator::default(); + let ast = AstBuilder::new(&allocator); + let parsed = oxc_parser::Parser::new( + &allocator, + "parts(external, { color: 'red' }, on ? { color: 'blue' } : null)", + oxc_span::SourceType::tsx(), + ) + .parse_expression() + .map_err(|error| format!("failed to parse valid call: {error:?}"))?; + let Expression::CallExpression(call) = parsed else { + panic!("expected call") + }; + // When + let (mut result, classes) = + compose(&ast, &call.arguments).ok_or("style parts were unreadable")?; + let class = crate::gen_class_name::gen_class_names(&ast, &mut result.styles, None, None) + .ok_or("selected atom produced no class")?; + let code = format!( + "const on = {on}; {}", + crate::utils::expression_to_code(&class) + ); + let mut context = boa_engine::Context::default(); + let selected = context + .eval(boa_engine::Source::from_bytes(&code)) + .map_err(|error| format!("choice failed to evaluate: {error}"))? + .to_string(&mut context) + .map_err(|error| format!("selected class failed string conversion: {error}"))? + .to_std_string_escaped(); + // Then + assert_eq!(classes.len(), 1); + assert_eq!( + crate::css_utils::rm_last_semi_colon(&crate::utils::expression_to_code(&classes[0])), + "external" + ); + assert_eq!( + selected, + ExtractStaticStyle::new("color", expected, 0, None) + .extract(None) + .to_string() + ); + Ok(()) + } +} diff --git a/libs/extractor/src/extractor/styled_template_composition.rs b/libs/extractor/src/extractor/styled_template_composition.rs new file mode 100644 index 000000000..de2dce0ea --- /dev/null +++ b/libs/extractor/src/extractor/styled_template_composition.rs @@ -0,0 +1,218 @@ +use super::{ + StyledBindings, call_with_props, identifier, props_function, props_leaves, rule_choices, +}; +use crate::{ + ExtractStyleProp, + composition::Composition, + css_utils::css_to_style_template, + utils::{build_time_error, readable_code, unplaced_error, unwrap_syntax_only}, +}; +use oxc_allocator::{CloneIn, GetAllocator}; +use oxc_ast::{ + ast::{Expression, FormalParameters, TemplateLiteral}, + builder::AstBuilder, +}; +use oxc_span::GetSpan; + +#[path = "styled_mixin_context.rs"] +mod context; +#[path = "styled_template_parts.rs"] +mod parts; + +pub(super) struct TemplateComposition<'a> { + pub styles: Vec>, + pub statements: Vec, + pub errors: Vec<(u32, String)>, +} + +#[cfg(test)] +#[path = "w27_styled_template_coverage.rs"] +mod coverage_tests; + +pub(super) fn compose<'a>( + ast: &AstBuilder<'a>, + template: &TemplateLiteral<'a>, + bindings: &StyledBindings<'_>, +) -> TemplateComposition<'a> { + let mut composition = Composition::default(); + let mut statements = Vec::new(); + let mut errors = Vec::new(); + for part in parts::split(ast, template, |expression| { + bindings.styles(expression).is_some() + }) { + match part { + parts::Part::Text(text) => composition.apply( + ast, + css_to_style_template(&text, 0, &None) + .styles + .into_iter() + .map(|style| ExtractStyleProp::Static(style.into())) + .collect(), + ), + parts::Part::Unplaced(index) => { + let expression = &template.expressions[index]; + errors.push((expression.span().start, unplaced_error(expression))); + } + parts::Part::Mixin { index, context } => { + let expression = &template.expressions[index]; + match mixin(ast, expression, bindings) { + Ok(Some(props)) => match context::contextualize(props, &context) { + Ok(props) => composition.apply(ast, props), + Err(context::ContextError::Never | context::ContextError::Opaque) => { + errors.push(nested_error(expression)); + } + }, + Ok(None) if context.is_empty() => statements.push(index), + Ok(None) => errors.push(nested_error(expression)), + Err(MixinError::Rules) => errors.push(( + expression.span().start, + build_time_error( + "styled", + &readable_code(expression), + crate::utils::STYLE_OBJECT, + ), + )), + } + } + } + } + TemplateComposition { + styles: composition.into_props(), + statements, + errors, + } +} + +fn nested_error(expression: &Expression<'_>) -> (u32, String) { + ( + expression.span().start, + build_time_error( + "styled", + &readable_code(expression), + "a nested mixin must give styles the build can read, so they inherit its enclosing selector, media query and layer", + ), + ) +} + +enum MixinError { + Rules, +} + +fn mixin<'a>( + ast: &AstBuilder<'a>, + expression: &Expression<'a>, + bindings: &StyledBindings<'_>, +) -> Result>>, MixinError> { + let mut expression = unwrap_syntax_only(expression).clone_in_with_semantic_ids(ast.allocator()); + let (params, rules) = match &expression { + Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_) => { + let Some((params, rules)) = crate::css_prop::render_function(&mut expression) else { + return Ok(None); + }; + ( + Some(params.clone_in_with_semantic_ids(ast.allocator())), + rules.clone_in_with_semantic_ids(ast.allocator()), + ) + } + _ => (None, expression), + }; + let mut lowered = match rule_choices::normalize(ast, &rules, bindings) { + Ok(lowered) => lowered, + Err(rule_choices::RuleError::BoundUndefined) => return Err(MixinError::Rules), + Err(rule_choices::RuleError::Opaque) => { + return if written_object(&rules) { + Err(MixinError::Rules) + } else { + Ok(None) + }; + } + }; + MixinReader { + ast, + bindings, + params: params.as_ref(), + } + .read(&mut lowered) + .map(Some) + .ok_or(MixinError::Rules) +} + +fn written_object(rules: &Expression<'_>) -> bool { + match unwrap_syntax_only(rules) { + Expression::ObjectExpression(_) => true, + Expression::ConditionalExpression(branch) => { + written_object(&branch.consequent) || written_object(&branch.alternate) + } + Expression::LogicalExpression(logical) => { + written_object(&logical.left) || written_object(&logical.right) + } + _ => false, + } +} + +struct MixinReader<'s, 'a, 'b> { + ast: &'s AstBuilder<'a>, + bindings: &'s StyledBindings<'b>, + params: Option<&'s FormalParameters<'a>>, +} + +impl<'a> MixinReader<'_, 'a, '_> { + fn read(&self, rules: &mut Expression<'a>) -> Option>> { + let Self { + ast, + bindings, + params, + } = *self; + if let Some(styles) = bindings.styles(rules) { + return Some( + styles + .iter() + .cloned() + .map(ExtractStyleProp::Static) + .collect(), + ); + } + match rules { + Expression::NullLiteral(_) => Some(Vec::new()), + Expression::ConditionalExpression(branch) => { + let test = match params { + Some(params) => crate::utils::wrap_direct_call( + ast, + &props_function( + ast, + params, + branch.test.clone_in_with_semantic_ids(ast.allocator()), + "opacity", + ), + &[identifier(ast, "rest")], + ), + None => branch.test.clone_in_with_semantic_ids(ast.allocator()), + }; + let yes = self.read(&mut branch.consequent)?; + let no = self.read(&mut branch.alternate)?; + let mut composition = Composition::default(); + composition.apply_conditional(ast, &test, yes, no); + Some(composition.into_props()) + } + Expression::ObjectExpression(object) => { + if let Some(params) = params { + props_leaves(ast, object, params)?; + } + call_with_props(ast, object); + let styles = super::extract_style_from_expression( + ast, + None, + rules, + 0, + &None, + super::LiteralHandling::ExpandResponsiveThemeToken, + ) + .styles; + let mut unreadable = Vec::new(); + crate::utils::unreadable_styles(&styles, true, &mut unreadable); + unreadable.is_empty().then_some(styles) + } + _ => None, + } + } +} diff --git a/libs/extractor/src/extractor/styled_template_parts.rs b/libs/extractor/src/extractor/styled_template_parts.rs new file mode 100644 index 000000000..e67d5206d --- /dev/null +++ b/libs/extractor/src/extractor/styled_template_parts.rs @@ -0,0 +1,170 @@ +use oxc_allocator::{CloneIn, FromIn, GetAllocator}; +use oxc_ast::{ + ast::{Expression, Str, TemplateElement, TemplateElementValue, TemplateLiteral}, + builder::AstBuilder, +}; + +// Use the CSS parser's lexer so quotes, escapes, comments and functions never +// turn data braces into enclosing rule contexts. +mod blocks { + include!("../css_utils/layer_blocks.rs"); +} + +pub(super) enum Part<'a> { + Text(TemplateLiteral<'a>), + Mixin { index: usize, context: Vec }, + Unplaced(usize), +} + +pub(super) fn split<'a>( + ast: &AstBuilder<'a>, + template: &TemplateLiteral<'a>, + known: impl Fn(&Expression<'a>) -> bool, +) -> Vec> { + let mut parts = Vec::new(); + let mut before = String::new(); + let mut from = 0; + let mut opening = Vec::new(); + for (index, expression) in template.expressions.iter().enumerate() { + before.push_str(&template.quasis[index].value.raw); + let place = place(&before, &template.quasis[index + 1..]); + match place { + crate::css_utils::Place::Statement + if known(expression) + || crate::utils::get_string_by_literal_expression(expression).is_none() => + { + let context = contexts(&before); + parts.push(Part::Text(segment( + ast, + template, + Segment { + range: from..=index, + opening: &opening, + close: context.len(), + }, + ))); + parts.push(Part::Mixin { + index, + context: context.clone(), + }); + from = index + 1; + opening = context; + before.push(';'); + } + crate::css_utils::Place::Value => before.push_str("__devupValue"), + _ => { + if let Some(text) = crate::utils::get_string_by_literal_expression(expression) { + before.push_str(&text); + } else { + parts.push(Part::Unplaced(index)); + } + } + } + } + parts.push(Part::Text(segment( + ast, + template, + Segment { + range: from..=template.expressions.len(), + opening: &opening, + close: 0, + }, + ))); + parts +} + +fn place(before: &str, after: &[TemplateElement<'_>]) -> crate::css_utils::Place { + let from = blocks::boundaries(before) + .last() + .map_or(0, |(index, _)| index + 1); + let head = css::rm_css_comment::rm_css_comment(&before[from..]); + let rest: String = after.iter().map(|quasi| quasi.value.raw.as_str()).collect(); + if blocks::boundaries(&rest) + .next() + .is_some_and(|(_, boundary)| boundary == '{') + { + return crate::css_utils::Place::Other; + } + if head.contains(':') { + crate::css_utils::Place::Value + } else if head.trim().is_empty() { + crate::css_utils::Place::Statement + } else { + crate::css_utils::Place::Other + } +} + +fn contexts(text: &str) -> Vec { + let mut stack = Vec::new(); + let mut from = 0; + for (index, boundary) in blocks::boundaries(text) { + match boundary { + '{' => stack.push(text[from..=index].to_string()), + '}' => { + stack.pop(); + } + _ => {} + } + from = index + 1; + } + stack +} + +struct Segment<'s> { + range: std::ops::RangeInclusive, + opening: &'s [String], + close: usize, +} + +fn segment<'a>( + ast: &AstBuilder<'a>, + template: &TemplateLiteral<'a>, + segment: Segment<'_>, +) -> TemplateLiteral<'a> { + let Segment { + range, + opening, + close, + } = segment; + let from = *range.start(); + let to = *range.end(); + let quasis = template.quasis[range] + .iter() + .enumerate() + .map(|(index, quasi)| { + let raw = format!( + "{}{}{}", + if index == 0 { + opening.concat() + } else { + String::new() + }, + quasi.value.raw, + if index == to - from { + "}".repeat(close) + } else { + String::new() + } + ); + TemplateElement::new( + quasi.span, + TemplateElementValue { + raw: Str::from_in(raw.as_str(), ast.allocator()), + cooked: None, + }, + index == to - from, + ast, + ) + }); + TemplateLiteral::new( + template.span, + oxc_allocator::Vec::from_iter_in(quasis, ast), + oxc_allocator::Vec::from_iter_in( + template.expressions[from..to] + .iter() + .map(|expression| expression.clone_in_with_semantic_ids(ast.allocator())), + ast, + ), + ast, + ) +} diff --git a/libs/extractor/src/extractor/w27_coverage_tests.rs b/libs/extractor/src/extractor/w27_coverage_tests.rs new file mode 100644 index 000000000..d3b3271c7 --- /dev/null +++ b/libs/extractor/src/extractor/w27_coverage_tests.rs @@ -0,0 +1,165 @@ +use crate::{ExtractOption, ExtractStyleValue, ResolvedModule, extract, extract_with_modules}; +use rstest::rstest; +use serial_test::serial; + +#[path = "w27_styled_composition_coverage.rs"] +mod styled_composition; + +#[test] +fn declaration_oracle_when_given_unsupported_atom_rejects_instead_of_filtering_it() { + // Given + let styles = [ExtractStyleValue::Typography("body".into())]; + // When + let result = std::panic::catch_unwind(|| { + super::super::w27_props_rule_choices_tests::support::declarations(&styles) + }); + // Then + assert!(result.is_err(), "unsupported atoms must fail the oracle"); +} + +#[test] +#[serial] +fn active_oracle_when_output_has_unsupported_atom_rejects_even_if_class_is_inactive() +-> Result<(), Box> { + // Given + let mut output = extract( + "oracle.tsx", + "import { styled } from '@devup-ui/react'; export const Choice = styled.div`color: red;`;", + super::option(), + )?; + output + .styles + .insert(ExtractStyleValue::Typography("body".into())); + // When + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + super::super::w27_props_rule_choices_tests::support::active(&output, "{}") + })); + // Then + assert!( + result.is_err(), + "inactive unsupported atoms must fail the oracle" + ); + Ok(()) +} + +#[test] +#[serial] +fn static_declaration_oracle_when_output_contains_runtime_values_rejects_it() +-> Result<(), Box> { + // Given + let source = "import styled from '@emotion/styled'; export const Choice = styled.div`width: ${p => p.width};`;"; + let output = extract("coverage.tsx", source, super::option())?; + assert!( + output + .styles + .iter() + .any(|style| matches!(style, ExtractStyleValue::Dynamic(_))) + ); + // When + let result = super::checked_declarations(&output); + // Then + assert!( + result.is_err(), + "runtime atoms must not be discarded by the static oracle" + ); + Ok(()) +} + +#[rstest] +#[case("runtime")] +#[case("(runtime as string)")] +#[serial] +fn empty_known_mixin_when_combined_with_unknown_class_preserves_the_class( + #[case] class: &str, +) -> Result<(), Box> { + // Given + let source = format!( + "import {{ css }} from '@devup-ui/react'; export const result = css`${{css({{}})}}${{{class}}}`;" + ); + // When + let output = extract("coverage.tsx", &source, ExtractOption::default())?; + // Then + assert_eq!(output.styles.len(), 0); + assert!(output.code.contains("runtime"), "{}", output.code); + assert!(!output.code.contains("css`"), "{}", output.code); + Ok(()) +} + +#[rstest] +#[case("const rules = { color: props.color }; return css(...rules);")] +#[case( + "const rules = { color: props.color }; expose(rules); return css([rules, { color: 'blue' }]);" +)] +#[case( + "const rules = { color: props.color }; return
;" +)] +#[serial] +fn local_runtime_rules_when_shape_cannot_be_composed_report_the_original_location( + #[case] body: &str, +) -> Result<(), Box> { + // Given + let source = + format!("import {{ css }} from '@emotion/react'; export function App(props) {{ {body} }}"); + // When + let error = extract("coverage-local.tsx", &source, super::option()) + .err() + .ok_or("unknown local composition unexpectedly compiled")? + .to_string(); + // Then + assert!(error.starts_with("coverage-local.tsx:1:"), "{error}"); + assert!(error.contains("build time"), "{error}"); + Ok(()) +} + +#[test] +#[serial] +fn deep_namespace_when_given_css_prop_does_not_inherit_one_level_definition() +-> Result<(), Box> { + // Given + let source = "/** @jsxImportSource @emotion/react */ import * as UI from './base'; export const result = ;"; + let resolver = |specifier: &str, _: &str| { + (specifier == "./base").then(|| ResolvedModule { + path: "/w27/base.tsx".into(), + code: "import styled from '@emotion/styled'; export const Child = styled.div`color: red;`;".into(), + }) + }; + // When + let output = extract_with_modules("/w27/entry.tsx", source, super::option(), false, &resolver)?; + // Then + assert!(output.code.contains("UI.Nested.Child"), "{}", output.code); + assert_eq!( + super::checked_declarations(&output)?, + [("color".into(), "blue".into())] + ); + Ok(()) +} + +#[rstest] +#[case("getUI().Child")] +#[case("UI.Nested.Child")] +#[case("UI[key]")] +#[serial] +fn unknown_namespace_selector_when_not_an_exact_member_reports_error( + #[case] selector: &str, +) -> Result<(), Box> { + // Given + let source = format!( + "import styled from '@emotion/styled'; import * as UI from './base'; export const result = styled.div`${{{selector}}} {{ color: blue; }}`;" + ); + let resolver = |specifier: &str, _: &str| { + (specifier == "./base").then(|| ResolvedModule { + path: "/w27/base.tsx".into(), + code: "import styled from '@emotion/styled'; export const Child = styled.div`color: red;`;".into(), + }) + }; + // When + let error = extract_with_modules("/w27/entry.tsx", &source, super::option(), false, &resolver) + .err() + .ok_or("unknown selector unexpectedly compiled")? + .to_string(); + // Then + assert!(error.starts_with("/w27/entry.tsx:1:"), "{error}"); + assert!(error.contains(selector), "{error}"); + assert!(error.contains("at build time"), "{error}"); + Ok(()) +} diff --git a/libs/extractor/src/extractor/w27_props_rule_choices_regressions.rs b/libs/extractor/src/extractor/w27_props_rule_choices_regressions.rs new file mode 100644 index 000000000..875b5be35 --- /dev/null +++ b/libs/extractor/src/extractor/w27_props_rule_choices_regressions.rs @@ -0,0 +1,249 @@ +use super::super::w27_props_rule_choices_tests::support::{active, declarations}; +use rstest::rstest; + +#[rstest] +#[case( + "function make(undefined) { return BODY; }", + "(p.on ? { color: 'red' } : undefined) ?? { color: 'blue' }" +)] +#[case( + "const undefined = value; const Choice = BODY;", + "(p.on ? { color: 'red' } : undefined) ?? { color: 'blue' }" +)] +#[case( + "function make(undefined = value) { return BODY; }", + "p.on ? undefined : { color: 'red' }" +)] +#[case( + "function make(undefined) { return BODY; }", + "undefined || { color: 'blue' }" +)] +#[case( + "function make(undefined) { return BODY; }", + "undefined ?? { color: 'blue' }" +)] +#[case("function make(undefined) { return BODY; }", "p.on && undefined")] +#[case( + "const red = css({ color: 'red' }); function make(undefined) { return BODY; }", + "p.on ? red : undefined" +)] +#[serial_test::serial] +fn bound_undefined_when_used_as_unknown_rule_shape_reports_original_callback_location( + #[case] scope: &str, + #[case] choice: &str, +) -> Result<(), Box> { + // Given: the spelling is a real parameter, capture or defaulted binding. + for body in [ + format!("styled.div(p => {choice})"), + format!("styled.div`${{p => {choice}}}`"), + ] { + let source = format!( + "import {{ styled, css }} from '@devup-ui/react';\n{}", + scope.replace("BODY", &body) + ); + let callback_start = source.find("p =>").ok_or("missing callback source")?; + let line_start = source.find('\n').ok_or("missing callback line")? + 1; + let location = format!("shadowed-choice.tsx:2:{}:", callback_start - line_start + 1); + // When + let error = crate::extract( + "shadowed-choice.tsx", + &source, + crate::ExtractOption::default(), + ) + .err() + .unwrap_or_else(|| panic!("bound unknown rule shape compiled: {source}")) + .to_string(); + // Then: the diagnostic still names the original source callback, not synthetic code. + assert!(error.contains(&location), "{error}"); + assert!(error.contains("cannot use `"), "{error}"); + assert!(error.contains("undefined"), "{error}"); + assert!( + error.contains("p.on") || choice.starts_with("undefined"), + "{error}" + ); + } + Ok(()) +} + +#[rstest] +#[case("undefined")] +#[case("void 0")] +#[case("null")] +#[serial_test::serial] +fn global_nullish_when_rule_branch_is_absent_keeps_blue_fallback(#[case] sentinel: &str) { + // Given + let choice = format!("(p.on ? {{ color: 'red' }} : {sentinel}) ?? {{ color: 'blue' }}"); + for body in [ + format!("styled.div(p => {choice})"), + format!("styled.div`${{p => {choice}}}`"), + ] { + // When + let output = compile(&format!("const Choice = {body};")); + // Then + assert_eq!( + declarations(&active(&output, "{ on: false }")), + [("color".into(), "blue".into())] + ); + } +} + +#[test] +#[serial_test::serial] +fn bound_undefined_when_scalar_css_value_remains_a_css_variable() { + // Given + let source = "function make(undefined) { return styled.div(p => ({ color: undefined })); }"; + // When + let output = compile(source); + // Then + assert!(output.code.contains("undefined")); + assert!(output.code.contains("--")); + assert!(output.styles.iter().any(|style| matches!(style, crate::ExtractStyleValue::Dynamic(value) if value.identifier().contains("undefined")))); +} + +#[test] +#[serial_test::serial] +fn bound_undefined_when_unreachable_rule_branch_is_skipped() { + // Given + for choice in [ + "({ color: 'red' }) ?? undefined", + "({ color: 'red' }) || undefined", + "(false && undefined) || { color: 'red' }", + ] { + // When + for body in [ + format!("styled.div(p => {choice})"), + format!("styled.div`${{p => {choice}}}`"), + ] { + let output = compile(&format!("function make(undefined) {{ return {body}; }}")); + // Then + assert!(output.styles.iter().any(|style| matches!(style, crate::ExtractStyleValue::Static(value) if value.property() == "color" && value.value() == "red"))); + } + } +} + +fn compile(rules: &str) -> crate::ExtractOutput { + let source = format!("import {{ styled }} from '@devup-ui/react'; {rules}"); + crate::extract( + "w27-choices-active.tsx", + &source, + crate::ExtractOption { + single_css: true, + ..crate::ExtractOption::default() + }, + ) + .unwrap_or_else(|error| panic!("{error}")) +} + +#[test] +#[serial_test::serial] +fn short_circuit_choices_preserve_active_atoms_when_props_are_truthy_falsy_or_nullish() { + // Given: a prior atom and written choices with distinct falsy/nullish behavior. + for (choice, off, nullish) in [ + ( + "(p.on && { color: 'red' }) || { color: 'blue' }", + "blue", + "blue", + ), + ( + "(p.on ? { color: 'red' } : null) ?? { color: 'blue' }", + "blue", + "blue", + ), + ( + "(p.on && { color: 'red' }) ?? { color: 'blue' }", + "green", + "blue", + ), + ("(p.on ? {} : false) ?? { color: 'blue' }", "green", "green"), + ( + "(p.on ? {} : undefined) || { color: 'blue' }", + "blue", + "blue", + ), + ] { + let rules = format!("const Choice = styled('div')({{ color: 'green' }}, p => {choice});"); + // When: the generated class expression runs with each props value. + let output = compile(&rules); + for (on, expected) in [ + ("true", "red"), + ("false", off), + ("0", off), + ("''", off), + ("null", nullish), + ("undefined", nullish), + ] { + let expected = if choice.contains("? {}") && on == "true" { + "green" + } else { + expected + }; + // Then: only the selected atom is active, not every emitted branch. + assert_eq!( + declarations(&active(&output, &format!("{{ on: {on} }}"))), + [("color".into(), expected.into())], + "{choice}, {on}" + ); + } + } +} + +#[test] +#[serial_test::serial] +fn nested_choices_keep_prior_properties_when_selected_object_omits_them() { + // Given: recursive OR/nullish choices with a defaulted parameter. + let rules = "const Choice = styled('div')({ color: 'green', padding: '9px' }, ({ on = true, alt }) => ((on && { color: 'red' }) || (alt ? { padding: '4px' } : null)) ?? { color: 'blue' });"; + let output = compile(rules); + // When / Then: the whole-object choice overlays only its selected properties. + for (props, color, padding) in [ + ("{}", "red", "9px"), + ("{ on: false, alt: true }", "green", "4px"), + ("{ on: false, alt: false }", "blue", "9px"), + ] { + assert_eq!( + declarations(&active(&output, props)), + [ + ("color".into(), color.into()), + ("padding".into(), padding.into()) + ] + ); + } +} + +#[test] +#[serial_test::serial] +fn unknown_runtime_rule_shape_stays_located_when_short_circuit_chooses_it() { + for choice in [ + "p.on || { color: 'blue' }", + "p.rules ?? { color: 'blue' }", + "(p.on && p.rules) || { color: 'blue' }", + ] { + let source = format!( + "import {{ styled }} from '@devup-ui/react'; const Choice = styled.div(p => {choice});" + ); + let error = crate::extract( + "unknown-choice.tsx", + &source, + crate::ExtractOption::default(), + ) + .err() + .unwrap_or_else(|| panic!("unknown shape compiled")); + assert!(error.to_string().contains("unknown-choice.tsx:1:")); + assert!(error.to_string().contains("cannot use")); + } +} + +#[test] +#[serial_test::serial] +fn unreachable_rule_source_is_skipped_when_written_object_short_circuits() { + for choice in [ + "({ color: 'red' }) || unknownRules()", + "({ color: 'red' }) ?? unknownRules()", + "(false && unknownRules()) || { color: 'red' }", + ] { + let output = compile(&format!("const Choice = styled.div(p => {choice});")); + assert_eq!( + declarations(&active(&output, "{}")), + [("color".into(), "red".into())] + ); + } +} diff --git a/libs/extractor/src/extractor/w27_props_rule_choices_tests.rs b/libs/extractor/src/extractor/w27_props_rule_choices_tests.rs new file mode 100644 index 000000000..ca56457fb --- /dev/null +++ b/libs/extractor/src/extractor/w27_props_rule_choices_tests.rs @@ -0,0 +1,288 @@ +use crate::extract_style::ExtractStyleProperty; +use crate::{ExtractOption, ExtractOutput, ExtractStyleValue, extract}; +use oxc_ast::ast::{ConditionalExpression, Expression}; +use oxc_ast_visit::{Visit, walk}; + +#[path = "w27_props_rule_choices_regressions.rs"] +mod regressions; + +#[path = "styled_choice_test_support.rs"] +pub(super) mod support; + +#[derive(Default)] +struct ClassChoices(Vec<(String, String)>); + +impl<'a> Visit<'a> for ClassChoices { + fn visit_conditional_expression(&mut self, expression: &ConditionalExpression<'a>) { + if let (Expression::StringLiteral(yes), Expression::StringLiteral(no)) = + (&expression.consequent, &expression.alternate) + { + self.0.push((yes.value.to_string(), no.value.to_string())); + } + walk::walk_conditional_expression(self, expression); + } +} + +fn class_choices(output: &ExtractOutput) -> Vec<(String, String)> { + let allocator = oxc_allocator::Allocator::default(); + let parsed = + oxc_parser::Parser::new(&allocator, &output.code, oxc_span::SourceType::tsx()).parse(); + assert_eq!(parsed.diagnostics.len(), 0); + let mut choices = ClassChoices::default(); + choices.visit_program(&parsed.program); + choices.0 +} + +fn class_for(output: &ExtractOutput, property: &str, value: &str) -> String { + let style = output + .styles + .iter() + .find_map(|style| match style { + ExtractStyleValue::Static(style) + if style.property() == property && style.value() == value => + { + Some(style) + } + ExtractStyleValue::Static(_) + | ExtractStyleValue::Typography(_) + | ExtractStyleValue::Dynamic(_) + | ExtractStyleValue::Css(_) + | ExtractStyleValue::Import(_) + | ExtractStyleValue::FontFace(_) + | ExtractStyleValue::Keyframes(_) => None, + }) + .unwrap_or_else(|| panic!("missing {property}: {value}")); + style + .extract(Some("w27-props-rule-choices.tsx")) + .to_string() +} + +fn compile(rules: &str) -> ExtractOutput { + let source = + format!("import {{ styled }} from '@devup-ui/react'; {rules} export {{ Choice }};"); + extract( + "w27-props-rule-choices.tsx", + &source, + ExtractOption::default(), + ) + .unwrap_or_else(|error| panic!("{error}")) +} + +fn values(output: &ExtractOutput, property: &str) -> Vec { + let mut values: Vec<_> = output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Static(style) if style.property() == property => { + Some(style.value().to_string()) + } + _ => None, + }) + .collect(); + values.sort(); + values +} + +#[test] +#[serial_test::serial] +fn base_atom_survives_when_logical_branch_is_missing() { + let rules = "const Base = styled('div')({ color: 'red' }); const Choice = styled(Base)(p => p.on && { color: 'blue' });"; + let output = compile(rules); + assert_eq!(values(&output, "color"), ["blue", "red"]); + assert!(output.code.contains("p.on")); + assert!(!output.code.contains("styled(")); + assert_eq!( + class_choices(&output), + [( + class_for(&output, "color", "blue"), + class_for(&output, "color", "red") + )] + ); + assert!(output.code.contains("style={style}")); +} + +#[test] +#[serial_test::serial] +fn distinct_keys_keep_base_when_ternary_branch_omits_them() { + let rules = "const Choice = styled('div')({ color: 'red', backgroundColor: 'black' }, p => p.on ? { color: 'blue' } : { backgroundColor: 'white' });"; + let output = compile(rules); + assert_eq!(values(&output, "color"), ["blue", "red"]); + assert_eq!(values(&output, "background-color"), ["black", "white"]); + let choices = class_choices(&output); + assert!(choices.contains(&( + class_for(&output, "color", "blue"), + class_for(&output, "color", "red") + ))); + assert!(choices.contains(&( + class_for(&output, "background-color", "black"), + class_for(&output, "background-color", "white") + ))); + assert!(output.code.contains("style={style}")); +} + +#[test] +#[serial_test::serial] +fn nested_selector_keeps_base_when_logical_branch_is_missing() { + let rules = "const Base = styled('div')({ '&:hover': { color: 'red', opacity: 0.5 } }); const Choice = styled(Base)(p => p.on && { '&:hover': { color: 'blue' } });"; + let output = compile(rules); + assert_eq!(values(&output, "color"), ["blue", "red"]); + assert_eq!(values(&output, "opacity"), [".5"]); + assert_eq!( + class_choices(&output), + [( + class_for(&output, "color", "blue"), + class_for(&output, "color", "red") + )] + ); + assert!(output.styles.iter().all(|value| match value { + ExtractStyleValue::Static(style) => style.selector().is_some(), + _ => false, + })); +} + +#[test] +#[serial_test::serial] +fn dynamic_leaf_uses_variable_when_rule_branch_is_selected() { + let rules = "const Base = styled('div')({ width: '12px' }); const Choice = styled(Base)(p => p.on && { width: p.width });"; + let output = compile(rules); + assert_eq!(values(&output, "width"), ["12px"]); + assert!(output.styles.iter().any( + |style| matches!(style, ExtractStyleValue::Dynamic(style) if style.property() == "width") + )); + assert!(output.code.contains("p.width")); + assert!(output.code.contains("p.on")); + assert!(output.code.contains("--")); + assert!(!output.code.contains("styled(")); + assert!( + class_choices(&output) + .iter() + .any(|(_, no)| *no == class_for(&output, "width", "12px")) + ); +} + +#[test] +#[serial_test::serial] +fn missing_branches_keep_base_when_ternary_returns_no_rules() { + for absent in ["null", "false", "undefined", "{}"] { + let rules = format!( + "const Choice = styled('div')({{ color: 'red' }}, p => p.on ? {{ color: 'blue' }} : {absent});" + ); + let output = compile(&rules); + assert_eq!( + class_choices(&output), + [( + class_for(&output, "color", "blue"), + class_for(&output, "color", "red") + )] + ); + } +} + +#[test] +#[serial_test::serial] +fn later_atom_wins_when_it_follows_conditional_rules() { + let rules = "const Choice = styled('div')({ color: 'red' }, p => p.on && { color: 'blue' }, { color: 'green' });"; + let output = compile(rules); + assert_eq!(values(&output, "color"), ["green"]); + assert_eq!(class_choices(&output), vec![]); +} + +#[test] +#[serial_test::serial] +fn theme_leaf_is_static_when_conditional_rules_read_theme() { + let rules = "const Choice = styled('div')(p => p.on ? { color: p.theme.colors.primary } : { color: 'red' });"; + let output = compile(rules); + assert_eq!(values(&output, "color"), ["red", "var(--colors-primary)"]); + assert!( + !output + .styles + .iter() + .any(|style| matches!(style, ExtractStyleValue::Dynamic(_))) + ); +} + +#[test] +#[serial_test::serial] +fn pure_comparison_is_supported_when_rule_choice_reads_props() { + let rules = "const Choice = styled('div')(({ on, count }) => on && count > 2 ? { color: 'blue' } : { color: 'red' });"; + let output = compile(rules); + assert_eq!(values(&output, "color"), ["blue", "red"]); + assert!(output.code.contains("count > 2")); +} + +#[test] +#[serial_test::serial] +fn unknown_shapes_are_rejected_when_function_cannot_name_rules() { + for rules in [ + "p => p.on ? p.styles : { color: 'red' }", + "p => p.on && { ...p.styles }", + "p => p.on && { [p.key]: 'red' }", + "p => check(p) ? { color: 'blue' } : { color: 'red' }", + ] { + let source = format!( + "import {{ styled }} from '@devup-ui/react'; const Choice = styled('div')({rules});" + ); + let Err(error) = extract("w27-unknown.tsx", &source, ExtractOption::default()) else { + panic!("unknowable rules must fail at build time"); + }; + assert!(error.to_string().contains("w27-unknown.tsx:")); + assert!(error.to_string().contains("cannot use")); + } +} + +#[test] +#[serial_test::serial] +fn invalid_condition_prop_is_withheld_when_rules_are_lowered() { + let rules = "const Choice = styled('div')(p => p.on && { color: p.title });"; + let output = compile(rules); + assert!( + output.code.contains("\"on\": __devupOmit"), + "{}", + output.code + ); + assert!(!output.code.contains("\"title\": __devupOmit")); + assert!(output.code.contains("p.title")); +} + +#[test] +#[serial_test::serial] +fn invalid_value_prop_is_withheld_when_whole_rule_function_is_lowered() { + let rules = "const Choice = styled('div')(p => ({ color: p.tone, opacity: p.tabIndex, cursor: p.onClick }));"; + let output = compile(rules); + assert!(output.code.contains("\"tone\": __devupOmit")); + assert!(!output.code.contains("\"tabIndex\": __devupOmit")); + assert!(!output.code.contains("\"onClick\": __devupOmit")); + assert!(output.code.contains("p.tone")); +} + +#[test] +#[serial_test::serial] +fn inherited_reads_keep_condition_prop_when_extension_is_used() { + let rules = "const Base = styled('div')(p => p.on ? { color: 'blue' } : { color: 'red' }); const Choice = styled(Base)({ opacity: 0.5 }); export const Example = ;"; + let output = compile(rules); + assert_eq!( + output.code.matches("\"on\": __devupOmit").count(), + 2, + "{}", + output.code + ); + assert!(output.code.contains("on={enabled}")); + assert!(!output.code.contains("\"title\": __devupOmit")); +} + +#[test] +#[serial_test::serial] +fn condition_flag_is_forwarded_when_explicit_policy_allows_it() { + let rules = "const Choice = styled('div').withConfig({ shouldForwardProp: name => name === 'on' })(p => p.on && { color: 'blue' });"; + let output = compile(rules); + assert!(!output.code.contains("\"on\": __devupOmit")); +} + +#[test] +#[serial_test::serial] +fn condition_flag_is_forwarded_when_styled_base_is_custom_component() { + let rules = "const Custom = props =>
; const Choice = styled(Custom)(p => p.on && { color: 'blue' });"; + let output = compile(rules); + assert!(!output.code.contains("\"on\": __devupOmit")); + assert!(output.code.contains("DevupAs = Custom")); +} diff --git a/libs/extractor/src/extractor/w27_styled_choice_coverage.rs b/libs/extractor/src/extractor/w27_styled_choice_coverage.rs new file mode 100644 index 000000000..6c7d62aa1 --- /dev/null +++ b/libs/extractor/src/extractor/w27_styled_choice_coverage.rs @@ -0,0 +1,195 @@ +use super::*; +use rstest::rstest; + +fn normalize_source<'a>( + ast: &AstBuilder<'a>, + source: &'a str, +) -> Result, Box> { + let parsed = + oxc_parser::Parser::new(ast.allocator(), source, oxc_span::SourceType::tsx()).parse(); + assert_eq!(parsed.diagnostics.len(), 0); + let scoping = oxc_semantic::SemanticBuilder::new() + .build(&parsed.program) + .semantic + .into_scoping(); + let statement = parsed + .program + .body + .first() + .and_then(|statement| match statement { + oxc_ast::ast::Statement::ExpressionStatement(statement) => Some(statement), + _ => None, + }) + .ok_or("expected expression statement")?; + let values = crate::style_values::StyleValues::new(scoping); + let imports = rustc_hash::FxHashMap::default(); + let inline_css = rustc_hash::FxHashMap::default(); + normalize( + ast, + &statement.expression, + &StyledBindings { + imports: &imports, + values: &values, + inline_css: &inline_css, + }, + ) + .map_err(|_| "readable choices did not normalize".into()) +} + +fn assert_props_lowering_contract(expression: &Expression<'_>) { + assert!( + matches!( + expression, + Expression::ObjectExpression(_) + | Expression::NullLiteral(_) + | Expression::ConditionalExpression(_) + ), + "props lowering received unnormalized rules: {expression:?}" + ); + if let Expression::ConditionalExpression(branch) = expression { + assert!(crate::utils::is_pure(&branch.test)); + assert_props_lowering_contract(&branch.consequent); + assert_props_lowering_contract(&branch.alternate); + } +} + +#[rstest] +#[case("unknownRules")] +#[case("p.on ? {color: 'red'} : unknownRules")] +#[case("check(p) ? {color: 'red'} : null")] +fn props_lowering_oracle_when_rules_are_unnormalized_rejects( + #[case] source: &str, +) -> Result<(), Box> { + // Given + let allocator = oxc_allocator::Allocator::default(); + let source = format!("({source});"); + let parsed = oxc_parser::Parser::new(&allocator, &source, oxc_span::SourceType::tsx()).parse(); + assert_eq!(parsed.diagnostics.len(), 0); + let statement = parsed + .program + .body + .first() + .ok_or("expected expression statement")?; + assert!(matches!( + statement, + oxc_ast::ast::Statement::ExpressionStatement(_) + )); + if let oxc_ast::ast::Statement::ExpressionStatement(statement) = statement { + let expression = crate::utils::unwrap_syntax_only(&statement.expression); + // When + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + assert_props_lowering_contract(expression); + })); + // Then + assert!( + result.is_err(), + "unsupported leaves and impure tests must fail" + ); + } + Ok(()) +} + +#[rstest] +#[case("")] +#[case("const rules = {};")] +fn normalization_oracle_when_expression_statement_is_missing_rejects(#[case] source: &str) { + // Given + let allocator = oxc_allocator::Allocator::default(); + let ast = AstBuilder::new(&allocator); + // When + let result = normalize_source(&ast, source); + // Then + assert!(result.is_err(), "missing expression statement must fail"); +} + +#[rstest] +#[case("p.on && { color: 'red' }")] +#[case("p.on ? { color: 'red' } : undefined")] +#[case("p.on ? false : true")] +#[case("(p.on && { color: 'red' }) ?? { color: 'blue' }")] +#[case("0 && unknownRules()")] +#[case("'' || { color: 'blue' }")] +fn normalized_rules_when_sent_to_props_lowering_have_only_pure_branches_and_object_or_null_leaves( + #[case] source: &str, +) -> Result<(), Box> { + // Given + let allocator = oxc_allocator::Allocator::default(); + let ast = AstBuilder::new(&allocator); + let source = format!("({source});"); + // When + let normalized = normalize_source(&ast, &source)?; + // Then + assert_props_lowering_contract(&normalized); + Ok(()) +} + +#[rstest] +#[case("0 && unknownRules()", "green")] +#[case("1 && { color: 'red' }", "red")] +#[case("'' || { color: 'blue' }", "blue")] +#[case("'yes' && { color: 'red' }", "red")] +#[case("0 ?? unknownRules()", "green")] +#[case("'' ?? unknownRules()", "green")] +#[case("({ color: 'red' }) && { color: 'blue' }", "blue")] +fn normalized_literal_when_selected_produces_expected_rule_value( + #[case] source: &str, + #[case] expected: &str, +) -> Result<(), Box> { + // Given: parser input is not pre-folded by the public extraction pipeline. + let allocator = oxc_allocator::Allocator::default(); + let ast = AstBuilder::new(&allocator); + let source = format!("({source});"); + // When + let normalized = normalize_source(&ast, &source)?; + let code = crate::utils::expression_to_code(&normalized); + let code = crate::css_utils::rm_last_semi_colon(&code); + let code = format!("({code})?.color ?? 'green'"); + let mut context = boa_engine::Context::default(); + let result = context + .eval(boa_engine::Source::from_bytes(&code)) + .map_err(|error| format!("generated expression failed to evaluate: {error}"))? + .to_string(&mut context) + .map_err(|error| format!("evaluated value failed string conversion: {error}"))? + .to_std_string_escaped(); + // Then + assert_eq!(result, expected); + Ok(()) +} + +#[test] +fn numeric_nan_when_short_circuited_does_not_read_unknown_rules() +-> Result<(), Box> { + // Given: NaN is a numeric AST value even though source spells it as a name. + let allocator = oxc_allocator::Allocator::default(); + let ast = AstBuilder::new(&allocator); + let expression = Expression::new_logical_expression( + SPAN, + Expression::new_numeric_literal( + SPAN, + f64::NAN, + None, + oxc_syntax::number::NumberBase::Decimal, + &ast, + ), + LogicalOperator::And, + Expression::new_identifier(SPAN, "unknown", &ast), + &ast, + ); + // When + let values = crate::style_values::StyleValues::default(); + let imports = rustc_hash::FxHashMap::default(); + let inline_css = rustc_hash::FxHashMap::default(); + let normalized = normalize( + &ast, + &expression, + &StyledBindings { + imports: &imports, + values: &values, + inline_css: &inline_css, + }, + ) + .map_err(|_| "falsy NaN did not normalize")?; + // Then + assert!(matches!(normalized, Expression::NullLiteral(_))); + Ok(()) +} diff --git a/libs/extractor/src/extractor/w27_styled_composition_coverage.rs b/libs/extractor/src/extractor/w27_styled_composition_coverage.rs new file mode 100644 index 000000000..ac4c98328 --- /dev/null +++ b/libs/extractor/src/extractor/w27_styled_composition_coverage.rs @@ -0,0 +1,114 @@ +use super::super::super::w27_props_rule_choices_tests::support::{active, declarations}; +use crate::{ExtractOption, extract}; +use rstest::rstest; +use serial_test::serial; + +#[test] +#[serial] +fn unreadable_selector_when_interpolation_follows_literal_prefix_is_located() +-> Result<(), Box> { + // Given + let source = "import { styled } from '@devup-ui/react'; export const Choice = styled.div`&${runtime} { color: red; }`;"; + // When + let error = extract("unplaced-selector.tsx", source, ExtractOption::default()) + .err() + .ok_or("unknown selector unexpectedly compiled")? + .to_string(); + // Then + assert!(error.starts_with("unplaced-selector.tsx:1:"), "{error}"); + assert!(error.contains("runtime"), "{error}"); + Ok(()) +} + +#[rstest] +#[case("0 && unknownRules()", "green")] +#[case("1 && { color: 'red' }", "red")] +#[case("'' || { color: 'blue' }", "blue")] +#[case("'yes' && { color: 'red' }", "red")] +#[case("0 ?? unknownRules()", "green")] +#[case("'' ?? unknownRules()", "green")] +#[case("({ color: 'red' }) && { color: 'blue' }", "blue")] +#[serial] +fn literal_choices_when_short_circuited_select_only_reachable_rules( + #[case] choice: &str, + #[case] expected: &str, +) -> Result<(), Box> { + // Given + let source = format!( + "import {{ styled }} from '@devup-ui/react'; export const Choice = styled.div({{ color: 'green' }}, p => {choice});" + ); + // When + let output = extract( + "literal-choice.tsx", + &source, + ExtractOption { + single_css: true, + ..ExtractOption::default() + }, + )?; + // Then + assert_eq!( + declarations(&active(&output, "{}")), + [("color".into(), expected.into())] + ); + Ok(()) +} + +#[test] +#[serial] +fn later_mixin_when_prior_context_has_closed_is_not_nested_in_prior_selector() +-> Result<(), Box> { + // Given + let source = "import { styled, css } from '@devup-ui/react'; const mixin = css({ color: 'red' }); export const Choice = styled.div`&:hover { color: blue; } ${mixin};`;"; + // When + let output = extract( + "closed-context.tsx", + source, + ExtractOption { + single_css: true, + ..ExtractOption::default() + }, + )?; + // Then + let mut keys = active(&output, "{}") + .into_iter() + .map(|value| match value { + crate::ExtractStyleValue::Static(style) => ( + style.value().to_string(), + style.selector().map(ToString::to_string), + ), + other => panic!("expected static atom, got {other:?}"), + }) + .collect::>(); + keys.sort(); + assert_eq!( + keys, + [ + ("blue".into(), Some("&:hover".into())), + ("red".into(), None) + ] + ); + Ok(()) +} + +#[rstest] +#[case("p => { notify(p); return 'external'; }")] +#[case("function(p) { notify(p); }")] +#[serial] +fn unreadable_callback_when_nested_reports_interpolation_location( + #[case] callback: &str, +) -> Result<(), Box> { + // Given + let source = format!( + "import {{ styled }} from '@devup-ui/react'; export const Choice = styled.div`&:hover {{ ${{{callback}}}; }}`;" + ); + // When + let error = extract("callback-boundary.tsx", &source, ExtractOption::default()) + .err() + .ok_or("unreadable nested callback unexpectedly compiled")? + .to_string(); + // Then + assert!(error.starts_with("callback-boundary.tsx:1:"), "{error}"); + assert!(error.contains("nested mixin"), "{error}"); + Ok(()) +} diff --git a/libs/extractor/src/extractor/w27_styled_context_coverage.rs b/libs/extractor/src/extractor/w27_styled_context_coverage.rs new file mode 100644 index 000000000..d1e874287 --- /dev/null +++ b/libs/extractor/src/extractor/w27_styled_context_coverage.rs @@ -0,0 +1,157 @@ +use super::*; +use css::style_selector::{AtRule, AtRuleKind}; +use rstest::rstest; + +#[test] +fn at_chain_when_nested_retains_outer_rules_and_child_selector() +-> Result<(), Box> { + // Given + let child = StyleSelector::At { + outer: vec![AtRule { + kind: AtRuleKind::Supports, + query: "(display: grid)".into(), + }], + kind: AtRuleKind::Media, + query: "print".into(), + selector: Some("&:focus".into()), + file: None, + }; + let parent = StyleSelector::Selector("&:hover".into()); + // When + let result = nest(Some(&parent), Some(&child)) + .map_err(|_| "failed to nest readable selector")? + .ok_or("nested selector was missing")?; + // Then + assert_eq!( + result.to_string(), + "@supports(display:grid) @media print &:hover:focus" + ); + Ok(()) +} + +#[rstest] +#[case(false)] +#[case(true)] +fn conflicting_media_when_contextualized_drops_static_or_dynamic_atom( + #[case] dynamic: bool, +) -> Result<(), Box> { + // Given + let selector = StyleSelector::At { + kind: AtRuleKind::Media, + query: "screen".into(), + selector: None, + outer: vec![], + file: None, + }; + let value = if dynamic { + ExtractStyleValue::Dynamic(ExtractDynamicStyle::new( + "width", + 0, + "rest.width", + Some(selector), + )) + } else { + ExtractStyleValue::Static(ExtractStaticStyle::new("color", "red", 0, Some(selector))) + }; + // When + let result = contextualize( + vec![ExtractStyleProp::Static(value)], + &["@media print {".into()], + ) + .map_err(|_| "never matching styles must be empty, not opaque")?; + // Then + assert!( + matches!(result.as_slice(), [ExtractStyleProp::StaticArray(values)] if values.is_empty()) + ); + Ok(()) +} + +#[rstest] +#[case(false)] +#[case(true)] +fn global_atom_when_contextualized_is_rejected_as_opaque(#[case] dynamic: bool) { + // Given + let selector = StyleSelector::Global("body".into(), "global.tsx".into()); + let value = if dynamic { + ExtractStyleValue::Dynamic(ExtractDynamicStyle::new( + "width", + 0, + "rest.width", + Some(selector), + )) + } else { + ExtractStyleValue::Static(ExtractStaticStyle::new("color", "red", 0, Some(selector))) + }; + // When + let result = contextualize(vec![ExtractStyleProp::Static(value)], &["&:hover {".into()]); + // Then + assert!(matches!(result, Err(ContextError::Opaque))); +} + +#[test] +fn dynamic_array_when_contextualized_preserves_important_order_layer_and_value() +-> Result<(), Box> { + // Given + let mut value = ExtractStyleValue::Dynamic(ExtractDynamicStyle::new( + "width", + 2, + "rest.width !important", + None, + )); + value.set_style_order(7); + let props = vec![ExtractStyleProp::StaticArray(vec![ + ExtractStyleProp::Static(value), + ])]; + // When + let result = contextualize(props, &["@layer outer {".into(), "&:hover {".into()]) + .map_err(|_| "failed to contextualize readable dynamic array")?; + // Then + let [ExtractStyleProp::StaticArray(array)] = result.as_slice() else { + panic!("array must be preserved") + }; + let [ExtractStyleProp::Static(ExtractStyleValue::Dynamic(style))] = array.as_slice() else { + panic!("dynamic atom must be preserved") + }; + assert_eq!( + ( + style.identifier(), + style.important(), + style.style_order(), + style.level(), + style.layer() + ), + ("rest.width", true, Some(7), 2, Some("outer")) + ); + assert_eq!( + style.selector().map(ToString::to_string), + Some("&:hover".into()) + ); + Ok(()) +} + +#[test] +fn opaque_array_entry_when_contextualized_fails_instead_of_filtering_it() { + // Given + let props = vec![ExtractStyleProp::StaticArray(vec![ + ExtractStyleProp::Static(ExtractStyleValue::Typography("body".into())), + ])]; + // When + let result = contextualize(props, &["&:hover {".into()]); + // Then + assert!(matches!(result, Err(ContextError::Opaque))); +} + +#[test] +fn impossible_parent_when_contextualized_emits_no_atoms() -> Result<(), Box> +{ + // Given + let props = vec![ExtractStyleProp::Static(ExtractStyleValue::Static( + ExtractStaticStyle::new("color", "red", 0, None), + ))]; + // When + let result = contextualize(props, &["@media print {".into(), "@media screen {".into()]) + .map_err(|_| "impossible context must be empty, not an error")?; + // Then + assert_eq!(result.len(), 0); + Ok(()) +} diff --git a/libs/extractor/src/extractor/w27_styled_known_mixins_regressions.rs b/libs/extractor/src/extractor/w27_styled_known_mixins_regressions.rs new file mode 100644 index 000000000..81fb0e3e5 --- /dev/null +++ b/libs/extractor/src/extractor/w27_styled_known_mixins_regressions.rs @@ -0,0 +1,257 @@ +use super::super::w27_props_rule_choices_tests::support::{active, declarations}; +use crate::{ExtractOption, ExtractStyleValue}; + +fn compile(template: &str) -> crate::ExtractOutput { + let source = format!( + "import {{ styled, css }} from '@devup-ui/react'; const mixin = css({{ color: 'red', padding: '4px' }}); export const Choice = styled.div`{template}`;" + ); + crate::extract( + "context-mixin.tsx", + &source, + ExtractOption { + single_css: true, + ..ExtractOption::default() + }, + ) + .unwrap_or_else(|error| panic!("{error}")) +} + +#[test] +#[serial_test::serial] +fn known_mixin_inherits_context_when_surrounded_by_layer_selector_and_media() { + // Given: the same atom competes inside each enclosing context. + for (open, close, layer, selector) in [ + ("@layer base {", "}", Some("base"), None), + ("&:hover {", "}", None, Some(":hover")), + ( + "@media print { &:hover { @layer base {", + "} } }", + Some("base"), + Some("print"), + ), + ] { + let output = compile(&format!("{open} ${{mixin}}; color: blue; {close}")); + // When: the component's class expression runs. + let selected = active(&output, "{}"); + // Then: red is replaced only in the matching context; padding inherits it. + assert_eq!( + declarations(&selected), + [ + ("color".into(), "blue".into()), + ("padding".into(), "4px".into()) + ] + ); + for value in selected { + let ExtractStyleValue::Static(style) = value else { + panic!("static atom required") + }; + assert_eq!(style.layer(), layer); + match selector { + Some(selector) => assert!( + style + .selector() + .is_some_and(|value| value.to_string().contains(selector)) + ), + None => assert_eq!(style.selector(), None), + } + } + } +} + +#[test] +#[serial_test::serial] +fn conditional_known_mixin_composes_per_property_when_later_color_is_always_present() { + // Given: a known callback mixin followed by an unconditional competing color. + for template in [ + "${p => p.on && mixin}; color: blue;", + "@layer base { &:hover { ${p => p.on && mixin}; color: blue; } }", + ] { + let output = compile(template); + // When / Then: blue always wins, and only the enabled branch supplies padding. + for on in [true, false] { + let mut expected = vec![("color".into(), "blue".into())]; + if on { + expected.push(("padding".into(), "4px".into())); + } + assert_eq!( + declarations(&active(&output, &format!("{{ on: {on} }}"))), + expected + ); + } + } +} + +#[test] +#[serial_test::serial] +fn context_sequence_keeps_prior_atom_when_later_conditional_mixin_is_absent() { + let output = compile( + "@layer base { &:hover { color: blue; ${p => p.on ? mixin : null}; } } color: green;", + ); + for on in [true, false] { + let selected = active(&output, &format!("{{ on: {on} }}")); + let mut expected = [ + ("color".into(), if on { "red" } else { "blue" }.into()), + ("color".into(), "green".into()), + ] + .into_iter() + .chain(on.then(|| ("padding".into(), "4px".into()))) + .collect::>(); + expected.sort(); + assert_eq!(declarations(&selected), expected); + } +} + +#[test] +#[serial_test::serial] +fn unknown_nested_mixin_fails_located_instead_of_applying_global_classes() { + // Given: opaque classes cannot be recontextualized. + for mixin in ["runtime", "p => p.on && runtime"] { + let source = format!( + "import {{ styled }} from '@devup-ui/react'; import {{ runtime }} from './runtime'; export const Choice = styled.div`@layer base {{ &:hover {{ ${{{mixin}}}; color: blue; }} }}`;" + ); + // When: extraction must refuse the unknown nested statement. + let error = crate::extract("unknown-nested.tsx", &source, ExtractOption::default()) + .err() + .unwrap_or_else(|| panic!("unknown nested mixin compiled")); + // Then: the error identifies the source interpolation. + assert!(error.to_string().contains("unknown-nested.tsx:1:")); + assert!(error.to_string().contains("cannot use")); + assert!(error.to_string().contains("runtime")); + } +} + +#[test] +#[serial_test::serial] +fn contextual_callback_preserves_dynamic_lengths_and_theme_bindings_when_enabled() { + // Given: a written callback object inside selector and layer contexts. + let output = compile( + "@layer base { &:hover { ${p => p.on && { padding: p.padding, color: p.theme.colors.primary }}; } }", + ); + // When: only the selected property's CSS-variable expression is evaluated. + let selected = active(&output, "{ on: true, padding: 8 }"); + let padding = selected + .iter() + .find_map(|style| match style { + ExtractStyleValue::Dynamic(style) if style.property() == "padding" => Some(style), + _ => None, + }) + .unwrap_or_else(|| panic!("missing dynamic padding")); + // Then: numeric lengths stay pixels, and theme paths remain static variables. + assert_eq!( + super::super::w27_props_rule_choices_tests::support::evaluate( + padding.identifier(), + "{ padding: 8 }" + ), + "8px" + ); + assert_eq!(padding.layer(), Some("base")); + assert!( + padding + .selector() + .is_some_and(|selector| selector.to_string().contains(":hover")) + ); + assert!(selected.iter().any(|style| matches!(style, ExtractStyleValue::Static(style) if style.value() == "var(--colors-primary)"))); + assert_eq!(active(&output, "{ on: false }"), vec![]); +} + +#[test] +#[serial_test::serial] +fn context_lexer_ignores_data_braces_when_known_mixin_follows_comments_and_quoted_values() { + let output = + compile("@layer base { &:hover { content: '}'; /* } */ ${mixin}; color: blue; } }"); + let selected = active(&output, "{}"); + assert_eq!( + declarations(&selected) + .iter() + .filter(|(property, _)| property == "color") + .cloned() + .collect::>(), + [("color".into(), "blue".into())] + ); + assert!(selected.iter().all(|style| matches!(style, ExtractStyleValue::Static(style) if style.layer() == Some("base") && style.selector().is_some_and(|selector| selector.to_string().contains(":hover"))))); +} + +#[test] +#[serial_test::serial] +fn contextual_composition_preserves_distinct_keys_when_mixin_has_inner_selectors_breakpoints_and_layers() + { + let source = "import { styled, css } from '@devup-ui/react'; const mixin = css({ color: ['red', 'orange'], '&:focus': { color: 'yellow' }, '@layer': { inner: { padding: '4px' } } }); export const Choice = styled.div`@layer outer { @media print { &:hover { ${mixin}; color: blue; &:focus { color: purple; } } } }`;"; + let output = crate::extract( + "nested-key-mixin.tsx", + source, + ExtractOption { + single_css: true, + ..ExtractOption::default() + }, + ) + .unwrap_or_else(|error| panic!("{error}")); + let selected = active(&output, "{}"); + let mut keys: Vec<_> = selected + .iter() + .map(|value| { + let ExtractStyleValue::Static(style) = value else { + panic!("static atom required") + }; + ( + style.property().to_string(), + style.value().to_string(), + style.level(), + style.layer().map(str::to_string), + style.selector().map(ToString::to_string), + ) + }) + .collect(); + keys.sort(); + assert_eq!( + keys, + [ + ( + "color".into(), + "blue".into(), + 0, + Some("outer".into()), + Some("@media print &:hover".into()) + ), + ( + "color".into(), + "orange".into(), + 1, + Some("outer".into()), + Some("@media print &:hover".into()) + ), + ( + "color".into(), + "purple".into(), + 0, + Some("outer".into()), + Some("@media print &:hover:focus".into()) + ), + ( + "padding".into(), + "4px".into(), + 0, + Some("outer.inner".into()), + Some("@media print &:hover".into()) + ), + ] + ); +} + +#[test] +#[serial_test::serial] +fn unknown_written_rule_branches_fail_when_top_level_callback_cannot_be_read_as_styles() { + for mixin in [ + "p => p.on ? { color: 'blue' } : p.rules", + "p => p.on && { ...p.rules }", + "p => check(p) ? { color: 'red' } : { color: 'blue' }", + ] { + let source = format!( + "import {{ styled }} from '@devup-ui/react'; export const Choice = styled.div`${{{mixin}}}; color: green;`;" + ); + let error = crate::extract("unknown-rule-mixin.tsx", &source, ExtractOption::default()) + .err() + .unwrap_or_else(|| panic!("unknown rule shape compiled")); + assert!(error.to_string().contains("unknown-rule-mixin.tsx:1:")); + assert!(error.to_string().contains("cannot use")); + } +} diff --git a/libs/extractor/src/extractor/w27_styled_known_mixins_tests.rs b/libs/extractor/src/extractor/w27_styled_known_mixins_tests.rs new file mode 100644 index 000000000..bf8a4457c --- /dev/null +++ b/libs/extractor/src/extractor/w27_styled_known_mixins_tests.rs @@ -0,0 +1,253 @@ +use crate::{ExtractOption, ExtractOutput, ExtractStyleValue, ImportAlias, ResolvedModule}; +use std::collections::HashMap; + +#[path = "w27_styled_known_mixins_regressions.rs"] +mod regressions; + +fn option() -> ExtractOption { + ExtractOption { + single_css: true, + import_aliases: HashMap::from([ + ("@emotion/react".to_string(), ImportAlias::NamedToNamed), + ( + "@emotion/styled".to_string(), + ImportAlias::DefaultToNamed("styled".to_string()), + ), + ]), + ..ExtractOption::default() + } +} + +fn imported(base: &str, body: &str) -> ExtractOutput { + let module = format!( + "import styled from '@emotion/styled'; import {{ css }} from '@emotion/react'; {base}" + ); + let entry = + format!("import styled from '@emotion/styled'; import {{ Base }} from './base'; {body}"); + let resolver = move |specifier: &str, _: &str| { + (specifier == "./base").then(|| ResolvedModule { + path: "/w27/base.tsx".to_string(), + code: module.clone(), + }) + }; + crate::extract_with_modules("/w27/entry.tsx", &entry, option(), false, &resolver) + .unwrap_or_else(|error| panic!("{error}")) +} + +fn declarations(output: &ExtractOutput) -> Vec<(String, String)> { + checked_declarations(output).unwrap_or_else(|error| panic!("{error}")) +} + +fn checked_declarations(output: &ExtractOutput) -> Result, String> { + let mut styles: Vec<_> = output + .styles + .iter() + .map(|value| match value { + ExtractStyleValue::Static(style) => { + Ok((style.property().to_string(), style.value().to_string())) + } + ExtractStyleValue::Typography(_) + | ExtractStyleValue::Dynamic(_) + | ExtractStyleValue::Css(_) + | ExtractStyleValue::Import(_) + | ExtractStyleValue::FontFace(_) + | ExtractStyleValue::Keyframes(_) => { + Err(format!("expected static atom, got {value:?}")) + } + }) + .collect::>()?; + styles.sort(); + Ok(styles) +} + +#[path = "w27_coverage_tests.rs"] +mod coverage_tests; + +#[test] +#[serial_test::serial] +fn imported_extension_flattens_when_base_composes_known_object_mixin() { + let base = "const mixin = css({ color: 'red', padding: 4 }); export const Base = styled.div`${mixin}; margin: 1px;`;"; + let output = imported(base, "export const Choice = styled(Base)`color: blue;`;"); + assert_eq!( + declarations(&output), + [ + ("color".into(), "blue".into()), + ("margin".into(), "1px".into()), + ("padding".into(), "4px".into()) + ] + ); + assert!(output.code.contains("DevupAs = \"div\"")); + assert!(!output.code.contains("DevupAs = Base")); + assert!(!output.code.contains("mixin")); + assert!(output.code.contains("--")); +} + +#[test] +#[serial_test::serial] +fn template_order_selects_color_when_known_tagged_mixin_is_first_or_last() { + for (template, color) in [ + ("${mixin}; color: blue;", "blue"), + ("color: blue; ${mixin};", "red"), + ] { + let base = format!( + "const mixin = css`color: red; padding: 4px;`; export const Base = styled.div`{template}`;" + ); + let output = imported(&base, "export const Choice = styled(Base)`margin: 1px;`;"); + assert_eq!( + declarations(&output), + [ + ("color".into(), color.into()), + ("margin".into(), "1px".into()), + ("padding".into(), "4px".into()) + ] + ); + } +} + +#[test] +#[serial_test::serial] +fn selectors_and_breakpoints_survive_when_imported_mixin_is_composed() { + let base = "import { css as nativeCss } from '@devup-ui/react'; const mixin = nativeCss({ color: ['red', 'orange'], '&:hover': { color: 'red' } }); export const Base = styled.div`${mixin}; &:hover { color: blue; }`;"; + let output = imported(base, "export const Choice = styled(Base)`margin: 1px;`;"); + assert!(output.styles.iter().any(|value| matches!(value, ExtractStyleValue::Static(style) if style.level() == 1 && style.value() == "orange"))); + let hover: Vec<_> = output + .styles + .iter() + .filter_map(|value| match value { + ExtractStyleValue::Static(style) + if style + .selector() + .is_some_and(|selector| selector.to_string().contains(":hover")) => + { + Some(style.value()) + } + _ => None, + }) + .collect(); + assert_eq!(hover, ["blue"]); + assert!(output.code.contains("DevupAs = \"div\"")); +} + +#[test] +#[serial_test::serial] +fn layered_metadata_keeps_later_atom_when_known_mixins_share_layer() { + use crate::extract_style::extract_static_style::ExtractStaticStyle; + use oxc_ast_visit::VisitMut; + let allocator = oxc_allocator::Allocator::default(); + let source = "import { styled } from '@devup-ui/react'; import { first, second, theme } from './layers'; export const Choice = styled.div`${first}; ${theme}; ${second}; color: black;`;"; + let mut program = oxc_parser::Parser::new(&allocator, source, oxc_span::SourceType::tsx()) + .parse() + .program; + let mut visitor = crate::visit::DevupVisitor::new( + &allocator, + "w27-layers.tsx", + "@devup-ui/react", + vec![], + None, + ); + visitor.import_css( + [ + ("first", "red", "base"), + ("second", "blue", "base"), + ("theme", "green", "theme"), + ] + .into_iter() + .map(|(name, value, layer)| { + ( + name.to_string(), + vec![ExtractStyleValue::Static( + ExtractStaticStyle::new_with_layer( + "color", + value, + 0, + None, + Some(layer.to_string()), + ), + )], + ) + }) + .collect(), + ); + visitor.visit_program(&mut program); + assert_eq!(visitor.errors, vec![]); + let mut layers: Vec<_> = visitor + .styles + .iter() + .map(|value| match value { + ExtractStyleValue::Static(style) => { + (style.layer().map(str::to_string), style.value().to_string()) + } + other => panic!("expected static atom, got {other:?}"), + }) + .collect(); + layers.sort(); + assert_eq!( + layers, + [ + (None, "black".into()), + (Some("base".into()), "blue".into()), + (Some("theme".into()), "green".into()) + ] + ); +} + +#[test] +#[serial_test::serial] +fn inline_calls_and_tags_are_known_when_imported_base_composes_them() { + for mixin in [ + "css({ color: 'red', padding: 4 })", + "css`color: red; padding: 4px;`", + ] { + let base = format!("export const Base = styled.div`${{{mixin}}}; color: blue;`;"); + let output = imported(&base, "export const Choice = styled(Base)`margin: 1px;`;"); + assert_eq!( + declarations(&output), + [ + ("color".into(), "blue".into()), + ("margin".into(), "1px".into()), + ("padding".into(), "4px".into()) + ] + ); + } +} + +#[test] +#[serial_test::serial] +fn dynamic_values_keep_variables_when_known_mixin_joins_template() { + let source = "import styled from '@emotion/styled'; import { css } from '@emotion/react'; const mixin = css({ padding: 4 }); export const Choice = styled.div`${mixin}; width: ${p => p.width};`;"; + let output = crate::extract("w27-dynamic-mixin.tsx", source, option()) + .unwrap_or_else(|error| panic!("{error}")); + assert!(output.styles.iter().any( + |value| matches!(value, ExtractStyleValue::Dynamic(style) if style.property() == "width") + )); + assert!( + !output + .code + .split("const Choice") + .nth(1) + .unwrap_or_default() + .contains("mixin") + ); + assert!(output.code.contains("--")); +} + +#[test] +#[serial_test::serial] +fn runtime_class_stays_opaque_when_styles_are_unknown() { + let source = "import styled from '@emotion/styled'; import { runtime } from './runtime'; export const Choice = styled.div`${runtime}; color: blue;`;"; + let output = crate::extract("w27-unknown-mixin.tsx", source, option()) + .unwrap_or_else(|error| panic!("{error}")); + assert!(output.code.contains("runtime")); + assert_eq!(declarations(&output), [("color".into(), "blue".into())]); +} + +#[test] +#[serial_test::serial] +fn runtime_class_remains_when_known_mixin_is_composed_beside_it() { + let source = "import styled from '@emotion/styled'; import { css } from '@emotion/react'; import { runtime } from './runtime'; const mixin = css({ color: 'red' }); export const Choice = styled.div`${runtime}; ${mixin}; color: blue;`;"; + let output = crate::extract("w27-mixed-mixins.tsx", source, option()) + .unwrap_or_else(|error| panic!("{error}")); + let choice = output.code.split("const Choice").nth(1).unwrap_or_default(); + assert!(choice.contains("runtime")); + assert!(!choice.contains("mixin")); +} diff --git a/libs/extractor/src/extractor/w27_styled_template_coverage.rs b/libs/extractor/src/extractor/w27_styled_template_coverage.rs new file mode 100644 index 000000000..1e61e8bb9 --- /dev/null +++ b/libs/extractor/src/extractor/w27_styled_template_coverage.rs @@ -0,0 +1,177 @@ +use super::*; +use crate::extract_style::{ExtractStyleProperty, extract_static_style::ExtractStaticStyle}; +use rstest::rstest; + +#[rstest] +#[case("true", "red")] +#[case("false", "blue")] +#[serial_test::serial] +fn direct_condition_when_no_props_callback_exists_keeps_module_binding( + #[case] on: &str, + #[case] expected: &str, +) -> Result<(), Box> { + // Given + let allocator = oxc_allocator::Allocator::default(); + let ast = AstBuilder::new(&allocator); + let imports = rustc_hash::FxHashMap::default(); + let values = crate::style_values::StyleValues::default(); + let inline_css = rustc_hash::FxHashMap::default(); + let bindings = StyledBindings { + imports: &imports, + values: &values, + inline_css: &inline_css, + }; + let expression = oxc_parser::Parser::new( + &allocator, + "on ? { color: 'red' } : { color: 'blue' }", + oxc_span::SourceType::tsx(), + ) + .parse_expression() + .map_err(|error| format!("failed to parse valid choice: {error:?}"))?; + // When + let mut styles = mixin(&ast, &expression, &bindings) + .map_err(|_| "failed to lower written choice")? + .ok_or("written choice produced no styles")?; + let mut context = boa_engine::Context::default(); + let class = crate::gen_class_name::gen_class_names(&ast, &mut styles, None, None) + .ok_or("conditional classes were missing")?; + let code = format!( + "const on = {on}; {}", + crate::utils::expression_to_code(&class) + ); + let selected = context + .eval(boa_engine::Source::from_bytes(&code)) + .map_err(|error| format!("module condition failed to evaluate in scope: {error}"))? + .to_string(&mut context) + .map_err(|error| format!("selected class failed string conversion: {error}"))? + .to_std_string_escaped(); + // Then + let expected = ExtractStaticStyle::new("color", expected, 0, None) + .extract(None) + .to_string(); + assert_eq!(selected, expected); + Ok(()) +} + +#[rstest] +#[case("(known) || { color: 'blue' }", false, "blue")] +#[case("(known) && { color: 'blue' }", true, "blue")] +#[case("(known) && unknownRules()", false, "")] +#[case("(known) ?? unknownRules()", false, "")] +#[serial_test::serial] +fn known_class_when_empty_or_nonempty_selects_rules_before_context_lowering( + #[case] source: &str, + #[case] truthy: bool, + #[case] expected: &str, +) -> Result<(), Box> { + // Given + let allocator = oxc_allocator::Allocator::default(); + let ast = AstBuilder::new(&allocator); + let expression = oxc_parser::Parser::new(&allocator, source, oxc_span::SourceType::tsx()) + .parse_expression() + .map_err(|error| format!("failed to parse valid choice: {error:?}"))?; + let Expression::LogicalExpression(logical) = &expression else { + panic!("expected logical choice") + }; + let known = if truthy { + vec![crate::ExtractStyleValue::Static(ExtractStaticStyle::new( + "color", "red", 0, None, + ))] + } else { + vec![] + }; + let imports = rustc_hash::FxHashMap::default(); + let values = crate::style_values::StyleValues::default(); + let inline_css = + rustc_hash::FxHashMap::from_iter([(unwrap_syntax_only(&logical.left).span().start, known)]); + let bindings = StyledBindings { + imports: &imports, + values: &values, + inline_css: &inline_css, + }; + // When + let mut styles = mixin(&ast, &expression, &bindings) + .map_err(|_| "failed to lower known class choice")? + .ok_or("known class choice produced no styles")?; + let class = crate::gen_class_name::gen_class_names(&ast, &mut styles, None, None) + .map(|expression| crate::utils::expression_to_code(&expression)); + // Then + match class { + None => assert_eq!(expected, ""), + Some(code) => { + let selected = super::super::w27_props_rule_choices_tests::support::evaluate( + crate::css_utils::rm_last_semi_colon(&code), + "{}", + ); + assert_eq!( + selected, + ExtractStaticStyle::new("color", expected, 0, None) + .extract(None) + .to_string() + ); + } + } + Ok(()) +} + +#[test] +fn opaque_known_atom_when_nested_reports_error_instead_of_applying_global_class() +-> Result<(), Box> { + // Given + let allocator = oxc_allocator::Allocator::default(); + let ast = AstBuilder::new(&allocator); + let expression = oxc_parser::Parser::new( + &allocator, + "`&:hover { ${known}; }`", + oxc_span::SourceType::tsx(), + ) + .parse_expression() + .map_err(|error| format!("failed to parse valid template: {error:?}"))?; + let Expression::TemplateLiteral(template) = expression else { + panic!("expected template") + }; + let imports = rustc_hash::FxHashMap::default(); + let values = crate::style_values::StyleValues::default(); + let inline_css = rustc_hash::FxHashMap::from_iter([( + template.expressions[0].span().start, + vec![crate::ExtractStyleValue::Typography("body".into())], + )]); + let bindings = StyledBindings { + imports: &imports, + values: &values, + inline_css: &inline_css, + }; + // When + let result = compose(&ast, &template, &bindings); + // Then + assert_eq!(result.styles.len(), 0); + assert_eq!(result.errors.len(), 1); + assert_eq!(result.errors[0].0, template.expressions[0].span().start); + assert!(result.errors[0].1.contains("nested mixin")); + Ok(()) +} + +#[test] +fn unknown_rule_when_reader_receives_it_fails_instead_of_silently_discarding_it() { + // Given + let allocator = oxc_allocator::Allocator::default(); + let ast = AstBuilder::new(&allocator); + let imports = rustc_hash::FxHashMap::default(); + let values = crate::style_values::StyleValues::default(); + let inline_css = rustc_hash::FxHashMap::default(); + let bindings = StyledBindings { + imports: &imports, + values: &values, + inline_css: &inline_css, + }; + let mut rules = Expression::new_identifier(oxc_span::SPAN, "unknown", &ast); + // When + let result = MixinReader { + ast: &ast, + bindings: &bindings, + params: None, + } + .read(&mut rules); + // Then + assert!(result.is_none()); +} diff --git a/libs/extractor/src/import_alias_visit.rs b/libs/extractor/src/import_alias_visit.rs index 321e25ef2..b7ee60bad 100644 --- a/libs/extractor/src/import_alias_visit.rs +++ b/libs/extractor/src/import_alias_visit.rs @@ -94,6 +94,10 @@ impl LibraryNumbers<'_> { for element in &array.elements { if let Some(element) = element.as_expression() { self.pixelify(element); + } else if let oxc_ast::ast::ArrayExpressionElement::SpreadElement(spread) = + element + { + self.pixelify(&spread.argument); } } } @@ -147,6 +151,10 @@ impl LibraryNumbers<'_> { for element in &array.elements { if let Some(element) = element.as_expression() { self.pixelify_css(element); + } else if let oxc_ast::ast::ArrayExpressionElement::SpreadElement(spread) = + element + { + self.pixelify_css(&spread.argument); } } } @@ -279,7 +287,10 @@ impl<'a> Visit<'a> for LibraryNumbers<'a> { Some(RulesAt::EveryArgument) => call .arguments .iter() - .filter_map(Argument::as_expression) + .filter_map(|argument| match argument { + Argument::SpreadElement(spread) => Some(&spread.argument), + argument => argument.as_expression(), + }) .collect(), None => vec![], }; @@ -818,6 +829,10 @@ fn generate_transformed_import( result } +#[cfg(test)] +#[path = "numeric_context_w27_tests.rs"] +mod numeric_context_w27_tests; + #[cfg(test)] mod tests { use super::*; diff --git a/libs/extractor/src/imported_constants.rs b/libs/extractor/src/imported_constants.rs index 9454252ee..b940c68f7 100644 --- a/libs/extractor/src/imported_constants.rs +++ b/libs/extractor/src/imported_constants.rs @@ -6,7 +6,7 @@ use std::cell::Cell; use std::collections::BTreeSet; use std::rc::Rc; -use oxc_allocator::{Allocator, FromIn, GetAllocator}; +use oxc_allocator::{Address, Allocator, CloneIn, FromIn, GetAddress, GetAllocator}; use oxc_ast::ast::{ Argument, ArrayExpressionElement, Expression, ImportDeclarationSpecifier, JSXAttributeItem, JSXElementName, ObjectPropertyKind, Program, Statement, Str, VariableDeclarationKind, @@ -177,13 +177,15 @@ struct LocalConstant { name: String, /// Where its binding is written binding: u32, - value: Constant, + end: u32, + value: Option, } -/// The `const` declarations holding object or array literals all written out +/// All simple `const` declarations, in declaration order, evaluated only after +/// semantic scoping has resolved their dependencies. #[derive(Default)] struct LocalConstants { - found: Vec, + bindings: FxHashMap, } /// `expression` as the value it writes out, when every part of it is written @@ -228,14 +230,83 @@ impl<'a> Visit<'a> for LocalConstants { for declarator in &declaration.declarations { if let oxc_ast::ast::BindingPattern::BindingIdentifier(binding) = &declarator.id && let Some(init) = &declarator.init - && let Some(value) = written_out(init) - && matches!(value, Constant::Record(_) | Constant::Array(_)) { - self.found.push(LocalConstant { - name: binding.name.to_string(), - binding: binding.span.start, - value, + self.bindings.insert(binding.span.start, init.span().end); + } + } + } + walk::walk_variable_declaration(self, declaration); + } +} + +struct ScopedLocals<'s, 'p, 'a, 'r> { + scope: &'s mut ModuleScope<'p, 'a>, + modules: &'s mut Modules<'r>, + scoping: &'s Scoping, + found: &'s mut Vec, + builder: &'s AstBuilder<'a>, + shapes: &'s mut FxHashMap>, + ends: &'s FxHashMap, + invalid: &'s mut FxHashSet, +} + +impl<'a> Visit<'a> for ScopedLocals<'_, '_, 'a, '_> { + fn visit_variable_declaration(&mut self, declaration: &oxc_ast::ast::VariableDeclaration<'a>) { + if declaration.kind == VariableDeclarationKind::Const { + for declarator in &declaration.declarations { + if let oxc_ast::ast::BindingPattern::BindingIdentifier(binding) = &declarator.id + && let Some(init) = &declarator.init + && let Some(symbol) = binding.symbol_id.get() + { + let written = self + .scoping + .get_resolved_reference_ids(symbol) + .iter() + .any(|reference| self.scoping.get_reference(*reference).is_write()); + let value = + written_out(init).or_else(|| self.scope.evaluate(self.modules, init)); + if let Some(value) = &value + && !written + && !value.is_mutable() + && value.js_literal().is_some() + { + self.scope + .exact + .insert(symbol, (init.span().end, value.clone())); + } + let exact = value.filter(|value| { + matches!(value, Constant::Record(_) | Constant::Array(_)) + && value.js_literal().is_some() }); + let shape = if exact.is_none() + && matches!( + crate::utils::unwrap_syntax_only(init), + Expression::ObjectExpression(_) | Expression::ArrayExpression(_) + ) { + local_rule_shapes::prepare(self.builder, init, &mut |expression| { + constant_literal( + self.builder, + &self.scope.evaluate(self.modules, expression)?, + true, + ) + }) + } else { + None + }; + if exact.is_some() || shape.is_some() { + let tdz = local_rule_shapes::has_tdz(init, self.scoping, self.ends); + if tdz { + self.invalid.insert(symbol); + } else if let Some(shape) = shape { + self.shapes.insert(symbol, shape); + } + self.found.push(LocalConstant { + name: binding.name.to_string(), + binding: binding.span.start, + end: init.span().end, + value: exact.filter(|_| !tdz), + }); + } } } } @@ -437,14 +508,14 @@ pub(crate) fn inline_constants<'a>( class_names: Vec::new(), }; read.visit_program(program); - if read.names.is_empty() { + if read.names.is_empty() && px.is_empty() { return Inlined::default(); } let mut locals = LocalConstants::default(); locals.visit_program(program); - locals - .found - .retain(|local| read.names.contains(&local.name)); + let mut found = Vec::new(); + let mut shapes = FxHashMap::default(); + let mut invalid = FxHashSet::default(); let mut modules = Modules { resolver, option, @@ -453,7 +524,11 @@ pub(crate) fn inline_constants<'a>( }; let mut symbols: FxHashMap = FxHashMap::default(); let mut inlined = Inlined::default(); - let (scoping, reads_math) = { + let scoping = SemanticBuilder::new() + .build(program) + .semantic + .into_scoping(); + let reads_math = { let mut scope = ModuleScope::new(filename, program, None); scope .style_names @@ -521,15 +596,12 @@ pub(crate) fn inline_constants<'a>( // hold a constant let reads_math = read.names.contains("Math") && !scope.binds("Math"); if !reads_math - && locals.found.is_empty() + && px.is_empty() + && locals.bindings.is_empty() && !read.names.iter().any(|name| scope.binds(name)) { return Inlined::default(); } - let scoping = SemanticBuilder::new() - .build(program) - .semantic - .into_scoping(); for name in &read.names { let bound = scope.binds(name); let constant = scope.lookup(&mut modules, name); @@ -581,46 +653,103 @@ pub(crate) fn inline_constants<'a>( symbols.extend(symbol.get().map(|symbol| (symbol, constant.clone()))); } } - (scoping, reads_math) + scope.scoped = Some(&scoping); + ScopedLocals { + scope: &mut scope, + modules: &mut modules, + scoping: &scoping, + found: &mut found, + builder: ast_builder, + shapes: &mut shapes, + ends: &locals.bindings, + invalid: &mut invalid, + } + .visit_program(program); + reads_math }; inlined.dependencies = modules.exports.into_keys().collect(); + let callbacks = bound_style_callbacks::rewrite(ast_builder, program, &scoping, &option.package); + inlined.errors.extend(callbacks.errors); + let mut captures = callbacks.captures; + shapes.retain(|symbol, shape| { + if let Some(captured) = local_rule_shapes::collect_captures(shape, &scoping) { + captures.extend(captured); + true + } else { + invalid.insert(*symbol); + false + } + }); + let pixel_callbacks = callbacks + .sites + .into_iter() + .filter_map(|(span, address)| px.contains(&(span.start, span.end)).then_some(address)) + .collect(); let mut declared = Vec::new(); for symbol in scoping.symbol_ids() { if scoping.symbol_scope_id(symbol) != scoping.root_scope_id() && scoping.symbol_flags(symbol).is_const_variable() - && let Some(local) = locals - .found + && let Some(local) = found .iter() .find(|local| local.binding == scoping.symbol_span(symbol).start) { - symbols.insert(symbol, local.value.clone()); + if let Some(value) = &local.value { + symbols.insert(symbol, value.clone()); + } declared.push((symbol, local)); } } - if !symbols.is_empty() || reads_math { + if !symbols.is_empty() + || !shapes.is_empty() + || !invalid.is_empty() + || reads_math + || !px.is_empty() + || !captures.is_empty() + { + let aliases = local_capture_reads::aliases(program, &scoping, &shapes, &captures); + let deferred = local_capture_reads::deferred_reads(program, &scoping); let mut inline = Inline { ast_builder, scoping: &scoping, symbols: &symbols, + shapes: &shapes, + aliases: &aliases, + deferred: &deferred, + local_ends: declared + .iter() + .map(|(symbol, local)| (*symbol, local.end)) + .collect(), + invalid: &invalid, style_roots: &style_roots, css_props: &css_props, apis: &apis, objects: false, styles: false, px: false, + numeric_role: NumericRole::Other, marks: px, + pixel_callbacks, replaced: FxHashMap::default(), + rejected: FxHashSet::default(), class_names: Vec::new(), }; inline.visit_program(program); for (symbol, local) in declared { let read = inline.replaced.get(&symbol).copied().unwrap_or(0); - if read > 0 && read < scoping.get_resolved_reference_ids(symbol).len() { + if inline.rejected.contains(&symbol) + || (read > 0 && read < scoping.get_resolved_reference_ids(symbol).len()) + { inlined .errors .push((local.binding, crate::utils::local_style_error(&local.name))); } } + local_capture_reads::Rename { + builder: ast_builder, + scoping: &scoping, + aliases: &aliases, + } + .visit_program(program); } inlined } @@ -1160,6 +1289,9 @@ struct ModuleScope<'p, 'a> { css_props: Option<&'p CssTakers<'p>>, uses: Option>>>, changes: FxHashMap>>, + /// Local rule evaluation resolves dependencies by symbol, never by name. + scoped: Option<&'p Scoping>, + exact: FxHashMap, } impl<'p, 'a> ModuleScope<'p, 'a> { @@ -1176,6 +1308,8 @@ impl<'p, 'a> ModuleScope<'p, 'a> { css_props: None, uses: None, changes: FxHashMap::default(), + scoped: None, + exact: FxHashMap::default(), } } @@ -1334,7 +1468,17 @@ impl<'p, 'a> ModuleScope<'p, 'a> { fn is_global_math(&self, expression: &Expression<'_>) -> bool { matches!(expression, Expression::Identifier(identifier) - if identifier.name == "Math" && !self.binds("Math")) + if identifier.name == "Math" && self.is_global(identifier)) + } + + fn is_global(&self, identifier: &oxc_ast::ast::IdentifierReference<'_>) -> bool { + match self.scoped { + Some(scoping) => identifier + .reference_id + .get() + .is_some_and(|reference| scoping.get_reference(reference).symbol_id().is_none()), + None => !self.binds(&identifier.name), + } } /// Record the members of an enum whose values are literals, up to the @@ -1637,7 +1781,7 @@ impl<'p, 'a> ModuleScope<'p, 'a> { Expression::NullLiteral(_) => Some(Constant::Null), Expression::BooleanLiteral(literal) => Some(Constant::Bool(literal.value)), Expression::Identifier(identifier) - if identifier.name == "undefined" && !self.binds("undefined") => + if identifier.name == "undefined" && self.is_global(identifier) => { Some(Constant::Undefined) } @@ -1663,7 +1807,14 @@ impl<'p, 'a> ModuleScope<'p, 'a> { let key = js_string(&self.evaluate(modules, &member.expression)?)?; member_of(&self.evaluate(modules, &member.object)?, &key) } - Expression::Identifier(identifier) => self.lookup(modules, &identifier.name), + Expression::Identifier(identifier) => match self.scoped { + Some(scoping) => { + let reference = scoping.get_reference(identifier.reference_id.get()?); + let (end, value) = self.exact.get(&reference.symbol_id()?)?; + (identifier.span.start >= *end).then(|| value.clone()) + } + None => self.lookup(modules, &identifier.name), + }, Expression::StaticMemberExpression(member) if self.is_global_math(&member.object) => { math_constant(member.property.name.as_str()) } @@ -1681,13 +1832,14 @@ impl<'p, 'a> ModuleScope<'p, 'a> { } fold_math(callee.property.name.as_str(), &arguments) } + _ if self.scoped.is_some() => None, callee if self.is_style_api(modules, callee) => { Some(Constant::Style(self.css_styles(modules, call))) } _ => self.evaluate_stylex(modules, call), }, Expression::TaggedTemplateExpression(tagged) - if self.is_style_api(modules, &tagged.tag) => + if self.scoped.is_none() && self.is_style_api(modules, &tagged.tag) => { Some(Constant::Style(None)) } @@ -1762,6 +1914,30 @@ impl<'p, 'a> ModuleScope<'p, 'a> { } } +#[cfg(test)] +#[path = "local_exact_constants_w27_tests.rs"] +mod local_exact_constants_w27_tests; + +#[path = "local_rule_shapes_w27.rs"] +mod local_rule_shapes; + +#[path = "local_capture_reads_w27.rs"] +mod local_capture_reads; + +#[path = "bound_style_callbacks.rs"] +mod bound_style_callbacks; + +#[path = "bound_callback_captures.rs"] +mod bound_callback_captures; + +#[cfg(test)] +#[path = "bound_style_callbacks_tests.rs"] +mod bound_style_callbacks_tests; + +#[cfg(test)] +#[path = "bound_callback_alias_tests.rs"] +mod bound_callback_alias_tests; + fn set_entry(entries: &mut Vec<(String, Constant)>, key: String, value: Constant) { match entries.iter_mut().find(|(existing, _)| *existing == key) { Some(entry) => entry.1 = value, @@ -1903,6 +2079,12 @@ fn fold_binary(operator: BinaryOperator, left: &Constant, right: &Constant) -> O }; number.is_finite().then_some(Constant::Number(number)) } +#[derive(Clone, Copy, PartialEq, Eq)] +enum NumericRole { + ElementCssProp, + Other, +} + /// Replaces reads of constants where styles read them: primitives in the /// arguments of style APIs and in style props, objects and arrays where they /// are read as style objects, as a copy elsewhere would change their identity. @@ -1911,6 +2093,11 @@ struct Inline<'s, 'a> { ast_builder: &'s AstBuilder<'a>, scoping: &'s Scoping, symbols: &'s FxHashMap, + shapes: &'s FxHashMap>, + aliases: &'s FxHashMap, + deferred: &'s FxHashSet, + local_ends: FxHashMap, + invalid: &'s FxHashSet, style_roots: &'s FxHashSet<&'s str>, css_props: &'s CssTakers<'s>, apis: &'s StyleApis<'s>, @@ -1920,12 +2107,16 @@ struct Inline<'s, 'a> { styles: bool, /// Inside a `css` prop, whose numbers Emotion reads as `px` lengths px: bool, + numeric_role: NumericRole, /// Where a constant stands as a value or rules of a library reading /// numbers as `px` lengths marks: &'s crate::import_alias_visit::PxMarks, + /// Clone identities retain per-use pixel context without changing source spans. + pixel_callbacks: FxHashSet
, /// How often a style read each binding below the top level, by what it /// held replaced: FxHashMap, + rejected: FxHashSet, /// The names the `` child functions around take `css` and /// `cx` by class_names: Vec, @@ -1937,6 +2128,11 @@ impl<'a> Inline<'_, 'a> { Expression::Identifier(identifier) => { let reference = identifier.reference_id.get()?; let symbol = self.scoping.get_reference(reference).symbol_id()?; + if self.local_ends.get(&symbol).is_some_and(|end| { + identifier.span.start < *end && !self.deferred.contains(&reference) + }) { + return None; + } self.symbols.get(&symbol).cloned() } Expression::StaticMemberExpression(member) if self.is_global_math(&member.object) => { @@ -2132,6 +2328,13 @@ fn px_rules<'a>(ast_builder: &AstBuilder<'a>, rules: &mut Expression<'a>) { } } } + if let Expression::ArrayExpression(array) = rules { + for element in &mut array.elements { + if let Some(expression) = element.as_expression_mut() { + px_rules(ast_builder, expression); + } + } + } } /// `constant` written as a literal, objects and arrays too when `objects` @@ -2189,8 +2392,71 @@ fn constant_literal<'a>( } impl<'a> VisitMut<'a> for Inline<'_, 'a> { + fn visit_formal_parameters(&mut self, parameters: &mut oxc_ast::ast::FormalParameters<'a>) { + let outer_px = std::mem::replace(&mut self.px, false); + walk_mut::walk_formal_parameters(self, parameters); + self.px = outer_px; + } + + fn visit_arrow_function_expression( + &mut self, + arrow: &mut oxc_ast::ast::ArrowFunctionExpression<'a>, + ) { + let outer = std::mem::replace(&mut self.numeric_role, NumericRole::Other); + walk_mut::walk_arrow_function_expression(self, arrow); + self.numeric_role = outer; + } + + fn visit_function( + &mut self, + function: &mut oxc_ast::ast::Function<'a>, + flags: oxc_syntax::scope::ScopeFlags, + ) { + let outer = std::mem::replace(&mut self.numeric_role, NumericRole::Other); + walk_mut::walk_function(self, function, flags); + self.numeric_role = outer; + } + fn visit_expression(&mut self, expression: &mut Expression<'a>) { if self.styles { + if let Expression::Identifier(identifier) = &*expression + && let Some(reference) = identifier.reference_id.get() + && let Some(symbol) = self.scoping.get_reference(reference).symbol_id() + && (self.invalid.contains(&symbol) + || self.local_ends.get(&symbol).is_some_and(|end| { + identifier.span.start < *end && !self.deferred.contains(&reference) + })) + { + self.rejected.insert(symbol); + } + if self.objects + && let Expression::Identifier(identifier) = &*expression + && let Some(reference) = identifier.reference_id.get() + && let Some(symbol) = self.scoping.get_reference(reference).symbol_id() + && self.local_ends.get(&symbol).is_some_and(|end| { + identifier.span.start >= *end || self.deferred.contains(&reference) + }) + && let Some(shape) = self.shapes.get(&symbol) + { + let root = match self.aliases.get(&symbol) { + Some(alias) => Expression::new_identifier( + identifier.span, + self.ast_builder.allocator().alloc_str(alias), + self.ast_builder, + ), + None => expression.clone_in_with_semantic_ids(self.ast_builder.allocator()), + }; + *expression = local_rule_shapes::expand(self.ast_builder, shape, &root); + *self.replaced.entry(symbol).or_default() += 1; + let outer = self.px; + self.px |= self.marks.contains(&(root.span().start, root.span().end)); + if self.px { + px_rules(self.ast_builder, expression); + } + self.visit_expression(expression); + self.px = outer; + return; + } if let Some(literal) = self .constant(expression) .and_then(|constant| self.literal(&constant)) @@ -2218,7 +2484,15 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { return; } } + let outer_px = self.px; + if matches!( + expression, + Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_) + ) { + self.px |= self.pixel_callbacks.contains(&expression.address()); + } walk_mut::walk_expression(self, expression); + self.px = outer_px; } fn visit_tagged_template_expression( @@ -2253,13 +2527,18 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { } fn visit_call_expression(&mut self, call: &mut oxc_ast::ast::CallExpression<'a>) { - self.visit_expression(&mut call.callee); + // Calls consume application inputs; only explicit style APIs read rules. + let outer_px = std::mem::replace(&mut self.px, false); + let outer_role = std::mem::replace(&mut self.numeric_role, NumericRole::Other); + self.reading_objects(false, |inline| inline.visit_expression(&mut call.callee)); if calls_one_of(&self.class_names, &call.callee) { self.reading_css(true, |inline| { for argument in &mut call.arguments { inline.visit_argument(argument); } }); + self.px = outer_px; + self.numeric_role = outer_role; return; } let objects = self.apis.reads(&call.callee); @@ -2273,9 +2552,16 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { match (css, argument) { (Some(css), Argument::ObjectExpression(props)) if index == 1 => { for (at, property) in props.properties.iter_mut().enumerate() { + let outer = inline.numeric_role; + inline.numeric_role = if at == css { + NumericRole::ElementCssProp + } else { + NumericRole::Other + }; inline.reading_css(at == css, |inline| { inline.visit_object_property_kind(property); }); + inline.numeric_role = outer; } } (_, argument) => inline.visit_argument(argument), @@ -2283,6 +2569,14 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { } }); }); + self.px = outer_px; + self.numeric_role = outer_role; + } + + fn visit_new_expression(&mut self, call: &mut oxc_ast::ast::NewExpression<'a>) { + let outer = std::mem::replace(&mut self.numeric_role, NumericRole::Other); + self.reading_css(false, |inline| walk_mut::walk_new_expression(inline, call)); + self.numeric_role = outer; } fn visit_jsx_opening_element(&mut self, element: &mut oxc_ast::ast::JSXOpeningElement<'a>) { @@ -2292,7 +2586,10 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { if self.css_props.attribute(element_name, attribute, |root| { self.style_roots.contains(root) }) { + let outer = self.numeric_role; + self.numeric_role = NumericRole::ElementCssProp; self.reading_css(true, |inline| inline.visit_jsx_attribute_item(attribute)); + self.numeric_role = outer; continue; } let objects = styled @@ -2312,15 +2609,38 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { } fn visit_object_property(&mut self, property: &mut oxc_ast::ast::ObjectProperty<'a>) { - let inlined_number = self.px - && self.styles - && matches!(self.constant(&property.value), Some(Constant::Number(_))); + let span = property.value.span(); + let px = self.styles && (self.px || self.marks.contains(&(span.start, span.end))); + let outer = self.px; + if property.key.static_name().is_some_and(|key| key == "vars") { + self.px = false; + } walk_mut::walk_object_property(self, property); + self.px = outer; if property.shorthand && !matches!(property.value, Expression::Identifier(_)) { property.shorthand = false; } - if inlined_number { + if px && property.key.static_name().is_none_or(|key| key != "vars") { px_value(self.ast_builder, property); + if self.numeric_role == NumericRole::ElementCssProp + && self.px + && property + .key + .static_name() + .is_some_and(|key| !crate::utils::keeps_bare_number(&key)) + && !matches!( + property.value, + Expression::ObjectExpression(_) + | Expression::ArrayExpression(_) + | Expression::ArrowFunctionExpression(_) + | Expression::FunctionExpression(_) + ) + && written_out(&property.value).is_none() + { + use oxc_allocator::TakeIn; + let value = property.value.take_in(self.ast_builder); + property.value = local_rule_shapes::number_length(self.ast_builder, value); + } } } } diff --git a/libs/extractor/src/imported_styled.rs b/libs/extractor/src/imported_styled.rs index d16983969..1e39f1be3 100644 --- a/libs/extractor/src/imported_styled.rs +++ b/libs/extractor/src/imported_styled.rs @@ -13,7 +13,7 @@ use oxc_ast::ast::{ use oxc_ast::builder::AstBuilder; use oxc_ast_visit::{Visit, VisitMut, walk}; use oxc_parser::{Parser, ParserReturn}; -use oxc_span::SourceType; +use oxc_span::{GetSpan, SourceType, Span}; use rustc_hash::{FxHashMap, FxHashSet}; use crate::css_prop::CssProp; @@ -218,6 +218,10 @@ impl<'a> Reader<'_, 'a> { /// The styled components the module at `path` exports, found by compiling /// it as its own extraction does fn read(&mut self, path: &str, code: &str) -> Option> { + self.read_source(path, code, false) + } + + fn read_source(&mut self, path: &str, code: &str, evaluated: bool) -> Option> { if is_vanilla_extract_file(path) { return None; } @@ -264,6 +268,7 @@ impl<'a> Reader<'_, 'a> { ); visitor.import_stylex(inlined.stylex_vars, inlined.stylex_themes); visitor.import_css(inlined.css_styles); + visitor.errors.extend(inlined.errors); visitor.unknown_bindings(&inlined.unknown); visitor.changed_bindings(inlined.changed); visitor.takes_css_prop(css_prop); @@ -272,23 +277,143 @@ impl<'a> Reader<'_, 'a> { let readable = visitor.errors.is_empty() && visitor.unknown_parts.is_empty() && !visitor.composes_unknown; + let components = visitor.components(self.allocator); + let needs_values = components.values().any(|component| { + component + .definition + .as_ref() + .is_some_and(|definition| !definition.portable()) + }); + if (!readable || needs_values) + && !evaluated + && let Some((computed, dependencies)) = + self.evaluate_values(path, code, &inlined.unknown) + { + self.dependencies.extend(dependencies); + return self.read_source(path, &computed, true); + } exports.extend( - visitor - .components(self.allocator) + components .into_iter() .map(|(name, component)| (name, component.shared(readable))), ); Some(exports) } + fn evaluate_values( + &self, + path: &str, + code: &str, + unknown: &crate::imported_constants::Unknown, + ) -> Option<(String, BTreeSet)> { + let Aliased { + code: transformed, + edits: alias_mapping, + .. + } = transform_import_aliases_with_edits( + code, + path, + &self.option.package, + &self.option.import_aliases, + ); + let allocator = Allocator::default(); + let parsed = + Parser::new(&allocator, &transformed, SourceType::from_path(path).ok()?).parse(); + if parsed.fatal_error { + return None; + } + let program = parsed.program; + let scoping = oxc_semantic::SemanticBuilder::new() + .build(&program) + .semantic + .into_scoping(); + let mut sites = ValueSites { + styled: imported_bindings(&program, &self.option.package) + .styled + .into_iter() + .filter_map(|name| scoping.get_root_binding(name.as_str().into())) + .collect(), + scoping: &scoping, + spans: Vec::new(), + }; + sites.visit_program(&program); + let mut index = 0; + let helper = loop { + let name = format!("__devupImportedValues{index}"); + if !code.contains(&name) { + break name; + } + index += 1; + }; + let mut insertions = vec![( + 0, + format!( + "import {{ css as {helper} }} from '{}';\n", + self.option.package + ), + )]; + for span in sites.spans { + insertions.push(( + usize::try_from(span.start).ok()?, + format!("{helper}({{ __value: ("), + )); + insertions.push((usize::try_from(span.end).ok()?, ")})".to_string())); + } + insertions.sort_unstable_by_key(|(offset, _)| *offset); + let mut source = String::new(); + let mut mapping = Vec::new(); + let mut copied = 0; + for (offset, insertion) in insertions { + source.push_str(transformed.get(copied..offset)?); + source.push_str(&insertion); + mapping.push((offset, offset, insertion.len())); + copied = offset; + } + source.push_str(transformed.get(copied..)?); + let (computed, replacements, dependencies) = crate::build_time_values::evaluate( + &source, + path, + self.option, + Some(self.resolver), + unknown, + )?; + let mut result = String::new(); + let mut original = 0; + let mut before = 0; + let mut after = 0_usize; + for (start, end, length) in replacements { + let replaced_at = after.checked_add(start.checked_sub(before)?)?; + // Retry unaliased source so computed Emotion numbers keep px semantics. + let original_start = crate::import_alias_visit::source_offset( + &alias_mapping, + crate::import_alias_visit::source_offset(&mapping, start), + ); + let original_end = crate::import_alias_visit::source_offset( + &alias_mapping, + crate::import_alias_visit::source_offset(&mapping, end), + ); + result.push_str(code.get(original..original_start)?); + result.push_str(computed.get(replaced_at..replaced_at.checked_add(length)?)?); + original = original_end; + before = end; + after = replaced_at.checked_add(length)?; + } + result.push_str(code.get(original..)?); + Some((result, dependencies)) + } + /// What the module re-exports from the modules it exports from fn reexports(&mut self, program: &Program<'_>, path: &str) -> Components<'a> { let mut exports = FxHashMap::default(); for statement in &program.body { match statement { - Statement::ExportFromDeclaration(export) => { + Statement::ExportFromDeclaration(export) if !export.export_kind.is_type() => { if let Some(module) = self.module(&export.source.value, path) { - for specifier in &export.specifiers { + for specifier in export + .specifiers + .iter() + .filter(|specifier| !specifier.export_kind.is_type()) + { if let Some(component) = module.get(specifier.local.name().as_str()) { exports.insert( specifier.exported.name().to_string(), @@ -298,7 +423,9 @@ impl<'a> Reader<'_, 'a> { } } } - Statement::ExportAllDeclaration(export) if export.exported.is_none() => { + Statement::ExportAllDeclaration(export) + if export.exported.is_none() && !export.export_kind.is_type() => + { if let Some(module) = self.module(&export.source.value, path) { for (name, component) in module.iter().filter(|(name, _)| *name != "default") @@ -325,16 +452,21 @@ impl<'a> Reader<'_, 'a> { allocator: &'b Allocator, ) -> Components<'b> { let imported = imported_bindings(program, &self.option.package); + let scoping = oxc_semantic::SemanticBuilder::new() + .build(program) + .semantic + .into_scoping(); + let compat = format!("{}/compat", self.option.package); let mut used = Used { + scoping: &scoping, + css_takers: crate::css_prop::CssTakers::new(program, css_prop, &compat), styled: imported.styled, css_prop: css_prop != CssProp::Off, names: FxHashSet::default(), }; used.visit_program(program); crate::style_values::selectors(program, &mut |expression| { - if let Expression::Identifier(identifier) = expression { - used.names.insert(identifier.name.to_string()); - } + used.select(expression); }); used.names.extend( Exports::scan(program) @@ -426,51 +558,157 @@ fn imported_bindings(program: &Program<'_>, package: &str) -> Imported { fn written_name(expression: &Expression<'_>) -> Option { match unwrap_syntax_only(expression) { Expression::Identifier(identifier) => Some(identifier.name.to_string()), - Expression::StaticMemberExpression(member) => match &member.object { - Expression::Identifier(object) => { - Some(format!("{}.{}", object.name, member.property.name)) - } - _ => None, - }, - _ => None, + expression => { + let (object, property) = crate::style_values::component_member(expression)?; + let Expression::Identifier(object) = unwrap_syntax_only(object) else { + return None; + }; + Some(format!("{}.{property}", object.name)) + } } } /// The bindings a program composes, takes the `css` prop of, or selects -struct Used { +struct Used<'s, 'p> { + scoping: &'s oxc_semantic::Scoping, + css_takers: crate::css_prop::CssTakers<'p>, styled: FxHashSet, css_prop: bool, names: FxHashSet, } -impl<'a> Visit<'a> for Used { +struct ValueSites<'s> { + scoping: &'s oxc_semantic::Scoping, + styled: FxHashSet, + spans: Vec, +} + +impl ValueSites<'_> { + fn is_styled(&self, expression: &Expression<'_>) -> bool { + match unwrap_syntax_only(expression) { + Expression::Identifier(reference) => reference + .reference_id + .get() + .and_then(|reference| self.scoping.get_reference(reference).symbol_id()) + .is_some_and(|symbol| self.styled.contains(&symbol)), + Expression::StaticMemberExpression(member) => self.is_styled(&member.object), + Expression::CallExpression(call) => self.is_styled(&call.callee), + _ => false, + } + } +} + +impl<'a> Visit<'a> for ValueSites<'_> { fn visit_call_expression(&mut self, call: &CallExpression<'a>) { + if self.is_styled(&call.callee) { + self.spans.extend( + call.arguments + .iter() + .filter_map(|argument| argument.as_expression()) + .map(GetSpan::span), + ); + } + walk::walk_call_expression(self, call); + } + + fn visit_tagged_template_expression( + &mut self, + tag: &oxc_ast::ast::TaggedTemplateExpression<'a>, + ) { + if self.is_styled(&tag.tag) { + self.spans + .extend(tag.quasi.expressions.iter().map(GetSpan::span)); + } + walk::walk_tagged_template_expression(self, tag); + } +} + +impl Used<'_, '_> { + fn record(&mut self, reference: &oxc_ast::ast::IdentifierReference<'_>, name: String) { + if let Some(symbol) = reference + .reference_id + .get() + .and_then(|reference| self.scoping.get_reference(reference).symbol_id()) + && self.scoping.get_root_binding(reference.name) == Some(symbol) + && self.scoping.symbol_flags(symbol).is_import() + { + self.names.insert(name); + } + } + + fn select(&mut self, expression: &Expression<'_>) { + match unwrap_syntax_only(expression) { + Expression::TemplateLiteral(template) => { + for expression in &template.expressions { + self.select(expression); + } + } + Expression::Identifier(reference) => { + if let Some(name) = written_name(expression) { + self.record(reference, name); + } + } + Expression::StaticMemberExpression(_) | Expression::ComputedMemberExpression(_) => { + if let Some((object, _)) = crate::style_values::component_member(expression) + && let Expression::Identifier(reference) = unwrap_syntax_only(object) + && let Some(name) = written_name(expression) + { + self.record(reference, name); + } + } + _ => {} + } + } +} + +impl<'a> Visit<'a> for Used<'_, '_> { + fn visit_call_expression(&mut self, call: &CallExpression<'a>) { + if self.css_takers.property(call, |_| false).is_some() + && let Some(element) = call + .arguments + .first() + .and_then(|argument| argument.as_expression()) + { + self.select(element); + } let name = match unwrap_syntax_only(&call.callee) { Expression::Identifier(callee) if self.styled.contains(callee.name.as_str()) => call .arguments .first() - .and_then(|first| first.as_expression()) - .and_then(written_name), + .and_then(|first| first.as_expression()), Expression::StaticMemberExpression(member) if member.property.name == "withComponent" => { - written_name(&member.object) + Some(&member.object) } _ => None, }; - self.names.extend(name); + if let Some(expression) = name { + self.select(expression); + } walk::walk_call_expression(self, call); } fn visit_jsx_opening_element(&mut self, element: &JSXOpeningElement<'a>) { if self.css_prop - && let JSXElementName::IdentifierReference(name) = &element.name && element.attributes.iter().any(|attribute| { matches!(attribute, JSXAttributeItem::Attribute(attribute) if matches!(&attribute.name, JSXAttributeName::Identifier(attribute) if attribute.name == "css")) }) { - self.names.insert(name.name.to_string()); + match &element.name { + JSXElementName::IdentifierReference(name) => { + self.record(name, name.name.to_string()); + } + JSXElementName::MemberExpression(member) => { + if let oxc_ast::ast::JSXMemberExpressionObject::IdentifierReference(root) = + &member.object + { + self.record(root, format!("{}.{}", root.name, member.property.name)); + } + } + _ => {} + } } walk::walk_jsx_opening_element(self, element); } @@ -482,6 +720,7 @@ mod tests { use std::collections::{BTreeSet, HashMap}; use insta::assert_debug_snapshot; + use rstest::rstest; use serial_test::serial; use super::*; @@ -548,6 +787,594 @@ mod tests { } const STYLED: &str = "import styled from '@emotion/styled';\n"; + + #[test] + #[serial] + fn compiled_namespace_css_when_literal_inlines_composed_styles_and_marker() { + // Given + let base = format!("{STYLED}export const Child = styled.span`padding: 4px; color: red;`;"); + let app = format!( + "{STYLED}import * as UI from './base'; import {{ jsx }} from '@emotion/react/jsx-runtime'; const Anchor = styled.div`${{UI.Child}} {{ margin: 0; }}`; export const P = jsx(UI['Child'], {{ css: {{ color: 'blue' }} }});" + ); + reset(); + // When + let output = extract_in( + &[("/src/base.ts", &base), ("/src/app.tsx", &app)], + "/src/app.tsx", + ) + .unwrap(); + // Then + assert!(output.code.contains("jsx(\"span\""), "{}", output.code); + assert!(!output.code.contains("jsx(UI["), "{}", output.code); + assert!(output.code.contains("className:"), "{}", output.code); + let styles: BTreeSet<_> = output + .styles + .iter() + .filter_map(|style| match style { + crate::ExtractStyleValue::Static(style) => { + Some((style.property.as_str(), style.value.as_str())) + } + _ => None, + }) + .collect(); + assert!( + styles.contains(&("padding", "4px")) && styles.contains(&("color", "blue")), + "{styles:?}" + ); + let selector = output + .styles + .iter() + .find_map(|style| { + let text = format!("{style:?}"); + let (_, rest) = text.split_once("& .")?; + rest.split(|c: char| !(c.is_alphanumeric() || c == '-' || c == '_')) + .find(|word| word.contains("--")) + .map(str::to_string) + }) + .unwrap(); + assert!(output.code.contains(&selector), "{}", output.code); + } + + #[rstest] + #[case("jsx(UI.Child, { css: { color: 'blue' }, as: target })", true)] + #[case("jsx(UI.Child, { css: { color: 'blue' }, ...props })", true)] + #[case("(UI) => jsx(UI.Child, { css: { color: 'blue' } })", false)] + #[serial] + fn compiled_namespace_css_when_opaque_or_shadowed_has_literal_parity( + #[case] body: &str, + #[case] overlap: bool, + ) { + // Given + let base = format!("{STYLED}export const Child = styled.span`color: red;`;"); + let app = format!( + "import * as UI from './base'; import {{ jsx }} from '@emotion/react/jsx-runtime'; export const P = {body};" + ); + reset(); + let expected = extract_in( + &[("/src/base.ts", &base), ("/src/app.tsx", &app)], + "/src/app.tsx", + ); + reset(); + let computed = app.replace("UI.Child", "UI['Child']"); + // When + let output = extract_in( + &[("/src/base.ts", &base), ("/src/app.tsx", &computed)], + "/src/app.tsx", + ); + // Then + if overlap { + let expected = expected.unwrap_err(); + let error = output.unwrap_err(); + assert!(expected.starts_with("/src/app.tsx:1:114:"), "{expected}"); + assert!(error.starts_with("/src/app.tsx:1:117:"), "{error}"); + assert_eq!( + error + .split_once(": ") + .unwrap() + .1 + .replace("UI[\"Child\"]", "UI.Child"), + expected.split_once(": ").unwrap().1 + ); + } else { + let output = output.unwrap(); + assert_eq!(output.styles, expected.unwrap().styles); + assert!(output.code.contains("jsx(UI["), "{}", output.code); + } + } + + #[test] + #[serial] + fn imported_computation_when_exact_retains_values_and_dependencies() { + // Given + let base = format!( + "{STYLED}import {{ size }} from './values'; const scaled = n => n * 4; export const Rules = styled.div({{ padding: scaled(size), color: 'red' }});" + ); + let app = format!( + "{STYLED}import {{ Rules }} from './base'; export const A = styled(Rules)`color: blue;`;" + ); + reset(); + // When + let output = extract_in( + &[ + ("/src/values.ts", "export const size = 2;"), + ("/src/base.ts", &base), + ("/src/app.tsx", &app), + ], + "/src/app.tsx", + ) + .unwrap(); + // Then + let mut styles: Vec<(&str, &str)> = output + .styles + .iter() + .map(|style| match style { + crate::ExtractStyleValue::Static(style) => { + (style.property.as_str(), style.value.as_str()) + } + other => panic!("expected static style, got {other:?}"), + }) + .collect(); + styles.sort_unstable(); + assert_eq!(styles, [("color", "blue"), ("padding", "8px")]); + assert!(output.code.contains("DevupAs = \"div\""), "{}", output.code); + assert_eq!(output.dependencies, ["/src/base.ts", "/src/values.ts"]); + } + + #[test] + #[serial] + fn imported_attrs_when_sibling_computation_is_exact_remain_portable() { + // Given + let base = format!( + "{STYLED}const scaled = n => n * 4; const size = 2; export const Rules = styled.div({{ padding: scaled(size) }}); export const Attrs = styled.div.attrs({{ id: 'known' }})`margin: 1px;`;" + ); + let app = format!( + "{STYLED}import {{ Attrs }} from './base'; export const A = styled(Attrs)`color: blue;`;" + ); + reset(); + // When + let output = extract_in( + &[("/src/base.ts", &base), ("/src/app.tsx", &app)], + "/src/app.tsx", + ) + .unwrap(); + // Then + assert!(output.code.contains("DevupAs = \"div\""), "{}", output.code); + assert!(output.code.contains("id: \"known\""), "{}", output.code); + assert_eq!(output.styles.len(), 2); + assert_eq!(output.dependencies, ["/src/base.ts"]); + } + + #[rstest] + #[case("styled.div({ padding: scaled(size), color: 'red' })")] + #[case("styled.div`padding: ${scaled(size)}px; color: red;`")] + #[case("styled.div(make(size))")] + #[serial] + fn imported_computation_when_rules_use_different_static_forms_is_exact(#[case] factory: &str) { + // Given + let base = format!( + "{STYLED}const __devupImportedValues0 = 'reserved'; externalEffect(); const scaled = n => n * 4; const make = n => ({{ padding: n * 4, color: 'red' }}); const size = 2; export const Rules = {factory};" + ); + let app = format!( + "{STYLED}import {{ Rules }} from './base'; export const A = styled(Rules)`color: blue;`;" + ); + reset(); + // When + let output = extract_in( + &[("/src/base.ts", &base), ("/src/app.tsx", &app)], + "/src/app.tsx", + ) + .unwrap(); + // Then + let mut styles: Vec<(&str, &str)> = output + .styles + .iter() + .map(|style| match style { + crate::ExtractStyleValue::Static(style) => { + (style.property.as_str(), style.value.as_str()) + } + other => panic!("expected static style, got {other:?}"), + }) + .collect(); + styles.sort_unstable(); + assert_eq!(styles, [("color", "blue"), ("padding", "8px")]); + assert!(output.code.contains("DevupAs = \"div\""), "{}", output.code); + assert!( + !output.code.contains("__devupImportedValues"), + "{}", + output.code + ); + } + + #[rstest] + #[case("const scaled = n => n * 4; const size = runtimeSize;", "scaled(size)")] + #[case("const scaled = n => n * 4; let size = 2; size = 3;", "scaled(size)")] + #[case( + "const scaled = n => n * 4; const size = Math.random();", + "scaled(size)" + )] + #[case( + "const scaled = n => { externalEffect(); return n * 4; }; const size = 2;", + "scaled(size)" + )] + #[case( + "const box = { size: 2 }; box.size = 3; const scaled = n => n * 4;", + "scaled(box.size)" + )] + #[serial] + fn imported_computation_when_runtime_or_mutating_remains_opaque( + #[case] scope: &str, + #[case] value: &str, + ) { + // Given + let base = + format!("{STYLED}{scope} export const Rules = styled.div({{ padding: {value} }});"); + let app = format!( + "{STYLED}import {{ Rules }} from './base'; export const A = styled(Rules)`color: blue;`;" + ); + reset(); + // When + let output = extract_in( + &[("/src/base.ts", &base), ("/src/app.tsx", &app)], + "/src/app.tsx", + ) + .unwrap(); + // Then + assert!(output.code.contains("DevupAs = Rules"), "{}", output.code); + assert_eq!(output.styles.len(), 1); + assert_eq!(output.dependencies, ["/src/base.ts"]); + } + + #[rstest] + #[case("export const P = styled.div`${UI.Child} { margin: 0; }`;")] + #[case("export const P = styled.div({ [UI.Child]: { margin: 0 } });")] + #[case("export const P = styled.div({ [`& ${UI.Child}`]: { margin: 0 } });")] + #[case("export const P = styled(UI.Child)`color: blue;`;")] + #[case("export const P = UI.Child.withComponent('section');")] + #[case( + "import { jsx } from '@emotion/react/jsx-runtime'; export const P = jsx(UI.Child, { css: { color: 'blue' } });" + )] + #[serial] + fn namespace_computed_when_literal_matches_dot_access(#[case] body: &str) { + // Given + let base = format!("{STYLED}export const Child = styled.div`padding: 4px;`;"); + let dot = format!("{STYLED}import * as UI from './base';\n{body}"); + let computed = dot.replace("UI.Child", "UI['Child']"); + reset(); + let expected = extract_in( + &[("/src/base.ts", &base), ("/src/app.tsx", &dot)], + "/src/app.tsx", + ) + .unwrap(); + reset(); + // When + let output = extract_in( + &[("/src/base.ts", &base), ("/src/app.tsx", &computed)], + "/src/app.tsx", + ) + .unwrap(); + // Then + assert_eq!(output.styles, expected.styles); + assert_eq!(output.dependencies, expected.dependencies); + assert_eq!(output.code, expected.code); + } + + #[rstest] + #[case("export const P = (UI) => styled.div`${UI['Child']} { margin: 0; }`;")] + #[case("export const P = styled.div`${UI[key]} { margin: 0; }`;")] + #[case("export const P = styled.div({ [UI[key]]: { margin: 0 } });")] + #[serial] + fn namespace_computed_when_shadowed_or_unknown_reports_error(#[case] body: &str) { + // Given + let base = format!("{STYLED}export const Child = styled.div`padding: 4px;`;"); + let app = format!( + "{STYLED}import * as UI from './base'; const Anchor = styled.div`${{UI.Child}} {{ margin: 0; }}`;\n{body}" + ); + reset(); + // When + let error = extract_in( + &[("/src/base.ts", &base), ("/src/app.tsx", &app)], + "/src/app.tsx", + ) + .unwrap_err(); + // Then + assert!(error.starts_with("/src/app.tsx:3:"), "{error}"); + assert!(error.contains("UI["), "{error}"); + } + + #[test] + #[serial] + fn namespace_selectors_when_imported_match_named_selectors() { + let base = format!("{STYLED}export const Child = styled.div`color: red;`;"); + for body in [ + "export const P = styled.div`${Child} { margin: 0; }`;", + "export const P = styled.div({ [Child]: { margin: 0 } });", + "export const P = styled.div({ [`&:hover ${Child}`]: { margin: 0 } });", + ] { + let named = format!("{STYLED}import {{ Child }} from './base';\n{body}"); + let namespace = format!( + "{STYLED}import * as UI from './base';\n{}", + body.replace("Child", "UI.Child") + ); + reset(); + let named = extract_in( + &[("/src/base.ts", &base), ("/src/app.tsx", &named)], + "/src/app.tsx", + ) + .unwrap(); + reset(); + let namespace = extract_in( + &[("/src/base.ts", &base), ("/src/app.tsx", &namespace)], + "/src/app.tsx", + ) + .unwrap(); + assert_eq!(namespace.styles, named.styles, "{body}"); + assert_eq!(namespace.dependencies, named.dependencies); + assert_eq!(namespace.dependencies, ["/src/base.ts"]); + } + } + + #[test] + #[serial] + fn namespace_css_props_when_imported_match_named_props() { + let base = format!("{STYLED}export const Child = styled.div`color: red; padding: 4px;`;"); + let header = "/** @jsxImportSource @emotion/react */\n"; + for body in [ + "export const C = () => ;", + "export const C = () => ;", + "export const C = () => ;", + "import { jsx } from 'react/jsx-runtime'; export const C = () => jsx(Child, { css: { color: 'blue' } });", + ] { + let named = format!("{header}import {{ Child }} from './base';\n{body}"); + let namespace = format!( + "{header}import * as UI from './base';\n{}", + body.replace("Child", "UI.Child") + ); + reset(); + let named = extract_in( + &[("/src/base.ts", &base), ("/src/app.tsx", &named)], + "/src/app.tsx", + ); + reset(); + let namespace = extract_in( + &[("/src/base.ts", &base), ("/src/app.tsx", &namespace)], + "/src/app.tsx", + ); + match (named, namespace) { + (Ok(named), Ok(namespace)) => { + assert_eq!(namespace.styles, named.styles, "{body}"); + let colors: Vec<&str> = namespace + .styles + .iter() + .filter_map(|style| match style { + crate::ExtractStyleValue::Static(style) + if style.property == "color" => + { + Some(style.value.as_str()) + } + _ => None, + }) + .collect(); + assert_eq!(colors, ["blue"]); + assert_eq!(namespace.dependencies, named.dependencies); + assert_eq!( + namespace.code.replace("import * as UI", "import { Child }"), + named.code + ); + } + (Err(named), Err(namespace)) => { + assert!(namespace.starts_with("/src/app.tsx:")); + let named_location = + named.split_once(": ").unwrap().0.rsplit_once(':').unwrap(); + let namespace_location = namespace + .split_once(": ") + .unwrap() + .0 + .rsplit_once(':') + .unwrap(); + assert_eq!(namespace_location.0, named_location.0); + assert_eq!( + namespace_location.1.parse::().unwrap(), + named_location.1.parse::().unwrap() + 3 + ); + assert_eq!( + namespace + .split_once(": ") + .unwrap() + .1 + .replace("UI.Child", "Child"), + named.split_once(": ").unwrap().1 + ); + } + (named, namespace) => panic!("{body}: {named:?} != {namespace:?}"), + } + } + } + + #[test] + #[serial] + fn namespace_reexports_when_selected_preserve_defining_markers() { + let base = format!("{STYLED}export const Child = styled.div`color: red;`;"); + let forward = "export { Child as Renamed } from './base';"; + let named = format!( + "{STYLED}import {{ Renamed }} from './forward'; export const P = styled.div`${{Renamed}} {{ margin: 0; }}`;" + ); + let namespace = format!( + "{STYLED}import * as UI from './forward'; export const P = styled.div`${{UI.Renamed}} {{ margin: 0; }}`;" + ); + reset(); + let named = extract_in( + &[ + ("/src/base.ts", &base), + ("/src/forward.ts", forward), + ("/src/app.tsx", &named), + ], + "/src/app.tsx", + ) + .unwrap(); + reset(); + let namespace = extract_in( + &[ + ("/src/base.ts", &base), + ("/src/forward.ts", forward), + ("/src/app.tsx", &namespace), + ], + "/src/app.tsx", + ) + .unwrap(); + assert_eq!(namespace.styles, named.styles); + assert_eq!(namespace.dependencies, ["/src/base.ts", "/src/forward.ts"]); + } + + #[test] + #[serial] + fn namespace_css_props_when_shadowed_keep_the_runtime_component() { + let base = format!("{STYLED}export const Child = styled.div`color: red; padding: 4px;`;"); + for body in [ + "export const C = (UI) => ;", + "import { jsx } from 'react/jsx-runtime'; export const C = (UI) => jsx(UI.Child, { css: { color: 'blue' } });", + ] { + let app = format!( + "/** @jsxImportSource @emotion/react */\nimport * as UI from './base';\nexport const A = ;\n{body}" + ); + reset(); + let output = extract_in( + &[("/src/base.ts", &base), ("/src/app.tsx", &app)], + "/src/app.tsx", + ) + .unwrap(); + assert!(output.code.contains("UI.Child")); + assert_eq!(output.styles.len(), 3); + assert!( + output + .styles + .iter() + .all(|style| !format!("{style:?}").contains("4px")) + ); + } + } + + #[test] + #[serial] + fn namespace_selectors_when_shadowed_are_not_imported_markers() { + let base = format!("{STYLED}export const Child = styled.div`color: red;`;"); + for body in [ + "export const P = (UI) => styled.div`${UI.Child} { margin: 0; }`;", + "export const P = (UI) => styled.div({ [UI.Child]: { margin: 0 } });", + "export const P = (UI) => styled.div({ [`& ${UI.Child}`]: { margin: 0 } });", + ] { + let app = format!( + "{STYLED}import * as UI from './base';\nconst Anchor = styled.div`${{UI.Child}} {{ padding: 0; }}`;\n{body}" + ); + reset(); + let error = extract_in( + &[("/src/base.ts", &base), ("/src/app.tsx", &app)], + "/src/app.tsx", + ) + .unwrap_err(); + assert!(error.starts_with("/src/app.tsx:"), "{error}"); + assert!(error.contains("UI.Child"), "{error}"); + } + } + + #[test] + #[serial] + fn namespace_css_props_when_no_value_export_exists_do_not_inherit_styles() { + let base = format!("{STYLED}export const Child = styled.div`padding: 4px;`;"); + for import in [ + "import * as UI from './base';", + "import type * as UI from './base';", + "import { Child as UI } from './base';", + "import { type Child as UI } from './base';", + ] { + let member = if import == "import * as UI from './base';" { + "Missing" + } else { + "Child" + }; + let app = format!( + "/** @jsxImportSource @emotion/react */\n{import}\nexport const C = () => ;" + ); + reset(); + let output = extract_in( + &[("/src/base.ts", &base), ("/src/app.tsx", &app)], + "/src/app.tsx", + ) + .unwrap(); + assert!(output.code.contains(&format!("UI.{member}"))); + assert_eq!(output.styles.len(), 1); + } + } + + #[test] + #[serial] + fn module_definitions_when_inlining_reports_invalid_locals_are_opaque() { + let allocator = Allocator::default(); + let option = option(); + let resolver = |_: &str, _: &str| None; + let mut reader = Reader { + option: &option, + resolver: &resolver, + allocator: &allocator, + modules: FxHashMap::default(), + reading: Vec::new(), + dependencies: BTreeSet::new(), + }; + let code = format!( + "{STYLED}import {{ css }} from '@devup-ui/react'; export const Child = styled.div`padding: 4px;`; export function bad() {{ const local = {{ color: 'red' }}; expose(local); return css(local); }}" + ); + reset(); + let module = reader.read("/src/base.ts", &code).unwrap(); + assert!(module["Child"].definition.is_none()); + assert_eq!(module["Child"].markers.len(), 1); + } + + #[test] + #[serial] + fn namespace_type_reexports_when_given_css_do_not_become_value_definitions() { + let base = format!("{STYLED}export const Child = styled.div`padding: 4px;`;"); + let app = "/** @jsxImportSource @emotion/react */\nimport * as UI from './forward'; export const C = () => ;"; + for forward in [ + "export type { Child } from './base';", + "export { type Child } from './base';", + "export type * from './base';", + ] { + reset(); + let output = extract_in( + &[ + ("/src/base.ts", &base), + ("/src/forward.ts", forward), + ("/src/app.tsx", app), + ], + "/src/app.tsx", + ) + .unwrap(); + assert!(output.code.contains(" (p.big ? '20px' : '10px')};`; export const Pick = styled.p.withConfig({ shouldForwardProp: (prop) => prop !== 'tone' })`color: ${(p) => p.tone};`; @@ -899,3 +1726,6 @@ export default styled.span`margin: 1px;`; assert_eq!(exports.index("default"), 0); } } +#[cfg(test)] +#[path = "imported_styled_coverage_tests.rs"] +mod coverage_tests; diff --git a/libs/extractor/src/imported_styled_coverage_tests.rs b/libs/extractor/src/imported_styled_coverage_tests.rs new file mode 100644 index 000000000..071372dde --- /dev/null +++ b/libs/extractor/src/imported_styled_coverage_tests.rs @@ -0,0 +1,55 @@ +use super::{Reader, written_name}; +use crate::ExtractOption; +use oxc_allocator::Allocator; +use oxc_parser::Parser; +use oxc_span::SourceType; +use rstest::rstest; +use rustc_hash::FxHashMap; +use std::collections::BTreeSet; + +#[test] +fn imported_evaluation_when_source_is_unparseable_returns_no_computed_module() { + // Given + let allocator = Allocator::default(); + let option = ExtractOption::default(); + let resolver = |_: &str, _: &str| None; + let reader = Reader { + option: &option, + resolver: &resolver, + allocator: &allocator, + modules: FxHashMap::default(), + reading: Vec::new(), + dependencies: BTreeSet::new(), + }; + // When + let result = reader.evaluate_values( + "/w27/broken.tsx", + "export const Broken = ;", + &crate::imported_constants::Unknown::default(), + ); + // Then + assert_eq!(result, None); +} + +#[rstest] +#[case("Child", Some("Child"))] +#[case("UI.Child", Some("UI.Child"))] +#[case("UI['Child']", Some("UI.Child"))] +#[case("getUI().Child", None)] +#[case("UI.Nested.Child", None)] +#[case("UI[key]", None)] +fn imported_name_when_member_is_not_exact_does_not_select_a_definition( + #[case] source: &str, + #[case] expected: Option<&str>, +) -> Result<(), Box> { + // Given + let allocator = Allocator::default(); + let expression = Parser::new(&allocator, source, SourceType::tsx()) + .parse_expression() + .map_err(|error| format!("{error:?}"))?; + // When + let name = written_name(&expression); + // Then + assert_eq!(name.as_deref(), expected); + Ok(()) +} diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 2939468b5..ad83e9b92 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -2,6 +2,8 @@ mod as_visit; mod build_time_values; mod component; mod composition; +#[cfg(test)] +mod css_composition_rest_tests; mod css_prop; mod css_utils; pub mod extract_style; diff --git a/libs/extractor/src/local_capture_reads_w27.rs b/libs/extractor/src/local_capture_reads_w27.rs new file mode 100644 index 000000000..368efbddc --- /dev/null +++ b/libs/extractor/src/local_capture_reads_w27.rs @@ -0,0 +1,184 @@ +use oxc_allocator::GetAllocator; +use oxc_ast::{ + ast::{ + ArrowFunctionExpression, BindingIdentifier, CallExpression, Expression, Function, + IdentifierReference, ObjectProperty, Program, + }, + builder::AstBuilder, +}; +use oxc_ast_visit::{Visit, VisitMut, walk, walk_mut}; +use oxc_semantic::Scoping; +use oxc_span::Span; +use oxc_syntax::{reference::ReferenceId, symbol::SymbolId}; +use rustc_hash::{FxHashMap, FxHashSet}; + +#[path = "capture_public_exports_w27.rs"] +mod public_exports; + +/// Names generated styled scopes bind after semantic references become strings. +pub(super) fn generated_name(name: &str) -> bool { + matches!( + name, + "rest" | "style" | "className" | "DevupAs" | "forwardedAs" + ) || name.starts_with("__devup") +} + +pub(super) fn aliases( + program: &Program<'_>, + scoping: &Scoping, + shapes: &FxHashMap>, + captures: &FxHashSet, +) -> FxHashMap { + #[derive(Default)] + struct Names(FxHashSet); + impl<'a> Visit<'a> for Names { + fn visit_binding_identifier(&mut self, identifier: &BindingIdentifier<'a>) { + self.0.insert(identifier.name.to_string()); + } + fn visit_identifier_reference(&mut self, identifier: &IdentifierReference<'a>) { + self.0.insert(identifier.name.to_string()); + } + } + let mut names = Names::default(); + names.visit_program(program); + let mut symbols: Vec<_> = shapes + .keys() + .copied() + .chain(captures.iter().copied()) + .collect(); + symbols.sort_unstable_by_key(|symbol| scoping.symbol_span(*symbol).start); + symbols.dedup(); + let mut aliases = FxHashMap::default(); + for symbol in symbols { + let name = scoping.symbol_name(symbol); + if !generated_name(name) && !captures.contains(&symbol) { + continue; + } + let base = format!("__devup_{name}"); + let mut alias = base.clone(); + let mut suffix = 0usize; + while names.0.contains(&alias) || generated_reserved(&alias) { + suffix += 1; + alias = format!("{base}{suffix}"); + } + names.0.insert(alias.clone()); + aliases.insert(symbol, alias); + } + aliases +} + +fn generated_reserved(name: &str) -> bool { + matches!( + name, + "__devupProps" + | "__devupContext" + | "__devupAttrs" + | "__devupValue" + | "__devupMixin" + | "__devupForwardRef" + | "__devupRefProps" + | "__devupRef" + | "__devupDom" + ) || name.starts_with("__devupOmit") +} + +pub(super) struct Rename<'s, 'a> { + pub builder: &'s AstBuilder<'a>, + pub scoping: &'s Scoping, + pub aliases: &'s FxHashMap, +} + +impl<'a> VisitMut<'a> for Rename<'_, 'a> { + fn visit_program(&mut self, program: &mut Program<'a>) { + public_exports::preserve(program, self); + walk_mut::walk_program(self, program); + } + + fn visit_binding_identifier(&mut self, identifier: &mut BindingIdentifier<'a>) { + if let Some(alias) = identifier + .symbol_id + .get() + .and_then(|symbol| self.aliases.get(&symbol)) + { + identifier.name = self.builder.allocator().alloc_str(alias).into(); + } + } + fn visit_identifier_reference(&mut self, identifier: &mut IdentifierReference<'a>) { + if let Some(alias) = identifier + .reference_id + .get() + .and_then(|reference| self.scoping.get_reference(reference).symbol_id()) + .and_then(|symbol| self.aliases.get(&symbol)) + { + identifier.name = self.builder.allocator().alloc_str(alias).into(); + } + } + fn visit_object_property(&mut self, property: &mut ObjectProperty<'a>) { + walk_mut::walk_object_property(self, property); + if property.shorthand + && let Expression::Identifier(identifier) = &property.value + && property + .key + .static_name() + .is_some_and(|key| key != identifier.name.as_str()) + { + property.shorthand = false; + } + } +} + +/// A deferred function permits later outer declarations, but not early reads +/// of declarations inside its own body. An IIFE adds no deferred boundary. +pub(super) fn deferred_reads(program: &Program<'_>, scoping: &Scoping) -> FxHashSet { + struct Reads<'s> { + scoping: &'s Scoping, + boundary: Option, + deferred: FxHashSet, + } + impl<'a> Visit<'a> for Reads<'_> { + fn visit_identifier_reference(&mut self, identifier: &IdentifierReference<'a>) { + if let Some(boundary) = self.boundary + && let Some(reference) = identifier.reference_id.get() + && let Some(symbol) = self.scoping.get_reference(reference).symbol_id() + && !boundary.contains_inclusive(self.scoping.symbol_span(symbol)) + { + self.deferred.insert(reference); + } + } + fn visit_arrow_function_expression(&mut self, arrow: &ArrowFunctionExpression<'a>) { + let outer = self.boundary.replace(arrow.span); + walk::walk_arrow_function_expression(self, arrow); + self.boundary = outer; + } + fn visit_function( + &mut self, + function: &Function<'a>, + flags: oxc_syntax::scope::ScopeFlags, + ) { + let outer = self.boundary.replace(function.span); + walk::walk_function(self, function, flags); + self.boundary = outer; + } + fn visit_call_expression(&mut self, call: &CallExpression<'a>) { + match crate::utils::unwrap_syntax_only(&call.callee) { + Expression::ArrowFunctionExpression(arrow) => { + walk::walk_arrow_function_expression(self, arrow); + } + Expression::FunctionExpression(function) => { + walk::walk_function(self, function, oxc_syntax::scope::ScopeFlags::empty()); + } + _ => self.visit_expression(&call.callee), + } + for argument in &call.arguments { + self.visit_argument(argument); + } + } + } + let mut reads = Reads { + scoping, + boundary: None, + deferred: FxHashSet::default(), + }; + reads.visit_program(program); + reads.deferred +} diff --git a/libs/extractor/src/local_exact_constants_w27_tests.rs b/libs/extractor/src/local_exact_constants_w27_tests.rs new file mode 100644 index 000000000..f1f502ebb --- /dev/null +++ b/libs/extractor/src/local_exact_constants_w27_tests.rs @@ -0,0 +1,448 @@ +use std::collections::HashMap; + +use crate::{ExtractOption, ExtractStyleValue, ImportAlias, extract}; +use css::{class_map::reset_class_map, file_map::reset_file_map}; +use rstest::rstest; +use serial_test::serial; + +#[path = "local_shape_composition_w27_tests.rs"] +mod composition; + +#[path = "local_shape_hygiene_w27_tests.rs"] +mod hygiene; + +fn emotion_option() -> ExtractOption { + ExtractOption { + import_aliases: HashMap::from([("@emotion/react".to_string(), ImportAlias::NamedToNamed)]), + ..ExtractOption::default() + } +} + +#[rstest] +#[case( + "const red = 'red'; export function f() { const inner = { color: red }; return css(inner); }", + "color", + "red" +)] +#[case( + "export function f() { const red = 'red'; const inner = { color: red }; return css(inner); }", + "color", + "red" +)] +#[case( + "export function f() { const gap = 2; const inner = { padding: gap * 4 }; return css(inner); }", + "padding", + "8px" +)] +#[case( + "const red = 'blue'; export function f() { const red = 'red'; const inner = { color: red }; return css(inner); }", + "color", + "red" +)] +#[case( + "export function a() { const red = 'blue'; } export function f() { const red = 'red'; const inner = { color: red }; return css(inner); }", + "color", + "red" +)] +#[case( + "export function f() { const gap = 2; const doubled = gap * 2; const inner = { padding: Math.max(doubled, 8) }; return css(inner); }", + "padding", + "8px" +)] +#[case( + "export function f() { const gap = 2; const inner = { padding: `${gap * 4}px` }; return css(inner); }", + "padding", + "8px" +)] +#[case( + "export function f() { const gap = 2; const inner = [{ padding: gap * 4 }]; return css(inner); }", + "padding", + "8px" +)] +#[case( + "export function f() { const gap = 2; console.log(gap); const inner = { padding: -gap }; return css(inner); }", + "padding", + "-2px" +)] +#[case( + "export function f() { const gap = 2; const inner = { padding: gap * 4 } as const; return css(inner); }", + "padding", + "8px" +)] +#[case( + "export function f() { const gap = 2; const inner = { padding: gap * 4 }; return
; }", + "padding", + "8px" +)] +#[serial] +fn exact_rules_when_dependencies_are_scoped_constants( + #[case] body: &str, + #[case] property: &str, + #[case] value: &str, +) -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let code = format!("import {{ css }} from '@emotion/react';\n{body}"); + // When + let output = extract("local-exact.tsx", &code, emotion_option())?; + // Then + let styles: Vec<(&str, &str)> = output + .styles + .iter() + .map(|style| match style { + ExtractStyleValue::Static(style) => (style.property.as_str(), style.value.as_str()), + other => panic!("expected static style, got {other:?}"), + }) + .collect(); + assert_eq!(styles, vec![(property, value)]); + assert!(!output.code.contains("css(inner)"), "{}", output.code); + Ok(()) +} + +#[rstest] +#[case( + "const red = 'red'; export function f(red) { const inner = { color: red }; return css(inner); }" +)] +#[case( + "const red = 'red'; export function f() { const inner = { color: red }; const red = 'blue'; return css(inner); }" +)] +#[case( + "export function f() { const inner = { padding: gap * 4 }; const gap = 2; return css(inner); }" +)] +#[case( + "export function f() { const gap = gap; const inner = { padding: gap }; return css(inner); }" +)] +#[case( + "export function f() { let gap = 2; const inner = { padding: gap * 4 }; return css(inner); }" +)] +#[case( + "export function f() { const gap = 2; gap = 3; const inner = { padding: gap * 4 }; return css(inner); }" +)] +#[case("export function f(gap) { const inner = { padding: gap * 4 }; return css(inner); }")] +#[case( + "export function f() { const gap = getGap(); const inner = { padding: gap * 4 }; return css(inner); }" +)] +#[case("export function f(Math) { const inner = { padding: Math.max(2, 8) }; return css(inner); }")] +#[case( + "export function f() { const Math = { max: () => 8 }; const inner = { padding: Math.max(2, 8) }; return css(inner); }" +)] +#[case( + "export function f() { const gap = { size: 2 }; mutate(gap); const inner = { padding: gap.size * 4 }; return css(inner); }" +)] +#[case( + "export function f() { const gap = 2; return css(inner); const inner = { padding: gap * 4 }; }" +)] +#[case( + "export function f() { const gap = 2; const inner = { padding: gap * 4 }; console.log(inner); return css(inner); }" +)] +#[case( + "export function f() { const gap = 2; const inner = { padding: gap * 4 }; inner.padding = 12; return css(inner); }" +)] +#[case( + "export function f() { const gap = 2; const inner = { padding: gap * 4 }; const alias = inner; return css(inner); }" +)] +#[case( + "export function f() { const gap = 2; const inner = { padding: gap * 4 }; return
; }" +)] +#[case( + "export function f(undefined) { const gap = undefined; const inner = { padding: gap }; return css(inner); }" +)] +#[case( + "export function f() { const gap = Math.random(); const inner = { padding: gap }; return css(inner); }" +)] +#[serial] +fn located_error_when_rules_are_unknown_changed_or_escaped(#[case] body: &str) { + // Given + reset_class_map(); + reset_file_map(); + let code = format!("import {{ css }} from '@emotion/react';\n{body}"); + // When + let Err(error) = extract("local-exact.tsx", &code, emotion_option()) else { + panic!("expected located error"); + }; + let error = error.to_string(); + // Then + assert!(error.starts_with("local-exact.tsx:2:"), "{error}"); + assert!(error.contains("inner"), "{error}"); +} + +#[test] +#[serial] +fn runtime_value_when_written_directly_on_an_element_keeps_css_variable() +-> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let code = "import { css } from '@emotion/react';\nexport function f(gap) { return
; }"; + // When + let output = extract("local-exact.tsx", code, emotion_option())?; + // Then + assert!( + output + .styles + .iter() + .any(|style| matches!(style, ExtractStyleValue::Dynamic(_))) + ); + assert!(output.code.contains("gap*4"), "{}", output.code); + Ok(()) +} + +#[rstest] +#[case( + "const inner = { color: 'red', m: 2 }; return ;", + "red" +)] +#[case( + "const inner = { color: 'red' }; return ;", + "red" +)] +#[case( + "const inner = { color: 'red' }; return ;", + "blue" +)] +#[case( + "const inner = { color: 'red', padding: 2 }; const C = styled('div', inner); return ;", + "red" +)] +#[case( + "const inner = { color: 'red' }; return
;", + "red" +)] +#[case( + "const inner = { color: 'red' }; return
;", + "blue" +)] +#[serial] +fn literal_shape_when_styles_are_composed_obeys_later_wins( + #[case] body: &str, + #[case] color: &str, +) -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let code = format!( + "import {{ Box, styled }} from '@devup-ui/react';\nexport function f() {{ {body} }}" + ); + // When + let output = extract("local-exact.tsx", &code, emotion_option())?; + // Then + let colors: Vec<&str> = output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Static(style) if style.property == "color" => { + Some(style.value.as_str()) + } + _ => None, + }) + .collect(); + assert_eq!(colors, vec![color]); + Ok(()) +} + +#[rstest] +#[case( + "const inner = { color: props.getColor(), _hover: { color: props.getHover() } }; return ;" +)] +#[case( + "const inner = { color: props.getColor(), _hover: { color: props.getHover() } }; return
;" +)] +#[case( + "const inner = { color: props.getColor(), _hover: { color: props.getHover() } }; const C = styled('div', inner); return ;" +)] +#[case( + "const inner = { color: props.getColor(), _hover: { color: props.getHover() } }; return
;" +)] +#[case( + "const inner = { color: [props.getColor(), 'red'], _hover: { color: props.getHover() } }; return ;" +)] +#[serial] +fn captured_runtime_shape_when_expanded_reads_original_data_paths( + #[case] body: &str, +) -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let code = format!( + "import {{ Box, styled }} from '@devup-ui/react';\nexport function f(props) {{ {body} }}" + ); + // When + let output = extract("local-exact.tsx", &code, emotion_option())?; + // Then + assert_eq!( + output.code.matches("props.getColor()").count(), + 1, + "{}", + output.code + ); + assert_eq!( + output.code.matches("props.getHover()").count(), + 1, + "{}", + output.code + ); + assert!(output.code.contains("inner[`color`]"), "{}", output.code); + assert!( + output.code.contains("inner[`_hover`][`color`]"), + "{}", + output.code + ); + assert_eq!( + output + .styles + .iter() + .filter(|style| matches!(style, ExtractStyleValue::Dynamic(_))) + .count(), + 2 + ); + Ok(()) +} + +#[rstest] +#[case( + "const inner = { color: props.c }; return ;", + true +)] +#[case( + "const inner = { color: props.c }; return ;", + false +)] +#[case( + "const inner = { color: props.c }; return
;", + true +)] +#[case( + "const inner = { color: props.c }; return
;", + false +)] +#[serial] +fn dynamic_shape_when_overridden_keeps_only_winning_color( + #[case] body: &str, + #[case] dynamic: bool, +) -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let code = + format!("import {{ Box }} from '@devup-ui/react';\nexport function f(props) {{ {body} }}"); + // When + let output = extract("local-exact.tsx", &code, emotion_option())?; + // Then + assert_eq!( + output + .styles + .iter() + .filter(|style| matches!(style, ExtractStyleValue::Dynamic(_))) + .count(), + usize::from(dynamic), + "{output:?}" + ); + let colors: Vec<&str> = output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Static(style) if style.property == "color" => { + Some(style.value.as_str()) + } + _ => None, + }) + .collect(); + assert_eq!(colors, if dynamic { vec![] } else { vec!["blue"] }); + Ok(()) +} + +#[rstest] +#[case("const inner = { color: props.c }; inner.color = 'blue'; return ;")] +#[case("const inner = { color: props.c }; mutate(inner); return ;")] +#[case("const inner = { color: props.c }; const alias = inner; return
;")] +#[case("const inner = { color: props.c }; console.log(inner.color); return
;")] +#[case("const inner = { color: props.c }; return ;")] +#[case("const inner = { color: props.c }; inner = {}; return ;")] +#[case("const inner = { color: red }; const red = 'red'; return ;")] +#[case("const inner = { color: inner }; return ;")] +#[case("return ; const inner = { color: props.c };")] +#[serial] +fn local_runtime_shape_when_changed_or_escaped_is_a_located_error(#[case] body: &str) { + // Given + reset_class_map(); + reset_file_map(); + let code = + format!("import {{ Box }} from '@devup-ui/react';\nexport function f(props) {{ {body} }}"); + // When + let Err(error) = extract("local-exact.tsx", &code, emotion_option()) else { + panic!("expected located error"); + }; + let error = error.to_string(); + // Then + assert!(error.starts_with("local-exact.tsx:2:"), "{error}"); + assert!(error.contains("inner"), "{error}"); +} + +#[rstest] +#[case("const inner = { ...props }; return ;")] +#[case("const inner = getProps(); return ;")] +#[serial] +fn unknown_object_when_spread_remains_unexpanded( + #[case] body: &str, +) -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let code = + format!("import {{ Box }} from '@devup-ui/react';\nexport function f(props) {{ {body} }}"); + // When + let output = extract("local-exact.tsx", &code, emotion_option())?; + // Then + assert!(output.code.contains("...inner"), "{}", output.code); + assert_eq!(output.styles.len(), 0); + Ok(()) +} + +#[test] +#[serial] +fn probe_when_all_three_exact_local_cases_share_a_module_compiles_exactly() +-> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let code = "import { css } from '@emotion/react'; const red = 'red'; export function topLevel() { const inner = { color: red }; return css(inner); } export function localValue() { const red = 'red'; const inner = { color: red }; return css(inner); } export function exactMath() { const gap = 2; const inner = { padding: gap * 4 }; return css(inner); }"; + // When + let output = extract("probe-local.tsx", code, emotion_option())?; + // Then + let mut styles: Vec<(&str, &str)> = output + .styles + .iter() + .map(|style| match style { + ExtractStyleValue::Static(style) => (style.property.as_str(), style.value.as_str()), + other => panic!("expected static style, got {other:?}"), + }) + .collect(); + styles.sort_unstable(); + assert_eq!(styles, vec![("color", "red"), ("padding", "8px")]); + Ok(()) +} + +#[rstest] +#[case("{ color: value, '&:hover': { padding: [2, value] } }", true)] +#[case("{ ...props }", false)] +#[case("{ [key]: value }", false)] +#[case("{ method() {} }", false)] +#[case("{ get color() { return 'red'; } }", false)] +#[case("{ __proto__: value }", false)] +#[case("{ padding: [...props] }", false)] +#[case("{ padding: [, value] }", true)] +fn runtime_shape_when_keys_are_fully_written_is_prepared(#[case] code: &str, #[case] known: bool) { + // Given + let allocator = oxc_allocator::Allocator::default(); + let builder = oxc_ast::builder::AstBuilder::new(&allocator); + let source = format!("({code});"); + let parsed = oxc_parser::Parser::new(&allocator, &source, oxc_span::SourceType::tsx()).parse(); + let oxc_ast::ast::Statement::ExpressionStatement(statement) = &parsed.program.body[0] else { + panic!("expected expression"); + }; + // When + let shape = super::local_rule_shapes::prepare(&builder, &statement.expression, &mut |_| None); + // Then + assert_eq!(shape.is_some(), known); +} diff --git a/libs/extractor/src/local_rule_shapes_w27.rs b/libs/extractor/src/local_rule_shapes_w27.rs new file mode 100644 index 000000000..743875f1c --- /dev/null +++ b/libs/extractor/src/local_rule_shapes_w27.rs @@ -0,0 +1,280 @@ +use oxc_allocator::{CloneIn, GetAllocator}; +use oxc_ast::{ + ast::{ArrayExpressionElement, Expression, ObjectPropertyKind, PropertyKind}, + builder::AstBuilder, +}; +use oxc_ast_visit::{Visit, walk}; +use oxc_semantic::Scoping; +use oxc_span::GetSpan; +use rustc_hash::FxHashMap; + +#[path = "local_shape_captures_w27.rs"] +mod captures; +pub(super) use captures::collect as collect_captures; + +/// Convert a captured length once, leaving zero and nonnumeric values untouched. +pub(super) fn number_length<'a>(builder: &AstBuilder<'a>, leaf: Expression<'a>) -> Expression<'a> { + use oxc_ast::ast::{BindingPattern, FormalParameter, FormalParameterKind, FormalParameters}; + use oxc_span::SPAN; + use oxc_syntax::operator::{BinaryOperator, LogicalOperator, UnaryOperator}; + let value = || Expression::new_identifier(SPAN, "__devupValue", builder); + let condition = Expression::new_logical_expression( + SPAN, + Expression::new_binary_expression( + SPAN, + Expression::new_unary_expression(SPAN, UnaryOperator::Typeof, value(), builder), + BinaryOperator::StrictEquality, + Expression::new_string_literal(SPAN, "number", None, builder), + builder, + ), + LogicalOperator::And, + Expression::new_binary_expression( + SPAN, + value(), + BinaryOperator::StrictInequality, + Expression::new_numeric_literal( + SPAN, + 0.0, + None, + oxc_syntax::number::NumberBase::Decimal, + builder, + ), + builder, + ), + builder, + ); + let body = Expression::new_conditional_expression( + SPAN, + condition, + Expression::new_binary_expression( + SPAN, + value(), + BinaryOperator::Addition, + Expression::new_string_literal(SPAN, "px", None, builder), + builder, + ), + value(), + builder, + ); + let mut params = oxc_allocator::Vec::new_in(builder); + params.push(FormalParameter::new( + SPAN, + oxc_allocator::Vec::new_in(builder), + BindingPattern::new_binding_identifier(SPAN, "__devupValue", builder), + None, + None, + false, + None, + false, + false, + builder, + )); + let arrow = Expression::new_arrow_function_expression( + SPAN, + false, + None::>>, + FormalParameters::boxed( + SPAN, + FormalParameterKind::ArrowFormalParameters, + params, + None::>>, + builder, + ), + None::>>, + body.into(), + builder, + ); + crate::utils::wrap_direct_call(builder, &arrow, &[leaf]) +} + +pub(super) fn has_tdz( + expression: &Expression<'_>, + scoping: &Scoping, + ends: &FxHashMap, +) -> bool { + struct TemporalReads<'s> { + scoping: &'s Scoping, + ends: &'s FxHashMap, + invalid: bool, + } + impl<'a> Visit<'a> for TemporalReads<'_> { + fn visit_arrow_function_expression( + &mut self, + _: &oxc_ast::ast::ArrowFunctionExpression<'a>, + ) { + } + + fn visit_function( + &mut self, + _: &oxc_ast::ast::Function<'a>, + _: oxc_syntax::scope::ScopeFlags, + ) { + } + + fn visit_call_expression(&mut self, call: &oxc_ast::ast::CallExpression<'a>) { + match crate::utils::unwrap_syntax_only(&call.callee) { + Expression::ArrowFunctionExpression(arrow) => { + walk::walk_arrow_function_expression(self, arrow); + } + Expression::FunctionExpression(function) => { + walk::walk_function(self, function, oxc_syntax::scope::ScopeFlags::empty()); + } + _ => self.visit_expression(&call.callee), + } + for argument in &call.arguments { + self.visit_argument(argument); + } + } + fn visit_identifier_reference( + &mut self, + identifier: &oxc_ast::ast::IdentifierReference<'a>, + ) { + if let Some(reference) = identifier.reference_id.get() + && let Some(symbol) = self.scoping.get_reference(reference).symbol_id() + && self + .ends + .get(&self.scoping.symbol_span(symbol).start) + .is_some_and(|end| identifier.span.start < *end) + { + self.invalid = true; + } + walk::walk_identifier_reference(self, identifier); + } + } + let mut reads = TemporalReads { + scoping, + ends, + invalid: false, + }; + reads.visit_expression(expression); + reads.invalid +} + +pub(super) fn prepare<'a>( + builder: &AstBuilder<'a>, + expression: &Expression<'a>, + fold: &mut impl FnMut(&Expression<'a>) -> Option>, +) -> Option> { + let expression = crate::utils::unwrap_syntax_only(expression); + match expression { + Expression::ObjectExpression(object) => { + let mut object = object.clone_in_with_semantic_ids(builder.allocator()); + for property in &mut object.properties { + let ObjectPropertyKind::ObjectProperty(property) = property else { + return None; + }; + if property.computed + || property.method + || property.kind != PropertyKind::Init + || property + .key + .static_name() + .is_none_or(|key| key == "__proto__") + { + return None; + } + property.value = prepare(builder, &property.value, fold)?; + } + Some(Expression::ObjectExpression(object)) + } + Expression::ArrayExpression(array) => { + let mut array = array.clone_in_with_semantic_ids(builder.allocator()); + let elements = + std::mem::replace(&mut array.elements, oxc_allocator::Vec::new_in(builder)); + for element in elements { + match element { + ArrayExpressionElement::Elision(elision) => array + .elements + .push(ArrayExpressionElement::Elision(elision)), + ArrayExpressionElement::SpreadElement(spread) => { + let Expression::ArrayExpression(mut inner) = + prepare(builder, &spread.argument, fold)? + else { + return None; + }; + array.elements.extend(inner.elements.drain(..)); + } + element => array + .elements + .push(prepare(builder, element.as_expression()?, fold)?.into()), + } + } + Some(Expression::ArrayExpression(array)) + } + expression => Some( + fold(expression) + .unwrap_or_else(|| expression.clone_in_with_semantic_ids(builder.allocator())), + ), + } +} + +/// Read captured runtime leaves through the original object, so moving a shape +/// into styles never evaluates its initializer expressions a second time. +pub(super) fn expand<'a>( + builder: &AstBuilder<'a>, + shape: &Expression<'a>, + root: &Expression<'a>, +) -> Expression<'a> { + let mut result = shape.clone_in_with_semantic_ids(builder.allocator()); + match &mut result { + Expression::ObjectExpression(object) => { + for property in &mut object.properties { + if let ObjectPropertyKind::ObjectProperty(property) = property + && let Some(name) = property.key.static_name() + { + let key = Expression::new_string_literal( + property.key.span(), + builder.allocator().alloc_str(&name), + None, + builder, + ); + let member = Expression::new_computed_member_expression( + property.value.span(), + root.clone_in_with_semantic_ids(builder.allocator()), + key, + false, + builder, + ); + property.value = if css::is_special_property::is_special_property(&name) + || matches!(name.as_ref(), "as" | "props" | "styleVars") + { + member + } else { + expand(builder, &property.value, &member) + }; + property.shorthand = false; + } + } + result + } + Expression::ArrayExpression(array) => { + for (index, element) in array.elements.iter_mut().enumerate() { + if let Some(value) = element.as_expression() { + let key = Expression::new_string_literal( + value.span(), + builder.allocator().alloc_str(&index.to_string()), + None, + builder, + ); + let member = Expression::new_computed_member_expression( + value.span(), + root.clone_in_with_semantic_ids(builder.allocator()), + key, + false, + builder, + ); + *element = expand(builder, value, &member).into(); + } + } + result + } + Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_) => result, + _ => { + if super::written_out(&result).is_some() { + result + } else { + root.clone_in_with_semantic_ids(builder.allocator()) + } + } + } +} diff --git a/libs/extractor/src/local_shape_captures_w27.rs b/libs/extractor/src/local_shape_captures_w27.rs new file mode 100644 index 000000000..0db04c52d --- /dev/null +++ b/libs/extractor/src/local_shape_captures_w27.rs @@ -0,0 +1,77 @@ +use oxc_ast::ast::{Expression, ObjectPropertyKind}; +use oxc_semantic::Scoping; +use oxc_syntax::symbol::SymbolId; +use rustc_hash::FxHashSet; + +/// Only function leaves move into new scopes; scalar leaves read the original object. +pub(in crate::imported_constants) fn collect( + shape: &Expression<'_>, + scoping: &Scoping, +) -> Option> { + let mut captures = FxHashSet::default(); + match shape { + Expression::ObjectExpression(object) => { + for property in &object.properties { + if let ObjectPropertyKind::ObjectProperty(property) = property { + captures.extend(collect(&property.value, scoping)?); + } + } + } + Expression::ArrayExpression(array) => { + for value in array + .elements + .iter() + .filter_map(|element| element.as_expression()) + { + captures.extend(collect(value, scoping)?); + } + } + Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_) => { + captures.extend(super::super::bound_callback_captures::collect( + shape, scoping, + )?); + } + _ => {} + } + Some(captures) +} + +#[cfg(test)] +mod tests { + use super::*; + use oxc_ast::ast::Statement; + + #[test] + fn array_function_leaves_collect_captures_without_scalar_initializer_reads() + -> Result<(), Box> { + // Given + let allocator = oxc_allocator::Allocator::default(); + let parsed = oxc_parser::Parser::new( + &allocator, + "let rest, scalar; const inner = [{color: [() => rest], width: scalar}];", + oxc_span::SourceType::ts(), + ) + .parse(); + let scoping = oxc_semantic::SemanticBuilder::new() + .build(&parsed.program) + .semantic + .into_scoping(); + let Statement::VariableDeclaration(declaration) = &parsed.program.body[1] else { + panic!("expected shape declaration"); + }; + let shape = declaration.declarations[0] + .init + .as_ref() + .ok_or("missing shape initializer")?; + let rest = scoping + .symbol_ids() + .find(|symbol| scoping.symbol_name(*symbol) == "rest") + .ok_or("missing captured rest binding")?; + // When + let captured = + collect(shape, &scoping).ok_or("readable function captures were rejected")?; + // Then + assert_eq!(captured, FxHashSet::from_iter([rest])); + Ok(()) + } +} diff --git a/libs/extractor/src/local_shape_composition_w27_tests.rs b/libs/extractor/src/local_shape_composition_w27_tests.rs new file mode 100644 index 000000000..1d3c6ed46 --- /dev/null +++ b/libs/extractor/src/local_shape_composition_w27_tests.rs @@ -0,0 +1,205 @@ +use super::*; + +#[rstest] +#[case( + "const inner = [{ padding: props.padding, lineHeight: props.lineHeight }]; return
;", + "inner[`0`][`padding`]", + "inner[`0`][`lineHeight`]" +)] +#[case( + "const inner = [...[...[{ padding: props.padding, lineHeight: props.lineHeight }]]]; return
;", + "inner[`0`][`padding`]", + "inner[`0`][`lineHeight`]" +)] +#[case( + "return
;", + "props.padding", + "props.lineHeight" +)] +#[case( + "return
;", + "props.padding", + "props.lineHeight" +)] +#[serial] +fn css_variables_when_exact_arrays_compose_keep_numeric_roles( + #[case] body: &str, + #[case] padding: &str, + #[case] line_height: &str, +) -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let code = + format!("import {{ css }} from '@emotion/react'; export function f(props) {{ {body} }}"); + // When + let output = extract("local-array.tsx", &code, emotion_option())?; + let compiled: String = output.code.split_whitespace().collect(); + // Then + assert_eq!( + output + .styles + .iter() + .filter(|style| matches!(style, ExtractStyleValue::Dynamic(_))) + .count(), + 2 + ); + assert!( + compiled.contains(&format!(")({padding})")), + "{}", + output.code + ); + assert!( + compiled.contains(&format!(":{line_height}")), + "{}", + output.code + ); + assert_eq!( + compiled.matches("typeof__devupValue").count(), + 1, + "{}", + output.code + ); + Ok(()) +} + +#[rstest] +#[case("return
;")] +#[case("return
;")] +#[case("const inner = [...props.rules]; return
;")] +#[case( + "const inner = [{ color: props.color }]; inner[0].color = 'red'; return
;" +)] +#[case("const inner = [{ color: props.color }]; consume(inner); return
;")] +#[case("const inner = [{ color: tone }]; const tone = 'red'; return
;")] +#[serial] +fn located_error_when_array_shape_is_unknown_changed_escaped_or_tdz(#[case] body: &str) { + // Given + reset_class_map(); + reset_file_map(); + let code = + format!("import {{ css }} from '@emotion/react';\nexport function f(props) {{ {body} }}"); + // When + let Err(error) = extract("local-array.tsx", &code, emotion_option()) else { + panic!("expected located error"); + }; + let error = error.to_string(); + // Then + assert!(error.starts_with("local-array.tsx:2:"), "{error}"); +} + +#[rstest] +#[case("(p) => p.tone", None)] +#[case("function(p) { return p.tone; }", None)] +#[case("(p) => p.theme.colors.brand", Some("var(--colors-brand)"))] +#[serial] +fn styled_callback_when_local_matches_inline_semantics( + #[case] callback: &str, + #[case] theme: Option<&str>, +) -> Result<(), Box> { + // Given + let source = |local: bool| { + let rules = format!("{{ color: {callback} }}"); + let declaration = if local { + format!("const inner = {rules}; const Inner = styled.div(inner);") + } else { + format!("const Inner = styled.div({rules});") + }; + format!( + "import {{ styled }} from '@devup-ui/react'; export function f() {{ {declaration} return ; }}" + ) + }; + reset_class_map(); + reset_file_map(); + let direct = extract("local-function.tsx", &source(false), emotion_option())?; + reset_class_map(); + reset_file_map(); + // When + let local = extract("local-function.tsx", &source(true), emotion_option())?; + // Then + assert_eq!(local.styles, direct.styles); + assert!(!local.code.contains(":inner[`color`]"), "{}", local.code); + if let Some(theme) = theme { + assert!( + local.styles.iter().any( + |style| matches!(style, ExtractStyleValue::Static(style) if style.value == theme) + ), + "{local:?}" + ); + } else { + assert!(local.code.contains("tone=\"purple\""), "{}", local.code); + assert!( + local.code.contains("\"tone\": __devupOmit"), + "{}", + local.code + ); + assert!(local.code.contains(".tone"), "{}", local.code); + } + Ok(()) +} + +#[test] +#[serial] +fn callbacks_when_passed_as_ordinary_props_keep_original_identity() +-> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let code = "import { Box } from '@devup-ui/react'; export function f() { const inner = { color: 'red', onClick: () => click(), 'data-format': () => format() }; return ; }"; + // When + let output = extract("local-callback.tsx", code, emotion_option())?; + // Then + assert!( + output.code.contains("onClick: inner[\"onClick\"]"), + "{}", + output.code + ); + assert!( + output.code.contains("inner[\"data-format\"]"), + "{}", + output.code + ); + assert_eq!(output.code.matches("click()").count(), 1, "{}", output.code); + assert_eq!( + output.code.matches("format()").count(), + 1, + "{}", + output.code + ); + Ok(()) +} + +#[test] +#[serial] +fn array_leaves_when_captured_use_original_paths_once() -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let code = "import { css } from '@emotion/react'; export function f(props) { const inner = [...[{ padding: props.getPadding(), lineHeight: props.lineHeight }]]; return
; }"; + // When + let output = extract("local-array.tsx", code, emotion_option())?; + // Then + assert_eq!( + output.code.matches("props.getPadding()").count(), + 1, + "{}", + output.code + ); + assert_eq!( + output.code.matches("props.lineHeight").count(), + 1, + "{}", + output.code + ); + assert!( + output.code.contains("(inner[`0`][`padding`])"), + "{}", + output.code + ); + assert!( + output.code.contains("inner[`0`][`lineHeight`]"), + "{}", + output.code + ); + Ok(()) +} diff --git a/libs/extractor/src/local_shape_hygiene_w27_tests.rs b/libs/extractor/src/local_shape_hygiene_w27_tests.rs new file mode 100644 index 000000000..df9b62810 --- /dev/null +++ b/libs/extractor/src/local_shape_hygiene_w27_tests.rs @@ -0,0 +1,255 @@ +use super::*; +use oxc_allocator::CloneIn; +use oxc_ast::ast::{Expression, JSXAttributeValue, Statement}; +use oxc_ast_visit::{VisitMut, walk_mut}; + +#[rstest] +#[case("const rest = p.color;", "{color: () => rest}", "")] +#[case("const style = p.color;", "{color: function() { return style; }}", "")] +#[case("const className = p.color;", "{color: () => className}", "")] +#[case( + "const color = p.color;", + "{color: () => color}", + "{ const color = 'orange'; return styled.div(inner); }" +)] +#[case("const rest = p.color;", "{_hover: {color: () => rest}}", "")] +#[case( + "let rest = 'orange';", + "{color: () => rest}", + "rest = p.color; return styled.div(inner);" +)] +#[serial] +fn prepared_function_leaves_when_cloned_keep_lexical_purple( + #[case] binding: &str, + #[case] shape: &str, + #[case] tail: &str, +) -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let tail = if tail.is_empty() { + "return styled.div(inner);" + } else { + tail + }; + let code = format!( + "import {{styled}} from '@devup-ui/react'; function f(p) {{ {binding} const inner = {shape}; {tail} }}" + ); + // When + let output = extract("local-hygiene.tsx", &code, emotion_option())?; + // Then + assert_eq!( + rendered_variables(&output.code)?, + serde_json::json!(["purple"]) + ); + Ok(()) +} + +/// Evaluate generated styled callbacks, substituting their rendered element's +/// style object for JSX so Boa observes the actual variable reads. +fn rendered_variables(code: &str) -> Result> { + struct RenderStyle<'a>(&'a oxc_allocator::Allocator); + impl<'a> VisitMut<'a> for RenderStyle<'a> { + fn visit_expression(&mut self, expression: &mut Expression<'a>) { + if let Expression::JSXElement(element) = expression { + let value = element + .opening_element + .attributes + .iter() + .find_map(|attribute| { + let attribute = attribute.as_attribute()?; + attribute + .name + .as_identifier() + .filter(|name| name.name == "style")?; + match attribute.value.as_ref()? { + JSXAttributeValue::ExpressionContainer(container) => { + container.expression.as_expression() + } + JSXAttributeValue::StringLiteral(_) + | JSXAttributeValue::Element(_) + | JSXAttributeValue::Fragment(_) => None, + } + }); + assert!(value.is_some(), "expected generated style object"); + if let Some(value) = value { + *expression = value.clone_in(self.0); + } + return; + } + walk_mut::walk_expression(self, expression); + } + } + let allocator = oxc_allocator::Allocator::default(); + let mut parsed = oxc_parser::Parser::new(&allocator, code, oxc_span::SourceType::tsx()).parse(); + assert_eq!(parsed.diagnostics.len(), 0); + RenderStyle(&allocator).visit_program(&mut parsed.program); + parsed + .program + .body + .retain(|statement| !matches!(statement, Statement::ImportDeclaration(_))); + let compiled = oxc_codegen::Codegen::new().build(&parsed.program).code; + let script = format!( + "const __devupForwardRef = fn => props => fn(props, undefined); {compiled}\nJSON.stringify(Object.values(f({{color:'purple'}})({{}})));" + ); + let json = boa_engine::Context::default() + .eval(boa_engine::Source::from_bytes(&script)) + .map_err(|error| format!("generated styled callback failed: {error}\n{script}"))? + .as_string() + .ok_or("expected JSON")? + .to_std_string_escaped(); + Ok(serde_json::from_str(&json)?) +} + +#[rstest] +#[case("
")] +#[case("
")] +#[case("
")] +#[case("
")] +#[case("
")] +fn rendered_oracle_when_generated_style_is_missing_rejects(#[case] jsx: &str) { + // Given + let code = format!("function f() {{ return () => {jsx}; }}"); + // When + let result = std::panic::catch_unwind(|| rendered_variables(&code)); + // Then + assert!(result.is_err(), "missing style must fail before evaluation"); +} + +#[rstest] +#[case("rest")] +#[case("style")] +#[case("className")] +#[case("DevupAs")] +#[serial] +fn captured_roots_when_generated_locals_shadow_source_keep_purple( + #[case] name: &str, +) -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let code = format!( + "import {{styled}} from '@devup-ui/react'; function f(p) {{ const {name} = {{color:p.color}}; const C = styled.div({name}); return C; }}" + ); + // When + let output = extract("local-hygiene.tsx", &code, emotion_option())?; + // Then + assert_eq!( + rendered_variables(&output.code)?, + serde_json::json!(["purple"]) + ); + Ok(()) +} + +#[rstest] +#[case( + "const __devup_rest = {color:'orange'}; const __devup_rest1 = {color:'blue'};", + "rest" +)] +#[case("const __devup_style = {color:'orange'};", "style")] +#[case("function deeper(__devup_rest) { return __devup_rest; }", "rest")] +#[case("function deeper() { return __devup_rest; }", "rest")] +#[serial] +fn captured_alias_when_existing_or_unresolved_names_collide_is_fresh( + #[case] declarations: &str, + #[case] name: &str, +) -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let code = format!( + "import {{styled}} from '@devup-ui/react'; function f(p) {{ {declarations} const {name} = {{color:p.color}}; const C = styled.div({name}); return C; }}" + ); + // When + let output = extract("local-hygiene.tsx", &code, emotion_option())?; + // Then + assert_eq!( + rendered_variables(&output.code)?, + serde_json::json!(["purple"]) + ); + assert!( + !output + .code + .contains(&format!("const __devup_{name} = {{ color: p.color }}")), + "{}", + output.code + ); + Ok(()) +} + +#[rstest] +#[case("const inner = [, {color:p.color}];", "inner[`1`][`color`]")] +#[case("const inner = [...[, {color:p.color}]];", "inner[`1`][`color`]")] +#[serial] +fn captured_sparse_array_when_expanded_retains_original_index( + #[case] declaration: &str, + #[case] read: &str, +) -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let code = format!( + "import {{css}} from '@emotion/react'; function f(p) {{ {declaration} return
; }}" + ); + // When + let output = extract("local-hygiene.tsx", &code, emotion_option())?; + // Then + assert!(output.code.contains(read), "{}", output.code); + assert_eq!( + output + .styles + .iter() + .filter(|style| matches!(style, ExtractStyleValue::Dynamic(_))) + .count(), + 1 + ); + Ok(()) +} + +#[rstest] +#[case("() => tone")] +#[case("function() { return tone; }")] +#[serial] +fn callback_when_later_binding_is_deferred_compiles( + #[case] callback: &str, +) -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let code = format!( + "import {{styled}} from '@devup-ui/react'; function f(p) {{ const inner = {{color:{callback}}}; const tone = p.color; const C = styled.div(inner); return C; }}" + ); + // When + let output = extract("local-hygiene.tsx", &code, emotion_option())?; + // Then + assert_eq!( + rendered_variables(&output.code)?, + serde_json::json!(["purple"]) + ); + Ok(()) +} + +#[rstest] +#[case("(() => tone)()")] +#[case("(function() { return tone; })()")] +#[case("(() => { const later = () => tone; return tone; })()")] +#[serial] +fn callback_when_invoked_before_binding_initializes_is_tdz( + #[case] read: &str, +) -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let code = format!( + "import {{styled}} from '@devup-ui/react';\nfunction f(p) {{ const inner = {{color:{read}}}; const tone = p.color; const C = styled.div(inner); return C; }}" + ); + // When + let error = extract("local-hygiene.tsx", &code, emotion_option()) + .err() + .ok_or("early IIFE must fail")? + .to_string(); + // Then + assert!(error.starts_with("local-hygiene.tsx:2:"), "{error}"); + assert!(error.contains("inner"), "{error}"); + Ok(()) +} diff --git a/libs/extractor/src/numeric_context_w27_tests.rs b/libs/extractor/src/numeric_context_w27_tests.rs new file mode 100644 index 000000000..68a881f57 --- /dev/null +++ b/libs/extractor/src/numeric_context_w27_tests.rs @@ -0,0 +1,308 @@ +use std::collections::HashMap; + +use crate::{ExtractOption, ExtractStyleValue, ImportAlias, extract}; +use css::{class_map::reset_class_map, file_map::reset_file_map}; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case("f({ size: 4 })", serde_json::json!({"size":4}))] +#[case("f({ size: 4, nested: { size: 4 }, label: 'auto' })", serde_json::json!({"size":4,"nested":{"size":4},"label":"auto"}))] +#[case("new F({ size: 4, nested: { size: 4 }, label: 'auto' }).width", serde_json::json!({"size":4,"nested":{"size":4},"label":"auto"}))] +#[serial] +fn call_inputs_when_css_reads_result_keep_application_numbers( + #[case] expression: &str, + #[case] argument: serde_json::Value, + #[values("3", "8", "0", "\"auto\"", "\"3\"")] result: &str, +) -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let code = format!( + "import {{css}} from '@emotion/react';export const App=()=>
;" + ); + let option = ExtractOption { + import_aliases: HashMap::from([("@emotion/react".into(), ImportAlias::NamedToNamed)]), + ..ExtractOption::default() + }; + // When + let output = extract("numeric.tsx", &code, option)?; + let variables = output + .code + .split_once("style={") + .ok_or("missing variables")? + .1 + .split_once("} />") + .ok_or("missing element end")? + .0; + let script = format!( + "const seen=[];function f(input){{seen.push([typeof input.size,input]);return {result};}}function F(input){{this.width=f(input);}}const vars={variables};JSON.stringify([seen,Object.values(vars)]);" + ); + let json = boa_engine::Context::default() + .eval(boa_engine::Source::from_bytes(&script)) + .map_err(|error| format!("generated variables failed: {error}"))? + .as_string() + .ok_or("expected JSON")? + .to_std_string_escaped(); + // Then + let value: serde_json::Value = serde_json::from_str(result)?; + let expected = match value.as_f64() { + Some(number) if number != 0.0 => serde_json::json!(format!("{number}px")), + _ => value, + }; + assert_eq!( + serde_json::from_str::(&json)?, + serde_json::json!([[["number", argument]], [expected]]) + ); + Ok(()) +} + +#[rstest] +#[case( + "@emotion/react", + "css(css(...[{color:'red',margin:1}]),{color:'green'})", + "1px" +)] +#[case("@emotion/react", "css(css(...pieces),{color:'green'})", "1px")] +#[case("@emotion/react", "css(css(...nested),{color:'green'})", "1px")] +#[case("@emotion/react", "css(css(...[...pieces]),{color:'green'})", "1px")] +#[case( + "@devup-ui/react", + "css(css(...[{color:'red',margin:1}]),{color:'green'})", + "4px" +)] +#[case("@devup-ui/react", "css(css(...pieces),{color:'green'})", "4px")] +#[case("@devup-ui/react", "css(css(...nested),{color:'green'})", "4px")] +#[case("@devup-ui/react", "css(css(...[...pieces]),{color:'green'})", "4px")] +#[serial] +fn spread_numbers_when_composed_keep_source_library_units( + #[case] library: &str, + #[case] expression: &str, + #[case] margin: &str, +) -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let code = format!( + "import {{css}} from '{library}'; const pieces=[{{color:'red',margin:1}}]; const nested=[...pieces]; export const result={expression};" + ); + let option = ExtractOption { + import_aliases: HashMap::from([("@emotion/react".into(), ImportAlias::NamedToNamed)]), + ..ExtractOption::default() + }; + // When + let output = extract("numeric.tsx", &code, option)?; + // Then + let margins: Vec<&str> = output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Static(style) if style.property == "margin" => { + Some(style.value.as_str()) + } + _ => None, + }) + .collect(); + assert_eq!(margins, vec![margin]); + Ok(()) +} + +#[rstest] +#[case( + "return
;", + "props.padding" +)] +#[case( + "const inner={padding:props.padding,lineHeight:props.lineHeight}; return
;", + "inner[`padding`]" +)] +#[case( + "const inner={padding:props.getPadding(),lineHeight:props.lineHeight}; return
;", + "inner[`padding`]" +)] +#[case( + "const inner={_hover:{padding:props.padding,lineHeight:props.lineHeight}}; return
;", + "inner[`_hover`][`padding`]" +)] +#[serial] +fn runtime_lengths_when_css_prop_reads_values_convert_only_lengths( + #[case] body: &str, + #[case] read: &str, + #[values("3", "0", "'12px'", "'3'")] input: &str, +) -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let code = format!("import {{css}} from '@emotion/react'; export function f(props){{{body}}}"); + let option = ExtractOption { + import_aliases: HashMap::from([("@emotion/react".into(), ImportAlias::NamedToNamed)]), + ..ExtractOption::default() + }; + // When + let output = extract("numeric.tsx", &code, option)?; + // Then + assert!(output.code.contains("typeof"), "{}", output.code); + assert!(output.code.contains("+`px`"), "{}", output.code); + assert!(output.code.contains(read), "{}", output.code); + assert_eq!(output.code.matches("typeof").count(), 1, "{}", output.code); + let initializer = body.split_once("return").ok_or("fixture needs a return")?.0; + let variables = output + .code + .split_once("style={") + .ok_or("compiled element needs CSS variables")? + .1 + .split_once("} />") + .ok_or("compiled style expression needs a closing element")? + .0; + let script = format!( + "let calls=0; const props={{padding:{input},lineHeight:2,getPadding(){{calls++;return this.padding;}}}}; {initializer} const vars={variables}; JSON.stringify([Object.values(vars),calls]);" + ); + let result = boa_engine::Context::default() + .eval(boa_engine::Source::from_bytes(&script)) + .map_err(|error| format!("cannot evaluate generated variables: {error}"))? + .as_string() + .ok_or("generated variable fixture must return JSON")? + .to_std_string_escaped(); + let (values, calls): (Vec, usize) = serde_json::from_str(&result)?; + let expected = match input { + "3" => serde_json::json!("3px"), + "0" => serde_json::json!(0), + "'12px'" => serde_json::json!("12px"), + "'3'" => serde_json::json!("3"), + _ => unreachable!(), + }; + let mut values: Vec = values.iter().map(ToString::to_string).collect(); + values.sort(); + let mut expected = vec!["2".to_string(), expected.to_string()]; + expected.sort(); + assert_eq!(values, expected); + assert_eq!(calls, usize::from(body.contains("getPadding()"))); + Ok(()) +} + +#[rstest] +#[case("@emotion/styled", "styled.div(inner)", "1px")] +#[case("@devup-ui/react", "styled('div',inner)", "4px")] +#[serial] +fn local_styled_numbers_when_aliased_keep_library_units( + #[case] library: &str, + #[case] call: &str, + #[case] margin: &str, +) -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let import = if library == "@emotion/styled" { + "import styled from '@emotion/styled';" + } else { + "import {styled} from '@devup-ui/react';" + }; + let code = format!( + "{import} export function f(){{const inner={{margin:1,p:2,mx:3,lineHeight:2,vars:{{'--raw':3}}}};const C={call};return ;}}" + ); + let option = ExtractOption { + import_aliases: HashMap::from([( + "@emotion/styled".into(), + ImportAlias::DefaultToNamed("styled".into()), + )]), + ..ExtractOption::default() + }; + // When + let output = extract("numeric.tsx", &code, option)?; + // Then + assert!(output.styles.iter().any(|style|matches!(style,ExtractStyleValue::Static(style) if style.property=="margin" && style.value==margin)), "{output:?}"); + assert!(output.styles.iter().any(|style|matches!(style,ExtractStyleValue::Static(style) if style.property=="padding" && style.value=="8px")), "{output:?}"); + Ok(()) +} + +#[test] +#[serial] +fn unknown_local_numbers_when_api_has_no_element_remain_located_error() { + // Given + reset_class_map(); + reset_file_map(); + let code = "import {css} from '@emotion/react'; export function f(props){const inner={padding:props.padding};return css(inner);}"; + let option = ExtractOption { + import_aliases: HashMap::from([("@emotion/react".into(), ImportAlias::NamedToNamed)]), + ..ExtractOption::default() + }; + // When + let Err(error) = extract("numeric.tsx", code, option) else { + panic!("CSS without an element cannot hold runtime values"); + }; + let error = error.to_string(); + // Then + assert!(error.starts_with("numeric.tsx:1:"), "{error}"); +} + +#[rstest] +#[case("return
;")] +#[case( + "const inner={padding:3,lineHeight:2,p:3,mx:2,margin:0,vars:{'--raw':3}}; return
;" +)] +#[serial] +fn static_numbers_when_css_prop_expands_keep_shorthands_and_unitless_values( + #[case] body: &str, +) -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let code = format!("import {{css}} from '@emotion/react'; export function f(){{{body}}}"); + let option = ExtractOption { + import_aliases: HashMap::from([("@emotion/react".into(), ImportAlias::NamedToNamed)]), + ..ExtractOption::default() + }; + // When + let output = extract("numeric.tsx", &code, option)?; + // Then + let styles: Vec<(&str, &str)> = output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Static(style) => { + Some((style.property.as_str(), style.value.as_str())) + } + _ => None, + }) + .collect(); + assert!(styles.contains(&("line-height", "2")), "{styles:?}"); + assert!(styles.contains(&("margin", "0")), "{styles:?}"); + assert!(styles.contains(&("--raw", "3")), "{styles:?}"); + assert!(styles.contains(&("padding", "3px")), "{styles:?}"); + assert!(styles.contains(&("margin-left", "8px")), "{styles:?}"); + assert!(!output.code.contains("typeof"), "{}", output.code); + Ok(()) +} + +#[rstest] +#[case( + "const inner={p:props.padding,mx:props.margin,lineHeight:props.lineHeight};return ;" +)] +#[case("return ;")] +#[case("const inner={vars:{'--raw':props.padding}};return
;")] +#[case("return
;")] +#[serial] +fn bare_runtime_numbers_when_native_or_vars_are_read_keep_existing_semantics( + #[case] body: &str, +) -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let code = format!("import {{Box}} from '@devup-ui/react';export function f(props){{{body}}}"); + let option = ExtractOption { + import_aliases: HashMap::from([("@emotion/react".into(), ImportAlias::NamedToNamed)]), + ..ExtractOption::default() + }; + // When + let output = extract("numeric.tsx", &code, option)?; + // Then + assert!(!output.code.contains("typeof"), "{}", output.code); + assert!( + output + .styles + .iter() + .any(|style| matches!(style, ExtractStyleValue::Dynamic(_))), + "{output:?}" + ); + Ok(()) +} diff --git a/libs/extractor/src/snapshots/extractor__imported_styled__tests__imported_components_are_selected_by_the_class_their_module_gives.snap b/libs/extractor/src/snapshots/extractor__imported_styled__tests__imported_components_are_selected_by_the_class_their_module_gives.snap index 835ee6a44..cdce3f3cf 100644 --- a/libs/extractor/src/snapshots/extractor__imported_styled__tests__imported_components_are_selected_by_the_class_their_module_gives.snap +++ b/libs/extractor/src/snapshots/extractor__imported_styled__tests__imported_components_are_selected_by_the_class_their_module_gives.snap @@ -4,7 +4,7 @@ expression: "consumers(&base_module(), &bodies)" --- [ "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport Span, { Base, Dyn, Pick } from \"./base\";\nexport const P = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\"b--a h i\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n{\n \"Static(ExtractStaticStyle { property: \\\"color\\\", value: \\\"red\\\", level: 0, selector: Some(Selector(\\\".a--b:hover &\\\")), style_order: None, layer: None })\",\n \"Static(ExtractStaticStyle { property: \\\"margin\\\", value: \\\"0\\\", level: 0, selector: Some(Selector(\\\"& .a--a\\\")), style_order: None, layer: None })\",\n}\n[\"/src/base.ts\"]", - "/src/app.tsx:3:55: `styled()` cannot use `[`&:focus ${Dyn}`]` at build time: its styles must be an object literal or a constant object, or be computed from constants", + "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport Span, { Base, Dyn, Pick } from \"./base\";\nexport const P = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\"b--a h i\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n{\n \"Static(ExtractStaticStyle { property: \\\"color\\\", value: \\\"red\\\", level: 0, selector: Some(Selector(\\\"&:focus .a--b\\\")), style_order: None, layer: None })\",\n \"Static(ExtractStaticStyle { property: \\\"margin\\\", value: \\\"0\\\", level: 0, selector: Some(Selector(\\\"& .a--a\\\")), style_order: None, layer: None })\",\n}\n[\"/src/base.ts\"]", "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport Span, { Base, Dyn, Pick } from \"./base\";\nexport const P = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\"b--a h\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n{\n \"Static(ExtractStaticStyle { property: \\\"margin\\\", value: \\\"0\\\", level: 0, selector: Some(Selector(\\\"& .a--d + .a--c\\\")), style_order: None, layer: None })\",\n}\n[\"/src/base.ts\"]", "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport Span, { Base, Dyn, Pick } from \"./base\";\nexport const P = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\"b--a h\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const Q = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\"b--b a--a i a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n{\n \"Static(ExtractStaticStyle { property: \\\"color\\\", value: \\\"blue\\\", level: 0, selector: None, style_order: None, layer: None })\",\n \"Static(ExtractStaticStyle { property: \\\"margin\\\", value: \\\"0\\\", level: 0, selector: Some(Selector(\\\"& .a--a\\\")), style_order: None, layer: None })\",\n \"Static(ExtractStaticStyle { property: \\\"padding\\\", value: \\\"4px\\\", level: 0, selector: None, style_order: None, layer: None })\",\n}\n[\"/src/base.ts\"]", ] diff --git a/libs/extractor/src/snapshots/extractor__imported_styled__tests__imported_components_the_build_cannot_read_stay_runtime_components.snap b/libs/extractor/src/snapshots/extractor__imported_styled__tests__imported_components_the_build_cannot_read_stay_runtime_components.snap index a51e528ff..a41f4e0f4 100644 --- a/libs/extractor/src/snapshots/extractor__imported_styled__tests__imported_components_the_build_cannot_read_stay_runtime_components.snap +++ b/libs/extractor/src/snapshots/extractor__imported_styled__tests__imported_components_the_build_cannot_read_stay_runtime_components.snap @@ -11,5 +11,5 @@ expression: outputs "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport { base } from \"./theme.css\";\nimport { Data } from \"./data.json\";\nimport { Broken } from \"./broken\";\nimport { Missing } from \"./missing\";\nimport { Nope } from \"./types\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = Data, forwardedAs, ...rest }) => )({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const B = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = Broken, forwardedAs, ...rest }) => )({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const C = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = Missing, forwardedAs, ...rest }) => )({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const D = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = Nope, forwardedAs, ...rest }) => )({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const E = __devupForwardRef((__devupRefProps, __devupRef) => ((DevupStyled) => ({ style, className, as: DevupAs = DevupStyled, forwardedAs, ...rest }) => )(base)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n{\n \"Static(ExtractStaticStyle { property: \\\"color\\\", value: \\\"blue\\\", level: 0, selector: None, style_order: None, layer: None })\",\n}\n[\"/src/broken.ts\", \"/src/data.json\", \"/src/theme.css.ts\", \"/src/types.ts\"]", "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport type { Base } from \"./types\";\nimport { type Base as Other } from \"./types\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = Base, forwardedAs, ...rest }) => )({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const B = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = Other, forwardedAs, ...rest }) => )({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n{\n \"Static(ExtractStaticStyle { property: \\\"color\\\", value: \\\"blue\\\", level: 0, selector: None, style_order: None, layer: None })\",\n}\n[\"/src/types.ts\"]", "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport { Base } from \"./types\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\"b--a a--a b\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n{\n \"Static(ExtractStaticStyle { property: \\\"color\\\", value: \\\"blue\\\", level: 0, selector: None, style_order: None, layer: None })\",\n}\n[\"/src/types.ts\"]", - "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport * as types from \"./types\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = types.deep.Base, forwardedAs, ...rest }) => )({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const B = __devupForwardRef((__devupRefProps, __devupRef) => ((DevupStyled) => ({ style, className, as: DevupAs = DevupStyled, forwardedAs, ...rest }) => )(types[\"Base\"])({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n{\n \"Static(ExtractStaticStyle { property: \\\"color\\\", value: \\\"blue\\\", level: 0, selector: None, style_order: None, layer: None })\",\n}\n[\"/src/types.ts\"]", + "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport * as types from \"./types\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = types.deep.Base, forwardedAs, ...rest }) => )({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const B = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\"b--b a--a b\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n{\n \"Static(ExtractStaticStyle { property: \\\"color\\\", value: \\\"blue\\\", level: 0, selector: None, style_order: None, layer: None })\",\n}\n[\"/src/types.ts\"]", ] diff --git a/libs/extractor/src/snapshots/extractor__tests__emotion_css_prop_in_every_form.snap b/libs/extractor/src/snapshots/extractor__tests__emotion_css_prop_in_every_form.snap index 7774be8cd..5ce74da68 100644 --- a/libs/extractor/src/snapshots/extractor__tests__emotion_css_prop_in_every_form.snap +++ b/libs/extractor/src/snapshots/extractor__tests__emotion_css_prop_in_every_form.snap @@ -361,7 +361,7 @@ expression: "emotion_outputs(&[(\"test.tsx\",\n\"export const App = () =>
typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(c)", selector: None, style_order: None, }, @@ -370,13 +370,13 @@ expression: "emotion_outputs(&[(\"test.tsx\",\n\"export const App = () =>
typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(w)", selector: None, style_order: None, }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const App = ({ c, w }) =>
;\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const App = ({ c, w }) =>
typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(w),\n\t\t\"--d\": (__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(c)\n\t},\n\t...{ opacity: 1 }\n}} />;\n", }, ), ( @@ -496,13 +496,13 @@ expression: "emotion_outputs(&[(\"test.tsx\",\n\"export const App = () =>
typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(f({size:4}))", selector: None, style_order: None, }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nconst SPACE = 4;\nconst base = {\n\tpadding: 4,\n\tlineHeight: 1.5,\n\t\"&:hover\": { margin: 2 }\n};\nexport const App = () =>
;\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nconst SPACE = 4;\nconst base = {\n\tpadding: 4,\n\tlineHeight: 1.5,\n\t\"&:hover\": { margin: 2 }\n};\nexport const App = () =>
typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(f({size:4})) }} />;\n", }, ), ( @@ -571,13 +571,13 @@ expression: "emotion_outputs(&[(\"test.tsx\",\n\"export const App = () =>
typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(c)", selector: None, style_order: None, }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nimport { jsx as _jsx } from \"react/jsx-runtime\";\nconst base = { padding: 2 };\nconst SPACE = 3;\nexport const App = ({ c, s }) => [_jsx(\"div\", { id: 1 }), _jsx(\"div\", {\n\tclassName: \"a b d\",\n\tstyle: {\n\t\t...{ \"--c\": c },\n\t\t...s\n\t}\n})];\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nimport { jsx as _jsx } from \"react/jsx-runtime\";\nconst base = { padding: 2 };\nconst SPACE = 3;\nexport const App = ({ c, s }) => [_jsx(\"div\", { id: 1 }), _jsx(\"div\", {\n\tclassName: \"a b d\",\n\tstyle: {\n\t\t...{ \"--c\": (__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(c) },\n\t\t...s\n\t}\n})];\n", }, ), ] diff --git a/libs/extractor/src/snapshots/extractor__tests__emotion_css_prop_reports_what_it_cannot_compile.snap b/libs/extractor/src/snapshots/extractor__tests__emotion_css_prop_reports_what_it_cannot_compile.snap index 8cc28399e..175b257ec 100644 --- a/libs/extractor/src/snapshots/extractor__tests__emotion_css_prop_reports_what_it_cannot_compile.snap +++ b/libs/extractor/src/snapshots/extractor__tests__emotion_css_prop_reports_what_it_cannot_compile.snap @@ -18,5 +18,5 @@ expression: errors "test.tsx:1:73: `css` on `
` cannot use `s[0]` at build time: a style object it composes must be written in it, or declared with `const` at the top level of the module, where the build reads it", "test.tsx:2:36: `css` on `
` cannot use `s` at build time: its styles must be an object literal or a constant object, or be computed from constants", "test.tsx:3:37: `css` on `
` cannot use `s` at build time: its styles must be an object literal or a constant object, or be computed from constants\ntest.tsx:2:1: `s` is changed here, so the build cannot read it as a constant", - "test.tsx:3:55: `css` on `