diff --git a/.changepacks/changepack_log_barrel_namespace_imports.json b/.changepacks/changepack_log_barrel_namespace_imports.json new file mode 100644 index 000000000..713e9b0ce --- /dev/null +++ b/.changepacks/changepack_log_barrel_namespace_imports.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "A project module re-exporting Devup UI (export { Box, css } from '@devup-ui/react', export * from '@devup-ui/react', export { styled as s }, export * as Devup, a default export of an import, chains of barrels and the compat entry) now works: a file importing Box or css from it compiles exactly as if it imported from the package, following the barrel through the resolver (relative paths, tsconfig paths, package exports), and hasDevupUI sees such a file. Members read from a namespace or default import of the package (Devup['css'], const { css } = Devup, Devup.css as a value) compile where the build can follow them, `Devup.x` for a name the package compiles being read like the named import. New build errors, located at the use: what a barrel re-exports but the build cannot read (an unresolvable `export *` or module, a namespace of modules re-exporting the package) is an error where its name is used instead of a runtime crash, and a namespace member read by a key the build cannot know (Devup[key]), written, or destructured in a form it cannot follow (a rest, a computed key, a default value, a nested pattern, an exported declaration, or members mixed with ones that stay at runtime) is an error that names the members to read. A namespace passed on whole (Object.keys(Devup), spyOn(Devup, 'css'), export default Devup) is left as it is", + "date": "2026-10-01T00:00:00.000Z" +} 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_compose_stylex.json b/.changepacks/changepack_log_compose_stylex.json new file mode 100644 index 000000000..d6e8db0d1 --- /dev/null +++ b/.changepacks/changepack_log_compose_stylex.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "stylex.props() and stylex.attrs() merge namespaces this file creates key by key as StyleX does: a later namespace's key replaces an earlier one's, so props(red, blue) is blue and props(blue, red) is red, and a key set to null removes the earlier value; conditions (cond && styles.x, cond ? a : b) choose per key. Arguments the build cannot read key by key (namespaces from other modules, props, dynamic namespaces, include()) keep the previous joined behavior", + "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_follow_aliases.json b/.changepacks/changepack_log_follow_aliases.json new file mode 100644 index 000000000..b011e08f4 --- /dev/null +++ b/.changepacks/changepack_log_follow_aliases.json @@ -0,0 +1,8 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "packages/bun-plugin/package.json": "Patch" + }, + "note": "hasDevupUI takes the import aliases and is true for a file extraction changes: one importing a redirected package (@emotion/styled, ...), naming @emotion/react in a @jsxImportSource pragma, or in a project whose tsconfig jsxImportSource is @emotion/react, so the Bun plugin no longer skips files whose only Devup UI is Emotion's css prop or an aliased import. Aliases of Devup UI now compile like the original: `export const B = Box`, `const c = css; export { c as cc }`, `export const D = Devup` and `export const C = Devup.css` in a barrel (the barrel module exports them as re-exports of the package, and what imports them follows them), `export { Box }` after an import, `export default Box`, `const D = Devup` namespace aliases, and `const inner = css` declared inside a function. A css() or keyframes() const that a function reads before its declaration compiles. New build error: a css()/keyframes() const read before its declaration runs (outside a function) is located and says to move the declaration above its first read", + "date": "2026-10-01T00:00:00.000Z" +} \ No newline at end of file diff --git a/.changepacks/changepack_log_js_semantics.json b/.changepacks/changepack_log_js_semantics.json new file mode 100644 index 000000000..299f4481f --- /dev/null +++ b/.changepacks/changepack_log_js_semantics.json @@ -0,0 +1,9 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "packages/components/package.json": "Patch", + "packages/reset-css/package.json": "Patch" + }, + "note": "Preserve JavaScript semantics for exact imported enum and Math values including negative zero, lexical constants, binding-aware CommonJS require, React createElement calls, type-only imports, and source-ordered props evaluation with native spread snapshots and CSS-variable fallbacks without a styling runtime. Migrate UI-kit style defaults by pure reordering and test reset CSS through compiled output. Report located build errors for runtime styleOrder values, unknowable whole-object style overrides, opaque selector literals, genuinely unmovable lazy logical suspension, Devup UI namespaces passed whole to runtime code, and immediate style reads in the temporal dead zone of lexical bindings; deferred initialized reads keep folding and pre-assignment var reads remain dynamic.", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_scoped_compiled_names.json b/.changepacks/changepack_log_scoped_compiled_names.json new file mode 100644 index 000000000..a8116dbe6 --- /dev/null +++ b/.changepacks/changepack_log_scoped_compiled_names.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Resolve compiled styling APIs and components by their lexical binding so shadowed local names, including minified library identifiers, remain ordinary JavaScript and JSX. No new build errors; existing diagnostics now apply only to the compiled binding they describe.", + "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/.changepacks/changepack_log_stylex_attrs_style.json b/.changepacks/changepack_log_stylex_attrs_style.json new file mode 100644 index 000000000..7ccca57d3 --- /dev/null +++ b/.changepacks/changepack_log_stylex_attrs_style.json @@ -0,0 +1,8 @@ +{ + "changes": { + "packages/react/package.json": "Patch", + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "stylex.attrs() gives the dynamic CSS variables it sets as `style` attribute text (`\"--a:\" + value`), as StyleX does for spreading onto non-React targets, instead of a style object; stylex.props() keeps the object React takes. The attrs() return type declares `style` as a string", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_stylex_dynamic_functions.json b/.changepacks/changepack_log_stylex_dynamic_functions.json new file mode 100644 index 000000000..9ef98c90b --- /dev/null +++ b/.changepacks/changepack_log_stylex_dynamic_functions.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Preserve exact JavaScript defaults, numeric arguments, source-order evaluation and final duplicate assignments in StyleX dynamic functions, static composition and variable conditions, with no styling runtime. Empty firstThatWorks omits its property, statically readable types helper extra arguments are evaluated and ignored, ordered static includes retain keyed precedence and null resets, shadowed undefined remains a real binding, and attrs returns serialized style text. Report located build errors with repairs for unsupported function forms, nested or overlapping dynamic calls, incompatible JSX/spread compositions, uncalled dynamic includes or function aliases, unconsumed helper calls and value escapes, runtime helper extra arguments, malformed at-rule conditions, non-flat or runtime theme-contract placeholders, and written namespace bindings, aliases or members whose metadata cannot be known exactly.", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/apps/landing/src/app/(detail)/components/Card.tsx b/apps/landing/src/app/(detail)/components/Card.tsx index b9ad4397c..450a528c6 100644 --- a/apps/landing/src/app/(detail)/components/Card.tsx +++ b/apps/landing/src/app/(detail)/components/Card.tsx @@ -7,13 +7,6 @@ export default function Card({ }: React.ComponentProps<'div'>) { return ( {children} diff --git a/apps/landing/src/components/Header/HeaderInput.tsx b/apps/landing/src/components/Header/HeaderInput.tsx index 83266dd9a..55e736891 100644 --- a/apps/landing/src/components/Header/HeaderInput.tsx +++ b/apps/landing/src/components/Header/HeaderInput.tsx @@ -12,17 +12,17 @@ export function HeaderInput(props: React.ComponentProps<'input'>) { > ) diff --git a/bindings/devup-ui-wasm/src/lib.rs b/bindings/devup-ui-wasm/src/lib.rs index 4560410f3..cd6e1a31b 100644 --- a/bindings/devup-ui-wasm/src/lib.rs +++ b/bindings/devup-ui-wasm/src/lib.rs @@ -5,7 +5,7 @@ use css::file_map::{ use extractor::extract_style::extract_style_value::ExtractStyleValue; use extractor::{ ExtractOption, ImportAlias, ModuleResolver, ResolvedModule, extract, extract_with_modules, - extract_without_source_map, has_devup_ui, + extract_without_source_map, has_devup_ui_with, }; use rustc_hash::FxHashSet; use sheet::StyleSheet; @@ -490,6 +490,15 @@ fn call_module_resolver( }) } +/// The resolver set by `setModuleResolver`, if any +#[cfg(not(tarpaulin_include))] +fn resolver_from_js() -> Option> { + let resolver = MODULE_RESOLVER.with_borrow(Clone::clone)?; + Some(Box::new(move |specifier: &str, importer: &str| { + call_module_resolver(&resolver, specifier, importer) + })) +} + /// Extract with the resolver set by `setModuleResolver`, if any #[cfg(not(tarpaulin_include))] #[allow(clippy::too_many_arguments)] @@ -670,8 +679,20 @@ pub fn get_theme_interface( #[wasm_bindgen(js_name = "hasDevupUI")] #[cfg(not(tarpaulin_include))] #[must_use] -pub fn has_devup_ui_wasm(filename: &str, code: &str, package: &str) -> bool { - has_devup_ui(filename, code, package) +pub fn has_devup_ui_wasm( + filename: &str, + code: &str, + package: &str, + import_aliases: JsValue, +) -> bool { + let aliases = import_aliases_from_js(import_aliases).unwrap_or_default(); + has_devup_ui_with( + filename, + code, + package, + &aliases, + resolver_from_js().as_deref(), + ) } #[cfg(test)] @@ -1551,7 +1572,7 @@ mod tests { ); // Test getters - assert!(!output.code().is_empty()); + assert_ne!(output.code(), ""); assert_eq!(output.css_file(), Some("devup-ui-0.css".to_string())); assert_eq!(output.map(), Some("//# sourceMappingURL=test".to_string())); assert!(output.css().is_some()); @@ -1583,30 +1604,40 @@ mod tests { } #[test] - #[serial] - fn test_has_devup_ui_wasm_function() { - // Test positive case - assert!(has_devup_ui_wasm( - "test.tsx", + fn has_devup_ui_covers_what_extraction_changes() { + let resolver = |specifier: &str, _: &str| { + (specifier == "./ui").then(|| ResolvedModule { + path: "/src/ui.ts".to_string(), + code: "export { Box } from '@devup-ui/react'".to_string(), + }) + }; + let aliases = HashMap::from([("@emotion/styled".to_string(), ImportAlias::NamedToNamed)]); + let check = |filename: &str, code: &str, resolver: Option<&ModuleResolver>| { + has_devup_ui_with(filename, code, "@devup-ui/react", &aliases, resolver) + }; + assert!(check( + "a.tsx", "import { Box } from '@devup-ui/react';", - "@devup-ui/react" - )); - - // Test negative case - assert!(!has_devup_ui_wasm( - "test.tsx", - "const x = 1;", - "@devup-ui/react" + None )); - - // Test invalid extension - assert!(!has_devup_ui_wasm( - "test.invalid", + assert!(!check("a.tsx", "const x = 1;", None)); + assert!(!check( + "a.invalid", "import { Box } from '@devup-ui/react';", - "@devup-ui/react" + None + )); + assert!(check( + "a.tsx", + "import { Box } from './ui';", + Some(&resolver) + )); + assert!(!check("a.tsx", "import { Box } from './ui';", None)); + assert!(check( + "a.tsx", + "import styled from '@emotion/styled';", + None )); } - #[test] #[serial] fn test_output_single_css_mode() { @@ -1951,7 +1982,7 @@ mod tests { assert!(result.is_ok()); let output = result.unwrap(); - assert!(!output.code().is_empty()); + assert_ne!(output.code(), ""); assert!(output.map().is_some()); } @@ -1975,7 +2006,7 @@ mod tests { assert!(result.is_ok()); let output = result.unwrap(); - assert!(!output.code().is_empty()); + assert_ne!(output.code(), ""); assert!(output.map().is_none()); } @@ -2000,7 +2031,7 @@ mod tests { assert!(result.is_err()); if let Err(error) = result { - assert!(!error.is_empty()); + assert_ne!(error, ""); } } diff --git a/libs/css/src/lib.rs b/libs/css/src/lib.rs index 576ba53b9..427732ae7 100644 --- a/libs/css/src/lib.rs +++ b/libs/css/src/lib.rs @@ -429,6 +429,25 @@ pub fn keyframes_to_keyframes_name(keyframes: &str, filename: Option<&str>) -> S }) } +/// The class marking the component `name` defined in `filename`, which other +/// styles select it by +#[must_use] +pub fn component_marker(name: &str, filename: &str) -> String { + with_prefix(|prefix| { + if is_debug() { + format!("{prefix}c-{name}") + } else { + let class_num = class_num_for_key("", |key| { + key.push_str("c-"); + key.push_str(filename); + key.push('-'); + key.push_str(name); + }); + format!("{prefix}{class_num}") + } + }) +} + /// ASCII lookup table for selector encoding. `None` means pass through (alphanumeric, `-`, `_`) /// or fall through to the Unicode escape path. const SELECTOR_ENCODE: [Option<&str>; 128] = { @@ -753,6 +772,25 @@ mod tests { ); } + #[test] + #[serial] + fn test_component_marker() { + set_debug(false); + reset_class_map(); + let child = component_marker("Child", "a.tsx"); + assert_eq!(child, "a"); + assert_eq!(component_marker("Child", "a.tsx"), child); + assert_eq!(component_marker("Child", "b.tsx"), "b"); + assert_eq!(component_marker("Other", "a.tsx"), "c"); + assert_eq!( + sheet_to_classname("color", 0, Some("red"), None, None, None), + "d" + ); + set_debug(true); + assert_eq!(component_marker("Child", "a.tsx"), "c-Child"); + set_debug(false); + } + #[test] #[serial] fn test_sheet_to_classname() { diff --git a/libs/css/src/theme_tokens.rs b/libs/css/src/theme_tokens.rs index d8d8fb021..3b2346aed 100644 --- a/libs/css/src/theme_tokens.rs +++ b/libs/css/src/theme_tokens.rs @@ -119,7 +119,7 @@ mod tests { set_typography_keys(vec!["body".to_string(), "title".to_string()]); assert_eq!(get_typography_keys(), vec!["body", "title"]); set_typography_keys(vec![]); - assert!(get_typography_keys().is_empty()); + assert_eq!(get_typography_keys(), Vec::::new()); } #[test] diff --git a/libs/extractor/src/barrel.rs b/libs/extractor/src/barrel.rs new file mode 100644 index 000000000..d7c0644c3 --- /dev/null +++ b/libs/extractor/src/barrel.rs @@ -0,0 +1,1154 @@ +//! Devup UI reached through something other than a named import of the +//! package: a project module re-exporting it (a barrel), or a namespace import +//! read for its members. +//! +//! Both are rewritten to the named imports of the package itself, so a file +//! compiles as it does importing them from the package, and what the build +//! cannot follow exactly is reported where it is used. + +use std::collections::BTreeSet; +use std::rc::Rc; + +use oxc_allocator::Allocator; +use oxc_ast::AstKind; +use oxc_ast::ast::{ + BindingIdentifier, BindingPattern, Declaration, ExportDefaultDeclarationKind, Expression, + ImportDeclarationSpecifier, Statement, StaticMemberExpression, VariableDeclarationKind, +}; +use oxc_parser::{Parser, ParserReturn}; +use oxc_semantic::{Semantic, SemanticBuilder}; +use oxc_span::{GetSpan, SourceType}; +use oxc_syntax::node::NodeId; +use oxc_syntax::symbol::SymbolId; +use rustc_hash::{FxHashMap, FxHashSet}; + +use crate::component::ExportVariableKind; +use crate::import_alias_visit::Edit; +use crate::module_loader::declared_names; +use crate::util_type::UtilType; +use crate::utils::is_vanilla_extract_file; +use crate::{ModuleResolver, ResolvedModule}; + +/// How a module gives a name it exports +#[derive(Clone)] +enum Link { + /// Declared in the module + Own, + /// Taken from `source`: `imported` is the name read from it, `None` its + /// namespace + From { + source: String, + imported: Option, + }, +} + +/// What a module exports, read without running it +struct Exports { + path: String, + named: FxHashMap, + /// Modules re-exported whole with `export * from` + stars: Vec, + mentions_package: bool, + /// Nothing in it can lead to the package + trivial: bool, +} + +/// Where reading a name from a module leads +enum Origin { + /// `imported` of `source`, a module of the package; `None` is its namespace + Devup { + source: String, + imported: Option, + }, + /// Something else: a name the module declares, or takes from elsewhere + Other, + /// A re-export of the package the build cannot follow, and why + Unfollowable(String), + /// The module does not export it + Absent, +} + +/// Whether `code` may re-export names from other modules +fn has_reexport_syntax(code: &str) -> bool { + code.match_indices("export").any(|(index, keyword)| { + code[index + keyword.len()..] + .trim_start() + .starts_with(['{', '*']) + }) +} + +fn analyze(module: &ResolvedModule, package: &str) -> Exports { + let mentions_package = module.code.contains(package); + let mut exports = Exports { + path: module.path.clone(), + named: FxHashMap::default(), + stars: Vec::new(), + mentions_package, + trivial: !mentions_package && !has_reexport_syntax(&module.code), + }; + if exports.trivial { + return exports; + } + let allocator = Allocator::default(); + let source_type = SourceType::from_path(&module.path).unwrap_or_default(); + let program = Parser::new(&allocator, &module.code, source_type) + .parse() + .program; + let semantic = SemanticBuilder::new().build(&program).semantic; + let mut imports: FxHashMap = FxHashMap::default(); + for statement in &program.body { + if let Statement::ImportDeclaration(import) = statement + && !import.import_kind.is_type() + { + for specifier in import.specifiers.iter().flatten() { + let (local, imported) = match specifier { + ImportDeclarationSpecifier::ImportSpecifier(named) + if !named.import_kind.is_type() => + { + (&named.local, Some(named.imported.name().to_string())) + } + ImportDeclarationSpecifier::ImportSpecifier(_) => continue, + ImportDeclarationSpecifier::ImportDefaultSpecifier(default) => { + (&default.local, Some("default".to_string())) + } + ImportDeclarationSpecifier::ImportNamespaceSpecifier(namespace) => { + (&namespace.local, None) + } + }; + imports.extend(local.symbol_id.get().map(|symbol| { + ( + symbol, + Link::From { + source: import.source.value.to_string(), + imported, + }, + ) + })); + } + } + } + for statement in &program.body { + match statement { + Statement::ExportFromDeclaration(export) if !export.export_kind.is_type() => { + for specifier in export + .specifiers + .iter() + .filter(|s| !s.export_kind.is_type()) + { + exports.named.insert( + specifier.exported.name().to_string(), + Link::From { + source: export.source.value.to_string(), + imported: Some(specifier.local.name().to_string()), + }, + ); + } + } + Statement::ExportAllDeclaration(export) if !export.export_kind.is_type() => { + let source = export.source.value.to_string(); + match &export.exported { + Some(name) => { + exports.named.insert( + name.name().to_string(), + Link::From { + source, + imported: None, + }, + ); + } + None => exports.stars.push(source), + } + } + Statement::ExportNamedDeclaration(export) if !export.export_kind.is_type() => { + for specifier in export + .specifiers + .iter() + .filter(|s| !s.export_kind.is_type()) + { + let link = export_symbol(&specifier.local, &semantic) + .and_then(|symbol| imports.get(&symbol)) + .cloned() + .unwrap_or(Link::Own); + exports + .named + .insert(specifier.exported.name().to_string(), link); + } + } + Statement::VariableDeclaration(declaration) => { + for (id, link) in alias_links(declaration, &imports, &semantic) { + imports.extend(id.symbol_id.get().map(|symbol| (symbol, link))); + } + } + Statement::ExportDeclaration(export) => { + for name in declared_names(&export.declaration) { + exports.named.insert(name, Link::Own); + } + if let Declaration::VariableDeclaration(declaration) = &export.declaration { + for (id, link) in alias_links(declaration, &imports, &semantic) { + exports.named.insert(id.name.to_string(), link.clone()); + imports.extend(id.symbol_id.get().map(|symbol| (symbol, link))); + } + } + } + Statement::ExportDefaultDeclaration(export) => { + let link = match &export.declaration { + ExportDefaultDeclarationKind::Identifier(name) => { + reference_symbol(name, &semantic) + .and_then(|symbol| imports.get(&symbol)) + .cloned() + } + _ => None, + }; + exports + .named + .insert("default".to_string(), link.unwrap_or(Link::Own)); + } + _ => {} + } + } + exports +} + +/// Where `Namespace.member` leads, for a namespace a module imports +fn member_link( + member: &StaticMemberExpression<'_>, + imports: &FxHashMap, + semantic: &Semantic<'_>, +) -> Option { + if let Expression::Identifier(object) = &member.object + && let Some(Link::From { + source, + imported: None, + }) = reference_symbol(object, semantic).and_then(|symbol| imports.get(&symbol)) + { + Some(Link::From { + source: source.clone(), + imported: Some(member.property.name.to_string()), + }) + } else { + None + } +} + +/// The bindings a `const` declaration makes of what a module imports +/// (`const B = Box`, `const C = Devup.css`), with where each leads +fn alias_links<'a>( + declaration: &'a oxc_ast::ast::VariableDeclaration<'a>, + imports: &FxHashMap, + semantic: &Semantic<'_>, +) -> Vec<(&'a BindingIdentifier<'a>, Link)> { + if declaration.kind != VariableDeclarationKind::Const { + return Vec::new(); + } + declaration + .declarations + .iter() + .filter_map(|declarator| { + let BindingPattern::BindingIdentifier(id) = &declarator.id else { + return None; + }; + let link = match declarator.init.as_ref()? { + Expression::Identifier(init) => { + imports.get(&reference_symbol(init, semantic)?)?.clone() + } + Expression::StaticMemberExpression(member) => { + member_link(member, imports, semantic)? + } + _ => return None, + }; + Some((id.as_ref(), link)) + }) + .collect() +} + +fn reference_symbol( + reference: &oxc_ast::ast::IdentifierReference<'_>, + semantic: &Semantic<'_>, +) -> Option { + reference + .reference_id + .get() + .and_then(|id| semantic.scoping().get_reference(id).symbol_id()) +} + +fn export_symbol( + local: &oxc_ast::ast::ModuleExportName<'_>, + semantic: &Semantic<'_>, +) -> Option { + match local { + oxc_ast::ast::ModuleExportName::IdentifierReference(reference) => { + reference_symbol(reference, semantic) + } + oxc_ast::ast::ModuleExportName::IdentifierName(_) + | oxc_ast::ast::ModuleExportName::StringLiteral(_) => None, + } +} + +/// Follows re-exports through the modules a resolver reads +struct Walker<'r, 'p> { + resolver: &'r ModuleResolver, + package: &'p str, + modules: FxHashMap>, + dependencies: BTreeSet, +} + +impl Walker<'_, '_> { + fn is_package(&self, source: &str) -> bool { + source == self.package + || source + .strip_prefix(self.package) + .is_some_and(|rest| rest.starts_with('/')) + } + + fn module(&mut self, specifier: &str, importer: &str) -> Option> { + let resolved = (self.resolver)(specifier, importer)?; + self.dependencies.insert(resolved.path.clone()); + Some( + self.modules + .entry(resolved.path.clone()) + .or_insert_with(|| Rc::new(analyze(&resolved, self.package))) + .clone(), + ) + } + + /// Where `imported` of `source`, imported by `importer`, leads + fn import_origin(&mut self, source: &str, importer: &str, imported: &str) -> Origin { + match self.module(source, importer) { + None => Origin::Other, + Some(module) => match self.origin(&module, imported, &mut Vec::new()) { + Origin::Absent => Origin::Other, + origin => origin, + }, + } + } + + fn origin( + &mut self, + module: &Exports, + name: &str, + reading: &mut Vec<(String, String)>, + ) -> Origin { + let key = (module.path.clone(), name.to_string()); + if module.trivial || reading.contains(&key) { + return Origin::Absent; + } + reading.push(key); + let origin = match module.named.get(name) { + Some(Link::Own) => Origin::Other, + Some(Link::From { source, imported }) => { + self.follow(module, source, imported.as_deref(), reading) + } + None if name == "default" => Origin::Absent, + None => self.through_stars(module, name, reading), + }; + reading.pop(); + origin + } + + fn follow( + &mut self, + module: &Exports, + source: &str, + imported: Option<&str>, + reading: &mut Vec<(String, String)>, + ) -> Origin { + if self.is_package(source) { + return Origin::Devup { + source: source.to_string(), + imported: imported.map(str::to_string), + }; + } + let Some(next) = self.module(source, &module.path) else { + return if module.mentions_package { + Origin::Unfollowable(format!("`{source}` cannot be read")) + } else { + Origin::Other + }; + }; + match imported { + Some(name) => match self.origin(&next, name, reading) { + Origin::Absent => Origin::Other, + origin => origin, + }, + None if self.touches_package(&next, &mut FxHashSet::default()) => { + Origin::Unfollowable(format!( + "`{source}` is a namespace of modules re-exporting `{}`", + self.package + )) + } + None => Origin::Other, + } + } + + fn through_stars( + &mut self, + module: &Exports, + name: &str, + reading: &mut Vec<(String, String)>, + ) -> Origin { + let (mut strong, mut weak, mut unfollowable) = (None, None, None); + for star in &module.stars { + if self.is_package(star) { + weak = Some(Origin::Devup { + source: star.clone(), + imported: Some(name.to_string()), + }); + } else if let Some(next) = self.module(star, &module.path) { + match self.origin(&next, name, reading) { + Origin::Absent => {} + found => strong = strong.or(Some(found)), + } + } else if module.mentions_package { + unfollowable = Some(Origin::Unfollowable(format!("`{star}` cannot be read"))); + } + } + strong.or(weak).or(unfollowable).unwrap_or(Origin::Absent) + } + + /// Whether `module` re-exports something from the package, however far + fn touches_package(&mut self, module: &Exports, seen: &mut FxHashSet) -> bool { + if !seen.insert(module.path.clone()) { + return false; + } + let sources = module + .named + .values() + .filter_map(|link| match link { + Link::From { source, .. } => Some(source), + Link::Own => None, + }) + .chain(&module.stars); + sources.into_iter().any(|source| { + self.is_package(source) + || self + .module(source, &module.path) + .is_some_and(|next| self.touches_package(&next, seen)) + }) + } +} + +/// What a file's imports of Devup UI became +pub(crate) enum Barreled { + Unchanged, + Rewritten(Rewritten), + /// What the build cannot follow, as `(offset, message)` in the source + Failed(Vec<(u32, String)>), +} + +pub(crate) struct Rewritten { + pub code: String, + /// The replacements made in order, so a position in `code` maps back to + /// the source + pub edits: Vec, + /// The modules read to follow re-exports + pub dependencies: BTreeSet, +} + +/// A namespace or default import whose members are read as values +struct NsDecl<'p, 'a> { + binding: &'p BindingIdentifier<'a>, + /// Where its declaration ends, after which the named imports it needs go + after: usize, + /// The barrel module it is a namespace of; `None` for the package + module: Option>, + source: String, +} + +/// A binding of a namespace, known once the program is analysed +struct Namespace { + local: String, + symbol: Option, + /// Where its declaration ends, after which the named imports it needs go + after: usize, + /// The barrel module it is a namespace of; `None` for the package + module: Option>, + source: String, +} + +/// The names the rewritten file gives what it takes from namespaces +#[derive(Default)] +struct Generated { + names: FxHashMap<(String, Option), String>, + taken: FxHashSet, +} + +impl Generated { + /// The local name of `imported` of `source`, with the statement importing + /// it when the name is new + fn name( + &mut self, + code: &str, + namespace: &str, + source: &str, + imported: Option<&str>, + ) -> (String, Option) { + let key = (source.to_string(), imported.map(str::to_string)); + if let Some(name) = self.names.get(&key) { + return (name.clone(), None); + } + let mut name = format!("{namespace}${}", imported.unwrap_or("namespace")); + while code.contains(&name) || self.taken.contains(&name) { + name.push('$'); + } + let quoted = serde_json::to_string(source).unwrap_or_default(); + let statement = match imported { + Some(imported) => format!("import {{ {imported} as {name} }} from {quoted};"), + None => format!("import * as {name} from {quoted};"), + }; + self.taken.insert(name.clone()); + self.names.insert(key, name.clone()); + (name, Some(statement)) + } +} + +mod aliases; +mod gate; +mod namespace_aliases; +use aliases::{Reach, declarator_removal, export_edits, is_assigned_from}; + +fn unreadable(local: &str, code: &str) -> String { + format!( + "`{local}` cannot use `{code}` at build time: read its members by name, as `{local}.css`, `{local}['css']` or `const {{ css }} = {local}`, or import them by name" + ) +} + +fn unfollowable(code: &str, specifier: &str, reason: &str) -> String { + format!( + "`{code}` cannot use `{specifier}` at build time: the build cannot follow its re-export of Devup UI ({reason}); import it from the package instead" + ) +} + +/// The member a computed key names, when it is a literal +fn literal_key(key: &Expression<'_>) -> Option { + match key { + Expression::StringLiteral(text) => Some(text.value.to_string()), + Expression::TemplateLiteral(template) => match template.quasis.as_slice() { + [only] => only.value.cooked.as_ref().map(ToString::to_string), + _ => None, + }, + _ => None, + } +} + +/// The import of `walker`'s modules that reach the package and are namespaces +fn namespace_of(walker: &mut Walker<'_, '_>, source: &str, importer: &str) -> Option> { + let module = walker.module(source, importer)?; + walker + .touches_package(&module, &mut FxHashSet::default()) + .then_some(module) +} + +/// Whether the build compiles the package export `name` away; the others are +/// values a namespace holds at runtime as they are +fn compiled_export(name: &str) -> bool { + name.parse::().is_ok() + || UtilType::from_str_opt(name).is_some() + || name == "styled" +} + +/// The specifiers of an import a statement keeps +#[derive(Default)] +struct Kept<'c> { + default: Option<&'c str>, + namespace: Option<&'c str>, + named: Vec<&'c str>, +} + +/// An import that no rewriting can follow exactly: the binding, where it +/// comes from, and why +struct Opaque<'p, 'a> { + binding: &'p BindingIdentifier<'a>, + source: String, + reason: String, +} + +/// A named or default import that is Devup UI: the binding, what it imports +/// from where, and what that is in the package +struct Bound<'p, 'a> { + binding: &'p BindingIdentifier<'a>, + /// Where the import declaration ends + after: usize, + source: String, + imported: String, + devup: (String, Option), +} + +#[derive(Default)] +struct Found<'p, 'a> { + bound: Vec>, + namespaces: Vec>, + opaque: Vec>, +} + +/// The statements importing what an import takes from the package through a +/// project module, with the rest of it, or `None` when none goes there +fn redirect_import<'p, 'a>( + walker: &mut Walker<'_, '_>, + code: &str, + filename: &str, + import: &'p oxc_ast::ast::ImportDeclaration<'a>, + found: &mut Found<'p, 'a>, +) -> Option { + let source = import.source.value.as_str(); + let after = import.span.end as usize; + let mut redirected: Vec<(Option, &str, String)> = Vec::new(); + let mut kept = Kept::default(); + for specifier in import.specifiers.iter().flatten() { + let (imported, local, is_default) = match specifier { + ImportDeclarationSpecifier::ImportSpecifier(named) if !named.import_kind.is_type() => { + (named.imported.name().to_string(), &named.local, false) + } + ImportDeclarationSpecifier::ImportSpecifier(named) => { + kept.named + .push(&code[named.span.start as usize..named.span.end as usize]); + continue; + } + ImportDeclarationSpecifier::ImportDefaultSpecifier(default) => { + ("default".to_string(), &default.local, true) + } + ImportDeclarationSpecifier::ImportNamespaceSpecifier(namespace) => { + kept.namespace = Some(namespace.local.name.as_str()); + if let Some(module) = namespace_of(walker, source, filename) { + found.namespaces.push(NsDecl { + binding: &namespace.local, + after, + module: Some(module), + source: source.to_string(), + }); + } + continue; + } + }; + match walker.import_origin(source, filename, &imported) { + Origin::Devup { + source: from, + imported: target, + } => { + found.bound.push(Bound { + binding: local, + after, + source: source.to_string(), + imported: imported.clone(), + devup: (from.clone(), target.clone()), + }); + redirected.push((target, local.name.as_str(), from)); + } + origin => { + if let Origin::Unfollowable(reason) = origin { + found.opaque.push(Opaque { + binding: local, + source: source.to_string(), + reason, + }); + } + let span = specifier.span(); + if is_default { + kept.default = Some(local.name.as_str()); + } else { + kept.named + .push(&code[span.start as usize..span.end as usize]); + } + } + } + } + if redirected.is_empty() { + return None; + } + let mut statements: Vec = Vec::new(); + let mut named: Vec<(String, Vec)> = Vec::new(); + for (imported, local, from) in redirected { + let quoted = serde_json::to_string(&from).unwrap_or_default(); + match imported { + None => statements.push(format!("import * as {local} from {quoted};")), + Some(imported) => { + let part = if imported == local { + imported + } else { + format!("{imported} as {local}") + }; + match named.iter_mut().find(|(source, _)| *source == quoted) { + Some((_, parts)) => parts.push(part), + None => named.push((quoted, vec![part])), + } + } + } + } + statements.extend( + named + .into_iter() + .map(|(quoted, parts)| format!("import {{ {} }} from {quoted};", parts.join(", "))), + ); + let mut parts: Vec = Vec::new(); + parts.extend(kept.default.map(str::to_string)); + parts.extend(kept.namespace.map(|name| format!("* as {name}"))); + if !kept.named.is_empty() { + parts.push(format!("{{ {} }}", kept.named.join(", "))); + } + if !parts.is_empty() { + let quoted = serde_json::to_string(source).unwrap_or_default(); + statements.push(format!("import {} from {quoted};", parts.join(", "))); + } + Some(statements.join("\n")) +} + +/// Rewrites the members read from namespaces, collecting what it cannot +struct Rewriter<'s, 'w, 'r, 'p> { + code: &'s str, + semantic: &'s Semantic<'s>, + walker: &'w mut Walker<'r, 'p>, + generated: Generated, + edits: Vec<(usize, usize, String)>, + errors: Vec<(u32, String)>, +} + +impl Rewriter<'_, '_, '_, '_> { + /// Where reading `name` from `namespace` leads + fn member_origin(&mut self, namespace: &Namespace, name: &str) -> Origin { + match &namespace.module { + None if !compiled_export(name) => Origin::Other, + None => Origin::Devup { + source: namespace.source.clone(), + imported: Some(name.to_string()), + }, + Some(module) => match self.walker.origin(module, name, &mut Vec::new()) { + Origin::Absent => Origin::Other, + origin => origin, + }, + } + } + + /// The value references of `binding`, as `(node, is written)` + fn references(&self, symbol: Option) -> Vec<(NodeId, bool)> { + let scoping = self.semantic.scoping(); + symbol.map_or_else(Vec::new, |symbol| { + scoping + .get_resolved_reference_ids(symbol) + .iter() + .map(|id| scoping.get_reference(*id)) + .filter(|reference| reference.is_value()) + .map(|reference| { + let flags = reference.flags(); + ( + reference.node_id(), + flags.is_write() || flags.is_member_write_target(), + ) + }) + .collect() + }) + } + + fn whole_error(&mut self, local: &str, start: u32, end: u32) { + let text = &self.code[start as usize..end as usize]; + let code = match text.char_indices().nth(60) { + Some((index, _)) => format!("{}…", &text[..index]), + None => text.to_string(), + }; + self.errors.push((start, unreadable(local, &code))); + } + + fn read_namespace(&mut self, namespace: &Namespace) { + let mut appended: Vec = Vec::new(); + for (node, written) in self.references(namespace.symbol) { + self.read_reference(namespace, &mut appended, node, written); + } + if !appended.is_empty() { + self.edits.push(( + namespace.after, + namespace.after, + format!("\n{}", appended.join("\n")), + )); + } + } + + fn read_reference( + &mut self, + namespace: &Namespace, + appended: &mut Vec, + node: NodeId, + written: bool, + ) { + let nodes = self.semantic.nodes(); + let span = nodes.kind(node).span(); + let parent = nodes.parent_kind(node); + let local = namespace.local.as_str(); + let member = match parent { + AstKind::StaticMemberExpression(member) => { + Some((member.span, member.property.name.to_string())) + } + AstKind::ComputedMemberExpression(member) if member.object.span() == span => { + literal_key(&member.expression).map(|name| (member.span, name)) + } + AstKind::JSXMemberExpression(member) => { + Some((member.span, member.property.name.to_string())) + } + _ => None, + }; + match (member, parent) { + _ if written => { + let span = parent.span(); + self.whole_error(local, span.start, span.end); + } + (Some((member_span, name)), _) => { + let origin = self.member_origin(namespace, &name); + self.replace(namespace, appended, member_span, &name, origin); + } + (None, AstKind::VariableDeclarator(declarator)) + if matches!(declarator.id, BindingPattern::BindingIdentifier(_)) + && declarator + .init + .as_ref() + .is_some_and(|init| init.span() == span) + && nodes + .parent_kind(nodes.parent_id(node)) + .as_variable_declaration() + .is_some_and(|declaration| { + declaration.kind == VariableDeclarationKind::Const + }) => {} + (None, AstKind::VariableDeclarator(declarator)) + if declarator + .init + .as_ref() + .is_some_and(|init| init.span() == span) => + { + self.destructure(namespace, appended, nodes.parent_id(node), declarator); + } + (None, AstKind::ComputedMemberExpression(member)) if member.object.span() == span => { + self.whole_error(local, member.span.start, member.span.end); + } + (None, AstKind::ExportSpecifier(_)) + if self.edits.iter().any(|(start, end, _)| { + *start <= span.start as usize && *end >= span.end as usize + }) => {} + (None, _) => self.whole_error(local, span.start, span.end), + } + } + + /// Replaces `span`, which reads `name` of the namespace, by what the + /// package gives it + fn replace( + &mut self, + namespace: &Namespace, + appended: &mut Vec, + span: oxc_span::Span, + name: &str, + origin: Origin, + ) { + match origin { + Origin::Devup { source, imported } => { + let (local, statement) = self.generated.name( + self.code, + namespace.local.as_str(), + &source, + imported.as_deref(), + ); + appended.extend(statement); + self.edits + .push((span.start as usize, span.end as usize, local)); + } + Origin::Unfollowable(reason) => { + let code = format!("{}.{name}", namespace.local); + self.errors + .push((span.start, unfollowable(&code, &namespace.source, &reason))); + } + Origin::Other | Origin::Absent => {} + } + } + + /// `const { css, Box: B } = Namespace`: the declaration goes, and what it + /// bound is read from the package where it is used + fn destructure( + &mut self, + namespace: &Namespace, + appended: &mut Vec, + declarator_id: NodeId, + declarator: &oxc_ast::ast::VariableDeclarator<'_>, + ) { + let local = namespace.local.as_str(); + let whole = |rewriter: &mut Self| { + rewriter.whole_error(local, declarator.span.start, declarator.span.end); + }; + let BindingPattern::ObjectPattern(pattern) = &declarator.id else { + return whole(self); + }; + if pattern.rest.is_some() { + return whole(self); + } + let mut members = Vec::new(); + for property in &pattern.properties { + match (&property.value, property.key.static_name()) { + (BindingPattern::BindingIdentifier(binding), Some(name)) if !property.computed => { + members.push((name.to_string(), binding)); + } + _ => return whole(self), + } + } + let mut devup = Vec::new(); + let mut unfollowed = None; + for (name, binding) in &members { + match self.member_origin(namespace, name) { + Origin::Devup { source, imported } => devup.push((name, binding, source, imported)), + Origin::Unfollowable(reason) => unfollowed = unfollowed.or(Some(reason)), + Origin::Other | Origin::Absent => {} + } + } + if let Some(reason) = unfollowed { + let code = &self.code[declarator.span.start as usize..declarator.span.end as usize]; + self.errors.push(( + declarator.span.start, + unfollowable(code, &namespace.source, &reason), + )); + return; + } + if devup.is_empty() { + return; + } + let nodes = self.semantic.nodes(); + let declaration_id = nodes.parent_id(declarator_id); + let declaration = match nodes.kind(declaration_id) { + AstKind::VariableDeclaration(declaration) + if devup.len() == members.len() + && !matches!( + nodes.parent_kind(declaration_id), + AstKind::ExportDeclaration(_) + ) => + { + declaration + } + _ => return whole(self), + }; + let (start, end) = declarator_removal(declaration, declarator.span); + self.edits + .push((start as usize, end as usize, String::new())); + for (name, binding, source, imported) in devup { + let (generated, statement) = + self.generated + .name(self.code, local, &source, imported.as_deref()); + appended.extend(statement); + for (node, written) in self.references(binding.symbol_id.get()) { + self.replace_binding(local, name, &generated, node, written); + } + } + } + + /// A read of what `const { name } = Namespace` bound, as `generated` + fn replace_binding( + &mut self, + namespace: &str, + name: &str, + generated: &str, + node: NodeId, + written: bool, + ) { + let nodes = self.semantic.nodes(); + let span = nodes.kind(node).span(); + let text = match nodes.parent_kind(node) { + _ if written => { + return self.whole_error(namespace, span.start, span.end); + } + AstKind::ObjectProperty(property) if property.shorthand => { + format!( + "{}: {generated}", + &self.code[span.start as usize..span.end as usize] + ) + } + AstKind::ExportSpecifier(specifier) if specifier.span == span => { + format!("{generated} as {name}") + } + _ => generated.to_string(), + }; + self.edits + .push((span.start as usize, span.end as usize, text)); + } +} + +/// Rewrites the imports of `code` that reach Devup UI through a project module +/// or a namespace +pub(crate) fn rewrite( + code: &str, + filename: &str, + package: &str, + resolver: Option<&ModuleResolver>, +) -> Barreled { + if is_vanilla_extract_file(filename) || (resolver.is_none() && !code.contains(package)) { + return Barreled::Unchanged; + } + let allocator = Allocator::default(); + let source_type = SourceType::from_path(filename).unwrap_or_default(); + let ParserReturn { + program, + fatal_error, + .. + } = Parser::new(&allocator, code, source_type).parse(); + if fatal_error { + return Barreled::Unchanged; + } + let no_modules = |_: &str, _: &str| -> Option { None }; + let mut walker = Walker { + resolver: resolver.unwrap_or(&no_modules), + package, + modules: FxHashMap::default(), + dependencies: BTreeSet::new(), + }; + let mut edits: Vec<(usize, usize, String)> = Vec::new(); + let mut found = Found::default(); + for statement in &program.body { + let Statement::ImportDeclaration(import) = statement else { + continue; + }; + if import.import_kind.is_type() || import.with_clause.is_some() { + continue; + } + if walker.is_package(import.source.value.as_str()) { + for specifier in import.specifiers.iter().flatten() { + let binding = match specifier { + ImportDeclarationSpecifier::ImportDefaultSpecifier(default) => &default.local, + ImportDeclarationSpecifier::ImportNamespaceSpecifier(namespace) => { + &namespace.local + } + ImportDeclarationSpecifier::ImportSpecifier(named) => { + if !named.import_kind.is_type() { + let imported = named.imported.name().to_string(); + found.bound.push(Bound { + binding: &named.local, + after: import.span.end as usize, + source: import.source.value.to_string(), + devup: (import.source.value.to_string(), Some(imported.clone())), + imported, + }); + } + continue; + } + }; + found.namespaces.push(NsDecl { + binding, + after: import.span.end as usize, + module: None, + source: import.source.value.to_string(), + }); + } + } else if let Some(statements) = + redirect_import(&mut walker, code, filename, import, &mut found) + { + edits.push(( + import.span.start as usize, + import.span.end as usize, + statements, + )); + } + } + let wants_semantic = !found.namespaces.is_empty() + || found.bound.iter().any(|bound| bound.devup.1.is_none()) + || !found.opaque.is_empty() + || (found + .bound + .iter() + .any(|bound| bound.devup.1.as_deref().is_some_and(compiled_export)) + && (gate::has_export_candidate(&program) + || code.contains("= keyframes(") + || code.contains("= css(") + || found.bound.iter().any(|bound| { + bound.devup.1.as_deref().is_some_and(compiled_export) + && is_assigned_from(code, bound.binding.name.as_str()) + }))); + let (mut edits, errors) = if wants_semantic { + let semantic = SemanticBuilder::new() + .with_build_nodes(true) + .build(&program) + .semantic; + let locals: FxHashMap = found + .bound + .iter() + .filter(|bound| bound.devup.1.as_deref().is_none_or(compiled_export)) + .filter_map(|bound| { + Some(( + bound.binding.symbol_id.get()?, + (bound.source.clone(), Some(bound.imported.clone())), + )) + }) + .chain(found.namespaces.iter().filter_map(|declared| { + Some(( + declared.binding.symbol_id.get()?, + (declared.source.clone(), None), + )) + })) + .collect(); + edits.extend(export_edits(&semantic, code, locals)); + let mut rewriter = Rewriter { + code, + semantic: &semantic, + walker: &mut walker, + generated: Generated::default(), + edits, + errors: Vec::new(), + }; + for opaque in &found.opaque { + let local = opaque.binding.name.as_str(); + for (node, _) in rewriter.references(opaque.binding.symbol_id.get()) { + let span = semantic.nodes().kind(node).span(); + rewriter.errors.push(( + span.start, + unfollowable(local, &opaque.source, &opaque.reason), + )); + } + } + let mut spaces: Vec = found + .namespaces + .iter() + .map(|declared| Namespace { + local: declared.binding.name.to_string(), + symbol: declared.binding.symbol_id.get(), + after: declared.after, + module: declared.module.clone(), + source: declared.source.clone(), + }) + .chain( + found + .bound + .iter() + .filter(|bound| bound.devup.1.is_none()) + .map(|bound| Namespace { + local: bound.binding.name.to_string(), + symbol: bound.binding.symbol_id.get(), + after: bound.after, + module: None, + source: bound.devup.0.clone(), + }), + ) + .collect(); + for namespace in &spaces { + rewriter.read_namespace(namespace); + } + rewriter.follow_aliases(&found.bound, &mut spaces); + rewriter.hoist_style_constants(&program, &found.bound); + (rewriter.edits, rewriter.errors) + } else { + (edits, Vec::new()) + }; + if !errors.is_empty() { + return Barreled::Failed(errors); + } + if edits.is_empty() { + return Barreled::Unchanged; + } + edits.sort_by_key(|(start, end, _)| (*start, *end)); + let recorded = edits + .iter() + .map(|(start, end, text)| (*start, *end, text.len())) + .collect(); + let mut result = code.to_string(); + for (start, end, text) in edits.into_iter().rev() { + result.replace_range(start..end, &text); + } + Barreled::Rewritten(Rewritten { + code: result, + edits: recorded, + dependencies: walker.dependencies, + }) +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used)] +mod tests; + +#[cfg(test)] +mod scope_tests; diff --git a/libs/extractor/src/barrel/aliases.rs b/libs/extractor/src/barrel/aliases.rs new file mode 100644 index 000000000..f9d9cf97d --- /dev/null +++ b/libs/extractor/src/barrel/aliases.rs @@ -0,0 +1,381 @@ +//! What a module binds to Devup UI under another name, and the style +//! constants it reads before they are declared + +use oxc_ast::AstKind; +use oxc_ast::ast::{ + BindingPattern, Declaration, ExportDefaultDeclarationKind, Expression, Program, Statement, + StaticMemberExpression, VariableDeclaration, VariableDeclarationKind, +}; +use oxc_semantic::Semantic; +use oxc_span::{GetSpan, Span}; +use oxc_syntax::node::NodeId; +use oxc_syntax::symbol::SymbolId; +use rustc_hash::FxHashMap; + +use super::{Bound, Namespace, Rewriter, compiled_export, export_symbol, reference_symbol}; + +/// Where a binding leads: the module it is read from, and the name read, `None` +/// for the module's namespace +pub(super) type Reach = (String, Option); + +/// The statement exporting `exported` as what `reach` names, from the module +/// the binding was imported from +fn reexport(exported: &str, reach: &Reach) -> String { + let quoted = serde_json::to_string(&reach.0).unwrap_or_default(); + match &reach.1 { + Some(imported) => format!("export {{ {imported} as {exported} }} from {quoted};"), + None => format!("export * as {exported} from {quoted};"), + } +} + +/// The text of `declaration`'s declarator at `span` removed from it: the +/// whole declaration when it is the only one +pub(super) fn declarator_removal(declaration: &VariableDeclaration<'_>, span: Span) -> (u32, u32) { + let declarators = &declaration.declarations; + let index = declarators + .iter() + .position(|other| other.span == span) + .unwrap_or_default(); + if declarators.len() == 1 { + (declaration.span.start, declaration.span.end) + } else if index + 1 < declarators.len() { + (span.start, declarators[index + 1].span.start) + } else { + (declarators[index - 1].span.end, span.end) + } +} + +/// Whether `name` is the value of an assignment in `code`, as in `= name` +pub(super) fn is_assigned_from(code: &str, name: &str) -> bool { + code.match_indices(name).any(|(index, _)| { + code[..index].trim_end().ends_with('=') + && !code[index + name.len()..] + .starts_with(|c: char| c.is_alphanumeric() || c == '_' || c == '$') + }) +} + +/// The edits that export Devup UI from a module as a re-export of the module +/// it came from: `export { Box }` after an import, `export default Box`, +/// `export const B = Box`. A module compiled with the import removed would +/// otherwise export a name nothing declares. +pub(super) fn export_edits( + semantic: &Semantic<'_>, + code: &str, + mut locals: FxHashMap, +) -> Vec<(usize, usize, String)> { + let mut edits = Vec::new(); + let program = semantic.nodes().program(); + for statement in &program.body { + match statement { + Statement::VariableDeclaration(declaration) => { + alias_reaches(declaration, &mut locals, semantic); + } + Statement::ExportDeclaration(export) => { + if let Declaration::VariableDeclaration(declaration) = &export.declaration { + let aliases = alias_reaches(declaration, &mut locals, semantic); + if !aliases.is_empty() { + let mut statements: Vec = Vec::new(); + let others: Vec<&str> = declaration + .declarations + .iter() + .flat_map(|declarator| declarator.id.get_binding_identifiers()) + .map(|id| id.name.as_str()) + .filter(|name| aliases.iter().all(|(alias, _)| alias != name)) + .collect(); + if !others.is_empty() { + statements.push(format!("export {{ {} }};", others.join(", "))); + } + statements + .extend(aliases.iter().map(|(name, reach)| reexport(name, reach))); + edits.push(( + export.span.start as usize, + declaration.span.start as usize, + String::new(), + )); + edits.push(( + export.span.end as usize, + export.span.end as usize, + format!("\n{}", statements.join("\n")), + )); + } + } + } + Statement::ExportNamedDeclaration(export) if !export.export_kind.is_type() => { + let mut statements = Vec::new(); + let mut kept = Vec::new(); + for specifier in &export.specifiers { + let reach = export_symbol(&specifier.local, semantic) + .and_then(|symbol| locals.get(&symbol)); + match reach { + Some(reach) if !specifier.export_kind.is_type() => { + statements.push(reexport(specifier.exported.name().as_str(), reach)); + } + _ => kept.push( + &code[specifier.span.start as usize..specifier.span.end as usize], + ), + } + } + if !statements.is_empty() { + if !kept.is_empty() { + statements.insert(0, format!("export {{ {} }};", kept.join(", "))); + } + edits.push(( + export.span.start as usize, + export.span.end as usize, + statements.join("\n"), + )); + } + } + Statement::ExportDefaultDeclaration(export) => { + if let ExportDefaultDeclarationKind::Identifier(name) = &export.declaration + && let Some(reach) = + reference_symbol(name, semantic).and_then(|symbol| locals.get(&symbol)) + && reach.1.is_some() + { + edits.push(( + export.span.start as usize, + export.span.end as usize, + reexport("default", reach), + )); + } + } + _ => {} + } + } + edits +} + +/// Where `Namespace.member` leads, when `Namespace` is a local of Devup UI and +/// the build compiles `member` away +fn namespace_member_reach( + member: &StaticMemberExpression<'_>, + locals: &FxHashMap, + semantic: &Semantic<'_>, +) -> Option { + if let Expression::Identifier(object) = &member.object + && let Some((source, None)) = + reference_symbol(object, semantic).and_then(|symbol| locals.get(&symbol)) + && compiled_export(member.property.name.as_str()) + { + Some((source.clone(), Some(member.property.name.to_string()))) + } else { + None + } +} + +/// The `const` bindings of `declaration` that alias a local of Devup UI or a +/// member of its namespace, `(name, where it leads)`, added to `locals` +fn alias_reaches<'a>( + declaration: &VariableDeclaration<'a>, + locals: &mut FxHashMap, + semantic: &Semantic<'_>, +) -> Vec<(&'a str, Reach)> { + let mut aliases = Vec::new(); + if declaration.kind != VariableDeclarationKind::Const { + return aliases; + } + for declarator in &declaration.declarations { + let BindingPattern::BindingIdentifier(id) = &declarator.id else { + continue; + }; + let reach = match &declarator.init { + Some(Expression::Identifier(init)) => reference_symbol(init, semantic) + .and_then(|symbol| locals.get(&symbol)) + .cloned(), + Some(Expression::StaticMemberExpression(member)) => { + namespace_member_reach(member, locals, semantic) + } + _ => None, + }; + if let Some(reach) = reach { + locals.extend(id.symbol_id.get().map(|symbol| (symbol, reach.clone()))); + aliases.push((id.name.as_str(), reach)); + } + } + aliases +} + +/// The style functions whose results a module may read before it declares them +const HOISTED: [&str; 2] = ["css", "keyframes"]; + +impl Rewriter<'_, '_, '_, '_> { + fn is_deferred(&self, node: NodeId) -> bool { + self.semantic.nodes().ancestor_kinds(node).any(|kind| { + matches!( + kind, + AstKind::Function(_) | AstKind::ArrowFunctionExpression(_) + ) + }) + } + + /// Follows what the module aliases: `const c = css` in a function reads + /// the package where it is used; `const D = Devup` is another namespace + pub(super) fn follow_aliases( + &mut self, + bound: &[Bound<'_, '_>], + namespaces: &mut Vec, + ) { + let scoping = self.semantic.scoping(); + let nodes = self.semantic.nodes(); + let mut named: FxHashMap, Reach)> = bound + .iter() + .filter_map(|bound| { + Some((bound.binding.symbol_id.get()?, (bound, bound.devup.clone()))) + }) + .collect(); + let first_new = namespaces.len(); + self.namespace_aliases(namespaces); + let spaces: FxHashMap = namespaces + .iter() + .enumerate() + .filter_map(|(index, namespace)| Some((namespace.symbol?, index))) + .collect(); + for node in nodes.iter() { + let AstKind::VariableDeclarator(declarator) = node.kind() else { + continue; + }; + let (BindingPattern::BindingIdentifier(id), Some(Expression::Identifier(init))) = + (&declarator.id, &declarator.init) + else { + continue; + }; + let declaration_id = nodes.parent_id(node.id()); + let (Some(declaration), Some(alias), Some(origin)) = ( + nodes.kind(declaration_id).as_variable_declaration(), + id.symbol_id.get(), + reference_symbol(init, self.semantic), + ) else { + continue; + }; + if declaration.kind != VariableDeclarationKind::Const { + continue; + } + if spaces.contains_key(&origin) { + continue; + } + if let Some((origin, reach)) = named.get(&origin).cloned() { + named.insert(alias, (origin, reach.clone())); + let exported = matches!( + nodes.parent_kind(declaration_id), + AstKind::ExportDeclaration(_) + ); + if exported || scoping.symbol_scope_id(alias) == scoping.root_scope_id() { + continue; + } + let (name, statement) = + self.generated + .name(self.code, id.name.as_str(), &reach.0, reach.1.as_deref()); + let after = origin.after; + if let Some(statement) = statement { + self.edits.push((after, after, format!("\n{statement}"))); + } + for (node, written) in self.references(Some(alias)) { + self.replace_binding(id.name.as_str(), id.name.as_str(), &name, node, written); + } + let (start, end) = declarator_removal(declaration, declarator.span); + self.edits + .push((start as usize, end as usize, String::new())); + } + } + let added: Vec = namespaces.drain(first_new..).collect(); + for namespace in &added { + self.read_namespace(namespace); + } + namespaces.extend(added); + } + + /// Moves the `const` a function reads before its declaration, binding + /// what `css()` or `keyframes()` gives from literals only, above its first + /// read, as a read after a `const` runs sees it; a read that runs before + /// it is an error + pub(super) fn hoist_style_constants(&mut self, program: &Program<'_>, bound: &[Bound<'_, '_>]) { + let nodes = self.semantic.nodes(); + let apis: FxHashMap = bound + .iter() + .filter_map(|bound| { + let api = bound.devup.1.as_deref()?; + HOISTED + .contains(&api) + .then(|| Some((bound.binding.symbol_id.get()?, api)))? + }) + .collect(); + let imports: Vec = bound + .iter() + .filter_map(|bound| bound.binding.symbol_id.get()) + .collect(); + for statement in &program.body { + let Some(Statement::VariableDeclaration(declaration)) = Some(statement) else { + continue; + }; + let [declarator] = declaration.declarations.as_slice() else { + continue; + }; + let ( + BindingPattern::BindingIdentifier(id), + Some(Expression::CallExpression(call)), + VariableDeclarationKind::Const, + ) = (&declarator.id, &declarator.init, declaration.kind) + else { + continue; + }; + let Expression::Identifier(callee) = &call.callee else { + continue; + }; + let Some(api) = + reference_symbol(callee, self.semantic).and_then(|symbol| apis.get(&symbol)) + else { + continue; + }; + let early: Vec<(NodeId, u32)> = self + .references(id.symbol_id.get()) + .into_iter() + .map(|(node, _)| (node, nodes.kind(node).span().start)) + .filter(|(_, start)| *start < declaration.span.start) + .collect(); + if early.is_empty() { + continue; + } + let name = id.name.as_str(); + if let Some((_, start)) = early.iter().find(|(node, _)| !self.is_deferred(*node)) { + self.errors.push(( + *start, + format!( + "`{api}()` cannot use `{name}` at build time: it is read before `const {name} = {api}(…)` runs, so move that declaration above where it is first read" + ), + )); + continue; + } + let literal_only = nodes.iter().all(|node| { + let AstKind::IdentifierReference(reference) = node.kind() else { + return true; + }; + let inside = + reference.span.start >= call.span.start && reference.span.end <= call.span.end; + !inside + || reference_symbol(reference, self.semantic) + .is_none_or(|symbol| imports.contains(&symbol)) + }); + if !literal_only { + continue; + } + let first = early + .iter() + .map(|(_, start)| *start) + .min() + .unwrap_or_default(); + let before = program + .body + .iter() + .find(|statement| statement.span().end > first) + .map_or(declaration.span.start, |statement| statement.span().start); + let text = &self.code[declaration.span.start as usize..declaration.span.end as usize]; + self.edits + .push((before as usize, before as usize, format!("{text}\n"))); + self.edits.push(( + declaration.span.start as usize, + declaration.span.end as usize, + String::new(), + )); + } + } +} diff --git a/libs/extractor/src/barrel/gate.rs b/libs/extractor/src/barrel/gate.rs new file mode 100644 index 000000000..f1e3ee9fe --- /dev/null +++ b/libs/extractor/src/barrel/gate.rs @@ -0,0 +1,120 @@ +use oxc_ast::ast::{ + BindingPattern, Declaration, ExportDefaultDeclarationKind, Expression, Program, Statement, + VariableDeclarationKind, +}; + +pub(super) fn has_export_candidate(program: &Program<'_>) -> bool { + program.body.iter().any(|statement| match statement { + Statement::ExportNamedDeclaration(export) => { + !export.export_kind.is_type() + && export + .specifiers + .iter() + .any(|specifier| !specifier.export_kind.is_type()) + } + Statement::ExportDefaultDeclaration(export) => { + matches!( + export.declaration, + ExportDefaultDeclarationKind::Identifier(_) + ) + } + Statement::ExportDeclaration(export) => match &export.declaration { + Declaration::VariableDeclaration(declaration) => { + declaration.kind == VariableDeclarationKind::Const + && declaration.declarations.iter().any(|declarator| { + matches!(declarator.id, BindingPattern::BindingIdentifier(_)) + && matches!( + declarator.init, + Some( + Expression::Identifier(_) + | Expression::StaticMemberExpression(_) + ) + ) + }) + } + _ => false, + }, + _ => false, + }) +} + +#[cfg(test)] +mod tests { + use oxc_allocator::Allocator; + use oxc_parser::Parser; + use oxc_span::SourceType; + use rstest::rstest; + + use super::has_export_candidate; + use crate::ResolvedModule; + use crate::barrel::{Barreled, Link, analyze, rewrite}; + + #[rstest] + #[case("export { Box };", true)] + #[case("export { type Props, css as style };", true)] + #[case("export { type Props };", false)] + #[case("export type { Box };", false)] + #[case("export {};", false)] + #[case("export default Box;", true)] + #[case("export default function Page() { return ; }", false)] + #[case("export default () => ;", false)] + #[case("export const B = /* comment */ Box;", true)] + #[case("export const c = UI.css;", true)] + #[case("export const other = 1, B = Box;", true)] + #[case("export const Page = ;", false)] + #[case("export const value = 1;", false)] + #[case("export const style = css({});", false)] + #[case("export const { Box } = UI;", false)] + #[case("export let B = Box;", false)] + #[case("export const B = Box['name'];", false)] + #[case("export function Page() { return ; }", false)] + #[case("export { Box } from '@devup-ui/react';", false)] + #[case("export * from '@devup-ui/react';", false)] + #[case("const text = 'export';", false)] + fn export_shape_selects_analysis_candidates(#[case] code: &str, #[case] expected: bool) { + let allocator = Allocator::default(); + let program = Parser::new(&allocator, code, SourceType::tsx()) + .parse() + .program; + + let candidate = has_export_candidate(&program); + + assert_eq!(candidate, expected, "{code}"); + } + + #[rstest] + #[case("const B = /* comment */ Box; export { B };")] + #[case("const B = /* comment */ Box; export default B;")] + #[case("export const B = /* comment */ Box;")] + fn exported_alias_is_rewritten_when_assignment_text_does_not_match(#[case] exported: &str) { + let code = format!("import {{ Box }} from '@devup-ui/react'; {exported}"); + + let result = rewrite(&code, "ui.tsx", "@devup-ui/react", None); + + let Barreled::Rewritten(result) = result else { + panic!("exported alias was not rewritten"); + }; + assert!( + result.code.contains("from \"@devup-ui/react\";"), + "{}", + result.code + ); + assert!(result.code.contains("export { Box as"), "{}", result.code); + } + + #[test] + fn inline_type_import_is_not_a_runtime_origin_when_value_imports_are_mixed() { + let module = ResolvedModule { + path: "ui.ts".to_string(), + code: "export { Box, css }; import { type Box, css } from '@devup-ui/react';" + .to_string(), + }; + + let exports = analyze(&module, "@devup-ui/react"); + + assert!(matches!(exports.named.get("Box"), Some(Link::Own))); + assert!( + matches!(exports.named.get("css"), Some(Link::From { source, imported: Some(name) }) if source == "@devup-ui/react" && name == "css") + ); + } +} diff --git a/libs/extractor/src/barrel/namespace_aliases.rs b/libs/extractor/src/barrel/namespace_aliases.rs new file mode 100644 index 000000000..26a9e3905 --- /dev/null +++ b/libs/extractor/src/barrel/namespace_aliases.rs @@ -0,0 +1,58 @@ +//! Whole namespace aliases, followed by semantic identity regardless of declaration order. + +use oxc_ast::AstKind; +use oxc_ast::ast::{BindingPattern, Expression, VariableDeclarationKind}; +use rustc_hash::FxHashMap; + +use super::{Namespace, Rewriter, reference_symbol}; + +impl Rewriter<'_, '_, '_, '_> { + /// Collects every immutable namespace alias before checking its runtime reads. + pub(super) fn namespace_aliases(&self, namespaces: &mut Vec) { + let mut spaces: FxHashMap<_, _> = namespaces + .iter() + .enumerate() + .filter_map(|(index, namespace)| Some((namespace.symbol?, index))) + .collect(); + let nodes = self.semantic.nodes(); + loop { + let before = spaces.len(); + for node in nodes.iter() { + let AstKind::VariableDeclarator(declarator) = node.kind() else { + continue; + }; + let (BindingPattern::BindingIdentifier(id), Some(Expression::Identifier(init))) = + (&declarator.id, &declarator.init) + else { + continue; + }; + let (Some(alias), Some(origin), AstKind::VariableDeclaration(declaration)) = ( + id.symbol_id.get(), + reference_symbol(init, self.semantic), + nodes.parent_kind(node.id()), + ) else { + continue; + }; + if declaration.kind != VariableDeclarationKind::Const || spaces.contains_key(&alias) + { + continue; + } + if let Some(&index) = spaces.get(&origin) { + let origin = &namespaces[index]; + let namespace = Namespace { + local: id.name.to_string(), + symbol: Some(alias), + after: origin.after, + module: origin.module.clone(), + source: origin.source.clone(), + }; + spaces.insert(alias, namespaces.len()); + namespaces.push(namespace); + } + } + if spaces.len() == before { + break; + } + } + } +} diff --git a/libs/extractor/src/barrel/namespace_escape_tests.rs b/libs/extractor/src/barrel/namespace_escape_tests.rs new file mode 100644 index 000000000..d55d29419 --- /dev/null +++ b/libs/extractor/src/barrel/namespace_escape_tests.rs @@ -0,0 +1,133 @@ +use super::{NS, Project, namespace_code, namespace_error}; +use crate::barrel::{Barreled, rewrite}; +use serial_test::serial; + +#[test] +#[serial] +fn runtime_namespace_escapes_are_rejected_when_used_whole() { + for usage in [ + "consume(Devup)", + "Object.keys(Devup)", + "function f() { return Devup }", + "const value = { ns: Devup }", + "const value = { Devup }", + "const value = [Devup]", + "export default Devup", + "export default { Devup }", + "const value = typeof Devup", + "let D = Devup; consume(D)", + "var D = Devup; consume(D)", + "const D = Devup; spyOn(D, 'css')", + "const D = Devup; const E = D; consume(E)", + "const D = Devup; export default D", + "function f() { const D = Devup; return D }", + "const E = D; const D = Devup; consume(E)", + "export { D }; const D = Devup", + "consume((Devup))", + "consume(Devup as object)", + ] { + let source = format!("{NS}{usage}\n"); + + let error = namespace_error(&source); + + assert!(error.contains("test.tsx:2:"), "{usage}: {error}"); + assert!( + error.contains("cannot use") && error.contains("read its members by name"), + "{usage}: {error}" + ); + } +} + +#[test] +#[serial] +fn safe_namespace_reads_preserve_semantic_identity() { + let source = format!( + "{NS}const D = Devup; const E = D;\nconst {{ css: c }} = E;\nexport const a = c({{ color: 'red' }});\nexport const b = D.css({{ color: 'blue' }});\nexport type N = typeof Devup;\nexport type A = typeof D;\nexport function f(Devup) {{ return spyOn(Devup, 'css') }}\nexport function g(D) {{ return D }}\nexport {{ Devup as UI }};\n" + ); + + let code = namespace_code(&source); + + assert!(!code.contains("c({") && !code.contains("D.css("), "{code}"); + assert!(code.contains("spyOn(Devup, \"css\")"), "{code}"); + assert!(code.contains("return D"), "{code}"); + assert!( + code.contains("typeof Devup") && code.contains("typeof D"), + "{code}" + ); + assert!(code.contains("export * as UI"), "{code}"); +} + +#[test] +#[serial] +fn type_only_and_unrelated_namespaces_remain_unchanged() { + for source in [ + "import type * as Devup from '@devup-ui/react'; export type N = typeof Devup;", + "import * as Devup from 'other'; spyOn(Devup, 'css');", + "import * as Devup from '@devup-ui/react'; function f() { const Devup = {}; return Devup }", + ] { + let result = rewrite(source, "test.tsx", "@devup-ui/react", None); + + assert!(matches!(result, Barreled::Unchanged)); + } +} + +#[test] +#[serial] +fn reexported_namespaces_are_rejected_when_their_runtime_value_escapes() { + for (index, (barrel, import)) in [ + ( + "export { css } from '@devup-ui/react'", + "import * as Devup from './ui'", + ), + ( + "export * as Devup from '@devup-ui/react'", + "import { Devup } from './ui'", + ), + ( + "import * as D from '@devup-ui/react'; export default D", + "import Devup from './ui'", + ), + ( + "import * as D from '@devup-ui/react'; export const Devup = D", + "import { Devup } from './ui'", + ), + ( + "export * as Devup from './inner'", + "import { Devup } from './ui'", + ), + ] + .into_iter() + .enumerate() + { + let source = format!("{import}\nspyOn(Devup, 'css')\n"); + let project = Project::new( + &format!("escape-{index}"), + &[ + ("ui.ts", barrel), + ("inner.ts", "export { css } from '@devup-ui/react'"), + ("app.tsx", &source), + ], + ); + + let error = project.error("app.tsx"); + + assert!(error.contains("app.tsx:2:7:"), "{error}"); + assert!( + error.contains("cannot use") && error.contains("import"), + "{error}" + ); + } +} + +#[test] +#[serial] +fn default_namespace_import_is_rejected_when_passed_to_runtime() { + let source = "import Devup from '@devup-ui/react'\nspyOn(Devup, 'css')\n"; + + let error = namespace_error(source); + + assert!( + error.contains("test.tsx:2:7: `Devup` cannot use `Devup`"), + "{error}" + ); +} diff --git a/libs/extractor/src/barrel/scope_tests.rs b/libs/extractor/src/barrel/scope_tests.rs new file mode 100644 index 000000000..8a1763f63 --- /dev/null +++ b/libs/extractor/src/barrel/scope_tests.rs @@ -0,0 +1,185 @@ +use rstest::rstest; + +use super::{Barreled, rewrite}; +use crate::ResolvedModule; + +const PACKAGE: &str = "@devup-ui/react"; + +#[rstest] +#[case( + "export { Box }; import { Box } from '@devup-ui/react';", + "import { Box } from './ui'; const view = ;" +)] +#[case( + "export default Box; import { Box } from '@devup-ui/react';", + "import Box from './ui'; const view = ;" +)] +#[case( + "const B = Box; export { B as Box }; import { Box } from '@devup-ui/react';", + "import { Box } from './ui'; const view = ;" +)] +fn barrel_import_is_followed_when_declared_after_its_export( + #[case] barrel: &str, + #[case] code: &str, +) { + let barrel = barrel.to_string(); + let resolver = move |_: &str, _: &str| { + Some(ResolvedModule { + path: "ui.ts".to_string(), + code: barrel.clone(), + }) + }; + + let result = rewrite(code, "app.tsx", PACKAGE, Some(&resolver)); + + let Barreled::Rewritten(result) = result else { + panic!("barrel import was not redirected"); + }; + assert!( + result.code.contains("from \"@devup-ui/react\""), + "{}", + result.code + ); +} + +#[rstest] +#[case("function f(UI) { return UI.css({ color: 'blue' }); }")] +#[case("function f() { const UI = other; return ; }")] +#[case("function f(UI) { const D = UI; return D.css({ color: 'blue' }); }")] +#[case("namespace Local { import UI = Other.UI; export const x = UI.css({}); }")] +fn namespace_shadow_is_preserved_when_outer_namespace_is_rewritten( + #[case] shadow: &str, + #[values(PACKAGE, "./ui")] source: &str, +) { + let code = format!("import * as UI from '{source}'; const outer = UI.css({{}}); {shadow}"); + let resolver = |_: &str, _: &str| { + Some(ResolvedModule { + path: "ui.ts".to_string(), + code: "export { css, Box } from '@devup-ui/react';".to_string(), + }) + }; + + let result = rewrite(&code, "app.tsx", PACKAGE, Some(&resolver)); + + let Barreled::Rewritten(result) = result else { + panic!("outer namespace was not rewritten"); + }; + assert!(result.code.contains(shadow), "{}", result.code); + assert!( + result.code.contains("const outer = UI$css({});"), + "{}", + result.code + ); +} + +#[rstest] +#[case("function f(css) { const local = css; return local({}); }")] +#[case("function f() { const css = other; const local = css; return local({}); }")] +#[case("namespace Local { import css = Other.css; export const value = css; }")] +fn named_shadow_is_preserved_when_import_alias_is_rewritten(#[case] shadow: &str) { + let code = format!( + "import {{ css }} from '@devup-ui/react'; function real() {{ const local = css; return local({{}}); }} {shadow}" + ); + + let result = rewrite(&code, "app.tsx", PACKAGE, None); + + let Barreled::Rewritten(result) = result else { + panic!("import alias was not rewritten"); + }; + assert!(result.code.contains(shadow), "{}", result.code); + assert!( + result.code.contains("return local$css({});"), + "{}", + result.code + ); +} + +#[test] +fn barrel_alias_shadow_is_preserved_when_same_spelling_occurs_in_multiple_scopes() { + let shadow = "function shadow(css) { const local = css; return local({}); }"; + let code = format!( + "import {{ css }} from './ui'; const top = css; function a() {{ const local = top; return local({{}}); }} function b() {{ const local = css; return local({{}}); }} {shadow}" + ); + let resolver = |_: &str, _: &str| { + Some(ResolvedModule { + path: "ui.ts".to_string(), + code: "import { css } from '@devup-ui/react'; function f(css) { const local = css; return local; } export { css };".to_string(), + }) + }; + + let result = rewrite(&code, "app.tsx", PACKAGE, Some(&resolver)); + + let Barreled::Rewritten(result) = result else { + panic!("barrel aliases were not rewritten"); + }; + assert!(result.code.contains(shadow), "{}", result.code); + assert_eq!(result.code.matches("return local$css({});").count(), 2); +} + +#[test] +fn nested_namespace_import_is_preserved_when_exporting_an_outer_alias() { + let shadow = "namespace Local { import UI = Other.UI; export const B = UI.Box; }"; + let code = format!("import * as UI from '@devup-ui/react'; {shadow} export const B = UI.Box;"); + + let result = rewrite(&code, "app.tsx", PACKAGE, None); + + let Barreled::Rewritten(result) = result else { + panic!("outer export was not rewritten"); + }; + assert!(result.code.contains(shadow), "{}", result.code); + assert!( + result + .code + .contains("export { Box as B } from \"@devup-ui/react\";"), + "{}", + result.code + ); +} + +#[test] +fn runtime_import_is_unchanged_when_exporting_its_alias() { + let code = + "import { useTheme } from '@devup-ui/react'; const theme = useTheme; export { theme };"; + + let result = rewrite(code, "app.tsx", PACKAGE, None); + + assert!(matches!(result, Barreled::Unchanged)); +} + +#[rstest] +#[case("export { Box } from '@devup-ui/react';")] +#[case("export { 'Box' as Box } from '@devup-ui/react';")] +fn source_export_name_has_no_local_binding_when_it_is_not_a_reference(#[case] code: &str) { + let allocator = oxc_allocator::Allocator::default(); + let program = oxc_parser::Parser::new(&allocator, code, oxc_span::SourceType::mjs()) + .parse() + .program; + let semantic = oxc_semantic::SemanticBuilder::new() + .build(&program) + .semantic; + let oxc_ast::ast::Statement::ExportFromDeclaration(export) = &program.body[0] else { + panic!("expected a source export"); + }; + + let symbol = super::export_symbol(&export.specifiers[0].local, &semantic); + + assert_eq!(symbol, None); +} + +#[test] +fn unresolved_alias_is_preserved_when_exporting_a_compiled_import() { + let code = + "import { Box } from '@devup-ui/react'; const alias = unknown; export { Box, alias };"; + + let result = rewrite(code, "app.tsx", PACKAGE, None); + + let Barreled::Rewritten(result) = result else { + panic!("compiled export was not rewritten"); + }; + assert!( + result.code.contains("const alias = unknown;"), + "{}", + result.code + ); + assert!(result.code.contains("export { alias };"), "{}", result.code); +} diff --git a/libs/extractor/src/barrel/tests.rs b/libs/extractor/src/barrel/tests.rs new file mode 100644 index 000000000..76fb93e80 --- /dev/null +++ b/libs/extractor/src/barrel/tests.rs @@ -0,0 +1,1075 @@ +use std::collections::HashMap; +use std::fs; +use std::path::{Component, Path, PathBuf}; + +use css::class_map::reset_class_map; +use css::file_map::reset_file_map; +use serial_test::serial; + +use crate::{ + ExtractOption, ExtractOutput, ModuleResolver, ResolvedModule, extract, extract_with_modules, + has_devup_ui_with, +}; + +#[path = "namespace_escape_tests.rs"] +mod namespace_escape_tests; + +/// A project of real files on disk, removed when dropped +struct Project { + root: PathBuf, +} + +impl Project { + fn new(name: &str, files: &[(&str, &str)]) -> Self { + let root = std::env::temp_dir().join(format!("devup-barrel-{}-{name}", std::process::id())); + let _ = fs::remove_dir_all(&root); + for (path, code) in files { + let file = root.join(path); + fs::create_dir_all(file.parent().unwrap()).unwrap(); + fs::write(file, code).unwrap(); + } + Self { root } + } + + fn path(&self, name: &str) -> String { + self.root.join(name).to_string_lossy().replace('\\', "/") + } + + fn resolver(&self) -> impl Fn(&str, &str) -> Option + use<> { + let root = self.root.clone(); + move |specifier, importer| { + let base = if specifier.starts_with('.') { + normalize(&Path::new(importer).parent().unwrap().join(specifier)) + } else { + root.join("node_modules").join(specifier) + }; + let mut candidates = vec![base.clone()]; + for extension in ["ts", "tsx", "js"] { + candidates.push(PathBuf::from(format!("{}.{extension}", base.display()))); + candidates.push(base.join(format!("index.{extension}"))); + } + candidates + .into_iter() + .find(|candidate| candidate.is_file()) + .map(|path| ResolvedModule { + code: fs::read_to_string(&path).unwrap(), + path: path.to_string_lossy().replace('\\', "/"), + }) + } + } + + fn extract(&self, name: &str) -> Result { + reset_class_map(); + reset_file_map(); + let filename = self.path(name); + let code = fs::read_to_string(&filename).unwrap(); + extract_with_modules( + &filename, + &code, + ExtractOption::default(), + false, + &self.resolver(), + ) + .map_err(|error| error.to_string()) + } + + fn code(&self, name: &str) -> String { + self.extract(name).unwrap().code + } + + fn error(&self, name: &str) -> String { + self.extract(name).unwrap_err() + } +} + +fn normalize(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + normalized.pop(); + } + component => normalized.push(component), + } + } + normalized +} + +impl Drop for Project { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } +} + +const APP: &str = "import { Box } from './ui'\nexport const a = \n"; + +fn assert_compiled(code: &str) { + assert!(code.contains("
\n", + ), + ], + ); + assert_eq!(project.code("app.tsx"), project.code("direct.tsx")); +} + +#[test] +#[serial] +fn chained_barrels_are_followed() { + let project = Project::new( + "chain", + &[ + ("ui/index.ts", "export * from './components'\n"), + ( + "ui/components/index.ts", + "export { Box } from './box'\nexport { Text } from '@devup-ui/react'\n", + ), + ( + "ui/components/box.ts", + "export { Box } from '@devup-ui/react'\n", + ), + ("app.tsx", APP), + ], + ); + let output = project.extract("app.tsx").unwrap(); + assert_compiled(&output.code); + assert_eq!( + output.dependencies, + vec![ + project.path("ui/components/box.ts"), + project.path("ui/components/index.ts"), + project.path("ui/index.ts"), + ] + ); +} + +#[test] +#[serial] +fn export_star_of_the_package_is_followed() { + let project = Project::new( + "star", + &[ + ( + "ui/index.ts", + "export * from '@devup-ui/react'\nexport * from './other'\n", + ), + ("ui/other.ts", "export const other = 1\nexport {}\n"), + ( + "app.tsx", + "import { Box, css } from './ui'\nimport { other } from './ui'\nexport const a = \nexport const b = other\n", + ), + ], + ); + let code = project.code("app.tsx"); + assert_compiled(&code); + assert!(code.contains("import { other } from \"./ui\""), "{code}"); + assert!(!code.contains("css("), "{code}"); +} + +#[test] +#[serial] +fn renamed_exports_keep_the_importers_names() { + let project = Project::new( + "renamed", + &[ + ( + "ui.ts", + "export { Box as Container, css as style, styled as s } from '@devup-ui/react'\n", + ), + ( + "app.tsx", + "import { Container as C, style, s } from './ui'\nexport const a = \nexport const b = style({ color: 'blue' })\nexport const D = s('div', { color: 'green' })\nexport const d = \n", + ), + ], + ); + let code = project.code("app.tsx"); + assert_compiled(&code); + assert!(!code.contains("style("), "{code}"); +} + +#[test] +#[serial] +fn compat_entry_is_followed() { + let project = Project::new( + "compat", + &[ + ( + "ui.ts", + "export { useTheme } from '@devup-ui/react/compat'\n", + ), + ( + "app.tsx", + "import { useTheme } from './ui'\nexport const t = useTheme\n", + ), + ], + ); + let code = project.code("app.tsx"); + assert!(code.contains("@devup-ui/react/compat"), "{code}"); + assert!(!code.contains("./ui"), "{code}"); +} + +#[test] +#[serial] +fn imports_of_other_things_stay() { + let project = Project::new( + "mixed", + &[ + ( + "ui.ts", + "import { Box as B } from '@devup-ui/react'\nexport { B }\nexport const helper = 1\nexport type Props = {}\nexport default function Page() {}\n", + ), + ( + "app.tsx", + "import Page, { B as Box, helper, type Props } from './ui'\nexport const a = \nexport const b = [Page, helper]\nexport const c: Props = {}\n", + ), + ], + ); + let code = project.code("app.tsx"); + assert_compiled(&code); + assert!( + code.contains("import Page, { helper, type Props } from \"./ui\""), + "{code}" + ); +} + +#[test] +#[serial] +fn default_exports_of_the_package_are_followed() { + let project = Project::new( + "default", + &[ + ( + "ui.ts", + "import { Box } from '@devup-ui/react'\nexport default Box\n", + ), + ( + "app.tsx", + "import UI from './ui'\nexport const a = \n", + ), + ], + ); + assert!(project.code("app.tsx").contains("
\n", + ), + ], + ); + assert_compiled(&project.code("app.tsx")); +} + +#[test] +#[serial] +fn namespace_of_a_barrel_is_read_by_member() { + let project = Project::new( + "barrel-namespace", + &[ + ( + "ui.ts", + "export { Box, css } from '@devup-ui/react'\nexport const helper = 1\n", + ), + ( + "app.tsx", + "import * as UI from './ui'\nexport const a = {UI.helper}\nexport const { css: c, helper } = { css: 1, helper: 2 }\nexport const d = UI['css']({ color: 'blue' })\n", + ), + ], + ); + let code = project.code("app.tsx"); + assert!(code.contains("
\nexport const b = css({ color: 'blue' })\n", + ), + ], + ); + let code = project.code("app.tsx"); + assert_compiled(&code); + assert!(!code.contains("= UI"), "{code}"); +} + +#[test] +#[serial] +fn destructure_leaves_what_the_barrel_declares() { + let project = Project::new( + "barrel-destructure-own", + &[ + ( + "ui.ts", + "export { Box } from '@devup-ui/react'\nexport const helper = 1\n", + ), + ( + "app.tsx", + "import * as UI from './ui'\nconst { helper } = UI\nexport const a = helper\n", + ), + ], + ); + assert!(project.code("app.tsx").contains("{ helper } = UI")); +} + +#[test] +#[serial] +fn unresolvable_barrel_is_a_located_error_where_used() { + let project = Project::new( + "unresolvable", + &[ + ( + "ui.ts", + "export * from './missing'\nexport { Box } from '@devup-ui/react'\n", + ), + ( + "app.tsx", + "import { Foo, Box } from './ui'\nexport const a = 1\nexport const b = \n", + ), + ( + "unused.tsx", + "import { Foo } from './ui'\nexport const a = 1\n", + ), + ( + "named.ts", + "export { Foo } from './missing'\nexport { Box } from '@devup-ui/react'\n", + ), + ( + "app-named.tsx", + "import { Foo } from './named'\nexport const b = \n", + ), + ], + ); + let error = project.error("app.tsx"); + assert!( + error.contains("app.tsx:3:19: `Foo` cannot use `./ui` at build time"), + "{error}" + ); + assert!(error.contains("`./missing` cannot be read"), "{error}"); + assert_eq!( + project.code("unused.tsx"), + "import { Foo } from './ui'\nexport const a = 1\n" + ); + assert!( + project + .error("app-named.tsx") + .contains("`./missing` cannot be read") + ); +} + +#[test] +#[serial] +fn unresolvable_modules_unrelated_to_the_package_stay() { + let project = Project::new( + "unrelated", + &[ + ( + "ui.ts", + "export * from './missing'\nexport { x } from './missing'\n", + ), + ( + "app.tsx", + "import { Foo, x } from './ui'\nexport const a = [Foo, x]\n", + ), + ], + ); + assert!( + project + .code("app.tsx") + .contains("import { Foo, x } from './ui'") + ); +} + +#[test] +#[serial] +fn namespaces_of_modules_are_followed_or_reported() { + let project = Project::new( + "namespace-module", + &[ + ("inner.ts", "export { Box } from '@devup-ui/react'\n"), + ("plain.ts", "export const x = 1\n"), + ( + "ui.ts", + "export * as inner from './inner'\nexport * as plain from './plain'\n", + ), + ( + "app.tsx", + "import { plain } from './ui'\nexport const a = plain.x\n", + ), + ( + "bad.tsx", + "import { inner } from './ui'\nexport const a = \n", + ), + ], + ); + assert!( + project + .code("app.tsx") + .contains("import { plain } from './ui'") + ); + assert!( + project + .error("bad.tsx") + .contains("a namespace of modules re-exporting `@devup-ui/react`") + ); +} + +#[test] +#[serial] +fn cycles_between_barrels_end() { + let project = Project::new( + "cycle", + &[ + ( + "a.ts", + "export * from './b'\nexport { Box } from '@devup-ui/react'\n", + ), + ("b.ts", "export * from './a'\n"), + ( + "app.tsx", + "import { Box, nothing } from './b'\nexport const a = \nexport const n = nothing\n", + ), + ], + ); + let code = project.code("app.tsx"); + assert_compiled(&code); + assert!(code.contains("import { nothing } from \"./b\""), "{code}"); +} + +#[test] +#[serial] +fn files_that_cannot_lead_to_the_package_are_not_read_further() { + let project = Project::new( + "plain", + &[ + ("plain.ts", "export const x = 1\n"), + ( + "app.tsx", + "import { x } from './plain'\nimport './side-effect'\nimport type { T } from './plain'\nimport {} from './plain'\nexport const a = x\n", + ), + ], + ); + let output = project.extract("app.tsx").unwrap(); + assert_eq!(output.styles.len(), 0); + assert_eq!(output.dependencies, Vec::::new()); +} + +#[test] +#[serial] +fn type_only_and_attribute_imports_are_left() { + let project = Project::new( + "types", + &[ + ("ui.ts", "export { Box } from '@devup-ui/react'\n"), + ( + "app.tsx", + "import type { Box } from './ui'\nimport data from './ui' with { type: 'json' }\nexport type A = typeof Box\nexport const d = data\n", + ), + ], + ); + let code = project.code("app.tsx"); + assert!(code.contains("./ui"), "{code}"); +} + +#[test] +#[serial] +fn export_forms_that_are_not_re_exports_are_declarations() { + let project = Project::new( + "declarations", + &[ + ( + "ui.ts", + "import { Box } from '@devup-ui/react'\nexport type { Box as BoxType } from '@devup-ui/react'\nexport type * from '@devup-ui/react'\nexport { type Box as T }\nexport enum E { A }\nexport default (() => Box)\n", + ), + ( + "app.tsx", + "import D, { BoxType, E, T } from './ui'\nexport const a = [D, BoxType, E, T]\n", + ), + ], + ); + let code = project.code("app.tsx"); + assert!( + code.contains("import D, { BoxType, E, T } from './ui'"), + "{code}" + ); +} + +#[test] +#[serial] +fn parse_failures_are_left_alone() { + let project = Project::new( + "broken", + &[ + ("ui.ts", "export { Box } from '@devup-ui/react'\n"), + ("app.tsx", "import { Box } from './ui'\nconst = <\n"), + ], + ); + assert_eq!( + project.code("app.tsx"), + "import { Box } from './ui'\nconst = <\n" + ); +} + +#[test] +#[serial] +fn vanilla_extract_stylesheets_are_left_alone() { + let project = Project::new( + "vanilla", + &[ + ("ui.ts", "export { style } from '@devup-ui/react'\n"), + ( + "a.css.ts", + "import { style } from '@vanilla-extract/css'\nexport const a = style({ color: 'red' })\n", + ), + ], + ); + assert!(project.extract("a.css.ts").is_ok()); +} + +fn namespace_error(code: &str) -> String { + reset_class_map(); + reset_file_map(); + extract("test.tsx", code, ExtractOption::default()) + .unwrap_err() + .to_string() +} + +fn namespace_code(code: &str) -> String { + reset_class_map(); + reset_file_map(); + extract("test.tsx", code, ExtractOption::default()) + .unwrap() + .code +} + +const NS: &str = "import * as Devup from '@devup-ui/react'\n"; + +#[test] +#[serial] +fn namespace_members_compile_by_name() { + let code = namespace_code(&format!( + "{NS}export const a = x\nexport const b = Devup['css']({{ color: 'blue' }})\nexport const c = Devup[`css`]({{ color: 'green' }})\nexport const d = Devup.css({{ color: 'blue' }})\n" + )); + assert!(code.contains("
\nexport function f() {{\n const {{ css: local }} = Devup\n return local({{ color: 'blue' }})\n}}\nconst g = 1, {{ keyframes }} = Devup, h = 2\nexport const k = [g, h, keyframes({{ from: {{ opacity: 0 }} }})]\nconst m = 1, {{ globalCss }} = Devup\nglobalCss({{ body: {{ margin: 0 }} }})\n" + )); + assert!(code.contains("
\n", + ); + assert!(code.contains("
fn\nexport const a = f(Devup.css)\n" + )); + assert!(error.contains("test.tsx:3:20:"), "{error}"); +} + +#[test] +#[serial] +fn namespace_read_whole_is_a_located_error() { + for code in [ + "export const key = 'css'\nexport const a = Devup[key]", + "Devup.css = 1", + "export const [a] = Devup", + "export const { ...rest } = Devup", + "const key = 'css'\nexport const { [key]: a } = Devup", + "export const { css = 1 } = Devup", + "export const { css: [a] } = Devup", + "export const { css } = Devup", + "const { css, stylex } = Devup\nexport const a = [css, stylex]", + ] { + let error = namespace_error(&format!("{NS}{code}\n")); + assert!( + error.contains("`Devup` cannot use `") && error.contains("at build time"), + "{code}: {error}" + ); + } +} + +#[test] +#[serial] +fn binding_reads_of_a_destructured_namespace_keep_their_meaning() { + for code in [ + "const { css } = Devup\nexport const a = { css }\n", + "const { css } = Devup\nexport { css }\n", + "const { css } = Devup\nexport { css as c }\n", + ] { + let error = namespace_error(&format!("{NS}{code}")); + assert!(error.contains("test.tsx:"), "{code}: {error}"); + } + let error = namespace_error(&format!("{NS}let {{ css }} = Devup\ncss = 1\n")); + assert!(error.contains("`Devup` cannot use `css`"), "{error}"); +} + +#[test] +#[serial] +fn namespace_without_a_resolver_or_package_text_is_unchanged() { + let output = extract( + "test.tsx", + "import * as Other from 'other'\nexport const a = Other.x\n", + ExtractOption::default(), + ) + .unwrap(); + assert_eq!(output.styles.len(), 0); +} + +#[test] +#[serial] +fn barrel_namespace_errors_name_the_member() { + let project = Project::new( + "barrel-namespace-errors", + &[ + ( + "ui.ts", + "export * from './missing'\nexport { Box } from '@devup-ui/react'\nexport const own = 1\n", + ), + ( + "member.tsx", + "import * as UI from './ui'\nexport const a = UI.nothing\nexport const b = UI.own\n", + ), + ( + "destructure.tsx", + "import * as UI from './ui'\nconst { nothing } = UI\nexport const a = nothing\n", + ), + ( + "mixed.tsx", + "import * as UI from './ui'\nconst { Box, own } = UI\nexport const a = [Box, own]\n", + ), + ], + ); + let error = project.error("member.tsx"); + assert!(error.contains("`UI.nothing` cannot use `./ui`"), "{error}"); + let error = project.error("destructure.tsx"); + assert!( + error.contains("`{ nothing } = UI` cannot use `./ui`"), + "{error}" + ); + let error = project.error("mixed.tsx"); + assert!( + error.contains("`UI` cannot use `const { Box, own } = UI`") + || error.contains("`UI` cannot use `{ Box, own } = UI`"), + "{error}" + ); +} + +#[test] +#[serial] +fn unfollowable_default_and_named_imports_report_each_use() { + let project = Project::new( + "unfollowable-default", + &[ + ( + "ui.ts", + "export { default } from './missing'\nexport { Box } from '@devup-ui/react'\n", + ), + ( + "app.tsx", + "import Page from './ui'\nexport const a = [Page, Page]\n", + ), + ], + ); + let error = project.error("app.tsx"); + assert!( + error.contains("app.tsx:2:19:") && error.contains("app.tsx:2:25:"), + "{error}" + ); +} + +#[test] +#[serial] +fn has_devup_ui_follows_barrels() { + let project = Project::new( + "has", + &[ + ("ui.ts", "export { Box } from '@devup-ui/react'\n"), + ("plain.ts", "export const x = 1\n"), + ], + ); + let resolver = project.resolver(); + let resolver: &ModuleResolver = &resolver; + let file = project.path("app.tsx"); + let gate = |file: &str, code: &str, resolver: &ModuleResolver| { + has_devup_ui_with( + file, + code, + "@devup-ui/react", + &HashMap::new(), + Some(resolver), + ) + }; + assert!(gate(&file, "import { Box } from './ui'", resolver)); + assert!(gate( + &file, + "import { Box } from '@devup-ui/react'", + resolver + )); + assert!(!gate(&file, "import { x } from './plain'", resolver)); + assert!(!gate(&file, "const a = 1", resolver)); + assert!(!gate("app.invalid", "import { Box } from './ui'", resolver)); + assert!(gate( + &file, + "import { Foo } from './unresolved'\nimport * as Devup from '@devup-ui/react'\nDevup.css", + resolver + )); +} + +#[test] +#[serial] +fn namespace_of_the_package_inside_a_barrel_namespace_is_followed() { + let project = Project::new( + "nested-namespace", + &[ + ( + "ui.ts", + "export * as Devup from '@devup-ui/react'\nexport const own = 1\n", + ), + ( + "app.tsx", + "import * as UI from './ui'\nexport const a = \nexport const b = UI.missing\n", + ), + ], + ); + let code = project.code("app.tsx"); + assert!(code.contains("
| { + has_devup_ui_with(file, code, "@devup-ui/react", aliases, None) + }; + assert!(gate( + "a.tsx", + "import styled from '@emotion/styled'\nexport const A = styled.div({})", + &aliases + )); + assert!(gate( + "a.tsx", + "/** @jsxImportSource @emotion/react */\nexport const a =
", + &aliases + )); + assert!(gate( + "a.tsx", + "import { css } from '@emotion/react'\nexport const a =
", + &aliases + )); + assert!(!gate( + "a.tsx", + "export const a =
", + &aliases + )); + assert!(!gate( + "a.tsx", + "import styled from '@emotion/styled'", + &HashMap::new() + )); + let jsx_runtime = HashMap::from([ + ( + "@emotion/react".to_string(), + crate::ImportAlias::NamedToNamed, + ), + ( + "@emotion/react/jsx-runtime".to_string(), + crate::ImportAlias::NamedToNamed, + ), + ]); + assert!(gate( + "a.tsx", + "export const a =
", + &jsx_runtime + )); +} + +#[test] +#[serial] +fn barrel_aliases_of_package_exports_are_followed_and_compile_themselves() { + let project = Project::new( + "alias-barrel", + &[ + ( + "ui.tsx", + "import { Box, css, Text } from '@devup-ui/react'\nimport * as Devup from '@devup-ui/react'\nexport const B = Box\nconst c = css\nexport { c as cc, Text }\nexport const D = Devup\nexport const C = Devup.css\nexport const helper = 1, F = Text\n", + ), + ( + "app.tsx", + "import { B, cc, Text, D, helper } from './ui'\nexport const a = \nexport const b = cc({ color: 'green' })\nexport const c = \nexport const d = helper\n", + ), + ( + "member.tsx", + "import { C } from './ui'\nexport const a = C({ color: 'red' })\n", + ), + ], + ); + let code = project.code("app.tsx"); + assert!(code.contains("
\nexport const b = D.css({{ color: 'blue' }})\n" + )); + assert!(code.contains("
]\n}\nexport const g = (css) => { const x = css; return x }\n", + ); + assert!(!code.contains("inner"), "{code}"); + assert!(!code.contains("again"), "{code}"); + assert!(code.contains("export const g = (css) => {"), "{code}"); + assert!(code.contains("const x = css"), "{code}"); +} + +#[test] +#[serial] +fn aliases_of_a_barrels_import_in_functions_compile() { + let project = Project::new( + "alias-nested", + &[ + ("ui.ts", "export { css } from '@devup-ui/react'\n"), + ( + "app.tsx", + "import { css } from './ui'\nexport function f() {\n const inner = css\n return inner({ color: 'red' })\n}\n", + ), + ], + ); + let code = project.code("app.tsx"); + assert!(!code.contains("inner"), "{code}"); +} + +#[test] +#[serial] +fn style_constants_read_in_functions_before_their_declaration_compile() { + let code = namespace_code( + "import { css, keyframes, Box } from '@devup-ui/react'\nexport function f() {\n return css({ animation: `${k} 1s` })\n}\nexport const g = () => \nexport const h = () => later\nconst k = keyframes({ from: { opacity: 0 }, to: { opacity: 1 } })\nconst c = css({ color: 'red' })\nconst later = 1\n", + ); + assert!(!code.contains("keyframes("), "{code}"); + assert!(!code.contains("${k}"), "{code}"); +} + +#[test] +#[serial] +fn style_constants_read_before_they_run_are_located_errors() { + let error = namespace_error( + "import { css, keyframes } from '@devup-ui/react'\nexport const a = css({ animation: `${k} 1s` })\nconst k = keyframes({ from: { opacity: 0 } })\n", + ); + assert!( + error.contains("test.tsx:2:") && error.contains("move that declaration above"), + "{error}" + ); +} + +#[test] +#[serial] +fn style_constants_computed_from_other_bindings_stay_where_they_are() { + let error = namespace_error( + "import { css, keyframes } from '@devup-ui/react'\nexport function f() { return css({ animation: `${k} 1s` }) }\nconst frames = { from: { opacity: 0 } }\nconst k = keyframes(frames)\n", + ); + assert!(error.contains("test.tsx:2:"), "{error}"); +} + +#[test] +#[serial] +fn aliases_that_are_not_package_exports_stay_declarations() { + let project = Project::new( + "alias-other", + &[ + ( + "ui.tsx", + "import { Box } from '@devup-ui/react'\nimport * as Devup from '@devup-ui/react'\nexport const N1 = Devup.css.name\nexport const N2 = Box.displayName\nexport const N3 = Devup.useTheme\nexport const N4 = Math.max\nexport const N5 = other.value\n", + ), + ( + "app.tsx", + "import { N1, N2, N3, N4, N5 } from './ui'\nexport const a = [N1, N2, N3, N4, N5]\n", + ), + ], + ); + let code = project.code("app.tsx"); + assert!(code.contains("N1"), "{code}"); + assert!(project.error("ui.tsx").contains("ui.tsx:3:")); +} diff --git a/libs/extractor/src/build_time_values.rs b/libs/extractor/src/build_time_values.rs index 1e8bdd7e3..64d93beaf 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( @@ -110,10 +111,10 @@ const GLOBALS: [&str; 18] = [ /// The members of `Math` every engine gives exactly; the others are /// approximations that may differ in their last digits -const EXACT_MATH: [&str; 20] = [ - "abs", "ceil", "floor", "round", "trunc", "sign", "max", "min", "sqrt", "fround", "imul", - "clz32", "PI", "E", "LN2", "LN10", "LOG2E", "LOG10E", "SQRT2", "SQRT1_2", -]; +pub(crate) mod exact_math; +#[cfg(test)] +mod w22_tests; +use exact_math::EXACT_MATH; /// Members giving what the locale, the Unicode data of the engine or chance /// make them, and `toString`, which engines only approximate with a radix: @@ -344,7 +345,10 @@ impl<'s, 'a> Finder<'s, 'a> { fn is_css(&self, callee: &Expression<'_>) -> bool { if let Expression::StaticMemberExpression(member) = callee { - return member.property.name == "css"; + return member.property.name == "css" + && self + .symbol(&member.object) + .is_some_and(|symbol| self.namespaces.contains(&symbol)); } self.symbol(callee) .is_some_and(|symbol| self.css.contains(&symbol)) @@ -1092,3 +1096,6 @@ fn compute( } (!computed.is_empty()).then_some((computed, changes.dependencies())) } + +#[cfg(test)] +mod scope_tests; diff --git a/libs/extractor/src/build_time_values/exact_math.rs b/libs/extractor/src/build_time_values/exact_math.rs new file mode 100644 index 000000000..7605d8409 --- /dev/null +++ b/libs/extractor/src/build_time_values/exact_math.rs @@ -0,0 +1,50 @@ +use boa_engine::{Context, Source}; + +/// Math members whose results the local safety gate permits across engines. +pub(crate) const EXACT_MATH: [&str; 20] = [ + "abs", "ceil", "floor", "round", "trunc", "sign", "max", "min", "sqrt", "fround", "imul", + "clz32", "PI", "E", "LN2", "LN10", "LOG2E", "LOG10E", "SQRT2", "SQRT1_2", +]; + +pub(crate) enum Operand<'a> { + Number(f64), + String(&'a str), + Bool(bool), + Null, + Undefined, +} + +impl Operand<'_> { + fn literal(&self) -> Option { + match self { + Self::Number(number) if *number == 0.0 && number.is_sign_negative() => { + Some("-0".to_string()) + } + Self::Number(number) => Some(crate::utils::js_number_string(*number)), + Self::String(text) => serde_json::to_string(text).ok(), + Self::Bool(value) => Some(value.to_string()), + Self::Null => Some("null".to_string()), + Self::Undefined => Some("undefined".to_string()), + } + } +} + +/// Evaluate only a trusted exact member over already resolved primitive operands. +/// No source expressions, user functions or module code reach Boa here. +pub(crate) fn evaluate(name: &str, arguments: Option<&[Operand<'_>]>) -> Option { + if !EXACT_MATH.contains(&name) { + return None; + } + let expression = match arguments { + None => format!("Math.{name}"), + Some(arguments) => { + let arguments: Option> = arguments.iter().map(Operand::literal).collect(); + format!("Math.{name}({})", arguments?.join(",")) + } + }; + let value = Context::default() + .eval(Source::from_bytes(expression.as_bytes())) + .ok()? + .as_number()?; + value.is_finite().then_some(value) +} diff --git a/libs/extractor/src/build_time_values/scope_tests.rs b/libs/extractor/src/build_time_values/scope_tests.rs new file mode 100644 index 000000000..310d45aac --- /dev/null +++ b/libs/extractor/src/build_time_values/scope_tests.rs @@ -0,0 +1,33 @@ +use super::has_build_time_values; +use crate::ExtractOption; + +#[test] +fn a_local_named_like_a_style_api_computes_nothing() { + let option = ExtractOption::default(); + let prelude = "import { css, Box } from '@devup-ui/react'; +import * as ui from '@devup-ui/react'; +const double = (n) => n * 2; +"; + for code in [ + "export const a = css({ w: double(2) });", + "export const a = ui.css({ w: double(2) });", + "export const a = ;", + "export const a = ;", + ] { + assert!( + has_build_time_values("a.tsx", &format!("{prelude}{code}"), &option, None), + "{code}" + ); + } + for code in [ + "export function f(css) { return css({ w: double(2) }); }", + "export function f(ui) { return ui.css({ w: double(2) }); }", + "export function f(Box) { return ; }", + "export function f(ui) { return ; }", + ] { + assert!( + !has_build_time_values("a.tsx", &format!("{prelude}{code}"), &option, None), + "{code}" + ); + } +} diff --git a/libs/extractor/src/build_time_values/w22_tests.rs b/libs/extractor/src/build_time_values/w22_tests.rs new file mode 100644 index 000000000..cad62b9e9 --- /dev/null +++ b/libs/extractor/src/build_time_values/w22_tests.rs @@ -0,0 +1,43 @@ +use super::*; +use rstest::rstest; + +#[rstest] +#[case( + "import {css} from '@devup-ui/react';function make(n){return n+'px';}export const a=css({w:make(2)});", + "\"2px\"" +)] +#[case( + "import {css} from '@devup-ui/react';const n=2;export const a=css({w:(n+3)+'px'});", + "\"5px\"" +)] +#[case( + "import {css} from '@devup-ui/react';const theme={color:'red'};export const a=css({color:theme.color});", + "\"red\"" +)] +fn original_build_time_evaluation_when_the_dependency_closure_is_exact_replaces_values( + #[case] source: &str, + #[case] expected: &str, +) { + let actual = evaluate( + source, + "a.tsx", + &crate::ExtractOption::default(), + None, + &crate::imported_constants::Unknown::default(), + ) + .unwrap_or_else(|| panic!("{source}")); + assert!(actual.0.contains(expected), "{}", actual.0); + assert_ne!(actual.1.len(), 0); +} + +#[test] +fn exact_math_when_resolved_numeric_operands_are_used_preserves_the_result() { + let result = exact_math::evaluate( + "imul", + Some(&[ + exact_math::Operand::Number(2.0), + exact_math::Operand::Number(3.0), + ]), + ); + assert_eq!(result, Some(6.0)); +} 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..2f59dd1d2 --- /dev/null +++ b/libs/extractor/src/css_prop.rs @@ -0,0 +1,1067 @@ +//! 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, IdentifierReference, 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_semantic::Scoping; +use oxc_span::SPAN; +use oxc_syntax::symbol::SymbolId; +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> { + css_prop: CssProp, + scoping: &'s Scoping, + /// The imported functions building elements from a type and props + jsx: FxHashSet, + /// The imported `ClassNames` of Emotion + class_names: FxHashSet, +} + +/// The binding `identifier` reads; `None` for a global +pub(crate) fn binding_of( + scoping: &Scoping, + identifier: &IdentifierReference<'_>, +) -> Option { + scoping + .get_reference(identifier.reference_id.get()?) + .symbol_id() +} + +/// Whether `identifier` reads a binding of the module's top level or a global, +/// and not a local of a function or block, which may share the name of one +pub(crate) fn reads_top_level(scoping: &Scoping, identifier: &IdentifierReference<'_>) -> bool { + binding_of(scoping, identifier) + .is_none_or(|symbol| scoping.symbol_scope_id(symbol) == scoping.root_scope_id()) +} + +/// The identifier `a`, `a.b` and `a[b]` start at +pub(crate) fn root_reference<'e, 'a>( + expression: &'e Expression<'a>, +) -> Option<&'e IdentifierReference<'a>> { + match expression { + Expression::Identifier(identifier) => Some(identifier), + Expression::StaticMemberExpression(member) => root_reference(&member.object), + Expression::ComputedMemberExpression(member) => root_reference(&member.object), + _ => None, + } +} + +impl<'s> CssTakers<'s> { + /// The elements `program` gives a `css` prop, as `css_prop` tells, with + /// the bindings `scoping` resolved; `compat` is the entry absorbing + /// Emotion's own `jsx` + pub(crate) fn new( + program: &Program<'_>, + scoping: &'s Scoping, + 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.extend(specifier.local.symbol_id.get()); + } else if imported == "ClassNames" + && (source == compat || source == EMOTION_REACT) + { + class_names.extend(specifier.local.symbol_id.get()); + } + } + } + } + Self { + css_prop, + scoping, + jsx, + class_names, + } + } + + /// The `css` and `cx` bindings a `` child function `element` + /// gives takes; empty for any other element, and for a `ClassNames` a + /// local of the same name stands for + pub(crate) fn class_names_calls(&self, element: &JSXElement<'_>) -> Vec { + match &element.opening_element.name { + JSXElementName::IdentifierReference(name) + if binding_of(self.scoping, name) + .is_some_and(|symbol| self.class_names.contains(&symbol)) => + { + class_names_calls(element) + } + _ => Vec::new(), + } + } + + /// Whether `callee` calls one of the `taken` bindings of `` + /// child functions + pub(crate) fn calls_class_names(&self, taken: &[SymbolId], callee: &Expression<'_>) -> bool { + matches!(callee, Expression::Identifier(callee) + if binding_of(self.scoping, callee).is_some_and(|symbol| taken.contains(&symbol))) + } + + /// Whether the element `name` takes the `css` prop; `devup` tells a + /// reference to a Devup UI component + pub(crate) fn takes( + &self, + name: &JSXElementName<'_>, + devup: impl Fn(&IdentifierReference<'_>) -> bool, + ) -> bool { + let root = crate::imported_constants::jsx_root_identifier(name); + self.css_prop.takes(name, |_| root.is_some_and(&devup)) + } + + /// Whether `attribute` of the element `name` is a `css` prop it takes; + /// `devup` tells a reference to a Devup UI component + pub(crate) fn attribute( + &self, + name: &JSXElementName<'_>, + attribute: &JSXAttributeItem<'_>, + devup: impl Fn(&IdentifierReference<'_>) -> bool, + ) -> bool { + matches!(attribute, JSXAttributeItem::Attribute(attribute) + if attribute.name.as_identifier().is_some_and(|attribute| attribute.name == "css")) + && self.takes(name, devup) + } + + /// Where among the props a `jsx()` call gives is the `css` prop the + /// element takes; `devup` tells a reference to a Devup UI component + pub(crate) fn property( + &self, + call: &CallExpression<'_>, + devup: impl Fn(&IdentifierReference<'_>) -> bool, + ) -> Option { + let Expression::Identifier(callee) = &call.callee else { + return None; + }; + let [element, Argument::ObjectExpression(props), ..] = call.arguments.as_slice() else { + return None; + }; + let element = element.as_expression()?; + let root = root_reference(unwrap_syntax_only(element)); + (binding_of(self.scoping, callee).is_some_and(|symbol| self.jsx.contains(&symbol)) + && self + .css_prop + .takes_type(element, |_| root.is_some_and(&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"; + +/// The binding a read stands for, `None` for a global +pub(crate) type Resolve<'r> = &'r dyn Fn(&IdentifierReference<'_>) -> Option; + +/// [`theme_rules_of`] for code no semantic analysis has read, whose names bind +/// nothing +#[cfg(test)] +pub(crate) fn theme_rules<'a>( + ast: &AstBuilder<'a>, + function: &Expression<'a>, +) -> Result, (Expression<'a>, &'static str)> { + theme_rules_of(ast, function, &|_| None) +} + +/// `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: only +/// the reads of the binding its parameter declares, as `resolve` tells them. +/// `Err` holds the code the build cannot write so, with what it requires. +pub(crate) fn theme_rules_of<'a>( + ast: &AstBuilder<'a>, + function: &Expression<'a>, + resolve: Resolve<'_>, +) -> 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, + resolve, + 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` and the +/// theme by +#[derive(Default, Clone, Copy)] +pub(crate) struct ClassNamesParams<'a> { + pub css: Option<&'a str>, + pub theme: Option<&'a str>, +} + +/// A binding a `` child function declares for what it takes +#[derive(Clone, Copy)] +struct Local<'a> { + name: &'a str, + symbol: Option, +} + +#[derive(Default, Clone, Copy)] +struct ClassNamesLocals<'a> { + css: Option>, + cx: Option>, + theme: Option>, +} + +/// 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 locals = class_names_locals(params)?; + Some(ClassNamesParams { + css: locals.css.map(|local| local.name), + theme: locals.theme.map(|local| local.name), + }) +} + +fn class_names_locals<'a>(params: &FormalParameters<'a>) -> Option> { + let mut names = ClassNamesLocals::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: local.name.as_str(), + symbol: local.symbol_id.get(), + }); + } + } + _ => return None, + } + Some(names) +} + +/// The parameters of the child function of the `` element `element` +fn class_names_function_params<'b, 'a>( + element: &'b JSXElement<'a>, +) -> Option<&'b FormalParameters<'a>> { + 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; + }; + match container.expression.as_expression()? { + Expression::ArrowFunctionExpression(arrow) => Some(&arrow.params), + Expression::FunctionExpression(function) => Some(&function.params), + _ => None, + } +} + +/// What the child function of the `` element `element` takes +pub(crate) fn class_names_child<'a>(element: &JSXElement<'a>) -> Option> { + class_names_function_params(element).and_then(class_names_params) +} + +/// The bindings the child function of the `` element `element` +/// takes `css` and `cx` by, which read styles +fn class_names_calls(element: &JSXElement<'_>) -> Vec { + class_names_function_params(element) + .and_then(class_names_locals) + .map(|names| { + [names.css, names.cx] + .into_iter() + .flatten() + .filter_map(|local| local.symbol) + .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: the reads +/// `resolve` tells stand for the binding, not those of a local sharing its +/// name. `Err` holds the first read the build cannot write so. +pub(crate) fn read_theme<'a>( + ast: &AstBuilder<'a>, + expression: &mut Expression<'a>, + theme: ThemeRoot<'a>, + value: bool, + resolve: Resolve<'_>, +) -> Result<(), Expression<'a>> { + let roots = [theme]; + let mut reads = ThemeReads { + ast, + roots: &roots, + resolve, + unread: None, + }; + reads.visit(expression, value); + reads.unread.map_or(Ok(()), Err) +} + +/// A binding a function of the theme reads it by, with the key of the theme it +/// holds when destructured +pub(crate) struct ThemeRoot<'a> { + pub name: &'a str, + pub symbol: Option, + pub key: Option, +} + +/// The bindings a function of the theme reads it by; `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![ThemeRoot { + name: identifier.name.as_str(), + symbol: identifier.symbol_id.get(), + key: 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(ThemeRoot { + name: local.name.as_str(), + symbol: local.symbol_id.get(), + key: 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 [ThemeRoot<'a>], + resolve: Resolve<'r>, + 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 symbol = (self.resolve)(identifier); + let root = self.roots.iter().find(|root| { + root.name == identifier.name.as_str() && root.symbol == symbol + })?; + path.extend(root.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;"); + } +} + +#[cfg(test)] +mod scope_tests; diff --git a/libs/extractor/src/css_prop/scope_tests.rs b/libs/extractor/src/css_prop/scope_tests.rs new file mode 100644 index 000000000..cdfbb56e7 --- /dev/null +++ b/libs/extractor/src/css_prop/scope_tests.rs @@ -0,0 +1,94 @@ +use oxc_allocator::Allocator; +use oxc_ast::ast::{Expression, IdentifierReference}; +use oxc_ast_visit::Visit; +use oxc_parser::Parser; +use oxc_semantic::{Scoping, SemanticBuilder}; +use oxc_span::SourceType; + +use super::{binding_of, reads_top_level, root_reference}; + +struct Reads<'s> { + scoping: &'s Scoping, + found: Vec<(String, bool, bool)>, +} + +impl<'a> Visit<'a> for Reads<'_> { + fn visit_identifier_reference(&mut self, identifier: &IdentifierReference<'a>) { + self.found.push(( + identifier.name.to_string(), + binding_of(self.scoping, identifier).is_some(), + reads_top_level(self.scoping, identifier), + )); + } +} + +#[test] +fn references_tell_locals_from_top_level_bindings_and_globals() { + let allocator = Allocator::default(); + let program = Parser::new( + &allocator, + "const top = 1; function f(local) { return [top, local, global]; }", + SourceType::tsx(), + ) + .parse() + .program; + let scoping = SemanticBuilder::new() + .build(&program) + .semantic + .into_scoping(); + let mut reads = Reads { + scoping: &scoping, + found: Vec::new(), + }; + reads.visit_program(&program); + assert_eq!( + reads.found, + vec![ + ("top".to_string(), true, true), + ("local".to_string(), true, false), + ("global".to_string(), false, true), + ] + ); +} + +#[test] +fn a_reference_without_semantic_data_reads_a_global() { + let allocator = Allocator::default(); + let program = Parser::new(&allocator, "", SourceType::tsx()) + .parse() + .program; + let scoping = SemanticBuilder::new() + .build(&program) + .semantic + .into_scoping(); + let Ok(Expression::Identifier(identifier)) = + Parser::new(&allocator, "a", SourceType::tsx()).parse_expression() + else { + panic!("a"); + }; + assert_eq!(binding_of(&scoping, &identifier), None); + assert!(reads_top_level(&scoping, &identifier)); +} + +#[test] +fn the_root_of_a_member_chain_is_the_identifier_it_starts_at() { + let allocator = Allocator::default(); + for (code, root) in [ + ("a", Some("a")), + ("a.b", Some("a")), + ("a[b]", Some("a")), + ("a.b[c].d", Some("a")), + ("a()", None), + ("'a'", None), + ] { + let Ok(expression) = Parser::new(&allocator, code, SourceType::tsx()).parse_expression() + else { + panic!("{code}"); + }; + assert_eq!( + root_reference(&expression).map(|identifier| identifier.name.as_str()), + root, + "{code}" + ); + } +} diff --git a/libs/extractor/src/css_utils.rs b/libs/extractor/src/css_utils.rs index f0d67200b..ab3239a64 100644 --- a/libs/extractor/src/css_utils.rs +++ b/libs/extractor/src/css_utils.rs @@ -146,14 +146,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([';', '{', '}']); @@ -587,19 +587,41 @@ fn nest_prelude(parent: Option<&StyleSelector>, prelude: &str) -> Option { - Some(selector) + Some(StyleSelector::nest_selector(parent, &descendants(prelude))) +} + +/// Each selector of the list `selectors` written without `&` as a descendant +/// of it, as stylis nests one +pub(crate) fn descendants(selectors: &str) -> String { + let mut depth = 0usize; + let mut from = 0; + let mut parts = Vec::new(); + for (index, c) in selectors.char_indices() { + match c { + '(' | '[' => depth += 1, + ')' | ']' => depth = depth.saturating_sub(1), + ',' if depth == 0 => { + parts.push(&selectors[from..index]); + from = index + 1; + } + _ => {} } - Some(StyleSelector::At { selector, .. }) => selector.as_ref(), - None => None, - }; - let template = if prelude.contains('&') || parent_selector.is_none() { - Cow::Borrowed(prelude) - } else { - Cow::Owned(format!("& {prelude}")) - }; - Some(StyleSelector::nest_selector(parent, &template)) + } + parts.push(&selectors[from..]); + parts + .iter() + .map(|part| { + let part = part.trim(); + if part.contains('&') { + part.to_string() + } else if part.starts_with(':') { + format!("&{part}") + } else { + format!("& {part}") + } + }) + .collect::>() + .join(",") } /// Optimize a declaration's value only when its property warrants multi-value @@ -1171,7 +1193,7 @@ mod tests { #[case( "`ul { font-family: 'Roboto Hello', sans-serif; }`", vec![ - ("font-family", "\"Roboto Hello\",sans-serif", Some(StyleSelector::Selector("ul".to_string()))), + ("font-family", "\"Roboto Hello\",sans-serif", Some(StyleSelector::Selector("& ul".to_string()))), ] )] #[case( @@ -1610,14 +1632,23 @@ mod tests { #[case( "ul { font-family: 'Roboto Hello', sans-serif; }", vec![ - ("font-family", "\"Roboto Hello\",sans-serif", Some(StyleSelector::Selector("ul".to_string()))), + ("font-family", "\"Roboto Hello\",sans-serif", Some(StyleSelector::Selector("& ul".to_string()))), ] )] #[case( "div { color: red; ; { background: blue; } }", vec![ - ("color", "red", Some(StyleSelector::Selector("div".to_string()))), - ("background", "blue", Some(StyleSelector::Selector("div".to_string()))), + ("color", "red", Some(StyleSelector::Selector("& div".to_string()))), + ("background", "blue", Some(StyleSelector::Selector("& div".to_string()))), + ] + )] + // A selector written without `&` selects within the element, each one of + // a list on its own, and a pseudo-class applies to the element itself + #[case( + "a, :is(b, c) d { color: red; } :hover { color: blue; }", + vec![ + ("color", "red", Some(StyleSelector::Selector("& a,&:is(b,c) d".to_string()))), + ("color", "blue", Some(StyleSelector::Selector("&:hover".to_string()))), ] )] // As in CSS nesting, only the text after the last `;` is the nested rule's @@ -1626,7 +1657,7 @@ mod tests { "color:red;background:blue { width: 1px; }", vec![ ("color", "red", None), - ("width", "1px", Some(StyleSelector::Selector("background:blue".to_string()))), + ("width", "1px", Some(StyleSelector::Selector("& background:blue".to_string()))), ] )] #[case( @@ -1634,7 +1665,7 @@ mod tests { vec![( "width", "1px", - Some(StyleSelector::Selector("color:red".to_string())) + Some(StyleSelector::Selector("& color:red".to_string())) )] )] #[case( diff --git a/libs/extractor/src/extract_style/extract_dynamic_style.rs b/libs/extractor/src/extract_style/extract_dynamic_style.rs index f09b03b07..382d05d8c 100644 --- a/libs/extractor/src/extract_style/extract_dynamic_style.rs +++ b/libs/extractor/src/extract_style/extract_dynamic_style.rs @@ -5,7 +5,17 @@ use css::{ style_selector::{StyleSelector, optimize_selector}, }; -use crate::extract_style::{ExtractStyleProperty, style_property::StyleProperty}; +use crate::extract_style::{ + ExtractStyleProperty, extract_static_style::ExtractStaticStyle, style_property::StyleProperty, +}; + +/// The variable an element sets to override a style it writes before a spread +/// the build cannot read, and the value the style keeps while it is unset +#[derive(PartialEq, Clone, Eq, Hash, Ord, PartialOrd, Debug)] +struct Override { + variable: String, + fallback: String, +} #[derive(PartialEq, Clone, Eq, Hash, Ord, PartialOrd)] pub struct ExtractDynamicStyle { @@ -24,6 +34,9 @@ pub struct ExtractDynamicStyle { important: bool, pub(crate) layer: Option, + + /// Set when the style is a static value a runtime spread may override + overridable: Option, } impl Debug for ExtractDynamicStyle { @@ -40,6 +53,9 @@ impl Debug for ExtractDynamicStyle { if let Some(layer) = &self.layer { s.field("layer", layer); } + if let Some(overridable) = &self.overridable { + s.field("overridable", overridable); + } s.finish() } } @@ -99,6 +115,19 @@ fn runtime_code(identifier: &str) -> String { } } +/// `key` as the characters of a variable name, each other character by its code +fn escaped(key: &str) -> String { + key.chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' { + c.to_string() + } else { + format!("_u{:04x}_", u32::from(c)) + } + }) + .collect() +} + impl ExtractDynamicStyle { /// create a new `ExtractDynamicStyle` pub fn new( @@ -118,9 +147,48 @@ impl ExtractDynamicStyle { style_order: None, important, layer: None, + overridable: None, } } + /// `style` as a stylesheet rule that gives way to `identifier`, the code + /// an element sets on a variable only the prop `key` it is written as + /// reads, so every breakpoint of that prop shares it and no other prop + /// setting the same property can reach it + pub fn overridable(style: &ExtractStaticStyle, identifier: &str, key: &str) -> Self { + let (fallback, important) = strip_important(style.value.clone()); + Self { + property: style.property.clone(), + level: style.level, + identifier: identifier.to_string(), + selector: None, + style_order: style.style_order, + important, + layer: style.layer.clone(), + overridable: Some(Override { + variable: sheet_to_variable_name( + &format!("{}-spread-{}", style.property, escaped(key)), + 0, + None, + ), + fallback, + }), + } + } + + /// Give way to what `read` reads over the value set now, as a spread + /// written after it replaces it: `read` is given that value + pub fn overridden_by(&mut self, read: impl FnOnce(&str) -> String) { + self.identifier = read(&self.identifier); + } + + /// The value `var()` falls back to while no override is set + pub fn fallback(&self) -> Option<&str> { + self.overridable + .as_ref() + .map(|overridable| overridable.fallback.as_str()) + } + pub const fn property(&self) -> &str { self.property.as_str() } @@ -153,19 +221,27 @@ impl ExtractDynamicStyle { impl ExtractStyleProperty for ExtractDynamicStyle { fn extract(&self, filename: Option<&str>) -> StyleProperty { let selector = super::class_selector(self.selector.as_ref(), self.layer()); + // What the rule falls back to is part of the class, as another + // fallback is another rule + let rule = self.overridable.as_ref().map(|overridable| { + let important = if self.important { " !important" } else { "" }; + format!( + "var({},{}){important}", + overridable.variable, overridable.fallback + ) + }); StyleProperty::Variable { class_name: sheet_to_classname( self.property.as_str(), self.level, - None, + rule.as_deref(), selector.as_deref(), self.style_order, filename, ), - variable_name: sheet_to_variable_name( - self.property.as_str(), - self.level, - selector.as_deref(), + variable_name: self.overridable.as_ref().map_or_else( + || sheet_to_variable_name(self.property.as_str(), self.level, selector.as_deref()), + |overridable| overridable.variable.clone(), ), identifier: self.identifier.clone(), } @@ -187,6 +263,12 @@ mod tests { assert!(!style.important()); } + #[test] + fn test_escaped_keeps_names_and_codes_every_other_character() { + assert_eq!(escaped("background-color"), "background-color"); + assert_eq!(escaped("a_b c"), "a_u005f_b_u0020_c"); + } + #[test] fn test_strip_important_plain() { let (id, important) = strip_important("color".to_string()); @@ -248,4 +330,11 @@ mod tests { assert_eq!(style.identifier(), "`${color}`"); assert!(style.important()); } + + #[test] + fn runtime_template_when_css_ends_in_a_semicolon_keeps_valid_javascript() { + let style = ExtractDynamicStyle::new("color", 0, " `${color};`; ", None); + assert_eq!(style.identifier(), "`${color}`"); + assert!(!style.important()); + } } diff --git a/libs/extractor/src/extractor/extract_style_from_expression.rs b/libs/extractor/src/extractor/extract_style_from_expression.rs index 4b8f7f27b..4e372ff90 100644 --- a/libs/extractor/src/extractor/extract_style_from_expression.rs +++ b/libs/extractor/src/extractor/extract_style_from_expression.rs @@ -37,6 +37,9 @@ use std::collections::BTreeMap; const IGNORED_IDENTIFIERS: [&str; 3] = ["undefined", "NaN", "Infinity"]; +#[cfg(test)] +mod direct_tests; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LiteralHandling { ExpandResponsiveThemeToken, @@ -1257,9 +1260,10 @@ fn nest_underscore_name(parent: Option<&StyleSelector>, name: &str) -> Option li'`) rather +/// than CSS properties. fn is_nested_selector_key(key: &str) -> bool { - key.starts_with(':') || key.contains('&') + key.starts_with([':', '.', '#', '[', '*', '>', '+', '~']) || key.contains('&') } fn nest_selectors_key(parent: Option<&StyleSelector>, key: &str) -> Option { diff --git a/libs/extractor/src/extractor/extract_style_from_expression/direct_tests.rs b/libs/extractor/src/extractor/extract_style_from_expression/direct_tests.rs new file mode 100644 index 000000000..400308e81 --- /dev/null +++ b/libs/extractor/src/extractor/extract_style_from_expression/direct_tests.rs @@ -0,0 +1,130 @@ +use super::*; +use oxc_allocator::Allocator; +use oxc_ast::ast::Statement; +use oxc_parser::Parser; +use oxc_span::SourceType; +use rstest::rstest; + +fn styles(source: &str, name: Option<&str>) -> (Vec, Vec) { + let allocator = Allocator::default(); + let ast = AstBuilder::new(&allocator); + let mut parsed = Parser::new(&allocator, source, SourceType::tsx()).parse(); + assert_eq!(parsed.diagnostics.len(), 0, "{source}"); + let Statement::ExpressionStatement(statement) = &mut parsed.program.body[0] else { + panic!("expression required") + }; + let result = extract_style_from_expression( + &ast, + name, + &mut statement.expression, + 0, + &name.is_none().then(|| StyleSelector::from("hover")), + LiteralHandling::ExpandResponsiveThemeToken, + ); + let errors = result + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleProp::Unreadable { code, .. } => Some(code.clone()), + _ => None, + }) + .collect(); + ( + result + .styles + .into_iter() + .flat_map(ExtractStyleProp::into_extract) + .collect(), + errors, + ) +} + +#[rstest] +#[case("['red','blue'][-1]", &[])] +#[case("['red','blue'][1]", &["blue"])] +#[case("['red','blue'][0.5]", &[])] +#[case("['red',,'blue'][1]", &[])] +#[case("({a:'red',a:'blue'})['a']", &["blue"])] +#[case("({a:'red'})['missing']", &[])] +#[case("({a:'red',...rest,a:'green'})['a']", &["green"])] +#[case("null ?? 'red'", &["red"])] +fn literal_selection_when_the_selected_entry_is_known( + #[case] source: &str, + #[case] expected: &[&str], +) { + let (actual, errors) = styles(source, Some("color")); + let values: Vec<_> = actual + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Static(style) => Some(style.value()), + _ => None, + }) + .collect(); + assert_eq!(values, expected, "{source}"); + assert_eq!(errors, Vec::::new()); +} + +#[rstest] +#[case("['red',...rest][1]", "const rest = ['blue'];")] +#[case("({a:'red',...rest})['a']", "const rest = {a:'blue'};")] +fn literal_selection_when_a_spread_can_replace_the_entry_is_dynamic( + #[case] source: &str, + #[case] setup: &str, +) { + let (actual, errors) = styles(source, Some("color")); + let [ExtractStyleValue::Dynamic(style)] = actual.as_slice() else { + panic!("dynamic style required: {actual:?}") + }; + assert_eq!(evaluate(style.identifier(), setup), "blue"); + assert_eq!(errors, Vec::::new()); +} + +#[test] +fn array_selection_when_the_key_is_runtime_keeps_known_prefix_and_spread() { + let (actual, errors) = styles("['red',...rest][key]", Some("color")); + assert_eq!(actual.len(), 2); + assert!(actual.iter().any(|style| matches!(style, + ExtractStyleValue::Static(style) if style.value() == "red"))); + assert!(actual.iter().any(|style| matches!(style, + ExtractStyleValue::Dynamic(style) if style.identifier().contains("...rest") && style.identifier().contains("key")))); + assert_eq!(errors, Vec::::new()); +} + +#[rstest] +#[case("`${runtime}`")] +#[case("source[key]")] +fn selector_selection_when_the_whole_shape_is_opaque_is_unreadable(#[case] source: &str) { + let (actual, errors) = styles(source, None); + assert_eq!(actual, vec![]); + assert_eq!(errors.len(), 1); + assert!(errors[0].contains(source)); +} + +#[test] +fn sequence_when_written_into_a_dynamic_style_keeps_its_parentheses() { + let (actual, errors) = styles("(first(), second())", Some("color")); + let [ExtractStyleValue::Dynamic(style)] = actual.as_slice() else { + panic!("dynamic style required: {actual:?}") + }; + assert_eq!( + evaluate( + style.identifier(), + "let trace=''; const first=()=>{trace+='1';}; const second=()=>{trace+='2';return trace;};" + ), + "12" + ); + assert_eq!(errors, Vec::::new()); +} + +fn evaluate(expression: &str, setup: &str) -> String { + let script = + format!("(()=>{{{setup} const style={{value:{expression}}}; return style.value;}})()"); + let mut context = boa_engine::Context::default(); + let value = context + .eval(boa_engine::Source::from_bytes(script.as_bytes())) + .unwrap_or_else(|error| panic!("{error}: {script}")); + value + .to_string(&mut context) + .unwrap_or_else(|error| panic!("{error}")) + .to_std_string_escaped() +} diff --git a/libs/extractor/src/extractor/extract_style_from_styled.rs b/libs/extractor/src/extractor/extract_style_from_styled.rs index 425ed0245..c7d0bf11f 100644 --- a/libs/extractor/src/extractor/extract_style_from_styled.rs +++ b/libs/extractor/src/extractor/extract_style_from_styled.rs @@ -1,5 +1,3 @@ -use rustc_hash::FxHashMap; - use crate::{ ExtractStyleProp, component::ExportVariableKind, @@ -11,6 +9,7 @@ use crate::{ }, 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, @@ -34,6 +33,9 @@ use oxc_syntax::operator::{BinaryOperator, LogicalOperator, UnaryOperator}; /// only the runtime gives that component const STYLED_BASE: &str = "DevupStyled"; +/// The Devup UI component an expression reads, by the bindings of the file +pub type Kinds<'k> = &'k dyn Fn(&Expression<'_>) -> Option; + const STYLED_FACTORY: &str = "it renders a tag, a component or a value naming one, with rule objects or CSS text"; @@ -46,7 +48,303 @@ struct Base<'a> { bound: Option>, } +/// A styled component the file defines, which a component extending it +/// renders in its place: what it renders, its classes and styles, and its +/// attrs, as the extension applies them before its own +pub struct StyledDefinition<'a> { + name: String, + bound: Option>, + classes: Vec>, + styles: Vec>, + attrs: Vec>, + /// The props its styles and attrs read + reads: Reads, + /// Its `shouldForwardProp`, as the build evaluates it + forward: Option, + /// The class other styles select it by, which a component extending it + /// gives too, as it renders this one + marker: Option, +} + +/// `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 class other styles select it by +#[derive(Clone, Copy, Default)] +pub struct Naming<'s> { + pub split_filename: Option<&'s str>, + pub marker: Option<&'s str>, +} + +/// 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), + } +} + +impl StyledDefinition<'_> { + /// Whether the styles and attrs of this component read the prop `name` + #[must_use] + pub fn reads_prop(&self, name: &str) -> bool { + self.reads.whole || self.reads.names.iter().any(|read| read == name) + } + + /// 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_prop(name) + || matches!( + name, + "className" | "style" | "as" | "forwardedAs" | "key" | "ref" | "children" + ) + || crate::styled_reads::passes( + name, + renders_tag(&self.name, self.bound.as_ref()), + self.forward.as_ref(), + ) + } + + /// Whether an extension can render what this definition renders: a value + /// only the runtime gives is read again only when it is a binding + #[must_use] + pub fn extendable(&self) -> bool { + self.bound + .as_ref() + .is_none_or(|bound| matches!(bound, Expression::Identifier(_))) + } +} + +impl<'a> StyledDefinition<'a> { + /// The tag an element using this component can render in its place, with + /// the styles the component gives it: a tag, with no attrs, no props read + /// and no value only the runtime gives + #[must_use] + pub fn inline(&self) -> Option<(&str, &[ExtractStyleProp<'a>])> { + (renders_tag(&self.name, self.bound.as_ref()) + && self.attrs.is_empty() + && self.reads == Reads::default() + && self.classes.is_empty() + && self.styles.iter().all(fixed)) + .then_some((self.name.as_str(), self.styles.as_slice())) + } + + /// The class other styles select the component by + #[must_use] + pub fn marker(&self) -> Option<&str> { + self.marker.as_deref() + } + + /// The styles the component gives what it renders + #[must_use] + pub fn styles(&self) -> &[ExtractStyleProp<'a>] { + &self.styles + } +} + +/// Whether `prop` holds only values the build knows +fn fixed(prop: &ExtractStyleProp<'_>) -> bool { + match prop { + ExtractStyleProp::Static(value) => !matches!(value, ExtractStyleValue::Dynamic(_)), + ExtractStyleProp::StaticArray(props) => props.iter().all(fixed), + _ => false, + } +} + +/// `Component.withComponent(target)`: the styles and attrs of `definition` +/// rendering `target`, a tag name or a component JSX can name; `None` for any +/// other target +pub fn with_component<'a>( + ast_builder: &AstBuilder<'a>, + definition: &StyledDefinition<'a>, + target: &Expression<'a>, + split_filename: Option<&str>, +) -> Option<(Expression<'a>, StyledDefinition<'a>)> { + let name = match unwrap_syntax_only(target) { + Expression::StringLiteral(literal) => literal.value.to_string(), + target => jsx_name(target)?, + }; + let base = Base::named(name); + let allocator = ast_builder.allocator(); + let mut styles: Vec> = definition + .styles + .iter() + .map(|style| style.clone_in(allocator)) + .collect(); + let classes: Vec> = clone_all(ast_builder, &definition.classes).collect(); + let class_name = merge_expression_for_class_name( + ast_builder, + clone_all(ast_builder, &classes).chain(gen_class_names( + ast_builder, + &mut styles, + None, + split_filename, + )), + ); + let withheld = withheld( + &definition.reads, + renders_tag(&base.name, None), + definition.forward.as_ref(), + ); + let component = create_styled_component( + ast_builder, + &base.name, + &class_name, + &gen_styles(ast_builder, &styles, None), + &withheld, + ); + let new_definition = base.definition( + ast_builder, + classes, + &styles, + &definition.attrs, + definition.reads.clone(), + definition.forward.clone(), + ); + Some(( + apply_attrs(ast_builder, component, &definition.attrs), + new_definition, + )) +} + +/// What extracting a styled component gives +pub struct StyledExtraction<'a> { + pub result: ExtractResult<'a>, + pub expression: Expression<'a>, + pub errors: Vec<(u32, String)>, + pub definition: Option>, +} + +/// The styles a component renders: those it extends or its base component +/// brings, then its own, a later declaration replacing an earlier one +fn compose_styles<'a>( + ast_builder: &AstBuilder<'a>, + inherited: Option<&StyledDefinition<'a>>, + defaults: Option>, + own: Vec>, +) -> Vec> { + let mut earlier: Vec> = inherited.map_or_else(Vec::new, |inherited| { + inherited + .styles + .iter() + .map(|style| style.clone_in(ast_builder.allocator())) + .collect() + }); + earlier.extend(defaults.into_iter().flatten().map(ExtractStyleProp::Static)); + if earlier.is_empty() { + return own; + } + let mut composition = crate::composition::Composition::default(); + composition.apply(ast_builder, earlier); + composition.apply(ast_builder, own); + composition.into_props() +} + +/// The classes and attrs a component extending `inherited` starts from +fn inherited_parts<'a>( + ast_builder: &AstBuilder<'a>, + inherited: Option<&StyledDefinition<'a>>, + attrs: &[Expression<'a>], +) -> (Vec>, Vec>) { + let allocator = ast_builder.allocator(); + let (classes, mut all_attrs) = inherited.map_or_else( + || (Vec::new(), Vec::new()), + |inherited| { + ( + inherited + .marker + .as_deref() + .map(|marker| marker_class(ast_builder, marker)) + .into_iter() + .chain(inherited.classes.iter().map(|c| c.clone_in(allocator))) + .collect(), + inherited + .attrs + .iter() + .map(|a| a.clone_in_with_semantic_ids(allocator)) + .collect(), + ) + }, + ); + all_attrs.extend( + attrs + .iter() + .map(|attr| attr.clone_in_with_semantic_ids(allocator)), + ); + (classes, all_attrs) +} + impl<'a> Base<'a> { + /// What `inherited` renders, in place of the component defining it + fn extending( + self, + ast_builder: &AstBuilder<'a>, + inherited: Option<&StyledDefinition<'a>>, + ) -> Self { + match inherited { + Some(inherited) => Self { + name: inherited.name.clone(), + styles: None, + bound: inherited + .bound + .as_ref() + .map(|bound| bound.clone_in(ast_builder.allocator())), + }, + None => self, + } + } + + /// The definition of the component rendering this base + fn definition( + &self, + ast_builder: &AstBuilder<'a>, + classes: Vec>, + styles: &[ExtractStyleProp<'a>], + attrs: &[Expression<'a>], + reads: Reads, + forward: Option, + ) -> StyledDefinition<'a> { + let allocator = ast_builder.allocator(); + StyledDefinition { + name: self.name.clone(), + bound: self.bound.as_ref().map(|bound| bound.clone_in(allocator)), + classes, + styles: styles + .iter() + .map(|style| style.clone_in(allocator)) + .collect(), + attrs: attrs + .iter() + .map(|attr| attr.clone_in_with_semantic_ids(allocator)) + .collect(), + reads, + forward, + marker: None, + } + } + + /// The props a component rendering this base keeps away from it + fn withheld(&self, reads: &Reads, forward: Option<&Forward>) -> Vec { + withheld(reads, renders_tag(&self.name, self.bound.as_ref()), forward) + } + const fn named(name: String) -> Self { Self { name, @@ -65,10 +363,33 @@ 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>, - imports: &FxHashMap, + imports: Kinds<'_>, ) -> Option> { match unwrap_syntax_only(input) { Expression::StaticMemberExpression(member) => { @@ -89,7 +410,7 @@ fn extract_base_tag_and_class_name<'a>( fn tag_from_argument<'a>( ast_builder: &AstBuilder<'a>, argument: &Argument<'a>, - imports: &FxHashMap, + imports: Kinds<'_>, ) -> Option> { let argument = unwrap_syntax_only(argument.as_expression()?); match argument { @@ -101,9 +422,7 @@ fn tag_from_argument<'a>( Expression::Identifier(ident) if ident.name == "undefined" => return None, _ => {} } - if let Expression::Identifier(ident) = argument - && let Some(kind) = imports.get(ident.name.as_str()) - { + if let Some(kind) = imports(argument) { return Some(Base { styles: Some(kind.extract()), ..Base::named(kind.to_tag().to_string()) @@ -149,7 +468,7 @@ fn jsx_name(expression: &Expression<'_>) -> Option { fn resolve_styled_call_target<'a>( ast_builder: &AstBuilder<'a>, call: &CallExpression<'a>, - imports: &FxHashMap, + imports: Kinds<'_>, ) -> Option<(Base<'a>, usize)> { if call.arguments.len() == 1 && let Some(base) = extract_base_tag_and_class_name(ast_builder, &call.callee, imports) @@ -180,10 +499,21 @@ fn resolve_styled_call_target<'a>( pub fn extract_style_from_styled<'a>( ast_builder: &AstBuilder<'a>, expression: &mut Expression<'a>, - split_filename: Option<&str>, - imports: &FxHashMap, + naming: Naming<'_>, + imports: Kinds<'_>, attrs: &[Expression<'a>], -) -> (ExtractResult<'a>, Expression<'a>, Vec<(u32, String)>) { + inherited: Option<&StyledDefinition<'a>>, + forward: Option, +) -> StyledExtraction<'a> { + let Naming { + split_filename, + marker, + } = naming; + let forward = combine_forward(inherited.and_then(|i| i.forward.as_ref()), forward); + let mut reads = inherited.map_or_else(Reads::default, |inherited| inherited.reads.clone()); + for attr in attrs { + reads.read_in(attr); + } let mut composed_classes = Vec::new(); let mut errors = Vec::new(); if let Expression::CallExpression(call) = expression @@ -209,13 +539,17 @@ 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` // Check if tag is styled.div or styled(...) // Extract CSS from template literal + for interpolation in &tag.quasi.expressions { + reads.read_in(interpolation); + } let TemplateStyles { styles, statements, @@ -225,16 +559,16 @@ pub fn extract_style_from_styled<'a>( let expression = &tag.quasi.expressions[index]; errors.push((expression.span().start, unplaced_error(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), @@ -255,12 +589,13 @@ 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::>() + marker + .map(|marker| marker_class(ast_builder, marker)) .into_iter() + .chain(clone_all(ast_builder, &classes)) .chain(gen_class_names( ast_builder, &mut props_styles, @@ -279,8 +614,13 @@ pub fn extract_style_from_styled<'a>( &base.name, &class_name, &gen_styles(ast_builder, &props_styles, None), + &base.withheld(&reads, forward.as_ref()), ); - let styled_component = base.render(ast_builder, apply_attrs(ast_builder, component, attrs)); + let mut definition = + base.definition(ast_builder, classes, &props_styles, &attrs, reads, forward); + definition.marker = marker.map(str::to_string); + let styled_component = + base.render(ast_builder, apply_attrs(ast_builder, component, &attrs)); let result = ExtractResult { styles: props_styles, @@ -290,7 +630,7 @@ pub fn extract_style_from_styled<'a>( props: None, }; - (Some(result), Some(styled_component)) + (Some(result), Some(styled_component), Some(definition)) } else if let Expression::CallExpression(call) = expression && let Some((mut base, style_index)) = resolve_styled_call_target(ast_builder, call, imports) @@ -298,6 +638,13 @@ pub fn extract_style_from_styled<'a>( // Case 2: styled.div({ bg: "red" }), styled("div")({ bg: "red" }), // or styled("div", { bg: "red" }) + reads.read_in( + if let Argument::SpreadElement(spread) = &call.arguments[style_index] { + &spread.argument + } else { + call.arguments[style_index].to_expression() + }, + ); // Extract styles from object expression let ExtractResult { mut styles, @@ -324,26 +671,41 @@ pub fn extract_style_from_styled<'a>( .into_iter() .map(|(offset, code)| (offset, build_time_error("styled", &code, STYLE_OBJECT))), ); - if let Some(default_class_name) = base.styles.take() { - styles.extend(default_class_name.into_iter().map(ExtractStyleProp::Static)); + if let Some(order) = style_order { + for style in &mut styles { + crate::composition::set_prop_order(style, order); + } } + let defaults = base.styles.take(); + let base = base.extending(ast_builder, inherited); + let mut styles = compose_styles(ast_builder, inherited, defaults, styles); + let (mut classes, attrs) = inherited_parts(ast_builder, inherited, attrs); + classes.extend(composed_classes); let class_name = merge_expression_for_class_name( ast_builder, - composed_classes.into_iter().chain(gen_class_names( - ast_builder, - &mut styles, - style_order, - split_filename, - )), + marker + .map(|marker| marker_class(ast_builder, marker)) + .into_iter() + .chain(clone_all(ast_builder, &classes)) + .chain(gen_class_names( + ast_builder, + &mut styles, + None, + split_filename, + )), ); let component = create_styled_component( ast_builder, &base.name, &class_name, &gen_styles(ast_builder, &styles, None), + &base.withheld(&reads, forward.as_ref()), ); - let styled_component = base.render(ast_builder, apply_attrs(ast_builder, component, attrs)); + let mut definition = base.definition(ast_builder, classes, &styles, &attrs, reads, forward); + definition.marker = marker.map(str::to_string); + let styled_component = + base.render(ast_builder, apply_attrs(ast_builder, component, &attrs)); let result = ExtractResult { styles, @@ -353,7 +715,7 @@ pub fn extract_style_from_styled<'a>( props, }; - (Some(result), Some(styled_component)) + (Some(result), Some(styled_component), Some(definition)) } else { // Left as written it would call `styled` at runtime, which only the build runs let code = match &*expression { @@ -364,13 +726,23 @@ 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, + } +} + +fn clone_all<'s, 'a>( + ast_builder: &'s AstBuilder<'a>, + expressions: &'s [Expression<'a>], +) -> impl Iterator> + 's { + expressions + .iter() + .map(|expression| expression.clone_in_with_semantic_ids(ast_builder.allocator())) } /// The name the attrs wrapper binds props to, chosen not to shadow what the @@ -379,15 +751,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> { + is_styled: impl Fn(&Expression<'_>) -> bool, +) -> (Vec>, Vec>) { let mut attrs = Vec::new(); + let mut configs = Vec::new(); if !is_modified_styled(factory, is_styled) { - return attrs; + return (attrs, configs); } while let Expression::CallExpression(call) = unwrap_syntax_only_mut(factory) && let CallExpression { @@ -397,19 +770,66 @@ 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) } -fn is_modified_styled(expression: &Expression<'_>, is_styled: impl Fn(&str) -> bool) -> bool { +/// 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(&Expression<'_>) -> bool, +) -> bool { let mut expression = unwrap_syntax_only(expression); let mut modified = false; while let Some(object) = modifier_object(expression) { @@ -419,10 +839,10 @@ fn is_modified_styled(expression: &Expression<'_>, is_styled: impl Fn(&str) -> b modified && match expression { Expression::StaticMemberExpression(member) => { - matches!(&member.object, Expression::Identifier(ident) if is_styled(&ident.name)) + matches!(&member.object, object @ Expression::Identifier(_) if is_styled(object)) } Expression::CallExpression(call) => { - matches!(&call.callee, Expression::Identifier(ident) if is_styled(&ident.name)) + matches!(&call.callee, callee @ Expression::Identifier(_) if is_styled(callee)) } _ => false, } @@ -452,44 +872,222 @@ 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 { + merged = spread_objects( + ast_builder, + merged, + attr.clone_in_with_semantic_ids(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 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( + 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_with_semantic_ids(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_with_semantic_ids(ast_builder.allocator()), + ast_builder, + ), + BinaryOperator::StrictEquality, + Expression::new_string_literal(SPAN, "function", None, ast_builder), + ast_builder, + ); + Expression::new_conditional_expression(SPAN, is_function, called, attr, ast_builder) +} + +/// `{ ...context, ...attrs, className, style }` with the classes joined and +/// the styles merged +fn merge_attrs_value<'a>(ast_builder: &AstBuilder<'a>) -> Expression<'a> { + let merged = spread_objects( + ast_builder, + identifier(ast_builder, ATTRS_CONTEXT), + identifier(ast_builder, ATTRS_VALUE), + ); + let class_name = joined_class( + ast_builder, + member(ast_builder, ATTRS_CONTEXT, "className"), + member(ast_builder, ATTRS_VALUE, "className"), + ); + let style = spread_objects( + ast_builder, + member(ast_builder, ATTRS_CONTEXT, "style"), + member(ast_builder, ATTRS_VALUE, "style"), + ); + let merged = with_property(ast_builder, merged, "className", class_name); + with_property(ast_builder, merged, "style", style) +} + +fn identifier<'a>(ast_builder: &AstBuilder<'a>, name: &'static str) -> Expression<'a> { + Expression::new_identifier(SPAN, name, ast_builder) +} + +fn member<'a>( + ast_builder: &AstBuilder<'a>, + object: &'static str, + name: &'static str, +) -> Expression<'a> { + Expression::StaticMemberExpression(oxc_ast::ast::StaticMemberExpression::boxed( + SPAN, + identifier(ast_builder, object), + oxc_ast::ast::IdentifierName::new(SPAN, name, ast_builder), + false, + ast_builder, + )) +} + +/// `[first, second].filter(Boolean).join(" ") || undefined` +fn joined_class<'a>( + ast_builder: &AstBuilder<'a>, + first: Expression<'a>, + second: Expression<'a>, +) -> Expression<'a> { + let joined = wrap_array_filter(ast_builder, &[first, second]) + .unwrap_or_else(|| identifier(ast_builder, "undefined")); + Expression::new_logical_expression( + SPAN, + joined, + LogicalOperator::Or, + identifier(ast_builder, "undefined"), ast_builder, - wrap_direct_call(ast_builder, &component, &[merged]), ) } +/// `object` with `name` set to `value`: an object spreading it, then setting +/// the property +fn with_property<'a>( + ast_builder: &AstBuilder<'a>, + object: Expression<'a>, + name: &'static str, + value: Expression<'a>, +) -> Expression<'a> { + let mut properties = match object { + Expression::ObjectExpression(object) => object.unbox().properties, + object => oxc_allocator::Vec::from_array_in( + [ObjectPropertyKind::new_spread_property( + SPAN, + object, + ast_builder, + )], + ast_builder, + ), + }; + properties.push(ObjectPropertyKind::new_object_property( + SPAN, + oxc_ast::ast::PropertyKind::Init, + PropertyKey::new_static_identifier(SPAN, name, ast_builder), + value, + false, + false, + false, + ast_builder, + )); + Expression::new_object_expression(SPAN, properties, ast_builder) +} + fn spread_objects<'a>( ast_builder: &AstBuilder<'a>, first: Expression<'a>, @@ -510,10 +1108,19 @@ fn spread_objects<'a>( } fn props_arrow<'a>(ast_builder: &AstBuilder<'a>, body: Expression<'a>) -> Expression<'a> { + named_arrow(ast_builder, ATTRS_PROPS, body) +} + +/// `(name) => body` +fn named_arrow<'a>( + ast_builder: &AstBuilder<'a>, + name: &'static str, + body: Expression<'a>, +) -> Expression<'a> { let parameter = FormalParameter::new( SPAN, oxc_allocator::Vec::new_in(ast_builder), - BindingPattern::new_binding_identifier(SPAN, ATTRS_PROPS, ast_builder), + BindingPattern::new_binding_identifier(SPAN, name, ast_builder), None::>>, None::>>, false, @@ -540,11 +1147,180 @@ fn props_arrow<'a>(ast_builder: &AstBuilder<'a>, body: Expression<'a>) -> Expres ) } +/// The binding the generated styled components forward refs through +pub const FORWARD_REF: &str = "__devupForwardRef"; + +/// `__devupForwardRef((p, ref) => component({ ...p, ref }))`: React 18 gives a +/// function component no `ref` prop, so the component takes it as one and +/// passes it to what it renders with the rest of its props +pub fn forward_ref<'a>(ast_builder: &AstBuilder<'a>, component: Expression<'a>) -> Expression<'a> { + let parameter = |name: &'static str| { + FormalParameter::new( + SPAN, + oxc_allocator::Vec::new_in(ast_builder), + BindingPattern::new_binding_identifier(SPAN, name, ast_builder), + None::>>, + None::>>, + false, + None, + false, + false, + ast_builder, + ) + }; + let props = with_property( + ast_builder, + identifier(ast_builder, "__devupRefProps"), + "ref", + identifier(ast_builder, "__devupRef"), + ); + let body = wrap_direct_call( + ast_builder, + &Expression::new_parenthesized_expression(SPAN, component, ast_builder), + &[props], + ); + let render = Expression::new_arrow_function_expression( + SPAN, + false, + None::>>, + FormalParameters::boxed( + SPAN, + FormalParameterKind::ArrowFormalParameters, + oxc_allocator::Vec::from_iter_in( + [parameter("__devupRefProps"), parameter("__devupRef")], + ast_builder, + ), + None::>>, + ast_builder, + ), + None::>>, + body.into(), + ast_builder, + ); + wrap_direct_call( + ast_builder, + &identifier(ast_builder, FORWARD_REF), + &[render], + ) +} + +/// `rest` without the props in `withheld`, as `(({ a: _0, ...p }) => p)(rest)` +fn without_props<'a>(ast_builder: &AstBuilder<'a>, withheld: &[String]) -> Expression<'a> { + let rest = Expression::new_identifier(SPAN, "rest", ast_builder); + if withheld.is_empty() { + return rest; + } + let properties = withheld.iter().enumerate().map(|(index, name)| { + BindingProperty::new( + SPAN, + PropertyKey::StringLiteral(oxc_ast::ast::StringLiteral::boxed( + SPAN, + Str::from_in(name.as_str(), ast_builder.allocator()), + None, + ast_builder, + )), + BindingPattern::new_binding_identifier( + SPAN, + Str::from_in( + format!("__devupOmit{index}").as_str(), + ast_builder.allocator(), + ), + ast_builder, + ), + false, + false, + ast_builder, + ) + }); + let pattern = BindingPattern::new_object_pattern( + SPAN, + oxc_allocator::Vec::from_iter_in(properties, ast_builder), + Some(BindingRestElement::boxed( + SPAN, + BindingPattern::new_binding_identifier(SPAN, "__devupDom", ast_builder), + ast_builder, + )), + ast_builder, + ); + let parameter = FormalParameter::new( + SPAN, + oxc_allocator::Vec::new_in(ast_builder), + pattern, + None::>>, + None::>>, + false, + None, + false, + false, + ast_builder, + ); + let arrow = Expression::new_arrow_function_expression( + SPAN, + false, + None::>>, + FormalParameters::boxed( + SPAN, + FormalParameterKind::ArrowFormalParameters, + oxc_allocator::Vec::from_iter_in([parameter], ast_builder), + None::>>, + ast_builder, + ), + None::>>, + Expression::new_identifier(SPAN, "__devupDom", ast_builder).into(), + ast_builder, + ); + wrap_direct_call( + ast_builder, + &Expression::new_parenthesized_expression(SPAN, arrow, ast_builder), + &[rest], + ) +} + +/// The binding a styled component renders through: what `as` names, or the +/// tag or component it was defined with +const RENDERED: &str = "DevupAs"; + +/// What a styled component renders when `as` names nothing: a tag name as a +/// string, a component as the binding or member JSX names it by +fn tag_expression<'a>(ast_builder: &AstBuilder<'a>, tag_name: &str) -> Expression<'a> { + if tag_name.starts_with(|c: char| c.is_ascii_lowercase()) && !tag_name.contains('.') { + return Expression::new_string_literal( + SPAN, + Str::from_in(tag_name, ast_builder.allocator()), + None, + ast_builder, + ); + } + let mut parts = tag_name.split('.'); + let first = parts.next().unwrap_or(tag_name); + parts.fold( + Expression::new_identifier( + SPAN, + Str::from_in(first, ast_builder.allocator()), + ast_builder, + ), + |object, property| { + Expression::StaticMemberExpression(oxc_ast::ast::StaticMemberExpression::boxed( + SPAN, + object, + oxc_ast::ast::IdentifierName::new( + SPAN, + Str::from_in(property, ast_builder.allocator()), + ast_builder, + ), + false, + ast_builder, + )) + }, + ) +} + fn create_styled_component<'a>( ast_builder: &AstBuilder<'a>, tag_name: &str, class_name: &Option>, style_vars: &Option>, + withheld: &[String], ) -> Expression<'a> { let params = FormalParameters::boxed( SPAN, @@ -577,6 +1353,39 @@ fn create_styled_component<'a>( false, ast_builder, ), + BindingProperty::new( + SPAN, + PropertyKey::new_static_identifier(SPAN, "as", ast_builder), + BindingPattern::new_assignment_pattern( + SPAN, + BindingPattern::new_binding_identifier( + SPAN, + RENDERED, + ast_builder, + ), + tag_expression(ast_builder, tag_name), + ast_builder, + ), + false, + false, + ast_builder, + ), + BindingProperty::new( + SPAN, + PropertyKey::new_static_identifier( + SPAN, + "forwardedAs", + ast_builder, + ), + BindingPattern::new_binding_identifier( + SPAN, + "forwardedAs", + ast_builder, + ), + true, + false, + ast_builder, + ), ], ast_builder, ), @@ -604,17 +1413,23 @@ fn create_styled_component<'a>( SPAN, JSXOpeningElement::boxed( SPAN, - JSXElementName::new_identifier( - SPAN, - Str::from_in(tag_name, ast_builder.allocator()), - ast_builder, - ), + JSXElementName::new_identifier(SPAN, RENDERED, ast_builder), None::>>, oxc_allocator::Vec::from_iter_in( vec![ JSXAttributeItem::new_spread_attribute( SPAN, - Expression::new_identifier(SPAN, "rest", ast_builder), + without_props(ast_builder, withheld), + ast_builder, + ), + JSXAttributeItem::new_attribute( + SPAN, + JSXAttributeName::new_identifier(SPAN, "as", ast_builder), + Some(JSXAttributeValue::new_expression_container( + SPAN, + Expression::new_identifier(SPAN, "forwardedAs", ast_builder).into(), + ast_builder, + )), ast_builder, ), JSXAttributeItem::new_attribute( diff --git a/libs/extractor/src/extractor/extract_style_from_stylex.rs b/libs/extractor/src/extractor/extract_style_from_stylex.rs index 69ff713a8..f3c19467b 100644 --- a/libs/extractor/src/extractor/extract_style_from_stylex.rs +++ b/libs/extractor/src/extractor/extract_style_from_stylex.rs @@ -1,18 +1,19 @@ use crate::ExtractStyleProp; -use crate::extract_style::extract_dynamic_style::ExtractDynamicStyle; use crate::extract_style::extract_static_style::ExtractStaticStyle; use crate::extract_style::extract_style_value::ExtractStyleValue; +use crate::stylex::assignments::is_final_assignment; use crate::stylex::{ - DecomposedStyle, SelectorPart, StylexIncludeRef, decompose_value_conditions, - dynamic_number_suffix, is_include_call_static, normalize_stylex_property, stylex_value, + DecomposedStyle, DynamicNamespace, SelectorPart, StylexIncludeRef, StylexResolver, + decompose_value_conditions, is_first_that_works_call, is_include_call_static, is_types_call, + normalize_stylex_property, stylex_value, }; use css::optimize_value::optimize_value; -use css::sheet_to_variable_name; use css::style_selector::StyleSelector; use oxc_ast::ast::{ - Argument, ArrowFunctionExpression, BindingPattern, Expression, ObjectExpression, - ObjectPropertyKind, SpreadElement, + Argument, Expression, ObjectExpression, ObjectPropertyKind, PropertyKind, SpreadElement, }; + +mod dynamic; use oxc_span::GetSpan; use rustc_hash::FxHashMap; @@ -23,7 +24,7 @@ use crate::utils::{ /// Construct a static style directly — bypass `convert_value()` to avoid devup-ui /// spacing transformations. `StyleX` values are raw CSS, only `optimize_value()`. -fn raw_static_style<'a>( +pub(crate) fn raw_static_style<'a>( property: String, value: &str, selector: Option, @@ -45,6 +46,7 @@ pub fn extract_stylex_declarations( api: &str, object: &ObjectExpression<'_>, errors: &mut Vec<(u32, String)>, + resolver: StylexResolver<'_>, ) -> Vec<(String, String)> { let mut declarations = vec![]; for property in &object.properties { @@ -64,13 +66,33 @@ pub fn extract_stylex_declarations( Some(value) => declarations.push((name, optimize_value(&value).into_owned())), None => errors.push(( property.value.span().start, - runtime_value_error(api, &readable_code(&property.value)), + declaration_error(api, &property.value, resolver), )), } } declarations } +/// Why `value` is no declaration value: a genuine `StyleX` helper is read only +/// inside the API that takes it +fn declaration_error(api: &str, value: &Expression<'_>, resolver: StylexResolver<'_>) -> String { + let code = readable_code(value); + match value { + Expression::CallExpression(call) + if is_first_that_works_call(&call.callee, resolver) + || is_include_call_static(&call.callee, resolver) + || is_types_call(&call.callee, resolver) => + { + build_time_error( + api, + &code, + "its values must be literals; `firstThatWorks()`, `include()` and `types` are read only inside `stylex.create()` and `stylex.defineVars()`", + ) + } + _ => runtime_value_error(api, &code), + } +} + /// Resolve a `vars.key` member access against the contracts `stylex.defineVars()` /// produced, yielding the `var(--x)` reference the value compiles to. fn var_reference<'v>( @@ -119,7 +141,9 @@ type Leaf<'l> = dyn Fn(&str, &Expression<'_>) -> Option + 'l; /// Handles static string/number values (Phase 1) and value-level conditions (Phase 2); /// what cannot be read at build time is reported in `errors`. /// -/// Returns a Vec of `(namespace_name, style_props, css_vars, include_refs)` tuples. Each namespace +/// Returns a Vec of `(namespace_name, style_props, css_vars, include_refs, key_groups)` tuples, +/// `key_groups` giving each top-level key of a static namespace and how many of its +/// styles it gives. Each namespace /// corresponds to a top-level key in the `stylex.create({...})` argument. #[allow(clippy::type_complexity)] pub fn extract_stylex_namespace_styles<'a>( @@ -127,11 +151,13 @@ pub fn extract_stylex_namespace_styles<'a>( keyframe_names: &FxHashMap, var_refs: &FxHashMap, errors: &mut Vec<(u32, String)>, + resolver: StylexResolver<'_>, ) -> Vec<( String, Vec>, - Option>, + Option, Vec, + Vec<(String, usize)>, )> { // A keyframes name or a `defineVars` member reads as the value it stands for let leaf = |property: &str, value: &Expression<'_>| { @@ -158,28 +184,44 @@ pub fn extract_stylex_namespace_styles<'a>( errors.push(key_error("stylex.create", &prop.key)); continue; }; + if prop.method || prop.kind != PropertyKind::Init { + errors.push(( + prop.span.start, + build_time_error( + "stylex.create", + "method/getter/setter namespace", + "use a plain namespace object or a synchronous expression-bodied arrow", + ), + )); + continue; + } match &prop.value { Expression::ArrowFunctionExpression(arrow) => { - let Some((styles, css_vars)) = - extract_stylex_dynamic_namespace(arrow, &leaf, errors) - else { - errors.push(( - prop.value.span().start, - build_time_error( - "stylex.create", - &readable_code(&prop.value), - "a dynamic style is an arrow function with plain parameters returning an object literal", - ), - )); + let Some((styles, css_vars)) = dynamic::extract(arrow, &leaf, errors) else { continue; }; - result.push((ns_name, styles, Some(css_vars), vec![])); + result.push((ns_name, styles, Some(css_vars), vec![], vec![])); } Expression::ObjectExpression(ns_obj) => { - let (styles, include_refs) = extract_stylex_namespace(ns_obj, &leaf, errors); - result.push((ns_name, styles, None, include_refs)); + let (styles, include_refs, groups) = + extract_stylex_namespace(ns_obj, &leaf, errors, resolver); + result.push((ns_name, styles, None, include_refs, groups)); } - Expression::NullLiteral(_) => result.push((ns_name, vec![], None, vec![])), + Expression::NullLiteral(_) => result.push((ns_name, vec![], None, vec![], vec![])), + Expression::FunctionExpression(function) => errors.push(( + function.span.start, + build_time_error( + "stylex.create", + if function.generator { + "generator function expression" + } else if function.r#async { + "async function expression" + } else { + "function expression" + }, + "use a synchronous expression-bodied arrow with plain scalar parameters", + ), + )), value => errors.push(( value.span().start, build_time_error( @@ -193,20 +235,31 @@ pub fn extract_stylex_namespace_styles<'a>( result } -/// The styles and `include()` references of one static namespace +/// The styles and `include()` references of one static namespace, with each +/// top-level key and how many of the styles it gives +#[allow(clippy::type_complexity)] fn extract_stylex_namespace<'a>( namespace: &ObjectExpression<'_>, leaf: &Leaf<'_>, errors: &mut Vec<(u32, String)>, -) -> (Vec>, Vec) { + resolver: StylexResolver<'_>, +) -> ( + Vec>, + Vec, + Vec<(String, usize)>, +) { let mut styles = vec![]; let mut include_refs = vec![]; - for style_prop in &namespace.properties { + let mut groups = vec![]; + for (index, style_prop) in namespace.properties.iter().enumerate() { let style_prop = match style_prop { ObjectPropertyKind::ObjectProperty(style_prop) => style_prop, ObjectPropertyKind::SpreadProperty(spread) => { - match include(spread) { - Some(Ok(include_ref)) => include_refs.push(include_ref), + match include(spread, resolver) { + Some(Ok(mut include_ref)) => { + include_ref.before_group = groups.len(); + include_refs.push(include_ref); + } Some(Err(error)) => errors.push(error), None => errors.push(spread_error("stylex.create", spread)), } @@ -217,6 +270,7 @@ fn extract_stylex_namespace<'a>( errors.push(key_error("stylex.create", &style_prop.key)); continue; }; + let final_assignment = is_final_assignment(&prop_name, &namespace.properties[index + 1..]); // Phase 2: pseudo-element / pseudo-class top-level keys if prop_name.starts_with(':') { @@ -232,7 +286,9 @@ fn extract_stylex_namespace<'a>( continue; }; let parent_selectors = [SelectorPart::Pseudo(prop_name.to_string())]; - for inner_prop in &inner_obj.properties { + let before = styles.len(); + let mut has_entries = false; + for (inner_index, inner_prop) in inner_obj.properties.iter().enumerate() { let inner_prop = match inner_prop { ObjectPropertyKind::ObjectProperty(inner_prop) => inner_prop, ObjectPropertyKind::SpreadProperty(spread) => { @@ -244,16 +300,23 @@ fn extract_stylex_namespace<'a>( errors.push(key_error("stylex.create", &inner_prop.key)); continue; }; - push_decomposed( - &mut styles, - decompose_value_conditions( - &normalize_stylex_property(inner_name.as_ref()), - &inner_prop.value, - &parent_selectors, - leaf, - errors, - ), + let decomposed = decompose_value_conditions( + &normalize_stylex_property(inner_name.as_ref()), + &inner_prop.value, + &parent_selectors, + leaf, + errors, + resolver, ); + if final_assignment + && is_final_assignment(&inner_name, &inner_obj.properties[inner_index + 1..]) + { + has_entries |= !decomposed.is_empty(); + push_decomposed(&mut styles, decomposed); + } + } + if has_entries { + groups.push((prop_name.to_string(), styles.len() - before)); } continue; } @@ -264,12 +327,21 @@ fn extract_stylex_namespace<'a>( "[stylex] WARNING: Shorthand property '{css_property}' may cause unexpected specificity issues. Consider using longhand properties (e.g., 'marginTop', 'paddingLeft')." ); } - push_decomposed( - &mut styles, - decompose_value_conditions(&css_property, &style_prop.value, &[], leaf, errors), + let before = styles.len(); + let decomposed = decompose_value_conditions( + &css_property, + &style_prop.value, + &[], + leaf, + errors, + resolver, ); + if final_assignment && !decomposed.is_empty() { + push_decomposed(&mut styles, decomposed); + groups.push((prop_name.to_string(), styles.len() - before)); + } } - (styles, include_refs) + (styles, include_refs, groups) } fn push_decomposed(styles: &mut Vec>, decomposed: Vec) { @@ -282,13 +354,19 @@ fn push_decomposed(styles: &mut Vec>, decomposed: Vec) -> Option> { +fn include( + spread: &SpreadElement<'_>, + resolver: StylexResolver<'_>, +) -> Option> { let Expression::CallExpression(call) = &spread.argument else { return None; }; - if !is_include_call_static(&call.callee) { + if !is_include_call_static(&call.callee, resolver) { return None; } + if let Err(error) = crate::stylex::validation::validate_helper_call(call, resolver) { + return Some(Err(error)); + } if let Some(Expression::StaticMemberExpression(member)) = call.arguments.first().and_then(Argument::as_expression) && let Expression::Identifier(ident) = &member.object @@ -296,7 +374,8 @@ fn include(spread: &SpreadElement<'_>) -> Option) -> Option( - arrow: &ArrowFunctionExpression<'_>, - leaf: &Leaf<'_>, - errors: &mut Vec<(u32, String)>, -) -> Option<( - Vec>, - Vec<(usize, String, &'static str)>, -)> { - // 1. Extract parameter names - let mut param_names = vec![]; - for param in &arrow.params.items { - let BindingPattern::BindingIdentifier(ident) = ¶m.pattern else { - return None; - }; - param_names.push(ident.name.to_string()); - } - - // 2. Get body ObjectExpression from expression body: (x) => ({ ... }) - let Expression::ObjectExpression(body_obj) = arrow.body.as_expression()?.without_parentheses() - else { - return None; - }; - - // 3. Process each property - let mut styles = vec![]; - let mut css_vars = vec![]; - - for prop in &body_obj.properties { - let prop = match prop { - ObjectPropertyKind::ObjectProperty(prop) => prop, - ObjectPropertyKind::SpreadProperty(spread) => { - errors.push(spread_error("stylex.create", spread)); - continue; - } - }; - let Some(prop_name) = get_string_by_property_key(&prop.key) else { - errors.push(key_error("stylex.create", &prop.key)); - continue; - }; - let css_property = normalize_stylex_property(&prop_name); - - // Check if value references a parameter (dynamic) - let is_dynamic = if prop.shorthand { - // Shorthand: { height } is equivalent to { height: height } - param_names.iter().position(|p| p == &prop_name) - } else if let Expression::Identifier(ident) = &prop.value { - param_names.iter().position(|p| p == ident.name.as_str()) - } else { - None - }; - - if let Some(param_idx) = is_dynamic { - // Dynamic property: generate CSS variable - let var_name = sheet_to_variable_name(&css_property, 0, None); - css_vars.push((param_idx, var_name, dynamic_number_suffix(&css_property))); - let param_name = ¶m_names[param_idx]; - styles.push(ExtractStyleProp::Static(ExtractStyleValue::Dynamic( - ExtractDynamicStyle::new(&css_property, 0, param_name, None), - ))); - continue; - } - match leaf(&css_property, &prop.value) { - Some(value) => styles.push(raw_static_style(css_property, &value, None)), - None => errors.push(( - prop.value.span().start, - build_time_error( - "stylex.create", - &readable_code(&prop.value), - "a dynamic style's value is one of its parameters or a static value; compute it before passing it", - ), - )), - } - } - - Some((styles, css_vars)) -} diff --git a/libs/extractor/src/extractor/extract_style_from_stylex/dynamic.rs b/libs/extractor/src/extractor/extract_style_from_stylex/dynamic.rs new file mode 100644 index 000000000..2510a7368 --- /dev/null +++ b/libs/extractor/src/extractor/extract_style_from_stylex/dynamic.rs @@ -0,0 +1,136 @@ +use super::{Leaf, raw_static_style}; +use crate::ExtractStyleProp; +use crate::extract_style::extract_dynamic_style::ExtractDynamicStyle; +use crate::extract_style::extract_style_value::ExtractStyleValue; +use crate::stylex::assignments::is_final_assignment; +use crate::stylex::{DynamicNamespace, Scalar, dynamic_number_suffix, normalize_stylex_property}; +use crate::utils::{ + build_time_error, get_string_by_property_key, key_error, readable_code, spread_error, +}; +use css::sheet_to_variable_name; +use oxc_allocator::CloneIn; +use oxc_ast::ast::{ + ArrowFunctionExpression, BindingPattern, Expression, ObjectPropertyKind, PropertyKind, +}; +use oxc_span::GetSpan; + +/// Validate the function before extracting its scalar property assignments. +pub(super) fn extract<'a>( + arrow: &ArrowFunctionExpression<'_>, + leaf: &Leaf<'_>, + errors: &mut Vec<(u32, String)>, +) -> Option<(Vec>, DynamicNamespace)> { + let allocator = oxc_allocator::Allocator::default(); + let arrow_code = readable_code(&Expression::ArrowFunctionExpression( + oxc_allocator::Box::new_in(arrow.clone_in(&allocator), &&allocator), + )); + let mut reject = |offset, form: &str| { + errors.push((offset, build_time_error("stylex.create", &arrow_code, + &format!("a dynamic style is not exact ({form}); this function form cannot be compiled exactly; use a synchronous expression-bodied arrow with plain scalar parameters and literal defaults")))); + }; + if arrow.r#async { + reject(arrow.span.start, "async arrow function"); + return None; + } + if let Some(rest) = &arrow.params.rest { + reject(rest.span.start, "rest parameter"); + return None; + } + let mut names = vec![]; + let mut namespace = DynamicNamespace::default(); + for param in &arrow.params.items { + let BindingPattern::BindingIdentifier(ident) = ¶m.pattern else { + reject( + param.pattern.span().start, + "destructuring parameter/default", + ); + return None; + }; + let default = match ¶m.initializer { + Some(value) => { + if let Some(default) = Scalar::literal(value) { + Some(default) + } else { + reject( + value.span().start, + &format!("non-exact default `{}`", readable_code(value)), + ); + return None; + } + } + None => None, + }; + names.push(ident.name.to_string()); + namespace.defaults.push(default); + } + let Some(body) = arrow.body.as_expression() else { + reject(arrow.body.span().start, "block/statement body"); + return None; + }; + let Expression::ObjectExpression(body) = body.without_parentheses() else { + reject( + body.span().start, + &format!("non-object body `{}`", readable_code(body)), + ); + return None; + }; + let mut styles = vec![]; + for (position, prop) in body.properties.iter().enumerate() { + let prop = match prop { + ObjectPropertyKind::ObjectProperty(prop) => prop, + ObjectPropertyKind::SpreadProperty(spread) => { + errors.push(spread_error("stylex.create", spread)); + continue; + } + }; + if prop.method || prop.kind != PropertyKind::Init { + errors.push(( + prop.span.start, + build_time_error( + "stylex.create", + "method/getter/setter body property", + "use a plain scalar property assignment", + ), + )); + continue; + } + let Some(name) = get_string_by_property_key(&prop.key) else { + errors.push(key_error("stylex.create", &prop.key)); + continue; + }; + let property = normalize_stylex_property(&name); + let final_assignment = is_final_assignment(&name, &body.properties[position + 1..]); + if final_assignment { + namespace.properties.push(property.clone()); + } + let index = match prop.value.without_parentheses() { + Expression::Identifier(ident) => { + names.iter().position(|name| name == ident.name.as_str()) + } + _ => None, + }; + if let Some(index) = index { + if !final_assignment { + continue; + } + namespace.css_vars.push(( + index, + sheet_to_variable_name(&property, 0, None), + dynamic_number_suffix(&property), + property.clone(), + )); + styles.push(ExtractStyleProp::Static(ExtractStyleValue::Dynamic( + ExtractDynamicStyle::new(&property, 0, &names[index], None), + ))); + } else if !matches!(prop.value.without_parentheses(), Expression::NullLiteral(_)) { + if let Some(value) = leaf(&property, &prop.value) { + if final_assignment { + styles.push(raw_static_style(property, &value, None)); + } + } else { + errors.push((prop.value.span().start, build_time_error("stylex.create", &readable_code(&prop.value), "a dynamic style's value is a non-exact body value; use a parameter or a static scalar value; compute scalar expressions before passing them"))); + } + } + } + Some((styles, namespace)) +} diff --git a/libs/extractor/src/gen_class_name.rs b/libs/extractor/src/gen_class_name.rs index 3b274b7fd..929367173 100644 --- a/libs/extractor/src/gen_class_name.rs +++ b/libs/extractor/src/gen_class_name.rs @@ -165,11 +165,7 @@ fn gen_class_name<'a>( false, ast_builder, )); - if let Expression::Identifier(_) = &expression { - Some(convert_class_name(ast_builder, &exp)) - } else { - Some(exp) - } + Some(convert_class_name(ast_builder, &exp)) } } } diff --git a/libs/extractor/src/gen_style.rs b/libs/extractor/src/gen_style.rs index 85b2f086d..079cacbf5 100644 --- a/libs/extractor/src/gen_style.rs +++ b/libs/extractor/src/gen_style.rs @@ -28,6 +28,22 @@ fn extend_reversed_styles<'a>( ); } +/// Keep one `'--name': value` entry for each variable a breakpoint-wise +/// override shares between classes +fn drop_repeated_variables(properties: &mut Vec>) { + let mut seen = FxHashSet::default(); + properties.retain(|property| { + if let ObjectPropertyKind::ObjectProperty(property) = property + && let Some(name) = property.key.name() + && name.starts_with("--") + && let Expression::Identifier(value) = &property.value + { + return seen.insert((name.into_owned(), value.name.to_string())); + } + true + }); +} + pub fn gen_styles<'a>( ast_builder: &AstBuilder<'a>, style_props: &[ExtractStyleProp<'a>], @@ -38,6 +54,7 @@ pub fn gen_styles<'a>( } let mut properties: Vec<_> = Vec::with_capacity(style_props.len()); extend_reversed_styles(ast_builder, &mut properties, style_props, filename); + drop_repeated_variables(&mut properties); if properties.is_empty() { return None; } @@ -60,7 +77,18 @@ fn push_one_sided_conditional<'a>( for p in gen_style(ast_builder, styles, filename) { if let ObjectPropertyKind::ObjectProperty(p) = p { let value = p.value.clone_in(ast_builder.allocator()); - let undefined = Expression::new_identifier(SPAN, "undefined", ast_builder); + let undefined = Expression::new_unary_expression( + SPAN, + oxc_ast::ast::UnaryOperator::Void, + Expression::new_numeric_literal( + SPAN, + 0.0, + None, + oxc_ast::ast::NumberBase::Decimal, + ast_builder, + ), + ast_builder, + ); let (consequent, alternate) = if value_when_true { (value, undefined) } else { @@ -355,7 +383,7 @@ mod tests { let generated = gen_styles(&builder, &styles, None).unwrap(); let code = expression_to_code(&generated); - assert!(code.contains("enabled?undefined:fallbackColor")); + assert!(code.contains("enabled?void 0:fallbackColor")); } #[test] diff --git a/libs/extractor/src/import_alias_scoping_tests.rs b/libs/extractor/src/import_alias_scoping_tests.rs new file mode 100644 index 000000000..fae3e4d79 --- /dev/null +++ b/libs/extractor/src/import_alias_scoping_tests.rs @@ -0,0 +1,253 @@ +use super::*; + +fn aliases() -> HashMap { + HashMap::from([ + (EMOTION_REACT.into(), ImportAlias::NamedToNamed), + ( + "@emotion/styled".into(), + ImportAlias::DefaultToNamed("styled".into()), + ), + ( + "styled-components".into(), + ImportAlias::DefaultToNamed("styled".into()), + ), + ("@vanilla-extract/css".into(), ImportAlias::NamedToNamed), + ]) +} + +#[test] +fn numbers_stay_numeric_when_api_bindings_are_shadowed() { + for (import, name, call) in [ + ( + "import { css } from '@emotion/react';", + "css", + "css({ top: 2 })", + ), + ( + "import { keyframes } from 'styled-components';", + "keyframes", + "keyframes({ from: { top: 2 } })", + ), + ( + "import { style as s } from '@vanilla-extract/css';", + "s", + "s({ top: 2 })", + ), + ( + "import { globalStyle } from '@vanilla-extract/css';", + "globalStyle", + "globalStyle('body', { top: 2 })", + ), + ( + "import styled from '@emotion/styled';", + "styled", + "styled.div({ top: 2 })", + ), + ( + "import * as styled from 'styled-components';", + "styled", + "styled('div').attrs({})({ top: 2 })", + ), + ] { + let code = format!("{import}\n{call}; function local({name}) {{ {call}; }}"); + let output = transform_import_aliases(&code, "test.tsx", "@devup-ui/react", &aliases()); + assert!( + output.contains(&format!("function local({name}) {{ {call}; }}")), + "{output}" + ); + assert!(output.contains("top: \"2px\""), "{output}"); + } +} + +#[test] +fn global_styles_stay_numeric_when_component_binding_is_shadowed() { + let code = "import { Global as G } from '@emotion/react';\nconst real = ; function local(G) { return ; }"; + let output = transform_import_aliases(code, "test.tsx", "@devup-ui/react", &aliases()); + assert!(output.contains("const real = ")); + assert!(output.contains("function local(G) { return ; }")); +} + +#[test] +fn jsx_props_stay_numeric_when_factory_binding_is_shadowed() { + for import in [ + "import { jsx as h } from '@emotion/react';", + "import { jsx as h } from 'react/jsx-runtime';", + ] { + let code = format!( + "{import}\nconst real = h('div', {{ css: {{ top: 2 }} }}); function local(h) {{ return h('div', {{ css: {{ top: 2 }} }}); }}" + ); + let output = transform_import_aliases(&code, "test.tsx", "@devup-ui/react", &aliases()); + assert!(output.contains("const real = h('div', { css: { top: \"2px\" } })")); + assert!(output.contains("function local(h) { return h('div', { css: { top: 2 } }); }")); + } +} + +#[test] +fn devup_css_props_stay_numeric_when_component_root_is_shadowed() { + for (import, root, tag, ty) in [ + ("import { Box as B } from '@devup-ui/react';", "B", "B", "B"), + ( + "import * as UI from '@devup-ui/react';", + "UI", + "UI.Box", + "UI.Box", + ), + ] { + let code = format!( + "{import}\nimport {{ jsx as h }} from 'react/jsx-runtime';\nconst real = <{tag} css={{{{ top: 2 }}}} />; const made = h({ty}, {{ css: {{ top: 2 }} }}); function local({root}) {{ return [<{tag} css={{{{ top: 2 }}}} />, h(({ty}), {{ css: {{ top: 2 }} }})]; }}" + ); + let output = transform_import_aliases(&code, "test.tsx", "@devup-ui/react", &aliases()); + assert!(output.contains(&format!("function local({root}) {{ return [<{tag} css={{{{ top: 2 }}}} />, h(({ty}), {{ css: {{ top: 2 }} }})]; }}")), "{output}"); + assert_eq!(output.matches("\"2px\"").count(), 2, "{output}"); + } +} + +#[test] +fn class_names_css_uses_child_binding_identity_when_nested_locals_shadow_it() { + for (child, pixels) in [ + ( + "({ cx, css: rule }) => <>{rule({ top: 2 })}{((rule) => rule({ top: 3 }))(other)}{({ css: rule }) => rule({ top: 4 })}{rule({ top: 5 })}", + 3, + ), + ( + "function ({ css: rule }) { return <>{rule({ top: 2 })}{((rule) => rule({ top: 3 }))(other)}; }", + 1, + ), + ] { + let code = format!( + "import {{ ClassNames as C }} from '@emotion/react';\nconst real = {{{child}}}; function local(C) {{ return {{({{ css: rule }}) => rule({{ top: 6 }})}}; }}" + ); + let output = transform_import_aliases(&code, "test.tsx", "@devup-ui/react", &aliases()); + assert!(output.contains("rule({ top: \"2px\" })"), "{output}"); + assert!(output.contains("rule({ top: 3 })"), "{output}"); + assert!(output.contains("rule({ top: 6 })"), "{output}"); + assert_eq!(output.matches("px\"").count(), pixels, "{output}"); + } +} + +#[test] +fn type_only_bindings_do_not_register_number_apis() { + for import in [ + "import type { css, Global, ClassNames, jsx } from '@emotion/react';", + "import { type css, type Global, type ClassNames, type jsx } from '@emotion/react';", + "import type styled from '@emotion/styled';", + "import type * as styled from 'styled-components';", + ] { + let body = "css({ top: 2 }); styled.div({ top: 2 }); jsx('div', { css: { top: 2 } }); const a = ; const b = {({ css }) => css({ top: 2 })};"; + let code = format!("{import}\n{body}"); + let output = transform_import_aliases(&code, "test.tsx", "@devup-ui/react", &aliases()); + assert!(output.ends_with(body), "{output}"); + } +} + +#[test] +fn named_styled_binding_pixelifies_chains_without_touching_block_locals() { + let code = "import { styled as s } from 'styled-components';\nconst real = s('div').withConfig({}).attrs({})({ top: 2 }); { const s = other; s.div({ top: 3 }); }"; + let output = transform_import_aliases(code, "test.tsx", "@devup-ui/react", &aliases()); + assert!(output.contains("attrs({})({ top: \"2px\" })")); + assert!(output.ends_with("{ const s = other; s.div({ top: 3 }); }")); +} + +#[test] +fn malformed_class_names_children_do_not_register_css_calls() { + for child in [ + "text", + "{other}", + "{2}", + "{'2'}", + "{({ css }) => css({ top: 2 })}{other}", + "{() => css({ top: 2 })}", + "{(rules) => css({ top: 2 })}", + "{({ cx }) => css({ top: 2 })}", + "{({ css, unknown }) => css({ top: 2 })}", + "{({ css = other }) => css({ top: 2 })}", + ] { + let code = format!( + "import {{ ClassNames as C }} from '@emotion/react';\nconst view = {child};" + ); + let output = transform_import_aliases(&code, "test.tsx", "@devup-ui/react", &aliases()); + assert!( + output.ends_with(&format!("const view = {child};")), + "{output}" + ); + } +} + +#[test] +fn type_only_devup_components_do_not_take_css_props() { + for import in [ + "import type { Box as B } from '@devup-ui/react';", + "import { type Box as B } from '@devup-ui/react';", + ] { + let code = format!("{import}\nconst view = ;"); + let output = transform_import_aliases(&code, "test.tsx", "@devup-ui/react", &aliases()); + assert_eq!(output, code); + } +} + +#[test] +fn number_edits_map_back_to_original_offsets_when_imports_are_rewritten() { + let code = "import { css as c } from '@emotion/react';\nc({ top: 23 }); function local(c) { c({ top: 24 }); }"; + let output = + transform_import_aliases_with_edits(code, "test.tsx", "@devup-ui/react", &aliases()); + let Some(pixel) = output.code.find("\"23px\"") else { + panic!("missing pixelified number: {}", output.code); + }; + let Some(original_pixel) = code.find("23") else { + panic!("missing original number"); + }; + assert_eq!(source_offset(&output.edits, pixel + 3), original_pixel); + let Some(local) = output.code.find("24") else { + panic!("missing local number: {}", output.code); + }; + let Some(original_local) = code.find("24") else { + panic!("missing original local number"); + }; + assert_eq!(source_offset(&output.edits, local), original_local); +} + +#[test] +fn devup_root_guards_follow_nested_members_but_not_other_element_types() { + let code = "import * as UI from '@devup-ui/react'; import { jsx as h } from 'react/jsx-runtime'; const real = [, h(UI['Box'], { css: { top: 2 } })]; const other = [, h(other(), { css: { top: 3 } })]; function local(UI) { return h(UI['Box'], { css: { top: 3 } }); }"; + let output = transform_import_aliases(code, "test.tsx", "@devup-ui/react", &aliases()); + assert_eq!(output.matches("\"2px\"").count(), 2, "{output}"); + assert_eq!(output.matches("top: 3").count(), 3, "{output}"); +} + +#[test] +fn inline_types_keep_modifiers_when_other_specifiers_are_retained() { + let code = "import { styleVariants, type style as Rule, style as s, type keyframes } from '@vanilla-extract/css';\ns({ top: 2 }); Rule({ top: 3 }); keyframes({ from: { top: 4 } });"; + let output = transform_import_aliases(code, "test.tsx", "@devup-ui/react", &aliases()); + assert_eq!( + output, + "import { css as s } from '@devup-ui/react'; import { styleVariants, type style as Rule, type keyframes } from '@vanilla-extract/css';\ns({ top: \"2px\" }); Rule({ top: 3 }); keyframes({ from: { top: 4 } });" + ); +} + +#[test] +fn nested_member_factory_css_uses_the_imported_namespace_binding() { + let code = "import * as UI from '@devup-ui/react'; import { jsx as h } from 'react/jsx-runtime';\nconst real = h((UI.parts.Box), { css: { top: 2 } }); function local(UI) { return h((UI.parts.Box), { css: { top: 3 } }); }"; + let output = transform_import_aliases(code, "test.tsx", "@devup-ui/react", &aliases()); + assert_eq!(output, code.replace("top: 2", "top: \"2px\"")); +} + +#[test] +fn global_styles_rewrite_conditional_rules_without_rewriting_shadowed_styles() { + let code = "import { Global as G } from '@emotion/react';\nconst real = ; function local(G) { return ; }"; + let output = transform_import_aliases(code, "test.tsx", "@devup-ui/react", &aliases()); + assert!(output.contains("styles={flag ? { top: \"2px\" } : { left: \"3px\" }} css={flag ? { right: \"4px\" } : { bottom: \"5px\" }}"), "{output}"); + assert!( + output.ends_with( + "function local(G) { return ; }" + ), + "{output}" + ); +} + +#[test] +fn class_names_css_binding_is_found_after_whitespace_children() { + let code = "import { ClassNames as C } from '@emotion/react';\nconst view = \n {({ css }) => css({ top: 2 })}\n;"; + let output = transform_import_aliases(code, "test.tsx", "@devup-ui/react", &aliases()); + assert!(output.contains("css({ top: \"2px\" })"), "{output}"); + assert!(!output.contains("css({ top: 2 })"), "{output}"); +} diff --git a/libs/extractor/src/import_alias_visit.rs b/libs/extractor/src/import_alias_visit.rs index 158d0f420..6fca99ac6 100644 --- a/libs/extractor/src/import_alias_visit.rs +++ b/libs/extractor/src/import_alias_visit.rs @@ -8,21 +8,29 @@ //! - `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, + root_reference, +}; 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, BindingIdentifier, BindingPattern, CallExpression, Expression, + IdentifierReference, ImportDeclarationSpecifier, JSXAttributeItem, JSXAttributeValue, JSXChild, + 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_semantic::{Scoping, SemanticBuilder}; use oxc_span::{GetSpan, SourceType}; +use oxc_syntax::symbol::SymbolId; use std::borrow::Cow; use std::collections::HashMap; @@ -37,26 +45,47 @@ enum RulesAt { /// outside a stylesheet. Those calls become Devup UI's, which read a number as its /// spacing scale, so it is rewritten to the `px` string the library makes of it. #[derive(Default)] -struct LibraryNumbers<'n> { - /// Local name of each style function and where its rules are - calls: Vec<(&'n str, RulesAt)>, - /// Local names that build styled components - styled: Vec<&'n str>, - /// Local names of components taking rules in `styles` (Emotion's `Global`) - components: Vec<&'n str>, +struct LibraryNumbers { + /// Import binding of each style function and where its rules are + calls: Vec<(SymbolId, RulesAt)>, + /// Import bindings that build styled components + styled: Vec, + /// Import bindings taking rules in `styles` (Emotion's `Global`) + components: Vec, + /// Which elements take the `css` prop, whose rules are Emotion's + css_prop: CssProp, + /// Import bindings of Devup UI's exports, which take the `css` prop as tags do + devup: Vec, + /// Import bindings of the functions building elements from a type and props + jsx: Vec, + /// Import bindings of Emotion's `ClassNames`, whose child function takes a + /// `css` taking rules + class_names: Vec, + scoping: Scoping, replacements: Vec<(usize, usize, String)>, } -impl LibraryNumbers<'_> { +impl LibraryNumbers { + fn symbol(&self, reference: &IdentifierReference<'_>) -> Option { + self.scoping + .get_reference(reference.reference_id.get()?) + .symbol_id() + } + + fn contains(&self, symbols: &[SymbolId], reference: &IdentifierReference<'_>) -> bool { + self.symbol(reference) + .is_some_and(|symbol| symbols.contains(&symbol)) + } + /// `styled.div`, `styled(tag)`, `styled(tag, options)` and their /// `.attrs()` / `.withConfig()`: whatever a call of it passes are rules fn is_styled_factory(&self, callee: &Expression) -> bool { match callee { Expression::StaticMemberExpression(member) => { - matches!(&member.object, Expression::Identifier(root) if self.styled.contains(&root.name.as_str())) + matches!(&member.object, Expression::Identifier(root) if self.contains(&self.styled, root)) } Expression::CallExpression(call) => match &call.callee { - Expression::Identifier(root) => self.styled.contains(&root.name.as_str()), + Expression::Identifier(root) => self.contains(&self.styled, root), Expression::StaticMemberExpression(member) => { matches!(member.property.name.as_str(), "attrs" | "withConfig") && self.is_styled_factory(&member.object) @@ -95,6 +124,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) { @@ -126,14 +215,31 @@ impl LibraryNumbers<'_> { } } -impl<'a> Visit<'a> for LibraryNumbers<'_> { +impl<'a> Visit<'a> for LibraryNumbers { + fn visit_jsx_element(&mut self, element: &JSXElement<'a>) { + let css = match &element.opening_element.name { + JSXElementName::IdentifierReference(name) if self.contains(&self.class_names, name) => { + class_names_css_symbol(element) + } + _ => 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 - .calls - .iter() - .find(|(name, _)| *name == callee.name.as_str()) - .map(|(_, rules_at)| *rules_at), + Expression::Identifier(callee) => self.symbol(callee).and_then(|symbol| { + self.calls + .iter() + .find(|(binding, _)| *binding == symbol) + .map(|(_, rules_at)| *rules_at) + }), callee => self .is_styled_factory(callee) .then_some(RulesAt::EveryArgument), @@ -155,31 +261,67 @@ impl<'a> Visit<'a> for LibraryNumbers<'_> { for rules in rules { self.pixelify(rules); } + if let Expression::Identifier(callee) = &call.callee + && self.contains(&self.jsx, callee) + && let [element, Argument::ObjectExpression(props), ..] = call.arguments.as_slice() + && let Some(element) = element.as_expression() + && self.css_prop.takes_type(element, |_| { + root_reference(crate::utils::unwrap_syntax_only(element)) + .is_some_and(|root| self.contains(&self.devup, 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); } fn visit_jsx_opening_element(&mut self, element: &JSXOpeningElement<'a>) { if let JSXElementName::IdentifierReference(name) = &element.name - && self.components.contains(&name.name.as_str()) + && self.contains(&self.components, name) { - 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, |_| { + crate::imported_constants::jsx_root_identifier(&element.name) + .is_some_and(|root| self.contains(&self.devup, root)) + }) { + self.pixelify_attribute(element, "css"); } walk_jsx_opening_element(self, element); } } +/// The binding of the child callback's `css`, not another callback's same name. +fn class_names_css_symbol(element: &JSXElement<'_>) -> Option { + let container = element.children.iter().find_map(|child| match child { + JSXChild::ExpressionContainer(container) => Some(container), + _ => None, + })?; + let params = match container.expression.as_expression()? { + Expression::ArrowFunctionExpression(arrow) => &arrow.params, + Expression::FunctionExpression(function) => &function.params, + _ => return None, + }; + let BindingPattern::ObjectPattern(object) = ¶ms.items.first()?.pattern else { + return None; + }; + let css = class_names_child(element)?.css?; + object + .properties + .iter() + .find_map(|property| match &property.value { + BindingPattern::BindingIdentifier(local) if local.name == css => local.symbol_id.get(), + _ => None, + }) +} + /// Map an aliased package's export onto the `@devup-ui/react` export that implements the /// same behaviour, so the extractor consumes the call and drops the import entirely — no /// dependency on either package survives. @@ -207,6 +349,9 @@ fn devup_equivalent(source: &str, imported: &str) -> Option ("@vanilla-extract/css", "globalStyle") => Some(DevupTarget::Main("globalCss")), (_, "keyframes") => Some(DevupTarget::Main("keyframes")), (_, "styled") => Some(DevupTarget::Main("styled")), + // The `css` props it builds compile away, leaving React's own element + ("@emotion/react", "jsx" | "createElement") => Some(DevupTarget::Compat("jsx")), + ("@emotion/react", "ClassNames") => Some(DevupTarget::Compat("ClassNames")), (_, "createGlobalStyle") => Some(DevupTarget::Compat("createGlobalStyle")), (_, "Global") => Some(DevupTarget::Compat("Global")), (_, "ThemeProvider") => Some(DevupTarget::Compat("ThemeProvider")), @@ -226,7 +371,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, } /// A replacement of `code[start..end]` by text of `length` bytes @@ -242,17 +398,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(); @@ -267,12 +434,86 @@ 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"); + let mut scoping = None; + let may_have_numbers = code.bytes().any(|byte| byte.is_ascii_digit()); + let may_take_css = CssProp::of(import_aliases, code, false) != CssProp::Off; + let mut symbol = |local: &BindingIdentifier<'_>| { + if !may_have_numbers { + return None; + } + scoping.get_or_insert_with(|| { + SemanticBuilder::new() + .build(&program) + .semantic + .into_scoping() + }); + local.symbol_id.get() + }; + + // 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 { + if import_decl.import_kind.is_type() { + continue; + } let source_value = import_decl.source.value.as_str(); - - if let Some(alias) = import_aliases.get(source_value) { + uses_emotion |= is_emotion(source_value) + && import_decl.specifiers.as_ref().is_none_or(|specifiers| { + specifiers.iter().any(|specifier| { + !matches!(specifier, ImportDeclarationSpecifier::ImportSpecifier(spec) if spec.import_kind.is_type()) + }) + }); + for specifier in import_decl.specifiers.iter().flatten() { + if matches!(specifier, ImportDeclarationSpecifier::ImportSpecifier(spec) if spec.import_kind.is_type()) + { + continue; + } + match specifier { + ImportDeclarationSpecifier::ImportSpecifier(spec) + if is_jsx_function( + source_value, + &imported_name(&spec.imported), + &compat, + ) && may_take_css => + { + numbers.jsx.extend(symbol(&spec.local)); + } + specifier if source_value == package && may_take_css => { + numbers.devup.extend(symbol(specifier.local())); + } + _ => {} + } + } + if emotion_aliased && let Some(runtime) = react_runtime(source_value) { + let span = import_decl.source.span; + transformations.push(( + span.start as usize, + span.end as usize, + format!("\"{runtime}\""), + )); + } else if let Some(alias) = import_aliases.get(source_value) { let span = import_decl.span; let new_import = generate_transformed_import(import_decl, alias, package, redirect_every_name); @@ -280,20 +521,38 @@ pub fn transform_import_aliases_with_edits<'a>( if !redirect_every_name { for specifier in import_decl.specifiers.iter().flatten() { match specifier { - ImportDeclarationSpecifier::ImportSpecifier(spec) => { - let local = spec.local.name.as_str(); + ImportDeclarationSpecifier::ImportSpecifier(spec) + if !spec.import_kind.is_type() => + { match (source_value, imported_name(&spec.imported).as_ref()) { ("@vanilla-extract/css", "style" | "keyframes") => { - numbers.calls.push((local, RulesAt::Argument(0))); + numbers.calls.extend( + symbol(&spec.local) + .map(|local| (local, RulesAt::Argument(0))), + ); } ("@vanilla-extract/css", "globalStyle") => { - numbers.calls.push((local, RulesAt::Argument(1))); + numbers.calls.extend( + symbol(&spec.local) + .map(|local| (local, RulesAt::Argument(1))), + ); } ( "@emotion/react" | "styled-components", "css" | "keyframes", - ) => numbers.calls.push((local, RulesAt::EveryArgument)), - ("@emotion/react", "Global") => numbers.components.push(local), + ) => numbers.calls.extend( + symbol(&spec.local) + .map(|local| (local, RulesAt::EveryArgument)), + ), + ("@emotion/react", "Global") => { + numbers.components.extend(symbol(&spec.local)); + } + ("@emotion/react", "ClassNames") => { + numbers.class_names.extend(symbol(&spec.local)); + } + ("@emotion/styled" | "styled-components", "styled") => { + numbers.styled.extend(symbol(&spec.local)); + } _ => {} } } @@ -303,7 +562,7 @@ pub fn transform_import_aliases_with_edits<'a>( "@emotion/styled" | "styled-components" ) => { - numbers.styled.push(spec.local.name.as_str()); + numbers.styled.extend(symbol(&spec.local)); } ImportDeclarationSpecifier::ImportNamespaceSpecifier(spec) if matches!( @@ -311,7 +570,7 @@ pub fn transform_import_aliases_with_edits<'a>( "@emotion/styled" | "styled-components" ) => { - numbers.styled.push(spec.local.name.as_str()); + numbers.styled.extend(symbol(&spec.local)); } _ => {} } @@ -320,15 +579,23 @@ pub fn transform_import_aliases_with_edits<'a>( } } } - if !(numbers.calls.is_empty() && numbers.styled.is_empty() && numbers.components.is_empty()) { + numbers.scoping = scoping.unwrap_or_default(); + 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 @@ -340,7 +607,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, + } } /// The source offset of `offset` in code `edits` made; an offset inside a @@ -510,6 +781,16 @@ fn generate_transformed_import( if let ImportDeclarationSpecifier::ImportSpecifier(spec) = specifier { let local = spec.local.name.as_str(); let imported = imported_name(&spec.imported); + if spec.import_kind.is_type() { + if !retained.is_empty() { + retained.push_str(", "); + } + retained.push_str("type "); + let mut named = String::new(); + push_specifier(&mut named, &imported, local); + retained.push_str(&named); + continue; + } match redirect_target(source, &imported, redirect_every_name) { Some(target) => push_redirect(&mut redirected, &mut compat, target, local), None => push_specifier(&mut retained, &imported, local), @@ -561,6 +842,10 @@ fn generate_transformed_import( result } +#[cfg(test)] +#[path = "import_alias_scoping_tests.rs"] +mod scoping_tests; + #[cfg(test)] mod tests { use super::*; @@ -1027,4 +1312,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..997ac0dd1 100644 --- a/libs/extractor/src/imported_constants.rs +++ b/libs/extractor/src/imported_constants.rs @@ -2,14 +2,15 @@ //! module declares with `const` becomes a static class instead of a CSS //! variable set at runtime. -use std::cell::Cell; +use std::cell::{Cell, OnceCell}; use std::collections::BTreeSet; use std::rc::Rc; use oxc_allocator::{Allocator, FromIn, GetAllocator}; use oxc_ast::ast::{ - Argument, ArrayExpressionElement, Expression, ImportDeclarationSpecifier, JSXAttributeItem, - JSXElementName, ObjectPropertyKind, Program, Statement, Str, VariableDeclarationKind, + Argument, ArrayExpressionElement, Expression, IdentifierReference, ImportDeclarationSpecifier, + JSXAttributeItem, JSXElementName, ObjectPropertyKind, Program, Statement, Str, + VariableDeclarationKind, }; use oxc_ast::builder::AstBuilder; use oxc_ast_visit::{Visit, VisitMut, walk, walk_mut}; @@ -21,8 +22,21 @@ 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, binding_of, reads_top_level, root_reference}; +use crate::extractor::ExtractResult; +use crate::extractor::extract_style_from_expression::{ + LiteralHandling, extract_style_from_expression, +}; use crate::stylex::StylexFunction; -use crate::{ExtractOption, ModuleResolver}; +use crate::{ExtractOption, ExtractStyleValue, ModuleResolver}; + +mod initialization; +mod lexical; +#[cfg(test)] +mod require_tests; +#[cfg(test)] +mod safety_tests; #[derive(Clone, Debug)] enum Constant { @@ -44,8 +58,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), } @@ -71,7 +86,11 @@ impl Constant { fn js_literal(&self) -> Option { match self { Self::String(text) => serde_json::to_string(text).ok(), - Self::Number(number) => Some(crate::utils::js_number_string(*number)), + Self::Number(number) => Some(if number.to_bits() == (-0.0_f64).to_bits() { + "-0".to_string() + } else { + crate::utils::js_number_string(*number) + }), Self::Null => Some("null".to_string()), Self::Bool(value) => Some(value.to_string()), Self::Undefined => Some("undefined".to_string()), @@ -151,11 +170,18 @@ pub(crate) enum ChangeSite { /// other modules, by the name the program binds them to #[derive(Default)] pub(crate) struct Inlined { + pub errors: Vec<(u32, String)>, 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, + /// The semantic analysis of the program as parsed, so the visitor reuses + /// it: constants inlined later leave the ids of surviving references as + /// they are. `None` when no analysis was needed + pub scoping: Option>, } /// Bindings styles read that hold an object or array code changes, whole or @@ -172,11 +198,17 @@ impl Changed { } /// Whether `expression` (`x`, `x.y` or `x[y]`) reads, whole or in part, an - /// object or array code changes - pub(crate) fn read_by(&self, expression: &Expression<'_>) -> bool { + /// object or array code changes, counting only the identifiers + /// `reads_binding` accepts, so a local named like a binding of the module + /// does not + pub(crate) fn read_by_in( + &self, + expression: &Expression<'_>, + reads_binding: &dyn Fn(&IdentifierReference<'_>) -> bool, + ) -> bool { let mut path = Vec::new(); let mut expression = expression; - let name = loop { + let identifier = loop { match expression { Expression::StaticMemberExpression(member) => { path.push(Some(member.property.name.as_str())); @@ -186,10 +218,14 @@ impl Changed { path.push(None); expression = &member.object; } - Expression::Identifier(identifier) => break identifier.name.as_str(), + Expression::Identifier(identifier) => break identifier, _ => return false, } }; + let name = identifier.name.as_str(); + if !reads_binding(identifier) { + return false; + } if self.whole.contains_key(name) { return true; } @@ -252,6 +288,16 @@ impl Unknown { /// Whether `expression` (`x`, `x.y.z`, `x[y]` or a call of one) reads what /// only running the module gives pub(crate) fn read_by(&self, expression: &Expression<'_>) -> bool { + self.read_by_in(expression, &|_| true) + } + + /// [`Self::read_by`] counting only the identifiers `reads_binding` + /// accepts, so a local named like a binding of the module does not + pub(crate) fn read_by_in( + &self, + expression: &Expression<'_>, + reads_binding: &dyn Fn(&IdentifierReference<'_>) -> bool, + ) -> bool { let mut path = Vec::new(); let mut expression = expression; loop { @@ -261,12 +307,19 @@ impl Unknown { expression = &member.object; } Expression::ComputedMemberExpression(member) => { - return crate::utils::binding_root(&member.object).is_some_and(|name| { - self.names.contains(name) || self.partial.contains_key(name) + return root_reference(&member.object).is_some_and(|identifier| { + reads_binding(identifier) + && (self.names.contains(identifier.name.as_str()) + || self.partial.contains_key(identifier.name.as_str())) }); } - Expression::CallExpression(call) => return self.read_by(&call.callee), + Expression::CallExpression(call) => { + return self.read_by_in(&call.callee, reads_binding); + } Expression::Identifier(identifier) => { + if !reads_binding(identifier) { + return false; + } let name = identifier.name.as_str(); if self.names.contains(name) { return true; @@ -305,51 +358,114 @@ enum Imported { } /// Inline the primitive constants `program` reads in styles, its own -/// module-level `const`s and those it imports, returning the files read +/// module-level `const`s and those it imports, returning the files read. +/// A program with no style import and no `css` prop is left as it is, with no +/// semantic analysis. pub(crate) fn inline_constants<'a>( ast_builder: &AstBuilder<'a>, program: &mut Program<'a>, filename: &str, option: &ExtractOption, resolver: Option<&ModuleResolver>, + css_prop: CssProp, +) -> Inlined { + let imports_style_package = program.body.iter().any(|statement| { + matches!(statement, Statement::ImportDeclaration(import) + if is_style_package(option, &import.source.value)) + }); + if !imports_style_package && css_prop == CssProp::Off { + return Inlined::default(); + } + let scoping = Rc::new( + SemanticBuilder::new() + .build(program) + .semantic + .into_scoping(), + ); + let mut inlined = inline_in( + &scoping, + ast_builder, + program, + filename, + option, + resolver, + css_prop, + ); + inlined.scoping = Some(scoping); + inlined +} + +fn is_style_package(option: &ExtractOption, source: &str) -> bool { + source.starts_with(&option.package) || source == crate::STYLEX_PACKAGE +} + +fn inline_in<'a>( + scoping: &Scoping, + ast_builder: &AstBuilder<'a>, + program: &mut Program<'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 compat = format!("{}/compat", option.package); + let css_props = CssTakers::new(program, scoping, css_prop, &compat); + let mut style = StyleSymbols::new(scoping); for statement in &program.body { if let Statement::ImportDeclaration(import) = statement - && is_style_package(&import.source.value) + && is_style_package(option, &import.source.value) { let stylex = import.source.value == crate::STYLEX_PACKAGE; for specifier in import.specifiers.iter().flatten() { - let local = specifier.local().name.as_str(); - style_roots.insert(local); - match specifier { - ImportDeclarationSpecifier::ImportSpecifier(specifier) => { - if takes_style_objects(stylex, &specifier.imported.name()) { - apis.functions.insert(local); + if let Some(local) = specifier.local().symbol_id.get() { + style.roots.insert(local); + match specifier { + ImportDeclarationSpecifier::ImportSpecifier(specifier) => { + if takes_style_objects(stylex, &specifier.imported.name()) { + style.functions.insert(local); + } + } + _ => { + style.namespaces.insert(local, stylex); } - } - _ => { - apis.namespaces.insert(local, stylex); } } } } } - if style_roots.is_empty() { - return Inlined::default(); - } let mut read = StyleReads { - style_roots: &style_roots, + style: &style, + css_props: &css_props, names: FxHashSet::default(), + symbols: FxHashSet::default(), + references: FxHashSet::default(), depth: 0, + class_names: Vec::new(), }; read.visit_program(program); - if read.names.is_empty() { + let initialization = initialization::Initialization::new(program, scoping); + let declarations = lexical::declarations(ast_builder, program, scoping); + loop { + let before = read.symbols.len(); + for (symbol, init) in &declarations { + if read.symbols.contains(symbol) { + read.reading(true, |read| read.visit_expression(init)); + } + } + if read.symbols.len() == before { + break; + } + } + if read.names.is_empty() && read.symbols.is_empty() { return Inlined::default(); } + let mut inlined = Inlined { + errors: initialization.errors(&read.references, scoping), + ..Inlined::default() + }; + if !inlined.errors.is_empty() { + return inlined; + } let mut modules = Modules { resolver, option, @@ -357,17 +473,21 @@ pub(crate) fn inline_constants<'a>( loading: Vec::new(), }; let mut symbols: FxHashMap = FxHashMap::default(); - let mut inlined = Inlined::default(); - let (scoping, reads_math) = { + let reads_math = { let mut scope = ModuleScope::new(filename, program, None); - scope - .style_names - .extend(style_roots.iter().map(ToString::to_string)); + scope.style_names.extend( + style + .roots + .iter() + .map(|symbol| scoping.symbol_name(*symbol).to_string()), + ); + scope.css_prop = Some((css_prop, &compat)); + scope.shared_scoping = Some(scoping); let mut bindings: FxHashMap<&str, Vec<&Cell>>> = FxHashMap::default(); for statement in &program.body { let declaration = match statement { Statement::ImportDeclaration(import) => { - if !is_style_package(&import.source.value) { + if !is_style_package(option, &import.source.value) { scope.import(import); for specifier in import.specifiers.iter().flatten() { let local = specifier.local(); @@ -421,16 +541,15 @@ pub(crate) fn inline_constants<'a>( } } } - // Scoping is only worth building when a style reads a name that may - // hold a constant + // Constants are only worth reading when a style reads a name that may + // hold one let reads_math = read.names.contains("Math") && !scope.binds("Math"); - if !reads_math && !read.names.iter().any(|name| scope.binds(name)) { + if !reads_math + && read.symbols.is_empty() + && !read.names.iter().any(|name| scope.binds(name)) + { return Inlined::default(); } - let scoping = SemanticBuilder::new() - .build(program) - .semantic - .into_scoping(); for name in &read.names { let bound = scope.binds(name); let constant = scope.lookup(&mut modules, name); @@ -471,24 +590,75 @@ pub(crate) fn inline_constants<'a>( Constant::Theme(class) => { inlined.stylex_themes.insert(name.clone(), class.clone()); } + Constant::Style(Some(styles)) => { + inlined + .css_styles + .insert(name.clone(), styles.as_ref().clone()); + } _ => {} } for symbol in bindings.get(name.as_str()).into_iter().flatten() { symbols.extend(symbol.get().map(|symbol| (symbol, constant.clone()))); } } - (scoping, reads_math) + reads_math }; inlined.dependencies = modules.exports.into_keys().collect(); + let mut pending = declarations; + pending.retain(|symbol, _| { + read.symbols.contains(symbol) && scoping.symbol_scope_id(*symbol) != scoping.root_scope_id() + }); + loop { + let inline = Inline { + ast_builder, + scoping, + initialization: &initialization, + symbols: &symbols, + style: &style, + css_props: &css_props, + objects: false, + styles: false, + px: false, + class_names: Vec::new(), + }; + let resolved: Vec<_> = pending + .iter() + .filter_map(|(symbol, init)| { + let value = inline.operand(init)?; + if matches!(&value, Constant::Number(number) if !number.is_finite()) { + return None; + } + matches!( + value, + Constant::String(_) + | Constant::Number(_) + | Constant::Null + | Constant::Bool(_) + | Constant::Undefined + ) + .then_some((*symbol, value)) + }) + .collect(); + if resolved.is_empty() { + break; + } + for (symbol, value) in resolved { + pending.remove(&symbol); + symbols.insert(symbol, value); + } + } if !symbols.is_empty() || reads_math { Inline { ast_builder, - scoping: &scoping, + scoping, + initialization: &initialization, symbols: &symbols, - style_roots: &style_roots, - apis: &apis, + style: &style, + css_props: &css_props, objects: false, styles: false, + px: false, + class_names: Vec::new(), } .visit_program(program); } @@ -567,12 +737,17 @@ impl<'p, 'a, 'r> ChangeCheck<'p, 'a, 'r> { } } -/// The local names of the style APIs that read style objects at build time -#[derive(Default)] -struct StyleApis<'s> { - functions: FxHashSet<&'s str>, +/// What the imports of the style packages bind, told by the binding an +/// identifier reads and not by its spelling: a local named like an import is +/// not a style API +struct StyleSymbols<'s> { + scoping: &'s Scoping, + /// Everything the packages give + roots: FxHashSet, + /// The style APIs that read style objects at build time + functions: FxHashSet, /// Namespace and default imports, `true` for `StyleX` - namespaces: FxHashMap<&'s str, bool>, + namespaces: FxHashMap, } fn takes_style_objects(stylex: bool, export: &str) -> bool { @@ -587,7 +762,37 @@ fn takes_style_objects(stylex: bool, export: &str) -> bool { } } -impl StyleApis<'_> { +impl<'s> StyleSymbols<'s> { + fn new(scoping: &'s Scoping) -> Self { + Self { + scoping, + roots: FxHashSet::default(), + functions: FxHashSet::default(), + namespaces: FxHashMap::default(), + } + } + + /// Whether `identifier` reads something a style package gives + fn has(&self, identifier: &IdentifierReference<'_>) -> bool { + binding_of(self.scoping, identifier).is_some_and(|symbol| self.roots.contains(&symbol)) + } + + /// Whether `expression` is a style API: a root the package gives, or a + /// member or call of one + fn is_root(&self, expression: &Expression<'_>) -> bool { + match expression { + Expression::Identifier(identifier) => self.has(identifier), + Expression::StaticMemberExpression(member) => self.is_root(&member.object), + Expression::CallExpression(call) => self.is_root(&call.callee), + _ => false, + } + } + + /// Whether `name` is a component of the packages: `` or `` + fn is_component(&self, name: &JSXElementName<'_>) -> bool { + jsx_root_identifier(name).is_some_and(|identifier| self.has(identifier)) + } + /// Whether calling `callee` reads its arguments as style objects: /// `css(...)`, `styled.div(...)`, `styled(Link).attrs(...)`, /// `stylex.create(...)` @@ -597,11 +802,12 @@ impl StyleApis<'_> { loop { match crate::utils::unwrap_syntax_only(expression) { Expression::Identifier(identifier) => { - let name = identifier.name.as_str(); - return self.functions.contains(name) - || self.namespaces.get(name).is_some_and(|stylex| { - member.is_some_and(|member| takes_style_objects(*stylex, member)) - }); + return binding_of(self.scoping, identifier).is_some_and(|symbol| { + self.functions.contains(&symbol) + || self.namespaces.get(&symbol).is_some_and(|stylex| { + member.is_some_and(|member| takes_style_objects(*stylex, member)) + }) + }); } Expression::StaticMemberExpression(inner) => { member = Some(inner.property.name.as_str()); @@ -619,27 +825,18 @@ impl StyleApis<'_> { /// Names read inside the props of the package's components and the arguments /// of its functions struct StyleReads<'s> { - style_roots: &'s FxHashSet<&'s str>, + style: &'s StyleSymbols<'s>, + css_props: &'s CssTakers<'s>, names: FxHashSet, + symbols: FxHashSet, + references: FxHashSet, depth: usize, -} - -/// Whether `expression` is a style API: a root the package gives, or a member -/// or call of one -fn is_style_root(style_roots: &FxHashSet<&str>, expression: &Expression<'_>) -> bool { - match expression { - Expression::Identifier(identifier) => style_roots.contains(identifier.name.as_str()), - Expression::StaticMemberExpression(member) => is_style_root(style_roots, &member.object), - Expression::CallExpression(call) => is_style_root(style_roots, &call.callee), - _ => false, - } + /// The bindings the `` child functions around take `css` and + /// `cx` by + class_names: Vec, } impl StyleReads<'_> { - fn is_style_root(&self, expression: &Expression<'_>) -> bool { - is_style_root(self.style_roots, expression) - } - fn reading(&mut self, style: bool, walk: impl FnOnce(&mut Self) -> T) -> T { self.depth += usize::from(style); let result = walk(self); @@ -649,18 +846,46 @@ impl StyleReads<'_> { } impl<'a> Visit<'a> for StyleReads<'_> { - fn visit_identifier_reference(&mut self, identifier: &oxc_ast::ast::IdentifierReference<'a>) { + fn visit_identifier_reference(&mut self, identifier: &IdentifierReference<'a>) { if self.depth > 0 { - self.names.insert(identifier.name.to_string()); + self.references.extend(identifier.reference_id.get()); + self.symbols + .extend(binding_of(self.style.scoping, identifier)); + if reads_top_level(self.style.scoping, identifier) { + self.names.insert(identifier.name.to_string()); + } } } + 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.style.is_root(&call.callee) + || self + .css_props + .calls_class_names(&self.class_names, &call.callee); + let css = self + .css_props + .property(call, |identifier| self.style.has(identifier)); 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,33 +895,42 @@ 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.style.is_root(&tagged.tag) + || self + .css_props + .calls_class_names(&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); + let style = self.style.is_component(&element.name); + for attribute in &element.attributes { + let style = style + || self + .css_props + .attribute(&element.name, attribute, |identifier| { + self.style.has(identifier) + }); + 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); + } + }); + } } } -/// The name `` or `` starts with -pub(crate) fn jsx_root<'n>(name: &'n JSXElementName<'_>) -> Option<&'n str> { +/// The identifier `` or `` starts with +pub(crate) fn jsx_root_identifier<'n, 'a>( + name: &'n JSXElementName<'a>, +) -> Option<&'n IdentifierReference<'a>> { match name { - JSXElementName::IdentifierReference(identifier) => Some(identifier.name.as_str()), + JSXElementName::IdentifierReference(identifier) => Some(identifier), JSXElementName::MemberExpression(member) => { let mut object = &member.object; while let oxc_ast::ast::JSXMemberExpressionObject::MemberExpression(inner) = object { @@ -704,7 +938,7 @@ pub(crate) fn jsx_root<'n>(name: &'n JSXElementName<'_>) -> Option<&'n str> { } match object { oxc_ast::ast::JSXMemberExpressionObject::IdentifierReference(identifier) => { - Some(identifier.name.as_str()) + Some(identifier) } _ => None, } @@ -713,6 +947,11 @@ pub(crate) fn jsx_root<'n>(name: &'n JSXElementName<'_>) -> Option<&'n str> { } } +/// The name `` or `` starts with +pub(crate) fn jsx_root<'n>(name: &'n JSXElementName<'_>) -> Option<&'n str> { + jsx_root_identifier(name).map(|identifier| identifier.name.as_str()) +} + /// The constant exports of the modules read, by path struct Modules<'r> { resolver: Option<&'r ModuleResolver>, @@ -990,12 +1229,26 @@ struct ModuleScope<'p, 'a> { source: Option<&'p str>, locals: FxHashMap, declarations: FxHashMap>, + enums: FxHashMap>, + enum_members: Option<( + &'p oxc_ast::ast::TSEnumDeclaration<'a>, + FxHashMap, + )>, imports: FxHashMap, 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, + /// and the entry absorbing Emotion's own `jsx` + css_prop: Option<(CssProp, &'p str)>, uses: Option>>>, changes: FxHashMap>>, + /// The semantic analysis the program extracted already has, which the + /// visitor reuses and building another over the same program would reset + shared_scoping: Option<&'p Scoping>, + /// The semantic analysis of a module read, built when a `StyleX` callee + /// first needs the binding it reads told + scoping: OnceCell, } impl<'p, 'a> ModuleScope<'p, 'a> { @@ -1006,11 +1259,16 @@ impl<'p, 'a> ModuleScope<'p, 'a> { source, locals: FxHashMap::default(), declarations: FxHashMap::default(), + enums: FxHashMap::default(), + enum_members: None, imports: FxHashMap::default(), style_imports: FxHashSet::default(), style_names: FxHashSet::default(), + css_prop: None, uses: None, changes: FxHashMap::default(), + shared_scoping: None, + scoping: OnceCell::new(), } } @@ -1032,6 +1290,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 +1373,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_prop, + )); self.uses = Some(uses.clone()); uses }; @@ -1112,8 +1421,13 @@ impl<'p, 'a> ModuleScope<'p, 'a> { fn binds(&self, name: &str) -> bool { self.declarations.contains_key(name) + || self.enums.contains_key(name) || self.imports.contains_key(name) || self.locals.contains_key(name) + || self + .semantic_scoping() + .get_root_binding(name.into()) + .is_some() } fn is_global_math(&self, expression: &Expression<'_>) -> bool { @@ -1121,37 +1435,46 @@ impl<'p, 'a> ModuleScope<'p, 'a> { if identifier.name == "Math" && !self.binds("Math")) } - /// Record the members of an enum whose values are literals, up to the - /// first that is computed, returning its name - fn declare_enum(&mut self, declaration: &oxc_ast::ast::TSEnumDeclaration<'_>) -> String { + /// Record an enum for lazy evaluation after module bindings are collected. + fn declare_enum(&mut self, declaration: &'p oxc_ast::ast::TSEnumDeclaration<'a>) -> String { let name = declaration.id.name.to_string(); - if declaration.declare { - return name; + if !declaration.declare { + self.enums.insert(name.clone(), declaration); } + name + } + + fn evaluate_enum( + &mut self, + modules: &mut Modules<'_>, + declaration: &'p oxc_ast::ast::TSEnumDeclaration<'a>, + ) -> Constant { + let name = declaration.id.name.to_string(); let mut members = FxHashMap::default(); let mut next = Some(0.0); + let outer = self.enum_members.take(); for member in &declaration.body.members { + self.locals + .insert(name.clone(), Constant::Object(Rc::new(members.clone()))); + self.enum_members = Some((declaration, members.clone())); let value = match &member.initializer { None => next.map(Constant::Number), - Some(Expression::StringLiteral(literal)) => { - Some(Constant::String(literal.value.to_string())) - } - Some(initializer) => { - crate::utils::js_number_literal(initializer).map(Constant::Number) - } + Some(initializer) => self.evaluate(modules, initializer), }; - let Some(value) = value else { + let Some(value @ (Constant::String(_) | Constant::Number(_))) = value else { break; }; + if matches!(&value, Constant::Number(number) if !number.is_finite()) { + break; + } next = match &value { Constant::Number(number) => Some(number + 1.0), _ => None, }; members.insert(member.id.static_name().to_string(), value); } - self.locals - .insert(name.clone(), Constant::Object(Rc::new(members))); - name + self.enum_members = outer; + Constant::Object(Rc::new(members)) } fn import(&mut self, import: &oxc_ast::ast::ImportDeclaration<'_>) { @@ -1182,7 +1505,13 @@ impl<'p, 'a> ModuleScope<'p, 'a> { else { continue; }; - if callee.name != "require" { + let scoping = self.semantic_scoping(); + if callee.name != "require" + || callee + .reference_id + .get() + .is_none_or(|reference| scoping.get_reference(reference).symbol_id().is_some()) + { continue; } let source = source.value.to_string(); @@ -1241,6 +1570,11 @@ impl<'p, 'a> ModuleScope<'p, 'a> { if let Some(constant) = self.locals.get(name) { return Some(constant.clone()); } + if let Some(declaration) = self.enums.remove(name) { + let value = self.evaluate_enum(modules, declaration); + self.locals.insert(name.to_string(), value.clone()); + return Some(value); + } // Taken out while it is evaluated, so a constant reading itself stops if let Some(init) = self.declarations.remove(name) && let Some(constant) = self.evaluate(modules, init) @@ -1256,25 +1590,45 @@ impl<'p, 'a> ModuleScope<'p, 'a> { } } + /// The `StyleX` API `callee` reads, as the import it reads binds it and + /// not as it is spelled: a local named like an import is not the API fn stylex_function(&self, callee: &Expression<'_>) -> Option { - let (name, member) = match callee { - Expression::Identifier(identifier) => (identifier.name.as_str(), None), + let (identifier, member) = match callee { + Expression::Identifier(identifier) => (identifier, None), Expression::StaticMemberExpression(member) => match &member.object { - Expression::Identifier(object) => { - (object.name.as_str(), Some(member.property.name.as_str())) - } + Expression::Identifier(object) => (object, Some(member.property.name.as_str())), _ => return None, }, _ => return None, }; - let export = match (self.imports.get(name)?, member) { + let export = match (self.imports.get(identifier.name.as_str())?, member) { ((source, _), _) if source != crate::STYLEX_PACKAGE => return None, ((_, Imported::Named(export)), None) => export.as_str(), ((_, Imported::Namespace), Some(export)) => export, ((_, Imported::Named(export)), Some(member)) if export == "default" => member, _ => return None, }; - StylexFunction::from_export_name(export) + let function = StylexFunction::from_export_name(export)?; + self.reads_top_level_binding(identifier).then_some(function) + } + + /// Whether `identifier` reads a binding of the module's top level, where + /// the imports bind, and not a local of a function or block + fn reads_top_level_binding(&self, identifier: &IdentifierReference<'_>) -> bool { + let scoping = self.semantic_scoping(); + binding_of(scoping, identifier) + .is_some_and(|symbol| scoping.symbol_scope_id(symbol) == scoping.root_scope_id()) + } + + fn semantic_scoping(&self) -> &Scoping { + self.shared_scoping.unwrap_or_else(|| { + self.scoping.get_or_init(|| { + SemanticBuilder::new() + .build(self.program) + .semantic + .into_scoping() + }) + }) } /// A value `StyleX` gives when this module's own extraction reads it, with @@ -1352,7 +1706,7 @@ impl<'p, 'a> ModuleScope<'p, 'a> { /// `StyleX` variable, as [`crate::stylex::variable_values`] reads it once /// constants are inlined fn variable_value(&mut self, modules: &mut Modules<'_>, value: &Expression<'_>) -> bool { - let value = crate::stylex::unwrap_types_call(value); + let value = crate::stylex::unwrap_types_call(value, &|callee| self.stylex_function(callee)); if matches!(value, Expression::NullLiteral(_)) || self.literal_text(modules, value).is_some() { @@ -1421,9 +1775,14 @@ impl<'p, 'a> ModuleScope<'p, 'a> { Expression::NullLiteral(_) => Some(Constant::Null), Expression::BooleanLiteral(literal) => Some(Constant::Bool(literal.value)), Expression::Identifier(identifier) - if identifier.name == "undefined" && !self.binds("undefined") => + if matches!(identifier.name.as_str(), "undefined" | "NaN" | "Infinity") + && !self.binds(&identifier.name) => { - Some(Constant::Undefined) + Some(match identifier.name.as_str() { + "NaN" => Constant::Number(f64::NAN), + "Infinity" => Constant::Number(f64::INFINITY), + _ => Constant::Undefined, + }) } Expression::ObjectExpression(object) => Some(self.object(modules, object)), Expression::ArrayExpression(array) => { @@ -1445,9 +1804,24 @@ impl<'p, 'a> ModuleScope<'p, 'a> { } Expression::ComputedMemberExpression(member) => { let key = js_string(&self.evaluate(modules, &member.expression)?)?; + if self.is_global_math(&member.object) { + return math_constant(&key); + } member_of(&self.evaluate(modules, &member.object)?, &key) } - Expression::Identifier(identifier) => self.lookup(modules, &identifier.name), + Expression::Identifier(identifier) => { + if let Some((declaration, members)) = &self.enum_members + && declaration.span.contains_inclusive(identifier.span) + && declaration + .body + .members + .iter() + .any(|member| member.id.static_name() == identifier.name) + { + return members.get(identifier.name.as_str()).cloned(); + } + self.lookup(modules, &identifier.name) + } Expression::StaticMemberExpression(member) if self.is_global_math(&member.object) => { math_constant(member.property.name.as_str()) } @@ -1455,23 +1829,24 @@ impl<'p, 'a> ModuleScope<'p, 'a> { &self.evaluate(modules, &member.object)?, member.property.name.as_str(), ), - Expression::CallExpression(call) => match &call.callee { - Expression::StaticMemberExpression(callee) - if self.is_global_math(&callee.object) => + Expression::CallExpression(call) => { + if let Some(name) = math_member(&call.callee, &|object| self.is_global_math(object)) { let mut arguments = Vec::with_capacity(call.arguments.len()); for argument in &call.arguments { arguments.push(self.evaluate(modules, argument.as_expression()?)?); } - fold_math(callee.property.name.as_str(), &arguments) + return fold_math(&name, &arguments); } - callee if self.is_style_api(modules, callee) => Some(Constant::Style), - _ => self.evaluate_stylex(modules, call), - }, + if self.is_style_api(modules, &call.callee) { + return 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), @@ -1597,61 +1972,50 @@ fn fold_template( } fn math_constant(name: &str) -> Option { - use std::f64::consts; - let value = match name { - "PI" => consts::PI, - "E" => consts::E, - "LN2" => consts::LN_2, - "LN10" => consts::LN_10, - "LOG2E" => consts::LOG2_E, - "LOG10E" => consts::LOG10_E, - "SQRT2" => consts::SQRT_2, - "SQRT1_2" => consts::FRAC_1_SQRT_2, - _ => return None, - }; - Some(Constant::Number(value)) + crate::build_time_values::exact_math::evaluate(name, None).map(Constant::Number) +} + +fn math_member<'a>( + expression: &Expression<'a>, + is_math: &dyn Fn(&Expression<'a>) -> bool, +) -> Option { + match expression { + Expression::StaticMemberExpression(member) if is_math(&member.object) => { + Some(member.property.name.to_string()) + } + Expression::ComputedMemberExpression(member) if is_math(&member.object) => { + crate::utils::get_string_by_literal_expression(&member.expression) + .map(std::borrow::Cow::into_owned) + } + _ => None, + } } /// `Math.{name}(...arguments)`, folded only where every engine computes the /// same result, so the CSS never depends on the platform that builds it fn fold_math(name: &str, arguments: &[Constant]) -> Option { - let mut numbers = Vec::with_capacity(arguments.len()); - for argument in arguments { - let Constant::Number(number) = argument else { + use crate::build_time_values::exact_math::{Operand, evaluate}; + if name == "pow" { + let (Some(Constant::Number(base)), Some(Constant::Number(exponent))) = + (arguments.first(), arguments.get(1)) + else { return None; }; - numbers.push(*number); - } - let first = numbers.first().copied(); - let value = match name { - "abs" => first?.abs(), - "ceil" => first?.ceil(), - "floor" => first?.floor(), - "trunc" => first?.trunc(), - "sqrt" => first?.sqrt(), - "sign" => { - let x = first?; - if x > 0.0 { - 1.0 - } else if x < 0.0 { - -1.0 - } else { - x - } - } - // JavaScript rounds a half up, toward +Infinity, where Rust rounds it - // away from zero; `x - floor(x)` is exact for every double - "round" => { - let x = first?; - let floor = x.floor(); - if x - floor >= 0.5 { floor + 1.0 } else { floor } - } - "max" => numbers.iter().copied().fold(f64::NEG_INFINITY, f64::max), - "min" => numbers.iter().copied().fold(f64::INFINITY, f64::min), - "pow" => exact_power(first?, *numbers.get(1)?)?, - _ => return None, - }; - value.is_finite().then_some(Constant::Number(value)) + return exact_power(*base, *exponent).map(Constant::Number); + } + let mut operands = Vec::with_capacity(arguments.len()); + for argument in arguments { + let operand = match argument { + Constant::Number(number) => Operand::Number(*number), + Constant::String(text) => Operand::String(text), + Constant::Bool(value) => Operand::Bool(*value), + Constant::Null => Operand::Null, + Constant::Undefined => Operand::Undefined, + _ => return None, + }; + operands.push(operand); + } + evaluate(name, Some(&operands)).map(Constant::Number) } /// An integer raised to a whole power, when the result is an exact integer @@ -1692,13 +2056,19 @@ fn fold_binary(operator: BinaryOperator, left: &Constant, right: &Constant) -> O struct Inline<'s, 'a> { ast_builder: &'s AstBuilder<'a>, scoping: &'s Scoping, + initialization: &'s initialization::Initialization, symbols: &'s FxHashMap, - style_roots: &'s FxHashSet<&'s str>, - apis: &'s StyleApis<'s>, + style: &'s StyleSymbols<'s>, + css_props: &'s CssTakers<'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 bindings the `` child functions around take `css` and + /// `cx` by + class_names: Vec, } impl<'a> Inline<'_, 'a> { @@ -1707,6 +2077,9 @@ impl<'a> Inline<'_, 'a> { Expression::Identifier(identifier) => { let reference = identifier.reference_id.get()?; let symbol = self.scoping.get_reference(reference).symbol_id()?; + if !self.initialization.allows(identifier) { + return None; + } self.symbols.get(&symbol).cloned() } Expression::StaticMemberExpression(member) if self.is_global_math(&member.object) => { @@ -1717,30 +2090,23 @@ impl<'a> Inline<'_, 'a> { member.property.name.as_str(), ), Expression::CallExpression(call) => { - let Expression::StaticMemberExpression(callee) = &call.callee else { - return None; - }; - if !self.is_global_math(&callee.object) { - return None; - } + let name = math_member(&call.callee, &|object| self.is_global_math(object))?; let arguments: Option> = call .arguments .iter() .map(|argument| self.operand(argument.as_expression()?)) .collect(); - fold_math(callee.property.name.as_str(), &arguments?) + fold_math(&name, &arguments?) } Expression::ComputedMemberExpression(member) => { let key = js_string(&self.operand(&member.expression)?)?; + if self.is_global_math(&member.object) { + return math_constant(&key); + } member_of(&self.constant(&member.object)?, &key) } // Folded only when they read a constant, leaving other code as written - Expression::TemplateLiteral(template) - if template - .expressions - .iter() - .any(|e| self.constant(e).is_some()) => - { + Expression::TemplateLiteral(template) => { let values: Option> = template .expressions .iter() @@ -1748,16 +2114,11 @@ impl<'a> Inline<'_, 'a> { .collect(); fold_template(template, &values?) } - Expression::BinaryExpression(binary) - if self.constant(&binary.left).is_some() - || self.constant(&binary.right).is_some() => - { - fold_binary( - binary.operator, - &self.operand(&binary.left)?, - &self.operand(&binary.right)?, - ) - } + Expression::BinaryExpression(binary) => fold_binary( + binary.operator, + &self.operand(&binary.left)?, + &self.operand(&binary.right)?, + ), Expression::UnaryExpression(unary) if unary.operator == oxc_syntax::operator::UnaryOperator::UnaryNegation => { @@ -1767,6 +2128,8 @@ impl<'a> Inline<'_, 'a> { } } Expression::ParenthesizedExpression(inner) => self.constant(&inner.expression), + Expression::TSAsExpression(inner) => self.operand(&inner.expression), + Expression::TSSatisfiesExpression(inner) => self.operand(&inner.expression), _ => None, } } @@ -1777,6 +2140,16 @@ impl<'a> Inline<'_, 'a> { Expression::StringLiteral(literal) => Some(Constant::String(literal.value.to_string())), Expression::BooleanLiteral(literal) => Some(Constant::Bool(literal.value)), Expression::NullLiteral(_) => Some(Constant::Null), + Expression::Identifier(identifier) + if binding_of(self.scoping, identifier).is_none() => + { + match identifier.name.as_str() { + "undefined" => Some(Constant::Undefined), + "NaN" => Some(Constant::Number(f64::NAN)), + "Infinity" => Some(Constant::Number(f64::INFINITY)), + _ => None, + } + } _ => crate::utils::js_number_literal(expression).map(Constant::Number), }) } @@ -1837,55 +2210,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 +2227,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) if value.is_finite() => 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 +2330,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,7 +2349,16 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { tagged: &mut oxc_ast::ast::TaggedTemplateExpression<'a>, ) { self.visit_expression(&mut tagged.tag); - let styles = is_style_root(self.style_roots, &tagged.tag); + if self + .css_props + .calls_class_names(&self.class_names, &tagged.tag) + { + self.reading_css(true, |inline| { + inline.visit_template_literal(&mut tagged.quasi); + }); + return; + } + let styles = self.style.is_root(&tagged.tag); self.reading_styles(styles, |inline| { inline.visit_template_literal(&mut tagged.quasi); }); @@ -1940,22 +2370,63 @@ 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); - let objects = self.apis.reads(&call.callee); - let styles = is_style_root(self.style_roots, &call.callee); - self.reading_styles(styles, |inline| { - inline.reading_objects(objects, |inline| { + if self + .css_props + .calls_class_names(&self.class_names, &call.callee) + { + self.reading_css(true, |inline| { for argument in &mut call.arguments { inline.visit_argument(argument); } }); + return; + } + let objects = self.style.reads(&call.callee); + let styles = self.style.is_root(&call.callee); + let css = self + .css_props + .property(call, |identifier| self.style.has(identifier)); + self.reading_styles(styles, |inline| { + inline.reading_objects(objects, |inline| { + for (index, argument) in call.arguments.iter_mut().enumerate() { + match (css, argument) { + (Some(css), Argument::ObjectExpression(props)) if index == 1 => { + for (at, property) in props.properties.iter_mut().enumerate() { + inline.reading_css(at == css, |inline| { + inline.visit_object_property_kind(property); + }); + } + } + (_, argument) => inline.visit_argument(argument), + } + } + }); }); } 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 styled = self.style.is_component(&element.name); + let element_name = &element.name; for attribute in &mut element.attributes { + if self + .css_props + .attribute(element_name, attribute, |identifier| { + self.style.has(identifier) + }) + { + self.reading_css(true, |inline| inline.visit_jsx_attribute_item(attribute)); + continue; + } let objects = styled && match attribute { JSXAttributeItem::Attribute(attribute) => { @@ -1973,9 +2444,30 @@ 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); + } } } + +#[cfg(test)] +mod exact_edge_tests; +#[cfg(test)] +mod exact_math_tests; +#[cfg(test)] +mod exact_tests; +#[cfg(test)] +mod numeric_semantics_tests; +#[cfg(test)] +mod scope_tests; +#[cfg(test)] +mod stylex_scope_tests; +#[cfg(test)] +mod tdz_tests; diff --git a/libs/extractor/src/imported_constants/exact_edge_tests.rs b/libs/extractor/src/imported_constants/exact_edge_tests.rs new file mode 100644 index 000000000..c51bd3a84 --- /dev/null +++ b/libs/extractor/src/imported_constants/exact_edge_tests.rs @@ -0,0 +1,57 @@ +use super::exact_tests::{extracted, static_values}; + +#[test] +#[serial_test::serial] +fn math_members_when_computed_names_are_literal() { + for imported in [false, true] { + let declaration = if imported { + "import { N, C } from './values';" + } else { + "const N = Math['imul'](3, 4); const C = Math['PI'];" + }; + let output = extracted(&format!("import {{ Box }} from '@devup-ui/react'; {declaration} export const view = ;"), "export const N = Math['imul'](3, 4); export const C = Math['PI'];").unwrap_or_else(|error| panic!("math_members_when_computed_names_are_literal: {error}")); + let values = static_values(&output); + assert!(values.contains(&"12".to_string()), "{values:?}"); + assert!( + values.contains(&"3.141592653589793".to_string()), + "{values:?}" + ); + } +} + +#[test] +#[serial_test::serial] +fn math_members_when_names_or_callable_values_are_unknown() { + for expression in [ + "Math[key](1)", + "Math.abs", + "Math.PI(1)", + "Math['random']()", + "Math['UNKNOWN']", + ] { + assert!(extracted("import { css } from '@devup-ui/react'; import { N } from './values'; css({ zIndex: N });", &format!("export const N = {expression};")).is_err(), "{expression}"); + } +} + +#[test] +#[serial_test::serial] +fn imported_values_when_nonfinite_numbers_stay_dynamic() { + let output = extracted("import { Box } from '@devup-ui/react'; import { N } from './values'; export const view = ;", "export const N = Infinity;").unwrap_or_else(|error| panic!("imported_values_when_nonfinite_numbers_stay_dynamic: {error}")); + assert!(output.code.contains("--"), "{}", output.code); +} + +#[test] +#[serial_test::serial] +fn nested_constants_when_undefined_and_negative_numbers_select_styles() { + let output = extracted("import { Box } from '@devup-ui/react'; export function f() { const empty = undefined; const n = -3; const alias = -n; const object = { value: 8 }; return ; }", "").unwrap_or_else(|error| panic!("nested_constants_when_undefined_and_negative_numbers_select_styles: {error}")); + let values = static_values(&output); + assert!(values.contains(&"3".to_string()), "{values:?}"); + assert!(values.contains(&"green".to_string()), "{values:?}"); +} + +#[test] +#[serial_test::serial] +fn enums_when_prior_siblings_include_exact_math() { + let output = extracted("import { css } from '@devup-ui/react'; import { E } from './values'; css({ zIndex: E.C });", "enum Other { A = 4, B = A + 1 } export enum E { A = Math.imul(2, 3), B = Other.B + A, C = E.B + 1 }").unwrap_or_else(|error| panic!("enums_when_prior_siblings_include_exact_math: {error}")); + assert_eq!(static_values(&output), vec!["12".to_string()]); +} diff --git a/libs/extractor/src/imported_constants/exact_math_tests.rs b/libs/extractor/src/imported_constants/exact_math_tests.rs new file mode 100644 index 000000000..60f97628d --- /dev/null +++ b/libs/extractor/src/imported_constants/exact_math_tests.rs @@ -0,0 +1,69 @@ +use super::exact_tests::{extracted, static_values}; + +#[test] +#[serial_test::serial] +fn fn09_math_when_local_and_imported_values_are_exact() { + for (expression, expected) in [ + ("Math.abs(-2)", "2"), + ("Math.ceil(1.2)", "2"), + ("Math.floor(1.8)", "1"), + ("Math.round(-2.5)", "-2"), + ("Math.trunc(-1.8)", "-1"), + ("Math.sign(-2)", "-1"), + ("Math.max(1, 3, 2)", "3"), + ("Math.min(1, -3, 2)", "-3"), + ("Math.sqrt(4)", "2"), + ("Math.fround(16777217)", "16777216"), + ("Math.imul(4294967295, 5)", "-5"), + ("Math.clz32(0)", "32"), + ("Math.PI", "3.141592653589793"), + ("Math.E", "2.718281828459045"), + ("Math.LN2", "0.6931471805599453"), + ("Math.LN10", "2.302585092994046"), + ("Math.LOG2E", "1.4426950408889634"), + ("Math.LOG10E", "0.4342944819032518"), + ("Math.SQRT2", "1.4142135623730951"), + ("Math.SQRT1_2", "0.7071067811865476"), + ("Math.imul(2147483647, 2)", "-2"), + ("Math.imul(3.9, 2.9)", "6"), + ("Math.clz32(-1)", "0"), + ("Math.clz32(4294967297)", "31"), + ("Math.fround(0.1)", "0.10000000149011612"), + ("Math.round(-0.5)", "0"), + ("Math.sign(-0)", "0"), + ("Math.max(-0, 0)", "0"), + ("Math.min(0, -0)", "0"), + ("Math.imul('3.9', true)", "3"), + ("Math.clz32(null)", "32"), + ("Math.clz32(undefined)", "32"), + ("Math.clz32(NaN)", "32"), + ("Math.clz32(Infinity)", "32"), + ("Math.max('1', 2)", "2"), + ] { + for imported in [false, true] { + let module = format!("export const VALUE = {expression};"); + let binding = if imported { + "import { VALUE } from './values';".to_string() + } else { + format!("function compute() {{ return {expression}; }} const VALUE = compute();") + }; + let output = extracted(&format!("import {{ css }} from '@devup-ui/react'; {binding} export const s = css({{ zIndex: VALUE }});"), &module).unwrap_or_else(|error| panic!("{expression}, imported={imported}: {error}")); + assert_eq!( + static_values(&output) + .iter() + .map(|value| value + .parse::() + .unwrap_or_else(|error| panic!("parse f64: {error}")) + .to_bits()) + .collect::>(), + vec![ + expected + .parse::() + .unwrap_or_else(|error| panic!("parse expected f64: {error}")) + .to_bits() + ], + "{expression}, imported={imported}" + ); + } + } +} diff --git a/libs/extractor/src/imported_constants/exact_tests.rs b/libs/extractor/src/imported_constants/exact_tests.rs new file mode 100644 index 000000000..da39d556b --- /dev/null +++ b/libs/extractor/src/imported_constants/exact_tests.rs @@ -0,0 +1,209 @@ +use crate::{ExtractOption, ExtractOutput, ExtractStyleValue, ResolvedModule}; + +pub(super) fn extracted(code: &str, module: &str) -> Result { + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + let module = module.to_string(); + let resolver = move |specifier: &str, _: &str| { + (specifier == "./values").then(|| ResolvedModule { + path: "/src/values.ts".to_string(), + code: module.clone(), + }) + }; + crate::extract_with_modules( + "/src/App.tsx", + code, + ExtractOption { + import_main_css: false, + ..ExtractOption::default() + }, + false, + &resolver, + ) + .map_err(|error| error.to_string()) +} + +pub(super) fn static_values(output: &ExtractOutput) -> Vec { + output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Static(style) => Some(style.value.clone()), + _ => None, + }) + .collect() +} + +#[test] +#[serial_test::serial] +fn fn08_computed_enum_when_preceding_members_are_exact() { + let module = "const STEP = 3; export enum E { A = 2, B = A + STEP, C = E.B + 1, D, Text = 'gr' + 'een' }"; + let output = extracted("import { css } from '@devup-ui/react'; import { E } from './values'; export const s = css({ zIndex: E.B, order: E.C, flexGrow: E.D, color: E.Text });", module).unwrap_or_else(|error| panic!("fn08_computed_enum_when_preceding_members_are_exact: {error}")); + let values = static_values(&output); + for expected in ["5", "6", "7", "green"] { + assert!(values.contains(&expected.to_string()), "{values:?}"); + } +} + +#[test] +#[serial_test::serial] +fn fn08_enum_when_a_member_is_uncertain_or_ambient() { + for module in [ + "export enum E { A = unknown(), B = A + 3, C = 8 }", + "export declare enum E { A = 2, B = 5 }", + "export enum E { A = B + 1, B = 5 }", + "const B = 8; export enum E { A = B + 1, B = 5 }", + "export enum E { A = 'green', B }", + ] { + assert!(extracted("import { css } from '@devup-ui/react'; import { E } from './values'; css({ zIndex: E.B });", module).is_err(), "{module}"); + } +} + +#[test] +#[serial_test::serial] +fn jsx12_primitives_when_lexical_bindings_share_names() { + let output = extracted("import { Box } from '@devup-ui/react'; const color = 'red'; export function f() { const color = 'green'; const n = Math.imul(2, 3); const alias = n + 1; observe(color, alias); return ; } export const g = () => { const color = 'blue'; return ; }; export const top = ;", "").unwrap_or_else(|error| panic!("jsx12_primitives_when_lexical_bindings_share_names: {error}")); + let values = static_values(&output); + for expected in ["red", "green", "blue", "7"] { + assert!(values.contains(&expected.to_string()), "{values:?}"); + } + assert!(!output.code.contains("--"), "{}", output.code); + assert!( + output.code.contains("observe(color, alias)"), + "{}", + output.code + ); +} + +#[test] +#[serial_test::serial] +fn jsx12_primitives_when_nested_scopes_read_imports_and_outer_constants() { + let output = extracted("import { Box } from '@devup-ui/react'; import { BASE } from './values'; const STEP = 2; export function f() { const n = BASE + STEP; { const n = 9; observe(n); } const value = (`${n}px` as const) satisfies string; return ; }", "export const BASE = 5;").unwrap_or_else(|error| panic!("jsx12_primitives_when_nested_scopes_read_imports_and_outer_constants: {error}")); + assert_eq!( + static_values(&output), + vec!["7px".to_string(), "7".to_string()] + ); + assert_eq!(output.dependencies, vec!["/src/values.ts".to_string()]); +} + +#[test] +#[serial_test::serial] +fn jsx12_constants_when_reads_precede_initialization_or_write_the_binding() { + for body in [ + "const view = ; var color = 'red'; return view;", + "const color = 'red'; color = 'blue'; return ;", + "const a = compute(); const b = a; return ;", + ] { + let output = extracted( + &format!("import {{ Box }} from '@devup-ui/react'; export function f() {{ {body} }}"), + "", + ) + .unwrap_or_else(|error| { + panic!( + "jsx12_constants_when_reads_precede_initialization_or_write_the_binding: {error}" + ) + }); + assert!(output.code.contains("--"), "{body}: {}", output.code); + } +} + +#[test] +#[serial_test::serial] +fn fn09_math_when_results_or_functions_are_uncertain() { + for expression in [ + "Math.sin(1)", + "Math.random()", + "Math.sqrt(-1)", + "Math.max()", + "Math.min()", + "Math.fround(1e100)", + "Math.NOPE(1)", + "Math.imul(unknown(), 2)", + ] { + let module = format!("export const VALUE = {expression};"); + assert!(extracted("import { css } from '@devup-ui/react'; import { VALUE } from './values'; css({ zIndex: VALUE });", &module).is_err(), "{expression}"); + } + for binding in [ + "let Math = source;", + "function Math() {}", + "const Math = { imul: unknown };", + "const undefined = unknown;", + ] { + let module = format!("{binding} export const VALUE = Math.imul(undefined, 2);"); + assert!(extracted("import { css } from '@devup-ui/react'; import { VALUE } from './values'; css({ zIndex: VALUE });", &module).is_err(), "{binding}"); + } +} + +#[test] +fn fn09_signed_zero_when_exact_math_preserves_js_edges() { + use crate::build_time_values::exact_math::{Operand, evaluate}; + for (name, arguments, negative) in [ + ("round", vec![-0.5], true), + ("round", vec![0.5], false), + ("sign", vec![-0.0], true), + ("min", vec![0.0, -0.0], true), + ("max", vec![-0.0, 0.0], false), + ("fround", vec![-0.0], true), + ] { + let arguments: Vec<_> = arguments.into_iter().map(Operand::Number).collect(); + let result = + evaluate(name, Some(&arguments)).unwrap_or_else(|| panic!("evaluate {name} failed")); + assert_eq!(result.is_sign_negative(), negative, "{name}"); + } + for (name, arguments) in [ + ("max", vec![f64::NAN, 1.0]), + ("min", vec![1.0, f64::NAN]), + ("sign", vec![f64::NAN]), + ("round", vec![f64::INFINITY]), + ] { + let arguments: Vec<_> = arguments.into_iter().map(Operand::Number).collect(); + assert_eq!(evaluate(name, Some(&arguments)), None, "{name}"); + } + assert!(super::fold_math("abs", &[super::Constant::Object(std::rc::Rc::default())]).is_none()); +} + +#[test] +#[serial_test::serial] +fn fn08_exact_prefix_when_later_enum_members_are_uncertain() { + let output = extracted("import { css } from '@devup-ui/react'; import { E } from './values'; css({ zIndex: E.A });", "const A = 99; const OFFSET = A; export enum E { A = 2, B = A + OFFSET, C = unknown(), D = 8 }").unwrap_or_else(|error| panic!("fn08_exact_prefix_when_later_enum_members_are_uncertain (E.A): {error}")); + assert_eq!(static_values(&output), vec!["2".to_string()]); + let output = extracted("import { css } from '@devup-ui/react'; import { E } from './values'; css({ zIndex: E.B });", "const A = 99; const OFFSET = A; export enum E { A = 2, B = A + OFFSET }").unwrap_or_else(|error| panic!("fn08_exact_prefix_when_later_enum_members_are_uncertain (E.B): {error}")); + assert_eq!(static_values(&output), vec!["101".to_string()]); +} + +#[test] +#[serial_test::serial] +fn jsx12_primitive_only_when_no_top_level_value_is_read() { + let output = extracted("import { Box } from '@devup-ui/react'; export function f() { const color = 'green'; const n = 3 as const; const yes = true; const empty = null; return ; }", "").unwrap_or_else(|error| panic!("jsx12_primitive_only_when_no_top_level_value_is_read: {error}")); + let values = static_values(&output); + for expected in ["green", "3", "block"] { + assert!(values.contains(&expected.to_string()), "{values:?}"); + } + assert!(!output.code.contains("--"), "{}", output.code); +} + +#[test] +#[serial_test::serial] +fn jsx12_values_when_nonfinite_constants_are_not_style_literals() { + let output = extracted("import { Box } from '@devup-ui/react'; export function f() { const n = Infinity; return ; }", "").unwrap_or_else(|error| panic!("jsx12_values_when_nonfinite_constants_are_not_style_literals: {error}")); + assert!(output.code.contains("--"), "{}", output.code); + assert!(extracted("import { css } from '@devup-ui/react'; import { E } from './values'; css({ zIndex: E.B });", "export enum E { A = Infinity, B }").is_err()); +} + +#[test] +#[serial_test::serial] +fn jsx12_values_when_parameters_mutation_or_uncertainty_prevent_folding() { + for body in [ + "const color = Math.random();", + "let color = 'red'; color = 'blue';", + "const object = { color: 'red' }; object.color = 'blue'; const color = object.color;", + "const Math = source; const color = Math.imul(2, 3);", + "const color = Math.sin(1);", + "const color = parameter;", + ] { + let output = extracted(&format!("import {{ Box }} from '@devup-ui/react'; const color = 'green'; export function f(parameter) {{ {body} return ; }}"), "").unwrap_or_else(|error| panic!("jsx12_values_when_parameters_mutation_or_uncertainty_prevent_folding: {error}")); + assert!(output.code.contains("--"), "{body}: {}", output.code); + } + let output = extracted("import { Box } from '@devup-ui/react'; const color = 'red'; export function f(color) { return ; }", "").unwrap_or_else(|error| panic!("jsx12_values_when_parameters_mutation_or_uncertainty_prevent_folding (param): {error}")); + assert!(output.code.contains("--"), "{}", output.code); +} diff --git a/libs/extractor/src/imported_constants/initialization.rs b/libs/extractor/src/imported_constants/initialization.rs new file mode 100644 index 000000000..c4856a460 --- /dev/null +++ b/libs/extractor/src/imported_constants/initialization.rs @@ -0,0 +1,231 @@ +use oxc_ast::ast::{ + ArrowFunctionExpression, CallExpression, Class, ClassElement, Expression, Function, + IdentifierReference, Program, VariableDeclaration, +}; +use oxc_ast_visit::{Visit, walk}; +use oxc_semantic::Scoping; +use oxc_span::{GetSpan, Span}; +use oxc_syntax::{reference::ReferenceId, scope::ScopeFlags, symbol::SymbolId}; +use rustc_hash::{FxHashMap, FxHashSet}; + +#[derive(Clone, Copy)] +enum Read { + Initialized, + Deferred, + Before { + temporal: bool, + at: u32, + symbol: SymbolId, + }, +} + +pub(super) struct Initialization { + reads: FxHashMap, +} + +impl Initialization { + pub(super) fn new(program: &Program<'_>, scoping: &Scoping) -> Self { + let mut collector = Collector { + scoping, + bindings: FxHashMap::default(), + references: Vec::new(), + frames: vec![Frame { + parent: 0, + deferred: false, + }], + frame: 0, + invoked: FxHashSet::default(), + }; + collector.visit_program(program); + let reads = collector + .references + .iter() + .map(|&(reference, symbol, at, frame)| { + let read = collector + .bindings + .get(&symbol) + .map_or(Read::Initialized, |binding| { + if collector.is_deferred(frame, binding.frame) { + Read::Deferred + } else if at < binding.initialized + && !binding + .class_initializers + .iter() + .any(|span| span.start <= at && at < span.end) + { + Read::Before { + temporal: binding.temporal, + at, + symbol, + } + } else { + Read::Initialized + } + }); + (reference, read) + }) + .collect(); + Self { reads } + } + + pub(super) fn allows(&self, identifier: &IdentifierReference<'_>) -> bool { + match identifier + .reference_id + .get() + .and_then(|reference| self.reads.get(&reference)) + { + Some(Read::Before { .. }) => false, + Some(Read::Initialized | Read::Deferred) | None => true, + } + } + + pub(super) fn errors( + &self, + references: &FxHashSet, + scoping: &Scoping, + ) -> Vec<(u32, String)> { + let mut errors: Vec<_> = references.iter().filter_map(|reference| { + let Read::Before { temporal: true, at, symbol } = self.reads.get(reference)? else { + return None; + }; + let name = scoping.symbol_name(*symbol); + Some((*at, format!("style value cannot use `{name}` at build time: it is read before its lexical declaration is initialized, so the original JavaScript throws ReferenceError; move the declaration of `{name}` above this use"))) + }).collect(); + errors.sort_unstable_by_key(|(at, _)| *at); + errors + } +} + +struct Binding { + initialized: u32, + class_initializers: Vec, + frame: usize, + temporal: bool, +} + +impl Binding { + /// Class self-bindings initialize after keys and extends, before static values. + fn class(class: &Class<'_>, frame: usize) -> Self { + let initialized = class.span.end; + let class_initializers = class + .body + .body + .iter() + .filter_map(|element| match element { + ClassElement::PropertyDefinition(property) if property.r#static => { + property.value.as_ref().map(GetSpan::span) + } + ClassElement::StaticBlock(block) => Some(block.span), + _ => None, + }) + .collect(); + Self { + initialized, + class_initializers, + frame, + temporal: true, + } + } +} + +struct Frame { + parent: usize, + deferred: bool, +} + +struct Collector<'s> { + scoping: &'s Scoping, + bindings: FxHashMap, + references: Vec<(ReferenceId, SymbolId, u32, usize)>, + frames: Vec, + frame: usize, + invoked: FxHashSet, +} + +impl Collector<'_> { + fn is_deferred(&self, mut from: usize, declaration: usize) -> bool { + while from != declaration && from != 0 { + let frame = &self.frames[from]; + if frame.deferred { + return true; + } + from = frame.parent; + } + false + } + + fn function(&mut self, start: u32, visit: impl FnOnce(&mut Self)) { + let outer = self.frame; + self.frame = self.frames.len(); + self.frames.push(Frame { + parent: outer, + deferred: !self.invoked.contains(&start), + }); + visit(self); + self.frame = outer; + } +} + +impl<'a> Visit<'a> for Collector<'_> { + fn visit_variable_declaration(&mut self, declaration: &VariableDeclaration<'a>) { + for declarator in &declaration.declarations { + for identifier in declarator.id.get_binding_identifiers() { + if let Some(symbol) = identifier.symbol_id.get() { + self.bindings.insert( + symbol, + Binding { + initialized: declarator.span.end, + class_initializers: Vec::new(), + frame: self.frame, + temporal: declaration.kind.is_lexical(), + }, + ); + } + } + } + walk::walk_variable_declaration(self, declaration); + } + + fn visit_class(&mut self, class: &Class<'a>) { + if let Some(symbol) = class.id.as_ref().and_then(|id| id.symbol_id.get()) { + self.bindings + .insert(symbol, Binding::class(class, self.frame)); + } + walk::walk_class(self, class); + } + + fn visit_identifier_reference(&mut self, identifier: &IdentifierReference<'a>) { + if let Some(reference) = identifier.reference_id.get() + && self.scoping.get_reference(reference).is_read() + && let Some(symbol) = self.scoping.get_reference(reference).symbol_id() + { + self.references + .push((reference, symbol, identifier.span.start, self.frame)); + } + } + + fn visit_function(&mut self, function: &Function<'a>, flags: ScopeFlags) { + self.function(function.span.start, |collector| { + walk::walk_function(collector, function, flags); + }); + } + + fn visit_arrow_function_expression(&mut self, arrow: &ArrowFunctionExpression<'a>) { + self.function(arrow.span.start, |collector| { + walk::walk_arrow_function_expression(collector, arrow); + }); + } + + fn visit_call_expression(&mut self, call: &CallExpression<'a>) { + match crate::utils::unwrap_syntax_only(&call.callee) { + Expression::FunctionExpression(function) => { + self.invoked.insert(function.span.start); + } + Expression::ArrowFunctionExpression(arrow) => { + self.invoked.insert(arrow.span.start); + } + _ => {} + } + walk::walk_call_expression(self, call); + } +} diff --git a/libs/extractor/src/imported_constants/lexical.rs b/libs/extractor/src/imported_constants/lexical.rs new file mode 100644 index 000000000..8554d301e --- /dev/null +++ b/libs/extractor/src/imported_constants/lexical.rs @@ -0,0 +1,52 @@ +use oxc_allocator::{CloneIn, GetAllocator}; +use oxc_ast::ast::{Expression, Program, VariableDeclaration, VariableDeclarationKind}; +use oxc_ast::builder::AstBuilder; +use oxc_ast_visit::{Visit, walk}; +use oxc_semantic::Scoping; +use oxc_syntax::symbol::SymbolId; +use rustc_hash::FxHashMap; + +/// Const initializers keyed by lexical identity, excluding written bindings. +pub(super) fn declarations<'a>( + builder: &AstBuilder<'a>, + program: &Program<'a>, + scoping: &Scoping, +) -> FxHashMap> { + let mut collector = Collector { + builder, + scoping, + found: FxHashMap::default(), + }; + collector.visit_program(program); + collector.found +} + +struct Collector<'s, 'a> { + builder: &'s AstBuilder<'a>, + scoping: &'s Scoping, + found: FxHashMap>, +} + +impl<'a> Visit<'a> for Collector<'_, 'a> { + fn visit_variable_declaration(&mut self, declaration: &VariableDeclaration<'a>) { + if declaration.kind == VariableDeclarationKind::Const { + for declarator in &declaration.declarations { + if let Some(identifier) = declarator.id.get_binding_identifier() + && let Some(symbol) = identifier.symbol_id.get() + && !self + .scoping + .get_resolved_reference_ids(symbol) + .iter() + .any(|reference| self.scoping.get_reference(*reference).is_write()) + && let Some(init) = &declarator.init + { + self.found.insert( + symbol, + init.clone_in_with_semantic_ids(self.builder.allocator()), + ); + } + } + } + walk::walk_variable_declaration(self, declaration); + } +} diff --git a/libs/extractor/src/imported_constants/numeric_semantics_tests.rs b/libs/extractor/src/imported_constants/numeric_semantics_tests.rs new file mode 100644 index 000000000..077f56db8 --- /dev/null +++ b/libs/extractor/src/imported_constants/numeric_semantics_tests.rs @@ -0,0 +1,84 @@ +use super::exact_tests::{extracted, static_values}; +use super::{Constant, constant_literal}; + +#[rstest::rstest] +#[case(("Math.sign(-0)", "green"))] +#[case(("Math.fround(-0)", "green"))] +#[case(("Math.min(0, -0)", "green"))] +#[case(("Math.sign(0)", "red"))] +#[case(("Math.fround(0)", "red"))] +#[case(("Math.max(-0, 0)", "red"))] +#[serial_test::serial] +fn computed_color_when_imported_zero_is_consumed( + #[case] scenario: (&str, &str), + #[values("Object.is(n, -0)", "1 / n === -Infinity")] predicate: &str, + #[values(false, true)] inline: bool, +) { + // Given + let (expression, expected) = scenario; + let module = format!("export const VALUE = {expression};"); + let consumer = if inline { + "export const s = css({ color: colorOf(VALUE) });" + } else { + "const color = colorOf(VALUE); export const s = css({ color });" + }; + let code = format!( + "import {{ css }} from '@devup-ui/react'; import {{ VALUE }} from './values'; + const colorOf = n => {predicate} ? 'green' : 'red'; {consumer}" + ); + + // When + let output = extracted(&code, &module).unwrap_or_else(|error| panic!("{code}: {error}")); + + // Then + assert_eq!( + static_values(&output), + vec![expected.to_string()], + "{expression}, {predicate}, inline={inline}" + ); +} + +#[rstest::rstest] +#[case("Math.sign(-0)")] +#[case("Math.fround(-0)")] +#[case("Math.min(0, -0)")] +#[case("Math.sign(0)")] +#[serial_test::serial] +fn canonical_css_zero_when_imported_zero_is_a_style_value(#[case] expression: &str) { + // Given + let module = format!("export const VALUE = {expression};"); + let code = "import { css } from '@devup-ui/react'; import { VALUE } from './values'; + export const s = css({ zIndex: VALUE });"; + + // When + let output = extracted(code, &module).unwrap_or_else(|error| panic!("{expression}: {error}")); + + // Then + assert_eq!(static_values(&output), vec!["0".to_string()]); +} + +#[rstest::rstest] +#[case(-0.0, "green")] +#[case(0.0, "red")] +#[serial_test::serial] +fn computed_color_when_scalar_ast_is_serialized(#[case] zero: f64, #[case] expected: &str) { + // Given + let allocator = oxc_allocator::Allocator::default(); + let builder = oxc_ast::builder::AstBuilder::new(&allocator); + let literal = constant_literal(&builder, &Constant::Number(zero), false) + .unwrap_or_else(|| panic!("finite scalar must have a literal")); + let mut codegen = oxc_codegen::Codegen::new(); + codegen.print_expression(&literal); + let code = format!( + "import {{ css }} from '@devup-ui/react'; + const colorOf = n => Object.is(n, -0) ? 'green' : 'red'; + export const s = css({{ color: colorOf({}) }});", + codegen.into_source_text() + ); + + // When + let output = extracted(&code, "").unwrap_or_else(|error| panic!("{code}: {error}")); + + // Then + assert_eq!(static_values(&output), vec![expected.to_string()]); +} diff --git a/libs/extractor/src/imported_constants/require_tests.rs b/libs/extractor/src/imported_constants/require_tests.rs new file mode 100644 index 000000000..86f9aff06 --- /dev/null +++ b/libs/extractor/src/imported_constants/require_tests.rs @@ -0,0 +1,98 @@ +use crate::{ExtractOption, ExtractStyleValue, ResolvedModule}; +use rstest::rstest; +use serial_test::serial; +use std::cell::RefCell; +use std::rc::Rc; + +fn require_probe(module: &str) -> (Vec, Result) { + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + let requested = Rc::new(RefCell::new(Vec::new())); + let recorded = requested.clone(); + let module = module.to_string(); + let resolver = move |specifier: &str, _: &str| { + recorded.borrow_mut().push(specifier.to_string()); + let source = match specifier { + "./values" => module.as_str(), + "./trap" => "export const color = 'green';", + "./local" => "export const require = () => ({ color: 'red' });", + _ => return None, + }; + Some(ResolvedModule { + path: format!("/src/{specifier}.ts"), + code: source.to_string(), + }) + }; + let output = crate::extract_with_modules("/src/App.tsx", "import {Box} from '@devup-ui/react';import {COLOR} from './values';export const a=;", ExtractOption::default(),false,&resolver).map_err(|error|error.to_string()); + (requested.borrow().clone(), output) +} + +#[rstest] +#[case( + "const require=()=>({color:'red'});const values=require('./trap');export const COLOR=values.color;" +)] +#[case( + "function require(){return {color:'red'};}const values=require('./trap');export const COLOR=values.color;" +)] +#[case("let require=load;const values=require('./trap');export const COLOR=values.color;")] +#[case("class require{}const values=require('./trap');export const COLOR=values.color;")] +#[case( + "import {require} from './local';const values=require('./trap');export const COLOR=values.color;" +)] +#[case("const values=require('./trap');const require=load;export const COLOR=values.color;")] +#[serial] +fn shadowed_require_when_reading_module_constants_never_resolves_a_module(#[case] module: &str) { + let (requested, output) = require_probe(module); + assert!( + !requested.iter().any(|specifier| specifier == "./trap"), + "{requested:?}" + ); + if let Ok(output) = output { + assert!( + !output.styles.iter().any( + |style| matches!(style,ExtractStyleValue::Static(style) if style.value=="green") + ), + "{}", + output.code + ); + } +} + +#[rstest] +#[case( + "export function f(require){const values=require('./trap');return values.color;}export const COLOR=f(local);" +)] +#[case( + "try{throw local;}catch(require){const values=require('./trap');observe(values);}export const COLOR=dynamic;" +)] +#[case( + "const require=local;{const values=require('./trap');observe(values);}export const COLOR=dynamic;" +)] +#[serial] +fn enclosing_require_when_nested_calls_are_read_never_loads_the_named_module(#[case] module: &str) { + let (requested, _) = require_probe(module); + assert!( + !requested.iter().any(|specifier| specifier == "./trap"), + "{requested:?}" + ); +} + +#[test] +#[serial] +fn unbound_require_when_reading_module_constants_still_loads() { + let (requested, output) = + require_probe("const values=require('./trap');export const COLOR=values.color;"); + let output = output.unwrap_or_else(|error| panic!("{error}")); + assert!( + requested.iter().any(|specifier| specifier == "./trap"), + "{requested:?}" + ); + assert!( + output + .styles + .iter() + .any(|style| matches!(style,ExtractStyleValue::Static(style) if style.value=="green")), + "{}", + output.code + ); +} diff --git a/libs/extractor/src/imported_constants/safety_tests.rs b/libs/extractor/src/imported_constants/safety_tests.rs new file mode 100644 index 000000000..67e773f14 --- /dev/null +++ b/libs/extractor/src/imported_constants/safety_tests.rs @@ -0,0 +1,89 @@ +use super::*; +use rstest::rstest; + +#[rstest] +#[case("tone; const tone='red';", None)] +#[case("const tone='red'; tone;", Some("\"red\""))] +fn inline_read_when_the_binding_initializes_on_either_side_of_it_is_safe( + #[case] source: &str, + #[case] expected: Option<&str>, +) { + let allocator = Allocator::default(); + let ast_builder = AstBuilder::new(&allocator); + let parsed = Parser::new(&allocator, source, SourceType::ts()).parse(); + let scoping = SemanticBuilder::new() + .build(&parsed.program) + .semantic + .into_scoping(); + let initialization = initialization::Initialization::new(&parsed.program, &scoping); + let style = StyleSymbols::new(&scoping); + let css_props = CssTakers::new(&parsed.program, &scoping, CssProp::Off, "@devup-ui/react"); + let symbol = scoping + .get_root_binding("tone".into()) + .unwrap_or_else(|| panic!("tone binding")); + let symbols = FxHashMap::from_iter([(symbol, Constant::String("red".to_string()))]); + let inline = Inline { + ast_builder: &ast_builder, + scoping: &scoping, + initialization: &initialization, + symbols: &symbols, + style: &style, + css_props: &css_props, + objects: false, + styles: false, + px: false, + class_names: Vec::new(), + }; + let expression = parsed + .program + .body + .iter() + .find_map(|statement| match statement { + Statement::ExpressionStatement(statement) => Some(&statement.expression), + _ => None, + }) + .unwrap_or_else(|| panic!("tone read")); + assert_eq!( + inline + .constant(expression) + .and_then(|value| value.js_literal()) + .as_deref(), + expected + ); +} + +#[rstest] +#[case(true)] +#[case(false)] +fn namespace_object_when_a_nested_member_is_a_function_is_recognized(#[case] function: bool) { + let leaf = if function { + Constant::Function + } else { + Constant::Number(2.0) + }; + let object = Constant::Object(Rc::new(FxHashMap::from_iter([( + "member".to_string(), + leaf, + )]))); + assert_eq!(object.has_function(), function); +} + +#[rstest] +#[case(vec![])] +#[case(vec![Constant::Number(2.0)])] +#[case(vec![Constant::String("2".to_string()), Constant::Number(3.0)])] +#[case(vec![Constant::Number(2.0), Constant::String("3".to_string())])] +fn exact_power_when_operands_are_not_two_numbers_does_not_fold(#[case] arguments: Vec) { + assert!(fold_math("pow", &arguments).is_none()); +} + +#[test] +#[serial_test::serial] +fn nested_math_constant_when_read_by_computed_key_keeps_its_exact_value() { + let output = super::exact_tests::extracted("import {Box} from '@devup-ui/react'; export function a(){const n=Math['PI'];return ;}", "") + .unwrap_or_else(|error| panic!("{error}")); + assert_eq!( + super::exact_tests::static_values(&output), + vec!["3.141592653589793".to_string()] + ); +} diff --git a/libs/extractor/src/imported_constants/scope_tests.rs b/libs/extractor/src/imported_constants/scope_tests.rs new file mode 100644 index 000000000..e6c5523a8 --- /dev/null +++ b/libs/extractor/src/imported_constants/scope_tests.rs @@ -0,0 +1,270 @@ +use std::rc::Rc; + +use oxc_allocator::Allocator; +use oxc_ast::ast::Expression; +use oxc_ast::builder::AstBuilder; +use oxc_codegen::Codegen; +use oxc_parser::Parser; +use oxc_span::SourceType; +use rustc_hash::FxHashMap; + +use super::{Change, ChangeSite, Changed, Inlined, Unknown, inline_constants}; +use crate::ExtractOption; +use crate::css_prop::CssProp; + +fn inline(code: &str, css_prop: CssProp) -> (String, Inlined) { + let allocator = Allocator::default(); + let mut program = Parser::new(&allocator, code, SourceType::tsx()) + .parse() + .program; + let inlined = inline_constants( + &AstBuilder::new(&allocator), + &mut program, + "a.tsx", + &ExtractOption::default(), + None, + css_prop, + ); + (Codegen::new().build(&program).code, inlined) +} + +fn expression<'a>(allocator: &'a Allocator, code: &'a str) -> Expression<'a> { + Parser::new(allocator, code, SourceType::tsx()) + .parse_expression() + .unwrap_or_else(|_| panic!("{code}")) +} + +#[test] +fn a_local_named_like_a_style_function_reads_no_styles() { + let (code, _) = inline( + "import { css } from '@devup-ui/react'; +import * as ui from '@devup-ui/react'; +const SIZE = 4; +export const own = css({ w: SIZE }); +export const member = ui.css({ w: SIZE }); +export function shadowed(css, ui) { return [css({ w: SIZE }), ui.css({ w: SIZE }), parseInt('1')]; }", + CssProp::Off, + ); + assert_eq!(code.matches("w: 4").count(), 2, "{code}"); + assert_eq!(code.matches("w: SIZE").count(), 2, "{code}"); +} + +#[test] +fn a_local_named_like_a_style_component_reads_no_styles() { + let (code, _) = inline( + "import { Box } from '@devup-ui/react'; +import * as ui from '@devup-ui/react'; +const COLOR = 'red'; +export const own = <>; +export function shadowed(Box, ui) { return <>; }", + CssProp::Off, + ); + assert_eq!(code.matches("{COLOR}").count(), 2, "{code}"); +} + +#[test] +fn a_local_named_like_a_class_names_binding_reads_no_styles() { + let (code, _) = inline( + "import { ClassNames } from '@emotion/react'; +const SIZE = 4; +export const own = {({ css }) => css({ width: SIZE })}; +export const nested = {({ css }) => ((css) => css({ width: SIZE }))(css)}; +export const tagged = {({ cx }) => cx`width: ${SIZE}px`}; +export function shadowed(ClassNames) { return {({ css }) => css({ width: SIZE })}; }", + CssProp::Elements, + ); + assert_eq!(code.matches("width: SIZE").count(), 2, "{code}"); + assert_eq!(code.matches("width: \"4px\"").count(), 1, "{code}"); + assert_eq!(code.matches("${SIZE}").count(), 0, "{code}"); +} + +#[test] +fn a_local_named_like_the_jsx_function_takes_no_css_prop() { + let (code, _) = inline( + "import { jsx } from '@emotion/react'; +import { Box } from '@devup-ui/react'; +const SIZE = 4; +export const tag = jsx('div', { css: { width: SIZE } }); +export const component = jsx(Box, { css: { width: SIZE } }); +export function shadowedJsx(jsx) { return jsx('div', { css: { width: SIZE } }); } +export function shadowedBox(Box) { return jsx(Box, { css: { width: SIZE } }); }", + CssProp::Elements, + ); + assert_eq!(code.matches("width: \"4px\"").count(), 2, "{code}"); + assert_eq!(code.matches("width: SIZE").count(), 2, "{code}"); +} + +#[test] +fn a_local_named_like_a_style_component_takes_no_css_attribute() { + let (code, _) = inline( + "import { Box } from '@devup-ui/react'; +const SIZE = 4; +export const own = ; +export const tag =
; +export const custom = ; +export function shadowed(Box) { return ; }", + CssProp::Elements, + ); + assert_eq!(code.matches("width: \"4px\"").count(), 2, "{code}"); + assert_eq!(code.matches("width: SIZE").count(), 2, "{code}"); +} + +#[test] +fn names_only_locals_read_are_not_reported_unknown_or_changed() { + let (_, shadowed) = inline( + "import { css } from '@devup-ui/react'; +const unknown = compute(); +const table = { a: 1 }; +table.a = 2; +export function f(unknown, table) { return css({ w: unknown, h: table.a }); }", + CssProp::Off, + ); + assert!(shadowed.unknown.is_empty()); + assert!(shadowed.changed.is_empty()); + + let (_, read) = inline( + "import { css } from '@devup-ui/react'; +const unknown = compute(); +const table = { a: 1 }; +table.a = 2; +export const a = css({ w: unknown, h: table.a });", + CssProp::Off, + ); + assert!(!read.unknown.is_empty()); + assert!(!read.changed.is_empty()); +} + +#[test] +fn the_scoping_built_is_kept_on_every_return_after_it() { + for (code, css_prop, kept) in [ + ("const SIZE = 4;\nexport const a = 1;", CssProp::Off, false), + ( + "import { css } from '@devup-ui/react';\nconst SIZE = 4;", + CssProp::Off, + true, + ), + ( + "import { css } from '@devup-ui/react';\nconst SIZE = 4;\ncss({ w: 1 });", + CssProp::Off, + true, + ), + ( + "import { css } from '@devup-ui/react';\nconst SIZE = 4;\ncss({ w: Math.max(1, 2) });", + CssProp::Off, + true, + ), + ( + "import { css } from '@devup-ui/react';\nconst SIZE = 4;\ncss({ w: SIZE });", + CssProp::Off, + true, + ), + ( + "const SIZE = 4;\nexport const a =
;", + CssProp::Elements, + true, + ), + ] { + let (_, inlined) = inline(code, css_prop); + assert_eq!(inlined.scoping.is_some(), kept, "{code}"); + if let Some(scoping) = &inlined.scoping { + assert!(scoping.get_root_binding("SIZE".into()).is_some(), "{code}"); + } + } +} + +struct Properties<'s> { + inlined: &'s Inlined, + scoping: &'s oxc_semantic::Scoping, + found: Vec<(bool, bool)>, +} + +impl<'a> oxc_ast_visit::Visit<'a> for Properties<'_> { + fn visit_object_property(&mut self, property: &oxc_ast::ast::ObjectProperty<'a>) { + let reads = |identifier: &oxc_ast::ast::IdentifierReference<'_>| { + crate::css_prop::reads_top_level(self.scoping, identifier) + }; + self.found.push(( + self.inlined.unknown.read_by_in(&property.value, &reads), + self.inlined.changed.read_by_in(&property.value, &reads), + )); + } +} + +#[test] +fn the_scoping_kept_guards_reads_of_unknown_and_changed_bindings() { + let allocator = Allocator::default(); + let mut program = Parser::new( + &allocator, + "import { css } from '@devup-ui/react'; +const unknown = compute(); +const table = { a: 1 }; +table.a = 2; +export const top = css({ w: unknown, h: table.a }); +export function shadowed(unknown, table) { return css({ w: unknown, h: table.a }); }", + SourceType::tsx(), + ) + .parse() + .program; + let inlined = inline_constants( + &AstBuilder::new(&allocator), + &mut program, + "a.tsx", + &ExtractOption::default(), + None, + CssProp::Off, + ); + let Some(scoping) = inlined.scoping.clone() else { + panic!("no scoping kept"); + }; + let mut properties = Properties { + inlined: &inlined, + scoping: &scoping, + found: Vec::new(), + }; + oxc_ast_visit::Visit::visit_program(&mut properties, &program); + assert_eq!( + properties.found, + vec![ + (false, false), + (true, false), + (false, true), + (false, false), + (false, false) + ] + ); +} + +#[test] +fn unknown_counts_only_the_identifiers_accepted() { + let allocator = Allocator::default(); + let unknown = Unknown { + names: std::iter::once("x".to_string()).collect(), + partial: FxHashMap::default(), + }; + for code in ["x", "x()", "x[k]", "x.y[k]", "x.y()"] { + let expression = expression(&allocator, code); + assert!(unknown.read_by_in(&expression, &|_| true), "{code}"); + assert!(!unknown.read_by_in(&expression, &|_| false), "{code}"); + } + let expression = expression(&allocator, "y[x]"); + assert!(!unknown.read_by_in(&expression, &|_| true)); +} + +#[test] +fn changed_counts_only_the_identifiers_accepted() { + let allocator = Allocator::default(); + let change = Rc::new(Change { + name: "x".to_string(), + site: ChangeSite::Here(0), + handed: false, + }); + let changed = Changed { + whole: FxHashMap::from_iter([("x".to_string(), change)]), + holding: FxHashMap::default(), + }; + for code in ["x", "x.y", "x[k]"] { + let expression = expression(&allocator, code); + assert!(changed.read_by_in(&expression, &|_| true), "{code}"); + assert!(!changed.read_by_in(&expression, &|_| false), "{code}"); + } +} diff --git a/libs/extractor/src/imported_constants/stylex_scope_tests.rs b/libs/extractor/src/imported_constants/stylex_scope_tests.rs new file mode 100644 index 000000000..ea6d77bf5 --- /dev/null +++ b/libs/extractor/src/imported_constants/stylex_scope_tests.rs @@ -0,0 +1,225 @@ +use oxc_allocator::Allocator; +use oxc_ast::ast::{CallExpression, Expression, Statement}; +use oxc_ast_visit::{Visit, walk}; +use oxc_parser::Parser; +use oxc_span::SourceType; + +use super::ModuleScope; +use crate::stylex::StylexFunction; + +/// The `StyleX` API the callee of each call reads, and the one its member +/// object reads, in source order +struct Callees<'s, 'p, 'a> { + scope: &'s ModuleScope<'p, 'a>, + found: Vec<(Option, Option)>, +} + +impl<'a> Visit<'a> for Callees<'_, '_, 'a> { + fn visit_call_expression(&mut self, call: &CallExpression<'a>) { + let object = match &call.callee { + Expression::StaticMemberExpression(member) => { + self.scope.stylex_function(&member.object) + } + _ => None, + }; + self.found + .push((self.scope.stylex_function(&call.callee), object)); + walk::walk_call_expression(self, call); + } +} + +fn read(code: &str) -> Vec<(Option, Option)> { + let allocator = Allocator::default(); + let program = Parser::new(&allocator, code, SourceType::tsx()) + .parse() + .program; + let mut scope = ModuleScope::new("a.tsx", &program, None); + for statement in &program.body { + if let Statement::ImportDeclaration(import) = statement { + scope.import(import); + } + } + let mut callees = Callees { + scope: &scope, + found: Vec::new(), + }; + callees.visit_program(&program); + callees.found +} + +#[test] +fn a_helper_is_the_import_its_callee_reads_and_not_its_spelling() { + let found = read( + "import * as sx from '@stylexjs/stylex'; +import { firstThatWorks as ftw, types as t, include } from '@stylexjs/stylex'; +import { include as other } from 'elsewhere'; +sx.firstThatWorks(a); +ftw(a); +include(a); +other(a); +t.color(a); +sx.types.color(a); +function shadowed(sx, ftw, include, t) { + sx.firstThatWorks(a); + ftw(a); + include(a); + t.color(a); + sx.types.color(a); +}", + ); + assert_eq!( + found, + vec![ + (Some(StylexFunction::FirstThatWorks), None), + (Some(StylexFunction::FirstThatWorks), None), + (Some(StylexFunction::Include), None), + (None, None), + (None, Some(StylexFunction::Types)), + (None, Some(StylexFunction::Types)), + (None, None), + (None, None), + (None, None), + (None, None), + (None, None), + ] + ); +} + +fn extracted(code: &str) -> Result { + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + crate::extract( + "test.tsx", + code, + crate::ExtractOption { + import_main_css: false, + ..crate::ExtractOption::default() + }, + ) + .map_err(|error| error.to_string()) +} + +fn declarations(output: &crate::ExtractOutput) -> Vec<(String, String)> { + let mut found: Vec<(String, String)> = output + .styles + .iter() + .filter_map(|style| match style { + crate::ExtractStyleValue::Static(style) => { + Some((style.property.clone(), style.value.clone())) + } + _ => None, + }) + .collect(); + found.sort(); + found +} + +fn error_of(code: &str) -> String { + extracted(code).err().unwrap_or_default() +} + +const SX: &str = "import * as sx from '@stylexjs/stylex';\n"; + +#[test] +#[serial_test::serial] +fn genuine_helpers_compile_through_every_import_spelling() { + let output = extracted( + "import * as sx from '@stylexjs/stylex'; +import { create, defineVars, firstThatWorks as ftw, include as inc, types as t } from '@stylexjs/stylex'; +const base = create({ a: { color: 'red' } }); +const vars = defineVars({ size: t.length('1px'), tone: sx.types.color({ default: 'blue' }) }); +const styles = sx.create({ + b: { backgroundColor: ftw('red', 'blue'), width: vars.size, borderColor: vars.tone }, + c: { ...inc(base.a), height: sx.firstThatWorks('1px', '2px') }, +}); +export const A = () =>
;", + ) + .unwrap_or_else(|error| panic!("{error}")); + assert_eq!( + declarations(&output), + vec![ + ("background-color".to_string(), "blue".to_string()), + ("background-color".to_string(), "red".to_string()), + ("border-color".to_string(), "var(--a-c)".to_string()), + ("color".to_string(), "red".to_string()), + ("height".to_string(), "1px".to_string()), + ("height".to_string(), "2px".to_string()), + ("width".to_string(), "var(--a-b)".to_string()), + ] + ); +} + +#[test] +#[serial_test::serial] +fn shadowed_and_unrelated_helpers_are_reported_at_build_time() { + for (code, expected) in [ + ( + "export function f(firstThatWorks) { return sx.create({ a: { color: firstThatWorks('red', 'blue') } }); }", + "`stylex.create()` cannot use `firstThatWorks(", + ), + ( + "export function f(o) { return sx.create({ a: { color: o.firstThatWorks('red') } }); }", + "`stylex.create()` cannot use `o.firstThatWorks(", + ), + ( + "const base = sx.create({ a: { color: 'red' } });\nexport function f(include) { return sx.create({ b: { ...include(base.a) } }); }", + "`stylex.create()` cannot use `...include(", + ), + ( + "const base = sx.create({ a: { color: 'red' } });\nexport function f(o) { return sx.create({ b: { ...o.include(base.a) } }); }", + "`stylex.create()` cannot use `...o.include(", + ), + ( + "export function f(types) { return sx.defineVars({ c: types.color('red') }); }", + "`stylex.defineVars()` cannot use `types.color(", + ), + ( + "export function f(o) { return sx.defineVars({ c: o.types.color('red') }); }", + "`stylex.defineVars()` cannot use `o.types.color(", + ), + ( + "export const p = sx.positionTry({ top: sx.firstThatWorks('1px') });", + "`stylex.positionTry()` cannot use `sx.firstThatWorks(", + ), + ( + "export function f(firstThatWorks) { return sx.positionTry({ top: firstThatWorks('1px') }); }", + "`stylex.positionTry()` cannot use `firstThatWorks(", + ), + ] { + let message = error_of(&format!("{SX}{code}")); + assert!(message.contains(expected), "{expected}: {message}"); + } +} + +#[test] +#[serial_test::serial] +fn aliased_types_read_in_an_imported_module_still_publish_their_variables() { + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + let resolver = |specifier: &str, _: &str| { + (specifier == "./vars").then(|| crate::ResolvedModule { + path: "/src/vars.ts".to_string(), + code: "import { defineVars, types as t } from '@stylexjs/stylex'; +export const vars = defineVars({ tone: t.color('red'), size: t.length(unknown()) });" + .to_string(), + }) + }; + let output = crate::extract_with_modules( + "/src/App.tsx", + "import * as sx from '@stylexjs/stylex'; +import { vars } from './vars'; +const s = sx.create({ a: { color: vars.tone } }); +export const A = () =>
;", + crate::ExtractOption { + import_main_css: false, + ..crate::ExtractOption::default() + }, + false, + &resolver, + ) + .unwrap_or_else(|error| panic!("{error}")); + assert_eq!( + declarations(&output), + vec![("color".to_string(), "var(--b-a)".to_string())] + ); +} diff --git a/libs/extractor/src/imported_constants/tdz_tests.rs b/libs/extractor/src/imported_constants/tdz_tests.rs new file mode 100644 index 000000000..adf4b1b0b --- /dev/null +++ b/libs/extractor/src/imported_constants/tdz_tests.rs @@ -0,0 +1,170 @@ +use oxc_allocator::Allocator; +use oxc_ast::builder::AstBuilder; +use oxc_codegen::Codegen; +use oxc_parser::Parser; +use oxc_span::SourceType; + +use super::{Inlined, inline_constants}; +use crate::ExtractOption; +use crate::css_prop::CssProp; + +fn inline(code: &str) -> (String, Inlined) { + let allocator = Allocator::default(); + let mut program = Parser::new(&allocator, code, SourceType::tsx()) + .parse() + .program; + let result = inline_constants( + &AstBuilder::new(&allocator), + &mut program, + "tdz.tsx", + &ExtractOption::default(), + None, + CssProp::Off, + ); + (Codegen::new().build(&program).code, result) +} + +#[test] +fn immediate_style_reads_when_lexical_bindings_are_uninitialized() { + for body in [ + "const view = ; const color = 'red';", + "const view = ; let color = 'red';", + "{ const view = ; const color = 'red'; }", + "function render() { const view = ; const color = 'red'; return view; }", + "const render = () => { const view = ; let color = 'red'; return view; };", + "const view = ; class color {}", + "const color = ;", + "const color = Math.max(color, 1); const view = ;", + "const color = later; const view = ; const later = 'red';", + "const view = (() => )(); const color = 'red';", + "const view = (function () { return ; })(); const color = 'red';", + "function render() { const color = later; const later = color; return ; }", + ] { + let source = format!("import {{ Box }} from '@devup-ui/react';\n{body}"); + let (_, result) = inline(&source); + assert_eq!(result.errors.len(), 1, "{source}"); + let (at, message) = &result.errors[0]; + let name = if body.contains("later") { + "later" + } else { + "color" + }; + assert_eq!( + &source[usize::try_from(*at).unwrap_or_else(|error| panic!("try_from at: {error}"))..] + [..name.len()], + name + ); + assert!(message.contains("ReferenceError"), "{message}"); + let located = crate::located_errors("tdz.tsx", &source, &[], result.errors); + assert!(located.starts_with("tdz.tsx:2:"), "{located}"); + } +} + +#[test] +fn deferred_style_reads_when_outer_bindings_initialize_before_invocation() { + for body in [ + "function Render() { return ; } const color = 'red';", + "const Render = () => ; const color = 'red';", + "register(() => ); const color = 'red';", + "function outer() { const Render = () => ; const color = 'red'; return Render; }", + "{ const Render = () => ; const color = 'red'; }", + ] { + let (code, result) = inline(&format!("import {{ Box }} from '@devup-ui/react';\n{body}")); + assert_eq!(result.errors, vec![], "{body}"); + assert!(code.contains("color={\"red\"}"), "{code}"); + } +} + +#[test] +fn var_reads_when_initializers_have_not_run_stay_dynamic() { + let source = "import { Box } from '@devup-ui/react'; const view = ; var color = 'red';"; + let (code, result) = inline(source); + assert_eq!(result.errors, vec![]); + assert!(code.contains("color={color}"), "{code}"); +} + +#[test] +#[serial_test::serial] +fn var_reads_when_rendered_before_assignment_use_css_variables() { + let output = super::exact_tests::extracted("import { Box } from '@devup-ui/react'; const view = ; var color = 'red';", "").unwrap_or_else(|error| panic!("var_reads_when_rendered_before_assignment_use_css_variables: {error}")); + assert!(output.code.contains("--"), "{}", output.code); +} + +#[test] +fn style_reads_when_shadowed_bindings_have_distinct_initialization() { + let source = "import { Box } from '@devup-ui/react'; const color = 'red'; function outer() { const view = ; const color = 'green'; return view; }"; + let (_, result) = inline(source); + assert_eq!(result.errors.len(), 1); + assert_eq!( + &source[usize::try_from(result.errors[0].0) + .unwrap_or_else(|error| panic!("try_from error offset: {error}"))..][..5], + "color" + ); +} + +#[test] +fn nonstyle_reads_when_preinitialization_is_not_a_style_dependency_are_untouched() { + let (code, result) = inline( + "import { Box } from '@devup-ui/react'; observe(color); const color = 'red'; const view = ;", + ); + assert_eq!(result.errors, vec![]); + assert!(code.contains("observe(color)"), "{code}"); + assert!(code.contains("color={\"red\"}"), "{code}"); +} + +#[test] +#[serial_test::serial] +fn class_self_reads_when_static_field_initialization_has_started() { + for source in [ + "import { Box } from '@devup-ui/react'; export class Palette { static tone = 'red'; static view = ; }", + "import { Box } from '@devup-ui/react'; class Palette { static tone = 'red'; static { const view = ; } }", + ] { + let output = super::exact_tests::extracted(source, "").unwrap_or_else(|error| { + panic!("class_self_reads_when_static_field_initialization_has_started: {error}") + }); + assert!(output.code.contains("Palette.tone"), "{}", output.code); + assert!(output.code.contains("className="), "{}", output.code); + } +} + +#[test] +fn class_self_reads_when_method_execution_is_deferred() { + let source = "import { Box } from '@devup-ui/react'; class Palette { view() { return ; } static tone = 'red'; }"; + let (_, result) = inline(source); + assert_eq!(result.errors, vec![]); +} + +#[test] +fn class_self_reads_when_class_binding_is_still_uninitialized() { + for body in [ + "const view = ; class Palette { static tone = 'red'; }", + "class Palette extends () { static tone = 'red'; }", + "class Palette { static tone = 'red'; static [] = 1; }", + ] { + let source = format!("import {{ Box }} from '@devup-ui/react';\n{body}"); + let (_, result) = inline(&source); + assert_eq!(result.errors.len(), 1, "{source}"); + let (at, message) = &result.errors[0]; + assert_eq!( + &source[usize::try_from(*at) + .unwrap_or_else(|error| panic!("try_from class read offset: {error}"))..][..7], + "Palette" + ); + assert!(message.contains("ReferenceError"), "{message}"); + let located = crate::located_errors("tdz.tsx", &source, &[], result.errors); + assert!(located.starts_with("tdz.tsx:2:"), "{located}"); + } +} + +#[test] +fn lexical_reads_when_static_initialization_precedes_their_declaration() { + for body in [ + "class Palette { static view = ; } const tone = 'red';", + "class Palette { static { const view = ; const tone = 'red'; } }", + ] { + let source = format!("import {{ Box }} from '@devup-ui/react'; {body}"); + let (_, result) = inline(&source); + assert_eq!(result.errors.len(), 1, "{source}"); + assert!(result.errors[0].1.contains("ReferenceError")); + } +} diff --git a/libs/extractor/src/jsx_semantics_tests.rs b/libs/extractor/src/jsx_semantics_tests.rs new file mode 100644 index 000000000..369f28890 --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests.rs @@ -0,0 +1,139 @@ +use super::*; +use crate::extract_style::style_property::StyleProperty; +use css::class_map::reset_class_map; +use css::file_map::reset_file_map; + +mod binding_edges; +mod calls; +mod capture_edges; +mod capture_preservation; +mod captures; +mod create_element; +mod css_prop; +mod errors; +mod evaluation_edges; +mod forwarded; +mod gate_edges; +mod ordered; +mod repeated; +mod slots; +mod style_order; +mod styled; +mod type_imports; +mod whole; + +const BOX: &str = "import { Box } from '@devup-ui/react';\n"; +const EMOTION: &str = "/** @jsxImportSource @emotion/react */\n"; +const JSX_RUNTIME: &str = "import { jsx } from 'react/jsx-runtime';\n"; + +fn compile_with(code: &str, option: ExtractOption) -> Result { + reset_class_map(); + reset_file_map(); + css::debug::set_debug(true); + let output = extract("a.tsx", code, option).map_err(|error| error.to_string()); + css::debug::set_debug(false); + output +} + +fn compile(code: &str) -> Result { + compile_with(code, ExtractOption::default()) +} + +fn compile_emotion(code: &str) -> Result { + compile_with( + code, + ExtractOption { + import_aliases: HashMap::from([( + "@emotion/react".to_string(), + ImportAlias::NamedToNamed, + )]), + ..ExtractOption::default() + }, + ) +} + +/// The output of a file that compiles +fn output(source: &str) -> ExtractOutput { + compile(source).unwrap_or_else(|error| panic!("the file does not compile: {error}")) +} + +fn code(source: &str) -> String { + output(source).code +} + +/// The message of a file that does not compile +fn error(source: &str) -> String { + compile(source) + .err() + .unwrap_or_else(|| panic!("the negative fixture compiles: {source}")) +} + +/// A style that gives way to a spread: its property, breakpoint, the code +/// setting the variable it reads, the variable and the value it keeps +struct Slot { + property: String, + level: u8, + identifier: String, + variable: String, + fallback: String, +} + +fn slots(output: &ExtractOutput) -> Vec { + let mut found: Vec<_> = output + .styles + .iter() + .filter_map(|style| match (style, style.extract(None)) { + ( + ExtractStyleValue::Dynamic(dynamic), + Some(StyleProperty::Variable { variable_name, .. }), + ) => Some(Slot { + property: dynamic.property().to_string(), + level: dynamic.level(), + identifier: captures::materialized(dynamic.identifier(), &output.code), + variable: variable_name, + fallback: dynamic.fallback()?.to_string(), + }), + _ => None, + }) + .collect(); + found.sort_by(|a, b| { + (&a.property, a.level, &a.fallback).cmp(&(&b.property, b.level, &b.fallback)) + }); + found +} + +fn static_styles(output: &ExtractOutput) -> Vec<(String, String)> { + let mut found: Vec<_> = output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Static(style) => { + Some((style.property().to_string(), style.value().to_string())) + } + _ => None, + }) + .collect(); + found.sort(); + found +} + +/// What the JavaScript `body` returns, run as the build's output runs it +fn run(body: &str) -> String { + let script = format!("(function() {{ {body} }})()"); + let mut context = boa_engine::Context::default(); + let value = context + .eval(boa_engine::Source::from_bytes(script.as_bytes())) + .unwrap_or_else(|error| panic!("{error}: {script}")); + value.to_string(&mut context).map_or_else( + |error| panic!("{error}: {script}"), + |text| text.to_std_string_escaped(), + ) +} + +/// What the code `identifier` of a slot reads once `setup` has bound the +/// props the spreads give: the value as JSON, or `undefined` when it sets none +fn read(identifier: &str, setup: &str) -> String { + run(&format!( + "{setup} const slot = {identifier}; return slot === undefined ? 'undefined' : JSON.stringify(slot);" + )) +} diff --git a/libs/extractor/src/jsx_semantics_tests/binding_edges.rs b/libs/extractor/src/jsx_semantics_tests/binding_edges.rs new file mode 100644 index 000000000..738965300 --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/binding_edges.rs @@ -0,0 +1,184 @@ +use super::whole::evaluate; +use super::*; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case("(Box)")] +#[case("Box as typeof Box")] +#[case("Box satisfies typeof Box")] +#[serial] +fn wrapped_component_bindings_when_create_element_reads_them_compile(#[case] component: &str) { + let source = format!( + "{BOX}import {{createElement}} from 'react'; export const a=()=>createElement({component},{{color:'red'}},'child');" + ); + let actual = evaluate(&source, "a()"); + assert_eq!(actual.element["type"], "div"); + assert_eq!(actual.element["children"], serde_json::json!(["child"])); +} + +#[test] +#[serial] +fn shadowed_undefined_when_used_as_props_is_forwarded() { + let source = format!( + "{BOX}import {{createElement}} from 'react';export const a=(undefined)=>createElement(Box,undefined,'child');" + ); + let actual = evaluate(&source, "a({id:'kept'})"); + assert_eq!(actual.element["props"]["id"], "kept"); +} + +#[test] +#[serial] +fn void_effect_when_used_as_props_runs_before_children() { + let source = format!( + "{BOX}import {{createElement}} from 'react';export const a=()=>createElement(Box,void trace.push('props'),(trace.push('children'),'child'));" + ); + let actual = evaluate(&source, "a()"); + assert_eq!(actual.trace, serde_json::json!(["props", "children"])); +} + +#[rstest] +#[case("undefined")] +#[case("void effect()")] +#[case("cond ? 1 : undefined")] +#[case("cond ? void effect() : 2")] +#[serial] +fn uncertain_empty_order_when_shadowed_or_effectful_is_located(#[case] order: &str) { + for element in [ + format!(""), + format!("jsx(Box,{{styleOrder:{order},color:'red'}})"), + ] { + let source = + format!("{BOX}{JSX_RUNTIME}export const a=(undefined,effect,cond)=>{element};"); + let message = error(&source); + assert!(message.starts_with("a.tsx:3:"), "{message}"); + assert!(message.contains(order), "{message}"); + } +} + +#[test] +#[serial] +fn shadowed_globals_when_spreads_copy_props_do_not_capture_generated_names() { + let source = format!( + "{BOX}export const a=(Object,undefined,__devupSpread0,__devupValue0)=> ;" + ); + let actual = evaluate(&source, "a(null,'not-undefined',{color:void 0},'red')"); + assert!( + actual.element["props"]["style"] + .as_object() + .is_some_and(serde_json::Map::is_empty) + ); +} + +#[rstest] +#[case("jsx(Box,{id:first(),as:type(),color:'red',...rest},child())")] +#[case("createElement(Box,{id:first(),as:type(),color:'red',...rest},child())")] +#[case("React.createElement(Box,{id:first(),as:type(),color:'red',...rest},child())")] +#[serial] +fn dynamic_as_when_props_are_captured_keeps_type_at_its_source_position(#[case] element: &str) { + let source = format!( + "{BOX}{JSX_RUNTIME}import React,{{createElement}} from 'react';export const a=(first,type,rest,child)=>{element};" + ); + let actual = evaluate( + &source, + "a(()=>{trace.push('id');return 'id';},()=>{trace.push('type');return 'section';},{get color(){trace.push('spread');return 'blue';}},()=>{trace.push('child');return 'child';})", + ); + assert_eq!(actual.element["type"], "section"); + assert_eq!( + actual.trace, + serde_json::json!(["id", "type", "spread", "child"]) + ); +} + +#[test] +#[serial] +fn dynamic_as_when_member_factory_looks_up_a_receiver_keeps_it() { + let source = format!( + "{BOX}import React from 'react';export const a=()=>React.createElement(Box,{{color:(trace.push('color'),'red'),as:(trace.push('type'),'a')}},(trace.push('child'),'child'));" + ); + let actual = evaluate( + &source, + "(()=>{React.marker='receiver';Object.defineProperty(React,'createElement',{get(){trace.push('callee');return function(type,props,...children){return {type,props,children,receiver:this.marker};};}});return a();})()", + ); + assert_eq!(actual.element["receiver"], "receiver"); + assert_eq!( + actual.trace, + serde_json::json!(["callee", "color", "type", "child"]) + ); +} + +#[test] +#[serial] +fn shadowed_undefined_when_a_conditional_style_is_absent_stays_absent() { + let source = + format!("{BOX}export const a=(undefined,active,value)=>;"); + let actual = evaluate(&source, "a('incorrect',false,'red')"); + assert!( + actual.element["props"]["style"] + .as_object() + .is_none_or(serde_json::Map::is_empty), + "{}", + actual.element + ); +} + +#[test] +#[serial] +fn locally_compiled_keyframes_when_capture_precedes_props_keep_static_rules() { + let source = "import {Box,keyframes} from '@devup-ui/react';export function a(props){const spin=keyframes({from:{opacity:0},to:{opacity:1}});return ;}"; + let actual = output(source); + assert!( + static_styles(&actual) + .iter() + .any(|(property, _)| property == "animation-name"), + "{}", + actual.code + ); + assert!(!actual.styles.iter().any(|style|matches!(style,ExtractStyleValue::Dynamic(style) if style.property()=="animation-name")),"{}",actual.code); +} + +#[rstest] +#[case("{take('child')}")] +#[case("createElement(Box,{id:take('id'),as:type,...rest,title:take('title')},take('child'))")] +#[serial] +fn absent_dynamic_as_when_captured_defaults_without_reordering_later_props(#[case] element: &str) { + let source = format!( + "{BOX}import {{createElement}} from 'react';export const a=(type,rest,take)=>{element};" + ); + let actual = evaluate( + &source, + "a(null,{get x(){trace.push('spread');return 1;}},key=>{trace.push(key);return key;})", + ); + assert_eq!(actual.element["type"], "div"); + assert_eq!( + actual.trace, + serde_json::json!(["id", "spread", "title", "child"]) + ); +} + +#[rstest] +#[case("({lg:'large',sm:'small'})[size]")] +#[case("({lg:'large',sm:'small'} as const)[size]")] +#[serial] +fn wrapped_written_typography_when_captured_keeps_its_known_tokens(#[case] value: &str) { + let source = format!( + "{BOX}export const a=(size,rest)=>;" + ); + let actual = output(&source); + assert!( + actual + .styles + .iter() + .any(|style| matches!(style,ExtractStyleValue::Typography(name) if name=="large")), + "{}", + actual.code + ); + assert!( + actual + .styles + .iter() + .any(|style| matches!(style,ExtractStyleValue::Typography(name) if name=="small")), + "{}", + actual.code + ); +} diff --git a/libs/extractor/src/jsx_semantics_tests/calls.rs b/libs/extractor/src/jsx_semantics_tests/calls.rs new file mode 100644 index 000000000..57e60a571 --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/calls.rs @@ -0,0 +1,201 @@ +use super::*; +use serial_test::serial; + +const CREATE_ELEMENT: &str = "import { createElement } from 'react';\n"; + +#[test] +#[serial] +fn a_call_gives_way_to_a_spread_as_an_element_does() { + let output = output(&format!( + "{BOX}{JSX_RUNTIME}export const a = (rest) => jsx(Box, {{ color: 'red', ...rest }});\nexport const b = (rest) => jsx(Box, {{ ...rest, color: 'red' }});" + )); + let found = slots(&output); + + assert_eq!(found.len(), 1); + assert_eq!(found[0].fallback, "red"); + assert_eq!( + read(&found[0].identifier, "const rest = { color: 'blue' };"), + "\"blue\"" + ); + assert_eq!(read(&found[0].identifier, "const rest = {};"), "undefined"); + assert_eq!(static_styles(&output).len(), 1); +} + +#[test] +#[serial] +fn a_call_gives_the_last_own_key_of_repeated_spreads_and_the_final_explicit_style() { + let output = output(&format!( + "{BOX}{JSX_RUNTIME}export const a = (first, last) => jsx(Box, {{ color: 'red', ...first, color: 'blue', ...last }});" + )); + let found = slots(&output); + + assert_eq!(found.len(), 1); + assert_eq!(found[0].fallback, "blue"); + let both = |first: &str, last: &str| format!("const first = {first}; const last = {last};"); + assert_eq!( + read(&found[0].identifier, &both("{ color: 'x' }", "{}")), + "undefined" + ); + assert_eq!( + read(&found[0].identifier, &both("{}", "{ color: undefined }")), + "undefined" + ); + assert_eq!( + read(&found[0].identifier, &both("{}", "{ color: 'y' }")), + "\"y\"" + ); +} + +#[test] +#[serial] +fn a_call_keeps_the_explicit_style_written_after_its_spread_without_a_slot() { + let output = output(&format!( + "{BOX}{JSX_RUNTIME}export const a = (rest) => jsx(Box, {{ color: 'red', ...rest, color: 'blue' }});" + )); + + assert_eq!(slots(&output).len(), 0); + assert_eq!( + static_styles(&output), + vec![("color".to_string(), "blue".to_string())] + ); +} + +#[test] +#[serial] +fn a_call_replaces_an_alias_written_earlier_in_the_props() { + let output = output(&format!( + "{BOX}{JSX_RUNTIME}export const a = (rest) => jsx(Box, {{ bg: 'red', background: 'blue', ...rest }});" + )); + let found = slots(&output); + + assert_eq!(found.len(), 1); + assert_eq!(found[0].fallback, "blue"); +} + +#[test] +#[serial] +fn a_call_with_a_runtime_value_does_not_bring_it_back_over_a_later_own_undefined() { + let output = output(&format!( + "{BOX}{JSX_RUNTIME}export const a = (rest, x) => jsx(Box, {{ p: x, ...rest }});" + )); + let identifier = output + .styles + .iter() + .find_map(|style| match style { + ExtractStyleValue::Dynamic(dynamic) => { + Some(captures::materialized(dynamic.identifier(), &output.code)) + } + _ => None, + }) + .unwrap_or_else(|| panic!("a dynamic style: {}", output.code)); + + assert_eq!( + read(&identifier, "const x = 5; const rest = { p: undefined };"), + "undefined" + ); + assert_eq!(read(&identifier, "const x = 5; const rest = {};"), "5"); +} + +#[test] +#[serial] +fn a_call_gives_way_with_the_keys_of_a_literal_spread_before_an_unknown_one() { + let output = output(&format!( + "{BOX}{JSX_RUNTIME}export const a = (rest) => jsx(Box, {{ ...{{ color: 'red' }}, ...rest }});" + )); + let found = slots(&output); + + assert_eq!(found.len(), 1); + assert_eq!(found[0].fallback, "red"); +} + +#[test] +#[serial] +fn a_call_reads_its_spread_once() { + let output = output(&format!( + "{BOX}{JSX_RUNTIME}export const a = (f) => jsx(Box, {{ color: 'red', ...f() }});" + )); + + assert_eq!(output.code.matches("f()").count(), 1, "{}", output.code); + assert!(output.code.contains("__devupSpread0"), "{}", output.code); +} + +#[test] +#[serial] +fn a_call_with_a_selector_object_before_a_spread_is_an_error() { + let message = error(&format!( + "{BOX}{JSX_RUNTIME}export const a = (rest) => jsx(Box, {{ _hover: {{ bg: 'red' }}, ...rest }});" + )); + + assert!(message.contains("cannot use `_hover`"), "{message}"); +} + +#[test] +#[serial] +fn a_call_keeps_the_props_it_does_not_compile() { + let rendered = code(&format!( + "{BOX}{JSX_RUNTIME}export const a = (rest) => jsx(Box, {{ as: 'a', id: 'x', styleOrder: 3, color: 'red', ...rest }});" + )); + + assert!(rendered.contains("jsx(\"a\""), "{rendered}"); + assert!(rendered.contains("id: \"x\""), "{rendered}"); +} + +#[test] +#[serial] +fn a_key_that_is_not_an_identifier_is_read_by_name_in_a_call() { + let rendered = code(&format!( + "{BOX}{JSX_RUNTIME}export const a = (rest) => jsx(Box, {{ 'background-color': 'red', ...rest }});" + )); + + assert!(rendered.contains("[\"background-color\"]"), "{rendered}"); +} + +#[test] +#[serial] +fn a_call_with_suspending_props_captures_them_in_the_caller() { + let rendered = code(&format!( + "{BOX}{JSX_RUNTIME}export const a = async (k, f, g) => jsx(Box, {{ color: 'red', [k]: await g(), ...f() }});" + )); + + assert_eq!(rendered.matches("f()").count(), 1, "{rendered}"); + assert!(rendered.contains("await g()"), "{rendered}"); +} + +#[test] +#[serial] +fn create_element_props_give_way_to_a_spread_after_an_explicit_style() { + let output = output(&format!( + "{CREATE_ELEMENT}{BOX}export const a = (rest) => createElement(Box, {{ color: 'red', ...rest }});" + )); + let found = slots(&output); + + assert_eq!(found.len(), 1); + assert_eq!( + read(&found[0].identifier, "const rest = { color: 'blue' };"), + "\"blue\"" + ); +} + +#[test] +#[serial] +fn create_element_gives_the_final_explicit_style_over_repeated_spreads() { + let output = output(&format!( + "{CREATE_ELEMENT}{BOX}export const a = (first, last) => createElement(Box, {{ color: 'red', ...first, color: 'blue', ...last }}, 'child');" + )); + let found = slots(&output); + + assert_eq!(found.len(), 1); + assert_eq!(found[0].fallback, "blue"); + assert!(output.code.contains("\"child\""), "{}", output.code); +} + +#[test] +#[serial] +fn create_element_with_props_held_in_a_value_gives_way_to_them() { + let output = output(&format!( + "{CREATE_ELEMENT}{BOX}export const a = (p) => createElement(Box, p, 'x');" + )); + + assert!(output.code.contains("...p"), "{}", output.code); + assert!(output.code.contains("?.className"), "{}", output.code); +} diff --git a/libs/extractor/src/jsx_semantics_tests/capture_edges.rs b/libs/extractor/src/jsx_semantics_tests/capture_edges.rs new file mode 100644 index 000000000..239e58010 --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/capture_edges.rs @@ -0,0 +1,70 @@ +use super::whole::evaluate; +use super::*; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case("false && load()", false)] +#[case("true && load()", true)] +#[case("null ?? load()", true)] +#[case("false || load()", true)] +#[serial] +fn literal_logical_when_the_right_side_runs_only_if_selected( + #[case] value: &str, + #[case] called: bool, +) { + let source = format!("{BOX}export const a=(load)=>;"); + let actual = evaluate(&source, "a(()=>{trace.push('load');return 'blue';})"); + assert_eq!( + actual.trace, + if called { + serde_json::json!(["load"]) + } else { + serde_json::json!([]) + } + ); +} + +#[test] +#[serial] +fn responsive_iterator_when_captured_runs_before_later_props_and_keeps_holes() { + let source = format!( + "{BOX}export const a=(items,take,later)=>;" + ); + let actual = evaluate( + &source, + "a({[Symbol.iterator](){trace.push('iterator');return [1][Symbol.iterator]();}},()=>{trace.push('take');return '8px';},()=>{trace.push('later');return 'id';})", + ); + assert_eq!( + actual.trace, + serde_json::json!(["iterator", "take", "later"]) + ); +} + +#[test] +#[serial] +fn child_suspension_when_empty_and_constant_children_precede_it_keeps_them() { + let source = format!( + "{BOX}export function* a(rest){{return {{/* empty */}}{{'constant'}}{{yield 'pause'}};}}" + ); + let actual = evaluate( + &source, + "(()=>{const iterator=a({});iterator.next();return iterator.next('child').value;})()", + ); + assert_eq!( + actual.element["children"], + serde_json::json!(["constant", "child"]) + ); +} + +#[test] +#[serial] +fn unrelated_react_call_when_it_receives_a_compile_only_component_is_not_a_factory() { + let message = error(&format!( + "{BOX}import * as React from 'react';export const a=React.cloneElement(Box,{{}});" + )); + assert!( + message.starts_with("a.tsx:2:") && message.contains("Box"), + "{message}" + ); +} diff --git a/libs/extractor/src/jsx_semantics_tests/capture_preservation.rs b/libs/extractor/src/jsx_semantics_tests/capture_preservation.rs new file mode 100644 index 000000000..f8013f0ca --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/capture_preservation.rs @@ -0,0 +1,43 @@ +use super::*; +use serial_test::serial; + +#[test] +#[serial] +fn class_templates_when_captured_keep_their_static_tailwind_rules() { + let actual = output(&format!( + "{BOX}export const a=(get,rest)=>;" + )); + let styles = static_styles(&actual); + assert!( + styles + .iter() + .any(|(property, value)| property == "color" && value == "#3B82F6"), + "{styles:?}" + ); + assert!( + styles + .iter() + .any(|(property, value)| property == "padding" && value == "1rem"), + "{styles:?}" + ); + assert_eq!(actual.code.matches("get()").count(), 1); +} + +#[test] +#[serial] +fn computed_literal_selector_keys_when_captured_keep_the_static_rule() { + let actual = output(&format!( + "{BOX}{JSX_RUNTIME}export const a=(rest)=>jsx(Box,{{['_hover']:{{bg:'blue'}},color:'red'}});" + )); + assert!(actual.styles.iter().any(|style|matches!(style,ExtractStyleValue::Static(style) if style.value=="blue"&&style.selector().is_some())),"{}",actual.code); +} + +#[test] +#[serial] +fn a_computed_literal_selector_when_an_unknown_key_can_replace_it_is_located() { + let message = error(&format!( + "{BOX}{JSX_RUNTIME}export const a=(key)=>jsx(Box,{{['_hover']:{{bg:'blue'}},[key]:'red'}});" + )); + assert!(message.contains("cannot use `_hover`"), "{message}"); + assert!(message.starts_with("a.tsx:3:"), "{message}"); +} diff --git a/libs/extractor/src/jsx_semantics_tests/captures.rs b/libs/extractor/src/jsx_semantics_tests/captures.rs new file mode 100644 index 000000000..ce0306eb3 --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/captures.rs @@ -0,0 +1,66 @@ +use oxc_allocator::Allocator; +use oxc_ast::ast::{CallExpression, Expression}; +use oxc_ast_visit::{Visit, walk}; +use oxc_parser::Parser; +use oxc_span::SourceType; + +#[derive(Default)] +struct Captures { + wrappers: Vec<(Vec, Vec)>, +} + +impl<'a> Visit<'a> for Captures { + fn visit_call_expression(&mut self, call: &CallExpression<'a>) { + if let Expression::ArrowFunctionExpression(arrow) = + crate::utils::unwrap_syntax_only(&call.callee) + { + let names: Vec<_> = arrow + .params + .items + .iter() + .filter_map(|parameter| { + parameter + .pattern + .get_identifier_name() + .map(|name| name.to_string()) + }) + .collect(); + if names.iter().any(|name| { + name.starts_with("__devupSpread") + || name.starts_with("__devupValue") + || name.starts_with("__devupBranch") + }) { + let values = call + .arguments + .iter() + .map(crate::utils::readable_argument) + .collect(); + self.wrappers.push((names, values)); + } + } + walk::walk_call_expression(self, call); + } +} + +pub(super) fn materialized(identifier: &str, code: &str) -> String { + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, code, SourceType::tsx()).parse(); + let mut captures = Captures::default(); + captures.visit_program(&parsed.program); + captures + .wrappers + .iter() + .rev() + .fold(identifier.to_string(), |expression, (names, values)| { + if names.iter().any(|name| expression.contains(name)) { + format!( + "(({}) => ({}))({})", + names.join(","), + expression, + values.join(",") + ) + } else { + expression + } + }) +} diff --git a/libs/extractor/src/jsx_semantics_tests/create_element.rs b/libs/extractor/src/jsx_semantics_tests/create_element.rs new file mode 100644 index 000000000..a782fbee8 --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/create_element.rs @@ -0,0 +1,145 @@ +use super::*; +use serial_test::serial; + +#[test] +#[serial] +fn create_element_compiles_by_binding_whatever_it_is_named() { + let rendered = code(&format!( + "import React, {{ createElement as make }} from 'react';\n{BOX}export const named = make(Box, {{ color: 'red' }}, 'child');\nexport const namespace = React.createElement(Box, {{ bg: 'blue' }}, 'a', 'b');" + )); + + assert!( + rendered.contains("make(\"div\", { className: \"color-0-red--255-a\" }, \"child\")"), + "{rendered}" + ); + assert!( + rendered.contains( + "React.createElement(\"div\", { className: \"background-0-blue--255-a\" }, \"a\", \"b\")" + ), + "{rendered}" + ); + assert!(!rendered.contains("Box"), "{rendered}"); +} + +#[test] +#[serial] +fn create_element_through_a_namespace_import_and_require_compiles() { + let rendered = code(&format!( + "import * as R from 'react';\n{BOX}const React = require('react');\nconst {{ createElement: ce }} = require('react');\nexport const a = R.createElement(Box, {{ bg: 'red' }});\nexport const b = React.createElement(Box, {{ bg: 'blue' }});\nexport const c = ce(Box, {{ bg: 'green' }});" + )); + + assert_eq!(rendered.matches("(\"div\"").count(), 3, "{rendered}"); +} + +#[test] +#[serial] +fn create_element_keeps_children_key_and_ref() { + let rendered = code(&format!( + "import {{ createElement }} from 'react';\n{BOX}export const a = (r) => createElement(Box, {{ bg: 'blue', key: 1, ref: r }}, 'a', createElement(Box));" + )); + + assert!(rendered.contains("key: 1"), "{rendered}"); + assert!(rendered.contains("ref: r"), "{rendered}"); + assert!( + rendered.contains("\"a\", createElement(\"div\", {})"), + "{rendered}" + ); +} + +#[test] +#[serial] +fn create_element_without_props_builds_the_element() { + let rendered = code(&format!( + "import {{ createElement }} from 'react';\n{BOX}export const a = createElement(Box);\nexport const b = createElement(Box, null, 'x');\nexport const c = createElement(Box, undefined);" + )); + + assert_eq!( + rendered.matches("createElement(\"div\", {}").count(), + 3, + "{rendered}" + ); +} + +#[test] +#[serial] +fn create_element_with_props_held_in_a_value_spreads_them() { + let rendered = code(&format!( + "import {{ createElement }} from 'react';\n{BOX}export const a = (p) => createElement(Box, p, 'x');\nexport const b = (f) => createElement(Box, f(), 'y');\nexport const c = createElement(Box, {{ p: 1 }} as {{ p: number }});" + )); + + assert!(rendered.contains("...p"), "{rendered}"); + assert!( + rendered.contains("className: __devupSpread0?.className || \"\""), + "{rendered}" + ); + assert!( + rendered.contains("createElement(\"div\", ((__devupSpread"), + "{rendered}" + ); + assert!( + rendered.contains("className: \"padding-0-4px--255-a\""), + "{rendered}" + ); +} + +#[test] +#[serial] +fn a_shadowed_create_element_is_not_react() { + let message = error(&format!( + "import {{ createElement }} from 'react';\n{BOX}export const a = (createElement) => createElement(Box, {{ color: 'red' }});" + )); + + assert!(message.contains("`Box` is read at runtime"), "{message}"); +} + +#[test] +#[serial] +fn a_shadowed_react_is_not_react() { + let message = error(&format!( + "import React from 'react';\n{BOX}export const a = (React) => React.createElement(Box, {{ color: 'red' }});" + )); + + assert!(message.contains("`Box` is read at runtime"), "{message}"); +} + +#[test] +#[serial] +fn create_element_of_anything_else_is_left_alone() { + let rendered = code(&format!( + "import {{ createElement }} from 'react';\n{BOX}export const a = createElement('div', {{ color: 'red' }});\nexport const b = createElement(Foo, {{ color: 'red' }});\nexport const c = ;" + )); + + assert!( + rendered.contains("createElement(\"div\", { color: \"red\" })"), + "{rendered}" + ); + assert!( + rendered.contains("createElement(Foo, { color: \"red\" })"), + "{rendered}" + ); +} + +#[test] +#[serial] +fn create_element_given_a_spread_argument_is_read_at_runtime() { + let message = error(&format!( + "import {{ createElement }} from 'react';\n{BOX}export const a = (args) => createElement(Box, ...args);" + )); + + assert!(message.contains("`Box` is read at runtime"), "{message}"); +} + +#[test] +#[serial] +fn create_element_props_that_hold_nothing_build_an_empty_object() { + let rendered = code(&format!( + "import React, {{ createElement, useState }} from 'react';\n{BOX}export const a = createElement(Box, void 0);\nexport const b = React.createElement(Box, null);\nexport const c = useState(0);" + )); + + assert_eq!( + rendered.matches("createElement(\"div\", {})").count(), + 2, + "{rendered}" + ); + assert!(rendered.contains("= useState(0)"), "{rendered}"); +} diff --git a/libs/extractor/src/jsx_semantics_tests/css_prop.rs b/libs/extractor/src/jsx_semantics_tests/css_prop.rs new file mode 100644 index 000000000..dd418477a --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/css_prop.rs @@ -0,0 +1,70 @@ +use super::*; +use serial_test::serial; + +fn emotion_error(source: &str) -> String { + match compile_emotion(source) { + Err(message) => message, + Ok(compiled) => panic!("the file compiles: {}", compiled.code), + } +} + +fn emotion_output(source: &str) -> ExtractOutput { + compile_emotion(source).unwrap_or_else(|error| panic!("the file does not compile: {error}")) +} + +#[test] +#[serial] +fn the_css_prop_before_a_spread_is_a_located_error() { + let message = emotion_error(&format!( + "{EMOTION}export const a = (rest) =>
;" + )); + + assert!( + message.contains("`css` on `
` cannot use `...rest`"), + "{message}" + ); + assert!(message.starts_with("a.tsx:2:"), "{message}"); + assert!( + message.contains("write the spread before `css`"), + "{message}" + ); +} + +#[test] +#[serial] +fn the_css_prop_after_a_spread_compiles() { + let output = emotion_output(&format!( + "{EMOTION}export const a = (rest) =>
;" + )); + + assert_eq!( + static_styles(&output), + vec![("color".to_string(), "red".to_string())] + ); +} + +#[test] +#[serial] +fn the_css_prop_of_a_call_before_a_spread_is_a_located_error() { + let message = emotion_error( + "import { jsx } from '@emotion/react';\nexport const a = (rest) => jsx('div', { css: { color: 'red' }, ...rest });", + ); + + assert!( + message.contains("`css` on `
` cannot use `...rest`"), + "{message}" + ); +} + +#[test] +#[serial] +fn the_css_prop_of_a_call_after_a_spread_compiles() { + let output = emotion_output( + "import { jsx } from '@emotion/react';\nexport const a = (rest) => jsx('div', { ...rest, css: { color: 'red' } });", + ); + + assert_eq!( + static_styles(&output), + vec![("color".to_string(), "red".to_string())] + ); +} diff --git a/libs/extractor/src/jsx_semantics_tests/errors.rs b/libs/extractor/src/jsx_semantics_tests/errors.rs new file mode 100644 index 000000000..bc120c15a --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/errors.rs @@ -0,0 +1,81 @@ +use super::*; +use serial_test::serial; + +#[test] +#[serial] +fn a_selector_object_before_a_spread_is_a_located_error() { + let message = error(&format!( + "{BOX}export const a = (rest) => ;" + )); + + assert!( + message.starts_with("a.tsx:3:3: `` cannot use `_hover`"), + "{message}" + ); + assert!( + message.contains("write the spread before `_hover`"), + "{message}" + ); +} + +#[test] +#[serial] +fn a_selector_object_after_a_spread_keeps_its_rule() { + let output = output(&format!( + "{BOX}export const a = (rest) => ;" + )); + + assert_eq!(slots(&output).len(), 0); + assert_eq!(static_styles(&output).len(), 1); +} + +#[test] +#[serial] +fn a_runtime_value_under_a_selector_before_a_spread_is_an_error() { + let message = error(&format!( + "{BOX}export const a = (rest, x) => ;" + )); + + assert!(message.contains("cannot use `_hover`"), "{message}"); +} + +#[test] +#[serial] +fn shapes_no_variable_can_follow_are_located_errors() { + for (attribute, key) in [ + ("typography=\"body\"", "typography"), + ("bg={['red', 'blue'][index]}", "bg"), + ("positioning={mode}", "positioning"), + ("selectors={{ '& > a': { color: 'red' } }}", "selectors"), + ] { + let message = error(&format!( + "{BOX}export const a = (rest, index, mode) => ;" + )); + + assert!( + message.contains(&format!("cannot use `{key}`")), + "{attribute}: {message}" + ); + } +} + +#[test] +#[serial] +fn a_value_the_build_cannot_read_stays_an_error_before_a_spread() { + let message = error(&format!( + "{BOX}export const a = (rest) => 1}} {{...rest}} />;" + )); + + assert!(message.contains("cannot use `() => 1`"), "{message}"); +} + +#[test] +#[serial] +fn a_spread_beside_suspending_props_is_evaluated_once() { + let rendered = code(&format!( + "{BOX}export const a = async (f, load) => ;" + )); + + assert_eq!(rendered.matches("f()").count(), 1, "{rendered}"); + assert!(rendered.contains("await load()"), "{rendered}"); +} diff --git a/libs/extractor/src/jsx_semantics_tests/evaluation_edges.rs b/libs/extractor/src/jsx_semantics_tests/evaluation_edges.rs new file mode 100644 index 000000000..0cff9f962 --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/evaluation_edges.rs @@ -0,0 +1,89 @@ +use super::whole::evaluate; +use super::*; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case("active || [2,load()]", "false", true)] +#[case("active || [2,load()]", "'12px'", false)] +#[case("active ?? [2,load()]", "null", true)] +#[case("active ?? [2,load()]", "0", false)] +#[serial] +fn lazy_logical_arrays_when_captured_evaluate_only_the_selected_branch( + #[case] value: &str, + #[case] active: &str, + #[case] loads: bool, +) { + let source = + format!("{BOX}export const a=(active,load,rest)=>;"); + let actual = evaluate( + &source, + &format!( + "a({active},()=>{{trace.push('branch');return '4px';}},{{get id(){{trace.push('spread');return 'id';}}}})" + ), + ); + assert_eq!( + actual.trace, + if loads { + serde_json::json!(["branch", "spread"]) + } else { + serde_json::json!(["spread"]) + } + ); +} + +#[test] +#[serial] +fn literal_spread_with_effectful_keys_when_followed_by_an_unknown_spread_keeps_effects() { + let source = format!( + "{BOX}{JSX_RUNTIME}export const a=(load,rest)=>jsx(Box,{{...{{color:load(),id:load()}},...rest}});" + ); + let actual = evaluate( + &source, + "a(()=>{trace.push('load');return 'red';},{color:'blue'})", + ); + assert_eq!(actual.trace, serde_json::json!(["load", "load"])); + assert_eq!(actual.element["props"]["id"], "red"); +} + +#[test] +#[serial] +fn suspending_children_when_wrapped_leave_await_and_yield_in_the_callers_scope() { + for declaration in [ + "async function a(load){return {await load()}{load()};}", + "function* a(load){return {yield load()}{load()};}", + ] { + let rendered = code(&format!("{BOX}export {declaration}")); + assert!(rendered.contains("load()")); + assert!(rendered.contains("__devupValue")); + } +} + +#[test] +#[serial] +fn trailing_children_spread_when_dynamic_as_is_captured_evaluates_the_iterable_once() { + let source = format!( + "{BOX}import {{createElement}} from 'react';export const a=(type,rest,kids)=>createElement(Box,{{as:type(),color:'red',...rest}},...kids());" + ); + let actual = evaluate( + &source, + "a(()=>{trace.push('type');return 'section';},{get id(){trace.push('spread');return 'id';}},()=>{trace.push('children');return ['a','b'];})", + ); + assert_eq!( + actual.trace, + serde_json::json!(["type", "spread", "children"]) + ); + assert_eq!(actual.element["children"], serde_json::json!(["a", "b"])); +} + +#[test] +#[serial] +fn duplicate_styles_when_a_throwing_earlier_read_is_discarded_still_throw() { + let source = format!("{BOX}export const a=(bad)=>;"); + assert!(code(&source).contains("bad")); + let actual = evaluate( + &source, + "(()=>{try{return a(missing);}catch(error){return error.name;}})()", + ); + assert_eq!(actual.element, "ReferenceError"); +} diff --git a/libs/extractor/src/jsx_semantics_tests/forwarded.rs b/libs/extractor/src/jsx_semantics_tests/forwarded.rs new file mode 100644 index 000000000..cdbf86320 --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/forwarded.rs @@ -0,0 +1,82 @@ +use super::whole::evaluate; +use super::*; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case( + r#""#, + "spread", + 2 +)] +#[case( + r#""#, + "direct", + 1 +)] +#[case( + r#""#, + "spread", + 2 +)] +#[case( + r#""#, + "last", + 2 +)] +#[case( + r#""#, + "spread", + 2 +)] +#[case(r"", "last", 3)] +#[serial] +fn forwarded_props_when_later_values_overwrite_them_match_original_props( + #[case] element: &str, + #[case] class: &str, + #[case] opacity: u8, +) { + let actual = evaluate( + &format!("{BOX}export const a=(rest,more)=>{element};"), + r"a({'className':'spread','style':{'opacity':2}},{'className':'last','style':{'opacity':3}})", + ); + assert_eq!(actual.element["props"]["className"], class); + assert_eq!(actual.element["props"]["style"]["opacity"], opacity); +} + +#[test] +#[serial] +fn shadowed_object_when_multiple_spreads_select_props_does_not_replace_the_intrinsic() { + let actual = evaluate( + &format!( + "{BOX}export const a=(Object,first,last)=>;" + ), + r"a(null,{'className':'first','style':{'opacity':2}},{'className':undefined,'style':undefined})", + ); + assert!( + actual.element["props"]["className"] + .as_str() + .is_some_and(|class| !class.contains("first") && !class.contains("direct")) + ); + assert!( + actual.element["props"]["style"] + .as_object() + .is_some_and(|style| !style.contains_key("opacity")) + ); +} + +#[test] +#[serial] +fn ordinary_attributes_when_styles_are_lowered_keep_source_order() { + let source = format!( + "{BOX}export const a=(take)=>{{take('child')}};" + ); + let actual = evaluate( + &source, + r"a(key=>{trace.push(key);return key==='style'?{'opacity':0.5}:key;})", + ); + assert_eq!( + actual.trace, + serde_json::json!(["id", "color", "class", "style", "title", "child"]) + ); +} diff --git a/libs/extractor/src/jsx_semantics_tests/gate_edges.rs b/libs/extractor/src/jsx_semantics_tests/gate_edges.rs new file mode 100644 index 000000000..20ae1e5c3 --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/gate_edges.rs @@ -0,0 +1,197 @@ +use super::whole::evaluate; +use super::*; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case("")] +#[case("createElement(Box,{...{color:first()},color:'blue'})")] +#[serial] +fn overwritten_known_spreads_when_compiled_still_evaluate_written_values(#[case] element: &str) { + let source = + format!("{BOX}import {{createElement}} from 'react';export const a=(first)=>{element};"); + let actual = evaluate(&source, "a(()=>{trace.push('first');return 'red';})"); + assert_eq!(actual.trace, serde_json::json!(["first"])); +} + +#[test] +#[serial] +fn known_spreads_when_style_keys_precede_dom_keys_keep_their_order() { + let source = + format!("{BOX}export const a=(take)=>;"); + let actual = evaluate(&source, "a(key=>{trace.push(key);return key;})"); + assert_eq!(actual.trace, serde_json::json!(["color", "id"])); +} + +#[test] +#[serial] +fn nested_copy_when_a_later_prop_changes_the_source_keeps_the_original_value() { + let source = format!( + "{BOX}export const a=(rest,later)=>;" + ); + let actual = evaluate( + &source, + "(()=>{let color='red';return a({get color(){trace.push('get');return color;}},()=>{trace.push('later');color='blue';return 'id';});})()", + ); + assert_eq!(actual.trace, serde_json::json!(["get", "later"])); + assert!( + actual.element["props"]["style"] + .as_object() + .is_some_and(|style| style.values().any(|value| value == "red")), + "{}", + actual.element + ); +} + +#[test] +#[serial] +fn literal_selector_when_early_snapshot_would_hide_it_reports_the_original_key() { + let message = error(&format!( + "{BOX}export const a=(rest,key)=>;" + )); + assert!( + message.contains("_hover") && message.starts_with("a.tsx:2:"), + "{message}" + ); +} + +#[test] +#[serial] +fn computed_selection_when_a_key_object_coerces_runs_the_coercion_once() { + let source = format!( + "{BOX}export const a=(key,first,second)=>;" + ); + let actual = evaluate( + &source, + "a({toString(){trace.push('key');return 'a';}},'red','blue')", + ); + assert_eq!(actual.trace, serde_json::json!(["key"])); +} + +#[test] +#[serial] +fn template_interpolations_when_later_props_execute_keep_coercion_order() { + let source = format!( + "{BOX}export const a=(value,later)=>;" + ); + let actual = evaluate( + &source, + "a({toString(){trace.push('coerce');return 'external';}},()=>{trace.push('later');return 'other';})", + ); + assert_eq!(actual.trace, serde_json::json!(["coerce", "later"])); +} + +#[rstest] +#[case("2 || load()", "padding", "8px")] +#[case("'$primary' ?? load()", "padding", "$primary")] +#[serial] +fn exact_logical_left_when_right_is_dynamic_keeps_its_static_style( + #[case] value: &str, + #[case] property: &str, + #[case] expected: &str, +) { + let actual = output(&format!( + "{BOX}export const a=(load)=>;" + )); + assert!( + static_styles(&actual).contains(&(property.to_string(), expected.to_string())), + "{}", + actual.code + ); +} + +#[rstest] +#[case("`${color}` + ' !important'", true)] +#[case("`${color}` + ';'", false)] +#[serial] +fn binary_css_suffix_when_captured_is_not_left_in_the_variable( + #[case] value: &str, + #[case] important: bool, +) { + let source = format!("{BOX}export const a=(color)=>;"); + let actual = output(&source); + let runtime = evaluate(&source, "a('red')"); + assert!( + runtime.element["props"]["style"] + .as_object() + .is_some_and(|style| style.values().all(|value| value == "red")), + "{}", + runtime.element + ); + assert!(actual.styles.iter().any(|style|matches!(style,ExtractStyleValue::Dynamic(style) if style.important()==important)),"{}",actual.code); +} + +#[rstest] +#[case( + "async function a(active,load,rest){return ;}", + "await load()" +)] +#[case( + "function* a(active,load,rest){return ;}", + "yield load()" +)] +#[serial] +fn lazy_suspending_props_when_a_sync_wrapper_cannot_move_them_are_located( + #[case] declaration: &str, + #[case] written: &str, +) { + let message = error(&format!("{BOX}export {declaration}")); + assert!( + message.starts_with("a.tsx:2:") + && message.contains(written) + && !message.contains("__devup"), + "{message}" + ); +} + +#[test] +#[serial] +fn nested_iterator_when_later_props_execute_is_expanded_at_its_original_position() { + let source = format!( + "{BOX}export const a=(items,later)=>;" + ); + let actual = evaluate( + &source, + "a({[Symbol.iterator](){trace.push('iterator');return ['red'][Symbol.iterator]();}},()=>{trace.push('later');return 'id';})", + ); + assert_eq!(actual.trace, serde_json::json!(["iterator", "later"])); +} + +#[test] +#[serial] +fn missing_native_choice_when_classes_are_combined_emits_no_undefined_class() { + let source = + format!("{BOX}export const a=(key)=>;"); + let actual = evaluate(&source, "a('missing')"); + assert!( + actual.element["props"]["className"] + .as_str() + .is_some_and(|class| !class.contains("undefined")), + "{}", + actual.element + ); +} + +#[rstest] +#[case("@media")] +#[case("@supports")] +#[serial] +fn hidden_at_rule_when_an_opaque_literal_is_snapshotted_is_located(#[case] rule: &str) { + let message = error(&format!( + "{BOX}export const a=(rest,key)=>;" + )); + assert!( + message.starts_with("a.tsx:2:") && message.contains(rule), + "{message}" + ); +} + +#[test] +#[serial] +fn object_valued_conditional_template_when_selected_coerces_before_later_interpolations() { + let source = format!( + "{BOX}{JSX_RUNTIME}export const a=(active,later)=>jsx(Box,{{className:`${{active ? {{toString(){{trace.push('coerce');return 'external';}}}} : 'fallback'}} ${{later()}}`}});" + ); + let actual = evaluate(&source, "a(true,()=>{trace.push('later');return 'other';})"); + assert_eq!(actual.trace, serde_json::json!(["coerce", "later"])); +} diff --git a/libs/extractor/src/jsx_semantics_tests/ordered.rs b/libs/extractor/src/jsx_semantics_tests/ordered.rs new file mode 100644 index 000000000..a44d88b8e --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/ordered.rs @@ -0,0 +1,152 @@ +use super::whole::evaluate; +use super::*; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case("")] +#[case("jsx(Box, {color:first(), ...read()})")] +#[case("createElement(Box, {color:first(), ...read()})")] +#[case("")] +#[serial] +fn explicit_effects_when_overwritten_still_run_before_spreads(#[case] element: &str) { + let source = format!( + "{BOX}{JSX_RUNTIME}import {{createElement}} from 'react'; export const a = (first, read) => {element};" + ); + let actual = evaluate( + &source, + "a(() => {trace.push('first'); return 'red';}, () => {trace.push('spread'); return {color:'blue'};})", + ); + assert_eq!(actual.trace, serde_json::json!(["first", "spread"])); + assert_eq!(actual.element["props"]["color"], "blue"); +} + +#[rstest] +#[case("")] +#[case("jsx(Box, {color:'red', bg:'gray', ...rest})")] +#[case("createElement(Box, {color:'red', bg:'gray', ...rest})")] +#[serial] +fn getters_when_props_are_forwarded_and_selected_run_once(#[case] element: &str) { + let source = format!( + "{BOX}{JSX_RUNTIME}import {{createElement}} from 'react'; export const a = (rest) => {element};" + ); + let actual = evaluate( + &source, + "a({get color(){trace.push('color');return 'blue';},get className(){trace.push('class');return 'user';},get style(){trace.push('style');return {opacity:0.5};}})", + ); + assert_eq!(actual.trace, serde_json::json!(["color", "class", "style"])); + assert_eq!(actual.element["props"]["style"]["opacity"], 0.5); +} + +#[rstest] +#[case("")] +#[case("jsx(Box, {color:x, id:change()})")] +#[case("createElement(Box, {color:x, id:change()})")] +#[serial] +fn earlier_identifier_when_later_props_mutate_it_keeps_its_value(#[case] element: &str) { + let source = format!( + "{BOX}{JSX_RUNTIME}import {{createElement}} from 'react'; export function a() {{let x='red';function change(){{x='blue';return 'id';}} return {element};}}" + ); + let actual = evaluate(&source, "a()"); + let style = actual.element["props"]["style"] + .as_object() + .unwrap_or_else(|| panic!("{}", actual.element)); + assert!( + style.values().any(|value| value == "red"), + "{}", + actual.element + ); +} + +#[test] +#[serial] +fn mixed_branches_when_captured_stay_lazy_and_keep_static_spacing() { + let source = format!( + "{BOX}export const a=(active, value, rest) => ;" + ); + let actual = evaluate( + &source, + "a(true,()=>{trace.push('inactive');return '9px';},{get id(){trace.push('spread');return 'id';}})", + ); + assert_eq!(actual.trace, serde_json::json!(["spread"])); + assert!( + slots(&output(&source)) + .iter() + .any(|slot| slot.fallback == "8px") + ); +} + +#[test] +#[serial] +fn a_throwing_spread_when_props_precede_it_keeps_the_original_trace() { + let source = format!( + "{BOX}export const a=() => ;" + ); + let actual = evaluate( + &source, + "(()=>{try{return a();}catch(error){return error;}})()", + ); + assert_eq!(actual.element, "boom"); + assert_eq!(actual.trace, serde_json::json!(["id", "getter"])); +} + +#[test] +#[serial] +fn receiver_lookup_when_props_are_captured_precedes_props_and_children() { + let source = format!( + "{BOX}import React from 'react'; export const a=()=>React.createElement(Box,{{color:(trace.push('color'),'red'),...{{get id(){{trace.push('spread');return 'id';}}}}}},(trace.push('child'),'child'));" + ); + let actual = evaluate( + &source, + "(()=>{Object.defineProperty(React,'createElement',{get(){trace.push('callee');return h;}});return a();})()", + ); + assert_eq!( + actual.trace, + serde_json::json!(["callee", "color", "spread", "child"]) + ); +} + +#[rstest] +#[case("")] +#[case("jsx(Box,{p:choose() ? 2 : value(),bg:last()})")] +#[case("createElement(Box,{p:choose() ? 2 : value(),bg:last()})")] +#[serial] +fn conditions_when_classes_and_variables_read_them_run_once(#[case] element: &str) { + let source = format!( + "{BOX}{JSX_RUNTIME}import {{createElement}} from 'react';export const a=(choose,value,last)=>{element};" + ); + let actual = evaluate( + &source, + "a(()=>{trace.push('test');return false;},()=>{trace.push('branch');return '9px';},()=>{trace.push('last');return 'red';})", + ); + assert_eq!(actual.trace, serde_json::json!(["test", "branch", "last"])); +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn logical_branches_when_captured_keep_short_circuit_evaluation(#[case] active: bool) { + let source = format!( + "{BOX}export const a=(active,load,rest)=>;" + ); + let actual = evaluate( + &source, + &format!( + "a({active},()=>{{trace.push('branch');return '9px';}},{{get id(){{trace.push('spread');return 'id';}}}})" + ), + ); + assert_eq!( + actual.trace, + if active { + serde_json::json!(["branch", "spread"]) + } else { + serde_json::json!(["spread"]) + } + ); + assert!( + slots(&output(&source)) + .iter() + .any(|slot| slot.fallback == "8px") + ); +} diff --git a/libs/extractor/src/jsx_semantics_tests/repeated.rs b/libs/extractor/src/jsx_semantics_tests/repeated.rs new file mode 100644 index 000000000..ef966db44 --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/repeated.rs @@ -0,0 +1,198 @@ +use super::*; +use serial_test::serial; + +fn slot_of<'a>(found: &'a [Slot], property: &str) -> &'a Slot { + found + .iter() + .find(|slot| slot.property == property) + .unwrap_or_else(|| panic!("a slot for `{property}`")) +} + +#[test] +#[serial] +fn the_last_spread_having_the_key_wins_and_a_missing_key_keeps_the_earlier_one() { + let output = output(&format!( + "{BOX}export const a = (a, b) => ;" + )); + let found = slots(&output); + let color = &slot_of(&found, "color").identifier; + let padding = &slot_of(&found, "padding").identifier; + + assert_eq!(found.len(), 2); + let both = |a: &str, b: &str| format!("const a = {a}; const b = {b};"); + assert_eq!(read(color, &both("{ color: 'blue' }", "{}")), "\"blue\""); + assert_eq!( + read(color, &both("{ color: 'blue' }", "{ color: 'green' }")), + "\"green\"" + ); + assert_eq!(read(color, &both("{}", "{ color: 'green' }")), "\"green\""); + assert_eq!(read(color, &both("{}", "{}")), "undefined"); + assert_eq!(read(padding, &both("{ p: 2 }", "{}")), "undefined"); + assert_eq!(read(padding, &both("{}", "{ p: 2 }")), "2"); +} + +#[test] +#[serial] +fn a_later_own_undefined_clears_what_an_earlier_spread_gave() { + let output = output(&format!( + "{BOX}export const a = (first, last) => ;" + )); + let identifier = &slots(&output)[0].identifier; + let both = |first: &str, last: &str| format!("const first = {first}; const last = {last};"); + + assert_eq!( + read( + identifier, + &both("{ color: 'blue' }", "{ color: undefined }") + ), + "undefined" + ); + assert_eq!( + read(identifier, &both("{ color: 'blue' }", "{ color: null }")), + "null" + ); + assert_eq!( + read(identifier, &both("{ color: 'blue' }", "{}")), + "\"blue\"" + ); +} + +#[test] +#[serial] +fn an_explicit_style_written_after_the_spread_is_the_only_one_that_counts() { + let output = output(&format!( + "{BOX}export const a = (rest) => ;" + )); + + assert_eq!(slots(&output).len(), 0); + assert_eq!( + static_styles(&output), + vec![("color".to_string(), "blue".to_string())] + ); +} + +#[test] +#[serial] +fn an_alias_written_later_replaces_the_earlier_key_of_the_property() { + let output = output(&format!( + "{BOX}export const a = (rest) => ;" + )); + let found = slots(&output); + + assert_eq!(found.len(), 1); + assert_eq!(found[0].fallback, "blue"); + assert_eq!(found[0].variable, "--background-spread-background-0-"); +} + +#[test] +#[serial] +fn a_literal_spread_before_an_unknown_one_gives_way_with_its_own_keys() { + let output = output(&format!( + "{BOX}export const a = (rest) => ;" + )); + let found = slots(&output); + + assert_eq!( + found + .iter() + .map(|slot| (slot.property.as_str(), slot.fallback.as_str())) + .collect::>(), + vec![("color", "red"), ("padding", "4px")] + ); + assert_eq!( + read( + &slot_of(&found, "color").identifier, + "const rest = { color: 'blue', margin: 1 };" + ), + "\"blue\"" + ); + assert_eq!( + read( + &slot_of(&found, "padding").identifier, + "const rest = { color: 'blue', margin: 1 };" + ), + "undefined" + ); +} + +#[test] +#[serial] +fn a_literal_holding_a_spread_is_one_more_source_to_read() { + let output = output(&format!( + "{BOX}export const a = (a, b) => ;" + )); + let found = slots(&output); + + assert_eq!(found.len(), 1); + let both = |a: &str, b: &str| format!("const a = {a}; const b = {b};"); + assert_eq!( + read(&found[0].identifier, &both("{ color: 'x' }", "{}")), + "\"x\"" + ); + assert_eq!( + read( + &found[0].identifier, + &both("{ color: 'x' }", "{ color: 'y' }") + ), + "\"y\"" + ); +} + +#[test] +#[serial] +fn a_literal_spread_after_the_unknown_one_stays_a_plain_style() { + let output = output(&format!( + "{BOX}export const a = (rest) => ;" + )); + + assert_eq!(slots(&output).len(), 0); + assert_eq!( + static_styles(&output), + vec![("color".to_string(), "red".to_string())] + ); +} + +#[test] +#[serial] +fn a_literal_spread_replaced_by_a_later_attribute_leaves_no_slot() { + let output = output(&format!( + "{BOX}export const a = (rest) => ;" + )); + + assert_eq!(slots(&output).len(), 0); + assert_eq!( + static_styles(&output), + vec![("color".to_string(), "blue".to_string())] + ); +} + +#[test] +#[serial] +fn a_literal_spread_keeps_the_props_that_are_not_styles() { + let rendered = code(&format!( + "{BOX}export const a = (rest) => ;" + )); + + assert!(rendered.contains("id: \"x\""), "{rendered}"); + assert!(!rendered.contains("color: \"red\""), "{rendered}"); +} + +#[test] +#[serial] +fn two_attributes_of_one_property_do_not_share_a_variable_when_written_differently() { + let output = output(&format!( + "{BOX}export const a = (rest) => ;" + )); + let variables: std::collections::BTreeSet = slots(&output) + .iter() + .map(|slot| slot.variable.clone()) + .collect(); + + assert_eq!( + variables, + std::collections::BTreeSet::from([ + "--margin-left-spread-ml-0-".to_string(), + "--margin-right-spread-mx-0-".to_string() + ]) + ); +} diff --git a/libs/extractor/src/jsx_semantics_tests/slots.rs b/libs/extractor/src/jsx_semantics_tests/slots.rs new file mode 100644 index 000000000..c60d63b6a --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/slots.rs @@ -0,0 +1,259 @@ +use super::*; +use serial_test::serial; + +#[test] +#[serial] +fn explicit_style_wins_when_the_spread_is_written_before_it() { + let output = output(&format!( + "{BOX}export const a = (rest) => ;" + )); + + assert_eq!(slots(&output).len(), 0); + assert_eq!( + static_styles(&output), + vec![("color".to_string(), "red".to_string())] + ); + assert!(!output.code.contains("rest?.color"), "{}", output.code); +} + +#[test] +#[serial] +fn explicit_style_falls_back_when_a_spread_is_written_after_it() { + let output = output(&format!( + "{BOX}export const a = (rest) => ;" + )); + + let found = slots(&output); + assert_eq!(found.len(), 1); + assert_eq!(found[0].property, "color"); + assert_eq!(found[0].level, 0); + assert_eq!(found[0].variable, "--color-spread-color-0-"); + assert_eq!(found[0].fallback, "red"); + assert!( + output.code.contains("\"--color-spread-color-0-\":"), + "{}", + output.code + ); + assert!(output.code.contains("...rest"), "{}", output.code); +} + +#[test] +#[serial] +fn the_slot_holds_what_the_spread_assigns_as_it_is() { + let output = output(&format!( + "{BOX}export const a = (rest) => ;" + )); + let identifier = &slots(&output)[0].identifier; + + assert_eq!( + read(identifier, "const rest = { color: 'blue' };"), + "\"blue\"" + ); + assert_eq!(read(identifier, "const rest = { color: 3 };"), "3"); + assert_eq!(read(identifier, "const rest = { color: null };"), "null"); + assert_eq!(read(identifier, "const rest = {};"), "undefined"); + assert_eq!(read(identifier, "const rest = null;"), "undefined"); + assert_eq!( + read(identifier, "const rest = { color: undefined };"), + "undefined" + ); +} + +#[test] +#[serial] +fn a_key_the_spread_only_inherits_is_not_a_key_it_assigns() { + let output = output(&format!( + "{BOX}export const a = (rest) => ;" + )); + let identifier = &slots(&output)[0].identifier; + + assert_eq!( + read( + identifier, + "const rest = Object.create({ color: 'inherited' });" + ), + "undefined" + ); + assert_eq!( + read( + identifier, + "const rest = Object.defineProperty({}, 'color', { value: 'hidden' });" + ), + "undefined" + ); +} + +#[test] +#[serial] +fn a_getter_of_the_spread_runs_once_for_the_slot() { + let output = output(&format!( + "{BOX}export const a = (rest) => ;" + )); + let identifier = &slots(&output)[0].identifier; + + assert_eq!( + run(&format!( + "let reads = 0; const rest = Object.defineProperty({{}}, 'color', {{ enumerable: true, get() {{ reads += 1; return 'x'; }} }}); const slot = {identifier}; return reads + ':' + slot;" + )), + "1:x" + ); +} + +#[test] +#[serial] +fn keys_only_the_spread_gives_stay_untouched() { + let output = output(&format!( + "{BOX}export const a = (rest) => ;" + )); + + assert_eq!(slots(&output).len(), 1); + assert!(output.code.contains("...rest"), "{}", output.code); + assert!(output.code.contains("rest.t"), "{}", output.code); +} + +#[test] +#[serial] +fn responsive_values_share_one_override_variable_with_their_own_fallbacks() { + let output = output(&format!( + "{BOX}export const a = (rest) => ;" + )); + + let found = slots(&output); + assert_eq!( + found + .iter() + .map(|slot| (slot.level, slot.fallback.as_str())) + .collect::>(), + vec![(0, "red"), (1, "blue"), (3, "$primary")] + ); + assert!(found.iter().all(|slot| { + slot.identifier == found[0].identifier && slot.variable == "--background-spread-bg-0-" + })); + assert_eq!( + output + .code + .matches("\"--background-spread-bg-0-\":") + .count(), + 1, + "{}", + output.code + ); +} + +#[test] +#[serial] +fn distinct_fallbacks_are_distinct_classes() { + let output = output(&format!( + "{BOX}export const a = (rest) => <>;" + )); + + let classes: std::collections::BTreeSet = output + .styles + .iter() + .filter_map(|style| match style.extract(None) { + Some(StyleProperty::Variable { class_name, .. }) => Some(class_name), + _ => None, + }) + .collect(); + assert_eq!(classes.len(), 2, "{classes:?}"); +} + +#[test] +#[serial] +fn a_later_own_undefined_does_not_bring_back_the_runtime_value() { + let output = output(&format!( + "{BOX}export const a = (rest, x) => ;" + )); + + assert_eq!(slots(&output).len(), 0); + let identifier = output + .styles + .iter() + .find_map(|style| match style { + ExtractStyleValue::Dynamic(dynamic) => { + Some(captures::materialized(dynamic.identifier(), &output.code)) + } + _ => None, + }) + .unwrap_or_else(|| panic!("a dynamic style: {}", output.code)); + let setup = |rest: &str| format!("const x = 5; const rest = {rest};"); + assert_eq!(read(&identifier, &setup("{}")), "5"); + assert_eq!(read(&identifier, &setup("{ p: 3 }")), "3"); + assert_eq!(read(&identifier, &setup("{ p: undefined }")), "undefined"); + assert_eq!(read(&identifier, &setup("{ p: null }")), "null"); +} + +#[test] +#[serial] +fn a_conditional_style_gives_way_in_each_branch() { + let output = output(&format!( + "{BOX}export const a = (rest, on) => ;" + )); + + assert_eq!(slots(&output).len(), 2); +} + +#[test] +#[serial] +fn style_order_stays_with_a_style_that_gives_way() { + let output = output(&format!( + "{BOX}export const a = (rest) => ;" + )); + + assert_eq!(slots(&output).len(), 1); + assert!(output.code.contains("--4-"), "{}", output.code); +} + +#[test] +#[serial] +fn a_spread_source_is_evaluated_once_in_the_order_written() { + let output = output(&format!( + "{BOX}export const a = (f) => ;" + )); + + assert_eq!(output.code.matches("f()").count(), 1, "{}", output.code); + assert!(output.code.contains("__devupSpread0"), "{}", output.code); +} + +#[test] +#[serial] +fn non_identifier_spread_sources_are_read_through_parentheses() { + let output = output(&format!( + "{BOX}export const a = (p) => ;" + )); + + let identifier = &slots(&output)[0].identifier; + assert_eq!( + read(identifier, "const p = {on:null,b:{color:'blue'}};"), + "\"blue\"" + ); + assert_eq!(output.code.matches("p.on ?? p.b").count(), 1); +} + +#[test] +#[serial] +fn an_object_literal_holding_a_spread_or_a_computed_key_may_carry_the_key() { + let output = output(&format!( + "{BOX}export const a = (rest) => ;\nexport const b = (k) => ;" + )); + + let found = slots(&output); + assert_eq!(found.len(), 2); + let actual = super::whole::evaluate( + &format!( + "{BOX}export const a=(rest)=>; export const b=(k)=>;" + ), + "[a({color:'blue'}).props.color,b('color').props.color]", + ); + assert_eq!(actual.element, serde_json::json!(["blue", 1])); +} + +#[test] +#[serial] +fn a_key_that_is_not_an_identifier_is_read_by_name() { + let rendered = code(&format!( + "{BOX}export const a = (rest) => ;" + )); + + assert!(rendered.contains("[\"background-color\"]"), "{rendered}"); +} diff --git a/libs/extractor/src/jsx_semantics_tests/style_order.rs b/libs/extractor/src/jsx_semantics_tests/style_order.rs new file mode 100644 index 000000000..74b2b08ce --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/style_order.rs @@ -0,0 +1,78 @@ +use super::*; +use serial_test::serial; + +#[test] +#[serial] +fn style_order_the_build_knows_is_kept() { + let rendered = code(&format!( + "{BOX}const ORDER = 3;\nexport const a = ;\nexport const b = ;\nexport const c = ;\nexport const d = ;\nexport const e = ;\nexport const f = ;\nexport const g = ;\nexport const h = ;" + )); + + for order in ["--3-", "--5-", "--255-", "--1-", "--2-", "--7-"] { + assert!(rendered.contains(order), "{order}: {rendered}"); + } +} + +#[test] +#[serial] +fn a_runtime_style_order_is_a_located_error() { + let message = error(&format!( + "{BOX}export const a = (runtime) => ;" + )); + + assert!(message.starts_with("a.tsx:2:"), "{message}"); + assert!( + message.contains("`` cannot use `runtime`"), + "{message}" + ); +} + +#[test] +#[serial] +fn style_order_that_is_not_a_number_or_a_condition_of_numbers_is_an_error() { + for (order, written) in [ + ("{cond || 2}", "cond || 2"), + ("{cond ? 1 : other}", "cond ? 1 : other"), + ("{cond ? other : 2}", "cond ? other : 2"), + ("{cond && other}", "cond && other"), + ("\"abc\"", "\"abc\""), + ("
", ""), + ("<>", "<>..."), + ] { + let message = error(&format!( + "{BOX}export const a = (cond, other) => ;" + )); + + assert!( + message.contains(&format!("`` cannot use `{written}`")), + "{order}: {message}" + ); + } +} + +#[test] +#[serial] +fn a_call_with_a_runtime_style_order_is_a_located_error() { + for order in ["runtime", "cond ? 1 : runtime", "cond && runtime"] { + let message = error(&format!( + "{BOX}{JSX_RUNTIME}export const a = (cond, runtime) => jsx(Box, {{ styleOrder: {order}, color: 'red' }});" + )); + + assert!( + message.contains(&format!("cannot use `{order}`")), + "{order}: {message}" + ); + } +} + +#[test] +#[serial] +fn a_call_with_a_known_style_order_compiles() { + let rendered = code(&format!( + "{BOX}{JSX_RUNTIME}export const a = (cond) => jsx(Box, {{ styleOrder: cond ? 2 : 4, color: 'red' }});\nexport const b = jsx(Box, {{ styleOrder: 6 as number, color: 'red' }});\nexport const c = jsx(Box, {{ color: 'red' }});" + )); + + for order in ["--2-", "--4-", "--6-"] { + assert!(rendered.contains(order), "{order}: {rendered}"); + } +} diff --git a/libs/extractor/src/jsx_semantics_tests/styled.rs b/libs/extractor/src/jsx_semantics_tests/styled.rs new file mode 100644 index 000000000..fb020a634 --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/styled.rs @@ -0,0 +1,155 @@ +use super::*; +use serial_test::serial; + +const CARD: &str = + "import { Box, styled } from '@devup-ui/react';\nconst Card = styled(Box, { bg: 'white' });\n"; + +fn card(element: &str) -> ExtractOutput { + output(&format!("{CARD}export const a = (props) => {element};")) +} + +#[test] +#[serial] +fn a_styled_component_renders_the_props_it_is_given_before_its_own_styles() { + let rendered = code( + "import { styled } from '@devup-ui/react';\nconst Card = styled('div', { color: 'red' });\nexport const a = (rest) => ;", + ); + + let spread = rendered + .find("{...") + .unwrap_or_else(|| panic!("{rendered}")); + let own = rendered + .find("className=") + .unwrap_or_else(|| panic!("{rendered}")); + assert!(spread < own, "{rendered}"); +} + +#[test] +#[serial] +fn an_explicit_style_after_a_spread_compiles_into_the_component() { + let output = card(""); + + assert_eq!(slots(&output).len(), 0); + assert!( + static_styles(&output).contains(&("color".to_string(), "red".to_string())), + "{}", + output.code + ); + assert!( + output.code.contains(""); + let found = slots(&output); + + assert_eq!(found.len(), 1); + assert_eq!(found[0].property, "color"); + assert_eq!(found[0].fallback, "red"); + assert_eq!( + read(&found[0].identifier, "const props = { color: 'blue' };"), + "\"blue\"" + ); + assert_eq!( + read(&found[0].identifier, "const props = { color: undefined };"), + "undefined" + ); + assert!(output.code.contains(""); + + assert!( + output + .code + .contains("const Card = __devupForwardRef((__devupRefProps, __devupRef) =>"), + "{}", + output.code + ); + assert!(output.code.contains("ref: __devupRef"), "{}", output.code); + assert!( + output.code.contains("\"theme\": __devupOmit0"), + "{}", + output.code + ); + assert!( + output.code.contains("background-0-white--255"), + "{}", + output.code + ); +} + +#[test] +#[serial] +fn repeated_spreads_give_the_last_own_key_to_a_styled_component() { + let output = card(""); + let found = slots(&output); + + assert_eq!(found.len(), 1); + assert_eq!(found[0].fallback, "blue"); + assert_eq!( + read( + &found[0].identifier, + "const props = { color: 'x', more: {} };" + ), + "undefined" + ); + assert_eq!( + read( + &found[0].identifier, + "const props = { more: { color: 'y' } };" + ), + "\"y\"" + ); +} + +#[test] +#[serial] +fn what_the_component_configures_or_reads_stays_an_attribute() { + let rendered = code( + "import { Box, styled } from '@devup-ui/react';\nconst Card = styled(Box)`color: ${(p) => p.tone};`;\nexport const a = (props) => ;", + ); + + assert!(rendered.contains("as=\"a\""), "{rendered}"); + assert!(rendered.contains("tone=\"x\""), "{rendered}"); + assert!( + rendered.contains("theme={__devupValue") && rendered.contains("props.theme"), + "{rendered}" + ); + assert!(!rendered.contains("p={1}"), "{rendered}"); +} + +#[test] +#[serial] +fn a_component_extending_a_styled_one_takes_the_style_props_too() { + let output = output(&format!( + "{CARD}const Wide = styled(Card, {{ w: '100%' }});\nexport const a = (props) => ;" + )); + + assert_eq!(slots(&output).len(), 1); + assert!(!output.code.contains("color=\"red\""), "{}", output.code); +} + +#[test] +#[serial] +fn a_styled_component_of_a_tag_keeps_passing_what_it_always_did() { + let rendered = code( + "import { styled } from '@devup-ui/react';\nconst Card = styled('div', { bg: 'white' });\nexport const a = (props) => ;", + ); + + assert!(rendered.contains("id=\"x\""), "{rendered}"); + assert!(rendered.contains("color=\"red\""), "{rendered}"); +} diff --git a/libs/extractor/src/jsx_semantics_tests/type_imports.rs b/libs/extractor/src/jsx_semantics_tests/type_imports.rs new file mode 100644 index 000000000..cfe7e5ec3 --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/type_imports.rs @@ -0,0 +1,75 @@ +use super::*; +use serial_test::serial; + +#[test] +#[serial] +fn a_type_only_import_declaration_stays_with_what_it_names() { + let rendered = code( + "import type { Box, css } from '@devup-ui/react';\nexport type A = typeof Box;\nexport type B = typeof css;", + ); + + assert!( + rendered.contains("import type { Box, css } from \"@devup-ui/react\""), + "{rendered}" + ); +} + +#[test] +#[serial] +fn a_type_only_default_and_namespace_import_stay() { + let rendered = code( + "import type Devup from '@devup-ui/react';\nimport type * as ns from '@devup-ui/react';\nexport type D = typeof Devup;\nexport type N = typeof ns.Box;", + ); + + assert!(rendered.contains("import type Devup from"), "{rendered}"); + assert!(rendered.contains("import type * as ns from"), "{rendered}"); +} + +#[test] +#[serial] +fn an_inline_type_specifier_stays_beside_the_compiled_ones() { + let rendered = code( + "import { Box, type Flex } from '@devup-ui/react';\nexport type F = typeof Flex;\nexport const a = ;", + ); + + assert!( + rendered.contains("import { type Flex } from \"@devup-ui/react\""), + "{rendered}" + ); + assert!(rendered.contains("
;\nexport const b = css({ color: 'blue' });", + ); + + assert!(rendered.contains(""), "{rendered}"); + assert!(rendered.contains("import type { Box }"), "{rendered}"); +} + +#[test] +#[serial] +fn type_specifiers_of_the_runtime_and_stylex_register_nothing() { + let rendered = code(&format!( + "{BOX}import {{ jsx, type jsxs }} from 'react/jsx-runtime';\nimport {{ type create }} from '@stylexjs/stylex';\nimport type {{ StyleXStyles }} from '@stylexjs/stylex';\nexport const a = jsx(Box, {{ color: 'red' }});\nexport type S = StyleXStyles | typeof create | typeof jsxs;" + )); + + assert!(rendered.contains("jsx(\"div\""), "{rendered}"); + assert!(rendered.contains("type jsxs"), "{rendered}"); + assert!(rendered.contains("type create"), "{rendered}"); +} diff --git a/libs/extractor/src/jsx_semantics_tests/whole.rs b/libs/extractor/src/jsx_semantics_tests/whole.rs new file mode 100644 index 000000000..65531659b --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/whole.rs @@ -0,0 +1,56 @@ +//! Runs the whole code a file compiles to, as a bundler runs it, with a fake +//! React that records the props each element is built with. + +use super::*; +use oxc_allocator::Allocator; +use oxc_codegen::Codegen; +use oxc_parser::Parser; +use oxc_semantic::SemanticBuilder; +use oxc_span::SourceType; +use oxc_transformer::{JsxRuntime, TransformOptions, Transformer}; +use std::path::Path; + +/// `code` with its types stripped and its JSX written as calls of `h` +fn plain_script(code: &str) -> String { + let allocator = Allocator::default(); + let mut program = Parser::new(&allocator, code, SourceType::tsx()) + .parse() + .program; + let scoping = SemanticBuilder::new() + .build(&program) + .semantic + .into_scoping(); + let mut options = TransformOptions::default(); + options.jsx.runtime = JsxRuntime::Classic; + options.jsx.pragma = Some("h".to_string()); + let _ = Transformer::new(&allocator, Path::new("a.tsx"), &options) + .build_with_scoping(scoping, &mut program); + Codegen::new() + .build(&program) + .code + .lines() + .filter(|line| !line.starts_with("import ")) + .map(|line| line.strip_prefix("export ").unwrap_or(line)) + .collect::>() + .join("\n") +} + +/// What `probe` gives once the code `source` compiles to has run, as JSON, +/// and the `trace` that code wrote while it ran +pub(super) struct Evaluation { + pub element: serde_json::Value, + pub trace: serde_json::Value, +} + +/// Compile `source`, run what it compiles to, then evaluate `probe` with +/// `elements` building plain objects: `{ type, props, children }` +pub(super) fn evaluate(source: &str, probe: &str) -> Evaluation { + let compiled = output(source).code; + let script = plain_script(&compiled); + let joined = run(&format!( + "const trace = []; const h = (type, props, ...children) => ({{ type, props: props && {{ ...props }}, children }}); const jsx = (type, props) => ({{ type, props: props && {{ ...props }} }}); const createElement = h; const React = {{ createElement: h }}; const ReactNS = React; {script}\nreturn JSON.stringify([{probe}, trace]);" + )); + let (element, trace): (serde_json::Value, serde_json::Value) = + serde_json::from_str(&joined).unwrap_or_else(|error| panic!("{error}: {joined}")); + Evaluation { element, trace } +} diff --git a/libs/extractor/src/legacy_semantics_contract_tests.rs b/libs/extractor/src/legacy_semantics_contract_tests.rs new file mode 100644 index 000000000..805aec0c4 --- /dev/null +++ b/libs/extractor/src/legacy_semantics_contract_tests.rs @@ -0,0 +1,170 @@ +//! Negative cases paired with legacy snapshots. Unknown spreads must precede +//! explicit selector/typography props; lexical self-initialization is a TDZ error, +//! unlike the hoisted `var` self-read retained as the positive dynamic control. + +use crate::{ExtractOption, extract}; +use css::class_map::reset_class_map; +use css::file_map::reset_file_map; + +fn error(filename: &str, source: &str, option: ExtractOption) -> String { + reset_class_map(); + reset_file_map(); + match extract(filename, source, option) { + Ok(output) => panic!("expected a build error, got {}", output.code), + Err(error) => error.to_string(), + } +} + +fn core_option() -> ExtractOption { + ExtractOption { + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), + single_css: true, + import_main_css: false, + import_aliases: std::collections::HashMap::new(), + } +} + +pub(super) fn extract_style_props_with_class_name() { + let source = r#"import { Box, Button as DevupButton, Center, css } from '@devup-ui/core' +import clsx from 'clsx' + + +"#; + + let message = error("test.tsx", source, core_option()); + + assert!( + message.starts_with("test.tsx:11:7: `` cannot use `typography`"), + "{message}" + ); + assert!( + message.contains("write the spread before `typography`"), + "{message}" + ); +} + +pub(super) fn extract_selector() { + let source = r"import {Center} from '@devup-ui/core' +
+ {children} +
+ "; + + let message = error("test.tsx", source, core_option()); + + for (location, key) in [("test.tsx:3:7", "_active"), ("test.tsx:9:7", "_hover")] { + assert!( + message.contains(&format!("{location}: `
` cannot use `{key}`")), + "{message}" + ); + assert!( + message.contains(&format!("replace the whole `{key}` object")), + "{message}" + ); + assert!( + message.contains(&format!("write the spread before `{key}`")), + "{message}" + ); + } +} + +pub(super) fn test_rest_props() { + let source = r"import { VStack } from '@devup-ui/core' + +export default function Card({ + children, + className, + ...props +}) { + return ( + + {children} + + ) +} + + "; + + let message = error("test.jsx", source, core_option()); + + assert!( + message.starts_with("test.jsx:10:7: `` cannot use `_active`"), + "{message}" + ); + assert!( + message.contains("replace the whole `_active` object"), + "{message}" + ); + assert!( + message.contains("write the spread before `_active`"), + "{message}" + ); +} + +pub(super) fn test_inline_local_constants() { + let source = r"import { Box, css, keyframes, globalCss } from '@devup-ui/react'; +const SIZE = 4; +const UNIT = `${SIZE}px`; +const DOUBLE = SIZE * 2; +const HALF = SIZE / 2 - 1; +const LABEL = 'a' + SIZE; +const TINY = 0.0000001; +const HUGE = 1e21; +const ZERO = -0; +const PX = 'px'; +const SELF = SELF; +let mutable = 1; +export const a = ; +export const b = css({ padding: `${SIZE * 2}px`, margin: SIZE - 1, width: SIZE * SIZE, height: (SIZE) }); +export const k = keyframes({ from: { opacity: SIZE / 8 } }); +globalCss({ body: { padding: UNIT } }); +export const c = ;"; + + let message = error("test.tsx", source, ExtractOption::default()); + + assert_eq!( + message, + "test.tsx:11:14: style value cannot use `SELF` at build time: it is read before its lexical declaration is initialized, so the original JavaScript throws ReferenceError; move the declaration of `SELF` above this use" + ); +} diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 68981f36c..8cb76fe0e 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -1,6 +1,9 @@ mod as_visit; +mod barrel; mod build_time_values; mod component; +mod composition; +mod css_prop; mod css_utils; pub mod extract_style; mod extractor; @@ -8,11 +11,18 @@ mod gen_class_name; mod gen_style; mod import_alias_visit; mod imported_constants; +#[cfg(test)] +mod jsx_semantics_tests; +#[cfg(test)] +mod legacy_semantics_contract_tests; mod module_loader; mod mutations; mod prop_modify_utils; +mod prop_valid; +mod scope; mod source_map; mod style_values; +mod styled_reads; mod stylex; mod tailwind; mod util_type; @@ -281,42 +291,74 @@ fn extract_with_source_map( source_map: bool, resolver: Option<&ModuleResolver>, ) -> Result> { - extract_source(filename, code, None, option, source_map, resolver) + extract_source(filename, code, None, false, option, source_map, resolver) } /// `evaluated` is the source `code` was computed from, with the layers of -/// edits, last made first, that map `code` back to it +/// edits, last made first, that map `code` back to it; `values_run` tells that +/// the values only running code gives are in `code` already fn extract_source( filename: &str, code: &str, evaluated: Option<(&str, &[&[import_alias_visit::Edit]])>, + values_run: bool, option: ExtractOption, source_map: bool, resolver: Option<&ModuleResolver>, ) -> Result> { + if evaluated.is_none() { + match barrel::rewrite(code, filename, &option.package, resolver) { + barrel::Barreled::Unchanged => {} + barrel::Barreled::Failed(errors) => { + return Err(located_errors(filename, code, &[], errors).into()); + } + barrel::Barreled::Rewritten(barreled) => { + let mut output = extract_source( + filename, + &barreled.code, + Some((code, &[barreled.edits.as_slice()])), + false, + option, + source_map, + resolver, + )?; + let mut files: std::collections::BTreeSet = + output.dependencies.into_iter().collect(); + files.extend(barreled.dependencies); + output.dependencies = files.into_iter().collect(); + return Ok(output); + } + } + } // 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( code, filename, &option.package, &option.import_aliases, ); - // Step 2: Check if code contains the target package (after transformation) + // Step 2: Check if code contains the target package (after transformation), + // gives an element a `css` prop, or had an import rewritten let has_relevant_import = transformed_code.contains(option.package.as_str()) || transformed_code.contains(STYLEX_PACKAGE); + let unchanged = || ExtractOutput { + styles: FxHashSet::default(), + code: code.to_string(), + map: None, + css_file: None, + dependencies: Vec::new(), + }; - if !has_relevant_import { + if !has_relevant_import && css_prop == css_prop::CssProp::Off && alias_edits.is_empty() { // skip if not using package - return Ok(ExtractOutput { - styles: FxHashSet::default(), - code: code.to_string(), - map: None, - css_file: None, - dependencies: Vec::new(), - }); + return Ok(unchanged()); } let mut dependencies = std::collections::BTreeSet::new(); @@ -424,10 +466,18 @@ fn extract_source( filename, &option, resolver, + css_prop, ) } else { imported_constants::Inlined::default() }; + if !inlined.errors.is_empty() { + let (source, earlier_edits) = evaluated.unwrap_or((code, &[])); + let edits: Vec<&[import_alias_visit::Edit]> = std::iter::once(alias_edits.as_slice()) + .chain(earlier_edits.iter().copied()) + .collect(); + return Err(located_errors(filename, source, &edits, inlined.errors).into()); + } dependencies.extend(inlined.dependencies); let mut visitor = DevupVisitor::new( &allocator, @@ -437,9 +487,16 @@ 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.reuse_scoping(inlined.scoping); visitor.visit_program(&mut program); + if !has_relevant_import && alias_edits.is_empty() && !visitor.compiled_css_prop { + // No element took the `css` prop the text seemed to give + return Ok(unchanged()); + } if let Some(error) = evaluation_error && imports_uncompiled(&program, &option.package) { @@ -448,7 +505,7 @@ fn extract_source( // Run the code a value computes, or tell rules the module computes from a // class it composes if (!visitor.errors.is_empty() || visitor.composes_unknown) - && evaluated.is_none() + && !values_run && !utils::is_vanilla_extract_file(filename) && let Some((computed, value_edits, read)) = build_time_values::evaluate( &transformed_code, @@ -458,10 +515,17 @@ fn extract_source( &inlined.unknown, ) { + let (source, earlier_edits) = evaluated.unwrap_or((code, &[])); + let layers: Vec<&[import_alias_visit::Edit]> = + [value_edits.as_slice(), alias_edits.as_slice()] + .into_iter() + .chain(earlier_edits.iter().copied()) + .collect(); let mut output = extract_source( filename, &computed, - Some((code, &[value_edits.as_slice(), alias_edits.as_slice()])), + Some((source, &layers)), + true, option, source_map, resolver, @@ -734,6 +798,37 @@ pub fn has_devup_ui(filename: &str, code: &str, package: &str) -> bool { false } +/// Whether extraction does something to a file. +/// +/// It imports the package (also through a project module that re-exports it, +/// or as a namespace), imports a package `import_aliases` redirects, or gives +/// an element a `css` prop that the aliases compile. +#[must_use] +pub fn has_devup_ui_with( + filename: &str, + code: &str, + package: &str, + import_aliases: &HashMap, + resolver: Option<&ModuleResolver>, +) -> bool { + SourceType::from_path(filename).is_ok() + && (has_devup_ui(filename, code, package) + || resolver.is_some_and(|resolver| { + !matches!( + barrel::rewrite(code, filename, package, Some(resolver)), + barrel::Barreled::Unchanged + ) + }) + || { + let aliased = import_alias_visit::transform_import_aliases_with_edits( + code, + filename, + package, + import_aliases, + ); + !aliased.edits.is_empty() || aliased.css_prop != css_prop::CssProp::Off + }) +} #[cfg(test)] #[allow(clippy::expect_used, clippy::unwrap_used)] mod tests { @@ -836,8 +931,8 @@ mod tests { alternate: None, }; - assert!(empty.extract().is_empty()); - assert!(empty.into_extract().is_empty()); + assert_eq!(empty.extract(), vec![]); + assert_eq!(empty.into_extract(), vec![]); } #[test] @@ -1783,6 +1878,7 @@ mod tests { import clsx from 'clsx' "#, ExtractOption { @@ -1810,6 +1905,7 @@ import clsx from 'clsx' ) .unwrap() )); + crate::legacy_semantics_contract_tests::extract_style_props_with_class_name(); } #[test] @@ -3023,6 +3119,7 @@ import clsx from 'clsx' "test.tsx", r"import {Center} from '@devup-ui/core'
{children}
@@ -3396,6 +3492,7 @@ import clsx from 'clsx' ) .unwrap() )); + crate::legacy_semantics_contract_tests::extract_selector(); } #[test] @@ -5054,7 +5151,7 @@ e(o, { className: "a", bg: variable, style: { color: "blue" }, ...props }) reset_class_map(); reset_file_map(); - assert_debug_snapshot!(ToBTreeSet::from(extract("test.js", r#""use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const e=require("react/jsx-runtime"),{Box,Text,Flex}=require("@devup-ui/react");function t(){return e.jsxs("div",{children:[e.jsx(Box,{["_hover"]:{bg:"blue"},bg:"$text",[variable]:"red",children:"hello"})]})}exports.Lib=t;"#, ExtractOption { package: "@devup-ui/react".to_string(), css_dir: "@devup-ui/react".to_string(), single_css: true, import_main_css: false, import_aliases: HashMap::new() }).unwrap())); + assert_debug_snapshot!(extract("test.js", r#""use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const e=require("react/jsx-runtime"),{Box,Text,Flex}=require("@devup-ui/react");function t(){return e.jsxs("div",{children:[e.jsx(Box,{["_hover"]:{bg:"blue"},bg:"$text",[variable]:"red",children:"hello"})]})}exports.Lib=t;"#, ExtractOption { package: "@devup-ui/react".to_string(), css_dir: "@devup-ui/react".to_string(), single_css: true, import_main_css: false, import_aliases: HashMap::new() }).map(ToBTreeSet::from).map_err(|error|error.to_string())); } #[test] @@ -5255,12 +5352,12 @@ export default function Card({ }) { return ( {children} @@ -5278,6 +5375,7 @@ export default function Card({ ) .unwrap() )); + crate::legacy_semantics_contract_tests::test_rest_props(); } #[test] @@ -6970,10 +7068,11 @@ export { .unwrap() )); - // Test 2: styleOrder={condition ? 5 : variable} — ternary with mixed static/dynamic + // Test 2: styleOrder={condition ? 5 : variable} — ternary with mixed static/dynamic, + // which the stylesheet cannot order by: a located build error reset_class_map(); reset_file_map(); - assert_debug_snapshot!(ToBTreeSet::from( + assert_debug_snapshot!( extract( "test.jsx", r#"import {Box} from '@devup-ui/core' @@ -6987,13 +7086,14 @@ export { import_aliases: HashMap::new() } ) - .unwrap() - )); + .map(ToBTreeSet::from) + .map_err(|error| error.to_string()) + ); - // Test 3: styleOrder={variable} — fully dynamic styleOrder + // Test 3: styleOrder={variable} — fully dynamic styleOrder, a located build error reset_class_map(); reset_file_map(); - assert_debug_snapshot!(ToBTreeSet::from( + assert_debug_snapshot!( extract( "test.jsx", r#"import {Box} from '@devup-ui/core' @@ -7007,8 +7107,9 @@ export { import_aliases: HashMap::new() } ) - .unwrap() - )); + .map(ToBTreeSet::from) + .map_err(|error| error.to_string()) + ); // Test 4: styleOrder={condition ? 5 : 10} with conditional style props // Verifies interaction between conditional styleOrder and conditional style values @@ -7228,10 +7329,10 @@ export { c as Lib };"#, )); // Coverage: utils.rs:100 — expression_to_style_order with plain variable (not conditional, not static) - // Also covers visit.rs:259,262 — fallback from pre-scan None to extract_style_from_expression's result + // The order is neither static nor conditional, so the call is a located build error reset_class_map(); reset_file_map(); - assert_debug_snapshot!(ToBTreeSet::from( + assert_debug_snapshot!( extract( "test.mjs", r#"import { jsx as e } from "react/jsx-runtime"; @@ -7248,8 +7349,9 @@ export { c as Lib };"#, import_aliases: HashMap::new() } ) - .unwrap() - )); + .map(ToBTreeSet::from) + .map_err(|error| error.to_string()) + ); // Coverage: visit.rs:297/492 — non-conditional static styleOrder via call expression (fallback _ branch) reset_class_map(); @@ -9316,7 +9418,7 @@ const TINY = 0.0000001; const HUGE = 1e21; const ZERO = -0; const PX = 'px'; -const SELF = SELF; +var SELF = SELF; let mutable = 1; export const a = ; export const b = css({ padding: `${SIZE * 2}px`, margin: SIZE - 1, width: SIZE * SIZE, height: (SIZE) }); @@ -9327,6 +9429,7 @@ export const c = String { + reset_class_map(); + reset_file_map(); + css::debug::set_debug(true); + let output = extract( + "test.tsx", + code, + ExtractOption { + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), + single_css: true, + import_main_css: false, + import_aliases: HashMap::new(), + }, + ); + css::debug::set_debug(false); + match output { + Ok(output) => output.code, + Err(error) => error.to_string(), + } + } + + // A later part of `css(...)` replaces an earlier part's declaration of the + // same property, selector, breakpoint and layer, whatever the stylesheet + // order of their classes + #[rstest] + #[case( + "css(yellow, azure)", + r#""color-0-red-_a__c_hover-255 background-0-black--255 color-0-azure--255""# + )] + #[case( + "css(azure, yellow)", + r#""background-0-black--255 color-0-red-_a__c_hover-255 color-0-yellow--255""# + )] + #[case( + "css({ color: 'green' }, yellow)", + r#""color-0-red-_a__c_hover-255 color-0-yellow--255""# + )] + #[case( + "css(yellow, { color: 'green' })", + r#""color-0-red-_a__c_hover-255 color-0-green--255""# + )] + #[case( + "css([yellow, azure])", + r#""color-0-red-_a__c_hover-255 background-0-black--255 color-0-azure--255""# + )] + #[case( + "css(...[yellow, azure])", + r#""color-0-red-_a__c_hover-255 background-0-black--255 color-0-azure--255""# + )] + #[case( + "css(yellow, null, undefined, false, azure)", + r#""color-0-red-_a__c_hover-255 background-0-black--255 color-0-azure--255""# + )] + #[case( + "css(yellow, on && azure)", + r#"`color-0-red-_a__c_hover-255 ${on ? "background-0-black--255" : ""} ${on ? "color-0-azure--255" : "color-0-yellow--255"}`"# + )] + #[case( + "css(on ? yellow : azure)", + r#"`${on ? "color-0-red-_a__c_hover-255" : ""} ${on ? "color-0-yellow--255" : "color-0-azure--255"} ${on ? "" : "background-0-black--255"}`"# + )] + #[case( + "css(yellow, on ? { color: 'pink' } : null)", + r#"`color-0-red-_a__c_hover-255 ${on ? "color-0-pink--255" : "color-0-yellow--255"}`"# + )] + #[case( + "css(yellow, { color: on ? 'pink' : 'teal' })", + r#"`color-0-red-_a__c_hover-255 ${on ? "color-0-pink--255" : "color-0-teal--255"}`"# + )] + #[case( + "css(yellow, ext, azure)", + r"`color-0-red-_a__c_hover-255 background-0-black--255 color-0-azure--255 ${ext}`" + )] + #[case( + "css(yellow, on ? 'plain' : azure)", + r#"`${on ? "plain" : ""} ${`color-0-red-_a__c_hover-255 ${on ? "" : "background-0-black--255"} ${on ? "color-0-yellow--255" : "color-0-azure--255"}`}`"# + )] + #[case( + "css(azure, wide)", + r#""background-0-black--255 color-0-a1--255 color-1-b1--255""# + )] + #[case( + "css(wide, { color: [null, 'x2'] })", + r#""color-0-a1--255 color-1-x2--255""# + )] + #[case( + "css(yellow, ordered)", + r#""color-0-red-_a__c_hover-255 color-0-navy--3""# + )] + #[case("css(heading, body)", r#""typo-body""#)] + #[case( + "css(chained, { color: 'gold' })", + r#""color-0-red-_a__c_hover-255 background-0-black--255 color-0-gold--255""# + )] + #[case( + "css(yellow, { color: { a: 'red', b: 'blue' }[size] })", + r"`color-0-red-_a__c_hover-255 color-0-yellow--255 ${{" + )] + #[case( + "css(yellow, on ? { color: { a: 'red' }[size] } : null)", + r#"`color-0-red-_a__c_hover-255 color-0-yellow--255 ${on ? { "a": "color-0-red--255" }[size] || "" : ""}`"# + )] + #[case( + "css(yellow, on || azure)", + r#"`${on ? typeof on === "string" ? on : "" : ""} ${`color-0-red-_a__c_hover-255 ${on ? "" : "background-0-black--255"} ${on ? "color-0-yellow--255" : "color-0-azure--255"}`}`"# + )] + #[case( + "css(yellow, ext ?? azure)", + r#"`${ext != null ? typeof ext === "string" ? ext : "" : ""} ${`color-0-red-_a__c_hover-255 ${ext != null ? "" : "background-0-black--255"} ${ext != null ? "color-0-yellow--255" : "color-0-azure--255"}`}`"# + )] + #[case( + "css(yellow, null ?? azure)", + r#""color-0-red-_a__c_hover-255 background-0-black--255 color-0-azure--255""# + )] + #[case( + "css(yellow, false ?? azure)", + r#""color-0-red-_a__c_hover-255 color-0-yellow--255""# + )] + #[case( + "css(azure || yellow)", + r#""background-0-black--255 color-0-azure--255""# + )] + #[case( + "css(yellow, on || 'plain')", + r#"`color-0-red-_a__c_hover-255 color-0-yellow--255 ${on ? typeof on === "string" ? on : "" : "plain"}`"# + )] + #[case( + "css(yellow, { color: on ? 'a' : 'b', m: [1, 2], styleOrder: 2 })", + r#"`color-0-red-_a__c_hover-255 margin-0-4px--2 margin-1-8px--2 ${on ? "color-0-a--2" : "color-0-b--2"}`"# + )] + #[case( + "css(yellow, { color: { a: 'x' }[size], styleOrder: 2 })", + r#"`color-0-red-_a__c_hover-255 color-0-yellow--255 ${{ "a": "color-0-x--2" }[size] || ""}`"# + )] + #[case( + "css(yellow, on ? { color: other ? 'a' : 'b' } : null)", + r#"`color-0-red-_a__c_hover-255 ${on ? other ? "color-0-a--255" : "color-0-b--255" : "color-0-yellow--255"}`"# + )] + #[case( + "css(yellow, fade)", + r#""k-525370705245237976 color-0-red-_a__c_hover-255 color-0-yellow--255""# + )] + #[case( + "css(yellow, { color: ['x', 'y'][idx], styleOrder: 2 })", + "`color-0-red-_a__c_hover-255 color-0-yellow--255 ${{\n\t\"0\": \"color-0-x--2\",\n\t\"1\": \"color-0-y--2\"\n}[idx] || \"\"}`" + )] + #[case( + "css(yellow, { color: 'pink' } || azure)", + r#""color-0-red-_a__c_hover-255 color-0-pink--255""# + )] + #[case( + "css(yellow, undefined ?? azure)", + r#""color-0-red-_a__c_hover-255 background-0-black--255 color-0-azure--255""# + )] + #[case( + "css(yellow, false ?? azure)", + r#""color-0-red-_a__c_hover-255 color-0-yellow--255""# + )] + #[serial] + fn test_css_composes_known_classes(#[case] call: &str, #[case] expected: &str) { + let code = readable_code(&format!( + "import {{css, keyframes}} from '@devup-ui/core' +const yellow = css({{ color: 'yellow', _hover: {{ color: 'red' }} }}) +const azure = css({{ color: 'azure', bg: 'black' }}) +const wide = css({{ color: ['a1', 'b1'] }}) +const ordered = css({{ color: 'navy', styleOrder: 3 }}) +const heading = css({{ typography: 'heading' }}) +const body = css({{ typography: 'body' }}) +const chained = css(yellow, azure) +const fade = keyframes({{ from: {{ opacity: 0 }} }}) +export const result = {call} +" + )); + assert!( + code.contains(&format!("export const result = {expected}")), + "{code}" + ); + } + + #[test] + #[serial] + fn test_styled_extension_composes_base() { + let code = readable_code( + "import {styled} from '@devup-ui/core' +const Base = styled.button`color: red; background: white; &:hover { color: red; }` +const Ext = styled(Base)`color: blue; &:hover { color: blue; }` +const Obj = styled.div({ color: 'red', p: 2 }) +const ObjExt = styled(Obj)({ color: 'blue' }) +const Twice = styled(ObjExt, { m: 1 }) +const Runtime = styled(make())({ color: 'red' }) +const FromRuntime = styled(Runtime)({ color: 'blue' }) +let Changing = styled.div({ color: 'red' }) +const FromChanging = styled(Changing)({ color: 'blue' }) +const Ordered = styled.div({ color: 'red', styleOrder: 3 })", + ); + for expected in [ + "const Ext = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"button\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\"color-0-blue-_a__c_hover-255 color-0-blue--255 background-0-white--255\", className]", + "const ObjExt = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\"color-0-blue--255 padding-0-8px--255\", className]", + "const Twice = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\"margin-0-4px--255 color-0-blue--255 padding-0-8px--255\", className]", + "const FromRuntime = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = Runtime, forwardedAs, ...rest }) => (({ style, className, as: DevupAs = Changing, forwardedAs, ...rest }) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\"color-0-red--3\", className]", + ] { + assert!(code.contains(expected), "{expected}\n{code}"); + } + } + + // `withComponent` renders the same styles as another tag or component, and + // `as` changes what a styled component renders while `forwardedAs` passes on + #[test] + #[serial] + fn test_styled_with_component_and_as() { + let code = readable_code( + "import {styled} from '@devup-ui/core' +const Section = styled.section.attrs({ role: 'region' })({ color: 'red' }) +export const Aside = Section.withComponent('aside') +export const Linked = Section.withComponent(Link) +export const Nested = Section.withComponent(motion.div) +export const Again = Aside.withComponent('nav') +export const Kept = Section.withComponent(make()) +export const Other = other.withComponent('aside') +export const a =
", + ); + for expected in [ + "export const Aside = __devupForwardRef((__devupRefProps, __devupRef) => ((__devupProps) => (({ style, className, as: DevupAs = \"aside\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\"color-0-red--255\", className]", + "as: DevupAs = Link,", + "as: DevupAs = motion.div,", + "export const Again = __devupForwardRef((__devupRefProps, __devupRef) => ((__devupProps) => (({ style, className, as: DevupAs = \"nav\",", + "export const Kept = Section.withComponent(make());", + "export const Other = other.withComponent(\"aside\");", + "...{ role: \"region\" }", + "
", + ] { + assert!(code.contains(expected), "{expected}\n{code}"); + } + } + + // A styled component keeps the props its styles read, `$` props and `theme` + // away from the tag, follows `shouldForwardProp` as the build evaluates it, + // and an element using it drops what it neither reads nor passes on + #[test] + #[serial] + fn test_styled_prop_forwarding() { + let code = readable_code( + "import {styled} from '@devup-ui/core' +import isPropValid from '@emotion/is-prop-valid' +const A = styled('h1', { shouldForwardProp: (prop) => prop !== 'tone' })({ color: 'red' }) +const B = styled.div`color: ${(p) => p.$c}; background: ${(p) => p.tone};` +const C = styled.div.withConfig({ shouldForwardProp: (prop, valid) => valid(prop) && !['x', 'y'].includes(prop) })({ color: 'red' }) +const D = styled('p', { shouldForwardProp: (prop) => isPropValid(prop) || prop.startsWith('data-') })({ color: 'red' }) +const E = styled('p', { shouldForwardProp: () => true })({ color: 'red' }) +const F = styled(Link)`color: ${(p) => p.$c};` +const G = styled('p', { shouldForwardProp: function (prop) { return prop === 'id' } })({ color: 'red' }) +const H = styled.div`color: ${function (p) { return p.c }};` +const I = styled.div`color: ${({ tone, ...more }) => tone};` +const J = styled.div`color: ${(p) => p[key]}; margin: ${([a]) => a}; padding: ${() => 1};` +const K = styled(A, { shouldForwardProp: (prop) => 'id' === prop || prop === 'title' })({ color: 'blue' }) +const L = styled.div.withConfig(config)`color: ${(p) => { return p.level }}; margin: ${({ [k]: v }) => v};` +const M = styled('p', { shouldForwardProp: (prop) => { return prop === 'id' } })({ color: 'red' }) +export const a = +export const b = +export const j = +export const f = +export const k = ", + ); + for expected in [ + "{...(({ \"tone\": __devupOmit0, ...__devupDom }) => __devupDom)(rest)}", + "{...(({ \"$c\": __devupOmit0, \"tone\": __devupOmit1, \"theme\": __devupOmit2, ...__devupDom }) => __devupDom)(rest)}", + "{...(({ \"x\": __devupOmit0, \"y\": __devupOmit1, \"theme\": __devupOmit2, ...__devupDom }) => __devupDom)(rest)}", + "{...(({ \"$c\": __devupOmit0, ...__devupDom }) => __devupDom)(rest)}", + "export const a = ", + "export const b = ", + "export const j = ", + "export const f = ", + "export const k = ", + "{...(({ \"level\": __devupOmit0, \"theme\": __devupOmit1, ...__devupDom }) => __devupDom)(rest)}", + ] { + assert!(code.contains(expected), "{expected}\n{code}"); + } + let error = readable_code( + "import {styled} from '@devup-ui/core' +const A = styled('h1', { shouldForwardProp: (prop) => allowed.has(prop) })({ color: 'red' }) +const B = styled('h1', { shouldForwardProp: (prop) => prop === other })({ color: 'red' }) +const C = styled('h1', { shouldForwardProp: (prop) => prop > 'a' })({ color: 'red' }) +const D = styled('h1', { shouldForwardProp: (prop) => prop ?? 'a' })({ color: 'red' }) +const E = styled('h1', { shouldForwardProp: (prop) => { const x = 1; return x } })({ color: 'red' }) +const F = styled('h1', { shouldForwardProp: check })({ color: 'red' }) +const G = styled('h1', { shouldForwardProp: ({ a }) => a })({ color: 'red' }) +const H = styled('h1', { shouldForwardProp: (prop) => [x].includes(prop) })({ color: 'red' }) +const I = styled('h1', { shouldForwardProp: (prop) => list.includes(prop) })({ color: 'red' }) +const J = styled('h1', { shouldForwardProp: (prop) => prop.endsWith('a') })({ color: 'red' }) +const K = styled('h1', { [k]: 1, ...o, shouldForwardProp: (prop) => valid(prop, 1) })({ color: 'red' }) +const L = styled('h1', { shouldForwardProp: (prop) => prop === 'a' ?? prop === 'b' })({ color: 'red' }) +const M = styled('h1', { shouldForwardProp: (prop) => x === y })({ color: 'red' }) +const N = styled('h1', { shouldForwardProp: (prop) => { prop === 'a' } })({ color: 'red' })", + ); + assert!( + error.contains("`shouldForwardProp` must be a function"), + "{error}" + ); + assert!(error.contains("allowed.has(prop)"), "{error}"); + } + + #[test] + #[serial] + fn test_styled_attrs_merge_in_order() { + let code = readable_code( + "import {styled} from '@devup-ui/core' +const Base = styled.input.attrs({ type: 'text', title: 'base' })({ color: 'red' }) +const Ext = styled(Base).attrs({ type: 'password' })({ color: 'blue' }) +const X = styled.div.attrs({ className: 'from-attrs', style: { color: 'green' } })({ color: 'red' }) +const F = styled.div.attrs((p) => ({ id: p.id }))({ color: 'red' }) +const V = styled.div.attrs(extra)({ color: 'red' })", + ); + for expected in [ + "...{\n\t\t...__devupProps,\n\t\t...{\n\t\t\ttype: \"text\",\n\t\t\ttitle: \"base\"\n\t\t}\n\t},\n\t...{ type: \"password\" }", + "className: [__devupContext.className, __devupAttrs.className].filter(Boolean).join(\" \") || undefined", + "className: [__devupContext.className, __devupProps.className].filter(Boolean).join(\" \") || undefined", + "...__devupContext.style,\n\t\t...__devupAttrs.style", + "((p) => ({ id: p.id }))(__devupContext)", + "typeof extra === \"function\" ? extra(__devupContext) : extra", + ] { + assert!(code.contains(expected), "{expected}\n{code}"); + } + } + + #[rstest] + #[case( + "", + "className=\"spread\" style={{ opacity: 2 }}" + )] + #[case( + "", + "className=\"direct\" style={{ opacity: 1 }}" + )] + #[case( + "", + "className={(\"className\" in __devupSpread0 ? __devupSpread0.className : \"direct\") || \"\"} style={\"style\" in __devupSpread0 ? __devupSpread0.style : { opacity: 1 }}" + )] + #[case( + "", + "className: \"last\"" + )] + #[case( + "", + "className: \"first\",\n\t\t...rest" + )] + #[case( + " color=\"red\" />", + "
" + )] + #[case( + "", + "className={(\"className\" in __devupSpread1 ? __devupSpread1.className : __devupSpread0?.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}"); + } + + // StyleX merges namespaces key by key: a later key wins and `null` removes it + #[rstest] + #[case("stylex.props(s.red, s.blue)", "{ className: \"color-0-blue--255\" }")] + #[case("stylex.props(s.blue, s.red)", "{ className: \"color-0-red--255\" }")] + #[case("stylex.props(s.red, s.reset)", "{ className: \"\" }")] + #[case( + "stylex.attrs(s.both, s.blue)", + "{ class: \"opacity-0-_d_5--255 color-0-blue--255\" }" + )] + #[case( + "stylex.props([s.red, [s.blue]], false, null, undefined)", + "{ className: \"color-0-blue--255\" }" + )] + #[case( + "stylex.props(s.red, on && s.blue)", + "{ className: on ? \"color-0-blue--255\" : \"color-0-red--255\" }" + )] + #[case( + "stylex.props(s.red, on && s.reset)", + "{ className: on ? \"\" : \"color-0-red--255\" }" + )] + #[case( + "stylex.props(on ? s.red : s.blue)", + "{ className: on ? \"color-0-red--255\" : \"color-0-blue--255\" }" + )] + #[case( + "stylex.props(on && s.red, on && s.red)", + "{ className: ((__devupStylexTest0, __devupStylexTest1) => __devupStylexTest1 ? \"color-0-red--255\" : __devupStylexTest0 ? \"color-0-red--255\" : \"\")(on, on) }" + )] + #[case( + "stylex.props(s['blue'], s.hover)", + "{ className: \"color-0-blue--255 color-0-red-_a__c_hover-255\" }" + )] + #[case("stylex.props(s.red, other.x)", "other.x")] + #[case("stylex.props(s.red, s[key])", "s[key]")] + #[case( + "stylex.props(s.red, s.missing)", + "{ className: \"color-0-red--255\" }" + )] + #[case("stylex.props(s.red, (cond).x)", "[cond.x]")] + #[case("stylex.props(s.red, ...rest)", "rest")] + #[case("stylex.props(s.red, s.fn(1))", "--")] + #[case("stylex.props(s.red, s.inc)", "margin-0-1px--255 color-0-green--255")] + #[case( + "stylex.props(on && s.reset)", + "{ className: ((__devupStylexTest0) => \"\")(on) }" + )] + #[case("stylex.props([s.red, rest])", "[rest].flat(Infinity)")] + #[serial] + fn test_stylex_props_merge_keys(#[case] call: &str, #[case] expected: &str) { + let code = readable_code(&format!( + "import stylex from '@stylexjs/stylex' +const s0 = stylex.create({{ a: {{ margin: '1px' }} }}) +const s = stylex.create({{ + red: {{ color: 'red' }}, + blue: {{ color: 'blue' }}, + reset: {{ color: null }}, + both: {{ color: 'red', opacity: 0.5 }}, + hover: {{ ':hover': {{ color: 'red' }} }}, + fn: (h) => ({{ height: h }}), + inc: {{ ...stylex.include(s0.a), color: 'green' }}, +}}) +export const result = {call}" + )); + 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 + ); + } + } + #[test] #[serial] fn test_tailwind_classname_extraction() { @@ -15902,7 +16515,7 @@ const el =
;", import_aliases: HashMap::new() }, ) - .unwrap() - )); + .expect_err("uncalled dynamic namespace must not survive") + .to_string() + ); } #[test] @@ -16911,7 +17525,7 @@ const el =
;", fn test_stylex_props_dynamic_as_non_call() { reset_class_map(); reset_file_map(); - assert_debug_snapshot!(ToBTreeSet::from( + assert_debug_snapshot!( extract( "test.tsx", r"import stylex from '@stylexjs/stylex'; @@ -16925,8 +17539,9 @@ const el =
;", import_aliases: HashMap::new() }, ) - .unwrap() - )); + .expect_err("uncalled dynamic namespace must not survive") + .to_string() + ); } #[test] @@ -16934,16 +17549,24 @@ const el =
;", fn test_stylex_include_dynamic_target() { reset_class_map(); reset_file_map(); - assert_debug_snapshot!(ToBTreeSet::from( - extract( + let error = extract( "test.tsx", r"import stylex from '@stylexjs/stylex'; const base = stylex.create({ dynamic: (x) => ({ color: x, fontSize: '14px' }) }); const composed = stylex.create({ fancy: { ...stylex.include(base.dynamic), backgroundColor: 'blue' } });", ExtractOption { package: "@devup-ui/react".to_string(), css_dir: "@devup-ui/react".to_string(), single_css: true, import_main_css: false, import_aliases: HashMap::new() }, ) - .unwrap() - )); + .expect_err("uncalled dynamic include must be rejected") + .to_string(); + assert!(error.contains("test.tsx:3:46:"), "{error}"); + assert!( + error.contains("uncalled dynamic namespace cannot be included exactly"), + "{error}" + ); + assert!( + error.contains("call the function directly in props/attrs"), + "{error}" + ); } #[test] @@ -18007,26 +18630,10 @@ export const J = other(Base).attrs({ id: 'j' })({ margin: 4 });", assert_debug_snapshot!(outputs); for (code, name) in [ - ( - "import { css } from '@devup-ui/react';\nexport const myCss = css;", - "css", - ), - ( - "import { Box } from '@devup-ui/react';\nexport const C = Box;\nexport const d = ;", - "Box", - ), ( "import { Box } from '@devup-ui/react';\nexport const list = [Box].map((B) => );", "Box", ), - ( - "import { css } from '@devup-ui/react';\nexport function f() { const inner = css; return inner({ color: 'red' }); }", - "css", - ), - ( - "import { css } from '@devup-ui/react';\nconst myCss = css;\nexport const v = myCss;", - "myCss", - ), ( "import { css } from '@devup-ui/react';\nlet myCss = css;\nmyCss = null;", "myCss", @@ -18260,7 +18867,7 @@ export const light = stylex.createTheme(colors, { primary: 'white' });", ( "const styles = stylex.create({ base: { fontSize: stylex.types.length(someVar), width: { default: stylex.types.length() }, position: stylex.firstThatWorks('sticky', someVar, ...rest) } });", &[ - "`stylex.create()` cannot use `stylex.types.length(someVar)` at build time", + "test.tsx:2:70: `stylex.create()` cannot use `someVar` at build time", "`stylex.create()` cannot use `stylex.types.length()` at build time", "`stylex.firstThatWorks()` cannot use `someVar` at build time", "`stylex.firstThatWorks()` cannot use `...rest` at build time", @@ -18343,7 +18950,7 @@ export const light = stylex.createTheme(colors, { primary: 'white' });", #[test] #[serial] fn test_stylex_props_join_styles_compiled_elsewhere() { - assert_debug_snapshot!(ToBTreeSet::from(extract_tsx( + assert_debug_snapshot!(extract("test.tsx", r"import * as stylex from '@stylexjs/stylex'; const fade = stylex.keyframes({ from: { opacity: 0 }, to: { opacity: 1 } }); const colors = stylex.defineVars({ text: 'black' }); @@ -18353,7 +18960,7 @@ const styles = stylex.create({ none: null, }); export const A = ({ style, rest }) =>
;" - ))); + , ExtractOption::default()).expect_err("escaping dynamic namespace must not survive").to_string()); } #[test] @@ -18778,7 +19385,7 @@ export const k = styled('div')({ color: SIZE });", &memory_resolver(CONSTANT_MODULES), ) .unwrap(); - assert!(without_imports.dependencies.is_empty()); + assert_eq!(without_imports.dependencies.len(), 0); let without_constants = extract_with_modules( "/src/Handler.tsx", "import { Box } from '@devup-ui/react';\nimport { handler } from './handler';\nexport const a = ;", @@ -19315,7 +19922,17 @@ export const a = ;", &memory_resolver(modules), ) .unwrap(); - assert!(imported.code.contains(")(make(5))"), "{}", imported.code); + assert_eq!( + imported.code.matches("make(5)").count(), + 1, + "{}", + imported.code + ); + assert!( + imported.code.contains("__proto__: null"), + "{}", + imported.code + ); } #[test] @@ -20728,6 +21345,25 @@ const e =
;" ))); } + // `attrs()` gives the `style` attribute as text, as StyleX does, while + // `props()` keeps the object React takes + #[test] + #[serial] + fn test_stylex_attrs_style_is_text() { + let code = extract_tsx( + r"import * as stylex from '@stylexjs/stylex'; +const s = stylex.create({ box: (w, h) => ({ width: w, height: h }) }); +export const a = stylex.attrs(s.box(w, h)); +export const p = stylex.props(s.box(w, h));", + ) + .code; + assert!( + code.contains("style: \"--a:\" + ((v) => typeof v === \"number\" ? v + \"px\" : v)(w) + \";--b:\" + ((v) => typeof v === \"number\" ? v + \"px\" : v)(h)"), + "{code}" + ); + assert!(code.contains("style: {\n\t\t\"--a\":"), "{code}"); + } + #[test] #[serial] fn test_emotion_global_with_spread_attribute() { @@ -20750,4 +21386,346 @@ 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 }) => <>
\n \",\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr\"import {Center} from '@devup-ui/core'\n \n {children}\n \n \",\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -97,5 +97,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n
\n {children}\n
;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n((__devupSpread0) =>
\n {children}\n
)({\n\t__proto__: null,\n\t...props\n});\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__extract_style_props_with_class_name-6.snap b/libs/extractor/src/snapshots/extractor__tests__extract_style_props_with_class_name-6.snap index 93a01d85e..7e7186d17 100644 --- a/libs/extractor/src/snapshots/extractor__tests__extract_style_props_with_class_name-6.snap +++ b/libs/extractor/src/snapshots/extractor__tests__extract_style_props_with_class_name-6.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Image} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Image} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -25,5 +25,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n\"Next.js;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n((__devupValue0) => \"Next.js)(styles.logo);\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__extract_style_props_with_class_name-9.snap b/libs/extractor/src/snapshots/extractor__tests__extract_style_props_with_class_name-9.snap index eea9d4bdc..f453b8d87 100644 --- a/libs/extractor/src/snapshots/extractor__tests__extract_style_props_with_class_name-9.snap +++ b/libs/extractor/src/snapshots/extractor__tests__extract_style_props_with_class_name-9.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import { Box, Button as DevupButton, Center, css } from '@devup-ui/core'\nimport clsx from 'clsx'\n\n\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import { Box, Button as DevupButton, Center, css } from '@devup-ui/core'\nimport clsx from 'clsx'\n\n\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -21,5 +21,5 @@ ToBTreeSet { "buttonS", ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\nimport clsx from \"clsx\";\n
- +
" `; @@ -111,7 +111,7 @@ exports[`Input should render disabled icon style when disabled is true 1`] = ` exports[`Controlled Input should render with value 1`] = ` "
- +
" `; diff --git a/packages/components/src/components/Input/__tests__/index.browser.test.tsx b/packages/components/src/components/Input/__tests__/index.browser.test.tsx index 8f7527f65..b088ef4eb 100644 --- a/packages/components/src/components/Input/__tests__/index.browser.test.tsx +++ b/packages/components/src/components/Input/__tests__/index.browser.test.tsx @@ -21,7 +21,7 @@ describe('Input', () => { const { container } = render() expect(container).toMatchSnapshot() expect(container.querySelector('[aria-label="input"]')).toHaveClass( - 'padding-right-0-36px--1', + 'padding-right-0-var_lp_--padding-right-spread-pr-0-_cm_36px_rp_--1', ) }) @@ -61,7 +61,7 @@ describe('Input', () => { const { container } = render() expect(container).toMatchSnapshot() expect(container.querySelector('[aria-label="input"]')).toHaveClass( - 'border-color-0-var_lp_--error_cm_light-dark_lp__h_D52B2E_cm__h_FF5B5E_rp__rp_--1', + 'border-color-0-var_lp_--border-color-spread-borderColor-0-_cm_var_lp_--error_cm_light-dark_lp__h_D52B2E_cm__h_FF5B5E_rp__rp__rp_--1', ) }) diff --git a/packages/components/src/components/Input/index.tsx b/packages/components/src/components/Input/index.tsx index ceff73d7a..a972de1b6 100644 --- a/packages/components/src/components/Input/index.tsx +++ b/packages/components/src/components/Input/index.tsx @@ -101,25 +101,6 @@ export function Input({ )} {clearButtonVisible && } {error && errorMessage && ( diff --git a/packages/components/src/components/Radio/__tests__/__snapshots__/index.browser.test.tsx.snap b/packages/components/src/components/Radio/__tests__/__snapshots__/index.browser.test.tsx.snap index 38cb80421..c8f615af4 100644 --- a/packages/components/src/components/Radio/__tests__/__snapshots__/index.browser.test.tsx.snap +++ b/packages/components/src/components/Radio/__tests__/__snapshots__/index.browser.test.tsx.snap @@ -3,7 +3,7 @@ exports[`Radio should Radio snapshot 1`] = ` "
@@ -13,7 +13,7 @@ exports[`Radio should Radio snapshot 1`] = ` exports[`Radio should Radio snapshot 2`] = ` "
@@ -23,7 +23,7 @@ exports[`Radio should Radio snapshot 2`] = ` exports[`Radio should Radio snapshot 3`] = ` "
@@ -33,7 +33,7 @@ exports[`Radio should Radio snapshot 3`] = ` exports[`Radio should Radio snapshot 4`] = ` "
@@ -43,7 +43,7 @@ exports[`Radio should Radio snapshot 4`] = ` exports[`Radio should Radio snapshot 5`] = ` "
@@ -53,7 +53,7 @@ exports[`Radio should Radio snapshot 5`] = ` exports[`Radio should Radio snapshot 6`] = ` "
@@ -63,7 +63,7 @@ exports[`Radio should Radio snapshot 6`] = ` exports[`Radio should Radio snapshot 7`] = ` "
@@ -73,7 +73,7 @@ exports[`Radio should Radio snapshot 7`] = ` exports[`Radio should Radio snapshot 8`] = ` "
@@ -83,7 +83,7 @@ exports[`Radio should Radio snapshot 8`] = ` exports[`Radio should Radio snapshot 9`] = ` "
@@ -93,7 +93,7 @@ exports[`Radio should Radio snapshot 9`] = ` exports[`Radio should Radio snapshot 10`] = ` "
@@ -103,7 +103,7 @@ exports[`Radio should Radio snapshot 10`] = ` exports[`Radio should Radio snapshot 11`] = ` "
diff --git a/packages/components/src/components/Radio/index.tsx b/packages/components/src/components/Radio/index.tsx index b6b5e921f..57559f2ba 100644 --- a/packages/components/src/components/Radio/index.tsx +++ b/packages/components/src/components/Radio/index.tsx @@ -81,9 +81,6 @@ export function Radio({ /> ) : ( )} {variant === 'button' ? ( diff --git a/packages/components/src/components/RadioGroup/__tests__/__snapshots__/index.browser.test.tsx.snap b/packages/components/src/components/RadioGroup/__tests__/__snapshots__/index.browser.test.tsx.snap index 002792775..64236928a 100644 --- a/packages/components/src/components/RadioGroup/__tests__/__snapshots__/index.browser.test.tsx.snap +++ b/packages/components/src/components/RadioGroup/__tests__/__snapshots__/index.browser.test.tsx.snap @@ -4,13 +4,13 @@ exports[`RadioGroup should RadioGroup snapshot 1`] = ` "