From e0a97d44ae33d9c544697cfc6aa46fa1a18c17c3 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 4 Oct 2026 06:06:48 +0900 Subject: [PATCH 1/7] fix(extractor): scope build-time style callee detection Refs #695 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/build_time_values.rs | 8 ++++- .../src/build_time_values/scope_tests.rs | 33 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 libs/extractor/src/build_time_values/scope_tests.rs diff --git a/libs/extractor/src/build_time_values.rs b/libs/extractor/src/build_time_values.rs index f9d85cdf9..38a55b80b 100644 --- a/libs/extractor/src/build_time_values.rs +++ b/libs/extractor/src/build_time_values.rs @@ -345,7 +345,10 @@ impl<'s, 'a> Finder<'s, 'a> { fn is_css(&self, callee: &Expression<'_>) -> bool { if let Expression::StaticMemberExpression(member) = callee { - return member.property.name == "css"; + return member.property.name == "css" + && self + .symbol(&member.object) + .is_some_and(|symbol| self.namespaces.contains(&symbol)); } self.symbol(callee) .is_some_and(|symbol| self.css.contains(&symbol)) @@ -1093,3 +1096,6 @@ fn compute( } (!computed.is_empty()).then_some((computed, changes.dependencies())) } + +#[cfg(test)] +mod scope_tests; diff --git a/libs/extractor/src/build_time_values/scope_tests.rs b/libs/extractor/src/build_time_values/scope_tests.rs new file mode 100644 index 000000000..310d45aac --- /dev/null +++ b/libs/extractor/src/build_time_values/scope_tests.rs @@ -0,0 +1,33 @@ +use super::has_build_time_values; +use crate::ExtractOption; + +#[test] +fn a_local_named_like_a_style_api_computes_nothing() { + let option = ExtractOption::default(); + let prelude = "import { css, Box } from '@devup-ui/react'; +import * as ui from '@devup-ui/react'; +const double = (n) => n * 2; +"; + for code in [ + "export const a = css({ w: double(2) });", + "export const a = ui.css({ w: double(2) });", + "export const a = ;", + "export const a = ;", + ] { + assert!( + has_build_time_values("a.tsx", &format!("{prelude}{code}"), &option, None), + "{code}" + ); + } + for code in [ + "export function f(css) { return css({ w: double(2) }); }", + "export function f(ui) { return ui.css({ w: double(2) }); }", + "export function f(Box) { return ; }", + "export function f(ui) { return ; }", + ] { + assert!( + !has_build_time_values("a.tsx", &format!("{prelude}{code}"), &option, None), + "{code}" + ); + } +} From f091ff1de02b5d16fd65cd8d9529d2bbd6ea04e9 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 4 Oct 2026 06:17:23 +0900 Subject: [PATCH 2/7] fix(extractor): resolve compiled APIs and lowered reads by lexical binding Refs #695 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/css_prop.rs | 252 +++-- libs/extractor/src/css_prop/scope_tests.rs | 94 ++ .../extractor/extract_style_from_styled.rs | 56 +- .../extractor/extract_style_from_stylex.rs | 52 +- libs/extractor/src/imported_constants.rs | 378 +++++--- .../src/imported_constants/scope_tests.rs | 270 ++++++ .../imported_constants/stylex_scope_tests.rs | 225 +++++ libs/extractor/src/lib.rs | 2 + libs/extractor/src/mutations.rs | 62 +- libs/extractor/src/mutations/scope_tests.rs | 164 ++++ libs/extractor/src/scope.rs | 237 +++++ libs/extractor/src/scope/class_names.rs | 39 + libs/extractor/src/scope/lookup.rs | 193 ++++ libs/extractor/src/scope/required.rs | 53 ++ libs/extractor/src/scope/tests.rs | 70 ++ libs/extractor/src/scope/tests/cjs_aliases.rs | 51 + libs/extractor/src/scope/tests/class_names.rs | 45 + libs/extractor/src/scope/tests/components.rs | 177 ++++ .../extractor/src/scope/tests/css_bindings.rs | 125 +++ libs/extractor/src/scope/tests/diagnostics.rs | 178 ++++ libs/extractor/src/scope/tests/imports.rs | 85 ++ .../src/scope/tests/lowered_reads.rs | 124 +++ .../src/scope/tests/preflight_contexts.rs | 206 ++++ libs/extractor/src/scope/tests/scoping.rs | 58 ++ libs/extractor/src/scope/tests/stylex.rs | 105 ++ libs/extractor/src/scope/tests/theme_reads.rs | 99 ++ libs/extractor/src/scope/visits.rs | 230 +++++ libs/extractor/src/style_values.rs | 8 +- libs/extractor/src/stylex.rs | 110 +-- libs/extractor/src/utils.rs | 14 +- libs/extractor/src/visit.rs | 896 ++++++++---------- 31 files changed, 3886 insertions(+), 772 deletions(-) create mode 100644 libs/extractor/src/css_prop/scope_tests.rs create mode 100644 libs/extractor/src/imported_constants/scope_tests.rs create mode 100644 libs/extractor/src/imported_constants/stylex_scope_tests.rs create mode 100644 libs/extractor/src/mutations/scope_tests.rs create mode 100644 libs/extractor/src/scope.rs create mode 100644 libs/extractor/src/scope/class_names.rs create mode 100644 libs/extractor/src/scope/lookup.rs create mode 100644 libs/extractor/src/scope/required.rs create mode 100644 libs/extractor/src/scope/tests.rs create mode 100644 libs/extractor/src/scope/tests/cjs_aliases.rs create mode 100644 libs/extractor/src/scope/tests/class_names.rs create mode 100644 libs/extractor/src/scope/tests/components.rs create mode 100644 libs/extractor/src/scope/tests/css_bindings.rs create mode 100644 libs/extractor/src/scope/tests/diagnostics.rs create mode 100644 libs/extractor/src/scope/tests/imports.rs create mode 100644 libs/extractor/src/scope/tests/lowered_reads.rs create mode 100644 libs/extractor/src/scope/tests/preflight_contexts.rs create mode 100644 libs/extractor/src/scope/tests/scoping.rs create mode 100644 libs/extractor/src/scope/tests/stylex.rs create mode 100644 libs/extractor/src/scope/tests/theme_reads.rs create mode 100644 libs/extractor/src/scope/visits.rs diff --git a/libs/extractor/src/css_prop.rs b/libs/extractor/src/css_prop.rs index 96cb8c59e..2f59dd1d2 100644 --- a/libs/extractor/src/css_prop.rs +++ b/libs/extractor/src/css_prop.rs @@ -7,13 +7,15 @@ use std::collections::HashMap; use oxc_allocator::{CloneIn, FromIn, GetAllocator}; use oxc_ast::ast::{ Argument, ArrowFunctionBody, BinaryOperator, BindingPattern, CallExpression, Expression, - FormalParameters, FunctionBody, ImportDeclarationSpecifier, JSXAttributeItem, JSXChild, - JSXElement, JSXElementName, ObjectPropertyKind, Program, Statement, Str, TemplateElement, - TemplateElementValue, TemplateLiteral, + 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; @@ -101,17 +103,53 @@ impl CssProp { /// What in a program takes the `css` prop: the elements `css_prop` tells, and /// the `jsx()` calls building them pub(crate) struct CssTakers<'s> { - pub css_prop: CssProp, - /// Local names of the functions building elements from a type and props - jsx: FxHashSet<&'s str>, - /// Local names of Emotion's `ClassNames` - class_names: FxHashSet<&'s str>, + 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; - /// `compat` is the entry absorbing Emotion's own `jsx` - pub(crate) fn new(program: &Program<'s>, css_prop: CssProp, compat: &str) -> Self { + /// 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 { @@ -123,28 +161,31 @@ impl<'s> CssTakers<'s> { }; let imported = specifier.imported.name(); if is_jsx_function(source, &imported, compat) { - jsx.insert(specifier.local.name.as_str()); + jsx.extend(specifier.local.symbol_id.get()); } else if imported == "ClassNames" && (source == compat || source == EMOTION_REACT) { - class_names.insert(specifier.local.name.as_str()); + class_names.extend(specifier.local.symbol_id.get()); } } } } Self { css_prop, + scoping, jsx, class_names, } } - /// The names a `` child function `element` gives takes `css` - /// and `cx` by; empty for any other element - pub(crate) fn class_names_calls(&self, element: &JSXElement<'_>) -> Vec { + /// 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 self.class_names.contains(name.name.as_str()) => + if binding_of(self.scoping, name) + .is_some_and(|symbol| self.class_names.contains(&symbol)) => { class_names_calls(element) } @@ -152,25 +193,43 @@ impl<'s> CssTakers<'s> { } } + /// 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 Devup UI component + /// `devup` tells a reference to a Devup UI component pub(crate) fn attribute( &self, name: &JSXElementName<'_>, attribute: &JSXAttributeItem<'_>, - devup: impl Fn(&str) -> bool, + devup: impl Fn(&IdentifierReference<'_>) -> bool, ) -> bool { matches!(attribute, JSXAttributeItem::Attribute(attribute) if attribute.name.as_identifier().is_some_and(|attribute| attribute.name == "css")) - && self.css_prop.takes(name, devup) + && self.takes(name, devup) } /// Where among the props a `jsx()` call gives is the `css` prop the - /// element takes + /// element takes; `devup` tells a reference to a Devup UI component pub(crate) fn property( &self, call: &CallExpression<'_>, - devup: impl Fn(&str) -> bool, + devup: impl Fn(&IdentifierReference<'_>) -> bool, ) -> Option { let Expression::Identifier(callee) = &call.callee else { return None; @@ -178,8 +237,12 @@ impl<'s> CssTakers<'s> { let [element, Argument::ObjectExpression(props), ..] = call.arguments.as_slice() else { return None; }; - (self.jsx.contains(callee.name.as_str()) - && self.css_prop.takes_type(element.as_expression()?, devup)) + 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) @@ -269,12 +332,27 @@ pub(crate) const UNPLACED: &str = 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. +/// 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<'a>( +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 { @@ -298,6 +376,7 @@ pub(crate) fn theme_rules<'a>( let mut reads = ThemeReads { ast, roots: &roots, + resolve, unread: None, }; reads.visit(&mut rules, false); @@ -347,21 +426,42 @@ pub(crate) fn render_function<'b, 'a>( } } -/// The names Emotion's `` child function takes `css`, `cx` and -/// the theme by +/// 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 cx: 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 mut names = ClassNamesParams::default(); + 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; } @@ -388,7 +488,10 @@ pub(crate) fn class_names_params<'a>( "theme" => &mut names.theme, _ => return None, }; - *slot = Some(local.name.as_str()); + *slot = Some(Local { + name: local.name.as_str(), + symbol: local.symbol_id.get(), + }); } } _ => return None, @@ -396,8 +499,10 @@ pub(crate) fn class_names_params<'a>( Some(names) } -/// What the child function of the `` element `element` takes -pub(crate) fn class_names_child<'a>(element: &JSXElement<'a>) -> Option> { +/// 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() @@ -406,60 +511,77 @@ pub(crate) fn class_names_child<'a>(element: &JSXElement<'a>) -> Option &arrow.params, - Expression::FunctionExpression(function) => &function.params, - _ => return None, - }; - class_names_params(params) + 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 names the child function of the `` element `element` +/// The bindings the child function of the `` element `element` /// takes `css` and `cx` by, which read styles -pub(crate) fn class_names_calls(element: &JSXElement<'_>) -> Vec { - class_names_child(element) +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() - .map(str::to_string) + .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; `Err` holds -/// the first read the build cannot write so +/// 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: &'a str, + theme: ThemeRoot<'a>, value: bool, + resolve: Resolve<'_>, ) -> Result<(), Expression<'a>> { - let roots = [(theme, None)]; + let roots = [theme]; let mut reads = ThemeReads { ast, roots: &roots, + resolve, unread: None, }; reads.visit(expression, value); reads.unread.map_or(Ok(()), Err) } -/// The names a function of the theme reads it by, each with the key of the -/// theme it holds when destructured; `None` for parameters the build cannot -/// follow -fn theme_roots<'a>(params: &FormalParameters<'a>) -> Option)>> { +/// 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![(identifier.name.as_str(), None)]) - } + 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() @@ -468,7 +590,11 @@ fn theme_roots<'a>(params: &FormalParameters<'a>) -> Option { - Some((local.name.as_str(), Some(key.to_string()))) + Some(ThemeRoot { + name: local.name.as_str(), + symbol: local.symbol_id.get(), + key: Some(key.to_string()), + }) } _ => None, }, @@ -484,7 +610,8 @@ fn theme_roots<'a>(params: &FormalParameters<'a>) -> Option { ast: &'r AstBuilder<'a>, - roots: &'r [(&'a str, Option)], + roots: &'r [ThemeRoot<'a>], + resolve: Resolve<'r>, unread: Option>, } @@ -521,11 +648,11 @@ impl<'a> ThemeReads<'_, 'a> { } Expression::ParenthesizedExpression(inner) => cursor = &inner.expression, Expression::Identifier(identifier) => { - let (_, key) = self - .roots - .iter() - .find(|(local, _)| *local == identifier.name.as_str())?; - path.extend(key.clone()); + 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) @@ -935,3 +1062,6 @@ mod tests { assert_eq!(expression_to_code(&code), "selector;"); } } + +#[cfg(test)] +mod scope_tests; diff --git a/libs/extractor/src/css_prop/scope_tests.rs b/libs/extractor/src/css_prop/scope_tests.rs new file mode 100644 index 000000000..cdfbb56e7 --- /dev/null +++ b/libs/extractor/src/css_prop/scope_tests.rs @@ -0,0 +1,94 @@ +use oxc_allocator::Allocator; +use oxc_ast::ast::{Expression, IdentifierReference}; +use oxc_ast_visit::Visit; +use oxc_parser::Parser; +use oxc_semantic::{Scoping, SemanticBuilder}; +use oxc_span::SourceType; + +use super::{binding_of, reads_top_level, root_reference}; + +struct Reads<'s> { + scoping: &'s Scoping, + found: Vec<(String, bool, bool)>, +} + +impl<'a> Visit<'a> for Reads<'_> { + fn visit_identifier_reference(&mut self, identifier: &IdentifierReference<'a>) { + self.found.push(( + identifier.name.to_string(), + binding_of(self.scoping, identifier).is_some(), + reads_top_level(self.scoping, identifier), + )); + } +} + +#[test] +fn references_tell_locals_from_top_level_bindings_and_globals() { + let allocator = Allocator::default(); + let program = Parser::new( + &allocator, + "const top = 1; function f(local) { return [top, local, global]; }", + SourceType::tsx(), + ) + .parse() + .program; + let scoping = SemanticBuilder::new() + .build(&program) + .semantic + .into_scoping(); + let mut reads = Reads { + scoping: &scoping, + found: Vec::new(), + }; + reads.visit_program(&program); + assert_eq!( + reads.found, + vec![ + ("top".to_string(), true, true), + ("local".to_string(), true, false), + ("global".to_string(), false, true), + ] + ); +} + +#[test] +fn a_reference_without_semantic_data_reads_a_global() { + let allocator = Allocator::default(); + let program = Parser::new(&allocator, "", SourceType::tsx()) + .parse() + .program; + let scoping = SemanticBuilder::new() + .build(&program) + .semantic + .into_scoping(); + let Ok(Expression::Identifier(identifier)) = + Parser::new(&allocator, "a", SourceType::tsx()).parse_expression() + else { + panic!("a"); + }; + assert_eq!(binding_of(&scoping, &identifier), None); + assert!(reads_top_level(&scoping, &identifier)); +} + +#[test] +fn the_root_of_a_member_chain_is_the_identifier_it_starts_at() { + let allocator = Allocator::default(); + for (code, root) in [ + ("a", Some("a")), + ("a.b", Some("a")), + ("a[b]", Some("a")), + ("a.b[c].d", Some("a")), + ("a()", None), + ("'a'", None), + ] { + let Ok(expression) = Parser::new(&allocator, code, SourceType::tsx()).parse_expression() + else { + panic!("{code}"); + }; + assert_eq!( + root_reference(&expression).map(|identifier| identifier.name.as_str()), + root, + "{code}" + ); + } +} diff --git a/libs/extractor/src/extractor/extract_style_from_styled.rs b/libs/extractor/src/extractor/extract_style_from_styled.rs index c1ceb13df..1571b5093 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, @@ -35,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"; @@ -272,12 +273,16 @@ fn inherited_parts<'a>( inherited .attrs .iter() - .map(|a| a.clone_in(allocator)) + .map(|a| a.clone_in_with_semantic_ids(allocator)) .collect(), ) }, ); - all_attrs.extend(attrs.iter().map(|attr| attr.clone_in(allocator))); + all_attrs.extend( + attrs + .iter() + .map(|attr| attr.clone_in_with_semantic_ids(allocator)), + ); (classes, all_attrs) } @@ -320,7 +325,10 @@ impl<'a> Base<'a> { .iter() .map(|style| style.clone_in(allocator)) .collect(), - attrs: attrs.iter().map(|attr| attr.clone_in(allocator)).collect(), + attrs: attrs + .iter() + .map(|attr| attr.clone_in_with_semantic_ids(allocator)) + .collect(), reads, forward, marker: None, @@ -376,7 +384,7 @@ pub fn extended<'b, 'a>(expression: &'b Expression<'a>) -> Option<&'b Expression 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) => { @@ -397,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 { @@ -409,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()) @@ -457,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) @@ -489,7 +495,7 @@ pub fn extract_style_from_styled<'a>( ast_builder: &AstBuilder<'a>, expression: &mut Expression<'a>, naming: Naming<'_>, - imports: &FxHashMap, + imports: Kinds<'_>, attrs: &[Expression<'a>], inherited: Option<&StyledDefinition<'a>>, forward: Option, @@ -731,7 +737,7 @@ fn clone_all<'s, 'a>( ) -> impl Iterator> + 's { expressions .iter() - .map(|expression| expression.clone_in(ast_builder.allocator())) + .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 @@ -744,7 +750,7 @@ const ATTRS_PROPS: &str = "__devupProps"; pub fn take_styled_modifiers<'a>( ast_builder: &AstBuilder<'a>, factory: &mut Expression<'a>, - is_styled: impl Fn(&str) -> bool, + is_styled: impl Fn(&Expression<'_>) -> bool, ) -> (Vec>, Vec>) { let mut attrs = Vec::new(); let mut configs = Vec::new(); @@ -815,7 +821,10 @@ pub fn read_forward(options: &[&Expression<'_>]) -> (Option, Option<(u3 const SHOULD_FORWARD_PROP: &str = "`shouldForwardProp` must be a function of the prop name that compares it with strings, `[...].includes(prop)`, `prop.startsWith(...)` or `isPropValid(prop)`, joined by `!`, `&&` and `||`"; -fn is_modified_styled(expression: &Expression<'_>, is_styled: impl Fn(&str) -> bool) -> bool { +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) { @@ -825,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, } @@ -894,7 +903,11 @@ fn sets_plain_props(attr: &Expression<'_>) -> bool { 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(ast_builder.allocator())); + merged = spread_objects( + ast_builder, + merged, + attr.clone_in_with_semantic_ids(ast_builder.allocator()), + ); } merged } @@ -918,7 +931,10 @@ fn merge_attrs<'a>(ast_builder: &AstBuilder<'a>, attrs: &[Expression<'a>]) -> Ex oxc_allocator::Vec::new_in(ast_builder), ast_builder, ), - resolve_attrs(ast_builder, attr.clone_in(ast_builder.allocator())), + resolve_attrs( + ast_builder, + attr.clone_in_with_semantic_ids(ast_builder.allocator()), + ), ); let merge = call_with_values( ast_builder, @@ -968,7 +984,7 @@ fn resolve_attrs<'a>(ast_builder: &AstBuilder<'a>, attr: Expression<'a>) -> Expr Expression::new_unary_expression( SPAN, UnaryOperator::Typeof, - attr.clone_in(ast_builder.allocator()), + attr.clone_in_with_semantic_ids(ast_builder.allocator()), ast_builder, ), BinaryOperator::StrictEquality, diff --git a/libs/extractor/src/extractor/extract_style_from_stylex.rs b/libs/extractor/src/extractor/extract_style_from_stylex.rs index 69ff713a8..5440b92a6 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/imported_constants.rs b/libs/extractor/src/imported_constants.rs index 03cb5c9b5..661669134 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}; @@ -22,7 +23,7 @@ use oxc_syntax::symbol::SymbolId; use rustc_hash::{FxHashMap, FxHashSet}; use crate::composition::{Composition, set_prop_order}; -use crate::css_prop::{CssProp, CssTakers}; +use crate::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, @@ -165,6 +166,10 @@ pub(crate) struct Inlined { 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 @@ -181,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())); @@ -195,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; } @@ -261,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 { @@ -270,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; @@ -314,7 +346,9 @@ 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>, @@ -323,37 +357,72 @@ pub(crate) fn inline_constants<'a>( resolver: Option<&ModuleResolver>, css_prop: CssProp, ) -> Inlined { - let is_style_package = - |source: &str| source.starts_with(&option.package) || source == crate::STYLEX_PACKAGE; - let mut style_roots = FxHashSet::default(); - let mut apis = StyleApis::default(); - let css_props = CssTakers::new(program, css_prop, &format!("{}/compat", option.package)); + let imports_style_package = program.body.iter().any(|statement| { + matches!(statement, Statement::ImportDeclaration(import) + if is_style_package(option, &import.source.value)) + }); + if !imports_style_package && css_prop == CssProp::Off { + return Inlined::default(); + } + let scoping = Rc::new( + SemanticBuilder::new() + .build(program) + .semantic + .into_scoping(), + ); + let mut inlined = inline_in( + &scoping, + ast_builder, + program, + filename, + option, + resolver, + css_prop, + ); + inlined.scoping = Some(scoping); + inlined +} + +fn is_style_package(option: &ExtractOption, source: &str) -> bool { + source.starts_with(&option.package) || source == crate::STYLEX_PACKAGE +} + +fn inline_in<'a>( + scoping: &Scoping, + ast_builder: &AstBuilder<'a>, + program: &mut Program<'a>, + filename: &str, + option: &ExtractOption, + resolver: Option<&ModuleResolver>, + css_prop: CssProp, +) -> Inlined { + let 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() && css_prop == CssProp::Off { - return Inlined::default(); - } let mut read = StyleReads { - style_roots: &style_roots, + style: &style, css_props: &css_props, names: FxHashSet::default(), depth: 0, @@ -371,17 +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.css_props = Some(&css_props); + 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(); @@ -435,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); @@ -496,17 +565,16 @@ pub(crate) fn inline_constants<'a>( 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, + style: &style, css_props: &css_props, - apis: &apis, objects: false, styles: false, px: false, @@ -589,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 { @@ -609,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(...)` @@ -619,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()); @@ -641,31 +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, - /// The names the `` child functions around take `css` and + /// The bindings the `` child functions around take `css` and /// `cx` by - class_names: Vec, -} - -/// Whether `expression` is a style API: a root the package gives, or a member -/// 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, - } + 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); @@ -675,8 +764,8 @@ 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()); } } @@ -691,11 +780,13 @@ impl<'a> Visit<'a> for StyleReads<'_> { 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) || calls_one_of(&self.class_names, &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, |root| self.style_roots.contains(root)); + .property(call, |identifier| self.style.has(identifier)); self.reading(style, |reads| { for (index, argument) in call.arguments.iter().enumerate() { match (css, argument) { @@ -717,17 +808,22 @@ 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) || calls_one_of(&self.class_names, &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)); + let style = self.style.is_component(&element.name); for attribute in &element.attributes { let style = style - || self.css_props.attribute(&element.name, attribute, |root| { - self.style_roots.contains(root) - }); + || 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 { @@ -742,15 +838,12 @@ impl<'a> Visit<'a> for StyleReads<'_> { } } -/// Whether `callee` is one of `names` -fn calls_one_of(names: &[String], callee: &Expression<'_>) -> bool { - matches!(callee, Expression::Identifier(callee) if names.iter().any(|name| name == callee.name.as_str())) -} - -/// The name `` or `` starts with -pub(crate) fn jsx_root<'n>(name: &'n JSXElementName<'_>) -> Option<&'n str> { +/// 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 { @@ -758,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, } @@ -767,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>, @@ -1048,10 +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 - css_props: Option<&'p CssTakers<'p>>, + /// 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> { @@ -1065,9 +1170,11 @@ impl<'p, 'a> ModuleScope<'p, 'a> { imports: FxHashMap::default(), style_imports: FxHashSet::default(), style_names: FxHashSet::default(), - css_props: None, + css_prop: None, uses: None, changes: FxHashMap::default(), + shared_scoping: None, + scoping: OnceCell::new(), } } @@ -1175,7 +1282,7 @@ impl<'p, 'a> ModuleScope<'p, 'a> { let uses = Rc::new(crate::mutations::uses( self.program, &|name| self.is_style_import(option, name), - self.css_props, + self.css_prop, )); self.uses = Some(uses.clone()); uses @@ -1364,25 +1471,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 @@ -1460,7 +1583,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() { @@ -1803,18 +1926,17 @@ struct Inline<'s, 'a> { ast_builder: &'s AstBuilder<'a>, scoping: &'s Scoping, symbols: &'s FxHashMap, - style_roots: &'s FxHashSet<&'s str>, + style: &'s StyleSymbols<'s>, css_props: &'s CssTakers<'s>, - apis: &'s StyleApis<'s>, /// Inside what the build reads as style objects objects: bool, /// Inside the arguments of a style API or a style prop styles: bool, /// Inside a `css` prop, whose numbers Emotion reads as `px` lengths px: bool, - /// The names the `` child functions around take `css` and + /// The bindings the `` child functions around take `css` and /// `cx` by - class_names: Vec, + class_names: Vec, } impl<'a> Inline<'_, 'a> { @@ -2092,13 +2214,16 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { tagged: &mut oxc_ast::ast::TaggedTemplateExpression<'a>, ) { self.visit_expression(&mut tagged.tag); - if calls_one_of(&self.class_names, &tagged.tag) { + 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 = is_style_root(self.style_roots, &tagged.tag); + let styles = self.style.is_root(&tagged.tag); self.reading_styles(styles, |inline| { inline.visit_template_literal(&mut tagged.quasi); }); @@ -2120,7 +2245,10 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { fn visit_call_expression(&mut self, call: &mut oxc_ast::ast::CallExpression<'a>) { self.visit_expression(&mut call.callee); - if calls_one_of(&self.class_names, &call.callee) { + 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); @@ -2128,11 +2256,11 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { }); return; } - let objects = self.apis.reads(&call.callee); - let styles = is_style_root(self.style_roots, &call.callee); + let objects = self.style.reads(&call.callee); + let styles = self.style.is_root(&call.callee); let css = self .css_props - .property(call, |root| self.style_roots.contains(root)); + .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() { @@ -2152,12 +2280,15 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { } fn visit_jsx_opening_element(&mut self, element: &mut oxc_ast::ast::JSXOpeningElement<'a>) { - let styled = jsx_root(&element.name).is_some_and(|root| self.style_roots.contains(root)); + let 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, |root| { - self.style_roots.contains(root) - }) { + 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; } @@ -2190,3 +2321,8 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { } } } + +#[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 000000000..e6c5523a8 --- /dev/null +++ b/libs/extractor/src/imported_constants/scope_tests.rs @@ -0,0 +1,270 @@ +use std::rc::Rc; + +use oxc_allocator::Allocator; +use oxc_ast::ast::Expression; +use oxc_ast::builder::AstBuilder; +use oxc_codegen::Codegen; +use oxc_parser::Parser; +use oxc_span::SourceType; +use rustc_hash::FxHashMap; + +use super::{Change, ChangeSite, Changed, Inlined, Unknown, inline_constants}; +use crate::ExtractOption; +use crate::css_prop::CssProp; + +fn inline(code: &str, css_prop: CssProp) -> (String, Inlined) { + let allocator = Allocator::default(); + let mut program = Parser::new(&allocator, code, SourceType::tsx()) + .parse() + .program; + let inlined = inline_constants( + &AstBuilder::new(&allocator), + &mut program, + "a.tsx", + &ExtractOption::default(), + None, + css_prop, + ); + (Codegen::new().build(&program).code, inlined) +} + +fn expression<'a>(allocator: &'a Allocator, code: &'a str) -> Expression<'a> { + Parser::new(allocator, code, SourceType::tsx()) + .parse_expression() + .unwrap_or_else(|_| panic!("{code}")) +} + +#[test] +fn a_local_named_like_a_style_function_reads_no_styles() { + let (code, _) = inline( + "import { css } from '@devup-ui/react'; +import * as ui from '@devup-ui/react'; +const SIZE = 4; +export const own = css({ w: SIZE }); +export const member = ui.css({ w: SIZE }); +export function shadowed(css, ui) { return [css({ w: SIZE }), ui.css({ w: SIZE }), parseInt('1')]; }", + CssProp::Off, + ); + assert_eq!(code.matches("w: 4").count(), 2, "{code}"); + assert_eq!(code.matches("w: SIZE").count(), 2, "{code}"); +} + +#[test] +fn a_local_named_like_a_style_component_reads_no_styles() { + let (code, _) = inline( + "import { Box } from '@devup-ui/react'; +import * as ui from '@devup-ui/react'; +const COLOR = 'red'; +export const own = <>; +export function shadowed(Box, ui) { return <>; }", + CssProp::Off, + ); + assert_eq!(code.matches("{COLOR}").count(), 2, "{code}"); +} + +#[test] +fn a_local_named_like_a_class_names_binding_reads_no_styles() { + let (code, _) = inline( + "import { ClassNames } from '@emotion/react'; +const SIZE = 4; +export const own = {({ css }) => css({ width: SIZE })}; +export const nested = {({ css }) => ((css) => css({ width: SIZE }))(css)}; +export const tagged = {({ cx }) => cx`width: ${SIZE}px`}; +export function shadowed(ClassNames) { return {({ css }) => css({ width: SIZE })}; }", + CssProp::Elements, + ); + assert_eq!(code.matches("width: SIZE").count(), 2, "{code}"); + assert_eq!(code.matches("width: \"4px\"").count(), 1, "{code}"); + assert_eq!(code.matches("${SIZE}").count(), 0, "{code}"); +} + +#[test] +fn a_local_named_like_the_jsx_function_takes_no_css_prop() { + let (code, _) = inline( + "import { jsx } from '@emotion/react'; +import { Box } from '@devup-ui/react'; +const SIZE = 4; +export const tag = jsx('div', { css: { width: SIZE } }); +export const component = jsx(Box, { css: { width: SIZE } }); +export function shadowedJsx(jsx) { return jsx('div', { css: { width: SIZE } }); } +export function shadowedBox(Box) { return jsx(Box, { css: { width: SIZE } }); }", + CssProp::Elements, + ); + assert_eq!(code.matches("width: \"4px\"").count(), 2, "{code}"); + assert_eq!(code.matches("width: SIZE").count(), 2, "{code}"); +} + +#[test] +fn a_local_named_like_a_style_component_takes_no_css_attribute() { + let (code, _) = inline( + "import { Box } from '@devup-ui/react'; +const SIZE = 4; +export const own = ; +export const tag =
; +export const custom = ; +export function shadowed(Box) { return ; }", + CssProp::Elements, + ); + assert_eq!(code.matches("width: \"4px\"").count(), 2, "{code}"); + assert_eq!(code.matches("width: SIZE").count(), 2, "{code}"); +} + +#[test] +fn names_only_locals_read_are_not_reported_unknown_or_changed() { + let (_, shadowed) = inline( + "import { css } from '@devup-ui/react'; +const unknown = compute(); +const table = { a: 1 }; +table.a = 2; +export function f(unknown, table) { return css({ w: unknown, h: table.a }); }", + CssProp::Off, + ); + assert!(shadowed.unknown.is_empty()); + assert!(shadowed.changed.is_empty()); + + let (_, read) = inline( + "import { css } from '@devup-ui/react'; +const unknown = compute(); +const table = { a: 1 }; +table.a = 2; +export const a = css({ w: unknown, h: table.a });", + CssProp::Off, + ); + assert!(!read.unknown.is_empty()); + assert!(!read.changed.is_empty()); +} + +#[test] +fn the_scoping_built_is_kept_on_every_return_after_it() { + for (code, css_prop, kept) in [ + ("const SIZE = 4;\nexport const a = 1;", CssProp::Off, false), + ( + "import { css } from '@devup-ui/react';\nconst SIZE = 4;", + CssProp::Off, + true, + ), + ( + "import { css } from '@devup-ui/react';\nconst SIZE = 4;\ncss({ w: 1 });", + CssProp::Off, + true, + ), + ( + "import { css } from '@devup-ui/react';\nconst SIZE = 4;\ncss({ w: Math.max(1, 2) });", + CssProp::Off, + true, + ), + ( + "import { css } from '@devup-ui/react';\nconst SIZE = 4;\ncss({ w: SIZE });", + CssProp::Off, + true, + ), + ( + "const SIZE = 4;\nexport const a =
;", + CssProp::Elements, + true, + ), + ] { + let (_, inlined) = inline(code, css_prop); + assert_eq!(inlined.scoping.is_some(), kept, "{code}"); + if let Some(scoping) = &inlined.scoping { + assert!(scoping.get_root_binding("SIZE".into()).is_some(), "{code}"); + } + } +} + +struct Properties<'s> { + inlined: &'s Inlined, + scoping: &'s oxc_semantic::Scoping, + found: Vec<(bool, bool)>, +} + +impl<'a> oxc_ast_visit::Visit<'a> for Properties<'_> { + fn visit_object_property(&mut self, property: &oxc_ast::ast::ObjectProperty<'a>) { + let reads = |identifier: &oxc_ast::ast::IdentifierReference<'_>| { + crate::css_prop::reads_top_level(self.scoping, identifier) + }; + self.found.push(( + self.inlined.unknown.read_by_in(&property.value, &reads), + self.inlined.changed.read_by_in(&property.value, &reads), + )); + } +} + +#[test] +fn the_scoping_kept_guards_reads_of_unknown_and_changed_bindings() { + let allocator = Allocator::default(); + let mut program = Parser::new( + &allocator, + "import { css } from '@devup-ui/react'; +const unknown = compute(); +const table = { a: 1 }; +table.a = 2; +export const top = css({ w: unknown, h: table.a }); +export function shadowed(unknown, table) { return css({ w: unknown, h: table.a }); }", + SourceType::tsx(), + ) + .parse() + .program; + let inlined = inline_constants( + &AstBuilder::new(&allocator), + &mut program, + "a.tsx", + &ExtractOption::default(), + None, + CssProp::Off, + ); + let Some(scoping) = inlined.scoping.clone() else { + panic!("no scoping kept"); + }; + let mut properties = Properties { + inlined: &inlined, + scoping: &scoping, + found: Vec::new(), + }; + oxc_ast_visit::Visit::visit_program(&mut properties, &program); + assert_eq!( + properties.found, + vec![ + (false, false), + (true, false), + (false, true), + (false, false), + (false, false) + ] + ); +} + +#[test] +fn unknown_counts_only_the_identifiers_accepted() { + let allocator = Allocator::default(); + let unknown = Unknown { + names: std::iter::once("x".to_string()).collect(), + partial: FxHashMap::default(), + }; + for code in ["x", "x()", "x[k]", "x.y[k]", "x.y()"] { + let expression = expression(&allocator, code); + assert!(unknown.read_by_in(&expression, &|_| true), "{code}"); + assert!(!unknown.read_by_in(&expression, &|_| false), "{code}"); + } + let expression = expression(&allocator, "y[x]"); + assert!(!unknown.read_by_in(&expression, &|_| true)); +} + +#[test] +fn changed_counts_only_the_identifiers_accepted() { + let allocator = Allocator::default(); + let change = Rc::new(Change { + name: "x".to_string(), + site: ChangeSite::Here(0), + handed: false, + }); + let changed = Changed { + whole: FxHashMap::from_iter([("x".to_string(), change)]), + holding: FxHashMap::default(), + }; + for code in ["x", "x.y", "x[k]"] { + let expression = expression(&allocator, code); + assert!(changed.read_by_in(&expression, &|_| true), "{code}"); + assert!(!changed.read_by_in(&expression, &|_| false), "{code}"); + } +} diff --git a/libs/extractor/src/imported_constants/stylex_scope_tests.rs b/libs/extractor/src/imported_constants/stylex_scope_tests.rs new file mode 100644 index 000000000..ea6d77bf5 --- /dev/null +++ b/libs/extractor/src/imported_constants/stylex_scope_tests.rs @@ -0,0 +1,225 @@ +use oxc_allocator::Allocator; +use oxc_ast::ast::{CallExpression, Expression, Statement}; +use oxc_ast_visit::{Visit, walk}; +use oxc_parser::Parser; +use oxc_span::SourceType; + +use super::ModuleScope; +use crate::stylex::StylexFunction; + +/// The `StyleX` API the callee of each call reads, and the one its member +/// object reads, in source order +struct Callees<'s, 'p, 'a> { + scope: &'s ModuleScope<'p, 'a>, + found: Vec<(Option, Option)>, +} + +impl<'a> Visit<'a> for Callees<'_, '_, 'a> { + fn visit_call_expression(&mut self, call: &CallExpression<'a>) { + let object = match &call.callee { + Expression::StaticMemberExpression(member) => { + self.scope.stylex_function(&member.object) + } + _ => None, + }; + self.found + .push((self.scope.stylex_function(&call.callee), object)); + walk::walk_call_expression(self, call); + } +} + +fn read(code: &str) -> Vec<(Option, Option)> { + let allocator = Allocator::default(); + let program = Parser::new(&allocator, code, SourceType::tsx()) + .parse() + .program; + let mut scope = ModuleScope::new("a.tsx", &program, None); + for statement in &program.body { + if let Statement::ImportDeclaration(import) = statement { + scope.import(import); + } + } + let mut callees = Callees { + scope: &scope, + found: Vec::new(), + }; + callees.visit_program(&program); + callees.found +} + +#[test] +fn a_helper_is_the_import_its_callee_reads_and_not_its_spelling() { + let found = read( + "import * as sx from '@stylexjs/stylex'; +import { firstThatWorks as ftw, types as t, include } from '@stylexjs/stylex'; +import { include as other } from 'elsewhere'; +sx.firstThatWorks(a); +ftw(a); +include(a); +other(a); +t.color(a); +sx.types.color(a); +function shadowed(sx, ftw, include, t) { + sx.firstThatWorks(a); + ftw(a); + include(a); + t.color(a); + sx.types.color(a); +}", + ); + assert_eq!( + found, + vec![ + (Some(StylexFunction::FirstThatWorks), None), + (Some(StylexFunction::FirstThatWorks), None), + (Some(StylexFunction::Include), None), + (None, None), + (None, Some(StylexFunction::Types)), + (None, Some(StylexFunction::Types)), + (None, None), + (None, None), + (None, None), + (None, None), + (None, None), + ] + ); +} + +fn extracted(code: &str) -> Result { + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + crate::extract( + "test.tsx", + code, + crate::ExtractOption { + import_main_css: false, + ..crate::ExtractOption::default() + }, + ) + .map_err(|error| error.to_string()) +} + +fn declarations(output: &crate::ExtractOutput) -> Vec<(String, String)> { + let mut found: Vec<(String, String)> = output + .styles + .iter() + .filter_map(|style| match style { + crate::ExtractStyleValue::Static(style) => { + Some((style.property.clone(), style.value.clone())) + } + _ => None, + }) + .collect(); + found.sort(); + found +} + +fn error_of(code: &str) -> String { + extracted(code).err().unwrap_or_default() +} + +const SX: &str = "import * as sx from '@stylexjs/stylex';\n"; + +#[test] +#[serial_test::serial] +fn genuine_helpers_compile_through_every_import_spelling() { + let output = extracted( + "import * as sx from '@stylexjs/stylex'; +import { create, defineVars, firstThatWorks as ftw, include as inc, types as t } from '@stylexjs/stylex'; +const base = create({ a: { color: 'red' } }); +const vars = defineVars({ size: t.length('1px'), tone: sx.types.color({ default: 'blue' }) }); +const styles = sx.create({ + b: { backgroundColor: ftw('red', 'blue'), width: vars.size, borderColor: vars.tone }, + c: { ...inc(base.a), height: sx.firstThatWorks('1px', '2px') }, +}); +export const A = () =>
;", + ) + .unwrap_or_else(|error| panic!("{error}")); + assert_eq!( + declarations(&output), + vec![ + ("background-color".to_string(), "blue".to_string()), + ("background-color".to_string(), "red".to_string()), + ("border-color".to_string(), "var(--a-c)".to_string()), + ("color".to_string(), "red".to_string()), + ("height".to_string(), "1px".to_string()), + ("height".to_string(), "2px".to_string()), + ("width".to_string(), "var(--a-b)".to_string()), + ] + ); +} + +#[test] +#[serial_test::serial] +fn shadowed_and_unrelated_helpers_are_reported_at_build_time() { + for (code, expected) in [ + ( + "export function f(firstThatWorks) { return sx.create({ a: { color: firstThatWorks('red', 'blue') } }); }", + "`stylex.create()` cannot use `firstThatWorks(", + ), + ( + "export function f(o) { return sx.create({ a: { color: o.firstThatWorks('red') } }); }", + "`stylex.create()` cannot use `o.firstThatWorks(", + ), + ( + "const base = sx.create({ a: { color: 'red' } });\nexport function f(include) { return sx.create({ b: { ...include(base.a) } }); }", + "`stylex.create()` cannot use `...include(", + ), + ( + "const base = sx.create({ a: { color: 'red' } });\nexport function f(o) { return sx.create({ b: { ...o.include(base.a) } }); }", + "`stylex.create()` cannot use `...o.include(", + ), + ( + "export function f(types) { return sx.defineVars({ c: types.color('red') }); }", + "`stylex.defineVars()` cannot use `types.color(", + ), + ( + "export function f(o) { return sx.defineVars({ c: o.types.color('red') }); }", + "`stylex.defineVars()` cannot use `o.types.color(", + ), + ( + "export const p = sx.positionTry({ top: sx.firstThatWorks('1px') });", + "`stylex.positionTry()` cannot use `sx.firstThatWorks(", + ), + ( + "export function f(firstThatWorks) { return sx.positionTry({ top: firstThatWorks('1px') }); }", + "`stylex.positionTry()` cannot use `firstThatWorks(", + ), + ] { + let message = error_of(&format!("{SX}{code}")); + assert!(message.contains(expected), "{expected}: {message}"); + } +} + +#[test] +#[serial_test::serial] +fn aliased_types_read_in_an_imported_module_still_publish_their_variables() { + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + let resolver = |specifier: &str, _: &str| { + (specifier == "./vars").then(|| crate::ResolvedModule { + path: "/src/vars.ts".to_string(), + code: "import { defineVars, types as t } from '@stylexjs/stylex'; +export const vars = defineVars({ tone: t.color('red'), size: t.length(unknown()) });" + .to_string(), + }) + }; + let output = crate::extract_with_modules( + "/src/App.tsx", + "import * as sx from '@stylexjs/stylex'; +import { vars } from './vars'; +const s = sx.create({ a: { color: vars.tone } }); +export const A = () =>
;", + crate::ExtractOption { + import_main_css: false, + ..crate::ExtractOption::default() + }, + false, + &resolver, + ) + .unwrap_or_else(|error| panic!("{error}")); + assert_eq!( + declarations(&output), + vec![("color".to_string(), "var(--b-a)".to_string())] + ); +} diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index d8e1e1d1a..d3c60b13a 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -15,6 +15,7 @@ mod module_loader; mod mutations; mod prop_modify_utils; mod prop_valid; +mod scope; mod source_map; mod style_values; mod styled_reads; @@ -479,6 +480,7 @@ fn extract_source( visitor.unknown_bindings(&inlined.unknown); visitor.changed_bindings(inlined.changed.clone()); visitor.takes_css_prop(css_prop); + visitor.reuse_scoping(inlined.scoping); visitor.visit_program(&mut program); if !has_relevant_import && alias_edits.is_empty() && !visitor.compiled_css_prop { // No element took the `css` prop the text seemed to give diff --git a/libs/extractor/src/mutations.rs b/libs/extractor/src/mutations.rs index e140a52ff..529a8a8c8 100644 --- a/libs/extractor/src/mutations.rs +++ b/libs/extractor/src/mutations.rs @@ -4,17 +4,20 @@ use oxc_ast::AstKind; use oxc_ast::ast::{ - Expression, JSXAttributeName, JSXElementName, ObjectPropertyKind, Program, + Expression, IdentifierReference, JSXAttributeName, JSXElementName, ObjectPropertyKind, Program, VariableDeclarationKind, }; use oxc_ast_visit::{Visit, walk}; -use oxc_semantic::{AstNodes, SemanticBuilder}; +use oxc_semantic::{AstNodes, Scoping, SemanticBuilder}; use oxc_span::GetSpan; use oxc_syntax::node::NodeId; use oxc_syntax::operator::UnaryOperator; use oxc_syntax::scope::ScopeFlags; use rustc_hash::FxHashMap; +use crate::css_prop::{CssProp, CssTakers, binding_of}; +use crate::imported_constants::jsx_root_identifier; + /// How code uses a top-level binding #[derive(Debug)] pub(crate) enum Use { @@ -105,10 +108,12 @@ const READING_FUNCTIONS: [&str; 8] = [ /// The uses of each top-level binding of `program` that may change what it /// holds; `style` tells the names of the style APIs, whose arguments the /// build reads and which never run, and `css` the `css` props the build reads +/// and the entry absorbing Emotion's own `jsx`. A name only counts for a +/// reference to the binding of the module, never to a local of the same name. pub(crate) fn uses( program: &Program<'_>, style: &dyn Fn(&str) -> bool, - css: Option<&crate::css_prop::CssTakers<'_>>, + css: Option<(CssProp, &str)>, ) -> FxHashMap> { let semantic = SemanticBuilder::new() .with_build_nodes(true) @@ -116,7 +121,7 @@ pub(crate) fn uses( .semantic; let scoping = semantic.scoping(); let nodes = semantic.nodes(); - let is_global = |name: &str| scoping.get_root_binding(name.into()).is_none(); + let takers = css.map(|(css_prop, compat)| CssTakers::new(program, scoping, css_prop, compat)); let mut uses: FxHashMap> = FxHashMap::default(); for (name, symbol) in scoping.get_bindings(scoping.root_scope_id()) { let init = match nodes.kind(scoping.symbol_declaration(*symbol)) { @@ -125,9 +130,9 @@ pub(crate) fn uses( }; let context = Context { nodes, + scoping, style, - css, - is_global: &is_global, + css: takers.as_ref(), init, }; let found: Vec = scoping @@ -146,9 +151,9 @@ pub(crate) fn uses( struct Context<'s, 'a> { nodes: &'s AstNodes<'a>, + scoping: &'s Scoping, style: &'s dyn Fn(&str) -> bool, - css: Option<&'s crate::css_prop::CssTakers<'s>>, - is_global: &'s dyn Fn(&str) -> bool, + css: Option<&'s CssTakers<'s>>, /// What the binding is declared as, when a `const` or `let` gives it init: Option<&'s Expression<'a>>, } @@ -371,12 +376,12 @@ impl Context<'_, '_> { match callee { Expression::Identifier(identifier) if READING_FUNCTIONS.contains(&identifier.name.as_str()) - && (self.is_global)(&identifier.name) => + && self.is_global(identifier) => { Some(("", identifier.name.as_str())) } Expression::StaticMemberExpression(member) => match &member.object { - Expression::Identifier(object) if (self.is_global)(&object.name) => { + Expression::Identifier(object) if self.is_global(object) => { Some((object.name.as_str(), member.property.name.as_str())) } _ => None, @@ -406,12 +411,12 @@ impl Context<'_, '_> { /// Whether `callee`, called at `id`, is the `css` or `cx` a `` /// child function around takes, whose calls the build compiles fn is_class_names_call(&self, id: NodeId, callee: &Expression<'_>) -> bool { - let (Some(css), Expression::Identifier(callee)) = (self.css, callee) else { + let Some(css) = self.css else { return false; }; self.nodes.ancestor_ids(id).any(|ancestor| { matches!(self.nodes.kind(ancestor), AstKind::JSXElement(element) - if css.class_names_calls(element).iter().any(|name| name == callee.name.as_str())) + if css.calls_class_names(&css.class_names_calls(element), callee)) }) } @@ -424,7 +429,7 @@ impl Context<'_, '_> { .as_identifier() .is_some_and(|name| name.name == "css") && matches!(self.nodes.parent_kind(id), AstKind::JSXOpeningElement(element) - if css.css_prop.takes(&element.name, |root| (self.style)(root))) + if css.takes(&element.name, |identifier| self.is_style_reference(identifier))) }) } @@ -434,7 +439,7 @@ impl Context<'_, '_> { self.css.is_some_and(|css| { matches!(self.nodes.parent_kind(self.nodes.parent_id(id)), AstKind::CallExpression(call) if matches!(call.arguments.get(1), Some(oxc_ast::ast::Argument::ObjectExpression(props)) - if css.property(call, |root| (self.style)(root)) + if css.property(call, |identifier| self.is_style_reference(identifier)) .and_then(|at| props.properties.get(at)) .is_some_and(|css| css.span() == property.span))) }) @@ -509,7 +514,7 @@ impl Context<'_, '_> { fn is_commonjs_export(&self, member: &oxc_ast::ast::MemberExpression<'_>) -> bool { let is_global = |expression: &Expression<'_>, name: &str| { matches!(expression, Expression::Identifier(identifier) - if identifier.name == name && (self.is_global)(name)) + if identifier.name == name && self.is_global(identifier)) }; match member.object() { Expression::StaticMemberExpression(inner) => { @@ -523,11 +528,28 @@ impl Context<'_, '_> { } } + /// Whether `identifier` reads a global, and not a local or an import of + /// the module that shares the name + fn is_global(&self, identifier: &IdentifierReference<'_>) -> bool { + binding_of(self.scoping, identifier).is_none() + } + + /// Whether `identifier` reads the top-level binding the style predicate + /// names: not a local of the same name, and not a global + fn is_style_reference(&self, identifier: &IdentifierReference<'_>) -> bool { + (self.style)(&identifier.name) + && binding_of(self.scoping, identifier).is_some_and(|symbol| { + self.scoping + .get_root_binding(identifier.name.as_str().into()) + == Some(symbol) + }) + } + fn is_style(&self, callee: &Expression<'_>) -> bool { let mut expression = callee; loop { match crate::utils::unwrap_syntax_only(expression) { - Expression::Identifier(identifier) => return (self.style)(&identifier.name), + Expression::Identifier(identifier) => return self.is_style_reference(identifier), Expression::StaticMemberExpression(member) => expression = &member.object, Expression::CallExpression(call) => expression = &call.callee, _ => return false, @@ -536,7 +558,7 @@ impl Context<'_, '_> { } fn is_style_element(&self, name: &JSXElementName<'_>) -> bool { - crate::imported_constants::jsx_root(name).is_some_and(|root| (self.style)(root)) + jsx_root_identifier(name).is_some_and(|identifier| self.is_style_reference(identifier)) } } @@ -668,7 +690,8 @@ await a;" #[test] fn reads() { insta::assert_debug_snapshot!(describe( - "const a = { x: 1, arrow: () => 1, plain() { return 1; }, nested() { return function () { return this; }; }, self() { return this; } }; + "import { css, Box } from '@devup-ui/react'; import * as Devup from '@devup-ui/react'; +const a = { x: 1, arrow: () => 1, plain() { return 1; }, nested() { return function () { return this; }; }, self() { return this; } }; String(a.x); parseInt(a.x); structuredClone(a); Object.keys(a); Object.freeze(a); Object.hasOwn(a, 'x'); Object.getOwnPropertyNames(a); JSON.stringify(a); Math.max(a.x); console.log(a); Array.isArray(a); css(a); css({ ...a, color: f(a) }); Devup.css(a); css.x`${a}`; css(1)(a); css({ w: a.self() }); ; {a}; ; ; {a}; @@ -718,3 +741,6 @@ function inner() { const kept = { a }; }" )); } } + +#[cfg(test)] +mod scope_tests; diff --git a/libs/extractor/src/mutations/scope_tests.rs b/libs/extractor/src/mutations/scope_tests.rs new file mode 100644 index 000000000..a248f707b --- /dev/null +++ b/libs/extractor/src/mutations/scope_tests.rs @@ -0,0 +1,164 @@ +use oxc_allocator::Allocator; +use oxc_parser::Parser; +use oxc_span::SourceType; + +use super::{Use, uses}; +use crate::css_prop::CssProp; + +const COMPAT: &str = "@devup-ui/react/compat"; + +/// The uses of the bindings of `code`, one line for each, with `styles` the +/// names the module imports from the style packages +fn describe(code: &str, styles: &[&str], css: Option<(CssProp, &str)>) -> Vec { + let allocator = Allocator::default(); + let program = Parser::new(&allocator, code, SourceType::tsx()) + .parse() + .program; + let line = |at: u32| { + code[at as usize..] + .lines() + .next() + .unwrap_or_default() + .to_string() + }; + let mut found: Vec = uses(&program, &|name| styles.contains(&name), css) + .into_iter() + .filter(|(name, _)| name == "a") + .flat_map(|(name, uses)| { + uses.into_iter().map(move |found| match found { + Use::Changes { at, depth } => format!("{name} changes {depth}: {}", line(at)), + Use::Escapes { at, path, into } => { + format!("{name} escapes {path:?} into {into:?}: {}", line(at)) + } + Use::Calls { at, path } => format!("{name} calls {path:?}: {}", line(at)), + }) + }) + .collect(); + found.sort(); + found +} + +#[test] +fn a_local_named_like_a_style_api_is_not_one() { + let styles = ["css", "Box", "Devup"]; + let imports = "import { css, Box } from '@devup-ui/react';\nimport * as Devup from '@devup-ui/react';\nconst a = {};\n"; + for (code, escapes) in [ + ("css(a);", 0), + ("css.x(a);", 0), + (";", 0), + (";", 0), + ("function f(css) { css(a); }", 1), + ("function f() { const css = g; css(a); }", 1), + ("function f(css) { css.x(a); }", 1), + ("function f(Box) { return ; }", 1), + ("function f(Devup) { return ; }", 1), + ] { + let code = format!("{imports}{code}"); + assert_eq!(describe(&code, &styles, None).len(), escapes, "{code}"); + } +} + +#[test] +fn a_global_named_like_a_style_api_is_not_one() { + let styles = ["css", "Box", "Devup"]; + for code in [ + "css(a);", + "css.x(a);", + ";", + ";", + ] { + let code = format!("const a = {{}};\n{code}"); + assert_eq!(describe(&code, &styles, None).len(), 1, "{code}"); + } +} + +#[test] +fn a_local_named_like_a_global_is_not_one() { + let a = "const a = {};\n"; + for (code, expected) in [ + ("Object.assign(a, {});", vec!["a changes 1: a, {});"]), + ( + "function f(Object) { Object.assign(a, {}); }", + vec!["a escapes [] into None: a, {}); }"], + ), + ("String(a);", vec![]), + ( + "function f(String) { String(a); }", + vec!["a escapes [] into None: a); }"], + ), + ("JSON.stringify(a);", vec![]), + ( + "function f(JSON) { JSON.stringify(a); }", + vec!["a escapes [] into None: a); }"], + ), + ] { + assert_eq!( + describe(&format!("{a}{code}"), &[], None), + expected, + "{code}" + ); + } +} + +#[test] +fn a_local_named_like_a_class_names_binding_is_not_one() { + let prelude = "import { ClassNames } from '@emotion/react';\nconst a = {};\n"; + for (code, escapes) in [ + ( + "const x = {({ css }) => css(f(a))};", + 0, + ), + ( + "const x = {({ cx }) => cx`${a}`};", + 0, + ), + ( + "const x = {({ css }) => ((css) => css(f(a)))(css)};", + 1, + ), + ( + "const x = {({ cx }) => ((cx) => cx`${a}`)(cx)};", + 1, + ), + ( + "function h(ClassNames) { return {({ css }) => css(f(a))}; }", + 1, + ), + ] { + let code = format!("{prelude}{code}"); + let css = Some((CssProp::Elements, COMPAT)); + assert_eq!(describe(&code, &[], css).len(), escapes, "{code}"); + } +} + +#[test] +fn a_local_named_like_an_element_builder_takes_no_css_prop() { + let prelude = "import { jsx } from '@emotion/react';\nimport { Box } from '@devup-ui/react';\nconst a = {};\n"; + for (css_prop, code, escapes) in [ + (CssProp::Elements, "jsx('div', { css: f(a) });", 0), + (CssProp::Elements, "jsx(Box, { css: f(a) });", 0), + (CssProp::Elements, ";", 0), + (CssProp::Elements, "
;", 0), + (CssProp::Elements, ";", 1), + (CssProp::Everywhere, ";", 0), + ( + CssProp::Elements, + "function g(jsx) { jsx('div', { css: f(a) }); }", + 1, + ), + ( + CssProp::Elements, + "function g(Box) { jsx(Box, { css: f(a) }); }", + 1, + ), + ( + CssProp::Elements, + "function g(Box) { return ; }", + 1, + ), + ] { + let code = format!("{prelude}{code}"); + let css = Some((css_prop, COMPAT)); + assert_eq!(describe(&code, &["Box"], css).len(), escapes, "{code}"); + } +} diff --git a/libs/extractor/src/scope.rs b/libs/extractor/src/scope.rs new file mode 100644 index 000000000..862c75254 --- /dev/null +++ b/libs/extractor/src/scope.rs @@ -0,0 +1,237 @@ +//! Which bindings of a file stand for the APIs the build compiles away. +//! A name decides nothing: a call, an element or a member is compiled only +//! when the binding it reads is the import (or an alias of it) the file +//! declares, so a parameter, a `const` or a function sharing the name stays +//! the code it is. + +mod class_names; +mod lookup; +mod required; +mod visits; + +use std::rc::Rc; + +use oxc_ast::ast::{BindingIdentifier, Expression, IdentifierReference, VariableDeclarator}; +use oxc_ast_visit::{Visit, VisitMut}; +use oxc_semantic::Scoping; +use oxc_syntax::symbol::SymbolId; +use rustc_hash::{FxHashMap, FxHashSet}; + +use crate::component::ExportVariableKind; +use crate::stylex::StylexFunction; +use crate::util_type::UtilType; + +pub use class_names::ClassNamesSymbols; +pub use visits::requires; + +/// What stands for `namespace.styled` once the visit has read it as a binding: +/// no source text spells it, so no binding can share it +pub const NAMESPACE_STYLED: &str = ".styled"; + +/// The bindings of a program that stand for compiled APIs, by symbol +#[derive(Default)] +pub struct Bindings { + scoping: Rc, + /// Components the package exports + imports: FxHashMap, + /// The package imported whole, whose members are its exports + namespaces: FxHashSet, + util_imports: FxHashMap>, + styled_imports: FxHashSet, + /// Functions building elements from a type and props, by the export + jsx_imports: FxHashMap, + jsx_namespaces: FxHashSet, + global_components: FxHashSet, + class_names_components: FxHashSet, + stylex_namespaces: FxHashSet, + stylex_imports: FxHashMap, + /// The bindings of reads whose copies lost their references, by where + /// they were written + recovered: FxHashMap<(u32, u32), SymbolId>, + /// What the build removes the declaration of, which only its calls and + /// elements may read + compiled: FxHashSet, + /// The `const newCss = css` declarations that go with them + aliases: FxHashSet, +} + +impl Bindings { + pub fn scope(&mut self, scoping: Rc) { + self.scoping = scoping; + } + + /// The binding `identifier` reads: by its reference, or by where it was + /// written when a copy of it lost the reference + pub fn symbol(&self, identifier: &IdentifierReference<'_>) -> Option { + identifier.reference_id.get().map_or_else( + || { + self.recovered + .get(&(identifier.span.start, identifier.span.end)) + .copied() + }, + |reference| self.scoping.get_reference(reference).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 sharing the name + pub fn reads_module(&self, identifier: &IdentifierReference<'_>) -> bool { + self.symbol(identifier).is_none_or(|symbol| { + self.scoping.symbol_scope_id(symbol) == self.scoping.root_scope_id() + }) + } + + /// Remember the bindings the reads in `expression` stand for, before code + /// that copies it without their references, which the visit then reads + /// again + pub fn remember(&mut self, expression: &Expression<'_>) { + let mut reads = visits::Remember::new(self); + reads.visit_expression(expression); + let recovered = reads.into_recovered(); + self.recovered.extend(recovered); + } + + /// The import binding a module declares under `name` + pub fn imported(&self, name: &str) -> Option { + self.scoping + .get_root_binding(name.into()) + .filter(|symbol| self.scoping.symbol_flags(*symbol).is_import()) + } + + /// Declare that `symbol` binds what the package exports as `imported`; + /// `false` when the package exports nothing the build compiles by it + pub fn export(&mut self, symbol: Option, imported: &str) -> bool { + symbol.is_some_and(|symbol| { + if let Ok(kind) = imported.parse::() { + self.imports.insert(symbol, kind); + } else if let Some(kind) = UtilType::from_str_opt(imported) { + self.util_imports.insert(symbol, Rc::new(kind)); + } else if imported == "styled" { + self.styled_imports.insert(symbol); + } else { + return false; + } + true + }) + } + + /// Declare that `symbol` binds the package imported whole + pub fn namespace(&mut self, symbol: Option) { + self.namespaces.extend(symbol); + } + + /// Declare that `symbol` binds the module building elements from a type + /// and props + pub fn jsx_namespace(&mut self, symbol: Option) { + self.jsx_namespaces.extend(symbol); + } + + /// Declare that `local` binds the function building elements `imported` + pub fn jsx_import(&mut self, local: &BindingIdentifier<'_>, imported: String) { + if let Some(symbol) = local.symbol_id.get() { + self.jsx_imports.insert(symbol, imported); + } + } + + pub fn global_component(&mut self, symbol: Option) { + self.global_components.extend(symbol); + } + + pub fn class_names_component(&mut self, symbol: Option) { + self.class_names_components.extend(symbol); + } + + pub fn stylex_namespace(&mut self, symbol: Option) { + self.stylex_namespaces.extend(symbol); + } + + pub fn stylex_import(&mut self, symbol: Option, function: StylexFunction) { + if let Some(symbol) = symbol { + self.stylex_imports.insert(symbol, function); + } + } + + /// Declare that the build removes the declaration of `symbol` + pub fn compile(&mut self, symbol: Option) { + self.compiled.extend(symbol); + } + + pub fn is_compiled(&self) -> bool { + !self.compiled.is_empty() + } + + /// Declare an alias of what `declarator` initializes from, when it binds + /// one: `const newCss = css` + pub fn alias(&mut self, declarator: &VariableDeclarator<'_>) { + if let Some(Expression::Identifier(init)) = &declarator.init + && let Some(from) = self.symbol(init) + && let Some(symbol) = declarator + .id + .get_binding_identifier() + .and_then(|id| id.symbol_id.get()) + && self.copy(from, symbol) + { + self.compiled.insert(symbol); + self.aliases.insert(symbol); + } + } + + fn copy(&mut self, from: SymbolId, to: SymbolId) -> bool { + if let Some(util) = self.util_imports.get(&from).cloned() { + self.util_imports.insert(to, util); + } else if self.styled_imports.contains(&from) { + self.styled_imports.insert(to); + } else if let Some(kind) = self.imports.get(&from).cloned() { + self.imports.insert(to, kind); + } else { + return false; + } + true + } + + /// Remove the declarations of the aliases from `program`, wherever they + /// stand + pub fn remove_aliases(&self, program: &mut oxc_ast::ast::Program<'_>) { + if !self.aliases.is_empty() { + visits::RemoveAliases::new(&self.aliases).visit_program(program); + } + } + + /// Where `program` still reads what the build compiled away, with the + /// name read: by the binding each read stands for, not by its name + pub fn compiled_reads(&self, program: &oxc_ast::ast::Program<'_>) -> Vec<(u32, String)> { + let mut reads = visits::CompiledReads::new(self, false); + reads.visit_program(program); + reads.into_found() + } + + /// Where `expression`, about to be written as styles, reads what the + /// build compiled away other than by calling or rendering it: those + /// reads would survive as names of nothing + pub fn lowered_reads(&self, expression: &Expression<'_>) -> Vec<(u32, String)> { + if self.compiled.is_empty() { + return Vec::new(); + } + let mut reads = visits::CompiledReads::new(self, true); + reads.visit_expression(expression); + reads.into_found() + } + + /// [`Self::lowered_reads`] of the attributes of an element + pub fn lowered_attribute_reads( + &self, + attributes: &[oxc_ast::ast::JSXAttributeItem<'_>], + ) -> Vec<(u32, String)> { + if self.compiled.is_empty() { + return Vec::new(); + } + let mut reads = visits::CompiledReads::new(self, true); + for attribute in attributes { + reads.visit_jsx_attribute_item(attribute); + } + reads.into_found() + } +} + +#[cfg(test)] +mod tests; diff --git a/libs/extractor/src/scope/class_names.rs b/libs/extractor/src/scope/class_names.rs new file mode 100644 index 000000000..626eee981 --- /dev/null +++ b/libs/extractor/src/scope/class_names.rs @@ -0,0 +1,39 @@ +use oxc_ast::ast::{BindingPattern, FormalParameters}; +use oxc_syntax::symbol::SymbolId; + +/// The `css`, `cx` and theme bindings a `` child function takes +#[derive(Default, Clone, Copy)] +pub struct ClassNamesSymbols { + pub css: Option, + pub cx: Option, + pub theme: Option, +} + +impl ClassNamesSymbols { + /// The bindings `params`, already read as `{ css, cx, theme }`, declare + pub fn of(params: &FormalParameters<'_>) -> Self { + let mut symbols = Self::default(); + if let Some(BindingPattern::ObjectPattern(object)) = + params.items.first().map(|param| ¶m.pattern) + { + for property in &object.properties { + let symbol = property + .value + .get_binding_identifier() + .and_then(|local| local.symbol_id.get()); + let slot = match property.key.static_name().as_deref() { + Some("css") => &mut symbols.css, + Some("cx") => &mut symbols.cx, + _ => &mut symbols.theme, + }; + *slot = symbol; + } + } + symbols + } + + /// Whether `symbol` is the binding `slot` holds + pub fn is(slot: Option, symbol: Option) -> bool { + slot.is_some() && slot == symbol + } +} diff --git a/libs/extractor/src/scope/lookup.rs b/libs/extractor/src/scope/lookup.rs new file mode 100644 index 000000000..018248691 --- /dev/null +++ b/libs/extractor/src/scope/lookup.rs @@ -0,0 +1,193 @@ +use std::rc::Rc; + +use oxc_ast::ast::{ + Expression, IdentifierReference, JSXElementName, JSXMemberExpressionObject, ObjectExpression, +}; +use oxc_ast_visit::Visit; +use oxc_syntax::symbol::SymbolId; +use rustc_hash::{FxHashMap, FxHashSet}; + +use super::{Bindings, NAMESPACE_STYLED, visits}; +use crate::component::ExportVariableKind; +use crate::stylex::StylexFunction; +use crate::util_type::UtilType; + +impl Bindings { + /// The export of the package `expression` reads as a member of the + /// package imported whole + fn member<'e>(&self, expression: &'e Expression<'_>) -> Option<&'e str> { + self.member_of(&self.namespaces, expression) + } + + fn member_of<'e>( + &self, + namespaces: &FxHashSet, + expression: &'e Expression<'_>, + ) -> Option<&'e str> { + if let Expression::StaticMemberExpression(member) = expression + && let Expression::Identifier(object) = &member.object + && self + .symbol(object) + .is_some_and(|symbol| namespaces.contains(&symbol)) + { + Some(member.property.name.as_str()) + } else { + None + } + } + + /// The component `expression` reads + pub fn kind(&self, expression: &Expression<'_>) -> Option { + match expression { + Expression::Identifier(identifier) => self + .symbol(identifier) + .and_then(|symbol| self.imports.get(&symbol)) + .cloned(), + expression => self.member(expression).and_then(|name| name.parse().ok()), + } + } + + /// The component the element `name` renders + pub fn element(&self, name: &JSXElementName<'_>) -> Option { + match name { + JSXElementName::IdentifierReference(identifier) => self + .symbol(identifier) + .and_then(|symbol| self.imports.get(&symbol)) + .cloned(), + JSXElementName::MemberExpression(member) => self + .namespace_object(&member.object) + .then(|| member.property.name.as_str().parse().ok()) + .flatten(), + _ => None, + } + } + + fn namespace_object(&self, object: &JSXMemberExpressionObject<'_>) -> bool { + matches!(object, JSXMemberExpressionObject::IdentifierReference(identifier) + if self.is_namespace(identifier)) + } + + /// Whether `identifier` reads the package imported whole + pub fn is_namespace(&self, identifier: &IdentifierReference<'_>) -> bool { + self.symbol(identifier) + .is_some_and(|symbol| self.namespaces.contains(&symbol)) + } + + /// Whether the element `name` is the `Global` component + pub fn is_global_component(&self, name: &JSXElementName<'_>) -> bool { + match name { + JSXElementName::IdentifierReference(identifier) => self + .symbol(identifier) + .is_some_and(|symbol| self.global_components.contains(&symbol)), + JSXElementName::MemberExpression(member) => { + self.namespace_object(&member.object) && member.property.name == "Global" + } + _ => false, + } + } + + /// Whether `name` is the `ClassNames` component + pub fn is_class_names(&self, name: &IdentifierReference<'_>) -> bool { + self.symbol(name) + .is_some_and(|symbol| self.class_names_components.contains(&symbol)) + } + + /// The style function `callee` reads: `css`, `Devup.keyframes`, ... + pub fn util(&self, callee: &Expression<'_>) -> Option> { + match callee { + Expression::Identifier(identifier) => self + .symbol(identifier) + .and_then(|symbol| self.util_imports.get(&symbol)) + .cloned(), + callee => self + .member(callee) + .and_then(UtilType::from_str_opt) + .map(Rc::new), + } + } + + /// Whether `expression` reads `styled` + pub fn is_styled(&self, expression: &Expression<'_>) -> bool { + match expression { + Expression::Identifier(identifier) => { + identifier.name == NAMESPACE_STYLED + || self + .symbol(identifier) + .is_some_and(|symbol| self.styled_imports.contains(&symbol)) + } + expression => self.member(expression) == Some("styled"), + } + } + + /// Whether `callee` is a style function or a supported styled factory + pub fn compiles(&self, callee: &Expression<'_>) -> bool { + self.util(callee).is_some() || self.is_styled(callee) || self.styled_factory(callee) + } + + fn styled_factory(&self, callee: &Expression<'_>) -> bool { + match callee { + Expression::CallExpression(call) => { + self.is_styled(&call.callee) + || matches!(&call.callee, Expression::StaticMemberExpression(member) + if matches!(member.property.name.as_str(), "attrs" | "withConfig") + && matches!(call.arguments.as_slice(), [argument] if argument.is_expression()) + && self.styled_factory(&member.object)) + } + Expression::StaticMemberExpression(member) => self.is_styled(&member.object), + _ => false, + } + } + + /// Whether `callee` reads the imported `styled` + pub fn styles(&self, callee: &Expression<'_>) -> bool { + self.is_styled(callee) + } + + /// Whether `identifier` reads a component the build renders in its place + pub fn renders(&self, identifier: &IdentifierReference<'_>) -> bool { + self.symbol(identifier).is_some_and(|symbol| { + self.imports.contains_key(&symbol) + || self.global_components.contains(&symbol) + || self.class_names_components.contains(&symbol) + }) + } + + /// Whether the file may read `styled` + pub fn may_style(&self) -> bool { + !self.styled_imports.is_empty() || !self.namespaces.is_empty() + } + + /// The function building elements `callee` reads + pub fn jsx_function(&self, callee: &Expression<'_>) -> Option { + match callee { + Expression::Identifier(identifier) => self + .symbol(identifier) + .and_then(|symbol| self.jsx_imports.get(&symbol)) + .cloned(), + callee => self + .member_of(&self.jsx_namespaces, callee) + .map(str::to_string), + } + } + + /// The `StyleX` API `callee` reads, as a named import or as a member of + /// the package imported whole + pub fn stylex_function(&self, callee: &Expression<'_>) -> Option { + match callee { + Expression::Identifier(identifier) => self + .symbol(identifier) + .and_then(|symbol| self.stylex_imports.get(&symbol)) + .cloned(), + callee => self + .member_of(&self.stylex_namespaces, callee) + .and_then(StylexFunction::from_export_name), + } + } + + /// The bindings the names `object` reads all stand for, by name + pub fn visible(&self, object: &ObjectExpression<'_>) -> FxHashMap { + let mut reads = visits::Names::new(self); + reads.visit_object_expression(object); + reads.into_visible() + } +} diff --git a/libs/extractor/src/scope/required.rs b/libs/extractor/src/scope/required.rs new file mode 100644 index 000000000..0cacf42ee --- /dev/null +++ b/libs/extractor/src/scope/required.rs @@ -0,0 +1,53 @@ +use oxc_ast::ast::{Argument, BindingPattern, Expression, VariableDeclarator}; + +use super::Bindings; +use crate::utils::get_string_by_property_key; + +impl Bindings { + /// Declare what `declarator` binds when it requires `package` or the JSX + /// runtime: `const { css } = require('@devup-ui/react')` + pub fn require(&mut self, declarator: &VariableDeclarator<'_>, package: &str) { + let Some(Expression::CallExpression(call)) = &declarator.init else { + return; + }; + let (Expression::Identifier(callee), [Argument::StringLiteral(source)]) = + (&call.callee, call.arguments.as_slice()) + else { + return; + }; + if callee.name != "require" || self.symbol(callee).is_some() { + return; + } + let from_package = if source.value == "react/jsx-runtime" { + false + } else if source.value == package { + true + } else { + return; + }; + match &declarator.id { + BindingPattern::BindingIdentifier(binding) if from_package => { + self.namespace(binding.symbol_id.get()); + } + BindingPattern::BindingIdentifier(binding) => { + self.jsx_namespace(binding.symbol_id.get()); + } + BindingPattern::ObjectPattern(object) => { + let exports = object.properties.iter().filter_map(|prop| { + Some(( + get_string_by_property_key(&prop.key)?, + prop.value.get_binding_identifier()?, + )) + }); + for (name, local) in exports { + if from_package { + self.export(local.symbol_id.get(), &name); + } else { + self.jsx_import(local, name); + } + } + } + _ => {} + } + } +} diff --git a/libs/extractor/src/scope/tests.rs b/libs/extractor/src/scope/tests.rs new file mode 100644 index 000000000..bc1a860db --- /dev/null +++ b/libs/extractor/src/scope/tests.rs @@ -0,0 +1,70 @@ +use css::{class_map::reset_class_map, file_map::reset_file_map}; +use oxc_allocator::Allocator; +use oxc_ast_visit::VisitMut; +use oxc_codegen::Codegen; +use oxc_parser::Parser; +use oxc_span::SourceType; + +use crate::visit::DevupVisitor; + +mod cjs_aliases; +mod class_names; +mod components; +mod css_bindings; +mod diagnostics; +mod imports; +mod lowered_reads; +mod preflight_contexts; +mod scoping; +mod stylex; +mod theme_reads; + +struct Visited { + code: String, + errors: Vec, + styles: usize, +} + +fn visit_with(code: &str, setup: impl FnOnce(&mut DevupVisitor<'_>)) -> Visited { + reset_class_map(); + reset_file_map(); + let allocator = Allocator::default(); + let mut program = Parser::new(&allocator, code, SourceType::tsx()) + .parse() + .program; + let mut visitor = + DevupVisitor::new(&allocator, "test.tsx", "@devup-ui/react", Vec::new(), None); + setup(&mut visitor); + visitor.visit_program(&mut program); + let printed = Codegen::new().build(&program).code; + Visited { + code: printed.split_whitespace().collect::>().join(" "), + errors: visitor + .errors + .iter() + .map(|(_, message)| message.clone()) + .collect(), + styles: visitor.styles.len(), + } +} + +fn visit(code: &str) -> Visited { + visit_with(code, |_| {}) +} + +const IMPORT_CSS: &str = "import { css } from '@devup-ui/react';\n"; + +fn extracted(code: &str) -> Result { + reset_class_map(); + reset_file_map(); + crate::extract( + "test.tsx", + code, + crate::ExtractOption { + import_main_css: false, + ..crate::ExtractOption::default() + }, + ) + .map(|output| output.code.split_whitespace().collect::>().join(" ")) + .map_err(|error| error.to_string()) +} diff --git a/libs/extractor/src/scope/tests/cjs_aliases.rs b/libs/extractor/src/scope/tests/cjs_aliases.rs new file mode 100644 index 000000000..651a8532b --- /dev/null +++ b/libs/extractor/src/scope/tests/cjs_aliases.rs @@ -0,0 +1,51 @@ +use serial_test::serial; + +use super::extracted; + +fn compiled(code: &str) -> String { + extracted(code).unwrap_or_else(|error| format!("error: {error}")) +} + +#[test] +#[serial] +fn comma_separated_declarators_alias_what_the_require_before_them_binds() { + for code in [ + "const { css: a } = require('@devup-ui/react'), b = a;\nexport const x = b({ color: 'red' });", + "const { css } = require('@devup-ui/react'), a = css, b = a;\nexport const x = b({ color: 'red' });", + "const { css: a } = require('@devup-ui/react');\nconst b = a;\nexport const x = b({ color: 'red' });", + "import { css as a } from '@devup-ui/react';\nconst b = a, c = b;\nexport const x = c({ color: 'red' });", + ] { + let output = compiled(code); + assert!( + output.contains("export const x = \"a-a\""), + "{code}: {output}" + ); + assert!( + !output.contains("b(") && !output.contains("c(") && !output.contains(" = a"), + "{code}: {output}" + ); + } +} + +#[test] +#[serial] +fn an_alias_is_read_by_the_functions_declared_before_it() { + let output = compiled( + "const { css } = require('@devup-ui/react'), f = () => b({ color: 'red' }), b = css;\n\ + export const x = f();", + ); + assert!(!output.contains("error"), "{output}"); + assert!(!output.contains("b ="), "{output}"); +} + +#[test] +#[serial] +fn a_declarator_not_required_from_the_package_stays_code() { + let output = compiled( + "const { css: a } = require('other'), b = a;\nexport const x = b({ color: 'red' });", + ); + assert!( + output.contains("b = a") && output.contains("b({ color: 'red' })"), + "{output}" + ); +} diff --git a/libs/extractor/src/scope/tests/class_names.rs b/libs/extractor/src/scope/tests/class_names.rs new file mode 100644 index 000000000..5232090fe --- /dev/null +++ b/libs/extractor/src/scope/tests/class_names.rs @@ -0,0 +1,45 @@ +use serial_test::serial; + +use super::visit; + +#[test] +#[serial] +fn class_names_calls_follow_the_bindings_the_child_function_declares() { + let visited = visit( + "import { ClassNames } from '@devup-ui/react/compat';\n\ + export const a = {({ css, cx }) =>
};\n\ + export const b = {({ css }) =>
css({ color: 'blue' }))((x) => x)} />};\n\ + export const c = ({ ClassNames }) => {({ css }) =>
};\n\ + export const d = {({ css: paint }) =>
};", + ); + assert_eq!(visited.errors, Vec::::new()); + assert_eq!(visited.styles, 2); + for expected in [ + "export const a =
;", + "((css) => css({ color: \"blue\" }))((x) => x)", + "{({ css }) =>
}", + "export const d =
;", + ] { + assert!( + visited.code.contains(expected), + "{expected}: {}", + visited.code + ); + } +} + +#[test] +#[serial] +fn class_names_reports_reads_of_the_bindings_it_takes_only() { + let visited = visit( + "import { ClassNames } from '@devup-ui/react/compat';\n\ + export const a = {({ css, theme }) =>
};\n\ + export const b = {({ css, theme }) =>
[css, theme])(1, 2)} />};", + ); + assert_eq!(visited.errors.len(), 1, "{:?}", visited.errors); + assert!( + visited.errors[0].contains("`` cannot use `css`"), + "{:?}", + visited.errors + ); +} diff --git a/libs/extractor/src/scope/tests/components.rs b/libs/extractor/src/scope/tests/components.rs new file mode 100644 index 000000000..388f08536 --- /dev/null +++ b/libs/extractor/src/scope/tests/components.rs @@ -0,0 +1,177 @@ +use serial_test::serial; + +use super::{visit, visit_with}; +use crate::css_prop::CssProp; + +#[test] +#[serial] +fn components_follow_the_binding_their_element_reads() { + let visited = visit( + "import { Box, Flex as flex } from '@devup-ui/react';\n\ + export const a = ({ Box }) => ;\n\ + export const b = ;\n\ + export function c(Box) { return ; }\n\ + export const d = ;\n\ + export const e = ;", + ); + assert_eq!( + visited.errors, + vec![ + "`Box` is read at runtime, where it does not exist: the build compiles it only where it is called or rendered" + .to_string() + ] + ); + assert_eq!(visited.styles, 1); + for local in [ + "", + "", + "", + "", + ] { + assert!(visited.code.contains(local), "{local}: {}", visited.code); + } + assert!( + visited.code.contains("
"), + "{}", + visited.code + ); +} + +#[test] +#[serial] +fn global_component_follows_its_binding() { + let visited = visit( + "import { Global } from '@devup-ui/react/compat';\n\ + import * as D from '@devup-ui/react';\n\ + export const a = ;\n\ + export const b = ({ Global }) => ;\n\ + export const c = ;\n\ + export const d = (D) => ;", + ); + assert_eq!(visited.errors, Vec::::new()); + assert_eq!(visited.styles, 2); + assert!( + visited.code.contains("export const a = ;"), + "{}", + visited.code + ); + assert!( + visited.code.contains("export const c = ;"), + "{}", + visited.code + ); + for local in [ + "", + "", + ] { + assert!(visited.code.contains(local), "{local}: {}", visited.code); + } +} + +#[test] +#[serial] +fn styled_follows_the_binding_it_is_read_through() { + let visited = visit( + "import { styled, Box } from '@devup-ui/react';\n\ + import * as D from '@devup-ui/react';\n\ + export const A = styled.div({ color: 'red' });\n\ + export const B = D.styled.div({ color: 'blue' });\n\ + export const C = styled('div', { color: 'green' });\n\ + export const E = D.styled('div', { color: 'orange' });\n\ + export const F = styled(Box)({ color: 'pink' });\n\ + export const G = styled(D.Box)({ color: 'gray' });\n\ + export const H = styled.div.attrs({ role: 'note' })({ color: 'teal' });\n\ + export const I = D.styled.div.attrs({ role: 'note' })({ color: 'navy' });\n\ + export function f(styled, D) {\n\ + \treturn [styled.div({ color: 'red' }), styled('div', { color: 'red' }), styled.div.attrs({ role: 'note' })({ color: 'red' }), D.styled.div({ color: 'red' }), D.styled('div', { color: 'red' })];\n\ + }", + ); + assert_eq!(visited.errors, Vec::::new()); + assert_eq!(visited.styles, 8); + assert!( + visited.code.contains( + "return [ styled.div({ color: \"red\" }), styled(\"div\", { color: \"red\" }), styled.div.attrs({ role: \"note\" })({ color: \"red\" }), D.styled.div({ color: \"red\" }), D.styled(\"div\", { color: \"red\" }) ];" + ), + "{}", + visited.code + ); + assert!( + !visited.code.contains("export const A = styled") + && !visited.code.contains("export const I = D.styled"), + "{}", + visited.code + ); +} + +#[test] +#[serial] +fn imported_jsx_function_is_read_through_its_binding() { + let visited = visit( + "import { jsx } from 'react/jsx-runtime';\n\ + import { Box } from '@devup-ui/react';\n\ + export const a = jsx(Box, { p: 1 });\n\ + export function f(jsx) { return jsx('div', { p: 2 }); }", + ); + assert_eq!(visited.errors, Vec::::new()); + assert_eq!(visited.styles, 1); + assert!( + visited + .code + .contains("export const a = jsx(\"div\", { className: \"a\" });"), + "{}", + visited.code + ); + assert!( + visited.code.contains("return jsx(\"div\", { p: 2 });"), + "{}", + visited.code + ); +} + +#[test] +#[serial] +fn css_prop_reaches_only_the_components_the_file_imports() { + let visited = visit_with( + "import { Box } from '@devup-ui/react';\n\ + export const a = ;\n\ + export const b = ({ Box }) => ;", + |visitor| visitor.takes_css_prop(CssProp::Elements), + ); + assert_eq!(visited.errors, Vec::::new()); + assert_eq!(visited.styles, 1); + assert!( + visited + .code + .contains("export const a =
;"), + "{}", + visited.code + ); + assert!( + visited.code.contains(""), + "{}", + visited.code + ); +} + +#[test] +#[serial] +fn calls_read_once_are_walked_again_with_the_bindings_they_read() { + let visited = visit( + "import { jsx } from 'react/jsx-runtime';\n\ + import { Box, css } from '@devup-ui/react';\n\ + export const a = jsx(Box, { ...props(), p: 1, className: css({ color: 'red' }) });\n\ + export function f(css) { return jsx(Box, { ...props(), p: 2, className: css({ color: 'blue' }) }); }", + ); + assert_eq!(visited.errors, Vec::::new(), "{}", visited.code); + assert_eq!(visited.styles, 3); + assert!( + !visited.code.contains("css({ color: \"red\" })"), + "{}", + visited.code + ); + assert!( + visited.code.contains("css({ color: \"blue\" })"), + "{}", + visited.code + ); +} diff --git a/libs/extractor/src/scope/tests/css_bindings.rs b/libs/extractor/src/scope/tests/css_bindings.rs new file mode 100644 index 000000000..05ef2196f --- /dev/null +++ b/libs/extractor/src/scope/tests/css_bindings.rs @@ -0,0 +1,125 @@ +use serial_test::serial; + +use super::{IMPORT_CSS, extracted, visit}; + +#[test] +#[serial] +fn style_function_shadowed_by_any_binding_stays_a_call() { + for declaration in [ + "function f(css) { return css({ color: 'red' }); }", + "const f = (css) => css({ color: 'red' });", + "function f() { const css = (x) => x; return css({ color: 'red' }); }", + "function f() { let css = (x) => x; return css({ color: 'red' }); }", + "function f() { var css = (x) => x; return css({ color: 'red' }); }", + "function f() { function css(x) { return x; } return css({ color: 'red' }); }", + "function f() { class css {} return css({ color: 'red' }); }", + "function f() { try { g(); } catch (css) { return css({ color: 'red' }); } }", + "function f() { { const css = (x) => x; return css({ color: 'red' }); } }", + "function f() { return [1].map((css) => css({ color: 'red' })); }", + ] { + let visited = visit(&format!( + "{IMPORT_CSS}export {declaration}\nexport const real = css({{ color: 'blue' }});" + )); + assert_eq!(visited.errors, Vec::::new(), "{declaration}"); + assert_eq!(visited.styles, 1, "{declaration}"); + assert!( + visited.code.contains("css({ color: \"red\" })"), + "{declaration}: {}", + visited.code + ); + assert!( + visited.code.ends_with("export const real = \"a\";"), + "{declaration}: {}", + visited.code + ); + } +} + +#[test] +#[serial] +fn minified_import_alias_shadowed_by_a_parameter() { + let visited = visit( + "import{css as a}from'@devup-ui/react';export const x=a({color:'red'});export const f=a=>a({color:'blue'});", + ); + assert_eq!(visited.errors, Vec::::new()); + assert_eq!(visited.styles, 1); + assert_eq!( + visited.code, + "export const x = \"a\"; export const f = (a) => a({ color: \"blue\" });" + ); +} + +#[test] +#[serial] +fn keyframes_global_css_and_templates_follow_their_bindings() { + let visited = visit( + "import { css, keyframes, globalCss } from '@devup-ui/react';\n\ + export function a() { class keyframes {} return keyframes({ from: { color: 'red' } }); }\n\ + export function b() { { const globalCss = (x) => x; return globalCss({ body: { margin: 0 } }); } }\n\ + export function c() { function css(x) { return x; } return css`color: red;`; }\n\ + export function d(keyframes) { return keyframes`from { color: red; }`; }\n\ + export function e(globalCss) { return globalCss`body { margin: 0; }`; }\n\ + export const real = css`color: blue;`;\n\ + export const frames = keyframes({ from: { color: 'red' } });\n\ + globalCss({ body: { margin: 0 } });", + ); + assert_eq!(visited.errors, Vec::::new()); + assert_eq!(visited.styles, 3); + for local in [ + "keyframes({ from: { color: \"red\" } })", + "globalCss({ body: { margin: 0 } })", + "css`color: red;`", + "keyframes`from { color: red; }`", + "globalCss`body { margin: 0; }`", + ] { + assert!(visited.code.contains(local), "{local}: {}", visited.code); + } + assert!( + visited.code.contains("export const real = \"a\";"), + "{}", + visited.code + ); +} + +#[test] +#[serial] +fn aliases_follow_the_binding_wherever_they_are_declared() { + let visited = visit(&format!( + "{IMPORT_CSS}\ + export function f() {{ const a = css; {{ const b = a; return b({{ color: 'red' }}); }} }}\n\ + export function g(css) {{ const c = css; return c({{ color: 'blue' }}); }}\n\ + export function h() {{ const keep = 1, d = css; return d({{ color: 'green' }}) + keep; }}" + )); + assert_eq!(visited.errors, Vec::::new()); + assert_eq!(visited.styles, 2); + assert_eq!( + visited.code, + "export function f() { { return \"a\"; } } \ + export function g(css) { const c = css; return c({ color: \"blue\" }); } \ + export function h() { const keep = 1; return \"b\" + keep; }" + ); +} + +#[test] +#[serial] +fn minified_alias_called_with_runtime_arguments_stays_a_call() { + let code = "import{css as a}from'@devup-ui/react';\ + export const x=a({color:'red'});\ + export function f(a,getValue){return a(getValue())}\ + export const g=(a)=>a('ordinary runtime argument');"; + let visited = visit(code); + assert_eq!(visited.errors, Vec::::new()); + assert_eq!(visited.styles, 1); + for expected in [ + "export const x = \"a\";", + "return a(getValue());", + "a(\"ordinary runtime argument\")", + ] { + assert!( + visited.code.contains(expected), + "{expected}: {}", + visited.code + ); + } + assert!(extracted(code).is_ok()); +} diff --git a/libs/extractor/src/scope/tests/diagnostics.rs b/libs/extractor/src/scope/tests/diagnostics.rs new file mode 100644 index 000000000..44e102ceb --- /dev/null +++ b/libs/extractor/src/scope/tests/diagnostics.rs @@ -0,0 +1,178 @@ +use css::{class_map::reset_class_map, file_map::reset_file_map}; +use serial_test::serial; + +use super::{IMPORT_CSS, extracted, visit}; + +#[test] +#[serial] +fn a_read_of_a_removed_alias_is_reported_where_it_is_read() { + let visited = visit(&format!( + "{IMPORT_CSS}\ + export function f() {{ const c = css; return [c]; }}\n\ + export function g(c) {{ return [c]; }}\n\ + export const keep = css;" + )); + assert_eq!( + visited.errors, + vec![ + "`c` is read at runtime, where it does not exist: the build compiles it only where it is called or rendered" + .to_string(), + "`css` is read at runtime, where it does not exist: the build compiles it only where it is called or rendered" + .to_string(), + ] + ); +} + +#[test] +#[serial] +fn shadowed_imports_stay_code_through_the_whole_extraction() { + reset_class_map(); + reset_file_map(); + let output = crate::extract( + "test.tsx", + "import { css, Box } from '@devup-ui/react';\n\ + export function f(css) { return css({ color: 'red' }); }\n\ + export const g = ({ Box }) => ;\n\ + export const real = css({ color: 'blue' });\n\ + export const view = ;", + crate::ExtractOption { + import_main_css: false, + ..crate::ExtractOption::default() + }, + ) + .map(|output| output.code.split_whitespace().collect::>().join(" ")); + assert_eq!( + output.map_err(|error| error.to_string()), + Ok("import \"@devup-ui/react/devup-ui-0.css\"; \ + export function f(css) { return css({ color: \"red\" }); } \ + export const g = ({ Box }) => ; \ + export const real = \"a-a\"; \ + export const view =
;" + .to_string()) + ); +} + +#[test] +#[serial] +fn changed_top_level_binding_is_reported_but_a_local_of_the_same_name_is_not() { + let own = extracted( + "import { css, Box } from '@devup-ui/react';\n\ + const base = { p: 4 };\nbase.p = 8;\n\ + export const a = css(base);", + ); + assert!(own.is_err_and(|error| error.contains("`css()` cannot use `base`"))); + + let element = extracted( + "import { Box } from '@devup-ui/react';\n\ + const base = { p: 4 };\nbase.p = 8;\n\ + export const a = ;", + ); + assert!(element.is_err_and(|error| error.contains("`` cannot use `base`"))); + + let shadowed = extracted( + "import { css, Box } from '@devup-ui/react';\n\ + const base = { p: 4 };\nbase.p = 8;\n\ + export function f(base) { return [css(base), ]; }\n\ + export const real = css({ color: 'red' });", + ); + assert!( + shadowed + .as_ref() + .is_ok_and(|code| code.contains("export const real = \"a-a\"") + && code.contains("
;", + "const rules = { color: 'red' };\nrules.color = 'blue';\nexport const a = (rules) => ;", + ), + ( + "const rules = compute();\nexport const a = ;", + "const rules = compute();\nexport const a = (rules) => ;", + ), + ] { + assert!( + compile(own).is_err_and(|error| error.contains("cannot use `rules`")), + "{own}" + ); + assert!( + compile(shadowed).is_ok_and(|code| code.contains("
")), + "{shadowed}" + ); + } +} + +#[test] +#[serial] +fn a_global_read_is_not_a_removed_alias_of_the_same_name() { + for (import, label) in [ + ("import { css } from '@devup-ui/react';", "import"), + ("const { css } = require('@devup-ui/react');", "require"), + ] { + let visited = visit(&format!( + "{import}\n\ + export function f() {{ const a = css; return a({{ color: 'red' }}); }}\n\ + export function g() {{ return a(); }}" + )); + assert_eq!(visited.errors, Vec::::new(), "{label}"); + assert!( + visited.code.contains("return a();"), + "{label}: {}", + visited.code + ); + assert!( + !visited.code.contains("const a = css"), + "{label}: {}", + visited.code + ); + } + let raw = visit(&format!( + "{IMPORT_CSS}export function f() {{ const a = css; return a({{ color: 'red' }}); }}\n\ + export const raw = [css];" + )); + assert_eq!(raw.errors.len(), 1, "{:?}", raw.errors); + assert!(raw.errors[0].starts_with("`css` is read at runtime")); +} diff --git a/libs/extractor/src/scope/tests/imports.rs b/libs/extractor/src/scope/tests/imports.rs new file mode 100644 index 000000000..f9c2b00ad --- /dev/null +++ b/libs/extractor/src/scope/tests/imports.rs @@ -0,0 +1,85 @@ +use serial_test::serial; + +use super::visit; + +#[test] +#[serial] +fn package_imported_whole_is_read_through_its_binding() { + for import in [ + "import * as D from '@devup-ui/react';", + "import D from '@devup-ui/react';", + ] { + let visited = visit(&format!( + "{import}\n\ + export const a = D.css({{ color: 'red' }});\n\ + export function b(D) {{ return D.css({{ color: 'blue' }}); }}\n\ + export const c = ;\n\ + export const d = (D) => ;\n\ + export const e = D.unknown({{ color: 'green' }});\n\ + export const f = ;\n\ + export const g = ;\n\ + export const h = ;\n\ + export const i = ;" + )); + assert_eq!(visited.errors, Vec::::new(), "{import}"); + assert_eq!(visited.styles, 2, "{import}"); + for expected in [ + "export const a = \"a\";", + "D.css({ color: \"blue\" })", + "
", + "(D) => ", + "D.unknown({ color: \"green\" })", + "", + "", + "", + "", + ] { + assert!( + visited.code.contains(expected), + "{import} {expected}: {}", + visited.code + ); + } + } +} + +#[test] +#[serial] +fn required_package_is_read_through_the_bindings_that_hold_it() { + let visited = visit( + "const { Box: B, css, ['styled']: s, ...rest } = require('@devup-ui/react');\n\ + const [x] = require('@devup-ui/react');\n\ + const { a: { b } } = require('@devup-ui/react');\n\ + const { jsx: j } = require('react/jsx-runtime');\n\ + const runtime = require('react/jsx-runtime'), D = require('@devup-ui/react');\n\ + export const t = j(B, { p: 1 });\n\ + export const u = css({ color: 'red' });\n\ + export function f(css) { return css({ color: 'blue' }); }\n\ + export const v = runtime.jsx(D.Box, { p: 2 });\n\ + export const w = D.css({ color: 'green' });\n\ + export function g(runtime, D) { return [runtime.jsx(D.Box, { p: 3 }), D.css({ color: 'teal' })]; }\n\ + export function h(require) {\n\ + \tconst { css: d } = require('@devup-ui/react');\n\ + \tconst E = require('@devup-ui/react');\n\ + \tconst r = require('react/jsx-runtime');\n\ + \treturn [d({ color: 'pink' }), E.css({ color: 'gray' }), r.jsx(E.Box, { p: 4 })];\n\ + }", + ); + assert_eq!(visited.errors, Vec::::new()); + assert_eq!(visited.styles, 4); + for expected in [ + "export const t = j(\"div\", { className: \"a\" });", + "export const u = \"b\";", + "css({ color: \"blue\" })", + "export const v = runtime.jsx(\"div\", { className: \"c\" });", + "export const w = \"d\";", + "return [runtime.jsx(D.Box, { p: 3 }), D.css({ color: \"teal\" })];", + "return [ d({ color: \"pink\" }), E.css({ color: \"gray\" }), r.jsx(E.Box, { p: 4 }) ];", + ] { + assert!( + visited.code.contains(expected), + "{expected}: {}", + visited.code + ); + } +} diff --git a/libs/extractor/src/scope/tests/lowered_reads.rs b/libs/extractor/src/scope/tests/lowered_reads.rs new file mode 100644 index 000000000..d71865b3a --- /dev/null +++ b/libs/extractor/src/scope/tests/lowered_reads.rs @@ -0,0 +1,124 @@ +use serial_test::serial; + +use super::{IMPORT_CSS, extracted, visit}; + +const READ: &str = "`css` is read at runtime, where it does not exist: the build compiles it only where it is called or rendered"; + +const IMPORT_BOX_CSS: &str = "import { Box, css } from '@devup-ui/react';\n"; + +fn reads(code: &str) -> Vec { + visit(&format!("{IMPORT_BOX_CSS}{code}")).errors +} + +#[test] +#[serial] +fn a_read_of_the_import_lowered_into_styles_is_reported() { + for code in [ + "export const a = ;", + "export const a = ;", + "export const a = ;", + "export const a = ;", + "export const a = ;", + "export const a = ;", + ] { + assert_eq!(reads(code), vec![READ.to_string()], "{code}"); + } +} + +#[test] +#[serial] +fn a_read_of_the_import_in_the_arguments_of_a_compiled_call_is_reported() { + for code in [ + "export const a = ;", + "export const a = css({ color: css });", + "export const a = css`color: ${css};`;", + "export const a = css({ color: 'red' }, css);", + ] { + let errors = reads(code); + assert_eq!( + errors.first(), + Some(&READ.to_string()), + "{code}: {errors:?}" + ); + } +} + +#[test] +#[serial] +fn a_read_of_the_import_in_a_jsx_call_is_reported() { + let visited = visit( + "import { jsx } from 'react/jsx-runtime';\n\ + import { Box, css } from '@devup-ui/react';\n\ + export const a = jsx(Box, { p: css });", + ); + assert_eq!(visited.errors, vec![READ.to_string()]); +} + +#[test] +#[serial] +fn a_read_of_an_alias_lowered_into_styles_is_reported() { + let visited = visit(&format!( + "{IMPORT_BOX_CSS}const alias = css;\nexport const a = ;" + )); + assert_eq!( + visited.errors, + vec![READ.replace("`css`", "`alias`")], + "{}", + visited.code + ); +} + +#[test] +#[serial] +fn a_local_or_a_global_sharing_the_name_is_a_value() { + for code in [ + "export const a = (css) => ;", + "export const a = ({ css }) => ;", + "export function f() { const css = 1; return ; }", + ] { + assert_eq!(reads(code), Vec::::new(), "{code}"); + } + let global = visit("import { Box } from '@devup-ui/react';\nexport const a = ;"); + assert_eq!(global.errors, Vec::::new()); + assert!(global.code.contains("css"), "{}", global.code); +} + +#[test] +#[serial] +fn nested_compiled_calls_and_elements_are_not_reads() { + for code in [ + "export const a = ;", + "export const a = ;", + "export const a = ;", + "export const a = css({ color: 'red' }, css({ color: 'blue' }));", + ] { + let visited = visit(&format!("{IMPORT_BOX_CSS}{code}")); + assert_eq!(visited.errors, Vec::::new(), "{code}"); + assert!(visited.styles > 0, "{code}"); + } + let styled = visit( + "import { Box, styled } from '@devup-ui/react';\n\ + export const A = styled(Box)({ color: 'red' });\n\ + export const B = styled.div({ color: 'blue' });\n\ + export const C = styled(Box).attrs({ id: 'c' })({ color: 'green' });", + ); + assert_eq!(styled.errors, Vec::::new()); +} + +#[test] +#[serial] +fn the_whole_extraction_rejects_the_read_with_where_it_is() { + let rejected = extracted(&format!( + "{IMPORT_CSS}export const a =
;" + )); + assert!(rejected.is_err_and(|error| error.contains("`css` is read at runtime"))); + let lowered = + extracted("import { Box, css } from '@devup-ui/react';\nexport const a = ;"); + assert!(lowered.is_err_and(|error| error.contains("`css` is read at runtime"))); + let shadowed = extracted( + "import { Box, css } from '@devup-ui/react';\n\ + export const a = (css) => ;\n\ + export const b = css({ color: 'red' });", + ); + assert!(shadowed.is_ok(), "{shadowed:?}"); +} diff --git a/libs/extractor/src/scope/tests/preflight_contexts.rs b/libs/extractor/src/scope/tests/preflight_contexts.rs new file mode 100644 index 000000000..3fa9655aa --- /dev/null +++ b/libs/extractor/src/scope/tests/preflight_contexts.rs @@ -0,0 +1,206 @@ +use serial_test::serial; + +use super::{extracted, visit}; + +const IMPORTS: &str = "import { Box, css, styled } from '@devup-ui/react';"; + +#[test] +#[serial] +fn nested_factory_components_compile_when_the_factory_binding_is_supported() { + for factory in ["jsx", "jsxs", "jsxDEV"] { + let code = format!( + "{IMPORTS} import {{ {factory} }} from 'react/jsx-runtime'; + export const a = {factory}(Box, {{children: {factory}(Box, {{p: 1}})}});" + ); + let output = extracted(&code).unwrap_or_else(|error| panic!("{error}")); + assert_eq!( + output.matches(&format!("{factory}(\"div\"")).count(), + 2, + "{output}" + ); + } +} + +#[test] +#[serial] +fn raw_reads_are_rejected_when_nested_factories_keep_them() { + for nested in [ + "jsx(Box, {id: css})", + "jsx(Box, {p: 1}, css)", + "jsx(Box, {p: 1}, ...[css])", + "jsx.call(null, Box, {p: 1})", + "jsx.apply(null, [Box, {p: 1}])", + "jsx.random(Box, {p: 1})", + "((jsx) => jsx(Box, {p: 1}))(other)", + "jsx(Box)", + "jsx.random(css, {p: 1})", + ] { + let code = format!( + "{IMPORTS} import {{ jsx }} from 'react/jsx-runtime'; + export const a = jsx(Box, {{children: {nested}}});" + ); + let Err(error) = extracted(&code) else { + panic!("raw factory read compiled: {nested}"); + }; + assert!(error.contains("is read at runtime"), "{nested}: {error}"); + } +} + +#[test] +#[serial] +fn style_function_members_are_rejected_when_the_visitor_does_not_compile_them() { + for value in [ + "css.call(null, {color: 'blue'})", + "css.apply(null, [{color: 'blue'}])", + "css.random({color: 'blue'})", + "css.attrs({id: 'x'})", + "css.withConfig({displayName: 'x'})", + "css.random`color: blue;`", + "styled.div.random({color: 'blue'})", + ] { + let code = format!("{IMPORTS} export const a = ;"); + let Err(error) = extracted(&code) else { + panic!("unsupported member compiled: {value}"); + }; + assert!(error.contains("is read at runtime"), "{value}: {error}"); + } +} + +#[test] +#[serial] +fn namespace_jsx_factories_compile_when_their_members_are_supported() { + let output = extracted(&format!( + "{IMPORTS} const React = require('react/jsx-runtime'); + export const a = React.jsx(Box, {{children: React.jsxs(Box, {{p: 1}})}});" + )) + .unwrap_or_else(|error| panic!("{error}")); + assert!(output.contains("React.jsxs(\"div\""), "{output}"); +} + +#[test] +#[serial] +fn raw_attrs_reads_are_rejected_when_the_import_would_be_removed() { + for attrs in [ + "{id: css}", + "{className: css}", + "{style: {color: css}}", + "() => ({id: css})", + "() => ({className: css})", + "() => ({style: {color: css}})", + "{id: css.call(null, {color: 'blue'})}", + "{id: css.apply(null, [{color: 'blue'}])}", + "{id: css.random({color: 'blue'})}", + ] { + let code = + format!("{IMPORTS} export const A = styled.div.attrs({attrs})({{color: 'red'}});"); + let Err(error) = extracted(&code) else { + panic!("raw attrs read compiled: {attrs}"); + }; + assert!( + error.contains("`css` is read at runtime"), + "{attrs}: {error}" + ); + } +} + +#[test] +#[serial] +fn attrs_compile_nested_calls_when_their_original_bindings_are_available() { + for attrs in [ + "{id: css({color: 'blue'})}", + "{className: css({color: 'blue'})}", + "() => ({className: css({color: 'blue'})})", + "{children: }", + "{children: jsx(Box, {p: 1})}", + ] { + let code = format!( + "{IMPORTS} import {{jsx}} from 'react/jsx-runtime'; + export const A = styled.div.attrs({attrs})({{color: 'red'}}); + export const B = styled(A)({{color: 'green'}});" + ); + let visited = visit(&code); + assert_eq!(visited.errors, Vec::::new(), "{attrs}"); + assert!(visited.styles >= 2, "{attrs}: {}", visited.code); + assert!(!visited.code.contains("css("), "{}", visited.code); + assert!(!visited.code.contains(" ({id: css})", + "() => { const css = 'local'; return {id: css}; }", + "css => ({id: css({color: 'blue'})})", + ] { + let code = + format!("{IMPORTS} export const A = styled.div.attrs({attrs})({{color: 'red'}});"); + let output = extracted(&code).unwrap_or_else(|error| panic!("{error}")); + assert!(output.contains("css"), "{output}"); + } +} + +#[test] +#[serial] +fn ignored_config_metadata_is_not_a_runtime_read() { + let output = extracted(&format!( + "{IMPORTS} export const A = styled.div.withConfig({{displayName: css}})({{color: 'red'}});" + )) + .unwrap_or_else(|error| panic!("{error}")); + assert!(!output.contains("displayName"), "{output}"); + let nested = extracted(&format!( + "{IMPORTS} export const a = ;" + )).unwrap_or_else(|error| panic!("{error}")); + assert!(!nested.contains("displayName"), "{nested}"); +} + +#[test] +#[serial] +fn namespace_factories_reject_raw_reads_when_styles_or_attrs_keep_them() { + for factory in [ + "D.styled.div({color: css})", + "D.styled.div`color: ${css};`", + "D.styled.div.attrs({id: css})({color: 'red'})", + "D.styled.div.attrs({className: css})`color: red;`", + "D.styled(Box)({color: css})", + "D.styled(Box).attrs(() => ({id: css}))({color: 'red'})", + ] { + let code = + format!("{IMPORTS} const D = require('@devup-ui/react'); export const A = {factory};"); + let Err(error) = extracted(&code) else { + panic!("raw namespace read compiled: {factory}"); + }; + assert!( + error.contains("`css` is read at runtime"), + "{factory}: {error}" + ); + } +} + +#[test] +#[serial] +fn namespace_bases_compile_when_the_component_binding_is_rendered() { + for factory in [ + "D.styled(Box)({color: 'red'})", + "D.styled(D.Box)({color: 'red'})", + "D.styled(Box).attrs({id: css({color: 'blue'})})`color: red;`", + ] { + let code = + format!("{IMPORTS} const D = require('@devup-ui/react'); export const A = {factory};"); + let output = extracted(&code).unwrap_or_else(|error| panic!("{error}")); + assert!(!output.contains("D.styled"), "{output}"); + } +} + +#[test] +#[serial] +fn namespace_members_stay_ordinary_when_the_namespace_is_shadowed() { + let output = extracted(&format!( + "{IMPORTS} const D = require('@devup-ui/react'); + export const f = (D, css) => D.styled.div.attrs({{id: css}})({{color: css}});" + )) + .unwrap_or_else(|error| panic!("{error}")); + assert!(output.contains("D.styled.div.attrs"), "{output}"); +} diff --git a/libs/extractor/src/scope/tests/scoping.rs b/libs/extractor/src/scope/tests/scoping.rs new file mode 100644 index 000000000..3202e03bb --- /dev/null +++ b/libs/extractor/src/scope/tests/scoping.rs @@ -0,0 +1,58 @@ +use css::{class_map::reset_class_map, file_map::reset_file_map}; +use oxc_allocator::Allocator; +use oxc_ast_visit::VisitMut; +use oxc_codegen::Codegen; +use oxc_parser::Parser; +use oxc_span::SourceType; +use serial_test::serial; + +use super::visit; +use crate::visit::DevupVisitor; + +#[test] +#[serial] +fn files_that_bind_nothing_the_build_compiles_build_no_scoping() { + let comment = + visit("// @devup-ui/react\nconst css = (x) => x;\nexport const a = css({ color: 'red' });"); + assert_eq!(comment.errors, Vec::::new()); + assert_eq!(comment.styles, 0); + assert!( + comment.code.contains("css({ color: \"red\" })"), + "{}", + comment.code + ); + + let other = visit("const css = require('other');\nexport const a = css({ color: 'red' });"); + assert_eq!(other.styles, 0); + assert!( + other.code.contains("css({ color: \"red\" })"), + "{}", + other.code + ); +} + +#[test] +#[serial] +fn analysis_given_to_the_visitor_is_reused() { + reset_class_map(); + reset_file_map(); + let allocator = Allocator::default(); + let code = "import { css } from '@devup-ui/react';\nexport function f(css) { return css({ color: 'red' }); }\nexport const a = css({ color: 'blue' });"; + let mut program = Parser::new(&allocator, code, SourceType::tsx()) + .parse() + .program; + let scoping = std::rc::Rc::new( + oxc_semantic::SemanticBuilder::new() + .build(&program) + .semantic + .into_scoping(), + ); + let mut visitor = + DevupVisitor::new(&allocator, "test.tsx", "@devup-ui/react", Vec::new(), None); + visitor.reuse_scoping(Some(scoping)); + visitor.visit_program(&mut program); + let printed = Codegen::new().build(&program).code; + assert!(printed.contains("css({ color: \"red\" })"), "{printed}"); + assert!(printed.contains("export const a = \"a\";"), "{printed}"); + assert_eq!(visitor.styles.len(), 1); +} diff --git a/libs/extractor/src/scope/tests/stylex.rs b/libs/extractor/src/scope/tests/stylex.rs new file mode 100644 index 000000000..50f3e3e94 --- /dev/null +++ b/libs/extractor/src/scope/tests/stylex.rs @@ -0,0 +1,105 @@ +use rustc_hash::FxHashMap; +use serial_test::serial; + +use super::{visit, visit_with}; + +#[test] +#[serial] +fn stylex_follows_the_bindings_of_its_namespaces() { + let visited = visit( + "import * as stylex from '@stylexjs/stylex';\n\ + import { props } from '@stylexjs/stylex';\n\ + const styles = stylex.create({ base: { color: 'red' } });\n\ + export const a = stylex.props(styles.base);\n\ + export const b = props(styles.base);\n\ + export function c(stylex) { return stylex.props(styles.base); }\n\ + export function d(props) { return props(styles.base); }\n\ + export function e(styles) { return stylex.props(styles.base); }\n\ + export function f() { const styles = stylex.create({ other: { color: 'blue' } }); return stylex.props(styles.other); }", + ); + assert_eq!(visited.errors, Vec::::new()); + assert_eq!(visited.styles, 2); + for expected in [ + "export const a = { className: \"a\" };", + "export const b = { className: \"a\" };", + "return stylex.props(styles.base);", + "return props(styles.base);", + "return { className: [styles.base].flat(Infinity).filter(Boolean).join(\" \") };", + "return { className: \"b\" };", + ] { + assert!( + visited.code.contains(expected), + "{expected}: {}", + visited.code + ); + } +} + +#[test] +#[serial] +fn stylex_variables_and_names_follow_the_bindings_that_hold_them() { + let visited = visit( + "import * as stylex from '@stylexjs/stylex';\n\ + const vars = stylex.defineVars({ color: 'red' });\n\ + const consts = stylex.defineConsts({ gap: '8px' });\n\ + const fade = stylex.keyframes({ from: { opacity: 0 } });\n\ + const theme = stylex.createTheme(vars, { color: 'blue' });\n\ + const styles = stylex.create({ base: { color: vars.color, marginTop: consts.gap, animationName: fade } });\n\ + const more = stylex.create({ extra: { ...stylex.include(styles.base), padding: 0 } });\n\ + export const a = stylex.props(theme, styles.base, more.extra);\n\ + export function f(vars) { return stylex.create({ other: { color: vars.color } }); }\n\ + export function g(fade) { return stylex.create({ other: { animationName: fade } }); }\n\ + export function h(styles) { return stylex.create({ other: { ...stylex.include(styles.base) } }); }\n\ + export function i(vars) { return stylex.createTheme(vars, { color: 'green' }); }", + ); + assert_eq!( + visited + .errors + .iter() + .map(|error| error.split(" at build time").next().unwrap_or_default()) + .collect::>(), + vec![ + "`stylex.create()` cannot use `vars.color`", + "`stylex.create()` cannot use `fade`", + "`stylex.include()` cannot use `styles.base`", + "`stylex.createTheme()` cannot use `vars, { color: \"green\" }`", + ] + ); +} + +#[test] +#[serial] +fn stylex_values_imported_from_other_modules_bind_to_their_imports() { + let visited = visit_with( + "import * as stylex from '@stylexjs/stylex';\n\ + import { vars, darkTheme } from './vars.stylex';\n\ + const styles = stylex.create({ base: { color: vars.color } });\n\ + export const a = stylex.props(darkTheme, styles.base);\n\ + export function f(vars, darkTheme) { return stylex.props(darkTheme, stylex.create({ other: { color: vars.color } })); }", + |visitor| { + visitor.import_stylex( + FxHashMap::from_iter([( + "vars".to_string(), + FxHashMap::from_iter([("color".to_string(), "--x".to_string())]), + )]), + FxHashMap::from_iter([ + ("darkTheme".to_string(), "dark-theme".to_string()), + ("absent".to_string(), "none".to_string()), + ]), + ); + }, + ); + assert_eq!(visited.errors.len(), 1, "{:?}", visited.errors); + assert!( + visited.errors[0].contains("`stylex.create()` cannot use `vars.color`"), + "{:?}", + visited.errors + ); + assert!( + visited + .code + .contains("export const a = { className: \"dark-theme a\" };"), + "{}", + visited.code + ); +} diff --git a/libs/extractor/src/scope/tests/theme_reads.rs b/libs/extractor/src/scope/tests/theme_reads.rs new file mode 100644 index 000000000..dc0e4bf45 --- /dev/null +++ b/libs/extractor/src/scope/tests/theme_reads.rs @@ -0,0 +1,99 @@ +use serial_test::serial; + +use super::{visit, visit_with}; +use crate::css_prop::CssProp; + +const IMPORT: &str = "import { ClassNames } from '@devup-ui/react/compat';\n"; + +fn class_names(rendered: &str) -> super::Visited { + visit(&format!( + "{IMPORT}export const a = {{({{ css, cx, theme }}) => {rendered}}};" + )) +} + +#[test] +#[serial] +fn a_local_sharing_the_name_of_the_theme_is_not_the_theme() { + for rendered in [ + "
theme.on)(state) })} />", + "
", + "
{ const theme = state; return theme.on; })() })} />", + "
{ { const theme = state; return theme.on; } })() })} />", + "
theme.on) })} />", + "
theme.on)(1, state) })} />", + ] { + let visited = class_names(rendered); + assert_eq!(visited.errors, Vec::::new(), "{rendered}"); + } +} + +#[test] +#[serial] +fn the_theme_the_child_function_takes_becomes_css_variables() { + let visited = class_names( + "
theme.m)(1) })} />", + ); + assert_eq!(visited.errors, Vec::::new()); + assert_eq!(visited.styles, 1); + assert!( + visited.code.contains("((theme) => theme.m)(1)"), + "{}", + visited.code + ); + assert!( + !visited.code.contains("var(--m)"), + "the parameter is not the theme: {}", + visited.code + ); +} + +#[test] +#[serial] +fn a_direct_read_of_the_theme_it_cannot_write_is_still_reported() { + for rendered in [ + "
", + "
theme.on)(state) })} />", + "
theme.on)(theme.on) })} />", + ] { + let visited = class_names(rendered); + assert_eq!(visited.errors.len(), 1, "{rendered}: {:?}", visited.errors); + assert!( + visited.errors[0].contains("`` cannot use `theme.on`"), + "{rendered}: {:?}", + visited.errors + ); + } +} + +#[test] +#[serial] +fn a_function_of_the_theme_in_a_css_prop_reads_only_its_own_parameter() { + let compile = |code: &str| { + visit_with( + &format!("import {{ Box }} from '@devup-ui/react';\n{code}"), + |visitor| visitor.takes_css_prop(CssProp::Elements), + ) + }; + let own = + compile("export const a = ({ color: theme.colors.primary })} />;"); + assert_eq!(own.errors, Vec::::new()); + assert!(own.code.contains("className"), "{}", own.code); + + let local = + compile("export const a = ({ color: ((theme) => theme.a)(1) })} />;"); + assert!( + !local.errors.iter().any(|error| error.contains("theme.a")), + "{:?}", + local.errors + ); + + let global = compile("export const a = ({ color: theme.a })} />;"); + assert!( + !global + .errors + .iter() + .any(|error| error.contains("`theme.a`")), + "a free `theme` is not a parameter: {:?}", + global.errors + ); +} diff --git a/libs/extractor/src/scope/visits.rs b/libs/extractor/src/scope/visits.rs new file mode 100644 index 000000000..9e8c79931 --- /dev/null +++ b/libs/extractor/src/scope/visits.rs @@ -0,0 +1,230 @@ +use oxc_ast::ast::{ + Argument, CallExpression, Expression, IdentifierReference, JSXElementName, Statement, TSType, + TaggedTemplateExpression, +}; +use oxc_ast_visit::{Visit, VisitMut, walk, walk_mut}; +use oxc_syntax::symbol::SymbolId; +use rustc_hash::{FxHashMap, FxHashSet}; + +use super::Bindings; + +/// The binding of every read in what it visits, by where the read was written +pub struct Remember<'s> { + bindings: &'s Bindings, + recovered: FxHashMap<(u32, u32), SymbolId>, +} + +impl<'s> Remember<'s> { + pub fn new(bindings: &'s Bindings) -> Self { + Self { + bindings, + recovered: FxHashMap::default(), + } + } + + pub fn into_recovered(self) -> FxHashMap<(u32, u32), SymbolId> { + self.recovered + } +} + +impl<'a> Visit<'a> for Remember<'_> { + fn visit_identifier_reference(&mut self, it: &IdentifierReference<'a>) { + if let Some(symbol) = self.bindings.symbol(it) { + self.recovered.insert((it.span.start, it.span.end), symbol); + } + } +} + +/// The binding each name read stands for, none when its reads differ +pub struct Names<'s> { + bindings: &'s Bindings, + names: FxHashMap>, +} + +impl<'s> Names<'s> { + pub fn new(bindings: &'s Bindings) -> Self { + Self { + bindings, + names: FxHashMap::default(), + } + } + + pub fn into_visible(self) -> FxHashMap { + self.names + .into_iter() + .filter_map(|(name, symbol)| Some((name, symbol?))) + .collect() + } +} + +impl<'a> Visit<'a> for Names<'_> { + fn visit_identifier_reference(&mut self, it: &IdentifierReference<'a>) { + let symbol = self.bindings.symbol(it); + self.names + .entry(it.name.to_string()) + .and_modify(|seen| { + if *seen != symbol { + *seen = None; + } + }) + .or_insert(symbol); + } +} + +/// The reads a program keeps, outside the types it erases, of bindings whose +/// declarations the build removes +pub struct CompiledReads<'s> { + bindings: &'s Bindings, + /// Whether the calls and elements the build compiles are not reads: the + /// code is read before they are + before_lowering: bool, + found: Vec<(u32, String)>, +} + +impl<'s> CompiledReads<'s> { + pub const fn new(bindings: &'s Bindings, before_lowering: bool) -> Self { + Self { + bindings, + before_lowering, + found: Vec::new(), + } + } + + pub fn into_found(self) -> Vec<(u32, String)> { + self.found + } + + /// The arguments of `call`, a call the build compiles, but not the base + /// `styled(Box)` takes + fn compiled_call(&mut self, call: &CallExpression<'_>) { + let base = self.bindings.styles(&call.callee); + let metadata = matches!(&call.callee, Expression::StaticMemberExpression(member) + if member.property.name == "withConfig" && self.bindings.compiles(&member.object)); + for (index, argument) in call.arguments.iter().enumerate() { + let renders = argument + .as_expression() + .is_some_and(|expression| self.bindings.kind(expression).is_some()); + if !(metadata || base && index == 0 && renders) { + self.visit_argument(argument); + } + } + self.compiled_target(&call.callee); + } + + /// What `callee`, the target of a call the build compiles, is called on + fn compiled_target(&mut self, callee: &Expression<'_>) { + match callee { + Expression::CallExpression(call) => self.compiled_call(call), + Expression::StaticMemberExpression(member) => self.compiled_target(&member.object), + _ => {} + } + } +} + +impl<'a> Visit<'a> for CompiledReads<'_> { + fn visit_identifier_reference(&mut self, it: &IdentifierReference<'a>) { + if self + .bindings + .symbol(it) + .is_some_and(|symbol| self.bindings.compiled.contains(&symbol)) + { + self.found.push((it.span.start, it.name.to_string())); + } + } + + fn visit_call_expression(&mut self, it: &CallExpression<'a>) { + if self.before_lowering + && self + .bindings + .jsx_function(&it.callee) + .is_some_and(|function| matches!(function.as_str(), "jsx" | "jsxs" | "jsxDEV")) + && it.arguments.get(1).is_some_and(Argument::is_expression) + && it + .arguments + .first() + .and_then(Argument::as_expression) + .is_some_and(|component| self.bindings.kind(component).is_some()) + { + self.visit_expression(&it.callee); + for argument in it.arguments.iter().skip(1) { + self.visit_argument(argument); + } + } else if self.before_lowering && self.bindings.compiles(&it.callee) { + self.compiled_call(it); + } else { + walk::walk_call_expression(self, it); + } + } + + fn visit_tagged_template_expression(&mut self, it: &TaggedTemplateExpression<'a>) { + if self.before_lowering && self.bindings.compiles(&it.tag) { + self.compiled_target(&it.tag); + self.visit_template_literal(&it.quasi); + } else { + walk::walk_tagged_template_expression(self, it); + } + } + + fn visit_jsx_element_name(&mut self, it: &JSXElementName<'a>) { + let rendered = matches!(it, JSXElementName::IdentifierReference(identifier) + if self.bindings.renders(identifier)); + if !(self.before_lowering && rendered) { + walk::walk_jsx_element_name(self, it); + } + } + + fn visit_ts_type(&mut self, _: &TSType<'a>) {} +} + +/// Whether `program` calls `require` for `package` +pub fn requires(program: &oxc_ast::ast::Program<'_>, package: &str) -> bool { + let mut requires = Requires { + package, + found: false, + }; + requires.visit_program(program); + requires.found +} + +struct Requires<'p> { + package: &'p str, + found: bool, +} + +impl<'a> Visit<'a> for Requires<'_> { + fn visit_call_expression(&mut self, it: &CallExpression<'a>) { + self.found |= matches!(&it.callee, Expression::Identifier(callee) if callee.name == "require") + && matches!(it.arguments.as_slice(), [Argument::StringLiteral(source)] if source.value == self.package); + walk::walk_call_expression(self, it); + } +} + +/// Removes the declarations of aliases, wherever they stand +pub struct RemoveAliases<'s> { + aliases: &'s FxHashSet, +} + +impl<'s> RemoveAliases<'s> { + pub const fn new(aliases: &'s FxHashSet) -> Self { + Self { aliases } + } +} + +impl<'a> VisitMut<'a> for RemoveAliases<'_> { + fn visit_statements(&mut self, it: &mut oxc_allocator::Vec<'a, Statement<'a>>) { + walk_mut::walk_statements(self, it); + it.retain_mut(|statement| { + let Statement::VariableDeclaration(declaration) = statement else { + return true; + }; + declaration.declarations.retain(|declarator| { + !declarator + .id + .get_binding_identifier() + .and_then(|id| id.symbol_id.get()) + .is_some_and(|symbol| self.aliases.contains(&symbol)) + }); + !declaration.declarations.is_empty() + }); + } +} diff --git a/libs/extractor/src/style_values.rs b/libs/extractor/src/style_values.rs index e908704c0..79d7cf12b 100644 --- a/libs/extractor/src/style_values.rs +++ b/libs/extractor/src/style_values.rs @@ -2,6 +2,8 @@ //! `const fadeIn = keyframes({ ... })`: the build gives it a name, so styles //! reading the binding read that name +use std::rc::Rc; + use oxc_allocator::{FromIn, GetAllocator}; use oxc_ast::ast::{BindingPattern, Expression, Str, TemplateLiteral}; use oxc_ast::builder::AstBuilder; @@ -29,14 +31,16 @@ pub enum StyleValue { #[derive(Default)] pub struct StyleValues { - scoping: Option, + scoping: Option>, values: FxHashMap, /// The styles behind `css()` classes the file imports, by binding imported: FxHashMap>, } impl StyleValues { - pub fn new(scoping: Scoping) -> Self { + /// The values of the bindings `scoping`, which the other readers of the + /// program share, tells + pub fn new(scoping: Rc) -> Self { Self { scoping: Some(scoping), values: FxHashMap::default(), diff --git a/libs/extractor/src/stylex.rs b/libs/extractor/src/stylex.rs index f7c272c5e..39ce07ef7 100644 --- a/libs/extractor/src/stylex.rs +++ b/libs/extractor/src/stylex.rs @@ -25,9 +25,19 @@ pub enum StylexFunction { DefineConsts, PositionTry, ViewTransitionClass, + /// Reads inside the value of a `stylex.create()` style + FirstThatWorks, + /// Reads inside a `stylex.create()` namespace, spread + Include, + /// The `types` object, whose members wrap a `stylex.defineVars()` value + Types, } -const STYLEX_EXPORTS: [(&str, StylexFunction); 10] = [ +/// Tells which `StyleX` API a callee or member object reads, by the binding it +/// reads and not by its spelling +pub type StylexResolver<'r> = &'r dyn Fn(&Expression<'_>) -> Option; + +const STYLEX_EXPORTS: [(&str, StylexFunction); 13] = [ ("create", StylexFunction::Create), ("props", StylexFunction::Props), ("attrs", StylexFunction::Attrs), @@ -38,6 +48,9 @@ const STYLEX_EXPORTS: [(&str, StylexFunction); 10] = [ ("defineConsts", StylexFunction::DefineConsts), ("positionTry", StylexFunction::PositionTry), ("viewTransitionClass", StylexFunction::ViewTransitionClass), + ("firstThatWorks", StylexFunction::FirstThatWorks), + ("include", StylexFunction::Include), + ("types", StylexFunction::Types), ]; impl StylexFunction { @@ -57,11 +70,13 @@ impl StylexFunction { .map_or("", |(name, _)| name) } - /// What a call must be to compile away, for the functions that do + /// What a call must be to compile away, for the functions that do. The + /// helpers read inside an enclosing `StyleX` call, which reports what it + /// cannot read of them #[must_use] pub const fn requirement(&self) -> Option<&'static str> { match self { - Self::Props | Self::Attrs => None, + Self::Props | Self::Attrs | Self::FirstThatWorks | Self::Include | Self::Types => None, Self::CreateTheme => Some( "it takes a `defineVars()` group, of this file or imported, and an object literal", ), @@ -93,9 +108,12 @@ pub type Conditions = Vec<(AtRuleKind, String)>; /// The values a `StyleX` variable takes: a literal, or a condition object of a /// `default` and at-rule keys (`@media`, `@supports`, `@container`), either /// possibly wrapped in `types.*()`. `None` when a value is not static. -pub fn variable_values(value: &Expression<'_>) -> Option> { +pub fn variable_values( + value: &Expression<'_>, + resolver: StylexResolver<'_>, +) -> Option> { let mut values = Vec::new(); - collect_variable_values(value, &mut Vec::new(), &mut values)?; + collect_variable_values(value, &mut Vec::new(), &mut values, resolver)?; Some(values) } @@ -103,8 +121,9 @@ fn collect_variable_values( value: &Expression<'_>, conditions: &mut Conditions, values: &mut Vec<(Conditions, String)>, + resolver: StylexResolver<'_>, ) -> Option<()> { - let value = unwrap_types_call(value); + let value = unwrap_types_call(value, resolver); if let Some(text) = get_string_by_literal_expression(value) { values.push((conditions.clone(), text.into_owned())); return Some(()); @@ -121,21 +140,24 @@ fn collect_variable_values( }; let key = get_string_by_property_key(&property.key)?; if key == "default" { - collect_variable_values(&property.value, conditions, values)?; + collect_variable_values(&property.value, conditions, values, resolver)?; continue; } let (kind, query) = split_at_rule_key(&key)?; conditions.push((kind, normalize_query(query))); - collect_variable_values(&property.value, conditions, values)?; + collect_variable_values(&property.value, conditions, values, resolver)?; conditions.pop(); } Some(()) } #[must_use] -pub fn unwrap_types_call<'b, 'a>(value: &'b Expression<'a>) -> &'b Expression<'a> { +pub fn unwrap_types_call<'b, 'a>( + value: &'b Expression<'a>, + resolver: StylexResolver<'_>, +) -> &'b Expression<'a> { match value { - Expression::CallExpression(call) if is_types_call(&call.callee) => call + Expression::CallExpression(call) if is_types_call(&call.callee, resolver) => call .arguments .first() .and_then(oxc_ast::ast::Argument::as_expression) @@ -190,37 +212,14 @@ pub fn css_variable_block(selector: &str, assignments: &[(String, String)]) -> S css } -/// Check if a call expression is `stylex.firstThatWorks()` or named `firstThatWorks()`. -pub fn is_first_that_works_call(callee: &Expression) -> bool { - // stylex.firstThatWorks(...) - if let Expression::StaticMemberExpression(member) = callee - && member.property.name.as_str() == "firstThatWorks" - { - return true; - } - // firstThatWorks(...) (named import) - if let Expression::Identifier(ident) = callee - && ident.name.as_str() == "firstThatWorks" - { - return true; - } - false +/// Whether `callee` reads the `firstThatWorks` the package gives +pub fn is_first_that_works_call(callee: &Expression, resolver: StylexResolver<'_>) -> bool { + resolver(callee) == Some(StylexFunction::FirstThatWorks) } -/// Check if a call expression is `stylex.include()` or named `include()`. -/// This is a static check that does NOT require access to the visitor. -pub fn is_include_call_static(callee: &Expression) -> bool { - if let Expression::StaticMemberExpression(member) = callee - && member.property.name.as_str() == "include" - { - return true; - } - if let Expression::Identifier(ident) = callee - && ident.name.as_str() == "include" - { - return true; - } - false +/// Whether `callee` reads the `include` the package gives +pub fn is_include_call_static(callee: &Expression, resolver: StylexResolver<'_>) -> bool { + resolver(callee) == Some(StylexFunction::Include) } /// A reference to a stylex.include(base.member) call found inside `stylex.create()`. @@ -231,19 +230,11 @@ pub struct StylexIncludeRef { pub offset: u32, } -/// Check if a call expression is `stylex.types.X()` or `types.X()` (type wrapper). -pub fn is_types_call(callee: &Expression) -> bool { - if let Expression::StaticMemberExpression(member) = callee { - // stylex.types.X(...) - if let Expression::StaticMemberExpression(inner) = &member.object { - return inner.property.name.as_str() == "types"; - } - // types.X(...) (named import) - if let Expression::Identifier(ident) = &member.object { - return ident.name.as_str() == "types"; - } - } - false +/// Whether `callee` is a member of the `types` the package gives, as +/// `stylex.types.color` or `types.color` +pub fn is_types_call(callee: &Expression, resolver: StylexResolver<'_>) -> bool { + matches!(callee, Expression::StaticMemberExpression(member) + if resolver(&member.object) == Some(StylexFunction::Types)) } /// Convert camelCase CSS property name to kebab-case. @@ -548,6 +539,7 @@ pub fn decompose_value_conditions( parent_selectors: &[SelectorPart], leaf: &dyn Fn(&str, &Expression) -> Option, errors: &mut Vec<(u32, String)>, + resolver: StylexResolver<'_>, ) -> Vec { if let Some(s) = leaf(css_property, value) { return decomposed_leaf(css_property, Some(s), parent_selectors) @@ -564,7 +556,7 @@ pub fn decompose_value_conditions( // CallExpression: firstThatWorks() → multiple fallback values with current selectors if let Expression::CallExpression(call) = value - && is_first_that_works_call(&call.callee) + && is_first_that_works_call(&call.callee, resolver) { let mut results = vec![]; for arg in call.arguments.iter().rev() { @@ -584,7 +576,7 @@ pub fn decompose_value_conditions( // CallExpression: types.*() → extract inner value, pass through selectors if let Expression::CallExpression(call) = value - && is_types_call(&call.callee) + && is_types_call(&call.callee, resolver) && let Some(s) = call .arguments .first() @@ -645,6 +637,7 @@ pub fn decompose_value_conditions( &selectors, leaf, errors, + resolver, )); } @@ -731,10 +724,11 @@ mod tests { let leaf = |property: &str, value: &Expression| { stylex_value(property, value).map(std::borrow::Cow::into_owned) }; - let styles: Vec<_> = decompose_value_conditions("color", value, &[], &leaf, &mut vec![]) - .into_iter() - .map(|style| (style.value, style.selector.map(|s| s.to_string()))) - .collect(); + let styles: Vec<_> = + decompose_value_conditions("color", value, &[], &leaf, &mut vec![], &|_| None) + .into_iter() + .map(|style| (style.value, style.selector.map(|s| s.to_string()))) + .collect(); assert_eq!( styles, vec![ diff --git a/libs/extractor/src/utils.rs b/libs/extractor/src/utils.rs index e5fb825c0..f68903c95 100644 --- a/libs/extractor/src/utils.rs +++ b/libs/extractor/src/utils.rs @@ -738,22 +738,24 @@ pub(super) fn style_arguments<'a>( pub(super) fn reads_unknown( expression: &Expression<'_>, unknown: &crate::imported_constants::Unknown, + reads: &dyn Fn(&oxc_ast::ast::IdentifierReference<'_>) -> bool, ) -> bool { match unwrap_syntax_only(expression) { Expression::ArrayExpression(array) => array.elements.iter().any(|element| { element .as_expression() - .is_some_and(|element| reads_unknown(element, unknown)) + .is_some_and(|element| reads_unknown(element, unknown, reads)) }), Expression::LogicalExpression(logical) => { - (logical.operator != LogicalOperator::And && reads_unknown(&logical.left, unknown)) - || reads_unknown(&logical.right, unknown) + (logical.operator != LogicalOperator::And + && reads_unknown(&logical.left, unknown, reads)) + || reads_unknown(&logical.right, unknown, reads) } Expression::ConditionalExpression(conditional) => { - reads_unknown(&conditional.consequent, unknown) - || reads_unknown(&conditional.alternate, unknown) + reads_unknown(&conditional.consequent, unknown, reads) + || reads_unknown(&conditional.alternate, unknown, reads) } - expression => unknown.read_by(expression), + expression => unknown.read_by_in(expression, reads), } } diff --git a/libs/extractor/src/visit.rs b/libs/extractor/src/visit.rs index f78d276d3..f17f73d2c 100644 --- a/libs/extractor/src/visit.rs +++ b/libs/extractor/src/visit.rs @@ -1,9 +1,8 @@ use crate::as_visit::As; -use crate::component::ExportVariableKind; use crate::composition::{KnownPart, KnownSide, KnownStyles, overlaps, set_prop_order}; use crate::css_prop::{ - ClassNamesParams, CssProp, THEME_READ, class_names_params, read_theme, render_function, - template_parts, theme_rules, + ClassNamesParams, CssProp, THEME_READ, ThemeRoot, class_names_params, read_theme, + render_function, template_parts, theme_rules_of, }; use crate::css_utils::{ TemplateStyles, css_to_style_template, keyframes_to_keyframes_style, optimize_css_block, @@ -37,6 +36,7 @@ use crate::prop_modify_utils::{ add_class_and_style, add_class_and_style_to_object, convert_class_name, modify_prop_object, modify_props, written_class_name, written_object_class_name, }; +use crate::scope::{Bindings, ClassNamesSymbols, NAMESPACE_STYLED}; use crate::stylex::{ StylexDynamicInfo, StylexFunction, StylexNamespaceValue, create_theme_class, css_variable_block, css_variable_rules, define_vars_variable, variable_values, @@ -68,7 +68,7 @@ use oxc_ast_visit::walk_mut::{ }; use oxc_syntax::number::NumberBase; use oxc_syntax::operator::LogicalOperator; -use strum::IntoEnumIterator; +use oxc_syntax::symbol::SymbolId; use crate::utils::{ CLASS_NAMES_CALL, CLASS_NAMES_CHILD, CLASS_NAMES_CLASS_MAP, CLASS_NAMES_PART, CSS_PROP_VALUE, @@ -177,11 +177,8 @@ fn style_property_into_string(style_property: StyleProperty) -> String { pub struct DevupVisitor<'a> { pub ast: AstBuilder<'a>, filename: String, - imports: FxHashMap, - import_object: Option, - jsx_imports: FxHashMap, - util_imports: FxHashMap>, - jsx_object: Option, + /// The bindings the file declares for the APIs the build compiles + bindings: Bindings, package: String, /// Entry the rewritten imports of absorbed third-party APIs land on. Its specifiers /// register exactly like the main package's so those calls still compile away. @@ -192,34 +189,30 @@ pub struct DevupVisitor<'a> { /// Styles the file writes that cannot be extracted at build time, by the /// offset of the code each is about pub errors: Vec<(u32, String)>, - styled_imports: FxHashSet, - /// Tracked `StyleX` default/namespace import names (e.g., `stylex` from `import stylex from '...'`) - stylex_imports: FxHashSet, - /// Tracked `StyleX` named imports (e.g., `create` from `import { create } from '...'`) - stylex_named_imports: FxHashMap, /// Pending `StyleX` namespace map from the most recent `stylex.create()` call. /// Set in `visit_expression`, consumed in `visit_variable_declarator`. stylex_pending_create: Option>, - /// Maps variable names to their namespace→className mappings from `stylex.create()`. - /// e.g., "styles" → { "base" → "a b", "active" → "c" } - stylex_namespaces: FxHashMap>, - /// Local names bound to the `Global` component, whose `styles` prop declares - /// global CSS instead of rendering markup. - global_style_components: FxHashSet, - /// Local names bound to Emotion's `ClassNames`, whose element becomes - /// what its child function renders - class_names_components: FxHashSet, + /// Maps bindings to their namespace→className mappings from `stylex.create()`. + /// e.g., `styles` → { "base" → "a b", "active" → "c" } + stylex_namespaces: FxHashMap>, /// What the `` child functions being compiled take - class_names_scope: Vec>, + class_names_scope: Vec, - /// `defineVars` members flattened to `"vars.key"` -> `"var(--x)"`, so a - /// `stylex.create()` value referencing one resolves to a static CSS value. - stylex_var_refs: FxHashMap, + /// `defineVars` and `defineConsts` members of a binding as `key` -> + /// `"var(--x)"`, so a `stylex.create()` value referencing one resolves to + /// a static CSS value. + stylex_var_refs: FxHashMap>, /// `defineVars` bindings as `vars` -> (`key` -> `--x`), the contract /// `createTheme` reassigns. - stylex_var_names: FxHashMap>, + stylex_var_names: FxHashMap>, /// `createTheme` bindings as `theme` -> `class`, so `stylex.props(theme)` resolves. - stylex_theme_classes: FxHashMap, + stylex_theme_classes: FxHashMap, + /// `StyleX` variables and themes the program imports from other modules, + /// by the name it binds them to + imported_stylex: ( + FxHashMap>, + FxHashMap, + ), /// Pending `defineVars` contract awaiting its variable declarator. stylex_pending_vars: Option>, /// Pending `createTheme` class awaiting its variable declarator. @@ -228,9 +221,9 @@ pub struct DevupVisitor<'a> { stylex_pending_consts: Option>, /// Pending keyframe animation name from most recent `stylex.keyframes()` call. stylex_pending_keyframe_name: Option, - /// Maps variable names to their keyframe animation names. - /// e.g., "fadeIn" → "a-a" - stylex_keyframe_names: FxHashMap, + /// Maps bindings to their keyframe animation names. + /// e.g., `fadeIn` → "a-a" + stylex_keyframe_names: FxHashMap, /// What the element just visited becomes when it is not an element any /// more (a dynamic `as`, a spread evaluated once): set in /// `visit_jsx_element`, written where the element stands by the visit of @@ -240,6 +233,8 @@ pub struct DevupVisitor<'a> { /// Elements whose type only the runtime gives, which each bind it to a /// name of their own runtime_types: usize, + /// The semantic analysis of the program, when built before the visit + scoping: Option>, /// The classes and keyframes names the file binds to a `const` style_values: crate::style_values::StyleValues, /// The styles of the last `css()` call giving a class, by where it starts, @@ -261,9 +256,6 @@ pub struct DevupVisitor<'a> { /// The styled components the file binds to a `const`, which a component /// extending one renders in its place styled_definitions: FxHashMap>, - /// What the build compiles away: the imports of the package it removes, - /// and the bindings aliasing them, which only its calls and elements read - compiled_names: FxHashSet, unknown_bindings: crate::imported_constants::Unknown, /// Whether `css()` or `styled()` joined as a class, or an element took /// through a spread, a binding that may hold rules only running the module @@ -285,60 +277,28 @@ pub struct DevupVisitor<'a> { local_styles: FxHashSet, } -/// Whether `declarator` only aliases what the build compiles away -/// (`const newCss = css`) -fn is_alias(declarator: &VariableDeclarator<'_>, compiled: &FxHashSet) -> bool { - matches!(&declarator.init, Some(Expression::Identifier(init)) if compiled.contains(init.name.as_str())) - && declarator - .id - .get_binding_identifier() - .is_some_and(|id| compiled.contains(id.name.as_str())) -} - -/// The reads of `names` a program keeps outside the types it erases; with -/// `scoping`, only those no binding of the program declares -struct CompiledReads<'s> { - names: &'s FxHashSet, - scoping: Option<&'s oxc_semantic::Scoping>, - found: Vec<(u32, String)>, -} - -impl<'a> oxc_ast_visit::Visit<'a> for CompiledReads<'_> { - fn visit_identifier_reference(&mut self, it: &oxc_ast::ast::IdentifierReference<'a>) { - if self.names.contains(it.name.as_str()) - && self.scoping.is_none_or(|scoping| { - it.reference_id - .get() - .is_none_or(|reference| scoping.get_reference(reference).symbol_id().is_none()) - }) - { - self.found.push((it.span.start, it.name.to_string())); - } - } - - fn visit_ts_type(&mut self, _: &oxc_ast::ast::TSType<'a>) {} -} - /// Whether `expression`, or a value it chooses, reads an object or array code -/// changes +/// changes, through the identifiers `reads` accepts fn reads_binding( expression: &Expression<'_>, changed: &crate::imported_constants::Changed, + reads: &dyn Fn(&oxc_ast::ast::IdentifierReference<'_>) -> bool, ) -> bool { match unwrap_syntax_only(expression) { Expression::ArrayExpression(array) => array.elements.iter().any(|element| { element .as_expression() - .is_some_and(|element| reads_binding(element, changed)) + .is_some_and(|element| reads_binding(element, changed, reads)) }), Expression::LogicalExpression(logical) => { - reads_binding(&logical.left, changed) || reads_binding(&logical.right, changed) + reads_binding(&logical.left, changed, reads) + || reads_binding(&logical.right, changed, reads) } Expression::ConditionalExpression(conditional) => { - reads_binding(&conditional.consequent, changed) - || reads_binding(&conditional.alternate, changed) + reads_binding(&conditional.consequent, changed, reads) + || reads_binding(&conditional.alternate, changed, reads) } - expression => changed.read_by(expression), + expression => changed.read_by_in(expression, reads), } } @@ -358,7 +318,9 @@ impl<'a> DevupVisitor<'a> { Argument::SpreadElement(spread) => &spread.argument, argument => argument.to_expression(), }; - if reads_unknown(expression, &self.unknown_bindings) { + if reads_unknown(expression, &self.unknown_bindings, &|identifier| { + self.bindings.reads_module(identifier) + }) { self.composes_unknown = true; self.unknown_parts.push(( argument.span().start, @@ -378,7 +340,9 @@ impl<'a> DevupVisitor<'a> { Argument::SpreadElement(spread) => &spread.argument, argument => argument.to_expression(), }; - if reads_binding(expression, &self.changed_bindings) { + if reads_binding(expression, &self.changed_bindings, &|identifier| { + self.bindings.reads_module(identifier) + }) { self.errors.push(( argument.span().start, build_time_error(api, &readable_argument(argument), STYLE_OBJECT), @@ -489,7 +453,9 @@ impl<'a> DevupVisitor<'a> { parts: &mut Vec>, text: Text, ) -> Option<()> { - let clone = |expression: &Expression<'a>| expression.clone_in(self.ast.allocator()); + let clone = |expression: &Expression<'a>| { + expression.clone_in_with_semantic_ids(self.ast.allocator()) + }; let (test, consequent, alternate) = match unwrap_syntax_only(expression) { Expression::ArrayExpression(array) => { for element in &array.elements { @@ -571,7 +537,9 @@ impl<'a> DevupVisitor<'a> { consequent: KnownSide<'a>, alternate: KnownSide<'a>, ) { - let clone = |expression: &Expression<'a>| expression.clone_in(self.ast.allocator()); + let clone = |expression: &Expression<'a>| { + expression.clone_in_with_semantic_ids(self.ast.allocator()) + }; let mut classes = [None, None]; let mut styles = [None, None]; for (index, side) in [consequent, alternate].into_iter().enumerate() { @@ -728,26 +696,18 @@ impl<'a> DevupVisitor<'a> { Self { ast: AstBuilder::new(allocator), filename: filename.to_string(), - imports: FxHashMap::default(), - jsx_imports: FxHashMap::default(), + bindings: Bindings::default(), package: package.to_string(), compat_package: format!("{package}/compat"), css_files, styles: FxHashSet::default(), errors: Vec::new(), - import_object: None, - jsx_object: None, - util_imports: FxHashMap::default(), split_filename, - styled_imports: FxHashSet::default(), - stylex_imports: FxHashSet::default(), - stylex_named_imports: FxHashMap::default(), - global_style_components: FxHashSet::default(), - class_names_components: FxHashSet::default(), class_names_scope: Vec::new(), stylex_var_refs: FxHashMap::default(), stylex_var_names: FxHashMap::default(), stylex_theme_classes: FxHashMap::default(), + imported_stylex: (FxHashMap::default(), FxHashMap::default()), stylex_pending_vars: None, stylex_pending_theme_class: None, stylex_pending_consts: None, @@ -758,6 +718,7 @@ impl<'a> DevupVisitor<'a> { pending_replacement: None, spreads_read_once: 0, runtime_types: 0, + scoping: None, style_values: crate::style_values::StyleValues::default(), css_styles: None, pending_styled: None, @@ -766,7 +727,6 @@ impl<'a> DevupVisitor<'a> { forwards_refs: false, styled_definitions: FxHashMap::default(), imported_css: FxHashMap::default(), - compiled_names: FxHashSet::default(), unknown_bindings: crate::imported_constants::Unknown::default(), composes_unknown: false, unknown_parts: Vec::new(), @@ -787,60 +747,19 @@ impl<'a> DevupVisitor<'a> { /// The style function `callee` names: `css`, `Devup.keyframes`, ... fn util_type(&self, callee: &Expression<'a>) -> Option> { - match callee { - Expression::Identifier(ident) => self.util_imports.get(ident.name.as_str()), - Expression::StaticMemberExpression(member) if !self.util_imports.is_empty() => { - let Expression::Identifier(ident) = &member.object else { - return None; - }; - let obj = ident.name.as_str(); - let prop = member.property.name.as_str(); - let mut key = String::with_capacity(obj.len() + 1 + prop.len()); - key.push_str(obj); - key.push('.'); - key.push_str(prop); - self.util_imports.get(key.as_str()) - } - _ => None, - } - .cloned() - } - - /// Register what the package imported whole as `local` gives: its - /// components, style functions, `styled` and `Global` as members - fn import_whole(&mut self, local: &str) { - for kind in ExportVariableKind::iter() { - self.imports.insert(format!("{local}.{kind}"), kind); - } - for (name, kind) in [ - ("css", UtilType::Css), - ("globalCss", UtilType::GlobalCss), - ("keyframes", UtilType::Keyframes), - ("createGlobalStyle", UtilType::GlobalCssComponent), - ] { - self.util_imports - .insert(format!("{local}.{name}"), Rc::new(kind)); - } - self.styled_imports.insert(format!("{local}.styled")); - self.global_style_components - .insert(format!("{local}.Global")); + self.bindings.util(callee) } /// `node` with a `styled` read as a member of the package imported whole - /// (`Devup.styled.div`) written as a binding of the same text, which the - /// extraction of `styled` reads like the named import + /// (`Devup.styled.div`) written as a binding no source can name, which + /// the extraction of `styled` reads like the named import fn plain_styled(&self, node: &mut Expression<'a>) { if let Expression::StaticMemberExpression(member) = unwrap_syntax_only(node) && member.property.name == "styled" && let Expression::Identifier(object) = &member.object { - let name = format!("{}.styled", object.name); - if self.styled_imports.contains(&name) { - *node = Expression::new_identifier( - SPAN, - Str::from_in(name.as_str(), self.ast.allocator()), - &self.ast, - ); + if self.bindings.is_namespace(object) { + *node = Expression::new_identifier(SPAN, NAMESPACE_STYLED, &self.ast); } return; } @@ -854,27 +773,13 @@ impl<'a> DevupVisitor<'a> { /// Report where `program` still reads what the build compiled away: its /// import is gone, so that code would throw at runtime fn report_compiled_reads(&mut self, program: &Program<'a>) { - let mut reads = CompiledReads { - names: &self.compiled_names, - scoping: None, - found: Vec::new(), - }; - oxc_ast_visit::Visit::visit_program(&mut reads, program); - if reads.found.is_empty() { - return; - } - // A binding the code declares may share the name - let scoping = oxc_semantic::SemanticBuilder::new() - .build(program) - .semantic - .into_scoping(); - let mut reads = CompiledReads { - names: &self.compiled_names, - scoping: Some(&scoping), - found: Vec::new(), - }; - oxc_ast_visit::Visit::visit_program(&mut reads, program); - for (offset, name) in reads.found { + let reads = self.bindings.compiled_reads(program); + self.report_reads(reads); + } + + /// Report the reads `reads` tells of what the build compiled away + fn report_reads(&mut self, reads: Vec<(u32, String)>) { + for (offset, name) in reads { if !self.errors.iter().any(|(at, _)| *at == offset) { self.errors.push(( offset, @@ -886,6 +791,51 @@ impl<'a> DevupVisitor<'a> { } } + /// Report what `it`, a call or template the build compiles, reads of + /// what the build compiled away: its styles are written from the code + /// read, which the reads would leave as the names of nothing + fn report_lowered_reads(&mut self, it: &Expression<'a>) { + let callee = match it { + Expression::CallExpression(call) => &call.callee, + Expression::TaggedTemplateExpression(tag) => &tag.tag, + _ => return, + }; + if self.bindings.compiles(callee) { + let reads = self.bindings.lowered_reads(it); + self.report_reads(reads); + } + } + + /// Reuse the semantic analysis of the program as parsed, which constants + /// inlined since then leave valid + pub fn reuse_scoping(&mut self, scoping: Option>) { + self.scoping = scoping; + } + + /// The bindings of `program`: the analysis given, or one built when the + /// program may bind what the build compiles + fn scoping_of(&mut self, program: &Program<'a>) -> Option> { + self.scoping.take().or_else(|| { + let imports_api = program.body.iter().any(|statement| { + matches!(statement, Statement::ImportDeclaration(import) + if import.source.value == self.package + || import.source.value == self.compat_package.as_str() + || import.source.value == "@stylexjs/stylex") + }); + (imports_api + || self.css_prop != CssProp::Off + || crate::scope::requires(program, &self.package)) + .then(|| { + Rc::new( + oxc_semantic::SemanticBuilder::new() + .build(program) + .semantic + .into_scoping(), + ) + }) + }) + } + /// Whether `program` imports a style function whose result it may bind: /// `css()` gives a class, `keyframes()` a name fn binds_style_results(&self, program: &Program<'a>) -> bool { @@ -970,9 +920,8 @@ impl<'a> DevupVisitor<'a> { /// Whether the element `name` takes Emotion's `css` prop fn takes_css(&self, name: &JSXElementName<'a>) -> bool { - self.css_prop.takes(name, |_| { - self.imports.contains_key(name.to_string().as_str()) - }) + self.css_prop + .takes(name, |_| self.bindings.element(name).is_some()) } /// Take the `css` prop off `element` when it takes one @@ -1010,6 +959,9 @@ impl<'a> DevupVisitor<'a> { /// The `css` prop `value` of `element`, written at `offset`, with what it /// composes written as parts, then visited fn css_value(&mut self, element: &str, mut value: Expression<'a>, offset: u32) -> CssValue<'a> { + self.bindings.remember(&value); + let reads = self.bindings.lowered_reads(&value); + self.report_reads(reads); self.css_prop_value(element, &mut value); self.visit_expression(&mut value); self.compiled_css_prop = true; @@ -1083,10 +1035,14 @@ impl<'a> DevupVisitor<'a> { } } function @ (Expression::ArrowFunctionExpression(_) - | Expression::FunctionExpression(_)) => match theme_rules(&self.ast, function) { - Ok(rules) => rules, - Err(unread) => self.css_prop_failure(element, unread), - }, + | Expression::FunctionExpression(_)) => { + match theme_rules_of(&self.ast, function, &|identifier| { + self.bindings.symbol(identifier) + }) { + Ok(rules) => rules, + Err(unread) => self.css_prop_failure(element, unread), + } + } _ => return, }; *value = parts; @@ -1182,7 +1138,9 @@ impl<'a> DevupVisitor<'a> { return earlier; } let unknown = self.css_part(&value, &|visitor, part| { - visitor.unknown_bindings.read_by(part) + visitor.unknown_bindings.read_by_in(part, &|identifier| { + visitor.bindings.reads_module(identifier) + }) }); if let Some(part) = unknown { self.composes_unknown = true; @@ -1192,7 +1150,9 @@ impl<'a> DevupVisitor<'a> { )); } let changed = self.css_part(&value, &|visitor, part| { - visitor.changed_bindings.read_by(part) + visitor.changed_bindings.read_by_in(part, &|identifier| { + visitor.bindings.reads_module(identifier) + }) }); if let Some(part) = changed { self.errors.push(( @@ -1456,7 +1416,7 @@ impl<'a> DevupVisitor<'a> { let JSXElementName::IdentifierReference(name) = &element.opening_element.name else { return None; }; - if !self.class_names_components.contains(name.name.as_str()) { + if !self.bindings.is_class_names(name) { return None; } Some( @@ -1495,15 +1455,21 @@ impl<'a> DevupVisitor<'a> { }; let function_at = function.span().start; let function_code = readable_code(function); - let Some((names, body)) = render_function(function) - .and_then(|(params, body)| Some((class_names_params(params)?, body))) - else { + let Some((names, symbols, body)) = render_function(function).and_then(|(params, body)| { + Some(( + class_names_params(params)?, + ClassNamesSymbols::of(params), + body, + )) + }) else { return Err((function_at, function_code)); }; let mut rendered = body.take_in(&self.ast); let mut calls = ClassNamesCalls { ast: &self.ast, + bindings: &self.bindings, names, + symbols, unread: None, }; calls.visit_expression(&mut rendered); @@ -1513,7 +1479,7 @@ impl<'a> DevupVisitor<'a> { element_error("ClassNames", &readable_code(&unread), requirement), )); } - self.class_names_scope.push(names); + self.class_names_scope.push(symbols); self.visit_expression(&mut rendered); self.class_names_scope.pop(); Ok(rendered) @@ -1525,11 +1491,11 @@ impl<'a> DevupVisitor<'a> { let Expression::Identifier(callee) = callee else { return None; }; - let name = Some(callee.name.as_str()); + let symbol = self.bindings.symbol(callee); self.class_names_scope.iter().rev().find_map(|names| { - if names.css == name { + if ClassNamesSymbols::is(names.css, symbol) { Some(Text::Rules) - } else if names.cx == name { + } else if ClassNamesSymbols::is(names.cx, symbol) { Some(Text::Classes) } else { None @@ -1570,19 +1536,31 @@ impl<'a> DevupVisitor<'a> { /// code the build cannot ready so is kept. struct ClassNamesCalls<'r, 'a> { ast: &'r AstBuilder<'a>, + bindings: &'r Bindings, names: ClassNamesParams<'a>, + symbols: ClassNamesSymbols, unread: Option<(Expression<'a>, &'static str)>, } impl<'a> ClassNamesCalls<'_, 'a> { - fn calls(callee: &Expression<'a>, name: Option<&'a str>) -> bool { - matches!(callee, Expression::Identifier(callee) if name == Some(callee.name.as_str())) + fn calls(&self, callee: &Expression<'a>, slot: Option) -> bool { + matches!(callee, Expression::Identifier(callee) + if ClassNamesSymbols::is(slot, self.bindings.symbol(callee))) } fn read_theme(&mut self, expression: &mut Expression<'a>, value: bool) { - if let Some(theme) = self.names.theme - && let Err(unread) = read_theme(self.ast, expression, theme, value) - { + let Some(name) = self.names.theme else { + return; + }; + let theme = ThemeRoot { + name, + symbol: self.symbols.theme, + key: None, + }; + let bindings = self.bindings; + if let Err(unread) = read_theme(self.ast, expression, theme, value, &|identifier| { + bindings.symbol(identifier) + }) { self.unread.get_or_insert((unread, THEME_READ)); } } @@ -1673,7 +1651,7 @@ impl<'a> VisitMut<'a> for ClassNamesCalls<'_, 'a> { walk_mut::walk_expression(self, it); return; }; - if !Self::calls(&tagged.tag, self.names.css) { + if !self.calls(&tagged.tag, self.symbols.css) { walk_mut::walk_expression(self, it); return; } @@ -1698,8 +1676,8 @@ impl<'a> VisitMut<'a> for ClassNamesCalls<'_, 'a> { } fn visit_call_expression(&mut self, call: &mut CallExpression<'a>) { - let cx = Self::calls(&call.callee, self.names.cx); - if !cx && !Self::calls(&call.callee, self.names.css) { + let cx = self.calls(&call.callee, self.symbols.cx); + if !cx && !self.calls(&call.callee, self.symbols.css) { walk_call_expression(self, call); return; } @@ -1718,10 +1696,12 @@ impl<'a> VisitMut<'a> for ClassNamesCalls<'_, 'a> { /// A read of the theme or of `css` and `cx` the calls do not take fn visit_identifier_reference(&mut self, it: &mut oxc_ast::ast::IdentifierReference<'a>) { - let name = Some(it.name.as_str()); - let requirement = if self.names.theme == name { + let symbol = self.bindings.symbol(it); + let requirement = if ClassNamesSymbols::is(self.symbols.theme, symbol) { THEME_READ - } else if self.names.css == name || self.names.cx == name { + } else if ClassNamesSymbols::is(self.symbols.css, symbol) + || ClassNamesSymbols::is(self.symbols.cx, symbol) + { CLASS_NAMES_CALL } else { return; @@ -1740,47 +1720,86 @@ impl<'a> DevupVisitor<'a> { /// it produces. `props()` targets React (`className`), `attrs()` targets raw HTML /// (`class`); everything else about the two calls is identical. fn stylex_class_attribute(&self, callee: &Expression) -> Option<&'static str> { - // Check namespace/default call: stylex.props(...) - if let Expression::StaticMemberExpression(member) = callee - && let Expression::Identifier(ident) = &member.object - && self.stylex_imports.contains(ident.name.as_str()) - { - return match member.property.name.as_str() { - "props" => Some("className"), - "attrs" => Some("class"), - _ => None, - }; - } - // Check named import call: props(...) - if let Expression::Identifier(ident) = callee { - return match self.stylex_named_imports.get(ident.name.as_str()) { - Some(StylexFunction::Props) => Some("className"), - Some(StylexFunction::Attrs) => Some("class"), - _ => None, - }; + match self.bindings.stylex_function(callee) { + Some(StylexFunction::Props) => Some("className"), + Some(StylexFunction::Attrs) => Some("class"), + _ => None, } - None } - /// `StyleX` variables and themes the program imports from other modules, - /// by the name it binds them to + /// The styles behind the `css()` classes the program imports from other + /// modules, by the name it binds them to pub fn import_css(&mut self, styles: FxHashMap>) { self.imported_css = styles; } + /// `StyleX` variables and themes the program imports from other modules, + /// by the name it binds them to pub fn import_stylex( &mut self, vars: FxHashMap>, themes: FxHashMap, ) { + self.imported_stylex = (vars, themes); + } + + /// Read the `StyleX` variables and themes the program imports as the + /// bindings that hold them + fn bind_imported_stylex(&mut self) { + let (vars, themes) = std::mem::take(&mut self.imported_stylex); for (name, contract) in vars { - for (key, variable) in &contract { - self.stylex_var_refs - .insert(format!("{name}.{key}"), format!("var({variable})")); + if let Some(symbol) = self.bindings.imported(&name) { + self.stylex_var_refs.entry(symbol).or_default().extend( + contract + .iter() + .map(|(key, variable)| (key.clone(), format!("var({variable})"))), + ); + self.stylex_var_names.insert(symbol, contract.clone()); } - self.stylex_var_names.insert(name, contract); } - self.stylex_theme_classes.extend(themes); + for (name, class) in themes { + if let Some(symbol) = self.bindings.imported(&name) { + self.stylex_theme_classes.insert(symbol, class); + } + } + } + + /// The keyframe names and `defineVars` members, as `vars.key`, that the + /// bindings `visible` to a `stylex.create()` call stand for + fn stylex_names( + &self, + visible: &FxHashMap, + ) -> (FxHashMap, FxHashMap) { + let keyframes = visible + .iter() + .filter_map(|(name, symbol)| { + Some(( + name.clone(), + self.stylex_keyframe_names.get(symbol)?.clone(), + )) + }) + .collect(); + let vars = visible + .iter() + .filter_map(|(name, symbol)| Some((name, self.stylex_var_refs.get(symbol)?))) + .flat_map(|(name, members)| { + members + .iter() + .map(move |(key, value)| (format!("{name}.{key}"), value.clone())) + }) + .collect(); + (keyframes, vars) + } + + /// The namespaces of the `stylex.create()` binding `object` reads + fn stylex_namespace( + &self, + object: &Expression<'a>, + ) -> Option<&FxHashMap> { + let Expression::Identifier(object) = object else { + return None; + }; + self.stylex_namespaces.get(&self.bindings.symbol(object)?) } /// The key of an entry of a `StyleX` object, or `None` after reporting a @@ -1827,25 +1846,8 @@ impl<'a> DevupVisitor<'a> { ) } - /// The `StyleX` API a callee names, through either the namespace form - /// (`stylex.defineVars`) or a named import. - fn stylex_function(&self, callee: &Expression) -> Option { - match callee { - Expression::StaticMemberExpression(member) - if matches!(&member.object, Expression::Identifier(ident) - if self.stylex_imports.contains(ident.name.as_str())) => - { - StylexFunction::from_export_name(member.property.name.as_str()) - } - Expression::Identifier(ident) => { - self.stylex_named_imports.get(ident.name.as_str()).cloned() - } - _ => None, - } - } - fn is_stylex_call(&self, callee: &Expression, function: &StylexFunction) -> bool { - self.stylex_function(callee).as_ref() == Some(function) + self.bindings.stylex_function(callee).as_ref() == Some(function) } /// Resolve `stylex.props()` arguments to className expressions and style properties. @@ -1954,8 +1956,7 @@ impl<'a> DevupVisitor<'a> { call: &CallExpression<'a>, ) -> Option<(Expression<'a>, Vec>)> { if let Expression::StaticMemberExpression(member) = &call.callee - && let Expression::Identifier(obj) = &member.object - && let Some(ns_map) = self.stylex_namespaces.get(obj.name.as_str()) + && let Some(ns_map) = self.stylex_namespace(&member.object) && let Some(StylexNamespaceValue::Dynamic(info)) = ns_map.get(member.property.name.as_str()) { @@ -1971,8 +1972,10 @@ impl<'a> DevupVisitor<'a> { if let Some(arg) = call.arguments.get(*param_idx) && let Some(arg_expr) = arg.as_expression() { - let arg_expr = - self.with_number_unit(arg_expr.clone_in(self.ast.allocator()), unit); + let arg_expr = self.with_number_unit( + arg_expr.clone_in_with_semantic_ids(self.ast.allocator()), + unit, + ); props.push(ObjectPropertyKind::new_object_property( SPAN, PropertyKind::Init, @@ -2045,8 +2048,7 @@ impl<'a> DevupVisitor<'a> { &self, member: &StaticMemberExpression<'a>, ) -> Option> { - if let Expression::Identifier(obj) = &member.object - && let Some(ns_map) = self.stylex_namespaces.get(obj.name.as_str()) + if let Some(ns_map) = self.stylex_namespace(&member.object) && let Some(StylexNamespaceValue::Static(cn)) = ns_map.get(member.property.name.as_str()) { @@ -2066,10 +2068,7 @@ impl<'a> DevupVisitor<'a> { &self, member: &ComputedMemberExpression<'a>, ) -> Option> { - let Expression::Identifier(obj) = &member.object else { - return None; - }; - let ns_map = self.stylex_namespaces.get(obj.name.as_str())?; + let ns_map = self.stylex_namespace(&member.object)?; if let Some(key) = get_string_by_literal_expression(&member.expression) { return match ns_map.get(key.as_ref()) { @@ -2093,8 +2092,12 @@ impl<'a> DevupVisitor<'a> { &self.ast, &Expression::ComputedMemberExpression(ComputedMemberExpression::boxed( SPAN, - member.object.clone_in(self.ast.allocator()), - member.expression.clone_in(self.ast.allocator()), + member + .object + .clone_in_with_semantic_ids(self.ast.allocator()), + member + .expression + .clone_in_with_semantic_ids(self.ast.allocator()), member.optional, &self.ast, )), @@ -2113,8 +2116,9 @@ impl<'a> DevupVisitor<'a> { } // darkTheme → Identifier bound to a stylex.createTheme() class Expression::Identifier(ident) if ident.name != "undefined" => self - .stylex_theme_classes - .get(ident.name.as_str()) + .bindings + .symbol(ident) + .and_then(|symbol| self.stylex_theme_classes.get(&symbol)) .and_then(|class_name| self.stylex_class_literal(class_name)), Expression::ChainExpression(chain) => match &chain.expression { ChainElement::StaticMemberExpression(member) => { @@ -2147,8 +2151,7 @@ impl<'a> DevupVisitor<'a> { }, _ => expr, }; - matches!(object, Expression::Identifier(ident) - if self.stylex_namespaces.contains_key(ident.name.as_str())) + self.stylex_namespace(object).is_some() } fn resolve_stylex_composed_arg(&self, expr: &Expression<'a>) -> Option> { @@ -2169,7 +2172,9 @@ impl<'a> DevupVisitor<'a> { // The right side should be the namespace reference if let Some(class_expr) = self.resolve_stylex_arg(&logical.right) { // Build: condition ? " className" : "" - let condition = logical.left.clone_in(self.ast.allocator()); + let condition = logical + .left + .clone_in_with_semantic_ids(self.ast.allocator()); Some(Expression::new_conditional_expression( SPAN, condition, @@ -2187,13 +2192,13 @@ impl<'a> DevupVisitor<'a> { let alternate = self.resolve_stylex_arg(&cond.alternate); match (consequent, alternate) { (Some(cons), Some(alt)) => { - let test = cond.test.clone_in(self.ast.allocator()); + let test = cond.test.clone_in_with_semantic_ids(self.ast.allocator()); Some(Expression::new_conditional_expression( SPAN, test, cons, alt, &self.ast, )) } (Some(cons), None) => { - let test = cond.test.clone_in(self.ast.allocator()); + let test = cond.test.clone_in_with_semantic_ids(self.ast.allocator()); Some(Expression::new_conditional_expression( SPAN, test, @@ -2203,7 +2208,7 @@ impl<'a> DevupVisitor<'a> { )) } (None, Some(alt)) => { - let test = cond.test.clone_in(self.ast.allocator()); + let test = cond.test.clone_in_with_semantic_ids(self.ast.allocator()); Some(Expression::new_conditional_expression( SPAN, Expression::new_unary_expression( @@ -2246,56 +2251,27 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { &mut self, it: &mut oxc_allocator::Vec<'a, VariableDeclarator<'a>>, ) { - for v in it.iter() { - if let VariableDeclarator { - id, - init: Some(Expression::Identifier(ident)), - .. - } = v - && let Some(name) = id.get_binding_identifier().map(|id| id.name.to_string()) - { - let original = ident.name.as_str(); - if let Some(util) = self.util_imports.get(original).cloned() { - self.util_imports.insert(name.clone(), util); - } else if self.styled_imports.contains(original) { - self.styled_imports.insert(name.clone()); - } else if let Some(kind) = self.imports.get(original).cloned() { - self.imports.insert(name.clone(), kind); - } else { - continue; - } - self.compiled_names.insert(name); - } + for declarator in it.iter() { + self.bindings.require(declarator, &self.package); + self.bindings.alias(declarator); } walk_variable_declarators(self, it); } fn visit_program(&mut self, it: &mut Program<'a>) { - if self.binds_style_results(it) || self.css_prop != CssProp::Off { - self.style_values = crate::style_values::StyleValues::new( - oxc_semantic::SemanticBuilder::new() - .build(it) - .semantic - .into_scoping(), - ); + if let Some(scoping) = self.scoping_of(it) { + self.bindings.scope(Rc::clone(&scoping)); + self.bind_imported_stylex(); + self.style_values = crate::style_values::StyleValues::new(scoping); self.style_values .import(std::mem::take(&mut self.imported_css)); - self.selected_components = crate::style_values::selected(it, &self.style_values); + if self.binds_style_results(it) || self.css_prop != CssProp::Off { + self.selected_components = crate::style_values::selected(it, &self.style_values); + } } walk_program(self, it); - if !self.compiled_names.is_empty() { - // Aliases only the calls and elements the build compiled read; at - // the top level nothing can shadow what they alias - let compiled = &self.compiled_names; - it.body.retain_mut(|statement| { - let Statement::VariableDeclaration(declaration) = statement else { - return true; - }; - declaration - .declarations - .retain(|declarator| !is_alias(declarator, compiled)); - !declaration.declarations.is_empty() - }); + if self.bindings.is_compiled() { + self.bindings.remove_aliases(it); self.report_compiled_reads(it); } if self.forwards_refs { @@ -2346,7 +2322,7 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { if !self.class_names_scope.is_empty() && self.compile_class_names_call(it) { return; } - if !self.styled_imports.is_empty() { + if self.bindings.may_style() { match it { Expression::CallExpression(call) => self.plain_styled(&mut call.callee), Expression::TaggedTemplateExpression(tag) => self.plain_styled(&mut tag.tag), @@ -2360,11 +2336,13 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { Expression::TaggedTemplateExpression(tag) => Some(&mut tag.tag), _ => None, }; - let styled_imports = &self.styled_imports; - let (attrs, mut configs) = factory.map_or_else( + let bindings = &self.bindings; + let (mut attrs, mut configs) = factory.map_or_else( || (Vec::new(), Vec::new()), |factory| { - take_styled_modifiers(&self.ast, factory, |name| styled_imports.contains(name)) + take_styled_modifiers(&self.ast, factory, |expression| { + bindings.is_styled(expression) + }) }, ); let factory = match it { @@ -2378,7 +2356,7 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { && call.arguments[1].as_expression().is_some_and(|options| { matches!(unwrap_syntax_only(options), Expression::ObjectExpression(_)) }) - && matches!(&call.callee, Expression::Identifier(ident) if self.styled_imports.contains(ident.name.as_str())) + && matches!(&call.callee, callee @ Expression::Identifier(_) if self.bindings.is_styled(callee)) && let Some(options) = call.arguments.pop() { configs.insert(0, options.into_expression()); @@ -2387,41 +2365,39 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { if let Some(error) = forward_error { self.errors.push(error); } + for attr in &mut attrs { + let reads = self.bindings.lowered_reads(attr); + self.report_reads(reads); + self.bindings.remember(attr); + self.visit_expression(attr); + } walk_expression(self, it); + self.report_lowered_reads(it); // Handle styled function calls - if !self.styled_imports.is_empty() { + if self.bindings.may_style() { let (tag_or_call, argument_count) = match it { Expression::TaggedTemplateExpression(tag) => (Some(&tag.tag), 0), Expression::CallExpression(call) => (Some(&call.callee), call.arguments.len()), _ => (None, 0), }; - let is_styled_name = |name: &str| self.styled_imports.contains(name); + let bindings = &self.bindings; - let is_styled = if let Some(tag_or_call) = tag_or_call.map(unwrap_syntax_only) { - if let Expression::StaticMemberExpression(member) = tag_or_call { - if let Expression::Identifier(ident) = &member.object { - is_styled_name(ident.name.as_str()) - } else { - false + let is_styled = tag_or_call + .map(unwrap_syntax_only) + .is_some_and(|tag_or_call| match tag_or_call { + Expression::StaticMemberExpression(member) => { + bindings.is_styled(&member.object) } - } else if let Expression::CallExpression(call) = tag_or_call { - if let Expression::Identifier(ident) = &call.callee { - is_styled_name(ident.name.as_str()) - } else { - false - } - } else if let Expression::Identifier(ident) = tag_or_call { + Expression::CallExpression(call) => bindings.is_styled(&call.callee), // styled("div", { ... }) puts the tag in the arguments, so the callee is // the bare identifier. One argument is the curried creator `styled("div")`, // which only becomes a component once its result is called. - is_styled_name(ident.name.as_str()) && argument_count == 2 - } else { - false - } - } else { - false - }; + Expression::Identifier(_) => { + bindings.is_styled(tag_or_call) && argument_count == 2 + } + _ => false, + }); if is_styled { self.style_values.read_in(&self.ast, it); @@ -2450,7 +2426,7 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { split_filename: self.split_filename.as_deref(), marker: marker.as_deref(), }, - &self.imports, + &|expression| self.bindings.kind(expression), &attrs, inherited, forward, @@ -2504,11 +2480,14 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { && self.is_stylex_call(&call.callee, &StylexFunction::Create) && let [Argument::ObjectExpression(arg)] = call.arguments.as_slice() { + let visible = self.bindings.visible(arg); + let (keyframe_names, var_refs) = self.stylex_names(&visible); let namespaces = extract_stylex_namespace_styles( arg, - &self.stylex_keyframe_names, - &self.stylex_var_refs, + &keyframe_names, + &var_refs, &mut self.errors, + &|callee| self.bindings.stylex_function(callee), ); let mut namespace_map: FxHashMap = FxHashMap::default(); @@ -2530,9 +2509,9 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { // Resolve include() references — prepend included classNames for inc_ref in &include_refs { - let Some(ns_value) = self - .stylex_namespaces + let Some(ns_value) = visible .get(&inc_ref.var_name) + .and_then(|symbol| self.stylex_namespaces.get(symbol)) .and_then(|ns| ns.get(&inc_ref.member_name)) else { self.errors.push(( @@ -2659,7 +2638,9 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { continue; }; let values = if publishes_values { - let Some(values) = variable_values(&prop.value) else { + let Some(values) = variable_values(&prop.value, &|callee| { + self.bindings.stylex_function(callee) + }) else { self.errors.push(( prop.value.span().start, runtime_value_error("stylex.defineVars", &readable_code(&prop.value)), @@ -2692,7 +2673,10 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { && self.is_stylex_call(&call.callee, &StylexFunction::CreateTheme) && let [contract_arg, values_arg] = call.arguments.as_slice() && let Some(Expression::Identifier(contract_ident)) = contract_arg.as_expression() - && let Some(contract) = self.stylex_var_names.get(contract_ident.name.as_str()) + && let Some(contract) = self + .bindings + .symbol(contract_ident) + .and_then(|symbol| self.stylex_var_names.get(&symbol)) && let Some(Expression::ObjectExpression(obj)) = values_arg.as_expression() { let mut variables = vec![]; @@ -2719,7 +2703,8 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { )); continue; }; - match variable_values(&prop.value) { + match variable_values(&prop.value, &|callee| self.bindings.stylex_function(callee)) + { Some(values) => variables.push((variable.clone(), values)), None => self.errors.push(( prop.value.span().start, @@ -2777,7 +2762,9 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { } else { "stylex.viewTransitionClass" }; - let declarations = extract_stylex_declarations(api, arg, &mut self.errors); + let declarations = extract_stylex_declarations(api, arg, &mut self.errors, &|callee| { + self.bindings.stylex_function(callee) + }); if !declarations.is_empty() { let css = if is_position_try { css_variable_block(&format!("@position-try {name}"), &declarations) @@ -2877,7 +2864,7 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { // Reached only when none of the blocks above replaced the call, so a surviving // compile-time call is one whose arguments could not be read statically. if let Expression::CallExpression(call) = it - && let Some(function) = self.stylex_function(&call.callee) + && let Some(function) = self.bindings.stylex_function(&call.callee) && let Some(requirement) = function.requirement() { let arguments: Vec = call.arguments.iter().map(readable_argument).collect(); @@ -3071,7 +3058,7 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { None, &self.ast, )), - rules.clone_in(self.ast.allocator()), + rules.clone_in_with_semantic_ids(self.ast.allocator()), false, false, false, @@ -3172,9 +3159,9 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { self.styles.insert(v); } } - let mixins = statements - .iter() - .map(|index| tag.quasi.expressions[*index].clone_in(self.ast.allocator())); + let mixins = statements.iter().map(|index| { + tag.quasi.expressions[*index].clone_in_with_semantic_ids(self.ast.allocator()) + }); merge_expression_for_class_name(&self.ast, mixins.chain(class_name)) .unwrap_or_else(|| Expression::new_string_literal(SPAN, "", None, &self.ast)) // already set style order @@ -3234,36 +3221,20 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { } } fn visit_call_expression(&mut self, it: &mut CallExpression<'a>) { - let jsx = if let Expression::Identifier(ident) = &it.callee { - self.jsx_imports.get(ident.name.as_str()).cloned() - } else if let Some(name) = &self.jsx_object - && let Expression::StaticMemberExpression(member) = &it.callee - && let Expression::Identifier(ident) = &member.object - && name == ident.name.as_str() - { - Some(member.property.name.to_string()) - } else { - None - }; - if let Some(j) = jsx + if let Some(j) = self.bindings.jsx_function(&it.callee) && matches!(j.as_str(), "jsx" | "jsxs" | "jsxDEV") && let Some(expr) = it.arguments.first().and_then(|arg| arg.as_expression()) { - let element_kind = if let Expression::Identifier(ident) = expr { - self.imports.get(ident.name.as_str()).cloned() - } else if let Expression::StaticMemberExpression(member) = expr - && let Expression::Identifier(ident) = &member.object - && self.import_object.as_deref() == Some(ident.name.as_str()) + let element_kind = self.bindings.kind(expr); + if let Some(props) = it.arguments.get(1).and_then(Argument::as_expression) { + self.bindings.remember(props); + } + if element_kind.is_some() + && let Some(props) = it.arguments.get(1).and_then(Argument::as_expression) { - member - .property - .name - .as_str() - .parse::() - .ok() - } else { - None - }; + let reads = self.bindings.lowered_reads(props); + self.report_reads(reads); + } let css = self.take_jsx_css_prop(it, element_kind.is_some()); if element_kind.is_none() && let Some(css) = css @@ -3431,7 +3402,7 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { it.arguments[0] = Argument::from(tag); if !read_once.is_empty() { let call = Expression::CallExpression(oxc_allocator::Box::new_in( - it.clone_in(self.ast.allocator()), + it.clone_in_with_semantic_ids(self.ast.allocator()), &self.ast, )); if let Expression::CallExpression(reading_once) = @@ -3446,47 +3417,6 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { } fn visit_variable_declarator(&mut self, it: &mut VariableDeclarator<'a>) { - if let Some(Expression::CallExpression(call)) = &it.init - && call.arguments.len() == 1 - && let (Expression::Identifier(ident), Argument::StringLiteral(arg)) = - (&call.callee, &call.arguments[0]) - && ident.name == "require" - { - if arg.value == "react/jsx-runtime" { - if let BindingPattern::BindingIdentifier(ident) = &it.id { - self.jsx_object = Some(ident.name.to_string()); - } else if let BindingPattern::ObjectPattern(object) = &it.id { - for prop in &object.properties { - if let Some(name) = get_string_by_property_key(&prop.key) - && let Some(k) = prop - .value - .get_binding_identifier() - .map(|id| id.name.to_string()) - { - self.jsx_imports.insert(k, name); - } - } - } - } else if arg.value == self.package { - if let BindingPattern::BindingIdentifier(ident) = &it.id { - self.import_object = Some(ident.name.to_string()); - } else if let BindingPattern::ObjectPattern(object) = &it.id { - for prop in &object.properties { - if let Some(name) = get_string_by_property_key(&prop.key) - && let Ok(kind) = ExportVariableKind::try_from( - prop.value - .get_binding_identifier() - .map(|id| id.name.to_string()) - .unwrap_or_default(), - ) - { - self.imports.insert(name, kind); - } - } - } - } - } - let style_result = match &it.init { Some(Expression::CallExpression(call)) => self.util_type(&call.callee), Some(Expression::TaggedTemplateExpression(tag)) => self.util_type(&tag.tag), @@ -3577,50 +3507,52 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { self.stylex_pending_create.take(); } + let bound = it + .id + .get_binding_identifier() + .and_then(|ident| ident.symbol_id.get()); + // After walking, capture stylex.create() variable binding if let Some(pending) = self.stylex_pending_create.take() - && let Some(ident) = it.id.get_binding_identifier() + && let Some(symbol) = bound { - self.stylex_namespaces - .insert(ident.name.to_string(), pending); + self.stylex_namespaces.insert(symbol, pending); } // Capture stylex.keyframes() variable binding if let Some(name) = self.stylex_pending_keyframe_name.take() - && let Some(ident) = it.id.get_binding_identifier() + && let Some(symbol) = bound { - self.stylex_keyframe_names - .insert(ident.name.to_string(), name); + self.stylex_keyframe_names.insert(symbol, name); } // Capture stylex.defineVars() variable binding if let Some(contract) = self.stylex_pending_vars.take() - && let Some(ident) = it.id.get_binding_identifier() + && let Some(symbol) = bound { - for (key, variable) in &contract { - self.stylex_var_refs - .insert(format!("{}.{key}", ident.name), format!("var({variable})")); - } - self.stylex_var_names - .insert(ident.name.to_string(), contract); + self.stylex_var_refs.entry(symbol).or_default().extend( + contract + .iter() + .map(|(key, variable)| (key.clone(), format!("var({variable})"))), + ); + self.stylex_var_names.insert(symbol, contract); } // Capture stylex.createTheme() variable binding if let Some(class_name) = self.stylex_pending_theme_class.take() - && let Some(ident) = it.id.get_binding_identifier() + && let Some(symbol) = bound { - self.stylex_theme_classes - .insert(ident.name.to_string(), class_name); + self.stylex_theme_classes.insert(symbol, class_name); } // Capture stylex.defineConsts() variable binding if let Some(constants) = self.stylex_pending_consts.take() - && let Some(ident) = it.id.get_binding_identifier() + && let Some(symbol) = bound { - for (key, value) in constants { - self.stylex_var_refs - .insert(format!("{}.{key}", ident.name), value); - } + self.stylex_var_refs + .entry(symbol) + .or_default() + .extend(constants); } } fn visit_import_declaration(&mut self, it: &mut ImportDeclaration<'a>) { @@ -3633,8 +3565,8 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { { for specifier in specifiers { if let ImportSpecifier(import) = specifier { - self.jsx_imports - .insert(import.local.to_string(), import.imported.to_string()); + self.bindings + .jsx_import(&import.local, import.imported.to_string()); } } } else if (it.source.value == self.package @@ -3646,40 +3578,34 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { match &specifiers[i] { ImportSpecifier(import) => { let imported_str = import.imported.to_string(); - let local = import.local.to_string(); + let local = import.local.symbol_id.get(); // Emotion's `jsx`, which builds elements as React does once // their `css` props are compiled if compat && imported_str == "jsx" { - self.jsx_imports.insert(local, imported_str); + self.bindings.jsx_import(&import.local, imported_str); continue; } if compat && imported_str == "ClassNames" { - self.class_names_components.insert(local.clone()); - self.compiled_names.insert(local); + self.bindings.class_names_component(local); + self.bindings.compile(local); specifiers.remove(i); continue; } - if let Ok(kind) = imported_str.parse::() { - self.imports.insert(local.clone(), kind); - } else if let Some(kind) = UtilType::from_str_opt(&imported_str) { - self.util_imports.insert(local.clone(), Rc::new(kind)); - } else if imported_str == "styled" { - self.styled_imports.insert(local.clone()); - } else { + if !self.bindings.export(local, &imported_str) { // `Global` stays, rendering nothing, so its binding does too if imported_str == "Global" { - self.global_style_components.insert(local); + self.bindings.global_component(local); } continue; } - self.compiled_names.insert(local); + self.bindings.compile(local); specifiers.remove(i); } ImportDeclarationSpecifier::ImportDefaultSpecifier(specifier) => { - self.import_whole(&specifier.local.name); + self.bindings.namespace(specifier.local.symbol_id.get()); } ImportDeclarationSpecifier::ImportNamespaceSpecifier(specifier) => { - self.import_whole(&specifier.local.name); + self.bindings.namespace(specifier.local.symbol_id.get()); } } } @@ -3688,17 +3614,18 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { for specifier in specifiers { match specifier { ImportDeclarationSpecifier::ImportDefaultSpecifier(default_spec) => { - self.stylex_imports - .insert(default_spec.local.name.to_string()); + self.bindings + .stylex_namespace(default_spec.local.symbol_id.get()); } ImportDeclarationSpecifier::ImportNamespaceSpecifier(ns_spec) => { - self.stylex_imports.insert(ns_spec.local.name.to_string()); + self.bindings + .stylex_namespace(ns_spec.local.symbol_id.get()); } ImportSpecifier(named_spec) => { let imported = named_spec.imported.to_string(); - let local = named_spec.local.name.to_string(); if let Some(func) = StylexFunction::from_export_name(&imported) { - self.stylex_named_imports.insert(local, func); + self.bindings + .stylex_import(named_spec.local.symbol_id.get(), func); } } } @@ -3739,17 +3666,11 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { // `` is Emotion's spelling of a global stylesheet. // Lift the rules out and strip every attribute, leaving a component that // renders nothing — the same shape `createGlobalStyle` collapses to. - if let Some(name) = match &elem.opening_element.name { - JSXElementName::Identifier(id) => Some(Cow::Borrowed(id.name.as_str())), - JSXElementName::IdentifierReference(id) => Some(Cow::Borrowed(id.name.as_str())), - JSXElementName::MemberExpression(member) - if !self.global_style_components.is_empty() => - { - Some(Cow::Owned(member.to_string())) - } - _ => None, - } && self.global_style_components.contains(name.as_ref()) + if self + .bindings + .is_global_component(&elem.opening_element.name) { + let name = elem.opening_element.name.to_string(); for i in (0..elem.opening_element.attributes.len()).rev() { let Attribute(attr) = &mut elem.opening_element.attributes[i] else { continue; @@ -3786,13 +3707,12 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { } // after run to convert css literal - let kind = match &elem.opening_element.name { - // Fast path: probe with `&str` directly, no allocation - JSXElementName::Identifier(id) => self.imports.get(id.name.as_str()), - JSXElementName::IdentifierReference(id) => self.imports.get(id.name.as_str()), - name => self.imports.get(name.to_string().as_str()), - }; + let kind = self.bindings.element(&elem.opening_element.name); if let Some(kind) = kind { + let reads = self + .bindings + .lowered_attribute_reads(&elem.opening_element.attributes); + self.report_reads(reads); let element_name = elem.opening_element.name.to_string(); // A spread whose value may change when read again is read once, as // its `className` and `style` are read beside it @@ -3839,14 +3759,18 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { && let JSXAttributeValue::ExpressionContainer(expr) = value && let Some(expression) = expr.expression.as_expression() { - props = Some(expression.clone_in(self.ast.allocator())); + props = Some( + expression.clone_in_with_semantic_ids(self.ast.allocator()), + ); } } else if property_name == "styleVars" { if let Some(value) = attr.value.as_ref() && let JSXAttributeValue::ExpressionContainer(expr) = value && let Some(expression) = expr.expression.as_expression() { - style_vars = Some(expression.clone_in(self.ast.allocator())); + style_vars = Some( + expression.clone_in_with_semantic_ids(self.ast.allocator()), + ); } } else if let Some(at) = &mut attr.value { if let JSXAttributeValue::ExpressionContainer(container) = at @@ -3910,8 +3834,16 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { && (matches!( unwrap_syntax_only(&spread.argument), Expression::CallExpression(_) - ) || reads_unknown(&spread.argument, &self.unknown_bindings)); - if runtime && reads_binding(&spread.argument, &self.changed_bindings) { + ) || reads_unknown( + &spread.argument, + &self.unknown_bindings, + &|identifier| self.bindings.reads_module(identifier), + )); + if runtime + && reads_binding(&spread.argument, &self.changed_bindings, &|identifier| { + self.bindings.reads_module(identifier) + }) + { self.errors.push(( spread.span.start, element_error( @@ -4004,8 +3936,9 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { } } JSXChild::Element(element) => { - let mut value = - Expression::JSXElement(element.clone_in(self.ast.allocator())); + let mut value = Expression::JSXElement( + element.clone_in_with_semantic_ids(self.ast.allocator()), + ); read_once.push(self.read_once(&mut value)); *child = JSXChild::ExpressionContainer(JSXExpressionContainer::boxed( SPAN, @@ -4014,8 +3947,9 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { )); } JSXChild::Fragment(fragment) => { - let mut value = - Expression::JSXFragment(fragment.clone_in(self.ast.allocator())); + let mut value = Expression::JSXFragment( + fragment.clone_in_with_semantic_ids(self.ast.allocator()), + ); read_once.push(self.read_once(&mut value)); *child = JSXChild::ExpressionContainer(JSXExpressionContainer::boxed( SPAN, @@ -4129,7 +4063,7 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { if !values.is_empty() { let element = self.pending_replacement.take().unwrap_or_else(|| { Expression::JSXElement(oxc_allocator::Box::new_in( - elem.clone_in(self.ast.allocator()), + elem.clone_in_with_semantic_ids(self.ast.allocator()), &self.ast, )) }); @@ -4162,28 +4096,34 @@ mod tests { } #[test] + #[serial] fn test_stylex_named_and_unrelated_imports() { + reset_class_map(); + reset_file_map(); let allocator = Allocator::default(); let source_type = SourceType::from_path("test.ts").unwrap(); - let mut program = Parser::new(&allocator, "import { create, props as sxProps, keyframes, unknown } from '@stylexjs/stylex'; import value from 'other';", source_type).parse().program; + let mut program = Parser::new(&allocator, "import { create, props as sxProps, keyframes, unknown } from '@stylexjs/stylex'; import value from 'other'; const styles = create({ base: { color: 'red' } }); export const a = sxProps(styles.base); export const b = unknown(styles.base); export const animation = keyframes({ from: { opacity: 0 } });", source_type).parse().program; let mut visitor = DevupVisitor::new(&allocator, "test.ts", "@devup-ui/react", Vec::new(), None); visitor.visit_program(&mut program); - assert_eq!( - visitor.stylex_named_imports.get("create"), - Some(&StylexFunction::Create) + let code = oxc_codegen::Codegen::new().build(&program).code; + assert!( + code.contains("const styles = { \"base\": \"a\" };"), + "{code}" ); - assert_eq!( - visitor.stylex_named_imports.get("sxProps"), - Some(&StylexFunction::Props) + assert!( + code.contains("export const a = { className: styles.base };") + || code.contains("export const a = { className: \"a\" };"), + "{code}" ); - assert_eq!( - visitor.stylex_named_imports.get("keyframes"), - Some(&StylexFunction::Keyframes) + assert!( + code.contains("export const b = unknown(styles.base);"), + "{code}" ); - assert!(!visitor.stylex_named_imports.contains_key("unknown")); + assert!(code.contains("export const animation = \""), "{code}"); + assert!(code.contains("import value from \"other\";"), "{code}"); } #[test] From 39ef954f6b37dd25e58d9066032a11273be92e22 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 4 Oct 2026 06:24:14 +0900 Subject: [PATCH 3/7] fix(extractor): scope aliased library number rewrites by binding Refs #695 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../src/import_alias_scoping_tests.rs | 243 ++++++++++++++++++ libs/extractor/src/import_alias_visit.rs | 201 +++++++++++---- 2 files changed, 392 insertions(+), 52 deletions(-) create mode 100644 libs/extractor/src/import_alias_scoping_tests.rs diff --git a/libs/extractor/src/import_alias_scoping_tests.rs b/libs/extractor/src/import_alias_scoping_tests.rs new file mode 100644 index 000000000..8d9ce5ab1 --- /dev/null +++ b/libs/extractor/src/import_alias_scoping_tests.rs @@ -0,0 +1,243 @@ +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}", + "{({ 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}" + ); +} diff --git a/libs/extractor/src/import_alias_visit.rs b/libs/extractor/src/import_alias_visit.rs index db6e569c8..6fca99ac6 100644 --- a/libs/extractor/src/import_alias_visit.rs +++ b/libs/extractor/src/import_alias_visit.rs @@ -11,22 +11,26 @@ 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, ArrowFunctionBody, CallExpression, Expression, ImportDeclarationSpecifier, - JSXAttributeItem, JSXAttributeValue, JSXElement, JSXElementName, JSXOpeningElement, - LogicalOperator, 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_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; @@ -41,35 +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, - /// Local names of Devup UI's exports, which take the `css` prop as tags do - devup: Vec<&'n str>, - /// Local names of the functions building elements from a type and props - jsx: Vec<&'n str>, - /// Local names of Emotion's `ClassNames`, whose child function takes a + /// 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<&'n str>, + 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) @@ -199,13 +215,11 @@ impl LibraryNumbers<'_> { } } -impl<'a> Visit<'a> for LibraryNumbers<'a> { +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.class_names.contains(&name.name.as_str()) => - { - class_names_child(element).and_then(|names| names.css) + JSXElementName::IdentifierReference(name) if self.contains(&self.class_names, name) => { + class_names_css_symbol(element) } _ => None, }; @@ -220,11 +234,12 @@ impl<'a> Visit<'a> for LibraryNumbers<'a> { 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), @@ -247,12 +262,13 @@ impl<'a> Visit<'a> for LibraryNumbers<'a> { self.pixelify(rules); } if let Expression::Identifier(callee) = &call.callee - && self.jsx.contains(&callee.name.as_str()) + && 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| self.devup.contains(&root)) + && 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 @@ -268,20 +284,44 @@ impl<'a> Visit<'a> for LibraryNumbers<'a> { 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) { self.pixelify_attribute(element, "styles"); } - if self - .css_prop - .takes(&element.name, |root| self.devup.contains(&root)) - { + 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. @@ -397,6 +437,21 @@ pub fn transform_import_aliases_with_edits<'a>( 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 @@ -420,21 +475,33 @@ pub fn transform_import_aliases_with_edits<'a>( 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(); - uses_emotion |= is_emotion(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.push(spec.local.name.as_str()); + numbers.jsx.extend(symbol(&spec.local)); } - specifier if source_value == package => { - numbers.devup.push(specifier.local().name.as_str()); + specifier if source_value == package && may_take_css => { + numbers.devup.extend(symbol(specifier.local())); } _ => {} } @@ -454,22 +521,37 @@ 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.push(local); + numbers.class_names.extend(symbol(&spec.local)); + } + ("@emotion/styled" | "styled-components", "styled") => { + numbers.styled.extend(symbol(&spec.local)); } _ => {} } @@ -480,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!( @@ -488,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)); } _ => {} } @@ -497,6 +579,7 @@ pub fn transform_import_aliases_with_edits<'a>( } } } + 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() @@ -698,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), @@ -749,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::*; From 5c231186d84298c95bbac4e996e3458eff966f63 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 4 Oct 2026 06:29:55 +0900 Subject: [PATCH 4/7] fix(extractor): follow barrel exports and aliases by symbol Refs #695 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/barrel.rs | 163 +++++++++++++------- libs/extractor/src/barrel/aliases.rs | 52 +++---- libs/extractor/src/barrel/gate.rs | 120 +++++++++++++++ libs/extractor/src/barrel/scope_tests.rs | 185 +++++++++++++++++++++++ 4 files changed, 438 insertions(+), 82 deletions(-) create mode 100644 libs/extractor/src/barrel/gate.rs create mode 100644 libs/extractor/src/barrel/scope_tests.rs diff --git a/libs/extractor/src/barrel.rs b/libs/extractor/src/barrel.rs index 5b59a7932..52062045f 100644 --- a/libs/extractor/src/barrel.rs +++ b/libs/extractor/src/barrel.rs @@ -94,31 +94,41 @@ fn analyze(module: &ResolvedModule, package: &str) -> Exports { let program = Parser::new(&allocator, &module.code, source_type) .parse() .program; - let mut imports: FxHashMap<&str, Link> = FxHashMap::default(); + let semantic = SemanticBuilder::new().build(&program).semantic; + let mut imports: FxHashMap = FxHashMap::default(); for statement in &program.body { - match statement { - Statement::ImportDeclaration(import) if !import.import_kind.is_type() => { - for specifier in import.specifiers.iter().flatten() { - let (local, imported) = match specifier { - ImportDeclarationSpecifier::ImportSpecifier(named) => { - (&named.local, Some(named.imported.name().to_string())) - } - ImportDeclarationSpecifier::ImportDefaultSpecifier(default) => { - (&default.local, Some("default".to_string())) - } - ImportDeclarationSpecifier::ImportNamespaceSpecifier(namespace) => { - (&namespace.local, None) - } - }; - imports.insert( - local.name.as_str(), + 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 @@ -155,8 +165,8 @@ fn analyze(module: &ResolvedModule, package: &str) -> Exports { .iter() .filter(|s| !s.export_kind.is_type()) { - let link = imports - .get(specifier.local.name().as_str()) + let link = export_symbol(&specifier.local, &semantic) + .and_then(|symbol| imports.get(&symbol)) .cloned() .unwrap_or(Link::Own); exports @@ -165,8 +175,8 @@ fn analyze(module: &ResolvedModule, package: &str) -> Exports { } } Statement::VariableDeclaration(declaration) => { - for (name, link) in alias_links(declaration, &imports) { - imports.insert(name, link); + for (id, link) in alias_links(declaration, &imports, &semantic) { + imports.extend(id.symbol_id.get().map(|symbol| (symbol, link))); } } Statement::ExportDeclaration(export) => { @@ -174,16 +184,18 @@ fn analyze(module: &ResolvedModule, package: &str) -> Exports { exports.named.insert(name, Link::Own); } if let Declaration::VariableDeclaration(declaration) = &export.declaration { - for (name, link) in alias_links(declaration, &imports) { - exports.named.insert(name.to_string(), link.clone()); - imports.insert(name, link); + 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) => { - imports.get(name.name.as_str()).cloned() + reference_symbol(name, &semantic) + .and_then(|symbol| imports.get(&symbol)) + .cloned() } _ => None, }; @@ -200,13 +212,14 @@ fn analyze(module: &ResolvedModule, package: &str) -> Exports { /// Where `Namespace.member` leads, for a namespace a module imports fn member_link( member: &StaticMemberExpression<'_>, - imports: &FxHashMap<&str, Link>, + imports: &FxHashMap, + semantic: &Semantic<'_>, ) -> Option { if let Expression::Identifier(object) = &member.object && let Some(Link::From { source, imported: None, - }) = imports.get(object.name.as_str()) + }) = reference_symbol(object, semantic).and_then(|symbol| imports.get(&symbol)) { Some(Link::From { source: source.clone(), @@ -220,9 +233,10 @@ fn member_link( /// 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: &oxc_ast::ast::VariableDeclaration<'a>, - imports: &FxHashMap<&str, Link>, -) -> Vec<(&'a str, Link)> { + declaration: &'a oxc_ast::ast::VariableDeclaration<'a>, + imports: &FxHashMap, + semantic: &Semantic<'_>, +) -> Vec<(&'a BindingIdentifier<'a>, Link)> { if declaration.kind != VariableDeclarationKind::Const { return Vec::new(); } @@ -234,15 +248,42 @@ fn alias_links<'a>( return None; }; let link = match declarator.init.as_ref()? { - Expression::Identifier(init) => imports.get(init.name.as_str())?.clone(), - Expression::StaticMemberExpression(member) => member_link(member, imports)?, + Expression::Identifier(init) => { + imports.get(&reference_symbol(init, semantic)?)?.clone() + } + Expression::StaticMemberExpression(member) => { + member_link(member, imports, semantic)? + } _ => return None, }; - Some((id.name.as_str(), link)) + 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, @@ -461,6 +502,7 @@ impl Generated { } mod aliases; +mod gate; use aliases::{Reach, declarator_removal, export_edits, is_assigned_from}; fn unreadable(local: &str, code: &str) -> String { @@ -979,36 +1021,42 @@ pub(crate) fn rewrite( )); } } - let locals: FxHashMap = found - .bound - .iter() - .filter(|bound| bound.devup.1.as_deref().is_some_and(compiled_export)) - .map(|bound| { - ( - bound.binding.name.to_string(), - (bound.source.clone(), Some(bound.imported.clone())), - ) - }) - .chain(found.namespaces.iter().map(|declared| { - ( - declared.binding.name.to_string(), - (declared.source.clone(), None), - ) - })) - .collect(); - edits.extend(export_edits(&program, code, locals)); let wants_semantic = !found.namespaces.is_empty() || !found.opaque.is_empty() - || found + || (found .bound .iter() - .any(|bound| is_assigned_from(code, bound.binding.name.as_str())) - || (!found.bound.is_empty() && (code.contains("= keyframes(") || code.contains("= css("))); + .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, @@ -1072,3 +1120,6 @@ pub(crate) fn rewrite( #[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 index 035fa23f2..1771f9e1b 100644 --- a/libs/extractor/src/barrel/aliases.rs +++ b/libs/extractor/src/barrel/aliases.rs @@ -6,12 +6,13 @@ 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}; +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 @@ -58,19 +59,20 @@ pub(super) fn is_assigned_from(code: &str, name: &str) -> bool { /// `export const B = Box`. A module compiled with the import removed would /// otherwise export a name nothing declares. pub(super) fn export_edits( - program: &Program<'_>, + semantic: &Semantic<'_>, code: &str, - mut locals: FxHashMap, + 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); + alias_reaches(declaration, &mut locals, semantic); } Statement::ExportDeclaration(export) => { if let Declaration::VariableDeclaration(declaration) = &export.declaration { - let aliases = alias_reaches(declaration, &mut locals); + let aliases = alias_reaches(declaration, &mut locals, semantic); if !aliases.is_empty() { let mut statements: Vec = Vec::new(); let others: Vec<&str> = declaration @@ -102,7 +104,8 @@ pub(super) fn export_edits( let mut statements = Vec::new(); let mut kept = Vec::new(); for specifier in &export.specifiers { - let reach = locals.get(specifier.local.name().as_str()); + 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)); @@ -125,7 +128,8 @@ pub(super) fn export_edits( } Statement::ExportDefaultDeclaration(export) => { if let ExportDefaultDeclarationKind::Identifier(name) = &export.declaration - && let Some(reach) = locals.get(name.name.as_str()) + && let Some(reach) = + reference_symbol(name, semantic).and_then(|symbol| locals.get(&symbol)) && reach.1.is_some() { edits.push(( @@ -145,10 +149,12 @@ pub(super) fn export_edits( /// the build compiles `member` away fn namespace_member_reach( member: &StaticMemberExpression<'_>, - locals: &FxHashMap, + locals: &FxHashMap, + semantic: &Semantic<'_>, ) -> Option { if let Expression::Identifier(object) = &member.object - && let Some((source, None)) = locals.get(object.name.as_str()) + && 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()))) @@ -161,7 +167,8 @@ fn namespace_member_reach( /// member of its namespace, `(name, where it leads)`, added to `locals` fn alias_reaches<'a>( declaration: &VariableDeclaration<'a>, - locals: &mut FxHashMap, + locals: &mut FxHashMap, + semantic: &Semantic<'_>, ) -> Vec<(&'a str, Reach)> { let mut aliases = Vec::new(); if declaration.kind != VariableDeclarationKind::Const { @@ -172,14 +179,16 @@ fn alias_reaches<'a>( continue; }; let reach = match &declarator.init { - Some(Expression::Identifier(init)) => locals.get(init.name.as_str()).cloned(), + Some(Expression::Identifier(init)) => reference_symbol(init, semantic) + .and_then(|symbol| locals.get(&symbol)) + .cloned(), Some(Expression::StaticMemberExpression(member)) => { - namespace_member_reach(member, locals) + namespace_member_reach(member, locals, semantic) } _ => None, }; if let Some(reach) = reach { - locals.insert(id.name.to_string(), reach.clone()); + locals.extend(id.symbol_id.get().map(|symbol| (symbol, reach.clone()))); aliases.push((id.name.as_str(), reach)); } } @@ -233,9 +242,7 @@ impl Rewriter<'_, '_, '_, '_> { let (Some(declaration), Some(alias), Some(origin)) = ( nodes.kind(declaration_id).as_variable_declaration(), id.symbol_id.get(), - init.reference_id - .get() - .and_then(|reference| scoping.get_reference(reference).symbol_id()), + reference_symbol(init, self.semantic), ) else { continue; }; @@ -290,7 +297,6 @@ impl Rewriter<'_, '_, '_, '_> { /// it is an error pub(super) fn hoist_style_constants(&mut self, program: &Program<'_>, bound: &[Bound<'_, '_>]) { let nodes = self.semantic.nodes(); - let scoping = self.semantic.scoping(); let apis: FxHashMap = bound .iter() .filter_map(|bound| { @@ -322,11 +328,8 @@ impl Rewriter<'_, '_, '_, '_> { let Expression::Identifier(callee) = &call.callee else { continue; }; - let Some(api) = callee - .reference_id - .get() - .and_then(|reference| scoping.get_reference(reference).symbol_id()) - .and_then(|symbol| apis.get(&symbol)) + let Some(api) = + reference_symbol(callee, self.semantic).and_then(|symbol| apis.get(&symbol)) else { continue; }; @@ -356,10 +359,7 @@ impl Rewriter<'_, '_, '_, '_> { let inside = reference.span.start >= call.span.start && reference.span.end <= call.span.end; !inside - || reference - .reference_id - .get() - .and_then(|reference| scoping.get_reference(reference).symbol_id()) + || reference_symbol(reference, self.semantic) .is_none_or(|symbol| imports.contains(&symbol)) }); if !literal_only { diff --git a/libs/extractor/src/barrel/gate.rs b/libs/extractor/src/barrel/gate.rs new file mode 100644 index 000000000..f1e3ee9fe --- /dev/null +++ b/libs/extractor/src/barrel/gate.rs @@ -0,0 +1,120 @@ +use oxc_ast::ast::{ + BindingPattern, Declaration, ExportDefaultDeclarationKind, Expression, Program, Statement, + VariableDeclarationKind, +}; + +pub(super) fn has_export_candidate(program: &Program<'_>) -> bool { + program.body.iter().any(|statement| match statement { + Statement::ExportNamedDeclaration(export) => { + !export.export_kind.is_type() + && export + .specifiers + .iter() + .any(|specifier| !specifier.export_kind.is_type()) + } + Statement::ExportDefaultDeclaration(export) => { + matches!( + export.declaration, + ExportDefaultDeclarationKind::Identifier(_) + ) + } + Statement::ExportDeclaration(export) => match &export.declaration { + Declaration::VariableDeclaration(declaration) => { + declaration.kind == VariableDeclarationKind::Const + && declaration.declarations.iter().any(|declarator| { + matches!(declarator.id, BindingPattern::BindingIdentifier(_)) + && matches!( + declarator.init, + Some( + Expression::Identifier(_) + | Expression::StaticMemberExpression(_) + ) + ) + }) + } + _ => false, + }, + _ => false, + }) +} + +#[cfg(test)] +mod tests { + use oxc_allocator::Allocator; + use oxc_parser::Parser; + use oxc_span::SourceType; + use rstest::rstest; + + use super::has_export_candidate; + use crate::ResolvedModule; + use crate::barrel::{Barreled, Link, analyze, rewrite}; + + #[rstest] + #[case("export { Box };", true)] + #[case("export { type Props, css as style };", true)] + #[case("export { type Props };", false)] + #[case("export type { Box };", false)] + #[case("export {};", false)] + #[case("export default Box;", true)] + #[case("export default function Page() { return ; }", false)] + #[case("export default () => ;", false)] + #[case("export const B = /* comment */ Box;", true)] + #[case("export const c = UI.css;", true)] + #[case("export const other = 1, B = Box;", true)] + #[case("export const Page = ;", false)] + #[case("export const value = 1;", false)] + #[case("export const style = css({});", false)] + #[case("export const { Box } = UI;", false)] + #[case("export let B = Box;", false)] + #[case("export const B = Box['name'];", false)] + #[case("export function Page() { return ; }", false)] + #[case("export { Box } from '@devup-ui/react';", false)] + #[case("export * from '@devup-ui/react';", false)] + #[case("const text = 'export';", false)] + fn export_shape_selects_analysis_candidates(#[case] code: &str, #[case] expected: bool) { + let allocator = Allocator::default(); + let program = Parser::new(&allocator, code, SourceType::tsx()) + .parse() + .program; + + let candidate = has_export_candidate(&program); + + assert_eq!(candidate, expected, "{code}"); + } + + #[rstest] + #[case("const B = /* comment */ Box; export { B };")] + #[case("const B = /* comment */ Box; export default B;")] + #[case("export const B = /* comment */ Box;")] + fn exported_alias_is_rewritten_when_assignment_text_does_not_match(#[case] exported: &str) { + let code = format!("import {{ Box }} from '@devup-ui/react'; {exported}"); + + let result = rewrite(&code, "ui.tsx", "@devup-ui/react", None); + + let Barreled::Rewritten(result) = result else { + panic!("exported alias was not rewritten"); + }; + assert!( + result.code.contains("from \"@devup-ui/react\";"), + "{}", + result.code + ); + assert!(result.code.contains("export { Box as"), "{}", result.code); + } + + #[test] + fn inline_type_import_is_not_a_runtime_origin_when_value_imports_are_mixed() { + let module = ResolvedModule { + path: "ui.ts".to_string(), + code: "export { Box, css }; import { type Box, css } from '@devup-ui/react';" + .to_string(), + }; + + let exports = analyze(&module, "@devup-ui/react"); + + assert!(matches!(exports.named.get("Box"), Some(Link::Own))); + assert!( + matches!(exports.named.get("css"), Some(Link::From { source, imported: Some(name) }) if source == "@devup-ui/react" && name == "css") + ); + } +} diff --git a/libs/extractor/src/barrel/scope_tests.rs b/libs/extractor/src/barrel/scope_tests.rs new file mode 100644 index 000000000..8a1763f63 --- /dev/null +++ b/libs/extractor/src/barrel/scope_tests.rs @@ -0,0 +1,185 @@ +use rstest::rstest; + +use super::{Barreled, rewrite}; +use crate::ResolvedModule; + +const PACKAGE: &str = "@devup-ui/react"; + +#[rstest] +#[case( + "export { Box }; import { Box } from '@devup-ui/react';", + "import { Box } from './ui'; const view = ;" +)] +#[case( + "export default Box; import { Box } from '@devup-ui/react';", + "import Box from './ui'; const view = ;" +)] +#[case( + "const B = Box; export { B as Box }; import { Box } from '@devup-ui/react';", + "import { Box } from './ui'; const view = ;" +)] +fn barrel_import_is_followed_when_declared_after_its_export( + #[case] barrel: &str, + #[case] code: &str, +) { + let barrel = barrel.to_string(); + let resolver = move |_: &str, _: &str| { + Some(ResolvedModule { + path: "ui.ts".to_string(), + code: barrel.clone(), + }) + }; + + let result = rewrite(code, "app.tsx", PACKAGE, Some(&resolver)); + + let Barreled::Rewritten(result) = result else { + panic!("barrel import was not redirected"); + }; + assert!( + result.code.contains("from \"@devup-ui/react\""), + "{}", + result.code + ); +} + +#[rstest] +#[case("function f(UI) { return UI.css({ color: 'blue' }); }")] +#[case("function f() { const UI = other; return ; }")] +#[case("function f(UI) { const D = UI; return D.css({ color: 'blue' }); }")] +#[case("namespace Local { import UI = Other.UI; export const x = UI.css({}); }")] +fn namespace_shadow_is_preserved_when_outer_namespace_is_rewritten( + #[case] shadow: &str, + #[values(PACKAGE, "./ui")] source: &str, +) { + let code = format!("import * as UI from '{source}'; const outer = UI.css({{}}); {shadow}"); + let resolver = |_: &str, _: &str| { + Some(ResolvedModule { + path: "ui.ts".to_string(), + code: "export { css, Box } from '@devup-ui/react';".to_string(), + }) + }; + + let result = rewrite(&code, "app.tsx", PACKAGE, Some(&resolver)); + + let Barreled::Rewritten(result) = result else { + panic!("outer namespace was not rewritten"); + }; + assert!(result.code.contains(shadow), "{}", result.code); + assert!( + result.code.contains("const outer = UI$css({});"), + "{}", + result.code + ); +} + +#[rstest] +#[case("function f(css) { const local = css; return local({}); }")] +#[case("function f() { const css = other; const local = css; return local({}); }")] +#[case("namespace Local { import css = Other.css; export const value = css; }")] +fn named_shadow_is_preserved_when_import_alias_is_rewritten(#[case] shadow: &str) { + let code = format!( + "import {{ css }} from '@devup-ui/react'; function real() {{ const local = css; return local({{}}); }} {shadow}" + ); + + let result = rewrite(&code, "app.tsx", PACKAGE, None); + + let Barreled::Rewritten(result) = result else { + panic!("import alias was not rewritten"); + }; + assert!(result.code.contains(shadow), "{}", result.code); + assert!( + result.code.contains("return local$css({});"), + "{}", + result.code + ); +} + +#[test] +fn barrel_alias_shadow_is_preserved_when_same_spelling_occurs_in_multiple_scopes() { + let shadow = "function shadow(css) { const local = css; return local({}); }"; + let code = format!( + "import {{ css }} from './ui'; const top = css; function a() {{ const local = top; return local({{}}); }} function b() {{ const local = css; return local({{}}); }} {shadow}" + ); + let resolver = |_: &str, _: &str| { + Some(ResolvedModule { + path: "ui.ts".to_string(), + code: "import { css } from '@devup-ui/react'; function f(css) { const local = css; return local; } export { css };".to_string(), + }) + }; + + let result = rewrite(&code, "app.tsx", PACKAGE, Some(&resolver)); + + let Barreled::Rewritten(result) = result else { + panic!("barrel aliases were not rewritten"); + }; + assert!(result.code.contains(shadow), "{}", result.code); + assert_eq!(result.code.matches("return local$css({});").count(), 2); +} + +#[test] +fn nested_namespace_import_is_preserved_when_exporting_an_outer_alias() { + let shadow = "namespace Local { import UI = Other.UI; export const B = UI.Box; }"; + let code = format!("import * as UI from '@devup-ui/react'; {shadow} export const B = UI.Box;"); + + let result = rewrite(&code, "app.tsx", PACKAGE, None); + + let Barreled::Rewritten(result) = result else { + panic!("outer export was not rewritten"); + }; + assert!(result.code.contains(shadow), "{}", result.code); + assert!( + result + .code + .contains("export { Box as B } from \"@devup-ui/react\";"), + "{}", + result.code + ); +} + +#[test] +fn runtime_import_is_unchanged_when_exporting_its_alias() { + let code = + "import { useTheme } from '@devup-ui/react'; const theme = useTheme; export { theme };"; + + let result = rewrite(code, "app.tsx", PACKAGE, None); + + assert!(matches!(result, Barreled::Unchanged)); +} + +#[rstest] +#[case("export { Box } from '@devup-ui/react';")] +#[case("export { 'Box' as Box } from '@devup-ui/react';")] +fn source_export_name_has_no_local_binding_when_it_is_not_a_reference(#[case] code: &str) { + let allocator = oxc_allocator::Allocator::default(); + let program = oxc_parser::Parser::new(&allocator, code, oxc_span::SourceType::mjs()) + .parse() + .program; + let semantic = oxc_semantic::SemanticBuilder::new() + .build(&program) + .semantic; + let oxc_ast::ast::Statement::ExportFromDeclaration(export) = &program.body[0] else { + panic!("expected a source export"); + }; + + let symbol = super::export_symbol(&export.specifiers[0].local, &semantic); + + assert_eq!(symbol, None); +} + +#[test] +fn unresolved_alias_is_preserved_when_exporting_a_compiled_import() { + let code = + "import { Box } from '@devup-ui/react'; const alias = unknown; export { Box, alias };"; + + let result = rewrite(code, "app.tsx", PACKAGE, None); + + let Barreled::Rewritten(result) = result else { + panic!("compiled export was not rewritten"); + }; + assert!( + result.code.contains("const alias = unknown;"), + "{}", + result.code + ); + assert!(result.code.contains("export { alias };"), "{}", result.code); +} From b54606a1ffbd163153c449f0b33c3197a0eb7159 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 4 Oct 2026 06:36:19 +0900 Subject: [PATCH 5/7] chore: add scoped compiled names patch changepack Refs #695 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .changepacks/changepack_log_scoped_compiled_names.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changepacks/changepack_log_scoped_compiled_names.json diff --git a/.changepacks/changepack_log_scoped_compiled_names.json b/.changepacks/changepack_log_scoped_compiled_names.json new file mode 100644 index 000000000..a8116dbe6 --- /dev/null +++ b/.changepacks/changepack_log_scoped_compiled_names.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Resolve compiled styling APIs and components by their lexical binding so shadowed local names, including minified library identifiers, remain ordinary JavaScript and JSX. No new build errors; existing diagnostics now apply only to the compiled binding they describe.", + "date": "2026-10-01T00:00:00.000Z" +} From 29812f376f62b74071080aec2f35efb9f0424d0f Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 4 Oct 2026 07:02:46 +0900 Subject: [PATCH 6/7] test(extractor): cover ClassNames whitespace and non-function children Refs #695 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/import_alias_scoping_tests.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/libs/extractor/src/import_alias_scoping_tests.rs b/libs/extractor/src/import_alias_scoping_tests.rs index 8d9ce5ab1..fae3e4d79 100644 --- a/libs/extractor/src/import_alias_scoping_tests.rs +++ b/libs/extractor/src/import_alias_scoping_tests.rs @@ -153,6 +153,8 @@ 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 })}", @@ -241,3 +243,11 @@ fn global_styles_rewrite_conditional_rules_without_rewriting_shadowed_styles() { "{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}"); +} From 0807cc8a3093adbb612f4a704c3403fae1252a8f Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 21 Oct 2026 03:20:28 +0900 Subject: [PATCH 7/7] test(extractor): cover Linux coverage edge paths Refs #682 #695 #688 #689 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/stylex_exact_key_tests.rs | 41 ++++++++++++ libs/extractor/src/stylex_no_storage_tests.rs | 9 +++ libs/extractor/src/visit.rs | 64 +++++++++++-------- 3 files changed, 86 insertions(+), 28 deletions(-) diff --git a/libs/extractor/src/stylex_exact_key_tests.rs b/libs/extractor/src/stylex_exact_key_tests.rs index b51ca0a8b..8f23ea653 100644 --- a/libs/extractor/src/stylex_exact_key_tests.rs +++ b/libs/extractor/src/stylex_exact_key_tests.rs @@ -180,6 +180,47 @@ mod stylex_exact_keys { ); } + #[rstest] + #[case::inherited(("s.base,", "[\"red\",\"white\"]", "[\"black\",\"blue\"]"))] + #[case::absent(("", "[\"red\"]", "[\"blue\"]"))] + #[serial] + fn alternate_only_key_preserves_winners_when_conditional_namespaces_are_disjoint( + #[case] sample: (&str, &str, &str), + #[values("props", "attrs")] api: &str, + #[values(false, true)] on: bool, + ) { + // Given: each branch replaces a different key, with or without earlier defaults. + reset_class_map(); + reset_file_map(); + let attribute = if api == "props" { "className" } else { "class" }; + let source = format!( + "import * as sx from '@stylexjs/stylex'; import {{jsx}} from 'react/jsx-runtime'; const s=sx.create({{base:{{color:'black',backgroundColor:'white'}},left:{{color:'red'}},right:{{backgroundColor:'blue'}}}}); function tick(label,value){{trace.push(label);return value;}} function render(on){{const out=sx.{api}({}tick('branch',on)?s.left:s.right);return jsx('div',{{className:out.{attribute}}});}}", + sample.0 + ); + + // When: the public extractor's output executes the selected branch in Boa. + let output = extract("stylex-disjoint.js", &source, ExtractOption::default()).unwrap(); + let observation = render_observation( + &output, + &format!("render({on})"), + Some("stylex-disjoint.js"), + ); + let (trace, mut values): (Vec, Vec) = + serde_json::from_str(&observation).unwrap(); + values.sort(); + + // Then: the missing side inherits only its prior key; the branch test runs once. + let expected = if on { sample.1 } else { sample.2 }; + assert_eq!(trace, ["branch"], "{}", output.code); + assert_eq!( + values, + serde_json::from_str::>(expected).unwrap(), + "{}", + output.code + ); + assert_eq!(callable_count(&output.code), callable_count(&source)); + } + #[test] #[serial] fn throwing_condition_still_throws_when_all_its_keys_are_overridden() { diff --git a/libs/extractor/src/stylex_no_storage_tests.rs b/libs/extractor/src/stylex_no_storage_tests.rs index a45064f57..37c3a0fc8 100644 --- a/libs/extractor/src/stylex_no_storage_tests.rs +++ b/libs/extractor/src/stylex_no_storage_tests.rs @@ -179,3 +179,12 @@ fn stylex_no_storage_stays_bounded_and_deterministic_when_many_choices_are_disca } eprintln!("NO STORAGE GROWTH {frame} {api} {sizes:?}"); } + +#[test] +#[should_panic(expected = "unknown fixture frame: invalid")] +fn no_storage_source_rejects_unknown_fixture_frame() { + // Given: the frame is not one of the fixture helper's supported execution contexts. + // When: constructing that fixture must fail rather than silently use a different frame. + // Then: the helper reports the unsupported frame in its existing panic. + no_storage_source("", "invalid", "props"); +} diff --git a/libs/extractor/src/visit.rs b/libs/extractor/src/visit.rs index 5fdee5ec3..9c359cc03 100644 --- a/libs/extractor/src/visit.rs +++ b/libs/extractor/src/visit.rs @@ -2227,29 +2227,35 @@ impl<'a> DevupVisitor<'a> { } => { let index = tests.len(); tests.push(test); - let mut keys: Vec<&String> = Vec::new(); - for (key, _) in consequent.iter().chain(&alternate) { - if !keys.contains(&key) { - keys.push(key); + let mut keys: Vec<(&String, &String, bool)> = Vec::new(); + for (side, is_consequent) in [(&consequent, true), (&alternate, false)] { + for (key, class) in side { + if !keys.iter().any(|(existing, _, _)| *existing == key) { + keys.push((key, class, is_consequent)); + } } } - for key in keys { + for (key, class, is_consequent) in keys { let previous = entries .iter() .position(|(existing, _)| existing == key) .map_or(StylexChoice::Class(String::new()), |index| { entries.remove(index).1 }); - let pick = |side: &[(String, String)]| { - side.iter() - .find(|(existing, _)| existing == key) - .map(|(_, class)| StylexChoice::Class(class.clone())) + let opposite = if is_consequent { + &alternate + } else { + &consequent }; - let (consequent, alternate) = match (pick(&consequent), pick(&alternate)) { - (Some(a), Some(b)) => (a, b), - (Some(a), None) => (a, previous), - (None, Some(b)) => (previous, b), - (None, None) => continue, + let opposite = opposite + .iter() + .find(|(existing, _)| existing == key) + .map_or(previous, |(_, class)| StylexChoice::Class(class.clone())); + let origin = StylexChoice::Class(class.clone()); + let (consequent, alternate) = if is_consequent { + (origin, opposite) + } else { + (opposite, origin) }; if consequent == alternate { entries.push((key.clone(), consequent)); @@ -2278,22 +2284,24 @@ impl<'a> DevupVisitor<'a> { let tests: Vec<_> = tests .into_iter() .enumerate() - .map(|(index, test)| match uses[index] { - 0 => { + .map(|(index, test)| { + if uses[index] == 0 { evaluations.push(test); - Expression::new_boolean_literal(SPAN, false, &self.ast) + return Expression::new_boolean_literal(SPAN, false, &self.ast); + } + match uses[index] { + 1 if Some(index) == last => test, + _ => match self.stylex_captures.capture(&self.ast, test) { + Ok((reference, assignment)) => { + evaluations.push(assignment); + reference + } + Err(error) => { + self.errors.push(error); + Expression::new_boolean_literal(SPAN, false, &self.ast) + } + }, } - 1 if Some(index) == last => test, - _ => match self.stylex_captures.capture(&self.ast, test) { - Ok((reference, assignment)) => { - evaluations.push(assignment); - reference - } - Err(error) => { - self.errors.push(error); - Expression::new_boolean_literal(SPAN, false, &self.ast) - } - }, }) .collect(); Some((