diff --git a/.changepacks/changepack_log_emotion_css.json b/.changepacks/changepack_log_emotion_css.json new file mode 100644 index 000000000..5f9b61a00 --- /dev/null +++ b/.changepacks/changepack_log_emotion_css.json @@ -0,0 +1,9 @@ +{ + "changes": { + "packages/plugin-utils/package.json": "Patch", + "packages/react/package.json": "Patch", + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "@emotion/css is aliased by default: css and keyframes compile like Devup UI's own (numbers in px, as Emotion reads them), injectGlobal declares global CSS. cx takes class names, as in Emotion: strings and runtime values are classes kept as written (falsy values are skipped), an object is a { name: condition } class map, arrays nest, and inline css() calls and css() classes compose with the rules css(a, b) uses. merge(className) splits what the build can read (string tokens, template literal parts) and composes the css() classes in it, keeping a runtime string it cannot split as it is. Namespace members, constant-string bracket access, const destructuring and const alias chains compile through these same named APIs with scope-aware binding resolution; numeric values retain Emotion units after constant inlining and build-time evaluation. The Emotion import disappears when no runtime-only member is used. Ambient declarations type the styling imports (cx(...args: ClassNamesArg[]), merge(className: string)). New located build errors: cx() cannot use a spread, a getter, a setter or a method in a class map, or a template literal call; merge() takes exactly one class string, not a spread or a template literal call; the root module has no default export, namespace member keys must be constant strings naming supported exports, and styling functions cannot be passed, mutated or re-exported. Optional styling reads/calls, namespace rest destructuring, styling defaults or nested patterns, non-const aliases, exported styling bindings and reads whose initialization order cannot be established are build errors instead of runtime dependencies. Styling loaded through require, import-equals or dynamic import must use static ES module imports; an unawaited dynamic namespace that the build cannot follow is also a located error. Its runtime-only APIs (cache, flush, hydrate, sheet, getRegisteredStyles) stay on @emotion/css, including their nested/default destructuring, optional reads and recognized runtime-loader access", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/libs/extractor/src/class_arguments.rs b/libs/extractor/src/class_arguments.rs new file mode 100644 index 000000000..0aa49884e --- /dev/null +++ b/libs/extractor/src/class_arguments.rs @@ -0,0 +1,380 @@ +//! Emotion's `cx` and `merge`: class names in, one class string out. +//! +//! Both are rewritten into a single array of class parts, which the `css()` +//! composition then joins like any other classes. A string is always a class +//! and an object is always a class map, never rules. + +use crate::gen_class_name::merge_expression_for_class_name; +use crate::utils::{ + build_time_error, get_str_by_property_key, get_string_by_literal_expression, readable_argument, + spread_error, unwrap_syntax_only, +}; +use oxc_allocator::{CloneIn, FromIn, GetAllocator}; +use oxc_ast::ast::{ + Argument, ArrayExpressionElement, Expression, ObjectExpression, ObjectPropertyKind, + PropertyKind, Str, TemplateElement, TemplateElementValue, TemplateLiteral, +}; +use oxc_ast::builder::AstBuilder; +use oxc_span::SPAN; +use oxc_syntax::operator::LogicalOperator; + +/// Which Emotion function a call is +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClassCall { + Cx, + Merge, +} + +impl ClassCall { + const fn api(self) -> &'static str { + match self { + ClassCall::Cx => "cx", + ClassCall::Merge => "merge", + } + } +} + +/// Whether a literal is truthy, `None` for what only the running code knows +fn literal_truth(expression: &Expression<'_>) -> Option { + match unwrap_syntax_only(expression) { + Expression::BooleanLiteral(literal) => Some(literal.value), + Expression::NullLiteral(_) => Some(false), + Expression::NumericLiteral(number) => Some(number.value != 0.0), + Expression::StringLiteral(text) => Some(!text.value.is_empty()), + Expression::Identifier(name) if name.name == "undefined" => Some(false), + _ => None, + } +} + +const MAP_ENTRY: &str = + "a class map entry must be written `name: condition`, not as a getter, setter or method"; +const MERGE_ARGUMENT: &str = "it takes one class string"; + +/// The parts of `arguments` as one array expression of classes, each a string, +/// a template literal or `test ? class : class`; what the build cannot read is +/// recorded in `errors` +pub(super) fn class_arguments<'a>( + ast: &AstBuilder<'a>, + call: ClassCall, + offset: u32, + arguments: &[Argument<'a>], + errors: &mut Vec<(u32, String)>, +) -> Expression<'a> { + let mut reader = ClassParts { ast, call, errors }; + if call == ClassCall::Merge && arguments.len() != 1 { + let code: Vec = arguments.iter().map(readable_argument).collect(); + reader.errors.push(( + offset, + build_time_error(call.api(), &code.join(", "), MERGE_ARGUMENT), + )); + } + let mut parts = Vec::new(); + for argument in arguments { + match argument { + Argument::SpreadElement(spread) => reader.errors.push(spread_error(call.api(), spread)), + argument => reader.add(argument.to_expression(), &mut parts), + } + } + Expression::new_array_expression( + SPAN, + oxc_allocator::Vec::from_iter_in(parts.into_iter().map(ArrayExpressionElement::from), ast), + ast, + ) +} + +struct ClassParts<'r, 'a> { + ast: &'r AstBuilder<'a>, + call: ClassCall, + + errors: &'r mut Vec<(u32, String)>, +} + +impl<'a> ClassParts<'_, 'a> { + fn string(&self, value: &str) -> Expression<'a> { + Expression::new_string_literal( + SPAN, + Str::from_in(value, self.ast.allocator()), + None, + self.ast, + ) + } + + fn clone(&self, expression: &Expression<'a>) -> Expression<'a> { + expression.clone_in(self.ast.allocator()) + } + + /// `${value}` + fn interpolation(&self, value: Expression<'a>) -> Expression<'a> { + let element = |tail| { + TemplateElement::new( + SPAN, + TemplateElementValue { + raw: Str::from_in("", self.ast.allocator()), + cooked: None, + }, + tail, + self.ast, + ) + }; + Expression::new_template_literal( + SPAN, + oxc_allocator::Vec::from_array_in([element(false), element(true)], self.ast), + oxc_allocator::Vec::from_array_in([value], self.ast), + self.ast, + ) + } + + /// `branch` as a single class: what `test ? class : class` may hold + fn class(&mut self, expression: &Expression<'a>) -> Expression<'a> { + let mut parts = Vec::new(); + self.add(expression, &mut parts); + match merge_expression_for_class_name(self.ast, parts) { + None => self.string(""), + Some(class @ (Expression::StringLiteral(_) | Expression::TemplateLiteral(_))) => class, + Some(other) => self.interpolation(other), + } + } + + /// `test ? when_true : when_false`, or the side a literal test picks + fn choose( + &self, + test: &Expression<'a>, + when_true: Expression<'a>, + when_false: Expression<'a>, + out: &mut Vec>, + ) { + out.push(match literal_truth(test) { + Some(true) => when_true, + Some(false) => when_false, + None => Expression::new_conditional_expression( + SPAN, + self.clone(test), + when_true, + when_false, + self.ast, + ), + }); + } + + /// A value only the running code knows, as a class + fn runtime(&self, value: Expression<'a>, out: &mut Vec>) { + out.push(if self.call == ClassCall::Cx { + self.without_falsy(value) + } else { + self.as_written(value) + }); + } + + /// `${value || ''}`: a falsy value is no class + fn without_falsy(&self, value: Expression<'a>) -> Expression<'a> { + let none = self.string(""); + let skipped = + Expression::new_logical_expression(SPAN, value, LogicalOperator::Or, none, self.ast); + self.interpolation(skipped) + } + + /// `value` itself where it reads as a class, `${value}` otherwise + fn as_written(&self, value: Expression<'a>) -> Expression<'a> { + if matches!( + value, + Expression::Identifier(_) + | Expression::StaticMemberExpression(_) + | Expression::ComputedMemberExpression(_) + ) { + value + } else { + self.interpolation(value) + } + } + + fn text(&self, text: &str, out: &mut Vec>) { + out.extend(text.split_whitespace().map(|class| self.string(class))); + } + + fn add(&mut self, expression: &Expression<'a>, out: &mut Vec>) { + match unwrap_syntax_only(expression) { + Expression::StringLiteral(text) => self.text(&text.value, out), + Expression::TemplateLiteral(template) => self.template(template, out), + Expression::NullLiteral(_) | Expression::BooleanLiteral(_) => {} + Expression::Identifier(name) if name.name == "undefined" => {} + Expression::NumericLiteral(number) => { + if number.value != 0.0 + && let Some(text) = get_string_by_literal_expression(expression) + { + out.push(self.string(&text)); + } + } + Expression::ArrayExpression(array) if self.call == ClassCall::Cx => { + for element in &array.elements { + match element { + ArrayExpressionElement::SpreadElement(spread) => { + self.errors.push(spread_error(self.call.api(), spread)); + } + element => { + if let Some(element) = element.as_expression() { + self.add(element, out); + } + } + } + } + } + Expression::ObjectExpression(map) if self.call == ClassCall::Cx => { + self.class_map(map, out); + } + Expression::ConditionalExpression(conditional) => { + let when_true = self.class(&conditional.consequent); + let when_false = self.class(&conditional.alternate); + self.choose(&conditional.test, when_true, when_false, out); + } + Expression::LogicalExpression(logical) if logical.operator == LogicalOperator::And => { + let class = self.class(&logical.right); + let none = self.string(""); + self.choose(&logical.left, class, none, out); + } + Expression::LogicalExpression(logical) => { + let fallback = self.class(&logical.right); + let chosen = Expression::new_logical_expression( + SPAN, + self.clone(&logical.left), + logical.operator, + fallback, + self.ast, + ); + self.runtime(chosen, out); + } + other => { + let value = self.clone(other); + self.runtime(value, out); + } + } + } + + /// A template literal split into its classes when every interpolation is + /// set apart by spaces, and kept whole when one is glued to a class + fn template(&mut self, template: &TemplateLiteral<'a>, out: &mut Vec>) { + let texts: Vec = template + .quasis + .iter() + .map(|quasi| { + quasi + .value + .cooked + .as_ref() + .unwrap_or(&quasi.value.raw) + .to_string() + }) + .collect(); + let glued = texts.windows(2).any(|pair| { + pair[0] + .chars() + .next_back() + .is_some_and(|c| !c.is_whitespace()) + || pair[1].chars().next().is_some_and(|c| !c.is_whitespace()) + }); + if glued { + out.push(Expression::TemplateLiteral(oxc_allocator::Box::new_in( + template.clone_in(self.ast.allocator()), + self.ast, + ))); + return; + } + for (index, text) in texts.iter().enumerate() { + self.text(text, out); + if let Some(expression) = template.expressions.get(index) { + self.add(expression, out); + } + } + } + + /// `{ name: condition }`: `name` while `condition` holds + fn class_map(&mut self, map: &ObjectExpression<'a>, out: &mut Vec>) { + for property in &map.properties { + let property = match property { + ObjectPropertyKind::ObjectProperty(property) => property, + ObjectPropertyKind::SpreadProperty(spread) => { + self.errors.push(spread_error(self.call.api(), spread)); + continue; + } + }; + if property.kind != PropertyKind::Init || property.method { + let accessor = match property.kind { + PropertyKind::Get => "get ", + PropertyKind::Set => "set ", + PropertyKind::Init => "", + }; + let name = get_str_by_property_key(&property.key).unwrap_or_default(); + self.errors.push(( + property.span.start, + build_time_error(self.call.api(), &format!("{accessor}{name}()"), MAP_ENTRY), + )); + continue; + } + let name = get_str_by_property_key(&property.key) + .map(|name| self.string(&name)) + .or_else(|| { + property + .key + .as_expression() + .map(|key| self.interpolation(self.clone(key))) + }); + if let Some(name) = name { + let none = self.string(""); + self.choose(&property.value, name, none, out); + } + } + } +} + +/// A class string whose interpolations hold templates, those spliced into it +pub(super) fn flatten_classes<'a>( + ast: &AstBuilder<'a>, + expression: Expression<'a>, +) -> Expression<'a> { + let Expression::TemplateLiteral(template) = &expression else { + return expression; + }; + let mut flat = Flat::default(); + flat.splice(ast, template); + flat.texts.push(std::mem::take(&mut flat.current)); + let last = flat.texts.len(); + let elements = flat.texts.iter().enumerate().map(|(index, text)| { + TemplateElement::new( + SPAN, + TemplateElementValue { + raw: Str::from_in(text.as_str(), ast.allocator()), + cooked: None, + }, + index + 1 == last, + ast, + ) + }); + Expression::new_template_literal( + SPAN, + oxc_allocator::Vec::from_iter_in(elements, ast), + oxc_allocator::Vec::from_iter_in(flat.expressions, ast), + ast, + ) +} + +#[derive(Default)] +struct Flat<'a> { + current: String, + texts: Vec, + expressions: Vec>, +} + +impl<'a> Flat<'a> { + fn splice(&mut self, ast: &AstBuilder<'a>, template: &TemplateLiteral<'a>) { + for (index, quasi) in template.quasis.iter().enumerate() { + self.current.push_str(&quasi.value.raw); + match template.expressions.get(index) { + Some(Expression::TemplateLiteral(inner)) => self.splice(ast, inner), + Some(other) => { + self.texts.push(std::mem::take(&mut self.current)); + self.expressions.push(other.clone_in(ast.allocator())); + } + None => {} + } + } + } +} diff --git a/libs/extractor/src/emotion_namespace.rs b/libs/extractor/src/emotion_namespace.rs new file mode 100644 index 000000000..1178214ae --- /dev/null +++ b/libs/extractor/src/emotion_namespace.rs @@ -0,0 +1,220 @@ +//! Resolve Emotion namespace reads before the ordinary import alias pass. +mod acquire; +mod bindings; +mod declarations; +mod driver; +mod order; +mod output; +mod reads; +mod restore; +pub(super) mod units; +pub(super) use driver::normalize; +pub(super) use output::Rewritten; +pub(super) use restore::original_error_code; + +use crate::{ImportAlias, import_alias_visit::Edit, utils::build_time_error}; +use oxc_allocator::Allocator; +use oxc_ast::{AstKind, ast::Expression}; +use oxc_parser::Parser; +use oxc_semantic::{Semantic, SemanticBuilder}; +use oxc_span::{GetSpan, SourceType, Span}; +use oxc_syntax::{node::NodeId, symbol::SymbolId}; +use std::{ + borrow::Cow, + collections::{BTreeMap, HashMap, HashSet}, +}; + +/// Where a namespace came from. Only an ES module import can be lowered to +/// generated named imports; `require()` and `import x = require()` hand out a +/// runtime object, so their styling members are build errors. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +enum Root { + Module(Span), + Require(Span), +} + +#[derive(Clone, PartialEq, Eq)] +enum Binding { + Namespace(Root), + Macro(&'static str), + String(String), +} + +struct Normalizer<'s, 'a> { + code: &'s str, + semantic: &'s Semantic<'a>, + bindings: HashMap, + aliases: HashSet, + runtime: HashSet, + loaders: HashSet, + module_api: HashSet, + names: HashSet, + imports: BTreeMap<&'static str, String>, + replacements: Vec<(usize, usize, String)>, + errors: Vec<(u32, String)>, +} + +type Normalized<'a> = (Cow<'a, str>, Vec); +type Errors = Vec<(u32, String)>; + +const REQUIRE_REQUIREMENT: &str = "styling APIs read through require(), import-equals or dynamic import cannot be compiled; use `import * as ns from '@emotion/css'`"; + +impl Normalizer<'_, '_> { + fn text(&self, span: Span) -> &str { + &self.code[span.start as usize..span.end as usize] + } + + fn error(&mut self, span: Span, requirement: &str) { + self.errors.push(( + span.start, + build_time_error("@emotion/css", self.text(span), requirement), + )); + } + + fn replace(&mut self, span: Span, value: String) { + self.replacements + .push((span.start as usize, span.end as usize, value)); + } + + fn is_runtime(&self, root: Root) -> bool { + matches!(root, Root::Require(_)) || self.runtime.contains(&root) + } + + fn symbol(&self, expression: &Expression<'_>) -> Option { + let Expression::Identifier(identifier) = expression.get_inner_expression() else { + return None; + }; + identifier + .reference_id + .get() + .and_then(|id| self.semantic.scoping().get_reference(id).symbol_id()) + } + + fn string(&self, expression: &Expression<'_>) -> Option { + match expression.get_inner_expression() { + Expression::StringLiteral(string) => Some(string.value.to_string()), + Expression::TemplateLiteral(template) if template.expressions.is_empty() => template + .quasis + .first()? + .value + .cooked + .as_ref() + .map(ToString::to_string), + expression => match self.bindings.get(&self.symbol(expression)?)? { + Binding::String(string) => Some(string.clone()), + Binding::Namespace(_) | Binding::Macro(_) => None, + }, + } + } + + fn namespace_root(&self, expression: &Expression<'_>) -> Option { + if let Some(root) = self.acquisition(expression) { + return Some(root); + } + match self.bindings.get(&self.symbol(expression)?)? { + Binding::Namespace(root) => Some(*root), + Binding::Macro(_) | Binding::String(_) => None, + } + } + + fn member(&self, expression: &Expression<'_>) -> Option<(Root, Option, bool)> { + let (object, key, optional) = match expression.get_inner_expression() { + Expression::StaticMemberExpression(member) => ( + &member.object, + Some(member.property.name.to_string()), + member.optional, + ), + Expression::ComputedMemberExpression(member) => ( + &member.object, + self.string(&member.expression), + member.optional, + ), + _ => return None, + }; + Some((self.namespace_root(object)?, key, optional)) + } + + fn value(&self, expression: &Expression<'_>) -> Option { + if let Some(binding) = self + .symbol(expression) + .and_then(|symbol| self.bindings.get(&symbol)) + { + return Some(binding.clone()); + } + if let Some(root) = self.acquisition(expression) { + return Some(Binding::Namespace(root)); + } + if let Some((root, Some(key), false)) = self.member(expression) { + return macro_name(&key) + .filter(|_| matches!(root, Root::Module(_))) + .map(Binding::Macro); + } + self.string(expression).map(Binding::String) + } + + fn fresh(&mut self, api: &'static str) -> String { + if let Some(name) = self.imports.get(api) { + return name.clone(); + } + let mut index = self.names.len(); + let name = loop { + let name = format!("__emotion_{api}_{index}"); + if self.names.insert(name.clone()) { + break name; + } + index += 1; + }; + self.imports.insert(api, name.clone()); + name + } + + /// The outermost syntax-only wrapper (`(x)`, `x as T`, `x!`, ...) of `node`. + fn expression_node(&self, node: NodeId) -> NodeId { + let mut current = node; + for parent in self.semantic.nodes().ancestor_ids(node) { + match self.semantic.nodes().kind(parent) { + AstKind::ParenthesizedExpression(_) + | AstKind::TSAsExpression(_) + | AstKind::TSSatisfiesExpression(_) + | AstKind::TSNonNullExpression(_) + | AstKind::TSTypeAssertion(_) + | AstKind::TSInstantiationExpression(_) => current = parent, + _ => break, + } + } + current + } +} + +fn macro_name(name: &str) -> Option<&'static str> { + match name { + "css" => Some("css"), + "cx" => Some("cx"), + "merge" => Some("merge"), + "keyframes" => Some("keyframes"), + "injectGlobal" => Some("injectGlobal"), + _ => None, + } +} + +fn runtime_name(name: &str) -> bool { + matches!( + name, + "cache" | "flush" | "hydrate" | "sheet" | "getRegisteredStyles" + ) +} + +/// Every name the program binds or reads as a free variable +fn identifier_names(semantic: &Semantic<'_>) -> HashSet { + let scoping = semantic.scoping(); + scoping + .symbol_names() + .map(ToString::to_string) + .chain( + scoping + .root_unresolved_references() + .keys() + .map(ToString::to_string), + ) + .collect() +} diff --git a/libs/extractor/src/emotion_namespace/acquire.rs b/libs/extractor/src/emotion_namespace/acquire.rs new file mode 100644 index 000000000..7dee84313 --- /dev/null +++ b/libs/extractor/src/emotion_namespace/acquire.rs @@ -0,0 +1,145 @@ +//! The ways a module can get hold of `@emotion/css` other than a static ES +//! import. Each one hands out a runtime object, so styling members read from it +//! are build errors; recognition is binding-aware, so a local `require`, +//! `module` or loader function of the same spelling is left alone. +use super::{AstKind, Normalizer, Root}; +use oxc_ast::ast::{ + AwaitExpression, BindingPattern, CallExpression, Expression, IdentifierReference, + ImportDeclaration, ImportDeclarationSpecifier, VariableDeclarator, +}; + +const EMOTION: &str = "@emotion/css"; + +const DYNAMIC_IMPORT_REQUIREMENT: &str = "a dynamic import hands out a runtime namespace; `await` it directly for runtime-only members, or use `import * as ns from '@emotion/css'`"; + +impl Normalizer<'_, '_> { + /// The namespace `expression` acquires, when it is a recognized acquisition + /// of the Emotion root. + pub(super) fn acquisition(&self, expression: &Expression<'_>) -> Option { + match expression.get_inner_expression() { + Expression::CallExpression(call) => self.require_call(call), + Expression::AwaitExpression(wait) => self.awaited_import(wait), + _ => None, + } + } + + pub(super) fn require_call(&self, call: &CallExpression<'_>) -> Option { + let [argument] = call.arguments.as_slice() else { + return None; + }; + (self.loader(&call.callee) && self.names_emotion(argument.as_expression()?)) + .then_some(Root::Require(call.span)) + } + + pub(super) fn awaited_import(&self, wait: &AwaitExpression<'_>) -> Option { + let Expression::ImportExpression(import) = wait.argument.get_inner_expression() else { + return None; + }; + self.names_emotion(&import.source) + .then_some(Root::Require(wait.span)) + } + + fn names_emotion(&self, expression: &Expression<'_>) -> bool { + self.string(expression).as_deref() == Some(EMOTION) + } + + fn is_global(&self, identifier: &IdentifierReference<'_>, name: &str) -> bool { + identifier.name == name + && identifier.reference_id.get().is_some_and(|id| { + self.semantic + .scoping() + .get_reference(id) + .symbol_id() + .is_none() + }) + } + + /// Whether `expression` is a function that loads modules: the global + /// `require`, `module.require`, a `createRequire(...)` result, or a binding + /// holding one of them. + fn loader(&self, expression: &Expression<'_>) -> bool { + match expression.get_inner_expression() { + Expression::Identifier(identifier) => { + self.is_global(identifier, "require") + || self + .symbol(expression) + .is_some_and(|symbol| self.loaders.contains(&symbol)) + } + Expression::StaticMemberExpression(member) => { + member.property.name == "require" + && matches!(member.object.get_inner_expression(), Expression::Identifier(object) if self.is_global(object, "module")) + } + Expression::CallExpression(call) => self.create_require(&call.callee), + _ => false, + } + } + + fn create_require(&self, callee: &Expression<'_>) -> bool { + match callee.get_inner_expression() { + Expression::Identifier(_) => self + .symbol(callee) + .is_some_and(|symbol| self.module_api.contains(&symbol)), + Expression::StaticMemberExpression(member) => { + member.property.name == "createRequire" + && self + .symbol(&member.object) + .is_some_and(|symbol| self.module_api.contains(&symbol)) + } + _ => false, + } + } + + pub(super) fn collect_loader( + &mut self, + declarator: &VariableDeclarator<'_>, + init: &Expression<'_>, + ) { + if let BindingPattern::BindingIdentifier(identifier) = &declarator.id + && let Some(symbol) = identifier.symbol_id.get() + && self.loader(init) + { + self.loaders.insert(symbol); + } + } + + /// Remember the bindings of `createRequire` and of the `module` namespace. + pub(super) fn collect_module_api(&mut self, import: &ImportDeclaration<'_>) { + if !matches!(import.source.value.as_str(), "module" | "node:module") + || import.import_kind.is_type() + { + return; + } + for specifier in import.specifiers.iter().flatten() { + let value_import = match specifier { + ImportDeclarationSpecifier::ImportSpecifier(named) => { + named.imported.name() == "createRequire" && !named.import_kind.is_type() + } + ImportDeclarationSpecifier::ImportDefaultSpecifier(_) + | ImportDeclarationSpecifier::ImportNamespaceSpecifier(_) => true, + }; + if value_import && let Some(symbol) = specifier.local().symbol_id.get() { + self.module_api.insert(symbol); + } + } + } + + /// A dynamic import that is not directly awaited hands the namespace to + /// code this pass cannot follow. + pub(super) fn reject_unawaited_imports(&mut self) { + let semantic = self.semantic; + for node in semantic.nodes().iter() { + let AstKind::ImportExpression(import) = node.kind() else { + continue; + }; + let expression = self.expression_node(node.id()); + if self.names_emotion(&import.source) + && !matches!( + semantic.nodes().parent_kind(expression), + AstKind::AwaitExpression(_) + ) + { + self.error(import.span, DYNAMIC_IMPORT_REQUIREMENT); + } + } + } +} diff --git a/libs/extractor/src/emotion_namespace/bindings.rs b/libs/extractor/src/emotion_namespace/bindings.rs new file mode 100644 index 000000000..3309786fe --- /dev/null +++ b/libs/extractor/src/emotion_namespace/bindings.rs @@ -0,0 +1,86 @@ +use super::{AstKind, Binding, Cow, GetSpan, Normalizer, Root, macro_name}; +use oxc_ast::ast::{BindingPattern, BindingProperty, Expression, VariableDeclarationKind}; +use oxc_syntax::node::NodeId; + +impl Normalizer<'_, '_> { + /// Resolve const aliases to a fixed point, including aliases in nested scopes. + /// A `require()` result may also be held by `let` and `var`, as `CommonJS` does. + pub(super) fn collect_bindings(&mut self) { + loop { + let before = self.bindings.len() + self.loaders.len(); + for node in self.semantic.nodes().iter() { + let AstKind::VariableDeclarator(declarator) = node.kind() else { + continue; + }; + let Some(init) = &declarator.init else { + continue; + }; + self.collect_loader(declarator, init); + if !self.alias_declaration(node.id(), init) { + continue; + } + match &declarator.id { + BindingPattern::BindingIdentifier(identifier) => { + if let (Some(symbol), Some(value)) = + (identifier.symbol_id.get(), self.value(init)) + { + if matches!(value, Binding::String(_)) + && self + .semantic + .scoping() + .get_resolved_reference_ids(symbol) + .iter() + .any(|reference| { + self.semantic.scoping().get_reference(*reference).is_write() + }) + { + continue; + } + if !matches!(value, Binding::String(_)) { + self.aliases.insert(init.span()); + } + self.bindings.insert(symbol, value); + } + } + BindingPattern::ObjectPattern(pattern) => { + if let Some(Binding::Namespace(root)) = self.value(init) { + self.aliases.insert(init.span()); + for property in &pattern.properties { + let key = self.property_key(property); + let api = key.as_deref().and_then(macro_name); + if key.is_some() && api.is_none() { + self.runtime.insert(root); + } + if let (Some(api), BindingPattern::BindingIdentifier(identifier)) = + (api, &property.value) + && matches!(root, Root::Module(_)) + && let Some(symbol) = identifier.symbol_id.get() + { + self.bindings.insert(symbol, Binding::Macro(api)); + } + } + } + } + _ => {} + } + } + if self.bindings.len() + self.loaders.len() == before { + break; + } + } + } + + fn alias_declaration(&self, declarator: NodeId, init: &Expression<'_>) -> bool { + matches!( + self.semantic.nodes().parent_kind(declarator), + AstKind::VariableDeclaration(declaration) if declaration.kind == VariableDeclarationKind::Const + ) || matches!(self.value(init), Some(Binding::Namespace(Root::Require(_)))) + } + + pub(super) fn property_key(&self, property: &BindingProperty<'_>) -> Option { + if property.computed { + return self.string(property.key.as_expression()?); + } + crate::utils::get_str_by_property_key(&property.key).map(Cow::into_owned) + } +} diff --git a/libs/extractor/src/emotion_namespace/declarations.rs b/libs/extractor/src/emotion_namespace/declarations.rs new file mode 100644 index 000000000..4e07a2464 --- /dev/null +++ b/libs/extractor/src/emotion_namespace/declarations.rs @@ -0,0 +1,143 @@ +use super::{ + AstKind, Binding, GetSpan, NodeId, Normalizer, REQUIRE_REQUIREMENT, Root, macro_name, + runtime_name, +}; +use oxc_ast::ast::{BindingPattern, Expression, ObjectPattern, VariableDeclarator}; + +/// A scope-independent stand-in: `undefined` can be shadowed, `void 0` cannot. +const ELIMINATED: &str = "void 0"; + +impl Normalizer<'_, '_> { + pub(super) fn lower_bindings(&mut self) { + for node in self.semantic.nodes().iter() { + let AstKind::VariableDeclarator(declarator) = node.kind() else { + continue; + }; + let Some(init) = &declarator.init else { + continue; + }; + let root = match self.value(init) { + Some(Binding::Macro(_)) => None, + Some(Binding::Namespace(root)) => Some(root), + Some(Binding::String(_)) | None => continue, + }; + if !self.alias_allowed(node.id(), declarator, init) { + continue; + } + if let Expression::ComputedMemberExpression(member) = init.get_inner_expression() { + self.check_read(&member.expression); + } + match root { + None => self.replace(init.span(), ELIMINATED.into()), + Some(root) => self.lower_namespace(declarator, init, root), + } + } + } + + fn alias_allowed( + &mut self, + node: NodeId, + declarator: &VariableDeclarator<'_>, + init: &Expression<'_>, + ) -> bool { + if !self.aliases.contains(&init.span()) { + self.error( + declarator.span, + "namespace and styling aliases must be simple const bindings", + ); + return false; + } + let declaration = self.semantic.nodes().parent_id(node); + if matches!( + self.semantic.nodes().parent_kind(declaration), + AstKind::ExportNamedDeclaration(_) | AstKind::ExportDeclaration(_) + ) { + self.error( + declarator.span, + "namespace and styling function bindings cannot be exported", + ); + return false; + } + true + } + + fn lower_namespace( + &mut self, + declarator: &VariableDeclarator<'_>, + init: &Expression<'_>, + root: Root, + ) { + let BindingPattern::ObjectPattern(pattern) = &declarator.id else { + if !self.is_runtime(root) { + self.replace(init.span(), "{}".into()); + } + return; + }; + let fields = self.pattern_fields(pattern, init, root); + if !matches!(root, Root::Require(_)) { + self.replace(init.span(), format!("{{{}}}", fields.join(","))); + } + } + + /// The members a destructuring keeps reading from the namespace: macros are + /// eliminated and runtime-only members are copied, so their nested patterns + /// and defaults keep working. + fn pattern_fields( + &mut self, + pattern: &ObjectPattern<'_>, + init: &Expression<'_>, + root: Root, + ) -> Vec { + if pattern.rest.is_some() { + self.error( + pattern.span, + "namespace rest destructuring can expose styling functions", + ); + } + let mut fields = Vec::new(); + for property in &pattern.properties { + let Some(key) = self.property_key(property) else { + self.error(property.span, "destructuring keys must be constant strings"); + continue; + }; + if property.computed + && let Some(expression) = property.key.as_expression() + { + self.check_read(expression); + } + let value = if macro_name(&key).is_some() { + if !matches!(property.value, BindingPattern::BindingIdentifier(_)) { + self.error( + property.span, + "use a plain binding without defaults or nested patterns", + ); + continue; + } + if matches!(root, Root::Require(_)) { + self.error(property.span, REQUIRE_REQUIREMENT); + continue; + } + ELIMINATED.to_string() + } else { + if !runtime_name(&key) { + self.error( + property.span, + "use a supported styling API or a runtime-only Emotion export", + ); + continue; + } + self.runtime.insert(root); + format!( + "{}[{}]", + self.text(init.span()), + crate::vanilla_extract::json_string(&key) + ) + }; + fields.push(format!( + "{}:{value}", + crate::vanilla_extract::json_string(&key) + )); + } + fields + } +} diff --git a/libs/extractor/src/emotion_namespace/driver.rs b/libs/extractor/src/emotion_namespace/driver.rs new file mode 100644 index 000000000..58c5900b3 --- /dev/null +++ b/libs/extractor/src/emotion_namespace/driver.rs @@ -0,0 +1,187 @@ +use super::{ + Allocator, BTreeMap, Binding, Cow, Errors, GetSpan, HashMap, HashSet, ImportAlias, Normalized, + Normalizer, Parser, Root, SemanticBuilder, SourceType, Span, identifier_names, macro_name, +}; +use oxc_ast::ast::{ + Declaration, ImportDeclaration, ImportDeclarationSpecifier, Statement, + TSImportEqualsDeclaration, TSModuleReference, +}; + +fn is_emotion_require(declaration: &TSImportEqualsDeclaration<'_>) -> bool { + !declaration.import_kind.is_type() + && matches!( + &declaration.module_reference, + TSModuleReference::ExternalModuleReference(reference) + if reference.expression.value == "@emotion/css" + ) +} + +impl Normalizer<'_, '_> { + /// Bind the namespaces a module statement imports; the span of an ES + /// namespace import goes to `namespaces`, to drop it when nothing reads it. + fn collect_statement(&mut self, statement: &Statement<'_>, namespaces: &mut Vec) { + match statement { + Statement::ExportAllDeclaration(export) + if export.source.value == "@emotion/css" && !export.export_kind.is_type() => + { + self.error( + export.span, + "a namespace containing styling functions cannot be re-exported", + ); + } + Statement::ExportFromDeclaration(export) + if export.source.value == "@emotion/css" && !export.export_kind.is_type() => + { + for specifier in &export.specifiers { + if !specifier.export_kind.is_type() + && (macro_name(specifier.local.name().as_str()).is_some() + || specifier.local.name() == "default") + { + self.error(specifier.span, "styling functions cannot be re-exported"); + } + } + } + Statement::ExportDeclaration(export) if matches!(&export.declaration, Declaration::TSImportEqualsDeclaration(declaration) if is_emotion_require(declaration)) => + { + self.error( + export.span, + "a namespace containing styling functions cannot be exported", + ); + } + Statement::TSImportEqualsDeclaration(declaration) + if is_emotion_require(declaration) => + { + if let Some(symbol) = declaration.id.symbol_id.get() { + self.bindings + .insert(symbol, Binding::Namespace(Root::Require(declaration.span))); + } + } + Statement::ImportDeclaration(import) => { + self.collect_module_api(import); + self.collect_import(import, namespaces); + } + _ => {} + } + } + + fn collect_import(&mut self, import: &ImportDeclaration<'_>, namespaces: &mut Vec) { + if import.source.value != "@emotion/css" || import.import_kind.is_type() { + return; + } + for (specifier, symbol) in import.specifiers.iter().flatten().filter_map(|specifier| { + specifier + .local() + .symbol_id + .get() + .map(|symbol| (specifier, symbol)) + }) { + match specifier { + ImportDeclarationSpecifier::ImportNamespaceSpecifier(_) => { + self.bindings + .insert(symbol, Binding::Namespace(Root::Module(import.span))); + namespaces.push(import.span); + } + ImportDeclarationSpecifier::ImportDefaultSpecifier(_) => self.error( + specifier.span(), + "the root module has no default export; use named or namespace imports", + ), + ImportDeclarationSpecifier::ImportSpecifier(named) + if !named.import_kind.is_type() => + { + let name = named.imported.name(); + if name == "default" { + self.error(named.span, "the root module has no default export"); + } + if let Some(api) = macro_name(name.as_str()) { + self.bindings.insert(symbol, Binding::Macro(api)); + } + } + ImportDeclarationSpecifier::ImportSpecifier(_) => {} + } + } + } +} + +/// Lower only the enabled root Emotion alias; offsets belong to the input source. +pub(crate) fn normalize<'a>( + code: &'a str, + filename: &str, + aliases: &HashMap, +) -> Result, Errors> { + if aliases.get("@emotion/css") != Some(&ImportAlias::NamedToNamed) + || !code.contains("@emotion/css") + { + return Ok((Cow::Borrowed(code), vec![])); + } + let allocator = Allocator::default(); + let parsed = Parser::new( + &allocator, + code, + SourceType::from_path(filename).unwrap_or_default(), + ) + .parse(); + let semantic = SemanticBuilder::new() + .with_build_nodes(true) + .build(&parsed.program) + .semantic; + let mut normalizer = Normalizer { + code, + semantic: &semantic, + bindings: HashMap::new(), + aliases: HashSet::new(), + runtime: HashSet::new(), + loaders: HashSet::new(), + module_api: HashSet::new(), + names: identifier_names(&semantic), + imports: BTreeMap::new(), + replacements: vec![], + errors: vec![], + }; + let mut namespaces = Vec::new(); + for statement in &parsed.program.body { + normalizer.collect_statement(statement, &mut namespaces); + } + normalizer.collect_bindings(); + normalizer.lower_reads(); + normalizer.reject_unawaited_imports(); + normalizer.lower_bindings(); + for span in namespaces { + if !normalizer.runtime.contains(&Root::Module(span)) { + normalizer.replace(span, String::new()); + } + } + if !normalizer.errors.is_empty() { + return Err(normalizer.errors); + } + if !normalizer.imports.is_empty() { + let imports = normalizer + .imports + .iter() + .map(|(api, name)| format!("{api} as {name}")) + .collect::>() + .join(","); + // Insert at the first statement rather than before a possible hashbang. + let offset = parsed + .program + .body + .first() + .map_or(0, |statement| statement.span().start); + normalizer.replace( + Span::new(offset, offset), + format!("import {{{imports}}} from '@emotion/css';\n"), + ); + } + normalizer + .replacements + .sort_by_key(|(start, end, _)| (*start, *end)); + let edits = normalizer + .replacements + .iter() + .map(|(start, end, value)| (*start, *end, value.len())) + .collect(); + let mut output = code.to_string(); + for (start, end, value) in normalizer.replacements.into_iter().rev() { + output.replace_range(start..end, &value); + } + Ok((Cow::Owned(output), edits)) +} diff --git a/libs/extractor/src/emotion_namespace/order.rs b/libs/extractor/src/emotion_namespace/order.rs new file mode 100644 index 000000000..35164af76 --- /dev/null +++ b/libs/extractor/src/emotion_namespace/order.rs @@ -0,0 +1,109 @@ +//! Reads of an eliminated alias must be able to run only after the alias is +//! initialized. Eliminating the alias removes the read, and with it the +//! `ReferenceError` the original code throws while the binding is still in its +//! temporal dead zone. +use super::{AstKind, GetSpan, Normalizer}; +use oxc_ast::ast::{Expression, Function, FunctionType}; +use oxc_syntax::{node::NodeId, reference::ReferenceId, symbol::SymbolId}; +use std::collections::HashSet; + +const fn is_function(kind: AstKind<'_>) -> bool { + matches!( + kind, + AstKind::Function(_) | AstKind::ArrowFunctionExpression(_) + ) +} + +impl Normalizer<'_, '_> { + /// Check the read `expression` names when it is a tracked variable. + pub(super) fn check_read(&mut self, expression: &Expression<'_>) { + if let Expression::Identifier(identifier) = expression.get_inner_expression() + && let Some(reference) = identifier.reference_id.get() + { + self.check_order(reference); + } + } + + /// Report a read of a tracked variable that can run before its declarator. + pub(super) fn check_order(&mut self, id: ReferenceId) { + let scoping = self.semantic.scoping(); + let reference = scoping.get_reference(id); + let Some(symbol) = reference.symbol_id() else { + return; + }; + if !self.bindings.contains_key(&symbol) { + return; + } + let declaration = scoping.symbol_declaration(symbol); + if matches!( + self.semantic.nodes().kind(declaration), + AstKind::VariableDeclarator(_) + ) && !self.runs_after(reference.node_id(), declaration, &mut HashSet::new()) + { + let span = self.semantic.nodes().kind(reference.node_id()).span(); + self.error( + span, + "an alias must be initialized before any read that can run first; declare it above the earliest code that can reach this read", + ); + } + } + + /// Whether code at `node` can only run once `declaration` has completed. + /// + /// Straight-line code in the declaring function is ordered by position. + /// Code in a nested function runs when that function is called, so the + /// outermost such function decides: one created after the declaration is + /// ordered, and a hoisted declaration is ordered only when everything that + /// can call it is. Which calls actually happen is not decided here; any + /// reference that could come first rejects the read. + fn runs_after( + &self, + node: NodeId, + declaration: NodeId, + visiting: &mut HashSet, + ) -> bool { + let nodes = self.semantic.nodes(); + let home = nodes + .ancestor_ids(declaration) + .find(|id| is_function(nodes.kind(*id))); + let outer = nodes + .ancestor_ids(node) + .take_while(|id| Some(*id) != home) + .filter(|id| is_function(nodes.kind(*id))) + .last(); + if let Some(function) = outer + && let AstKind::Function(declared) = nodes.kind(function) + && declared.r#type == FunctionType::FunctionDeclaration + { + return self.callers_run_after(declared, declaration, visiting); + } + let anchor = outer.unwrap_or(node); + let case = nodes + .ancestor_ids(declaration) + .take_while(|id| !is_function(nodes.kind(*id))) + .find(|id| matches!(nodes.kind(*id), AstKind::SwitchCase(_))); + nodes.kind(anchor).span().start >= nodes.kind(declaration).span().end + && case.is_none_or(|case| nodes.ancestor_ids(anchor).any(|id| id == case)) + } + + fn callers_run_after( + &self, + function: &Function<'_>, + declaration: NodeId, + visiting: &mut HashSet, + ) -> bool { + let Some(symbol) = function.id.as_ref().and_then(|id| id.symbol_id.get()) else { + return true; + }; + if !visiting.insert(symbol) { + return true; + } + let scoping = self.semantic.scoping(); + scoping + .get_resolved_reference_ids(symbol) + .iter() + .map(|id| scoping.get_reference(*id)) + .filter(|reference| !reference.is_type()) + .all(|reference| self.runs_after(reference.node_id(), declaration, visiting)) + } +} diff --git a/libs/extractor/src/emotion_namespace/output.rs b/libs/extractor/src/emotion_namespace/output.rs new file mode 100644 index 000000000..6fcb7df6e --- /dev/null +++ b/libs/extractor/src/emotion_namespace/output.rs @@ -0,0 +1,44 @@ +use super::{Allocator, Edit, Parser, SourceType}; +use crate::{ExtractOutput, source_map}; +use oxc_codegen::{Codegen, CodegenOptions}; +use rustc_hash::FxHashSet; +use std::path::PathBuf; + +/// A module the namespace pass rewrote but that has nothing for Devup UI to +/// extract. `code` is the rewritten module; `edits`, last made first, map it +/// back to `source`. +pub(crate) struct Rewritten<'a> { + pub filename: &'a str, + pub code: &'a str, + pub source: &'a str, + pub edits: &'a [&'a [Edit]], +} + +impl Rewritten<'_> { + /// Generated from the rewritten module, so the map points at `source`. + pub(crate) fn output(&self) -> ExtractOutput { + let allocator = Allocator::default(); + let program = Parser::new( + &allocator, + self.code, + SourceType::from_path(self.filename).unwrap_or_default(), + ) + .parse() + .program; + let result = Codegen::new() + .with_options(CodegenOptions { + source_map_path: Some(PathBuf::from(self.filename)), + ..Default::default() + }) + .build(&program); + ExtractOutput { + styles: FxHashSet::default(), + code: result.code, + map: result.map.map(|map| { + source_map::remap(map, self.code, self.source, self.edits).to_json_string() + }), + css_file: None, + dependencies: Vec::new(), + } + } +} diff --git a/libs/extractor/src/emotion_namespace/reads.rs b/libs/extractor/src/emotion_namespace/reads.rs new file mode 100644 index 000000000..42de640cc --- /dev/null +++ b/libs/extractor/src/emotion_namespace/reads.rs @@ -0,0 +1,133 @@ +use super::{ + AstKind, Binding, GetSpan, NodeId, Normalizer, REQUIRE_REQUIREMENT, Root, macro_name, + runtime_name, +}; + +impl Normalizer<'_, '_> { + pub(super) fn lower_reads(&mut self) { + let mut bindings: Vec<_> = self + .bindings + .iter() + .map(|(symbol, binding)| (*symbol, binding.clone())) + .collect(); + bindings.sort_by_key(|(symbol, _)| *symbol); + for (symbol, binding) in bindings { + for reference_id in self.semantic.scoping().get_resolved_reference_ids(symbol) { + let reference = self.semantic.scoping().get_reference(*reference_id); + if reference.is_type() { + continue; + } + if !matches!(binding, Binding::String(_)) { + self.check_order(*reference_id); + } + let node = self.expression_node(reference.node_id()); + let span = self.semantic.nodes().kind(node).span(); + if self.aliases.contains(&span) { + continue; + } + match &binding { + Binding::Namespace(root) => self.read_namespace(*root, node), + Binding::Macro(api) => self.lower_macro(node, api), + Binding::String(_) => {} + } + } + } + for node in self.semantic.nodes().iter() { + let root = match node.kind() { + AstKind::CallExpression(call) => self.require_call(call), + AstKind::AwaitExpression(wait) => self.awaited_import(wait), + _ => None, + }; + if let Some(root) = root { + let node = self.expression_node(node.id()); + let span = self.semantic.nodes().kind(node).span(); + if !self.aliases.contains(&span) { + self.read_namespace(root, node); + } + } + } + } + + fn read_namespace(&mut self, root: Root, node: NodeId) { + if !self.lower_member(root, node) { + let span = self.semantic.nodes().kind(node).span(); + self.error( + span, + "a namespace may only be read through statically known members or const aliases", + ); + } + } + + /// Lower the member read of a namespace expression `node`; `false` when + /// `node` is not the object of a member read at all. + fn lower_member(&mut self, root: Root, node: NodeId) -> bool { + let semantic = self.semantic; + let span = semantic.nodes().kind(node).span(); + let parent = semantic.nodes().parent_id(node); + let (key, optional, member_span) = match semantic.nodes().kind(parent) { + AstKind::StaticMemberExpression(member) if member.object.span() == span => ( + Some(member.property.name.to_string()), + member.optional, + member.span, + ), + AstKind::ComputedMemberExpression(member) if member.object.span() == span => { + self.check_read(&member.expression); + ( + self.string(&member.expression), + member.optional, + member.span, + ) + } + _ => return false, + }; + let member = self.expression_node(parent); + if self.aliases.contains(&semantic.nodes().kind(member).span()) { + return true; + } + let Some(key) = key else { + self.error( + member_span, + "namespace member keys must be constant strings", + ); + return true; + }; + if let Some(api) = macro_name(&key) { + if optional { + self.error(member_span, "optional namespace reads cannot be compiled"); + } else if matches!(root, Root::Require(_)) { + self.error(member_span, REQUIRE_REQUIREMENT); + } else { + self.lower_macro(parent, api); + } + } else if runtime_name(&key) { + self.runtime.insert(root); + } else { + self.error( + member_span, + "use a supported styling API or a runtime-only Emotion export", + ); + } + true + } + + fn lower_macro(&mut self, node: NodeId, api: &'static str) { + let expression = self.expression_node(node); + let span = self.semantic.nodes().kind(expression).span(); + let callable = match self.semantic.nodes().parent_kind(expression) { + AstKind::CallExpression(call) => call.callee.span() == span && !call.optional, + AstKind::TaggedTemplateExpression(tagged) => tagged.tag.span() == span, + _ => false, + }; + if callable { + let name = self.fresh(api); + self.replace(span, name); + } else { + self.error(span, "styling functions must be called directly, not mutated, exported or passed as values"); + } + } +} + +#[cfg(test)] +mod coverage { + include!("../emotion_namespace_tests/coverage.rs"); +} diff --git a/libs/extractor/src/emotion_namespace/restore.rs b/libs/extractor/src/emotion_namespace/restore.rs new file mode 100644 index 000000000..8bb6084d9 --- /dev/null +++ b/libs/extractor/src/emotion_namespace/restore.rs @@ -0,0 +1,82 @@ +use super::{Allocator, AstKind, Parser, SemanticBuilder, SourceType, identifier_names}; + +/// Put the written expression back into an error message that names a +/// generated import; names the program itself binds or reads stay as written. +pub(crate) fn original_error_code( + source: &str, + offset: usize, + mut message: String, + source_type: SourceType, +) -> String { + if !message.contains("__emotion_") { + return message; + } + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, source, source_type).parse(); + if !parsed.diagnostics.is_empty() { + return format!( + "{message}\nEmotion diagnostic restoration failed: {:?}", + parsed.diagnostics + ); + } + let program = parsed.program; + let semantic = SemanticBuilder::new() + .with_build_nodes(true) + .build(&program) + .semantic; + let owned = identifier_names(&semantic); + let original = semantic + .nodes() + .iter() + .filter_map(|node| match node.kind() { + AstKind::StaticMemberExpression(member) => Some(member.span), + AstKind::ComputedMemberExpression(member) => Some(member.span), + AstKind::IdentifierReference(identifier) => Some(identifier.span), + AstKind::ParenthesizedExpression(expression) => Some(expression.span), + AstKind::TSAsExpression(expression) => Some(expression.span), + AstKind::TSSatisfiesExpression(expression) => Some(expression.span), + AstKind::TSNonNullExpression(expression) => Some(expression.span), + AstKind::TSTypeAssertion(expression) => Some(expression.span), + AstKind::TSInstantiationExpression(expression) => Some(expression.span), + _ => None, + }) + .filter(|span| span.start as usize == offset) + .max_by_key(|span| span.end); + let markers = message + .find("cannot use `") + .map(|start| start + "cannot use `".len()) + .zip(message.rfind("` at build time:")); + if let Some(span) = original + && let Some((start, end)) = markers + { + let original = &source[span.start as usize..span.end as usize]; + let code = format!("({})", &message[start..end]); + // The diagnostic expression is emitted by readable_code from the parsed AST. + let parsed = Parser::new(&allocator, &code, source_type).parse(); + let semantic = SemanticBuilder::new() + .with_build_nodes(true) + .build(&parsed.program) + .semantic; + let mut references = semantic + .nodes() + .iter() + .filter_map(|node| match node.kind() { + AstKind::IdentifierReference(identifier) + if identifier.name.starts_with("__emotion_") + && !owned.contains(identifier.name.as_str()) => + { + Some(identifier.span) + } + _ => None, + }) + .collect::>(); + references.sort_unstable_by_key(|span| std::cmp::Reverse(span.start)); + for span in references { + message.replace_range( + start + span.start as usize - 1..start + span.end as usize - 1, + original, + ); + } + } + message +} diff --git a/libs/extractor/src/emotion_namespace/units.rs b/libs/extractor/src/emotion_namespace/units.rs new file mode 100644 index 000000000..c0d68bc6b --- /dev/null +++ b/libs/extractor/src/emotion_namespace/units.rs @@ -0,0 +1,178 @@ +use crate::utils::{get_str_by_property_key, js_number_literal, keeps_bare_number}; +use oxc_allocator::{Allocator, FromIn, GetAllocator}; +use oxc_ast::{ + ast::{ + CallExpression, Expression, ImportDeclarationSpecifier, ObjectPropertyKind, Program, + Statement, Str, + }, + builder::AstBuilder, +}; +use oxc_ast_visit::{VisitMut, walk_mut}; +use oxc_parser::Parser; +use oxc_span::{GetSpan, SourceType}; + +struct Units<'a> { + ast: AstBuilder<'a>, + calls: Vec, +} + +impl<'a> Units<'a> { + fn rules(&self, expression: &mut Expression<'a>) { + match expression { + Expression::ObjectExpression(object) => { + for property in &mut object.properties { + if let ObjectPropertyKind::ObjectProperty(property) = property + && let Some(key) = get_str_by_property_key(&property.key) + { + self.value(&key, &mut property.value); + } + } + } + Expression::ArrayExpression(array) => { + for element in &mut array.elements { + if let Some(expression) = element.as_expression_mut() { + self.rules(expression); + } + } + } + Expression::ConditionalExpression(conditional) => { + self.rules(&mut conditional.consequent); + self.rules(&mut conditional.alternate); + } + Expression::LogicalExpression(logical) => self.rules(&mut logical.right), + Expression::ParenthesizedExpression(inner) => self.rules(&mut inner.expression), + _ => {} + } + } + + fn value(&self, key: &str, expression: &mut Expression<'a>) { + if let Some(number) = js_number_literal(expression) { + if number != 0.0 && !keeps_bare_number(key) { + *expression = Expression::new_string_literal( + expression.span(), + Str::from_in(format!("{number}px").as_str(), self.ast.allocator()), + None, + &self.ast, + ); + } + return; + } + match expression { + Expression::ObjectExpression(_) if key != "vars" => self.rules(expression), + Expression::ConditionalExpression(conditional) => { + self.value(key, &mut conditional.consequent); + self.value(key, &mut conditional.alternate); + } + Expression::LogicalExpression(logical) => self.value(key, &mut logical.right), + Expression::ParenthesizedExpression(inner) => self.value(key, &mut inner.expression), + _ => {} + } + } +} + +impl<'a> VisitMut<'a> for Units<'a> { + fn visit_call_expression(&mut self, call: &mut CallExpression<'a>) { + if matches!(&call.callee, Expression::Identifier(identifier) if self.calls.iter().any(|name| name == identifier.name.as_str())) + { + for argument in &mut call.arguments { + if let Some(expression) = argument.as_expression_mut() { + self.rules(expression); + } + } + } + walk_mut::walk_call_expression(self, call); + } +} + +/// Preserve Emotion units for values reduced by the constant inliner. +pub(crate) fn pixelify<'a>(allocator: &'a Allocator, program: &mut Program<'a>, original: &str) { + let source_allocator = Allocator::default(); + let source = Parser::new(&source_allocator, original, SourceType::tsx()) + .parse() + .program; + let mut calls = Vec::new(); + for statement in &source.body { + if let Statement::ImportDeclaration(import) = statement + && import.source.value == "@emotion/css" + { + for specifier in import.specifiers.iter().flatten() { + if let ImportDeclarationSpecifier::ImportSpecifier(named) = specifier + && matches!( + named.imported.name().as_str(), + "css" | "keyframes" | "injectGlobal" + ) + { + calls.push(named.local.name.to_string()); + } + } + } + } + Units { + ast: AstBuilder::new(allocator), + calls, + } + .visit_program(program); +} + +#[cfg(test)] +mod tests { + use super::*; + use oxc_codegen::Codegen; + use rstest::rstest; + + #[rstest] + #[case( + r"css([{ padding: 2 }, [{ margin: -3 }], , ...rest, null]);", + r"css([{ padding: '2px' }, [{ margin: '-3px' }], , ...rest, null]);" + )] + #[case( + r"css(flag ? { padding: 2 } : { margin: 3 });", + r"css(flag ? { padding: '2px' } : { margin: '3px' });" + )] + #[case( + r"css(flag && ({ padding: 2 }));", + r"css(flag && ({ padding: '2px' }));" + )] + #[case( + r"css({ padding: flag ? 2 : -3 });", + r"css({ padding: flag ? '2px' : '-3px' });" + )] + #[case(r"css({ padding: flag && 2 });", r"css({ padding: flag && '2px' });")] + #[case( + r"css({ padding: (flag ? 2 : 3) });", + r"css({ padding: (flag ? '2px' : '3px') });" + )] + #[case( + r"keyframes({ from: { margin: 2 }, to: { margin: 0 } }); injectGlobal({ body: { padding: 3 } });", + r"keyframes({ from: { margin: '2px' }, to: { margin: 0 } }); injectGlobal({ body: { padding: '3px' } });" + )] + #[case( + r"css({ padding: 0, opacity: 0.5, lineHeight: 2, vars: { size: 3 }, margin: '4', ...rest, [key]: 5 }); cx({ picked: 2 }); other({ padding: 3 });", + r"css({ padding: 0, opacity: 0.5, lineHeight: 2, vars: { size: 3 }, margin: '4', ...rest, [key]: 5 }); cx({ picked: 2 }); other({ padding: 3 });" + )] + fn preserves_emotion_units_when_rules_contain_nested_expressions( + #[case] body: &str, + #[case] expected_body: &str, + ) { + // Given + let imports = "import {css,keyframes,injectGlobal,cx} from '@emotion/css';"; + let source = format!("{imports}{body}"); + let expected = format!("{imports}{expected_body}"); + let allocator = Allocator::default(); + let mut parsed = Parser::new(&allocator, &source, SourceType::tsx()).parse(); + assert_eq!(parsed.diagnostics.len(), 0); + let expected_allocator = Allocator::default(); + let expected_program = + Parser::new(&expected_allocator, &expected, SourceType::tsx()).parse(); + assert_eq!(expected_program.diagnostics.len(), 0); + + // When + pixelify(&allocator, &mut parsed.program, &source); + + // Then + assert_eq!( + Codegen::new().build(&parsed.program).code, + Codegen::new().build(&expected_program.program).code, + ); + } +} diff --git a/libs/extractor/src/emotion_namespace_tests.rs b/libs/extractor/src/emotion_namespace_tests.rs new file mode 100644 index 000000000..3c6f6d9b2 --- /dev/null +++ b/libs/extractor/src/emotion_namespace_tests.rs @@ -0,0 +1,261 @@ +use super::*; +use css::{class_map::reset_class_map, file_map::reset_file_map}; +use serial_test::serial; +mod mapping { + include!("emotion_namespace_tests/mapping.rs"); +} +mod diagnostics { + include!("emotion_namespace_tests/diagnostics.rs"); +} +mod exports { + include!("emotion_namespace_tests/exports.rs"); +} +mod order { + include!("emotion_namespace_tests/order.rs"); +} +mod acquisition { + include!("emotion_namespace_tests/acquisition.rs"); +} +mod loaders { + include!("emotion_namespace_tests/loaders.rs"); +} + +fn compile(code: &str) -> Result { + reset_class_map(); + reset_file_map(); + extract( + "namespace.tsx", + code, + ExtractOption { + import_aliases: HashMap::from([("@emotion/css".into(), ImportAlias::NamedToNamed)]), + ..ExtractOption::default() + }, + ) + .map_err(|error| error.to_string()) +} + +#[test] +#[serial] +fn namespace_members_compile_when_called() { + let code = "import * as E from '@emotion/css';\nconst a = E.css({ padding: 8, opacity: 0.5 }); const b = E['css']`color:red;`; export const c = E.cx(a, { selected: 8 }); export const d = E.merge('external'); export const k = E.keyframes({from:{opacity:0}}); E.injectGlobal`body{margin:0}`;"; + let output = compile(code).unwrap(); + assert!(!output.code.contains("@emotion/css"), "{}", output.code); + assert!(!output.code.contains("E."), "{}", output.code); + let styles = format!("{:?}", output.styles); + assert!(styles.contains("8px"), "{styles}"); + assert!(styles.contains(".5"), "{styles}"); + assert!(!styles.contains("selected"), "{styles}"); +} + +#[test] +#[serial] +fn namespace_destructure_has_named_import_semantics() { + let named = compile("import {css,cx} from '@emotion/css'; const a=css({padding:8,opacity:0.5}); export const b=cx(a,{picked:8});").unwrap(); + let namespace = compile("import * as E from '@emotion/css'; const {css: c, ['cx']: join}=E; const a=c({padding:8,opacity:0.5}); export const b=join(a,{picked:8});").unwrap(); + assert_eq!(named.styles, namespace.styles); + assert!( + !namespace.code.contains("@emotion/css"), + "{}", + namespace.code + ); +} + +#[test] +#[serial] +fn runtime_members_keep_namespace_when_used() { + let output = compile("import * as E from '@emotion/css'; const {css,flush,cache}=E; export const a=css({padding:8}); flush(); console.log(cache,E.sheet,E.hydrate,E.getRegisteredStyles);").unwrap(); + assert!(output.code.contains("@emotion/css"), "{}", output.code); + assert!(output.code.contains("E.sheet"), "{}", output.code); + assert!(!output.code.contains("css("), "{}", output.code); +} + +#[test] +#[serial] +fn constant_aliases_follow_symbols_when_scopes_shadow_names() { + let output = compile(r"import * as E from '@emotion/css'; const __emotion_css_0=1; const key='css', N=E, c=N[key]; function f(E){return E.css({ padding: 3 })} function g(){const {css: c}=N; return c({ padding: 8 })} export const a=c({ opacity: 0.5 });").unwrap(); + assert!(!output.code.contains("@emotion/css"), "{}", output.code); + assert!(output.code.contains("E.css"), "{}", output.code); + assert!(format!("{:?}", output.styles).contains("8px")); +} + +#[test] +#[serial] +fn unsafe_namespace_reads_report_original_locations() { + for body in [ + "E[key]({color:'red'});", + "consume(E);", + "consume(E.css);", + "E.css?.({});", + "E.css=fn;", + "const {...rest}=E;", + "const {css=fn}=E;", + "const {css:{call}}=E;", + "export const {css}=E;", + "export {E};", + ] { + let error = compile(&format!("import * as E from '@emotion/css';\n{body}")).unwrap_err(); + assert!(error.starts_with("namespace.tsx:2:"), "{body}\n{error}"); + assert!(error.contains("cannot use"), "{error}"); + assert!(!error.contains("__emotion_"), "{error}"); + } +} + +#[test] +#[serial] +fn default_import_reports_error_when_value_imported() { + for import in [ + "import E from '@emotion/css';", + "import E, * as N from '@emotion/css';", + "import {default as E} from '@emotion/css';", + ] { + let error = compile(import).unwrap_err(); + assert!(error.starts_with("namespace.tsx:1:"), "{error}"); + assert!(error.contains("default"), "{error}"); + } +} + +#[test] +#[serial] +fn disabled_alias_preserves_namespace_source() { + let code = r"import * as E from '@emotion/css'; E.css({ padding: 8 });"; + let output = extract("namespace.tsx", code, ExtractOption::default()).unwrap(); + assert_eq!(output.code, code); +} + +#[test] +#[serial] +fn style_error_keeps_original_position_after_normalization() { + let error = + compile("import * as E from '@emotion/css';\nexport const a=E.css({color: unknown});") + .unwrap_err(); + assert!(error.contains("namespace.tsx:2:16:"), "{error}"); + assert!(error.contains("unknown"), "{error}"); +} + +#[test] +#[serial] +fn computed_values_compile_through_boa_when_namespace_normalized() { + let output = compile("import * as E from '@emotion/css'; function double(n){return n*2} export const a=E.css({padding:double(4)});").unwrap(); + assert!( + format!("{:?}", output.styles).contains("8px"), + "{:?}", + output.styles + ); + assert!(!output.code.contains("@emotion/css"), "{}", output.code); +} + +#[test] +#[serial] +fn constants_keep_emotion_units_when_inlined() { + let output = compile("import * as E from '@emotion/css'; const n=2; export const a=E.css({padding:n*4,opacity:n/4});").unwrap(); + let styles = format!("{:?}", output.styles); + assert!(styles.contains("8px"), "{styles}"); + assert!(styles.contains(".5"), "{styles}"); +} + +#[test] +#[serial] +fn bracket_members_compile_when_all_keys_are_static() { + let output = compile(r"import * as E from '@emotion/css'; const key='css', k2=key; const a=E[k2]({ margin: 3 }); const b=E[`css`]`color:red`; export const c=E['cx'](a,b); export const d=E['merge']('a b'); export const k=E['keyframes']`from{ opacity: 0 }to{ opacity: 1 }`; E['injectGlobal']({body:{ margin: 2 }});").unwrap(); + assert!(!output.code.contains("@emotion/css"), "{}", output.code); + assert!(format!("{:?}", output.styles).contains("3px")); +} + +#[test] +#[serial] +fn runtime_alias_chains_keep_source_bindings_when_destructured() { + let output = compile("import * as E from '@emotion/css'; const N=E, A=N; const {css,flush}=A; export const a=css({padding:8}); flush();").unwrap(); + assert!(output.code.contains("N = E"), "{}", output.code); + assert!(output.code.contains("A = N"), "{}", output.code); + assert!(output.code.contains("A[\"flush\"]"), "{}", output.code); +} + +#[test] +#[serial] +fn destructuring_in_for_initializer_stays_valid_when_lowered() { + let output = compile(r"import * as E from '@emotion/css'; for(const {css}=E, other=1; other<2;){console.log(css({ padding: 8 }));break}").unwrap(); + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, &output.code, SourceType::tsx()).parse(); + assert_eq!(parsed.diagnostics.len(), 0, "{}", output.code); + assert!(!output.code.contains("@emotion/css"), "{}", output.code); +} + +#[test] +#[serial] +fn unsupported_patterns_and_reexports_fail_when_alias_enabled() { + for body in [ + "let N=E; N.css({});", + "const [css]=E;", + "const {[key]:css}=E;", + "const {unknown}=E;", + "E.unknown({});", + "E?.css({});", + "const c=E.css; export {c};", + "const c=E.css; consume(c);", + "const N=E; N={};", + "const key='css'; key='cx'; E[key]({});", + ] { + let error = compile(&format!("import * as E from '@emotion/css';\n{body}")).unwrap_err(); + assert!(error.starts_with("namespace.tsx:2:"), "{body}\n{error}"); + } + for code in [ + "export * from '@emotion/css';", + "export * as E from '@emotion/css';", + "export {css} from '@emotion/css';", + "export {default} from '@emotion/css';", + ] { + assert!(compile(code).unwrap_err().starts_with("namespace.tsx:1:")); + } +} + +#[test] +#[serial] +fn tagged_class_error_mentions_original_expression_when_normalized() { + let error = + compile("import * as E from '@emotion/css';\nexport const a=E.cx`a b`;").unwrap_err(); + assert!(error.contains("namespace.tsx:2:16:"), "{error}"); + assert!(error.contains("E.cx`a b`"), "{error}"); + assert!(!error.contains("__emotion_"), "{error}"); +} + +#[test] +#[serial] +fn type_only_reads_do_not_trigger_escape_errors() { + let output = compile(r"import type Default from '@emotion/css'; import * as E from '@emotion/css'; type Api=typeof E.css; const a=E.css({ padding: 8 });").unwrap(); + assert!(format!("{:?}", output.styles).contains("8px")); +} + +#[test] +#[serial] +fn unused_macros_drop_emotion_when_no_runtime_reads_remain() { + let output = compile("import * as E from '@emotion/css'; const {css}=E; const N=E;").unwrap(); + assert!(!output.code.contains("@emotion/css"), "{}", output.code); +} + +#[test] +#[serial] +fn generated_import_skips_existing_names_when_name_collides() { + let output = compile(r"import * as E from '@emotion/css'; const __emotion_css_3=7; export const a=E.css({ padding: 8 }); console.log(__emotion_css_3);").unwrap(); + assert!( + output.code.contains("console.log(__emotion_css_3)"), + "{}", + output.code + ); + assert!(format!("{:?}", output.styles).contains("8px")); + assert!(!output.code.contains("@emotion/css"), "{}", output.code); +} + +#[test] +#[serial] +fn type_only_and_runtime_reexports_remain_when_not_styling() { + for code in [ + "export type * from '@emotion/css';", + "export type {css} from '@emotion/css';", + "export {flush} from '@emotion/css';", + "import {type css} from '@emotion/css';", + "import * as E from '@emotion/css'; console.log(E.cache);", + "import {flush} from '@emotion/css'; flush();", + ] { + assert!(compile(code).is_ok(), "{code}"); + } +} diff --git a/libs/extractor/src/emotion_namespace_tests/acquisition.rs b/libs/extractor/src/emotion_namespace_tests/acquisition.rs new file mode 100644 index 000000000..c757c5119 --- /dev/null +++ b/libs/extractor/src/emotion_namespace_tests/acquisition.rs @@ -0,0 +1,161 @@ +use super::*; +use rstest::rstest; + +const IMPORT: &str = "import * as E from '@emotion/css';\n"; + +#[test] +#[serial] +fn ordinary_await_is_preserved_when_namespace_styles_compile() { + // Given + let code = format!("{IMPORT}export const value = await Promise.resolve(1); export const style = E.css({{padding:8}});"); + + // When + let output = compile(&code).unwrap(); + + // Then + assert!(output.code.contains("await Promise.resolve(1)"), "{}", output.code); + assert!(!output.code.contains("@emotion/css"), "{}", output.code); + assert!(format!("{:?}", output.styles).contains("8px")); +} + +#[rstest] +#[case("import E = require('@emotion/css');", "export const a=E.css({ padding: 8 });")] +#[case("const E = require('@emotion/css');", "export const a=E.css({ padding: 8 });")] +#[case("var E = require('@emotion/css');", "export const a=E['cx']('a');")] +#[case("const E = require('@emotion/css');", "const c=E.css; export const a=c({});")] +#[case("export {};", "const { css } = require('@emotion/css'); css({ padding: 8 });")] +#[case("export {};", "export const a=require('@emotion/css').css({ padding: 8 });")] +#[case("export {};", "const c=require('@emotion/css').css; c({ padding: 8 });")] +#[case("export {};", "const {cache, ...rest} = require('@emotion/css');")] +#[case("export {};", "consume(require('@emotion/css'));")] +#[serial] +fn require_acquired_styling_reports_a_located_error(#[case] head: &str, #[case] body: &str) { + // Given: the styling API would otherwise stay a runtime Emotion call + // When + let error = compile(&format!("{head}\n{body}")).unwrap_err(); + + // Then + assert!(error.starts_with("namespace.tsx:2:"), "{error}"); + assert!(error.contains("cannot use"), "{error}"); +} + +#[rstest] +#[case("import E = require('@emotion/css');", "E.css")] +#[case("export {};", "require('@emotion/css').cx")] +#[serial] +fn require_acquired_styling_asks_for_an_es_import(#[case] head: &str, #[case] read: &str) { + // Given + let code = format!("{head}\nexport const a={read}({{padding:8}});"); + + // When + let error = compile(&code).unwrap_err(); + + // Then + assert!(error.contains(&format!("cannot use `{read}`")), "{error}"); + assert!(error.contains("require(), import-equals"), "{error}"); + assert!(error.contains("import * as ns from '@emotion/css'"), "{error}"); +} + +#[test] +#[serial] +fn exported_import_equals_reports_the_namespace_re_export() { + // Given + let code = "export import E = require('@emotion/css');"; + + // When + let error = compile(code).unwrap_err(); + + // Then + assert!(error.starts_with("namespace.tsx:1:1:"), "{error}"); + assert!(error.contains("cannot be exported"), "{error}"); +} + +#[rstest] +#[case("const { flush, cache: { key } } = require('@emotion/css'); flush(); console.log(key);")] +#[case("const E = require('@emotion/css'); E.cache; E['flush'](); E?.sheet;")] +#[case("var E = require('@emotion/css'), N = E; N.hydrate();")] +#[case("import E = require('@emotion/css'); console.log(E.sheet, E?.cache);")] +#[case("function require(){} require('@emotion/css').css({ padding: 8 });")] +#[case("import type E = require('@emotion/css'); type Api = typeof E;")] +#[case("import fs = require('fs'); export { fs };")] +#[serial] +fn runtime_only_require_use_stays_untouched(#[case] body: &str) { + // Given + // When + let output = compile(body).unwrap(); + + // Then + assert_eq!(output.code, body); +} + +#[test] +#[serial] +fn runtime_destructuring_keeps_nested_patterns_defaults_and_optional_reads() { + // Given + let code = format!( + "{IMPORT}const {{css, cache: {{key}}, flush = () => {{}}}} = E;\nexport const a = css({{padding: 8}});\nexport const b = [key, flush, E?.cache, E?.['sheet']];" + ); + + // When + let output = compile(&code).unwrap(); + + // Then + assert!(format!("{:?}", output.styles).contains("8px"), "{}", output.code); + assert!(output.code.contains("@emotion/css"), "{}", output.code); + assert!(output.code.contains("E?.cache"), "{}", output.code); + assert!(output.code.contains("cache: { key }"), "{}", output.code); + assert!(!output.code.contains("css("), "{}", output.code); +} + +#[rstest] +#[case("const {cache, ...rest} = E;")] +#[case("const {css = other, cache} = E;")] +#[case("const { css: { call }, cache } = E;")] +#[case("export const a = E?.css({});")] +#[case("export const a = E?.['cx']('a');")] +#[case("import N = E; N.css({ padding: 8 });")] +#[case("import c = E.css; c({ padding: 8 });")] +#[serial] +fn macro_shapes_without_a_build_time_meaning_stay_errors(#[case] body: &str) { + // Given + // When + let error = compile(&format!("{IMPORT}{body}")).unwrap_err(); + + // Then + assert!(error.starts_with("namespace.tsx:2:"), "{error}"); +} + +#[test] +#[serial] +fn string_literal_export_names_compile_by_their_value() { + // Given + let code = "import {'css' as c, 'cx' as join} from '@emotion/css'; const a=c({ padding: 8 }); export const b=join(a,{picked:8});"; + + // When + let output = compile(code).unwrap(); + + // Then + assert!(format!("{:?}", output.styles).contains("8px"), "{}", output.code); + assert!(!output.code.contains("@emotion/css"), "{}", output.code); +} + +#[test] +fn string_literal_export_names_are_quoted_only_when_retained() { + // Given + let code = "import {'css' as c, 'cx' as join, 'weird-name' as w} from '@emotion/css'"; + let aliases = HashMap::from([("@emotion/css".to_string(), ImportAlias::NamedToNamed)]); + + // When + let output = import_alias_visit::transform_import_aliases( + code, + "namespace.tsx", + "@devup-ui/react", + &aliases, + ); + + // Then + assert_eq!( + output, + "import { css as c } from '@devup-ui/react'; import { cx as join } from '@devup-ui/react/compat'; import { \"weird-name\" as w } from '@emotion/css';" + ); +} diff --git a/libs/extractor/src/emotion_namespace_tests/coverage.rs b/libs/extractor/src/emotion_namespace_tests/coverage.rs new file mode 100644 index 000000000..b89991af1 --- /dev/null +++ b/libs/extractor/src/emotion_namespace_tests/coverage.rs @@ -0,0 +1,157 @@ +use crate::{ExtractOption, ImportAlias, emotion_namespace, extract}; +use css::{class_map::reset_class_map, file_map::reset_file_map}; +use rstest::rstest; +use serial_test::serial; +use std::collections::HashMap; + +fn aliases() -> HashMap { + HashMap::from([("@emotion/css".into(), ImportAlias::NamedToNamed)]) +} + +fn normalized(code: &str) -> Result { + emotion_namespace::normalize(code, "namespace.tsx", &aliases()) + .map(|(code, _)| code.into_owned()) + .map_err(|errors| format!("{errors:?}")) +} + +#[test] +fn alias_and_type_reads_compile_when_only_value_calls_need_lowering() -> Result<(), String> { + // Given + let source = r"import * as E from '@emotion/css'; let uninitialized; type Api=typeof E; const key='css', N=E, c=N[key]; type Call=typeof c; c({ padding: 8 });"; + + // When + let output = normalized(source)?; + + // Then + assert!(output.contains("type Api=typeof E"), "{output}"); + assert!(output.contains("type Call=typeof c"), "{output}"); + assert!(output.contains("N={}"), "{output}"); + assert!(output.contains("c=void 0"), "{output}"); + assert!(!output.contains("N[key]"), "{output}"); + Ok(()) +} + +#[rstest] +#[case(r"(E).css({ padding: 8 });")] +#[case(r"(E.css as typeof E.css)({ padding: 8 });")] +#[case(r"(E.css satisfies typeof E.css)({ padding: 8 });")] +#[case(r"E.css!({ padding: 8 });")] +fn direct_calls_compile_when_wrapped_in_syntax_only_expressions( + #[case] body: &str, +) -> Result<(), String> { + // Given + let source = format!("import * as E from '@emotion/css'; {body}"); + + // When + let output = normalized(&source)?; + + // Then + assert!(output.contains("({ padding: 8 })"), "{output}"); + assert!(!output.contains("E.css"), "{output}"); + assert!(output.contains("css as __emotion_css_"), "{output}"); + Ok(()) +} + +#[test] +fn generated_import_avoids_collision_when_initial_name_is_taken() -> Result<(), String> { + // Given: E, __emotion_css_3 and a are the three distinct symbols; no unresolved names. + let source = r"import * as E from '@emotion/css'; export const __emotion_css_3=7; export const a=E.css({ padding: 8 });"; + + // When + let output = normalized(source)?; + + // Then + assert!(output.contains("css as __emotion_css_4"), "{output}"); + assert!( + output.contains(r"a=__emotion_css_4({ padding: 8 })"), + "{output}" + ); + assert!( + output.contains("export const __emotion_css_3=7"), + "{output}" + ); + Ok(()) +} + +#[test] +fn static_string_properties_remain_when_not_namespace_reads() -> Result<(), String> { + // Given + let source = r"import * as E from '@emotion/css'; const key='css'; const length=key.length; E[key]({ padding: 8 });"; + + // When + let output = normalized(source)?; + + // Then + assert!(output.contains("const length=key.length"), "{output}"); + assert!(!output.contains("E[key]"), "{output}"); + Ok(()) +} + +#[rstest] +#[case("E[E]({});", "E[E]")] +#[case("const c=E.css; E[c]({});", "E[c]")] +#[case("const c=E.css; const property=c.length;", "c")] +#[case("E[key]({});", "E[key]")] +#[case("const c=E.css; c=other;", "c")] +#[case("const N=E; N={};", "N")] +#[case("const key='css'; key='cx'; E[key]({});", "E[key]")] +#[case("consume(E);", "E")] +#[case("const [css]=E;", "E")] +#[serial] +fn unsafe_reads_report_locations_when_keys_or_bindings_escape( + #[case] body: &str, + #[case] offending: &str, +) { + // Given + reset_class_map(); + reset_file_map(); + let source = format!("import * as E from '@emotion/css';\n{body}"); + + // When + let error = match extract( + "namespace.tsx", + &source, + ExtractOption { + import_aliases: aliases(), + ..ExtractOption::default() + }, + ) { + Ok(output) => panic!("unexpected success: {}", output.code), + Err(error) => error.to_string(), + }; + + // Then + assert!(error.starts_with("namespace.tsx:2:"), "{error}"); + assert!( + error.contains(&format!("cannot use `{offending}`")), + "{error}" + ); + assert!(!error.contains("__emotion_"), "{error}"); +} + +#[test] +#[serial] +fn original_error_preserves_user_identifiers_when_bracket_macro_is_restored() { + // Given + reset_class_map(); + reset_file_map(); + let source = "import * as E from '@emotion/css';\nconst __emotion_user=external; export const a=E['cx']`a ${__emotion_user}`;"; + + // When + let error = match extract( + "namespace.tsx", + source, + ExtractOption { + import_aliases: aliases(), + ..ExtractOption::default() + }, + ) { + Ok(output) => panic!("unexpected success: {}", output.code), + Err(error) => error.to_string(), + }; + + // Then + assert!(error.contains("namespace.tsx:2:47:"), "{error}"); + assert!(error.contains("E['cx']`a ${__emotion_user}`"), "{error}"); + assert!(!error.contains("__emotion_cx_"), "{error}"); +} diff --git a/libs/extractor/src/emotion_namespace_tests/diagnostics.rs b/libs/extractor/src/emotion_namespace_tests/diagnostics.rs new file mode 100644 index 000000000..38cd1ea87 --- /dev/null +++ b/libs/extractor/src/emotion_namespace_tests/diagnostics.rs @@ -0,0 +1,124 @@ +use super::*; +use rstest::rstest; + +#[test] +#[serial] +fn emotion_restoration_reports_malformed_source_when_normalization_rejects_a_read() { + // Given: normalization can reject a namespace read before the main parser runs. + let source = "import * as E from '@emotion/css';\nE.__emotion_user(); const broken;"; + + // When + let error = compile(source).unwrap_err(); + + // Then + assert!(error.starts_with("namespace.tsx:2:1:"), "{error}"); + assert!(error.contains("E.__emotion_user"), "{error}"); + assert!(error.contains("Emotion diagnostic restoration failed:"), "{error}"); +} + +#[test] +#[serial] +fn emotion_restoration_preserves_generic_runtime_read_errors_when_user_names_match_prefix() { + // Given + let source = "import {css as __emotion_user} from '@devup-ui/react';\nconsume(__emotion_user);"; + + // When + let error = compile(source).unwrap_err(); + + // Then + assert_eq!(error, "namespace.tsx:2:9: `__emotion_user` is read at runtime, where it does not exist: the build compiles it only where it is called or rendered"); +} + +#[rstest] +#[case("E.cx")] +#[case("(E.cx)")] +#[case("(E.cx as typeof E.cx)")] +#[case("(E.cx satisfies typeof E.cx)")] +#[case("E.cx!")] +#[case("(E.merge)")] +#[case("(E.merge as typeof E.merge)")] +#[case("(E.merge satisfies typeof E.merge)")] +#[case("E.merge!")] +#[case("(E.cx)")] +#[serial] +fn emotion_tag_error_restores_written_callee_when_wrapped(#[case] callee: &str) { + // Given + let source = format!("import * as E from '@emotion/css';\nexport const result={callee}`a b`;"); + let api = if callee.contains("merge") { "merge" } else { "cx" }; + + // When + let error = compile(&source).unwrap_err(); + + // Then + assert_eq!(error, format!("namespace.tsx:2:21: `{api}()` cannot use `{callee}`a b`` at build time: call it with class names, as in `cx('a', 'b')`")); +} + +#[rstest] +#[case("a __emotion_user")] +#[case("a __emotion_cx_2")] +#[case("a \\n__emotion_user")] +#[case("${\"__emotion_user\"}")] +#[case("${/* __emotion_user */ unknown}")] +#[case("${__emotion_user}")] +#[case("${__emotion_cx_2}")] +#[serial] +fn emotion_tag_error_preserves_user_text_when_restoring_callee(#[case] text: &str) { + // Given + let source = format!("import * as E from '@emotion/css';\nexport const result=E.cx`{text}`;"); + + // When + let error = compile(&source).unwrap_err(); + + // Then: readable_code removes comments, but restoration must not rewrite text or references. + let shown = text.replace("/* __emotion_user */ ", ""); + assert_eq!(error, format!("namespace.tsx:2:21: `cx()` cannot use `E.cx`{shown}`` at build time: call it with class names, as in `cx('a', 'b')`")); +} + +#[test] +#[serial] +fn emotion_tag_error_preserves_bound_interpolation_when_restoring_callee() { + // Given + let source = "import * as E from '@emotion/css';\nconst __emotion_user = unknown;\nexport const result=E.merge`a ${__emotion_user}`;"; + + // When + let error = compile(source).unwrap_err(); + + // Then + assert_eq!(error, "namespace.tsx:3:21: `merge()` cannot use `E.merge`a ${__emotion_user}`` at build time: call it with class names, as in `cx('a', 'b')`"); +} + +#[rstest] +#[case("(E.cx)", "cx")] +#[case("(E.merge)", "merge")] +#[serial] +fn emotion_tag_error_uses_ts_source_type_when_restoring_assertions( + #[case] callee: &str, + #[case] api: &str, +) { + // Given + let source = format!("import * as E from '@emotion/css';\nexport const result={callee}`a b`;"); + reset_class_map(); + reset_file_map(); + + // When + let error = extract("assertion.ts", &source, ExtractOption { + import_aliases: HashMap::from([("@emotion/css".into(), ImportAlias::NamedToNamed)]), + ..ExtractOption::default() + }).unwrap_err().to_string(); + + // Then + assert_eq!(error, format!("assertion.ts:2:21: `{api}()` cannot use `{callee}`a b`` at build time: call it with class names, as in `cx('a', 'b')`")); +} + +#[test] +fn emotion_restoration_preserves_comments_and_requirements_when_they_name_generated_identifiers() { + // Given + let source = "import * as E from '@emotion/css';\nexport const result=E.cx/* __emotion_cx_2 __emotion_user */`a b`;"; + let message = utils::build_time_error("cx", "__emotion_cx_2/* __emotion_cx_2 __emotion_user */`a b`", "keep __emotion_cx_2 verbatim"); + + // When + let error = emotion_namespace::original_error_code(source, source.find("E.cx").unwrap(), message, SourceType::tsx()); + + // Then + assert_eq!(error, "`cx()` cannot use `E.cx/* __emotion_cx_2 __emotion_user */`a b`` at build time: keep __emotion_cx_2 verbatim"); +} diff --git a/libs/extractor/src/emotion_namespace_tests/exports.rs b/libs/extractor/src/emotion_namespace_tests/exports.rs new file mode 100644 index 000000000..2caca8270 --- /dev/null +++ b/libs/extractor/src/emotion_namespace_tests/exports.rs @@ -0,0 +1,32 @@ +use super::*; + +#[test] +#[serial] +fn local_styling_binding_compiles_when_function_is_exported() { + let code = "import * as E from '@emotion/css'; export function scoped(){const {css:make}=E; return make({padding:8});}"; + let output = compile(code).unwrap(); + assert!(format!("{:?}", output.styles).contains("8px")); + assert!(!output.code.contains("@emotion/css"), "{}", output.code); + assert!(!output.code.contains("make("), "{}", output.code); +} + +#[test] +#[serial] +fn local_styling_binding_compiles_when_class_is_exported() { + let code = "import * as E from '@emotion/css'; export class Scoped{method(){const {css:make}=E; return make({padding:8});}}"; + let output = compile(code).unwrap(); + assert!(format!("{:?}", output.styles).contains("8px")); + assert!(!output.code.contains("@emotion/css"), "{}", output.code); + assert!(!output.code.contains("make("), "{}", output.code); +} + +#[test] +#[serial] +fn styling_binding_errors_with_single_quotes_when_directly_exported() { + let code = "import * as E from '@emotion/css';\nexport const {css}=E;"; + let error = compile(code).unwrap_err(); + assert_eq!( + error, + "namespace.tsx:2:14: `@emotion/css()` cannot use `{css}=E` at build time: namespace and styling function bindings cannot be exported" + ); +} diff --git a/libs/extractor/src/emotion_namespace_tests/loaders.rs b/libs/extractor/src/emotion_namespace_tests/loaders.rs new file mode 100644 index 000000000..0d23c6a9a --- /dev/null +++ b/libs/extractor/src/emotion_namespace_tests/loaders.rs @@ -0,0 +1,162 @@ +use super::*; +use rstest::rstest; + +fn compile_file(filename: &str, code: &str) -> Result { + reset_class_map(); + reset_file_map(); + extract( + filename, + code, + ExtractOption { + import_aliases: HashMap::from([("@emotion/css".into(), ImportAlias::NamedToNamed)]), + ..ExtractOption::default() + }, + ) + .map_err(|error| error.to_string()) +} + +#[rstest] +#[case("a.js", "const r = require; export const a = r('@emotion/css').css({ padding: 8 });")] +#[case("a.cjs", "const load = module.require; const E = load('@emotion/css'); E.css({ padding: 8 });")] +#[case("a.cjs", "const E = module.require('@emotion/css'); E['cx']('a');")] +#[case("a.js", "const name = '@emotion/css'; require(name).keyframes({});")] +#[case("a.js", "const E = await import('@emotion/css'); E.css({ padding: 8 });")] +#[case("a.js", "export const a = (await import('@emotion/css')).css({ padding: 8 });")] +#[case("a.js", "const { css } = await import('@emotion/css'); css({ padding: 8 });")] +#[case( + "a.mjs", + "import { createRequire } from 'node:module'; const E = createRequire(import.meta.url)('@emotion/css'); E.cx('a');" +)] +#[case( + "a.mjs", + "import * as m from 'module'; const req = m.createRequire(import.meta.url); req('@emotion/css').css({ padding: 8 });" +)] +#[case( + "a.mjs", + "import mod from 'module'; const { css } = mod.createRequire(import.meta.url)('@emotion/css'); css({});" +)] +#[case("a.ts", "import E = require('@emotion/css'); E.css({ padding: 8 });")] +#[case("a.jsx", "const E = require('@emotion/css'); export const a = E.merge('a b');")] +#[serial] +fn styling_through_a_runtime_loader_is_a_located_error(#[case] filename: &str, #[case] code: &str) { + // Given: the styling API would stay an Emotion call at runtime + // When + let error = compile_file(filename, code).unwrap_err(); + + // Then + assert!(error.starts_with(&format!("{filename}:1:")), "{error}"); + assert!(error.contains("cannot use"), "{error}"); + assert!(error.contains("require(), import-equals or dynamic import"), "{error}"); +} + +#[rstest] +#[case("import('@emotion/css').then((m) => m.css({ padding: 8 }));", "import('@emotion/css')")] +#[case("const p = import('@emotion/css'); consume(p);", "import('@emotion/css')")] +#[case("export const p = (import('@emotion/css'));", "import('@emotion/css')")] +#[case("const E = await import('@emotion/css'); consume(E);", "E")] +#[serial] +fn escaping_dynamic_imports_are_located_errors(#[case] code: &str, #[case] shown: &str) { + // Given + // When + let error = compile_file("a.js", code).unwrap_err(); + + // Then + assert!(error.starts_with("a.js:1:"), "{error}"); + assert!(error.contains(&format!("cannot use `{shown}")), "{error}"); +} + +#[rstest] +#[case("a.cjs", "const r = require; const E = r('@emotion/css'); E.flush(); console.log(E.cache);")] +#[case("a.js", "const E = await import('@emotion/css'); E.flush(); E?.sheet;")] +#[case("a.js", "const { flush, cache: { key } } = await import('@emotion/css'); flush(key);")] +#[case( + "a.mjs", + "import { createRequire } from 'module'; createRequire(import.meta.url)('@emotion/css').flush();" +)] +#[case("a.cjs", "module.require('@emotion/css').hydrate([]);")] +#[serial] +fn runtime_only_loader_use_is_kept_as_written(#[case] filename: &str, #[case] code: &str) { + // Given + // When + let output = compile_file(filename, code).unwrap(); + + // Then + assert_eq!(output.code, code); +} + +#[rstest] +#[case("a.cjs", "function f(require) { return require('@emotion/css').css({ padding: 8 }); }")] +#[case("a.cjs", "function g(module) { return module.require('@emotion/css').css({}); }")] +#[case("a.js", "const load = (n) => n; export const a = load('@emotion/css').css({});")] +#[case("a.js", "const require = (n) => n; export const a = require('@emotion/css').css({});")] +#[case( + "a.mjs", + "import { createRequire } from './own.js'; export const a = createRequire(1)('@emotion/css').css({});" +)] +#[case( + "a.mjs", + "import { createRequire } from 'module'; function f(createRequire) { return createRequire(1)('@emotion/css').css({}); }" +)] +#[case("a.js", "const name = other; export const a = import(name);")] +#[serial] +fn shadowed_or_foreign_loaders_are_not_rewritten(#[case] filename: &str, #[case] code: &str) { + // Given: the spelling matches but no binding is the module system's + // When + let output = compile_file(filename, code).unwrap(); + + // Then + assert_eq!(output.code, code); +} + +#[rstest] +#[case("a.js", "const E = await import('@emotion/css'); E.css({ padding: 8 });")] +#[case("a.cjs", "const E = module.require('@emotion/css'); E.css({ padding: 8 });")] +#[case("a.ts", "import E = require('@emotion/css'); E.css({ padding: 8 });")] +#[case("a.js", "import('@emotion/css').then((m) => m.css({}));")] +#[serial] +fn disabled_alias_leaves_acquisition_code_untouched(#[case] filename: &str, #[case] code: &str) { + // Given + reset_class_map(); + reset_file_map(); + + // When + let output = extract(filename, code, ExtractOption::default()).unwrap(); + + // Then + assert_eq!(output.code, code); +} + +#[rstest] +#[case("a.js")] +#[case("a.jsx")] +#[case("a.mjs")] +#[case("a.ts")] +#[serial] +fn static_namespace_imports_compile_in_every_file_type(#[case] filename: &str) { + // Given + let code = "import * as E from '@emotion/css'; export const a = E.css({ padding: 8 });"; + + // When + let output = compile_file(filename, code).unwrap(); + + // Then + assert!(format!("{:?}", output.styles).contains("8px"), "{}", output.code); + assert!(!output.code.contains("@emotion/css"), "{}", output.code); +} + +#[test] +#[serial] +fn unitless_values_from_boa_stay_bare_when_namespace_is_normalized() { + // Given: `double(1)` is computed by Boa, so the value is rewritten after the + // namespace pass, and `opacity` takes no unit + let code = "import * as E from '@emotion/css'; function double(n) { return n * 2 } export const a = E.css({ opacity: double(1) });"; + + // When + let output = compile(code).unwrap(); + + // Then + let styles = format!("{:?}", output.styles); + assert!(styles.contains("opacity"), "{styles}"); + assert!(!styles.contains("2px"), "{styles}"); + assert!(!output.code.contains("@emotion/css"), "{}", output.code); +} diff --git a/libs/extractor/src/emotion_namespace_tests/mapping.rs b/libs/extractor/src/emotion_namespace_tests/mapping.rs new file mode 100644 index 000000000..4cb115cee --- /dev/null +++ b/libs/extractor/src/emotion_namespace_tests/mapping.rs @@ -0,0 +1,135 @@ +use super::*; + +#[test] +#[serial] +fn partial_boa_evaluation_keeps_original_error_location() { + let error = compile("import * as E from '@emotion/css';\nfunction double(n){return n*2}\nexport const a=E.css({padding:double(4),color:unknown});").unwrap_err(); + assert!(error.contains("namespace.tsx:3:16:"), "{error}"); + assert!(error.contains("unknown"), "{error}"); + assert!(!error.contains("__emotion_"), "{error}"); +} + +#[test] +#[serial] +fn source_map_preserves_original_source_when_namespace_and_boa_edit_it() { + let code = "import * as E from '@emotion/css';\nfunction double(n){return n*2}\nexport const a=E.css({padding:double(4)});"; + reset_class_map(); + reset_file_map(); + let output = extract_with_source_map( + "namespace.tsx", + code, + ExtractOption { + import_aliases: HashMap::from([("@emotion/css".into(), ImportAlias::NamedToNamed)]), + ..ExtractOption::default() + }, + true, + None, + ) + .unwrap(); + let map: serde_json::Value = serde_json::from_str(output.map.as_deref().unwrap()).unwrap(); + assert_eq!(map["sourcesContent"][0], code); + assert_eq!(map["sources"][0], "namespace.tsx"); +} + +fn written_position(output: &ExtractOutput, needle: &str) -> (u32, u32) { + let map = oxc_sourcemap::SourceMap::from_json_string(output.map.as_deref().unwrap()).unwrap(); + let (line, generated) = output + .code + .lines() + .enumerate() + .find(|(_, line)| line.contains(needle)) + .unwrap(); + let column = generated.find(needle).unwrap(); + let token = map + .get_tokens() + .find(|token| token.get_dst_line() as usize == line && token.get_dst_col() as usize == column) + .unwrap(); + (token.get_src_line(), token.get_src_col()) +} + +#[test] +#[serial] +fn namespace_only_edits_keep_a_map_to_the_original_when_nothing_is_extracted() { + // Given: the import is removed, so every later line moves up + let code = "import * as E from '@emotion/css';\nconst N=E;\nthrow new Error('boom');\nexport const after=1;"; + reset_class_map(); + reset_file_map(); + + // When + let output = compile(code).unwrap(); + + // Then + let map = oxc_sourcemap::SourceMap::from_json_string(output.map.as_deref().unwrap()).unwrap(); + assert_eq!(map.get_source_content(0), Some(code)); + assert!(!output.code.contains("@emotion/css"), "{}", output.code); + assert_eq!(written_position(&output, "N ="), (1, 6)); + assert_eq!(written_position(&output, "throw"), (2, 0)); + assert_eq!(written_position(&output, "after"), (3, 13)); +} + +#[test] +#[serial] +fn longer_replacements_keep_later_positions_when_namespace_stays_for_runtime_reads() { + // Given: the destructured source grows into an object of the kept members + let code = "import * as E from '@emotion/css';\nconst {flush = 1} = E;\nexport const after = flush;"; + reset_class_map(); + reset_file_map(); + + // When + let output = compile(code).unwrap(); + + // Then + assert!(output.code.contains("@emotion/css"), "{}", output.code); + assert_eq!(written_position(&output, "after"), (2, 13)); +} + +#[test] +#[serial] +fn namespace_only_edits_skip_the_map_when_it_is_not_requested() { + // Given + let code = "import * as E from '@emotion/css';\nconst N=E;\nthrow new Error('boom');"; + reset_class_map(); + reset_file_map(); + + // When + let output = extract_without_source_map( + "namespace.tsx", + code, + ExtractOption { + import_aliases: HashMap::from([("@emotion/css".into(), ImportAlias::NamedToNamed)]), + ..ExtractOption::default() + }, + ) + .unwrap(); + + // Then + assert_eq!(output.map, None); + assert!(!output.code.contains("@emotion/css"), "{}", output.code); +} + +#[test] +#[serial] +fn error_text_restores_generated_names_when_only_comments_and_strings_mention_them() { + for source in [ + "// __emotion_cx_2\nexport const a=E.cx`a b`;", + "const t='__emotion_cx_3';\nexport const a=E.cx`a b`;", + ] { + // Given: no binding or reference of the program has the generated name + // When + let error = compile(&format!("import * as E from '@emotion/css';\n{source}")).unwrap_err(); + + // Then + assert!(error.contains("E.cx`a b`"), "{error}"); + assert!(!error.contains("__emotion_cx_"), "{error}"); + } +} + +#[test] +#[serial] +fn disabled_emotion_alias_preserves_calls_when_other_styles_are_evaluated() { + reset_class_map(); + reset_file_map(); + let output = extract("namespace.tsx", "import {css as devup} from '@devup-ui/react'; import {css as emotion} from '@emotion/css'; function double(n){return n*2} export const a=devup({padding:double(4)}); export const b=emotion({padding:8});", ExtractOption::default()).unwrap(); + assert!(output.code.contains("padding: 8"), "{}", output.code); + assert!(output.code.contains("emotion({"), "{}", output.code); +} diff --git a/libs/extractor/src/emotion_namespace_tests/order.rs b/libs/extractor/src/emotion_namespace_tests/order.rs new file mode 100644 index 000000000..8754052a9 --- /dev/null +++ b/libs/extractor/src/emotion_namespace_tests/order.rs @@ -0,0 +1,108 @@ +use super::*; +use rstest::rstest; + +const IMPORT: &str = "import * as E from '@emotion/css';\n"; + +#[rstest] +#[case( + "let result; try {result=c({ padding: 8 })}catch{result='tdz'} const c=E.css; export const a=result;", + "c" +)] +#[case("const N=A; const A=E; export const a=N.css({ padding: 8 });", "A")] +#[case( + "const early=()=>c({ padding: 8 }); const c=E.css; export const a=early();", + "c" +)] +#[case( + "export const a=make(); const { css }=E; function make(){return css({ padding: 8 })}", + "css" +)] +#[case( + "switch(1){case 1: const c=E.css; break; case 2: c({ padding: 8 })}", + "c" +)] +#[case("export const a=E[key]({ padding: 8 }); const key='css';", "key")] +#[case( + "const c=E[key]; const key='css'; export const a=c({ padding: 8 });", + "key" +)] +#[case( + "const {[key]: c}=E; const key='css'; export const a=c({ padding: 8 });", + "key" +)] +#[serial] +fn reads_before_initialization_report_the_alias_when_elimination_would_hide_tdz( + #[case] body: &str, + #[case] alias: &str, +) { + // Given: the original code reads the alias while it is still uninitialized + // When + let error = compile(&format!("{IMPORT}{body}")).unwrap_err(); + + // Then + assert!(error.starts_with("namespace.tsx:2:"), "{error}"); + assert!(error.contains(&format!("cannot use `{alias}`")), "{error}"); + assert!(error.contains("must be initialized"), "{error}"); +} + +#[rstest] +#[case("const c=E.css; export const a=c({ padding: 8 });")] +#[case("const N=E, A=N; const { css }=A; export const a=css({ padding: 8 });")] +#[case("const { css }=E; export const make=()=>css({ padding: 8 });")] +#[case("const { css }=E; export function make(){return css({ padding: 8 })} export const a=make();")] +#[case("function make(){return css({ padding: 8 })} const { css }=E; export const a=make();")] +#[case("function outer(){const c=E.css; return ()=>c({ padding: 8 })} export const a=outer()();")] +#[case("const { css }=E; function f(n){return n ? f(n-1) : css({ padding: 8 })} export const a=f(1);")] +#[case("const { css }=E; export default function(){return css({ padding: 8 })}")] +#[case("const { css }=E; export class K{ m(){return css({ padding: 8 })} }")] +#[case("switch(1){case 1: const c=E.css; console.log(c({ padding: 8 }));}")] +#[serial] +fn ordered_reads_compile_when_aliases_are_initialized_first(#[case] body: &str) { + // Given + let code = format!("{IMPORT}{body}"); + + // When + let output = compile(&code).unwrap(); + + // Then + assert!(format!("{:?}", output.styles).contains("8px"), "{}", output.code); + assert!(!output.code.contains("@emotion/css"), "{}", output.code); +} + +#[test] +#[serial] +fn eliminated_bindings_survive_a_shadowed_undefined() { + for body in [ + "const c=E.css; const undefined=1; export const a=c({ padding: 8 });", + "const {css: c}=E; const undefined=1; export const a=c({ padding: 8 });", + ] { + // Given: `undefined` is a user binding here + // When + let output = compile(&format!("{IMPORT}{body}")).unwrap(); + + // Then + assert!(output.code.contains("void 0"), "{}", output.code); + assert!(!output.code.contains("c = undefined"), "{}", output.code); + } +} + +#[rstest] +#[case("const c=(E.css);")] +#[case("const c=E.css as typeof E.css;")] +#[case("const c=E.css satisfies typeof E.css;")] +#[case("const c=E.css!;")] +#[case("const c=(E.css as typeof E.css)!;")] +#[case("const c=E.css;")] +#[case("const N=(E) as typeof E, c=N.css;")] +#[serial] +fn aliases_compile_when_wrapped_in_syntax_only_expressions(#[case] alias: &str) { + // Given + let code = format!("{IMPORT}{alias} export const a=c({{padding:8}});"); + + // When + let output = compile(&code).unwrap(); + + // Then + assert!(format!("{:?}", output.styles).contains("8px"), "{}", output.code); + assert!(!output.code.contains("@emotion/css"), "{}", output.code); +} diff --git a/libs/extractor/src/import_alias_visit.rs b/libs/extractor/src/import_alias_visit.rs index 158d0f420..e6985195c 100644 --- a/libs/extractor/src/import_alias_visit.rs +++ b/libs/extractor/src/import_alias_visit.rs @@ -204,7 +204,12 @@ fn devup_equivalent(source: &str, imported: &str) -> Option // `style({...})` and `css({...})` both hand back a class name for a style object, // and `globalStyle(selector, rules)` is `globalCss` with the selector split out. ("@vanilla-extract/css", "style") | (_, "css") => Some(DevupTarget::Main("css")), - ("@vanilla-extract/css", "globalStyle") => Some(DevupTarget::Main("globalCss")), + // `cx` and `merge` take class names rather than styles, so they are no `css` + ("@emotion/css", "cx") => Some(DevupTarget::Compat("cx")), + ("@emotion/css", "merge") => Some(DevupTarget::Compat("merge")), + ("@vanilla-extract/css", "globalStyle") | ("@emotion/css", "injectGlobal") => { + Some(DevupTarget::Main("globalCss")) + } (_, "keyframes") => Some(DevupTarget::Main("keyframes")), (_, "styled") => Some(DevupTarget::Main("styled")), (_, "createGlobalStyle") => Some(DevupTarget::Compat("createGlobalStyle")), @@ -282,7 +287,7 @@ pub fn transform_import_aliases_with_edits<'a>( match specifier { ImportDeclarationSpecifier::ImportSpecifier(spec) => { let local = spec.local.name.as_str(); - match (source_value, imported_name(&spec.imported).as_ref()) { + match (source_value, imported_name(&spec.imported)) { ("@vanilla-extract/css", "style" | "keyframes") => { numbers.calls.push((local, RulesAt::Argument(0))); } @@ -292,7 +297,10 @@ pub fn transform_import_aliases_with_edits<'a>( ( "@emotion/react" | "styled-components", "css" | "keyframes", - ) => numbers.calls.push((local, RulesAt::EveryArgument)), + ) + | ("@emotion/css", "css" | "keyframes" | "injectGlobal") => { + numbers.calls.push((local, RulesAt::EveryArgument)); + } ("@emotion/react", "Global") => numbers.components.push(local), _ => {} } @@ -343,6 +351,54 @@ pub fn transform_import_aliases_with_edits<'a>( (Cow::Owned(result), edits) } +/// Reapply Emotion units to literals produced after the initial alias pass. +pub(super) fn pixelify_emotion_values<'a>( + code: &'a str, + filename: &str, + original: &str, +) -> (Cow<'a, str>, Vec) { + let allocator = Allocator::default(); + let source_type = SourceType::from_path(filename).unwrap_or_default(); + let original_program = Parser::new(&allocator, original, source_type) + .parse() + .program; + let mut numbers = LibraryNumbers::default(); + for statement in &original_program.body { + if let Statement::ImportDeclaration(import) = statement + && import.source.value == "@emotion/css" + { + for specifier in import.specifiers.iter().flatten() { + if let ImportDeclarationSpecifier::ImportSpecifier(named) = specifier + && matches!( + named.imported.name().as_str(), + "css" | "keyframes" | "injectGlobal" + ) + { + numbers + .calls + .push((named.local.name.as_str(), RulesAt::EveryArgument)); + } + } + } + } + let program = Parser::new(&allocator, code, source_type).parse().program; + numbers.visit_program(&program); + numbers.replacements.sort_by_key(|(start, ..)| *start); + let edits = numbers + .replacements + .iter() + .map(|(start, end, replacement)| (*start, *end, replacement.len())) + .collect(); + if numbers.replacements.is_empty() { + return (Cow::Borrowed(code), edits); + } + let mut output = code.to_string(); + for (start, end, replacement) in numbers.replacements.into_iter().rev() { + output.replace_range(start..end, &replacement); + } + (Cow::Owned(output), edits) +} + /// The source offset of `offset` in code `edits` made; an offset inside a /// replacement maps to where the replaced text began #[must_use] @@ -407,14 +463,25 @@ fn push_specifier(parts: &mut String, imported: &str, local: &str) { } } -/// Borrow the exported name for the common identifier cases to avoid a per-specifier -/// heap allocation. Only the rare string-literal export name (`import { "x" as y }`) -/// needs an owned `String`, and its `Display` output is quoted. -fn imported_name<'a>(imported: &'a ModuleExportName) -> Cow<'a, str> { +/// The logical exported name: `import { 'css' as c }` and `import { css as c }` both +/// export `css`, so the mapping tables match on this. +fn imported_name<'a>(imported: &'a ModuleExportName) -> &'a str { + match imported { + ModuleExportName::IdentifierName(id) => id.name.as_str(), + ModuleExportName::IdentifierReference(id) => id.name.as_str(), + ModuleExportName::StringLiteral(literal) => literal.value.as_str(), + } +} + +/// The exported name as it must be written back into an import clause; only the +/// rare string-literal export name (`import { "x" as y }`) needs an owned, quoted +/// `String`. +fn exported_text<'a>(imported: &'a ModuleExportName) -> Cow<'a, str> { match imported { - ModuleExportName::IdentifierName(id) => Cow::Borrowed(id.name.as_str()), - ModuleExportName::IdentifierReference(id) => Cow::Borrowed(id.name.as_str()), ModuleExportName::StringLiteral(_) => Cow::Owned(imported.to_string()), + ModuleExportName::IdentifierName(_) | ModuleExportName::IdentifierReference(_) => { + Cow::Borrowed(imported_name(imported)) + } } } @@ -509,10 +576,16 @@ fn generate_transformed_import( for specifier in specifiers { if let ImportDeclarationSpecifier::ImportSpecifier(spec) = specifier { let local = spec.local.name.as_str(); - let imported = imported_name(&spec.imported); - match redirect_target(source, &imported, redirect_every_name) { + let written = exported_text(&spec.imported); + // A stylesheet passes every export through as written + let name = if redirect_every_name { + written.as_ref() + } else { + imported_name(&spec.imported) + }; + match redirect_target(source, name, redirect_every_name) { Some(target) => push_redirect(&mut redirected, &mut compat, target, local), - None => push_specifier(&mut retained, &imported, local), + None => push_specifier(&mut retained, &written, local), } } } @@ -728,6 +801,30 @@ mod tests { )); } + #[test] + fn test_emotion_css_maps_onto_devup_equivalents() { + let code = transform_import_aliases( + "import { css, cx, keyframes, injectGlobal, merge, flush } from '@emotion/css'", + "test.tsx", + "@devup-ui/react", + &HashMap::from([("@emotion/css".to_string(), ImportAlias::NamedToNamed)]), + ); + assert!( + code.contains( + "import { css, keyframes, globalCss as injectGlobal } from '@devup-ui/react';" + ), + "{code}" + ); + assert!( + code.contains("import { cx, merge } from '@devup-ui/react/compat';"), + "{code}" + ); + assert!( + code.contains("import { flush } from '@emotion/css';"), + "{code}" + ); + } + #[test] fn test_no_matching_alias() { assert_snapshot!(transform_import_aliases( diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index a5853d5b5..396d372a6 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -1,7 +1,9 @@ mod as_visit; mod build_time_values; +mod class_arguments; mod component; mod css_utils; +mod emotion_namespace; pub mod extract_style; mod extractor; mod gen_class_name; @@ -294,11 +296,16 @@ fn extract_source( source_map: bool, resolver: Option<&ModuleResolver>, ) -> Result> { + let (namespace_code, namespace_edits) = + emotion_namespace::normalize(code, filename, &option.import_aliases).map_err(|errors| { + let (source, edits) = evaluated.unwrap_or((code, &[])); + located_errors(filename, source, edits, errors) + })?; // Step 1: Transform import aliases // e.g., `import styled from '@emotion/styled'` → `import { styled } from '@devup-ui/react'` // e.g., `import { style } from '@vanilla-extract/css'` → `import { style } from '@devup-ui/react'` let (transformed_code, alias_edits) = import_alias_visit::transform_import_aliases_with_edits( - code, + &namespace_code, filename, &option.package, &option.import_aliases, @@ -310,13 +317,28 @@ fn extract_source( if !has_relevant_import { // skip if not using package - return Ok(ExtractOutput { - styles: FxHashSet::default(), - code: code.to_string(), - map: None, - css_file: None, - dependencies: Vec::new(), - }); + if namespace_edits.is_empty() { + return Ok(ExtractOutput { + styles: FxHashSet::default(), + code: namespace_code.into_owned(), + map: None, + css_file: None, + dependencies: Vec::new(), + }); + } + let (source, earlier_edits) = evaluated.unwrap_or((code, &[])); + let edits: Vec<&[import_alias_visit::Edit]> = std::iter::once(namespace_edits.as_slice()) + .chain(earlier_edits.iter().copied()) + .collect(); + let mut output = emotion_namespace::Rewritten { + filename, + code: &namespace_code, + source, + edits: &edits, + } + .output(); + output.map = output.map.filter(|_| source_map); + return Ok(output); } let mut dependencies = std::collections::BTreeSet::new(); @@ -429,6 +451,9 @@ fn extract_source( imported_constants::Inlined::default() }; dependencies.extend(inlined.dependencies); + if !namespace_edits.is_empty() { + emotion_namespace::units::pixelify(&allocator, &mut program, &namespace_code); + } let mut visitor = DevupVisitor::new( &allocator, filename, @@ -458,10 +483,23 @@ fn extract_source( &inlined.unknown, ) { + let (computed, number_edits) = if namespace_edits.is_empty() { + (std::borrow::Cow::Borrowed(computed.as_str()), Vec::new()) + } else { + import_alias_visit::pixelify_emotion_values(&computed, filename, &namespace_code) + }; let mut output = extract_source( filename, &computed, - Some((code, &[value_edits.as_slice(), alias_edits.as_slice()])), + Some(( + code, + &[ + number_edits.as_slice(), + value_edits.as_slice(), + alias_edits.as_slice(), + namespace_edits.as_slice(), + ], + )), option, source_map, resolver, @@ -473,9 +511,11 @@ fn extract_source( return Ok(output); } let (source, earlier_edits) = evaluated.unwrap_or((code, &[])); - let edits: Vec<&[import_alias_visit::Edit]> = std::iter::once(alias_edits.as_slice()) - .chain(earlier_edits.iter().copied()) - .collect(); + let edits: Vec<&[import_alias_visit::Edit]> = + [alias_edits.as_slice(), namespace_edits.as_slice()] + .into_iter() + .chain(earlier_edits.iter().copied()) + .collect(); visitor.errors.append(&mut visitor.unknown_parts); if !visitor.errors.is_empty() { let mut message = located_errors(filename, source, &edits, visitor.errors); @@ -560,6 +600,12 @@ fn located_errors( let offset = edits.iter().fold(offset as usize, |offset, edits| { import_alias_visit::source_offset(edits, offset) }); + let message = emotion_namespace::original_error_code( + source, + offset, + message, + SourceType::from_path(filename).unwrap_or_default(), + ); format!("{}: {message}", locate(filename, source, offset)) }) .collect::>() @@ -737,6 +783,9 @@ pub fn has_devup_ui(filename: &str, code: &str, package: &str) -> bool { #[cfg(test)] #[allow(clippy::expect_used, clippy::unwrap_used)] mod tests { + mod emotion_namespace_tests { + include!("emotion_namespace_tests.rs"); + } use std::collections::BTreeSet; use super::*; @@ -20836,4 +20885,170 @@ export const App = () => Result { + reset_class_map(); + reset_file_map(); + extract( + "test.tsx", + &format!("import {{ css }} from '@devup-ui/react';\nimport {{ cx, merge }} from '@devup-ui/react/compat';\n{code}"), + ExtractOption::default(), + ) + .map(|output| output.code) + .map_err(|error| error.to_string()) + } + + #[test] + #[serial] + fn test_emotion_cx_takes_class_names() { + assert_debug_snapshot!(emotion_class_call( + r"const red = css({ color: 'red' }); +export const a = cx('external'); +export const b = cx(red, 'external', { picked: true }); +export const c = cx(red, false && 'x'); +export const d = (on, k, cls, props) => cx(cls, { on, [k]: on, 'a b': props.x, off: false, 1: on, [`t`]: on }, [on && 'x', [cls]], on ? { y: 1 } : null, on || cls, cls ?? 'z', 0, 5, null, undefined, `t ${cls} u`, `glued${cls}`, props.make(), (on as boolean) && 'w', false || 'v'); +export const e = cx(); +export const f = cx([, 'a'], [' b c ']); +export const g = (on) => cx(on ? { a: on } : [on && 'b'], on ? (on ? 'c' : 'd') : 'e'); +export const h = (on) => cx({ [on.key]: on.value }); +export const i = () => cx(red, `${red} x`); +export const j = cx({ a: null, b: 'x', c: '', d: 0, e: undefined, f: 2, g: false, h: true });" + )); + } + + #[test] + #[serial] + fn test_emotion_merge_splits_what_it_can() { + assert_debug_snapshot!(emotion_class_call( + r"const red = css({ color: 'red' }); +export const a = (cls) => merge(cls); +export const b = (cls) => merge(`${red} external ${cls}`); +export const c = merge('a b'); +export const d = (on, cls) => merge(on ? 'a b' : cls); +export const e = (cls) => merge(cls || 'x'); +export const f = (make) => merge(make()); +export const g = (cls) => merge(`glued${cls}`); +export const h = (cls) => merge(cls.names); +export const i = merge(red); +export const j = (cls) => merge({ a: cls }); +export const k = (cls) => merge([cls]);" + )); + } + + #[test] + #[serial] + fn test_emotion_cx_and_merge_report_what_they_cannot_compile() { + for (code, error) in [ + ( + "export const a = (list) => cx(...list);", + "test.tsx:3:31: `cx()` cannot use `...list` at build time", + ), + ( + "export const a = (list) => cx([...list]);", + "`cx()` cannot use `...list` at build time", + ), + ( + "export const a = (rest) => cx({ ...rest });", + "`cx()` cannot use `...rest` at build time", + ), + ( + "export const a = () => cx({ get b() { return true; } });", + "`cx()` cannot use `get b()` at build time: a class map entry must be written `name: condition`", + ), + ( + "export const a = () => cx({ b() { return true; } });", + "`cx()` cannot use `b()` at build time: a class map entry must be written `name: condition`", + ), + ( + "export const a = () => cx({ set b(v) {} });", + "`cx()` cannot use `set b()` at build time", + ), + ( + "export const a = () => cx`a b`;", + "`cx()` cannot use `cx`a b`` at build time: call it with class names", + ), + ( + "export const a = () => merge`a b`;", + "`merge()` cannot use `merge`a b`` at build time: call it with class names", + ), + ( + "export const a = () => merge();", + "`merge()` cannot use `` at build time: it takes one class string", + ), + ( + "export const a = (x, y) => merge(x, y);", + "`merge()` cannot use `x, y` at build time: it takes one class string", + ), + ( + "export const a = (list) => merge(...list);", + "`merge()` cannot use `...list` at build time", + ), + ] { + let message = emotion_class_call(code).err().unwrap_or_default(); + assert!(message.contains(error), "{code}\n{message}"); + } + } + + #[test] + #[serial] + fn test_emotion_class_calls_follow_the_package_namespace_and_aliases() { + reset_class_map(); + reset_file_map(); + let output = extract( + "test.tsx", + "import * as compat from '@devup-ui/react/compat'; +import { cx as classNames, merge as joined } from '@emotion/css'; +export const a = compat.cx('a', { b: true }); +export const b = compat.merge('c d'); +export const c = classNames('e'); +export const d = joined('f'); +export const e = classNames({ g: 1 }, { h: 8 });", + ExtractOption { + package: "@devup-ui/react".to_string(), + css_dir: "@devup-ui/react".to_string(), + single_css: true, + import_main_css: false, + import_aliases: HashMap::from([( + "@emotion/css".to_string(), + ImportAlias::NamedToNamed, + )]), + }, + ) + .unwrap(); + assert_debug_snapshot!(output.code); + } } diff --git a/libs/extractor/src/snapshots/extractor__tests__emotion_class_calls_follow_the_package_namespace_and_aliases.snap b/libs/extractor/src/snapshots/extractor__tests__emotion_class_calls_follow_the_package_namespace_and_aliases.snap new file mode 100644 index 000000000..a280240ea --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__emotion_class_calls_follow_the_package_namespace_and_aliases.snap @@ -0,0 +1,5 @@ +--- +source: libs/extractor/src/lib.rs +expression: output.code +--- +"import * as compat from \"@devup-ui/react/compat\";\nexport const a = \"a b\";\nexport const b = \"c d\";\nexport const c = \"e\";\nexport const d = \"f\";\nexport const e = \"g h\";\n" diff --git a/libs/extractor/src/snapshots/extractor__tests__emotion_cx_takes_class_names.snap b/libs/extractor/src/snapshots/extractor__tests__emotion_cx_takes_class_names.snap new file mode 100644 index 000000000..59ef02f89 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__emotion_cx_takes_class_names.snap @@ -0,0 +1,7 @@ +--- +source: libs/extractor/src/lib.rs +expression: "emotion_class_call(r\"const red = css({ color: 'red' });\nexport const a = cx('external');\nexport const b = cx(red, 'external', { picked: true });\nexport const c = cx(red, false && 'x');\nexport const d = (on, k, cls, props) => cx(cls, { on, [k]: on, 'a b': props.x, off: false, 1: on, [`t`]: on }, [on && 'x', [cls]], on ? { y: 1 } : null, on || cls, cls ?? 'z', 0, 5, null, undefined, `t ${cls} u`, `glued${cls}`, props.make(), (on as boolean) && 'w', false || 'v');\nexport const e = cx();\nexport const f = cx([, 'a'], [' b c ']);\nexport const g = (on) => cx(on ? { a: on } : [on && 'b'], on ? (on ? 'c' : 'd') : 'e');\nexport const h = (on) => cx({ [on.key]: on.value });\nexport const i = () => cx(red, `${red} x`);\nexport const j = cx({ a: null, b: 'x', c: '', d: 0, e: undefined, f: 2, g: false, h: true });\")" +--- +Ok( + "import \"@devup-ui/react/devup-ui-0.css\";\nconst red = \"a-a\";\nexport const a = \"external\";\nexport const b = \"a-a external picked\";\nexport const c = \"a-a\";\nexport const d = (on, k, cls, props) => `5 t u v ${cls || \"\"} ${on ? \"on\" : \"\"} ${on ? `${k}` : \"\"} ${props.x ? \"a b\" : \"\"} ${on ? \"1\" : \"\"} ${on ? \"t\" : \"\"} ${on ? \"x\" : \"\"} ${cls || \"\"} ${on ? \"y\" : \"\"} ${on || `${cls || \"\"}` || \"\"} ${(cls ?? \"z\") || \"\"} ${cls || \"\"} glued${cls} ${props.make() || \"\"} ${(on as boolean) ? \"w\" : \"\"}`;\nexport const e = \"\";\nexport const f = \"a b c\";\nexport const g = (on) => `${on ? `${on ? \"a\" : \"\"}` : `${on ? \"b\" : \"\"}`} ${on ? `${on ? \"c\" : \"d\"}` : \"e\"}`;\nexport const h = (on) => on.value ? `${on.key}` : \"\";\nexport const i = () => \"a-a a-a x\";\nexport const j = \"b f h\";\n", +) diff --git a/libs/extractor/src/snapshots/extractor__tests__emotion_merge_splits_what_it_can.snap b/libs/extractor/src/snapshots/extractor__tests__emotion_merge_splits_what_it_can.snap new file mode 100644 index 000000000..6e9ca92d1 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__emotion_merge_splits_what_it_can.snap @@ -0,0 +1,7 @@ +--- +source: libs/extractor/src/lib.rs +expression: "emotion_class_call(r\"const red = css({ color: 'red' });\nexport const a = (cls) => merge(cls);\nexport const b = (cls) => merge(`${red} external ${cls}`);\nexport const c = merge('a b');\nexport const d = (on, cls) => merge(on ? 'a b' : cls);\nexport const e = (cls) => merge(cls || 'x');\nexport const f = (make) => merge(make());\nexport const g = (cls) => merge(`glued${cls}`);\nexport const h = (cls) => merge(cls.names);\nexport const i = merge(red);\nexport const j = (cls) => merge({ a: cls });\nexport const k = (cls) => merge([cls]);\")" +--- +Ok( + "import \"@devup-ui/react/devup-ui-0.css\";\nconst red = \"a-a\";\nexport const a = (cls) => cls;\nexport const b = (cls) => `a-a external ${cls}`;\nexport const c = \"a b\";\nexport const d = (on, cls) => on ? \"a b\" : `${cls}`;\nexport const e = (cls) => `${cls || \"x\"}`;\nexport const f = (make) => `${make()}`;\nexport const g = (cls) => `glued${cls}`;\nexport const h = (cls) => cls.names;\nexport const i = \"a-a\";\nexport const j = (cls) => `${{ a: cls }}`;\nexport const k = (cls) => `${[cls]}`;\n", +) diff --git a/libs/extractor/src/style_values.rs b/libs/extractor/src/style_values.rs index 77dc8f569..946c8a93f 100644 --- a/libs/extractor/src/style_values.rs +++ b/libs/extractor/src/style_values.rs @@ -50,26 +50,34 @@ impl StyleValues { /// `expression` reading what the bindings recorded hold pub fn read_in<'a>(&self, ast: &AstBuilder<'a>, expression: &mut Expression<'a>) { - if let Some(mut reads) = self.reads(ast) { + if let Some(mut reads) = self.reads(ast, false) { + reads.visit_expression(expression); + } + } + + /// `expression` as class names, which read a class in a template literal + /// too: there it is a string to join, not CSS text + pub fn read_in_class_names<'a>(&self, ast: &AstBuilder<'a>, expression: &mut Expression<'a>) { + if let Some(mut reads) = self.reads(ast, true) { reads.visit_expression(expression); } } /// The CSS text `template` reading what the bindings recorded hold pub fn read_in_text<'a>(&self, ast: &AstBuilder<'a>, template: &mut TemplateLiteral<'a>) { - if let Some(mut reads) = self.reads(ast) { + if let Some(mut reads) = self.reads(ast, false) { reads.visit_template_literal(template); } } - fn reads<'s, 'a>(&'s self, ast: &'s AstBuilder<'a>) -> Option> { + fn reads<'s, 'a>(&'s self, ast: &'s AstBuilder<'a>, in_rules: bool) -> Option> { let scoping = self.scoping.as_ref()?; (!self.values.is_empty()).then_some(Reads { ast, scoping, values: &self.values, in_text: false, - in_rules: false, + in_rules, }) } } diff --git a/libs/extractor/src/util_type.rs b/libs/extractor/src/util_type.rs index 0ae752f95..6f3854b64 100644 --- a/libs/extractor/src/util_type.rs +++ b/libs/extractor/src/util_type.rs @@ -1,6 +1,12 @@ +use crate::class_arguments::ClassCall; + #[derive(Debug, PartialEq, Eq)] pub enum UtilType { Css, + /// Emotion's `cx`: class names composed like `css()` parts + Cx, + /// Emotion's `merge`: the classes of a class string, composed like `cx` + Merge, GlobalCss, /// `createGlobalStyle` — global CSS that callers render as a component /// (``), so the call must collapse to a component rather than @@ -14,6 +20,8 @@ impl UtilType { pub fn from_str_opt(value: &str) -> Option { match value { "css" => Some(UtilType::Css), + "cx" => Some(UtilType::Cx), + "merge" => Some(UtilType::Merge), "globalCss" => Some(UtilType::GlobalCss), "createGlobalStyle" => Some(UtilType::GlobalCssComponent), "keyframes" => Some(UtilType::Keyframes), @@ -21,6 +29,34 @@ impl UtilType { } } + /// Whether the call gives a class name composed as `css()` composes its parts + #[must_use] + pub const fn is_css(&self) -> bool { + matches!(self, UtilType::Css | UtilType::Cx | UtilType::Merge) + } + + /// The Emotion class function the call is, if it is one + #[must_use] + pub const fn class_call(&self) -> Option { + match self { + UtilType::Cx => Some(ClassCall::Cx), + UtilType::Merge => Some(ClassCall::Merge), + _ => None, + } + } + + /// The name the call is reported under + #[must_use] + pub const fn api(&self) -> &'static str { + match self { + UtilType::Css => "css", + UtilType::Cx => "cx", + UtilType::Merge => "merge", + UtilType::Keyframes => "keyframes", + UtilType::GlobalCss | UtilType::GlobalCssComponent => "globalCss", + } + } + #[must_use] pub const fn is_component(&self) -> bool { matches!(self, UtilType::GlobalCssComponent) diff --git a/libs/extractor/src/visit.rs b/libs/extractor/src/visit.rs index 93846123e..06c9fba96 100644 --- a/libs/extractor/src/visit.rs +++ b/libs/extractor/src/visit.rs @@ -1,4 +1,5 @@ use crate::as_visit::As; +use crate::class_arguments::{class_arguments, flatten_classes}; use crate::component::ExportVariableKind; use crate::css_utils::{ TemplateStyles, css_to_style_template, keyframes_to_keyframes_style, optimize_css_block, @@ -376,6 +377,8 @@ impl<'a> DevupVisitor<'a> { } for (name, kind) in [ ("css", UtilType::Css), + ("cx", UtilType::Cx), + ("merge", UtilType::Merge), ("globalCss", UtilType::GlobalCss), ("keyframes", UtilType::Keyframes), ("createGlobalStyle", UtilType::GlobalCssComponent), @@ -1609,13 +1612,28 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { Argument::SpreadElement(spread) => &mut spread.argument, argument => argument.to_expression_mut(), }; - self.style_values.read_in(&self.ast, expression); + if util_type.class_call().is_some() { + self.style_values.read_in_class_names(&self.ast, expression); + } else { + self.style_values.read_in(&self.ast, expression); + } } let offset = call.span.start; - let is_css = matches!(util_type.as_ref(), UtilType::Css); + let is_css = util_type.is_css(); if is_css { - self.unknown_arguments("css", &call.arguments); - self.changed_arguments("css", &call.arguments); + self.unknown_arguments(util_type.api(), &call.arguments); + self.changed_arguments(util_type.api(), &call.arguments); + } + if let Some(class_call) = util_type.class_call() { + let classes = class_arguments( + &self.ast, + class_call, + offset, + &call.arguments, + &mut self.errors, + ); + call.arguments = + oxc_allocator::Vec::from_array_in([Argument::from(classes)], &self.ast); } let composed_classes = if is_css && let Some(StyleArguments { classes, rules }) = @@ -1634,7 +1652,7 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { }; if call.arguments.len() == 1 { let r = util_type.as_ref(); - *it = if matches!(r, UtilType::Css) { + *it = if r.is_css() { let ExtractResult { mut styles, style_order, @@ -1653,7 +1671,7 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { ); if let Some(value) = runtime_value(&styles) { self.errors - .push((offset, runtime_value_error("css", &value))); + .push((offset, runtime_value_error(r.api(), &value))); } if styles.is_empty() { @@ -1791,7 +1809,7 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { *it = self.global_css_result(util_type.is_component()); } else { *it = match util_type.as_ref() { - UtilType::Css | UtilType::Keyframes => { + UtilType::Css | UtilType::Cx | UtilType::Merge | UtilType::Keyframes => { Expression::new_string_literal(SPAN, "", None, &self.ast) } global => self.global_css_result(global.is_component()), @@ -1808,17 +1826,33 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { ) .unwrap_or_else(|| Expression::new_string_literal(SPAN, "", None, &self.ast)); } + if util_type.class_call().is_some() { + let classes = std::mem::replace( + it, + Expression::new_string_literal(SPAN, "", None, &self.ast), + ); + *it = flatten_classes(&self.ast, classes); + } } + } else if let Expression::TaggedTemplateExpression(tag) = it + && let Some(class_type) = self.util_type(&tag.tag) + && class_type.class_call().is_some() + { + self.errors.push(( + tag.span.start, + build_time_error( + class_type.api(), + &readable_code(it), + "call it with class names, as in `cx('a', 'b')`", + ), + )); + *it = Expression::new_string_literal(SPAN, "", None, &self.ast); } else if let Expression::TaggedTemplateExpression(tag) = it && let Some(css_type) = self.util_type(&tag.tag) { self.style_values.read_in_text(&self.ast, &mut tag.quasi); let r = css_type.as_ref(); - let api = match r { - UtilType::Css => "css", - UtilType::Keyframes => "keyframes", - UtilType::GlobalCss | UtilType::GlobalCssComponent => "globalCss", - }; + let api = r.api(); let mut build_css_str = || { template_css_text(&tag.quasi, api).unwrap_or_else(|error| { self.errors.push(error); @@ -2160,7 +2194,7 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { Some(Expression::TaggedTemplateExpression(tag)) => self.util_type(&tag.tag), _ => None, } - .filter(|util| matches!(util.as_ref(), UtilType::Css | UtilType::Keyframes)) + .filter(|util| util.is_css() || matches!(util.as_ref(), UtilType::Keyframes)) .and_then(|util| Some((util, self.style_values.constant(&it.id)?))); walk_variable_declarator(self, it); @@ -2171,7 +2205,7 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { let value = value.value.to_string(); self.style_values.insert( symbol, - if matches!(util.as_ref(), UtilType::Css) { + if util.is_css() { crate::style_values::StyleValue::Class(value) } else { crate::style_values::StyleValue::Keyframes(value) diff --git a/packages/next-plugin/src/__tests__/plugin.test.ts b/packages/next-plugin/src/__tests__/plugin.test.ts index 8bc599b7b..9cf7d1095 100644 --- a/packages/next-plugin/src/__tests__/plugin.test.ts +++ b/packages/next-plugin/src/__tests__/plugin.test.ts @@ -399,6 +399,7 @@ describe('DevupUINextPlugin', () => { importAliases: { '@emotion/react': null, '@emotion/styled': 'styled', + '@emotion/css': null, '@vanilla-extract/css': null, 'styled-components': 'styled', }, @@ -490,6 +491,7 @@ describe('DevupUINextPlugin', () => { importAliases: { '@emotion/react': null, '@emotion/styled': 'styled', + '@emotion/css': null, '@vanilla-extract/css': null, 'styled-components': 'styled', }, @@ -588,6 +590,7 @@ describe('DevupUINextPlugin', () => { importAliases: { '@emotion/react': null, '@emotion/styled': 'styled', + '@emotion/css': null, '@vanilla-extract/css': null, 'styled-components': 'styled', }, @@ -640,6 +643,7 @@ describe('DevupUINextPlugin', () => { importAliases: { '@emotion/react': null, '@emotion/styled': 'styled', + '@emotion/css': null, '@vanilla-extract/css': null, 'styled-components': 'styled', }, @@ -879,6 +883,7 @@ export const box = style({ color: 'red' })` importAliases: { '@emotion/react': null, '@emotion/styled': 'styled', + '@emotion/css': null, '@vanilla-extract/css': null, 'styled-components': 'styled', }, diff --git a/packages/plugin-utils/src/__tests__/create-compat-types.test.ts b/packages/plugin-utils/src/__tests__/create-compat-types.test.ts index dd3fff83d..0255bc589 100644 --- a/packages/plugin-utils/src/__tests__/create-compat-types.test.ts +++ b/packages/plugin-utils/src/__tests__/create-compat-types.test.ts @@ -20,6 +20,7 @@ describe('createCompatTypes', () => { mergeImportAliases({ '@emotion/react': false, '@emotion/styled': false, + '@emotion/css': false, '@vanilla-extract/css': false, }), ), @@ -36,6 +37,7 @@ describe('createCompatTypes', () => { createCompatTypes({ '@emotion/react': null, '@emotion/styled': 'styled', + '@emotion/css': null, }), ).toBe( [ diff --git a/packages/plugin-utils/src/__tests__/merge-import-aliases.test.ts b/packages/plugin-utils/src/__tests__/merge-import-aliases.test.ts index 286292711..34dca997b 100644 --- a/packages/plugin-utils/src/__tests__/merge-import-aliases.test.ts +++ b/packages/plugin-utils/src/__tests__/merge-import-aliases.test.ts @@ -13,6 +13,7 @@ describe('mergeImportAliases', () => { expect(result).toEqual({ '@emotion/react': null, '@emotion/styled': 'styled', + '@emotion/css': null, 'styled-components': 'styled', '@vanilla-extract/css': null, }) @@ -26,6 +27,7 @@ describe('mergeImportAliases', () => { expect(result).toEqual({ '@emotion/react': null, '@emotion/styled': 'styled', + '@emotion/css': null, 'styled-components': 'styled', '@vanilla-extract/css': null, 'my-lib': 'customExport', @@ -61,6 +63,7 @@ describe('mergeImportAliases', () => { const result = mergeImportAliases({ '@emotion/react': false, '@emotion/styled': false, + '@emotion/css': false, 'styled-components': false, '@vanilla-extract/css': false, }) @@ -86,6 +89,7 @@ describe('DEFAULT_IMPORT_ALIASES', () => { expect(DEFAULT_IMPORT_ALIASES).toEqual({ '@emotion/react': true, '@emotion/styled': 'styled', + '@emotion/css': true, 'styled-components': 'styled', '@vanilla-extract/css': true, }) diff --git a/packages/plugin-utils/src/types.ts b/packages/plugin-utils/src/types.ts index 1a1365c2d..833cd1d40 100644 --- a/packages/plugin-utils/src/types.ts +++ b/packages/plugin-utils/src/types.ts @@ -106,6 +106,7 @@ export type ImportAliases = Record export const DEFAULT_IMPORT_ALIASES: ImportAliases = { '@emotion/react': true, '@emotion/styled': 'styled', + '@emotion/css': true, 'styled-components': 'styled', '@vanilla-extract/css': true, } @@ -147,6 +148,7 @@ export function mergeImportAliases( const COMPAT_TYPE_ENTRIES: Record = { '@emotion/react': 'emotion', '@emotion/styled': 'emotion', + '@emotion/css': 'emotion', '@vanilla-extract/css': 'vanilla-extract', 'styled-components': 'styled-components', } diff --git a/packages/react/src/compat/__tests__/index.test.ts b/packages/react/src/compat/__tests__/index.test.ts index ad6e86619..b2824a447 100644 --- a/packages/react/src/compat/__tests__/index.test.ts +++ b/packages/react/src/compat/__tests__/index.test.ts @@ -9,6 +9,8 @@ describe('compat entry', () => { ThemeProvider: expect.any(Function), createGlobalStyle: expect.any(Function), + cx: expect.any(Function), + merge: expect.any(Function), useTheme: expect.any(Function), withTheme: expect.any(Function), diff --git a/packages/react/src/compat/emotion.d.ts b/packages/react/src/compat/emotion.d.ts index 69bdf9176..b3d2ef0e4 100644 --- a/packages/react/src/compat/emotion.d.ts +++ b/packages/react/src/compat/emotion.d.ts @@ -4,6 +4,12 @@ declare module '@emotion/styled' { export default styled } +declare module '@emotion/css' { + export { css, globalCss as injectGlobal, keyframes } from '@devup-ui/react' + export type { ClassNamesArg } from '@devup-ui/react/compat' + export { cx, merge } from '@devup-ui/react/compat' +} + declare module '@emotion/react' { export { css, keyframes } from '@devup-ui/react' export type { StyledTheme as Theme } from '@devup-ui/react/compat' diff --git a/packages/react/src/compat/index.ts b/packages/react/src/compat/index.ts index c22aa8eb4..c5c4c9989 100644 --- a/packages/react/src/compat/index.ts +++ b/packages/react/src/compat/index.ts @@ -11,6 +11,8 @@ export { Global } from '../components/Global' export { ThemeProvider } from '../components/ThemeProvider' export { useStyledTheme as useTheme } from '../hooks/use-styled-theme' export { createGlobalStyle } from '../utils/create-global-style' +export type { ClassNamesArg } from '../utils/emotion-classes' +export { cx, merge } from '../utils/emotion-classes' export { isStyledComponent, ServerStyleSheet, diff --git a/packages/react/src/utils/__tests__/emotion-classes.test.ts b/packages/react/src/utils/__tests__/emotion-classes.test.ts new file mode 100644 index 000000000..a413215e2 --- /dev/null +++ b/packages/react/src/utils/__tests__/emotion-classes.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'bun:test' + +import { cx, merge } from '../emotion-classes' + +describe('emotion class composition', () => { + it('cx cannot run on the runtime', () => { + expect(() => cx('a', { b: true }, ['c'])).toThrowError( + 'Cannot run on the runtime', + ) + }) + + it('merge cannot run on the runtime', () => { + expect(() => merge('a b')).toThrowError('Cannot run on the runtime') + }) +}) diff --git a/packages/react/src/utils/emotion-classes.ts b/packages/react/src/utils/emotion-classes.ts new file mode 100644 index 000000000..b85cb8a89 --- /dev/null +++ b/packages/react/src/utils/emotion-classes.ts @@ -0,0 +1,28 @@ +/** + * One argument of Emotion's `cx`: a class name, a falsy value that is skipped, + * a class map (`{ name: condition }`) or an array of them. + */ +export type ClassNamesArg = + | string + | number + | boolean + | null + | undefined + | { [name: string]: unknown } + | ClassNamesArg[] + +/** + * Emotion compatible class composition. The call is compiled away at build + * time into the class string it names, so there is nothing to run. + */ +export function cx(..._classNames: ClassNamesArg[]): string { + throw new Error('Cannot run on the runtime') +} + +/** + * Emotion compatible `merge`: the classes of a class string, composed like + * `cx` composes its classes. Compiled away at build time like `cx`. + */ +export function merge(_className: string): string { + throw new Error('Cannot run on the runtime') +} diff --git a/packages/vite-plugin/src/__tests__/plugin.test.ts b/packages/vite-plugin/src/__tests__/plugin.test.ts index 4e14b5fd8..4296709f7 100644 --- a/packages/vite-plugin/src/__tests__/plugin.test.ts +++ b/packages/vite-plugin/src/__tests__/plugin.test.ts @@ -1382,6 +1382,7 @@ describe('devupUIVitePlugin', () => { { '@emotion/react': null, '@emotion/styled': 'styled', + '@emotion/css': null, '@vanilla-extract/css': null, 'styled-components': 'styled', }, @@ -1400,6 +1401,7 @@ describe('devupUIVitePlugin', () => { { '@emotion/react': null, '@emotion/styled': 'styled', + '@emotion/css': null, '@vanilla-extract/css': null, 'styled-components': 'styled', },