diff --git a/.changepacks/changepack_log_barrel_namespace_imports.json b/.changepacks/changepack_log_barrel_namespace_imports.json new file mode 100644 index 00000000..713e9b0c --- /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 00000000..6aa4c50d --- /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 00000000..4d05b283 --- /dev/null +++ b/.changepacks/changepack_log_compose_styled.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "styled(Base) extending a styled component the file binds to a const renders Base's tag directly with Base's styles composed under its own, so the extension's declarations replace Base's for the same property, selector, breakpoint and layer instead of losing to them by stylesheet order; Base's attrs apply before the extension's. attrs merge as styled-components merges them: className is joined with the caller's, style is merged and other props replace earlier ones. On an element, a className or style spread after the explicit prop now wins, as React merges props in the order they are written", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_emotion_class_names.json b/.changepacks/changepack_log_emotion_class_names.json new file mode 100644 index 00000000..9317e2ec --- /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 00000000..98c3f725 --- /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 00000000..71e82e1d --- /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 00000000..ceb23299 --- /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 00000000..b011e08f --- /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_located_diagnostics.json b/.changepacks/changepack_log_located_diagnostics.json new file mode 100644 index 00000000..b5cdc04f --- /dev/null +++ b/.changepacks/changepack_log_located_diagnostics.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Report extraction, module-resolution and stylesheet execution failures at their original source locations with the cause and repair. Reject recoverable syntax errors, nondeterministic build-time reads, synthetic descriptor/function-source and legacy accessor reflection, and unsafe stylesheet execution fallbacks instead of silently changing CSS. Preserve module resolver and serialization exceptions, evaluate transitive helper side effects, and prevent placeholder-name collisions from losing styles. Match import package names only at exact or slash-delimited boundaries so sibling data packages remain external. Retain helper CSS loads in evaluation order with deduplicated portable relative specifiers and query suffixes, without CSS content dependencies. Report missing CSS or incompatible path roots as located errors, and reject host-recorded unknown CSS export observations even when caught; unused bindings, object passing and typeof follow default TypeScript/JavaScript import semantics.", + "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 00000000..a8116dbe --- /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 00000000..7dd26ffb --- /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 00000000..8dfbb440 --- /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 00000000..a51d45fe --- /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/bindings/devup-ui-wasm/src/lib.rs b/bindings/devup-ui-wasm/src/lib.rs index 4560410f..26c6b4fc 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; @@ -14,6 +14,8 @@ use std::collections::{BTreeMap, HashMap}; use std::fmt::Display; use std::sync::{LazyLock, Mutex}; use wasm_bindgen::prelude::*; +mod resolver_state; +use resolver_state::ResolverState; static GLOBAL_STYLE_SHEET: LazyLock> = LazyLock::new(|| Mutex::new(StyleSheet::default())); @@ -357,6 +359,7 @@ pub fn code_extract_internal( import_aliases, SourceMapMode::Generate, None, + None, ) } @@ -382,6 +385,7 @@ pub fn code_extract_without_source_map_internal( import_aliases, SourceMapMode::Skip, None, + None, ) } @@ -410,6 +414,7 @@ pub fn code_extract_with_modules_internal( import_aliases, SourceMapMode::Generate, Some(resolver), + None, ) } @@ -425,6 +430,7 @@ fn code_extract_internal_impl( import_aliases: HashMap, source_map: SourceMapMode, resolver: Option<&ModuleResolver>, + resolver_state: Option<&RefCell>, ) -> Result { let option = ExtractOption { package: package.to_string(), @@ -445,6 +451,9 @@ fn code_extract_internal_impl( (None, SourceMapMode::Skip) => extract_without_source_map(filename, code, option), }; + if let Some(state) = resolver_state { + state.borrow().check()?; + } match extracted { Ok(output) => Ok(Output::new( output.code, @@ -473,21 +482,70 @@ pub fn set_module_resolver(resolver: Option) { #[cfg(not(tarpaulin_include))] fn call_module_resolver( resolver: &js_sys::Function, - specifier: &str, - importer: &str, + request: resolver_state::ResolverRequest, + state: &RefCell, ) -> Option { - let module = resolver - .call2(&JsValue::NULL, &specifier.into(), &importer.into()) - .ok()?; - let field = |name: &str| { - js_sys::Reflect::get(&module, &name.into()) - .ok()? - .as_string() + let specifier = request.specifier(); + let importer = request.importer(); + let message = |error: JsValue| { + if let Some(message) = error.as_string() { + return message; + } + match js_sys::Reflect::get(&error, &"message".into()) { + Ok(value) => value.as_string().unwrap_or_else(|| format!("{error:?}")), + Err(_) => format!("{error:?}"), + } }; - Some(ResolvedModule { - path: field("path")?, - code: field("code")?, - }) + let resolved = (|| -> Result, String> { + let module = resolver + .call2(&JsValue::NULL, &specifier.into(), &importer.into()) + .map_err(message)?; + if module.is_null() || module.is_undefined() { + return Ok(None); + } + if !module.is_object() || js_sys::Array::is_array(&module) { + return Err("malformed module resolver result: expected an object {path:string,code:string} or null/undefined".to_string()); + } + let field = |name: &str| -> Result { + js_sys::Reflect::get(&module, &name.into()) + .map_err(message)? + .as_string() + .ok_or_else(|| { + format!("malformed module resolver result: `{name}` must be a string") + }) + }; + let path = field("path")?; + if path.is_empty() { + return Err( + "malformed module resolver result: `path` must be a non-empty string".to_string(), + ); + } + Ok(Some(ResolvedModule { + path, + code: field("code")?, + })) + })(); + match resolved { + Ok(Some(module)) => { + state.borrow_mut().cache(&module, &request); + Some(module) + } + Ok(None) => None, + Err(cause) => { + state.borrow_mut().record(&request, &cause); + None + } + } +} + +/// The resolver set by `setModuleResolver`, if any +#[cfg(not(tarpaulin_include))] +fn resolver_from_js(state: std::rc::Rc>) -> Option> { + let resolver = MODULE_RESOLVER.with_borrow(Clone::clone)?; + Some(Box::new(move |specifier: &str, importer: &str| { + let request = state.borrow().request(specifier, importer)?; + call_module_resolver(&resolver, request, &state) + })) } /// Extract with the resolver set by `setModuleResolver`, if any @@ -505,9 +563,8 @@ fn code_extract_js( source_map: SourceMapMode, ) -> Result { let import_aliases = import_aliases_from_js(import_aliases)?; - let resolver = MODULE_RESOLVER.with_borrow(Clone::clone).map(|resolver| { - move |specifier: &str, importer: &str| call_module_resolver(&resolver, specifier, importer) - }); + let state = std::rc::Rc::new(RefCell::new(ResolverState::new(filename, code))); + let resolver = resolver_from_js(std::rc::Rc::clone(&state)); code_extract_internal_impl( filename, code, @@ -518,9 +575,8 @@ fn code_extract_js( import_main_css_in_css, import_aliases, source_map, - resolver - .as_ref() - .map(|resolver| resolver as &ModuleResolver), + resolver.as_deref(), + Some(&state), ) .map_err(js_error) } @@ -669,9 +725,18 @@ 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, +) -> Result { + let aliases = import_aliases_from_js(import_aliases).unwrap_or_default(); + let state = std::rc::Rc::new(RefCell::new(ResolverState::new(filename, code))); + let resolver = resolver_from_js(std::rc::Rc::clone(&state)); + let result = has_devup_ui_with(filename, code, package, &aliases, resolver.as_deref()); + state.borrow().check().map_err(js_error)?; + Ok(result) } #[cfg(test)] @@ -1551,7 +1616,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 +1648,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" + None )); - - // Test negative case - assert!(!has_devup_ui_wasm( - "test.tsx", - "const x = 1;", - "@devup-ui/react" - )); - - // 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 +2026,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 +2050,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 +2075,7 @@ mod tests { assert!(result.is_err()); if let Err(error) = result { - assert!(!error.is_empty()); + assert_ne!(error, ""); } } diff --git a/bindings/devup-ui-wasm/src/resolver_state.rs b/bindings/devup-ui-wasm/src/resolver_state.rs new file mode 100644 index 00000000..d7c318b0 --- /dev/null +++ b/bindings/devup-ui-wasm/src/resolver_state.rs @@ -0,0 +1,130 @@ +use std::collections::{BTreeSet, HashMap}; + +use extractor::ResolvedModule; +use extractor::module_reference::{ModuleReferenceLocation, original_module_reference_locations}; + +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)] +struct OriginSite { + filename: String, + location: ModuleReferenceLocation, +} + +#[derive(Clone)] +struct OriginSites { + first: OriginSite, + rest: Vec, +} + +struct ModuleSource { + code: String, + ingress: Option, +} + +/// A callback request with at least one original reference site. +pub(crate) struct ResolverRequest { + specifier: String, + importer: String, + origins: OriginSites, +} + +impl ResolverRequest { + pub(crate) fn specifier(&self) -> &str { + &self.specifier + } + + pub(crate) fn importer(&self) -> &str { + &self.importer + } +} + +#[derive(PartialEq, Eq, PartialOrd, Ord)] +struct ResolverFailure { + site: OriginSite, + specifier: String, + importer: String, + cause: String, +} + +/// Resolver diagnostics and original module sources owned by one extraction. +pub(crate) struct ResolverState { + sources: HashMap, + failures: BTreeSet, +} + +impl ResolverState { + pub(crate) fn new(filename: &str, source: &str) -> Self { + Self { + sources: HashMap::from([( + filename.to_string(), + ModuleSource { + code: source.to_string(), + ingress: None, + }, + )]), + failures: BTreeSet::new(), + } + } + + pub(crate) fn cache(&mut self, module: &ResolvedModule, request: &ResolverRequest) { + self.sources.insert( + module.path.clone(), + ModuleSource { + code: module.code.clone(), + ingress: Some(request.origins.clone()), + }, + ); + } + + /// Bind original references before calling JS; recursive exploratory reads + /// without a semantic reference retain the real import that loaded the module. + pub(crate) fn request(&self, specifier: &str, importer: &str) -> Option { + let source = self.sources.get(importer)?; + let mut sites = original_module_reference_locations(importer, &source.code, specifier) + .into_iter() + .map(|location| OriginSite { + filename: importer.to_string(), + location, + }); + let origins = match sites.next() { + Some(first) => OriginSites { + first, + rest: sites.collect(), + }, + None => source.ingress.clone()?, + }; + Some(ResolverRequest { + specifier: specifier.to_string(), + importer: importer.to_string(), + origins, + }) + } + + pub(crate) fn record(&mut self, request: &ResolverRequest, cause: &str) { + for site in std::iter::once(&request.origins.first).chain(&request.origins.rest) { + self.failures.insert(ResolverFailure { + site: site.clone(), + specifier: request.specifier.clone(), + importer: request.importer.clone(), + cause: cause.to_string(), + }); + } + } + + pub(crate) fn check(&self) -> Result<(), String> { + if self.failures.is_empty() { + Ok(()) + } else { + Err(self.failures.iter().map(|failure| { + let OriginSite { filename, location } = &failure.site; + format!( + "{filename}:{}:{}: module resolver failed for `{}` from `{}`: {}\nFix: repair the module resolver; return {{path:string,code:string}} with a non-empty path, or null/undefined for an unresolved module.", + location.line, location.column, failure.specifier, failure.importer, failure.cause, + ) + }).collect::>().join("\n")) + } + } +} + +#[cfg(test)] +#[path = "resolver_state_tests.rs"] +mod tests; diff --git a/bindings/devup-ui-wasm/src/resolver_state_tests.rs b/bindings/devup-ui-wasm/src/resolver_state_tests.rs new file mode 100644 index 00000000..317e3bbf --- /dev/null +++ b/bindings/devup-ui-wasm/src/resolver_state_tests.rs @@ -0,0 +1,174 @@ +use super::*; +use crate::{SourceMapMode, code_extract_internal_impl, export_sheet_internal}; +use std::{cell::RefCell, rc::Rc}; + +#[test] +fn diagnostic_when_resolver_fails_in_cached_imported_typescript() -> Result<(), String> { + // Given + let mut state = ResolverState::new("entry.tsx", "import './barrel';"); + let ingress = state + .request("./barrel", "entry.tsx") + .ok_or("missing ingress reference")?; + state.cache( + &ResolvedModule { + path: "barrel.ts".to_string(), + code: "type Color = string;\nexport {\n color\n} from './color';".to_string(), + }, + &ingress, + ); + let request = state + .request("./color", "barrel.ts") + .ok_or("missing imported reference")?; + // When + state.record(&request, "permission denied: /original/path"); + // Then + let error = state.check().err().unwrap_or_default(); + assert!(error.starts_with("barrel.ts:4:8:")); + assert!(error.contains("permission denied: /original/path")); + assert!(error.contains("Fix: repair the module resolver")); + Ok(()) +} + +#[test] +fn diagnostic_when_specifier_has_multiple_original_sites() -> Result<(), String> { + // Given + let mut state = ResolverState::new("entry.ts", "import './color';\nexport * from './color';"); + let request = state + .request("./color", "entry.ts") + .ok_or("missing original reference")?; + // When + state.record(&request, "broken resolver"); + // Then + let error = state.check().err().unwrap_or_default(); + let diagnostics = error + .lines() + .filter(|line| line.starts_with("entry.ts:")) + .collect::>(); + assert_eq!(diagnostics.len(), 2); + assert!(diagnostics[0].starts_with("entry.ts:1:8: module resolver failed")); + assert!(diagnostics[1].starts_with("entry.ts:2:15: module resolver failed")); + assert!( + diagnostics + .iter() + .all(|line| line.ends_with("broken resolver")) + ); + Ok(()) +} + +#[test] +fn diagnostic_when_repeated_resolution_fails_is_deduplicated() -> Result<(), String> { + // Given + let mut state = ResolverState::new("entry.ts", "import './color';"); + let request = state + .request("./color", "entry.ts") + .ok_or("missing original reference")?; + state.record(&request, "broken resolver"); + let first = state.check(); + // When + state.record(&request, "broken resolver"); + // Then + assert_eq!(state.check(), first); + Ok(()) +} + +#[test] +#[serial_test::serial] +fn publication_when_exploratory_resolution_fails_is_rejected() -> Result<(), String> { + // Given + let source = "import { Box } from '@devup-ui/react';\nimport { color } from './color';\nconst x = ;"; + let state = Rc::new(RefCell::new(ResolverState::new("entry.tsx", source))); + let recorded = Rc::clone(&state); + let resolver = move |specifier: &str, importer: &str| { + let request = recorded.borrow().request(specifier, importer)?; + recorded + .borrow_mut() + .record(&request, "exploratory resolver failure"); + None + }; + let before = export_sheet_internal()?; + // When + let result = code_extract_internal_impl( + "entry.tsx", + source, + "@devup-ui/react", + "df".to_string(), + true, + false, + false, + HashMap::new(), + SourceMapMode::Generate, + Some(&resolver), + Some(&state), + ); + // Then + let error = result.err().unwrap_or_default(); + assert!(error.starts_with("entry.tsx:2:23:")); + assert!(error.contains("exploratory resolver failure")); + assert_eq!(export_sheet_internal()?, before); + Ok(()) +} + +#[test] +fn clean_extraction_state_when_another_extraction_failed() -> Result<(), String> { + // Given + let mut failed = ResolverState::new("entry.ts", "import './color';"); + let request = failed + .request("./color", "entry.ts") + .ok_or("missing original reference")?; + failed.record(&request, "broken resolver"); + // When + let clean = ResolverState::new("entry.ts", "import './color';"); + // Then + assert_eq!(clean.check(), Ok(())); + Ok(()) +} + +#[test] +#[serial_test::serial] +fn diagnostic_when_imported_constant_exploration_uses_shadowed_require_retains_ingress() +-> Result<(), String> { + // Given + let source = "import { Box } from '@devup-ui/react';\nimport { color } from './helper';\nconst x = ;"; + let state = Rc::new(RefCell::new(ResolverState::new("entry.tsx", source))); + let recorded = Rc::clone(&state); + let resolver = move |specifier: &str, importer: &str| { + let request = recorded.borrow().request(specifier, importer)?; + match (request.importer(), request.specifier()) { + ("entry.tsx", "./helper") => { + let module = ResolvedModule { + path: "helper.ts".to_string(), + code: "const require = () => ({color: 'red'}); const {color} = require('./shadowed'); exports.color = color;".to_string(), + }; + recorded.borrow_mut().cache(&module, &request); + Some(module) + } + ("helper.ts", "./shadowed") => { + recorded + .borrow_mut() + .record(&request, "shadowed resolver failure"); + None + } + other => panic!("unexpected module reference: {other:?}"), + } + }; + let before = export_sheet_internal()?; + // When + let result = code_extract_internal_impl( + "entry.tsx", + source, + "@devup-ui/react", + "df".to_string(), + true, + false, + false, + HashMap::new(), + SourceMapMode::Generate, + Some(&resolver), + Some(&state), + ); + // Then + let error = result.err().unwrap_or_default(); + assert!(error.starts_with("entry.tsx:2:23: module resolver failed for `./shadowed` from `helper.ts`: shadowed resolver failure")); + assert_eq!(export_sheet_internal()?, before); + Ok(()) +} diff --git a/libs/css/src/lib.rs b/libs/css/src/lib.rs index 576ba53b..427732ae 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 d8d8fb02..3b2346ae 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 00000000..29bc26b2 --- /dev/null +++ b/libs/extractor/src/barrel.rs @@ -0,0 +1,1122 @@ +//! 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 { + crate::package_specifier::is_package(source, self.package) + } + + 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; +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(_)) => {} + (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, _) => {} + } + } + + /// 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.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_some_and(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(), + }) + .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 00000000..1771f9e1 --- /dev/null +++ b/libs/extractor/src/barrel/aliases.rs @@ -0,0 +1,388 @@ +//! 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 mut spaces: FxHashMap = namespaces + .iter() + .enumerate() + .filter_map(|(index, namespace)| Some((namespace.symbol?, index))) + .collect(); + let first_new = namespaces.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 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 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); + } else 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 00000000..f1e3ee9f --- /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/scope_tests.rs b/libs/extractor/src/barrel/scope_tests.rs new file mode 100644 index 00000000..8a1763f6 --- /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 00000000..d509e81d --- /dev/null +++ b/libs/extractor/src/barrel/tests.rs @@ -0,0 +1,1072 @@ +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, +}; + +/// 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("
fn)(Devup)\nexport default Devup\nexport const c = typeof Devup\n" + )); + assert!(code.contains("Object.keys(Devup)"), "{code}"); + assert!(code.contains("export default Devup"), "{code}"); +} + +#[test] +#[serial] +fn namespace_styled_in_a_stylesheet_is_compiled_by_the_visitor() { + reset_class_map(); + reset_file_map(); + let output = extract( + "test.css.ts", + "import * as Devup from '@devup-ui/react'\nexport const A = Devup.styled.div({ color: 'red' })\n", + ExtractOption::default(), + ) + .unwrap(); + assert!(!output.code.contains("Devup.styled"), "{}", output.code); + assert_ne!(output.styles.len(), 0); +} + +#[test] +fn gate_sees_files_only_extraction_of_aliases_changes() { + let aliases = HashMap::from([ + ( + "@emotion/react".to_string(), + crate::ImportAlias::NamedToNamed, + ), + ( + "@emotion/styled".to_string(), + crate::ImportAlias::DefaultToNamed("styled".to_string()), + ), + ]); + let gate = |file: &str, code: &str, aliases: &HashMap| { + 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' }})\nlet L = Devup\nexport const c = L\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 1e8bdd7e..56aacd6e 100644 --- a/libs/extractor/src/build_time_values.rs +++ b/libs/extractor/src/build_time_values.rs @@ -21,6 +21,13 @@ use oxc_syntax::reference::ReferenceId; use oxc_syntax::symbol::SymbolId; use rustc_hash::{FxHashMap, FxHashSet}; +#[path = "build_time_values_execute.rs"] +mod execute; +#[path = "build_time_values_mapping.rs"] +mod mapping; +use execute::compute; +use mapping::Generated; + use crate::stylex::StylexFunction; use crate::utils::{binding_root, get_string_by_literal_expression, unwrap_syntax_only}; use crate::{ExtractOption, ModuleResolver}; @@ -57,14 +64,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,11 +80,12 @@ 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( &program, - &|source| source.starts_with(option.package.as_str()), + &|source| crate::package_specifier::is_package(source, &option.package), &inlined.unknown, &|name| changes.is_changed(name), &|name| changes.known(name), @@ -278,7 +286,10 @@ impl<'s, 'a> Finder<'s, 'a> { Statement::ExportDefaultDeclaration(export) => { match &export.declaration { ExportDefaultDeclarationKind::FunctionDeclaration(function) => { - declare(function.id.as_ref().and_then(|id| id.symbol_id.get()), true); + declare( + function.id.as_ref().and_then(|id| id.symbol_id.get()), + function.body.is_some(), + ); } ExportDefaultDeclarationKind::ClassDeclaration(class) => { declare(class.id.as_ref().and_then(|id| id.symbol_id.get()), false); @@ -298,13 +309,18 @@ impl<'s, 'a> Finder<'s, 'a> { for identifier in declarator.id.get_binding_identifiers() { declare( identifier.symbol_id.get(), - declaration.kind == VariableDeclarationKind::Const, + declaration.kind == VariableDeclarationKind::Const + && !declaration.declare + && declarator.init.is_some(), ); } } } Declaration::FunctionDeclaration(function) => { - declare(function.id.as_ref().and_then(|id| id.symbol_id.get()), true); + declare( + function.id.as_ref().and_then(|id| id.symbol_id.get()), + function.body.is_some(), + ); } Declaration::ClassDeclaration(class) => { declare(class.id.as_ref().and_then(|id| id.symbol_id.get()), false); @@ -344,7 +360,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)) @@ -906,22 +925,36 @@ impl<'a> Visit<'a> for Reads<'_> { fn visit_ts_type(&mut self, _: &oxc_ast::ast::TSType<'a>) {} } +/// What evaluating computed: the code with the values written in their place, +/// the replacements made, and the files read +pub(crate) type Evaluated = ( + String, + Vec, + BTreeSet, +); + +/// An error the build reports: the offset in the code evaluated that it names, +/// and what it tells +pub(crate) type Located = (usize, String); + /// `code` with what its style values compute written in their place, the /// replacements made, and the files read; `None` when running the code it /// reads computes none of them as a string, a finite number, or a plain -/// object or array of those -pub(crate) fn evaluate( +/// object or array of those. Code that reads what differs between builds or +/// pages (the clock, chance, the environment, the locale) is an error naming +/// the style value it computes, whether or not the code caught what the read +/// threw. +pub(crate) fn evaluate_located( code: &str, filename: &str, option: &ExtractOption, resolver: Option<&ModuleResolver>, unknown: &crate::imported_constants::Unknown, -) -> Option<( - String, - Vec, - BTreeSet, -)> { - let (mut values, dependencies) = compute(code, filename, option, resolver, unknown)?; +) -> Result, Vec> { + let Some((mut values, dependencies)) = compute(code, filename, option, resolver, unknown)? + else { + return Ok(None); + }; values.sort_unstable_by_key(|(span, _)| span.start); let mut result = String::with_capacity(code.len()); let mut edits = Vec::with_capacity(values.len()); @@ -934,61 +967,17 @@ pub(crate) fn evaluate( copied = end; } result.push_str(&code[copied..]); - Some((result, edits, dependencies)) + Ok(Some((result, edits, dependencies))) } /// A value's source text by the span of the code computing it type Replacement = (Span, String); -/// Run before the values: the code checked to read nothing that differs -/// between builds or pages, nothing does even if the check missed it -/// (reading `Date`, `Math.random`, the environment or the locale throws), and -/// the source text of a value the build can read. A statement that throws is -/// recorded, and a value reading it is not computed -const PRELUDE: &str = r#"delete globalThis.Date; -Math.random = undefined; -for (const name of ["window", "self", "document", "navigator", "location", "history", "localStorage", "sessionStorage", "matchMedia", "screen", "innerWidth", "innerHeight", "devicePixelRatio", "process", "global", "Deno", "Bun", "Intl"]) Object.defineProperty(globalThis, name, { get() { throw new ReferenceError(`${name} is only known at runtime`); }, configurable: true }); -for (const [prototype, names] of [[Object.prototype, ["toLocaleString"]], [Number.prototype, ["toLocaleString"]], [BigInt.prototype, ["toLocaleString"]], [Array.prototype, ["toLocaleString"]], [String.prototype, ["localeCompare", "toLocaleUpperCase", "toLocaleLowerCase", "normalize"]]]) for (const name of names) Object.defineProperty(prototype, name, { value() { throw new ReferenceError(`${name} depends on the locale`); }, configurable: true, writable: true }); -Object.setPrototypeOf(globalThis, new Proxy(Object.getPrototypeOf(globalThis), { get(target, key, receiver) { if (typeof key === "string" && !(key in target)) throw new ReferenceError(`${key} is only known at runtime`); return Reflect.get(target, key, receiver); } })); -const __failed__ = (() => { const fail = () => { throw new ReferenceError("its value threw"); }; return new Proxy(function () {}, { get: fail, apply: fail, construct: fail, getPrototypeOf: fail }); })(); -const __failed_statements__ = new Set(); -const __try__ = (compute, statement) => { try { return compute(); } catch { __failed_statements__.add(statement); return __failed__; } }; -const __literal__ = (value) => { - const plain = (item) => item === undefined || item === null || typeof item === "string" || typeof item === "boolean" || (typeof item === "number" && Number.isFinite(item)) - || (Array.isArray(item) && item.every(plain)) - || (typeof item === "object" && Object.getPrototypeOf(item) === Object.prototype && Object.getOwnPropertySymbols(item).length === 0 && !Object.prototype.hasOwnProperty.call(item, "__proto__") && Object.values(item).every(plain)); - if (typeof value === "number") return Number.isFinite(value) ? String(value) : undefined; - return value !== undefined && plain(value) ? JSON.stringify(value) : undefined; -}; -"#; - -fn compute( - code: &str, - filename: &str, - option: &ExtractOption, - resolver: Option<&ModuleResolver>, - unknown: &crate::imported_constants::Unknown, -) -> Option<(Vec, BTreeSet)> { +/// The statements the values read, run as they are written, and the imports +/// they read as the build knows them: the modules those come from never run +fn definitions(program: &Program<'_>, code: &str, found: &[Found]) -> Generated { use std::fmt::Write; - use boa_engine::{Context, JsObject, Source}; - - let allocator = Allocator::default(); - let program = parse(&allocator, filename, code)?; - let is_style = |source: &str| { - source.starts_with(option.package.as_str()) || option.import_aliases.contains_key(source) - }; - let changes = crate::imported_constants::ChangeCheck::new(&program, filename, option, resolver); - let found = find( - &program, - &is_style, - unknown, - &|name| changes.is_changed(name), - &|name| changes.known(name), - ); - if found.is_empty() { - return None; - } let statements: BTreeSet = found .iter() .flat_map(|found| found.closure.statements.iter().copied()) @@ -998,11 +987,9 @@ fn compute( .flat_map(|found| &found.closure.imports) .map(|(name, value)| (name.as_str(), value.as_str())) .collect(); - // The modules imports come from never run: their values are written as - // the build knows them - let mut module = String::new(); + let mut module = Generated::default(); for (name, value) in imports { - let _ = writeln!(module, "const {name} = {value};"); + let _ = writeln!(module.text, "const {name} = {value};"); } for index in statements { let statement = &program.body[index]; @@ -1017,17 +1004,21 @@ fn compute( if let Some(declaration) = declaration { // A binding whose value throws is one reading it throws for, // leaving the others - for declarator in &declaration.declarations { - let Some(init) = &declarator.init else { - continue; - }; - let _ = writeln!( - module, - "{} {}__try__(() => ({}), {index});", - declaration.kind.as_str(), - &code[declarator.span.start as usize..init.span().start as usize], - &code[init.span().start as usize..init.span().end as usize], + for (declarator, init) in declaration + .declarations + .iter() + .filter_map(|declarator| declarator.init.as_ref().map(|init| (declarator, init))) + { + module.text.push_str(declaration.kind.as_str()); + module.text.push(' '); + module.copy( + code, + declarator.span.start as usize, + init.span().start as usize, ); + module.text.push_str("__try__(() => ("); + module.copy(code, init.span().start as usize, init.span().end as usize); + let _ = writeln!(module.text, "), {index});"); } continue; } @@ -1036,59 +1027,36 @@ fn compute( Statement::ExportDefaultDeclaration(export) => export.declaration.span(), statement => statement.span(), }; - module.push_str(&code[span.start as usize..span.end as usize]); - module.push('\n'); + module.copy(code, span.start as usize, span.end as usize); + module.text.push('\n'); } - // Each runs apart, so one that throws leaves the others - for (index, found) in found.iter().enumerate() { - let statements: Vec = found - .closure - .statements - .iter() - .map(ToString::to_string) - .collect(); - let _ = writeln!( - module, - "const __value_{index}__ = (() => {{ if ([{}].some((statement) => __failed_statements__.has(statement))) return undefined; try {{ return __literal__(({})); }} catch {{ return undefined; }} }})();", - statements.join(", "), - &code[found.span.start as usize..found.span.end as usize] - ); - } - let names: Vec = (0..found.len()) - .map(|index| format!("__value_{index}__")) + module +} + +/// The source text of what `found` computes, or nothing when a statement it +/// reads threw. Each value runs apart, so one that throws leaves the others. +fn value_script(code: &str, found: &Found) -> Generated { + let statements: Vec = found + .closure + .statements + .iter() + .map(ToString::to_string) .collect(); - let _ = writeln!(module, "[{}];", names.join(", ")); - - let script = crate::vanilla_extract::strip_typescript(&module, filename); - let mut context = Context::default(); - context - .runtime_limits_mut() - .set_loop_iteration_limit(crate::module_loader::LOOP_ITERATION_LIMIT); - let values = context - .eval(Source::from_bytes(format!("{PRELUDE}{script}").as_bytes())) - .ok()?; - let values = values.as_object().filter(JsObject::is_array)?; - let mut computed = Vec::new(); - for (index, found) in found.into_iter().enumerate() { - let Some(literal) = values - .get(index, &mut context) - .ok()? - .as_string() - .map(|literal| literal.to_std_string_escaped()) - else { - continue; - }; - // A part joined gives rules, or a class as a string - if found.rules_only && !literal.starts_with(['{', '[', '"']) { - continue; - } - computed.push(( - found.span, - match found.shorthand { - Some(key) => format!("{key}: {literal}"), - None => literal, - }, - )); - } - (!computed.is_empty()).then_some((computed, changes.dependencies())) + let mut generated = Generated::default(); + generated.text = format!( + "(() => {{ if ([{}].some((statement) => __failed_statements__.has(statement))) return undefined; try {{ return __literal__((", + statements.join(", ") + ); + generated.copy(code, found.span.start as usize, found.span.end as usize); + generated + .text + .push_str(")); } catch { return undefined; } })();"); + generated } + +#[cfg(test)] +mod scope_tests; + +#[cfg(test)] +#[path = "build_time_values_sandbox_tests.rs"] +mod sandbox_tests; 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 00000000..5529f9c8 --- /dev/null +++ b/libs/extractor/src/build_time_values/scope_tests.rs @@ -0,0 +1,51 @@ +use super::has_build_time_values; +use crate::ExtractOption; + +#[test] +fn erased_bindings_are_runtime_only_inputs() { + let option = ExtractOption::default(); + for body in [ + "declare const a: number, b: number; export const x = css({ w: a + b });", + "declare function getHover(): object; export const x = ;", + "export declare function getHover(): object; export const x = ;", + ] { + // Given erased declarations referenced by a style expression. + let code = format!("import {{ css, Box }} from '@devup-ui/react'; {body}"); + // When selecting exact computations, then none may execute those inputs. + assert!( + !has_build_time_values("a.tsx", &code, &option, None), + "{body}" + ); + } +} + +#[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_execute.rs b/libs/extractor/src/build_time_values_execute.rs new file mode 100644 index 00000000..88fb78ec --- /dev/null +++ b/libs/extractor/src/build_time_values_execute.rs @@ -0,0 +1,170 @@ +//! Execution of the exact value closures selected by the lexical reader. + +use boa_engine::{Context, Source}; +use oxc_allocator::Allocator; +use std::{collections::BTreeSet, path::Path}; + +use super::mapping::Mapped; +use super::{Located, Replacement, definitions, find, parse, value_script}; +use crate::evaluation_sandbox::{Failure, Sandbox}; +use crate::{ExtractOption, ModuleResolver}; + +const HELPERS: &str = r#"const __failed__ = (() => { const fail = () => { throw new ReferenceError("its value threw"); }; return new Proxy(function () {}, { get: fail, apply: fail, construct: fail, getPrototypeOf: fail }); })(); +const __failed_statements__ = new Set(); +const __try__ = (compute, statement) => { try { return compute(); } catch { __failed_statements__.add(statement); return __failed__; } }; +const __literal__ = (value) => { + const plain = (item) => item === undefined || item === null || typeof item === "string" || typeof item === "boolean" || (typeof item === "number" && Number.isFinite(item)) + || (Array.isArray(item) && item.every(plain)) + || (typeof item === "object" && Object.getPrototypeOf(item) === Object.prototype && Object.getOwnPropertySymbols(item).length === 0 && !Object.prototype.hasOwnProperty.call(item, "__proto__") && Object.values(item).every(plain)); + if (typeof value === "number") return Number.isFinite(value) ? String(value) : undefined; + return value !== undefined && plain(value) ? JSON.stringify(value) : undefined; +}; +"#; + +const HELPERS_SOURCE: &str = ""; +const DEFINITIONS_SOURCE: &str = ""; +const VALUE_SOURCE: &str = ""; +type Computed = (Vec, BTreeSet); + +fn initialize_helpers(sandbox: &Sandbox, context: &mut Context) -> Result<(), Failure> { + sandbox + .run_source( + context, + Source::from_bytes(HELPERS).with_path(Path::new(HELPERS_SOURCE)), + ) + .map(|_| ()) +} + +fn setup_failure(failure: Failure) -> Vec { + let reasons = match failure { + Failure::Js(error) => vec![error.to_string()], + Failure::Forbidden(violations) => violations + .iter() + .map(|violation| violation.error().to_string()) + .collect(), + }; + reasons + .into_iter() + .map(|reason| { + ( + 0, + format!("{reason}; report internal evaluation setup failure"), + ) + }) + .collect() +} + +fn stop( + failure: Failure, + scripts: &[Mapped], + code: &str, +) -> Result, Vec> { + let violations = match failure { + Failure::Forbidden(violations) => violations, + Failure::Js(_) => return Ok(None), + }; + let mut errors: Vec = violations + .iter() + .map(|violation| { + let offset = scripts.iter().find_map(|script| script.locate(violation)); + let Some(offset) = offset else { + return (0, violation.error().to_string()); + }; + let tail = &code[offset..]; + let read = tail + .split_once('\n') + .map_or(tail, |(line, _)| line) + .trim_end_matches(';'); + ( + offset, + format!( + "`{}` cannot use `{}` at build time: {}", + violation.name(), + read, + violation.requirement() + ), + ) + }) + .collect(); + errors.dedup(); + Err(errors) +} + +pub(super) fn compute( + code: &str, + filename: &str, + option: &ExtractOption, + resolver: Option<&ModuleResolver>, + unknown: &crate::imported_constants::Unknown, +) -> Result, Vec> { + let allocator = Allocator::default(); + let Some(program) = parse(&allocator, filename, code) else { + return Ok(None); + }; + let is_style = |source: &str| { + crate::package_specifier::is_package(source, &option.package) + || option.import_aliases.contains_key(source) + }; + let changes = crate::imported_constants::ChangeCheck::new(&program, filename, option, resolver); + let found = find( + &program, + &is_style, + unknown, + &|name| changes.is_changed(name), + &|name| changes.known(name), + ); + if found.is_empty() { + return Ok(None); + } + let mut context = Context::default(); + let sandbox = Sandbox::new(&mut context).map_err(|error| vec![(0, error.to_string())])?; + initialize_helpers(&sandbox, &mut context).map_err(setup_failure)?; + let module = definitions(&program, code, &found); + let mut scripts = vec![Mapped::new(module, filename, DEFINITIONS_SOURCE)]; + sandbox + .prepare(&mut context, &scripts[0].instrumented) + .map_err(|error| vec![(0, error.to_string())])?; + if let Err(failure) = sandbox.run_source( + &mut context, + Source::from_bytes(&scripts[0].instrumented.code).with_path(Path::new(DEFINITIONS_SOURCE)), + ) { + return stop(failure, &scripts, code); + } + let mut computed = Vec::new(); + for found in found { + let value_source = format!("{VALUE_SOURCE}:{}", found.span.start); + let script = Mapped::new(value_script(code, &found), filename, &value_source); + sandbox + .prepare(&mut context, &script.instrumented) + .map_err(|error| vec![(0, error.to_string())])?; + let result = sandbox.run_source( + &mut context, + Source::from_bytes(&script.instrumented.code).with_path(Path::new(&value_source)), + ); + scripts.push(script); + let value = match result { + Ok(value) => value, + Err(failure) => return stop(failure, &scripts, code), + }; + let Some(literal) = value + .as_string() + .map(|literal| literal.to_std_string_escaped()) + else { + continue; + }; + if found.rules_only && !literal.starts_with(['{', '[', '"']) { + continue; + } + computed.push(( + found.span, + match found.shorthand { + Some(key) => format!("{key}: {literal}"), + None => literal, + }, + )); + } + Ok((!computed.is_empty()).then_some((computed, changes.dependencies()))) +} + +#[cfg(test)] +mod build_time_values_execute_coverage_tests; diff --git a/libs/extractor/src/build_time_values_execute_coverage_tests.rs b/libs/extractor/src/build_time_values_execute_coverage_tests.rs new file mode 100644 index 00000000..2ebea874 --- /dev/null +++ b/libs/extractor/src/build_time_values_execute_coverage_tests.rs @@ -0,0 +1,174 @@ +use boa_engine::{Context, Source}; + +use super::{compute, initialize_helpers, setup_failure, stop}; +use crate::evaluation_sandbox::{Failure, Sandbox}; + +#[test] +fn execution_failure_preserves_guard_error_when_no_generated_script_can_locate_it() +-> Result<(), String> { + // Given + let mut context = Context::default(); + let sandbox = Sandbox::new(&mut context).map_err(|error| error.to_string())?; + let failure = sandbox + .run_source(&mut context, Source::from_bytes("Date.now()")) + .err() + .ok_or("guard succeeded")?; + // When + let errors = stop(failure, &[], "Date.now()") + .err() + .ok_or("failure was lost")?; + // Then + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].0, 0); + assert!(errors[0].1.contains("`Date`")); + assert!(errors[0].1.starts_with("ReferenceError:")); + Ok(()) +} + +#[test] +fn invalid_source_has_no_computed_values() { + // Given + let code = "import { css } from '@devup-ui/react'; const = css({ w: 2 });"; + // When + let result = compute( + code, + "invalid.tsx", + &crate::ExtractOption::default(), + None, + &crate::imported_constants::Unknown::default(), + ); + // Then + assert!(matches!(result, Ok(None))); +} + +#[test] +fn mutable_sibling_declarations_leave_style_values_at_runtime() { + // Given + let code = "import { css } from '@devup-ui/react'; let unused, width = 2; export const a = css({ w: width + 1 });"; + // When + let result = compute( + code, + "sibling.tsx", + &crate::ExtractOption::default(), + None, + &crate::imported_constants::Unknown::default(), + ); + // Then + assert!(matches!(result, Ok(None))); +} + +#[test] +fn definitions_skip_ambient_declarators_when_generating_initialized_values() -> Result<(), String> { + use super::super::{Closure, Found, definitions, parse}; + + // Given + let code = "declare const external: number; const width = 2;"; + let allocator = oxc_allocator::Allocator::default(); + let program = parse(&allocator, "definitions.ts", code).ok_or("valid TypeScript rejected")?; + let found = Found { + span: oxc_span::Span::default(), + closure: Closure { + statements: [0, 1].into_iter().collect(), + imports: Default::default(), + }, + shorthand: None, + rules_only: false, + }; + // When + let generated = definitions(&program, code, &[found]); + // Then + assert_eq!(generated.text, "const width = __try__(() => (2), 1);\n"); + Ok(()) +} + +#[test] +fn helpers_initialize_when_realm_is_fresh() -> Result<(), String> { + // Given + let mut context = Context::default(); + let sandbox = Sandbox::new(&mut context).map_err(|error| error.to_string())?; + // When + let result = initialize_helpers(&sandbox, &mut context); + // Then + assert!(result.is_ok()); + assert_eq!( + context + .eval(Source::from_bytes("__literal__({ width: 3 })")) + .map_err(|error| error.to_string())? + .as_string() + .ok_or("literal helper did not serialize value")? + .to_std_string_escaped(), + "{\"width\":3}" + ); + Ok(()) +} + +#[test] +fn setup_failure_preserves_native_syntax_error_when_binding_is_reserved() -> Result<(), String> { + // Given + let mut context = Context::default(); + let sandbox = Sandbox::new(&mut context).map_err(|error| error.to_string())?; + context + .eval(Source::from_bytes("const __failed__ = 1;")) + .map_err(|error| error.to_string())?; + let failure = initialize_helpers(&sandbox, &mut context) + .err() + .ok_or("duplicate lexical binding succeeded")?; + let original = match &failure { + Failure::Js(error) => error.to_string(), + Failure::Forbidden(_) => return Err("duplicate binding must be a native JS error".into()), + }; + assert!(original.starts_with("SyntaxError:"), "{original}"); + // When + let errors = setup_failure(failure); + // Then + assert_eq!( + errors, + vec![( + 0, + format!("{original}; report internal evaluation setup failure") + )] + ); + Ok(()) +} + +#[test] +fn setup_failure_preserves_forbidden_read_provenance() -> Result<(), String> { + // Given + let mut context = Context::default(); + let sandbox = Sandbox::new(&mut context).map_err(|error| error.to_string())?; + let failure = sandbox + .run_source(&mut context, Source::from_bytes("Date.now()")) + .err() + .ok_or("forbidden clock read succeeded")?; + let original = match &failure { + Failure::Forbidden(violations) => violations[0].error().to_string(), + Failure::Js(_) => return Err("clock read must preserve forbidden evidence".into()), + }; + // When + let errors = setup_failure(failure); + // Then + assert_eq!( + errors, + vec![( + 0, + format!("{original}; report internal evaluation setup failure") + )] + ); + Ok(()) +} + +#[test] +fn ordinary_js_failure_leaves_computation_at_runtime() -> Result<(), String> { + // Given + let mut context = Context::default(); + let sandbox = Sandbox::new(&mut context).map_err(|error| error.to_string())?; + let failure = sandbox + .run_source(&mut context, Source::from_bytes("null.value")) + .err() + .ok_or("native TypeError missing")?; + // When + let result = stop(failure, &[], "null.value"); + // Then + assert!(matches!(result, Ok(None))); + Ok(()) +} diff --git a/libs/extractor/src/build_time_values_mapping.rs b/libs/extractor/src/build_time_values_mapping.rs new file mode 100644 index 00000000..00606cc7 --- /dev/null +++ b/libs/extractor/src/build_time_values_mapping.rs @@ -0,0 +1,74 @@ +//! Original-source tracing for generated build-time helper statements. + +use crate::evaluation_sandbox::{Violation, instrument}; +use crate::source_map::{Lines, Trace}; + +#[derive(Default)] +pub(super) struct Generated { + pub(super) text: String, + copied: Vec<(usize, usize, usize)>, +} + +impl Generated { + pub(super) fn copy(&mut self, source: &str, from: usize, to: usize) { + let start = self.text.len(); + self.text.push_str(&source[from..to]); + self.copied.push((start, self.text.len(), from)); + } + + fn locate(&self, offset: usize) -> Option { + self.copied + .iter() + .find(|(start, end, _)| (*start..*end).contains(&offset)) + .map(|(start, _, original)| original + offset - start) + } +} + +pub(super) struct Mapped { + generated: Generated, + stripped: std::rc::Rc, + trace: Trace, + path: String, + pub(super) instrumented: crate::evaluation_sandbox::Instrumented, +} + +impl Mapped { + pub(super) fn new(generated: Generated, filename: &str, path: &str) -> Self { + let stripped = crate::vanilla_extract::strip_typescript_marked(&generated.text, filename); + let trace = Trace::new(&stripped.marks, &[]); + let instrumented = instrument(&stripped.code, path); + Self { + generated, + stripped, + trace, + path: path.to_string(), + instrumented, + } + } + + pub(super) fn locate(&self, violation: &Violation) -> Option { + let offset = match violation.site() { + Some((place, offset)) if place.starts_with(&format!("{}:", self.path)) => offset, + Some(_) => return None, + None => { + let error = self.instrumented.explain(&violation.error().to_string()); + let frame = error.lines().find_map(|line| { + line.split_once(&format!("({}:", self.path)) + .map(|(_, position)| position) + })?; + let (line, column) = frame.trim_end_matches(')').split_once(':')?; + Lines::new(&self.stripped.code).code_point_offset( + line.parse::().ok()?.saturating_sub(1), + column.parse::().ok()?.saturating_sub(1), + ) + } + }; + self.generated.locate( + self.trace + .resolve(&self.stripped.code, &self.generated.text, offset), + ) + } +} + +#[cfg(test)] +mod build_time_values_mapping_coverage_tests; diff --git a/libs/extractor/src/build_time_values_mapping_coverage_tests.rs b/libs/extractor/src/build_time_values_mapping_coverage_tests.rs new file mode 100644 index 00000000..7345f4f0 --- /dev/null +++ b/libs/extractor/src/build_time_values_mapping_coverage_tests.rs @@ -0,0 +1,40 @@ +use boa_engine::{Context, Source}; +use rstest::rstest; + +use super::{Generated, Mapped}; +use crate::evaluation_sandbox::{Failure, Sandbox}; + +#[rstest] +#[case("Date.now()", "Date")] +#[case("Math.random()", "Math.random")] +fn fallback_frames_trace_to_copied_source_when_execution_has_no_instrumentation( + #[case] read: &str, + #[case] name: &str, +) -> Result<(), String> { + // Given + let script = format!("function read() {{ return {read}; }} read();"); + let original = format!("// original\n{script}"); + let start = original.find(&script).ok_or("missing fixture read")?; + let mut generated = Generated::default(); + generated.copy(&original, start, original.len()); + let mapped = Mapped::new(generated, "boundary.ts", "mapped.js"); + let mut context = Context::default(); + let sandbox = Sandbox::new(&mut context).map_err(|error| error.to_string())?; + // When + let result = sandbox.run_source( + &mut context, + Source::from_bytes(&script).with_path(std::path::Path::new("mapped.js")), + ); + let Err(Failure::Forbidden(reads)) = result else { + return Err("missing forbidden read".to_string()); + }; + // Then + assert_eq!(reads[0].name(), name); + assert_eq!( + mapped.locate(&reads[0]), + Some(original.find('{').ok_or("missing original function body")?), + "{}", + reads[0].error() + ); + Ok(()) +} diff --git a/libs/extractor/src/build_time_values_sandbox_tests.rs b/libs/extractor/src/build_time_values_sandbox_tests.rs new file mode 100644 index 00000000..177a8e3d --- /dev/null +++ b/libs/extractor/src/build_time_values_sandbox_tests.rs @@ -0,0 +1,87 @@ +use rstest::rstest; + +fn evaluate(code: &str) -> Result, Vec<(usize, String)>> { + super::evaluate_located( + code, + "a.tsx", + &crate::ExtractOption::default(), + None, + &crate::imported_constants::Unknown::default(), + ) + .map(|evaluated| evaluated.map(|(code, ..)| code)) +} + +const COMPARE: &str = "import { css } from '@devup-ui/react';\nconst compare = Object.getOwnPropertyDescriptor(String.prototype, 'localeCompare').value;\n"; + +#[test] +fn a_later_value_reports_its_own_read_offset() -> Result<(), String> { + // Given two separate computed values, with the forbidden read in the second. + let code = "import { css } from '@devup-ui/react';\nexport const a = css({ w: Number('2'), h: Object.getOwnPropertyDescriptor(String.prototype, 'localeCompare').value('a', 'b') });"; + // When evaluation records the read. + let errors = evaluate(code).err().ok_or("evaluation succeeded")?; + // Then its location names the second value, not the first generated script. + assert_eq!( + errors[0].0, + code.find("Object.getOwnPropertyDescriptor") + .ok_or("missing read")? + ); + Ok(()) +} + +#[rstest] +#[case("export const a = css({ w: compare('a', 'b') });")] +#[case( + "const sorted = ['b', 'a'].map((item) => compare(item, 'a'));\nexport const a = css({ w: sorted[0] });" +)] +fn forbidden_helper_references_report_the_helper_not_the_style_value( + #[case] tail: &str, +) -> Result<(), String> { + let code = format!("{COMPARE}{tail}"); + let errors = evaluate(&code).err().ok_or("evaluation succeeded")?; + assert_eq!(errors.len(), 1); + assert_eq!( + errors[0].0, + code.find("Object.getOwnPropertyDescriptor") + .ok_or("fixture occurrence missing")? + ); + assert!(errors[0].1.contains("String.prototype.localeCompare")); + assert!(!errors[0].1.contains("Fix:")); + assert!( + errors[0] + .1 + .contains("cannot use `Object.getOwnPropertyDescriptor") + ); + Ok(()) +} + +#[test] +fn deterministic_code_still_computes_its_values() -> Result<(), String> { + let code = "import { css } from '@devup-ui/react';\nconst double = (n) => Math.max(n, 1) * 2;\nexport const a = css({ w: double(2) });"; + let result = evaluate(code) + .map_err(|errors| format!("{errors:?}"))? + .ok_or("no computed value")?; + assert!(result.contains("w: 4")); + Ok(()) +} + +#[rstest] +#[case( + "import { css } from '@devup-ui/react';\nconst fail = () => { throw 1; };\nexport const a = css({ w: fail() });" +)] +#[case("import { css } from '@devup-ui/react';\nconst NaN = 1;\nexport const a = css({ w: NaN });")] +#[case( + "import { css } from '@devup-ui/react';\ndeclare const a: number, b: number;\nexport const x = css({ w: a + b });" +)] +#[case( + "import { Box } from '@devup-ui/react';\ndeclare function getHover(): object;\nexport const x = ;" +)] +fn a_definition_that_fails_for_another_reason_leaves_runtime_values( + #[case] code: &str, +) -> Result<(), String> { + assert!( + evaluate(code) + .map_err(|errors| format!("{errors:?}"))? + .is_none() + ); + Ok(()) +} diff --git a/libs/extractor/src/ci_coverage_tests.rs b/libs/extractor/src/ci_coverage_tests.rs new file mode 100644 index 00000000..1d9a0e12 --- /dev/null +++ b/libs/extractor/src/ci_coverage_tests.rs @@ -0,0 +1,25 @@ +use crate::{ExtractOption, extract}; + +#[rstest::rstest] +#[case("computed.tsx")] +#[case("computed.css.ts")] +#[serial_test::serial] +fn computed_style_guard_error_names_original_read_location( + #[case] filename: &str, +) -> Result<(), Box> { + // Given + let code = "import { css } from '@devup-ui/react';\nconst compare = Object.getOwnPropertyDescriptor(String.prototype, 'localeCompare').value;\nexport const a = css({ w: compare('a', 'b') });"; + // When + let error = extract(filename, code, ExtractOption::default()) + .err() + .ok_or("forbidden style unexpectedly extracted")?; + // Then + let error = error.to_string(); + assert!(error.starts_with(&format!("{filename}:2:17:")), "{error}"); + assert!(error.contains("String.prototype.localeCompare"), "{error}"); + assert!( + error.contains("Fix: use a literal or a CSS variable"), + "{error}" + ); + Ok(()) +} diff --git a/libs/extractor/src/composition.rs b/libs/extractor/src/composition.rs new file mode 100644 index 00000000..b59abb6c --- /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 00000000..6a1e21b6 --- /dev/null +++ b/libs/extractor/src/css_prop.rs @@ -0,0 +1,1065 @@ +//! 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| crate::package_specifier::is_package(source, package)) +} + +/// 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 00000000..cdfbb56e --- /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 f0d67200..ab3239a6 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/diagnostics_tests.rs b/libs/extractor/src/diagnostics_tests.rs new file mode 100644 index 00000000..c698747e --- /dev/null +++ b/libs/extractor/src/diagnostics_tests.rs @@ -0,0 +1,90 @@ +use crate::{ExtractOption, extract_without_source_map}; +use serial_test::serial; + +#[test] +#[serial] +fn fatal_parse_reports_original_file_and_first_parser_error() { + let source = "import { Box } from '@devup-ui/react';\nexport const view = ;"; + + let error = extract_without_source_map("/src/broken.tsx", source, ExtractOption::default()) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + + assert!(error.starts_with("/src/broken.tsx:2:31:"), "{error}"); + assert!(error.contains("Unterminated regular expression"), "{error}"); + assert!(error.contains("Fix:"), "{error}"); +} + +#[test] +#[serial] +fn unsupported_extension_reports_the_input_file_and_repair() { + let source = "import { Box } from '@devup-ui/react';"; + + let error = extract_without_source_map("/src/broken.unknown", source, ExtractOption::default()) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + + assert!(error.starts_with("/src/broken.unknown:1:1:"), "{error}"); + assert!(error.contains("Unknown file extension"), "{error}"); + assert!(error.contains("Fix:"), "{error}"); +} + +#[rstest::rstest] +#[case("\n")] +#[case("\r")] +#[case("\r\n")] +#[case("\u{2028}")] +#[case("\u{2029}")] +fn locations_follow_javascript_line_terminators(#[case] separator: &str) { + let source = format!("first{separator}한😀x"); + + let location = crate::locate("/src/unicode.ts", &source, source.len()); + + assert_eq!(location, "/src/unicode.ts:2:4"); +} + +#[rstest::rstest] +#[case("return 1;")] +#[case("const invalid = /[z-a]/;")] +#[case("const repeated = 1; const repeated = 2;")] +#[case("const invalid = 01;")] +#[serial] +fn recoverable_and_semantic_errors_do_not_publish_css(#[case] invalid: &str) { + let source = format!( + "import {{ css }} from '@devup-ui/react';\n{invalid}\nexport const card = css({{ color: 'red' }});" + ); + + let error = extract_without_source_map("/src/syntax.tsx", &source, ExtractOption::default()) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + + assert!( + error.starts_with("/src/syntax.tsx:2:"), + "{invalid}: {error}" + ); + assert!(error.contains("Cannot parse source:"), "{error}"); + assert!(error.contains("Fix:"), "{error}"); +} + +#[rstest::rstest] +#[case("")] +#[case("")] +#[case("")] +#[case("")] +#[case("
")] +#[serial] +fn empty_jsx_attribute_expressions_are_syntax_errors_not_absent_values(#[case] element: &str) { + let source = format!("import {{ Box }} from '@devup-ui/react';\nexport const App = {element};"); + + let error = extract_without_source_map("/src/empty.tsx", &source, ExtractOption::default()) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + + assert!(error.starts_with("/src/empty.tsx:2:"), "{error}"); + assert!(error.contains("Cannot parse source:"), "{error}"); + assert!(error.contains("non-empty 'expression'"), "{error}"); +} diff --git a/libs/extractor/src/evaluation_sandbox.rs b/libs/extractor/src/evaluation_sandbox.rs new file mode 100644 index 00000000..bcd16a3d --- /dev/null +++ b/libs/extractor/src/evaluation_sandbox.rs @@ -0,0 +1,286 @@ +//! The deterministic Boa sandbox every build-time evaluation runs in. +//! +//! What the build computes must be the same on every build and page, so what +//! differs between them (the clock, chance, the page or process running the +//! code, the locale) cannot be read. The host records each such read before +//! it throws, so code catching the error cannot hide it: a run that recorded +//! one fails whatever its code did after, and the engine's call frames of the +//! read are kept for the error that names where it happened. +//! +//! The sandbox is set up by native code and by one small source of its own, +//! [`INTERNAL_SOURCE`], so no frame of the code that runs is shifted by it. + +use std::cell::RefCell; +use std::rc::Rc; + +use boa_engine::{ + Context, JsArgs, JsError, JsNativeError, JsObject, JsResult, JsString, JsValue, NativeFunction, + Source, js_string, + object::{FunctionObjectBuilder, builtins::JsProxy}, + parser::source::ReadChar, + property::Attribute, +}; + +/// The path of the source setting the sandbox up: its call frames are the +/// sandbox's own, not the code's +pub(crate) const INTERNAL_SOURCE: &str = ""; + +/// Loop iterations an evaluation may run before it fails instead of hanging +/// the build +const LOOP_ITERATION_LIMIT: u64 = 10_000_000; + +/// What a read gives that only the build, the page or the engine decides +#[derive(Clone, Copy)] +enum Kind { + Clock, + Random, + Environment, + Locale, + Source, + Css, +} + +impl Kind { + const fn requirement(self) -> &'static str { + match self { + Self::Clock => "the time it gives differs on every build", + Self::Random => "the chance it gives differs on every build", + Self::Environment => "only the page or process running the code knows it", + Self::Locale => "its result depends on the locale and the engine's Unicode data", + Self::Source => "its text changes when the build transforms the function", + Self::Css => { + "CSS module class names are generated by the bundler and are unknown at build time" + } + } + } + + /// Of what `name` is: a global or `owner.member`, or `Type.prototype.method` + fn of(name: &str) -> Self { + match name.split('.').next().unwrap_or_default() { + "Date" | "performance" => Self::Clock, + "crypto" | "Math" => Self::Random, + "Intl" | "Temporal" => Self::Locale, + "Function" => Self::Source, + _ if name.contains(".prototype.") => Self::Locale, + _ => Self::Environment, + } + } +} + +#[path = "evaluation_sandbox_guards.rs"] +mod guards; +use guards::{SETUP, guard_methods}; + +#[path = "evaluation_sandbox_reflection.rs"] +mod reflection; +use reflection::guard_globals; + +#[path = "evaluation_sandbox_sites.rs"] +mod sites; +pub(crate) use sites::{Instrumented, instrument}; + +#[path = "evaluation_sandbox_css.rs"] +mod css; +pub(crate) use css::css_object; + +#[path = "evaluation_sandbox_evidence.rs"] +mod evidence; + +struct Recorded { + name: String, + css: Option, + error: JsValue, + site: Option<(String, u32)>, +} + +/// What the host holds of the forbidden reads a run made, out of the code's +/// reach +#[derive(Clone, Default)] +struct Evidence { + reads: Rc>>, + site: Rc>>, + methods: Rc>>, +} + +/// A forbidden read the code made +pub(crate) struct Violation { + name: String, + kind: Kind, + error: JsError, + site: Option<(String, u32)>, + css_message: Option, +} + +impl Violation { + /// Immutable CSS cause and frames, without Boa's native display suffix. + pub(crate) fn css_message(&self) -> Option<&str> { + self.css_message.as_deref() + } + + /// What was read, as the code writes it (`Date`, `String.prototype.normalize`) + pub(crate) fn name(&self) -> &str { + &self.name + } + + /// Why the read cannot be known at build time + pub(crate) const fn requirement(&self) -> &'static str { + self.kind.requirement() + } + + /// The error with an instrumented read frame, or the engine's actual call frames. + pub(crate) const fn error(&self) -> &JsError { + &self.error + } + + /// The original offset of an instrumented read and the source containing it. + pub(crate) fn site(&self) -> Option<(&str, usize)> { + self.site + .as_ref() + .map(|(place, offset)| (place.as_str(), *offset as usize)) + } +} + +/// Why a run failed +pub(crate) enum Failure { + /// The code read what the build cannot know, however it was written: + /// every read it made is listed + Forbidden(Vec), + /// The code failed for another reason + Js(JsError), +} + +/// The deterministic environment of one [`Context`], and the evidence of the +/// reads it forbade +pub(crate) struct Sandbox { + evidence: Evidence, +} + +fn record(context: &mut Context, name: &str, site: Option<(String, u32)>) -> JsValue { + let frame = site.as_ref().map_or_else(String::new, |(place, _)| { + format!("\n at ({place})") + }); + let error = JsNativeError::reference() + .with_message(format!( + "`{name}` cannot be read at build time: {}{frame}", + Kind::of(name).requirement() + )) + .into_opaque(context); + let error = JsValue::from(error); + if let Some(evidence) = context.get_data::() { + evidence.reads.borrow_mut().push(Recorded { + name: name.to_string(), + css: None, + error: error.clone(), + site, + }); + } + error +} + +/// The recorder called by the guards written in [`SETUP`]. +fn forbid_native(_: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult { + let name = args.get_or_undefined(0).to_string(context)?; + let site = context + .get_data::() + .and_then(|evidence| evidence.site.borrow().clone()); + Ok(record(context, &name.to_std_string_escaped(), site)) +} + +/// A function the realm owns +fn function(context: &Context, name: &str, native: NativeFunction) -> JsObject { + let realm = context.realm().clone(); + FunctionObjectBuilder::new(&realm, native) + .name(JsString::from(name)) + .build() + .into() +} + +/// A console whose calls do nothing, as what a module logs changes no value +fn install_console(context: &mut Context) -> JsResult<()> { + let target = JsObject::with_null_proto(); + let console = JsProxy::builder(target) + .get(|_, _, context| { + let call = NativeFunction::from_copy_closure(|_, _, _| Ok(JsValue::undefined())); + Ok(function(context, "", call).into()) + }) + .build(context)?; + context.register_global_property( + js_string!("console"), + console, + Attribute::WRITABLE | Attribute::CONFIGURABLE, + ) +} + +#[cfg(test)] +#[path = "evaluation_sandbox_tests.rs"] +mod tests; + +#[cfg(test)] +#[path = "evaluation_sandbox_reflection_tests.rs"] +mod reflection_tests; + +impl Sandbox { + /// Installs the instrumented source's native identity/thunk helper. + pub(crate) fn prepare(&self, context: &mut Context, source: &Instrumented) -> JsResult<()> { + // A new script has no read site until its instrumentation establishes one. + *self.evidence.site.borrow_mut() = None; + context.register_global_builtin_callable( + JsString::from(source.helper.as_str()), + 3, + NativeFunction::from_copy_closure_with_captures( + |_, args, sites, context| { + let index = args.get_or_undefined(0).to_u32(context)? as usize; + if let Some((place, offset)) = sites.get(index) + && let Some(evidence) = context.get_data::() + { + *evidence.site.borrow_mut() = Some((place.clone(), *offset)); + } + let value = args.get_or_undefined(1); + if args.get_or_undefined(2).to_boolean() { + value + .to_object(context)? + .call(&JsValue::undefined(), &[], context) + } else { + Ok(value.clone()) + } + }, + source.sites.clone(), + ), + ) + } + + /// Makes `context` deterministic: the clock, chance, the environment and + /// the locale cannot be read, an unbounded loop fails instead of hanging + /// the build, and `console` calls do nothing + pub(crate) fn new(context: &mut Context) -> JsResult { + context + .runtime_limits_mut() + .set_loop_iteration_limit(LOOP_ITERATION_LIMIT); + let evidence = Evidence::default(); + context.insert_data(evidence.clone()); + install_console(context)?; + guard_methods(context)?; + guard_globals(context)?; + Ok(Self { evidence }) + } + + /// Runs `source`, failing if it or anything run before it read what the + /// build cannot know, whether or not its code caught the error. A source + /// with a path names its call frames by it. + pub(crate) fn run_source( + &self, + context: &mut Context, + source: Source<'_, R>, + ) -> Result { + match context.eval(source) { + Ok(value) => self.check(&[]).map(|()| value), + Err(error) => { + self.check(&[&error])?; + Err(Failure::Js(error)) + } + } + } +} + +#[cfg(test)] +mod coverage_tests; diff --git a/libs/extractor/src/evaluation_sandbox/coverage_tests.rs b/libs/extractor/src/evaluation_sandbox/coverage_tests.rs new file mode 100644 index 00000000..e3f9cda2 --- /dev/null +++ b/libs/extractor/src/evaluation_sandbox/coverage_tests.rs @@ -0,0 +1,148 @@ +use boa_engine::{Context, JsValue, Source}; +use rstest::rstest; + +use super::{Failure, Sandbox, instrument}; + +fn evaluate(script: &str) -> Result { + let mut context = Context::default(); + let sandbox = Sandbox::new(&mut context).map_err(|error| error.to_string())?; + let source = instrument(script, "boundary.js"); + sandbox + .prepare(&mut context, &source) + .map_err(|error| error.to_string())?; + match sandbox.run_source(&mut context, Source::from_bytes(&source.code)) { + Ok(value) => value + .to_string(&mut context) + .map(|text| text.to_std_string_escaped()) + .map_err(|error| error.to_string()), + Err(Failure::Js(error)) => Err(error.to_string()), + Err(Failure::Forbidden(reads)) => Err(format!( + "forbidden: {}", + reads + .iter() + .map(super::Violation::name) + .collect::>() + .join(",") + )), + } +} + +#[rstest] +#[case("Math.random = () => 0.25; Math.random()", "0.25")] +#[case( + "String.prototype.normalize = function() { return this + '!'; }; 'a'.normalize()", + "a!" +)] +#[case( + "const o = { x: 4 }; Object.getOwnPropertyDescriptor(o, 'x').value", + "4" +)] +#[case("Object.getOwnPropertyDescriptor({}, 'missing')", "undefined")] +#[case( + "const o = { get x() { return 5 } }; Object.getOwnPropertyDescriptor(o, 'x').get.call(o)", + "5" +)] +#[case("const key = 'x'; const { [key]: value } = { x: 6 }; value", "6")] +#[case("const { ['x']: value } = { x: 8 }; value", "8")] +#[case( + "const o = { n: 2, add(v) { this.n += v; return this.n } }; o?.add?.(3)", + "5" +)] +#[case("let n = 1; eval('n += 2'); n", "3")] +fn semantics_survive_instrumentation_when_values_are_local( + #[case] script: &str, + #[case] expected: &str, +) -> Result<(), String> { + // Given / When + let result = evaluate(script)?; + // Then + assert_eq!(result, expected); + Ok(()) +} + +#[rstest] +#[case("({ window })", "window")] +#[case("Object.getOwnPropertyDescriptor(Math, 'random')", "Math.random")] +#[case( + "Object.getOwnPropertyDescriptor(String.prototype, 'normalize')", + "String.prototype.normalize" +)] +fn reflection_and_shorthand_fail_when_they_reach_real_guards( + #[case] script: &str, + #[case] name: &str, +) -> Result<(), String> { + // Given / When + let error = evaluate(script) + .err() + .ok_or("guard did not reject the read")?; + // Then + assert_eq!(error, format!("forbidden: {name}")); + Ok(()) +} + +#[rstest] +#[case("Date.now()", true)] +#[case("try { Date.now() } catch {} 4", false)] +fn uninstrumented_reads_keep_evidence_when_errors_are_thrown_or_caught( + #[case] script: &str, + #[case] thrown: bool, +) -> Result<(), String> { + // Given + let mut context = Context::default(); + let sandbox = Sandbox::new(&mut context).map_err(|error| error.to_string())?; + // When + let result = sandbox.run_source(&mut context, Source::from_bytes(script)); + let Err(Failure::Forbidden(reads)) = result else { + return Err("missing forbidden read".to_string()); + }; + // Then + assert_eq!(reads.len(), 1); + assert_eq!(reads[0].name(), "Date"); + assert_eq!(reads[0].site(), None); + assert!(reads[0].error().as_opaque().is_some()); + if thrown { + assert!(reads[0].error().to_string().contains("
")); + } + Ok(()) +} + +#[test] +fn prepare_helper_returns_identity_when_an_index_has_no_read_site() -> Result<(), String> { + // Given + let mut context = Context::default(); + let sandbox = Sandbox::new(&mut context).map_err(|error| error.to_string())?; + let source = instrument("1", "boundary.js"); + sandbox + .prepare(&mut context, &source) + .map_err(|error| error.to_string())?; + // When + let value = context + .eval(Source::from_bytes(&format!("{}(999, 7)", source.helper))) + .map_err(|error| error.to_string())?; + // Then + assert!(value.strict_equals(&JsValue::from(7))); + assert!(sandbox.check(&[]).is_ok()); + Ok(()) +} + +#[test] +fn ordinary_javascript_failures_remain_errors_instead_of_becoming_forbidden_reads() +-> Result<(), String> { + // Given / When + let error = evaluate("throw new TypeError('local failure')") + .err() + .ok_or("throw succeeded")?; + // Then + assert!(error.starts_with("TypeError: local failure")); + Ok(()) +} + +#[rstest] +#[case("TypeError: bad\n at eval (boundary.js:unknown)")] +#[case("TypeError: bad\n at eval (boundary.js:x:y)")] +fn frame_rebasing_preserves_foreign_coordinates_when_eval_has_no_numeric_site(#[case] error: &str) { + // Given + let source = instrument("eval('throw new Error()')", "boundary.js"); + // When / Then + assert_eq!(source.explain(error), error); +} diff --git a/libs/extractor/src/evaluation_sandbox_css.rs b/libs/extractor/src/evaluation_sandbox_css.rs new file mode 100644 index 00000000..3a83dace --- /dev/null +++ b/libs/extractor/src/evaluation_sandbox_css.rs @@ -0,0 +1,154 @@ +//! Native observations of bundler-owned CSS objects; no export value is fabricated. + +use boa_engine::{ + Context, JsArgs, JsError, JsNativeError, JsObject, JsResult, JsString, JsValue, NativeFunction, +}; + +use super::{Evidence, Kind, Recorded}; + +pub(super) struct Observation { + pub(super) file: String, + pub(super) error: JsError, + pub(super) message: String, +} + +#[derive(Clone, Copy)] +enum Operation { + Get, + Has, + Keys, + Descriptor, + Prototype, +} + +impl Operation { + fn key(self, args: &[JsValue], context: &mut Context) -> JsResult { + let prefix = match self { + Self::Get => "", + Self::Has => "has:", + Self::Descriptor => "getOwnPropertyDescriptor:", + Self::Keys => return Ok("ownKeys".to_string()), + Self::Prototype => return Ok("getPrototypeOf".to_string()), + }; + let value = args.get_or_undefined(1); + let key = match value.as_symbol() { + Some(symbol) => symbol.descriptive_string().to_std_string_escaped(), + None => value.to_string(context)?.to_std_string_escaped(), + }; + Ok(format!("{prefix}{key}")) + } +} + +struct Request<'a> { + file: &'a str, + args: &'a [JsValue], + operation: Operation, +} + +pub(super) fn message(file: &str, key: &str) -> String { + format!( + "Cannot read CSS export '{key}' of '{file}' at build time: {}", + Kind::Css.requirement() + ) +} + +fn observe(request: Request<'_>, context: &mut Context) -> JsResult { + let Some(evidence) = context.get_data::().cloned() else { + return Err(JsNativeError::error() + .with_message("CSS observation requires a sandbox") + .into()); + }; + let file = request.file; + let name = request.operation.key(request.args, context)?; + let site = evidence.site.borrow().clone(); + let frame = site.as_ref().map_or_else(String::new, |(place, _)| { + format!("\n at ({place})") + }); + let frames = context + .stack_trace() + .enumerate() + .filter_map(|(index, frame)| { + let location = frame.position(); + if index == 0 + && let Some((place, _)) = &site + { + return Some(format!( + "\n at {} ({place})", + location.function_name.to_std_string_escaped() + )); + } + let position = location.position?; + Some(format!( + "\n at {} ({}:{}:{})", + location.function_name.to_std_string_escaped(), + location.path, + position.line_number(), + position.column_number() + )) + }) + .collect::(); + let immutable: JsError = JsNativeError::reference() + .with_message(format!("{}{frame}{frames}", message(file, &name))) + .into(); + let message = format!("ReferenceError: {}{frame}{frames}", message(file, &name)); + let error = immutable.clone().into_opaque(context)?; + let mut reads = evidence.reads.borrow_mut(); + if !reads + .iter() + .any(|read| read.css.as_ref().is_some_and(|css| css.file == file) && read.site == site) + { + reads.push(Recorded { + name, + css: Some(Observation { + file: file.to_string(), + error: immutable, + message, + }), + error: error.clone(), + site, + }); + } + Err(JsError::from_opaque(error)) +} + +/// Creates an opaque object whose actual runtime traps record unknown observations. +pub(crate) fn css_object(context: &mut Context, file: &str) -> JsResult { + let target = JsObject::with_null_proto(); + let handler = JsObject::with_null_proto(); + for (name, operation) in [ + ("get", Operation::Get), + ("has", Operation::Has), + ("ownKeys", Operation::Keys), + ("getOwnPropertyDescriptor", Operation::Descriptor), + ("getPrototypeOf", Operation::Prototype), + ] { + let native = NativeFunction::from_copy_closure_with_captures( + move |_, args, file, context| { + observe( + Request { + file, + args, + operation, + }, + context, + ) + }, + file.to_string(), + ); + handler.create_data_property_or_throw( + JsString::from(name), + super::function(context, name, native), + context, + )?; + } + context + .intrinsics() + .constructors() + .proxy() + .constructor() + .construct(&[target.into(), handler.into()], None, context) +} + +#[cfg(test)] +#[path = "evaluation_sandbox_css_tests.rs"] +mod tests; diff --git a/libs/extractor/src/evaluation_sandbox_css_tests.rs b/libs/extractor/src/evaluation_sandbox_css_tests.rs new file mode 100644 index 00000000..8dc7afa6 --- /dev/null +++ b/libs/extractor/src/evaluation_sandbox_css_tests.rs @@ -0,0 +1,91 @@ +use boa_engine::{Context, Source, js_string}; + +use super::super::{Failure, Sandbox}; + +#[test] +fn css_observation_remains_fatal_when_host_serializes_after_evaluation() -> Result<(), String> { + // Given + let mut context = Context::default(); + let sandbox = Sandbox::new(&mut context).map_err(|error| error.to_string())?; + let object = super::css_object(&mut context, "/src/styles.module.css") + .map_err(|error| error.to_string())?; + let error = object + .get(js_string!("card"), &mut context) + .err() + .ok_or("no CSS value is invented")?; + // When + let Err(Failure::Forbidden(reads)) = sandbox.check(&[&error]) else { + return Err("host read must be a fatal CSS observation".to_string()); + }; + // Then + assert_eq!(reads.len(), 1); + assert!( + reads[0] + .css_message() + .is_some_and(|message| message.contains("export 'card' of '/src/styles.module.css'")) + ); + assert_eq!(reads[0].site(), None); + Ok(()) +} + +#[test] +fn css_observation_reports_context_boundary_when_proxy_is_used_without_sandbox() +-> Result<(), String> { + // Given + let mut context = Context::default(); + let object = super::css_object(&mut context, "/src/styles.module.css") + .map_err(|error| error.to_string())?; + // When + let error = object + .get(js_string!("card"), &mut context) + .err() + .ok_or("a host observer needs its sandbox")?; + // Then + assert!( + error + .to_string() + .contains("CSS observation requires a sandbox") + ); + Ok(()) +} + +#[test] +fn css_observation_is_immutable_when_caught_error_prototype_is_replaced() -> Result<(), String> { + // Given + let mut context = Context::default(); + let sandbox = Sandbox::new(&mut context).map_err(|error| error.to_string())?; + let object = super::css_object(&mut context, "/src/styles.module.css") + .map_err(|error| error.to_string())?; + context + .register_global_property( + js_string!("styles"), + object, + boa_engine::property::Attribute::empty(), + ) + .map_err(|error| error.to_string())?; + let source = super::super::instrument( + "try{styles.card}catch(error){error.message='mutated';ReferenceError.prototype.name='Changed';}try{styles.other}catch{}", + "fixture.js", + ); + sandbox + .prepare(&mut context, &source) + .map_err(|error| error.to_string())?; + // When + let Err(Failure::Forbidden(reads)) = + sandbox.run_source(&mut context, Source::from_bytes(&source.code)) + else { + return Err("caught errors cannot erase host evidence".to_string()); + }; + // Then + assert_eq!(reads.len(), 2); + assert!(reads[0].css_message().is_some_and(|message| { + message.starts_with("ReferenceError: Cannot read CSS export 'card'") + })); + assert!( + !reads[0] + .css_message() + .unwrap_or_default() + .contains("mutated") + ); + Ok(()) +} diff --git a/libs/extractor/src/evaluation_sandbox_evidence.rs b/libs/extractor/src/evaluation_sandbox_evidence.rs new file mode 100644 index 00000000..3ab6c423 --- /dev/null +++ b/libs/extractor/src/evaluation_sandbox_evidence.rs @@ -0,0 +1,54 @@ +//! Converts host-held observations into failures without trusting caught CSS errors. + +use boa_engine::{JsError, JsNativeError}; + +use super::{Failure, Kind, Sandbox, Violation}; + +impl Sandbox { + /// Fails if a forbidden read was recorded. `errors` are the errors the host + /// got back from code it ran: one a read threw gives that read its call + /// frames when the code did not rethrow it first, so a host that serializes + /// the values the code made passes the errors that failed it here. + pub(crate) fn check(&self, errors: &[&JsError]) -> Result<(), Failure> { + let recorded = self.evidence.reads.borrow(); + if recorded.is_empty() { + return Ok(()); + } + Err(Failure::Forbidden( + recorded + .iter() + .map(|read| Violation { + kind: if read.css.is_some() { + Kind::Css + } else { + Kind::of(&read.name) + }, + name: read.name.clone(), + css_message: read.css.as_ref().map(|css| css.message.clone()), + site: read.site.clone(), + error: match (&read.css, &read.site) { + (Some(css), _) => css.error.clone(), + (None, Some((place, _))) => JsNativeError::reference() + .with_message(format!( + "`{}` cannot be read at build time: {}\n at ({place})", + read.name, + Kind::of(&read.name).requirement() + )) + .into(), + (None, None) => errors + .iter() + .find(|error| { + error + .as_opaque() + .is_some_and(|thrown| thrown.strict_equals(&read.error)) + }) + .map_or_else( + || JsError::from_opaque(read.error.clone()), + |error| (*error).clone(), + ), + }, + }) + .collect(), + )) + } +} diff --git a/libs/extractor/src/evaluation_sandbox_guards.rs b/libs/extractor/src/evaluation_sandbox_guards.rs new file mode 100644 index 00000000..3b112dc0 --- /dev/null +++ b/libs/extractor/src/evaluation_sandbox_guards.rs @@ -0,0 +1,253 @@ +//! What the sandbox forbids reading, and the source that guards the globals. + +use super::reflection::{check_accessor, check_descriptor}; +use super::{Evidence, function, record}; + +pub(super) const EXACT_GLOBALS: &[&str] = &[ + "undefined", + "NaN", + "Infinity", + "globalThis", + "Math", + "Object", + "Array", + "String", + "Number", + "Boolean", + "BigInt", + "Symbol", + "JSON", + "Reflect", + "Proxy", + "Promise", + "Map", + "Set", + "WeakMap", + "WeakSet", + "Error", + "TypeError", + "ReferenceError", + "SyntaxError", + "RangeError", + "URIError", + "EvalError", + "Uint8Array", + "Uint8ClampedArray", + "Uint16Array", + "Uint32Array", + "Int8Array", + "Int16Array", + "Int32Array", + "Float32Array", + "Float64Array", + "BigInt64Array", + "BigUint64Array", + "ArrayBuffer", + "DataView", + "parseInt", + "parseFloat", + "isNaN", + "isFinite", + "encodeURI", + "decodeURI", + "encodeURIComponent", + "decodeURIComponent", + "eval", + "console", +]; +use boa_engine::{ + Context, JsArgs, JsError, JsObject, JsResult, JsString, JsValue, NativeFunction, + object::FunctionObjectBuilder, property::PropertyDescriptor, +}; + +fn guard_method(context: &mut Context, holder: &JsObject, label: (&str, &str)) -> JsResult<()> { + let (owner, method) = label; + let guard = function( + context, + method, + NativeFunction::from_copy_closure_with_captures( + |_, _, name: &String, context| { + let site = context + .get_data::() + .and_then(|evidence| evidence.site.borrow().clone()); + Err(JsError::from_opaque(record(context, name, site))) + }, + format!("{owner}.{method}"), + ), + ); + let setter = function( + context, + "set", + NativeFunction::from_copy_closure_with_captures( + |this, args, method: &String, context| { + this.to_object(context)?.define_property_or_throw( + JsString::from(method.as_str()), + PropertyDescriptor::builder() + .value(args.get_or_undefined(0).clone()) + .writable(true) + .enumerable(false) + .configurable(true), + context, + )?; + Ok(JsValue::undefined()) + }, + method.to_string(), + ), + ); + holder.define_property_or_throw( + JsString::from(method), + PropertyDescriptor::builder() + .get(guard.clone()) + .set(setter.clone()) + .enumerable(false) + .configurable(true), + context, + )?; + if let Some(evidence) = context.get_data::() { + evidence + .methods + .borrow_mut() + .push((guard, format!("{owner}.{method}"))); + evidence + .methods + .borrow_mut() + .push((setter, format!("{owner}.{method}"))); + } + Ok(()) +} + +pub(super) fn guard_methods(context: &mut Context) -> JsResult<()> { + let math = context.intrinsics().objects().math(); + guard_method(context, &math, ("Math", "random"))?; + for (owner, methods) in GUARDED_METHODS { + let constructors = context.intrinsics().constructors(); + let holder = match owner { + "Object" => constructors.object(), + "Number" => constructors.number(), + "BigInt" => constructors.bigint(), + "Array" => constructors.array(), + "TypedArray" => constructors.typed_array(), + "Function" => constructors.function(), + _ => constructors.string(), + } + .prototype(); + for method in methods { + guard_method(context, &holder, (&format!("{owner}.prototype"), method))?; + } + } + guard_descriptor(context) +} + +fn guard_descriptor(context: &mut Context) -> JsResult<()> { + let object = context.intrinsics().constructors().object().constructor(); + let prototype = context.intrinsics().constructors().object().prototype(); + let reflect = context.intrinsics().objects().reflect(); + for (holder, name, batch) in [ + (&object, "getOwnPropertyDescriptor", Some(false)), + (&object, "getOwnPropertyDescriptors", Some(true)), + (&reflect, "getOwnPropertyDescriptor", Some(false)), + (&prototype, "__lookupGetter__", None), + (&prototype, "__lookupSetter__", None), + ] { + let original = holder + .get(JsString::from(name), context)? + .to_object(context)?; + let realm = context.realm().clone(); + let wrapper = FunctionObjectBuilder::new( + &realm, + NativeFunction::from_copy_closure_with_captures( + |this, args, captures: &(JsObject, Option), context| { + let site = context + .get_data::() + .and_then(|evidence| evidence.site.borrow().clone()); + let value = captures.0.call(this, args, context)?; + match captures.1 { + Some(true) => { + let descriptors = value.to_object(context)?; + for key in descriptors.own_property_keys(context)? { + check_descriptor( + &descriptors.get(key, context)?, + site.clone(), + context, + )?; + } + } + Some(false) => { + check_descriptor(&value, site, context)?; + } + None => check_accessor(&value, site, context)?, + } + Ok(value) + }, + (original, batch), + ), + ) + .name(JsString::from(name)) + .length(if batch == Some(false) { 2 } else { 1 }) + .build(); + holder.define_property_or_throw( + JsString::from(name), + PropertyDescriptor::builder() + .value(wrapper) + .writable(true) + .enumerable(false) + .configurable(true), + context, + )?; + } + Ok(()) +} + +/// Globals the engine gives whose values depend on the locale or the clock; +/// any other global nothing declares is the environment's +pub(super) const GUARDED_GLOBALS: [&str; 5] = ["Date", "Intl", "performance", "crypto", "Temporal"]; + +/// Methods whose results depend on locale, Unicode data, or transformed source. +pub(super) const GUARDED_METHODS: [(&str, &[&str]); 7] = [ + ("Object", &["toLocaleString"]), + ("Number", &["toLocaleString"]), + ("BigInt", &["toLocaleString"]), + ("Array", &["toLocaleString"]), + ("TypedArray", &["toLocaleString"]), + ("Function", &["toString"]), + ( + "String", + &[ + "localeCompare", + "toLocaleUpperCase", + "toLocaleLowerCase", + "normalize", + ], + ), +]; + +/// Guards the globals with `forbid`, which records a read and gives the error +/// that fails it. Assigning a guarded global gives the code a value of its +/// own. Globals nothing declares are guarded alike, through the prototype of +/// the global object. +/// Keep function bodies and descriptors out of nested call arguments: Boa's +/// debug parser otherwise exhausts the default Windows main-thread stack. +pub(super) const SETUP: &str = r#"(function (forbid, config) { + const global = globalThis; + function assign(object, name, value) { + const descriptor = { value, writable: true, enumerable: true, configurable: true }; + Object.defineProperty(object, name, descriptor); + } + function read(object, name, label) { + const get = function () { throw forbid(label); }; + const set = function (value) { assign(object, name, value); }; + const descriptor = { get, set, enumerable: false, configurable: true }; + Object.defineProperty(object, name, descriptor); + } + for (const name of config.names) read(global, name, name); + function get(target, key, receiver) { + if (typeof key === "string" && !(key in target)) throw forbid(key); + return Reflect.get(target, key, receiver); + } + function has(target, key) { + return (typeof key === "string" && !(key in target)) || Reflect.has(target, key); + } + const handler = { get, has }; + const prototype = new Proxy(Object.getPrototypeOf(global), handler); + Object.setPrototypeOf(global, prototype); +})"#; diff --git a/libs/extractor/src/evaluation_sandbox_reflection.rs b/libs/extractor/src/evaluation_sandbox_reflection.rs new file mode 100644 index 00000000..607afe16 --- /dev/null +++ b/libs/extractor/src/evaluation_sandbox_reflection.rs @@ -0,0 +1,267 @@ +//! Identity-based protection of synthetic accessors exposed by reflection. + +use super::{ + Evidence, INTERNAL_SOURCE, SETUP, forbid_native, function, guards::GUARDED_GLOBALS, record, +}; +use boa_engine::{ + Context, JsError, JsResult, JsString, JsValue, NativeFunction, Source, js_string, +}; + +pub(super) fn guard_globals(context: &mut Context) -> JsResult<()> { + let setup = context.eval(Source::from_reader( + SETUP.as_bytes(), + Some(std::path::Path::new(INTERNAL_SOURCE)), + ))?; + let forbid = function( + context, + "forbid", + NativeFunction::from_fn_ptr(forbid_native), + ); + let config = JsValue::from_json(&serde_json::json!({ "names": GUARDED_GLOBALS }), context)?; + setup + .to_object(context)? + .call(&JsValue::undefined(), &[forbid.into(), config], context)?; + for name in GUARDED_GLOBALS { + let descriptor = boa_engine::builtins::object::OrdinaryObject::get_own_property_descriptor( + &JsValue::undefined(), + &[context.global_object().into(), JsString::from(name).into()], + context, + )? + .to_object(context)?; + for key in [js_string!("get"), js_string!("set")] { + let accessor = descriptor.get(key, context)?.to_object(context)?; + if let Some(evidence) = context.get_data::() { + evidence + .methods + .borrow_mut() + .push((accessor, name.to_string())); + } + } + } + Ok(()) +} + +pub(super) fn check_descriptor( + value: &JsValue, + site: Option<(String, u32)>, + context: &mut Context, +) -> JsResult<()> { + if let Some(descriptor) = value.as_object() { + let getter = descriptor + .borrow() + .properties() + .get(&js_string!("get").into()) + .and_then(|property| property.value().cloned()); + if let Some(getter) = getter { + check_accessor(&getter, site, context)?; + } + } + Ok(()) +} + +pub(super) fn check_accessor( + value: &JsValue, + site: Option<(String, u32)>, + context: &mut Context, +) -> JsResult<()> { + let name = context.get_data::().and_then(|evidence| { + evidence.methods.borrow().iter().find_map(|(method, name)| { + value + .as_object() + .filter(|accessor| accessor == method) + .map(|_| name.clone()) + }) + }); + match name { + Some(name) => Err(JsError::from_opaque(record(context, &name, site))), + None => Ok(()), + } +} + +#[cfg(test)] +mod tests { + use super::super::{Failure, reflection_tests::run}; + use rstest::rstest; + + #[rstest] + #[case("(()=>{}).__lookupGetter__('toString')", "Function.prototype.toString")] + #[case("Math.__lookupGetter__('random')", "Math.random")] + #[case("Math.__lookupSetter__('random')", "Math.random")] + #[case( + "const read = Object.prototype.__lookupGetter__;\nread.call(Math, 'random')", + "Math.random" + )] + #[case( + "const read = Object.prototype.__lookupSetter__.bind(Math);\nread('random')", + "Math.random" + )] + #[case( + "const key = 'random';\nMath['__lookup' + 'Getter__'](key)", + "Math.random" + )] + #[case( + "const key = { toString() { Math.max(1, 2); return 'random' } };\nMath.__lookupSetter__(key)", + "Math.random" + )] + #[case( + "const child = Object.create(Math);\nchild.__lookupGetter__('random')", + "Math.random" + )] + fn legacy_protected_accessors_fail_at_actual_call( + #[case] script: &str, + #[case] name: &str, + ) -> Result<(), String> { + // Given + let offset = script.rfind('\n').map_or(0, |at| at + 1); + // When + let Err(Failure::Forbidden(reads)) = run(script) else { + return Err("legacy reflection escaped".into()); + }; + // Then + assert_eq!(reads.len(), 1); + assert_eq!(reads[0].name(), name); + assert_eq!(reads[0].site().map(|(_, at)| at), Some(offset)); + Ok(()) + } + + #[rstest] + fn legacy_global_accessors_record_before_throw_even_when_errors_are_mutated( + #[values("__lookupGetter__", "__lookupSetter__")] reader: &str, + #[values("Date", "Intl", "performance", "crypto", "Temporal")] name: &str, + ) -> Result<(), String> { + // Given + let script = format!( + "try {{ globalThis.{reader}('{name}') }} catch (error) {{ error.message = 'changed'; error.name = 'changed'; }}\nglobalThis.{name} = 7; 'accepted'" + ); + // When + let Err(Failure::Forbidden(reads)) = run(&script) else { + return Err("caught legacy reflection escaped".into()); + }; + // Then + assert_eq!(reads.len(), 1); + assert_eq!(reads[0].name(), name); + assert_eq!(reads[0].site().map(|(_, at)| at), Some(6)); + assert!(reads[0].error().to_string().contains(&format!("`{name}`"))); + assert_eq!( + reads[0].requirement(), + super::super::Kind::of(name).requirement() + ); + Ok(()) + } + + #[rstest] + #[case( + "Math.random = () => 7; String(Math.__lookupGetter__('random'))", + "undefined" + )] + #[case( + "Math.random = () => 7; String(Math.__lookupSetter__('random'))", + "undefined" + )] + #[case( + "Function.prototype.toString = () => 'own'; String((()=>{}).__lookupGetter__('toString'))", + "undefined" + )] + #[case( + "globalThis.Date = 7; String(globalThis.__lookupSetter__('Date'))", + "undefined" + )] + #[case( + "const o = { n: 3, get random() { return this.n }, set random(v) { this.n = v } }; const child = Object.create(o); child.__lookupSetter__('random').call(child, 8); String(child.__lookupGetter__('random').call(child))", + "8" + )] + #[case( + "Object.defineProperty(Math, 'random', { get() { return 4 }, set(v) {} }); String(Math.__lookupGetter__('random')())", + "4" + )] + #[case( + "Object.defineProperty(globalThis, 'Intl', { set(v) { this.saved = v } }); globalThis.__lookupSetter__('Intl').call(globalThis, 9); String(globalThis.saved)", + "9" + )] + #[case( + "String(Object.prototype.__lookupGetter__.call('a', '0'))", + "undefined" + )] + #[case("String(Object.prototype.__lookupSetter__.call(1, 'x'))", "undefined")] + #[case("String(({}).__lookupGetter__('missing'))", "undefined")] + #[case("String(({ random: 2 }).__lookupSetter__('random'))", "undefined")] + #[case( + "String(Object.prototype.__lookupGetter__.length) + ':' + Object.prototype.__lookupSetter__.length", + "1:1" + )] + #[case( + "let order = ''; const o = new Proxy({ get x() { return 2 } }, { getOwnPropertyDescriptor(t, k) { order += 'd'; return Reflect.getOwnPropertyDescriptor(t, k) } }); const key = { toString() { order += 'k'; return 'x' } }; const get = Object.prototype.__lookupGetter__.call(o, key); `${order}:${get()}`", + "kd:2" + )] + #[case( + "let order = ''; try { Object.prototype.__lookupGetter__.call(null, { toString() { order += 'k'; return 'x' } }) } catch(e) { order += e instanceof TypeError ? 't' : 'e' } order", + "t" + )] + #[case( + "let order = ''; try { Object.prototype.__lookupSetter__.call(undefined, { toString() { order += 'k'; return 'x' } }) } catch(e) { order += e instanceof TypeError ? 't' : 'e' } order", + "t" + )] + fn legacy_ordinary_accessors_preserve_native_semantics( + #[case] script: &str, + #[case] expected: &str, + ) -> Result<(), String> { + // Given / When + let value = match run(script) { + Ok(value) => value, + Err(Failure::Js(error)) => return Err(error.to_string()), + Err(Failure::Forbidden(_)) => return Err("ordinary legacy accessor forbidden".into()), + }; + // Then + assert_eq!( + value + .as_string() + .ok_or("expected string")? + .to_std_string_escaped(), + expected + ); + Ok(()) + } + + #[rstest] + #[case( + "(()=>{}).__lookupGetter__('toString')", + "Function.prototype.toString", + "()=>{}" + )] + #[case( + "Math.__lookupGetter__('random')", + "Math.random", + "Math.__lookupGetter__" + )] + #[serial_test::serial] + fn legacy_stylesheet_branches_fail_instead_of_emitting_blue_css( + #[case] read: &str, + #[case] name: &str, + #[case] callsite: &str, + ) -> Result<(), String> { + // Given + let code = format!( + "import {{style}} from '@devup-ui/react';\nexport const card=style({{color:{read}?'blue':'red'}});" + ); + let offset = code.find(callsite).ok_or("missing read")?; + // When + let error = crate::extract("legacy.css.ts", &code, crate::ExtractOption::default()) + .err() + .ok_or("legacy reflection silently emitted CSS")? + .to_string(); + // Then + assert!( + error.starts_with(&format!( + "{}:", + crate::locate("legacy.css.ts", &code, offset) + )), + "{error}" + ); + assert!(error.contains(&format!("`{name}`")), "{error}"); + assert!( + error.contains("Fix: use a literal or a CSS variable"), + "{error}" + ); + Ok(()) + } +} diff --git a/libs/extractor/src/evaluation_sandbox_reflection_tests.rs b/libs/extractor/src/evaluation_sandbox_reflection_tests.rs new file mode 100644 index 00000000..0959ca7f --- /dev/null +++ b/libs/extractor/src/evaluation_sandbox_reflection_tests.rs @@ -0,0 +1,320 @@ +use std::path::Path; + +use boa_engine::{Context, JsValue, Source}; +use rstest::rstest; + +use super::{Failure, Sandbox}; + +pub(super) fn run(script: &str) -> Result { + let mut context = Context::default(); + let sandbox = Sandbox::new(&mut context).map_err(Failure::Js)?; + let source = super::instrument(script, "reflection.js"); + sandbox + .prepare(&mut context, &source) + .map_err(Failure::Js)?; + sandbox.run_source( + &mut context, + Source::from_bytes(&source.code).with_path(Path::new("reflection.js")), + ) +} + +#[rstest] +#[case("Object.getOwnPropertyDescriptor(Math, 'random')", "Math.random")] +#[case("Object.getOwnPropertyDescriptors(Math)", "Math.random")] +#[case("Reflect.getOwnPropertyDescriptor(Math, 'random')", "Math.random")] +#[case( + "const api = Reflect;\napi['getOwnProperty' + 'Descriptor'](Math, 'random')", + "Math.random" +)] +#[case( + "const read = Reflect.getOwnPropertyDescriptor;\nread(Math, 'random')", + "Math.random" +)] +#[case( + "const { getOwnPropertyDescriptor: read } = Reflect;\nread(Math, 'random')", + "Math.random" +)] +#[case( + "const read = Reflect.getOwnPropertyDescriptor.bind(null, Math);\nread('random')", + "Math.random" +)] +#[case( + "const read = Object.getOwnPropertyDescriptors.bind(null);\nread(Math)", + "Math.random" +)] +#[case( + "Object.getOwnPropertyDescriptors(String.prototype)", + "String.prototype.localeCompare" +)] +#[case( + "Reflect.getOwnPropertyDescriptor(String.prototype, 'normalize')", + "String.prototype.normalize" +)] +#[case("Object.getOwnPropertyDescriptors(globalThis)", "Date")] +fn guarded_descriptors_fail_at_the_reflection_call( + #[case] script: &str, + #[case] name: &str, +) -> Result<(), String> { + // Given + let offset = script.rfind('\n').map_or(0, |at| at + 1); + // When + let Err(Failure::Forbidden(violations)) = run(script) else { + return Err("reflection did not record a forbidden read".to_string()); + }; + // Then + assert_eq!(violations.len(), 1); + assert_eq!(violations[0].name(), name); + let place = crate::locate("reflection.js", script, offset); + assert_eq!(violations[0].site(), Some((place.as_str(), offset))); + assert!(violations[0].error().to_string().contains(&place)); + Ok(()) +} + +#[rstest] +fn generated_global_getter_descriptors_are_guarded( + #[values("Object.getOwnPropertyDescriptor", "Reflect.getOwnPropertyDescriptor")] reader: &str, + #[values("Date", "Intl", "performance", "crypto", "Temporal")] name: &str, +) -> Result<(), String> { + // Given + let script = format!("{reader}(globalThis, '{name}')"); + // When + let Err(Failure::Forbidden(violations)) = run(&script) else { + return Err("global guard descriptor escaped".to_string()); + }; + // Then + assert_eq!(violations.len(), 1); + assert_eq!(violations[0].name(), name); + assert_eq!(violations[0].site().map(|(_, offset)| offset), Some(0)); + Ok(()) +} + +#[rstest] +#[case("Object.getOwnPropertyDescriptor(Math, 'random')")] +#[case("Object.getOwnPropertyDescriptors(Math)")] +#[case("Reflect.getOwnPropertyDescriptor(Math, 'random')")] +#[case("Object.getOwnPropertyDescriptor(globalThis, 'Date')")] +#[case("Reflect.getOwnPropertyDescriptor(globalThis, 'Date')")] +fn caught_descriptor_errors_cannot_mutate_the_host_evidence( + #[case] read: &str, +) -> Result<(), String> { + // Given + let script = format!( + "try {{ {read} }} catch (error) {{ error.message = 'changed'; error.name = 'changed'; }}\nMath.random = () => 1; 'accepted'" + ); + // When + let Err(Failure::Forbidden(violations)) = run(&script) else { + return Err("caught reflection was accepted".to_string()); + }; + // Then + assert_eq!(violations.len(), 1); + let name = if read.contains("globalThis") { + "Date" + } else { + "Math.random" + }; + assert_eq!(violations[0].name(), name); + assert_eq!(violations[0].site().map(|(_, offset)| offset), Some(6)); + assert_eq!( + violations[0].requirement(), + super::Kind::of(name).requirement() + ); + assert!( + violations[0] + .error() + .to_string() + .contains(&format!("`{name}`")) + ); + Ok(()) +} + +#[test] +fn key_coercion_cannot_replace_the_reflection_call_site() -> Result<(), String> { + // Given + let script = "const key = { toString() { Math.max(1, 2); return 'random' } };\nReflect.getOwnPropertyDescriptor(Math, key)"; + let offset = script + .find("Reflect.getOwnPropertyDescriptor") + .ok_or("missing read")?; + // When + let Err(Failure::Forbidden(violations)) = run(script) else { + return Err("coerced reflection was accepted".to_string()); + }; + // Then + assert_eq!(violations[0].site().map(|(_, at)| at), Some(offset)); + Ok(()) +} + +#[rstest] +#[case("String(Object.getOwnPropertyDescriptor({ x: 1 }, 'x').value)", "1")] +#[case("String(Reflect.getOwnPropertyDescriptor({ x: 1 }, 'x').value)", "1")] +#[case("String(Object.getOwnPropertyDescriptors({ x: 1 }).x.value)", "1")] +#[case( + "`${Object.getOwnPropertyDescriptor.length}:${Object.getOwnPropertyDescriptors.length}:${Reflect.getOwnPropertyDescriptor.length}`", + "2:1:2" +)] +#[case("String(Object.getOwnPropertyDescriptor({}, 'missing'))", "undefined")] +#[case("String(Reflect.getOwnPropertyDescriptor({}, 'missing'))", "undefined")] +#[case("String(Object.getOwnPropertyDescriptor('a', '0').value)", "a")] +#[case("String(Object.getOwnPropertyDescriptors('a')['0'].value)", "a")] +#[case( + "const o = { get random() { return 4 } }; const d = Reflect.getOwnPropertyDescriptor(o, 'random'); String(d.get.call(o))", + "4" +)] +#[case( + "const o = { get x() { throw new Error('getter executed') } }; String(typeof Object.getOwnPropertyDescriptors(o).x.get)", + "function" +)] +#[case( + "const o = {}; Object.defineProperty(o, 'x', { value: 4, writable: false, enumerable: false, configurable: false }); JSON.stringify(Reflect.getOwnPropertyDescriptor(o, 'x'))", + "{\"value\":4,\"writable\":false,\"enumerable\":false,\"configurable\":false}" +)] +#[case( + "const o = { normalize() { return 5 } }; String(Object.getOwnPropertyDescriptors(o).normalize.value())", + "5" +)] +#[case( + "Math.random = () => 7; String(Object.getOwnPropertyDescriptors(Math).random.value())", + "7" +)] +#[case( + "String.prototype.normalize = () => 'own'; String(Reflect.getOwnPropertyDescriptor(String.prototype, 'normalize').value())", + "own" +)] +#[case( + "globalThis.Date = 9; String(Object.getOwnPropertyDescriptor(globalThis, 'Date').value)", + "9" +)] +#[case( + "globalThis.Date = 9; globalThis.Intl = 8; globalThis.performance = 7; globalThis.crypto = 6; globalThis.Temporal = 5; String(Object.getOwnPropertyDescriptors(globalThis).Date.value)", + "9" +)] +#[case( + "Object.defineProperty(globalThis, 'Intl', { get() { return 8 } }); String(Reflect.getOwnPropertyDescriptor(globalThis, 'Intl').get())", + "8" +)] +#[case( + "String(Reflect.getOwnPropertyDescriptor(Math, 'PI').value === Math.PI)", + "true" +)] +#[case( + "const key = Symbol('x'); const o = { [key]: 6 }; String(Object.getOwnPropertyDescriptors(o)[key].value)", + "6" +)] +#[case( + "Object.defineProperty(Object.prototype, 'get', { get() { throw new Error('inherited get') } }); String(Object.getOwnPropertyDescriptor({ x: 1 }, 'x').value)", + "1" +)] +#[case( + "const read = { getOwnPropertyDescriptors() { return { x: { value: 3 } } } }; String(read.getOwnPropertyDescriptors().x.value)", + "3" +)] +#[case( + "let order = ''; const o = new Proxy({ x: 2 }, { getOwnPropertyDescriptor(target, key) { order += 'd'; return Reflect.getOwnPropertyDescriptor(target, key) } }); const key = { toString() { order += 'k'; return 'x' } }; const d = Reflect.getOwnPropertyDescriptor.call(null, o, key); `${order}:${d.value}`", + "kd:2" +)] +fn user_owned_descriptors_keep_their_semantics( + #[case] script: &str, + #[case] expected: &str, +) -> Result<(), String> { + // Given / When + let value = match run(script) { + Ok(value) => value, + Err(Failure::Js(error)) => return Err(error.to_string()), + Err(Failure::Forbidden(_)) => return Err("ordinary descriptor was forbidden".to_string()), + }; + // Then + assert_eq!( + value + .as_string() + .ok_or("expected a string")? + .to_std_string_escaped(), + expected + ); + Ok(()) +} + +#[test] +fn reflected_getter_reads_stay_forbidden_during_serialization() -> Result<(), String> { + // Given + let mut context = Context::default(); + let sandbox = Sandbox::new(&mut context).map_err(|error| error.to_string())?; + let script = "({ get value() { return Object.getOwnPropertyDescriptors(Math).random.value } })"; + let source = super::instrument(script, "reflection.js"); + sandbox + .prepare(&mut context, &source) + .map_err(|error| error.to_string())?; + let value = sandbox + .run_source(&mut context, Source::from_bytes(&source.code)) + .map_err(|_| "object creation failed")?; + let json = context.intrinsics().objects().json(); + let stringify = json + .get(boa_engine::js_string!("stringify"), &mut context) + .map_err(|error| error.to_string())? + .to_object(&mut context) + .map_err(|error| error.to_string())?; + // When + let error = stringify + .call(&JsValue::undefined(), &[value], &mut context) + .err() + .ok_or("reflection serialized silently")?; + // Then + let Err(Failure::Forbidden(violations)) = sandbox.check(&[&error]) else { + return Err("serialization lost forbidden evidence".to_string()); + }; + assert_eq!(violations[0].name(), "Math.random"); + assert_eq!( + violations[0].site().map(|(_, offset)| offset), + script.find("Object.getOwnPropertyDescriptors") + ); + Ok(()) +} + +#[test] +fn reflect_primitive_target_keeps_its_native_type_error() -> Result<(), String> { + // Given / When + let value = run("try { Reflect.getOwnPropertyDescriptor(1, 'x') } catch (error) { String(error instanceof TypeError) }") + .map_err(|_| "primitive reflection did not preserve its catchable error")?; + // Then + assert_eq!( + value + .as_string() + .ok_or("expected a caught error")? + .to_std_string_escaped(), + "true" + ); + Ok(()) +} + +#[test] +#[serial_test::serial] +fn stylesheet_descriptor_branch_fails_instead_of_emitting_blue_css() -> Result<(), String> { + // Given + let code = "import { style } from '@vanilla-extract/css';\nexport const card = style({ color: Object.getOwnPropertyDescriptors(Math).random.value ? 'red' : 'blue' });"; + let offset = code + .find("Object.getOwnPropertyDescriptors") + .ok_or("missing read")?; + // When + let error = crate::extract( + "reflected-random.css.ts", + code, + crate::ExtractOption { + import_aliases: std::collections::HashMap::from([( + "@vanilla-extract/css".to_string(), + crate::ImportAlias::NamedToNamed, + )]), + ..crate::ExtractOption::default() + }, + ) + .err() + .ok_or("reflection silently emitted CSS")? + .to_string(); + // Then + let place = crate::locate("reflected-random.css.ts", code, offset); + assert!(error.starts_with(&format!("{place}:")), "{error}"); + assert!(error.contains("`Math.random`"), "{error}"); + assert!(error.contains(super::Kind::Random.requirement()), "{error}"); + assert!( + error.contains("Fix: use a literal or a CSS variable"), + "{error}" + ); + Ok(()) +} diff --git a/libs/extractor/src/evaluation_sandbox_sites.rs b/libs/extractor/src/evaluation_sandbox_sites.rs new file mode 100644 index 00000000..1e48e307 --- /dev/null +++ b/libs/extractor/src/evaluation_sandbox_sites.rs @@ -0,0 +1,266 @@ +//! Lexically bound source instrumentation for exact sandbox read sites. + +use std::hash::{Hash, Hasher}; + +use oxc_ast::ast::{Expression, IdentifierReference, ObjectProperty}; +use oxc_ast_visit::{Visit, walk}; +use oxc_semantic::{Scoping, SemanticBuilder}; +use oxc_span::{GetSpan, SourceType, Span}; + +#[path = "evaluation_sandbox_sites_operations.rs"] +mod operations; + +/// Source edits and read coordinates, reusable before module mapper code generation. +pub(crate) struct Instrumented { + pub(crate) code: String, + pub(crate) edits: Vec, + pub(super) sites: Vec<(String, u32)>, + pub(super) helper: String, + source: String, + path: String, +} + +struct Reads<'s> { + scoping: &'s Scoping, + source: &'s str, + helper: &'s str, + changes: Vec<(Span, String)>, + sites: Vec<(String, u32)>, + path: &'s str, +} + +impl Reads<'_> { + fn site(&mut self, span: Span) -> usize { + let id = self.sites.len(); + self.sites.push(( + crate::locate(self.path, self.source, span.start as usize), + span.start, + )); + id + } + + fn external(&self, identifier: &IdentifierReference<'_>) -> bool { + let Some(reference) = identifier.reference_id.get() else { + return false; + }; + let reference = self.scoping.get_reference(reference); + reference.symbol_id().is_none() + && reference.is_read() + && !reference.is_write() + && !super::guards::EXACT_GLOBALS.contains(&identifier.name.as_str()) + } + + fn write(&mut self, span: Span) { + let id = self.site(span); + self.changes.push(( + Span::new(span.start, span.start), + format!("({}({id}, undefined), ", self.helper), + )); + self.changes + .push((Span::new(span.end, span.end), ")".to_string())); + } +} + +impl<'a> Visit<'a> for Reads<'_> { + fn visit_identifier_reference(&mut self, identifier: &IdentifierReference<'a>) { + if self.external(identifier) { + let id = self.site(identifier.span); + self.changes.push(( + identifier.span, + format!("({}({id}, () => {}, true))", self.helper, identifier.name), + )); + } + } + + fn visit_object_property(&mut self, property: &ObjectProperty<'a>) { + if property.shorthand + && let Expression::Identifier(identifier) = &property.value + && self.external(identifier) + { + let id = self.site(identifier.span); + self.changes.push(( + identifier.span, + format!( + "{}: {}({id}, () => {}, true)", + identifier.name, self.helper, identifier.name + ), + )); + } else { + walk::walk_object_property(self, property); + } + } + + fn visit_static_member_expression( + &mut self, + member: &oxc_ast::ast::StaticMemberExpression<'a>, + ) { + let id = self.site(member.span); + let access = Span::new(member.object.span().end, member.property.span.end); + let optional = if member.optional { "?." } else { "" }; + self.changes.push(( + access, + format!( + "{optional}[{}({id}, \"{}\")]", + self.helper, member.property.name + ), + )); + walk::walk_static_member_expression(self, member); + } + + fn visit_computed_member_expression( + &mut self, + member: &oxc_ast::ast::ComputedMemberExpression<'a>, + ) { + // The key may itself read globals; establish the member site after it evaluates. + let id = self.site(member.span); + let key = member.expression.span(); + self.changes.push(( + Span::new(key.start, key.start), + format!("{}({id}, ", self.helper), + )); + self.changes + .push((Span::new(key.end, key.end), ")".to_string())); + walk::walk_computed_member_expression(self, member); + } + + fn visit_call_expression(&mut self, call: &oxc_ast::ast::CallExpression<'a>) { + self.visit_expression(&call.callee); + for (index, argument) in call.arguments.iter().enumerate() { + if index + 1 == call.arguments.len() && !argument.is_spread() { + let id = self.site(call.span); + let span = argument.span(); + self.changes.push(( + Span::new(span.start, span.start), + format!("{}({id}, ", self.helper), + )); + self.changes + .push((Span::new(span.end, span.end), ")".to_string())); + } + self.visit_argument(argument); + } + } + + fn visit_binding_property(&mut self, property: &oxc_ast::ast::BindingProperty<'a>) { + let id = self.site(property.key.span()); + let span = property.key.span(); + let written = &self.source[span.start as usize..span.end as usize]; + if property.computed { + self.changes.push(( + Span::new(span.start, span.start), + format!("{}({id}, ", self.helper), + )); + self.changes + .push((Span::new(span.end, span.end), ")".to_string())); + } else { + let key = match &property.key { + oxc_ast::ast::PropertyKey::StaticIdentifier(_) => format!("\"{written}\""), + _ => written.to_string(), + }; + let binding = if property.shorthand { + format!(": {written}") + } else { + String::new() + }; + self.changes + .push((span, format!("[{}({id}, {key})]{binding}", self.helper))); + } + walk::walk_binding_property(self, property); + } + + fn visit_assignment_expression(&mut self, assignment: &oxc_ast::ast::AssignmentExpression<'a>) { + self.write(assignment.span); + self.visit_assignment_target(&assignment.left); + self.visit_expression(&assignment.right); + } + + fn visit_update_expression(&mut self, update: &oxc_ast::ast::UpdateExpression<'a>) { + self.write(update.span); + walk::walk_update_expression(self, update); + } +} + +/// Adds identity calls to member keys and thunk calls around unbound identifiers. +/// Receivers, short-circuit optional chains and direct `eval` calls are unchanged. +pub(crate) fn instrument(source: &str, path: &str) -> Instrumented { + let allocator = oxc_allocator::Allocator::default(); + let parsed = oxc_parser::Parser::new(&allocator, source, SourceType::default()).parse(); + let scoping = SemanticBuilder::new() + .build(&parsed.program) + .semantic + .into_scoping(); + let mut hash = rustc_hash::FxHasher::default(); + source.hash(&mut hash); + path.hash(&mut hash); + let helper = + crate::fresh_name::fresh_name(&format!("__devup_read_site_{:x}__", hash.finish()), source); + let mut reads = Reads { + scoping: &scoping, + source, + helper: &helper, + changes: Vec::new(), + sites: Vec::new(), + path, + }; + if parsed.diagnostics.is_empty() { + reads.visit_program(&parsed.program); + operations::Operations(&mut reads).visit_program(&parsed.program); + } + reads + .changes + .sort_by_key(|(span, _)| (span.start, span.end)); + let mut code = String::with_capacity(source.len()); + let mut edits = Vec::new(); + let mut copied = 0; + for (span, text) in reads.changes { + let (start, end) = (span.start as usize, span.end as usize); + code.push_str(&source[copied..start]); + code.push_str(&text); + edits.push((start, end, text.len())); + copied = end; + } + code.push_str(&source[copied..]); + Instrumented { + code, + edits, + sites: reads.sites, + helper, + source: source.to_string(), + path: path.to_string(), + } +} + +impl Instrumented { + /// Maps engine frames to the pre-instrumentation text; explicit read frames already name it. + pub(crate) fn explain(&self, error: &str) -> String { + error + .lines() + .map(|line| { + if line.contains("at (") { + return line.to_string(); + } + let marker = format!("({}:", self.path); + let Some((prefix, position)) = line.rsplit_once(&marker) else { + return line.to_string(); + }; + let Some((row, column)) = position.trim_end_matches(')').split_once(':') else { + return line.to_string(); + }; + let (Ok(row), Ok(column)) = (row.parse::(), column.parse::()) else { + return line.to_string(); + }; + let offset = crate::source_map::Lines::new(&self.code) + .code_point_offset(row.saturating_sub(1), column.saturating_sub(1)); + let offset = crate::import_alias_visit::source_offset(&self.edits, offset); + format!( + "{prefix}({})", + crate::locate(&self.path, &self.source, offset) + ) + }) + .collect::>() + .join("\n") + } +} + +#[cfg(test)] +#[path = "evaluation_sandbox_sites_ci_tests.rs"] +mod ci_tests; diff --git a/libs/extractor/src/evaluation_sandbox_sites_ci_tests.rs b/libs/extractor/src/evaluation_sandbox_sites_ci_tests.rs new file mode 100644 index 00000000..bcdb0143 --- /dev/null +++ b/libs/extractor/src/evaluation_sandbox_sites_ci_tests.rs @@ -0,0 +1,26 @@ +use oxc_ast_visit::Visit; + +use super::Reads; + +#[test] +fn unannotated_identifier_is_preserved_without_an_invented_read_site() { + // Given + let source = "unknown"; + let allocator = oxc_allocator::Allocator::default(); + let parsed = + oxc_parser::Parser::new(&allocator, source, oxc_span::SourceType::default()).parse(); + let scoping = oxc_semantic::Scoping::default(); + let mut reads = Reads { + scoping: &scoping, + source, + helper: "read", + changes: Vec::new(), + sites: Vec::new(), + path: "unannotated.js", + }; + // When + reads.visit_program(&parsed.program); + // Then + assert_eq!(reads.changes, vec![]); + assert_eq!(reads.sites, vec![]); +} diff --git a/libs/extractor/src/evaluation_sandbox_sites_operations.rs b/libs/extractor/src/evaluation_sandbox_sites_operations.rs new file mode 100644 index 00000000..8b4fb78a --- /dev/null +++ b/libs/extractor/src/evaluation_sandbox_sites_operations.rs @@ -0,0 +1,59 @@ +//! Original operation positions for proxy traps without explicit member lookups. + +use oxc_ast::ast::{ + BinaryExpression, ForInStatement, SpreadElement, TemplateLiteral, UnaryExpression, +}; +use oxc_ast_visit::{Visit, walk}; +use oxc_span::{GetSpan, Span}; +use oxc_syntax::operator::UnaryOperator; + +pub(super) struct Operations<'r, 's>(pub(super) &'r mut super::Reads<'s>); + +impl super::Reads<'_> { + fn value_site(&mut self, value: Span, operation: Span) { + let id = self.site(operation); + self.changes.push(( + Span::new(value.start, value.start), + format!("{}({id}, ", self.helper), + )); + self.changes + .push((Span::new(value.end, value.end), ")".to_string())); + } +} + +impl<'a> Visit<'a> for Operations<'_, '_> { + fn visit_binary_expression(&mut self, binary: &BinaryExpression<'a>) { + self.0.value_site(binary.right.span(), binary.span); + walk::walk_binary_expression(self, binary); + } + + fn visit_unary_expression(&mut self, unary: &UnaryExpression<'a>) { + match unary.operator { + UnaryOperator::UnaryPlus | UnaryOperator::UnaryNegation | UnaryOperator::BitwiseNot => { + self.0.value_site(unary.argument.span(), unary.span); + } + UnaryOperator::LogicalNot + | UnaryOperator::Typeof + | UnaryOperator::Void + | UnaryOperator::Delete => {} + } + walk::walk_unary_expression(self, unary); + } + + fn visit_spread_element(&mut self, spread: &SpreadElement<'a>) { + self.0.value_site(spread.argument.span(), spread.span); + walk::walk_spread_element(self, spread); + } + + fn visit_for_in_statement(&mut self, statement: &ForInStatement<'a>) { + self.0.value_site(statement.right.span(), statement.span); + walk::walk_for_in_statement(self, statement); + } + + fn visit_template_literal(&mut self, template: &TemplateLiteral<'a>) { + for expression in &template.expressions { + self.0.value_site(expression.span(), expression.span()); + } + walk::walk_template_literal(self, template); + } +} diff --git a/libs/extractor/src/evaluation_sandbox_tests.rs b/libs/extractor/src/evaluation_sandbox_tests.rs new file mode 100644 index 00000000..3f7cc425 --- /dev/null +++ b/libs/extractor/src/evaluation_sandbox_tests.rs @@ -0,0 +1,271 @@ +use boa_engine::{Context, JsValue, Source, js_string}; +use rstest::rstest; + +use super::{Failure, Sandbox}; + +fn run(script: &str) -> Result { + let mut context = Context::default(); + let sandbox = Sandbox::new(&mut context).map_err(Failure::Js)?; + let source = super::instrument(script, "fixture.js"); + sandbox + .prepare(&mut context, &source) + .map_err(Failure::Js)?; + sandbox.run_source( + &mut context, + Source::from_bytes(&source.code).with_path(std::path::Path::new("fixture.js")), + ) +} + +fn text(script: &str) -> Result { + let mut context = Context::default(); + let sandbox = Sandbox::new(&mut context).map_err(|error| error.to_string())?; + let source = super::instrument(script, "fixture.js"); + sandbox + .prepare(&mut context, &source) + .map_err(|error| error.to_string())?; + let value = match sandbox.run_source( + &mut context, + Source::from_bytes(&source.code).with_path(std::path::Path::new("fixture.js")), + ) { + Ok(value) => value, + Err(Failure::Js(error)) => return Err(error.to_string()), + Err(Failure::Forbidden(_)) => return Err("unexpected forbidden read".to_string()), + }; + value + .to_string(&mut context) + .map(|value| value.to_std_string_escaped()) + .map_err(|error| error.to_string()) +} + +fn reads(script: &str) -> Result, String> { + match run(script) { + Err(Failure::Forbidden(violations)) => Ok(violations + .iter() + .map(|violation| violation.name().to_string()) + .collect()), + Err(Failure::Js(error)) => Err(format!("failed another way: {error}")), + Ok(_) => Err("read nothing forbidden".to_string()), + } +} + +#[path = "evaluation_sandbox_tests_ci.rs"] +mod ci_tests; + +#[test] +fn deterministic_computation_and_console_calls_run() -> Result<(), String> { + assert_eq!( + text( + "console.log('a'); console.table(1); `${Math.max(1, 2)}${JSON.stringify({ a: [1] })}${'b'.toUpperCase()}`" + )?, + r#"2{"a":[1]}B"# + ); + Ok(()) +} + +#[rstest] +#[case("Date.now()", "Date")] +#[case("new Date()", "Date")] +#[case("Date()", "Date")] +#[case("Date.UTC(2020, 0)", "Date")] +#[case("globalThis['Da' + 'te'].parse('x')", "Date")] +#[case("const clock = Date; clock.now()", "Date")] +#[case("Math.random()", "Math.random")] +#[case("Math.random", "Math.random")] +#[case("performance.now()", "performance")] +#[case("performance.timeOrigin", "performance")] +#[case("performance.memory", "performance")] +#[case("crypto.randomUUID()", "crypto")] +#[case("crypto.getRandomValues(new Uint8Array(1))", "crypto")] +#[case("crypto.subtle", "crypto")] +#[case("typeof window", "window")] +#[case("typeof process", "process")] +#[case("navigator.language", "navigator")] +#[case("fetch('x')", "fetch")] +#[case("new Intl.NumberFormat('en')", "Intl")] +#[case("Intl.getCanonicalLocales('en')", "Intl")] +#[case("Boolean(Intl)", "Intl")] +#[case("typeof Intl", "Intl")] +#[case("typeof Date", "Date")] +#[case("typeof Temporal", "Temporal")] +#[case("(1).toLocaleString()", "Number.prototype.toLocaleString")] +#[case("[1].toLocaleString()", "Array.prototype.toLocaleString")] +#[case("({}).toLocaleString()", "Object.prototype.toLocaleString")] +#[case("1n.toLocaleString()", "BigInt.prototype.toLocaleString")] +#[case( + "new Uint8Array(1).toLocaleString()", + "TypedArray.prototype.toLocaleString" +)] +#[case("'a'.localeCompare('b')", "String.prototype.localeCompare")] +#[case("'a'.toLocaleUpperCase()", "String.prototype.toLocaleUpperCase")] +#[case("'A'.toLocaleLowerCase()", "String.prototype.toLocaleLowerCase")] +#[case("'a'.normalize()", "String.prototype.normalize")] +#[case("const normalize = 'a'.normalize; 1", "String.prototype.normalize")] +#[case( + "const key = 'normalize'; const normalize = 'a'[key]; 1", + "String.prototype.normalize" +)] +#[case("const global = globalThis; global['Intl']", "Intl")] +#[case("setTimeout", "setTimeout")] +#[case("typeof require", "require")] +#[case("const { random } = Math; 1", "Math.random")] +#[case("const { normalize: method } = 'a'; 1", "String.prototype.normalize")] +#[case("eval('typeof Intl')", "Intl")] +fn a_forbidden_read_fails_the_run(#[case] script: &str, #[case] name: &str) -> Result<(), String> { + assert_eq!(reads(script)?, [name]); + Ok(()) +} + +#[rstest] +#[case("try { Date.now() } catch { 1 }")] +#[case("try { Math.random() } catch (error) { throw new Error('other') }")] +#[case("try { window } catch {} 'fine'")] +#[case("const read = () => { try { return performance.now() } catch { return 0 } }; read()")] +#[case("function f() { return Date.now() } try { f() } catch (error) { delete error.message }")] +#[case("({ get a() { return Date.now() } }).a")] +#[case("JSON.stringify({ get a() { return Math.random() } })")] +#[case("[1, 2].map(() => crypto.getRandomValues(new Uint8Array(1)))")] +fn a_caught_forbidden_read_still_fails_the_run(#[case] script: &str) -> Result<(), String> { + assert_eq!(reads(script)?.len(), 1, "{script}"); + Ok(()) +} + +#[test] +fn every_read_is_listed_in_order() -> Result<(), String> { + assert_eq!( + reads( + "try { Date.now() } catch {} try { window } catch {} try { 'a'.normalize() } catch {}" + )?, + ["Date", "window", "String.prototype.normalize"] + ); + Ok(()) +} + +#[test] +fn code_cannot_clear_the_evidence() -> Result<(), String> { + assert_eq!(reads("try { Date.now() } catch {} const global = globalThis; for (const key of Reflect.ownKeys(global)) { try { delete global[key] } catch {} } Object.freeze(Object.prototype); 1")?.first().map(String::as_str), Some("Date")); + Ok(()) +} + +#[rstest] +#[case( + "const history = [1, 2]; var screen = 'big'; globalThis.document = { a: 1 }; const Date = 7; `${history.length}${screen}${document.a}${Date}`", + "2big17" +)] +#[case("const Intl = 3; Boolean(Intl)", "true")] +#[case( + "const o = { value: 7, get read() { return this.value }, method() { return this.read } }; o.method()", + "7" +)] +#[case("let o = null; o?.normalize()", "undefined")] +#[case("let o = null; o?.x.y", "undefined")] +#[case("let o = null; o?.['x'].y()", "undefined")] +#[case("let o = null; o?.['x']?.y", "undefined")] +#[case("const o = { normalize() { return 4 } }; o.normalize()", "4")] +#[case("let x = 1; eval('x = 3'); x", "3")] +#[case( + "let o = { x: 1 }; o.x++; o['x'] = 4; delete o.x; JSON.stringify(o)", + "{}" +)] +#[case("const { x = 4, y: value } = { y: 3 }; `${x}${value}`", "43")] +#[case("const Intl = 3; eval('Intl')", "3")] +fn declared_values_and_receivers_keep_their_semantics( + #[case] script: &str, + #[case] expected: &str, +) -> Result<(), String> { + assert_eq!(text(script)?, expected); + Ok(()) +} + +#[test] +fn reads_in_a_host_run_function_are_found_by_the_check() -> Result<(), String> { + let mut context = Context::default(); + let sandbox = Sandbox::new(&mut context).map_err(|error| error.to_string())?; + let source = super::instrument("({ get width() { return Date.now() } })", "fixture.js"); + sandbox + .prepare(&mut context, &source) + .map_err(|error| error.to_string())?; + let value = sandbox + .run_source( + &mut context, + Source::from_bytes(&source.code).with_path(std::path::Path::new("fixture.js")), + ) + .map_err(|_| "creation failed".to_string())?; + let json = context.intrinsics().objects().json(); + let stringify = json + .get(js_string!("stringify"), &mut context) + .map_err(|error| error.to_string())?; + let error = stringify + .to_object(&mut context) + .map_err(|error| error.to_string())? + .call(&JsValue::undefined(), &[value], &mut context) + .err() + .ok_or("serialization succeeded")?; + let Err(Failure::Forbidden(violations)) = sandbox.check(&[&error]) else { + return Err("no violation".to_string()); + }; + assert_eq!(violations.len(), 1); + assert_eq!(violations[0].name(), "Date"); + assert_eq!(violations[0].site().map(|(_, offset)| offset), Some(24)); + Ok(()) +} + +#[rstest] +#[case("function helper() {\n return typeof window\n}\nhelper()", "window")] +#[case( + "function helper() {\n return Date\n}\ntry { helper() } catch {}", + "Date" +)] +#[case( + "function helper() {\n return globalThis['Intl']\n}\nhelper()", + "globalThis" +)] +fn a_read_reports_its_actual_original_site( + #[case] script: &str, + #[case] occurrence: &str, +) -> Result<(), String> { + let Err(Failure::Forbidden(violations)) = run(script) else { + return Err("no violation".to_string()); + }; + let offset = script + .find(occurrence) + .ok_or("fixture occurrence missing")?; + assert_eq!(violations[0].site().map(|(_, at)| at), Some(offset)); + assert!(violations[0].error().to_string().contains(&crate::locate( + "fixture.js", + script, + offset + ))); + Ok(()) +} + +#[test] +fn other_failures_stay_failures_of_their_own() { + assert!(matches!( + run("throw new Error('boom')"), + Err(Failure::Js(_)) + )); + assert!(matches!(run("for (;;) {}"), Err(Failure::Js(_)))); + assert!(matches!(run("missing.read"), Err(Failure::Forbidden(_)))); + assert!(matches!(run("let a = ;"), Err(Failure::Js(_)))); +} + +#[test] +fn generated_call_frames_map_back_to_original_columns() -> Result<(), String> { + let script = "const value = { fail() { throw new TypeError('bad') } };\nvalue.fail();"; + let mut context = Context::default(); + let sandbox = Sandbox::new(&mut context).map_err(|error| error.to_string())?; + let source = super::instrument(script, "fixture.js"); + sandbox + .prepare(&mut context, &source) + .map_err(|error| error.to_string())?; + let Err(Failure::Js(error)) = sandbox.run_source( + &mut context, + Source::from_bytes(&source.code).with_path(std::path::Path::new("fixture.js")), + ) else { + return Err("no TypeError".to_string()); + }; + let mapped = source.explain(&error.to_string()); + assert!(mapped.contains("TypeError: bad"), "{mapped}"); + assert!(mapped.contains("fixture.js:2:11"), "{mapped}"); + Ok(()) +} diff --git a/libs/extractor/src/evaluation_sandbox_tests_ci.rs b/libs/extractor/src/evaluation_sandbox_tests_ci.rs new file mode 100644 index 00000000..f99f74a5 --- /dev/null +++ b/libs/extractor/src/evaluation_sandbox_tests_ci.rs @@ -0,0 +1,29 @@ +use rstest::rstest; + +use super::{reads, text}; + +#[test] +fn text_rejects_javascript_failure_instead_of_reporting_a_value() { + // Given / When + let result = text("throw new TypeError('fixture failure')"); + // Then + assert!(result.is_err()); +} + +#[test] +fn text_rejects_forbidden_read_even_when_script_catches_it() { + // Given / When + let result = text("try { Date.now() } catch {} 'caught'"); + // Then + assert!(result.is_err()); +} + +#[rstest] +#[case("throw new TypeError('fixture failure')")] +#[case("Math.max(1, 2)")] +fn reads_rejects_runs_without_forbidden_evidence(#[case] script: &str) { + // Given / When + let result = reads(script); + // Then + assert!(result.is_err()); +} diff --git a/libs/extractor/src/extractor/extract_style_from_expression.rs b/libs/extractor/src/extractor/extract_style_from_expression.rs index 4b8f7f27..0b4b43d4 100644 --- a/libs/extractor/src/extractor/extract_style_from_expression.rs +++ b/libs/extractor/src/extractor/extract_style_from_expression.rs @@ -1257,9 +1257,10 @@ fn nest_underscore_name(parent: Option<&StyleSelector>, name: &str) -> Option li'`) rather +/// than CSS properties. fn is_nested_selector_key(key: &str) -> bool { - key.starts_with(':') || key.contains('&') + key.starts_with([':', '.', '#', '[', '*', '>', '+', '~']) || key.contains('&') } fn nest_selectors_key(parent: Option<&StyleSelector>, key: &str) -> Option { diff --git a/libs/extractor/src/extractor/extract_style_from_styled.rs b/libs/extractor/src/extractor/extract_style_from_styled.rs index 425ed024..1571b509 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,298 @@ 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 an element using this component keeps passing the prop `name`: + /// what its styles read, what it passes on, and what React or the + /// component itself takes + #[must_use] + pub fn takes(&self, name: &str) -> bool { + self.reads.whole + || self.reads.names.iter().any(|read| read == name) + || matches!( + name, + "className" | "style" | "as" | "forwardedAs" | "key" | "ref" | "children" + ) + || crate::styled_reads::passes( + name, + renders_tag(&self.name, self.bound.as_ref()), + self.forward.as_ref(), + ) + } + + /// Whether an extension can render what this definition renders: a value + /// only the runtime gives is read again only when it is a binding + #[must_use] + pub fn extendable(&self) -> bool { + self.bound + .as_ref() + .is_none_or(|bound| matches!(bound, Expression::Identifier(_))) + } +} + +impl<'a> StyledDefinition<'a> { + /// The tag an element using this component can render in its place, with + /// the styles the component gives it: a tag, with no attrs, no props read + /// and no value only the runtime gives + #[must_use] + pub fn inline(&self) -> Option<(&str, &[ExtractStyleProp<'a>])> { + (renders_tag(&self.name, self.bound.as_ref()) + && self.attrs.is_empty() + && self.reads == Reads::default() + && self.classes.is_empty() + && self.styles.iter().all(fixed)) + .then_some((self.name.as_str(), self.styles.as_slice())) + } + + /// The 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 +358,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 +405,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 +417,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 +463,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 +494,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 +534,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 +554,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 +584,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 +609,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 +625,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 +633,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 +666,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 +710,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 +721,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 +746,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 +765,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 +834,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 +867,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 +1103,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 +1142,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 +1348,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 +1408,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 69ff713a..5440b92a 100644 --- a/libs/extractor/src/extractor/extract_style_from_stylex.rs +++ b/libs/extractor/src/extractor/extract_style_from_stylex.rs @@ -3,8 +3,9 @@ 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::{ - DecomposedStyle, SelectorPart, StylexIncludeRef, decompose_value_conditions, - dynamic_number_suffix, is_include_call_static, normalize_stylex_property, stylex_value, + DecomposedStyle, SelectorPart, StylexIncludeRef, StylexResolver, decompose_value_conditions, + dynamic_number_suffix, 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; @@ -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>( @@ -127,6 +149,7 @@ pub fn extract_stylex_namespace_styles<'a>( keyframe_names: &FxHashMap, var_refs: &FxHashMap, errors: &mut Vec<(u32, String)>, + resolver: StylexResolver<'_>, ) -> Vec<( String, Vec>, @@ -176,7 +199,8 @@ pub fn extract_stylex_namespace_styles<'a>( result.push((ns_name, styles, Some(css_vars), vec![])); } Expression::ObjectExpression(ns_obj) => { - let (styles, include_refs) = extract_stylex_namespace(ns_obj, &leaf, errors); + let (styles, include_refs) = + extract_stylex_namespace(ns_obj, &leaf, errors, resolver); result.push((ns_name, styles, None, include_refs)); } Expression::NullLiteral(_) => result.push((ns_name, vec![], None, vec![])), @@ -198,6 +222,7 @@ fn extract_stylex_namespace<'a>( namespace: &ObjectExpression<'_>, leaf: &Leaf<'_>, errors: &mut Vec<(u32, String)>, + resolver: StylexResolver<'_>, ) -> (Vec>, Vec) { let mut styles = vec![]; let mut include_refs = vec![]; @@ -205,7 +230,7 @@ fn extract_stylex_namespace<'a>( let style_prop = match style_prop { ObjectPropertyKind::ObjectProperty(style_prop) => style_prop, ObjectPropertyKind::SpreadProperty(spread) => { - match include(spread) { + match include(spread, resolver) { Some(Ok(include_ref)) => include_refs.push(include_ref), Some(Err(error)) => errors.push(error), None => errors.push(spread_error("stylex.create", spread)), @@ -252,6 +277,7 @@ fn extract_stylex_namespace<'a>( &parent_selectors, leaf, errors, + resolver, ), ); } @@ -266,7 +292,14 @@ fn extract_stylex_namespace<'a>( } push_decomposed( &mut styles, - decompose_value_conditions(&css_property, &style_prop.value, &[], leaf, errors), + decompose_value_conditions( + &css_property, + &style_prop.value, + &[], + leaf, + errors, + resolver, + ), ); } (styles, include_refs) @@ -282,11 +315,14 @@ 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 Some(Expression::StaticMemberExpression(member)) = diff --git a/libs/extractor/src/fresh_name.rs b/libs/extractor/src/fresh_name.rs new file mode 100644 index 00000000..4ffa16d3 --- /dev/null +++ b/libs/extractor/src/fresh_name.rs @@ -0,0 +1,38 @@ +//! Source-safe names for generated instrumentation helpers. + +pub(crate) fn fresh_name(base: &str, source: &str) -> String { + let mut candidate = base.to_string(); + while source.contains(&candidate) { + candidate.push('_'); + } + candidate +} + +#[cfg(test)] +mod tests { + use super::fresh_name; + use rstest::rstest; + + #[rstest] + #[case("__devup_read_site_123__")] + #[case("__devup_operation_site_123__")] + fn chooses_unused_suffix_when_source_contains_collisions(#[case] base: &str) { + // Given: valid JavaScript with the base and two occupied suffixes. + let source = format!("const {base} = 1, {base}_ = 2, {base}__ = 3;"); + // When + let chosen = fresh_name(base, &source); + // Then + assert_eq!(chosen, format!("{base}___")); + assert!(!source.contains(&chosen)); + } + + #[test] + fn preserves_base_when_source_has_no_collision() { + // Given + let source = "const value = 1;"; + // When + let chosen = fresh_name("__helper__", source); + // Then + assert_eq!(chosen, "__helper__"); + } +} 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 00000000..fae3e4d7 --- /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 158d0f42..6fca99ac 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 a600d1de..e539cd2c 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,14 @@ use oxc_syntax::operator::BinaryOperator; use oxc_syntax::symbol::SymbolId; use rustc_hash::{FxHashMap, FxHashSet}; +use crate::composition::{Composition, set_prop_order}; +use crate::css_prop::{CssProp, CssTakers, 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}; #[derive(Clone, Debug)] enum Constant { @@ -44,8 +51,9 @@ enum Constant { /// The class a `StyleX` theme applies Theme(String), /// What another style API gives: a class, a component or a keyframes - /// name, never rules - Style, + /// name, never rules; for a `css()` class, the styles behind it when + /// they are known + Style(Option>>), /// An object or array code changes, or a value read from one Changed(Rc), } @@ -154,8 +162,14 @@ pub(crate) struct Inlined { pub dependencies: BTreeSet, pub stylex_vars: FxHashMap>, pub stylex_themes: FxHashMap, + /// The styles behind imported `css()` classes + pub css_styles: FxHashMap>, pub unknown: Unknown, pub changed: Changed, + /// 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 +186,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 +206,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 +276,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 +295,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,46 +346,87 @@ 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 { + crate::package_specifier::is_package(source, &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(), depth: 0, + class_names: Vec::new(), }; read.visit_program(program); if read.names.is_empty() { @@ -358,16 +440,21 @@ pub(crate) fn inline_constants<'a>( }; 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 +508,12 @@ 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)) { 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 +554,31 @@ 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(); if !symbols.is_empty() || reads_math { Inline { ast_builder, - scoping: &scoping, + scoping, 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 +657,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 +682,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 +722,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 +745,16 @@ 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, 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 +764,41 @@ impl StyleReads<'_> { } impl<'a> Visit<'a> for StyleReads<'_> { - fn visit_identifier_reference(&mut self, identifier: &oxc_ast::ast::IdentifierReference<'a>) { - if self.depth > 0 { + fn visit_identifier_reference(&mut self, identifier: &IdentifierReference<'a>) { + if self.depth > 0 && reads_top_level(self.style.scoping, identifier) { self.names.insert(identifier.name.to_string()); } } + 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 +808,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 +851,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 +860,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>, @@ -994,8 +1146,17 @@ struct ModuleScope<'p, 'a> { style_imports: FxHashSet, /// Style APIs besides the imports, which never run what they are given style_names: FxHashSet, + /// The `css` props of the file extracted, which never run what they hold, + /// 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> { @@ -1009,8 +1170,11 @@ impl<'p, 'a> ModuleScope<'p, 'a> { 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(), } } @@ -1027,17 +1191,67 @@ impl<'p, 'a> ModuleScope<'p, 'a> { self.style_imports.contains(name) || self.imports.get(name).is_some_and(|(source, _)| { source != crate::STYLEX_PACKAGE - && (source.starts_with(modules.option.package.as_str()) + && (crate::package_specifier::is_package(source, &modules.option.package) || modules.option.import_aliases.contains_key(source)) }) } + /// 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" || !crate::package_specifier::is_package(source, &modules.option.package) + { + 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) || self.imports.get(name).is_some_and(|(source, _)| { source == crate::STYLEX_PACKAGE - || source.starts_with(option.package.as_str()) + || crate::package_specifier::is_package(source, &option.package) || option.import_aliases.contains_key(source) }) } @@ -1066,9 +1280,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 }; @@ -1256,25 +1472,41 @@ 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.shared_scoping.unwrap_or_else(|| { + self.scoping.get_or_init(|| { + SemanticBuilder::new() + .build(self.program) + .semantic + .into_scoping() + }) + }); + binding_of(scoping, identifier) + .is_some_and(|symbol| scoping.symbol_scope_id(symbol) == scoping.root_scope_id()) } /// A value `StyleX` gives when this module's own extraction reads it, with @@ -1352,7 +1584,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() { @@ -1465,13 +1697,15 @@ impl<'p, 'a> ModuleScope<'p, 'a> { } fold_math(callee.property.name.as_str(), &arguments) } - callee if self.is_style_api(modules, callee) => Some(Constant::Style), + callee if self.is_style_api(modules, callee) => { + Some(Constant::Style(self.css_styles(modules, call))) + } _ => self.evaluate_stylex(modules, call), }, Expression::TaggedTemplateExpression(tagged) if self.is_style_api(modules, &tagged.tag) => { - Some(Constant::Style) + Some(Constant::Style(None)) } Expression::TSAsExpression(inner) => self.evaluate(modules, &inner.expression), Expression::TSSatisfiesExpression(inner) => self.evaluate(modules, &inner.expression), @@ -1693,12 +1927,17 @@ struct Inline<'s, 'a> { ast_builder: &'s AstBuilder<'a>, scoping: &'s Scoping, 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> { @@ -1837,55 +2076,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 +2093,99 @@ impl<'a> Inline<'_, 'a> { self.styles = outer; result } + + /// `visit` reading a `css` prop when `css` + fn reading_css(&mut self, css: bool, visit: impl FnOnce(&mut Self) -> T) -> T { + let outer = std::mem::replace(&mut self.px, css); + let result = self.reading_styles(css, |inline| inline.reading_objects(css, visit)); + self.px = outer; + result + } +} + +/// `property` holding a number as the `px` length Emotion reads it as +fn px_value<'a>(ast_builder: &AstBuilder<'a>, property: &mut oxc_ast::ast::ObjectProperty<'a>) { + if let Some(number) = crate::utils::js_number_literal(&property.value) + && number != 0.0 + && property + .key + .static_name() + .is_some_and(|key| !crate::utils::keeps_bare_number(&key)) + { + property.value = Expression::new_string_literal( + SPAN, + Str::from_in(format!("{number}px").as_str(), ast_builder.allocator()), + None, + ast_builder, + ); + } +} + +/// The rules `rules` with their numbers as the `px` lengths Emotion reads them +/// as, nested rules included +fn px_rules<'a>(ast_builder: &AstBuilder<'a>, rules: &mut Expression<'a>) { + if let Expression::ObjectExpression(object) = rules { + for property in &mut object.properties { + if let ObjectPropertyKind::ObjectProperty(property) = property { + px_value(ast_builder, property); + px_rules(ast_builder, &mut property.value); + } + } + } +} + +/// `constant` written as a literal, objects and arrays too when `objects` +fn constant_literal<'a>( + builder: &AstBuilder<'a>, + constant: &Constant, + objects: bool, +) -> Option> { + match constant { + Constant::String(value) => Some(Expression::new_string_literal( + SPAN, + Str::from_in(value.as_str(), builder.allocator()), + None, + builder, + )), + Constant::Number(value) => Some(Expression::new_numeric_literal( + SPAN, + *value, + None, + NumberBase::Decimal, + builder, + )), + Constant::Null => Some(Expression::new_null_literal(SPAN, builder)), + Constant::Bool(value) => Some(Expression::new_boolean_literal(SPAN, *value, builder)), + Constant::Record(entries) if objects => { + let mut properties = oxc_allocator::Vec::with_capacity_in(entries.len(), builder); + for (key, value) in entries.iter() { + properties.push(ObjectPropertyKind::new_object_property( + SPAN, + oxc_ast::ast::PropertyKind::Init, + oxc_ast::ast::PropertyKey::StringLiteral(oxc_ast::ast::StringLiteral::boxed( + SPAN, + Str::from_in(key.as_str(), builder.allocator()), + None, + builder, + )), + constant_literal(builder, value, objects)?, + false, + false, + false, + builder, + )); + } + Some(Expression::new_object_expression(SPAN, properties, builder)) + } + Constant::Array(values) if objects => { + let mut elements = oxc_allocator::Vec::with_capacity_in(values.len(), builder); + for value in values.iter() { + elements.push(constant_literal(builder, value, objects)?.into()); + } + Some(Expression::new_array_expression(SPAN, elements, builder)) + } + _ => None, + } } impl<'a> VisitMut<'a> for Inline<'_, 'a> { @@ -1912,6 +2196,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 +2215,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 +2236,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 +2310,23 @@ 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)] +#[path = "imported_constants_package_boundary_tests.rs"] +mod package_boundary_tests; +#[cfg(test)] +mod scope_tests; +#[cfg(test)] +mod stylex_scope_tests; 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 00000000..e6c5523a --- /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 00000000..ea6d77bf --- /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_package_boundary_tests.rs b/libs/extractor/src/imported_constants_package_boundary_tests.rs new file mode 100644 index 00000000..6d88acf3 --- /dev/null +++ b/libs/extractor/src/imported_constants_package_boundary_tests.rs @@ -0,0 +1,49 @@ +use oxc_allocator::Allocator; +use oxc_ast::ast::{Expression, Statement}; +use oxc_parser::Parser; +use oxc_span::SourceType; +use rstest::rstest; + +use super::{ModuleScope, Modules}; +use crate::ExtractOption; + +#[rstest] +#[case("@devup-ui/react-values", false)] +#[case("@devup-ui/react", true)] +#[case("@devup-ui/react/styles", true)] +#[case("@devup-ui/react/compat", true)] +fn package_boundary_css_styles_only_reads_api_exports( + #[case] source: &str, + #[case] expected: bool, +) { + // Given + let allocator = Allocator::default(); + let code = format!("import {{css}} from '{source}';css({{color:'blue'}});"); + let program = Parser::new(&allocator, &code, SourceType::tsx()) + .parse() + .program; + let mut scope = ModuleScope::new("/helper.ts", &program, None); + let Statement::ImportDeclaration(import) = &program.body[0] else { + panic!("import fixture"); + }; + scope.import(import); + let Statement::ExpressionStatement(statement) = &program.body[1] else { + panic!("call fixture"); + }; + let Expression::CallExpression(call) = &statement.expression else { + panic!("call fixture"); + }; + let option = ExtractOption::default(); + let mut modules = Modules { + resolver: None, + option: &option, + exports: Default::default(), + loading: vec![], + }; + // When + let styles = scope.css_styles(&mut modules, call); + // Then + assert_eq!(styles.is_some(), expected); + assert_eq!(scope.is_style_api(&modules, &call.callee), expected); + assert_eq!(scope.is_style_import(&option, "css"), expected); +} diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 68981f36..14b879ed 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -1,18 +1,35 @@ mod as_visit; +mod barrel; mod build_time_values; +#[cfg(test)] +mod ci_coverage_tests; mod component; +mod composition; +mod css_prop; mod css_utils; +#[cfg(test)] +mod diagnostics_tests; +mod evaluation_sandbox; pub mod extract_style; mod extractor; +mod fresh_name; mod gen_class_name; mod gen_style; mod import_alias_visit; mod imported_constants; mod module_loader; +pub mod module_reference; mod mutations; +mod package_specifier; mod prop_modify_utils; +mod prop_valid; +mod scope; mod source_map; mod style_values; +mod styled_reads; +mod stylesheet_policy; +#[cfg(test)] +mod stylesheet_regression_tests; mod stylex; mod tailwind; mod util_type; @@ -26,7 +43,7 @@ use oxc_allocator::{Allocator, CloneIn}; use oxc_ast::ast::Expression; use oxc_ast_visit::VisitMut; use oxc_codegen::{Codegen, CodegenOptions}; -use oxc_parser::{Parser, ParserReturn}; +use oxc_parser::{ParseOptions, Parser, ParserReturn}; use oxc_span::SourceType; use rustc_hash::FxHashMap; use rustc_hash::FxHashSet; @@ -281,52 +298,149 @@ 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(); - let mut evaluation_error = None; + if utils::is_vanilla_extract_file(filename) + && !values_run + && stylesheet_policy::imports_plain(&transformed_code, filename, &option, resolver) + && let Some((computed, value_edits, read)) = build_time_values::evaluate_located( + &transformed_code, + filename, + &option, + resolver, + &imported_constants::Unknown::default(), + ) + .map_err(|errors| { + let (source, earlier_edits) = evaluated.unwrap_or((code, &[])); + errors + .into_iter() + .map(|(offset, message)| { + let offset = std::iter::once(alias_edits.as_slice()) + .chain(earlier_edits.iter().copied()) + .fold(offset, |offset, edits| { + import_alias_visit::source_offset(edits, offset) + }); + format!( + "{}: {message}. Fix: use a literal or a CSS variable", + locate(filename, source, offset) + ) + }) + .collect::>() + .join("\n") + })? + { + 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((source, &layers)), + true, + option, + source_map, + resolver, + )?; + let mut files: std::collections::BTreeSet = + output.dependencies.into_iter().collect(); + files.extend(read); + output.dependencies = files.into_iter().collect(); + return Ok(output); + } // Step 3: Handle vanilla-extract style files (.css.ts, .css.js) // `processed_code` is Some only when vanilla-extract generation succeeded; // otherwise the untouched `transformed_code` is parsed directly (no copy). - let processed_code: Option = if utils::is_vanilla_extract_file(filename) { + let processed_code: Option = if utils::is_vanilla_extract_file(filename) + && stylesheet_policy::plan(&transformed_code, filename, &option, resolver, &|_| false) + == stylesheet_policy::Plan::Run + { + let (source, earlier_edits) = evaluated.unwrap_or((code, &[])); + let layers: Vec<&[import_alias_visit::Edit]> = std::iter::once(alias_edits.as_slice()) + .chain(earlier_edits.iter().copied()) + .collect(); // Use transformed code (with imports already pointing to @devup-ui/react) - match vanilla_extract::execute_stylesheet(&transformed_code, filename, &option, resolver) { + match vanilla_extract::execute_located( + vanilla_extract::Stylesheet { + filename, + code: &transformed_code, + source, + edits: &layers, + }, + &option, + resolver, + ) { Ok((collected, imports)) => { dependencies = imports.dependencies; // Keyframes names are generated, so extract the referenced ones @@ -364,21 +478,7 @@ fn extract_source( .collect() }) } - // A stylesheet another one imports must give its own values, and an - // import cycle read too early fails as it does in ES modules, so both - // are reported rather than hidden behind plain extraction - Err(error) - if module_loader::loading_for_stylesheet() - || error.contains(module_loader::IMPORT_CYCLE) => - { - return Err(error.into()); - } - // Plain extraction still compiles Devup UI's own APIs; the error is - // reported when calls it cannot compile remain - Err(error) => { - evaluation_error = Some(error); - None - } + Err(error) => return Err(error.into()), } } else { None @@ -396,7 +496,11 @@ fn extract_source( let code_to_parse = processed_code.as_deref().unwrap_or(&transformed_code); - let source_type = SourceType::from_path(filename)?; + let source_type = SourceType::from_path(filename).map_err(|error| { + format!( + "{filename}:1:1: {error}. Fix: use a supported JavaScript or TypeScript file extension" + ) + })?; let (bucket, global, css_file) = resolve_css_target(filename, &option); let import_main = option.import_main_css && !global; // Presize to the exact final length (1 target + optional main-css entry) and @@ -412,10 +516,45 @@ fn extract_source( let ParserReturn { mut program, // AST fatal_error, // Parser encountered an error it couldn't recover from + diagnostics, .. - } = Parser::new(&allocator, code_to_parse, source_type).parse(); - if fatal_error { - return Err("Parser panicked".into()); + } = Parser::new(&allocator, code_to_parse, source_type) + .with_options(ParseOptions { + parse_regular_expression: true, + ..ParseOptions::default() + }) + .parse(); + let semantic_diagnostics = if diagnostics.is_empty() { + oxc_semantic::SemanticBuilder::new() + .with_check_syntax_error(true) + .build(&program) + .diagnostics + } else { + Default::default() + }; + if fatal_error || !diagnostics.is_empty() || !semantic_diagnostics.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(); + let errors = diagnostics + .iter() + .chain(semantic_diagnostics.iter()) + .take(1) + .map(|error| { + ( + error + .labels + .first() + .map_or(0, oxc_span::LabeledSpan::offset), + format!( + "Cannot parse source: {}. Fix: correct the syntax at this location", + error.to_string().trim_end_matches('.') + ), + ) + }) + .collect(); + return Err(located_errors(filename, source, &edits, errors).into()); } let inlined = if processed_code.is_none() { imported_constants::inline_constants( @@ -424,6 +563,7 @@ fn extract_source( filename, &option, resolver, + css_prop, ) } else { imported_constants::Inlined::default() @@ -437,31 +577,58 @@ 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 let Some(error) = evaluation_error - && imports_uncompiled(&program, &option.package) - { - return Err(error.into()); + 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()); } // 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( + && let Some((computed, value_edits, read)) = build_time_values::evaluate_located( &transformed_code, filename, &option, resolver, &inlined.unknown, ) + .map_err(|errors| { + let (source, earlier_edits) = evaluated.unwrap_or((code, &[])); + errors + .into_iter() + .map(|(offset, message)| { + let offset = std::iter::once(alias_edits.as_slice()) + .chain(earlier_edits.iter().copied()) + .fold(offset, |offset, edits| { + import_alias_visit::source_offset(edits, offset) + }); + format!( + "{}: {message}. Fix: use a literal or a CSS variable", + locate(filename, source, offset) + ) + }) + .collect::>() + .join("\n") + })? { + 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, @@ -528,21 +695,6 @@ fn main_css_path(css_dir: &str) -> String { /// kept for parse/sourcemap. Global (shared-chunk) files are emitted like /// single-css: into devup-ui.css with prefix-less global naming, so styles /// shared across routes ship once. -/// Whether `program` still imports a value from `package` that extraction did -/// not compile away -fn imports_uncompiled(program: &oxc_ast::ast::Program<'_>, package: &str) -> bool { - program.body.iter().any(|statement| { - matches!(statement, oxc_ast::ast::Statement::ImportDeclaration(import) - if import.source.value == package - && !import.import_kind.is_type() - && import.specifiers.iter().flatten().any(|specifier| matches!( - specifier, - oxc_ast::ast::ImportDeclarationSpecifier::ImportSpecifier(named) - if !named.import_kind.is_type() - ))) - }) -} - /// `errors` in source order, one per line, each led by `filename:line:column` /// of the code it is about; each layer of `edits`, last made first, maps the /// offsets back to `source` @@ -569,12 +721,23 @@ fn located_errors( /// `filename:line:column` of `offset` in `source` fn locate(filename: &str, source: &str, offset: usize) -> String { let before = source.get(..offset).unwrap_or(source); - let line_start = before.rfind('\n').map_or(0, |index| index + 1); - format!( - "{filename}:{}:{}", - before.matches('\n').count() + 1, - before[line_start..].chars().count() + 1 - ) + let (mut line, mut column) = (1, 1); + let mut characters = before.chars().peekable(); + while let Some(character) = characters.next() { + match character { + '\r' => { + characters.next_if_eq(&'\n'); + line += 1; + column = 1; + } + '\n' | '\u{2028}' | '\u{2029}' => { + line += 1; + column = 1; + } + _ => column += 1, + } + } + format!("{filename}:{line}:{column}") } /// A line for each binding `message` names that code changes, telling where @@ -734,6 +897,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 +1030,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] @@ -1427,7 +1621,7 @@ mod tests { extract( "test.tsx", r"import {Box} from '@devup-ui/core' - /> + /> ", ExtractOption { package: "@devup-ui/core".to_string(), @@ -1676,7 +1870,7 @@ mod tests { extract( "test.tsx", r"import {Box as C} from '@devup-ui/core' - + ", ExtractOption { package: "@devup-ui/core".to_string(), @@ -4779,7 +4973,7 @@ import clsx from 'clsx' ) .unwrap_err() .to_string() - .starts_with("Unknown file extension") + .starts_with("test.wrong:1:1: Unknown file extension") ); reset_class_map(); @@ -4798,7 +4992,7 @@ import clsx from 'clsx' ) .unwrap_err() .to_string(), - "Parser panicked" + "test.tsx:1:11: Cannot parse source: Expected `from` but found `string`. Fix: correct the syntax at this location" ); } @@ -6931,7 +7125,7 @@ export { extract( "test.jsx", r#"import {Box, css} from '@devup-ui/core' - + "#, ExtractOption { package: "@devup-ui/core".to_string(), @@ -7674,7 +7868,7 @@ export { c as Lib };"#, extract( "test.jsx", r"import {Box} from '@devup-ui/core' - + ", ExtractOption { package: "@devup-ui/core".to_string(), @@ -9434,7 +9628,7 @@ keyframes({ extract( "test.tsx", r"import {Box} from '@devup-ui/core' - /> + /> ", ExtractOption { package: "@devup-ui/core".to_string(), @@ -9467,7 +9661,7 @@ keyframes({ extract( "test1.tsx", r"import {Box} from '@devup-ui/core' - /> + /> ", ExtractOption { package: "@devup-ui/core".to_string(), @@ -13598,7 +13792,7 @@ globalCss({ ); assert!(result.is_ok()); let output = result.unwrap(); - assert!(!output.code.is_empty()); + assert_ne!(output.code, ""); } #[test] @@ -13615,7 +13809,8 @@ globalCss({ .map(|error| error.to_string()) .unwrap_or_default(); assert!( - error.starts_with("JS execution error: TypeError"), + error.starts_with("broken.css.ts:3:") + && error.contains(": JS execution error: TypeError"), "{error}" ); @@ -14553,6 +14748,449 @@ const Button = styled.button({ bg: 'red' }) )); } + /// `code` extracted with readable class names + fn readable_code(code: &str) -> String { + reset_class_map(); + reset_file_map(); + css::debug::set_debug(true); + let output = extract( + "test.tsx", + code, + ExtractOption { + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), + single_css: true, + import_main_css: false, + import_aliases: HashMap::new(), + }, + ); + css::debug::set_debug(false); + match output { + Ok(output) => output.code, + Err(error) => error.to_string(), + } + } + + // A later part of `css(...)` replaces an earlier part's declaration of the + // same property, selector, breakpoint and layer, whatever the stylesheet + // order of their classes + #[rstest] + #[case( + "css(yellow, azure)", + r#""color-0-red-_a__c_hover-255 background-0-black--255 color-0-azure--255""# + )] + #[case( + "css(azure, yellow)", + r#""background-0-black--255 color-0-red-_a__c_hover-255 color-0-yellow--255""# + )] + #[case( + "css({ color: 'green' }, yellow)", + r#""color-0-red-_a__c_hover-255 color-0-yellow--255""# + )] + #[case( + "css(yellow, { color: 'green' })", + r#""color-0-red-_a__c_hover-255 color-0-green--255""# + )] + #[case( + "css([yellow, azure])", + r#""color-0-red-_a__c_hover-255 background-0-black--255 color-0-azure--255""# + )] + #[case( + "css(...[yellow, azure])", + r#""color-0-red-_a__c_hover-255 background-0-black--255 color-0-azure--255""# + )] + #[case( + "css(yellow, null, undefined, false, azure)", + r#""color-0-red-_a__c_hover-255 background-0-black--255 color-0-azure--255""# + )] + #[case( + "css(yellow, on && azure)", + r#"`color-0-red-_a__c_hover-255 ${on ? "background-0-black--255" : ""} ${on ? "color-0-azure--255" : "color-0-yellow--255"}`"# + )] + #[case( + "css(on ? yellow : azure)", + r#"`${on ? "color-0-red-_a__c_hover-255" : ""} ${on ? "color-0-yellow--255" : "color-0-azure--255"} ${on ? "" : "background-0-black--255"}`"# + )] + #[case( + "css(yellow, on ? { color: 'pink' } : null)", + r#"`color-0-red-_a__c_hover-255 ${on ? "color-0-pink--255" : "color-0-yellow--255"}`"# + )] + #[case( + "css(yellow, { color: on ? 'pink' : 'teal' })", + r#"`color-0-red-_a__c_hover-255 ${on ? "color-0-pink--255" : "color-0-teal--255"}`"# + )] + #[case( + "css(yellow, ext, azure)", + r"`color-0-red-_a__c_hover-255 background-0-black--255 color-0-azure--255 ${ext}`" + )] + #[case( + "css(yellow, on ? 'plain' : azure)", + r#"`${on ? "plain" : ""} ${`color-0-red-_a__c_hover-255 ${on ? "" : "background-0-black--255"} ${on ? "color-0-yellow--255" : "color-0-azure--255"}`}`"# + )] + #[case( + "css(azure, wide)", + r#""background-0-black--255 color-0-a1--255 color-1-b1--255""# + )] + #[case( + "css(wide, { color: [null, 'x2'] })", + r#""color-0-a1--255 color-1-x2--255""# + )] + #[case( + "css(yellow, ordered)", + r#""color-0-red-_a__c_hover-255 color-0-navy--3""# + )] + #[case("css(heading, body)", r#""typo-body""#)] + #[case( + "css(chained, { color: 'gold' })", + r#""color-0-red-_a__c_hover-255 background-0-black--255 color-0-gold--255""# + )] + #[case( + "css(yellow, { color: { a: 'red', b: 'blue' }[size] })", + r"`color-0-red-_a__c_hover-255 color-0-yellow--255 ${{" + )] + #[case( + "css(yellow, on ? { color: { a: 'red' }[size] } : null)", + r#"`color-0-red-_a__c_hover-255 color-0-yellow--255 ${on ? { "a": "color-0-red--255" }[size] || "" : ""}`"# + )] + #[case( + "css(yellow, on || azure)", + r#"`${on ? typeof on === "string" ? on : "" : ""} ${`color-0-red-_a__c_hover-255 ${on ? "" : "background-0-black--255"} ${on ? "color-0-yellow--255" : "color-0-azure--255"}`}`"# + )] + #[case( + "css(yellow, ext ?? azure)", + r#"`${ext != null ? typeof ext === "string" ? ext : "" : ""} ${`color-0-red-_a__c_hover-255 ${ext != null ? "" : "background-0-black--255"} ${ext != null ? "color-0-yellow--255" : "color-0-azure--255"}`}`"# + )] + #[case( + "css(yellow, null ?? azure)", + r#""color-0-red-_a__c_hover-255 background-0-black--255 color-0-azure--255""# + )] + #[case( + "css(yellow, false ?? azure)", + r#""color-0-red-_a__c_hover-255 color-0-yellow--255""# + )] + #[case( + "css(azure || yellow)", + r#""background-0-black--255 color-0-azure--255""# + )] + #[case( + "css(yellow, on || 'plain')", + r#"`color-0-red-_a__c_hover-255 color-0-yellow--255 ${on ? typeof on === "string" ? on : "" : "plain"}`"# + )] + #[case( + "css(yellow, { color: on ? 'a' : 'b', m: [1, 2], styleOrder: 2 })", + r#"`color-0-red-_a__c_hover-255 margin-0-4px--2 margin-1-8px--2 ${on ? "color-0-a--2" : "color-0-b--2"}`"# + )] + #[case( + "css(yellow, { color: { a: 'x' }[size], styleOrder: 2 })", + r#"`color-0-red-_a__c_hover-255 color-0-yellow--255 ${{ "a": "color-0-x--2" }[size] || ""}`"# + )] + #[case( + "css(yellow, on ? { color: other ? 'a' : 'b' } : null)", + r#"`color-0-red-_a__c_hover-255 ${on ? other ? "color-0-a--255" : "color-0-b--255" : "color-0-yellow--255"}`"# + )] + #[case( + "css(yellow, fade)", + r#""k-525370705245237976 color-0-red-_a__c_hover-255 color-0-yellow--255""# + )] + #[case( + "css(yellow, { color: ['x', 'y'][idx], styleOrder: 2 })", + "`color-0-red-_a__c_hover-255 color-0-yellow--255 ${{\n\t\"0\": \"color-0-x--2\",\n\t\"1\": \"color-0-y--2\"\n}[idx] || \"\"}`" + )] + #[case( + "css(yellow, { color: 'pink' } || azure)", + r#""color-0-red-_a__c_hover-255 color-0-pink--255""# + )] + #[case( + "css(yellow, undefined ?? azure)", + r#""color-0-red-_a__c_hover-255 background-0-black--255 color-0-azure--255""# + )] + #[case( + "css(yellow, false ?? azure)", + r#""color-0-red-_a__c_hover-255 color-0-yellow--255""# + )] + #[serial] + fn test_css_composes_known_classes(#[case] call: &str, #[case] expected: &str) { + let code = readable_code(&format!( + "import {{css, keyframes}} from '@devup-ui/core' +const yellow = css({{ color: 'yellow', _hover: {{ color: 'red' }} }}) +const azure = css({{ color: 'azure', bg: 'black' }}) +const wide = css({{ color: ['a1', 'b1'] }}) +const ordered = css({{ color: 'navy', styleOrder: 3 }}) +const heading = css({{ typography: 'heading' }}) +const body = css({{ typography: 'body' }}) +const chained = css(yellow, azure) +const fade = keyframes({{ from: {{ opacity: 0 }} }}) +export const result = {call} +" + )); + assert!( + code.contains(&format!("export const result = {expected}")), + "{code}" + ); + } + + #[test] + #[serial] + fn test_styled_extension_composes_base() { + let code = readable_code( + "import {styled} from '@devup-ui/core' +const Base = styled.button`color: red; background: white; &:hover { color: red; }` +const Ext = styled(Base)`color: blue; &:hover { color: blue; }` +const Obj = styled.div({ color: 'red', p: 2 }) +const ObjExt = styled(Obj)({ color: 'blue' }) +const Twice = styled(ObjExt, { m: 1 }) +const Runtime = styled(make())({ color: 'red' }) +const FromRuntime = styled(Runtime)({ color: 'blue' }) +let Changing = styled.div({ color: 'red' }) +const FromChanging = styled(Changing)({ color: 'blue' }) +const Ordered = styled.div({ color: 'red', styleOrder: 3 })", + ); + for expected in [ + "const Ext = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"button\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\"color-0-blue-_a__c_hover-255 color-0-blue--255 background-0-white--255\", className]", + "const ObjExt = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\"color-0-blue--255 padding-0-8px--255\", className]", + "const Twice = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\"margin-0-4px--255 color-0-blue--255 padding-0-8px--255\", className]", + "const FromRuntime = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = Runtime, forwardedAs, ...rest }) => (({ style, className, as: DevupAs = Changing, forwardedAs, ...rest }) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\"color-0-red--3\", className]", + ] { + assert!(code.contains(expected), "{expected}\n{code}"); + } + } + + // `withComponent` renders the same styles as another tag or component, and + // `as` changes what a styled component renders while `forwardedAs` passes on + #[test] + #[serial] + fn test_styled_with_component_and_as() { + let code = readable_code( + "import {styled} from '@devup-ui/core' +const Section = styled.section.attrs({ role: 'region' })({ color: 'red' }) +export const Aside = Section.withComponent('aside') +export const Linked = Section.withComponent(Link) +export const Nested = Section.withComponent(motion.div) +export const Again = Aside.withComponent('nav') +export const Kept = Section.withComponent(make()) +export const Other = other.withComponent('aside') +export const a =
", + ); + for expected in [ + "export const Aside = __devupForwardRef((__devupRefProps, __devupRef) => ((__devupProps) => (({ style, className, as: DevupAs = \"aside\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\"color-0-red--255\", className]", + "as: DevupAs = Link,", + "as: DevupAs = motion.div,", + "export const Again = __devupForwardRef((__devupRefProps, __devupRef) => ((__devupProps) => (({ style, className, as: DevupAs = \"nav\",", + "export const Kept = Section.withComponent(make());", + "export const Other = other.withComponent(\"aside\");", + "...{ role: \"region\" }", + "
", + ] { + assert!(code.contains(expected), "{expected}\n{code}"); + } + } + + // A styled component keeps the props its styles read, `$` props and `theme` + // away from the tag, follows `shouldForwardProp` as the build evaluates it, + // and an element using it drops what it neither reads nor passes on + #[test] + #[serial] + fn test_styled_prop_forwarding() { + let code = readable_code( + "import {styled} from '@devup-ui/core' +import isPropValid from '@emotion/is-prop-valid' +const A = styled('h1', { shouldForwardProp: (prop) => prop !== 'tone' })({ color: 'red' }) +const B = styled.div`color: ${(p) => p.$c}; background: ${(p) => p.tone};` +const C = styled.div.withConfig({ shouldForwardProp: (prop, valid) => valid(prop) && !['x', 'y'].includes(prop) })({ color: 'red' }) +const D = styled('p', { shouldForwardProp: (prop) => isPropValid(prop) || prop.startsWith('data-') })({ color: 'red' }) +const E = styled('p', { shouldForwardProp: () => true })({ color: 'red' }) +const F = styled(Link)`color: ${(p) => p.$c};` +const G = styled('p', { shouldForwardProp: function (prop) { return prop === 'id' } })({ color: 'red' }) +const H = styled.div`color: ${function (p) { return p.c }};` +const I = styled.div`color: ${({ tone, ...more }) => tone};` +const J = styled.div`color: ${(p) => p[key]}; margin: ${([a]) => a}; padding: ${() => 1};` +const K = styled(A, { shouldForwardProp: (prop) => 'id' === prop || prop === 'title' })({ color: 'blue' }) +const L = styled.div.withConfig(config)`color: ${(p) => { return p.level }}; margin: ${({ [k]: v }) => v};` +const M = styled('p', { shouldForwardProp: (prop) => { return prop === 'id' } })({ color: 'red' }) +export const a = +export const b = +export const j = +export const f = +export const k = ", + ); + for expected in [ + "{...(({ \"tone\": __devupOmit0, ...__devupDom }) => __devupDom)(rest)}", + "{...(({ \"$c\": __devupOmit0, \"tone\": __devupOmit1, \"theme\": __devupOmit2, ...__devupDom }) => __devupDom)(rest)}", + "{...(({ \"x\": __devupOmit0, \"y\": __devupOmit1, \"theme\": __devupOmit2, ...__devupDom }) => __devupDom)(rest)}", + "{...(({ \"$c\": __devupOmit0, ...__devupDom }) => __devupDom)(rest)}", + "export const a = ", + "export const b = ", + "export const j = ", + "export const f = ", + "export const k = ", + "{...(({ \"level\": __devupOmit0, \"theme\": __devupOmit1, ...__devupDom }) => __devupDom)(rest)}", + ] { + assert!(code.contains(expected), "{expected}\n{code}"); + } + let error = readable_code( + "import {styled} from '@devup-ui/core' +const A = styled('h1', { shouldForwardProp: (prop) => allowed.has(prop) })({ color: 'red' }) +const B = styled('h1', { shouldForwardProp: (prop) => prop === other })({ color: 'red' }) +const C = styled('h1', { shouldForwardProp: (prop) => prop > 'a' })({ color: 'red' }) +const D = styled('h1', { shouldForwardProp: (prop) => prop ?? 'a' })({ color: 'red' }) +const E = styled('h1', { shouldForwardProp: (prop) => { const x = 1; return x } })({ color: 'red' }) +const F = styled('h1', { shouldForwardProp: check })({ color: 'red' }) +const G = styled('h1', { shouldForwardProp: ({ a }) => a })({ color: 'red' }) +const H = styled('h1', { shouldForwardProp: (prop) => [x].includes(prop) })({ color: 'red' }) +const I = styled('h1', { shouldForwardProp: (prop) => list.includes(prop) })({ color: 'red' }) +const J = styled('h1', { shouldForwardProp: (prop) => prop.endsWith('a') })({ color: 'red' }) +const K = styled('h1', { [k]: 1, ...o, shouldForwardProp: (prop) => valid(prop, 1) })({ color: 'red' }) +const L = styled('h1', { shouldForwardProp: (prop) => prop === 'a' ?? prop === 'b' })({ color: 'red' }) +const M = styled('h1', { shouldForwardProp: (prop) => x === y })({ color: 'red' }) +const N = styled('h1', { shouldForwardProp: (prop) => { prop === 'a' } })({ color: 'red' })", + ); + assert!( + error.contains("`shouldForwardProp` must be a function"), + "{error}" + ); + assert!(error.contains("allowed.has(prop)"), "{error}"); + } + + #[test] + #[serial] + fn test_styled_attrs_merge_in_order() { + let code = readable_code( + "import {styled} from '@devup-ui/core' +const Base = styled.input.attrs({ type: 'text', title: 'base' })({ color: 'red' }) +const Ext = styled(Base).attrs({ type: 'password' })({ color: 'blue' }) +const X = styled.div.attrs({ className: 'from-attrs', style: { color: 'green' } })({ color: 'red' }) +const F = styled.div.attrs((p) => ({ id: p.id }))({ color: 'red' }) +const V = styled.div.attrs(extra)({ color: 'red' })", + ); + for expected in [ + "...{\n\t\t...__devupProps,\n\t\t...{\n\t\t\ttype: \"text\",\n\t\t\ttitle: \"base\"\n\t\t}\n\t},\n\t...{ type: \"password\" }", + "className: [__devupContext.className, __devupAttrs.className].filter(Boolean).join(\" \") || undefined", + "className: [__devupContext.className, __devupProps.className].filter(Boolean).join(\" \") || undefined", + "...__devupContext.style,\n\t\t...__devupAttrs.style", + "((p) => ({ id: p.id }))(__devupContext)", + "typeof extra === \"function\" ? extra(__devupContext) : extra", + ] { + assert!(code.contains(expected), "{expected}\n{code}"); + } + } + + #[rstest] + #[case( + "", + "className=\"spread\" style={{ opacity: 2 }}" + )] + #[case( + "", + "className=\"direct\" style={{ opacity: 1 }}" + )] + #[case( + "", + "className={(\"className\" in Object(rest) ? rest.className : \"direct\") || \"\"} style={\"style\" in Object(rest) ? rest.style : { opacity: 1 }}" + )] + #[case( + "", + "className=\"last\"" + )] + #[case( + "", + "className={{\n\tclassName: \"first\",\n\t...rest\n}.className || \"\"}" + )] + #[case( + " color=\"red\" />", + "
" + )] + #[case( + "", + "className={(\"className\" in Object(more) ? more.className : rest?.className) || \"\"}" + )] + #[serial] + fn test_jsx_props_written_later_win(#[case] element: &str, #[case] expected: &str) { + let code = readable_code(&format!( + "import {{Box}} from '@devup-ui/core' +export const a = {element}" + )); + assert!(code.contains(expected), "{expected}\n{code}"); + } + + #[test] + #[serial] + fn test_css_composing_reports_runtime_values() { + let code = readable_code( + "import {css} from '@devup-ui/core' +const base = css({ color: 'red' }) +export const a = css(base, { color: tone }) +export const b = css(base, { [key]: 'x', styleOrder: 2 }) +export const c = css(base, getStyles()) +export const d = css(base, { positioning: side, styleOrder: 2 })", + ); + assert!(!code.contains("test.tsx:6:"), "{code}"); + assert!(code.contains("`css()` cannot use `tone`"), "{code}"); + assert!( + code.contains("Cannot compose `\"color-0-red--255\", getStyles()`"), + "{code}" + ); + } + + // The styles of a `css()` class another module exports compose as well + #[test] + #[serial] + fn test_css_composes_imported_classes() { + reset_class_map(); + reset_file_map(); + let modules: &[(&str, &str)] = &[( + "/src/styles.ts", + "import { css, keyframes } from '@devup-ui/react'; +import * as Devup from '@devup-ui/react'; +const brand = 'teal'; +export const base = css({ color: brand, _hover: { color: 'red' } }); +export const danger = css({ color: 'crimson' }); +export const twice = css({ color: 'a' }, { m: 1 }); +export const text = css`color: blue;`; +export const runtime = css({ color: globalThis.tone }); +export const listed = css([{ color: 'b' }]); +export const spaced = Devup.css({ color: 'c' }); +export const fade = keyframes({ from: { opacity: 0 } }); +export const called = Devup({ color: 'e' }); +export const ordered = css({ color: 'f', m: 2, styleOrder: 2 });", + )]; + let resolver = memory_resolver(modules); + css::debug::set_debug(true); + let output = extract_with_modules( + "/src/App.tsx", + "import { css } from '@devup-ui/react'; +import { base, danger, twice, text, runtime, listed, spaced, fade, called, ordered } from './styles'; +export const a = css(base, danger); +export const b = css(danger, base, { m: 1 }); +export const c = css(twice, text, runtime, listed, spaced, fade); +export const d = css(ordered, danger, called);", + ExtractOption { + import_aliases: HashMap::from([( + "@emotion/css".to_string(), + ImportAlias::NamedToNamed, + )]), + ..ExtractOption::default() + }, + false, + &resolver, + ) + .unwrap(); + css::debug::set_debug(false); + for expected in [ + r#"export const a = "color-0-red-_a__c_hover-255-a color-0-crimson--255-a";"#, + r#"export const b = "color-0-red-_a__c_hover-255-a color-0-teal--255-a margin-0-4px--255-a";"#, + "export const c = `${twice} ${text} ${runtime} ${listed} ${spaced} ${fade}`;", + "export const d = `margin-0-8px--2-a color-0-crimson--255-a ${called}`;", + ] { + assert!( + output.code.contains(expected), + "{expected} in {}", + output.code + ); + } + } + #[test] #[serial] fn test_tailwind_classname_extraction() { @@ -18007,26 +18645,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", @@ -18724,7 +19346,7 @@ import orange from './tokens'; import * as tokens from './tokens'; import named from './named'; import { handler } from './handler'; -import cjs, { COMPILED, FROM_REQUIRE, DESTRUCTURED, MUTATED, DYNAMIC } from './cjs-tokens'; +import cjs, { COMPILED, FROM_REQUIRE, DESTRUCTURED, MUTATED, DYNAMIC as CJS_DYNAMIC } from './cjs-tokens'; import cjsObject, { OBJ, OVERRIDDEN, LATER } from './cjs-object'; import twelve from './cjs-value'; import twice from './cjs-twice'; @@ -18740,7 +19362,7 @@ export const f = { PRIMARY, unused, SIZE: tokens.SIZE }; export const g =
; export const h = ; export const i = Devup['css']({ color: SIZE }); -export const l = ; +export const l = ; export const j = ; export const k = styled('div')({ color: SIZE });", ExtractOption { @@ -18778,7 +19400,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 = ;", @@ -20271,7 +20893,6 @@ export const b = 2 ? 1 : 2} />;" .is_ok() ); for code in [ - "const Math = { max: () => 1 };\nexport const a = css({ width: Math.max(1, 2) });", "const Math = { PI: 3 };\nexport const a = css({ width: Math.PI, height: Math.max(1, 2) });", "import Math from './math';\nexport const a = css({ width: Math.max(1, 2) });", "export const a = (Math) => css({ width: Math.max(1, 2) });", @@ -20288,6 +20909,18 @@ export const b = 2 ? 1 : 2} />;" .unwrap_or_default(); assert!(message.contains("Math."), "{code}: {message}"); } + reset_class_map(); + reset_file_map(); + let computed = extract( + "test.tsx", + "import { css } from '@devup-ui/react';\nconst Math = { max: () => 1 };\nexport const a = css({ width: Math.max(1, 2) });", + ExtractOption::default(), + ) + .unwrap(); + assert_eq!( + static_values(&computed), + BTreeSet::from([("width".to_string(), "4px".to_string())]) + ); } #[test] @@ -20750,4 +21383,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 }) => <> -
-
+
+
Option 1
-
+
Option 2
-
+
-
+
Option 3
-
+
Option 4
-
-