diff --git a/.changepacks/changepack_log_compose_known_styles.json b/.changepacks/changepack_log_compose_known_styles.json new file mode 100644 index 000000000..6aa4c50d9 --- /dev/null +++ b/.changepacks/changepack_log_compose_known_styles.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "css(a, b) composing classes whose styles the build knows (a const bound to css() in the file, or a css() result another module exports with a rule object every value of which is known) merges their styles: a later part's declaration replaces an earlier one's for the same property, selector, breakpoint and layer, also under conditions (css(base, cond && danger)) and for ||/?? parts, where the classes used to be joined and the stylesheet order picked the winner. vanilla-extract style([a, b]) passes each composed style as its own argument, so the later one wins, and a style composed again later (style([a, b, a])) is kept. Classes the build does not know (strings, props, CSS Modules) are kept as they are", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_compose_styled.json b/.changepacks/changepack_log_compose_styled.json new file mode 100644 index 000000000..4d05b2838 --- /dev/null +++ b/.changepacks/changepack_log_compose_styled.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "styled(Base) extending a styled component the file binds to a const renders Base's tag directly with Base's styles composed under its own, so the extension's declarations replace Base's for the same property, selector, breakpoint and layer instead of losing to them by stylesheet order; Base's attrs apply before the extension's. attrs merge as styled-components merges them: className is joined with the caller's, style is merged and other props replace earlier ones. On an element, a className or style spread after the explicit prop now wins, as React merges props in the order they are written", + "date": "2026-10-01T00:00:00.000Z" +} 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_emotion_class_names.json b/.changepacks/changepack_log_emotion_class_names.json new file mode 100644 index 000000000..9317e2ecf --- /dev/null +++ b/.changepacks/changepack_log_emotion_class_names.json @@ -0,0 +1,8 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "packages/react/package.json": "Patch" + }, + "note": "Emotion's now compiles at build time with no runtime: the element becomes what its child function ({ css, cx, theme }) => ... renders, each css and cx call becomes the classes it composes (later styles replace earlier ones; css reads strings as CSS text and its tagged templates split around mixins; cx keeps unknown classes, reads { name: condition } objects as conditional classes and composes nested css and cx calls), and theme.a.b in their values becomes var(--a-b). Numbers in css rules are px lengths, as Emotion reads them, and top-level constants are inlined. New build errors, each at file:line:col: a without exactly one child function taking { css, cx, theme } that gives what it renders at once (attributes, other children, rest or default parameters, async or generator functions, several statements); reading the theme other than as theme.a.b in a css value; using css or cx other than by calling them; a cx object with a spread, getter or method; parts css and cx cannot compose (runtime calls, spreads, a condition choosing between styles and classes). @devup-ui/react/compat exports ClassNames with the ClassNamesContent and ClassNamesArg types, and the @emotion/react declarations include them", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_emotion_component_selectors.json b/.changepacks/changepack_log_emotion_component_selectors.json new file mode 100644 index 000000000..98c3f7259 --- /dev/null +++ b/.changepacks/changepack_log_emotion_component_selectors.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Emotion and styled-components component selectors now compile at build time: a styled component the file defines that another style selects (${Child} { ... } in CSS text, [Child] or [`&:hover ${Child}`] as a rule key) gets a short marker class, which only such components get, and the selector reads it as .marker; a component extending it, or an element inlining it for its css prop, keeps the marker. Selectors written without & now nest under the component as stylis nests them (ul { ... } is & ul, a selector list nests each selector, :hover applies to the component itself) instead of becoming global rules, and rule-object keys starting with ., #, [, *, >, + or ~ are read as nested selectors instead of properties", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_emotion_css_prop.json b/.changepacks/changepack_log_emotion_css_prop.json new file mode 100644 index 000000000..71e82e1df --- /dev/null +++ b/.changepacks/changepack_log_emotion_css_prop.json @@ -0,0 +1,8 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "packages/react/package.json": "Patch" + }, + "note": "Emotion's css prop compiles at build time while @emotion/react is aliased: on tags and Devup UI components always, and on every element once the file imports @emotion/react or @emotion/styled or names @emotion/react in a @jsxImportSource pragma; jsx, jsxs and jsxDEV from @emotion/react/jsx-runtime (or jsx-dev-runtime) and jsx from @emotion/react compile it too, so libraries built with Emotion's JSX runtime do as well. The prop becomes the element's className, with CSS variables in its style for values only the runtime gives: arrays and conditions compose with a later part replacing what an earlier one sets, css() classes the file knows compose by their styles, strings and templates are CSS text, css`` mixins split the text around them, a function of the theme reads theme.a.b as var(--a-b), unitless numbers are px as in Emotion (constants included), and a className holding known css() classes overrides the prop as Emotion's registered classes do. A styled component the file defines renders its tag in the element's place when the prop overrides its styles and it renders a tag with no attrs or props read and the element has no spread, as or forwardedAs. Emotion's JSX runtime imports become react/jsx-runtime, the pragma names react, and jsx comes from @devup-ui/react/compat (React's createElement); @devup-ui/react/compat/css-prop types the prop on React.Attributes. New build errors, each with file:line:column and the code: a css prop part the build cannot read (a call, an element), a style object declared inside a function or with let, a binding only running the module gives or code changes, a theme function that does more than return rules or reads the theme other than as theme.a.b in a value, an interpolation CSS text cannot place or a mixin inside a nested rule, and a css prop overriding a styled component's styles where its tag cannot be rendered in place. Composing css() with a part that reads a known binding (such as a keyframes name) now reads its value", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_emotion_jsx_import_source.json b/.changepacks/changepack_log_emotion_jsx_import_source.json new file mode 100644 index 000000000..ceb232995 --- /dev/null +++ b/.changepacks/changepack_log_emotion_jsx_import_source.json @@ -0,0 +1,14 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "packages/plugin-utils/package.json": "Patch", + "packages/react/package.json": "Patch", + "packages/vite-plugin/package.json": "Patch", + "packages/next-plugin/package.json": "Patch", + "packages/webpack-plugin/package.json": "Patch", + "packages/rsbuild-plugin/package.json": "Patch", + "packages/bun-plugin/package.json": "Patch" + }, + "note": "A project whose tsconfig.json (or jsconfig.json) builds JSX with Emotion (compilerOptions.jsxImportSource of @emotion/react, through extends and project references too) now compiles the css prop on every element of every file, and its .tsx and .jsx files are built with React's JSX runtime through a /** @jsxImportSource react */ pragma, which a file's own JSX pragma overrides, so the bundler no longer imports Emotion's runtime. The plugins read the setting into a new @emotion/react/jsx-runtime alias (importAliases can turn it off with false, and it is left out when @emotion/react is not aliased); @devup-ui/react/compat/emotion-jsx-runtime declares Emotion's JSX runtimes as React's so the project type-checks without Emotion installed. plugin-utils exports readJsxImportSource, and mergeImportAliases takes the JSX import source", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_emotion_rule_numbers.json b/.changepacks/changepack_log_emotion_rule_numbers.json new file mode 100644 index 000000000..f12449dd0 --- /dev/null +++ b/.changepacks/changepack_log_emotion_rule_numbers.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Numbers in rules written against Emotion, styled-components or vanilla-extract now read as px lengths when a constant stands in for the literal, as they do for a literal written in place: css({ padding: gap }) with const gap = 8, styled.div({ margin: SIZE }), and the same with imported constants and constant style objects give 8px, while unitless properties keep bare numbers, 0 stays 0 and Devup shorthands such as p keep the spacing scale. css`${mixin}; color: red;` outside the css prop composes the mixin by styles, so a later declaration replaces the mixin's, and a css`...` template is a known style a later css() composes. Inline css() calls inside css() arguments (css(css({ ... }), { ... }), css(cond && css({ ... }), base)) are composed with the other parts, the later winning, instead of being compiled to a class of their own. No new build errors", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_imported_styled_definitions.json b/.changepacks/changepack_log_imported_styled_definitions.json new file mode 100644 index 000000000..184bcaf65 --- /dev/null +++ b/.changepacks/changepack_log_imported_styled_definitions.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "A styled component another module defines now composes at build time exactly as one the file defines itself: styled(ImportedBase)(...), styled(ImportedBase).attrs(...), ImportedBase.withComponent(...) and the Emotion css prop on ImportedBase render the base's tag with the base's styles under their own (the later declaration wins per property, selector, breakpoint and layer), merge attrs in order and filter props by what the styles read and shouldForwardProp. The build reads the other module through the same module resolution imported constants use (relative paths, tsconfig paths, package exports, include libraries) and reports it as a dependency, so a change to it rebuilds the importing file. A definition the build cannot read exactly (a base only the runtime gives, an attrs or style expression reading a binding of the defining module, a module with a build error, an unresolvable module, an import cycle) stays a runtime component that wraps, as before. Every exported styled component now carries a marker class computed from its file and its export name alone, so ${ImportedChild} { ... } in CSS text and [ImportedChild] as a rule key select it from another file; the marker replaces the registry-ordered one, which two files could not compute alike. No new build errors: the css prop on an imported styled component now reports the same override error a local one does when its styles overlap the component's", + "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..439e30dc2 --- /dev/null +++ b/.changepacks/changepack_log_local_style_objects.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "A style object declared with const inside a function and passed to css(), styled(), globalCss(), keyframes(), a style prop or a spread (also through the Emotion, styled-components and vanilla-extract aliases) now compiles when every entry is written out and the object is only read as styles, instead of leaving the object where the class belongs; the build errors, at the binding, when the object is also read elsewhere (where code may change it), and at the call when it holds values only the runtime gives. Emotion's ClassNames cx keeps a function call's result as a class that stays as written, as @emotion/css cx does. css() and styled() compose an array of rule objects under a condition. A style function of the props (styled.div((props) => ({ ... })), and as a later argument) compiles: each value reading the props becomes a CSS variable the component sets (a number is a px length unless the property is unitless), theme reads (including theme.a[2]) become var(--a-2), and a condition chooses per value. New build errors: styled(...).attrs(...) chained after the styles (chain .attrs before them), a style function of the props that does not give one rule object at once with every entry written out, and a style object declared in a function that is also read elsewhere", + "date": "2026-10-01T00:00:00.000Z" +} \ No newline at end of file diff --git a/.changepacks/changepack_log_styled_forward_ref.json b/.changepacks/changepack_log_styled_forward_ref.json new file mode 100644 index 000000000..7dd26ffbb --- /dev/null +++ b/.changepacks/changepack_log_styled_forward_ref.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Styled components the build generates are wrapped in React's forwardRef, so a ref passed to them reaches the element they render on React 18 as well as React 19; the file imports forwardRef from react when it defines one", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_styled_props.json b/.changepacks/changepack_log_styled_props.json new file mode 100644 index 000000000..8dfbb440e --- /dev/null +++ b/.changepacks/changepack_log_styled_props.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Styled components no longer pass every prop to the tag they render: `$` props, `theme` and the props their style functions or attrs read are kept away from a tag unless it takes them as attributes, and `shouldForwardProp` (Emotion options or styled-components withConfig) is evaluated at build time. A `shouldForwardProp` the build cannot evaluate is a build error naming the file, line and code, with the forms it accepts. An element using a styled component the file defines drops props the component neither reads nor passes on; spread props are passed as written", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_styled_targets.json b/.changepacks/changepack_log_styled_targets.json new file mode 100644 index 000000000..a51d45fe7 --- /dev/null +++ b/.changepacks/changepack_log_styled_targets.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Styled components render what their `as` prop names, defaulting to the tag or component they were defined with, and pass `forwardedAs` on as `as`, as styled-components and Emotion do; `as` used to reach the rendered tag as an attribute. `Component.withComponent(target)` on a styled component the file binds to a const builds a component rendering the same styles and attrs as `target` (a tag name or a component JSX can name), where it used to call a method the generated function does not have and throw", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/libs/css/src/lib.rs b/libs/css/src/lib.rs index 576ba53b9..d9bd4ae76 100644 --- a/libs/css/src/lib.rs +++ b/libs/css/src/lib.rs @@ -429,6 +429,29 @@ pub fn keyframes_to_keyframes_name(keyframes: &str, filename: Option<&str>) -> S }) } +/// The class marking the component `name` that `filename` defines. +/// +/// Other styles, in this file or another, select the component by it. It is +/// `index` among the components of the file, so it comes from the file and the +/// component alone: every file that selects the component computes the same +/// class, in any process. Two dashes keep it apart from every class styles are +/// named by. +#[must_use] +pub fn component_marker(name: &str, index: usize, filename: &str) -> String { + with_prefix(|prefix| { + let file = get_file_num_by_filename(filename); + if is_debug() { + format!("{prefix}c-{file}-{name}") + } else { + format!( + "{prefix}{}--{}", + num_to_nm_base(file), + num_to_nm_base(index) + ) + } + }) +} + /// ASCII lookup table for selector encoding. `None` means pass through (alphanumeric, `-`, `_`) /// or fall through to the Unicode escape path. const SELECTOR_ENCODE: [Option<&str>; 128] = { @@ -753,6 +776,26 @@ mod tests { ); } + #[test] + #[serial] + fn test_component_marker() { + set_debug(false); + reset_class_map(); + crate::file_map::reset_file_map(); + let child = component_marker("Child", 0, "a.tsx"); + assert_eq!(child, "a--a"); + assert_eq!(component_marker("Child", 0, "a.tsx"), child); + assert_eq!(component_marker("Child", 0, "b.tsx"), "b--a"); + assert_eq!(component_marker("Other", 2, "a.tsx"), "a--c"); + assert_eq!( + sheet_to_classname("color", 0, Some("red"), None, None, None), + "a" + ); + set_debug(true); + assert_eq!(component_marker("Child", 0, "a.tsx"), "c-0-Child"); + set_debug(false); + } + #[test] #[serial] fn test_sheet_to_classname() { diff --git a/libs/extractor/src/bound_callback_alias_tests.rs b/libs/extractor/src/bound_callback_alias_tests.rs new file mode 100644 index 000000000..ae69c9bc2 --- /dev/null +++ b/libs/extractor/src/bound_callback_alias_tests.rs @@ -0,0 +1,91 @@ +use std::collections::HashMap; + +use crate::{ExtractOption, ExtractStyleValue, ImportAlias, extract}; +use css::{class_map::reset_class_map, file_map::reset_file_map}; +use rstest::rstest; +use serial_test::serial; + +#[path = "bound_callback_export_tests.rs"] +mod exports; + +#[path = "bound_callback_numeric_tests.rs"] +mod numeric; + +#[rstest] +#[case("p => ({'@layer': {base: {color:p.color}}})", true)] +#[case("function(p) { return {'@layer': {base: {color:p.color}}}; }", true)] +#[case("p => ({'@layer': {inherit: {color:p.color}}})", false)] +#[case( + "function(p) { return {'@layer': {inherit: {color:p.color}}}; }", + false +)] +#[serial] +fn compiles_callback_layer_qa_sources( + #[case] callback: &str, + #[case] valid: bool, +) -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let source = format!( + "import styled from '@emotion/styled';\nconst rules = {callback}; export const Choice = styled.div(rules);" + ); + let option = ExtractOption { + import_aliases: HashMap::from([( + "@emotion/styled".into(), + ImportAlias::DefaultToNamed("styled".into()), + )]), + ..ExtractOption::default() + }; + // When + let result = extract("callback-layer.tsx", &source, option); + // Then + if valid { + let output = result?; + assert!(output.styles.iter().any(|style| matches!(style, ExtractStyleValue::Dynamic(style) if style.layer.as_deref() == Some("base")))); + assert!(output.code.contains("\"--"), "{}", output.code); + } else { + let Err(error) = result else { + panic!("expected invalid layer error"); + }; + let error = error.to_string(); + let line = source + .lines() + .nth(1) + .ok_or("missing callback source line")?; + let column = line.find("inherit").ok_or("missing inherit key")? + 1; + assert!( + error.starts_with(&format!("callback-layer.tsx:2:{column}:")), + "{error}" + ); + assert!(error.contains("@layer inherit"), "{error}"); + } + Ok(()) +} + +#[rstest] +#[case("import * as ui from '@devup-ui/react';", "ui.styled.div")] +#[case("import {styled as make} from '@devup-ui/react';", "make.div")] +#[serial] +fn resolves_semantic_style_imports( + #[case] import: &str, + #[case] callee: &str, +) -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let source = format!( + "{import} const rules = p => ({{color:p.color}}); export const C = {callee}(rules);" + ); + // When + let output = extract("callback.tsx", &source, ExtractOption::default())?; + // Then + assert!( + output + .styles + .iter() + .any(|style| matches!(style, ExtractStyleValue::Dynamic(_))), + "{output:?}" + ); + Ok(()) +} diff --git a/libs/extractor/src/bound_callback_captures.rs b/libs/extractor/src/bound_callback_captures.rs new file mode 100644 index 000000000..f9046203e --- /dev/null +++ b/libs/extractor/src/bound_callback_captures.rs @@ -0,0 +1,51 @@ +use oxc_ast::{ast::Expression, ast::IdentifierReference}; +use oxc_ast_visit::Visit; +use oxc_semantic::Scoping; +use oxc_span::{GetSpan, Span}; +use oxc_syntax::symbol::SymbolId; +use rustc_hash::FxHashSet; + +pub(super) fn collect( + expression: &Expression<'_>, + scoping: &Scoping, +) -> Option> { + struct Captures<'s> { + scoping: &'s Scoping, + span: Span, + symbols: FxHashSet, + safe: bool, + } + impl<'a> Visit<'a> for Captures<'_> { + fn visit_identifier_reference(&mut self, identifier: &IdentifierReference<'a>) { + match identifier + .reference_id + .get() + .and_then(|id| self.scoping.get_reference(id).symbol_id()) + { + Some(symbol) + if !self + .span + .contains_inclusive(self.scoping.symbol_span(symbol)) => + { + self.symbols.insert(symbol); + } + None if super::local_capture_reads::generated_name(&identifier.name) + || self.scoping.symbol_ids().any(|symbol| { + self.scoping.symbol_name(symbol) == identifier.name.as_str() + }) => + { + self.safe = false; + } + _ => {} + } + } + } + let mut collector = Captures { + scoping, + span: expression.span(), + symbols: FxHashSet::default(), + safe: true, + }; + collector.visit_expression(expression); + collector.safe.then_some(collector.symbols) +} diff --git a/libs/extractor/src/bound_callback_export_tests.rs b/libs/extractor/src/bound_callback_export_tests.rs new file mode 100644 index 000000000..3be224972 --- /dev/null +++ b/libs/extractor/src/bound_callback_export_tests.rs @@ -0,0 +1,206 @@ +use super::*; +use oxc_allocator::{Allocator, CloneIn, TakeIn}; +use oxc_ast::ast::{Declaration, Expression, JSXAttributeValue, Statement}; +use oxc_ast_visit::{VisitMut, walk_mut}; +use oxc_span::SourceType; + +fn declared_names(declaration: &Declaration<'_>) -> Vec { + match declaration { + Declaration::VariableDeclaration(variable) => variable + .declarations + .iter() + .flat_map(|declarator| declarator.id.get_binding_identifiers()) + .map(|binding| binding.name.to_string()) + .collect(), + declaration => declaration + .id() + .iter() + .map(|id| id.name.to_string()) + .collect(), + } +} + +#[rstest] +#[case("export const color = 'red';", "color", "color")] +#[case( + "export function getColor() { return 'red'; }", + "getColor()", + "getColor" +)] +#[case("export let color = 'red';", "color", "color")] +#[case("export var color = 'red';", "color", "color")] +#[case("export const color = 'red', other = 'blue';", "color", "color,other")] +#[case("const color = 'red'; export {color};", "color", "color")] +#[case( + "const color = 'red'; export {color as publicColor};", + "color", + "publicColor" +)] +#[case( + "export default function getColor() { return 'red'; }", + "getColor()", + "default" +)] +#[case("export const color = 'red';", "p.color", "color")] +#[serial] +fn public_names_survive_when_callback_captures_exports( + #[case] declaration: &str, + #[case] value: &str, + #[case] names: &str, +) -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let source = format!( + "import {{styled}} from '@devup-ui/react'; {declaration} const rules = p => ({{color:{value}}}); export const Choice = styled.div(rules);" + ); + // When + let output = extract("export-capture.tsx", &source, ExtractOption::default())?; + // Then + let allocator = Allocator::default(); + let parsed = oxc_parser::Parser::new(&allocator, &output.code, SourceType::tsx()).parse(); + assert_eq!(parsed.diagnostics.len(), 0); + let mut exports = Vec::new(); + for statement in &parsed.program.body { + match statement { + Statement::ExportDeclaration(export) => { + exports.extend(declared_names(&export.declaration)); + } + Statement::ExportNamedDeclaration(export) => exports.extend( + export + .specifiers + .iter() + .map(|specifier| specifier.exported.name().to_string()), + ), + Statement::ExportDefaultDeclaration(_) => exports.push("default".into()), + _ => {} + } + } + exports.sort(); + let mut expected: Vec<_> = names.split(',').chain(["Choice"]).collect(); + expected.sort_unstable(); + assert_eq!(exports, expected, "{}", output.code); + Ok(()) +} + +/// Observe the generated element's style values using the same JSX-to-Boa +/// adapter as the capture hygiene tests, with getters for the parsed exports. +fn consumer_values(code: &str) -> Result> { + struct RenderStyle<'a>(&'a Allocator); + impl<'a> VisitMut<'a> for RenderStyle<'a> { + fn visit_expression(&mut self, expression: &mut Expression<'a>) { + if let Expression::JSXElement(element) = expression { + let value = element + .opening_element + .attributes + .iter() + .find_map(|attribute| { + let attribute = attribute.as_attribute()?; + attribute + .name + .as_identifier() + .filter(|name| name.name == "style")?; + match attribute.value.as_ref()? { + JSXAttributeValue::ExpressionContainer(container) => { + container.expression.as_expression() + } + JSXAttributeValue::StringLiteral(_) + | JSXAttributeValue::Element(_) + | JSXAttributeValue::Fragment(_) => None, + } + }); + assert!(value.is_some(), "expected generated style object"); + if let Some(value) = value { + *expression = value.clone_in(self.0); + } + return; + } + walk_mut::walk_expression(self, expression); + } + } + let allocator = Allocator::default(); + let mut parsed = oxc_parser::Parser::new(&allocator, code, SourceType::tsx()).parse(); + assert_eq!(parsed.diagnostics.len(), 0); + RenderStyle(&allocator).visit_program(&mut parsed.program); + let mut getters = Vec::new(); + let mut body = oxc_allocator::Vec::new_in(&&allocator); + for statement in parsed.program.body { + match statement { + Statement::ImportDeclaration(_) => {} + Statement::ExportDeclaration(mut export) => { + getters.extend( + declared_names(&export.declaration) + .iter() + .map(|name| format!("get {name}() {{ return {name}; }}")), + ); + body.push(Statement::from(export.declaration.take_in(&&allocator))); + } + Statement::ExportNamedDeclaration(export) => { + getters.extend(export.specifiers.iter().map(|specifier| { + format!( + "get {}() {{ return {}; }}", + specifier.exported.name(), + specifier.local.name() + ) + })); + } + statement => body.push(statement), + } + } + parsed.program.body = body; + let compiled = oxc_codegen::Codegen::new().build(&parsed.program).code; + let script = format!( + "const __devupForwardRef = fn => props => fn(props, undefined); {compiled}\nconst consumer = {{{}}}; const before = Object.values(consumer.Choice({{}})); consumer.update(); JSON.stringify([consumer.color, before, Object.values(consumer.Choice({{}}))]);", + getters.join(",") + ); + let json = boa_engine::Context::default() + .eval(boa_engine::Source::from_bytes(&script)) + .map_err(|error| format!("consumer evaluation failed: {error}\n{script}"))? + .as_string() + .ok_or("expected JSON")? + .to_std_string_escaped(); + Ok(serde_json::from_str(&json)?) +} + +#[rstest] +#[case("
")] +#[case("
")] +#[case("
")] +#[case("
")] +#[case("
")] +fn consumer_oracle_when_generated_style_is_missing_rejects(#[case] jsx: &str) { + // Given + let code = format!("const Choice = () => {jsx};"); + // When + let result = std::panic::catch_unwind(|| consumer_values(&code)); + // Then + assert!(result.is_err(), "missing style must fail before evaluation"); +} + +#[rstest] +#[case("export let color = 'red';", "color")] +#[case("let color = 'red'; export {color};", "color")] +#[case( + "export let color = 'red'; function getColor() { return color; }", + "getColor()" +)] +#[serial] +fn consumer_and_rendered_values_stay_live_when_user_scopes_shadow_capture( + #[case] declaration: &str, + #[case] value: &str, +) -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let source = format!( + "import {{styled}} from '@devup-ui/react'; {declaration} export function update() {{ color = 'blue'; }} const rules = p => ({{color:{value}}}); function make(color) {{ return styled.div(rules); }} export const Choice = make('green');" + ); + // When + let output = extract("export-live.tsx", &source, ExtractOption::default())?; + // Then + assert_eq!( + consumer_values(&output.code)?, + serde_json::json!(["blue", ["red"], ["blue"]]) + ); + Ok(()) +} diff --git a/libs/extractor/src/bound_callback_numeric_tests.rs b/libs/extractor/src/bound_callback_numeric_tests.rs new file mode 100644 index 000000000..169e833f1 --- /dev/null +++ b/libs/extractor/src/bound_callback_numeric_tests.rs @@ -0,0 +1,258 @@ +use super::*; +use crate::extract_style::extract_static_style::ExtractStaticStyle; + +fn option() -> ExtractOption { + ExtractOption { + import_aliases: HashMap::from([ + ( + "@emotion/styled".into(), + ImportAlias::DefaultToNamed("styled".into()), + ), + ( + "styled-components".into(), + ImportAlias::DefaultToNamed("styled".into()), + ), + ]), + ..ExtractOption::default() + } +} + +#[rstest] +#[case(("import styled from '@emotion/styled';", "3px"))] +#[case(("import styled from 'styled-components';", "3px"))] +#[case(("import {styled} from '@devup-ui/react';", "12px"))] +#[serial] +fn literal_units_match_inline_when_callbacks_are_bound( + #[case] library: (&str, &str), + #[values(false, true)] bound: bool, + #[values(false, true)] function: bool, +) -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let (import, padding) = library; + let rules = "{padding:3,p:4,lineHeight:2,margin:0,vars:{'--raw':5},_hover:{padding:3},'@layer':{base:{padding:3}}}"; + let callback = if function { + format!("function(p) {{ return {rules}; }}") + } else { + format!("p => ({rules})") + }; + let site = if bound { + format!("const rules = {callback}; export const Choice = styled.div(rules);") + } else { + format!("export const Choice = styled.div({callback});") + }; + // When + let output = extract("callback-units.tsx", &format!("{import} {site}"), option())?; + // Then + let values: Vec<_> = output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Static(style) => { + Some((style.property.as_str(), style.value.as_str())) + } + _ => None, + }) + .collect(); + assert_eq!( + values + .iter() + .filter(|(key, value)| *key == "padding" && *value == padding) + .count(), + 3, + "{output:?}" + ); + assert!(values.contains(&("padding", "16px")), "{output:?}"); + assert!(values.contains(&("line-height", "2")), "{output:?}"); + assert!(values.contains(&("margin", "0")), "{output:?}"); + assert!(values.contains(&("--raw", "5")), "{output:?}"); + Ok(()) +} + +#[rstest] +#[case(("import styled from '@emotion/styled';", "3px", "4px"))] +#[case(("import styled from 'styled-components';", "3px", "4px"))] +#[case(("import {styled} from '@devup-ui/react';", "12px", "16px"))] +#[serial] +fn selected_numeric_rules_when_callbacks_short_circuit_keep_library_units( + #[case] library: (&str, &str, &str), + #[values("||", "??", "&&", "condition")] choice: &str, + #[values(false, true)] bound: bool, +) -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let (import, left_padding, right_padding) = library; + let left = "({padding:3,lineHeight:2,margin:0,vars:{'--raw':5}})"; + let right = "{padding:4,lineHeight:6,margin:0,vars:{'--raw':9}}"; + let rules = if choice == "condition" { + format!("p.input.padding === 3 && {right}") + } else { + format!("{left} {choice} {right}") + }; + let callback = format!("p => ({rules})"); + let site = if bound { + format!("const rules={callback};export const Choice=styled.div(rules);") + } else { + format!("export const Choice=styled.div({callback});") + }; + // When + let output = extract( + "callback-logical-units.tsx", + &format!("{import}{site}"), + ExtractOption { + single_css: true, + ..option() + }, + )?; + // Then: evaluate the generated class selection, not just the emitted atoms. + let classes = output + .code + .split_once("className={") + .ok_or("generated className")? + .1 + .split_once("} style=") + .ok_or("generated style attribute")? + .0; + let atoms = output + .styles + .iter() + .map(|style| { + style + .extract(None) + .ok_or("logical numeric atom has a class identity") + .map(|class| (class.to_string(), style)) + }) + .collect::, _>>()?; + for input in [3, 4] { + let script = + format!("const rest={{input:{{padding:{input}}}}};const className='';({classes});"); + let mut context = boa_engine::Context::default(); + let names = context + .eval(boa_engine::Source::from_bytes(&script))? + .to_string(&mut context)? + .to_std_string_escaped(); + let mut selected: Vec<_> = atoms + .iter() + .filter(|(class, _)| names.split_whitespace().any(|name| name == class)) + .map(|(_, style)| (*style).clone()) + .collect(); + selected.sort_unstable(); + let declarations = if choice == "condition" && input == 4 { + vec![] + } else if choice == "&&" || choice == "condition" { + vec![ + ("--raw", "9"), + ("line-height", "6"), + ("margin", "0"), + ("padding", right_padding), + ] + } else { + vec![ + ("--raw", "5"), + ("line-height", "2"), + ("margin", "0"), + ("padding", left_padding), + ] + }; + let mut expected: Vec<_> = declarations + .into_iter() + .map(|(property, value)| { + ExtractStyleValue::Static(ExtractStaticStyle::new(property, value, 0, None)) + }) + .collect(); + expected.sort_unstable(); + assert_eq!( + selected, expected, + "{choice}, input={input}: {}", + output.code + ); + } + Ok(()) +} + +#[rstest] +#[case(("native.div(rules)", "emotion.div(rules)"))] +#[case(("emotion.div(rules)", "native.div(rules)"))] +#[serial] +fn use_site_units_stay_independent_when_one_callback_serves_both_libraries( + #[case] sites: (&str, &str), + #[values("p => ({ padding: 3 })", "function(p) {return { padding: 3 };}")] callback: &str, +) -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let (first, last) = sites; + let source = format!( + "import emotion from '@emotion/styled'; import {{styled as native}} from '@devup-ui/react'; const rules={callback}; export const First={first}; export const Last={last};" + ); + // When + let output = extract("callback-mixed.tsx", &source, option())?; + // Then + let mut paddings: Vec<_> = output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Static(style) if style.property == "padding" => { + Some(style.value.as_str()) + } + _ => None, + }) + .collect(); + paddings.sort_unstable(); + assert_eq!(paddings, vec!["12px", "3px"], "{output:?}"); + Ok(()) +} + +#[rstest] +#[case("import styled from '@emotion/styled';")] +#[case("import {styled} from '@devup-ui/react';")] +#[serial] +fn runtime_values_and_call_inputs_stay_ordinary_when_callback_is_bound( + #[case] import: &str, + #[values("3", "0", "'3'", "'auto'")] input: &str, + #[values(false, true)] bound: bool, +) -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let callback = "({lineHeight,raw,input={ size: 3, nested: { size: 4 } }}) => ({padding:consume(input,{ size: 3, nested: { size: 4 } }),lineHeight,vars:{'--raw':raw}})"; + let site = if bound { + format!("const rules={callback};export const Choice=styled.div(rules);") + } else { + format!("export const Choice=styled.div({callback});") + }; + // When + let output = extract("callback-values.tsx", &format!("{import}{site}"), option())?; + // Then + let variables = output + .code + .split_once("style={") + .ok_or("missing style")? + .1 + .split_once("} />") + .ok_or("missing JSX end")? + .0; + let script = format!( + "const seen=[]; const rest={{lineHeight:2,raw:7}};const style={{}};function consume(data,explicit){{seen.push(data,explicit);return {input};}}const variables={variables};JSON.stringify([seen,Object.values(variables)]);" + ); + let json = boa_engine::Context::default() + .eval(boa_engine::Source::from_bytes(&script)) + .map_err(|error| format!("generated callback failed: {error}\n{script}"))? + .as_string() + .ok_or("expected JSON")? + .to_std_string_escaped(); + let padding = match input { + "3" => serde_json::json!("3px"), + "0" => serde_json::json!(0), + "'3'" => serde_json::json!("3"), + "'auto'" => serde_json::json!("auto"), + _ => unreachable!(), + }; + assert_eq!( + serde_json::from_str::(&json)?, + serde_json::json!([[{"size":3,"nested":{"size":4}},{"size":3,"nested":{"size":4}}], [padding,2,7]]) + ); + Ok(()) +} diff --git a/libs/extractor/src/bound_style_callbacks.rs b/libs/extractor/src/bound_style_callbacks.rs new file mode 100644 index 000000000..c41645ba0 --- /dev/null +++ b/libs/extractor/src/bound_style_callbacks.rs @@ -0,0 +1,255 @@ +use oxc_allocator::{Address, CloneIn, GetAddress, GetAllocator}; +use oxc_ast::{ + ast::{ + BindingPattern, CallExpression, Declaration, ExportDeclaration, Expression, + ImportDeclaration, ImportDeclarationSpecifier, Program, TaggedTemplateExpression, + VariableDeclaration, VariableDeclarationKind, + }, + builder::AstBuilder, +}; +use oxc_ast_visit::{Visit, VisitMut, walk, walk_mut}; +use oxc_semantic::Scoping; +use oxc_span::{GetSpan, Span}; +use oxc_syntax::{reference::ReferenceId, symbol::SymbolId}; +use rustc_hash::{FxHashMap, FxHashSet}; + +struct Callback<'a> { + expression: Expression<'a>, + constant: bool, +} + +pub(super) struct Rewritten { + pub captures: FxHashSet, + pub errors: Vec<(u32, String)>, + pub sites: Vec<(Span, Address)>, +} + +struct Sites<'s, 'a> { + builder: &'s AstBuilder<'a>, + scoping: &'s Scoping, + styled: FxHashSet, + namespaces: FxHashSet, + package: &'s str, + callbacks: FxHashMap>, + reads: FxHashMap>, +} + +impl<'a> Sites<'_, 'a> { + fn root(&self, expression: &Expression<'a>) -> bool { + match crate::utils::unwrap_syntax_only(expression) { + Expression::Identifier(identifier) => identifier + .reference_id + .get() + .and_then(|id| self.scoping.get_reference(id).symbol_id()) + .is_some_and(|symbol| self.styled.contains(&symbol)), + Expression::StaticMemberExpression(member) => { + self.root(&member.object) + || (member.property.name == "styled" + && matches!(&member.object, Expression::Identifier(identifier) + if identifier.reference_id.get() + .and_then(|id| self.scoping.get_reference(id).symbol_id()) + .is_some_and(|symbol| self.namespaces.contains(&symbol)))) + } + Expression::CallExpression(call) => self.root(&call.callee), + _ => false, + } + } + + fn read(&mut self, expression: &Expression<'a>) { + if let Expression::Identifier(identifier) = crate::utils::unwrap_syntax_only(expression) + && let Some(reference) = identifier.reference_id.get() + && let Some(symbol) = self.scoping.get_reference(reference).symbol_id() + { + self.reads + .entry(symbol) + .or_default() + .push((reference, identifier.span.start)); + } + } +} + +impl<'a> Visit<'a> for Sites<'_, 'a> { + fn visit_import_declaration(&mut self, import: &ImportDeclaration<'a>) { + if !import.source.value.starts_with(self.package) { + return; + } + for specifier in import.specifiers.iter().flatten() { + if let Some(symbol) = specifier.local().symbol_id.get() { + match specifier { + ImportDeclarationSpecifier::ImportSpecifier(specifier) + if specifier.imported.name() == "styled" => + { + self.styled.insert(symbol); + } + ImportDeclarationSpecifier::ImportNamespaceSpecifier(_) + | ImportDeclarationSpecifier::ImportDefaultSpecifier(_) => { + self.namespaces.insert(symbol); + } + ImportDeclarationSpecifier::ImportSpecifier(_) => {} + } + } + } + } + + fn visit_variable_declaration(&mut self, declaration: &VariableDeclaration<'a>) { + for declarator in &declaration.declarations { + if let BindingPattern::BindingIdentifier(binding) = &declarator.id + && let Some(init) = &declarator.init + && let Some(symbol) = binding.symbol_id.get() + && matches!( + crate::utils::unwrap_syntax_only(init), + Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_) + ) + { + self.callbacks.insert( + symbol, + Callback { + expression: crate::utils::unwrap_syntax_only(init) + .clone_in_with_semantic_ids(self.builder.allocator()), + constant: declaration.kind == VariableDeclarationKind::Const, + }, + ); + } + } + walk::walk_variable_declaration(self, declaration); + } + + fn visit_export_declaration(&mut self, export: &ExportDeclaration<'a>) { + walk::walk_export_declaration(self, export); + if let Declaration::VariableDeclaration(declaration) = &export.declaration { + for declarator in &declaration.declarations { + if let BindingPattern::BindingIdentifier(binding) = &declarator.id + && let Some(callback) = binding + .symbol_id + .get() + .and_then(|symbol| self.callbacks.get_mut(&symbol)) + { + callback.constant = false; + } + } + } + } + + fn visit_call_expression(&mut self, call: &CallExpression<'a>) { + if self.root(&call.callee) { + let start = match crate::utils::unwrap_syntax_only(&call.callee) { + Expression::Identifier(_) => Some(1), + Expression::StaticMemberExpression(member) + if matches!(member.property.name.as_str(), "attrs" | "withConfig") => + { + None + } + _ => Some(0), + }; + if let Some(start) = start { + for argument in call.arguments.iter().skip(start) { + if let Some(expression) = argument.as_expression() { + self.read(expression); + } + } + } + } + walk::walk_call_expression(self, call); + } + + fn visit_tagged_template_expression(&mut self, tagged: &TaggedTemplateExpression<'a>) { + if self.root(&tagged.tag) { + for expression in &tagged.quasi.expressions { + self.read(expression); + } + } + walk::walk_tagged_template_expression(self, tagged); + } +} + +/// Clone read-only callbacks only at styled rule sites; preserve semantic IDs +/// so captures can be renamed before lowering turns references into source. +pub(super) fn rewrite<'a>( + builder: &AstBuilder<'a>, + program: &mut Program<'a>, + scoping: &Scoping, + package: &str, +) -> Rewritten { + let mut sites = Sites { + builder, + scoping, + package, + styled: FxHashSet::default(), + namespaces: FxHashSet::default(), + callbacks: FxHashMap::default(), + reads: FxHashMap::default(), + }; + sites.visit_program(program); + let deferred = super::local_capture_reads::deferred_reads(program, scoping); + let mut replacements = FxHashMap::default(); + let mut captures = FxHashSet::default(); + let mut errors = Vec::new(); + for (symbol, callback) in &sites.callbacks { + let Some(reads) = sites.reads.get(symbol) else { + continue; + }; + let references = scoping.get_resolved_reference_ids(*symbol); + let safe = callback.constant + && references.len() == reads.len() + && references + .iter() + .all(|id| !scoping.get_reference(*id).is_write()) + && reads.iter().all(|(id, start)| { + deferred.contains(id) || *start >= callback.expression.span().end + }); + if !safe { + errors.push(( + scoping.symbol_span(*symbol).start, + crate::utils::local_style_error(scoping.symbol_name(*symbol)), + )); + continue; + } + let Some(captured) = super::bound_callback_captures::collect(&callback.expression, scoping) + else { + errors.push(( + scoping.symbol_span(*symbol).start, + crate::utils::local_style_error(scoping.symbol_name(*symbol)), + )); + continue; + }; + captures.extend(captured); + for (reference, _) in reads { + replacements.insert(*reference, &callback.expression); + } + } + let mut rewrite = Rewrite { + builder, + replacements, + sites: Vec::new(), + }; + rewrite.visit_program(program); + errors.sort_unstable_by_key(|(start, _)| *start); + Rewritten { + captures, + errors, + sites: rewrite.sites, + } +} + +struct Rewrite<'s, 'a> { + builder: &'s AstBuilder<'a>, + replacements: FxHashMap>, + sites: Vec<(Span, Address)>, +} + +impl<'a> VisitMut<'a> for Rewrite<'_, 'a> { + fn visit_expression(&mut self, expression: &mut Expression<'a>) { + if let Expression::Identifier(identifier) = expression + && let Some(callback) = identifier + .reference_id + .get() + .and_then(|id| self.replacements.get(&id)) + { + let span = identifier.span; + *expression = callback.clone_in_with_semantic_ids(self.builder.allocator()); + self.sites.push((span, expression.address())); + return; + } + walk_mut::walk_expression(self, expression); + } +} diff --git a/libs/extractor/src/bound_style_callbacks_tests.rs b/libs/extractor/src/bound_style_callbacks_tests.rs new file mode 100644 index 000000000..6cd959096 --- /dev/null +++ b/libs/extractor/src/bound_style_callbacks_tests.rs @@ -0,0 +1,248 @@ +use crate::{ExtractOption, ExtractStyleValue, extract}; +use css::{class_map::reset_class_map, file_map::reset_file_map}; +use rstest::rstest; +use serial_test::serial; + +fn compile(source: &str) -> Result> { + extract("callback.tsx", source, ExtractOption::default()) +} + +#[rstest] +#[case("p => ({'@layer': {base: {color: p.color}}})", false)] +#[case("function(p) { return {'@layer': {base: {color: p.color}}}; }", false)] +#[case("p => ({'@layer': {base: {color: p.color}}})", true)] +#[case("function(p) { return {'@layer': {base: {color: p.color}}}; }", true)] +#[case("({color = 'red'}) => ({'@layer': {base: {color}}})", true)] +#[case("(p = {}) => ({'@layer': {base: {color: p.color}}})", false)] +#[serial] +fn compiles_bound_callback_like_inline( + #[case] callback: &str, + #[case] local: bool, +) -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let body = format!("const rules = {callback}; return styled.div(rules);"); + let body = if local { + format!("export function make() {{ {body} }}") + } else { + format!("const rules = {callback}; export const C = styled.div(rules);") + }; + let source = format!("import {{styled}} from '@devup-ui/react';\n{body}"); + // When + let output = compile(&source)?; + // Then + assert!( + output + .styles + .iter() + .any(|style| matches!(style, ExtractStyleValue::Dynamic(style) if style.layer.as_deref() == Some("base"))), + "{output:?}" + ); + assert!(output.code.contains("\"--"), "{}", output.code); + assert!( + !output.code.contains("styled.div(rules)"), + "{}", + output.code + ); + Ok(()) +} + +#[rstest] +#[case("p => ({'@layer': {inherit: {color: p.color}}})")] +#[case("function(p) { return {'@layer': {inherit: {color: p.color}}}; }")] +#[serial] +fn reports_original_invalid_layer_key( + #[case] callback: &str, +) -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let source = format!( + "import {{styled}} from '@devup-ui/react';\nconst rules = {callback};\nexport const C = styled.div(rules);" + ); + // When + let error = compile(&source) + .err() + .ok_or("expected invalid layer error")? + .to_string(); + // Then + assert!(error.starts_with("callback.tsx:2:"), "{error}"); + assert!(error.contains("@layer inherit"), "{error}"); + Ok(()) +} + +#[rstest] +#[case("let rules = p => ({color:p.color}); export const C = styled.div(rules);")] +#[case( + "const rules = p => ({color:p.color}); rules = p => ({color:'red'}); export const C = styled.div(rules);" +)] +#[case("const rules = p => ({color:p.color}); consume(rules); export const C = styled.div(rules);")] +#[case("export const C = styled.div(rules); const rules = p => ({color:p.color});")] +#[case("const rules = p => unknown(p); export const C = styled.div(rules);")] +#[case("const rules = async p => ({color:p.color}); export const C = styled.div(rules);")] +#[case("export const rules = p => ({color:p.color}); export const C = styled.div(rules);")] +#[case( + "export function make() { const rules = p => { const x = p.color; return { color:x}; }; return styled.div(rules); }" +)] +#[case("const rules = p => ({ color:rest}); export const C = styled.div(rules);")] +#[case( + "const rules = p => ({ color:missing}); export function make(missing) { return styled.div(rules); }" +)] +#[serial] +fn rejects_unsafe_bound_callbacks(#[case] body: &str) -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let source = format!("import {{styled}} from '@devup-ui/react';\n{body}"); + // When + let error = compile(&source) + .err() + .ok_or("expected unsafe callback error")? + .to_string(); + // Then + assert!(error.starts_with("callback.tsx:2:"), "{error}"); + Ok(()) +} + +#[test] +#[serial] +fn preserves_application_arguments_inside_callback() -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let source = "import {styled} from '@devup-ui/react'; const rules = p => ({color: consume(p.color)}); export const C = styled.div(rules);"; + // When + let output = compile(source)?; + // Then + assert!(output.code.contains("consume(p.color)"), "{}", output.code); + assert!( + output + .styles + .iter() + .any(|style| matches!(style, ExtractStyleValue::Dynamic(_))) + ); + Ok(()) +} + +#[rstest] +#[case( + "const rules = p => ({color:p.color}); export function make() { const rules = p => ({color:'blue'}); return styled.div(rules); }", + "blue" +)] +#[serial] +fn resolves_lexical_constants( + #[case] body: &str, + #[case] expected: &str, +) -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let source = format!("import {{styled}} from '@devup-ui/react'; {body}"); + // When + let output = compile(&source)?; + // Then + assert!(output.styles.iter().any(|style| matches!(style, ExtractStyleValue::Static(style) if style.property == "color" && style.value == expected)), "{output:?}"); + Ok(()) +} + +#[rstest] +#[case("rest")] +#[case("style")] +#[case("__devupValue")] +#[case("color")] +#[serial] +fn preserves_capture_across_generated_and_user_shadowing( + #[case] name: &str, +) -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let source = format!( + "import {{styled}} from '@devup-ui/react'; export function outer({name}) {{ const rules = p => ({{color:{name}}}); return function inner({name}) {{ return styled.div(rules); }}; }}" + ); + // When + let output = compile(&source)?; + // Then + assert!( + output + .code + .contains(&format!("function outer(__devup_{name}")), + "{}", + output.code + ); + assert!( + output.code.contains(&format!("function inner({name})")), + "{}", + output.code + ); + assert!(output.styles.iter().any(|style| matches!(style, ExtractStyleValue::Dynamic(style) if style.identifier().contains(&format!("__devup_{name}"))))); + Ok(()) +} + +#[test] +#[serial] +fn permits_deferred_outer_read_before_its_declaration() -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let source = "import {styled} from '@devup-ui/react'; export function make() { const rules = p => ({ color:later}); const later = getColor(); return styled.div(rules); }"; + // When + let output = compile(source)?; + // Then + assert!(output.styles.iter().any(|style| matches!(style, ExtractStyleValue::Dynamic(style) if style.identifier().contains("__devup_later"))), "{output:?}"); + Ok(()) +} + +#[rstest] +#[case("export const C = styled.div` ${rules} `;")] +#[case("export const C = styled('div')(rules);")] +#[serial] +fn resolves_callback_at_styled_rule_sites( + #[case] use_site: &str, +) -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let source = format!( + "import {{styled}} from '@devup-ui/react'; const rules = p => ({{color:p.color}}); {use_site}" + ); + // When + let output = compile(&source)?; + // Then + assert!( + output + .styles + .iter() + .any(|style| matches!(style, ExtractStyleValue::Dynamic(_))), + "{output:?}" + ); + Ok(()) +} + +#[test] +#[serial] +fn leaves_ordinary_function_props_and_application_data_unchanged() +-> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let source = "import {styled, Box} from '@devup-ui/react'; const rules = p => ({color:p.color}); export const data = consume(rules); export const button = ; export const C = styled.div({color:p => consume({width:2}, p.color)});"; + // When + let output = compile(source)?; + // Then + assert!(output.code.contains("consume(rules)"), "{}", output.code); + assert!( + output.code.contains(&format!("onClick={{{}}}", "rules")), + "{}", + output.code + ); + assert!( + output + .code + .contains(&format!("consume({{width:{}}},p.color)", 2)), + "{}", + output.code + ); + Ok(()) +} diff --git a/libs/extractor/src/build_time_values.rs b/libs/extractor/src/build_time_values.rs index 1e8bdd7e3..c7156bbf7 100644 --- a/libs/extractor/src/build_time_values.rs +++ b/libs/extractor/src/build_time_values.rs @@ -57,14 +57,14 @@ pub(crate) fn has_build_time_values( option: &ExtractOption, resolver: Option<&ModuleResolver>, ) -> bool { - let (code, _) = crate::import_alias_visit::transform_import_aliases_with_edits( + let aliased = crate::import_alias_visit::transform_import_aliases_with_edits( code, filename, &option.package, &option.import_aliases, ); let allocator = Allocator::default(); - let Some(mut program) = parse(&allocator, filename, &code) else { + let Some(mut program) = parse(&allocator, filename, &aliased.code) else { return false; }; let inlined = crate::imported_constants::inline_constants( @@ -73,6 +73,8 @@ pub(crate) fn has_build_time_values( filename, option, resolver, + aliased.css_prop, + &aliased.px, ); let changes = crate::imported_constants::ChangeCheck::new(&program, filename, option, resolver); !find( diff --git a/libs/extractor/src/capture_public_exports_w27.rs b/libs/extractor/src/capture_public_exports_w27.rs new file mode 100644 index 000000000..f228e3324 --- /dev/null +++ b/libs/extractor/src/capture_public_exports_w27.rs @@ -0,0 +1,67 @@ +use oxc_allocator::{GetAllocator, TakeIn, Vec}; +use oxc_ast::ast::{ + BindingIdentifier, Declaration, ExportSpecifier, IdentifierName, IdentifierReference, + ImportOrExportKind, ModuleExportName, Program, Statement, +}; + +/// Separate renamed declarations from their public names with live export +/// specifiers, moving the original AST to retain semantic IDs and source spans. +pub(super) fn preserve<'a>(program: &mut Program<'a>, rename: &super::Rename<'_, 'a>) { + let mut body = Vec::with_capacity_in(program.body.len(), rename.builder); + for statement in program.body.take_in(rename.builder) { + let Statement::ExportDeclaration(mut export) = statement else { + body.push(statement); + continue; + }; + let bindings: std::vec::Vec<&BindingIdentifier<'a>> = match &export.declaration { + Declaration::VariableDeclaration(variable) => variable + .declarations + .iter() + .flat_map(|declarator| declarator.id.get_binding_identifiers()) + .collect(), + declaration => declaration.id().into_iter().collect(), + }; + if !bindings.iter().any(|binding| { + binding + .symbol_id + .get() + .is_some_and(|symbol| rename.aliases.contains_key(&symbol)) + }) { + body.push(Statement::ExportDeclaration(export)); + continue; + } + let specifiers = Vec::from_iter_in( + bindings.iter().map(|binding| { + let local = binding + .symbol_id + .get() + .and_then(|symbol| rename.aliases.get(&symbol)) + .map_or(binding.name.as_str(), String::as_str); + ExportSpecifier::new( + binding.span, + ModuleExportName::IdentifierReference(IdentifierReference::new( + binding.span, + rename.builder.allocator().alloc_str(local), + rename.builder, + )), + ModuleExportName::IdentifierName(IdentifierName::new( + binding.span, + binding.name, + rename.builder, + )), + ImportOrExportKind::Value, + rename.builder, + ) + }), + rename.builder, + ); + body.push(Statement::from(export.declaration.take_in(rename.builder))); + body.push(Statement::new_export_named_declaration( + export.span, + specifiers, + ImportOrExportKind::Value, + rename.builder, + )); + } + program.body = body; +} diff --git a/libs/extractor/src/composition.rs b/libs/extractor/src/composition.rs new file mode 100644 index 000000000..b59abb6c6 --- /dev/null +++ b/libs/extractor/src/composition.rs @@ -0,0 +1,402 @@ +//! Styles composed in one place, such as `css(base, cond && danger)`: when the +//! build knows the parts, a later part's declaration replaces an earlier one's +//! for the same property, selector, breakpoint and layer. Atomic classes alone +//! 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, +} + +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), + } + } +} + +/// What a key resolves to +enum Choice<'a> { + Empty, + Atom(ExtractStyleValue), + Conditional { + test: Expression<'a>, + consequent: Box>, + alternate: Box>, + }, +} + +/// What a part does to a key +enum Overlay<'a> { + /// Leaves what came before + Keep, + Atom(ExtractStyleValue), + Conditional { + test: Expression<'a>, + consequent: Box>, + alternate: Box>, + }, +} + +/// The parts composed so far, in source order: a key a later part sets moves +/// to the end, as when merging objects +#[derive(Default)] +pub struct Composition<'a> { + entries: Vec<(CascadeKey, Choice<'a>)>, + /// Styles whose properties the build cannot pair up, kept as they are + unkeyed: Vec>, +} + +impl<'a> Composition<'a> { + /// A part the build knows completely + pub fn apply(&mut self, ast_builder: &AstBuilder<'a>, props: Vec>) { + let mut overlays: Vec<(CascadeKey, Overlay<'a>)> = Vec::new(); + for prop in props { + self.overlays(ast_builder, prop, &mut overlays); + } + for (key, overlay) in overlays { + self.overlay(ast_builder, key, overlay); + } + } + + /// A part applying only while `test` holds, or `alternate` otherwise + pub fn apply_conditional( + &mut self, + ast_builder: &AstBuilder<'a>, + test: &Expression<'a>, + consequent: Vec>, + alternate: Vec>, + ) { + let prop = ExtractStyleProp::Conditional { + condition: test.clone_in(ast_builder.allocator()), + consequent: Some(Box::new(ExtractStyleProp::StaticArray(consequent))), + alternate: Some(Box::new(ExtractStyleProp::StaticArray(alternate))), + }; + self.apply(ast_builder, vec![prop]); + } + + /// Each key a prop sets, with what it sets it to + fn overlays( + &mut self, + ast_builder: &AstBuilder<'a>, + prop: ExtractStyleProp<'a>, + overlays: &mut Vec<(CascadeKey, Overlay<'a>)>, + ) { + match prop { + ExtractStyleProp::Static(value) => { + push_overlay(overlays, CascadeKey::of(&value), Overlay::Atom(value)); + } + ExtractStyleProp::StaticArray(props) => { + for prop in props { + self.overlays(ast_builder, prop, overlays); + } + } + ExtractStyleProp::Conditional { + condition, + consequent, + alternate, + } if [&consequent, &alternate] + .into_iter() + .flatten() + .all(|side| keyed(side)) => + { + let mut sides = [Vec::new(), Vec::new()]; + for (side, prop) in sides.iter_mut().zip([consequent, alternate]) { + if let Some(prop) = prop { + self.overlays(ast_builder, *prop, side); + } + } + let [mut consequent, mut alternate] = sides; + let mut keys: Vec = Vec::new(); + for (key, _) in consequent.iter().chain(&alternate) { + if !keys.contains(key) { + keys.push(key.clone()); + } + } + for key in keys { + let consequent = take_overlay(&mut consequent, &key); + let alternate = take_overlay(&mut alternate, &key); + push_overlay( + overlays, + key, + Overlay::Conditional { + test: condition.clone_in(ast_builder.allocator()), + consequent: Box::new(consequent), + alternate: Box::new(alternate), + }, + ); + } + } + prop => self.unkeyed.push(prop), + } + } + + fn overlay(&mut self, ast_builder: &AstBuilder<'a>, key: CascadeKey, overlay: Overlay<'a>) { + let previous = self + .entries + .iter() + .position(|(existing, _)| *existing == key) + .map_or(Choice::Empty, |index| self.entries.remove(index).1); + let choice = resolve(ast_builder, overlay, previous); + self.entries.push((key, choice)); + } + + /// 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(); + 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 + .into_iter() + .filter_map(|(_, choice)| into_prop(choice)) + .chain(self.unkeyed) + .collect() + } + + /// The composed atoms when no condition chooses between them + #[must_use] + pub fn unconditional(&self) -> Option> { + if !self.unkeyed.is_empty() { + return None; + } + let mut values = Vec::new(); + for (_, choice) in &self.entries { + match choice { + Choice::Atom(value) => values.push(value.clone()), + // A key is empty only under a condition + Choice::Empty | Choice::Conditional { .. } => return None, + } + } + Some(values) + } +} + +/// Styles a part of `css(...)` gives +pub enum KnownStyles<'a> { + /// The styles of a `css()` class the file binds + Known(Vec), + /// A rule object + Rules(Expression<'a>), +} + +/// A side of a condition among the parts of `css(...)` +pub enum KnownSide<'a> { + Styles(Vec>), + /// A class the build does not know the styles of + Class(Expression<'a>), + Empty, +} + +/// A part of `css(...)`, in source order +pub enum KnownPart<'a> { + Styles(Vec>), + Conditional { + test: Expression<'a>, + consequent: Vec>, + alternate: Vec>, + }, + Class(Expression<'a>), +} + +/// `prop`'s styles at `order`, unless one sets its own +pub fn set_prop_order(prop: &mut ExtractStyleProp<'_>, order: u8) { + match prop { + ExtractStyleProp::Static(value) => value.set_style_order(order), + ExtractStyleProp::StaticArray(props) => { + for prop in props { + set_prop_order(prop, order); + } + } + ExtractStyleProp::Conditional { + consequent, + alternate, + .. + } => { + for side in [consequent, alternate].into_iter().flatten() { + set_prop_order(side, order); + } + } + ExtractStyleProp::Enum { map, .. } => { + for prop in map.values_mut().flatten() { + set_prop_order(prop, order); + } + } + ExtractStyleProp::MemberExpression { map, .. } => { + for prop in map.values_mut() { + set_prop_order(prop, order); + } + } + // Class names the code gives, and styles reported as errors + ExtractStyleProp::Expression { .. } | ExtractStyleProp::Unreadable { .. } => {} + } +} + +/// 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) +} + +/// Whether every style `prop` holds has a key, so a condition around it can be +/// applied key by key +fn keyed(prop: &ExtractStyleProp<'_>) -> bool { + match prop { + ExtractStyleProp::Static(_) => true, + ExtractStyleProp::StaticArray(props) => props.iter().all(keyed), + ExtractStyleProp::Conditional { + consequent, + alternate, + .. + } => [consequent, alternate] + .into_iter() + .flatten() + .all(|side| keyed(side)), + _ => false, + } +} + +/// `overlay` replacing what `overlays` already sets for the key: a part +/// declaring a property twice keeps the later declaration +fn push_overlay<'a>( + overlays: &mut Vec<(CascadeKey, Overlay<'a>)>, + key: CascadeKey, + overlay: Overlay<'a>, +) { + overlays.retain(|(existing, _)| *existing != key); + overlays.push((key, overlay)); +} + +fn take_overlay<'a>( + overlays: &mut Vec<(CascadeKey, Overlay<'a>)>, + key: &CascadeKey, +) -> Overlay<'a> { + overlays + .iter() + .position(|(existing, _)| existing == key) + .map_or(Overlay::Keep, |index| overlays.remove(index).1) +} + +/// What `overlay` makes of `previous` +fn resolve<'a>( + ast_builder: &AstBuilder<'a>, + overlay: Overlay<'a>, + previous: Choice<'a>, +) -> Choice<'a> { + match overlay { + Overlay::Keep => previous, + Overlay::Atom(value) => Choice::Atom(value), + Overlay::Conditional { + test, + consequent, + alternate, + } => { + let copy = copy_choice(ast_builder, &previous); + Choice::Conditional { + test, + consequent: Box::new(resolve(ast_builder, *consequent, previous)), + alternate: Box::new(resolve(ast_builder, *alternate, copy)), + } + } + } +} + +fn copy_choice<'a>(ast_builder: &AstBuilder<'a>, choice: &Choice<'a>) -> Choice<'a> { + match choice { + Choice::Empty => Choice::Empty, + Choice::Atom(value) => Choice::Atom(value.clone()), + Choice::Conditional { + test, + consequent, + alternate, + } => Choice::Conditional { + test: test.clone_in(ast_builder.allocator()), + consequent: Box::new(copy_choice(ast_builder, consequent)), + alternate: Box::new(copy_choice(ast_builder, alternate)), + }, + } +} + +fn into_prop(choice: Choice<'_>) -> Option> { + match choice { + Choice::Empty => None, + Choice::Atom(value) => Some(ExtractStyleProp::Static(value)), + Choice::Conditional { + test, + consequent, + alternate, + } => { + let (consequent, alternate) = (into_prop(*consequent), into_prop(*alternate)); + (consequent.is_some() || alternate.is_some()).then(|| ExtractStyleProp::Conditional { + condition: test, + consequent: consequent.map(Box::new), + alternate: alternate.map(Box::new), + }) + } + } +} diff --git a/libs/extractor/src/composition_coverage_tests.rs b/libs/extractor/src/composition_coverage_tests.rs new file mode 100644 index 000000000..53112de89 --- /dev/null +++ b/libs/extractor/src/composition_coverage_tests.rs @@ -0,0 +1,214 @@ +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, 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(scoping); + let known = visitor + .style_values + .root_symbol("known") + .ok_or("missing known binding")?; + let external = visitor + .style_values + .root_symbol("external") + .ok_or("missing external binding")?; + visitor + .style_values + .insert(known, StyleValue::Class(String::new(), Some(vec![]))); + visitor + .style_values + .insert(external, StyleValue::Class("external".into(), None)); + let oxc_ast::ast::Statement::ExpressionStatement(statement) = + parsed.program.body.last().ok_or("missing expression")? + else { + return Err("expected expression fixture".into()); + }; + let Expression::TaggedTemplateExpression(tag) = &statement.expression else { + return Err("expected tag".into()); + }; + // When + let result = visitor + .compose_template(tag, false) + .ok_or("composition was not recognized")?; + // Then + assert_eq!(matches!(result, Expression::StringLiteral(_)), literal); + assert_eq!( + crate::utils::readable_code(&result), + if literal { "\"external\"" } else { "opaque" } + ); + assert_eq!(visitor.styles.len(), 0); + Ok(()) +} + +#[rstest] +#[case("css(known, { color: 'blue' })")] +#[case("css(flag ? known : null, { color: 'blue' })")] +#[serial] +fn known_call_when_later_rules_replace_all_choices_records_exact_inline_metadata( + #[case] call: &str, +) -> Result<(), Box> { + // Given + let allocator = Allocator::default(); + let source = format!("const known = ''; {call};"); + let parsed = Parser::new(&allocator, &source, SourceType::tsx()).parse(); + let scoping = SemanticBuilder::new() + .build(&parsed.program) + .semantic + .into_scoping(); + let known = scoping + .get_root_binding("known".into()) + .ok_or("missing known binding")?; + let mut visitor = + DevupVisitor::new(&allocator, "coverage.tsx", "@devup-ui/react", vec![], None); + visitor.style_values = StyleValues::new(scoping); + visitor.style_values.insert( + known, + StyleValue::Class( + "known".into(), + Some(vec![ExtractStyleValue::Static(ExtractStaticStyle::new( + "color", "red", 0, None, + ))]), + ), + ); + let oxc_ast::ast::Statement::ExpressionStatement(statement) = + parsed.program.body.last().ok_or("missing expression")? + else { + return Err("expected call fixture".into()); + }; + let Expression::CallExpression(call) = &statement.expression else { + return Err("expected call".into()); + }; + // When + let result = visitor + .compose_known_styles(call, false) + .ok_or("known call was unreadable")?; + // Then + let expected = vec![ExtractStyleValue::Static(ExtractStaticStyle::new( + "color", "blue", 0, None, + ))]; + assert_eq!( + visitor.css_styles, + Some((call.span.start, expected.clone())) + ); + assert_eq!( + visitor.inline_css_styles.get(&call.span.start), + Some(&expected) + ); + assert!( + matches!(result, Expression::StringLiteral(literal) if literal.span.start == call.span.start) + ); + Ok(()) +} + +#[test] +#[serial] +fn known_array_when_it_contains_holes_skips_them_without_losing_metadata() +-> Result<(), Box> { + // Given + let allocator = Allocator::default(); + let parsed = Parser::new( + &allocator, + "const known = ''; [, known];", + SourceType::tsx(), + ) + .parse(); + let scoping = SemanticBuilder::new() + .build(&parsed.program) + .semantic + .into_scoping(); + let known = scoping + .get_root_binding("known".into()) + .ok_or("missing known binding")?; + let style = ExtractStyleValue::Static(ExtractStaticStyle::new("color", "red", 0, None)); + let mut visitor = + DevupVisitor::new(&allocator, "coverage.tsx", "@devup-ui/react", vec![], None); + visitor.style_values = StyleValues::new(scoping); + visitor.style_values.insert( + known, + StyleValue::Class("known".into(), Some(vec![style.clone()])), + ); + let oxc_ast::ast::Statement::ExpressionStatement(statement) = + parsed.program.body.last().ok_or("missing expression")? + else { + return Err("expected array fixture".into()); + }; + let mut parts = Vec::new(); + // When + visitor + .known_parts(&statement.expression, &mut parts, Text::Classes) + .ok_or("array was unreadable")?; + // Then + assert_eq!(parts.len(), 1); + assert!( + matches!(&parts[0], KnownPart::Styles(styles) if matches!(&styles[..], [super::KnownStyles::Known(values)] if values == &[style])) + ); + Ok(()) +} diff --git a/libs/extractor/src/composition_runtime_coverage_tests.rs b/libs/extractor/src/composition_runtime_coverage_tests.rs new file mode 100644 index 000000000..da5e91bbb --- /dev/null +++ b/libs/extractor/src/composition_runtime_coverage_tests.rs @@ -0,0 +1,161 @@ +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(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, 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, 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(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, 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/css_composition_rest_tests.rs b/libs/extractor/src/css_composition_rest_tests.rs new file mode 100644 index 000000000..26a82142b --- /dev/null +++ b/libs/extractor/src/css_composition_rest_tests.rs @@ -0,0 +1,405 @@ +use crate::extract_style::ExtractStyleProperty; +use crate::extract_style::extract_static_style::ExtractStaticStyle; +use crate::extract_style::style_property::StyleProperty; +use crate::{ExtractOption, ExtractOutput, ExtractStyleValue, ImportAlias, extract}; +use oxc_allocator::Allocator; +use oxc_ast::ast::Expression; +use oxc_parser::Parser; +use oxc_span::SourceType; +use rstest::rstest; +use serial_test::serial; +use std::collections::HashMap; + +fn compile(body: &str) -> ExtractOutput { + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + extract( + "composition.tsx", + &format!("import {{ css }} from '@devup-ui/react';\n{body}"), + ExtractOption { + single_css: true, + ..ExtractOption::default() + }, + ) + .unwrap_or_else(|error| panic!("{body}: {error}")) +} + +fn result_styles(output: &ExtractOutput, condition: bool) -> Vec { + fn classes(expression: &Expression<'_>, condition: bool) -> String { + match expression { + Expression::StringLiteral(literal) => literal.value.to_string(), + Expression::ConditionalExpression(choice) => classes( + if condition { + &choice.consequent + } else { + &choice.alternate + }, + condition, + ), + Expression::TemplateLiteral(template) => { + let mut result = String::new(); + for (index, quasi) in template.quasis.iter().enumerate() { + result.push_str(quasi.value.raw.as_str()); + if let Some(expression) = template.expressions.get(index) { + result.push_str(&classes(expression, condition)); + } + } + result + } + _ => panic!("unexpected compiled classes: {expression:?}"), + } + } + let source = output + .code + .split("const result = ") + .nth(1) + .unwrap_or_else(|| panic!("missing result: {}", output.code)); + let source = source.trim().trim_end_matches(';'); + let allocator = Allocator::default(); + let expression = Parser::new(&allocator, source, SourceType::tsx()) + .parse_expression() + .unwrap_or_else(|error| panic!("{source}: {error:?}")); + let classes = classes(&expression, condition); + let mut styles: Vec<_> = output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Static(style) => match style.extract(None) { + StyleProperty::ClassName(name) + if classes.split_whitespace().any(|class| class == name) => + { + Some(style.clone()) + } + _ => None, + }, + _ => None, + }) + .collect(); + styles.sort(); + styles +} + +#[rstest] +#[case("css(css`color: red; padding: 4px;`, { color: 'blue', m: 2 })", "blue")] +#[case("css({ color: 'blue', m: 2 }, css`color: red; padding: 4px;`)", "red")] +#[case( + "css`color: blue; margin: 8px; ${css`color: red; padding: 4px;`}`", + "red" +)] +#[case( + "css`${css`color: red; padding: 4px;`} color: blue; margin: 8px;`", + "blue" +)] +#[case( + "css({ color: 'blue', m: 2 }, css(...[{ color: 'red', p: 1 }]))", + "red" +)] +#[case( + "css(css(...[{ color: 'red', p: 1 }]), { color: 'blue', m: 2 })", + "blue" +)] +#[case( + "css({ color: 'blue', m: 2 }, css(...[, ...[{ color: 'red', p: 1 }], null]))", + "red" +)] +#[case( + "css`color: blue; margin: 8px; ${css(...[{ color: 'red', p: 1 }])}`", + "red" +)] +#[test] +#[serial] +fn later_styles_win_when_inline_parts_compose(#[case] expression: &str, #[case] color: &str) { + let source = format!("export const result = {expression};"); + let output = compile(&source); + let styles = result_styles(&output, true); + let declarations: Vec<_> = styles + .iter() + .map(|style| (style.property.as_str(), style.value.as_str())) + .collect(); + assert_eq!( + declarations, + vec![("color", color), ("margin", "8px"), ("padding", "4px")] + ); + assert!(!output.code.contains("css("), "{}", output.code); + assert!(!output.code.contains("css`"), "{}", output.code); +} + +#[rstest] +#[case( + "css({ color: 'green', m: 2 }, flag ? css`color: red; padding: 4px;` : css`color: blue; padding: 4px;`)", + "red", + "blue" +)] +#[case( + "css(flag && css`color: red; padding: 4px;`, { color: 'blue', m: 2 })", + "blue", + "blue" +)] +#[case( + "css`color: green; margin: 8px; ${flag ? css`color: red;` : css`color: blue;`}`", + "red", + "blue" +)] +#[case( + "css(css`color: red; ${flag && css`color: green;`}`, { color: 'blue', m: 2 })", + "blue", + "blue" +)] +#[case( + "css({ color: 'blue', m: 2 }, css`color: red; ${flag && css`color: green;`}`)", + "green", + "red" +)] +#[test] +#[serial] +fn choices_compose_when_inline_templates_are_conditional( + #[case] expression: &str, + #[case] yes: &str, + #[case] no: &str, +) { + let output = compile(&format!("export const result = {expression};")); + for (condition, color) in [(true, yes), (false, no)] { + let styles = result_styles(&output, condition); + let colors: Vec<_> = styles + .iter() + .filter(|style| style.property == "color") + .map(|style| style.value.as_str()) + .collect(); + assert_eq!(colors, vec![color]); + assert!( + styles + .iter() + .any(|style| style.property == "margin" && style.value == "8px") + ); + } +} + +#[test] +#[serial] +fn spread_arrays_compose_when_constants_are_exact() { + let output = compile( + "const first = [{ color: 'red', p: 1 }];\nconst parts = [...first, , { color: 'green' }];\nexport const result = css(css(...parts), { color: 'blue', m: 2 });", + ); + let styles = result_styles(&output, true); + assert_eq!( + styles + .iter() + .map(|style| (style.property.as_str(), style.value.as_str())) + .collect::>(), + vec![("color", "blue"), ("margin", "8px"), ("padding", "4px")] + ); +} + +#[test] +#[serial] +fn external_literals_stay_classes_when_their_text_matches_generated_classes() { + let output = compile( + "const base = css`color: red;`;\nexport const result = css('a', css`color: blue;`);", + ); + assert!(output.code.contains("\"a "), "{}", output.code); +} + +#[rstest] +#[case( + "css(css`color: red; &:hover { color: orange; }`, { color: 'blue', _focus: { color: 'green' } })" +)] +#[case( + "css({ color: 'blue', _focus: { color: 'green' } }, css`color: red; &:hover { color: orange; }`)" +)] +#[test] +#[serial] +fn selectors_stay_distinct_when_inline_templates_compose(#[case] expression: &str) { + let output = compile(&format!("export const result = {expression};")); + let styles = result_styles(&output, true); + assert_eq!(styles.len(), 3); + for (selector, value) in [(":hover", "orange"), (":focus", "green")] { + assert!( + styles.iter().any(|style| style.value == value + && style + .selector + .as_ref() + .is_some_and(|actual| actual.to_string().contains(selector))), + "{styles:?}" + ); + } +} + +#[test] +#[serial] +fn units_stay_library_specific_when_nested_spreads_compose() { + let option = ExtractOption { + single_css: true, + import_aliases: HashMap::from([("@emotion/react".to_string(), ImportAlias::NamedToNamed)]), + ..ExtractOption::default() + }; + let output = extract("composition.tsx", "import { css } from '@emotion/react';\nexport const result = css(css(...[{ padding: 2, p: 3, m: 3, opacity: 0.5 }]), { padding: 5 });", option).unwrap_or_else(|error| panic!("{error}")); + let styles = result_styles(&output, true); + assert_eq!( + styles + .iter() + .map(|style| (style.property.as_str(), style.value.as_str())) + .collect::>(), + vec![("margin", "12px"), ("opacity", ".5"), ("padding", "5px")] + ); +} + +#[test] +#[serial] +fn breakpoints_stay_distinct_when_nested_spreads_override_base_styles() { + let output = compile( + "export const result = css(css(...[{ color: ['red', null, 'green'] }]), css`color: blue;`);", + ); + let styles = result_styles(&output, true); + assert_eq!( + styles + .iter() + .map(|style| (style.value.as_str(), style.level)) + .collect::>(), + vec![("blue", 0), ("green", 2)] + ); +} + +#[test] +#[serial] +fn layers_stay_distinct_when_nested_spreads_override_matching_rules() { + use oxc_ast_visit::VisitMut; + let allocator = Allocator::default(); + let mut program = Parser::new(&allocator, "import { css } from '@devup-ui/react';\nimport { first, second, theme } from './layers';\nexport const result = css(css(...[first, theme]), second);", SourceType::tsx()).parse().program; + let mut visitor = crate::visit::DevupVisitor::new( + &allocator, + "composition.tsx", + "@devup-ui/react", + vec![], + None, + ); + visitor.import_css( + [ + ("first", "red", "base"), + ("second", "blue", "base"), + ("theme", "green", "theme"), + ] + .into_iter() + .map(|(name, value, layer)| { + ( + name.to_string(), + vec![ExtractStyleValue::Static( + ExtractStaticStyle::new_with_layer( + "color", + value, + 0, + None, + Some(layer.to_string()), + ), + )], + ) + }) + .collect(), + ); + visitor.visit_program(&mut program); + assert_eq!(visitor.errors, vec![]); + let output = ExtractOutput { + styles: visitor.styles, + code: oxc_codegen::Codegen::new().build(&program).code, + map: None, + css_file: None, + dependencies: vec![], + }; + let styles = result_styles(&output, true); + assert_eq!( + styles + .iter() + .map(|style| (style.value.as_str(), style.layer.as_deref())) + .collect::>(), + vec![("blue", Some("base")), ("green", Some("theme"))] + ); +} + +#[rstest] +#[case("css(css`color: ${runtime};`, { m: 2 })")] +#[case("css`color: blue; ${css`&${runtime} { color: red; }`}`")] +#[case("css({ color: 'red' }, css(...[...rest]))")] +#[test] +#[serial] +fn runtime_only_parts_fail_when_the_build_cannot_read_styles(#[case] expression: &str) { + let source = + format!("import {{ css }} from '@devup-ui/react';\nexport const result = {expression};"); + let error = extract("composition.tsx", &source, ExtractOption::default()) + .err() + .unwrap_or_else(|| panic!("expected build error: {source}")) + .to_string(); + assert!(error.starts_with("composition.tsx:2:"), "{error}"); +} + +#[rstest] +#[case("css(...[first, second])", "blue")] +#[case("css(...[second, first])", "red")] +#[case("css(...[, ...[first, , second], ,])", "blue")] +#[case("css(...[, ...[second, , first], ,])", "red")] +#[case("css(first, ...parts)", "blue")] +#[case("css(second, ...reverse)", "red")] +#[case("css(...[css(...[first]), second])", "blue")] +#[case("css(...[second, css(...[first])])", "red")] +#[case("css(...[first], second)", "blue")] +#[case("css(second, ...[first])", "red")] +#[test] +#[serial] +fn later_styles_win_when_known_classes_are_argument_spreads( + #[case] expression: &str, + #[case] color: &str, +) { + // Given + let source = format!( + "const first = css({{ color: 'red', p: 1 }});\nconst second = css({{ color: 'blue', m: 2 }});\nconst parts = [...[{{ color: 'red', p: 1 }}], , {{ color: 'blue', m: 2 }}];\nconst reverse = [...[{{ color: 'blue', m: 2 }}], , {{ color: 'red', p: 1 }}];\nexport const result = {expression};" + ); + // When + let output = compile(&source); + // Then + assert_eq!( + result_styles(&output, true) + .iter() + .map(|style| (style.property.as_str(), style.value.as_str())) + .collect::>(), + vec![("color", color), ("margin", "8px"), ("padding", "4px")] + ); +} + +#[test] +#[serial] +fn frame_selectors_survive_when_known_classes_are_argument_spreads() { + // Given + let source = "import { styled } from '@devup-ui/react';\nconst Frame = styled('div', { p: 1 });\nconst first = css`${Frame} { color: red; padding: 4px; }`;\nconst second = css`${Frame} { color: blue; }`;\nexport const result = css(...[first, second]);"; + // When + let output = compile(source); + let styles = result_styles(&output, true); + // Then + assert_eq!( + styles + .iter() + .map(|style| (style.property.as_str(), style.value.as_str())) + .collect::>(), + vec![("color", "blue"), ("padding", "4px")] + ); + assert!(styles.iter().all(|style| style.selector.is_some())); + assert_eq!(styles[0].selector, styles[1].selector); +} + +#[rstest] +#[case("css({ color: 'red' }, css(...rest))", "composition.tsx:2:45:")] +#[case("css`color: red; ${css(...rest)}`", "composition.tsx:2:41:")] +#[case("css(...rest)", "composition.tsx:2:23:")] +#[case("css(...[...rest])", "composition.tsx:2:23:")] +#[test] +#[serial] +fn unknown_spreads_fail_at_the_nested_call_when_arrays_are_runtime_only( + #[case] expression: &str, + #[case] location: &str, +) { + let source = + format!("import {{ css }} from '@devup-ui/react';\nexport const result = {expression};"); + let error = extract("composition.tsx", &source, ExtractOption::default()) + .err() + .unwrap_or_else(|| panic!("expected build error: {source}")) + .to_string(); + assert!(error.starts_with(location), "{error}"); + assert!(error.contains("...rest"), "{error}"); +} diff --git a/libs/extractor/src/css_prop.rs b/libs/extractor/src/css_prop.rs new file mode 100644 index 000000000..96cb8c59e --- /dev/null +++ b/libs/extractor/src/css_prop.rs @@ -0,0 +1,937 @@ +//! Emotion's `css` prop: styles an element takes in place of a class name. +//! The build composes them like `css()` and gives the element the classes and +//! CSS variables they compile to, so no runtime reads the prop. + +use std::collections::HashMap; + +use oxc_allocator::{CloneIn, FromIn, GetAllocator}; +use oxc_ast::ast::{ + Argument, ArrowFunctionBody, BinaryOperator, BindingPattern, CallExpression, Expression, + FormalParameters, FunctionBody, ImportDeclarationSpecifier, JSXAttributeItem, JSXChild, + JSXElement, JSXElementName, ObjectPropertyKind, Program, Statement, Str, TemplateElement, + TemplateElementValue, TemplateLiteral, +}; +use oxc_ast::builder::AstBuilder; +use oxc_ast_visit::{VisitMut, walk_mut}; +use oxc_span::SPAN; +use rustc_hash::FxHashSet; + +use crate::ImportAlias; +use crate::css_utils::{Place, interpolation_place}; +use crate::utils::{binding_root, get_string_by_literal_expression, unwrap_syntax_only}; + +/// The package whose alias makes elements take the `css` prop +pub(crate) const EMOTION_REACT: &str = "@emotion/react"; + +/// Emotion's JSX runtime, whose alias tells that the project builds JSX with +/// it, as a `jsxImportSource` of `@emotion/react` in tsconfig does +pub(crate) const EMOTION_JSX_RUNTIME: &str = "@emotion/react/jsx-runtime"; + +/// The pragma building a file's JSX with React, as its `css` props compile +pub(crate) const REACT_JSX_PRAGMA: &str = "/** @jsxImportSource react */\n"; + +/// Whether the project builds JSX with Emotion's runtime, which then builds it +/// with React's as every `css` prop compiles +pub(crate) fn builds_jsx_with_emotion(import_aliases: &HashMap) -> bool { + import_aliases.contains_key(EMOTION_REACT) && import_aliases.contains_key(EMOTION_JSX_RUNTIME) +} + +/// Whether `filename` is written in JSX, which the project's JSX settings build +pub(crate) fn is_jsx_file(filename: &str) -> bool { + filename + .rsplit_once('.') + .is_some_and(|(_, extension)| matches!(extension, "tsx" | "jsx")) +} + +/// Which elements of a file take Emotion's `css` prop +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum CssProp { + /// No element: Emotion is not aliased, or the file writes no `css` prop + #[default] + Off, + /// Tags and Devup UI components, which take no `css` prop of their own + Elements, + /// Every element, as the file shows it uses Emotion + Everywhere, +} + +impl CssProp { + /// Which elements of `code` take the prop, with the aliases the build + /// applies, and whether the file shows it uses Emotion + pub(crate) fn of( + import_aliases: &HashMap, + code: &str, + uses_emotion: bool, + ) -> Self { + if !import_aliases.contains_key(EMOTION_REACT) || !may_take_css_prop(code) { + Self::Off + } else if uses_emotion { + Self::Everywhere + } else { + Self::Elements + } + } + + /// Whether the element `name` takes the prop; `devup` tells a Devup UI + /// component + pub(crate) fn takes(self, name: &JSXElementName<'_>, devup: impl Fn(&str) -> bool) -> bool { + match self { + Self::Off => false, + Self::Everywhere => true, + Self::Elements => match name { + JSXElementName::Identifier(_) | JSXElementName::NamespacedName(_) => true, + name => crate::imported_constants::jsx_root(name).is_some_and(devup), + }, + } + } + + /// Whether the element a `jsx()` call builds from `element` takes the prop + pub(crate) fn takes_type(self, element: &Expression<'_>, devup: impl Fn(&str) -> bool) -> bool { + match self { + Self::Off => false, + Self::Everywhere => true, + Self::Elements => match unwrap_syntax_only(element) { + Expression::StringLiteral(_) => true, + element => binding_root(element).is_some_and(devup), + }, + } + } +} + +/// What in a program takes the `css` prop: the elements `css_prop` tells, and +/// the `jsx()` calls building them +pub(crate) struct CssTakers<'s> { + pub css_prop: CssProp, + /// Local names of the functions building elements from a type and props + jsx: FxHashSet<&'s str>, + /// Local names of Emotion's `ClassNames` + class_names: FxHashSet<&'s str>, +} + +impl<'s> CssTakers<'s> { + /// The elements `program` gives a `css` prop, as `css_prop` tells; + /// `compat` is the entry absorbing Emotion's own `jsx` + pub(crate) fn new(program: &Program<'s>, css_prop: CssProp, compat: &str) -> Self { + let mut jsx = FxHashSet::default(); + let mut class_names = FxHashSet::default(); + for statement in &program.body { + if let Statement::ImportDeclaration(import) = statement { + let source = import.source.value.as_str(); + for specifier in import.specifiers.iter().flatten() { + let ImportDeclarationSpecifier::ImportSpecifier(specifier) = specifier else { + continue; + }; + let imported = specifier.imported.name(); + if is_jsx_function(source, &imported, compat) { + jsx.insert(specifier.local.name.as_str()); + } else if imported == "ClassNames" + && (source == compat || source == EMOTION_REACT) + { + class_names.insert(specifier.local.name.as_str()); + } + } + } + } + Self { + css_prop, + jsx, + class_names, + } + } + + /// The names a `` child function `element` gives takes `css` + /// and `cx` by; empty for any other element + pub(crate) fn class_names_calls(&self, element: &JSXElement<'_>) -> Vec { + match &element.opening_element.name { + JSXElementName::IdentifierReference(name) + if self.class_names.contains(name.name.as_str()) => + { + class_names_calls(element) + } + _ => Vec::new(), + } + } + + /// Whether `attribute` of the element `name` is a `css` prop it takes; + /// `devup` tells a Devup UI component + pub(crate) fn attribute( + &self, + name: &JSXElementName<'_>, + attribute: &JSXAttributeItem<'_>, + devup: impl Fn(&str) -> bool, + ) -> bool { + matches!(attribute, JSXAttributeItem::Attribute(attribute) + if attribute.name.as_identifier().is_some_and(|attribute| attribute.name == "css")) + && self.css_prop.takes(name, devup) + } + + /// Where among the props a `jsx()` call gives is the `css` prop the + /// element takes + pub(crate) fn property( + &self, + call: &CallExpression<'_>, + devup: impl Fn(&str) -> bool, + ) -> Option { + let Expression::Identifier(callee) = &call.callee else { + return None; + }; + let [element, Argument::ObjectExpression(props), ..] = call.arguments.as_slice() else { + return None; + }; + (self.jsx.contains(callee.name.as_str()) + && self.css_prop.takes_type(element.as_expression()?, devup)) + .then(|| { + props.properties.iter().rposition(|property| { + matches!(property, ObjectPropertyKind::ObjectProperty(property) + if !property.computed && property.key.static_name().is_some_and(|key| key == "css")) + }) + }) + .flatten() + } +} + +/// Whether `code` may give an element a `css` prop: `css` written as a JSX +/// attribute or as the key of an object +pub(crate) fn may_take_css_prop(code: &str) -> bool { + code.match_indices("css").any(|(index, _)| { + let before = code[..index].chars().next_back(); + let after = code[index + 3..].trim_start(); + let attribute = before.is_some_and(char::is_whitespace) + && after.starts_with('=') + && !after[1..].starts_with('='); + let key = before.is_some_and(|c| c.is_whitespace() || matches!(c, '{' | ',' | '"' | '\'')) + && after + .strip_prefix(['"', '\'']) + .unwrap_or(after) + .trim_start() + .starts_with(':'); + attribute || key + }) +} + +/// Whether importing `source` shows a file uses Emotion +pub(crate) fn is_emotion(source: &str) -> bool { + [EMOTION_REACT, "@emotion/styled"].iter().any(|package| { + source + .strip_prefix(package) + .is_some_and(|rest| rest.is_empty() || rest.starts_with('/')) + }) +} + +/// Where the JSX pragma in the comment text `comment` names `@emotion/react` +/// as the module JSX is built with +pub(crate) fn emotion_pragma(comment: &str) -> Option { + let at = comment.find("@jsxImportSource")? + "@jsxImportSource".len(); + let value = comment[at..].trim_start(); + let rest = value.strip_prefix(EMOTION_REACT)?; + rest.chars() + .next() + .is_none_or(|c| c.is_whitespace() || c == '*') + .then(|| comment.len() - value.len()) +} + +/// The React module an Emotion JSX runtime stands in for +pub(crate) fn react_runtime(source: &str) -> Option<&'static str> { + match source { + "@emotion/react/jsx-runtime" => Some("react/jsx-runtime"), + "@emotion/react/jsx-dev-runtime" => Some("react/jsx-dev-runtime"), + _ => None, + } +} + +/// Whether `imported` from `source` builds an element from a type and props; +/// `compat` is the entry absorbing Emotion's own `jsx` +pub(crate) fn is_jsx_function(source: &str, imported: &str, compat: &str) -> bool { + match source { + "react/jsx-runtime" + | "react/jsx-dev-runtime" + | "@emotion/react/jsx-runtime" + | "@emotion/react/jsx-dev-runtime" => matches!(imported, "jsx" | "jsxs" | "jsxDEV"), + source => { + (source == EMOTION_REACT || source == compat) + && matches!(imported, "jsx" | "createElement") + } + } +} + +/// What a function of the theme requires to give rules at build time +pub(crate) const THEME_FUNCTION: &str = + "a function of the theme must give its rules at once, as `theme => ({ ... })`"; + +/// What a function of the theme requires of its reads of the theme +pub(crate) const THEME_READ: &str = "it may read the theme only as `theme.a.b` in a value, which becomes the CSS variable `var(--a-b)` the `ThemeProvider` sets"; + +/// What CSS text requires of an interpolation it cannot place +pub(crate) const UNPLACED: &str = + "an interpolation in CSS text must be a value, or a mixin standing where a declaration would"; + +/// What CSS text requires of a mixin +pub(crate) const NESTED_MIXIN: &str = + "a mixin must stand outside nested rules, where the parts it composes with can be split"; + +/// `function`, a function of the theme, as the rules it gives, each read of +/// the theme written as the CSS variable the `ThemeProvider` sets for it. +/// `Err` holds the code the build cannot write so, with what it requires. +pub(crate) fn theme_rules<'a>( + ast: &AstBuilder<'a>, + function: &Expression<'a>, +) -> Result, (Expression<'a>, &'static str)> { + let unsupported = || (function.clone_in(ast.allocator()), THEME_FUNCTION); + let (params, body) = match function { + Expression::ArrowFunctionExpression(arrow) if !arrow.r#async => { + let body = match &arrow.body { + ArrowFunctionBody::FunctionBody(body) => returned(body), + body => body.as_expression(), + }; + (&arrow.params, body) + } + Expression::FunctionExpression(function) if !function.r#async && !function.generator => ( + &function.params, + function.body.as_deref().and_then(returned), + ), + _ => return Err(unsupported()), + }; + let (Some(roots), Some(body)) = (theme_roots(params), body) else { + return Err(unsupported()); + }; + let mut rules = body.clone_in_with_semantic_ids(ast.allocator()); + let mut reads = ThemeReads { + ast, + roots: &roots, + unread: None, + }; + reads.visit(&mut rules, false); + match reads.unread { + Some(unread) => Err((unread, THEME_READ)), + None => Ok(rules), + } +} + +/// What a function body gives when it is a single `return` +pub(crate) fn returned<'b, 'a>(body: &'b FunctionBody<'a>) -> Option<&'b Expression<'a>> { + match body.statements.as_slice() { + [Statement::ReturnStatement(statement)] => statement.argument.as_ref(), + _ => None, + } +} + +fn returned_mut<'b, 'a>(body: &'b mut FunctionBody<'a>) -> Option<&'b mut Expression<'a>> { + match body.statements.as_mut_slice() { + [Statement::ReturnStatement(statement)] => statement.argument.as_mut(), + _ => None, + } +} + +/// The parameters of `function` and what it gives at once, when it is a +/// plain function giving one value +pub(crate) fn render_function<'b, 'a>( + function: &'b mut Expression<'a>, +) -> Option<(&'b FormalParameters<'a>, &'b mut Expression<'a>)> { + match function { + Expression::ArrowFunctionExpression(arrow) if !arrow.r#async => { + let arrow = &mut **arrow; + let body = match &mut arrow.body { + ArrowFunctionBody::FunctionBody(body) => returned_mut(body)?, + body => body.as_expression_mut()?, + }; + Some((&arrow.params, body)) + } + Expression::FunctionExpression(function) if !function.r#async && !function.generator => { + let function = &mut **function; + Some(( + &function.params, + returned_mut(function.body.as_deref_mut()?)?, + )) + } + _ => None, + } +} + +/// The names Emotion's `` child function takes `css`, `cx` and +/// the theme by +#[derive(Default, Clone, Copy)] +pub(crate) struct ClassNamesParams<'a> { + pub css: Option<&'a str>, + pub cx: Option<&'a str>, + pub theme: Option<&'a str>, +} + +/// What `params` take of `{ css, cx, theme }`; `None` when they take it in a +/// way the build cannot follow +pub(crate) fn class_names_params<'a>( + params: &FormalParameters<'a>, +) -> Option> { + let mut names = ClassNamesParams::default(); + if params.rest.is_some() { + return None; + } + match params.items.as_slice() { + [] => {} + [param] if param.initializer.is_none() => { + let BindingPattern::ObjectPattern(object) = ¶m.pattern else { + return None; + }; + if object.rest.is_some() { + return None; + } + for property in &object.properties { + let (BindingPattern::BindingIdentifier(local), Some(key), false) = ( + &property.value, + property.key.static_name(), + property.computed, + ) else { + return None; + }; + let slot = match key.as_ref() { + "css" => &mut names.css, + "cx" => &mut names.cx, + "theme" => &mut names.theme, + _ => return None, + }; + *slot = Some(local.name.as_str()); + } + } + _ => return None, + } + Some(names) +} + +/// What the child function of the `` element `element` takes +pub(crate) fn class_names_child<'a>(element: &JSXElement<'a>) -> Option> { + let mut children = element + .children + .iter() + .filter(|child| !matches!(child, JSXChild::Text(text) if text.value.trim().is_empty())); + let (Some(JSXChild::ExpressionContainer(container)), None) = (children.next(), children.next()) + else { + return None; + }; + let params = match container.expression.as_expression()? { + Expression::ArrowFunctionExpression(arrow) => &arrow.params, + Expression::FunctionExpression(function) => &function.params, + _ => return None, + }; + class_names_params(params) +} + +/// The names the child function of the `` element `element` +/// takes `css` and `cx` by, which read styles +pub(crate) fn class_names_calls(element: &JSXElement<'_>) -> Vec { + class_names_child(element) + .map(|names| { + [names.css, names.cx] + .into_iter() + .flatten() + .map(str::to_string) + .collect() + }) + .unwrap_or_default() +} + +/// Write the reads of the theme bound to `theme` in `expression`, a CSS value +/// when `value`, as the CSS variables the `ThemeProvider` sets; `Err` holds +/// the first read the build cannot write so +pub(crate) fn read_theme<'a>( + ast: &AstBuilder<'a>, + expression: &mut Expression<'a>, + theme: &'a str, + value: bool, +) -> Result<(), Expression<'a>> { + let roots = [(theme, None)]; + let mut reads = ThemeReads { + ast, + roots: &roots, + unread: None, + }; + reads.visit(expression, value); + reads.unread.map_or(Ok(()), Err) +} + +/// The names a function of the theme reads it by, each with the key of the +/// theme it holds when destructured; `None` for parameters the build cannot +/// follow +fn theme_roots<'a>(params: &FormalParameters<'a>) -> Option)>> { + if params.rest.is_some() { + return None; + } + match params.items.as_slice() { + [] => Some(Vec::new()), + [param] if param.initializer.is_none() => match ¶m.pattern { + BindingPattern::BindingIdentifier(identifier) => { + Some(vec![(identifier.name.as_str(), None)]) + } + BindingPattern::ObjectPattern(object) if object.rest.is_none() => object + .properties + .iter() + .map( + |property| match (&property.value, property.key.static_name()) { + (BindingPattern::BindingIdentifier(local), Some(key)) + if !property.computed => + { + Some((local.name.as_str(), Some(key.to_string()))) + } + _ => None, + }, + ) + .collect(), + _ => None, + }, + _ => None, + } +} + +/// Writes each read of the theme in a value as the CSS variable it becomes, +/// keeping the first read that is not one +struct ThemeReads<'r, 'a> { + ast: &'r AstBuilder<'a>, + roots: &'r [(&'a str, Option)], + unread: Option>, +} + +/// How an expression reads the theme +enum ThemeRead { + /// By the path of keys to a value + Path(Vec), + /// Otherwise, as a whole or through a call or a key only the runtime gives + Other, +} + +impl<'a> ThemeReads<'_, 'a> { + /// How `expression` reads the theme, when it does + fn path(&self, expression: &Expression<'a>) -> Option { + let mut path = Vec::new(); + let mut exact = true; + let mut cursor = expression; + loop { + match cursor { + Expression::StaticMemberExpression(member) => { + path.push(member.property.name.to_string()); + cursor = &member.object; + } + Expression::ComputedMemberExpression(member) => { + match get_string_by_literal_expression(&member.expression) { + Some(key) => path.push(key.into_owned()), + None => exact = false, + } + cursor = &member.object; + } + Expression::CallExpression(call) => { + exact = false; + cursor = &call.callee; + } + Expression::ParenthesizedExpression(inner) => cursor = &inner.expression, + Expression::Identifier(identifier) => { + let (_, key) = self + .roots + .iter() + .find(|(local, _)| *local == identifier.name.as_str())?; + path.extend(key.clone()); + path.reverse(); + return Some(if exact && !path.is_empty() { + ThemeRead::Path(path) + } else { + ThemeRead::Other + }); + } + _ => return None, + } + } + } + + /// Visit `expression`, which stands where a CSS value does when `value` + fn visit(&mut self, expression: &mut Expression<'a>, value: bool) { + match self.path(expression) { + Some(ThemeRead::Path(path)) if value => { + let variable = format!("var(--{})", path.join("-")); + *expression = Expression::new_string_literal( + SPAN, + Str::from_in(variable.as_str(), self.ast.allocator()), + None, + self.ast, + ); + return; + } + Some(_) => { + if self.unread.is_none() { + self.unread = Some(expression.clone_in(self.ast.allocator())); + } + return; + } + None => {} + } + match expression { + Expression::ObjectExpression(object) => { + for property in &mut object.properties { + match property { + ObjectPropertyKind::ObjectProperty(property) => { + if let Some(key) = property.key.as_expression_mut() { + self.visit(key, false); + } + self.visit(&mut property.value, true); + } + ObjectPropertyKind::SpreadProperty(spread) => { + self.visit(&mut spread.argument, false); + } + } + } + } + Expression::TemplateLiteral(template) => { + for expression in &mut template.expressions { + self.visit(expression, true); + } + } + Expression::ArrayExpression(array) => { + for element in &mut array.elements { + if let Some(element) = element.as_expression_mut() { + self.visit(element, value); + } + } + } + Expression::ConditionalExpression(conditional) => { + self.visit(&mut conditional.test, false); + self.visit(&mut conditional.consequent, value); + self.visit(&mut conditional.alternate, value); + } + Expression::LogicalExpression(logical) => { + self.visit(&mut logical.left, false); + self.visit(&mut logical.right, value); + } + // Text joined to a string stays text + Expression::BinaryExpression(binary) + if binary.operator == BinaryOperator::Addition + && [&binary.left, &binary.right].into_iter().any(|side| { + matches!( + side, + Expression::StringLiteral(_) | Expression::TemplateLiteral(_) + ) + }) => + { + self.visit(&mut binary.left, value); + self.visit(&mut binary.right, value); + } + Expression::ParenthesizedExpression(inner) => self.visit(&mut inner.expression, value), + expression => walk_mut::walk_expression(self, expression), + } + } +} + +impl<'a> VisitMut<'a> for ThemeReads<'_, 'a> { + fn visit_expression(&mut self, expression: &mut Expression<'a>) { + self.visit(expression, false); + } +} + +/// CSS text as the parts of a `css` prop: when `mixins`, an interpolation +/// standing where a declaration would is a mixin composed there, splitting the +/// text around it. `Err` holds an interpolation the parts cannot place, with +/// what the text requires of it. +pub(crate) fn template_parts<'a>( + ast: &AstBuilder<'a>, + template: &TemplateLiteral<'a>, + mixins: bool, +) -> Result>, (Expression<'a>, &'static str)> { + let mut parts = Vec::new(); + let mut text = String::new(); + let mut depth = 0usize; + let mut from = 0; + for (index, expression) in template.expressions.iter().enumerate() { + let quasi = template.quasis[index].value.raw.as_str(); + text.push_str(quasi); + depth = (depth + quasi.matches('{').count()).saturating_sub(quasi.matches('}').count()); + let place = interpolation_place(&text, &template.quasis[index + 1..]); + if matches!(place, Place::Value) || get_string_by_literal_expression(expression).is_some() { + continue; + } + let requirement = match place { + Place::Statement if mixins && depth == 0 => { + parts.extend(segment(ast, template, from, index)); + parts.push(expression.clone_in_with_semantic_ids(ast.allocator())); + from = index + 1; + continue; + } + Place::Statement if mixins => NESTED_MIXIN, + _ => UNPLACED, + }; + return Err((expression.clone_in(ast.allocator()), requirement)); + } + parts.extend(segment(ast, template, from, template.expressions.len())); + Ok(parts) +} + +/// The text of `template` from the quasi `from` to the quasi `to`, with the +/// values between them; `None` when it holds nothing +fn segment<'a>( + ast: &AstBuilder<'a>, + template: &TemplateLiteral<'a>, + from: usize, + to: usize, +) -> Option> { + let quasis = &template.quasis[from..=to]; + if from == to && quasis[0].value.raw.trim().is_empty() { + return None; + } + let allocator = ast.allocator(); + let quasis = quasis.iter().enumerate().map(|(index, quasi)| { + TemplateElement::new( + SPAN, + TemplateElementValue { + raw: quasi.value.raw, + cooked: quasi.value.cooked, + }, + index == to - from, + ast, + ) + }); + let expressions = template.expressions[from..to] + .iter() + .map(|expression| expression.clone_in_with_semantic_ids(allocator)); + Some(Expression::new_template_literal( + SPAN, + oxc_allocator::Vec::from_iter_in(quasis, ast), + oxc_allocator::Vec::from_iter_in(expressions, ast), + ast, + )) +} + +#[cfg(test)] +#[allow( + clippy::expect_used, + clippy::unwrap_used, + clippy::literal_string_with_formatting_args +)] +mod tests { + use super::*; + use crate::utils::expression_to_code; + use oxc_allocator::Allocator; + use oxc_parser::Parser; + use oxc_span::SourceType; + + fn parsed<'a>(allocator: &'a Allocator, code: &'a str) -> Expression<'a> { + Parser::new(allocator, code, SourceType::tsx()) + .parse_expression() + .unwrap() + } + + #[test] + fn test_css_prop_needles() { + for code in [ + "
", + "", + "jsx('div', { css: a })", + "jsx('div', {css:a})", + "jsx('div', {\"css\": a})", + "jsx('div', {'css' : a})", + "f(a,css:b)", + ] { + assert!(may_take_css_prop(code), "{code}"); + } + for code in [ + "import './a.css'", + "a.css = b", + "if (a css == b) {}", + "css(a)", + "
", + "css", + ] { + assert!(!may_take_css_prop(code), "{code}"); + } + } + + #[test] + fn test_css_prop_of_aliases() { + let mut aliases = HashMap::new(); + assert_eq!(CssProp::of(&aliases, "
", true), CssProp::Off); + aliases.insert(EMOTION_REACT.to_string(), ImportAlias::NamedToNamed); + assert_eq!(CssProp::of(&aliases, "
", true), CssProp::Off); + assert_eq!( + CssProp::of(&aliases, "
", false), + CssProp::Elements + ); + assert_eq!( + CssProp::of(&aliases, "
", true), + CssProp::Everywhere + ); + } + + #[test] + fn test_emotion_sources_and_pragmas() { + assert!(is_emotion("@emotion/react")); + assert!(is_emotion("@emotion/react/jsx-runtime")); + assert!(is_emotion("@emotion/styled")); + assert!(!is_emotion("@emotion/reactive")); + assert!(!is_emotion("@emotion/css")); + + assert_eq!( + emotion_pragma("* @jsxImportSource @emotion/react "), + Some(19) + ); + assert_eq!(emotion_pragma("@jsxImportSource @emotion/react*"), Some(17)); + assert_eq!( + emotion_pragma(" @jsxImportSource\t@emotion/react"), + Some(18) + ); + assert_eq!(emotion_pragma("@jsxImportSource react"), None); + assert_eq!(emotion_pragma("@jsxImportSource @emotion/reactive"), None); + assert_eq!(emotion_pragma("@jsx jsx"), None); + + assert_eq!( + react_runtime("@emotion/react/jsx-runtime"), + Some("react/jsx-runtime") + ); + assert_eq!( + react_runtime("@emotion/react/jsx-dev-runtime"), + Some("react/jsx-dev-runtime") + ); + assert_eq!(react_runtime("@emotion/react"), None); + + assert!(is_jsx_file("src/App.tsx")); + assert!(is_jsx_file("App.jsx")); + assert!(!is_jsx_file("App.ts")); + assert!(!is_jsx_file("Makefile")); + + let mut aliases = + HashMap::from([(EMOTION_JSX_RUNTIME.to_string(), ImportAlias::NamedToNamed)]); + assert!(!builds_jsx_with_emotion(&aliases)); + aliases.insert(EMOTION_REACT.to_string(), ImportAlias::NamedToNamed); + assert!(builds_jsx_with_emotion(&aliases)); + + let compat = "@devup-ui/react/compat"; + assert!(is_jsx_function("react/jsx-runtime", "jsxs", compat)); + assert!(is_jsx_function( + "@emotion/react/jsx-dev-runtime", + "jsxDEV", + compat + )); + assert!(!is_jsx_function("react/jsx-runtime", "Fragment", compat)); + assert!(is_jsx_function("@emotion/react", "createElement", compat)); + assert!(is_jsx_function(compat, "jsx", compat)); + assert!(!is_jsx_function("react", "createElement", compat)); + assert!(!is_jsx_function(EMOTION_REACT, "css", compat)); + } + + #[test] + fn test_takes() { + let allocator = Allocator::default(); + let devup = |name: &str| name == "Box"; + for (code, off, elements, everywhere) in [ + ("
", false, true, true), + ("", false, true, true), + ("", false, true, true), + ("", false, false, true), + ("", false, false, true), + ] { + let Expression::JSXElement(element) = parsed(&allocator, code) else { + panic!("{code}"); + }; + let name = &element.opening_element.name; + assert_eq!(CssProp::Off.takes(name, devup), off, "{code}"); + assert_eq!(CssProp::Elements.takes(name, devup), elements, "{code}"); + assert_eq!(CssProp::Everywhere.takes(name, devup), everywhere, "{code}"); + } + for (code, elements) in [ + ("'div'", true), + ("Box", true), + ("Custom", false), + ("f()", false), + ] { + let element = parsed(&allocator, code); + assert!(!CssProp::Off.takes_type(&element, devup), "{code}"); + assert_eq!( + CssProp::Elements.takes_type(&element, devup), + elements, + "{code}" + ); + assert!(CssProp::Everywhere.takes_type(&element, devup), "{code}"); + } + } + + #[test] + fn test_theme_rules() { + let allocator = Allocator::default(); + let ast = AstBuilder::new(&allocator); + for (code, expected) in [ + ( + "theme => ({ color: theme.colors.primary })", + "({color:`var(--colors-primary)`});", + ), + ( + "({ colors, space: s }) => ({ color: colors.text, margin: s[2] })", + "({color:`var(--colors-text)`,margin:`var(--space-2)`});", + ), + ( + "function (t) { return [{ border: `1px solid ${t.line}` }, a && { color: '#' + t.a }]; }", + "[{border:`1px solid ${`var(--line)`}`},a&&{color:`#`+`var(--a)`}];", + ), + ( + "t => c ? { color: (t.a) } : { [k]: t['b'], ...rest }", + "c?{color:`var(--a)`}:{[k]:`var(--b)`,...rest};", + ), + ("() => ({ color: 'red' })", "({color:`red`});"), + ] { + let rules = theme_rules(&ast, &parsed(&allocator, code)).unwrap(); + assert_eq!(expression_to_code(&rules), expected, "{code}"); + } + for (code, unread) in [ + ("t => ({ color: t })", "t"), + ("t => ({ ...t.mixins })", "t.mixins"), + ("t => ({ margin: t.spacing(2) })", "t.spacing(2)"), + ("t => ({ color: t.colors[name] })", "t.colors[name]"), + ("t => t.dark ? {} : {}", "t.dark"), + ("t => ({ width: t.a * 2 })", "t.a"), + ("t => ({ color: t.a || 'red' })", "t.a"), + ("t => ({ color: f(t.a), border: t.b() })", "t.a"), + ] { + let (read, requirement) = theme_rules(&ast, &parsed(&allocator, code)).unwrap_err(); + assert_eq!(requirement, THEME_READ, "{code}"); + assert_eq!(expression_to_code(&read), format!("{unread};"), "{code}"); + } + for code in [ + "async t => ({})", + "function* (t) { return {}; }", + "async function (t) { return {}; }", + "(t, u) => ({})", + "(...t) => ({})", + "(t = {}) => ({})", + "([t]) => ({})", + "({ a: { b } }) => ({})", + "({ ...t }) => ({})", + "({ [k]: t }) => ({})", + "t => { const a = 1; return {}; }", + "t => {}", + "function (t) {}", + "a", + ] { + let (_, requirement) = theme_rules(&ast, &parsed(&allocator, code)).unwrap_err(); + assert_eq!(requirement, THEME_FUNCTION, "{code}"); + } + } + + #[test] + fn test_template_parts() { + let allocator = Allocator::default(); + let ast = AstBuilder::new(&allocator); + let parts = |code: &'static str, mixins: bool| { + let Expression::TemplateLiteral(template) = parsed(&allocator, code) else { + panic!("{code}"); + }; + template_parts(&ast, &template, mixins) + .map(|parts| parts.iter().map(expression_to_code).collect::()) + }; + assert_eq!( + parts("`color: ${c}; ${'margin: 0'};`", false).unwrap(), + "`color: ${c}; ${`margin: 0`};`;" + ); + assert_eq!( + parts("`${base}; color: ${c}; ${other}`", true).unwrap(), + "base;`; color: ${c}; `;other;" + ); + assert_eq!(parts("``", true).unwrap(), ""); + let (_, requirement) = parts("`${base}; color: red;`", false).unwrap_err(); + assert_eq!(requirement, UNPLACED); + let (_, requirement) = parts("`&:hover { ${base}; }`", true).unwrap_err(); + assert_eq!(requirement, NESTED_MIXIN); + let (code, requirement) = parts("`${selector} { color: red; }`", true).unwrap_err(); + assert_eq!(requirement, UNPLACED); + assert_eq!(expression_to_code(&code), "selector;"); + } +} diff --git a/libs/extractor/src/css_utils.rs b/libs/extractor/src/css_utils.rs index f0d67200b..e30956fbd 100644 --- a/libs/extractor/src/css_utils.rs +++ b/libs/extractor/src/css_utils.rs @@ -21,6 +21,18 @@ use oxc_ast::ast::Expression; use oxc_ast::ast::{TemplateElement, TemplateLiteral}; use oxc_ast::builder::AstBuilder; +mod component_layer_objects; +#[cfg(test)] +mod component_layer_policy_tests; +#[cfg(test)] +mod component_layer_tests; +mod component_layers; +mod layer_blocks; +mod layer_names; +pub(crate) use component_layer_objects::object_layer_errors; +pub(crate) use component_layers::{expression_layer_errors, nest_layer, template_layer_errors}; +pub(crate) use layer_names::parse as parse_layer_name; + use crate::extract_style::{ extract_dynamic_style::ExtractDynamicStyle, extract_static_style::ExtractStaticStyle, extract_style_value::ExtractStyleValue, @@ -74,7 +86,16 @@ pub(crate) fn theme_var_reference(expr: &Expression<'_>) -> Option { loop { match cursor { Expression::StaticMemberExpression(member) => { - path.push(member.property.name.as_str()); + path.push(member.property.name.to_string()); + cursor = &member.object; + } + Expression::ComputedMemberExpression(member) => { + path.push(match &member.expression { + Expression::StringLiteral(key) => key.value.to_string(), + key => { + crate::utils::js_number_literal(key).map(crate::utils::js_number_string)? + } + }); cursor = &member.object; } Expression::Identifier(ident) => { @@ -146,14 +167,14 @@ pub struct TemplateStyles { pub unplaced: Vec, } -enum Place { +pub(crate) enum Place { Value, Statement, Other, } /// Where an interpolation stands, from the CSS written before and after it -fn interpolation_place(before: &str, after: &[TemplateElement<'_>]) -> Place { +pub(crate) fn interpolation_place(before: &str, after: &[TemplateElement<'_>]) -> Place { let head = &before[before.rfind([';', '{', '}']).map_or(0, |index| index + 1)..]; let rest: String = after.iter().map(|quasi| quasi.value.raw.as_str()).collect(); let end = rest.find([';', '{', '}']); @@ -411,12 +432,15 @@ pub fn css_to_style_template( } static_value.push_str(&value[cursor..]); // Create a new static style with the evaluated value - styles.push(CssToStyleResult::Static(ExtractStaticStyle::new( - style.property(), - &static_value, - style.level(), - style.selector().cloned(), - ))); + styles.push(CssToStyleResult::Static( + ExtractStaticStyle::new_with_layer( + style.property(), + &static_value, + style.level(), + style.selector().cloned(), + style.layer.clone(), + ), + )); } else { // Not all expressions are literals - need to create dynamic style // Check if value is just a placeholder (no surrounding text) @@ -428,12 +452,7 @@ pub fn css_to_style_template( // Value is just the expression - use expression code directly let identifier = dynamic_expr_code(&css.expressions[*idx], &shared_allocator); - styles.push(CssToStyleResult::Dynamic(ExtractDynamicStyle::new( - style.property(), - style.level(), - &identifier, - style.selector().cloned(), - ))); + styles.push(component_layers::dynamic_from(&style, &identifier)); } else { // Value has surrounding text - need to create template literal // Reconstruct the template literal by replacing placeholders with ${expr} syntax @@ -502,12 +521,7 @@ pub fn css_to_style_template( // Wrap in template literal backticks let final_identifier = format!("`{template_literal}`"); - styles.push(CssToStyleResult::Dynamic(ExtractDynamicStyle::new( - style.property(), - style.level(), - &final_identifier, - style.selector().cloned(), - ))); + styles.push(component_layers::dynamic_from(&style, &final_identifier)); } } } @@ -526,7 +540,7 @@ pub fn css_to_style( selector: &Option, ) -> Vec { let mut styles = vec![]; - collect_css_block(&rm_css_comment(css), level, selector, &mut styles); + collect_css_block(css, level, selector, &mut styles); // A single declaration (or none) is trivially ordered, so skip the comparison // sort's setup entirely for the very common single-property case. The multi-source @@ -548,15 +562,19 @@ fn collect_css_block( styles: &mut Vec, ) { let mut rest = css; - while let Some(open) = rest.find('{') { + while let Some((open, _)) = + layer_blocks::boundaries(rest).find(|(_, character)| *character == '{') + { let head = &rest[..open]; - let (declarations, prelude) = head.rsplit_once(';').unwrap_or(("", head)); + let (declarations, prelude) = layer_blocks::boundaries(head) + .filter(|(_, character)| *character == ';') + .last() + .map_or(("", head), |(at, _)| (&head[..at], &head[at + 1..])); styles.extend(css_to_style_block(declarations, level, selector)); let body_start = open + 1; let mut depth = 1usize; - let body_end = rest[body_start..] - .char_indices() + let body_end = layer_blocks::boundaries(&rest[body_start..]) .find_map(|(index, c)| { match c { '{' => depth += 1, @@ -567,10 +585,16 @@ fn collect_css_block( }) .unwrap_or(rest.len()); let body = &rest[body_start..body_end]; - let prelude = prelude.trim(); + let prelude = component_layers::trim_trivia(prelude).trim_end(); if prelude.is_empty() || prelude == "&" { collect_css_block(body, level, selector, styles); - } else if let Some(nested) = nest_prelude(selector.as_ref(), prelude) { + } else if let Some(layer) = component_layers::named_layer(prelude) { + let from = styles.len(); + collect_css_block(body, level, selector, styles); + for style in &mut styles[from..] { + nest_layer(&layer, &mut style.layer); + } + } else if let Some(nested) = nest_prelude(selector.as_ref(), &rm_css_comment(prelude)) { collect_css_block(body, level, &Some(nested), styles); } rest = rest.get(body_end + 1..).unwrap_or_default(); @@ -587,19 +611,41 @@ fn nest_prelude(parent: Option<&StyleSelector>, prelude: &str) -> Option { - Some(selector) + Some(StyleSelector::nest_selector(parent, &descendants(prelude))) +} + +/// Each selector of the list `selectors` written without `&` as a descendant +/// of it, as stylis nests one +pub(crate) fn descendants(selectors: &str) -> String { + let mut depth = 0usize; + let mut from = 0; + let mut parts = Vec::new(); + for (index, c) in selectors.char_indices() { + match c { + '(' | '[' => depth += 1, + ')' | ']' => depth = depth.saturating_sub(1), + ',' if depth == 0 => { + parts.push(&selectors[from..index]); + from = index + 1; + } + _ => {} } - Some(StyleSelector::At { selector, .. }) => selector.as_ref(), - None => None, - }; - let template = if prelude.contains('&') || parent_selector.is_none() { - Cow::Borrowed(prelude) - } else { - Cow::Owned(format!("& {prelude}")) - }; - Some(StyleSelector::nest_selector(parent, &template)) + } + parts.push(&selectors[from..]); + parts + .iter() + .map(|part| { + let part = part.trim(); + if part.contains('&') { + part.to_string() + } else if part.starts_with(':') { + format!("&{part}") + } else { + format!("& {part}") + } + }) + .collect::>() + .join(",") } /// Optimize a declaration's value only when its property warrants multi-value @@ -1171,7 +1217,7 @@ mod tests { #[case( "`ul { font-family: 'Roboto Hello', sans-serif; }`", vec![ - ("font-family", "\"Roboto Hello\",sans-serif", Some(StyleSelector::Selector("ul".to_string()))), + ("font-family", "\"Roboto Hello\",sans-serif", Some(StyleSelector::Selector("& ul".to_string()))), ] )] #[case( @@ -1610,14 +1656,23 @@ mod tests { #[case( "ul { font-family: 'Roboto Hello', sans-serif; }", vec![ - ("font-family", "\"Roboto Hello\",sans-serif", Some(StyleSelector::Selector("ul".to_string()))), + ("font-family", "\"Roboto Hello\",sans-serif", Some(StyleSelector::Selector("& ul".to_string()))), ] )] #[case( "div { color: red; ; { background: blue; } }", vec![ - ("color", "red", Some(StyleSelector::Selector("div".to_string()))), - ("background", "blue", Some(StyleSelector::Selector("div".to_string()))), + ("color", "red", Some(StyleSelector::Selector("& div".to_string()))), + ("background", "blue", Some(StyleSelector::Selector("& div".to_string()))), + ] + )] + // A selector written without `&` selects within the element, each one of + // a list on its own, and a pseudo-class applies to the element itself + #[case( + "a, :is(b, c) d { color: red; } :hover { color: blue; }", + vec![ + ("color", "red", Some(StyleSelector::Selector("& a,&:is(b,c) d".to_string()))), + ("color", "blue", Some(StyleSelector::Selector("&:hover".to_string()))), ] )] // As in CSS nesting, only the text after the last `;` is the nested rule's @@ -1626,7 +1681,7 @@ mod tests { "color:red;background:blue { width: 1px; }", vec![ ("color", "red", None), - ("width", "1px", Some(StyleSelector::Selector("background:blue".to_string()))), + ("width", "1px", Some(StyleSelector::Selector("& background:blue".to_string()))), ] )] #[case( @@ -1634,16 +1689,14 @@ mod tests { vec![( "width", "1px", - Some(StyleSelector::Selector("color:red".to_string())) + Some(StyleSelector::Selector("& color:red".to_string())) )] )] #[case( "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..53371bdf2 --- /dev/null +++ b/libs/extractor/src/css_utils/component_layer_tests.rs @@ -0,0 +1,214 @@ +use super::{CssToStyleResult, css_to_style, css_to_style_template}; +use crate::{ExtractOption, ExtractStyleValue, ImportAlias, extract}; +use oxc_allocator::Allocator; +use oxc_ast::ast::Expression; +use oxc_parser::Parser; +use oxc_span::SourceType; +use rstest::rstest; +use serial_test::serial; +use std::collections::HashMap; + +#[test] +fn layer_metadata_nests_when_blocks_wrap_selectors_and_media() { + let text = + "@layer base { @layer reset { @media print { &:hover { color: red; } } } } color: blue;"; + let styles = css_to_style(text, 2, &None); + assert_eq!(styles.len(), 2); + let red = styles + .iter() + .find(|style| style.value == "red") + .unwrap_or_else(|| panic!("missing layered declaration: {styles:?}")); + assert_eq!(red.layer.as_deref(), Some("base.reset")); + assert_eq!(red.level, 2); + assert!(red.selector.as_ref().is_some_and(|selector| { + let text = selector.to_string(); + text.contains("print") && text.contains(":hover") + })); + assert!( + styles + .iter() + .any(|style| style.value == "blue" && style.layer.is_none()) + ); +} + +#[rstest] +#[case("`@layer base { color: ${'red'}; }`", "red")] +#[case("`@layer base { color: ${runtime.color}; }`", "runtime.color")] +#[case("`@layer base { width: ${runtime.width}px; }`", "`${runtime.width}px`")] +#[test] +fn layer_metadata_survives_when_template_values_are_rebuilt( + #[case] source: &str, + #[case] value: &str, +) { + let allocator = Allocator::default(); + let expression = Parser::new(&allocator, source, SourceType::tsx()) + .parse_expression() + .unwrap_or_else(|error| panic!("{error:?}")); + let Expression::TemplateLiteral(template) = expression else { + panic!("expected template") + }; + let parsed = css_to_style_template(&template, 0, &None); + assert_eq!(parsed.styles.len(), 1); + match &parsed.styles[0] { + CssToStyleResult::Static(style) => { + assert_eq!(style.layer.as_deref(), Some("base")); + assert_eq!(style.value, value); + } + CssToStyleResult::Dynamic(style) => { + assert_eq!(style.layer(), Some("base")); + assert_eq!(style.identifier(), value); + } + } +} + +#[rstest] +#[case( + "import { css } from '@devup-ui/react';\nexport const result = css`@layer base { color: red; }`;" +)] +#[case( + "import { styled } from '@devup-ui/react';\nexport const result = styled.div`@layer base { color: red; }`;" +)] +#[case( + "import { css } from '@emotion/react';\nexport const result =
;" +)] +#[case( + "import { ClassNames } from '@emotion/react';\nexport const result = {({css}) =>
};" +)] +#[test] +#[serial] +fn declarations_are_retained_when_component_apis_read_layer_text(#[case] source: &str) { + let option = ExtractOption { + single_css: true, + import_aliases: HashMap::from([("@emotion/react".to_string(), ImportAlias::NamedToNamed)]), + ..ExtractOption::default() + }; + let output = extract("layer.tsx", source, option).unwrap_or_else(|error| panic!("{error}")); + let layers: Vec<_> = output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Static(style) => Some(( + style.property.as_str(), + style.value.as_str(), + style.layer.as_deref(), + )), + _ => None, + }) + .collect(); + assert_eq!(layers, vec![("color", "red", Some("base"))]); +} + +#[rstest] +#[case( + "import { css } from '@devup-ui/react';\nexport const result = css`color: red;\n @layer base, theme;\n color: blue;`;" +)] +#[case( + "import { css } from '@devup-ui/react';\nexport const result = css(css`color: red;\n @layer base, theme;`, { m: 2 });" +)] +#[case( + "import { css } from '@devup-ui/react';\nexport const result = css`color: red;\n @layer base, theme; ${css`color: green;`}`;" +)] +#[case( + "import { css } from '@emotion/react';\nexport const result =
;" +)] +#[case( + "import { ClassNames } from '@emotion/react';\nexport const result = {({css}) =>
};" +)] +#[case( + "import { styled } from '@devup-ui/react';\nexport const result = styled.div`color: red;\n @layer base, theme;`;" +)] +#[test] +#[serial] +fn layer_order_is_rejected_at_its_source_when_component_apis_read_statements(#[case] source: &str) { + let option = ExtractOption { + import_aliases: HashMap::from([("@emotion/react".to_string(), ImportAlias::NamedToNamed)]), + ..ExtractOption::default() + }; + let error = extract("layer.tsx", source, option) + .err() + .unwrap_or_else(|| panic!("expected component layer-order error: {source}")); + let error = error.to_string(); + assert!(error.starts_with("layer.tsx:3:2:"), "{error}"); + assert!(error.contains("@layer base, theme;"), "{error}"); + assert!(error.contains("globalCss"), "{error}"); +} + +#[rstest] +#[case("`color: red; /* @layer a, b; */ content: '@layer a, b;';`")] +#[case("`@layer base { color: red; }`")] +#[test] +fn validation_ignores_order_text_when_it_is_not_a_layer_statement(#[case] source: &str) { + let allocator = Allocator::default(); + let expression = Parser::new(&allocator, source, SourceType::tsx()) + .parse_expression() + .unwrap_or_else(|error| panic!("{error:?}")); + assert_eq!(super::expression_layer_errors(&expression, "css"), vec![]); +} + +#[test] +fn validation_locates_statements_when_comments_precede_them() { + let source = "`/* before */ @layer base, theme;`"; + let allocator = Allocator::default(); + let expression = Parser::new(&allocator, source, SourceType::tsx()) + .parse_expression() + .unwrap_or_else(|error| panic!("{error:?}")); + let errors = super::expression_layer_errors(&expression, "css"); + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].0, 14); +} + +#[rstest] +#[case( + "css(css`@layer base { color: red; } @layer theme { color: green; }`, css`@layer base { color: blue; }`)", + "blue" +)] +#[case( + "css(css`@layer base { color: blue; }`, css`@layer base { color: red; } @layer theme { color: green; }`)", + "red" +)] +#[test] +#[serial] +fn later_declarations_win_only_in_the_same_layer_when_templates_compose( + #[case] expression: &str, + #[case] color: &str, +) { + use crate::extract_style::{ExtractStyleProperty, style_property::StyleProperty}; + let source = + format!("import {{ css }} from '@devup-ui/react';\nexport const result = {expression};"); + let output = extract( + "layer.tsx", + &source, + ExtractOption { + single_css: true, + ..ExtractOption::default() + }, + ) + .unwrap_or_else(|error| panic!("{error}")); + let classes = output + .code + .split("const result = ") + .nth(1) + .unwrap_or_else(|| panic!("missing result: {}", output.code)); + let classes: String = serde_json::from_str(classes.trim().trim_end_matches(';')) + .unwrap_or_else(|error| panic!("{error}: {classes}")); + let mut layers: Vec<_> = output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Static(style) => match style.extract(None) { + StyleProperty::ClassName(name) + if classes.split_whitespace().any(|class| class == name) => + { + Some((style.layer.as_deref(), style.value.as_str())) + } + _ => None, + }, + _ => None, + }) + .collect(); + layers.sort(); + assert_eq!( + layers, + vec![(Some("base"), color), (Some("theme"), "green")] + ); +} diff --git a/libs/extractor/src/css_utils/component_layers.rs b/libs/extractor/src/css_utils/component_layers.rs new file mode 100644 index 000000000..1c69daf63 --- /dev/null +++ b/libs/extractor/src/css_utils/component_layers.rs @@ -0,0 +1,141 @@ +use super::CssToStyleResult; +use crate::extract_style::{ + extract_dynamic_style::ExtractDynamicStyle, extract_static_style::ExtractStaticStyle, +}; +use crate::utils::{build_time_error, get_string_by_literal_expression, readable_code}; +use oxc_ast::ast::{Expression, TemplateLiteral}; + +pub(crate) const LAYER_ORDER_REQUIREMENT: &str = "layer order statements belong in `globalCss`, not component styles; declare the order with `globalCss` and use named `@layer` blocks here"; +pub(super) const LAYER_NAME_REQUIREMENT: &str = "name the layer, for example `@layer components { ... }`: component layer names must be dot-separated CSS identifiers, not CSS-wide reserved keywords; declare layer order with `globalCss`"; + +pub(crate) fn template_layer_errors( + template: &TemplateLiteral<'_>, + api: &str, +) -> Vec<(u32, String)> { + let mut text = String::new(); + let mut source = Vec::new(); + for (index, quasi) in template.quasis.iter().enumerate() { + source.push((text.len(), quasi.span.start)); + text.push_str(&quasi.value.raw); + if let Some(expression) = template.expressions.get(index) { + text.push_str(&get_string_by_literal_expression(expression).map_or_else( + || format!("${{{}}}", readable_code(expression)), + std::borrow::Cow::into_owned, + )); + } + } + statement_errors(&text, api, |offset| { + source + .iter() + .rev() + .find(|(start, _)| *start <= offset) + .and_then(|(start, original)| { + u32::try_from(offset - start) + .ok() + .and_then(|delta| original.checked_add(delta)) + }) + .unwrap_or(template.span.start) + }) +} + +pub(crate) fn expression_layer_errors( + expression: &Expression<'_>, + api: &str, +) -> Vec<(u32, String)> { + match crate::utils::unwrap_syntax_only(expression) { + Expression::TemplateLiteral(template) => template_layer_errors(template, api), + Expression::StringLiteral(literal) => { + statement_errors(&literal.value, api, |_| literal.span.start) + } + Expression::ArrayExpression(array) => array + .elements + .iter() + .filter_map(|element| element.as_expression()) + .flat_map(|part| expression_layer_errors(part, api)) + .collect(), + Expression::ConditionalExpression(choice) => [&choice.consequent, &choice.alternate] + .into_iter() + .flat_map(|part| expression_layer_errors(part, api)) + .collect(), + Expression::LogicalExpression(logical) => expression_layer_errors(&logical.right, api), + Expression::ObjectExpression(_) => super::object_layer_errors(expression, api), + _ => vec![], + } +} + +fn statement_errors(text: &str, api: &str, offset: impl Fn(usize) -> u32) -> Vec<(u32, String)> { + let mut errors = Vec::new(); + let mut from = 0; + for (index, character) in super::layer_blocks::boundaries(text) { + let head = &text[from..index]; + let trimmed = trim_trivia(head); + if let Some(name) = layer_prelude(trimmed) { + let requirement = match character { + ';' => Some(LAYER_ORDER_REQUIREMENT), + '{' if super::parse_layer_name(name).is_none() => Some(LAYER_NAME_REQUIREMENT), + _ => None, + }; + if let Some(requirement) = requirement { + errors.push(( + offset(from + head.len() - trimmed.len()), + build_time_error(api, &format!("{trimmed}{character}"), requirement), + )); + } + } + from = index + character.len_utf8(); + } + errors +} + +pub(super) fn trim_trivia(mut text: &str) -> &str { + loop { + text = text.trim_start(); + match text + .strip_prefix("/*") + .and_then(|comment| comment.find("*/").map(|end| &comment[end + 2..])) + { + Some(rest) => text = rest, + None => return text, + } + } +} + +pub(super) fn named_layer(prelude: &str) -> Option { + super::parse_layer_name(layer_prelude(prelude)?) +} + +fn layer_prelude(prelude: &str) -> Option<&str> { + if !prelude + .get(..6) + .is_some_and(|keyword| keyword.eq_ignore_ascii_case("@layer")) + { + return None; + } + prelude.get(6..).filter(|rest| { + rest.is_empty() + || rest.starts_with([' ', '\t', '\n', '\r', '\u{c}']) + || rest.starts_with("/*") + }) +} + +pub(crate) fn nest_layer(layer: &str, inner: &mut Option) { + *inner = Some(match inner.take() { + Some(inner) => format!("{layer}.{inner}"), + None => layer.to_string(), + }); +} + +pub(super) fn dynamic_from(style: &ExtractStaticStyle, identifier: &str) -> CssToStyleResult { + let mut dynamic = ExtractDynamicStyle::new( + style.property(), + style.level(), + identifier, + style.selector().cloned(), + ); + dynamic.layer.clone_from(&style.layer); + CssToStyleResult::Dynamic(dynamic) +} + +#[cfg(test)] +#[path = "component_layer_coverage_tests.rs"] +mod coverage_tests; diff --git a/libs/extractor/src/css_utils/layer_blocks.rs b/libs/extractor/src/css_utils/layer_blocks.rs new file mode 100644 index 000000000..59c37aaf2 --- /dev/null +++ b/libs/extractor/src/css_utils/layer_blocks.rs @@ -0,0 +1,44 @@ +pub(super) fn boundaries(text: &str) -> impl Iterator + '_ { + let mut characters = text.char_indices().peekable(); + let mut quote = None; + let mut escaped = false; + let mut comment = false; + let mut parentheses = 0usize; + std::iter::from_fn(move || { + while let Some((index, character)) = characters.next() { + if comment { + if character == '*' && characters.peek().is_some_and(|(_, next)| *next == '/') { + characters.next(); + comment = false; + } + continue; + } + if escaped { + escaped = false; + continue; + } + if character == '\\' { + escaped = true; + continue; + } + if let Some(end) = quote { + if character == end { + quote = None; + } + continue; + } + match character { + '/' if characters.peek().is_some_and(|(_, next)| *next == '*') => { + characters.next(); + comment = true; + } + '\'' | '"' => quote = Some(character), + '(' => parentheses += 1, + ')' => parentheses = parentheses.saturating_sub(1), + ';' | '{' | '}' if parentheses == 0 => return Some((index, character)), + _ => {} + } + } + None + }) +} diff --git a/libs/extractor/src/css_utils/layer_names.rs b/libs/extractor/src/css_utils/layer_names.rs new file mode 100644 index 000000000..a0cfefdb1 --- /dev/null +++ b/libs/extractor/src/css_utils/layer_names.rs @@ -0,0 +1,159 @@ +use std::iter::Peekable; +use std::str::Chars; + +const fn name_start(character: char) -> bool { + character.is_ascii_alphabetic() || character == '_' || character >= '\u{80}' +} + +fn escape(characters: &mut Peekable>) -> Option { + let first = characters.next()?; + if matches!(first, '\n' | '\r' | '\u{c}') { + return None; + } + if let Some(mut value) = first.to_digit(16) { + for _ in 1..6 { + let Some(digit) = characters + .peek() + .and_then(|character| character.to_digit(16)) + else { + break; + }; + characters.next(); + value = value * 16 + digit; + } + if characters + .peek() + .is_some_and(|character| matches!(character, ' ' | '\t' | '\n' | '\r' | '\u{c}')) + { + let whitespace = characters.next(); + if whitespace == Some('\r') && characters.peek() == Some(&'\n') { + characters.next(); + } + } + Some( + char::from_u32(value) + .filter(|character| *character != '\0') + .unwrap_or('\u{fffd}'), + ) + } else { + Some(first) + } +} + +fn identifier(characters: &mut Peekable>) -> Option { + let first = characters.next()?; + let mut decoded = String::new(); + match first { + '\\' => decoded.push(escape(characters)?), + '-' => { + if !characters + .peek() + .is_some_and(|next| name_start(*next) || matches!(next, '-' | '\\')) + { + return None; + } + decoded.push(first); + } + character if name_start(character) => decoded.push(character), + _ => return None, + } + while let Some(character) = characters.peek().copied() { + if character == '\\' { + characters.next(); + decoded.push(escape(characters)?); + } else if name_start(character) || character.is_ascii_digit() || character == '-' { + characters.next(); + decoded.push(character); + } else { + break; + } + } + if ["initial", "inherit", "unset", "revert", "revert-layer"] + .iter() + .any(|reserved| decoded.eq_ignore_ascii_case(reserved)) + { + return None; + } + Some(serialize(&decoded)) +} + +fn serialize(decoded: &str) -> String { + let mut result = String::new(); + let lone_hyphen = decoded == "-"; + let leading_hyphen = decoded.starts_with('-'); + for (index, character) in decoded.chars().enumerate() { + if character.is_ascii_digit() && (index == 0 || index == 1 && leading_hyphen) + || character <= '\u{1f}' + || character == '\u{7f}' + { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut bytes = [0; 4]; + character.encode_utf8(&mut bytes); + let value = bytes[0]; + result.push('\\'); + if value >= 16 { + result.push(char::from(HEX[usize::from(value >> 4)])); + } + result.push(char::from(HEX[usize::from(value & 15)])); + result.push(' '); + } else if name_start(character) + || character.is_ascii_digit() + || character == '-' && !lone_hyphen + { + result.push(character); + } else { + result.push('\\'); + result.push(character); + } + } + result +} + +fn skip_trivia(characters: &mut Peekable>, whitespace: bool) -> Option<()> { + loop { + while whitespace + && characters + .peek() + .is_some_and(|character| matches!(character, ' ' | '\t' | '\n' | '\r' | '\u{c}')) + { + characters.next(); + } + if characters.peek() != Some(&'/') { + return Some(()); + } + let mut comment = characters.clone(); + comment.next(); + if comment.next() != Some('*') { + return Some(()); + } + loop { + if comment.next()? == '*' && comment.peek() == Some(&'/') { + comment.next(); + *characters = comment; + break; + } + } + } +} + +/// Canonical CSS serialization keeps escaped dots within an identifier distinct +/// from dots separating nested layers, while equivalent escapes share metadata. +pub(crate) fn parse(name: &str) -> Option { + let mut characters = name.chars().peekable(); + let mut parts = Vec::new(); + skip_trivia(&mut characters, true)?; + loop { + skip_trivia(&mut characters, false)?; + parts.push(identifier(&mut characters)?); + skip_trivia(&mut characters, false)?; + match characters.next() { + Some('.') => {} + None => return Some(parts.join(".")), + Some(' ' | '\t' | '\n' | '\r' | '\u{c}') => { + skip_trivia(&mut characters, true)?; + return characters.next().is_none().then(|| parts.join(".")); + } + Some(_) => return None, + } + } +} diff --git a/libs/extractor/src/extractor/extract_style_from_expression.rs b/libs/extractor/src/extractor/extract_style_from_expression.rs index 4b8f7f27b..909aa839e 100644 --- a/libs/extractor/src/extractor/extract_style_from_expression.rs +++ b/libs/extractor/src/extractor/extract_style_from_expression.rs @@ -435,6 +435,7 @@ pub fn extract_style_from_expression<'a>( literal_handling, ) .styles; + let layer = crate::css_utils::parse_layer_name(&layer).unwrap_or(layer); place_in_layer(&mut styles, &layer); props.extend(styles); } @@ -1046,12 +1047,7 @@ pub fn extract_style_from_expression<'a>( /// Put every static declaration of `props` in `layer`, nesting the layer one /// already sits in pub(crate) fn place_in_layer(props: &mut [ExtractStyleProp<'_>], layer: &str) { - let nest = |inner: &mut Option| { - *inner = Some(match inner.take() { - Some(inner) => format!("{layer}.{inner}"), - None => layer.to_string(), - }); - }; + let nest = |inner: &mut Option| crate::css_utils::nest_layer(layer, inner); for prop in props { match prop { ExtractStyleProp::Static(ExtractStyleValue::Static(style)) => nest(&mut style.layer), @@ -1257,9 +1253,10 @@ fn nest_underscore_name(parent: Option<&StyleSelector>, name: &str) -> Option li'`) rather +/// than CSS properties. fn is_nested_selector_key(key: &str) -> bool { - key.starts_with(':') || key.contains('&') + key.starts_with([':', '.', '#', '[', '*', '>', '+', '~']) || key.contains('&') } fn nest_selectors_key(parent: Option<&StyleSelector>, key: &str) -> Option { diff --git a/libs/extractor/src/extractor/extract_style_from_styled.rs b/libs/extractor/src/extractor/extract_style_from_styled.rs index 425ed0245..f73a644a2 100644 --- a/libs/extractor/src/extractor/extract_style_from_styled.rs +++ b/libs/extractor/src/extractor/extract_style_from_styled.rs @@ -3,7 +3,6 @@ use rustc_hash::FxHashMap; use crate::{ ExtractStyleProp, component::ExportVariableKind, - css_utils::{TemplateStyles, css_to_style_template}, extract_style::extract_style_value::ExtractStyleValue, extractor::{ ExtractResult, @@ -11,11 +10,12 @@ use crate::{ }, gen_class_name::{gen_class_names, merge_expression_for_class_name}, gen_style::gen_styles, + styled_reads::{Forward, Reads, withheld as props_withheld}, utils::{ - STYLE_OBJECT, StyleArguments, build_time_error, call_with_values, merge_object_expressions, - readable_code, reads_directly, style_arguments, uncomposable_error, unplaced_error, - unreadable_styles, unwrap_syntax_only, unwrap_syntax_only_mut, wrap_array_filter, - wrap_direct_call, + STYLE_OBJECT, StyleArguments, build_time_error, call_with_values, expression_to_code, + merge_object_expressions, readable_code, reads_directly, style_arguments, + uncomposable_error, unreadable_styles, unwrap_syntax_only, unwrap_syntax_only_mut, + wrap_array_filter, wrap_direct_call, }, }; use oxc_allocator::{CloneIn, FromIn, GetAllocator}; @@ -27,9 +27,222 @@ use oxc_ast::{ }, builder::AstBuilder, }; -use oxc_span::{GetSpan, SPAN}; +use oxc_parser::{Parser, ParserReturn}; +use oxc_span::{GetSpan, SPAN, SourceType}; use oxc_syntax::operator::{BinaryOperator, LogicalOperator, UnaryOperator}; +const PROPS_FUNCTION: &str = "a style function of the props must give one rule object at once, with every entry written out, as `(props) => ({ color: props.color })`"; + +#[path = "styled_rule_choices.rs"] +mod rule_choices; + +#[path = "styled_template_composition.rs"] +mod template_composition; + +#[path = "styled_rule_parts.rs"] +mod rule_parts; + +/// The rules `style`, an argument of a styled component, gives, with each +/// value a function of the props written as the value of a CSS variable the +/// component sets: a function giving rules becomes the rules with each value +/// read from the props as such a function, so a condition chooses per value. +/// `Err` holds a function the build cannot read so. +fn rules_reading_props<'a>( + ast_builder: &AstBuilder<'a>, + style: &mut Expression<'a>, + bindings: &StyledBindings<'_>, +) -> Result<(), Expression<'a>> { + use oxc_allocator::TakeIn; + + if matches!( + unwrap_syntax_only(style), + Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_) + ) { + let code = style.clone_in(ast_builder.allocator()); + let (params, returned) = crate::css_prop::render_function(style) + .ok_or_else(|| code.clone_in(ast_builder.allocator()))?; + let params = params.clone_in_with_semantic_ids(ast_builder.allocator()); + let mut rules = returned.take_in(ast_builder); + rules = rule_choices::normalize(ast_builder, &rules, bindings) + .map_err(|_| code.clone_in(ast_builder.allocator()))?; + props_rules(ast_builder, &mut rules, ¶ms).ok_or(code)?; + *style = rules; + } + if let Expression::ObjectExpression(object) = unwrap_syntax_only_mut(style) { + call_with_props(ast_builder, object); + } + Ok(()) +} + +/// Written rule branches lowered independently, so an omitted property keeps +/// the atom preceding it rather than setting an absent CSS variable. +fn props_rules<'a>( + ast_builder: &AstBuilder<'a>, + rules: &mut Expression<'a>, + params: &FormalParameters<'a>, +) -> Option<()> { + use oxc_allocator::TakeIn; + + let rules = unwrap_syntax_only_mut(rules); + match rules { + Expression::ObjectExpression(object) => { + props_leaves(ast_builder, object, params)?; + call_with_props(ast_builder, object); + } + Expression::ConditionalExpression(conditional) => { + props_rules(ast_builder, &mut conditional.consequent, params)?; + props_rules(ast_builder, &mut conditional.alternate, params)?; + let test = conditional.test.take_in(ast_builder); + let function = props_function(ast_builder, params, test, "opacity"); + conditional.test = + wrap_direct_call(ast_builder, &function, &[identifier(ast_builder, "rest")]); + } + Expression::NullLiteral(_) => {} + _ => return None, + } + Some(()) +} + +/// Whether `value` is a literal, which reads the same given any props +fn is_written_out(value: &Expression<'_>) -> bool { + let value = unwrap_syntax_only(value); + crate::utils::get_string_by_literal_expression(value).is_some() + || crate::utils::js_number_literal(value).is_some() + || matches!( + value, + Expression::BooleanLiteral(_) | Expression::NullLiteral(_) + ) +} + +/// The values of `object` that read the props, each as a function taking the +/// parameters `params` of the function that gave the rules +fn props_leaves<'a>( + ast_builder: &AstBuilder<'a>, + object: &mut oxc_ast::ast::ObjectExpression<'a>, + params: &FormalParameters<'a>, +) -> Option<()> { + use oxc_allocator::TakeIn; + + for property in &mut object.properties { + let ObjectPropertyKind::ObjectProperty(property) = property else { + return None; + }; + if property.computed || property.method || property.kind != oxc_ast::ast::PropertyKind::Init + { + return None; + } + let key = crate::utils::get_str_by_property_key(&property.key)?; + match unwrap_syntax_only_mut(&mut property.value) { + Expression::ObjectExpression(inner) => props_leaves(ast_builder, inner, params)?, + Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_) => {} + value if is_written_out(value) => {} + _ => { + let leaf = property.value.take_in(ast_builder); + property.value = props_function(ast_builder, params, leaf, &key); + } + } + } + Some(()) +} + +/// `(params) => leaf`: a number is a `px` length, as Emotion reads it, unless +/// the property takes numbers as they are or the function reads the theme +fn props_function<'a>( + ast_builder: &AstBuilder<'a>, + params: &FormalParameters<'a>, + leaf: Expression<'a>, + key: &str, +) -> Expression<'a> { + let function = |body: Expression<'a>| { + Expression::new_arrow_function_expression( + SPAN, + false, + None::>>, + oxc_allocator::Box::new_in( + params.clone_in_with_semantic_ids(ast_builder.allocator()), + ast_builder, + ), + None::>>, + body.into(), + ast_builder, + ) + }; + let plain = function(leaf.clone_in_with_semantic_ids(ast_builder.allocator())); + if crate::utils::keeps_bare_number(key) + || crate::css_utils::theme_var_reference(&plain).is_some() + { + return plain; + } + let value = || identifier(ast_builder, "__devupValue"); + let is_number = Expression::new_binary_expression( + SPAN, + Expression::new_unary_expression(SPAN, UnaryOperator::Typeof, value(), ast_builder), + BinaryOperator::StrictEquality, + Expression::new_string_literal(SPAN, "number", None, ast_builder), + ast_builder, + ); + let length = Expression::new_binary_expression( + SPAN, + value(), + BinaryOperator::Addition, + Expression::new_string_literal(SPAN, "px", None, ast_builder), + ast_builder, + ); + let length = Expression::new_conditional_expression( + SPAN, + Expression::new_logical_expression( + SPAN, + is_number, + LogicalOperator::And, + Expression::new_binary_expression( + SPAN, + value(), + BinaryOperator::StrictInequality, + Expression::new_numeric_literal( + SPAN, + 0.0, + None, + oxc_syntax::number::NumberBase::Decimal, + ast_builder, + ), + ast_builder, + ), + ast_builder, + ), + length, + value(), + ast_builder, + ); + let length = named_arrow(ast_builder, "__devupValue", length); + function(wrap_direct_call(ast_builder, &length, &[leaf])) +} + +/// The values of `object` that are functions of the props written as the +/// result of calling them with the props the component renders, which the +/// styles read as the values of CSS variables; a function reading the theme +/// stays, as the styles read it as a variable of the theme +fn call_with_props<'a>( + ast_builder: &AstBuilder<'a>, + object: &mut oxc_ast::ast::ObjectExpression<'a>, +) { + for property in &mut object.properties { + let ObjectPropertyKind::ObjectProperty(property) = property else { + continue; + }; + match unwrap_syntax_only_mut(&mut property.value) { + Expression::ObjectExpression(inner) => call_with_props(ast_builder, inner), + function @ (Expression::ArrowFunctionExpression(_) + | Expression::FunctionExpression(_)) + if crate::css_utils::theme_var_reference(function).is_none() => + { + *function = + wrap_direct_call(ast_builder, function, &[identifier(ast_builder, "rest")]); + } + _ => {} + } + } +} + /// The binding a styled component reads the component it renders through, when /// only the runtime gives that component const STYLED_BASE: &str = "DevupStyled"; @@ -46,7 +259,379 @@ struct Base<'a> { bound: Option>, } +/// A styled component the file defines, which a component extending it +/// renders in its place: what it renders, its classes and styles, and its +/// attrs, as the extension applies them before its own +pub struct StyledDefinition<'a> { + name: String, + bound: Option>, + classes: Vec>, + styles: Vec>, + attrs: Vec>, + /// The props its styles and attrs read + reads: Reads, + /// Its `shouldForwardProp`, as the build evaluates it + forward: Option, + /// The classes other styles select it by, which a component extending it + /// gives too, as it renders this one + markers: Vec, +} + +/// `marker` as a class among those a component gives +fn marker_class<'a>(ast_builder: &AstBuilder<'a>, marker: &str) -> Expression<'a> { + Expression::new_string_literal( + SPAN, + Str::from_in(marker, ast_builder.allocator()), + None, + ast_builder, + ) +} + +/// `markers` as classes among those a component gives +fn marker_classes<'s, 'a>( + ast_builder: &'s AstBuilder<'a>, + markers: &'s [String], +) -> impl Iterator> + 's { + markers + .iter() + .map(|marker| marker_class(ast_builder, marker)) +} + +/// How the classes of a styled component are named: the file its atoms are +/// split into, and the classes other styles select it by +#[derive(Clone, Copy, Default)] +pub struct Naming<'s> { + pub split_filename: Option<&'s str>, + pub markers: &'s [String], +} + +/// The bindings styled factories and their CSS mixins read in this module. +pub struct StyledBindings<'s> { + pub imports: &'s FxHashMap, + pub values: &'s crate::style_values::StyleValues, + pub inline_css: &'s FxHashMap>, +} + +impl StyledBindings<'_> { + fn styles(&self, expression: &Expression<'_>) -> Option<&[ExtractStyleValue]> { + self.values + .styles(unwrap_syntax_only(expression)) + .or_else(|| { + self.inline_css + .get(&expression.span().start) + .map(Vec::as_slice) + }) + } +} + +/// Whether a styled component renders a tag, which takes only valid +/// attributes, rather than a component +fn renders_tag(name: &str, bound: Option<&Expression<'_>>) -> bool { + bound.is_none() && name.starts_with(|c: char| c.is_ascii_lowercase()) && !name.contains('.') +} + +/// The shared legacy whitelist includes `on`, but a native tag cannot take +/// that styling flag as a boolean attribute. Explicit forwarding stays intact. +fn withheld(reads: &Reads, renders_tag: bool, forward: Option<&Forward>) -> Vec { + let mut names = props_withheld(reads, renders_tag, forward); + if renders_tag && forward.is_none() && reads.names.iter().any(|name| name == "on") { + names.push("on".to_string()); + } + names +} + +/// `own` applying after `inherited`: a prop passes only when both pass it +fn combine_forward(inherited: Option<&Forward>, own: Option) -> Option { + match (inherited.cloned(), own) { + (Some(inherited), Some(own)) => Some(Forward::And(Box::new(inherited), Box::new(own))), + (inherited, own) => own.or(inherited), + } +} + +impl StyledDefinition<'_> { + /// Whether an element using this component keeps passing the prop `name`: + /// what its styles read, what it passes on, and what React or the + /// component itself takes + #[must_use] + pub fn takes(&self, name: &str) -> bool { + self.reads.whole + || self.reads.names.iter().any(|read| read == name) + || matches!( + name, + "className" | "style" | "as" | "forwardedAs" | "key" | "ref" | "children" + ) + || crate::styled_reads::passes( + name, + renders_tag(&self.name, self.bound.as_ref()), + self.forward.as_ref(), + ) + } + + /// Whether an extension can render what this definition renders: a value + /// only the runtime gives is read again only when it is a binding + #[must_use] + pub fn extendable(&self) -> bool { + self.bound + .as_ref() + .is_none_or(|bound| matches!(bound, Expression::Identifier(_))) + } +} + +impl<'a> StyledDefinition<'a> { + /// The tag an element using this component can render in its place, with + /// the styles the component gives it: a tag, with no attrs, no props read + /// and no value only the runtime gives + #[must_use] + pub fn inline(&self) -> Option<(&str, &[ExtractStyleProp<'a>])> { + (renders_tag(&self.name, self.bound.as_ref()) + && self.attrs.is_empty() + && self.reads == Reads::default() + && self.classes.is_empty() + && self.styles.iter().all(fixed)) + .then_some((self.name.as_str(), self.styles.as_slice())) + } + + /// The classes other styles select the component by + #[must_use] + pub fn markers(&self) -> &[String] { + &self.markers + } + + /// This definition in the arena of another file + #[must_use] + pub fn clone_in<'b>(&self, allocator: &'b oxc_allocator::Allocator) -> StyledDefinition<'b> { + StyledDefinition { + name: self.name.clone(), + bound: self.bound.as_ref().map(|bound| bound.clone_in(allocator)), + classes: self + .classes + .iter() + .map(|class| class.clone_in(allocator)) + .collect(), + styles: self + .styles + .iter() + .map(|style| style.clone_in(allocator)) + .collect(), + attrs: self + .attrs + .iter() + .map(|attr| attr.clone_in(allocator)) + .collect(), + reads: self.reads.clone(), + forward: self.forward.clone(), + markers: self.markers.clone(), + } + } + + /// Whether another file can render this component in its place: it + /// renders a tag, and nothing it holds reads a binding of the file + /// defining it + #[must_use] + pub fn portable(&self) -> bool { + renders_tag(&self.name, self.bound.as_ref()) && closed(self.code()) + } + + /// The code the component evaluates for its classes, attrs and styles + fn code(&self) -> impl Iterator + '_ { + self.classes + .iter() + .chain(&self.attrs) + .chain(self.styles.iter().flat_map(style_expressions)) + .map(expression_to_code) + .chain( + self.styles + .iter() + .flat_map(ExtractStyleProp::extract) + .filter_map(|style| match style { + ExtractStyleValue::Dynamic(style) => Some(style.identifier().to_string()), + _ => None, + }), + ) + } + + /// The styles the component gives what it renders + #[must_use] + pub fn styles(&self) -> &[ExtractStyleProp<'a>] { + &self.styles + } +} + +/// Whether `prop` holds only values the build knows +fn fixed(prop: &ExtractStyleProp<'_>) -> bool { + match prop { + ExtractStyleProp::Static(value) => !matches!(value, ExtractStyleValue::Dynamic(_)), + ExtractStyleProp::StaticArray(props) => props.iter().all(fixed), + _ => false, + } +} + +/// `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>, + split_filename: Option<&str>, +) -> Option<(Expression<'a>, StyledDefinition<'a>)> { + let name = match unwrap_syntax_only(target) { + Expression::StringLiteral(literal) => literal.value.to_string(), + target => jsx_name(target)?, + }; + let base = Base::named(name); + let allocator = ast_builder.allocator(); + 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, + split_filename, + )), + ); + let withheld = withheld( + &definition.reads, + renders_tag(&base.name, None), + definition.forward.as_ref(), + ); + let component = create_styled_component( + ast_builder, + &base.name, + &class_name, + &gen_styles(ast_builder, &styles, None), + &withheld, + ); + let new_definition = base.definition( + ast_builder, + classes, + &styles, + &definition.attrs, + definition.reads.clone(), + definition.forward.clone(), + ); + Some(( + apply_attrs(ast_builder, component, &definition.attrs), + new_definition, + )) +} + +/// What extracting a styled component gives +pub struct StyledExtraction<'a> { + pub result: ExtractResult<'a>, + pub expression: Expression<'a>, + pub errors: Vec<(u32, String)>, + pub definition: Option>, +} + +/// The styles a component renders: those it extends or its base component +/// brings, then its own, a later declaration replacing an earlier one +fn compose_styles<'a>( + ast_builder: &AstBuilder<'a>, + inherited: Option<&StyledDefinition<'a>>, + defaults: Option>, + own: Vec>, +) -> Vec> { + let mut earlier: Vec> = inherited.map_or_else(Vec::new, |inherited| { + inherited + .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 mut composition = crate::composition::Composition::default(); + composition.apply(ast_builder, earlier); + composition.apply(ast_builder, own); + composition.into_props() +} + +/// The classes and attrs a component extending `inherited` starts from +fn inherited_parts<'a>( + ast_builder: &AstBuilder<'a>, + inherited: Option<&StyledDefinition<'a>>, + attrs: &[Expression<'a>], +) -> (Vec>, Vec>) { + let allocator = ast_builder.allocator(); + let (classes, mut all_attrs) = inherited.map_or_else( + || (Vec::new(), Vec::new()), + |inherited| { + ( + marker_classes(ast_builder, &inherited.markers) + .chain(inherited.classes.iter().map(|c| c.clone_in(allocator))) + .collect(), + inherited + .attrs + .iter() + .map(|a| a.clone_in(allocator)) + .collect(), + ) + }, + ); + all_attrs.extend(attrs.iter().map(|attr| attr.clone_in(allocator))); + (classes, all_attrs) +} + impl<'a> Base<'a> { + /// What `inherited` renders, in place of the component defining it + fn extending( + self, + ast_builder: &AstBuilder<'a>, + inherited: Option<&StyledDefinition<'a>>, + ) -> Self { + match inherited { + Some(inherited) => Self { + name: inherited.name.clone(), + styles: None, + bound: inherited + .bound + .as_ref() + .map(|bound| bound.clone_in(ast_builder.allocator())), + }, + None => self, + } + } + + /// The definition of the component rendering this base + fn definition( + &self, + ast_builder: &AstBuilder<'a>, + classes: Vec>, + styles: &[ExtractStyleProp<'a>], + attrs: &[Expression<'a>], + reads: Reads, + forward: Option, + ) -> StyledDefinition<'a> { + let allocator = ast_builder.allocator(); + StyledDefinition { + name: self.name.clone(), + bound: self.bound.as_ref().map(|bound| bound.clone_in(allocator)), + classes, + styles: styles + .iter() + .map(|style| style.clone_in(allocator)) + .collect(), + attrs: attrs.iter().map(|attr| attr.clone_in(allocator)).collect(), + reads, + forward, + markers: Vec::new(), + } + } + + /// The props a component rendering this base keeps away from it + fn withheld(&self, reads: &Reads, forward: Option<&Forward>) -> Vec { + withheld(reads, renders_tag(&self.name, self.bound.as_ref()), forward) + } + const fn named(name: String) -> Self { Self { name, @@ -65,6 +650,29 @@ impl<'a> Base<'a> { } } +/// The component `styled(Component)` extends, as written: the argument naming +/// it in a tagged template `styled(Component)`, `styled(Component)({...})` or +/// `styled(Component, {...})` +#[must_use] +pub fn extended<'b, 'a>(expression: &'b Expression<'a>) -> Option<&'b Expression<'a>> { + let factory = match expression { + Expression::TaggedTemplateExpression(tag) => &tag.tag, + Expression::CallExpression(call) + if matches!( + unwrap_syntax_only(&call.callee), + Expression::CallExpression(_) + ) => + { + &call.callee + } + expression => expression, + }; + let Expression::CallExpression(call) = unwrap_syntax_only(factory) else { + return None; + }; + unwrap_syntax_only(call.arguments.first()?.as_expression()?).into() +} + fn extract_base_tag_and_class_name<'a>( ast_builder: &AstBuilder<'a>, input: &Expression<'a>, @@ -180,21 +788,71 @@ fn resolve_styled_call_target<'a>( pub fn extract_style_from_styled<'a>( ast_builder: &AstBuilder<'a>, expression: &mut Expression<'a>, - split_filename: Option<&str>, - imports: &FxHashMap, + naming: Naming<'_>, + bindings: StyledBindings<'_>, attrs: &[Expression<'a>], -) -> (ExtractResult<'a>, Expression<'a>, Vec<(u32, String)>) { + inherited: Option<&StyledDefinition<'a>>, + forward: Option, +) -> StyledExtraction<'a> { + let imports = bindings.imports; + let Naming { + split_filename, + markers, + } = naming; + let forward = combine_forward(inherited.and_then(|i| i.forward.as_ref()), forward); + let mut reads = inherited.map_or_else(Reads::default, |inherited| inherited.reads.clone()); + for attr in attrs { + reads.read_in(attr); + } let mut composed_classes = Vec::new(); + let mut composed_rules = None; let mut errors = Vec::new(); + errors.extend(crate::css_utils::object_layer_errors(expression, "styled")); if let Expression::CallExpression(call) = expression && extract_base_tag_and_class_name(ast_builder, &call.callee, imports).is_some() { + for argument in &call.arguments { + reads.read_in(match argument { + Argument::SpreadElement(spread) => &spread.argument, + argument => argument.to_expression(), + }); + } + for argument in &mut call.arguments { + if let Some(rules) = argument.as_expression_mut() + && let Err(code) = rules_reading_props(ast_builder, rules, &bindings) + { + errors.push(( + code.span().start, + build_time_error("styled", &readable_code(&code), PROPS_FUNCTION), + )); + *rules = Expression::new_object_expression( + SPAN, + oxc_allocator::Vec::new_in(ast_builder), + ast_builder, + ); + } + } match style_arguments(ast_builder, &call.arguments) { Some(StyleArguments { classes, rules }) => { call.arguments = oxc_allocator::Vec::from_array_in([Argument::from(rules)], ast_builder); composed_classes = classes; } + None if !reads_directly(&call.arguments) + && let Some((rules, classes)) = + rule_parts::compose(ast_builder, &call.arguments) => + { + composed_rules = Some(rules); + composed_classes = classes; + call.arguments = oxc_allocator::Vec::from_array_in( + [Argument::from(Expression::new_object_expression( + SPAN, + oxc_allocator::Vec::new_in(ast_builder), + ast_builder, + ))], + ast_builder, + ); + } None if !reads_directly(&call.arguments) => { errors.push((call.span.start, uncomposable_error(&call.arguments))); call.arguments = oxc_allocator::Vec::from_array_in( @@ -209,32 +867,32 @@ pub fn extract_style_from_styled<'a>( None => {} } } - let (result, new_expr) = if let Expression::TaggedTemplateExpression(tag) = expression + 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 - let TemplateStyles { - styles, - statements, - unplaced, - } = css_to_style_template(&tag.quasi, 0, &None); - for index in unplaced { - let expression = &tag.quasi.expressions[index]; - errors.push((expression.span().start, unplaced_error(expression))); - } - let mut props_styles: Vec> = styles - .into_iter() - .map(|ex| ExtractStyleProp::Static(ex.into())) - .collect(); - - if let Some(default_class_name) = base.styles.take() { - props_styles.extend(default_class_name.into_iter().map(ExtractStyleProp::Static)); + for interpolation in &tag.quasi.expressions { + reads.read_in(interpolation); } + let template_composition::TemplateComposition { + styles: own, + statements, + errors: template_errors, + } = template_composition::compose(ast_builder, &tag.quasi, &bindings); + errors.extend(template_errors); + let defaults = base.styles.take(); + let base = base.extending(ast_builder, inherited); + let mut props_styles = compose_styles(ast_builder, inherited, defaults, own); + let (mut classes, attrs) = inherited_parts(ast_builder, inherited, attrs); - let mixins = statements.into_iter().map(|index| { + classes.extend(statements.into_iter().map(|index| { let mixin = &tag.quasi.expressions[index]; if matches!( unwrap_syntax_only(mixin), @@ -255,12 +913,11 @@ pub fn extract_style_from_styled<'a>( } else { mixin.clone_in(ast_builder.allocator()) } - }); + })); let class_name = merge_expression_for_class_name( ast_builder, - mixins - .collect::>() - .into_iter() + marker_classes(ast_builder, markers) + .chain(clone_all(ast_builder, &classes)) .chain(gen_class_names( ast_builder, &mut props_styles, @@ -279,8 +936,13 @@ pub fn extract_style_from_styled<'a>( &base.name, &class_name, &gen_styles(ast_builder, &props_styles, None), + &base.withheld(&reads, forward.as_ref()), ); - let styled_component = base.render(ast_builder, apply_attrs(ast_builder, component, attrs)); + let mut definition = + base.definition(ast_builder, classes, &props_styles, &attrs, reads, forward); + definition.markers = markers.to_vec(); + let styled_component = + base.render(ast_builder, apply_attrs(ast_builder, component, &attrs)); let result = ExtractResult { styles: props_styles, @@ -290,7 +952,7 @@ pub fn extract_style_from_styled<'a>( props: None, }; - (Some(result), Some(styled_component)) + (Some(result), Some(styled_component), Some(definition)) } else if let Expression::CallExpression(call) = expression && let Some((mut base, style_index)) = resolve_styled_call_target(ast_builder, call, imports) @@ -298,6 +960,13 @@ pub fn extract_style_from_styled<'a>( // Case 2: styled.div({ bg: "red" }), styled("div")({ bg: "red" }), // or styled("div", { bg: "red" }) + reads.read_in( + if let Argument::SpreadElement(spread) = &call.arguments[style_index] { + &spread.argument + } else { + call.arguments[style_index].to_expression() + }, + ); // Extract styles from object expression let ExtractResult { mut styles, @@ -305,18 +974,20 @@ pub fn extract_style_from_styled<'a>( style_vars, props, .. - } = extract_style_from_expression( - ast_builder, - None, - if let Argument::SpreadElement(spread) = &mut call.arguments[style_index] { - &mut spread.argument - } else { - call.arguments[style_index].to_expression_mut() - }, - 0, - &None, - LiteralHandling::ExpandResponsiveThemeToken, - ); + } = composed_rules.unwrap_or_else(|| { + extract_style_from_expression( + ast_builder, + None, + if let Argument::SpreadElement(spread) = &mut call.arguments[style_index] { + &mut spread.argument + } else { + call.arguments[style_index].to_expression_mut() + }, + 0, + &None, + LiteralHandling::ExpandResponsiveThemeToken, + ) + }); let mut unreadable = Vec::new(); unreadable_styles(&styles, true, &mut unreadable); errors.extend( @@ -324,26 +995,39 @@ pub fn extract_style_from_styled<'a>( .into_iter() .map(|(offset, code)| (offset, build_time_error("styled", &code, STYLE_OBJECT))), ); - if let Some(default_class_name) = base.styles.take() { - styles.extend(default_class_name.into_iter().map(ExtractStyleProp::Static)); + if let Some(order) = style_order { + for style in &mut styles { + 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 classes, attrs) = inherited_parts(ast_builder, inherited, attrs); + classes.extend(composed_classes); let class_name = merge_expression_for_class_name( ast_builder, - composed_classes.into_iter().chain(gen_class_names( - ast_builder, - &mut styles, - style_order, - split_filename, - )), + marker_classes(ast_builder, markers) + .chain(clone_all(ast_builder, &classes)) + .chain(gen_class_names( + ast_builder, + &mut styles, + None, + split_filename, + )), ); let component = create_styled_component( ast_builder, &base.name, &class_name, &gen_styles(ast_builder, &styles, None), + &base.withheld(&reads, forward.as_ref()), ); - let styled_component = base.render(ast_builder, apply_attrs(ast_builder, component, attrs)); + let mut definition = base.definition(ast_builder, classes, &styles, &attrs, reads, forward); + definition.markers = markers.to_vec(); + let styled_component = + base.render(ast_builder, apply_attrs(ast_builder, component, &attrs)); let result = ExtractResult { styles, @@ -353,7 +1037,7 @@ pub fn extract_style_from_styled<'a>( props, }; - (Some(result), Some(styled_component)) + (Some(result), Some(styled_component), Some(definition)) } else { // Left as written it would call `styled` at runtime, which only the build runs let code = match &*expression { @@ -364,13 +1048,93 @@ pub fn extract_style_from_styled<'a>( expression.span().start, build_time_error("styled", &code, STYLED_FACTORY), )); - (None, None) + (None, None, None) }; - ( - result.unwrap_or_else(ExtractResult::default), - new_expr.unwrap_or_else(|| expression.clone_in(ast_builder.allocator())), + StyledExtraction { + result: result.unwrap_or_else(ExtractResult::default), + expression: new_expr.unwrap_or_else(|| expression.clone_in(ast_builder.allocator())), errors, - ) + definition, + } +} + +/// What every module reads the same, besides the props a component takes +const SHARED_NAMES: &[&str] = &[ + "rest", + "undefined", + "NaN", + "Infinity", + "Math", + "Number", + "String", + "Boolean", + "Object", + "Array", + "JSON", + "parseInt", + "parseFloat", + "isNaN", + "isFinite", +]; + +/// The expressions a component evaluates for `prop` +fn style_expressions<'p, 'a>(prop: &'p ExtractStyleProp<'a>) -> Vec<&'p Expression<'a>> { + match prop { + ExtractStyleProp::Static(_) | ExtractStyleProp::Unreadable { .. } => Vec::new(), + ExtractStyleProp::StaticArray(props) => props.iter().flat_map(style_expressions).collect(), + ExtractStyleProp::Conditional { + condition, + consequent, + alternate, + } => std::iter::once(condition) + .chain(consequent.iter().flat_map(|prop| style_expressions(prop))) + .chain(alternate.iter().flat_map(|prop| style_expressions(prop))) + .collect(), + ExtractStyleProp::Enum { condition, map } => std::iter::once(condition) + .chain(map.values().flatten().flat_map(style_expressions)) + .collect(), + ExtractStyleProp::Expression { expression, .. } => vec![expression], + ExtractStyleProp::MemberExpression { map, expression } => std::iter::once(expression) + .chain(map.values().flat_map(|prop| style_expressions(prop))) + .collect(), + } +} + +/// Whether the expressions `code` writes read nothing but the bindings they +/// declare, the props, and what every module reads the same +fn closed(code: impl Iterator) -> bool { + let source = code + .map(|code| ["(", crate::css_utils::rm_last_semi_colon(&code), ");\n"].concat()) + .collect::(); + let allocator = oxc_allocator::Allocator::default(); + let ParserReturn { + program, + fatal_error, + .. + } = Parser::new(&allocator, &source, SourceType::tsx()).parse(); + let scoping = oxc_semantic::SemanticBuilder::new() + .build(&program) + .semantic + .into_scoping(); + !fatal_error + && scoping + .root_unresolved_references() + .iter() + .all(|(name, references)| { + SHARED_NAMES.contains(&name.as_str()) + || references + .iter() + .all(|reference| !scoping.get_reference(*reference).is_value()) + }) +} + +fn clone_all<'s, 'a>( + ast_builder: &'s AstBuilder<'a>, + expressions: &'s [Expression<'a>], +) -> impl Iterator> + 's { + expressions + .iter() + .map(|expression| expression.clone_in(ast_builder.allocator())) } /// The name the attrs wrapper binds props to, chosen not to shadow what the @@ -379,15 +1143,16 @@ const ATTRS_PROPS: &str = "__devupProps"; /// Strip styled-components' `.attrs()` / `.withConfig()` off a styled factory /// such as `styled.div.attrs(a).withConfig(c)`, returning the attrs in the -/// order they apply. `withConfig` only tunes runtime behavior, so it is dropped. +/// order they apply, and the `withConfig` options in the order they apply pub fn take_styled_modifiers<'a>( ast_builder: &AstBuilder<'a>, factory: &mut Expression<'a>, is_styled: impl Fn(&str) -> bool, -) -> Vec> { +) -> (Vec>, Vec>) { let mut attrs = Vec::new(); + let mut configs = Vec::new(); if !is_modified_styled(factory, is_styled) { - return attrs; + return (attrs, configs); } while let Expression::CallExpression(call) = unwrap_syntax_only_mut(factory) && let CallExpression { @@ -397,18 +1162,62 @@ pub fn take_styled_modifiers<'a>( && matches!(member.property.name.as_str(), "attrs" | "withConfig") { let placeholder = || Expression::new_null_literal(SPAN, ast_builder); - if member.property.name == "attrs" - && let Some(argument) = arguments[0].as_expression_mut() - { - attrs.push(std::mem::replace(argument, placeholder())); + if let Some(argument) = arguments[0].as_expression_mut() { + let argument = std::mem::replace(argument, placeholder()); + if member.property.name == "attrs" { + attrs.push(argument); + } else { + configs.push(argument); + } } let object = std::mem::replace(&mut member.object, placeholder()); *factory = object; } attrs.reverse(); - attrs + configs.reverse(); + (attrs, configs) +} + +/// The `shouldForwardProp` the options objects give, the later applying after +/// the earlier, with the offset and code of one the build cannot evaluate +pub fn read_forward(options: &[&Expression<'_>]) -> (Option, Option<(u32, String)>) { + let mut forward = None; + for options in options { + let Expression::ObjectExpression(object) = unwrap_syntax_only(options) else { + continue; + }; + for property in &object.properties { + let ObjectPropertyKind::ObjectProperty(property) = property else { + continue; + }; + if property.computed + || property.key.static_name().as_deref() != Some("shouldForwardProp") + { + continue; + } + match Forward::read(&property.value) { + Some(own) => forward = combine_forward(forward.as_ref(), Some(own)), + None => { + return ( + forward, + Some(( + property.value.span().start, + build_time_error( + "styled", + &readable_code(&property.value), + SHOULD_FORWARD_PROP, + ), + )), + ); + } + } + } + } + (forward, None) } +const SHOULD_FORWARD_PROP: &str = "`shouldForwardProp` must be a function of the prop name that compares it with strings, `[...].includes(prop)`, `prop.startsWith(...)` or `isPropValid(prop)`, joined by `!`, `&&` and `||`"; + fn is_modified_styled(expression: &Expression<'_>, is_styled: impl Fn(&str) -> bool) -> bool { let mut expression = unwrap_syntax_only(expression); let mut modified = false; @@ -452,44 +1261,215 @@ fn apply_attrs<'a>( if attrs.is_empty() { return component; } - let props = || Expression::new_identifier(SPAN, ATTRS_PROPS, ast_builder); - let mut merged = props(); + let merged = if attrs.iter().all(sets_plain_props) { + spread_attrs(ast_builder, attrs) + } else { + merge_attrs(ast_builder, attrs) + }; + props_arrow( + ast_builder, + wrap_direct_call(ast_builder, &component, &[merged]), + ) +} + +/// The binding each attrs step reads the props merged so far through +const ATTRS_CONTEXT: &str = "__devupContext"; +/// The binding each attrs step reads what its attrs give through +const ATTRS_VALUE: &str = "__devupAttrs"; + +/// Whether `attr` is an object setting props other than `className` and +/// `style` by name, which spreading over the props merges as attrs do +fn sets_plain_props(attr: &Expression<'_>) -> bool { + let Expression::ObjectExpression(object) = unwrap_syntax_only(attr) else { + return false; + }; + object.properties.iter().all(|property| { + matches!(property, ObjectPropertyKind::ObjectProperty(property) + if !property.computed + && property + .key + .static_name() + .is_some_and(|name| name != "className" && name != "style")) + }) +} + +/// The props with each attrs spread over them in order +fn spread_attrs<'a>(ast_builder: &AstBuilder<'a>, attrs: &[Expression<'a>]) -> Expression<'a> { + let mut merged = identifier(ast_builder, ATTRS_PROPS); for attr in attrs { - let attr = attr.clone_in(ast_builder.allocator()); - if matches!(unwrap_syntax_only(&attr), Expression::ObjectExpression(_)) { - merged = spread_objects(ast_builder, merged, attr); - continue; - } - let called = wrap_direct_call(ast_builder, &attr, &[props()]); - let resolved = if matches!( - unwrap_syntax_only(&attr), - Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_) - ) { - called - } else { - let is_function = Expression::new_binary_expression( + merged = spread_objects(ast_builder, merged, attr.clone_in(ast_builder.allocator())); + } + merged +} + +/// The props merged as styled-components merges attrs: each attrs, or what +/// calling it with the props merged so far gives, joins its `className` to +/// theirs, merges its `style` over theirs and replaces their other props; the +/// caller's `className` comes last +fn merge_attrs<'a>(ast_builder: &AstBuilder<'a>, attrs: &[Expression<'a>]) -> Expression<'a> { + let mut merged = with_property( + ast_builder, + identifier(ast_builder, ATTRS_PROPS), + "className", + identifier(ast_builder, "undefined"), + ); + for attr in attrs { + let resolved = spread_objects( + ast_builder, + Expression::new_object_expression( SPAN, - Expression::new_unary_expression( - SPAN, - UnaryOperator::Typeof, - attr.clone_in(ast_builder.allocator()), - ast_builder, - ), - BinaryOperator::StrictEquality, - Expression::new_string_literal(SPAN, "function", None, ast_builder), + oxc_allocator::Vec::new_in(ast_builder), ast_builder, - ); - Expression::new_conditional_expression(SPAN, is_function, called, attr, ast_builder) - }; - let step = props_arrow(ast_builder, spread_objects(ast_builder, props(), resolved)); + ), + resolve_attrs(ast_builder, attr.clone_in(ast_builder.allocator())), + ); + let merge = call_with_values( + ast_builder, + vec![(ATTRS_VALUE.to_string(), resolved)], + merge_attrs_value(ast_builder), + ); + let step = named_arrow(ast_builder, ATTRS_CONTEXT, merge); merged = wrap_direct_call(ast_builder, &step, &[merged]); } - props_arrow( + let caller_class = joined_class( + ast_builder, + member(ast_builder, ATTRS_CONTEXT, "className"), + member(ast_builder, ATTRS_PROPS, "className"), + ); + let last = named_arrow( + ast_builder, + ATTRS_CONTEXT, + with_property( + ast_builder, + identifier(ast_builder, ATTRS_CONTEXT), + "className", + caller_class, + ), + ); + wrap_direct_call(ast_builder, &last, &[merged]) +} + +/// What `attr` gives: itself when it is an object, or what calling it with +/// the props merged so far gives when it is a function +fn resolve_attrs<'a>(ast_builder: &AstBuilder<'a>, attr: Expression<'a>) -> Expression<'a> { + if matches!(unwrap_syntax_only(&attr), Expression::ObjectExpression(_)) { + return attr; + } + let called = wrap_direct_call( + ast_builder, + &attr, + &[identifier(ast_builder, ATTRS_CONTEXT)], + ); + if matches!( + unwrap_syntax_only(&attr), + Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_) + ) { + return called; + } + let is_function = Expression::new_binary_expression( + SPAN, + Expression::new_unary_expression( + SPAN, + UnaryOperator::Typeof, + attr.clone_in(ast_builder.allocator()), + ast_builder, + ), + BinaryOperator::StrictEquality, + Expression::new_string_literal(SPAN, "function", None, ast_builder), + ast_builder, + ); + Expression::new_conditional_expression(SPAN, is_function, called, attr, ast_builder) +} + +/// `{ ...context, ...attrs, className, style }` with the classes joined and +/// the styles merged +fn merge_attrs_value<'a>(ast_builder: &AstBuilder<'a>) -> Expression<'a> { + let merged = spread_objects( + ast_builder, + identifier(ast_builder, ATTRS_CONTEXT), + identifier(ast_builder, ATTRS_VALUE), + ); + let class_name = joined_class( + ast_builder, + member(ast_builder, ATTRS_CONTEXT, "className"), + member(ast_builder, ATTRS_VALUE, "className"), + ); + let style = spread_objects( + ast_builder, + member(ast_builder, ATTRS_CONTEXT, "style"), + member(ast_builder, ATTRS_VALUE, "style"), + ); + let merged = with_property(ast_builder, merged, "className", class_name); + with_property(ast_builder, merged, "style", style) +} + +fn identifier<'a>(ast_builder: &AstBuilder<'a>, name: &'static str) -> Expression<'a> { + Expression::new_identifier(SPAN, name, ast_builder) +} + +fn member<'a>( + ast_builder: &AstBuilder<'a>, + object: &'static str, + name: &'static str, +) -> Expression<'a> { + Expression::StaticMemberExpression(oxc_ast::ast::StaticMemberExpression::boxed( + SPAN, + identifier(ast_builder, object), + oxc_ast::ast::IdentifierName::new(SPAN, name, ast_builder), + false, + ast_builder, + )) +} + +/// `[first, second].filter(Boolean).join(" ") || undefined` +fn joined_class<'a>( + ast_builder: &AstBuilder<'a>, + first: Expression<'a>, + second: Expression<'a>, +) -> Expression<'a> { + let joined = wrap_array_filter(ast_builder, &[first, second]) + .unwrap_or_else(|| identifier(ast_builder, "undefined")); + Expression::new_logical_expression( + SPAN, + joined, + LogicalOperator::Or, + identifier(ast_builder, "undefined"), ast_builder, - wrap_direct_call(ast_builder, &component, &[merged]), ) } +/// `object` with `name` set to `value`: an object spreading it, then setting +/// the property +fn with_property<'a>( + ast_builder: &AstBuilder<'a>, + object: Expression<'a>, + name: &'static str, + value: Expression<'a>, +) -> Expression<'a> { + let mut properties = match object { + Expression::ObjectExpression(object) => object.unbox().properties, + object => oxc_allocator::Vec::from_array_in( + [ObjectPropertyKind::new_spread_property( + SPAN, + object, + ast_builder, + )], + ast_builder, + ), + }; + properties.push(ObjectPropertyKind::new_object_property( + SPAN, + oxc_ast::ast::PropertyKind::Init, + PropertyKey::new_static_identifier(SPAN, name, ast_builder), + value, + false, + false, + false, + ast_builder, + )); + Expression::new_object_expression(SPAN, properties, ast_builder) +} + fn spread_objects<'a>( ast_builder: &AstBuilder<'a>, first: Expression<'a>, @@ -510,10 +1490,19 @@ fn spread_objects<'a>( } fn props_arrow<'a>(ast_builder: &AstBuilder<'a>, body: Expression<'a>) -> Expression<'a> { + named_arrow(ast_builder, ATTRS_PROPS, body) +} + +/// `(name) => body` +fn named_arrow<'a>( + ast_builder: &AstBuilder<'a>, + name: &'static str, + body: Expression<'a>, +) -> Expression<'a> { let parameter = FormalParameter::new( SPAN, oxc_allocator::Vec::new_in(ast_builder), - BindingPattern::new_binding_identifier(SPAN, ATTRS_PROPS, ast_builder), + BindingPattern::new_binding_identifier(SPAN, name, ast_builder), None::>>, None::>>, false, @@ -540,11 +1529,180 @@ fn props_arrow<'a>(ast_builder: &AstBuilder<'a>, body: Expression<'a>) -> Expres ) } +/// The binding the generated styled components forward refs through +pub const FORWARD_REF: &str = "__devupForwardRef"; + +/// `__devupForwardRef((p, ref) => component({ ...p, ref }))`: React 18 gives a +/// function component no `ref` prop, so the component takes it as one and +/// passes it to what it renders with the rest of its props +pub fn forward_ref<'a>(ast_builder: &AstBuilder<'a>, component: Expression<'a>) -> Expression<'a> { + let parameter = |name: &'static str| { + FormalParameter::new( + SPAN, + oxc_allocator::Vec::new_in(ast_builder), + BindingPattern::new_binding_identifier(SPAN, name, ast_builder), + None::>>, + None::>>, + false, + None, + false, + false, + ast_builder, + ) + }; + let props = with_property( + ast_builder, + identifier(ast_builder, "__devupRefProps"), + "ref", + identifier(ast_builder, "__devupRef"), + ); + let body = wrap_direct_call( + ast_builder, + &Expression::new_parenthesized_expression(SPAN, component, ast_builder), + &[props], + ); + let render = Expression::new_arrow_function_expression( + SPAN, + false, + None::>>, + FormalParameters::boxed( + SPAN, + FormalParameterKind::ArrowFormalParameters, + oxc_allocator::Vec::from_iter_in( + [parameter("__devupRefProps"), parameter("__devupRef")], + ast_builder, + ), + None::>>, + ast_builder, + ), + None::>>, + body.into(), + ast_builder, + ); + wrap_direct_call( + ast_builder, + &identifier(ast_builder, FORWARD_REF), + &[render], + ) +} + +/// `rest` without the props in `withheld`, as `(({ a: _0, ...p }) => p)(rest)` +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(), + ast_builder, + ); + wrap_direct_call( + ast_builder, + &Expression::new_parenthesized_expression(SPAN, arrow, ast_builder), + &[rest], + ) +} + +/// The binding a styled component renders through: what `as` names, or the +/// tag or component it was defined with +const RENDERED: &str = "DevupAs"; + +/// What a styled component renders when `as` names nothing: a tag name as a +/// string, a component as the binding or member JSX names it by +fn tag_expression<'a>(ast_builder: &AstBuilder<'a>, tag_name: &str) -> Expression<'a> { + if tag_name.starts_with(|c: char| c.is_ascii_lowercase()) && !tag_name.contains('.') { + return Expression::new_string_literal( + SPAN, + Str::from_in(tag_name, ast_builder.allocator()), + None, + ast_builder, + ); + } + let mut parts = tag_name.split('.'); + let first = parts.next().unwrap_or(tag_name); + parts.fold( + Expression::new_identifier( + SPAN, + Str::from_in(first, ast_builder.allocator()), + ast_builder, + ), + |object, property| { + Expression::StaticMemberExpression(oxc_ast::ast::StaticMemberExpression::boxed( + SPAN, + object, + oxc_ast::ast::IdentifierName::new( + SPAN, + Str::from_in(property, ast_builder.allocator()), + ast_builder, + ), + false, + ast_builder, + )) + }, + ) +} + fn create_styled_component<'a>( ast_builder: &AstBuilder<'a>, tag_name: &str, class_name: &Option>, style_vars: &Option>, + withheld: &[String], ) -> Expression<'a> { let params = FormalParameters::boxed( SPAN, @@ -577,6 +1735,39 @@ fn create_styled_component<'a>( false, ast_builder, ), + BindingProperty::new( + SPAN, + PropertyKey::new_static_identifier(SPAN, "as", ast_builder), + BindingPattern::new_assignment_pattern( + SPAN, + BindingPattern::new_binding_identifier( + SPAN, + RENDERED, + ast_builder, + ), + tag_expression(ast_builder, tag_name), + ast_builder, + ), + false, + false, + ast_builder, + ), + BindingProperty::new( + SPAN, + PropertyKey::new_static_identifier( + SPAN, + "forwardedAs", + ast_builder, + ), + BindingPattern::new_binding_identifier( + SPAN, + "forwardedAs", + ast_builder, + ), + true, + false, + ast_builder, + ), ], ast_builder, ), @@ -604,17 +1795,23 @@ fn create_styled_component<'a>( SPAN, JSXOpeningElement::boxed( SPAN, - JSXElementName::new_identifier( - SPAN, - Str::from_in(tag_name, ast_builder.allocator()), - ast_builder, - ), + JSXElementName::new_identifier(SPAN, RENDERED, ast_builder), None::>>, oxc_allocator::Vec::from_iter_in( vec![ JSXAttributeItem::new_spread_attribute( SPAN, - Expression::new_identifier(SPAN, "rest", ast_builder), + without_props(ast_builder, withheld), + ast_builder, + ), + JSXAttributeItem::new_attribute( + SPAN, + JSXAttributeName::new_identifier(SPAN, "as", ast_builder), + Some(JSXAttributeValue::new_expression_container( + SPAN, + Expression::new_identifier(SPAN, "forwardedAs", ast_builder).into(), + ast_builder, + )), ast_builder, ), JSXAttributeItem::new_attribute( @@ -696,3 +1893,82 @@ fn create_styled_component<'a>( ast_builder, ) } + +#[cfg(test)] +#[path = "w27_props_rule_choices_tests.rs"] +mod w27_props_rule_choices_tests; + +#[cfg(test)] +#[path = "w27_styled_linux_coverage.rs"] +mod w27_styled_linux_coverage; + +#[cfg(test)] +#[path = "w27_styled_known_mixins_tests.rs"] +mod w27_styled_known_mixins_tests; + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used)] +mod tests { + use std::collections::BTreeMap; + + use oxc_allocator::Allocator; + + use super::*; + + #[test] + fn style_expressions_find_what_every_kind_of_style_evaluates() { + let allocator = Allocator::default(); + let ast_builder = AstBuilder::new(&allocator); + let name = |name: &str| { + Expression::new_identifier(SPAN, Str::from_in(name, &allocator), &ast_builder) + }; + let evaluated = |expression| ExtractStyleProp::Expression { + styles: vec![], + expression, + }; + let props = [ + ExtractStyleProp::Static(ExtractStyleValue::Typography("t".to_string())), + ExtractStyleProp::Unreadable { + offset: 0, + code: String::new(), + prop: false, + }, + ExtractStyleProp::StaticArray(vec![evaluated(name("a"))]), + ExtractStyleProp::Conditional { + condition: name("b"), + consequent: Some(Box::new(evaluated(name("c")))), + alternate: Some(Box::new(evaluated(name("d")))), + }, + ExtractStyleProp::Enum { + condition: name("e"), + map: BTreeMap::from([("x".to_string(), vec![evaluated(name("f"))])]), + }, + ExtractStyleProp::MemberExpression { + map: BTreeMap::from([("k".to_string(), Box::new(evaluated(name("h"))))]), + expression: name("g"), + }, + ]; + let code: Vec = props + .iter() + .flat_map(style_expressions) + .map(expression_to_code) + .collect(); + assert_eq!(code, ["a;", "b;", "c;", "d;", "e;", "f;", "g;", "h;"]); + } + + #[test] + fn code_is_closed_when_it_reads_only_what_it_declares() { + let closed_code = |code: &[&str]| closed(code.iter().map(ToString::to_string)); + assert!(closed_code(&[])); + assert!(closed_code(&[ + "(p) => p.x * Math.PI", + "((p) => p.on)(rest)" + ])); + assert!(closed_code(&["(p: Props) => p.x"])); + assert!(closed_code(&["({ id: `known` });", "((p) => p.on)(rest);"])); + assert!(!closed_code(&["({ onClick: handler });"])); + assert!(!closed_code(&["(p) => scale(p.x)"])); + assert!(!closed_code(&["(p) => p.x", "window.mode"])); + assert!(!closed_code(&["@#$"])); + } +} diff --git a/libs/extractor/src/extractor/styled_choice_test_support.rs b/libs/extractor/src/extractor/styled_choice_test_support.rs new file mode 100644 index 000000000..1e7a6b895 --- /dev/null +++ b/libs/extractor/src/extractor/styled_choice_test_support.rs @@ -0,0 +1,108 @@ +use crate::extract_style::ExtractStyleProperty; +use crate::extract_style::style_property::StyleProperty; +use crate::{ExtractOutput, ExtractStyleValue}; +use oxc_ast::ast::{JSXAttribute, JSXAttributeValue}; +use oxc_ast_visit::Visit; + +#[derive(Default)] +struct Classes(String); + +impl<'a> Visit<'a> for Classes { + fn visit_jsx_attribute(&mut self, attribute: &JSXAttribute<'a>) { + if attribute + .name + .as_identifier() + .is_some_and(|name| name.name == "className") + && let Some(JSXAttributeValue::ExpressionContainer(value)) = &attribute.value + && let Some(expression) = value.expression.as_expression() + { + self.0 = + crate::css_utils::rm_last_semi_colon(&crate::utils::expression_to_code(expression)) + .to_string(); + } + } +} + +pub(crate) fn active(output: &ExtractOutput, props: &str) -> Vec { + let allocator = oxc_allocator::Allocator::default(); + let parsed = + oxc_parser::Parser::new(&allocator, &output.code, oxc_span::SourceType::tsx()).parse(); + assert_eq!(parsed.diagnostics.len(), 0); + let mut classes = Classes::default(); + classes.visit_program(&parsed.program); + assert_ne!(classes.0, "", "expected generated className expression"); + let names = evaluate(&classes.0, props); + output + .styles + .iter() + .filter(|value| { + let class = match value { + ExtractStyleValue::Static(style) => style.extract(None).to_string(), + ExtractStyleValue::Dynamic(style) => { + let property = style.extract(None); + assert!( + matches!(property, StyleProperty::Variable { .. }), + "dynamic atom requires a variable" + ); + match property { + StyleProperty::Variable { class_name, .. } + | StyleProperty::ClassName(class_name) => class_name, + } + } + other => panic!("expected static atom, got {other:?}"), + }; + names.split_whitespace().any(|name| name == class) + }) + .cloned() + .collect() +} + +pub(crate) fn evaluate(expression: &str, props: &str) -> String { + let code = format!("const rest = {props}; const className = ''; ({expression});"); + let mut context = boa_engine::Context::default(); + context + .eval(boa_engine::Source::from_bytes(&code)) + .unwrap_or_else(|error| panic!("{error}: {code}")) + .to_string(&mut context) + .unwrap_or_else(|error| panic!("{error}")) + .to_std_string_escaped() +} + +pub(crate) fn declarations(styles: &[ExtractStyleValue]) -> Vec<(String, String)> { + let mut declarations: Vec<_> = styles + .iter() + .map(|value| match value { + ExtractStyleValue::Static(style) => { + (style.property().to_string(), style.value().to_string()) + } + other => panic!("expected static atom, got {other:?}"), + }) + .collect(); + declarations.sort(); + declarations +} + +#[rstest::rstest] +#[case("
")] +#[case("
")] +#[case("
")] +#[case("
")] +fn active_oracle_when_generated_class_expression_is_missing_rejects( + #[case] jsx: &str, +) -> Result<(), Box> { + // Given + let mut output = crate::extract( + "oracle.tsx", + "const value = 1;", + crate::ExtractOption::default(), + )?; + output.code = format!("const Choice = () => {jsx};"); + // When + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| active(&output, "{}"))); + // Then + assert!( + result.is_err(), + "missing classes must not become empty styles" + ); + Ok(()) +} diff --git a/libs/extractor/src/extractor/styled_mixin_context.rs b/libs/extractor/src/extractor/styled_mixin_context.rs new file mode 100644 index 000000000..7cfcf6ed8 --- /dev/null +++ b/libs/extractor/src/extractor/styled_mixin_context.rs @@ -0,0 +1,135 @@ +use crate::extract_style::{ + extract_dynamic_style::ExtractDynamicStyle, extract_static_style::ExtractStaticStyle, +}; +use crate::{ExtractStyleProp, ExtractStyleValue}; +use css::style_selector::StyleSelector; + +pub(super) enum ContextError { + Never, + Opaque, +} + +#[cfg(test)] +#[path = "w27_styled_context_coverage.rs"] +mod coverage_tests; + +pub(super) fn contextualize<'a>( + props: Vec>, + context: &[String], +) -> Result>, ContextError> { + if context.is_empty() { + return Ok(props); + } + let text = format!( + "{}--devupContext:0;{}", + context.concat(), + "}".repeat(context.len()) + ); + let parsed = crate::css_utils::css_to_style(&text, 0, &None); + let Some(parent) = parsed.first() else { + return Ok(Vec::new()); + }; + props.into_iter().map(|prop| place(prop, parent)).collect() +} + +fn nest( + parent: Option<&StyleSelector>, + child: Option<&StyleSelector>, +) -> Result, ContextError> { + let mut selector = parent.cloned(); + match child { + None => {} + Some(StyleSelector::Selector(child)) => { + selector = Some(StyleSelector::nest_selector(selector.as_ref(), child)); + } + Some(StyleSelector::At { + outer, + kind, + query, + selector: child, + .. + }) => { + for rule in outer { + selector = Some( + StyleSelector::nest_at_rule(selector.as_ref(), rule.kind, &rule.query) + .ok_or(ContextError::Never)?, + ); + } + selector = Some( + StyleSelector::nest_at_rule(selector.as_ref(), *kind, query) + .ok_or(ContextError::Never)?, + ); + if let Some(child) = child { + selector = Some(StyleSelector::nest_selector(selector.as_ref(), child)); + } + } + Some(StyleSelector::Global(_, _)) => return Err(ContextError::Opaque), + } + Ok(selector) +} + +fn place<'a>( + prop: ExtractStyleProp<'a>, + parent: &ExtractStaticStyle, +) -> Result, ContextError> { + match prop { + ExtractStyleProp::Static(ExtractStyleValue::Static(mut style)) => { + style.selector = match nest(parent.selector(), style.selector()) { + Ok(selector) => selector, + Err(ContextError::Never) => return Ok(ExtractStyleProp::StaticArray(Vec::new())), + Err(ContextError::Opaque) => return Err(ContextError::Opaque), + }; + if let Some(layer) = parent.layer() { + crate::css_utils::nest_layer(layer, &mut style.layer); + } + Ok(ExtractStyleProp::Static(ExtractStyleValue::Static(style))) + } + ExtractStyleProp::Static(ExtractStyleValue::Dynamic(style)) => { + let selector = match nest(parent.selector(), style.selector()) { + Ok(selector) => selector, + Err(ContextError::Never) => return Ok(ExtractStyleProp::StaticArray(Vec::new())), + Err(ContextError::Opaque) => return Err(ContextError::Opaque), + }; + let identifier = if style.important() { + format!("{} !important", style.identifier()) + } else { + style.identifier().to_string() + }; + let mut nested = + ExtractDynamicStyle::new(style.property(), style.level(), &identifier, selector); + nested.layer = style.layer().map(str::to_string); + if let Some(layer) = parent.layer() { + crate::css_utils::nest_layer(layer, &mut nested.layer); + } + let mut value = ExtractStyleValue::Dynamic(nested); + if let Some(order) = style.style_order() { + value.set_style_order(order); + } + Ok(ExtractStyleProp::Static(value)) + } + ExtractStyleProp::StaticArray(props) => Ok(ExtractStyleProp::StaticArray( + props + .into_iter() + .map(|prop| place(prop, parent)) + .collect::>()?, + )), + ExtractStyleProp::Conditional { + condition, + consequent, + alternate, + } => Ok(ExtractStyleProp::Conditional { + condition, + consequent: consequent + .map(|prop| place(*prop, parent).map(Box::new)) + .transpose()?, + alternate: alternate + .map(|prop| place(*prop, parent).map(Box::new)) + .transpose()?, + }), + ExtractStyleProp::Static(_) + | ExtractStyleProp::Expression { .. } + | ExtractStyleProp::Enum { .. } + | ExtractStyleProp::MemberExpression { .. } + | ExtractStyleProp::Unreadable { .. } => Err(ContextError::Opaque), + } +} diff --git a/libs/extractor/src/extractor/styled_rule_choices.rs b/libs/extractor/src/extractor/styled_rule_choices.rs new file mode 100644 index 000000000..0dccc96db --- /dev/null +++ b/libs/extractor/src/extractor/styled_rule_choices.rs @@ -0,0 +1,242 @@ +use oxc_allocator::{CloneIn, GetAllocator}; +use oxc_ast::{ast::Expression, builder::AstBuilder}; +use oxc_span::{GetSpan, SPAN}; +use oxc_syntax::operator::{BinaryOperator, LogicalOperator, UnaryOperator}; + +use super::StyledBindings; +use crate::utils::{is_pure, unwrap_syntax_only}; + +#[cfg(test)] +#[path = "w27_styled_choice_coverage.rs"] +mod coverage_tests; + +enum Choice<'a> { + Rules { + value: Expression<'a>, + truthy: bool, + }, + Scalar { + value: Expression<'a>, + truthy: Option, + nullish: Option, + }, + Branch { + test: Expression<'a>, + yes: Box>, + no: Box>, + }, +} + +struct ShortCircuit<'s, 'a, 'b> { + operator: LogicalOperator, + right: &'s Expression<'a>, + bindings: &'s StyledBindings<'b>, +} + +pub(super) enum RuleError { + Opaque, + BoundUndefined, +} + +pub(super) fn normalize<'a>( + ast: &AstBuilder<'a>, + value: &Expression<'a>, + bindings: &StyledBindings<'_>, +) -> Result, RuleError> { + emit(ast, read(ast, value, bindings)?).ok_or(RuleError::Opaque) +} + +fn read<'a>( + ast: &AstBuilder<'a>, + value: &Expression<'a>, + bindings: &StyledBindings<'_>, +) -> Result, RuleError> { + let value = unwrap_syntax_only(value); + if let Some(styles) = bindings.styles(value) { + return Ok(Choice::Rules { + value: value.clone_in_with_semantic_ids(ast.allocator()), + truthy: !styles.is_empty(), + }); + } + match value { + Expression::ObjectExpression(_) => Ok(Choice::Rules { + value: value.clone_in_with_semantic_ids(ast.allocator()), + truthy: true, + }), + Expression::ConditionalExpression(branch) if is_pure(&branch.test) => Ok(Choice::Branch { + test: branch.test.clone_in_with_semantic_ids(ast.allocator()), + yes: Box::new(read(ast, &branch.consequent, bindings)?), + no: Box::new(read(ast, &branch.alternate, bindings)?), + }), + Expression::LogicalExpression(logical) => { + let left = read(ast, &logical.left, bindings)?; + select( + ast, + left, + &ShortCircuit { + operator: logical.operator, + right: &logical.right, + bindings, + }, + ) + } + Expression::NullLiteral(_) => Ok(scalar(ast, value, (Some(false), Some(true)))), + Expression::UnaryExpression(unary) + if unary.operator == UnaryOperator::Void && is_pure(&unary.argument) => + { + Ok(scalar(ast, value, (Some(false), Some(true)))) + } + Expression::BooleanLiteral(literal) => { + Ok(scalar(ast, value, (Some(literal.value), Some(false)))) + } + Expression::Identifier(identifier) if identifier.name == "undefined" => { + if bindings.values.symbol(value).is_some() { + Err(RuleError::BoundUndefined) + } else if identifier.reference_id.get().is_some() { + Ok(scalar(ast, value, (Some(false), Some(true)))) + } else { + Err(RuleError::Opaque) + } + } + Expression::NumericLiteral(literal) => Ok(scalar( + ast, + value, + ( + Some(literal.value != 0.0 && !literal.value.is_nan()), + Some(false), + ), + )), + Expression::StringLiteral(literal) => Ok(scalar( + ast, + value, + (Some(!literal.value.is_empty()), Some(false)), + )), + value if is_pure(value) => Ok(scalar(ast, value, (None, None))), + _ => Err(RuleError::Opaque), + } +} + +fn scalar<'a>( + ast: &AstBuilder<'a>, + value: &Expression<'a>, + facts: (Option, Option), +) -> Choice<'a> { + let (truthy, nullish) = facts; + Choice::Scalar { + value: value.clone_in_with_semantic_ids(ast.allocator()), + truthy, + nullish, + } +} + +fn select<'a>( + ast: &AstBuilder<'a>, + left: Choice<'a>, + next: &ShortCircuit<'_, 'a, '_>, +) -> Result, RuleError> { + let right = || read(ast, next.right, next.bindings); + match left { + Choice::Branch { test, yes, no } => Ok(Choice::Branch { + test, + yes: Box::new(select(ast, *yes, next)?), + no: Box::new(select(ast, *no, next)?), + }), + left @ Choice::Rules { truthy, .. } => match next.operator { + LogicalOperator::And if truthy => right(), + LogicalOperator::Or if !truthy => right(), + LogicalOperator::And | LogicalOperator::Or | LogicalOperator::Coalesce => Ok(left), + }, + Choice::Scalar { + value, + truthy, + nullish, + } => { + let selected = match next.operator { + LogicalOperator::And => truthy.map(|truthy| !truthy), + LogicalOperator::Or => truthy, + LogicalOperator::Coalesce => nullish.map(|nullish| !nullish), + }; + let left = scalar(ast, &value, (truthy, nullish)); + match selected { + Some(true) => Ok(left), + Some(false) => right(), + None => { + let (test, yes, no) = match next.operator { + LogicalOperator::And => ( + value.clone_in_with_semantic_ids(ast.allocator()), + right()?, + scalar(ast, &value, (Some(false), nullish)), + ), + LogicalOperator::Or => ( + value.clone_in_with_semantic_ids(ast.allocator()), + scalar(ast, &value, (Some(true), Some(false))), + right()?, + ), + LogicalOperator::Coalesce => ( + is_nullish(ast, &value), + right()?, + scalar(ast, &value, (truthy, Some(false))), + ), + }; + Ok(Choice::Branch { + test, + yes: Box::new(yes), + no: Box::new(no), + }) + } + } + } + } +} + +fn is_nullish<'a>(ast: &AstBuilder<'a>, value: &Expression<'a>) -> Expression<'a> { + let compare = |right| { + Expression::new_binary_expression( + SPAN, + value.clone_in_with_semantic_ids(ast.allocator()), + BinaryOperator::StrictEquality, + right, + ast, + ) + }; + let undefined = Expression::new_unary_expression( + SPAN, + UnaryOperator::Void, + Expression::new_numeric_literal( + SPAN, + 0.0, + None, + oxc_syntax::number::NumberBase::Decimal, + ast, + ), + ast, + ); + Expression::new_logical_expression( + SPAN, + compare(Expression::new_null_literal(SPAN, ast)), + LogicalOperator::Or, + compare(undefined), + ast, + ) +} + +fn emit<'a>(ast: &AstBuilder<'a>, choice: Choice<'a>) -> Option> { + match choice { + Choice::Rules { value, .. } => Some(value), + Choice::Scalar { + value, + truthy, + nullish, + } => (truthy == Some(false) + || nullish == Some(true) + || matches!(value, Expression::BooleanLiteral(_))) + .then(|| Expression::new_null_literal(value.span(), ast)), + Choice::Branch { test, yes, no } => Some(Expression::new_conditional_expression( + test.span(), + test, + emit(ast, *yes)?, + emit(ast, *no)?, + ast, + )), + } +} diff --git a/libs/extractor/src/extractor/styled_rule_parts.rs b/libs/extractor/src/extractor/styled_rule_parts.rs new file mode 100644 index 000000000..441a72de7 --- /dev/null +++ b/libs/extractor/src/extractor/styled_rule_parts.rs @@ -0,0 +1,146 @@ +use super::{ExtractResult, LiteralHandling, extract_style_from_expression}; +use crate::{ + composition::Composition, + utils::{StyleArguments, style_arguments, unwrap_syntax_only}, +}; +use oxc_ast::{ + ast::{Argument, Expression}, + builder::AstBuilder, +}; + +pub(super) fn compose<'a>( + ast: &AstBuilder<'a>, + arguments: &[Argument<'a>], +) -> Option<(ExtractResult<'a>, Vec>)> { + let mut composition = Composition::default(); + let mut classes = Vec::new(); + let mut result = ExtractResult::default(); + for argument in arguments { + let mut rules = match argument.as_expression() { + Some(rules) if written(rules) => rules.clone_in_with_semantic_ids(ast.allocator()), + _ => { + let StyleArguments { + classes: own, + rules, + } = style_arguments(ast, std::slice::from_ref(argument))?; + classes.extend(own); + rules + } + }; + let part = extract_style_from_expression( + ast, + None, + &mut rules, + 0, + &None, + LiteralHandling::ExpandResponsiveThemeToken, + ); + composition.apply(ast, part.styles); + result.style_order = part.style_order.or(result.style_order); + result.style_vars = part.style_vars.or(result.style_vars); + result.props = part.props.or(result.props); + } + result.styles = composition.into_props(); + Some((result, classes)) +} + +fn written(rules: &Expression<'_>) -> bool { + match unwrap_syntax_only(rules) { + Expression::ObjectExpression(_) + | Expression::NullLiteral(_) + | Expression::BooleanLiteral(_) => true, + Expression::Identifier(identifier) => identifier.name == "undefined", + Expression::ConditionalExpression(branch) => { + written(&branch.consequent) && written(&branch.alternate) + } + _ => false, + } +} + +use oxc_allocator::{CloneIn, GetAllocator}; + +#[cfg(test)] +mod coverage_tests { + use super::*; + use crate::extract_style::{ExtractStyleProperty, extract_static_style::ExtractStaticStyle}; + + #[test] + fn undefined_part_when_next_to_written_choice_adds_no_style_or_class() + -> Result<(), Box> { + // Given + let allocator = oxc_allocator::Allocator::default(); + let ast = AstBuilder::new(&allocator); + let parsed = oxc_parser::Parser::new( + &allocator, + "parts(undefined, on ? { color: 'red' } : null)", + oxc_span::SourceType::tsx(), + ) + .parse_expression() + .map_err(|error| format!("failed to parse valid call: {error:?}"))?; + let Expression::CallExpression(call) = parsed else { + panic!("expected call") + }; + // When + let (result, classes) = + compose(&ast, &call.arguments).ok_or("written parts were unreadable")?; + // Then + assert_eq!(classes.len(), 0); + assert!(matches!( + result.styles.as_slice(), + [crate::ExtractStyleProp::Conditional { .. }] + )); + Ok(()) + } + + #[rstest::rstest] + #[case("true", "blue")] + #[case("false", "red")] + #[serial_test::serial] + fn opaque_class_when_paired_with_written_choice_preserves_selected_atom( + #[case] on: &str, + #[case] expected: &str, + ) -> Result<(), Box> { + // Given + let allocator = oxc_allocator::Allocator::default(); + let ast = AstBuilder::new(&allocator); + let parsed = oxc_parser::Parser::new( + &allocator, + "parts(external, { color: 'red' }, on ? { color: 'blue' } : null)", + oxc_span::SourceType::tsx(), + ) + .parse_expression() + .map_err(|error| format!("failed to parse valid call: {error:?}"))?; + let Expression::CallExpression(call) = parsed else { + panic!("expected call") + }; + // When + let (mut result, classes) = + compose(&ast, &call.arguments).ok_or("style parts were unreadable")?; + let class = crate::gen_class_name::gen_class_names(&ast, &mut result.styles, None, None) + .ok_or("selected atom produced no class")?; + let code = format!( + "const on = {on}; {}", + crate::utils::expression_to_code(&class) + ); + let mut context = boa_engine::Context::default(); + let selected = context + .eval(boa_engine::Source::from_bytes(&code)) + .map_err(|error| format!("choice failed to evaluate: {error}"))? + .to_string(&mut context) + .map_err(|error| format!("selected class failed string conversion: {error}"))? + .to_std_string_escaped(); + // Then + assert_eq!(classes.len(), 1); + assert_eq!( + crate::css_utils::rm_last_semi_colon(&crate::utils::expression_to_code(&classes[0])), + "external" + ); + assert_eq!( + selected, + ExtractStaticStyle::new("color", expected, 0, None) + .extract(None) + .to_string() + ); + Ok(()) + } +} diff --git a/libs/extractor/src/extractor/styled_template_composition.rs b/libs/extractor/src/extractor/styled_template_composition.rs new file mode 100644 index 000000000..de2dce0ea --- /dev/null +++ b/libs/extractor/src/extractor/styled_template_composition.rs @@ -0,0 +1,218 @@ +use super::{ + StyledBindings, call_with_props, identifier, props_function, props_leaves, rule_choices, +}; +use crate::{ + ExtractStyleProp, + composition::Composition, + css_utils::css_to_style_template, + utils::{build_time_error, readable_code, unplaced_error, unwrap_syntax_only}, +}; +use oxc_allocator::{CloneIn, GetAllocator}; +use oxc_ast::{ + ast::{Expression, FormalParameters, TemplateLiteral}, + builder::AstBuilder, +}; +use oxc_span::GetSpan; + +#[path = "styled_mixin_context.rs"] +mod context; +#[path = "styled_template_parts.rs"] +mod parts; + +pub(super) struct TemplateComposition<'a> { + pub styles: Vec>, + pub statements: Vec, + pub errors: Vec<(u32, String)>, +} + +#[cfg(test)] +#[path = "w27_styled_template_coverage.rs"] +mod coverage_tests; + +pub(super) fn compose<'a>( + ast: &AstBuilder<'a>, + template: &TemplateLiteral<'a>, + bindings: &StyledBindings<'_>, +) -> TemplateComposition<'a> { + let mut composition = Composition::default(); + let mut statements = Vec::new(); + let mut errors = Vec::new(); + for part in parts::split(ast, template, |expression| { + bindings.styles(expression).is_some() + }) { + match part { + parts::Part::Text(text) => composition.apply( + ast, + css_to_style_template(&text, 0, &None) + .styles + .into_iter() + .map(|style| ExtractStyleProp::Static(style.into())) + .collect(), + ), + parts::Part::Unplaced(index) => { + let expression = &template.expressions[index]; + errors.push((expression.span().start, unplaced_error(expression))); + } + parts::Part::Mixin { index, context } => { + let expression = &template.expressions[index]; + match mixin(ast, expression, bindings) { + Ok(Some(props)) => match context::contextualize(props, &context) { + Ok(props) => composition.apply(ast, props), + Err(context::ContextError::Never | context::ContextError::Opaque) => { + errors.push(nested_error(expression)); + } + }, + Ok(None) if context.is_empty() => statements.push(index), + Ok(None) => errors.push(nested_error(expression)), + Err(MixinError::Rules) => errors.push(( + expression.span().start, + build_time_error( + "styled", + &readable_code(expression), + crate::utils::STYLE_OBJECT, + ), + )), + } + } + } + } + TemplateComposition { + styles: composition.into_props(), + statements, + errors, + } +} + +fn nested_error(expression: &Expression<'_>) -> (u32, String) { + ( + expression.span().start, + build_time_error( + "styled", + &readable_code(expression), + "a nested mixin must give styles the build can read, so they inherit its enclosing selector, media query and layer", + ), + ) +} + +enum MixinError { + Rules, +} + +fn mixin<'a>( + ast: &AstBuilder<'a>, + expression: &Expression<'a>, + bindings: &StyledBindings<'_>, +) -> Result>>, MixinError> { + let mut expression = unwrap_syntax_only(expression).clone_in_with_semantic_ids(ast.allocator()); + let (params, rules) = match &expression { + Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_) => { + let Some((params, rules)) = crate::css_prop::render_function(&mut expression) else { + return Ok(None); + }; + ( + Some(params.clone_in_with_semantic_ids(ast.allocator())), + rules.clone_in_with_semantic_ids(ast.allocator()), + ) + } + _ => (None, expression), + }; + let mut lowered = match rule_choices::normalize(ast, &rules, bindings) { + Ok(lowered) => lowered, + Err(rule_choices::RuleError::BoundUndefined) => return Err(MixinError::Rules), + Err(rule_choices::RuleError::Opaque) => { + return if written_object(&rules) { + Err(MixinError::Rules) + } else { + Ok(None) + }; + } + }; + MixinReader { + ast, + bindings, + params: params.as_ref(), + } + .read(&mut lowered) + .map(Some) + .ok_or(MixinError::Rules) +} + +fn written_object(rules: &Expression<'_>) -> bool { + match unwrap_syntax_only(rules) { + Expression::ObjectExpression(_) => true, + Expression::ConditionalExpression(branch) => { + written_object(&branch.consequent) || written_object(&branch.alternate) + } + Expression::LogicalExpression(logical) => { + written_object(&logical.left) || written_object(&logical.right) + } + _ => false, + } +} + +struct MixinReader<'s, 'a, 'b> { + ast: &'s AstBuilder<'a>, + bindings: &'s StyledBindings<'b>, + params: Option<&'s FormalParameters<'a>>, +} + +impl<'a> MixinReader<'_, 'a, '_> { + fn read(&self, rules: &mut Expression<'a>) -> Option>> { + let Self { + ast, + bindings, + params, + } = *self; + if let Some(styles) = bindings.styles(rules) { + return Some( + styles + .iter() + .cloned() + .map(ExtractStyleProp::Static) + .collect(), + ); + } + match rules { + Expression::NullLiteral(_) => Some(Vec::new()), + Expression::ConditionalExpression(branch) => { + let test = match params { + Some(params) => crate::utils::wrap_direct_call( + ast, + &props_function( + ast, + params, + branch.test.clone_in_with_semantic_ids(ast.allocator()), + "opacity", + ), + &[identifier(ast, "rest")], + ), + None => branch.test.clone_in_with_semantic_ids(ast.allocator()), + }; + let yes = self.read(&mut branch.consequent)?; + let no = self.read(&mut branch.alternate)?; + let mut composition = Composition::default(); + composition.apply_conditional(ast, &test, yes, no); + Some(composition.into_props()) + } + Expression::ObjectExpression(object) => { + if let Some(params) = params { + props_leaves(ast, object, params)?; + } + call_with_props(ast, object); + let styles = super::extract_style_from_expression( + ast, + None, + rules, + 0, + &None, + super::LiteralHandling::ExpandResponsiveThemeToken, + ) + .styles; + let mut unreadable = Vec::new(); + crate::utils::unreadable_styles(&styles, true, &mut unreadable); + unreadable.is_empty().then_some(styles) + } + _ => None, + } + } +} diff --git a/libs/extractor/src/extractor/styled_template_parts.rs b/libs/extractor/src/extractor/styled_template_parts.rs new file mode 100644 index 000000000..5dd198bf2 --- /dev/null +++ b/libs/extractor/src/extractor/styled_template_parts.rs @@ -0,0 +1,174 @@ +use oxc_allocator::{CloneIn, FromIn, GetAllocator}; +use oxc_ast::{ + ast::{Expression, Str, TemplateElement, TemplateElementValue, TemplateLiteral}, + builder::AstBuilder, +}; + +#[cfg(test)] +#[path = "w27_styled_template_parts_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 rest: String = after.iter().map(|quasi| quasi.value.raw.as_str()).collect(); + if blocks::boundaries(&rest) + .next() + .is_some_and(|(_, boundary)| boundary == '{') + { + return crate::css_utils::Place::Other; + } + if head.contains(':') { + crate::css_utils::Place::Value + } else if head.trim().is_empty() { + crate::css_utils::Place::Statement + } else { + crate::css_utils::Place::Other + } +} + +fn contexts(text: &str) -> Vec { + let mut stack = Vec::new(); + let mut from = 0; + for (index, boundary) in blocks::boundaries(text) { + match boundary { + '{' => stack.push(text[from..=index].to_string()), + '}' => { + stack.pop(); + } + _ => {} + } + from = index + 1; + } + stack +} + +struct Segment<'s> { + range: std::ops::RangeInclusive, + opening: &'s [String], + close: usize, +} + +fn segment<'a>( + ast: &AstBuilder<'a>, + template: &TemplateLiteral<'a>, + segment: Segment<'_>, +) -> TemplateLiteral<'a> { + let Segment { + range, + opening, + close, + } = segment; + let from = *range.start(); + let to = *range.end(); + let quasis = template.quasis[range] + .iter() + .enumerate() + .map(|(index, quasi)| { + let raw = format!( + "{}{}{}", + if index == 0 { + opening.concat() + } else { + String::new() + }, + quasi.value.raw, + if index == to - from { + "}".repeat(close) + } else { + String::new() + } + ); + TemplateElement::new( + quasi.span, + TemplateElementValue { + raw: Str::from_in(raw.as_str(), ast.allocator()), + cooked: None, + }, + index == to - from, + ast, + ) + }); + TemplateLiteral::new( + template.span, + oxc_allocator::Vec::from_iter_in(quasis, ast), + oxc_allocator::Vec::from_iter_in( + template.expressions[from..to] + .iter() + .map(|expression| expression.clone_in_with_semantic_ids(ast.allocator())), + ast, + ), + ast, + ) +} diff --git a/libs/extractor/src/extractor/w27_coverage_tests.rs b/libs/extractor/src/extractor/w27_coverage_tests.rs new file mode 100644 index 000000000..d3b3271c7 --- /dev/null +++ b/libs/extractor/src/extractor/w27_coverage_tests.rs @@ -0,0 +1,165 @@ +use crate::{ExtractOption, ExtractStyleValue, ResolvedModule, extract, extract_with_modules}; +use rstest::rstest; +use serial_test::serial; + +#[path = "w27_styled_composition_coverage.rs"] +mod styled_composition; + +#[test] +fn declaration_oracle_when_given_unsupported_atom_rejects_instead_of_filtering_it() { + // Given + let styles = [ExtractStyleValue::Typography("body".into())]; + // When + let result = std::panic::catch_unwind(|| { + super::super::w27_props_rule_choices_tests::support::declarations(&styles) + }); + // Then + assert!(result.is_err(), "unsupported atoms must fail the oracle"); +} + +#[test] +#[serial] +fn active_oracle_when_output_has_unsupported_atom_rejects_even_if_class_is_inactive() +-> Result<(), Box> { + // Given + let mut output = extract( + "oracle.tsx", + "import { styled } from '@devup-ui/react'; export const Choice = styled.div`color: red;`;", + super::option(), + )?; + output + .styles + .insert(ExtractStyleValue::Typography("body".into())); + // When + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + super::super::w27_props_rule_choices_tests::support::active(&output, "{}") + })); + // Then + assert!( + result.is_err(), + "inactive unsupported atoms must fail the oracle" + ); + Ok(()) +} + +#[test] +#[serial] +fn static_declaration_oracle_when_output_contains_runtime_values_rejects_it() +-> Result<(), Box> { + // Given + let source = "import styled from '@emotion/styled'; export const Choice = styled.div`width: ${p => p.width};`;"; + let output = extract("coverage.tsx", source, super::option())?; + assert!( + output + .styles + .iter() + .any(|style| matches!(style, ExtractStyleValue::Dynamic(_))) + ); + // When + let result = super::checked_declarations(&output); + // Then + assert!( + result.is_err(), + "runtime atoms must not be discarded by the static oracle" + ); + Ok(()) +} + +#[rstest] +#[case("runtime")] +#[case("(runtime as string)")] +#[serial] +fn empty_known_mixin_when_combined_with_unknown_class_preserves_the_class( + #[case] class: &str, +) -> Result<(), Box> { + // Given + let source = format!( + "import {{ css }} from '@devup-ui/react'; export const result = css`${{css({{}})}}${{{class}}}`;" + ); + // When + let output = extract("coverage.tsx", &source, ExtractOption::default())?; + // Then + assert_eq!(output.styles.len(), 0); + assert!(output.code.contains("runtime"), "{}", output.code); + assert!(!output.code.contains("css`"), "{}", output.code); + Ok(()) +} + +#[rstest] +#[case("const rules = { color: props.color }; return css(...rules);")] +#[case( + "const rules = { color: props.color }; expose(rules); return css([rules, { color: 'blue' }]);" +)] +#[case( + "const rules = { color: props.color }; return
;" +)] +#[serial] +fn local_runtime_rules_when_shape_cannot_be_composed_report_the_original_location( + #[case] body: &str, +) -> Result<(), Box> { + // Given + let source = + format!("import {{ css }} from '@emotion/react'; export function App(props) {{ {body} }}"); + // When + let error = extract("coverage-local.tsx", &source, super::option()) + .err() + .ok_or("unknown local composition unexpectedly compiled")? + .to_string(); + // Then + assert!(error.starts_with("coverage-local.tsx:1:"), "{error}"); + assert!(error.contains("build time"), "{error}"); + Ok(()) +} + +#[test] +#[serial] +fn deep_namespace_when_given_css_prop_does_not_inherit_one_level_definition() +-> Result<(), Box> { + // Given + let source = "/** @jsxImportSource @emotion/react */ import * as UI from './base'; export const result = ;"; + let resolver = |specifier: &str, _: &str| { + (specifier == "./base").then(|| ResolvedModule { + path: "/w27/base.tsx".into(), + code: "import styled from '@emotion/styled'; export const Child = styled.div`color: red;`;".into(), + }) + }; + // When + let output = extract_with_modules("/w27/entry.tsx", source, super::option(), false, &resolver)?; + // Then + assert!(output.code.contains("UI.Nested.Child"), "{}", output.code); + assert_eq!( + super::checked_declarations(&output)?, + [("color".into(), "blue".into())] + ); + Ok(()) +} + +#[rstest] +#[case("getUI().Child")] +#[case("UI.Nested.Child")] +#[case("UI[key]")] +#[serial] +fn unknown_namespace_selector_when_not_an_exact_member_reports_error( + #[case] selector: &str, +) -> Result<(), Box> { + // Given + let source = format!( + "import styled from '@emotion/styled'; import * as UI from './base'; export const result = styled.div`${{{selector}}} {{ color: blue; }}`;" + ); + let resolver = |specifier: &str, _: &str| { + (specifier == "./base").then(|| ResolvedModule { + path: "/w27/base.tsx".into(), + code: "import styled from '@emotion/styled'; export const Child = styled.div`color: red;`;".into(), + }) + }; + // When + let error = extract_with_modules("/w27/entry.tsx", &source, super::option(), false, &resolver) + .err() + .ok_or("unknown selector unexpectedly compiled")? + .to_string(); + // Then + assert!(error.starts_with("/w27/entry.tsx:1:"), "{error}"); + assert!(error.contains(selector), "{error}"); + assert!(error.contains("at build time"), "{error}"); + Ok(()) +} diff --git a/libs/extractor/src/extractor/w27_props_rule_choices_regressions.rs b/libs/extractor/src/extractor/w27_props_rule_choices_regressions.rs new file mode 100644 index 000000000..875b5be35 --- /dev/null +++ b/libs/extractor/src/extractor/w27_props_rule_choices_regressions.rs @@ -0,0 +1,249 @@ +use super::super::w27_props_rule_choices_tests::support::{active, declarations}; +use rstest::rstest; + +#[rstest] +#[case( + "function make(undefined) { return BODY; }", + "(p.on ? { color: 'red' } : undefined) ?? { color: 'blue' }" +)] +#[case( + "const undefined = value; const Choice = BODY;", + "(p.on ? { color: 'red' } : undefined) ?? { color: 'blue' }" +)] +#[case( + "function make(undefined = value) { return BODY; }", + "p.on ? undefined : { color: 'red' }" +)] +#[case( + "function make(undefined) { return BODY; }", + "undefined || { color: 'blue' }" +)] +#[case( + "function make(undefined) { return BODY; }", + "undefined ?? { color: 'blue' }" +)] +#[case("function make(undefined) { return BODY; }", "p.on && undefined")] +#[case( + "const red = css({ color: 'red' }); function make(undefined) { return BODY; }", + "p.on ? red : undefined" +)] +#[serial_test::serial] +fn bound_undefined_when_used_as_unknown_rule_shape_reports_original_callback_location( + #[case] scope: &str, + #[case] choice: &str, +) -> Result<(), Box> { + // Given: the spelling is a real parameter, capture or defaulted binding. + for body in [ + format!("styled.div(p => {choice})"), + format!("styled.div`${{p => {choice}}}`"), + ] { + let source = format!( + "import {{ styled, css }} from '@devup-ui/react';\n{}", + scope.replace("BODY", &body) + ); + let callback_start = source.find("p =>").ok_or("missing callback source")?; + let line_start = source.find('\n').ok_or("missing callback line")? + 1; + let location = format!("shadowed-choice.tsx:2:{}:", callback_start - line_start + 1); + // When + let error = crate::extract( + "shadowed-choice.tsx", + &source, + crate::ExtractOption::default(), + ) + .err() + .unwrap_or_else(|| panic!("bound unknown rule shape compiled: {source}")) + .to_string(); + // Then: the diagnostic still names the original source callback, not synthetic code. + assert!(error.contains(&location), "{error}"); + assert!(error.contains("cannot use `"), "{error}"); + assert!(error.contains("undefined"), "{error}"); + assert!( + error.contains("p.on") || choice.starts_with("undefined"), + "{error}" + ); + } + Ok(()) +} + +#[rstest] +#[case("undefined")] +#[case("void 0")] +#[case("null")] +#[serial_test::serial] +fn global_nullish_when_rule_branch_is_absent_keeps_blue_fallback(#[case] sentinel: &str) { + // Given + let choice = format!("(p.on ? {{ color: 'red' }} : {sentinel}) ?? {{ color: 'blue' }}"); + for body in [ + format!("styled.div(p => {choice})"), + format!("styled.div`${{p => {choice}}}`"), + ] { + // When + let output = compile(&format!("const Choice = {body};")); + // Then + assert_eq!( + declarations(&active(&output, "{ on: false }")), + [("color".into(), "blue".into())] + ); + } +} + +#[test] +#[serial_test::serial] +fn bound_undefined_when_scalar_css_value_remains_a_css_variable() { + // Given + let source = "function make(undefined) { return styled.div(p => ({ color: undefined })); }"; + // When + let output = compile(source); + // Then + assert!(output.code.contains("undefined")); + assert!(output.code.contains("--")); + assert!(output.styles.iter().any(|style| matches!(style, crate::ExtractStyleValue::Dynamic(value) if value.identifier().contains("undefined")))); +} + +#[test] +#[serial_test::serial] +fn bound_undefined_when_unreachable_rule_branch_is_skipped() { + // Given + for choice in [ + "({ color: 'red' }) ?? undefined", + "({ color: 'red' }) || undefined", + "(false && undefined) || { color: 'red' }", + ] { + // When + for body in [ + format!("styled.div(p => {choice})"), + format!("styled.div`${{p => {choice}}}`"), + ] { + let output = compile(&format!("function make(undefined) {{ return {body}; }}")); + // Then + assert!(output.styles.iter().any(|style| matches!(style, crate::ExtractStyleValue::Static(value) if value.property() == "color" && value.value() == "red"))); + } + } +} + +fn compile(rules: &str) -> crate::ExtractOutput { + let source = format!("import {{ styled }} from '@devup-ui/react'; {rules}"); + crate::extract( + "w27-choices-active.tsx", + &source, + crate::ExtractOption { + single_css: true, + ..crate::ExtractOption::default() + }, + ) + .unwrap_or_else(|error| panic!("{error}")) +} + +#[test] +#[serial_test::serial] +fn short_circuit_choices_preserve_active_atoms_when_props_are_truthy_falsy_or_nullish() { + // Given: a prior atom and written choices with distinct falsy/nullish behavior. + for (choice, off, nullish) in [ + ( + "(p.on && { color: 'red' }) || { color: 'blue' }", + "blue", + "blue", + ), + ( + "(p.on ? { color: 'red' } : null) ?? { color: 'blue' }", + "blue", + "blue", + ), + ( + "(p.on && { color: 'red' }) ?? { color: 'blue' }", + "green", + "blue", + ), + ("(p.on ? {} : false) ?? { color: 'blue' }", "green", "green"), + ( + "(p.on ? {} : undefined) || { color: 'blue' }", + "blue", + "blue", + ), + ] { + let rules = format!("const Choice = styled('div')({{ color: 'green' }}, p => {choice});"); + // When: the generated class expression runs with each props value. + let output = compile(&rules); + for (on, expected) in [ + ("true", "red"), + ("false", off), + ("0", off), + ("''", off), + ("null", nullish), + ("undefined", nullish), + ] { + let expected = if choice.contains("? {}") && on == "true" { + "green" + } else { + expected + }; + // Then: only the selected atom is active, not every emitted branch. + assert_eq!( + declarations(&active(&output, &format!("{{ on: {on} }}"))), + [("color".into(), expected.into())], + "{choice}, {on}" + ); + } + } +} + +#[test] +#[serial_test::serial] +fn nested_choices_keep_prior_properties_when_selected_object_omits_them() { + // Given: recursive OR/nullish choices with a defaulted parameter. + let rules = "const Choice = styled('div')({ color: 'green', padding: '9px' }, ({ on = true, alt }) => ((on && { color: 'red' }) || (alt ? { padding: '4px' } : null)) ?? { color: 'blue' });"; + let output = compile(rules); + // When / Then: the whole-object choice overlays only its selected properties. + for (props, color, padding) in [ + ("{}", "red", "9px"), + ("{ on: false, alt: true }", "green", "4px"), + ("{ on: false, alt: false }", "blue", "9px"), + ] { + assert_eq!( + declarations(&active(&output, props)), + [ + ("color".into(), color.into()), + ("padding".into(), padding.into()) + ] + ); + } +} + +#[test] +#[serial_test::serial] +fn unknown_runtime_rule_shape_stays_located_when_short_circuit_chooses_it() { + for choice in [ + "p.on || { color: 'blue' }", + "p.rules ?? { color: 'blue' }", + "(p.on && p.rules) || { color: 'blue' }", + ] { + let source = format!( + "import {{ styled }} from '@devup-ui/react'; const Choice = styled.div(p => {choice});" + ); + let error = crate::extract( + "unknown-choice.tsx", + &source, + crate::ExtractOption::default(), + ) + .err() + .unwrap_or_else(|| panic!("unknown shape compiled")); + assert!(error.to_string().contains("unknown-choice.tsx:1:")); + assert!(error.to_string().contains("cannot use")); + } +} + +#[test] +#[serial_test::serial] +fn unreachable_rule_source_is_skipped_when_written_object_short_circuits() { + for choice in [ + "({ color: 'red' }) || unknownRules()", + "({ color: 'red' }) ?? unknownRules()", + "(false && unknownRules()) || { color: 'red' }", + ] { + let output = compile(&format!("const Choice = styled.div(p => {choice});")); + assert_eq!( + declarations(&active(&output, "{}")), + [("color".into(), "red".into())] + ); + } +} diff --git a/libs/extractor/src/extractor/w27_props_rule_choices_tests.rs b/libs/extractor/src/extractor/w27_props_rule_choices_tests.rs new file mode 100644 index 000000000..ca56457fb --- /dev/null +++ b/libs/extractor/src/extractor/w27_props_rule_choices_tests.rs @@ -0,0 +1,288 @@ +use crate::extract_style::ExtractStyleProperty; +use crate::{ExtractOption, ExtractOutput, ExtractStyleValue, extract}; +use oxc_ast::ast::{ConditionalExpression, Expression}; +use oxc_ast_visit::{Visit, walk}; + +#[path = "w27_props_rule_choices_regressions.rs"] +mod regressions; + +#[path = "styled_choice_test_support.rs"] +pub(super) mod support; + +#[derive(Default)] +struct ClassChoices(Vec<(String, String)>); + +impl<'a> Visit<'a> for ClassChoices { + fn visit_conditional_expression(&mut self, expression: &ConditionalExpression<'a>) { + if let (Expression::StringLiteral(yes), Expression::StringLiteral(no)) = + (&expression.consequent, &expression.alternate) + { + self.0.push((yes.value.to_string(), no.value.to_string())); + } + walk::walk_conditional_expression(self, expression); + } +} + +fn class_choices(output: &ExtractOutput) -> Vec<(String, String)> { + let allocator = oxc_allocator::Allocator::default(); + let parsed = + oxc_parser::Parser::new(&allocator, &output.code, oxc_span::SourceType::tsx()).parse(); + assert_eq!(parsed.diagnostics.len(), 0); + let mut choices = ClassChoices::default(); + choices.visit_program(&parsed.program); + choices.0 +} + +fn class_for(output: &ExtractOutput, property: &str, value: &str) -> String { + let style = output + .styles + .iter() + .find_map(|style| match style { + ExtractStyleValue::Static(style) + if style.property() == property && style.value() == value => + { + Some(style) + } + ExtractStyleValue::Static(_) + | ExtractStyleValue::Typography(_) + | ExtractStyleValue::Dynamic(_) + | ExtractStyleValue::Css(_) + | ExtractStyleValue::Import(_) + | ExtractStyleValue::FontFace(_) + | ExtractStyleValue::Keyframes(_) => None, + }) + .unwrap_or_else(|| panic!("missing {property}: {value}")); + style + .extract(Some("w27-props-rule-choices.tsx")) + .to_string() +} + +fn compile(rules: &str) -> ExtractOutput { + let source = + format!("import {{ styled }} from '@devup-ui/react'; {rules} export {{ Choice }};"); + extract( + "w27-props-rule-choices.tsx", + &source, + ExtractOption::default(), + ) + .unwrap_or_else(|error| panic!("{error}")) +} + +fn values(output: &ExtractOutput, property: &str) -> Vec { + let mut values: Vec<_> = output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Static(style) if style.property() == property => { + Some(style.value().to_string()) + } + _ => None, + }) + .collect(); + values.sort(); + values +} + +#[test] +#[serial_test::serial] +fn base_atom_survives_when_logical_branch_is_missing() { + let rules = "const Base = styled('div')({ color: 'red' }); const Choice = styled(Base)(p => p.on && { color: 'blue' });"; + let output = compile(rules); + assert_eq!(values(&output, "color"), ["blue", "red"]); + assert!(output.code.contains("p.on")); + assert!(!output.code.contains("styled(")); + assert_eq!( + class_choices(&output), + [( + class_for(&output, "color", "blue"), + class_for(&output, "color", "red") + )] + ); + assert!(output.code.contains("style={style}")); +} + +#[test] +#[serial_test::serial] +fn distinct_keys_keep_base_when_ternary_branch_omits_them() { + let rules = "const Choice = styled('div')({ color: 'red', backgroundColor: 'black' }, p => p.on ? { color: 'blue' } : { backgroundColor: 'white' });"; + let output = compile(rules); + assert_eq!(values(&output, "color"), ["blue", "red"]); + assert_eq!(values(&output, "background-color"), ["black", "white"]); + let choices = class_choices(&output); + assert!(choices.contains(&( + class_for(&output, "color", "blue"), + class_for(&output, "color", "red") + ))); + assert!(choices.contains(&( + class_for(&output, "background-color", "black"), + class_for(&output, "background-color", "white") + ))); + assert!(output.code.contains("style={style}")); +} + +#[test] +#[serial_test::serial] +fn nested_selector_keeps_base_when_logical_branch_is_missing() { + let rules = "const Base = styled('div')({ '&:hover': { color: 'red', opacity: 0.5 } }); const Choice = styled(Base)(p => p.on && { '&:hover': { color: 'blue' } });"; + let output = compile(rules); + assert_eq!(values(&output, "color"), ["blue", "red"]); + assert_eq!(values(&output, "opacity"), [".5"]); + assert_eq!( + class_choices(&output), + [( + class_for(&output, "color", "blue"), + class_for(&output, "color", "red") + )] + ); + assert!(output.styles.iter().all(|value| match value { + ExtractStyleValue::Static(style) => style.selector().is_some(), + _ => false, + })); +} + +#[test] +#[serial_test::serial] +fn dynamic_leaf_uses_variable_when_rule_branch_is_selected() { + let rules = "const Base = styled('div')({ width: '12px' }); const Choice = styled(Base)(p => p.on && { width: p.width });"; + let output = compile(rules); + assert_eq!(values(&output, "width"), ["12px"]); + assert!(output.styles.iter().any( + |style| matches!(style, ExtractStyleValue::Dynamic(style) if style.property() == "width") + )); + assert!(output.code.contains("p.width")); + assert!(output.code.contains("p.on")); + assert!(output.code.contains("--")); + assert!(!output.code.contains("styled(")); + assert!( + class_choices(&output) + .iter() + .any(|(_, no)| *no == class_for(&output, "width", "12px")) + ); +} + +#[test] +#[serial_test::serial] +fn missing_branches_keep_base_when_ternary_returns_no_rules() { + for absent in ["null", "false", "undefined", "{}"] { + let rules = format!( + "const Choice = styled('div')({{ color: 'red' }}, p => p.on ? {{ color: 'blue' }} : {absent});" + ); + let output = compile(&rules); + assert_eq!( + class_choices(&output), + [( + class_for(&output, "color", "blue"), + class_for(&output, "color", "red") + )] + ); + } +} + +#[test] +#[serial_test::serial] +fn later_atom_wins_when_it_follows_conditional_rules() { + let rules = "const Choice = styled('div')({ color: 'red' }, p => p.on && { color: 'blue' }, { color: 'green' });"; + let output = compile(rules); + assert_eq!(values(&output, "color"), ["green"]); + assert_eq!(class_choices(&output), vec![]); +} + +#[test] +#[serial_test::serial] +fn theme_leaf_is_static_when_conditional_rules_read_theme() { + let rules = "const Choice = styled('div')(p => p.on ? { color: p.theme.colors.primary } : { color: 'red' });"; + let output = compile(rules); + assert_eq!(values(&output, "color"), ["red", "var(--colors-primary)"]); + assert!( + !output + .styles + .iter() + .any(|style| matches!(style, ExtractStyleValue::Dynamic(_))) + ); +} + +#[test] +#[serial_test::serial] +fn pure_comparison_is_supported_when_rule_choice_reads_props() { + let rules = "const Choice = styled('div')(({ on, count }) => on && count > 2 ? { color: 'blue' } : { color: 'red' });"; + let output = compile(rules); + assert_eq!(values(&output, "color"), ["blue", "red"]); + assert!(output.code.contains("count > 2")); +} + +#[test] +#[serial_test::serial] +fn unknown_shapes_are_rejected_when_function_cannot_name_rules() { + for rules in [ + "p => p.on ? p.styles : { color: 'red' }", + "p => p.on && { ...p.styles }", + "p => p.on && { [p.key]: 'red' }", + "p => check(p) ? { color: 'blue' } : { color: 'red' }", + ] { + let source = format!( + "import {{ styled }} from '@devup-ui/react'; const Choice = styled('div')({rules});" + ); + let Err(error) = extract("w27-unknown.tsx", &source, ExtractOption::default()) else { + panic!("unknowable rules must fail at build time"); + }; + assert!(error.to_string().contains("w27-unknown.tsx:")); + assert!(error.to_string().contains("cannot use")); + } +} + +#[test] +#[serial_test::serial] +fn invalid_condition_prop_is_withheld_when_rules_are_lowered() { + let rules = "const Choice = styled('div')(p => p.on && { color: p.title });"; + let output = compile(rules); + assert!( + output.code.contains("\"on\": __devupOmit"), + "{}", + output.code + ); + assert!(!output.code.contains("\"title\": __devupOmit")); + assert!(output.code.contains("p.title")); +} + +#[test] +#[serial_test::serial] +fn invalid_value_prop_is_withheld_when_whole_rule_function_is_lowered() { + let rules = "const Choice = styled('div')(p => ({ color: p.tone, opacity: p.tabIndex, cursor: p.onClick }));"; + let output = compile(rules); + assert!(output.code.contains("\"tone\": __devupOmit")); + assert!(!output.code.contains("\"tabIndex\": __devupOmit")); + assert!(!output.code.contains("\"onClick\": __devupOmit")); + assert!(output.code.contains("p.tone")); +} + +#[test] +#[serial_test::serial] +fn inherited_reads_keep_condition_prop_when_extension_is_used() { + let rules = "const Base = styled('div')(p => p.on ? { color: 'blue' } : { color: 'red' }); const Choice = styled(Base)({ opacity: 0.5 }); export const Example = ;"; + let output = compile(rules); + assert_eq!( + output.code.matches("\"on\": __devupOmit").count(), + 2, + "{}", + output.code + ); + assert!(output.code.contains("on={enabled}")); + assert!(!output.code.contains("\"title\": __devupOmit")); +} + +#[test] +#[serial_test::serial] +fn condition_flag_is_forwarded_when_explicit_policy_allows_it() { + let rules = "const Choice = styled('div').withConfig({ shouldForwardProp: name => name === 'on' })(p => p.on && { color: 'blue' });"; + let output = compile(rules); + assert!(!output.code.contains("\"on\": __devupOmit")); +} + +#[test] +#[serial_test::serial] +fn condition_flag_is_forwarded_when_styled_base_is_custom_component() { + let rules = "const Custom = props =>
; const Choice = styled(Custom)(p => p.on && { color: 'blue' });"; + let output = compile(rules); + assert!(!output.code.contains("\"on\": __devupOmit")); + assert!(output.code.contains("DevupAs = Custom")); +} diff --git a/libs/extractor/src/extractor/w27_styled_choice_coverage.rs b/libs/extractor/src/extractor/w27_styled_choice_coverage.rs new file mode 100644 index 000000000..3930cc27f --- /dev/null +++ b/libs/extractor/src/extractor/w27_styled_choice_coverage.rs @@ -0,0 +1,237 @@ +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 imports = rustc_hash::FxHashMap::default(); + let inline_css = rustc_hash::FxHashMap::default(); + // When + let result = normalize( + &ast, + &expression, + &StyledBindings { + imports: &imports, + 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(scoping); + let imports = rustc_hash::FxHashMap::default(); + let inline_css = rustc_hash::FxHashMap::default(); + normalize( + ast, + &statement.expression, + &StyledBindings { + imports: &imports, + values: &values, + inline_css: &inline_css, + }, + ) + .map_err(|_| "readable choices did not normalize".into()) +} + +fn assert_props_lowering_contract(expression: &Expression<'_>) { + assert!( + matches!( + expression, + Expression::ObjectExpression(_) + | Expression::NullLiteral(_) + | Expression::ConditionalExpression(_) + ), + "props lowering received unnormalized rules: {expression:?}" + ); + if let Expression::ConditionalExpression(branch) = expression { + assert!(crate::utils::is_pure(&branch.test)); + assert_props_lowering_contract(&branch.consequent); + assert_props_lowering_contract(&branch.alternate); + } +} + +#[rstest] +#[case("unknownRules")] +#[case("p.on ? {color: 'red'} : unknownRules")] +#[case("check(p) ? {color: 'red'} : null")] +fn props_lowering_oracle_when_rules_are_unnormalized_rejects( + #[case] source: &str, +) -> Result<(), Box> { + // Given + let allocator = oxc_allocator::Allocator::default(); + let source = format!("({source});"); + let parsed = oxc_parser::Parser::new(&allocator, &source, oxc_span::SourceType::tsx()).parse(); + assert_eq!(parsed.diagnostics.len(), 0); + let statement = parsed + .program + .body + .first() + .ok_or("expected expression statement")?; + assert!(matches!( + statement, + oxc_ast::ast::Statement::ExpressionStatement(_) + )); + if let oxc_ast::ast::Statement::ExpressionStatement(statement) = statement { + let expression = crate::utils::unwrap_syntax_only(&statement.expression); + // When + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + assert_props_lowering_contract(expression); + })); + // Then + assert!( + result.is_err(), + "unsupported leaves and impure tests must fail" + ); + } + Ok(()) +} + +#[rstest] +#[case("")] +#[case("const rules = {};")] +fn normalization_oracle_when_expression_statement_is_missing_rejects(#[case] source: &str) { + // Given + let allocator = oxc_allocator::Allocator::default(); + let ast = AstBuilder::new(&allocator); + // When + let result = normalize_source(&ast, source); + // Then + assert!(result.is_err(), "missing expression statement must fail"); +} + +#[rstest] +#[case("p.on && { color: 'red' }")] +#[case("p.on ? { color: 'red' } : undefined")] +#[case("p.on ? false : true")] +#[case("(p.on && { color: 'red' }) ?? { color: 'blue' }")] +#[case("0 && unknownRules()")] +#[case("'' || { color: 'blue' }")] +fn normalized_rules_when_sent_to_props_lowering_have_only_pure_branches_and_object_or_null_leaves( + #[case] source: &str, +) -> Result<(), Box> { + // Given + let allocator = oxc_allocator::Allocator::default(); + let ast = AstBuilder::new(&allocator); + let source = format!("({source});"); + // When + let normalized = normalize_source(&ast, &source)?; + // Then + assert_props_lowering_contract(&normalized); + Ok(()) +} + +#[rstest] +#[case("0 && unknownRules()", "green")] +#[case("1 && { color: 'red' }", "red")] +#[case("'' || { color: 'blue' }", "blue")] +#[case("'yes' && { color: 'red' }", "red")] +#[case("0 ?? unknownRules()", "green")] +#[case("'' ?? unknownRules()", "green")] +#[case("({ color: 'red' }) && { color: 'blue' }", "blue")] +fn normalized_literal_when_selected_produces_expected_rule_value( + #[case] source: &str, + #[case] expected: &str, +) -> Result<(), Box> { + // Given: parser input is not pre-folded by the public extraction pipeline. + let allocator = oxc_allocator::Allocator::default(); + let ast = AstBuilder::new(&allocator); + let source = format!("({source});"); + // When + let normalized = normalize_source(&ast, &source)?; + let code = crate::utils::expression_to_code(&normalized); + let code = crate::css_utils::rm_last_semi_colon(&code); + let code = format!("({code})?.color ?? 'green'"); + let mut context = boa_engine::Context::default(); + let result = context + .eval(boa_engine::Source::from_bytes(&code)) + .map_err(|error| format!("generated expression failed to evaluate: {error}"))? + .to_string(&mut context) + .map_err(|error| format!("evaluated value failed string conversion: {error}"))? + .to_std_string_escaped(); + // Then + assert_eq!(result, expected); + Ok(()) +} + +#[test] +fn numeric_nan_when_short_circuited_does_not_read_unknown_rules() +-> Result<(), Box> { + // Given: NaN is a numeric AST value even though source spells it as a name. + let allocator = oxc_allocator::Allocator::default(); + let ast = AstBuilder::new(&allocator); + let expression = Expression::new_logical_expression( + SPAN, + Expression::new_numeric_literal( + SPAN, + f64::NAN, + None, + oxc_syntax::number::NumberBase::Decimal, + &ast, + ), + LogicalOperator::And, + Expression::new_identifier(SPAN, "unknown", &ast), + &ast, + ); + // When + let values = crate::style_values::StyleValues::default(); + let imports = rustc_hash::FxHashMap::default(); + let inline_css = rustc_hash::FxHashMap::default(); + let normalized = normalize( + &ast, + &expression, + &StyledBindings { + imports: &imports, + values: &values, + inline_css: &inline_css, + }, + ) + .map_err(|_| "falsy NaN did not normalize")?; + // Then + assert!(matches!(normalized, Expression::NullLiteral(_))); + Ok(()) +} diff --git a/libs/extractor/src/extractor/w27_styled_composition_coverage.rs b/libs/extractor/src/extractor/w27_styled_composition_coverage.rs new file mode 100644 index 000000000..ac4c98328 --- /dev/null +++ b/libs/extractor/src/extractor/w27_styled_composition_coverage.rs @@ -0,0 +1,114 @@ +use super::super::super::w27_props_rule_choices_tests::support::{active, declarations}; +use crate::{ExtractOption, extract}; +use rstest::rstest; +use serial_test::serial; + +#[test] +#[serial] +fn unreadable_selector_when_interpolation_follows_literal_prefix_is_located() +-> Result<(), Box> { + // Given + let source = "import { styled } from '@devup-ui/react'; export const Choice = styled.div`&${runtime} { color: red; }`;"; + // When + let error = extract("unplaced-selector.tsx", source, ExtractOption::default()) + .err() + .ok_or("unknown selector unexpectedly compiled")? + .to_string(); + // Then + assert!(error.starts_with("unplaced-selector.tsx:1:"), "{error}"); + assert!(error.contains("runtime"), "{error}"); + Ok(()) +} + +#[rstest] +#[case("0 && unknownRules()", "green")] +#[case("1 && { color: 'red' }", "red")] +#[case("'' || { color: 'blue' }", "blue")] +#[case("'yes' && { color: 'red' }", "red")] +#[case("0 ?? unknownRules()", "green")] +#[case("'' ?? unknownRules()", "green")] +#[case("({ color: 'red' }) && { color: 'blue' }", "blue")] +#[serial] +fn literal_choices_when_short_circuited_select_only_reachable_rules( + #[case] choice: &str, + #[case] expected: &str, +) -> Result<(), Box> { + // Given + let source = format!( + "import {{ styled }} from '@devup-ui/react'; export const Choice = styled.div({{ color: 'green' }}, p => {choice});" + ); + // When + let output = extract( + "literal-choice.tsx", + &source, + ExtractOption { + single_css: true, + ..ExtractOption::default() + }, + )?; + // Then + assert_eq!( + declarations(&active(&output, "{}")), + [("color".into(), expected.into())] + ); + Ok(()) +} + +#[test] +#[serial] +fn later_mixin_when_prior_context_has_closed_is_not_nested_in_prior_selector() +-> Result<(), Box> { + // Given + let source = "import { styled, css } from '@devup-ui/react'; const mixin = css({ color: 'red' }); export const Choice = styled.div`&:hover { color: blue; } ${mixin};`;"; + // When + let output = extract( + "closed-context.tsx", + source, + ExtractOption { + single_css: true, + ..ExtractOption::default() + }, + )?; + // Then + let mut keys = active(&output, "{}") + .into_iter() + .map(|value| match value { + crate::ExtractStyleValue::Static(style) => ( + style.value().to_string(), + style.selector().map(ToString::to_string), + ), + other => panic!("expected static atom, got {other:?}"), + }) + .collect::>(); + keys.sort(); + assert_eq!( + keys, + [ + ("blue".into(), Some("&:hover".into())), + ("red".into(), None) + ] + ); + Ok(()) +} + +#[rstest] +#[case("p => { notify(p); return 'external'; }")] +#[case("function(p) { notify(p); }")] +#[serial] +fn unreadable_callback_when_nested_reports_interpolation_location( + #[case] callback: &str, +) -> Result<(), Box> { + // Given + let source = format!( + "import {{ styled }} from '@devup-ui/react'; export const Choice = styled.div`&:hover {{ ${{{callback}}}; }}`;" + ); + // When + let error = extract("callback-boundary.tsx", &source, ExtractOption::default()) + .err() + .ok_or("unreadable nested callback unexpectedly compiled")? + .to_string(); + // Then + assert!(error.starts_with("callback-boundary.tsx:1:"), "{error}"); + assert!(error.contains("nested mixin"), "{error}"); + Ok(()) +} diff --git a/libs/extractor/src/extractor/w27_styled_context_coverage.rs b/libs/extractor/src/extractor/w27_styled_context_coverage.rs new file mode 100644 index 000000000..d1e874287 --- /dev/null +++ b/libs/extractor/src/extractor/w27_styled_context_coverage.rs @@ -0,0 +1,157 @@ +use super::*; +use css::style_selector::{AtRule, AtRuleKind}; +use rstest::rstest; + +#[test] +fn at_chain_when_nested_retains_outer_rules_and_child_selector() +-> Result<(), Box> { + // Given + let child = StyleSelector::At { + outer: vec![AtRule { + kind: AtRuleKind::Supports, + query: "(display: grid)".into(), + }], + kind: AtRuleKind::Media, + query: "print".into(), + selector: Some("&:focus".into()), + file: None, + }; + let parent = StyleSelector::Selector("&:hover".into()); + // When + let result = nest(Some(&parent), Some(&child)) + .map_err(|_| "failed to nest readable selector")? + .ok_or("nested selector was missing")?; + // Then + assert_eq!( + result.to_string(), + "@supports(display:grid) @media print &:hover:focus" + ); + Ok(()) +} + +#[rstest] +#[case(false)] +#[case(true)] +fn conflicting_media_when_contextualized_drops_static_or_dynamic_atom( + #[case] dynamic: bool, +) -> Result<(), Box> { + // Given + let selector = StyleSelector::At { + kind: AtRuleKind::Media, + query: "screen".into(), + selector: None, + outer: vec![], + file: None, + }; + let value = if dynamic { + ExtractStyleValue::Dynamic(ExtractDynamicStyle::new( + "width", + 0, + "rest.width", + Some(selector), + )) + } else { + ExtractStyleValue::Static(ExtractStaticStyle::new("color", "red", 0, Some(selector))) + }; + // When + let result = contextualize( + vec![ExtractStyleProp::Static(value)], + &["@media print {".into()], + ) + .map_err(|_| "never matching styles must be empty, not opaque")?; + // Then + assert!( + matches!(result.as_slice(), [ExtractStyleProp::StaticArray(values)] if values.is_empty()) + ); + Ok(()) +} + +#[rstest] +#[case(false)] +#[case(true)] +fn global_atom_when_contextualized_is_rejected_as_opaque(#[case] dynamic: bool) { + // Given + let selector = StyleSelector::Global("body".into(), "global.tsx".into()); + let value = if dynamic { + ExtractStyleValue::Dynamic(ExtractDynamicStyle::new( + "width", + 0, + "rest.width", + Some(selector), + )) + } else { + ExtractStyleValue::Static(ExtractStaticStyle::new("color", "red", 0, Some(selector))) + }; + // When + let result = contextualize(vec![ExtractStyleProp::Static(value)], &["&:hover {".into()]); + // Then + assert!(matches!(result, Err(ContextError::Opaque))); +} + +#[test] +fn dynamic_array_when_contextualized_preserves_important_order_layer_and_value() +-> Result<(), Box> { + // Given + let mut value = ExtractStyleValue::Dynamic(ExtractDynamicStyle::new( + "width", + 2, + "rest.width !important", + None, + )); + value.set_style_order(7); + let props = vec![ExtractStyleProp::StaticArray(vec![ + ExtractStyleProp::Static(value), + ])]; + // When + let result = contextualize(props, &["@layer outer {".into(), "&:hover {".into()]) + .map_err(|_| "failed to contextualize readable dynamic array")?; + // Then + let [ExtractStyleProp::StaticArray(array)] = result.as_slice() else { + panic!("array must be preserved") + }; + let [ExtractStyleProp::Static(ExtractStyleValue::Dynamic(style))] = array.as_slice() else { + panic!("dynamic atom must be preserved") + }; + assert_eq!( + ( + style.identifier(), + style.important(), + style.style_order(), + style.level(), + style.layer() + ), + ("rest.width", true, Some(7), 2, Some("outer")) + ); + assert_eq!( + style.selector().map(ToString::to_string), + Some("&:hover".into()) + ); + Ok(()) +} + +#[test] +fn opaque_array_entry_when_contextualized_fails_instead_of_filtering_it() { + // Given + let props = vec![ExtractStyleProp::StaticArray(vec![ + ExtractStyleProp::Static(ExtractStyleValue::Typography("body".into())), + ])]; + // When + let result = contextualize(props, &["&:hover {".into()]); + // Then + assert!(matches!(result, Err(ContextError::Opaque))); +} + +#[test] +fn impossible_parent_when_contextualized_emits_no_atoms() -> Result<(), Box> +{ + // Given + let props = vec![ExtractStyleProp::Static(ExtractStyleValue::Static( + ExtractStaticStyle::new("color", "red", 0, None), + ))]; + // When + let result = contextualize(props, &["@media print {".into(), "@media screen {".into()]) + .map_err(|_| "impossible context must be empty, not an error")?; + // Then + assert_eq!(result.len(), 0); + Ok(()) +} diff --git a/libs/extractor/src/extractor/w27_styled_known_mixins_regressions.rs b/libs/extractor/src/extractor/w27_styled_known_mixins_regressions.rs new file mode 100644 index 000000000..81fb0e3e5 --- /dev/null +++ b/libs/extractor/src/extractor/w27_styled_known_mixins_regressions.rs @@ -0,0 +1,257 @@ +use super::super::w27_props_rule_choices_tests::support::{active, declarations}; +use crate::{ExtractOption, ExtractStyleValue}; + +fn compile(template: &str) -> crate::ExtractOutput { + let source = format!( + "import {{ styled, css }} from '@devup-ui/react'; const mixin = css({{ color: 'red', padding: '4px' }}); export const Choice = styled.div`{template}`;" + ); + crate::extract( + "context-mixin.tsx", + &source, + ExtractOption { + single_css: true, + ..ExtractOption::default() + }, + ) + .unwrap_or_else(|error| panic!("{error}")) +} + +#[test] +#[serial_test::serial] +fn known_mixin_inherits_context_when_surrounded_by_layer_selector_and_media() { + // Given: the same atom competes inside each enclosing context. + for (open, close, layer, selector) in [ + ("@layer base {", "}", Some("base"), None), + ("&:hover {", "}", None, Some(":hover")), + ( + "@media print { &:hover { @layer base {", + "} } }", + Some("base"), + Some("print"), + ), + ] { + let output = compile(&format!("{open} ${{mixin}}; color: blue; {close}")); + // When: the component's class expression runs. + let selected = active(&output, "{}"); + // Then: red is replaced only in the matching context; padding inherits it. + assert_eq!( + declarations(&selected), + [ + ("color".into(), "blue".into()), + ("padding".into(), "4px".into()) + ] + ); + for value in selected { + let ExtractStyleValue::Static(style) = value else { + panic!("static atom required") + }; + assert_eq!(style.layer(), layer); + match selector { + Some(selector) => assert!( + style + .selector() + .is_some_and(|value| value.to_string().contains(selector)) + ), + None => assert_eq!(style.selector(), None), + } + } + } +} + +#[test] +#[serial_test::serial] +fn conditional_known_mixin_composes_per_property_when_later_color_is_always_present() { + // Given: a known callback mixin followed by an unconditional competing color. + for template in [ + "${p => p.on && mixin}; color: blue;", + "@layer base { &:hover { ${p => p.on && mixin}; color: blue; } }", + ] { + let output = compile(template); + // When / Then: blue always wins, and only the enabled branch supplies padding. + for on in [true, false] { + let mut expected = vec![("color".into(), "blue".into())]; + if on { + expected.push(("padding".into(), "4px".into())); + } + assert_eq!( + declarations(&active(&output, &format!("{{ on: {on} }}"))), + expected + ); + } + } +} + +#[test] +#[serial_test::serial] +fn context_sequence_keeps_prior_atom_when_later_conditional_mixin_is_absent() { + let output = compile( + "@layer base { &:hover { color: blue; ${p => p.on ? mixin : null}; } } color: green;", + ); + for on in [true, false] { + let selected = active(&output, &format!("{{ on: {on} }}")); + let mut expected = [ + ("color".into(), if on { "red" } else { "blue" }.into()), + ("color".into(), "green".into()), + ] + .into_iter() + .chain(on.then(|| ("padding".into(), "4px".into()))) + .collect::>(); + expected.sort(); + assert_eq!(declarations(&selected), expected); + } +} + +#[test] +#[serial_test::serial] +fn unknown_nested_mixin_fails_located_instead_of_applying_global_classes() { + // Given: opaque classes cannot be recontextualized. + for mixin in ["runtime", "p => p.on && runtime"] { + let source = format!( + "import {{ styled }} from '@devup-ui/react'; import {{ runtime }} from './runtime'; export const Choice = styled.div`@layer base {{ &:hover {{ ${{{mixin}}}; color: blue; }} }}`;" + ); + // When: extraction must refuse the unknown nested statement. + let error = crate::extract("unknown-nested.tsx", &source, ExtractOption::default()) + .err() + .unwrap_or_else(|| panic!("unknown nested mixin compiled")); + // Then: the error identifies the source interpolation. + assert!(error.to_string().contains("unknown-nested.tsx:1:")); + assert!(error.to_string().contains("cannot use")); + assert!(error.to_string().contains("runtime")); + } +} + +#[test] +#[serial_test::serial] +fn contextual_callback_preserves_dynamic_lengths_and_theme_bindings_when_enabled() { + // Given: a written callback object inside selector and layer contexts. + let output = compile( + "@layer base { &:hover { ${p => p.on && { padding: p.padding, color: p.theme.colors.primary }}; } }", + ); + // When: only the selected property's CSS-variable expression is evaluated. + let selected = active(&output, "{ on: true, padding: 8 }"); + let padding = selected + .iter() + .find_map(|style| match style { + ExtractStyleValue::Dynamic(style) if style.property() == "padding" => Some(style), + _ => None, + }) + .unwrap_or_else(|| panic!("missing dynamic padding")); + // Then: numeric lengths stay pixels, and theme paths remain static variables. + assert_eq!( + super::super::w27_props_rule_choices_tests::support::evaluate( + padding.identifier(), + "{ padding: 8 }" + ), + "8px" + ); + assert_eq!(padding.layer(), Some("base")); + assert!( + padding + .selector() + .is_some_and(|selector| selector.to_string().contains(":hover")) + ); + assert!(selected.iter().any(|style| matches!(style, ExtractStyleValue::Static(style) if style.value() == "var(--colors-primary)"))); + assert_eq!(active(&output, "{ on: false }"), vec![]); +} + +#[test] +#[serial_test::serial] +fn context_lexer_ignores_data_braces_when_known_mixin_follows_comments_and_quoted_values() { + let output = + compile("@layer base { &:hover { content: '}'; /* } */ ${mixin}; color: blue; } }"); + let selected = active(&output, "{}"); + assert_eq!( + declarations(&selected) + .iter() + .filter(|(property, _)| property == "color") + .cloned() + .collect::>(), + [("color".into(), "blue".into())] + ); + assert!(selected.iter().all(|style| matches!(style, ExtractStyleValue::Static(style) if style.layer() == Some("base") && style.selector().is_some_and(|selector| selector.to_string().contains(":hover"))))); +} + +#[test] +#[serial_test::serial] +fn contextual_composition_preserves_distinct_keys_when_mixin_has_inner_selectors_breakpoints_and_layers() + { + let source = "import { styled, css } from '@devup-ui/react'; const mixin = css({ color: ['red', 'orange'], '&:focus': { color: 'yellow' }, '@layer': { inner: { padding: '4px' } } }); export const Choice = styled.div`@layer outer { @media print { &:hover { ${mixin}; color: blue; &:focus { color: purple; } } } }`;"; + let output = crate::extract( + "nested-key-mixin.tsx", + source, + ExtractOption { + single_css: true, + ..ExtractOption::default() + }, + ) + .unwrap_or_else(|error| panic!("{error}")); + let selected = active(&output, "{}"); + let mut keys: Vec<_> = selected + .iter() + .map(|value| { + let ExtractStyleValue::Static(style) = value else { + panic!("static atom required") + }; + ( + style.property().to_string(), + style.value().to_string(), + style.level(), + style.layer().map(str::to_string), + style.selector().map(ToString::to_string), + ) + }) + .collect(); + keys.sort(); + assert_eq!( + keys, + [ + ( + "color".into(), + "blue".into(), + 0, + Some("outer".into()), + Some("@media print &:hover".into()) + ), + ( + "color".into(), + "orange".into(), + 1, + Some("outer".into()), + Some("@media print &:hover".into()) + ), + ( + "color".into(), + "purple".into(), + 0, + Some("outer".into()), + Some("@media print &:hover:focus".into()) + ), + ( + "padding".into(), + "4px".into(), + 0, + Some("outer.inner".into()), + Some("@media print &:hover".into()) + ), + ] + ); +} + +#[test] +#[serial_test::serial] +fn unknown_written_rule_branches_fail_when_top_level_callback_cannot_be_read_as_styles() { + for mixin in [ + "p => p.on ? { color: 'blue' } : p.rules", + "p => p.on && { ...p.rules }", + "p => check(p) ? { color: 'red' } : { color: 'blue' }", + ] { + let source = format!( + "import {{ styled }} from '@devup-ui/react'; export const Choice = styled.div`${{{mixin}}}; color: green;`;" + ); + let error = crate::extract("unknown-rule-mixin.tsx", &source, ExtractOption::default()) + .err() + .unwrap_or_else(|| panic!("unknown rule shape compiled")); + assert!(error.to_string().contains("unknown-rule-mixin.tsx:1:")); + assert!(error.to_string().contains("cannot use")); + } +} diff --git a/libs/extractor/src/extractor/w27_styled_known_mixins_tests.rs b/libs/extractor/src/extractor/w27_styled_known_mixins_tests.rs new file mode 100644 index 000000000..bf8a4457c --- /dev/null +++ b/libs/extractor/src/extractor/w27_styled_known_mixins_tests.rs @@ -0,0 +1,253 @@ +use crate::{ExtractOption, ExtractOutput, ExtractStyleValue, ImportAlias, ResolvedModule}; +use std::collections::HashMap; + +#[path = "w27_styled_known_mixins_regressions.rs"] +mod regressions; + +fn option() -> ExtractOption { + ExtractOption { + single_css: true, + import_aliases: HashMap::from([ + ("@emotion/react".to_string(), ImportAlias::NamedToNamed), + ( + "@emotion/styled".to_string(), + ImportAlias::DefaultToNamed("styled".to_string()), + ), + ]), + ..ExtractOption::default() + } +} + +fn imported(base: &str, body: &str) -> ExtractOutput { + let module = format!( + "import styled from '@emotion/styled'; import {{ css }} from '@emotion/react'; {base}" + ); + let entry = + format!("import styled from '@emotion/styled'; import {{ Base }} from './base'; {body}"); + let resolver = move |specifier: &str, _: &str| { + (specifier == "./base").then(|| ResolvedModule { + path: "/w27/base.tsx".to_string(), + code: module.clone(), + }) + }; + crate::extract_with_modules("/w27/entry.tsx", &entry, option(), false, &resolver) + .unwrap_or_else(|error| panic!("{error}")) +} + +fn declarations(output: &ExtractOutput) -> Vec<(String, String)> { + checked_declarations(output).unwrap_or_else(|error| panic!("{error}")) +} + +fn checked_declarations(output: &ExtractOutput) -> Result, String> { + let mut styles: Vec<_> = output + .styles + .iter() + .map(|value| match value { + ExtractStyleValue::Static(style) => { + Ok((style.property().to_string(), style.value().to_string())) + } + ExtractStyleValue::Typography(_) + | ExtractStyleValue::Dynamic(_) + | ExtractStyleValue::Css(_) + | ExtractStyleValue::Import(_) + | ExtractStyleValue::FontFace(_) + | ExtractStyleValue::Keyframes(_) => { + Err(format!("expected static atom, got {value:?}")) + } + }) + .collect::>()?; + styles.sort(); + Ok(styles) +} + +#[path = "w27_coverage_tests.rs"] +mod coverage_tests; + +#[test] +#[serial_test::serial] +fn imported_extension_flattens_when_base_composes_known_object_mixin() { + let base = "const mixin = css({ color: 'red', padding: 4 }); export const Base = styled.div`${mixin}; margin: 1px;`;"; + let output = imported(base, "export const Choice = styled(Base)`color: blue;`;"); + assert_eq!( + declarations(&output), + [ + ("color".into(), "blue".into()), + ("margin".into(), "1px".into()), + ("padding".into(), "4px".into()) + ] + ); + assert!(output.code.contains("DevupAs = \"div\"")); + assert!(!output.code.contains("DevupAs = Base")); + assert!(!output.code.contains("mixin")); + assert!(output.code.contains("--")); +} + +#[test] +#[serial_test::serial] +fn template_order_selects_color_when_known_tagged_mixin_is_first_or_last() { + for (template, color) in [ + ("${mixin}; color: blue;", "blue"), + ("color: blue; ${mixin};", "red"), + ] { + let base = format!( + "const mixin = css`color: red; padding: 4px;`; export const Base = styled.div`{template}`;" + ); + let output = imported(&base, "export const Choice = styled(Base)`margin: 1px;`;"); + assert_eq!( + declarations(&output), + [ + ("color".into(), color.into()), + ("margin".into(), "1px".into()), + ("padding".into(), "4px".into()) + ] + ); + } +} + +#[test] +#[serial_test::serial] +fn selectors_and_breakpoints_survive_when_imported_mixin_is_composed() { + let base = "import { css as nativeCss } from '@devup-ui/react'; const mixin = nativeCss({ color: ['red', 'orange'], '&:hover': { color: 'red' } }); export const Base = styled.div`${mixin}; &:hover { color: blue; }`;"; + let output = imported(base, "export const Choice = styled(Base)`margin: 1px;`;"); + assert!(output.styles.iter().any(|value| matches!(value, ExtractStyleValue::Static(style) if style.level() == 1 && style.value() == "orange"))); + let hover: Vec<_> = output + .styles + .iter() + .filter_map(|value| match value { + ExtractStyleValue::Static(style) + if style + .selector() + .is_some_and(|selector| selector.to_string().contains(":hover")) => + { + Some(style.value()) + } + _ => None, + }) + .collect(); + assert_eq!(hover, ["blue"]); + assert!(output.code.contains("DevupAs = \"div\"")); +} + +#[test] +#[serial_test::serial] +fn layered_metadata_keeps_later_atom_when_known_mixins_share_layer() { + use crate::extract_style::extract_static_style::ExtractStaticStyle; + use oxc_ast_visit::VisitMut; + let allocator = oxc_allocator::Allocator::default(); + let source = "import { styled } from '@devup-ui/react'; import { first, second, theme } from './layers'; export const Choice = styled.div`${first}; ${theme}; ${second}; color: black;`;"; + let mut program = oxc_parser::Parser::new(&allocator, source, oxc_span::SourceType::tsx()) + .parse() + .program; + let mut visitor = crate::visit::DevupVisitor::new( + &allocator, + "w27-layers.tsx", + "@devup-ui/react", + vec![], + None, + ); + visitor.import_css( + [ + ("first", "red", "base"), + ("second", "blue", "base"), + ("theme", "green", "theme"), + ] + .into_iter() + .map(|(name, value, layer)| { + ( + name.to_string(), + vec![ExtractStyleValue::Static( + ExtractStaticStyle::new_with_layer( + "color", + value, + 0, + None, + Some(layer.to_string()), + ), + )], + ) + }) + .collect(), + ); + visitor.visit_program(&mut program); + assert_eq!(visitor.errors, vec![]); + let mut layers: Vec<_> = visitor + .styles + .iter() + .map(|value| match value { + ExtractStyleValue::Static(style) => { + (style.layer().map(str::to_string), style.value().to_string()) + } + other => panic!("expected static atom, got {other:?}"), + }) + .collect(); + layers.sort(); + assert_eq!( + layers, + [ + (None, "black".into()), + (Some("base".into()), "blue".into()), + (Some("theme".into()), "green".into()) + ] + ); +} + +#[test] +#[serial_test::serial] +fn inline_calls_and_tags_are_known_when_imported_base_composes_them() { + for mixin in [ + "css({ color: 'red', padding: 4 })", + "css`color: red; padding: 4px;`", + ] { + let base = format!("export const Base = styled.div`${{{mixin}}}; color: blue;`;"); + let output = imported(&base, "export const Choice = styled(Base)`margin: 1px;`;"); + assert_eq!( + declarations(&output), + [ + ("color".into(), "blue".into()), + ("margin".into(), "1px".into()), + ("padding".into(), "4px".into()) + ] + ); + } +} + +#[test] +#[serial_test::serial] +fn dynamic_values_keep_variables_when_known_mixin_joins_template() { + let source = "import styled from '@emotion/styled'; import { css } from '@emotion/react'; const mixin = css({ padding: 4 }); export const Choice = styled.div`${mixin}; width: ${p => p.width};`;"; + let output = crate::extract("w27-dynamic-mixin.tsx", source, option()) + .unwrap_or_else(|error| panic!("{error}")); + assert!(output.styles.iter().any( + |value| matches!(value, ExtractStyleValue::Dynamic(style) if style.property() == "width") + )); + assert!( + !output + .code + .split("const Choice") + .nth(1) + .unwrap_or_default() + .contains("mixin") + ); + assert!(output.code.contains("--")); +} + +#[test] +#[serial_test::serial] +fn runtime_class_stays_opaque_when_styles_are_unknown() { + let source = "import styled from '@emotion/styled'; import { runtime } from './runtime'; export const Choice = styled.div`${runtime}; color: blue;`;"; + let output = crate::extract("w27-unknown-mixin.tsx", source, option()) + .unwrap_or_else(|error| panic!("{error}")); + assert!(output.code.contains("runtime")); + assert_eq!(declarations(&output), [("color".into(), "blue".into())]); +} + +#[test] +#[serial_test::serial] +fn runtime_class_remains_when_known_mixin_is_composed_beside_it() { + let source = "import styled from '@emotion/styled'; import { css } from '@emotion/react'; import { runtime } from './runtime'; const mixin = css({ color: 'red' }); export const Choice = styled.div`${runtime}; ${mixin}; color: blue;`;"; + let output = crate::extract("w27-mixed-mixins.tsx", source, option()) + .unwrap_or_else(|error| panic!("{error}")); + let choice = output.code.split("const Choice").nth(1).unwrap_or_default(); + assert!(choice.contains("runtime")); + assert!(!choice.contains("mixin")); +} diff --git a/libs/extractor/src/extractor/w27_styled_linux_coverage.rs b/libs/extractor/src/extractor/w27_styled_linux_coverage.rs new file mode 100644 index 000000000..6132ac3dd --- /dev/null +++ b/libs/extractor/src/extractor/w27_styled_linux_coverage.rs @@ -0,0 +1,186 @@ +use super::*; +use rstest::rstest; + +#[path = "w27_styled_statement_coverage.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 callback = Parser::new(&allocator, "p => null", SourceType::tsx()) + .parse_expression() + .map_err(|error| format!("{error:?}"))?; + let callback = match callback { + Expression::ArrowFunctionExpression(callback) => Some(callback), + _ => None, + } + .ok_or("expected callback fixture")?; + let mut rules = Parser::new(&allocator, source, SourceType::tsx()) + .parse_expression() + .map_err(|error| format!("{error:?}"))?; + let before = expression_to_code(&rules); + // When + let result = props_rules(&ast, &mut rules, &callback.params); + // Then + assert_eq!(result, None); + assert_eq!(expression_to_code(&rules), before); + Ok(()) +} + +#[test] +fn direct_props_lowerer_when_object_is_written_preserves_props_value() +-> Result<(), Box> { + // Given + let allocator = oxc_allocator::Allocator::default(); + let ast = AstBuilder::new(&allocator); + let callback = Parser::new(&allocator, "p => null", SourceType::tsx()) + .parse_expression() + .map_err(|error| format!("{error:?}"))?; + let callback = match callback { + Expression::ArrowFunctionExpression(callback) => Some(callback), + _ => None, + } + .ok_or("expected callback fixture")?; + let mut rules = Parser::new(&allocator, "({opacity: p.opacity})", SourceType::tsx()) + .parse_expression() + .map_err(|error| format!("{error:?}"))?; + // When + let result = props_rules(&ast, &mut rules, &callback.params); + // Then + assert_eq!(result, Some(())); + let code = expression_to_code(&rules); + assert_eq!( + super::w27_props_rule_choices_tests::support::evaluate( + &format!( + "JSON.stringify(({}))", + crate::css_utils::rm_last_semi_colon(&code) + ), + "{opacity: 0.4}", + ), + r#"{"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 imports = FxHashMap::default(); + 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: &imports, + values: &values, + inline_css: &inline_css, + }, + &[], + None, + 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 imports = FxHashMap::default(); + 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: &imports, + values: &values, + inline_css: &inline_css, + }, + &[], + None, + None, + ); + // Then + assert_eq!(output.errors, vec![]); + let styles = output + .result + .styles + .iter() + .flat_map(ExtractStyleProp::extract) + .collect::>(); + assert_eq!( + super::w27_props_rule_choices_tests::support::declarations(&styles), + [("opacity".into(), ".6".into())] + ); + Ok(()) +} diff --git a/libs/extractor/src/extractor/w27_styled_statement_coverage.rs b/libs/extractor/src/extractor/w27_styled_statement_coverage.rs new file mode 100644 index 000000000..7908df737 --- /dev/null +++ b/libs/extractor/src/extractor/w27_styled_statement_coverage.rs @@ -0,0 +1,140 @@ +use super::super::*; +use rstest::rstest; + +#[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 imports = FxHashMap::default(); + 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: &imports, + values: &values, + inline_css: &inline_css, + }, + &[], + None, + 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 selected = super::super::w27_props_rule_choices_tests::support::evaluate( + 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 imports = FxHashMap::default(); + 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: &imports, + values: &values, + inline_css: &inline_css, + }, + &[], + None, + 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/w27_styled_template_coverage.rs b/libs/extractor/src/extractor/w27_styled_template_coverage.rs new file mode 100644 index 000000000..1e61e8bb9 --- /dev/null +++ b/libs/extractor/src/extractor/w27_styled_template_coverage.rs @@ -0,0 +1,177 @@ +use super::*; +use crate::extract_style::{ExtractStyleProperty, extract_static_style::ExtractStaticStyle}; +use rstest::rstest; + +#[rstest] +#[case("true", "red")] +#[case("false", "blue")] +#[serial_test::serial] +fn direct_condition_when_no_props_callback_exists_keeps_module_binding( + #[case] on: &str, + #[case] expected: &str, +) -> Result<(), Box> { + // Given + let allocator = oxc_allocator::Allocator::default(); + let ast = AstBuilder::new(&allocator); + let imports = rustc_hash::FxHashMap::default(); + let values = crate::style_values::StyleValues::default(); + let inline_css = rustc_hash::FxHashMap::default(); + let bindings = StyledBindings { + imports: &imports, + values: &values, + inline_css: &inline_css, + }; + let expression = oxc_parser::Parser::new( + &allocator, + "on ? { color: 'red' } : { color: 'blue' }", + oxc_span::SourceType::tsx(), + ) + .parse_expression() + .map_err(|error| format!("failed to parse valid choice: {error:?}"))?; + // When + let mut styles = mixin(&ast, &expression, &bindings) + .map_err(|_| "failed to lower written choice")? + .ok_or("written choice produced no styles")?; + let mut context = boa_engine::Context::default(); + let class = crate::gen_class_name::gen_class_names(&ast, &mut styles, None, None) + .ok_or("conditional classes were missing")?; + let code = format!( + "const on = {on}; {}", + crate::utils::expression_to_code(&class) + ); + let selected = context + .eval(boa_engine::Source::from_bytes(&code)) + .map_err(|error| format!("module condition failed to evaluate in scope: {error}"))? + .to_string(&mut context) + .map_err(|error| format!("selected class failed string conversion: {error}"))? + .to_std_string_escaped(); + // Then + let expected = ExtractStaticStyle::new("color", expected, 0, None) + .extract(None) + .to_string(); + assert_eq!(selected, expected); + Ok(()) +} + +#[rstest] +#[case("(known) || { color: 'blue' }", false, "blue")] +#[case("(known) && { color: 'blue' }", true, "blue")] +#[case("(known) && unknownRules()", false, "")] +#[case("(known) ?? unknownRules()", false, "")] +#[serial_test::serial] +fn known_class_when_empty_or_nonempty_selects_rules_before_context_lowering( + #[case] source: &str, + #[case] truthy: bool, + #[case] expected: &str, +) -> Result<(), Box> { + // Given + let allocator = oxc_allocator::Allocator::default(); + let ast = AstBuilder::new(&allocator); + let expression = oxc_parser::Parser::new(&allocator, source, oxc_span::SourceType::tsx()) + .parse_expression() + .map_err(|error| format!("failed to parse valid choice: {error:?}"))?; + let Expression::LogicalExpression(logical) = &expression else { + panic!("expected logical choice") + }; + let known = if truthy { + vec![crate::ExtractStyleValue::Static(ExtractStaticStyle::new( + "color", "red", 0, None, + ))] + } else { + vec![] + }; + let imports = rustc_hash::FxHashMap::default(); + let values = crate::style_values::StyleValues::default(); + let inline_css = + rustc_hash::FxHashMap::from_iter([(unwrap_syntax_only(&logical.left).span().start, known)]); + let bindings = StyledBindings { + imports: &imports, + values: &values, + inline_css: &inline_css, + }; + // When + let mut styles = mixin(&ast, &expression, &bindings) + .map_err(|_| "failed to lower known class choice")? + .ok_or("known class choice produced no styles")?; + let class = crate::gen_class_name::gen_class_names(&ast, &mut styles, None, None) + .map(|expression| crate::utils::expression_to_code(&expression)); + // Then + match class { + None => assert_eq!(expected, ""), + Some(code) => { + let selected = super::super::w27_props_rule_choices_tests::support::evaluate( + crate::css_utils::rm_last_semi_colon(&code), + "{}", + ); + assert_eq!( + selected, + ExtractStaticStyle::new("color", expected, 0, None) + .extract(None) + .to_string() + ); + } + } + Ok(()) +} + +#[test] +fn opaque_known_atom_when_nested_reports_error_instead_of_applying_global_class() +-> Result<(), Box> { + // Given + let allocator = oxc_allocator::Allocator::default(); + let ast = AstBuilder::new(&allocator); + let expression = oxc_parser::Parser::new( + &allocator, + "`&:hover { ${known}; }`", + oxc_span::SourceType::tsx(), + ) + .parse_expression() + .map_err(|error| format!("failed to parse valid template: {error:?}"))?; + let Expression::TemplateLiteral(template) = expression else { + panic!("expected template") + }; + let imports = rustc_hash::FxHashMap::default(); + let values = crate::style_values::StyleValues::default(); + let inline_css = rustc_hash::FxHashMap::from_iter([( + template.expressions[0].span().start, + vec![crate::ExtractStyleValue::Typography("body".into())], + )]); + let bindings = StyledBindings { + imports: &imports, + values: &values, + inline_css: &inline_css, + }; + // When + let result = compose(&ast, &template, &bindings); + // Then + assert_eq!(result.styles.len(), 0); + assert_eq!(result.errors.len(), 1); + assert_eq!(result.errors[0].0, template.expressions[0].span().start); + assert!(result.errors[0].1.contains("nested mixin")); + Ok(()) +} + +#[test] +fn unknown_rule_when_reader_receives_it_fails_instead_of_silently_discarding_it() { + // Given + let allocator = oxc_allocator::Allocator::default(); + let ast = AstBuilder::new(&allocator); + let imports = rustc_hash::FxHashMap::default(); + let values = crate::style_values::StyleValues::default(); + let inline_css = rustc_hash::FxHashMap::default(); + let bindings = StyledBindings { + imports: &imports, + values: &values, + inline_css: &inline_css, + }; + let mut rules = Expression::new_identifier(oxc_span::SPAN, "unknown", &ast); + // When + let result = MixinReader { + ast: &ast, + bindings: &bindings, + params: None, + } + .read(&mut rules); + // Then + assert!(result.is_none()); +} diff --git a/libs/extractor/src/extractor/w27_styled_template_parts_tests.rs b/libs/extractor/src/extractor/w27_styled_template_parts_tests.rs new file mode 100644 index 000000000..db19ae3f6 --- /dev/null +++ b/libs/extractor/src/extractor/w27_styled_template_parts_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/import_alias_visit.rs b/libs/extractor/src/import_alias_visit.rs index 158d0f420..b7ee60bad 100644 --- a/libs/extractor/src/import_alias_visit.rs +++ b/libs/extractor/src/import_alias_visit.rs @@ -8,18 +8,22 @@ //! - `import { style } from '@vanilla-extract/css'` → `import { style } from '@devup-ui/react'` use crate::ImportAlias; +use crate::css_prop::{ + CssProp, EMOTION_REACT, REACT_JSX_PRAGMA, builds_jsx_with_emotion, class_names_child, + emotion_pragma, is_emotion, is_jsx_file, is_jsx_function, react_runtime, returned, +}; use crate::utils::{ get_str_by_property_key, is_vanilla_extract_file, js_number_literal, keeps_bare_number, }; use oxc_allocator::Allocator; use oxc_ast::ast::{ - Argument, CallExpression, Expression, ImportDeclarationSpecifier, JSXAttributeItem, - JSXAttributeValue, JSXElementName, JSXOpeningElement, ModuleExportName, ObjectPropertyKind, - Statement, + Argument, ArrowFunctionBody, CallExpression, Expression, ImportDeclarationSpecifier, + JSXAttributeItem, JSXAttributeValue, JSXElement, JSXElementName, JSXOpeningElement, + LogicalOperator, ModuleExportName, ObjectPropertyKind, Statement, }; use oxc_ast_visit::{ Visit, - walk::{walk_call_expression, walk_jsx_opening_element}, + walk::{walk_call_expression, walk_jsx_element, walk_jsx_opening_element}, }; use oxc_parser::Parser; use oxc_span::{GetSpan, SourceType}; @@ -44,10 +48,27 @@ struct LibraryNumbers<'n> { styled: Vec<&'n str>, /// Local names of components taking rules in `styles` (Emotion's `Global`) components: Vec<&'n str>, + /// Which elements take the `css` prop, whose rules are Emotion's + css_prop: CssProp, + /// Local names of Devup UI's exports, which take the `css` prop as tags do + devup: Vec<&'n str>, + /// Local names of the functions building elements from a type and props + jsx: Vec<&'n str>, + /// Local names of Emotion's `ClassNames`, whose child function takes a + /// `css` taking rules + class_names: Vec<&'n str>, replacements: Vec<(usize, usize, String)>, + /// Where a constant inlined later is a number or rules of the library, as + /// the source reads them + marks: Vec<(usize, usize)>, } impl LibraryNumbers<'_> { + fn mark(expression: &Expression) -> (usize, usize) { + let span = expression.span(); + (span.start as usize, span.end as usize) + } + /// `styled.div`, `styled(tag)`, `styled(tag, options)` and their /// `.attrs()` / `.withConfig()`: whatever a call of it passes are rules fn is_styled_factory(&self, callee: &Expression) -> bool { @@ -73,15 +94,24 @@ 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); } } } Expression::ObjectExpression(object) => { for property in &object.properties { - if let ObjectPropertyKind::ObjectProperty(property) = property - && let Some(key) = get_str_by_property_key(&property.key) - { - self.pixelify_value(&key, &property.value); + match property { + ObjectPropertyKind::ObjectProperty(property) => { + if let Some(key) = get_str_by_property_key(&property.key) { + self.pixelify_value(&key, &property.value); + } + } + ObjectPropertyKind::SpreadProperty(spread) => { + self.marks.push(Self::mark(&spread.argument)); + } } } } @@ -89,9 +119,92 @@ 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)), + } + } + + /// The rules a `css` prop composes: a function of the theme gives them + fn pixelify_css(&mut self, value: &Expression) { + match value { + Expression::ArrayExpression(array) => { + 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); + } + } + } + Expression::ConditionalExpression(conditional) => { + self.pixelify_css(&conditional.consequent); + self.pixelify_css(&conditional.alternate); + } + Expression::LogicalExpression(logical) => { + if logical.operator != LogicalOperator::And { + self.pixelify_css(&logical.left); + } + self.pixelify_css(&logical.right); + } + Expression::ParenthesizedExpression(inner) => self.pixelify_css(&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_css(rules); + } + } + Expression::FunctionExpression(function) => { + if let Some(rules) = function.body.as_deref().and_then(returned) { + self.pixelify_css(rules); + } + } + rules => self.pixelify(rules), + } + } + + /// The value of the attribute `name` on `element`, pixelified as `css` + /// prop rules when `css` + fn pixelify_attribute(&mut self, element: &JSXOpeningElement, name: &str) { + for attribute in &element.attributes { + if let JSXAttributeItem::Attribute(attribute) = attribute + && attribute + .name + .as_identifier() + .is_some_and(|attribute| attribute.name == name) + && let Some(JSXAttributeValue::ExpressionContainer(container)) = &attribute.value + && let Some(rules) = container.expression.as_expression() + { + if name == "css" { + self.pixelify_css(rules); + } else { + self.pixelify(rules); + } + } } } @@ -107,6 +220,9 @@ impl LibraryNumbers<'_> { } return; } + if !matches!(value, Expression::ObjectExpression(_)) && !keeps_bare_number(key) { + self.marks.push(Self::mark(value)); + } match value { Expression::ObjectExpression(_) => { if key != "vars" { @@ -117,7 +233,12 @@ impl LibraryNumbers<'_> { self.pixelify_value(key, &conditional.consequent); self.pixelify_value(key, &conditional.alternate); } - Expression::LogicalExpression(logical) => self.pixelify_value(key, &logical.right), + Expression::LogicalExpression(logical) => { + if logical.operator != LogicalOperator::And { + self.pixelify_value(key, &logical.left); + } + self.pixelify_value(key, &logical.right); + } Expression::ParenthesizedExpression(inner) => { self.pixelify_value(key, &inner.expression); } @@ -126,7 +247,25 @@ impl LibraryNumbers<'_> { } } -impl<'a> Visit<'a> for LibraryNumbers<'_> { +impl<'a> Visit<'a> for LibraryNumbers<'a> { + fn visit_jsx_element(&mut self, element: &JSXElement<'a>) { + let css = match &element.opening_element.name { + JSXElementName::IdentifierReference(name) + if self.class_names.contains(&name.name.as_str()) => + { + class_names_child(element).and_then(|names| names.css) + } + _ => None, + }; + if let Some(css) = css { + self.calls.push((css, RulesAt::EveryArgument)); + } + walk_jsx_element(self, element); + if css.is_some() { + self.calls.pop(); + } + } + fn visit_call_expression(&mut self, call: &CallExpression<'a>) { let rules_at = match &call.callee { Expression::Identifier(callee) => self @@ -148,13 +287,33 @@ 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![], }; for rules in rules { self.pixelify(rules); } + if let Expression::Identifier(callee) = &call.callee + && self.jsx.contains(&callee.name.as_str()) + && let [element, Argument::ObjectExpression(props), ..] = call.arguments.as_slice() + && let Some(element) = element.as_expression() + && self + .css_prop + .takes_type(element, |root| self.devup.contains(&root)) + { + for property in &props.properties { + if let ObjectPropertyKind::ObjectProperty(property) = property + && !property.computed + && property.key.static_name().is_some_and(|key| key == "css") + { + self.pixelify_css(&property.value); + } + } + } walk_call_expression(self, call); } @@ -162,19 +321,13 @@ impl<'a> Visit<'a> for LibraryNumbers<'_> { if let JSXElementName::IdentifierReference(name) = &element.name && self.components.contains(&name.name.as_str()) { - for attribute in &element.attributes { - if let JSXAttributeItem::Attribute(attribute) = attribute - && attribute - .name - .as_identifier() - .is_some_and(|name| name.name == "styles") - && let Some(JSXAttributeValue::ExpressionContainer(container)) = - &attribute.value - && let Some(rules) = container.expression.as_expression() - { - self.pixelify(rules); - } - } + self.pixelify_attribute(element, "styles"); + } + if self + .css_prop + .takes(&element.name, |root| self.devup.contains(&root)) + { + self.pixelify_attribute(element, "css"); } walk_jsx_opening_element(self, element); } @@ -207,6 +360,9 @@ fn devup_equivalent(source: &str, imported: &str) -> Option ("@vanilla-extract/css", "globalStyle") => Some(DevupTarget::Main("globalCss")), (_, "keyframes") => Some(DevupTarget::Main("keyframes")), (_, "styled") => Some(DevupTarget::Main("styled")), + // The `css` props it builds compile away, leaving React's own element + ("@emotion/react", "jsx" | "createElement") => Some(DevupTarget::Compat("jsx")), + ("@emotion/react", "ClassNames") => Some(DevupTarget::Compat("ClassNames")), (_, "createGlobalStyle") => Some(DevupTarget::Compat("createGlobalStyle")), (_, "Global") => Some(DevupTarget::Compat("Global")), (_, "ThemeProvider") => Some(DevupTarget::Compat("ThemeProvider")), @@ -226,9 +382,28 @@ pub fn transform_import_aliases<'a>( package: &str, import_aliases: &HashMap, ) -> Cow<'a, str> { - transform_import_aliases_with_edits(code, filename, package, import_aliases).0 + transform_import_aliases_with_edits(code, filename, package, import_aliases).code +} + +/// What rewriting aliased imports gives +pub struct Aliased<'a> { + /// The code, or the original code when nothing was rewritten + pub code: Cow<'a, str>, + /// The replacements made in order, so a position in the code maps back to + /// the source + pub edits: Vec, + /// Which elements take Emotion's `css` prop + pub css_prop: CssProp, + /// Where, in `code`, a constant read is a number or rules the library + /// reads as `px` lengths + pub px: PxMarks, } +/// The spans of the style values and rules written as an expression a +/// constant can stand in, which Emotion, styled-components and vanilla-extract +/// read numbers in as `px` lengths +pub type PxMarks = rustc_hash::FxHashSet<(u32, u32)>; + /// A replacement of `code[start..end]` by text of `length` bytes pub type Edit = (usize, usize, usize); @@ -242,17 +417,29 @@ pub type Edit = (usize, usize, usize); /// /// # Returns /// The transformed source code, or the original code if no transformations were -/// needed, and the replacements made in order, so a position in the result maps -/// back to the source +/// needed, the replacements made in order, so a position in the result maps +/// back to the source, and which elements take Emotion's `css` prop pub fn transform_import_aliases_with_edits<'a>( code: &'a str, filename: &str, package: &str, import_aliases: &HashMap, -) -> (Cow<'a, str>, Vec) { - // Quick check: if no aliases match, return original code - if import_aliases.is_empty() || !import_aliases.keys().any(|alias| code.contains(alias)) { - return (Cow::Borrowed(code), Vec::new()); +) -> Aliased<'a> { + let unchanged = |css_prop, px| Aliased { + code: Cow::Borrowed(code), + edits: Vec::new(), + css_prop, + px, + }; + let emotion_aliased = import_aliases.contains_key(EMOTION_REACT); + let emotion_jsx = builds_jsx_with_emotion(import_aliases); + // Quick check: if no aliases match, no element takes a `css` prop and the + // project builds no JSX with Emotion, return original code + if !(emotion_jsx && is_jsx_file(filename)) + && CssProp::of(import_aliases, code, false) == CssProp::Off + && (import_aliases.is_empty() || !import_aliases.keys().any(|alias| code.contains(alias))) + { + return unchanged(CssProp::Off, PxMarks::default()); } let allocator = Allocator::default(); @@ -267,12 +454,59 @@ pub fn transform_import_aliases_with_edits<'a>( // Collect import transformations let mut transformations: Vec<(usize, usize, String)> = Vec::new(); let mut numbers = LibraryNumbers::default(); + let mut uses_emotion = emotion_jsx; + let mut jsx_pragma = false; + let compat = format!("{package}/compat"); + + // A pragma building JSX with Emotion builds it with React once the `css` + // props are compiled + for comment in &program.comments { + let span = comment.content_span(); + let text = &code[span.start as usize..span.end as usize]; + jsx_pragma |= text.contains("@jsx"); + if let Some(at) = emotion_pragma(text) { + uses_emotion = true; + if emotion_aliased { + let start = span.start as usize + at; + transformations.push((start, start + EMOTION_REACT.len(), "react".to_string())); + } + } + } + // So does a project building every file's JSX with Emotion, through a + // pragma, which a file's own one overrides + if emotion_jsx && is_jsx_file(filename) && !jsx_pragma { + transformations.push((0, 0, REACT_JSX_PRAGMA.to_string())); + } for stmt in &program.body { if let Statement::ImportDeclaration(import_decl) = stmt { let source_value = import_decl.source.value.as_str(); - - if let Some(alias) = import_aliases.get(source_value) { + uses_emotion |= is_emotion(source_value); + for specifier in import_decl.specifiers.iter().flatten() { + match specifier { + ImportDeclarationSpecifier::ImportSpecifier(spec) + if is_jsx_function( + source_value, + &imported_name(&spec.imported), + &compat, + ) => + { + numbers.jsx.push(spec.local.name.as_str()); + } + specifier if source_value == package => { + numbers.devup.push(specifier.local().name.as_str()); + } + _ => {} + } + } + if emotion_aliased && let Some(runtime) = react_runtime(source_value) { + let span = import_decl.source.span; + transformations.push(( + span.start as usize, + span.end as usize, + format!("\"{runtime}\""), + )); + } else if let Some(alias) = import_aliases.get(source_value) { let span = import_decl.span; let new_import = generate_transformed_import(import_decl, alias, package, redirect_every_name); @@ -294,6 +528,9 @@ pub fn transform_import_aliases_with_edits<'a>( "css" | "keyframes", ) => numbers.calls.push((local, RulesAt::EveryArgument)), ("@emotion/react", "Global") => numbers.components.push(local), + ("@emotion/react", "ClassNames") => { + numbers.class_names.push(local); + } _ => {} } } @@ -320,27 +557,58 @@ pub fn transform_import_aliases_with_edits<'a>( } } } - if !(numbers.calls.is_empty() && numbers.styled.is_empty() && numbers.components.is_empty()) { + numbers.css_prop = CssProp::of(import_aliases, code, uses_emotion); + if !(numbers.calls.is_empty() + && numbers.styled.is_empty() + && numbers.components.is_empty() + && numbers.class_names.is_empty()) + || numbers.css_prop != CssProp::Off + { numbers.visit_program(&program); transformations.append(&mut numbers.replacements); - transformations.sort_unstable_by_key(|(start, ..)| *start); } + // An insertion comes before a replacement starting where it does + transformations.sort_unstable_by_key(|(start, end, _)| (*start, *end)); // Apply transformations in reverse order to preserve positions if transformations.is_empty() { - return (Cow::Borrowed(code), Vec::new()); + let px = numbers + .marks + .iter() + .map(|&(start, end)| (start as u32, end as u32)) + .collect(); + return unchanged(numbers.css_prop, px); } let edits = transformations .iter() .map(|(start, end, replacement)| (*start, *end, replacement.len())) .collect(); + let shifted = |offset: usize| { + let before = transformations + .iter() + .take_while(|(_, end, _)| *end <= offset); + let (added, removed) = before.fold((0, 0), |(added, removed), (start, end, text)| { + (added + text.len(), removed + end - start) + }); + (offset + added - removed) as u32 + }; + let px = numbers + .marks + .iter() + .map(|&(start, end)| (shifted(start), shifted(end))) + .collect(); let mut result = code.to_string(); for (start, end, replacement) in transformations.into_iter().rev() { result.replace_range(start..end, &replacement); } - (Cow::Owned(result), edits) + Aliased { + code: Cow::Owned(result), + edits, + css_prop: numbers.css_prop, + px, + } } /// The source offset of `offset` in code `edits` made; an offset inside a @@ -561,6 +829,10 @@ fn generate_transformed_import( result } +#[cfg(test)] +#[path = "numeric_context_w27_tests.rs"] +mod numeric_context_w27_tests; + #[cfg(test)] mod tests { use super::*; @@ -794,6 +1066,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' @@ -1027,4 +1326,86 @@ const x = 1;", ); } } + + #[test] + fn test_css_prop_numbers_runtimes_and_pragmas() { + let mut aliases = emotion_alias(); + aliases.insert(EMOTION_REACT.to_string(), ImportAlias::NamedToNamed); + let outputs: Vec<(String, CssProp)> = [ + "export const a =
;", + "export const a =
;", + "export const a =
({ top: 1 }), t => { return { left: 2 }; }, t => { f(); return { right: 3 }; }, function (t) { return { bottom: 4 }; }, function (t) { f(); }]} />;", + "import { Box } from '@devup-ui/react';\nexport const a = <>;", + "import styled from '@emotion/styled';\nexport const a = ;", + "/** @jsxImportSource @emotion/react */\nexport const a = ;", + "import { jsx as _jsx } from '@emotion/react/jsx-runtime';\nimport { jsxDEV } from '@emotion/react/jsx-dev-runtime';\nexport const a = [_jsx('div', { css: { padding: 2 }, id: 1, ['css']: { top: 1 } }), jsxDEV(Custom, { css: { top: 3 } }), _jsx('div', props), f('div', { css: { top: 4 } })];", + "import { jsx, createElement } from '@emotion/react';\nexport const a = [jsx('div', { css: { padding: 2 } }), createElement('div', null)];", + "import { jsx as _jsx } from 'react/jsx-runtime';\nexport const a = [_jsx('div', { css: { padding: 2 } }), _jsx(Custom, { css: { padding: 2 } })];", + "export const a = { css: 1 };", + ] + .iter() + .map(|code| { + let aliased = + transform_import_aliases_with_edits(code, "test.tsx", "@devup-ui/react", &aliases); + (aliased.code.into_owned(), aliased.css_prop) + }) + .collect(); + insta::assert_debug_snapshot!(outputs); + + let without_css_prop = transform_import_aliases_with_edits( + "export const a =
;", + "test.tsx", + "@devup-ui/react", + &emotion_alias(), + ); + assert_eq!(without_css_prop.css_prop, CssProp::Off); + assert!(matches!(without_css_prop.code, Cow::Borrowed(_))); + } + + #[test] + fn test_project_building_jsx_with_emotion() { + let mut aliases = emotion_alias(); + aliases.insert(EMOTION_REACT.to_string(), ImportAlias::NamedToNamed); + aliases.insert( + crate::css_prop::EMOTION_JSX_RUNTIME.to_string(), + ImportAlias::NamedToNamed, + ); + let outputs: Vec<(String, CssProp, Vec)> = [ + ("test.tsx", "export const a = ;"), + ( + "test.jsx", + "import styled from '@emotion/styled';\nexport const a =
;", + ), + ( + "test.tsx", + "/** @jsxImportSource @emotion/react */\nexport const a =
;", + ), + ("test.tsx", "/** @jsx h */\nexport const a =
;"), + ("test.ts", "export const a = 1;"), + ( + "test.ts", + "import { jsx as _jsx } from '@emotion/react/jsx-runtime';\nexport const a = _jsx(Custom, { css: { top: 1 } });", + ), + ] + .iter() + .map(|(filename, code)| { + let aliased = + transform_import_aliases_with_edits(code, filename, "@devup-ui/react", &aliases); + (aliased.code.into_owned(), aliased.css_prop, aliased.edits) + }) + .collect(); + insta::assert_debug_snapshot!(outputs); + + let runtime_alone = transform_import_aliases_with_edits( + "export const a =
;", + "test.tsx", + "@devup-ui/react", + &HashMap::from([( + crate::css_prop::EMOTION_JSX_RUNTIME.to_string(), + ImportAlias::NamedToNamed, + )]), + ); + assert_eq!(runtime_alone.css_prop, CssProp::Off); + assert!(matches!(runtime_alone.code, Cow::Borrowed(_))); + } } diff --git a/libs/extractor/src/imported_constants.rs b/libs/extractor/src/imported_constants.rs index a600d1deb..3a4c7c66d 100644 --- a/libs/extractor/src/imported_constants.rs +++ b/libs/extractor/src/imported_constants.rs @@ -6,7 +6,7 @@ use std::cell::Cell; use std::collections::BTreeSet; use std::rc::Rc; -use oxc_allocator::{Allocator, FromIn, GetAllocator}; +use oxc_allocator::{Address, Allocator, CloneIn, FromIn, GetAddress, GetAllocator}; use oxc_ast::ast::{ Argument, ArrayExpressionElement, Expression, ImportDeclarationSpecifier, JSXAttributeItem, JSXElementName, ObjectPropertyKind, Program, Statement, Str, VariableDeclarationKind, @@ -15,14 +15,24 @@ use oxc_ast::builder::AstBuilder; use oxc_ast_visit::{Visit, VisitMut, walk, walk_mut}; use oxc_parser::Parser; use oxc_semantic::{Scoping, SemanticBuilder}; -use oxc_span::{SPAN, SourceType}; +use oxc_span::{GetSpan, SPAN, SourceType}; use oxc_syntax::number::NumberBase; use oxc_syntax::operator::BinaryOperator; use oxc_syntax::symbol::SymbolId; use rustc_hash::{FxHashMap, FxHashSet}; +use crate::composition::{Composition, set_prop_order}; +use crate::css_prop::{CssProp, CssTakers}; +use crate::extractor::ExtractResult; +use crate::extractor::extract_style_from_expression::{ + LiteralHandling, extract_style_from_expression, +}; use crate::stylex::StylexFunction; -use crate::{ExtractOption, ModuleResolver}; +use crate::{ExtractOption, ExtractStyleValue, ModuleResolver}; + +#[cfg(test)] +#[path = "local_capture_coverage_tests.rs"] +mod local_capture_coverage_tests; #[derive(Clone, Debug)] enum Constant { @@ -44,8 +54,9 @@ enum Constant { /// The class a `StyleX` theme applies Theme(String), /// What another style API gives: a class, a component or a keyframes - /// name, never rules - Style, + /// name, never rules; for a `css()` class, the styles behind it when + /// they are known + Style(Option>>), /// An object or array code changes, or a value read from one Changed(Rc), } @@ -154,8 +165,157 @@ pub(crate) struct Inlined { pub dependencies: BTreeSet, pub stylex_vars: FxHashMap>, pub stylex_themes: FxHashMap, + /// The styles behind imported `css()` classes + pub css_styles: FxHashMap>, pub unknown: Unknown, pub changed: Changed, + /// Style objects declared below the top level that styles read, which + /// code elsewhere reads too, so the build cannot tell they are unchanged + pub errors: Vec<(u32, String)>, +} + +/// A `const` below the top level of the module holding an object or array +/// literal that is all written out, which styles read as they read top-level +/// constants when nothing else reads it +struct LocalConstant { + name: String, + /// Where its binding is written + binding: u32, + 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 @@ -312,11 +472,14 @@ pub(crate) fn inline_constants<'a>( filename: &str, option: &ExtractOption, resolver: Option<&ModuleResolver>, + css_prop: CssProp, + px: &crate::import_alias_visit::PxMarks, ) -> Inlined { let is_style_package = |source: &str| source.starts_with(&option.package) || source == crate::STYLEX_PACKAGE; let mut style_roots = FxHashSet::default(); let mut apis = StyleApis::default(); + let css_props = CssTakers::new(program, css_prop, &format!("{}/compat", option.package)); for statement in &program.body { if let Statement::ImportDeclaration(import) = statement && is_style_package(&import.source.value) @@ -338,18 +501,25 @@ pub(crate) fn inline_constants<'a>( } } } - if style_roots.is_empty() { + if style_roots.is_empty() && css_prop == CssProp::Off { return Inlined::default(); } let mut read = StyleReads { style_roots: &style_roots, + css_props: &css_props, names: FxHashSet::default(), depth: 0, + class_names: Vec::new(), }; read.visit_program(program); - if read.names.is_empty() { + if read.names.is_empty() && px.is_empty() { return Inlined::default(); } + let mut locals = LocalConstants::default(); + locals.visit_program(program); + let mut found = Vec::new(); + let mut shapes = FxHashMap::default(); + let mut invalid = FxHashSet::default(); let mut modules = Modules { resolver, option, @@ -358,11 +528,16 @@ pub(crate) fn inline_constants<'a>( }; let mut symbols: FxHashMap = FxHashMap::default(); let mut inlined = Inlined::default(); - let (scoping, reads_math) = { + let scoping = SemanticBuilder::new() + .build(program) + .semantic + .into_scoping(); + let reads_math = { let mut scope = ModuleScope::new(filename, program, None); scope .style_names .extend(style_roots.iter().map(ToString::to_string)); + scope.css_props = Some(&css_props); let mut bindings: FxHashMap<&str, Vec<&Cell>>> = FxHashMap::default(); for statement in &program.body { let declaration = match statement { @@ -424,13 +599,13 @@ pub(crate) fn inline_constants<'a>( // Scoping is only worth building when a style reads a name that may // hold a constant let reads_math = read.names.contains("Math") && !scope.binds("Math"); - if !reads_math && !read.names.iter().any(|name| scope.binds(name)) { + if !reads_math + && px.is_empty() + && locals.bindings.is_empty() + && !read.names.iter().any(|name| scope.binds(name)) + { return Inlined::default(); } - let scoping = SemanticBuilder::new() - .build(program) - .semantic - .into_scoping(); for name in &read.names { let bound = scope.binds(name); let constant = scope.lookup(&mut modules, name); @@ -471,24 +646,112 @@ pub(crate) fn inline_constants<'a>( Constant::Theme(class) => { inlined.stylex_themes.insert(name.clone(), class.clone()); } + Constant::Style(Some(styles)) => { + inlined + .css_styles + .insert(name.clone(), styles.as_ref().clone()); + } _ => {} } for symbol in bindings.get(name.as_str()).into_iter().flatten() { symbols.extend(symbol.get().map(|symbol| (symbol, constant.clone()))); } } - (scoping, reads_math) + scope.scoped = Some(&scoping); + ScopedLocals { + scope: &mut scope, + modules: &mut modules, + scoping: &scoping, + found: &mut found, + builder: ast_builder, + shapes: &mut shapes, + ends: &locals.bindings, + invalid: &mut invalid, + } + .visit_program(program); + reads_math }; inlined.dependencies = modules.exports.into_keys().collect(); - 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 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() + { + let aliases = local_capture_reads::aliases(program, &scoping, &shapes, &captures); + let deferred = local_capture_reads::deferred_reads(program, &scoping); + let mut inline = Inline { ast_builder, scoping: &scoping, symbols: &symbols, + shapes: &shapes, + aliases: &aliases, + deferred: &deferred, + local_ends: declared + .iter() + .map(|(symbol, local)| (*symbol, local.end)) + .collect(), + invalid: &invalid, style_roots: &style_roots, + css_props: &css_props, apis: &apis, objects: false, styles: false, + px: false, + numeric_role: NumericRole::Other, + marks: px, + pixel_callbacks, + replaced: FxHashMap::default(), + rejected: FxHashSet::default(), + class_names: Vec::new(), + }; + inline.visit_program(program); + for (symbol, local) in declared { + let read = inline.replaced.get(&symbol).copied().unwrap_or(0); + if inline.rejected.contains(&symbol) + || (read > 0 && read < scoping.get_resolved_reference_ids(symbol).len()) + { + inlined + .errors + .push((local.binding, crate::utils::local_style_error(&local.name))); + } + } + local_capture_reads::Rename { + builder: ast_builder, + scoping: &scoping, + aliases: &aliases, } .visit_program(program); } @@ -620,8 +883,12 @@ impl StyleApis<'_> { /// of its functions struct StyleReads<'s> { style_roots: &'s FxHashSet<&'s str>, + css_props: &'s CssTakers<'s>, names: FxHashSet, depth: usize, + /// The names the `` child functions around take `css` and + /// `cx` by + class_names: Vec, } /// Whether `expression` is a style API: a root the package gives, or a member @@ -655,12 +922,33 @@ impl<'a> Visit<'a> for StyleReads<'_> { } } + fn visit_jsx_element(&mut self, element: &oxc_ast::ast::JSXElement<'a>) { + let calls = self.css_props.class_names_calls(element); + let taken = calls.len(); + self.class_names.extend(calls); + oxc_ast_visit::walk::walk_jsx_element(self, element); + self.class_names.truncate(self.class_names.len() - taken); + } + fn visit_call_expression(&mut self, call: &oxc_ast::ast::CallExpression<'a>) { self.visit_expression(&call.callee); - let style = self.is_style_root(&call.callee); + let style = + self.is_style_root(&call.callee) || calls_one_of(&self.class_names, &call.callee); + let css = self + .css_props + .property(call, |root| self.style_roots.contains(root)); self.reading(style, |reads| { - for argument in &call.arguments { - reads.visit_argument(argument); + for (index, argument) in call.arguments.iter().enumerate() { + match (css, argument) { + (Some(css), Argument::ObjectExpression(props)) if index == 1 => { + for (at, property) in props.properties.iter().enumerate() { + reads.reading(at == css, |reads| { + reads.visit_object_property_kind(property); + }); + } + } + _ => reads.visit_argument(argument), + } } }); } @@ -670,29 +958,36 @@ impl<'a> Visit<'a> for StyleReads<'_> { tagged: &oxc_ast::ast::TaggedTemplateExpression<'a>, ) { self.visit_expression(&tagged.tag); - let style = self.is_style_root(&tagged.tag); + let style = self.is_style_root(&tagged.tag) || calls_one_of(&self.class_names, &tagged.tag); self.reading(style, |reads| reads.visit_template_literal(&tagged.quasi)); } fn visit_jsx_opening_element(&mut self, element: &oxc_ast::ast::JSXOpeningElement<'a>) { let style = jsx_root(&element.name).is_some_and(|root| self.style_roots.contains(root)); - self.reading(style, |reads| { - for attribute in &element.attributes { - match attribute { - JSXAttributeItem::Attribute(attribute) => { - if let Some(value) = &attribute.value { - reads.visit_jsx_attribute_value(value); - } - } - JSXAttributeItem::SpreadAttribute(spread) => { - reads.visit_expression(&spread.argument); + for attribute in &element.attributes { + let style = style + || self.css_props.attribute(&element.name, attribute, |root| { + self.style_roots.contains(root) + }); + self.reading(style, |reads| match attribute { + JSXAttributeItem::Attribute(attribute) => { + if let Some(value) = &attribute.value { + reads.visit_jsx_attribute_value(value); } } - } - }); + JSXAttributeItem::SpreadAttribute(spread) => { + reads.visit_expression(&spread.argument); + } + }); + } } } +/// Whether `callee` is one of `names` +fn calls_one_of(names: &[String], callee: &Expression<'_>) -> bool { + matches!(callee, Expression::Identifier(callee) if names.iter().any(|name| name == callee.name.as_str())) +} + /// The name `` or `` starts with pub(crate) fn jsx_root<'n>(name: &'n JSXElementName<'_>) -> Option<&'n str> { match name { @@ -994,8 +1289,13 @@ struct ModuleScope<'p, 'a> { style_imports: FxHashSet, /// Style APIs besides the imports, which never run what they are given style_names: FxHashSet, + /// The `css` props of the file extracted, which never run what they hold + css_props: Option<&'p CssTakers<'p>>, uses: Option>>>, changes: FxHashMap>>, + /// Local rule evaluation resolves dependencies by symbol, never by name. + scoped: Option<&'p Scoping>, + exact: FxHashMap, } impl<'p, 'a> ModuleScope<'p, 'a> { @@ -1009,8 +1309,11 @@ impl<'p, 'a> ModuleScope<'p, 'a> { imports: FxHashMap::default(), style_imports: FxHashSet::default(), style_names: FxHashSet::default(), + css_props: None, uses: None, changes: FxHashMap::default(), + scoped: None, + exact: FxHashMap::default(), } } @@ -1032,6 +1335,55 @@ impl<'p, 'a> ModuleScope<'p, 'a> { }) } + /// The styles behind `css(rules)`, the package's own `css` given one + /// rule object every value of which is known, as the module's class names + /// do not tell them + fn css_styles( + &mut self, + modules: &mut Modules<'_>, + call: &oxc_ast::ast::CallExpression<'_>, + ) -> Option>> { + let Expression::Identifier(callee) = &call.callee else { + return None; + }; + let (source, Imported::Named(export)) = self.imports.get(callee.name.as_str())? else { + return None; + }; + if export != "css" || !source.starts_with(modules.option.package.as_str()) { + return None; + } + let [argument] = call.arguments.as_slice() else { + return None; + }; + let rules = self.evaluate(modules, argument.as_expression()?)?; + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let mut rules = match constant_literal(&builder, &rules, true)? { + rules @ Expression::ObjectExpression(_) => rules, + _ => return None, + }; + 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 { + set_prop_order(prop, order); + } + } + let mut composition = Composition::default(); + composition.apply(&builder, styles); + composition.unconditional().map(Rc::new) + } + fn is_style_import(&self, option: &ExtractOption, name: &str) -> bool { self.style_imports.contains(name) || self.style_names.contains(name) @@ -1066,9 +1418,11 @@ impl<'p, 'a> ModuleScope<'p, 'a> { uses.clone() } else { let option = modules.option; - let uses = Rc::new(crate::mutations::uses(self.program, &|name| { - self.is_style_import(option, name) - })); + let uses = Rc::new(crate::mutations::uses( + self.program, + &|name| self.is_style_import(option, name), + self.css_props, + )); self.uses = Some(uses.clone()); uses }; @@ -1118,7 +1472,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 @@ -1421,7 +1785,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) } @@ -1447,7 +1811,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()) } @@ -1465,13 +1836,16 @@ impl<'p, 'a> ModuleScope<'p, 'a> { } fold_math(callee.property.name.as_str(), &arguments) } - callee if self.is_style_api(modules, callee) => Some(Constant::Style), + _ if self.scoped.is_some() => None, + callee if self.is_style_api(modules, callee) => { + Some(Constant::Style(self.css_styles(modules, call))) + } _ => self.evaluate_stylex(modules, call), }, Expression::TaggedTemplateExpression(tagged) - if self.is_style_api(modules, &tagged.tag) => + if self.scoped.is_none() && self.is_style_api(modules, &tagged.tag) => { - Some(Constant::Style) + Some(Constant::Style(None)) } Expression::TSAsExpression(inner) => self.evaluate(modules, &inner.expression), Expression::TSSatisfiesExpression(inner) => self.evaluate(modules, &inner.expression), @@ -1544,6 +1918,30 @@ impl<'p, 'a> ModuleScope<'p, 'a> { } } +#[cfg(test)] +#[path = "local_exact_constants_w27_tests.rs"] +mod local_exact_constants_w27_tests; + +#[path = "local_rule_shapes_w27.rs"] +mod local_rule_shapes; + +#[path = "local_capture_reads_w27.rs"] +mod local_capture_reads; + +#[path = "bound_style_callbacks.rs"] +mod bound_style_callbacks; + +#[path = "bound_callback_captures.rs"] +mod bound_callback_captures; + +#[cfg(test)] +#[path = "bound_style_callbacks_tests.rs"] +mod bound_style_callbacks_tests; + +#[cfg(test)] +#[path = "bound_callback_alias_tests.rs"] +mod bound_callback_alias_tests; + fn set_entry(entries: &mut Vec<(String, Constant)>, key: String, value: Constant) { match entries.iter_mut().find(|(existing, _)| *existing == key) { Some(entry) => entry.1 = value, @@ -1685,6 +2083,12 @@ fn fold_binary(operator: BinaryOperator, left: &Constant, right: &Constant) -> O }; number.is_finite().then_some(Constant::Number(number)) } +#[derive(Clone, Copy, PartialEq, Eq)] +enum NumericRole { + ElementCssProp, + Other, +} + /// Replaces reads of constants where styles read them: primitives in the /// arguments of style APIs and in style props, objects and arrays where they /// are read as style objects, as a copy elsewhere would change their identity. @@ -1693,12 +2097,33 @@ struct Inline<'s, 'a> { ast_builder: &'s AstBuilder<'a>, scoping: &'s Scoping, symbols: &'s FxHashMap, + shapes: &'s FxHashMap>, + aliases: &'s FxHashMap, + deferred: &'s FxHashSet, + local_ends: FxHashMap, + invalid: &'s FxHashSet, style_roots: &'s FxHashSet<&'s str>, + css_props: &'s CssTakers<'s>, apis: &'s StyleApis<'s>, /// Inside what the build reads as style objects objects: bool, /// Inside the arguments of a style API or a style prop styles: bool, + /// Inside a `css` prop, whose numbers Emotion reads as `px` lengths + px: bool, + numeric_role: NumericRole, + /// Where a constant stands as a value or rules of a library reading + /// numbers as `px` lengths + marks: &'s crate::import_alias_visit::PxMarks, + /// Clone identities retain per-use pixel context without changing source spans. + pixel_callbacks: FxHashSet
, + /// How often a style read each binding below the top level, by what it + /// held + replaced: FxHashMap, + rejected: FxHashSet, + /// The names the `` child functions around take `css` and + /// `cx` by + class_names: Vec, } impl<'a> Inline<'_, 'a> { @@ -1707,6 +2132,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) => { @@ -1837,55 +2267,7 @@ impl<'a> Inline<'_, 'a> { } fn literal(&self, constant: &Constant) -> Option> { - let builder = self.ast_builder; - match constant { - Constant::String(value) => Some(Expression::new_string_literal( - SPAN, - Str::from_in(value.as_str(), builder.allocator()), - None, - builder, - )), - Constant::Number(value) => Some(Expression::new_numeric_literal( - SPAN, - *value, - None, - NumberBase::Decimal, - builder, - )), - Constant::Null => Some(Expression::new_null_literal(SPAN, builder)), - Constant::Bool(value) => Some(Expression::new_boolean_literal(SPAN, *value, builder)), - Constant::Record(entries) if self.objects => { - let mut properties = oxc_allocator::Vec::with_capacity_in(entries.len(), builder); - for (key, value) in entries.iter() { - properties.push(ObjectPropertyKind::new_object_property( - SPAN, - oxc_ast::ast::PropertyKind::Init, - oxc_ast::ast::PropertyKey::StringLiteral( - oxc_ast::ast::StringLiteral::boxed( - SPAN, - Str::from_in(key.as_str(), builder.allocator()), - None, - builder, - ), - ), - self.literal(value)?, - false, - false, - false, - builder, - )); - } - Some(Expression::new_object_expression(SPAN, properties, builder)) - } - Constant::Array(values) if self.objects => { - let mut elements = oxc_allocator::Vec::with_capacity_in(values.len(), builder); - for value in values.iter() { - elements.push(self.literal(value)?.into()); - } - Some(Expression::new_array_expression(SPAN, elements, builder)) - } - _ => None, - } + constant_literal(self.ast_builder, constant, self.objects) } fn reading_objects(&mut self, objects: bool, visit: impl FnOnce(&mut Self) -> T) -> T { @@ -1902,16 +2284,202 @@ impl<'a> Inline<'_, 'a> { self.styles = outer; result } + + /// `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 result = self.reading_styles(css, |inline| inline.reading_objects(css, visit)); + self.px = outer; + result + } +} + +/// `value` as the `px` length Emotion reads it as when it is a number +fn px_number<'a>(ast_builder: &AstBuilder<'a>, value: &mut Expression<'a>) { + if let Some(number) = crate::utils::js_number_literal(value) + && number != 0.0 + { + *value = Expression::new_string_literal( + SPAN, + Str::from_in(format!("{number}px").as_str(), ast_builder.allocator()), + None, + ast_builder, + ); + } +} + +/// `property` holding a number as the `px` length Emotion reads it as +fn px_value<'a>(ast_builder: &AstBuilder<'a>, property: &mut oxc_ast::ast::ObjectProperty<'a>) { + if property + .key + .static_name() + .is_some_and(|key| !crate::utils::keeps_bare_number(&key)) + { + px_number(ast_builder, &mut property.value); + } +} + +/// The rules `rules` with their numbers as the `px` lengths Emotion reads them +/// as, nested rules included +fn px_rules<'a>(ast_builder: &AstBuilder<'a>, rules: &mut Expression<'a>) { + if let Expression::ObjectExpression(object) = rules { + for property in &mut object.properties { + if let ObjectPropertyKind::ObjectProperty(property) = property + && property.key.static_name().is_none_or(|key| key != "vars") + { + px_value(ast_builder, property); + px_rules(ast_builder, &mut property.value); + } + } + } + if let Expression::ArrayExpression(array) = rules { + for element in &mut array.elements { + if let Some(expression) = element.as_expression_mut() { + px_rules(ast_builder, expression); + } + } + } +} + +/// `constant` written as a literal, objects and arrays too when `objects` +fn constant_literal<'a>( + builder: &AstBuilder<'a>, + constant: &Constant, + objects: bool, +) -> Option> { + match constant { + Constant::String(value) => Some(Expression::new_string_literal( + SPAN, + Str::from_in(value.as_str(), builder.allocator()), + None, + builder, + )), + Constant::Number(value) => Some(Expression::new_numeric_literal( + SPAN, + *value, + None, + NumberBase::Decimal, + builder, + )), + Constant::Null => Some(Expression::new_null_literal(SPAN, builder)), + Constant::Bool(value) => Some(Expression::new_boolean_literal(SPAN, *value, builder)), + Constant::Record(entries) if objects => { + let mut properties = oxc_allocator::Vec::with_capacity_in(entries.len(), builder); + for (key, value) in entries.iter() { + properties.push(ObjectPropertyKind::new_object_property( + SPAN, + oxc_ast::ast::PropertyKind::Init, + oxc_ast::ast::PropertyKey::StringLiteral(oxc_ast::ast::StringLiteral::boxed( + SPAN, + Str::from_in(key.as_str(), builder.allocator()), + None, + builder, + )), + constant_literal(builder, value, objects)?, + false, + false, + false, + builder, + )); + } + Some(Expression::new_object_expression(SPAN, properties, builder)) + } + Constant::Array(values) if objects => { + let mut elements = oxc_allocator::Vec::with_capacity_in(values.len(), builder); + for value in values.iter() { + elements.push(constant_literal(builder, value, objects)?.into()); + } + Some(Expression::new_array_expression(SPAN, elements, builder)) + } + _ => None, + } } impl<'a> VisitMut<'a> for Inline<'_, 'a> { + fn visit_formal_parameters(&mut self, parameters: &mut oxc_ast::ast::FormalParameters<'a>) { + let outer_px = std::mem::replace(&mut self.px, false); + walk_mut::walk_formal_parameters(self, parameters); + self.px = outer_px; + } + + fn visit_arrow_function_expression( + &mut self, + arrow: &mut oxc_ast::ast::ArrowFunctionExpression<'a>, + ) { + let outer = std::mem::replace(&mut self.numeric_role, NumericRole::Other); + walk_mut::walk_arrow_function_expression(self, arrow); + self.numeric_role = outer; + } + + fn visit_function( + &mut self, + function: &mut oxc_ast::ast::Function<'a>, + flags: oxc_syntax::scope::ScopeFlags, + ) { + let outer = std::mem::replace(&mut self.numeric_role, NumericRole::Other); + walk_mut::walk_function(self, function, flags); + self.numeric_role = outer; + } + fn visit_expression(&mut self, expression: &mut Expression<'a>) { if self.styles { + if let Expression::Identifier(identifier) = &*expression + && let Some(reference) = identifier.reference_id.get() + && let Some(symbol) = self.scoping.get_reference(reference).symbol_id() + && (self.invalid.contains(&symbol) + || self.local_ends.get(&symbol).is_some_and(|end| { + identifier.span.start < *end && !self.deferred.contains(&reference) + })) + { + self.rejected.insert(symbol); + } + if self.objects + && let Expression::Identifier(identifier) = &*expression + && let Some(reference) = identifier.reference_id.get() + && let Some(symbol) = self.scoping.get_reference(reference).symbol_id() + && self.local_ends.get(&symbol).is_some_and(|end| { + identifier.span.start >= *end || self.deferred.contains(&reference) + }) + && let Some(shape) = self.shapes.get(&symbol) + { + let root = match self.aliases.get(&symbol) { + Some(alias) => Expression::new_identifier( + identifier.span, + self.ast_builder.allocator().alloc_str(alias), + self.ast_builder, + ), + None => expression.clone_in_with_semantic_ids(self.ast_builder.allocator()), + }; + *expression = local_rule_shapes::expand(self.ast_builder, shape, &root); + *self.replaced.entry(symbol).or_default() += 1; + let outer = self.px; + self.px |= self.marks.contains(&(root.span().start, root.span().end)); + if self.px { + px_rules(self.ast_builder, expression); + } + self.visit_expression(expression); + self.px = outer; + return; + } if let Some(literal) = self .constant(expression) .and_then(|constant| self.literal(&constant)) { + let span = expression.span(); + let marked = self.marks.contains(&(span.start, span.end)); + if let Expression::Identifier(identifier) = &*expression + && let Some(reference) = identifier.reference_id.get() + && let Some(symbol) = self.scoping.get_reference(reference).symbol_id() + { + *self.replaced.entry(symbol).or_default() += 1; + } *expression = literal; + if marked { + px_number(self.ast_builder, expression); + } + if self.px || marked { + px_rules(self.ast_builder, expression); + } return; } if let Some(chosen) = self.chosen(expression) { @@ -1920,7 +2488,15 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { return; } } + let outer_px = self.px; + if matches!( + expression, + Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_) + ) { + self.px |= self.pixel_callbacks.contains(&expression.address()); + } walk_mut::walk_expression(self, expression); + self.px = outer_px; } fn visit_tagged_template_expression( @@ -1928,6 +2504,12 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { tagged: &mut oxc_ast::ast::TaggedTemplateExpression<'a>, ) { self.visit_expression(&mut tagged.tag); + if calls_one_of(&self.class_names, &tagged.tag) { + self.reading_css(true, |inline| { + inline.visit_template_literal(&mut tagged.quasi); + }); + return; + } let styles = is_style_root(self.style_roots, &tagged.tag); self.reading_styles(styles, |inline| { inline.visit_template_literal(&mut tagged.quasi); @@ -1940,22 +2522,80 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { }); } + fn visit_jsx_element(&mut self, element: &mut oxc_ast::ast::JSXElement<'a>) { + let calls = self.css_props.class_names_calls(element); + let taken = calls.len(); + self.class_names.extend(calls); + walk_mut::walk_jsx_element(self, element); + self.class_names.truncate(self.class_names.len() - taken); + } + fn visit_call_expression(&mut self, call: &mut oxc_ast::ast::CallExpression<'a>) { - self.visit_expression(&mut call.callee); + // Calls consume application inputs; only explicit style APIs read rules. + let outer_px = std::mem::replace(&mut self.px, false); + let outer_role = std::mem::replace(&mut self.numeric_role, NumericRole::Other); + self.reading_objects(false, |inline| inline.visit_expression(&mut call.callee)); + if calls_one_of(&self.class_names, &call.callee) { + self.reading_css(true, |inline| { + for argument in &mut call.arguments { + inline.visit_argument(argument); + } + }); + self.px = outer_px; + self.numeric_role = outer_role; + return; + } let objects = self.apis.reads(&call.callee); let styles = is_style_root(self.style_roots, &call.callee); + let css = self + .css_props + .property(call, |root| self.style_roots.contains(root)); self.reading_styles(styles, |inline| { inline.reading_objects(objects, |inline| { - for argument in &mut call.arguments { - inline.visit_argument(argument); + 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() { + let outer = inline.numeric_role; + inline.numeric_role = if at == css { + NumericRole::ElementCssProp + } else { + NumericRole::Other + }; + inline.reading_css(at == css, |inline| { + inline.visit_object_property_kind(property); + }); + inline.numeric_role = outer; + } + } + (_, argument) => inline.visit_argument(argument), + } } }); }); + self.px = outer_px; + self.numeric_role = outer_role; + } + + fn visit_new_expression(&mut self, call: &mut oxc_ast::ast::NewExpression<'a>) { + let outer = std::mem::replace(&mut self.numeric_role, NumericRole::Other); + self.reading_css(false, |inline| walk_mut::walk_new_expression(inline, call)); + self.numeric_role = outer; } fn visit_jsx_opening_element(&mut self, element: &mut oxc_ast::ast::JSXOpeningElement<'a>) { let styled = jsx_root(&element.name).is_some_and(|root| self.style_roots.contains(root)); + let element_name = &element.name; for attribute in &mut element.attributes { + if self.css_props.attribute(element_name, attribute, |root| { + self.style_roots.contains(root) + }) { + let outer = self.numeric_role; + self.numeric_role = NumericRole::ElementCssProp; + self.reading_css(true, |inline| inline.visit_jsx_attribute_item(attribute)); + self.numeric_role = outer; + continue; + } let objects = styled && match attribute { JSXAttributeItem::Attribute(attribute) => { @@ -1973,9 +2613,38 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { } fn visit_object_property(&mut self, property: &mut oxc_ast::ast::ObjectProperty<'a>) { + let span = property.value.span(); + let px = self.styles && (self.px || self.marks.contains(&(span.start, span.end))); + let outer = self.px; + if property.key.static_name().is_some_and(|key| key == "vars") { + self.px = false; + } walk_mut::walk_object_property(self, property); + self.px = outer; if property.shorthand && !matches!(property.value, Expression::Identifier(_)) { property.shorthand = false; } + if px && property.key.static_name().is_none_or(|key| key != "vars") { + px_value(self.ast_builder, property); + if self.numeric_role == NumericRole::ElementCssProp + && self.px + && property + .key + .static_name() + .is_some_and(|key| !crate::utils::keeps_bare_number(&key)) + && !matches!( + property.value, + Expression::ObjectExpression(_) + | Expression::ArrayExpression(_) + | Expression::ArrowFunctionExpression(_) + | Expression::FunctionExpression(_) + ) + && written_out(&property.value).is_none() + { + use oxc_allocator::TakeIn; + let value = property.value.take_in(self.ast_builder); + property.value = local_rule_shapes::number_length(self.ast_builder, value); + } + } } } diff --git a/libs/extractor/src/imported_styled.rs b/libs/extractor/src/imported_styled.rs new file mode 100644 index 000000000..1e39f1be3 --- /dev/null +++ b/libs/extractor/src/imported_styled.rs @@ -0,0 +1,1731 @@ +//! Styled components other modules define, read where a file extends one, +//! selects one in CSS text or gives one a `css` prop, so they compose exactly +//! as the ones the file defines itself. + +use std::collections::BTreeSet; +use std::rc::Rc; + +use oxc_allocator::{Allocator, GetAllocator}; +use oxc_ast::ast::{ + CallExpression, Declaration, Expression, ImportDeclarationSpecifier, JSXAttributeItem, + JSXAttributeName, JSXElementName, JSXOpeningElement, Program, Statement, +}; +use oxc_ast::builder::AstBuilder; +use oxc_ast_visit::{Visit, VisitMut, walk}; +use oxc_parser::{Parser, ParserReturn}; +use oxc_span::{GetSpan, SourceType, Span}; +use rustc_hash::{FxHashMap, FxHashSet}; + +use crate::css_prop::CssProp; +use crate::extractor::extract_style_from_styled::StyledDefinition; +use crate::import_alias_visit::{Aliased, transform_import_aliases_with_edits}; +use crate::utils::{is_vanilla_extract_file, unwrap_syntax_only}; +use crate::visit::DevupVisitor; +use crate::{ExtractOption, ModuleResolver}; + +/// What a module exports, which marks the components other files select +#[derive(Default)] +pub(crate) struct Exports { + /// Every name the module exports, in order + names: Vec, + /// The names each binding is exported as + bindings: FxHashMap>, + /// Whether `export default` exports a value no binding holds + anonymous_default: bool, +} + +impl Exports { + pub(crate) fn scan(program: &Program<'_>) -> Self { + let mut exports = Self::default(); + for statement in &program.body { + match statement { + Statement::ExportDeclaration(export) => { + if let Declaration::VariableDeclaration(declaration) = &export.declaration { + for declarator in &declaration.declarations { + if let Some(identifier) = declarator.id.get_binding_identifier() { + exports.add(identifier.name.as_str(), identifier.name.as_str()); + } + } + } + } + Statement::ExportNamedDeclaration(export) if !export.export_kind.is_type() => { + for specifier in export + .specifiers + .iter() + .filter(|specifier| !specifier.export_kind.is_type()) + { + exports.add( + specifier.local.name().as_str(), + specifier.exported.name().as_str(), + ); + } + } + Statement::ExportDefaultDeclaration(export) => { + match export.declaration.as_expression() { + Some(Expression::Identifier(identifier)) => { + exports.add(identifier.name.as_str(), "default"); + } + Some(_) => { + exports.anonymous_default = true; + exports.names.push("default".to_string()); + } + None => {} + } + } + _ => {} + } + } + exports.names.sort_unstable(); + exports.names.dedup(); + exports + } + + fn add(&mut self, binding: &str, exported: &str) { + self.names.push(exported.to_string()); + self.bindings + .entry(binding.to_string()) + .or_default() + .push(exported.to_string()); + } + + /// How many names the module exports + pub(crate) const fn count(&self) -> usize { + self.names.len() + } + + /// Where the exported `name` stands among the names the module exports + pub(crate) fn index(&self, name: &str) -> usize { + self.names + .binary_search_by(|exported| exported.as_str().cmp(name)) + .unwrap_or_default() + } + + /// The names the binding `binding` is exported as + pub(crate) fn of(&self, binding: &str) -> &[String] { + self.bindings.get(binding).map_or(&[], Vec::as_slice) + } + + pub(crate) fn bindings(&self) -> impl Iterator)> { + self.bindings.iter() + } + + pub(crate) const fn has_anonymous_default(&self) -> bool { + self.anonymous_default + } +} + +/// A styled component another module defines: the classes other styles +/// select it by, and what composing it takes when the build can read it +pub struct ImportedComponent<'a> { + pub markers: Vec, + pub definition: Option>, +} + +impl ImportedComponent<'_> { + /// This component in the arena of the file that reads it + pub fn clone_in<'b>(&self, allocator: &'b Allocator) -> ImportedComponent<'b> { + ImportedComponent { + markers: self.markers.clone(), + definition: self + .definition + .as_ref() + .map(|definition| definition.clone_in(allocator)), + } + } + + /// This component as another file can use it: composed only when its + /// definition holds nothing of the module defining it + fn shared(self, readable: bool) -> Self { + Self { + definition: self + .definition + .filter(|definition| readable && definition.portable()), + ..self + } + } +} + +type Components<'a> = FxHashMap>; + +/// The components a file imports, and the modules read to find them +#[derive(Default)] +pub(crate) struct Reading<'a> { + pub components: Components<'a>, + pub dependencies: BTreeSet, +} + +/// Read the styled components `program` extends, selects or gives a `css` +/// prop, from the modules it imports them from +pub(crate) fn read<'a>( + ast_builder: &AstBuilder<'a>, + program: &Program<'a>, + filename: &str, + option: &ExtractOption, + resolver: Option<&ModuleResolver>, + css_prop: CssProp, +) -> Reading<'a> { + let Some(resolver) = resolver else { + return Reading::default(); + }; + let allocator = ast_builder.allocator(); + let mut reader = Reader { + option, + resolver, + allocator, + modules: FxHashMap::default(), + reading: vec![filename.to_string()], + dependencies: BTreeSet::new(), + }; + let components = reader.imports(program, filename, css_prop, allocator); + Reading { + components, + dependencies: reader.dependencies, + } +} + +/// The components a module exports, by export name +type Module<'a> = Rc>; + +struct Reader<'r, 'a> { + option: &'r ExtractOption, + resolver: &'r ModuleResolver, + allocator: &'a Allocator, + modules: FxHashMap>>, + /// The modules being read, which one of them importing another in a + /// cycle cannot be read through + reading: Vec, + dependencies: BTreeSet, +} + +impl<'a> Reader<'_, 'a> { + /// The components of the module `specifier` names, as `importer` imports it + fn module(&mut self, specifier: &str, importer: &str) -> Option> { + let module = (self.resolver)(specifier, importer)?; + self.dependencies.insert(module.path.clone()); + if let Some(read) = self.modules.get(&module.path) { + return read.clone(); + } + if self.reading.contains(&module.path) { + return None; + } + self.reading.push(module.path.clone()); + let read = self.read(&module.path, &module.code).map(Rc::new); + self.reading.pop(); + self.modules.insert(module.path, read.clone()); + read + } + + /// 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; + } + let Aliased { + code: transformed, + css_prop, + px, + .. + } = transform_import_aliases_with_edits( + code, + path, + &self.option.package, + &self.option.import_aliases, + ); + let sub = Allocator::default(); + let ParserReturn { + mut program, + fatal_error, + .. + } = Parser::new(&sub, &transformed, SourceType::from_path(path).ok()?).parse(); + if fatal_error { + return None; + } + let ast_builder = AstBuilder::new(&sub); + let inlined = crate::imported_constants::inline_constants( + &ast_builder, + &mut program, + path, + self.option, + Some(self.resolver), + css_prop, + &px, + ); + self.dependencies.extend(inlined.dependencies); + let imports = self.imports(&program, path, css_prop, &sub); + let mut exports = self.reexports(&program, path); + let (bucket, global, _) = crate::resolve_css_target(path, self.option); + let mut visitor = DevupVisitor::new( + &sub, + path, + &self.option.package, + Vec::new(), + if global { None } else { Some(bucket) }, + ); + 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); + visitor.import_styled(imports); + visitor.visit_program(&mut program); + 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( + 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) if !export.export_kind.is_type() => { + if let Some(module) = self.module(&export.source.value, path) { + 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(), + component.clone_in(self.allocator), + ); + } + } + } + } + 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") + { + exports + .entry(name.clone()) + .or_insert_with(|| component.clone_in(self.allocator)); + } + } + } + _ => {} + } + } + exports + } + + /// The components `program` imports that it extends, selects or gives a + /// `css` prop, in the arena of the file that reads them + fn imports<'b>( + &mut self, + program: &Program<'_>, + importer: &str, + css_prop: CssProp, + allocator: &'b Allocator, + ) -> Components<'b> { + let imported = imported_bindings(program, &self.option.package); + let scoping = oxc_semantic::SemanticBuilder::new() + .build(program) + .semantic + .into_scoping(); + let compat = format!("{}/compat", self.option.package); + let mut used = Used { + scoping: &scoping, + css_takers: crate::css_prop::CssTakers::new(program, css_prop, &compat), + styled: imported.styled, + css_prop: css_prop != CssProp::Off, + names: FxHashSet::default(), + }; + used.visit_program(program); + crate::style_values::selectors(program, &mut |expression| { + used.select(expression); + }); + used.names.extend( + Exports::scan(program) + .bindings() + .map(|(binding, _)| binding.clone()), + ); + let mut names: Vec = used.names.into_iter().collect(); + names.sort_unstable(); + let mut components = FxHashMap::default(); + for name in names { + let found = match name.split_once('.') { + Some((namespace, member)) => imported + .bindings + .get(namespace) + .filter(|(_, export)| export == NAMESPACE) + .map(|(source, _)| (source, member)), + None => imported + .bindings + .get(&name) + .map(|(source, export)| (source, export.as_str())), + }; + if let Some((source, export)) = found + && let Some(module) = self.module(source, importer) + && let Some(component) = module.get(export) + { + components.insert(name, component.clone_in(allocator)); + } + } + components + } +} + +/// What a namespace import is imported as +const NAMESPACE: &str = "*"; + +/// What a program imports +struct Imported { + /// Each binding another module's export, with that module and the export + bindings: FxHashMap, + /// The bindings of the package's `styled` + styled: FxHashSet, +} + +fn imported_bindings(program: &Program<'_>, package: &str) -> Imported { + let mut imported = Imported { + bindings: FxHashMap::default(), + styled: FxHashSet::default(), + }; + for statement in &program.body { + let Statement::ImportDeclaration(import) = statement else { + continue; + }; + if import.import_kind.is_type() { + continue; + } + let source = import.source.value.as_str(); + let from_package = source.starts_with(package); + for specifier in import.specifiers.iter().flatten() { + let (local, export) = match specifier { + ImportDeclarationSpecifier::ImportSpecifier(specifier) + if !specifier.import_kind.is_type() => + { + ( + specifier.local.name.to_string(), + specifier.imported.name().to_string(), + ) + } + ImportDeclarationSpecifier::ImportDefaultSpecifier(specifier) => { + (specifier.local.name.to_string(), "default".to_string()) + } + ImportDeclarationSpecifier::ImportNamespaceSpecifier(specifier) => { + (specifier.local.name.to_string(), NAMESPACE.to_string()) + } + ImportDeclarationSpecifier::ImportSpecifier(_) => continue, + }; + if !from_package { + imported + .bindings + .insert(local, (source.to_string(), export)); + } else if export == "styled" { + imported.styled.insert(local); + } + } + } + imported +} + +/// A binding or a member of one, as the program writes it +fn written_name(expression: &Expression<'_>) -> Option { + match unwrap_syntax_only(expression) { + Expression::Identifier(identifier) => Some(identifier.name.to_string()), + 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, 'p> { + scoping: &'s oxc_semantic::Scoping, + css_takers: crate::css_prop::CssTakers<'p>, + styled: FxHashSet, + css_prop: bool, + names: FxHashSet, +} + +struct ValueSites<'s> { + scoping: &'s oxc_semantic::Scoping, + styled: FxHashSet, + spans: Vec, +} + +impl ValueSites<'_> { + fn is_styled(&self, expression: &Expression<'_>) -> bool { + match unwrap_syntax_only(expression) { + Expression::Identifier(reference) => reference + .reference_id + .get() + .and_then(|reference| self.scoping.get_reference(reference).symbol_id()) + .is_some_and(|symbol| self.styled.contains(&symbol)), + Expression::StaticMemberExpression(member) => self.is_styled(&member.object), + Expression::CallExpression(call) => self.is_styled(&call.callee), + _ => false, + } + } +} + +impl<'a> Visit<'a> for ValueSites<'_> { + fn visit_call_expression(&mut self, call: &CallExpression<'a>) { + if self.is_styled(&call.callee) { + self.spans.extend( + call.arguments + .iter() + .filter_map(|argument| argument.as_expression()) + .map(GetSpan::span), + ); + } + walk::walk_call_expression(self, call); + } + + fn visit_tagged_template_expression( + &mut self, + tag: &oxc_ast::ast::TaggedTemplateExpression<'a>, + ) { + if self.is_styled(&tag.tag) { + self.spans + .extend(tag.quasi.expressions.iter().map(GetSpan::span)); + } + walk::walk_tagged_template_expression(self, tag); + } +} + +impl Used<'_, '_> { + fn record(&mut self, reference: &oxc_ast::ast::IdentifierReference<'_>, name: String) { + if let Some(symbol) = reference + .reference_id + .get() + .and_then(|reference| self.scoping.get_reference(reference).symbol_id()) + && self.scoping.get_root_binding(reference.name) == Some(symbol) + && self.scoping.symbol_flags(symbol).is_import() + { + self.names.insert(name); + } + } + + fn select(&mut self, expression: &Expression<'_>) { + match unwrap_syntax_only(expression) { + Expression::TemplateLiteral(template) => { + for expression in &template.expressions { + self.select(expression); + } + } + Expression::Identifier(reference) => { + if let Some(name) = written_name(expression) { + self.record(reference, name); + } + } + Expression::StaticMemberExpression(_) | Expression::ComputedMemberExpression(_) => { + if let Some((object, _)) = crate::style_values::component_member(expression) + && let Expression::Identifier(reference) = unwrap_syntax_only(object) + && let Some(name) = written_name(expression) + { + self.record(reference, name); + } + } + _ => {} + } + } +} + +impl<'a> Visit<'a> for Used<'_, '_> { + fn visit_call_expression(&mut self, call: &CallExpression<'a>) { + if self.css_takers.property(call, |_| false).is_some() + && let Some(element) = call + .arguments + .first() + .and_then(|argument| argument.as_expression()) + { + self.select(element); + } + let name = match unwrap_syntax_only(&call.callee) { + Expression::Identifier(callee) if self.styled.contains(callee.name.as_str()) => call + .arguments + .first() + .and_then(|first| first.as_expression()), + Expression::StaticMemberExpression(member) + if member.property.name == "withComponent" => + { + Some(&member.object) + } + _ => None, + }; + 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 + && element.attributes.iter().any(|attribute| { + matches!(attribute, JSXAttributeItem::Attribute(attribute) + if matches!(&attribute.name, JSXAttributeName::Identifier(attribute) if attribute.name == "css")) + }) + { + 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); + } +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used)] +mod tests { + use std::collections::{BTreeSet, HashMap}; + + use insta::assert_debug_snapshot; + use rstest::rstest; + use serial_test::serial; + + use super::*; + use crate::{ExtractOutput, ImportAlias, ResolvedModule, extract_with_modules}; + + 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 extract_in(files: &[(&str, &str)], entry: &str) -> Result { + let modules: Vec<(String, String)> = files + .iter() + .map(|(path, code)| ((*path).to_string(), (*code).to_string())) + .collect(); + let resolver = move |specifier: &str, importer: &str| { + let directory = importer + .rsplit_once('/') + .map_or("", |(directory, _)| directory); + let path = format!("{directory}/{}", specifier.trim_start_matches("./")); + modules + .iter() + .find(|(file, _)| { + ["", ".ts", ".tsx", ".css.ts", "/index.ts"] + .iter() + .any(|extension| format!("{path}{extension}") == *file) + }) + .map(|(file, code)| ResolvedModule { + path: file.clone(), + code: code.clone(), + }) + }; + let code = files.iter().find(|(path, _)| *path == entry).unwrap().1; + extract_with_modules(entry, code, option(), false, &resolver).map_err(|e| e.to_string()) + } + + fn reset() { + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + } + + fn render(files: &[(&str, &str)], entry: &str) -> String { + reset(); + match extract_in(files, entry) { + Ok(output) => { + let styles: BTreeSet = output + .styles + .iter() + .map(|style| format!("{style:?}")) + .collect(); + format!("{}\n{styles:#?}\n{:?}", output.code, output.dependencies) + } + Err(error) => error, + } + } + + const STYLED: &str = "import styled from '@emotion/styled';\n"; + + #[test] + #[serial] + fn compiled_namespace_css_when_literal_inlines_composed_styles_and_marker() { + // Given + let base = format!("{STYLED}export const Child = styled.span`padding: 4px; color: red;`;"); + let app = format!( + "{STYLED}import * as UI from './base'; import {{ jsx }} from '@emotion/react/jsx-runtime'; const Anchor = styled.div`${{UI.Child}} {{ margin: 0; }}`; export const P = jsx(UI['Child'], {{ css: {{ color: 'blue' }} }});" + ); + reset(); + // When + let output = extract_in( + &[("/src/base.ts", &base), ("/src/app.tsx", &app)], + "/src/app.tsx", + ) + .unwrap(); + // Then + assert!(output.code.contains("jsx(\"span\""), "{}", output.code); + assert!(!output.code.contains("jsx(UI["), "{}", output.code); + assert!(output.code.contains("className:"), "{}", output.code); + let styles: BTreeSet<_> = output + .styles + .iter() + .filter_map(|style| match style { + crate::ExtractStyleValue::Static(style) => { + Some((style.property.as_str(), style.value.as_str())) + } + _ => None, + }) + .collect(); + assert!( + styles.contains(&("padding", "4px")) && styles.contains(&("color", "blue")), + "{styles:?}" + ); + let selector = output + .styles + .iter() + .find_map(|style| { + let text = format!("{style:?}"); + let (_, rest) = text.split_once("& .")?; + rest.split(|c: char| !(c.is_alphanumeric() || c == '-' || c == '_')) + .find(|word| word.contains("--")) + .map(str::to_string) + }) + .unwrap(); + assert!(output.code.contains(&selector), "{}", output.code); + } + + #[rstest] + #[case("jsx(UI.Child, { css: { color: 'blue' }, as: target })", true)] + #[case("jsx(UI.Child, { css: { color: 'blue' }, ...props })", true)] + #[case("(UI) => jsx(UI.Child, { css: { color: 'blue' } })", false)] + #[serial] + fn compiled_namespace_css_when_opaque_or_shadowed_has_literal_parity( + #[case] body: &str, + #[case] overlap: bool, + ) { + // Given + let base = format!("{STYLED}export const Child = styled.span`color: red;`;"); + let app = format!( + "import * as UI from './base'; import {{ jsx }} from '@emotion/react/jsx-runtime'; export const P = {body};" + ); + reset(); + let expected = extract_in( + &[("/src/base.ts", &base), ("/src/app.tsx", &app)], + "/src/app.tsx", + ); + reset(); + let computed = app.replace("UI.Child", "UI['Child']"); + // When + let output = extract_in( + &[("/src/base.ts", &base), ("/src/app.tsx", &computed)], + "/src/app.tsx", + ); + // Then + if overlap { + let expected = expected.unwrap_err(); + let error = output.unwrap_err(); + assert!(expected.starts_with("/src/app.tsx:1:114:"), "{expected}"); + assert!(error.starts_with("/src/app.tsx:1:117:"), "{error}"); + assert_eq!( + error + .split_once(": ") + .unwrap() + .1 + .replace("UI[\"Child\"]", "UI.Child"), + expected.split_once(": ").unwrap().1 + ); + } else { + let output = output.unwrap(); + assert_eq!(output.styles, expected.unwrap().styles); + assert!(output.code.contains("jsx(UI["), "{}", output.code); + } + } + + #[test] + #[serial] + fn imported_computation_when_exact_retains_values_and_dependencies() { + // Given + let base = format!( + "{STYLED}import {{ size }} from './values'; const scaled = n => n * 4; export const Rules = styled.div({{ padding: scaled(size), color: 'red' }});" + ); + let app = format!( + "{STYLED}import {{ Rules }} from './base'; export const A = styled(Rules)`color: blue;`;" + ); + reset(); + // When + let output = extract_in( + &[ + ("/src/values.ts", "export const size = 2;"), + ("/src/base.ts", &base), + ("/src/app.tsx", &app), + ], + "/src/app.tsx", + ) + .unwrap(); + // Then + let mut styles: Vec<(&str, &str)> = output + .styles + .iter() + .map(|style| match style { + crate::ExtractStyleValue::Static(style) => { + (style.property.as_str(), style.value.as_str()) + } + other => panic!("expected static style, got {other:?}"), + }) + .collect(); + styles.sort_unstable(); + assert_eq!(styles, [("color", "blue"), ("padding", "8px")]); + assert!(output.code.contains("DevupAs = \"div\""), "{}", output.code); + assert_eq!(output.dependencies, ["/src/base.ts", "/src/values.ts"]); + } + + #[test] + #[serial] + fn imported_attrs_when_sibling_computation_is_exact_remain_portable() { + // Given + let base = format!( + "{STYLED}const scaled = n => n * 4; const size = 2; export const Rules = styled.div({{ padding: scaled(size) }}); export const Attrs = styled.div.attrs({{ id: 'known' }})`margin: 1px;`;" + ); + let app = format!( + "{STYLED}import {{ Attrs }} from './base'; export const A = styled(Attrs)`color: blue;`;" + ); + reset(); + // When + let output = extract_in( + &[("/src/base.ts", &base), ("/src/app.tsx", &app)], + "/src/app.tsx", + ) + .unwrap(); + // Then + assert!(output.code.contains("DevupAs = \"div\""), "{}", output.code); + assert!(output.code.contains("id: \"known\""), "{}", output.code); + assert_eq!(output.styles.len(), 2); + assert_eq!(output.dependencies, ["/src/base.ts"]); + } + + #[rstest] + #[case("styled.div({ padding: scaled(size), color: 'red' })")] + #[case("styled.div`padding: ${scaled(size)}px; color: red;`")] + #[case("styled.div(make(size))")] + #[serial] + fn imported_computation_when_rules_use_different_static_forms_is_exact(#[case] factory: &str) { + // Given + let base = format!( + "{STYLED}const __devupImportedValues0 = 'reserved'; externalEffect(); const scaled = n => n * 4; const make = n => ({{ padding: n * 4, color: 'red' }}); const size = 2; export const Rules = {factory};" + ); + let app = format!( + "{STYLED}import {{ Rules }} from './base'; export const A = styled(Rules)`color: blue;`;" + ); + reset(); + // When + let output = extract_in( + &[("/src/base.ts", &base), ("/src/app.tsx", &app)], + "/src/app.tsx", + ) + .unwrap(); + // Then + let mut styles: Vec<(&str, &str)> = output + .styles + .iter() + .map(|style| match style { + crate::ExtractStyleValue::Static(style) => { + (style.property.as_str(), style.value.as_str()) + } + other => panic!("expected static style, got {other:?}"), + }) + .collect(); + styles.sort_unstable(); + assert_eq!(styles, [("color", "blue"), ("padding", "8px")]); + assert!(output.code.contains("DevupAs = \"div\""), "{}", output.code); + assert!( + !output.code.contains("__devupImportedValues"), + "{}", + output.code + ); + } + + #[rstest] + #[case("const scaled = n => n * 4; const size = runtimeSize;", "scaled(size)")] + #[case("const scaled = n => n * 4; let size = 2; size = 3;", "scaled(size)")] + #[case( + "const scaled = n => n * 4; const size = Math.random();", + "scaled(size)" + )] + #[case( + "const scaled = n => { externalEffect(); return n * 4; }; const size = 2;", + "scaled(size)" + )] + #[case( + "const box = { size: 2 }; box.size = 3; const scaled = n => n * 4;", + "scaled(box.size)" + )] + #[serial] + fn imported_computation_when_runtime_or_mutating_remains_opaque( + #[case] scope: &str, + #[case] value: &str, + ) { + // Given + let base = + format!("{STYLED}{scope} export const Rules = styled.div({{ padding: {value} }});"); + let app = format!( + "{STYLED}import {{ Rules }} from './base'; export const A = styled(Rules)`color: blue;`;" + ); + reset(); + // When + let output = extract_in( + &[("/src/base.ts", &base), ("/src/app.tsx", &app)], + "/src/app.tsx", + ) + .unwrap(); + // Then + assert!(output.code.contains("DevupAs = Rules"), "{}", output.code); + assert_eq!(output.styles.len(), 1); + assert_eq!(output.dependencies, ["/src/base.ts"]); + } + + #[rstest] + #[case("export const P = styled.div`${UI.Child} { margin: 0; }`;")] + #[case("export const P = styled.div({ [UI.Child]: { margin: 0 } });")] + #[case("export const P = styled.div({ [`& ${UI.Child}`]: { margin: 0 } });")] + #[case("export const P = styled(UI.Child)`color: blue;`;")] + #[case("export const P = UI.Child.withComponent('section');")] + #[case( + "import { jsx } from '@emotion/react/jsx-runtime'; export const P = jsx(UI.Child, { css: { color: 'blue' } });" + )] + #[serial] + fn namespace_computed_when_literal_matches_dot_access(#[case] body: &str) { + // Given + let base = format!("{STYLED}export const Child = styled.div`padding: 4px;`;"); + let dot = format!("{STYLED}import * as UI from './base';\n{body}"); + let computed = dot.replace("UI.Child", "UI['Child']"); + reset(); + let expected = extract_in( + &[("/src/base.ts", &base), ("/src/app.tsx", &dot)], + "/src/app.tsx", + ) + .unwrap(); + reset(); + // When + let output = extract_in( + &[("/src/base.ts", &base), ("/src/app.tsx", &computed)], + "/src/app.tsx", + ) + .unwrap(); + // Then + assert_eq!(output.styles, expected.styles); + assert_eq!(output.dependencies, expected.dependencies); + assert_eq!(output.code, expected.code); + } + + #[rstest] + #[case("export const P = (UI) => styled.div`${UI['Child']} { margin: 0; }`;")] + #[case("export const P = styled.div`${UI[key]} { margin: 0; }`;")] + #[case("export const P = styled.div({ [UI[key]]: { margin: 0 } });")] + #[serial] + fn namespace_computed_when_shadowed_or_unknown_reports_error(#[case] body: &str) { + // Given + let base = format!("{STYLED}export const Child = styled.div`padding: 4px;`;"); + let app = format!( + "{STYLED}import * as UI from './base'; const Anchor = styled.div`${{UI.Child}} {{ margin: 0; }}`;\n{body}" + ); + reset(); + // When + let error = extract_in( + &[("/src/base.ts", &base), ("/src/app.tsx", &app)], + "/src/app.tsx", + ) + .unwrap_err(); + // Then + assert!(error.starts_with("/src/app.tsx:3:"), "{error}"); + assert!(error.contains("UI["), "{error}"); + } + + #[test] + #[serial] + fn namespace_selectors_when_imported_match_named_selectors() { + let base = format!("{STYLED}export const Child = styled.div`color: red;`;"); + for body in [ + "export const P = styled.div`${Child} { margin: 0; }`;", + "export const P = styled.div({ [Child]: { margin: 0 } });", + "export const P = styled.div({ [`&:hover ${Child}`]: { margin: 0 } });", + ] { + let named = format!("{STYLED}import {{ Child }} from './base';\n{body}"); + let namespace = format!( + "{STYLED}import * as UI from './base';\n{}", + body.replace("Child", "UI.Child") + ); + reset(); + let named = extract_in( + &[("/src/base.ts", &base), ("/src/app.tsx", &named)], + "/src/app.tsx", + ) + .unwrap(); + reset(); + let namespace = extract_in( + &[("/src/base.ts", &base), ("/src/app.tsx", &namespace)], + "/src/app.tsx", + ) + .unwrap(); + assert_eq!(namespace.styles, named.styles, "{body}"); + assert_eq!(namespace.dependencies, named.dependencies); + assert_eq!(namespace.dependencies, ["/src/base.ts"]); + } + } + + #[test] + #[serial] + fn namespace_css_props_when_imported_match_named_props() { + let base = format!("{STYLED}export const Child = styled.div`color: red; padding: 4px;`;"); + let header = "/** @jsxImportSource @emotion/react */\n"; + for body in [ + "export const C = () => ;", + "export const C = () => ;", + "export const C = () => ;", + "import { jsx } from 'react/jsx-runtime'; export const C = () => jsx(Child, { css: { color: 'blue' } });", + ] { + let named = format!("{header}import {{ Child }} from './base';\n{body}"); + let namespace = format!( + "{header}import * as UI from './base';\n{}", + body.replace("Child", "UI.Child") + ); + reset(); + let named = extract_in( + &[("/src/base.ts", &base), ("/src/app.tsx", &named)], + "/src/app.tsx", + ); + reset(); + let namespace = extract_in( + &[("/src/base.ts", &base), ("/src/app.tsx", &namespace)], + "/src/app.tsx", + ); + match (named, namespace) { + (Ok(named), Ok(namespace)) => { + assert_eq!(namespace.styles, named.styles, "{body}"); + let colors: Vec<&str> = namespace + .styles + .iter() + .filter_map(|style| match style { + crate::ExtractStyleValue::Static(style) + if style.property == "color" => + { + Some(style.value.as_str()) + } + _ => None, + }) + .collect(); + assert_eq!(colors, ["blue"]); + assert_eq!(namespace.dependencies, named.dependencies); + assert_eq!( + namespace.code.replace("import * as UI", "import { Child }"), + named.code + ); + } + (Err(named), Err(namespace)) => { + assert!(namespace.starts_with("/src/app.tsx:")); + let named_location = + named.split_once(": ").unwrap().0.rsplit_once(':').unwrap(); + let namespace_location = namespace + .split_once(": ") + .unwrap() + .0 + .rsplit_once(':') + .unwrap(); + assert_eq!(namespace_location.0, named_location.0); + assert_eq!( + namespace_location.1.parse::().unwrap(), + named_location.1.parse::().unwrap() + 3 + ); + assert_eq!( + namespace + .split_once(": ") + .unwrap() + .1 + .replace("UI.Child", "Child"), + named.split_once(": ").unwrap().1 + ); + } + (named, namespace) => panic!("{body}: {named:?} != {namespace:?}"), + } + } + } + + #[test] + #[serial] + fn namespace_reexports_when_selected_preserve_defining_markers() { + let base = format!("{STYLED}export const Child = styled.div`color: red;`;"); + let forward = "export { Child as Renamed } from './base';"; + let named = format!( + "{STYLED}import {{ Renamed }} from './forward'; export const P = styled.div`${{Renamed}} {{ margin: 0; }}`;" + ); + let namespace = format!( + "{STYLED}import * as UI from './forward'; export const P = styled.div`${{UI.Renamed}} {{ margin: 0; }}`;" + ); + reset(); + let named = extract_in( + &[ + ("/src/base.ts", &base), + ("/src/forward.ts", forward), + ("/src/app.tsx", &named), + ], + "/src/app.tsx", + ) + .unwrap(); + reset(); + let namespace = extract_in( + &[ + ("/src/base.ts", &base), + ("/src/forward.ts", forward), + ("/src/app.tsx", &namespace), + ], + "/src/app.tsx", + ) + .unwrap(); + assert_eq!(namespace.styles, named.styles); + assert_eq!(namespace.dependencies, ["/src/base.ts", "/src/forward.ts"]); + } + + #[test] + #[serial] + fn namespace_css_props_when_shadowed_keep_the_runtime_component() { + let base = format!("{STYLED}export const Child = styled.div`color: red; padding: 4px;`;"); + for body in [ + "export const C = (UI) => ;", + "import { jsx } from 'react/jsx-runtime'; export const C = (UI) => jsx(UI.Child, { css: { color: 'blue' } });", + ] { + let app = format!( + "/** @jsxImportSource @emotion/react */\nimport * as UI from './base';\nexport const A = ;\n{body}" + ); + reset(); + let output = extract_in( + &[("/src/base.ts", &base), ("/src/app.tsx", &app)], + "/src/app.tsx", + ) + .unwrap(); + assert!(output.code.contains("UI.Child")); + assert_eq!(output.styles.len(), 3); + assert!( + output + .styles + .iter() + .all(|style| !format!("{style:?}").contains("4px")) + ); + } + } + + #[test] + #[serial] + fn namespace_selectors_when_shadowed_are_not_imported_markers() { + let base = format!("{STYLED}export const Child = styled.div`color: red;`;"); + for body in [ + "export const P = (UI) => styled.div`${UI.Child} { margin: 0; }`;", + "export const P = (UI) => styled.div({ [UI.Child]: { margin: 0 } });", + "export const P = (UI) => styled.div({ [`& ${UI.Child}`]: { margin: 0 } });", + ] { + let app = format!( + "{STYLED}import * as UI from './base';\nconst Anchor = styled.div`${{UI.Child}} {{ padding: 0; }}`;\n{body}" + ); + reset(); + let error = extract_in( + &[("/src/base.ts", &base), ("/src/app.tsx", &app)], + "/src/app.tsx", + ) + .unwrap_err(); + assert!(error.starts_with("/src/app.tsx:"), "{error}"); + assert!(error.contains("UI.Child"), "{error}"); + } + } + + #[test] + #[serial] + fn namespace_css_props_when_no_value_export_exists_do_not_inherit_styles() { + let base = format!("{STYLED}export const Child = styled.div`padding: 4px;`;"); + for import in [ + "import * as UI from './base';", + "import type * as UI from './base';", + "import { Child as UI } from './base';", + "import { type Child as UI } from './base';", + ] { + let member = if import == "import * as UI from './base';" { + "Missing" + } else { + "Child" + }; + let app = format!( + "/** @jsxImportSource @emotion/react */\n{import}\nexport const C = () => ;" + ); + reset(); + let output = extract_in( + &[("/src/base.ts", &base), ("/src/app.tsx", &app)], + "/src/app.tsx", + ) + .unwrap(); + assert!(output.code.contains(&format!("UI.{member}"))); + assert_eq!(output.styles.len(), 1); + } + } + + #[test] + #[serial] + fn module_definitions_when_inlining_reports_invalid_locals_are_opaque() { + let allocator = Allocator::default(); + let option = option(); + let resolver = |_: &str, _: &str| None; + let mut reader = Reader { + option: &option, + resolver: &resolver, + allocator: &allocator, + modules: FxHashMap::default(), + reading: Vec::new(), + dependencies: BTreeSet::new(), + }; + let code = format!( + "{STYLED}import {{ css }} from '@devup-ui/react'; export const Child = styled.div`padding: 4px;`; export function bad() {{ const local = {{ color: 'red' }}; expose(local); return css(local); }}" + ); + reset(); + let module = reader.read("/src/base.ts", &code).unwrap(); + assert!(module["Child"].definition.is_none()); + assert_eq!(module["Child"].markers.len(), 1); + } + + #[test] + #[serial] + fn namespace_type_reexports_when_given_css_do_not_become_value_definitions() { + let base = format!("{STYLED}export const Child = styled.div`padding: 4px;`;"); + let app = "/** @jsxImportSource @emotion/react */\nimport * as UI from './forward'; export const C = () => ;"; + for forward in [ + "export type { Child } from './base';", + "export { type Child } from './base';", + "export type * from './base';", + ] { + reset(); + let output = extract_in( + &[ + ("/src/base.ts", &base), + ("/src/forward.ts", forward), + ("/src/app.tsx", app), + ], + "/src/app.tsx", + ) + .unwrap(); + assert!(output.code.contains(" (p.big ? '20px' : '10px')};`; +export const Pick = styled.p.withConfig({ shouldForwardProp: (prop) => prop !== 'tone' })`color: ${(p) => p.tone};`; +export default styled.span`margin: 1px;`; +"; + + fn base_module() -> String { + format!("{STYLED}{BASE_BODY}") + } + + fn consumers<'a>(base: &'a str, bodies: &[&'a str]) -> Vec { + bodies + .iter() + .map(|body| { + render( + &[ + ("/src/base.ts", base), + ( + "/src/app.tsx", + &format!( + "{STYLED}import Span, {{ Base, Dyn, Pick }} from './base';\n{body}" + ), + ), + ], + "/src/app.tsx", + ) + }) + .collect() + } + + const COMPOSITIONS: &[&str] = &[ + "export const A = styled(Base)`color: blue;`;", + "export const A = styled(Base)({ color: 'blue', padding: 8 });", + "export const A = styled(Base).attrs({ role: 'note' })`color: blue;`;", + "export const A = styled(Base).attrs((p) => ({ title: p.title }))`color: blue;`;", + "export const A = styled(Dyn)`color: blue;`;", + "export const A = styled(Pick)`margin: 0;`;", + "export const A = styled(Span)`color: blue;`;", + "export const A = styled(Base)`color: blue;`;\nexport const B = styled(A)`padding: 0;`;", + "export const W = Dyn.withComponent('section');\nexport const V = Base.withComponent(Span);", + "export const A = styled(Base)`color: blue;`;\nexport const C = () => ;", + ]; + + #[test] + #[serial] + fn imported_components_compose_like_local_ones() { + assert_debug_snapshot!(consumers(&base_module(), COMPOSITIONS)); + } + + const DEFINITIONS: [(&str, &str); 4] = [ + ( + "Base", + "export const Base = styled.div`color: red; padding: 4px;`;\n", + ), + ( + "Dyn", + "export const Dyn = styled.button`font-size: ${(p) => (p.big ? '20px' : '10px')};`;\n", + ), + ( + "Pick", + "export const Pick = styled.p.withConfig({ shouldForwardProp: (prop) => prop !== 'tone' })`color: ${(p) => p.tone};`;\n", + ), + ("Span", "export default styled.span`margin: 1px;`;\n"), + ]; + + #[test] + #[serial] + fn imported_components_give_no_style_local_ones_do_not() { + let pragma = "/** @jsxImportSource @emotion/react */\n"; + let css_props = [ + "export const C = () => ;", + "export const C = () => ;", + ]; + for body in COMPOSITIONS.iter().chain(&css_props) { + let header = if body.contains("css=") { pragma } else { "" }; + let used: Vec<&(&str, &str)> = DEFINITIONS + .iter() + .filter(|(name, _)| body.contains(name)) + .collect(); + let module: String = used.iter().map(|(_, definition)| *definition).collect(); + let named: Vec<&str> = used + .iter() + .map(|(name, _)| *name) + .filter(|name| *name != "Span") + .collect(); + let default = if used.iter().any(|(name, _)| *name == "Span") { + "Span, " + } else { + "" + }; + reset(); + let imported = extract_in( + &[ + ("/src/base.ts", &format!("{STYLED}{module}")), + ( + "/src/app.tsx", + &format!( + "{header}{STYLED}import {default}{{ {} }} from './base';\n{body}", + named.join(", ") + ), + ), + ], + "/src/app.tsx", + ) + .unwrap(); + reset(); + let local_source = format!( + "{header}{STYLED}{}\n{body}", + module.replace("export default styled.span", "const Span = styled.span") + ); + let local = extract_in(&[("/src/app.tsx", &local_source)], "/src/app.tsx").unwrap(); + assert!(imported.styles.is_subset(&local.styles), "{body}"); + assert_eq!(imported.dependencies, ["/src/base.ts"]); + } + } + #[test] + #[serial] + fn imported_components_give_css_props_what_local_ones_give() { + let pragma = "/** @jsxImportSource @emotion/react */\n"; + let bodies = [ + "export const C = () => ;", + "export const C = () => ;", + "export const C = () => ;", + "export const C = ({ rest }) => ;", + ]; + let outputs: Vec = bodies + .iter() + .map(|body| { + render( + &[ + ("/src/base.ts", &base_module()), + ( + "/src/app.tsx", + &format!("{pragma}import {{ Base, Dyn }} from './base';\n{body}"), + ), + ], + "/src/app.tsx", + ) + }) + .collect(); + assert_debug_snapshot!(outputs); + } + + #[test] + #[serial] + fn imported_components_are_selected_by_the_class_their_module_gives() { + let bodies = [ + "export const P = styled.div`${Base} { margin: 0; } ${Dyn}:hover & { color: red; }`;", + "export const P = styled.div({ [Base]: { margin: 0 }, [`&:focus ${Dyn}`]: { color: 'red' } });", + "export const P = styled.div`${Span} + ${Pick} { margin: 0; }`;", + "export const P = styled.div`${Base} { margin: 0; }`;\nexport const Q = styled(Base)`color: blue;`;", + ]; + assert_debug_snapshot!(consumers(&base_module(), &bodies)); + + let mid = format!( + "{STYLED}import {{ Base }} from './base';\nexport const Mid = styled(Base)`width: 1px;`;\nconst Local = styled(Mid)`height: 1px;`;\nexport {{ Local as Renamed }};\nexport default Mid;" + ); + let app = format!( + "{STYLED}import {{ Base }} from './base';\nimport Top, {{ Mid, Renamed }} from './mid';\nexport const P = styled.div`${{Base}} {{ margin: 0; }} ${{Mid}} {{ color: red; }} ${{Renamed}} {{ top: 0; }} ${{Top}} {{ left: 0; }}`;" + ); + assert_debug_snapshot!(render( + &[ + ("/src/base.ts", &base_module()), + ("/src/mid.ts", &mid), + ("/src/app.tsx", &app), + ], + "/src/app.tsx", + )); + } + + #[test] + #[serial] + fn every_file_computes_the_class_of_a_component_from_its_module_and_name() { + let files = [ + ("/src/base.ts", base_module()), + ( + "/src/app.tsx", + format!( + "{STYLED}import {{ Dyn }} from './base';\nexport const P = styled.div`${{Dyn}} {{ margin: 0; }}`;" + ), + ), + ]; + let files: Vec<(&str, &str)> = files.iter().map(|(p, c)| (*p, c.as_str())).collect(); + reset(); + let _ = css::file_map::get_file_num_by_filename("/src/app.tsx"); + let app = extract_in(&files, "/src/app.tsx").unwrap(); + let base = extract_in(&files, "/src/base.ts").unwrap(); + let marker = |text: &str| { + text.split(|c: char| !(c.is_alphanumeric() || c == '-' || c == '_')) + .find(|word| word.contains("--")) + .unwrap() + .to_string() + }; + let selector = app + .styles + .iter() + .find_map(|style| { + format!("{style:?}") + .split_once("& .") + .map(|(_, rest)| marker(rest)) + }) + .unwrap(); + assert!(base.code.contains(&selector), "{selector}: {}", base.code); + } + + #[test] + #[serial] + fn re_exports_and_namespaces_reach_the_defining_module() { + let reexports: &[(&str, &str)] = &[ + ("/src/base.ts", &base_module_static()), + ( + "/src/star.ts", + "export * from './base';\nexport * as everything from './base';\nexport const Base = 1;", + ), + ( + "/src/named.ts", + "export { Base as Renamed, Dyn, Missing } from './base';\nexport { default as Span } from './base';", + ), + ( + "/src/forward.ts", + "import { Dyn } from './base';\nexport { Dyn };\nexport type { Dyn as DynType };", + ), + ( + "/src/chain.ts", + "export * from './star';\nexport * from './named';", + ), + ]; + let bodies = [ + "import { Dyn } from './star';\nexport const A = styled(Dyn)`color: blue;`;", + "import { Base } from './star';\nexport const A = styled(Base)`color: blue;`;", + "import { Renamed, Span } from './named';\nexport const A = styled(Renamed)`color: blue;`;\nexport const B = styled(Span)`color: blue;`;", + "import { Dyn } from './forward';\nexport const A = styled(Dyn)`color: blue;`;", + "import { Renamed, Dyn } from './chain';\nexport const A = styled(Renamed)`color: blue;`;\nexport const B = styled(Dyn)`color: blue;`;", + "import * as UI from './named';\nexport const A = styled(UI.Renamed)`color: blue;`;\nexport const W = UI.Dyn.withComponent('a');\nexport const N = styled(UI.Nope)`color: red;`;", + "import { Base } from './named';\nexport const A = styled(Base)`color: blue;`;", + "import { Dyn, Span } from './star';\nexport const A = styled(Dyn)`color: blue;`;\nexport const B = styled(Span)`color: blue;`;", + ]; + let outputs: Vec = bodies + .iter() + .map(|body| { + let app = format!("{STYLED}{body}"); + let mut files = reexports.to_vec(); + files.push(("/src/app.tsx", &app)); + render(&files, "/src/app.tsx") + }) + .collect(); + assert_debug_snapshot!(outputs); + } + + fn base_module_static() -> String { + base_module() + } + + #[test] + #[serial] + fn imported_components_the_build_cannot_read_stay_runtime_components() { + let modules: &[(&str, &str)] = &[ + ( + "/src/link.ts", + "import styled from '@emotion/styled';\nimport Link from 'next/link';\nexport const L = styled(Link)`color: red;`;\nexport const M = styled(L)`margin: 0;`;", + ), + ( + "/src/attrs.ts", + "import styled from '@emotion/styled';\nconst handler = () => 1;\nexport const H = styled.div.attrs({ onClick: handler })`color: red;`;", + ), + ( + "/src/function.ts", + "import styled from '@emotion/styled';\nfunction scaled(n) { return n * 2; }\nexport const D = styled.div`margin: ${(p) => scaled(p.x)}px;`;", + ), + ( + "/src/global.ts", + "import styled from '@emotion/styled';\nexport const E = styled.div({ display: process.env.MODE });", + ), + ( + "/src/closed.ts", + "import styled from '@emotion/styled';\nexport const G = styled.div`margin: ${(p) => Number(p.x)}px;`;\nexport const T = styled.div({ color: ['red', 'blue'] });\nexport const U = styled.div`color: ${(p: Props) => p.tone};`;\ntype Props = { tone: string };", + ), + ( + "/src/plain.ts", + "export const Base = 5;\nexport function fn() {}\nexport const { a, b } = { a: 1, b: 2 };\nexport default function () {}\nexport type { Props };\nexport { type Props as P };", + ), + ( + "/src/bad.ts", + "import styled from '@emotion/styled';\nexport const Good = styled.div`color: red;`;\nexport const Bad = styled.div;", + ), + ( + "/src/cycle-a.ts", + "import styled from '@emotion/styled';\nimport { B } from './cycle-b';\nexport const A = styled(B)`color: red;`;", + ), + ( + "/src/cycle-b.ts", + "import styled from '@emotion/styled';\nimport { A } from './cycle-a';\nexport const B = styled(A)`color: blue;`;", + ), + ( + "/src/theme.css.ts", + "import { style } from '@vanilla-extract/css';\nexport const base = style({ color: 'red' });", + ), + ("/src/data.json", "{}"), + ("/src/broken.ts", "export const = ;"), + ( + "/src/types.ts", + "import styled from '@emotion/styled';\nexport const Base = styled.div`color: red;`;", + ), + ]; + let bodies = [ + "import { L, M } from './link';\nexport const A = styled(L)`color: blue;`;\nexport const B = styled(M)`color: blue;`;", + "import { H } from './attrs';\nimport { D } from './function';\nimport { E } from './global';\nimport { G, T, U } from './closed';\nexport const A = styled(H)`color: blue;`;\nexport const B = styled(D)`color: blue;`;\nexport const C = styled(G)`color: blue;`;\nexport const F = styled(E)`color: blue;`;\nexport const I = styled(T)`color: blue;`;\nexport const J = styled(U)`color: blue;`;", + "import Anonymous, { Base, fn, a, P } from './plain';\nexport const A = styled(Base)`color: blue;`;\nexport const B = styled(Anonymous)`color: blue;`;\nexport const C = styled(fn)`color: blue;`;\nexport const D = styled(a)`color: blue;`;\nexport const E = styled(P)`color: blue;`;", + "import { Good, Bad } from './bad';\nexport const A = styled(Good)`color: blue;`;\nexport const B = styled.div`${Good} { color: red; }`;", + "import { A } from './cycle-a';\nexport const App = styled(A)`color: blue;`;", + "import { base } from './theme.css';\nimport { Data } from './data.json';\nimport { Broken } from './broken';\nimport { Missing } from './missing';\nimport { Nope } from './types';\nexport const A = styled(Data)`color: blue;`;\nexport const B = styled(Broken)`color: blue;`;\nexport const C = styled(Missing)`color: blue;`;\nexport const D = styled(Nope)`color: blue;`;\nexport const E = styled(base)`color: blue;`;", + "import type { Base } from './types';\nimport { type Base as Other } from './types';\nexport const A = styled(Base)`color: blue;`;\nexport const B = styled(Other)`color: blue;`;", + "import { Base } from './types';\nexport const A = styled(Base)`color: blue;`;", + "import * as types from './types';\nexport const A = styled(types.deep.Base)`color: blue;`;\nexport const B = styled(types['Base'])`color: blue;`;", + ]; + let outputs: Vec = bodies + .iter() + .map(|body| { + let app = format!("{STYLED}{body}"); + let mut files = modules.to_vec(); + files.push(("/src/app.tsx", &app)); + render(&files, "/src/app.tsx") + }) + .collect(); + assert_debug_snapshot!(outputs); + } + + #[test] + #[serial] + fn exports_name_what_a_module_gives_other_files() { + let code = "import styled from '@emotion/styled';\nexport const A = 1, { b } = {};\nconst C = 2;\nexport { C, C as D };\nexport default C;\nexport function f() {}\nexport type { C as T };"; + let allocator = Allocator::default(); + let program = Parser::new(&allocator, code, SourceType::ts()) + .parse() + .program; + let exports = Exports::scan(&program); + assert_eq!(exports.count(), 4); + assert_eq!(exports.of("C"), ["C", "D", "default"]); + assert_eq!(exports.of("A"), ["A"]); + assert_eq!(exports.of("missing"), [] as [String; 0]); + assert_eq!(exports.index("D"), 2); + assert!(!exports.has_anonymous_default()); + let code = "export default styled.div`color: red;`;"; + let program = Parser::new(&allocator, code, SourceType::ts()) + .parse() + .program; + let exports = Exports::scan(&program); + assert!(exports.has_anonymous_default()); + 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..69019eb4c --- /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( + "/w27/broken.tsx", + "export const Broken = ;", + &crate::imported_constants::Unknown::default(), + ); + // Then + assert_eq!(result, None); +} + +#[rstest] +#[case("Child", Some("Child"))] +#[case("UI.Child", Some("UI.Child"))] +#[case("UI['Child']", Some("UI.Child"))] +#[case("getUI().Child", None)] +#[case("UI.Nested.Child", None)] +#[case("UI[key]", None)] +fn imported_name_when_member_is_not_exact_does_not_select_a_definition( + #[case] source: &str, + #[case] expected: Option<&str>, +) -> Result<(), Box> { + // Given + let allocator = Allocator::default(); + let expression = Parser::new(&allocator, source, SourceType::tsx()) + .parse_expression() + .map_err(|error| format!("{error:?}"))?; + // When + let name = written_name(&expression); + // Then + assert_eq!(name.as_deref(), expected); + Ok(()) +} diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index a5853d5b5..d2e38a3da 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -1,6 +1,10 @@ mod as_visit; mod build_time_values; mod component; +mod composition; +#[cfg(test)] +mod css_composition_rest_tests; +mod css_prop; mod css_utils; pub mod extract_style; mod extractor; @@ -8,11 +12,14 @@ mod gen_class_name; mod gen_style; mod import_alias_visit; mod imported_constants; +mod imported_styled; mod module_loader; mod mutations; mod prop_modify_utils; +mod prop_valid; mod source_map; mod style_values; +mod styled_reads; mod stylex; mod tailwind; mod util_type; @@ -77,8 +84,8 @@ pub enum ExtractStyleProp<'a> { }, } -impl<'a> ExtractStyleProp<'a> { - pub fn clone_in(&self, alloc: &'a Allocator) -> Self { +impl ExtractStyleProp<'_> { + pub fn clone_in<'b>(&self, alloc: &'b Allocator) -> ExtractStyleProp<'b> { match self { ExtractStyleProp::Static(v) => ExtractStyleProp::Static(v.clone()), ExtractStyleProp::StaticArray(arr) => { @@ -297,26 +304,33 @@ fn extract_source( // Step 1: Transform import aliases // e.g., `import styled from '@emotion/styled'` → `import { styled } from '@devup-ui/react'` // e.g., `import { style } from '@vanilla-extract/css'` → `import { style } from '@devup-ui/react'` - let (transformed_code, alias_edits) = import_alias_visit::transform_import_aliases_with_edits( + let import_alias_visit::Aliased { + code: transformed_code, + edits: alias_edits, + css_prop, + px, + } = import_alias_visit::transform_import_aliases_with_edits( code, filename, &option.package, &option.import_aliases, ); - // Step 2: Check if code contains the target package (after transformation) + // Step 2: Check if code contains the target package (after transformation), + // gives an element a `css` prop, or had an import rewritten let has_relevant_import = transformed_code.contains(option.package.as_str()) || transformed_code.contains(STYLEX_PACKAGE); + let unchanged = || ExtractOutput { + styles: FxHashSet::default(), + code: code.to_string(), + map: None, + css_file: None, + dependencies: Vec::new(), + }; - if !has_relevant_import { + if !has_relevant_import && css_prop == css_prop::CssProp::Off && alias_edits.is_empty() { // skip if not using package - return Ok(ExtractOutput { - styles: FxHashSet::default(), - code: code.to_string(), - map: None, - css_file: None, - dependencies: Vec::new(), - }); + return Ok(unchanged()); } let mut dependencies = std::collections::BTreeSet::new(); @@ -424,11 +438,26 @@ fn extract_source( filename, &option, resolver, + css_prop, + &px, ) } else { imported_constants::Inlined::default() }; dependencies.extend(inlined.dependencies); + let imported = if processed_code.is_none() { + imported_styled::read( + &oxc_ast::builder::AstBuilder::new(&allocator), + &program, + filename, + &option, + resolver, + css_prop, + ) + } else { + imported_styled::Reading::default() + }; + dependencies.extend(imported.dependencies); let mut visitor = DevupVisitor::new( &allocator, filename, @@ -437,9 +466,17 @@ fn extract_source( if global { None } else { Some(bucket) }, ); 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); + visitor.import_styled(imported.components); visitor.visit_program(&mut program); + if !has_relevant_import && alias_edits.is_empty() && !visitor.compiled_css_prop { + // No element took the `css` prop the text seemed to give + return Ok(unchanged()); + } if let Some(error) = evaluation_error && imports_uncompiled(&program, &option.package) { @@ -14553,6 +14590,449 @@ const Button = styled.button({ bg: 'red' }) )); } + /// `code` extracted with readable class names + fn readable_code(code: &str) -> String { + reset_class_map(); + reset_file_map(); + css::debug::set_debug(true); + let output = extract( + "test.tsx", + code, + ExtractOption { + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), + single_css: true, + import_main_css: false, + import_aliases: HashMap::new(), + }, + ); + css::debug::set_debug(false); + match output { + Ok(output) => output.code, + Err(error) => error.to_string(), + } + } + + // 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() { + let code = readable_code( + "import {styled} from '@devup-ui/core' +const Base = styled.button`color: red; background: white; &:hover { color: red; }` +const Ext = styled(Base)`color: blue; &:hover { color: blue; }` +const Obj = styled.div({ color: 'red', p: 2 }) +const ObjExt = styled(Obj)({ color: 'blue' }) +const Twice = styled(ObjExt, { m: 1 }) +const Runtime = styled(make())({ color: 'red' }) +const FromRuntime = styled(Runtime)({ color: 'blue' }) +let Changing = styled.div({ color: 'red' }) +const FromChanging = styled(Changing)({ color: 'blue' }) +const Ordered = styled.div({ color: 'red', styleOrder: 3 })", + ); + for expected in [ + "const Ext = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"button\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\"color-0-blue-_a__c_hover-255 color-0-blue--255 background-0-white--255\", className]", + "const ObjExt = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\"color-0-blue--255 padding-0-8px--255\", className]", + "const Twice = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\"margin-0-4px--255 color-0-blue--255 padding-0-8px--255\", className]", + "const FromRuntime = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = Runtime, forwardedAs, ...rest }) => (({ style, className, as: DevupAs = Changing, forwardedAs, ...rest }) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\"color-0-red--3\", className]", + ] { + assert!(code.contains(expected), "{expected}\n{code}"); + } + } + + // `withComponent` renders the same styles as another tag or component, and + // `as` changes what a styled component renders while `forwardedAs` passes on + #[test] + #[serial] + fn test_styled_with_component_and_as() { + let code = readable_code( + "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) +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 =
", + ); + for expected in [ + "export const Aside = __devupForwardRef((__devupRefProps, __devupRef) => ((__devupProps) => (({ style, className, as: DevupAs = \"aside\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\"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\");", + "...{ role: \"region\" }", + "
", + ] { + assert!(code.contains(expected), "{expected}\n{code}"); + } + } + + // A styled component keeps the props its styles read, `$` props and `theme` + // away from the tag, follows `shouldForwardProp` as the build evaluates it, + // and an element using it drops what it neither reads nor passes on + #[test] + #[serial] + fn test_styled_prop_forwarding() { + let code = readable_code( + "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};` +const C = styled.div.withConfig({ shouldForwardProp: (prop, valid) => valid(prop) && !['x', 'y'].includes(prop) })({ color: 'red' }) +const D = styled('p', { shouldForwardProp: (prop) => isPropValid(prop) || prop.startsWith('data-') })({ color: 'red' }) +const E = styled('p', { shouldForwardProp: () => true })({ color: 'red' }) +const F = styled(Link)`color: ${(p) => p.$c};` +const G = styled('p', { shouldForwardProp: function (prop) { return prop === 'id' } })({ color: 'red' }) +const H = styled.div`color: ${function (p) { return p.c }};` +const I = styled.div`color: ${({ tone, ...more }) => tone};` +const J = styled.div`color: ${(p) => p[key]}; margin: ${([a]) => a}; padding: ${() => 1};` +const K = styled(A, { shouldForwardProp: (prop) => 'id' === prop || prop === 'title' })({ color: 'blue' }) +const L = styled.div.withConfig(config)`color: ${(p) => { return p.level }}; margin: ${({ [k]: v }) => v};` +const M = styled('p', { shouldForwardProp: (prop) => { return prop === 'id' } })({ color: 'red' }) +export const a = +export const b = +export const j = +export const f = +export const k = ", + ); + for expected in [ + "{...(({ \"tone\": __devupOmit0, ...__devupDom }) => __devupDom)(rest)}", + "{...(({ \"$c\": __devupOmit0, \"tone\": __devupOmit1, \"theme\": __devupOmit2, ...__devupDom }) => __devupDom)(rest)}", + "{...(({ \"x\": __devupOmit0, \"y\": __devupOmit1, \"theme\": __devupOmit2, ...__devupDom }) => __devupDom)(rest)}", + "{...(({ \"$c\": __devupOmit0, ...__devupDom }) => __devupDom)(rest)}", + "export const a = ", + "export const b = ", + "export const j = ", + "export const f = ", + "export const k = ", + "{...(({ \"level\": __devupOmit0, \"theme\": __devupOmit1, ...__devupDom }) => __devupDom)(rest)}", + ] { + assert!(code.contains(expected), "{expected}\n{code}"); + } + let error = readable_code( + "import {styled} from '@devup-ui/core' +const A = styled('h1', { shouldForwardProp: (prop) => allowed.has(prop) })({ color: 'red' }) +const B = styled('h1', { shouldForwardProp: (prop) => prop === other })({ color: 'red' }) +const C = styled('h1', { shouldForwardProp: (prop) => prop > 'a' })({ color: 'red' }) +const D = styled('h1', { shouldForwardProp: (prop) => prop ?? 'a' })({ color: 'red' }) +const E = styled('h1', { shouldForwardProp: (prop) => { const x = 1; return x } })({ color: 'red' }) +const F = styled('h1', { shouldForwardProp: check })({ color: 'red' }) +const G = styled('h1', { shouldForwardProp: ({ a }) => a })({ color: 'red' }) +const H = styled('h1', { shouldForwardProp: (prop) => [x].includes(prop) })({ color: 'red' }) +const I = styled('h1', { shouldForwardProp: (prop) => list.includes(prop) })({ color: 'red' }) +const J = styled('h1', { shouldForwardProp: (prop) => prop.endsWith('a') })({ color: 'red' }) +const K = styled('h1', { [k]: 1, ...o, shouldForwardProp: (prop) => valid(prop, 1) })({ color: 'red' }) +const L = styled('h1', { shouldForwardProp: (prop) => prop === 'a' ?? prop === 'b' })({ color: 'red' }) +const M = styled('h1', { shouldForwardProp: (prop) => x === y })({ color: 'red' }) +const N = styled('h1', { shouldForwardProp: (prop) => { prop === 'a' } })({ color: 'red' })", + ); + assert!( + error.contains("`shouldForwardProp` must be a function"), + "{error}" + ); + assert!(error.contains("allowed.has(prop)"), "{error}"); + } + + #[test] + #[serial] + fn test_styled_attrs_merge_in_order() { + let code = readable_code( + "import {styled} from '@devup-ui/core' +const Base = styled.input.attrs({ type: 'text', title: 'base' })({ color: 'red' }) +const Ext = styled(Base).attrs({ type: 'password' })({ color: 'blue' }) +const X = styled.div.attrs({ className: 'from-attrs', style: { color: 'green' } })({ color: 'red' }) +const F = styled.div.attrs((p) => ({ id: p.id }))({ color: 'red' }) +const V = styled.div.attrs(extra)({ color: 'red' })", + ); + for expected in [ + "...{\n\t\t...__devupProps,\n\t\t...{\n\t\t\ttype: \"text\",\n\t\t\ttitle: \"base\"\n\t\t}\n\t},\n\t...{ type: \"password\" }", + "className: [__devupContext.className, __devupAttrs.className].filter(Boolean).join(\" \") || undefined", + "className: [__devupContext.className, __devupProps.className].filter(Boolean).join(\" \") || undefined", + "...__devupContext.style,\n\t\t...__devupAttrs.style", + "((p) => ({ id: p.id }))(__devupContext)", + "typeof extra === \"function\" ? extra(__devupContext) : extra", + ] { + assert!(code.contains(expected), "{expected}\n{code}"); + } + } + + #[rstest] + #[case( + "", + "className=\"spread\" style={{ opacity: 2 }}" + )] + #[case( + "", + "className=\"direct\" style={{ opacity: 1 }}" + )] + #[case( + "", + "className={(\"className\" in Object(rest) ? rest.className : \"direct\") || \"\"} style={\"style\" in Object(rest) ? rest.style : { opacity: 1 }}" + )] + #[case( + "", + "className=\"last\"" + )] + #[case( + "", + "className={{\n\tclassName: \"first\",\n\t...rest\n}.className || \"\"}" + )] + #[case( + " color=\"red\" />", + "
" + )] + #[case( + "", + "className={(\"className\" in Object(more) ? more.className : rest?.className) || \"\"}" + )] + #[serial] + fn test_jsx_props_written_later_win(#[case] element: &str, #[case] expected: &str) { + let code = readable_code(&format!( + "import {{Box}} from '@devup-ui/core' +export const a = {element}" + )); + assert!(code.contains(expected), "{expected}\n{code}"); + } + + #[test] + #[serial] + fn test_css_composing_reports_runtime_values() { + let code = readable_code( + "import {css} from '@devup-ui/core' +const base = css({ color: 'red' }) +export const a = css(base, { color: tone }) +export const b = css(base, { [key]: 'x', styleOrder: 2 }) +export const c = css(base, getStyles()) +export const d = css(base, { positioning: side, styleOrder: 2 })", + ); + assert!(!code.contains("test.tsx:6:"), "{code}"); + assert!(code.contains("`css()` cannot use `tone`"), "{code}"); + assert!( + code.contains("Cannot compose `\"color-0-red--255\", getStyles()`"), + "{code}" + ); + } + + // The styles of a `css()` class another module exports compose as well + #[test] + #[serial] + fn test_css_composes_imported_classes() { + reset_class_map(); + reset_file_map(); + let modules: &[(&str, &str)] = &[( + "/src/styles.ts", + "import { css, keyframes } from '@devup-ui/react'; +import * as Devup from '@devup-ui/react'; +const brand = 'teal'; +export const base = css({ color: brand, _hover: { color: 'red' } }); +export const danger = css({ color: 'crimson' }); +export const twice = css({ color: 'a' }, { m: 1 }); +export const text = css`color: blue;`; +export const runtime = css({ color: globalThis.tone }); +export const listed = css([{ color: 'b' }]); +export const spaced = Devup.css({ color: 'c' }); +export const fade = keyframes({ from: { opacity: 0 } }); +export const called = Devup({ color: 'e' }); +export const ordered = css({ color: 'f', m: 2, styleOrder: 2 });", + )]; + let resolver = memory_resolver(modules); + css::debug::set_debug(true); + let output = extract_with_modules( + "/src/App.tsx", + "import { css } from '@devup-ui/react'; +import { base, danger, twice, text, runtime, listed, spaced, fade, called, ordered } from './styles'; +export const a = css(base, danger); +export const b = css(danger, base, { m: 1 }); +export const c = css(twice, text, runtime, listed, spaced, fade); +export const d = css(ordered, danger, called);", + ExtractOption { + import_aliases: HashMap::from([( + "@emotion/css".to_string(), + ImportAlias::NamedToNamed, + )]), + ..ExtractOption::default() + }, + false, + &resolver, + ) + .unwrap(); + 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 = `${twice} ${text} ${runtime} ${listed} ${spaced} ${fade}`;", + "export const d = `margin-0-8px--2-a color-0-crimson--255-a ${called}`;", + ] { + assert!( + output.code.contains(expected), + "{expected} in {}", + output.code + ); + } + } + // Each Tailwind class becomes the classes of its styles; every other class, // and every class that runs into an interpolation, stays as written #[rstest] @@ -17865,16 +18345,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] @@ -20836,4 +21314,581 @@ export const App = () => ExtractOption { + ExtractOption { + package: "@devup-ui/react".to_string(), + css_dir: "@devup-ui/react".to_string(), + single_css: true, + import_main_css: false, + import_aliases: HashMap::from([ + ("@emotion/react".to_string(), ImportAlias::NamedToNamed), + ( + "@emotion/styled".to_string(), + ImportAlias::DefaultToNamed("styled".to_string()), + ), + ]), + } + } + + fn emotion_outputs(files: &[(&str, &str)]) -> Vec<(String, ToBTreeSet)> { + files + .iter() + .map(|(filename, code)| { + reset_class_map(); + reset_file_map(); + ( + (*code).to_string(), + ToBTreeSet::from(extract(filename, code, emotion_option()).unwrap()), + ) + }) + .collect() + } + + #[test] + #[serial] + fn test_emotion_css_prop_in_every_form() { + assert_debug_snapshot!(emotion_outputs(&[ + ( + "test.tsx", + "export const App = () =>
;" + ), + ( + "test.tsx", + "export const App = ({ danger }) =>
;" + ), + ( + "test.tsx", + "export const App = ({ active }) =>
;" + ), + ( + "test.tsx", + "import { css } from '@emotion/react';\nconst style = css`color: red;`;\nexport const App = () =>
;" + ), + ( + "test.tsx", + "/** @jsxImportSource @emotion/react */\nexport const App = () =>
;" + ), + ( + "test.ts", + "import { jsx } from '@emotion/react';\nexport const App = () => jsx('div', { css: { color: 'red' } });" + ), + ( + "test.ts", + "import { jsx as _jsx } from '@emotion/react/jsx-runtime';\nexport const App = () => _jsx('div', { css: { color: 'red' } });" + ), + ( + "test.ts", + "import { jsxs as _jsxs } from '@emotion/react/jsx-runtime';\nexport const App = () => _jsxs('div', { css: { color: 'red' }, children: ['a', 'b'] });" + ), + ( + "test.ts", + "import { jsxDEV as _jsxDEV } from '@emotion/react/jsx-dev-runtime';\nexport const App = () => _jsxDEV('div', { css: { padding: 4 } }, void 0, false);" + ), + ( + "test.tsx", + "/** @jsx jsx */\nimport { jsx } from '@emotion/react';\nexport const App = () =>
;" + ), + ( + "test.tsx", + "export const App = () =>
({ color: theme.colors.primary, padding: 8 })} />;" + ), + ( + "test.tsx", + "export const App = () =>
{ return { color: colors.text }; }} />;" + ), + ( + "test.tsx", + "import { css } from '@emotion/react';\nconst override = css({ color: 'blue' });\nexport const App = () =>
;" + ), + ( + "test.tsx", + "import { css } from '@emotion/react';\nconst override = css({ color: 'blue' });\nexport const App = () =>
;" + ), + ( + "test.tsx", + "import { css } from '@emotion/react';\nconst base = css({ color: 'blue', margin: 0 });\nexport const App = ({ cls }) => <>
;" + ), + ( + "test.tsx", + "export const App = ({ c, w }) =>
;" + ), + ( + "test.tsx", + "export const App = ({ c }) => <>
;" + ), + ( + "test.tsx", + "const base = { color: 'red', padding: 4 };\nexport const App = () =>
;" + ), + ( + "test.tsx", + "const SPACE = 4;\nconst base = { padding: 4, lineHeight: 1.5, '&:hover': { margin: 2 } };\nexport const App = () =>
;" + ), + ( + "test.tsx", + "export const App = ({ cls, s }) => <>
css={{ color: 'red' }} />
;" + ), + ( + "test.ts", + "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 }] })];" + ), + ])); + } + + #[test] + #[serial] + fn test_emotion_css_prop_on_every_element() { + assert_debug_snapshot!(emotion_outputs(&[ + ( + "test.tsx", + "const Custom = ({ className }) =>
;\nexport const App = () => ;" + ), + ("test.tsx", "export const a = { css: 1 };"), + ( + "test.tsx", + "import styled from '@emotion/styled';\nconst Custom = ({ className }) =>
;\nexport const App = () => <>;" + ), + ( + "test.tsx", + "import { Box } from '@devup-ui/react';\nexport const App = () => <>;" + ), + ( + "test.tsx", + "import styled from '@emotion/styled';\nconst Button = styled.button({ color: 'blue', margin: 1 });\nexport const App = ({ f, rest, cls }) => <> -
-
+
+
Option 1
-
+
Option 2
-
+
-
+
Option 3
-
+
Option 4
-
-