diff --git a/.changepacks/changepack_log_scoped_compiled_names.json b/.changepacks/changepack_log_scoped_compiled_names.json new file mode 100644 index 000000000..3959d1e2b --- /dev/null +++ b/.changepacks/changepack_log_scoped_compiled_names.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Resolve compiled styling APIs and components by lexical binding so shadowed local names remain ordinary JavaScript and JSX, and preserve StyleX conditions exactly once in argument order. Conditions needing capture storage now report located build errors in parameter defaults, class field initializers, and statement frames visible to direct eval when safe local storage is unavailable; move the value into a variable in a safe statement body.", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/libs/extractor/src/barrel.rs b/libs/extractor/src/barrel.rs index 5b59a7932..52062045f 100644 --- a/libs/extractor/src/barrel.rs +++ b/libs/extractor/src/barrel.rs @@ -94,31 +94,41 @@ fn analyze(module: &ResolvedModule, package: &str) -> Exports { let program = Parser::new(&allocator, &module.code, source_type) .parse() .program; - let mut imports: FxHashMap<&str, Link> = FxHashMap::default(); + let semantic = SemanticBuilder::new().build(&program).semantic; + let mut imports: FxHashMap = FxHashMap::default(); for statement in &program.body { - match statement { - Statement::ImportDeclaration(import) if !import.import_kind.is_type() => { - for specifier in import.specifiers.iter().flatten() { - let (local, imported) = match specifier { - ImportDeclarationSpecifier::ImportSpecifier(named) => { - (&named.local, Some(named.imported.name().to_string())) - } - ImportDeclarationSpecifier::ImportDefaultSpecifier(default) => { - (&default.local, Some("default".to_string())) - } - ImportDeclarationSpecifier::ImportNamespaceSpecifier(namespace) => { - (&namespace.local, None) - } - }; - imports.insert( - local.name.as_str(), + if let Statement::ImportDeclaration(import) = statement + && !import.import_kind.is_type() + { + for specifier in import.specifiers.iter().flatten() { + let (local, imported) = match specifier { + ImportDeclarationSpecifier::ImportSpecifier(named) + if !named.import_kind.is_type() => + { + (&named.local, Some(named.imported.name().to_string())) + } + ImportDeclarationSpecifier::ImportSpecifier(_) => continue, + ImportDeclarationSpecifier::ImportDefaultSpecifier(default) => { + (&default.local, Some("default".to_string())) + } + ImportDeclarationSpecifier::ImportNamespaceSpecifier(namespace) => { + (&namespace.local, None) + } + }; + imports.extend(local.symbol_id.get().map(|symbol| { + ( + symbol, Link::From { source: import.source.value.to_string(), imported, }, - ); - } + ) + })); } + } + } + for statement in &program.body { + match statement { Statement::ExportFromDeclaration(export) if !export.export_kind.is_type() => { for specifier in export .specifiers @@ -155,8 +165,8 @@ fn analyze(module: &ResolvedModule, package: &str) -> Exports { .iter() .filter(|s| !s.export_kind.is_type()) { - let link = imports - .get(specifier.local.name().as_str()) + let link = export_symbol(&specifier.local, &semantic) + .and_then(|symbol| imports.get(&symbol)) .cloned() .unwrap_or(Link::Own); exports @@ -165,8 +175,8 @@ fn analyze(module: &ResolvedModule, package: &str) -> Exports { } } Statement::VariableDeclaration(declaration) => { - for (name, link) in alias_links(declaration, &imports) { - imports.insert(name, link); + for (id, link) in alias_links(declaration, &imports, &semantic) { + imports.extend(id.symbol_id.get().map(|symbol| (symbol, link))); } } Statement::ExportDeclaration(export) => { @@ -174,16 +184,18 @@ fn analyze(module: &ResolvedModule, package: &str) -> Exports { exports.named.insert(name, Link::Own); } if let Declaration::VariableDeclaration(declaration) = &export.declaration { - for (name, link) in alias_links(declaration, &imports) { - exports.named.insert(name.to_string(), link.clone()); - imports.insert(name, link); + for (id, link) in alias_links(declaration, &imports, &semantic) { + exports.named.insert(id.name.to_string(), link.clone()); + imports.extend(id.symbol_id.get().map(|symbol| (symbol, link))); } } } Statement::ExportDefaultDeclaration(export) => { let link = match &export.declaration { ExportDefaultDeclarationKind::Identifier(name) => { - imports.get(name.name.as_str()).cloned() + reference_symbol(name, &semantic) + .and_then(|symbol| imports.get(&symbol)) + .cloned() } _ => None, }; @@ -200,13 +212,14 @@ fn analyze(module: &ResolvedModule, package: &str) -> Exports { /// Where `Namespace.member` leads, for a namespace a module imports fn member_link( member: &StaticMemberExpression<'_>, - imports: &FxHashMap<&str, Link>, + imports: &FxHashMap, + semantic: &Semantic<'_>, ) -> Option { if let Expression::Identifier(object) = &member.object && let Some(Link::From { source, imported: None, - }) = imports.get(object.name.as_str()) + }) = reference_symbol(object, semantic).and_then(|symbol| imports.get(&symbol)) { Some(Link::From { source: source.clone(), @@ -220,9 +233,10 @@ fn member_link( /// The bindings a `const` declaration makes of what a module imports /// (`const B = Box`, `const C = Devup.css`), with where each leads fn alias_links<'a>( - declaration: &oxc_ast::ast::VariableDeclaration<'a>, - imports: &FxHashMap<&str, Link>, -) -> Vec<(&'a str, Link)> { + declaration: &'a oxc_ast::ast::VariableDeclaration<'a>, + imports: &FxHashMap, + semantic: &Semantic<'_>, +) -> Vec<(&'a BindingIdentifier<'a>, Link)> { if declaration.kind != VariableDeclarationKind::Const { return Vec::new(); } @@ -234,15 +248,42 @@ fn alias_links<'a>( return None; }; let link = match declarator.init.as_ref()? { - Expression::Identifier(init) => imports.get(init.name.as_str())?.clone(), - Expression::StaticMemberExpression(member) => member_link(member, imports)?, + Expression::Identifier(init) => { + imports.get(&reference_symbol(init, semantic)?)?.clone() + } + Expression::StaticMemberExpression(member) => { + member_link(member, imports, semantic)? + } _ => return None, }; - Some((id.name.as_str(), link)) + Some((id.as_ref(), link)) }) .collect() } +fn reference_symbol( + reference: &oxc_ast::ast::IdentifierReference<'_>, + semantic: &Semantic<'_>, +) -> Option { + reference + .reference_id + .get() + .and_then(|id| semantic.scoping().get_reference(id).symbol_id()) +} + +fn export_symbol( + local: &oxc_ast::ast::ModuleExportName<'_>, + semantic: &Semantic<'_>, +) -> Option { + match local { + oxc_ast::ast::ModuleExportName::IdentifierReference(reference) => { + reference_symbol(reference, semantic) + } + oxc_ast::ast::ModuleExportName::IdentifierName(_) + | oxc_ast::ast::ModuleExportName::StringLiteral(_) => None, + } +} + /// Follows re-exports through the modules a resolver reads struct Walker<'r, 'p> { resolver: &'r ModuleResolver, @@ -461,6 +502,7 @@ impl Generated { } mod aliases; +mod gate; use aliases::{Reach, declarator_removal, export_edits, is_assigned_from}; fn unreadable(local: &str, code: &str) -> String { @@ -979,36 +1021,42 @@ pub(crate) fn rewrite( )); } } - let locals: FxHashMap = found - .bound - .iter() - .filter(|bound| bound.devup.1.as_deref().is_some_and(compiled_export)) - .map(|bound| { - ( - bound.binding.name.to_string(), - (bound.source.clone(), Some(bound.imported.clone())), - ) - }) - .chain(found.namespaces.iter().map(|declared| { - ( - declared.binding.name.to_string(), - (declared.source.clone(), None), - ) - })) - .collect(); - edits.extend(export_edits(&program, code, locals)); let wants_semantic = !found.namespaces.is_empty() || !found.opaque.is_empty() - || found + || (found .bound .iter() - .any(|bound| is_assigned_from(code, bound.binding.name.as_str())) - || (!found.bound.is_empty() && (code.contains("= keyframes(") || code.contains("= css("))); + .any(|bound| bound.devup.1.as_deref().is_some_and(compiled_export)) + && (gate::has_export_candidate(&program) + || code.contains("= keyframes(") + || code.contains("= css(") + || found.bound.iter().any(|bound| { + bound.devup.1.as_deref().is_some_and(compiled_export) + && is_assigned_from(code, bound.binding.name.as_str()) + }))); let (mut edits, errors) = if wants_semantic { let semantic = SemanticBuilder::new() .with_build_nodes(true) .build(&program) .semantic; + let locals: FxHashMap = found + .bound + .iter() + .filter(|bound| bound.devup.1.as_deref().is_some_and(compiled_export)) + .filter_map(|bound| { + Some(( + bound.binding.symbol_id.get()?, + (bound.source.clone(), Some(bound.imported.clone())), + )) + }) + .chain(found.namespaces.iter().filter_map(|declared| { + Some(( + declared.binding.symbol_id.get()?, + (declared.source.clone(), None), + )) + })) + .collect(); + edits.extend(export_edits(&semantic, code, locals)); let mut rewriter = Rewriter { code, semantic: &semantic, @@ -1072,3 +1120,6 @@ pub(crate) fn rewrite( #[cfg(test)] #[allow(clippy::expect_used, clippy::unwrap_used)] mod tests; + +#[cfg(test)] +mod scope_tests; diff --git a/libs/extractor/src/barrel/aliases.rs b/libs/extractor/src/barrel/aliases.rs index 035fa23f2..1771f9e1b 100644 --- a/libs/extractor/src/barrel/aliases.rs +++ b/libs/extractor/src/barrel/aliases.rs @@ -6,12 +6,13 @@ use oxc_ast::ast::{ BindingPattern, Declaration, ExportDefaultDeclarationKind, Expression, Program, Statement, StaticMemberExpression, VariableDeclaration, VariableDeclarationKind, }; +use oxc_semantic::Semantic; use oxc_span::{GetSpan, Span}; use oxc_syntax::node::NodeId; use oxc_syntax::symbol::SymbolId; use rustc_hash::FxHashMap; -use super::{Bound, Namespace, Rewriter, compiled_export}; +use super::{Bound, Namespace, Rewriter, compiled_export, export_symbol, reference_symbol}; /// Where a binding leads: the module it is read from, and the name read, `None` /// for the module's namespace @@ -58,19 +59,20 @@ pub(super) fn is_assigned_from(code: &str, name: &str) -> bool { /// `export const B = Box`. A module compiled with the import removed would /// otherwise export a name nothing declares. pub(super) fn export_edits( - program: &Program<'_>, + semantic: &Semantic<'_>, code: &str, - mut locals: FxHashMap, + mut locals: FxHashMap, ) -> Vec<(usize, usize, String)> { let mut edits = Vec::new(); + let program = semantic.nodes().program(); for statement in &program.body { match statement { Statement::VariableDeclaration(declaration) => { - alias_reaches(declaration, &mut locals); + alias_reaches(declaration, &mut locals, semantic); } Statement::ExportDeclaration(export) => { if let Declaration::VariableDeclaration(declaration) = &export.declaration { - let aliases = alias_reaches(declaration, &mut locals); + let aliases = alias_reaches(declaration, &mut locals, semantic); if !aliases.is_empty() { let mut statements: Vec = Vec::new(); let others: Vec<&str> = declaration @@ -102,7 +104,8 @@ pub(super) fn export_edits( let mut statements = Vec::new(); let mut kept = Vec::new(); for specifier in &export.specifiers { - let reach = locals.get(specifier.local.name().as_str()); + let reach = export_symbol(&specifier.local, semantic) + .and_then(|symbol| locals.get(&symbol)); match reach { Some(reach) if !specifier.export_kind.is_type() => { statements.push(reexport(specifier.exported.name().as_str(), reach)); @@ -125,7 +128,8 @@ pub(super) fn export_edits( } Statement::ExportDefaultDeclaration(export) => { if let ExportDefaultDeclarationKind::Identifier(name) = &export.declaration - && let Some(reach) = locals.get(name.name.as_str()) + && let Some(reach) = + reference_symbol(name, semantic).and_then(|symbol| locals.get(&symbol)) && reach.1.is_some() { edits.push(( @@ -145,10 +149,12 @@ pub(super) fn export_edits( /// the build compiles `member` away fn namespace_member_reach( member: &StaticMemberExpression<'_>, - locals: &FxHashMap, + locals: &FxHashMap, + semantic: &Semantic<'_>, ) -> Option { if let Expression::Identifier(object) = &member.object - && let Some((source, None)) = locals.get(object.name.as_str()) + && let Some((source, None)) = + reference_symbol(object, semantic).and_then(|symbol| locals.get(&symbol)) && compiled_export(member.property.name.as_str()) { Some((source.clone(), Some(member.property.name.to_string()))) @@ -161,7 +167,8 @@ fn namespace_member_reach( /// member of its namespace, `(name, where it leads)`, added to `locals` fn alias_reaches<'a>( declaration: &VariableDeclaration<'a>, - locals: &mut FxHashMap, + locals: &mut FxHashMap, + semantic: &Semantic<'_>, ) -> Vec<(&'a str, Reach)> { let mut aliases = Vec::new(); if declaration.kind != VariableDeclarationKind::Const { @@ -172,14 +179,16 @@ fn alias_reaches<'a>( continue; }; let reach = match &declarator.init { - Some(Expression::Identifier(init)) => locals.get(init.name.as_str()).cloned(), + Some(Expression::Identifier(init)) => reference_symbol(init, semantic) + .and_then(|symbol| locals.get(&symbol)) + .cloned(), Some(Expression::StaticMemberExpression(member)) => { - namespace_member_reach(member, locals) + namespace_member_reach(member, locals, semantic) } _ => None, }; if let Some(reach) = reach { - locals.insert(id.name.to_string(), reach.clone()); + locals.extend(id.symbol_id.get().map(|symbol| (symbol, reach.clone()))); aliases.push((id.name.as_str(), reach)); } } @@ -233,9 +242,7 @@ impl Rewriter<'_, '_, '_, '_> { let (Some(declaration), Some(alias), Some(origin)) = ( nodes.kind(declaration_id).as_variable_declaration(), id.symbol_id.get(), - init.reference_id - .get() - .and_then(|reference| scoping.get_reference(reference).symbol_id()), + reference_symbol(init, self.semantic), ) else { continue; }; @@ -290,7 +297,6 @@ impl Rewriter<'_, '_, '_, '_> { /// it is an error pub(super) fn hoist_style_constants(&mut self, program: &Program<'_>, bound: &[Bound<'_, '_>]) { let nodes = self.semantic.nodes(); - let scoping = self.semantic.scoping(); let apis: FxHashMap = bound .iter() .filter_map(|bound| { @@ -322,11 +328,8 @@ impl Rewriter<'_, '_, '_, '_> { let Expression::Identifier(callee) = &call.callee else { continue; }; - let Some(api) = callee - .reference_id - .get() - .and_then(|reference| scoping.get_reference(reference).symbol_id()) - .and_then(|symbol| apis.get(&symbol)) + let Some(api) = + reference_symbol(callee, self.semantic).and_then(|symbol| apis.get(&symbol)) else { continue; }; @@ -356,10 +359,7 @@ impl Rewriter<'_, '_, '_, '_> { let inside = reference.span.start >= call.span.start && reference.span.end <= call.span.end; !inside - || reference - .reference_id - .get() - .and_then(|reference| scoping.get_reference(reference).symbol_id()) + || reference_symbol(reference, self.semantic) .is_none_or(|symbol| imports.contains(&symbol)) }); if !literal_only { diff --git a/libs/extractor/src/barrel/gate.rs b/libs/extractor/src/barrel/gate.rs new file mode 100644 index 000000000..f1e3ee9fe --- /dev/null +++ b/libs/extractor/src/barrel/gate.rs @@ -0,0 +1,120 @@ +use oxc_ast::ast::{ + BindingPattern, Declaration, ExportDefaultDeclarationKind, Expression, Program, Statement, + VariableDeclarationKind, +}; + +pub(super) fn has_export_candidate(program: &Program<'_>) -> bool { + program.body.iter().any(|statement| match statement { + Statement::ExportNamedDeclaration(export) => { + !export.export_kind.is_type() + && export + .specifiers + .iter() + .any(|specifier| !specifier.export_kind.is_type()) + } + Statement::ExportDefaultDeclaration(export) => { + matches!( + export.declaration, + ExportDefaultDeclarationKind::Identifier(_) + ) + } + Statement::ExportDeclaration(export) => match &export.declaration { + Declaration::VariableDeclaration(declaration) => { + declaration.kind == VariableDeclarationKind::Const + && declaration.declarations.iter().any(|declarator| { + matches!(declarator.id, BindingPattern::BindingIdentifier(_)) + && matches!( + declarator.init, + Some( + Expression::Identifier(_) + | Expression::StaticMemberExpression(_) + ) + ) + }) + } + _ => false, + }, + _ => false, + }) +} + +#[cfg(test)] +mod tests { + use oxc_allocator::Allocator; + use oxc_parser::Parser; + use oxc_span::SourceType; + use rstest::rstest; + + use super::has_export_candidate; + use crate::ResolvedModule; + use crate::barrel::{Barreled, Link, analyze, rewrite}; + + #[rstest] + #[case("export { Box };", true)] + #[case("export { type Props, css as style };", true)] + #[case("export { type Props };", false)] + #[case("export type { Box };", false)] + #[case("export {};", false)] + #[case("export default Box;", true)] + #[case("export default function Page() { return ; }", false)] + #[case("export default () => ;", false)] + #[case("export const B = /* comment */ Box;", true)] + #[case("export const c = UI.css;", true)] + #[case("export const other = 1, B = Box;", true)] + #[case("export const Page = ;", false)] + #[case("export const value = 1;", false)] + #[case("export const style = css({});", false)] + #[case("export const { Box } = UI;", false)] + #[case("export let B = Box;", false)] + #[case("export const B = Box['name'];", false)] + #[case("export function Page() { return ; }", false)] + #[case("export { Box } from '@devup-ui/react';", false)] + #[case("export * from '@devup-ui/react';", false)] + #[case("const text = 'export';", false)] + fn export_shape_selects_analysis_candidates(#[case] code: &str, #[case] expected: bool) { + let allocator = Allocator::default(); + let program = Parser::new(&allocator, code, SourceType::tsx()) + .parse() + .program; + + let candidate = has_export_candidate(&program); + + assert_eq!(candidate, expected, "{code}"); + } + + #[rstest] + #[case("const B = /* comment */ Box; export { B };")] + #[case("const B = /* comment */ Box; export default B;")] + #[case("export const B = /* comment */ Box;")] + fn exported_alias_is_rewritten_when_assignment_text_does_not_match(#[case] exported: &str) { + let code = format!("import {{ Box }} from '@devup-ui/react'; {exported}"); + + let result = rewrite(&code, "ui.tsx", "@devup-ui/react", None); + + let Barreled::Rewritten(result) = result else { + panic!("exported alias was not rewritten"); + }; + assert!( + result.code.contains("from \"@devup-ui/react\";"), + "{}", + result.code + ); + assert!(result.code.contains("export { Box as"), "{}", result.code); + } + + #[test] + fn inline_type_import_is_not_a_runtime_origin_when_value_imports_are_mixed() { + let module = ResolvedModule { + path: "ui.ts".to_string(), + code: "export { Box, css }; import { type Box, css } from '@devup-ui/react';" + .to_string(), + }; + + let exports = analyze(&module, "@devup-ui/react"); + + assert!(matches!(exports.named.get("Box"), Some(Link::Own))); + assert!( + matches!(exports.named.get("css"), Some(Link::From { source, imported: Some(name) }) if source == "@devup-ui/react" && name == "css") + ); + } +} diff --git a/libs/extractor/src/barrel/scope_tests.rs b/libs/extractor/src/barrel/scope_tests.rs new file mode 100644 index 000000000..8a1763f63 --- /dev/null +++ b/libs/extractor/src/barrel/scope_tests.rs @@ -0,0 +1,185 @@ +use rstest::rstest; + +use super::{Barreled, rewrite}; +use crate::ResolvedModule; + +const PACKAGE: &str = "@devup-ui/react"; + +#[rstest] +#[case( + "export { Box }; import { Box } from '@devup-ui/react';", + "import { Box } from './ui'; const view = ;" +)] +#[case( + "export default Box; import { Box } from '@devup-ui/react';", + "import Box from './ui'; const view = ;" +)] +#[case( + "const B = Box; export { B as Box }; import { Box } from '@devup-ui/react';", + "import { Box } from './ui'; const view = ;" +)] +fn barrel_import_is_followed_when_declared_after_its_export( + #[case] barrel: &str, + #[case] code: &str, +) { + let barrel = barrel.to_string(); + let resolver = move |_: &str, _: &str| { + Some(ResolvedModule { + path: "ui.ts".to_string(), + code: barrel.clone(), + }) + }; + + let result = rewrite(code, "app.tsx", PACKAGE, Some(&resolver)); + + let Barreled::Rewritten(result) = result else { + panic!("barrel import was not redirected"); + }; + assert!( + result.code.contains("from \"@devup-ui/react\""), + "{}", + result.code + ); +} + +#[rstest] +#[case("function f(UI) { return UI.css({ color: 'blue' }); }")] +#[case("function f() { const UI = other; return ; }")] +#[case("function f(UI) { const D = UI; return D.css({ color: 'blue' }); }")] +#[case("namespace Local { import UI = Other.UI; export const x = UI.css({}); }")] +fn namespace_shadow_is_preserved_when_outer_namespace_is_rewritten( + #[case] shadow: &str, + #[values(PACKAGE, "./ui")] source: &str, +) { + let code = format!("import * as UI from '{source}'; const outer = UI.css({{}}); {shadow}"); + let resolver = |_: &str, _: &str| { + Some(ResolvedModule { + path: "ui.ts".to_string(), + code: "export { css, Box } from '@devup-ui/react';".to_string(), + }) + }; + + let result = rewrite(&code, "app.tsx", PACKAGE, Some(&resolver)); + + let Barreled::Rewritten(result) = result else { + panic!("outer namespace was not rewritten"); + }; + assert!(result.code.contains(shadow), "{}", result.code); + assert!( + result.code.contains("const outer = UI$css({});"), + "{}", + result.code + ); +} + +#[rstest] +#[case("function f(css) { const local = css; return local({}); }")] +#[case("function f() { const css = other; const local = css; return local({}); }")] +#[case("namespace Local { import css = Other.css; export const value = css; }")] +fn named_shadow_is_preserved_when_import_alias_is_rewritten(#[case] shadow: &str) { + let code = format!( + "import {{ css }} from '@devup-ui/react'; function real() {{ const local = css; return local({{}}); }} {shadow}" + ); + + let result = rewrite(&code, "app.tsx", PACKAGE, None); + + let Barreled::Rewritten(result) = result else { + panic!("import alias was not rewritten"); + }; + assert!(result.code.contains(shadow), "{}", result.code); + assert!( + result.code.contains("return local$css({});"), + "{}", + result.code + ); +} + +#[test] +fn barrel_alias_shadow_is_preserved_when_same_spelling_occurs_in_multiple_scopes() { + let shadow = "function shadow(css) { const local = css; return local({}); }"; + let code = format!( + "import {{ css }} from './ui'; const top = css; function a() {{ const local = top; return local({{}}); }} function b() {{ const local = css; return local({{}}); }} {shadow}" + ); + let resolver = |_: &str, _: &str| { + Some(ResolvedModule { + path: "ui.ts".to_string(), + code: "import { css } from '@devup-ui/react'; function f(css) { const local = css; return local; } export { css };".to_string(), + }) + }; + + let result = rewrite(&code, "app.tsx", PACKAGE, Some(&resolver)); + + let Barreled::Rewritten(result) = result else { + panic!("barrel aliases were not rewritten"); + }; + assert!(result.code.contains(shadow), "{}", result.code); + assert_eq!(result.code.matches("return local$css({});").count(), 2); +} + +#[test] +fn nested_namespace_import_is_preserved_when_exporting_an_outer_alias() { + let shadow = "namespace Local { import UI = Other.UI; export const B = UI.Box; }"; + let code = format!("import * as UI from '@devup-ui/react'; {shadow} export const B = UI.Box;"); + + let result = rewrite(&code, "app.tsx", PACKAGE, None); + + let Barreled::Rewritten(result) = result else { + panic!("outer export was not rewritten"); + }; + assert!(result.code.contains(shadow), "{}", result.code); + assert!( + result + .code + .contains("export { Box as B } from \"@devup-ui/react\";"), + "{}", + result.code + ); +} + +#[test] +fn runtime_import_is_unchanged_when_exporting_its_alias() { + let code = + "import { useTheme } from '@devup-ui/react'; const theme = useTheme; export { theme };"; + + let result = rewrite(code, "app.tsx", PACKAGE, None); + + assert!(matches!(result, Barreled::Unchanged)); +} + +#[rstest] +#[case("export { Box } from '@devup-ui/react';")] +#[case("export { 'Box' as Box } from '@devup-ui/react';")] +fn source_export_name_has_no_local_binding_when_it_is_not_a_reference(#[case] code: &str) { + let allocator = oxc_allocator::Allocator::default(); + let program = oxc_parser::Parser::new(&allocator, code, oxc_span::SourceType::mjs()) + .parse() + .program; + let semantic = oxc_semantic::SemanticBuilder::new() + .build(&program) + .semantic; + let oxc_ast::ast::Statement::ExportFromDeclaration(export) = &program.body[0] else { + panic!("expected a source export"); + }; + + let symbol = super::export_symbol(&export.specifiers[0].local, &semantic); + + assert_eq!(symbol, None); +} + +#[test] +fn unresolved_alias_is_preserved_when_exporting_a_compiled_import() { + let code = + "import { Box } from '@devup-ui/react'; const alias = unknown; export { Box, alias };"; + + let result = rewrite(code, "app.tsx", PACKAGE, None); + + let Barreled::Rewritten(result) = result else { + panic!("compiled export was not rewritten"); + }; + assert!( + result.code.contains("const alias = unknown;"), + "{}", + result.code + ); + assert!(result.code.contains("export { alias };"), "{}", result.code); +} diff --git a/libs/extractor/src/build_time_values.rs b/libs/extractor/src/build_time_values.rs index 97f9a5daa..e6200125a 100644 --- a/libs/extractor/src/build_time_values.rs +++ b/libs/extractor/src/build_time_values.rs @@ -349,7 +349,10 @@ impl<'s, 'a> Finder<'s, 'a> { fn is_css(&self, callee: &Expression<'_>) -> bool { if let Expression::StaticMemberExpression(member) = callee { - return member.property.name == "css"; + return member.property.name == "css" + && self + .symbol(&member.object) + .is_some_and(|symbol| self.namespaces.contains(&symbol)); } self.symbol(callee) .is_some_and(|symbol| self.css.contains(&symbol)) @@ -1097,3 +1100,6 @@ fn compute( } (!computed.is_empty()).then_some((computed, changes.dependencies())) } + +#[cfg(test)] +mod scope_tests; diff --git a/libs/extractor/src/build_time_values/scope_tests.rs b/libs/extractor/src/build_time_values/scope_tests.rs new file mode 100644 index 000000000..310d45aac --- /dev/null +++ b/libs/extractor/src/build_time_values/scope_tests.rs @@ -0,0 +1,33 @@ +use super::has_build_time_values; +use crate::ExtractOption; + +#[test] +fn a_local_named_like_a_style_api_computes_nothing() { + let option = ExtractOption::default(); + let prelude = "import { css, Box } from '@devup-ui/react'; +import * as ui from '@devup-ui/react'; +const double = (n) => n * 2; +"; + for code in [ + "export const a = css({ w: double(2) });", + "export const a = ui.css({ w: double(2) });", + "export const a = ;", + "export const a = ;", + ] { + assert!( + has_build_time_values("a.tsx", &format!("{prelude}{code}"), &option, None), + "{code}" + ); + } + for code in [ + "export function f(css) { return css({ w: double(2) }); }", + "export function f(ui) { return ui.css({ w: double(2) }); }", + "export function f(Box) { return ; }", + "export function f(ui) { return ; }", + ] { + assert!( + !has_build_time_values("a.tsx", &format!("{prelude}{code}"), &option, None), + "{code}" + ); + } +} diff --git a/libs/extractor/src/ci_factory_alias_support_tests.rs b/libs/extractor/src/ci_factory_alias_support_tests.rs index adfabc316..9e16fb1ee 100644 --- a/libs/extractor/src/ci_factory_alias_support_tests.rs +++ b/libs/extractor/src/ci_factory_alias_support_tests.rs @@ -47,7 +47,7 @@ impl Binding { fn extract_fixture(source: &str, single_css: bool, active: bool) -> ExtractOutput { extract( - "/src/W70h-native.js", + "/src/alias-native.js", source, ExtractOption { single_css, @@ -63,7 +63,7 @@ fn extract_fixture(source: &str, single_css: bool, active: bool) -> ExtractOutpu } fn observe(output: &ExtractOutput, invocation: &str, single_css: bool) -> (Vec, Vec) { - let observed = render_observation(output, invocation, (!single_css).then_some("/src/W70h-native.js")); + let observed = render_observation(output, invocation, (!single_css).then_some("/src/alias-native.js")); let (trace, mut declarations): (Vec, Vec) = serde_json::from_str(&observed).unwrap(); // These fixtures have distinct CSS properties, never competing declarations. declarations.sort(); diff --git a/libs/extractor/src/class_names_binding_tests.rs b/libs/extractor/src/class_names_binding_tests.rs index 41f50ee3b..dfaa3ff86 100644 --- a/libs/extractor/src/class_names_binding_tests.rs +++ b/libs/extractor/src/class_names_binding_tests.rs @@ -228,13 +228,12 @@ fn class_names_binding_mutation_shadow_is_runtime_escape( let source = format!("import {{ClassNames}} from '@emotion/react';const data={{padding:1}};{body}"); let parsed = Parser::new(&arena, &source, SourceType::tsx()).parse(); - let takers = crate::css_prop::CssTakers::new( + // When: classify whether the ordinary shadow may change data. + let uses = crate::mutations::uses( &parsed.program, - crate::css_prop::CssProp::Off, - "@devup-ui/react/compat", + &|name| name == "ClassNames", + Some((crate::css_prop::CssProp::Off, "@devup-ui/react/compat")), ); - // When: classify whether the ordinary shadow may change data. - let uses = crate::mutations::uses(&parsed.program, &|name| name == "ClassNames", Some(&takers)); // Then: data escapes to runtime code; it must not be treated as compile-only use. assert_eq!( uses.get("data").is_some_and(|uses| uses diff --git a/libs/extractor/src/css_prop.rs b/libs/extractor/src/css_prop.rs index 460a151a4..754de43e6 100644 --- a/libs/extractor/src/css_prop.rs +++ b/libs/extractor/src/css_prop.rs @@ -7,9 +7,9 @@ use std::collections::HashMap; use oxc_allocator::{CloneIn, FromIn, GetAllocator}; use oxc_ast::ast::{ Argument, ArrowFunctionBody, BinaryOperator, BindingPattern, CallExpression, Expression, - FormalParameters, FunctionBody, ImportDeclarationSpecifier, JSXAttributeItem, JSXChild, - JSXElement, JSXElementName, ObjectPropertyKind, Program, Statement, Str, TemplateElement, - TemplateElementValue, TemplateLiteral, + FormalParameters, FunctionBody, IdentifierReference, ImportDeclarationSpecifier, + JSXAttributeItem, JSXChild, JSXElement, JSXElementName, ObjectPropertyKind, Program, Statement, + Str, TemplateElement, TemplateElementValue, TemplateLiteral, }; use oxc_ast::builder::AstBuilder; use oxc_ast_visit::{VisitMut, walk_mut}; @@ -25,8 +25,6 @@ use crate::utils::{ unwrap_syntax_only, }; -type Resolve<'r> = &'r dyn Fn(&oxc_ast::ast::IdentifierReference<'_>) -> Option; - /// The package whose alias makes elements take the `css` prop pub(crate) const EMOTION_REACT: &str = "@emotion/react"; @@ -108,17 +106,53 @@ impl CssProp { /// What in a program takes the `css` prop: the elements `css_prop` tells, and /// the `jsx()` calls building them pub(crate) struct CssTakers<'s> { - pub css_prop: CssProp, - /// Local names of the functions building elements from a type and props - jsx: FxHashSet<&'s str>, - /// Local names of Emotion's `ClassNames` - class_names: FxHashSet<&'s str>, + css_prop: CssProp, + scoping: &'s Scoping, + /// The imported functions building elements from a type and props + jsx: FxHashSet, + /// The imported `ClassNames` of Emotion + class_names: FxHashSet, +} + +/// The binding `identifier` reads; `None` for a global +pub(crate) fn binding_of( + scoping: &Scoping, + identifier: &IdentifierReference<'_>, +) -> Option { + scoping + .get_reference(identifier.reference_id.get()?) + .symbol_id() +} + +/// Whether `identifier` reads a binding of the module's top level or a global, +/// and not a local of a function or block, which may share the name of one +pub(crate) fn reads_top_level(scoping: &Scoping, identifier: &IdentifierReference<'_>) -> bool { + binding_of(scoping, identifier) + .is_none_or(|symbol| scoping.symbol_scope_id(symbol) == scoping.root_scope_id()) +} + +/// The identifier `a`, `a.b` and `a[b]` start at +pub(crate) fn root_reference<'e, 'a>( + expression: &'e Expression<'a>, +) -> Option<&'e IdentifierReference<'a>> { + match expression { + Expression::Identifier(identifier) => Some(identifier), + Expression::StaticMemberExpression(member) => root_reference(&member.object), + Expression::ComputedMemberExpression(member) => root_reference(&member.object), + _ => None, + } } impl<'s> CssTakers<'s> { - /// The elements `program` gives a `css` prop, as `css_prop` tells; - /// `compat` is the entry absorbing Emotion's own `jsx` - pub(crate) fn new(program: &Program<'s>, css_prop: CssProp, compat: &str) -> Self { + /// The elements `program` gives a `css` prop, as `css_prop` tells, with + /// the bindings `scoping` resolved; `compat` is the entry absorbing + /// Emotion's own `jsx` + pub(crate) fn new( + program: &Program<'_>, + scoping: &'s Scoping, + css_prop: CssProp, + compat: &str, + ) -> Self { let mut jsx = FxHashSet::default(); let mut class_names = FxHashSet::default(); for statement in &program.body { @@ -130,38 +164,31 @@ impl<'s> CssTakers<'s> { }; let imported = specifier.imported.name(); if is_jsx_function(source, &imported, compat) { - jsx.insert(specifier.local.name.as_str()); + jsx.extend(specifier.local.symbol_id.get()); } else if imported == "ClassNames" && (source == compat || source == EMOTION_REACT) { - class_names.insert(specifier.local.name.as_str()); + class_names.extend(specifier.local.symbol_id.get()); } } } } Self { css_prop, + scoping, jsx, class_names, } } - pub(crate) fn has_class_names(&self) -> bool { - !self.class_names.is_empty() - } - - /// The child callback's API bindings, only when the element reads the import. - pub(crate) fn class_names_calls( - &self, - element: &JSXElement<'_>, - scoping: &Scoping, - ) -> Vec { + /// The `css` and `cx` bindings a `` child function `element` + /// gives takes; empty for any other element, and for a `ClassNames` a + /// local of the same name stands for + pub(crate) fn class_names_calls(&self, element: &JSXElement<'_>) -> Vec { match &element.opening_element.name { JSXElementName::IdentifierReference(name) - if self.class_names.contains(name.name.as_str()) - && binding_of(scoping, name).is_some_and(|symbol| { - scoping.get_root_binding(name.name.as_str().into()) == Some(symbol) - }) => + if binding_of(self.scoping, name) + .is_some_and(|symbol| self.class_names.contains(&symbol)) => { class_names_calls(element) } @@ -169,25 +196,43 @@ impl<'s> CssTakers<'s> { } } + /// Whether `callee` calls one of the `taken` bindings of `` + /// child functions + pub(crate) fn calls_class_names(&self, taken: &[SymbolId], callee: &Expression<'_>) -> bool { + matches!(callee, Expression::Identifier(callee) + if binding_of(self.scoping, callee).is_some_and(|symbol| taken.contains(&symbol))) + } + + /// Whether the element `name` takes the `css` prop; `devup` tells a + /// reference to a Devup UI component + pub(crate) fn takes( + &self, + name: &JSXElementName<'_>, + devup: impl Fn(&IdentifierReference<'_>) -> bool, + ) -> bool { + let root = crate::imported_constants::jsx_root_identifier(name); + self.css_prop.takes(name, |_| root.is_some_and(&devup)) + } + /// Whether `attribute` of the element `name` is a `css` prop it takes; - /// `devup` tells a Devup UI component + /// `devup` tells a reference to a Devup UI component pub(crate) fn attribute( &self, name: &JSXElementName<'_>, attribute: &JSXAttributeItem<'_>, - devup: impl Fn(&str) -> bool, + devup: impl Fn(&IdentifierReference<'_>) -> bool, ) -> bool { matches!(attribute, JSXAttributeItem::Attribute(attribute) if attribute.name.as_identifier().is_some_and(|attribute| attribute.name == "css")) - && self.css_prop.takes(name, devup) + && self.takes(name, devup) } /// Where among the props a `jsx()` call gives is the `css` prop the - /// element takes + /// element takes; `devup` tells a reference to a Devup UI component pub(crate) fn property( &self, call: &CallExpression<'_>, - devup: impl Fn(&str) -> bool, + devup: impl Fn(&IdentifierReference<'_>) -> bool, ) -> Option { let Expression::Identifier(callee) = &call.callee else { return None; @@ -195,8 +240,12 @@ impl<'s> CssTakers<'s> { let [element, Argument::ObjectExpression(props), ..] = call.arguments.as_slice() else { return None; }; - (self.jsx.contains(callee.name.as_str()) - && self.css_prop.takes_type(element.as_expression()?, devup)) + let element = element.as_expression()?; + let root = root_reference(unwrap_syntax_only(element)); + (binding_of(self.scoping, callee).is_some_and(|symbol| self.jsx.contains(&symbol)) + && self + .css_prop + .takes_type(element, |_| root.is_some_and(&devup))) .then(|| { props.properties.iter().rposition(|property| { matches!(property, ObjectPropertyKind::ObjectProperty(property) @@ -288,12 +337,27 @@ pub(crate) const UNPLACED: &str = pub(crate) const NESTED_MIXIN: &str = "a mixin must stand outside nested rules, where the parts it composes with can be split"; +/// The binding a read stands for, `None` for a global +pub(crate) type Resolve<'r> = &'r dyn Fn(&IdentifierReference<'_>) -> Option; + +/// [`theme_rules_of`] for code no semantic analysis has read, whose names bind +/// nothing +#[cfg(test)] +pub(crate) fn theme_rules<'a>( + ast: &AstBuilder<'a>, + function: &Expression<'a>, +) -> Result, (Expression<'a>, &'static str)> { + theme_rules_of(ast, function, &|_| None) +} + /// `function`, a function of the theme, as the rules it gives, each read of -/// the theme written as the CSS variable the `ThemeProvider` sets for it. +/// the theme written as the CSS variable the `ThemeProvider` sets for it: only +/// the reads of the binding its parameter declares, as `resolve` tells them. /// `Err` holds the code the build cannot write so, with what it requires. -pub(crate) fn theme_rules<'a>( +pub(crate) fn theme_rules_of<'a>( ast: &AstBuilder<'a>, function: &Expression<'a>, + resolve: Resolve<'_>, ) -> Result, (Expression<'a>, &'static str)> { let unsupported = || (function.clone_in(ast.allocator()), THEME_FUNCTION); let (params, body) = match function { @@ -317,7 +381,7 @@ pub(crate) fn theme_rules<'a>( let mut reads = ThemeReads { ast, roots: &roots, - binding: None, + resolve, unread: None, }; reads.visit(&mut rules, false); @@ -381,15 +445,6 @@ impl ClassNamesParams { } } -pub(crate) fn binding_of( - scoping: &Scoping, - identifier: &oxc_ast::ast::IdentifierReference<'_>, -) -> Option { - scoping - .get_reference(identifier.reference_id.get()?) - .symbol_id() -} - /// What `params` take of `{ css, cx, theme }`; `None` when they take it in a /// way the build cannot follow pub(crate) fn class_names_params(params: &FormalParameters<'_>) -> Option { @@ -428,8 +483,10 @@ pub(crate) fn class_names_params(params: &FormalParameters<'_>) -> Option` element `element` takes -pub(crate) fn class_names_child(element: &JSXElement<'_>) -> Option { +/// The parameters of the child function of the `` element `element` +fn class_names_function_params<'b, 'a>( + element: &'b JSXElement<'a>, +) -> Option<&'b FormalParameters<'a>> { let mut children = element .children .iter() @@ -438,12 +495,16 @@ pub(crate) fn class_names_child(element: &JSXElement<'_>) -> Option &arrow.params, - Expression::FunctionExpression(function) => &function.params, - _ => return None, - }; - class_names_params(params) + match container.expression.as_expression()? { + Expression::ArrowFunctionExpression(arrow) => Some(&arrow.params), + Expression::FunctionExpression(function) => Some(&function.params), + _ => None, + } +} + +/// What the child function of the `` element `element` takes +pub(crate) fn class_names_child(element: &JSXElement<'_>) -> Option { + class_names_function_params(element).and_then(class_names_params) } /// The child function's `css` and `cx` bindings, which read styles. @@ -463,29 +524,47 @@ pub(crate) fn read_theme<'a>( value: bool, resolve: Resolve<'_>, ) -> Result<(), Expression<'a>> { + let roots: Vec<_> = names + .iter() + .filter_map(|names| names.theme) + .map(|symbol| ThemeRoot { + name: "", + symbol: Some(symbol), + key: None, + }) + .collect(); let mut reads = ThemeReads { ast, - roots: &[], - binding: Some((names, resolve)), + roots: &roots, + resolve, unread: None, }; reads.visit(expression, value); reads.unread.map_or(Ok(()), Err) } -/// The names a function of the theme reads it by, each with the key of the -/// theme it holds when destructured; `None` for parameters the build cannot -/// follow -fn theme_roots<'a>(params: &FormalParameters<'a>) -> Option)>> { +/// A binding a function of the theme reads it by, with the key of the theme it +/// holds when destructured +pub(crate) struct ThemeRoot<'a> { + pub name: &'a str, + pub symbol: Option, + pub key: Option, +} + +/// The bindings a function of the theme reads it by; `None` for parameters +/// the build cannot follow +fn theme_roots<'a>(params: &FormalParameters<'a>) -> Option>> { if params.rest.is_some() { return None; } match params.items.as_slice() { [] => Some(Vec::new()), [param] if param.initializer.is_none() => match ¶m.pattern { - BindingPattern::BindingIdentifier(identifier) => { - Some(vec![(identifier.name.as_str(), None)]) - } + BindingPattern::BindingIdentifier(identifier) => Some(vec![ThemeRoot { + name: identifier.name.as_str(), + symbol: identifier.symbol_id.get(), + key: None, + }]), BindingPattern::ObjectPattern(object) if object.rest.is_none() => object .properties .iter() @@ -494,7 +573,11 @@ fn theme_roots<'a>(params: &FormalParameters<'a>) -> Option { - Some((local.name.as_str(), Some(key.to_string()))) + Some(ThemeRoot { + name: local.name.as_str(), + symbol: local.symbol_id.get(), + key: Some(key.to_string()), + }) } _ => None, }, @@ -510,8 +593,8 @@ fn theme_roots<'a>(params: &FormalParameters<'a>) -> Option { ast: &'r AstBuilder<'a>, - roots: &'r [(&'a str, Option)], - binding: Option<(&'r [ClassNamesParams], Resolve<'r>)>, + roots: &'r [ThemeRoot<'a>], + resolve: Resolve<'r>, unread: Option>, } @@ -548,21 +631,12 @@ impl<'a> ThemeReads<'_, 'a> { } Expression::ParenthesizedExpression(inner) => cursor = &inner.expression, Expression::Identifier(identifier) => { - if let Some((names, resolve)) = self.binding { - let symbol = resolve(identifier); - if !names - .iter() - .any(|names| ClassNamesParams::is(names.theme, symbol)) - { - return None; - } - } else { - let (_, key) = self - .roots - .iter() - .find(|(local, _)| *local == identifier.name.as_str())?; - path.extend(key.clone()); - } + let symbol = (self.resolve)(identifier); + let root = self.roots.iter().find(|root| { + root.symbol == symbol + && (symbol.is_some() || root.name == identifier.name.as_str()) + })?; + path.extend(root.key.clone()); path.reverse(); return Some(if exact && !path.is_empty() { ThemeRead::Path(path) @@ -1000,3 +1074,6 @@ mod tests { ); } } + +#[cfg(test)] +mod scope_tests; diff --git a/libs/extractor/src/css_prop/scope_tests.rs b/libs/extractor/src/css_prop/scope_tests.rs new file mode 100644 index 000000000..cdfbb56e7 --- /dev/null +++ b/libs/extractor/src/css_prop/scope_tests.rs @@ -0,0 +1,94 @@ +use oxc_allocator::Allocator; +use oxc_ast::ast::{Expression, IdentifierReference}; +use oxc_ast_visit::Visit; +use oxc_parser::Parser; +use oxc_semantic::{Scoping, SemanticBuilder}; +use oxc_span::SourceType; + +use super::{binding_of, reads_top_level, root_reference}; + +struct Reads<'s> { + scoping: &'s Scoping, + found: Vec<(String, bool, bool)>, +} + +impl<'a> Visit<'a> for Reads<'_> { + fn visit_identifier_reference(&mut self, identifier: &IdentifierReference<'a>) { + self.found.push(( + identifier.name.to_string(), + binding_of(self.scoping, identifier).is_some(), + reads_top_level(self.scoping, identifier), + )); + } +} + +#[test] +fn references_tell_locals_from_top_level_bindings_and_globals() { + let allocator = Allocator::default(); + let program = Parser::new( + &allocator, + "const top = 1; function f(local) { return [top, local, global]; }", + SourceType::tsx(), + ) + .parse() + .program; + let scoping = SemanticBuilder::new() + .build(&program) + .semantic + .into_scoping(); + let mut reads = Reads { + scoping: &scoping, + found: Vec::new(), + }; + reads.visit_program(&program); + assert_eq!( + reads.found, + vec![ + ("top".to_string(), true, true), + ("local".to_string(), true, false), + ("global".to_string(), false, true), + ] + ); +} + +#[test] +fn a_reference_without_semantic_data_reads_a_global() { + let allocator = Allocator::default(); + let program = Parser::new(&allocator, "", SourceType::tsx()) + .parse() + .program; + let scoping = SemanticBuilder::new() + .build(&program) + .semantic + .into_scoping(); + let Ok(Expression::Identifier(identifier)) = + Parser::new(&allocator, "a", SourceType::tsx()).parse_expression() + else { + panic!("a"); + }; + assert_eq!(binding_of(&scoping, &identifier), None); + assert!(reads_top_level(&scoping, &identifier)); +} + +#[test] +fn the_root_of_a_member_chain_is_the_identifier_it_starts_at() { + let allocator = Allocator::default(); + for (code, root) in [ + ("a", Some("a")), + ("a.b", Some("a")), + ("a[b]", Some("a")), + ("a.b[c].d", Some("a")), + ("a()", None), + ("'a'", None), + ] { + let Ok(expression) = Parser::new(&allocator, code, SourceType::tsx()).parse_expression() + else { + panic!("{code}"); + }; + assert_eq!( + root_reference(&expression).map(|identifier| identifier.name.as_str()), + root, + "{code}" + ); + } +} diff --git a/libs/extractor/src/extractor/extract_style_from_styled.rs b/libs/extractor/src/extractor/extract_style_from_styled.rs index ec4d3e61e..853908060 100644 --- a/libs/extractor/src/extractor/extract_style_from_styled.rs +++ b/libs/extractor/src/extractor/extract_style_from_styled.rs @@ -45,6 +45,9 @@ use oxc_syntax::operator::{BinaryOperator, LogicalOperator, UnaryOperator}; /// only the runtime gives that component const STYLED_BASE: &str = "DevupStyled"; +/// The Devup UI component an expression reads, by the bindings of the file +pub type Kinds<'k> = &'k dyn Fn(&Expression<'_>) -> Option; + const STYLED_FACTORY: &str = "it renders a tag, a component or a value naming one, with rule objects or CSS text"; @@ -554,12 +557,16 @@ fn inherited_parts<'a>( inherited .attrs .iter() - .map(|a| a.clone_in(allocator)) + .map(|a| a.clone_in_with_semantic_ids(allocator)) .collect(), ) }, ); - all_attrs.extend(attrs.iter().map(|attr| attr.clone_in(allocator))); + all_attrs.extend( + attrs + .iter() + .map(|attr| attr.clone_in_with_semantic_ids(allocator)), + ); (classes, all_attrs) } @@ -611,7 +618,10 @@ impl<'a> Base<'a> { .iter() .map(|style| style.clone_in(allocator)) .collect(), - attrs: attrs.iter().map(|attr| attr.clone_in(allocator)).collect(), + attrs: attrs + .iter() + .map(|attr| attr.clone_in_with_semantic_ids(allocator)) + .collect(), reads, forward, markers: Vec::new(), @@ -671,7 +681,7 @@ pub fn extended<'b, 'a>(expression: &'b Expression<'a>) -> Option<&'b Expression fn extract_base_tag_and_class_name<'a>( ast_builder: &AstBuilder<'a>, input: &Expression<'a>, - imports: &FxHashMap, + imports: Kinds<'_>, ) -> Option> { match unwrap_syntax_only(input) { Expression::StaticMemberExpression(member) => { @@ -692,7 +702,7 @@ fn extract_base_tag_and_class_name<'a>( fn tag_from_argument<'a>( ast_builder: &AstBuilder<'a>, argument: &Argument<'a>, - imports: &FxHashMap, + imports: Kinds<'_>, ) -> Option> { let argument = unwrap_syntax_only(argument.as_expression()?); match argument { @@ -704,9 +714,7 @@ fn tag_from_argument<'a>( Expression::Identifier(ident) if ident.name == "undefined" => return None, _ => {} } - if let Expression::Identifier(ident) = argument - && let Some(kind) = imports.get(ident.name.as_str()) - { + if let Some(kind) = imports(argument) { return Some(Base { styles: Some(kind.extract()), ..Base::named(kind.to_tag().to_string()) @@ -756,7 +764,7 @@ fn jsx_name(expression: &Expression<'_>) -> Option { fn resolve_styled_call_target<'a>( ast_builder: &AstBuilder<'a>, call: &CallExpression<'a>, - imports: &FxHashMap, + imports: Kinds<'_>, ) -> Option<(Base<'a>, usize)> { if call.arguments.len() == 1 && let Some(base) = extract_base_tag_and_class_name(ast_builder, &call.callee, imports) @@ -788,7 +796,7 @@ pub fn extract_style_from_styled<'a>( ast_builder: &AstBuilder<'a>, expression: &mut Expression<'a>, naming: Naming<'_>, - imports: &FxHashMap, + imports: Kinds<'_>, modifiers: (&[Expression<'a>], Option), inherited: Option<&StyledDefinition<'a>>, context: StyledContext<'_>, @@ -1202,7 +1210,7 @@ fn clone_all<'s, 'a>( ) -> impl Iterator> + 's { expressions .iter() - .map(|expression| expression.clone_in(ast_builder.allocator())) + .map(|expression| expression.clone_in_with_semantic_ids(ast_builder.allocator())) } /// The name the attrs wrapper binds props to, chosen not to shadow what the @@ -1215,7 +1223,7 @@ const ATTRS_PROPS: &str = "__devupProps"; pub fn take_styled_modifiers<'a>( ast_builder: &AstBuilder<'a>, factory: &mut Expression<'a>, - is_styled: impl Fn(&str) -> bool, + is_styled: impl Fn(&Expression<'_>) -> bool, ) -> (Vec>, Vec>) { let mut attrs = Vec::new(); let mut configs = Vec::new(); @@ -1286,7 +1294,10 @@ pub fn read_forward(options: &[&Expression<'_>]) -> (Option, Option<(u3 const SHOULD_FORWARD_PROP: &str = "`shouldForwardProp` must be a function of the prop name that compares it with strings, `[...].includes(prop)`, `prop.startsWith(...)` or `isPropValid(prop)`, joined by `!`, `&&` and `||`"; -fn is_modified_styled(expression: &Expression<'_>, is_styled: impl Fn(&str) -> bool) -> bool { +fn is_modified_styled( + expression: &Expression<'_>, + is_styled: impl Fn(&Expression<'_>) -> bool, +) -> bool { let mut expression = unwrap_syntax_only(expression); let mut modified = false; while let Some(object) = modifier_object(expression) { @@ -1296,10 +1307,10 @@ fn is_modified_styled(expression: &Expression<'_>, is_styled: impl Fn(&str) -> b modified && match expression { Expression::StaticMemberExpression(member) => { - matches!(&member.object, Expression::Identifier(ident) if is_styled(&ident.name)) + matches!(&member.object, object @ Expression::Identifier(_) if is_styled(object)) } Expression::CallExpression(call) => { - matches!(&call.callee, Expression::Identifier(ident) if is_styled(&ident.name)) + matches!(&call.callee, callee @ Expression::Identifier(_) if is_styled(callee)) } _ => false, } @@ -1365,7 +1376,11 @@ fn sets_plain_props(attr: &Expression<'_>) -> bool { fn spread_attrs<'a>(ast_builder: &AstBuilder<'a>, attrs: &[Expression<'a>]) -> Expression<'a> { let mut merged = identifier(ast_builder, ATTRS_PROPS); for attr in attrs { - merged = spread_objects(ast_builder, merged, attr.clone_in(ast_builder.allocator())); + merged = spread_objects( + ast_builder, + merged, + attr.clone_in_with_semantic_ids(ast_builder.allocator()), + ); } merged } @@ -1389,7 +1404,10 @@ fn merge_attrs<'a>(ast_builder: &AstBuilder<'a>, attrs: &[Expression<'a>]) -> Ex oxc_allocator::Vec::new_in(ast_builder), ast_builder, ), - resolve_attrs(ast_builder, attr.clone_in(ast_builder.allocator())), + resolve_attrs( + ast_builder, + attr.clone_in_with_semantic_ids(ast_builder.allocator()), + ), ); let merge = call_with_values( ast_builder, @@ -1439,7 +1457,7 @@ fn resolve_attrs<'a>(ast_builder: &AstBuilder<'a>, attr: Expression<'a>) -> Expr Expression::new_unary_expression( SPAN, UnaryOperator::Typeof, - attr.clone_in(ast_builder.allocator()), + attr.clone_in_with_semantic_ids(ast_builder.allocator()), ast_builder, ), BinaryOperator::StrictEquality, diff --git a/libs/extractor/src/extractor/extract_style_from_stylex.rs b/libs/extractor/src/extractor/extract_style_from_stylex.rs index fe63de62b..42bdc0f32 100644 --- a/libs/extractor/src/extractor/extract_style_from_stylex.rs +++ b/libs/extractor/src/extractor/extract_style_from_stylex.rs @@ -3,8 +3,9 @@ use crate::extract_style::extract_dynamic_style::ExtractDynamicStyle; use crate::extract_style::extract_static_style::ExtractStaticStyle; use crate::extract_style::extract_style_value::ExtractStyleValue; use crate::stylex::{ - DecomposedStyle, SelectorPart, StylexIncludeRef, decompose_value_conditions, - dynamic_number_suffix, is_include_call_static, normalize_stylex_property, stylex_value, + DecomposedStyle, SelectorPart, StylexIncludeRef, StylexResolver, decompose_value_conditions, + dynamic_number_suffix, is_first_that_works_call, is_include_call_static, is_types_call, + normalize_stylex_property, stylex_value, }; use css::optimize_value::optimize_value; use css::sheet_to_variable_name; @@ -46,6 +47,7 @@ pub fn extract_stylex_declarations( api: &str, object: &ObjectExpression<'_>, errors: &mut Vec<(u32, String)>, + resolver: StylexResolver<'_>, ) -> Vec<(String, String)> { let mut declarations = vec![]; for property in &object.properties { @@ -72,13 +74,33 @@ pub fn extract_stylex_declarations( Some(value) => declarations.push((name, optimize_value(&value).into_owned())), None => errors.push(( property.value.span().start, - runtime_value_error(api, &readable_code(&property.value)), + declaration_error(api, &property.value, resolver), )), } } declarations } +/// Why `value` is no declaration value: a genuine `StyleX` helper is read only +/// inside the API that takes it +fn declaration_error(api: &str, value: &Expression<'_>, resolver: StylexResolver<'_>) -> String { + let code = readable_code(value); + match value { + Expression::CallExpression(call) + if is_first_that_works_call(&call.callee, resolver) + || is_include_call_static(&call.callee, resolver) + || is_types_call(&call.callee, resolver) => + { + build_time_error( + api, + &code, + "its values must be literals; `firstThatWorks()`, `include()` and `types` are read only inside `stylex.create()` and `stylex.defineVars()`", + ) + } + _ => runtime_value_error(api, &code), + } +} + /// Resolve a `vars.key` member access against the contracts `stylex.defineVars()` /// produced, yielding the `var(--x)` reference the value compiles to. fn var_reference<'v>( @@ -137,6 +159,7 @@ pub fn extract_stylex_namespace_styles<'a>( keyframe_names: &FxHashMap, var_refs: &FxHashMap, errors: &mut Vec<(u32, String)>, + resolver: StylexResolver<'_>, ) -> Vec<( String, Vec>, @@ -188,7 +211,7 @@ pub fn extract_stylex_namespace_styles<'a>( } Expression::ObjectExpression(ns_obj) => { let (styles, include_refs, groups) = - extract_stylex_namespace(ns_obj, &leaf, errors); + extract_stylex_namespace(ns_obj, &leaf, errors, resolver); result.push((ns_name, styles, None, include_refs, groups)); } Expression::NullLiteral(_) => result.push((ns_name, vec![], None, vec![], vec![])), @@ -212,6 +235,7 @@ fn extract_stylex_namespace<'a>( namespace: &ObjectExpression<'_>, leaf: &Leaf<'_>, errors: &mut Vec<(u32, String)>, + resolver: StylexResolver<'_>, ) -> ( Vec>, Vec, @@ -224,7 +248,7 @@ fn extract_stylex_namespace<'a>( let style_prop = match style_prop { ObjectPropertyKind::ObjectProperty(style_prop) => style_prop, ObjectPropertyKind::SpreadProperty(spread) => { - match include(spread) { + match include(spread, resolver) { Some(Ok(include_ref)) => include_refs.push(include_ref), Some(Err(error)) => errors.push(error), None => errors.push(spread_error("stylex.create", spread)), @@ -280,6 +304,7 @@ fn extract_stylex_namespace<'a>( &parent_selectors, leaf, errors, + resolver, ), ); } @@ -303,7 +328,14 @@ fn extract_stylex_namespace<'a>( let before = styles.len(); push_decomposed( &mut styles, - decompose_value_conditions(&css_property, &style_prop.value, &[], leaf, errors), + decompose_value_conditions( + &css_property, + &style_prop.value, + &[], + leaf, + errors, + resolver, + ), ); groups.push((prop_name.to_string(), styles.len() - before)); } @@ -320,11 +352,14 @@ fn push_decomposed(styles: &mut Vec>, decomposed: Vec) -> Option> { +fn include( + spread: &SpreadElement<'_>, + resolver: StylexResolver<'_>, +) -> Option> { let Expression::CallExpression(call) = &spread.argument else { return None; }; - if !is_include_call_static(&call.callee) { + if !is_include_call_static(&call.callee, resolver) { return None; } if let Some(Expression::StaticMemberExpression(member)) = diff --git a/libs/extractor/src/import_alias_scoping_tests.rs b/libs/extractor/src/import_alias_scoping_tests.rs new file mode 100644 index 000000000..fae3e4d79 --- /dev/null +++ b/libs/extractor/src/import_alias_scoping_tests.rs @@ -0,0 +1,253 @@ +use super::*; + +fn aliases() -> HashMap { + HashMap::from([ + (EMOTION_REACT.into(), ImportAlias::NamedToNamed), + ( + "@emotion/styled".into(), + ImportAlias::DefaultToNamed("styled".into()), + ), + ( + "styled-components".into(), + ImportAlias::DefaultToNamed("styled".into()), + ), + ("@vanilla-extract/css".into(), ImportAlias::NamedToNamed), + ]) +} + +#[test] +fn numbers_stay_numeric_when_api_bindings_are_shadowed() { + for (import, name, call) in [ + ( + "import { css } from '@emotion/react';", + "css", + "css({ top: 2 })", + ), + ( + "import { keyframes } from 'styled-components';", + "keyframes", + "keyframes({ from: { top: 2 } })", + ), + ( + "import { style as s } from '@vanilla-extract/css';", + "s", + "s({ top: 2 })", + ), + ( + "import { globalStyle } from '@vanilla-extract/css';", + "globalStyle", + "globalStyle('body', { top: 2 })", + ), + ( + "import styled from '@emotion/styled';", + "styled", + "styled.div({ top: 2 })", + ), + ( + "import * as styled from 'styled-components';", + "styled", + "styled('div').attrs({})({ top: 2 })", + ), + ] { + let code = format!("{import}\n{call}; function local({name}) {{ {call}; }}"); + let output = transform_import_aliases(&code, "test.tsx", "@devup-ui/react", &aliases()); + assert!( + output.contains(&format!("function local({name}) {{ {call}; }}")), + "{output}" + ); + assert!(output.contains("top: \"2px\""), "{output}"); + } +} + +#[test] +fn global_styles_stay_numeric_when_component_binding_is_shadowed() { + let code = "import { Global as G } from '@emotion/react';\nconst real = ; function local(G) { return ; }"; + let output = transform_import_aliases(code, "test.tsx", "@devup-ui/react", &aliases()); + assert!(output.contains("const real = ")); + assert!(output.contains("function local(G) { return ; }")); +} + +#[test] +fn jsx_props_stay_numeric_when_factory_binding_is_shadowed() { + for import in [ + "import { jsx as h } from '@emotion/react';", + "import { jsx as h } from 'react/jsx-runtime';", + ] { + let code = format!( + "{import}\nconst real = h('div', {{ css: {{ top: 2 }} }}); function local(h) {{ return h('div', {{ css: {{ top: 2 }} }}); }}" + ); + let output = transform_import_aliases(&code, "test.tsx", "@devup-ui/react", &aliases()); + assert!(output.contains("const real = h('div', { css: { top: \"2px\" } })")); + assert!(output.contains("function local(h) { return h('div', { css: { top: 2 } }); }")); + } +} + +#[test] +fn devup_css_props_stay_numeric_when_component_root_is_shadowed() { + for (import, root, tag, ty) in [ + ("import { Box as B } from '@devup-ui/react';", "B", "B", "B"), + ( + "import * as UI from '@devup-ui/react';", + "UI", + "UI.Box", + "UI.Box", + ), + ] { + let code = format!( + "{import}\nimport {{ jsx as h }} from 'react/jsx-runtime';\nconst real = <{tag} css={{{{ top: 2 }}}} />; const made = h({ty}, {{ css: {{ top: 2 }} }}); function local({root}) {{ return [<{tag} css={{{{ top: 2 }}}} />, h(({ty}), {{ css: {{ top: 2 }} }})]; }}" + ); + let output = transform_import_aliases(&code, "test.tsx", "@devup-ui/react", &aliases()); + assert!(output.contains(&format!("function local({root}) {{ return [<{tag} css={{{{ top: 2 }}}} />, h(({ty}), {{ css: {{ top: 2 }} }})]; }}")), "{output}"); + assert_eq!(output.matches("\"2px\"").count(), 2, "{output}"); + } +} + +#[test] +fn class_names_css_uses_child_binding_identity_when_nested_locals_shadow_it() { + for (child, pixels) in [ + ( + "({ cx, css: rule }) => <>{rule({ top: 2 })}{((rule) => rule({ top: 3 }))(other)}{({ css: rule }) => rule({ top: 4 })}{rule({ top: 5 })}", + 3, + ), + ( + "function ({ css: rule }) { return <>{rule({ top: 2 })}{((rule) => rule({ top: 3 }))(other)}; }", + 1, + ), + ] { + let code = format!( + "import {{ ClassNames as C }} from '@emotion/react';\nconst real = {{{child}}}; function local(C) {{ return {{({{ css: rule }}) => rule({{ top: 6 }})}}; }}" + ); + let output = transform_import_aliases(&code, "test.tsx", "@devup-ui/react", &aliases()); + assert!(output.contains("rule({ top: \"2px\" })"), "{output}"); + assert!(output.contains("rule({ top: 3 })"), "{output}"); + assert!(output.contains("rule({ top: 6 })"), "{output}"); + assert_eq!(output.matches("px\"").count(), pixels, "{output}"); + } +} + +#[test] +fn type_only_bindings_do_not_register_number_apis() { + for import in [ + "import type { css, Global, ClassNames, jsx } from '@emotion/react';", + "import { type css, type Global, type ClassNames, type jsx } from '@emotion/react';", + "import type styled from '@emotion/styled';", + "import type * as styled from 'styled-components';", + ] { + let body = "css({ top: 2 }); styled.div({ top: 2 }); jsx('div', { css: { top: 2 } }); const a = ; const b = {({ css }) => css({ top: 2 })};"; + let code = format!("{import}\n{body}"); + let output = transform_import_aliases(&code, "test.tsx", "@devup-ui/react", &aliases()); + assert!(output.ends_with(body), "{output}"); + } +} + +#[test] +fn named_styled_binding_pixelifies_chains_without_touching_block_locals() { + let code = "import { styled as s } from 'styled-components';\nconst real = s('div').withConfig({}).attrs({})({ top: 2 }); { const s = other; s.div({ top: 3 }); }"; + let output = transform_import_aliases(code, "test.tsx", "@devup-ui/react", &aliases()); + assert!(output.contains("attrs({})({ top: \"2px\" })")); + assert!(output.ends_with("{ const s = other; s.div({ top: 3 }); }")); +} + +#[test] +fn malformed_class_names_children_do_not_register_css_calls() { + for child in [ + "text", + "{other}", + "{2}", + "{'2'}", + "{({ css }) => css({ top: 2 })}{other}", + "{() => css({ top: 2 })}", + "{(rules) => css({ top: 2 })}", + "{({ cx }) => css({ top: 2 })}", + "{({ css, unknown }) => css({ top: 2 })}", + "{({ css = other }) => css({ top: 2 })}", + ] { + let code = format!( + "import {{ ClassNames as C }} from '@emotion/react';\nconst view = {child};" + ); + let output = transform_import_aliases(&code, "test.tsx", "@devup-ui/react", &aliases()); + assert!( + output.ends_with(&format!("const view = {child};")), + "{output}" + ); + } +} + +#[test] +fn type_only_devup_components_do_not_take_css_props() { + for import in [ + "import type { Box as B } from '@devup-ui/react';", + "import { type Box as B } from '@devup-ui/react';", + ] { + let code = format!("{import}\nconst view = ;"); + let output = transform_import_aliases(&code, "test.tsx", "@devup-ui/react", &aliases()); + assert_eq!(output, code); + } +} + +#[test] +fn number_edits_map_back_to_original_offsets_when_imports_are_rewritten() { + let code = "import { css as c } from '@emotion/react';\nc({ top: 23 }); function local(c) { c({ top: 24 }); }"; + let output = + transform_import_aliases_with_edits(code, "test.tsx", "@devup-ui/react", &aliases()); + let Some(pixel) = output.code.find("\"23px\"") else { + panic!("missing pixelified number: {}", output.code); + }; + let Some(original_pixel) = code.find("23") else { + panic!("missing original number"); + }; + assert_eq!(source_offset(&output.edits, pixel + 3), original_pixel); + let Some(local) = output.code.find("24") else { + panic!("missing local number: {}", output.code); + }; + let Some(original_local) = code.find("24") else { + panic!("missing original local number"); + }; + assert_eq!(source_offset(&output.edits, local), original_local); +} + +#[test] +fn devup_root_guards_follow_nested_members_but_not_other_element_types() { + let code = "import * as UI from '@devup-ui/react'; import { jsx as h } from 'react/jsx-runtime'; const real = [, h(UI['Box'], { css: { top: 2 } })]; const other = [, h(other(), { css: { top: 3 } })]; function local(UI) { return h(UI['Box'], { css: { top: 3 } }); }"; + let output = transform_import_aliases(code, "test.tsx", "@devup-ui/react", &aliases()); + assert_eq!(output.matches("\"2px\"").count(), 2, "{output}"); + assert_eq!(output.matches("top: 3").count(), 3, "{output}"); +} + +#[test] +fn inline_types_keep_modifiers_when_other_specifiers_are_retained() { + let code = "import { styleVariants, type style as Rule, style as s, type keyframes } from '@vanilla-extract/css';\ns({ top: 2 }); Rule({ top: 3 }); keyframes({ from: { top: 4 } });"; + let output = transform_import_aliases(code, "test.tsx", "@devup-ui/react", &aliases()); + assert_eq!( + output, + "import { css as s } from '@devup-ui/react'; import { styleVariants, type style as Rule, type keyframes } from '@vanilla-extract/css';\ns({ top: \"2px\" }); Rule({ top: 3 }); keyframes({ from: { top: 4 } });" + ); +} + +#[test] +fn nested_member_factory_css_uses_the_imported_namespace_binding() { + let code = "import * as UI from '@devup-ui/react'; import { jsx as h } from 'react/jsx-runtime';\nconst real = h((UI.parts.Box), { css: { top: 2 } }); function local(UI) { return h((UI.parts.Box), { css: { top: 3 } }); }"; + let output = transform_import_aliases(code, "test.tsx", "@devup-ui/react", &aliases()); + assert_eq!(output, code.replace("top: 2", "top: \"2px\"")); +} + +#[test] +fn global_styles_rewrite_conditional_rules_without_rewriting_shadowed_styles() { + let code = "import { Global as G } from '@emotion/react';\nconst real = ; function local(G) { return ; }"; + let output = transform_import_aliases(code, "test.tsx", "@devup-ui/react", &aliases()); + assert!(output.contains("styles={flag ? { top: \"2px\" } : { left: \"3px\" }} css={flag ? { right: \"4px\" } : { bottom: \"5px\" }}"), "{output}"); + assert!( + output.ends_with( + "function local(G) { return ; }" + ), + "{output}" + ); +} + +#[test] +fn class_names_css_binding_is_found_after_whitespace_children() { + let code = "import { ClassNames as C } from '@emotion/react';\nconst view = \n {({ css }) => css({ top: 2 })}\n;"; + let output = transform_import_aliases(code, "test.tsx", "@devup-ui/react", &aliases()); + assert!(output.contains("css({ top: \"2px\" })"), "{output}"); + assert!(!output.contains("css({ top: 2 })"), "{output}"); +} diff --git a/libs/extractor/src/import_alias_visit.rs b/libs/extractor/src/import_alias_visit.rs index 117d84d50..1810c6520 100644 --- a/libs/extractor/src/import_alias_visit.rs +++ b/libs/extractor/src/import_alias_visit.rs @@ -10,25 +10,28 @@ use crate::ImportAlias; use crate::css_prop::{ CssProp, EMOTION_REACT, builds_jsx_with_emotion, class_names_child, is_emotion, is_jsx_file, - is_jsx_function, react_runtime, returned, + is_jsx_function, react_runtime, returned, root_reference, }; use crate::utils::{ get_str_by_property_key, is_vanilla_extract_file, js_number_literal, keeps_bare_number, }; use oxc_allocator::Allocator; use oxc_ast::ast::{ - Argument, ArrowFunctionBody, CallExpression, Expression, ImportDeclarationSpecifier, - JSXAttributeItem, JSXAttributeValue, JSXElement, JSXElementName, JSXOpeningElement, - LogicalOperator, ModuleExportName, ObjectPropertyKind, Statement, + Argument, ArrowFunctionBody, BindingIdentifier, CallExpression, Expression, + IdentifierReference, ImportDeclarationSpecifier, JSXAttributeItem, JSXAttributeValue, + JSXElement, JSXElementName, JSXOpeningElement, LogicalOperator, ModuleExportName, + ObjectPropertyKind, Statement, }; use oxc_ast_visit::{ Visit, walk::{walk_call_expression, walk_jsx_element, walk_jsx_opening_element}, }; use oxc_parser::Parser; +use oxc_semantic::{Scoping, SemanticBuilder}; use oxc_span::GetSpan; #[cfg(test)] use oxc_span::SourceType; +use oxc_syntax::symbol::SymbolId; use std::borrow::Cow; use std::collections::HashMap; @@ -43,31 +46,42 @@ enum RulesAt { /// outside a stylesheet. Those calls become Devup UI's, which read a number as its /// spacing scale, so it is rewritten to the `px` string the library makes of it. #[derive(Default)] -struct LibraryNumbers<'n> { - /// Local name of each style function and where its rules are - calls: Vec<(&'n str, RulesAt)>, - /// Local names that build styled components - styled: Vec<&'n str>, - /// Local names of components taking rules in `styles` (Emotion's `Global`) - components: Vec<&'n str>, +struct LibraryNumbers { + /// Import binding of each style function and where its rules are + calls: Vec<(SymbolId, RulesAt)>, + /// Import bindings that build styled components + styled: Vec, + /// Import bindings taking rules in `styles` (Emotion's `Global`) + components: Vec, /// Which elements take the `css` prop, whose rules are Emotion's css_prop: CssProp, - /// Local names of Devup UI's exports, which take the `css` prop as tags do - devup: Vec<&'n str>, - /// Local names of the functions building elements from a type and props - jsx: Vec<&'n str>, - /// Local names of Emotion's `ClassNames`, whose child function takes a + /// Import bindings of Devup UI's exports, which take the `css` prop as tags do + devup: Vec, + /// Import bindings of the functions building elements from a type and props + jsx: Vec, + /// Import bindings of Emotion's `ClassNames`, whose child function takes a /// `css` taking rules - class_names: Vec<&'n str>, - class_names_calls: Vec, - scoping: Option, + class_names: Vec, + class_names_calls: Vec, + scoping: Scoping, replacements: Vec<(usize, usize, String)>, /// Where a constant inlined later is a number or rules of the library, as /// the source reads them marks: Vec<(usize, usize)>, } -impl LibraryNumbers<'_> { +impl LibraryNumbers { + fn symbol(&self, reference: &IdentifierReference<'_>) -> Option { + self.scoping + .get_reference(reference.reference_id.get()?) + .symbol_id() + } + + fn contains(&self, symbols: &[SymbolId], reference: &IdentifierReference<'_>) -> bool { + self.symbol(reference) + .is_some_and(|symbol| symbols.contains(&symbol)) + } + fn mark(expression: &Expression) -> (usize, usize) { let span = expression.span(); (span.start as usize, span.end as usize) @@ -78,10 +92,10 @@ impl LibraryNumbers<'_> { fn is_styled_factory(&self, callee: &Expression) -> bool { match callee { Expression::StaticMemberExpression(member) => { - matches!(&member.object, Expression::Identifier(root) if self.styled.contains(&root.name.as_str())) + matches!(&member.object, Expression::Identifier(root) if self.contains(&self.styled, root)) } Expression::CallExpression(call) => match &call.callee { - Expression::Identifier(root) => self.styled.contains(&root.name.as_str()), + Expression::Identifier(root) => self.contains(&self.styled, root), Expression::StaticMemberExpression(member) => { matches!(member.property.name.as_str(), "attrs" | "withConfig") && self.is_styled_factory(&member.object) @@ -226,7 +240,7 @@ impl LibraryNumbers<'_> { } } -impl LibraryNumbers<'_> { +impl LibraryNumbers { /// `key: [a, b]` in these libraries declares `key` once per value, later /// ones as fallbacks a browser keeps when it understands them, where Devup /// UI reads an array as breakpoints. Literal values become one value @@ -287,17 +301,10 @@ fn kebab_property(key: &str) -> String { property } -impl<'a> Visit<'a> for LibraryNumbers<'a> { +impl<'a> Visit<'a> for LibraryNumbers { fn visit_jsx_element(&mut self, element: &JSXElement<'a>) { let css = match &element.opening_element.name { - JSXElementName::IdentifierReference(name) - if self.class_names.contains(&name.name.as_str()) - && self.scoping.as_ref().is_some_and(|scoping| { - crate::css_prop::binding_of(scoping, name).is_some_and(|symbol| { - scoping.get_root_binding(name.name.as_str().into()) == Some(symbol) - }) - }) => - { + JSXElementName::IdentifierReference(name) if self.contains(&self.class_names, name) => { class_names_child(element).and_then(|names| names.css) } _ => None, @@ -313,27 +320,16 @@ impl<'a> Visit<'a> for LibraryNumbers<'a> { fn visit_call_expression(&mut self, call: &CallExpression<'a>) { let rules_at = match &call.callee { - Expression::Identifier(callee) => { - let symbol = self - .scoping - .as_ref() - .and_then(|scoping| crate::css_prop::binding_of(scoping, callee)); - if symbol.is_some_and(|symbol| self.class_names_calls.contains(&symbol)) { + Expression::Identifier(callee) => self.symbol(callee).and_then(|symbol| { + if self.class_names_calls.contains(&symbol) { Some(RulesAt::EveryArgument) } else { self.calls .iter() - .find(|(name, _)| { - *name == callee.name.as_str() - && self.scoping.as_ref().is_none_or(|scoping| { - symbol.is_some() - && scoping.get_root_binding(callee.name.as_str().into()) - == symbol - }) - }) + .find(|(binding, _)| *binding == symbol) .map(|(_, rules_at)| *rules_at) } - } + }), callee => self .is_styled_factory(callee) .then_some(RulesAt::EveryArgument), @@ -356,12 +352,13 @@ impl<'a> Visit<'a> for LibraryNumbers<'a> { self.pixelify(rules); } if let Expression::Identifier(callee) = &call.callee - && self.jsx.contains(&callee.name.as_str()) + && self.contains(&self.jsx, callee) && let [element, Argument::ObjectExpression(props), ..] = call.arguments.as_slice() && let Some(element) = element.as_expression() - && self - .css_prop - .takes_type(element, |root| self.devup.contains(&root)) + && self.css_prop.takes_type(element, |_| { + root_reference(crate::utils::unwrap_syntax_only(element)) + .is_some_and(|root| self.contains(&self.devup, root)) + }) { for property in &props.properties { if let ObjectPropertyKind::ObjectProperty(property) = property @@ -377,14 +374,14 @@ impl<'a> Visit<'a> for LibraryNumbers<'a> { fn visit_jsx_opening_element(&mut self, element: &JSXOpeningElement<'a>) { if let JSXElementName::IdentifierReference(name) = &element.name - && self.components.contains(&name.name.as_str()) + && self.contains(&self.components, name) { self.pixelify_attribute(element, "styles"); } - if self - .css_prop - .takes(&element.name, |root| self.devup.contains(&root)) - { + if self.css_prop.takes(&element.name, |_| { + crate::imported_constants::jsx_root_identifier(&element.name) + .is_some_and(|root| self.contains(&self.devup, root)) + }) { self.pixelify_attribute(element, "css"); } walk_jsx_opening_element(self, element); @@ -558,6 +555,18 @@ pub fn transform_import_aliases_with_edits<'a>( // Collect import transformations let mut transformations: Vec<(usize, usize, String)> = Vec::new(); + let mut scoping = None; + let may_take_css = CssProp::of(import_aliases, code, false) != CssProp::Off; + let mut symbol = |local: &BindingIdentifier<'_>| { + scoping.get_or_insert_with(|| { + SemanticBuilder::new() + .build(&program) + .semantic + .into_scoping() + }); + local.symbol_id.get() + }; + if emotion_aliased { transformations.extend(crate::jsx_import_source::emotion_replacements(&program)); } @@ -573,21 +582,33 @@ pub fn transform_import_aliases_with_edits<'a>( for stmt in &program.body { if let Statement::ImportDeclaration(import_decl) = stmt { + if import_decl.import_kind.is_type() { + continue; + } let source_value = import_decl.source.value.as_str(); - uses_emotion |= is_emotion(source_value); + uses_emotion |= is_emotion(source_value) + && import_decl.specifiers.as_ref().is_none_or(|specifiers| { + specifiers.iter().any(|specifier| { + !matches!(specifier, ImportDeclarationSpecifier::ImportSpecifier(spec) if spec.import_kind.is_type()) + }) + }); for specifier in import_decl.specifiers.iter().flatten() { + if matches!(specifier, ImportDeclarationSpecifier::ImportSpecifier(spec) if spec.import_kind.is_type()) + { + continue; + } match specifier { ImportDeclarationSpecifier::ImportSpecifier(spec) if is_jsx_function( source_value, imported_name(&spec.imported), &compat, - ) => + ) && may_take_css => { - numbers.jsx.push(spec.local.name.as_str()); + numbers.jsx.extend(symbol(&spec.local)); } - specifier if source_value == package => { - numbers.devup.push(specifier.local().name.as_str()); + specifier if source_value == package && may_take_css => { + numbers.devup.extend(symbol(specifier.local())); } _ => {} } @@ -609,25 +630,40 @@ pub fn transform_import_aliases_with_edits<'a>( if !redirect_every_name { for specifier in import_decl.specifiers.iter().flatten() { match specifier { - ImportDeclarationSpecifier::ImportSpecifier(spec) => { - let local = spec.local.name.as_str(); + ImportDeclarationSpecifier::ImportSpecifier(spec) + if !spec.import_kind.is_type() => + { match (source_value, imported_name(&spec.imported)) { ("@vanilla-extract/css", "style" | "keyframes") => { - numbers.calls.push((local, RulesAt::Argument(0))); + numbers.calls.extend( + symbol(&spec.local) + .map(|local| (local, RulesAt::Argument(0))), + ); } ("@vanilla-extract/css", "globalStyle") => { - numbers.calls.push((local, RulesAt::Argument(1))); + numbers.calls.extend( + symbol(&spec.local) + .map(|local| (local, RulesAt::Argument(1))), + ); } ( "@emotion/react" | "styled-components", "css" | "keyframes", ) | ("@emotion/css", "css" | "keyframes" | "injectGlobal") => { - numbers.calls.push((local, RulesAt::EveryArgument)); + numbers.calls.extend( + symbol(&spec.local) + .map(|local| (local, RulesAt::EveryArgument)), + ); + } + ("@emotion/react", "Global") => { + numbers.components.extend(symbol(&spec.local)); } - ("@emotion/react", "Global") => numbers.components.push(local), ("@emotion/react", "ClassNames") => { - numbers.class_names.push(local); + numbers.class_names.extend(symbol(&spec.local)); + } + ("@emotion/styled" | "styled-components", "styled") => { + numbers.styled.extend(symbol(&spec.local)); } _ => {} } @@ -638,7 +674,7 @@ pub fn transform_import_aliases_with_edits<'a>( "@emotion/styled" | "styled-components" ) => { - numbers.styled.push(spec.local.name.as_str()); + numbers.styled.extend(symbol(&spec.local)); } ImportDeclarationSpecifier::ImportNamespaceSpecifier(spec) if matches!( @@ -646,7 +682,7 @@ pub fn transform_import_aliases_with_edits<'a>( "@emotion/styled" | "styled-components" ) => { - numbers.styled.push(spec.local.name.as_str()); + numbers.styled.extend(symbol(&spec.local)); } _ => {} } @@ -655,17 +691,8 @@ pub fn transform_import_aliases_with_edits<'a>( } } } + numbers.scoping = scoping.unwrap_or_default(); numbers.css_prop = CssProp::of(import_aliases, code, uses_emotion); - if !numbers.class_names.is_empty() - && (code.bytes().any(|byte| byte.is_ascii_digit()) || code.contains('[')) - { - numbers.scoping = Some( - oxc_semantic::SemanticBuilder::new() - .build(&program) - .semantic - .into_scoping(), - ); - } if !(numbers.calls.is_empty() && numbers.styled.is_empty() && numbers.components.is_empty() @@ -730,26 +757,33 @@ pub(super) fn pixelify_emotion_values<'a>( let original_program = Parser::new(&allocator, original, source_type) .parse() .program; - let mut numbers = LibraryNumbers::default(); + let program = Parser::new(&allocator, code, source_type).parse().program; + let mut numbers = LibraryNumbers { + scoping: SemanticBuilder::new() + .build(&program) + .semantic + .into_scoping(), + ..LibraryNumbers::default() + }; for statement in &original_program.body { if let Statement::ImportDeclaration(import) = statement && import.source.value == "@emotion/css" + && !import.import_kind.is_type() { for specifier in import.specifiers.iter().flatten() { if let ImportDeclarationSpecifier::ImportSpecifier(named) = specifier + && !named.import_kind.is_type() && matches!( named.imported.name().as_str(), "css" | "keyframes" | "injectGlobal" ) + && let Some(symbol) = numbers.scoping.get_root_binding(named.local.name) { - numbers - .calls - .push((named.local.name.as_str(), RulesAt::EveryArgument)); + numbers.calls.push((symbol, 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 @@ -941,6 +975,11 @@ fn generate_transformed_import( } } + let retains_values = retained_default.is_some() || specifiers.iter().any(|specifier| { + matches!(specifier, ImportDeclarationSpecifier::ImportSpecifier(spec) + if !spec.import_kind.is_type() + && redirect_target(source, imported_name(&spec.imported), redirect_every_name).is_none()) + }); // Handle named specifiers (kept as-is for both variants) for specifier in specifiers { if let ImportDeclarationSpecifier::ImportSpecifier(spec) = specifier { @@ -949,7 +988,17 @@ fn generate_transformed_import( // Types are erased, so they stay on the package without making it a // runtime dependency if spec.import_kind.is_type() { - push_specifier(&mut types, &written, local); + if retains_values { + if !retained.is_empty() { + retained.push_str(", "); + } + retained.push_str("type "); + let mut named = String::new(); + push_specifier(&mut named, &written, local); + retained.push_str(&named); + } else { + push_specifier(&mut types, &written, local); + } continue; } // A stylesheet passes every export through as written @@ -1019,6 +1068,10 @@ fn generate_transformed_import( result } +#[cfg(test)] +#[path = "import_alias_scoping_tests.rs"] +mod scoping_tests; + #[cfg(test)] mod tests { use super::*; diff --git a/libs/extractor/src/imported_constants.rs b/libs/extractor/src/imported_constants.rs index 5c69a2041..89fc2d516 100644 --- a/libs/extractor/src/imported_constants.rs +++ b/libs/extractor/src/imported_constants.rs @@ -2,14 +2,15 @@ //! module declares with `const` becomes a static class instead of a CSS //! variable set at runtime. -use std::cell::Cell; +use std::cell::{Cell, OnceCell}; use std::collections::BTreeSet; use std::rc::Rc; use oxc_allocator::{Allocator, FromIn, GetAllocator}; use oxc_ast::ast::{ - Argument, ArrayExpressionElement, Expression, ImportDeclarationSpecifier, JSXAttributeItem, - JSXElementName, ObjectPropertyKind, Program, Statement, Str, VariableDeclarationKind, + Argument, ArrayExpressionElement, Expression, IdentifierReference, ImportDeclarationSpecifier, + JSXAttributeItem, JSXElementName, ObjectPropertyKind, Program, Statement, Str, + VariableDeclarationKind, }; use oxc_ast::builder::AstBuilder; use oxc_ast_visit::{Visit, VisitMut, walk, walk_mut}; @@ -22,7 +23,7 @@ use oxc_syntax::symbol::SymbolId; use rustc_hash::{FxHashMap, FxHashSet}; use crate::composition::{Composition, set_prop_order}; -use crate::css_prop::{CssProp, CssTakers, binding_of}; +use crate::css_prop::{CssProp, CssTakers, binding_of, reads_top_level, root_reference}; use crate::extractor::ExtractResult; use crate::extractor::extract_style_from_expression::{ LiteralHandling, extract_style_from_expression, @@ -218,6 +219,9 @@ pub(crate) struct Inlined { pub css_failures: Vec, pub unknown: Unknown, pub changed: Changed, + /// The semantic analysis of the program as parsed, so the visitor reuses + /// it: constants inlined later leave the ids of surviving references as + /// they are. `None` when no analysis was needed pub scoping: Option>, } @@ -235,11 +239,17 @@ impl Changed { } /// Whether `expression` (`x`, `x.y` or `x[y]`) reads, whole or in part, an - /// object or array code changes - pub(crate) fn read_by(&self, expression: &Expression<'_>) -> bool { + /// object or array code changes, counting only the identifiers + /// `reads_binding` accepts, so a local named like a binding of the module + /// does not + pub(crate) fn read_by_in( + &self, + expression: &Expression<'_>, + reads_binding: &dyn Fn(&IdentifierReference<'_>) -> bool, + ) -> bool { let mut path = Vec::new(); let mut expression = expression; - let name = loop { + let identifier = loop { match expression { Expression::StaticMemberExpression(member) => { path.push(Some(member.property.name.as_str())); @@ -249,10 +259,14 @@ impl Changed { path.push(None); expression = &member.object; } - Expression::Identifier(identifier) => break identifier.name.as_str(), + Expression::Identifier(identifier) => break identifier, _ => return false, } }; + let name = identifier.name.as_str(); + if !reads_binding(identifier) { + return false; + } if self.whole.contains_key(name) { return true; } @@ -315,6 +329,16 @@ impl Unknown { /// Whether `expression` (`x`, `x.y.z`, `x[y]` or a call of one) reads what /// only running the module gives pub(crate) fn read_by(&self, expression: &Expression<'_>) -> bool { + self.read_by_in(expression, &|_| true) + } + + /// [`Self::read_by`] counting only the identifiers `reads_binding` + /// accepts, so a local named like a binding of the module does not + pub(crate) fn read_by_in( + &self, + expression: &Expression<'_>, + reads_binding: &dyn Fn(&IdentifierReference<'_>) -> bool, + ) -> bool { let mut path = Vec::new(); let mut expression = expression; loop { @@ -324,12 +348,19 @@ impl Unknown { expression = &member.object; } Expression::ComputedMemberExpression(member) => { - return crate::utils::binding_root(&member.object).is_some_and(|name| { - self.names.contains(name) || self.partial.contains_key(name) + return root_reference(&member.object).is_some_and(|identifier| { + reads_binding(identifier) + && (self.names.contains(identifier.name.as_str()) + || self.partial.contains_key(identifier.name.as_str())) }); } - Expression::CallExpression(call) => return self.read_by(&call.callee), + Expression::CallExpression(call) => { + return self.read_by_in(&call.callee, reads_binding); + } Expression::Identifier(identifier) => { + if !reads_binding(identifier) { + return false; + } let name = identifier.name.as_str(); if self.names.contains(name) { return true; @@ -368,7 +399,9 @@ enum Imported { } /// Inline the primitive constants `program` reads in styles, its own -/// module-level `const`s and those it imports, returning the files read +/// module-level `const`s and those it imports, returning the files read. +/// A program with no style import and no `css` prop is left as it is, with no +/// semantic analysis. pub(crate) fn inline_constants<'a>( ast_builder: &AstBuilder<'a>, program: &mut Program<'a>, @@ -378,66 +411,89 @@ pub(crate) fn inline_constants<'a>( css_prop: CssProp, px: &crate::import_alias_visit::PxMarks, ) -> Inlined { - let is_style_package = - |source: &str| source.starts_with(&option.package) || source == crate::STYLEX_PACKAGE; - let mut style_roots = FxHashSet::default(); - let mut apis = StyleApis::default(); + let imports_style_package = program.body.iter().any(|statement| { + matches!(statement, Statement::ImportDeclaration(import) + if is_style_package(option, &import.source.value)) + }); + if !imports_style_package && css_prop == CssProp::Off { + return Inlined::default(); + } + let scoping = Rc::new( + SemanticBuilder::new() + .build(program) + .semantic + .into_scoping(), + ); + let mut inlined = inline_in( + &scoping, + ast_builder, + program, + filename, + option, + resolver, + (css_prop, px), + ); + inlined.scoping = Some(scoping); + inlined +} + +fn is_style_package(option: &ExtractOption, source: &str) -> bool { + source.starts_with(&option.package) || source == crate::STYLEX_PACKAGE +} + +fn inline_in<'a>( + scoping: &Scoping, + ast_builder: &AstBuilder<'a>, + program: &mut Program<'a>, + filename: &str, + option: &ExtractOption, + resolver: Option<&ModuleResolver>, + css_input: (CssProp, &crate::import_alias_visit::PxMarks), +) -> Inlined { + let (css_prop, px) = css_input; let compat = format!("{}/compat", option.package); - let css_props = CssTakers::new(program, css_prop, &compat); + let css_props = CssTakers::new(program, scoping, css_prop, &compat); + let mut style = StyleSymbols::new(scoping); for statement in &program.body { if let Statement::ImportDeclaration(import) = statement - && is_style_package(&import.source.value) + && is_style_package(option, &import.source.value) { let stylex = import.source.value == crate::STYLEX_PACKAGE; for specifier in import.specifiers.iter().flatten() { - let local = specifier.local().name.as_str(); if matches!(specifier, ImportDeclarationSpecifier::ImportSpecifier(specifier) if specifier.imported.name() == "ClassNames" && import.source.value == compat.as_str()) { continue; } - style_roots.insert(local); - match specifier { - ImportDeclarationSpecifier::ImportSpecifier(specifier) => { - if !stylex && specifier.imported.name() == "css" { - apis.css.insert(local); + if let Some(local) = specifier.local().symbol_id.get() { + style.roots.insert(local); + match specifier { + ImportDeclarationSpecifier::ImportSpecifier(specifier) => { + if !stylex && specifier.imported.name() == "css" { + style.css.insert(local); + } + if takes_style_objects(stylex, &specifier.imported.name()) { + style.functions.insert(local); + } } - if takes_style_objects(stylex, &specifier.imported.name()) { - apis.functions.insert(local); + _ => { + style.namespaces.insert(local, stylex); } } - _ => { - apis.namespaces.insert(local, stylex); - } } } } } - if style_roots.is_empty() && css_prop == CssProp::Off && !css_props.has_class_names() { - return Inlined::default(); - } - let mut shared_scoping = css_props.has_class_names().then(|| { - Rc::new( - SemanticBuilder::new() - .build(program) - .semantic - .into_scoping(), - ) - }); let mut read = StyleReads { - style_roots: &style_roots, + style: &style, css_props: &css_props, names: FxHashSet::default(), depth: 0, class_names: Vec::new(), - scoping: shared_scoping.as_deref(), }; read.visit_program(program); if read.names.is_empty() { - return Inlined { - scoping: shared_scoping, - ..Inlined::default() - }; + return Inlined::default(); } let names = read.names; let mut modules = Modules { @@ -448,17 +504,21 @@ pub(crate) fn inline_constants<'a>( }; let mut symbols: FxHashMap = FxHashMap::default(); let mut inlined = Inlined::default(); - let (scoping, reads_math) = { + let reads_math = { let mut scope = ModuleScope::new(filename, program, None); - scope - .style_names - .extend(style_roots.iter().map(ToString::to_string)); - scope.css_props = Some(&css_props); + scope.style_names.extend( + style + .roots + .iter() + .map(|symbol| scoping.symbol_name(*symbol).to_string()), + ); + scope.css_prop = Some((css_prop, &compat)); + scope.shared_scoping = Some(scoping); let mut bindings: FxHashMap<&str, Vec<&Cell>>> = FxHashMap::default(); for statement in &program.body { let declaration = match statement { Statement::ImportDeclaration(import) => { - if !is_style_package(&import.source.value) { + if !is_style_package(option, &import.source.value) { scope.import(import); for specifier in import.specifiers.iter().flatten() { let local = specifier.local(); @@ -512,25 +572,12 @@ pub(crate) fn inline_constants<'a>( } } } - // Scoping is only worth building when a style reads a name that may - // hold a constant + // Constants are only worth reading when a style reads a name that may + // hold one let reads_math = names.contains("Math") && !scope.binds("Math"); if !reads_math && !names.iter().any(|name| scope.binds(name)) { - return Inlined { - scoping: shared_scoping, - ..Inlined::default() - }; + return Inlined::default(); } - let scoping = shared_scoping - .get_or_insert_with(|| { - Rc::new( - SemanticBuilder::new() - .build(program) - .semantic - .into_scoping(), - ) - }) - .clone(); for name in &names { let bound = scope.binds(name); let constant = scope.lookup(&mut modules, name); @@ -582,17 +629,16 @@ pub(crate) fn inline_constants<'a>( symbols.extend(symbol.get().map(|symbol| (symbol, constant.clone()))); } } - (scoping, reads_math) + reads_math }; inlined.dependencies = modules.exports.into_keys().collect(); if !symbols.is_empty() || reads_math { Inline { ast_builder, - scoping: &scoping, + scoping, symbols: &symbols, - style_roots: &style_roots, + style: &style, css_props: &css_props, - apis: &apis, css_failures: &mut inlined.css_failures, unknown: &mut inlined.unknown, objects: false, @@ -604,9 +650,6 @@ pub(crate) fn inline_constants<'a>( } .visit_program(program); } - if css_props.has_class_names() { - inlined.scoping = shared_scoping; - } inlined } @@ -682,13 +725,18 @@ impl<'p, 'a, 'r> ChangeCheck<'p, 'a, 'r> { } } -/// The local names of the style APIs that read style objects at build time -#[derive(Default)] -struct StyleApis<'s> { - functions: FxHashSet<&'s str>, - css: FxHashSet<&'s str>, +/// What the imports of the style packages bind, told by the binding an +/// identifier reads and not by its spelling: a local named like an import is +/// not a style API +struct StyleSymbols<'s> { + scoping: &'s Scoping, + /// Everything the packages give + roots: FxHashSet, + /// The style APIs that read style objects at build time + functions: FxHashSet, + css: FxHashSet, /// Namespace and default imports, `true` for `StyleX` - namespaces: FxHashMap<&'s str, bool>, + namespaces: FxHashMap, } fn takes_style_objects(stylex: bool, export: &str) -> bool { @@ -703,7 +751,51 @@ fn takes_style_objects(stylex: bool, export: &str) -> bool { } } -impl StyleApis<'_> { +impl<'s> StyleSymbols<'s> { + fn new(scoping: &'s Scoping) -> Self { + Self { + scoping, + roots: FxHashSet::default(), + functions: FxHashSet::default(), + css: FxHashSet::default(), + namespaces: FxHashMap::default(), + } + } + + /// Whether `identifier` reads something a style package gives + fn has(&self, identifier: &IdentifierReference<'_>) -> bool { + binding_of(self.scoping, identifier).is_some_and(|symbol| self.roots.contains(&symbol)) + } + + /// Whether `expression` is a style API: a root the package gives, or a + /// member or call of one + fn is_root(&self, expression: &Expression<'_>) -> bool { + match expression { + Expression::Identifier(identifier) => self.has(identifier), + Expression::StaticMemberExpression(member) => self.is_root(&member.object), + Expression::CallExpression(call) => self.is_root(&call.callee), + _ => false, + } + } + + fn is_css(&self, expression: &Expression<'_>) -> bool { + match expression { + Expression::Identifier(identifier) => binding_of(self.scoping, identifier) + .is_some_and(|symbol| self.css.contains(&symbol)), + Expression::StaticMemberExpression(member) if member.property.name == "css" => { + matches!(&member.object, Expression::Identifier(identifier) + if binding_of(self.scoping, identifier) + .and_then(|symbol| self.namespaces.get(&symbol)) == Some(&false)) + } + _ => false, + } + } + + /// Whether `name` is a component of the packages: `` or `` + fn is_component(&self, name: &JSXElementName<'_>) -> bool { + jsx_root_identifier(name).is_some_and(|identifier| self.has(identifier)) + } + /// Whether calling `callee` reads its arguments as style objects: /// `css(...)`, `styled.div(...)`, `styled(Link).attrs(...)`, /// `stylex.create(...)` @@ -713,11 +805,12 @@ impl StyleApis<'_> { loop { match crate::utils::unwrap_syntax_only(expression) { Expression::Identifier(identifier) => { - let name = identifier.name.as_str(); - return self.functions.contains(name) - || self.namespaces.get(name).is_some_and(|stylex| { - member.is_some_and(|member| takes_style_objects(*stylex, member)) - }); + return binding_of(self.scoping, identifier).is_some_and(|symbol| { + self.functions.contains(&symbol) + || self.namespaces.get(&symbol).is_some_and(|stylex| { + member.is_some_and(|member| takes_style_objects(*stylex, member)) + }) + }); } Expression::StaticMemberExpression(inner) => { member = Some(inner.property.name.as_str()); @@ -735,44 +828,16 @@ impl StyleApis<'_> { /// Names read inside the props of the package's components and the arguments /// of its functions struct StyleReads<'s> { - style_roots: &'s FxHashSet<&'s str>, + style: &'s StyleSymbols<'s>, css_props: &'s CssTakers<'s>, names: FxHashSet, depth: usize, - /// The API bindings of the surrounding `` child functions. + /// The bindings the `` child functions around take `css` and + /// `cx` by class_names: Vec, - scoping: Option<&'s Scoping>, -} - -/// Whether `expression` is a style API: a root the package gives, or a member -/// or call of one -fn is_style_root( - style_roots: &FxHashSet<&str>, - expression: &Expression<'_>, - scoping: Option<&Scoping>, -) -> bool { - match expression { - Expression::Identifier(identifier) => { - style_roots.contains(identifier.name.as_str()) - && scoping.is_none_or(|scoping| { - binding_of(scoping, identifier).is_some_and(|symbol| { - scoping.get_root_binding(identifier.name.as_str().into()) == Some(symbol) - }) - }) - } - Expression::StaticMemberExpression(member) => { - is_style_root(style_roots, &member.object, scoping) - } - Expression::CallExpression(call) => is_style_root(style_roots, &call.callee, scoping), - _ => false, - } } impl StyleReads<'_> { - fn is_style_root(&self, expression: &Expression<'_>) -> bool { - is_style_root(self.style_roots, expression, self.scoping) - } - fn reading(&mut self, style: bool, walk: impl FnOnce(&mut Self) -> T) -> T { self.depth += usize::from(style); let result = walk(self); @@ -782,17 +847,14 @@ impl StyleReads<'_> { } impl<'a> Visit<'a> for StyleReads<'_> { - fn visit_identifier_reference(&mut self, identifier: &oxc_ast::ast::IdentifierReference<'a>) { - if self.depth > 0 { + fn visit_identifier_reference(&mut self, identifier: &IdentifierReference<'a>) { + if self.depth > 0 && reads_top_level(self.style.scoping, identifier) { self.names.insert(identifier.name.to_string()); } } fn visit_jsx_element(&mut self, element: &oxc_ast::ast::JSXElement<'a>) { - let calls = self - .scoping - .map(|scoping| self.css_props.class_names_calls(element, scoping)) - .unwrap_or_default(); + let calls = self.css_props.class_names_calls(element); let taken = calls.len(); self.class_names.extend(calls); oxc_ast_visit::walk::walk_jsx_element(self, element); @@ -801,11 +863,13 @@ impl<'a> Visit<'a> for StyleReads<'_> { fn visit_call_expression(&mut self, call: &oxc_ast::ast::CallExpression<'a>) { self.visit_expression(&call.callee); - let style = self.is_style_root(&call.callee) - || calls_one_of(self.scoping, &self.class_names, &call.callee); + let style = self.style.is_root(&call.callee) + || self + .css_props + .calls_class_names(&self.class_names, &call.callee); let css = self .css_props - .property(call, |root| self.style_roots.contains(root)); + .property(call, |identifier| self.style.has(identifier)); self.reading(style, |reads| { for (index, argument) in call.arguments.iter().enumerate() { match (css, argument) { @@ -827,18 +891,22 @@ impl<'a> Visit<'a> for StyleReads<'_> { tagged: &oxc_ast::ast::TaggedTemplateExpression<'a>, ) { self.visit_expression(&tagged.tag); - let style = self.is_style_root(&tagged.tag) - || calls_one_of(self.scoping, &self.class_names, &tagged.tag); + let style = self.style.is_root(&tagged.tag) + || self + .css_props + .calls_class_names(&self.class_names, &tagged.tag); self.reading(style, |reads| reads.visit_template_literal(&tagged.quasi)); } fn visit_jsx_opening_element(&mut self, element: &oxc_ast::ast::JSXOpeningElement<'a>) { - let style = jsx_root(&element.name).is_some_and(|root| self.style_roots.contains(root)); + let style = self.style.is_component(&element.name); for attribute in &element.attributes { let style = style - || self.css_props.attribute(&element.name, attribute, |root| { - self.style_roots.contains(root) - }); + || self + .css_props + .attribute(&element.name, attribute, |identifier| { + self.style.has(identifier) + }); self.reading(style, |reads| match attribute { JSXAttributeItem::Attribute(attribute) => { if let Some(value) = &attribute.value { @@ -853,15 +921,12 @@ impl<'a> Visit<'a> for StyleReads<'_> { } } -/// Whether `callee` is one of `names` -fn calls_one_of(scoping: Option<&Scoping>, symbols: &[SymbolId], callee: &Expression<'_>) -> bool { - matches!(callee, Expression::Identifier(callee) if scoping.and_then(|scoping| binding_of(scoping, callee)).is_some_and(|symbol| symbols.contains(&symbol))) -} - -/// The name `` or `` starts with -pub(crate) fn jsx_root<'n>(name: &'n JSXElementName<'_>) -> Option<&'n str> { +/// The identifier `` or `` starts with +pub(crate) fn jsx_root_identifier<'n, 'a>( + name: &'n JSXElementName<'a>, +) -> Option<&'n IdentifierReference<'a>> { match name { - JSXElementName::IdentifierReference(identifier) => Some(identifier.name.as_str()), + JSXElementName::IdentifierReference(identifier) => Some(identifier), JSXElementName::MemberExpression(member) => { let mut object = &member.object; while let oxc_ast::ast::JSXMemberExpressionObject::MemberExpression(inner) = object { @@ -869,7 +934,7 @@ pub(crate) fn jsx_root<'n>(name: &'n JSXElementName<'_>) -> Option<&'n str> { } match object { oxc_ast::ast::JSXMemberExpressionObject::IdentifierReference(identifier) => { - Some(identifier.name.as_str()) + Some(identifier) } _ => None, } @@ -878,6 +943,11 @@ pub(crate) fn jsx_root<'n>(name: &'n JSXElementName<'_>) -> Option<&'n str> { } } +/// The name `` or `` starts with +pub(crate) fn jsx_root<'n>(name: &'n JSXElementName<'_>) -> Option<&'n str> { + jsx_root_identifier(name).map(|identifier| identifier.name.as_str()) +} + /// The constant exports of the modules read, by path struct Modules<'r> { resolver: Option<&'r ModuleResolver>, @@ -1173,10 +1243,17 @@ struct ModuleScope<'p, 'a> { style_imports: FxHashSet, /// Style APIs besides the imports, which never run what they are given style_names: FxHashSet, - /// The `css` props of the file extracted, which never run what they hold - css_props: Option<&'p CssTakers<'p>>, + /// The `css` props of the file extracted, which never run what they hold, + /// and the entry absorbing Emotion's own `jsx` + css_prop: Option<(CssProp, &'p str)>, uses: Option>>>, changes: FxHashMap>>, + /// The semantic analysis the program extracted already has, which the + /// visitor reuses and building another over the same program would reset + shared_scoping: Option<&'p Scoping>, + /// The semantic analysis of a module read, built when a `StyleX` callee + /// first needs the binding it reads told + scoping: OnceCell, } impl<'p, 'a> ModuleScope<'p, 'a> { @@ -1192,9 +1269,11 @@ impl<'p, 'a> ModuleScope<'p, 'a> { imports: FxHashMap::default(), style_imports: FxHashSet::default(), style_names: FxHashSet::default(), - css_props: None, + css_prop: None, uses: None, changes: FxHashMap::default(), + shared_scoping: None, + scoping: OnceCell::new(), } } @@ -1343,7 +1422,7 @@ impl<'p, 'a> ModuleScope<'p, 'a> { let uses = Rc::new(crate::mutations::uses( self.program, &|name| self.is_style_import(option, name), - self.css_props, + self.css_prop, )); self.uses = Some(uses.clone()); uses @@ -1545,25 +1624,41 @@ impl<'p, 'a> ModuleScope<'p, 'a> { } } + /// The `StyleX` API `callee` reads, as the import it reads binds it and + /// not as it is spelled: a local named like an import is not the API fn stylex_function(&self, callee: &Expression<'_>) -> Option { - let (name, member) = match callee { - Expression::Identifier(identifier) => (identifier.name.as_str(), None), + let (identifier, member) = match callee { + Expression::Identifier(identifier) => (identifier, None), Expression::StaticMemberExpression(member) => match &member.object { - Expression::Identifier(object) => { - (object.name.as_str(), Some(member.property.name.as_str())) - } + Expression::Identifier(object) => (object, Some(member.property.name.as_str())), _ => return None, }, _ => return None, }; - let export = match (self.imports.get(name)?, member) { + let export = match (self.imports.get(identifier.name.as_str())?, member) { ((source, _), _) if source != crate::STYLEX_PACKAGE => return None, ((_, Imported::Named(export)), None) => export.as_str(), ((_, Imported::Namespace), Some(export)) => export, ((_, Imported::Named(export)), Some(member)) if export == "default" => member, _ => return None, }; - StylexFunction::from_export_name(export) + let function = StylexFunction::from_export_name(export)?; + self.reads_top_level_binding(identifier).then_some(function) + } + + /// Whether `identifier` reads a binding of the module's top level, where + /// the imports bind, and not a local of a function or block + fn reads_top_level_binding(&self, identifier: &IdentifierReference<'_>) -> bool { + let scoping = self.shared_scoping.unwrap_or_else(|| { + self.scoping.get_or_init(|| { + SemanticBuilder::new() + .build(self.program) + .semantic + .into_scoping() + }) + }); + binding_of(scoping, identifier) + .is_some_and(|symbol| scoping.symbol_scope_id(symbol) == scoping.root_scope_id()) } /// A value `StyleX` gives when this module's own extraction reads it, with @@ -1641,7 +1736,7 @@ impl<'p, 'a> ModuleScope<'p, 'a> { /// `StyleX` variable, as [`crate::stylex::variable_values`] reads it once /// constants are inlined fn variable_value(&mut self, modules: &mut Modules<'_>, value: &Expression<'_>) -> bool { - let value = crate::stylex::unwrap_types_call(value); + let value = crate::stylex::unwrap_types_call(value, &|callee| self.stylex_function(callee)); if matches!(value, Expression::NullLiteral(_)) || self.literal_text(modules, value).is_some() { @@ -1991,9 +2086,8 @@ struct Inline<'s, 'a> { ast_builder: &'s AstBuilder<'a>, scoping: &'s Scoping, symbols: &'s FxHashMap, - style_roots: &'s FxHashSet<&'s str>, + style: &'s StyleSymbols<'s>, css_props: &'s CssTakers<'s>, - apis: &'s StyleApis<'s>, css_failures: &'s mut Vec, unknown: &'s mut Unknown, /// Inside what the build reads as style objects @@ -2006,7 +2100,8 @@ struct Inline<'s, 'a> { /// Where a constant stands as a value or rules of a library reading /// numbers as `px` lengths marks: &'s crate::import_alias_visit::PxMarks, - /// The API bindings of the surrounding `` child functions. + /// The bindings the `` child functions around take `css` and + /// `cx` by class_names: Vec, } @@ -2333,27 +2428,17 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { tagged: &mut oxc_ast::ast::TaggedTemplateExpression<'a>, ) { self.visit_expression(&mut tagged.tag); - if calls_one_of(Some(self.scoping), &self.class_names, &tagged.tag) { + if self + .css_props + .calls_class_names(&self.class_names, &tagged.tag) + { self.reading_css(true, |inline| { inline.visit_template_literal(&mut tagged.quasi); }); return; } - let styles = is_style_root( - self.style_roots, - &tagged.tag, - self.css_props.has_class_names().then_some(self.scoping), - ); - let css = is_style_root(self.style_roots, &tagged.tag, Some(self.scoping)) - && match &tagged.tag { - Expression::Identifier(name) => self.apis.css.contains(name.name.as_str()), - Expression::StaticMemberExpression(member) => { - member.property.name == "css" - && matches!(&member.object, Expression::Identifier(name) - if self.apis.namespaces.get(name.name.as_str()) == Some(&false)) - } - _ => false, - }; + let styles = self.style.is_root(&tagged.tag); + let css = self.style.is_css(&tagged.tag); if !css { self.reading_styles(styles, |inline| { inline.visit_template_literal(&mut tagged.quasi); @@ -2419,7 +2504,7 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { } fn visit_jsx_element(&mut self, element: &mut oxc_ast::ast::JSXElement<'a>) { - let calls = self.css_props.class_names_calls(element, self.scoping); + let calls = self.css_props.class_names_calls(element); let taken = calls.len(); self.class_names.extend(calls); walk_mut::walk_jsx_element(self, element); @@ -2428,7 +2513,10 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { fn visit_call_expression(&mut self, call: &mut oxc_ast::ast::CallExpression<'a>) { self.visit_expression(&mut call.callee); - if calls_one_of(Some(self.scoping), &self.class_names, &call.callee) { + if self + .css_props + .calls_class_names(&self.class_names, &call.callee) + { self.reading_css(true, |inline| { for argument in &mut call.arguments { inline.visit_argument(argument); @@ -2436,15 +2524,11 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { }); return; } - let objects = self.apis.reads(&call.callee); - let styles = is_style_root( - self.style_roots, - &call.callee, - self.css_props.has_class_names().then_some(self.scoping), - ); + let objects = self.style.reads(&call.callee); + let styles = self.style.is_root(&call.callee); let css = self .css_props - .property(call, |root| self.style_roots.contains(root)); + .property(call, |identifier| self.style.has(identifier)); self.reading_styles(styles, |inline| { inline.reading_objects(objects, |inline| { for (index, argument) in call.arguments.iter_mut().enumerate() { @@ -2464,12 +2548,15 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { } fn visit_jsx_opening_element(&mut self, element: &mut oxc_ast::ast::JSXOpeningElement<'a>) { - let styled = jsx_root(&element.name).is_some_and(|root| self.style_roots.contains(root)); + let styled = self.style.is_component(&element.name); let element_name = &element.name; for attribute in &mut element.attributes { - if self.css_props.attribute(element_name, attribute, |root| { - self.style_roots.contains(root) - }) { + if self + .css_props + .attribute(element_name, attribute, |identifier| { + self.style.has(identifier) + }) + { self.reading_css(true, |inline| inline.visit_jsx_attribute_item(attribute)); continue; } @@ -2504,3 +2591,8 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { } } } + +#[cfg(test)] +mod scope_tests; +#[cfg(test)] +mod stylex_scope_tests; diff --git a/libs/extractor/src/imported_constants/scope_tests.rs b/libs/extractor/src/imported_constants/scope_tests.rs new file mode 100644 index 000000000..53d1b0ced --- /dev/null +++ b/libs/extractor/src/imported_constants/scope_tests.rs @@ -0,0 +1,272 @@ +use std::rc::Rc; + +use oxc_allocator::Allocator; +use oxc_ast::ast::Expression; +use oxc_ast::builder::AstBuilder; +use oxc_codegen::Codegen; +use oxc_parser::Parser; +use oxc_span::SourceType; +use rustc_hash::FxHashMap; + +use super::{Change, ChangeSite, Changed, Inlined, Unknown, inline_constants}; +use crate::ExtractOption; +use crate::css_prop::CssProp; + +fn inline(code: &str, css_prop: CssProp) -> (String, Inlined) { + let allocator = Allocator::default(); + let mut program = Parser::new(&allocator, code, SourceType::tsx()) + .parse() + .program; + let inlined = inline_constants( + &AstBuilder::new(&allocator), + &mut program, + "a.tsx", + &ExtractOption::default(), + None, + css_prop, + &crate::import_alias_visit::PxMarks::default(), + ); + (Codegen::new().build(&program).code, inlined) +} + +fn expression<'a>(allocator: &'a Allocator, code: &'a str) -> Expression<'a> { + Parser::new(allocator, code, SourceType::tsx()) + .parse_expression() + .unwrap_or_else(|_| panic!("{code}")) +} + +#[test] +fn a_local_named_like_a_style_function_reads_no_styles() { + let (code, _) = inline( + "import { css } from '@devup-ui/react'; +import * as ui from '@devup-ui/react'; +const SIZE = 4; +export const own = css({ w: SIZE }); +export const member = ui.css({ w: SIZE }); +export function shadowed(css, ui) { return [css({ w: SIZE }), ui.css({ w: SIZE }), parseInt('1')]; }", + CssProp::Off, + ); + assert_eq!(code.matches("w: 4").count(), 2, "{code}"); + assert_eq!(code.matches("w: SIZE").count(), 2, "{code}"); +} + +#[test] +fn a_local_named_like_a_style_component_reads_no_styles() { + let (code, _) = inline( + "import { Box } from '@devup-ui/react'; +import * as ui from '@devup-ui/react'; +const COLOR = 'red'; +export const own = <>; +export function shadowed(Box, ui) { return <>; }", + CssProp::Off, + ); + assert_eq!(code.matches("{COLOR}").count(), 2, "{code}"); +} + +#[test] +fn a_local_named_like_a_class_names_binding_reads_no_styles() { + let (code, _) = inline( + "import { ClassNames } from '@emotion/react'; +const SIZE = 4; +export const own = {({ css }) => css({ width: SIZE })}; +export const nested = {({ css }) => ((css) => css({ width: SIZE }))(css)}; +export const tagged = {({ cx }) => cx`width: ${SIZE}px`}; +export function shadowed(ClassNames) { return {({ css }) => css({ width: SIZE })}; }", + CssProp::Elements, + ); + assert_eq!(code.matches("width: SIZE").count(), 2, "{code}"); + assert_eq!(code.matches("width: \"4px\"").count(), 1, "{code}"); + assert_eq!(code.matches("${SIZE}").count(), 0, "{code}"); +} + +#[test] +fn a_local_named_like_the_jsx_function_takes_no_css_prop() { + let (code, _) = inline( + "import { jsx } from '@emotion/react'; +import { Box } from '@devup-ui/react'; +const SIZE = 4; +export const tag = jsx('div', { css: { width: SIZE } }); +export const component = jsx(Box, { css: { width: SIZE } }); +export function shadowedJsx(jsx) { return jsx('div', { css: { width: SIZE } }); } +export function shadowedBox(Box) { return jsx(Box, { css: { width: SIZE } }); }", + CssProp::Elements, + ); + assert_eq!(code.matches("width: \"4px\"").count(), 2, "{code}"); + assert_eq!(code.matches("width: SIZE").count(), 2, "{code}"); +} + +#[test] +fn a_local_named_like_a_style_component_takes_no_css_attribute() { + let (code, _) = inline( + "import { Box } from '@devup-ui/react'; +const SIZE = 4; +export const own = ; +export const tag =
; +export const custom = ; +export function shadowed(Box) { return ; }", + CssProp::Elements, + ); + assert_eq!(code.matches("width: \"4px\"").count(), 2, "{code}"); + assert_eq!(code.matches("width: SIZE").count(), 2, "{code}"); +} + +#[test] +fn names_only_locals_read_are_not_reported_unknown_or_changed() { + let (_, shadowed) = inline( + "import { css } from '@devup-ui/react'; +const unknown = compute(); +const table = { a: 1 }; +table.a = 2; +export function f(unknown, table) { return css({ w: unknown, h: table.a }); }", + CssProp::Off, + ); + assert!(shadowed.unknown.is_empty()); + assert!(shadowed.changed.is_empty()); + + let (_, read) = inline( + "import { css } from '@devup-ui/react'; +const unknown = compute(); +const table = { a: 1 }; +table.a = 2; +export const a = css({ w: unknown, h: table.a });", + CssProp::Off, + ); + assert!(!read.unknown.is_empty()); + assert!(!read.changed.is_empty()); +} + +#[test] +fn the_scoping_built_is_kept_on_every_return_after_it() { + for (code, css_prop, kept) in [ + ("const SIZE = 4;\nexport const a = 1;", CssProp::Off, false), + ( + "import { css } from '@devup-ui/react';\nconst SIZE = 4;", + CssProp::Off, + true, + ), + ( + "import { css } from '@devup-ui/react';\nconst SIZE = 4;\ncss({ w: 1 });", + CssProp::Off, + true, + ), + ( + "import { css } from '@devup-ui/react';\nconst SIZE = 4;\ncss({ w: Math.max(1, 2) });", + CssProp::Off, + true, + ), + ( + "import { css } from '@devup-ui/react';\nconst SIZE = 4;\ncss({ w: SIZE });", + CssProp::Off, + true, + ), + ( + "const SIZE = 4;\nexport const a =
;", + CssProp::Elements, + true, + ), + ] { + let (_, inlined) = inline(code, css_prop); + assert_eq!(inlined.scoping.is_some(), kept, "{code}"); + if let Some(scoping) = &inlined.scoping { + assert!(scoping.get_root_binding("SIZE".into()).is_some(), "{code}"); + } + } +} + +struct Properties<'s> { + inlined: &'s Inlined, + scoping: &'s oxc_semantic::Scoping, + found: Vec<(bool, bool)>, +} + +impl<'a> oxc_ast_visit::Visit<'a> for Properties<'_> { + fn visit_object_property(&mut self, property: &oxc_ast::ast::ObjectProperty<'a>) { + let reads = |identifier: &oxc_ast::ast::IdentifierReference<'_>| { + crate::css_prop::reads_top_level(self.scoping, identifier) + }; + self.found.push(( + self.inlined.unknown.read_by_in(&property.value, &reads), + self.inlined.changed.read_by_in(&property.value, &reads), + )); + } +} + +#[test] +fn the_scoping_kept_guards_reads_of_unknown_and_changed_bindings() { + let allocator = Allocator::default(); + let mut program = Parser::new( + &allocator, + "import { css } from '@devup-ui/react'; +const unknown = compute(); +const table = { a: 1 }; +table.a = 2; +export const top = css({ w: unknown, h: table.a }); +export function shadowed(unknown, table) { return css({ w: unknown, h: table.a }); }", + SourceType::tsx(), + ) + .parse() + .program; + let inlined = inline_constants( + &AstBuilder::new(&allocator), + &mut program, + "a.tsx", + &ExtractOption::default(), + None, + CssProp::Off, + &crate::import_alias_visit::PxMarks::default(), + ); + let Some(scoping) = inlined.scoping.clone() else { + panic!("no scoping kept"); + }; + let mut properties = Properties { + inlined: &inlined, + scoping: &scoping, + found: Vec::new(), + }; + oxc_ast_visit::Visit::visit_program(&mut properties, &program); + assert_eq!( + properties.found, + vec![ + (false, false), + (true, false), + (false, true), + (false, false), + (false, false) + ] + ); +} + +#[test] +fn unknown_counts_only_the_identifiers_accepted() { + let allocator = Allocator::default(); + let unknown = Unknown { + names: std::iter::once("x".to_string()).collect(), + partial: FxHashMap::default(), + }; + for code in ["x", "x()", "x[k]", "x.y[k]", "x.y()"] { + let expression = expression(&allocator, code); + assert!(unknown.read_by_in(&expression, &|_| true), "{code}"); + assert!(!unknown.read_by_in(&expression, &|_| false), "{code}"); + } + let expression = expression(&allocator, "y[x]"); + assert!(!unknown.read_by_in(&expression, &|_| true)); +} + +#[test] +fn changed_counts_only_the_identifiers_accepted() { + let allocator = Allocator::default(); + let change = Rc::new(Change { + name: "x".to_string(), + site: ChangeSite::Here(0), + handed: false, + }); + let changed = Changed { + whole: FxHashMap::from_iter([("x".to_string(), change)]), + holding: FxHashMap::default(), + }; + for code in ["x", "x.y", "x[k]"] { + let expression = expression(&allocator, code); + assert!(changed.read_by_in(&expression, &|_| true), "{code}"); + assert!(!changed.read_by_in(&expression, &|_| false), "{code}"); + } +} diff --git a/libs/extractor/src/imported_constants/stylex_scope_tests.rs b/libs/extractor/src/imported_constants/stylex_scope_tests.rs new file mode 100644 index 000000000..ea6d77bf5 --- /dev/null +++ b/libs/extractor/src/imported_constants/stylex_scope_tests.rs @@ -0,0 +1,225 @@ +use oxc_allocator::Allocator; +use oxc_ast::ast::{CallExpression, Expression, Statement}; +use oxc_ast_visit::{Visit, walk}; +use oxc_parser::Parser; +use oxc_span::SourceType; + +use super::ModuleScope; +use crate::stylex::StylexFunction; + +/// The `StyleX` API the callee of each call reads, and the one its member +/// object reads, in source order +struct Callees<'s, 'p, 'a> { + scope: &'s ModuleScope<'p, 'a>, + found: Vec<(Option, Option)>, +} + +impl<'a> Visit<'a> for Callees<'_, '_, 'a> { + fn visit_call_expression(&mut self, call: &CallExpression<'a>) { + let object = match &call.callee { + Expression::StaticMemberExpression(member) => { + self.scope.stylex_function(&member.object) + } + _ => None, + }; + self.found + .push((self.scope.stylex_function(&call.callee), object)); + walk::walk_call_expression(self, call); + } +} + +fn read(code: &str) -> Vec<(Option, Option)> { + let allocator = Allocator::default(); + let program = Parser::new(&allocator, code, SourceType::tsx()) + .parse() + .program; + let mut scope = ModuleScope::new("a.tsx", &program, None); + for statement in &program.body { + if let Statement::ImportDeclaration(import) = statement { + scope.import(import); + } + } + let mut callees = Callees { + scope: &scope, + found: Vec::new(), + }; + callees.visit_program(&program); + callees.found +} + +#[test] +fn a_helper_is_the_import_its_callee_reads_and_not_its_spelling() { + let found = read( + "import * as sx from '@stylexjs/stylex'; +import { firstThatWorks as ftw, types as t, include } from '@stylexjs/stylex'; +import { include as other } from 'elsewhere'; +sx.firstThatWorks(a); +ftw(a); +include(a); +other(a); +t.color(a); +sx.types.color(a); +function shadowed(sx, ftw, include, t) { + sx.firstThatWorks(a); + ftw(a); + include(a); + t.color(a); + sx.types.color(a); +}", + ); + assert_eq!( + found, + vec![ + (Some(StylexFunction::FirstThatWorks), None), + (Some(StylexFunction::FirstThatWorks), None), + (Some(StylexFunction::Include), None), + (None, None), + (None, Some(StylexFunction::Types)), + (None, Some(StylexFunction::Types)), + (None, None), + (None, None), + (None, None), + (None, None), + (None, None), + ] + ); +} + +fn extracted(code: &str) -> Result { + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + crate::extract( + "test.tsx", + code, + crate::ExtractOption { + import_main_css: false, + ..crate::ExtractOption::default() + }, + ) + .map_err(|error| error.to_string()) +} + +fn declarations(output: &crate::ExtractOutput) -> Vec<(String, String)> { + let mut found: Vec<(String, String)> = output + .styles + .iter() + .filter_map(|style| match style { + crate::ExtractStyleValue::Static(style) => { + Some((style.property.clone(), style.value.clone())) + } + _ => None, + }) + .collect(); + found.sort(); + found +} + +fn error_of(code: &str) -> String { + extracted(code).err().unwrap_or_default() +} + +const SX: &str = "import * as sx from '@stylexjs/stylex';\n"; + +#[test] +#[serial_test::serial] +fn genuine_helpers_compile_through_every_import_spelling() { + let output = extracted( + "import * as sx from '@stylexjs/stylex'; +import { create, defineVars, firstThatWorks as ftw, include as inc, types as t } from '@stylexjs/stylex'; +const base = create({ a: { color: 'red' } }); +const vars = defineVars({ size: t.length('1px'), tone: sx.types.color({ default: 'blue' }) }); +const styles = sx.create({ + b: { backgroundColor: ftw('red', 'blue'), width: vars.size, borderColor: vars.tone }, + c: { ...inc(base.a), height: sx.firstThatWorks('1px', '2px') }, +}); +export const A = () =>
;", + ) + .unwrap_or_else(|error| panic!("{error}")); + assert_eq!( + declarations(&output), + vec![ + ("background-color".to_string(), "blue".to_string()), + ("background-color".to_string(), "red".to_string()), + ("border-color".to_string(), "var(--a-c)".to_string()), + ("color".to_string(), "red".to_string()), + ("height".to_string(), "1px".to_string()), + ("height".to_string(), "2px".to_string()), + ("width".to_string(), "var(--a-b)".to_string()), + ] + ); +} + +#[test] +#[serial_test::serial] +fn shadowed_and_unrelated_helpers_are_reported_at_build_time() { + for (code, expected) in [ + ( + "export function f(firstThatWorks) { return sx.create({ a: { color: firstThatWorks('red', 'blue') } }); }", + "`stylex.create()` cannot use `firstThatWorks(", + ), + ( + "export function f(o) { return sx.create({ a: { color: o.firstThatWorks('red') } }); }", + "`stylex.create()` cannot use `o.firstThatWorks(", + ), + ( + "const base = sx.create({ a: { color: 'red' } });\nexport function f(include) { return sx.create({ b: { ...include(base.a) } }); }", + "`stylex.create()` cannot use `...include(", + ), + ( + "const base = sx.create({ a: { color: 'red' } });\nexport function f(o) { return sx.create({ b: { ...o.include(base.a) } }); }", + "`stylex.create()` cannot use `...o.include(", + ), + ( + "export function f(types) { return sx.defineVars({ c: types.color('red') }); }", + "`stylex.defineVars()` cannot use `types.color(", + ), + ( + "export function f(o) { return sx.defineVars({ c: o.types.color('red') }); }", + "`stylex.defineVars()` cannot use `o.types.color(", + ), + ( + "export const p = sx.positionTry({ top: sx.firstThatWorks('1px') });", + "`stylex.positionTry()` cannot use `sx.firstThatWorks(", + ), + ( + "export function f(firstThatWorks) { return sx.positionTry({ top: firstThatWorks('1px') }); }", + "`stylex.positionTry()` cannot use `firstThatWorks(", + ), + ] { + let message = error_of(&format!("{SX}{code}")); + assert!(message.contains(expected), "{expected}: {message}"); + } +} + +#[test] +#[serial_test::serial] +fn aliased_types_read_in_an_imported_module_still_publish_their_variables() { + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + let resolver = |specifier: &str, _: &str| { + (specifier == "./vars").then(|| crate::ResolvedModule { + path: "/src/vars.ts".to_string(), + code: "import { defineVars, types as t } from '@stylexjs/stylex'; +export const vars = defineVars({ tone: t.color('red'), size: t.length(unknown()) });" + .to_string(), + }) + }; + let output = crate::extract_with_modules( + "/src/App.tsx", + "import * as sx from '@stylexjs/stylex'; +import { vars } from './vars'; +const s = sx.create({ a: { color: vars.tone } }); +export const A = () =>
;", + crate::ExtractOption { + import_main_css: false, + ..crate::ExtractOption::default() + }, + false, + &resolver, + ) + .unwrap_or_else(|error| panic!("{error}")); + assert_eq!( + declarations(&output), + vec![("color".to_string(), "var(--b-a)".to_string())] + ); +} diff --git a/libs/extractor/src/imported_styled.rs b/libs/extractor/src/imported_styled.rs index 29841de0a..e9c457607 100644 --- a/libs/extractor/src/imported_styled.rs +++ b/libs/extractor/src/imported_styled.rs @@ -5,7 +5,7 @@ use std::collections::BTreeSet; use std::rc::Rc; -use oxc_allocator::{Allocator, GetAllocator}; +use oxc_allocator::{Allocator, CloneIn, GetAllocator}; use oxc_ast::ast::{ CallExpression, Declaration, Expression, ImportDeclarationSpecifier, JSXAttributeItem, JSXAttributeName, JSXElementName, JSXOpeningElement, Program, Statement, @@ -272,6 +272,7 @@ impl<'a> Reader<'_, 'a> { visitor.takes_css_prop(css_prop); visitor.import_styled(imports); visitor.reserve_styled(styled_capability); + visitor.reuse_scoping(inlined.scoping); visitor.visit_program(&mut program); let readable = visitor.errors.is_empty() && visitor.unknown_parts.is_empty() @@ -328,12 +329,20 @@ impl<'a> Reader<'_, 'a> { css_prop: CssProp, allocator: &'b Allocator, ) -> Components<'b> { + let arena = Allocator::default(); + let isolated = program.clone_in(&arena); + let program = &isolated; + let scoping = oxc_semantic::SemanticBuilder::new() + .build(program) + .semantic + .into_scoping(); let imported = imported_bindings(program, &self.option.package); let mut used = Used { styled: imported.styled, css_prop: css_prop != CssProp::Off, css: CssTakers::new( program, + &scoping, css_prop, &format!("{}/compat", self.option.package), ), diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 2a068b314..3611be9ad 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -44,6 +44,7 @@ mod quoted_strings_tests; pub mod reservation_state; #[cfg(test)] mod responsive_selector_tests; +mod scope; mod source_map; mod style_collector; mod style_values; @@ -636,12 +637,10 @@ fn extract_source( ); visitor.import_stylex(inlined.stylex_vars, inlined.stylex_themes); visitor.import_css(inlined.css_styles); - if let Some(scoping) = inlined.scoping { - visitor.reuse_scoping(scoping); - } visitor.unknown_bindings(&inlined.unknown); visitor.changed_bindings(inlined.changed.clone()); visitor.takes_css_prop(css_prop); + visitor.reuse_scoping(inlined.scoping); visitor.import_styled(imported.components); visitor.reserve_styled(styled_capability); visitor.visit_program(&mut program); @@ -15944,7 +15943,7 @@ export const a = {element}" )] #[case( "stylex.props(on && s.red, on && s.red)", - "{ className: on ? \"color-0-red--255\" : on ? \"color-0-red--255\" : \"\" }" + "(_s0 = !!on, { className: on ? \"color-0-red--255\" : _s0 ? \"color-0-red--255\" : \"\" })" )] #[case( "stylex.props(s['blue'], s.hover)", diff --git a/libs/extractor/src/mutations.rs b/libs/extractor/src/mutations.rs index a9f33dea0..709cbcc33 100644 --- a/libs/extractor/src/mutations.rs +++ b/libs/extractor/src/mutations.rs @@ -5,17 +5,20 @@ use oxc_allocator::CloneIn; use oxc_ast::AstKind; use oxc_ast::ast::{ - Expression, JSXAttributeName, JSXElementName, ObjectPropertyKind, Program, + Expression, IdentifierReference, JSXAttributeName, JSXElementName, ObjectPropertyKind, Program, VariableDeclarationKind, }; use oxc_ast_visit::{Visit, walk}; -use oxc_semantic::{AstNodes, SemanticBuilder}; +use oxc_semantic::{AstNodes, Scoping, SemanticBuilder}; use oxc_span::GetSpan; use oxc_syntax::node::NodeId; use oxc_syntax::operator::UnaryOperator; use oxc_syntax::scope::ScopeFlags; use rustc_hash::FxHashMap; +use crate::css_prop::{CssProp, CssTakers, binding_of}; +use crate::imported_constants::jsx_root_identifier; + /// How code uses a top-level binding #[derive(Debug)] pub(crate) enum Use { @@ -106,25 +109,25 @@ const READING_FUNCTIONS: [&str; 8] = [ /// The uses of each top-level binding of `program` that may change what it /// holds; `style` tells the names of the style APIs, whose arguments the /// build reads and which never run, and `css` the `css` props the build reads +/// and the entry absorbing Emotion's own `jsx`. A name only counts for a +/// reference to the binding of the module, never to a local of the same name. pub(crate) fn uses( program: &Program<'_>, style: &dyn Fn(&str) -> bool, - css: Option<&crate::css_prop::CssTakers<'_>>, + css: Option<(CssProp, &str)>, ) -> FxHashMap> { let allocator = oxc_allocator::Allocator::default(); - // ClassNames callers retain semantic IDs for inlining and compilation. - // Analyze a separate tree so building mutation nodes cannot reset those IDs. - let isolated = css - .filter(|css| css.has_class_names()) - .map(|_| program.clone_in(&allocator)); - let program = isolated.as_ref().unwrap_or(program); + // Callers retain semantic IDs for inlining and compilation. Analyze a + // separate tree so building mutation nodes cannot reset those IDs. + let isolated = program.clone_in(&allocator); + let program = &isolated; let semantic = SemanticBuilder::new() .with_build_nodes(true) .build(program) .semantic; let scoping = semantic.scoping(); let nodes = semantic.nodes(); - let is_global = |name: &str| scoping.get_root_binding(name.into()).is_none(); + let takers = css.map(|(css_prop, compat)| CssTakers::new(program, scoping, css_prop, compat)); let mut uses: FxHashMap> = FxHashMap::default(); for (name, symbol) in scoping.get_bindings(scoping.root_scope_id()) { let init = match nodes.kind(scoping.symbol_declaration(*symbol)) { @@ -135,8 +138,7 @@ pub(crate) fn uses( nodes, scoping, style, - css, - is_global: &is_global, + css: takers.as_ref(), init, }; let found: Vec = scoping @@ -155,10 +157,9 @@ pub(crate) fn uses( struct Context<'s, 'a> { nodes: &'s AstNodes<'a>, - scoping: &'s oxc_semantic::Scoping, + scoping: &'s Scoping, style: &'s dyn Fn(&str) -> bool, - css: Option<&'s crate::css_prop::CssTakers<'s>>, - is_global: &'s dyn Fn(&str) -> bool, + css: Option<&'s CssTakers<'s>>, /// What the binding is declared as, when a `const` or `let` gives it init: Option<&'s Expression<'a>>, } @@ -381,12 +382,12 @@ impl Context<'_, '_> { match callee { Expression::Identifier(identifier) if READING_FUNCTIONS.contains(&identifier.name.as_str()) - && (self.is_global)(&identifier.name) => + && self.is_global(identifier) => { Some(("", identifier.name.as_str())) } Expression::StaticMemberExpression(member) => match &member.object { - Expression::Identifier(object) if (self.is_global)(&object.name) => { + Expression::Identifier(object) if self.is_global(object) => { Some((object.name.as_str(), member.property.name.as_str())) } _ => None, @@ -416,15 +417,12 @@ impl Context<'_, '_> { /// Whether `callee`, called at `id`, is the `css` or `cx` a `` /// child function around takes, whose calls the build compiles fn is_class_names_call(&self, id: NodeId, callee: &Expression<'_>) -> bool { - let (Some(css), Expression::Identifier(callee)) = (self.css, callee) else { - return false; - }; - let Some(symbol) = crate::css_prop::binding_of(self.scoping, callee) else { + let Some(css) = self.css else { return false; }; self.nodes.ancestor_ids(id).any(|ancestor| { matches!(self.nodes.kind(ancestor), AstKind::JSXElement(element) - if css.class_names_calls(element, self.scoping).contains(&symbol)) + if css.calls_class_names(&css.class_names_calls(element), callee)) }) } @@ -437,7 +435,7 @@ impl Context<'_, '_> { .as_identifier() .is_some_and(|name| name.name == "css") && matches!(self.nodes.parent_kind(id), AstKind::JSXOpeningElement(element) - if css.css_prop.takes(&element.name, |root| (self.style)(root))) + if css.takes(&element.name, |identifier| self.is_style_reference(identifier))) }) } @@ -447,7 +445,7 @@ impl Context<'_, '_> { self.css.is_some_and(|css| { matches!(self.nodes.parent_kind(self.nodes.parent_id(id)), AstKind::CallExpression(call) if matches!(call.arguments.get(1), Some(oxc_ast::ast::Argument::ObjectExpression(props)) - if css.property(call, |root| (self.style)(root)) + if css.property(call, |identifier| self.is_style_reference(identifier)) .and_then(|at| props.properties.get(at)) .is_some_and(|css| css.span() == property.span))) }) @@ -522,7 +520,7 @@ impl Context<'_, '_> { fn is_commonjs_export(&self, member: &oxc_ast::ast::MemberExpression<'_>) -> bool { let is_global = |expression: &Expression<'_>, name: &str| { matches!(expression, Expression::Identifier(identifier) - if identifier.name == name && (self.is_global)(name)) + if identifier.name == name && self.is_global(identifier)) }; match member.object() { Expression::StaticMemberExpression(inner) => { @@ -536,22 +534,28 @@ impl Context<'_, '_> { } } + /// Whether `identifier` reads a global, and not a local or an import of + /// the module that shares the name + fn is_global(&self, identifier: &IdentifierReference<'_>) -> bool { + binding_of(self.scoping, identifier).is_none() + } + + /// Whether `identifier` reads the top-level binding the style predicate + /// names: not a local of the same name, and not a global + fn is_style_reference(&self, identifier: &IdentifierReference<'_>) -> bool { + (self.style)(&identifier.name) + && binding_of(self.scoping, identifier).is_some_and(|symbol| { + self.scoping + .get_root_binding(identifier.name.as_str().into()) + == Some(symbol) + }) + } + fn is_style(&self, callee: &Expression<'_>) -> bool { let mut expression = callee; loop { match crate::utils::unwrap_syntax_only(expression) { - Expression::Identifier(identifier) => { - return (self.style)(&identifier.name) - && self.css.is_none_or(|css| { - !css.has_class_names() - || crate::css_prop::binding_of(self.scoping, identifier) - .is_some_and(|symbol| { - self.scoping - .get_root_binding(identifier.name.as_str().into()) - == Some(symbol) - }) - }); - } + Expression::Identifier(identifier) => return self.is_style_reference(identifier), Expression::StaticMemberExpression(member) => expression = &member.object, Expression::CallExpression(call) => expression = &call.callee, _ => return false, @@ -560,19 +564,7 @@ impl Context<'_, '_> { } fn is_style_element(&self, name: &JSXElementName<'_>) -> bool { - if self - .css - .is_some_and(crate::css_prop::CssTakers::has_class_names) - && let JSXElementName::IdentifierReference(identifier) = name - && !crate::css_prop::binding_of(self.scoping, identifier).is_some_and(|symbol| { - self.scoping - .get_root_binding(identifier.name.as_str().into()) - == Some(symbol) - }) - { - return false; - } - crate::imported_constants::jsx_root(name).is_some_and(|root| (self.style)(root)) + jsx_root_identifier(name).is_some_and(|identifier| self.is_style_reference(identifier)) } } @@ -704,7 +696,8 @@ await a;" #[test] fn reads() { insta::assert_debug_snapshot!(describe( - "const a = { x: 1, arrow: () => 1, plain() { return 1; }, nested() { return function () { return this; }; }, self() { return this; } }; + "import { css, Box } from '@devup-ui/react'; import * as Devup from '@devup-ui/react'; +const a = { x: 1, arrow: () => 1, plain() { return 1; }, nested() { return function () { return this; }; }, self() { return this; } }; String(a.x); parseInt(a.x); structuredClone(a); Object.keys(a); Object.freeze(a); Object.hasOwn(a, 'x'); Object.getOwnPropertyNames(a); JSON.stringify(a); Math.max(a.x); console.log(a); Array.isArray(a); css(a); css({ ...a, color: f(a) }); Devup.css(a); css.x`${a}`; css(1)(a); css({ w: a.self() }); ; {a}; ; ; {a}; @@ -754,3 +747,6 @@ function inner() { const kept = { a }; }" )); } } + +#[cfg(test)] +mod scope_tests; diff --git a/libs/extractor/src/mutations/scope_tests.rs b/libs/extractor/src/mutations/scope_tests.rs new file mode 100644 index 000000000..a248f707b --- /dev/null +++ b/libs/extractor/src/mutations/scope_tests.rs @@ -0,0 +1,164 @@ +use oxc_allocator::Allocator; +use oxc_parser::Parser; +use oxc_span::SourceType; + +use super::{Use, uses}; +use crate::css_prop::CssProp; + +const COMPAT: &str = "@devup-ui/react/compat"; + +/// The uses of the bindings of `code`, one line for each, with `styles` the +/// names the module imports from the style packages +fn describe(code: &str, styles: &[&str], css: Option<(CssProp, &str)>) -> Vec { + let allocator = Allocator::default(); + let program = Parser::new(&allocator, code, SourceType::tsx()) + .parse() + .program; + let line = |at: u32| { + code[at as usize..] + .lines() + .next() + .unwrap_or_default() + .to_string() + }; + let mut found: Vec = uses(&program, &|name| styles.contains(&name), css) + .into_iter() + .filter(|(name, _)| name == "a") + .flat_map(|(name, uses)| { + uses.into_iter().map(move |found| match found { + Use::Changes { at, depth } => format!("{name} changes {depth}: {}", line(at)), + Use::Escapes { at, path, into } => { + format!("{name} escapes {path:?} into {into:?}: {}", line(at)) + } + Use::Calls { at, path } => format!("{name} calls {path:?}: {}", line(at)), + }) + }) + .collect(); + found.sort(); + found +} + +#[test] +fn a_local_named_like_a_style_api_is_not_one() { + let styles = ["css", "Box", "Devup"]; + let imports = "import { css, Box } from '@devup-ui/react';\nimport * as Devup from '@devup-ui/react';\nconst a = {};\n"; + for (code, escapes) in [ + ("css(a);", 0), + ("css.x(a);", 0), + (";", 0), + (";", 0), + ("function f(css) { css(a); }", 1), + ("function f() { const css = g; css(a); }", 1), + ("function f(css) { css.x(a); }", 1), + ("function f(Box) { return ; }", 1), + ("function f(Devup) { return ; }", 1), + ] { + let code = format!("{imports}{code}"); + assert_eq!(describe(&code, &styles, None).len(), escapes, "{code}"); + } +} + +#[test] +fn a_global_named_like_a_style_api_is_not_one() { + let styles = ["css", "Box", "Devup"]; + for code in [ + "css(a);", + "css.x(a);", + ";", + ";", + ] { + let code = format!("const a = {{}};\n{code}"); + assert_eq!(describe(&code, &styles, None).len(), 1, "{code}"); + } +} + +#[test] +fn a_local_named_like_a_global_is_not_one() { + let a = "const a = {};\n"; + for (code, expected) in [ + ("Object.assign(a, {});", vec!["a changes 1: a, {});"]), + ( + "function f(Object) { Object.assign(a, {}); }", + vec!["a escapes [] into None: a, {}); }"], + ), + ("String(a);", vec![]), + ( + "function f(String) { String(a); }", + vec!["a escapes [] into None: a); }"], + ), + ("JSON.stringify(a);", vec![]), + ( + "function f(JSON) { JSON.stringify(a); }", + vec!["a escapes [] into None: a); }"], + ), + ] { + assert_eq!( + describe(&format!("{a}{code}"), &[], None), + expected, + "{code}" + ); + } +} + +#[test] +fn a_local_named_like_a_class_names_binding_is_not_one() { + let prelude = "import { ClassNames } from '@emotion/react';\nconst a = {};\n"; + for (code, escapes) in [ + ( + "const x = {({ css }) => css(f(a))};", + 0, + ), + ( + "const x = {({ cx }) => cx`${a}`};", + 0, + ), + ( + "const x = {({ css }) => ((css) => css(f(a)))(css)};", + 1, + ), + ( + "const x = {({ cx }) => ((cx) => cx`${a}`)(cx)};", + 1, + ), + ( + "function h(ClassNames) { return {({ css }) => css(f(a))}; }", + 1, + ), + ] { + let code = format!("{prelude}{code}"); + let css = Some((CssProp::Elements, COMPAT)); + assert_eq!(describe(&code, &[], css).len(), escapes, "{code}"); + } +} + +#[test] +fn a_local_named_like_an_element_builder_takes_no_css_prop() { + let prelude = "import { jsx } from '@emotion/react';\nimport { Box } from '@devup-ui/react';\nconst a = {};\n"; + for (css_prop, code, escapes) in [ + (CssProp::Elements, "jsx('div', { css: f(a) });", 0), + (CssProp::Elements, "jsx(Box, { css: f(a) });", 0), + (CssProp::Elements, ";", 0), + (CssProp::Elements, "
;", 0), + (CssProp::Elements, ";", 1), + (CssProp::Everywhere, ";", 0), + ( + CssProp::Elements, + "function g(jsx) { jsx('div', { css: f(a) }); }", + 1, + ), + ( + CssProp::Elements, + "function g(Box) { jsx(Box, { css: f(a) }); }", + 1, + ), + ( + CssProp::Elements, + "function g(Box) { return ; }", + 1, + ), + ] { + let code = format!("{prelude}{code}"); + let css = Some((css_prop, COMPAT)); + assert_eq!(describe(&code, &["Box"], css).len(), escapes, "{code}"); + } +} diff --git a/libs/extractor/src/scope.rs b/libs/extractor/src/scope.rs new file mode 100644 index 000000000..8e136a7cb --- /dev/null +++ b/libs/extractor/src/scope.rs @@ -0,0 +1,235 @@ +//! Which bindings of a file stand for the APIs the build compiles away. +//! A name decides nothing: a call, an element or a member is compiled only +//! when the binding it reads is the import (or an alias of it) the file +//! declares, so a parameter, a `const` or a function sharing the name stays +//! the code it is. + +mod lookup; +mod required; +mod visits; + +use std::rc::Rc; + +use oxc_ast::ast::{BindingIdentifier, Expression, IdentifierReference, VariableDeclarator}; +use oxc_ast_visit::{Visit, VisitMut}; +use oxc_semantic::Scoping; +use oxc_syntax::symbol::SymbolId; +use rustc_hash::{FxHashMap, FxHashSet}; + +use crate::component::ExportVariableKind; +use crate::stylex::StylexFunction; +use crate::util_type::UtilType; + +pub use visits::requires; + +/// What stands for `namespace.styled` once the visit has read it as a binding: +/// no source text spells it, so no binding can share it +pub const NAMESPACE_STYLED: &str = ".styled"; + +/// The bindings of a program that stand for compiled APIs, by symbol +#[derive(Default)] +pub struct Bindings { + scoping: Rc, + /// Components the package exports + imports: FxHashMap, + /// The package imported whole, whose members are its exports + namespaces: FxHashSet, + util_imports: FxHashMap>, + styled_imports: FxHashSet, + /// Functions building elements from a type and props, by the export + jsx_imports: FxHashMap, + jsx_namespaces: FxHashSet, + global_components: FxHashSet, + class_names_components: FxHashSet, + stylex_namespaces: FxHashSet, + stylex_imports: FxHashMap, + /// The bindings of reads whose copies lost their references, by where + /// they were written + recovered: FxHashMap<(u32, u32), SymbolId>, + /// What the build removes the declaration of, which only its calls and + /// elements may read + compiled: FxHashSet, + /// The `const newCss = css` declarations that go with them + aliases: FxHashSet, +} + +impl Bindings { + pub fn scope(&mut self, scoping: Rc) { + self.scoping = scoping; + } + + /// The binding `identifier` reads: by its reference, or by where it was + /// written when a copy of it lost the reference + pub fn symbol(&self, identifier: &IdentifierReference<'_>) -> Option { + identifier.reference_id.get().map_or_else( + || { + self.recovered + .get(&(identifier.span.start, identifier.span.end)) + .copied() + }, + |reference| self.scoping.get_reference(reference).symbol_id(), + ) + } + + /// Whether `identifier` reads a binding of the module's top level, or a + /// global, and not a local of a function or block sharing the name + pub fn reads_module(&self, identifier: &IdentifierReference<'_>) -> bool { + self.symbol(identifier).is_none_or(|symbol| { + self.scoping.symbol_scope_id(symbol) == self.scoping.root_scope_id() + }) + } + + /// Remember the bindings the reads in `expression` stand for, before code + /// that copies it without their references, which the visit then reads + /// again + pub fn remember(&mut self, expression: &Expression<'_>) { + let mut reads = visits::Remember::new(self); + reads.visit_expression(expression); + let recovered = reads.into_recovered(); + self.recovered.extend(recovered); + } + + /// The import binding a module declares under `name` + pub fn imported(&self, name: &str) -> Option { + self.scoping + .get_root_binding(name.into()) + .filter(|symbol| self.scoping.symbol_flags(*symbol).is_import()) + } + + /// Declare that `symbol` binds what the package exports as `imported`; + /// `false` when the package exports nothing the build compiles by it + pub fn export(&mut self, symbol: Option, imported: &str) -> bool { + symbol.is_some_and(|symbol| { + if let Ok(kind) = imported.parse::() { + self.imports.insert(symbol, kind); + } else if let Some(kind) = UtilType::from_str_opt(imported) { + self.util_imports.insert(symbol, Rc::new(kind)); + } else if imported == "styled" { + self.styled_imports.insert(symbol); + } else { + return false; + } + true + }) + } + + /// Declare that `symbol` binds the package imported whole + pub fn namespace(&mut self, symbol: Option) { + self.namespaces.extend(symbol); + } + + /// Declare that `symbol` binds the module building elements from a type + /// and props + pub fn jsx_namespace(&mut self, symbol: Option) { + self.jsx_namespaces.extend(symbol); + } + + /// Declare that `local` binds the function building elements `imported` + pub fn jsx_import(&mut self, local: &BindingIdentifier<'_>, imported: String) { + if let Some(symbol) = local.symbol_id.get() { + self.jsx_imports.insert(symbol, imported); + } + } + + pub fn global_component(&mut self, symbol: Option) { + self.global_components.extend(symbol); + } + + pub fn class_names_component(&mut self, symbol: Option) { + self.class_names_components.extend(symbol); + } + + pub fn stylex_namespace(&mut self, symbol: Option) { + self.stylex_namespaces.extend(symbol); + } + + pub fn stylex_import(&mut self, symbol: Option, function: StylexFunction) { + if let Some(symbol) = symbol { + self.stylex_imports.insert(symbol, function); + } + } + + /// Declare that the build removes the declaration of `symbol` + pub fn compile(&mut self, symbol: Option) { + self.compiled.extend(symbol); + } + + pub fn is_compiled(&self) -> bool { + !self.compiled.is_empty() + } + + /// Declare an alias of what `declarator` initializes from, when it binds + /// one: `const newCss = css` + pub fn alias(&mut self, declarator: &VariableDeclarator<'_>) { + if let Some(Expression::Identifier(init)) = &declarator.init + && let Some(from) = self.symbol(init) + && let Some(symbol) = declarator + .id + .get_binding_identifier() + .and_then(|id| id.symbol_id.get()) + && self.copy(from, symbol) + { + self.compiled.insert(symbol); + self.aliases.insert(symbol); + } + } + + fn copy(&mut self, from: SymbolId, to: SymbolId) -> bool { + if let Some(util) = self.util_imports.get(&from).cloned() { + self.util_imports.insert(to, util); + } else if self.styled_imports.contains(&from) { + self.styled_imports.insert(to); + } else if let Some(kind) = self.imports.get(&from).cloned() { + self.imports.insert(to, kind); + } else { + return false; + } + true + } + + /// Remove the declarations of the aliases from `program`, wherever they + /// stand + pub fn remove_aliases(&self, program: &mut oxc_ast::ast::Program<'_>) { + if !self.aliases.is_empty() { + visits::RemoveAliases::new(&self.aliases).visit_program(program); + } + } + + /// Where `program` still reads what the build compiled away, with the + /// name read: by the binding each read stands for, not by its name + pub fn compiled_reads(&self, program: &oxc_ast::ast::Program<'_>) -> Vec<(u32, String)> { + let mut reads = visits::CompiledReads::new(self, false); + reads.visit_program(program); + reads.into_found() + } + + /// Where `expression`, about to be written as styles, reads what the + /// build compiled away other than by calling or rendering it: those + /// reads would survive as names of nothing + pub fn lowered_reads(&self, expression: &Expression<'_>) -> Vec<(u32, String)> { + if self.compiled.is_empty() { + return Vec::new(); + } + let mut reads = visits::CompiledReads::new(self, true); + reads.visit_expression(expression); + reads.into_found() + } + + /// [`Self::lowered_reads`] of the attributes of an element + pub fn lowered_attribute_reads( + &self, + attributes: &[oxc_ast::ast::JSXAttributeItem<'_>], + ) -> Vec<(u32, String)> { + if self.compiled.is_empty() { + return Vec::new(); + } + let mut reads = visits::CompiledReads::new(self, true); + for attribute in attributes { + reads.visit_jsx_attribute_item(attribute); + } + reads.into_found() + } +} + +#[cfg(test)] +mod tests; diff --git a/libs/extractor/src/scope/lookup.rs b/libs/extractor/src/scope/lookup.rs new file mode 100644 index 000000000..018248691 --- /dev/null +++ b/libs/extractor/src/scope/lookup.rs @@ -0,0 +1,193 @@ +use std::rc::Rc; + +use oxc_ast::ast::{ + Expression, IdentifierReference, JSXElementName, JSXMemberExpressionObject, ObjectExpression, +}; +use oxc_ast_visit::Visit; +use oxc_syntax::symbol::SymbolId; +use rustc_hash::{FxHashMap, FxHashSet}; + +use super::{Bindings, NAMESPACE_STYLED, visits}; +use crate::component::ExportVariableKind; +use crate::stylex::StylexFunction; +use crate::util_type::UtilType; + +impl Bindings { + /// The export of the package `expression` reads as a member of the + /// package imported whole + fn member<'e>(&self, expression: &'e Expression<'_>) -> Option<&'e str> { + self.member_of(&self.namespaces, expression) + } + + fn member_of<'e>( + &self, + namespaces: &FxHashSet, + expression: &'e Expression<'_>, + ) -> Option<&'e str> { + if let Expression::StaticMemberExpression(member) = expression + && let Expression::Identifier(object) = &member.object + && self + .symbol(object) + .is_some_and(|symbol| namespaces.contains(&symbol)) + { + Some(member.property.name.as_str()) + } else { + None + } + } + + /// The component `expression` reads + pub fn kind(&self, expression: &Expression<'_>) -> Option { + match expression { + Expression::Identifier(identifier) => self + .symbol(identifier) + .and_then(|symbol| self.imports.get(&symbol)) + .cloned(), + expression => self.member(expression).and_then(|name| name.parse().ok()), + } + } + + /// The component the element `name` renders + pub fn element(&self, name: &JSXElementName<'_>) -> Option { + match name { + JSXElementName::IdentifierReference(identifier) => self + .symbol(identifier) + .and_then(|symbol| self.imports.get(&symbol)) + .cloned(), + JSXElementName::MemberExpression(member) => self + .namespace_object(&member.object) + .then(|| member.property.name.as_str().parse().ok()) + .flatten(), + _ => None, + } + } + + fn namespace_object(&self, object: &JSXMemberExpressionObject<'_>) -> bool { + matches!(object, JSXMemberExpressionObject::IdentifierReference(identifier) + if self.is_namespace(identifier)) + } + + /// Whether `identifier` reads the package imported whole + pub fn is_namespace(&self, identifier: &IdentifierReference<'_>) -> bool { + self.symbol(identifier) + .is_some_and(|symbol| self.namespaces.contains(&symbol)) + } + + /// Whether the element `name` is the `Global` component + pub fn is_global_component(&self, name: &JSXElementName<'_>) -> bool { + match name { + JSXElementName::IdentifierReference(identifier) => self + .symbol(identifier) + .is_some_and(|symbol| self.global_components.contains(&symbol)), + JSXElementName::MemberExpression(member) => { + self.namespace_object(&member.object) && member.property.name == "Global" + } + _ => false, + } + } + + /// Whether `name` is the `ClassNames` component + pub fn is_class_names(&self, name: &IdentifierReference<'_>) -> bool { + self.symbol(name) + .is_some_and(|symbol| self.class_names_components.contains(&symbol)) + } + + /// The style function `callee` reads: `css`, `Devup.keyframes`, ... + pub fn util(&self, callee: &Expression<'_>) -> Option> { + match callee { + Expression::Identifier(identifier) => self + .symbol(identifier) + .and_then(|symbol| self.util_imports.get(&symbol)) + .cloned(), + callee => self + .member(callee) + .and_then(UtilType::from_str_opt) + .map(Rc::new), + } + } + + /// Whether `expression` reads `styled` + pub fn is_styled(&self, expression: &Expression<'_>) -> bool { + match expression { + Expression::Identifier(identifier) => { + identifier.name == NAMESPACE_STYLED + || self + .symbol(identifier) + .is_some_and(|symbol| self.styled_imports.contains(&symbol)) + } + expression => self.member(expression) == Some("styled"), + } + } + + /// Whether `callee` is a style function or a supported styled factory + pub fn compiles(&self, callee: &Expression<'_>) -> bool { + self.util(callee).is_some() || self.is_styled(callee) || self.styled_factory(callee) + } + + fn styled_factory(&self, callee: &Expression<'_>) -> bool { + match callee { + Expression::CallExpression(call) => { + self.is_styled(&call.callee) + || matches!(&call.callee, Expression::StaticMemberExpression(member) + if matches!(member.property.name.as_str(), "attrs" | "withConfig") + && matches!(call.arguments.as_slice(), [argument] if argument.is_expression()) + && self.styled_factory(&member.object)) + } + Expression::StaticMemberExpression(member) => self.is_styled(&member.object), + _ => false, + } + } + + /// Whether `callee` reads the imported `styled` + pub fn styles(&self, callee: &Expression<'_>) -> bool { + self.is_styled(callee) + } + + /// Whether `identifier` reads a component the build renders in its place + pub fn renders(&self, identifier: &IdentifierReference<'_>) -> bool { + self.symbol(identifier).is_some_and(|symbol| { + self.imports.contains_key(&symbol) + || self.global_components.contains(&symbol) + || self.class_names_components.contains(&symbol) + }) + } + + /// Whether the file may read `styled` + pub fn may_style(&self) -> bool { + !self.styled_imports.is_empty() || !self.namespaces.is_empty() + } + + /// The function building elements `callee` reads + pub fn jsx_function(&self, callee: &Expression<'_>) -> Option { + match callee { + Expression::Identifier(identifier) => self + .symbol(identifier) + .and_then(|symbol| self.jsx_imports.get(&symbol)) + .cloned(), + callee => self + .member_of(&self.jsx_namespaces, callee) + .map(str::to_string), + } + } + + /// The `StyleX` API `callee` reads, as a named import or as a member of + /// the package imported whole + pub fn stylex_function(&self, callee: &Expression<'_>) -> Option { + match callee { + Expression::Identifier(identifier) => self + .symbol(identifier) + .and_then(|symbol| self.stylex_imports.get(&symbol)) + .cloned(), + callee => self + .member_of(&self.stylex_namespaces, callee) + .and_then(StylexFunction::from_export_name), + } + } + + /// The bindings the names `object` reads all stand for, by name + pub fn visible(&self, object: &ObjectExpression<'_>) -> FxHashMap { + let mut reads = visits::Names::new(self); + reads.visit_object_expression(object); + reads.into_visible() + } +} diff --git a/libs/extractor/src/scope/required.rs b/libs/extractor/src/scope/required.rs new file mode 100644 index 000000000..0cacf42ee --- /dev/null +++ b/libs/extractor/src/scope/required.rs @@ -0,0 +1,53 @@ +use oxc_ast::ast::{Argument, BindingPattern, Expression, VariableDeclarator}; + +use super::Bindings; +use crate::utils::get_string_by_property_key; + +impl Bindings { + /// Declare what `declarator` binds when it requires `package` or the JSX + /// runtime: `const { css } = require('@devup-ui/react')` + pub fn require(&mut self, declarator: &VariableDeclarator<'_>, package: &str) { + let Some(Expression::CallExpression(call)) = &declarator.init else { + return; + }; + let (Expression::Identifier(callee), [Argument::StringLiteral(source)]) = + (&call.callee, call.arguments.as_slice()) + else { + return; + }; + if callee.name != "require" || self.symbol(callee).is_some() { + return; + } + let from_package = if source.value == "react/jsx-runtime" { + false + } else if source.value == package { + true + } else { + return; + }; + match &declarator.id { + BindingPattern::BindingIdentifier(binding) if from_package => { + self.namespace(binding.symbol_id.get()); + } + BindingPattern::BindingIdentifier(binding) => { + self.jsx_namespace(binding.symbol_id.get()); + } + BindingPattern::ObjectPattern(object) => { + let exports = object.properties.iter().filter_map(|prop| { + Some(( + get_string_by_property_key(&prop.key)?, + prop.value.get_binding_identifier()?, + )) + }); + for (name, local) in exports { + if from_package { + self.export(local.symbol_id.get(), &name); + } else { + self.jsx_import(local, name); + } + } + } + _ => {} + } + } +} diff --git a/libs/extractor/src/scope/tests.rs b/libs/extractor/src/scope/tests.rs new file mode 100644 index 000000000..430953154 --- /dev/null +++ b/libs/extractor/src/scope/tests.rs @@ -0,0 +1,70 @@ +use css::{class_map::reset_class_map, file_map::reset_file_map}; +use oxc_allocator::Allocator; +use oxc_ast_visit::VisitMut; +use oxc_codegen::Codegen; +use oxc_parser::Parser; +use oxc_span::SourceType; + +use crate::visit::DevupVisitor; + +mod cjs_aliases; +mod class_names; +mod components; +mod css_bindings; +mod diagnostics; +mod imports; +mod lowered_reads; +mod preflight_contexts; +mod scoping; +mod stylex; +mod theme_reads; + +struct Visited { + code: String, + errors: Vec, + styles: usize, +} + +fn visit_with(code: &str, setup: impl FnOnce(&mut DevupVisitor<'_>)) -> Visited { + reset_class_map(); + reset_file_map(); + let allocator = Allocator::default(); + let mut program = Parser::new(&allocator, code, SourceType::tsx()) + .parse() + .program; + let mut visitor = + DevupVisitor::new(&allocator, "test.tsx", "@devup-ui/react", Vec::new(), None); + setup(&mut visitor); + visitor.visit_program(&mut program); + let printed = Codegen::new().build(&program).code; + Visited { + code: printed.split_whitespace().collect::>().join(" "), + errors: visitor + .errors + .iter() + .map(|(_, message)| message.clone()) + .collect(), + styles: visitor.styles.into_styles("test.tsx").len(), + } +} + +fn visit(code: &str) -> Visited { + visit_with(code, |_| {}) +} + +const IMPORT_CSS: &str = "import { css } from '@devup-ui/react';\n"; + +fn extracted(code: &str) -> Result { + reset_class_map(); + reset_file_map(); + crate::extract( + "test.tsx", + code, + crate::ExtractOption { + import_main_css: false, + ..crate::ExtractOption::default() + }, + ) + .map(|output| output.code.split_whitespace().collect::>().join(" ")) + .map_err(|error| error.to_string()) +} diff --git a/libs/extractor/src/scope/tests/cjs_aliases.rs b/libs/extractor/src/scope/tests/cjs_aliases.rs new file mode 100644 index 000000000..651a8532b --- /dev/null +++ b/libs/extractor/src/scope/tests/cjs_aliases.rs @@ -0,0 +1,51 @@ +use serial_test::serial; + +use super::extracted; + +fn compiled(code: &str) -> String { + extracted(code).unwrap_or_else(|error| format!("error: {error}")) +} + +#[test] +#[serial] +fn comma_separated_declarators_alias_what_the_require_before_them_binds() { + for code in [ + "const { css: a } = require('@devup-ui/react'), b = a;\nexport const x = b({ color: 'red' });", + "const { css } = require('@devup-ui/react'), a = css, b = a;\nexport const x = b({ color: 'red' });", + "const { css: a } = require('@devup-ui/react');\nconst b = a;\nexport const x = b({ color: 'red' });", + "import { css as a } from '@devup-ui/react';\nconst b = a, c = b;\nexport const x = c({ color: 'red' });", + ] { + let output = compiled(code); + assert!( + output.contains("export const x = \"a-a\""), + "{code}: {output}" + ); + assert!( + !output.contains("b(") && !output.contains("c(") && !output.contains(" = a"), + "{code}: {output}" + ); + } +} + +#[test] +#[serial] +fn an_alias_is_read_by_the_functions_declared_before_it() { + let output = compiled( + "const { css } = require('@devup-ui/react'), f = () => b({ color: 'red' }), b = css;\n\ + export const x = f();", + ); + assert!(!output.contains("error"), "{output}"); + assert!(!output.contains("b ="), "{output}"); +} + +#[test] +#[serial] +fn a_declarator_not_required_from_the_package_stays_code() { + let output = compiled( + "const { css: a } = require('other'), b = a;\nexport const x = b({ color: 'red' });", + ); + assert!( + output.contains("b = a") && output.contains("b({ color: 'red' })"), + "{output}" + ); +} diff --git a/libs/extractor/src/scope/tests/class_names.rs b/libs/extractor/src/scope/tests/class_names.rs new file mode 100644 index 000000000..5232090fe --- /dev/null +++ b/libs/extractor/src/scope/tests/class_names.rs @@ -0,0 +1,45 @@ +use serial_test::serial; + +use super::visit; + +#[test] +#[serial] +fn class_names_calls_follow_the_bindings_the_child_function_declares() { + let visited = visit( + "import { ClassNames } from '@devup-ui/react/compat';\n\ + export const a = {({ css, cx }) =>
};\n\ + export const b = {({ css }) =>
css({ color: 'blue' }))((x) => x)} />};\n\ + export const c = ({ ClassNames }) => {({ css }) =>
};\n\ + export const d = {({ css: paint }) =>
};", + ); + assert_eq!(visited.errors, Vec::::new()); + assert_eq!(visited.styles, 2); + for expected in [ + "export const a =
;", + "((css) => css({ color: \"blue\" }))((x) => x)", + "{({ css }) =>
}", + "export const d =
;", + ] { + assert!( + visited.code.contains(expected), + "{expected}: {}", + visited.code + ); + } +} + +#[test] +#[serial] +fn class_names_reports_reads_of_the_bindings_it_takes_only() { + let visited = visit( + "import { ClassNames } from '@devup-ui/react/compat';\n\ + export const a = {({ css, theme }) =>
};\n\ + export const b = {({ css, theme }) =>
[css, theme])(1, 2)} />};", + ); + assert_eq!(visited.errors.len(), 1, "{:?}", visited.errors); + assert!( + visited.errors[0].contains("`` cannot use `css`"), + "{:?}", + visited.errors + ); +} diff --git a/libs/extractor/src/scope/tests/components.rs b/libs/extractor/src/scope/tests/components.rs new file mode 100644 index 000000000..388f08536 --- /dev/null +++ b/libs/extractor/src/scope/tests/components.rs @@ -0,0 +1,177 @@ +use serial_test::serial; + +use super::{visit, visit_with}; +use crate::css_prop::CssProp; + +#[test] +#[serial] +fn components_follow_the_binding_their_element_reads() { + let visited = visit( + "import { Box, Flex as flex } from '@devup-ui/react';\n\ + export const a = ({ Box }) => ;\n\ + export const b = ;\n\ + export function c(Box) { return ; }\n\ + export const d = ;\n\ + export const e = ;", + ); + assert_eq!( + visited.errors, + vec![ + "`Box` is read at runtime, where it does not exist: the build compiles it only where it is called or rendered" + .to_string() + ] + ); + assert_eq!(visited.styles, 1); + for local in [ + "", + "", + "", + "", + ] { + assert!(visited.code.contains(local), "{local}: {}", visited.code); + } + assert!( + visited.code.contains("
"), + "{}", + visited.code + ); +} + +#[test] +#[serial] +fn global_component_follows_its_binding() { + let visited = visit( + "import { Global } from '@devup-ui/react/compat';\n\ + import * as D from '@devup-ui/react';\n\ + export const a = ;\n\ + export const b = ({ Global }) => ;\n\ + export const c = ;\n\ + export const d = (D) => ;", + ); + assert_eq!(visited.errors, Vec::::new()); + assert_eq!(visited.styles, 2); + assert!( + visited.code.contains("export const a = ;"), + "{}", + visited.code + ); + assert!( + visited.code.contains("export const c = ;"), + "{}", + visited.code + ); + for local in [ + "", + "", + ] { + assert!(visited.code.contains(local), "{local}: {}", visited.code); + } +} + +#[test] +#[serial] +fn styled_follows_the_binding_it_is_read_through() { + let visited = visit( + "import { styled, Box } from '@devup-ui/react';\n\ + import * as D from '@devup-ui/react';\n\ + export const A = styled.div({ color: 'red' });\n\ + export const B = D.styled.div({ color: 'blue' });\n\ + export const C = styled('div', { color: 'green' });\n\ + export const E = D.styled('div', { color: 'orange' });\n\ + export const F = styled(Box)({ color: 'pink' });\n\ + export const G = styled(D.Box)({ color: 'gray' });\n\ + export const H = styled.div.attrs({ role: 'note' })({ color: 'teal' });\n\ + export const I = D.styled.div.attrs({ role: 'note' })({ color: 'navy' });\n\ + export function f(styled, D) {\n\ + \treturn [styled.div({ color: 'red' }), styled('div', { color: 'red' }), styled.div.attrs({ role: 'note' })({ color: 'red' }), D.styled.div({ color: 'red' }), D.styled('div', { color: 'red' })];\n\ + }", + ); + assert_eq!(visited.errors, Vec::::new()); + assert_eq!(visited.styles, 8); + assert!( + visited.code.contains( + "return [ styled.div({ color: \"red\" }), styled(\"div\", { color: \"red\" }), styled.div.attrs({ role: \"note\" })({ color: \"red\" }), D.styled.div({ color: \"red\" }), D.styled(\"div\", { color: \"red\" }) ];" + ), + "{}", + visited.code + ); + assert!( + !visited.code.contains("export const A = styled") + && !visited.code.contains("export const I = D.styled"), + "{}", + visited.code + ); +} + +#[test] +#[serial] +fn imported_jsx_function_is_read_through_its_binding() { + let visited = visit( + "import { jsx } from 'react/jsx-runtime';\n\ + import { Box } from '@devup-ui/react';\n\ + export const a = jsx(Box, { p: 1 });\n\ + export function f(jsx) { return jsx('div', { p: 2 }); }", + ); + assert_eq!(visited.errors, Vec::::new()); + assert_eq!(visited.styles, 1); + assert!( + visited + .code + .contains("export const a = jsx(\"div\", { className: \"a\" });"), + "{}", + visited.code + ); + assert!( + visited.code.contains("return jsx(\"div\", { p: 2 });"), + "{}", + visited.code + ); +} + +#[test] +#[serial] +fn css_prop_reaches_only_the_components_the_file_imports() { + let visited = visit_with( + "import { Box } from '@devup-ui/react';\n\ + export const a = ;\n\ + export const b = ({ Box }) => ;", + |visitor| visitor.takes_css_prop(CssProp::Elements), + ); + assert_eq!(visited.errors, Vec::::new()); + assert_eq!(visited.styles, 1); + assert!( + visited + .code + .contains("export const a =
;"), + "{}", + visited.code + ); + assert!( + visited.code.contains(""), + "{}", + visited.code + ); +} + +#[test] +#[serial] +fn calls_read_once_are_walked_again_with_the_bindings_they_read() { + let visited = visit( + "import { jsx } from 'react/jsx-runtime';\n\ + import { Box, css } from '@devup-ui/react';\n\ + export const a = jsx(Box, { ...props(), p: 1, className: css({ color: 'red' }) });\n\ + export function f(css) { return jsx(Box, { ...props(), p: 2, className: css({ color: 'blue' }) }); }", + ); + assert_eq!(visited.errors, Vec::::new(), "{}", visited.code); + assert_eq!(visited.styles, 3); + assert!( + !visited.code.contains("css({ color: \"red\" })"), + "{}", + visited.code + ); + assert!( + visited.code.contains("css({ color: \"blue\" })"), + "{}", + visited.code + ); +} diff --git a/libs/extractor/src/scope/tests/css_bindings.rs b/libs/extractor/src/scope/tests/css_bindings.rs new file mode 100644 index 000000000..05ef2196f --- /dev/null +++ b/libs/extractor/src/scope/tests/css_bindings.rs @@ -0,0 +1,125 @@ +use serial_test::serial; + +use super::{IMPORT_CSS, extracted, visit}; + +#[test] +#[serial] +fn style_function_shadowed_by_any_binding_stays_a_call() { + for declaration in [ + "function f(css) { return css({ color: 'red' }); }", + "const f = (css) => css({ color: 'red' });", + "function f() { const css = (x) => x; return css({ color: 'red' }); }", + "function f() { let css = (x) => x; return css({ color: 'red' }); }", + "function f() { var css = (x) => x; return css({ color: 'red' }); }", + "function f() { function css(x) { return x; } return css({ color: 'red' }); }", + "function f() { class css {} return css({ color: 'red' }); }", + "function f() { try { g(); } catch (css) { return css({ color: 'red' }); } }", + "function f() { { const css = (x) => x; return css({ color: 'red' }); } }", + "function f() { return [1].map((css) => css({ color: 'red' })); }", + ] { + let visited = visit(&format!( + "{IMPORT_CSS}export {declaration}\nexport const real = css({{ color: 'blue' }});" + )); + assert_eq!(visited.errors, Vec::::new(), "{declaration}"); + assert_eq!(visited.styles, 1, "{declaration}"); + assert!( + visited.code.contains("css({ color: \"red\" })"), + "{declaration}: {}", + visited.code + ); + assert!( + visited.code.ends_with("export const real = \"a\";"), + "{declaration}: {}", + visited.code + ); + } +} + +#[test] +#[serial] +fn minified_import_alias_shadowed_by_a_parameter() { + let visited = visit( + "import{css as a}from'@devup-ui/react';export const x=a({color:'red'});export const f=a=>a({color:'blue'});", + ); + assert_eq!(visited.errors, Vec::::new()); + assert_eq!(visited.styles, 1); + assert_eq!( + visited.code, + "export const x = \"a\"; export const f = (a) => a({ color: \"blue\" });" + ); +} + +#[test] +#[serial] +fn keyframes_global_css_and_templates_follow_their_bindings() { + let visited = visit( + "import { css, keyframes, globalCss } from '@devup-ui/react';\n\ + export function a() { class keyframes {} return keyframes({ from: { color: 'red' } }); }\n\ + export function b() { { const globalCss = (x) => x; return globalCss({ body: { margin: 0 } }); } }\n\ + export function c() { function css(x) { return x; } return css`color: red;`; }\n\ + export function d(keyframes) { return keyframes`from { color: red; }`; }\n\ + export function e(globalCss) { return globalCss`body { margin: 0; }`; }\n\ + export const real = css`color: blue;`;\n\ + export const frames = keyframes({ from: { color: 'red' } });\n\ + globalCss({ body: { margin: 0 } });", + ); + assert_eq!(visited.errors, Vec::::new()); + assert_eq!(visited.styles, 3); + for local in [ + "keyframes({ from: { color: \"red\" } })", + "globalCss({ body: { margin: 0 } })", + "css`color: red;`", + "keyframes`from { color: red; }`", + "globalCss`body { margin: 0; }`", + ] { + assert!(visited.code.contains(local), "{local}: {}", visited.code); + } + assert!( + visited.code.contains("export const real = \"a\";"), + "{}", + visited.code + ); +} + +#[test] +#[serial] +fn aliases_follow_the_binding_wherever_they_are_declared() { + let visited = visit(&format!( + "{IMPORT_CSS}\ + export function f() {{ const a = css; {{ const b = a; return b({{ color: 'red' }}); }} }}\n\ + export function g(css) {{ const c = css; return c({{ color: 'blue' }}); }}\n\ + export function h() {{ const keep = 1, d = css; return d({{ color: 'green' }}) + keep; }}" + )); + assert_eq!(visited.errors, Vec::::new()); + assert_eq!(visited.styles, 2); + assert_eq!( + visited.code, + "export function f() { { return \"a\"; } } \ + export function g(css) { const c = css; return c({ color: \"blue\" }); } \ + export function h() { const keep = 1; return \"b\" + keep; }" + ); +} + +#[test] +#[serial] +fn minified_alias_called_with_runtime_arguments_stays_a_call() { + let code = "import{css as a}from'@devup-ui/react';\ + export const x=a({color:'red'});\ + export function f(a,getValue){return a(getValue())}\ + export const g=(a)=>a('ordinary runtime argument');"; + let visited = visit(code); + assert_eq!(visited.errors, Vec::::new()); + assert_eq!(visited.styles, 1); + for expected in [ + "export const x = \"a\";", + "return a(getValue());", + "a(\"ordinary runtime argument\")", + ] { + assert!( + visited.code.contains(expected), + "{expected}: {}", + visited.code + ); + } + assert!(extracted(code).is_ok()); +} diff --git a/libs/extractor/src/scope/tests/diagnostics.rs b/libs/extractor/src/scope/tests/diagnostics.rs new file mode 100644 index 000000000..44e102ceb --- /dev/null +++ b/libs/extractor/src/scope/tests/diagnostics.rs @@ -0,0 +1,178 @@ +use css::{class_map::reset_class_map, file_map::reset_file_map}; +use serial_test::serial; + +use super::{IMPORT_CSS, extracted, visit}; + +#[test] +#[serial] +fn a_read_of_a_removed_alias_is_reported_where_it_is_read() { + let visited = visit(&format!( + "{IMPORT_CSS}\ + export function f() {{ const c = css; return [c]; }}\n\ + export function g(c) {{ return [c]; }}\n\ + export const keep = css;" + )); + assert_eq!( + visited.errors, + vec![ + "`c` is read at runtime, where it does not exist: the build compiles it only where it is called or rendered" + .to_string(), + "`css` is read at runtime, where it does not exist: the build compiles it only where it is called or rendered" + .to_string(), + ] + ); +} + +#[test] +#[serial] +fn shadowed_imports_stay_code_through_the_whole_extraction() { + reset_class_map(); + reset_file_map(); + let output = crate::extract( + "test.tsx", + "import { css, Box } from '@devup-ui/react';\n\ + export function f(css) { return css({ color: 'red' }); }\n\ + export const g = ({ Box }) => ;\n\ + export const real = css({ color: 'blue' });\n\ + export const view = ;", + crate::ExtractOption { + import_main_css: false, + ..crate::ExtractOption::default() + }, + ) + .map(|output| output.code.split_whitespace().collect::>().join(" ")); + assert_eq!( + output.map_err(|error| error.to_string()), + Ok("import \"@devup-ui/react/devup-ui-0.css\"; \ + export function f(css) { return css({ color: \"red\" }); } \ + export const g = ({ Box }) => ; \ + export const real = \"a-a\"; \ + export const view =
;" + .to_string()) + ); +} + +#[test] +#[serial] +fn changed_top_level_binding_is_reported_but_a_local_of_the_same_name_is_not() { + let own = extracted( + "import { css, Box } from '@devup-ui/react';\n\ + const base = { p: 4 };\nbase.p = 8;\n\ + export const a = css(base);", + ); + assert!(own.is_err_and(|error| error.contains("`css()` cannot use `base`"))); + + let element = extracted( + "import { Box } from '@devup-ui/react';\n\ + const base = { p: 4 };\nbase.p = 8;\n\ + export const a = ;", + ); + assert!(element.is_err_and(|error| error.contains("`` cannot use `base`"))); + + let shadowed = extracted( + "import { css, Box } from '@devup-ui/react';\n\ + const base = { p: 4 };\nbase.p = 8;\n\ + export function f(base) { return [css(base), ]; }\n\ + export const real = css({ color: 'red' });", + ); + assert!( + shadowed + .as_ref() + .is_ok_and(|code| code.contains("export const real = \"a-a\"") + && code.contains("
;", + "const rules = { color: 'red' };\nrules.color = 'blue';\nexport const a = (rules) => ;", + ), + ( + "const rules = compute();\nexport const a = ;", + "const rules = compute();\nexport const a = (rules) => ;", + ), + ] { + assert!( + compile(own).is_err_and(|error| error.contains("cannot use `rules`")), + "{own}" + ); + assert!( + compile(shadowed).is_ok_and(|code| code.contains("
")), + "{shadowed}" + ); + } +} + +#[test] +#[serial] +fn a_global_read_is_not_a_removed_alias_of_the_same_name() { + for (import, label) in [ + ("import { css } from '@devup-ui/react';", "import"), + ("const { css } = require('@devup-ui/react');", "require"), + ] { + let visited = visit(&format!( + "{import}\n\ + export function f() {{ const a = css; return a({{ color: 'red' }}); }}\n\ + export function g() {{ return a(); }}" + )); + assert_eq!(visited.errors, Vec::::new(), "{label}"); + assert!( + visited.code.contains("return a();"), + "{label}: {}", + visited.code + ); + assert!( + !visited.code.contains("const a = css"), + "{label}: {}", + visited.code + ); + } + let raw = visit(&format!( + "{IMPORT_CSS}export function f() {{ const a = css; return a({{ color: 'red' }}); }}\n\ + export const raw = [css];" + )); + assert_eq!(raw.errors.len(), 1, "{:?}", raw.errors); + assert!(raw.errors[0].starts_with("`css` is read at runtime")); +} diff --git a/libs/extractor/src/scope/tests/imports.rs b/libs/extractor/src/scope/tests/imports.rs new file mode 100644 index 000000000..f9c2b00ad --- /dev/null +++ b/libs/extractor/src/scope/tests/imports.rs @@ -0,0 +1,85 @@ +use serial_test::serial; + +use super::visit; + +#[test] +#[serial] +fn package_imported_whole_is_read_through_its_binding() { + for import in [ + "import * as D from '@devup-ui/react';", + "import D from '@devup-ui/react';", + ] { + let visited = visit(&format!( + "{import}\n\ + export const a = D.css({{ color: 'red' }});\n\ + export function b(D) {{ return D.css({{ color: 'blue' }}); }}\n\ + export const c = ;\n\ + export const d = (D) => ;\n\ + export const e = D.unknown({{ color: 'green' }});\n\ + export const f = ;\n\ + export const g = ;\n\ + export const h = ;\n\ + export const i = ;" + )); + assert_eq!(visited.errors, Vec::::new(), "{import}"); + assert_eq!(visited.styles, 2, "{import}"); + for expected in [ + "export const a = \"a\";", + "D.css({ color: \"blue\" })", + "
", + "(D) => ", + "D.unknown({ color: \"green\" })", + "", + "", + "", + "", + ] { + assert!( + visited.code.contains(expected), + "{import} {expected}: {}", + visited.code + ); + } + } +} + +#[test] +#[serial] +fn required_package_is_read_through_the_bindings_that_hold_it() { + let visited = visit( + "const { Box: B, css, ['styled']: s, ...rest } = require('@devup-ui/react');\n\ + const [x] = require('@devup-ui/react');\n\ + const { a: { b } } = require('@devup-ui/react');\n\ + const { jsx: j } = require('react/jsx-runtime');\n\ + const runtime = require('react/jsx-runtime'), D = require('@devup-ui/react');\n\ + export const t = j(B, { p: 1 });\n\ + export const u = css({ color: 'red' });\n\ + export function f(css) { return css({ color: 'blue' }); }\n\ + export const v = runtime.jsx(D.Box, { p: 2 });\n\ + export const w = D.css({ color: 'green' });\n\ + export function g(runtime, D) { return [runtime.jsx(D.Box, { p: 3 }), D.css({ color: 'teal' })]; }\n\ + export function h(require) {\n\ + \tconst { css: d } = require('@devup-ui/react');\n\ + \tconst E = require('@devup-ui/react');\n\ + \tconst r = require('react/jsx-runtime');\n\ + \treturn [d({ color: 'pink' }), E.css({ color: 'gray' }), r.jsx(E.Box, { p: 4 })];\n\ + }", + ); + assert_eq!(visited.errors, Vec::::new()); + assert_eq!(visited.styles, 4); + for expected in [ + "export const t = j(\"div\", { className: \"a\" });", + "export const u = \"b\";", + "css({ color: \"blue\" })", + "export const v = runtime.jsx(\"div\", { className: \"c\" });", + "export const w = \"d\";", + "return [runtime.jsx(D.Box, { p: 3 }), D.css({ color: \"teal\" })];", + "return [ d({ color: \"pink\" }), E.css({ color: \"gray\" }), r.jsx(E.Box, { p: 4 }) ];", + ] { + assert!( + visited.code.contains(expected), + "{expected}: {}", + visited.code + ); + } +} diff --git a/libs/extractor/src/scope/tests/lowered_reads.rs b/libs/extractor/src/scope/tests/lowered_reads.rs new file mode 100644 index 000000000..d71865b3a --- /dev/null +++ b/libs/extractor/src/scope/tests/lowered_reads.rs @@ -0,0 +1,124 @@ +use serial_test::serial; + +use super::{IMPORT_CSS, extracted, visit}; + +const READ: &str = "`css` is read at runtime, where it does not exist: the build compiles it only where it is called or rendered"; + +const IMPORT_BOX_CSS: &str = "import { Box, css } from '@devup-ui/react';\n"; + +fn reads(code: &str) -> Vec { + visit(&format!("{IMPORT_BOX_CSS}{code}")).errors +} + +#[test] +#[serial] +fn a_read_of_the_import_lowered_into_styles_is_reported() { + for code in [ + "export const a = ;", + "export const a = ;", + "export const a = ;", + "export const a = ;", + "export const a = ;", + "export const a = ;", + ] { + assert_eq!(reads(code), vec![READ.to_string()], "{code}"); + } +} + +#[test] +#[serial] +fn a_read_of_the_import_in_the_arguments_of_a_compiled_call_is_reported() { + for code in [ + "export const a = ;", + "export const a = css({ color: css });", + "export const a = css`color: ${css};`;", + "export const a = css({ color: 'red' }, css);", + ] { + let errors = reads(code); + assert_eq!( + errors.first(), + Some(&READ.to_string()), + "{code}: {errors:?}" + ); + } +} + +#[test] +#[serial] +fn a_read_of_the_import_in_a_jsx_call_is_reported() { + let visited = visit( + "import { jsx } from 'react/jsx-runtime';\n\ + import { Box, css } from '@devup-ui/react';\n\ + export const a = jsx(Box, { p: css });", + ); + assert_eq!(visited.errors, vec![READ.to_string()]); +} + +#[test] +#[serial] +fn a_read_of_an_alias_lowered_into_styles_is_reported() { + let visited = visit(&format!( + "{IMPORT_BOX_CSS}const alias = css;\nexport const a = ;" + )); + assert_eq!( + visited.errors, + vec![READ.replace("`css`", "`alias`")], + "{}", + visited.code + ); +} + +#[test] +#[serial] +fn a_local_or_a_global_sharing_the_name_is_a_value() { + for code in [ + "export const a = (css) => ;", + "export const a = ({ css }) => ;", + "export function f() { const css = 1; return ; }", + ] { + assert_eq!(reads(code), Vec::::new(), "{code}"); + } + let global = visit("import { Box } from '@devup-ui/react';\nexport const a = ;"); + assert_eq!(global.errors, Vec::::new()); + assert!(global.code.contains("css"), "{}", global.code); +} + +#[test] +#[serial] +fn nested_compiled_calls_and_elements_are_not_reads() { + for code in [ + "export const a = ;", + "export const a = ;", + "export const a = ;", + "export const a = css({ color: 'red' }, css({ color: 'blue' }));", + ] { + let visited = visit(&format!("{IMPORT_BOX_CSS}{code}")); + assert_eq!(visited.errors, Vec::::new(), "{code}"); + assert!(visited.styles > 0, "{code}"); + } + let styled = visit( + "import { Box, styled } from '@devup-ui/react';\n\ + export const A = styled(Box)({ color: 'red' });\n\ + export const B = styled.div({ color: 'blue' });\n\ + export const C = styled(Box).attrs({ id: 'c' })({ color: 'green' });", + ); + assert_eq!(styled.errors, Vec::::new()); +} + +#[test] +#[serial] +fn the_whole_extraction_rejects_the_read_with_where_it_is() { + let rejected = extracted(&format!( + "{IMPORT_CSS}export const a =
;" + )); + assert!(rejected.is_err_and(|error| error.contains("`css` is read at runtime"))); + let lowered = + extracted("import { Box, css } from '@devup-ui/react';\nexport const a = ;"); + assert!(lowered.is_err_and(|error| error.contains("`css` is read at runtime"))); + let shadowed = extracted( + "import { Box, css } from '@devup-ui/react';\n\ + export const a = (css) => ;\n\ + export const b = css({ color: 'red' });", + ); + assert!(shadowed.is_ok(), "{shadowed:?}"); +} diff --git a/libs/extractor/src/scope/tests/preflight_contexts.rs b/libs/extractor/src/scope/tests/preflight_contexts.rs new file mode 100644 index 000000000..3fa9655aa --- /dev/null +++ b/libs/extractor/src/scope/tests/preflight_contexts.rs @@ -0,0 +1,206 @@ +use serial_test::serial; + +use super::{extracted, visit}; + +const IMPORTS: &str = "import { Box, css, styled } from '@devup-ui/react';"; + +#[test] +#[serial] +fn nested_factory_components_compile_when_the_factory_binding_is_supported() { + for factory in ["jsx", "jsxs", "jsxDEV"] { + let code = format!( + "{IMPORTS} import {{ {factory} }} from 'react/jsx-runtime'; + export const a = {factory}(Box, {{children: {factory}(Box, {{p: 1}})}});" + ); + let output = extracted(&code).unwrap_or_else(|error| panic!("{error}")); + assert_eq!( + output.matches(&format!("{factory}(\"div\"")).count(), + 2, + "{output}" + ); + } +} + +#[test] +#[serial] +fn raw_reads_are_rejected_when_nested_factories_keep_them() { + for nested in [ + "jsx(Box, {id: css})", + "jsx(Box, {p: 1}, css)", + "jsx(Box, {p: 1}, ...[css])", + "jsx.call(null, Box, {p: 1})", + "jsx.apply(null, [Box, {p: 1}])", + "jsx.random(Box, {p: 1})", + "((jsx) => jsx(Box, {p: 1}))(other)", + "jsx(Box)", + "jsx.random(css, {p: 1})", + ] { + let code = format!( + "{IMPORTS} import {{ jsx }} from 'react/jsx-runtime'; + export const a = jsx(Box, {{children: {nested}}});" + ); + let Err(error) = extracted(&code) else { + panic!("raw factory read compiled: {nested}"); + }; + assert!(error.contains("is read at runtime"), "{nested}: {error}"); + } +} + +#[test] +#[serial] +fn style_function_members_are_rejected_when_the_visitor_does_not_compile_them() { + for value in [ + "css.call(null, {color: 'blue'})", + "css.apply(null, [{color: 'blue'}])", + "css.random({color: 'blue'})", + "css.attrs({id: 'x'})", + "css.withConfig({displayName: 'x'})", + "css.random`color: blue;`", + "styled.div.random({color: 'blue'})", + ] { + let code = format!("{IMPORTS} export const a = ;"); + let Err(error) = extracted(&code) else { + panic!("unsupported member compiled: {value}"); + }; + assert!(error.contains("is read at runtime"), "{value}: {error}"); + } +} + +#[test] +#[serial] +fn namespace_jsx_factories_compile_when_their_members_are_supported() { + let output = extracted(&format!( + "{IMPORTS} const React = require('react/jsx-runtime'); + export const a = React.jsx(Box, {{children: React.jsxs(Box, {{p: 1}})}});" + )) + .unwrap_or_else(|error| panic!("{error}")); + assert!(output.contains("React.jsxs(\"div\""), "{output}"); +} + +#[test] +#[serial] +fn raw_attrs_reads_are_rejected_when_the_import_would_be_removed() { + for attrs in [ + "{id: css}", + "{className: css}", + "{style: {color: css}}", + "() => ({id: css})", + "() => ({className: css})", + "() => ({style: {color: css}})", + "{id: css.call(null, {color: 'blue'})}", + "{id: css.apply(null, [{color: 'blue'}])}", + "{id: css.random({color: 'blue'})}", + ] { + let code = + format!("{IMPORTS} export const A = styled.div.attrs({attrs})({{color: 'red'}});"); + let Err(error) = extracted(&code) else { + panic!("raw attrs read compiled: {attrs}"); + }; + assert!( + error.contains("`css` is read at runtime"), + "{attrs}: {error}" + ); + } +} + +#[test] +#[serial] +fn attrs_compile_nested_calls_when_their_original_bindings_are_available() { + for attrs in [ + "{id: css({color: 'blue'})}", + "{className: css({color: 'blue'})}", + "() => ({className: css({color: 'blue'})})", + "{children: }", + "{children: jsx(Box, {p: 1})}", + ] { + let code = format!( + "{IMPORTS} import {{jsx}} from 'react/jsx-runtime'; + export const A = styled.div.attrs({attrs})({{color: 'red'}}); + export const B = styled(A)({{color: 'green'}});" + ); + let visited = visit(&code); + assert_eq!(visited.errors, Vec::::new(), "{attrs}"); + assert!(visited.styles >= 2, "{attrs}: {}", visited.code); + assert!(!visited.code.contains("css("), "{}", visited.code); + assert!(!visited.code.contains(" ({id: css})", + "() => { const css = 'local'; return {id: css}; }", + "css => ({id: css({color: 'blue'})})", + ] { + let code = + format!("{IMPORTS} export const A = styled.div.attrs({attrs})({{color: 'red'}});"); + let output = extracted(&code).unwrap_or_else(|error| panic!("{error}")); + assert!(output.contains("css"), "{output}"); + } +} + +#[test] +#[serial] +fn ignored_config_metadata_is_not_a_runtime_read() { + let output = extracted(&format!( + "{IMPORTS} export const A = styled.div.withConfig({{displayName: css}})({{color: 'red'}});" + )) + .unwrap_or_else(|error| panic!("{error}")); + assert!(!output.contains("displayName"), "{output}"); + let nested = extracted(&format!( + "{IMPORTS} export const a = ;" + )).unwrap_or_else(|error| panic!("{error}")); + assert!(!nested.contains("displayName"), "{nested}"); +} + +#[test] +#[serial] +fn namespace_factories_reject_raw_reads_when_styles_or_attrs_keep_them() { + for factory in [ + "D.styled.div({color: css})", + "D.styled.div`color: ${css};`", + "D.styled.div.attrs({id: css})({color: 'red'})", + "D.styled.div.attrs({className: css})`color: red;`", + "D.styled(Box)({color: css})", + "D.styled(Box).attrs(() => ({id: css}))({color: 'red'})", + ] { + let code = + format!("{IMPORTS} const D = require('@devup-ui/react'); export const A = {factory};"); + let Err(error) = extracted(&code) else { + panic!("raw namespace read compiled: {factory}"); + }; + assert!( + error.contains("`css` is read at runtime"), + "{factory}: {error}" + ); + } +} + +#[test] +#[serial] +fn namespace_bases_compile_when_the_component_binding_is_rendered() { + for factory in [ + "D.styled(Box)({color: 'red'})", + "D.styled(D.Box)({color: 'red'})", + "D.styled(Box).attrs({id: css({color: 'blue'})})`color: red;`", + ] { + let code = + format!("{IMPORTS} const D = require('@devup-ui/react'); export const A = {factory};"); + let output = extracted(&code).unwrap_or_else(|error| panic!("{error}")); + assert!(!output.contains("D.styled"), "{output}"); + } +} + +#[test] +#[serial] +fn namespace_members_stay_ordinary_when_the_namespace_is_shadowed() { + let output = extracted(&format!( + "{IMPORTS} const D = require('@devup-ui/react'); + export const f = (D, css) => D.styled.div.attrs({{id: css}})({{color: css}});" + )) + .unwrap_or_else(|error| panic!("{error}")); + assert!(output.contains("D.styled.div.attrs"), "{output}"); +} diff --git a/libs/extractor/src/scope/tests/scoping.rs b/libs/extractor/src/scope/tests/scoping.rs new file mode 100644 index 000000000..90926333e --- /dev/null +++ b/libs/extractor/src/scope/tests/scoping.rs @@ -0,0 +1,58 @@ +use css::{class_map::reset_class_map, file_map::reset_file_map}; +use oxc_allocator::Allocator; +use oxc_ast_visit::VisitMut; +use oxc_codegen::Codegen; +use oxc_parser::Parser; +use oxc_span::SourceType; +use serial_test::serial; + +use super::visit; +use crate::visit::DevupVisitor; + +#[test] +#[serial] +fn files_that_bind_nothing_the_build_compiles_build_no_scoping() { + let comment = + visit("// @devup-ui/react\nconst css = (x) => x;\nexport const a = css({ color: 'red' });"); + assert_eq!(comment.errors, Vec::::new()); + assert_eq!(comment.styles, 0); + assert!( + comment.code.contains("css({ color: \"red\" })"), + "{}", + comment.code + ); + + let other = visit("const css = require('other');\nexport const a = css({ color: 'red' });"); + assert_eq!(other.styles, 0); + assert!( + other.code.contains("css({ color: \"red\" })"), + "{}", + other.code + ); +} + +#[test] +#[serial] +fn analysis_given_to_the_visitor_is_reused() { + reset_class_map(); + reset_file_map(); + let allocator = Allocator::default(); + let code = "import { css } from '@devup-ui/react';\nexport function f(css) { return css({ color: 'red' }); }\nexport const a = css({ color: 'blue' });"; + let mut program = Parser::new(&allocator, code, SourceType::tsx()) + .parse() + .program; + let scoping = std::rc::Rc::new( + oxc_semantic::SemanticBuilder::new() + .build(&program) + .semantic + .into_scoping(), + ); + let mut visitor = + DevupVisitor::new(&allocator, "test.tsx", "@devup-ui/react", Vec::new(), None); + visitor.reuse_scoping(Some(scoping)); + visitor.visit_program(&mut program); + let printed = Codegen::new().build(&program).code; + assert!(printed.contains("css({ color: \"red\" })"), "{printed}"); + assert!(printed.contains("export const a = \"a\";"), "{printed}"); + assert_eq!(visitor.styles.into_styles("test.tsx").len(), 1); +} diff --git a/libs/extractor/src/scope/tests/stylex.rs b/libs/extractor/src/scope/tests/stylex.rs new file mode 100644 index 000000000..50f3e3e94 --- /dev/null +++ b/libs/extractor/src/scope/tests/stylex.rs @@ -0,0 +1,105 @@ +use rustc_hash::FxHashMap; +use serial_test::serial; + +use super::{visit, visit_with}; + +#[test] +#[serial] +fn stylex_follows_the_bindings_of_its_namespaces() { + let visited = visit( + "import * as stylex from '@stylexjs/stylex';\n\ + import { props } from '@stylexjs/stylex';\n\ + const styles = stylex.create({ base: { color: 'red' } });\n\ + export const a = stylex.props(styles.base);\n\ + export const b = props(styles.base);\n\ + export function c(stylex) { return stylex.props(styles.base); }\n\ + export function d(props) { return props(styles.base); }\n\ + export function e(styles) { return stylex.props(styles.base); }\n\ + export function f() { const styles = stylex.create({ other: { color: 'blue' } }); return stylex.props(styles.other); }", + ); + assert_eq!(visited.errors, Vec::::new()); + assert_eq!(visited.styles, 2); + for expected in [ + "export const a = { className: \"a\" };", + "export const b = { className: \"a\" };", + "return stylex.props(styles.base);", + "return props(styles.base);", + "return { className: [styles.base].flat(Infinity).filter(Boolean).join(\" \") };", + "return { className: \"b\" };", + ] { + assert!( + visited.code.contains(expected), + "{expected}: {}", + visited.code + ); + } +} + +#[test] +#[serial] +fn stylex_variables_and_names_follow_the_bindings_that_hold_them() { + let visited = visit( + "import * as stylex from '@stylexjs/stylex';\n\ + const vars = stylex.defineVars({ color: 'red' });\n\ + const consts = stylex.defineConsts({ gap: '8px' });\n\ + const fade = stylex.keyframes({ from: { opacity: 0 } });\n\ + const theme = stylex.createTheme(vars, { color: 'blue' });\n\ + const styles = stylex.create({ base: { color: vars.color, marginTop: consts.gap, animationName: fade } });\n\ + const more = stylex.create({ extra: { ...stylex.include(styles.base), padding: 0 } });\n\ + export const a = stylex.props(theme, styles.base, more.extra);\n\ + export function f(vars) { return stylex.create({ other: { color: vars.color } }); }\n\ + export function g(fade) { return stylex.create({ other: { animationName: fade } }); }\n\ + export function h(styles) { return stylex.create({ other: { ...stylex.include(styles.base) } }); }\n\ + export function i(vars) { return stylex.createTheme(vars, { color: 'green' }); }", + ); + assert_eq!( + visited + .errors + .iter() + .map(|error| error.split(" at build time").next().unwrap_or_default()) + .collect::>(), + vec![ + "`stylex.create()` cannot use `vars.color`", + "`stylex.create()` cannot use `fade`", + "`stylex.include()` cannot use `styles.base`", + "`stylex.createTheme()` cannot use `vars, { color: \"green\" }`", + ] + ); +} + +#[test] +#[serial] +fn stylex_values_imported_from_other_modules_bind_to_their_imports() { + let visited = visit_with( + "import * as stylex from '@stylexjs/stylex';\n\ + import { vars, darkTheme } from './vars.stylex';\n\ + const styles = stylex.create({ base: { color: vars.color } });\n\ + export const a = stylex.props(darkTheme, styles.base);\n\ + export function f(vars, darkTheme) { return stylex.props(darkTheme, stylex.create({ other: { color: vars.color } })); }", + |visitor| { + visitor.import_stylex( + FxHashMap::from_iter([( + "vars".to_string(), + FxHashMap::from_iter([("color".to_string(), "--x".to_string())]), + )]), + FxHashMap::from_iter([ + ("darkTheme".to_string(), "dark-theme".to_string()), + ("absent".to_string(), "none".to_string()), + ]), + ); + }, + ); + assert_eq!(visited.errors.len(), 1, "{:?}", visited.errors); + assert!( + visited.errors[0].contains("`stylex.create()` cannot use `vars.color`"), + "{:?}", + visited.errors + ); + assert!( + visited + .code + .contains("export const a = { className: \"dark-theme a\" };"), + "{}", + visited.code + ); +} diff --git a/libs/extractor/src/scope/tests/theme_reads.rs b/libs/extractor/src/scope/tests/theme_reads.rs new file mode 100644 index 000000000..dc0e4bf45 --- /dev/null +++ b/libs/extractor/src/scope/tests/theme_reads.rs @@ -0,0 +1,99 @@ +use serial_test::serial; + +use super::{visit, visit_with}; +use crate::css_prop::CssProp; + +const IMPORT: &str = "import { ClassNames } from '@devup-ui/react/compat';\n"; + +fn class_names(rendered: &str) -> super::Visited { + visit(&format!( + "{IMPORT}export const a = {{({{ css, cx, theme }}) => {rendered}}};" + )) +} + +#[test] +#[serial] +fn a_local_sharing_the_name_of_the_theme_is_not_the_theme() { + for rendered in [ + "
theme.on)(state) })} />", + "
", + "
{ const theme = state; return theme.on; })() })} />", + "
{ { const theme = state; return theme.on; } })() })} />", + "
theme.on) })} />", + "
theme.on)(1, state) })} />", + ] { + let visited = class_names(rendered); + assert_eq!(visited.errors, Vec::::new(), "{rendered}"); + } +} + +#[test] +#[serial] +fn the_theme_the_child_function_takes_becomes_css_variables() { + let visited = class_names( + "
theme.m)(1) })} />", + ); + assert_eq!(visited.errors, Vec::::new()); + assert_eq!(visited.styles, 1); + assert!( + visited.code.contains("((theme) => theme.m)(1)"), + "{}", + visited.code + ); + assert!( + !visited.code.contains("var(--m)"), + "the parameter is not the theme: {}", + visited.code + ); +} + +#[test] +#[serial] +fn a_direct_read_of_the_theme_it_cannot_write_is_still_reported() { + for rendered in [ + "
", + "
theme.on)(state) })} />", + "
theme.on)(theme.on) })} />", + ] { + let visited = class_names(rendered); + assert_eq!(visited.errors.len(), 1, "{rendered}: {:?}", visited.errors); + assert!( + visited.errors[0].contains("`` cannot use `theme.on`"), + "{rendered}: {:?}", + visited.errors + ); + } +} + +#[test] +#[serial] +fn a_function_of_the_theme_in_a_css_prop_reads_only_its_own_parameter() { + let compile = |code: &str| { + visit_with( + &format!("import {{ Box }} from '@devup-ui/react';\n{code}"), + |visitor| visitor.takes_css_prop(CssProp::Elements), + ) + }; + let own = + compile("export const a = ({ color: theme.colors.primary })} />;"); + assert_eq!(own.errors, Vec::::new()); + assert!(own.code.contains("className"), "{}", own.code); + + let local = + compile("export const a = ({ color: ((theme) => theme.a)(1) })} />;"); + assert!( + !local.errors.iter().any(|error| error.contains("theme.a")), + "{:?}", + local.errors + ); + + let global = compile("export const a = ({ color: theme.a })} />;"); + assert!( + !global + .errors + .iter() + .any(|error| error.contains("`theme.a`")), + "a free `theme` is not a parameter: {:?}", + global.errors + ); +} diff --git a/libs/extractor/src/scope/visits.rs b/libs/extractor/src/scope/visits.rs new file mode 100644 index 000000000..9e8c79931 --- /dev/null +++ b/libs/extractor/src/scope/visits.rs @@ -0,0 +1,230 @@ +use oxc_ast::ast::{ + Argument, CallExpression, Expression, IdentifierReference, JSXElementName, Statement, TSType, + TaggedTemplateExpression, +}; +use oxc_ast_visit::{Visit, VisitMut, walk, walk_mut}; +use oxc_syntax::symbol::SymbolId; +use rustc_hash::{FxHashMap, FxHashSet}; + +use super::Bindings; + +/// The binding of every read in what it visits, by where the read was written +pub struct Remember<'s> { + bindings: &'s Bindings, + recovered: FxHashMap<(u32, u32), SymbolId>, +} + +impl<'s> Remember<'s> { + pub fn new(bindings: &'s Bindings) -> Self { + Self { + bindings, + recovered: FxHashMap::default(), + } + } + + pub fn into_recovered(self) -> FxHashMap<(u32, u32), SymbolId> { + self.recovered + } +} + +impl<'a> Visit<'a> for Remember<'_> { + fn visit_identifier_reference(&mut self, it: &IdentifierReference<'a>) { + if let Some(symbol) = self.bindings.symbol(it) { + self.recovered.insert((it.span.start, it.span.end), symbol); + } + } +} + +/// The binding each name read stands for, none when its reads differ +pub struct Names<'s> { + bindings: &'s Bindings, + names: FxHashMap>, +} + +impl<'s> Names<'s> { + pub fn new(bindings: &'s Bindings) -> Self { + Self { + bindings, + names: FxHashMap::default(), + } + } + + pub fn into_visible(self) -> FxHashMap { + self.names + .into_iter() + .filter_map(|(name, symbol)| Some((name, symbol?))) + .collect() + } +} + +impl<'a> Visit<'a> for Names<'_> { + fn visit_identifier_reference(&mut self, it: &IdentifierReference<'a>) { + let symbol = self.bindings.symbol(it); + self.names + .entry(it.name.to_string()) + .and_modify(|seen| { + if *seen != symbol { + *seen = None; + } + }) + .or_insert(symbol); + } +} + +/// The reads a program keeps, outside the types it erases, of bindings whose +/// declarations the build removes +pub struct CompiledReads<'s> { + bindings: &'s Bindings, + /// Whether the calls and elements the build compiles are not reads: the + /// code is read before they are + before_lowering: bool, + found: Vec<(u32, String)>, +} + +impl<'s> CompiledReads<'s> { + pub const fn new(bindings: &'s Bindings, before_lowering: bool) -> Self { + Self { + bindings, + before_lowering, + found: Vec::new(), + } + } + + pub fn into_found(self) -> Vec<(u32, String)> { + self.found + } + + /// The arguments of `call`, a call the build compiles, but not the base + /// `styled(Box)` takes + fn compiled_call(&mut self, call: &CallExpression<'_>) { + let base = self.bindings.styles(&call.callee); + let metadata = matches!(&call.callee, Expression::StaticMemberExpression(member) + if member.property.name == "withConfig" && self.bindings.compiles(&member.object)); + for (index, argument) in call.arguments.iter().enumerate() { + let renders = argument + .as_expression() + .is_some_and(|expression| self.bindings.kind(expression).is_some()); + if !(metadata || base && index == 0 && renders) { + self.visit_argument(argument); + } + } + self.compiled_target(&call.callee); + } + + /// What `callee`, the target of a call the build compiles, is called on + fn compiled_target(&mut self, callee: &Expression<'_>) { + match callee { + Expression::CallExpression(call) => self.compiled_call(call), + Expression::StaticMemberExpression(member) => self.compiled_target(&member.object), + _ => {} + } + } +} + +impl<'a> Visit<'a> for CompiledReads<'_> { + fn visit_identifier_reference(&mut self, it: &IdentifierReference<'a>) { + if self + .bindings + .symbol(it) + .is_some_and(|symbol| self.bindings.compiled.contains(&symbol)) + { + self.found.push((it.span.start, it.name.to_string())); + } + } + + fn visit_call_expression(&mut self, it: &CallExpression<'a>) { + if self.before_lowering + && self + .bindings + .jsx_function(&it.callee) + .is_some_and(|function| matches!(function.as_str(), "jsx" | "jsxs" | "jsxDEV")) + && it.arguments.get(1).is_some_and(Argument::is_expression) + && it + .arguments + .first() + .and_then(Argument::as_expression) + .is_some_and(|component| self.bindings.kind(component).is_some()) + { + self.visit_expression(&it.callee); + for argument in it.arguments.iter().skip(1) { + self.visit_argument(argument); + } + } else if self.before_lowering && self.bindings.compiles(&it.callee) { + self.compiled_call(it); + } else { + walk::walk_call_expression(self, it); + } + } + + fn visit_tagged_template_expression(&mut self, it: &TaggedTemplateExpression<'a>) { + if self.before_lowering && self.bindings.compiles(&it.tag) { + self.compiled_target(&it.tag); + self.visit_template_literal(&it.quasi); + } else { + walk::walk_tagged_template_expression(self, it); + } + } + + fn visit_jsx_element_name(&mut self, it: &JSXElementName<'a>) { + let rendered = matches!(it, JSXElementName::IdentifierReference(identifier) + if self.bindings.renders(identifier)); + if !(self.before_lowering && rendered) { + walk::walk_jsx_element_name(self, it); + } + } + + fn visit_ts_type(&mut self, _: &TSType<'a>) {} +} + +/// Whether `program` calls `require` for `package` +pub fn requires(program: &oxc_ast::ast::Program<'_>, package: &str) -> bool { + let mut requires = Requires { + package, + found: false, + }; + requires.visit_program(program); + requires.found +} + +struct Requires<'p> { + package: &'p str, + found: bool, +} + +impl<'a> Visit<'a> for Requires<'_> { + fn visit_call_expression(&mut self, it: &CallExpression<'a>) { + self.found |= matches!(&it.callee, Expression::Identifier(callee) if callee.name == "require") + && matches!(it.arguments.as_slice(), [Argument::StringLiteral(source)] if source.value == self.package); + walk::walk_call_expression(self, it); + } +} + +/// Removes the declarations of aliases, wherever they stand +pub struct RemoveAliases<'s> { + aliases: &'s FxHashSet, +} + +impl<'s> RemoveAliases<'s> { + pub const fn new(aliases: &'s FxHashSet) -> Self { + Self { aliases } + } +} + +impl<'a> VisitMut<'a> for RemoveAliases<'_> { + fn visit_statements(&mut self, it: &mut oxc_allocator::Vec<'a, Statement<'a>>) { + walk_mut::walk_statements(self, it); + it.retain_mut(|statement| { + let Statement::VariableDeclaration(declaration) = statement else { + return true; + }; + declaration.declarations.retain(|declarator| { + !declarator + .id + .get_binding_identifier() + .and_then(|id| id.symbol_id.get()) + .is_some_and(|symbol| self.aliases.contains(&symbol)) + }); + !declaration.declarations.is_empty() + }); + } +} diff --git a/libs/extractor/src/style_values.rs b/libs/extractor/src/style_values.rs index 73707842d..421302220 100644 --- a/libs/extractor/src/style_values.rs +++ b/libs/extractor/src/style_values.rs @@ -2,6 +2,8 @@ //! `const fadeIn = keyframes({ ... })`: the build gives it a name, so styles //! reading the binding read that name +use std::rc::Rc; + use oxc_allocator::{CloneIn, FromIn, GetAllocator}; use oxc_ast::ast::{Argument, BindingPattern, Expression, Str, TemplateLiteral}; use oxc_ast::builder::AstBuilder; @@ -30,7 +32,7 @@ pub enum StyleValue { #[derive(Default)] pub struct StyleValues<'a> { - scoping: Option>, + scoping: Option>, values: FxHashMap, /// Known imported styles, including top-level constant aliases, by binding imported: FxHashMap>, @@ -42,7 +44,9 @@ pub struct StyleValues<'a> { } impl<'a> StyleValues<'a> { - pub fn new(scoping: std::rc::Rc) -> Self { + /// The values of the bindings `scoping`, which the other readers of the + /// program share, tells + pub fn new(scoping: Rc) -> Self { Self { scoping: Some(scoping), values: FxHashMap::default(), @@ -57,14 +61,6 @@ impl<'a> StyleValues<'a> { self.scoping.is_some() } - pub fn reads_root_binding(&self, identifier: &oxc_ast::ast::IdentifierReference<'_>) -> bool { - self.scoping.as_ref().is_none_or(|scoping| { - scoping - .get_root_binding(identifier.name.as_str().into()) - .is_none_or(|root| self.reference_symbol(identifier) == Some(root)) - }) - } - pub fn import(&mut self, imported: FxHashMap>) { self.imported = imported; } diff --git a/libs/extractor/src/stylex.rs b/libs/extractor/src/stylex.rs index f7c272c5e..39ce07ef7 100644 --- a/libs/extractor/src/stylex.rs +++ b/libs/extractor/src/stylex.rs @@ -25,9 +25,19 @@ pub enum StylexFunction { DefineConsts, PositionTry, ViewTransitionClass, + /// Reads inside the value of a `stylex.create()` style + FirstThatWorks, + /// Reads inside a `stylex.create()` namespace, spread + Include, + /// The `types` object, whose members wrap a `stylex.defineVars()` value + Types, } -const STYLEX_EXPORTS: [(&str, StylexFunction); 10] = [ +/// Tells which `StyleX` API a callee or member object reads, by the binding it +/// reads and not by its spelling +pub type StylexResolver<'r> = &'r dyn Fn(&Expression<'_>) -> Option; + +const STYLEX_EXPORTS: [(&str, StylexFunction); 13] = [ ("create", StylexFunction::Create), ("props", StylexFunction::Props), ("attrs", StylexFunction::Attrs), @@ -38,6 +48,9 @@ const STYLEX_EXPORTS: [(&str, StylexFunction); 10] = [ ("defineConsts", StylexFunction::DefineConsts), ("positionTry", StylexFunction::PositionTry), ("viewTransitionClass", StylexFunction::ViewTransitionClass), + ("firstThatWorks", StylexFunction::FirstThatWorks), + ("include", StylexFunction::Include), + ("types", StylexFunction::Types), ]; impl StylexFunction { @@ -57,11 +70,13 @@ impl StylexFunction { .map_or("", |(name, _)| name) } - /// What a call must be to compile away, for the functions that do + /// What a call must be to compile away, for the functions that do. The + /// helpers read inside an enclosing `StyleX` call, which reports what it + /// cannot read of them #[must_use] pub const fn requirement(&self) -> Option<&'static str> { match self { - Self::Props | Self::Attrs => None, + Self::Props | Self::Attrs | Self::FirstThatWorks | Self::Include | Self::Types => None, Self::CreateTheme => Some( "it takes a `defineVars()` group, of this file or imported, and an object literal", ), @@ -93,9 +108,12 @@ pub type Conditions = Vec<(AtRuleKind, String)>; /// The values a `StyleX` variable takes: a literal, or a condition object of a /// `default` and at-rule keys (`@media`, `@supports`, `@container`), either /// possibly wrapped in `types.*()`. `None` when a value is not static. -pub fn variable_values(value: &Expression<'_>) -> Option> { +pub fn variable_values( + value: &Expression<'_>, + resolver: StylexResolver<'_>, +) -> Option> { let mut values = Vec::new(); - collect_variable_values(value, &mut Vec::new(), &mut values)?; + collect_variable_values(value, &mut Vec::new(), &mut values, resolver)?; Some(values) } @@ -103,8 +121,9 @@ fn collect_variable_values( value: &Expression<'_>, conditions: &mut Conditions, values: &mut Vec<(Conditions, String)>, + resolver: StylexResolver<'_>, ) -> Option<()> { - let value = unwrap_types_call(value); + let value = unwrap_types_call(value, resolver); if let Some(text) = get_string_by_literal_expression(value) { values.push((conditions.clone(), text.into_owned())); return Some(()); @@ -121,21 +140,24 @@ fn collect_variable_values( }; let key = get_string_by_property_key(&property.key)?; if key == "default" { - collect_variable_values(&property.value, conditions, values)?; + collect_variable_values(&property.value, conditions, values, resolver)?; continue; } let (kind, query) = split_at_rule_key(&key)?; conditions.push((kind, normalize_query(query))); - collect_variable_values(&property.value, conditions, values)?; + collect_variable_values(&property.value, conditions, values, resolver)?; conditions.pop(); } Some(()) } #[must_use] -pub fn unwrap_types_call<'b, 'a>(value: &'b Expression<'a>) -> &'b Expression<'a> { +pub fn unwrap_types_call<'b, 'a>( + value: &'b Expression<'a>, + resolver: StylexResolver<'_>, +) -> &'b Expression<'a> { match value { - Expression::CallExpression(call) if is_types_call(&call.callee) => call + Expression::CallExpression(call) if is_types_call(&call.callee, resolver) => call .arguments .first() .and_then(oxc_ast::ast::Argument::as_expression) @@ -190,37 +212,14 @@ pub fn css_variable_block(selector: &str, assignments: &[(String, String)]) -> S css } -/// Check if a call expression is `stylex.firstThatWorks()` or named `firstThatWorks()`. -pub fn is_first_that_works_call(callee: &Expression) -> bool { - // stylex.firstThatWorks(...) - if let Expression::StaticMemberExpression(member) = callee - && member.property.name.as_str() == "firstThatWorks" - { - return true; - } - // firstThatWorks(...) (named import) - if let Expression::Identifier(ident) = callee - && ident.name.as_str() == "firstThatWorks" - { - return true; - } - false +/// Whether `callee` reads the `firstThatWorks` the package gives +pub fn is_first_that_works_call(callee: &Expression, resolver: StylexResolver<'_>) -> bool { + resolver(callee) == Some(StylexFunction::FirstThatWorks) } -/// Check if a call expression is `stylex.include()` or named `include()`. -/// This is a static check that does NOT require access to the visitor. -pub fn is_include_call_static(callee: &Expression) -> bool { - if let Expression::StaticMemberExpression(member) = callee - && member.property.name.as_str() == "include" - { - return true; - } - if let Expression::Identifier(ident) = callee - && ident.name.as_str() == "include" - { - return true; - } - false +/// Whether `callee` reads the `include` the package gives +pub fn is_include_call_static(callee: &Expression, resolver: StylexResolver<'_>) -> bool { + resolver(callee) == Some(StylexFunction::Include) } /// A reference to a stylex.include(base.member) call found inside `stylex.create()`. @@ -231,19 +230,11 @@ pub struct StylexIncludeRef { pub offset: u32, } -/// Check if a call expression is `stylex.types.X()` or `types.X()` (type wrapper). -pub fn is_types_call(callee: &Expression) -> bool { - if let Expression::StaticMemberExpression(member) = callee { - // stylex.types.X(...) - if let Expression::StaticMemberExpression(inner) = &member.object { - return inner.property.name.as_str() == "types"; - } - // types.X(...) (named import) - if let Expression::Identifier(ident) = &member.object { - return ident.name.as_str() == "types"; - } - } - false +/// Whether `callee` is a member of the `types` the package gives, as +/// `stylex.types.color` or `types.color` +pub fn is_types_call(callee: &Expression, resolver: StylexResolver<'_>) -> bool { + matches!(callee, Expression::StaticMemberExpression(member) + if resolver(&member.object) == Some(StylexFunction::Types)) } /// Convert camelCase CSS property name to kebab-case. @@ -548,6 +539,7 @@ pub fn decompose_value_conditions( parent_selectors: &[SelectorPart], leaf: &dyn Fn(&str, &Expression) -> Option, errors: &mut Vec<(u32, String)>, + resolver: StylexResolver<'_>, ) -> Vec { if let Some(s) = leaf(css_property, value) { return decomposed_leaf(css_property, Some(s), parent_selectors) @@ -564,7 +556,7 @@ pub fn decompose_value_conditions( // CallExpression: firstThatWorks() → multiple fallback values with current selectors if let Expression::CallExpression(call) = value - && is_first_that_works_call(&call.callee) + && is_first_that_works_call(&call.callee, resolver) { let mut results = vec![]; for arg in call.arguments.iter().rev() { @@ -584,7 +576,7 @@ pub fn decompose_value_conditions( // CallExpression: types.*() → extract inner value, pass through selectors if let Expression::CallExpression(call) = value - && is_types_call(&call.callee) + && is_types_call(&call.callee, resolver) && let Some(s) = call .arguments .first() @@ -645,6 +637,7 @@ pub fn decompose_value_conditions( &selectors, leaf, errors, + resolver, )); } @@ -731,10 +724,11 @@ mod tests { let leaf = |property: &str, value: &Expression| { stylex_value(property, value).map(std::borrow::Cow::into_owned) }; - let styles: Vec<_> = decompose_value_conditions("color", value, &[], &leaf, &mut vec![]) - .into_iter() - .map(|style| (style.value, style.selector.map(|s| s.to_string()))) - .collect(); + let styles: Vec<_> = + decompose_value_conditions("color", value, &[], &leaf, &mut vec![], &|_| None) + .into_iter() + .map(|style| (style.value, style.selector.map(|s| s.to_string()))) + .collect(); assert_eq!( styles, vec![ diff --git a/libs/extractor/src/stylex_capture_tests.rs b/libs/extractor/src/stylex_capture_tests.rs new file mode 100644 index 000000000..68ae933e1 --- /dev/null +++ b/libs/extractor/src/stylex_capture_tests.rs @@ -0,0 +1,183 @@ +#[rstest] +#[case::default(("function f(out=sx.API(flag() && s.base)){}", "parameter default"))] +#[case::destructured(("function f({out=sx.API(flag() && s.base)}={}){}", "parameter default"))] +#[case::arrow_default(("const f=(out=sx.API(flag() && s.base))=>out;", "parameter default"))] +#[case::instance_field(("class C { out=sx.API(flag() && s.base); }", "class field initializer"))] +#[case::static_field(("class C { static out=sx.API(flag() && s.base); }", "class field initializer"))] +#[case::private_field(("class C { #out=sx.API(flag() && s.base); }", "class field initializer"))] +#[case::accessor_field(("class C { accessor out=sx.API(flag() && s.base); }", "class field initializer"))] +#[serial] +fn stylex_capture_is_located_when_storage_has_no_execution_local_statement( + #[case] sample: (&str, &str), + #[values("props", "attrs")] api: &str, +) { + // Given: evaluating an expression-only initializer needs local condition storage. + reset_class_map(); + reset_file_map(); + let body = sample.0.replace("API", api); + let source = format!("import * as sx from '@stylexjs/stylex';\nconst s=sx.create({{base:{{color:'red',backgroundColor:'blue'}}}});\n{body}"); + // When + let error = extract("capture-context.js", &source, ExtractOption::default()).unwrap_err().to_string(); + // Then: the diagnostic is at the condition, not at the call or enclosing declaration. + let column = body.find("flag()").unwrap() + 1; + assert!(error.starts_with(&format!("capture-context.js:3:{column}:")), "{error}"); + assert!(error.contains(sample.1) && error.contains("move the value into a variable"), "{error}"); + eprintln!("CONTEXT {error}"); +} + +#[rstest] +#[case::concise(("const render=()=>jsx('div',sx.API((trace.push('once'),true)&&s.base));", "render()", "[\"once\"]"))] +#[case::method(("class C { render(){return jsx('div',sx.API((trace.push('once'),true)&&s.base));}} function render(){return new C().render();}", "render()", "[\"once\"]"))] +#[case::getter(("const c={get out(){return sx.API((trace.push('once'),true)&&s.base);}}; function render(){return jsx('div',c.out);}", "render()", "[\"once\"]"))] +#[case::setter(("const c={set out(v){this.value=sx.API((trace.push(v),true)&&s.base);}}; function render(){c.out='once';return jsx('div',c.value);}", "render()", "[\"once\"]"))] +#[case::recursive(("function choose(n){const out=sx.API((trace.push(n),true)&&s.base,(n>0?(choose(n-1),false):false)&&s.later);return out;} function render(){return jsx('div',choose(1));}", "render()", "[1,0]"))] +#[case::tdz(("function render(){try{sx.API(value&&s.base);}catch(e){trace.push(e.name);}let value=true;return jsx('div',sx.API(value&&s.base));}", "render()", "[\"ReferenceError\"]"))] +#[case::shadow_collision(("const _s0=7; function render(){let _s1=9;{let _s2=11;trace.push(_s0,_s1,_s2);return jsx('div',sx.API((trace.push('once'),true)&&s.base));}}", "render()", "[7,9,11,\"once\"]"))] +#[case::closure_loop(("function render(){const fs=[];for(let i=0;i<2;i++){const out=sx.API((trace.push(i),true)&&s.base);fs.push(()=>out);}return jsx('div',fs[0]());}", "render()", "[0,1]"))] +#[case::short_circuit(("function render(){false&&sx.API((trace.push('bad'),true)&&s.base);return jsx('div',sx.API((trace.push('once'),true)&&s.base));}", "render()", "[\"once\"]"))] +#[case::generator(("function* gen(n){return sx.API((trace.push(n),true)&&s.base,(yield n)&&s.later);}function render(){const a=gen('a'),b=gen('b');a.next();b.next();const out=a.next(false).value;b.next(true);return jsx('div',out);}", "render()", "[\"a\",\"b\"]"))] +#[case::field_arrow(("class C { render=()=>jsx('div',sx.API((trace.push('once'),true)&&s.base));}function render(){return new C().render();}", "render()", "[\"once\"]"))] +#[case::default_arrow(("function f(render=()=>jsx('div',sx.API((trace.push('once'),true)&&s.base))){return render();}", "f()", "[\"once\"]"))] +#[case::reentrant_getter(("let entered=false;const state={get on(){trace.push(entered);if(!entered){entered=true;render();entered=false;}return !entered;}};function render(){return jsx('div',sx.API(state.on&&s.base));}", "render()", "[false,true]"))] +#[case::static_block(("class C {static {this.out=sx.API(true&&s.base);}} function render(){return jsx('div',C.out);}", "render()", "[]"))] +#[case::unbound_reference(("function render(){const out=sx.API(true&&s.base);trace.push(typeof _s0);return jsx('div',out);}", "render()", "[\"undefined\"]"))] +#[serial] +fn stylex_capture_preserves_execution_scope_when_bodies_reenter_or_suspend( + #[case] sample: (&str, &str, &str), + #[values("props", "attrs")] api: &str, +) { + // Given: independent executions must retain their own earlier condition results. + reset_class_map(); + reset_file_map(); + let body = sample.0.replace("API", api); + let body = if api == "attrs" { body.replace("jsx('div',", "adapt('div',") } else { body }; + let source = format!("import * as sx from '@stylexjs/stylex';import {{jsx}} from 'react/jsx-runtime';const s=sx.create({{base:{{color:'red',backgroundColor:'blue'}},later:{{color:'green'}}}});function adapt(tag,p){{return jsx(tag,{{className:p.class}});}}{body}"); + // When + let output = extract("capture-body.js", &source, ExtractOption::default()).unwrap(); + assert_eq!(callable_count(&output.code), callable_count(&source)); + let observation = render_observation(&output, sample.1, Some("capture-body.js")); + let (trace, mut values): (serde_json::Value, Vec) = serde_json::from_str(&observation).unwrap(); + values.sort(); + // Then + assert_eq!(trace, serde_json::from_str::(sample.2).unwrap(), "{}", output.code); + assert_eq!(values, ["blue", "red"], "{}", output.code); + eprintln!("BODY {api} {} bytes={}\n{}", sample.1, output.code.len(), output.code); +} + +#[test] +#[serial] +fn stylex_capture_stays_linear_when_many_conditions_overlap_both_sides() { + // Given: every condition overwrites the same key on both sides (old trees must be dropped). + let mut sizes = Vec::new(); + for count in [8, 16, 32, 64] { + reset_class_map(); + reset_file_map(); + let arguments = (0..count).map(|i| format!("(trace.push({i}),{i}%2===0)?s.base:s.later")).collect::>().join(","); + let source = format!("import * as sx from '@stylexjs/stylex';import {{jsx}} from 'react/jsx-runtime';const s=sx.create({{base:{{color:'red'}},later:{{color:'green'}}}});function render(){{return jsx('div',sx.props({arguments}));}}"); + // When + let output = extract("capture-growth.js", &source, ExtractOption::default()).unwrap(); + assert_eq!(callable_count(&output.code), callable_count(&source)); + let observation = render_observation(&output, "render()", Some("capture-growth.js")); + let (trace, values): (Vec, Vec) = serde_json::from_str(&observation).unwrap(); + // Then: every argument runs, the final odd branch wins, output is deterministic and bounded. + assert_eq!(trace, (0..count).collect::>()); + assert_eq!(values, ["green"]); + assert!(output.code.len() < 200 * count + 1000, "{}", output.code.len()); + reset_class_map(); + reset_file_map(); + assert_eq!(output.code, extract("capture-growth.js", &source, ExtractOption::default()).unwrap().code); + sizes.push((count, output.code.len())); + } + eprintln!("GROWTH {sizes:?}"); +} + +fn callable_count(source: &str) -> usize { + use oxc_ast_visit::{Visit, walk}; + struct Count(usize); + impl<'a> Visit<'a> for Count { + fn visit_function(&mut self, it: &oxc_ast::ast::Function<'a>, flags: oxc_syntax::scope::ScopeFlags) { + self.0 += 1; + walk::walk_function(self, it, flags); + } + fn visit_arrow_function_expression(&mut self, it: &oxc_ast::ast::ArrowFunctionExpression<'a>) { + self.0 += 1; + walk::walk_arrow_function_expression(self, it); + } + } + let arena = Allocator::default(); + let parsed = Parser::new(&arena, source, SourceType::mjs()).parse(); + assert!(parsed.diagnostics.is_empty(), "{source}"); + let mut count = Count(0); + count.visit_program(&parsed.program); + count.0 +} + +#[rstest] +#[serial] +fn stylex_capture_survives_await_when_activations_interleave(#[values("props", "attrs")] api: &str) { + // Given: the first activation suspends after capturing true, while another captures false. + reset_class_map(); + reset_file_map(); + let attribute = if api == "props" { "className" } else { "class" }; + let source = format!("import * as sx from '@stylexjs/stylex';const s=sx.create({{base:{{color:'red',backgroundColor:'blue'}},later:{{color:'green'}}}});async function render(on,wait){{return sx.{api}((trace.push(on),on)&&s.base,(await wait)&&s.later).{attribute};}}const reference=sx.{api}(s.base).{attribute};"); + // When: the emitted native JS runs its actual promise jobs. + let output = extract("capture-await.js", &source, ExtractOption::default()).unwrap(); + assert_eq!(callable_count(&output.code), callable_count(&source)); + let arena = Allocator::default(); + let mut program = Parser::new(&arena, &output.code, SourceType::mjs()).parse().program; + program.body.retain(|s| !matches!(s, oxc_ast::ast::Statement::ImportDeclaration(_))); + let script = format!("{};const trace=[];let release,result;const pending=new Promise(resolve=>release=resolve);const first=render(true,pending);render(false,false).then(()=>release(false));first.then(value=>result=JSON.stringify([trace,value.split(/\\s+/).sort().join(' ')===reference.split(/\\s+/).sort().join(' ')]));", oxc_codegen::Codegen::new().build(&program).code); + let mut context = boa_engine::Context::default(); + context.eval(boa_engine::Source::from_bytes(&script)).unwrap(); + context.run_jobs().unwrap(); + let result = context.eval(boa_engine::Source::from_bytes("result")).unwrap().as_string().unwrap().to_std_string_escaped(); + // Then + assert_eq!(result, "[[true,false],true]", "{}", output.code); + eprintln!("AWAIT {api} {result}\n{}", output.code); +} + +#[test] +#[serial] +fn stylex_capture_accounts_for_output_when_multiple_keys_share_one_condition() { + // Given: the independently executed public-WASM counterexample from the durable report. + reset_class_map(); + reset_file_map(); + let source = "import * as sx from '@stylexjs/stylex';const s=sx.create({base:{color:'red',backgroundColor:'blue'}});export function probe(){let n=0;const out=sx.props(++n===1 && s.base);return [n,out.className];}"; + // When + let output = extract("capture-account.js", source, ExtractOption {single_css:true,..ExtractOption::default()}).unwrap(); + // Then: no callable is added; byte and implementation-line accounting is durable test output. + assert_eq!(callable_count(&output.code), callable_count(source)); + eprintln!("ACCOUNT bytes={}\n{}", output.code.len(), output.code); + for (name, text) in [("visit.rs", include_str!("visit.rs")), ("visit/stylex_capture.rs", include_str!("visit/stylex_capture.rs")), ("stylex_capture_tests.rs", include_str!("stylex_capture_tests.rs")), ("stylex_no_storage_tests.rs", include_str!("stylex_no_storage_tests.rs")), ("stylex_exact_key_tests.rs", include_str!("stylex_exact_key_tests.rs"))] { + let pure = text.lines().filter(|line| { let line=line.trim(); !line.is_empty() && !line.starts_with("//") && !line.starts_with('#') }).count(); + eprintln!("LINES {name} pure={pure} total={}", text.lines().count()); + } +} + +#[rstest] +#[case::one_key("s.one, (trace.push('once'), true)&&s.one")] +#[case::empty("(trace.push('once'), true)?null:null")] +#[case::equal("(trace.push('once'), true)?s.base:s.base")] +#[case::overridden("(trace.push('once'), true)&&s.base, s.base")] +#[serial] +fn stylex_capture_accepts_defaults_when_no_storage_is_needed( + #[case] arguments: &str, + #[values(false, true)] field: bool, +) { + // Given: a default can evaluate directly once or discard a condition before constant classes. + reset_class_map(); + reset_file_map(); + let body = if field { + format!("class C {{out=sx.props({arguments});}}function render(){{return jsx('div',new C().out);}}") + } else { + format!("function render(out=sx.props({arguments})){{return jsx('div',out);}}") + }; + let source=format!("import * as sx from '@stylexjs/stylex';import {{jsx}} from 'react/jsx-runtime';const s=sx.create({{one:{{color:'red'}},base:{{color:'red',backgroundColor:'blue'}}}});{body}"); + // When + let output=extract("capture-default-control.js", &source, ExtractOption::default()).unwrap(); + let observation=render_observation(&output,"render()",Some("capture-default-control.js")); + let (trace,_): (Vec,Vec)=serde_json::from_str(&observation).unwrap(); + // Then: no error and the unpruned authored effect runs once despite equal rendered outcomes. + assert_eq!(trace,["once"],"{}",output.code); + assert_eq!(callable_count(&output.code), callable_count(&source)); +} diff --git a/libs/extractor/src/stylex_eval_tests.rs b/libs/extractor/src/stylex_eval_tests.rs new file mode 100644 index 000000000..f4e2d0e0b --- /dev/null +++ b/libs/extractor/src/stylex_eval_tests.rs @@ -0,0 +1,118 @@ +#[rstest] +#[case::direct(("function probe(){try{CAPTURE;return 'accepted';}catch(error){return error.name;}}", "eval('_s0')"))] +#[case::dynamic(("function probe(code){try{CAPTURE;return 'accepted';}catch(error){return error.name;}}", "eval(code)"))] +#[case::parenthesized(("function probe(code){try{CAPTURE;return 'accepted';}catch(error){return error.name;}}", "(eval)(code)"))] +#[case::closure(("function probe(code){const read=()=>eval(code);CAPTURE;return read();}", "true"))] +#[case::nested_closure(("function probe(code){CAPTURE;{const read=()=>()=>eval(code);return read()();}}", "true"))] +#[case::before(("function probe(code){eval(code);return CAPTURE;}", "true"))] +#[case::after(("function probe(code){CAPTURE;return eval(code);}", "true"))] +#[case::concise(("const probe=code=>(CAPTURE,eval(code));", "true"))] +#[case::switch_sibling(("function probe(code){switch(1){case 0:return eval(code);case 1:return CAPTURE;}}", "true"))] +#[case::switch_test(("function probe(code){switch(1){case eval(code):return CAPTURE;}}", "true"))] +#[case::closure_default(("function probe(code){function read(value=eval(code)){return value;}CAPTURE;return read();}", "true"))] +#[case::method_closure(("function probe(code){const read={get value(){return eval(code);}};CAPTURE;return read.value;}", "true"))] +#[case::module(("CAPTURE;function probe(code){return eval(code);}", "true"))] +#[case::loop_header(("function probe(code){const read=()=>eval(code);for(let i=0;i<1;i++){break;}for(let out=CAPTURE;false;){}return read();}", "true"))] +#[serial] +fn stylex_eval_guard_is_located_when_capture_bindings_are_visible( + #[case] sample: (&str, &str), + #[values("props", "attrs")] api: &str, +) { + // Given: only the authored condition needs storage; direct eval may inspect its frame. + reset_class_map(); + reset_file_map(); + let body = sample.0.replace("CAPTURE", &format!("sx.{api}({} && s.base)", sample.1)); + let source = format!("import * as sx from '@stylexjs/stylex';\nconst s=sx.create({{base:{{color:'red',backgroundColor:'blue'}}}});\n{body}"); + // When + let result = extract("capture-eval.js", &source, ExtractOption::default()); + // Then: rejection points to the condition even when eval is in another nested closure/case. + let error = match result { + Err(error) => error.to_string(), + Ok(output) => panic!("capture exposed to direct eval: observed={}\n{}", observe_eval(&output.code, "probe(String.fromCharCode(95,115,48))"), output.code), + }; + let column = body.find(&format!("sx.{api}(")).unwrap() + format!("sx.{api}(").len() + 1; + assert!(error.starts_with(&format!("capture-eval.js:3:{column}:")), "{error}"); + assert!(error.contains("visible to direct eval") && error.contains("move the value into a variable"), "{error}"); + eprintln!("EVAL ERROR {error}"); +} + +#[rstest] +#[case::sequence(("", "(0,eval)(code)", "ReferenceError"))] +#[case::optional(("", "eval?.(code)", "ReferenceError"))] +#[case::member(("", "globalThis.eval(code)", "ReferenceError"))] +#[case::computed_member(("", "globalThis['eval'](code)", "ReferenceError"))] +#[case::alias(("const indirect=eval;", "indirect(code)", "ReferenceError"))] +#[case::sibling(("function sibling(code){return eval(code);}", "sibling(code)", "ReferenceError"))] +#[case::outer_closure(("const read=code=>eval(code);", "read(code)", "ReferenceError"))] +#[serial] +fn stylex_eval_guard_preserves_indirect_and_sibling_calls_when_they_cannot_see_storage( + #[case] sample: (&str, &str, &str), + #[values("props", "attrs")] api: &str, +) { + // Given: runtime lookup of an unknown name cannot see probe's injected local binding. + reset_class_map(); + reset_file_map(); + let source = format!("import * as sx from '@stylexjs/stylex';const s=sx.create({{base:{{color:'red',backgroundColor:'blue'}}}});{}function probe(code){{try{{sx.{api}({} && s.base);return 'accepted';}}catch(error){{return error.name;}}}}", sample.0, sample.1); + // When + let output = extract("capture-eval-control.js", &source, ExtractOption::default()).unwrap(); + let observed = observe_eval(&output.code, "probe(String.fromCharCode(95,115,48))"); + // Then: capture is accepted, no callable was added, and the original ReferenceError remains. + assert_eq!(observed, sample.2, "{}", output.code); + assert_eq!(callable_count(&output.code), callable_count(&source)); +} + +#[rstest] +#[case::outer_block("function probe(code){const read=()=>eval(code);{sx.API(true && s.base);try{return read();}catch(error){return error.name;}}}")] +#[case::outer_function("function probe(code){function read(){return eval(code);}{sx.API(true && s.base);try{return read();}catch(error){return error.name;}}}")] +#[case::sibling_block("function probe(code){{sx.API(true && s.base);}{try{return eval(code);}catch(error){return error.name;}}}")] +#[case::switch_discriminant("function probe(code){try{switch(eval(code)){case 0:sx.API(true && s.base);}}catch(error){return error.name;}}")] +#[case::parameter_eval("function probe(code,value=eval(code)){sx.API(true && s.base);return value;}")] +#[case::parameter_closure("function probe(code,read=()=>eval(code)){sx.API(true && s.base);try{return read();}catch(error){return error.name;}}")] +#[case::parameter_concise("const probe=(code,read=()=>eval(code))=>(sx.API(true && s.base),read());")] +#[serial] +fn stylex_eval_guard_preserves_lexical_boundaries_when_eval_is_defined_outside_storage( + #[case] body: &str, + #[values("props", "attrs")] api: &str, +) { + // Given: dynamic invocation inside an inner block does not relocate an eval closure's scope. + reset_class_map(); + reset_file_map(); + let source = format!("import * as sx from '@stylexjs/stylex';const s=sx.create({{base:{{color:'red',backgroundColor:'blue'}}}});{}", body.replace("API", api)); + // When + let output = extract("capture-eval-boundary.js", &source, ExtractOption::default()).unwrap(); + let observed = observe_eval(&output.code, "probe(String.fromCharCode(95,115,48))"); + // Then: direct eval still cannot find the new inner binding (including body/default scopes). + assert_eq!(observed, "ReferenceError", "{}", output.code); + assert_eq!(callable_count(&output.code), callable_count(&source)); +} + +#[rstest] +#[case::direct_once("eval(code) && s.one")] +#[case::empty("eval(code) ? null : null")] +#[case::equal("eval(code) ? s.base : s.base")] +#[case::overridden("eval(code) && s.base,s.base")] +#[serial] +fn stylex_eval_guard_preserves_authored_errors_when_no_binding_is_injected( + #[case] arguments: &str, + #[values("props", "attrs")] api: &str, +) { + // Given: equal/empty/overridden/direct-once conditions never need a capture slot. + reset_class_map(); + reset_file_map(); + let source = format!("import * as sx from '@stylexjs/stylex';const s=sx.create({{one:{{color:'red'}},base:{{color:'red',backgroundColor:'blue'}}}});function probe(code){{try{{sx.{api}({arguments});return 'accepted';}}catch(error){{return error.name;}}}}"); + // When + let output = extract("capture-eval-no-storage.js", &source, ExtractOption::default()).unwrap(); + let observed = observe_eval(&output.code, "probe(String.fromCharCode(95,115,48))"); + // Then: compilation is accepted and the original authored error is neither dropped nor masked. + assert_eq!(observed, "ReferenceError", "{}", output.code); + assert_eq!(callable_count(&output.code), callable_count(&source)); +} + +fn observe_eval(code: &str, call: &str) -> String { + let arena = Allocator::default(); + let mut parsed = Parser::new(&arena, code, SourceType::mjs()).parse(); + assert!(parsed.diagnostics.is_empty(), "{code}"); + parsed.program.body.retain(|s| !matches!(s, oxc_ast::ast::Statement::ImportDeclaration(_))); + let script = format!("{};try{{String({call});}}catch(error){{error.name;}}", oxc_codegen::Codegen::new().build(&parsed.program).code); + boa_engine::Context::default().eval(boa_engine::Source::from_bytes(&script)).unwrap().as_string().unwrap().to_std_string_escaped() +} diff --git a/libs/extractor/src/stylex_exact_key_tests.rs b/libs/extractor/src/stylex_exact_key_tests.rs index 8786ccba0..8f23ea653 100644 --- a/libs/extractor/src/stylex_exact_key_tests.rs +++ b/libs/extractor/src/stylex_exact_key_tests.rs @@ -1,5 +1,8 @@ mod stylex_exact_keys { use super::*; + include!("stylex_capture_tests.rs"); + include!("stylex_eval_tests.rs"); + include!("stylex_no_storage_tests.rs"); #[rstest] #[case::leading_zero_select(("05", "5", "styles['05']", "red"))] @@ -80,4 +83,157 @@ mod stylex_exact_keys { // Then: the actual selected class has the authored color and the key effect occurs once. assert_eq!(observation, format!("[[\"key\"],[{expected:?}]]")); } + + #[rstest] + #[case::logical("css && s.base")] + #[case::ternary("css ? s.base : s.later")] + #[case::copied_previous("css && s.base, on && s.later")] + #[case::equal("css ? s.base : s.base")] + #[case::empty("css ? null : null")] + #[case::overridden("css && s.later, s.later")] + #[serial] + fn compiled_condition_read_is_located_when_stylex_keeps_the_condition(#[case] arguments: &str) { + // Given: css is the imported compile-time binding, not an ordinary shadow. + reset_class_map(); + reset_file_map(); + let source = format!( + "import {{css}} from '@devup-ui/react';\nimport * as sx from '@stylexjs/stylex';\nconst s=sx.create({{base:{{color:'red',backgroundColor:'blue'}},later:{{color:'green'}}}});\nexport const output=sx.props({arguments});" + ); + + // When: the public native extractor lowers a retained condition. + let error = extract("stylex-condition.js", &source, ExtractOption::default()) + .expect_err("a compiled binding cannot survive as a runtime value") + .to_string(); + + // Then: lexical diagnostics point at the authored read. + assert!(error.starts_with("stylex-condition.js:4:"), "{error}"); + assert!(error.contains("`css` is read at runtime"), "{error}"); + } + + #[rstest] + #[case::logical("css && s.base")] + #[case::ternary("css ? s.base : s.empty")] + #[case::copied_previous("css && s.base, false && s.later")] + #[serial] + fn local_condition_stays_ordinary_when_it_shadows_the_compiled_css_import( + #[case] arguments: &str, + ) { + // Given: the parameter shadows the imported compile-time API. + reset_class_map(); + reset_file_map(); + let source = format!( + "import {{css}} from '@devup-ui/react'; import * as sx from '@stylexjs/stylex'; import {{jsx}} from 'react/jsx-runtime'; const s=sx.create({{base:{{color:'red',backgroundColor:'blue'}},later:{{color:'green'}},empty:{{}}}}); function render(css) {{ return jsx('div', {{...sx.props({arguments})}}); }}" + ); + + // When: real extracted output executes with the ordinary local value. + let output = extract("stylex-shadow.js", &source, ExtractOption::default()).unwrap(); + let observation = render_observation(&output, "render(true)", Some("stylex-shadow.js")); + let (trace, mut values): (Vec, Vec) = + serde_json::from_str(&observation).unwrap(); + values.sort(); + + // Then: no spelling-based diagnostic rejects the shadow, and both keys survive. + assert_eq!((trace, values), (vec![], vec!["blue".into(), "red".into()])); + } + + #[rstest] + #[case::multi_key(("", "(trace.push('once'), true) && s.base", "[\"once\"]", "[\"blue\",\"red\"]"))] + #[case::two_conditions(("", "(trace.push('first'), true) && s.base, (trace.push('second'), true) && s.later", "[\"first\",\"second\"]", "[\"blue\",\"green\"]"))] + #[case::same_key_order(("", "(trace.push('first'), true) && s.later, (trace.push('second'), true) && s.later", "[\"first\",\"second\"]", "[\"green\"]"))] + #[case::full_override(("", "(trace.push('overridden'), true) && s.base, s.full", "[\"overridden\"]", "[\"black\",\"green\"]"))] + #[case::empty_selected(("", "(trace.push('empty'), true) && s.empty", "[\"empty\"]", "[]"))] + #[case::empty_sides(("", "(trace.push('empty'), true) ? s.empty : s.empty", "[\"empty\"]", "[]"))] + #[case::equal_sides(("", "(trace.push('equal'), true) ? s.base : s.base", "[\"equal\"]", "[\"blue\",\"red\"]"))] + #[case::getter(("const state={get on(){trace.push('get');return true;}};", "state.on && s.base", "[\"get\"]", "[\"blue\",\"red\"]"))] + #[case::mutation(("let n=0;", "(trace.push(String(++n)), n === 1) && s.base, (trace.push('second'), false) && s.later", "[\"1\",\"second\"]", "[\"blue\",\"red\"]"))] + #[case::falsy(("", "(trace.push('false'), false) && s.base", "[\"false\"]", "[]"))] + #[case::null_reset(("", "s.base, (trace.push('clear'), true) && s.clear", "[\"clear\"]", "[]"))] + #[serial] + fn authored_effects_run_once_in_order_when_stylex_merges_keys( + #[case] sample: (&str, &str, &str, &str), + #[values("props", "attrs")] api: &str, + ) { + // Given: conditions with effects, including styles with no surviving selected key. + reset_class_map(); + reset_file_map(); + let (setup, arguments, expected_trace, expected_values) = sample; + let attribute = if api == "props" { "className" } else { "class" }; + let source = format!( + "import * as sx from '@stylexjs/stylex'; import {{jsx}} from 'react/jsx-runtime'; const s=sx.create({{base:{{color:'red',backgroundColor:'blue'}},later:{{color:'green'}},full:{{color:'green',backgroundColor:'black'}},empty:{{}},clear:{{color:null,backgroundColor:null}}}}); function render() {{ {setup} const out=sx.{api}({arguments}); return jsx('div', {{className:out.{attribute}}}); }}" + ); + + // When: the existing native Boa renderer executes the extracted call. + let output = extract("stylex-effects.js", &source, ExtractOption::default()).unwrap(); + let observation = render_observation(&output, "render()", Some("stylex-effects.js")); + let (trace, mut values): (Vec, Vec) = + serde_json::from_str(&observation).unwrap(); + values.sort(); + let expected_trace: Vec = serde_json::from_str(expected_trace).unwrap(); + let expected_values: Vec = serde_json::from_str(expected_values).unwrap(); + + // Then: argument-order effects and only the winning authored declarations are observed. + assert_eq!( + (trace, values), + (expected_trace, expected_values), + "{}", + output.code + ); + } + + #[rstest] + #[case::inherited(("s.base,", "[\"red\",\"white\"]", "[\"black\",\"blue\"]"))] + #[case::absent(("", "[\"red\"]", "[\"blue\"]"))] + #[serial] + fn alternate_only_key_preserves_winners_when_conditional_namespaces_are_disjoint( + #[case] sample: (&str, &str, &str), + #[values("props", "attrs")] api: &str, + #[values(false, true)] on: bool, + ) { + // Given: each branch replaces a different key, with or without earlier defaults. + reset_class_map(); + reset_file_map(); + let attribute = if api == "props" { "className" } else { "class" }; + let source = format!( + "import * as sx from '@stylexjs/stylex'; import {{jsx}} from 'react/jsx-runtime'; const s=sx.create({{base:{{color:'black',backgroundColor:'white'}},left:{{color:'red'}},right:{{backgroundColor:'blue'}}}}); function tick(label,value){{trace.push(label);return value;}} function render(on){{const out=sx.{api}({}tick('branch',on)?s.left:s.right);return jsx('div',{{className:out.{attribute}}});}}", + sample.0 + ); + + // When: the public extractor's output executes the selected branch in Boa. + let output = extract("stylex-disjoint.js", &source, ExtractOption::default()).unwrap(); + let observation = render_observation( + &output, + &format!("render({on})"), + Some("stylex-disjoint.js"), + ); + let (trace, mut values): (Vec, Vec) = + serde_json::from_str(&observation).unwrap(); + values.sort(); + + // Then: the missing side inherits only its prior key; the branch test runs once. + let expected = if on { sample.1 } else { sample.2 }; + assert_eq!(trace, ["branch"], "{}", output.code); + assert_eq!( + values, + serde_json::from_str::>(expected).unwrap(), + "{}", + output.code + ); + assert_eq!(callable_count(&output.code), callable_count(&source)); + } + + #[test] + #[serial] + fn throwing_condition_still_throws_when_all_its_keys_are_overridden() { + // Given: an overridden argument whose getter must stop later argument evaluation. + reset_class_map(); + reset_file_map(); + let source = "import * as sx from '@stylexjs/stylex'; import {jsx} from 'react/jsx-runtime'; const s=sx.create({base:{color:'red'},later:{color:'green'}}); function render() { const state={get on(){trace.push('get');throw new Error('stop');}}; try { sx.props(state.on && s.base, (trace.push('later'), true) ? s.later : s.later); } catch(error) { trace.push(error.message); } return jsx('div', {}); }"; + + // When: real extracted output executes the call inside its authored catch boundary. + let output = extract("stylex-throw.js", source, ExtractOption::default()).unwrap(); + let observation = render_observation(&output, "render()", Some("stylex-throw.js")); + + // Then: neither dropping the first getter nor evaluating the later condition is valid. + assert_eq!(observation, "[[\"get\",\"stop\"],[]]", "{}", output.code); + } } diff --git a/libs/extractor/src/stylex_no_storage_tests.rs b/libs/extractor/src/stylex_no_storage_tests.rs new file mode 100644 index 000000000..37c3a0fc8 --- /dev/null +++ b/libs/extractor/src/stylex_no_storage_tests.rs @@ -0,0 +1,190 @@ +#[rstest] +#[case::inherited(("s.base,tick('equal',on)&&s.base", "[\"equal\"]", "[\"blue\",\"red\"]", "[\"blue\",\"red\"]"))] +#[case::discarded(("tick('first',!on)&&s.one,tick('last',on)?s.one:s.other", "[\"first\",\"last\"]", "[\"red\"]", "[\"green\"]"))] +#[case::empty_before_live(("tick('empty',on)?s.empty:s.empty,tick('last',on)&&s.one", "[\"empty\",\"last\"]", "[\"red\"]", "[]"))] +#[case::equal_before_live(("tick('equal',on)?s.base:s.base,tick('last',on)?s.one:s.other", "[\"equal\",\"last\"]", "[\"blue\",\"red\"]", "[\"blue\",\"green\"]"))] +#[case::overridden_before_live(("tick('first',on)&&s.base,s.base,tick('last',on)?s.one:s.other", "[\"first\",\"last\"]", "[\"blue\",\"red\"]", "[\"blue\",\"green\"]"))] +#[case::inherited_reset(("s.reset,tick('equal',on)&&s.reset", "[\"equal\"]", "[]", "[]"))] +#[case::dead_tail(("[s.base,[tick('equal',on)&&s.base]],tick('empty',!on)?s.empty:s.empty", "[\"equal\",\"empty\"]", "[\"blue\",\"red\"]", "[\"blue\",\"red\"]"))] +#[serial] +fn stylex_no_storage_preserves_winners_when_final_dependencies_disappear( + #[case] sample: (&str, &str, &str, &str), + #[values("default", "field", "eval")] frame: &str, + #[values("props", "attrs")] api: &str, +) { + // Given: authored tests include inherited-equal and later-discarded choices in guarded frames. + reset_class_map(); + reset_file_map(); + let source = no_storage_source(sample.0, frame, api); + // When: the actual public extractor lowers the expression-only/eval-visible call. + let output = extract("no-storage.js", &source, ExtractOption::default()).unwrap(); + // Then: both truth values retain ordered effects and the independently authored CSS winner. + for (on, expected) in [(true, sample.2), (false, sample.3)] { + let observed = render_observation(&output, &format!("render({on})"), Some("no-storage.js")); + let (trace, mut values): (Vec, Vec) = serde_json::from_str(&observed).unwrap(); + values.sort(); + assert_eq!(trace, serde_json::from_str::>(sample.1).unwrap(), "{}", output.code); + assert_eq!(values, serde_json::from_str::>(expected).unwrap(), "{}", output.code); + } + assert_eq!(callable_count(&output.code), callable_count(&source)); + assert!(!output.code.contains("let _s"), "{}", output.code); + eprintln!("NO STORAGE {frame} {api} {} bytes={}\n{}", sample.0, output.code.len(), output.code); +} + +fn no_storage_source(arguments: &str, frame: &str, api: &str) -> String { + let attribute = if api == "props" { "className" } else { "class" }; + let call = format!("sx.{api}({arguments})"); + let body = match frame { + "default" => format!("function choose(on,out={call}){{return out;}}function render(on){{return adapt(choose(on));}}"), + "field" => format!("function render(on){{class C{{out={call};}}return adapt(new C().out);}}"), + "eval" => format!("function render(on){{eval('');return adapt({call});}}"), + "body" => format!("function render(on){{return adapt({call});}}"), + _ => panic!("unknown fixture frame: {frame}"), + }; + format!("import * as sx from '@stylexjs/stylex';import {{jsx}} from 'react/jsx-runtime';const s=sx.create({{base:{{color:'red',backgroundColor:'blue'}},one:{{color:'red'}},other:{{color:'green'}},empty:{{}},reset:{{color:null,backgroundColor:null}}}});function tick(label,value){{trace.push(label);return value;}}function adapt(out){{return jsx('div',{{className:out.{attribute}}});}}{body}") +} + +#[rstest] +#[serial] +fn stylex_no_storage_uses_the_last_test_directly_when_an_earlier_result_is_captured( + #[values("props", "attrs")] api: &str, + #[values(false, true)] on: bool, +) { + // Given: the final one-key test is direct, but its false branch needs an earlier live result. + reset_class_map(); + reset_file_map(); + let source = no_storage_source("tick('first',on)&&s.one,tick('last',!on)&&s.other", "body", api); + // When + let output = extract("no-storage-last.js", &source, ExtractOption::default()).unwrap(); + let observed = render_observation(&output, &format!("render({on})"), Some("no-storage-last.js")); + // Then: both tests execute once in order and either branch selects the authored winner. + let winner = if on { "red" } else { "green" }; + assert_eq!(observed, format!("[[\"first\",\"last\"],[\"{winner}\"]]"), "{}", output.code); + assert_eq!(output.code.matches("let _s0;").count(), 1, "{}", output.code); + assert!(!output.code.contains("_s1"), "{}", output.code); + assert_eq!(callable_count(&output.code), callable_count(&source)); + eprintln!("LAST DIRECT {api} {on} bytes={}\n{}", output.code.len(), output.code); +} + +#[rstest] +#[case::inherited("s.base,state.on&&s.base,tick('later',true)?s.empty:s.empty")] +#[case::discarded("state.on&&s.one,tick('later',true)?s.one:s.other")] +#[serial] +fn stylex_no_storage_preserves_throws_when_the_throwing_choice_is_dead( + #[case] arguments: &str, + #[values("default", "field", "eval")] frame: &str, + #[values("props", "attrs")] api: &str, +) { + // Given: a throwing getter precedes a later authored test, even though its classes vanish. + reset_class_map(); + reset_file_map(); + let source = format!("{}const trace=[];const state={{get on(){{trace.push('get');throw Error('stop');}}}};", no_storage_source(arguments, frame, api)); + // When: execute extracted code inside the original caller's catch boundary. + let output = extract("no-storage-throw.js", &source, ExtractOption::default()).unwrap(); + let observed = observe_eval(&output.code, "(()=>{try{render(true);}catch(error){trace.push(error.message);}return JSON.stringify(trace);})()"); + // Then: no later test executes after the throw, and no callable is synthesized. + assert_eq!(observed, "[\"get\",\"stop\"]", "{}", output.code); + assert_eq!(callable_count(&output.code), callable_count(&source)); +} + +#[rstest] +#[serial] +fn stylex_no_storage_requires_capture_when_a_later_dead_test_follows_a_live_test( + #[values("body", "default", "field", "eval")] frame: &str, + #[values("props", "attrs")] api: &str, +) { + // Given: only the earlier single-key condition survives; the later effect still must follow it. + reset_class_map(); + reset_file_map(); + let source = no_storage_source("tick('live',on)&&s.one,tick('dead',false)?s.empty:s.empty", frame, api); + // When + let result = extract("no-storage-order.js", &source, ExtractOption::default()); + // Then: bodies capture, while expression-only/eval-visible frames retain genuine errors. + match frame { + "body" => { + let output = result.unwrap(); + for (on, values) in [(true, "[\"red\"]"), (false, "[]")] { + assert_eq!(render_observation(&output, &format!("render({on})"), Some("no-storage-order.js")), format!("[[\"live\",\"dead\"],{values}]"), "{}", output.code); + } + assert!(output.code.contains("let _s"), "{}", output.code); + assert_eq!(callable_count(&output.code), callable_count(&source)); + } + "default" | "field" | "eval" => { + let error = result.unwrap_err().to_string(); + let column = source.find("tick('live',on)").unwrap() + 1; + assert!(error.starts_with(&format!("no-storage-order.js:1:{column}:")), "{error}"); + let context = match frame { + "default" => "parameter default", + "field" => "class field initializer", + "eval" => "visible to direct eval", + _ => unreachable!(), + }; + assert!(error.contains(context), "{error}"); + } + _ => unreachable!(), + } +} + +#[rstest] +#[case::inherited("s.base,css&&s.base")] +#[case::discarded("css&&s.one,tick('last',on)?s.one:s.other")] +#[case::empty("css?s.empty:s.empty,tick('last',on)?s.one:s.other")] +#[case::overridden("css&&s.base,s.base,tick('last',on)?s.one:s.other")] +#[serial] +fn stylex_no_storage_keeps_semantic_diagnostics_when_a_compiled_test_is_discarded( + #[case] arguments: &str, + #[values("default", "field", "eval")] frame: &str, + #[values("props", "attrs")] api: &str, +) { + // Given: the discarded test still reads the real imported compile-time API. + reset_class_map(); + reset_file_map(); + let source = format!("import {{css}} from '@devup-ui/react';\n{}", no_storage_source(arguments, frame, api)); + // When + let error = extract("no-storage-binding.js", &source, ExtractOption::default()).unwrap_err().to_string(); + // Then: original semantic IDs locate the read, not a new storage-context diagnostic. + let column = source.split_once('\n').unwrap().1.find("css").unwrap() + 1; + assert!(error.starts_with(&format!("no-storage-binding.js:2:{column}:")), "{error}"); + assert!(error.contains("`css` is read at runtime"), "{error}"); + assert!(!error.contains("condition capture"), "{error}"); +} + +#[rstest] +#[serial] +fn stylex_no_storage_stays_bounded_and_deterministic_when_many_choices_are_discarded( + #[values("default", "field", "eval")] frame: &str, + #[values("props", "attrs")] api: &str, +) { + // Given: every both-sided condition replaces the earlier single-key choice. + let mut sizes = Vec::new(); + for count in [8, 16, 32, 64] { + reset_class_map(); + reset_file_map(); + let arguments = (0..count).map(|i| format!("tick('{i}',{i}%2===0)?s.one:s.other")).collect::>().join(","); + let source = no_storage_source(&arguments, frame, api); + // When + let output = extract("no-storage-growth.js", &source, ExtractOption::default()).unwrap(); + let observed = render_observation(&output, "render(true)", Some("no-storage-growth.js")); + let (trace, values): (Vec, Vec) = serde_json::from_str(&observed).unwrap(); + // Then: all effects survive in order, only the last winner survives, with linear JS growth. + assert_eq!(trace, (0..count).map(|i| i.to_string()).collect::>()); + assert_eq!(values, ["green"]); + assert!(!output.code.contains("let _s"), "{}", output.code); + assert!(output.code.len() < count * 100 + 1000, "{}", output.code.len()); + assert_eq!(callable_count(&output.code), callable_count(&source)); + reset_class_map(); + reset_file_map(); + assert_eq!(output.code, extract("no-storage-growth.js", &source, ExtractOption::default()).unwrap().code); + sizes.push((count, output.code.len())); + } + eprintln!("NO STORAGE GROWTH {frame} {api} {sizes:?}"); +} + +#[test] +#[should_panic(expected = "unknown fixture frame: invalid")] +fn no_storage_source_rejects_unknown_fixture_frame() { + // Given: the frame is not one of the fixture helper's supported execution contexts. + // When: constructing that fixture must fail rather than silently use a different frame. + // Then: the helper reports the unsupported frame in its existing panic. + no_storage_source("", "invalid", "props"); +} diff --git a/libs/extractor/src/utils.rs b/libs/extractor/src/utils.rs index 31f31151f..ecdaf46d6 100644 --- a/libs/extractor/src/utils.rs +++ b/libs/extractor/src/utils.rs @@ -742,22 +742,24 @@ pub(super) fn style_arguments<'a>( pub(super) fn reads_unknown( expression: &Expression<'_>, unknown: &crate::imported_constants::Unknown, + reads: &dyn Fn(&oxc_ast::ast::IdentifierReference<'_>) -> bool, ) -> bool { match unwrap_syntax_only(expression) { Expression::ArrayExpression(array) => array.elements.iter().any(|element| { element .as_expression() - .is_some_and(|element| reads_unknown(element, unknown)) + .is_some_and(|element| reads_unknown(element, unknown, reads)) }), Expression::LogicalExpression(logical) => { - (logical.operator != LogicalOperator::And && reads_unknown(&logical.left, unknown)) - || reads_unknown(&logical.right, unknown) + (logical.operator != LogicalOperator::And + && reads_unknown(&logical.left, unknown, reads)) + || reads_unknown(&logical.right, unknown, reads) } Expression::ConditionalExpression(conditional) => { - reads_unknown(&conditional.consequent, unknown) - || reads_unknown(&conditional.alternate, unknown) + reads_unknown(&conditional.consequent, unknown, reads) + || reads_unknown(&conditional.alternate, unknown, reads) } - expression => unknown.read_by(expression), + expression => unknown.read_by_in(expression, reads), } } diff --git a/libs/extractor/src/visit.rs b/libs/extractor/src/visit.rs index 999e6f0a6..9338f084c 100644 --- a/libs/extractor/src/visit.rs +++ b/libs/extractor/src/visit.rs @@ -1,10 +1,9 @@ use crate::as_visit::As; use crate::class_arguments::{class_arguments, flatten_classes}; -use crate::component::ExportVariableKind; use crate::composition::{KnownPart, KnownSide, KnownStyles, overlaps, set_prop_order}; use crate::css_prop::{ ClassNamesParams, CssProp, THEME_READ, class_names_child, class_names_params, read_theme, - render_function, template_parts, theme_rules, + render_function, template_parts, theme_rules_of, }; use crate::css_utils::{ TemplateStyles, css_to_style_template, keyframes_to_keyframes_style, optimize_css_block, @@ -41,6 +40,7 @@ use crate::prop_modify_utils::{ add_class_and_style, add_class_and_style_to_object, compile_cva, convert_class_name, modify_prop_object, modify_props, written_class_name, written_object_class_name, }; +use crate::scope::{Bindings, NAMESPACE_STYLED}; use crate::style_collector::StyleCollector; use crate::stylex::{ StylexDynamicInfo, StylexFunction, StylexNamespaceValue, create_theme_class, @@ -73,7 +73,7 @@ use oxc_ast_visit::walk_mut::{ }; use oxc_syntax::number::NumberBase; use oxc_syntax::operator::LogicalOperator; -use strum::IntoEnumIterator; +use oxc_syntax::symbol::SymbolId; use crate::utils::{ CLASS_NAMES_CALL, CLASS_NAMES_CHILD, CLASS_NAMES_CLASS_MAP, CLASS_NAMES_PART, CSS_PROP_VALUE, @@ -91,6 +91,7 @@ use oxc_span::{GetSpan, SPAN}; use rustc_hash::{FxHashMap, FxHashSet}; use std::borrow::Cow; use std::rc::Rc; +mod stylex_capture; /// What text among composed parts holds #[derive(Clone, Copy, PartialEq, Eq)] @@ -182,11 +183,8 @@ fn style_property_into_string(style_property: StyleProperty) -> String { pub struct DevupVisitor<'a> { pub ast: AstBuilder<'a>, filename: String, - imports: FxHashMap, - import_object: Option, - jsx_imports: FxHashMap, - util_imports: FxHashMap>, - jsx_object: Option, + /// The bindings the file declares for the APIs the build compiles + bindings: Bindings, package: String, /// Entry the rewritten imports of absorbed third-party APIs land on. Its specifiers /// register exactly like the main package's so those calls still compile away. @@ -197,41 +195,38 @@ pub struct DevupVisitor<'a> { /// Styles the file writes that cannot be extracted at build time, by the /// offset of the code each is about pub errors: Vec<(u32, String)>, - styled_imports: FxHashSet, styled_jsx: Option, styled_capability: Option, - /// Tracked `StyleX` default/namespace import names (e.g., `stylex` from `import stylex from '...'`) - stylex_imports: FxHashSet, - /// Tracked `StyleX` named imports (e.g., `create` from `import { create } from '...'`) - stylex_named_imports: FxHashMap, /// Pending `StyleX` namespace map from the most recent `stylex.create()` call. /// Set in `visit_expression`, consumed in `visit_variable_declarator`. stylex_pending_create: Option>, - /// Maps variable names to their namespace→className mappings from `stylex.create()`. - /// e.g., "styles" → { "base" → "a b", "active" → "c" } - stylex_namespaces: FxHashMap>, + /// Maps bindings to their namespace→className mappings from `stylex.create()`. + /// e.g., `styles` → { "base" → "a b", "active" → "c" } + stylex_namespaces: FxHashMap>, + /// What the `` child functions being compiled take + class_names_scope: Vec, /// The classes each top-level key of a static namespace gives, by binding /// and namespace, so `props()` lets a later namespace's key replace an /// earlier one's as `StyleX` merges them - stylex_keys: FxHashMap>>, + stylex_keys: FxHashMap>>, stylex_pending_keys: Option>>, - /// Local names bound to the `Global` component, whose `styles` prop declares - /// global CSS instead of rendering markup. - global_style_components: FxHashSet, - /// Import bindings of Emotion's `ClassNames`, whose element becomes - /// what its child function renders - class_names_components: FxHashSet, - /// What the `` child functions being compiled take - class_names_scope: Vec, + stylex_captures: stylex_capture::Captures, - /// `defineVars` members flattened to `"vars.key"` -> `"var(--x)"`, so a - /// `stylex.create()` value referencing one resolves to a static CSS value. - stylex_var_refs: FxHashMap, + /// `defineVars` and `defineConsts` members of a binding as `key` -> + /// `"var(--x)"`, so a `stylex.create()` value referencing one resolves to + /// a static CSS value. + stylex_var_refs: FxHashMap>, /// `defineVars` bindings as `vars` -> (`key` -> `--x`), the contract /// `createTheme` reassigns. - stylex_var_names: FxHashMap>, + stylex_var_names: FxHashMap>, /// `createTheme` bindings as `theme` -> `class`, so `stylex.props(theme)` resolves. - stylex_theme_classes: FxHashMap, + stylex_theme_classes: FxHashMap, + /// `StyleX` variables and themes the program imports from other modules, + /// by the name it binds them to + imported_stylex: ( + FxHashMap>, + FxHashMap, + ), /// Pending `defineVars` contract awaiting its variable declarator. stylex_pending_vars: Option>, /// Pending `createTheme` class awaiting its variable declarator. @@ -240,9 +235,9 @@ pub struct DevupVisitor<'a> { stylex_pending_consts: Option>, /// Pending keyframe animation name from most recent `stylex.keyframes()` call. stylex_pending_keyframe_name: Option, - /// Maps variable names to their keyframe animation names. - /// e.g., "fadeIn" → "a-a" - stylex_keyframe_names: FxHashMap, + /// Maps bindings to their keyframe animation names. + /// e.g., `fadeIn` → "a-a" + stylex_keyframe_names: FxHashMap, /// What the element just visited becomes when it is not an element any /// more (a dynamic `as`, a spread evaluated once): set in /// `visit_jsx_element`, written where the element stands by the visit of @@ -252,6 +247,8 @@ pub struct DevupVisitor<'a> { /// Elements whose type only the runtime gives, which each bind it to a /// name of their own runtime_types: usize, + /// The semantic analysis of the program, when built before the visit + scoping: Option>, /// The classes and keyframes names the file binds to a `const` style_values: crate::style_values::StyleValues<'a>, /// The styles of the last `css()` call giving a class, by where it starts, @@ -284,9 +281,6 @@ pub struct DevupVisitor<'a> { /// The styled components the file binds to a `const`, which a component /// extending one renders in its place styled_definitions: FxHashMap>, - /// What the build compiles away: the imports of the package it removes, - /// and the bindings aliasing them, which only its calls and elements read - compiled_names: FxHashSet, unknown_bindings: crate::imported_constants::Unknown, /// Whether `css()` or `styled()` joined as a class, or an element took /// through a spread, a binding that may hold rules only running the module @@ -321,115 +315,59 @@ enum StylexPart<'a> { /// The class a key ends up with once `stylex.props(...)` merges the parts; /// an empty class when none sets it or `null` removes it -enum StylexChoice<'a> { +#[derive(PartialEq, Eq)] +enum StylexChoice { Class(String), Conditional { - test: Expression<'a>, - consequent: Box>, - alternate: Box>, + test: usize, + consequent: Box, + alternate: Box, }, } -impl<'a> StylexChoice<'a> { - fn copy(&self, ast: &AstBuilder<'a>) -> Self { +impl StylexChoice { + fn dependencies(&self, uses: &mut [usize]) { match self { - StylexChoice::Class(class) => StylexChoice::Class(class.clone()), - StylexChoice::Conditional { + Self::Class(_) => {} + Self::Conditional { test, consequent, alternate, - } => StylexChoice::Conditional { - test: test.clone_in(ast.allocator()), - consequent: Box::new(consequent.copy(ast)), - alternate: Box::new(alternate.copy(ast)), - }, - } - } -} - -/// Whether `declarator` only aliases what the build compiles away -/// (`const newCss = css`) -fn is_alias(declarator: &VariableDeclarator<'_>, compiled: &FxHashSet) -> bool { - matches!(&declarator.init, Some(Expression::Identifier(init)) if compiled.contains(init.name.as_str())) - && declarator - .id - .get_binding_identifier() - .is_some_and(|id| compiled.contains(id.name.as_str())) -} - -/// The reads of `names` a program keeps outside the types it erases; with -/// `scoping`, only those no binding of the program declares -/// Whether the file has a `className` that is more than a string -#[derive(Default)] -struct DynamicClassNames { - found: bool, -} - -impl<'a> oxc_ast_visit::Visit<'a> for DynamicClassNames { - fn visit_jsx_attribute(&mut self, it: &oxc_ast::ast::JSXAttribute<'a>) { - if let oxc_ast::ast::JSXAttributeName::Identifier(name) = &it.name - && name.name == "className" - && let Some(JSXAttributeValue::ExpressionContainer(container)) = &it.value - && container - .expression - .as_expression() - .is_some_and(|expression| !matches!(expression, Expression::StringLiteral(_))) - { - self.found = true; - } - oxc_ast_visit::walk::walk_jsx_attribute(self, it); - } -} - -struct CompiledReads<'s> { - names: &'s FxHashSet, - scoping: Option<&'s oxc_semantic::Scoping>, - found: Vec<(u32, String)>, -} - -impl<'a> oxc_ast_visit::Visit<'a> for CompiledReads<'_> { - fn visit_identifier_reference(&mut self, it: &oxc_ast::ast::IdentifierReference<'a>) { - if self.names.contains(it.name.as_str()) - && self.scoping.is_none_or(|scoping| { - it.reference_id - .get() - .is_none_or(|reference| scoping.get_reference(reference).symbol_id().is_none()) - }) - { - self.found.push((it.span.start, it.name.to_string())); + } => { + uses[*test] += 1; + consequent.dependencies(uses); + alternate.dependencies(uses); + } } } - - fn visit_ts_type(&mut self, _: &oxc_ast::ast::TSType<'a>) {} } /// Whether `expression`, or a value it chooses, reads an object or array code -/// changes +/// changes, through the identifiers `reads` accepts fn reads_binding( expression: &Expression<'_>, changed: &crate::imported_constants::Changed, + reads: &dyn Fn(&oxc_ast::ast::IdentifierReference<'_>) -> bool, ) -> bool { match unwrap_syntax_only(expression) { Expression::ArrayExpression(array) => array.elements.iter().any(|element| { element .as_expression() - .is_some_and(|element| reads_binding(element, changed)) + .is_some_and(|element| reads_binding(element, changed, reads)) }), Expression::LogicalExpression(logical) => { - reads_binding(&logical.left, changed) || reads_binding(&logical.right, changed) + reads_binding(&logical.left, changed, reads) + || reads_binding(&logical.right, changed, reads) } Expression::ConditionalExpression(conditional) => { - reads_binding(&conditional.consequent, changed) - || reads_binding(&conditional.alternate, changed) + reads_binding(&conditional.consequent, changed, reads) + || reads_binding(&conditional.alternate, changed, reads) } - expression => changed.read_by(expression), + expression => changed.read_by_in(expression, reads), } } impl<'a> DevupVisitor<'a> { - pub(crate) fn reuse_scoping(&mut self, scoping: Rc) { - self.style_values = crate::style_values::StyleValues::new(scoping); - } pub fn changed_bindings(&mut self, changed: crate::imported_constants::Changed) { self.changed_bindings = changed; } @@ -445,7 +383,9 @@ impl<'a> DevupVisitor<'a> { Argument::SpreadElement(spread) => &spread.argument, argument => argument.to_expression(), }; - if reads_unknown(expression, &self.unknown_bindings) { + if reads_unknown(expression, &self.unknown_bindings, &|identifier| { + self.bindings.reads_module(identifier) + }) { self.composes_unknown = true; self.unknown_parts.push(( argument.span().start, @@ -465,7 +405,9 @@ impl<'a> DevupVisitor<'a> { Argument::SpreadElement(spread) => &spread.argument, argument => argument.to_expression(), }; - if reads_binding(expression, &self.changed_bindings) { + if reads_binding(expression, &self.changed_bindings, &|identifier| { + self.bindings.reads_module(identifier) + }) { self.errors.push(( argument.span().start, build_time_error(api, &readable_argument(argument), STYLE_OBJECT), @@ -592,9 +534,13 @@ impl<'a> DevupVisitor<'a> { Expression::ObjectExpression(_) ) || self.reads_known_styles(expression) || (!self.unknown_bindings.is_empty() - && reads_unknown(expression, &self.unknown_bindings)) + && reads_unknown(expression, &self.unknown_bindings, &|identifier| { + self.bindings.reads_module(identifier) + })) || (!self.changed_bindings.is_empty() - && reads_binding(expression, &self.changed_bindings)) + && reads_binding(expression, &self.changed_bindings, &|identifier| { + self.bindings.reads_module(identifier) + })) }) { return None; @@ -957,40 +903,34 @@ impl<'a> DevupVisitor<'a> { Self { ast: AstBuilder::new(allocator), filename: filename.to_string(), - imports: FxHashMap::default(), - jsx_imports: FxHashMap::default(), + bindings: Bindings::default(), package: package.to_string(), compat_package: format!("{package}/compat"), css_files, styles: StyleCollector::default(), errors: Vec::new(), - import_object: None, - jsx_object: None, - util_imports: FxHashMap::default(), split_filename, - styled_imports: FxHashSet::default(), styled_jsx: None, styled_capability: None, - stylex_imports: FxHashSet::default(), - stylex_named_imports: FxHashMap::default(), - global_style_components: FxHashSet::default(), - class_names_components: FxHashSet::default(), class_names_scope: Vec::new(), stylex_var_refs: FxHashMap::default(), stylex_var_names: FxHashMap::default(), stylex_theme_classes: FxHashMap::default(), + imported_stylex: (FxHashMap::default(), FxHashMap::default()), stylex_pending_vars: None, stylex_pending_theme_class: None, stylex_pending_consts: None, stylex_pending_create: None, stylex_keys: FxHashMap::default(), stylex_pending_keys: None, + stylex_captures: stylex_capture::Captures::default(), stylex_namespaces: FxHashMap::default(), stylex_pending_keyframe_name: None, stylex_keyframe_names: FxHashMap::default(), pending_replacement: None, spreads_read_once: 0, runtime_types: 0, + scoping: None, style_values: crate::style_values::StyleValues::default(), css_styles: None, pending_styled: None, @@ -1003,7 +943,6 @@ impl<'a> DevupVisitor<'a> { forwards_refs: false, styled_definitions: FxHashMap::default(), imported_css: FxHashMap::default(), - compiled_names: FxHashSet::default(), unknown_bindings: crate::imported_constants::Unknown::default(), composes_unknown: false, unknown_parts: Vec::new(), @@ -1135,66 +1074,19 @@ impl<'a> DevupVisitor<'a> { /// The style function `callee` names: `css`, `Devup.keyframes`, ... fn util_type(&self, callee: &Expression<'a>) -> Option> { - match callee { - Expression::Identifier(ident) => { - let util = self.util_imports.get(ident.name.as_str())?; - self.style_values.reads_root_binding(ident).then_some(util) - } - Expression::StaticMemberExpression(member) if !self.util_imports.is_empty() => { - let Expression::Identifier(ident) = &member.object else { - return None; - }; - let obj = ident.name.as_str(); - let prop = member.property.name.as_str(); - let mut key = String::with_capacity(obj.len() + 1 + prop.len()); - key.push_str(obj); - key.push('.'); - key.push_str(prop); - let util = self.util_imports.get(key.as_str())?; - self.style_values.reads_root_binding(ident).then_some(util) - } - _ => None, - } - .cloned() - } - - /// Register what the package imported whole as `local` gives: its - /// components, style functions, `styled` and `Global` as members - fn import_whole(&mut self, local: &str) { - for kind in ExportVariableKind::iter() { - self.imports.insert(format!("{local}.{kind}"), kind); - } - for (name, kind) in [ - ("css", UtilType::Css), - ("cx", UtilType::Cx), - ("merge", UtilType::Merge), - ("globalCss", UtilType::GlobalCss), - ("keyframes", UtilType::Keyframes), - ("createGlobalStyle", UtilType::GlobalCssComponent), - ] { - self.util_imports - .insert(format!("{local}.{name}"), Rc::new(kind)); - } - self.styled_imports.insert(format!("{local}.styled")); - self.global_style_components - .insert(format!("{local}.Global")); + self.bindings.util(callee) } /// `node` with a `styled` read as a member of the package imported whole - /// (`Devup.styled.div`) written as a binding of the same text, which the - /// extraction of `styled` reads like the named import + /// (`Devup.styled.div`) written as a binding no source can name, which + /// the extraction of `styled` reads like the named import fn plain_styled(&self, node: &mut Expression<'a>) { if let Expression::StaticMemberExpression(member) = unwrap_syntax_only(node) && member.property.name == "styled" && let Expression::Identifier(object) = &member.object { - let name = format!("{}.styled", object.name); - if self.styled_imports.contains(&name) { - *node = Expression::new_identifier( - SPAN, - Str::from_in(name.as_str(), self.ast.allocator()), - &self.ast, - ); + if self.bindings.is_namespace(object) { + *node = Expression::new_identifier(SPAN, NAMESPACE_STYLED, &self.ast); } return; } @@ -1208,27 +1100,13 @@ impl<'a> DevupVisitor<'a> { /// Report where `program` still reads what the build compiled away: its /// import is gone, so that code would throw at runtime fn report_compiled_reads(&mut self, program: &Program<'a>) { - let mut reads = CompiledReads { - names: &self.compiled_names, - scoping: None, - found: Vec::new(), - }; - oxc_ast_visit::Visit::visit_program(&mut reads, program); - if reads.found.is_empty() { - return; - } - // A binding the code declares may share the name - let scoping = oxc_semantic::SemanticBuilder::new() - .build(program) - .semantic - .into_scoping(); - let mut reads = CompiledReads { - names: &self.compiled_names, - scoping: Some(&scoping), - found: Vec::new(), - }; - oxc_ast_visit::Visit::visit_program(&mut reads, program); - for (offset, name) in reads.found { + let reads = self.bindings.compiled_reads(program); + self.report_reads(reads); + } + + /// Report the reads `reads` tells of what the build compiled away + fn report_reads(&mut self, reads: Vec<(u32, String)>) { + for (offset, name) in reads { if !self.errors.iter().any(|(at, _)| *at == offset) { self.errors.push(( offset, @@ -1240,22 +1118,48 @@ impl<'a> DevupVisitor<'a> { } } - /// Whether `program` imports a style function whose result it may bind: - /// `css()` gives a class, `keyframes()` a name - fn binds_style_results(&self, program: &Program<'a>) -> bool { - let mut class_names = DynamicClassNames::default(); - oxc_ast_visit::Visit::visit_program(&mut class_names, program); - class_names.found || program.body.iter().any(|statement| { - matches!(statement, Statement::ImportDeclaration(import) - if (import.source.value == self.package - || import.source.value == self.compat_package.as_str()) - && import.specifiers.iter().flatten().any(|specifier| match specifier { - ImportSpecifier(specifier) => { - matches!(specifier.imported.name().as_str(), "css" | "keyframes" | "styled") - || (import.source.value == self.compat_package.as_str() && specifier.imported.name() == "ClassNames") - } - _ => true, - })) + /// Report what `it`, a call or template the build compiles, reads of + /// what the build compiled away: its styles are written from the code + /// read, which the reads would leave as the names of nothing + fn report_lowered_reads(&mut self, it: &Expression<'a>) { + let callee = match it { + Expression::CallExpression(call) => &call.callee, + Expression::TaggedTemplateExpression(tag) => &tag.tag, + _ => return, + }; + if self.bindings.compiles(callee) { + let reads = self.bindings.lowered_reads(it); + self.report_reads(reads); + } + } + + /// Reuse the semantic analysis of the program as parsed, which constants + /// inlined since then leave valid + pub fn reuse_scoping(&mut self, scoping: Option>) { + self.scoping = scoping; + } + + /// The bindings of `program`: the analysis given, or one built when the + /// program may bind what the build compiles + fn scoping_of(&mut self, program: &Program<'a>) -> Option> { + self.scoping.take().or_else(|| { + let imports_api = program.body.iter().any(|statement| { + matches!(statement, Statement::ImportDeclaration(import) + if import.source.value == self.package + || import.source.value == self.compat_package.as_str() + || import.source.value == "@stylexjs/stylex") + }); + (imports_api + || self.css_prop != CssProp::Off + || crate::scope::requires(program, &self.package)) + .then(|| { + Rc::new( + oxc_semantic::SemanticBuilder::new() + .build(program) + .semantic + .into_scoping(), + ) + }) }) } @@ -1324,9 +1228,8 @@ impl<'a> DevupVisitor<'a> { /// Whether the element `name` takes Emotion's `css` prop fn takes_css(&self, name: &JSXElementName<'a>) -> bool { - self.css_prop.takes(name, |_| { - self.imports.contains_key(name.to_string().as_str()) - }) + self.css_prop + .takes(name, |_| self.bindings.element(name).is_some()) } /// Take the `css` prop off `element` when it takes one @@ -1364,6 +1267,9 @@ impl<'a> DevupVisitor<'a> { /// The `css` prop `value` of `element`, written at `offset`, with what it /// composes written as parts, then visited fn css_value(&mut self, element: &str, mut value: Expression<'a>, offset: u32) -> CssValue<'a> { + self.bindings.remember(&value); + let reads = self.bindings.lowered_reads(&value); + self.report_reads(reads); self.css_prop_value(element, &mut value); self.visit_expression(&mut value); self.compiled_css_prop = true; @@ -1438,10 +1344,14 @@ impl<'a> DevupVisitor<'a> { } } function @ (Expression::ArrowFunctionExpression(_) - | Expression::FunctionExpression(_)) => match theme_rules(&self.ast, function) { - Ok(rules) => rules, - Err(unread) => self.css_prop_failure(element, unread), - }, + | Expression::FunctionExpression(_)) => { + match theme_rules_of(&self.ast, function, &|identifier| { + self.bindings.symbol(identifier) + }) { + Ok(rules) => rules, + Err(unread) => self.css_prop_failure(element, unread), + } + } _ => return, }; *value = parts; @@ -1537,7 +1447,9 @@ impl<'a> DevupVisitor<'a> { return earlier; } let unknown = self.css_part(&value, &|visitor, part| { - visitor.unknown_bindings.read_by(part) + visitor.unknown_bindings.read_by_in(part, &|identifier| { + visitor.bindings.reads_module(identifier) + }) }); if let Some(part) = unknown { self.composes_unknown = true; @@ -1547,7 +1459,9 @@ impl<'a> DevupVisitor<'a> { )); } let changed = self.css_part(&value, &|visitor, part| { - visitor.changed_bindings.read_by(part) + visitor.changed_bindings.read_by_in(part, &|identifier| { + visitor.bindings.reads_module(identifier) + }) }); if let Some(part) = changed { self.errors.push(( @@ -1844,11 +1758,7 @@ impl<'a> DevupVisitor<'a> { let JSXElementName::IdentifierReference(name) = &element.opening_element.name else { return None; }; - if !self - .style_values - .reference_symbol(name) - .is_some_and(|symbol| self.class_names_components.contains(&symbol)) - { + if !self.bindings.is_class_names(name) { return None; } Some( @@ -1898,7 +1808,7 @@ impl<'a> DevupVisitor<'a> { let mut calls = ClassNamesCalls { ast: &self.ast, names: vec![names], - components: &self.class_names_components, + bindings: &self.bindings, style_values: &self.style_values, unread: None, }; @@ -1922,7 +1832,7 @@ impl<'a> DevupVisitor<'a> { let Expression::Identifier(callee) = callee else { return None; }; - let symbol = self.style_values.reference_symbol(callee); + let symbol = self.bindings.symbol(callee); self.class_names_scope.iter().rev().find_map(|names| { if ClassNamesParams::is(names.css, symbol) { Some(Text::Rules) @@ -1967,15 +1877,18 @@ impl<'a> DevupVisitor<'a> { /// code the build cannot ready so is kept. struct ClassNamesCalls<'r, 'a> { ast: &'r AstBuilder<'a>, + bindings: &'r Bindings, names: Vec, - components: &'r FxHashSet, style_values: &'r crate::style_values::StyleValues<'a>, unread: Option<(Expression<'a>, &'static str)>, } impl<'a> ClassNamesCalls<'_, 'a> { fn calls(&self, callee: &Expression<'a>) -> Option { - let symbol = self.style_values.symbol(callee); + let Expression::Identifier(callee) = callee else { + return None; + }; + let symbol = self.bindings.symbol(callee); self.names.iter().rev().find_map(|names| { if ClassNamesParams::is(names.css, symbol) { Some(Text::Rules) @@ -1989,7 +1902,7 @@ impl<'a> ClassNamesCalls<'_, 'a> { fn read_theme(&mut self, expression: &mut Expression<'a>, value: bool) { if let Err(unread) = read_theme(self.ast, expression, &self.names, value, &|identifier| { - self.style_values.reference_symbol(identifier) + self.bindings.symbol(identifier) }) { self.unread.get_or_insert((unread, THEME_READ)); } @@ -2099,12 +2012,7 @@ impl<'a> ClassNamesCalls<'_, 'a> { impl<'a> VisitMut<'a> for ClassNamesCalls<'_, 'a> { fn visit_jsx_element(&mut self, element: &mut JSXElement<'a>) { let names = match &element.opening_element.name { - JSXElementName::IdentifierReference(name) - if self - .style_values - .reference_symbol(name) - .is_some_and(|symbol| self.components.contains(&symbol)) => - { + JSXElementName::IdentifierReference(name) if self.bindings.is_class_names(name) => { class_names_child(element) } _ => None, @@ -2168,7 +2076,7 @@ impl<'a> VisitMut<'a> for ClassNamesCalls<'_, 'a> { /// A read of the theme or of `css` and `cx` the calls do not take fn visit_identifier_reference(&mut self, it: &mut oxc_ast::ast::IdentifierReference<'a>) { - let symbol = self.style_values.reference_symbol(it); + let symbol = self.bindings.symbol(it); let requirement = if self .names .iter() @@ -2196,47 +2104,86 @@ impl<'a> DevupVisitor<'a> { /// it produces. `props()` targets React (`className`), `attrs()` targets raw HTML /// (`class`); everything else about the two calls is identical. fn stylex_class_attribute(&self, callee: &Expression) -> Option<&'static str> { - // Check namespace/default call: stylex.props(...) - if let Expression::StaticMemberExpression(member) = callee - && let Expression::Identifier(ident) = &member.object - && self.stylex_imports.contains(ident.name.as_str()) - { - return match member.property.name.as_str() { - "props" => Some("className"), - "attrs" => Some("class"), - _ => None, - }; - } - // Check named import call: props(...) - if let Expression::Identifier(ident) = callee { - return match self.stylex_named_imports.get(ident.name.as_str()) { - Some(StylexFunction::Props) => Some("className"), - Some(StylexFunction::Attrs) => Some("class"), - _ => None, - }; + match self.bindings.stylex_function(callee) { + Some(StylexFunction::Props) => Some("className"), + Some(StylexFunction::Attrs) => Some("class"), + _ => None, } - None } - /// `StyleX` variables and themes the program imports from other modules, - /// by the name it binds them to + /// The styles behind the `css()` classes the program imports from other + /// modules, by the name it binds them to pub fn import_css(&mut self, styles: FxHashMap>) { self.imported_css = styles; } + /// `StyleX` variables and themes the program imports from other modules, + /// by the name it binds them to pub fn import_stylex( &mut self, vars: FxHashMap>, themes: FxHashMap, ) { + self.imported_stylex = (vars, themes); + } + + /// Read the `StyleX` variables and themes the program imports as the + /// bindings that hold them + fn bind_imported_stylex(&mut self) { + let (vars, themes) = std::mem::take(&mut self.imported_stylex); for (name, contract) in vars { - for (key, variable) in &contract { - self.stylex_var_refs - .insert(format!("{name}.{key}"), format!("var({variable})")); + if let Some(symbol) = self.bindings.imported(&name) { + self.stylex_var_refs.entry(symbol).or_default().extend( + contract + .iter() + .map(|(key, variable)| (key.clone(), format!("var({variable})"))), + ); + self.stylex_var_names.insert(symbol, contract.clone()); + } + } + for (name, class) in themes { + if let Some(symbol) = self.bindings.imported(&name) { + self.stylex_theme_classes.insert(symbol, class); } - self.stylex_var_names.insert(name, contract); } - self.stylex_theme_classes.extend(themes); + } + + /// The keyframe names and `defineVars` members, as `vars.key`, that the + /// bindings `visible` to a `stylex.create()` call stand for + fn stylex_names( + &self, + visible: &FxHashMap, + ) -> (FxHashMap, FxHashMap) { + let keyframes = visible + .iter() + .filter_map(|(name, symbol)| { + Some(( + name.clone(), + self.stylex_keyframe_names.get(symbol)?.clone(), + )) + }) + .collect(); + let vars = visible + .iter() + .filter_map(|(name, symbol)| Some((name, self.stylex_var_refs.get(symbol)?))) + .flat_map(|(name, members)| { + members + .iter() + .map(move |(key, value)| (format!("{name}.{key}"), value.clone())) + }) + .collect(); + (keyframes, vars) + } + + /// The namespaces of the `stylex.create()` binding `object` reads + fn stylex_namespace( + &self, + object: &Expression<'a>, + ) -> Option<&FxHashMap> { + let Expression::Identifier(object) = object else { + return None; + }; + self.stylex_namespaces.get(&self.bindings.symbol(object)?) } /// The key of an entry of a `StyleX` object, or `None` after reporting a @@ -2283,39 +2230,23 @@ impl<'a> DevupVisitor<'a> { ) } - /// The `StyleX` API a callee names, through either the namespace form - /// (`stylex.defineVars`) or a named import. - fn stylex_function(&self, callee: &Expression) -> Option { - match callee { - Expression::StaticMemberExpression(member) - if matches!(&member.object, Expression::Identifier(ident) - if self.stylex_imports.contains(ident.name.as_str())) => - { - StylexFunction::from_export_name(member.property.name.as_str()) - } - Expression::Identifier(ident) => { - self.stylex_named_imports.get(ident.name.as_str()).cloned() - } - _ => None, - } - } - fn is_stylex_call(&self, callee: &Expression, function: &StylexFunction) -> bool { - self.stylex_function(callee).as_ref() == Some(function) + self.bindings.stylex_function(callee).as_ref() == Some(function) } /// `stylex.props(...)` merging namespaces whose keys the build knows: a /// later namespace's key replaces an earlier one's, and `null` removes it, /// as `StyleX` merges them. `None` when an argument is anything else. fn compose_stylex_props( - &self, + &mut self, arguments: &oxc_allocator::Vec<'a, Argument<'a>>, - ) -> Option> { + ) -> Option<(Expression<'a>, Vec>)> { let mut parts = Vec::new(); for argument in arguments { self.stylex_parts(argument.as_expression()?, &mut parts)?; } - let mut entries: Vec<(String, StylexChoice<'a>)> = Vec::new(); + let mut entries: Vec<(String, StylexChoice)> = Vec::new(); + let mut tests = Vec::new(); for part in parts { match part { StylexPart::Keys(keys) => { @@ -2329,46 +2260,95 @@ impl<'a> DevupVisitor<'a> { consequent, alternate, } => { - let mut keys: Vec<&String> = Vec::new(); - for (key, _) in consequent.iter().chain(&alternate) { - if !keys.contains(&key) { - keys.push(key); + let index = tests.len(); + tests.push(test); + let mut keys: Vec<(&String, &String, bool)> = Vec::new(); + for (side, is_consequent) in [(&consequent, true), (&alternate, false)] { + for (key, class) in side { + if !keys.iter().any(|(existing, _, _)| *existing == key) { + keys.push((key, class, is_consequent)); + } } } - for key in keys { + for (key, class, is_consequent) in keys { let previous = entries .iter() .position(|(existing, _)| existing == key) .map_or(StylexChoice::Class(String::new()), |index| { entries.remove(index).1 }); - let copy = previous.copy(&self.ast); - let pick = |side: &[(String, String)], previous| { - side.iter() - .find(|(existing, _)| existing == key) - .map_or(previous, |(_, class)| StylexChoice::Class(class.clone())) + let opposite = if is_consequent { + &alternate + } else { + &consequent }; + let opposite = opposite + .iter() + .find(|(existing, _)| existing == key) + .map_or(previous, |(_, class)| StylexChoice::Class(class.clone())); + let origin = StylexChoice::Class(class.clone()); + let (consequent, alternate) = if is_consequent { + (origin, opposite) + } else { + (opposite, origin) + }; + if consequent == alternate { + entries.push((key.clone(), consequent)); + continue; + } entries.push(( key.clone(), StylexChoice::Conditional { - test: test.clone_in(self.ast.allocator()), - consequent: Box::new(pick(&consequent, previous)), - alternate: Box::new(pick(&alternate, copy)), + test: index, + consequent: Box::new(consequent), + alternate: Box::new(alternate), }, )); } } } } - Some( + let mut uses = vec![0; tests.len()]; + for (_, choice) in &entries { + choice.dependencies(&mut uses); + } + // A later dead test still follows an earlier live test; only the last authored + // test can stay direct without moving its evaluation past another test. + let last = tests.len().checked_sub(1); + let mut evaluations = Vec::new(); + let tests: Vec<_> = tests + .into_iter() + .enumerate() + .map(|(index, test)| { + if uses[index] == 0 { + evaluations.push(test); + return Expression::new_boolean_literal(SPAN, false, &self.ast); + } + match uses[index] { + 1 if Some(index) == last => test, + _ => match self.stylex_captures.capture(&self.ast, test) { + Ok((reference, assignment)) => { + evaluations.push(assignment); + reference + } + Err(error) => { + self.errors.push(error); + Expression::new_boolean_literal(SPAN, false, &self.ast) + } + }, + } + }) + .collect(); + Some(( merge_expression_for_class_name( &self.ast, entries .into_iter() - .filter_map(|(_, choice)| self.render_stylex_choice(choice)), + .filter_map(|(_, choice)| self.render_stylex_choice(choice, &tests)), ) .unwrap_or_else(|| Expression::new_string_literal(SPAN, "", None, &self.ast)), - ) + evaluations, + )) } /// The parts of a `stylex.props()` argument, in order @@ -2383,14 +2363,18 @@ impl<'a> DevupVisitor<'a> { if logical.operator == oxc_ast::ast::LogicalOperator::And => { parts.push(StylexPart::Conditional { - test: logical.left.clone_in(self.ast.allocator()), + test: logical + .left + .clone_in_with_semantic_ids(self.ast.allocator()), consequent: self.stylex_keyed(&logical.right)?, alternate: Vec::new(), }); } Expression::ConditionalExpression(conditional) => { parts.push(StylexPart::Conditional { - test: conditional.test.clone_in(self.ast.allocator()), + test: conditional + .test + .clone_in_with_semantic_ids(self.ast.allocator()), consequent: self.stylex_keyed(&conditional.consequent)?, alternate: self.stylex_keyed(&conditional.alternate)?, }); @@ -2420,12 +2404,16 @@ impl<'a> DevupVisitor<'a> { return None; }; self.stylex_keys - .get(object.name.as_str())? + .get(&self.bindings.symbol(object)?)? .get(&name) .cloned() } - fn render_stylex_choice(&self, choice: StylexChoice<'a>) -> Option> { + fn render_stylex_choice( + &self, + choice: StylexChoice, + tests: &[Expression<'a>], + ) -> Option> { match choice { StylexChoice::Class(class) => self.stylex_class_literal(&class), StylexChoice::Conditional { @@ -2434,24 +2422,15 @@ impl<'a> DevupVisitor<'a> { alternate, } => { let empty = || Expression::new_string_literal(SPAN, "", None, &self.ast); - match ( - self.render_stylex_choice(*consequent), - self.render_stylex_choice(*alternate), - ) { - (None, None) => None, - (Some(consequent), Some(alternate)) - if crate::utils::is_same_expression(&consequent, &alternate) => - { - Some(consequent) - } - (consequent, alternate) => Some(Expression::new_conditional_expression( - SPAN, - test, - consequent.unwrap_or_else(empty), - alternate.unwrap_or_else(empty), - &self.ast, - )), - } + Some(Expression::new_conditional_expression( + SPAN, + tests[test].clone_in_with_semantic_ids(self.ast.allocator()), + self.render_stylex_choice(*consequent, tests) + .unwrap_or_else(empty), + self.render_stylex_choice(*alternate, tests) + .unwrap_or_else(empty), + &self.ast, + )) } } } @@ -2603,8 +2582,7 @@ impl<'a> DevupVisitor<'a> { call: &CallExpression<'a>, ) -> Option<(Expression<'a>, Vec>)> { if let Expression::StaticMemberExpression(member) = &call.callee - && let Expression::Identifier(obj) = &member.object - && let Some(ns_map) = self.stylex_namespaces.get(obj.name.as_str()) + && let Some(ns_map) = self.stylex_namespace(&member.object) && let Some(StylexNamespaceValue::Dynamic(info)) = ns_map.get(member.property.name.as_str()) { @@ -2620,8 +2598,10 @@ impl<'a> DevupVisitor<'a> { if let Some(arg) = call.arguments.get(*param_idx) && let Some(arg_expr) = arg.as_expression() { - let arg_expr = - self.with_number_unit(arg_expr.clone_in(self.ast.allocator()), unit); + let arg_expr = self.with_number_unit( + arg_expr.clone_in_with_semantic_ids(self.ast.allocator()), + unit, + ); props.push(ObjectPropertyKind::new_object_property( SPAN, PropertyKind::Init, @@ -2694,8 +2674,7 @@ impl<'a> DevupVisitor<'a> { &self, member: &StaticMemberExpression<'a>, ) -> Option> { - if let Expression::Identifier(obj) = &member.object - && let Some(ns_map) = self.stylex_namespaces.get(obj.name.as_str()) + if let Some(ns_map) = self.stylex_namespace(&member.object) && let Some(StylexNamespaceValue::Static(cn)) = ns_map.get(member.property.name.as_str()) { @@ -2715,10 +2694,7 @@ impl<'a> DevupVisitor<'a> { &self, member: &ComputedMemberExpression<'a>, ) -> Option> { - let Expression::Identifier(obj) = &member.object else { - return None; - }; - let ns_map = self.stylex_namespaces.get(obj.name.as_str())?; + let ns_map = self.stylex_namespace(&member.object)?; if let Some(key) = get_key_by_literal_expression(&member.expression) { return match ns_map.get(key.as_ref()) { @@ -2742,8 +2718,12 @@ impl<'a> DevupVisitor<'a> { &self.ast, &Expression::ComputedMemberExpression(ComputedMemberExpression::boxed( SPAN, - member.object.clone_in(self.ast.allocator()), - member.expression.clone_in(self.ast.allocator()), + member + .object + .clone_in_with_semantic_ids(self.ast.allocator()), + member + .expression + .clone_in_with_semantic_ids(self.ast.allocator()), member.optional, &self.ast, )), @@ -2762,8 +2742,9 @@ impl<'a> DevupVisitor<'a> { } // darkTheme → Identifier bound to a stylex.createTheme() class Expression::Identifier(ident) if ident.name != "undefined" => self - .stylex_theme_classes - .get(ident.name.as_str()) + .bindings + .symbol(ident) + .and_then(|symbol| self.stylex_theme_classes.get(&symbol)) .and_then(|class_name| self.stylex_class_literal(class_name)), Expression::ChainExpression(chain) => match &chain.expression { ChainElement::StaticMemberExpression(member) => { @@ -2796,8 +2777,7 @@ impl<'a> DevupVisitor<'a> { }, _ => expr, }; - matches!(object, Expression::Identifier(ident) - if self.stylex_namespaces.contains_key(ident.name.as_str())) + self.stylex_namespace(object).is_some() } fn resolve_stylex_composed_arg(&self, expr: &Expression<'a>) -> Option> { @@ -2818,7 +2798,9 @@ impl<'a> DevupVisitor<'a> { // The right side should be the namespace reference if let Some(class_expr) = self.resolve_stylex_arg(&logical.right) { // Build: condition ? " className" : "" - let condition = logical.left.clone_in(self.ast.allocator()); + let condition = logical + .left + .clone_in_with_semantic_ids(self.ast.allocator()); Some(Expression::new_conditional_expression( SPAN, condition, @@ -2836,13 +2818,13 @@ impl<'a> DevupVisitor<'a> { let alternate = self.resolve_stylex_arg(&cond.alternate); match (consequent, alternate) { (Some(cons), Some(alt)) => { - let test = cond.test.clone_in(self.ast.allocator()); + let test = cond.test.clone_in_with_semantic_ids(self.ast.allocator()); Some(Expression::new_conditional_expression( SPAN, test, cons, alt, &self.ast, )) } (Some(cons), None) => { - let test = cond.test.clone_in(self.ast.allocator()); + let test = cond.test.clone_in_with_semantic_ids(self.ast.allocator()); Some(Expression::new_conditional_expression( SPAN, test, @@ -2852,7 +2834,7 @@ impl<'a> DevupVisitor<'a> { )) } (None, Some(alt)) => { - let test = cond.test.clone_in(self.ast.allocator()); + let test = cond.test.clone_in_with_semantic_ids(self.ast.allocator()); Some(Expression::new_conditional_expression( SPAN, Expression::new_unary_expression( @@ -2881,6 +2863,89 @@ impl<'a> DevupVisitor<'a> { } impl<'a> VisitMut<'a> for DevupVisitor<'a> { + fn enter_scope( + &mut self, + flags: oxc_syntax::scope::ScopeFlags, + scope_id: &std::cell::Cell>, + ) { + oxc_ast_visit::Visit::enter_scope(&mut self.stylex_captures, flags, scope_id); + } + + fn leave_scope(&mut self) { + oxc_ast_visit::Visit::leave_scope(&mut self.stylex_captures); + } + + fn visit_statements(&mut self, it: &mut oxc_allocator::Vec<'a, Statement<'a>>) { + let context = self.stylex_captures.context.take(); + self.stylex_captures.push_frame(); + walk_mut::walk_statements(self, it); + let (_, names) = self.stylex_captures.frames.pop().unwrap_or_default(); + self.stylex_captures.context = context; + if !names.is_empty() { + it.insert(0, stylex_capture::declaration(&self.ast, names)); + } + } + + fn visit_formal_parameters(&mut self, it: &mut FormalParameters<'a>) { + let context = self.stylex_captures.context.replace("a parameter default"); + walk_mut::walk_formal_parameters(self, it); + self.stylex_captures.context = context; + } + + fn visit_property_definition(&mut self, it: &mut oxc_ast::ast::PropertyDefinition<'a>) { + self.visit_decorators(&mut it.decorators); + self.visit_property_key(&mut it.key); + let context = self + .stylex_captures + .context + .replace("a class field initializer"); + if let Some(value) = &mut it.value { + self.visit_expression(value); + } + self.stylex_captures.context = context; + } + + fn visit_accessor_property(&mut self, it: &mut oxc_ast::ast::AccessorProperty<'a>) { + self.visit_decorators(&mut it.decorators); + self.visit_property_key(&mut it.key); + let context = self + .stylex_captures + .context + .replace("a class field initializer"); + if let Some(value) = &mut it.value { + self.visit_expression(value); + } + self.stylex_captures.context = context; + } + + fn visit_arrow_function_body(&mut self, it: &mut oxc_ast::ast::ArrowFunctionBody<'a>) { + if let oxc_ast::ast::ArrowFunctionBody::FunctionBody(body) = it { + self.visit_function_body(body); + return; + } + let context = self.stylex_captures.context.take(); + self.stylex_captures.push_frame(); + self.visit_expression(it.to_expression_mut()); + let (_, names) = self.stylex_captures.frames.pop().unwrap_or_default(); + self.stylex_captures.context = context; + if !names.is_empty() { + let value = it.to_expression_mut().take_in(&self.ast); + let statements = oxc_allocator::Vec::from_array_in( + [ + stylex_capture::declaration(&self.ast, names), + Statement::new_return_statement(SPAN, Some(value), &self.ast), + ], + &self.ast, + ); + *it = oxc_ast::ast::ArrowFunctionBody::new_function_body( + SPAN, + oxc_allocator::Vec::new_in(&self.ast), + statements, + &self.ast, + ); + } + } + fn visit_expression_statement(&mut self, it: &mut ExpressionStatement<'a>) { walk_expression_statement(self, it); // `globalCss()` gives nothing, which a statement need not write out @@ -2895,26 +2960,9 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { &mut self, it: &mut oxc_allocator::Vec<'a, VariableDeclarator<'a>>, ) { - for v in it.iter() { - if let VariableDeclarator { - id, - init: Some(Expression::Identifier(ident)), - .. - } = v - && let Some(name) = id.get_binding_identifier().map(|id| id.name.to_string()) - { - let original = ident.name.as_str(); - if let Some(util) = self.util_imports.get(original).cloned() { - self.util_imports.insert(name.clone(), util); - } else if self.styled_imports.contains(original) { - self.styled_imports.insert(name.clone()); - } else if let Some(kind) = self.imports.get(original).cloned() { - self.imports.insert(name.clone(), kind); - } else { - continue; - } - self.compiled_names.insert(name); - } + for declarator in it.iter() { + self.bindings.require(declarator, &self.package); + self.bindings.alias(declarator); } walk_variable_declarators(self, it); } @@ -2947,18 +2995,14 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { self.styled_jsx = StyledJsx::reserve(it); self.styled_capability .get_or_insert_with(|| StyledCapability::reserve(it)); - if !self.style_values.has_scoping() - && (self.binds_style_results(it) || self.css_prop != CssProp::Off) - { - self.style_values = crate::style_values::StyleValues::new(Rc::new( - oxc_semantic::SemanticBuilder::new() - .build(it) - .semantic - .into_scoping(), - )); + if let Some(scoping) = self.scoping_of(it) { + self.bindings.scope(Rc::clone(&scoping)); + self.bind_imported_stylex(); + self.style_values = crate::style_values::StyleValues::new(scoping); + self.style_values + .import(std::mem::take(&mut self.imported_css)); } - self.style_values - .import(std::mem::take(&mut self.imported_css)); + self.stylex_captures.reserve(it); if self.style_values.has_scoping() { self.selected_components = crate::style_values::selected(it, &self.style_values); let imported: Vec<_> = self @@ -2979,19 +3023,8 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { } walk_program(self, it); self.style_values.read_names(&self.ast, it); - if !self.compiled_names.is_empty() { - // Aliases only the calls and elements the build compiled read; at - // the top level nothing can shadow what they alias - let compiled = &self.compiled_names; - it.body.retain_mut(|statement| { - let Statement::VariableDeclaration(declaration) = statement else { - return true; - }; - declaration - .declarations - .retain(|declarator| !is_alias(declarator, compiled)); - !declaration.declarations.is_empty() - }); + if self.bindings.is_compiled() { + self.bindings.remove_aliases(it); self.report_compiled_reads(it); } if self.forwards_refs { @@ -3052,7 +3085,7 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { if !self.class_names_scope.is_empty() && self.compile_class_names_call(it) { return; } - if !self.styled_imports.is_empty() { + if self.bindings.may_style() { match it { Expression::CallExpression(call) => self.plain_styled(&mut call.callee), Expression::TaggedTemplateExpression(tag) => self.plain_styled(&mut tag.tag), @@ -3066,11 +3099,13 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { Expression::TaggedTemplateExpression(tag) => Some(&mut tag.tag), _ => None, }; - let styled_imports = &self.styled_imports; - let (attrs, mut configs) = factory.map_or_else( + let bindings = &self.bindings; + let (mut attrs, mut configs) = factory.map_or_else( || (Vec::new(), Vec::new()), |factory| { - take_styled_modifiers(&self.ast, factory, |name| styled_imports.contains(name)) + take_styled_modifiers(&self.ast, factory, |expression| { + bindings.is_styled(expression) + }) }, ); let factory = match it { @@ -3084,7 +3119,7 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { && call.arguments[1].as_expression().is_some_and(|options| { matches!(unwrap_syntax_only(options), Expression::ObjectExpression(_)) }) - && matches!(&call.callee, Expression::Identifier(ident) if self.styled_imports.contains(ident.name.as_str())) + && matches!(&call.callee, callee @ Expression::Identifier(_) if self.bindings.is_styled(callee)) && let Some(options) = call.arguments.pop() { configs.insert(0, options.into_expression()); @@ -3093,6 +3128,12 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { if let Some(error) = forward_error { self.errors.push(error); } + for attr in &mut attrs { + let reads = self.bindings.lowered_reads(attr); + self.report_reads(reads); + self.bindings.remember(attr); + self.visit_expression(attr); + } let flattened = match it { Expression::CallExpression(call) if self.is_css(&call.callee) => call .arguments @@ -3113,40 +3154,32 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { _ => false, }; walk_expression(self, it); + self.report_lowered_reads(it); // Handle styled function calls - if !self.styled_imports.is_empty() { + if self.bindings.may_style() { let (tag_or_call, argument_count) = match it { Expression::TaggedTemplateExpression(tag) => (Some(&tag.tag), 0), Expression::CallExpression(call) => (Some(&call.callee), call.arguments.len()), _ => (None, 0), }; - let is_styled_name = |name: &str| self.styled_imports.contains(name); + let bindings = &self.bindings; - let is_styled = if let Some(tag_or_call) = tag_or_call.map(unwrap_syntax_only) { - if let Expression::StaticMemberExpression(member) = tag_or_call { - if let Expression::Identifier(ident) = &member.object { - is_styled_name(ident.name.as_str()) - } else { - false + let is_styled = tag_or_call + .map(unwrap_syntax_only) + .is_some_and(|tag_or_call| match tag_or_call { + Expression::StaticMemberExpression(member) => { + bindings.is_styled(&member.object) } - } else if let Expression::CallExpression(call) = tag_or_call { - if let Expression::Identifier(ident) = &call.callee { - is_styled_name(ident.name.as_str()) - } else { - false - } - } else if let Expression::Identifier(ident) = tag_or_call { + Expression::CallExpression(call) => bindings.is_styled(&call.callee), // styled("div", { ... }) puts the tag in the arguments, so the callee is // the bare identifier. One argument is the curried creator `styled("div")`, // which only becomes a component once its result is called. - is_styled_name(ident.name.as_str()) && argument_count == 2 - } else { - false - } - } else { - false - }; + Expression::Identifier(_) => { + bindings.is_styled(tag_or_call) && argument_count == 2 + } + _ => false, + }); if is_styled { self.style_values.read_in(&self.ast, it); @@ -3177,7 +3210,7 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { split_filename: self.split_filename.as_deref(), markers: &markers, }, - &self.imports, + &|expression| self.bindings.kind(expression), (&attrs, forward), inherited.as_ref(), StyledContext { @@ -3251,11 +3284,14 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { && self.is_stylex_call(&call.callee, &StylexFunction::Create) && let [Argument::ObjectExpression(arg)] = call.arguments.as_slice() { + let visible = self.bindings.visible(arg); + let (keyframe_names, var_refs) = self.stylex_names(&visible); let namespaces = extract_stylex_namespace_styles( arg, - &self.stylex_keyframe_names, - &self.stylex_var_refs, + &keyframe_names, + &var_refs, &mut self.errors, + &|callee| self.bindings.stylex_function(callee), ); let mut namespace_map: FxHashMap = FxHashMap::default(); @@ -3297,9 +3333,9 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { // Resolve include() references — prepend included classNames for inc_ref in &include_refs { - let Some(ns_value) = self - .stylex_namespaces + let Some(ns_value) = visible .get(&inc_ref.var_name) + .and_then(|symbol| self.stylex_namespaces.get(symbol)) .and_then(|ns| ns.get(&inc_ref.member_name)) else { self.errors.push(( @@ -3427,7 +3463,9 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { continue; }; let values = if publishes_values { - let Some(values) = variable_values(&prop.value) else { + let Some(values) = variable_values(&prop.value, &|callee| { + self.bindings.stylex_function(callee) + }) else { self.errors.push(( prop.value.span().start, runtime_value_error("stylex.defineVars", &readable_code(&prop.value)), @@ -3461,7 +3499,10 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { && self.is_stylex_call(&call.callee, &StylexFunction::CreateTheme) && let [contract_arg, values_arg] = call.arguments.as_slice() && let Some(Expression::Identifier(contract_ident)) = contract_arg.as_expression() - && let Some(contract) = self.stylex_var_names.get(contract_ident.name.as_str()) + && let Some(contract) = self + .bindings + .symbol(contract_ident) + .and_then(|symbol| self.stylex_var_names.get(&symbol)) && let Some(Expression::ObjectExpression(obj)) = values_arg.as_expression() { let mut variables = vec![]; @@ -3488,7 +3529,8 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { )); continue; }; - match variable_values(&prop.value) { + match variable_values(&prop.value, &|callee| self.bindings.stylex_function(callee)) + { Some(values) => variables.push((variable.clone(), values)), None => self.errors.push(( prop.value.span().start, @@ -3547,7 +3589,9 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { } else { "stylex.viewTransitionClass" }; - let declarations = extract_stylex_declarations(api, arg, &mut self.errors); + let declarations = extract_stylex_declarations(api, arg, &mut self.errors, &|callee| { + self.bindings.stylex_function(callee) + }); if !declarations.is_empty() { let css = if is_position_try { css_variable_block(&format!("@position-try {name}"), &declarations) @@ -3605,10 +3649,14 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { if let Expression::CallExpression(call) = it && let Some(class_attribute) = self.stylex_class_attribute(&call.callee) { - let (class_exprs, style_props) = match self.compose_stylex_props(&call.arguments) { - Some(class_name) => (vec![class_name], Vec::new()), - None => self.resolve_stylex_props_args(&call.arguments), - }; + let (class_exprs, style_props, mut evaluations) = + if let Some((class_name, evaluations)) = self.compose_stylex_props(&call.arguments) + { + (vec![class_name], Vec::new(), evaluations) + } else { + let (classes, styles) = self.resolve_stylex_props_args(&call.arguments); + (classes, styles, Vec::new()) + }; // Build className expression using existing merge utility let class_name_expr = merge_expression_for_class_name(&self.ast, class_exprs) @@ -3656,12 +3704,20 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { } *it = Expression::new_object_expression(SPAN, props, &self.ast); + if !evaluations.is_empty() { + evaluations.push(it.take_in(&self.ast)); + *it = Expression::new_sequence_expression( + SPAN, + oxc_allocator::Vec::from_iter_in(evaluations, &self.ast), + &self.ast, + ); + } } // Reached only when none of the blocks above replaced the call, so a surviving // compile-time call is one whose arguments could not be read statically. if let Expression::CallExpression(call) = it - && let Some(function) = self.stylex_function(&call.callee) + && let Some(function) = self.bindings.stylex_function(&call.callee) && let Some(requirement) = function.requirement() { let arguments: Vec = call.arguments.iter().map(readable_argument).collect(); @@ -3890,7 +3946,7 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { None, &self.ast, )), - rules.clone_in(self.ast.allocator()), + rules.clone_in_with_semantic_ids(self.ast.allocator()), false, false, false, @@ -4054,9 +4110,9 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { self.styles.insert(v); } } - let mixins = statements - .iter() - .map(|index| tag.quasi.expressions[*index].clone_in(self.ast.allocator())); + let mixins = statements.iter().map(|index| { + tag.quasi.expressions[*index].clone_in_with_semantic_ids(self.ast.allocator()) + }); merge_expression_for_class_name(&self.ast, mixins.chain(class_name)) .unwrap_or_else(|| Expression::new_string_literal(SPAN, "", None, &self.ast)) // already set style order @@ -4121,36 +4177,20 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { let styles = compile_cva(&self.ast, it, self.split_filename.as_deref()); self.styles.extend(styles); } - let jsx = if let Expression::Identifier(ident) = &it.callee { - self.jsx_imports.get(ident.name.as_str()).cloned() - } else if let Some(name) = &self.jsx_object - && let Expression::StaticMemberExpression(member) = &it.callee - && let Expression::Identifier(ident) = &member.object - && name == ident.name.as_str() - { - Some(member.property.name.to_string()) - } else { - None - }; - if let Some(j) = jsx + if let Some(j) = self.bindings.jsx_function(&it.callee) && matches!(j.as_str(), "jsx" | "jsxs" | "jsxDEV") && let Some(expr) = it.arguments.first().and_then(|arg| arg.as_expression()) { - let element_kind = if let Expression::Identifier(ident) = expr { - self.imports.get(ident.name.as_str()).cloned() - } else if let Expression::StaticMemberExpression(member) = expr - && let Expression::Identifier(ident) = &member.object - && self.import_object.as_deref() == Some(ident.name.as_str()) + let element_kind = self.bindings.kind(expr); + if let Some(props) = it.arguments.get(1).and_then(Argument::as_expression) { + self.bindings.remember(props); + } + if element_kind.is_some() + && let Some(props) = it.arguments.get(1).and_then(Argument::as_expression) { - member - .property - .name - .as_str() - .parse::() - .ok() - } else { - None - }; + let reads = self.bindings.lowered_reads(props); + self.report_reads(reads); + } let css = self.take_jsx_css_prop(it, element_kind.is_some()); if element_kind.is_none() && let Some(css) = css @@ -4333,47 +4373,6 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { } fn visit_variable_declarator(&mut self, it: &mut VariableDeclarator<'a>) { - if let Some(Expression::CallExpression(call)) = &it.init - && call.arguments.len() == 1 - && let (Expression::Identifier(ident), Argument::StringLiteral(arg)) = - (&call.callee, &call.arguments[0]) - && ident.name == "require" - { - if arg.value == "react/jsx-runtime" { - if let BindingPattern::BindingIdentifier(ident) = &it.id { - self.jsx_object = Some(ident.name.to_string()); - } else if let BindingPattern::ObjectPattern(object) = &it.id { - for prop in &object.properties { - if let Some(name) = get_string_by_property_key(&prop.key) - && let Some(k) = prop - .value - .get_binding_identifier() - .map(|id| id.name.to_string()) - { - self.jsx_imports.insert(k, name); - } - } - } - } else if arg.value == self.package { - if let BindingPattern::BindingIdentifier(ident) = &it.id { - self.import_object = Some(ident.name.to_string()); - } else if let BindingPattern::ObjectPattern(object) = &it.id { - for prop in &object.properties { - if let Some(name) = get_string_by_property_key(&prop.key) - && let Ok(kind) = ExportVariableKind::try_from( - prop.value - .get_binding_identifier() - .map(|id| id.name.to_string()) - .unwrap_or_default(), - ) - { - self.imports.insert(name, kind); - } - } - } - } - } - let style_result = match &it.init { Some(Expression::CallExpression(call)) => self.util_type(&call.callee), Some(Expression::TaggedTemplateExpression(tag)) => self.util_type(&tag.tag), @@ -4491,54 +4490,56 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { self.stylex_pending_create.take(); } + let bound = it + .id + .get_binding_identifier() + .and_then(|ident| ident.symbol_id.get()); + // After walking, capture stylex.create() variable binding let pending_keys = self.stylex_pending_keys.take(); if let Some(pending) = self.stylex_pending_create.take() - && let Some(ident) = it.id.get_binding_identifier() + && let Some(symbol) = bound { - self.stylex_namespaces - .insert(ident.name.to_string(), pending); + self.stylex_namespaces.insert(symbol, pending); if let Some(keys) = pending_keys { - self.stylex_keys.insert(ident.name.to_string(), keys); + self.stylex_keys.insert(symbol, keys); } } // Capture stylex.keyframes() variable binding if let Some(name) = self.stylex_pending_keyframe_name.take() - && let Some(ident) = it.id.get_binding_identifier() + && let Some(symbol) = bound { - self.stylex_keyframe_names - .insert(ident.name.to_string(), name); + self.stylex_keyframe_names.insert(symbol, name); } // Capture stylex.defineVars() variable binding if let Some(contract) = self.stylex_pending_vars.take() - && let Some(ident) = it.id.get_binding_identifier() + && let Some(symbol) = bound { - for (key, variable) in &contract { - self.stylex_var_refs - .insert(format!("{}.{key}", ident.name), format!("var({variable})")); - } - self.stylex_var_names - .insert(ident.name.to_string(), contract); + self.stylex_var_refs.entry(symbol).or_default().extend( + contract + .iter() + .map(|(key, variable)| (key.clone(), format!("var({variable})"))), + ); + self.stylex_var_names.insert(symbol, contract); } // Capture stylex.createTheme() variable binding if let Some(class_name) = self.stylex_pending_theme_class.take() - && let Some(ident) = it.id.get_binding_identifier() + && let Some(symbol) = bound { - self.stylex_theme_classes - .insert(ident.name.to_string(), class_name); + self.stylex_theme_classes.insert(symbol, class_name); } // Capture stylex.defineConsts() variable binding if let Some(constants) = self.stylex_pending_consts.take() - && let Some(ident) = it.id.get_binding_identifier() + && let Some(symbol) = bound { - for (key, value) in constants { - self.stylex_var_refs - .insert(format!("{}.{key}", ident.name), value); - } + self.stylex_var_refs + .entry(symbol) + .or_default() + .extend(constants); } } fn visit_import_declaration(&mut self, it: &mut ImportDeclaration<'a>) { @@ -4551,8 +4552,8 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { { for specifier in specifiers { if let ImportSpecifier(import) = specifier { - self.jsx_imports - .insert(import.local.to_string(), import.imported.to_string()); + self.bindings + .jsx_import(&import.local, import.imported.to_string()); } } } else if (it.source.value == self.package @@ -4564,41 +4565,34 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { match &specifiers[i] { ImportSpecifier(import) => { let imported_str = import.imported.to_string(); - let local = import.local.to_string(); + let local = import.local.symbol_id.get(); // Emotion's `jsx`, which builds elements as React does once // their `css` props are compiled if compat && imported_str == "jsx" { - self.jsx_imports.insert(local, imported_str); + self.bindings.jsx_import(&import.local, imported_str); continue; } if compat && imported_str == "ClassNames" { - self.class_names_components - .extend(import.local.symbol_id.get()); - self.compiled_names.insert(local); + self.bindings.class_names_component(local); + self.bindings.compile(local); specifiers.remove(i); continue; } - if let Ok(kind) = imported_str.parse::() { - self.imports.insert(local.clone(), kind); - } else if let Some(kind) = UtilType::from_str_opt(&imported_str) { - self.util_imports.insert(local.clone(), Rc::new(kind)); - } else if imported_str == "styled" { - self.styled_imports.insert(local.clone()); - } else { + if !self.bindings.export(local, &imported_str) { // `Global` stays, rendering nothing, so its binding does too if imported_str == "Global" { - self.global_style_components.insert(local); + self.bindings.global_component(local); } continue; } - self.compiled_names.insert(local); + self.bindings.compile(local); specifiers.remove(i); } ImportDeclarationSpecifier::ImportDefaultSpecifier(specifier) => { - self.import_whole(&specifier.local.name); + self.bindings.namespace(specifier.local.symbol_id.get()); } ImportDeclarationSpecifier::ImportNamespaceSpecifier(specifier) => { - self.import_whole(&specifier.local.name); + self.bindings.namespace(specifier.local.symbol_id.get()); } } } @@ -4607,17 +4601,18 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { for specifier in specifiers { match specifier { ImportDeclarationSpecifier::ImportDefaultSpecifier(default_spec) => { - self.stylex_imports - .insert(default_spec.local.name.to_string()); + self.bindings + .stylex_namespace(default_spec.local.symbol_id.get()); } ImportDeclarationSpecifier::ImportNamespaceSpecifier(ns_spec) => { - self.stylex_imports.insert(ns_spec.local.name.to_string()); + self.bindings + .stylex_namespace(ns_spec.local.symbol_id.get()); } ImportSpecifier(named_spec) => { let imported = named_spec.imported.to_string(); - let local = named_spec.local.name.to_string(); if let Some(func) = StylexFunction::from_export_name(&imported) { - self.stylex_named_imports.insert(local, func); + self.bindings + .stylex_import(named_spec.local.symbol_id.get(), func); } } } @@ -4658,17 +4653,11 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { // `` is Emotion's spelling of a global stylesheet. // Lift the rules out and strip every attribute, leaving a component that // renders nothing — the same shape `createGlobalStyle` collapses to. - if let Some(name) = match &elem.opening_element.name { - JSXElementName::Identifier(id) => Some(Cow::Borrowed(id.name.as_str())), - JSXElementName::IdentifierReference(id) => Some(Cow::Borrowed(id.name.as_str())), - JSXElementName::MemberExpression(member) - if !self.global_style_components.is_empty() => - { - Some(Cow::Owned(member.to_string())) - } - _ => None, - } && self.global_style_components.contains(name.as_ref()) + if self + .bindings + .is_global_component(&elem.opening_element.name) { + let name = elem.opening_element.name.to_string(); for i in (0..elem.opening_element.attributes.len()).rev() { let Attribute(attr) = &mut elem.opening_element.attributes[i] else { continue; @@ -4707,13 +4696,12 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { } // after run to convert css literal - let kind = match &elem.opening_element.name { - // Fast path: probe with `&str` directly, no allocation - JSXElementName::Identifier(id) => self.imports.get(id.name.as_str()), - JSXElementName::IdentifierReference(id) => self.imports.get(id.name.as_str()), - name => self.imports.get(name.to_string().as_str()), - }; + let kind = self.bindings.element(&elem.opening_element.name); if let Some(kind) = kind { + let reads = self + .bindings + .lowered_attribute_reads(&elem.opening_element.attributes); + self.report_reads(reads); let element_name = elem.opening_element.name.to_string(); // A spread whose value may change when read again is read once, as // its `className` and `style` are read beside it @@ -4767,14 +4755,18 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { && let JSXAttributeValue::ExpressionContainer(expr) = value && let Some(expression) = expr.expression.as_expression() { - props = Some(expression.clone_in(self.ast.allocator())); + props = Some( + expression.clone_in_with_semantic_ids(self.ast.allocator()), + ); } } else if property_name == "styleVars" { if let Some(value) = attr.value.as_ref() && let JSXAttributeValue::ExpressionContainer(expr) = value && let Some(expression) = expr.expression.as_expression() { - style_vars = Some(expression.clone_in(self.ast.allocator())); + style_vars = Some( + expression.clone_in_with_semantic_ids(self.ast.allocator()), + ); } } else if let Some(at) = &mut attr.value { if let JSXAttributeValue::ExpressionContainer(container) = at @@ -4838,8 +4830,16 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { && (matches!( unwrap_syntax_only(&spread.argument), Expression::CallExpression(_) - ) || reads_unknown(&spread.argument, &self.unknown_bindings)); - if runtime && reads_binding(&spread.argument, &self.changed_bindings) { + ) || reads_unknown( + &spread.argument, + &self.unknown_bindings, + &|identifier| self.bindings.reads_module(identifier), + )); + if runtime + && reads_binding(&spread.argument, &self.changed_bindings, &|identifier| { + self.bindings.reads_module(identifier) + }) + { self.errors.push(( spread.span.start, element_error( @@ -4932,8 +4932,9 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { } } JSXChild::Element(element) => { - let mut value = - Expression::JSXElement(element.clone_in(self.ast.allocator())); + let mut value = Expression::JSXElement( + element.clone_in_with_semantic_ids(self.ast.allocator()), + ); read_once.push(self.read_once(&mut value)); *child = JSXChild::ExpressionContainer(JSXExpressionContainer::boxed( SPAN, @@ -4942,8 +4943,9 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { )); } JSXChild::Fragment(fragment) => { - let mut value = - Expression::JSXFragment(fragment.clone_in(self.ast.allocator())); + let mut value = Expression::JSXFragment( + fragment.clone_in_with_semantic_ids(self.ast.allocator()), + ); read_once.push(self.read_once(&mut value)); *child = JSXChild::ExpressionContainer(JSXExpressionContainer::boxed( SPAN, @@ -5072,7 +5074,7 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { if !values.is_empty() { let element = self.pending_replacement.take().unwrap_or_else(|| { Expression::JSXElement(oxc_allocator::Box::new_in( - elem.clone_in(self.ast.allocator()), + elem.clone_in_with_semantic_ids(self.ast.allocator()), &self.ast, )) }); @@ -5105,28 +5107,34 @@ mod tests { } #[test] + #[serial] fn test_stylex_named_and_unrelated_imports() { + reset_class_map(); + reset_file_map(); let allocator = Allocator::default(); let source_type = SourceType::from_path("test.ts").unwrap(); - let mut program = Parser::new(&allocator, "import { create, props as sxProps, keyframes, unknown } from '@stylexjs/stylex'; import value from 'other';", source_type).parse().program; + let mut program = Parser::new(&allocator, "import { create, props as sxProps, keyframes, unknown } from '@stylexjs/stylex'; import value from 'other'; const styles = create({ base: { color: 'red' } }); export const a = sxProps(styles.base); export const b = unknown(styles.base); export const animation = keyframes({ from: { opacity: 0 } });", source_type).parse().program; let mut visitor = DevupVisitor::new(&allocator, "test.ts", "@devup-ui/react", Vec::new(), None); visitor.visit_program(&mut program); - assert_eq!( - visitor.stylex_named_imports.get("create"), - Some(&StylexFunction::Create) + let code = oxc_codegen::Codegen::new().build(&program).code; + assert!( + code.contains("const styles = { \"base\": \"a\" };"), + "{code}" ); - assert_eq!( - visitor.stylex_named_imports.get("sxProps"), - Some(&StylexFunction::Props) + assert!( + code.contains("export const a = { className: styles.base };") + || code.contains("export const a = { className: \"a\" };"), + "{code}" ); - assert_eq!( - visitor.stylex_named_imports.get("keyframes"), - Some(&StylexFunction::Keyframes) + assert!( + code.contains("export const b = unknown(styles.base);"), + "{code}" ); - assert!(!visitor.stylex_named_imports.contains_key("unknown")); + assert!(code.contains("export const animation = \""), "{code}"); + assert!(code.contains("import value from \"other\";"), "{code}"); } #[test] diff --git a/libs/extractor/src/visit/stylex_capture.rs b/libs/extractor/src/visit/stylex_capture.rs new file mode 100644 index 000000000..0e11c854d --- /dev/null +++ b/libs/extractor/src/visit/stylex_capture.rs @@ -0,0 +1,129 @@ +use super::{ + AstBuilder, BindingPattern, Expression, FxHashSet, GetAllocator, GetSpan, Program, SPAN, + Statement, UnaryOperator, VariableDeclarator, +}; +use oxc_ast::ast::{AssignmentOperator, AssignmentTarget, VariableDeclarationKind}; +use oxc_ast_visit::{Visit, walk}; +use oxc_syntax::scope::{ScopeFlags, ScopeId}; +use std::cell::Cell; + +#[derive(Default)] +pub(super) struct Captures { + names: FxHashSet, + next: usize, + scopes: Vec>, + eval_scopes: FxHashSet, + pub frames: Vec<(bool, Vec)>, + pub context: Option<&'static str>, +} + +impl Captures { + pub fn reserve(&mut self, program: &Program<'_>) { + self.visit_program(program); + } + + pub fn push_frame(&mut self) { + let visible = self + .scopes + .last() + .is_some_and(|scope| scope.is_some_and(|id| self.eval_scopes.contains(&id))); + self.frames.push((visible, Vec::new())); + } + + pub fn capture<'a>( + &mut self, + ast: &AstBuilder<'a>, + test: Expression<'a>, + ) -> Result<(Expression<'a>, Expression<'a>), (u32, String)> { + if self.frames.is_empty() || self.context.is_some() { + return Err(( + test.span().start, + format!( + "StyleX condition capture is not supported in {}; move the value into a variable in a statement body", + self.context.unwrap_or("this expression-only context") + ), + )); + } + if self.frames.last().is_some_and(|(visible, _)| *visible) { + return Err(( + test.span().start, + "StyleX condition capture is not supported in a statement body whose bindings are visible to direct eval; move the value into a variable in a scope not visible to direct eval".to_string(), + )); + } + let name = loop { + let name = format!("_s{}", self.next); + self.next += 1; + if self.names.insert(name.clone()) { + break name; + } + }; + if let Some((_, frame)) = self.frames.last_mut() { + frame.push(name.clone()); + } + let name = ast.allocator().alloc_str(&name); + let boolean = Expression::new_unary_expression( + SPAN, + UnaryOperator::LogicalNot, + Expression::new_unary_expression(SPAN, UnaryOperator::LogicalNot, test, ast), + ast, + ); + let assignment = Expression::new_assignment_expression( + SPAN, + AssignmentOperator::Assign, + AssignmentTarget::new_assignment_target_identifier(SPAN, name, ast), + boolean, + ast, + ); + Ok((Expression::new_identifier(SPAN, name, ast), assignment)) + } +} + +impl<'a> Visit<'a> for Captures { + fn enter_scope(&mut self, _flags: ScopeFlags, scope_id: &Cell>) { + self.scopes.push(scope_id.get()); + } + fn leave_scope(&mut self) { + self.scopes.pop(); + } + fn visit_binding_identifier(&mut self, it: &oxc_ast::ast::BindingIdentifier<'a>) { + self.names.insert(it.name.to_string()); + } + fn visit_identifier_reference(&mut self, it: &oxc_ast::ast::IdentifierReference<'a>) { + self.names.insert(it.name.to_string()); + } + fn visit_formal_parameters(&mut self, it: &oxc_ast::ast::FormalParameters<'a>) { + // Parameter-created eval closures cannot see the function body's lexical declarations. + let scope = self.scopes.pop(); + walk::walk_formal_parameters(self, it); + self.scopes.extend(scope); + } + fn visit_call_expression(&mut self, it: &oxc_ast::ast::CallExpression<'a>) { + if !it.optional + && matches!(it.callee.get_inner_expression(), Expression::Identifier(id) if id.name == "eval") + { + self.eval_scopes + .extend(self.scopes.iter().flatten().copied()); + } + walk::walk_call_expression(self, it); + } +} + +pub(super) fn declaration<'a>(ast: &AstBuilder<'a>, names: Vec) -> Statement<'a> { + let declarations = names.into_iter().map(|name| { + VariableDeclarator::new( + SPAN, + BindingPattern::new_binding_identifier(SPAN, ast.allocator().alloc_str(&name), ast), + None, + None, + false, + ast, + ) + }); + Statement::new_variable_declaration( + SPAN, + VariableDeclarationKind::Let, + oxc_allocator::Vec::from_iter_in(declarations, ast), + false, + ast, + ) +}