From a558a34ba3a9603b4fb5c868c94e24596b0d6137 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sat, 3 Oct 2026 17:39:34 +0900 Subject: [PATCH 1/5] fix(extractor): compile Devup UI re-exported by project modules and read through namespaces Refs #695 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- ...angepack_log_barrel_namespace_imports.json | 7 + bindings/devup-ui-wasm/src/lib.rs | 50 +- libs/extractor/src/barrel.rs | 940 ++++++++++++++++++ libs/extractor/src/barrel/tests.rs | 859 ++++++++++++++++ libs/extractor/src/lib.rs | 59 +- libs/extractor/src/module_loader.rs | 2 +- ...tor__tests__inline_imported_constants.snap | 2 +- .../bun-plugin/src/__tests__/barrel.test.ts | 103 ++ .../next-plugin/src/__tests__/loader.test.ts | 39 + .../src/__tests__/plugin.test.ts | 29 + .../vite-plugin/src/__tests__/plugin.test.ts | 20 + .../src/__tests__/loader.test.ts | 34 + 12 files changed, 2136 insertions(+), 8 deletions(-) create mode 100644 .changepacks/changepack_log_barrel_namespace_imports.json create mode 100644 libs/extractor/src/barrel.rs create mode 100644 libs/extractor/src/barrel/tests.rs create mode 100644 packages/bun-plugin/src/__tests__/barrel.test.ts diff --git a/.changepacks/changepack_log_barrel_namespace_imports.json b/.changepacks/changepack_log_barrel_namespace_imports.json new file mode 100644 index 000000000..713e9b0ce --- /dev/null +++ b/.changepacks/changepack_log_barrel_namespace_imports.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "A project module re-exporting Devup UI (export { Box, css } from '@devup-ui/react', export * from '@devup-ui/react', export { styled as s }, export * as Devup, a default export of an import, chains of barrels and the compat entry) now works: a file importing Box or css from it compiles exactly as if it imported from the package, following the barrel through the resolver (relative paths, tsconfig paths, package exports), and hasDevupUI sees such a file. Members read from a namespace or default import of the package (Devup['css'], const { css } = Devup, Devup.css as a value) compile where the build can follow them, `Devup.x` for a name the package compiles being read like the named import. New build errors, located at the use: what a barrel re-exports but the build cannot read (an unresolvable `export *` or module, a namespace of modules re-exporting the package) is an error where its name is used instead of a runtime crash, and a namespace member read by a key the build cannot know (Devup[key]), written, or destructured in a form it cannot follow (a rest, a computed key, a default value, a nested pattern, an exported declaration, or members mixed with ones that stay at runtime) is an error that names the members to read. A namespace passed on whole (Object.keys(Devup), spyOn(Devup, 'css'), export default Devup) is left as it is", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/bindings/devup-ui-wasm/src/lib.rs b/bindings/devup-ui-wasm/src/lib.rs index 0a2347ac2..f8248a395 100644 --- a/bindings/devup-ui-wasm/src/lib.rs +++ b/bindings/devup-ui-wasm/src/lib.rs @@ -5,7 +5,7 @@ use css::file_map::{ use extractor::extract_style::extract_style_value::ExtractStyleValue; use extractor::{ ExtractOption, ImportAlias, ModuleResolver, ResolvedModule, extract, extract_with_modules, - extract_without_source_map, has_devup_ui, + extract_without_source_map, has_devup_ui, has_devup_ui_through, }; use rustc_hash::FxHashSet; use sheet::StyleSheet; @@ -490,6 +490,29 @@ fn call_module_resolver( }) } +/// The resolver set by `setModuleResolver`, if any +#[cfg(not(tarpaulin_include))] +fn resolver_from_js() -> Option> { + let resolver = MODULE_RESOLVER.with_borrow(Clone::clone)?; + Some(Box::new(move |specifier: &str, importer: &str| { + call_module_resolver(&resolver, specifier, importer) + })) +} + +/// [`has_devup_ui`], also true for a file reaching the package through a +/// project module when there is a resolver +fn has_devup_ui_in( + filename: &str, + code: &str, + package: &str, + resolver: Option<&ModuleResolver>, +) -> bool { + match resolver { + Some(resolver) => has_devup_ui_through(filename, code, package, resolver), + None => has_devup_ui(filename, code, package), + } +} + /// Extract with the resolver set by `setModuleResolver`, if any #[cfg(not(tarpaulin_include))] #[allow(clippy::too_many_arguments)] @@ -671,7 +694,7 @@ pub fn get_theme_interface( #[cfg(not(tarpaulin_include))] #[must_use] pub fn has_devup_ui_wasm(filename: &str, code: &str, package: &str) -> bool { - has_devup_ui(filename, code, package) + has_devup_ui_in(filename, code, package, resolver_from_js().as_deref()) } #[cfg(test)] @@ -1607,6 +1630,29 @@ mod tests { )); } + #[test] + fn has_devup_ui_follows_a_barrel_only_with_a_resolver() { + let resolver = |specifier: &str, _: &str| { + (specifier == "./ui").then(|| ResolvedModule { + path: "/src/ui.ts".to_string(), + code: "export { Box } from '@devup-ui/react'".to_string(), + }) + }; + let code = "import { Box } from './ui';"; + assert!(has_devup_ui_in( + "/src/app.tsx", + code, + "@devup-ui/react", + Some(&resolver) + )); + assert!(!has_devup_ui_in( + "/src/app.tsx", + code, + "@devup-ui/react", + None + )); + } + #[test] #[serial] fn test_output_single_css_mode() { diff --git a/libs/extractor/src/barrel.rs b/libs/extractor/src/barrel.rs new file mode 100644 index 000000000..0f3e4ecf9 --- /dev/null +++ b/libs/extractor/src/barrel.rs @@ -0,0 +1,940 @@ +//! Devup UI reached through something other than a named import of the +//! package: a project module re-exporting it (a barrel), or a namespace import +//! read for its members. +//! +//! Both are rewritten to the named imports of the package itself, so a file +//! compiles as it does importing them from the package, and what the build +//! cannot follow exactly is reported where it is used. + +use std::collections::BTreeSet; +use std::rc::Rc; + +use oxc_allocator::Allocator; +use oxc_ast::AstKind; +use oxc_ast::ast::{ + BindingIdentifier, BindingPattern, ExportDefaultDeclarationKind, Expression, + ImportDeclarationSpecifier, Statement, +}; +use oxc_parser::{Parser, ParserReturn}; +use oxc_semantic::{Semantic, SemanticBuilder}; +use oxc_span::{GetSpan, SourceType}; +use oxc_syntax::node::NodeId; +use rustc_hash::{FxHashMap, FxHashSet}; + +use crate::component::ExportVariableKind; +use crate::import_alias_visit::Edit; +use crate::module_loader::declared_names; +use crate::util_type::UtilType; +use crate::utils::is_vanilla_extract_file; +use crate::{ModuleResolver, ResolvedModule}; + +/// How a module gives a name it exports +#[derive(Clone)] +enum Link { + /// Declared in the module + Own, + /// Taken from `source`: `imported` is the name read from it, `None` its + /// namespace + From { + source: String, + imported: Option, + }, +} + +/// What a module exports, read without running it +struct Exports { + path: String, + named: FxHashMap, + /// Modules re-exported whole with `export * from` + stars: Vec, + mentions_package: bool, + /// Nothing in it can lead to the package + trivial: bool, +} + +/// Where reading a name from a module leads +enum Origin { + /// `imported` of `source`, a module of the package; `None` is its namespace + Devup { + source: String, + imported: Option, + }, + /// Something else: a name the module declares, or takes from elsewhere + Other, + /// A re-export of the package the build cannot follow, and why + Unfollowable(String), + /// The module does not export it + Absent, +} + +/// Whether `code` may re-export names from other modules +fn has_reexport_syntax(code: &str) -> bool { + code.match_indices("export").any(|(index, keyword)| { + code[index + keyword.len()..] + .trim_start() + .starts_with(['{', '*']) + }) +} + +fn analyze(module: &ResolvedModule, package: &str) -> Exports { + let mentions_package = module.code.contains(package); + let mut exports = Exports { + path: module.path.clone(), + named: FxHashMap::default(), + stars: Vec::new(), + mentions_package, + trivial: !mentions_package && !has_reexport_syntax(&module.code), + }; + if exports.trivial { + return exports; + } + let allocator = Allocator::default(); + let source_type = SourceType::from_path(&module.path).unwrap_or_default(); + let program = Parser::new(&allocator, &module.code, source_type) + .parse() + .program; + let mut imports: FxHashMap<&str, Link> = 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(), + Link::From { + source: import.source.value.to_string(), + imported, + }, + ); + } + } + Statement::ExportFromDeclaration(export) if !export.export_kind.is_type() => { + for specifier in export + .specifiers + .iter() + .filter(|s| !s.export_kind.is_type()) + { + exports.named.insert( + specifier.exported.name().to_string(), + Link::From { + source: export.source.value.to_string(), + imported: Some(specifier.local.name().to_string()), + }, + ); + } + } + Statement::ExportAllDeclaration(export) if !export.export_kind.is_type() => { + let source = export.source.value.to_string(); + match &export.exported { + Some(name) => { + exports.named.insert( + name.name().to_string(), + Link::From { + source, + imported: None, + }, + ); + } + None => exports.stars.push(source), + } + } + Statement::ExportNamedDeclaration(export) if !export.export_kind.is_type() => { + for specifier in export + .specifiers + .iter() + .filter(|s| !s.export_kind.is_type()) + { + let link = imports + .get(specifier.local.name().as_str()) + .cloned() + .unwrap_or(Link::Own); + exports + .named + .insert(specifier.exported.name().to_string(), link); + } + } + Statement::ExportDeclaration(export) => { + for name in declared_names(&export.declaration) { + exports.named.insert(name, Link::Own); + } + } + Statement::ExportDefaultDeclaration(export) => { + let link = match &export.declaration { + ExportDefaultDeclarationKind::Identifier(name) => { + imports.get(name.name.as_str()).cloned() + } + _ => None, + }; + exports + .named + .insert("default".to_string(), link.unwrap_or(Link::Own)); + } + _ => {} + } + } + exports +} + +/// Follows re-exports through the modules a resolver reads +struct Walker<'r, 'p> { + resolver: &'r ModuleResolver, + package: &'p str, + modules: FxHashMap>, + dependencies: BTreeSet, +} + +impl Walker<'_, '_> { + fn is_package(&self, source: &str) -> bool { + source == self.package + || source + .strip_prefix(self.package) + .is_some_and(|rest| rest.starts_with('/')) + } + + fn module(&mut self, specifier: &str, importer: &str) -> Option> { + let resolved = (self.resolver)(specifier, importer)?; + self.dependencies.insert(resolved.path.clone()); + Some( + self.modules + .entry(resolved.path.clone()) + .or_insert_with(|| Rc::new(analyze(&resolved, self.package))) + .clone(), + ) + } + + /// Where `imported` of `source`, imported by `importer`, leads + fn import_origin(&mut self, source: &str, importer: &str, imported: &str) -> Origin { + match self.module(source, importer) { + None => Origin::Other, + Some(module) => match self.origin(&module, imported, &mut Vec::new()) { + Origin::Absent => Origin::Other, + origin => origin, + }, + } + } + + fn origin( + &mut self, + module: &Exports, + name: &str, + reading: &mut Vec<(String, String)>, + ) -> Origin { + let key = (module.path.clone(), name.to_string()); + if module.trivial || reading.contains(&key) { + return Origin::Absent; + } + reading.push(key); + let origin = match module.named.get(name) { + Some(Link::Own) => Origin::Other, + Some(Link::From { source, imported }) => { + self.follow(module, source, imported.as_deref(), reading) + } + None if name == "default" => Origin::Absent, + None => self.through_stars(module, name, reading), + }; + reading.pop(); + origin + } + + fn follow( + &mut self, + module: &Exports, + source: &str, + imported: Option<&str>, + reading: &mut Vec<(String, String)>, + ) -> Origin { + if self.is_package(source) { + return Origin::Devup { + source: source.to_string(), + imported: imported.map(str::to_string), + }; + } + let Some(next) = self.module(source, &module.path) else { + return if module.mentions_package { + Origin::Unfollowable(format!("`{source}` cannot be read")) + } else { + Origin::Other + }; + }; + match imported { + Some(name) => match self.origin(&next, name, reading) { + Origin::Absent => Origin::Other, + origin => origin, + }, + None if self.touches_package(&next, &mut FxHashSet::default()) => { + Origin::Unfollowable(format!( + "`{source}` is a namespace of modules re-exporting `{}`", + self.package + )) + } + None => Origin::Other, + } + } + + fn through_stars( + &mut self, + module: &Exports, + name: &str, + reading: &mut Vec<(String, String)>, + ) -> Origin { + let (mut strong, mut weak, mut unfollowable) = (None, None, None); + for star in &module.stars { + if self.is_package(star) { + weak = Some(Origin::Devup { + source: star.clone(), + imported: Some(name.to_string()), + }); + } else if let Some(next) = self.module(star, &module.path) { + match self.origin(&next, name, reading) { + Origin::Absent => {} + found => strong = strong.or(Some(found)), + } + } else if module.mentions_package { + unfollowable = Some(Origin::Unfollowable(format!("`{star}` cannot be read"))); + } + } + strong.or(weak).or(unfollowable).unwrap_or(Origin::Absent) + } + + /// Whether `module` re-exports something from the package, however far + fn touches_package(&mut self, module: &Exports, seen: &mut FxHashSet) -> bool { + if !seen.insert(module.path.clone()) { + return false; + } + let sources = module + .named + .values() + .filter_map(|link| match link { + Link::From { source, .. } => Some(source), + Link::Own => None, + }) + .chain(&module.stars); + sources.into_iter().any(|source| { + self.is_package(source) + || self + .module(source, &module.path) + .is_some_and(|next| self.touches_package(&next, seen)) + }) + } +} + +/// What a file's imports of Devup UI became +pub(crate) enum Barreled { + Unchanged, + Rewritten(Rewritten), + /// What the build cannot follow, as `(offset, message)` in the source + Failed(Vec<(u32, String)>), +} + +pub(crate) struct Rewritten { + pub code: String, + /// The replacements made in order, so a position in `code` maps back to + /// the source + pub edits: Vec, + /// The modules read to follow re-exports + pub dependencies: BTreeSet, +} + +/// A namespace or default import whose members are read as values +struct Namespace<'p, 'a> { + binding: &'p BindingIdentifier<'a>, + /// Where its declaration ends, after which the named imports it needs go + after: usize, + /// The barrel module it is a namespace of; `None` for the package + module: Option>, + source: String, +} + +/// The names the rewritten file gives what it takes from namespaces +#[derive(Default)] +struct Generated { + names: FxHashMap<(String, Option), String>, + taken: FxHashSet, +} + +impl Generated { + /// The local name of `imported` of `source`, with the statement importing + /// it when the name is new + fn name( + &mut self, + code: &str, + namespace: &str, + source: &str, + imported: Option<&str>, + ) -> (String, Option) { + let key = (source.to_string(), imported.map(str::to_string)); + if let Some(name) = self.names.get(&key) { + return (name.clone(), None); + } + let mut name = format!("{namespace}${}", imported.unwrap_or("namespace")); + while code.contains(&name) || self.taken.contains(&name) { + name.push('$'); + } + let quoted = serde_json::to_string(source).unwrap_or_default(); + let statement = match imported { + Some(imported) => format!("import {{ {imported} as {name} }} from {quoted};"), + None => format!("import * as {name} from {quoted};"), + }; + self.taken.insert(name.clone()); + self.names.insert(key, name.clone()); + (name, Some(statement)) + } +} + +fn unreadable(local: &str, code: &str) -> String { + format!( + "`{local}` cannot use `{code}` at build time: read its members by name, as `{local}.css`, `{local}['css']` or `const {{ css }} = {local}`, or import them by name" + ) +} + +fn unfollowable(code: &str, specifier: &str, reason: &str) -> String { + format!( + "`{code}` cannot use `{specifier}` at build time: the build cannot follow its re-export of Devup UI ({reason}); import it from the package instead" + ) +} + +/// The member a computed key names, when it is a literal +fn literal_key(key: &Expression<'_>) -> Option { + match key { + Expression::StringLiteral(text) => Some(text.value.to_string()), + Expression::TemplateLiteral(template) => match template.quasis.as_slice() { + [only] => only.value.cooked.as_ref().map(ToString::to_string), + _ => None, + }, + _ => None, + } +} + +/// The import of `walker`'s modules that reach the package and are namespaces +fn namespace_of(walker: &mut Walker<'_, '_>, source: &str, importer: &str) -> Option> { + let module = walker.module(source, importer)?; + walker + .touches_package(&module, &mut FxHashSet::default()) + .then_some(module) +} + +/// Whether the build compiles the package export `name` away; the others are +/// values a namespace holds at runtime as they are +fn compiled_export(name: &str) -> bool { + name.parse::().is_ok() + || UtilType::from_str_opt(name).is_some() + || name == "styled" +} + +/// The specifiers of an import a statement keeps +#[derive(Default)] +struct Kept<'c> { + default: Option<&'c str>, + namespace: Option<&'c str>, + named: Vec<&'c str>, +} + +/// An import that no rewriting can follow exactly: the binding, where it +/// comes from, and why +struct Opaque<'p, 'a> { + binding: &'p BindingIdentifier<'a>, + source: String, + reason: String, +} + +#[derive(Default)] +struct Found<'p, 'a> { + namespaces: Vec>, + opaque: Vec>, +} + +/// The statements importing what an import takes from the package through a +/// project module, with the rest of it, or `None` when none goes there +fn redirect_import<'p, 'a>( + walker: &mut Walker<'_, '_>, + code: &str, + filename: &str, + import: &'p oxc_ast::ast::ImportDeclaration<'a>, + found: &mut Found<'p, 'a>, +) -> Option { + let source = import.source.value.as_str(); + let after = import.span.end as usize; + let mut redirected: Vec<(Option, &str, String)> = Vec::new(); + let mut kept = Kept::default(); + for specifier in import.specifiers.iter().flatten() { + let (imported, local, is_default) = match specifier { + ImportDeclarationSpecifier::ImportSpecifier(named) if !named.import_kind.is_type() => { + (named.imported.name().to_string(), &named.local, false) + } + ImportDeclarationSpecifier::ImportSpecifier(named) => { + kept.named + .push(&code[named.span.start as usize..named.span.end as usize]); + continue; + } + ImportDeclarationSpecifier::ImportDefaultSpecifier(default) => { + ("default".to_string(), &default.local, true) + } + ImportDeclarationSpecifier::ImportNamespaceSpecifier(namespace) => { + kept.namespace = Some(namespace.local.name.as_str()); + if let Some(module) = namespace_of(walker, source, filename) { + found.namespaces.push(Namespace { + binding: &namespace.local, + after, + module: Some(module), + source: source.to_string(), + }); + } + continue; + } + }; + match walker.import_origin(source, filename, &imported) { + Origin::Devup { + source: from, + imported, + } => redirected.push((imported, local.name.as_str(), from)), + origin => { + if let Origin::Unfollowable(reason) = origin { + found.opaque.push(Opaque { + binding: local, + source: source.to_string(), + reason, + }); + } + let span = specifier.span(); + if is_default { + kept.default = Some(local.name.as_str()); + } else { + kept.named + .push(&code[span.start as usize..span.end as usize]); + } + } + } + } + if redirected.is_empty() { + return None; + } + let mut statements: Vec = Vec::new(); + let mut named: Vec<(String, Vec)> = Vec::new(); + for (imported, local, from) in redirected { + let quoted = serde_json::to_string(&from).unwrap_or_default(); + match imported { + None => statements.push(format!("import * as {local} from {quoted};")), + Some(imported) => { + let part = if imported == local { + imported + } else { + format!("{imported} as {local}") + }; + match named.iter_mut().find(|(source, _)| *source == quoted) { + Some((_, parts)) => parts.push(part), + None => named.push((quoted, vec![part])), + } + } + } + } + statements.extend( + named + .into_iter() + .map(|(quoted, parts)| format!("import {{ {} }} from {quoted};", parts.join(", "))), + ); + let mut parts: Vec = Vec::new(); + parts.extend(kept.default.map(str::to_string)); + parts.extend(kept.namespace.map(|name| format!("* as {name}"))); + if !kept.named.is_empty() { + parts.push(format!("{{ {} }}", kept.named.join(", "))); + } + if !parts.is_empty() { + let quoted = serde_json::to_string(source).unwrap_or_default(); + statements.push(format!("import {} from {quoted};", parts.join(", "))); + } + Some(statements.join("\n")) +} + +/// Rewrites the members read from namespaces, collecting what it cannot +struct Rewriter<'s, 'w, 'r, 'p> { + code: &'s str, + semantic: &'s Semantic<'s>, + walker: &'w mut Walker<'r, 'p>, + generated: Generated, + edits: Vec<(usize, usize, String)>, + errors: Vec<(u32, String)>, +} + +impl Rewriter<'_, '_, '_, '_> { + /// Where reading `name` from `namespace` leads + fn member_origin(&mut self, namespace: &Namespace<'_, '_>, name: &str) -> Origin { + match &namespace.module { + None if !compiled_export(name) => Origin::Other, + None => Origin::Devup { + source: namespace.source.clone(), + imported: Some(name.to_string()), + }, + Some(module) => match self.walker.origin(module, name, &mut Vec::new()) { + Origin::Absent => Origin::Other, + origin => origin, + }, + } + } + + /// The value references of `binding`, as `(node, is written)` + fn references(&self, binding: &BindingIdentifier<'_>) -> Vec<(NodeId, bool)> { + let scoping = self.semantic.scoping(); + binding.symbol_id.get().map_or_else(Vec::new, |symbol| { + scoping + .get_resolved_reference_ids(symbol) + .iter() + .map(|id| scoping.get_reference(*id)) + .filter(|reference| reference.is_value()) + .map(|reference| { + let flags = reference.flags(); + ( + reference.node_id(), + flags.is_write() || flags.is_member_write_target(), + ) + }) + .collect() + }) + } + + fn whole_error(&mut self, local: &str, start: u32, end: u32) { + let text = &self.code[start as usize..end as usize]; + let code = match text.char_indices().nth(60) { + Some((index, _)) => format!("{}…", &text[..index]), + None => text.to_string(), + }; + self.errors.push((start, unreadable(local, &code))); + } + + fn read_namespace(&mut self, namespace: &Namespace<'_, '_>) { + let mut appended: Vec = Vec::new(); + for (node, written) in self.references(namespace.binding) { + self.read_reference(namespace, &mut appended, node, written); + } + if !appended.is_empty() { + self.edits.push(( + namespace.after, + namespace.after, + format!("\n{}", appended.join("\n")), + )); + } + } + + fn read_reference( + &mut self, + namespace: &Namespace<'_, '_>, + appended: &mut Vec, + node: NodeId, + written: bool, + ) { + let nodes = self.semantic.nodes(); + let span = nodes.kind(node).span(); + let parent = nodes.parent_kind(node); + let local = namespace.binding.name.as_str(); + let member = match parent { + AstKind::StaticMemberExpression(member) => { + Some((member.span, member.property.name.to_string())) + } + AstKind::ComputedMemberExpression(member) if member.object.span() == span => { + literal_key(&member.expression).map(|name| (member.span, name)) + } + AstKind::JSXMemberExpression(member) => { + Some((member.span, member.property.name.to_string())) + } + _ => None, + }; + match (member, parent) { + _ if written => { + let span = parent.span(); + self.whole_error(local, span.start, span.end); + } + (Some((member_span, name)), _) => { + let origin = self.member_origin(namespace, &name); + self.replace(namespace, appended, member_span, &name, origin); + } + (None, AstKind::VariableDeclarator(declarator)) + if declarator + .init + .as_ref() + .is_some_and(|init| init.span() == span) => + { + self.destructure(namespace, appended, nodes.parent_id(node), declarator); + } + (None, AstKind::ComputedMemberExpression(member)) if member.object.span() == span => { + self.whole_error(local, member.span.start, member.span.end); + } + (None, _) => {} + } + } + + /// Replaces `span`, which reads `name` of the namespace, by what the + /// package gives it + fn replace( + &mut self, + namespace: &Namespace<'_, '_>, + appended: &mut Vec, + span: oxc_span::Span, + name: &str, + origin: Origin, + ) { + match origin { + Origin::Devup { source, imported } => { + let (local, statement) = self.generated.name( + self.code, + namespace.binding.name.as_str(), + &source, + imported.as_deref(), + ); + appended.extend(statement); + self.edits + .push((span.start as usize, span.end as usize, local)); + } + Origin::Unfollowable(reason) => { + let code = format!("{}.{name}", namespace.binding.name); + self.errors + .push((span.start, unfollowable(&code, &namespace.source, &reason))); + } + Origin::Other | Origin::Absent => {} + } + } + + /// `const { css, Box: B } = Namespace`: the declaration goes, and what it + /// bound is read from the package where it is used + fn destructure( + &mut self, + namespace: &Namespace<'_, '_>, + appended: &mut Vec, + declarator_id: NodeId, + declarator: &oxc_ast::ast::VariableDeclarator<'_>, + ) { + let local = namespace.binding.name.as_str(); + let whole = |rewriter: &mut Self| { + rewriter.whole_error(local, declarator.span.start, declarator.span.end); + }; + let BindingPattern::ObjectPattern(pattern) = &declarator.id else { + return whole(self); + }; + if pattern.rest.is_some() { + return whole(self); + } + let mut members = Vec::new(); + for property in &pattern.properties { + match (&property.value, property.key.static_name()) { + (BindingPattern::BindingIdentifier(binding), Some(name)) if !property.computed => { + members.push((name.to_string(), binding)); + } + _ => return whole(self), + } + } + let mut devup = Vec::new(); + let mut unfollowed = None; + for (name, binding) in &members { + match self.member_origin(namespace, name) { + Origin::Devup { source, imported } => devup.push((name, binding, source, imported)), + Origin::Unfollowable(reason) => unfollowed = unfollowed.or(Some(reason)), + Origin::Other | Origin::Absent => {} + } + } + if let Some(reason) = unfollowed { + let code = &self.code[declarator.span.start as usize..declarator.span.end as usize]; + self.errors.push(( + declarator.span.start, + unfollowable(code, &namespace.source, &reason), + )); + return; + } + if devup.is_empty() { + return; + } + let nodes = self.semantic.nodes(); + let declaration_id = nodes.parent_id(declarator_id); + let declaration = match nodes.kind(declaration_id) { + AstKind::VariableDeclaration(declaration) + if devup.len() == members.len() + && !matches!( + nodes.parent_kind(declaration_id), + AstKind::ExportDeclaration(_) + ) => + { + declaration + } + _ => return whole(self), + }; + let declarators = &declaration.declarations; + let index = declarators + .iter() + .position(|other| other.span == declarator.span) + .unwrap_or_default(); + let (start, end) = if declarators.len() == 1 { + (declaration.span.start, declaration.span.end) + } else if index + 1 < declarators.len() { + (declarator.span.start, declarators[index + 1].span.start) + } else { + (declarators[index - 1].span.end, declarator.span.end) + }; + self.edits + .push((start as usize, end as usize, String::new())); + for (name, binding, source, imported) in devup { + let (generated, statement) = + self.generated + .name(self.code, local, &source, imported.as_deref()); + appended.extend(statement); + for (node, written) in self.references(binding) { + self.replace_binding(local, name, &generated, node, written); + } + } + } + + /// A read of what `const { name } = Namespace` bound, as `generated` + fn replace_binding( + &mut self, + namespace: &str, + name: &str, + generated: &str, + node: NodeId, + written: bool, + ) { + let nodes = self.semantic.nodes(); + let span = nodes.kind(node).span(); + let text = match nodes.parent_kind(node) { + _ if written => { + return self.whole_error(namespace, span.start, span.end); + } + AstKind::ObjectProperty(property) if property.shorthand => { + format!( + "{}: {generated}", + &self.code[span.start as usize..span.end as usize] + ) + } + AstKind::ExportSpecifier(specifier) if specifier.span == span => { + format!("{generated} as {name}") + } + _ => generated.to_string(), + }; + self.edits + .push((span.start as usize, span.end as usize, text)); + } +} + +/// Rewrites the imports of `code` that reach Devup UI through a project module +/// or a namespace +pub(crate) fn rewrite( + code: &str, + filename: &str, + package: &str, + resolver: Option<&ModuleResolver>, +) -> Barreled { + if is_vanilla_extract_file(filename) || (resolver.is_none() && !code.contains(package)) { + return Barreled::Unchanged; + } + let allocator = Allocator::default(); + let source_type = SourceType::from_path(filename).unwrap_or_default(); + let ParserReturn { + program, + fatal_error, + .. + } = Parser::new(&allocator, code, source_type).parse(); + if fatal_error { + return Barreled::Unchanged; + } + let no_modules = |_: &str, _: &str| -> Option { None }; + let mut walker = Walker { + resolver: resolver.unwrap_or(&no_modules), + package, + modules: FxHashMap::default(), + dependencies: BTreeSet::new(), + }; + let mut edits: Vec<(usize, usize, String)> = Vec::new(); + let mut found = Found::default(); + for statement in &program.body { + let Statement::ImportDeclaration(import) = statement else { + continue; + }; + if import.import_kind.is_type() || import.with_clause.is_some() { + continue; + } + if walker.is_package(import.source.value.as_str()) { + for specifier in import.specifiers.iter().flatten() { + let binding = match specifier { + ImportDeclarationSpecifier::ImportDefaultSpecifier(default) => &default.local, + ImportDeclarationSpecifier::ImportNamespaceSpecifier(namespace) => { + &namespace.local + } + ImportDeclarationSpecifier::ImportSpecifier(_) => continue, + }; + found.namespaces.push(Namespace { + binding, + after: import.span.end as usize, + module: None, + source: import.source.value.to_string(), + }); + } + } else if let Some(statements) = + redirect_import(&mut walker, code, filename, import, &mut found) + { + edits.push(( + import.span.start as usize, + import.span.end as usize, + statements, + )); + } + } + let (mut edits, errors) = if found.namespaces.is_empty() && found.opaque.is_empty() { + (edits, Vec::new()) + } else { + let semantic = SemanticBuilder::new() + .with_build_nodes(true) + .build(&program) + .semantic; + let mut rewriter = Rewriter { + code, + semantic: &semantic, + walker: &mut walker, + generated: Generated::default(), + edits, + errors: Vec::new(), + }; + for opaque in &found.opaque { + let local = opaque.binding.name.as_str(); + for (node, _) in rewriter.references(opaque.binding) { + let span = semantic.nodes().kind(node).span(); + rewriter.errors.push(( + span.start, + unfollowable(local, &opaque.source, &opaque.reason), + )); + } + } + for namespace in &found.namespaces { + rewriter.read_namespace(namespace); + } + (rewriter.edits, rewriter.errors) + }; + if !errors.is_empty() { + return Barreled::Failed(errors); + } + if edits.is_empty() { + return Barreled::Unchanged; + } + edits.sort_by_key(|(start, end, _)| (*start, *end)); + let recorded = edits + .iter() + .map(|(start, end, text)| (*start, *end, text.len())) + .collect(); + let mut result = code.to_string(); + for (start, end, text) in edits.into_iter().rev() { + result.replace_range(start..end, &text); + } + Barreled::Rewritten(Rewritten { + code: result, + edits: recorded, + dependencies: walker.dependencies, + }) +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used)] +mod tests; diff --git a/libs/extractor/src/barrel/tests.rs b/libs/extractor/src/barrel/tests.rs new file mode 100644 index 000000000..594b0820b --- /dev/null +++ b/libs/extractor/src/barrel/tests.rs @@ -0,0 +1,859 @@ +use std::fs; +use std::path::{Component, Path, PathBuf}; + +use css::class_map::reset_class_map; +use css::file_map::reset_file_map; +use serial_test::serial; + +use crate::{ + ExtractOption, ExtractOutput, ModuleResolver, ResolvedModule, extract, extract_with_modules, + has_devup_ui_through, +}; + +/// A project of real files on disk, removed when dropped +struct Project { + root: PathBuf, +} + +impl Project { + fn new(name: &str, files: &[(&str, &str)]) -> Self { + let root = std::env::temp_dir().join(format!("devup-barrel-{}-{name}", std::process::id())); + let _ = fs::remove_dir_all(&root); + for (path, code) in files { + let file = root.join(path); + fs::create_dir_all(file.parent().unwrap()).unwrap(); + fs::write(file, code).unwrap(); + } + Self { root } + } + + fn path(&self, name: &str) -> String { + self.root.join(name).to_string_lossy().replace('\\', "/") + } + + fn resolver(&self) -> impl Fn(&str, &str) -> Option + use<> { + let root = self.root.clone(); + move |specifier, importer| { + let base = if specifier.starts_with('.') { + normalize(&Path::new(importer).parent().unwrap().join(specifier)) + } else { + root.join("node_modules").join(specifier) + }; + let mut candidates = vec![base.clone()]; + for extension in ["ts", "tsx", "js"] { + candidates.push(PathBuf::from(format!("{}.{extension}", base.display()))); + candidates.push(base.join(format!("index.{extension}"))); + } + candidates + .into_iter() + .find(|candidate| candidate.is_file()) + .map(|path| ResolvedModule { + code: fs::read_to_string(&path).unwrap(), + path: path.to_string_lossy().replace('\\', "/"), + }) + } + } + + fn extract(&self, name: &str) -> Result { + reset_class_map(); + reset_file_map(); + let filename = self.path(name); + let code = fs::read_to_string(&filename).unwrap(); + extract_with_modules( + &filename, + &code, + ExtractOption::default(), + false, + &self.resolver(), + ) + .map_err(|error| error.to_string()) + } + + fn code(&self, name: &str) -> String { + self.extract(name).unwrap().code + } + + fn error(&self, name: &str) -> String { + self.extract(name).unwrap_err() + } +} + +fn normalize(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + normalized.pop(); + } + component => normalized.push(component), + } + } + normalized +} + +impl Drop for Project { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } +} + +const APP: &str = "import { Box } from './ui'\nexport const a = \n"; + +fn assert_compiled(code: &str) { + assert!(code.contains("
\n", + ), + ], + ); + assert_eq!(project.code("app.tsx"), project.code("direct.tsx")); +} + +#[test] +#[serial] +fn chained_barrels_are_followed() { + let project = Project::new( + "chain", + &[ + ("ui/index.ts", "export * from './components'\n"), + ( + "ui/components/index.ts", + "export { Box } from './box'\nexport { Text } from '@devup-ui/react'\n", + ), + ( + "ui/components/box.ts", + "export { Box } from '@devup-ui/react'\n", + ), + ("app.tsx", APP), + ], + ); + let output = project.extract("app.tsx").unwrap(); + assert_compiled(&output.code); + assert_eq!( + output.dependencies, + vec![ + project.path("ui/components/box.ts"), + project.path("ui/components/index.ts"), + project.path("ui/index.ts"), + ] + ); +} + +#[test] +#[serial] +fn export_star_of_the_package_is_followed() { + let project = Project::new( + "star", + &[ + ( + "ui/index.ts", + "export * from '@devup-ui/react'\nexport * from './other'\n", + ), + ("ui/other.ts", "export const other = 1\nexport {}\n"), + ( + "app.tsx", + "import { Box, css } from './ui'\nimport { other } from './ui'\nexport const a = \nexport const b = other\n", + ), + ], + ); + let code = project.code("app.tsx"); + assert_compiled(&code); + assert!(code.contains("import { other } from \"./ui\""), "{code}"); + assert!(!code.contains("css("), "{code}"); +} + +#[test] +#[serial] +fn renamed_exports_keep_the_importers_names() { + let project = Project::new( + "renamed", + &[ + ( + "ui.ts", + "export { Box as Container, css as style, styled as s } from '@devup-ui/react'\n", + ), + ( + "app.tsx", + "import { Container as C, style, s } from './ui'\nexport const a = \nexport const b = style({ color: 'blue' })\nexport const D = s('div', { color: 'green' })\nexport const d = \n", + ), + ], + ); + let code = project.code("app.tsx"); + assert_compiled(&code); + assert!(!code.contains("style("), "{code}"); +} + +#[test] +#[serial] +fn compat_entry_is_followed() { + let project = Project::new( + "compat", + &[ + ( + "ui.ts", + "export { useTheme } from '@devup-ui/react/compat'\n", + ), + ( + "app.tsx", + "import { useTheme } from './ui'\nexport const t = useTheme\n", + ), + ], + ); + let code = project.code("app.tsx"); + assert!(code.contains("@devup-ui/react/compat"), "{code}"); + assert!(!code.contains("./ui"), "{code}"); +} + +#[test] +#[serial] +fn imports_of_other_things_stay() { + let project = Project::new( + "mixed", + &[ + ( + "ui.ts", + "import { Box as B } from '@devup-ui/react'\nexport { B }\nexport const helper = 1\nexport type Props = {}\nexport default function Page() {}\n", + ), + ( + "app.tsx", + "import Page, { B as Box, helper, type Props } from './ui'\nexport const a = \nexport const b = [Page, helper]\nexport const c: Props = {}\n", + ), + ], + ); + let code = project.code("app.tsx"); + assert_compiled(&code); + assert!( + code.contains("import Page, { helper, type Props } from \"./ui\""), + "{code}" + ); +} + +#[test] +#[serial] +fn default_exports_of_the_package_are_followed() { + let project = Project::new( + "default", + &[ + ( + "ui.ts", + "import { Box } from '@devup-ui/react'\nexport default Box\n", + ), + ( + "app.tsx", + "import UI from './ui'\nexport const a = \n", + ), + ], + ); + assert!(project.code("app.tsx").contains("
\n", + ), + ], + ); + assert_compiled(&project.code("app.tsx")); +} + +#[test] +#[serial] +fn namespace_of_a_barrel_is_read_by_member() { + let project = Project::new( + "barrel-namespace", + &[ + ( + "ui.ts", + "export { Box, css } from '@devup-ui/react'\nexport const helper = 1\n", + ), + ( + "app.tsx", + "import * as UI from './ui'\nexport const a = {UI.helper}\nexport const { css: c, helper } = { css: 1, helper: 2 }\nexport const d = UI['css']({ color: 'blue' })\n", + ), + ], + ); + let code = project.code("app.tsx"); + assert!(code.contains("
\nexport const b = css({ color: 'blue' })\n", + ), + ], + ); + let code = project.code("app.tsx"); + assert_compiled(&code); + assert!(!code.contains("= UI"), "{code}"); +} + +#[test] +#[serial] +fn destructure_leaves_what_the_barrel_declares() { + let project = Project::new( + "barrel-destructure-own", + &[ + ( + "ui.ts", + "export { Box } from '@devup-ui/react'\nexport const helper = 1\n", + ), + ( + "app.tsx", + "import * as UI from './ui'\nconst { helper } = UI\nexport const a = helper\n", + ), + ], + ); + assert!(project.code("app.tsx").contains("{ helper } = UI")); +} + +#[test] +#[serial] +fn unresolvable_barrel_is_a_located_error_where_used() { + let project = Project::new( + "unresolvable", + &[ + ( + "ui.ts", + "export * from './missing'\nexport { Box } from '@devup-ui/react'\n", + ), + ( + "app.tsx", + "import { Foo, Box } from './ui'\nexport const a = 1\nexport const b = \n", + ), + ( + "unused.tsx", + "import { Foo } from './ui'\nexport const a = 1\n", + ), + ( + "named.ts", + "export { Foo } from './missing'\nexport { Box } from '@devup-ui/react'\n", + ), + ( + "app-named.tsx", + "import { Foo } from './named'\nexport const b = \n", + ), + ], + ); + let error = project.error("app.tsx"); + assert!( + error.contains("app.tsx:3:19: `Foo` cannot use `./ui` at build time"), + "{error}" + ); + assert!(error.contains("`./missing` cannot be read"), "{error}"); + assert_eq!( + project.code("unused.tsx"), + "import { Foo } from './ui'\nexport const a = 1\n" + ); + assert!( + project + .error("app-named.tsx") + .contains("`./missing` cannot be read") + ); +} + +#[test] +#[serial] +fn unresolvable_modules_unrelated_to_the_package_stay() { + let project = Project::new( + "unrelated", + &[ + ( + "ui.ts", + "export * from './missing'\nexport { x } from './missing'\n", + ), + ( + "app.tsx", + "import { Foo, x } from './ui'\nexport const a = [Foo, x]\n", + ), + ], + ); + assert!( + project + .code("app.tsx") + .contains("import { Foo, x } from './ui'") + ); +} + +#[test] +#[serial] +fn namespaces_of_modules_are_followed_or_reported() { + let project = Project::new( + "namespace-module", + &[ + ("inner.ts", "export { Box } from '@devup-ui/react'\n"), + ("plain.ts", "export const x = 1\n"), + ( + "ui.ts", + "export * as inner from './inner'\nexport * as plain from './plain'\n", + ), + ( + "app.tsx", + "import { plain } from './ui'\nexport const a = plain.x\n", + ), + ( + "bad.tsx", + "import { inner } from './ui'\nexport const a = \n", + ), + ], + ); + assert!( + project + .code("app.tsx") + .contains("import { plain } from './ui'") + ); + assert!( + project + .error("bad.tsx") + .contains("a namespace of modules re-exporting `@devup-ui/react`") + ); +} + +#[test] +#[serial] +fn cycles_between_barrels_end() { + let project = Project::new( + "cycle", + &[ + ( + "a.ts", + "export * from './b'\nexport { Box } from '@devup-ui/react'\n", + ), + ("b.ts", "export * from './a'\n"), + ( + "app.tsx", + "import { Box, nothing } from './b'\nexport const a = \nexport const n = nothing\n", + ), + ], + ); + let code = project.code("app.tsx"); + assert_compiled(&code); + assert!(code.contains("import { nothing } from \"./b\""), "{code}"); +} + +#[test] +#[serial] +fn files_that_cannot_lead_to_the_package_are_not_read_further() { + let project = Project::new( + "plain", + &[ + ("plain.ts", "export const x = 1\n"), + ( + "app.tsx", + "import { x } from './plain'\nimport './side-effect'\nimport type { T } from './plain'\nimport {} from './plain'\nexport const a = x\n", + ), + ], + ); + let output = project.extract("app.tsx").unwrap(); + assert_eq!(output.styles.len(), 0); + assert_eq!(output.dependencies, Vec::::new()); +} + +#[test] +#[serial] +fn type_only_and_attribute_imports_are_left() { + let project = Project::new( + "types", + &[ + ("ui.ts", "export { Box } from '@devup-ui/react'\n"), + ( + "app.tsx", + "import type { Box } from './ui'\nimport data from './ui' with { type: 'json' }\nexport type A = typeof Box\nexport const d = data\n", + ), + ], + ); + let code = project.code("app.tsx"); + assert!(code.contains("./ui"), "{code}"); +} + +#[test] +#[serial] +fn export_forms_that_are_not_re_exports_are_declarations() { + let project = Project::new( + "declarations", + &[ + ( + "ui.ts", + "import { Box } from '@devup-ui/react'\nexport type { Box as BoxType } from '@devup-ui/react'\nexport type * from '@devup-ui/react'\nexport { type Box as T }\nexport enum E { A }\nexport default (() => Box)\n", + ), + ( + "app.tsx", + "import D, { BoxType, E, T } from './ui'\nexport const a = [D, BoxType, E, T]\n", + ), + ], + ); + let code = project.code("app.tsx"); + assert!( + code.contains("import D, { BoxType, E, T } from './ui'"), + "{code}" + ); +} + +#[test] +#[serial] +fn parse_failures_are_left_alone() { + let project = Project::new( + "broken", + &[ + ("ui.ts", "export { Box } from '@devup-ui/react'\n"), + ("app.tsx", "import { Box } from './ui'\nconst = <\n"), + ], + ); + assert_eq!( + project.code("app.tsx"), + "import { Box } from './ui'\nconst = <\n" + ); +} + +#[test] +#[serial] +fn vanilla_extract_stylesheets_are_left_alone() { + let project = Project::new( + "vanilla", + &[ + ("ui.ts", "export { style } from '@devup-ui/react'\n"), + ( + "a.css.ts", + "import { style } from '@vanilla-extract/css'\nexport const a = style({ color: 'red' })\n", + ), + ], + ); + assert!(project.extract("a.css.ts").is_ok()); +} + +fn namespace_error(code: &str) -> String { + reset_class_map(); + reset_file_map(); + extract("test.tsx", code, ExtractOption::default()) + .unwrap_err() + .to_string() +} + +fn namespace_code(code: &str) -> String { + reset_class_map(); + reset_file_map(); + extract("test.tsx", code, ExtractOption::default()) + .unwrap() + .code +} + +const NS: &str = "import * as Devup from '@devup-ui/react'\n"; + +#[test] +#[serial] +fn namespace_members_compile_by_name() { + let code = namespace_code(&format!( + "{NS}export const a = x\nexport const b = Devup['css']({{ color: 'blue' }})\nexport const c = Devup[`css`]({{ color: 'green' }})\nexport const d = Devup.css({{ color: 'blue' }})\n" + )); + assert!(code.contains("
\nexport function f() {{\n const {{ css: local }} = Devup\n return local({{ color: 'blue' }})\n}}\nconst g = 1, {{ keyframes }} = Devup, h = 2\nexport const k = [g, h, keyframes({{ from: {{ opacity: 0 }} }})]\nconst m = 1, {{ globalCss }} = Devup\nglobalCss({{ body: {{ margin: 0 }} }})\n" + )); + assert!(code.contains("
\n", + ); + assert!(code.contains("
fn\nexport const a = f(Devup.css)\n" + )); + assert!(error.contains("test.tsx:3:20:"), "{error}"); +} + +#[test] +#[serial] +fn namespace_read_whole_is_a_located_error() { + for code in [ + "export const key = 'css'\nexport const a = Devup[key]", + "Devup.css = 1", + "export const [a] = Devup", + "export const { ...rest } = Devup", + "const key = 'css'\nexport const { [key]: a } = Devup", + "export const { css = 1 } = Devup", + "export const { css: [a] } = Devup", + "export const { css } = Devup", + "const { css, stylex } = Devup\nexport const a = [css, stylex]", + ] { + let error = namespace_error(&format!("{NS}{code}\n")); + assert!( + error.contains("`Devup` cannot use `") && error.contains("at build time"), + "{code}: {error}" + ); + } +} + +#[test] +#[serial] +fn binding_reads_of_a_destructured_namespace_keep_their_meaning() { + for code in [ + "const { css } = Devup\nexport const a = { css }\n", + "const { css } = Devup\nexport { css }\n", + "const { css } = Devup\nexport { css as c }\n", + ] { + let error = namespace_error(&format!("{NS}{code}")); + assert!(error.contains("test.tsx:"), "{code}: {error}"); + } + let error = namespace_error(&format!("{NS}let {{ css }} = Devup\ncss = 1\n")); + assert!(error.contains("`Devup` cannot use `css`"), "{error}"); +} + +#[test] +#[serial] +fn namespace_without_a_resolver_or_package_text_is_unchanged() { + let output = extract( + "test.tsx", + "import * as Other from 'other'\nexport const a = Other.x\n", + ExtractOption::default(), + ) + .unwrap(); + assert_eq!(output.styles.len(), 0); +} + +#[test] +#[serial] +fn barrel_namespace_errors_name_the_member() { + let project = Project::new( + "barrel-namespace-errors", + &[ + ( + "ui.ts", + "export * from './missing'\nexport { Box } from '@devup-ui/react'\nexport const own = 1\n", + ), + ( + "member.tsx", + "import * as UI from './ui'\nexport const a = UI.nothing\nexport const b = UI.own\n", + ), + ( + "destructure.tsx", + "import * as UI from './ui'\nconst { nothing } = UI\nexport const a = nothing\n", + ), + ( + "mixed.tsx", + "import * as UI from './ui'\nconst { Box, own } = UI\nexport const a = [Box, own]\n", + ), + ], + ); + let error = project.error("member.tsx"); + assert!(error.contains("`UI.nothing` cannot use `./ui`"), "{error}"); + let error = project.error("destructure.tsx"); + assert!( + error.contains("`{ nothing } = UI` cannot use `./ui`"), + "{error}" + ); + let error = project.error("mixed.tsx"); + assert!( + error.contains("`UI` cannot use `const { Box, own } = UI`") + || error.contains("`UI` cannot use `{ Box, own } = UI`"), + "{error}" + ); +} + +#[test] +#[serial] +fn unfollowable_default_and_named_imports_report_each_use() { + let project = Project::new( + "unfollowable-default", + &[ + ( + "ui.ts", + "export { default } from './missing'\nexport { Box } from '@devup-ui/react'\n", + ), + ( + "app.tsx", + "import Page from './ui'\nexport const a = [Page, Page]\n", + ), + ], + ); + let error = project.error("app.tsx"); + assert!( + error.contains("app.tsx:2:19:") && error.contains("app.tsx:2:25:"), + "{error}" + ); +} + +#[test] +#[serial] +fn has_devup_ui_follows_barrels() { + let project = Project::new( + "has", + &[ + ("ui.ts", "export { Box } from '@devup-ui/react'\n"), + ("plain.ts", "export const x = 1\n"), + ], + ); + let resolver = project.resolver(); + let resolver: &ModuleResolver = &resolver; + let file = project.path("app.tsx"); + assert!(has_devup_ui_through( + &file, + "import { Box } from './ui'", + "@devup-ui/react", + resolver + )); + assert!(has_devup_ui_through( + &file, + "import { Box } from '@devup-ui/react'", + "@devup-ui/react", + resolver + )); + assert!(!has_devup_ui_through( + &file, + "import { x } from './plain'", + "@devup-ui/react", + resolver + )); + assert!(!has_devup_ui_through( + &file, + "const a = 1", + "@devup-ui/react", + resolver + )); + assert!(!has_devup_ui_through( + "app.invalid", + "import { Box } from './ui'", + "@devup-ui/react", + resolver + )); + assert!(has_devup_ui_through( + &file, + "import { Foo } from './unresolved'\nimport * as Devup from '@devup-ui/react'\nDevup.css", + "@devup-ui/react", + resolver, + )); +} + +#[test] +#[serial] +fn namespace_of_the_package_inside_a_barrel_namespace_is_followed() { + let project = Project::new( + "nested-namespace", + &[ + ( + "ui.ts", + "export * as Devup from '@devup-ui/react'\nexport const own = 1\n", + ), + ( + "app.tsx", + "import * as UI from './ui'\nexport const a = \nexport const b = UI.missing\n", + ), + ], + ); + let code = project.code("app.tsx"); + assert!(code.contains("
fn)(Devup)\nexport default Devup\nexport const c = typeof Devup\n" + )); + assert!(code.contains("Object.keys(Devup)"), "{code}"); + assert!(code.contains("export default Devup"), "{code}"); +} diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index ac1dcbde6..b0fae41ff 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -1,4 +1,5 @@ mod as_visit; +mod barrel; mod build_time_values; mod component; mod composition; @@ -285,19 +286,45 @@ fn extract_with_source_map( source_map: bool, resolver: Option<&ModuleResolver>, ) -> Result> { - extract_source(filename, code, None, option, source_map, resolver) + extract_source(filename, code, None, false, option, source_map, resolver) } /// `evaluated` is the source `code` was computed from, with the layers of -/// edits, last made first, that map `code` back to it +/// edits, last made first, that map `code` back to it; `values_run` tells that +/// the values only running code gives are in `code` already fn extract_source( filename: &str, code: &str, evaluated: Option<(&str, &[&[import_alias_visit::Edit]])>, + values_run: bool, option: ExtractOption, source_map: bool, resolver: Option<&ModuleResolver>, ) -> Result> { + if evaluated.is_none() { + match barrel::rewrite(code, filename, &option.package, resolver) { + barrel::Barreled::Unchanged => {} + barrel::Barreled::Failed(errors) => { + return Err(located_errors(filename, code, &[], errors).into()); + } + barrel::Barreled::Rewritten(barreled) => { + let mut output = extract_source( + filename, + &barreled.code, + Some((code, &[barreled.edits.as_slice()])), + false, + option, + source_map, + resolver, + )?; + let mut files: std::collections::BTreeSet = + output.dependencies.into_iter().collect(); + files.extend(barreled.dependencies); + output.dependencies = files.into_iter().collect(); + return Ok(output); + } + } + } // Step 1: Transform import aliases // e.g., `import styled from '@emotion/styled'` → `import { styled } from '@devup-ui/react'` // e.g., `import { style } from '@vanilla-extract/css'` → `import { style } from '@devup-ui/react'` @@ -465,7 +492,7 @@ fn extract_source( // Run the code a value computes, or tell rules the module computes from a // class it composes if (!visitor.errors.is_empty() || visitor.composes_unknown) - && evaluated.is_none() + && !values_run && !utils::is_vanilla_extract_file(filename) && let Some((computed, value_edits, read)) = build_time_values::evaluate( &transformed_code, @@ -475,10 +502,17 @@ fn extract_source( &inlined.unknown, ) { + let (source, earlier_edits) = evaluated.unwrap_or((code, &[])); + let layers: Vec<&[import_alias_visit::Edit]> = + [value_edits.as_slice(), alias_edits.as_slice()] + .into_iter() + .chain(earlier_edits.iter().copied()) + .collect(); let mut output = extract_source( filename, &computed, - Some((code, &[value_edits.as_slice(), alias_edits.as_slice()])), + Some((source, &layers)), + true, option, source_map, resolver, @@ -751,6 +785,23 @@ pub fn has_devup_ui(filename: &str, code: &str, package: &str) -> bool { false } +/// [`has_devup_ui`], also true for a file importing the package through a +/// project module that re-exports it, or reading members of a namespace import +#[must_use] +pub fn has_devup_ui_through( + filename: &str, + code: &str, + package: &str, + resolver: &ModuleResolver, +) -> bool { + SourceType::from_path(filename).is_ok() + && (has_devup_ui(filename, code, package) + || !matches!( + barrel::rewrite(code, filename, package, Some(resolver)), + barrel::Barreled::Unchanged + )) +} + #[cfg(test)] #[allow(clippy::expect_used, clippy::unwrap_used)] mod tests { diff --git a/libs/extractor/src/module_loader.rs b/libs/extractor/src/module_loader.rs index 4082ff395..ff8b315d0 100644 --- a/libs/extractor/src/module_loader.rs +++ b/libs/extractor/src/module_loader.rs @@ -451,7 +451,7 @@ fn export_name(name: &ModuleExportName<'_>) -> String { name.name().to_string() } -fn declared_names(declaration: &Declaration<'_>) -> Vec { +pub(crate) fn declared_names(declaration: &Declaration<'_>) -> Vec { match declaration { Declaration::VariableDeclaration(declaration) => declaration .declarations diff --git a/libs/extractor/src/snapshots/extractor__tests__inline_imported_constants.snap b/libs/extractor/src/snapshots/extractor__tests__inline_imported_constants.snap index a590c2c1b..5649ea29f 100644 --- a/libs/extractor/src/snapshots/extractor__tests__inline_imported_constants.snap +++ b/libs/extractor/src/snapshots/extractor__tests__inline_imported_constants.snap @@ -475,5 +475,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport * as Devup from \"@devup-ui/react\";\nimport { PRIMARY, SIZE, NEG, TEMPLATE, colors, ALIAS, MUTABLE, DYNAMIC, renamed, alsoMutable, reBase, notThere, gone, value, ns, NEG_STRING, TEMPLATE_EXPRESSION, SPACED, NOT_OBJECT, helper, looped } from \"./tokens\";\nimport orange from \"./tokens\";\nimport * as tokens from \"./tokens\";\nimport named from \"./named\";\nimport { handler } from \"./handler\";\nimport cjs, { COMPILED, FROM_REQUIRE, DESTRUCTURED, MUTATED, DYNAMIC } from \"./cjs-tokens\";\nimport cjsObject, { OBJ, OVERRIDDEN, LATER } from \"./cjs-object\";\nimport twelve from \"./cjs-value\";\nimport twice from \"./cjs-twice\";\nimport marker, { named as markerNamed } from \"./cjs-marker\";\nimport { unused } from \"./unused\";\nimport missing from \"./missing\";\nexport const a =
;\nexport const b = \"a\";\nexport const c =
;\nexport const d = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\"a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const e = (PRIMARY) =>
;\nexport const f = {\n\tPRIMARY,\n\tunused,\n\tSIZE: tokens.SIZE\n};\nexport const g =
;\nexport const h = ;\nexport const i = Devup[\"css\"]({ color: SIZE });\nexport const l =
;\nexport const j =
;\nexport const k = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\"ba\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport * as Devup from \"@devup-ui/react\";\nimport { PRIMARY, SIZE, NEG, TEMPLATE, colors, ALIAS, MUTABLE, DYNAMIC, renamed, alsoMutable, reBase, notThere, gone, value, ns, NEG_STRING, TEMPLATE_EXPRESSION, SPACED, NOT_OBJECT, helper, looped } from \"./tokens\";\nimport orange from \"./tokens\";\nimport * as tokens from \"./tokens\";\nimport named from \"./named\";\nimport { handler } from \"./handler\";\nimport cjs, { COMPILED, FROM_REQUIRE, DESTRUCTURED, MUTATED, DYNAMIC } from \"./cjs-tokens\";\nimport cjsObject, { OBJ, OVERRIDDEN, LATER } from \"./cjs-object\";\nimport twelve from \"./cjs-value\";\nimport twice from \"./cjs-twice\";\nimport marker, { named as markerNamed } from \"./cjs-marker\";\nimport { unused } from \"./unused\";\nimport missing from \"./missing\";\nexport const a =
;\nexport const b = \"a\";\nexport const c =
;\nexport const d = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\"a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const e = (PRIMARY) =>
;\nexport const f = {\n\tPRIMARY,\n\tunused,\n\tSIZE: tokens.SIZE\n};\nexport const g =
;\nexport const h = ;\nexport const i = \"at\";\nexport const l =
;\nexport const j =
;\nexport const k = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\"at\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n", } diff --git a/packages/bun-plugin/src/__tests__/barrel.test.ts b/packages/bun-plugin/src/__tests__/barrel.test.ts new file mode 100644 index 000000000..49901774c --- /dev/null +++ b/packages/bun-plugin/src/__tests__/barrel.test.ts @@ -0,0 +1,103 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import { createModuleResolver } from '@devup-ui/plugin-utils' +import { + codeExtract, + hasDevupUI, + registerTheme, + setModuleResolver, +} from '@devup-ui/wasm' +import { afterAll, beforeAll, describe, expect, it } from 'bun:test' + +const files: Record = { + 'tsconfig.json': JSON.stringify({ + compilerOptions: { baseUrl: '.', paths: { '@ui/*': ['src/ui/*'] } }, + }), + 'src/ui/index.ts': `export { Box, css } from '@devup-ui/react'\nexport * from './more'\n`, + 'src/ui/more.ts': `export { Text } from '@devup-ui/react'\n`, + 'src/ui/broken.ts': `export * from './nowhere'\nexport { Grid } from '@devup-ui/react'\n`, + 'node_modules/@acme/ui/package.json': JSON.stringify({ + name: '@acme/ui', + exports: { '.': { import: './dist/index.js' } }, + }), + 'node_modules/@acme/ui/dist/index.js': `export { Flex } from '@devup-ui/react'\n`, + 'src/relative.tsx': `import { Box, Text } from './ui'\nexport const a = \n`, + 'src/paths.tsx': `import { Box } from '@ui/index'\nexport const a = \n`, + 'src/package.tsx': `import { Flex } from '@acme/ui'\nexport const a = \n`, + 'src/namespace.tsx': `import * as UI from './ui'\nconst { css } = UI\nexport const a = \n`, + 'src/unfollowable.tsx': `import { Grid, Missing } from './ui/broken'\nexport const a = \n`, + 'src/plain.tsx': `import { helper } from './ui/more'\nexport const a = helper\n`, +} + +let root = '' + +function read(name: string) { + return { + filename: join(root, name).replaceAll('\\', '/'), + code: files[name], + } +} + +function extract(name: string) { + const { filename, code } = read(name) + return codeExtract( + filename, + code, + '@devup-ui/react', + '@devup-ui/react', + true, + false, + false, + {}, + ) +} + +beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'devup-barrel-')) + for (const [name, code] of Object.entries(files)) { + const file = join(root, name) + mkdirSync(dirname(file), { recursive: true }) + writeFileSync(file, code) + } + registerTheme({}) + setModuleResolver( + createModuleResolver({ + cwd: root, + toId: (path) => path.replaceAll('\\', '/'), + }), + ) +}) + +afterAll(() => { + setModuleResolver(createModuleResolver()) + rmSync(root, { recursive: true, force: true }) +}) + +describe('Devup UI re-exported by a project module', () => { + it.each(['relative', 'paths', 'package', 'namespace'])( + 'is found in %s.tsx by the gate and compiled', + (name) => { + const { filename, code } = read(`src/${name}.tsx`) + expect(hasDevupUI(filename, code, '@devup-ui/react')).toBe(true) + const output = extract(`src/${name}.tsx`) + expect(output.code).toContain('
{ + const { filename, code } = read('src/plain.tsx') + expect(hasDevupUI(filename, code, '@devup-ui/react')).toBe(false) + }) + + it('reports what it cannot follow where it is used', () => { + const { filename, code } = read('src/unfollowable.tsx') + expect(hasDevupUI(filename, code, '@devup-ui/react')).toBe(true) + expect(() => extract('src/unfollowable.tsx')).toThrow( + /unfollowable\.tsx:2:19: `Missing` cannot use `\.\/ui\/broken` at build time/, + ) + }) +}) diff --git a/packages/next-plugin/src/__tests__/loader.test.ts b/packages/next-plugin/src/__tests__/loader.test.ts index 3f4df0644..4586bb962 100644 --- a/packages/next-plugin/src/__tests__/loader.test.ts +++ b/packages/next-plugin/src/__tests__/loader.test.ts @@ -301,6 +301,45 @@ describe('devupUILoader', () => { }) }) + it('extracts a file that reaches Devup UI only through a project module', async () => { + const source = `import { Box } from './ui' +export const a = ` + const asyncCallback = mock() + const t = { + getOptions: () => ({ + package: 'package', + cssDir: 'cssFile', + watch: false, + singleCss: true, + defaultClassMap: {}, + defaultFileMap: {}, + defaultSheet: {}, + }), + async: mock().mockReturnValue(asyncCallback), + resourcePath: 'index.tsx', + addDependency: mock(), + } + codeExtractSpy.mockReturnValue({ + code: '
', + css: undefined, + free: mock(), + map: undefined, + cssFile: undefined, + updatedBaseStyle: false, + [Symbol.dispose]: mock(), + }) + devupUILoader.bind(asLoaderContext(t))(Buffer.from(source), 'index.tsx') + + expect(codeExtractSpy.mock.calls.at(-1)?.[1]).toBe(source) + await waitFor(() => { + expect(asyncCallback).toHaveBeenCalledWith( + null, + '
', + null, + ) + }) + }) + it('should handle error in build mode', async () => { const asyncCallback = mock() const t = { diff --git a/packages/rsbuild-plugin/src/__tests__/plugin.test.ts b/packages/rsbuild-plugin/src/__tests__/plugin.test.ts index 3fa5b9fd4..07e19edf8 100644 --- a/packages/rsbuild-plugin/src/__tests__/plugin.test.ts +++ b/packages/rsbuild-plugin/src/__tests__/plugin.test.ts @@ -324,6 +324,35 @@ const App = () => `, const App = () => `, ) }) + it('transforms a file that reaches Devup UI only through a project module', async () => { + const source = `import { Box } from './ui' +export const a = ` + const plugin = DevupUI() + const transform = mock() + await plugin.setup( + createSetupContext({ + transform, + modifyRsbuildConfig: mock(), + }), + ) + codeExtractSpy.mockReturnValue( + createCodeExtractResult({ + code: '
', + css: '', + cssFile: 'devup-ui.css', + }), + ) + await expect( + transform.mock.calls[1][1]({ + code: source, + resourcePath: 'src/App.tsx', + }), + ).resolves.toEqual({ + code: '
', + map: undefined, + }) + expect(codeExtractSpy.mock.calls.at(-1)?.[1]).toBe(source) + }) it.each( createTestMatrix({ updatedBaseStyle: [true, false], diff --git a/packages/vite-plugin/src/__tests__/plugin.test.ts b/packages/vite-plugin/src/__tests__/plugin.test.ts index 2c910f406..b2e274765 100644 --- a/packages/vite-plugin/src/__tests__/plugin.test.ts +++ b/packages/vite-plugin/src/__tests__/plugin.test.ts @@ -1184,6 +1184,26 @@ describe('devupUIVitePlugin', () => { ) }) + it('extracts a file that reaches Devup UI only through a project module', async () => { + const source = `import { Box } from './ui' +export const a = ` + codeExtractSpy.mockReturnValue( + createCodeExtractResult({ + code: '
', + css: '', + cssFile: undefined, + map: undefined, + updatedBaseStyle: false, + }), + ) + const plugin = createPlugin({}) + + expect(await plugin.transform(source, 'src/App.tsx')).toEqual({ + code: '
', + }) + expect(codeExtractSpy.mock.calls.at(-1)?.[1]).toBe(source) + }) + it('sholud add relative path to css file', async () => { getCssSpy.mockReturnValue('css code') codeExtractSpy.mockReturnValue( diff --git a/packages/webpack-plugin/src/__tests__/loader.test.ts b/packages/webpack-plugin/src/__tests__/loader.test.ts index 8697cb090..0a60962eb 100644 --- a/packages/webpack-plugin/src/__tests__/loader.test.ts +++ b/packages/webpack-plugin/src/__tests__/loader.test.ts @@ -275,6 +275,40 @@ describe('devupUILoader', () => { }) }) + it('extracts a file that reaches Devup UI only through a project module', async () => { + const source = `import { Box } from './ui' +export const a = ` + const asyncCallback = mock() + const t = createLoaderContext( + { + package: 'package', + cssDir: 'cssFile', + watch: false, + singleCss: true, + }, + asyncCallback, + ) + codeExtractSpy.mockReturnValue( + createCodeExtractResult({ + code: '
', + css: undefined, + map: undefined, + cssFile: undefined, + updatedBaseStyle: false, + }), + ) + devupUILoader.bind(t)(Buffer.from(source), 'index.tsx') + + expect(codeExtractSpy.mock.calls.at(-1)?.[1]).toBe(source) + await waitFor(() => { + expect(asyncCallback).toHaveBeenCalledWith( + null, + '
', + null, + ) + }) + }) + it('should handle error', async () => { const asyncCallback = mock() const t = createLoaderContext( From 10ec611303d1a1541796025eb5d868d00a98e439 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sat, 3 Oct 2026 18:00:17 +0900 Subject: [PATCH 2/5] test(extractor): cover the namespace styled read a stylesheet still takes from the visitor Refs #695 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/barrel/tests.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/libs/extractor/src/barrel/tests.rs b/libs/extractor/src/barrel/tests.rs index 594b0820b..62ccc281d 100644 --- a/libs/extractor/src/barrel/tests.rs +++ b/libs/extractor/src/barrel/tests.rs @@ -857,3 +857,18 @@ fn namespace_passed_whole_is_left_to_the_runtime() { assert!(code.contains("Object.keys(Devup)"), "{code}"); assert!(code.contains("export default Devup"), "{code}"); } + +#[test] +#[serial] +fn namespace_styled_in_a_stylesheet_is_compiled_by_the_visitor() { + reset_class_map(); + reset_file_map(); + let output = extract( + "test.css.ts", + "import * as Devup from '@devup-ui/react'\nexport const A = Devup.styled.div({ color: 'red' })\n", + ExtractOption::default(), + ) + .unwrap(); + assert!(!output.code.contains("Devup.styled"), "{}", output.code); + assert_ne!(output.styles.len(), 0); +} From 07e17127930ca9272095204e2c3aa0fc3bc29490 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sat, 3 Oct 2026 21:17:22 +0900 Subject: [PATCH 3/5] fix(extractor): follow aliases of Devup UI and read style constants declared after their use Refs #695, #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../changepack_log_follow_aliases.json | 8 + bindings/devup-ui-wasm/src/lib.rs | 91 ++--- libs/extractor/src/barrel.rs | 206 ++++++++-- libs/extractor/src/barrel/aliases.rs | 385 ++++++++++++++++++ libs/extractor/src/barrel/tests.rs | 237 +++++++++-- libs/extractor/src/lib.rs | 48 ++- .../bun-plugin/src/__tests__/barrel.test.ts | 50 ++- packages/bun-plugin/src/plugin.ts | 2 +- .../next-plugin/src/__tests__/loader.test.ts | 39 ++ .../src/__tests__/plugin.test.ts | 30 ++ .../vite-plugin/src/__tests__/plugin.test.ts | 20 + .../src/__tests__/loader.test.ts | 34 ++ 12 files changed, 996 insertions(+), 154 deletions(-) create mode 100644 .changepacks/changepack_log_follow_aliases.json create mode 100644 libs/extractor/src/barrel/aliases.rs diff --git a/.changepacks/changepack_log_follow_aliases.json b/.changepacks/changepack_log_follow_aliases.json new file mode 100644 index 000000000..b011e08f4 --- /dev/null +++ b/.changepacks/changepack_log_follow_aliases.json @@ -0,0 +1,8 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "packages/bun-plugin/package.json": "Patch" + }, + "note": "hasDevupUI takes the import aliases and is true for a file extraction changes: one importing a redirected package (@emotion/styled, ...), naming @emotion/react in a @jsxImportSource pragma, or in a project whose tsconfig jsxImportSource is @emotion/react, so the Bun plugin no longer skips files whose only Devup UI is Emotion's css prop or an aliased import. Aliases of Devup UI now compile like the original: `export const B = Box`, `const c = css; export { c as cc }`, `export const D = Devup` and `export const C = Devup.css` in a barrel (the barrel module exports them as re-exports of the package, and what imports them follows them), `export { Box }` after an import, `export default Box`, `const D = Devup` namespace aliases, and `const inner = css` declared inside a function. A css() or keyframes() const that a function reads before its declaration compiles. New build error: a css()/keyframes() const read before its declaration runs (outside a function) is located and says to move the declaration above its first read", + "date": "2026-10-01T00:00:00.000Z" +} \ No newline at end of file diff --git a/bindings/devup-ui-wasm/src/lib.rs b/bindings/devup-ui-wasm/src/lib.rs index f8248a395..cd6e1a31b 100644 --- a/bindings/devup-ui-wasm/src/lib.rs +++ b/bindings/devup-ui-wasm/src/lib.rs @@ -5,7 +5,7 @@ use css::file_map::{ use extractor::extract_style::extract_style_value::ExtractStyleValue; use extractor::{ ExtractOption, ImportAlias, ModuleResolver, ResolvedModule, extract, extract_with_modules, - extract_without_source_map, has_devup_ui, has_devup_ui_through, + extract_without_source_map, has_devup_ui_with, }; use rustc_hash::FxHashSet; use sheet::StyleSheet; @@ -499,20 +499,6 @@ fn resolver_from_js() -> Option> { })) } -/// [`has_devup_ui`], also true for a file reaching the package through a -/// project module when there is a resolver -fn has_devup_ui_in( - filename: &str, - code: &str, - package: &str, - resolver: Option<&ModuleResolver>, -) -> bool { - match resolver { - Some(resolver) => has_devup_ui_through(filename, code, package, resolver), - None => has_devup_ui(filename, code, package), - } -} - /// Extract with the resolver set by `setModuleResolver`, if any #[cfg(not(tarpaulin_include))] #[allow(clippy::too_many_arguments)] @@ -693,8 +679,20 @@ pub fn get_theme_interface( #[wasm_bindgen(js_name = "hasDevupUI")] #[cfg(not(tarpaulin_include))] #[must_use] -pub fn has_devup_ui_wasm(filename: &str, code: &str, package: &str) -> bool { - has_devup_ui_in(filename, code, package, resolver_from_js().as_deref()) +pub fn has_devup_ui_wasm( + filename: &str, + code: &str, + package: &str, + import_aliases: JsValue, +) -> bool { + let aliases = import_aliases_from_js(import_aliases).unwrap_or_default(); + has_devup_ui_with( + filename, + code, + package, + &aliases, + resolver_from_js().as_deref(), + ) } #[cfg(test)] @@ -1606,53 +1604,40 @@ mod tests { } #[test] - #[serial] - fn test_has_devup_ui_wasm_function() { - // Test positive case - assert!(has_devup_ui_wasm( - "test.tsx", - "import { Box } from '@devup-ui/react';", - "@devup-ui/react" - )); - - // Test negative case - assert!(!has_devup_ui_wasm( - "test.tsx", - "const x = 1;", - "@devup-ui/react" - )); - - // Test invalid extension - assert!(!has_devup_ui_wasm( - "test.invalid", - "import { Box } from '@devup-ui/react';", - "@devup-ui/react" - )); - } - - #[test] - fn has_devup_ui_follows_a_barrel_only_with_a_resolver() { + fn has_devup_ui_covers_what_extraction_changes() { let resolver = |specifier: &str, _: &str| { (specifier == "./ui").then(|| ResolvedModule { path: "/src/ui.ts".to_string(), code: "export { Box } from '@devup-ui/react'".to_string(), }) }; - let code = "import { Box } from './ui';"; - assert!(has_devup_ui_in( - "/src/app.tsx", - code, - "@devup-ui/react", + let aliases = HashMap::from([("@emotion/styled".to_string(), ImportAlias::NamedToNamed)]); + let check = |filename: &str, code: &str, resolver: Option<&ModuleResolver>| { + has_devup_ui_with(filename, code, "@devup-ui/react", &aliases, resolver) + }; + assert!(check( + "a.tsx", + "import { Box } from '@devup-ui/react';", + None + )); + assert!(!check("a.tsx", "const x = 1;", None)); + assert!(!check( + "a.invalid", + "import { Box } from '@devup-ui/react';", + None + )); + assert!(check( + "a.tsx", + "import { Box } from './ui';", Some(&resolver) )); - assert!(!has_devup_ui_in( - "/src/app.tsx", - code, - "@devup-ui/react", + assert!(!check("a.tsx", "import { Box } from './ui';", None)); + assert!(check( + "a.tsx", + "import styled from '@emotion/styled';", None )); } - #[test] #[serial] fn test_output_single_css_mode() { diff --git a/libs/extractor/src/barrel.rs b/libs/extractor/src/barrel.rs index 0f3e4ecf9..2d4015b51 100644 --- a/libs/extractor/src/barrel.rs +++ b/libs/extractor/src/barrel.rs @@ -12,13 +12,14 @@ use std::rc::Rc; use oxc_allocator::Allocator; use oxc_ast::AstKind; use oxc_ast::ast::{ - BindingIdentifier, BindingPattern, ExportDefaultDeclarationKind, Expression, - ImportDeclarationSpecifier, Statement, + BindingIdentifier, BindingPattern, Declaration, ExportDefaultDeclarationKind, Expression, + ImportDeclarationSpecifier, Statement, VariableDeclarationKind, }; use oxc_parser::{Parser, ParserReturn}; use oxc_semantic::{Semantic, SemanticBuilder}; use oxc_span::{GetSpan, SourceType}; use oxc_syntax::node::NodeId; +use oxc_syntax::symbol::SymbolId; use rustc_hash::{FxHashMap, FxHashSet}; use crate::component::ExportVariableKind; @@ -163,10 +164,21 @@ fn analyze(module: &ResolvedModule, package: &str) -> Exports { .insert(specifier.exported.name().to_string(), link); } } + Statement::VariableDeclaration(declaration) => { + for (name, link) in alias_links(declaration, &imports) { + imports.insert(name, link); + } + } Statement::ExportDeclaration(export) => { for name in declared_names(&export.declaration) { exports.named.insert(name, Link::Own); } + if let Declaration::VariableDeclaration(declaration) = &export.declaration { + for (name, link) in alias_links(declaration, &imports) { + exports.named.insert(name.to_string(), link.clone()); + imports.insert(name, link); + } + } } Statement::ExportDefaultDeclaration(export) => { let link = match &export.declaration { @@ -185,6 +197,46 @@ fn analyze(module: &ResolvedModule, package: &str) -> Exports { exports } +/// 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)> { + if declaration.kind != VariableDeclarationKind::Const { + return Vec::new(); + } + declaration + .declarations + .iter() + .filter_map(|declarator| { + let BindingPattern::BindingIdentifier(id) = &declarator.id else { + return None; + }; + let link = match declarator.init.as_ref()? { + Expression::Identifier(init) => imports.get(init.name.as_str())?.clone(), + Expression::StaticMemberExpression(member) => { + let Expression::Identifier(object) = &member.object else { + return None; + }; + match imports.get(object.name.as_str())? { + Link::From { + source, + imported: None, + } => Link::From { + source: source.clone(), + imported: Some(member.property.name.to_string()), + }, + _ => return None, + } + } + _ => return None, + }; + Some((id.name.as_str(), link)) + }) + .collect() +} + /// Follows re-exports through the modules a resolver reads struct Walker<'r, 'p> { resolver: &'r ModuleResolver, @@ -346,7 +398,7 @@ pub(crate) struct Rewritten { } /// A namespace or default import whose members are read as values -struct Namespace<'p, 'a> { +struct NsDecl<'p, 'a> { binding: &'p BindingIdentifier<'a>, /// Where its declaration ends, after which the named imports it needs go after: usize, @@ -355,6 +407,17 @@ struct Namespace<'p, 'a> { source: String, } +/// A binding of a namespace, known once the program is analysed +struct Namespace { + local: String, + symbol: Option, + /// Where its declaration ends, after which the named imports it needs go + after: usize, + /// The barrel module it is a namespace of; `None` for the package + module: Option>, + source: String, +} + /// The names the rewritten file gives what it takes from namespaces #[derive(Default)] struct Generated { @@ -391,6 +454,9 @@ impl Generated { } } +mod aliases; +use aliases::{Reach, declarator_removal, export_edits, is_assigned_from}; + fn unreadable(local: &str, code: &str) -> String { format!( "`{local}` cannot use `{code}` at build time: read its members by name, as `{local}.css`, `{local}['css']` or `const {{ css }} = {local}`, or import them by name" @@ -447,9 +513,21 @@ struct Opaque<'p, 'a> { reason: String, } +/// A named or default import that is Devup UI: the binding, what it imports +/// from where, and what that is in the package +struct Bound<'p, 'a> { + binding: &'p BindingIdentifier<'a>, + /// Where the import declaration ends + after: usize, + source: String, + imported: String, + devup: (String, Option), +} + #[derive(Default)] struct Found<'p, 'a> { - namespaces: Vec>, + bound: Vec>, + namespaces: Vec>, opaque: Vec>, } @@ -482,7 +560,7 @@ fn redirect_import<'p, 'a>( ImportDeclarationSpecifier::ImportNamespaceSpecifier(namespace) => { kept.namespace = Some(namespace.local.name.as_str()); if let Some(module) = namespace_of(walker, source, filename) { - found.namespaces.push(Namespace { + found.namespaces.push(NsDecl { binding: &namespace.local, after, module: Some(module), @@ -495,8 +573,17 @@ fn redirect_import<'p, 'a>( match walker.import_origin(source, filename, &imported) { Origin::Devup { source: from, - imported, - } => redirected.push((imported, local.name.as_str(), from)), + imported: target, + } => { + found.bound.push(Bound { + binding: local, + after, + source: source.to_string(), + imported: imported.clone(), + devup: (from.clone(), target.clone()), + }); + redirected.push((target, local.name.as_str(), from)); + } origin => { if let Origin::Unfollowable(reason) = origin { found.opaque.push(Opaque { @@ -567,7 +654,7 @@ struct Rewriter<'s, 'w, 'r, 'p> { impl Rewriter<'_, '_, '_, '_> { /// Where reading `name` from `namespace` leads - fn member_origin(&mut self, namespace: &Namespace<'_, '_>, name: &str) -> Origin { + fn member_origin(&mut self, namespace: &Namespace, name: &str) -> Origin { match &namespace.module { None if !compiled_export(name) => Origin::Other, None => Origin::Devup { @@ -582,9 +669,9 @@ impl Rewriter<'_, '_, '_, '_> { } /// The value references of `binding`, as `(node, is written)` - fn references(&self, binding: &BindingIdentifier<'_>) -> Vec<(NodeId, bool)> { + fn references(&self, symbol: Option) -> Vec<(NodeId, bool)> { let scoping = self.semantic.scoping(); - binding.symbol_id.get().map_or_else(Vec::new, |symbol| { + symbol.map_or_else(Vec::new, |symbol| { scoping .get_resolved_reference_ids(symbol) .iter() @@ -610,9 +697,9 @@ impl Rewriter<'_, '_, '_, '_> { self.errors.push((start, unreadable(local, &code))); } - fn read_namespace(&mut self, namespace: &Namespace<'_, '_>) { + fn read_namespace(&mut self, namespace: &Namespace) { let mut appended: Vec = Vec::new(); - for (node, written) in self.references(namespace.binding) { + for (node, written) in self.references(namespace.symbol) { self.read_reference(namespace, &mut appended, node, written); } if !appended.is_empty() { @@ -626,7 +713,7 @@ impl Rewriter<'_, '_, '_, '_> { fn read_reference( &mut self, - namespace: &Namespace<'_, '_>, + namespace: &Namespace, appended: &mut Vec, node: NodeId, written: bool, @@ -634,7 +721,7 @@ impl Rewriter<'_, '_, '_, '_> { let nodes = self.semantic.nodes(); let span = nodes.kind(node).span(); let parent = nodes.parent_kind(node); - let local = namespace.binding.name.as_str(); + let local = namespace.local.as_str(); let member = match parent { AstKind::StaticMemberExpression(member) => { Some((member.span, member.property.name.to_string())) @@ -656,6 +743,8 @@ impl Rewriter<'_, '_, '_, '_> { let origin = self.member_origin(namespace, &name); self.replace(namespace, appended, member_span, &name, origin); } + (None, AstKind::VariableDeclarator(declarator)) + if matches!(declarator.id, BindingPattern::BindingIdentifier(_)) => {} (None, AstKind::VariableDeclarator(declarator)) if declarator .init @@ -675,7 +764,7 @@ impl Rewriter<'_, '_, '_, '_> { /// package gives it fn replace( &mut self, - namespace: &Namespace<'_, '_>, + namespace: &Namespace, appended: &mut Vec, span: oxc_span::Span, name: &str, @@ -685,7 +774,7 @@ impl Rewriter<'_, '_, '_, '_> { Origin::Devup { source, imported } => { let (local, statement) = self.generated.name( self.code, - namespace.binding.name.as_str(), + namespace.local.as_str(), &source, imported.as_deref(), ); @@ -694,7 +783,7 @@ impl Rewriter<'_, '_, '_, '_> { .push((span.start as usize, span.end as usize, local)); } Origin::Unfollowable(reason) => { - let code = format!("{}.{name}", namespace.binding.name); + let code = format!("{}.{name}", namespace.local); self.errors .push((span.start, unfollowable(&code, &namespace.source, &reason))); } @@ -706,12 +795,12 @@ impl Rewriter<'_, '_, '_, '_> { /// bound is read from the package where it is used fn destructure( &mut self, - namespace: &Namespace<'_, '_>, + namespace: &Namespace, appended: &mut Vec, declarator_id: NodeId, declarator: &oxc_ast::ast::VariableDeclarator<'_>, ) { - let local = namespace.binding.name.as_str(); + let local = namespace.local.as_str(); let whole = |rewriter: &mut Self| { rewriter.whole_error(local, declarator.span.start, declarator.span.end); }; @@ -764,18 +853,7 @@ impl Rewriter<'_, '_, '_, '_> { } _ => return whole(self), }; - let declarators = &declaration.declarations; - let index = declarators - .iter() - .position(|other| other.span == declarator.span) - .unwrap_or_default(); - let (start, end) = if declarators.len() == 1 { - (declaration.span.start, declaration.span.end) - } else if index + 1 < declarators.len() { - (declarator.span.start, declarators[index + 1].span.start) - } else { - (declarators[index - 1].span.end, declarator.span.end) - }; + let (start, end) = declarator_removal(declaration, declarator.span); self.edits .push((start as usize, end as usize, String::new())); for (name, binding, source, imported) in devup { @@ -783,7 +861,7 @@ impl Rewriter<'_, '_, '_, '_> { self.generated .name(self.code, local, &source, imported.as_deref()); appended.extend(statement); - for (node, written) in self.references(binding) { + for (node, written) in self.references(binding.symbol_id.get()) { self.replace_binding(local, name, &generated, node, written); } } @@ -864,9 +942,21 @@ pub(crate) fn rewrite( ImportDeclarationSpecifier::ImportNamespaceSpecifier(namespace) => { &namespace.local } - ImportDeclarationSpecifier::ImportSpecifier(_) => continue, + ImportDeclarationSpecifier::ImportSpecifier(named) => { + if !named.import_kind.is_type() { + let imported = named.imported.name().to_string(); + found.bound.push(Bound { + binding: &named.local, + after: import.span.end as usize, + source: import.source.value.to_string(), + devup: (import.source.value.to_string(), Some(imported.clone())), + imported, + }); + } + continue; + } }; - found.namespaces.push(Namespace { + found.namespaces.push(NsDecl { binding, after: import.span.end as usize, module: None, @@ -883,9 +973,32 @@ pub(crate) fn rewrite( )); } } - let (mut edits, errors) = if found.namespaces.is_empty() && found.opaque.is_empty() { - (edits, Vec::new()) - } else { + 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 + .bound + .iter() + .any(|bound| is_assigned_from(code, bound.binding.name.as_str())) + || (!found.bound.is_empty() && (code.contains("= keyframes(") || code.contains("= css("))); + let (mut edits, errors) = if wants_semantic { let semantic = SemanticBuilder::new() .with_build_nodes(true) .build(&program) @@ -900,7 +1013,7 @@ pub(crate) fn rewrite( }; for opaque in &found.opaque { let local = opaque.binding.name.as_str(); - for (node, _) in rewriter.references(opaque.binding) { + for (node, _) in rewriter.references(opaque.binding.symbol_id.get()) { let span = semantic.nodes().kind(node).span(); rewriter.errors.push(( span.start, @@ -908,10 +1021,25 @@ pub(crate) fn rewrite( )); } } - for namespace in &found.namespaces { + let mut spaces: Vec = found + .namespaces + .iter() + .map(|declared| Namespace { + local: declared.binding.name.to_string(), + symbol: declared.binding.symbol_id.get(), + after: declared.after, + module: declared.module.clone(), + source: declared.source.clone(), + }) + .collect(); + for namespace in &spaces { rewriter.read_namespace(namespace); } + rewriter.follow_aliases(&found.bound, &mut spaces); + rewriter.hoist_style_constants(&program, &found.bound); (rewriter.edits, rewriter.errors) + } else { + (edits, Vec::new()) }; if !errors.is_empty() { return Barreled::Failed(errors); diff --git a/libs/extractor/src/barrel/aliases.rs b/libs/extractor/src/barrel/aliases.rs new file mode 100644 index 000000000..04b89fa9f --- /dev/null +++ b/libs/extractor/src/barrel/aliases.rs @@ -0,0 +1,385 @@ +//! What a module binds to Devup UI under another name, and the style +//! constants it reads before they are declared + +use oxc_ast::AstKind; +use oxc_ast::ast::{ + BindingPattern, Declaration, ExportDefaultDeclarationKind, Expression, Program, Statement, + VariableDeclaration, VariableDeclarationKind, +}; +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}; + +/// Where a binding leads: the module it is read from, and the name read, `None` +/// for the module's namespace +pub(super) type Reach = (String, Option); + +/// The statement exporting `exported` as what `reach` names, from the module +/// the binding was imported from +fn reexport(exported: &str, reach: &Reach) -> String { + let quoted = serde_json::to_string(&reach.0).unwrap_or_default(); + match &reach.1 { + Some(imported) => format!("export {{ {imported} as {exported} }} from {quoted};"), + None => format!("export * as {exported} from {quoted};"), + } +} + +/// The text of `declaration`'s declarator at `span` removed from it: the +/// whole declaration when it is the only one +pub(super) fn declarator_removal(declaration: &VariableDeclaration<'_>, span: Span) -> (u32, u32) { + let declarators = &declaration.declarations; + let index = declarators + .iter() + .position(|other| other.span == span) + .unwrap_or_default(); + if declarators.len() == 1 { + (declaration.span.start, declaration.span.end) + } else if index + 1 < declarators.len() { + (span.start, declarators[index + 1].span.start) + } else { + (declarators[index - 1].span.end, span.end) + } +} + +/// Whether `name` is the value of an assignment in `code`, as in `= name` +pub(super) fn is_assigned_from(code: &str, name: &str) -> bool { + code.match_indices(name).any(|(index, _)| { + code[..index].trim_end().ends_with('=') + && !code[index + name.len()..] + .starts_with(|c: char| c.is_alphanumeric() || c == '_' || c == '$') + }) +} + +/// The edits that export Devup UI from a module as a re-export of the module +/// it came from: `export { Box }` after an import, `export default Box`, +/// `export const B = Box`. A module compiled with the import removed would +/// otherwise export a name nothing declares. +pub(super) fn export_edits( + program: &Program<'_>, + code: &str, + mut locals: FxHashMap, +) -> Vec<(usize, usize, String)> { + let mut edits = Vec::new(); + for statement in &program.body { + match statement { + Statement::VariableDeclaration(declaration) => { + alias_reaches(declaration, &mut locals); + } + Statement::ExportDeclaration(export) => { + if let Declaration::VariableDeclaration(declaration) = &export.declaration { + let aliases = alias_reaches(declaration, &mut locals); + if !aliases.is_empty() { + let mut statements: Vec = Vec::new(); + let others: Vec<&str> = declaration + .declarations + .iter() + .flat_map(|declarator| declarator.id.get_binding_identifiers()) + .map(|id| id.name.as_str()) + .filter(|name| aliases.iter().all(|(alias, _)| alias != name)) + .collect(); + if !others.is_empty() { + statements.push(format!("export {{ {} }};", others.join(", "))); + } + statements + .extend(aliases.iter().map(|(name, reach)| reexport(name, reach))); + edits.push(( + export.span.start as usize, + declaration.span.start as usize, + String::new(), + )); + edits.push(( + export.span.end as usize, + export.span.end as usize, + format!("\n{}", statements.join("\n")), + )); + } + } + } + Statement::ExportNamedDeclaration(export) if !export.export_kind.is_type() => { + let mut statements = Vec::new(); + let mut kept = Vec::new(); + for specifier in &export.specifiers { + let reach = locals.get(specifier.local.name().as_str()); + match reach { + Some(reach) if !specifier.export_kind.is_type() => { + statements.push(reexport(specifier.exported.name().as_str(), reach)); + } + _ => kept.push( + &code[specifier.span.start as usize..specifier.span.end as usize], + ), + } + } + if !statements.is_empty() { + if !kept.is_empty() { + statements.insert(0, format!("export {{ {} }};", kept.join(", "))); + } + edits.push(( + export.span.start as usize, + export.span.end as usize, + statements.join("\n"), + )); + } + } + Statement::ExportDefaultDeclaration(export) => { + if let ExportDefaultDeclarationKind::Identifier(name) = &export.declaration + && let Some(reach) = locals.get(name.name.as_str()) + && reach.1.is_some() + { + edits.push(( + export.span.start as usize, + export.span.end as usize, + reexport("default", reach), + )); + } + } + _ => {} + } + } + edits +} + +/// The `const` bindings of `declaration` that alias a local of Devup UI or a +/// member of its namespace, `(name, where it leads)`, added to `locals` +fn alias_reaches<'a>( + declaration: &VariableDeclaration<'a>, + locals: &mut FxHashMap, +) -> Vec<(&'a str, Reach)> { + let mut aliases = Vec::new(); + if declaration.kind != VariableDeclarationKind::Const { + return aliases; + } + for declarator in &declaration.declarations { + let BindingPattern::BindingIdentifier(id) = &declarator.id else { + continue; + }; + let reach = match &declarator.init { + Some(Expression::Identifier(init)) => locals.get(init.name.as_str()).cloned(), + Some(Expression::StaticMemberExpression(member)) => match &member.object { + Expression::Identifier(object) => match locals.get(object.name.as_str()) { + Some((source, None)) if compiled_export(member.property.name.as_str()) => { + Some((source.clone(), Some(member.property.name.to_string()))) + } + _ => None, + }, + _ => None, + }, + _ => None, + }; + if let Some(reach) = reach { + locals.insert(id.name.to_string(), reach.clone()); + aliases.push((id.name.as_str(), reach)); + } + } + aliases +} + +/// The style functions whose results a module may read before it declares them +const HOISTED: [&str; 2] = ["css", "keyframes"]; + +impl Rewriter<'_, '_, '_, '_> { + fn is_deferred(&self, node: NodeId) -> bool { + self.semantic.nodes().ancestor_kinds(node).any(|kind| { + matches!( + kind, + AstKind::Function(_) | AstKind::ArrowFunctionExpression(_) + ) + }) + } + + /// Follows what the module aliases: `const c = css` in a function reads + /// the package where it is used; `const D = Devup` is another namespace + pub(super) fn follow_aliases( + &mut self, + bound: &[Bound<'_, '_>], + namespaces: &mut Vec, + ) { + let scoping = self.semantic.scoping(); + let nodes = self.semantic.nodes(); + let mut named: FxHashMap, Reach)> = bound + .iter() + .filter_map(|bound| { + Some((bound.binding.symbol_id.get()?, (bound, bound.devup.clone()))) + }) + .collect(); + let mut spaces: FxHashMap = namespaces + .iter() + .enumerate() + .filter_map(|(index, namespace)| Some((namespace.symbol?, index))) + .collect(); + let first_new = namespaces.len(); + for node in nodes.iter() { + let AstKind::VariableDeclarator(declarator) = node.kind() else { + continue; + }; + let (BindingPattern::BindingIdentifier(id), Some(Expression::Identifier(init))) = + (&declarator.id, &declarator.init) + else { + continue; + }; + let declaration_id = nodes.parent_id(node.id()); + let AstKind::VariableDeclaration(declaration) = nodes.kind(declaration_id) else { + continue; + }; + let (Some(alias), Some(origin)) = ( + id.symbol_id.get(), + init.reference_id + .get() + .and_then(|reference| scoping.get_reference(reference).symbol_id()), + ) else { + continue; + }; + if declaration.kind != VariableDeclarationKind::Const { + continue; + } + if let Some(&index) = spaces.get(&origin) { + let origin = &namespaces[index]; + let namespace = Namespace { + local: id.name.to_string(), + symbol: Some(alias), + after: origin.after, + module: origin.module.clone(), + source: origin.source.clone(), + }; + spaces.insert(alias, namespaces.len()); + namespaces.push(namespace); + } else if let Some((origin, reach)) = named.get(&origin).cloned() { + named.insert(alias, (origin, reach.clone())); + let exported = matches!( + nodes.parent_kind(declaration_id), + AstKind::ExportDeclaration(_) + ); + if exported || scoping.symbol_scope_id(alias) == scoping.root_scope_id() { + continue; + } + let (name, statement) = + self.generated + .name(self.code, id.name.as_str(), &reach.0, reach.1.as_deref()); + let after = origin.after; + if let Some(statement) = statement { + self.edits.push((after, after, format!("\n{statement}"))); + } + for (node, written) in self.references(Some(alias)) { + self.replace_binding(id.name.as_str(), id.name.as_str(), &name, node, written); + } + let (start, end) = declarator_removal(declaration, declarator.span); + self.edits + .push((start as usize, end as usize, String::new())); + } + } + let added: Vec = namespaces.drain(first_new..).collect(); + for namespace in &added { + self.read_namespace(namespace); + } + namespaces.extend(added); + } + + /// Moves the `const` a function reads before its declaration, binding + /// what `css()` or `keyframes()` gives from literals only, above its first + /// read, as a read after a `const` runs sees it; a read that runs before + /// it is an error + pub(super) fn hoist_style_constants(&mut self, program: &Program<'_>, bound: &[Bound<'_, '_>]) { + let nodes = self.semantic.nodes(); + let scoping = self.semantic.scoping(); + let apis: FxHashMap = bound + .iter() + .filter_map(|bound| { + let api = bound.devup.1.as_deref()?; + HOISTED + .contains(&api) + .then(|| Some((bound.binding.symbol_id.get()?, api)))? + }) + .collect(); + let imports: Vec = bound + .iter() + .filter_map(|bound| bound.binding.symbol_id.get()) + .collect(); + for statement in &program.body { + let Some(Statement::VariableDeclaration(declaration)) = Some(statement) else { + continue; + }; + let [declarator] = declaration.declarations.as_slice() else { + continue; + }; + let ( + BindingPattern::BindingIdentifier(id), + Some(Expression::CallExpression(call)), + VariableDeclarationKind::Const, + ) = (&declarator.id, &declarator.init, declaration.kind) + else { + continue; + }; + let Expression::Identifier(callee) = &call.callee else { + continue; + }; + let Some(api) = callee + .reference_id + .get() + .and_then(|reference| scoping.get_reference(reference).symbol_id()) + .and_then(|symbol| apis.get(&symbol)) + else { + continue; + }; + let early: Vec<(NodeId, u32)> = self + .references(id.symbol_id.get()) + .into_iter() + .map(|(node, _)| (node, nodes.kind(node).span().start)) + .filter(|(_, start)| *start < declaration.span.start) + .collect(); + if early.is_empty() { + continue; + } + let name = id.name.as_str(); + if let Some((_, start)) = early.iter().find(|(node, _)| !self.is_deferred(*node)) { + self.errors.push(( + *start, + format!( + "`{api}()` cannot use `{name}` at build time: it is read before `const {name} = {api}(…)` runs, so move that declaration above where it is first read" + ), + )); + continue; + } + let literal_only = nodes.iter().all(|node| { + let AstKind::IdentifierReference(reference) = node.kind() else { + return true; + }; + let inside = + reference.span.start >= call.span.start && reference.span.end <= call.span.end; + !inside + || reference + .reference_id + .get() + .and_then(|reference| scoping.get_reference(reference).symbol_id()) + .is_none_or(|symbol| imports.contains(&symbol)) + }); + if !literal_only { + continue; + } + let first = early + .iter() + .map(|(_, start)| *start) + .min() + .unwrap_or_default(); + let Some(before) = program + .body + .iter() + .find(|statement| statement.span().end > first) + else { + continue; + }; + let text = &self.code[declaration.span.start as usize..declaration.span.end as usize]; + self.edits.push(( + before.span().start as usize, + before.span().start as usize, + format!("{text}\n"), + )); + self.edits.push(( + declaration.span.start as usize, + declaration.span.end as usize, + String::new(), + )); + } + } +} diff --git a/libs/extractor/src/barrel/tests.rs b/libs/extractor/src/barrel/tests.rs index 62ccc281d..d00fa112f 100644 --- a/libs/extractor/src/barrel/tests.rs +++ b/libs/extractor/src/barrel/tests.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::fs; use std::path::{Component, Path, PathBuf}; @@ -7,7 +8,7 @@ use serial_test::serial; use crate::{ ExtractOption, ExtractOutput, ModuleResolver, ResolvedModule, extract, extract_with_modules, - has_devup_ui_through, + has_devup_ui_with, }; /// A project of real files on disk, removed when dropped @@ -771,41 +772,28 @@ fn has_devup_ui_follows_barrels() { let resolver = project.resolver(); let resolver: &ModuleResolver = &resolver; let file = project.path("app.tsx"); - assert!(has_devup_ui_through( - &file, - "import { Box } from './ui'", - "@devup-ui/react", - resolver - )); - assert!(has_devup_ui_through( + let gate = |file: &str, code: &str, resolver: &ModuleResolver| { + has_devup_ui_with( + file, + code, + "@devup-ui/react", + &HashMap::new(), + Some(resolver), + ) + }; + assert!(gate(&file, "import { Box } from './ui'", resolver)); + assert!(gate( &file, "import { Box } from '@devup-ui/react'", - "@devup-ui/react", - resolver - )); - assert!(!has_devup_ui_through( - &file, - "import { x } from './plain'", - "@devup-ui/react", resolver )); - assert!(!has_devup_ui_through( - &file, - "const a = 1", - "@devup-ui/react", - resolver - )); - assert!(!has_devup_ui_through( - "app.invalid", - "import { Box } from './ui'", - "@devup-ui/react", - resolver - )); - assert!(has_devup_ui_through( + assert!(!gate(&file, "import { x } from './plain'", resolver)); + assert!(!gate(&file, "const a = 1", resolver)); + assert!(!gate("app.invalid", "import { Box } from './ui'", resolver)); + assert!(gate( &file, "import { Foo } from './unresolved'\nimport * as Devup from '@devup-ui/react'\nDevup.css", - "@devup-ui/react", - resolver, + resolver )); } @@ -872,3 +860,192 @@ fn namespace_styled_in_a_stylesheet_is_compiled_by_the_visitor() { assert!(!output.code.contains("Devup.styled"), "{}", output.code); assert_ne!(output.styles.len(), 0); } + +#[test] +fn gate_sees_files_only_extraction_of_aliases_changes() { + let aliases = HashMap::from([ + ( + "@emotion/react".to_string(), + crate::ImportAlias::NamedToNamed, + ), + ( + "@emotion/styled".to_string(), + crate::ImportAlias::DefaultToNamed("styled".to_string()), + ), + ]); + let gate = |file: &str, code: &str, aliases: &HashMap| { + has_devup_ui_with(file, code, "@devup-ui/react", aliases, None) + }; + assert!(gate( + "a.tsx", + "import styled from '@emotion/styled'\nexport const A = styled.div({})", + &aliases + )); + assert!(gate( + "a.tsx", + "/** @jsxImportSource @emotion/react */\nexport const a =
", + &aliases + )); + assert!(gate( + "a.tsx", + "import { css } from '@emotion/react'\nexport const a =
", + &aliases + )); + assert!(!gate( + "a.tsx", + "export const a =
", + &aliases + )); + assert!(!gate( + "a.tsx", + "import styled from '@emotion/styled'", + &HashMap::new() + )); + let jsx_runtime = HashMap::from([ + ( + "@emotion/react".to_string(), + crate::ImportAlias::NamedToNamed, + ), + ( + "@emotion/react/jsx-runtime".to_string(), + crate::ImportAlias::NamedToNamed, + ), + ]); + assert!(gate( + "a.tsx", + "export const a =
", + &jsx_runtime + )); +} + +#[test] +#[serial] +fn barrel_aliases_of_package_exports_are_followed_and_compile_themselves() { + let project = Project::new( + "alias-barrel", + &[ + ( + "ui.tsx", + "import { Box, css, Text } from '@devup-ui/react'\nimport * as Devup from '@devup-ui/react'\nexport const B = Box\nconst c = css\nexport { c as cc, Text }\nexport const D = Devup\nexport const C = Devup.css\nexport const helper = 1, F = Text\n", + ), + ( + "app.tsx", + "import { B, cc, Text, D, helper } from './ui'\nexport const a = \nexport const b = cc({ color: 'green' })\nexport const c = \nexport const d = helper\n", + ), + ( + "member.tsx", + "import { C } from './ui'\nexport const a = C({ color: 'red' })\n", + ), + ], + ); + let code = project.code("app.tsx"); + assert!(code.contains("
\nexport const b = D.css({{ color: 'blue' }})\nlet L = Devup\nexport const c = L\n" + )); + assert!(code.contains("
]\n}\nexport const g = (css) => { const x = css; return x }\n", + ); + assert!(!code.contains("inner"), "{code}"); + assert!(!code.contains("again"), "{code}"); + assert!(code.contains("export const g = (css) => {"), "{code}"); + assert!(code.contains("const x = css"), "{code}"); +} + +#[test] +#[serial] +fn aliases_of_a_barrels_import_in_functions_compile() { + let project = Project::new( + "alias-nested", + &[ + ("ui.ts", "export { css } from '@devup-ui/react'\n"), + ( + "app.tsx", + "import { css } from './ui'\nexport function f() {\n const inner = css\n return inner({ color: 'red' })\n}\n", + ), + ], + ); + let code = project.code("app.tsx"); + assert!(!code.contains("inner"), "{code}"); +} + +#[test] +#[serial] +fn style_constants_read_in_functions_before_their_declaration_compile() { + let code = namespace_code( + "import { css, keyframes, Box } from '@devup-ui/react'\nexport function f() {\n return css({ animation: `${k} 1s` })\n}\nexport const g = () => \nexport const h = () => later\nconst k = keyframes({ from: { opacity: 0 }, to: { opacity: 1 } })\nconst c = css({ color: 'red' })\nconst later = 1\n", + ); + assert!(!code.contains("keyframes("), "{code}"); + assert!(!code.contains("${k}"), "{code}"); +} + +#[test] +#[serial] +fn style_constants_read_before_they_run_are_located_errors() { + let error = namespace_error( + "import { css, keyframes } from '@devup-ui/react'\nexport const a = css({ animation: `${k} 1s` })\nconst k = keyframes({ from: { opacity: 0 } })\n", + ); + assert!( + error.contains("test.tsx:2:") && error.contains("move that declaration above"), + "{error}" + ); +} + +#[test] +#[serial] +fn style_constants_computed_from_other_bindings_stay_where_they_are() { + let error = namespace_error( + "import { css, keyframes } from '@devup-ui/react'\nexport function f() { return css({ animation: `${k} 1s` }) }\nconst frames = { from: { opacity: 0 } }\nconst k = keyframes(frames)\n", + ); + assert!(error.contains("test.tsx:2:"), "{error}"); +} diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index b0fae41ff..d8e1e1d1a 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -785,23 +785,37 @@ pub fn has_devup_ui(filename: &str, code: &str, package: &str) -> bool { false } -/// [`has_devup_ui`], also true for a file importing the package through a -/// project module that re-exports it, or reading members of a namespace import +/// Whether extraction does something to a file. +/// +/// It imports the package (also through a project module that re-exports it, +/// or as a namespace), imports a package `import_aliases` redirects, or gives +/// an element a `css` prop that the aliases compile. #[must_use] -pub fn has_devup_ui_through( +pub fn has_devup_ui_with( filename: &str, code: &str, package: &str, - resolver: &ModuleResolver, + import_aliases: &HashMap, + resolver: Option<&ModuleResolver>, ) -> bool { SourceType::from_path(filename).is_ok() && (has_devup_ui(filename, code, package) - || !matches!( - barrel::rewrite(code, filename, package, Some(resolver)), - barrel::Barreled::Unchanged - )) + || resolver.is_some_and(|resolver| { + !matches!( + barrel::rewrite(code, filename, package, Some(resolver)), + barrel::Barreled::Unchanged + ) + }) + || { + let aliased = import_alias_visit::transform_import_aliases_with_edits( + code, + filename, + package, + import_aliases, + ); + !aliased.edits.is_empty() || aliased.css_prop != css_prop::CssProp::Off + }) } - #[cfg(test)] #[allow(clippy::expect_used, clippy::unwrap_used)] mod tests { @@ -18518,26 +18532,10 @@ export const J = other(Base).attrs({ id: 'j' })({ margin: 4 });", assert_debug_snapshot!(outputs); for (code, name) in [ - ( - "import { css } from '@devup-ui/react';\nexport const myCss = css;", - "css", - ), - ( - "import { Box } from '@devup-ui/react';\nexport const C = Box;\nexport const d = ;", - "Box", - ), ( "import { Box } from '@devup-ui/react';\nexport const list = [Box].map((B) => );", "Box", ), - ( - "import { css } from '@devup-ui/react';\nexport function f() { const inner = css; return inner({ color: 'red' }); }", - "css", - ), - ( - "import { css } from '@devup-ui/react';\nconst myCss = css;\nexport const v = myCss;", - "myCss", - ), ( "import { css } from '@devup-ui/react';\nlet myCss = css;\nmyCss = null;", "myCss", diff --git a/packages/bun-plugin/src/__tests__/barrel.test.ts b/packages/bun-plugin/src/__tests__/barrel.test.ts index 49901774c..20df6a95e 100644 --- a/packages/bun-plugin/src/__tests__/barrel.test.ts +++ b/packages/bun-plugin/src/__tests__/barrel.test.ts @@ -2,7 +2,11 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' -import { createModuleResolver } from '@devup-ui/plugin-utils' +import { + createModuleResolver, + mergeImportAliases, + readJsxImportSource, +} from '@devup-ui/plugin-utils' import { codeExtract, hasDevupUI, @@ -28,6 +32,12 @@ const files: Record = { 'src/package.tsx': `import { Flex } from '@acme/ui'\nexport const a = \n`, 'src/namespace.tsx': `import * as UI from './ui'\nconst { css } = UI\nexport const a = \n`, 'src/unfollowable.tsx': `import { Grid, Missing } from './ui/broken'\nexport const a = \n`, + 'src/pragma.tsx': `/** @jsxImportSource @emotion/react */\nexport const a =
\n`, + 'emotion/tsconfig.json': JSON.stringify({ + compilerOptions: { jsxImportSource: '@emotion/react' }, + }), + 'emotion/runtime.tsx': `export const a =
\n`, + 'src/styled.tsx': `import styled from '@emotion/styled'\nexport const A = styled.div({ color: 'green' })\n`, 'src/plain.tsx': `import { helper } from './ui/more'\nexport const a = helper\n`, } @@ -40,7 +50,7 @@ function read(name: string) { } } -function extract(name: string) { +function extract(name: string, aliases = {}) { const { filename, code } = read(name) return codeExtract( filename, @@ -50,7 +60,7 @@ function extract(name: string) { true, false, false, - {}, + aliases, ) } @@ -80,7 +90,7 @@ describe('Devup UI re-exported by a project module', () => { 'is found in %s.tsx by the gate and compiled', (name) => { const { filename, code } = read(`src/${name}.tsx`) - expect(hasDevupUI(filename, code, '@devup-ui/react')).toBe(true) + expect(hasDevupUI(filename, code, '@devup-ui/react', {})).toBe(true) const output = extract(`src/${name}.tsx`) expect(output.code).toContain('
{ it('leaves a file that never reaches Devup UI to the gate', () => { const { filename, code } = read('src/plain.tsx') - expect(hasDevupUI(filename, code, '@devup-ui/react')).toBe(false) + expect(hasDevupUI(filename, code, '@devup-ui/react', {})).toBe(false) }) it('reports what it cannot follow where it is used', () => { const { filename, code } = read('src/unfollowable.tsx') - expect(hasDevupUI(filename, code, '@devup-ui/react')).toBe(true) + expect(hasDevupUI(filename, code, '@devup-ui/react', {})).toBe(true) expect(() => extract('src/unfollowable.tsx')).toThrow( /unfollowable\.tsx:2:19: `Missing` cannot use `\.\/ui\/broken` at build time/, ) }) }) + +describe('files only the Emotion css prop or an aliased import changes', () => { + const aliases = mergeImportAliases(undefined, undefined) + + it.each(['pragma', 'styled'])( + 'is found in %s.tsx by the gate and compiled', + (name) => { + const { filename, code } = read(`src/${name}.tsx`) + expect(hasDevupUI(filename, code, '@devup-ui/react', aliases)).toBe(true) + expect(hasDevupUI(filename, code, '@devup-ui/react', {})).toBe(false) + expect(extract(`src/${name}.tsx`, aliases).code).not.toContain('css={{') + }, + ) + + it('is found when the tsconfig builds JSX with Emotion', () => { + const tsconfigAliases = mergeImportAliases( + undefined, + readJsxImportSource(join(root, 'emotion')), + ) + const { filename, code } = read('emotion/runtime.tsx') + expect(hasDevupUI(filename, code, '@devup-ui/react', tsconfigAliases)).toBe( + true, + ) + expect(extract('emotion/runtime.tsx', tsconfigAliases).code).not.toContain( + 'css={{', + ) + }) +}) diff --git a/packages/bun-plugin/src/plugin.ts b/packages/bun-plugin/src/plugin.ts index 81edd30ae..12ddd4c1f 100644 --- a/packages/bun-plugin/src/plugin.ts +++ b/packages/bun-plugin/src/plugin.ts @@ -90,7 +90,7 @@ async function loadSourceFile(filePath: string) { : 'js' const contents = await Bun.file(filePath).text() - if (hasDevupUI(filePath, contents, libPackage)) { + if (hasDevupUI(filePath, contents, libPackage, importAliases)) { const code = codeExtract( filePath, contents, diff --git a/packages/next-plugin/src/__tests__/loader.test.ts b/packages/next-plugin/src/__tests__/loader.test.ts index 4586bb962..bf0229381 100644 --- a/packages/next-plugin/src/__tests__/loader.test.ts +++ b/packages/next-plugin/src/__tests__/loader.test.ts @@ -340,6 +340,45 @@ export const a = ` }) }) + it('extracts a file that only the Emotion css prop compiles (pragma, no Devup UI import)', async () => { + const source = `/** @jsxImportSource @emotion/react */ +export const a =
` + const asyncCallback = mock() + const t = { + getOptions: () => ({ + package: 'package', + cssDir: 'cssFile', + watch: false, + singleCss: true, + defaultClassMap: {}, + defaultFileMap: {}, + defaultSheet: {}, + }), + async: mock().mockReturnValue(asyncCallback), + resourcePath: 'index.tsx', + addDependency: mock(), + } + codeExtractSpy.mockReturnValue({ + code: '
', + css: undefined, + free: mock(), + map: undefined, + cssFile: undefined, + updatedBaseStyle: false, + [Symbol.dispose]: mock(), + }) + devupUILoader.bind(asLoaderContext(t))(Buffer.from(source), 'index.tsx') + + expect(codeExtractSpy.mock.calls.at(-1)?.[1]).toBe(source) + await waitFor(() => { + expect(asyncCallback).toHaveBeenCalledWith( + null, + '
', + null, + ) + }) + }) + it('should handle error in build mode', async () => { const asyncCallback = mock() const t = { diff --git a/packages/rsbuild-plugin/src/__tests__/plugin.test.ts b/packages/rsbuild-plugin/src/__tests__/plugin.test.ts index 07e19edf8..8a295eb19 100644 --- a/packages/rsbuild-plugin/src/__tests__/plugin.test.ts +++ b/packages/rsbuild-plugin/src/__tests__/plugin.test.ts @@ -353,6 +353,36 @@ export const a = ` }) expect(codeExtractSpy.mock.calls.at(-1)?.[1]).toBe(source) }) + + it('transforms a file that only the Emotion css prop compiles (pragma, no Devup UI import)', async () => { + const source = `/** @jsxImportSource @emotion/react */ +export const a =
` + const plugin = DevupUI() + const transform = mock() + await plugin.setup( + createSetupContext({ + transform, + modifyRsbuildConfig: mock(), + }), + ) + codeExtractSpy.mockReturnValue( + createCodeExtractResult({ + code: '
', + css: '', + cssFile: 'devup-ui.css', + }), + ) + await expect( + transform.mock.calls[1][1]({ + code: source, + resourcePath: 'src/App.tsx', + }), + ).resolves.toEqual({ + code: '
', + map: undefined, + }) + expect(codeExtractSpy.mock.calls.at(-1)?.[1]).toBe(source) + }) it.each( createTestMatrix({ updatedBaseStyle: [true, false], diff --git a/packages/vite-plugin/src/__tests__/plugin.test.ts b/packages/vite-plugin/src/__tests__/plugin.test.ts index b2e274765..36d2d9915 100644 --- a/packages/vite-plugin/src/__tests__/plugin.test.ts +++ b/packages/vite-plugin/src/__tests__/plugin.test.ts @@ -1204,6 +1204,26 @@ export const a = ` expect(codeExtractSpy.mock.calls.at(-1)?.[1]).toBe(source) }) + it('extracts a file that only the Emotion css prop compiles (pragma, no Devup UI import)', async () => { + const source = `/** @jsxImportSource @emotion/react */ +export const a =
` + codeExtractSpy.mockReturnValue( + createCodeExtractResult({ + code: '
', + css: '', + cssFile: undefined, + map: undefined, + updatedBaseStyle: false, + }), + ) + const plugin = createPlugin({}) + + expect(await plugin.transform(source, 'src/App.tsx')).toEqual({ + code: '
', + }) + expect(codeExtractSpy.mock.calls.at(-1)?.[1]).toBe(source) + }) + it('sholud add relative path to css file', async () => { getCssSpy.mockReturnValue('css code') codeExtractSpy.mockReturnValue( diff --git a/packages/webpack-plugin/src/__tests__/loader.test.ts b/packages/webpack-plugin/src/__tests__/loader.test.ts index 0a60962eb..ff2d0a435 100644 --- a/packages/webpack-plugin/src/__tests__/loader.test.ts +++ b/packages/webpack-plugin/src/__tests__/loader.test.ts @@ -309,6 +309,40 @@ export const a = ` }) }) + it('extracts a file that only the Emotion css prop compiles (pragma, no Devup UI import)', async () => { + const source = `/** @jsxImportSource @emotion/react */ +export const a =
` + const asyncCallback = mock() + const t = createLoaderContext( + { + package: 'package', + cssDir: 'cssFile', + watch: false, + singleCss: true, + }, + asyncCallback, + ) + codeExtractSpy.mockReturnValue( + createCodeExtractResult({ + code: '
', + css: undefined, + map: undefined, + cssFile: undefined, + updatedBaseStyle: false, + }), + ) + devupUILoader.bind(t)(Buffer.from(source), 'index.tsx') + + expect(codeExtractSpy.mock.calls.at(-1)?.[1]).toBe(source) + await waitFor(() => { + expect(asyncCallback).toHaveBeenCalledWith( + null, + '
', + null, + ) + }) + }) + it('should handle error', async () => { const asyncCallback = mock() const t = createLoaderContext( From 4830601da2abdd12f9213e519cdedc64354e4933 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sat, 3 Oct 2026 22:37:46 +0900 Subject: [PATCH 4/5] test(extractor): cover alias forms that are not package exports Refs #695 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/barrel.rs | 38 ++++++++++++--------- libs/extractor/src/barrel/aliases.rs | 49 +++++++++++++++------------- libs/extractor/src/barrel/tests.rs | 21 ++++++++++++ 3 files changed, 69 insertions(+), 39 deletions(-) diff --git a/libs/extractor/src/barrel.rs b/libs/extractor/src/barrel.rs index 2d4015b51..5b59a7932 100644 --- a/libs/extractor/src/barrel.rs +++ b/libs/extractor/src/barrel.rs @@ -13,7 +13,7 @@ use oxc_allocator::Allocator; use oxc_ast::AstKind; use oxc_ast::ast::{ BindingIdentifier, BindingPattern, Declaration, ExportDefaultDeclarationKind, Expression, - ImportDeclarationSpecifier, Statement, VariableDeclarationKind, + ImportDeclarationSpecifier, Statement, StaticMemberExpression, VariableDeclarationKind, }; use oxc_parser::{Parser, ParserReturn}; use oxc_semantic::{Semantic, SemanticBuilder}; @@ -197,6 +197,26 @@ fn analyze(module: &ResolvedModule, package: &str) -> Exports { exports } +/// Where `Namespace.member` leads, for a namespace a module imports +fn member_link( + member: &StaticMemberExpression<'_>, + imports: &FxHashMap<&str, Link>, +) -> Option { + if let Expression::Identifier(object) = &member.object + && let Some(Link::From { + source, + imported: None, + }) = imports.get(object.name.as_str()) + { + Some(Link::From { + source: source.clone(), + imported: Some(member.property.name.to_string()), + }) + } else { + None + } +} + /// The bindings a `const` declaration makes of what a module imports /// (`const B = Box`, `const C = Devup.css`), with where each leads fn alias_links<'a>( @@ -215,21 +235,7 @@ fn alias_links<'a>( }; let link = match declarator.init.as_ref()? { Expression::Identifier(init) => imports.get(init.name.as_str())?.clone(), - Expression::StaticMemberExpression(member) => { - let Expression::Identifier(object) = &member.object else { - return None; - }; - match imports.get(object.name.as_str())? { - Link::From { - source, - imported: None, - } => Link::From { - source: source.clone(), - imported: Some(member.property.name.to_string()), - }, - _ => return None, - } - } + Expression::StaticMemberExpression(member) => member_link(member, imports)?, _ => return None, }; Some((id.name.as_str(), link)) diff --git a/libs/extractor/src/barrel/aliases.rs b/libs/extractor/src/barrel/aliases.rs index 04b89fa9f..035fa23f2 100644 --- a/libs/extractor/src/barrel/aliases.rs +++ b/libs/extractor/src/barrel/aliases.rs @@ -4,7 +4,7 @@ use oxc_ast::AstKind; use oxc_ast::ast::{ BindingPattern, Declaration, ExportDefaultDeclarationKind, Expression, Program, Statement, - VariableDeclaration, VariableDeclarationKind, + StaticMemberExpression, VariableDeclaration, VariableDeclarationKind, }; use oxc_span::{GetSpan, Span}; use oxc_syntax::node::NodeId; @@ -141,6 +141,22 @@ pub(super) fn export_edits( edits } +/// Where `Namespace.member` leads, when `Namespace` is a local of Devup UI and +/// the build compiles `member` away +fn namespace_member_reach( + member: &StaticMemberExpression<'_>, + locals: &FxHashMap, +) -> Option { + if let Expression::Identifier(object) = &member.object + && let Some((source, None)) = locals.get(object.name.as_str()) + && compiled_export(member.property.name.as_str()) + { + Some((source.clone(), Some(member.property.name.to_string()))) + } else { + None + } +} + /// The `const` bindings of `declaration` that alias a local of Devup UI or a /// member of its namespace, `(name, where it leads)`, added to `locals` fn alias_reaches<'a>( @@ -157,15 +173,9 @@ fn alias_reaches<'a>( }; let reach = match &declarator.init { Some(Expression::Identifier(init)) => locals.get(init.name.as_str()).cloned(), - Some(Expression::StaticMemberExpression(member)) => match &member.object { - Expression::Identifier(object) => match locals.get(object.name.as_str()) { - Some((source, None)) if compiled_export(member.property.name.as_str()) => { - Some((source.clone(), Some(member.property.name.to_string()))) - } - _ => None, - }, - _ => None, - }, + Some(Expression::StaticMemberExpression(member)) => { + namespace_member_reach(member, locals) + } _ => None, }; if let Some(reach) = reach { @@ -220,10 +230,8 @@ impl Rewriter<'_, '_, '_, '_> { continue; }; let declaration_id = nodes.parent_id(node.id()); - let AstKind::VariableDeclaration(declaration) = nodes.kind(declaration_id) else { - continue; - }; - let (Some(alias), Some(origin)) = ( + let (Some(declaration), Some(alias), Some(origin)) = ( + nodes.kind(declaration_id).as_variable_declaration(), id.symbol_id.get(), init.reference_id .get() @@ -362,19 +370,14 @@ impl Rewriter<'_, '_, '_, '_> { .map(|(_, start)| *start) .min() .unwrap_or_default(); - let Some(before) = program + let before = program .body .iter() .find(|statement| statement.span().end > first) - else { - continue; - }; + .map_or(declaration.span.start, |statement| statement.span().start); let text = &self.code[declaration.span.start as usize..declaration.span.end as usize]; - self.edits.push(( - before.span().start as usize, - before.span().start as usize, - format!("{text}\n"), - )); + self.edits + .push((before as usize, before as usize, format!("{text}\n"))); self.edits.push(( declaration.span.start as usize, declaration.span.end as usize, diff --git a/libs/extractor/src/barrel/tests.rs b/libs/extractor/src/barrel/tests.rs index d00fa112f..d509e81d8 100644 --- a/libs/extractor/src/barrel/tests.rs +++ b/libs/extractor/src/barrel/tests.rs @@ -1049,3 +1049,24 @@ fn style_constants_computed_from_other_bindings_stay_where_they_are() { ); assert!(error.contains("test.tsx:2:"), "{error}"); } + +#[test] +#[serial] +fn aliases_that_are_not_package_exports_stay_declarations() { + let project = Project::new( + "alias-other", + &[ + ( + "ui.tsx", + "import { Box } from '@devup-ui/react'\nimport * as Devup from '@devup-ui/react'\nexport const N1 = Devup.css.name\nexport const N2 = Box.displayName\nexport const N3 = Devup.useTheme\nexport const N4 = Math.max\nexport const N5 = other.value\n", + ), + ( + "app.tsx", + "import { N1, N2, N3, N4, N5 } from './ui'\nexport const a = [N1, N2, N3, N4, N5]\n", + ), + ], + ); + let code = project.code("app.tsx"); + assert!(code.contains("N1"), "{code}"); + assert!(project.error("ui.tsx").contains("ui.tsx:3:")); +} From f66b4c582f97843cddc36f574bdab944692fe393 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 8 Oct 2026 16:57:34 +0900 Subject: [PATCH 5/5] fix(bun-plugin): keep local barrel consumers compiled after main update The automatic merge of main 11790bef replaced the Bun source gate with a direct package scanner, which missed PR 737 local barrel imports. Keep that scanner for main's StyleX/alias behavior and add the original three-argument resolver-aware hasDevupUI fallback. Add a real-process Box/css barrel regression; fresh WASM/root builds and all 7 Bun stylesheet regressions pass. No new build errors or runtime styling are introduced. Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../bun-plugin/__regression__/css-emit.bun.ts | 40 +++++++++++++++++++ packages/bun-plugin/src/plugin.ts | 6 ++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/packages/bun-plugin/__regression__/css-emit.bun.ts b/packages/bun-plugin/__regression__/css-emit.bun.ts index 7a39652cd..a7f206fb3 100644 --- a/packages/bun-plugin/__regression__/css-emit.bun.ts +++ b/packages/bun-plugin/__regression__/css-emit.bun.ts @@ -160,6 +160,46 @@ for (const width of [201, 203]) expect(css).toContain('width:' + width + 'px') } }) +it('compiles Box and css when the runtime consumer imports a local barrel', () => { + const cwd = mkdtempSync(join(tmpdir(), 'devup-css-emit-')) + try { + writeFileSync( + join(cwd, 'ui.ts'), + `export { Box, css } from '@devup-ui/react'\n`, + ) + writeFileSync( + join(cwd, 'consumer.tsx'), + `/** @jsxRuntime classic */ +/** @jsx createElement */ +import { Box, css } from './ui' + +function createElement(type: string, props: { readonly className: string }) { + return { type, props } +} +export const cls = css({ width: '401px' }) +export const box = +`, + ) + run( + cwd, + `import { readFileSync } from 'node:fs' +import { expect } from 'bun:test' + +await import(${JSON.stringify(pluginEntry.replaceAll('\\', '/'))}) +const { cls, box } = await import('./consumer.tsx') +expect(typeof cls).toBe('string') +expect(cls.length).toBeGreaterThan(0) +expect(box.type).toBe('div') +expect(box.props.className.length).toBeGreaterThan(0) +const css = readFileSync('./df/devup-ui/devup-ui.css', 'utf-8') +for (const width of [401, 403]) expect(css).toContain('width:' + width + 'px') +`, + ) + } finally { + rmSync(cwd, { recursive: true, force: true }) + } +}) + it('emits vanilla-extract styles through the WASM engine', () => { const cwd = mkdtempSync(join(tmpdir(), 'devup-css-emit-')) try { diff --git a/packages/bun-plugin/src/plugin.ts b/packages/bun-plugin/src/plugin.ts index 7bba14105..ac7a8fa07 100644 --- a/packages/bun-plugin/src/plugin.ts +++ b/packages/bun-plugin/src/plugin.ts @@ -15,6 +15,7 @@ import { codeExtract, getCss, getThemeInterface, + hasDevupUI, registerShorthands, registerTheme, setDebug, @@ -118,7 +119,10 @@ async function loadSourceFile(filePath: string, bundling: boolean) { : 'js' const contents = await Bun.file(filePath).text() - if (importsCompiledPackage(contents, loader)) { + if ( + importsCompiledPackage(contents, loader) || + hasDevupUI(filePath, contents, libPackage) + ) { const code = codeExtract( filePath, contents,