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_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_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_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 d830648b6..f0d3dbb50 100644 --- a/libs/css/src/lib.rs +++ b/libs/css/src/lib.rs @@ -469,6 +469,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] = { @@ -793,6 +816,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/build_time_values.rs b/libs/extractor/src/build_time_values.rs index 1e8bdd7e3..f9d85cdf9 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,7 @@ pub(crate) fn has_build_time_values( filename, option, resolver, + aliased.css_prop, ); let changes = crate::imported_constants::ChangeCheck::new(&program, filename, option, resolver); !find( diff --git a/libs/extractor/src/class_names_numeric_tests.rs b/libs/extractor/src/class_names_numeric_tests.rs new file mode 100644 index 000000000..83b5d9a02 --- /dev/null +++ b/libs/extractor/src/class_names_numeric_tests.rs @@ -0,0 +1,180 @@ +use crate::{ExtractOption, ExtractOutput, ImportAlias, extract}; +use boa_engine::{Context, Source}; +use css::{class_map::reset_class_map, file_map::reset_file_map}; +use oxc_allocator::Allocator; +use oxc_ast::ast::Statement; +use oxc_codegen::Codegen; +use oxc_parser::Parser; +use oxc_span::SourceType; +use rstest::rstest; +use serial_test::serial; +use std::collections::HashMap; + +fn compile(expression: &str) -> Result { + reset_class_map(); + reset_file_map(); + extract( + "numeric.tsx", + &format!( + "import {{ ClassNames }} from '@emotion/react';const App = on => {{({{css,cx}}) => {expression}}};" + ), + ExtractOption { + single_css: true, + import_main_css: false, + import_aliases: HashMap::from([( + "@emotion/react".to_string(), + ImportAlias::NamedToNamed, + )]), + ..ExtractOption::default() + }, + ) + .map_err(|error| error.to_string()) +} + +fn execute(code: &str) -> String { + let arena = Allocator::default(); + let mut parsed = Parser::new(&arena, code, SourceType::tsx()).parse(); + assert_eq!(parsed.diagnostics.len(), 0, "{code}"); + parsed + .program + .body + .retain(|statement| !matches!(statement, Statement::ImportDeclaration(_))); + let code = Codegen::new().build(&parsed.program).code; + let script = format!( + "{code}\nJSON.stringify([App(true), App(false)].map(classes => classes.split(/\\s+/).filter(Boolean).join(' ')));" + ); + Context::default() + .eval(Source::from_bytes(&script)) + .unwrap_or_else(|error| panic!("{script}\n{error}")) + .as_string() + .unwrap_or_else(|| panic!("expected serialized class tokens from {script}")) + .to_std_string_escaped() +} + +#[rstest] +#[case("cx(1)", r#"["1","1"]"#)] +#[case("cx(0)", r#"["",""]"#)] +#[case("cx(['caller',[1,0,[2.5]]])", r#"["caller 1 2.5","caller 1 2.5"]"#)] +#[case("cx(1e21,1e-7,0x10)", r#"["1e+21 1e-7 16","1e+21 1e-7 16"]"#)] +#[case("cx(on ? 1 : 0)", r#"["1",""]"#)] +#[case("cx(on ? [0,2] : [3,0])", r#"["2","3"]"#)] +#[case("cx(on && 4)", r#"["4",""]"#)] +#[case("cx(on && 0)", r#"["",""]"#)] +#[case("cx(0 || 5)", r#"["5","5"]"#)] +#[case("cx(6 || 7)", r#"["6","6"]"#)] +#[case("cx(0 ?? 8)", r#"["",""]"#)] +#[case("cx(null ?? 9)", r#"["9","9"]"#)] +#[case("cx(-1)", r#"["-1","-1"]"#)] +#[case("cx(-0)", r#"["",""]"#)] +#[case("cx(+2)", r#"["2","2"]"#)] +#[case("cx(+0)", r#"["",""]"#)] +#[case("cx(['caller',[-1,-0,[+2]]])", r#"["caller -1 2","caller -1 2"]"#)] +#[case("cx(on ? -1 : +2)", r#"["-1","2"]"#)] +#[case("cx(on ? [-0,+2] : [-1,+0])", r#"["2","-1"]"#)] +#[case("cx(on && -1)", r#"["-1",""]"#)] +#[case("cx(on && -0)", r#"["",""]"#)] +#[case("cx(-0 || +2)", r#"["2","2"]"#)] +#[case("cx(-0 ?? +2)", r#"["",""]"#)] +#[case("cx(-1 || +2)", r#"["-1","-1"]"#)] +#[serial] +fn class_names_numeric_classes_execute_when_static( + #[case] expression: &str, + #[case] expected: &str, +) { + // Given: static numeric class leaves, with runtime branch controllers where relevant. + let output = compile(expression).unwrap_or_else(|error| panic!("{expression}\n{error}")); + // When: execute the actual emitted render function for both branch states. + let result = execute(&output.code); + // Then: nonzero numbers are class text and zero contributes no class. + assert_eq!(result, expected); + assert_eq!(output.styles.len(), 0); +} + +#[test] +#[serial] +fn class_names_numeric_map_conditions_keep_truthiness() { + // Given: numbers are conditions, not classes, inside class-map values. + let output = compile("cx({ kept: 2, omitted: 0, chosen: on })") + .unwrap_or_else(|error| panic!("{error}")); + // When: execute both runtime choices. + let result = execute(&output.code); + // Then: keys, not the numeric condition values, become classes. + assert_eq!(result, r#"["kept chosen","kept"]"#); + assert_eq!(output.styles.len(), 0); +} + +#[rstest] +#[case("cx(css({padding:[1,2],opacity:[0,0.5]}),'caller')")] +#[case("cx([css({padding:[1,2],opacity:[0,0.5]}),['caller']])")] +#[case("cx(css({padding:[1,2],opacity:[0,0.5]}),'caller',1,0)")] +#[serial] +fn class_names_numeric_rules_keep_fallbacks(#[case] expression: &str) { + // Given: numeric CSS fallback arrays alongside ordinary class arguments. + let output = compile(expression).unwrap_or_else(|error| panic!("{expression}\n{error}")); + let mut styles: Vec<_> = output + .styles + .iter() + .map(|style| match style { + crate::extract_style::extract_style_value::ExtractStyleValue::Static(style) => { + (style.property.as_str(), style.value.as_str(), style.level) + } + other => panic!("unexpected style: {other:?}"), + }) + .collect(); + styles.sort_unstable(); + // When: execute the emitted class expression and inspect the extracted declarations. + let result = execute(&output.code); + // Then: numeric rules retain px/unitless fallback semantics at breakpoint zero. + assert!(result.contains("caller"), "{result}"); + assert_eq!( + styles, + [ + ("opacity", "0;opacity:.5", 0), + ("padding", "1px;padding:2px", 0) + ] + ); +} + +#[rstest] +#[case("cx(getClass(),1)")] +#[case("cx([1,[getClass()]])")] +#[case("cx(on ? 1 : getClass())")] +#[serial] +fn class_names_numeric_arguments_keep_unknown_call_guards(#[case] expression: &str) { + // Given: a numeric class does not make an unknown call statically readable. + // When: extract a class expression containing such a call. + let error = compile(expression) + .err() + .unwrap_or_else(|| panic!("expected unknown-call rejection for {expression}")); + // Then: the existing located build-time rejection is retained. + assert!(error.starts_with("numeric.tsx:1:"), "{error}"); + assert!(error.contains("getClass()"), "{error}"); + assert!(error.contains("at build time"), "{error}"); +} + +#[rstest] +#[case("cx(+'2')")] +#[case("cx(-'2')")] +#[case("cx(+true)")] +#[case("cx(-false)")] +#[case("cx(~1)")] +#[case("cx(-on)")] +#[case("cx(+on)")] +#[case("cx(-getClass())")] +#[case("cx(+getClass())")] +#[case("cx(1 + 2)")] +#[case("cx(-(1 + 2))")] +#[case("cx(-(-1))")] +#[case("cx([+2,[-getClass()]])")] +#[case("cx(on ? -1 : +getClass())")] +#[serial] +fn class_names_numeric_arguments_reject_nonliteral_operands(#[case] expression: &str) { + // Given: only exact signed numeric literal leaves are authorized. + // When: extract a coercion, computed operand, or unknown call. + let error = compile(expression) + .err() + .unwrap_or_else(|| panic!("expected nonliteral rejection for {expression}")); + // Then: the existing located build-time rejection remains in force. + assert!(error.starts_with("numeric.tsx:1:"), "{error}"); + assert!(error.contains("at build time"), "{error}"); +} 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/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 c9545f989..875b20c9b 100644 --- a/libs/extractor/src/css_utils.rs +++ b/libs/extractor/src/css_utils.rs @@ -227,14 +227,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([';', '{', '}']); @@ -668,12 +668,17 @@ fn nest_prelude(parent: Option<&StyleSelector>, prelude: &str) -> Option String { + split_selector_list(selectors) .into_iter() .map(relative_selector) .collect::>() - .join(","); - Some(StyleSelector::nest_selector(parent, &template)) + .join(",") } /// A selector of a nested rule made relative to the rule it is in, as @@ -1708,6 +1713,15 @@ mod tests { ("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 // selector; everything before it is declarations. #[case( diff --git a/libs/extractor/src/dead_properties/origin.rs b/libs/extractor/src/dead_properties/origin.rs index 5da5637a4..a7b806341 100644 --- a/libs/extractor/src/dead_properties/origin.rs +++ b/libs/extractor/src/dead_properties/origin.rs @@ -1,6 +1,6 @@ use crate::{ ExtractOption, - import_alias_visit::{source_offset, transform_import_aliases_with_edits}, + import_alias_visit::{Aliased, source_offset, transform_import_aliases_with_edits}, }; pub(crate) fn map_evaluated( @@ -33,7 +33,11 @@ pub(crate) fn evaluated_calls( filename: &str, option: &ExtractOption, ) -> Vec<(usize, usize)> { - let (aliased, edits) = transform_import_aliases_with_edits( + let Aliased { + code: aliased, + edits, + .. + } = transform_import_aliases_with_edits( source, filename, &option.package, diff --git a/libs/extractor/src/extract_style/extract_dynamic_style.rs b/libs/extractor/src/extract_style/extract_dynamic_style.rs index f09b03b07..74cf36ea2 100644 --- a/libs/extractor/src/extract_style/extract_dynamic_style.rs +++ b/libs/extractor/src/extract_style/extract_dynamic_style.rs @@ -137,6 +137,10 @@ impl ExtractDynamicStyle { self.identifier.as_str() } + pub(crate) const fn identifier_mut(&mut self) -> &mut String { + &mut self.identifier + } + pub const fn style_order(&self) -> Option { self.style_order } diff --git a/libs/extractor/src/extractor/extract_style_from_expression.rs b/libs/extractor/src/extractor/extract_style_from_expression.rs index 0f26950ca..a296e75ff 100644 --- a/libs/extractor/src/extractor/extract_style_from_expression.rs +++ b/libs/extractor/src/extractor/extract_style_from_expression.rs @@ -1341,9 +1341,10 @@ fn nest_underscore_name(parent: Option<&StyleSelector>, name: &str) -> Nested { } /// Emotion/styled-components style object keys that are nested selectors -/// (`'&:hover'`, `':hover'`, `'.parent &'`) rather than CSS properties. +/// (`'&:hover'`, `':hover'`, `'.parent &'`, `'.child'`, `'> li'`) rather +/// than CSS properties. fn is_nested_selector_key(key: &str) -> bool { - key.starts_with(':') || key.contains('&') + key.starts_with([':', '.', '#', '[', '*', '>', '+', '~']) || key.contains('&') } /// A key without `&` is relative to the element: a pseudo-class, a diff --git a/libs/extractor/src/extractor/extract_style_from_styled.rs b/libs/extractor/src/extractor/extract_style_from_styled.rs index c8ad9d20d..7c9fbe78e 100644 --- a/libs/extractor/src/extractor/extract_style_from_styled.rs +++ b/libs/extractor/src/extractor/extract_style_from_styled.rs @@ -1,4 +1,9 @@ +use super::styled_capability::StyledCapability; +use super::styled_css_slot::{StyledContext, StyledCssSlot}; +#[path = "styled_retained.rs"] +mod retained; use super::styled_jsx::StyledJsx; +use retained::Execution; use rustc_hash::FxHashMap; use crate::{ @@ -10,15 +15,16 @@ use crate::{ ExtractResult, extract_style_from_expression::{LiteralHandling, extract_style_from_expression}, styled_record::prepare, - styled_record_evaluation::bind_render, + styled_record_evaluation::{Evaluations, bind_render, visit_style_expressions}, }, gen_class_name::{gen_class_names, merge_expression_for_class_name}, gen_style::gen_styles, + styled_reads::{Forward, Reads, 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, unplaced_error, unreadable_styles, unwrap_syntax_only, + unwrap_syntax_only_mut, wrap_array_filter, wrap_direct_call, }, }; use oxc_allocator::{CloneIn, FromIn, GetAllocator}; @@ -30,7 +36,9 @@ use oxc_ast::{ }, builder::AstBuilder, }; -use oxc_span::{GetSpan, SPAN}; +use oxc_ast_visit::VisitMut; +use oxc_parser::{Parser, ParserReturn}; +use oxc_span::{GetSpan, SPAN, SourceType}; use oxc_syntax::operator::{BinaryOperator, LogicalOperator, UnaryOperator}; /// The binding a styled component reads the component it renders through, when @@ -50,7 +58,573 @@ 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, + target: Option>, + 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, + inherited_markers: Vec, + bindings: Vec<(String, Expression<'a>)>, + capability: Option, + execution: Execution<'a>, +} + +/// `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, + ) +} + +/// 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], +} + +/// 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('.') +} + +/// `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), + } +} + +struct Rename<'b, 'a> { + builder: &'b AstBuilder<'a>, + names: FxHashMap, +} + +impl<'a> VisitMut<'a> for Rename<'_, 'a> { + fn visit_identifier_reference( + &mut self, + identifier: &mut oxc_ast::ast::IdentifierReference<'a>, + ) { + let mut code = String::new(); + // Dynamic identifiers can hold emitted JS code. Captured names + // were excluded from authored source, so only whole tokens change. + let separator = |ch: char| !ch.is_alphanumeric() && ch != '_' && ch != '$'; + for chunk in identifier.name.split_inclusive(separator) { + let token = chunk.trim_end_matches(separator); + code.push_str(self.names.get(token).map_or(token, String::as_str)); + code.push_str(&chunk[token.len()..]); + } + identifier.name = oxc_ast::ast::Ident::from_in(code.as_str(), self.builder.allocator()); + } +} + +impl<'a> StyledDefinition<'a> { + fn visit_expressions( + &mut self, + builder: &AstBuilder<'a>, + mut visit: impl FnMut(&mut Expression<'a>), + ) { + for expression in self + .target + .iter_mut() + .chain(&mut self.bound) + .chain(&mut self.classes) + .chain(&mut self.attrs) + { + visit(expression); + } + for (_, value) in &mut self.bindings { + visit(value); + } + for style in &mut self.styles { + visit_style_expressions(builder, style, &mut visit); + } + } + + fn rebind(&self, builder: &AstBuilder<'a>, scope: &mut Expression<'a>) -> Self { + if !self.execution.replays() && self.bindings.is_empty() { + return self.clone_in(builder.allocator()); + } + let allocator = builder.allocator(); + let mut inherited = Self { + name: self.name.clone(), + target: self.target.as_ref().map(|value| value.clone_in(allocator)), + bound: self.bound.as_ref().map(|value| value.clone_in(allocator)), + classes: clone_all(builder, &self.classes).collect(), + styles: self + .styles + .iter() + .map(|style| style.clone_in(allocator)) + .collect(), + attrs: clone_all(builder, &self.attrs).collect(), + reads: self.reads.clone(), + forward: self.forward.clone(), + markers: self.markers.clone(), + inherited_markers: self.inherited_markers.clone(), + capability: self.capability.clone(), + execution: self.execution.clone_in(allocator), + bindings: self + .bindings + .iter() + .map(|(name, value)| (name.clone(), value.clone_in(allocator))) + .collect(), + }; + let mut reserved = vec![scope.clone_in(allocator)]; + inherited.visit_expressions(builder, |value| reserved.push(value.clone_in(allocator))); + reserved.extend(inherited.bindings.iter().map(|(name, _)| { + Expression::new_identifier( + SPAN, + oxc_ast::ast::Ident::from_in(name.as_str(), allocator), + builder, + ) + })); + let reservation = Expression::new_array_expression( + SPAN, + oxc_allocator::Vec::from_iter_in(reserved.into_iter().map(Into::into), builder), + builder, + ); + let mut reads = Evaluations::new(builder, readable_code(&reservation)); + let mut rename = Rename { + builder, + names: FxHashMap::default(), + }; + for (name, _) in &mut inherited.bindings { + let fresh = reads.fresh_name(); + rename.names.insert(name.clone(), fresh.clone()); + *name = fresh; + } + for (_, value) in &mut inherited.bindings { + rename.visit_expression(value); + } + for style in &mut inherited.styles { + visit_style_expressions(builder, style, &mut |value| rename.visit_expression(value)); + } + let mut reserved = vec![reservation]; + reserved.extend(inherited.bindings.iter().map(|(name, _)| { + Expression::new_identifier( + SPAN, + oxc_ast::ast::Ident::from_in(name.as_str(), allocator), + builder, + ) + })); + *scope = Expression::new_array_expression( + SPAN, + oxc_allocator::Vec::from_iter_in(reserved.into_iter().map(Into::into), builder), + builder, + ); + 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, self.native(), 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>])> { + (self.fixed_native() && self.bindings.is_empty() && self.execution.replays()) + .then_some((self.name.as_str(), self.styles.as_slice())) + } + + fn fixed_native(&self) -> bool { + 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) + } + + fn with_css_slot(mut self, slot: Option<&StyledCapability>, inherited: Option<&Self>) -> Self { + self.capability = slot + .filter(|_| { + (self.fixed_native() && !self.bindings.is_empty()) + || !self.execution.replays() + || (!self.native() + && self.fixed_summary() + && inherited.is_some_and(|base| { + base.bindings.is_empty() + && base.fixed_summary() + && (base.portable() || base.capability.is_some()) + })) + }) + .cloned(); + self + } + + pub(crate) fn css_slot(&self) -> Option<&StyledCssSlot> { + self.capability + .as_ref() + .filter(|_| self.fixed_summary()) + .map(|capability| &capability.classes) + } + + /// 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(), + target: self + .target + .as_ref() + .map(|target| target.clone_in(allocator)), + 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(), + inherited_markers: self.inherited_markers.clone(), + bindings: self + .bindings + .iter() + .map(|(name, value)| (name.clone(), value.clone_in(allocator))) + .collect(), + capability: self.capability.clone(), + execution: self.execution.clone_in(allocator), + } + } + + /// 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 { + self.bindings.is_empty() + && self.execution.replays() + && 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, + }), + ) + } + + fn with_markers(mut self, markers: &[String], inherited: Option<&Self>) -> Self { + self.markers = markers.to_vec(); + self.inherited_markers = inherited + .into_iter() + .flat_map(|definition| { + definition + .markers + .iter() + .chain(&definition.inherited_markers) + }) + .cloned() + .collect(); + self + } + + pub(crate) fn marker_classes(&self, ast: &AstBuilder<'a>) -> Option> { + merge_expression_for_class_name( + ast, + self.markers + .iter() + .chain(&self.inherited_markers) + .map(|marker| marker_class(ast, marker)), + ) + } + + /// 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>, + naming: Naming<'_>, + context: StyledContext<'_>, +) -> Option<(Expression<'a>, StyledDefinition<'a>)> { + let name = match unwrap_syntax_only(target) { + Expression::StringLiteral(literal) => literal.value.to_string(), + target => jsx_name(target)?, + }; + let allocator = ast_builder.allocator(); + let base = Base { + target: match unwrap_syntax_only(target) { + Expression::StringLiteral(_) => None, + _ => Some(target.clone_in(allocator)), + }, + ..Base::named(name) + }; + let mut scope = target.clone_in(allocator); + let mut definition = definition.rebind(ast_builder, &mut scope); + match &mut definition.execution { + Execution::Delegated(renderer) => renderer.native = renders_tag(&base.name, None), + Execution::Replay | Execution::RetainedFixed { .. } => {} + } + let mut styles: Vec> = definition + .styles + .iter() + .map(|style| style.clone_in(allocator)) + .collect(); + let classes: Vec> = clone_all(ast_builder, &definition.classes).collect(); + let class_name = merge_expression_for_class_name( + ast_builder, + clone_all(ast_builder, &classes).chain(gen_class_names( + ast_builder, + &mut styles, + None, + naming.split_filename, + )), + ); + let withheld = withheld( + &definition.reads, + renders_tag(&base.name, None), + definition.forward.as_ref(), + ); + let new_definition = base + .definition( + ast_builder, + classes, + &styles, + &definition.attrs, + (definition.reads.clone(), definition.forward.clone()), + &definition.bindings, + ) + .with_execution(ast_builder, Some(&definition)) + .with_css_slot(context.capability, Some(&definition)) + .with_markers(naming.markers, Some(&definition)); + let component = create_styled_component( + ast_builder, + &base, + StyledRender { + class_name: &class_name, + style_vars: &gen_styles(ast_builder, &styles, None), + bindings: definition + .bindings + .iter() + .map(|(name, value)| (name.clone(), value.clone_in(allocator))) + .collect(), + withheld: &withheld, + capability: new_definition.capability.as_ref(), + execution: &new_definition.execution, + markers: new_definition.render_markers(ast_builder), + }, + context.jsx, + ); + Some(( + new_definition.retain_bound( + ast_builder, + base.render( + ast_builder, + apply_attrs(ast_builder, component, &definition.attrs), + ), + ), + new_definition, + )) +} + +/// What extracting a styled component gives +pub struct StyledExtraction<'a> { + pub result: ExtractResult<'a>, + 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| { + ( + clone_all(ast_builder, &inherited.classes).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(), + target: inherited + .target + .as_ref() + .map(|target| target.clone_in(ast_builder.allocator())), + 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>], + props: (Reads, Option), + bindings: &[(String, Expression<'a>)], + ) -> StyledDefinition<'a> { + let allocator = ast_builder.allocator(); + let (reads, forward) = props; + StyledDefinition { + name: self.name.clone(), + target: self + .target + .as_ref() + .map(|target| target.clone_in(allocator)), + 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(), + inherited_markers: Vec::new(), + capability: None, + execution: Execution::Replay, + bindings: bindings + .iter() + .map(|(name, value)| (name.clone(), value.clone_in(allocator))) + .collect(), + } + } + const fn named(name: String) -> Self { Self { name, @@ -63,6 +637,7 @@ impl<'a> Base<'a> { /// `component` with the base bound to the name it renders, when it is a /// runtime value fn render(self, ast_builder: &AstBuilder<'a>, component: Expression<'a>) -> Expression<'a> { + let component = forward_ref(ast_builder, component); match self.bound { Some(bound) => call_with_values(ast_builder, vec![(self.name, bound)], component), None => component, @@ -70,6 +645,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>, @@ -189,12 +787,25 @@ 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>, + naming: Naming<'_>, imports: &FxHashMap, - attrs: &[Expression<'a>], - helper: &mut Option, -) -> (ExtractResult<'a>, Expression<'a>, Vec<(u32, String)>) { - let original_scope = expression.clone_in(ast_builder.allocator()); + modifiers: (&[Expression<'a>], Option), + inherited: Option<&StyledDefinition<'a>>, + context: StyledContext<'_>, +) -> StyledExtraction<'a> { + let Naming { + split_filename, + markers, + } = naming; + let (attrs, forward) = modifiers; + let mut original_scope = expression.clone_in(ast_builder.allocator()); + let inherited = inherited.map(|definition| definition.rebind(ast_builder, &mut original_scope)); + let inherited = inherited.as_ref(); + 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 errors = Vec::new(); if let Expression::CallExpression(call) = expression @@ -220,7 +831,8 @@ 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) { // Case 1: styled.div`css` or styled("div")`css` @@ -240,13 +852,17 @@ pub fn extract_style_from_styled<'a>( ) })); if !errors.is_empty() { - return ( - ExtractResult::default(), - Expression::new_string_literal(SPAN, "", None, ast_builder), + return StyledExtraction { + result: ExtractResult::default(), + expression: Expression::new_string_literal(SPAN, "", None, ast_builder), errors, - ); + definition: None, + }; } + for interpolation in &tag.quasi.expressions { + reads.read_in(interpolation); + } let TemplateStyles { styles, statements, @@ -263,16 +879,16 @@ pub fn extract_style_from_styled<'a>( .filter(|expression| crate::css_utils::reads_unmapped_theme(expression)) .map(|expression| crate::css_utils::unmapped_theme_error("styled", expression)), ); - let mut props_styles: Vec> = styles + let own: Vec> = styles .into_iter() .map(|ex| ExtractStyleProp::Static(ex.into())) .collect(); + 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); - if let Some(default_class_name) = base.styles.take() { - props_styles.extend(default_class_name.into_iter().map(ExtractStyleProp::Static)); - } - - 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), @@ -293,18 +909,15 @@ 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() - .chain(gen_class_names( - ast_builder, - &mut props_styles, - None, - split_filename, - )), + clone_all(ast_builder, &classes).chain(gen_class_names( + ast_builder, + &mut props_styles, + None, + split_filename, + )), ); let tag = Some(Expression::new_string_literal( SPAN, @@ -312,17 +925,51 @@ pub fn extract_style_from_styled<'a>( None, ast_builder, )); + let bindings = inherited.map_or_else(Vec::new, |definition| { + definition + .bindings + .iter() + .map(|(name, value)| (name.clone(), value.clone_in(ast_builder.allocator()))) + .collect() + }); + let withheld = withheld( + &reads, + inherited.map_or_else( + || renders_tag(&base.name, base.bound.as_ref()), + StyledDefinition::native, + ), + forward.as_ref(), + ); + let definition = base + .definition( + ast_builder, + classes, + &props_styles, + &attrs, + (reads, forward), + &bindings, + ) + .with_execution(ast_builder, inherited) + .with_css_slot(context.capability, inherited) + .with_markers(markers, inherited); let component = create_styled_component( ast_builder, &base, StyledRender { class_name: &class_name, style_vars: &gen_styles(ast_builder, &props_styles, None), - bindings: Vec::new(), + bindings, + withheld: &withheld, + capability: definition.capability.as_ref(), + execution: &definition.execution, + markers: definition.render_markers(ast_builder), }, - helper, + context.jsx, + ); + let styled_component = definition.retain_bound( + ast_builder, + base.render(ast_builder, apply_attrs(ast_builder, component, &attrs)), ); - let styled_component = base.render(ast_builder, apply_attrs(ast_builder, component, attrs)); let result = ExtractResult { styles: props_styles, @@ -332,7 +979,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) @@ -340,16 +987,30 @@ 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() + }, + ); let prepared = call.arguments[style_index] .as_expression() .and_then(|rules| prepare(ast_builder, rules, &original_scope)); - let bindings = match prepared { + let mut bindings: Vec<_> = inherited.map_or_else(Vec::new, |definition| { + definition + .bindings + .iter() + .map(|(name, value)| (name.clone(), value.clone_in(ast_builder.allocator()))) + .collect() + }); + bindings.extend(match prepared { Some(prepared) => { call.arguments[style_index] = Argument::from(prepared.rules); prepared.bindings } None => Vec::new(), - }; + }); // Extract styles from object expression let ExtractResult { mut styles, @@ -377,19 +1038,46 @@ pub fn extract_style_from_styled<'a>( build_time_error("styled", &code, requirement.unwrap_or(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( + clone_all(ast_builder, &classes).chain(gen_class_names( ast_builder, &mut styles, - style_order, + None, split_filename, )), ); + let withheld = withheld( + &reads, + inherited.map_or_else( + || renders_tag(&base.name, base.bound.as_ref()), + StyledDefinition::native, + ), + forward.as_ref(), + ); + let definition = base + .definition( + ast_builder, + classes, + &styles, + &attrs, + (reads, forward), + &bindings, + ) + .with_execution(ast_builder, inherited) + .with_css_slot(context.capability, inherited) + .with_markers(markers, inherited); let component = create_styled_component( ast_builder, &base, @@ -397,10 +1085,17 @@ pub fn extract_style_from_styled<'a>( class_name: &class_name, style_vars: &gen_styles(ast_builder, &styles, None), bindings, + withheld: &withheld, + capability: definition.capability.as_ref(), + execution: &definition.execution, + markers: definition.render_markers(ast_builder), }, - helper, + context.jsx, + ); + let styled_component = definition.retain_bound( + ast_builder, + base.render(ast_builder, apply_attrs(ast_builder, component, &attrs)), ); - let styled_component = base.render(ast_builder, apply_attrs(ast_builder, component, attrs)); let result = ExtractResult { styles, @@ -410,7 +1105,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 { @@ -421,13 +1116,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| ["(", &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 @@ -436,15 +1211,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 { @@ -454,18 +1230,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; @@ -509,44 +1329,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>, @@ -567,10 +1558,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, @@ -597,10 +1597,147 @@ 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"; + struct StyledRender<'b, 'a> { class_name: &'b Option>, style_vars: &'b Option>, bindings: Vec<(String, Expression<'a>)>, + withheld: &'b [String], + capability: Option<&'b StyledCapability>, + execution: &'b Execution<'a>, + markers: Option>, } fn create_styled_component<'a>( @@ -613,6 +1750,10 @@ fn create_styled_component<'a>( class_name, style_vars, bindings, + withheld, + capability, + execution, + markers, } = render; let params = FormalParameters::boxed( SPAN, @@ -645,6 +1786,49 @@ 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, + ), + base.target.as_ref().map_or_else( + || { + Expression::new_string_literal( + SPAN, + Str::from_in(&base.name, ast_builder.allocator()), + None, + ast_builder, + ) + }, + |target| target.clone_in(ast_builder.allocator()), + ), + 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, ), @@ -668,7 +1852,22 @@ fn create_styled_component<'a>( None::>>, ast_builder, ); - let class_name = class_name.as_ref().map_or_else( + let replacement = capability.map(|slot| slot.classes.classes(ast_builder, class_name.as_ref())); + let delegated = match execution { + Execution::Delegated(renderer) => Some(renderer), + Execution::Replay | Execution::RetainedFixed { .. } => None, + }; + let own_classes = merge_expression_for_class_name( + ast_builder, + markers.into_iter().chain( + replacement + .as_ref() + .or(class_name.as_ref()) + .filter(|_| delegated.is_none()) + .map(|classes| classes.clone_in(ast_builder.allocator())), + ), + ); + let class_name = own_classes.as_ref().map_or_else( || Expression::new_identifier(SPAN, "className", ast_builder), |name| { wrap_array_filter( @@ -694,19 +1893,28 @@ fn create_styled_component<'a>( .unwrap_or_else(|| style_vars.clone_in(ast_builder.allocator())) }, ); + let omissions = capability.map(|slot| { + let mut omissions = withheld.to_vec(); + slot.omit(&mut omissions); + omissions + }); + let withheld = omissions.as_deref().unwrap_or(withheld); + let ordinary = without_props(ast_builder, withheld); + let forwarded = capability.map_or_else( + || ordinary.clone_in(ast_builder.allocator()), + |slot| slot.forwarded(ast_builder, &ordinary), + ); + let (target, rest, as_prop) = retained::transport( + ast_builder, + delegated, + retained::Transport { + capability, + replacement: replacement.as_ref(), + forwarded, + }, + ); let body = if let Some(helper) = helper { - let target = base.target.as_ref().map_or_else( - || { - Expression::new_string_literal( - SPAN, - Str::from_in(&base.name, ast_builder.allocator()), - None, - ast_builder, - ) - }, - |target| target.clone_in(ast_builder.allocator()), - ); - helper.render(ast_builder, target, (class_name, style)) + helper.render(ast_builder, target, (class_name, style, as_prop), rest) } else { Expression::new_jsx_element( SPAN, @@ -714,15 +1922,24 @@ fn create_styled_component<'a>( SPAN, JSXElementName::new_identifier( SPAN, - Str::from_in(&base.name, ast_builder.allocator()), + Str::from_in( + delegated.map_or(RENDERED, |renderer| renderer.name.as_str()), + ast_builder.allocator(), + ), ast_builder, ), None::>>, oxc_allocator::Vec::from_iter_in( vec![ - JSXAttributeItem::new_spread_attribute( + JSXAttributeItem::new_spread_attribute(SPAN, rest, ast_builder), + JSXAttributeItem::new_attribute( SPAN, - Expression::new_identifier(SPAN, "rest", ast_builder), + JSXAttributeName::new_identifier(SPAN, "as", ast_builder), + Some(JSXAttributeValue::new_expression_container( + SPAN, + as_prop.into(), + ast_builder, + )), ast_builder, ), JSXAttributeItem::new_attribute( @@ -765,3 +1982,69 @@ fn create_styled_component<'a>( ast_builder, ) } + +#[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, + requirement: None, + }, + 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(&["(p) => scale(p.x)"])); + assert!(!closed_code(&["(p) => p.x", "window.mode"])); + assert!(!closed_code(&["@#$"])); + } +} diff --git a/libs/extractor/src/extractor/mod.rs b/libs/extractor/src/extractor/mod.rs index d95289c0d..403957c41 100644 --- a/libs/extractor/src/extractor/mod.rs +++ b/libs/extractor/src/extractor/mod.rs @@ -9,6 +9,8 @@ pub(super) mod extract_style_from_jsx; pub(super) mod extract_style_from_member_expression; pub(super) mod extract_style_from_styled; pub(super) mod extract_style_from_stylex; +pub(super) mod styled_capability; +pub(super) mod styled_css_slot; pub(super) mod styled_jsx; mod styled_record; mod styled_record_evaluation; diff --git a/libs/extractor/src/extractor/styled_capability.rs b/libs/extractor/src/extractor/styled_capability.rs new file mode 100644 index 000000000..aac7b888f --- /dev/null +++ b/libs/extractor/src/extractor/styled_capability.rs @@ -0,0 +1,37 @@ +use oxc_ast::{ + ast::{Expression, Program}, + builder::AstBuilder, +}; + +use super::styled_css_slot::StyledCssSlot; + +/// Independently reserved complete replacements consumed by the defining renderer. +#[derive(Clone)] +pub(crate) struct StyledCapability { + pub classes: StyledCssSlot, + pub forwarded: StyledCssSlot, +} + +impl StyledCapability { + pub(crate) fn reserve(program: &Program<'_>) -> Self { + Self { + classes: StyledCssSlot::reserve(program), + forwarded: StyledCssSlot::reserve_named(program, "__devupForwarded"), + } + } + + pub(crate) fn omit(&self, names: &mut Vec) { + names.extend([ + self.classes.name().to_string(), + self.forwarded.name().to_string(), + ]); + } + + pub(crate) fn forwarded<'a>( + &self, + ast: &AstBuilder<'a>, + ordinary: &Expression<'a>, + ) -> Expression<'a> { + self.forwarded.classes(ast, Some(ordinary)) + } +} diff --git a/libs/extractor/src/extractor/styled_css_slot.rs b/libs/extractor/src/extractor/styled_css_slot.rs new file mode 100644 index 000000000..6d2746f57 --- /dev/null +++ b/libs/extractor/src/extractor/styled_css_slot.rs @@ -0,0 +1,170 @@ +use oxc_allocator::{CloneIn, FromIn, GetAllocator}; +use oxc_ast::{ + ast::{ + BindingIdentifier, ComputedMemberExpression, Expression, IdentifierName, + IdentifierReference, JSXAttributeItem, JSXAttributeName, JSXAttributeValue, JSXIdentifier, + ObjectPropertyKind, Program, PropertyKey, PropertyKind, Str, + }, + builder::AstBuilder, +}; +use oxc_ast_visit::{ + Visit, + walk::{walk_computed_member_expression, walk_property_key}, +}; +use oxc_span::SPAN; +use oxc_syntax::operator::LogicalOperator; +use rustc_hash::FxHashSet; + +use super::styled_capability::StyledCapability; +use super::styled_jsx::StyledJsx; +use crate::utils::unwrap_syntax_only; + +pub(crate) struct StyledContext<'s> { + pub jsx: &'s mut Option, + pub capability: Option<&'s StyledCapability>, +} + +pub(crate) enum CssPropLowering { + Retained, + Inline(String), + Slot(StyledCssSlot), +} + +#[derive(Clone)] +pub(crate) struct StyledCssSlot(String); + +#[derive(Default)] +struct Names(FxHashSet); + +fn static_key_name<'a>(expression: &Expression<'a>) -> Option> { + match unwrap_syntax_only(expression) { + Expression::StringLiteral(literal) => Some(literal.value), + Expression::TemplateLiteral(template) => template.single_quasi(), + Expression::TSTypeAssertion(assertion) => static_key_name(&assertion.expression), + _ => None, + } +} + +impl<'a> Visit<'a> for Names { + fn visit_binding_identifier(&mut self, it: &BindingIdentifier<'a>) { + self.0.insert(it.name.to_string()); + } + + fn visit_identifier_reference(&mut self, it: &IdentifierReference<'a>) { + self.0.insert(it.name.to_string()); + } + + fn visit_identifier_name(&mut self, it: &IdentifierName<'a>) { + self.0.insert(it.name.to_string()); + } + + fn visit_jsx_identifier(&mut self, it: &JSXIdentifier<'a>) { + self.0.insert(it.name.to_string()); + } + + fn visit_property_key(&mut self, it: &PropertyKey<'a>) { + if let Some(name) = it + .as_expression() + .and_then(static_key_name) + .map(|name| name.to_string()) + .or_else(|| it.static_name().map(std::borrow::Cow::into_owned)) + { + self.0.insert(name); + } + walk_property_key(self, it); + } + + fn visit_computed_member_expression(&mut self, it: &ComputedMemberExpression<'a>) { + if let Some(name) = static_key_name(&it.expression).or_else(|| it.static_property_name()) { + self.0.insert(name.to_string()); + } + walk_computed_member_expression(self, it); + } +} + +impl StyledCssSlot { + /// Reserve a private prop before extraction can remove authored names or keys. + pub(crate) fn reserve(program: &Program<'_>) -> Self { + Self::reserve_named(program, "__devupCss") + } + + pub(crate) fn reserve_named(program: &Program<'_>, prefix: &str) -> Self { + let mut names = Names::default(); + names.visit_program(program); + let mut suffix = 0; + loop { + let name = format!("{prefix}{suffix}"); + if !names.0.contains(&name) { + return Self(name); + } + suffix += 1; + } + } + + pub(crate) fn name(&self) -> &str { + &self.0 + } + + pub(crate) fn classes<'a>( + &self, + ast: &AstBuilder<'a>, + own: Option<&Expression<'a>>, + ) -> Expression<'a> { + Expression::new_logical_expression( + SPAN, + Expression::new_static_member_expression( + SPAN, + Expression::new_identifier(SPAN, "rest", ast), + IdentifierName::new(SPAN, Str::from_in(self.name(), ast.allocator()), ast), + false, + ast, + ), + LogicalOperator::Coalesce, + own.map_or_else( + || Expression::new_string_literal(SPAN, "", None, ast), + |own| own.clone_in(ast.allocator()), + ), + ast, + ) + } + + pub(crate) fn attribute<'a>( + &self, + ast: &AstBuilder<'a>, + classes: Option>, + ) -> JSXAttributeItem<'a> { + JSXAttributeItem::new_attribute( + SPAN, + JSXAttributeName::new_identifier(SPAN, Str::from_in(self.name(), ast.allocator()), ast), + Some(JSXAttributeValue::new_expression_container( + SPAN, + classes + .unwrap_or_else(|| Expression::new_string_literal(SPAN, "", None, ast)) + .into(), + ast, + )), + ast, + ) + } + + pub(crate) fn property<'a>( + &self, + ast: &AstBuilder<'a>, + classes: Option>, + ) -> ObjectPropertyKind<'a> { + ObjectPropertyKind::new_object_property( + SPAN, + PropertyKind::Init, + PropertyKey::new_static_identifier( + SPAN, + Str::from_in(self.name(), ast.allocator()), + ast, + ), + classes.unwrap_or_else(|| Expression::new_string_literal(SPAN, "", None, ast)), + false, + false, + false, + ast, + ) + } +} diff --git a/libs/extractor/src/extractor/styled_jsx.rs b/libs/extractor/src/extractor/styled_jsx.rs index 75af546ef..1616e8013 100644 --- a/libs/extractor/src/extractor/styled_jsx.rs +++ b/libs/extractor/src/extractor/styled_jsx.rs @@ -59,16 +59,13 @@ impl StyledJsx { &mut self, ast: &AstBuilder<'a>, target: Expression<'a>, - props: (Expression<'a>, Expression<'a>), + props: (Expression<'a>, Expression<'a>, Expression<'a>), + rest: Expression<'a>, ) -> Expression<'a> { self.used = true; - let mut properties = Vec::with_capacity_in(3, ast); - properties.push(ObjectPropertyKind::new_spread_property( - SPAN, - Expression::new_identifier(SPAN, "rest", ast), - ast, - )); - for (key, value) in [("className", props.0), ("style", props.1)] { + let mut properties = Vec::with_capacity_in(4, ast); + properties.push(ObjectPropertyKind::new_spread_property(SPAN, rest, ast)); + for (key, value) in [("className", props.0), ("style", props.1), ("as", props.2)] { properties.push(ObjectPropertyKind::new_object_property( SPAN, PropertyKind::Init, diff --git a/libs/extractor/src/extractor/styled_jsx_tests.rs b/libs/extractor/src/extractor/styled_jsx_tests.rs index 024c57303..cf5ad2889 100644 --- a/libs/extractor/src/extractor/styled_jsx_tests.rs +++ b/libs/extractor/src/extractor/styled_jsx_tests.rs @@ -22,7 +22,9 @@ fn commonjs_binding_resolves_factory_when_renderer_is_used() { ( Expression::new_identifier(SPAN, "className", &ast), Expression::new_identifier(SPAN, "style", &ast), + Expression::new_string_literal(SPAN, "nav", None, &ast), ), + Expression::new_identifier(SPAN, "rest", &ast), ); // When: the real binding and renderer execute against a CommonJS factory export. program.body.push( @@ -46,7 +48,7 @@ fn commonjs_binding_resolves_factory_when_renderer_is_used() { .as_string() .unwrap_or_else(|| panic!("CommonJS renderer JSON result must be a string")) .to_std_string_escaped(), - r#"{"target":"article","props":{"children":"child","className":"caller","style":{"color":"olive"}}}"#, + r#"{"target":"article","props":{"children":"child","className":"caller","style":{"color":"olive"},"as":"nav"}}"#, ); } diff --git a/libs/extractor/src/extractor/styled_record_evaluation.rs b/libs/extractor/src/extractor/styled_record_evaluation.rs index 6a2ac15c8..6dc227fcf 100644 --- a/libs/extractor/src/extractor/styled_record_evaluation.rs +++ b/libs/extractor/src/extractor/styled_record_evaluation.rs @@ -2,7 +2,7 @@ use oxc_allocator::{CloneIn, FromIn, GetAllocator}; use oxc_ast::{ast::Expression, builder::AstBuilder}; use oxc_span::{GetSpan, SPAN}; -use crate::utils::{call_with_values, is_same_expression}; +use crate::utils::is_same_expression; /// Ordered reads shared by the class and variable expressions of one render. pub(super) struct Evaluations<'b, 'a> { @@ -23,13 +23,7 @@ impl<'b, 'a> Evaluations<'b, 'a> { } pub fn capture(&mut self, value: &Expression<'a>, guard: &Expression<'a>) -> Expression<'a> { - let name = loop { - let name = format!("__devupRecord{}", self.next); - self.next += 1; - if !self.source.contains(&name) { - break name; - } - }; + let name = self.fresh_name(); let value = self.choose(guard, value, &self.undefined()); self.bindings.push((name.clone(), value)); Expression::new_identifier( @@ -39,6 +33,16 @@ impl<'b, 'a> Evaluations<'b, 'a> { ) } + pub fn fresh_name(&mut self) -> String { + loop { + let name = format!("__devupRecord{}", self.next); + self.next += 1; + if !self.source.contains(&name) { + return name; + } + } + } + pub fn boolean(&self, value: bool) -> Expression<'a> { Expression::new_boolean_literal(SPAN, value, self.builder) } @@ -73,76 +77,161 @@ impl<'b, 'a> Evaluations<'b, 'a> { } } -pub(super) fn bind_render<'a>( +fn visit_style_value_expression<'a>( builder: &AstBuilder<'a>, - bindings: Vec<(String, Expression<'a>)>, - mut body: Expression<'a>, -) -> Expression<'a> { - // Dependent arguments must be inside the scope of the preceding read. - for binding in bindings.into_iter().rev() { - body = call_with_values(builder, vec![binding], body); + style: &mut crate::ExtractStyleValue, + visit: &mut impl FnMut(&mut Expression<'a>), +) { + use crate::ExtractStyleValue; + match style { + ExtractStyleValue::Dynamic(value) => { + let mut expression = Expression::new_identifier( + SPAN, + oxc_ast::ast::Ident::from_in(value.identifier(), builder.allocator()), + builder, + ); + visit(&mut expression); + *value.identifier_mut() = crate::utils::readable_code(&expression); + } + ExtractStyleValue::Static(_) + | ExtractStyleValue::Typography(_) + | ExtractStyleValue::Css(_) + | ExtractStyleValue::Import(_) + | ExtractStyleValue::FontFace(_) + | ExtractStyleValue::Keyframes(_) => {} } - body } -#[cfg(test)] -mod tests { - use boa_engine::{Context, Source}; - use oxc_allocator::Allocator; - use oxc_ast::ast::Statement; - use oxc_parser::Parser; - use oxc_span::SourceType; - use rstest::rstest; - - use super::*; - use crate::utils::readable_code; - - #[rstest] - #[case::equal_true(true, true)] - #[case::equal_false(false, false)] - #[case::different_true(true, false)] - #[case::different_false(false, true)] - fn boolean_choice_when_test_is_captured( - #[case] yes: bool, - #[case] no: bool, - #[values(false, true)] on: bool, - ) { - let allocator = Allocator::default(); - let builder = AstBuilder::new(&allocator); - let source = "(trace.push('test'), on);"; - let parsed = Parser::new(&allocator, source, SourceType::tsx()).parse(); - assert_eq!(parsed.diagnostics.len(), 0); - let Statement::ExpressionStatement(statement) = &parsed.program.body[0] else { - panic!("test fixture must be an expression"); - }; - let mut reads = Evaluations::new(&builder, source.to_string()); - let test = reads.capture(&statement.expression, &reads.boolean(true)); - let choice = reads.choose(&test, &reads.boolean(yes), &reads.boolean(no)); - match &choice { - Expression::BooleanLiteral(value) => { - assert_eq!(yes, no); - assert_eq!(value.value, yes); +pub(super) fn visit_style_expressions<'a>( + builder: &AstBuilder<'a>, + style: &mut crate::ExtractStyleProp<'a>, + visit: &mut impl FnMut(&mut Expression<'a>), +) { + use crate::ExtractStyleProp; + match style { + ExtractStyleProp::Static(value) => visit_style_value_expression(builder, value, visit), + ExtractStyleProp::Unreadable { .. } => {} + ExtractStyleProp::StaticArray(styles) => { + for style in styles { + visit_style_expressions(builder, style, visit); + } + } + ExtractStyleProp::Conditional { + condition, + consequent, + alternate, + } => { + visit(condition); + for style in consequent.iter_mut().chain(alternate.iter_mut()) { + visit_style_expressions(builder, style, visit); + } + } + ExtractStyleProp::Enum { condition, map } => { + visit(condition); + for style in map.values_mut().flatten() { + visit_style_expressions(builder, style, visit); + } + } + ExtractStyleProp::Expression { expression, styles } => { + visit(expression); + for style in styles { + visit_style_value_expression(builder, style, visit); } - Expression::ConditionalExpression(_) => assert_ne!(yes, no), - _ => panic!("Boolean choice must be constant or conditional"), } - let render = bind_render(&builder, reads.bindings, choice); - let script = format!( - "const trace = []; const on = {on}; const result = {}; JSON.stringify([result, trace]);", - readable_code(&render) + ExtractStyleProp::MemberExpression { expression, map } => { + visit(expression); + for style in map.values_mut() { + visit_style_expressions(builder, style, visit); + } + } + } +} + +pub(super) fn bind_render<'a>( + builder: &AstBuilder<'a>, + bindings: Vec<(String, Expression<'a>)>, + body: Expression<'a>, +) -> Expression<'a> { + use oxc_allocator::Vec as ArenaVec; + use oxc_ast::ast::{ + ArrowFunctionBody, BindingPattern, FormalParameterKind, FormalParameters, Statement, + VariableDeclaration, VariableDeclarationKind, VariableDeclarator, + }; + if bindings.is_empty() { + return body; + } + // Sibling declarations preserve dependent read order without one scope per capture. + let mut statements = ArenaVec::with_capacity_in(bindings.len() + 1, builder); + for (name, value) in bindings { + let name = oxc_ast::ast::Ident::from_in(name.as_str(), builder.allocator()); + // Sequence evaluation prevents declaration-name inference for anonymous definitions. + let value = Expression::new_sequence_expression( + SPAN, + ArenaVec::from_array_in( + [ + Expression::new_numeric_literal( + SPAN, + 0.0, + None, + oxc_syntax::number::NumberBase::Decimal, + builder, + ), + value, + ], + builder, + ), + builder, ); - let mut context = Context::default(); - let result = match context.eval(Source::from_bytes(script.as_bytes())) { - Ok(result) => result, - Err(error) => panic!("Boolean choice evaluation failed: {error}"), - }; - let result = match result.as_string() { - Some(result) => result.to_std_string_escaped(), - None => panic!("Boolean choice must return a JSON string"), - }; - assert_eq!( - result, - format!(r#"[{},["test"]]"#, if on { yes } else { no }) + let declaration = VariableDeclarator::new( + SPAN, + BindingPattern::new_binding_identifier(SPAN, name, builder), + None, + Some(value), + false, + builder, ); + statements.push(Statement::VariableDeclaration(VariableDeclaration::boxed( + SPAN, + VariableDeclarationKind::Const, + ArenaVec::from_array_in([declaration], builder), + false, + builder, + ))); } + statements.push(Statement::new_return_statement(SPAN, Some(body), builder)); + let arrow = Expression::new_arrow_function_expression( + SPAN, + false, + None, + FormalParameters::boxed( + SPAN, + FormalParameterKind::ArrowFormalParameters, + ArenaVec::new_in(builder), + None, + builder, + ), + None, + ArrowFunctionBody::new_function_body(SPAN, ArenaVec::new_in(builder), statements, builder), + builder, + ); + Expression::new_call_expression( + SPAN, + Expression::new_parenthesized_expression(SPAN, arrow, builder), + None, + ArenaVec::new_in(builder), + false, + builder, + ) } + +#[cfg(test)] +#[path = "styled_record_evaluation_visitor_tests.rs"] +mod visitor_tests; + +#[cfg(test)] +#[path = "styled_record_evaluation_depth_tests.rs"] +mod depth_tests; + +#[cfg(test)] +#[path = "styled_record_evaluation_tests.rs"] +mod tests; diff --git a/libs/extractor/src/extractor/styled_record_evaluation_depth_tests.rs b/libs/extractor/src/extractor/styled_record_evaluation_depth_tests.rs new file mode 100644 index 000000000..77d53d175 --- /dev/null +++ b/libs/extractor/src/extractor/styled_record_evaluation_depth_tests.rs @@ -0,0 +1,185 @@ +use boa_engine::{Context, Source}; +use oxc_allocator::{Allocator, CloneIn}; +use oxc_ast::{ast::Statement, builder::AstBuilder}; +use oxc_parser::Parser; +use oxc_span::SourceType; +use rstest::rstest; + +use super::{Evaluations, bind_render}; +use crate::utils::{call_with_values, readable_code}; + +#[rstest] +fn ordered_captures_when_many_guarded_reads_depend_on_prior_bindings( + #[values(false, true)] outer: bool, + #[values(false, true)] inner: bool, +) { + // Given: shallow authored expressions, reserved names and dependent guarded reads. + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let mut reads = Evaluations::new(&builder, "__devupRecord0 __devupRecord1".to_string()); + let parse = |source: &str| { + let parsed = Parser::new(&allocator, source, SourceType::mjs()).parse(); + assert_eq!(parsed.diagnostics.len(), 0); + let Statement::ExpressionStatement(statement) = &parsed.program.body[0] else { + panic!("capture fixture must be an expression"); + }; + statement.expression.clone_in(&allocator) + }; + let outer_read = reads.capture( + &parse("(trace.push('outer'), outer);"), + &reads.boolean(true), + ); + let inner_read = reads.capture(&parse("(trace.push('inner'), inner);"), &outer_read); + let guard = reads.choose(&outer_read, &inner_read, &reads.boolean(false)); + let mut previous = "-1".to_string(); + let mut values = Vec::new(); + for index in 0..32 { + let value = reads.capture( + &parse(&format!("(trace.push('leaf:{index}'), {previous} + 1);")), + &guard, + ); + previous = readable_code(&value); + values.push(previous.clone()); + } + let body = parse(&format!( + "JSON.stringify([__devupRecord0,__devupRecord1,[{}].filter(v=>v!==undefined),trace]);", + values.join(",") + )); + + // When: parse and execute production binding emission at the default test stack. + let render = bind_render(&builder, reads.bindings, body); + let script = format!( + "const trace=[];const outer={outer},inner={inner},__devupRecord0='authored',__devupRecord1='collision';{};", + readable_code(&render) + ); + let mut context = Context::default(); + let result = context + .eval(Source::from_bytes(script.as_bytes())) + .unwrap_or_else(|error| panic!("dependent capture execution failed: {error}")); + let result = result + .as_string() + .unwrap_or_else(|| panic!("dependent capture observation must be JSON")) + .to_std_string_escaped(); + + // Then: every selected read runs once in order; absent branches never read leaves. + let mut trace = vec!["outer".to_string()]; + let mut expected_values = Vec::new(); + if outer { + trace.push("inner".to_string()); + if inner { + for index in 0..32 { + trace.push(format!("leaf:{index}")); + expected_values.push(index); + } + } + } + let expected = serde_json::to_string(&("authored", "collision", expected_values, trace)) + .unwrap_or_else(|error| panic!("capture observation serialization failed: {error}")); + assert_eq!(result, expected); +} + +#[rstest] +#[case::class("class {static {trace.push(this.name)}}", "")] +#[case::function("function(){trace.push(arguments.callee.name)}", "controller();")] +fn anonymous_names_when_prepared_controller_is_bound( + #[case] initializer: &str, + #[case] observe: &str, + #[values(false, true)] bounded: bool, +) { + // Given: the authored styled conditional spread reaches typed preparation. + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let source = format!("styled.div({{color:'orange',...({initializer}?{{color:'red'}}:{{}})}});"); + let parsed = Parser::new(&allocator, &source, SourceType::tsx()).parse(); + assert_eq!(parsed.diagnostics.len(), 0); + let Statement::ExpressionStatement(statement) = &parsed.program.body[0] else { + panic!("styled fixture must be an expression"); + }; + let oxc_ast::ast::Expression::CallExpression(call) = &statement.expression else { + panic!("styled fixture must be a call"); + }; + let rules = call.arguments[0].to_expression(); + let prepared = super::super::styled_record::prepare(&builder, rules, &statement.expression) + .unwrap_or_else(|| panic!("anonymous controller must require preparation")); + assert_eq!(prepared.bindings.len(), 1); + let body_source = format!( + "((record,controller)=>{{{observe}return JSON.stringify([record.color,controller.name,trace]);}})({},{});", + readable_code(&prepared.rules), + prepared.bindings[0].0 + ); + let body = Parser::new(&allocator, &body_source, SourceType::tsx()).parse(); + assert_eq!(body.diagnostics.len(), 0); + let Statement::ExpressionStatement(body) = &body.program.body[0] else { + panic!("observation must be an expression"); + }; + + // When: execute either the old argument seam or the production bounded binder. + let body = body.expression.clone_in(&allocator); + let render = if bounded { + bind_render(&builder, prepared.bindings, body) + } else { + call_with_values(&builder, prepared.bindings, body) + }; + let script = format!("const trace=[];{};", readable_code(&render)); + let mut context = Context::default(); + let result = context + .eval(Source::from_bytes(script.as_bytes())) + .unwrap_or_else(|error| panic!("prepared anonymous controller execution failed: {error}")); + let result = result + .as_string() + .unwrap_or_else(|| panic!("anonymous controller observation must be JSON")) + .to_std_string_escaped(); + + // Then: later color wins and static/self-name observers see the original empty name. + assert_eq!(result, r#"["red","",[""]]"#); +} + +#[rstest] +#[case::class("class {static {trace.push(this.name)}}", "")] +#[case::function("function(){trace.push(arguments.callee.name)}", "value();")] +fn anonymous_names_when_capture_initializer_is_bound( + #[case] initializer: &str, + #[case] observe: &str, + #[values(false, true)] bounded: bool, +) { + // Given: an anonymous expression captured under the production literal-true guard. + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let source = format!("({initializer});"); + let parsed = Parser::new(&allocator, &source, SourceType::tsx()).parse(); + assert_eq!(parsed.diagnostics.len(), 0); + let Statement::ExpressionStatement(statement) = &parsed.program.body[0] else { + panic!("anonymous fixture must be an expression"); + }; + let mut reads = Evaluations::new(&builder, source.clone()); + let value = reads.capture(&statement.expression, &reads.boolean(true)); + let body_source = format!( + "(value=>{{{observe}return JSON.stringify([value.name,trace]);}})({});", + readable_code(&value) + ); + let body = Parser::new(&allocator, &body_source, SourceType::tsx()).parse(); + assert_eq!(body.diagnostics.len(), 0); + let Statement::ExpressionStatement(body) = &body.program.body[0] else { + panic!("observation must be an expression"); + }; + + // When: execute either the old argument seam or the production bounded binder. + let body = body.expression.clone_in(&allocator); + let render = if bounded { + bind_render(&builder, reads.bindings, body) + } else { + call_with_values(&builder, reads.bindings, body) + }; + let script = format!("const trace=[];{};", readable_code(&render)); + let mut context = Context::default(); + let result = context + .eval(Source::from_bytes(script.as_bytes())) + .unwrap_or_else(|error| panic!("anonymous capture execution failed: {error}")); + let result = result + .as_string() + .unwrap_or_else(|| panic!("anonymous capture observation must be JSON")) + .to_std_string_escaped(); + + // Then: both the value's name and its authored observer retain argument semantics. + assert_eq!(result, r#"["",[""]]"#); +} diff --git a/libs/extractor/src/extractor/styled_record_evaluation_tests.rs b/libs/extractor/src/extractor/styled_record_evaluation_tests.rs new file mode 100644 index 000000000..f6c326ec6 --- /dev/null +++ b/libs/extractor/src/extractor/styled_record_evaluation_tests.rs @@ -0,0 +1,58 @@ +use boa_engine::{Context, Source}; +use oxc_allocator::Allocator; +use oxc_ast::ast::Statement; +use oxc_parser::Parser; +use oxc_span::SourceType; +use rstest::rstest; + +use super::*; +use crate::utils::readable_code; + +#[rstest] +#[case::equal_true(true, true)] +#[case::equal_false(false, false)] +#[case::different_true(true, false)] +#[case::different_false(false, true)] +fn boolean_choice_when_test_is_captured( + #[case] yes: bool, + #[case] no: bool, + #[values(false, true)] on: bool, +) { + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let source = "(trace.push('test'), on);"; + let parsed = Parser::new(&allocator, source, SourceType::tsx()).parse(); + assert_eq!(parsed.diagnostics.len(), 0); + let Statement::ExpressionStatement(statement) = &parsed.program.body[0] else { + panic!("test fixture must be an expression"); + }; + let mut reads = Evaluations::new(&builder, source.to_string()); + let test = reads.capture(&statement.expression, &reads.boolean(true)); + let choice = reads.choose(&test, &reads.boolean(yes), &reads.boolean(no)); + match &choice { + Expression::BooleanLiteral(value) => { + assert_eq!(yes, no); + assert_eq!(value.value, yes); + } + Expression::ConditionalExpression(_) => assert_ne!(yes, no), + _ => panic!("Boolean choice must be constant or conditional"), + } + let render = bind_render(&builder, reads.bindings, choice); + let script = format!( + "const trace = []; const on = {on}; const result = {}; JSON.stringify([result, trace]);", + readable_code(&render) + ); + let mut context = Context::default(); + let result = match context.eval(Source::from_bytes(script.as_bytes())) { + Ok(result) => result, + Err(error) => panic!("Boolean choice evaluation failed: {error}"), + }; + let result = match result.as_string() { + Some(result) => result.to_std_string_escaped(), + None => panic!("Boolean choice must return a JSON string"), + }; + assert_eq!( + result, + format!(r#"[{},["test"]]"#, if on { yes } else { no }) + ); +} diff --git a/libs/extractor/src/extractor/styled_record_evaluation_visitor_tests.rs b/libs/extractor/src/extractor/styled_record_evaluation_visitor_tests.rs new file mode 100644 index 000000000..8cc7b8dcf --- /dev/null +++ b/libs/extractor/src/extractor/styled_record_evaluation_visitor_tests.rs @@ -0,0 +1,301 @@ +use std::collections::BTreeMap; + +use oxc_allocator::{Allocator, FromIn, GetAllocator}; +use oxc_ast::{ast::Expression, builder::AstBuilder}; +use oxc_span::SPAN; +use rstest::rstest; + +use super::visit_style_expressions; +use crate::{ + ExtractStyleProp, ExtractStyleValue, + extract_style::{ + extract_dynamic_style::ExtractDynamicStyle, extract_static_style::ExtractStaticStyle, + }, + utils::readable_code, +}; + +#[derive(Clone, Copy)] +enum Container { + Conditional, + StaticArray, + Enum, + Expression, + MemberExpression, +} + +fn identifier<'a>(builder: &AstBuilder<'a>, name: &str) -> Expression<'a> { + Expression::new_identifier( + SPAN, + oxc_ast::ast::Ident::from_in(name, builder.allocator()), + builder, + ) +} + +fn payload<'a>(builder: &AstBuilder<'a>, name: &str) -> ExtractStyleProp<'a> { + ExtractStyleProp::Expression { + expression: identifier(builder, name), + styles: vec![ + ExtractStyleValue::Dynamic(ExtractDynamicStyle::new("color", 2, name, None)), + ExtractStyleValue::Dynamic(ExtractDynamicStyle::new( + "background", + 4, + &format!("{name} + {name}.color !important"), + None, + )), + ExtractStyleValue::Static(ExtractStaticStyle::new( + "--authored", + "__devupRecord0", + 3, + None, + )), + ], + } +} + +fn assert_identifier(expression: &Expression<'_>, expected: &str) { + let Expression::Identifier(identifier) = expression else { + panic!("visitor must preserve the identifier expression"); + }; + assert_eq!(identifier.name.as_str(), expected); + assert_eq!(readable_code(expression), expected); +} + +fn assert_payload(style: &ExtractStyleProp<'_>, expected: &str) { + let ExtractStyleProp::Expression { expression, styles } = style else { + panic!("fixture payload must remain an Expression"); + }; + assert_identifier(expression, expected); + assert_eq!(styles.len(), 3); + let ExtractStyleValue::Dynamic(simple) = &styles[0] else { + panic!("first payload must remain dynamic"); + }; + assert_eq!(simple.identifier(), expected); + assert_eq!( + simple, + &ExtractDynamicStyle::new("color", 2, expected, None) + ); + let ExtractStyleValue::Dynamic(code) = &styles[1] else { + panic!("second payload must remain dynamic code"); + }; + assert_eq!(code.identifier(), format!("{expected} + {expected}.color")); + assert_eq!( + code, + &ExtractDynamicStyle::new( + "background", + 4, + &format!("{expected} + {expected}.color !important"), + None, + ) + ); + assert_eq!( + styles[2], + ExtractStyleValue::Static(ExtractStaticStyle::new( + "--authored", + "__devupRecord0", + 3, + None, + )) + ); +} + +#[test] +#[should_panic(expected = "visitor must preserve the identifier expression")] +fn identifier_guard_rejects_when_expression_is_null() { + // Given: an AST expression with the wrong shape. + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let expression = Expression::new_null_literal(SPAN, &builder); + // When/Then: the existing identifier assertion rejects it at its shape guard. + assert_identifier(&expression, "__devupRecord0"); +} + +#[test] +#[should_panic(expected = "fixture payload must remain an Expression")] +fn payload_guard_rejects_when_container_is_an_array() { + // Given: a non-Expression container. + let style = ExtractStyleProp::StaticArray(vec![]); + // When/Then: the existing payload assertion rejects the container shape. + assert_payload(&style, "__devupRecord0"); +} + +#[test] +#[should_panic(expected = "first payload must remain dynamic")] +fn payload_guard_rejects_when_first_declaration_is_static() { + // Given: a valid payload with only its first declaration's shape changed. + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let mut style = payload(&builder, "__devupRecord0"); + if let ExtractStyleProp::Expression { styles, .. } = &mut style { + styles[0] = styles[2].clone(); + } + // When/Then: earlier identifier/length checks pass, then the first guard rejects. + assert_payload(&style, "__devupRecord0"); +} + +#[test] +#[should_panic(expected = "second payload must remain dynamic code")] +fn payload_guard_rejects_when_second_declaration_is_static() { + // Given: a valid payload with only its second declaration's shape changed. + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let mut style = payload(&builder, "__devupRecord0"); + if let ExtractStyleProp::Expression { styles, .. } = &mut style { + styles[1] = styles[2].clone(); + } + // When/Then: the first declaration checks pass, then the second guard rejects. + assert_payload(&style, "__devupRecord0"); +} + +#[rstest] +#[case::conditional(Container::Conditional)] +#[case::static_array(Container::StaticArray)] +#[case::enum_map(Container::Enum)] +#[case::expression(Container::Expression)] +#[case::member_map(Container::MemberExpression)] +fn payloads_are_rewritten_when_style_expressions_are_visited(#[case] container: Container) { + // Given: two distinct nested captures, including serialized dynamic code. + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let first = payload(&builder, "__devupRecord0"); + let second = payload(&builder, "__devupRecord1"); + let control = identifier(&builder, "__devupRecord2"); + let mut style = match container { + Container::Conditional => ExtractStyleProp::Conditional { + condition: control, + consequent: Some(Box::new(first)), + alternate: Some(Box::new(second)), + }, + Container::StaticArray => ExtractStyleProp::StaticArray(vec![first, second]), + Container::Enum => ExtractStyleProp::Enum { + condition: control, + map: BTreeMap::from([ + ("__devupRecord0".to_string(), vec![first, second]), + ("empty".to_string(), vec![]), + ]), + }, + Container::Expression => first, + Container::MemberExpression => ExtractStyleProp::MemberExpression { + expression: control, + map: BTreeMap::from([ + ("__devupRecord0".to_string(), Box::new(first)), + ("second".to_string(), Box::new(second)), + ]), + }, + }; + + // When: the callback rewrites actual AST identifier references/code payloads. + visit_style_expressions(&builder, &mut style, &mut |expression| { + let Expression::Identifier(value) = expression else { + panic!("fixture visitor receives identifier expressions"); + }; + let renamed = value.name.replace("__devupRecord", "__devupInherited"); + value.name = oxc_ast::ast::Ident::from_in(renamed.as_str(), builder.allocator()); + }); + + // Then: every branch/map/array entry and AST control was rewritten. + match &style { + ExtractStyleProp::Conditional { + condition, + consequent, + alternate, + } => { + assert_identifier(condition, "__devupInherited2"); + let (Some(consequent), Some(alternate)) = (consequent, alternate) else { + panic!("both conditional branches must remain present"); + }; + assert_payload(consequent, "__devupInherited0"); + assert_payload(alternate, "__devupInherited1"); + } + ExtractStyleProp::StaticArray(styles) => { + assert_eq!(styles.len(), 2); + assert_payload(&styles[0], "__devupInherited0"); + assert_payload(&styles[1], "__devupInherited1"); + } + ExtractStyleProp::Enum { condition, map } => { + assert_identifier(condition, "__devupInherited2"); + assert_eq!(map.len(), 2); + assert_eq!(map["empty"].len(), 0); + assert_payload(&map["__devupRecord0"][0], "__devupInherited0"); + assert_payload(&map["__devupRecord0"][1], "__devupInherited1"); + } + ExtractStyleProp::Expression { .. } => assert_payload(&style, "__devupInherited0"), + ExtractStyleProp::MemberExpression { expression, map } => { + assert_identifier(expression, "__devupInherited2"); + assert_eq!(map.len(), 2); + assert_payload(&map["__devupRecord0"], "__devupInherited0"); + assert_payload(&map["second"], "__devupInherited1"); + } + ExtractStyleProp::Static(_) | ExtractStyleProp::Unreadable { .. } => { + panic!("container must remain unchanged"); + } + } +} + +#[test] +fn metadata_is_unchanged_when_style_expressions_are_visited() { + // Given: static declarations and unreadable diagnostics are not expressions. + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let static_value = ExtractStyleValue::Static(ExtractStaticStyle::new( + "--authored", + "__devupRecord0", + 3, + None, + )); + let mut style = ExtractStyleProp::StaticArray(vec![ + ExtractStyleProp::Static(static_value.clone()), + ExtractStyleProp::Unreadable { + offset: 17, + code: "__devupRecord0".to_string(), + prop: true, + requirement: Some("a valid selector"), + }, + ExtractStyleProp::Static(ExtractStyleValue::Dynamic(ExtractDynamicStyle::new( + "color", + 2, + "__devupRecord0", + None, + ))), + ]); + + // When: only the dynamic declaration is visited. + visit_style_expressions(&builder, &mut style, &mut |expression| { + assert_identifier(expression, "__devupRecord0"); + *expression = identifier(&builder, "__devupInherited0"); + }); + + // Then: metadata stays byte-identical and the dynamic declaration changes. + let ExtractStyleProp::StaticArray(styles) = style else { + panic!("array fixture"); + }; + let ExtractStyleProp::Static(value) = &styles[0] else { + panic!("static fixture"); + }; + assert_eq!(value, &static_value); + let ExtractStyleProp::Unreadable { + offset, + code, + prop, + requirement, + } = &styles[1] + else { + panic!("unreadable fixture"); + }; + assert_eq!( + (*offset, code.as_str(), *prop), + (17, "__devupRecord0", true) + ); + assert_eq!(*requirement, Some("a valid selector")); + let ExtractStyleProp::Static(value) = &styles[2] else { + panic!("dynamic fixture"); + }; + assert_eq!( + value, + &ExtractStyleValue::Dynamic(ExtractDynamicStyle::new( + "color", + 2, + "__devupInherited0", + None, + )) + ); +} diff --git a/libs/extractor/src/extractor/styled_record_tests.rs b/libs/extractor/src/extractor/styled_record_tests.rs index 4f999f15d..d4b3fe0c3 100644 --- a/libs/extractor/src/extractor/styled_record_tests.rs +++ b/libs/extractor/src/extractor/styled_record_tests.rs @@ -9,6 +9,206 @@ use rstest::rstest; use super::{styled_record::prepare, styled_record_evaluation::bind_render}; use crate::utils::readable_code; +#[rstest] +#[case::prepared_base("const Ext=styled(Base)({'p':1});", "", "4px")] +#[case::two_levels( + "const Mid=styled(Base)({'p':1});const Ext=styled(Mid)({'p':2});", + "", + "8px" +)] +#[case::tagged_extension("const Ext=styled(Base)`padding:4px`;", "", "4px")] +#[case::retargeted_extension( + "const Mid=Base.withComponent('section');const Ext=styled(Mid)({'p':1});", + "", + "4px" +)] +#[case::capture_collisions( + "const Mid=styled(Base)({backgroundColor:'white',...((trace.push('own'),own)?{backgroundColor:'black'}:{})});const Ext=styled(Mid)(__devupRecord0,{p:1,...((trace.push('authored'),__devupRecord1)?{p:2}:{})});", + ",own,__devupRecord0,__devupRecord1", + "8px" +)] +#[serial_test::serial] +fn inherited_captures_when_native_extension_renders( + #[case] extension: &str, + #[case] parameters: &str, + #[case] padding: &str, + #[values(false, true)] on: bool, + #[values(false, true)] authored: bool, + #[values(false, true)] single_css: bool, +) { + // Given: independent inherited, own and authored controllers. + reset_class_map(); + reset_file_map(); + let code = format!( + "import {{styled}} from '@devup-ui/react';const trace=[];function make(on{parameters}){{const Base=styled.div({{color:'red',...((trace.push('base'),on)?{{color:'blue'}}:{{}})}});{extension}return Ext.render({{id:'extension',className:'caller'}},null);}}const rendered=make({on},{},'authored',{authored});", + !on + ); + let output = match crate::extract( + "test.tsx", + &code, + crate::ExtractOption { + single_css, + ..crate::ExtractOption::default() + }, + ) { + Ok(output) => output, + Err(error) => panic!("extension fixture must extract: {error}"), + }; + let mut declarations = Vec::new(); + for style in &output.styles { + let crate::ExtractStyleValue::Static(value) = style else { + panic!("fixture declarations must be static"); + }; + let Some(crate::extract_style::style_property::StyleProperty::ClassName(name)) = + style.extract(if single_css { None } else { Some("test.tsx") }) + else { + panic!("fixture declaration must have a class"); + }; + declarations.push(format!( + "['{name}','{}','{}']", + value.property(), + value.value() + )); + } + // When: execute the emitted native render, not a generated-name assertion. + let rendered = evaluate_native_render( + &output.code, + &format!( + "const names=rendered.props.className.split(' ');const styles=[{}].filter(([name])=>names.includes(name)).map(([,prop,value])=>[prop,value]).sort();JSON.stringify([rendered.type,rendered.props.id,styles,names.filter(name=>name==='authored'||name==='caller'),trace]);", + declarations.join(",") + ), + ); + // Then: only the selected declarations survive, with ordered single reads. + let color = if on { "blue" } else { "red" }; + let target = if extension.contains("withComponent") { + "section" + } else { + "div" + }; + let expected = if parameters.is_empty() { + format!( + r#"["{target}","extension",[["color","{color}"],["padding","{padding}"]],["caller"],["base"]]"# + ) + } else { + let background = if on { "white" } else { "black" }; + let padding = if authored { padding } else { "4px" }; + format!( + r#"["div","extension",[["background-color","{background}"],["color","{color}"],["padding","{padding}"]],["authored","caller"],["base","own","authored"]]"# + ) + }; + assert_eq!(rendered, expected); +} + +#[rstest] +#[serial_test::serial] +fn inherited_captures_when_dynamic_native_base_renders( + #[values(false, true)] on: bool, + #[values(false, true)] single_css: bool, +) { + // Given: a lowercase runtime target binding and an inherited captured guard. + reset_class_map(); + reset_file_map(); + let code = format!( + "import {{styled}} from '@devup-ui/react';const trace=[];function make(on,target){{const Base=styled(target)({{color:'red',...((trace.push('base'),on)?{{color:'blue'}}:{{}})}});const Ext=styled(Base)({{p:1}});return Ext.render({{id:'extension',className:'caller'}},null);}}const rendered=make({on},'div');" + ); + let output = match crate::extract( + "test.tsx", + &code, + crate::ExtractOption { + single_css, + ..crate::ExtractOption::default() + }, + ) { + Ok(output) => output, + Err(error) => panic!("dynamic base extension must extract: {error}"), + }; + let mut declarations = Vec::new(); + for style in &output.styles { + let crate::ExtractStyleValue::Static(value) = style else { + panic!("fixture declarations must be static"); + }; + let Some(crate::extract_style::style_property::StyleProperty::ClassName(name)) = + style.extract(if single_css { None } else { Some("test.tsx") }) + else { + panic!("fixture declaration must have a class"); + }; + declarations.push(format!( + "['{name}','{}','{}']", + value.property(), + value.value() + )); + } + // When: execute the emitted extension with its actual runtime tag value. + let rendered = evaluate_native_render( + &output.code, + &format!( + "const names=rendered.props.className.split(' ');const styles=[{}].filter(([name])=>names.includes(name)).map(([,prop,value])=>[prop,value]).sort();JSON.stringify([rendered.type,rendered.props.id,styles,names.filter(name=>name==='caller'),trace]);", + declarations.join(",") + ), + ); + // Then: the tag, selected color, extension padding and single read survive. + let color = if on { "blue" } else { "red" }; + assert_eq!( + rendered, + format!( + r#"["div","extension",[["color","{color}"],["padding","4px"]],["caller"],["base"]]"# + ) + ); +} + +#[rstest] +#[serial_test::serial] +fn inherited_dependent_reads_when_attrs_and_later_styles_compose( + #[values(false, true)] outer: bool, + #[values(false, true)] inner: bool, + #[values(false, true)] single_css: bool, +) { + // Given: nested captured guards, a dynamic leaf and an authored attrs name. + reset_class_map(); + reset_file_map(); + let code = format!( + "import {{styled}} from '@devup-ui/react';const trace=[];function make(outer,inner,__devupRecord0){{const Base=styled.div.attrs({{title:__devupRecord0,id:'base'}})({{color:'red',backgroundColor:'white',...((trace.push('outer'),outer)?{{...((trace.push('inner'),inner)?{{backgroundColor:(trace.push('leaf'),'gold')}}:{{}})}}:{{}})}});const Ext=styled(Base).attrs({{id:'extension'}})({{color:'orange'}});return Ext.render({{}},null);}}const rendered=make({outer},{inner},'authored-title');" + ); + let output = match crate::extract( + "test.tsx", + &code, + crate::ExtractOption { + single_css, + ..crate::ExtractOption::default() + }, + ) { + Ok(output) => output, + Err(error) => panic!("dependent extension must extract: {error}"), + }; + let mut colors = Vec::new(); + for style in &output.styles { + if let crate::ExtractStyleValue::Static(value) = style + && value.property() == "color" + && let Some(crate::extract_style::style_property::StyleProperty::ClassName(name)) = + style.extract(if single_css { None } else { Some("test.tsx") }) + { + colors.push(format!("['{name}','{}']", value.value())); + } + } + // When: render the flattened extension through the existing native harness. + let rendered = evaluate_native_render( + &output.code, + &format!( + "const names=rendered.props.className.split(' ');JSON.stringify([rendered.props.id,rendered.props.title,[{}].filter(([name])=>names.includes(name)).map(([,value])=>value),Object.values(rendered.props.style||{{}}).filter(value=>value!=null),trace]);", + colors.join(",") + ), + ); + // Then: guarded reads stay single and ordered, attrs and later color win. + let expected = match (outer, inner) { + (true, true) => { + r#"["extension","authored-title",["orange"],["gold"],["outer","inner","leaf"]]"# + } + (true, false) => r#"["extension","authored-title",["orange"],[],["outer","inner"]]"#, + (false, _) => r#"["extension","authored-title",["orange"],[],["outer"]]"#, + }; + assert_eq!(rendered, expected); +} + fn evaluate_record(rules: &str, setup: &str) -> String { let allocator = Allocator::default(); let builder = AstBuilder::new(&allocator); @@ -287,11 +487,11 @@ fn original_record_when_unaffected_or_unreadable(#[case] rules: &str) { assert_eq!(readable_code(&statement.expression), original); } -/// Evaluate the emitted render scope, lowering only its empty native JSX element. +/// Evaluate the emitted render scope, lowering its empty native or bound JSX element. fn evaluate_native_render(code: &str, observation: &str) -> String { use oxc_ast::ast::{ - ImportDeclaration, JSXAttributeItem, JSXAttributeName, JSXAttributeValue, JSXElement, - JSXElementName, + ImportDeclaration, ImportDeclarationSpecifier, JSXAttributeItem, JSXAttributeName, + JSXAttributeValue, JSXElement, JSXElementName, }; use oxc_ast_visit::Visit; use oxc_span::Span; @@ -301,15 +501,36 @@ fn evaluate_native_render(code: &str, observation: &str) -> String { impl<'a> Visit<'a> for NativeElements { fn visit_import_declaration(&mut self, import: &ImportDeclaration<'a>) { - self.0.push((import.span, String::new())); + let replacement = import + .specifiers + .iter() + .flatten() + .filter_map(|specifier| match specifier { + ImportDeclarationSpecifier::ImportSpecifier(named) + if import.source.value == "react" + && named.imported.to_string() == "forwardRef" => + { + Some(format!("const {}=render=>({{render}});", named.local.name)) + } + _ => None, + }) + .collect::(); + self.0.push((import.span, replacement)); } fn visit_jsx_element(&mut self, element: &JSXElement<'a>) { assert_eq!(element.children.len(), 0); - let JSXElementName::Identifier(tag) = &element.opening_element.name else { - panic!("fixture must render a native element"); + let tag = match &element.opening_element.name { + JSXElementName::Identifier(tag) => { + assert_eq!(tag.name, "div"); + "'div'" + } + JSXElementName::IdentifierReference(tag) => { + assert!(["DevupStyled", "DevupAs"].contains(&tag.name.as_str())); + tag.name.as_str() + } + _ => panic!("fixture must render a native element"), }; - assert_eq!(tag.name, "div"); let props: Vec<_> = element .opening_element .attributes @@ -336,7 +557,7 @@ fn evaluate_native_render(code: &str, observation: &str) -> String { .collect(); self.0.push(( element.span, - format!("({{type:'div',props:{{{}}}}})", props.join(",")), + format!("({{type:{tag},props:{{{}}}}})", props.join(",")), )); } } @@ -369,6 +590,63 @@ fn evaluate_native_render(code: &str, observation: &str) -> String { } } +#[rstest] +#[serial_test::serial] +fn captures_and_filter_survive_when_prepared_base_retargets_then_extends( + #[values("test.tsx", "test.ts")] filename: &str, + #[values(false, true)] on: bool, + #[values(false, true)] single_css: bool, +) { + // Given: conditional captures, prop-reading attrs and an inherited filter. + reset_class_map(); + reset_file_map(); + let source = format!( + "import {{styled}} from '@devup-ui/react';const trace=[];const on={on};const Base=styled.div.attrs(p=>({{'data-tone':p.tone}})).withConfig({{shouldForwardProp:p=>p!=='tone'&&p!=='blocked'}})({{color:'red',...((trace.push('base'),on)?{{color:'blue'}}:{{}})}});const Mid=Base.withComponent('section');const Card=styled(Mid)({{p:1}});" + ); + let output = match crate::extract( + filename, + &source, + crate::ExtractOption { + single_css, + ..crate::ExtractOption::default() + }, + ) { + Ok(output) => output, + Err(error) => panic!("prepared filter extraction failed: {error}"), + }; + let color_classes: Vec<_> = output + .styles + .iter() + .filter_map(|style| match style { + crate::ExtractStyleValue::Static(value) + if value.property() == "color" + && value.value() == if on { "blue" } else { "red" } => + { + match style.extract(if single_css { None } else { Some(filename) }) { + Some(crate::extract_style::style_property::StyleProperty::ClassName(name)) => { + Some(name) + } + _ => panic!("selected color must have a class"), + } + } + _ => None, + }) + .collect(); + assert_ne!(color_classes.len(), 0); + let winning = serde_json::to_string(&color_classes) + .unwrap_or_else(|error| panic!("class fixture serialization failed: {error}")); + // When: the emitted extension renders with distinct input, selected target and forwarded target. + let observation = format!( + "const __devupJsx0=(type,props)=>({{type,props}});const rendered=Card.render({{tone:'olive',blocked:9,id:'card',ref:99,children:'child',className:'caller',as:'article',forwardedAs:'nav'}},7);JSON.stringify([rendered.type,rendered.props.as,rendered.props.id,rendered.props.ref,rendered.props.children,rendered.props.className.includes('caller'),{winning}.some(c=>rendered.props.className.split(' ').includes(c)),rendered.props['data-tone'],'tone' in rendered.props,'blocked' in rendered.props,trace]);" + ); + let result = evaluate_native_render(&output.code, &observation); + // Then: captures run once, attrs reads survive, filtered props disappear and identity/ordinary props remain. + assert_eq!( + result, + r#"["article","nav","card",7,"child",true,true,"olive",false,false,["base"]]"# + ); +} + #[rstest] #[serial_test::serial] fn template_key_precedence_when_public_component_renders( @@ -378,7 +656,7 @@ fn template_key_precedence_when_public_component_renders( reset_class_map(); reset_file_map(); let code = format!( - "import {{styled}} from '@devup-ui/react'; const trace=[]; const on={on}; const Card=styled('div',{{...((trace.push('spread'),on)?{{[`color`]:(trace.push('leaf'),'red')}}:{{color:'purple'}}),[`color`]:'orange'}}); const rendered=Card({{id:'card'}});" + "import {{styled}} from '@devup-ui/react'; const trace=[]; const on={on}; const Card=styled('div',{{...((trace.push('spread'),on)?{{[`color`]:(trace.push('leaf'),'red')}}:{{color:'purple'}}),[`color`]:'orange'}}); const rendered=Card.render({{id:'card'}},null);" ); let output = match crate::extract( "test.tsx", @@ -465,7 +743,7 @@ fn authored_composed_class_when_public_component_renders( reset_class_map(); reset_file_map(); let code = format!( - "import {{styled}} from '@devup-ui/react'; function make(on,__devupRecord0){{const Card=styled.div(__devupRecord0,{{...(on?{{color:'red'}}:{{}}),color:'orange'}});return Card({{id:'card',className:'caller-class'}});}} const rendered=make({on},'authored-class');" + "import {{styled}} from '@devup-ui/react'; function make(on,__devupRecord0){{const Card=styled.div(__devupRecord0,{{...(on?{{color:'red'}}:{{}}),color:'orange'}});return Card.render({{id:'card',className:'caller-class'}},null);}} const rendered=make({on},'authored-class');" ); let output = match crate::extract( "test.tsx", diff --git a/libs/extractor/src/extractor/styled_retained.rs b/libs/extractor/src/extractor/styled_retained.rs new file mode 100644 index 000000000..cbd4f75b0 --- /dev/null +++ b/libs/extractor/src/extractor/styled_retained.rs @@ -0,0 +1,220 @@ +use super::{ + RENDERED, STYLED_BASE, StyledCapability, StyledDefinition, call_with_values, fixed, identifier, + jsx_name, marker_class, merge_expression_for_class_name, renders_tag, with_property, + without_props, +}; +use oxc_allocator::{Allocator, CloneIn, GetAllocator}; +use oxc_ast::{ + ast::{Expression, ObjectPropertyKind}, + builder::AstBuilder, +}; +use oxc_span::SPAN; + +pub(super) enum Execution<'a> { + Replay, + RetainedFixed { + native: bool, + capability: StyledCapability, + }, + Delegated(Renderer<'a>), +} + +pub(super) struct Renderer<'a> { + pub name: String, + pub target: Expression<'a>, + pub bound: Option>, + pub capability: StyledCapability, + pub native: bool, + pub identities: Vec, +} + +impl Execution<'_> { + pub(super) const fn replays(&self) -> bool { + match self { + Self::Replay => true, + Self::RetainedFixed { .. } | Self::Delegated(_) => false, + } + } + + pub(super) fn clone_in<'b>(&self, allocator: &'b Allocator) -> Execution<'b> { + match self { + Self::Replay => Execution::Replay, + Self::RetainedFixed { native, capability } => Execution::RetainedFixed { + native: *native, + capability: capability.clone(), + }, + Self::Delegated(renderer) => Execution::Delegated(Renderer { + name: renderer.name.clone(), + target: renderer.target.clone_in(allocator), + bound: renderer + .bound + .as_ref() + .map(|value| value.clone_in(allocator)), + capability: renderer.capability.clone(), + native: renderer.native, + identities: renderer.identities.clone(), + }), + } + } +} + +impl<'a> StyledDefinition<'a> { + pub(crate) fn shared(mut self) -> Option { + if self.portable() { + return Some(self); + } + if self.fixed_summary() + && let Some(capability) = self.capability.take() + { + let native = self.native(); + self.name = String::new(); + self.target = None; + self.bound = None; + self.bindings.clear(); + self.execution = Execution::RetainedFixed { + native, + capability: capability.clone(), + }; + self.capability = Some(capability); + return Some(self); + } + None + } + + pub(crate) fn used_as(&self, ast: &AstBuilder<'a>, target: &Expression<'a>) -> Self { + let mut definition = self.clone_in(ast.allocator()); + match &self.execution { + Execution::RetainedFixed { native, capability } => { + let name = jsx_name(target); + definition.execution = Execution::Delegated(Renderer { + name: name.clone().unwrap_or_else(|| STYLED_BASE.to_string()), + target: name.as_ref().map_or_else( + || identifier(ast, STYLED_BASE), + |_| target.clone_in(ast.allocator()), + ), + bound: name.is_none().then(|| target.clone_in(ast.allocator())), + capability: capability.clone(), + native: *native, + identities: self + .markers + .iter() + .chain(&self.inherited_markers) + .cloned() + .collect(), + }); + definition.target = Some(identifier(ast, "undefined")); + } + Execution::Replay | Execution::Delegated(_) => {} + } + definition + } + + pub(super) fn fixed_summary(&self) -> bool { + self.attrs.is_empty() && self.classes.is_empty() && self.styles.iter().all(fixed) + } + + pub(super) fn native(&self) -> bool { + match &self.execution { + Execution::Replay => renders_tag(&self.name, self.bound.as_ref()), + Execution::RetainedFixed { native, .. } => *native, + Execution::Delegated(renderer) => renderer.native, + } + } + + pub(super) fn with_execution(mut self, ast: &AstBuilder<'a>, inherited: Option<&Self>) -> Self { + if let Some(inherited) = inherited { + self.execution = inherited.execution.clone_in(ast.allocator()); + } + self + } + + pub(super) fn render_markers(&self, ast: &AstBuilder<'a>) -> Option> { + let inherited = match &self.execution { + Execution::Delegated(renderer) => renderer.identities.as_slice(), + Execution::Replay | Execution::RetainedFixed { .. } => &[], + }; + merge_expression_for_class_name( + ast, + self.markers + .iter() + .chain(&self.inherited_markers) + .filter(|marker| !inherited.contains(marker)) + .map(|marker| marker_class(ast, marker)), + ) + } + + pub(super) fn retain_bound( + &self, + ast: &AstBuilder<'a>, + component: Expression<'a>, + ) -> Expression<'a> { + match &self.execution { + Execution::Delegated(renderer) => match &renderer.bound { + Some(bound) => call_with_values( + ast, + vec![(STYLED_BASE.to_string(), bound.clone_in(ast.allocator()))], + component, + ), + None => component, + }, + Execution::Replay | Execution::RetainedFixed { .. } => component, + } + } +} + +pub(super) struct Transport<'b, 'a> { + pub capability: Option<&'b StyledCapability>, + pub replacement: Option<&'b Expression<'a>>, + pub forwarded: Expression<'a>, +} + +pub(super) fn transport<'a>( + ast: &AstBuilder<'a>, + renderer: Option<&Renderer<'a>>, + transport: Transport<'_, 'a>, +) -> (Expression<'a>, Expression<'a>, Expression<'a>) { + match renderer { + None => ( + identifier(ast, RENDERED), + transport.forwarded, + identifier(ast, "forwardedAs"), + ), + Some(renderer) => { + let mut controls = Vec::new(); + if let Some(capability) = transport.capability { + capability.omit(&mut controls); + } + let raw = with_property( + ast, + without_props(ast, &controls), + "forwardedAs", + identifier(ast, "forwardedAs"), + ); + let raw = Expression::new_object_expression( + SPAN, + oxc_allocator::Vec::from_array_in( + [ + ObjectPropertyKind::new_spread_property(SPAN, raw, ast), + renderer.capability.classes.property( + ast, + transport + .replacement + .map(|value| value.clone_in(ast.allocator())), + ), + renderer + .capability + .forwarded + .property(ast, Some(transport.forwarded)), + ], + ast, + ), + ast, + ); + ( + renderer.target.clone_in(ast.allocator()), + raw, + identifier(ast, RENDERED), + ) + } + } +} diff --git a/libs/extractor/src/import_alias_visit.rs b/libs/extractor/src/import_alias_visit.rs index 0a8afbc2e..9619636fd 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,6 +48,15 @@ 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)>, } @@ -95,6 +108,66 @@ impl LibraryNumbers<'_> { } } + /// 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); + } + } + } + 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); + } + } + } + } + fn pixelify_value(&mut self, key: &str, value: &Expression) { if let Some(number) = js_number_literal(value) { if number != 0.0 && !keeps_bare_number(key) { @@ -184,7 +257,25 @@ fn kebab_property(key: &str) -> String { property } -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 @@ -213,6 +304,23 @@ impl<'a> Visit<'a> for LibraryNumbers<'_> { 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); } @@ -220,19 +328,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); } @@ -270,6 +372,9 @@ fn devup_equivalent(source: &str, imported: &str) -> Option } (_, "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")), @@ -292,7 +397,18 @@ 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, } /// Packages generating CSS on top of vanilla-extract's, which Devup UI does not @@ -347,17 +463,28 @@ 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| Aliased { + code: Cow::Borrowed(code), + edits: Vec::new(), + css_prop, + }; + 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); } let allocator = Allocator::default(); @@ -372,12 +499,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) && !import_decl.import_kind.is_type() { let span = import_decl.span; @@ -404,6 +578,9 @@ pub fn transform_import_aliases_with_edits<'a>( numbers.calls.push((local, RulesAt::EveryArgument)); } ("@emotion/react", "Global") => numbers.components.push(local), + ("@emotion/react", "ClassNames") => { + numbers.class_names.push(local); + } _ => {} } } @@ -430,15 +607,22 @@ 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()); + return unchanged(numbers.css_prop); } let edits = transformations @@ -450,7 +634,11 @@ pub fn transform_import_aliases_with_edits<'a>( result.replace_range(start..end, &replacement); } - (Cow::Owned(result), edits) + Aliased { + code: Cow::Owned(result), + edits, + css_prop: numbers.css_prop, + } } /// Reapply Emotion units to literals produced after the initial alias pass. @@ -1342,4 +1530,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..03cb5c9b5 100644 --- a/libs/extractor/src/imported_constants.rs +++ b/libs/extractor/src/imported_constants.rs @@ -21,8 +21,14 @@ 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}; #[derive(Clone, Debug)] enum Constant { @@ -44,8 +50,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,6 +161,8 @@ 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, } @@ -312,11 +321,13 @@ pub(crate) fn inline_constants<'a>( filename: &str, option: &ExtractOption, resolver: Option<&ModuleResolver>, + css_prop: CssProp, ) -> 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,13 +349,15 @@ 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() { @@ -363,6 +376,7 @@ pub(crate) fn inline_constants<'a>( 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 { @@ -471,6 +485,11 @@ 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() { @@ -486,9 +505,12 @@ pub(crate) fn inline_constants<'a>( scoping: &scoping, symbols: &symbols, style_roots: &style_roots, + css_props: &css_props, apis: &apis, objects: false, styles: false, + px: false, + class_names: Vec::new(), } .visit_program(program); } @@ -620,8 +642,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 +681,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 +717,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,6 +1048,8 @@ 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>>, } @@ -1009,6 +1065,7 @@ 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(), } @@ -1032,6 +1089,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 +1172,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 }; @@ -1465,13 +1573,15 @@ 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), + 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) => { - Some(Constant::Style) + Some(Constant::Style(None)) } Expression::TSAsExpression(inner) => self.evaluate(modules, &inner.expression), Expression::TSSatisfiesExpression(inner) => self.evaluate(modules, &inner.expression), @@ -1694,11 +1804,17 @@ struct Inline<'s, 'a> { scoping: &'s Scoping, symbols: &'s FxHashMap, 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, + /// The names the `` child functions around take `css` and + /// `cx` by + class_names: Vec, } impl<'a> Inline<'_, 'a> { @@ -1837,55 +1953,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,6 +1970,99 @@ 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 + } +} + +/// `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 let Some(number) = crate::utils::js_number_literal(&property.value) + && number != 0.0 + && property + .key + .static_name() + .is_some_and(|key| !crate::utils::keeps_bare_number(&key)) + { + property.value = Expression::new_string_literal( + SPAN, + Str::from_in(format!("{number}px").as_str(), ast_builder.allocator()), + None, + ast_builder, + ); + } +} + +/// 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 { + px_value(ast_builder, property); + px_rules(ast_builder, &mut property.value); + } + } + } +} + +/// `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> { @@ -1912,6 +2073,9 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { .and_then(|constant| self.literal(&constant)) { *expression = literal; + if self.px { + px_rules(self.ast_builder, expression); + } return; } if let Some(chosen) = self.chosen(expression) { @@ -1928,6 +2092,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,14 +2110,42 @@ 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); + if calls_one_of(&self.class_names, &call.callee) { + self.reading_css(true, |inline| { + for argument in &mut call.arguments { + inline.visit_argument(argument); + } + }); + 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() { + inline.reading_css(at == css, |inline| { + inline.visit_object_property_kind(property); + }); + } + } + (_, argument) => inline.visit_argument(argument), + } } }); }); @@ -1955,7 +2153,14 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { 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) + }) { + self.reading_css(true, |inline| inline.visit_jsx_attribute_item(attribute)); + continue; + } let objects = styled && match attribute { JSXAttributeItem::Attribute(attribute) => { @@ -1973,9 +2178,15 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { } fn visit_object_property(&mut self, property: &mut oxc_ast::ast::ObjectProperty<'a>) { + let inlined_number = self.px + && self.styles + && matches!(self.constant(&property.value), Some(Constant::Number(_))); walk_mut::walk_object_property(self, property); if property.shorthand && !matches!(property.value, Expression::Identifier(_)) { property.shorthand = false; } + if inlined_number { + px_value(self.ast_builder, property); + } } } diff --git a/libs/extractor/src/imported_styled.rs b/libs/extractor/src/imported_styled.rs new file mode 100644 index 000000000..bc9ed4325 --- /dev/null +++ b/libs/extractor/src/imported_styled.rs @@ -0,0 +1,914 @@ +//! 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::SourceType; +use rustc_hash::{FxHashMap, FxHashSet}; + +use crate::css_prop::{CssProp, CssTakers}; +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(|_| readable) + .and_then(StyledDefinition::shared), + ..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> { + if is_vanilla_extract_file(path) { + return None; + } + let Aliased { + code: transformed, + css_prop, + .. + } = 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 styled_capability = + crate::extractor::styled_capability::StyledCapability::reserve(&program); + let inlined = crate::imported_constants::inline_constants( + &ast_builder, + &mut program, + path, + self.option, + Some(self.resolver), + css_prop, + ); + 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.unknown_bindings(&inlined.unknown); + visitor.changed_bindings(inlined.changed); + visitor.takes_css_prop(css_prop); + visitor.import_styled(imports); + visitor.reserve_styled(styled_capability); + visitor.visit_program(&mut program); + let readable = visitor.errors.is_empty() + && visitor.unknown_parts.is_empty() + && !visitor.composes_unknown; + exports.extend( + visitor + .components(self.allocator) + .into_iter() + .map(|(name, component)| (name, component.shared(readable))), + ); + Some(exports) + } + + /// 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 let Some(module) = self.module(&export.source.value, path) { + for specifier in &export.specifiers { + 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() => { + 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 mut used = Used { + styled: imported.styled, + css_prop: css_prop != CssProp::Off, + css: CssTakers::new( + program, + css_prop, + &format!("{}/compat", self.option.package), + ), + names: FxHashSet::default(), + }; + used.visit_program(program); + crate::style_values::selectors(program, &mut |expression| { + if let Expression::Identifier(identifier) = expression { + used.names.insert(identifier.name.to_string()); + } + }); + used.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::StaticMemberExpression(member) => match &member.object { + Expression::Identifier(object) => { + Some(format!("{}.{}", object.name, member.property.name)) + } + _ => None, + }, + _ => None, + } +} + +/// The bindings a program composes, takes the `css` prop of, or selects +struct Used<'s> { + styled: FxHashSet, + css_prop: bool, + css: CssTakers<'s>, + names: FxHashSet, +} + +impl<'a> Visit<'a> for Used<'_> { + fn visit_call_expression(&mut self, call: &CallExpression<'a>) { + let name = match unwrap_syntax_only(&call.callee) { + Expression::Identifier(callee) if self.styled.contains(callee.name.as_str()) => call + .arguments + .first() + .and_then(|first| first.as_expression()) + .and_then(written_name), + Expression::StaticMemberExpression(member) + if member.property.name == "withComponent" => + { + written_name(&member.object) + } + _ if self.css.property(call, |_| true).is_some() => call + .arguments + .first() + .and_then(|argument| argument.as_expression()) + .and_then(written_name), + _ => None, + }; + self.names.extend(name); + walk::walk_call_expression(self, call); + } + + fn visit_jsx_opening_element(&mut self, element: &JSXOpeningElement<'a>) { + if self.css_prop + && let JSXElementName::IdentifierReference(name) = &element.name + && element.attributes.iter().any(|attribute| { + matches!(attribute, JSXAttributeItem::Attribute(attribute) + if matches!(&attribute.name, JSXAttributeName::Identifier(attribute) if attribute.name == "css")) + }) + { + self.names.insert(name.name.to_string()); + } + 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 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"; + const BASE_BODY: &str = r"export const Base = styled.div`color: red; padding: 4px;`; +export const Dyn = styled.button`font-size: ${(p) => (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); + } +} diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 732ede591..ceedd3a06 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -1,7 +1,11 @@ mod as_visit; mod build_time_values; mod class_arguments; +#[cfg(test)] +mod class_names_numeric_tests; mod component; +mod composition; +mod css_prop; mod css_utils; mod emotion_namespace; pub mod extract_style; @@ -10,13 +14,16 @@ 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; #[cfg(test)] mod responsive_selector_tests; mod source_map; mod style_values; +mod styled_reads; #[cfg(test)] mod styled_source_tests; mod stylex; @@ -110,8 +117,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) => { @@ -347,18 +354,30 @@ 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, + } = import_alias_visit::transform_import_aliases_with_edits( &namespace_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 if namespace_edits.is_empty() { return Ok(ExtractOutput { @@ -487,6 +506,7 @@ fn extract_source( if fatal_error { return Err("Parser panicked".into()); } + let styled_capability = extractor::styled_capability::StyledCapability::reserve(&program); let inlined = if processed_code.is_none() { imported_constants::inline_constants( &oxc_ast::builder::AstBuilder::new(&allocator), @@ -494,11 +514,25 @@ fn extract_source( filename, &option, resolver, + css_prop, ) } 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); if !namespace_edits.is_empty() { emotion_namespace::units::pixelify(&allocator, &mut program, &namespace_code); } @@ -510,9 +544,21 @@ 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.unknown_bindings(&inlined.unknown); visitor.changed_bindings(inlined.changed.clone()); + visitor.takes_css_prop(css_prop); + visitor.import_styled(imported.components); + visitor.reserve_styled(styled_capability); visitor.visit_program(&mut program); + if !has_relevant_import + && alias_edits.is_empty() + && namespace_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) { @@ -14751,6 +14797,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={[\"c-0-Aside color-0-red--255\", className]", + "as: DevupAs = Link,", + "as: DevupAs = motion.div,", + "export const Again = __devupForwardRef((__devupRefProps, __devupRef) => ((__devupProps) => (({ style, className, as: DevupAs = \"nav\",", + "export const Kept = Section.withComponent(make());", + "export const Other = other.withComponent(\"aside\");", + "...{ 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] @@ -21519,6 +22008,275 @@ 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
-
-