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 3035834a1..de307e8fd 100644 --- a/bindings/devup-ui-wasm/src/lib.rs +++ b/bindings/devup-ui-wasm/src/lib.rs @@ -10,7 +10,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; @@ -549,6 +549,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)] @@ -730,7 +753,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)] @@ -1400,6 +1423,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..62ccc281d --- /dev/null +++ b/libs/extractor/src/barrel/tests.rs @@ -0,0 +1,874 @@ +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}"); +} + +#[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); +} diff --git a/libs/extractor/src/barrel_pipeline_tests.rs b/libs/extractor/src/barrel_pipeline_tests.rs new file mode 100644 index 000000000..7293f7934 --- /dev/null +++ b/libs/extractor/src/barrel_pipeline_tests.rs @@ -0,0 +1,161 @@ +use crate::dead_properties_test_utils::{failure, position}; +use crate::{ExtractOption, ImportAlias, ResolvedModule, extract_with_modules}; +use css::{class_map::reset_class_map, file_map::reset_file_map}; +use rstest::rstest; +use serial_test::serial; + +fn resolver(specifier: &str, _: &str) -> Option { + match specifier { + "./ui" => Some(ResolvedModule { + path: "/src/ui.ts".to_string(), + code: "export { css, Box } from '@devup-ui/react';".to_string(), + }), + _ => None, + } +} + +#[test] +#[serial] +fn companion_import_is_rejected_when_barrel_edits_are_needed() { + // Given: rewriting the first import must not bypass companion validation. + reset_class_map(); + reset_file_map(); + let source = "import { Box } from './ui';\nimport { recipe } from '@vanilla-extract/recipes';\nexport const a = ;"; + let option = ExtractOption { + import_aliases: std::collections::HashMap::from([( + "@vanilla-extract/css".to_string(), + ImportAlias::NamedToNamed, + )]), + ..ExtractOption::default() + }; + + // When + let error = failure(extract_with_modules( + "/src/app.tsx", + source, + option, + false, + &resolver, + )); + + // Then: the original import site, not the rewritten source, is reported. + assert!( + error.starts_with("/src/app.tsx:2:1: `@vanilla-extract/recipes`"), + "{error}" + ); +} + +#[rstest] +#[case("import { css } from './ui';", "css")] +#[case("import * as UI from './ui';", "UI.css")] +#[serial] +fn literal_dead_declaration_keeps_its_key_when_only_barrel_edits_exist( + #[case] import: &str, + #[case] api: &str, +) { + // Given: no Boa value evaluation is needed for an authored dead property. + reset_class_map(); + reset_file_map(); + let statement = format!("export const a = {api}({{ boxOrient: 'vertical' }});"); + let source = format!("{import}\n{statement}"); + let column = position(&statement, "boxOrient") + 1; + + // When + let error = failure(extract_with_modules( + "/src/app.tsx", + &source, + ExtractOption::default(), + false, + &resolver, + )); + + // Then: mapping edits alone must not snap a key diagnostic to its call. + assert!( + error.starts_with(&format!("/src/app.tsx:2:{column}:")), + "{error}" + ); + assert!(error.contains("boxOrient"), "{error}"); +} + +#[rstest] +#[case("import { css } from './ui';", "css")] +#[case("import * as UI from './ui';", "UI.css")] +#[serial] +fn computed_dead_declaration_keeps_its_call_when_barrel_and_boa_edit_source( + #[case] import: &str, + #[case] api: &str, +) { + // Given: the dead property exists only after evaluating the style argument. + reset_class_map(); + reset_file_map(); + let source = format!( + "{import}\nfunction rules() {{ return {{ boxOrient: 'vertical' }}; }}\nexport const a = {api}(rules());" + ); + + // When + let error = failure(extract_with_modules( + "/src/app.tsx", + &source, + ExtractOption::default(), + false, + &resolver, + )); + + // Then: the evaluated declaration belongs to its authored styling call. + assert!(error.starts_with("/src/app.tsx:3:18:"), "{error}"); + assert!(error.contains("boxOrient"), "{error}"); +} + +#[test] +#[serial] +fn source_map_and_units_survive_barrel_namespace_alias_and_boa_layers() +-> Result<(), Box> { + // Given: both factories require evaluation, but retain their own number units. + reset_class_map(); + reset_file_map(); + let source = "import * as UI from './ui';\nimport * as E from '@emotion/css';\nfunction double(n) { return n * 2; }\nexport const a = UI.css({ p: double(4) });\nexport const b = E.css({ padding: double(4) });\nexport const after = 1;"; + let option = ExtractOption { + import_aliases: std::collections::HashMap::from([( + "@emotion/css".to_string(), + ImportAlias::NamedToNamed, + )]), + ..ExtractOption::default() + }; + + // When + let output = extract_with_modules("/src/app.tsx", source, option, true, &resolver)?; + + // Then: both computed atoms, dependencies and authored map coordinates survive. + let mut padding: Vec<_> = output + .styles + .iter() + .filter_map(|value| match value { + crate::ExtractStyleValue::Static(style) if style.property() == "padding" => { + Some(style.value()) + } + _ => None, + }) + .collect(); + padding.sort_unstable(); + assert_eq!(padding, vec!["32px", "8px"]); + assert_eq!(output.dependencies, vec!["/src/ui.ts"]); + let map = + oxc_sourcemap::SourceMap::from_json_string(output.map.as_deref().ok_or("missing map")?)?; + assert_eq!(map.get_source_content(0), Some(source)); + let (line, generated) = output + .code + .lines() + .enumerate() + .find(|(_, line)| line.contains("after")) + .ok_or("missing generated declaration")?; + let column = position(generated, "after"); + let token = map + .get_tokens() + .find(|token| { + usize::try_from(token.get_dst_line()) == Ok(line) + && usize::try_from(token.get_dst_col()) == Ok(column) + }) + .ok_or("missing declaration token")?; + assert_eq!((token.get_src_line(), token.get_src_col()), (5, 13)); + Ok(()) +} diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 10f315d47..36313f850 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -1,4 +1,7 @@ mod as_visit; +mod barrel; +#[cfg(test)] +mod barrel_pipeline_tests; mod build_time_values; mod class_arguments; #[cfg(test)] @@ -359,15 +362,17 @@ fn extract_with_source_map( let allocator = Allocator::default(); parsing::parse(&allocator, filename, code)?; } - extract_source(filename, code, None, option, source_map, resolver) + extract_source(filename, code, None, None, 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` holds the +/// authored styling-call ranges only after build-time value evaluation fn extract_source( filename: &str, code: &str, evaluated: Option<(&str, &[&[import_alias_visit::Edit]])>, + values_run: Option<&[(usize, usize)]>, option: ExtractOption, source_map: bool, resolver: Option<&ModuleResolver>, @@ -379,6 +384,30 @@ fn extract_source( return Err(located_errors(filename, code, &[], errors).into()); } } + 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()])), + None, + 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); + } + } + } let (namespace_code, namespace_edits) = emotion_namespace::normalize(code, filename, &option.import_aliases).map_err(|errors| { let (source, edits) = evaluated.unwrap_or((code, &[])); @@ -630,7 +659,7 @@ fn extract_source( .errors .iter() .any(|(_, error)| dead_properties::terminal_error(error)) - && evaluated.is_none() + && values_run.is_none() && !utils::is_vanilla_extract_file(filename) && let Some((computed, value_edits, read)) = build_time_values::evaluate( &transformed_code, @@ -645,18 +674,32 @@ fn extract_source( } else { import_alias_visit::pixelify_emotion_values(&computed, filename, &namespace_code) }; + let (source, earlier_edits) = evaluated.unwrap_or((code, &[])); + let calls: Vec<_> = dead_properties::evaluated_calls(code, filename, &option) + .into_iter() + .map(|(start, end)| { + let authored = |offset| { + earlier_edits.iter().fold(offset, |offset, edits| { + import_alias_visit::source_offset(edits, offset) + }) + }; + (authored(start), authored(end)) + }) + .collect(); + let layers: Vec<&[import_alias_visit::Edit]> = [ + number_edits.as_slice(), + value_edits.as_slice(), + alias_edits.as_slice(), + namespace_edits.as_slice(), + ] + .into_iter() + .chain(earlier_edits.iter().copied()) + .collect(); let mut output = extract_source( filename, &computed, - Some(( - code, - &[ - number_edits.as_slice(), - value_edits.as_slice(), - alias_edits.as_slice(), - namespace_edits.as_slice(), - ], - )), + Some((source, &layers)), + Some(&calls), option, source_map, resolver, @@ -676,9 +719,8 @@ fn extract_source( visitor.errors.append(&mut visitor.unknown_parts); if !visitor.errors.is_empty() { let mut errors = visitor.errors; - let error_edits = if evaluated.is_some() { - let calls = dead_properties::evaluated_calls(source, filename, &option); - dead_properties::map_evaluated(&mut errors, &edits, &calls)?; + let error_edits = if let Some(calls) = values_run { + dead_properties::map_evaluated(&mut errors, &edits, calls)?; &[][..] } else { edits.as_slice() @@ -946,6 +988,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 { + parsing::source_type(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 012a98488..ed0e664c5 100644 --- a/libs/extractor/src/module_loader.rs +++ b/libs/extractor/src/module_loader.rs @@ -485,7 +485,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 3cbab3fa2..5408491ff 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)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--d 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)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--k 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)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--d 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)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--k at\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n", } 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/__tests__/barrel.test.ts b/packages/bun-plugin/src/__tests__/barrel.test.ts new file mode 100644 index 000000000..7a744195e --- /dev/null +++ b/packages/bun-plugin/src/__tests__/barrel.test.ts @@ -0,0 +1,108 @@ +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, beforeEach, 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) + } +}) + +// The shared test setup resets the engine around every test, so each test sets its own +// theme and resolver +beforeEach(() => { + 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/bun-plugin/src/plugin.ts b/packages/bun-plugin/src/plugin.ts index a742e2452..a8ab5afb5 100644 --- a/packages/bun-plugin/src/plugin.ts +++ b/packages/bun-plugin/src/plugin.ts @@ -22,6 +22,7 @@ import { codeExtract, getCss, getThemeInterface, + hasDevupUI, registerShorthands, registerTheme, reserveClassSlots, @@ -138,7 +139,8 @@ async function loadSourceFile(filePath: string, bundling: boolean) { if ( isProjectJsx(filePath, importAliases) || - importsCompiledPackage(contents, loader) + importsCompiledPackage(contents, loader) || + hasDevupUI(filePath, contents, libPackage) ) { const code = codeExtract( filePath, diff --git a/packages/next-plugin/src/__tests__/loader.test.ts b/packages/next-plugin/src/__tests__/loader.test.ts index 15a8bf7af..a11be0eb7 100644 --- a/packages/next-plugin/src/__tests__/loader.test.ts +++ b/packages/next-plugin/src/__tests__/loader.test.ts @@ -305,6 +305,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 52f50039a..34852bc88 100644 --- a/packages/rsbuild-plugin/src/__tests__/plugin.test.ts +++ b/packages/rsbuild-plugin/src/__tests__/plugin.test.ts @@ -333,6 +333,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 ec5fc3081..9bdb159f7 100644 --- a/packages/vite-plugin/src/__tests__/plugin.test.ts +++ b/packages/vite-plugin/src/__tests__/plugin.test.ts @@ -1697,6 +1697,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 e5b1d07c7..df1b88800 100644 --- a/packages/webpack-plugin/src/__tests__/loader.test.ts +++ b/packages/webpack-plugin/src/__tests__/loader.test.ts @@ -285,6 +285,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(