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/.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 3035834a1..36c4bf5e0 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_with, }; use rustc_hash::FxHashSet; use sheet::StyleSheet; @@ -549,6 +549,15 @@ 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) + })) +} + /// Extract with the resolver set by `setModuleResolver`, if any #[cfg(not(tarpaulin_include))] #[allow(clippy::too_many_arguments)] @@ -729,8 +738,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(filename, code, package) +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)] @@ -1375,31 +1396,40 @@ mod tests { } #[test] - #[serial] - fn test_has_devup_ui_wasm_function() { - let _state = TestStateGuard::new(); - // Test positive case - assert!(has_devup_ui_wasm( - "test.tsx", + 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 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';", - "@devup-ui/react" - )); - - // Test negative case - assert!(!has_devup_ui_wasm( - "test.tsx", - "const x = 1;", - "@devup-ui/react" + None )); - - // Test invalid extension - assert!(!has_devup_ui_wasm( - "test.invalid", + assert!(!check("a.tsx", "const x = 1;", None)); + assert!(!check( + "a.invalid", "import { Box } from '@devup-ui/react';", - "@devup-ui/react" + None + )); + assert!(check( + "a.tsx", + "import { Box } from './ui';", + Some(&resolver) + )); + 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 new file mode 100644 index 000000000..5b59a7932 --- /dev/null +++ b/libs/extractor/src/barrel.rs @@ -0,0 +1,1074 @@ +//! 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, Declaration, ExportDefaultDeclarationKind, Expression, + ImportDeclarationSpecifier, Statement, StaticMemberExpression, 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; +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::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 { + ExportDefaultDeclarationKind::Identifier(name) => { + imports.get(name.name.as_str()).cloned() + } + _ => None, + }; + exports + .named + .insert("default".to_string(), link.unwrap_or(Link::Own)); + } + _ => {} + } + } + 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>( + 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) => member_link(member, imports)?, + _ => 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, + 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 NsDecl<'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, +} + +/// 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 { + 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)) + } +} + +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" + ) +} + +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, +} + +/// 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> { + bound: Vec>, + 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(NsDecl { + binding: &namespace.local, + after, + module: Some(module), + source: source.to_string(), + }); + } + continue; + } + }; + match walker.import_origin(source, filename, &imported) { + Origin::Devup { + source: 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 { + 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, symbol: Option) -> Vec<(NodeId, bool)> { + let scoping = self.semantic.scoping(); + symbol.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.symbol) { + 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.local.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 matches!(declarator.id, BindingPattern::BindingIdentifier(_)) => {} + (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.local.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.local); + 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.local.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 (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 { + let (generated, statement) = + self.generated + .name(self.code, local, &source, imported.as_deref()); + appended.extend(statement); + for (node, written) in self.references(binding.symbol_id.get()) { + 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(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(NsDecl { + 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 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) + .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.symbol_id.get()) { + let span = semantic.nodes().kind(node).span(); + rewriter.errors.push(( + span.start, + unfollowable(local, &opaque.source, &opaque.reason), + )); + } + } + 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); + } + 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/aliases.rs b/libs/extractor/src/barrel/aliases.rs new file mode 100644 index 000000000..035fa23f2 --- /dev/null +++ b/libs/extractor/src/barrel/aliases.rs @@ -0,0 +1,388 @@ +//! 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, + StaticMemberExpression, 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 +} + +/// 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>( + 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)) => { + namespace_member_reach(member, locals) + } + _ => 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 (Some(declaration), Some(alias), Some(origin)) = ( + nodes.kind(declaration_id).as_variable_declaration(), + id.symbol_id.get(), + init.reference_id + .get() + .and_then(|reference| scoping.get_reference(reference).symbol_id()), + ) 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 before = program + .body + .iter() + .find(|statement| statement.span().end > first) + .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 as usize, before 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 new file mode 100644 index 000000000..d509e81d8 --- /dev/null +++ b/libs/extractor/src/barrel/tests.rs @@ -0,0 +1,1072 @@ +use std::collections::HashMap; +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_with, +}; + +/// 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"); + 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'", + resolver + )); + 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", + 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); +} + +#[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}"); +} + +#[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:")); +} 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..40565edbf 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,37 @@ pub fn has_devup_ui(filename: &str, code: &str, package: &str) -> bool { false } +/// 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_with( + filename: &str, + code: &str, + package: &str, + import_aliases: &HashMap, + resolver: Option<&ModuleResolver>, +) -> bool { + parsing::source_type(filename).is_ok() + && (has_devup_ui(filename, code, package) + || 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 { @@ -20385,26 +20458,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/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..ee442b774 --- /dev/null +++ b/packages/bun-plugin/src/__tests__/barrel.test.ts @@ -0,0 +1,146 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import { + createModuleResolver, + mergeImportAliases, + readJsxImportSource, +} 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/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`, +} + +let root = '' + +function read(name: string) { + return { + filename: join(root, name).replaceAll('\\', '/'), + code: files[name], + } +} + +function extract(name: string, aliases = {}) { + const { filename, code } = read(name) + return codeExtract( + filename, + code, + '@devup-ui/react', + '@devup-ui/react', + true, + false, + false, + aliases, + ) +} + +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/, + ) + }) +}) + +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 a742e2452..6aceeacaa 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, importAliases) ) { 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..6eb97ec17 100644 --- a/packages/next-plugin/src/__tests__/loader.test.ts +++ b/packages/next-plugin/src/__tests__/loader.test.ts @@ -305,6 +305,84 @@ 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('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 52f50039a..3ad496a24 100644 --- a/packages/rsbuild-plugin/src/__tests__/plugin.test.ts +++ b/packages/rsbuild-plugin/src/__tests__/plugin.test.ts @@ -333,6 +333,65 @@ 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('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 ec5fc3081..1a8f299f9 100644 --- a/packages/vite-plugin/src/__tests__/plugin.test.ts +++ b/packages/vite-plugin/src/__tests__/plugin.test.ts @@ -1697,6 +1697,46 @@ 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('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 e5b1d07c7..532dc9c55 100644 --- a/packages/webpack-plugin/src/__tests__/loader.test.ts +++ b/packages/webpack-plugin/src/__tests__/loader.test.ts @@ -285,6 +285,74 @@ 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('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(