diff --git a/.changepacks/changepack_log_composition_rest.json b/.changepacks/changepack_log_composition_rest.json new file mode 100644 index 000000000..3cab7cb9f --- /dev/null +++ b/.changepacks/changepack_log_composition_rest.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Compose namespace-imported styled components, inline tagged CSS and exact CSS spreads with later styles winning, and compile conditional props rule objects and read-only function-local style shapes without a styling runtime. Local function captures keep lexical hygiene, so user names like rest, style or className never collide with generated code. Styled callbacks bound to read-only const arrow or function declarations compile like inline callbacks while keeping the original exports and live bindings. Emotion numbers on the left operand of OR and nullish whole-rule choices become px like the right operand. Preserve Emotion numeric units and captured values, read exact imported computations, and retain named CSS layer metadata through composition. Unknowable shapes and spreads keep located build errors, including an unknown whole-rule shape held by a local or shadowing binding named undefined; namespace css props report the same non-inlineable overlap error as named imports, and anonymous layers, invalid layer names, and component layer-order statements report located errors directing users to named layers and globalCss.", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_local_style_objects.json b/.changepacks/changepack_log_local_style_objects.json new file mode 100644 index 000000000..88d69c9e5 --- /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 only to styling APIs now compiles when its entries are written out, including Emotion, styled-components and vanilla-extract aliases. Successful member, primitive and constant-condition folding accounts for erased semantic references, so mutation or extra-use paths keep the existing located refusal while safe style-only member reads compile. css() and styled() compose known conditional rule arrays with the controller read once. Whole-rule props callbacks preserve their original invocation scope, destructuring, defaults, rest and authored argument evaluation order, including covered reads and empty/static-only results; fixed dynamic values become CSS variables without a runtime rule-object interpreter. Nonzero numbers remain px lengths except for unitless properties, and proven theme paths remain static CSS variables. Implicit actual-props theme reads through own arguments or a first rest formal retain located errors with correct catch, loop, switch and var binding ownership. Named whole-rule callbacks that refer to their own binding, including metadata reads, now give a located error naming the callback and explaining that recursion is not compiled; a same-named type parameter cannot hide that value self-reference, while type-only names and actual value shadows still compile. Repeated preparation checks, traversal and error buffering are consolidated without changing output or existing diagnostics. Opaque calls in Emotion ClassNames cx remain located errors until their result is proven unable to carry Devup style classes; only that allowance is deferred. Other located errors remain: attrs chained after styles, callbacks without one immediate explicit rule object, and local style objects also read elsewhere or holding unprovable style structure. No styling runtime or helper is added.", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_styled_callback_binding_repair.json b/.changepacks/changepack_log_styled_callback_binding_repair.json new file mode 100644 index 000000000..ae62ea361 --- /dev/null +++ b/.changepacks/changepack_log_styled_callback_binding_repair.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Preserve extractor callback, theme-read and rule-array behavior while removing redundant preparation and unreachable analysis paths. Add Linux-coverable regression cases without coverage exclusions. No new build errors.", + "date": "2026-10-21T00:00:00.000Z" +} 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..c8a80e21b --- /dev/null +++ b/libs/extractor/src/bound_callback_numeric_tests.rs @@ -0,0 +1,224 @@ +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. + for input in [3, 4] { + let mut selected = crate::styled_source_tests::captured_render::active( + ("callback-logical-units.tsx", None), + &output, + &format!("{{input:{{padding:{input}}}}}"), + ); + 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 source = format!( + "{import}const seen=[];function consume(data,explicit){{seen.push(data,explicit);return {input};}}const observe=()=>seen;{site}" + ); + let output = extract("callback-values.tsx", &source, option())?; + // Then + let observed = crate::styled_source_tests::captured_render::execute( + ("callback-values.tsx", Some("callback-values.tsx")), + &output, + "const element=render({target:Choice,props:{lineHeight:2,raw:7}});JSON.stringify([observe(),Object.values(element.props.style)]);", + ); + 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!( + observed, + serde_json::json!([[{"size":3,"nested":{"size":4}},{"size":3,"nested":{"size":4}}], [padding,2,7]]) + ); + Ok(()) +} diff --git a/libs/extractor/src/bound_callback_properties.rs b/libs/extractor/src/bound_callback_properties.rs new file mode 100644 index 000000000..38c392182 --- /dev/null +++ b/libs/extractor/src/bound_callback_properties.rs @@ -0,0 +1,43 @@ +use oxc_ast::ast::{Expression, ObjectPropertyKind, PropertyKind}; + +use super::Sites; + +impl<'a> Sites<'_, 'a> { + pub(super) fn read_properties(&mut self, expression: &Expression<'a>) { + let Expression::ObjectExpression(object) = crate::utils::unwrap_syntax_only(expression) + else { + return; + }; + for property in &object.properties { + let ObjectPropertyKind::ObjectProperty(property) = property else { + continue; + }; + let Some(key) = property.key.static_name() else { + continue; + }; + if property.method + || property.kind != PropertyKind::Init + || css::is_special_property::is_special_property(&key) + || matches!( + key.as_ref(), + "styleOrder" | "styleVars" | "props" | "params" | "as" + ) + { + continue; + } + match crate::utils::unwrap_syntax_only(&property.value) { + Expression::Identifier(identifier) => { + if let Some(reference) = identifier.reference_id.get() + && let Some(symbol) = self.scoping.get_reference(reference).symbol_id() + { + self.properties + .entry(symbol) + .or_default() + .push((reference, identifier.span.start)); + } + } + value => self.read_properties(value), + } + } + } +} diff --git a/libs/extractor/src/bound_style_callbacks.rs b/libs/extractor/src/bound_style_callbacks.rs new file mode 100644 index 000000000..b125190f1 --- /dev/null +++ b/libs/extractor/src/bound_style_callbacks.rs @@ -0,0 +1,265 @@ +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}; + +#[path = "bound_callback_properties.rs"] +mod properties; + +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>, + properties: 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); + self.read_properties(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(), + properties: 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 reads = sites + .reads + .get(symbol) + .into_iter() + .flatten() + .chain(sites.properties.get(symbol).into_iter().flatten()); + let count = reads.clone().count(); + if count == 0 { + continue; + } + let references = scoping.get_resolved_reference_ids(*symbol); + let safe = callback.constant + && references.len() == count + && references + .iter() + .all(|id| !scoping.get_reference(*id).is_write()) + && reads.clone().all(|(id, start)| { + deferred.contains(id) || *start >= callback.expression.span().end + }); + let Some(captured) = safe + .then(|| super::bound_callback_captures::collect(&callback.expression, scoping)) + .flatten() + else { + if sites.reads.contains_key(symbol) { + 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..2285e42f9 --- /dev/null +++ b/libs/extractor/src/bound_style_callbacks_tests.rs @@ -0,0 +1,270 @@ +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.property() == "color") + )); + let observed = crate::styled_source_tests::captured_render::execute( + ("callback.tsx", Some("callback.tsx")), + &output, + "const C=outer('red')('blue');const element=render({target:C,props:{color:'green'}});JSON.stringify(values(element,'color'));", + ); + assert_eq!(observed, serde_json::json!(["red"])); + 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 executable = format!( + "let reads=0;function getColor(){{reads++;return 'red';}}const observe=()=>reads;{source}" + ); + let output = compile(&executable)?; + // Then + assert!(output.styles.iter().any(|style| matches!(style, ExtractStyleValue::Dynamic(style) if style.property() == "color")), "{output:?}"); + let observed = crate::styled_source_tests::captured_render::execute( + ("callback.tsx", Some("callback.tsx")), + &output, + "const C=make();const before=observe();const element=render({target:C,props:{}});JSON.stringify([before,observe(),values(element,'color')]);", + ); + assert_eq!(observed, serde_json::json!([1, 1, ["red"]])); + 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 executable = format!( + "const seen=[];function consume(value,color){{seen.push([value,color]);return arguments.length===1?value:color;}}const observe=()=>seen;{source}" + ); + let output = compile(&executable)?; + // Then + assert!(output.code.contains("consume(rules)"), "{}", output.code); + assert!( + output.code.contains(&format!("onClick={{{}}}", "rules")), + "{}", + output.code + ); + let observed = crate::styled_source_tests::captured_render::execute( + ("callback.tsx", Some("callback.tsx")), + &output, + "const element=render({target:C,props:{color:'red'}});JSON.stringify([typeof data,data===button.props.onClick,data({color:'blue'}),values(element,'color'),observe().map(([value,color])=>[typeof value==='function'?value===data:value,color])]);", + ); + assert_eq!( + observed, + serde_json::json!(["function", true, {"color":"blue"}, ["red"], [[true,null],[{"width":2},"red"]]]) + ); + Ok(()) +} diff --git a/libs/extractor/src/capture_public_exports.rs b/libs/extractor/src/capture_public_exports.rs new file mode 100644 index 000000000..f228e3324 --- /dev/null +++ b/libs/extractor/src/capture_public_exports.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/class_selection_expectations.rs b/libs/extractor/src/class_selection_expectations.rs new file mode 100644 index 000000000..f8b893db0 --- /dev/null +++ b/libs/extractor/src/class_selection_expectations.rs @@ -0,0 +1,171 @@ +pub use crate::class_selection_state::{Control, External, State, states}; + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct Declaration { + pub property: String, + pub value: String, + pub level: u8, + pub selector: Option, + pub layer: Option, + pub order: u8, +} + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum Meaning { + Declaration(Declaration), + Typography(String), + Keyframes(String), + Opaque(String), +} + +pub fn declaration(property: &str, value: &str) -> Meaning { + Meaning::Declaration(atom(property, value)) +} + +fn atom(property: &str, value: &str) -> Declaration { + Declaration { + property: property.into(), + value: value.into(), + level: 0, + selector: None, + layer: None, + order: 255, + } +} + +pub fn expected(case: usize, state: State) -> Vec { + let on = state.on.truthy(); + let mut result = Vec::new(); + let color = match case { + 1 | 5 | 6 | 7 | 12 | 23 | 25 | 33 => "azure", + 2 | 3 | 19 | 20 | 24 | 26 | 28 | 30 | 31 | 34 => "yellow", + 4 => "green", + 8 => { + if on { + "azure" + } else { + "yellow" + } + } + 9 | 13 | 21 => { + if on { + "yellow" + } else { + "azure" + } + } + 10 => { + if on { + "pink" + } else { + "yellow" + } + } + 11 => { + if on { + "pink" + } else { + "teal" + } + } + 14 | 15 => "a1", + 16 => "navy", + 17 => "", + 18 => "gold", + 22 => match state.ext { + External::Null | External::Undefined => "azure", + External::Zero | External::Text(_) => "yellow", + }, + 27 => { + if on { + "a" + } else { + "b" + } + } + 29 => { + if on { + if state.other { "a" } else { "b" } + } else { + "yellow" + } + } + 32 => "pink", + _ => panic!("unregistered original case {case}"), + }; + if !color.is_empty() { + let mut plain = atom("color", color); + plain.order = match case { + 16 => 3, + 27 => 2, + _ => 255, + }; + result.push(Meaning::Declaration(plain)); + } + if !matches!(case, 14 | 15 | 17 | 25) && (case != 9 || on) { + let mut hover = atom("color", "red"); + hover.selector = Some("&:hover".into()); + result.push(Meaning::Declaration(hover)); + } + if color == "azure" || matches!(case, 2 | 14 | 18) { + result.push(declaration("background", "black")); + } + match case { + 12 => match state.ext { + External::Text(text) => result.extend( + text.split_whitespace() + .map(|token| Meaning::Opaque(token.into())), + ), + External::Null | External::Undefined | External::Zero => { + unreachable!("text-only fixture states") + } + }, + 13 if on => result.push(Meaning::Opaque("plain".into())), + 14 | 15 => { + let mut responsive = atom("color", if case == 14 { "b1" } else { "x2" }); + responsive.level = 1; + result.push(Meaning::Declaration(responsive)); + } + 17 => result.push(Meaning::Typography("body".into())), + 19 => match state.size { + "a" => result.push(declaration("color", "red")), + "b" => result.push(declaration("color", "blue")), + _ => {} + }, + 20 if on && state.size == "a" => result.push(declaration("color", "red")), + 21 | 26 => match state.on { + Control::Opaque => result.push(Meaning::Opaque("opaque".into())), + Control::False if case == 26 => result.push(Meaning::Opaque("plain".into())), + Control::False | Control::True => {} + }, + 22 => match state.ext { + External::Text(text) => result.push(Meaning::Opaque(text.into())), + External::Null | External::Undefined | External::Zero => {} + }, + 27 => { + for (level, value) in [(0, "4px"), (1, "8px")] { + let mut margin = atom("margin", value); + margin.level = level; + margin.order = 2; + result.push(Meaning::Declaration(margin)); + } + } + 28 | 31 => { + let lookup = match case { + 28 if state.size == "a" => Some("x"), + 31 if state.idx == 0 => Some("x"), + 31 if state.idx == 1 => Some("y"), + _ => None, + }; + if let Some(color) = lookup { + let mut lookup = atom("color", color); + lookup.order = 2; + result.push(Meaning::Declaration(lookup)); + } + } + 30 => result.push(Meaning::Keyframes("k-525370705245237976".into())), + _ => {} + } + result.sort(); + result +} diff --git a/libs/extractor/src/class_selection_fixtures.rs b/libs/extractor/src/class_selection_fixtures.rs new file mode 100644 index 000000000..a8b67e90a --- /dev/null +++ b/libs/extractor/src/class_selection_fixtures.rs @@ -0,0 +1,66 @@ +pub const CALLS: [&str; 34] = [ + "css(yellow, azure)", + "css(azure, yellow)", + "css({ color: 'green' }, yellow)", + "css(yellow, { color: 'green' })", + "css([yellow, azure])", + "css(...[yellow, azure])", + "css(yellow, null, undefined, false, azure)", + "css(yellow, on && azure)", + "css(on ? yellow : azure)", + "css(yellow, on ? { color: 'pink' } : null)", + "css(yellow, { color: on ? 'pink' : 'teal' })", + "css(yellow, ext, azure)", + "css(yellow, on ? 'plain' : azure)", + "css(azure, wide)", + "css(wide, { color: [null, 'x2'] })", + "css(yellow, ordered)", + "css(heading, body)", + "css(chained, { color: 'gold' })", + "css(yellow, { color: { a: 'red', b: 'blue' }[size] })", + "css(yellow, on ? { color: { a: 'red' }[size] } : null)", + "css(yellow, on || azure)", + "css(yellow, ext ?? azure)", + "css(yellow, null ?? azure)", + "css(yellow, false ?? azure)", + "css(azure || yellow)", + "css(yellow, on || 'plain')", + "css(yellow, { color: on ? 'a' : 'b', m: [1, 2], styleOrder: 2 })", + "css(yellow, { color: { a: 'x' }[size], styleOrder: 2 })", + "css(yellow, on ? { color: other ? 'a' : 'b' } : null)", + "css(yellow, fade)", + "css(yellow, { color: ['x', 'y'][idx], styleOrder: 2 })", + "css(yellow, { color: 'pink' } || azure)", + "css(yellow, undefined ?? azure)", + "css(yellow, false ?? azure)", +]; + +pub fn source(case: usize) -> String { + let call = CALLS[case - 1]; + format!( + "import {{css, keyframes}} from '@devup-ui/core' +const yellow = css({{ color: 'yellow', _hover: {{ color: 'red' }} }}) +const azure = css({{ color: 'azure', bg: 'black' }}) +const wide = css({{ color: ['a1', 'b1'] }}) +const ordered = css({{ color: 'navy', styleOrder: 3 }}) +const heading = css({{ typography: 'heading' }}) +const body = css({{ typography: 'body' }}) +const chained = css(yellow, azure) +const fade = keyframes({{ from: {{ opacity: 0 }} }}) +export const result = {call} +" + ) +} + +pub const IMPORTED_BASE: &str = "import { css } from '@devup-ui/react'; +const brand = 'teal'; +export const base = css({ color: brand, _hover: { color: 'red' } }); +export const danger = css({ color: 'crimson' });"; + +pub const IMPORTED_APP: &str = "import { css } from '@devup-ui/react'; +import { base, danger } from './styles.ts'; +export const a = css(base, danger); +export const b = css(danger, base, { m: 1 });"; + +pub const INLINE: &str = "import { css } from '@emotion/react';\nconst base = css({ color: 'green', margin: 4 });\nexport const a = (cond) => css(cond && css({ color: 'red' }), base);"; +pub const TEXT: &str = "import { css } from '@emotion/react';\nconst mixin = css({ color: 'red', margin: 1 });\nexport const a = css`color: green; ${mixin}; padding: 2px;`;"; diff --git a/libs/extractor/src/class_selection_state.rs b/libs/extractor/src/class_selection_state.rs new file mode 100644 index 000000000..db79596e6 --- /dev/null +++ b/libs/extractor/src/class_selection_state.rs @@ -0,0 +1,103 @@ +#[derive(Clone, Copy, Debug)] +pub enum Control { + False, + True, + Opaque, +} + +impl Control { + pub const fn truthy(self) -> bool { + match self { + Self::False => false, + Self::True | Self::Opaque => true, + } + } + + pub const fn javascript(self) -> &'static str { + match self { + Self::False => "false", + Self::True => "true", + Self::Opaque => "'opaque'", + } + } +} + +#[derive(Clone, Copy, Debug)] +pub enum External { + Null, + Undefined, + Zero, + Text(&'static str), +} + +impl External { + pub fn javascript(self) -> String { + match self { + Self::Null => "null".into(), + Self::Undefined => "undefined".into(), + Self::Zero => "0".into(), + Self::Text(text) => serde_json::to_string(text) + .unwrap_or_else(|error| panic!("external string fixture: {error}")), + } + } +} + +#[derive(Clone, Copy, Debug)] +pub struct State { + pub on: Control, + pub other: bool, + pub ext: External, + pub size: &'static str, + pub idx: u8, +} + +pub fn states(case: usize) -> Vec { + let mut result = Vec::new(); + for on in [Control::False, Control::True, Control::Opaque] { + let base = State { + on, + other: false, + ext: External::Text("external"), + size: "a", + idx: 0, + }; + match case { + 12 => { + for ext in [ + External::Text(""), + External::Text("external"), + External::Text("external extra"), + ] { + result.push(State { ext, ..base }); + } + } + 19 | 20 | 28 => { + for size in ["a", "b", "missing"] { + result.push(State { size, ..base }); + } + } + 22 => { + for ext in [ + External::Null, + External::Undefined, + External::Text("external"), + External::Zero, + ] { + result.push(State { ext, ..base }); + } + } + 29 => { + for other in [false, true] { + result.push(State { other, ..base }); + } + } + 31 => { + for idx in [0, 1, 9] { + result.push(State { idx, ..base }); + } + } + _ => result.push(base), + } + } + result +} diff --git a/libs/extractor/src/class_selection_support.rs b/libs/extractor/src/class_selection_support.rs new file mode 100644 index 000000000..61a0a9855 --- /dev/null +++ b/libs/extractor/src/class_selection_support.rs @@ -0,0 +1,185 @@ +use crate::class_selection_expectations::{Declaration, Meaning, State}; +use crate::extract_style::{extract_style_value::ExtractStyleValue, style_property::StyleProperty}; +use crate::styled_source_tests::namespace_module_execution; +use crate::{ExtractOption, ExtractOutput, ImportAlias, ResolvedModule}; +use std::collections::HashMap; + +pub type Modules = Vec<(String, ExtractOutput)>; + +pub fn compile(files: &[(&str, &str)], option: ExtractOption) -> Modules { + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + css::debug::set_debug(true); + let sources: Vec<_> = files + .iter() + .map(|(name, code)| ((*name).to_string(), (*code).to_string())) + .collect(); + let resolver = move |specifier: &str, importer: &str| { + let directory = importer + .rsplit_once('/') + .unwrap_or_else(|| panic!("absolute fixture path")) + .0; + let path = format!("{directory}/{}", specifier.trim_start_matches("./")); + sources + .iter() + .find(|(name, _)| *name == path) + .map(|(name, code)| ResolvedModule { + path: name.clone(), + code: code.clone(), + }) + }; + files + .iter() + .map(|(name, code)| { + ( + (*name).into(), + crate::extract_with_modules(name, code, option.clone(), false, &resolver) + .unwrap_or_else(|error| panic!("{name}: {error}")), + ) + }) + .collect() +} + +pub fn option(single_css: bool, package: &str) -> ExtractOption { + ExtractOption { + single_css, + package: package.into(), + css_dir: package.into(), + import_aliases: HashMap::from([("@emotion/react".into(), ImportAlias::NamedToNamed)]), + ..ExtractOption::default() + } +} + +pub fn known(case: usize, single_css: bool) -> Modules { + let source = format!( + "import './environment.js';\n{}", + crate::class_selection_fixtures::source(case) + ); + compile( + &[("/namespace/known.ts", &source)], + option(single_css, "@devup-ui/core"), + ) +} + +pub fn observe(outputs: &Modules, observer: &str, globals: &str) -> serde_json::Value { + let environment = crate::extract( + "/namespace/environment.js", + globals, + ExtractOption::default(), + ) + .unwrap_or_else(|error| panic!("environment fixture: {error}")); + let mut modules = vec![("/namespace/environment.js".into(), environment)]; + modules.extend(outputs.iter().map(|(name, output)| { + ( + name.clone(), + ExtractOutput { + styles: output.styles.clone(), + code: output.code.clone(), + map: output.map.clone(), + css_file: output.css_file.clone(), + dependencies: output.dependencies.clone(), + }, + ) + })); + namespace_module_execution::execute(&modules, observer) +} + +pub fn observe_known(outputs: &Modules, state: State) -> String { + let globals = format!( + "globalThis.on={};globalThis.other={};globalThis.ext={};globalThis.size={:?};globalThis.idx={};", + state.on.javascript(), + state.other, + state.ext.javascript(), + state.size, + state.idx + ); + observe( + outputs, + "import {result} from './known.ts';globalThis.observed=result;", + &globals, + ) + .as_str() + .unwrap_or_else(|| panic!("selected result must be a string")) + .into() +} + +pub fn class(style: &ExtractStyleValue, filename: Option<&str>) -> Option { + match style.extract(filename) { + Some(StyleProperty::ClassName(name)) => Some(name), + Some(StyleProperty::Variable { .. }) => panic!("unexpected dynamic declaration: {style:?}"), + None => None, + } +} + +pub fn meaning(style: &ExtractStyleValue, name: &str) -> Meaning { + match style { + ExtractStyleValue::Static(value) => Meaning::Declaration(Declaration { + property: value.property.clone(), + value: value.value.clone(), + level: value.level, + selector: value.selector.as_ref().map(ToString::to_string), + layer: value.layer.clone(), + order: value.style_order.unwrap_or(255), + }), + ExtractStyleValue::Typography(value) => { + assert_eq!(name, format!("typo-{value}")); + Meaning::Typography(value.clone()) + } + ExtractStyleValue::Keyframes(value) => { + assert_eq!(name, "k-525370705245237976"); + assert_eq!(value.keyframes.len(), 1); + let from = &value.keyframes["from"]; + assert_eq!(from.len(), 1); + assert_eq!(from[0].property, "opacity"); + assert_eq!(from[0].value, "0"); + assert_eq!( + ( + from[0].level, + &from[0].selector, + &from[0].layer, + from[0].style_order + ), + (0, &None, &None, None) + ); + Meaning::Keyframes(name.into()) + } + ExtractStyleValue::Dynamic(_) + | ExtractStyleValue::Css(_) + | ExtractStyleValue::Import(_) + | ExtractStyleValue::FontFace(_) + | ExtractStyleValue::LayerOrder(_) => panic!("unexpected selected metadata: {style:?}"), + } +} + +pub fn selected<'a>( + outputs: &'a Modules, + tokens: &str, + single_css: bool, +) -> Vec<(String, &'a ExtractStyleValue)> { + tokens + .split_whitespace() + .filter_map(|token| { + let mut matching = outputs.iter().flat_map(|(name, output)| { + output.styles.iter().filter(move |style| { + class(style, (!single_css).then_some(name.as_str())).as_deref() == Some(token) + }) + }); + matching.next().map(|style| (token.into(), style)) + }) + .collect() +} + +pub fn meanings(outputs: &Modules, tokens: &str, single_css: bool) -> Vec { + let selected = selected(outputs, tokens, single_css); + let mut result: Vec<_> = tokens + .split_whitespace() + .map(|token| { + selected.iter().find(|(name, _)| name == token).map_or_else( + || Meaning::Opaque(token.into()), + |(name, style)| meaning(style, name), + ) + }) + .collect(); + result.sort(); + result +} diff --git a/libs/extractor/src/class_selection_tests.rs b/libs/extractor/src/class_selection_tests.rs new file mode 100644 index 000000000..8d671f3b8 --- /dev/null +++ b/libs/extractor/src/class_selection_tests.rs @@ -0,0 +1,167 @@ +use crate::{class_selection_expectations as intent, class_selection_support as support}; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case(1)] +#[case(2)] +#[case(3)] +#[case(4)] +#[case(5)] +#[case(6)] +#[case(7)] +#[case(8)] +#[case(9)] +#[case(10)] +#[case(11)] +#[case(12)] +#[case(13)] +#[case(14)] +#[case(15)] +#[case(16)] +#[case(17)] +#[case(18)] +#[case(19)] +#[case(20)] +#[case(21)] +#[case(22)] +#[case(23)] +#[case(24)] +#[case(25)] +#[case(26)] +#[case(27)] +#[case(28)] +#[case(29)] +#[case(30)] +#[case(31)] +#[case(32)] +#[case(33)] +#[case(34)] +#[serial] +fn test_css_composes_known_classes(#[case] case: usize, #[values(false, true)] single_css: bool) { + // Given: the original complete module and independently varied authored globals. + let _state = crate::test_state::TestStateGuard::new(); + let outputs = support::known(case, single_css); + for state in intent::states(case) { + // When: ESM executes the entire emitted module, including all compiler captures. + let tokens = support::observe_known(&outputs, state); + // Then: exact metadata keys/values and opaque tokens match authored intent. + assert_eq!( + support::meanings(&outputs, &tokens, single_css), + intent::expected(case, state), + "case{case} {state:?} single_css={single_css}\n{outputs:?}\n{tokens}" + ); + } +} + +pub(crate) fn imported_b(single_css: bool) { + let outputs = support::compile( + &[ + ( + "/namespace/app.ts", + crate::class_selection_fixtures::IMPORTED_APP, + ), + ( + "/namespace/styles.ts", + crate::class_selection_fixtures::IMPORTED_BASE, + ), + ], + support::option(single_css, "@devup-ui/react"), + ); + assert_eq!(outputs[0].1.dependencies, vec!["/namespace/styles.ts"]); + let observed = support::observe( + &outputs, + "import {a,b} from './app.ts';globalThis.observed=[a,b];", + "", + ); + for (index, color) in [(0, "crimson"), (1, "teal")] { + let mut expected = vec![ + intent::declaration("color", color), + intent::Meaning::Declaration(intent::Declaration { + property: "color".into(), + value: "red".into(), + level: 0, + selector: Some("&:hover".into()), + layer: None, + order: 255, + }), + ]; + if index == 1 { + expected.push(intent::declaration("margin", "4px")); + } + expected.sort(); + assert_eq!( + support::meanings( + &outputs, + observed[index] + .as_str() + .unwrap_or_else(|| panic!("imported class result")), + single_css + ), + expected + ); + } +} + +#[rstest] +#[serial] +fn imported_class_b_selects_original_keys(#[values(false, true)] single_css: bool) { + // Given: the original base/danger declarations and b call across real module boundaries. + let _state = crate::test_state::TestStateGuard::new(); + // When/Then: execute both complete modules and check exact selected declarations. + imported_b(single_css); +} + +#[rstest] +#[case::inline( + crate::class_selection_fixtures::INLINE, + "[a(false),a(true),a('opaque')]" +)] +#[case::text(crate::class_selection_fixtures::TEXT, "[a]")] +#[serial] +fn snapshot_fixture_selects_original_meaning( + #[case] source: &str, + #[case] call: &str, + #[values(false, true)] single_css: bool, +) { + // Given: the byte-identical second tuple from each original snapshot owner. + let _state = crate::test_state::TestStateGuard::new(); + let outputs = support::compile( + &[("/namespace/fixture.ts", source)], + support::option(single_css, "@devup-ui/react"), + ); + // When: the complete emitted ESM executes, not a detached class expression. + let observed = support::observe( + &outputs, + &format!("import {{a}} from './fixture.ts';globalThis.observed={call};"), + "", + ); + // Then: independent exact winners exclude the covered red/green declaration respectively. + let mut expected = match call { + "[a]" => vec![ + intent::declaration("color", "red"), + intent::declaration("margin", "1px"), + intent::declaration("padding", "2px"), + ], + _ => vec![ + intent::declaration("color", "green"), + intent::declaration("margin", "4px"), + ], + }; + expected.sort(); + for tokens in observed + .as_array() + .unwrap_or_else(|| panic!("snapshot fixture observations")) + { + assert_eq!( + support::meanings( + &outputs, + tokens + .as_str() + .unwrap_or_else(|| panic!("snapshot fixture class result")), + single_css + ), + expected + ); + } +} diff --git a/libs/extractor/src/composition.rs b/libs/extractor/src/composition.rs index b59abb6c6..59a6cbdf0 100644 --- a/libs/extractor/src/composition.rs +++ b/libs/extractor/src/composition.rs @@ -4,48 +4,21 @@ //! cannot express that, because which of two classes wins depends on their //! order in the stylesheet, not on the order they were composed in. -use css::style_selector::StyleSelector; use oxc_allocator::{CloneIn, GetAllocator}; use oxc_ast::ast::Expression; use oxc_ast::builder::AstBuilder; use crate::{ExtractStyleProp, ExtractStyleValue}; -/// What a declaration competes on: property, selector, breakpoint and layer -#[derive(Clone, PartialEq, Eq)] -struct CascadeKey { - property: String, - selector: Option, - level: u8, - layer: Option, -} +#[path = "composition_keys.rs"] +mod key_query; +#[path = "composition_residual.rs"] +mod residual; +pub(crate) use residual::{CascadeKey, Coverage, coverage}; -impl CascadeKey { - fn of(value: &ExtractStyleValue) -> Self { - let (property, selector, level, layer) = match value { - ExtractStyleValue::Static(style) => ( - style.property(), - style.selector(), - style.level(), - style.layer(), - ), - ExtractStyleValue::Dynamic(style) => ( - style.property(), - style.selector(), - style.level(), - style.layer(), - ), - // A preset: the only other value a style prop holds - _ => ("typography", None, 0, None), - }; - Self { - property: property.to_string(), - selector: selector.cloned(), - level, - layer: layer.map(ToString::to_string), - } - } -} +#[cfg(test)] +#[path = "layer_registration_tests.rs"] +mod layer_registration_tests; /// What a key resolves to enum Choice<'a> { @@ -75,6 +48,7 @@ enum Overlay<'a> { #[derive(Default)] pub struct Composition<'a> { entries: Vec<(CascadeKey, Choice<'a>)>, + registrations: Vec, /// Styles whose properties the build cannot pair up, kept as they are unkeyed: Vec>, } @@ -115,6 +89,11 @@ impl<'a> Composition<'a> { overlays: &mut Vec<(CascadeKey, Overlay<'a>)>, ) { match prop { + ExtractStyleProp::Static(value @ ExtractStyleValue::LayerOrder(_)) => { + if !self.registrations.contains(&value) { + self.registrations.push(value); + } + } ExtractStyleProp::Static(value) => { push_overlay(overlays, CascadeKey::of(&value), Overlay::Atom(value)); } @@ -176,17 +155,26 @@ impl<'a> Composition<'a> { /// A class applying after the composed parts, whose styles `values` the /// build knows: what it sets replaces what they set pub fn cover(&mut self, values: &[ExtractStyleValue]) { - let keys: Vec = values.iter().map(CascadeKey::of).collect(); + let keys: Vec = values + .iter() + .filter(|value| !matches!(value, ExtractStyleValue::LayerOrder(_))) + .map(CascadeKey::of) + .collect(); self.entries.retain(|(key, _)| !keys.contains(key)); } /// The composed styles, for class names and the stylesheet #[must_use] pub fn into_props(self) -> Vec> { - self.entries + self.registrations .into_iter() - .filter_map(|(_, choice)| into_prop(choice)) - .chain(self.unkeyed) + .map(ExtractStyleProp::Static) + .chain( + self.entries + .into_iter() + .filter_map(|(_, choice)| into_prop(choice)) + .chain(self.unkeyed), + ) .collect() } @@ -196,7 +184,7 @@ impl<'a> Composition<'a> { if !self.unkeyed.is_empty() { return None; } - let mut values = Vec::new(); + let mut values = self.registrations.clone(); for (_, choice) in &self.entries { match choice { Choice::Atom(value) => values.push(value.clone()), @@ -271,36 +259,7 @@ pub fn set_prop_order(prop: &mut ExtractStyleProp<'_>, order: u8) { /// Whether `later` may set what `earlier` sets, so the order of their classes /// decides which applies: a style whose key the build cannot tell may pub fn overlaps(earlier: &[ExtractStyleProp<'_>], later: &[ExtractStyleProp<'_>]) -> bool { - match (keys(earlier), keys(later)) { - (Some(earlier), Some(later)) => later.iter().any(|key| earlier.contains(key)), - _ => true, - } -} - -/// The keys `props` set, `None` when a style chosen at runtime has none; -/// classes only the runtime gives compose at runtime, so they set none here -fn keys(props: &[ExtractStyleProp<'_>]) -> Option> { - let mut keys = Vec::new(); - for prop in props { - match prop { - ExtractStyleProp::Static(value) => keys.push(CascadeKey::of(value)), - ExtractStyleProp::StaticArray(props) => keys.extend(self::keys(props)?), - ExtractStyleProp::Conditional { - consequent, - alternate, - .. - } => { - for side in [consequent, alternate].into_iter().flatten() { - keys.extend(self::keys(std::slice::from_ref(side.as_ref()))?); - } - } - ExtractStyleProp::Expression { .. } | ExtractStyleProp::Unreadable { .. } => {} - ExtractStyleProp::Enum { .. } | ExtractStyleProp::MemberExpression { .. } => { - return None; - } - } - } - Some(keys) + coverage(earlier).overlaps(later) } /// Whether every style `prop` holds has a key, so a condition around it can be diff --git a/libs/extractor/src/composition_coverage_tests.rs b/libs/extractor/src/composition_coverage_tests.rs new file mode 100644 index 000000000..27f7687e1 --- /dev/null +++ b/libs/extractor/src/composition_coverage_tests.rs @@ -0,0 +1,217 @@ +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; + +#[test] +#[serial] +fn inline_tag_when_only_runtime_classes_remain_folds_without_losing_order() +-> Result<(), Box> { + // Given + let allocator = Allocator::default(); + let first_name = "first"; + let second_name = "second"; + let source = format!("css`${{{first_name}}}${{{second_name}}}`"); + let expression = Parser::new(&allocator, &source, SourceType::tsx()) + .parse_expression() + .map_err(|error| format!("{error:?}"))?; + let Expression::TaggedTemplateExpression(tag) = expression else { + panic!("expected tagged fixture"); + }; + let mut visitor = DevupVisitor::new(&allocator, "inline.tsx", "@devup-ui/react", vec![], None); + let compiled = visitor + .compose_template(&tag, true) + .ok_or("tag was rejected")?; + // When + let side = visitor.known_side(&compiled, Text::Classes); + // Then + let Some(super::KnownSide::Class(class)) = side else { + panic!("expected folded runtime classes"); + }; + assert_eq!( + crate::utils::readable_code(&class), + format!("`${{{first_name}}} ${{{second_name}}}`") + ); + assert_eq!( + visitor.styles.into_styles("inline.tsx"), + rustc_hash::FxHashSet::default() + ); + assert_eq!(visitor.css_styles, None); + let parts = visitor + .inline_css_parts + .get(&tag.span.start) + .ok_or("missing inline metadata")?; + assert!( + matches!(&parts[..], [KnownPart::Class(first), KnownPart::Class(second)] + if crate::utils::readable_code(first) == "first" && crate::utils::readable_code(second) == "second") + ); + Ok(()) +} + +#[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(std::rc::Rc::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.into_styles("coverage.tsx").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(std::rc::Rc::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(std::rc::Rc::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/composition_keys.rs b/libs/extractor/src/composition_keys.rs new file mode 100644 index 000000000..6fd670d6a --- /dev/null +++ b/libs/extractor/src/composition_keys.rs @@ -0,0 +1,30 @@ +use super::CascadeKey; +use crate::{ExtractStyleProp, ExtractStyleValue}; + +/// The keys `props` set, `None` when a style chosen at runtime has none; +/// classes only the runtime gives compose at runtime, so they set none here. +pub(super) fn keys(props: &[ExtractStyleProp<'_>]) -> Option> { + let mut keys = Vec::new(); + for prop in props { + match prop { + ExtractStyleProp::Static(ExtractStyleValue::LayerOrder(_)) + | ExtractStyleProp::Expression { .. } + | ExtractStyleProp::Unreadable { .. } => {} + ExtractStyleProp::Static(value) => keys.push(CascadeKey::of(value)), + ExtractStyleProp::StaticArray(props) => keys.extend(self::keys(props)?), + ExtractStyleProp::Conditional { + consequent, + alternate, + .. + } => { + for side in [consequent, alternate].into_iter().flatten() { + keys.extend(self::keys(std::slice::from_ref(side.as_ref()))?); + } + } + ExtractStyleProp::Enum { .. } | ExtractStyleProp::MemberExpression { .. } => { + return None; + } + } + } + Some(keys) +} diff --git a/libs/extractor/src/composition_residual.rs b/libs/extractor/src/composition_residual.rs new file mode 100644 index 000000000..de0a1668c --- /dev/null +++ b/libs/extractor/src/composition_residual.rs @@ -0,0 +1,129 @@ +use super::{Composition, into_prop}; +use crate::{ExtractStyleProp, ExtractStyleValue}; +use css::style_selector::StyleSelector; + +#[derive(Clone)] +pub(crate) struct Coverage(Option>); + +pub(crate) fn coverage(props: &[ExtractStyleProp<'_>]) -> Coverage { + Coverage(super::key_query::keys(props)) +} + +impl Coverage { + pub(crate) fn overlaps(&self, props: &[ExtractStyleProp<'_>]) -> bool { + match (&self.0, super::key_query::keys(props)) { + (Some(earlier), Some(later)) => later.iter().any(|key| earlier.contains(key)), + (None, _) | (_, None) => true, + } + } + + pub(crate) fn union(&self, own: &Self) -> Self { + Self(match (&self.0, &own.0) { + (Some(earlier), Some(later)) => Some(earlier.iter().chain(later).cloned().collect()), + (None, _) | (_, None) => None, + }) + } +} + +/// What a declaration competes on: property, selector, breakpoint and layer. +#[derive(Clone, PartialEq, Eq)] +pub(crate) struct CascadeKey { + property: String, + selector: Option, + level: u8, + layer: Option, +} + +impl CascadeKey { + pub(super) fn of(value: &ExtractStyleValue) -> Self { + let (property, selector, level, layer) = match value { + ExtractStyleValue::Static(style) => ( + style.property(), + style.selector(), + style.level(), + style.layer(), + ), + ExtractStyleValue::Dynamic(style) => ( + style.property(), + style.selector(), + style.level(), + style.layer(), + ), + _ => ("typography", None, 0, None), + }; + Self { + property: property.to_string(), + selector: selector.cloned(), + level, + layer: layer.map(ToString::to_string), + } + } +} + +impl<'a> Composition<'a> { + pub(crate) fn into_residual(self) -> Vec<(Option, Vec>)> { + let mut entries: Vec<_> = self + .entries + .into_iter() + .map(|(key, choice)| (Some(key), into_prop(choice).into_iter().collect())) + .collect(); + if !self.unkeyed.is_empty() { + entries.push((None, self.unkeyed)); + } + entries + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::extract_style::{ + extract_dynamic_style::ExtractDynamicStyle, extract_static_style::ExtractStaticStyle, + }; + + #[test] + fn overlap_when_dynamic_atoms_differ_only_in_value_uses_the_cascade_key() { + // Given + let atom = |identifier: &str, layer: &str| { + let mut style = ExtractDynamicStyle::new("width", 1, identifier, None); + style.layer = Some(layer.to_string()); + ExtractStyleProp::Static(ExtractStyleValue::Dynamic(style)) + }; + let earlier = coverage(&[atom("first", "base")]); + // When + let same = earlier.overlaps(&[atom("second", "base")]); + let separate = earlier.overlaps(&[atom("second", "theme")]); + // Then + assert!(same); + assert!(!separate); + } + + #[test] + fn union_when_either_side_has_unknown_keys_keeps_overlap_conservative() { + // Given + let arena = oxc_allocator::Allocator::default(); + let condition = oxc_parser::Parser::new(&arena, "variant", oxc_span::SourceType::tsx()) + .parse_expression() + .unwrap_or_else(|error| panic!("{error:?}")); + let atom = || { + ExtractStyleProp::Static(ExtractStyleValue::Static(ExtractStaticStyle::new( + "color", "red", 0, None, + ))) + }; + let known = coverage(&[atom()]); + let unknown = coverage(&[ExtractStyleProp::Enum { + condition, + map: std::collections::BTreeMap::from([("red".into(), vec![atom()])]), + }]); + let disjoint = [ExtractStyleProp::Static(ExtractStyleValue::Static( + ExtractStaticStyle::new("margin", "4px", 0, None), + ))]; + // When + let known_then_unknown = known.union(&unknown); + let unknown_then_known = unknown.union(&known); + // Then + assert!(!known.overlaps(&disjoint)); + assert!(known_then_unknown.overlaps(&disjoint)); + assert!(unknown_then_known.overlaps(&disjoint)); + } +} diff --git a/libs/extractor/src/composition_runtime_coverage_tests.rs b/libs/extractor/src/composition_runtime_coverage_tests.rs new file mode 100644 index 000000000..d860985cb --- /dev/null +++ b/libs/extractor/src/composition_runtime_coverage_tests.rs @@ -0,0 +1,170 @@ +use super::*; +use crate::extract_style::extract_static_style::ExtractStaticStyle; +use oxc_parser::Parser; +use oxc_span::SourceType; +use rstest::rstest; +use serial_test::serial; +use std::collections::BTreeMap; + +#[test] +fn overlap_when_runtime_key_map_is_unresolved_is_conservative() { + // Given: disjoint atoms would not compete, but runtime maps have unknown keys. + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let color = ExtractStyleProp::Static(ExtractStyleValue::Static(ExtractStaticStyle::new( + "color", "red", 0, None, + ))); + let width = ExtractStyleProp::Static(ExtractStyleValue::Static(ExtractStaticStyle::new( + "width", "10px", 0, None, + ))); + let enum_prop = ExtractStyleProp::Enum { + condition: Expression::new_identifier(SPAN, "variant", &builder), + map: BTreeMap::from([("wide".to_string(), vec![width.clone_in(&allocator)])]), + }; + let member = ExtractStyleProp::MemberExpression { + expression: Expression::new_identifier(SPAN, "variant", &builder), + map: BTreeMap::from([("wide".to_string(), Box::new(width.clone_in(&allocator)))]), + }; + // When / Then: test both operand positions and the genuinely disjoint control. + assert!(overlaps( + std::slice::from_ref(&color), + std::slice::from_ref(&enum_prop) + )); + assert!(overlaps( + std::slice::from_ref(&enum_prop), + std::slice::from_ref(&color) + )); + assert!(overlaps( + std::slice::from_ref(&color), + std::slice::from_ref(&member) + )); + assert!(overlaps( + std::slice::from_ref(&member), + std::slice::from_ref(&color) + )); + assert!(!overlaps(&[color], &[width])); +} + +#[rstest] +#[case("css(known, ...unknown)", true)] +#[case("css(known, {color:'blue'})", false)] +#[serial] +fn known_composition_when_argument_is_spread_rejects_before_expression_conversion( + #[case] expression: &str, + #[case] spread: bool, +) -> Result<(), Box> { + // Given + let allocator = Allocator::default(); + let source = format!("const known = ''; {expression};"); + let parsed = Parser::new(&allocator, &source, SourceType::tsx()).parse(); + assert_eq!(parsed.diagnostics.len(), 0); + let scoping = oxc_semantic::SemanticBuilder::new() + .build(&parsed.program) + .semantic + .into_scoping(); + let symbol = scoping + .get_root_binding("known".into()) + .ok_or("missing known binding")?; + let mut visitor = DevupVisitor::new(&allocator, "spread.tsx", "@devup-ui/react", vec![], None); + visitor.style_values = crate::style_values::StyleValues::new(std::rc::Rc::new(scoping)); + visitor.style_values.insert( + symbol, + crate::style_values::StyleValue::Class( + "known".into(), + Some(vec![ExtractStyleValue::Static(ExtractStaticStyle::new( + "color", "red", 0, None, + ))]), + ), + ); + let Statement::ExpressionStatement(statement) = &parsed.program.body[1] else { + panic!("expected expression"); + }; + let Expression::CallExpression(call) = &statement.expression else { + panic!("expected call"); + }; + // When + let result = visitor.compose_known_styles(call, false); + // Then + if spread { + assert!(result.is_none()); + assert_eq!( + visitor.styles.into_styles("spread.tsx"), + FxHashSet::default() + ); + assert_eq!(visitor.css_styles, None); + assert_eq!(visitor.inline_css_styles, FxHashMap::default()); + } else { + let result = result.ok_or("known composition was rejected")?; + let expected = vec![ExtractStyleValue::Static(ExtractStaticStyle::new( + "color", "blue", 0, None, + ))]; + assert!(matches!(result, Expression::StringLiteral(_))); + assert_eq!( + visitor.styles.into_styles("spread.tsx"), + FxHashSet::from_iter(expected.clone()) + ); + assert_eq!( + visitor.css_styles, + Some((call.span.start, expected.clone())) + ); + assert_eq!( + visitor.inline_css_styles.get(&call.span.start), + Some(&expected) + ); + } + Ok(()) +} + +#[test] +#[serial] +fn inline_tagged_choice_when_reused_retains_atoms_and_choice_metadata() +-> Result<(), Box> { + // Given: tagged composition retains a conditional atom, not an opaque class. + let allocator = Allocator::default(); + let source = "const known = ''; css`${flag ? known : null}`;"; + let parsed = Parser::new(&allocator, source, SourceType::tsx()).parse(); + assert_eq!(parsed.diagnostics.len(), 0); + let scoping = oxc_semantic::SemanticBuilder::new() + .build(&parsed.program) + .semantic + .into_scoping(); + let symbol = scoping + .get_root_binding("known".into()) + .ok_or("missing known binding")?; + let red = ExtractStyleValue::Static(ExtractStaticStyle::new("color", "red", 0, None)); + let mut visitor = DevupVisitor::new(&allocator, "inline.tsx", "@devup-ui/react", vec![], None); + visitor.style_values = crate::style_values::StyleValues::new(std::rc::Rc::new(scoping)); + visitor.style_values.insert( + symbol, + crate::style_values::StyleValue::Class("known".into(), Some(vec![red.clone()])), + ); + let Statement::ExpressionStatement(statement) = &parsed.program.body[1] else { + panic!("expected expression"); + }; + let Expression::TaggedTemplateExpression(tag) = &statement.expression else { + panic!("expected tag"); + }; + let compiled = visitor + .compose_template(tag, false) + .ok_or("tag was rejected")?; + // When: a conditional side cannot erase the nested choice to one class. + let side = visitor.known_side(&compiled, Text::Classes); + // Then + assert!(side.is_none()); + assert_eq!(compiled.span().start, tag.span.start); + assert_eq!( + visitor.styles.into_styles("inline.tsx"), + FxHashSet::from_iter([red.clone()]) + ); + assert_eq!(visitor.css_styles, None); + let parts = visitor + .inline_css_parts + .get(&tag.span.start) + .ok_or("missing inline parts")?; + assert!( + matches!(&parts[..], [KnownPart::Conditional {test, consequent, alternate}] + if readable_code(test) == "flag" && alternate.is_empty() + && matches!(&consequent[..], [KnownStyles::Known(values)] if values == &[red])) + ); + Ok(()) +} diff --git a/libs/extractor/src/consumed_references.rs b/libs/extractor/src/consumed_references.rs new file mode 100644 index 000000000..2be0dbea3 --- /dev/null +++ b/libs/extractor/src/consumed_references.rs @@ -0,0 +1,81 @@ +//! Semantic references removed by successful constant inlining. + +use oxc_ast::ast::{Expression, IdentifierReference}; +use oxc_ast_visit::Visit; +use oxc_semantic::Scoping; +use oxc_syntax::{reference::ReferenceId, symbol::SymbolId}; +use rustc_hash::{FxHashMap, FxHashSet}; + +/// Counts erased references once, even when a retained AST node is revisited. +pub(super) struct ConsumedReferences<'s> { + scoping: &'s Scoping, + seen: FxHashSet, + by_symbol: FxHashMap, +} + +impl<'s> ConsumedReferences<'s> { + pub(super) fn new(scoping: &'s Scoping) -> Self { + Self { + scoping, + seen: FxHashSet::default(), + by_symbol: FxHashMap::default(), + } + } + + pub(super) fn count(&self, symbol: SymbolId) -> usize { + self.by_symbol.get(&symbol).copied().unwrap_or(0) + } + + /// Call only for a subtree about to be erased, never for a folding probe. + pub(super) fn consume(&mut self, expression: &Expression<'_>) { + self.visit_expression(expression); + } +} + +impl<'a> Visit<'a> for ConsumedReferences<'_> { + fn visit_identifier_reference(&mut self, identifier: &IdentifierReference<'a>) { + if let Some(reference) = identifier.reference_id.get() + && let Some(symbol) = self.scoping.get_reference(reference).symbol_id() + && self.seen.insert(reference) + { + *self.by_symbol.entry(symbol).or_default() += 1; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use oxc_ast::ast::Statement; + + #[test] + fn references_count_once_when_a_computed_subtree_is_revisited() { + // Given: two distinct references to one binding in a computed member. + let allocator = oxc_allocator::Allocator::default(); + let program = oxc_parser::Parser::new( + &allocator, + "const holder = {}; holder[holder.key];", + oxc_span::SourceType::ts(), + ) + .parse() + .program; + let scoping = oxc_semantic::SemanticBuilder::new() + .build(&program) + .semantic + .into_scoping(); + let symbol = scoping + .symbol_ids() + .find(|symbol| scoping.symbol_name(*symbol) == "holder") + .unwrap_or_else(|| panic!("fixture must declare holder")); + let Some(Statement::ExpressionStatement(statement)) = program.body.last() else { + panic!("fixture must end with the computed read"); + }; + let mut references = ConsumedReferences::new(&scoping); + // When: the same semantic subtree is accounted more than once. + for _ in 0..2 { + references.consume(&statement.expression); + } + // Then: distinct operands count, repeated visits do not. + assert_eq!(references.count(symbol), 2); + } +} diff --git a/libs/extractor/src/css_boundary.rs b/libs/extractor/src/css_boundary.rs new file mode 100644 index 000000000..296dfbc70 --- /dev/null +++ b/libs/extractor/src/css_boundary.rs @@ -0,0 +1,168 @@ +use super::{CssValue, DevupVisitor, JsxCss}; +use crate::extractor::{ + extract_style_from_styled::{StyledDefinition, forwarding}, + styled_css_slot::CssPropLowering, +}; +use crate::prop_modify_utils::{ + add_class_and_style, add_class_and_style_to_object, written_class_name, +}; +use oxc_allocator::{FromIn, GetAllocator}; +use oxc_ast::ast::{ + Argument, CallExpression, Expression, JSXAttributeItem, JSXAttributeName, JSXElement, + JSXElementName, ObjectPropertyKind, Str, +}; +use oxc_span::SPAN; +use oxc_syntax::symbol::SymbolId; + +#[path = "css_forwarded_props.rs"] +mod props; +use props::{filter_jsx, filter_object}; + +impl<'a> DevupVisitor<'a> { + pub(super) fn css_definition( + &self, + symbol: Option, + lookup: &str, + ) -> Option<&StyledDefinition<'a>> { + symbol.and_then(|symbol| { + if let Some((namespace, _)) = lookup.split_once('.') { + (self.style_values.import_name(symbol) == Some(namespace)) + .then(|| self.imported_styled.get(lookup)) + .flatten() + .and_then(|component| component.definition.as_ref()) + } else { + self.definition(symbol) + } + }) + } + + pub(super) fn lower_css_prop(&mut self, element: &mut JSXElement<'a>, css: CssValue<'a>) { + let name = element.opening_element.name.to_string(); + let attributes = &element.opening_element.attributes; + let class_name = written_class_name(&self.ast, attributes); + let renders = attributes.iter().all(|attribute| match attribute { + JSXAttributeItem::Attribute(attribute) => !matches!(&attribute.name, JSXAttributeName::Identifier(name) if name.name == "as" || name.name == "forwardedAs"), + JSXAttributeItem::SpreadAttribute(_) => false, + }); + let symbol = self.style_values.jsx_symbol(&element.opening_element.name); + let Some((lowering, props)) = + self.css_prop_styles((&name, &name), css, symbol, renders, class_name.as_ref()) + else { + return; + }; + let (class_name, style) = self.css_class_and_style(props); + let local = matches!( + element.opening_element.name, + JSXElementName::IdentifierReference(_) + ); + let inline = match &lowering { + CssPropLowering::Inline(_) => true, + CssPropLowering::Retained | CssPropLowering::Slot(_) => false, + }; + if (local || inline) + && let Some(definition) = self.css_definition(symbol, &name) + { + let site = if local { + forwarding::Site::LocalJsx + } else { + forwarding::Site::Renderer + }; + let names = element + .opening_element + .attributes + .iter() + .filter_map(|attribute| match attribute { + JSXAttributeItem::Attribute(attribute) => attribute + .name + .as_identifier() + .map(|name| std::borrow::Cow::Borrowed(name.name.as_str())), + JSXAttributeItem::SpreadAttribute(_) => None, + }); + let denied = forwarding::denied(definition, names, site); + filter_jsx(&self.ast, &mut element.opening_element.attributes, &denied); + } + let class_name = match lowering { + CssPropLowering::Retained => class_name, + CssPropLowering::Inline(tag) => { + crate::as_visit::rename( + &self.ast, + element, + JSXElementName::new_identifier( + SPAN, + Str::from_in(tag.as_str(), self.ast.allocator()), + &self.ast, + ), + ); + class_name + } + CssPropLowering::Slot(slot) => { + element + .opening_element + .attributes + .push(slot.attribute(&self.ast, class_name)); + None + } + }; + add_class_and_style( + &self.ast, + &mut element.opening_element.attributes, + class_name, + style, + ); + } + + pub(super) fn lower_jsx_css_prop(&mut self, call: &mut CallExpression<'a>, jsx: JsxCss<'a>) { + let JsxCss { + css, + element, + lookup, + symbol, + class_name, + renders, + } = jsx; + let Some((lowering, props)) = self.css_prop_styles( + (&element, &lookup), + css, + symbol, + renders, + class_name.as_ref(), + ) else { + return; + }; + let (class_name, style) = self.css_class_and_style(props); + let inline = match &lowering { + CssPropLowering::Inline(_) => true, + CssPropLowering::Retained | CssPropLowering::Slot(_) => false, + }; + let class_name = match lowering { + CssPropLowering::Retained => class_name, + CssPropLowering::Inline(tag) => { + call.arguments[0] = Argument::from(Expression::new_string_literal( + SPAN, + Str::from_in(tag.as_str(), self.ast.allocator()), + None, + &self.ast, + )); + class_name + } + CssPropLowering::Slot(slot) => { + if let Some(Argument::ObjectExpression(props)) = call.arguments.get_mut(1) { + props.properties.push(slot.property(&self.ast, class_name)); + } + None + } + }; + if let Some(Argument::ObjectExpression(props)) = call.arguments.get_mut(1) { + add_class_and_style_to_object(&self.ast, &mut props.properties, class_name, style); + if inline && let Some(definition) = self.css_definition(symbol, &lookup) { + let names = props + .properties + .iter() + .filter_map(ObjectPropertyKind::as_property) + .filter_map(|property| property.key.static_name()); + let denied = forwarding::denied(definition, names, forwarding::Site::Renderer); + filter_object(&self.ast, props, &denied); + } + } + } +} 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..5f352a114 --- /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.into_styles("composition.tsx"), + 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_forwarded_props.rs b/libs/extractor/src/css_forwarded_props.rs new file mode 100644 index 000000000..f6c458ec7 --- /dev/null +++ b/libs/extractor/src/css_forwarded_props.rs @@ -0,0 +1,120 @@ +use crate::extractor::extract_style_from_styled::forwarding; +use oxc_allocator::TakeIn; +use oxc_ast::{ + ast::{Expression, JSXAttributeItem, JSXAttributeValue, ObjectExpression, ObjectPropertyKind}, + builder::AstBuilder, +}; +use oxc_span::SPAN; +use oxc_syntax::operator::UnaryOperator; + +pub(super) fn filter_object<'a>( + ast: &AstBuilder<'a>, + props: &mut ObjectExpression<'a>, + denied: &[String], +) { + if denied.is_empty() { + return; + } + let original = + Expression::new_object_expression(props.span, props.properties.take_in(ast), ast); + // Copy config first so accessors run in original enumeration order on their original receiver. + let copy = Expression::new_object_expression( + SPAN, + oxc_allocator::Vec::from_iter_in( + [ObjectPropertyKind::new_spread_property(SPAN, original, ast)], + ast, + ), + ast, + ); + let filtered = forwarding::without_props(ast, copy, denied); + props + .properties + .push(ObjectPropertyKind::new_spread_property(SPAN, filtered, ast)); +} + +pub(super) fn filter_jsx<'a>( + ast: &AstBuilder<'a>, + attrs: &mut oxc_allocator::Vec<'a, JSXAttributeItem<'a>>, + denied: &[String], +) { + attrs.retain_mut(|item| { + let JSXAttributeItem::Attribute(attribute) = item else { + return true; + }; + let Some(name) = attribute.name.as_identifier() else { + return true; + }; + if !denied.iter().any(|denied| denied == name.name.as_str()) { + return true; + } + let value = match attribute.value.take() { + Some(JSXAttributeValue::ExpressionContainer(mut container)) => container + .expression + .as_expression_mut() + .map(|value| value.take_in(ast)), + Some(JSXAttributeValue::Element(element)) => Some(Expression::JSXElement(element)), + Some(JSXAttributeValue::Fragment(fragment)) => Some(Expression::JSXFragment(fragment)), + Some(JSXAttributeValue::StringLiteral(_)) | None => None, + }; + match value { + None + | Some( + Expression::StringLiteral(_) + | Expression::NumericLiteral(_) + | Expression::BooleanLiteral(_) + | Expression::NullLiteral(_) + | Expression::BigIntLiteral(_), + ) => false, + Some(value) => { + *item = JSXAttributeItem::new_spread_attribute( + SPAN, + Expression::new_unary_expression(SPAN, UnaryOperator::Void, value, ast), + ast, + ); + true + } + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[rstest::rstest] + #[case("
xml:lang='en' keep='yes' />", "text xml:lang='en' keep='yes' />", "<>text")] + fn denied_jsx_value_when_it_is_an_element_retains_evaluation_and_namespaced_attributes( + #[case] source: &str, + #[case] evaluated: &str, + ) { + // Given + let arena = oxc_allocator::Allocator::default(); + let ast = AstBuilder::new(&arena); + let mut expression = oxc_parser::Parser::new(&arena, source, oxc_span::SourceType::tsx()) + .parse_expression() + .unwrap_or_else(|error| panic!("{error:?}")); + let Expression::JSXElement(element) = &mut expression else { + panic!("expected JSX") + }; + // When + filter_jsx( + &ast, + &mut element.opening_element.attributes, + &["denied".into()], + ); + // Then + let JSXAttributeItem::SpreadAttribute(effect) = &element.opening_element.attributes[0] + else { + panic!("denied element creation must still execute") + }; + assert!( + matches!(&effect.argument, Expression::UnaryExpression(unary) if unary.operator == UnaryOperator::Void) + ); + let code = crate::utils::expression_to_code(&expression); + assert!(!code.contains("denied=")); + assert!(code.contains(evaluated)); + assert!(code.contains("xml:lang=\"en\"")); + assert!(code.contains("keep=\"yes\"")); + } +} diff --git a/libs/extractor/src/css_prop_composition.rs b/libs/extractor/src/css_prop_composition.rs new file mode 100644 index 000000000..e9e2f20cf --- /dev/null +++ b/libs/extractor/src/css_prop_composition.rs @@ -0,0 +1,82 @@ +use super::{CssValue, DevupVisitor, css_prop_override_error, overlaps}; +use crate::{ExtractStyleProp, extractor::styled_css_slot::CssPropLowering}; +use oxc_allocator::GetAllocator; +use oxc_ast::ast::Expression; + +enum OwnStyles<'a> { + Replay(Vec>), + Residual(crate::composition::Coverage), +} + +impl<'a> DevupVisitor<'a> { + /// The styles the `css` prop of `element` gives it. When the element is a + /// styled component the file binds to `symbol`, fixed own styles compose + /// under it if `renders`: capture-free definitions inline their tag, + /// captured definitions keep their renderer through its private slot. + /// `None` when the build cannot order them. + pub(super) fn css_prop_styles( + &mut self, + names: (&str, &str), + css: CssValue<'a>, + symbol: Option, + renders: bool, + class_name: Option<&Expression<'a>>, + ) -> Option<(CssPropLowering, Vec>)> { + let (element, lookup) = names; + let allocator = self.ast.allocator(); + let clone = |styles: &[ExtractStyleProp<'a>]| -> Vec> { + styles + .iter() + .map(|style| style.clone_in(allocator)) + .collect() + }; + let definition = self.css_definition(symbol, lookup); + let markers = definition.and_then(|definition| definition.marker_classes(&self.ast)); + let composed = definition.filter(|_| renders).and_then(|definition| { + definition + .inline() + .map(|(tag, _)| { + ( + CssPropLowering::Inline(tag.to_string()), + clone(definition.composition_styles()), + ) + }) + .or_else(|| { + definition.css_slot().map(|slot| { + ( + CssPropLowering::Slot(slot.clone()), + clone(definition.composition_styles()), + ) + }) + }) + }); + if let Some((lowering, styles)) = composed { + let markers = match &lowering { + CssPropLowering::Inline(_) => markers, + CssPropLowering::Retained | CssPropLowering::Slot(_) => None, + }; + let mut props = self.compose_css_prop(element, css, styles, class_name); + props.extend(markers.map(|expression| ExtractStyleProp::Expression { + expression, + styles: vec![], + })); + return Some((lowering, props)); + } + let own = definition.map(|definition| { + definition.css_coverage().map_or_else( + || OwnStyles::Replay(clone(definition.composition_styles())), + OwnStyles::Residual, + ) + }); + let offset = css.offset; + let props = self.compose_css_prop(element, css, vec![], class_name); + if own.is_some_and(|own| match own { + OwnStyles::Replay(styles) => overlaps(&styles, &props), + OwnStyles::Residual(coverage) => coverage.overlaps(&props), + }) { + self.errors.push((offset, css_prop_override_error(element))); + return None; + } + Some((CssPropLowering::Retained, props)) + } +} diff --git a/libs/extractor/src/css_utils.rs b/libs/extractor/src/css_utils.rs index 24d43997f..de59cb905 100644 --- a/libs/extractor/src/css_utils.rs +++ b/libs/extractor/src/css_utils.rs @@ -25,6 +25,25 @@ 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; +#[path = "implicit_props_theme.rs"] +mod implicit_props_theme; + +#[path = "theme_callback.rs"] +mod theme_callback; + +pub(crate) use implicit_props_theme::reads as reads_implicit_props_theme; + use crate::extract_style::{ extract_dynamic_style::ExtractDynamicStyle, extract_static_style::ExtractStaticStyle, extract_style_value::ExtractStyleValue, @@ -57,24 +76,43 @@ pub fn rm_last_semi_colon(code: &str) -> &str { /// style static; left alone the arrow would be called with the element's props, /// which carry no `theme`, and silently evaluate to `undefined`. pub(crate) fn theme_var_reference(expr: &Expression<'_>) -> Option { - let Expression::ArrowFunctionExpression(arrow) = expr else { - return None; + use oxc_ast_visit::Visit; + struct Defaults(bool); + impl<'a> Visit<'a> for Defaults { + fn visit_assignment_pattern(&mut self, _: &oxc_ast::ast::AssignmentPattern<'a>) { + self.0 = true; + } + } + let (params, body) = match expr { + Expression::ArrowFunctionExpression(arrow) if !arrow.r#async => { + (&arrow.params, arrow.body.as_expression()?) + } + Expression::FunctionExpression(function) if !function.r#async && !function.generator => ( + &function.params, + crate::css_prop::returned(function.body.as_deref()?)?, + ), + _ => return None, }; - let [param] = arrow.params.items.as_slice() else { + let [param] = params.items.as_slice() else { return None; }; - let root = match ¶m.pattern { - BindingPattern::BindingIdentifier(ident) => ThemeRoot::Props(ident.name.as_str()), - BindingPattern::ObjectPattern(pattern) => pattern - .properties - .iter() - .any(|p| get_str_by_property_key(&p.key).is_some_and(|key| key == "theme")) - .then_some(ThemeRoot::Theme)?, - _ => return None, - }; + let mut defaults = Defaults(false); + defaults.visit_binding_pattern(¶m.pattern); + if params.rest.is_some() || param.initializer.is_some() || defaults.0 { + return None; + } + theme_leaf_reference(params, body) +} + +/// Map a leaf without erasing its enclosing callback's parameter evaluation. +pub(crate) fn theme_leaf_reference( + params: &oxc_ast::ast::FormalParameters<'_>, + body: &Expression<'_>, +) -> Option { + let root = theme_root(¶ms.items.first()?.pattern)?; let mut path: Vec> = Vec::new(); - let mut cursor = arrow.body.as_expression()?; + let mut cursor = body; loop { match cursor { Expression::StaticMemberExpression(member) => { @@ -88,8 +126,7 @@ pub(crate) fn theme_var_reference(expr: &Expression<'_>) -> Option { } Expression::Identifier(ident) => { let matches_root = match root { - ThemeRoot::Props(name) => ident.name.as_str() == name, - ThemeRoot::Theme => ident.name.as_str() == "theme", + ThemeRoot::Props(name) | ThemeRoot::Theme(name) => ident.name.as_str() == name, }; if !matches_root { return None; @@ -116,7 +153,7 @@ pub(crate) fn theme_var_reference(expr: &Expression<'_>) -> Option { pub(crate) fn reads_unmapped_theme(expr: &Expression<'_>) -> bool { use oxc_ast_visit::{Visit, walk}; struct ThemeReads<'n> { - props: Option<&'n str>, + root: ThemeRoot<'n>, found: bool, } impl<'a> Visit<'a> for ThemeReads<'_> { @@ -125,48 +162,61 @@ pub(crate) fn reads_unmapped_theme(expr: &Expression<'_>) -> bool { it: &oxc_ast::ast::StaticMemberExpression<'a>, ) { if it.property.name == "theme" - && matches!(&it.object, Expression::Identifier(object) if Some(object.name.as_str()) == self.props) + && matches!(self.root, ThemeRoot::Props(name) + if matches!(&it.object, Expression::Identifier(object) if object.name == name)) { self.found = true; } walk::walk_static_member_expression(self, it); } + fn visit_computed_member_expression( + &mut self, + it: &oxc_ast::ast::ComputedMemberExpression<'a>, + ) { + if get_string_by_literal_expression(&it.expression).is_some_and(|key| key == "theme") + && matches!(self.root, ThemeRoot::Props(name) + if matches!(&it.object, Expression::Identifier(object) if object.name == name)) + { + self.found = true; + } + walk::walk_computed_member_expression(self, it); + } fn visit_identifier_reference(&mut self, it: &oxc_ast::ast::IdentifierReference<'a>) { - if self.props.is_none() && it.name == "theme" { + if matches!(self.root, ThemeRoot::Theme(name) if it.name == name) { self.found = true; } } } - let Expression::ArrowFunctionExpression(arrow) = expr else { + if reads_implicit_props_theme(expr) { + return true; + } + let Some(theme_callback::Callback { params, body }) = theme_callback::Callback::read(expr) + else { return false; }; - let [param] = arrow.params.items.as_slice() else { + let Some(param) = params.items.first() else { return false; }; - let props = match ¶m.pattern { - BindingPattern::BindingIdentifier(ident) => Some(ident.name.as_str()), - BindingPattern::ObjectPattern(pattern) - if pattern - .properties - .iter() - .any(|p| get_str_by_property_key(&p.key).is_some_and(|key| key == "theme")) => - { - None - } - _ => return false, + let Some(root) = theme_root(¶m.pattern) else { + return false; }; if theme_var_reference(expr).is_some() { return false; } - let mut reads = ThemeReads { - props, - found: false, - }; - match &arrow.body { - oxc_ast::ast::ArrowFunctionBody::FunctionBody(body) => reads.visit_function_body(body), - body => { - if let Some(expression) = body.as_expression() { - reads.visit_expression(expression); + let mut reads = ThemeReads { root, found: false }; + reads.visit_formal_parameters(params); + match body { + theme_callback::Body::Arrow(body) => match body { + oxc_ast::ast::ArrowFunctionBody::FunctionBody(body) => reads.visit_function_body(body), + body => { + if let Some(expression) = body.as_expression() { + reads.visit_expression(expression); + } + } + }, + theme_callback::Body::Function(body) => { + if let Some(body) = body { + reads.visit_function_body(body); } } } @@ -185,9 +235,32 @@ pub(crate) fn unmapped_theme_error(api: &str, expr: &Expression<'_>) -> (u32, St ) } +#[derive(Clone, Copy)] enum ThemeRoot<'a> { Props(&'a str), - Theme, + Theme(&'a str), +} + +/// The props parameter or the local binding destructured from its theme. +fn theme_root<'p>(pattern: &'p BindingPattern<'_>) -> Option> { + match pattern { + BindingPattern::BindingIdentifier(identifier) => { + Some(ThemeRoot::Props(identifier.name.as_str())) + } + BindingPattern::ObjectPattern(object) => object.properties.iter().find_map(|property| { + let mut value = &property.value; + while let BindingPattern::AssignmentPattern(default) = value { + value = &default.left; + } + let BindingPattern::BindingIdentifier(identifier) = value else { + return None; + }; + get_str_by_property_key(&property.key) + .is_some_and(|key| key == "theme") + .then_some(ThemeRoot::Theme(identifier.name.as_str())) + }), + _ => None, + } } /// Convert a dynamic template-literal expression into identifier code, @@ -513,12 +586,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) @@ -530,12 +606,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 @@ -604,12 +675,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)); } } } @@ -628,7 +694,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 @@ -650,29 +716,39 @@ fn collect_css_block( styles: &mut Vec, ) { let mut rest = css; - while let Some(open) = find_unquoted(rest, b'{') { + while let Some((open, _)) = + layer_blocks::boundaries(rest).find(|(_, character)| *character == '{') + { let head = &rest[..open]; - let (declarations, prelude) = rfind_unquoted(head, b';') - .map_or(("", head), |index| (&head[..index], &head[index + 1..])); + 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 = unquoted_bytes(&rest[body_start..]) - .find_map(|(index, byte)| { - match byte { - b'{' => depth += 1, - b'}' => depth -= 1, + let body_end = layer_blocks::boundaries(&rest[body_start..]) + .find_map(|(index, c)| { + match c { + '{' => depth += 1, + '}' => depth -= 1, _ => {} } (depth == 0).then_some(body_start + index) }) .unwrap_or(rest.len()); let body = &rest[body_start..body_end]; - let prelude = trim_css(prelude); + let prelude = trim_css(component_layers::trim_trivia(prelude)); 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(); @@ -1773,9 +1849,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..269db712c --- /dev/null +++ b/libs/extractor/src/css_utils/component_layer_coverage_tests.rs @@ -0,0 +1,213 @@ +use oxc_allocator::Allocator; +use oxc_parser::Parser; +use oxc_span::SourceType; +use rstest::rstest; + +#[rstest] +#[case( + "css({'@layer': {...runtime, inherit: {color: 'red'}}})", + "inherit", + "@layer inherit" +)] +#[case( + "css({'@layer': {[runtime.name]: {color: 'red'}}})", + "runtime.name", + "@layer runtime.name" +)] +#[case( + "css({'@layer': {base: '@layer inherit { color: red; }'}})", + "'@layer inherit", + "@layer inherit {" +)] +#[case( + "css({'@layer': {base: `@layer inherit { color: red; }`}})", + "@layer inherit", + "@layer inherit {" +)] +fn dictionary_layers_when_spread_dynamic_or_nested_text_keep_error_context( + #[case] source: &str, + #[case] location: &str, + #[case] code: &str, +) -> Result<(), Box> { + // Given + let allocator = Allocator::default(); + let expression = Parser::new(&allocator, source, SourceType::tsx()) + .parse_expression() + .map_err(|error| format!("{error:?}"))?; + let offset = u32::try_from(source.find(location).ok_or("missing fixture location")?)?; + // When + let errors = crate::css_utils::object_layer_errors(&expression, "css"); + // Then + assert_eq!( + errors, + vec![( + offset, + format!( + "`css()` cannot use `{code}` at build time: {}", + super::LAYER_NAME_REQUIREMENT + ) + )] + ); + Ok(()) +} + +#[rstest] +#[case("css({'@layer': {...runtime, base: {color: 'red'}}})")] +#[case("css({'@layer': {base: '@layer theme { color: red; }'}})")] +#[case("css({'@layer': {base: `@layer theme { color: red; }`}})")] +fn dictionary_layers_when_readable_names_are_valid_have_no_policy_errors( + #[case] source: &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 errors = crate::css_utils::object_layer_errors(&expression, "css"); + // Then + assert_eq!(errors, vec![]); + Ok(()) +} + +#[rstest] +#[case(r"base\/reset", Some(r"base\/reset"))] +#[case(r"-\31 base", Some(r"-\31 base"))] +#[case("base/reset", None)] +#[case("/base", None)] +#[case("1base", None)] +fn layer_names_when_slash_is_escaped_or_not_respect_identifier_boundaries( + #[case] source: &str, + #[case] expected: Option<&str>, +) { + // Given / When + let name = crate::css_utils::parse_layer_name(source); + // Then + assert_eq!(name.as_deref(), expected); +} + +#[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..620b14062 --- /dev/null +++ b/libs/extractor/src/css_utils/component_layer_tests.rs @@ -0,0 +1,250 @@ +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")] + ); +} + +#[test] +fn canonical_layer_when_css_block_is_parsed_keeps_nested_declarations() { + // Given + let source = "@layer outer { @layer inner { color:red; } }"; + // When + let styles = css_to_style(source, 0, &None); + // Then + assert_eq!(styles.len(), 1); + assert_eq!( + (styles[0].property(), styles[0].value(), styles[0].layer()), + ("color", "red", Some("outer.inner")) + ); +} + +#[test] +fn visitor_when_css_object_has_an_anonymous_layer_reports_its_source() { + use oxc_ast_visit::VisitMut; + // Given + let arena = Allocator::default(); + let source = "import {css} from '@devup-ui/react';css({'@layer':{'':{color:'red'}}});"; + let mut parsed = Parser::new(&arena, source, SourceType::tsx()).parse(); + assert_eq!(parsed.diagnostics.len(), 0); + let mut visitor = + crate::visit::DevupVisitor::new(&arena, "layer.tsx", "@devup-ui/react", vec![], None); + // When + visitor.visit_program(&mut parsed.program); + // Then + assert_ne!(visitor.errors.len(), 0); + assert!( + visitor + .errors + .iter() + .any(|(_, error)| error.contains("layer")) + ); +} 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..6abe097c4 --- /dev/null +++ b/libs/extractor/src/css_utils/layer_blocks.rs @@ -0,0 +1,120 @@ +pub(super) fn boundaries(text: &str) -> impl Iterator + '_ { + let mut characters = text.bytes().enumerate().peekable(); + let mut quote = css::quoted::QuoteState::default(); + let mut comment = false; + let mut parentheses = 0usize; + std::iter::from_fn(move || { + while let Some((index, character)) = characters.next() { + if comment { + if character == b'*' && characters.peek().is_some_and(|(_, next)| *next == b'/') { + characters.next(); + comment = false; + } + continue; + } + if !quote.is_syntax(character) { + continue; + } + match character { + b'/' if characters.peek().is_some_and(|(_, next)| *next == b'*') => { + characters.next(); + comment = true; + } + b'(' => parentheses += 1, + b')' => parentheses = parentheses.saturating_sub(1), + b';' | b'{' | b'}' if parentheses == 0 => { + return Some((index, char::from(character))); + } + _ => {} + } + } + None + }) +} + +#[cfg(test)] +mod tests { + use super::boundaries; + + #[test] + fn layer_boundaries_resume_when_bad_strings_end_at_raw_newlines() { + // Given + for newline in ["\n", "\r", "\r\n", "\u{c}"] { + let text = format!("content: 'broken{newline}; @layer base {{ color: red; }}"); + // When + let actual: Vec<_> = boundaries(&text).map(|(index, _)| &text[index..]).collect(); + // Then + assert_eq!( + actual, + vec![ + "; @layer base { color: red; }", + "{ color: red; }", + "; }", + "}" + ] + ); + } + } + + #[test] + fn layer_boundaries_resume_when_a_function_contains_a_bad_string() { + // Given + let text = "background: fn('broken\n); @layer base { color: red; }"; + // When + let actual: Vec<_> = boundaries(text).map(|(_, character)| character).collect(); + // Then + assert_eq!(actual, vec![';', '{', ';', '}']); + } + + #[test] + fn layer_boundaries_stay_quoted_when_hex_escapes_consume_newlines() { + // Given + for escape in ["\\62\n", "\\000062\r\n", "\\62\u{c}", "\\\r\n"] { + let text = + format!("@layer base {{ content: '{escape}}}; @layer fake {{'; color: red; }}"); + // When + let actual: Vec<_> = boundaries(&text).map(|(_, character)| character).collect(); + // Then + assert_eq!(actual, vec!['{', ';', ';', '}']); + } + } + + #[test] + fn layer_boundaries_ignore_comments_and_nested_function_delimiters() { + // Given + let text = + "/* ' \\ { ; ( */ @layer base { value: fn((a;{b}), '};'); /* ) } */ color: red; }"; + // When + let actual: Vec<_> = boundaries(text).map(|(_, character)| character).collect(); + // Then + assert_eq!(actual, vec!['{', ';', ';', '}']); + } + + #[test] + fn layer_boundaries_keep_escaped_delimiters_and_unicode_byte_offsets() { + // Given + let text = "@layer 한글\\{base { content: '\\' }'; color: red; }"; + // When + let actual: Vec<_> = boundaries(text).map(|(index, _)| &text[index..]).collect(); + // Then + assert_eq!( + actual, + vec![ + "{ content: '\\' }'; color: red; }", + "; color: red; }", + "; }", + "}" + ] + ); + } + + #[test] + fn layer_boundaries_ignore_eof_string_payload_whitespace() { + // Given + let text = "@layer base { content: 'open }; "; + // When + let actual: Vec<_> = boundaries(text).map(|(_, character)| character).collect(); + // Then + assert_eq!(actual, vec!['{']); + } +} 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..1391ff363 --- /dev/null +++ b/libs/extractor/src/css_utils/layer_names.rs @@ -0,0 +1,178 @@ +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, + } + } +} + +#[cfg(test)] +mod tests { + #[rstest::rstest] + #[case("base ", Some("base"))] + #[case("base /* trailing */\t", Some("base"))] + #[case("base extra", None)] + #[case(r"\31 base.inner", Some(r"\31 base.inner"))] + #[case(r"base\.inner", Some(r"base\.inner"))] + fn canonical_name_when_escaped_or_followed_by_trivia_preserves_identity( + #[case] source: &str, + #[case] expected: Option<&str>, + ) { + // Given / When + let name = super::parse(source); + // Then + assert_eq!(name.as_deref(), expected); + } +} diff --git a/libs/extractor/src/emotion_prop_join_support.rs b/libs/extractor/src/emotion_prop_join_support.rs new file mode 100644 index 000000000..cd2423b6f --- /dev/null +++ b/libs/extractor/src/emotion_prop_join_support.rs @@ -0,0 +1,228 @@ +use crate::class_selection_support::{self, Modules}; +use crate::extract_style::{extract_style_value::ExtractStyleValue, style_property::StyleProperty}; +use std::collections::BTreeMap; + +#[derive(Clone, Copy, Debug)] +pub enum Case { + Values, + WidthCall, + Compiled, +} + +pub const VALUES: &str = "export const App = ({ c, w }) =>
;"; +pub const WIDTH_CALL: &str = "const SPACE = 4;\nconst base = { padding: 4, lineHeight: 1.5, '&:hover': { margin: 2 } };\nexport const App = () =>
;"; +pub const COMPILED: &str = "import { jsx as _jsx } from '@emotion/react/jsx-runtime';\nconst base = { padding: 2 };\nconst SPACE = 3;\nexport const App = ({ c, s }) => [_jsx('div', { id: 1 }), _jsx('div', { style: s, css: [base, { margin: SPACE, color: c }] })];"; + +#[derive(Debug)] +pub struct Element { + pub target: String, + pub class_name: String, + pub inline: BTreeMap, +} + +#[derive(Debug)] +pub struct Join { + pub token: String, + pub property: String, + pub selector: Option, + pub variable: Option, + pub value: String, +} + +pub struct Record { + pub outputs: Modules, + pub element: Element, + pub joins: Vec, +} + +fn element(value: &serde_json::Value) -> Element { + let props = value["props"] + .as_object() + .unwrap_or_else(|| panic!("element props")); + assert_eq!(props.len(), 2, "only className and style on styled div"); + let inline = props["style"] + .as_object() + .unwrap_or_else(|| panic!("inline style")) + .iter() + .map(|(key, value)| { + let scalar = match value { + serde_json::Value::String(value) => value.clone(), + serde_json::Value::Number(value) => value.to_string(), + serde_json::Value::Null + | serde_json::Value::Bool(_) + | serde_json::Value::Array(_) + | serde_json::Value::Object(_) => panic!("unexpected inline value: {value}"), + }; + (key.clone(), scalar) + }) + .collect(); + Element { + target: value["target"] + .as_str() + .unwrap_or_else(|| panic!("native target")) + .into(), + class_name: props["className"] + .as_str() + .unwrap_or_else(|| panic!("selected tokens")) + .into(), + inline, + } +} + +pub fn selected(outputs: &Modules, element: &Element, single_css: bool) -> Vec { + let mut joins = Vec::new(); + for token in element.class_name.split_whitespace() { + for (name, output) in outputs { + for style in &output.styles { + let extracted = style.extract((!single_css).then_some(name.as_str())); + let join = match (style, extracted) { + (ExtractStyleValue::Static(value), Some(StyleProperty::ClassName(class))) + if class == token => + { + assert_eq!( + (value.level, &value.layer, value.style_order), + (0, &None, None) + ); + Some(Join { + token: token.into(), + property: value.property.clone(), + selector: value.selector.as_ref().map(ToString::to_string), + variable: None, + value: value.value.clone(), + }) + } + ( + ExtractStyleValue::Dynamic(value), + Some(StyleProperty::Variable { + class_name, + variable_name, + .. + }), + ) if class_name == token => { + assert_eq!( + (value.level(), value.layer(), value.style_order()), + (0, None, None) + ); + assert!(!value.important()); + Some(Join { + token: token.into(), + property: value.property().into(), + selector: value.selector().map(ToString::to_string), + value: element + .inline + .get(&variable_name) + .unwrap_or_else(|| panic!("selected variable on actual element")) + .clone(), + variable: Some(variable_name), + }) + } + _ => None, + }; + if let Some(join) = join { + joins.push(join); + } + } + } + } + joins +} + +pub fn run(case: Case, single_css: bool) -> Record { + let (original, observer, globals) = match case { + Case::Values => (VALUES, "globalThis.observed=App({c:'orange',w:17});", ""), + Case::WidthCall => ( + WIDTH_CALL, + "const element=App();globalThis.observed={element,calls:globalThis.calls};", + "globalThis.calls=[];globalThis.f=argument=>{globalThis.calls.push(argument);return 19;};", + ), + Case::Compiled => ( + COMPILED, + "globalThis.observed=App({c:'purple',s:{opacity: 0.75}});", + "", + ), + }; + let source = format!("import './environment.js';\n{original}"); + let outputs = class_selection_support::compile( + &[("/namespace/prop.tsx", &source)], + class_selection_support::option(single_css, "@devup-ui/react"), + ); + let observed = class_selection_support::observe( + &outputs, + &format!("import {{App}} from './prop.tsx';{observer}"), + globals, + ); + let native = match case { + Case::Values => element(&observed), + Case::WidthCall => { + assert_eq!(observed["calls"], serde_json::json!([{ "size": 4 }])); + element(&observed["element"]) + } + Case::Compiled => { + let elements = observed + .as_array() + .unwrap_or_else(|| panic!("original two elements")); + assert_eq!(elements.len(), 2); + assert_eq!( + elements[0], + serde_json::json!({"target":"div","props":{"id":1}}) + ); + element(&elements[1]) + } + }; + assert_eq!(native.target, "div"); + let joins = selected(&outputs, &native, single_css); + let mut actual: Vec<_> = joins + .iter() + .map(|join| { + ( + join.property.as_str(), + join.selector.as_deref(), + join.value.as_str(), + join.variable.is_some(), + ) + }) + .collect(); + actual.sort(); + let mut expected = match case { + Case::Values => vec![ + ("color", None, "orange", true), + ("width", None, "17px", true), + ], + Case::WidthCall => vec![ + ("padding", None, "4px", false), + ("line-height", None, "1.5", false), + ("margin", Some("&:hover"), "2px", false), + ("margin", None, "4px", false), + ("width", None, "19px", true), + ], + Case::Compiled => vec![ + ("color", None, "purple", true), + ("margin", None, "3px", false), + ("padding", None, "2px", false), + ], + }; + expected.sort(); + assert_eq!(actual, expected); + let opaque: Vec<_> = native + .class_name + .split_whitespace() + .filter(|token| !joins.iter().any(|join| join.token == *token)) + .collect(); + let (opaque_expected, opacity) = match case { + Case::Values => (vec!["x"], Some("1")), + Case::WidthCall => (vec![], None), + Case::Compiled => (vec![], Some("0.75")), + }; + assert_eq!(opaque, opaque_expected); + assert_eq!(native.inline.get("opacity").map(String::as_str), opacity); + assert_eq!( + native.inline.len(), + joins.iter().filter(|join| join.variable.is_some()).count() + + usize::from(opacity.is_some()) + ); + Record { + outputs, + element: native, + joins, + } +} diff --git a/libs/extractor/src/emotion_prop_join_tests.rs b/libs/extractor/src/emotion_prop_join_tests.rs new file mode 100644 index 000000000..edcc0ab9d --- /dev/null +++ b/libs/extractor/src/emotion_prop_join_tests.rs @@ -0,0 +1,27 @@ +use crate::emotion_prop_join_support::{Case, run}; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case::values(Case::Values)] +#[case::width_call(Case::WidthCall)] +#[case::compiled(Case::Compiled)] +#[serial] +fn original_css_prop_values_join_their_selected_properties( + #[case] case: Case, + #[values(false, true)] single_css: bool, +) { + // Given: original complete fixture source and isolated extraction state. + let _state = crate::test_state::TestStateGuard::new(); + // When: execute the emitted module and its actual App once. + let record = run(case, single_css); + // Then: the helper's authored property/value oracle covers every selected token. + assert_eq!(record.element.target, "div"); + assert_eq!(record.outputs.len(), 1); + let expected_count = match case { + Case::Values => 2, + Case::WidthCall => 5, + Case::Compiled => 3, + }; + assert_eq!(record.joins.len(), expected_count); +} diff --git a/libs/extractor/src/extractor/extract_style_from_expression.rs b/libs/extractor/src/extractor/extract_style_from_expression.rs index fe1af3863..74b558f80 100644 --- a/libs/extractor/src/extractor/extract_style_from_expression.rs +++ b/libs/extractor/src/extractor/extract_style_from_expression.rs @@ -249,6 +249,26 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( selector: &Option, literal_handling: LiteralHandling, prepare: &mut impl FnMut(&mut Expression<'a>), +) -> ExtractResult<'a> { + extract_projected::( + ast_builder, + name, + expression, + (level, selector), + literal_handling, + &mut super::style_order_projection::Projection::default(), + prepare, + ) +} + +pub(super) fn extract_projected<'a, const LOCATE: bool>( + ast_builder: &AstBuilder<'a>, + name: Option<&str>, + expression: &mut Expression<'a>, + (level, selector): (u8, &Option), + literal_handling: LiteralHandling, + projection: &mut super::style_order_projection::Projection<'a>, + prepare: &mut impl FnMut(&mut Expression<'a>), ) -> ExtractResult<'a> { let mut typo = false; let expression = unwrap_syntax_only_mut(expression); @@ -281,13 +301,16 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( flatten_spreads(ast_builder, obj); let mut props_styles: Vec> = vec![]; let mut tag = None; + projection.root(); for idx in (0..obj.properties.len()).rev() { let mut prop = obj.properties.remove(idx); if !match &mut prop { ObjectPropertyKind::ObjectProperty(prop) if get_str_by_property_key(&prop.key).is_none() => { - props_styles.push(unreadable_key(&prop.key, true)); + let error = unreadable_key(&prop.key, true); + projection.record(ast_builder, &error); + props_styles.push(error); false } ObjectPropertyKind::ObjectProperty(prop) => { @@ -319,12 +342,11 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( }); let ExtractResult { styles, tag: _tag, .. - } = extract_with_policy::( + } = projection.extract::( ast_builder, Some(disassembled), sibling.as_mut().unwrap_or(&mut prop.value), - 0, - &None, + (0, &None), LiteralHandling::ExpandResponsiveThemeToken, prepare, ); @@ -340,12 +362,11 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( ObjectPropertyKind::SpreadProperty(prop) => { let ExtractResult { styles, tag: _tag, .. - } = extract_with_policy::( + } = projection.extract::( ast_builder, None, &mut prop.argument, - 0, - &None, + (0, &None), LiteralHandling::ExpandResponsiveThemeToken, prepare, ); @@ -356,6 +377,7 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( } { obj.properties.insert(idx, prop); } + projection.end_property(); } ExtractResult { styles: props_styles, @@ -365,39 +387,42 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( props, } } - Expression::ConditionalExpression(conditional) => ExtractResult { - props: None, - styles: vec![ExtractStyleProp::Conditional { - condition: conditional.test.clone_in(ast_builder.allocator()), - consequent: Some(Box::new(ExtractStyleProp::StaticArray( - extract_with_policy::( - ast_builder, - None, - &mut conditional.consequent, - level, - &None, - literal_handling, - prepare, - ) - .styles, - ))), - alternate: Some(Box::new(ExtractStyleProp::StaticArray( - extract_with_policy::( - ast_builder, - None, - &mut conditional.alternate, - level, - selector, - literal_handling, - prepare, - ) - .styles, - ))), - }], - tag: None, - style_order, - style_vars, - }, + Expression::ConditionalExpression(conditional) => { + projection.conditional(false); + ExtractResult { + props: None, + styles: vec![ExtractStyleProp::Conditional { + condition: conditional.test.clone_in(ast_builder.allocator()), + consequent: Some(Box::new(ExtractStyleProp::StaticArray( + projection + .extract::( + ast_builder, + None, + &mut conditional.consequent, + (level, &None), + literal_handling, + prepare, + ) + .styles, + ))), + alternate: Some(Box::new(ExtractStyleProp::StaticArray( + projection + .extract::( + ast_builder, + None, + &mut conditional.alternate, + (level, selector), + literal_handling, + prepare, + ) + .styles, + ))), + }], + tag: None, + style_order, + style_vars, + } + } Expression::TemplateLiteral(tmp) => ExtractResult { styles: css_to_style_literal(tmp, level, selector) .into_iter() @@ -426,9 +451,9 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( ast_builder, (part, offset), &mut expression.clone_in_with_semantic_ids(ast_builder.allocator()), - level, - selector, + (level, selector), literal_handling, + projection, prepare, )); } @@ -462,7 +487,9 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( for p in &mut obj.properties { if let ObjectPropertyKind::ObjectProperty(o) = p { let Some(key_name) = get_string_by_property_key(&o.key) else { - props.push(unreadable_key(&o.key, false)); + let error = unreadable_key(&o.key, false); + projection.record(ast_builder, &error); + props.push(error); continue; }; let offset = o.key.span().start; @@ -471,9 +498,9 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( ast_builder, (part, offset), &mut o.value.clone_in_with_semantic_ids(ast_builder.allocator()), - level, - selector, + (level, selector), literal_handling, + projection, prepare, )); } @@ -494,17 +521,19 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( if let ObjectPropertyKind::ObjectProperty(o) = p && let Some(layer) = get_string_by_property_key(&o.key) { - let mut styles = extract_with_policy::( - ast_builder, - None, - &mut o.value, - level, - selector, - literal_handling, - prepare, - ) - .styles; + let mut styles = projection + .extract::( + ast_builder, + None, + &mut o.value, + (level, selector), + literal_handling, + prepare, + ) + .styles; + let layer = crate::css_utils::parse_layer_name(&layer).unwrap_or(layer); place_in_layer(&mut styles, &layer); + projection.layer(&layer); props.extend(styles); } } @@ -525,16 +554,16 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( StyleSelector::nest_at_rule(selector.as_ref(), kind, &query) { props.extend( - extract_with_policy::( - ast_builder, - None, - &mut o.value, - level, - &Some(at_selector), - literal_handling, - prepare, - ) - .styles, + projection + .extract::( + ast_builder, + None, + &mut o.value, + (level, &Some(at_selector)), + literal_handling, + prepare, + ) + .styles, ); } } @@ -551,9 +580,9 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( ast_builder, (name, offset), expression, - level, - selector, + (level, selector), literal_handling, + projection, prepare, ), ..ExtractResult::default() @@ -639,20 +668,21 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( } // `styled.div({ color: (p) => p.theme.brand })` — the object spelling of // the template interpolation, resolved to the same build-time `var()`. - Expression::ArrowFunctionExpression(_) => match (name, theme_var_reference(expression)) - { - (Some(name), Some(reference)) => ExtractResult { - styles: create_static_styles( - name, - &reference, - &[level], - selector, - ThemeTokenResolution::default(), - ), - ..ExtractResult::default() - }, - _ => unreadable(expression), - }, + Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_) => { + match (name, theme_var_reference(expression)) { + (Some(name), Some(reference)) => ExtractResult { + styles: create_static_styles( + name, + &reference, + &[level], + selector, + ThemeTokenResolution::default(), + ), + ..ExtractResult::default() + }, + _ => unreadable(expression), + } + } // Each variant is kept on its own line so per-line coverage // tools (tarpaulin on CI) can attribute the hit to the exact // pattern being exercised. @@ -827,7 +857,7 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( LogicalOperator::Or => literal_truthiness(&logical.left) == Some(false), LogicalOperator::Coalesce => nullish, }; - extract_with_policy::( + projection.extract::( ast_builder, name, if takes_right { @@ -835,8 +865,7 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( } else { &mut logical.left }, - level, - selector, + (level, selector), literal_handling, prepare, ) @@ -845,7 +874,7 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( if literal_truthiness(&conditional.test).is_some() => { let consequent = literal_truthiness(&conditional.test) == Some(true); - extract_with_policy::( + projection.extract::( ast_builder, name, if consequent { @@ -853,24 +882,24 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( } else { &mut conditional.alternate }, - level, - selector, + (level, selector), literal_handling, prepare, ) } Expression::LogicalExpression(logical) => { + projection.conditional(logical.operator == LogicalOperator::Coalesce); let res = Some(Box::new(ExtractStyleProp::StaticArray( - extract_with_policy::( - ast_builder, - name, - &mut logical.right, - level, - selector, - literal_handling, - prepare, - ) - .styles, + projection + .extract::( + ast_builder, + name, + &mut logical.right, + (level, selector), + literal_handling, + prepare, + ) + .styles, ))); match logical.operator { LogicalOperator::Or => ExtractResult { @@ -911,16 +940,16 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( ast_builder, ), consequent: Some(Box::new(ExtractStyleProp::StaticArray( - extract_with_policy::( - ast_builder, - name, - &mut logical.left, - level, - selector, - literal_handling, - prepare, - ) - .styles, + projection + .extract::( + ast_builder, + name, + &mut logical.left, + (level, selector), + literal_handling, + prepare, + ) + .styles, ))), alternate: res, }], @@ -944,16 +973,16 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( for (idx, element) in array.elements.iter_mut().enumerate() { if let Some(element) = element.as_expression_mut() { props.extend( - extract_with_policy::( - ast_builder, - name, - element, - idx as u8, - selector, - LiteralHandling::KeepSingleClass, - prepare, - ) - .styles, + projection + .extract::( + ast_builder, + name, + element, + (idx as u8, selector), + LiteralHandling::KeepSingleClass, + prepare, + ) + .styles, ); } } @@ -967,42 +996,42 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( } Expression::ConditionalExpression(conditional) => { if is_same_expression(&conditional.consequent, &conditional.alternate) { - extract_with_policy::( + projection.extract::( ast_builder, name, &mut conditional.consequent, - level, - selector, + (level, selector), literal_handling, prepare, ) } else { + projection.conditional(false); ExtractResult { styles: vec![ExtractStyleProp::Conditional { condition: conditional.test.clone_in(ast_builder.allocator()), consequent: Some(Box::new(ExtractStyleProp::StaticArray( - extract_with_policy::( - ast_builder, - name, - &mut conditional.consequent, - level, - selector, - literal_handling, - prepare, - ) - .styles, + projection + .extract::( + ast_builder, + name, + &mut conditional.consequent, + (level, selector), + literal_handling, + prepare, + ) + .styles, ))), alternate: Some(Box::new(ExtractStyleProp::StaticArray( - extract_with_policy::( - ast_builder, - name, - &mut conditional.alternate, - level, - selector, - literal_handling, - prepare, - ) - .styles, + projection + .extract::( + ast_builder, + name, + &mut conditional.alternate, + (level, selector), + literal_handling, + prepare, + ) + .styles, ))), }], ..ExtractResult::default() @@ -1060,22 +1089,24 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( ObjectPropertyKind::ObjectProperty(o) => o, ObjectPropertyKind::SpreadProperty(spread) => { props.extend( - extract_with_policy::( - ast_builder, - None, - &mut spread.argument, - level, - &selector, - literal_handling, - prepare, - ) - .styles, + projection + .extract::( + ast_builder, + None, + &mut spread.argument, + (level, &selector), + literal_handling, + prepare, + ) + .styles, ); continue; } }; let Some(key_name) = o.key.name() else { - props.push(unreadable_key(&o.key, false)); + let error = unreadable_key(&o.key, false); + projection.record(ast_builder, &error); + props.push(error); continue; }; if key_name == "params" { @@ -1085,6 +1116,7 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( &key_name, o.key.span().start, ) { + projection.record(ast_builder, &error); props.push(error); continue; } @@ -1094,16 +1126,16 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( let mut sibling = (properties.len() != 0) .then(|| o.value.clone_in_with_semantic_ids(ast_builder.allocator())); props.extend( - extract_with_policy::( - ast_builder, - Some(name), - sibling.as_mut().unwrap_or(&mut o.value), - level, - &selector, - literal_handling, - prepare, - ) - .styles, + projection + .extract::( + ast_builder, + Some(name), + sibling.as_mut().unwrap_or(&mut o.value), + (level, &selector), + literal_handling, + prepare, + ) + .styles, ); } } @@ -1117,12 +1149,7 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( Some(_) if matches!(expression, Expression::NullLiteral(_)) => { ExtractResult::default() } - Some(_) - if matches!( - expression, - Expression::FunctionExpression(_) | Expression::ClassExpression(_) - ) => - { + Some(_) if matches!(expression, Expression::ClassExpression(_)) => { unreadable(expression) } // A sequence keeps its parentheses, as its code is written into @@ -1181,12 +1208,7 @@ pub(super) fn locate_dynamic(style: &mut ExtractStyleProp<'_>, offset: u32) { /// 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), @@ -1371,26 +1393,25 @@ impl From> for Nested { fn extract_under_key<'a, const LOCATE: bool>( ast_builder: &AstBuilder<'a>, - located_key: (&str, u32), + (key, offset): (&str, u32), expression: &mut Expression<'a>, - level: u8, - selector: &Option, + (level, selector): (u8, &Option), literal_handling: LiteralHandling, + projection: &mut super::style_order_projection::Projection<'a>, prepare: &mut impl FnMut(&mut Expression<'a>), ) -> Vec> { - let (key, offset) = located_key; match nest_selectors_key(selector.as_ref(), key) { Nested::Under(nested) => { - extract_with_policy::( - ast_builder, - None, - expression, - level, - &Some(nested), - literal_handling, - prepare, - ) - .styles + projection + .extract::( + ast_builder, + None, + expression, + (level, &Some(nested)), + literal_handling, + prepare, + ) + .styles } Nested::Never => vec![], Nested::Unknown => vec![misplaced(offset, key.to_string(), SELECTOR_NAME)], diff --git a/libs/extractor/src/extractor/extract_style_from_styled.rs b/libs/extractor/src/extractor/extract_style_from_styled.rs index 853908060..3151f6aed 100644 --- a/libs/extractor/src/extractor/extract_style_from_styled.rs +++ b/libs/extractor/src/extractor/extract_style_from_styled.rs @@ -2,6 +2,14 @@ use super::styled_capability::StyledCapability; use super::styled_css_slot::{StyledContext, StyledCssSlot}; #[path = "styled_retained.rs"] mod retained; +#[path = "styled_retarget.rs"] +mod retarget; +pub use retarget::with_component; +#[path = "styled_residual.rs"] +mod residual; +#[path = "styled_stages.rs"] +mod stages; +use super::style_order_projection::{AuthoredStyles, extract_with_order}; use super::styled_jsx::StyledJsx; use retained::Execution; use rustc_hash::FxHashMap; @@ -9,21 +17,19 @@ use rustc_hash::FxHashMap; use crate::{ ExtractStyleProp, component::ExportVariableKind, - css_utils::{TemplateStyles, css_to_style_template}, extract_style::extract_style_value::ExtractStyleValue, extractor::{ ExtractResult, - extract_style_from_expression::{LiteralHandling, extract_style_from_expression}, + extract_style_from_expression::LiteralHandling, styled_record::prepare, - styled_record_evaluation::{Evaluations, bind_render, visit_style_expressions}, + styled_record_evaluation::{Evaluations, bind_render_body, visit_style_expressions}, }, 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, + STYLE_OBJECT, build_time_error, call_with_values, expression_to_code, + merge_object_expressions, readable_code, unreadable_styles, unwrap_syntax_only, unwrap_syntax_only_mut, wrap_array_filter, wrap_direct_call, }, }; @@ -41,6 +47,34 @@ use oxc_parser::{Parser, ParserReturn}; use oxc_span::{GetSpan, SPAN, SourceType}; use oxc_syntax::operator::{BinaryOperator, LogicalOperator, UnaryOperator}; +#[path = "styled_props.rs"] +mod props; + +#[path = "styled_scalar.rs"] +pub(super) mod scalar; + +#[path = "styled_rule_choices.rs"] +mod rule_choices; + +#[path = "styled_template_composition.rs"] +mod template_composition; + +#[path = "styled_rule_parts.rs"] +mod rule_parts; + +#[path = "styled_rule_records.rs"] +mod records; + +#[path = "styled_template_bindings.rs"] +mod template_bindings; + +#[path = "styled_arguments.rs"] +mod arguments; + +#[cfg(test)] +#[path = "styled_render_body_tests.rs"] +mod render_body_tests; + /// The binding a styled component reads the component it renders through, when /// only the runtime gives that component const STYLED_BASE: &str = "DevupStyled"; @@ -70,6 +104,7 @@ pub struct StyledDefinition<'a> { bound: Option>, classes: Vec>, styles: Vec>, + composition_styles: Option>, attrs: Vec>, /// The props its styles and attrs read reads: Reads, @@ -102,12 +137,42 @@ 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: Kinds<'s>, + pub values: &'s crate::style_values::StyleValues<'s>, + pub inline_css: &'s FxHashMap>, +} + +impl StyledBindings<'_> { + fn styles(&self, expression: &Expression<'_>) -> Option<&[ExtractStyleValue]> { + self.values + .styles(unwrap_syntax_only(expression)) + .or_else(|| match unwrap_syntax_only(expression) { + Expression::StringLiteral(literal) => { + self.inline_css.get(&literal.span.start).map(Vec::as_slice) + } + _ => None, + }) + } +} + /// 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) { @@ -160,6 +225,11 @@ impl<'a> StyledDefinition<'a> { for style in &mut self.styles { visit_style_expressions(builder, style, &mut visit); } + if let Some(styles) = &mut self.composition_styles { + for style in &mut styles.0 { + visit_style_expressions(builder, style, &mut visit); + } + } } fn rebind(&self, builder: &AstBuilder<'a>, scope: &mut Expression<'a>) -> Self { @@ -170,6 +240,10 @@ impl<'a> StyledDefinition<'a> { let mut inherited = Self { name: self.name.clone(), target: self.target.as_ref().map(|value| value.clone_in(allocator)), + composition_styles: self + .composition_styles + .as_ref() + .map(|styles| styles.clone_in(allocator)), bound: self.bound.as_ref().map(|value| value.clone_in(allocator)), classes: clone_all(builder, &self.classes).collect(), styles: self @@ -217,9 +291,19 @@ impl<'a> StyledDefinition<'a> { for (_, value) in &mut inherited.bindings { rename.visit_expression(value); } + for class in &mut inherited.classes { + rename.visit_expression(class); + } for style in &mut inherited.styles { visit_style_expressions(builder, style, &mut |value| rename.visit_expression(value)); } + if let Some(styles) = &mut inherited.composition_styles { + for style in &mut styles.0 { + visit_style_expressions(builder, style, &mut |value| { + rename.visit_expression(value); + }); + } + } let mut reserved = vec![reservation]; reserved.extend(inherited.bindings.iter().map(|(name, _)| { Expression::new_identifier( @@ -283,7 +367,8 @@ impl<'a> StyledDefinition<'a> { fn with_css_slot(mut self, slot: Option<&StyledCapability>, inherited: Option<&Self>) -> Self { self.capability = slot .filter(|_| { - (self.fixed_native() && !self.bindings.is_empty()) + (!self.fixed_summary() && !self.portable()) + || (self.fixed_native() && !self.bindings.is_empty()) || !self.execution.replays() || (!self.native() && self.fixed_summary() @@ -315,6 +400,10 @@ impl<'a> StyledDefinition<'a> { pub fn clone_in<'b>(&self, allocator: &'b oxc_allocator::Allocator) -> StyledDefinition<'b> { StyledDefinition { name: self.name.clone(), + composition_styles: self + .composition_styles + .as_ref() + .map(|styles| styles.clone_in(allocator)), target: self .target .as_ref() @@ -408,6 +497,17 @@ impl<'a> StyledDefinition<'a> { pub fn styles(&self) -> &[ExtractStyleProp<'a>] { &self.styles } + + pub(crate) fn composition_styles(&self) -> &[ExtractStyleProp<'a>] { + self.composition_styles + .as_ref() + .map_or(&self.styles, |styles| &styles.0) + } + + fn with_composition_styles(mut self, styles: Option>) -> Self { + self.composition_styles = styles; + self + } } /// Whether `prop` holds only values the build knows @@ -419,96 +519,6 @@ fn fixed(prop: &ExtractStyleProp<'_>) -> bool { } } -/// `Component.withComponent(target)`: the styles and attrs of `definition` -/// rendering `target`, a tag name or a component JSX can name; `None` for any -/// other target -pub fn with_component<'a>( - ast_builder: &AstBuilder<'a>, - definition: &StyledDefinition<'a>, - target: &Expression<'a>, - naming: Naming<'_>, - context: StyledContext<'_>, -) -> Option<(Expression<'a>, StyledDefinition<'a>)> { - let name = match unwrap_syntax_only(target) { - Expression::StringLiteral(literal) => literal.value.to_string(), - target => jsx_name(target)?, - }; - let allocator = ast_builder.allocator(); - let base = Base { - target: match unwrap_syntax_only(target) { - Expression::StringLiteral(_) => None, - _ => Some(target.clone_in(allocator)), - }, - ..Base::named(name) - }; - let mut scope = target.clone_in(allocator); - let mut definition = definition.rebind(ast_builder, &mut scope); - match &mut definition.execution { - Execution::Delegated(renderer) => renderer.native = renders_tag(&base.name, None), - Execution::Replay | Execution::RetainedFixed { .. } => {} - } - let mut styles: Vec> = definition - .styles - .iter() - .map(|style| style.clone_in(allocator)) - .collect(); - let classes: Vec> = clone_all(ast_builder, &definition.classes).collect(); - let class_name = merge_expression_for_class_name( - ast_builder, - clone_all(ast_builder, &classes).chain(gen_class_names( - ast_builder, - &mut styles, - None, - naming.split_filename, - )), - ); - let withheld = withheld( - &definition.reads, - renders_tag(&base.name, None), - definition.forward.as_ref(), - ); - let new_definition = base - .definition( - ast_builder, - classes, - &styles, - &definition.attrs, - (definition.reads.clone(), definition.forward.clone()), - &definition.bindings, - ) - .with_execution(ast_builder, Some(&definition)) - .with_css_slot(context.capability, Some(&definition)) - .with_markers(naming.markers, Some(&definition)); - let component = create_styled_component( - ast_builder, - &base, - StyledRender { - class_name: &class_name, - style_vars: &gen_styles(ast_builder, &styles, None), - bindings: definition - .bindings - .iter() - .map(|(name, value)| (name.clone(), value.clone_in(allocator))) - .collect(), - withheld: &withheld, - capability: new_definition.capability.as_ref(), - execution: &new_definition.execution, - markers: new_definition.render_markers(ast_builder), - }, - context.jsx, - ); - Some(( - new_definition.retain_bound( - ast_builder, - base.render( - ast_builder, - apply_attrs(ast_builder, component, &definition.attrs), - ), - ), - new_definition, - )) -} - /// What extracting a styled component gives pub struct StyledExtraction<'a> { pub result: ExtractResult<'a>, @@ -523,23 +533,43 @@ fn compose_styles<'a>( ast_builder: &AstBuilder<'a>, inherited: Option<&StyledDefinition<'a>>, defaults: Option>, - own: Vec>, -) -> Vec> { + mut own: Vec>, + authored: Option>, +) -> (Vec>, Option>) { let mut earlier: Vec> = inherited.map_or_else(Vec::new, |inherited| { inherited - .styles + .composition_styles() .iter() .map(|style| style.clone_in(ast_builder.allocator())) .collect() }); earlier.extend(defaults.into_iter().flatten().map(ExtractStyleProp::Static)); if earlier.is_empty() { - return own; + let summary = authored.map(|styles| styles.normalized(ast_builder)); + if let Some(summary) = &summary { + own.splice( + 0..0, + summary + .0 + .iter() + .filter(|prop| { + matches!( + prop, + ExtractStyleProp::Static(ExtractStyleValue::LayerOrder(_)) + ) + }) + .map(|prop| prop.clone_in(ast_builder.allocator())), + ); + } + return (own, summary); } let mut composition = crate::composition::Composition::default(); composition.apply(ast_builder, earlier); - composition.apply(ast_builder, own); - composition.into_props() + match authored { + Some(authored) => authored.apply(ast_builder, &mut composition), + None => composition.apply(ast_builder, own), + } + (composition.into_props(), None) } /// The classes and attrs a component extending `inherited` starts from @@ -614,6 +644,7 @@ impl<'a> Base<'a> { .map(|target| target.clone_in(allocator)), bound: self.bound.as_ref().map(|bound| bound.clone_in(allocator)), classes, + composition_styles: None, styles: styles .iter() .map(|style| style.clone_in(allocator)) @@ -796,16 +827,17 @@ pub fn extract_style_from_styled<'a>( ast_builder: &AstBuilder<'a>, expression: &mut Expression<'a>, naming: Naming<'_>, - imports: Kinds<'_>, - modifiers: (&[Expression<'a>], Option), + bindings: StyledBindings<'_>, + modifiers: (&[Expression<'a>], Option, &[u32]), inherited: Option<&StyledDefinition<'a>>, context: StyledContext<'_>, ) -> StyledExtraction<'a> { + let imports = bindings.imports; let Naming { split_filename, markers, } = naming; - let (attrs, forward) = modifiers; + let (attrs, forward, refused_array_controllers) = modifiers; let mut original_scope = expression.clone_in(ast_builder.allocator()); let inherited = inherited.map(|definition| definition.rebind(ast_builder, &mut original_scope)); let inherited = inherited.as_ref(); @@ -816,33 +848,37 @@ pub fn extract_style_from_styled<'a>( } let mut composed_classes = Vec::new(); let mut errors = Vec::new(); + errors.extend(crate::css_utils::object_layer_errors(expression, "styled")); + let mut captured_bindings = Vec::new(); + let mut composed_rules = None; if let Expression::CallExpression(call) = expression && extract_base_tag_and_class_name(ast_builder, &call.callee, imports).is_some() { - match style_arguments(ast_builder, &call.arguments) { - Some(StyleArguments { classes, rules }) => { - call.arguments = - oxc_allocator::Vec::from_array_in([Argument::from(rules)], ast_builder); - composed_classes = classes; - } - None if !reads_directly(&call.arguments) => { - errors.push((call.span.start, uncomposable_error(&call.arguments))); - 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 => {} - } + let prepared = arguments::prepare_with_bindings( + ast_builder, + call, + ( + arguments::Context { + filename: context.filename, + scope: &mut original_scope, + reads: &mut reads, + refused: refused_array_controllers, + }, + Some(&bindings), + ), + ); + composed_rules = prepared.rules; + composed_classes = prepared.classes; + captured_bindings = prepared.bindings; + errors.extend(prepared.errors); } let (result, new_expr, definition) = if let Expression::TaggedTemplateExpression(tag) = 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 @@ -871,53 +907,23 @@ 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))); - } - errors.extend( - tag.quasi - .expressions - .iter() - .filter(|expression| crate::css_utils::reads_unmapped_theme(expression)) - .map(|expression| crate::css_utils::unmapped_theme_error("styled", expression)), + bindings: own_bindings, + errors: template_errors, + } = template_composition::compose_in( + ast_builder, + &tag.quasi, + (&bindings, &original_scope, context.filename), ); - let own: Vec> = styles - .into_iter() - .map(|ex| ExtractStyleProp::Static(ex.into())) - .collect(); + 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); + let (mut props_styles, _) = compose_styles(ast_builder, inherited, defaults, own, None); let (mut classes, attrs) = inherited_parts(ast_builder, inherited, attrs); - classes.extend(statements.into_iter().map(|index| { - let mixin = &tag.quasi.expressions[index]; - if matches!( - unwrap_syntax_only(mixin), - Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_) - ) { - // A mixin returns a class, or `false` when its condition fails - Expression::new_logical_expression( - SPAN, - wrap_direct_call( - ast_builder, - mixin, - &[Expression::new_identifier(SPAN, "rest", ast_builder)], - ), - LogicalOperator::Or, - Expression::new_string_literal(SPAN, "", None, ast_builder), - ast_builder, - ) - } else { - mixin.clone_in(ast_builder.allocator()) - } - })); + classes.extend(statements); let class_name = merge_expression_for_class_name( ast_builder, clone_all(ast_builder, &classes).chain(gen_class_names( @@ -933,13 +939,14 @@ pub fn extract_style_from_styled<'a>( None, ast_builder, )); - let bindings = inherited.map_or_else(Vec::new, |definition| { + let mut bindings = inherited.map_or_else(Vec::new, |definition| { definition .bindings .iter() .map(|(name, value)| (name.clone(), value.clone_in(ast_builder.allocator()))) .collect() }); + bindings.extend(own_bindings); let withheld = withheld( &reads, inherited.map_or_else( @@ -960,23 +967,42 @@ pub fn extract_style_from_styled<'a>( .with_execution(ast_builder, inherited) .with_css_slot(context.capability, inherited) .with_markers(markers, inherited); - let component = create_styled_component( + let staged = stages::create( ast_builder, - &base, - StyledRender { - class_name: &class_name, - style_vars: &gen_styles(ast_builder, &props_styles, None), - bindings, - withheld: &withheld, - capability: definition.capability.as_ref(), - execution: &definition.execution, - markers: definition.render_markers(ast_builder), + &definition, + stages::Context { + base: &base, + naming, + jsx: context.jsx, }, - context.jsx, ); + let has_stage = staged.is_some(); + let component = staged.unwrap_or_else(|| { + create_styled_component( + ast_builder, + &base, + StyledRender { + class_name: &class_name, + style_vars: &gen_styles(ast_builder, &props_styles, None), + bindings, + withheld: &withheld, + capability: definition.capability.as_ref(), + execution: &definition.execution, + markers: definition.render_markers(ast_builder), + }, + context.jsx, + ) + }); let styled_component = definition.retain_bound( ast_builder, - base.render(ast_builder, apply_attrs(ast_builder, component, &attrs)), + base.render( + ast_builder, + if has_stage { + component + } else { + apply_attrs(ast_builder, component, &attrs) + }, + ), ); let result = ExtractResult { @@ -1002,9 +1028,12 @@ pub fn extract_style_from_styled<'a>( call.arguments[style_index].to_expression() }, ); - let prepared = call.arguments[style_index] - .as_expression() - .and_then(|rules| prepare(ast_builder, rules, &original_scope)); + let prepared = match &composed_rules { + Some(_) => None, + None => call.arguments[style_index] + .as_expression() + .and_then(|rules| prepare(ast_builder, rules, &original_scope)), + }; let mut bindings: Vec<_> = inherited.map_or_else(Vec::new, |definition| { definition .bindings @@ -1012,6 +1041,7 @@ pub fn extract_style_from_styled<'a>( .map(|(name, value)| (name.clone(), value.clone_in(ast_builder.allocator()))) .collect() }); + bindings.extend(captured_bindings); bindings.extend(match prepared { Some(prepared) => { call.arguments[style_index] = Argument::from(prepared.rules); @@ -1020,24 +1050,29 @@ pub fn extract_style_from_styled<'a>( None => Vec::new(), }); // Extract styles from object expression + let mut authored = None; let ExtractResult { mut styles, style_order, 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(|| { + let (result, projection) = extract_with_order( + 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, + ); + authored = Some(projection.registered(context.filename)); + result + }); let mut unreadable = Vec::new(); unreadable_styles(&styles, true, &mut unreadable); errors.extend(unreadable.into_iter().map(|(offset, code, requirement)| { @@ -1050,10 +1085,16 @@ pub fn extract_style_from_styled<'a>( for style in &mut styles { crate::composition::set_prop_order(style, order); } + if let Some(authored) = &mut authored { + for style in &mut authored.0 { + crate::composition::set_prop_order(style, order); + } + } } let defaults = base.styles.take(); let base = base.extending(ast_builder, inherited); - let mut styles = compose_styles(ast_builder, inherited, defaults, styles); + let (mut styles, composition_styles) = + compose_styles(ast_builder, inherited, defaults, styles, authored); let (mut classes, attrs) = inherited_parts(ast_builder, inherited, attrs); classes.extend(composed_classes); @@ -1083,26 +1124,46 @@ pub fn extract_style_from_styled<'a>( (reads, forward), &bindings, ) + .with_composition_styles(composition_styles) .with_execution(ast_builder, inherited) .with_css_slot(context.capability, inherited) .with_markers(markers, inherited); - let component = create_styled_component( + let staged = stages::create( ast_builder, - &base, - StyledRender { - class_name: &class_name, - style_vars: &gen_styles(ast_builder, &styles, None), - bindings, - withheld: &withheld, - capability: definition.capability.as_ref(), - execution: &definition.execution, - markers: definition.render_markers(ast_builder), + &definition, + stages::Context { + base: &base, + naming, + jsx: context.jsx, }, - context.jsx, ); + let has_stage = staged.is_some(); + let component = staged.unwrap_or_else(|| { + create_styled_component( + ast_builder, + &base, + StyledRender { + class_name: &class_name, + style_vars: &gen_styles(ast_builder, &styles, None), + bindings, + withheld: &withheld, + capability: definition.capability.as_ref(), + execution: &definition.execution, + markers: definition.render_markers(ast_builder), + }, + context.jsx, + ) + }); let styled_component = definition.retain_bound( ast_builder, - base.render(ast_builder, apply_attrs(ast_builder, component, &attrs)), + base.render( + ast_builder, + if has_stage { + component + } else { + apply_attrs(ast_builder, component, &attrs) + }, + ), ); let result = ExtractResult { @@ -1180,7 +1241,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 { @@ -1672,75 +1733,14 @@ pub fn forward_ref<'a>(ast_builder: &AstBuilder<'a>, component: Expression<'a>) ) } -/// `rest` without the props in `withheld`, as `(({ a: _0, ...p }) => p)(rest)` +#[path = "styled_forwarding.rs"] +pub(crate) mod forwarding; + fn without_props<'a>(ast_builder: &AstBuilder<'a>, withheld: &[String]) -> Expression<'a> { - let rest = Expression::new_identifier(SPAN, "rest", ast_builder); - if withheld.is_empty() { - return rest; - } - let properties = withheld.iter().enumerate().map(|(index, name)| { - BindingProperty::new( - SPAN, - PropertyKey::StringLiteral(oxc_ast::ast::StringLiteral::boxed( - SPAN, - Str::from_in(name.as_str(), ast_builder.allocator()), - None, - ast_builder, - )), - BindingPattern::new_binding_identifier( - SPAN, - Str::from_in( - format!("__devupOmit{index}").as_str(), - ast_builder.allocator(), - ), - ast_builder, - ), - false, - false, - ast_builder, - ) - }); - let pattern = BindingPattern::new_object_pattern( - SPAN, - oxc_allocator::Vec::from_iter_in(properties, ast_builder), - Some(BindingRestElement::boxed( - SPAN, - BindingPattern::new_binding_identifier(SPAN, "__devupDom", ast_builder), - ast_builder, - )), - ast_builder, - ); - let parameter = FormalParameter::new( - SPAN, - oxc_allocator::Vec::new_in(ast_builder), - pattern, - None::>>, - None::>>, - false, - None, - false, - false, - ast_builder, - ); - let arrow = Expression::new_arrow_function_expression( - SPAN, - false, - None::>>, - FormalParameters::boxed( - SPAN, - FormalParameterKind::ArrowFormalParameters, - oxc_allocator::Vec::from_iter_in([parameter], ast_builder), - None::>>, - ast_builder, - ), - None::>>, - Expression::new_identifier(SPAN, "__devupDom", ast_builder).into(), + forwarding::without_props( ast_builder, - ); - wrap_direct_call( - ast_builder, - &Expression::new_parenthesized_expression(SPAN, arrow, ast_builder), - &[rest], + Expression::new_identifier(SPAN, "rest", ast_builder), + withheld, ) } @@ -1873,7 +1873,10 @@ fn create_styled_component<'a>( let replacement = capability.map(|slot| slot.classes.classes(ast_builder, class_name.as_ref())); let delegated = match execution { Execution::Delegated(renderer) => Some(renderer), - Execution::Replay | Execution::RetainedFixed { .. } => None, + Execution::Replay + | Execution::RetainedFixed { .. } + | Execution::RetainedDynamic { .. } + | Execution::Staged { .. } => None, }; let own_classes = merge_expression_for_class_name( ast_builder, @@ -1997,11 +2000,23 @@ fn create_styled_component<'a>( None::>>, params, None::>>, - bind_render(ast_builder, bindings, body).into(), + bind_render_body(ast_builder, bindings, body), ast_builder, ) } +#[cfg(test)] +#[path = "props_rule_choice_tests.rs"] +mod props_rule_choice_tests; + +#[cfg(test)] +#[path = "styled_extraction_edge_tests.rs"] +mod styled_extraction_edge_tests; + +#[cfg(test)] +#[path = "known_mixin_tests.rs"] +mod known_mixin_tests; + #[cfg(test)] #[allow(clippy::expect_used, clippy::unwrap_used)] mod tests { @@ -2062,6 +2077,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/known_mixin_execution_tests.rs b/libs/extractor/src/extractor/known_mixin_execution_tests.rs new file mode 100644 index 000000000..469fd3e1b --- /dev/null +++ b/libs/extractor/src/extractor/known_mixin_execution_tests.rs @@ -0,0 +1,177 @@ +use crate::{ExtractOption, ExtractStyleValue, ResolvedModule, extract, extract_with_modules}; +use rstest::rstest; +use serial_test::serial; + +#[path = "styled_composition_tests.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::props_rule_choice_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::props_rule_choice_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: "/composition/base.tsx".into(), + code: "import styled from '@emotion/styled'; export const Child = styled.div`color: red;`;".into(), + }) + }; + // When + let output = extract_with_modules( + "/composition/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: "/composition/base.tsx".into(), + code: "import styled from '@emotion/styled'; export const Child = styled.div`color: red;`;".into(), + }) + }; + // When + let error = extract_with_modules( + "/composition/entry.tsx", + &source, + super::option(), + false, + &resolver, + ) + .err() + .ok_or("unknown selector unexpectedly compiled")? + .to_string(); + // Then + assert!(error.starts_with("/composition/entry.tsx:1:"), "{error}"); + assert!(error.contains(selector), "{error}"); + assert!(error.contains("at build time"), "{error}"); + Ok(()) +} diff --git a/libs/extractor/src/extractor/known_mixin_regressions.rs b/libs/extractor/src/extractor/known_mixin_regressions.rs new file mode 100644 index 000000000..622866a4b --- /dev/null +++ b/libs/extractor/src/extractor/known_mixin_regressions.rs @@ -0,0 +1,258 @@ +use super::super::props_rule_choice_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!( + crate::styled_source_tests::captured_render::execute( + ("context-mixin.tsx", None), + &output, + "const element=render({target:Choice,props:{on:true,padding:8}});JSON.stringify(Object.values(element.props.style));" + ), + serde_json::json!(["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/known_mixin_tests.rs b/libs/extractor/src/extractor/known_mixin_tests.rs new file mode 100644 index 000000000..38fc249e1 --- /dev/null +++ b/libs/extractor/src/extractor/known_mixin_tests.rs @@ -0,0 +1,277 @@ +use crate::{ExtractOption, ExtractOutput, ExtractStyleValue, ImportAlias, ResolvedModule}; +use std::collections::HashMap; + +#[path = "known_mixin_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: "/composition/base.tsx".to_string(), + code: module.clone(), + }) + }; + crate::extract_with_modules("/composition/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() + .filter_map(|value| match value { + ExtractStyleValue::Static(style) => Some(Ok(( + style.property().to_string(), + style.value().to_string(), + ))), + ExtractStyleValue::LayerOrder(_) => None, + ExtractStyleValue::Typography(_) + | ExtractStyleValue::Dynamic(_) + | ExtractStyleValue::Css(_) + | ExtractStyleValue::Import(_) + | ExtractStyleValue::FontFace(_) + | ExtractStyleValue::Keyframes(_) => { + Some(Err(format!("expected static atom, got {value:?}"))) + } + }) + .collect::>()?; + styles.sort(); + Ok(styles) +} + +#[path = "known_mixin_execution_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, + "mixin-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 + .into_styles("mixin-layers.tsx") + .iter() + .filter_map(|value| match value { + ExtractStyleValue::Static(style) => { + Some((style.layer().map(str::to_string), style.value().to_string())) + } + ExtractStyleValue::LayerOrder(_) => None, + 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("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("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("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")); +} + +#[test] +#[serial_test::serial] +fn declaration_oracle_when_mixin_registers_a_layer_preserves_its_declaration() { + // Given + let source = + "import {styled} from '@devup-ui/react';const Choice=styled.div`@layer base {color:red;}`;"; + let output = crate::extract("layer-mixin.tsx", source, option()) + .unwrap_or_else(|error| panic!("{error}")); + // When + let declarations = declarations(&output); + // Then + assert_eq!(declarations, [("color".into(), "red".into())]); + assert!( + output + .styles + .iter() + .any(|style| matches!(style, ExtractStyleValue::LayerOrder(_))) + ); +} diff --git a/libs/extractor/src/extractor/layer_choice_tests.rs b/libs/extractor/src/extractor/layer_choice_tests.rs new file mode 100644 index 000000000..2a8bd2520 --- /dev/null +++ b/libs/extractor/src/extractor/layer_choice_tests.rs @@ -0,0 +1,28 @@ +use super::*; +use crate::ExtractStyleValue; +use crate::extract_style::extract_layer_order::ExtractLayerOrder; + +#[test] +fn known_class_is_falsy_when_metadata_is_its_only_entry() { + // Given: an exact existing inline carrier with registration-only metadata. + let arena = oxc_allocator::Allocator::default(); + let ast = AstBuilder::new(&arena); + let value = Expression::new_string_literal(SPAN, "", None, &ast); + let values = crate::style_values::StyleValues::default(); + let inline_css = rustc_hash::FxHashMap::from_iter([( + SPAN.start, + vec![ExtractStyleValue::LayerOrder(ExtractLayerOrder { + file: "actual.tsx".to_string(), + layers: vec!["a".to_string()], + })], + )]); + let bindings = StyledBindings { + imports: &|_| None, + values: &values, + inline_css: &inline_css, + }; + // When: whole-rule choice normalization reads the known class. + let choice = read(&ast, &value, &bindings); + // Then: registration does not turn the empty class into a truthy rule. + assert!(matches!(choice, Ok(Choice::Rules { truthy: false, .. }))); +} diff --git a/libs/extractor/src/extractor/mod.rs b/libs/extractor/src/extractor/mod.rs index e661a4e3c..7189dd2a4 100644 --- a/libs/extractor/src/extractor/mod.rs +++ b/libs/extractor/src/extractor/mod.rs @@ -9,13 +9,16 @@ pub(super) mod extract_style_from_jsx; pub(super) mod extract_style_from_member_expression; pub(super) mod extract_style_from_styled; pub(super) mod extract_style_from_stylex; +pub(super) mod rule_array_evaluation; +pub(crate) mod style_order_projection; pub(super) mod styled_capability; pub(super) mod styled_css_slot; pub(super) mod styled_jsx; mod styled_record; -mod styled_record_evaluation; +pub(super) mod styled_record_evaluation; #[cfg(test)] mod styled_record_tests; +pub(super) mod styled_rule_arrays; mod styled_transient; /** diff --git a/libs/extractor/src/extractor/props_rule_choice_regressions.rs b/libs/extractor/src/extractor/props_rule_choice_regressions.rs new file mode 100644 index 000000000..36f0e017b --- /dev/null +++ b/libs/extractor/src/extractor/props_rule_choice_regressions.rs @@ -0,0 +1,255 @@ +use super::super::props_rule_choice_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.property() == "color"))); + let observed = crate::styled_source_tests::captured_render::execute( + ("rule-choices-active.tsx", None), + &output, + "const element=render({target:make('purple'),props:{}});JSON.stringify(values(element,'color'));", + ); + assert_eq!(observed, serde_json::json!(["purple"])); +} + +#[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( + "rule-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/props_rule_choice_tests.rs b/libs/extractor/src/extractor/props_rule_choice_tests.rs new file mode 100644 index 000000000..d59c4bad6 --- /dev/null +++ b/libs/extractor/src/extractor/props_rule_choice_tests.rs @@ -0,0 +1,283 @@ +use crate::extract_style::ExtractStyleProperty; +use crate::{ExtractOption, ExtractOutput, ExtractStyleValue, extract}; + +#[path = "props_rule_choice_regressions.rs"] +mod regressions; + +#[path = "styled_choice_test_support.rs"] +pub(super) mod support; + +fn selected_classes(output: &ExtractOutput, on: bool) -> Vec { + let mut classes: Vec<_> = crate::styled_source_tests::captured_render::active( + ("props-rule-choices.tsx", Some("props-rule-choices.tsx")), + output, + &format!("{{on:{on},width:'24px'}}"), + ) + .iter() + .map(|style| { + style + .extract(Some("props-rule-choices.tsx")) + .unwrap_or_else(|| panic!("selected declaration class identity")) + .to_string() + }) + .collect(); + classes.sort(); + classes +} + +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::LayerOrder(_) + | ExtractStyleValue::Keyframes(_) => None, + }) + .unwrap_or_else(|| panic!("missing {property}: {value}")); + style.extract(Some("props-rule-choices.tsx")).to_string() +} + +fn compile(rules: &str) -> ExtractOutput { + let source = + format!("import {{ styled }} from '@devup-ui/react'; {rules} export {{ Choice }};"); + extract("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(")); + for (on, color) in [(true, "blue"), (false, "red")] { + assert_eq!( + selected_classes(&output, on), + [class_for(&output, "color", color)] + ); + } + 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"]); + for (on, color, background) in [(true, "blue", "black"), (false, "red", "white")] { + let mut expected = [ + class_for(&output, "color", color), + class_for(&output, "background-color", background), + ]; + expected.sort(); + assert_eq!(selected_classes(&output, on), expected); + } + 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"]); + for (on, color) in [(true, "blue"), (false, "red")] { + let mut expected = [ + class_for(&output, "color", color), + class_for(&output, "opacity", ".5"), + ]; + expected.sort(); + assert_eq!(selected_classes(&output, on), expected); + } + 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_eq!( + selected_classes(&output, false), + [class_for(&output, "width", "12px")] + ); + let observed = crate::styled_source_tests::captured_render::execute( + ("props-rule-choices.tsx", Some("props-rule-choices.tsx")), + &output, + "const element=render({target:Choice,props:{on:true,width:'24px'}});JSON.stringify(values(element,'width'));", + ); + assert_eq!(observed, serde_json::json!(["24px"])); +} + +#[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); + for (on, color) in [(true, "blue"), (false, "red")] { + assert_eq!( + selected_classes(&output, on), + [class_for(&output, "color", color)] + ); + } + } +} + +#[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"]); + for on in [true, false] { + assert_eq!( + selected_classes(&output, on), + [class_for(&output, "color", "green")] + ); + } +} + +#[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("unknown-rule.tsx", &source, ExtractOption::default()) else { + panic!("unknowable rules must fail at build time"); + }; + assert!(error.to_string().contains("unknown-rule.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/rule_array_evaluation.rs b/libs/extractor/src/extractor/rule_array_evaluation.rs new file mode 100644 index 000000000..5badabd41 --- /dev/null +++ b/libs/extractor/src/extractor/rule_array_evaluation.rs @@ -0,0 +1,182 @@ +//! Capture authored evaluations before rule-array composition duplicates controllers. + +use oxc_allocator::{CloneIn, GetAllocator}; +use oxc_ast::ast::{Expression, ObjectPropertyKind, PropertyKind}; +use oxc_span::SPAN; +use oxc_syntax::operator::{BinaryOperator, LogicalOperator}; + +use super::styled_record_evaluation::Evaluations; +use crate::utils::{ + Suspends, get_string_by_literal_expression, unwrap_syntax_only, unwrap_syntax_only_mut, +}; + +/// Arrays here compose rules, rather than selecting responsive property values. +pub(crate) fn composes_array_under_condition(expression: &Expression<'_>) -> bool { + let sides = match unwrap_syntax_only(expression) { + Expression::ArrayExpression(array) => { + return array.elements.iter().any(|element| { + element + .as_expression() + .is_some_and(composes_array_under_condition) + }); + } + Expression::ConditionalExpression(choice) => [&choice.consequent, &choice.alternate], + Expression::LogicalExpression(logical) => [&logical.left, &logical.right], + _ => return false, + }; + sides.into_iter().any(|side| { + matches!(unwrap_syntax_only(side), Expression::ArrayExpression(_)) + || composes_array_under_condition(side) + }) +} + +#[cfg(test)] +#[path = "rule_array_evaluation_tests.rs"] +mod tests; + +/// The caller first checks the original composition, so captures cannot turn a +/// refused call, spread or controller into an accepted identifier. +pub(crate) fn prepare<'a>( + reads: &mut Evaluations<'_, 'a>, + source: &Expression<'a>, + known: impl Fn(&Expression<'a>) -> bool, +) -> Option> { + let mut suspends = Suspends::default(); + oxc_ast_visit::Visit::visit_expression(&mut suspends, source); + if suspends.found { + return None; + } + let mut result = source.clone_in_with_semantic_ids(reads.builder.allocator()); + let guard = reads.boolean(true); + Preparation { reads, known }.value(&mut result, &guard)?; + Some(result) +} + +struct Preparation<'r, 'b, 'a, K> { + reads: &'r mut Evaluations<'b, 'a>, + known: K, +} + +impl<'a, K: Fn(&Expression<'a>) -> bool> Preparation<'_, '_, 'a, K> { + fn capture(&mut self, value: &mut Expression<'a>, guard: &Expression<'a>) { + if get_string_by_literal_expression(value).is_none() + && !matches!(unwrap_syntax_only(value), Expression::NullLiteral(_)) + && !matches!(unwrap_syntax_only(value), Expression::Identifier(identifier) + if matches!(identifier.name.as_str(), "undefined" | "NaN" | "Infinity")) + && !(self.known)(unwrap_syntax_only(value)) + { + *value = self.reads.capture(value, guard); + } + } + + fn value(&mut self, value: &mut Expression<'a>, guard: &Expression<'a>) -> Option<()> { + if (self.known)(unwrap_syntax_only(value)) + || get_string_by_literal_expression(value).is_some() + { + return Some(()); + } + let builder = self.reads.builder; + match unwrap_syntax_only_mut(value) { + Expression::ConditionalExpression(choice) => { + self.capture(&mut choice.test, guard); + let yes = self + .reads + .choose(guard, &choice.test, &self.reads.boolean(false)); + let no = self.reads.choose( + guard, + &self.reads.choose( + &choice.test, + &self.reads.boolean(false), + &self.reads.boolean(true), + ), + &self.reads.boolean(false), + ); + self.value(&mut choice.consequent, &yes)?; + self.value(&mut choice.alternate, &no)?; + } + Expression::LogicalExpression(logical) => { + // A rule object/array is truthy, but evaluating its authored + // leaves still precedes everything after this part. + let takes_right = if matches!( + unwrap_syntax_only(&logical.left), + Expression::ObjectExpression(_) | Expression::ArrayExpression(_) + ) { + self.value(&mut logical.left, guard)?; + self.reads.boolean(logical.operator == LogicalOperator::And) + } else { + self.capture(&mut logical.left, guard); + match logical.operator { + LogicalOperator::And => logical.left.clone_in(builder.allocator()), + LogicalOperator::Or => self.reads.choose( + &logical.left, + &self.reads.boolean(false), + &self.reads.boolean(true), + ), + LogicalOperator::Coalesce => Expression::new_binary_expression( + SPAN, + logical.left.clone_in(builder.allocator()), + BinaryOperator::Equality, + Expression::new_null_literal(SPAN, builder), + builder, + ), + } + }; + let right = self + .reads + .choose(guard, &takes_right, &self.reads.boolean(false)); + self.value(&mut logical.right, &right)?; + } + Expression::ArrayExpression(array) => { + for element in &mut array.elements { + match element { + oxc_ast::ast::ArrayExpressionElement::SpreadElement(spread) => { + self.value(&mut spread.argument, guard)?; + } + element => { + if let Some(value) = element.as_expression_mut() { + self.value(value, guard)?; + } + } + } + } + } + Expression::ObjectExpression(object) => { + for property in &mut object.properties { + match property { + ObjectPropertyKind::ObjectProperty(property) => { + if property.method || property.kind != PropertyKind::Init { + return None; + } + // Keep authored keys and literal lookup tables intact: + // the extractor still owns their policy and atom choices. + self.value(&mut property.value, guard)?; + property.shorthand = false; + } + ObjectPropertyKind::SpreadProperty(spread) => { + self.value(&mut spread.argument, guard)?; + } + } + } + } + Expression::ComputedMemberExpression(member) + if matches!( + unwrap_syntax_only(&member.object), + Expression::ObjectExpression(_) | Expression::ArrayExpression(_) + ) => + { + self.value(&mut member.object, guard)?; + self.capture(&mut member.expression, guard); + } + Expression::NullLiteral(_) + | Expression::BigIntLiteral(_) + | Expression::RegExpLiteral(_) + | Expression::FunctionExpression(_) + | Expression::ArrowFunctionExpression(_) + | Expression::ClassExpression(_) => {} + Expression::Identifier(identifier) + if matches!(identifier.name.as_str(), "undefined" | "NaN" | "Infinity") => {} + _ => *value = self.reads.capture(value, guard), + } + Some(()) + } +} diff --git a/libs/extractor/src/extractor/rule_array_evaluation_tests.rs b/libs/extractor/src/extractor/rule_array_evaluation_tests.rs new file mode 100644 index 000000000..c33ac3e3f --- /dev/null +++ b/libs/extractor/src/extractor/rule_array_evaluation_tests.rs @@ -0,0 +1,124 @@ +use boa_engine::{Context, Source}; +use oxc_allocator::Allocator; +use oxc_ast::builder::AstBuilder; +use oxc_parser::Parser; +use oxc_span::SourceType; +use rstest::rstest; + +use super::super::styled_record_evaluation::{Evaluations, bind_render}; +use crate::utils::readable_code; + +#[rstest] +#[case::object_and( + "({x:read('left',1)}) && [{x:read('right',2)}]", + r#"[[{"x":2}],["left","right"]]"# +)] +#[case::array_or( + "[{x:read('left',1)}] || [{x:read('right',2)}]", + r#"[[{"x":1}],["left"]]"# +)] +#[case::object_coalesce( + "({x:read('left',1)}) ?? [{x:read('right',2)}]", + r#"[{"x":1},["left"]]"# +)] +#[case::null_coalesce( + "read('left',null) ?? [{x:read('right',2)}]", + r#"[[{"x":2}],["left","right"]]"# +)] +#[case::zero_coalesce("read('left',0) ?? [{x:read('right',2)}]", r#"[0,["left"]]"#)] +#[case::false_coalesce("read('left',false) ?? [{x:read('right',2)}]", r#"[false,["left"]]"#)] +#[case::spread( + "[...[{x:read('spread',3)}],{x:read('tail',4)}]", + r#"[[{"x":3},{"x":4}],["spread","tail"]]"# +)] +fn repair_745_rule_array_captures_preserve_evaluation( + #[case] source: &str, + #[case] expected: &str, +) { + // Given: authored evaluations with observable order and short circuiting. + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let expression = Parser::new(&allocator, source, SourceType::mjs()) + .parse_expression() + .unwrap_or_else(|error| panic!("{error:?}")); + let mut reads = Evaluations::new(&builder, source.to_string()); + + // When: execute the prepared expression through the production binder. + let prepared = super::prepare(&mut reads, &expression, |_| false) + .unwrap_or_else(|| panic!("capturable fixture")); + let render = bind_render(&builder, reads.bindings, prepared); + let script = format!( + "const trace=[];const read=(name,value)=>(trace.push(name),value);JSON.stringify([{},trace]);", + readable_code(&render) + ); + let observed = Context::default() + .eval(Source::from_bytes(script.as_bytes())) + .unwrap_or_else(|error| panic!("{error}")) + .as_string() + .unwrap_or_else(|| panic!("JSON observation")) + .to_std_string_escaped(); + + // Then: the chosen value and each once-only evaluation retain authored order. + assert_eq!(observed, expected); +} + +#[rstest] +#[case::await_value("[{color:await tone}]")] +#[case::yield_value("[{color:yield tone}]")] +#[case::getter("[{get color(){return 'red'}}]")] +#[case::setter("[{set color(value){}}]")] +#[case::method("[{color(){return 'red'}}]")] +fn repair_745_rule_array_refuses_unpreparable_values(#[case] source: &str) { + // Given: a parsed suspension or non-data property that cannot be captured. + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let source = if source.contains("yield") { + format!("function*(){{return {source};}}") + } else { + source.to_string() + }; + let expression = Parser::new(&allocator, &source, SourceType::mjs()) + .parse_expression() + .unwrap_or_else(|error| panic!("{error:?}")); + let expression = match &expression { + oxc_ast::ast::Expression::FunctionExpression(function) => crate::css_prop::returned( + function + .body + .as_deref() + .unwrap_or_else(|| panic!("generator body")), + ) + .unwrap_or_else(|| panic!("returned fixture")), + expression => expression, + }; + let mut reads = Evaluations::new(&builder, source.clone()); + + // When: attempt preparation at the capture boundary. + let prepared = super::prepare(&mut reads, expression, |_| false); + + // Then: the original refusal remains explicit, rather than capturing invalid syntax. + assert!(prepared.is_none()); +} + +#[rstest] +#[case("read() ? [] : []")] +#[case("(read() ? [] : []) || (other && [])")] +fn repair_745_refused_controllers_keep_authored_spans(#[case] source: &str) { + // Given: impure controllers nested on either side of a logical composition. + let allocator = Allocator::default(); + let expression = Parser::new(&allocator, source, SourceType::mjs()) + .parse_expression() + .unwrap_or_else(|error| panic!("{error:?}")); + let mut refused = Vec::new(); + + // When: collect controller refusals before any rewriting. + super::super::styled_rule_arrays::refused_controllers(&expression, &mut refused); + + // Then: the call-controlled choice remains refused at its authored start. + let start = u32::try_from( + source + .find("read()") + .unwrap_or_else(|| panic!("controller fixture")), + ) + .unwrap_or_else(|error| panic!("{error}")); + assert!(refused.contains(&start), "{refused:?}"); +} diff --git a/libs/extractor/src/extractor/style_order_nullish_tests.rs b/libs/extractor/src/extractor/style_order_nullish_tests.rs new file mode 100644 index 000000000..d76df512b --- /dev/null +++ b/libs/extractor/src/extractor/style_order_nullish_tests.rs @@ -0,0 +1,69 @@ +use super::*; + +#[rstest::rstest] +#[case::null("null", "red")] +#[case::undefined("undefined", "red")] +#[case::value("'blue'", "blue")] +#[serial_test::serial] +fn authored_branches_when_nullish_choose_the_matching_color( + #[case] tone: &str, + #[case] expected: &str, +) { + // Given: nullish extraction visits the fallback before the authored left operand. + let arena = oxc_allocator::Allocator::default(); + let ast = AstBuilder::new(&arena); + let mut expression = + oxc_parser::Parser::new(&arena, "tone??'red'", oxc_span::SourceType::mjs()) + .parse_expression() + .unwrap_or_else(|error| panic!("{error:?}")); + // When: authored projection reconstructs the actual condition and its two sides. + let (_, authored) = extract_with_order( + &ast, + Some("color"), + &mut expression, + 0, + &None, + LiteralHandling::ExpandResponsiveThemeToken, + ); + let [ + ExtractStyleProp::Conditional { + condition, + consequent, + alternate, + }, + ] = authored.0.as_slice() + else { + panic!("expected projected nullish condition") + }; + let code = format!( + "const tone={tone};{}", + crate::utils::expression_to_code(condition) + ); + let selected = boa_engine::Context::default() + .eval(boa_engine::Source::from_bytes(&code)) + .unwrap_or_else(|error| panic!("{error}")) + .as_boolean() + .unwrap_or_else(|| panic!("condition must be boolean")); + let side = if selected { consequent } else { alternate }; + let values: Vec<_> = side + .as_ref() + .unwrap_or_else(|| panic!("selected side must exist")) + .extract() + .into_iter() + .map(|value| match value { + ExtractStyleValue::Static(style) => style.value, + ExtractStyleValue::Dynamic(style) => { + let code = format!("const tone={tone};{}", style.identifier()); + boa_engine::Context::default() + .eval(boa_engine::Source::from_bytes(&code)) + .unwrap_or_else(|error| panic!("{error}")) + .as_string() + .unwrap_or_else(|| panic!("color must be a string")) + .to_std_string_escaped() + } + _ => panic!("expected color atom"), + }) + .collect(); + // Then: fallback and dynamic values are never attached to each other's condition side. + assert_eq!(values, vec![expected]); +} diff --git a/libs/extractor/src/extractor/style_order_projection.rs b/libs/extractor/src/extractor/style_order_projection.rs new file mode 100644 index 000000000..8a691e96c --- /dev/null +++ b/libs/extractor/src/extractor/style_order_projection.rs @@ -0,0 +1,241 @@ +//! Authored chunks retained during extraction, independently of legacy emission order. +use super::{ + ExtractResult, + extract_style_from_expression::{self, LiteralHandling}, +}; +use crate::{ExtractStyleProp, composition::Composition}; +use css::style_selector::StyleSelector; +use oxc_allocator::{CloneIn, GetAllocator}; +use oxc_ast::{ast::Expression, builder::AstBuilder}; + +pub(crate) struct AuthoredStyles<'a>(pub(crate) Vec>); + +impl<'a> AuthoredStyles<'a> { + /// Capture authored first use before a fold can erase its first declaration. + pub(crate) fn registered(mut self, file: &str) -> Self { + let mut collector = crate::style_collector::StyleCollector::default(); + collector.use_layers(&self.0); + let registrations = collector.into_styles(file); + self.0.splice( + 0..0, + registrations.into_iter().map(ExtractStyleProp::Static), + ); + self + } + + pub(crate) fn clone_in<'b>( + &self, + allocator: &'b oxc_allocator::Allocator, + ) -> AuthoredStyles<'b> { + AuthoredStyles(self.0.iter().map(|prop| prop.clone_in(allocator)).collect()) + } + + pub(crate) fn normalized(self, ast: &AstBuilder<'a>) -> Self { + let mut composition = Composition::default(); + self.apply(ast, &mut composition); + let mut styles = composition.into_props(); + extract_style_from_expression::yield_typography(&mut styles); + Self(styles) + } + + pub(crate) fn apply(self, ast: &AstBuilder<'a>, composition: &mut Composition<'a>) { + for prop in self.0 { + match prop { + ExtractStyleProp::StaticArray(props) => Self(props).apply(ast, composition), + ExtractStyleProp::Conditional { + condition, + consequent, + alternate, + } => { + let normalize = |side: Option>>| { + Self(side.into_iter().map(|prop| *prop).collect()) + .normalized(ast) + .0 + }; + composition.apply_conditional( + ast, + &condition, + normalize(consequent), + normalize(alternate), + ); + } + prop => composition.apply(ast, vec![prop]), + } + } + } +} + +#[derive(Default)] +pub(super) struct Projection<'a> { + children: Option>>, + root_chunks: Option>>, + branches: bool, + swapped: bool, +} + +impl<'a> Projection<'a> { + pub(super) fn enabled() -> Self { + Self { + children: Some(Vec::new()), + ..Self::default() + } + } + + pub(super) fn extract( + &mut self, + ast: &AstBuilder<'a>, + name: Option<&str>, + expression: &mut Expression<'a>, + coordinates: (u8, &Option), + handling: LiteralHandling, + prepare: &mut impl FnMut(&mut Expression<'a>), + ) -> ExtractResult<'a> { + let mut child = if self.children.is_some() { + Self::enabled() + } else { + Self::default() + }; + let result = extract_style_from_expression::extract_projected::( + ast, + name, + expression, + coordinates, + handling, + &mut child, + prepare, + ); + if let Some(children) = &mut self.children { + children.push(child.finish(ast, &result.styles)); + } + result + } + + pub(super) fn root(&mut self) { + if self.children.is_some() { + self.root_chunks = Some(Vec::new()); + } + } + + pub(super) fn end_property(&mut self) { + if let (Some(children), Some(chunks)) = (&mut self.children, &mut self.root_chunks) { + chunks.push(AuthoredStyles( + children.drain(..).flat_map(|chunk| chunk.0).collect(), + )); + } + } + + pub(super) const fn conditional(&mut self, swapped: bool) { + self.branches = true; + self.swapped = swapped; + } + + pub(super) fn record(&mut self, ast: &AstBuilder<'a>, prop: &ExtractStyleProp<'a>) { + if let Some(children) = &mut self.children { + children.push(AuthoredStyles(vec![prop.clone_in(ast.allocator())])); + } + } + + pub(super) fn layer(&mut self, layer: &str) { + if let Some(child) = self + .children + .as_mut() + .and_then(|children| children.last_mut()) + { + extract_style_from_expression::place_in_layer(&mut child.0, layer); + } + } + + pub(super) fn finish( + self, + ast: &AstBuilder<'a>, + raw: &[ExtractStyleProp<'a>], + ) -> AuthoredStyles<'a> { + if let Some(chunks) = self.root_chunks { + return AuthoredStyles(chunks.into_iter().rev().flat_map(|chunk| chunk.0).collect()); + } + let children = self.children.unwrap_or_default(); + if children.is_empty() { + return AuthoredStyles( + raw.iter() + .map(|prop| prop.clone_in(ast.allocator())) + .collect(), + ); + } + if self.branches + && let [ + ExtractStyleProp::Conditional { + condition, + consequent, + alternate, + }, + ] = raw + { + let mut children = children.into_iter(); + let mut side = || { + children + .next() + .map(|child| Box::new(ExtractStyleProp::StaticArray(child.0))) + }; + let (yes, no) = if self.swapped { + let no = side(); + (side(), no) + } else { + let yes = consequent.as_ref().and_then(|_| side()); + (yes, alternate.as_ref().and_then(|_| side())) + }; + return AuthoredStyles(vec![ExtractStyleProp::Conditional { + condition: condition.clone_in(ast.allocator()), + consequent: yes, + alternate: no, + }]); + } + AuthoredStyles(children.into_iter().flat_map(|child| child.0).collect()) + } +} + +pub(crate) fn extract_with_order<'a>( + ast: &AstBuilder<'a>, + name: Option<&str>, + expression: &mut Expression<'a>, + level: u8, + selector: &Option, + handling: LiteralHandling, +) -> (ExtractResult<'a>, AuthoredStyles<'a>) { + extract_with_order_policy::( + ast, + name, + expression, + level, + selector, + handling, + &mut |_| {}, + ) +} + +/// Retain authored chunks with the caller's dynamic-leaf location and preparation policy. +pub(crate) fn extract_with_order_policy<'a, const LOCATE: bool>( + ast: &AstBuilder<'a>, + name: Option<&str>, + expression: &mut Expression<'a>, + level: u8, + selector: &Option, + handling: LiteralHandling, + prepare: &mut impl FnMut(&mut Expression<'a>), +) -> (ExtractResult<'a>, AuthoredStyles<'a>) { + let mut projection = Projection::enabled(); + let result = extract_style_from_expression::extract_projected::( + ast, + name, + expression, + (level, selector), + handling, + &mut projection, + prepare, + ); + let authored = projection.finish(ast, &result.styles); + (result, authored) +} + +#[cfg(test)] +#[path = "style_order_projection_tests.rs"] +mod tests; diff --git a/libs/extractor/src/extractor/style_order_projection_tests.rs b/libs/extractor/src/extractor/style_order_projection_tests.rs new file mode 100644 index 000000000..cf80fe985 --- /dev/null +++ b/libs/extractor/src/extractor/style_order_projection_tests.rs @@ -0,0 +1,239 @@ +use super::*; +use crate::{ + extract_style::extract_style_value::ExtractStyleValue, gen_class_name::gen_class_names, +}; + +#[path = "style_order_nullish_tests.rs"] +mod nullish; + +#[rstest::rstest] +#[case::keep(false, "12px")] +#[case::replace(true, "16px")] +#[serial_test::serial] +fn sequential_chunks_keep_previous_atom_when_later_branch_omits_key( + #[case] on: bool, + #[case] expected: &str, +) -> Result<(), Box> { + // Given: the expression extractor admits a conditional scalar before any capture lowering. + let allocator = oxc_allocator::Allocator::default(); + let ast = AstBuilder::new(&allocator); + let mut expression = oxc_parser::Parser::new( + &allocator, + "({padding:3,p:on?4:null})", + oxc_span::SourceType::tsx(), + ) + .parse_expression() + .map_err(|error| format!("{error:?}"))?; + // When: the same traversal produces legacy emission and sequential authored chunks. + let (_, authored) = extract_with_order( + &ast, + None, + &mut expression, + 0, + &None, + LiteralHandling::ExpandResponsiveThemeToken, + ); + let mut normalized = authored.normalized(&ast).0; + let class = gen_class_names(&ast, &mut normalized, None, None).ok_or("missing class")?; + let code = format!("const on={on};{}", crate::utils::expression_to_code(&class)); + let selected = boa_engine::Context::default() + .eval(boa_engine::Source::from_bytes(&code)) + .map_err(|error| error.to_string())? + .as_string() + .ok_or("class must be a string")? + .to_std_string_escaped(); + // Then: Keep resolves against the earlier alias rather than an empty composition. + let values: Vec<_> = normalized + .iter() + .flat_map(ExtractStyleProp::extract) + .filter_map(|value| { + let class = value.extract(None)?; + match (value, class) { + ( + ExtractStyleValue::Static(style), + crate::extract_style::style_property::StyleProperty::ClassName(name), + ) if selected.split_whitespace().any(|selected| selected == name) => { + Some(style.value().to_string()) + } + _ => None, + } + }) + .collect(); + assert_eq!(values, vec![expected]); + Ok(()) +} + +#[rstest::rstest] +#[case::keep(false, "12px", "20px")] +#[case::replace(true, "16px", "24px")] +#[serial_test::serial] +fn located_dynamic_leaf_keeps_authored_conditional_layer_winners( + #[case] on: bool, + #[case] plain: &str, + #[case] layered: &str, +) -> Result<(), Box> { + // Given: aliases compete independently inside and outside a layer. + let allocator = oxc_allocator::Allocator::default(); + let ast = AstBuilder::new(&allocator); + let mut expression = oxc_parser::Parser::new( + &allocator, + "({padding:3,p:on?4:null,'@layer':{base:{padding:5,p:on?6:null}},color:tone})", + oxc_span::SourceType::tsx(), + ) + .parse_expression() + .map_err(|error| format!("{error:?}"))?; + let mut prepared = 0; + // When: the combined entry locates leaves while retaining authored chunks. + let (result, authored) = extract_with_order_policy::( + &ast, + None, + &mut expression, + 0, + &None, + LiteralHandling::ExpandResponsiveThemeToken, + &mut |_| prepared += 1, + ); + // Then: both views retain the exact leaf location, not its containing object. + assert_eq!(prepared, 1); + for styles in [&result.styles, &authored.0] { + let mut unreadable = Vec::new(); + crate::utils::unreadable_styles(styles, false, &mut unreadable); + assert_eq!(unreadable, vec![(70, "tone".to_string(), None)]); + } + let mut normalized = authored.normalized(&ast).0; + let class = gen_class_names(&ast, &mut normalized, None, None).ok_or("missing class")?; + let code = format!("const on={on};{}", crate::utils::expression_to_code(&class)); + let selected = boa_engine::Context::default() + .eval(boa_engine::Source::from_bytes(&code)) + .map_err(|error| error.to_string())? + .as_string() + .ok_or("class must be a string")? + .to_std_string_escaped(); + let mut winners: Vec<_> = normalized + .iter() + .flat_map(ExtractStyleProp::extract) + .filter_map(|value| { + let class = value.extract(None)?; + match (value, class) { + ( + ExtractStyleValue::Static(style), + crate::extract_style::style_property::StyleProperty::ClassName(name), + ) if selected.split_whitespace().any(|selected| selected == name) => { + Some((style.layer, style.property, style.value)) + } + _ => None, + } + }) + .collect(); + winners.sort(); + assert_eq!( + winners, + vec![ + (None, "padding".to_string(), plain.to_string()), + ( + Some("base".to_string()), + "padding".to_string(), + layered.to_string(), + ), + ] + ); + Ok(()) +} + +#[rstest::rstest] +#[case::object("({value:motion})['value']")] +#[case::array("[motion][0]")] +#[serial_test::serial] +fn preparation_reaches_nested_shorthand_member_siblings_with_original_binding( + #[case] member: &str, +) -> Result<(), Box> { + // Given: semantic references survive the clones used for shorthand siblings. + let allocator = oxc_allocator::Allocator::default(); + let ast = AstBuilder::new(&allocator); + let source = format!("const motion = null; ({{_hover:{{mx:{member}}}}});"); + let mut parsed = + oxc_parser::Parser::new(&allocator, &source, oxc_span::SourceType::tsx()).parse(); + assert_eq!(parsed.diagnostics.len(), 0); + let scoping = std::rc::Rc::new( + oxc_semantic::SemanticBuilder::new() + .build(&parsed.program) + .semantic + .into_scoping(), + ); + let mut values = crate::style_values::StyleValues::new(std::rc::Rc::clone(&scoping)); + let symbol = values + .root_symbol("motion") + .ok_or("missing motion binding")?; + let reference = *scoping + .get_resolved_reference_ids(symbol) + .first() + .ok_or("missing motion reference")?; + values.insert( + symbol, + crate::style_values::StyleValue::Keyframes("pulse".to_string()), + ); + let oxc_ast::ast::Statement::ExpressionStatement(statement) = + parsed.program.body.last_mut().ok_or("missing rules")? + else { + return Err("expected rules expression".into()); + }; + let mut prepared = Vec::new(); + // When: real binding normalization runs at the combined seam's prepare hook. + let (_, authored) = extract_with_order_policy::( + &ast, + None, + &mut statement.expression, + 0, + &None, + LiteralHandling::ExpandResponsiveThemeToken, + &mut |value| { + let reference = match value { + Expression::Identifier(identifier) => identifier.reference_id.get(), + _ => None, + }; + prepared.push((values.symbol(value), reference)); + values.read_in(&ast, value); + }, + ); + // Then: each sibling sees the original binding before its value is serialized. + assert_eq!(prepared, vec![(Some(symbol), Some(reference)); 2]); + let mut observed = Vec::new(); + for value in authored + .normalized(&ast) + .0 + .iter() + .flat_map(ExtractStyleProp::extract) + { + let ExtractStyleValue::Dynamic(style) = value else { + return Err("expected prepared dynamic sibling".into()); + }; + let value = boa_engine::Context::default() + .eval(boa_engine::Source::from_bytes(style.identifier())) + .map_err(|error| error.to_string())? + .as_string() + .ok_or("prepared value must be a string")? + .to_std_string_escaped(); + observed.push(( + style.property().to_string(), + style.selector().map(ToString::to_string), + value, + )); + } + observed.sort(); + assert_eq!( + observed, + vec![ + ( + "margin-left".to_string(), + Some("&:hover".to_string()), + "pulse".to_string(), + ), + ( + "margin-right".to_string(), + Some("&:hover".to_string()), + "pulse".to_string(), + ), + ] + ); + Ok(()) +} diff --git a/libs/extractor/src/extractor/styled_arguments.rs b/libs/extractor/src/extractor/styled_arguments.rs new file mode 100644 index 000000000..167a52b18 --- /dev/null +++ b/libs/extractor/src/extractor/styled_arguments.rs @@ -0,0 +1,246 @@ +//! Admission precedes one authored-order schedule shared by callbacks and rule arrays. + +use oxc_allocator::{CloneIn, GetAllocator}; +use oxc_ast::{ + ast::{Argument, CallExpression, Expression}, + builder::AstBuilder, +}; +use oxc_span::SPAN; + +use super::{ExtractResult, StyledBindings, props, records, rule_parts}; +use crate::{ + extractor::{ + rule_array_evaluation, styled_record_evaluation::Evaluations, + styled_rule_arrays::distribute_conditions, + }, + styled_reads::Reads, + utils::{StyleArguments, readable_code, reads_directly, style_arguments, uncomposable_error}, +}; + +pub(super) struct Context<'s, 'a> { + pub filename: &'s str, + pub scope: &'s mut Expression<'a>, + pub reads: &'s mut Reads, + pub refused: &'s [u32], +} + +pub(super) struct Prepared<'a> { + pub rules: Option>, + pub classes: Vec>, + pub bindings: Vec<(String, Expression<'a>)>, + pub errors: Vec<(u32, String)>, +} + +fn distribute_arguments<'a>( + builder: &AstBuilder<'a>, + arguments: &mut [Argument<'a>], + refused: &[u32], +) { + for argument in arguments { + if let Some(part) = argument.as_expression_mut() { + distribute_conditions(builder, part, refused); + } + } +} + +#[cfg(test)] +pub(super) fn prepare<'a>( + builder: &AstBuilder<'a>, + call: &mut CallExpression<'a>, + context: Context<'_, 'a>, +) -> Prepared<'a> { + prepare_with_bindings(builder, call, (context, None)) +} + +pub(super) fn prepare_with_bindings<'a>( + builder: &AstBuilder<'a>, + call: &mut CallExpression<'a>, + (context, bindings): (Context<'_, 'a>, Option<&StyledBindings<'_>>), +) -> Prepared<'a> { + let Context { + filename, + scope, + reads, + refused, + } = context; + let arguments = &mut call.arguments; + let mut result = Prepared { + rules: None, + classes: Vec::new(), + bindings: Vec::new(), + errors: Vec::new(), + }; + let mut evaluations = Evaluations::new(builder, readable_code(scope)); + let mut plans = Vec::with_capacity(arguments.len()); + for argument in &mut *arguments { + let plan = if let Some(rules) = argument.as_expression_mut() { + reads.read_in(rules); + match props::lower(&mut evaluations, rules, bindings) { + Ok(plan) => plan, + Err(error) => { + result.errors.push(error); + *rules = Expression::new_object_expression( + SPAN, + oxc_allocator::Vec::new_in(builder), + builder, + ); + None + } + } + } else { + None + }; + plans.push(plan); + } + let mut distributed = arguments.clone_in_with_semantic_ids(builder.allocator()); + distribute_arguments(builder, &mut distributed, refused); + let multipart = distributed.len() > 1 + || distributed.iter().any(|argument| { + let expression = match argument { + Argument::SpreadElement(spread) => &spread.argument, + argument => argument.to_expression(), + }; + matches!( + crate::utils::unwrap_syntax_only(expression), + Expression::ArrayExpression(_) + ) + }); + let admitted = reads_directly(&distributed) + || style_arguments(builder, &distributed).is_some() + || rule_parts::admitted(builder, &distributed, bindings); + let callbacks = plans.iter().any(Option::is_some); + let capture = callbacks + || multipart + || arguments.iter().any(|argument| { + argument + .as_expression() + .is_some_and(rule_array_evaluation::composes_array_under_condition) + }); + let mut scheduled_parts = true; + if admitted && capture { + let mut prepared = arguments.clone_in_with_semantic_ids(builder.allocator()); + let mut complete = true; + for (argument, plan) in prepared.iter_mut().zip(plans) { + if let Some(props::Plan::Properties(original)) = plan { + if let Some(rules) = + super::super::styled_record::prepare_callbacks(&mut evaluations, &original) + { + *argument = Argument::from(rules); + } else { + scheduled_parts = false; + } + } else if let Some(props::Plan::Whole(plan)) = plan { + evaluations.bindings.push((plan.name, plan.value)); + if !matches!( + argument.as_expression(), + Some(Expression::ObjectExpression(_)) + ) && let Some(rules) = argument.as_expression_mut() + && let Some(prepared) = + rule_array_evaluation::prepare(&mut evaluations, rules, |value| { + bindings.is_some_and(|bindings| bindings.styles(value).is_some()) + }) + { + *rules = prepared; + } + } else { + let value = match &*argument { + Argument::SpreadElement(spread) => &spread.argument, + argument => argument.to_expression(), + }; + let start = evaluations.bindings.len(); + if let Some(value_read) = + rule_array_evaluation::prepare(&mut evaluations, value, |value| { + bindings.is_some_and(|bindings| bindings.styles(value).is_some()) + }) + { + if callbacks && let Argument::SpreadElement(spread) = argument { + spread.argument = value_read; + } else { + *argument = Argument::from(value_read); + } + } else { + evaluations.bindings.truncate(start); + scheduled_parts = false; + if !callbacks { + complete = false; + break; + } + } + } + } + if complete { + *arguments = prepared; + records::reserve(builder, scope, &evaluations.bindings); + result.bindings = evaluations.bindings; + } + } + distribute_arguments(builder, arguments, refused); + let known_rules = bindings.is_some_and(|bindings| { + arguments.iter().any(|argument| { + argument + .as_expression() + .is_some_and(|rules| rule_parts::contains_known_rules(rules, bindings)) + }) + }); + match (!known_rules && !multipart) + .then(|| style_arguments(builder, arguments)) + .flatten() + { + Some(StyleArguments { classes, rules }) => { + *arguments = oxc_allocator::Vec::from_array_in([Argument::from(rules)], builder); + result.classes = classes; + } + None if (known_rules || !reads_directly(arguments)) + && rule_parts::admitted(builder, arguments, bindings) => + { + for argument in &mut *arguments { + if (!multipart || scheduled_parts) + && let Some(rules) = argument.as_expression_mut() + { + records::prepare(builder, rules, (scope, &mut result.bindings)); + } + } + if let Some((rules, classes)) = + rule_parts::compose_bound(builder, arguments, (bindings, filename)) + { + result.rules = Some(rules); + result.classes = classes; + let carrier = multipart + .then(|| style_arguments(builder, arguments)) + .flatten(); + *arguments = oxc_allocator::Vec::from_array_in( + [Argument::from(carrier.map_or_else( + || { + Expression::new_object_expression( + SPAN, + oxc_allocator::Vec::new_in(builder), + builder, + ) + }, + |carrier| carrier.rules, + ))], + builder, + ); + } + } + None if !reads_directly(arguments) => { + result + .errors + .push((call.span.start, uncomposable_error(arguments))); + *arguments = oxc_allocator::Vec::from_array_in( + [Argument::from(Expression::new_object_expression( + SPAN, + oxc_allocator::Vec::new_in(builder), + builder, + ))], + builder, + ); + } + None => {} + } + result +} + +#[cfg(test)] +#[path = "styled_arguments_tests.rs"] +mod tests; diff --git a/libs/extractor/src/extractor/styled_arguments_tests.rs b/libs/extractor/src/extractor/styled_arguments_tests.rs new file mode 100644 index 000000000..f4573538d --- /dev/null +++ b/libs/extractor/src/extractor/styled_arguments_tests.rs @@ -0,0 +1,185 @@ +use boa_engine::{Context as BoaContext, Source}; +use oxc_allocator::Allocator; +use oxc_parser::Parser; +use oxc_span::SourceType; +use rstest::rstest; + +use super::*; +use crate::extractor::styled_record_evaluation::bind_render; + +#[test] +fn callback_preparation_when_a_responsive_spread_refuses_capture_keeps_the_authored_schedule() { + // Given: the callback can be lowered, but its enclosing record cannot be captured. + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let source = "styled({color:p=>p.color,margin:[...values]})"; + let mut scope = Parser::new(&allocator, source, SourceType::mjs()) + .parse_expression() + .unwrap_or_else(|error| panic!("{error:?}")); + let mut expression = scope.clone_in(&allocator); + let Expression::CallExpression(call) = &mut expression else { + panic!("expected styled call") + }; + let mut reads = Reads::default(); + // When: argument preparation attempts and refuses the complete callback record. + let prepared = prepare( + &builder, + call, + Context { + filename: "arguments.tsx", + scope: &mut scope, + reads: &mut reads, + refused: &[], + }, + ); + // Then: no partial binding escapes; the callback runs once and the responsive spread remains intact. + assert_eq!(prepared.errors, vec![]); + assert_eq!(prepared.bindings.len(), 0); + let code = readable_code(call.arguments[0].to_expression()); + let script = format!( + "const trace=[];const values=[1,2];const rest={{get color(){{trace.push('color');return 'red'}}}};const result={code};JSON.stringify([result.color,result.margin,trace]);" + ); + let actual = BoaContext::default() + .eval(Source::from_bytes(&script)) + .unwrap_or_else(|error| panic!("{error}")) + .as_string() + .unwrap_or_else(|| panic!("expected JSON observation")) + .to_std_string_escaped(); + assert_eq!( + serde_json::from_str::(&actual) + .unwrap_or_else(|error| panic!("{error}")), + serde_json::json!(["red", [1, 2], ["color"]]) + ); +} + +#[rstest] +#[case::callback_spread( + "styled(...[{color:read('spread','red')}],p=>({margin:p.m}))", + r#"[{"color":"red","margin":"2px"},["spread","props"]]"# +)] +#[case::array_spread( + "styled(...[{color:read('spread','red')}],on?[{margin:read('margin',2)}]:[])", + r#"[{"color":"red","margin":2},["spread","margin"]]"# +)] +fn repair_745_styled_arguments_keep_spread_capture_order( + #[case] source: &str, + #[case] expected: &str, +) { + // Given: spread rules preceding a callback or a conditional rule array. + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let mut scope = Parser::new(&allocator, source, SourceType::mjs()) + .parse_expression() + .unwrap_or_else(|error| panic!("{error:?}")); + let mut expression = scope.clone_in(&allocator); + assert!(expression.is_call_expression()); + if let Expression::CallExpression(call) = &mut expression { + let mut reads = Reads::default(); + + // When: prepare real styled arguments and execute their ordered bindings. + let prepared = prepare( + &builder, + call, + Context { + filename: "arguments.tsx", + scope: &mut scope, + reads: &mut reads, + refused: &[], + }, + ); + assert_eq!(prepared.errors, vec![]); + let rules = call.arguments[0].to_expression().clone_in(&allocator); + let render = bind_render(&builder, prepared.bindings, rules); + let script = format!( + "const trace=[];const on=true;const rest={{get m(){{trace.push('props');return 2}}}};const read=(name,value)=>(trace.push(name),value);JSON.stringify([{},trace]);", + readable_code(&render) + ); + let observed = BoaContext::default() + .eval(Source::from_bytes(script.as_bytes())) + .unwrap_or_else(|error| panic!("{error}")) + .as_string() + .unwrap_or_else(|| panic!("JSON observation")) + .to_std_string_escaped(); + + // Then: spread reads precede later argument reads, each once, with the same rules. + assert_eq!(observed, expected); + } +} + +#[rstest] +#[case::callback("styled({color:await tone},p=>({margin:p.m}))", 1)] +#[case::array("styled(on?[{color:await tone}]:[],{margin:1})", 0)] +fn repair_745_styled_arguments_keep_suspending_fallback( + #[case] source: &str, + #[case] captures: usize, +) { + // Given: a suspending sibling that preparation must leave at its original seam. + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let mut scope = Parser::new(&allocator, source, SourceType::mjs()) + .parse_expression() + .unwrap_or_else(|error| panic!("{error:?}")); + let mut expression = scope.clone_in(&allocator); + assert!(expression.is_call_expression()); + if let Expression::CallExpression(call) = &mut expression { + let mut reads = Reads::default(); + + // When: prepare the admitted call without hoisting the suspension. + let prepared = prepare( + &builder, + call, + Context { + filename: "arguments.tsx", + scope: &mut scope, + reads: &mut reads, + refused: &[], + }, + ); + + // Then: successful callback captures survive, but the awaiting value stays authored. + assert_eq!(prepared.errors, vec![]); + assert_eq!(prepared.bindings.len(), captures); + assert!(readable_code(call.arguments[0].to_expression()).contains("await tone")); + } +} + +#[test] +fn repair_745_nested_callback_keeps_unmapped_theme_error() { + // Given: a nested property callback with its own, different props root. + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let source = "styled(p=>({color:q=>q.theme.colors[key]}))"; + let mut scope = Parser::new(&allocator, source, SourceType::mjs()) + .parse_expression() + .unwrap_or_else(|error| panic!("{error:?}")); + let mut expression = scope.clone_in(&allocator); + assert!(expression.is_call_expression()); + if let Expression::CallExpression(call) = &mut expression { + let mut reads = Reads::default(); + + // When: lower the whole-rule callback through the ordinary argument seam. + let prepared = prepare( + &builder, + call, + Context { + filename: "arguments.tsx", + scope: &mut scope, + reads: &mut reads, + refused: &[], + }, + ); + + // Then: the inner callback's unknown theme access retains its located guard. + assert_eq!(prepared.errors.len(), 1); + assert!(prepared.errors[0].1.contains("a theme read must be a path")); + assert_eq!( + prepared.errors[0].0, + u32::try_from( + source + .find("q=>") + .unwrap_or_else(|| panic!("inner callback fixture")) + ) + .unwrap_or_else(|error| panic!("{error}")) + ); + } +} diff --git a/libs/extractor/src/extractor/styled_capture_statements.rs b/libs/extractor/src/extractor/styled_capture_statements.rs new file mode 100644 index 000000000..7db429794 --- /dev/null +++ b/libs/extractor/src/extractor/styled_capture_statements.rs @@ -0,0 +1,53 @@ +use oxc_allocator::{FromIn, GetAllocator, Vec}; +use oxc_ast::{ + ast::{ + BindingPattern, Expression, Ident, Statement, VariableDeclaration, VariableDeclarationKind, + VariableDeclarator, + }, + builder::AstBuilder, +}; +use oxc_span::SPAN; + +/// Capture ordered values without inferring names for anonymous definitions. +pub(crate) fn capture_statements<'a>( + builder: &AstBuilder<'a>, + bindings: std::vec::Vec<(String, Expression<'a>)>, +) -> Vec<'a, Statement<'a>> { + let mut statements = Vec::with_capacity_in(bindings.len() + 1, builder); + for (name, value) in bindings { + let name = Ident::from_in(name.as_str(), builder.allocator()); + let value = Expression::new_sequence_expression( + SPAN, + Vec::from_array_in( + [ + Expression::new_numeric_literal( + SPAN, + 0.0, + None, + oxc_syntax::number::NumberBase::Decimal, + builder, + ), + value, + ], + builder, + ), + builder, + ); + let declaration = VariableDeclarator::new( + SPAN, + BindingPattern::new_binding_identifier(SPAN, name, builder), + None, + Some(value), + false, + builder, + ); + statements.push(Statement::VariableDeclaration(VariableDeclaration::boxed( + SPAN, + VariableDeclarationKind::Const, + Vec::from_array_in([declaration], builder), + false, + builder, + ))); + } + statements +} 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..5846ddefc --- /dev/null +++ b/libs/extractor/src/extractor/styled_choice_test_support.rs @@ -0,0 +1,122 @@ +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"); + crate::styled_source_tests::captured_render::active(("choice.tsx", None), output, props) +} + +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() + .filter_map(|value| match value { + ExtractStyleValue::Static(style) => { + Some((style.property().to_string(), style.value().to_string())) + } + ExtractStyleValue::LayerOrder(order) => { + assert_ne!(order.layers.len(), 0); + None + } + 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(()) +} + +#[test] +#[serial_test::serial] +fn active_oracle_when_callback_selects_a_rule_executes_the_complete_renderer() { + // Given + let source = "import {styled} from '@devup-ui/react';const Choice=styled.div(p=>p.on?{color:'red'}:{color:'blue'});"; + let output = crate::extract( + "choice.tsx", + source, + crate::ExtractOption { + single_css: true, + ..Default::default() + }, + ) + .unwrap_or_else(|error| panic!("{error}")); + // When + let selected = active(&output, "{on:true}"); + // Then + assert_eq!(declarations(&selected), [("color".into(), "red".into())]); +} + +#[test] +fn declarations_when_layer_metadata_is_present_keeps_only_declarations() { + // Given + let styles = vec![ExtractStyleValue::LayerOrder( + crate::extract_style::extract_layer_order::ExtractLayerOrder { + file: "layers.tsx".into(), + layers: vec!["base".into()], + }, + )]; + // When + let declarations = declarations(&styles); + // Then + assert_eq!(declarations, vec![]); +} diff --git a/libs/extractor/src/extractor/styled_choice_tests.rs b/libs/extractor/src/extractor/styled_choice_tests.rs new file mode 100644 index 000000000..c17139bea --- /dev/null +++ b/libs/extractor/src/extractor/styled_choice_tests.rs @@ -0,0 +1,234 @@ +use super::*; +use rstest::rstest; + +#[test] +fn undefined_when_reference_has_no_semantic_identity_is_opaque() +-> Result<(), Box> { + // Given: raw parser AST cannot distinguish an unbound name from a shadowed one. + let allocator = oxc_allocator::Allocator::default(); + let ast = AstBuilder::new(&allocator); + let expression = oxc_parser::Parser::new(&allocator, "undefined", oxc_span::SourceType::tsx()) + .parse_expression() + .map_err(|error| format!("{error:?}"))?; + let values = crate::style_values::StyleValues::default(); + let inline_css = rustc_hash::FxHashMap::default(); + // When + let result = normalize( + &ast, + &expression, + &StyledBindings { + imports: &|_| None, + values: &values, + inline_css: &inline_css, + }, + ); + // Then + assert!(matches!(result, Err(RuleError::Opaque))); + assert_eq!(crate::utils::readable_code(&expression), "undefined"); + Ok(()) +} + +#[test] +fn undefined_when_semantics_proves_unbound_normalizes_to_empty_rules() +-> Result<(), Box> { + // Given + let allocator = oxc_allocator::Allocator::default(); + let ast = AstBuilder::new(&allocator); + // When + let normalized = normalize_source(&ast, "undefined;")?; + // Then + assert!(matches!(normalized, Expression::NullLiteral(_))); + assert_eq!(crate::utils::readable_code(&normalized), "null"); + Ok(()) +} + +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(std::rc::Rc::new(scoping)); + let inline_css = rustc_hash::FxHashMap::default(); + normalize( + ast, + &statement.expression, + &StyledBindings { + imports: &|_| None, + 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 inline_css = rustc_hash::FxHashMap::default(); + let normalized = normalize( + &ast, + &expression, + &StyledBindings { + imports: &|_| None, + 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/styled_composition_tests.rs b/libs/extractor/src/extractor/styled_composition_tests.rs new file mode 100644 index 000000000..6ae4ebc94 --- /dev/null +++ b/libs/extractor/src/extractor/styled_composition_tests.rs @@ -0,0 +1,114 @@ +use super::super::super::props_rule_choice_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/styled_css_slot.rs b/libs/extractor/src/extractor/styled_css_slot.rs index 6d2746f57..7320a7291 100644 --- a/libs/extractor/src/extractor/styled_css_slot.rs +++ b/libs/extractor/src/extractor/styled_css_slot.rs @@ -20,6 +20,7 @@ use super::styled_jsx::StyledJsx; use crate::utils::unwrap_syntax_only; pub(crate) struct StyledContext<'s> { + pub filename: &'s str, pub jsx: &'s mut Option, pub capability: Option<&'s StyledCapability>, } diff --git a/libs/extractor/src/extractor/styled_direct_rules.rs b/libs/extractor/src/extractor/styled_direct_rules.rs new file mode 100644 index 000000000..53505845b --- /dev/null +++ b/libs/extractor/src/extractor/styled_direct_rules.rs @@ -0,0 +1,100 @@ +use oxc_allocator::{CloneIn, GetAllocator}; +use oxc_ast::{ + ast::{Expression, ObjectExpression}, + builder::AstBuilder, +}; +use oxc_ast_visit::{VisitMut, walk_mut}; +use rustc_hash::FxHashMap; + +use super::{PropsRulesError, branches::Captures, capture}; + +pub(super) struct Direct<'b, 'p, 'a> { + pub builder: &'b AstBuilder<'a>, + pub name: &'p str, + pub scope: Expression<'a>, + pub next: usize, +} + +impl<'a> Captures<'a> for Direct<'_, '_, 'a> { + fn object( + &mut self, + source: &ObjectExpression<'a>, + local: &str, + ) -> Result<(Expression<'a>, Vec>), PropsRulesError> { + let builder = self.builder; + let source = Expression::ObjectExpression(oxc_allocator::Box::new_in( + source.clone_in_with_semantic_ids(builder.allocator()), + builder, + )); + let Some(mut prepared) = + crate::extractor::styled_record::prepare(builder, &source, &self.scope) + else { + let evaluated = source.clone_in_with_semantic_ids(builder.allocator()); + return Ok((source, vec![evaluated])); + }; + super::super::records::reserve(builder, &mut self.scope, &prepared.bindings); + let start = self.next; + self.next += prepared.bindings.len(); + let names = prepared + .bindings + .iter() + .enumerate() + .map(|(index, (name, _))| (name.clone(), start + index)) + .collect(); + let mut slots = Slots { + builder, + vector: self.name, + names, + }; + slots.visit_expression(&mut prepared.rules); + let values = prepared + .bindings + .into_iter() + .enumerate() + .map(|(index, (_, mut value))| { + slots.visit_expression(&mut value); + capture::assign(builder, capture::slot(builder, local, start + index), value) + }) + .collect(); + Ok((prepared.rules, values)) + } + + fn theme(&self, source: &Expression<'a>) -> Result<(), PropsRulesError> { + if crate::css_utils::reads_unmapped_theme(source) { + return Err(PropsRulesError::Theme( + crate::css_utils::unmapped_theme_error("styled", source), + )); + } + Ok(()) + } + + fn discarded_object(&self, source: &Expression<'a>) -> Result, PropsRulesError> { + Ok(source.clone_in_with_semantic_ids(self.builder.allocator())) + } +} + +struct Slots<'b, 'p, 'a> { + builder: &'b AstBuilder<'a>, + vector: &'p str, + names: FxHashMap, +} + +#[cfg(test)] +#[path = "styled_direct_rules_tests.rs"] +mod tests; + +impl<'a> VisitMut<'a> for Slots<'_, '_, 'a> { + fn visit_expression(&mut self, expression: &mut Expression<'a>) { + if let Expression::Identifier(identifier) = expression + && let Some(index) = self.names.get(identifier.name.as_str()) + { + *expression = Expression::ComputedMemberExpression(capture::slot( + self.builder, + self.vector, + *index, + )); + return; + } + walk_mut::walk_expression(self, expression); + } +} diff --git a/libs/extractor/src/extractor/styled_direct_rules_tests.rs b/libs/extractor/src/extractor/styled_direct_rules_tests.rs new file mode 100644 index 000000000..38e12e83e --- /dev/null +++ b/libs/extractor/src/extractor/styled_direct_rules_tests.rs @@ -0,0 +1,61 @@ +use super::*; +use oxc_ast::ast::{ObjectPropertyKind, Statement}; + +#[test] +fn discarded_rules_when_cloned_preserve_the_original_semantic_reference() { + // Given: a real module binding and the object expression that reads it. + let arena = oxc_allocator::Allocator::default(); + let builder = AstBuilder::new(&arena); + let parsed = oxc_parser::Parser::new( + &arena, + "const tone='red'; ({ color: tone });", + oxc_span::SourceType::mjs(), + ) + .parse(); + assert_eq!(parsed.diagnostics.len(), 0); + let scoping = oxc_semantic::SemanticBuilder::new() + .build(&parsed.program) + .semantic + .into_scoping(); + let symbol = scoping + .symbol_ids() + .find(|id| scoping.symbol_name(*id) == "tone") + .unwrap_or_else(|| panic!("expected tone binding")); + let reference = scoping.get_resolved_reference_ids(symbol)[0]; + let Statement::ExpressionStatement(statement) = &parsed.program.body[1] else { + panic!("expected rules") + }; + let direct = Direct { + builder: &builder, + name: "captures", + scope: statement.expression.clone_in_with_semantic_ids(&arena), + next: 1, + }; + // When: the direct-capture contract retains an object for evaluation only. + let Ok(cloned) = direct.discarded_object(&statement.expression) else { + panic!("direct discarded rules must be retained") + }; + // Then: the clone still resolves to the original binding, rather than a new free name. + let Expression::ObjectExpression(object) = crate::utils::unwrap_syntax_only(&cloned) else { + panic!("expected cloned object") + }; + let ObjectPropertyKind::ObjectProperty(property) = &object.properties[0] else { + panic!("expected color property") + }; + let Expression::Identifier(identifier) = &property.value else { + panic!("expected original reference") + }; + assert_eq!(identifier.reference_id.get(), Some(reference)); + assert_eq!(scoping.get_reference(reference).symbol_id(), Some(symbol)); + let script = format!( + "const tone='red';JSON.stringify({});", + crate::utils::readable_code(&cloned) + ); + let observed = boa_engine::Context::default() + .eval(boa_engine::Source::from_bytes(&script)) + .unwrap_or_else(|error| panic!("{error}")) + .as_string() + .unwrap_or_else(|| panic!("expected JSON observation")) + .to_std_string_escaped(); + assert_eq!(observed, r#"{"color":"red"}"#); +} diff --git a/libs/extractor/src/extractor/styled_extraction_edge_tests.rs b/libs/extractor/src/extractor/styled_extraction_edge_tests.rs new file mode 100644 index 000000000..2e4ec454a --- /dev/null +++ b/libs/extractor/src/extractor/styled_extraction_edge_tests.rs @@ -0,0 +1,181 @@ +use super::*; +use rstest::rstest; + +#[path = "styled_statement_tests.rs"] +mod statements; + +#[rstest] +#[case("unknownRules")] +#[case("[{}]")] +#[case("false")] +fn direct_props_lowerer_when_rules_are_unsupported_rejects_without_mutation( + #[case] source: &str, +) -> Result<(), Box> { + // Given + let allocator = oxc_allocator::Allocator::default(); + let ast = AstBuilder::new(&allocator); + let mut rules = Parser::new(&allocator, source, SourceType::tsx()) + .parse_expression() + .map_err(|error| format!("{error:?}"))?; + let before = expression_to_code(&rules); + // When + let mut evaluations = + crate::extractor::styled_record_evaluation::Evaluations::new(&ast, before.clone()); + let result = + props::lower(&mut evaluations, &mut rules, None).map_err(|error| format!("{error:?}"))?; + // Then + assert!(result.is_none()); + assert_eq!(evaluations.bindings.len(), 0); + assert_eq!(expression_to_code(&rules), before); + Ok(()) +} + +#[test] +#[serial_test::serial] +fn direct_props_lowerer_when_object_is_written_preserves_props_value() +-> Result<(), Box> { + // Given + let source = + "import {styled} from '@devup-ui/react';const Choice=styled.div(p=>({opacity:p.opacity}));"; + // When + let output = crate::extract( + "opacity.tsx", + source, + crate::ExtractOption { + single_css: true, + ..crate::ExtractOption::default() + }, + )?; + // Then + assert_eq!( + crate::styled_source_tests::captured_render::execute( + ("opacity.tsx", None), + &output, + "const element=render({target:Choice,props:{ opacity: 0.4 }});JSON.stringify({ opacity: values(element,'opacity')[0] });", + ), + serde_json::json!({"opacity":0.4}) + ); + Ok(()) +} + +#[rstest] +#[case("styled.div(...[{opacity: p => p.$opacity}])", true)] +#[case("styled.div(...unknownRules)", false)] +#[serial_test::serial] +fn raw_styled_spread_when_received_by_extractor_keeps_reads_or_composition_error( + #[case] source: &str, + #[case] reads_props: bool, +) -> Result<(), Box> { + // Given: the public visitor may inline known spreads before this helper sees them. + let allocator = oxc_allocator::Allocator::default(); + let ast = AstBuilder::new(&allocator); + let mut expression = Parser::new(&allocator, source, SourceType::tsx()) + .parse_expression() + .map_err(|error| format!("{error:?}"))?; + let values = crate::style_values::StyleValues::default(); + let inline_css = FxHashMap::default(); + // When + let output = extract_style_from_styled( + &ast, + &mut expression, + Naming::default(), + StyledBindings { + imports: &|_| None, + values: &values, + inline_css: &inline_css, + }, + (&[], None, &[]), + None, + StyledContext { + filename: "linux.tsx", + jsx: &mut None, + capability: None, + }, + ); + // Then + if reads_props { + let definition = output.definition.ok_or("missing spread definition")?; + assert_eq!( + definition.reads, + Reads { + names: vec!["$opacity".into()], + whole: false + } + ); + assert!(definition.takes("$opacity")); + assert_eq!(output.errors.len(), 1); + let (offset, error) = output + .errors + .first() + .ok_or("expected spread callback error")?; + assert_eq!( + *offset, + u32::try_from(source.find("p =>").ok_or("missing callback location")?)? + ); + assert!( + error.contains("p.$opacity") && error.contains("object literal"), + "{error}" + ); + } else { + assert_eq!(output.errors.len(), 1); + let (offset, error) = output + .errors + .first() + .ok_or("expected spread composition error")?; + assert_eq!(*offset, 0); + assert!( + error.contains("unknownRules") && error.contains("at build time"), + "{error}" + ); + } + Ok(()) +} + +#[test] +#[serial_test::serial] +fn raw_styled_spread_when_array_contains_written_objects_extracts_exact_value() +-> Result<(), Box> { + // Given + let allocator = oxc_allocator::Allocator::default(); + let ast = AstBuilder::new(&allocator); + let mut expression = Parser::new( + &allocator, + "styled.div(...[{opacity: 0.6}])", + SourceType::tsx(), + ) + .parse_expression() + .map_err(|error| format!("{error:?}"))?; + let values = crate::style_values::StyleValues::default(); + let inline_css = FxHashMap::default(); + // When + let output = extract_style_from_styled( + &ast, + &mut expression, + Naming::default(), + StyledBindings { + imports: &|_| None, + values: &values, + inline_css: &inline_css, + }, + (&[], None, &[]), + None, + StyledContext { + filename: "linux.tsx", + jsx: &mut None, + capability: None, + }, + ); + // Then + assert_eq!(output.errors, vec![]); + let styles = output + .result + .styles + .iter() + .flat_map(ExtractStyleProp::extract) + .collect::>(); + assert_eq!( + super::props_rule_choice_tests::support::declarations(&styles), + [("opacity".into(), ".6".into())] + ); + Ok(()) +} diff --git a/libs/extractor/src/extractor/styled_forwarding.rs b/libs/extractor/src/extractor/styled_forwarding.rs new file mode 100644 index 000000000..d7ff491df --- /dev/null +++ b/libs/extractor/src/extractor/styled_forwarding.rs @@ -0,0 +1,120 @@ +use super::{StyledDefinition, withheld}; +use crate::styled_reads::{Forward, passes}; +use oxc_allocator::{FromIn, GetAllocator}; +use oxc_ast::{ + ast::{ + BindingPattern, BindingProperty, BindingRestElement, Expression, FormalParameter, + FormalParameterKind, FormalParameterRest, FormalParameters, PropertyKey, Str, + StringLiteral, TSTypeAnnotation, TSTypeParameterDeclaration, + }, + builder::AstBuilder, +}; +use oxc_span::SPAN; +use std::borrow::Cow; + +#[derive(Clone, Copy)] +pub(crate) enum Site { + LocalJsx, + Renderer, +} + +pub(crate) fn denied<'n>( + definition: &StyledDefinition<'_>, + names: impl Iterator>, + site: Site, +) -> Vec { + // Renderer transport preserves unknown ordinary props; local JSX input validation is stricter. + let withheld = withheld( + &definition.reads, + definition.native(), + definition.forward.as_ref(), + ); + let default = Forward::Always(true); + names + .filter(|name| { + let name = name.as_ref(); + !definition.takes(name) + && match site { + Site::LocalJsx => true, + Site::Renderer => { + definition.forward.is_some() + || withheld.iter().any(|withheld| withheld == name) + || !passes(name, definition.native(), Some(&default)) + } + } + }) + .map(Cow::into_owned) + .collect() +} + +/// Filter a forwarded copy using the same destructuring carrier as styled renders. +pub(crate) fn without_props<'a>( + builder: &AstBuilder<'a>, + props: Expression<'a>, + withheld: &[String], +) -> Expression<'a> { + if withheld.is_empty() { + return props; + } + let properties = withheld.iter().enumerate().map(|(index, name)| { + BindingProperty::new( + SPAN, + PropertyKey::StringLiteral(StringLiteral::boxed( + SPAN, + Str::from_in(name.as_str(), builder.allocator()), + None, + builder, + )), + BindingPattern::new_binding_identifier( + SPAN, + Str::from_in(format!("__devupOmit{index}").as_str(), builder.allocator()), + builder, + ), + false, + false, + builder, + ) + }); + let pattern = BindingPattern::new_object_pattern( + SPAN, + oxc_allocator::Vec::from_iter_in(properties, builder), + Some(BindingRestElement::boxed( + SPAN, + BindingPattern::new_binding_identifier(SPAN, "__devupDom", builder), + builder, + )), + builder, + ); + let parameter = FormalParameter::new( + SPAN, + oxc_allocator::Vec::new_in(builder), + pattern, + None::>>, + None::>>, + false, + None, + false, + false, + builder, + ); + let arrow = Expression::new_arrow_function_expression( + SPAN, + false, + None::>>, + FormalParameters::boxed( + SPAN, + FormalParameterKind::ArrowFormalParameters, + oxc_allocator::Vec::from_iter_in([parameter], builder), + None::>>, + builder, + ), + None::>>, + Expression::new_identifier(SPAN, "__devupDom", builder).into(), + builder, + ); + crate::utils::wrap_direct_call( + builder, + &Expression::new_parenthesized_expression(SPAN, arrow, builder), + &[props], + ) +} 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..1248b8f24 --- /dev/null +++ b/libs/extractor/src/extractor/styled_mixin_context.rs @@ -0,0 +1,145 @@ +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 = "styled_mixin_context_tests.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::LayerOrder(mut order)) => { + if let Some(parent) = parent.layer() { + for layer in &mut order.layers { + *layer = format!("{parent}.{layer}"); + } + } + Ok(ExtractStyleProp::Static(ExtractStyleValue::LayerOrder( + order, + ))) + } + 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_mixin_context_tests.rs b/libs/extractor/src/extractor/styled_mixin_context_tests.rs new file mode 100644 index 000000000..e0277bd5a --- /dev/null +++ b/libs/extractor/src/extractor/styled_mixin_context_tests.rs @@ -0,0 +1,190 @@ +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(()) +} + +#[test] +fn static_atom_when_nested_in_layer_keeps_its_selector_and_inner_layer() +-> Result<(), Box> { + // Given + let atom = ExtractStaticStyle::new_with_layer( + "color", + "red", + 1, + Some(StyleSelector::Selector("&:focus".into())), + Some("inner".into()), + ); + // When + let result = contextualize( + vec![ExtractStyleProp::Static(ExtractStyleValue::Static(atom))], + &["@layer outer {".into(), "&:hover {".into()], + ) + .map_err(|_| "readable layered selector was rejected")?; + // Then + let [ExtractStyleProp::Static(ExtractStyleValue::Static(style))] = result.as_slice() else { + panic!("expected one contextualized static atom") + }; + assert_eq!(style.layer(), Some("outer.inner")); + assert_eq!( + style.selector().map(ToString::to_string).as_deref(), + Some("&:hover:focus") + ); + assert_eq!( + (style.property(), style.value(), style.level()), + ("color", "red", 1) + ); + Ok(()) +} diff --git a/libs/extractor/src/extractor/styled_planning_tests.rs b/libs/extractor/src/extractor/styled_planning_tests.rs new file mode 100644 index 000000000..b7f74d5a2 --- /dev/null +++ b/libs/extractor/src/extractor/styled_planning_tests.rs @@ -0,0 +1,96 @@ +use super::super::StyledBindings; +use super::*; + +#[rstest::rstest] +#[case("unknown() && {color:'red'}", false)] +#[case("(p=>p.theme.brand()) && {color:'red'}", true)] +fn direct_schedule_when_controller_is_unreadable_returns_the_original_error( + #[case] source: &str, + #[case] theme: bool, +) { + // Given + let arena = oxc_allocator::Allocator::default(); + let ast = AstBuilder::new(&arena); + let mut expression = oxc_parser::Parser::new(&arena, source, oxc_span::SourceType::tsx()) + .parse_expression() + .unwrap_or_else(|error| panic!("{error:?}")); + let scope = expression.clone_in_with_semantic_ids(&arena); + let values = crate::style_values::StyleValues::default(); + let inline_css = rustc_hash::FxHashMap::default(); + let bindings = StyledBindings { + imports: &|_| None, + values: &values, + inline_css: &inline_css, + }; + let mut evaluations = Evaluations::new(&ast, source.into()); + // When + let Err(error) = lower_direct(&mut evaluations, &mut expression, (&bindings, &scope)) else { + panic!("invalid controller cannot be scheduled") + }; + // Then + assert_eq!(error.1.contains("theme"), theme, "{}", error.1); + assert_eq!(evaluations.bindings.len(), 0); + assert_eq!(readable_code(&expression), readable_code(&scope)); +} + +#[test] +fn callback_record_when_later_spread_is_unknown_rolls_back_only_its_own_captures() { + // Given + let arena = oxc_allocator::Allocator::default(); + let ast = AstBuilder::new(&arena); + let source = "({color:p=>p.color,...unknown})"; + let expression = oxc_parser::Parser::new(&arena, source, oxc_span::SourceType::tsx()) + .parse_expression() + .unwrap_or_else(|error| panic!("{error:?}")); + let mut evaluations = Evaluations::new(&ast, source.into()); + evaluations.bindings.push(( + "earlier".into(), + Expression::new_boolean_literal(SPAN, true, &ast), + )); + // When + let prepared = + crate::extractor::styled_record::prepare_callbacks(&mut evaluations, &expression); + // Then + assert!(prepared.is_none()); + assert_eq!(evaluations.bindings.len(), 1); + assert_eq!(evaluations.bindings[0].0, "earlier"); + assert!(matches!(&evaluations.bindings[0].1, Expression::BooleanLiteral(value) if value.value)); +} + +#[rstest::rstest] +#[case("styled.div`${{color:p=>p.color}};`", "{color:'red'}", serde_json::json!(["red"]))] +#[case("styled.div(p=>({color:p.on?(p.input.inner?p.color:null):null}))", "{on:true,input:{ inner: true },color:'red'}", serde_json::json!(["red"]))] +#[case("styled.div(p=>({color:p.on?(p.input.inner?p.color:null):null}))", "{on:false,input:{get inner(){throw Error('inactive')}}}", serde_json::json!([]))] +#[case("styled.div(p=>(p.on||false)&&{color:'red'})", "{on:false}", serde_json::json!([]))] +#[case("styled.div(p=>(p.on||false)&&{color:'red'})", "{on:true}", serde_json::json!(["red"]))] +#[case("styled.div(p=>{return p.on?{color:p.color}:{color:'blue'};})", "{on:true,color:'red'}", serde_json::json!(["red"]))] +#[case("styled.div(p=>{return p.on?{color:p.color}:{color:'blue'};})", "{on:false,color:'red'}", serde_json::json!(["blue"]))] +#[case("styled.div(p=>({[p.input.key]:p.color,get unused(){throw Error('getter must not run')}})&&{color:'blue'})", "{input:{key:'color'},color:'red'}", serde_json::json!(["blue"]))] +#[serial_test::serial] +fn rendered_rules_when_nested_choices_or_template_property_callbacks_select_keep_authored_values( + #[case] rules: &str, + #[case] props: &str, + #[case] expected: serde_json::Value, +) { + // Given + let source = format!("import {{styled}} from '@devup-ui/react';const Choice={rules};"); + let output = crate::extract( + "choice.tsx", + &source, + crate::ExtractOption { + single_css: true, + ..Default::default() + }, + ) + .unwrap_or_else(|error| panic!("{error}")); + // When + let observed = crate::styled_source_tests::captured_render::execute( + ("choice.tsx", None), + &output, + &format!( + "const element=render({{target:Choice,props:{props}}});JSON.stringify(values(element,'color'));" + ), + ); + // Then + assert_eq!(observed, expected); +} diff --git a/libs/extractor/src/extractor/styled_preparation_cost_tests.rs b/libs/extractor/src/extractor/styled_preparation_cost_tests.rs new file mode 100644 index 000000000..4b7c4e3ce --- /dev/null +++ b/libs/extractor/src/extractor/styled_preparation_cost_tests.rs @@ -0,0 +1,94 @@ +use super::*; +use oxc_allocator::Allocator; +use oxc_parser::Parser; +use oxc_span::SourceType; + +#[rstest::rstest] +#[case("({p:4,_hover:{color:'blue'},m:[1,null,2]})")] +#[case("on?{ p: 4 }:{ p: 2 }")] +#[case("'external'")] +fn original_ast_when_no_property_callback_exists_uses_no_arena_storage(#[case] source: &str) { + // Given + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let mut style = Parser::new(&allocator, source, SourceType::tsx()) + .parse_expression() + .unwrap_or_else(|error| panic!("{error:?}")); + let original = readable_code(&style); + let mut evaluations = Evaluations::new(&builder, original.clone()); + let before = allocator.used_bytes(); + // When + let plan = + lower(&mut evaluations, &mut style, None).unwrap_or_else(|error| panic!("{error:?}")); + // Then + assert!(plan.is_none()); + assert_eq!(allocator.used_bytes(), before); + assert_eq!(readable_code(&style), original); + assert_eq!(evaluations.bindings.len(), 0); +} + +#[test] +fn composition_admission_when_parts_are_written_uses_no_arena_storage() { + // Given + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let expression = Parser::new( + &allocator, + "parts([{p:4,_hover:{ color: 'blue' }},on?{ p: 2 }:null],false)", + SourceType::tsx(), + ) + .parse_expression() + .unwrap_or_else(|error| panic!("{error:?}")); + let Expression::CallExpression(call) = &expression else { + panic!("call fixture required") + }; + let before = allocator.used_bytes(); + // When + let admitted = super::super::rule_parts::admitted(&builder, &call.arguments, None); + // Then + assert!(admitted); + assert_eq!(allocator.used_bytes(), before); +} + +#[rstest::rstest] +#[case("parts({ p: 4 })", true)] +#[case("parts(false,null,undefined)", true)] +#[case("parts(on?{ p: 4 }:null)", true)] +#[case("parts('opaque',`text`)", true)] +#[case("parts(external,{ p: 2 })", true)] +#[case("parts([{ p: 4 },...[null,{ p: 2 }]])", true)] +#[case("parts(...[{ p: 4 },on?{ p: 2 }:false])", true)] +#[case("parts(on&&{ p: 2 })", true)] +#[case("parts(on||{ p: 2 })", true)] +#[case("parts(on??{ p: 2 })", true)] +#[case("parts(unknown())", false)] +#[case("parts(...unknown)", false)] +#[case("parts([...unknown])", false)] +#[case("parts([,{ p: 2 }])", false)] +#[case("parts(on?{ p: 2 }:unknown())", false)] +#[serial_test::serial] +fn composition_admission_when_shape_varies_matches_extraction_without_rewriting_source( + #[case] source: &str, + #[case] expected: bool, +) { + // Given + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let expression = Parser::new(&allocator, source, SourceType::tsx()) + .parse_expression() + .unwrap_or_else(|error| panic!("{error:?}")); + let Expression::CallExpression(call) = &expression else { + panic!("call fixture required") + }; + let original = readable_code(&expression); + // When + let admitted = super::super::rule_parts::admitted(&builder, &call.arguments, None); + // Then + assert_eq!(admitted, expected); + assert_eq!( + admitted, + super::super::rule_parts::compose_bound(&builder, &call.arguments, (None, "cost.tsx")) + .is_some() + ); + assert_eq!(readable_code(&expression), original); +} diff --git a/libs/extractor/src/extractor/styled_property_callbacks.rs b/libs/extractor/src/extractor/styled_property_callbacks.rs new file mode 100644 index 000000000..a02ef6b02 --- /dev/null +++ b/libs/extractor/src/extractor/styled_property_callbacks.rs @@ -0,0 +1,49 @@ +//! Ordinary-property callback discovery and admission-view rewriting. +use crate::{ + css_utils::{reads_unmapped_theme, theme_var_reference, unmapped_theme_error}, + utils::{unwrap_syntax_only, unwrap_syntax_only_mut, wrap_direct_call}, +}; +use oxc_ast::{ + ast::{Expression, ObjectExpression, ObjectPropertyKind}, + builder::AstBuilder, +}; + +pub(super) fn contains(object: &ObjectExpression<'_>) -> bool { + object.properties.iter().any(|property| { + let ObjectPropertyKind::ObjectProperty(property) = property else { + return false; + }; + match unwrap_syntax_only(&property.value) { + Expression::ObjectExpression(inner) => contains(inner), + function @ (Expression::ArrowFunctionExpression(_) + | Expression::FunctionExpression(_)) => theme_var_reference(function).is_none(), + _ => false, + } + }) +} + +pub(super) fn rewrite<'a>( + builder: &AstBuilder<'a>, + object: &mut ObjectExpression<'a>, +) -> Result<(), (u32, String)> { + for property in &mut object.properties { + let ObjectPropertyKind::ObjectProperty(property) = property else { + continue; + }; + match unwrap_syntax_only_mut(&mut property.value) { + Expression::ObjectExpression(inner) => rewrite(builder, inner)?, + function @ (Expression::ArrowFunctionExpression(_) + | Expression::FunctionExpression(_)) + if theme_var_reference(function).is_none() => + { + if reads_unmapped_theme(function) { + return Err(unmapped_theme_error("styled", function)); + } + *function = + wrap_direct_call(builder, function, &[super::identifier(builder, "rest")]); + } + _ => {} + } + } + Ok(()) +} diff --git a/libs/extractor/src/extractor/styled_props.rs b/libs/extractor/src/extractor/styled_props.rs new file mode 100644 index 000000000..b50c55e69 --- /dev/null +++ b/libs/extractor/src/extractor/styled_props.rs @@ -0,0 +1,246 @@ +//! Fixed rule keys with one original props-callback invocation per render. + +use oxc_allocator::{CloneIn, GetAllocator}; +use oxc_ast::{ast::Expression, builder::AstBuilder}; +use oxc_span::{GetSpan, SPAN}; + +use super::{super::styled_record_evaluation::Evaluations, identifier}; +use crate::{ + css_utils::{reads_implicit_props_theme, reads_unmapped_theme, unmapped_theme_error}, + utils::{build_time_error, readable_code, unwrap_syntax_only, unwrap_syntax_only_mut}, +}; + +#[path = "styled_props_values.rs"] +mod values; + +#[path = "styled_props_leaves.rs"] +mod leaves; + +#[path = "styled_props_self_reference.rs"] +mod self_reference; + +#[path = "styled_props_capture.rs"] +mod capture; + +#[path = "styled_props_branches.rs"] +mod branches; + +#[path = "styled_props_discarded.rs"] +mod discarded; + +#[path = "styled_direct_rules.rs"] +mod direct; + +#[path = "styled_property_callbacks.rs"] +mod properties; + +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 })`"; + +pub(super) struct Invocation<'a> { + pub name: String, + pub value: Expression<'a>, +} + +pub(super) enum Plan<'a> { + Whole(Invocation<'a>), + Properties(Expression<'a>), +} + +enum PropsRulesError { + Rules, + Theme((u32, String)), +} + +/// Plan a supported callback without scheduling it ahead of earlier arguments. +pub(super) fn lower<'a>( + evaluations: &mut Evaluations<'_, 'a>, + style: &mut Expression<'a>, + bindings: Option<&super::StyledBindings<'_>>, +) -> Result>, (u32, String)> { + let builder = evaluations.builder; + if !matches!( + unwrap_syntax_only(style), + Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_) + ) { + return call_with_props(builder, style).map(|original| original.map(Plan::Properties)); + } + let mut function = style.clone_in_with_semantic_ids(builder.allocator()); + let invalid = || { + ( + style.span().start, + build_time_error("styled", &readable_code(style), PROPS_FUNCTION), + ) + }; + let (params, returned) = crate::css_prop::render_function(&mut function).ok_or_else(invalid)?; + let object = matches!( + unwrap_syntax_only(returned), + Expression::ObjectExpression(_) + ); + if !object + && !bindings.is_some_and(|bindings| { + super::rule_choices::normalize(builder, returned, bindings).is_ok() + }) + { + return Err(invalid()); + } + if reads_implicit_props_theme(style) { + return Err(unmapped_theme_error("styled", style)); + } + let parameter_reads = + values::analysis(builder, params, Expression::new_null_literal(SPAN, builder)); + if reads_unmapped_theme(¶meter_reads) { + return Err(unmapped_theme_error("styled", style)); + } + if let Some(error) = self_reference::error(style) { + return Err(error); + } + let name = evaluations.fresh_name(); + let mut leaves = leaves::Leaves { + builder, + params, + name: &name, + values: oxc_allocator::Vec::new_in(builder), + local: None, + guard: None, + }; + let map_error = |error| match error { + PropsRulesError::Rules => invalid(), + PropsRulesError::Theme(error) => error, + }; + let skeleton = if let Expression::ObjectExpression(object) = unwrap_syntax_only(returned) { + if leaves::shape::needs_projection(returned) { + let local = evaluations.fresh_name(); + let (skeleton, assignments) = + branches::Captures::object(&mut leaves, object, &local).map_err(map_error)?; + let schedule = if assignments.is_empty() { + values::zero(builder) + } else { + Expression::new_sequence_expression( + SPAN, + oxc_allocator::Vec::from_iter_in(assignments, builder), + builder, + ) + }; + *returned = branches::returned(builder, schedule, &local); + capture::declare(builder, &mut function, &local); + Some(skeleton) + } else { + let mut skeleton = object.clone_in_with_semantic_ids(builder.allocator()); + leaves.object(&mut skeleton).map_err(map_error)?; + *returned = Expression::new_array_expression(SPAN, leaves.values, builder); + Some(Expression::ObjectExpression(skeleton)) + } + } else { + bindings + .map(|bindings| { + let local = evaluations.fresh_name(); + leaves.values.push(values::zero(builder).into()); + let mut plan = branches::Branches { + builder, + name: &name, + leaves, + local: &local, + bindings, + rules: Vec::new(), + }; + let schedule = plan.schedule(returned).map_err(map_error)?; + *returned = branches::returned(builder, schedule, &local); + let skeleton = plan.skeleton(); + Ok((skeleton, local)) + }) + .transpose()? + .map(|(skeleton, local)| { + capture::declare(builder, &mut function, &local); + skeleton + }) + }; + Ok(skeleton.map(|skeleton| { + let value = Expression::new_call_expression( + SPAN, + function, + None, + oxc_allocator::Vec::from_array_in([identifier(builder, "rest").into()], builder), + false, + builder, + ); + *style = skeleton; + Plan::Whole(Invocation { name, value }) + })) +} + +pub(super) fn lower_direct<'a>( + evaluations: &mut Evaluations<'_, 'a>, + style: &mut Expression<'a>, + (bindings, scope): (&super::StyledBindings<'_>, &Expression<'a>), +) -> Result<(), (u32, String)> { + let builder = evaluations.builder; + let invalid = || { + ( + style.span().start, + build_time_error("styled", &readable_code(style), crate::utils::STYLE_OBJECT), + ) + }; + let name = evaluations.fresh_name(); + let initial = Expression::new_array_expression( + SPAN, + oxc_allocator::Vec::from_array_in([capture::discriminator(builder, 0).into()], builder), + builder, + ); + let mut scope = scope.clone_in_with_semantic_ids(builder.allocator()); + super::records::reserve( + builder, + &mut scope, + &[(name.clone(), initial.clone_in(builder.allocator()))], + ); + let mut plan = branches::Branches { + builder, + name: &name, + leaves: direct::Direct { + builder, + name: &name, + scope, + next: 1, + }, + local: &name, + bindings, + rules: Vec::new(), + }; + let schedule = plan.schedule(style).map_err(|error| match error { + PropsRulesError::Rules => invalid(), + PropsRulesError::Theme(error) => error, + })?; + *style = plan.skeleton(); + evaluations.bindings.push((name, initial)); + evaluations.capture(&schedule, &evaluations.boolean(true)); + Ok(()) +} + +/// Retain the original property source separately from the admission view. +fn call_with_props<'a>( + builder: &AstBuilder<'a>, + style: &mut Expression<'a>, +) -> Result>, (u32, String)> { + let Expression::ObjectExpression(object) = unwrap_syntax_only(style) else { + return Ok(None); + }; + if !properties::contains(object) { + return Ok(None); + } + let original = style.clone_in_with_semantic_ids(builder.allocator()); + if let Expression::ObjectExpression(object) = unwrap_syntax_only_mut(style) { + properties::rewrite(builder, object)?; + } + Ok(Some(original)) +} + +#[cfg(test)] +#[path = "styled_props_admission_tests.rs"] +mod admission_tests; + +#[cfg(test)] +#[path = "styled_preparation_cost_tests.rs"] +mod preparation_cost_tests; + +#[cfg(test)] +#[path = "styled_planning_tests.rs"] +mod planning_tests; diff --git a/libs/extractor/src/extractor/styled_props_admission_tests.rs b/libs/extractor/src/extractor/styled_props_admission_tests.rs new file mode 100644 index 000000000..0e0dc44ea --- /dev/null +++ b/libs/extractor/src/extractor/styled_props_admission_tests.rs @@ -0,0 +1,127 @@ +use super::super::StyledBindings; +use super::*; +use oxc_ast::ast::Statement; + +#[rstest::rstest] +#[case("p=>({color:p.color})", false, serde_json::json!([{"color":"red"},["color"]]))] +#[case("p=>({color:p.color})", true, serde_json::json!([{"color":"red"},["color"]]))] +#[case("p=>p.on?{color:p.color}:{color:'blue'}", true, serde_json::json!([{"color":"red"},["color"]]))] +#[case("p=>p.on?{color:p.color}:null", true, serde_json::json!([{"color":"red"},["color"]]))] +#[case("p=>!p.on?{color:p.color}:{color:'blue'}", true, serde_json::json!([{"color":"blue"},[]]))] +fn props_admission_when_callback_is_supported_preserves_values_and_once_reads( + #[case] source: &str, + #[case] bound: bool, + #[case] expected: serde_json::Value, +) { + // Given: parsed callbacks with real semantic bindings, not invented reference IDs. + let arena = oxc_allocator::Allocator::default(); + let builder = AstBuilder::new(&arena); + let parsed = oxc_parser::Parser::new(&arena, source, oxc_span::SourceType::mjs()).parse(); + assert_eq!(parsed.diagnostics.len(), 0); + let scoping = oxc_semantic::SemanticBuilder::new() + .build(&parsed.program) + .semantic + .into_scoping(); + let Statement::ExpressionStatement(statement) = &parsed.program.body[0] else { + panic!("expected callback") + }; + let mut style = statement.expression.clone_in_with_semantic_ids(&arena); + let values = crate::style_values::StyleValues::new(std::rc::Rc::new(scoping)); + let inline_css = rustc_hash::FxHashMap::default(); + let bindings = StyledBindings { + imports: &|_| None, + values: &values, + inline_css: &inline_css, + }; + let mut evaluations = Evaluations::new(&builder, source.into()); + // When: lower the callback and execute its real invocation and resulting rule skeleton. + let result = lower(&mut evaluations, &mut style, bound.then_some(&bindings)) + .unwrap_or_else(|error| panic!("{error:?}")); + let Some(Plan::Whole(invocation)) = result else { + panic!("supported callback must have a whole invocation") + }; + let script = format!( + "const trace=[];const rest={{on:true,get color(){{trace.push('color');return 'red'}}}};const {}={};JSON.stringify([{},trace]);", + invocation.name, + readable_code(&invocation.value), + readable_code(&style) + ); + let observed = boa_engine::Context::default() + .eval(boa_engine::Source::from_bytes(&script)) + .unwrap_or_else(|error| panic!("{error}")) + .as_string() + .unwrap_or_else(|| panic!("expected JSON observation")) + .to_std_string_escaped(); + // Then: selected rules match the authored branch; only selected reads execute, once. + assert_eq!( + serde_json::from_str::(&observed) + .unwrap_or_else(|error| panic!("{error}")), + expected + ); +} + +#[rstest::rstest] +#[case("p=>p.on?{color:p.color}:null", false, false)] +#[case("p=>p.theme.colors[key]", false, false)] +#[case("p=>p.theme.colors[key]", true, false)] +#[case("p=>p.on?{color:p.theme.colors[key]}:null", false, false)] +#[case("p=>p.on?{color:p.theme.colors[key]}:null", true, true)] +#[case("p=>({color:p.theme.colors[key]})", false, true)] +#[case("p=>({color:p.theme.colors[key]})", true, true)] +fn props_admission_when_callback_is_refused_preserves_rules_before_theme_precedence( + #[case] source: &str, + #[case] bound: bool, + #[case] theme: bool, +) { + // Given: the same real semantic environment on both sides of the admission guard. + let arena = oxc_allocator::Allocator::default(); + let builder = AstBuilder::new(&arena); + let parsed = oxc_parser::Parser::new(&arena, source, oxc_span::SourceType::mjs()).parse(); + assert_eq!(parsed.diagnostics.len(), 0); + let scoping = oxc_semantic::SemanticBuilder::new() + .build(&parsed.program) + .semantic + .into_scoping(); + let Statement::ExpressionStatement(statement) = &parsed.program.body[0] else { + panic!("expected callback") + }; + let mut style = statement.expression.clone_in_with_semantic_ids(&arena); + let original = readable_code(&style); + let expected = if theme { + let leaf = oxc_parser::Parser::new( + &arena, + "p=>p.theme.colors[key]", + oxc_span::SourceType::mjs(), + ) + .parse_expression() + .unwrap_or_else(|error| panic!("{error:?}")); + let offset = source + .find("p.theme") + .unwrap_or_else(|| panic!("expected theme read")); + ( + u32::try_from(offset).unwrap_or_else(|error| panic!("{error}")), + unmapped_theme_error("styled", &leaf).1, + ) + } else { + ( + style.span().start, + build_time_error("styled", &original, PROPS_FUNCTION), + ) + }; + let values = crate::style_values::StyleValues::new(std::rc::Rc::new(scoping)); + let inline_css = rustc_hash::FxHashMap::default(); + let bindings = StyledBindings { + imports: &|_| None, + values: &values, + inline_css: &inline_css, + }; + let mut evaluations = Evaluations::new(&builder, source.into()); + // When: unsupported rules must refuse before a later theme diagnostic can replace them. + let Err(error) = lower(&mut evaluations, &mut style, bound.then_some(&bindings)) else { + panic!("callback must be refused") + }; + // Then: exact error and location survive; refusal leaves source and captures untouched. + assert_eq!(error, expected); + assert_eq!(readable_code(&style), original); + assert_eq!(evaluations.bindings.len(), 0); +} diff --git a/libs/extractor/src/extractor/styled_props_branches.rs b/libs/extractor/src/extractor/styled_props_branches.rs new file mode 100644 index 000000000..714a7e58b --- /dev/null +++ b/libs/extractor/src/extractor/styled_props_branches.rs @@ -0,0 +1,191 @@ +use oxc_allocator::{CloneIn, FromIn, GetAllocator}; +use oxc_ast::{ast::Expression, builder::AstBuilder}; +use oxc_span::SPAN; +use oxc_syntax::operator::{BinaryOperator, LogicalOperator}; + +use super::super::{ + StyledBindings, + rule_choices::{self, Choice}, +}; +use super::{PropsRulesError, capture}; +use crate::utils::unwrap_syntax_only; + +pub(super) trait Captures<'a> { + fn object( + &mut self, + source: &oxc_ast::ast::ObjectExpression<'a>, + local: &str, + ) -> Result<(Expression<'a>, Vec>), PropsRulesError>; + fn discarded_object(&self, source: &Expression<'a>) -> Result, PropsRulesError>; + fn theme(&self, source: &Expression<'a>) -> Result<(), PropsRulesError>; +} + +pub(super) struct Branches<'b, 'p, 'a, 's, C> { + pub builder: &'b AstBuilder<'a>, + pub name: &'p str, + pub leaves: C, + pub local: &'p str, + pub bindings: &'p StyledBindings<'s>, + pub rules: Vec>, +} + +fn facts(choice: Choice<'_>) -> (Option, Option) { + match choice { + Choice::Rules { truthy, .. } => (Some(truthy), Some(false)), + Choice::Scalar { + truthy, nullish, .. + } => (truthy, nullish), + Choice::Branch { yes, no, .. } => { + let yes = facts(*yes); + let no = facts(*no); + ( + if yes.0 == no.0 { yes.0 } else { None }, + if yes.1 == no.1 { yes.1 } else { None }, + ) + } + } +} + +impl<'a, C: Captures<'a>> Branches<'_, '_, 'a, '_, C> { + pub fn schedule(&mut self, source: &Expression<'a>) -> Result, PropsRulesError> { + let builder = self.builder; + let source = unwrap_syntax_only(source); + if let Some(styles) = self.bindings.styles(source) { + let truthy = !styles.is_empty(); + self.rules + .push(source.clone_in_with_semantic_ids(builder.allocator())); + return Ok(self.selected(Vec::new(), truthy)); + } + match source { + Expression::ObjectExpression(object) => { + let (skeleton, values) = self.leaves.object(object, self.local)?; + self.rules.push(skeleton); + Ok(self.selected(values, true)) + } + Expression::ConditionalExpression(branch) => { + self.leaves.theme(&branch.test)?; + let yes = self.schedule(&branch.consequent)?; + let no = self.schedule(&branch.alternate)?; + Ok(Expression::new_conditional_expression( + branch.span, + branch.test.clone_in_with_semantic_ids(builder.allocator()), + yes, + no, + builder, + )) + } + Expression::LogicalExpression(logical) => { + let (truthy, nullish) = facts( + rule_choices::read(builder, &logical.left, self.bindings) + .map_err(|_| PropsRulesError::Rules)?, + ); + let right = match logical.operator { + LogicalOperator::And => truthy, + LogicalOperator::Or => truthy.map(|value| !value), + LogicalOperator::Coalesce => nullish, + }; + let start = self.rules.len(); + let left = if right == Some(true) + && let Expression::ObjectExpression(object) = unwrap_syntax_only(&logical.left) + { + super::discarded::schedule(builder, &mut self.leaves, object, self.local)? + } else { + self.schedule(&logical.left)? + }; + match right { + Some(false) => Ok(left), + Some(true) => { + self.rules.truncate(start); + let right = self.schedule(&logical.right)?; + Ok(Expression::new_sequence_expression( + SPAN, + oxc_allocator::Vec::from_array_in([left, right], builder), + builder, + )) + } + None => { + let right = self.schedule(&logical.right)?; + Ok(Expression::new_logical_expression( + logical.span, + left, + logical.operator, + right, + builder, + )) + } + } + } + _ => { + self.leaves.theme(source)?; + Ok(Expression::new_sequence_expression( + SPAN, + oxc_allocator::Vec::from_array_in( + [ + capture::assign( + builder, + capture::slot(builder, self.local, 0), + capture::discriminator(builder, 0), + ), + source.clone_in_with_semantic_ids(builder.allocator()), + ], + builder, + ), + builder, + )) + } + } + } + + fn selected(&self, mut values: Vec>, truthy: bool) -> Expression<'a> { + let builder = self.builder; + values.push(capture::assign( + builder, + capture::slot(builder, self.local, 0), + capture::discriminator(builder, self.rules.len()), + )); + values.push(Expression::new_boolean_literal(SPAN, truthy, builder)); + Expression::new_sequence_expression( + SPAN, + oxc_allocator::Vec::from_iter_in(values, builder), + builder, + ) + } + + pub fn skeleton(self) -> Expression<'a> { + let builder = self.builder; + let mut result = Expression::new_null_literal(SPAN, builder); + for (index, rules) in self.rules.into_iter().enumerate().rev() { + let test = Expression::new_binary_expression( + SPAN, + Expression::ComputedMemberExpression(capture::slot(builder, self.name, 0)), + BinaryOperator::StrictEquality, + capture::discriminator(builder, index + 1), + builder, + ); + result = Expression::new_conditional_expression(SPAN, test, rules, result, builder); + } + result + } +} + +pub(super) fn returned<'a>( + builder: &AstBuilder<'a>, + schedule: Expression<'a>, + local: &str, +) -> Expression<'a> { + Expression::new_sequence_expression( + SPAN, + oxc_allocator::Vec::from_array_in( + [ + schedule, + Expression::new_identifier( + SPAN, + oxc_ast::ast::Ident::from_in(local, builder.allocator()), + builder, + ), + ], + builder, + ), + builder, + ) +} diff --git a/libs/extractor/src/extractor/styled_props_capture.rs b/libs/extractor/src/extractor/styled_props_capture.rs new file mode 100644 index 000000000..404363234 --- /dev/null +++ b/libs/extractor/src/extractor/styled_props_capture.rs @@ -0,0 +1,111 @@ +use oxc_allocator::{FromIn, GetAllocator, TakeIn}; +use oxc_ast::{ + ast::{ + ArrowFunctionBody, AssignmentTarget, BindingPattern, ComputedMemberExpression, Expression, + Statement, VariableDeclaration, VariableDeclarationKind, VariableDeclarator, + }, + builder::AstBuilder, +}; +use oxc_span::SPAN; +use oxc_syntax::operator::AssignmentOperator; + +pub(super) fn slot<'a>( + builder: &AstBuilder<'a>, + name: &str, + index: usize, +) -> oxc_allocator::Box<'a, ComputedMemberExpression<'a>> { + ComputedMemberExpression::boxed( + SPAN, + Expression::new_identifier( + SPAN, + oxc_ast::ast::Ident::from_in(name, builder.allocator()), + builder, + ), + Expression::new_string_literal( + SPAN, + oxc_ast::ast::Str::from_in(index.to_string().as_str(), builder.allocator()), + None, + builder, + ), + false, + builder, + ) +} + +pub(super) fn assign<'a>( + builder: &AstBuilder<'a>, + target: oxc_allocator::Box<'a, ComputedMemberExpression<'a>>, + value: Expression<'a>, +) -> Expression<'a> { + Expression::new_assignment_expression( + SPAN, + AssignmentOperator::Assign, + AssignmentTarget::ComputedMemberExpression(target), + value, + builder, + ) +} + +pub(super) fn discriminator<'a>(builder: &AstBuilder<'a>, value: usize) -> Expression<'a> { + Expression::new_string_literal( + SPAN, + oxc_ast::ast::Str::from_in(value.to_string().as_str(), builder.allocator()), + None, + builder, + ) +} + +pub(super) fn declare<'a>(builder: &AstBuilder<'a>, function: &mut Expression<'a>, name: &str) { + let declaration = Statement::VariableDeclaration(VariableDeclaration::boxed( + SPAN, + VariableDeclarationKind::Const, + oxc_allocator::Vec::from_array_in( + [VariableDeclarator::new( + SPAN, + BindingPattern::new_binding_identifier( + SPAN, + oxc_ast::ast::Ident::from_in(name, builder.allocator()), + builder, + ), + None, + Some(Expression::new_array_expression( + SPAN, + oxc_allocator::Vec::from_array_in([discriminator(builder, 0).into()], builder), + builder, + )), + false, + builder, + )], + builder, + ), + false, + builder, + )); + match crate::utils::unwrap_syntax_only_mut(function) { + Expression::ArrowFunctionExpression(arrow) => match &mut arrow.body { + ArrowFunctionBody::FunctionBody(body) => body.statements.insert(0, declaration), + body => { + let value = body.as_expression_mut().map(|value| value.take_in(builder)); + let statements = oxc_allocator::Vec::from_array_in( + [ + declaration, + Statement::new_return_statement(SPAN, value, builder), + ], + builder, + ); + *body = ArrowFunctionBody::new_function_body( + SPAN, + oxc_allocator::Vec::new_in(builder), + statements, + builder, + ); + } + }, + Expression::FunctionExpression(function) => { + if let Some(body) = &mut function.body { + body.statements.insert(0, declaration); + } + } + _ => unreachable!("only original callbacks have capture declarations"), + } +} diff --git a/libs/extractor/src/extractor/styled_props_discarded.rs b/libs/extractor/src/extractor/styled_props_discarded.rs new file mode 100644 index 000000000..8f3b837d0 --- /dev/null +++ b/libs/extractor/src/extractor/styled_props_discarded.rs @@ -0,0 +1,143 @@ +use oxc_allocator::{CloneIn, FromIn, GetAllocator}; +use oxc_ast::{ + ast::{Expression, FormalParameters, ObjectExpression, ObjectPropertyKind, PropertyKind, Str}, + builder::AstBuilder, +}; +use oxc_ast_visit::{Visit, walk}; +use oxc_span::SPAN; + +use super::{PropsRulesError, branches::Captures, values}; +use crate::{ + css_utils::{ + reads_unmapped_theme, theme_leaf_reference, theme_var_reference, unmapped_theme_error, + }, + utils::{unwrap_syntax_only, unwrap_syntax_only_mut}, +}; + +pub(super) fn schedule<'a>( + builder: &AstBuilder<'a>, + leaves: &mut impl Captures<'a>, + object: &ObjectExpression<'a>, + local: &str, +) -> Result, PropsRulesError> { + match leaves.object(object, local) { + Ok((_, values)) if values.is_empty() => { + Ok(Expression::new_boolean_literal(SPAN, true, builder)) + } + Ok((_, values)) => Ok(Expression::new_sequence_expression( + SPAN, + oxc_allocator::Vec::from_iter_in(values, builder), + builder, + )), + Err(PropsRulesError::Rules) => { + leaves.discarded_object(&Expression::ObjectExpression(oxc_allocator::Box::new_in( + object.clone_in_with_semantic_ids(builder.allocator()), + builder, + ))) + } + Err(error) => Err(error), + } +} + +pub(super) fn mapped<'a>( + builder: &AstBuilder<'a>, + params: &FormalParameters<'a>, + source: &Expression<'a>, +) -> Result, PropsRulesError> { + let mut transformed = source.clone_in_with_semantic_ids(builder.allocator()); + let policy = Theme { builder, params }; + if let Expression::ObjectExpression(object) = unwrap_syntax_only_mut(&mut transformed) { + policy.object(object)?; + } + Ok(transformed) +} + +struct Theme<'b, 'p, 'a> { + builder: &'b AstBuilder<'a>, + params: &'p FormalParameters<'a>, +} + +impl<'a> Theme<'_, '_, 'a> { + fn object(&self, object: &mut ObjectExpression<'a>) -> Result<(), PropsRulesError> { + for property in &mut object.properties { + match property { + ObjectPropertyKind::ObjectProperty(property) => { + if let Some(key) = property.key.as_expression() { + self.check(key)?; + } + if property.method || property.kind != PropertyKind::Init { + self.check(&property.value)?; + } else { + self.value(&mut property.value)?; + } + } + ObjectPropertyKind::SpreadProperty(spread) => self.check(&spread.argument)?, + } + } + Ok(()) + } + + fn value(&self, value: &mut Expression<'a>) -> Result<(), PropsRulesError> { + if let Expression::ObjectExpression(inner) = unwrap_syntax_only_mut(value) { + return self.object(inner); + } + let source = unwrap_syntax_only(value); + if let Some(reference) = + theme_leaf_reference(self.params, source).or_else(|| theme_var_reference(source)) + { + *value = Expression::new_string_literal( + SPAN, + Str::from_in(reference.as_str(), self.builder.allocator()), + None, + self.builder, + ); + return Ok(()); + } + self.check(value) + } + + fn check(&self, source: &Expression<'a>) -> Result<(), PropsRulesError> { + let analyzed = values::analysis( + self.builder, + self.params, + source.clone_in_with_semantic_ids(self.builder.allocator()), + ); + if theme_leaf_reference(self.params, unwrap_syntax_only(source)).is_some() + || reads_unmapped_theme(&analyzed) + { + return Err(PropsRulesError::Theme(unmapped_theme_error( + "styled", &analyzed, + ))); + } + // Outer props analysis does not bind a nested function's own props parameter. + let mut functions = Functions(None); + functions.visit_expression(source); + match functions.0 { + Some(error) => Err(PropsRulesError::Theme(error)), + None => Ok(()), + } + } +} + +struct Functions(Option<(u32, String)>); + +#[cfg(test)] +#[path = "styled_props_discarded_tests.rs"] +mod tests; + +impl<'a> Visit<'a> for Functions { + fn visit_expression(&mut self, expression: &Expression<'a>) { + if self.0.is_some() { + return; + } + if matches!( + expression, + Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_) + ) && (reads_unmapped_theme(expression) || theme_var_reference(expression).is_some()) + { + self.0 = Some(unmapped_theme_error("styled", expression)); + return; + } + walk::walk_expression(self, expression); + } +} diff --git a/libs/extractor/src/extractor/styled_props_discarded_tests.rs b/libs/extractor/src/extractor/styled_props_discarded_tests.rs new file mode 100644 index 000000000..b63e5f99f --- /dev/null +++ b/libs/extractor/src/extractor/styled_props_discarded_tests.rs @@ -0,0 +1,25 @@ +use super::*; +use oxc_span::GetSpan; + +#[test] +fn discarded_functions_when_multiple_theme_reads_fail_keep_the_first_error() { + // Given: two independently invalid nested callbacks in authored traversal order. + let arena = oxc_allocator::Allocator::default(); + let source = "[p=>p.theme.colors[key],q=>q.theme.other[key]]"; + let expression = oxc_parser::Parser::new(&arena, source, oxc_span::SourceType::mjs()) + .parse_expression() + .unwrap_or_else(|error| panic!("{error:?}")); + let Expression::ArrayExpression(array) = &expression else { + panic!("expected callback array") + }; + let first = array.elements[0] + .as_expression() + .unwrap_or_else(|| panic!("expected first callback")); + let expected = unmapped_theme_error("styled", first); + let mut functions = Functions(None); + // When: the real visitor encounters both callback expressions. + functions.visit_expression(&expression); + // Then: later callbacks cannot replace the original located theme diagnostic. + assert_eq!(functions.0, Some(expected)); + assert_eq!(first.span().start, 1); +} diff --git a/libs/extractor/src/extractor/styled_props_leaves.rs b/libs/extractor/src/extractor/styled_props_leaves.rs new file mode 100644 index 000000000..1fab80cab --- /dev/null +++ b/libs/extractor/src/extractor/styled_props_leaves.rs @@ -0,0 +1,201 @@ +use oxc_allocator::{CloneIn, FromIn, GetAllocator, TakeIn}; +use oxc_ast::{ + ast::{ + ArrayExpressionElement, ComputedMemberExpression, Expression, FormalParameters, + ObjectExpression, ObjectPropertyKind, PropertyKind, Str, + }, + builder::AstBuilder, +}; +use oxc_span::SPAN; + +use super::{super::identifier, PropsRulesError, values}; +#[path = "styled_props_shape.rs"] +pub(super) mod shape; +use crate::{ + css_utils::{ + reads_unmapped_theme, theme_leaf_reference, theme_var_reference, unmapped_theme_error, + }, + utils::{ + get_str_by_property_key, unwrap_syntax_only, unwrap_syntax_only_mut, wrap_direct_call, + }, +}; + +pub(super) struct Leaves<'b, 'p, 'a> { + pub builder: &'b AstBuilder<'a>, + pub params: &'p FormalParameters<'a>, + pub name: &'p str, + pub values: oxc_allocator::Vec<'a, ArrayExpressionElement<'a>>, + pub local: Option, + pub guard: Option>, +} + +impl<'a> Leaves<'_, '_, 'a> { + pub fn object(&mut self, object: &mut ObjectExpression<'a>) -> Result<(), PropsRulesError> { + let start = self.values.len(); + let result = self.object_inner(object); + if result.is_err() { + self.values.truncate(start); + } + result + } + + fn object_inner(&mut self, object: &mut ObjectExpression<'a>) -> Result<(), PropsRulesError> { + for property in &mut object.properties { + let ObjectPropertyKind::ObjectProperty(property) = property else { + return Err(PropsRulesError::Rules); + }; + if property.computed || property.method || property.kind != PropertyKind::Init { + return Err(PropsRulesError::Rules); + } + let key = get_str_by_property_key(&property.key).ok_or(PropsRulesError::Rules)?; + if let Expression::ObjectExpression(inner) = unwrap_syntax_only_mut(&mut property.value) + { + self.object_inner(inner)?; + } else { + self.leaf(&mut property.value, &key)?; + } + property.shorthand = false; + } + Ok(()) + } + + fn leaf(&mut self, value: &mut Expression<'a>, key: &str) -> Result<(), PropsRulesError> { + if self.project(value, key)? { + return Ok(()); + } + let builder = self.builder; + if matches!(unwrap_syntax_only(value), Expression::BooleanLiteral(value) if !value.value) { + *value = Expression::new_null_literal(SPAN, builder); + return Ok(()); + } + let function = matches!( + unwrap_syntax_only(value), + Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_) + ); + if values::is_written_out(value) { + return Ok(()); + } + let reference = if function { + theme_var_reference(unwrap_syntax_only(value)) + } else { + theme_leaf_reference(self.params, unwrap_syntax_only(value)) + }; + if let Some(reference) = reference { + *value = Expression::new_string_literal( + SPAN, + Str::from_in(reference.as_str(), builder.allocator()), + None, + builder, + ); + return Ok(()); + } + self.theme(value)?; + let index = self.values.len().to_string(); + let slot = Expression::ComputedMemberExpression(ComputedMemberExpression::boxed( + SPAN, + Expression::new_identifier(SPAN, Str::from_in(self.name, builder.allocator()), builder), + Expression::new_string_literal( + SPAN, + Str::from_in(index.as_str(), builder.allocator()), + None, + builder, + ), + false, + builder, + )); + let source = value.take_in(builder); + let source = self.guarded(source); + self.values.push(source.into()); + *value = if function { + // A member call would incorrectly give an ordinary function the vector as `this`. + let direct = Expression::new_sequence_expression( + SPAN, + oxc_allocator::Vec::from_array_in([values::zero(builder), slot], builder), + builder, + ); + wrap_direct_call(builder, &direct, &[identifier(builder, "rest")]) + } else { + values::captured(builder, key, slot) + }; + Ok(()) + } + + fn theme(&self, value: &Expression<'a>) -> Result<(), PropsRulesError> { + let function = matches!( + unwrap_syntax_only(value), + Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_) + ); + let plain = values::analysis( + self.builder, + self.params, + value.clone_in_with_semantic_ids(self.builder.allocator()), + ); + let unmapped = if reads_unmapped_theme(&plain) { + Some(&plain) + } else if function && reads_unmapped_theme(unwrap_syntax_only(value)) { + Some(value) + } else { + None + }; + if let Some(unmapped) = unmapped { + return Err(PropsRulesError::Theme(unmapped_theme_error( + "styled", unmapped, + ))); + } + Ok(()) + } + + fn guarded(&self, source: Expression<'a>) -> Expression<'a> { + match &self.guard { + Some(guard) => Expression::new_conditional_expression( + SPAN, + guard.clone_in(self.builder.allocator()), + source, + Expression::new_identifier(SPAN, "undefined", self.builder), + self.builder, + ), + None => source, + } + } +} + +impl<'a> super::branches::Captures<'a> for Leaves<'_, '_, 'a> { + fn object( + &mut self, + source: &ObjectExpression<'a>, + local: &str, + ) -> Result<(Expression<'a>, Vec>), PropsRulesError> { + self.local = Some(local.to_string()); + let builder = self.builder; + let mut skeleton = source.clone_in_with_semantic_ids(builder.allocator()); + let start = self.values.len(); + Leaves::object(self, &mut skeleton)?; + let values = self + .values + .iter() + .enumerate() + .skip(start) + .map(|(index, value)| { + super::capture::assign( + builder, + super::capture::slot(builder, local, index), + value + .to_expression() + .clone_in_with_semantic_ids(builder.allocator()), + ) + }) + .collect(); + Ok(( + Expression::ObjectExpression(oxc_allocator::Box::new_in(skeleton, builder)), + values, + )) + } + + fn discarded_object(&self, source: &Expression<'a>) -> Result, PropsRulesError> { + super::discarded::mapped(self.builder, self.params, source) + } + + fn theme(&self, source: &Expression<'a>) -> Result<(), PropsRulesError> { + Leaves::theme(self, source) + } +} diff --git a/libs/extractor/src/extractor/styled_props_self_reference.rs b/libs/extractor/src/extractor/styled_props_self_reference.rs new file mode 100644 index 000000000..2a3966679 --- /dev/null +++ b/libs/extractor/src/extractor/styled_props_self_reference.rs @@ -0,0 +1,66 @@ +//! Resolve a named whole-rule callback's own binding before changing its return value. + +use oxc_allocator::{Allocator, CloneIn}; +use oxc_ast::{ + ast::{Expression, ExpressionStatement, Program, Statement}, + builder::AstBuilder, +}; +use oxc_semantic::SemanticBuilder; +use oxc_span::{GetSpan, SPAN, SourceType}; + +use crate::utils::{build_time_error, readable_code, unwrap_syntax_only}; + +pub(super) fn error(style: &Expression<'_>) -> Option<(u32, String)> { + let Expression::FunctionExpression(function) = unwrap_syntax_only(style) else { + return None; + }; + let binding = function.id.as_ref()?; + let name = binding.name.as_str(); + // A fresh clone keeps semantic analysis from replacing the enclosing module's IDs. + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let mut value = function.clone_in(&allocator); + // Oxc binds a same-named type parameter over the function's value binding. + // Erase only this type-only list in the analysis clone, never the emitted callback. + value.type_parameters = None; + let body = oxc_allocator::Vec::from_array_in( + [Statement::ExpressionStatement(ExpressionStatement::boxed( + SPAN, + Expression::FunctionExpression(value), + &builder, + ))], + &builder, + ); + let program = Program::new( + SPAN, + SourceType::tsx(), + "", + oxc_allocator::Vec::new_in(&builder), + None, + oxc_allocator::Vec::new_in(&builder), + body, + &builder, + ); + let semantic = SemanticBuilder::new().build(&program).semantic; + let scoping = semantic.scoping(); + // This isolated function binds its own name before any parameters or body bindings. + let symbol = scoping.symbol_ids().next()?; + scoping + .get_resolved_reference_ids(symbol) + .iter() + .any(|reference| scoping.get_reference(*reference).is_value()) + .then(|| { + ( + style.span().start, + build_time_error( + "styled", + &readable_code(style), + &format!("callback `{name}` refers to itself; recursion is not compiled"), + ), + ) + }) +} + +#[cfg(test)] +#[path = "styled_props_self_reference_tests.rs"] +mod tests; diff --git a/libs/extractor/src/extractor/styled_props_self_reference_tests.rs b/libs/extractor/src/extractor/styled_props_self_reference_tests.rs new file mode 100644 index 000000000..76c9818ce --- /dev/null +++ b/libs/extractor/src/extractor/styled_props_self_reference_tests.rs @@ -0,0 +1,30 @@ +use oxc_allocator::Allocator; +use oxc_parser::Parser; +use oxc_span::SourceType; +use rstest::rstest; + +#[rstest] +#[case::parentheses("(CALLBACK)")] +#[case::as_type("(CALLBACK) as Function")] +#[case::satisfies("(CALLBACK) satisfies Function")] +#[case::non_null("(CALLBACK)!")] +#[case::instantiation("(CALLBACK)")] +#[case::combined("(((CALLBACK)) as Function)!")] +fn self_reference_identity_survives_syntax_wrappers( + #[case] wrapper: &str, + #[values(false, true)] shadowed: bool, +) { + // Given: wrappers surround a generic callback with either self or parameter value reads. + let parameter = if shadowed { "rules" } else { "p" }; + let callback = + format!("function rules({parameter}:rules){{return {{color:rules.name}};}}"); + let source = wrapper.replace("CALLBACK", &callback); + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, &source, SourceType::tsx()) + .parse_expression() + .unwrap_or_else(|error| panic!("invalid wrapper fixture: {error:?}")); + // When: the same guard analyzes the isolated expression beneath every supported wrapper. + let error = super::error(&parsed); + // Then: only references to the function value are refused. + assert_eq!(error.is_some(), !shadowed, "{source}: {error:?}"); +} diff --git a/libs/extractor/src/extractor/styled_props_shape.rs b/libs/extractor/src/extractor/styled_props_shape.rs new file mode 100644 index 000000000..7d8879133 --- /dev/null +++ b/libs/extractor/src/extractor/styled_props_shape.rs @@ -0,0 +1,115 @@ +use super::{Leaves, PropsRulesError}; +use crate::utils::{unwrap_syntax_only, unwrap_syntax_only_mut}; +use oxc_allocator::{CloneIn, GetAllocator}; +use oxc_ast::ast::Expression; +use oxc_span::SPAN; +use oxc_syntax::operator::UnaryOperator; + +pub(crate) fn needs_projection(value: &Expression<'_>) -> bool { + match unwrap_syntax_only(value) { + Expression::ConditionalExpression(branch) => { + omitted(&branch.consequent) + || omitted(&branch.alternate) + || needs_projection(&branch.consequent) + || needs_projection(&branch.alternate) + } + Expression::ArrayExpression(_) => true, + Expression::ObjectExpression(object) => { + object.properties.iter().any(|property| match property { + oxc_ast::ast::ObjectPropertyKind::ObjectProperty(property) => { + needs_projection(&property.value) + } + oxc_ast::ast::ObjectPropertyKind::SpreadProperty(_) => false, + }) + } + _ => false, + } +} + +impl<'a> Leaves<'_, '_, 'a> { + pub(super) fn project( + &mut self, + value: &mut Expression<'a>, + key: &str, + ) -> Result { + let builder = self.builder; + match (unwrap_syntax_only_mut(value), self.local.as_deref()) { + (Expression::ArrayExpression(array), _) + if !array.elements.iter().any(|element| { + matches!( + element, + oxc_ast::ast::ArrayExpressionElement::SpreadElement(_) + ) + }) => + { + for element in &mut array.elements { + if let Some(value) = element.as_expression_mut() { + self.leaf(value, key)?; + } + } + Ok(true) + } + (Expression::ConditionalExpression(branch), Some(local)) + if omitted(&branch.consequent) + || omitted(&branch.alternate) + || needs_projection(&branch.consequent) + || needs_projection(&branch.alternate) => + { + self.theme(&branch.test)?; + let index = self.values.len(); + let test = + self.guarded(branch.test.clone_in_with_semantic_ids(builder.allocator())); + self.values.push(test.into()); + let local_test = Expression::ComputedMemberExpression(super::super::capture::slot( + builder, local, index, + )); + let parent = self.guard.take(); + self.guard = Some(under( + builder, + parent.as_ref(), + local_test.clone_in(builder.allocator()), + )); + self.leaf(&mut branch.consequent, key)?; + let inverted = Expression::new_unary_expression( + SPAN, + UnaryOperator::LogicalNot, + local_test, + builder, + ); + self.guard = Some(under(builder, parent.as_ref(), inverted)); + self.leaf(&mut branch.alternate, key)?; + self.guard = parent; + branch.test = Expression::ComputedMemberExpression(super::super::capture::slot( + builder, self.name, index, + )); + Ok(true) + } + _ => Ok(false), + } + } +} + +fn omitted(value: &Expression<'_>) -> bool { + match unwrap_syntax_only(value) { + Expression::NullLiteral(_) => true, + Expression::BooleanLiteral(value) => !value.value, + _ => false, + } +} + +fn under<'a>( + builder: &oxc_ast::builder::AstBuilder<'a>, + parent: Option<&Expression<'a>>, + test: Expression<'a>, +) -> Expression<'a> { + match parent { + Some(parent) => Expression::new_conditional_expression( + SPAN, + parent.clone_in(builder.allocator()), + test, + Expression::new_boolean_literal(SPAN, false, builder), + builder, + ), + None => test, + } +} diff --git a/libs/extractor/src/extractor/styled_props_values.rs b/libs/extractor/src/extractor/styled_props_values.rs new file mode 100644 index 000000000..215a97265 --- /dev/null +++ b/libs/extractor/src/extractor/styled_props_values.rs @@ -0,0 +1,46 @@ +use oxc_allocator::{CloneIn, GetAllocator}; +use oxc_ast::{ + ast::{Expression, FormalParameters}, + builder::AstBuilder, +}; +use oxc_span::GetSpan; + +pub(super) use crate::extractor::extract_style_from_styled::scalar::zero; + +/// Only theme analysis uses a synthetic per-leaf arrow; emitted execution never does. +pub(super) fn analysis<'a>( + builder: &AstBuilder<'a>, + params: &FormalParameters<'a>, + leaf: Expression<'a>, +) -> Expression<'a> { + Expression::new_arrow_function_expression( + leaf.span(), + false, + None, + oxc_allocator::Box::new_in( + params.clone_in_with_semantic_ids(builder.allocator()), + builder, + ), + None, + leaf.into(), + builder, + ) +} + +pub(super) fn is_written_out(value: &Expression<'_>) -> bool { + let value = crate::utils::unwrap_syntax_only(value); + crate::utils::get_string_by_literal_expression(value).is_some() + || matches!(value, Expression::NullLiteral(_)) +} + +pub(super) fn captured<'a>( + builder: &AstBuilder<'a>, + key: &str, + slot: Expression<'a>, +) -> Expression<'a> { + crate::extractor::extract_style_from_styled::scalar::captured( + builder, + crate::extractor::extract_style_from_styled::scalar::Origin::WholeRule(key), + slot, + ) +} diff --git a/libs/extractor/src/extractor/styled_record.rs b/libs/extractor/src/extractor/styled_record.rs index c13fabcc8..8caa55ea2 100644 --- a/libs/extractor/src/extractor/styled_record.rs +++ b/libs/extractor/src/extractor/styled_record.rs @@ -25,9 +25,15 @@ pub(super) struct PreparedRecord<'a> { pub bindings: Vec<(String, Expression<'a>)>, } -struct Preparation<'b, 'a> { - reads: Evaluations<'b, 'a>, +enum Origin { + Values, + OrdinaryCallbacks, +} + +struct Preparation<'r, 'b, 'a> { + reads: &'r mut Evaluations<'b, 'a>, affected: bool, + origin: Origin, } pub(super) fn prepare<'a>( @@ -35,9 +41,11 @@ pub(super) fn prepare<'a>( rules: &Expression<'a>, scope: &Expression<'a>, ) -> Option> { + let mut reads = Evaluations::new(builder, readable_code(scope)); let mut preparation = Preparation { - reads: Evaluations::new(builder, readable_code(scope)), + reads: &mut reads, affected: false, + origin: Origin::Values, }; let entries = preparation.record(rules, &preparation.reads.boolean(true))?; if !preparation.affected && preparation.reads.bindings.is_empty() { @@ -45,11 +53,29 @@ pub(super) fn prepare<'a>( } Some(PreparedRecord { rules: preparation.materialize(rules.span(), entries), - bindings: preparation.reads.bindings, + bindings: reads.bindings, }) } -impl<'a> Preparation<'_, 'a> { +pub(super) fn prepare_callbacks<'a>( + reads: &mut Evaluations<'_, 'a>, + rules: &Expression<'a>, +) -> Option> { + let start = reads.bindings.len(); + let mut preparation = Preparation { + reads, + affected: false, + origin: Origin::OrdinaryCallbacks, + }; + if let Some(entries) = preparation.record(rules, &preparation.reads.boolean(true)) { + Some(preparation.materialize(rules.span(), entries)) + } else { + preparation.reads.bindings.truncate(start); + None + } +} + +impl<'a> Preparation<'_, '_, 'a> { fn record( &mut self, source: &Expression<'a>, @@ -70,7 +96,7 @@ impl<'a> Preparation<'_, 'a> { key.as_str(), "styleOrder" | "styleVars" | "props" | "params" | "as" ) { - property.value = self.value(&property.value, guard)?; + property.value = self.value(&property.value, guard, &key)?; } property.shorthand = false; let entry = Entry { diff --git a/libs/extractor/src/extractor/styled_record_evaluation.rs b/libs/extractor/src/extractor/styled_record_evaluation.rs index c11af549a..5c3c6f2b5 100644 --- a/libs/extractor/src/extractor/styled_record_evaluation.rs +++ b/libs/extractor/src/extractor/styled_record_evaluation.rs @@ -1,11 +1,17 @@ use oxc_allocator::{CloneIn, FromIn, GetAllocator}; -use oxc_ast::{ast::Expression, builder::AstBuilder}; +use oxc_ast::{ + ast::{ArrowFunctionBody, Expression}, + builder::AstBuilder, +}; use oxc_span::{GetSpan, SPAN}; use crate::utils::is_same_expression; +#[path = "styled_capture_statements.rs"] +mod captures; +pub(crate) use captures::capture_statements; /// Ordered reads shared by the class and variable expressions of one render. -pub(super) struct Evaluations<'b, 'a> { +pub(crate) struct Evaluations<'b, 'a> { pub builder: &'b AstBuilder<'a>, source: String, pub bindings: Vec<(String, Expression<'a>)>, @@ -150,58 +156,32 @@ pub(super) fn visit_style_expressions<'a>( } } -pub(super) fn bind_render<'a>( +/// Ordered captures owned directly by a compiler-generated render arrow. +pub(crate) fn bind_render_body<'a>( + builder: &AstBuilder<'a>, + bindings: Vec<(String, Expression<'a>)>, + body: Expression<'a>, +) -> ArrowFunctionBody<'a> { + use oxc_allocator::Vec as ArenaVec; + use oxc_ast::ast::Statement; + if bindings.is_empty() { + return body.into(); + } + let mut statements = capture_statements(builder, bindings); + statements.push(Statement::new_return_statement(SPAN, Some(body), builder)); + ArrowFunctionBody::new_function_body(SPAN, ArenaVec::new_in(builder), statements, builder) +} + +pub(crate) fn bind_render<'a>( builder: &AstBuilder<'a>, bindings: Vec<(String, Expression<'a>)>, body: Expression<'a>, ) -> Expression<'a> { use oxc_allocator::Vec as ArenaVec; - use oxc_ast::ast::{ - ArrowFunctionBody, BindingPattern, FormalParameterKind, FormalParameters, Statement, - VariableDeclaration, VariableDeclarationKind, VariableDeclarator, - }; + use oxc_ast::ast::{FormalParameterKind, FormalParameters}; if bindings.is_empty() { return body; } - // Sibling declarations preserve dependent read order without one scope per capture. - let mut statements = ArenaVec::with_capacity_in(bindings.len() + 1, builder); - for (name, value) in bindings { - let name = oxc_ast::ast::Ident::from_in(name.as_str(), builder.allocator()); - // Sequence evaluation prevents declaration-name inference for anonymous definitions. - let value = Expression::new_sequence_expression( - SPAN, - ArenaVec::from_array_in( - [ - Expression::new_numeric_literal( - SPAN, - 0.0, - None, - oxc_syntax::number::NumberBase::Decimal, - builder, - ), - value, - ], - builder, - ), - builder, - ); - let declaration = VariableDeclarator::new( - SPAN, - BindingPattern::new_binding_identifier(SPAN, name, builder), - None, - Some(value), - false, - builder, - ); - statements.push(Statement::VariableDeclaration(VariableDeclaration::boxed( - SPAN, - VariableDeclarationKind::Const, - ArenaVec::from_array_in([declaration], builder), - false, - builder, - ))); - } - statements.push(Statement::new_return_statement(SPAN, Some(body), builder)); let arrow = Expression::new_arrow_function_expression( SPAN, false, @@ -214,7 +194,7 @@ pub(super) fn bind_render<'a>( builder, ), None, - ArrowFunctionBody::new_function_body(SPAN, ArenaVec::new_in(builder), statements, builder), + bind_render_body(builder, bindings, body), builder, ); Expression::new_call_expression( diff --git a/libs/extractor/src/extractor/styled_record_evaluation_tests.rs b/libs/extractor/src/extractor/styled_record_evaluation_tests.rs index f6c326ec6..a1069c78b 100644 --- a/libs/extractor/src/extractor/styled_record_evaluation_tests.rs +++ b/libs/extractor/src/extractor/styled_record_evaluation_tests.rs @@ -8,6 +8,29 @@ use rstest::rstest; use super::*; use crate::utils::readable_code; +#[test] +fn repair_745_empty_captures_keep_the_callers_arguments() { + // Given: an expression whose arguments belong to its enclosing function. + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let body = Parser::new(&allocator, "arguments[0]", SourceType::mjs()) + .parse_expression() + .unwrap_or_else(|error| panic!("{error:?}")); + + // When: bind no captures, then execute the emitted expression in its authored scope. + let render = bind_render(&builder, Vec::new(), body); + let script = format!("(function(){{return {};}})(7)", readable_code(&render)); + let result = Context::default() + .eval(Source::from_bytes(script.as_bytes())) + .unwrap_or_else(|error| panic!("{error}")); + + // Then: no zero-argument wrapper steals the caller's arguments object. + assert_eq!( + result.as_number().map(f64::to_bits), + Some(7.0_f64.to_bits()) + ); +} + #[rstest] #[case::equal_true(true, true)] #[case::equal_false(false, false)] diff --git a/libs/extractor/src/extractor/styled_record_selection.rs b/libs/extractor/src/extractor/styled_record_selection.rs index f74f7a40d..338bc60c0 100644 --- a/libs/extractor/src/extractor/styled_record_selection.rs +++ b/libs/extractor/src/extractor/styled_record_selection.rs @@ -51,7 +51,7 @@ pub(super) fn direct_lookup(object: &Expression<'_>, index: &Expression<'_>) -> } } -impl<'a> Preparation<'_, 'a> { +impl<'a> Preparation<'_, '_, 'a> { pub(super) fn select_lookup( &mut self, member: &mut ComputedMemberExpression<'a>, diff --git a/libs/extractor/src/extractor/styled_record_selection_tests.rs b/libs/extractor/src/extractor/styled_record_selection_tests.rs index fae9293bf..08c5dfdfd 100644 --- a/libs/extractor/src/extractor/styled_record_selection_tests.rs +++ b/libs/extractor/src/extractor/styled_record_selection_tests.rs @@ -78,8 +78,9 @@ fn selection_rejects_without_mutation_or_capture_when_object_is_unsupported(#[ca let mut member = member.clone_in(&allocator); let original = format!("{member:?}"); let mut preparation = Preparation { - reads: Evaluations::new(&builder, source.to_string()), + reads: &mut Evaluations::new(&builder, source.to_string()), affected: false, + origin: super::super::Origin::Values, }; let guard = preparation.reads.boolean(true); diff --git a/libs/extractor/src/extractor/styled_record_value.rs b/libs/extractor/src/extractor/styled_record_value.rs index c7bcb4858..52ffbd03b 100644 --- a/libs/extractor/src/extractor/styled_record_value.rs +++ b/libs/extractor/src/extractor/styled_record_value.rs @@ -1,4 +1,4 @@ -use super::Preparation; +use super::{Origin, Preparation}; use crate::utils::{get_string_by_literal_expression, unwrap_syntax_only}; use oxc_allocator::{CloneIn, GetAllocator}; use oxc_ast::ast::{ArrayExpressionElement, Expression, ObjectPropertyKind, PropertyKind}; @@ -73,11 +73,12 @@ fn static_lookup_value(source: &Expression<'_>) -> bool { } } -impl<'a> Preparation<'_, 'a> { +impl<'a> Preparation<'_, '_, 'a> { pub(super) fn value( &mut self, source: &Expression<'a>, guard: &Expression<'a>, + key: &str, ) -> Option> { let source = unwrap_syntax_only(source); let builder = self.reads.builder; @@ -119,8 +120,8 @@ impl<'a> Preparation<'_, 'a> { ), &self.reads.boolean(false), ); - let yes = self.value(&choice.consequent, &yes_guard)?; - let no = self.value(&choice.alternate, &no_guard)?; + let yes = self.value(&choice.consequent, &yes_guard, key)?; + let no = self.value(&choice.alternate, &no_guard, key)?; Some(self.reads.choose(&test, &yes, &no)) } Expression::LogicalExpression(logical) => { @@ -143,7 +144,7 @@ impl<'a> Preparation<'_, 'a> { let right_guard = self.reads .choose(guard, &takes_right, &self.reads.boolean(false)); - let right = self.value(&logical.right, &right_guard)?; + let right = self.value(&logical.right, &right_guard, key)?; Some(Expression::new_logical_expression( source.span(), left, @@ -156,13 +157,29 @@ impl<'a> Preparation<'_, 'a> { let mut array = array.clone_in(builder.allocator()); for element in &mut array.elements { if let Some(value) = element.as_expression_mut() { - *value = self.value(value, guard)?; + *value = self.value(value, guard, key)?; } else if matches!(element, ArrayExpressionElement::SpreadElement(_)) { return None; } } Some(Expression::ArrayExpression(array)) } + Expression::FunctionExpression(_) | Expression::ArrowFunctionExpression(_) + if matches!(self.origin, Origin::OrdinaryCallbacks) + && crate::css_utils::theme_var_reference(source).is_none() => + { + let call = crate::utils::wrap_direct_call( + builder, + source, + &[Expression::new_identifier(oxc_span::SPAN, "rest", builder)], + ); + let slot = self.reads.capture(&call, guard); + Some(crate::extractor::extract_style_from_styled::scalar::captured( + builder, + crate::extractor::extract_style_from_styled::scalar::Origin::OrdinaryProperty(key), + slot, + )) + } Expression::NullLiteral(_) | Expression::BigIntLiteral(_) | Expression::RegExpLiteral(_) diff --git a/libs/extractor/src/extractor/styled_render_body_tests.rs b/libs/extractor/src/extractor/styled_render_body_tests.rs new file mode 100644 index 000000000..f27de23a6 --- /dev/null +++ b/libs/extractor/src/extractor/styled_render_body_tests.rs @@ -0,0 +1,237 @@ +use boa_engine::{Context, Source}; +use oxc_allocator::Allocator; +use oxc_ast::ast::{ArrowFunctionBody, Statement, VariableDeclarationKind}; +use oxc_parser::Parser; +use oxc_span::SourceType; +use rstest::rstest; + +use super::*; + +fn expression<'a>(builder: &AstBuilder<'a>, source: &str) -> Expression<'a> { + let expression = Parser::new( + builder.allocator(), + source.trim_end_matches(';'), + SourceType::mjs(), + ) + .parse_expression() + .unwrap_or_else(|error| panic!("invalid render fixture: {error:?}")); + unwrap_syntax_only(&expression).clone_in(builder.allocator()) +} + +fn component<'a>( + builder: &AstBuilder<'a>, + render: StyledRender<'_, 'a>, + jsx: bool, +) -> Expression<'a> { + let source_type = if jsx { + SourceType::tsx() + } else { + SourceType::mjs() + }; + let parsed = Parser::new(builder.allocator(), "", source_type).parse(); + let mut helper = StyledJsx::reserve(&parsed.program); + create_styled_component( + builder, + &Base::named("div".to_string()), + render, + &mut helper, + ) +} + +#[rstest] +fn direct_body_when_generated_component_has_captures(#[values(false, true)] jsx: bool) { + // Given: ordered captures with a dependency on the preceding binding. + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let bindings = vec![ + ("first".to_string(), expression(&builder, "readFirst();")), + ( + "second".to_string(), + expression(&builder, "readSecond(first);"), + ), + ]; + + // When: build the actual props-taking component arrow in either output mode. + let render = component( + &builder, + StyledRender { + class_name: &None, + style_vars: &None, + bindings, + withheld: &[], + capability: None, + execution: &Execution::Replay, + markers: None, + }, + jsx, + ); + + // Then: the arrow directly owns sibling const declarations and the element return. + let Expression::ArrowFunctionExpression(arrow) = render else { + panic!("generated component must be an arrow"); + }; + assert_eq!(arrow.params.items.len(), 1); + let ArrowFunctionBody::FunctionBody(body) = &arrow.body else { + panic!("captured component must directly own a block body"); + }; + let [ + Statement::VariableDeclaration(first), + Statement::VariableDeclaration(second), + Statement::ReturnStatement(returned), + ] = body.statements.as_slice() + else { + panic!("captures must be sibling declarations followed by the render return"); + }; + for (declaration, name, initializer) in [ + (first, "first", "readFirst()"), + (second, "second", "readSecond(first)"), + ] { + assert_eq!(declaration.kind, VariableDeclarationKind::Const); + let [binding] = declaration.declarations.as_slice() else { + panic!("each capture must have its own declaration"); + }; + let identifier = binding + .id + .get_binding_identifier() + .unwrap_or_else(|| panic!("capture needs an identifier")); + assert_eq!(identifier.name, name); + let Some(Expression::SequenceExpression(sequence)) = &binding.init else { + panic!("capture must retain its sequence name barrier"); + }; + let [Expression::NumericLiteral(zero), value] = sequence.expressions.as_slice() else { + panic!("capture name barrier must be exactly (0, initializer)"); + }; + assert_eq!(zero.value.to_bits(), 0.0_f64.to_bits()); + assert_eq!(readable_code(value), initializer); + } + match returned + .argument + .as_ref() + .unwrap_or_else(|| panic!("render needs a return value")) + { + Expression::JSXElement(_) => assert!(jsx), + Expression::CallExpression(call) => { + assert!(!jsx); + assert!(matches!(&call.callee, Expression::Identifier(name) + if name.name == "__devupJsx0")); + } + _ => panic!("component must return its element, not a capture IIFE"), + } +} + +#[rstest] +fn concise_body_when_generated_component_has_no_captures(#[values(false, true)] jsx: bool) { + // Given: no per-render captures. + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + + // When: build the generated component without bindings. + let render = component( + &builder, + StyledRender { + class_name: &None, + style_vars: &None, + bindings: Vec::new(), + withheld: &[], + capability: None, + execution: &Execution::Replay, + markers: None, + }, + jsx, + ); + + // Then: its element remains a concise expression body. + let Expression::ArrowFunctionExpression(arrow) = render else { + panic!("generated component must be an arrow"); + }; + assert_eq!( + matches!( + arrow + .body + .as_expression() + .unwrap_or_else(|| panic!("empty captures must stay concise")), + Expression::JSXElement(_) + ), + jsx + ); +} + +#[rstest] +#[case::class("class { static { trace.push('class:' + this.name); } }", true)] +#[case::function("function() {}", false)] +fn names_and_evaluation_when_captures_move_into_generated_render( + #[case] initializer: &str, + #[case] class_observer: bool, +) { + // Given: an anonymous definition and a dependent read of destructured rest props. + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let class_name = Some(expression(&builder, "__devupRecord1;")); + let bindings = vec![ + ( + "__devupRecord0".to_string(), + expression(&builder, &format!("({initializer});")), + ), + ( + "__devupRecord1".to_string(), + expression( + &builder, + "(trace.push('capture:' + __devupRecord0.name + ':' + rest.value), rest.value);", + ), + ), + ]; + let render = component( + &builder, + StyledRender { + class_name: &class_name, + style_vars: &None, + bindings, + withheld: &[], + capability: None, + execution: &Execution::Replay, + markers: None, + }, + false, + ); + let script = format!( + r" + const trace = []; + const __devupJsx0 = (tag, props) => props.className; + let current = 'one'; + const props = {{ + get style() {{ trace.push('style'); return {{}}; }}, + get className() {{ trace.push('className'); }}, + get as() {{ trace.push('as'); }}, + get forwardedAs() {{ trace.push('forwardedAs'); }}, + get value() {{ trace.push('rest'); return current; }} + }}; + const render = {}; + trace.push('defined'); + JSON.stringify([render(props), (current = 'two', render(props)), trace]); + ", + readable_code(&render) + ); + + // When: execute the emitted component through two renders. + let mut context = Context::default(); + let result = context + .eval(Source::from_bytes(script.as_bytes())) + .unwrap_or_else(|error| panic!("generated render evaluation failed: {error}")); + let result = result + .as_string() + .unwrap_or_else(|| panic!("observation must be JSON text")) + .to_std_string_escaped(); + + // Then: destructuring precedes ordered captures each time, with no inferred name. + let mut trace = vec!["defined".to_string()]; + for value in ["one", "two"] { + trace.extend(["style", "className", "as", "forwardedAs", "rest"].map(str::to_string)); + if class_observer { + trace.push("class:".to_string()); + } + trace.push(format!("capture::{value}")); + } + let expected = serde_json::to_string(&("one", "two", trace)) + .unwrap_or_else(|error| panic!("expected observation must serialize: {error}")); + assert_eq!(result, expected); +} diff --git a/libs/extractor/src/extractor/styled_residual.rs b/libs/extractor/src/extractor/styled_residual.rs new file mode 100644 index 000000000..20b7cbc90 --- /dev/null +++ b/libs/extractor/src/extractor/styled_residual.rs @@ -0,0 +1,118 @@ +use super::{ + AstBuilder, Execution, Expression, ExtractStyleProp, GetAllocator, Naming, StyledDefinition, + gen_class_names, merge_expression_for_class_name, +}; +use crate::composition::{CascadeKey, Composition, Coverage, coverage}; + +impl StyledDefinition<'_> { + pub(crate) fn css_coverage(&self) -> Option { + match &self.execution { + Execution::RetainedDynamic { shape, .. } => Some(shape.1.clone()), + Execution::Staged { shape, .. } => { + Some(shape.1.union(&coverage(self.composition_styles()))) + } + Execution::Replay | Execution::RetainedFixed { .. } | Execution::Delegated(_) => None, + } + } + + pub(super) fn residual_shape(&self) -> Shape { + let arena = oxc_allocator::Allocator::default(); + let ast = AstBuilder::new(&arena); + let own = shape(&ast, self.composition_styles()); + match &self.execution { + Execution::Staged { shape, .. } => shape.union(&own), + Execution::RetainedDynamic { shape, .. } => shape.clone(), + Execution::Replay | Execution::RetainedFixed { .. } | Execution::Delegated(_) => own, + } + } +} + +#[derive(Clone)] +pub(super) struct Shape(pub Vec>, Coverage); + +#[cfg(test)] +#[path = "styled_residual_tests.rs"] +mod tests; + +pub(super) struct Selections<'a> { + pub shape: Shape, + pub values: Vec>>, +} + +pub(super) fn shape(ast: &AstBuilder<'_>, styles: &[ExtractStyleProp<'_>]) -> Shape { + let mut composition = Composition::default(); + composition.apply( + ast, + styles + .iter() + .map(|prop| prop.clone_in(ast.allocator())) + .collect(), + ); + Shape( + composition + .into_residual() + .into_iter() + .map(|(key, _)| key) + .collect(), + coverage(styles), + ) +} + +pub(super) fn selections<'a>( + ast: &AstBuilder<'a>, + styles: &[ExtractStyleProp<'a>], + naming: Naming<'_>, +) -> Selections<'a> { + let mut composition = Composition::default(); + composition.apply( + ast, + styles + .iter() + .map(|prop| prop.clone_in(ast.allocator())) + .collect(), + ); + let (keys, values) = composition + .into_residual() + .into_iter() + .map(|(key, mut props)| { + let classes = gen_class_names(ast, &mut props, None, naming.split_filename); + (key, merge_expression_for_class_name(ast, classes)) + }) + .unzip(); + Selections { + shape: Shape(keys, coverage(styles)), + values, + } +} + +impl Shape { + pub(super) fn union(&self, own: &Self) -> Self { + let mut shape = self.clone(); + for key in &own.0 { + if key.is_none() || !shape.0.contains(key) { + shape.0.push(key.clone()); + } + } + shape.1 = self.1.union(&own.1); + shape + } + + pub(super) fn merge(&self, own: &Self, frame: &str, values: &str) -> String { + let inherited = self.0.iter().enumerate().map(|(index, key)| { + own.0 + .iter() + .position(|other| key.is_some() && key == other) + .map_or_else( + || format!("{frame}[0][{index}]"), + |later| format!("({values}[{later}]||{frame}[0][{index}])"), + ) + }); + let added = own + .0 + .iter() + .enumerate() + .filter(|(_, key)| key.is_none() || !self.0.contains(key)) + .map(|(index, _)| format!("{values}[{index}]")); + format!("[{}]", inherited.chain(added).collect::>().join(",")) + } +} diff --git a/libs/extractor/src/extractor/styled_residual_tests.rs b/libs/extractor/src/extractor/styled_residual_tests.rs new file mode 100644 index 000000000..788dccdf5 --- /dev/null +++ b/libs/extractor/src/extractor/styled_residual_tests.rs @@ -0,0 +1,60 @@ +use super::*; +use crate::extract_style::{ + extract_static_style::ExtractStaticStyle, extract_style_value::ExtractStyleValue, +}; + +#[test] +fn residual_union_when_new_keys_and_external_classes_arrive_keeps_their_slots() { + // Given: a repeated color key, distinct keys, and independent unkeyed class expressions. + let arena = oxc_allocator::Allocator::default(); + let ast = AstBuilder::new(&arena); + let atom = |property: &str, value: &str| { + ExtractStyleProp::Static(ExtractStyleValue::Static(ExtractStaticStyle::new( + property, value, 0, None, + ))) + }; + let external = || ExtractStyleProp::Expression { + expression: oxc_parser::Parser::new(&arena, "external", oxc_span::SourceType::mjs()) + .parse_expression() + .unwrap_or_else(|error| panic!("{error:?}")), + styles: vec![], + }; + let color = atom("color", "red"); + let opacity = atom("opacity", "0.5"); + let padding = atom("padding", "4px"); + let inherited = shape( + &ast, + &[color.clone_in(&arena), opacity.clone_in(&arena), external()], + ); + let own = shape( + &ast, + &[color.clone_in(&arena), padding.clone_in(&arena), external()], + ); + // When: the residual carrier combines inherited and newly declared slot shapes. + let joined = inherited.union(&own); + // Then: only keyed duplicates coalesce; distinct keys and both external slots remain ordered. + assert!( + joined.0 + == vec![ + inherited.0[0].clone(), + inherited.0[1].clone(), + None, + own.0[1].clone(), + None + ] + ); + let merged = inherited.merge(&own, "frame", "own"); + let script = format!( + "const frame=[['old-color','old-opacity','old-external']];const own=['new-color','new-padding','new-external'];JSON.stringify({merged});" + ); + let actual = boa_engine::Context::default() + .eval(boa_engine::Source::from_bytes(&script)) + .unwrap_or_else(|error| panic!("{error}")) + .as_string() + .unwrap_or_else(|| panic!("expected JSON observation")) + .to_std_string_escaped(); + assert_eq!( + actual, + r#"["new-color","old-opacity","old-external","new-padding","new-external"]"# + ); +} diff --git a/libs/extractor/src/extractor/styled_retained.rs b/libs/extractor/src/extractor/styled_retained.rs index cbd4f75b0..ad2e2e1f9 100644 --- a/libs/extractor/src/extractor/styled_retained.rs +++ b/libs/extractor/src/extractor/styled_retained.rs @@ -1,14 +1,12 @@ use super::{ - RENDERED, STYLED_BASE, StyledCapability, StyledDefinition, call_with_values, fixed, identifier, - jsx_name, marker_class, merge_expression_for_class_name, renders_tag, with_property, - without_props, + STYLED_BASE, StyledCapability, StyledDefinition, call_with_values, fixed, identifier, jsx_name, + marker_class, merge_expression_for_class_name, renders_tag, }; use oxc_allocator::{Allocator, CloneIn, GetAllocator}; -use oxc_ast::{ - ast::{Expression, ObjectPropertyKind}, - builder::AstBuilder, -}; -use oxc_span::SPAN; +use oxc_ast::{ast::Expression, builder::AstBuilder}; +#[path = "styled_retained_transport.rs"] +mod scalar_transport; +pub(super) use scalar_transport::{Transport, transport}; pub(super) enum Execution<'a> { Replay, @@ -16,7 +14,17 @@ pub(super) enum Execution<'a> { native: bool, capability: StyledCapability, }, + RetainedDynamic { + native: bool, + capability: StyledCapability, + shape: super::residual::Shape, + }, Delegated(Renderer<'a>), + Staged { + renderer: Renderer<'a>, + shape: super::residual::Shape, + retarget: Option>, + }, } pub(super) struct Renderer<'a> { @@ -28,11 +36,27 @@ pub(super) struct Renderer<'a> { pub identities: Vec, } +impl Renderer<'_> { + fn clone_in<'b>(&self, allocator: &'b Allocator) -> Renderer<'b> { + Renderer { + name: self.name.clone(), + target: self.target.clone_in(allocator), + bound: self.bound.as_ref().map(|value| value.clone_in(allocator)), + capability: self.capability.clone(), + native: self.native, + identities: self.identities.clone(), + } + } +} + impl Execution<'_> { pub(super) const fn replays(&self) -> bool { match self { Self::Replay => true, - Self::RetainedFixed { .. } | Self::Delegated(_) => false, + Self::RetainedFixed { .. } + | Self::RetainedDynamic { .. } + | Self::Delegated(_) + | Self::Staged { .. } => false, } } @@ -43,17 +67,25 @@ impl Execution<'_> { native: *native, capability: capability.clone(), }, - Self::Delegated(renderer) => Execution::Delegated(Renderer { - name: renderer.name.clone(), - target: renderer.target.clone_in(allocator), - bound: renderer - .bound - .as_ref() - .map(|value| value.clone_in(allocator)), - capability: renderer.capability.clone(), - native: renderer.native, - identities: renderer.identities.clone(), - }), + Self::RetainedDynamic { + native, + capability, + shape, + } => Execution::RetainedDynamic { + native: *native, + capability: capability.clone(), + shape: shape.clone(), + }, + Self::Staged { + renderer, + shape, + retarget, + } => Execution::Staged { + renderer: renderer.clone_in(allocator), + shape: shape.clone(), + retarget: retarget.as_ref().map(|value| value.clone_in(allocator)), + }, + Self::Delegated(renderer) => Execution::Delegated(renderer.clone_in(allocator)), } } } @@ -63,6 +95,26 @@ impl<'a> StyledDefinition<'a> { if self.portable() { return Some(self); } + if !self.fixed_summary() + && let Some(capability) = self.capability.clone() + { + let shape = self.residual_shape(); + let native = self.native(); + self.name = String::new(); + self.target = None; + self.bound = None; + self.bindings.clear(); + self.attrs.clear(); + self.classes.clear(); + self.styles.clear(); + self.composition_styles = None; + self.execution = Execution::RetainedDynamic { + native, + capability, + shape, + }; + return Some(self); + } if self.fixed_summary() && let Some(capability) = self.capability.take() { @@ -104,20 +156,51 @@ impl<'a> StyledDefinition<'a> { }); definition.target = Some(identifier(ast, "undefined")); } - Execution::Replay | Execution::Delegated(_) => {} + Execution::RetainedDynamic { + native, + capability, + shape, + } => { + definition.execution = Execution::Staged { + renderer: Renderer { + name: jsx_name(target).unwrap_or_else(|| STYLED_BASE.to_string()), + target: target.clone_in(ast.allocator()), + bound: None, + capability: capability.clone(), + native: *native, + identities: self + .markers + .iter() + .chain(&self.inherited_markers) + .cloned() + .collect(), + }, + shape: shape.clone(), + retarget: None, + }; + definition.target = Some(identifier(ast, "undefined")); + } + Execution::Replay | Execution::Delegated(_) | Execution::Staged { .. } => {} } definition } pub(super) fn fixed_summary(&self) -> bool { - self.attrs.is_empty() && self.classes.is_empty() && self.styles.iter().all(fixed) + !matches!( + self.execution, + Execution::RetainedDynamic { .. } | Execution::Staged { .. } + ) && self.attrs.is_empty() + && self.classes.is_empty() + && self.styles.iter().all(fixed) } pub(super) fn native(&self) -> bool { match &self.execution { Execution::Replay => renders_tag(&self.name, self.bound.as_ref()), - Execution::RetainedFixed { native, .. } => *native, - Execution::Delegated(renderer) => renderer.native, + Execution::RetainedFixed { native, .. } | Execution::RetainedDynamic { native, .. } => { + *native + } + Execution::Delegated(renderer) | Execution::Staged { renderer, .. } => renderer.native, } } @@ -130,8 +213,12 @@ impl<'a> StyledDefinition<'a> { pub(super) fn render_markers(&self, ast: &AstBuilder<'a>) -> Option> { let inherited = match &self.execution { - Execution::Delegated(renderer) => renderer.identities.as_slice(), - Execution::Replay | Execution::RetainedFixed { .. } => &[], + Execution::Delegated(renderer) | Execution::Staged { renderer, .. } => { + renderer.identities.as_slice() + } + Execution::Replay + | Execution::RetainedFixed { .. } + | Execution::RetainedDynamic { .. } => &[], }; merge_expression_for_class_name( ast, @@ -149,72 +236,19 @@ impl<'a> StyledDefinition<'a> { component: Expression<'a>, ) -> Expression<'a> { match &self.execution { - Execution::Delegated(renderer) => match &renderer.bound { - Some(bound) => call_with_values( - ast, - vec![(STYLED_BASE.to_string(), bound.clone_in(ast.allocator()))], - component, - ), - None => component, - }, - Execution::Replay | Execution::RetainedFixed { .. } => component, - } - } -} - -pub(super) struct Transport<'b, 'a> { - pub capability: Option<&'b StyledCapability>, - pub replacement: Option<&'b Expression<'a>>, - pub forwarded: Expression<'a>, -} - -pub(super) fn transport<'a>( - ast: &AstBuilder<'a>, - renderer: Option<&Renderer<'a>>, - transport: Transport<'_, 'a>, -) -> (Expression<'a>, Expression<'a>, Expression<'a>) { - match renderer { - None => ( - identifier(ast, RENDERED), - transport.forwarded, - identifier(ast, "forwardedAs"), - ), - Some(renderer) => { - let mut controls = Vec::new(); - if let Some(capability) = transport.capability { - capability.omit(&mut controls); + Execution::Delegated(renderer) | Execution::Staged { renderer, .. } => { + match &renderer.bound { + Some(bound) => call_with_values( + ast, + vec![(STYLED_BASE.to_string(), bound.clone_in(ast.allocator()))], + component, + ), + None => component, + } } - let raw = with_property( - ast, - without_props(ast, &controls), - "forwardedAs", - identifier(ast, "forwardedAs"), - ); - let raw = Expression::new_object_expression( - SPAN, - oxc_allocator::Vec::from_array_in( - [ - ObjectPropertyKind::new_spread_property(SPAN, raw, ast), - renderer.capability.classes.property( - ast, - transport - .replacement - .map(|value| value.clone_in(ast.allocator())), - ), - renderer - .capability - .forwarded - .property(ast, Some(transport.forwarded)), - ], - ast, - ), - ast, - ); - ( - renderer.target.clone_in(ast.allocator()), - raw, - identifier(ast, RENDERED), - ) + Execution::Replay + | Execution::RetainedFixed { .. } + | Execution::RetainedDynamic { .. } => component, } } } diff --git a/libs/extractor/src/extractor/styled_retained_transport.rs b/libs/extractor/src/extractor/styled_retained_transport.rs new file mode 100644 index 000000000..9d180bfb5 --- /dev/null +++ b/libs/extractor/src/extractor/styled_retained_transport.rs @@ -0,0 +1,65 @@ +use super::super::{RENDERED, StyledCapability, identifier, with_property, without_props}; +use super::Renderer; +use oxc_allocator::{CloneIn, GetAllocator}; +use oxc_ast::{ + ast::{Expression, ObjectPropertyKind}, + builder::AstBuilder, +}; +use oxc_span::SPAN; + +pub(in super::super) struct Transport<'b, 'a> { + pub capability: Option<&'b StyledCapability>, + pub replacement: Option<&'b Expression<'a>>, + pub forwarded: Expression<'a>, +} + +pub(in super::super) fn transport<'a>( + ast: &AstBuilder<'a>, + renderer: Option<&Renderer<'a>>, + transport: Transport<'_, 'a>, +) -> (Expression<'a>, Expression<'a>, Expression<'a>) { + match renderer { + None => ( + identifier(ast, RENDERED), + transport.forwarded, + identifier(ast, "forwardedAs"), + ), + Some(renderer) => { + let mut controls = Vec::new(); + if let Some(capability) = transport.capability { + capability.omit(&mut controls); + } + let raw = with_property( + ast, + without_props(ast, &controls), + "forwardedAs", + identifier(ast, "forwardedAs"), + ); + let raw = Expression::new_object_expression( + SPAN, + oxc_allocator::Vec::from_array_in( + [ + ObjectPropertyKind::new_spread_property(SPAN, raw, ast), + renderer.capability.classes.property( + ast, + transport + .replacement + .map(|value| value.clone_in(ast.allocator())), + ), + renderer + .capability + .forwarded + .property(ast, Some(transport.forwarded)), + ], + ast, + ), + ast, + ); + ( + renderer.target.clone_in(ast.allocator()), + raw, + identifier(ast, RENDERED), + ) + } + } +} diff --git a/libs/extractor/src/extractor/styled_retarget.rs b/libs/extractor/src/extractor/styled_retarget.rs new file mode 100644 index 000000000..38fd6a079 --- /dev/null +++ b/libs/extractor/src/extractor/styled_retarget.rs @@ -0,0 +1,138 @@ +use super::{ + AstBuilder, Base, CloneIn, Execution, Expression, ExtractStyleProp, GetAllocator, Naming, + StyledContext, StyledDefinition, StyledRender, apply_attrs, clone_all, create_styled_component, + gen_class_names, gen_styles, jsx_name, merge_expression_for_class_name, renders_tag, stages, + unwrap_syntax_only, withheld, +}; + +/// Retarget a known styled definition without changing its execution owner. +pub fn with_component<'a>( + ast_builder: &AstBuilder<'a>, + definition: &StyledDefinition<'a>, + target: &Expression<'a>, + naming: Naming<'_>, + context: StyledContext<'_>, +) -> Option<(Expression<'a>, StyledDefinition<'a>)> { + let name = match unwrap_syntax_only(target) { + Expression::StringLiteral(literal) => literal.value.to_string(), + target => jsx_name(target)?, + }; + let allocator = ast_builder.allocator(); + let base = Base { + target: match unwrap_syntax_only(target) { + Expression::StringLiteral(_) => None, + _ => Some(target.clone_in(allocator)), + }, + ..Base::named(name) + }; + let mut scope = target.clone_in(allocator); + let mut definition = definition.rebind(ast_builder, &mut scope); + match &mut definition.execution { + Execution::Staged { + renderer, retarget, .. + } => { + renderer.native = renders_tag(&base.name, None); + *retarget = Some(target.clone_in(allocator)); + definition.name.clone_from(&base.name); + definition.target = base.target.as_ref().map(|value| value.clone_in(allocator)); + definition.capability = context.capability.cloned(); + let inherited = definition.clone_in(allocator); + definition = definition.with_markers(naming.markers, Some(&inherited)); + let component = stages::create( + ast_builder, + &definition, + stages::Context { + base: &base, + naming, + jsx: context.jsx, + }, + )?; + return Some((base.render(ast_builder, component), definition)); + } + Execution::Delegated(renderer) => renderer.native = renders_tag(&base.name, None), + Execution::Replay | Execution::RetainedFixed { .. } | Execution::RetainedDynamic { .. } => { + } + } + let mut styles: Vec> = definition + .styles + .iter() + .map(|style| style.clone_in(allocator)) + .collect(); + let classes: Vec> = clone_all(ast_builder, &definition.classes).collect(); + let class_name = merge_expression_for_class_name( + ast_builder, + clone_all(ast_builder, &classes).chain(gen_class_names( + ast_builder, + &mut styles, + None, + naming.split_filename, + )), + ); + let withheld = withheld( + &definition.reads, + renders_tag(&base.name, None), + definition.forward.as_ref(), + ); + let new_definition = base + .definition( + ast_builder, + classes, + &styles, + &definition.attrs, + (definition.reads.clone(), definition.forward.clone()), + &definition.bindings, + ) + .with_composition_styles( + definition + .composition_styles + .as_ref() + .map(|styles| styles.clone_in(allocator)), + ) + .with_execution(ast_builder, Some(&definition)) + .with_css_slot(context.capability, Some(&definition)) + .with_markers(naming.markers, Some(&definition)); + let staged = stages::create( + ast_builder, + &new_definition, + stages::Context { + base: &base, + naming, + jsx: context.jsx, + }, + ); + let has_stage = staged.is_some(); + let component = staged.unwrap_or_else(|| { + create_styled_component( + ast_builder, + &base, + StyledRender { + class_name: &class_name, + style_vars: &gen_styles(ast_builder, &styles, None), + bindings: definition + .bindings + .iter() + .map(|(name, value)| (name.clone(), value.clone_in(allocator))) + .collect(), + withheld: &withheld, + capability: new_definition.capability.as_ref(), + execution: &new_definition.execution, + markers: new_definition.render_markers(ast_builder), + }, + context.jsx, + ) + }); + Some(( + new_definition.retain_bound( + ast_builder, + base.render( + ast_builder, + if has_stage { + component + } else { + apply_attrs(ast_builder, component, &definition.attrs) + }, + ), + ), + new_definition, + )) +} diff --git a/libs/extractor/src/extractor/styled_rule_arrays.rs b/libs/extractor/src/extractor/styled_rule_arrays.rs new file mode 100644 index 000000000..e87d5bbef --- /dev/null +++ b/libs/extractor/src/extractor/styled_rule_arrays.rs @@ -0,0 +1,124 @@ +use oxc_allocator::{CloneIn, GetAllocator}; +use oxc_ast::{ast::Expression, builder::AstBuilder}; +use oxc_span::{GetSpan, SPAN}; +use oxc_syntax::operator::LogicalOperator; + +use crate::utils::{is_pure, unwrap_syntax_only, unwrap_syntax_only_mut}; + +/// Distribute only the controllers the existing pure-only policy permits. +/// Authored evaluations, when prepared, are already shared render bindings. +pub(super) fn distribute_conditions<'a>( + builder: &AstBuilder<'a>, + part: &mut Expression<'a>, + refused: &[u32], +) { + let clone = |expression: &Expression<'a>| expression.clone_in(builder.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, builder); + 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() { + distribute_conditions(builder, element, refused); + } + } + return; + } + Expression::LogicalExpression(logical) + if logical.operator == LogicalOperator::And + && is_pure(&logical.left) + && !refused.contains(&logical.span.start) + && let Some(parts) = parts_of(&logical.right) => + { + parts + .into_iter() + .map(|part| { + Expression::new_logical_expression( + SPAN, + clone(&logical.left), + LogicalOperator::And, + part, + builder, + ) + }) + .collect() + } + Expression::ConditionalExpression(conditional) + if is_pure(&conditional.test) + && !refused.contains(&conditional.span.start) + && 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, + builder, + ) + }; + 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), builder), + builder, + ); + distribute_conditions(builder, part, refused); +} + +pub(crate) fn refused_controllers(expression: &Expression<'_>, refused: &mut Vec) { + match unwrap_syntax_only(expression) { + Expression::ArrayExpression(array) => { + for element in &array.elements { + if let Some(element) = element.as_expression() { + refused_controllers(element, refused); + } + } + } + Expression::ConditionalExpression(choice) => { + if !is_pure(&choice.test) { + refused.push(choice.span.start); + } + refused_controllers(&choice.consequent, refused); + refused_controllers(&choice.alternate, refused); + } + Expression::LogicalExpression(logical) => { + if !is_pure(&logical.left) { + refused.push(logical.span().start); + } + if logical.operator != LogicalOperator::And { + refused_controllers(&logical.left, refused); + } + refused_controllers(&logical.right, refused); + } + _ => {} + } +} 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..806fd8ff9 --- /dev/null +++ b/libs/extractor/src/extractor/styled_rule_choices.rs @@ -0,0 +1,248 @@ +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 = "styled_choice_tests.rs"] +mod coverage_tests; + +#[cfg(test)] +#[path = "layer_choice_tests.rs"] +mod layer_choice_tests; + +pub(super) 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) +} + +pub(super) 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 + .iter() + .any(|style| !matches!(style, crate::ExtractStyleValue::LayerOrder(_))), + }); + } + 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_inputs.rs b/libs/extractor/src/extractor/styled_rule_inputs.rs new file mode 100644 index 000000000..dd8bdfd7c --- /dev/null +++ b/libs/extractor/src/extractor/styled_rule_inputs.rs @@ -0,0 +1,79 @@ +//! Shared structural admission for composition, without extracting declarations. +use super::super::StyledBindings; +use crate::utils::{StyleArguments, style_arguments, unwrap_syntax_only}; +use oxc_allocator::{CloneIn, GetAllocator}; +use oxc_ast::{ + ast::{Argument, Expression}, + builder::AstBuilder, +}; + +pub(super) enum Input<'s, 'a> { + Rules(&'s Expression<'a>), + Class(&'s Expression<'a>), + Prepared(StyleArguments<'a>), +} + +pub(super) fn visit<'a>( + ast: &AstBuilder<'a>, + (arguments, bindings): (&[Argument<'a>], Option<&StyledBindings<'_>>), + mut consume: impl FnMut(Input<'_, 'a>), +) -> Option<()> { + let mut pending = arguments + .iter() + .rev() + .map(|argument| match argument { + Argument::SpreadElement(spread) => match unwrap_syntax_only(&spread.argument) { + Expression::ArrayExpression(_) => Some(&spread.argument), + _ => None, + }, + argument => argument.as_expression(), + }) + .collect::>>()?; + while let Some(source) = pending.pop() { + if let Expression::ArrayExpression(array) = unwrap_syntax_only(source) { + for element in array.elements.iter().rev() { + let source = match element { + oxc_ast::ast::ArrayExpressionElement::SpreadElement(spread) => { + match unwrap_syntax_only(&spread.argument) { + Expression::ArrayExpression(_) => &spread.argument, + _ => return None, + } + } + element => element.as_expression()?, + }; + pending.push(source); + } + continue; + } + match unwrap_syntax_only(source) { + rules if written(rules, bindings) => consume(Input::Rules(rules)), + Expression::StringLiteral(_) | Expression::TemplateLiteral(_) => { + consume(Input::Class(source)); + } + _ => { + let argument = Argument::from(source.clone_in_with_semantic_ids(ast.allocator())); + consume(Input::Prepared(style_arguments( + ast, + std::slice::from_ref(&argument), + )?)); + } + } + } + Some(()) +} + +fn written(rules: &Expression<'_>, bindings: Option<&StyledBindings<'_>>) -> bool { + if bindings.is_some_and(|bindings| bindings.styles(rules).is_some()) { + return true; + } + match unwrap_syntax_only(rules) { + Expression::ObjectExpression(_) + | Expression::NullLiteral(_) + | Expression::BooleanLiteral(_) => true, + Expression::Identifier(identifier) => identifier.name == "undefined", + Expression::ConditionalExpression(branch) => { + written(&branch.consequent, bindings) && written(&branch.alternate, bindings) + } + _ => false, + } +} diff --git a/libs/extractor/src/extractor/styled_rule_part.rs b/libs/extractor/src/extractor/styled_rule_part.rs new file mode 100644 index 000000000..fed63daad --- /dev/null +++ b/libs/extractor/src/extractor/styled_rule_part.rs @@ -0,0 +1,56 @@ +use crate::{ + composition::Composition, + extractor::{ + ExtractResult, + extract_style_from_expression::LiteralHandling, + extract_style_from_styled::StyledBindings, + style_order_projection::{AuthoredStyles, extract_with_order}, + }, +}; +use oxc_ast::{ast::Expression, builder::AstBuilder}; + +pub(super) fn extract<'a>( + ast: &AstBuilder<'a>, + rules: &mut Expression<'a>, + (bindings, filename): (Option<&StyledBindings<'_>>, &str), +) -> ExtractResult<'a> { + if let Some(styles) = bindings.and_then(|bindings| bindings.styles(rules)) { + return ExtractResult { + styles: AuthoredStyles( + styles + .iter() + .cloned() + .map(crate::ExtractStyleProp::Static) + .collect(), + ) + .registered(filename) + .0, + ..ExtractResult::default() + }; + } + if let Expression::ConditionalExpression(branch) = crate::utils::unwrap_syntax_only_mut(rules) { + let yes = extract(ast, &mut branch.consequent, (bindings, filename)); + let no = extract(ast, &mut branch.alternate, (bindings, filename)); + let mut composition = Composition::default(); + composition.apply_conditional( + ast, + &branch.test, + AuthoredStyles(yes.styles).normalized(ast).0, + AuthoredStyles(no.styles).normalized(ast).0, + ); + return ExtractResult { + styles: composition.into_props(), + ..ExtractResult::default() + }; + } + let (mut result, authored) = extract_with_order( + ast, + None, + rules, + 0, + &None, + LiteralHandling::ExpandResponsiveThemeToken, + ); + result.styles = authored.registered(filename).0; + result +} 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..d5b27ce54 --- /dev/null +++ b/libs/extractor/src/extractor/styled_rule_parts.rs @@ -0,0 +1,164 @@ +use super::ExtractResult; +use crate::extractor::style_order_projection::AuthoredStyles; +#[path = "styled_rule_inputs.rs"] +mod inputs; +#[path = "styled_rule_part.rs"] +mod part; +use crate::{composition::Composition, utils::unwrap_syntax_only}; +use oxc_ast::{ + ast::{Argument, Expression}, + builder::AstBuilder, +}; + +pub(super) fn contains_known_rules( + rules: &Expression<'_>, + bindings: &super::StyledBindings<'_>, +) -> bool { + if bindings.styles(rules).is_some() { + return true; + } + match unwrap_syntax_only(rules) { + Expression::ConditionalExpression(branch) => { + contains_known_rules(&branch.consequent, bindings) + || contains_known_rules(&branch.alternate, bindings) + } + _ => false, + } +} + +#[cfg(test)] +pub(super) fn compose<'a>( + ast: &AstBuilder<'a>, + arguments: &[Argument<'a>], +) -> Option<(ExtractResult<'a>, Vec>)> { + compose_bound(ast, arguments, (None, "parts.tsx")) +} + +pub(super) fn compose_bound<'a>( + ast: &AstBuilder<'a>, + arguments: &[Argument<'a>], + (bindings, filename): (Option<&super::StyledBindings<'_>>, &str), +) -> Option<(ExtractResult<'a>, Vec>)> { + let mut composition = Composition::default(); + let mut classes = Vec::new(); + let mut result = ExtractResult::default(); + inputs::visit(ast, (arguments, bindings), |input| { + let mut rules = match input { + inputs::Input::Rules(rules) => rules.clone_in_with_semantic_ids(ast.allocator()), + inputs::Input::Class(class) => { + classes.push(class.clone_in_with_semantic_ids(ast.allocator())); + return; + } + inputs::Input::Prepared(crate::utils::StyleArguments { + classes: own, + rules, + }) => { + classes.extend(own); + rules + } + }; + let part = part::extract(ast, &mut rules, (bindings, filename)); + AuthoredStyles(part.styles).apply(ast, &mut composition); + result.style_order = part.style_order.or(result.style_order); + result.style_vars = part.style_vars.or_else(|| result.style_vars.take()); + result.props = part.props.or_else(|| result.props.take()); + })?; + result.styles = composition.into_props(); + Some((result, classes)) +} + +pub(super) fn admitted<'a>( + ast: &AstBuilder<'a>, + arguments: &[Argument<'a>], + bindings: Option<&super::StyledBindings<'_>>, +) -> bool { + inputs::visit(ast, (arguments, bindings), |_| {}).is_some() +} + +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_rule_records.rs b/libs/extractor/src/extractor/styled_rule_records.rs new file mode 100644 index 000000000..d982c55d2 --- /dev/null +++ b/libs/extractor/src/extractor/styled_rule_records.rs @@ -0,0 +1,44 @@ +use oxc_allocator::{CloneIn, FromIn, GetAllocator}; +use oxc_ast::{ast::Expression, builder::AstBuilder}; +use oxc_span::SPAN; + +pub(super) fn reserve<'a>( + builder: &AstBuilder<'a>, + scope: &mut Expression<'a>, + bindings: &[(String, Expression<'a>)], +) { + if bindings.is_empty() { + return; + } + let names = bindings.iter().map(|(name, _)| { + Expression::new_identifier( + SPAN, + oxc_ast::ast::Ident::from_in(name.as_str(), builder.allocator()), + builder, + ) + .into() + }); + let mut reserved = oxc_allocator::Vec::from_iter_in(names, builder); + reserved.extend( + bindings + .iter() + .map(|(_, value)| value.clone_in_with_semantic_ids(builder.allocator()).into()), + ); + reserved.push(scope.clone_in(builder.allocator()).into()); + *scope = Expression::new_array_expression(SPAN, reserved, builder); +} + +pub(super) fn prepare<'a>( + builder: &AstBuilder<'a>, + rules: &mut Expression<'a>, + (scope, bindings): (&mut Expression<'a>, &mut Vec<(String, Expression<'a>)>), +) { + if let Expression::ConditionalExpression(branch) = crate::utils::unwrap_syntax_only_mut(rules) { + prepare(builder, &mut branch.consequent, (scope, bindings)); + prepare(builder, &mut branch.alternate, (scope, bindings)); + } else if let Some(prepared) = crate::extractor::styled_record::prepare(builder, rules, scope) { + *rules = prepared.rules; + reserve(builder, scope, &prepared.bindings); + bindings.extend(prepared.bindings); + } +} diff --git a/libs/extractor/src/extractor/styled_scalar.rs b/libs/extractor/src/extractor/styled_scalar.rs new file mode 100644 index 000000000..6f543c9e2 --- /dev/null +++ b/libs/extractor/src/extractor/styled_scalar.rs @@ -0,0 +1,150 @@ +use oxc_allocator::{CloneIn, GetAllocator}; +use oxc_ast::{ast::Expression, builder::AstBuilder}; +use oxc_span::SPAN; +use oxc_syntax::operator::{BinaryOperator, LogicalOperator, UnaryOperator}; + +pub(in crate::extractor) enum Origin<'k> { + WholeRule(&'k str), + OrdinaryProperty(&'k str), +} + +pub(in crate::extractor) fn zero<'a>(builder: &AstBuilder<'a>) -> Expression<'a> { + Expression::new_numeric_literal( + SPAN, + 0.0, + None, + oxc_syntax::number::NumberBase::Decimal, + builder, + ) +} + +pub(in crate::extractor) fn length<'a>( + builder: &AstBuilder<'a>, + leaf: Expression<'a>, +) -> Expression<'a> { + let value = || Expression::new_identifier(SPAN, "__devupValue", builder); + let is_number = 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, + ); + let length = Expression::new_binary_expression( + SPAN, + value(), + BinaryOperator::Addition, + Expression::new_string_literal(SPAN, "px", None, builder), + 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, + zero(builder), + builder, + ), + builder, + ), + length, + value(), + builder, + ); + crate::utils::wrap_direct_call( + builder, + &super::named_arrow(builder, "__devupValue", length), + &[leaf], + ) +} + +/// The producer supplies a stable capture and its styled numeric origin, never an authored read. +pub(in crate::extractor) fn captured<'a>( + builder: &AstBuilder<'a>, + origin: Origin<'_>, + slot: Expression<'a>, +) -> Expression<'a> { + let (key, pixels) = match origin { + Origin::WholeRule(key) => (key, !crate::utils::keeps_bare_number(key)), + Origin::OrdinaryProperty(key) => (key, false), + }; + let read = || slot.clone_in(builder.allocator()); + let present = Expression::new_logical_expression( + SPAN, + Expression::new_binary_expression( + SPAN, + read(), + BinaryOperator::Inequality, + Expression::new_null_literal(SPAN, builder), + builder, + ), + LogicalOperator::And, + Expression::new_binary_expression( + SPAN, + read(), + BinaryOperator::StrictInequality, + Expression::new_boolean_literal(SPAN, false, builder), + builder, + ), + builder, + ); + let value = if pixels { + length(builder, read()) + } else if !crate::utils::keeps_bare_number(key) + || css::disassemble_property(key).all(|property| crate::utils::is_unitless_key(&property)) + { + read() + } else { + let numeric = Expression::new_logical_expression( + SPAN, + Expression::new_binary_expression( + SPAN, + Expression::new_unary_expression(SPAN, UnaryOperator::Typeof, read(), builder), + BinaryOperator::StrictEquality, + Expression::new_string_literal(SPAN, "number", None, builder), + builder, + ), + LogicalOperator::And, + Expression::new_binary_expression( + SPAN, + read(), + BinaryOperator::StrictInequality, + zero(builder), + builder, + ), + builder, + ); + let scaled = Expression::new_binary_expression( + SPAN, + Expression::new_binary_expression( + SPAN, + read(), + BinaryOperator::Multiplication, + Expression::new_numeric_literal( + SPAN, + 4.0, + None, + oxc_syntax::number::NumberBase::Decimal, + builder, + ), + builder, + ), + BinaryOperator::Addition, + Expression::new_string_literal(SPAN, "px", None, builder), + builder, + ); + Expression::new_conditional_expression(SPAN, numeric, scaled, read(), builder) + }; + Expression::new_conditional_expression( + SPAN, + present, + value, + Expression::new_null_literal(SPAN, builder), + builder, + ) +} diff --git a/libs/extractor/src/extractor/styled_stage_render.rs b/libs/extractor/src/extractor/styled_stage_render.rs new file mode 100644 index 000000000..1665cf0cb --- /dev/null +++ b/libs/extractor/src/extractor/styled_stage_render.rs @@ -0,0 +1,122 @@ +use super::super::{ + ATTRS_CONTEXT, ATTRS_VALUE, Base, Execution, STYLED_BASE, StyledJsx, StyledRender, + call_with_values, create_styled_component, identifier, member, merge_attrs_value, named_arrow, + resolve_attrs, spread_objects, with_property, wrap_direct_call, +}; +use super::expression; +use crate::utils::readable_code as code; +use oxc_allocator::{CloneIn, FromIn, GetAllocator}; +use oxc_ast::{ + ast::{Expression, Ident}, + builder::AstBuilder, +}; +use oxc_span::SPAN; + +pub(super) fn stage_element<'a>( + ast: &AstBuilder<'a>, + element: (Expression<'a>, Expression<'a>), + helper: &mut Option, +) -> Expression<'a> { + let (target, props) = element; + match helper { + Some(helper) => { + let raw = code(&props); + let rendered = helper.render( + ast, + target, + ( + expression(ast, &format!("{STYLED_BASE}.className")), + expression(ast, &format!("{STYLED_BASE}.style")), + expression(ast, &format!("{STYLED_BASE}.as")), + ), + identifier(ast, STYLED_BASE), + ); + expression(ast, &format!("({STYLED_BASE}=>{})({raw})", code(&rendered))) + } + None => call_with_values( + ast, + vec![(STYLED_BASE.to_string(), target)], + expression(ast, &format!("", code(&props))), + ), + } +} + +pub(super) fn stage_attrs<'a>( + ast: &AstBuilder<'a>, + attrs: &[Expression<'a>], + stage: Attrs<'_>, +) -> Expression<'a> { + let mut merged = expression(ast, stage.input); + for attr in attrs { + if stage.plain { + let spread = spread_objects(ast, merged, attr.clone_in(ast.allocator())); + let style = Expression::new_conditional_expression( + SPAN, + expression(ast, stage.hybrid), + spread_objects( + ast, + Expression::new_object_expression(SPAN, oxc_allocator::Vec::new_in(ast), ast), + member(ast, ATTRS_CONTEXT, "style"), + ), + member(ast, ATTRS_CONTEXT, "style"), + ast, + ); + let merged_style = with_property(ast, identifier(ast, ATTRS_CONTEXT), "style", style); + merged = wrap_direct_call( + ast, + &named_arrow(ast, ATTRS_CONTEXT, merged_style), + &[spread], + ); + continue; + } + let resolved = spread_objects( + ast, + Expression::new_object_expression(SPAN, oxc_allocator::Vec::new_in(ast), ast), + resolve_attrs(ast, attr.clone_in(ast.allocator())), + ); + let merge = call_with_values( + ast, + vec![(ATTRS_VALUE.to_string(), resolved)], + merge_attrs_value(ast), + ); + merged = wrap_direct_call(ast, &named_arrow(ast, ATTRS_CONTEXT, merge), &[merged]); + } + merged +} + +pub(super) struct Attrs<'s> { + pub input: &'s str, + pub hybrid: &'s str, + pub plain: bool, +} + +pub(super) fn terminal<'a>( + ast: &AstBuilder<'a>, + target: (&Base<'a>, &str), + helper: &mut Option, +) -> Expression<'a> { + let base = Base { + name: target.0.name.clone(), + target: Some(Expression::new_identifier( + SPAN, + Ident::from_in(target.1, ast.allocator()), + ast, + )), + styles: None, + bound: None, + }; + create_styled_component( + ast, + &base, + StyledRender { + class_name: &None, + style_vars: &None, + bindings: Vec::new(), + withheld: &[], + capability: None, + execution: &Execution::Replay, + markers: None, + }, + helper, + ) +} diff --git a/libs/extractor/src/extractor/styled_stages.rs b/libs/extractor/src/extractor/styled_stages.rs new file mode 100644 index 000000000..ed30e560b --- /dev/null +++ b/libs/extractor/src/extractor/styled_stages.rs @@ -0,0 +1,236 @@ +use super::{ + AstBuilder, Base, CloneIn, Evaluations, Execution, Expression, FromIn, GetAllocator, Naming, + SPAN, StyledDefinition, StyledJsx, gen_class_names, gen_styles, + merge_expression_for_class_name, residual, sets_plain_props, withheld, without_props, +}; +use crate::utils::readable_code as code; +use oxc_ast::ast::{Ident, Program}; +use oxc_span::SourceType; +#[path = "styled_stage_render.rs"] +mod render; +use render::{stage_attrs, stage_element}; + +pub(super) struct Context<'b, 'a, 's> { + pub base: &'b Base<'a>, + pub naming: Naming<'s>, + pub jsx: &'b mut Option, +} + +pub(super) fn expression<'a>(ast: &AstBuilder<'a>, code: &str) -> Expression<'a> { + Expression::new_identifier( + SPAN, + Ident::from_in(format!("({code})").as_str(), ast.allocator()), + ast, + ) +} + +pub(super) fn create<'a>( + ast: &AstBuilder<'a>, + definition: &StyledDefinition<'a>, + context: Context<'_, 'a, '_>, +) -> Option> { + let capability = definition.capability.as_ref()?; + if definition.fixed_summary() || definition.portable() { + return None; + } + let allocator = ast.allocator(); + let own = residual::selections(ast, definition.composition_styles(), context.naming); + let mut reservation = definition.code().collect::>().join(";"); + for (name, value) in &definition.bindings { + reservation.push_str(name); + reservation.push_str(&code(value)); + } + for attr in &definition.attrs { + reservation.push_str(&code(attr)); + } + for value in context.base.target.iter().chain(&context.base.bound) { + reservation.push_str(&code(value)); + } + match &definition.execution { + Execution::Staged { + renderer, retarget, .. + } => { + reservation.push_str(&code(&renderer.target)); + if let Some(value) = retarget { + reservation.push_str(&code(value)); + } + } + Execution::Replay + | Execution::RetainedFixed { .. } + | Execution::RetainedDynamic { .. } + | Execution::Delegated(_) => {} + } + let mut names = Evaluations::new(ast, reservation); + let mut fresh = || names.fresh_name(); + let input = fresh(); + let control = fresh(); + let clean = fresh(); + let props = fresh(); + let frame = fresh(); + let values = fresh(); + let result = fresh(); + let start = fresh(); + let attrs = fresh(); + let render = fresh(); + let caller = fresh(); + let dropped = fresh(); + let reserved = fresh(); + let hybrid = fresh(); + let own_default = fresh(); + let resolver = fresh(); + let resolved = fresh(); + let input_as = fresh(); + let plain = definition.attrs.iter().all(sets_plain_props); + let hybrid_value = if plain { + format!("{control}?{control}[2]:false") + } else { + "true".to_string() + }; + let slot = capability.classes.name(); + let forwarded_slot = capability.forwarded.name(); + let own_target = match &definition.execution { + Execution::Staged { retarget, .. } => retarget.as_ref().map_or_else( + || "(void 0)".to_string(), + |target| format!("()=>({})", code(target)), + ), + Execution::Replay + | Execution::RetainedFixed { .. } + | Execution::RetainedDynamic { .. } + | Execution::Delegated(_) => "(void 0)".to_string(), + }; + let resolve_target = format!( + "const {own_default}={own_target};const {resolver}=({control}&&{control}[3])??{own_default};" + ); + let class_values = own + .values + .iter() + .map(|value| value.as_ref().map_or_else(|| "\"\"".to_string(), code)) + .map(|value| format!("({value})")) + .collect::>() + .join(","); + let style_vars = gen_styles(ast, &definition.styles, None); + let variables = style_vars + .as_ref() + .map_or_else(|| "(void 0)".to_string(), code); + let marker_value = definition.render_markers(ast); + let has_markers = marker_value.is_some() || !definition.classes.is_empty(); + let markers = marker_value + .as_ref() + .map_or_else(|| "(void 0)".to_string(), code); + let opaque = definition + .classes + .iter() + .map(|value| format!("({})", code(value))) + .collect::>() + .join(","); + let statements = super::super::styled_record_evaluation::capture_statements( + ast, + definition + .bindings + .iter() + .map(|(name, value)| (name.clone(), value.clone_in(allocator))) + .collect(), + ); + let program = Program::new( + SPAN, + SourceType::d_ts(), + "", + oxc_allocator::Vec::new_in(ast), + None, + oxc_allocator::Vec::new_in(ast), + statements, + ast, + ); + let bindings = oxc_codegen::Codegen::new().build(&program).code; + let withheld = withheld( + &definition.reads, + definition.native(), + definition.forward.as_ref(), + ); + let forwarded = super::super::styled_transient::filter(ast, without_props(ast, &withheld)); + let finish = format!( + "{result}[4]({{...{},as:{result}[3],forwardedAs:{props}.forwardedAs,className:({result}[0].length||{result}[2]!==void 0)?[...{result}[0],{result}[2],{props}.className].filter(Boolean).join(' '):{props}.className,style:{result}[1]===void 0?{props}.style:{{...{result}[1],...{props}.style}}}})", + code(&forwarded), + ); + let dispatch = format!("{control}?(0,{control}[1])({props},{result}):{finish}"); + let merged_attrs = stage_attrs( + ast, + &definition.attrs, + render::Attrs { + input: &clean, + hybrid: &hybrid, + plain, + }, + ); + let code = match &definition.execution { + Execution::Staged { + renderer, shape, .. + } => { + let keys = shape.merge(&own.shape, &frame, &values); + let variables = if style_vars.is_some() { + format!("{{...{frame}[1],...{variables}}}") + } else { + format!("{frame}[1]") + }; + let markers = if has_markers { + format!("[{frame}[2],{markers},{opaque}].filter(Boolean).join(' ')") + } else { + format!("{frame}[2]") + }; + let body = format!( + "({props},{frame})=>{{const {{style,className,as:DevupAs={frame}[3],forwardedAs}}={props};const rest={frame}[5];{bindings}const {values}=[{class_values}];const {result}=[{keys},{variables},{markers},{frame}[3],{frame}[4],rest];return {dispatch};}}" + ); + let next = format!( + "({clean},{start},{hybrid})=>{{const {attrs}={};return {control}?(0,{control}[0])({attrs},{start},{hybrid}):{start}({attrs});}}", + code(&merged_attrs) + ); + let incoming = format!( + "{{...{clean},{}:[{attrs},{render},{hybrid},{resolver}]}}", + renderer.capability.classes.name() + ); + let target = renderer.target.clone_in(allocator); + let element = stage_element(ast, (target, expression(ast, &incoming)), context.jsx); + format!( + "{input}=>{{const {{{slot}:{control},{forwarded_slot}:{reserved},...{clean}}}={input};{resolve_target}const {hybrid}={hybrid_value};const {attrs}={next};const {render}={body};return {};}}", + code(&element) + ) + } + Execution::Replay => { + let mut ordinary = definition + .styles + .iter() + .map(|value| value.clone_in(allocator)) + .collect::>(); + let ordinary = merge_expression_for_class_name( + ast, + gen_class_names(ast, &mut ordinary, None, context.naming.split_filename), + ); + let ordinary = ordinary + .as_ref() + .map_or_else(|| "[]".to_string(), |value| format!("[{}]", code(value))); + let markers = if has_markers { + format!("[{markers},{opaque}].filter(Boolean).join(' ')") + } else { + "(void 0)".to_string() + }; + let terminal = render::terminal(ast, (context.base, &resolved), context.jsx); + let target = context + .base + .target + .as_ref() + .map_or_else(|| format!("{:?}", context.base.name), code); + let style_body = format!( + "({props})=>{{const {{style,className,as:{input_as},forwardedAs,...rest}}={props};let DevupAs={input_as};if(DevupAs===void 0){{if({resolver}){{DevupAs = (0,{resolver})();}}else{{DevupAs = {target};}}}}const {resolved}=DevupAs;{bindings}const {result}=[{control}?[{class_values}]:{ordinary},{variables},{markers},{resolved},{},rest];return {dispatch};}}", + code(&terminal) + ); + format!( + "{input}=>{{const {{{slot}:{control},{forwarded_slot}:{reserved},...{dropped}}}={input};{resolve_target}const {hybrid}={hybrid_value};const {caller}={dropped}.className;const {clean}={{...{dropped},className:void 0}};const {render}={style_body};const {start}={props}=>{render}({{...{props},className:{hybrid}?([{props}.className,{caller}].filter(Boolean).join(' ')||void 0):{caller}}});const {attrs}={};return {control}?(0,{control}[0])({attrs},{start},{hybrid}):{start}({attrs});}}", + code(&merged_attrs) + ) + } + Execution::RetainedFixed { .. } + | Execution::RetainedDynamic { .. } + | Execution::Delegated(_) => return None, + }; + Some(expression(ast, &code)) +} diff --git a/libs/extractor/src/extractor/styled_statement_tests.rs b/libs/extractor/src/extractor/styled_statement_tests.rs new file mode 100644 index 000000000..2f51e1820 --- /dev/null +++ b/libs/extractor/src/extractor/styled_statement_tests.rs @@ -0,0 +1,158 @@ +use super::super::*; +use rstest::rstest; +use std::fmt::Write; + +#[rstest] +#[case("p => p.on ? {opacity: 0.4} : p.rules")] +#[case("function(p) { return p.on ? {opacity: 0.4} : p.rules; }")] +#[serial_test::serial] +fn unknown_written_callback_branch_when_top_level_reports_original_interpolation( + #[case] callback: &str, +) -> Result<(), Box> { + // Given + let source = format!( + "import {{ styled }} from '@devup-ui/react';\nexport const Choice = styled.div`${{{callback}}};`;" + ); + let column = source + .rsplit('\n') + .next() + .ok_or("missing fixture line")? + .find(callback) + .ok_or("missing fixture callback")? + + 1; + // When + let error = crate::extract( + "unknown-callback.tsx", + &source, + crate::ExtractOption::default(), + ) + .err() + .ok_or("unknown written callback branch compiled")? + .to_string(); + // Then + assert!( + error.starts_with(&format!("unknown-callback.tsx:2:{column}:")), + "{error}" + ); + assert!( + error.contains("p.rules") && error.contains("object literal"), + "{error}" + ); + Ok(()) +} + +#[rstest] +#[case("p => p.on && p.external")] +#[case("function(p) { return p.on && p.external; }")] +#[serial_test::serial] +fn opaque_statement_callback_when_top_level_is_called_with_props_and_false_becomes_empty( + #[case] callback: &str, + #[values(false, true)] on: bool, +) -> Result<(), Box> { + // Given + let allocator = oxc_allocator::Allocator::default(); + let ast = AstBuilder::new(&allocator); + let source = format!("styled.div`${{{callback}}};`"); + let mut expression = Parser::new(&allocator, &source, SourceType::tsx()) + .parse_expression() + .map_err(|error| format!("{error:?}"))?; + let values = crate::style_values::StyleValues::default(); + let inline_css = FxHashMap::default(); + // When + let output = extract_style_from_styled( + &ast, + &mut expression, + Naming::default(), + StyledBindings { + imports: &|_| None, + values: &values, + inline_css: &inline_css, + }, + (&[], None, &[]), + None, + StyledContext { + filename: "statement.tsx", + jsx: &mut None, + capability: None, + }, + ); + // Then: opaque class callbacks legitimately reach the statement fallback. + assert_eq!(output.errors, vec![]); + let definition = output.definition.ok_or("missing callback definition")?; + assert_eq!( + definition.reads, + Reads { + names: vec!["on".into(), "external".into()], + whole: false + } + ); + assert_eq!(definition.classes.len(), 1); + let class = definition + .classes + .first() + .ok_or("expected callback class expression")?; + let code = expression_to_code(class); + let mut schedule = String::new(); + for (name, value) in &definition.bindings { + write!( + schedule, + "const {name} = ({});", + crate::css_utils::rm_last_semi_colon(&expression_to_code(value)) + )?; + } + let selected = super::super::props_rule_choice_tests::support::evaluate( + &format!( + "(()=>{{{schedule}return {};}})()", + crate::css_utils::rm_last_semi_colon(&code) + ), + &format!("{{on: {on}, external: 'external-class'}}"), + ); + assert_eq!(selected, if on { "external-class" } else { "" }); + Ok(()) +} + +#[test] +fn uncurried_styled_when_second_argument_is_callback_stays_object_only() +-> Result<(), Box> { + // Given + let allocator = oxc_allocator::Allocator::default(); + let ast = AstBuilder::new(&allocator); + let source = "styled('div', p => ({opacity: p.opacity}))"; + let mut expression = Parser::new(&allocator, source, SourceType::tsx()) + .parse_expression() + .map_err(|error| format!("{error:?}"))?; + let values = crate::style_values::StyleValues::default(); + let inline_css = FxHashMap::default(); + // When + let output = extract_style_from_styled( + &ast, + &mut expression, + Naming::default(), + StyledBindings { + imports: &|_| None, + values: &values, + inline_css: &inline_css, + }, + (&[], None, &[]), + None, + StyledContext { + filename: "statement.tsx", + jsx: &mut None, + capability: None, + }, + ); + // Then + assert!(output.definition.is_none()); + assert_eq!( + output.errors, + vec![( + 0, + build_time_error("styled", &readable_code(&expression), STYLED_FACTORY) + )] + ); + assert_eq!( + expression_to_code(&output.expression), + expression_to_code(&expression) + ); + Ok(()) +} diff --git a/libs/extractor/src/extractor/styled_template_bindings.rs b/libs/extractor/src/extractor/styled_template_bindings.rs new file mode 100644 index 000000000..758799cbd --- /dev/null +++ b/libs/extractor/src/extractor/styled_template_bindings.rs @@ -0,0 +1,109 @@ +use oxc_allocator::{CloneIn, GetAllocator}; +use oxc_ast::{ast::Expression, builder::AstBuilder}; + +use super::{StyledBindings, identifier, props, records}; +use crate::{ + extractor::{rule_array_evaluation, styled_record_evaluation::Evaluations}, + utils::{readable_code, unwrap_syntax_only, wrap_direct_call}, +}; + +pub(super) struct Schedule<'b, 'a> { + pub builder: &'b AstBuilder<'a>, + scope: Expression<'a>, + pub bindings: Vec<(String, Expression<'a>)>, +} + +impl<'b, 'a> Schedule<'b, 'a> { + pub fn new(builder: &'b AstBuilder<'a>, scope: &Expression<'a>) -> Self { + Self { + builder, + scope: scope.clone_in(builder.allocator()), + bindings: Vec::new(), + } + } + + pub fn scalar(&mut self, expression: &mut Expression<'a>) -> Result<(), (u32, String)> { + if crate::css_utils::reads_unmapped_theme(expression) { + return Err(crate::css_utils::unmapped_theme_error("styled", expression)); + } + if crate::utils::get_string_by_literal_expression(expression).is_some() + || crate::utils::js_number_literal(expression).is_some() + || matches!( + unwrap_syntax_only(expression), + Expression::NullLiteral(_) | Expression::BooleanLiteral(_) + ) + || crate::css_utils::theme_var_reference(expression).is_some() + { + return Ok(()); + } + let value = match unwrap_syntax_only(expression) { + Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_) => { + wrap_direct_call( + self.builder, + expression, + &[identifier(self.builder, "rest")], + ) + } + _ => expression.clone_in_with_semantic_ids(self.builder.allocator()), + }; + let mut evaluations = Evaluations::new(self.builder, readable_code(&self.scope)); + *expression = evaluations.capture(&value, &evaluations.boolean(true)); + self.commit(evaluations.bindings); + Ok(()) + } + + pub fn rules( + &mut self, + expression: &mut Expression<'a>, + bindings: &StyledBindings<'_>, + ) -> Result<(), (u32, String)> { + let mut evaluations = Evaluations::new(self.builder, readable_code(&self.scope)); + match unwrap_syntax_only(expression) { + Expression::ArrowFunctionExpression(_) + | Expression::FunctionExpression(_) + | Expression::ObjectExpression(_) => { + match props::lower(&mut evaluations, expression, Some(bindings))? { + Some(props::Plan::Whole(invocation)) => evaluations + .bindings + .push((invocation.name, invocation.value)), + Some(props::Plan::Properties(original)) => { + if let Some(prepared) = crate::extractor::styled_record::prepare_callbacks( + &mut evaluations, + &original, + ) { + *expression = prepared; + } + } + None => {} + } + } + _ => props::lower_direct(&mut evaluations, expression, (bindings, &self.scope))?, + } + if !matches!( + unwrap_syntax_only(expression), + Expression::ObjectExpression(_) + ) && let Some(prepared) = + rule_array_evaluation::prepare(&mut evaluations, expression, |value| { + bindings.styles(value).is_some() + }) + { + *expression = prepared; + } + self.commit(evaluations.bindings); + records::prepare( + self.builder, + expression, + (&mut self.scope, &mut self.bindings), + ); + Ok(()) + } + + fn commit(&mut self, bindings: Vec<(String, Expression<'a>)>) { + records::reserve(self.builder, &mut self.scope, &bindings); + self.bindings.extend(bindings); + } +} + +#[cfg(test)] +#[path = "template_planning_tests.rs"] +mod tests; 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..fad46d1a5 --- /dev/null +++ b/libs/extractor/src/extractor/styled_template_composition.rs @@ -0,0 +1,148 @@ +use super::{StyledBindings, template_bindings::Schedule}; +use crate::{ + ExtractStyleProp, + composition::Composition, + css_utils::css_to_style_template, + extractor::style_order_projection::AuthoredStyles, + utils::{build_time_error, readable_code, unplaced_error, unwrap_syntax_only}, +}; +use oxc_allocator::{CloneIn, GetAllocator}; +use oxc_ast::{ + ast::{Expression, TemplateLiteral}, + builder::AstBuilder, +}; +use oxc_span::GetSpan; + +#[path = "styled_mixin_context.rs"] +mod context; +#[path = "styled_template_parts.rs"] +mod parts; + +#[path = "styled_template_mixin.rs"] +mod mixins; + +#[cfg(test)] +use mixins::MixinReader; + +pub(super) struct TemplateComposition<'a> { + pub styles: Vec>, + pub statements: Vec>, + pub bindings: Vec<(String, Expression<'a>)>, + pub errors: Vec<(u32, String)>, +} + +#[cfg(test)] +#[path = "styled_template_mixin_tests.rs"] +mod coverage_tests; + +#[cfg(test)] +pub(super) fn compose<'a>( + ast: &AstBuilder<'a>, + template: &TemplateLiteral<'a>, + bindings: &StyledBindings<'_>, +) -> TemplateComposition<'a> { + let scope = Expression::TemplateLiteral(oxc_allocator::Box::new_in( + template.clone_in(ast.allocator()), + ast, + )); + compose_in(ast, template, (bindings, &scope, "template.tsx")) +} + +pub(super) fn compose_in<'a>( + ast: &AstBuilder<'a>, + template: &TemplateLiteral<'a>, + (bindings, scope, filename): (&StyledBindings<'_>, &Expression<'a>, &str), +) -> TemplateComposition<'a> { + let mut composition = Composition::default(); + let mut statements = Vec::new(); + let mut errors = Vec::new(); + let mut schedule = Schedule::new(ast, scope); + for part in parts::split(ast, template, |expression| { + bindings.styles(expression).is_some() + }) { + match part { + parts::Part::Text(mut text) => { + for expression in &mut text.expressions { + if let Err(error) = schedule.scalar(expression) { + errors.push(error); + } + } + AuthoredStyles( + css_to_style_template(&text, 0, &None) + .styles + .into_iter() + .map(|style| ExtractStyleProp::Static(style.into())) + .collect(), + ) + .registered(filename) + .apply(ast, &mut composition); + } + 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 mixins::read(&mut schedule, expression, bindings) { + Ok(Some(props)) => match context::contextualize(props, &context) { + Ok(props) => AuthoredStyles(props) + .registered(filename) + .apply(ast, &mut composition), + Err(context::ContextError::Never | context::ContextError::Opaque) => { + errors.push(nested_error(expression)); + } + }, + Ok(None) if context.is_empty() => { + let callback = matches!( + unwrap_syntax_only(expression), + Expression::ArrowFunctionExpression(_) + | Expression::FunctionExpression(_) + ); + let mut value = expression.clone_in_with_semantic_ids(ast.allocator()); + if let Err(error) = schedule.scalar(&mut value) { + errors.push(error); + } + if callback { + value = Expression::new_logical_expression( + expression.span(), + value, + oxc_syntax::operator::LogicalOperator::Or, + Expression::new_string_literal(oxc_span::SPAN, "", None, ast), + ast, + ); + } + statements.push(value); + } + Ok(None) => errors.push(nested_error(expression)), + Err(error) => errors.push(error), + } + } + } + } + TemplateComposition { + styles: composition.into_props(), + statements, + bindings: schedule.bindings, + 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", + ), + ) +} + +#[cfg(test)] +fn mixin<'a>( + schedule: &mut Schedule<'_, 'a>, + expression: &Expression<'a>, + bindings: &StyledBindings<'_>, +) -> Result>>, (u32, String)> { + mixins::read(schedule, expression, bindings) +} diff --git a/libs/extractor/src/extractor/styled_template_mixin.rs b/libs/extractor/src/extractor/styled_template_mixin.rs new file mode 100644 index 000000000..b9366c484 --- /dev/null +++ b/libs/extractor/src/extractor/styled_template_mixin.rs @@ -0,0 +1,127 @@ +use super::super::{StyledBindings, rule_choices, template_bindings::Schedule}; +use crate::{ + ExtractStyleProp, + extractor::style_order_projection::extract_with_order, + utils::{build_time_error, readable_code, unwrap_syntax_only}, +}; +use oxc_allocator::{CloneIn, GetAllocator}; +use oxc_ast::{ast::Expression, builder::AstBuilder}; +use oxc_span::GetSpan; + +pub(super) fn read<'a>( + schedule: &mut Schedule<'_, 'a>, + expression: &Expression<'a>, + bindings: &StyledBindings<'_>, +) -> Result>>, (u32, String)> { + let ast = schedule.builder; + let invalid = || { + ( + expression.span().start, + build_time_error( + "styled", + &readable_code(expression), + crate::utils::STYLE_OBJECT, + ), + ) + }; + let mut expression = unwrap_syntax_only(expression).clone_in_with_semantic_ids(ast.allocator()); + let rules = match &expression { + Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_) => { + let Some((_, rules)) = crate::css_prop::render_function(&mut expression) else { + return Ok(None); + }; + rules.clone_in_with_semantic_ids(ast.allocator()) + } + _ => expression.clone_in_with_semantic_ids(ast.allocator()), + }; + match rule_choices::normalize(ast, &rules, bindings) { + Ok(_) => {} + Err(rule_choices::RuleError::BoundUndefined) => return Err(invalid()), + Err(rule_choices::RuleError::Opaque) => { + return if written_object(&rules) { + Err(invalid()) + } else { + Ok(None) + }; + } + } + let fixed = matches!( + expression, + Expression::Identifier(_) | Expression::StringLiteral(_) + ) && bindings.styles(&expression).is_some_and(|styles| { + styles.iter().all(|style| { + matches!( + style, + crate::ExtractStyleValue::Static(_) | crate::ExtractStyleValue::LayerOrder(_) + ) + }) + }); + if !fixed { + schedule.rules(&mut expression, bindings)?; + } + MixinReader { ast, bindings } + .read(&mut expression) + .map(Some) + .ok_or_else(invalid) +} + +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, + } +} + +pub(super) struct MixinReader<'s, 'a, 'b> { + pub ast: &'s AstBuilder<'a>, + pub bindings: &'s StyledBindings<'b>, +} + +impl<'a> MixinReader<'_, 'a, '_> { + pub fn read(&self, rules: &mut Expression<'a>) -> Option>> { + let Self { ast, bindings } = *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 = branch.test.clone_in_with_semantic_ids(ast.allocator()); + let yes = self.read(&mut branch.consequent)?; + let no = self.read(&mut branch.alternate)?; + Some(vec![ExtractStyleProp::Conditional { + condition: test, + consequent: Some(Box::new(ExtractStyleProp::StaticArray(yes))), + alternate: Some(Box::new(ExtractStyleProp::StaticArray(no))), + }]) + } + Expression::ObjectExpression(_) => { + let (_, authored) = extract_with_order( + ast, + None, + rules, + 0, + &None, + super::super::LiteralHandling::ExpandResponsiveThemeToken, + ); + let styles = authored.0; + 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_mixin_tests.rs b/libs/extractor/src/extractor/styled_template_mixin_tests.rs new file mode 100644 index 000000000..b6614a95b --- /dev/null +++ b/libs/extractor/src/extractor/styled_template_mixin_tests.rs @@ -0,0 +1,196 @@ +use super::*; +use crate::extract_style::{ExtractStyleProperty, extract_static_style::ExtractStaticStyle}; +use rstest::rstest; +use std::fmt::Write; + +#[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 values = crate::style_values::StyleValues::default(); + let inline_css = rustc_hash::FxHashMap::default(); + let bindings = StyledBindings { + imports: &|_| None, + 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 schedule = Schedule::new(&ast, &expression); + let mut styles = mixin(&mut schedule, &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 mut captured = String::new(); + for (name, value) in &schedule.bindings { + write!( + captured, + "const {name}=({});", + crate::css_utils::rm_last_semi_colon(&crate::utils::expression_to_code(value)) + )?; + } + let code = format!( + "const rest={{}};const on = {on}; {captured} {}", + 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 source = source.replace("(known)", "('known')"); + 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 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: &|_| None, + values: &values, + inline_css: &inline_css, + }; + // When + let mut schedule = Schedule::new(&ast, &expression); + let mut styles = mixin(&mut schedule, &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 mut captured = String::new(); + for (name, value) in &schedule.bindings { + write!( + captured, + "const {name}=({});", + crate::css_utils::rm_last_semi_colon(&crate::utils::expression_to_code(value)) + )?; + } + let selected = super::super::props_rule_choice_tests::support::evaluate( + &format!( + "(()=>{{{}return {};}})()", + captured, + 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 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: &|_| None, + 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 values = crate::style_values::StyleValues::default(); + let inline_css = rustc_hash::FxHashMap::default(); + let bindings = StyledBindings { + imports: &|_| None, + 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, + } + .read(&mut rules); + // Then + assert!(result.is_none()); +} diff --git a/libs/extractor/src/extractor/styled_template_part_tests.rs b/libs/extractor/src/extractor/styled_template_part_tests.rs new file mode 100644 index 000000000..db19ae3f6 --- /dev/null +++ b/libs/extractor/src/extractor/styled_template_part_tests.rs @@ -0,0 +1,68 @@ +use super::*; +use crate::css_utils::Place; +use rstest::rstest; + +#[rstest] +#[case("`&${'.active'};`", "selector")] +#[case("`color: ${'red'};`", "value")] +#[case("`${'color: red;'};`", "statement")] +fn literal_prefix_when_nonempty_is_selector_context_not_value_or_statement( + #[case] source: &str, + #[case] expected: &str, +) -> Result<(), Box> { + // Given + let allocator = oxc_allocator::Allocator::default(); + let expression = oxc_parser::Parser::new(&allocator, source, oxc_span::SourceType::tsx()) + .parse_expression() + .map_err(|error| format!("{error:?}"))?; + let template = match expression { + Expression::TemplateLiteral(template) => Some(template), + _ => None, + } + .ok_or("expected template fixture")?; + // When + let actual = place(&template.quasis[0].value.raw, &template.quasis[1..]); + // Then + assert_eq!( + match actual { + Place::Other => "selector", + Place::Value => "value", + Place::Statement => "statement", + }, + expected + ); + Ok(()) +} + +#[test] +fn unknown_suffix_when_selector_prefix_is_nonempty_preserves_interpolation_index() +-> Result<(), Box> { + // Given + let allocator = oxc_allocator::Allocator::default(); + let ast = AstBuilder::new(&allocator); + let source = "`&${suffix};`"; + let expression = oxc_parser::Parser::new(&allocator, source, oxc_span::SourceType::tsx()) + .parse_expression() + .map_err(|error| format!("{error:?}"))?; + let template = match expression { + Expression::TemplateLiteral(template) => Some(template), + _ => None, + } + .ok_or("expected template fixture")?; + // When + let parts = split(&ast, &template, |_| false); + // Then + assert!(matches!( + parts.as_slice(), + [Part::Unplaced(0), Part::Text(_)] + )); + let text = match &parts[1] { + Part::Text(text) => Some(text), + Part::Mixin { .. } | Part::Unplaced(_) => None, + } + .ok_or("expected preserved template text")?; + assert_eq!(text.quasis[0].value.raw, "&"); + assert_eq!(text.quasis[1].value.raw, ";"); + assert_eq!(crate::utils::readable_code(&text.expressions[0]), "suffix"); + Ok(()) +} 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..6ffd2c28c --- /dev/null +++ b/libs/extractor/src/extractor/styled_template_parts.rs @@ -0,0 +1,175 @@ +use oxc_allocator::{CloneIn, FromIn, GetAllocator}; +use oxc_ast::{ + ast::{Expression, Str, TemplateElement, TemplateElementValue, TemplateLiteral}, + builder::AstBuilder, +}; + +#[cfg(test)] +#[path = "styled_template_part_tests.rs"] +mod coverage_tests; + +// 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 mut rest = before.to_string(); + rest.extend(after.iter().map(|quasi| quasi.value.raw.as_str())); + if blocks::boundaries(&rest) + .find(|(index, _)| *index >= before.len()) + .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/template_planning_tests.rs b/libs/extractor/src/extractor/template_planning_tests.rs new file mode 100644 index 000000000..32041c022 --- /dev/null +++ b/libs/extractor/src/extractor/template_planning_tests.rs @@ -0,0 +1,57 @@ +use super::*; + +#[test] +fn template_when_opaque_callback_reads_unmapped_theme_retains_the_located_error() { + // Given + let arena = oxc_allocator::Allocator::default(); + let ast = AstBuilder::new(&arena); + let expression = oxc_parser::Parser::new( + &arena, + "`${p=>p.theme.brand()};`", + oxc_span::SourceType::tsx(), + ) + .parse_expression() + .unwrap_or_else(|error| panic!("{error:?}")); + let Expression::TemplateLiteral(template) = expression else { + panic!("template") + }; + let values = crate::style_values::StyleValues::default(); + let inline_css = rustc_hash::FxHashMap::default(); + let bindings = StyledBindings { + imports: &|_| None, + values: &values, + inline_css: &inline_css, + }; + // When + let result = super::super::template_composition::compose(&ast, &template, &bindings); + // Then + assert_eq!(result.errors.len(), 1); + assert!(result.errors[0].1.contains("theme")); + assert_eq!(result.styles.len(), 0); + assert_eq!(result.bindings.len(), 0); +} + +#[test] +fn projected_selectors_when_key_is_dynamic_keep_the_refusal_in_both_views() { + // Given + let arena = oxc_allocator::Allocator::default(); + let ast = AstBuilder::new(&arena); + let mut expression = oxc_parser::Parser::new( + &arena, + "({[selector]:{color:'red'}})", + oxc_span::SourceType::tsx(), + ) + .parse_expression() + .unwrap_or_else(|error| panic!("{error:?}")); + // When + let (raw, authored) = crate::extractor::style_order_projection::extract_with_order( + &ast, Some("selectors"), &mut expression, 0, &None, + crate::extractor::extract_style_from_expression::LiteralHandling::ExpandResponsiveThemeToken, + ); + // Then + for props in [&raw.styles, &authored.0] { + assert!( + matches!(props.as_slice(), [crate::ExtractStyleProp::Unreadable{code,prop:false,..}] if code == "[selector]") + ); + } +} diff --git a/libs/extractor/src/implicit_props_theme.rs b/libs/extractor/src/implicit_props_theme.rs new file mode 100644 index 000000000..567278ec6 --- /dev/null +++ b/libs/extractor/src/implicit_props_theme.rs @@ -0,0 +1,234 @@ +//! Error detection for alternate access to a callback's actual props argument. + +use oxc_ast::ast::{ + ArrowFunctionBody, ArrowFunctionExpression, BindingPattern, BlockStatement, CatchClause, Class, + ComputedMemberExpression, Expression, ForInStatement, ForOfStatement, ForStatement, + ForStatementInit, ForStatementLeft, Function, FunctionBody, StaticBlock, + StaticMemberExpression, SwitchStatement, +}; +use oxc_ast_visit::{Visit, walk}; +use oxc_syntax::scope::ScopeFlags; + +use crate::utils::{get_string_by_literal_expression, unwrap_syntax_only}; + +#[path = "implicit_props_theme_scope.rs"] +mod scope; +use super::theme_callback::{Body, Callback}; +use scope::Bindings; + +#[cfg(test)] +#[path = "implicit_props_theme_tests.rs"] +mod tests; + +/// These roots deliberately do not participate in static theme-path mapping. +pub(crate) fn reads(expr: &Expression<'_>) -> bool { + let Some(Callback { params, body }) = Callback::read(unwrap_syntax_only(expr)) else { + return false; + }; + let arguments = matches!(body, Body::Function(_)); + let rest = if params.items.is_empty() { + params + .rest + .as_ref() + .and_then(|rest| match &rest.rest.argument { + BindingPattern::BindingIdentifier(identifier) => Some(identifier.name.as_str()), + _ => None, + }) + } else { + None + }; + let mut reads = ThemeReads { + roots: Roots { arguments, rest }, + found: false, + }; + // The callback itself establishes roots; nested functions only inherit rest. + let mut bindings = Bindings::new(reads.roots); + bindings.visit_formal_parameters(params); + reads.roots.arguments &= !bindings.arguments; + reads.visit_formal_parameters(params); + match body { + Body::Function(body) => { + if let Some(body) = body { + reads.visit_root_body(body); + } + } + Body::Arrow(body) => match body { + ArrowFunctionBody::FunctionBody(body) => reads.visit_root_body(body), + body => reads.visit_arrow_function_body(body), + }, + } + reads.found +} + +#[derive(Clone, Copy)] +struct Roots<'n> { + arguments: bool, + rest: Option<&'n str>, +} + +impl Roots<'_> { + fn is_props(self, expr: &Expression<'_>) -> bool { + let Expression::ComputedMemberExpression(member) = unwrap_syntax_only(expr) else { + return false; + }; + if get_string_by_literal_expression(&member.expression).is_none_or(|key| key != "0") { + return false; + } + matches!(unwrap_syntax_only(&member.object), Expression::Identifier(identifier) + if (self.arguments && identifier.name == "arguments") + || self.rest.is_some_and(|name| identifier.name == name)) + } + + fn shadowed(self, bindings: &Bindings<'_>) -> Self { + Self { + arguments: self.arguments && !bindings.arguments, + rest: self.rest.filter(|_| !bindings.rest), + } + } +} + +struct ThemeReads<'n> { + roots: Roots<'n>, + found: bool, +} + +impl ThemeReads<'_> { + fn visit_root_body(&mut self, body: &FunctionBody<'_>) { + let saved = self.roots; + // Root `var` redeclarations retain the rest parameter / own arguments root. + // With non-simple parameters the body's var binding copies that value. + // Function declarations can replace that value, so still mask them here. + self.roots = saved.shadowed(&Bindings::lexical(saved, &body.statements)); + walk::walk_function_body(self, body); + self.roots = saved; + } +} + +impl<'a> Visit<'a> for ThemeReads<'_> { + fn visit_static_member_expression(&mut self, it: &StaticMemberExpression<'a>) { + self.found |= it.property.name == "theme" && self.roots.is_props(&it.object); + walk::walk_static_member_expression(self, it); + } + + fn visit_computed_member_expression(&mut self, it: &ComputedMemberExpression<'a>) { + self.found |= get_string_by_literal_expression(&it.expression) + .is_some_and(|key| key == "theme") + && self.roots.is_props(&it.object); + walk::walk_computed_member_expression(self, it); + } + + fn visit_function(&mut self, it: &Function<'a>, flags: ScopeFlags) { + let saved = self.roots; + let mut bindings = Bindings::new(saved); + bindings.visit_formal_parameters(&it.params); + if let Some(id) = &it.id { + bindings.visit_binding_identifier(id); + } + self.roots = saved.shadowed(&bindings); + self.roots.arguments = false; + walk::walk_function(self, it, flags); + self.roots = saved; + } + + fn visit_arrow_function_expression(&mut self, it: &ArrowFunctionExpression<'a>) { + let saved = self.roots; + let mut bindings = Bindings::new(saved); + bindings.visit_formal_parameters(&it.params); + self.roots = saved.shadowed(&bindings); + walk::walk_arrow_function_expression(self, it); + self.roots = saved; + } + + fn visit_function_body(&mut self, it: &FunctionBody<'a>) { + let saved = self.roots; + self.roots = saved + .shadowed(&Bindings::function_vars(saved, &it.statements)) + .shadowed(&Bindings::lexical(saved, &it.statements)); + walk::walk_function_body(self, it); + self.roots = saved; + } + + fn visit_block_statement(&mut self, it: &BlockStatement<'a>) { + let saved = self.roots; + self.roots = saved.shadowed(&Bindings::lexical(saved, &it.body)); + walk::walk_block_statement(self, it); + self.roots = saved; + } + fn visit_catch_clause(&mut self, it: &CatchClause<'a>) { + let saved = self.roots; + let mut bindings = Bindings::new(saved); + if let Some(param) = &it.param { + bindings.visit_binding_pattern(¶m.pattern); + } + self.roots = saved.shadowed(&bindings); + walk::walk_catch_clause(self, it); + self.roots = saved; + } + + fn visit_for_statement(&mut self, it: &ForStatement<'a>) { + let saved = self.roots; + let mut bindings = Bindings::new(saved); + if let Some(ForStatementInit::VariableDeclaration(declaration)) = &it.init { + bindings.lexical_declaration(declaration); + } + self.roots = saved.shadowed(&bindings); + walk::walk_for_statement(self, it); + self.roots = saved; + } + + fn visit_for_in_statement(&mut self, it: &ForInStatement<'a>) { + let saved = self.roots; + let mut bindings = Bindings::new(saved); + if let ForStatementLeft::VariableDeclaration(declaration) = &it.left { + bindings.lexical_declaration(declaration); + } + self.roots = saved.shadowed(&bindings); + walk::walk_for_in_statement(self, it); + self.roots = saved; + } + + fn visit_for_of_statement(&mut self, it: &ForOfStatement<'a>) { + let saved = self.roots; + let mut bindings = Bindings::new(saved); + if let ForStatementLeft::VariableDeclaration(declaration) = &it.left { + bindings.lexical_declaration(declaration); + } + self.roots = saved.shadowed(&bindings); + walk::walk_for_of_statement(self, it); + self.roots = saved; + } + + fn visit_switch_statement(&mut self, it: &SwitchStatement<'a>) { + self.visit_expression(&it.discriminant); + let saved = self.roots; + for case in &it.cases { + self.roots = self + .roots + .shadowed(&Bindings::lexical(saved, &case.consequent)); + } + for case in &it.cases { + self.visit_switch_case(case); + } + self.roots = saved; + } + + fn visit_class(&mut self, it: &Class<'a>) { + let saved = self.roots; + let mut bindings = Bindings::new(saved); + if let Some(id) = &it.id { + bindings.visit_binding_identifier(id); + } + self.roots = saved.shadowed(&bindings); + walk::walk_class(self, it); + self.roots = saved; + } + + fn visit_static_block(&mut self, it: &StaticBlock<'a>) { + let saved = self.roots; + self.roots = saved + .shadowed(&Bindings::function_vars(saved, &it.body)) + .shadowed(&Bindings::lexical(saved, &it.body)); + walk::walk_static_block(self, it); + self.roots = saved; + } +} diff --git a/libs/extractor/src/implicit_props_theme_scope.rs b/libs/extractor/src/implicit_props_theme_scope.rs new file mode 100644 index 000000000..92d927c9b --- /dev/null +++ b/libs/extractor/src/implicit_props_theme_scope.rs @@ -0,0 +1,93 @@ +//! Binding ownership for the implicit callback-props detector. + +use oxc_ast::ast::{ + BindingIdentifier, Class, Expression, Function, Statement, VariableDeclaration, + VariableDeclarationKind, +}; +use oxc_ast_visit::Visit; +use oxc_syntax::scope::ScopeFlags; + +use super::Roots; + +/// Only binding names are collected; pattern defaults never introduce bindings here. +pub(super) struct Bindings<'n> { + roots: Roots<'n>, + pub(super) arguments: bool, + pub(super) rest: bool, +} + +impl<'n> Bindings<'n> { + pub(super) const fn new(roots: Roots<'n>) -> Self { + Self { + roots, + arguments: false, + rest: false, + } + } + + /// Direct declarations belong to this block, not to its catch/loop/switch children. + pub(super) fn lexical<'a>(roots: Roots<'n>, statements: &[Statement<'a>]) -> Self { + let mut bindings = Self::new(roots); + for statement in statements { + match statement { + Statement::VariableDeclaration(declaration) => { + bindings.lexical_declaration(declaration); + } + Statement::FunctionDeclaration(function) => { + if let Some(id) = &function.id { + bindings.visit_binding_identifier(id); + } + } + Statement::ClassDeclaration(class) => { + if let Some(id) = &class.id { + bindings.visit_binding_identifier(id); + } + } + _ => {} + } + } + bindings + } + + pub(super) fn lexical_declaration(&mut self, declaration: &VariableDeclaration<'_>) { + if declaration.kind != VariableDeclarationKind::Var { + for declarator in &declaration.declarations { + self.visit_binding_pattern(&declarator.id); + } + } + } + + /// Var declarations cross statement scopes, but never function or class boundaries. + pub(super) fn function_vars<'a>(roots: Roots<'n>, statements: &[Statement<'a>]) -> Self { + let mut vars = FunctionVars(Self::new(roots)); + for statement in statements { + vars.visit_statement(statement); + } + vars.0 + } +} + +impl<'a> Visit<'a> for Bindings<'_> { + fn visit_binding_identifier(&mut self, it: &BindingIdentifier<'a>) { + self.arguments |= it.name == "arguments"; + self.rest |= self.roots.rest.is_some_and(|name| it.name == name); + } + + fn visit_expression(&mut self, _: &Expression<'a>) {} +} + +struct FunctionVars<'n>(Bindings<'n>); + +impl<'a> Visit<'a> for FunctionVars<'_> { + fn visit_variable_declaration(&mut self, it: &VariableDeclaration<'a>) { + if it.kind == VariableDeclarationKind::Var { + for declarator in &it.declarations { + self.0.visit_binding_pattern(&declarator.id); + } + } + } + + fn visit_expression(&mut self, _: &Expression<'a>) {} + fn visit_function(&mut self, _: &Function<'a>, _: ScopeFlags) {} + fn visit_class(&mut self, _: &Class<'a>) {} +} diff --git a/libs/extractor/src/implicit_props_theme_tests.rs b/libs/extractor/src/implicit_props_theme_tests.rs new file mode 100644 index 000000000..f8e299141 --- /dev/null +++ b/libs/extractor/src/implicit_props_theme_tests.rs @@ -0,0 +1,151 @@ +use oxc_allocator::Allocator; +use oxc_ast::ast::Statement; +use oxc_parser::Parser; +use oxc_span::SourceType; +use rstest::rstest; + +#[rstest] +#[case::catch_outside("(...all)=>{try{}catch(all){}return all[0].theme;}", true)] +#[case::rest_array("(...[props])=>props.theme", false)] +#[case::rest_object("(...{first})=>first.theme", false)] +#[case::other_argument("function(){return arguments[1].theme;}", false)] +#[case::dynamic_argument("(...all)=>all[index].theme", false)] +#[case::catch_inside("(...all)=>{try{}catch(all){return all[0].theme;}}", false)] +#[case::catch_pattern("(...all)=>{try{}catch({all}){return all[0].theme;}}", false)] +#[case::catch_default_outer("(...all)=>{try{}catch({x=all[0].theme}){}}", true)] +#[case::catch_body_lexical("(...all)=>{try{}catch({x=all[0].theme}){let all;}}", true)] +#[case::arguments_catch_outside( + "function(){try{}catch(arguments){}return arguments[0].theme;}", + true +)] +#[case::arguments_catch_inside( + "function(){try{}catch(arguments){return arguments[0].theme;}}", + false +)] +#[case::for_outside("(...all)=>{for(let all=0;false;){}return all[0].theme;}", true)] +#[case::for_inside("(...all)=>{for(let all=[];all[0].theme;all[0].theme){}}", false)] +#[case::for_initializer("(...all)=>{for(let all=all[0].theme;false;){}}", false)] +#[case::for_body_lexical("(...all)=>{for(;all[0].theme;){let all;}}", true)] +#[case::for_in_outside("(...all)=>{for(const all in {}){}return all[0].theme;}", true)] +#[case::for_in_inside("(...all)=>{for(const all in {}){all[0].theme;}}", false)] +#[case::for_in_rhs("(...all)=>{for(const all in all[0].theme){}}", false)] +#[case::for_of_outside("(...all)=>{for(const all of []){}return all[0].theme;}", true)] +#[case::for_of_inside("(...all)=>{for(const all of []){all[0].theme;}}", false)] +#[case::for_of_rhs("(...all)=>{for(const all of all[0].theme){}}", false)] +#[case::arguments_for_outside( + "function(){for(let arguments;false;){}return arguments[0].theme;}", + true +)] +#[case::arguments_for_in_inside("function(){for(let arguments in {}){arguments[0].theme;}}", false)] +#[case::arguments_for_of_outside( + "function(){for(let arguments of []){}return arguments[0].theme;}", + true +)] +#[case::switch_outside("(...all)=>{switch(0){case 0:let all;}return all[0].theme;}", true)] +#[case::switch_discriminant("(...all)=>{switch(all[0].theme){case 0:let all;}}", true)] +#[case::switch_shared_cases( + "(...all)=>{switch(0){case all[0].theme:break;default:let all;}}", + false +)] +#[case::switch_nested_block("(...all)=>{switch(0){case all[0].theme:{let all;}}}", true)] +#[case::arguments_switch_outside( + "function(){switch(0){case 0:let arguments;}return arguments[0].theme;}", + true +)] +#[case::block_lexical_outside("(...all)=>{{let all;}return all[0].theme;}", true)] +#[case::block_lexical_inside("(...all)=>{{all[0].theme;let all;}}", false)] +#[case::block_function_outside("(...all)=>{{function all(){}}return all[0].theme;}", true)] +#[case::block_function_inside("(...all)=>{{function all(){}all[0].theme;}}", false)] +#[case::block_class_inside("(...all)=>{{class all{}all[0].theme;}}", false)] +#[case::root_rest_var("(...all)=>{var all;return all[0].theme;}", true)] +#[case::root_rest_nested_var("(...all)=>{{var all;}return all[0].theme;}", true)] +#[case::root_arguments_var("function(){var arguments;return arguments[0].theme;}", true)] +#[case::root_arguments_nested_var( + "function(){if(false){var arguments;}return arguments[0].theme;}", + true +)] +#[case::root_arguments_default_var("function(p=0){var arguments;return arguments[0].theme;}", true)] +#[case::root_named_arguments("function arguments(){return arguments[0].theme;}", true)] +#[case::root_rest_named_arguments( + "(...arguments)=>{var arguments;return arguments[0].theme;}", + true +)] +#[case::root_arguments_parameter("function(arguments){return arguments[0].theme;}", false)] +#[case::root_arguments_function( + "function(){function arguments(){}return arguments[0].theme;}", + false +)] +#[case::root_rest_function("(...all)=>{function all(){}return all[0].theme;}", false)] +#[case::nested_block_var("(...all)=>(()=>{all[0].theme;{var all;}})()", false)] +#[case::nested_if_var("(...all)=>(()=>{if(false){var all;}return all[0].theme;})()", false)] +#[case::nested_catch_var( + "(...all)=>(()=>{try{}catch(e){var all;}return all[0].theme;})()", + false +)] +#[case::nested_for_var("(...all)=>(()=>{for(var all;false;){}return all[0].theme;})()", false)] +#[case::nested_for_in_var("(...all)=>(()=>{for(var all in {}){}return all[0].theme;})()", false)] +#[case::nested_for_of_var("(...all)=>(()=>{for(var all of []){}return all[0].theme;})()", false)] +#[case::nested_switch_var( + "(...all)=>(()=>{switch(0){case 0:var all;}return all[0].theme;})()", + false +)] +#[case::nested_normal_rest_var( + "(...all)=>function(){if(false){var all;}return all[0].theme;}", + false +)] +#[case::nested_normal_arguments( + "function(){return function(){return arguments[0].theme;};}", + false +)] +#[case::nested_arrow_arguments("function(){return ()=>arguments[0].theme;}", true)] +#[case::nested_arrow_arguments_var( + "function(){return ()=>{if(false){var arguments;}return arguments[0].theme;};}", + false +)] +#[case::nested_arrow_param("(...all)=>(all)=>all[0].theme", false)] +#[case::nested_normal_name("(...all)=>function all(){return all[0].theme;}", false)] +#[case::default_before_body_var("(...all)=>(x=all[0].theme)=>{var all;}", true)] +#[case::default_before_body_let("(...all)=>(x=all[0].theme)=>{let all;}", true)] +#[case::root_default_before_let("function(x=arguments[0].theme){let arguments;}", true)] +#[case::parameter_order("function(p,x=arguments[0].theme){return x;}", true)] +#[case::later_parameter_tdz("(...all)=>(x=all[0].theme,all)=>x", false)] +#[case::destructured_parameter_default("(...all)=>({x=all[0].theme})=>x", true)] +#[case::inner_function_var_boundary( + "(...all)=>(()=>{function local(){var all;}return all[0].theme;})()", + true +)] +#[case::inner_arrow_var_boundary( + "(...all)=>(()=>{const local=()=>{var all;};return all[0].theme;})()", + true +)] +#[case::inner_class_var_boundary( + "(...all)=>(()=>{class Local{static{var all;}}return all[0].theme;})()", + true +)] +#[case::class_expression_name("(...all)=>(class all{static x=all[0].theme;})", false)] +#[case::class_expression_outer("(...all)=>(class Local{static x=all[0].theme;})", true)] +#[case::static_block_var("(...all)=>(class Local{static{all[0].theme;{var all;}}})", false)] +#[case::static_block_restore( + "(...all)=>(class Local{static{var all;}static x=all[0].theme;})", + true +)] +fn implicit_theme_detection_follows_binding_owners(#[case] callback: &str, #[case] expected: bool) { + // Given: a parsed callback with a distinct lexical or function binding owner. + let arena = Allocator::default(); + let source = format!("({callback});"); + let parsed = Parser::new(&arena, &source, SourceType::script()).parse(); + assert_eq!( + parsed.diagnostics.len(), + 0, + "{callback}: {:?}", + parsed.diagnostics + ); + assert!(!parsed.fatal_error, "{callback}"); + let Statement::ExpressionStatement(statement) = &parsed.program.body[0] else { + panic!("expected callback expression: {callback}"); + }; + // When: the same detector used by the located guard resolves implicit props roots. + let observed = super::reads(&statement.expression); + // Then: only the actual callback roots are recognized, without invoking Boa. + assert_eq!(observed, expected, "{callback}"); +} diff --git a/libs/extractor/src/import_alias_visit.rs b/libs/extractor/src/import_alias_visit.rs index 1810c6520..648e1aaf8 100644 --- a/libs/extractor/src/import_alias_visit.rs +++ b/libs/extractor/src/import_alias_visit.rs @@ -112,6 +112,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); } } } @@ -134,8 +138,27 @@ 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 { + 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)), } } @@ -147,6 +170,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); } } } @@ -344,7 +371,10 @@ impl<'a> Visit<'a> for LibraryNumbers { 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![], }; @@ -1068,6 +1098,10 @@ fn generate_transformed_import( result } +#[cfg(test)] +#[path = "numeric_context_tests.rs"] +mod numeric_context_tests; + #[cfg(test)] #[path = "import_alias_scoping_tests.rs"] mod scoping_tests; @@ -1428,6 +1462,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' diff --git a/libs/extractor/src/imported_constants.rs b/libs/extractor/src/imported_constants.rs index 89fc2d516..d2e652982 100644 --- a/libs/extractor/src/imported_constants.rs +++ b/libs/extractor/src/imported_constants.rs @@ -6,7 +6,7 @@ use std::cell::{Cell, OnceCell}; 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, IdentifierReference, ImportDeclarationSpecifier, JSXAttributeItem, JSXElementName, ObjectPropertyKind, Program, Statement, Str, @@ -24,17 +24,28 @@ use rustc_hash::{FxHashMap, FxHashSet}; use crate::composition::{Composition, set_prop_order}; use crate::css_prop::{CssProp, CssTakers, binding_of, reads_top_level, root_reference}; -use crate::extractor::ExtractResult; -use crate::extractor::extract_style_from_expression::{ - LiteralHandling, extract_style_from_expression, -}; +use crate::extractor::extract_style_from_expression::LiteralHandling; use crate::stylex::StylexFunction; use crate::{ExtractOption, ExtractStyleValue, ModuleResolver}; +#[cfg(test)] +#[path = "local_capture_coverage_tests.rs"] +mod local_capture_coverage_tests; + +#[cfg(test)] +#[path = "imported_constants_join_tests.rs"] +mod imported_constants_join_tests; + +#[path = "consumed_references.rs"] +mod consumed_references; +use consumed_references::ConsumedReferences; #[cfg(test)] #[path = "imported_constants_px_tests.rs"] mod px_tests; +#[path = "imported_css_root.rs"] +mod imported_css_root; + #[derive(Clone, Debug)] enum Constant { String(String), @@ -219,12 +230,159 @@ pub(crate) struct Inlined { pub css_failures: Vec, 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)>, /// The semantic analysis of the program as parsed, so the visitor reuses /// it: constants inlined later leave the ids of surviving references as /// they are. `None` when no analysis was needed pub scoping: Option>, } +/// 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, + end: u32, + value: Option, +} + +/// All simple `const` declarations, in declaration order, evaluated only after +/// semantic scoping has resolved their dependencies. +#[derive(Default)] +struct LocalConstants { + bindings: FxHashMap, +} + +/// `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 + { + 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), + }); + } + } + } + } + walk::walk_variable_declaration(self, declaration); + } +} + /// Bindings styles read that hold an object or array code changes, whole or /// in some member #[derive(Default, Clone)] @@ -484,18 +642,25 @@ fn inline_in<'a>( } } } + style.visit_program(program); let mut read = StyleReads { style: &style, css_props: &css_props, names: FxHashSet::default(), + locals: FxHashSet::default(), depth: 0, class_names: Vec::new(), }; read.visit_program(program); - if read.names.is_empty() { + if read.names.is_empty() && read.locals.is_empty() && px.is_empty() { return Inlined::default(); } let names = read.names; + let mut locals = LocalConstants::default(); + locals.visit_program(program); + let mut found = Vec::new(); + let mut shapes = FxHashMap::default(); + let mut invalid = FxHashSet::default(); let mut modules = Modules { resolver, option, @@ -572,12 +737,7 @@ fn inline_in<'a>( } } } - // Constants are only worth reading when a style reads a name that may - // hold one let reads_math = names.contains("Math") && !scope.binds("Math"); - if !reads_math && !names.iter().any(|name| scope.binds(name)) { - return Inlined::default(); - } for name in &names { let bound = scope.binds(name); let constant = scope.lookup(&mut modules, name); @@ -629,14 +789,75 @@ fn inline_in<'a>( symbols.extend(symbol.get().map(|symbol| (symbol, constant.clone()))); } } + scope.scoped = Some(scoping); + ScopedLocals { + scope: &mut scope, + modules: &mut modules, + 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(); - if !symbols.is_empty() || reads_math { - Inline { + 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 has_callbacks = !callbacks.sites.is_empty(); + 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) = found + .iter() + .find(|local| local.binding == scoping.symbol_span(symbol).start) + { + if let Some(value) = &local.value { + symbols.insert(symbol, value.clone()); + } + declared.push((symbol, local)); + } + } + if !symbols.is_empty() + || !shapes.is_empty() + || !invalid.is_empty() + || reads_math + || !px.is_empty() + || !captures.is_empty() + || has_callbacks + { + 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, symbols: &symbols, + shapes: &shapes, + aliases: &aliases, + deferred: &deferred, + local_ends: declared + .iter() + .map(|(symbol, local)| (*symbol, local.end)) + .collect(), + invalid: &invalid, style: &style, css_props: &css_props, css_failures: &mut inlined.css_failures, @@ -644,9 +865,29 @@ fn inline_in<'a>( objects: false, styles: false, px: false, + numeric_role: NumericRole::Other, position: InlinePosition::Rules, marks: px, + pixel_callbacks, + replaced: ConsumedReferences::new(scoping), + rejected: FxHashSet::default(), class_names: Vec::new(), + }; + inline.visit_program(program); + for (symbol, local) in declared { + let read = inline.replaced.count(symbol); + 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, + aliases: &aliases, } .visit_program(program); } @@ -825,12 +1066,34 @@ impl<'s> StyleSymbols<'s> { } } } +impl<'a> Visit<'a> for StyleSymbols<'_> { + fn visit_variable_declarator(&mut self, declarator: &oxc_ast::ast::VariableDeclarator<'a>) { + if let Some(Expression::Identifier(init)) = &declarator.init + && let Some(from) = binding_of(self.scoping, init) + && let Some(to) = declarator + .id + .get_binding_identifier() + .and_then(|identifier| identifier.symbol_id.get()) + { + for symbols in [&mut self.roots, &mut self.functions, &mut self.css] { + if symbols.contains(&from) { + symbols.insert(to); + } + } + } + walk::walk_variable_declarator(self, declarator); + } +} + /// Names read inside the props of the package's components and the arguments /// of its functions struct StyleReads<'s> { style: &'s StyleSymbols<'s>, css_props: &'s CssTakers<'s>, names: FxHashSet, + /// Local bindings demanded by genuine style positions, separate from + /// the root names used for module lookup and diagnostics. + locals: FxHashSet, depth: usize, /// The bindings the `` child functions around take `css` and /// `cx` by @@ -848,8 +1111,12 @@ impl StyleReads<'_> { impl<'a> Visit<'a> for StyleReads<'_> { fn visit_identifier_reference(&mut self, identifier: &IdentifierReference<'a>) { - if self.depth > 0 && reads_top_level(self.style.scoping, identifier) { - self.names.insert(identifier.name.to_string()); + if self.depth > 0 { + if reads_top_level(self.style.scoping, identifier) { + self.names.insert(identifier.name.to_string()); + } else if let Some(symbol) = binding_of(self.style.scoping, identifier) { + self.locals.insert(symbol); + } } } @@ -1248,6 +1515,9 @@ struct ModuleScope<'p, 'a> { css_prop: Option<(CssProp, &'p str)>, uses: Option>>>, changes: FxHashMap>>, + /// Local rule evaluation resolves dependencies by symbol, never by name. + scoped: Option<&'p Scoping>, + exact: FxHashMap, /// The semantic analysis the program extracted already has, which the /// visitor reuses and building another over the same program would reset shared_scoping: Option<&'p Scoping>, @@ -1272,6 +1542,8 @@ impl<'p, 'a> ModuleScope<'p, 'a> { css_prop: None, uses: None, changes: FxHashMap::default(), + scoped: None, + exact: FxHashMap::default(), shared_scoping: None, scoping: OnceCell::new(), } @@ -1345,24 +1617,23 @@ impl<'p, 'a> ModuleScope<'p, 'a> { if let Some(mut rules @ Expression::ObjectExpression(_)) = constant_literal(&builder, value, true) { - let ExtractResult { - mut styles, - style_order, - .. - } = extract_style_from_expression( - &builder, - None, - &mut rules, - 0, - &None, - LiteralHandling::ExpandResponsiveThemeToken, - ); - if let Some(order) = style_order { - for prop in &mut styles { + let (result, mut authored) = + crate::extractor::style_order_projection::extract_with_order( + &builder, + None, + &mut rules, + 0, + &None, + LiteralHandling::ExpandResponsiveThemeToken, + ); + if let Some(order) = result.style_order { + for prop in &mut authored.0 { set_prop_order(prop, order); } } - composition.apply(&builder, styles); + authored + .registered(self.path) + .apply(&builder, &mut composition); continue; } ChainReason::Constant @@ -1473,7 +1744,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 @@ -1805,7 +2086,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) } @@ -1831,7 +2112,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()) } @@ -1849,6 +2137,7 @@ 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( self.css_styles(modules, call) .unwrap_or(Constant::Style(None)), @@ -1856,7 +2145,7 @@ impl<'p, 'a> ModuleScope<'p, 'a> { _ => 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)) } @@ -1931,6 +2220,30 @@ impl<'p, 'a> ModuleScope<'p, 'a> { } } +#[cfg(test)] +#[path = "local_exact_constants_tests.rs"] +mod local_exact_constants_tests; + +#[path = "local_rule_shapes.rs"] +mod local_rule_shapes; + +#[path = "local_capture_reads.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, @@ -2072,6 +2385,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, +} + #[derive(Clone, Copy)] enum InlinePosition { Rules, @@ -2086,6 +2405,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: &'s StyleSymbols<'s>, css_props: &'s CssTakers<'s>, css_failures: &'s mut Vec, @@ -2096,10 +2420,16 @@ struct Inline<'s, 'a> { styles: bool, /// Inside a `css` prop, whose numbers Emotion reads as `px` lengths px: bool, + numeric_role: NumericRole, position: InlinePosition, /// 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
, + /// Semantic references erased by style folding or constant selection. + replaced: ConsumedReferences<'s>, + rejected: FxHashSet, /// The bindings the `` child functions around take `css` and /// `cx` by class_names: Vec, @@ -2111,6 +2441,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) => { @@ -2193,21 +2528,30 @@ impl<'a> Inline<'_, 'a> { Constant::Number(number) => number != 0.0 && !number.is_nan(), Constant::Null | Constant::Undefined => false, Constant::Bool(value) => value, + Constant::Style(Some(styles)) => styles + .iter() + .any(|style| !matches!(style, crate::ExtractStyleValue::LayerOrder(_))), + Constant::Style(None) => return None, _ => true, }) } /// The side of a condition or of `&&`, `||` or `??` a constant or literal /// chooses, taken out of `expression` - fn chosen(&self, expression: &mut Expression<'a>) -> Option> { + fn chosen(&mut self, expression: &mut Expression<'a>) -> Option> { use oxc_allocator::TakeIn; let allocator = self.ast_builder; match expression { Expression::ConditionalExpression(conditional) => { - Some(if self.holds(&conditional.test)? { + let holds = self.holds(&conditional.test)?; + self.replaced.consume(&conditional.test); + // Only scan the erased side; the selected side is visited normally. + Some(if holds { + self.replaced.consume(&conditional.alternate); conditional.consequent.take_in(allocator) } else { + self.replaced.consume(&conditional.consequent); conditional.alternate.take_in(allocator) }) } @@ -2221,8 +2565,10 @@ impl<'a> Inline<'_, 'a> { } }; Some(if keep_left { + self.replaced.consume(&logical.right); logical.left.take_in(allocator) } else { + self.replaced.consume(&logical.left); logical.right.take_in(allocator) }) } @@ -2262,7 +2608,16 @@ impl<'a> Inline<'_, 'a> { /// `visit` reading a `css` prop when `css` fn reading_css(&mut self, css: bool, visit: impl FnOnce(&mut Self) -> T) -> T { let outer = std::mem::replace(&mut self.px, css); + let outer_position = std::mem::replace( + &mut self.position, + if css { + InlinePosition::Rules + } else { + InlinePosition::Property + }, + ); let result = self.reading_styles(css, |inline| inline.reading_objects(css, visit)); + self.position = outer_position; self.px = outer; result } @@ -2386,8 +2741,79 @@ 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); + let outer_role = std::mem::replace(&mut self.numeric_role, NumericRole::Other); + let outer_position = std::mem::replace(&mut self.position, InlinePosition::Property); + walk_mut::walk_formal_parameters(self, parameters); + self.position = outer_position; + self.numeric_role = outer_role; + 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); + let outer_position = std::mem::replace(&mut self.position, InlinePosition::Rules); + walk_mut::walk_arrow_function_expression(self, arrow); + self.position = outer_position; + 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); + let outer_position = std::mem::replace(&mut self.position, InlinePosition::Rules); + walk_mut::walk_function(self, function, flags); + self.position = outer_position; + 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()), + }; + self.replaced.consume(expression); + *expression = local_rule_shapes::expand(self.ast_builder, shape, &root); + 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(constant) = self.constant(expression) { if let Constant::Failed(message) = constant { self.css_failures.push(message); @@ -2396,6 +2822,7 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { if let Some(literal) = self.literal(&constant) { let span = expression.span(); let marked = self.marks.contains(&(span.start, span.end)); + self.replaced.consume(expression); *expression = literal; if marked { px_number(self.ast_builder, expression); @@ -2417,7 +2844,15 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { } let span = expression.span(); let marked = self.styles && self.marks.contains(&(span.start, span.end)); + 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; if marked && matches!(expression, Expression::ObjectExpression(_)) { px_rules(self.ast_builder, expression); } @@ -2512,7 +2947,11 @@ 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); + let outer_position = std::mem::replace(&mut self.position, InlinePosition::Property); + self.reading_objects(false, |inline| inline.visit_expression(&mut call.callee)); if self .css_props .calls_class_names(&self.class_names, &call.callee) @@ -2522,6 +2961,9 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { inline.visit_argument(argument); } }); + self.px = outer_px; + self.numeric_role = outer_role; + self.position = outer_position; return; } let objects = self.style.reads(&call.callee); @@ -2529,22 +2971,34 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { let css = self .css_props .property(call, |identifier| self.style.has(identifier)); + self.position = if objects { + InlinePosition::Rules + } else { + InlinePosition::Property + }; self.reading_styles(styles, |inline| { inline.reading_objects(objects, |inline| { for (index, argument) in call.arguments.iter_mut().enumerate() { match (css, argument) { (Some(css), Argument::ObjectExpression(props)) if index == 1 => { - for (at, property) in props.properties.iter_mut().enumerate() { - inline.reading_css(at == css, |inline| { - inline.visit_object_property_kind(property); - }); - } + inline.compiled_css_props(props, css); } (_, argument) => inline.visit_argument(argument), } } }); }); + self.px = outer_px; + self.numeric_role = outer_role; + self.position = outer_position; + } + + fn visit_new_expression(&mut self, call: &mut oxc_ast::ast::NewExpression<'a>) { + let outer = std::mem::replace(&mut self.numeric_role, NumericRole::Other); + let outer_position = std::mem::replace(&mut self.position, InlinePosition::Property); + self.reading_css(false, |inline| walk_mut::walk_new_expression(inline, call)); + self.position = outer_position; + self.numeric_role = outer; } fn visit_jsx_opening_element(&mut self, element: &mut oxc_ast::ast::JSXOpeningElement<'a>) { @@ -2557,7 +3011,10 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { self.style.has(identifier) }) { + 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 @@ -2577,17 +3034,40 @@ 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 outer = std::mem::replace(&mut self.position, InlinePosition::Property); + 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; + } + let outer_position = std::mem::replace(&mut self.position, InlinePosition::Property); walk_mut::walk_object_property(self, property); - self.position = outer; + self.position = outer_position; + 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_constants_join_tests.rs b/libs/extractor/src/imported_constants_join_tests.rs new file mode 100644 index 000000000..0fea8fa1e --- /dev/null +++ b/libs/extractor/src/imported_constants_join_tests.rs @@ -0,0 +1,177 @@ +use super::*; +use rstest::rstest; + +#[test] +fn compiled_css_shorthand_when_inlined_keeps_the_css_property_name() { + // Given: a real local rule constant is passed as a compiled JSX shorthand property. + let arena = Allocator::default(); + let source = + "import {jsx} from '@emotion/react';const css={ margin: 2 };const view=jsx('div',{css});"; + let mut parsed = Parser::new(&arena, source, SourceType::mjs()).parse(); + assert_eq!(parsed.diagnostics.len(), 0); + let options = ExtractOption { + import_aliases: std::collections::HashMap::from([( + "@emotion/react".into(), + crate::ImportAlias::NamedToNamed, + )]), + ..Default::default() + }; + // When: ordinary constant inlining rewrites the value at the compiled CSS-prop root. + let result = inline_constants( + &AstBuilder::new(&arena), + &mut parsed.program, + "shorthand.ts", + &options, + None, + CssProp::Everywhere, + &FxHashSet::default(), + ); + assert_eq!(result.errors, vec![]); + parsed + .program + .body + .retain(|statement| !matches!(statement, Statement::ImportDeclaration(_))); + let code = oxc_codegen::Codegen::new().build(&parsed.program).code; + let script = format!( + "function jsx(target,props){{return {{target,props}};}}{code};JSON.stringify(view);" + ); + let actual = boa_engine::Context::default() + .eval(boa_engine::Source::from_bytes(&script)) + .unwrap_or_else(|error| panic!("{error}")) + .as_string() + .unwrap_or_else(|| panic!("expected JSON observation")) + .to_std_string_escaped(); + // Then: the rewritten value is valid JavaScript under the original css key, with Emotion pixels. + assert_eq!( + serde_json::from_str::(&actual) + .unwrap_or_else(|error| panic!("{error}")), + serde_json::json!({"target":"div","props":{"css":{"margin":"2px"}}}) + ); +} + +#[test] +fn pixel_rules_when_nested_selectors_are_arrays_preserve_unitless_values() +-> Result<(), Box> { + // Given: selector arrays retain numbers for native extraction to scale later. + let allocator = Allocator::default(); + let mut parsed = Parser::new( + &allocator, + "const rules={_hover:[{margin:2,lineHeight:3}],vars:{'--raw':4}};", + SourceType::tsx(), + ) + .parse(); + assert_eq!(parsed.diagnostics.len(), 0); + let Some(Statement::VariableDeclaration(declaration)) = parsed.program.body.first_mut() else { + return Err("missing rules declaration".into()); + }; + let rules = declaration.declarations[0] + .init + .as_mut() + .ok_or("missing rules initializer")?; + // When: the production pixel traversal prepares the complete rule tree. + px_rules(&AstBuilder::new(&allocator), rules); + let generated = oxc_codegen::Codegen::new().build(&parsed.program).code; + let script = format!("{generated}\nJSON.stringify(rules);"); + let json = boa_engine::Context::default() + .eval(boa_engine::Source::from_bytes(&script)) + .map_err(|error| error.to_string())? + .as_string() + .ok_or("expected JSON result")? + .to_std_string_escaped(); + // Then: preparation leaves selector-array values and vars untouched. + assert_eq!( + serde_json::from_str::(&json)?, + serde_json::json!({"_hover":[{"margin":2,"lineHeight":3}],"vars":{"--raw":4}}) + ); + Ok(()) +} + +#[rstest] +#[case("inner")] +#[case("rest")] +fn shape_accounting_when_selection_erases_a_read_counts_original_sites( + #[case] name: &str, + #[values(false, true)] observed: bool, +) -> Result<(), Box> { + // Given: expansion manufactures several member reads from one original site. + let allocator = Allocator::default(); + let observation = if observed { + format!("observe({name});") + } else { + String::new() + }; + let source = format!( + "import {{styled}} from '@devup-ui/react'; function f(props) {{ const {name} = {{color:props.color,_hover:{{color:props.hover}}}}; {observation} return styled.div(true ? {name} : {name}); }}" + ); + let mut parsed = Parser::new(&allocator, &source, SourceType::tsx()).parse(); + assert_eq!(parsed.diagnostics.len(), 0); + let offset = u32::try_from(source.find(&format!("{name} =")).ok_or("missing binding")?)?; + // When: selection erases one site and shape expansion consumes the other. + let inlined = inline_constants( + &AstBuilder::new(&allocator), + &mut parsed.program, + "join.tsx", + &ExtractOption::default(), + None, + CssProp::Off, + &FxHashSet::default(), + ); + // Then: generated members neither hide an extra use nor invent one. + let errors = if observed { + vec![(offset, crate::utils::local_style_error(name))] + } else { + vec![] + }; + assert_eq!(inlined.errors, errors); + Ok(()) +} + +#[test] +fn scoping_when_a_bound_callback_captures_a_local_is_returned_for_reuse() +-> Result<(), Box> { + struct Reads<'s> { + scoping: &'s Scoping, + symbols: Vec, + } + impl<'a> Visit<'a> for Reads<'_> { + fn visit_identifier_reference( + &mut self, + identifier: &oxc_ast::ast::IdentifierReference<'a>, + ) { + if identifier.name == "__devup_rest" + && let Some(symbol) = binding_of(self.scoping, identifier) + { + self.symbols.push(symbol); + } + } + } + // Given: the callback and a generated capture alias must share original IDs. + let allocator = Allocator::default(); + let source = "import {styled} from '@devup-ui/react'; function f(props) { const rest=props.color; const rules=p=>({ color: rest }); return styled.div(rules); }"; + let mut parsed = Parser::new(&allocator, source, SourceType::tsx()).parse(); + assert_eq!(parsed.diagnostics.len(), 0); + // When: the callback is cloned and its captured binding is renamed. + let inlined = inline_constants( + &AstBuilder::new(&allocator), + &mut parsed.program, + "join.tsx", + &ExtractOption::default(), + None, + CssProp::Off, + &FxHashSet::default(), + ); + // Then: the caller receives the semantic phase that owns the renamed reads. + assert_eq!(inlined.errors, vec![]); + let scoping = inlined.scoping.ok_or("missing shared scoping")?; + let symbol = scoping + .symbol_ids() + .find(|symbol| scoping.symbol_name(*symbol) == "rest") + .ok_or("missing captured symbol")?; + let mut reads = Reads { + scoping: &scoping, + symbols: Vec::new(), + }; + reads.visit_program(&parsed.program); + assert_eq!(reads.symbols, vec![symbol, symbol]); + Ok(()) +} diff --git a/libs/extractor/src/imported_css_root.rs b/libs/extractor/src/imported_css_root.rs new file mode 100644 index 000000000..bfc7da906 --- /dev/null +++ b/libs/extractor/src/imported_css_root.rs @@ -0,0 +1,29 @@ +use super::{Inline, NumericRole}; +use oxc_ast::ast::{Expression, ObjectExpression, ObjectPropertyKind}; +use oxc_ast_visit::VisitMut; + +impl<'a> Inline<'_, 'a> { + pub(super) fn compiled_css_props(&mut self, props: &mut ObjectExpression<'a>, css: usize) { + for (index, property) in props.properties.iter_mut().enumerate() { + let root = index == css; + let outer = std::mem::replace( + &mut self.numeric_role, + if root { + NumericRole::ElementCssProp + } else { + NumericRole::Other + }, + ); + self.reading_css(root, |inline| match property { + ObjectPropertyKind::ObjectProperty(property) if root => { + inline.visit_expression(&mut property.value); + if property.shorthand && !matches!(property.value, Expression::Identifier(_)) { + property.shorthand = false; + } + } + property => inline.visit_object_property_kind(property), + }); + self.numeric_role = outer; + } + } +} diff --git a/libs/extractor/src/imported_styled.rs b/libs/extractor/src/imported_styled.rs index e9c457607..4ef3322bc 100644 --- a/libs/extractor/src/imported_styled.rs +++ b/libs/extractor/src/imported_styled.rs @@ -5,7 +5,7 @@ use std::collections::BTreeSet; use std::rc::Rc; -use oxc_allocator::{Allocator, CloneIn, GetAllocator}; +use oxc_allocator::{Allocator, GetAllocator}; use oxc_ast::ast::{ CallExpression, Declaration, Expression, ImportDeclarationSpecifier, JSXAttributeItem, JSXAttributeName, JSXElementName, JSXOpeningElement, Program, Statement, @@ -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, CssTakers}; @@ -164,6 +164,7 @@ pub(crate) fn read<'a>( option: &ExtractOption, resolver: Option<&ModuleResolver>, css_prop: CssProp, + scoping: &mut Option>, ) -> Reading<'a> { let Some(resolver) = resolver else { return Reading::default(); @@ -177,7 +178,7 @@ pub(crate) fn read<'a>( reading: vec![filename.to_string()], dependencies: BTreeSet::new(), }; - let components = reader.imports(program, filename, css_prop, allocator); + let components = reader.imports(program, filename, css_prop, allocator, scoping); Reading { components, dependencies: reader.dependencies, @@ -219,6 +220,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; } @@ -245,7 +250,7 @@ impl<'a> Reader<'_, 'a> { let ast_builder = AstBuilder::new(&sub); let styled_capability = crate::extractor::styled_capability::StyledCapability::reserve(&program); - let inlined = crate::imported_constants::inline_constants( + let mut inlined = crate::imported_constants::inline_constants( &ast_builder, &mut program, path, @@ -255,7 +260,7 @@ impl<'a> Reader<'_, 'a> { &px, ); self.dependencies.extend(inlined.dependencies); - let imports = self.imports(&program, path, css_prop, &sub); + let imports = self.imports(&program, path, css_prop, &sub, &mut inlined.scoping); let mut exports = self.reexports(&program, path); let (bucket, global, _) = crate::resolve_css_target(path, self.option); let mut visitor = DevupVisitor::new( @@ -267,6 +272,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); @@ -277,23 +283,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(), @@ -303,7 +429,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") @@ -328,31 +456,30 @@ impl<'a> Reader<'_, 'a> { importer: &str, css_prop: CssProp, allocator: &'b Allocator, + scoping: &mut Option>, ) -> Components<'b> { - let arena = Allocator::default(); - let isolated = program.clone_in(&arena); - let program = &isolated; - let scoping = oxc_semantic::SemanticBuilder::new() - .build(program) - .semantic - .into_scoping(); let imported = imported_bindings(program, &self.option.package); + // Callback recovery retains original IDs; rebuilding here would reassign + // them on the rewritten AST while its consumers retain the original table. + let scoping = scoping.get_or_insert_with(|| { + Rc::new( + oxc_semantic::SemanticBuilder::new() + .build(program) + .semantic + .into_scoping(), + ) + }); + let compat = format!("{}/compat", self.option.package); let mut used = Used { + scoping, + css_takers: CssTakers::new(program, scoping, css_prop, &compat), styled: imported.styled, css_prop: css_prop != CssProp::Off, - css: CssTakers::new( - program, - &scoping, - css_prop, - &format!("{}/compat", self.option.package), - ), 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) @@ -444,57 +571,153 @@ 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<'s> { +struct Used<'s, 'p> { + scoping: &'s oxc_semantic::Scoping, + css_takers: CssTakers<'p>, styled: FxHashSet, css_prop: bool, - css: CssTakers<'s>, 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>) { 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) } - _ if self.css.property(call, |_| true).is_some() => call + _ if self.css_takers.property(call, |_| true).is_some() => call .arguments .first() - .and_then(|argument| argument.as_expression()) - .and_then(written_name), + .and_then(|argument| argument.as_expression()), _ => 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); } @@ -506,6 +729,7 @@ mod tests { use std::collections::{BTreeSet, HashMap}; use insta::assert_debug_snapshot; + use rstest::rstest; use serial_test::serial; use super::*; @@ -572,6 +796,636 @@ mod tests { } const STYLED: &str = "import styled from '@emotion/styled';\n"; + + #[rstest] + #[case("jsx(Child, { css: { color: 'red' } });", CssProp::Elements, &["Child"])] + #[case("jsx(UI.Child, { css: { color: 'red' } });", CssProp::Everywhere, &["UI.Child"])] + #[case("jsx(UI['Child'], { css: { color: 'red' } });", CssProp::Everywhere, &["UI.Child"])] + #[case("(UI => jsx(UI.Child, { css: { color: 'red' } }));", CssProp::Everywhere, &[])] + #[case("jsx(UI[key], { css: { color: 'red' } });", CssProp::Everywhere, &[])] + fn imported_css_discovery_when_general_or_namespace_uses_semantic_roots( + #[case] body: &str, + #[case] css_prop: CssProp, + #[case] expected: &[&str], + ) { + // Given + let allocator = Allocator::default(); + let source = format!( + "import * as UI from './base'; import {{ Child }} from './base'; import {{ jsx }} from 'react/jsx-runtime'; {body}" + ); + let parsed = Parser::new(&allocator, &source, SourceType::tsx()).parse(); + assert_eq!(parsed.diagnostics.len(), 0); + let scoping = oxc_semantic::SemanticBuilder::new() + .build(&parsed.program) + .semantic + .into_scoping(); + let mut used = Used { + scoping: &scoping, + css_takers: CssTakers::new( + &parsed.program, + &scoping, + css_prop, + "@devup-ui/react/compat", + ), + styled: FxHashSet::default(), + css_prop: css_prop != CssProp::Off, + names: FxHashSet::default(), + }; + // When + used.visit_program(&parsed.program); + // Then + let names: BTreeSet<_> = used.names.iter().map(String::as_str).collect(); + assert_eq!(names, expected.iter().copied().collect()); + } + + #[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, + ) { + let _state = crate::test_state::TestStateGuard::new(); + // 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_eq!(output.styles.len(), 1); + assert_eq!(output.dependencies, ["/src/base.ts"]); + let _proof = crate::retained_render_assertions::opaque(scope, value); + } + + #[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};`; @@ -923,3 +1777,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..71e381c42 --- /dev/null +++ b/libs/extractor/src/imported_styled_coverage_tests.rs @@ -0,0 +1,92 @@ +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; + +#[rstest] +#[case("styled.div", true)] +#[case("styled('div')", true)] +#[case("styled['div']", false)] +#[case("flag ? styled : other", false)] +#[case("42", false)] +fn imported_value_sites_when_creator_shape_is_unsupported_are_not_styled( + #[case] creator: &str, + #[case] expected: bool, +) -> Result<(), Box> { + // Given: semantic references resolve the actual styled import. + let allocator = Allocator::default(); + let source = format!("import {{styled}} from '@devup-ui/react'; ({creator});"); + let parsed = Parser::new(&allocator, &source, SourceType::tsx()).parse(); + assert_eq!(parsed.diagnostics.len(), 0); + let scoping = oxc_semantic::SemanticBuilder::new() + .build(&parsed.program) + .semantic + .into_scoping(); + let symbol = scoping + .get_root_binding("styled".into()) + .ok_or("missing styled import")?; + let sites = super::ValueSites { + scoping: &scoping, + styled: rustc_hash::FxHashSet::from_iter([symbol]), + spans: vec![], + }; + let oxc_ast::ast::Statement::ExpressionStatement(statement) = &parsed.program.body[1] else { + panic!("expected creator expression"); + }; + // When + let result = sites.is_styled(&statement.expression); + // Then + assert_eq!(result, expected); + Ok(()) +} + +#[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( + "/composition/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/layer_registration_tests.rs b/libs/extractor/src/layer_registration_tests.rs new file mode 100644 index 000000000..1f9ec91c2 --- /dev/null +++ b/libs/extractor/src/layer_registration_tests.rs @@ -0,0 +1,134 @@ +use super::*; +use crate::extract_style::{ + extract_layer_order::ExtractLayerOrder, extract_static_style::ExtractStaticStyle, +}; +use crate::extractor::style_order_projection::AuthoredStyles; +use crate::style_collector::StyleCollector; +use oxc_allocator::Allocator; + +fn registration(file: &str, layers: &[&str]) -> ExtractStyleValue { + ExtractStyleValue::LayerOrder(ExtractLayerOrder { + file: file.to_string(), + layers: layers.iter().map(|layer| (*layer).to_string()).collect(), + }) +} + +fn padding(layer: &str, value: &str) -> ExtractStyleProp<'static> { + ExtractStyleProp::Static(ExtractStyleValue::Static( + ExtractStaticStyle::new_with_layer("padding", value, 0, None, Some(layer.to_string())), + )) +} + +#[test] +fn registrations_survive_when_override_and_cover_remove_first_atoms() { + // Given: authored a precedes b, and a is later replaced then covered. + let arena = Allocator::default(); + let ast = AstBuilder::new(&arena); + let mut composition = Composition::default(); + AuthoredStyles(vec![padding("a", "4px"), padding("b", "8px")]) + .registered("actual.tsx") + .apply(&ast, &mut composition); + composition.apply(&ast, vec![padding("a", "12px")]); + // When: a known class covers a's remaining atom. + composition.cover(&padding("a", "16px").into_extract()); + let values = composition + .unconditional() + .unwrap_or_else(|| panic!("unconditional fixture")); + // Then: the first-use record remains ahead of the sole b declaration. + assert_eq!( + values, + vec![ + registration("actual.tsx", &["a", "b"]), + ExtractStyleValue::Static(ExtractStaticStyle::new_with_layer( + "padding", + "8px", + 0, + None, + Some("b".to_string()) + )) + ] + ); +} + +#[test] +fn metadata_is_transparent_when_typography_is_composed_and_covered() { + // Given: a preset and registration share no cascade key. + let arena = Allocator::default(); + let ast = AstBuilder::new(&arena); + let mut composition = Composition::default(); + let order = registration("actual.tsx", &["a"]); + composition.apply( + &ast, + vec![ExtractStyleProp::Static(ExtractStyleValue::Typography( + "body".to_string(), + ))], + ); + // When: metadata is applied and used as a known covering value. + composition.apply(&ast, vec![ExtractStyleProp::Static(order.clone())]); + composition.cover(std::slice::from_ref(&order)); + // Then: typography and the classless record both survive. + assert_eq!( + composition.unconditional(), + Some(vec![ + order, + ExtractStyleValue::Typography("body".to_string()) + ]) + ); +} + +#[test] +fn registration_has_no_overlap_when_other_part_sets_typography() { + // Given: registration-only props and a typography declaration. + let metadata = [ExtractStyleProp::Static(registration("actual.tsx", &["a"]))]; + let typography = [ExtractStyleProp::Static(ExtractStyleValue::Typography( + "body".to_string(), + ))]; + // When: admission compares their keys. + let overlaps = overlaps(&metadata, &typography); + // Then: registration creates no declaration competition. + assert!(!overlaps); +} + +#[test] +fn conditional_registration_is_unconditional_when_it_has_no_declaration() { + // Given: only one branch carries a classless registration. + let arena = Allocator::default(); + let ast = AstBuilder::new(&arena); + let mut composition = Composition::default(); + let order = registration("actual.tsx", &["a", "a.inner"]); + // When: a conditional is folded. + composition.apply_conditional( + &ast, + &Expression::new_identifier(oxc_span::SPAN, "on", &ast), + vec![ExtractStyleProp::Static(order.clone())], + vec![], + ); + // Then: metadata does not introduce an unkeyed or conditional class. + assert_eq!(composition.unconditional(), Some(vec![order.clone()])); + assert!(order.extract(None).is_none()); + assert!(matches!( + composition.into_props().as_slice(), + [ExtractStyleProp::Static(ExtractStyleValue::LayerOrder(_))] + )); +} + +#[test] +fn collector_consolidates_when_partial_imported_records_precede_fallback_atoms() { + // Given: declarations are reordered, but metadata records authored a before b. + let mut collector = StyleCollector::default(); + let props = vec![ + padding("b", "8px"), + ExtractStyleProp::Static(registration("import.ts", &["a", "b"])), + ]; + // When: metadata is consumed before fallback and duplicate records are inserted. + collector.use_layers(&props); + collector.insert(registration("import.ts", &["a", "b"])); + collector.insert(registration("other.ts", &["b", "c.inner"])); + assert!(!collector.is_empty()); + let styles = collector.into_styles("actual.tsx"); + // Then: the consuming file owns exactly one consolidated record. + assert_eq!( + styles, + std::iter::once(registration("actual.tsx", &["a", "b", "c", "c.inner"])).collect() + ); +} diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 81881f1b2..533dc4364 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -8,8 +8,20 @@ mod class_arguments; mod class_names_binding_tests; #[cfg(test)] mod class_names_numeric_tests; +#[cfg(test)] +mod class_selection_expectations; +#[cfg(test)] +mod class_selection_fixtures; +#[cfg(test)] +mod class_selection_state; +#[cfg(test)] +mod class_selection_support; +#[cfg(test)] +mod class_selection_tests; mod component; mod composition; +#[cfg(test)] +mod css_composition_rest_tests; mod css_prop; #[cfg(test)] mod css_prop_contract_tests; @@ -44,7 +56,13 @@ mod quoted_strings_tests; pub mod reservation_state; #[cfg(test)] mod responsive_selector_tests; +#[cfg(test)] +mod retained_render_assertions; +#[cfg(test)] +mod retained_render_support; mod scope; +#[cfg(test)] +mod selector_array_units_tests; mod source_map; mod style_collector; mod style_values; @@ -592,7 +610,7 @@ fn extract_source( let mut program = parsing::parse(&allocator, filename, code_to_parse)?; let header_pragmas = jsx_pragmas::HeaderPragmas::capture(&program); let styled_capability = extractor::styled_capability::StyledCapability::reserve(&program); - let inlined = if processed_code.is_none() { + let mut inlined = if processed_code.is_none() { imported_constants::inline_constants( &oxc_ast::builder::AstBuilder::new(&allocator), &mut program, @@ -620,6 +638,7 @@ fn extract_source( &option, resolver, css_prop, + &mut inlined.scoping, ) } else { imported_styled::Reading::default() @@ -637,6 +656,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); @@ -15582,164 +15602,6 @@ const Button = styled.button({ bg: 'red' }) } } - // A later part of `css(...)` replaces an earlier part's declaration of the - // same property, selector, breakpoint and layer, whatever the stylesheet - // order of their classes - #[rstest] - #[case( - "css(yellow, azure)", - r#""color-0-red-_a__c_hover-255 background-0-black--255 color-0-azure--255""# - )] - #[case( - "css(azure, yellow)", - r#""background-0-black--255 color-0-red-_a__c_hover-255 color-0-yellow--255""# - )] - #[case( - "css({ color: 'green' }, yellow)", - r#""color-0-red-_a__c_hover-255 color-0-yellow--255""# - )] - #[case( - "css(yellow, { color: 'green' })", - r#""color-0-red-_a__c_hover-255 color-0-green--255""# - )] - #[case( - "css([yellow, azure])", - r#""color-0-red-_a__c_hover-255 background-0-black--255 color-0-azure--255""# - )] - #[case( - "css(...[yellow, azure])", - r#""color-0-red-_a__c_hover-255 background-0-black--255 color-0-azure--255""# - )] - #[case( - "css(yellow, null, undefined, false, azure)", - r#""color-0-red-_a__c_hover-255 background-0-black--255 color-0-azure--255""# - )] - #[case( - "css(yellow, on && azure)", - r#"`color-0-red-_a__c_hover-255 ${on ? "background-0-black--255" : ""} ${on ? "color-0-azure--255" : "color-0-yellow--255"}`"# - )] - #[case( - "css(on ? yellow : azure)", - r#"`${on ? "color-0-red-_a__c_hover-255" : ""} ${on ? "color-0-yellow--255" : "color-0-azure--255"} ${on ? "" : "background-0-black--255"}`"# - )] - #[case( - "css(yellow, on ? { color: 'pink' } : null)", - r#"`color-0-red-_a__c_hover-255 ${on ? "color-0-pink--255" : "color-0-yellow--255"}`"# - )] - #[case( - "css(yellow, { color: on ? 'pink' : 'teal' })", - r#"`color-0-red-_a__c_hover-255 ${on ? "color-0-pink--255" : "color-0-teal--255"}`"# - )] - #[case( - "css(yellow, ext, azure)", - r"`color-0-red-_a__c_hover-255 background-0-black--255 color-0-azure--255 ${ext}`" - )] - #[case( - "css(yellow, on ? 'plain' : azure)", - r#"`${on ? "plain" : ""} ${`color-0-red-_a__c_hover-255 ${on ? "" : "background-0-black--255"} ${on ? "color-0-yellow--255" : "color-0-azure--255"}`}`"# - )] - #[case( - "css(azure, wide)", - r#""background-0-black--255 color-0-a1--255 color-1-b1--255""# - )] - #[case( - "css(wide, { color: [null, 'x2'] })", - r#""color-0-a1--255 color-1-x2--255""# - )] - #[case( - "css(yellow, ordered)", - r#""color-0-red-_a__c_hover-255 color-0-navy--3""# - )] - #[case("css(heading, body)", r#""typo-body""#)] - #[case( - "css(chained, { color: 'gold' })", - r#""color-0-red-_a__c_hover-255 background-0-black--255 color-0-gold--255""# - )] - #[case( - "css(yellow, { color: { a: 'red', b: 'blue' }[size] })", - r"`color-0-red-_a__c_hover-255 color-0-yellow--255 ${{" - )] - #[case( - "css(yellow, on ? { color: { a: 'red' }[size] } : null)", - r#"`color-0-red-_a__c_hover-255 color-0-yellow--255 ${on ? { "a": "color-0-red--255" }[size] || "" : ""}`"# - )] - #[case( - "css(yellow, on || azure)", - r#"`${on ? typeof on === "string" ? on : "" : ""} ${`color-0-red-_a__c_hover-255 ${on ? "" : "background-0-black--255"} ${on ? "color-0-yellow--255" : "color-0-azure--255"}`}`"# - )] - #[case( - "css(yellow, ext ?? azure)", - r#"`${ext != null ? typeof ext === "string" ? ext : "" : ""} ${`color-0-red-_a__c_hover-255 ${ext != null ? "" : "background-0-black--255"} ${ext != null ? "color-0-yellow--255" : "color-0-azure--255"}`}`"# - )] - #[case( - "css(yellow, null ?? azure)", - r#""color-0-red-_a__c_hover-255 background-0-black--255 color-0-azure--255""# - )] - #[case( - "css(yellow, false ?? azure)", - r#""color-0-red-_a__c_hover-255 color-0-yellow--255""# - )] - #[case( - "css(azure || yellow)", - r#""background-0-black--255 color-0-azure--255""# - )] - #[case( - "css(yellow, on || 'plain')", - r#"`color-0-red-_a__c_hover-255 color-0-yellow--255 ${on ? typeof on === "string" ? on : "" : "plain"}`"# - )] - #[case( - "css(yellow, { color: on ? 'a' : 'b', m: [1, 2], styleOrder: 2 })", - r#"`color-0-red-_a__c_hover-255 margin-0-4px--2 margin-1-8px--2 ${on ? "color-0-a--2" : "color-0-b--2"}`"# - )] - #[case( - "css(yellow, { color: { a: 'x' }[size], styleOrder: 2 })", - r#"`color-0-red-_a__c_hover-255 color-0-yellow--255 ${{ "a": "color-0-x--2" }[size] || ""}`"# - )] - #[case( - "css(yellow, on ? { color: other ? 'a' : 'b' } : null)", - r#"`color-0-red-_a__c_hover-255 ${on ? other ? "color-0-a--255" : "color-0-b--255" : "color-0-yellow--255"}`"# - )] - #[case( - "css(yellow, fade)", - r#""k-525370705245237976 color-0-red-_a__c_hover-255 color-0-yellow--255""# - )] - #[case( - "css(yellow, { color: ['x', 'y'][idx], styleOrder: 2 })", - "`color-0-red-_a__c_hover-255 color-0-yellow--255 ${{\n\t\"0\": \"color-0-x--2\",\n\t\"1\": \"color-0-y--2\"\n}[idx] || \"\"}`" - )] - #[case( - "css(yellow, { color: 'pink' } || azure)", - r#""color-0-red-_a__c_hover-255 color-0-pink--255""# - )] - #[case( - "css(yellow, undefined ?? azure)", - r#""color-0-red-_a__c_hover-255 background-0-black--255 color-0-azure--255""# - )] - #[case( - "css(yellow, false ?? azure)", - r#""color-0-red-_a__c_hover-255 color-0-yellow--255""# - )] - #[serial] - fn test_css_composes_known_classes(#[case] call: &str, #[case] expected: &str) { - let code = readable_code(&format!( - "import {{css, keyframes}} from '@devup-ui/core' -const yellow = css({{ color: 'yellow', _hover: {{ color: 'red' }} }}) -const azure = css({{ color: 'azure', bg: 'black' }}) -const wide = css({{ color: ['a1', 'b1'] }}) -const ordered = css({{ color: 'navy', styleOrder: 3 }}) -const heading = css({{ typography: 'heading' }}) -const body = css({{ typography: 'body' }}) -const chained = css(yellow, azure) -const fade = keyframes({{ from: {{ opacity: 0 }} }}) -export const result = {call} -" - )); - assert!( - code.contains(&format!("export const result = {expected}")), - "{code}" - ); - } - #[test] #[serial] fn test_styled_extension_composes_base() { @@ -15773,8 +15635,8 @@ const Ordered = styled.div({ color: 'red', styleOrder: 3 })", #[test] #[serial] fn test_styled_with_component_and_as() { - let code = readable_code( - "import {styled} from '@devup-ui/core' + let _state = crate::test_state::TestStateGuard::new(); + let source = "import {styled} from '@devup-ui/core' const Section = styled.section.attrs({ role: 'region' })({ color: 'red' }) export const Aside = Section.withComponent('aside') export const Linked = Section.withComponent(Link) @@ -15782,12 +15644,10 @@ export const Nested = Section.withComponent(motion.div) export const Again = Aside.withComponent('nav') export const Kept = Section.withComponent(make()) export const Other = other.withComponent('aside') -export const a =
", - ); +export const a =
"; + let code = readable_code(source); for expected in [ "export const Aside = __devupForwardRef((__devupRefProps, __devupRef) => ((__devupProps) => (({ style, className, as: DevupAs = \"aside\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"c-0-Aside color-0-red--255\", className]", - "as: DevupAs = Link,", - "as: DevupAs = motion.div,", "export const Again = __devupForwardRef((__devupRefProps, __devupRef) => ((__devupProps) => (({ style, className, as: DevupAs = \"nav\",", "export const Kept = Section.withComponent(make());", "export const Other = other.withComponent(\"aside\");", @@ -15796,6 +15656,7 @@ export const a =
", ] { assert!(code.contains(expected), "{expected}\n{code}"); } + let _proof = crate::retained_render_assertions::retarget(source); } // A styled component keeps the props its styles read, `$` props and `theme` @@ -15804,8 +15665,8 @@ export const a =
", #[test] #[serial] fn test_styled_prop_forwarding() { - let code = readable_code( - "import {styled} from '@devup-ui/core' + let _state = crate::test_state::TestStateGuard::new(); + let source = "import {styled} from '@devup-ui/core' import isPropValid from '@emotion/is-prop-valid' const A = styled('h1', { shouldForwardProp: (prop) => prop !== 'tone' })({ color: 'red' }) const B = styled.div`color: ${(p) => p.$c}; background: ${(p) => p.tone};` @@ -15824,19 +15685,16 @@ export const a = export const b = export const j = export const f = -export const k = ", - ); +export const k = "; + let code = readable_code(source); for expected in [ "{...Object.fromEntries(Object.entries((({ \"tone\": __devupOmit0, ...__devupDom }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))}", - "{...Object.fromEntries(Object.entries((({ \"$c\": __devupOmit0, \"tone\": __devupOmit1, \"theme\": __devupOmit2, ...__devupDom }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))}", "{...Object.fromEntries(Object.entries((({ \"x\": __devupOmit0, \"y\": __devupOmit1, \"theme\": __devupOmit2, ...__devupDom }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))}", - "{...Object.fromEntries(Object.entries((({ \"$c\": __devupOmit0, ...__devupDom }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))}", "export const a = ", "export const b = ", "export const j = ", "export const f = ", "export const k = ", - "{...Object.fromEntries(Object.entries((({ \"level\": __devupOmit0, \"theme\": __devupOmit1, ...__devupDom }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))}", ] { assert!(code.contains(expected), "{expected}\n{code}"); } @@ -15862,6 +15720,7 @@ const N = styled('h1', { shouldForwardProp: (prop) => { prop === 'a' } })({ colo "{error}" ); assert!(error.contains("allowed.has(prop)"), "{error}"); + let _proof = crate::retained_render_assertions::forwarding(source); } #[test] @@ -16055,7 +15914,6 @@ export const d = css(ordered, danger, called);", css::debug::set_debug(false); for expected in [ r#"export const a = "color-0-red-_a__c_hover-255-a color-0-crimson--255-a";"#, - r#"export const b = "color-0-red-_a__c_hover-255-a color-0-teal--255-a margin-0-4px--255-a";"#, "export const c = `color-0-a--255-a margin-0-4px--255-a ${text} ${listed} ${spaced} ${fade}`;", "export const d = `margin-0-8px--2-a color-0-crimson--255-a ${called}`;", ] { @@ -16065,6 +15923,9 @@ export const d = css(ordered, danger, called);", output.code ); } + for single_css in [false, true] { + crate::class_selection_tests::imported_b(single_css); + } } #[rstest] @@ -20256,16 +20117,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] @@ -23046,7 +22905,7 @@ export const K = styled.div(base, cond && { color: 'blue' }, { margin: 1 });", ); assert_eq!( result.err().map(|error| error.to_string()), - Some("test.tsx:2:1: Cannot compose `(p) => { return p.cls; }` at build time: each style must be a rule object, a class, or a condition choosing between them".to_string()), + Some("test.tsx:2:12: `styled()` cannot use `(p) => { return p.cls; }` 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 })`".to_string()), ); } @@ -23899,7 +23758,9 @@ injectGlobal`body { margin: 0; }`;", "export const App = () => {({ theme }) => };", "export const App = () => {({ css, theme }) => };", "export const App = () => {({ css }) => };", + "export const App = () => {({ css }) => };", "export const App = () => {({ cx }) => };", + "export const App = () => {({ cx }) => };", "export const App = ({ s }) => {({ cx }) => };", "export const App = () => {({ cx }) => };", "export const App = ({ s }) => {({ css }) => };", @@ -24226,6 +24087,197 @@ export const k = (cls) => merge([cls]);" )); } + #[test] + #[serial] + fn test_style_objects_declared_in_functions() { + let devup = "import { css, styled, globalCss, keyframes, Box } from '@devup-ui/react';\n"; + let cases: Vec = [ + "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); }", + "export function f() { const inner = ({ color: ('red') }); return css(inner); }", + ] + .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(); + let error = + extract("test.tsx", &format!("{import}{code}"), emotion_option()).unwrap_err(); + 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 + )); + } + + #[test] + #[serial] + fn test_styled_props_rules_keep_unmapped_theme_guards() { + for rules in [ + "(p) => ({ color: p.theme.colors[key] })", + "({ theme }) => ({ color: theme.colors[key] })", + "(p) => ({ margin: p.theme.space(2) })", + "{ color: (p) => p.theme.colors[key] }", + "{ margin: ({ theme }) => theme.space(2) }", + "function (p) { return { color: p.theme.colors[key] }; }", + "{ color: function (p) { return p.theme.colors[key]; } }", + "{ margin: function ({ theme }) { return theme.space(2); } }", + "{ color: function (p) { const color = p.theme.colors.primary; return color; } }", + "{ color: async function (p) { return p.theme.colors.primary; } }", + "{ color: function* (p) { return p.theme.colors.primary; } }", + "{ color: async (p) => p.theme.colors.primary }", + "(p) => ({ color: p['theme'].colors[key] })", + "{ color: (p) => p['theme'].colors[key] }", + "({ theme: themeValue }) => ({ color: themeValue.colors[key] })", + "{ color: ({ theme: themeValue }) => themeValue.colors[key] }", + ] { + reset_class_map(); + reset_file_map(); + let code = format!( + "import styled from '@emotion/styled';\nexport const A = styled.div({rules});" + ); + let error = extract("test.tsx", &code, emotion_option()) + .unwrap_err() + .to_string(); + assert!(error.starts_with("test.tsx:2:"), "{error}"); + assert!(error.contains("a theme read must be a path"), "{error}"); + } + } + + #[test] + #[serial] + fn test_styled_renamed_theme_roots_keep_located_errors() { + for rules in [ + "({ theme: themeValue }) => ({ color: themeValue.colors[key] })", + "{ color: ({ theme: themeValue }) => themeValue.colors[key] }", + ] { + reset_class_map(); + reset_file_map(); + let source = format!( + "import styled from '@emotion/styled';\nexport const A = styled.div({rules});" + ); + let error = extract("test.tsx", &source, emotion_option()) + .unwrap_err() + .to_string(); + assert!(error.starts_with("test.tsx:2:"), "{error}"); + assert!(error.contains("a theme read must be a path"), "{error}"); + } + } + + #[test] + #[serial] + fn test_styled_function_leaves_map_literal_theme_paths() { + for rules in [ + "{ color: function (p) { return p.theme.colors.primary; }, margin: function (p) { return p.theme.space[2]; } }", + "{ color: function ({ theme }) { return theme['colors'].primary; }, margin: function ({ theme }) { return theme.space[2]; } }", + "{ color: function (p) { return p['theme'].colors.primary; }, margin: function (p) { return p['theme'].space[2]; } }", + "{ color: ({ theme: themeValue }) => themeValue.colors.primary, margin: ({ theme: themeValue }) => themeValue.space[2] }", + "{ color: function ({ theme: themeValue }) { return themeValue.colors.primary; }, margin: function ({ theme: themeValue }) { return themeValue.space[2]; } }", + ] { + reset_class_map(); + reset_file_map(); + let source = format!( + "import styled from '@emotion/styled';\nexport const A = styled.div({rules});" + ); + let output = extract("test.tsx", &source, emotion_option()).unwrap(); + for expected in ["var(--colors-primary)", "var(--space-2)"] { + assert!(output.styles.iter().any(|style| { + matches!(style, crate::ExtractStyleValue::Static(style) if style.value() == expected) + })); + } + assert!(!output.code.contains("p.theme")); + assert!(!output.code.contains("theme.space")); + } + } + #[test] #[serial] fn test_emotion_class_calls_follow_the_package_namespace_and_aliases() { @@ -24255,3 +24307,8 @@ export const e = classNames({ g: 1 }, { h: 8 });", assert_debug_snapshot!(output.code); } } + +#[cfg(test)] +mod emotion_prop_join_support; +#[cfg(test)] +mod emotion_prop_join_tests; diff --git a/libs/extractor/src/local_capture_coverage_tests.rs b/libs/extractor/src/local_capture_coverage_tests.rs new file mode 100644 index 000000000..c51c0b2dd --- /dev/null +++ b/libs/extractor/src/local_capture_coverage_tests.rs @@ -0,0 +1,186 @@ +use super::*; +use rstest::rstest; + +#[rstest] +#[case("rest")] +#[case("className")] +fn local_shape_when_callback_capture_is_unresolved_reports_binding_error( + #[case] name: &str, +) -> Result<(), Box> { + // Given: these names collide with locals introduced by generated styled scopes. + let allocator = Allocator::default(); + let source = format!( + "import {{styled}} from '@devup-ui/react';\nfunction f() {{ const inner = {{color: () => {name}}}; return styled.div(inner); }}" + ); + let mut parsed = Parser::new(&allocator, &source, SourceType::tsx()).parse(); + assert_eq!(parsed.diagnostics.len(), 0); + let offset = u32::try_from(source.find("inner =").ok_or("missing binding")?)?; + // When + let inlined = inline_constants( + &AstBuilder::new(&allocator), + &mut parsed.program, + "capture.tsx", + &ExtractOption::default(), + None, + CssProp::Off, + &FxHashSet::default(), + ); + // Then: the rejected shape stays a binding read, never a relocated callback. + assert_eq!( + inlined.errors, + vec![(offset, crate::utils::local_style_error("inner"))] + ); + assert_eq!( + crate::located_errors("capture.tsx", &source, &[], inlined.errors), + format!( + "capture.tsx:2:22: {}", + crate::utils::local_style_error("inner") + ) + ); + let generated = oxc_codegen::Codegen::new().build(&parsed.program).code; + assert!(generated.contains("styled.div(inner)"), "{generated}"); + Ok(()) +} + +#[test] +fn local_shape_when_callback_capture_is_resolved_retains_lexical_read() { + // Given + let allocator = Allocator::default(); + let source = "import {styled} from '@devup-ui/react'; function f(p) { const rest = p.color; const inner = {color: () => rest}; return styled.div(inner); }"; + let mut parsed = Parser::new(&allocator, source, SourceType::tsx()).parse(); + assert_eq!(parsed.diagnostics.len(), 0); + // When + let inlined = inline_constants( + &AstBuilder::new(&allocator), + &mut parsed.program, + "capture.tsx", + &ExtractOption::default(), + None, + CssProp::Off, + &FxHashSet::default(), + ); + // Then + assert_eq!(inlined.errors, vec![]); + let generated = oxc_codegen::Codegen::new().build(&parsed.program).code; + assert!( + generated.contains("color: () => __devup_rest"), + "{generated}" + ); +} + +#[rstest] +#[case(true)] +#[case(false)] +fn renamed_shorthand_when_capture_alias_changes_preserves_public_key_and_value( + #[case] rename: bool, +) -> Result<(), Box> { + // Given: only rest is renamed; the unrelated shorthand is a control. + let allocator = Allocator::default(); + let mut parsed = Parser::new( + &allocator, + "function f() { const rest = 'purple'; const unchanged = 'green'; return {rest, unchanged}; }", + SourceType::tsx(), + ).parse(); + assert_eq!(parsed.diagnostics.len(), 0); + let scoping = SemanticBuilder::new() + .build(&parsed.program) + .semantic + .into_scoping(); + let symbol = scoping + .symbol_ids() + .find(|symbol| scoping.symbol_name(*symbol) == "rest") + .ok_or("missing captured binding")?; + let aliases = if rename { + FxHashMap::from_iter([(symbol, "__devup_rest".to_string())]) + } else { + FxHashMap::default() + }; + // When + local_capture_reads::Rename { + builder: &AstBuilder::new(&allocator), + scoping: &scoping, + aliases: &aliases, + } + .visit_program(&mut parsed.program); + let generated = oxc_codegen::Codegen::new().build(&parsed.program).code; + let script = format!("{generated}\nJSON.stringify(f());"); + let value = boa_engine::Context::default() + .eval(boa_engine::Source::from_bytes(&script)) + .map_err(|error| error.to_string())?; + let json = value + .as_string() + .ok_or("expected JSON result")? + .to_std_string_escaped(); + // Then: a shorthand accidentally kept after renaming would change the key. + assert_eq!( + serde_json::from_str::(&json)?, + serde_json::json!({"rest": "purple", "unchanged": "green"}) + ); + Ok(()) +} + +#[test] +#[serial_test::serial] +fn local_shape_when_emotion_styles_read_runtime_leaves_preserves_pixel_context() { + // Given + let source = "import styled from '@emotion/styled';function make(input){const shape={padding:2,color:input.color};return styled.div(shape);}const Choice=make({color:'red'});"; + let option = ExtractOption { + single_css: true, + import_aliases: std::collections::HashMap::from([( + "@emotion/styled".into(), + crate::ImportAlias::DefaultToNamed("styled".into()), + )]), + ..Default::default() + }; + let output = + crate::extract("local-shape.tsx", source, option).unwrap_or_else(|error| panic!("{error}")); + // When + let observed = crate::styled_source_tests::captured_render::execute( + ("local-shape.tsx", None), + &output, + "const element=render({target:Choice,props:{}});JSON.stringify([values(element,'color'),values(element,'padding')]);", + ); + // Then + assert_eq!(observed, serde_json::json!([["red"], ["2px"]])); +} + +#[rstest] +#[case("{}", "blue")] +#[case("{color:'green'}", "red")] +#[serial_test::serial] +fn imported_class_when_used_as_a_controller_selects_by_declarations_not_metadata( + #[case] rules: &str, + #[case] expected: &str, +) { + // Given + let module = format!("import {{css}} from '@devup-ui/react';export const mixin=css({rules});"); + let resolver = move |specifier: &str, _: &str| { + (specifier == "./base").then(|| crate::ResolvedModule { + path: "/base.tsx".into(), + code: module.clone(), + }) + }; + let source = "import {styled} from '@devup-ui/react';import {mixin} from './base';const Choice=styled.div(mixin?{color:'red'}:{color:'blue'});"; + // When + let output = crate::extract_with_modules( + "/entry.tsx", + source, + ExtractOption { + single_css: true, + ..Default::default() + }, + false, + &resolver, + ) + .unwrap_or_else(|error| panic!("{error}")); + // Then + let colors: Vec<_> = output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Static(style) if style.property() == "color" => Some(style.value()), + _ => None, + }) + .collect(); + assert_eq!(colors, [expected]); +} diff --git a/libs/extractor/src/local_capture_reads.rs b/libs/extractor/src/local_capture_reads.rs new file mode 100644 index 000000000..0bfa9eca2 --- /dev/null +++ b/libs/extractor/src/local_capture_reads.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.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_tests.rs b/libs/extractor/src/local_exact_constants_tests.rs new file mode 100644 index 000000000..2133bd527 --- /dev/null +++ b/libs/extractor/src/local_exact_constants_tests.rs @@ -0,0 +1,455 @@ +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 = "static_member_test_support.rs"] +mod member_paths; + +#[path = "local_shape_composition_tests.rs"] +mod composition; + +#[path = "local_shape_hygiene_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!( + member_paths::contains(&output.code, &["inner", "color"]), + "{}", + output.code + ); + assert!( + member_paths::contains(&output.code, &["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.rs b/libs/extractor/src/local_rule_shapes.rs new file mode 100644 index 000000000..b7e4ffeaf --- /dev/null +++ b/libs/extractor/src/local_rule_shapes.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.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.rs b/libs/extractor/src/local_shape_captures.rs new file mode 100644 index 000000000..0db04c52d --- /dev/null +++ b/libs/extractor/src/local_shape_captures.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_tests.rs b/libs/extractor/src/local_shape_composition_tests.rs new file mode 100644 index 000000000..1d3c6ed46 --- /dev/null +++ b/libs/extractor/src/local_shape_composition_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_tests.rs b/libs/extractor/src/local_shape_hygiene_tests.rs new file mode 100644 index 000000000..df9b62810 --- /dev/null +++ b/libs/extractor/src/local_shape_hygiene_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_tests.rs b/libs/extractor/src/numeric_context_tests.rs new file mode 100644 index 000000000..68a881f57 --- /dev/null +++ b/libs/extractor/src/numeric_context_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/retained_render_assertions.rs b/libs/extractor/src/retained_render_assertions.rs new file mode 100644 index 000000000..a61e5230b --- /dev/null +++ b/libs/extractor/src/retained_render_assertions.rs @@ -0,0 +1,195 @@ +use crate::class_selection_support::{compile, observe, option}; +use crate::retained_render_support::{RenderRecords, declarations, props}; +use serde_json::json; + +pub fn opaque(scope: &str, value: &str) -> RenderRecords { + let expected = match scope { + "const scaled = n => n * 4; const size = runtimeSize;" + | "const scaled = n => { externalEffect(); return n * 4; }; const size = 2;" => 8, + "const scaled = n => n * 4; let size = 2; size = 3;" + | "const box = { size: 2 }; box.size = 3; const scaled = n => n * 4;" => 12, + "const scaled = n => n * 4; const size = Math.random();" => 2, + _ => panic!("original opaque fixture"), + }; + let mut records = Vec::new(); + for single_css in [false, true] { + let base = format!( + "import './environment.js'; import {{styled}} from '@devup-ui/core'; {scope} export const Rules = styled.div({{ padding: {value} }});" + ); + for surface in ["base", "reexport"] { + let app = format!( + "import {{styled}} from '@devup-ui/core'; import {{Rules}} from './{surface}.ts'; export const A = styled(Rules)`color: blue;`;" + ); + let outputs = compile( + &[ + ("/namespace/base.ts", &base), + ("/namespace/reexport.ts", "export {Rules} from './base.ts';"), + ("/namespace/app.tsx", &app), + ], + option(single_css, "@devup-ui/core"), + ); + let consumer = &outputs[2].1; + assert_eq!(consumer.styles.len(), 1); + assert_eq!( + consumer.dependencies, + match surface { + "base" => vec!["/namespace/base.ts"], + "reexport" => vec!["/namespace/base.ts", "/namespace/reexport.ts"], + _ => unreachable!(), + } + ); + for private in ["scaled", "runtimeSize", "externalEffect", "box.size"] { + assert!(!consumer.code.contains(private), "{}", consumer.code); + } + // Given: authored free globals execute before the original defining module. + let actual = observe( + &outputs, + concat!( + "import {", + "A", + "} from './app.tsx'; const input={ref:7,children:'child',id:'kept',className:'caller',style:{opacity:0.5}}; const first=render(jsx(A,input)); const effects=globalThis.effects; const second=render(jsx(A,input)); globalThis.observed={first,second,effects,total:globalThis.effects};" + ), + "globalThis.runtimeSize=2; Math.random=()=>0.5; globalThis.effects=0; globalThis.externalEffect=()=>{globalThis.effects++};", + ); + // Then: each actual render preserves origin computation and exactly one effect. + let effects = usize::from(scope.contains("externalEffect")); + assert_eq!(actual["effects"], effects); + assert_eq!(actual["total"], effects * 2); + for element in [&actual["first"], &actual["second"]] { + assert_eq!(element["target"], "div"); + props(element, json!({"ref":7,"children":"child","id":"kept"})); + assert_eq!( + declarations(&outputs, element, single_css), + vec![ + ("color".into(), "blue".into()), + ("padding".into(), expected.into()) + ] + ); + } + records.push(( + outputs, + vec![actual["first"].clone(), actual["second"].clone()], + single_css, + )); + } + } + records +} + +pub fn forwarding(source: &str) -> RenderRecords { + // The full extraction/refusal owner stays intact; these original definitions execute verbatim. + let definitions = source + .lines() + .filter(|line| { + ["const B =", "const L =", "const F ="] + .iter() + .any(|prefix| line.starts_with(prefix)) + }) + .collect::>() + .join("\n"); + let source = format!( + "import './environment.js'; import {{styled}} from '@devup-ui/core';\n{definitions}\nexport {{B,L,F}};" + ); + let mut records = Vec::new(); + for single_css in [false, true] { + let outputs = compile( + &[("/namespace/forwarding.tsx", &source)], + option(single_css, "@devup-ui/core"), + ); + let actual = observe( + &outputs, + "import {B,L,F} from './forwarding.tsx'; const common={ref:7,children:'child',id:'kept',className:'caller',style:{opacity:0.5},theme:{brand:'theme'},$secret:'private'}; const elements=[render(jsx(B,{...common,$c:'red',tone:'blue'})),render(jsx(L,{...common,level:'green',tone:'ordinary',other:'kept',distance:'3px'})),render(jsx(F,{...common,$c:'purple',tone:'ordinary',forwardedAs:'b'}))]; globalThis.observed={elements,calls:globalThis.calls};", + "globalThis.k='distance'; globalThis.config={}; globalThis.calls=[]; globalThis.Link=(props)=>{calls.push(['Link',props.ref,props.as]);return jsx('a',props)};", + ); + assert_eq!(actual["calls"], json!([["Link", 7, "b"]])); + for (i, expected) in [json!({"ref":7,"children":"child","id":"kept"}), json!({"ref":7,"children":"child","id":"kept","tone":"ordinary","other":"kept","distance":"3px"}), json!({"ref":7,"children":"child","id":"kept","tone":"ordinary","theme":{"brand":"theme"},"as":"b"})].into_iter().enumerate() { + assert_eq!(actual["elements"][i]["target"], if i == 2 { "a" } else { "div" }); + props(&actual["elements"][i], expected); + } + assert_eq!( + declarations(&outputs, &actual["elements"][0], single_css), + vec![ + ("background".into(), "blue".into()), + ("color".into(), "red".into()) + ] + ); + assert_eq!( + declarations(&outputs, &actual["elements"][1], single_css), + vec![ + ("color".into(), "green".into()), + ("margin".into(), "3px".into()) + ] + ); + assert_eq!( + declarations(&outputs, &actual["elements"][2], single_css), + vec![("color".into(), "purple".into())] + ); + records.push(( + outputs, + actual["elements"] + .as_array() + .unwrap_or_else(|| panic!("elements")) + .clone(), + single_css, + )); + } + records +} + +pub fn retarget(source: &str) -> RenderRecords { + // Unknown make()/other operations remain extraction-only with their original assertions. + let definitions = source + .lines() + .filter(|line| { + !line.contains("make()") + && !line.contains("other.withComponent") + && !line.starts_with("export const a =") + }) + .collect::>() + .join("\n"); + let source = format!("import './environment.js';\n{definitions}"); + let mut records = Vec::new(); + for single_css in [false, true] { + let outputs = compile( + &[("/namespace/retarget.tsx", &source)], + option(single_css, "@devup-ui/core"), + ); + let actual = observe( + &outputs, + "import {Aside,Linked,Nested,Again} from './retarget.tsx'; const common={children:'child',className:'caller',style:{opacity:0.5}}; const linked=render(jsx(Linked,{...common,ref:7,forwardedAs:'b'})); const nested=render(jsx(Nested,{...common,ref:9})); const overridden=render(jsx(Linked,{...common,as:'nav',ref:11})); const aside=render(jsx(Aside,{...common,ref:13})); const again=render(jsx(Again,{...common,ref:15})); globalThis.observed={elements:[linked,nested,overridden,aside,again],calls:globalThis.calls};", + "globalThis.calls=[]; globalThis.Link=(props)=>{calls.push(['Link',props.ref]);return jsx('a',props)}; globalThis.motion={div:(props)=>{calls.push(['motion.div',props.ref]);return jsx('main',props)}};", + ); + assert_eq!(actual["calls"], json!([["Link", 7], ["motion.div", 9]])); + for (i, (target, reference)) in [ + ("a", 7), + ("main", 9), + ("nav", 11), + ("aside", 13), + ("nav", 15), + ] + .into_iter() + .enumerate() + { + let element = &actual["elements"][i]; + assert_eq!(element["target"], target); + let mut expected = json!({"ref":reference,"children":"child","role":"region"}); + if i == 0 { + expected["as"] = json!("b"); + } + props(element, expected); + assert_eq!( + declarations(&outputs, element, single_css), + vec![("color".into(), "red".into())] + ); + } + records.push(( + outputs, + actual["elements"] + .as_array() + .unwrap_or_else(|| panic!("elements")) + .clone(), + single_css, + )); + } + records +} diff --git a/libs/extractor/src/retained_render_support.rs b/libs/extractor/src/retained_render_support.rs new file mode 100644 index 000000000..899779a60 --- /dev/null +++ b/libs/extractor/src/retained_render_support.rs @@ -0,0 +1,98 @@ +use crate::ExtractStyleValue; +use crate::class_selection_support::Modules; +use crate::extract_style::style_property::StyleProperty; +use serde_json::{Value, json}; + +pub type RenderRecords = Vec<(Modules, Vec, bool)>; + +/// Join selected atoms to concrete emitted values without evaluating detached expressions. +pub fn declarations(outputs: &Modules, element: &Value, single_css: bool) -> Vec<(String, Value)> { + let tokens = element["props"]["className"] + .as_str() + .unwrap_or_else(|| panic!("class string")); + assert_eq!( + tokens + .split_whitespace() + .filter(|token| *token == "caller") + .count(), + 1 + ); + let mut selected = Vec::new(); + let mut variables = std::collections::BTreeSet::new(); + for token in tokens.split_whitespace() { + let mut matches = Vec::new(); + for (name, output) in outputs { + for style in &output.styles { + let filename = (!single_css).then_some(name.as_str()); + let (class, variable) = match style.extract(filename) { + Some(StyleProperty::ClassName(class)) => (class, None), + Some(StyleProperty::Variable { + class_name, + variable_name, + .. + }) => (class_name, Some(variable_name)), + None => continue, + }; + if class != token { + continue; + } + let row = match style { + ExtractStyleValue::Static(style) => { + assert_eq!( + (style.level, &style.selector, &style.layer), + (0, &None, &None) + ); + (style.property.clone(), json!(style.value)) + } + ExtractStyleValue::Dynamic(style) => { + assert_eq!( + (style.level(), style.selector(), style.layer()), + (0, None, None) + ); + let variable = variable.unwrap_or_else(|| panic!("dynamic variable")); + variables.insert(variable.clone()); + let value = &element["props"]["style"][&variable]; + assert!( + value.is_string() || value.is_number(), + "concrete CSS value: {element}" + ); + (style.property().into(), value.clone()) + } + other => panic!("unexpected selected style {other:?}"), + }; + if !matches.contains(&row) { + matches.push(row); + } + } + } + assert!( + matches.len() <= 1, + "ambiguous selected atom {token}: {matches:?}" + ); + selected.extend(matches); + } + let mut style = element["props"]["style"] + .as_object() + .unwrap_or_else(|| panic!("style object")) + .clone(); + assert_eq!(style.remove("opacity"), Some(json!(0.5))); + assert_eq!( + style + .keys() + .cloned() + .collect::>(), + variables + ); + selected.sort_by(|a, b| a.0.cmp(&b.0)); + selected +} + +pub fn props(element: &Value, expected: Value) { + let mut actual = element["props"] + .as_object() + .unwrap_or_else(|| panic!("props object")) + .clone(); + actual.remove("className"); + actual.remove("style"); + assert_eq!(Value::Object(actual), expected); +} diff --git a/libs/extractor/src/selector_array_units_tests.rs b/libs/extractor/src/selector_array_units_tests.rs new file mode 100644 index 000000000..f26ab7998 --- /dev/null +++ b/libs/extractor/src/selector_array_units_tests.rs @@ -0,0 +1,53 @@ +use crate::{ + ExtractOption, ImportAlias, extract, extract_style::extract_style_value::ExtractStyleValue, +}; +use css::{class_map::reset_class_map, file_map::reset_file_map, style_selector::StyleSelector}; +use rstest::rstest; +use serial_test::serial; +use std::collections::HashMap; + +#[rstest] +#[case::selector_array("css({ _hover: [{ padding: A }] })", ("4px", true))] +#[case::selector_constant_array("css({ _hover: selectorValues })", ("4px", true))] +#[case::singleton("css({ padding: [A] })", ("4px", false))] +#[case::fallback("css({ padding: [A, B] })", ("1px;padding:2px", false))] +#[serial] +fn units_when_library_arrays_have_distinct_rule_contexts( + #[case] expression: &str, + #[case] expected: (&str, bool), + #[values(false, true)] single_css: bool, +) { + // Given: selector arrays are native responsive rules, not scalar fallbacks. + let _state = crate::test_state::TestStateGuard::new(); + reset_class_map(); + reset_file_map(); + let source = format!( + "import {{ css }} from '@emotion/react';\nconst A=1, B=2; const selectorValues=[{{ padding: A }}];\nexport const a = {expression};" + ); + // When: the complete library alias and constant-inlining pipeline extracts it. + let output = extract( + "selector-arrays.tsx", + &source, + ExtractOption { + single_css, + import_aliases: HashMap::from([("@emotion/react".into(), ImportAlias::NamedToNamed)]), + ..ExtractOption::default() + }, + ) + .unwrap_or_else(|error| panic!("{expression}: {error}")); + // Then: units and selector/breakpoint identity remain specific to that context. + assert_eq!(output.styles.len(), 1, "{expression}"); + let Some(ExtractStyleValue::Static(style)) = output.styles.iter().next() else { + panic!("expected one static declaration: {expression}"); + }; + assert_eq!( + (style.property.as_str(), style.value.as_str(), style.level), + ("padding", expected.0, 0) + ); + assert_eq!( + style.selector, + expected + .1 + .then(|| StyleSelector::Selector("&:hover".into())) + ); +} diff --git a/libs/extractor/src/snapshots/extractor__imported_styled__tests__imported_components_compose_like_local_ones.snap b/libs/extractor/src/snapshots/extractor__imported_styled__tests__imported_components_compose_like_local_ones.snap index ea9adb269..4185af39a 100644 --- a/libs/extractor/src/snapshots/extractor__imported_styled__tests__imported_components_compose_like_local_ones.snap +++ b/libs/extractor/src/snapshots/extractor__imported_styled__tests__imported_components_compose_like_local_ones.snap @@ -7,10 +7,10 @@ expression: "consumers(&base_module(), COMPOSITIONS)" "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport Span, { Base, Dyn, Pick } from \"./base\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--a a--a h i\", 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: \\\"padding\\\", value: \\\"8px\\\", level: 0, selector: None, 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 A = __devupForwardRef((__devupRefProps, __devupRef) => ((__devupProps) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--a a--a h a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupProps,\n\t...{ role: \"note\" }\n}))({\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: \\\"padding\\\", value: \\\"4px\\\", level: 0, selector: None, 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 A = __devupForwardRef((__devupRefProps, __devupRef) => ((__devupProps) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--a a--a h a\", className].filter(Boolean).join(\" \")} style={style} />)(((__devupContext) => ({\n\t...__devupContext,\n\tclassName: [__devupContext.className, __devupProps.className].filter(Boolean).join(\" \") || undefined\n}))(((__devupContext) => ((__devupAttrs) => ({\n\t...__devupContext,\n\t...__devupAttrs,\n\tclassName: [__devupContext.className, __devupAttrs.className].filter(Boolean).join(\" \") || undefined,\n\tstyle: {\n\t\t...__devupContext.style,\n\t\t...__devupAttrs.style\n\t}\n}))({\n\t...{},\n\t...((p) => ({ title: p.title }))(__devupContext)\n}))({\n\t...__devupProps,\n\tclassName: undefined\n}))))({\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: \\\"padding\\\", value: \\\"4px\\\", level: 0, selector: None, 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 A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"button\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--a a--b h c\", className].filter(Boolean).join(\" \")} style={{\n\t...{ \"--d\": (p=>p.big?`20px`:`10px`)(rest) },\n\t...style\n}} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n{\n \"Dynamic(ExtractDynamicStyle { property: \\\"font-size\\\", level: 0, identifier: \\\"(p=>p.big?`20px`:`10px`)(rest)\\\", selector: None, style_order: None })\",\n \"Static(ExtractStaticStyle { property: \\\"color\\\", value: \\\"blue\\\", level: 0, selector: None, 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 A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"p\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--a a--c h e\", className].filter(Boolean).join(\" \")} style={{\n\t...{ \"--f\": (p=>p.tone)(rest) },\n\t...style\n}} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n{\n \"Dynamic(ExtractDynamicStyle { property: \\\"color\\\", level: 0, identifier: \\\"(p=>p.tone)(rest)\\\", selector: None, style_order: None })\",\n \"Static(ExtractStaticStyle { property: \\\"margin\\\", value: \\\"0\\\", level: 0, selector: None, 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 A = __devupForwardRef((__devupRefProps, __devupRef) => (__devupRecord0=>{const {__devupCss0:__devupRecord1,__devupForwarded0:__devupRecord12,...__devupRecord2}=__devupRecord0;const __devupRecord14=(void 0);const __devupRecord15=(__devupRecord1&&__devupRecord1[3])??__devupRecord14;const __devupRecord13=__devupRecord1?__devupRecord1[2]:false;const __devupRecord8=(__devupRecord2,__devupRecord7,__devupRecord13)=>{const __devupRecord8=(__devupRecord2);return __devupRecord1?(0,__devupRecord1[0])(__devupRecord8,__devupRecord7,__devupRecord13):__devupRecord7(__devupRecord8);};const __devupRecord9=(__devupRecord3,__devupRecord4)=>{const {style,className,as:DevupAs=__devupRecord4[3],forwardedAs}=__devupRecord3;const rest=__devupRecord4[5];const __devupRecord5=[(\"h\")];const __devupRecord6=[[__devupRecord4[0][0],__devupRecord5[0]],__devupRecord4[1],[__devupRecord4[2],\"b--a\",].filter(Boolean).join(' '),__devupRecord4[3],__devupRecord4[4],rest];return __devupRecord1?(0,__devupRecord1[1])(__devupRecord3,__devupRecord6):__devupRecord6[4]({...Object.fromEntries(Object.entries((({ \"big\": __devupOmit0, \"theme\": __devupOmit1, ...__devupDom }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\")),as:__devupRecord6[3],forwardedAs:__devupRecord3.forwardedAs,className:(__devupRecord6[0].length||__devupRecord6[2]!==void 0)?[...__devupRecord6[0],__devupRecord6[2],__devupRecord3.className].filter(Boolean).join(' '):__devupRecord3.className,style:__devupRecord6[1]===void 0?__devupRecord3.style:{...__devupRecord6[1],...__devupRecord3.style}});};return ((DevupStyled) => ())(Dyn);})({\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/base.ts\"]", + "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport Span, { Base, Dyn, Pick } from \"./base\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (__devupRecord0=>{const {__devupCss0:__devupRecord1,__devupForwarded0:__devupRecord12,...__devupRecord2}=__devupRecord0;const __devupRecord14=(void 0);const __devupRecord15=(__devupRecord1&&__devupRecord1[3])??__devupRecord14;const __devupRecord13=__devupRecord1?__devupRecord1[2]:false;const __devupRecord8=(__devupRecord2,__devupRecord7,__devupRecord13)=>{const __devupRecord8=(__devupRecord2);return __devupRecord1?(0,__devupRecord1[0])(__devupRecord8,__devupRecord7,__devupRecord13):__devupRecord7(__devupRecord8);};const __devupRecord9=(__devupRecord3,__devupRecord4)=>{const {style,className,as:DevupAs=__devupRecord4[3],forwardedAs}=__devupRecord3;const rest=__devupRecord4[5];const __devupRecord5=[(\"h\")];const __devupRecord6=[[__devupRecord4[0][0],__devupRecord5[0]],__devupRecord4[1],[__devupRecord4[2],\"b--a\",].filter(Boolean).join(' '),__devupRecord4[3],__devupRecord4[4],rest];return __devupRecord1?(0,__devupRecord1[1])(__devupRecord3,__devupRecord6):__devupRecord6[4]({...Object.fromEntries(Object.entries((({ \"tone\": __devupOmit0, ...__devupDom }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\")),as:__devupRecord6[3],forwardedAs:__devupRecord3.forwardedAs,className:(__devupRecord6[0].length||__devupRecord6[2]!==void 0)?[...__devupRecord6[0],__devupRecord6[2],__devupRecord3.className].filter(Boolean).join(' '):__devupRecord3.className,style:__devupRecord6[1]===void 0?__devupRecord3.style:{...__devupRecord6[1],...__devupRecord3.style}});};return ((DevupStyled) => ())(Pick);})({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n{\n \"Static(ExtractStaticStyle { property: \\\"margin\\\", value: \\\"0\\\", level: 0, selector: None, 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 A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"span\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--a a--d h g\", 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: \\\"1px\\\", level: 0, selector: None, 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 A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--a a--a h a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const B = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--b b--a a--a i h\", 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: \\\"padding\\\", value: \\\"0\\\", level: 0, selector: None, 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\"]", - "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport Span, { Base, Dyn, Pick } from \"./base\";\nexport const W = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"section\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--b a--b c\", className].filter(Boolean).join(\" \")} style={{\n\t...{ \"--d\": (p=>p.big?`20px`:`10px`)(rest) },\n\t...style\n}} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const V = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = Span, forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[`b--a a--a ${rest.__devupCss0 ?? \"a b\"}`, className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n{\n \"Dynamic(ExtractDynamicStyle { property: \\\"font-size\\\", level: 0, identifier: \\\"(p=>p.big?`20px`:`10px`)(rest)\\\", selector: None, style_order: None })\",\n \"Static(ExtractStaticStyle { property: \\\"color\\\", value: \\\"red\\\", level: 0, selector: None, 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\"]", + "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport Span, { Base, Dyn, Pick } from \"./base\";\nexport const W = __devupForwardRef((__devupRefProps, __devupRef) => (__devupRecord0=>{const {__devupCss0:__devupRecord1,__devupForwarded0:__devupRecord12,...__devupRecord2}=__devupRecord0;const __devupRecord14=()=>(\"section\");const __devupRecord15=(__devupRecord1&&__devupRecord1[3])??__devupRecord14;const __devupRecord13=__devupRecord1?__devupRecord1[2]:false;const __devupRecord8=(__devupRecord2,__devupRecord7,__devupRecord13)=>{const __devupRecord8=(__devupRecord2);return __devupRecord1?(0,__devupRecord1[0])(__devupRecord8,__devupRecord7,__devupRecord13):__devupRecord7(__devupRecord8);};const __devupRecord9=(__devupRecord3,__devupRecord4)=>{const {style,className,as:DevupAs=__devupRecord4[3],forwardedAs}=__devupRecord3;const rest=__devupRecord4[5];const __devupRecord5=[];const __devupRecord6=[[__devupRecord4[0][0]],__devupRecord4[1],[__devupRecord4[2],\"b--b\",].filter(Boolean).join(' '),__devupRecord4[3],__devupRecord4[4],rest];return __devupRecord1?(0,__devupRecord1[1])(__devupRecord3,__devupRecord6):__devupRecord6[4]({...Object.fromEntries(Object.entries((({ \"big\": __devupOmit0, \"theme\": __devupOmit1, ...__devupDom }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\")),as:__devupRecord6[3],forwardedAs:__devupRecord3.forwardedAs,className:(__devupRecord6[0].length||__devupRecord6[2]!==void 0)?[...__devupRecord6[0],__devupRecord6[2],__devupRecord3.className].filter(Boolean).join(' '):__devupRecord3.className,style:__devupRecord6[1]===void 0?__devupRecord3.style:{...__devupRecord6[1],...__devupRecord3.style}});};return ((DevupStyled) => ())(Dyn);})({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const V = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = Span, forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[`b--a a--a ${rest.__devupCss0 ?? \"a b\"}`, 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: None, 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\"]", "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport Span, { Base, Dyn, Pick } from \"./base\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--a a--a h a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const C = () => ;\n\n{\n \"Static(ExtractStaticStyle { property: \\\"color\\\", value: \\\"blue\\\", level: 0, selector: None, 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 140ca50b0..82abaa049 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 @@ -4,12 +4,12 @@ expression: outputs --- [ "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport { L, M } from \"./link\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = L, forwardedAs, ...rest }) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--a c\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const B = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = M, forwardedAs, ...rest }) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--b c\", 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/link.ts\"]", - "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport { H } from \"./attrs\";\nimport { D } from \"./function\";\nimport { E } from \"./global\";\nimport { G, T, U } from \"./closed\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = H, forwardedAs, ...rest }) => k[0] !== \"$\"))} as={forwardedAs} className={[\"e--a i\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const B = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = D, forwardedAs, ...rest }) => k[0] !== \"$\"))} as={forwardedAs} className={[\"e--b i\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const C = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"e--c c--a i a\", className].filter(Boolean).join(\" \")} style={{\n\t...{ \"--b\": `${(p=>Number(p.x))(rest)}px` },\n\t...style\n}} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const F = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = E, forwardedAs, ...rest }) => k[0] !== \"$\"))} as={forwardedAs} className={[\"e--d i\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const I = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"e--e c--b i\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const J = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"e--f c--c i\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n{\n \"Dynamic(ExtractDynamicStyle { property: \\\"margin\\\", level: 0, identifier: \\\"`${(p=>Number(p.x))(rest)}px`\\\", selector: None, style_order: None })\",\n \"Static(ExtractStaticStyle { property: \\\"color\\\", value: \\\"blue\\\", level: 0, selector: None, style_order: None, layer: None })\",\n}\n[\"/src/attrs.ts\", \"/src/closed.ts\", \"/src/function.ts\", \"/src/global.ts\"]", + "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport { H } from \"./attrs\";\nimport { D } from \"./function\";\nimport { E } from \"./global\";\nimport { G, T, U } from \"./closed\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (__devupRecord0=>{const {__devupCss0:__devupRecord1,__devupForwarded0:__devupRecord12,...__devupRecord2}=__devupRecord0;const __devupRecord14=(void 0);const __devupRecord15=(__devupRecord1&&__devupRecord1[3])??__devupRecord14;const __devupRecord13=__devupRecord1?__devupRecord1[2]:false;const __devupRecord8=(__devupRecord2,__devupRecord7,__devupRecord13)=>{const __devupRecord8=(__devupRecord2);return __devupRecord1?(0,__devupRecord1[0])(__devupRecord8,__devupRecord7,__devupRecord13):__devupRecord7(__devupRecord8);};const __devupRecord9=(__devupRecord3,__devupRecord4)=>{const {style,className,as:DevupAs=__devupRecord4[3],forwardedAs}=__devupRecord3;const rest=__devupRecord4[5];const __devupRecord5=[(\"i\")];const __devupRecord6=[[(__devupRecord5[0]||__devupRecord4[0][0])],__devupRecord4[1],[__devupRecord4[2],\"e--a\",].filter(Boolean).join(' '),__devupRecord4[3],__devupRecord4[4],rest];return __devupRecord1?(0,__devupRecord1[1])(__devupRecord3,__devupRecord6):__devupRecord6[4]({...Object.fromEntries(Object.entries((({ \"theme\": __devupOmit0, ...__devupDom }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\")),as:__devupRecord6[3],forwardedAs:__devupRecord3.forwardedAs,className:(__devupRecord6[0].length||__devupRecord6[2]!==void 0)?[...__devupRecord6[0],__devupRecord6[2],__devupRecord3.className].filter(Boolean).join(' '):__devupRecord3.className,style:__devupRecord6[1]===void 0?__devupRecord3.style:{...__devupRecord6[1],...__devupRecord3.style}});};return ((DevupStyled) => ())(H);})({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const B = __devupForwardRef((__devupRefProps, __devupRef) => (__devupRecord0=>{const {__devupCss0:__devupRecord1,__devupForwarded0:__devupRecord12,...__devupRecord2}=__devupRecord0;const __devupRecord14=(void 0);const __devupRecord15=(__devupRecord1&&__devupRecord1[3])??__devupRecord14;const __devupRecord13=__devupRecord1?__devupRecord1[2]:false;const __devupRecord8=(__devupRecord2,__devupRecord7,__devupRecord13)=>{const __devupRecord8=(__devupRecord2);return __devupRecord1?(0,__devupRecord1[0])(__devupRecord8,__devupRecord7,__devupRecord13):__devupRecord7(__devupRecord8);};const __devupRecord9=(__devupRecord3,__devupRecord4)=>{const {style,className,as:DevupAs=__devupRecord4[3],forwardedAs}=__devupRecord3;const rest=__devupRecord4[5];const __devupRecord5=[(\"i\")];const __devupRecord6=[[__devupRecord4[0][0],__devupRecord5[0]],__devupRecord4[1],[__devupRecord4[2],\"e--b\",].filter(Boolean).join(' '),__devupRecord4[3],__devupRecord4[4],rest];return __devupRecord1?(0,__devupRecord1[1])(__devupRecord3,__devupRecord6):__devupRecord6[4]({...Object.fromEntries(Object.entries((({ \"theme\": __devupOmit0, ...__devupDom }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\")),as:__devupRecord6[3],forwardedAs:__devupRecord3.forwardedAs,className:(__devupRecord6[0].length||__devupRecord6[2]!==void 0)?[...__devupRecord6[0],__devupRecord6[2],__devupRecord3.className].filter(Boolean).join(' '):__devupRecord3.className,style:__devupRecord6[1]===void 0?__devupRecord3.style:{...__devupRecord6[1],...__devupRecord3.style}});};return ((DevupStyled) => ())(D);})({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const C = __devupForwardRef((__devupRefProps, __devupRef) => (__devupRecord0=>{const {__devupCss0:__devupRecord1,__devupForwarded0:__devupRecord12,...__devupRecord2}=__devupRecord0;const __devupRecord14=(void 0);const __devupRecord15=(__devupRecord1&&__devupRecord1[3])??__devupRecord14;const __devupRecord13=__devupRecord1?__devupRecord1[2]:false;const __devupRecord8=(__devupRecord2,__devupRecord7,__devupRecord13)=>{const __devupRecord8=(__devupRecord2);return __devupRecord1?(0,__devupRecord1[0])(__devupRecord8,__devupRecord7,__devupRecord13):__devupRecord7(__devupRecord8);};const __devupRecord9=(__devupRecord3,__devupRecord4)=>{const {style,className,as:DevupAs=__devupRecord4[3],forwardedAs}=__devupRecord3;const rest=__devupRecord4[5];const __devupRecord5=[(\"i\")];const __devupRecord6=[[__devupRecord4[0][0],__devupRecord5[0]],__devupRecord4[1],[__devupRecord4[2],\"e--c\",].filter(Boolean).join(' '),__devupRecord4[3],__devupRecord4[4],rest];return __devupRecord1?(0,__devupRecord1[1])(__devupRecord3,__devupRecord6):__devupRecord6[4]({...Object.fromEntries(Object.entries((({ \"theme\": __devupOmit0, ...__devupDom }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\")),as:__devupRecord6[3],forwardedAs:__devupRecord3.forwardedAs,className:(__devupRecord6[0].length||__devupRecord6[2]!==void 0)?[...__devupRecord6[0],__devupRecord6[2],__devupRecord3.className].filter(Boolean).join(' '):__devupRecord3.className,style:__devupRecord6[1]===void 0?__devupRecord3.style:{...__devupRecord6[1],...__devupRecord3.style}});};return ((DevupStyled) => ())(G);})({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const F = __devupForwardRef((__devupRefProps, __devupRef) => (__devupRecord0=>{const {__devupCss0:__devupRecord1,__devupForwarded0:__devupRecord12,...__devupRecord2}=__devupRecord0;const __devupRecord14=(void 0);const __devupRecord15=(__devupRecord1&&__devupRecord1[3])??__devupRecord14;const __devupRecord13=__devupRecord1?__devupRecord1[2]:false;const __devupRecord8=(__devupRecord2,__devupRecord7,__devupRecord13)=>{const __devupRecord8=(__devupRecord2);return __devupRecord1?(0,__devupRecord1[0])(__devupRecord8,__devupRecord7,__devupRecord13):__devupRecord7(__devupRecord8);};const __devupRecord9=(__devupRecord3,__devupRecord4)=>{const {style,className,as:DevupAs=__devupRecord4[3],forwardedAs}=__devupRecord3;const rest=__devupRecord4[5];const __devupRecord5=[(\"i\")];const __devupRecord6=[[__devupRecord4[0][0],__devupRecord5[0]],__devupRecord4[1],[__devupRecord4[2],\"e--d\",].filter(Boolean).join(' '),__devupRecord4[3],__devupRecord4[4],rest];return __devupRecord1?(0,__devupRecord1[1])(__devupRecord3,__devupRecord6):__devupRecord6[4]({...Object.fromEntries(Object.entries((({ \"theme\": __devupOmit0, ...__devupDom }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\")),as:__devupRecord6[3],forwardedAs:__devupRecord3.forwardedAs,className:(__devupRecord6[0].length||__devupRecord6[2]!==void 0)?[...__devupRecord6[0],__devupRecord6[2],__devupRecord3.className].filter(Boolean).join(' '):__devupRecord3.className,style:__devupRecord6[1]===void 0?__devupRecord3.style:{...__devupRecord6[1],...__devupRecord3.style}});};return ((DevupStyled) => ())(E);})({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const I = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"e--e c--b i\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const J = __devupForwardRef((__devupRefProps, __devupRef) => (__devupRecord0=>{const {__devupCss0:__devupRecord1,__devupForwarded0:__devupRecord12,...__devupRecord2}=__devupRecord0;const __devupRecord14=(void 0);const __devupRecord15=(__devupRecord1&&__devupRecord1[3])??__devupRecord14;const __devupRecord13=__devupRecord1?__devupRecord1[2]:false;const __devupRecord8=(__devupRecord2,__devupRecord7,__devupRecord13)=>{const __devupRecord8=(__devupRecord2);return __devupRecord1?(0,__devupRecord1[0])(__devupRecord8,__devupRecord7,__devupRecord13):__devupRecord7(__devupRecord8);};const __devupRecord9=(__devupRecord3,__devupRecord4)=>{const {style,className,as:DevupAs=__devupRecord4[3],forwardedAs}=__devupRecord3;const rest=__devupRecord4[5];const __devupRecord5=[(\"i\")];const __devupRecord6=[[(__devupRecord5[0]||__devupRecord4[0][0])],__devupRecord4[1],[__devupRecord4[2],\"e--f\",].filter(Boolean).join(' '),__devupRecord4[3],__devupRecord4[4],rest];return __devupRecord1?(0,__devupRecord1[1])(__devupRecord3,__devupRecord6):__devupRecord6[4]({...Object.fromEntries(Object.entries((({ \"tone\": __devupOmit0, \"theme\": __devupOmit1, ...__devupDom }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\")),as:__devupRecord6[3],forwardedAs:__devupRecord3.forwardedAs,className:(__devupRecord6[0].length||__devupRecord6[2]!==void 0)?[...__devupRecord6[0],__devupRecord6[2],__devupRecord3.className].filter(Boolean).join(' '):__devupRecord3.className,style:__devupRecord6[1]===void 0?__devupRecord3.style:{...__devupRecord6[1],...__devupRecord3.style}});};return ((DevupStyled) => ())(U);})({\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/attrs.ts\", \"/src/closed.ts\", \"/src/function.ts\", \"/src/global.ts\"]", "/src/app.tsx:3:18: `styled()` cannot use `styled(5)` at build time: it renders a tag, a component or a value naming one, with rule objects or CSS text", "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport { Good, Bad } from \"./bad\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = Good, forwardedAs, ...rest }) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--a b\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const B = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--b c\", 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: \\\"color\\\", value: \\\"red\\\", level: 0, selector: Some(Selector(\\\"& .a--b\\\")), style_order: None, layer: None })\",\n}\n[\"/src/bad.ts\"]", "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport { A } from \"./cycle-a\";\nexport const App = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = A, forwardedAs, ...rest }) => k[0] !== \"$\"))} as={forwardedAs} className={[\"c--a 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}\n[\"/src/cycle-a.ts\", \"/src/cycle-b.ts\"]", "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 }) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--a b\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const B = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = Broken, forwardedAs, ...rest }) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--b b\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const C = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = Missing, forwardedAs, ...rest }) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--c b\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const D = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = Nope, forwardedAs, ...rest }) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--d b\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const E = ((DevupStyled) => __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = DevupStyled, forwardedAs, ...rest }) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--e b\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n})))(base);\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 }) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--a a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const B = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = Other, forwardedAs, ...rest }) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--b 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}\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)).filter(([k]) => k[0] !== \"$\"))} 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 }) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--a a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const B = ((DevupStyled) => __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = DevupStyled, forwardedAs, ...rest }) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--b a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n})))(types[\"Base\"]);\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 }) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--a b\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const B = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} 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__imported_styled__tests__re_exports_and_namespaces_reach_the_defining_module.snap b/libs/extractor/src/snapshots/extractor__imported_styled__tests__re_exports_and_namespaces_reach_the_defining_module.snap index 8088d3f8f..a672baa36 100644 --- a/libs/extractor/src/snapshots/extractor__imported_styled__tests__re_exports_and_namespaces_reach_the_defining_module.snap +++ b/libs/extractor/src/snapshots/extractor__imported_styled__tests__re_exports_and_namespaces_reach_the_defining_module.snap @@ -3,12 +3,12 @@ source: libs/extractor/src/imported_styled.rs expression: outputs --- [ - "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport { Dyn } from \"./star\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"button\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--a a--b h c\", className].filter(Boolean).join(\" \")} style={{\n\t...{ \"--d\": (p=>p.big?`20px`:`10px`)(rest) },\n\t...style\n}} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n{\n \"Dynamic(ExtractDynamicStyle { property: \\\"font-size\\\", level: 0, identifier: \\\"(p=>p.big?`20px`:`10px`)(rest)\\\", selector: None, style_order: None })\",\n \"Static(ExtractStaticStyle { property: \\\"color\\\", value: \\\"blue\\\", level: 0, selector: None, style_order: None, layer: None })\",\n}\n[\"/src/base.ts\", \"/src/star.ts\"]", + "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport { Dyn } from \"./star\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (__devupRecord0=>{const {__devupCss0:__devupRecord1,__devupForwarded0:__devupRecord12,...__devupRecord2}=__devupRecord0;const __devupRecord14=(void 0);const __devupRecord15=(__devupRecord1&&__devupRecord1[3])??__devupRecord14;const __devupRecord13=__devupRecord1?__devupRecord1[2]:false;const __devupRecord8=(__devupRecord2,__devupRecord7,__devupRecord13)=>{const __devupRecord8=(__devupRecord2);return __devupRecord1?(0,__devupRecord1[0])(__devupRecord8,__devupRecord7,__devupRecord13):__devupRecord7(__devupRecord8);};const __devupRecord9=(__devupRecord3,__devupRecord4)=>{const {style,className,as:DevupAs=__devupRecord4[3],forwardedAs}=__devupRecord3;const rest=__devupRecord4[5];const __devupRecord5=[(\"h\")];const __devupRecord6=[[__devupRecord4[0][0],__devupRecord5[0]],__devupRecord4[1],[__devupRecord4[2],\"b--a\",].filter(Boolean).join(' '),__devupRecord4[3],__devupRecord4[4],rest];return __devupRecord1?(0,__devupRecord1[1])(__devupRecord3,__devupRecord6):__devupRecord6[4]({...Object.fromEntries(Object.entries((({ \"big\": __devupOmit0, \"theme\": __devupOmit1, ...__devupDom }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\")),as:__devupRecord6[3],forwardedAs:__devupRecord3.forwardedAs,className:(__devupRecord6[0].length||__devupRecord6[2]!==void 0)?[...__devupRecord6[0],__devupRecord6[2],__devupRecord3.className].filter(Boolean).join(' '):__devupRecord3.className,style:__devupRecord6[1]===void 0?__devupRecord3.style:{...__devupRecord6[1],...__devupRecord3.style}});};return ((DevupStyled) => ())(Dyn);})({\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/base.ts\", \"/src/star.ts\"]", "/src/app.tsx:3:18: `styled()` cannot use `styled(1)` at build time: it renders a tag, a component or a value naming one, with rule objects or CSS text", "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport { Renamed, Span } from \"./named\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--a a--a h a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const B = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"span\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--b a--d h g\", 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: \\\"1px\\\", level: 0, selector: None, 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\", \"/src/named.ts\"]", - "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport { Dyn } from \"./forward\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"button\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--a a--b h c\", className].filter(Boolean).join(\" \")} style={{\n\t...{ \"--d\": (p=>p.big?`20px`:`10px`)(rest) },\n\t...style\n}} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n{\n \"Dynamic(ExtractDynamicStyle { property: \\\"font-size\\\", level: 0, identifier: \\\"(p=>p.big?`20px`:`10px`)(rest)\\\", selector: None, style_order: None })\",\n \"Static(ExtractStaticStyle { property: \\\"color\\\", value: \\\"blue\\\", level: 0, selector: None, style_order: None, layer: None })\",\n}\n[\"/src/base.ts\", \"/src/forward.ts\"]", - "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport { Renamed, Dyn } from \"./chain\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--a a--a h a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const B = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"button\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--b a--b h c\", className].filter(Boolean).join(\" \")} style={{\n\t...{ \"--d\": (p=>p.big?`20px`:`10px`)(rest) },\n\t...style\n}} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n{\n \"Dynamic(ExtractDynamicStyle { property: \\\"font-size\\\", level: 0, identifier: \\\"(p=>p.big?`20px`:`10px`)(rest)\\\", selector: None, style_order: None })\",\n \"Static(ExtractStaticStyle { property: \\\"color\\\", value: \\\"blue\\\", level: 0, selector: None, 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\", \"/src/chain.ts\", \"/src/named.ts\", \"/src/star.ts\"]", - "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport * as UI from \"./named\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--a a--a h a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const W = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"a\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--c a--b c\", className].filter(Boolean).join(\" \")} style={{\n\t...{ \"--d\": (p=>p.big?`20px`:`10px`)(rest) },\n\t...style\n}} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const N = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = UI.Nope, forwardedAs, ...rest }) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--b b\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n{\n \"Dynamic(ExtractDynamicStyle { property: \\\"font-size\\\", level: 0, identifier: \\\"(p=>p.big?`20px`:`10px`)(rest)\\\", selector: None, style_order: None })\",\n \"Static(ExtractStaticStyle { property: \\\"color\\\", value: \\\"blue\\\", level: 0, selector: None, style_order: None, layer: None })\",\n \"Static(ExtractStaticStyle { property: \\\"color\\\", value: \\\"red\\\", level: 0, selector: None, 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\", \"/src/named.ts\"]", + "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport { Dyn } from \"./forward\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (__devupRecord0=>{const {__devupCss0:__devupRecord1,__devupForwarded0:__devupRecord12,...__devupRecord2}=__devupRecord0;const __devupRecord14=(void 0);const __devupRecord15=(__devupRecord1&&__devupRecord1[3])??__devupRecord14;const __devupRecord13=__devupRecord1?__devupRecord1[2]:false;const __devupRecord8=(__devupRecord2,__devupRecord7,__devupRecord13)=>{const __devupRecord8=(__devupRecord2);return __devupRecord1?(0,__devupRecord1[0])(__devupRecord8,__devupRecord7,__devupRecord13):__devupRecord7(__devupRecord8);};const __devupRecord9=(__devupRecord3,__devupRecord4)=>{const {style,className,as:DevupAs=__devupRecord4[3],forwardedAs}=__devupRecord3;const rest=__devupRecord4[5];const __devupRecord5=[(\"h\")];const __devupRecord6=[[__devupRecord4[0][0],__devupRecord5[0]],__devupRecord4[1],[__devupRecord4[2],\"b--a\",].filter(Boolean).join(' '),__devupRecord4[3],__devupRecord4[4],rest];return __devupRecord1?(0,__devupRecord1[1])(__devupRecord3,__devupRecord6):__devupRecord6[4]({...Object.fromEntries(Object.entries((({ \"big\": __devupOmit0, \"theme\": __devupOmit1, ...__devupDom }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\")),as:__devupRecord6[3],forwardedAs:__devupRecord3.forwardedAs,className:(__devupRecord6[0].length||__devupRecord6[2]!==void 0)?[...__devupRecord6[0],__devupRecord6[2],__devupRecord3.className].filter(Boolean).join(' '):__devupRecord3.className,style:__devupRecord6[1]===void 0?__devupRecord3.style:{...__devupRecord6[1],...__devupRecord3.style}});};return ((DevupStyled) => ())(Dyn);})({\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/base.ts\", \"/src/forward.ts\"]", + "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport { Renamed, Dyn } from \"./chain\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--a a--a h a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const B = __devupForwardRef((__devupRefProps, __devupRef) => (__devupRecord0=>{const {__devupCss0:__devupRecord1,__devupForwarded0:__devupRecord12,...__devupRecord2}=__devupRecord0;const __devupRecord14=(void 0);const __devupRecord15=(__devupRecord1&&__devupRecord1[3])??__devupRecord14;const __devupRecord13=__devupRecord1?__devupRecord1[2]:false;const __devupRecord8=(__devupRecord2,__devupRecord7,__devupRecord13)=>{const __devupRecord8=(__devupRecord2);return __devupRecord1?(0,__devupRecord1[0])(__devupRecord8,__devupRecord7,__devupRecord13):__devupRecord7(__devupRecord8);};const __devupRecord9=(__devupRecord3,__devupRecord4)=>{const {style,className,as:DevupAs=__devupRecord4[3],forwardedAs}=__devupRecord3;const rest=__devupRecord4[5];const __devupRecord5=[(\"h\")];const __devupRecord6=[[__devupRecord4[0][0],__devupRecord5[0]],__devupRecord4[1],[__devupRecord4[2],\"b--b\",].filter(Boolean).join(' '),__devupRecord4[3],__devupRecord4[4],rest];return __devupRecord1?(0,__devupRecord1[1])(__devupRecord3,__devupRecord6):__devupRecord6[4]({...Object.fromEntries(Object.entries((({ \"big\": __devupOmit0, \"theme\": __devupOmit1, ...__devupDom }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\")),as:__devupRecord6[3],forwardedAs:__devupRecord3.forwardedAs,className:(__devupRecord6[0].length||__devupRecord6[2]!==void 0)?[...__devupRecord6[0],__devupRecord6[2],__devupRecord3.className].filter(Boolean).join(' '):__devupRecord3.className,style:__devupRecord6[1]===void 0?__devupRecord3.style:{...__devupRecord6[1],...__devupRecord3.style}});};return ((DevupStyled) => ())(Dyn);})({\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: \\\"padding\\\", value: \\\"4px\\\", level: 0, selector: None, style_order: None, layer: None })\",\n}\n[\"/src/base.ts\", \"/src/chain.ts\", \"/src/named.ts\", \"/src/star.ts\"]", + "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport * as UI from \"./named\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--a a--a h a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const W = __devupForwardRef((__devupRefProps, __devupRef) => (__devupRecord0=>{const {__devupCss0:__devupRecord1,__devupForwarded0:__devupRecord12,...__devupRecord2}=__devupRecord0;const __devupRecord14=()=>(\"a\");const __devupRecord15=(__devupRecord1&&__devupRecord1[3])??__devupRecord14;const __devupRecord13=__devupRecord1?__devupRecord1[2]:false;const __devupRecord8=(__devupRecord2,__devupRecord7,__devupRecord13)=>{const __devupRecord8=(__devupRecord2);return __devupRecord1?(0,__devupRecord1[0])(__devupRecord8,__devupRecord7,__devupRecord13):__devupRecord7(__devupRecord8);};const __devupRecord9=(__devupRecord3,__devupRecord4)=>{const {style,className,as:DevupAs=__devupRecord4[3],forwardedAs}=__devupRecord3;const rest=__devupRecord4[5];const __devupRecord5=[];const __devupRecord6=[[__devupRecord4[0][0]],__devupRecord4[1],[__devupRecord4[2],\"b--c\",].filter(Boolean).join(' '),__devupRecord4[3],__devupRecord4[4],rest];return __devupRecord1?(0,__devupRecord1[1])(__devupRecord3,__devupRecord6):__devupRecord6[4]({...Object.fromEntries(Object.entries((({ \"big\": __devupOmit0, \"theme\": __devupOmit1, ...__devupDom }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\")),as:__devupRecord6[3],forwardedAs:__devupRecord3.forwardedAs,className:(__devupRecord6[0].length||__devupRecord6[2]!==void 0)?[...__devupRecord6[0],__devupRecord6[2],__devupRecord3.className].filter(Boolean).join(' '):__devupRecord3.className,style:__devupRecord6[1]===void 0?__devupRecord3.style:{...__devupRecord6[1],...__devupRecord3.style}});};return ((DevupStyled) => ())(UI.Dyn);})({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const N = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = UI.Nope, forwardedAs, ...rest }) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--b 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 \"Static(ExtractStaticStyle { property: \\\"color\\\", value: \\\"red\\\", level: 0, selector: None, 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\", \"/src/named.ts\"]", "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport { Base } from \"./named\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = Base, forwardedAs, ...rest }) => k[0] !== \"$\"))} 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: \\\"color\\\", value: \\\"blue\\\", level: 0, selector: None, style_order: None, layer: None })\",\n}\n[\"/src/base.ts\", \"/src/named.ts\"]", - "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport { Dyn, Span } from \"./star\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"button\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--a a--b h c\", className].filter(Boolean).join(\" \")} style={{\n\t...{ \"--d\": (p=>p.big?`20px`:`10px`)(rest) },\n\t...style\n}} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const B = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = Span, forwardedAs, ...rest }) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--b h\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n{\n \"Dynamic(ExtractDynamicStyle { property: \\\"font-size\\\", level: 0, identifier: \\\"(p=>p.big?`20px`:`10px`)(rest)\\\", selector: None, style_order: None })\",\n \"Static(ExtractStaticStyle { property: \\\"color\\\", value: \\\"blue\\\", level: 0, selector: None, style_order: None, layer: None })\",\n}\n[\"/src/base.ts\", \"/src/star.ts\"]", + "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport { Dyn, Span } from \"./star\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (__devupRecord0=>{const {__devupCss0:__devupRecord1,__devupForwarded0:__devupRecord12,...__devupRecord2}=__devupRecord0;const __devupRecord14=(void 0);const __devupRecord15=(__devupRecord1&&__devupRecord1[3])??__devupRecord14;const __devupRecord13=__devupRecord1?__devupRecord1[2]:false;const __devupRecord8=(__devupRecord2,__devupRecord7,__devupRecord13)=>{const __devupRecord8=(__devupRecord2);return __devupRecord1?(0,__devupRecord1[0])(__devupRecord8,__devupRecord7,__devupRecord13):__devupRecord7(__devupRecord8);};const __devupRecord9=(__devupRecord3,__devupRecord4)=>{const {style,className,as:DevupAs=__devupRecord4[3],forwardedAs}=__devupRecord3;const rest=__devupRecord4[5];const __devupRecord5=[(\"h\")];const __devupRecord6=[[__devupRecord4[0][0],__devupRecord5[0]],__devupRecord4[1],[__devupRecord4[2],\"b--a\",].filter(Boolean).join(' '),__devupRecord4[3],__devupRecord4[4],rest];return __devupRecord1?(0,__devupRecord1[1])(__devupRecord3,__devupRecord6):__devupRecord6[4]({...Object.fromEntries(Object.entries((({ \"big\": __devupOmit0, \"theme\": __devupOmit1, ...__devupDom }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\")),as:__devupRecord6[3],forwardedAs:__devupRecord3.forwardedAs,className:(__devupRecord6[0].length||__devupRecord6[2]!==void 0)?[...__devupRecord6[0],__devupRecord6[2],__devupRecord3.className].filter(Boolean).join(' '):__devupRecord3.className,style:__devupRecord6[1]===void 0?__devupRecord3.style:{...__devupRecord6[1],...__devupRecord3.style}});};return ((DevupStyled) => ())(Dyn);})({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const B = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = Span, forwardedAs, ...rest }) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--b h\", 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/base.ts\", \"/src/star.ts\"]", ] 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__conditional_composition.snap b/libs/extractor/src/snapshots/extractor__tests__conditional_composition.snap index 247c9e524..a86dfe6af 100644 --- a/libs/extractor/src/snapshots/extractor__tests__conditional_composition.snap +++ b/libs/extractor/src/snapshots/extractor__tests__conditional_composition.snap @@ -117,5 +117,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nexport const a = `a ${cond ? \"b\" : \"c\"} ${cond ? \"d\" : \"\"}`;\nexport const b = `${flag ? \"e\" : \"f\"} ${flag ? \"\" : \"g\"}`;\nexport const c = `c ${cond ? other : \"\"} ${flag ? \"x\" : \"\"}`;\nexport const d = `i ${cond ? \"h\" : \"\"}`;\nexport const e = `${flag ? other : \"\"} ${flag ? \"c\" : \"b\"}`;\nexport const h = `${cond ? typeof cond === \"string\" ? cond : \"\" : \"\"} ${cond ? \"c\" : \"b\"}`;\nexport const i = \"c\";\nexport const j = `${value != null ? typeof value === \"string\" ? value : \"\" : \"\"} ${value != null ? \"c\" : \"b\"}`;\nexport const K = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => (() => {\n\tconst __devupRecord0 = (0, cond);\n\treturn __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[`a--a ${`${base} ${`j ${__devupRecord0 ? \"b\" : \"\"}`}`}`, className].filter(Boolean).join(\" \")} style={style} />;\n})())({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nexport const a = `a ${cond ? \"b\" : \"c\"} ${cond ? \"d\" : \"\"}`;\nexport const b = `${flag ? \"e\" : \"f\"} ${flag ? \"\" : \"g\"}`;\nexport const c = `c ${cond ? other : \"\"} ${flag ? \"x\" : \"\"}`;\nexport const d = `i ${cond ? \"h\" : \"\"}`;\nexport const e = `${flag ? other : \"\"} ${flag ? \"c\" : \"b\"}`;\nexport const h = `${cond ? typeof cond === \"string\" ? cond : \"\" : \"\"} ${cond ? \"c\" : \"b\"}`;\nexport const i = \"c\";\nexport const j = `${value != null ? typeof value === \"string\" ? value : \"\" : \"\"} ${value != null ? \"c\" : \"b\"}`;\nexport const K = __devupForwardRef((__devupRefProps, __devupRef) => (__devupRecord3=>{const {__devupCss0:__devupRecord4,__devupForwarded0:__devupRecord15,...__devupRecord14}=__devupRecord3;const __devupRecord17=(void 0);const __devupRecord18=(__devupRecord4&&__devupRecord4[3])??__devupRecord17;const __devupRecord16=__devupRecord4?__devupRecord4[2]:false;const __devupRecord13=__devupRecord14.className;const __devupRecord5={...__devupRecord14,className:void 0};const __devupRecord12=(__devupRecord6)=>{const {style,className,as:__devupRecord20,forwardedAs,...rest}=__devupRecord6;let DevupAs=__devupRecord20;if(DevupAs===void 0){if(__devupRecord18){DevupAs = (0,__devupRecord18)();}else{DevupAs = \"div\";}}const __devupRecord19=DevupAs;const __devupRecord0 = (0, base);\nconst __devupRecord1 = (0, cond);\nconst __devupRecord2 = (0, __devupRecord1);\nconst __devupRecord9=[__devupRecord4?[((__devupRecord2 ? true : false) ? __devupRecord2 ? \"b\" : \"\" : \"\"),(\"j\")]:[`j ${(__devupRecord2 ? true : false) ? __devupRecord2 ? \"b\" : \"\" : \"\"}`],(void 0),[\"a--a\",(__devupRecord0)].filter(Boolean).join(' '),__devupRecord19,({ style, className, as: DevupAs = __devupRecord19, forwardedAs, ...rest }) => k[0] !== \"$\"))} as={forwardedAs} className={className} style={style} />,rest];return __devupRecord4?(0,__devupRecord4[1])(__devupRecord6,__devupRecord9):__devupRecord9[4]({...Object.fromEntries(Object.entries((({ \"theme\": __devupOmit0, ...__devupDom }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\")),as:__devupRecord9[3],forwardedAs:__devupRecord6.forwardedAs,className:(__devupRecord9[0].length||__devupRecord9[2]!==void 0)?[...__devupRecord9[0],__devupRecord9[2],__devupRecord6.className].filter(Boolean).join(' '):__devupRecord6.className,style:__devupRecord9[1]===void 0?__devupRecord6.style:{...__devupRecord9[1],...__devupRecord6.style}});};const __devupRecord10=__devupRecord6=>__devupRecord12({...__devupRecord6,className:__devupRecord16?([__devupRecord6.className,__devupRecord13].filter(Boolean).join(' ')||void 0):__devupRecord13});const __devupRecord11=(__devupRecord5);return __devupRecord4?(0,__devupRecord4[0])(__devupRecord11,__devupRecord10,__devupRecord16):__devupRecord10(__devupRecord11);})({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__emotion_and_styled_components_object_styles.snap b/libs/extractor/src/snapshots/extractor__tests__emotion_and_styled_components_object_styles.snap index 569e4ab3d..6bc57fdff 100644 --- a/libs/extractor/src/snapshots/extractor__tests__emotion_and_styled_components_object_styles.snap +++ b/libs/extractor/src/snapshots/extractor__tests__emotion_and_styled_components_object_styles.snap @@ -166,5 +166,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport { Global } from \"@devup-ui/react/compat\";\nexport const a = \"a b c d\";\nexport const B = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--b e\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const C = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"span\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--c f\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const D = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--d f g h i\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const E = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => (() => {\n\tconst __devupRecord0 = (0, cond);\n\treturn __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[`a--e ${`j ${__devupRecord0 ? \"k\" : \"\"}`}`, className].filter(Boolean).join(\" \")} style={style} />;\n})())({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const F = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"p\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--f l\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const spin = \"m\";\nexport const App = () => <>;\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport { Global } from \"@devup-ui/react/compat\";\nexport const a = \"a b c d\";\nexport const B = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--b e\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const C = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"span\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--c f\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const D = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--d g h i f\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const E = __devupForwardRef((__devupRefProps, __devupRef) => (__devupRecord2=>{const {__devupCss0:__devupRecord3,__devupForwarded0:__devupRecord14,...__devupRecord13}=__devupRecord2;const __devupRecord16=(void 0);const __devupRecord17=(__devupRecord3&&__devupRecord3[3])??__devupRecord16;const __devupRecord15=__devupRecord3?__devupRecord3[2]:false;const __devupRecord12=__devupRecord13.className;const __devupRecord4={...__devupRecord13,className:void 0};const __devupRecord11=(__devupRecord5)=>{const {style,className,as:__devupRecord19,forwardedAs,...rest}=__devupRecord5;let DevupAs=__devupRecord19;if(DevupAs===void 0){if(__devupRecord17){DevupAs = (0,__devupRecord17)();}else{DevupAs = \"div\";}}const __devupRecord18=DevupAs;const __devupRecord0 = (0, cond);\nconst __devupRecord1 = (0, __devupRecord0);\nconst __devupRecord8=[__devupRecord3?[(\"k\"),((__devupRecord1 ? true : false) ? __devupRecord1 ? \"j\" : \"\" : \"\")]:[`k ${(__devupRecord1 ? true : false) ? __devupRecord1 ? \"j\" : \"\" : \"\"}`],(void 0),[\"a--e\",].filter(Boolean).join(' '),__devupRecord18,({ style, className, as: DevupAs = __devupRecord18, forwardedAs, ...rest }) => k[0] !== \"$\"))} as={forwardedAs} className={className} style={style} />,rest];return __devupRecord3?(0,__devupRecord3[1])(__devupRecord5,__devupRecord8):__devupRecord8[4]({...Object.fromEntries(Object.entries((({ \"theme\": __devupOmit0, ...__devupDom }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\")),as:__devupRecord8[3],forwardedAs:__devupRecord5.forwardedAs,className:(__devupRecord8[0].length||__devupRecord8[2]!==void 0)?[...__devupRecord8[0],__devupRecord8[2],__devupRecord5.className].filter(Boolean).join(' '):__devupRecord5.className,style:__devupRecord8[1]===void 0?__devupRecord5.style:{...__devupRecord8[1],...__devupRecord5.style}});};const __devupRecord9=__devupRecord5=>__devupRecord11({...__devupRecord5,className:__devupRecord15?([__devupRecord5.className,__devupRecord12].filter(Boolean).join(' ')||void 0):__devupRecord12});const __devupRecord10=(__devupRecord4);return __devupRecord3?(0,__devupRecord3[0])(__devupRecord10,__devupRecord9,__devupRecord15):__devupRecord9(__devupRecord10);})({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const F = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"p\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--f l\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const spin = \"m\";\nexport const App = () => <>;\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..b2214279c --- /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 +--- +[ + "Error: 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", + "Error: test.tsx:2:76: `` cannot use `cx(css({ color: \"red\" }), on && getClass(), css({ color: \"blue\" }))` at build time: `css` and `cx` compose only style objects, CSS text, classes, calls of them, or arrays or conditions of these", + "Error: test.tsx:2:70: `` cannot use `cx(o.getClass(), [make(1)], \"x\")` at build time: `css` and `cx` compose only style objects, CSS text, classes, calls of them, or arrays or conditions of these", +] 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..436c676f9 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,7 +23,9 @@ 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:66: `` cannot use `css(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: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:65: `` cannot use `cx(new Klass())` 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: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", diff --git a/libs/extractor/src/snapshots/extractor__tests__emotion_css_flattens_inline_css_calls.snap b/libs/extractor/src/snapshots/extractor__tests__emotion_css_flattens_inline_css_calls.snap index 10b1d1f72..7da6a4b9f 100644 --- a/libs/extractor/src/snapshots/extractor__tests__emotion_css_flattens_inline_css_calls.snap +++ b/libs/extractor/src/snapshots/extractor__tests__emotion_css_flattens_inline_css_calls.snap @@ -9,15 +9,15 @@ expression: "library_outputs(&[\"import { css } from '@emotion/react';\\nexport ), ( "import { css } from '@emotion/react';\nconst base = css({ color: 'green', margin: 4 });\nexport const a = (cond) => css(cond && css({ color: 'red' }), base);", - "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"green\", 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\\\";\\nconst base = \\\"a b\\\";\\nexport const a = (cond) => \\\"b a\\\";\\n\" }", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"green\", 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\\\";\\nconst base = \\\"a b\\\";\\nexport const a = (cond) => (() => {\\n\\tconst __devupRecord0 = (0, cond);\\n\\treturn \\\"a b\\\";\\n})();\\n\" }", ), ( "import { css } from '@emotion/react';\nexport const a = (cond) => css(cond ? css({ color: 'red' }) : null, { color: 'blue' });", - "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 a = (cond) => \\\"a\\\";\\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 a = (cond) => (() => {\\n\\tconst __devupRecord0 = (0, cond);\\n\\treturn \\\"a\\\";\\n})();\\n\" }", ), ( "import { css } from '@emotion/react';\nexport const a = (cond) => css(cond || css({ color: 'red' }), { 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\\\";\\nexport const a = (cond) => `${cond ? typeof cond === \\\"string\\\" ? cond : \\\"\\\" : \\\"\\\"} ${`b ${cond ? \\\"\\\" : \\\"a\\\"}`}`;\\n\" }", + "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\\\";\\nexport const a = (cond) => (() => {\\n\\tconst __devupRecord0 = (0, cond);\\n\\treturn `${__devupRecord0 ? typeof __devupRecord0 === \\\"string\\\" ? __devupRecord0 : \\\"\\\" : \\\"\\\"} ${`b ${__devupRecord0 ? \\\"\\\" : \\\"a\\\"}`}`;\\n})();\\n\" }", ), ( "import { css } from '@emotion/react';\nexport const a = css([css({ color: 'red' }), [css({ margin: 1 }), { color: 'blue' }]]);", @@ -29,7 +29,7 @@ expression: "library_outputs(&[\"import { css } from '@emotion/react';\\nexport ), ( "import { css } from '@emotion/react';\nexport const a = (cond) => css(cond && [css({ 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\" }", + "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) => (() => {\\n\\tconst __devupRecord0 = (0, cond);\\n\\treturn `b ${__devupRecord0 ? \\\"a\\\" : \\\"\\\"}`;\\n})();\\n\" }", ), ( "import { css } from '@emotion/react';\nexport const a = (rest) => css(css(...rest), { color: 'blue' });", @@ -37,7 +37,7 @@ expression: "library_outputs(&[\"import { css } from '@emotion/react';\\nexport ), ( "import { css } from '@emotion/react';\nexport const a = (cond, cls) => css(css({ color: 'red' }), cls, cond && css({ color: 'blue' }));", - "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 })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport const a = (cond, cls) => `${cls} ${cond ? \\\"a\\\" : \\\"b\\\"}`;\\n\" }", + "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 })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport const a = (cond, cls) => (() => {\\n\\tconst __devupRecord0 = (0, cls);\\n\\tconst __devupRecord1 = (0, cond);\\n\\treturn `${__devupRecord0} ${__devupRecord1 ? \\\"a\\\" : \\\"b\\\"}`;\\n})();\\n\" }", ), ( "import { css } from '@emotion/react';\nexport const a = css(css({ color: 'red' }));", 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 06359c240..72a0a490c 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)(c),\n\t\t\"--d\": (__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(w)\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 c\",\n\tstyle: {\n\t\t...{ \"--d\": (__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_on_every_element.snap b/libs/extractor/src/snapshots/extractor__tests__emotion_css_prop_on_every_element.snap index e30921b8e..34503f489 100644 --- a/libs/extractor/src/snapshots/extractor__tests__emotion_css_prop_on_every_element.snap +++ b/libs/extractor/src/snapshots/extractor__tests__emotion_css_prop_on_every_element.snap @@ -206,7 +206,7 @@ expression: "emotion_outputs(&[(\"test.tsx\",\n\"const Custom = ({ className }) }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nconst Wide = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nconst Toggle = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => (() => {\n\tconst __devupRecord0 = (0, on);\n\treturn __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[__devupRecord0 ? \"b\" : \"c\", className].filter(Boolean).join(\" \")} style={style} />;\n})())({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const App = ({ rest, c }) => <>
;\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nconst Wide = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nconst Toggle = __devupForwardRef((__devupRefProps, __devupRef) => (__devupRecord1=>{const {__devupCss0:__devupRecord2,__devupForwarded0:__devupRecord13,...__devupRecord12}=__devupRecord1;const __devupRecord15=(void 0);const __devupRecord16=(__devupRecord2&&__devupRecord2[3])??__devupRecord15;const __devupRecord14=__devupRecord2?__devupRecord2[2]:false;const __devupRecord11=__devupRecord12.className;const __devupRecord3={...__devupRecord12,className:void 0};const __devupRecord10=(__devupRecord4)=>{const {style,className,as:__devupRecord18,forwardedAs,...rest}=__devupRecord4;let DevupAs=__devupRecord18;if(DevupAs===void 0){if(__devupRecord16){DevupAs = (0,__devupRecord16)();}else{DevupAs = \"div\";}}const __devupRecord17=DevupAs;const __devupRecord0 = (0, on);\nconst __devupRecord7=[__devupRecord2?[(__devupRecord0 ? \"b\" : \"c\")]:[__devupRecord0 ? \"b\" : \"c\"],(void 0),(void 0),__devupRecord17,({ style, className, as: DevupAs = __devupRecord17, forwardedAs, ...rest }) => k[0] !== \"$\"))} as={forwardedAs} className={className} style={style} />,rest];return __devupRecord2?(0,__devupRecord2[1])(__devupRecord4,__devupRecord7):__devupRecord7[4]({...Object.fromEntries(Object.entries((({ \"theme\": __devupOmit0, ...__devupDom }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\")),as:__devupRecord7[3],forwardedAs:__devupRecord4.forwardedAs,className:(__devupRecord7[0].length||__devupRecord7[2]!==void 0)?[...__devupRecord7[0],__devupRecord7[2],__devupRecord4.className].filter(Boolean).join(' '):__devupRecord4.className,style:__devupRecord7[1]===void 0?__devupRecord4.style:{...__devupRecord7[1],...__devupRecord4.style}});};const __devupRecord8=__devupRecord4=>__devupRecord10({...__devupRecord4,className:__devupRecord14?([__devupRecord4.className,__devupRecord11].filter(Boolean).join(' ')||void 0):__devupRecord11});const __devupRecord9=(__devupRecord3);return __devupRecord2?(0,__devupRecord2[0])(__devupRecord9,__devupRecord8,__devupRecord14):__devupRecord8(__devupRecord9);})({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const App = ({ rest, c }) => <>
;\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 `