diff --git a/.changepacks/changepack_log_css_capture_validation.json b/.changepacks/changepack_log_css_capture_validation.json new file mode 100644 index 000000000..84470eef1 --- /dev/null +++ b/.changepacks/changepack_log_css_capture_validation.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Narrow CSS capture validation states and cover namespace tags, registered template composition, nested responsive rejection and bounded-call checker errors without changing runtime behavior.", + "date": "2026-10-22T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_deep_fixture_cost.json b/.changepacks/changepack_log_deep_fixture_cost.json new file mode 100644 index 000000000..e18726b63 --- /dev/null +++ b/.changepacks/changepack_log_deep_fixture_cost.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Keep 100000-level heap stress coverage while reducing fixture construction overhead. No new build errors or runtime behavior changes.", + "date": "2026-10-22T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_located_diagnostics.json b/.changepacks/changepack_log_located_diagnostics.json new file mode 100644 index 000000000..b5cdc04f9 --- /dev/null +++ b/.changepacks/changepack_log_located_diagnostics.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Report extraction, module-resolution and stylesheet execution failures at their original source locations with the cause and repair. Reject recoverable syntax errors, nondeterministic build-time reads, synthetic descriptor/function-source and legacy accessor reflection, and unsafe stylesheet execution fallbacks instead of silently changing CSS. Preserve module resolver and serialization exceptions, evaluate transitive helper side effects, and prevent placeholder-name collisions from losing styles. Match import package names only at exact or slash-delimited boundaries so sibling data packages remain external. Retain helper CSS loads in evaluation order with deduplicated portable relative specifiers and query suffixes, without CSS content dependencies. Report missing CSS or incompatible path roots as located errors, and reject host-recorded unknown CSS export observations even when caught; unused bindings, object passing and typeof follow default TypeScript/JavaScript import semantics.", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_scoped_compiled_names.json b/.changepacks/changepack_log_scoped_compiled_names.json new file mode 100644 index 000000000..3959d1e2b --- /dev/null +++ b/.changepacks/changepack_log_scoped_compiled_names.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Resolve compiled styling APIs and components by lexical binding so shadowed local names remain ordinary JavaScript and JSX, and preserve StyleX conditions exactly once in argument order. Conditions needing capture storage now report located build errors in parameter defaults, class field initializers, and statement frames visible to direct eval when safe local storage is unavailable; move the value into a variable in a safe statement body.", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/bindings/devup-ui-wasm/src/atom_hoist_measurement/error_tests.rs b/bindings/devup-ui-wasm/src/atom_hoist_measurement/error_tests.rs index 5b596a7b6..620b5f69b 100644 --- a/bindings/devup-ui-wasm/src/atom_hoist_measurement/error_tests.rs +++ b/bindings/devup-ui-wasm/src/atom_hoist_measurement/error_tests.rs @@ -14,10 +14,13 @@ fn build_reports_failing_file_when_fixture_contains_malformed_jsx() { }; // Then: the typed error retains its failing file and parser context. assert_eq!(error.file, "src/shared/s00.tsx"); - assert_eq!(error.message, "src/shared/s00.tsx:1:61: Unexpected token"); + assert_eq!( + error.message, + "src/shared/s00.tsx:1:61: Cannot parse source: Unexpected token. Fix: correct the syntax at this location" + ); assert_eq!( error.to_string(), - "src/shared/s00.tsx: src/shared/s00.tsx:1:61: Unexpected token" + "src/shared/s00.tsx: src/shared/s00.tsx:1:61: Cannot parse source: Unexpected token. Fix: correct the syntax at this location" ); assert_eq!(css::atom_hoist::atom_hoist_threshold(), None); assert_eq!(css::file_routes::get_file_routes().len(), 0); diff --git a/bindings/devup-ui-wasm/src/lib.rs b/bindings/devup-ui-wasm/src/lib.rs index 36c4bf5e0..7045c8a9b 100644 --- a/bindings/devup-ui-wasm/src/lib.rs +++ b/bindings/devup-ui-wasm/src/lib.rs @@ -19,6 +19,8 @@ use std::collections::{BTreeMap, HashMap}; use std::fmt::Display; use std::sync::{LazyLock, Mutex}; use wasm_bindgen::prelude::*; +mod resolver_state; +use resolver_state::ResolverState; mod state; #[cfg(test)] @@ -416,6 +418,7 @@ pub fn code_extract_internal( import_aliases, SourceMapMode::Generate, None, + None, ) } @@ -441,6 +444,7 @@ pub fn code_extract_without_source_map_internal( import_aliases, SourceMapMode::Skip, None, + None, ) } @@ -469,6 +473,7 @@ pub fn code_extract_with_modules_internal( import_aliases, SourceMapMode::Generate, Some(resolver), + None, ) } @@ -484,6 +489,7 @@ fn code_extract_internal_impl( import_aliases: HashMap, source_map: SourceMapMode, resolver: Option<&ModuleResolver>, + resolver_state: Option<&RefCell>, ) -> Result { let option = ExtractOption { package: package.to_string(), @@ -504,6 +510,9 @@ fn code_extract_internal_impl( (None, SourceMapMode::Skip) => extract_without_source_map(filename, code, option), }; + if let Some(state) = resolver_state { + state.borrow().check()?; + } match extracted { Ok(output) => Ok(Output::new( output.code, @@ -532,29 +541,69 @@ pub fn set_module_resolver(resolver: Option) { #[cfg(not(tarpaulin_include))] fn call_module_resolver( resolver: &js_sys::Function, - specifier: &str, - importer: &str, + request: resolver_state::ResolverRequest, + state: &RefCell, ) -> Option { - let module = resolver - .call2(&JsValue::NULL, &specifier.into(), &importer.into()) - .ok()?; - let field = |name: &str| { - js_sys::Reflect::get(&module, &name.into()) - .ok()? - .as_string() + let specifier = request.specifier(); + let importer = request.importer(); + let message = |error: JsValue| { + if let Some(message) = error.as_string() { + return message; + } + match js_sys::Reflect::get(&error, &"message".into()) { + Ok(value) => value.as_string().unwrap_or_else(|| format!("{error:?}")), + Err(_) => format!("{error:?}"), + } }; - Some(ResolvedModule { - path: field("path")?, - code: field("code")?, - }) + let resolved = (|| -> Result, String> { + let module = resolver + .call2(&JsValue::NULL, &specifier.into(), &importer.into()) + .map_err(message)?; + if module.is_null() || module.is_undefined() { + return Ok(None); + } + if !module.is_object() || js_sys::Array::is_array(&module) { + return Err("malformed module resolver result: expected an object {path:string,code:string} or null/undefined".to_string()); + } + let field = |name: &str| -> Result { + js_sys::Reflect::get(&module, &name.into()) + .map_err(message)? + .as_string() + .ok_or_else(|| { + format!("malformed module resolver result: `{name}` must be a string") + }) + }; + let path = field("path")?; + if path.is_empty() { + return Err( + "malformed module resolver result: `path` must be a non-empty string".to_string(), + ); + } + Ok(Some(ResolvedModule { + path, + code: field("code")?, + })) + })(); + match resolved { + Ok(Some(module)) => { + state.borrow_mut().cache(&module, &request); + Some(module) + } + Ok(None) => None, + Err(cause) => { + state.borrow_mut().record(&request, &cause); + None + } + } } /// The resolver set by `setModuleResolver`, if any #[cfg(not(tarpaulin_include))] -fn resolver_from_js() -> Option> { +fn resolver_from_js(state: std::rc::Rc>) -> Option> { let resolver = MODULE_RESOLVER.with_borrow(Clone::clone)?; Some(Box::new(move |specifier: &str, importer: &str| { - call_module_resolver(&resolver, specifier, importer) + let request = state.borrow().request(specifier, importer)?; + call_module_resolver(&resolver, request, &state) })) } @@ -573,9 +622,8 @@ fn code_extract_js( source_map: SourceMapMode, ) -> Result { let import_aliases = import_aliases_from_js(import_aliases)?; - let resolver = MODULE_RESOLVER.with_borrow(Clone::clone).map(|resolver| { - move |specifier: &str, importer: &str| call_module_resolver(&resolver, specifier, importer) - }); + let state = std::rc::Rc::new(RefCell::new(ResolverState::new(filename, code))); + let resolver = resolver_from_js(std::rc::Rc::clone(&state)); code_extract_internal_impl( filename, code, @@ -586,9 +634,8 @@ fn code_extract_js( import_main_css_in_css, import_aliases, source_map, - resolver - .as_ref() - .map(|resolver| resolver as &ModuleResolver), + resolver.as_deref(), + Some(&state), ) .map_err(js_error) } @@ -737,21 +784,18 @@ 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, import_aliases: JsValue, -) -> bool { +) -> Result { let aliases = import_aliases_from_js(import_aliases).unwrap_or_default(); - has_devup_ui_with( - filename, - code, - package, - &aliases, - resolver_from_js().as_deref(), - ) + let state = std::rc::Rc::new(RefCell::new(ResolverState::new(filename, code))); + let resolver = resolver_from_js(std::rc::Rc::clone(&state)); + let result = has_devup_ui_with(filename, code, package, &aliases, resolver.as_deref()); + state.borrow().check().map_err(js_error)?; + Ok(result) } #[cfg(test)] diff --git a/bindings/devup-ui-wasm/src/reservation.rs b/bindings/devup-ui-wasm/src/reservation.rs index b485b671e..da8ae6151 100644 --- a/bindings/devup-ui-wasm/src/reservation.rs +++ b/bindings/devup-ui-wasm/src/reservation.rs @@ -10,7 +10,12 @@ use wasm_bindgen::prelude::*; use crate::with_style_sheet_mut; mod rows; #[cfg(not(tarpaulin_include))] -use crate::{MODULE_RESOLVER, call_module_resolver, import_aliases_from_js, js_error}; +use crate::{ + MODULE_RESOLVER, call_module_resolver, import_aliases_from_js, js_error, + resolver_state::ResolverState, +}; +#[cfg(not(tarpaulin_include))] +use std::{cell::RefCell, rc::Rc}; /// Prepared physical input, keyed exactly as its later real transform. pub struct ReservationInput { @@ -105,7 +110,14 @@ pub fn reserve_class_slots( ..ExtractOption::default() }; let resolver = MODULE_RESOLVER.with_borrow(Clone::clone).map(|resolver| { - move |specifier: &str, importer: &str| call_module_resolver(&resolver, specifier, importer) + let state = Rc::new(RefCell::new(ResolverState::default())); + for input in &inputs { + state.borrow_mut().add_source(&input.filename, &input.code); + } + move |specifier: &str, importer: &str| { + let request = state.borrow().request(specifier, importer)?; + call_module_resolver(&resolver, request, &state) + } }); reserve_class_slots_internal( &inputs, diff --git a/bindings/devup-ui-wasm/src/resolver_state.rs b/bindings/devup-ui-wasm/src/resolver_state.rs new file mode 100644 index 000000000..4f0a1dc3c --- /dev/null +++ b/bindings/devup-ui-wasm/src/resolver_state.rs @@ -0,0 +1,141 @@ +use std::collections::{BTreeSet, HashMap}; + +use extractor::ResolvedModule; +use extractor::module_reference::{ModuleReferenceLocation, original_module_reference_locations}; + +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)] +struct OriginSite { + filename: String, + location: ModuleReferenceLocation, +} + +#[derive(Clone)] +struct OriginSites { + first: OriginSite, + rest: Vec, +} + +struct ModuleSource { + code: String, + ingress: Option, +} + +/// A callback request with at least one original reference site. +pub(crate) struct ResolverRequest { + specifier: String, + importer: String, + origins: OriginSites, +} + +impl ResolverRequest { + pub(crate) fn specifier(&self) -> &str { + &self.specifier + } + + pub(crate) fn importer(&self) -> &str { + &self.importer + } +} + +#[derive(PartialEq, Eq, PartialOrd, Ord)] +struct ResolverFailure { + site: OriginSite, + specifier: String, + importer: String, + cause: String, +} + +/// Resolver diagnostics and original module sources owned by one extraction. +#[derive(Default)] +pub(crate) struct ResolverState { + sources: HashMap, + failures: BTreeSet, +} + +impl ResolverState { + pub(crate) fn new(filename: &str, source: &str) -> Self { + Self { + sources: HashMap::from([( + filename.to_string(), + ModuleSource { + code: source.to_string(), + ingress: None, + }, + )]), + failures: BTreeSet::new(), + } + } + + pub(crate) fn cache(&mut self, module: &ResolvedModule, request: &ResolverRequest) { + self.sources.insert( + module.path.clone(), + ModuleSource { + code: module.code.clone(), + ingress: Some(request.origins.clone()), + }, + ); + } + + pub(crate) fn add_source(&mut self, filename: &str, code: &str) { + self.sources.insert( + filename.to_string(), + ModuleSource { + code: code.to_string(), + ingress: None, + }, + ); + } + + /// Bind original references before calling JS; recursive exploratory reads + /// without a semantic reference retain the real import that loaded the module. + pub(crate) fn request(&self, specifier: &str, importer: &str) -> Option { + let source = self.sources.get(importer)?; + let mut sites = original_module_reference_locations(importer, &source.code, specifier) + .into_iter() + .map(|location| OriginSite { + filename: importer.to_string(), + location, + }); + let origins = match sites.next() { + Some(first) => OriginSites { + first, + rest: sites.collect(), + }, + None => source.ingress.clone()?, + }; + Some(ResolverRequest { + specifier: specifier.to_string(), + importer: importer.to_string(), + origins, + }) + } + + pub(crate) fn record(&mut self, request: &ResolverRequest, cause: &str) { + for site in std::iter::once(&request.origins.first).chain(&request.origins.rest) { + self.failures.insert(ResolverFailure { + site: site.clone(), + specifier: request.specifier.clone(), + importer: request.importer.clone(), + cause: cause.to_string(), + }); + } + } + + pub(crate) fn check(&self) -> Result<(), String> { + if self.failures.is_empty() { + Ok(()) + } else { + Err(self.failures.iter().map(|failure| { + let OriginSite { filename, location } = &failure.site; + format!( + "{filename}:{}:{}: module resolver failed for `{}` from `{}`: {}\nFix: repair the module resolver; return {{path:string,code:string}} with a non-empty path, or null/undefined for an unresolved module.", + location.line, location.column, failure.specifier, failure.importer, failure.cause, + ) + }).collect::>().join("\n")) + } + } +} + +#[cfg(test)] +#[path = "resolver_state_tests.rs"] +mod tests; diff --git a/bindings/devup-ui-wasm/src/resolver_state_tests.rs b/bindings/devup-ui-wasm/src/resolver_state_tests.rs new file mode 100644 index 000000000..0e0d1cfb0 --- /dev/null +++ b/bindings/devup-ui-wasm/src/resolver_state_tests.rs @@ -0,0 +1,203 @@ +use super::*; +use crate::{SourceMapMode, code_extract_internal_impl, export_sheet_internal}; +use std::{cell::RefCell, rc::Rc}; + +#[test] +fn reservation_roots_keep_their_original_diagnostic_sites() -> Result<(), String> { + // Given + let mut state = ResolverState::default(); + assert_eq!(state.sources.len(), 0); + state.add_source("first.ts", "import './shared';"); + state.add_source("second.ts", "\nimport './shared';"); + + // When + for filename in ["second.ts", "first.ts"] { + let request = state + .request("./shared", filename) + .ok_or("missing reservation root reference")?; + state.record(&request, "reservation resolver failure"); + } + + // Then + assert_eq!(state.sources.len(), 2); + let error = state.check().err().ok_or("missing resolver diagnostics")?; + let sites = error + .lines() + .filter(|line| line.contains("module resolver failed")) + .collect::>(); + assert_eq!(sites.len(), 2); + assert!(sites[0].starts_with("first.ts:1:8:"), "{error}"); + assert!(sites[1].starts_with("second.ts:2:8:"), "{error}"); + Ok(()) +} + +#[test] +fn diagnostic_when_resolver_fails_in_cached_imported_typescript() -> Result<(), String> { + // Given + let mut state = ResolverState::new("entry.tsx", "import './barrel';"); + let ingress = state + .request("./barrel", "entry.tsx") + .ok_or("missing ingress reference")?; + state.cache( + &ResolvedModule { + path: "barrel.ts".to_string(), + code: "type Color = string;\nexport {\n color\n} from './color';".to_string(), + }, + &ingress, + ); + let request = state + .request("./color", "barrel.ts") + .ok_or("missing imported reference")?; + // When + state.record(&request, "permission denied: /original/path"); + // Then + let error = state.check().err().unwrap_or_default(); + assert!(error.starts_with("barrel.ts:4:8:")); + assert!(error.contains("permission denied: /original/path")); + assert!(error.contains("Fix: repair the module resolver")); + Ok(()) +} + +#[test] +fn diagnostic_when_specifier_has_multiple_original_sites() -> Result<(), String> { + // Given + let mut state = ResolverState::new("entry.ts", "import './color';\nexport * from './color';"); + let request = state + .request("./color", "entry.ts") + .ok_or("missing original reference")?; + // When + state.record(&request, "broken resolver"); + // Then + let error = state.check().err().unwrap_or_default(); + let diagnostics = error + .lines() + .filter(|line| line.starts_with("entry.ts:")) + .collect::>(); + assert_eq!(diagnostics.len(), 2); + assert!(diagnostics[0].starts_with("entry.ts:1:8: module resolver failed")); + assert!(diagnostics[1].starts_with("entry.ts:2:15: module resolver failed")); + assert!( + diagnostics + .iter() + .all(|line| line.ends_with("broken resolver")) + ); + Ok(()) +} + +#[test] +fn diagnostic_when_repeated_resolution_fails_is_deduplicated() -> Result<(), String> { + // Given + let mut state = ResolverState::new("entry.ts", "import './color';"); + let request = state + .request("./color", "entry.ts") + .ok_or("missing original reference")?; + state.record(&request, "broken resolver"); + let first = state.check(); + // When + state.record(&request, "broken resolver"); + // Then + assert_eq!(state.check(), first); + Ok(()) +} + +#[test] +#[serial_test::serial] +fn publication_when_exploratory_resolution_fails_is_rejected() -> Result<(), String> { + // Given + let source = "import { Box } from '@devup-ui/react';\nimport { color } from './color';\nconst x = ;"; + let state = Rc::new(RefCell::new(ResolverState::new("entry.tsx", source))); + let recorded = Rc::clone(&state); + let resolver = move |specifier: &str, importer: &str| { + let request = recorded.borrow().request(specifier, importer)?; + recorded + .borrow_mut() + .record(&request, "exploratory resolver failure"); + None + }; + let before = export_sheet_internal()?; + // When + let result = code_extract_internal_impl( + "entry.tsx", + source, + "@devup-ui/react", + "df".to_string(), + true, + false, + false, + HashMap::new(), + SourceMapMode::Generate, + Some(&resolver), + Some(&state), + ); + // Then + let error = result.err().unwrap_or_default(); + assert!(error.starts_with("entry.tsx:2:23:")); + assert!(error.contains("exploratory resolver failure")); + assert_eq!(export_sheet_internal()?, before); + Ok(()) +} + +#[test] +fn clean_extraction_state_when_another_extraction_failed() -> Result<(), String> { + // Given + let mut failed = ResolverState::new("entry.ts", "import './color';"); + let request = failed + .request("./color", "entry.ts") + .ok_or("missing original reference")?; + failed.record(&request, "broken resolver"); + // When + let clean = ResolverState::new("entry.ts", "import './color';"); + // Then + assert_eq!(clean.check(), Ok(())); + Ok(()) +} + +#[test] +#[serial_test::serial] +fn diagnostic_when_imported_constant_exploration_uses_shadowed_require_retains_ingress() +-> Result<(), String> { + // Given + let source = "import { Box } from '@devup-ui/react';\nimport { color } from './helper';\nconst x = ;"; + let state = Rc::new(RefCell::new(ResolverState::new("entry.tsx", source))); + let recorded = Rc::clone(&state); + let resolver = move |specifier: &str, importer: &str| { + let request = recorded.borrow().request(specifier, importer)?; + match (request.importer(), request.specifier()) { + ("entry.tsx", "./helper") => { + let module = ResolvedModule { + path: "helper.ts".to_string(), + code: "const require = () => ({color: 'red'}); const {color} = require('./shadowed'); exports.color = color;".to_string(), + }; + recorded.borrow_mut().cache(&module, &request); + Some(module) + } + ("helper.ts", "./shadowed") => { + recorded + .borrow_mut() + .record(&request, "shadowed resolver failure"); + None + } + other => panic!("unexpected module reference: {other:?}"), + } + }; + let before = export_sheet_internal()?; + // When + let result = code_extract_internal_impl( + "entry.tsx", + source, + "@devup-ui/react", + "df".to_string(), + true, + false, + false, + HashMap::new(), + SourceMapMode::Generate, + Some(&resolver), + Some(&state), + ); + // Then + let error = result.err().unwrap_or_default(); + assert!(error.starts_with("entry.tsx:2:23: module resolver failed for `./shadowed` from `helper.ts`: shadowed resolver failure")); + assert_eq!(export_sheet_internal()?, before); + Ok(()) +} diff --git a/e2e/css-composition.spec.ts b/e2e/css-composition.spec.ts index 0be7146c6..0fd843407 100644 --- a/e2e/css-composition.spec.ts +++ b/e2e/css-composition.spec.ts @@ -420,9 +420,7 @@ for (const singleCss of [true, false]) { }) } - test('later argument wins across the imported W35i leaf', async ({ - page, - }) => { + test('later argument wins across the imported leaf', async ({ page }) => { const emitted = artifacts( singleCss, `${api} import { base as child } from './child'; export const base = css(child, { color: 'blue', margin: 3 });`, diff --git a/libs/extractor/src/barrel.rs b/libs/extractor/src/barrel.rs index 5b59a7932..29bc26b23 100644 --- a/libs/extractor/src/barrel.rs +++ b/libs/extractor/src/barrel.rs @@ -94,31 +94,41 @@ fn analyze(module: &ResolvedModule, package: &str) -> Exports { let program = Parser::new(&allocator, &module.code, source_type) .parse() .program; - let mut imports: FxHashMap<&str, Link> = FxHashMap::default(); + let semantic = SemanticBuilder::new().build(&program).semantic; + let mut imports: FxHashMap = FxHashMap::default(); for statement in &program.body { - match statement { - Statement::ImportDeclaration(import) if !import.import_kind.is_type() => { - for specifier in import.specifiers.iter().flatten() { - let (local, imported) = match specifier { - ImportDeclarationSpecifier::ImportSpecifier(named) => { - (&named.local, Some(named.imported.name().to_string())) - } - ImportDeclarationSpecifier::ImportDefaultSpecifier(default) => { - (&default.local, Some("default".to_string())) - } - ImportDeclarationSpecifier::ImportNamespaceSpecifier(namespace) => { - (&namespace.local, None) - } - }; - imports.insert( - local.name.as_str(), + if let Statement::ImportDeclaration(import) = statement + && !import.import_kind.is_type() + { + for specifier in import.specifiers.iter().flatten() { + let (local, imported) = match specifier { + ImportDeclarationSpecifier::ImportSpecifier(named) + if !named.import_kind.is_type() => + { + (&named.local, Some(named.imported.name().to_string())) + } + ImportDeclarationSpecifier::ImportSpecifier(_) => continue, + ImportDeclarationSpecifier::ImportDefaultSpecifier(default) => { + (&default.local, Some("default".to_string())) + } + ImportDeclarationSpecifier::ImportNamespaceSpecifier(namespace) => { + (&namespace.local, None) + } + }; + imports.extend(local.symbol_id.get().map(|symbol| { + ( + symbol, Link::From { source: import.source.value.to_string(), imported, }, - ); - } + ) + })); } + } + } + for statement in &program.body { + match statement { Statement::ExportFromDeclaration(export) if !export.export_kind.is_type() => { for specifier in export .specifiers @@ -155,8 +165,8 @@ fn analyze(module: &ResolvedModule, package: &str) -> Exports { .iter() .filter(|s| !s.export_kind.is_type()) { - let link = imports - .get(specifier.local.name().as_str()) + let link = export_symbol(&specifier.local, &semantic) + .and_then(|symbol| imports.get(&symbol)) .cloned() .unwrap_or(Link::Own); exports @@ -165,8 +175,8 @@ fn analyze(module: &ResolvedModule, package: &str) -> Exports { } } Statement::VariableDeclaration(declaration) => { - for (name, link) in alias_links(declaration, &imports) { - imports.insert(name, link); + for (id, link) in alias_links(declaration, &imports, &semantic) { + imports.extend(id.symbol_id.get().map(|symbol| (symbol, link))); } } Statement::ExportDeclaration(export) => { @@ -174,16 +184,18 @@ fn analyze(module: &ResolvedModule, package: &str) -> Exports { exports.named.insert(name, Link::Own); } if let Declaration::VariableDeclaration(declaration) = &export.declaration { - for (name, link) in alias_links(declaration, &imports) { - exports.named.insert(name.to_string(), link.clone()); - imports.insert(name, link); + for (id, link) in alias_links(declaration, &imports, &semantic) { + exports.named.insert(id.name.to_string(), link.clone()); + imports.extend(id.symbol_id.get().map(|symbol| (symbol, link))); } } } Statement::ExportDefaultDeclaration(export) => { let link = match &export.declaration { ExportDefaultDeclarationKind::Identifier(name) => { - imports.get(name.name.as_str()).cloned() + reference_symbol(name, &semantic) + .and_then(|symbol| imports.get(&symbol)) + .cloned() } _ => None, }; @@ -200,13 +212,14 @@ fn analyze(module: &ResolvedModule, package: &str) -> Exports { /// Where `Namespace.member` leads, for a namespace a module imports fn member_link( member: &StaticMemberExpression<'_>, - imports: &FxHashMap<&str, Link>, + imports: &FxHashMap, + semantic: &Semantic<'_>, ) -> Option { if let Expression::Identifier(object) = &member.object && let Some(Link::From { source, imported: None, - }) = imports.get(object.name.as_str()) + }) = reference_symbol(object, semantic).and_then(|symbol| imports.get(&symbol)) { Some(Link::From { source: source.clone(), @@ -220,9 +233,10 @@ fn member_link( /// The bindings a `const` declaration makes of what a module imports /// (`const B = Box`, `const C = Devup.css`), with where each leads fn alias_links<'a>( - declaration: &oxc_ast::ast::VariableDeclaration<'a>, - imports: &FxHashMap<&str, Link>, -) -> Vec<(&'a str, Link)> { + declaration: &'a oxc_ast::ast::VariableDeclaration<'a>, + imports: &FxHashMap, + semantic: &Semantic<'_>, +) -> Vec<(&'a BindingIdentifier<'a>, Link)> { if declaration.kind != VariableDeclarationKind::Const { return Vec::new(); } @@ -234,15 +248,42 @@ fn alias_links<'a>( return None; }; let link = match declarator.init.as_ref()? { - Expression::Identifier(init) => imports.get(init.name.as_str())?.clone(), - Expression::StaticMemberExpression(member) => member_link(member, imports)?, + Expression::Identifier(init) => { + imports.get(&reference_symbol(init, semantic)?)?.clone() + } + Expression::StaticMemberExpression(member) => { + member_link(member, imports, semantic)? + } _ => return None, }; - Some((id.name.as_str(), link)) + Some((id.as_ref(), link)) }) .collect() } +fn reference_symbol( + reference: &oxc_ast::ast::IdentifierReference<'_>, + semantic: &Semantic<'_>, +) -> Option { + reference + .reference_id + .get() + .and_then(|id| semantic.scoping().get_reference(id).symbol_id()) +} + +fn export_symbol( + local: &oxc_ast::ast::ModuleExportName<'_>, + semantic: &Semantic<'_>, +) -> Option { + match local { + oxc_ast::ast::ModuleExportName::IdentifierReference(reference) => { + reference_symbol(reference, semantic) + } + oxc_ast::ast::ModuleExportName::IdentifierName(_) + | oxc_ast::ast::ModuleExportName::StringLiteral(_) => None, + } +} + /// Follows re-exports through the modules a resolver reads struct Walker<'r, 'p> { resolver: &'r ModuleResolver, @@ -253,10 +294,7 @@ struct Walker<'r, 'p> { impl Walker<'_, '_> { fn is_package(&self, source: &str) -> bool { - source == self.package - || source - .strip_prefix(self.package) - .is_some_and(|rest| rest.starts_with('/')) + crate::package_specifier::is_package(source, self.package) } fn module(&mut self, specifier: &str, importer: &str) -> Option> { @@ -461,6 +499,7 @@ impl Generated { } mod aliases; +mod gate; use aliases::{Reach, declarator_removal, export_edits, is_assigned_from}; fn unreadable(local: &str, code: &str) -> String { @@ -979,36 +1018,42 @@ pub(crate) fn rewrite( )); } } - let locals: FxHashMap = found - .bound - .iter() - .filter(|bound| bound.devup.1.as_deref().is_some_and(compiled_export)) - .map(|bound| { - ( - bound.binding.name.to_string(), - (bound.source.clone(), Some(bound.imported.clone())), - ) - }) - .chain(found.namespaces.iter().map(|declared| { - ( - declared.binding.name.to_string(), - (declared.source.clone(), None), - ) - })) - .collect(); - edits.extend(export_edits(&program, code, locals)); let wants_semantic = !found.namespaces.is_empty() || !found.opaque.is_empty() - || found + || (found .bound .iter() - .any(|bound| is_assigned_from(code, bound.binding.name.as_str())) - || (!found.bound.is_empty() && (code.contains("= keyframes(") || code.contains("= css("))); + .any(|bound| bound.devup.1.as_deref().is_some_and(compiled_export)) + && (gate::has_export_candidate(&program) + || code.contains("= keyframes(") + || code.contains("= css(") + || found.bound.iter().any(|bound| { + bound.devup.1.as_deref().is_some_and(compiled_export) + && is_assigned_from(code, bound.binding.name.as_str()) + }))); let (mut edits, errors) = if wants_semantic { let semantic = SemanticBuilder::new() .with_build_nodes(true) .build(&program) .semantic; + let locals: FxHashMap = found + .bound + .iter() + .filter(|bound| bound.devup.1.as_deref().is_some_and(compiled_export)) + .filter_map(|bound| { + Some(( + bound.binding.symbol_id.get()?, + (bound.source.clone(), Some(bound.imported.clone())), + )) + }) + .chain(found.namespaces.iter().filter_map(|declared| { + Some(( + declared.binding.symbol_id.get()?, + (declared.source.clone(), None), + )) + })) + .collect(); + edits.extend(export_edits(&semantic, code, locals)); let mut rewriter = Rewriter { code, semantic: &semantic, @@ -1072,3 +1117,6 @@ pub(crate) fn rewrite( #[cfg(test)] #[allow(clippy::expect_used, clippy::unwrap_used)] mod tests; + +#[cfg(test)] +mod scope_tests; diff --git a/libs/extractor/src/barrel/aliases.rs b/libs/extractor/src/barrel/aliases.rs index 035fa23f2..1771f9e1b 100644 --- a/libs/extractor/src/barrel/aliases.rs +++ b/libs/extractor/src/barrel/aliases.rs @@ -6,12 +6,13 @@ use oxc_ast::ast::{ BindingPattern, Declaration, ExportDefaultDeclarationKind, Expression, Program, Statement, StaticMemberExpression, VariableDeclaration, VariableDeclarationKind, }; +use oxc_semantic::Semantic; use oxc_span::{GetSpan, Span}; use oxc_syntax::node::NodeId; use oxc_syntax::symbol::SymbolId; use rustc_hash::FxHashMap; -use super::{Bound, Namespace, Rewriter, compiled_export}; +use super::{Bound, Namespace, Rewriter, compiled_export, export_symbol, reference_symbol}; /// Where a binding leads: the module it is read from, and the name read, `None` /// for the module's namespace @@ -58,19 +59,20 @@ pub(super) fn is_assigned_from(code: &str, name: &str) -> bool { /// `export const B = Box`. A module compiled with the import removed would /// otherwise export a name nothing declares. pub(super) fn export_edits( - program: &Program<'_>, + semantic: &Semantic<'_>, code: &str, - mut locals: FxHashMap, + mut locals: FxHashMap, ) -> Vec<(usize, usize, String)> { let mut edits = Vec::new(); + let program = semantic.nodes().program(); for statement in &program.body { match statement { Statement::VariableDeclaration(declaration) => { - alias_reaches(declaration, &mut locals); + alias_reaches(declaration, &mut locals, semantic); } Statement::ExportDeclaration(export) => { if let Declaration::VariableDeclaration(declaration) = &export.declaration { - let aliases = alias_reaches(declaration, &mut locals); + let aliases = alias_reaches(declaration, &mut locals, semantic); if !aliases.is_empty() { let mut statements: Vec = Vec::new(); let others: Vec<&str> = declaration @@ -102,7 +104,8 @@ pub(super) fn export_edits( let mut statements = Vec::new(); let mut kept = Vec::new(); for specifier in &export.specifiers { - let reach = locals.get(specifier.local.name().as_str()); + let reach = export_symbol(&specifier.local, semantic) + .and_then(|symbol| locals.get(&symbol)); match reach { Some(reach) if !specifier.export_kind.is_type() => { statements.push(reexport(specifier.exported.name().as_str(), reach)); @@ -125,7 +128,8 @@ pub(super) fn export_edits( } Statement::ExportDefaultDeclaration(export) => { if let ExportDefaultDeclarationKind::Identifier(name) = &export.declaration - && let Some(reach) = locals.get(name.name.as_str()) + && let Some(reach) = + reference_symbol(name, semantic).and_then(|symbol| locals.get(&symbol)) && reach.1.is_some() { edits.push(( @@ -145,10 +149,12 @@ pub(super) fn export_edits( /// the build compiles `member` away fn namespace_member_reach( member: &StaticMemberExpression<'_>, - locals: &FxHashMap, + locals: &FxHashMap, + semantic: &Semantic<'_>, ) -> Option { if let Expression::Identifier(object) = &member.object - && let Some((source, None)) = locals.get(object.name.as_str()) + && let Some((source, None)) = + reference_symbol(object, semantic).and_then(|symbol| locals.get(&symbol)) && compiled_export(member.property.name.as_str()) { Some((source.clone(), Some(member.property.name.to_string()))) @@ -161,7 +167,8 @@ fn namespace_member_reach( /// member of its namespace, `(name, where it leads)`, added to `locals` fn alias_reaches<'a>( declaration: &VariableDeclaration<'a>, - locals: &mut FxHashMap, + locals: &mut FxHashMap, + semantic: &Semantic<'_>, ) -> Vec<(&'a str, Reach)> { let mut aliases = Vec::new(); if declaration.kind != VariableDeclarationKind::Const { @@ -172,14 +179,16 @@ fn alias_reaches<'a>( continue; }; let reach = match &declarator.init { - Some(Expression::Identifier(init)) => locals.get(init.name.as_str()).cloned(), + Some(Expression::Identifier(init)) => reference_symbol(init, semantic) + .and_then(|symbol| locals.get(&symbol)) + .cloned(), Some(Expression::StaticMemberExpression(member)) => { - namespace_member_reach(member, locals) + namespace_member_reach(member, locals, semantic) } _ => None, }; if let Some(reach) = reach { - locals.insert(id.name.to_string(), reach.clone()); + locals.extend(id.symbol_id.get().map(|symbol| (symbol, reach.clone()))); aliases.push((id.name.as_str(), reach)); } } @@ -233,9 +242,7 @@ impl Rewriter<'_, '_, '_, '_> { let (Some(declaration), Some(alias), Some(origin)) = ( nodes.kind(declaration_id).as_variable_declaration(), id.symbol_id.get(), - init.reference_id - .get() - .and_then(|reference| scoping.get_reference(reference).symbol_id()), + reference_symbol(init, self.semantic), ) else { continue; }; @@ -290,7 +297,6 @@ impl Rewriter<'_, '_, '_, '_> { /// it is an error pub(super) fn hoist_style_constants(&mut self, program: &Program<'_>, bound: &[Bound<'_, '_>]) { let nodes = self.semantic.nodes(); - let scoping = self.semantic.scoping(); let apis: FxHashMap = bound .iter() .filter_map(|bound| { @@ -322,11 +328,8 @@ impl Rewriter<'_, '_, '_, '_> { let Expression::Identifier(callee) = &call.callee else { continue; }; - let Some(api) = callee - .reference_id - .get() - .and_then(|reference| scoping.get_reference(reference).symbol_id()) - .and_then(|symbol| apis.get(&symbol)) + let Some(api) = + reference_symbol(callee, self.semantic).and_then(|symbol| apis.get(&symbol)) else { continue; }; @@ -356,10 +359,7 @@ impl Rewriter<'_, '_, '_, '_> { let inside = reference.span.start >= call.span.start && reference.span.end <= call.span.end; !inside - || reference - .reference_id - .get() - .and_then(|reference| scoping.get_reference(reference).symbol_id()) + || reference_symbol(reference, self.semantic) .is_none_or(|symbol| imports.contains(&symbol)) }); if !literal_only { diff --git a/libs/extractor/src/barrel/gate.rs b/libs/extractor/src/barrel/gate.rs new file mode 100644 index 000000000..f1e3ee9fe --- /dev/null +++ b/libs/extractor/src/barrel/gate.rs @@ -0,0 +1,120 @@ +use oxc_ast::ast::{ + BindingPattern, Declaration, ExportDefaultDeclarationKind, Expression, Program, Statement, + VariableDeclarationKind, +}; + +pub(super) fn has_export_candidate(program: &Program<'_>) -> bool { + program.body.iter().any(|statement| match statement { + Statement::ExportNamedDeclaration(export) => { + !export.export_kind.is_type() + && export + .specifiers + .iter() + .any(|specifier| !specifier.export_kind.is_type()) + } + Statement::ExportDefaultDeclaration(export) => { + matches!( + export.declaration, + ExportDefaultDeclarationKind::Identifier(_) + ) + } + Statement::ExportDeclaration(export) => match &export.declaration { + Declaration::VariableDeclaration(declaration) => { + declaration.kind == VariableDeclarationKind::Const + && declaration.declarations.iter().any(|declarator| { + matches!(declarator.id, BindingPattern::BindingIdentifier(_)) + && matches!( + declarator.init, + Some( + Expression::Identifier(_) + | Expression::StaticMemberExpression(_) + ) + ) + }) + } + _ => false, + }, + _ => false, + }) +} + +#[cfg(test)] +mod tests { + use oxc_allocator::Allocator; + use oxc_parser::Parser; + use oxc_span::SourceType; + use rstest::rstest; + + use super::has_export_candidate; + use crate::ResolvedModule; + use crate::barrel::{Barreled, Link, analyze, rewrite}; + + #[rstest] + #[case("export { Box };", true)] + #[case("export { type Props, css as style };", true)] + #[case("export { type Props };", false)] + #[case("export type { Box };", false)] + #[case("export {};", false)] + #[case("export default Box;", true)] + #[case("export default function Page() { return ; }", false)] + #[case("export default () => ;", false)] + #[case("export const B = /* comment */ Box;", true)] + #[case("export const c = UI.css;", true)] + #[case("export const other = 1, B = Box;", true)] + #[case("export const Page = ;", false)] + #[case("export const value = 1;", false)] + #[case("export const style = css({});", false)] + #[case("export const { Box } = UI;", false)] + #[case("export let B = Box;", false)] + #[case("export const B = Box['name'];", false)] + #[case("export function Page() { return ; }", false)] + #[case("export { Box } from '@devup-ui/react';", false)] + #[case("export * from '@devup-ui/react';", false)] + #[case("const text = 'export';", false)] + fn export_shape_selects_analysis_candidates(#[case] code: &str, #[case] expected: bool) { + let allocator = Allocator::default(); + let program = Parser::new(&allocator, code, SourceType::tsx()) + .parse() + .program; + + let candidate = has_export_candidate(&program); + + assert_eq!(candidate, expected, "{code}"); + } + + #[rstest] + #[case("const B = /* comment */ Box; export { B };")] + #[case("const B = /* comment */ Box; export default B;")] + #[case("export const B = /* comment */ Box;")] + fn exported_alias_is_rewritten_when_assignment_text_does_not_match(#[case] exported: &str) { + let code = format!("import {{ Box }} from '@devup-ui/react'; {exported}"); + + let result = rewrite(&code, "ui.tsx", "@devup-ui/react", None); + + let Barreled::Rewritten(result) = result else { + panic!("exported alias was not rewritten"); + }; + assert!( + result.code.contains("from \"@devup-ui/react\";"), + "{}", + result.code + ); + assert!(result.code.contains("export { Box as"), "{}", result.code); + } + + #[test] + fn inline_type_import_is_not_a_runtime_origin_when_value_imports_are_mixed() { + let module = ResolvedModule { + path: "ui.ts".to_string(), + code: "export { Box, css }; import { type Box, css } from '@devup-ui/react';" + .to_string(), + }; + + let exports = analyze(&module, "@devup-ui/react"); + + assert!(matches!(exports.named.get("Box"), Some(Link::Own))); + assert!( + matches!(exports.named.get("css"), Some(Link::From { source, imported: Some(name) }) if source == "@devup-ui/react" && name == "css") + ); + } +} diff --git a/libs/extractor/src/barrel/scope_tests.rs b/libs/extractor/src/barrel/scope_tests.rs new file mode 100644 index 000000000..8a1763f63 --- /dev/null +++ b/libs/extractor/src/barrel/scope_tests.rs @@ -0,0 +1,185 @@ +use rstest::rstest; + +use super::{Barreled, rewrite}; +use crate::ResolvedModule; + +const PACKAGE: &str = "@devup-ui/react"; + +#[rstest] +#[case( + "export { Box }; import { Box } from '@devup-ui/react';", + "import { Box } from './ui'; const view = ;" +)] +#[case( + "export default Box; import { Box } from '@devup-ui/react';", + "import Box from './ui'; const view = ;" +)] +#[case( + "const B = Box; export { B as Box }; import { Box } from '@devup-ui/react';", + "import { Box } from './ui'; const view = ;" +)] +fn barrel_import_is_followed_when_declared_after_its_export( + #[case] barrel: &str, + #[case] code: &str, +) { + let barrel = barrel.to_string(); + let resolver = move |_: &str, _: &str| { + Some(ResolvedModule { + path: "ui.ts".to_string(), + code: barrel.clone(), + }) + }; + + let result = rewrite(code, "app.tsx", PACKAGE, Some(&resolver)); + + let Barreled::Rewritten(result) = result else { + panic!("barrel import was not redirected"); + }; + assert!( + result.code.contains("from \"@devup-ui/react\""), + "{}", + result.code + ); +} + +#[rstest] +#[case("function f(UI) { return UI.css({ color: 'blue' }); }")] +#[case("function f() { const UI = other; return ; }")] +#[case("function f(UI) { const D = UI; return D.css({ color: 'blue' }); }")] +#[case("namespace Local { import UI = Other.UI; export const x = UI.css({}); }")] +fn namespace_shadow_is_preserved_when_outer_namespace_is_rewritten( + #[case] shadow: &str, + #[values(PACKAGE, "./ui")] source: &str, +) { + let code = format!("import * as UI from '{source}'; const outer = UI.css({{}}); {shadow}"); + let resolver = |_: &str, _: &str| { + Some(ResolvedModule { + path: "ui.ts".to_string(), + code: "export { css, Box } from '@devup-ui/react';".to_string(), + }) + }; + + let result = rewrite(&code, "app.tsx", PACKAGE, Some(&resolver)); + + let Barreled::Rewritten(result) = result else { + panic!("outer namespace was not rewritten"); + }; + assert!(result.code.contains(shadow), "{}", result.code); + assert!( + result.code.contains("const outer = UI$css({});"), + "{}", + result.code + ); +} + +#[rstest] +#[case("function f(css) { const local = css; return local({}); }")] +#[case("function f() { const css = other; const local = css; return local({}); }")] +#[case("namespace Local { import css = Other.css; export const value = css; }")] +fn named_shadow_is_preserved_when_import_alias_is_rewritten(#[case] shadow: &str) { + let code = format!( + "import {{ css }} from '@devup-ui/react'; function real() {{ const local = css; return local({{}}); }} {shadow}" + ); + + let result = rewrite(&code, "app.tsx", PACKAGE, None); + + let Barreled::Rewritten(result) = result else { + panic!("import alias was not rewritten"); + }; + assert!(result.code.contains(shadow), "{}", result.code); + assert!( + result.code.contains("return local$css({});"), + "{}", + result.code + ); +} + +#[test] +fn barrel_alias_shadow_is_preserved_when_same_spelling_occurs_in_multiple_scopes() { + let shadow = "function shadow(css) { const local = css; return local({}); }"; + let code = format!( + "import {{ css }} from './ui'; const top = css; function a() {{ const local = top; return local({{}}); }} function b() {{ const local = css; return local({{}}); }} {shadow}" + ); + let resolver = |_: &str, _: &str| { + Some(ResolvedModule { + path: "ui.ts".to_string(), + code: "import { css } from '@devup-ui/react'; function f(css) { const local = css; return local; } export { css };".to_string(), + }) + }; + + let result = rewrite(&code, "app.tsx", PACKAGE, Some(&resolver)); + + let Barreled::Rewritten(result) = result else { + panic!("barrel aliases were not rewritten"); + }; + assert!(result.code.contains(shadow), "{}", result.code); + assert_eq!(result.code.matches("return local$css({});").count(), 2); +} + +#[test] +fn nested_namespace_import_is_preserved_when_exporting_an_outer_alias() { + let shadow = "namespace Local { import UI = Other.UI; export const B = UI.Box; }"; + let code = format!("import * as UI from '@devup-ui/react'; {shadow} export const B = UI.Box;"); + + let result = rewrite(&code, "app.tsx", PACKAGE, None); + + let Barreled::Rewritten(result) = result else { + panic!("outer export was not rewritten"); + }; + assert!(result.code.contains(shadow), "{}", result.code); + assert!( + result + .code + .contains("export { Box as B } from \"@devup-ui/react\";"), + "{}", + result.code + ); +} + +#[test] +fn runtime_import_is_unchanged_when_exporting_its_alias() { + let code = + "import { useTheme } from '@devup-ui/react'; const theme = useTheme; export { theme };"; + + let result = rewrite(code, "app.tsx", PACKAGE, None); + + assert!(matches!(result, Barreled::Unchanged)); +} + +#[rstest] +#[case("export { Box } from '@devup-ui/react';")] +#[case("export { 'Box' as Box } from '@devup-ui/react';")] +fn source_export_name_has_no_local_binding_when_it_is_not_a_reference(#[case] code: &str) { + let allocator = oxc_allocator::Allocator::default(); + let program = oxc_parser::Parser::new(&allocator, code, oxc_span::SourceType::mjs()) + .parse() + .program; + let semantic = oxc_semantic::SemanticBuilder::new() + .build(&program) + .semantic; + let oxc_ast::ast::Statement::ExportFromDeclaration(export) = &program.body[0] else { + panic!("expected a source export"); + }; + + let symbol = super::export_symbol(&export.specifiers[0].local, &semantic); + + assert_eq!(symbol, None); +} + +#[test] +fn unresolved_alias_is_preserved_when_exporting_a_compiled_import() { + let code = + "import { Box } from '@devup-ui/react'; const alias = unknown; export { Box, alias };"; + + let result = rewrite(code, "app.tsx", PACKAGE, None); + + let Barreled::Rewritten(result) = result else { + panic!("compiled export was not rewritten"); + }; + assert!( + result.code.contains("const alias = unknown;"), + "{}", + result.code + ); + assert!(result.code.contains("export { alias };"), "{}", result.code); +} diff --git a/libs/extractor/src/build_time_values.rs b/libs/extractor/src/build_time_values.rs index 97f9a5daa..2c04fa125 100644 --- a/libs/extractor/src/build_time_values.rs +++ b/libs/extractor/src/build_time_values.rs @@ -21,6 +21,13 @@ use oxc_syntax::reference::ReferenceId; use oxc_syntax::symbol::SymbolId; use rustc_hash::{FxHashMap, FxHashSet}; +#[path = "build_time_values_execute.rs"] +mod execute; +#[path = "build_time_values_mapping.rs"] +mod mapping; +use execute::compute; +use mapping::Generated; + use crate::stylex::StylexFunction; use crate::utils::{ binding_root, get_key_by_literal_expression, get_string_by_literal_expression, @@ -82,7 +89,7 @@ pub(crate) fn has_build_time_values( let changes = crate::imported_constants::ChangeCheck::new(&program, filename, option, resolver); !find( &program, - &|source| source.starts_with(option.package.as_str()), + &|source| crate::package_specifier::is_package(source, &option.package), &inlined.unknown, &|name| changes.is_changed(name), &|name| changes.known(name), @@ -283,7 +290,10 @@ impl<'s, 'a> Finder<'s, 'a> { Statement::ExportDefaultDeclaration(export) => { match &export.declaration { ExportDefaultDeclarationKind::FunctionDeclaration(function) => { - declare(function.id.as_ref().and_then(|id| id.symbol_id.get()), true); + declare( + function.id.as_ref().and_then(|id| id.symbol_id.get()), + function.body.is_some(), + ); } ExportDefaultDeclarationKind::ClassDeclaration(class) => { declare(class.id.as_ref().and_then(|id| id.symbol_id.get()), false); @@ -303,13 +313,18 @@ impl<'s, 'a> Finder<'s, 'a> { for identifier in declarator.id.get_binding_identifiers() { declare( identifier.symbol_id.get(), - declaration.kind == VariableDeclarationKind::Const, + declaration.kind == VariableDeclarationKind::Const + && !declaration.declare + && declarator.init.is_some(), ); } } } Declaration::FunctionDeclaration(function) => { - declare(function.id.as_ref().and_then(|id| id.symbol_id.get()), true); + declare( + function.id.as_ref().and_then(|id| id.symbol_id.get()), + function.body.is_some(), + ); } Declaration::ClassDeclaration(class) => { declare(class.id.as_ref().and_then(|id| id.symbol_id.get()), false); @@ -349,7 +364,10 @@ impl<'s, 'a> Finder<'s, 'a> { fn is_css(&self, callee: &Expression<'_>) -> bool { if let Expression::StaticMemberExpression(member) = callee { - return member.property.name == "css"; + return member.property.name == "css" + && self + .symbol(&member.object) + .is_some_and(|symbol| self.namespaces.contains(&symbol)); } self.symbol(callee) .is_some_and(|symbol| self.css.contains(&symbol)) @@ -911,22 +929,36 @@ impl<'a> Visit<'a> for Reads<'_> { fn visit_ts_type(&mut self, _: &oxc_ast::ast::TSType<'a>) {} } +/// What evaluating computed: the code with the values written in their place, +/// the replacements made, and the files read +pub(crate) type Evaluated = ( + String, + Vec, + BTreeSet, +); + +/// An error the build reports: the offset in the code evaluated that it names, +/// and what it tells +pub(crate) type Located = (usize, String); + /// `code` with what its style values compute written in their place, the /// replacements made, and the files read; `None` when running the code it /// reads computes none of them as a string, a finite number, or a plain -/// object or array of those -pub(crate) fn evaluate( +/// object or array of those. Code that reads what differs between builds or +/// pages (the clock, chance, the environment, the locale) is an error naming +/// the style value it computes, whether or not the code caught what the read +/// threw. +pub(crate) fn evaluate_located( code: &str, filename: &str, option: &ExtractOption, resolver: Option<&ModuleResolver>, unknown: &crate::imported_constants::Unknown, -) -> Option<( - String, - Vec, - BTreeSet, -)> { - let (mut values, dependencies) = compute(code, filename, option, resolver, unknown)?; +) -> Result, Vec> { + let Some((mut values, dependencies)) = compute(code, filename, option, resolver, unknown)? + else { + return Ok(None); + }; values.sort_unstable_by_key(|(span, _)| span.start); let mut result = String::with_capacity(code.len()); let mut edits = Vec::with_capacity(values.len()); @@ -939,61 +971,17 @@ pub(crate) fn evaluate( copied = end; } result.push_str(&code[copied..]); - Some((result, edits, dependencies)) + Ok(Some((result, edits, dependencies))) } /// A value's source text by the span of the code computing it type Replacement = (Span, String); -/// Run before the values: the code checked to read nothing that differs -/// between builds or pages, nothing does even if the check missed it -/// (reading `Date`, `Math.random`, the environment or the locale throws), and -/// the source text of a value the build can read. A statement that throws is -/// recorded, and a value reading it is not computed -const PRELUDE: &str = r#"delete globalThis.Date; -Math.random = undefined; -for (const name of ["window", "self", "document", "navigator", "location", "history", "localStorage", "sessionStorage", "matchMedia", "screen", "innerWidth", "innerHeight", "devicePixelRatio", "process", "global", "Deno", "Bun", "Intl"]) Object.defineProperty(globalThis, name, { get() { throw new ReferenceError(`${name} is only known at runtime`); }, configurable: true }); -for (const [prototype, names] of [[Object.prototype, ["toLocaleString"]], [Number.prototype, ["toLocaleString"]], [BigInt.prototype, ["toLocaleString"]], [Array.prototype, ["toLocaleString"]], [String.prototype, ["localeCompare", "toLocaleUpperCase", "toLocaleLowerCase", "normalize"]]]) for (const name of names) Object.defineProperty(prototype, name, { value() { throw new ReferenceError(`${name} depends on the locale`); }, configurable: true, writable: true }); -Object.setPrototypeOf(globalThis, new Proxy(Object.getPrototypeOf(globalThis), { get(target, key, receiver) { if (typeof key === "string" && !(key in target)) throw new ReferenceError(`${key} is only known at runtime`); return Reflect.get(target, key, receiver); } })); -const __failed__ = (() => { const fail = () => { throw new ReferenceError("its value threw"); }; return new Proxy(function () {}, { get: fail, apply: fail, construct: fail, getPrototypeOf: fail }); })(); -const __failed_statements__ = new Set(); -const __try__ = (compute, statement) => { try { return compute(); } catch { __failed_statements__.add(statement); return __failed__; } }; -const __literal__ = (value) => { - const plain = (item) => item === undefined || item === null || typeof item === "string" || typeof item === "boolean" || (typeof item === "number" && Number.isFinite(item)) - || (Array.isArray(item) && item.every(plain)) - || (typeof item === "object" && Object.getPrototypeOf(item) === Object.prototype && Object.getOwnPropertySymbols(item).length === 0 && !Object.prototype.hasOwnProperty.call(item, "__proto__") && Object.values(item).every(plain)); - if (typeof value === "number") return Number.isFinite(value) ? String(value) : undefined; - return value !== undefined && plain(value) ? JSON.stringify(value) : undefined; -}; -"#; - -fn compute( - code: &str, - filename: &str, - option: &ExtractOption, - resolver: Option<&ModuleResolver>, - unknown: &crate::imported_constants::Unknown, -) -> Option<(Vec, BTreeSet)> { +/// The statements the values read, run as they are written, and the imports +/// they read as the build knows them: the modules those come from never run +fn definitions(program: &Program<'_>, code: &str, found: &[Found]) -> Generated { use std::fmt::Write; - use boa_engine::{Context, JsObject, Source}; - - let allocator = Allocator::default(); - let program = parse(&allocator, filename, code)?; - let is_style = |source: &str| { - source.starts_with(option.package.as_str()) || option.import_aliases.contains_key(source) - }; - let changes = crate::imported_constants::ChangeCheck::new(&program, filename, option, resolver); - let found = find( - &program, - &is_style, - unknown, - &|name| changes.is_changed(name), - &|name| changes.known(name), - ); - if found.is_empty() { - return None; - } let statements: BTreeSet = found .iter() .flat_map(|found| found.closure.statements.iter().copied()) @@ -1003,11 +991,9 @@ fn compute( .flat_map(|found| &found.closure.imports) .map(|(name, value)| (name.as_str(), value.as_str())) .collect(); - // The modules imports come from never run: their values are written as - // the build knows them - let mut module = String::new(); + let mut module = Generated::default(); for (name, value) in imports { - let _ = writeln!(module, "const {name} = {value};"); + let _ = writeln!(module.text, "const {name} = {value};"); } for index in statements { let statement = &program.body[index]; @@ -1022,17 +1008,21 @@ fn compute( if let Some(declaration) = declaration { // A binding whose value throws is one reading it throws for, // leaving the others - for declarator in &declaration.declarations { - let Some(init) = &declarator.init else { - continue; - }; - let _ = writeln!( - module, - "{} {}__try__(() => ({}), {index});", - declaration.kind.as_str(), - &code[declarator.span.start as usize..init.span().start as usize], - &code[init.span().start as usize..init.span().end as usize], + for (declarator, init) in declaration + .declarations + .iter() + .filter_map(|declarator| declarator.init.as_ref().map(|init| (declarator, init))) + { + module.text.push_str(declaration.kind.as_str()); + module.text.push(' '); + module.copy( + code, + declarator.span.start as usize, + init.span().start as usize, ); + module.text.push_str("__try__(() => ("); + module.copy(code, init.span().start as usize, init.span().end as usize); + let _ = writeln!(module.text, "), {index});"); } continue; } @@ -1041,59 +1031,36 @@ fn compute( Statement::ExportDefaultDeclaration(export) => export.declaration.span(), statement => statement.span(), }; - module.push_str(&code[span.start as usize..span.end as usize]); - module.push('\n'); + module.copy(code, span.start as usize, span.end as usize); + module.text.push('\n'); } - // Each runs apart, so one that throws leaves the others - for (index, found) in found.iter().enumerate() { - let statements: Vec = found - .closure - .statements - .iter() - .map(ToString::to_string) - .collect(); - let _ = writeln!( - module, - "const __value_{index}__ = (() => {{ if ([{}].some((statement) => __failed_statements__.has(statement))) return undefined; try {{ return __literal__(({})); }} catch {{ return undefined; }} }})();", - statements.join(", "), - &code[found.span.start as usize..found.span.end as usize] - ); - } - let names: Vec = (0..found.len()) - .map(|index| format!("__value_{index}__")) + module +} + +/// The source text of what `found` computes, or nothing when a statement it +/// reads threw. Each value runs apart, so one that throws leaves the others. +fn value_script(code: &str, found: &Found) -> Generated { + let statements: Vec = found + .closure + .statements + .iter() + .map(ToString::to_string) .collect(); - let _ = writeln!(module, "[{}];", names.join(", ")); - - let script = crate::vanilla_extract::strip_typescript(&module, filename); - let mut context = Context::default(); - context - .runtime_limits_mut() - .set_loop_iteration_limit(crate::module_loader::LOOP_ITERATION_LIMIT); - let values = context - .eval(Source::from_bytes(format!("{PRELUDE}{script}").as_bytes())) - .ok()?; - let values = values.as_object().filter(JsObject::is_array)?; - let mut computed = Vec::new(); - for (index, found) in found.into_iter().enumerate() { - let Some(literal) = values - .get(index, &mut context) - .ok()? - .as_string() - .map(|literal| literal.to_std_string_escaped()) - else { - continue; - }; - // A part joined gives rules, or a class as a string - if found.rules_only && !literal.starts_with(['{', '[', '"']) { - continue; - } - computed.push(( - found.span, - match found.shorthand { - Some(key) => format!("{key}: {literal}"), - None => literal, - }, - )); - } - (!computed.is_empty()).then_some((computed, changes.dependencies())) + let mut generated = Generated::default(); + generated.text = format!( + "(() => {{ if ([{}].some((statement) => __failed_statements__.has(statement))) return undefined; try {{ return __literal__((", + statements.join(", ") + ); + generated.copy(code, found.span.start as usize, found.span.end as usize); + generated + .text + .push_str(")); } catch { return undefined; } })();"); + generated } + +#[cfg(test)] +mod scope_tests; + +#[cfg(test)] +#[path = "build_time_values_sandbox_tests.rs"] +mod sandbox_tests; diff --git a/libs/extractor/src/build_time_values/scope_tests.rs b/libs/extractor/src/build_time_values/scope_tests.rs new file mode 100644 index 000000000..5529f9c8c --- /dev/null +++ b/libs/extractor/src/build_time_values/scope_tests.rs @@ -0,0 +1,51 @@ +use super::has_build_time_values; +use crate::ExtractOption; + +#[test] +fn erased_bindings_are_runtime_only_inputs() { + let option = ExtractOption::default(); + for body in [ + "declare const a: number, b: number; export const x = css({ w: a + b });", + "declare function getHover(): object; export const x = ;", + "export declare function getHover(): object; export const x = ;", + ] { + // Given erased declarations referenced by a style expression. + let code = format!("import {{ css, Box }} from '@devup-ui/react'; {body}"); + // When selecting exact computations, then none may execute those inputs. + assert!( + !has_build_time_values("a.tsx", &code, &option, None), + "{body}" + ); + } +} + +#[test] +fn a_local_named_like_a_style_api_computes_nothing() { + let option = ExtractOption::default(); + let prelude = "import { css, Box } from '@devup-ui/react'; +import * as ui from '@devup-ui/react'; +const double = (n) => n * 2; +"; + for code in [ + "export const a = css({ w: double(2) });", + "export const a = ui.css({ w: double(2) });", + "export const a = ;", + "export const a = ;", + ] { + assert!( + has_build_time_values("a.tsx", &format!("{prelude}{code}"), &option, None), + "{code}" + ); + } + for code in [ + "export function f(css) { return css({ w: double(2) }); }", + "export function f(ui) { return ui.css({ w: double(2) }); }", + "export function f(Box) { return ; }", + "export function f(ui) { return ; }", + ] { + assert!( + !has_build_time_values("a.tsx", &format!("{prelude}{code}"), &option, None), + "{code}" + ); + } +} diff --git a/libs/extractor/src/build_time_values_execute.rs b/libs/extractor/src/build_time_values_execute.rs new file mode 100644 index 000000000..88fb78ecc --- /dev/null +++ b/libs/extractor/src/build_time_values_execute.rs @@ -0,0 +1,170 @@ +//! Execution of the exact value closures selected by the lexical reader. + +use boa_engine::{Context, Source}; +use oxc_allocator::Allocator; +use std::{collections::BTreeSet, path::Path}; + +use super::mapping::Mapped; +use super::{Located, Replacement, definitions, find, parse, value_script}; +use crate::evaluation_sandbox::{Failure, Sandbox}; +use crate::{ExtractOption, ModuleResolver}; + +const HELPERS: &str = r#"const __failed__ = (() => { const fail = () => { throw new ReferenceError("its value threw"); }; return new Proxy(function () {}, { get: fail, apply: fail, construct: fail, getPrototypeOf: fail }); })(); +const __failed_statements__ = new Set(); +const __try__ = (compute, statement) => { try { return compute(); } catch { __failed_statements__.add(statement); return __failed__; } }; +const __literal__ = (value) => { + const plain = (item) => item === undefined || item === null || typeof item === "string" || typeof item === "boolean" || (typeof item === "number" && Number.isFinite(item)) + || (Array.isArray(item) && item.every(plain)) + || (typeof item === "object" && Object.getPrototypeOf(item) === Object.prototype && Object.getOwnPropertySymbols(item).length === 0 && !Object.prototype.hasOwnProperty.call(item, "__proto__") && Object.values(item).every(plain)); + if (typeof value === "number") return Number.isFinite(value) ? String(value) : undefined; + return value !== undefined && plain(value) ? JSON.stringify(value) : undefined; +}; +"#; + +const HELPERS_SOURCE: &str = ""; +const DEFINITIONS_SOURCE: &str = ""; +const VALUE_SOURCE: &str = ""; +type Computed = (Vec, BTreeSet); + +fn initialize_helpers(sandbox: &Sandbox, context: &mut Context) -> Result<(), Failure> { + sandbox + .run_source( + context, + Source::from_bytes(HELPERS).with_path(Path::new(HELPERS_SOURCE)), + ) + .map(|_| ()) +} + +fn setup_failure(failure: Failure) -> Vec { + let reasons = match failure { + Failure::Js(error) => vec![error.to_string()], + Failure::Forbidden(violations) => violations + .iter() + .map(|violation| violation.error().to_string()) + .collect(), + }; + reasons + .into_iter() + .map(|reason| { + ( + 0, + format!("{reason}; report internal evaluation setup failure"), + ) + }) + .collect() +} + +fn stop( + failure: Failure, + scripts: &[Mapped], + code: &str, +) -> Result, Vec> { + let violations = match failure { + Failure::Forbidden(violations) => violations, + Failure::Js(_) => return Ok(None), + }; + let mut errors: Vec = violations + .iter() + .map(|violation| { + let offset = scripts.iter().find_map(|script| script.locate(violation)); + let Some(offset) = offset else { + return (0, violation.error().to_string()); + }; + let tail = &code[offset..]; + let read = tail + .split_once('\n') + .map_or(tail, |(line, _)| line) + .trim_end_matches(';'); + ( + offset, + format!( + "`{}` cannot use `{}` at build time: {}", + violation.name(), + read, + violation.requirement() + ), + ) + }) + .collect(); + errors.dedup(); + Err(errors) +} + +pub(super) fn compute( + code: &str, + filename: &str, + option: &ExtractOption, + resolver: Option<&ModuleResolver>, + unknown: &crate::imported_constants::Unknown, +) -> Result, Vec> { + let allocator = Allocator::default(); + let Some(program) = parse(&allocator, filename, code) else { + return Ok(None); + }; + let is_style = |source: &str| { + crate::package_specifier::is_package(source, &option.package) + || option.import_aliases.contains_key(source) + }; + let changes = crate::imported_constants::ChangeCheck::new(&program, filename, option, resolver); + let found = find( + &program, + &is_style, + unknown, + &|name| changes.is_changed(name), + &|name| changes.known(name), + ); + if found.is_empty() { + return Ok(None); + } + let mut context = Context::default(); + let sandbox = Sandbox::new(&mut context).map_err(|error| vec![(0, error.to_string())])?; + initialize_helpers(&sandbox, &mut context).map_err(setup_failure)?; + let module = definitions(&program, code, &found); + let mut scripts = vec![Mapped::new(module, filename, DEFINITIONS_SOURCE)]; + sandbox + .prepare(&mut context, &scripts[0].instrumented) + .map_err(|error| vec![(0, error.to_string())])?; + if let Err(failure) = sandbox.run_source( + &mut context, + Source::from_bytes(&scripts[0].instrumented.code).with_path(Path::new(DEFINITIONS_SOURCE)), + ) { + return stop(failure, &scripts, code); + } + let mut computed = Vec::new(); + for found in found { + let value_source = format!("{VALUE_SOURCE}:{}", found.span.start); + let script = Mapped::new(value_script(code, &found), filename, &value_source); + sandbox + .prepare(&mut context, &script.instrumented) + .map_err(|error| vec![(0, error.to_string())])?; + let result = sandbox.run_source( + &mut context, + Source::from_bytes(&script.instrumented.code).with_path(Path::new(&value_source)), + ); + scripts.push(script); + let value = match result { + Ok(value) => value, + Err(failure) => return stop(failure, &scripts, code), + }; + let Some(literal) = value + .as_string() + .map(|literal| literal.to_std_string_escaped()) + else { + continue; + }; + if found.rules_only && !literal.starts_with(['{', '[', '"']) { + continue; + } + computed.push(( + found.span, + match found.shorthand { + Some(key) => format!("{key}: {literal}"), + None => literal, + }, + )); + } + Ok((!computed.is_empty()).then_some((computed, changes.dependencies()))) +} + +#[cfg(test)] +mod build_time_values_execute_coverage_tests; diff --git a/libs/extractor/src/build_time_values_execute_coverage_tests.rs b/libs/extractor/src/build_time_values_execute_coverage_tests.rs new file mode 100644 index 000000000..2ebea874a --- /dev/null +++ b/libs/extractor/src/build_time_values_execute_coverage_tests.rs @@ -0,0 +1,174 @@ +use boa_engine::{Context, Source}; + +use super::{compute, initialize_helpers, setup_failure, stop}; +use crate::evaluation_sandbox::{Failure, Sandbox}; + +#[test] +fn execution_failure_preserves_guard_error_when_no_generated_script_can_locate_it() +-> Result<(), String> { + // Given + let mut context = Context::default(); + let sandbox = Sandbox::new(&mut context).map_err(|error| error.to_string())?; + let failure = sandbox + .run_source(&mut context, Source::from_bytes("Date.now()")) + .err() + .ok_or("guard succeeded")?; + // When + let errors = stop(failure, &[], "Date.now()") + .err() + .ok_or("failure was lost")?; + // Then + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].0, 0); + assert!(errors[0].1.contains("`Date`")); + assert!(errors[0].1.starts_with("ReferenceError:")); + Ok(()) +} + +#[test] +fn invalid_source_has_no_computed_values() { + // Given + let code = "import { css } from '@devup-ui/react'; const = css({ w: 2 });"; + // When + let result = compute( + code, + "invalid.tsx", + &crate::ExtractOption::default(), + None, + &crate::imported_constants::Unknown::default(), + ); + // Then + assert!(matches!(result, Ok(None))); +} + +#[test] +fn mutable_sibling_declarations_leave_style_values_at_runtime() { + // Given + let code = "import { css } from '@devup-ui/react'; let unused, width = 2; export const a = css({ w: width + 1 });"; + // When + let result = compute( + code, + "sibling.tsx", + &crate::ExtractOption::default(), + None, + &crate::imported_constants::Unknown::default(), + ); + // Then + assert!(matches!(result, Ok(None))); +} + +#[test] +fn definitions_skip_ambient_declarators_when_generating_initialized_values() -> Result<(), String> { + use super::super::{Closure, Found, definitions, parse}; + + // Given + let code = "declare const external: number; const width = 2;"; + let allocator = oxc_allocator::Allocator::default(); + let program = parse(&allocator, "definitions.ts", code).ok_or("valid TypeScript rejected")?; + let found = Found { + span: oxc_span::Span::default(), + closure: Closure { + statements: [0, 1].into_iter().collect(), + imports: Default::default(), + }, + shorthand: None, + rules_only: false, + }; + // When + let generated = definitions(&program, code, &[found]); + // Then + assert_eq!(generated.text, "const width = __try__(() => (2), 1);\n"); + Ok(()) +} + +#[test] +fn helpers_initialize_when_realm_is_fresh() -> Result<(), String> { + // Given + let mut context = Context::default(); + let sandbox = Sandbox::new(&mut context).map_err(|error| error.to_string())?; + // When + let result = initialize_helpers(&sandbox, &mut context); + // Then + assert!(result.is_ok()); + assert_eq!( + context + .eval(Source::from_bytes("__literal__({ width: 3 })")) + .map_err(|error| error.to_string())? + .as_string() + .ok_or("literal helper did not serialize value")? + .to_std_string_escaped(), + "{\"width\":3}" + ); + Ok(()) +} + +#[test] +fn setup_failure_preserves_native_syntax_error_when_binding_is_reserved() -> Result<(), String> { + // Given + let mut context = Context::default(); + let sandbox = Sandbox::new(&mut context).map_err(|error| error.to_string())?; + context + .eval(Source::from_bytes("const __failed__ = 1;")) + .map_err(|error| error.to_string())?; + let failure = initialize_helpers(&sandbox, &mut context) + .err() + .ok_or("duplicate lexical binding succeeded")?; + let original = match &failure { + Failure::Js(error) => error.to_string(), + Failure::Forbidden(_) => return Err("duplicate binding must be a native JS error".into()), + }; + assert!(original.starts_with("SyntaxError:"), "{original}"); + // When + let errors = setup_failure(failure); + // Then + assert_eq!( + errors, + vec![( + 0, + format!("{original}; report internal evaluation setup failure") + )] + ); + Ok(()) +} + +#[test] +fn setup_failure_preserves_forbidden_read_provenance() -> Result<(), String> { + // Given + let mut context = Context::default(); + let sandbox = Sandbox::new(&mut context).map_err(|error| error.to_string())?; + let failure = sandbox + .run_source(&mut context, Source::from_bytes("Date.now()")) + .err() + .ok_or("forbidden clock read succeeded")?; + let original = match &failure { + Failure::Forbidden(violations) => violations[0].error().to_string(), + Failure::Js(_) => return Err("clock read must preserve forbidden evidence".into()), + }; + // When + let errors = setup_failure(failure); + // Then + assert_eq!( + errors, + vec![( + 0, + format!("{original}; report internal evaluation setup failure") + )] + ); + Ok(()) +} + +#[test] +fn ordinary_js_failure_leaves_computation_at_runtime() -> Result<(), String> { + // Given + let mut context = Context::default(); + let sandbox = Sandbox::new(&mut context).map_err(|error| error.to_string())?; + let failure = sandbox + .run_source(&mut context, Source::from_bytes("null.value")) + .err() + .ok_or("native TypeError missing")?; + // When + let result = stop(failure, &[], "null.value"); + // Then + assert!(matches!(result, Ok(None))); + Ok(()) +} diff --git a/libs/extractor/src/build_time_values_mapping.rs b/libs/extractor/src/build_time_values_mapping.rs new file mode 100644 index 000000000..00606cc7b --- /dev/null +++ b/libs/extractor/src/build_time_values_mapping.rs @@ -0,0 +1,74 @@ +//! Original-source tracing for generated build-time helper statements. + +use crate::evaluation_sandbox::{Violation, instrument}; +use crate::source_map::{Lines, Trace}; + +#[derive(Default)] +pub(super) struct Generated { + pub(super) text: String, + copied: Vec<(usize, usize, usize)>, +} + +impl Generated { + pub(super) fn copy(&mut self, source: &str, from: usize, to: usize) { + let start = self.text.len(); + self.text.push_str(&source[from..to]); + self.copied.push((start, self.text.len(), from)); + } + + fn locate(&self, offset: usize) -> Option { + self.copied + .iter() + .find(|(start, end, _)| (*start..*end).contains(&offset)) + .map(|(start, _, original)| original + offset - start) + } +} + +pub(super) struct Mapped { + generated: Generated, + stripped: std::rc::Rc, + trace: Trace, + path: String, + pub(super) instrumented: crate::evaluation_sandbox::Instrumented, +} + +impl Mapped { + pub(super) fn new(generated: Generated, filename: &str, path: &str) -> Self { + let stripped = crate::vanilla_extract::strip_typescript_marked(&generated.text, filename); + let trace = Trace::new(&stripped.marks, &[]); + let instrumented = instrument(&stripped.code, path); + Self { + generated, + stripped, + trace, + path: path.to_string(), + instrumented, + } + } + + pub(super) fn locate(&self, violation: &Violation) -> Option { + let offset = match violation.site() { + Some((place, offset)) if place.starts_with(&format!("{}:", self.path)) => offset, + Some(_) => return None, + None => { + let error = self.instrumented.explain(&violation.error().to_string()); + let frame = error.lines().find_map(|line| { + line.split_once(&format!("({}:", self.path)) + .map(|(_, position)| position) + })?; + let (line, column) = frame.trim_end_matches(')').split_once(':')?; + Lines::new(&self.stripped.code).code_point_offset( + line.parse::().ok()?.saturating_sub(1), + column.parse::().ok()?.saturating_sub(1), + ) + } + }; + self.generated.locate( + self.trace + .resolve(&self.stripped.code, &self.generated.text, offset), + ) + } +} + +#[cfg(test)] +mod build_time_values_mapping_coverage_tests; diff --git a/libs/extractor/src/build_time_values_mapping_coverage_tests.rs b/libs/extractor/src/build_time_values_mapping_coverage_tests.rs new file mode 100644 index 000000000..7345f4f0e --- /dev/null +++ b/libs/extractor/src/build_time_values_mapping_coverage_tests.rs @@ -0,0 +1,40 @@ +use boa_engine::{Context, Source}; +use rstest::rstest; + +use super::{Generated, Mapped}; +use crate::evaluation_sandbox::{Failure, Sandbox}; + +#[rstest] +#[case("Date.now()", "Date")] +#[case("Math.random()", "Math.random")] +fn fallback_frames_trace_to_copied_source_when_execution_has_no_instrumentation( + #[case] read: &str, + #[case] name: &str, +) -> Result<(), String> { + // Given + let script = format!("function read() {{ return {read}; }} read();"); + let original = format!("// original\n{script}"); + let start = original.find(&script).ok_or("missing fixture read")?; + let mut generated = Generated::default(); + generated.copy(&original, start, original.len()); + let mapped = Mapped::new(generated, "boundary.ts", "mapped.js"); + let mut context = Context::default(); + let sandbox = Sandbox::new(&mut context).map_err(|error| error.to_string())?; + // When + let result = sandbox.run_source( + &mut context, + Source::from_bytes(&script).with_path(std::path::Path::new("mapped.js")), + ); + let Err(Failure::Forbidden(reads)) = result else { + return Err("missing forbidden read".to_string()); + }; + // Then + assert_eq!(reads[0].name(), name); + assert_eq!( + mapped.locate(&reads[0]), + Some(original.find('{').ok_or("missing original function body")?), + "{}", + reads[0].error() + ); + Ok(()) +} diff --git a/libs/extractor/src/build_time_values_sandbox_tests.rs b/libs/extractor/src/build_time_values_sandbox_tests.rs new file mode 100644 index 000000000..177a8e3dc --- /dev/null +++ b/libs/extractor/src/build_time_values_sandbox_tests.rs @@ -0,0 +1,87 @@ +use rstest::rstest; + +fn evaluate(code: &str) -> Result, Vec<(usize, String)>> { + super::evaluate_located( + code, + "a.tsx", + &crate::ExtractOption::default(), + None, + &crate::imported_constants::Unknown::default(), + ) + .map(|evaluated| evaluated.map(|(code, ..)| code)) +} + +const COMPARE: &str = "import { css } from '@devup-ui/react';\nconst compare = Object.getOwnPropertyDescriptor(String.prototype, 'localeCompare').value;\n"; + +#[test] +fn a_later_value_reports_its_own_read_offset() -> Result<(), String> { + // Given two separate computed values, with the forbidden read in the second. + let code = "import { css } from '@devup-ui/react';\nexport const a = css({ w: Number('2'), h: Object.getOwnPropertyDescriptor(String.prototype, 'localeCompare').value('a', 'b') });"; + // When evaluation records the read. + let errors = evaluate(code).err().ok_or("evaluation succeeded")?; + // Then its location names the second value, not the first generated script. + assert_eq!( + errors[0].0, + code.find("Object.getOwnPropertyDescriptor") + .ok_or("missing read")? + ); + Ok(()) +} + +#[rstest] +#[case("export const a = css({ w: compare('a', 'b') });")] +#[case( + "const sorted = ['b', 'a'].map((item) => compare(item, 'a'));\nexport const a = css({ w: sorted[0] });" +)] +fn forbidden_helper_references_report_the_helper_not_the_style_value( + #[case] tail: &str, +) -> Result<(), String> { + let code = format!("{COMPARE}{tail}"); + let errors = evaluate(&code).err().ok_or("evaluation succeeded")?; + assert_eq!(errors.len(), 1); + assert_eq!( + errors[0].0, + code.find("Object.getOwnPropertyDescriptor") + .ok_or("fixture occurrence missing")? + ); + assert!(errors[0].1.contains("String.prototype.localeCompare")); + assert!(!errors[0].1.contains("Fix:")); + assert!( + errors[0] + .1 + .contains("cannot use `Object.getOwnPropertyDescriptor") + ); + Ok(()) +} + +#[test] +fn deterministic_code_still_computes_its_values() -> Result<(), String> { + let code = "import { css } from '@devup-ui/react';\nconst double = (n) => Math.max(n, 1) * 2;\nexport const a = css({ w: double(2) });"; + let result = evaluate(code) + .map_err(|errors| format!("{errors:?}"))? + .ok_or("no computed value")?; + assert!(result.contains("w: 4")); + Ok(()) +} + +#[rstest] +#[case( + "import { css } from '@devup-ui/react';\nconst fail = () => { throw 1; };\nexport const a = css({ w: fail() });" +)] +#[case("import { css } from '@devup-ui/react';\nconst NaN = 1;\nexport const a = css({ w: NaN });")] +#[case( + "import { css } from '@devup-ui/react';\ndeclare const a: number, b: number;\nexport const x = css({ w: a + b });" +)] +#[case( + "import { Box } from '@devup-ui/react';\ndeclare function getHover(): object;\nexport const x = ;" +)] +fn a_definition_that_fails_for_another_reason_leaves_runtime_values( + #[case] code: &str, +) -> Result<(), String> { + assert!( + evaluate(code) + .map_err(|errors| format!("{errors:?}"))? + .is_none() + ); + Ok(()) +} diff --git a/libs/extractor/src/ci_coverage_tests.rs b/libs/extractor/src/ci_coverage_tests.rs new file mode 100644 index 000000000..1d9a0e124 --- /dev/null +++ b/libs/extractor/src/ci_coverage_tests.rs @@ -0,0 +1,25 @@ +use crate::{ExtractOption, extract}; + +#[rstest::rstest] +#[case("computed.tsx")] +#[case("computed.css.ts")] +#[serial_test::serial] +fn computed_style_guard_error_names_original_read_location( + #[case] filename: &str, +) -> Result<(), Box> { + // Given + let code = "import { css } from '@devup-ui/react';\nconst compare = Object.getOwnPropertyDescriptor(String.prototype, 'localeCompare').value;\nexport const a = css({ w: compare('a', 'b') });"; + // When + let error = extract(filename, code, ExtractOption::default()) + .err() + .ok_or("forbidden style unexpectedly extracted")?; + // Then + let error = error.to_string(); + assert!(error.starts_with(&format!("{filename}:2:17:")), "{error}"); + assert!(error.contains("String.prototype.localeCompare"), "{error}"); + assert!( + error.contains("Fix: use a literal or a CSS variable"), + "{error}" + ); + Ok(()) +} diff --git a/libs/extractor/src/ci_factory_alias_support_tests.rs b/libs/extractor/src/ci_factory_alias_support_tests.rs index adfabc316..9e16fb1ee 100644 --- a/libs/extractor/src/ci_factory_alias_support_tests.rs +++ b/libs/extractor/src/ci_factory_alias_support_tests.rs @@ -47,7 +47,7 @@ impl Binding { fn extract_fixture(source: &str, single_css: bool, active: bool) -> ExtractOutput { extract( - "/src/W70h-native.js", + "/src/alias-native.js", source, ExtractOption { single_css, @@ -63,7 +63,7 @@ fn extract_fixture(source: &str, single_css: bool, active: bool) -> ExtractOutpu } fn observe(output: &ExtractOutput, invocation: &str, single_css: bool) -> (Vec, Vec) { - let observed = render_observation(output, invocation, (!single_css).then_some("/src/W70h-native.js")); + let observed = render_observation(output, invocation, (!single_css).then_some("/src/alias-native.js")); let (trace, mut declarations): (Vec, Vec) = serde_json::from_str(&observed).unwrap(); // These fixtures have distinct CSS properties, never competing declarations. declarations.sort(); diff --git a/libs/extractor/src/class_names_binding_tests.rs b/libs/extractor/src/class_names_binding_tests.rs index 41f50ee3b..dfaa3ff86 100644 --- a/libs/extractor/src/class_names_binding_tests.rs +++ b/libs/extractor/src/class_names_binding_tests.rs @@ -228,13 +228,12 @@ fn class_names_binding_mutation_shadow_is_runtime_escape( let source = format!("import {{ClassNames}} from '@emotion/react';const data={{padding:1}};{body}"); let parsed = Parser::new(&arena, &source, SourceType::tsx()).parse(); - let takers = crate::css_prop::CssTakers::new( + // When: classify whether the ordinary shadow may change data. + let uses = crate::mutations::uses( &parsed.program, - crate::css_prop::CssProp::Off, - "@devup-ui/react/compat", + &|name| name == "ClassNames", + Some((crate::css_prop::CssProp::Off, "@devup-ui/react/compat")), ); - // When: classify whether the ordinary shadow may change data. - let uses = crate::mutations::uses(&parsed.program, &|name| name == "ClassNames", Some(&takers)); // Then: data escapes to runtime code; it must not be treated as compile-only use. assert_eq!( uses.get("data").is_some_and(|uses| uses diff --git a/libs/extractor/src/css_prop.rs b/libs/extractor/src/css_prop.rs index 460a151a4..e02eb6a48 100644 --- a/libs/extractor/src/css_prop.rs +++ b/libs/extractor/src/css_prop.rs @@ -7,9 +7,9 @@ use std::collections::HashMap; use oxc_allocator::{CloneIn, FromIn, GetAllocator}; use oxc_ast::ast::{ Argument, ArrowFunctionBody, BinaryOperator, BindingPattern, CallExpression, Expression, - FormalParameters, FunctionBody, ImportDeclarationSpecifier, JSXAttributeItem, JSXChild, - JSXElement, JSXElementName, ObjectPropertyKind, Program, Statement, Str, TemplateElement, - TemplateElementValue, TemplateLiteral, + FormalParameters, FunctionBody, IdentifierReference, ImportDeclarationSpecifier, + JSXAttributeItem, JSXChild, JSXElement, JSXElementName, ObjectPropertyKind, Program, Statement, + Str, TemplateElement, TemplateElementValue, TemplateLiteral, }; use oxc_ast::builder::AstBuilder; use oxc_ast_visit::{VisitMut, walk_mut}; @@ -25,8 +25,6 @@ use crate::utils::{ unwrap_syntax_only, }; -type Resolve<'r> = &'r dyn Fn(&oxc_ast::ast::IdentifierReference<'_>) -> Option; - /// The package whose alias makes elements take the `css` prop pub(crate) const EMOTION_REACT: &str = "@emotion/react"; @@ -108,17 +106,53 @@ impl CssProp { /// What in a program takes the `css` prop: the elements `css_prop` tells, and /// the `jsx()` calls building them pub(crate) struct CssTakers<'s> { - pub css_prop: CssProp, - /// Local names of the functions building elements from a type and props - jsx: FxHashSet<&'s str>, - /// Local names of Emotion's `ClassNames` - class_names: FxHashSet<&'s str>, + css_prop: CssProp, + scoping: &'s Scoping, + /// The imported functions building elements from a type and props + jsx: FxHashSet, + /// The imported `ClassNames` of Emotion + class_names: FxHashSet, +} + +/// The binding `identifier` reads; `None` for a global +pub(crate) fn binding_of( + scoping: &Scoping, + identifier: &IdentifierReference<'_>, +) -> Option { + scoping + .get_reference(identifier.reference_id.get()?) + .symbol_id() +} + +/// Whether `identifier` reads a binding of the module's top level or a global, +/// and not a local of a function or block, which may share the name of one +pub(crate) fn reads_top_level(scoping: &Scoping, identifier: &IdentifierReference<'_>) -> bool { + binding_of(scoping, identifier) + .is_none_or(|symbol| scoping.symbol_scope_id(symbol) == scoping.root_scope_id()) +} + +/// The identifier `a`, `a.b` and `a[b]` start at +pub(crate) fn root_reference<'e, 'a>( + expression: &'e Expression<'a>, +) -> Option<&'e IdentifierReference<'a>> { + match expression { + Expression::Identifier(identifier) => Some(identifier), + Expression::StaticMemberExpression(member) => root_reference(&member.object), + Expression::ComputedMemberExpression(member) => root_reference(&member.object), + _ => None, + } } impl<'s> CssTakers<'s> { - /// The elements `program` gives a `css` prop, as `css_prop` tells; - /// `compat` is the entry absorbing Emotion's own `jsx` - pub(crate) fn new(program: &Program<'s>, css_prop: CssProp, compat: &str) -> Self { + /// The elements `program` gives a `css` prop, as `css_prop` tells, with + /// the bindings `scoping` resolved; `compat` is the entry absorbing + /// Emotion's own `jsx` + pub(crate) fn new( + program: &Program<'_>, + scoping: &'s Scoping, + css_prop: CssProp, + compat: &str, + ) -> Self { let mut jsx = FxHashSet::default(); let mut class_names = FxHashSet::default(); for statement in &program.body { @@ -130,38 +164,31 @@ impl<'s> CssTakers<'s> { }; let imported = specifier.imported.name(); if is_jsx_function(source, &imported, compat) { - jsx.insert(specifier.local.name.as_str()); + jsx.extend(specifier.local.symbol_id.get()); } else if imported == "ClassNames" && (source == compat || source == EMOTION_REACT) { - class_names.insert(specifier.local.name.as_str()); + class_names.extend(specifier.local.symbol_id.get()); } } } } Self { css_prop, + scoping, jsx, class_names, } } - pub(crate) fn has_class_names(&self) -> bool { - !self.class_names.is_empty() - } - - /// The child callback's API bindings, only when the element reads the import. - pub(crate) fn class_names_calls( - &self, - element: &JSXElement<'_>, - scoping: &Scoping, - ) -> Vec { + /// The `css` and `cx` bindings a `` child function `element` + /// gives takes; empty for any other element, and for a `ClassNames` a + /// local of the same name stands for + pub(crate) fn class_names_calls(&self, element: &JSXElement<'_>) -> Vec { match &element.opening_element.name { JSXElementName::IdentifierReference(name) - if self.class_names.contains(name.name.as_str()) - && binding_of(scoping, name).is_some_and(|symbol| { - scoping.get_root_binding(name.name.as_str().into()) == Some(symbol) - }) => + if binding_of(self.scoping, name) + .is_some_and(|symbol| self.class_names.contains(&symbol)) => { class_names_calls(element) } @@ -169,25 +196,43 @@ impl<'s> CssTakers<'s> { } } + /// Whether `callee` calls one of the `taken` bindings of `` + /// child functions + pub(crate) fn calls_class_names(&self, taken: &[SymbolId], callee: &Expression<'_>) -> bool { + matches!(callee, Expression::Identifier(callee) + if binding_of(self.scoping, callee).is_some_and(|symbol| taken.contains(&symbol))) + } + + /// Whether the element `name` takes the `css` prop; `devup` tells a + /// reference to a Devup UI component + pub(crate) fn takes( + &self, + name: &JSXElementName<'_>, + devup: impl Fn(&IdentifierReference<'_>) -> bool, + ) -> bool { + let root = crate::imported_constants::jsx_root_identifier(name); + self.css_prop.takes(name, |_| root.is_some_and(&devup)) + } + /// Whether `attribute` of the element `name` is a `css` prop it takes; - /// `devup` tells a Devup UI component + /// `devup` tells a reference to a Devup UI component pub(crate) fn attribute( &self, name: &JSXElementName<'_>, attribute: &JSXAttributeItem<'_>, - devup: impl Fn(&str) -> bool, + devup: impl Fn(&IdentifierReference<'_>) -> bool, ) -> bool { matches!(attribute, JSXAttributeItem::Attribute(attribute) if attribute.name.as_identifier().is_some_and(|attribute| attribute.name == "css")) - && self.css_prop.takes(name, devup) + && self.takes(name, devup) } /// Where among the props a `jsx()` call gives is the `css` prop the - /// element takes + /// element takes; `devup` tells a reference to a Devup UI component pub(crate) fn property( &self, call: &CallExpression<'_>, - devup: impl Fn(&str) -> bool, + devup: impl Fn(&IdentifierReference<'_>) -> bool, ) -> Option { let Expression::Identifier(callee) = &call.callee else { return None; @@ -195,8 +240,12 @@ impl<'s> CssTakers<'s> { let [element, Argument::ObjectExpression(props), ..] = call.arguments.as_slice() else { return None; }; - (self.jsx.contains(callee.name.as_str()) - && self.css_prop.takes_type(element.as_expression()?, devup)) + let element = element.as_expression()?; + let root = root_reference(unwrap_syntax_only(element)); + (binding_of(self.scoping, callee).is_some_and(|symbol| self.jsx.contains(&symbol)) + && self + .css_prop + .takes_type(element, |_| root.is_some_and(&devup))) .then(|| { props.properties.iter().rposition(|property| { matches!(property, ObjectPropertyKind::ObjectProperty(property) @@ -228,11 +277,9 @@ pub(crate) fn may_take_css_prop(code: &str) -> bool { /// Whether importing `source` shows a file uses Emotion pub(crate) fn is_emotion(source: &str) -> bool { - [EMOTION_REACT, "@emotion/styled"].iter().any(|package| { - source - .strip_prefix(package) - .is_some_and(|rest| rest.is_empty() || rest.starts_with('/')) - }) + [EMOTION_REACT, "@emotion/styled"] + .iter() + .any(|package| crate::package_specifier::is_package(source, package)) } /// Where the JSX pragma in the comment text `comment` names `@emotion/react` @@ -288,12 +335,27 @@ pub(crate) const UNPLACED: &str = pub(crate) const NESTED_MIXIN: &str = "a mixin must stand outside nested rules, where the parts it composes with can be split"; +/// The binding a read stands for, `None` for a global +pub(crate) type Resolve<'r> = &'r dyn Fn(&IdentifierReference<'_>) -> Option; + +/// [`theme_rules_of`] for code no semantic analysis has read, whose names bind +/// nothing +#[cfg(test)] +pub(crate) fn theme_rules<'a>( + ast: &AstBuilder<'a>, + function: &Expression<'a>, +) -> Result, (Expression<'a>, &'static str)> { + theme_rules_of(ast, function, &|_| None) +} + /// `function`, a function of the theme, as the rules it gives, each read of -/// the theme written as the CSS variable the `ThemeProvider` sets for it. +/// the theme written as the CSS variable the `ThemeProvider` sets for it: only +/// the reads of the binding its parameter declares, as `resolve` tells them. /// `Err` holds the code the build cannot write so, with what it requires. -pub(crate) fn theme_rules<'a>( +pub(crate) fn theme_rules_of<'a>( ast: &AstBuilder<'a>, function: &Expression<'a>, + resolve: Resolve<'_>, ) -> Result, (Expression<'a>, &'static str)> { let unsupported = || (function.clone_in(ast.allocator()), THEME_FUNCTION); let (params, body) = match function { @@ -317,7 +379,7 @@ pub(crate) fn theme_rules<'a>( let mut reads = ThemeReads { ast, roots: &roots, - binding: None, + resolve, unread: None, }; reads.visit(&mut rules, false); @@ -381,15 +443,6 @@ impl ClassNamesParams { } } -pub(crate) fn binding_of( - scoping: &Scoping, - identifier: &oxc_ast::ast::IdentifierReference<'_>, -) -> Option { - scoping - .get_reference(identifier.reference_id.get()?) - .symbol_id() -} - /// What `params` take of `{ css, cx, theme }`; `None` when they take it in a /// way the build cannot follow pub(crate) fn class_names_params(params: &FormalParameters<'_>) -> Option { @@ -428,8 +481,10 @@ pub(crate) fn class_names_params(params: &FormalParameters<'_>) -> Option` element `element` takes -pub(crate) fn class_names_child(element: &JSXElement<'_>) -> Option { +/// The parameters of the child function of the `` element `element` +fn class_names_function_params<'b, 'a>( + element: &'b JSXElement<'a>, +) -> Option<&'b FormalParameters<'a>> { let mut children = element .children .iter() @@ -438,12 +493,16 @@ pub(crate) fn class_names_child(element: &JSXElement<'_>) -> Option &arrow.params, - Expression::FunctionExpression(function) => &function.params, - _ => return None, - }; - class_names_params(params) + match container.expression.as_expression()? { + Expression::ArrowFunctionExpression(arrow) => Some(&arrow.params), + Expression::FunctionExpression(function) => Some(&function.params), + _ => None, + } +} + +/// What the child function of the `` element `element` takes +pub(crate) fn class_names_child(element: &JSXElement<'_>) -> Option { + class_names_function_params(element).and_then(class_names_params) } /// The child function's `css` and `cx` bindings, which read styles. @@ -463,29 +522,47 @@ pub(crate) fn read_theme<'a>( value: bool, resolve: Resolve<'_>, ) -> Result<(), Expression<'a>> { + let roots: Vec<_> = names + .iter() + .filter_map(|names| names.theme) + .map(|symbol| ThemeRoot { + name: "", + symbol: Some(symbol), + key: None, + }) + .collect(); let mut reads = ThemeReads { ast, - roots: &[], - binding: Some((names, resolve)), + roots: &roots, + resolve, unread: None, }; reads.visit(expression, value); reads.unread.map_or(Ok(()), Err) } -/// The names a function of the theme reads it by, each with the key of the -/// theme it holds when destructured; `None` for parameters the build cannot -/// follow -fn theme_roots<'a>(params: &FormalParameters<'a>) -> Option)>> { +/// A binding a function of the theme reads it by, with the key of the theme it +/// holds when destructured +pub(crate) struct ThemeRoot<'a> { + pub name: &'a str, + pub symbol: Option, + pub key: Option, +} + +/// The bindings a function of the theme reads it by; `None` for parameters +/// the build cannot follow +fn theme_roots<'a>(params: &FormalParameters<'a>) -> Option>> { if params.rest.is_some() { return None; } match params.items.as_slice() { [] => Some(Vec::new()), [param] if param.initializer.is_none() => match ¶m.pattern { - BindingPattern::BindingIdentifier(identifier) => { - Some(vec![(identifier.name.as_str(), None)]) - } + BindingPattern::BindingIdentifier(identifier) => Some(vec![ThemeRoot { + name: identifier.name.as_str(), + symbol: identifier.symbol_id.get(), + key: None, + }]), BindingPattern::ObjectPattern(object) if object.rest.is_none() => object .properties .iter() @@ -494,7 +571,11 @@ fn theme_roots<'a>(params: &FormalParameters<'a>) -> Option { - Some((local.name.as_str(), Some(key.to_string()))) + Some(ThemeRoot { + name: local.name.as_str(), + symbol: local.symbol_id.get(), + key: Some(key.to_string()), + }) } _ => None, }, @@ -510,8 +591,8 @@ fn theme_roots<'a>(params: &FormalParameters<'a>) -> Option { ast: &'r AstBuilder<'a>, - roots: &'r [(&'a str, Option)], - binding: Option<(&'r [ClassNamesParams], Resolve<'r>)>, + roots: &'r [ThemeRoot<'a>], + resolve: Resolve<'r>, unread: Option>, } @@ -548,21 +629,12 @@ impl<'a> ThemeReads<'_, 'a> { } Expression::ParenthesizedExpression(inner) => cursor = &inner.expression, Expression::Identifier(identifier) => { - if let Some((names, resolve)) = self.binding { - let symbol = resolve(identifier); - if !names - .iter() - .any(|names| ClassNamesParams::is(names.theme, symbol)) - { - return None; - } - } else { - let (_, key) = self - .roots - .iter() - .find(|(local, _)| *local == identifier.name.as_str())?; - path.extend(key.clone()); - } + let symbol = (self.resolve)(identifier); + let root = self.roots.iter().find(|root| { + root.symbol == symbol + && (symbol.is_some() || root.name == identifier.name.as_str()) + })?; + path.extend(root.key.clone()); path.reverse(); return Some(if exact && !path.is_empty() { ThemeRead::Path(path) @@ -1000,3 +1072,6 @@ mod tests { ); } } + +#[cfg(test)] +mod scope_tests; diff --git a/libs/extractor/src/css_prop/scope_tests.rs b/libs/extractor/src/css_prop/scope_tests.rs new file mode 100644 index 000000000..cdfbb56e7 --- /dev/null +++ b/libs/extractor/src/css_prop/scope_tests.rs @@ -0,0 +1,94 @@ +use oxc_allocator::Allocator; +use oxc_ast::ast::{Expression, IdentifierReference}; +use oxc_ast_visit::Visit; +use oxc_parser::Parser; +use oxc_semantic::{Scoping, SemanticBuilder}; +use oxc_span::SourceType; + +use super::{binding_of, reads_top_level, root_reference}; + +struct Reads<'s> { + scoping: &'s Scoping, + found: Vec<(String, bool, bool)>, +} + +impl<'a> Visit<'a> for Reads<'_> { + fn visit_identifier_reference(&mut self, identifier: &IdentifierReference<'a>) { + self.found.push(( + identifier.name.to_string(), + binding_of(self.scoping, identifier).is_some(), + reads_top_level(self.scoping, identifier), + )); + } +} + +#[test] +fn references_tell_locals_from_top_level_bindings_and_globals() { + let allocator = Allocator::default(); + let program = Parser::new( + &allocator, + "const top = 1; function f(local) { return [top, local, global]; }", + SourceType::tsx(), + ) + .parse() + .program; + let scoping = SemanticBuilder::new() + .build(&program) + .semantic + .into_scoping(); + let mut reads = Reads { + scoping: &scoping, + found: Vec::new(), + }; + reads.visit_program(&program); + assert_eq!( + reads.found, + vec![ + ("top".to_string(), true, true), + ("local".to_string(), true, false), + ("global".to_string(), false, true), + ] + ); +} + +#[test] +fn a_reference_without_semantic_data_reads_a_global() { + let allocator = Allocator::default(); + let program = Parser::new(&allocator, "", SourceType::tsx()) + .parse() + .program; + let scoping = SemanticBuilder::new() + .build(&program) + .semantic + .into_scoping(); + let Ok(Expression::Identifier(identifier)) = + Parser::new(&allocator, "a", SourceType::tsx()).parse_expression() + else { + panic!("a"); + }; + assert_eq!(binding_of(&scoping, &identifier), None); + assert!(reads_top_level(&scoping, &identifier)); +} + +#[test] +fn the_root_of_a_member_chain_is_the_identifier_it_starts_at() { + let allocator = Allocator::default(); + for (code, root) in [ + ("a", Some("a")), + ("a.b", Some("a")), + ("a[b]", Some("a")), + ("a.b[c].d", Some("a")), + ("a()", None), + ("'a'", None), + ] { + let Ok(expression) = Parser::new(&allocator, code, SourceType::tsx()).parse_expression() + else { + panic!("{code}"); + }; + assert_eq!( + root_reference(&expression).map(|identifier| identifier.name.as_str()), + root, + "{code}" + ); + } +} diff --git a/libs/extractor/src/css_prop_contract_tests.rs b/libs/extractor/src/css_prop_contract_tests.rs index 51c149662..fe39430b7 100644 --- a/libs/extractor/src/css_prop_contract_tests.rs +++ b/libs/extractor/src/css_prop_contract_tests.rs @@ -82,7 +82,7 @@ fn empty_css_expression_is_rejected_when_mixed_with_valid_forms() { // Then: the original invalid input has the exact located parser error. assert_eq!( error, - "test.tsx:1:192: JSX attributes must only be assigned a non-empty 'expression'." + "test.tsx:1:192: Cannot parse source: JSX attributes must only be assigned a non-empty 'expression'. Fix: correct the syntax at this location" ); } diff --git a/libs/extractor/src/dead_properties.rs b/libs/extractor/src/dead_properties.rs index c93a8e17a..19b2d5af1 100644 --- a/libs/extractor/src/dead_properties.rs +++ b/libs/extractor/src/dead_properties.rs @@ -6,16 +6,17 @@ use crate::{ }; use css::utils::to_kebab_case; +mod analysis; mod calls; +mod instrument; mod objects; mod origin; mod strings; pub(crate) use origin::{evaluated_calls, map_evaluated}; mod text; -pub(crate) use calls::instrument; +pub(crate) use instrument::{instrument, instrument_evaluated, instrument_located}; pub(crate) use objects::authored_errors; pub(crate) use objects::{ObjectKind, evaluated_errors}; -pub(crate) const ERROR_CHANNEL: &str = "DEVUP_DEAD_DECLARATION\n"; pub(crate) fn terminal_error(message: &str) -> bool { message diff --git a/libs/extractor/src/dead_properties/analysis.rs b/libs/extractor/src/dead_properties/analysis.rs new file mode 100644 index 000000000..3f54020df --- /dev/null +++ b/libs/extractor/src/dead_properties/analysis.rs @@ -0,0 +1,56 @@ +use crate::{import_alias_visit::source_offset, vanilla_extract::Stylesheet}; + +pub(super) struct Analysis { + pub insertions: Vec<(u32, String)>, + pub errors: Vec<(u32, String)>, + pub ranges: Vec<(u32, u32)>, +} + +impl Analysis { + pub fn diagnostics( + &self, + input: Stylesheet<'_>, + evaluated: Option<&[(usize, usize)]>, + ) -> Result { + let authored = |offset| { + input + .edits + .iter() + .fold(offset, |offset, edits| source_offset(edits, offset)) + }; + self.errors + .iter() + .map(|(offset, message)| { + let offset = usize::try_from(*offset).map_err(|error| error.to_string())?; + let key = authored(offset); + let call = self + .ranges + .iter() + .filter(|(start, end)| { + usize::try_from(*start).is_ok_and(|start| start <= offset) + && usize::try_from(*end).is_ok_and(|end| offset <= end) + }) + .min_by_key(|(start, end)| end - start) + .map(|(start, _)| usize::try_from(*start).map(authored)) + .transpose() + .map_err(|error| error.to_string())?; + let owner = evaluated.and_then(|ranges| { + ranges + .iter() + .filter(|(start, end)| { + call.is_some_and(|call| *start <= call && call <= *end) + && *start <= key + && key <= *end + }) + .min_by_key(|(start, end)| end - start) + }); + let at = owner.map_or(key, |(start, _)| *start); + Ok(format!( + "{}: {message}", + crate::locate(input.filename, input.source, at) + )) + }) + .collect::, String>>() + .map(|errors| errors.join("\n")) + } +} diff --git a/libs/extractor/src/dead_properties/calls.rs b/libs/extractor/src/dead_properties/calls.rs index 36c146bfb..9e4d40207 100644 --- a/libs/extractor/src/dead_properties/calls.rs +++ b/libs/extractor/src/dead_properties/calls.rs @@ -1,25 +1,34 @@ //! Authored styling-call metadata carried through stylesheet execution. +use super::analysis::Analysis; use super::objects::{ObjectKind, authored_errors}; -use oxc_ast::ast::{CallExpression, Expression, ImportDeclarationSpecifier, Statement}; -use oxc_ast_visit::{Visit, walk::walk_call_expression}; +use oxc_ast::ast::{ + CallExpression, Expression, ImportDeclarationSpecifier, Statement, TaggedTemplateExpression, +}; +use oxc_ast_visit::{ + Visit, + walk::{walk_call_expression, walk_tagged_template_expression}, +}; +use oxc_span::GetSpan; use rustc_hash::FxHashMap; -pub(crate) struct Instrumented { - pub code: String, - pub calls: Vec<(u32, u32)>, -} - -pub(crate) fn instrument( - code: &str, - filename: &str, +pub(super) fn collect( + stylesheet: crate::vanilla_extract::Stylesheet<'_>, package: &str, -) -> Result { +) -> Analysis { + let crate::vanilla_extract::Stylesheet { + filename, + code, + source, + edits, + } = stylesheet; let allocator = oxc_allocator::Allocator::default(); let parsed = oxc_parser::Parser::new( &allocator, code, - oxc_span::SourceType::from_path(filename).unwrap_or_else(|_| oxc_span::SourceType::ts()), + oxc_span::SourceType::from_path(filename) + .unwrap_or_else(|_| oxc_span::SourceType::ts()) + .with_module(true), ) .parse(); let semantic = oxc_semantic::SemanticBuilder::new() @@ -45,8 +54,9 @@ pub(crate) fn instrument( } } let mut visitor = Calls { - code, filename, + source, + edits, imports, scoping: semantic.scoping(), insertions: Vec::new(), @@ -61,32 +71,21 @@ pub(crate) fn instrument( } } visitor.visit_program(&parsed.program); - if !visitor.errors.is_empty() { - return Err(visitor.errors.join("\n")); - } - visitor.insertions.sort_by_key(|(offset, _)| *offset); - let mut output = String::new(); - let mut copied = 0; - for (offset, insertion) in visitor.insertions { - let offset = usize::try_from(offset).map_err(|error| error.to_string())?; - output.push_str(&code[copied..offset]); - output.push_str(&insertion); - copied = offset; + Analysis { + insertions: visitor.insertions, + errors: visitor.errors, + ranges: visitor.ranges, } - output.push_str(&code[copied..]); - Ok(Instrumented { - code: output, - calls: visitor.ranges, - }) } struct Calls<'s> { - code: &'s str, filename: &'s str, + source: &'s str, + edits: &'s [&'s [crate::import_alias_visit::Edit]], imports: FxHashMap, scoping: &'s oxc_semantic::Scoping, insertions: Vec<(u32, String)>, - errors: Vec, + errors: Vec<(u32, String)>, ranges: Vec<(u32, u32)>, } @@ -111,14 +110,21 @@ impl<'a> Visit<'a> for Aliases<'_, '_> { impl Calls<'_> { fn api(&self, expression: &Expression<'_>) -> Option { - let (identifier, member) = match expression { + let (identifier, member) = match crate::utils::unwrap_syntax_only(expression) { Expression::Identifier(identifier) => (identifier.as_ref(), None), Expression::StaticMemberExpression(member) => match &member.object { Expression::Identifier(identifier) => { - (identifier.as_ref(), Some(member.property.name.as_str())) + (identifier.as_ref(), Some(member.property.name.to_string())) } _ => return None, }, + Expression::ComputedMemberExpression(member) => match &member.object { + Expression::Identifier(identifier) => ( + identifier.as_ref(), + Some(member.static_property_name()?.to_string()), + ), + _ => return None, + }, _ => return None, }; let symbol = self @@ -127,7 +133,7 @@ impl Calls<'_> { .symbol_id()?; let imported = self.imports.get(&symbol)?; match (imported.as_str(), member) { - ("*", Some(member)) => Some(member.to_string()), + ("*", Some(member)) => Some(member), ("styled", Some(_)) => Some("styled".to_string()), (_, None) => Some(imported.clone()), _ => None, @@ -135,14 +141,41 @@ impl Calls<'_> { } fn location(&self, offset: u32) -> String { - let prefix = &self.code[..usize::try_from(offset).unwrap_or(0)]; - let line = prefix.bytes().filter(|byte| *byte == b'\n').count() + 1; - let column = prefix.rsplit('\n').next().unwrap_or("").chars().count() + 1; - format!("{}:{line}:{column}", self.filename) + let offset = self + .edits + .iter() + .fold(usize::try_from(offset).unwrap_or(0), |offset, edits| { + crate::import_alias_visit::source_offset(edits, offset) + }); + crate::locate(self.filename, self.source, offset) } } impl<'a> Visit<'a> for Calls<'_> { + fn visit_tagged_template_expression(&mut self, tagged: &TaggedTemplateExpression<'a>) { + let tag = crate::utils::unwrap_syntax_only(&tagged.tag); + if self.api(tag).as_deref() == Some("css") { + // Keep the template site and its eval/await/yield expressions outside the forwarding closure. + let receiver = match tag { + Expression::StaticMemberExpression(member) => { + crate::utils::readable_code(&member.object) + } + Expression::ComputedMemberExpression(member) => { + crate::utils::readable_code(&member.object) + } + _ => "undefined".to_string(), + }; + self.ranges.push((tagged.span.start, tagged.span.end)); + self.insertions.push((tagged.tag.span().start, format!( + "((__receiver, __tag) => (...__args) => __vanilla_extract__.__at({:?}, \"css\", __tag, __receiver, __args))({receiver}, (", + self.location(tagged.span.start), + ))); + self.insertions + .push((tagged.tag.span().end, "))".to_string())); + } + walk_tagged_template_expression(self, tagged); + } + fn visit_call_expression(&mut self, call: &CallExpression<'a>) { if let Some(api) = self.api(&call.callee) { let shape = match api.as_str() { @@ -162,10 +195,9 @@ impl<'a> Visit<'a> for Calls<'_> { .and_then(|argument| argument.as_expression()) { for (offset, path, requirement) in authored_errors(expression, kind) { - self.errors.push(format!( - "{}: {}", - self.location(offset), - crate::utils::build_time_error(&api, &path, requirement) + self.errors.push(( + offset, + crate::utils::build_time_error(&api, &path, requirement), )); } } diff --git a/libs/extractor/src/dead_properties/instrument.rs b/libs/extractor/src/dead_properties/instrument.rs new file mode 100644 index 000000000..549c7d4d9 --- /dev/null +++ b/libs/extractor/src/dead_properties/instrument.rs @@ -0,0 +1,105 @@ +use crate::{import_alias_visit::Edit, vanilla_extract::Stylesheet}; + +pub(crate) struct Instrumented { + pub code: String, + pub calls: Vec<(u32, u32)>, + pub edits: Vec, +} + +impl Instrumented { + pub(crate) fn generated(&self, offset: usize) -> bool { + let mut inserted = 0; + self.edits.iter().any(|&(at, _, length)| { + let start = at + inserted; + inserted += length; + (start..start + length).contains(&offset) + }) + } +} + +pub(crate) fn instrument( + code: &str, + filename: &str, + package: &str, +) -> Result { + instrument_located( + Stylesheet { + code, + filename, + source: code, + edits: &[], + }, + package, + ) +} + +pub(crate) fn instrument_located( + input: Stylesheet<'_>, + package: &str, +) -> Result { + instrument_evaluated(input, package, None) +} + +pub(crate) fn instrument_evaluated( + input: Stylesheet<'_>, + package: &str, + evaluated: Option<&[(usize, usize)]>, +) -> Result { + let mut analysis = super::calls::collect(input, package); + if !analysis.errors.is_empty() { + if evaluated.is_some() { + let authored = Stylesheet { + code: input.source, + edits: &[], + ..input + }; + let original = super::calls::collect(authored, package); + if !original.errors.is_empty() { + return Err(original.diagnostics(authored, None)?); + } + } + return Err(analysis.diagnostics(input, evaluated)?); + } + analysis.insertions.sort_by_key(|(offset, _)| *offset); + let mut code = String::new(); + let mut copied = 0; + let mut edits = Vec::new(); + for (offset, insertion) in analysis.insertions { + let offset = usize::try_from(offset).map_err(|error| error.to_string())?; + code.push_str(&input.code[copied..offset]); + code.push_str(&insertion); + edits.push((offset, offset, insertion.len())); + copied = offset; + } + code.push_str(&input.code[copied..]); + Ok(Instrumented { + code, + calls: analysis.ranges, + edits, + }) +} + +#[cfg(test)] +mod tests { + #[test] + fn evaluated_instrumentation_prefers_the_authored_dead_key() -> Result<(), String> { + // Given: retained evaluation reports a different dead key than the authored call. + let source = "import{css}from'@devup-ui/react';\ncss({boxAlign:'start'});"; + let code = "import{css}from'@devup-ui/react';css({boxPack:'end'});"; + let input = super::Stylesheet { + source, + code, + filename: "original.css.ts", + edits: &[], + }; + // When + let error = super::instrument_evaluated(input, "@devup-ui/react", Some(&[])) + .err() + .ok_or("authored dead key must reject")?; + // Then + assert!(error.starts_with("original.css.ts:2:6:"), "{error}"); + assert!(error.contains("boxAlign"), "{error}"); + assert_eq!(error.matches("boxPack").count(), 0, "{error}"); + Ok(()) + } +} diff --git a/libs/extractor/src/diagnostics_tests.rs b/libs/extractor/src/diagnostics_tests.rs new file mode 100644 index 000000000..c698747e4 --- /dev/null +++ b/libs/extractor/src/diagnostics_tests.rs @@ -0,0 +1,90 @@ +use crate::{ExtractOption, extract_without_source_map}; +use serial_test::serial; + +#[test] +#[serial] +fn fatal_parse_reports_original_file_and_first_parser_error() { + let source = "import { Box } from '@devup-ui/react';\nexport const view = ;"; + + let error = extract_without_source_map("/src/broken.tsx", source, ExtractOption::default()) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + + assert!(error.starts_with("/src/broken.tsx:2:31:"), "{error}"); + assert!(error.contains("Unterminated regular expression"), "{error}"); + assert!(error.contains("Fix:"), "{error}"); +} + +#[test] +#[serial] +fn unsupported_extension_reports_the_input_file_and_repair() { + let source = "import { Box } from '@devup-ui/react';"; + + let error = extract_without_source_map("/src/broken.unknown", source, ExtractOption::default()) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + + assert!(error.starts_with("/src/broken.unknown:1:1:"), "{error}"); + assert!(error.contains("Unknown file extension"), "{error}"); + assert!(error.contains("Fix:"), "{error}"); +} + +#[rstest::rstest] +#[case("\n")] +#[case("\r")] +#[case("\r\n")] +#[case("\u{2028}")] +#[case("\u{2029}")] +fn locations_follow_javascript_line_terminators(#[case] separator: &str) { + let source = format!("first{separator}한😀x"); + + let location = crate::locate("/src/unicode.ts", &source, source.len()); + + assert_eq!(location, "/src/unicode.ts:2:4"); +} + +#[rstest::rstest] +#[case("return 1;")] +#[case("const invalid = /[z-a]/;")] +#[case("const repeated = 1; const repeated = 2;")] +#[case("const invalid = 01;")] +#[serial] +fn recoverable_and_semantic_errors_do_not_publish_css(#[case] invalid: &str) { + let source = format!( + "import {{ css }} from '@devup-ui/react';\n{invalid}\nexport const card = css({{ color: 'red' }});" + ); + + let error = extract_without_source_map("/src/syntax.tsx", &source, ExtractOption::default()) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + + assert!( + error.starts_with("/src/syntax.tsx:2:"), + "{invalid}: {error}" + ); + assert!(error.contains("Cannot parse source:"), "{error}"); + assert!(error.contains("Fix:"), "{error}"); +} + +#[rstest::rstest] +#[case("")] +#[case("")] +#[case("")] +#[case("")] +#[case("
")] +#[serial] +fn empty_jsx_attribute_expressions_are_syntax_errors_not_absent_values(#[case] element: &str) { + let source = format!("import {{ Box }} from '@devup-ui/react';\nexport const App = {element};"); + + let error = extract_without_source_map("/src/empty.tsx", &source, ExtractOption::default()) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + + assert!(error.starts_with("/src/empty.tsx:2:"), "{error}"); + assert!(error.contains("Cannot parse source:"), "{error}"); + assert!(error.contains("non-empty 'expression'"), "{error}"); +} diff --git a/libs/extractor/src/emotion_namespace_tests/diagnostics.rs b/libs/extractor/src/emotion_namespace_tests/diagnostics.rs index 89b9ee7ec..09ec3b4d3 100644 --- a/libs/extractor/src/emotion_namespace_tests/diagnostics.rs +++ b/libs/extractor/src/emotion_namespace_tests/diagnostics.rs @@ -11,7 +11,7 @@ fn syntax_errors_are_reported_before_namespace_normalization() { let error = compile(source).unwrap_err(); // Then - assert_eq!(error, "namespace.tsx:2:27: Missing initializer in const declaration"); + assert_eq!(error, "namespace.tsx:2:27: Cannot parse source: Missing initializer in const declaration. Fix: correct the syntax at this location"); } #[test] @@ -29,6 +29,22 @@ fn restoration_preserves_the_diagnostic_when_its_source_is_malformed() { assert!(error.contains("Missing initializer in const declaration")); } +#[test] +#[serial] +fn emotion_restoration_reports_malformed_source_when_normalization_rejects_a_read() { + // Given: restoration receives a located namespace diagnostic and malformed source. + let source = "import * as E from '@emotion/css';\nE.__emotion_user(); const broken;"; + let message = format!("namespace.tsx:2:1: {}", utils::build_time_error("@emotion/css", "E.__emotion_user", "use a supported styling API or a runtime-only Emotion export")); + + // When + let error = emotion_namespace::original_error_code(source, source.find("E.__emotion_user").unwrap(), message, SourceType::tsx()); + + // Then + assert!(error.starts_with("namespace.tsx:2:1:"), "{error}"); + assert!(error.contains("E.__emotion_user"), "{error}"); + assert!(error.contains("Emotion diagnostic restoration failed:"), "{error}"); +} + #[test] #[serial] fn emotion_restoration_preserves_generic_runtime_read_errors_when_user_names_match_prefix() { diff --git a/libs/extractor/src/evaluation_sandbox.rs b/libs/extractor/src/evaluation_sandbox.rs new file mode 100644 index 000000000..cb4a1c493 --- /dev/null +++ b/libs/extractor/src/evaluation_sandbox.rs @@ -0,0 +1,301 @@ +//! The deterministic Boa sandbox every build-time evaluation runs in. +//! +//! What the build computes must be the same on every build and page, so what +//! differs between them (the clock, chance, the page or process running the +//! code, the locale) cannot be read. The host records each such read before +//! it throws, so code catching the error cannot hide it: a run that recorded +//! one fails whatever its code did after, and the engine's call frames of the +//! read are kept for the error that names where it happened. +//! +//! The sandbox is set up by native code and by one small source of its own, +//! [`INTERNAL_SOURCE`], so no frame of the code that runs is shifted by it. + +use std::cell::RefCell; +use std::rc::Rc; + +use boa_engine::{ + Context, JsArgs, JsError, JsNativeError, JsObject, JsResult, JsString, JsValue, NativeFunction, + Source, js_string, + object::{FunctionObjectBuilder, builtins::JsProxy}, + parser::source::ReadChar, + property::Attribute, +}; + +/// The path of the source setting the sandbox up: its call frames are the +/// sandbox's own, not the code's +pub(crate) const INTERNAL_SOURCE: &str = ""; + +/// Loop iterations an evaluation may run before it fails instead of hanging +/// the build +const LOOP_ITERATION_LIMIT: u64 = 10_000_000; + +/// What a read gives that only the build, the page or the engine decides +#[derive(Clone, Copy)] +enum Kind { + Clock, + Random, + Environment, + Locale, + Source, + Css, +} + +impl Kind { + const fn requirement(self) -> &'static str { + match self { + Self::Clock => "the time it gives differs on every build", + Self::Random => "the chance it gives differs on every build", + Self::Environment => "only the page or process running the code knows it", + Self::Locale => "its result depends on the locale and the engine's Unicode data", + Self::Source => "its text changes when the build transforms the function", + Self::Css => { + "CSS module class names are generated by the bundler and are unknown at build time" + } + } + } + + /// Of what `name` is: a global or `owner.member`, or `Type.prototype.method` + fn of(name: &str) -> Self { + match name.split('.').next().unwrap_or_default() { + "Date" | "performance" => Self::Clock, + "crypto" | "Math" => Self::Random, + "Intl" | "Temporal" => Self::Locale, + "Function" => Self::Source, + _ if name.contains(".prototype.") => Self::Locale, + _ => Self::Environment, + } + } +} + +#[path = "evaluation_sandbox_guards.rs"] +mod guards; +use guards::{SETUP, guard_methods}; + +#[path = "evaluation_sandbox_reflection.rs"] +mod reflection; +use reflection::guard_globals; + +#[path = "evaluation_sandbox_sites.rs"] +mod sites; +pub(crate) use sites::{Instrumented, instrument, instrument_located}; + +#[path = "evaluation_sandbox_css.rs"] +mod css; +pub(crate) use css::css_object; + +#[path = "evaluation_sandbox_evidence.rs"] +mod evidence; + +#[path = "evaluation_sandbox_date.rs"] +mod date; +pub(crate) use date::check_export; + +struct Recorded { + name: String, + css: Option, + error: JsValue, + site: Option<(String, u32)>, +} + +/// What the host holds of the forbidden reads a run made, out of the code's +/// reach +#[derive(Clone, Default)] +struct Evidence { + reads: Rc>>, + site: Rc>>, + methods: Rc>>, +} + +/// A forbidden read the code made +pub(crate) struct Violation { + name: String, + kind: Kind, + error: JsError, + site: Option<(String, u32)>, + css_message: Option, +} + +impl Violation { + /// Immutable CSS cause and frames, without Boa's native display suffix. + pub(crate) fn css_message(&self) -> Option<&str> { + self.css_message.as_deref() + } + + /// What was read, as the code writes it (`Date`, `String.prototype.normalize`) + pub(crate) fn name(&self) -> &str { + &self.name + } + + /// Why the read cannot be known at build time + pub(crate) const fn requirement(&self) -> &'static str { + self.kind.requirement() + } + + /// The error with an instrumented read frame, or the engine's actual call frames. + pub(crate) const fn error(&self) -> &JsError { + &self.error + } + + /// The original offset of an instrumented read and the source containing it. + pub(crate) fn site(&self) -> Option<(&str, usize)> { + self.site + .as_ref() + .map(|(place, offset)| (place.as_str(), *offset as usize)) + } +} + +/// Why a run failed +pub(crate) enum Failure { + /// The code read what the build cannot know, however it was written: + /// every read it made is listed + Forbidden(Vec), + /// The code failed for another reason + Js(JsError), +} + +/// The deterministic environment of one [`Context`], and the evidence of the +/// reads it forbade +pub(crate) struct Sandbox { + evidence: Evidence, +} + +fn record(context: &mut Context, name: &str, site: Option<(String, u32)>) -> JsValue { + let frame = site.as_ref().map_or_else(String::new, |(place, _)| { + format!("\n at ({place})") + }); + let error = JsNativeError::reference() + .with_message(format!( + "`{name}` cannot be read at build time: {}{frame}", + Kind::of(name).requirement() + )) + .into_opaque(context); + let error = JsValue::from(error); + if let Some(evidence) = context.get_data::() { + evidence.reads.borrow_mut().push(Recorded { + name: name.to_string(), + css: None, + error: error.clone(), + site, + }); + } + error +} + +/// The recorder called by the guards written in [`SETUP`]. +fn forbid_native(_: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult { + let name = args.get_or_undefined(0).to_string(context)?; + let site = context + .get_data::() + .and_then(|evidence| evidence.site.borrow().clone()); + Ok(record(context, &name.to_std_string_escaped(), site)) +} + +/// A function the realm owns +fn function(context: &Context, name: &str, native: NativeFunction) -> JsObject { + let realm = context.realm().clone(); + FunctionObjectBuilder::new(&realm, native) + .name(JsString::from(name)) + .build() + .into() +} + +/// A console whose calls do nothing, as what a module logs changes no value +fn install_console(context: &mut Context) -> JsResult<()> { + let target = JsObject::with_null_proto(); + let console = JsProxy::builder(target) + .get(|_, _, context| { + let call = NativeFunction::from_copy_closure(|_, _, _| Ok(JsValue::undefined())); + Ok(function(context, "", call).into()) + }) + .build(context)?; + context.register_global_property( + js_string!("console"), + console, + Attribute::WRITABLE | Attribute::CONFIGURABLE, + ) +} + +#[cfg(test)] +#[path = "evaluation_sandbox_tests.rs"] +mod tests; + +#[cfg(test)] +#[path = "evaluation_sandbox_reflection_tests.rs"] +mod reflection_tests; + +#[cfg(test)] +#[path = "evaluation_sandbox_date_tests.rs"] +mod date_tests; + +impl Sandbox { + /// Installs the instrumented source's native identity/thunk helper. + pub(crate) fn prepare(&self, context: &mut Context, source: &Instrumented) -> JsResult<()> { + // A new script has no read site until its instrumentation establishes one. + *self.evidence.site.borrow_mut() = None; + context.register_global_builtin_callable( + JsString::from(source.helper.as_str()), + 3, + NativeFunction::from_copy_closure_with_captures( + |_, args, sites, context| { + let index = args.get_or_undefined(0).to_u32(context)? as usize; + if let Some((place, offset)) = sites.get(index) + && let Some(evidence) = context.get_data::() + { + *evidence.site.borrow_mut() = Some((place.clone(), *offset)); + } + let value = args.get_or_undefined(1); + if args.get_or_undefined(2).to_boolean() { + value + .to_object(context)? + .call(&JsValue::undefined(), &[], context) + } else { + Ok(value.clone()) + } + }, + source.sites.clone(), + ), + ) + } + + /// Makes `context` deterministic: the clock, chance, the environment and + /// the locale cannot be read, an unbounded loop fails instead of hanging + /// the build, and `console` calls do nothing + pub(crate) fn new(context: &mut Context) -> JsResult { + context + .runtime_limits_mut() + .set_loop_iteration_limit(LOOP_ITERATION_LIMIT); + let evidence = Evidence::default(); + context.insert_data(evidence.clone()); + install_console(context)?; + guard_methods(context)?; + guard_globals(context)?; + Ok(Self { evidence }) + } + + /// Adds only the stylesheet's numeric Date construction and UTC reads. + pub(crate) fn stylesheet(context: &mut Context) -> JsResult { + let sandbox = Self::new(context)?; + date::install(context)?; + Ok(sandbox) + } + + /// Runs `source`, failing if it or anything run before it read what the + /// build cannot know, whether or not its code caught the error. A source + /// with a path names its call frames by it. + pub(crate) fn run_source( + &self, + context: &mut Context, + source: Source<'_, R>, + ) -> Result { + match context.eval(source) { + Ok(value) => self.check(&[]).map(|()| value), + Err(error) => { + self.check(&[&error])?; + Err(Failure::Js(error)) + } + } + } +} + +#[cfg(test)] +mod coverage_tests; diff --git a/libs/extractor/src/evaluation_sandbox/coverage_tests.rs b/libs/extractor/src/evaluation_sandbox/coverage_tests.rs new file mode 100644 index 000000000..e3f9cda28 --- /dev/null +++ b/libs/extractor/src/evaluation_sandbox/coverage_tests.rs @@ -0,0 +1,148 @@ +use boa_engine::{Context, JsValue, Source}; +use rstest::rstest; + +use super::{Failure, Sandbox, instrument}; + +fn evaluate(script: &str) -> Result { + let mut context = Context::default(); + let sandbox = Sandbox::new(&mut context).map_err(|error| error.to_string())?; + let source = instrument(script, "boundary.js"); + sandbox + .prepare(&mut context, &source) + .map_err(|error| error.to_string())?; + match sandbox.run_source(&mut context, Source::from_bytes(&source.code)) { + Ok(value) => value + .to_string(&mut context) + .map(|text| text.to_std_string_escaped()) + .map_err(|error| error.to_string()), + Err(Failure::Js(error)) => Err(error.to_string()), + Err(Failure::Forbidden(reads)) => Err(format!( + "forbidden: {}", + reads + .iter() + .map(super::Violation::name) + .collect::>() + .join(",") + )), + } +} + +#[rstest] +#[case("Math.random = () => 0.25; Math.random()", "0.25")] +#[case( + "String.prototype.normalize = function() { return this + '!'; }; 'a'.normalize()", + "a!" +)] +#[case( + "const o = { x: 4 }; Object.getOwnPropertyDescriptor(o, 'x').value", + "4" +)] +#[case("Object.getOwnPropertyDescriptor({}, 'missing')", "undefined")] +#[case( + "const o = { get x() { return 5 } }; Object.getOwnPropertyDescriptor(o, 'x').get.call(o)", + "5" +)] +#[case("const key = 'x'; const { [key]: value } = { x: 6 }; value", "6")] +#[case("const { ['x']: value } = { x: 8 }; value", "8")] +#[case( + "const o = { n: 2, add(v) { this.n += v; return this.n } }; o?.add?.(3)", + "5" +)] +#[case("let n = 1; eval('n += 2'); n", "3")] +fn semantics_survive_instrumentation_when_values_are_local( + #[case] script: &str, + #[case] expected: &str, +) -> Result<(), String> { + // Given / When + let result = evaluate(script)?; + // Then + assert_eq!(result, expected); + Ok(()) +} + +#[rstest] +#[case("({ window })", "window")] +#[case("Object.getOwnPropertyDescriptor(Math, 'random')", "Math.random")] +#[case( + "Object.getOwnPropertyDescriptor(String.prototype, 'normalize')", + "String.prototype.normalize" +)] +fn reflection_and_shorthand_fail_when_they_reach_real_guards( + #[case] script: &str, + #[case] name: &str, +) -> Result<(), String> { + // Given / When + let error = evaluate(script) + .err() + .ok_or("guard did not reject the read")?; + // Then + assert_eq!(error, format!("forbidden: {name}")); + Ok(()) +} + +#[rstest] +#[case("Date.now()", true)] +#[case("try { Date.now() } catch {} 4", false)] +fn uninstrumented_reads_keep_evidence_when_errors_are_thrown_or_caught( + #[case] script: &str, + #[case] thrown: bool, +) -> Result<(), String> { + // Given + let mut context = Context::default(); + let sandbox = Sandbox::new(&mut context).map_err(|error| error.to_string())?; + // When + let result = sandbox.run_source(&mut context, Source::from_bytes(script)); + let Err(Failure::Forbidden(reads)) = result else { + return Err("missing forbidden read".to_string()); + }; + // Then + assert_eq!(reads.len(), 1); + assert_eq!(reads[0].name(), "Date"); + assert_eq!(reads[0].site(), None); + assert!(reads[0].error().as_opaque().is_some()); + if thrown { + assert!(reads[0].error().to_string().contains("
")); + } + Ok(()) +} + +#[test] +fn prepare_helper_returns_identity_when_an_index_has_no_read_site() -> Result<(), String> { + // Given + let mut context = Context::default(); + let sandbox = Sandbox::new(&mut context).map_err(|error| error.to_string())?; + let source = instrument("1", "boundary.js"); + sandbox + .prepare(&mut context, &source) + .map_err(|error| error.to_string())?; + // When + let value = context + .eval(Source::from_bytes(&format!("{}(999, 7)", source.helper))) + .map_err(|error| error.to_string())?; + // Then + assert!(value.strict_equals(&JsValue::from(7))); + assert!(sandbox.check(&[]).is_ok()); + Ok(()) +} + +#[test] +fn ordinary_javascript_failures_remain_errors_instead_of_becoming_forbidden_reads() +-> Result<(), String> { + // Given / When + let error = evaluate("throw new TypeError('local failure')") + .err() + .ok_or("throw succeeded")?; + // Then + assert!(error.starts_with("TypeError: local failure")); + Ok(()) +} + +#[rstest] +#[case("TypeError: bad\n at eval (boundary.js:unknown)")] +#[case("TypeError: bad\n at eval (boundary.js:x:y)")] +fn frame_rebasing_preserves_foreign_coordinates_when_eval_has_no_numeric_site(#[case] error: &str) { + // Given + let source = instrument("eval('throw new Error()')", "boundary.js"); + // When / Then + assert_eq!(source.explain(error), error); +} diff --git a/libs/extractor/src/evaluation_sandbox_css.rs b/libs/extractor/src/evaluation_sandbox_css.rs new file mode 100644 index 000000000..3a83dace8 --- /dev/null +++ b/libs/extractor/src/evaluation_sandbox_css.rs @@ -0,0 +1,154 @@ +//! Native observations of bundler-owned CSS objects; no export value is fabricated. + +use boa_engine::{ + Context, JsArgs, JsError, JsNativeError, JsObject, JsResult, JsString, JsValue, NativeFunction, +}; + +use super::{Evidence, Kind, Recorded}; + +pub(super) struct Observation { + pub(super) file: String, + pub(super) error: JsError, + pub(super) message: String, +} + +#[derive(Clone, Copy)] +enum Operation { + Get, + Has, + Keys, + Descriptor, + Prototype, +} + +impl Operation { + fn key(self, args: &[JsValue], context: &mut Context) -> JsResult { + let prefix = match self { + Self::Get => "", + Self::Has => "has:", + Self::Descriptor => "getOwnPropertyDescriptor:", + Self::Keys => return Ok("ownKeys".to_string()), + Self::Prototype => return Ok("getPrototypeOf".to_string()), + }; + let value = args.get_or_undefined(1); + let key = match value.as_symbol() { + Some(symbol) => symbol.descriptive_string().to_std_string_escaped(), + None => value.to_string(context)?.to_std_string_escaped(), + }; + Ok(format!("{prefix}{key}")) + } +} + +struct Request<'a> { + file: &'a str, + args: &'a [JsValue], + operation: Operation, +} + +pub(super) fn message(file: &str, key: &str) -> String { + format!( + "Cannot read CSS export '{key}' of '{file}' at build time: {}", + Kind::Css.requirement() + ) +} + +fn observe(request: Request<'_>, context: &mut Context) -> JsResult { + let Some(evidence) = context.get_data::().cloned() else { + return Err(JsNativeError::error() + .with_message("CSS observation requires a sandbox") + .into()); + }; + let file = request.file; + let name = request.operation.key(request.args, context)?; + let site = evidence.site.borrow().clone(); + let frame = site.as_ref().map_or_else(String::new, |(place, _)| { + format!("\n at ({place})") + }); + let frames = context + .stack_trace() + .enumerate() + .filter_map(|(index, frame)| { + let location = frame.position(); + if index == 0 + && let Some((place, _)) = &site + { + return Some(format!( + "\n at {} ({place})", + location.function_name.to_std_string_escaped() + )); + } + let position = location.position?; + Some(format!( + "\n at {} ({}:{}:{})", + location.function_name.to_std_string_escaped(), + location.path, + position.line_number(), + position.column_number() + )) + }) + .collect::(); + let immutable: JsError = JsNativeError::reference() + .with_message(format!("{}{frame}{frames}", message(file, &name))) + .into(); + let message = format!("ReferenceError: {}{frame}{frames}", message(file, &name)); + let error = immutable.clone().into_opaque(context)?; + let mut reads = evidence.reads.borrow_mut(); + if !reads + .iter() + .any(|read| read.css.as_ref().is_some_and(|css| css.file == file) && read.site == site) + { + reads.push(Recorded { + name, + css: Some(Observation { + file: file.to_string(), + error: immutable, + message, + }), + error: error.clone(), + site, + }); + } + Err(JsError::from_opaque(error)) +} + +/// Creates an opaque object whose actual runtime traps record unknown observations. +pub(crate) fn css_object(context: &mut Context, file: &str) -> JsResult { + let target = JsObject::with_null_proto(); + let handler = JsObject::with_null_proto(); + for (name, operation) in [ + ("get", Operation::Get), + ("has", Operation::Has), + ("ownKeys", Operation::Keys), + ("getOwnPropertyDescriptor", Operation::Descriptor), + ("getPrototypeOf", Operation::Prototype), + ] { + let native = NativeFunction::from_copy_closure_with_captures( + move |_, args, file, context| { + observe( + Request { + file, + args, + operation, + }, + context, + ) + }, + file.to_string(), + ); + handler.create_data_property_or_throw( + JsString::from(name), + super::function(context, name, native), + context, + )?; + } + context + .intrinsics() + .constructors() + .proxy() + .constructor() + .construct(&[target.into(), handler.into()], None, context) +} + +#[cfg(test)] +#[path = "evaluation_sandbox_css_tests.rs"] +mod tests; diff --git a/libs/extractor/src/evaluation_sandbox_css_tests.rs b/libs/extractor/src/evaluation_sandbox_css_tests.rs new file mode 100644 index 000000000..8dc7afa69 --- /dev/null +++ b/libs/extractor/src/evaluation_sandbox_css_tests.rs @@ -0,0 +1,91 @@ +use boa_engine::{Context, Source, js_string}; + +use super::super::{Failure, Sandbox}; + +#[test] +fn css_observation_remains_fatal_when_host_serializes_after_evaluation() -> Result<(), String> { + // Given + let mut context = Context::default(); + let sandbox = Sandbox::new(&mut context).map_err(|error| error.to_string())?; + let object = super::css_object(&mut context, "/src/styles.module.css") + .map_err(|error| error.to_string())?; + let error = object + .get(js_string!("card"), &mut context) + .err() + .ok_or("no CSS value is invented")?; + // When + let Err(Failure::Forbidden(reads)) = sandbox.check(&[&error]) else { + return Err("host read must be a fatal CSS observation".to_string()); + }; + // Then + assert_eq!(reads.len(), 1); + assert!( + reads[0] + .css_message() + .is_some_and(|message| message.contains("export 'card' of '/src/styles.module.css'")) + ); + assert_eq!(reads[0].site(), None); + Ok(()) +} + +#[test] +fn css_observation_reports_context_boundary_when_proxy_is_used_without_sandbox() +-> Result<(), String> { + // Given + let mut context = Context::default(); + let object = super::css_object(&mut context, "/src/styles.module.css") + .map_err(|error| error.to_string())?; + // When + let error = object + .get(js_string!("card"), &mut context) + .err() + .ok_or("a host observer needs its sandbox")?; + // Then + assert!( + error + .to_string() + .contains("CSS observation requires a sandbox") + ); + Ok(()) +} + +#[test] +fn css_observation_is_immutable_when_caught_error_prototype_is_replaced() -> Result<(), String> { + // Given + let mut context = Context::default(); + let sandbox = Sandbox::new(&mut context).map_err(|error| error.to_string())?; + let object = super::css_object(&mut context, "/src/styles.module.css") + .map_err(|error| error.to_string())?; + context + .register_global_property( + js_string!("styles"), + object, + boa_engine::property::Attribute::empty(), + ) + .map_err(|error| error.to_string())?; + let source = super::super::instrument( + "try{styles.card}catch(error){error.message='mutated';ReferenceError.prototype.name='Changed';}try{styles.other}catch{}", + "fixture.js", + ); + sandbox + .prepare(&mut context, &source) + .map_err(|error| error.to_string())?; + // When + let Err(Failure::Forbidden(reads)) = + sandbox.run_source(&mut context, Source::from_bytes(&source.code)) + else { + return Err("caught errors cannot erase host evidence".to_string()); + }; + // Then + assert_eq!(reads.len(), 2); + assert!(reads[0].css_message().is_some_and(|message| { + message.starts_with("ReferenceError: Cannot read CSS export 'card'") + })); + assert!( + !reads[0] + .css_message() + .unwrap_or_default() + .contains("mutated") + ); + Ok(()) +} diff --git a/libs/extractor/src/evaluation_sandbox_date.rs b/libs/extractor/src/evaluation_sandbox_date.rs new file mode 100644 index 000000000..11fbc64a2 --- /dev/null +++ b/libs/extractor/src/evaluation_sandbox_date.rs @@ -0,0 +1,131 @@ +//! Stylesheet-only Date capability; the native constructor never enters authored reach. + +use boa_engine::{ + Context, JsError, JsObject, JsResult, JsString, JsValue, NativeFunction, js_string, + object::FunctionObjectBuilder, + property::{Attribute, PropertyDescriptor, PropertyKey}, +}; + +use super::{Evidence, guards::guard_method, record}; +use std::cell::RefCell; + +#[derive(Default)] +struct Created(RefCell>); + +struct Creation { + date: JsObject, + site: Option<(String, u32)>, +} + +const READS: &[&str] = &[ + "getTime", + "valueOf", + "getUTCDate", + "getUTCDay", + "getUTCFullYear", + "getUTCHours", + "getUTCMilliseconds", + "getUTCMinutes", + "getUTCMonth", + "getUTCSeconds", + "toISOString", + "toUTCString", + "toGMTString", +]; + +pub(super) fn install(context: &mut Context) -> JsResult<()> { + context.insert_data(Created::default()); + let intrinsic = context.intrinsics().constructors().date(); + let original = intrinsic.constructor(); + let prototype = intrinsic.prototype(); + let utc = original.get(js_string!("UTC"), context)?; + let realm = context.realm().clone(); + let constructor = FunctionObjectBuilder::new( + &realm, + NativeFunction::from_copy_closure_with_captures( + |new_target, args, original, context| { + let site = context + .get_data::() + .and_then(|evidence| evidence.site.borrow().clone()); + // No coercion may send a string to the engine parser or reach local-time construction. + if let Some(target) = new_target.as_object() + && let [value] = args + && value.is_number() + { + let date = original.construct(args, Some(&target), context)?; + if let Some(created) = context.get_data::() { + created.0.borrow_mut().push(Creation { + date: date.clone(), + site, + }); + } + return Ok(date.into()); + } + Err(JsError::from_opaque(record(context, "Date", site))) + }, + original, + ), + ) + .name(js_string!("Date")) + .length(7) + .constructor(true) + .build(); + constructor.define_property_or_throw( + js_string!("prototype"), + PropertyDescriptor::builder() + .value(prototype.clone()) + .writable(false) + .enumerable(false) + .configurable(false), + context, + )?; + constructor.define_property_or_throw( + js_string!("UTC"), + PropertyDescriptor::builder() + .value(utc) + .writable(true) + .enumerable(false) + .configurable(true), + context, + )?; + prototype.define_property_or_throw( + js_string!("constructor"), + PropertyDescriptor::builder() + .value(constructor.clone()) + .writable(true) + .enumerable(false) + .configurable(true), + context, + )?; + for key in prototype.own_property_keys(context)? { + if let PropertyKey::String(key) = key { + let name = key.to_std_string_escaped(); + if name != "constructor" && !READS.contains(&name.as_str()) { + guard_method(context, &prototype, ("Date.prototype", &name))?; + } + } + } + for name in ["now", "parse"] { + guard_method(context, &constructor, ("Date", name))?; + } + context.register_global_property( + JsString::from("Date"), + constructor, + Attribute::WRITABLE | Attribute::CONFIGURABLE, + ) +} + +/// Date objects are evaluation capabilities, not plain exports to re-emit at runtime. +pub(crate) fn check_export(value: &JsValue, context: &mut Context) -> JsResult<()> { + let site = value.as_object().and_then(|object| { + context.get_data::().and_then(|created| { + created.0.borrow().iter().find_map(|creation| { + JsObject::equals(&creation.date, &object).then(|| creation.site.clone()) + }) + }) + }); + match site { + Some(site) => Err(JsError::from_opaque(record(context, "Date", site))), + None => Ok(()), + } +} diff --git a/libs/extractor/src/evaluation_sandbox_date_negative_tests.rs b/libs/extractor/src/evaluation_sandbox_date_negative_tests.rs new file mode 100644 index 000000000..62e35423b --- /dev/null +++ b/libs/extractor/src/evaluation_sandbox_date_negative_tests.rs @@ -0,0 +1,98 @@ +use super::extract_value; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case::clock_call("Date()")] +#[case::call_ignores_arguments("Date(0)")] +#[case::clock_constructor("new Date()")] +#[case::clock_method("Date.now()")] +#[case::clock_read("Date.now")] +#[case::parse("Date.parse('2020-01-01')")] +#[case::parse_read("Date.parse")] +#[case::string_constructor("new Date('2020-01-01T00:00:00Z')")] +#[case::local_constructor("new Date(2020, 0, 1)")] +#[case::local_date("new Date(0).getDate()")] +#[case::local_day("new Date(0).getDay()")] +#[case::local_year("new Date(0).getFullYear()")] +#[case::local_legacy_year("new Date(0).getYear()")] +#[case::local_month("new Date(0).getMonth()")] +#[case::local_hours("new Date(0).getHours()")] +#[case::local_minutes("new Date(0).getMinutes()")] +#[case::local_seconds("new Date(0).getSeconds()")] +#[case::local_milliseconds("new Date(0).getMilliseconds()")] +#[case::timezone("new Date(0).getTimezoneOffset()")] +#[case::stringify("new Date(0).toString()")] +#[case::date_string("new Date(0).toDateString()")] +#[case::time_string("new Date(0).toTimeString()")] +#[case::locale_string("new Date(0).toLocaleString()")] +#[case::locale_date("new Date(0).toLocaleDateString()")] +#[case::locale_time("new Date(0).toLocaleTimeString()")] +#[case::implicit_string("String(new Date(0))")] +#[case::primitive_default("new Date(0)[Symbol.toPrimitive]('default')")] +#[case::primitive_string("Date.prototype[Symbol.toPrimitive].call(new Date(0), 'string')")] +#[case::prototype_constructor("new (Object.getPrototypeOf(new Date(0)).constructor)()")] +#[case::constructor_descriptor( + "new (Object.getOwnPropertyDescriptor(Date.prototype, 'constructor').value)()" +)] +#[case::global_descriptor("Object.getOwnPropertyDescriptor(globalThis, 'Date').value.now()")] +#[case::reflected_now("Reflect.getOwnPropertyDescriptor(Date, 'now')")] +#[case::reflected_parse("Object.getOwnPropertyDescriptors(Date)")] +#[case::reflected_local("Object.getOwnPropertyDescriptor(Date.prototype, 'getHours')")] +#[case::legacy_local("Date.prototype.__lookupGetter__('getHours')")] +#[case::borrowed_local("Date.prototype.getHours.call(new Date(0))")] +#[case::reflect_construct("Reflect.construct(Date, [])")] +#[case::bound_constructor("new (Date.bind(null))()")] +#[case::eval_clock("eval('Date.now()')")] +#[case::function_clock("Object.constructor('return Date.now()')()")] +#[case::object_constructor("new Date({ valueOf() { return 0; } })")] +#[case::undefined_constructor("new Date(undefined)")] +#[case::boolean_constructor("new Date(true)")] +#[case::null_constructor("new Date(null)")] +#[case::local_setter("new Date(0).setHours(1)")] +#[case::utc_mutation_outside_read_subset("new Date(0).setUTCFullYear(2020)")] +#[case::computed_constructor("new (Object.getPrototypeOf(new Date(0))['constructor'])()")] +#[case::computed_now("Object.getOwnPropertyDescriptor(globalThis, 'Date').value['now']()")] +#[case::borrowed_primitive("Date.prototype[Symbol.toPrimitive].call(new Date(0), 'default')")] +#[case::date_copy_outside_numeric_subset("new Date(new Date(0))")] +#[case::string_coercion_outside_numeric_subset( + "new Date({[Symbol.toPrimitive]() { return '0'; }})" +)] +#[case::argument_effect("Date.UTC({valueOf() { return Date.now(); }}, 0, 1)")] +#[case::utc_reader_argument_effect("new Date(0).getUTCDate(Date.now())")] +#[case::numeric_constructor_argument_effect("new Date((Date.now(), 0))")] +#[case::explicit_receiver_call("Date.call(Date, 0)")] +#[case::explicit_receiver_apply("Reflect.apply(Date, Date, [0])")] +#[case::computed_clock_call("new (new Date(0)['constructor'])()")] +#[case::serialization_outside_read_subset("new Date(0).toJSON()")] +#[case::implicit_serialization("JSON.stringify(new Date(0))")] +#[serial] +fn forbidden_date_fails_when_entry_or_helper_catches_or_propagates( + #[case] expression: &str, + #[values(false, true)] imported: bool, + #[values(false, true)] caught: bool, +) -> Result<(), String> { + // Given + let body = if caught { + format!( + "try {{ {expression}; }} catch (error) {{ error.message = 'changed'; error.name = 'changed'; delete error.stack; }}\nreturn 1;" + ) + } else { + format!("return {expression};") + }; + // When + let error = extract_value(&body, imported) + .err() + .ok_or("forbidden Date operation emitted CSS")?; + // Then + let path = if imported { + "date-helper.ts:2:" + } else { + "date-entry.css.ts:3:" + }; + assert!(error.starts_with(path), "{expression}: {error}"); + assert!(error.contains("`Date"), "{error}"); + assert!(!error.contains(""), "{error}"); + assert!(!error.contains("__devup_read_site_"), "{error}"); + Ok(()) +} diff --git a/libs/extractor/src/evaluation_sandbox_date_semantics_tests.rs b/libs/extractor/src/evaluation_sandbox_date_semantics_tests.rs new file mode 100644 index 000000000..59c83f3ee --- /dev/null +++ b/libs/extractor/src/evaluation_sandbox_date_semantics_tests.rs @@ -0,0 +1,184 @@ +use super::extract_value; +use crate::ExtractStyleValue; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case::utc_coercion_order( + "let order = ''; const year = {valueOf() {order += 'y'; return 2020;}}; const month = {valueOf() {order += 'm'; return 0;}}; const ignored = {valueOf() {throw new Error('extra argument coerced');}}; const n = Date.UTC(year, month, 1, 0, 0, 0, 0, (order += 'e', ignored)); return order === 'eym' && n === 1577836800000;" +)] +#[case::utc_abrupt_conversion( + "let order = ''; const first = {valueOf() {order += 'a'; throw new TypeError('input');}}; const second = {valueOf() {order += 'b'; return 0;}}; try {Date.UTC(first, second);} catch (error) {return error instanceof TypeError && order === 'a';} return false;" +)] +#[case::reader_receivers( + "try {Date.prototype.getTime.call({});} catch (error) {return error instanceof TypeError;} return false;" +)] +#[case::utc_reader_receivers( + "try {Date.prototype.getUTCDate.call({});} catch (error) {return error instanceof TypeError;} return false;" +)] +#[case::proxy_receiver( + "try {Date.prototype.valueOf.call(new Proxy(new Date(0), {}));} catch (error) {return error instanceof TypeError;} return false;" +)] +#[case::native_slots( + "const d = new Date(9); return d instanceof Date && Object.getPrototypeOf(d) === Date.prototype && Date.prototype.getTime.call(d) === 9;" +)] +#[case::reader_argument_effects( + "let effects = 0; const value = new Date(3).getTime(++effects); return effects === 1 && value === 3;" +)] +#[case::constructor_argument_effects( + "let effects = 0; const value = new Date((++effects, 3)).getTime(); return effects === 1 && value === 3;" +)] +#[case::subclass( + "class Epoch extends Date {} const date = new Epoch(3); return date instanceof Epoch && date instanceof Date && date.getTime() === 3;" +)] +#[case::reflect_new_target( + "function Epoch() {} Epoch.prototype = Object.create(Date.prototype); const date = Reflect.construct(Date, [8], Epoch); return Object.getPrototypeOf(date) === Epoch.prototype && date.getTime() === 8;" +)] +#[case::fallback_prototype( + "function Epoch() {} Epoch.prototype = null; const date = Reflect.construct(Date, [8], Epoch); return Object.getPrototypeOf(date) === Date.prototype && date.getTime() === 8;" +)] +#[case::primitive_number( + "return Date.prototype[Symbol.toPrimitive].call(new Date(7), 'number') === 7;" +)] +#[case::primitive_invalid_hint( + "try {Date.prototype[Symbol.toPrimitive].call(new Date(7), 'other');} catch (error) {return error instanceof TypeError;} return false;" +)] +#[case::borrowed_utc( + "const read = new Date(0).getUTCFullYear; return read.call(new Date(Date.UTC(2020, 0, 1))) === 2020;" +)] +#[case::reflected_utc( + "return Reflect.getOwnPropertyDescriptor(Date, 'UTC').value.call(null, 2020, 0, 1) === 1577836800000;" +)] +#[case::borrowed_iso( + "return Date.prototype.toISOString.call(new Date(0)) === '1970-01-01T00:00:00.000Z';" +)] +#[case::pure_replacement( + "Date.now = () => 7; Date.prototype.getHours = () => 9; return Date.now() === 7 && new Date(0).getHours() === 9;" +)] +#[case::global_replacement( + "const D = Date; globalThis.Date = () => 5; return Date() === 5 && new D(2).getTime() === 2 && D.UTC(2020) === 1577836800000;" +)] +#[serial] +fn native_semantics_are_preserved_when_date_capability_runs( + #[case] body: &str, + #[values(false, true)] imported: bool, +) -> Result<(), String> { + // Given / When + let output = extract_value(body, imported)?; + // Then + assert!( + output.styles.iter().any(|style| matches!(style, + ExtractStyleValue::Static(style) if style.property == "opacity" && style.value == "true" + )), + "{:?}", + output.styles + ); + Ok(()) +} + +#[rstest] +#[case("date")] +#[case("{nested: [date]}")] +#[serial] +fn date_exports_are_rejected_at_creation_when_later_reads_change_the_active_site( + #[case] value: &str, +) -> Result<(), String> { + // Given + let source = format!( + "import {{style}} from '@devup-ui/react';\nconst date = new Date(0);\nconst later = new Date(1).getTime();\nexport const result = {value};" + ); + let offset = source.find("Date(0)").ok_or("missing creation")?; + // When + let error = crate::extract( + "date-export.css.ts", + &source, + crate::ExtractOption::default(), + ) + .err() + .ok_or("Date object escaped to runtime output")? + .to_string(); + // Then + assert!( + error.starts_with(&format!( + "{}:", + crate::locate("date-export.css.ts", &source, offset) + )), + "{error}" + ); + assert!(error.contains("ReferenceError: `Date`"), "{error}"); + Ok(()) +} + +#[rstest] +#[case("new Date(0).getTime()")] +#[case("Date.UTC(2020, 0, 1)")] +#[serial] +fn date_expression_stays_on_full_plan_when_written_directly_in_css(#[case] expression: &str) { + // Given + let source = format!( + "import {{css}} from '@devup-ui/react'; export const card = css({{opacity: String({expression})}});" + ); + // When + let plan = crate::stylesheet_policy::plan( + &source, + "direct-date.css.ts", + &crate::ExtractOption::default(), + None, + &|_| false, + ); + // Then + assert_eq!(plan, crate::stylesheet_policy::Plan::Run); +} + +#[rstest] +#[case("new Date(0).getTime()", "0")] +#[case("Date.UTC(2020, 0, 1)", "1577836800000")] +#[serial] +fn direct_stylesheet_date_is_static_when_the_approved_style_api_runs( + #[case] expression: &str, + #[case] expected: &str, +) -> Result<(), String> { + // Given + let source = format!( + "import {{style}} from '@devup-ui/react'; export const card = style({{opacity: String({expression})}});" + ); + // When + let output = crate::extract( + "direct-date.css.ts", + &source, + crate::ExtractOption::default(), + ) + .map_err(|error| error.to_string())?; + // Then + assert!(output.styles.iter().any(|style| matches!(style, + ExtractStyleValue::Static(style) if style.property == "opacity" && style.value == expected + )), "{:?}", output.styles); + assert!(!output.code.contains("Date"), "{}", output.code); + assert!(!output.code.contains("var(--"), "{}", output.code); + Ok(()) +} + +#[rstest] +#[serial] +fn immutable_date_evidence_survives_when_multiple_errors_and_guards_are_mutated( + #[values(false, true)] imported: bool, +) -> Result<(), String> { + // Given + let body = "try {Date.now;} catch (error) {error.message = 'other'; Object.setPrototypeOf(error, null);}\ntry {Date.parse;} catch (error) {delete error.message;}\nDate.now = () => 7; Date.parse = () => 8; return 1;"; + // When + let error = extract_value(body, imported) + .err() + .ok_or("caught Date evidence was discarded")?; + // Then + let path = if imported { + "date-helper.ts" + } else { + "date-entry.css.ts" + }; + let row = if imported { 2 } else { 3 }; + assert!(error.contains(&format!("{path}:{row}:6:")), "{error}"); + assert!(error.contains(&format!("{path}:{}:6:", row + 1)), "{error}"); + assert!(error.contains("`Date.now`"), "{error}"); + assert!(error.contains("`Date.parse`"), "{error}"); + Ok(()) +} diff --git a/libs/extractor/src/evaluation_sandbox_date_tests.rs b/libs/extractor/src/evaluation_sandbox_date_tests.rs new file mode 100644 index 000000000..32e653b26 --- /dev/null +++ b/libs/extractor/src/evaluation_sandbox_date_tests.rs @@ -0,0 +1,128 @@ +use crate::{ExtractOption, ExtractStyleValue, ResolvedModule, extract_with_modules}; +use css::{class_map::reset_class_map, file_map::reset_file_map}; +use rstest::rstest; +use serial_test::serial; + +#[path = "evaluation_sandbox_date_negative_tests.rs"] +mod date_negative_tests; + +#[path = "evaluation_sandbox_date_semantics_tests.rs"] +mod date_semantics_tests; + +fn extract_value(body: &str, imported: bool) -> Result { + reset_class_map(); + reset_file_map(); + let helper = format!("export function read() {{\n{body}\n}}"); + let source = if imported { + "import {style} from '@devup-ui/react';\nimport {read} from './date-helper';\nexport const card = style({opacity: String(read())});".to_string() + } else { + format!( + "import {{style}} from '@devup-ui/react';\nfunction read() {{\n{body}\n}}\nexport const card = style({{opacity: String(read())}});" + ) + }; + let resolver = move |specifier: &str, _: &str| { + (specifier == "./date-helper").then(|| ResolvedModule { + path: "date-helper.ts".to_string(), + code: helper.clone(), + }) + }; + extract_with_modules( + "date-entry.css.ts", + &source, + ExtractOption::default(), + false, + &resolver, + ) + .map_err(|error| error.to_string()) +} + +#[rstest] +#[case("new Date(0).getTime()", "0")] +#[case("Date.UTC(2020, 0, 1)", "1577836800000")] +#[case("new Date(Date.UTC(2020, 0, 1)).valueOf()", "1577836800000")] +#[case("new Date(-1.9).getTime()", "-1")] +#[case("new Date(0).getUTCFullYear()", "1970")] +#[case("new Date(0).getUTCMonth()", "0")] +#[case("new Date(0).getUTCDate()", "1")] +#[case("new Date(0).getUTCDay()", "4")] +#[case("new Date(0).getUTCHours()", "0")] +#[case("new Date(0).getUTCMinutes()", "0")] +#[case("new Date(0).getUTCSeconds()", "0")] +#[case("new Date(123).getUTCMilliseconds()", "123")] +#[case("Number.isNaN(new Date(NaN).getTime())", "true")] +#[case("Number.isNaN(new Date(Infinity).getTime())", "true")] +#[case("Number.isNaN(new Date(8640000000000001).getTime())", "true")] +#[case("new Date(8640000000000000).getTime()", "8640000000000000")] +#[case("new Date(-8640000000000000).getTime()", "-8640000000000000")] +#[case("Date.UTC(99, 12, 1)", "946684800000")] +#[case("Date.UTC('2020', '0', '1')", "1577836800000")] +#[case("Number.isNaN(Date.UTC())", "true")] +#[case("Date.UTC(2020)", "1577836800000")] +#[case("+new Date(7)", "7")] +#[case("Reflect.construct(Date, [9]).getTime()", "9")] +#[case("new (new Date(0).constructor)(12).getTime()", "12")] +#[case("new Date(0).toISOString() === '1970-01-01T00:00:00.000Z'", "true")] +#[case( + "new Date(0).toUTCString() === 'Thu, 01 Jan 1970 00:00:00 GMT'", + "true" +)] +#[case("new Date(0).toGMTString() === new Date(0).toUTCString()", "true")] +#[case( + "Date.prototype.toISOString.call(new Date(0)) === '1970-01-01T00:00:00.000Z'", + "true" +)] +#[serial] +fn deterministic_date_is_emitted_when_full_stylesheet_runs( + #[case] expression: &str, + #[case] expected: &str, + #[values(false, true)] imported: bool, +) -> Result<(), String> { + // Given / When + let output = extract_value(&format!("return {expression};"), imported)?; + // Then + assert!(output.styles.iter().any(|style| matches!(style, + ExtractStyleValue::Static(style) if style.property == "opacity" && style.value == expected + )), "{:?}", output.styles); + assert!(!output.code.contains("Date"), "{}", output.code); + assert!(!output.code.contains("var(--"), "{}", output.code); + assert!(!output.code.contains("style("), "{}", output.code); + Ok(()) +} + +#[rstest] +#[case("new Date(0).getTime()")] +#[case("Date.UTC(2020, 0, 1)")] +fn default_sandbox_stays_strict_when_date_is_deterministic(#[case] expression: &str) { + // Given / When + let result = super::reflection_tests::run(expression); + // Then + assert!(matches!(result, Err(super::Failure::Forbidden(reads)) if reads[0].name() == "Date")); +} + +#[rstest] +#[case("Date.UTC(2020, 0, 1)")] +#[case("new Date(0).getTime()")] +fn actual_value_evaluation_stays_strict_when_date_is_reached_indirectly( + #[case] expression: &str, +) -> Result<(), String> { + // Given + let code = format!( + "import {{css}} from '@devup-ui/react'; const read = Object.constructor('return {expression}'); export const card = css({{opacity: read()}});" + ); + // When + let errors = crate::build_time_values::evaluate_located( + &code, + "date-value.tsx", + &ExtractOption::default(), + None, + &crate::imported_constants::Unknown::default(), + ) + .err() + .ok_or("Date must fail in actual value evaluation")?; + // Then + assert!( + errors.iter().any(|(_, error)| error.contains("`Date`")), + "{errors:?}" + ); + Ok(()) +} diff --git a/libs/extractor/src/evaluation_sandbox_evidence.rs b/libs/extractor/src/evaluation_sandbox_evidence.rs new file mode 100644 index 000000000..3ab6c4230 --- /dev/null +++ b/libs/extractor/src/evaluation_sandbox_evidence.rs @@ -0,0 +1,54 @@ +//! Converts host-held observations into failures without trusting caught CSS errors. + +use boa_engine::{JsError, JsNativeError}; + +use super::{Failure, Kind, Sandbox, Violation}; + +impl Sandbox { + /// Fails if a forbidden read was recorded. `errors` are the errors the host + /// got back from code it ran: one a read threw gives that read its call + /// frames when the code did not rethrow it first, so a host that serializes + /// the values the code made passes the errors that failed it here. + pub(crate) fn check(&self, errors: &[&JsError]) -> Result<(), Failure> { + let recorded = self.evidence.reads.borrow(); + if recorded.is_empty() { + return Ok(()); + } + Err(Failure::Forbidden( + recorded + .iter() + .map(|read| Violation { + kind: if read.css.is_some() { + Kind::Css + } else { + Kind::of(&read.name) + }, + name: read.name.clone(), + css_message: read.css.as_ref().map(|css| css.message.clone()), + site: read.site.clone(), + error: match (&read.css, &read.site) { + (Some(css), _) => css.error.clone(), + (None, Some((place, _))) => JsNativeError::reference() + .with_message(format!( + "`{}` cannot be read at build time: {}\n at ({place})", + read.name, + Kind::of(&read.name).requirement() + )) + .into(), + (None, None) => errors + .iter() + .find(|error| { + error + .as_opaque() + .is_some_and(|thrown| thrown.strict_equals(&read.error)) + }) + .map_or_else( + || JsError::from_opaque(read.error.clone()), + |error| (*error).clone(), + ), + }, + }) + .collect(), + )) + } +} diff --git a/libs/extractor/src/evaluation_sandbox_guards.rs b/libs/extractor/src/evaluation_sandbox_guards.rs new file mode 100644 index 000000000..1edaf4392 --- /dev/null +++ b/libs/extractor/src/evaluation_sandbox_guards.rs @@ -0,0 +1,257 @@ +//! What the sandbox forbids reading, and the source that guards the globals. + +use super::reflection::{check_accessor, check_descriptor}; +use super::{Evidence, function, record}; + +pub(super) const EXACT_GLOBALS: &[&str] = &[ + "undefined", + "NaN", + "Infinity", + "globalThis", + "Math", + "Object", + "Array", + "String", + "Number", + "Boolean", + "BigInt", + "Symbol", + "JSON", + "Reflect", + "Proxy", + "Promise", + "Map", + "Set", + "WeakMap", + "WeakSet", + "Error", + "TypeError", + "ReferenceError", + "SyntaxError", + "RangeError", + "URIError", + "EvalError", + "Uint8Array", + "Uint8ClampedArray", + "Uint16Array", + "Uint32Array", + "Int8Array", + "Int16Array", + "Int32Array", + "Float32Array", + "Float64Array", + "BigInt64Array", + "BigUint64Array", + "ArrayBuffer", + "DataView", + "parseInt", + "parseFloat", + "isNaN", + "isFinite", + "encodeURI", + "decodeURI", + "encodeURIComponent", + "decodeURIComponent", + "eval", + "console", +]; +use boa_engine::{ + Context, JsArgs, JsError, JsObject, JsResult, JsString, JsValue, NativeFunction, + object::FunctionObjectBuilder, property::PropertyDescriptor, +}; + +pub(super) fn guard_method( + context: &mut Context, + holder: &JsObject, + label: (&str, &str), +) -> JsResult<()> { + let (owner, method) = label; + let guard = function( + context, + method, + NativeFunction::from_copy_closure_with_captures( + |_, _, name: &String, context| { + let site = context + .get_data::() + .and_then(|evidence| evidence.site.borrow().clone()); + Err(JsError::from_opaque(record(context, name, site))) + }, + format!("{owner}.{method}"), + ), + ); + let setter = function( + context, + "set", + NativeFunction::from_copy_closure_with_captures( + |this, args, method: &String, context| { + this.to_object(context)?.define_property_or_throw( + JsString::from(method.as_str()), + PropertyDescriptor::builder() + .value(args.get_or_undefined(0).clone()) + .writable(true) + .enumerable(false) + .configurable(true), + context, + )?; + Ok(JsValue::undefined()) + }, + method.to_string(), + ), + ); + holder.define_property_or_throw( + JsString::from(method), + PropertyDescriptor::builder() + .get(guard.clone()) + .set(setter.clone()) + .enumerable(false) + .configurable(true), + context, + )?; + if let Some(evidence) = context.get_data::() { + evidence + .methods + .borrow_mut() + .push((guard, format!("{owner}.{method}"))); + evidence + .methods + .borrow_mut() + .push((setter, format!("{owner}.{method}"))); + } + Ok(()) +} + +pub(super) fn guard_methods(context: &mut Context) -> JsResult<()> { + let math = context.intrinsics().objects().math(); + guard_method(context, &math, ("Math", "random"))?; + for (owner, methods) in GUARDED_METHODS { + let constructors = context.intrinsics().constructors(); + let holder = match owner { + "Object" => constructors.object(), + "Number" => constructors.number(), + "BigInt" => constructors.bigint(), + "Array" => constructors.array(), + "TypedArray" => constructors.typed_array(), + "Function" => constructors.function(), + _ => constructors.string(), + } + .prototype(); + for method in methods { + guard_method(context, &holder, (&format!("{owner}.prototype"), method))?; + } + } + guard_descriptor(context) +} + +fn guard_descriptor(context: &mut Context) -> JsResult<()> { + let object = context.intrinsics().constructors().object().constructor(); + let prototype = context.intrinsics().constructors().object().prototype(); + let reflect = context.intrinsics().objects().reflect(); + for (holder, name, batch) in [ + (&object, "getOwnPropertyDescriptor", Some(false)), + (&object, "getOwnPropertyDescriptors", Some(true)), + (&reflect, "getOwnPropertyDescriptor", Some(false)), + (&prototype, "__lookupGetter__", None), + (&prototype, "__lookupSetter__", None), + ] { + let original = holder + .get(JsString::from(name), context)? + .to_object(context)?; + let realm = context.realm().clone(); + let wrapper = FunctionObjectBuilder::new( + &realm, + NativeFunction::from_copy_closure_with_captures( + |this, args, captures: &(JsObject, Option), context| { + let site = context + .get_data::() + .and_then(|evidence| evidence.site.borrow().clone()); + let value = captures.0.call(this, args, context)?; + match captures.1 { + Some(true) => { + let descriptors = value.to_object(context)?; + for key in descriptors.own_property_keys(context)? { + check_descriptor( + &descriptors.get(key, context)?, + site.clone(), + context, + )?; + } + } + Some(false) => { + check_descriptor(&value, site, context)?; + } + None => check_accessor(&value, site, context)?, + } + Ok(value) + }, + (original, batch), + ), + ) + .name(JsString::from(name)) + .length(if batch == Some(false) { 2 } else { 1 }) + .build(); + holder.define_property_or_throw( + JsString::from(name), + PropertyDescriptor::builder() + .value(wrapper) + .writable(true) + .enumerable(false) + .configurable(true), + context, + )?; + } + Ok(()) +} + +/// Globals the engine gives whose values depend on the locale or the clock; +/// any other global nothing declares is the environment's +pub(super) const GUARDED_GLOBALS: [&str; 5] = ["Date", "Intl", "performance", "crypto", "Temporal"]; + +/// Methods whose results depend on locale, Unicode data, or transformed source. +pub(super) const GUARDED_METHODS: [(&str, &[&str]); 7] = [ + ("Object", &["toLocaleString"]), + ("Number", &["toLocaleString"]), + ("BigInt", &["toLocaleString"]), + ("Array", &["toLocaleString"]), + ("TypedArray", &["toLocaleString"]), + ("Function", &["toString"]), + ( + "String", + &[ + "localeCompare", + "toLocaleUpperCase", + "toLocaleLowerCase", + "normalize", + ], + ), +]; + +/// Guards the globals with `forbid`, which records a read and gives the error +/// that fails it. Assigning a guarded global gives the code a value of its +/// own. Globals nothing declares are guarded alike, through the prototype of +/// the global object. +/// Keep function bodies and descriptors out of nested call arguments: Boa's +/// debug parser otherwise exhausts the default Windows main-thread stack. +pub(super) const SETUP: &str = r#"(function (forbid, config) { + const global = globalThis; + function assign(object, name, value) { + const descriptor = { value, writable: true, enumerable: true, configurable: true }; + Object.defineProperty(object, name, descriptor); + } + function read(object, name, label) { + const get = function () { throw forbid(label); }; + const set = function (value) { assign(object, name, value); }; + const descriptor = { get, set, enumerable: false, configurable: true }; + Object.defineProperty(object, name, descriptor); + } + for (const name of config.names) read(global, name, name); + function get(target, key, receiver) { + if (typeof key === "string" && !(key in target)) throw forbid(key); + return Reflect.get(target, key, receiver); + } + function has(target, key) { + return (typeof key === "string" && !(key in target)) || Reflect.has(target, key); + } + const handler = { get, has }; + const prototype = new Proxy(Object.getPrototypeOf(global), handler); + Object.setPrototypeOf(global, prototype); +})"#; diff --git a/libs/extractor/src/evaluation_sandbox_reflection.rs b/libs/extractor/src/evaluation_sandbox_reflection.rs new file mode 100644 index 000000000..607afe169 --- /dev/null +++ b/libs/extractor/src/evaluation_sandbox_reflection.rs @@ -0,0 +1,267 @@ +//! Identity-based protection of synthetic accessors exposed by reflection. + +use super::{ + Evidence, INTERNAL_SOURCE, SETUP, forbid_native, function, guards::GUARDED_GLOBALS, record, +}; +use boa_engine::{ + Context, JsError, JsResult, JsString, JsValue, NativeFunction, Source, js_string, +}; + +pub(super) fn guard_globals(context: &mut Context) -> JsResult<()> { + let setup = context.eval(Source::from_reader( + SETUP.as_bytes(), + Some(std::path::Path::new(INTERNAL_SOURCE)), + ))?; + let forbid = function( + context, + "forbid", + NativeFunction::from_fn_ptr(forbid_native), + ); + let config = JsValue::from_json(&serde_json::json!({ "names": GUARDED_GLOBALS }), context)?; + setup + .to_object(context)? + .call(&JsValue::undefined(), &[forbid.into(), config], context)?; + for name in GUARDED_GLOBALS { + let descriptor = boa_engine::builtins::object::OrdinaryObject::get_own_property_descriptor( + &JsValue::undefined(), + &[context.global_object().into(), JsString::from(name).into()], + context, + )? + .to_object(context)?; + for key in [js_string!("get"), js_string!("set")] { + let accessor = descriptor.get(key, context)?.to_object(context)?; + if let Some(evidence) = context.get_data::() { + evidence + .methods + .borrow_mut() + .push((accessor, name.to_string())); + } + } + } + Ok(()) +} + +pub(super) fn check_descriptor( + value: &JsValue, + site: Option<(String, u32)>, + context: &mut Context, +) -> JsResult<()> { + if let Some(descriptor) = value.as_object() { + let getter = descriptor + .borrow() + .properties() + .get(&js_string!("get").into()) + .and_then(|property| property.value().cloned()); + if let Some(getter) = getter { + check_accessor(&getter, site, context)?; + } + } + Ok(()) +} + +pub(super) fn check_accessor( + value: &JsValue, + site: Option<(String, u32)>, + context: &mut Context, +) -> JsResult<()> { + let name = context.get_data::().and_then(|evidence| { + evidence.methods.borrow().iter().find_map(|(method, name)| { + value + .as_object() + .filter(|accessor| accessor == method) + .map(|_| name.clone()) + }) + }); + match name { + Some(name) => Err(JsError::from_opaque(record(context, &name, site))), + None => Ok(()), + } +} + +#[cfg(test)] +mod tests { + use super::super::{Failure, reflection_tests::run}; + use rstest::rstest; + + #[rstest] + #[case("(()=>{}).__lookupGetter__('toString')", "Function.prototype.toString")] + #[case("Math.__lookupGetter__('random')", "Math.random")] + #[case("Math.__lookupSetter__('random')", "Math.random")] + #[case( + "const read = Object.prototype.__lookupGetter__;\nread.call(Math, 'random')", + "Math.random" + )] + #[case( + "const read = Object.prototype.__lookupSetter__.bind(Math);\nread('random')", + "Math.random" + )] + #[case( + "const key = 'random';\nMath['__lookup' + 'Getter__'](key)", + "Math.random" + )] + #[case( + "const key = { toString() { Math.max(1, 2); return 'random' } };\nMath.__lookupSetter__(key)", + "Math.random" + )] + #[case( + "const child = Object.create(Math);\nchild.__lookupGetter__('random')", + "Math.random" + )] + fn legacy_protected_accessors_fail_at_actual_call( + #[case] script: &str, + #[case] name: &str, + ) -> Result<(), String> { + // Given + let offset = script.rfind('\n').map_or(0, |at| at + 1); + // When + let Err(Failure::Forbidden(reads)) = run(script) else { + return Err("legacy reflection escaped".into()); + }; + // Then + assert_eq!(reads.len(), 1); + assert_eq!(reads[0].name(), name); + assert_eq!(reads[0].site().map(|(_, at)| at), Some(offset)); + Ok(()) + } + + #[rstest] + fn legacy_global_accessors_record_before_throw_even_when_errors_are_mutated( + #[values("__lookupGetter__", "__lookupSetter__")] reader: &str, + #[values("Date", "Intl", "performance", "crypto", "Temporal")] name: &str, + ) -> Result<(), String> { + // Given + let script = format!( + "try {{ globalThis.{reader}('{name}') }} catch (error) {{ error.message = 'changed'; error.name = 'changed'; }}\nglobalThis.{name} = 7; 'accepted'" + ); + // When + let Err(Failure::Forbidden(reads)) = run(&script) else { + return Err("caught legacy reflection escaped".into()); + }; + // Then + assert_eq!(reads.len(), 1); + assert_eq!(reads[0].name(), name); + assert_eq!(reads[0].site().map(|(_, at)| at), Some(6)); + assert!(reads[0].error().to_string().contains(&format!("`{name}`"))); + assert_eq!( + reads[0].requirement(), + super::super::Kind::of(name).requirement() + ); + Ok(()) + } + + #[rstest] + #[case( + "Math.random = () => 7; String(Math.__lookupGetter__('random'))", + "undefined" + )] + #[case( + "Math.random = () => 7; String(Math.__lookupSetter__('random'))", + "undefined" + )] + #[case( + "Function.prototype.toString = () => 'own'; String((()=>{}).__lookupGetter__('toString'))", + "undefined" + )] + #[case( + "globalThis.Date = 7; String(globalThis.__lookupSetter__('Date'))", + "undefined" + )] + #[case( + "const o = { n: 3, get random() { return this.n }, set random(v) { this.n = v } }; const child = Object.create(o); child.__lookupSetter__('random').call(child, 8); String(child.__lookupGetter__('random').call(child))", + "8" + )] + #[case( + "Object.defineProperty(Math, 'random', { get() { return 4 }, set(v) {} }); String(Math.__lookupGetter__('random')())", + "4" + )] + #[case( + "Object.defineProperty(globalThis, 'Intl', { set(v) { this.saved = v } }); globalThis.__lookupSetter__('Intl').call(globalThis, 9); String(globalThis.saved)", + "9" + )] + #[case( + "String(Object.prototype.__lookupGetter__.call('a', '0'))", + "undefined" + )] + #[case("String(Object.prototype.__lookupSetter__.call(1, 'x'))", "undefined")] + #[case("String(({}).__lookupGetter__('missing'))", "undefined")] + #[case("String(({ random: 2 }).__lookupSetter__('random'))", "undefined")] + #[case( + "String(Object.prototype.__lookupGetter__.length) + ':' + Object.prototype.__lookupSetter__.length", + "1:1" + )] + #[case( + "let order = ''; const o = new Proxy({ get x() { return 2 } }, { getOwnPropertyDescriptor(t, k) { order += 'd'; return Reflect.getOwnPropertyDescriptor(t, k) } }); const key = { toString() { order += 'k'; return 'x' } }; const get = Object.prototype.__lookupGetter__.call(o, key); `${order}:${get()}`", + "kd:2" + )] + #[case( + "let order = ''; try { Object.prototype.__lookupGetter__.call(null, { toString() { order += 'k'; return 'x' } }) } catch(e) { order += e instanceof TypeError ? 't' : 'e' } order", + "t" + )] + #[case( + "let order = ''; try { Object.prototype.__lookupSetter__.call(undefined, { toString() { order += 'k'; return 'x' } }) } catch(e) { order += e instanceof TypeError ? 't' : 'e' } order", + "t" + )] + fn legacy_ordinary_accessors_preserve_native_semantics( + #[case] script: &str, + #[case] expected: &str, + ) -> Result<(), String> { + // Given / When + let value = match run(script) { + Ok(value) => value, + Err(Failure::Js(error)) => return Err(error.to_string()), + Err(Failure::Forbidden(_)) => return Err("ordinary legacy accessor forbidden".into()), + }; + // Then + assert_eq!( + value + .as_string() + .ok_or("expected string")? + .to_std_string_escaped(), + expected + ); + Ok(()) + } + + #[rstest] + #[case( + "(()=>{}).__lookupGetter__('toString')", + "Function.prototype.toString", + "()=>{}" + )] + #[case( + "Math.__lookupGetter__('random')", + "Math.random", + "Math.__lookupGetter__" + )] + #[serial_test::serial] + fn legacy_stylesheet_branches_fail_instead_of_emitting_blue_css( + #[case] read: &str, + #[case] name: &str, + #[case] callsite: &str, + ) -> Result<(), String> { + // Given + let code = format!( + "import {{style}} from '@devup-ui/react';\nexport const card=style({{color:{read}?'blue':'red'}});" + ); + let offset = code.find(callsite).ok_or("missing read")?; + // When + let error = crate::extract("legacy.css.ts", &code, crate::ExtractOption::default()) + .err() + .ok_or("legacy reflection silently emitted CSS")? + .to_string(); + // Then + assert!( + error.starts_with(&format!( + "{}:", + crate::locate("legacy.css.ts", &code, offset) + )), + "{error}" + ); + assert!(error.contains(&format!("`{name}`")), "{error}"); + assert!( + error.contains("Fix: use a literal or a CSS variable"), + "{error}" + ); + Ok(()) + } +} diff --git a/libs/extractor/src/evaluation_sandbox_reflection_tests.rs b/libs/extractor/src/evaluation_sandbox_reflection_tests.rs new file mode 100644 index 000000000..0959ca7fe --- /dev/null +++ b/libs/extractor/src/evaluation_sandbox_reflection_tests.rs @@ -0,0 +1,320 @@ +use std::path::Path; + +use boa_engine::{Context, JsValue, Source}; +use rstest::rstest; + +use super::{Failure, Sandbox}; + +pub(super) fn run(script: &str) -> Result { + let mut context = Context::default(); + let sandbox = Sandbox::new(&mut context).map_err(Failure::Js)?; + let source = super::instrument(script, "reflection.js"); + sandbox + .prepare(&mut context, &source) + .map_err(Failure::Js)?; + sandbox.run_source( + &mut context, + Source::from_bytes(&source.code).with_path(Path::new("reflection.js")), + ) +} + +#[rstest] +#[case("Object.getOwnPropertyDescriptor(Math, 'random')", "Math.random")] +#[case("Object.getOwnPropertyDescriptors(Math)", "Math.random")] +#[case("Reflect.getOwnPropertyDescriptor(Math, 'random')", "Math.random")] +#[case( + "const api = Reflect;\napi['getOwnProperty' + 'Descriptor'](Math, 'random')", + "Math.random" +)] +#[case( + "const read = Reflect.getOwnPropertyDescriptor;\nread(Math, 'random')", + "Math.random" +)] +#[case( + "const { getOwnPropertyDescriptor: read } = Reflect;\nread(Math, 'random')", + "Math.random" +)] +#[case( + "const read = Reflect.getOwnPropertyDescriptor.bind(null, Math);\nread('random')", + "Math.random" +)] +#[case( + "const read = Object.getOwnPropertyDescriptors.bind(null);\nread(Math)", + "Math.random" +)] +#[case( + "Object.getOwnPropertyDescriptors(String.prototype)", + "String.prototype.localeCompare" +)] +#[case( + "Reflect.getOwnPropertyDescriptor(String.prototype, 'normalize')", + "String.prototype.normalize" +)] +#[case("Object.getOwnPropertyDescriptors(globalThis)", "Date")] +fn guarded_descriptors_fail_at_the_reflection_call( + #[case] script: &str, + #[case] name: &str, +) -> Result<(), String> { + // Given + let offset = script.rfind('\n').map_or(0, |at| at + 1); + // When + let Err(Failure::Forbidden(violations)) = run(script) else { + return Err("reflection did not record a forbidden read".to_string()); + }; + // Then + assert_eq!(violations.len(), 1); + assert_eq!(violations[0].name(), name); + let place = crate::locate("reflection.js", script, offset); + assert_eq!(violations[0].site(), Some((place.as_str(), offset))); + assert!(violations[0].error().to_string().contains(&place)); + Ok(()) +} + +#[rstest] +fn generated_global_getter_descriptors_are_guarded( + #[values("Object.getOwnPropertyDescriptor", "Reflect.getOwnPropertyDescriptor")] reader: &str, + #[values("Date", "Intl", "performance", "crypto", "Temporal")] name: &str, +) -> Result<(), String> { + // Given + let script = format!("{reader}(globalThis, '{name}')"); + // When + let Err(Failure::Forbidden(violations)) = run(&script) else { + return Err("global guard descriptor escaped".to_string()); + }; + // Then + assert_eq!(violations.len(), 1); + assert_eq!(violations[0].name(), name); + assert_eq!(violations[0].site().map(|(_, offset)| offset), Some(0)); + Ok(()) +} + +#[rstest] +#[case("Object.getOwnPropertyDescriptor(Math, 'random')")] +#[case("Object.getOwnPropertyDescriptors(Math)")] +#[case("Reflect.getOwnPropertyDescriptor(Math, 'random')")] +#[case("Object.getOwnPropertyDescriptor(globalThis, 'Date')")] +#[case("Reflect.getOwnPropertyDescriptor(globalThis, 'Date')")] +fn caught_descriptor_errors_cannot_mutate_the_host_evidence( + #[case] read: &str, +) -> Result<(), String> { + // Given + let script = format!( + "try {{ {read} }} catch (error) {{ error.message = 'changed'; error.name = 'changed'; }}\nMath.random = () => 1; 'accepted'" + ); + // When + let Err(Failure::Forbidden(violations)) = run(&script) else { + return Err("caught reflection was accepted".to_string()); + }; + // Then + assert_eq!(violations.len(), 1); + let name = if read.contains("globalThis") { + "Date" + } else { + "Math.random" + }; + assert_eq!(violations[0].name(), name); + assert_eq!(violations[0].site().map(|(_, offset)| offset), Some(6)); + assert_eq!( + violations[0].requirement(), + super::Kind::of(name).requirement() + ); + assert!( + violations[0] + .error() + .to_string() + .contains(&format!("`{name}`")) + ); + Ok(()) +} + +#[test] +fn key_coercion_cannot_replace_the_reflection_call_site() -> Result<(), String> { + // Given + let script = "const key = { toString() { Math.max(1, 2); return 'random' } };\nReflect.getOwnPropertyDescriptor(Math, key)"; + let offset = script + .find("Reflect.getOwnPropertyDescriptor") + .ok_or("missing read")?; + // When + let Err(Failure::Forbidden(violations)) = run(script) else { + return Err("coerced reflection was accepted".to_string()); + }; + // Then + assert_eq!(violations[0].site().map(|(_, at)| at), Some(offset)); + Ok(()) +} + +#[rstest] +#[case("String(Object.getOwnPropertyDescriptor({ x: 1 }, 'x').value)", "1")] +#[case("String(Reflect.getOwnPropertyDescriptor({ x: 1 }, 'x').value)", "1")] +#[case("String(Object.getOwnPropertyDescriptors({ x: 1 }).x.value)", "1")] +#[case( + "`${Object.getOwnPropertyDescriptor.length}:${Object.getOwnPropertyDescriptors.length}:${Reflect.getOwnPropertyDescriptor.length}`", + "2:1:2" +)] +#[case("String(Object.getOwnPropertyDescriptor({}, 'missing'))", "undefined")] +#[case("String(Reflect.getOwnPropertyDescriptor({}, 'missing'))", "undefined")] +#[case("String(Object.getOwnPropertyDescriptor('a', '0').value)", "a")] +#[case("String(Object.getOwnPropertyDescriptors('a')['0'].value)", "a")] +#[case( + "const o = { get random() { return 4 } }; const d = Reflect.getOwnPropertyDescriptor(o, 'random'); String(d.get.call(o))", + "4" +)] +#[case( + "const o = { get x() { throw new Error('getter executed') } }; String(typeof Object.getOwnPropertyDescriptors(o).x.get)", + "function" +)] +#[case( + "const o = {}; Object.defineProperty(o, 'x', { value: 4, writable: false, enumerable: false, configurable: false }); JSON.stringify(Reflect.getOwnPropertyDescriptor(o, 'x'))", + "{\"value\":4,\"writable\":false,\"enumerable\":false,\"configurable\":false}" +)] +#[case( + "const o = { normalize() { return 5 } }; String(Object.getOwnPropertyDescriptors(o).normalize.value())", + "5" +)] +#[case( + "Math.random = () => 7; String(Object.getOwnPropertyDescriptors(Math).random.value())", + "7" +)] +#[case( + "String.prototype.normalize = () => 'own'; String(Reflect.getOwnPropertyDescriptor(String.prototype, 'normalize').value())", + "own" +)] +#[case( + "globalThis.Date = 9; String(Object.getOwnPropertyDescriptor(globalThis, 'Date').value)", + "9" +)] +#[case( + "globalThis.Date = 9; globalThis.Intl = 8; globalThis.performance = 7; globalThis.crypto = 6; globalThis.Temporal = 5; String(Object.getOwnPropertyDescriptors(globalThis).Date.value)", + "9" +)] +#[case( + "Object.defineProperty(globalThis, 'Intl', { get() { return 8 } }); String(Reflect.getOwnPropertyDescriptor(globalThis, 'Intl').get())", + "8" +)] +#[case( + "String(Reflect.getOwnPropertyDescriptor(Math, 'PI').value === Math.PI)", + "true" +)] +#[case( + "const key = Symbol('x'); const o = { [key]: 6 }; String(Object.getOwnPropertyDescriptors(o)[key].value)", + "6" +)] +#[case( + "Object.defineProperty(Object.prototype, 'get', { get() { throw new Error('inherited get') } }); String(Object.getOwnPropertyDescriptor({ x: 1 }, 'x').value)", + "1" +)] +#[case( + "const read = { getOwnPropertyDescriptors() { return { x: { value: 3 } } } }; String(read.getOwnPropertyDescriptors().x.value)", + "3" +)] +#[case( + "let order = ''; const o = new Proxy({ x: 2 }, { getOwnPropertyDescriptor(target, key) { order += 'd'; return Reflect.getOwnPropertyDescriptor(target, key) } }); const key = { toString() { order += 'k'; return 'x' } }; const d = Reflect.getOwnPropertyDescriptor.call(null, o, key); `${order}:${d.value}`", + "kd:2" +)] +fn user_owned_descriptors_keep_their_semantics( + #[case] script: &str, + #[case] expected: &str, +) -> Result<(), String> { + // Given / When + let value = match run(script) { + Ok(value) => value, + Err(Failure::Js(error)) => return Err(error.to_string()), + Err(Failure::Forbidden(_)) => return Err("ordinary descriptor was forbidden".to_string()), + }; + // Then + assert_eq!( + value + .as_string() + .ok_or("expected a string")? + .to_std_string_escaped(), + expected + ); + Ok(()) +} + +#[test] +fn reflected_getter_reads_stay_forbidden_during_serialization() -> Result<(), String> { + // Given + let mut context = Context::default(); + let sandbox = Sandbox::new(&mut context).map_err(|error| error.to_string())?; + let script = "({ get value() { return Object.getOwnPropertyDescriptors(Math).random.value } })"; + let source = super::instrument(script, "reflection.js"); + sandbox + .prepare(&mut context, &source) + .map_err(|error| error.to_string())?; + let value = sandbox + .run_source(&mut context, Source::from_bytes(&source.code)) + .map_err(|_| "object creation failed")?; + let json = context.intrinsics().objects().json(); + let stringify = json + .get(boa_engine::js_string!("stringify"), &mut context) + .map_err(|error| error.to_string())? + .to_object(&mut context) + .map_err(|error| error.to_string())?; + // When + let error = stringify + .call(&JsValue::undefined(), &[value], &mut context) + .err() + .ok_or("reflection serialized silently")?; + // Then + let Err(Failure::Forbidden(violations)) = sandbox.check(&[&error]) else { + return Err("serialization lost forbidden evidence".to_string()); + }; + assert_eq!(violations[0].name(), "Math.random"); + assert_eq!( + violations[0].site().map(|(_, offset)| offset), + script.find("Object.getOwnPropertyDescriptors") + ); + Ok(()) +} + +#[test] +fn reflect_primitive_target_keeps_its_native_type_error() -> Result<(), String> { + // Given / When + let value = run("try { Reflect.getOwnPropertyDescriptor(1, 'x') } catch (error) { String(error instanceof TypeError) }") + .map_err(|_| "primitive reflection did not preserve its catchable error")?; + // Then + assert_eq!( + value + .as_string() + .ok_or("expected a caught error")? + .to_std_string_escaped(), + "true" + ); + Ok(()) +} + +#[test] +#[serial_test::serial] +fn stylesheet_descriptor_branch_fails_instead_of_emitting_blue_css() -> Result<(), String> { + // Given + let code = "import { style } from '@vanilla-extract/css';\nexport const card = style({ color: Object.getOwnPropertyDescriptors(Math).random.value ? 'red' : 'blue' });"; + let offset = code + .find("Object.getOwnPropertyDescriptors") + .ok_or("missing read")?; + // When + let error = crate::extract( + "reflected-random.css.ts", + code, + crate::ExtractOption { + import_aliases: std::collections::HashMap::from([( + "@vanilla-extract/css".to_string(), + crate::ImportAlias::NamedToNamed, + )]), + ..crate::ExtractOption::default() + }, + ) + .err() + .ok_or("reflection silently emitted CSS")? + .to_string(); + // Then + let place = crate::locate("reflected-random.css.ts", code, offset); + assert!(error.starts_with(&format!("{place}:")), "{error}"); + assert!(error.contains("`Math.random`"), "{error}"); + assert!(error.contains(super::Kind::Random.requirement()), "{error}"); + assert!( + error.contains("Fix: use a literal or a CSS variable"), + "{error}" + ); + Ok(()) +} diff --git a/libs/extractor/src/evaluation_sandbox_sites.rs b/libs/extractor/src/evaluation_sandbox_sites.rs new file mode 100644 index 000000000..4e2df233a --- /dev/null +++ b/libs/extractor/src/evaluation_sandbox_sites.rs @@ -0,0 +1,273 @@ +//! Lexically bound source instrumentation for exact sandbox read sites. + +use std::hash::{Hash, Hasher}; + +use oxc_ast::ast::{Expression, IdentifierReference, ObjectProperty}; +use oxc_ast_visit::{Visit, walk}; +use oxc_semantic::{Scoping, SemanticBuilder}; +use oxc_span::{GetSpan, SourceType, Span}; + +#[path = "evaluation_sandbox_sites_operations.rs"] +mod operations; +pub(crate) use operations::instrument; + +/// Source edits and read coordinates, reusable before module mapper code generation. +pub(crate) struct Instrumented { + pub(crate) code: String, + pub(crate) edits: Vec, + pub(super) sites: Vec<(String, u32)>, + pub(super) helper: String, + source: String, + path: String, +} + +struct Reads<'s> { + scoping: &'s Scoping, + source: &'s str, + helper: &'s str, + changes: Vec<(Span, String)>, + sites: Vec<(String, u32)>, + path: &'s str, +} + +impl Reads<'_> { + fn site(&mut self, span: Span) -> usize { + let id = self.sites.len(); + self.sites.push(( + crate::locate(self.path, self.source, span.start as usize), + span.start, + )); + id + } + + fn external(&self, identifier: &IdentifierReference<'_>) -> bool { + let Some(reference) = identifier.reference_id.get() else { + return false; + }; + let reference = self.scoping.get_reference(reference); + reference.symbol_id().is_none() + && reference.is_read() + && !reference.is_write() + && !super::guards::EXACT_GLOBALS.contains(&identifier.name.as_str()) + } + + fn write(&mut self, span: Span) { + let id = self.site(span); + self.changes.push(( + Span::new(span.start, span.start), + format!("({}({id}, undefined), ", self.helper), + )); + self.changes + .push((Span::new(span.end, span.end), ")".to_string())); + } +} + +impl<'a> Visit<'a> for Reads<'_> { + fn visit_identifier_reference(&mut self, identifier: &IdentifierReference<'a>) { + if self.external(identifier) { + let id = self.site(identifier.span); + self.changes.push(( + identifier.span, + format!("({}({id}, () => {}, true))", self.helper, identifier.name), + )); + } + } + + fn visit_object_property(&mut self, property: &ObjectProperty<'a>) { + if property.shorthand + && let Expression::Identifier(identifier) = &property.value + && self.external(identifier) + { + let id = self.site(identifier.span); + self.changes.push(( + identifier.span, + format!( + "{}: {}({id}, () => {}, true)", + identifier.name, self.helper, identifier.name + ), + )); + } else { + walk::walk_object_property(self, property); + } + } + + fn visit_static_member_expression( + &mut self, + member: &oxc_ast::ast::StaticMemberExpression<'a>, + ) { + let id = self.site(member.span); + let access = Span::new(member.object.span().end, member.property.span.end); + let optional = if member.optional { "?." } else { "" }; + self.changes.push(( + access, + format!( + "{optional}[{}({id}, \"{}\")]", + self.helper, member.property.name + ), + )); + walk::walk_static_member_expression(self, member); + } + + fn visit_computed_member_expression( + &mut self, + member: &oxc_ast::ast::ComputedMemberExpression<'a>, + ) { + // The key may itself read globals; establish the member site after it evaluates. + let id = self.site(member.span); + let key = member.expression.span(); + self.changes.push(( + Span::new(key.start, key.start), + format!("{}({id}, ", self.helper), + )); + self.changes + .push((Span::new(key.end, key.end), ")".to_string())); + walk::walk_computed_member_expression(self, member); + } + + fn visit_call_expression(&mut self, call: &oxc_ast::ast::CallExpression<'a>) { + self.visit_expression(&call.callee); + for (index, argument) in call.arguments.iter().enumerate() { + if index + 1 == call.arguments.len() && !argument.is_spread() { + let id = self.site(call.span); + let span = argument.span(); + self.changes.push(( + Span::new(span.start, span.start), + format!("{}({id}, ", self.helper), + )); + self.changes + .push((Span::new(span.end, span.end), ")".to_string())); + } + self.visit_argument(argument); + } + } + + fn visit_binding_property(&mut self, property: &oxc_ast::ast::BindingProperty<'a>) { + let id = self.site(property.key.span()); + let span = property.key.span(); + let written = &self.source[span.start as usize..span.end as usize]; + if property.computed { + self.changes.push(( + Span::new(span.start, span.start), + format!("{}({id}, ", self.helper), + )); + self.changes + .push((Span::new(span.end, span.end), ")".to_string())); + } else { + let key = match &property.key { + oxc_ast::ast::PropertyKey::StaticIdentifier(_) => format!("\"{written}\""), + _ => written.to_string(), + }; + let binding = if property.shorthand { + format!(": {written}") + } else { + String::new() + }; + self.changes + .push((span, format!("[{}({id}, {key})]{binding}", self.helper))); + } + walk::walk_binding_property(self, property); + } + + fn visit_assignment_expression(&mut self, assignment: &oxc_ast::ast::AssignmentExpression<'a>) { + self.write(assignment.span); + self.visit_assignment_target(&assignment.left); + self.visit_expression(&assignment.right); + } + + fn visit_update_expression(&mut self, update: &oxc_ast::ast::UpdateExpression<'a>) { + self.write(update.span); + walk::walk_update_expression(self, update); + } +} + +/// Adds identity calls to member keys and thunk calls around unbound identifiers. +/// Receivers, short-circuit optional chains and direct `eval` calls are unchanged. +pub(crate) fn instrument_located( + source: &str, + path: &str, + authored: &dyn Fn(usize) -> bool, +) -> Instrumented { + let allocator = oxc_allocator::Allocator::default(); + let parsed = oxc_parser::Parser::new(&allocator, source, SourceType::default()).parse(); + let scoping = SemanticBuilder::new() + .build(&parsed.program) + .semantic + .into_scoping(); + let mut hash = rustc_hash::FxHasher::default(); + source.hash(&mut hash); + path.hash(&mut hash); + let helper = + crate::fresh_name::fresh_name(&format!("__devup_read_site_{:x}__", hash.finish()), source); + let mut reads = Reads { + scoping: &scoping, + source, + helper: &helper, + changes: Vec::new(), + sites: Vec::new(), + path, + }; + if parsed.diagnostics.is_empty() { + reads.visit_program(&parsed.program); + operations::Operations(&mut reads).visit_program(&parsed.program); + operations::locate_members(&parsed.program, &mut reads, authored); + } + reads + .changes + .sort_by_key(|(span, _)| (span.start, span.end)); + let mut code = String::with_capacity(source.len()); + let mut edits = Vec::new(); + let mut copied = 0; + for (span, text) in reads.changes { + let (start, end) = (span.start as usize, span.end as usize); + code.push_str(&source[copied..start]); + code.push_str(&text); + edits.push((start, end, text.len())); + copied = end; + } + code.push_str(&source[copied..]); + Instrumented { + code, + edits, + sites: reads.sites, + helper, + source: source.to_string(), + path: path.to_string(), + } +} + +impl Instrumented { + /// Maps engine frames to the pre-instrumentation text; explicit read frames already name it. + pub(crate) fn explain(&self, error: &str) -> String { + error + .lines() + .filter(|line| *line != format!(" at {} (native)", self.helper)) + .map(|line| { + if line.contains("at (") { + return line.to_string(); + } + let marker = format!("({}:", self.path); + let Some((prefix, position)) = line.rsplit_once(&marker) else { + return line.to_string(); + }; + let Some((row, column)) = position.trim_end_matches(')').split_once(':') else { + return line.to_string(); + }; + let (Ok(row), Ok(column)) = (row.parse::(), column.parse::()) else { + return line.to_string(); + }; + let offset = crate::source_map::Lines::new(&self.code) + .code_point_offset(row.saturating_sub(1), column.saturating_sub(1)); + let offset = crate::import_alias_visit::source_offset(&self.edits, offset); + format!( + "{prefix}({})", + crate::locate(&self.path, &self.source, offset) + ) + }) + .collect::>() + .join("\n") + } +} + +#[cfg(test)] +#[path = "evaluation_sandbox_sites_ci_tests.rs"] +mod ci_tests; diff --git a/libs/extractor/src/evaluation_sandbox_sites_ci_tests.rs b/libs/extractor/src/evaluation_sandbox_sites_ci_tests.rs new file mode 100644 index 000000000..bcdb01439 --- /dev/null +++ b/libs/extractor/src/evaluation_sandbox_sites_ci_tests.rs @@ -0,0 +1,26 @@ +use oxc_ast_visit::Visit; + +use super::Reads; + +#[test] +fn unannotated_identifier_is_preserved_without_an_invented_read_site() { + // Given + let source = "unknown"; + let allocator = oxc_allocator::Allocator::default(); + let parsed = + oxc_parser::Parser::new(&allocator, source, oxc_span::SourceType::default()).parse(); + let scoping = oxc_semantic::Scoping::default(); + let mut reads = Reads { + scoping: &scoping, + source, + helper: "read", + changes: Vec::new(), + sites: Vec::new(), + path: "unannotated.js", + }; + // When + reads.visit_program(&parsed.program); + // Then + assert_eq!(reads.changes, vec![]); + assert_eq!(reads.sites, vec![]); +} diff --git a/libs/extractor/src/evaluation_sandbox_sites_operations.rs b/libs/extractor/src/evaluation_sandbox_sites_operations.rs new file mode 100644 index 000000000..a7d24c977 --- /dev/null +++ b/libs/extractor/src/evaluation_sandbox_sites_operations.rs @@ -0,0 +1,101 @@ +//! Original operation positions for proxy traps without explicit member lookups. + +use oxc_ast::ast::{ + BinaryExpression, ForInStatement, SpreadElement, TemplateLiteral, UnaryExpression, +}; +use oxc_ast_visit::{Visit, walk}; +use oxc_span::{GetSpan, Span}; +use oxc_syntax::operator::UnaryOperator; + +pub(super) struct Operations<'r, 's>(pub(super) &'r mut super::Reads<'s>); + +pub(crate) fn instrument(source: &str, path: &str) -> super::Instrumented { + super::instrument_located(source, path, &|_| true) +} + +pub(super) fn locate_members( + program: &oxc_ast::ast::Program<'_>, + reads: &mut super::Reads<'_>, + authored: &dyn Fn(usize) -> bool, +) { + struct Members<'r, 's, 'f>(&'r mut super::Reads<'s>, &'f dyn Fn(usize) -> bool); + impl Members<'_, '_, '_> { + fn locate(&mut self, from: u32, to: u32) { + if !(self.1)(from as usize) { + for (place, offset) in &mut self.0.sites { + if *offset == from { + *offset = to; + *place = crate::locate(self.0.path, self.0.source, to as usize); + } + } + } + } + } + impl<'a> Visit<'a> for Members<'_, '_, '_> { + fn visit_static_member_expression( + &mut self, + member: &oxc_ast::ast::StaticMemberExpression<'a>, + ) { + self.locate(member.span.start, member.property.span.start); + walk::walk_static_member_expression(self, member); + } + + fn visit_computed_member_expression( + &mut self, + member: &oxc_ast::ast::ComputedMemberExpression<'a>, + ) { + self.locate(member.span.start, member.expression.span().start); + walk::walk_computed_member_expression(self, member); + } + } + Members(reads, authored).visit_program(program); +} + +impl super::Reads<'_> { + fn value_site(&mut self, value: Span, operation: Span) { + let id = self.site(operation); + self.changes.push(( + Span::new(value.start, value.start), + format!("{}({id}, ", self.helper), + )); + self.changes + .push((Span::new(value.end, value.end), ")".to_string())); + } +} + +impl<'a> Visit<'a> for Operations<'_, '_> { + fn visit_binary_expression(&mut self, binary: &BinaryExpression<'a>) { + self.0.value_site(binary.right.span(), binary.span); + walk::walk_binary_expression(self, binary); + } + + fn visit_unary_expression(&mut self, unary: &UnaryExpression<'a>) { + match unary.operator { + UnaryOperator::UnaryPlus | UnaryOperator::UnaryNegation | UnaryOperator::BitwiseNot => { + self.0.value_site(unary.argument.span(), unary.span); + } + UnaryOperator::LogicalNot + | UnaryOperator::Typeof + | UnaryOperator::Void + | UnaryOperator::Delete => {} + } + walk::walk_unary_expression(self, unary); + } + + fn visit_spread_element(&mut self, spread: &SpreadElement<'a>) { + self.0.value_site(spread.argument.span(), spread.span); + walk::walk_spread_element(self, spread); + } + + fn visit_for_in_statement(&mut self, statement: &ForInStatement<'a>) { + self.0.value_site(statement.right.span(), statement.span); + walk::walk_for_in_statement(self, statement); + } + + fn visit_template_literal(&mut self, template: &TemplateLiteral<'a>) { + for expression in &template.expressions { + self.0.value_site(expression.span(), expression.span()); + } + walk::walk_template_literal(self, template); + } +} diff --git a/libs/extractor/src/evaluation_sandbox_tests.rs b/libs/extractor/src/evaluation_sandbox_tests.rs new file mode 100644 index 000000000..3f7cc425d --- /dev/null +++ b/libs/extractor/src/evaluation_sandbox_tests.rs @@ -0,0 +1,271 @@ +use boa_engine::{Context, JsValue, Source, js_string}; +use rstest::rstest; + +use super::{Failure, Sandbox}; + +fn run(script: &str) -> Result { + let mut context = Context::default(); + let sandbox = Sandbox::new(&mut context).map_err(Failure::Js)?; + let source = super::instrument(script, "fixture.js"); + sandbox + .prepare(&mut context, &source) + .map_err(Failure::Js)?; + sandbox.run_source( + &mut context, + Source::from_bytes(&source.code).with_path(std::path::Path::new("fixture.js")), + ) +} + +fn text(script: &str) -> Result { + let mut context = Context::default(); + let sandbox = Sandbox::new(&mut context).map_err(|error| error.to_string())?; + let source = super::instrument(script, "fixture.js"); + sandbox + .prepare(&mut context, &source) + .map_err(|error| error.to_string())?; + let value = match sandbox.run_source( + &mut context, + Source::from_bytes(&source.code).with_path(std::path::Path::new("fixture.js")), + ) { + Ok(value) => value, + Err(Failure::Js(error)) => return Err(error.to_string()), + Err(Failure::Forbidden(_)) => return Err("unexpected forbidden read".to_string()), + }; + value + .to_string(&mut context) + .map(|value| value.to_std_string_escaped()) + .map_err(|error| error.to_string()) +} + +fn reads(script: &str) -> Result, String> { + match run(script) { + Err(Failure::Forbidden(violations)) => Ok(violations + .iter() + .map(|violation| violation.name().to_string()) + .collect()), + Err(Failure::Js(error)) => Err(format!("failed another way: {error}")), + Ok(_) => Err("read nothing forbidden".to_string()), + } +} + +#[path = "evaluation_sandbox_tests_ci.rs"] +mod ci_tests; + +#[test] +fn deterministic_computation_and_console_calls_run() -> Result<(), String> { + assert_eq!( + text( + "console.log('a'); console.table(1); `${Math.max(1, 2)}${JSON.stringify({ a: [1] })}${'b'.toUpperCase()}`" + )?, + r#"2{"a":[1]}B"# + ); + Ok(()) +} + +#[rstest] +#[case("Date.now()", "Date")] +#[case("new Date()", "Date")] +#[case("Date()", "Date")] +#[case("Date.UTC(2020, 0)", "Date")] +#[case("globalThis['Da' + 'te'].parse('x')", "Date")] +#[case("const clock = Date; clock.now()", "Date")] +#[case("Math.random()", "Math.random")] +#[case("Math.random", "Math.random")] +#[case("performance.now()", "performance")] +#[case("performance.timeOrigin", "performance")] +#[case("performance.memory", "performance")] +#[case("crypto.randomUUID()", "crypto")] +#[case("crypto.getRandomValues(new Uint8Array(1))", "crypto")] +#[case("crypto.subtle", "crypto")] +#[case("typeof window", "window")] +#[case("typeof process", "process")] +#[case("navigator.language", "navigator")] +#[case("fetch('x')", "fetch")] +#[case("new Intl.NumberFormat('en')", "Intl")] +#[case("Intl.getCanonicalLocales('en')", "Intl")] +#[case("Boolean(Intl)", "Intl")] +#[case("typeof Intl", "Intl")] +#[case("typeof Date", "Date")] +#[case("typeof Temporal", "Temporal")] +#[case("(1).toLocaleString()", "Number.prototype.toLocaleString")] +#[case("[1].toLocaleString()", "Array.prototype.toLocaleString")] +#[case("({}).toLocaleString()", "Object.prototype.toLocaleString")] +#[case("1n.toLocaleString()", "BigInt.prototype.toLocaleString")] +#[case( + "new Uint8Array(1).toLocaleString()", + "TypedArray.prototype.toLocaleString" +)] +#[case("'a'.localeCompare('b')", "String.prototype.localeCompare")] +#[case("'a'.toLocaleUpperCase()", "String.prototype.toLocaleUpperCase")] +#[case("'A'.toLocaleLowerCase()", "String.prototype.toLocaleLowerCase")] +#[case("'a'.normalize()", "String.prototype.normalize")] +#[case("const normalize = 'a'.normalize; 1", "String.prototype.normalize")] +#[case( + "const key = 'normalize'; const normalize = 'a'[key]; 1", + "String.prototype.normalize" +)] +#[case("const global = globalThis; global['Intl']", "Intl")] +#[case("setTimeout", "setTimeout")] +#[case("typeof require", "require")] +#[case("const { random } = Math; 1", "Math.random")] +#[case("const { normalize: method } = 'a'; 1", "String.prototype.normalize")] +#[case("eval('typeof Intl')", "Intl")] +fn a_forbidden_read_fails_the_run(#[case] script: &str, #[case] name: &str) -> Result<(), String> { + assert_eq!(reads(script)?, [name]); + Ok(()) +} + +#[rstest] +#[case("try { Date.now() } catch { 1 }")] +#[case("try { Math.random() } catch (error) { throw new Error('other') }")] +#[case("try { window } catch {} 'fine'")] +#[case("const read = () => { try { return performance.now() } catch { return 0 } }; read()")] +#[case("function f() { return Date.now() } try { f() } catch (error) { delete error.message }")] +#[case("({ get a() { return Date.now() } }).a")] +#[case("JSON.stringify({ get a() { return Math.random() } })")] +#[case("[1, 2].map(() => crypto.getRandomValues(new Uint8Array(1)))")] +fn a_caught_forbidden_read_still_fails_the_run(#[case] script: &str) -> Result<(), String> { + assert_eq!(reads(script)?.len(), 1, "{script}"); + Ok(()) +} + +#[test] +fn every_read_is_listed_in_order() -> Result<(), String> { + assert_eq!( + reads( + "try { Date.now() } catch {} try { window } catch {} try { 'a'.normalize() } catch {}" + )?, + ["Date", "window", "String.prototype.normalize"] + ); + Ok(()) +} + +#[test] +fn code_cannot_clear_the_evidence() -> Result<(), String> { + assert_eq!(reads("try { Date.now() } catch {} const global = globalThis; for (const key of Reflect.ownKeys(global)) { try { delete global[key] } catch {} } Object.freeze(Object.prototype); 1")?.first().map(String::as_str), Some("Date")); + Ok(()) +} + +#[rstest] +#[case( + "const history = [1, 2]; var screen = 'big'; globalThis.document = { a: 1 }; const Date = 7; `${history.length}${screen}${document.a}${Date}`", + "2big17" +)] +#[case("const Intl = 3; Boolean(Intl)", "true")] +#[case( + "const o = { value: 7, get read() { return this.value }, method() { return this.read } }; o.method()", + "7" +)] +#[case("let o = null; o?.normalize()", "undefined")] +#[case("let o = null; o?.x.y", "undefined")] +#[case("let o = null; o?.['x'].y()", "undefined")] +#[case("let o = null; o?.['x']?.y", "undefined")] +#[case("const o = { normalize() { return 4 } }; o.normalize()", "4")] +#[case("let x = 1; eval('x = 3'); x", "3")] +#[case( + "let o = { x: 1 }; o.x++; o['x'] = 4; delete o.x; JSON.stringify(o)", + "{}" +)] +#[case("const { x = 4, y: value } = { y: 3 }; `${x}${value}`", "43")] +#[case("const Intl = 3; eval('Intl')", "3")] +fn declared_values_and_receivers_keep_their_semantics( + #[case] script: &str, + #[case] expected: &str, +) -> Result<(), String> { + assert_eq!(text(script)?, expected); + Ok(()) +} + +#[test] +fn reads_in_a_host_run_function_are_found_by_the_check() -> Result<(), String> { + let mut context = Context::default(); + let sandbox = Sandbox::new(&mut context).map_err(|error| error.to_string())?; + let source = super::instrument("({ get width() { return Date.now() } })", "fixture.js"); + sandbox + .prepare(&mut context, &source) + .map_err(|error| error.to_string())?; + let value = sandbox + .run_source( + &mut context, + Source::from_bytes(&source.code).with_path(std::path::Path::new("fixture.js")), + ) + .map_err(|_| "creation failed".to_string())?; + let json = context.intrinsics().objects().json(); + let stringify = json + .get(js_string!("stringify"), &mut context) + .map_err(|error| error.to_string())?; + let error = stringify + .to_object(&mut context) + .map_err(|error| error.to_string())? + .call(&JsValue::undefined(), &[value], &mut context) + .err() + .ok_or("serialization succeeded")?; + let Err(Failure::Forbidden(violations)) = sandbox.check(&[&error]) else { + return Err("no violation".to_string()); + }; + assert_eq!(violations.len(), 1); + assert_eq!(violations[0].name(), "Date"); + assert_eq!(violations[0].site().map(|(_, offset)| offset), Some(24)); + Ok(()) +} + +#[rstest] +#[case("function helper() {\n return typeof window\n}\nhelper()", "window")] +#[case( + "function helper() {\n return Date\n}\ntry { helper() } catch {}", + "Date" +)] +#[case( + "function helper() {\n return globalThis['Intl']\n}\nhelper()", + "globalThis" +)] +fn a_read_reports_its_actual_original_site( + #[case] script: &str, + #[case] occurrence: &str, +) -> Result<(), String> { + let Err(Failure::Forbidden(violations)) = run(script) else { + return Err("no violation".to_string()); + }; + let offset = script + .find(occurrence) + .ok_or("fixture occurrence missing")?; + assert_eq!(violations[0].site().map(|(_, at)| at), Some(offset)); + assert!(violations[0].error().to_string().contains(&crate::locate( + "fixture.js", + script, + offset + ))); + Ok(()) +} + +#[test] +fn other_failures_stay_failures_of_their_own() { + assert!(matches!( + run("throw new Error('boom')"), + Err(Failure::Js(_)) + )); + assert!(matches!(run("for (;;) {}"), Err(Failure::Js(_)))); + assert!(matches!(run("missing.read"), Err(Failure::Forbidden(_)))); + assert!(matches!(run("let a = ;"), Err(Failure::Js(_)))); +} + +#[test] +fn generated_call_frames_map_back_to_original_columns() -> Result<(), String> { + let script = "const value = { fail() { throw new TypeError('bad') } };\nvalue.fail();"; + let mut context = Context::default(); + let sandbox = Sandbox::new(&mut context).map_err(|error| error.to_string())?; + let source = super::instrument(script, "fixture.js"); + sandbox + .prepare(&mut context, &source) + .map_err(|error| error.to_string())?; + let Err(Failure::Js(error)) = sandbox.run_source( + &mut context, + Source::from_bytes(&source.code).with_path(std::path::Path::new("fixture.js")), + ) else { + return Err("no TypeError".to_string()); + }; + let mapped = source.explain(&error.to_string()); + assert!(mapped.contains("TypeError: bad"), "{mapped}"); + assert!(mapped.contains("fixture.js:2:11"), "{mapped}"); + Ok(()) +} diff --git a/libs/extractor/src/evaluation_sandbox_tests_ci.rs b/libs/extractor/src/evaluation_sandbox_tests_ci.rs new file mode 100644 index 000000000..f99f74a5a --- /dev/null +++ b/libs/extractor/src/evaluation_sandbox_tests_ci.rs @@ -0,0 +1,29 @@ +use rstest::rstest; + +use super::{reads, text}; + +#[test] +fn text_rejects_javascript_failure_instead_of_reporting_a_value() { + // Given / When + let result = text("throw new TypeError('fixture failure')"); + // Then + assert!(result.is_err()); +} + +#[test] +fn text_rejects_forbidden_read_even_when_script_catches_it() { + // Given / When + let result = text("try { Date.now() } catch {} 'caught'"); + // Then + assert!(result.is_err()); +} + +#[rstest] +#[case("throw new TypeError('fixture failure')")] +#[case("Math.max(1, 2)")] +fn reads_rejects_runs_without_forbidden_evidence(#[case] script: &str) { + // Given / When + let result = reads(script); + // Then + assert!(result.is_err()); +} diff --git a/libs/extractor/src/extractor/extract_global_style_from_expression.rs b/libs/extractor/src/extractor/extract_global_style_from_expression.rs index 9d38fd88e..9053f834e 100644 --- a/libs/extractor/src/extractor/extract_global_style_from_expression.rs +++ b/libs/extractor/src/extractor/extract_global_style_from_expression.rs @@ -37,6 +37,24 @@ pub fn extract_global_style_from_expression<'a>( expression: &mut Expression<'a>, file: &str, ) -> GlobalExtractResult<'a> { + let invalid: Vec<_> = crate::dead_properties::authored_errors( + expression, + crate::dead_properties::ObjectKind::Globals, + ) + .into_iter() + .map(|(offset, code, requirement)| ExtractStyleProp::Unreadable { + offset, + code, + prop: false, + requirement: Some(requirement), + }) + .collect(); + if !invalid.is_empty() { + return GlobalExtractResult { + styles: invalid, + style_order: None, + }; + } let mut styles = vec![]; collect_global_styles(ast_builder, expression, file, &[], &mut styles); GlobalExtractResult { diff --git a/libs/extractor/src/extractor/extract_style_from_styled.rs b/libs/extractor/src/extractor/extract_style_from_styled.rs index ec4d3e61e..853908060 100644 --- a/libs/extractor/src/extractor/extract_style_from_styled.rs +++ b/libs/extractor/src/extractor/extract_style_from_styled.rs @@ -45,6 +45,9 @@ use oxc_syntax::operator::{BinaryOperator, LogicalOperator, UnaryOperator}; /// only the runtime gives that component const STYLED_BASE: &str = "DevupStyled"; +/// The Devup UI component an expression reads, by the bindings of the file +pub type Kinds<'k> = &'k dyn Fn(&Expression<'_>) -> Option; + const STYLED_FACTORY: &str = "it renders a tag, a component or a value naming one, with rule objects or CSS text"; @@ -554,12 +557,16 @@ fn inherited_parts<'a>( inherited .attrs .iter() - .map(|a| a.clone_in(allocator)) + .map(|a| a.clone_in_with_semantic_ids(allocator)) .collect(), ) }, ); - all_attrs.extend(attrs.iter().map(|attr| attr.clone_in(allocator))); + all_attrs.extend( + attrs + .iter() + .map(|attr| attr.clone_in_with_semantic_ids(allocator)), + ); (classes, all_attrs) } @@ -611,7 +618,10 @@ impl<'a> Base<'a> { .iter() .map(|style| style.clone_in(allocator)) .collect(), - attrs: attrs.iter().map(|attr| attr.clone_in(allocator)).collect(), + attrs: attrs + .iter() + .map(|attr| attr.clone_in_with_semantic_ids(allocator)) + .collect(), reads, forward, markers: Vec::new(), @@ -671,7 +681,7 @@ pub fn extended<'b, 'a>(expression: &'b Expression<'a>) -> Option<&'b Expression fn extract_base_tag_and_class_name<'a>( ast_builder: &AstBuilder<'a>, input: &Expression<'a>, - imports: &FxHashMap, + imports: Kinds<'_>, ) -> Option> { match unwrap_syntax_only(input) { Expression::StaticMemberExpression(member) => { @@ -692,7 +702,7 @@ fn extract_base_tag_and_class_name<'a>( fn tag_from_argument<'a>( ast_builder: &AstBuilder<'a>, argument: &Argument<'a>, - imports: &FxHashMap, + imports: Kinds<'_>, ) -> Option> { let argument = unwrap_syntax_only(argument.as_expression()?); match argument { @@ -704,9 +714,7 @@ fn tag_from_argument<'a>( Expression::Identifier(ident) if ident.name == "undefined" => return None, _ => {} } - if let Expression::Identifier(ident) = argument - && let Some(kind) = imports.get(ident.name.as_str()) - { + if let Some(kind) = imports(argument) { return Some(Base { styles: Some(kind.extract()), ..Base::named(kind.to_tag().to_string()) @@ -756,7 +764,7 @@ fn jsx_name(expression: &Expression<'_>) -> Option { fn resolve_styled_call_target<'a>( ast_builder: &AstBuilder<'a>, call: &CallExpression<'a>, - imports: &FxHashMap, + imports: Kinds<'_>, ) -> Option<(Base<'a>, usize)> { if call.arguments.len() == 1 && let Some(base) = extract_base_tag_and_class_name(ast_builder, &call.callee, imports) @@ -788,7 +796,7 @@ pub fn extract_style_from_styled<'a>( ast_builder: &AstBuilder<'a>, expression: &mut Expression<'a>, naming: Naming<'_>, - imports: &FxHashMap, + imports: Kinds<'_>, modifiers: (&[Expression<'a>], Option), inherited: Option<&StyledDefinition<'a>>, context: StyledContext<'_>, @@ -1202,7 +1210,7 @@ fn clone_all<'s, 'a>( ) -> impl Iterator> + 's { expressions .iter() - .map(|expression| expression.clone_in(ast_builder.allocator())) + .map(|expression| expression.clone_in_with_semantic_ids(ast_builder.allocator())) } /// The name the attrs wrapper binds props to, chosen not to shadow what the @@ -1215,7 +1223,7 @@ const ATTRS_PROPS: &str = "__devupProps"; pub fn take_styled_modifiers<'a>( ast_builder: &AstBuilder<'a>, factory: &mut Expression<'a>, - is_styled: impl Fn(&str) -> bool, + is_styled: impl Fn(&Expression<'_>) -> bool, ) -> (Vec>, Vec>) { let mut attrs = Vec::new(); let mut configs = Vec::new(); @@ -1286,7 +1294,10 @@ pub fn read_forward(options: &[&Expression<'_>]) -> (Option, Option<(u3 const SHOULD_FORWARD_PROP: &str = "`shouldForwardProp` must be a function of the prop name that compares it with strings, `[...].includes(prop)`, `prop.startsWith(...)` or `isPropValid(prop)`, joined by `!`, `&&` and `||`"; -fn is_modified_styled(expression: &Expression<'_>, is_styled: impl Fn(&str) -> bool) -> bool { +fn is_modified_styled( + expression: &Expression<'_>, + is_styled: impl Fn(&Expression<'_>) -> bool, +) -> bool { let mut expression = unwrap_syntax_only(expression); let mut modified = false; while let Some(object) = modifier_object(expression) { @@ -1296,10 +1307,10 @@ fn is_modified_styled(expression: &Expression<'_>, is_styled: impl Fn(&str) -> b modified && match expression { Expression::StaticMemberExpression(member) => { - matches!(&member.object, Expression::Identifier(ident) if is_styled(&ident.name)) + matches!(&member.object, object @ Expression::Identifier(_) if is_styled(object)) } Expression::CallExpression(call) => { - matches!(&call.callee, Expression::Identifier(ident) if is_styled(&ident.name)) + matches!(&call.callee, callee @ Expression::Identifier(_) if is_styled(callee)) } _ => false, } @@ -1365,7 +1376,11 @@ fn sets_plain_props(attr: &Expression<'_>) -> bool { fn spread_attrs<'a>(ast_builder: &AstBuilder<'a>, attrs: &[Expression<'a>]) -> Expression<'a> { let mut merged = identifier(ast_builder, ATTRS_PROPS); for attr in attrs { - merged = spread_objects(ast_builder, merged, attr.clone_in(ast_builder.allocator())); + merged = spread_objects( + ast_builder, + merged, + attr.clone_in_with_semantic_ids(ast_builder.allocator()), + ); } merged } @@ -1389,7 +1404,10 @@ fn merge_attrs<'a>(ast_builder: &AstBuilder<'a>, attrs: &[Expression<'a>]) -> Ex oxc_allocator::Vec::new_in(ast_builder), ast_builder, ), - resolve_attrs(ast_builder, attr.clone_in(ast_builder.allocator())), + resolve_attrs( + ast_builder, + attr.clone_in_with_semantic_ids(ast_builder.allocator()), + ), ); let merge = call_with_values( ast_builder, @@ -1439,7 +1457,7 @@ fn resolve_attrs<'a>(ast_builder: &AstBuilder<'a>, attr: Expression<'a>) -> Expr Expression::new_unary_expression( SPAN, UnaryOperator::Typeof, - attr.clone_in(ast_builder.allocator()), + attr.clone_in_with_semantic_ids(ast_builder.allocator()), ast_builder, ), BinaryOperator::StrictEquality, diff --git a/libs/extractor/src/extractor/extract_style_from_stylex.rs b/libs/extractor/src/extractor/extract_style_from_stylex.rs index fe63de62b..42bdc0f32 100644 --- a/libs/extractor/src/extractor/extract_style_from_stylex.rs +++ b/libs/extractor/src/extractor/extract_style_from_stylex.rs @@ -3,8 +3,9 @@ use crate::extract_style::extract_dynamic_style::ExtractDynamicStyle; use crate::extract_style::extract_static_style::ExtractStaticStyle; use crate::extract_style::extract_style_value::ExtractStyleValue; use crate::stylex::{ - DecomposedStyle, SelectorPart, StylexIncludeRef, decompose_value_conditions, - dynamic_number_suffix, is_include_call_static, normalize_stylex_property, stylex_value, + DecomposedStyle, SelectorPart, StylexIncludeRef, StylexResolver, decompose_value_conditions, + dynamic_number_suffix, is_first_that_works_call, is_include_call_static, is_types_call, + normalize_stylex_property, stylex_value, }; use css::optimize_value::optimize_value; use css::sheet_to_variable_name; @@ -46,6 +47,7 @@ pub fn extract_stylex_declarations( api: &str, object: &ObjectExpression<'_>, errors: &mut Vec<(u32, String)>, + resolver: StylexResolver<'_>, ) -> Vec<(String, String)> { let mut declarations = vec![]; for property in &object.properties { @@ -72,13 +74,33 @@ pub fn extract_stylex_declarations( Some(value) => declarations.push((name, optimize_value(&value).into_owned())), None => errors.push(( property.value.span().start, - runtime_value_error(api, &readable_code(&property.value)), + declaration_error(api, &property.value, resolver), )), } } declarations } +/// Why `value` is no declaration value: a genuine `StyleX` helper is read only +/// inside the API that takes it +fn declaration_error(api: &str, value: &Expression<'_>, resolver: StylexResolver<'_>) -> String { + let code = readable_code(value); + match value { + Expression::CallExpression(call) + if is_first_that_works_call(&call.callee, resolver) + || is_include_call_static(&call.callee, resolver) + || is_types_call(&call.callee, resolver) => + { + build_time_error( + api, + &code, + "its values must be literals; `firstThatWorks()`, `include()` and `types` are read only inside `stylex.create()` and `stylex.defineVars()`", + ) + } + _ => runtime_value_error(api, &code), + } +} + /// Resolve a `vars.key` member access against the contracts `stylex.defineVars()` /// produced, yielding the `var(--x)` reference the value compiles to. fn var_reference<'v>( @@ -137,6 +159,7 @@ pub fn extract_stylex_namespace_styles<'a>( keyframe_names: &FxHashMap, var_refs: &FxHashMap, errors: &mut Vec<(u32, String)>, + resolver: StylexResolver<'_>, ) -> Vec<( String, Vec>, @@ -188,7 +211,7 @@ pub fn extract_stylex_namespace_styles<'a>( } Expression::ObjectExpression(ns_obj) => { let (styles, include_refs, groups) = - extract_stylex_namespace(ns_obj, &leaf, errors); + extract_stylex_namespace(ns_obj, &leaf, errors, resolver); result.push((ns_name, styles, None, include_refs, groups)); } Expression::NullLiteral(_) => result.push((ns_name, vec![], None, vec![], vec![])), @@ -212,6 +235,7 @@ fn extract_stylex_namespace<'a>( namespace: &ObjectExpression<'_>, leaf: &Leaf<'_>, errors: &mut Vec<(u32, String)>, + resolver: StylexResolver<'_>, ) -> ( Vec>, Vec, @@ -224,7 +248,7 @@ fn extract_stylex_namespace<'a>( let style_prop = match style_prop { ObjectPropertyKind::ObjectProperty(style_prop) => style_prop, ObjectPropertyKind::SpreadProperty(spread) => { - match include(spread) { + match include(spread, resolver) { Some(Ok(include_ref)) => include_refs.push(include_ref), Some(Err(error)) => errors.push(error), None => errors.push(spread_error("stylex.create", spread)), @@ -280,6 +304,7 @@ fn extract_stylex_namespace<'a>( &parent_selectors, leaf, errors, + resolver, ), ); } @@ -303,7 +328,14 @@ fn extract_stylex_namespace<'a>( let before = styles.len(); push_decomposed( &mut styles, - decompose_value_conditions(&css_property, &style_prop.value, &[], leaf, errors), + decompose_value_conditions( + &css_property, + &style_prop.value, + &[], + leaf, + errors, + resolver, + ), ); groups.push((prop_name.to_string(), styles.len() - before)); } @@ -320,11 +352,14 @@ fn push_decomposed(styles: &mut Vec>, decomposed: Vec) -> Option> { +fn include( + spread: &SpreadElement<'_>, + resolver: StylexResolver<'_>, +) -> Option> { let Expression::CallExpression(call) = &spread.argument else { return None; }; - if !is_include_call_static(&call.callee) { + if !is_include_call_static(&call.callee, resolver) { return None; } if let Some(Expression::StaticMemberExpression(member)) = diff --git a/libs/extractor/src/fresh_name.rs b/libs/extractor/src/fresh_name.rs new file mode 100644 index 000000000..4ffa16d34 --- /dev/null +++ b/libs/extractor/src/fresh_name.rs @@ -0,0 +1,38 @@ +//! Source-safe names for generated instrumentation helpers. + +pub(crate) fn fresh_name(base: &str, source: &str) -> String { + let mut candidate = base.to_string(); + while source.contains(&candidate) { + candidate.push('_'); + } + candidate +} + +#[cfg(test)] +mod tests { + use super::fresh_name; + use rstest::rstest; + + #[rstest] + #[case("__devup_read_site_123__")] + #[case("__devup_operation_site_123__")] + fn chooses_unused_suffix_when_source_contains_collisions(#[case] base: &str) { + // Given: valid JavaScript with the base and two occupied suffixes. + let source = format!("const {base} = 1, {base}_ = 2, {base}__ = 3;"); + // When + let chosen = fresh_name(base, &source); + // Then + assert_eq!(chosen, format!("{base}___")); + assert!(!source.contains(&chosen)); + } + + #[test] + fn preserves_base_when_source_has_no_collision() { + // Given + let source = "const value = 1;"; + // When + let chosen = fresh_name("__helper__", source); + // Then + assert_eq!(chosen, "__helper__"); + } +} diff --git a/libs/extractor/src/import_alias_scoping_tests.rs b/libs/extractor/src/import_alias_scoping_tests.rs new file mode 100644 index 000000000..fae3e4d79 --- /dev/null +++ b/libs/extractor/src/import_alias_scoping_tests.rs @@ -0,0 +1,253 @@ +use super::*; + +fn aliases() -> HashMap { + HashMap::from([ + (EMOTION_REACT.into(), ImportAlias::NamedToNamed), + ( + "@emotion/styled".into(), + ImportAlias::DefaultToNamed("styled".into()), + ), + ( + "styled-components".into(), + ImportAlias::DefaultToNamed("styled".into()), + ), + ("@vanilla-extract/css".into(), ImportAlias::NamedToNamed), + ]) +} + +#[test] +fn numbers_stay_numeric_when_api_bindings_are_shadowed() { + for (import, name, call) in [ + ( + "import { css } from '@emotion/react';", + "css", + "css({ top: 2 })", + ), + ( + "import { keyframes } from 'styled-components';", + "keyframes", + "keyframes({ from: { top: 2 } })", + ), + ( + "import { style as s } from '@vanilla-extract/css';", + "s", + "s({ top: 2 })", + ), + ( + "import { globalStyle } from '@vanilla-extract/css';", + "globalStyle", + "globalStyle('body', { top: 2 })", + ), + ( + "import styled from '@emotion/styled';", + "styled", + "styled.div({ top: 2 })", + ), + ( + "import * as styled from 'styled-components';", + "styled", + "styled('div').attrs({})({ top: 2 })", + ), + ] { + let code = format!("{import}\n{call}; function local({name}) {{ {call}; }}"); + let output = transform_import_aliases(&code, "test.tsx", "@devup-ui/react", &aliases()); + assert!( + output.contains(&format!("function local({name}) {{ {call}; }}")), + "{output}" + ); + assert!(output.contains("top: \"2px\""), "{output}"); + } +} + +#[test] +fn global_styles_stay_numeric_when_component_binding_is_shadowed() { + let code = "import { Global as G } from '@emotion/react';\nconst real = ; function local(G) { return ; }"; + let output = transform_import_aliases(code, "test.tsx", "@devup-ui/react", &aliases()); + assert!(output.contains("const real = ")); + assert!(output.contains("function local(G) { return ; }")); +} + +#[test] +fn jsx_props_stay_numeric_when_factory_binding_is_shadowed() { + for import in [ + "import { jsx as h } from '@emotion/react';", + "import { jsx as h } from 'react/jsx-runtime';", + ] { + let code = format!( + "{import}\nconst real = h('div', {{ css: {{ top: 2 }} }}); function local(h) {{ return h('div', {{ css: {{ top: 2 }} }}); }}" + ); + let output = transform_import_aliases(&code, "test.tsx", "@devup-ui/react", &aliases()); + assert!(output.contains("const real = h('div', { css: { top: \"2px\" } })")); + assert!(output.contains("function local(h) { return h('div', { css: { top: 2 } }); }")); + } +} + +#[test] +fn devup_css_props_stay_numeric_when_component_root_is_shadowed() { + for (import, root, tag, ty) in [ + ("import { Box as B } from '@devup-ui/react';", "B", "B", "B"), + ( + "import * as UI from '@devup-ui/react';", + "UI", + "UI.Box", + "UI.Box", + ), + ] { + let code = format!( + "{import}\nimport {{ jsx as h }} from 'react/jsx-runtime';\nconst real = <{tag} css={{{{ top: 2 }}}} />; const made = h({ty}, {{ css: {{ top: 2 }} }}); function local({root}) {{ return [<{tag} css={{{{ top: 2 }}}} />, h(({ty}), {{ css: {{ top: 2 }} }})]; }}" + ); + let output = transform_import_aliases(&code, "test.tsx", "@devup-ui/react", &aliases()); + assert!(output.contains(&format!("function local({root}) {{ return [<{tag} css={{{{ top: 2 }}}} />, h(({ty}), {{ css: {{ top: 2 }} }})]; }}")), "{output}"); + assert_eq!(output.matches("\"2px\"").count(), 2, "{output}"); + } +} + +#[test] +fn class_names_css_uses_child_binding_identity_when_nested_locals_shadow_it() { + for (child, pixels) in [ + ( + "({ cx, css: rule }) => <>{rule({ top: 2 })}{((rule) => rule({ top: 3 }))(other)}{({ css: rule }) => rule({ top: 4 })}{rule({ top: 5 })}", + 3, + ), + ( + "function ({ css: rule }) { return <>{rule({ top: 2 })}{((rule) => rule({ top: 3 }))(other)}; }", + 1, + ), + ] { + let code = format!( + "import {{ ClassNames as C }} from '@emotion/react';\nconst real = {{{child}}}; function local(C) {{ return {{({{ css: rule }}) => rule({{ top: 6 }})}}; }}" + ); + let output = transform_import_aliases(&code, "test.tsx", "@devup-ui/react", &aliases()); + assert!(output.contains("rule({ top: \"2px\" })"), "{output}"); + assert!(output.contains("rule({ top: 3 })"), "{output}"); + assert!(output.contains("rule({ top: 6 })"), "{output}"); + assert_eq!(output.matches("px\"").count(), pixels, "{output}"); + } +} + +#[test] +fn type_only_bindings_do_not_register_number_apis() { + for import in [ + "import type { css, Global, ClassNames, jsx } from '@emotion/react';", + "import { type css, type Global, type ClassNames, type jsx } from '@emotion/react';", + "import type styled from '@emotion/styled';", + "import type * as styled from 'styled-components';", + ] { + let body = "css({ top: 2 }); styled.div({ top: 2 }); jsx('div', { css: { top: 2 } }); const a = ; const b = {({ css }) => css({ top: 2 })};"; + let code = format!("{import}\n{body}"); + let output = transform_import_aliases(&code, "test.tsx", "@devup-ui/react", &aliases()); + assert!(output.ends_with(body), "{output}"); + } +} + +#[test] +fn named_styled_binding_pixelifies_chains_without_touching_block_locals() { + let code = "import { styled as s } from 'styled-components';\nconst real = s('div').withConfig({}).attrs({})({ top: 2 }); { const s = other; s.div({ top: 3 }); }"; + let output = transform_import_aliases(code, "test.tsx", "@devup-ui/react", &aliases()); + assert!(output.contains("attrs({})({ top: \"2px\" })")); + assert!(output.ends_with("{ const s = other; s.div({ top: 3 }); }")); +} + +#[test] +fn malformed_class_names_children_do_not_register_css_calls() { + for child in [ + "text", + "{other}", + "{2}", + "{'2'}", + "{({ css }) => css({ top: 2 })}{other}", + "{() => css({ top: 2 })}", + "{(rules) => css({ top: 2 })}", + "{({ cx }) => css({ top: 2 })}", + "{({ css, unknown }) => css({ top: 2 })}", + "{({ css = other }) => css({ top: 2 })}", + ] { + let code = format!( + "import {{ ClassNames as C }} from '@emotion/react';\nconst view = {child};" + ); + let output = transform_import_aliases(&code, "test.tsx", "@devup-ui/react", &aliases()); + assert!( + output.ends_with(&format!("const view = {child};")), + "{output}" + ); + } +} + +#[test] +fn type_only_devup_components_do_not_take_css_props() { + for import in [ + "import type { Box as B } from '@devup-ui/react';", + "import { type Box as B } from '@devup-ui/react';", + ] { + let code = format!("{import}\nconst view = ;"); + let output = transform_import_aliases(&code, "test.tsx", "@devup-ui/react", &aliases()); + assert_eq!(output, code); + } +} + +#[test] +fn number_edits_map_back_to_original_offsets_when_imports_are_rewritten() { + let code = "import { css as c } from '@emotion/react';\nc({ top: 23 }); function local(c) { c({ top: 24 }); }"; + let output = + transform_import_aliases_with_edits(code, "test.tsx", "@devup-ui/react", &aliases()); + let Some(pixel) = output.code.find("\"23px\"") else { + panic!("missing pixelified number: {}", output.code); + }; + let Some(original_pixel) = code.find("23") else { + panic!("missing original number"); + }; + assert_eq!(source_offset(&output.edits, pixel + 3), original_pixel); + let Some(local) = output.code.find("24") else { + panic!("missing local number: {}", output.code); + }; + let Some(original_local) = code.find("24") else { + panic!("missing original local number"); + }; + assert_eq!(source_offset(&output.edits, local), original_local); +} + +#[test] +fn devup_root_guards_follow_nested_members_but_not_other_element_types() { + let code = "import * as UI from '@devup-ui/react'; import { jsx as h } from 'react/jsx-runtime'; const real = [, h(UI['Box'], { css: { top: 2 } })]; const other = [, h(other(), { css: { top: 3 } })]; function local(UI) { return h(UI['Box'], { css: { top: 3 } }); }"; + let output = transform_import_aliases(code, "test.tsx", "@devup-ui/react", &aliases()); + assert_eq!(output.matches("\"2px\"").count(), 2, "{output}"); + assert_eq!(output.matches("top: 3").count(), 3, "{output}"); +} + +#[test] +fn inline_types_keep_modifiers_when_other_specifiers_are_retained() { + let code = "import { styleVariants, type style as Rule, style as s, type keyframes } from '@vanilla-extract/css';\ns({ top: 2 }); Rule({ top: 3 }); keyframes({ from: { top: 4 } });"; + let output = transform_import_aliases(code, "test.tsx", "@devup-ui/react", &aliases()); + assert_eq!( + output, + "import { css as s } from '@devup-ui/react'; import { styleVariants, type style as Rule, type keyframes } from '@vanilla-extract/css';\ns({ top: \"2px\" }); Rule({ top: 3 }); keyframes({ from: { top: 4 } });" + ); +} + +#[test] +fn nested_member_factory_css_uses_the_imported_namespace_binding() { + let code = "import * as UI from '@devup-ui/react'; import { jsx as h } from 'react/jsx-runtime';\nconst real = h((UI.parts.Box), { css: { top: 2 } }); function local(UI) { return h((UI.parts.Box), { css: { top: 3 } }); }"; + let output = transform_import_aliases(code, "test.tsx", "@devup-ui/react", &aliases()); + assert_eq!(output, code.replace("top: 2", "top: \"2px\"")); +} + +#[test] +fn global_styles_rewrite_conditional_rules_without_rewriting_shadowed_styles() { + let code = "import { Global as G } from '@emotion/react';\nconst real = ; function local(G) { return ; }"; + let output = transform_import_aliases(code, "test.tsx", "@devup-ui/react", &aliases()); + assert!(output.contains("styles={flag ? { top: \"2px\" } : { left: \"3px\" }} css={flag ? { right: \"4px\" } : { bottom: \"5px\" }}"), "{output}"); + assert!( + output.ends_with( + "function local(G) { return ; }" + ), + "{output}" + ); +} + +#[test] +fn class_names_css_binding_is_found_after_whitespace_children() { + let code = "import { ClassNames as C } from '@emotion/react';\nconst view = \n {({ css }) => css({ top: 2 })}\n;"; + let output = transform_import_aliases(code, "test.tsx", "@devup-ui/react", &aliases()); + assert!(output.contains("css({ top: \"2px\" })"), "{output}"); + assert!(!output.contains("css({ top: 2 })"), "{output}"); +} diff --git a/libs/extractor/src/import_alias_visit.rs b/libs/extractor/src/import_alias_visit.rs index 117d84d50..1810c6520 100644 --- a/libs/extractor/src/import_alias_visit.rs +++ b/libs/extractor/src/import_alias_visit.rs @@ -10,25 +10,28 @@ use crate::ImportAlias; use crate::css_prop::{ CssProp, EMOTION_REACT, builds_jsx_with_emotion, class_names_child, is_emotion, is_jsx_file, - is_jsx_function, react_runtime, returned, + is_jsx_function, react_runtime, returned, root_reference, }; use crate::utils::{ get_str_by_property_key, is_vanilla_extract_file, js_number_literal, keeps_bare_number, }; use oxc_allocator::Allocator; use oxc_ast::ast::{ - Argument, ArrowFunctionBody, CallExpression, Expression, ImportDeclarationSpecifier, - JSXAttributeItem, JSXAttributeValue, JSXElement, JSXElementName, JSXOpeningElement, - LogicalOperator, ModuleExportName, ObjectPropertyKind, Statement, + Argument, ArrowFunctionBody, BindingIdentifier, CallExpression, Expression, + IdentifierReference, ImportDeclarationSpecifier, JSXAttributeItem, JSXAttributeValue, + JSXElement, JSXElementName, JSXOpeningElement, LogicalOperator, ModuleExportName, + ObjectPropertyKind, Statement, }; use oxc_ast_visit::{ Visit, walk::{walk_call_expression, walk_jsx_element, walk_jsx_opening_element}, }; use oxc_parser::Parser; +use oxc_semantic::{Scoping, SemanticBuilder}; use oxc_span::GetSpan; #[cfg(test)] use oxc_span::SourceType; +use oxc_syntax::symbol::SymbolId; use std::borrow::Cow; use std::collections::HashMap; @@ -43,31 +46,42 @@ enum RulesAt { /// outside a stylesheet. Those calls become Devup UI's, which read a number as its /// spacing scale, so it is rewritten to the `px` string the library makes of it. #[derive(Default)] -struct LibraryNumbers<'n> { - /// Local name of each style function and where its rules are - calls: Vec<(&'n str, RulesAt)>, - /// Local names that build styled components - styled: Vec<&'n str>, - /// Local names of components taking rules in `styles` (Emotion's `Global`) - components: Vec<&'n str>, +struct LibraryNumbers { + /// Import binding of each style function and where its rules are + calls: Vec<(SymbolId, RulesAt)>, + /// Import bindings that build styled components + styled: Vec, + /// Import bindings taking rules in `styles` (Emotion's `Global`) + components: Vec, /// Which elements take the `css` prop, whose rules are Emotion's css_prop: CssProp, - /// Local names of Devup UI's exports, which take the `css` prop as tags do - devup: Vec<&'n str>, - /// Local names of the functions building elements from a type and props - jsx: Vec<&'n str>, - /// Local names of Emotion's `ClassNames`, whose child function takes a + /// Import bindings of Devup UI's exports, which take the `css` prop as tags do + devup: Vec, + /// Import bindings of the functions building elements from a type and props + jsx: Vec, + /// Import bindings of Emotion's `ClassNames`, whose child function takes a /// `css` taking rules - class_names: Vec<&'n str>, - class_names_calls: Vec, - scoping: Option, + class_names: Vec, + class_names_calls: Vec, + scoping: Scoping, replacements: Vec<(usize, usize, String)>, /// Where a constant inlined later is a number or rules of the library, as /// the source reads them marks: Vec<(usize, usize)>, } -impl LibraryNumbers<'_> { +impl LibraryNumbers { + fn symbol(&self, reference: &IdentifierReference<'_>) -> Option { + self.scoping + .get_reference(reference.reference_id.get()?) + .symbol_id() + } + + fn contains(&self, symbols: &[SymbolId], reference: &IdentifierReference<'_>) -> bool { + self.symbol(reference) + .is_some_and(|symbol| symbols.contains(&symbol)) + } + fn mark(expression: &Expression) -> (usize, usize) { let span = expression.span(); (span.start as usize, span.end as usize) @@ -78,10 +92,10 @@ impl LibraryNumbers<'_> { fn is_styled_factory(&self, callee: &Expression) -> bool { match callee { Expression::StaticMemberExpression(member) => { - matches!(&member.object, Expression::Identifier(root) if self.styled.contains(&root.name.as_str())) + matches!(&member.object, Expression::Identifier(root) if self.contains(&self.styled, root)) } Expression::CallExpression(call) => match &call.callee { - Expression::Identifier(root) => self.styled.contains(&root.name.as_str()), + Expression::Identifier(root) => self.contains(&self.styled, root), Expression::StaticMemberExpression(member) => { matches!(member.property.name.as_str(), "attrs" | "withConfig") && self.is_styled_factory(&member.object) @@ -226,7 +240,7 @@ impl LibraryNumbers<'_> { } } -impl LibraryNumbers<'_> { +impl LibraryNumbers { /// `key: [a, b]` in these libraries declares `key` once per value, later /// ones as fallbacks a browser keeps when it understands them, where Devup /// UI reads an array as breakpoints. Literal values become one value @@ -287,17 +301,10 @@ fn kebab_property(key: &str) -> String { property } -impl<'a> Visit<'a> for LibraryNumbers<'a> { +impl<'a> Visit<'a> for LibraryNumbers { fn visit_jsx_element(&mut self, element: &JSXElement<'a>) { let css = match &element.opening_element.name { - JSXElementName::IdentifierReference(name) - if self.class_names.contains(&name.name.as_str()) - && self.scoping.as_ref().is_some_and(|scoping| { - crate::css_prop::binding_of(scoping, name).is_some_and(|symbol| { - scoping.get_root_binding(name.name.as_str().into()) == Some(symbol) - }) - }) => - { + JSXElementName::IdentifierReference(name) if self.contains(&self.class_names, name) => { class_names_child(element).and_then(|names| names.css) } _ => None, @@ -313,27 +320,16 @@ impl<'a> Visit<'a> for LibraryNumbers<'a> { fn visit_call_expression(&mut self, call: &CallExpression<'a>) { let rules_at = match &call.callee { - Expression::Identifier(callee) => { - let symbol = self - .scoping - .as_ref() - .and_then(|scoping| crate::css_prop::binding_of(scoping, callee)); - if symbol.is_some_and(|symbol| self.class_names_calls.contains(&symbol)) { + Expression::Identifier(callee) => self.symbol(callee).and_then(|symbol| { + if self.class_names_calls.contains(&symbol) { Some(RulesAt::EveryArgument) } else { self.calls .iter() - .find(|(name, _)| { - *name == callee.name.as_str() - && self.scoping.as_ref().is_none_or(|scoping| { - symbol.is_some() - && scoping.get_root_binding(callee.name.as_str().into()) - == symbol - }) - }) + .find(|(binding, _)| *binding == symbol) .map(|(_, rules_at)| *rules_at) } - } + }), callee => self .is_styled_factory(callee) .then_some(RulesAt::EveryArgument), @@ -356,12 +352,13 @@ impl<'a> Visit<'a> for LibraryNumbers<'a> { self.pixelify(rules); } if let Expression::Identifier(callee) = &call.callee - && self.jsx.contains(&callee.name.as_str()) + && self.contains(&self.jsx, callee) && let [element, Argument::ObjectExpression(props), ..] = call.arguments.as_slice() && let Some(element) = element.as_expression() - && self - .css_prop - .takes_type(element, |root| self.devup.contains(&root)) + && self.css_prop.takes_type(element, |_| { + root_reference(crate::utils::unwrap_syntax_only(element)) + .is_some_and(|root| self.contains(&self.devup, root)) + }) { for property in &props.properties { if let ObjectPropertyKind::ObjectProperty(property) = property @@ -377,14 +374,14 @@ impl<'a> Visit<'a> for LibraryNumbers<'a> { fn visit_jsx_opening_element(&mut self, element: &JSXOpeningElement<'a>) { if let JSXElementName::IdentifierReference(name) = &element.name - && self.components.contains(&name.name.as_str()) + && self.contains(&self.components, name) { self.pixelify_attribute(element, "styles"); } - if self - .css_prop - .takes(&element.name, |root| self.devup.contains(&root)) - { + if self.css_prop.takes(&element.name, |_| { + crate::imported_constants::jsx_root_identifier(&element.name) + .is_some_and(|root| self.contains(&self.devup, root)) + }) { self.pixelify_attribute(element, "css"); } walk_jsx_opening_element(self, element); @@ -558,6 +555,18 @@ pub fn transform_import_aliases_with_edits<'a>( // Collect import transformations let mut transformations: Vec<(usize, usize, String)> = Vec::new(); + let mut scoping = None; + let may_take_css = CssProp::of(import_aliases, code, false) != CssProp::Off; + let mut symbol = |local: &BindingIdentifier<'_>| { + scoping.get_or_insert_with(|| { + SemanticBuilder::new() + .build(&program) + .semantic + .into_scoping() + }); + local.symbol_id.get() + }; + if emotion_aliased { transformations.extend(crate::jsx_import_source::emotion_replacements(&program)); } @@ -573,21 +582,33 @@ pub fn transform_import_aliases_with_edits<'a>( for stmt in &program.body { if let Statement::ImportDeclaration(import_decl) = stmt { + if import_decl.import_kind.is_type() { + continue; + } let source_value = import_decl.source.value.as_str(); - uses_emotion |= is_emotion(source_value); + uses_emotion |= is_emotion(source_value) + && import_decl.specifiers.as_ref().is_none_or(|specifiers| { + specifiers.iter().any(|specifier| { + !matches!(specifier, ImportDeclarationSpecifier::ImportSpecifier(spec) if spec.import_kind.is_type()) + }) + }); for specifier in import_decl.specifiers.iter().flatten() { + if matches!(specifier, ImportDeclarationSpecifier::ImportSpecifier(spec) if spec.import_kind.is_type()) + { + continue; + } match specifier { ImportDeclarationSpecifier::ImportSpecifier(spec) if is_jsx_function( source_value, imported_name(&spec.imported), &compat, - ) => + ) && may_take_css => { - numbers.jsx.push(spec.local.name.as_str()); + numbers.jsx.extend(symbol(&spec.local)); } - specifier if source_value == package => { - numbers.devup.push(specifier.local().name.as_str()); + specifier if source_value == package && may_take_css => { + numbers.devup.extend(symbol(specifier.local())); } _ => {} } @@ -609,25 +630,40 @@ pub fn transform_import_aliases_with_edits<'a>( if !redirect_every_name { for specifier in import_decl.specifiers.iter().flatten() { match specifier { - ImportDeclarationSpecifier::ImportSpecifier(spec) => { - let local = spec.local.name.as_str(); + ImportDeclarationSpecifier::ImportSpecifier(spec) + if !spec.import_kind.is_type() => + { match (source_value, imported_name(&spec.imported)) { ("@vanilla-extract/css", "style" | "keyframes") => { - numbers.calls.push((local, RulesAt::Argument(0))); + numbers.calls.extend( + symbol(&spec.local) + .map(|local| (local, RulesAt::Argument(0))), + ); } ("@vanilla-extract/css", "globalStyle") => { - numbers.calls.push((local, RulesAt::Argument(1))); + numbers.calls.extend( + symbol(&spec.local) + .map(|local| (local, RulesAt::Argument(1))), + ); } ( "@emotion/react" | "styled-components", "css" | "keyframes", ) | ("@emotion/css", "css" | "keyframes" | "injectGlobal") => { - numbers.calls.push((local, RulesAt::EveryArgument)); + numbers.calls.extend( + symbol(&spec.local) + .map(|local| (local, RulesAt::EveryArgument)), + ); + } + ("@emotion/react", "Global") => { + numbers.components.extend(symbol(&spec.local)); } - ("@emotion/react", "Global") => numbers.components.push(local), ("@emotion/react", "ClassNames") => { - numbers.class_names.push(local); + numbers.class_names.extend(symbol(&spec.local)); + } + ("@emotion/styled" | "styled-components", "styled") => { + numbers.styled.extend(symbol(&spec.local)); } _ => {} } @@ -638,7 +674,7 @@ pub fn transform_import_aliases_with_edits<'a>( "@emotion/styled" | "styled-components" ) => { - numbers.styled.push(spec.local.name.as_str()); + numbers.styled.extend(symbol(&spec.local)); } ImportDeclarationSpecifier::ImportNamespaceSpecifier(spec) if matches!( @@ -646,7 +682,7 @@ pub fn transform_import_aliases_with_edits<'a>( "@emotion/styled" | "styled-components" ) => { - numbers.styled.push(spec.local.name.as_str()); + numbers.styled.extend(symbol(&spec.local)); } _ => {} } @@ -655,17 +691,8 @@ pub fn transform_import_aliases_with_edits<'a>( } } } + numbers.scoping = scoping.unwrap_or_default(); numbers.css_prop = CssProp::of(import_aliases, code, uses_emotion); - if !numbers.class_names.is_empty() - && (code.bytes().any(|byte| byte.is_ascii_digit()) || code.contains('[')) - { - numbers.scoping = Some( - oxc_semantic::SemanticBuilder::new() - .build(&program) - .semantic - .into_scoping(), - ); - } if !(numbers.calls.is_empty() && numbers.styled.is_empty() && numbers.components.is_empty() @@ -730,26 +757,33 @@ pub(super) fn pixelify_emotion_values<'a>( let original_program = Parser::new(&allocator, original, source_type) .parse() .program; - let mut numbers = LibraryNumbers::default(); + let program = Parser::new(&allocator, code, source_type).parse().program; + let mut numbers = LibraryNumbers { + scoping: SemanticBuilder::new() + .build(&program) + .semantic + .into_scoping(), + ..LibraryNumbers::default() + }; for statement in &original_program.body { if let Statement::ImportDeclaration(import) = statement && import.source.value == "@emotion/css" + && !import.import_kind.is_type() { for specifier in import.specifiers.iter().flatten() { if let ImportDeclarationSpecifier::ImportSpecifier(named) = specifier + && !named.import_kind.is_type() && matches!( named.imported.name().as_str(), "css" | "keyframes" | "injectGlobal" ) + && let Some(symbol) = numbers.scoping.get_root_binding(named.local.name) { - numbers - .calls - .push((named.local.name.as_str(), RulesAt::EveryArgument)); + numbers.calls.push((symbol, RulesAt::EveryArgument)); } } } } - let program = Parser::new(&allocator, code, source_type).parse().program; numbers.visit_program(&program); numbers.replacements.sort_by_key(|(start, ..)| *start); let edits = numbers @@ -941,6 +975,11 @@ fn generate_transformed_import( } } + let retains_values = retained_default.is_some() || specifiers.iter().any(|specifier| { + matches!(specifier, ImportDeclarationSpecifier::ImportSpecifier(spec) + if !spec.import_kind.is_type() + && redirect_target(source, imported_name(&spec.imported), redirect_every_name).is_none()) + }); // Handle named specifiers (kept as-is for both variants) for specifier in specifiers { if let ImportDeclarationSpecifier::ImportSpecifier(spec) = specifier { @@ -949,7 +988,17 @@ fn generate_transformed_import( // Types are erased, so they stay on the package without making it a // runtime dependency if spec.import_kind.is_type() { - push_specifier(&mut types, &written, local); + if retains_values { + if !retained.is_empty() { + retained.push_str(", "); + } + retained.push_str("type "); + let mut named = String::new(); + push_specifier(&mut named, &written, local); + retained.push_str(&named); + } else { + push_specifier(&mut types, &written, local); + } continue; } // A stylesheet passes every export through as written @@ -1019,6 +1068,10 @@ fn generate_transformed_import( result } +#[cfg(test)] +#[path = "import_alias_scoping_tests.rs"] +mod scoping_tests; + #[cfg(test)] mod tests { use super::*; diff --git a/libs/extractor/src/imported_constants.rs b/libs/extractor/src/imported_constants.rs index 5c69a2041..79b2a1575 100644 --- a/libs/extractor/src/imported_constants.rs +++ b/libs/extractor/src/imported_constants.rs @@ -2,14 +2,15 @@ //! module declares with `const` becomes a static class instead of a CSS //! variable set at runtime. -use std::cell::Cell; +use std::cell::{Cell, OnceCell}; use std::collections::BTreeSet; use std::rc::Rc; use oxc_allocator::{Allocator, FromIn, GetAllocator}; use oxc_ast::ast::{ - Argument, ArrayExpressionElement, Expression, ImportDeclarationSpecifier, JSXAttributeItem, - JSXElementName, ObjectPropertyKind, Program, Statement, Str, VariableDeclarationKind, + Argument, ArrayExpressionElement, Expression, IdentifierReference, ImportDeclarationSpecifier, + JSXAttributeItem, JSXElementName, ObjectPropertyKind, Program, Statement, Str, + VariableDeclarationKind, }; use oxc_ast::builder::AstBuilder; use oxc_ast_visit::{Visit, VisitMut, walk, walk_mut}; @@ -22,7 +23,7 @@ use oxc_syntax::symbol::SymbolId; use rustc_hash::{FxHashMap, FxHashSet}; use crate::composition::{Composition, set_prop_order}; -use crate::css_prop::{CssProp, CssTakers, binding_of}; +use crate::css_prop::{CssProp, CssTakers, binding_of, reads_top_level, root_reference}; use crate::extractor::ExtractResult; use crate::extractor::extract_style_from_expression::{ LiteralHandling, extract_style_from_expression, @@ -218,6 +219,9 @@ pub(crate) struct Inlined { pub css_failures: Vec, pub unknown: Unknown, pub changed: Changed, + /// The semantic analysis of the program as parsed, so the visitor reuses + /// it: constants inlined later leave the ids of surviving references as + /// they are. `None` when no analysis was needed pub scoping: Option>, } @@ -235,11 +239,17 @@ impl Changed { } /// Whether `expression` (`x`, `x.y` or `x[y]`) reads, whole or in part, an - /// object or array code changes - pub(crate) fn read_by(&self, expression: &Expression<'_>) -> bool { + /// object or array code changes, counting only the identifiers + /// `reads_binding` accepts, so a local named like a binding of the module + /// does not + pub(crate) fn read_by_in( + &self, + expression: &Expression<'_>, + reads_binding: &dyn Fn(&IdentifierReference<'_>) -> bool, + ) -> bool { let mut path = Vec::new(); let mut expression = expression; - let name = loop { + let identifier = loop { match expression { Expression::StaticMemberExpression(member) => { path.push(Some(member.property.name.as_str())); @@ -249,10 +259,14 @@ impl Changed { path.push(None); expression = &member.object; } - Expression::Identifier(identifier) => break identifier.name.as_str(), + Expression::Identifier(identifier) => break identifier, _ => return false, } }; + let name = identifier.name.as_str(); + if !reads_binding(identifier) { + return false; + } if self.whole.contains_key(name) { return true; } @@ -315,6 +329,16 @@ impl Unknown { /// Whether `expression` (`x`, `x.y.z`, `x[y]` or a call of one) reads what /// only running the module gives pub(crate) fn read_by(&self, expression: &Expression<'_>) -> bool { + self.read_by_in(expression, &|_| true) + } + + /// [`Self::read_by`] counting only the identifiers `reads_binding` + /// accepts, so a local named like a binding of the module does not + pub(crate) fn read_by_in( + &self, + expression: &Expression<'_>, + reads_binding: &dyn Fn(&IdentifierReference<'_>) -> bool, + ) -> bool { let mut path = Vec::new(); let mut expression = expression; loop { @@ -324,12 +348,19 @@ impl Unknown { expression = &member.object; } Expression::ComputedMemberExpression(member) => { - return crate::utils::binding_root(&member.object).is_some_and(|name| { - self.names.contains(name) || self.partial.contains_key(name) + return root_reference(&member.object).is_some_and(|identifier| { + reads_binding(identifier) + && (self.names.contains(identifier.name.as_str()) + || self.partial.contains_key(identifier.name.as_str())) }); } - Expression::CallExpression(call) => return self.read_by(&call.callee), + Expression::CallExpression(call) => { + return self.read_by_in(&call.callee, reads_binding); + } Expression::Identifier(identifier) => { + if !reads_binding(identifier) { + return false; + } let name = identifier.name.as_str(); if self.names.contains(name) { return true; @@ -368,7 +399,9 @@ enum Imported { } /// Inline the primitive constants `program` reads in styles, its own -/// module-level `const`s and those it imports, returning the files read +/// module-level `const`s and those it imports, returning the files read. +/// A program with no style import and no `css` prop is left as it is, with no +/// semantic analysis. pub(crate) fn inline_constants<'a>( ast_builder: &AstBuilder<'a>, program: &mut Program<'a>, @@ -378,66 +411,89 @@ pub(crate) fn inline_constants<'a>( css_prop: CssProp, px: &crate::import_alias_visit::PxMarks, ) -> Inlined { - let is_style_package = - |source: &str| source.starts_with(&option.package) || source == crate::STYLEX_PACKAGE; - let mut style_roots = FxHashSet::default(); - let mut apis = StyleApis::default(); + let imports_style_package = program.body.iter().any(|statement| { + matches!(statement, Statement::ImportDeclaration(import) + if is_style_package(option, &import.source.value)) + }); + if !imports_style_package && css_prop == CssProp::Off { + return Inlined::default(); + } + let scoping = Rc::new( + SemanticBuilder::new() + .build(program) + .semantic + .into_scoping(), + ); + let mut inlined = inline_in( + &scoping, + ast_builder, + program, + filename, + option, + resolver, + (css_prop, px), + ); + inlined.scoping = Some(scoping); + inlined +} + +fn is_style_package(option: &ExtractOption, source: &str) -> bool { + crate::package_specifier::is_package(source, &option.package) || source == crate::STYLEX_PACKAGE +} + +fn inline_in<'a>( + scoping: &Scoping, + ast_builder: &AstBuilder<'a>, + program: &mut Program<'a>, + filename: &str, + option: &ExtractOption, + resolver: Option<&ModuleResolver>, + css_input: (CssProp, &crate::import_alias_visit::PxMarks), +) -> Inlined { + let (css_prop, px) = css_input; let compat = format!("{}/compat", option.package); - let css_props = CssTakers::new(program, css_prop, &compat); + let css_props = CssTakers::new(program, scoping, css_prop, &compat); + let mut style = StyleSymbols::new(scoping); for statement in &program.body { if let Statement::ImportDeclaration(import) = statement - && is_style_package(&import.source.value) + && is_style_package(option, &import.source.value) { let stylex = import.source.value == crate::STYLEX_PACKAGE; for specifier in import.specifiers.iter().flatten() { - let local = specifier.local().name.as_str(); if matches!(specifier, ImportDeclarationSpecifier::ImportSpecifier(specifier) if specifier.imported.name() == "ClassNames" && import.source.value == compat.as_str()) { continue; } - style_roots.insert(local); - match specifier { - ImportDeclarationSpecifier::ImportSpecifier(specifier) => { - if !stylex && specifier.imported.name() == "css" { - apis.css.insert(local); + if let Some(local) = specifier.local().symbol_id.get() { + style.roots.insert(local); + match specifier { + ImportDeclarationSpecifier::ImportSpecifier(specifier) => { + if !stylex && specifier.imported.name() == "css" { + style.css.insert(local); + } + if takes_style_objects(stylex, &specifier.imported.name()) { + style.functions.insert(local); + } } - if takes_style_objects(stylex, &specifier.imported.name()) { - apis.functions.insert(local); + _ => { + style.namespaces.insert(local, stylex); } } - _ => { - apis.namespaces.insert(local, stylex); - } } } } } - if style_roots.is_empty() && css_prop == CssProp::Off && !css_props.has_class_names() { - return Inlined::default(); - } - let mut shared_scoping = css_props.has_class_names().then(|| { - Rc::new( - SemanticBuilder::new() - .build(program) - .semantic - .into_scoping(), - ) - }); let mut read = StyleReads { - style_roots: &style_roots, + style: &style, css_props: &css_props, names: FxHashSet::default(), depth: 0, class_names: Vec::new(), - scoping: shared_scoping.as_deref(), }; read.visit_program(program); if read.names.is_empty() { - return Inlined { - scoping: shared_scoping, - ..Inlined::default() - }; + return Inlined::default(); } let names = read.names; let mut modules = Modules { @@ -448,17 +504,21 @@ pub(crate) fn inline_constants<'a>( }; let mut symbols: FxHashMap = FxHashMap::default(); let mut inlined = Inlined::default(); - let (scoping, reads_math) = { + let reads_math = { let mut scope = ModuleScope::new(filename, program, None); - scope - .style_names - .extend(style_roots.iter().map(ToString::to_string)); - scope.css_props = Some(&css_props); + scope.style_names.extend( + style + .roots + .iter() + .map(|symbol| scoping.symbol_name(*symbol).to_string()), + ); + scope.css_prop = Some((css_prop, &compat)); + scope.shared_scoping = Some(scoping); let mut bindings: FxHashMap<&str, Vec<&Cell>>> = FxHashMap::default(); for statement in &program.body { let declaration = match statement { Statement::ImportDeclaration(import) => { - if !is_style_package(&import.source.value) { + if !is_style_package(option, &import.source.value) { scope.import(import); for specifier in import.specifiers.iter().flatten() { let local = specifier.local(); @@ -512,25 +572,12 @@ pub(crate) fn inline_constants<'a>( } } } - // Scoping is only worth building when a style reads a name that may - // hold a constant + // Constants are only worth reading when a style reads a name that may + // hold one let reads_math = names.contains("Math") && !scope.binds("Math"); if !reads_math && !names.iter().any(|name| scope.binds(name)) { - return Inlined { - scoping: shared_scoping, - ..Inlined::default() - }; + return Inlined::default(); } - let scoping = shared_scoping - .get_or_insert_with(|| { - Rc::new( - SemanticBuilder::new() - .build(program) - .semantic - .into_scoping(), - ) - }) - .clone(); for name in &names { let bound = scope.binds(name); let constant = scope.lookup(&mut modules, name); @@ -582,17 +629,16 @@ pub(crate) fn inline_constants<'a>( symbols.extend(symbol.get().map(|symbol| (symbol, constant.clone()))); } } - (scoping, reads_math) + reads_math }; inlined.dependencies = modules.exports.into_keys().collect(); if !symbols.is_empty() || reads_math { Inline { ast_builder, - scoping: &scoping, + scoping, symbols: &symbols, - style_roots: &style_roots, + style: &style, css_props: &css_props, - apis: &apis, css_failures: &mut inlined.css_failures, unknown: &mut inlined.unknown, objects: false, @@ -604,9 +650,6 @@ pub(crate) fn inline_constants<'a>( } .visit_program(program); } - if css_props.has_class_names() { - inlined.scoping = shared_scoping; - } inlined } @@ -682,13 +725,18 @@ impl<'p, 'a, 'r> ChangeCheck<'p, 'a, 'r> { } } -/// The local names of the style APIs that read style objects at build time -#[derive(Default)] -struct StyleApis<'s> { - functions: FxHashSet<&'s str>, - css: FxHashSet<&'s str>, +/// What the imports of the style packages bind, told by the binding an +/// identifier reads and not by its spelling: a local named like an import is +/// not a style API +struct StyleSymbols<'s> { + scoping: &'s Scoping, + /// Everything the packages give + roots: FxHashSet, + /// The style APIs that read style objects at build time + functions: FxHashSet, + css: FxHashSet, /// Namespace and default imports, `true` for `StyleX` - namespaces: FxHashMap<&'s str, bool>, + namespaces: FxHashMap, } fn takes_style_objects(stylex: bool, export: &str) -> bool { @@ -703,7 +751,51 @@ fn takes_style_objects(stylex: bool, export: &str) -> bool { } } -impl StyleApis<'_> { +impl<'s> StyleSymbols<'s> { + fn new(scoping: &'s Scoping) -> Self { + Self { + scoping, + roots: FxHashSet::default(), + functions: FxHashSet::default(), + css: FxHashSet::default(), + namespaces: FxHashMap::default(), + } + } + + /// Whether `identifier` reads something a style package gives + fn has(&self, identifier: &IdentifierReference<'_>) -> bool { + binding_of(self.scoping, identifier).is_some_and(|symbol| self.roots.contains(&symbol)) + } + + /// Whether `expression` is a style API: a root the package gives, or a + /// member or call of one + fn is_root(&self, expression: &Expression<'_>) -> bool { + match expression { + Expression::Identifier(identifier) => self.has(identifier), + Expression::StaticMemberExpression(member) => self.is_root(&member.object), + Expression::CallExpression(call) => self.is_root(&call.callee), + _ => false, + } + } + + fn is_css(&self, expression: &Expression<'_>) -> bool { + match expression { + Expression::Identifier(identifier) => binding_of(self.scoping, identifier) + .is_some_and(|symbol| self.css.contains(&symbol)), + Expression::StaticMemberExpression(member) if member.property.name == "css" => { + matches!(&member.object, Expression::Identifier(identifier) + if binding_of(self.scoping, identifier) + .and_then(|symbol| self.namespaces.get(&symbol)) == Some(&false)) + } + _ => false, + } + } + + /// Whether `name` is a component of the packages: `` or `` + fn is_component(&self, name: &JSXElementName<'_>) -> bool { + jsx_root_identifier(name).is_some_and(|identifier| self.has(identifier)) + } + /// Whether calling `callee` reads its arguments as style objects: /// `css(...)`, `styled.div(...)`, `styled(Link).attrs(...)`, /// `stylex.create(...)` @@ -713,11 +805,12 @@ impl StyleApis<'_> { loop { match crate::utils::unwrap_syntax_only(expression) { Expression::Identifier(identifier) => { - let name = identifier.name.as_str(); - return self.functions.contains(name) - || self.namespaces.get(name).is_some_and(|stylex| { - member.is_some_and(|member| takes_style_objects(*stylex, member)) - }); + return binding_of(self.scoping, identifier).is_some_and(|symbol| { + self.functions.contains(&symbol) + || self.namespaces.get(&symbol).is_some_and(|stylex| { + member.is_some_and(|member| takes_style_objects(*stylex, member)) + }) + }); } Expression::StaticMemberExpression(inner) => { member = Some(inner.property.name.as_str()); @@ -735,44 +828,16 @@ impl StyleApis<'_> { /// Names read inside the props of the package's components and the arguments /// of its functions struct StyleReads<'s> { - style_roots: &'s FxHashSet<&'s str>, + style: &'s StyleSymbols<'s>, css_props: &'s CssTakers<'s>, names: FxHashSet, depth: usize, - /// The API bindings of the surrounding `` child functions. + /// The bindings the `` child functions around take `css` and + /// `cx` by class_names: Vec, - scoping: Option<&'s Scoping>, -} - -/// Whether `expression` is a style API: a root the package gives, or a member -/// or call of one -fn is_style_root( - style_roots: &FxHashSet<&str>, - expression: &Expression<'_>, - scoping: Option<&Scoping>, -) -> bool { - match expression { - Expression::Identifier(identifier) => { - style_roots.contains(identifier.name.as_str()) - && scoping.is_none_or(|scoping| { - binding_of(scoping, identifier).is_some_and(|symbol| { - scoping.get_root_binding(identifier.name.as_str().into()) == Some(symbol) - }) - }) - } - Expression::StaticMemberExpression(member) => { - is_style_root(style_roots, &member.object, scoping) - } - Expression::CallExpression(call) => is_style_root(style_roots, &call.callee, scoping), - _ => false, - } } impl StyleReads<'_> { - fn is_style_root(&self, expression: &Expression<'_>) -> bool { - is_style_root(self.style_roots, expression, self.scoping) - } - fn reading(&mut self, style: bool, walk: impl FnOnce(&mut Self) -> T) -> T { self.depth += usize::from(style); let result = walk(self); @@ -782,17 +847,14 @@ impl StyleReads<'_> { } impl<'a> Visit<'a> for StyleReads<'_> { - fn visit_identifier_reference(&mut self, identifier: &oxc_ast::ast::IdentifierReference<'a>) { - if self.depth > 0 { + fn visit_identifier_reference(&mut self, identifier: &IdentifierReference<'a>) { + if self.depth > 0 && reads_top_level(self.style.scoping, identifier) { self.names.insert(identifier.name.to_string()); } } fn visit_jsx_element(&mut self, element: &oxc_ast::ast::JSXElement<'a>) { - let calls = self - .scoping - .map(|scoping| self.css_props.class_names_calls(element, scoping)) - .unwrap_or_default(); + let calls = self.css_props.class_names_calls(element); let taken = calls.len(); self.class_names.extend(calls); oxc_ast_visit::walk::walk_jsx_element(self, element); @@ -801,11 +863,13 @@ impl<'a> Visit<'a> for StyleReads<'_> { fn visit_call_expression(&mut self, call: &oxc_ast::ast::CallExpression<'a>) { self.visit_expression(&call.callee); - let style = self.is_style_root(&call.callee) - || calls_one_of(self.scoping, &self.class_names, &call.callee); + let style = self.style.is_root(&call.callee) + || self + .css_props + .calls_class_names(&self.class_names, &call.callee); let css = self .css_props - .property(call, |root| self.style_roots.contains(root)); + .property(call, |identifier| self.style.has(identifier)); self.reading(style, |reads| { for (index, argument) in call.arguments.iter().enumerate() { match (css, argument) { @@ -827,18 +891,22 @@ impl<'a> Visit<'a> for StyleReads<'_> { tagged: &oxc_ast::ast::TaggedTemplateExpression<'a>, ) { self.visit_expression(&tagged.tag); - let style = self.is_style_root(&tagged.tag) - || calls_one_of(self.scoping, &self.class_names, &tagged.tag); + let style = self.style.is_root(&tagged.tag) + || self + .css_props + .calls_class_names(&self.class_names, &tagged.tag); self.reading(style, |reads| reads.visit_template_literal(&tagged.quasi)); } fn visit_jsx_opening_element(&mut self, element: &oxc_ast::ast::JSXOpeningElement<'a>) { - let style = jsx_root(&element.name).is_some_and(|root| self.style_roots.contains(root)); + let style = self.style.is_component(&element.name); for attribute in &element.attributes { let style = style - || self.css_props.attribute(&element.name, attribute, |root| { - self.style_roots.contains(root) - }); + || self + .css_props + .attribute(&element.name, attribute, |identifier| { + self.style.has(identifier) + }); self.reading(style, |reads| match attribute { JSXAttributeItem::Attribute(attribute) => { if let Some(value) = &attribute.value { @@ -853,15 +921,12 @@ impl<'a> Visit<'a> for StyleReads<'_> { } } -/// Whether `callee` is one of `names` -fn calls_one_of(scoping: Option<&Scoping>, symbols: &[SymbolId], callee: &Expression<'_>) -> bool { - matches!(callee, Expression::Identifier(callee) if scoping.and_then(|scoping| binding_of(scoping, callee)).is_some_and(|symbol| symbols.contains(&symbol))) -} - -/// The name `` or `` starts with -pub(crate) fn jsx_root<'n>(name: &'n JSXElementName<'_>) -> Option<&'n str> { +/// The identifier `` or `` starts with +pub(crate) fn jsx_root_identifier<'n, 'a>( + name: &'n JSXElementName<'a>, +) -> Option<&'n IdentifierReference<'a>> { match name { - JSXElementName::IdentifierReference(identifier) => Some(identifier.name.as_str()), + JSXElementName::IdentifierReference(identifier) => Some(identifier), JSXElementName::MemberExpression(member) => { let mut object = &member.object; while let oxc_ast::ast::JSXMemberExpressionObject::MemberExpression(inner) = object { @@ -869,7 +934,7 @@ pub(crate) fn jsx_root<'n>(name: &'n JSXElementName<'_>) -> Option<&'n str> { } match object { oxc_ast::ast::JSXMemberExpressionObject::IdentifierReference(identifier) => { - Some(identifier.name.as_str()) + Some(identifier) } _ => None, } @@ -878,6 +943,11 @@ pub(crate) fn jsx_root<'n>(name: &'n JSXElementName<'_>) -> Option<&'n str> { } } +/// The name `` or `` starts with +pub(crate) fn jsx_root<'n>(name: &'n JSXElementName<'_>) -> Option<&'n str> { + jsx_root_identifier(name).map(|identifier| identifier.name.as_str()) +} + /// The constant exports of the modules read, by path struct Modules<'r> { resolver: Option<&'r ModuleResolver>, @@ -1173,10 +1243,17 @@ struct ModuleScope<'p, 'a> { style_imports: FxHashSet, /// Style APIs besides the imports, which never run what they are given style_names: FxHashSet, - /// The `css` props of the file extracted, which never run what they hold - css_props: Option<&'p CssTakers<'p>>, + /// The `css` props of the file extracted, which never run what they hold, + /// and the entry absorbing Emotion's own `jsx` + css_prop: Option<(CssProp, &'p str)>, uses: Option>>>, changes: FxHashMap>>, + /// The semantic analysis the program extracted already has, which the + /// visitor reuses and building another over the same program would reset + shared_scoping: Option<&'p Scoping>, + /// The semantic analysis of a module read, built when a `StyleX` callee + /// first needs the binding it reads told + scoping: OnceCell, } impl<'p, 'a> ModuleScope<'p, 'a> { @@ -1192,9 +1269,11 @@ impl<'p, 'a> ModuleScope<'p, 'a> { imports: FxHashMap::default(), style_imports: FxHashSet::default(), style_names: FxHashSet::default(), - css_props: None, + css_prop: None, uses: None, changes: FxHashMap::default(), + shared_scoping: None, + scoping: OnceCell::new(), } } @@ -1211,7 +1290,7 @@ impl<'p, 'a> ModuleScope<'p, 'a> { self.style_imports.contains(name) || self.imports.get(name).is_some_and(|(source, _)| { source != crate::STYLEX_PACKAGE - && (source.starts_with(modules.option.package.as_str()) + && (crate::package_specifier::is_package(source, &modules.option.package) || modules.option.import_aliases.contains_key(source)) }) } @@ -1228,7 +1307,8 @@ impl<'p, 'a> ModuleScope<'p, 'a> { let (source, Imported::Named(export)) = self.imports.get(callee.name.as_str())? else { return None; }; - if export != "css" || !source.starts_with(modules.option.package.as_str()) { + if export != "css" || !crate::package_specifier::is_package(source, &modules.option.package) + { return None; } let code = self.source?; @@ -1311,7 +1391,7 @@ impl<'p, 'a> ModuleScope<'p, 'a> { || self.style_names.contains(name) || self.imports.get(name).is_some_and(|(source, _)| { source == crate::STYLEX_PACKAGE - || source.starts_with(option.package.as_str()) + || crate::package_specifier::is_package(source, &option.package) || option.import_aliases.contains_key(source) }) } @@ -1343,7 +1423,7 @@ impl<'p, 'a> ModuleScope<'p, 'a> { let uses = Rc::new(crate::mutations::uses( self.program, &|name| self.is_style_import(option, name), - self.css_props, + self.css_prop, )); self.uses = Some(uses.clone()); uses @@ -1545,25 +1625,41 @@ impl<'p, 'a> ModuleScope<'p, 'a> { } } + /// The `StyleX` API `callee` reads, as the import it reads binds it and + /// not as it is spelled: a local named like an import is not the API fn stylex_function(&self, callee: &Expression<'_>) -> Option { - let (name, member) = match callee { - Expression::Identifier(identifier) => (identifier.name.as_str(), None), + let (identifier, member) = match callee { + Expression::Identifier(identifier) => (identifier, None), Expression::StaticMemberExpression(member) => match &member.object { - Expression::Identifier(object) => { - (object.name.as_str(), Some(member.property.name.as_str())) - } + Expression::Identifier(object) => (object, Some(member.property.name.as_str())), _ => return None, }, _ => return None, }; - let export = match (self.imports.get(name)?, member) { + let export = match (self.imports.get(identifier.name.as_str())?, member) { ((source, _), _) if source != crate::STYLEX_PACKAGE => return None, ((_, Imported::Named(export)), None) => export.as_str(), ((_, Imported::Namespace), Some(export)) => export, ((_, Imported::Named(export)), Some(member)) if export == "default" => member, _ => return None, }; - StylexFunction::from_export_name(export) + let function = StylexFunction::from_export_name(export)?; + self.reads_top_level_binding(identifier).then_some(function) + } + + /// Whether `identifier` reads a binding of the module's top level, where + /// the imports bind, and not a local of a function or block + fn reads_top_level_binding(&self, identifier: &IdentifierReference<'_>) -> bool { + let scoping = self.shared_scoping.unwrap_or_else(|| { + self.scoping.get_or_init(|| { + SemanticBuilder::new() + .build(self.program) + .semantic + .into_scoping() + }) + }); + binding_of(scoping, identifier) + .is_some_and(|symbol| scoping.symbol_scope_id(symbol) == scoping.root_scope_id()) } /// A value `StyleX` gives when this module's own extraction reads it, with @@ -1641,7 +1737,7 @@ impl<'p, 'a> ModuleScope<'p, 'a> { /// `StyleX` variable, as [`crate::stylex::variable_values`] reads it once /// constants are inlined fn variable_value(&mut self, modules: &mut Modules<'_>, value: &Expression<'_>) -> bool { - let value = crate::stylex::unwrap_types_call(value); + let value = crate::stylex::unwrap_types_call(value, &|callee| self.stylex_function(callee)); if matches!(value, Expression::NullLiteral(_)) || self.literal_text(modules, value).is_some() { @@ -1991,9 +2087,8 @@ struct Inline<'s, 'a> { ast_builder: &'s AstBuilder<'a>, scoping: &'s Scoping, symbols: &'s FxHashMap, - style_roots: &'s FxHashSet<&'s str>, + style: &'s StyleSymbols<'s>, css_props: &'s CssTakers<'s>, - apis: &'s StyleApis<'s>, css_failures: &'s mut Vec, unknown: &'s mut Unknown, /// Inside what the build reads as style objects @@ -2006,7 +2101,8 @@ struct Inline<'s, 'a> { /// Where a constant stands as a value or rules of a library reading /// numbers as `px` lengths marks: &'s crate::import_alias_visit::PxMarks, - /// The API bindings of the surrounding `` child functions. + /// The bindings the `` child functions around take `css` and + /// `cx` by class_names: Vec, } @@ -2333,27 +2429,17 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { tagged: &mut oxc_ast::ast::TaggedTemplateExpression<'a>, ) { self.visit_expression(&mut tagged.tag); - if calls_one_of(Some(self.scoping), &self.class_names, &tagged.tag) { + if self + .css_props + .calls_class_names(&self.class_names, &tagged.tag) + { self.reading_css(true, |inline| { inline.visit_template_literal(&mut tagged.quasi); }); return; } - let styles = is_style_root( - self.style_roots, - &tagged.tag, - self.css_props.has_class_names().then_some(self.scoping), - ); - let css = is_style_root(self.style_roots, &tagged.tag, Some(self.scoping)) - && match &tagged.tag { - Expression::Identifier(name) => self.apis.css.contains(name.name.as_str()), - Expression::StaticMemberExpression(member) => { - member.property.name == "css" - && matches!(&member.object, Expression::Identifier(name) - if self.apis.namespaces.get(name.name.as_str()) == Some(&false)) - } - _ => false, - }; + let styles = self.style.is_root(&tagged.tag); + let css = self.style.is_css(&tagged.tag); if !css { self.reading_styles(styles, |inline| { inline.visit_template_literal(&mut tagged.quasi); @@ -2419,7 +2505,7 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { } fn visit_jsx_element(&mut self, element: &mut oxc_ast::ast::JSXElement<'a>) { - let calls = self.css_props.class_names_calls(element, self.scoping); + let calls = self.css_props.class_names_calls(element); let taken = calls.len(); self.class_names.extend(calls); walk_mut::walk_jsx_element(self, element); @@ -2428,7 +2514,10 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { fn visit_call_expression(&mut self, call: &mut oxc_ast::ast::CallExpression<'a>) { self.visit_expression(&mut call.callee); - if calls_one_of(Some(self.scoping), &self.class_names, &call.callee) { + if self + .css_props + .calls_class_names(&self.class_names, &call.callee) + { self.reading_css(true, |inline| { for argument in &mut call.arguments { inline.visit_argument(argument); @@ -2436,15 +2525,11 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { }); return; } - let objects = self.apis.reads(&call.callee); - let styles = is_style_root( - self.style_roots, - &call.callee, - self.css_props.has_class_names().then_some(self.scoping), - ); + let objects = self.style.reads(&call.callee); + let styles = self.style.is_root(&call.callee); let css = self .css_props - .property(call, |root| self.style_roots.contains(root)); + .property(call, |identifier| self.style.has(identifier)); self.reading_styles(styles, |inline| { inline.reading_objects(objects, |inline| { for (index, argument) in call.arguments.iter_mut().enumerate() { @@ -2464,12 +2549,15 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { } fn visit_jsx_opening_element(&mut self, element: &mut oxc_ast::ast::JSXOpeningElement<'a>) { - let styled = jsx_root(&element.name).is_some_and(|root| self.style_roots.contains(root)); + let styled = self.style.is_component(&element.name); let element_name = &element.name; for attribute in &mut element.attributes { - if self.css_props.attribute(element_name, attribute, |root| { - self.style_roots.contains(root) - }) { + if self + .css_props + .attribute(element_name, attribute, |identifier| { + self.style.has(identifier) + }) + { self.reading_css(true, |inline| inline.visit_jsx_attribute_item(attribute)); continue; } @@ -2504,3 +2592,11 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { } } } + +#[cfg(test)] +#[path = "imported_constants_package_boundary_tests.rs"] +mod package_boundary_tests; +#[cfg(test)] +mod scope_tests; +#[cfg(test)] +mod stylex_scope_tests; diff --git a/libs/extractor/src/imported_constants/scope_tests.rs b/libs/extractor/src/imported_constants/scope_tests.rs new file mode 100644 index 000000000..53d1b0ced --- /dev/null +++ b/libs/extractor/src/imported_constants/scope_tests.rs @@ -0,0 +1,272 @@ +use std::rc::Rc; + +use oxc_allocator::Allocator; +use oxc_ast::ast::Expression; +use oxc_ast::builder::AstBuilder; +use oxc_codegen::Codegen; +use oxc_parser::Parser; +use oxc_span::SourceType; +use rustc_hash::FxHashMap; + +use super::{Change, ChangeSite, Changed, Inlined, Unknown, inline_constants}; +use crate::ExtractOption; +use crate::css_prop::CssProp; + +fn inline(code: &str, css_prop: CssProp) -> (String, Inlined) { + let allocator = Allocator::default(); + let mut program = Parser::new(&allocator, code, SourceType::tsx()) + .parse() + .program; + let inlined = inline_constants( + &AstBuilder::new(&allocator), + &mut program, + "a.tsx", + &ExtractOption::default(), + None, + css_prop, + &crate::import_alias_visit::PxMarks::default(), + ); + (Codegen::new().build(&program).code, inlined) +} + +fn expression<'a>(allocator: &'a Allocator, code: &'a str) -> Expression<'a> { + Parser::new(allocator, code, SourceType::tsx()) + .parse_expression() + .unwrap_or_else(|_| panic!("{code}")) +} + +#[test] +fn a_local_named_like_a_style_function_reads_no_styles() { + let (code, _) = inline( + "import { css } from '@devup-ui/react'; +import * as ui from '@devup-ui/react'; +const SIZE = 4; +export const own = css({ w: SIZE }); +export const member = ui.css({ w: SIZE }); +export function shadowed(css, ui) { return [css({ w: SIZE }), ui.css({ w: SIZE }), parseInt('1')]; }", + CssProp::Off, + ); + assert_eq!(code.matches("w: 4").count(), 2, "{code}"); + assert_eq!(code.matches("w: SIZE").count(), 2, "{code}"); +} + +#[test] +fn a_local_named_like_a_style_component_reads_no_styles() { + let (code, _) = inline( + "import { Box } from '@devup-ui/react'; +import * as ui from '@devup-ui/react'; +const COLOR = 'red'; +export const own = <>; +export function shadowed(Box, ui) { return <>; }", + CssProp::Off, + ); + assert_eq!(code.matches("{COLOR}").count(), 2, "{code}"); +} + +#[test] +fn a_local_named_like_a_class_names_binding_reads_no_styles() { + let (code, _) = inline( + "import { ClassNames } from '@emotion/react'; +const SIZE = 4; +export const own = {({ css }) => css({ width: SIZE })}; +export const nested = {({ css }) => ((css) => css({ width: SIZE }))(css)}; +export const tagged = {({ cx }) => cx`width: ${SIZE}px`}; +export function shadowed(ClassNames) { return {({ css }) => css({ width: SIZE })}; }", + CssProp::Elements, + ); + assert_eq!(code.matches("width: SIZE").count(), 2, "{code}"); + assert_eq!(code.matches("width: \"4px\"").count(), 1, "{code}"); + assert_eq!(code.matches("${SIZE}").count(), 0, "{code}"); +} + +#[test] +fn a_local_named_like_the_jsx_function_takes_no_css_prop() { + let (code, _) = inline( + "import { jsx } from '@emotion/react'; +import { Box } from '@devup-ui/react'; +const SIZE = 4; +export const tag = jsx('div', { css: { width: SIZE } }); +export const component = jsx(Box, { css: { width: SIZE } }); +export function shadowedJsx(jsx) { return jsx('div', { css: { width: SIZE } }); } +export function shadowedBox(Box) { return jsx(Box, { css: { width: SIZE } }); }", + CssProp::Elements, + ); + assert_eq!(code.matches("width: \"4px\"").count(), 2, "{code}"); + assert_eq!(code.matches("width: SIZE").count(), 2, "{code}"); +} + +#[test] +fn a_local_named_like_a_style_component_takes_no_css_attribute() { + let (code, _) = inline( + "import { Box } from '@devup-ui/react'; +const SIZE = 4; +export const own = ; +export const tag =
; +export const custom = ; +export function shadowed(Box) { return ; }", + CssProp::Elements, + ); + assert_eq!(code.matches("width: \"4px\"").count(), 2, "{code}"); + assert_eq!(code.matches("width: SIZE").count(), 2, "{code}"); +} + +#[test] +fn names_only_locals_read_are_not_reported_unknown_or_changed() { + let (_, shadowed) = inline( + "import { css } from '@devup-ui/react'; +const unknown = compute(); +const table = { a: 1 }; +table.a = 2; +export function f(unknown, table) { return css({ w: unknown, h: table.a }); }", + CssProp::Off, + ); + assert!(shadowed.unknown.is_empty()); + assert!(shadowed.changed.is_empty()); + + let (_, read) = inline( + "import { css } from '@devup-ui/react'; +const unknown = compute(); +const table = { a: 1 }; +table.a = 2; +export const a = css({ w: unknown, h: table.a });", + CssProp::Off, + ); + assert!(!read.unknown.is_empty()); + assert!(!read.changed.is_empty()); +} + +#[test] +fn the_scoping_built_is_kept_on_every_return_after_it() { + for (code, css_prop, kept) in [ + ("const SIZE = 4;\nexport const a = 1;", CssProp::Off, false), + ( + "import { css } from '@devup-ui/react';\nconst SIZE = 4;", + CssProp::Off, + true, + ), + ( + "import { css } from '@devup-ui/react';\nconst SIZE = 4;\ncss({ w: 1 });", + CssProp::Off, + true, + ), + ( + "import { css } from '@devup-ui/react';\nconst SIZE = 4;\ncss({ w: Math.max(1, 2) });", + CssProp::Off, + true, + ), + ( + "import { css } from '@devup-ui/react';\nconst SIZE = 4;\ncss({ w: SIZE });", + CssProp::Off, + true, + ), + ( + "const SIZE = 4;\nexport const a =
;", + CssProp::Elements, + true, + ), + ] { + let (_, inlined) = inline(code, css_prop); + assert_eq!(inlined.scoping.is_some(), kept, "{code}"); + if let Some(scoping) = &inlined.scoping { + assert!(scoping.get_root_binding("SIZE".into()).is_some(), "{code}"); + } + } +} + +struct Properties<'s> { + inlined: &'s Inlined, + scoping: &'s oxc_semantic::Scoping, + found: Vec<(bool, bool)>, +} + +impl<'a> oxc_ast_visit::Visit<'a> for Properties<'_> { + fn visit_object_property(&mut self, property: &oxc_ast::ast::ObjectProperty<'a>) { + let reads = |identifier: &oxc_ast::ast::IdentifierReference<'_>| { + crate::css_prop::reads_top_level(self.scoping, identifier) + }; + self.found.push(( + self.inlined.unknown.read_by_in(&property.value, &reads), + self.inlined.changed.read_by_in(&property.value, &reads), + )); + } +} + +#[test] +fn the_scoping_kept_guards_reads_of_unknown_and_changed_bindings() { + let allocator = Allocator::default(); + let mut program = Parser::new( + &allocator, + "import { css } from '@devup-ui/react'; +const unknown = compute(); +const table = { a: 1 }; +table.a = 2; +export const top = css({ w: unknown, h: table.a }); +export function shadowed(unknown, table) { return css({ w: unknown, h: table.a }); }", + SourceType::tsx(), + ) + .parse() + .program; + let inlined = inline_constants( + &AstBuilder::new(&allocator), + &mut program, + "a.tsx", + &ExtractOption::default(), + None, + CssProp::Off, + &crate::import_alias_visit::PxMarks::default(), + ); + let Some(scoping) = inlined.scoping.clone() else { + panic!("no scoping kept"); + }; + let mut properties = Properties { + inlined: &inlined, + scoping: &scoping, + found: Vec::new(), + }; + oxc_ast_visit::Visit::visit_program(&mut properties, &program); + assert_eq!( + properties.found, + vec![ + (false, false), + (true, false), + (false, true), + (false, false), + (false, false) + ] + ); +} + +#[test] +fn unknown_counts_only_the_identifiers_accepted() { + let allocator = Allocator::default(); + let unknown = Unknown { + names: std::iter::once("x".to_string()).collect(), + partial: FxHashMap::default(), + }; + for code in ["x", "x()", "x[k]", "x.y[k]", "x.y()"] { + let expression = expression(&allocator, code); + assert!(unknown.read_by_in(&expression, &|_| true), "{code}"); + assert!(!unknown.read_by_in(&expression, &|_| false), "{code}"); + } + let expression = expression(&allocator, "y[x]"); + assert!(!unknown.read_by_in(&expression, &|_| true)); +} + +#[test] +fn changed_counts_only_the_identifiers_accepted() { + let allocator = Allocator::default(); + let change = Rc::new(Change { + name: "x".to_string(), + site: ChangeSite::Here(0), + handed: false, + }); + let changed = Changed { + whole: FxHashMap::from_iter([("x".to_string(), change)]), + holding: FxHashMap::default(), + }; + for code in ["x", "x.y", "x[k]"] { + let expression = expression(&allocator, code); + assert!(changed.read_by_in(&expression, &|_| true), "{code}"); + assert!(!changed.read_by_in(&expression, &|_| false), "{code}"); + } +} diff --git a/libs/extractor/src/imported_constants/stylex_scope_tests.rs b/libs/extractor/src/imported_constants/stylex_scope_tests.rs new file mode 100644 index 000000000..ea6d77bf5 --- /dev/null +++ b/libs/extractor/src/imported_constants/stylex_scope_tests.rs @@ -0,0 +1,225 @@ +use oxc_allocator::Allocator; +use oxc_ast::ast::{CallExpression, Expression, Statement}; +use oxc_ast_visit::{Visit, walk}; +use oxc_parser::Parser; +use oxc_span::SourceType; + +use super::ModuleScope; +use crate::stylex::StylexFunction; + +/// The `StyleX` API the callee of each call reads, and the one its member +/// object reads, in source order +struct Callees<'s, 'p, 'a> { + scope: &'s ModuleScope<'p, 'a>, + found: Vec<(Option, Option)>, +} + +impl<'a> Visit<'a> for Callees<'_, '_, 'a> { + fn visit_call_expression(&mut self, call: &CallExpression<'a>) { + let object = match &call.callee { + Expression::StaticMemberExpression(member) => { + self.scope.stylex_function(&member.object) + } + _ => None, + }; + self.found + .push((self.scope.stylex_function(&call.callee), object)); + walk::walk_call_expression(self, call); + } +} + +fn read(code: &str) -> Vec<(Option, Option)> { + let allocator = Allocator::default(); + let program = Parser::new(&allocator, code, SourceType::tsx()) + .parse() + .program; + let mut scope = ModuleScope::new("a.tsx", &program, None); + for statement in &program.body { + if let Statement::ImportDeclaration(import) = statement { + scope.import(import); + } + } + let mut callees = Callees { + scope: &scope, + found: Vec::new(), + }; + callees.visit_program(&program); + callees.found +} + +#[test] +fn a_helper_is_the_import_its_callee_reads_and_not_its_spelling() { + let found = read( + "import * as sx from '@stylexjs/stylex'; +import { firstThatWorks as ftw, types as t, include } from '@stylexjs/stylex'; +import { include as other } from 'elsewhere'; +sx.firstThatWorks(a); +ftw(a); +include(a); +other(a); +t.color(a); +sx.types.color(a); +function shadowed(sx, ftw, include, t) { + sx.firstThatWorks(a); + ftw(a); + include(a); + t.color(a); + sx.types.color(a); +}", + ); + assert_eq!( + found, + vec![ + (Some(StylexFunction::FirstThatWorks), None), + (Some(StylexFunction::FirstThatWorks), None), + (Some(StylexFunction::Include), None), + (None, None), + (None, Some(StylexFunction::Types)), + (None, Some(StylexFunction::Types)), + (None, None), + (None, None), + (None, None), + (None, None), + (None, None), + ] + ); +} + +fn extracted(code: &str) -> Result { + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + crate::extract( + "test.tsx", + code, + crate::ExtractOption { + import_main_css: false, + ..crate::ExtractOption::default() + }, + ) + .map_err(|error| error.to_string()) +} + +fn declarations(output: &crate::ExtractOutput) -> Vec<(String, String)> { + let mut found: Vec<(String, String)> = output + .styles + .iter() + .filter_map(|style| match style { + crate::ExtractStyleValue::Static(style) => { + Some((style.property.clone(), style.value.clone())) + } + _ => None, + }) + .collect(); + found.sort(); + found +} + +fn error_of(code: &str) -> String { + extracted(code).err().unwrap_or_default() +} + +const SX: &str = "import * as sx from '@stylexjs/stylex';\n"; + +#[test] +#[serial_test::serial] +fn genuine_helpers_compile_through_every_import_spelling() { + let output = extracted( + "import * as sx from '@stylexjs/stylex'; +import { create, defineVars, firstThatWorks as ftw, include as inc, types as t } from '@stylexjs/stylex'; +const base = create({ a: { color: 'red' } }); +const vars = defineVars({ size: t.length('1px'), tone: sx.types.color({ default: 'blue' }) }); +const styles = sx.create({ + b: { backgroundColor: ftw('red', 'blue'), width: vars.size, borderColor: vars.tone }, + c: { ...inc(base.a), height: sx.firstThatWorks('1px', '2px') }, +}); +export const A = () =>
;", + ) + .unwrap_or_else(|error| panic!("{error}")); + assert_eq!( + declarations(&output), + vec![ + ("background-color".to_string(), "blue".to_string()), + ("background-color".to_string(), "red".to_string()), + ("border-color".to_string(), "var(--a-c)".to_string()), + ("color".to_string(), "red".to_string()), + ("height".to_string(), "1px".to_string()), + ("height".to_string(), "2px".to_string()), + ("width".to_string(), "var(--a-b)".to_string()), + ] + ); +} + +#[test] +#[serial_test::serial] +fn shadowed_and_unrelated_helpers_are_reported_at_build_time() { + for (code, expected) in [ + ( + "export function f(firstThatWorks) { return sx.create({ a: { color: firstThatWorks('red', 'blue') } }); }", + "`stylex.create()` cannot use `firstThatWorks(", + ), + ( + "export function f(o) { return sx.create({ a: { color: o.firstThatWorks('red') } }); }", + "`stylex.create()` cannot use `o.firstThatWorks(", + ), + ( + "const base = sx.create({ a: { color: 'red' } });\nexport function f(include) { return sx.create({ b: { ...include(base.a) } }); }", + "`stylex.create()` cannot use `...include(", + ), + ( + "const base = sx.create({ a: { color: 'red' } });\nexport function f(o) { return sx.create({ b: { ...o.include(base.a) } }); }", + "`stylex.create()` cannot use `...o.include(", + ), + ( + "export function f(types) { return sx.defineVars({ c: types.color('red') }); }", + "`stylex.defineVars()` cannot use `types.color(", + ), + ( + "export function f(o) { return sx.defineVars({ c: o.types.color('red') }); }", + "`stylex.defineVars()` cannot use `o.types.color(", + ), + ( + "export const p = sx.positionTry({ top: sx.firstThatWorks('1px') });", + "`stylex.positionTry()` cannot use `sx.firstThatWorks(", + ), + ( + "export function f(firstThatWorks) { return sx.positionTry({ top: firstThatWorks('1px') }); }", + "`stylex.positionTry()` cannot use `firstThatWorks(", + ), + ] { + let message = error_of(&format!("{SX}{code}")); + assert!(message.contains(expected), "{expected}: {message}"); + } +} + +#[test] +#[serial_test::serial] +fn aliased_types_read_in_an_imported_module_still_publish_their_variables() { + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + let resolver = |specifier: &str, _: &str| { + (specifier == "./vars").then(|| crate::ResolvedModule { + path: "/src/vars.ts".to_string(), + code: "import { defineVars, types as t } from '@stylexjs/stylex'; +export const vars = defineVars({ tone: t.color('red'), size: t.length(unknown()) });" + .to_string(), + }) + }; + let output = crate::extract_with_modules( + "/src/App.tsx", + "import * as sx from '@stylexjs/stylex'; +import { vars } from './vars'; +const s = sx.create({ a: { color: vars.tone } }); +export const A = () =>
;", + crate::ExtractOption { + import_main_css: false, + ..crate::ExtractOption::default() + }, + false, + &resolver, + ) + .unwrap_or_else(|error| panic!("{error}")); + assert_eq!( + declarations(&output), + vec![("color".to_string(), "var(--b-a)".to_string())] + ); +} diff --git a/libs/extractor/src/imported_constants_package_boundary_tests.rs b/libs/extractor/src/imported_constants_package_boundary_tests.rs new file mode 100644 index 000000000..fdae724ce --- /dev/null +++ b/libs/extractor/src/imported_constants_package_boundary_tests.rs @@ -0,0 +1,49 @@ +use oxc_allocator::Allocator; +use oxc_ast::ast::{Expression, Statement}; +use oxc_parser::Parser; +use oxc_span::SourceType; +use rstest::rstest; + +use super::{ModuleScope, Modules}; +use crate::ExtractOption; + +#[rstest] +#[case("@devup-ui/react-values", false)] +#[case("@devup-ui/react", true)] +#[case("@devup-ui/react/styles", true)] +#[case("@devup-ui/react/compat", true)] +fn package_boundary_css_styles_only_reads_api_exports( + #[case] source: &str, + #[case] expected: bool, +) { + // Given + let allocator = Allocator::default(); + let code = format!("import {{css}} from '{source}';css({{color:'blue'}});"); + let program = Parser::new(&allocator, &code, SourceType::tsx()) + .parse() + .program; + let mut scope = ModuleScope::new("/helper.ts", &program, Some(&code)); + let Statement::ImportDeclaration(import) = &program.body[0] else { + panic!("import fixture"); + }; + scope.import(import); + let Statement::ExpressionStatement(statement) = &program.body[1] else { + panic!("call fixture"); + }; + let Expression::CallExpression(call) = &statement.expression else { + panic!("call fixture"); + }; + let option = ExtractOption::default(); + let mut modules = Modules { + resolver: None, + option: &option, + exports: Default::default(), + loading: vec![], + }; + // When + let styles = scope.css_styles(&mut modules, call); + // Then + assert_eq!(styles.is_some(), expected); + assert_eq!(scope.is_style_api(&modules, &call.callee), expected); + assert_eq!(scope.is_style_import(&option, "css"), expected); +} diff --git a/libs/extractor/src/imported_constants_px_tests.rs b/libs/extractor/src/imported_constants_px_tests.rs index 6fc49d25f..0746e6884 100644 --- a/libs/extractor/src/imported_constants_px_tests.rs +++ b/libs/extractor/src/imported_constants_px_tests.rs @@ -1,6 +1,68 @@ use super::*; use rstest::rstest; +#[test] +fn namespace_css_tag_materializes_record_when_inlined_without_alias_normalization() { + // Given: authored namespace syntax reaches the parsed-source seam unchanged. + let source = concat!( + "import * as UI from '@devup-ui/react';", + "const rules={p:2,color:'tomato'};", + "export const card=UI.css`${rules}`;", + ); + let allocator = Allocator::default(); + let mut program = crate::parsing::parse(&allocator, "/src/namespace.ts", source) + .unwrap_or_else(|error| panic!("invalid namespace fixture: {error}")); + + // When: inline real constants with semantic binding resolution and no px marks. + inline_constants( + &AstBuilder::new(&allocator), + &mut program, + "/src/namespace.ts", + &ExtractOption::default(), + None, + CssProp::Off, + &crate::import_alias_visit::PxMarks::default(), + ); + + // Then: the exported namespace tag contains the actual materialized record. + let Some(Statement::ExportDeclaration(export)) = program.body.last() else { + panic!("expected exported card"); + }; + let oxc_ast::ast::Declaration::VariableDeclaration(declaration) = &export.declaration else { + panic!("expected card declaration"); + }; + let Some(Expression::TaggedTemplateExpression(tagged)) = + declaration.declarations[0].init.as_ref() + else { + panic!("expected namespace css tag"); + }; + let Expression::StaticMemberExpression(member) = &tagged.tag else { + panic!("expected namespace member"); + }; + let Expression::Identifier(namespace) = &member.object else { + panic!("expected namespace binding"); + }; + assert_eq!(namespace.name, "UI"); + assert_eq!(member.property.name, "css"); + let [Expression::ObjectExpression(object)] = tagged.quasi.expressions.as_slice() else { + panic!("expected materialized record interpolation"); + }; + let [ + ObjectPropertyKind::ObjectProperty(p), + ObjectPropertyKind::ObjectProperty(color), + ] = object.properties.as_slice() + else { + panic!("expected two record properties"); + }; + assert_eq!(p.key.static_name().as_deref(), Some("p")); + assert_eq!(crate::utils::js_number_literal(&p.value), Some(2.0)); + assert_eq!(color.key.static_name().as_deref(), Some("color")); + let Expression::StringLiteral(value) = &color.value else { + panic!("expected materialized color string"); + }; + assert_eq!(value.value, "tomato"); +} + #[test] fn own_css_record_keeps_fallback_order_when_authored_px_mark_is_inlined() { // Given: Emotion-style fallback arrays, not responsive breakpoints. diff --git a/libs/extractor/src/imported_styled.rs b/libs/extractor/src/imported_styled.rs index 29841de0a..e9c457607 100644 --- a/libs/extractor/src/imported_styled.rs +++ b/libs/extractor/src/imported_styled.rs @@ -5,7 +5,7 @@ use std::collections::BTreeSet; use std::rc::Rc; -use oxc_allocator::{Allocator, GetAllocator}; +use oxc_allocator::{Allocator, CloneIn, GetAllocator}; use oxc_ast::ast::{ CallExpression, Declaration, Expression, ImportDeclarationSpecifier, JSXAttributeItem, JSXAttributeName, JSXElementName, JSXOpeningElement, Program, Statement, @@ -272,6 +272,7 @@ impl<'a> Reader<'_, 'a> { visitor.takes_css_prop(css_prop); visitor.import_styled(imports); visitor.reserve_styled(styled_capability); + visitor.reuse_scoping(inlined.scoping); visitor.visit_program(&mut program); let readable = visitor.errors.is_empty() && visitor.unknown_parts.is_empty() @@ -328,12 +329,20 @@ impl<'a> Reader<'_, 'a> { css_prop: CssProp, allocator: &'b Allocator, ) -> Components<'b> { + let arena = Allocator::default(); + let isolated = program.clone_in(&arena); + let program = &isolated; + let scoping = oxc_semantic::SemanticBuilder::new() + .build(program) + .semantic + .into_scoping(); let imported = imported_bindings(program, &self.option.package); let mut used = Used { styled: imported.styled, css_prop: css_prop != CssProp::Off, css: CssTakers::new( program, + &scoping, css_prop, &format!("{}/compat", self.option.package), ), diff --git a/libs/extractor/src/jsx_import_source.rs b/libs/extractor/src/jsx_import_source.rs index f90c69e27..328bd0554 100644 --- a/libs/extractor/src/jsx_import_source.rs +++ b/libs/extractor/src/jsx_import_source.rs @@ -199,6 +199,78 @@ mod tests { ); } + #[rstest] + #[case::override_header( + "/** @jsxImportSource custom */\nconst broken;\nexport const view =
;", + "project.tsx:2:7: Cannot parse source:", + "Missing initializer in const declaration" + )] + #[case::octal_css_value( + "export const view=
;", + "project.tsx:1:38: Cannot parse source:", + "octal" + )] + #[serial_test::serial] + fn project_jsx_rejects_original_syntax_before_preprocessing( + #[case] source: &str, + #[case] location: &str, + #[case] cause: &str, + ) -> Result<(), String> { + // Given: project-only Emotion JSX, without a package name in the source. + let option = crate::ExtractOption { + import_aliases: HashMap::from([ + ("@emotion/react".into(), ImportAlias::NamedToNamed), + ( + "@emotion/react/jsx-runtime".into(), + ImportAlias::NamedToNamed, + ), + ]), + ..crate::ExtractOption::default() + }; + + // When: the public extractor sees an override or a pixelifiable number. + let error = crate::extract("project.tsx", source, option) + .err() + .ok_or("expected original-source parsing error")? + .to_string(); + + // Then: the authored error survives, located before any source rewriting. + assert!(error.starts_with(location), "{error}"); + assert!( + error.to_lowercase().contains(&cause.to_lowercase()), + "{error}" + ); + assert!( + error.contains("Fix: correct the syntax at this location"), + "{error}" + ); + Ok(()) + } + + #[test] + #[serial_test::serial] + fn project_jsx_validation_leaves_unrelated_typescript_unchanged() -> Result<(), String> { + // Given: a non-JSX file without relevant imports in an Emotion project. + let source = "const broken;"; + let option = crate::ExtractOption { + import_aliases: HashMap::from([ + ("@emotion/react".into(), ImportAlias::NamedToNamed), + ( + "@emotion/react/jsx-runtime".into(), + ImportAlias::NamedToNamed, + ), + ]), + ..crate::ExtractOption::default() + }; + // When: extraction checks whether this unrelated file needs processing. + let output = + crate::extract("unrelated.ts", source, option).map_err(|error| error.to_string())?; + // Then: project JSX validation has not expanded to unrelated files. + assert_eq!(output.code, source); + assert_eq!(output.styles.len(), 0); + Ok(()) + } + #[rstest] #[case::plain("")] #[case::hashbang("#!/usr/bin/env node\n")] @@ -229,7 +301,7 @@ mod tests { assert_eq!( error, format!( - "project.tsx:{line}:28: JSX attributes must only be assigned a non-empty 'expression'." + "project.tsx:{line}:28: Cannot parse source: JSX attributes must only be assigned a non-empty 'expression'. Fix: correct the syntax at this location" ) ); Ok(()) diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 2a068b314..54043f9c6 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -3,6 +3,8 @@ mod barrel; #[cfg(test)] mod barrel_pipeline_tests; mod build_time_values; +#[cfg(test)] +mod ci_coverage_tests; mod class_arguments; #[cfg(test)] mod class_names_binding_tests; @@ -14,9 +16,13 @@ mod css_prop; #[cfg(test)] mod css_prop_contract_tests; mod css_utils; +#[cfg(test)] +mod diagnostics_tests; mod emotion_namespace; +mod evaluation_sandbox; pub mod extract_style; mod extractor; +mod fresh_name; mod gen_class_name; mod gen_style; mod import_alias_visit; @@ -25,9 +31,15 @@ mod imported_styled; mod jsx_import_source; mod jsx_pragmas; mod module_loader; +pub mod module_reference; mod mutations; #[cfg(test)] mod numeric_template_tests; +mod own_css_context; +mod own_css_lower; +mod own_css_merge; +mod own_css_plan; +mod package_specifier; mod parsing; #[cfg(test)] mod pragma_annotation_tests; @@ -44,12 +56,38 @@ mod quoted_strings_tests; pub mod reservation_state; #[cfg(test)] mod responsive_selector_tests; +mod scope; mod source_map; +#[cfg(test)] +mod source_reconciliation_tests; mod style_collector; mod style_values; mod styled_reads; #[cfg(test)] mod styled_source_tests; +#[cfg(test)] +mod stylesheet_coverage_contract_tests; +#[cfg(test)] +mod stylesheet_css_adapter_error_tests; +#[cfg(test)] +mod stylesheet_css_adapter_tests; +#[cfg(test)] +mod stylesheet_css_tag_parity_tests; +#[cfg(test)] +mod stylesheet_css_tag_terminal_tests; +#[cfg(test)] +mod stylesheet_deep_object_bridge_tests; +#[cfg(test)] +mod stylesheet_deep_object_r2_tests; +#[cfg(test)] +mod stylesheet_deep_object_tests; +#[cfg(test)] +mod stylesheet_gate_boundaries_deep_tests; +#[cfg(test)] +mod stylesheet_gate_boundaries_tests; +mod stylesheet_policy; +#[cfg(test)] +mod stylesheet_regression_tests; mod stylex; mod tailwind; mod tailwind_children; @@ -354,18 +392,6 @@ fn extract_with_source_map( source_map: bool, resolver: Option<&ModuleResolver>, ) -> Result> { - if code.contains(&option.package) - || code.contains(STYLEX_PACKAGE) - || (css_prop::builds_jsx_with_emotion(&option.import_aliases) - && css_prop::is_jsx_file(filename)) - || option - .import_aliases - .keys() - .any(|alias| code.contains(alias)) - { - let allocator = Allocator::default(); - parsing::parse(&allocator, filename, code)?; - } extract_source(filename, code, None, None, option, source_map, resolver) } @@ -382,6 +408,28 @@ fn extract_source( resolver: Option<&ModuleResolver>, ) -> Result> { let _atom_mode = css::atom_hoist::AtomModeGuard::new(option.single_css); + let allocator = Allocator::default(); + let original_program = if code.contains(&option.package) + || code.contains(STYLEX_PACKAGE) + || (css_prop::builds_jsx_with_emotion(&option.import_aliases) + && css_prop::is_jsx_file(filename)) + || option + .import_aliases + .keys() + .any(|package| code.contains(package)) + { + let source_type = parsing::source_type(filename).map_err(|error| { + format!("{filename}:1:1: {error}. Fix: use a supported JavaScript or TypeScript file extension") + })?; + Some( + parsing::checked(&allocator, code, source_type).map_err(|errors| { + let (source, edits) = evaluated.unwrap_or((code, &[])); + located_errors(filename, source, edits, errors) + })?, + ) + } else { + None + }; if evaluated.is_none() && option.import_aliases.contains_key("@vanilla-extract/css") { let errors = import_alias_visit::companion_errors(code, filename); if !errors.is_empty() { @@ -490,14 +538,113 @@ fn extract_source( } let mut dependencies = std::collections::BTreeSet::new(); - let mut evaluation_error = None; + if utils::is_vanilla_extract_file(filename) + && values_run.is_none() + && stylesheet_policy::imports_plain(&transformed_code, filename, &option, resolver) + && let Some((computed, value_edits, read)) = build_time_values::evaluate_located( + &transformed_code, + filename, + &option, + resolver, + &imported_constants::Unknown::default(), + ) + .map_err(|errors| { + let (source, earlier_edits) = evaluated.unwrap_or((code, &[])); + errors + .into_iter() + .map(|(offset, message)| { + let offset = [alias_edits.as_slice(), namespace_edits.as_slice()] + .into_iter() + .chain(earlier_edits.iter().copied()) + .fold(offset, |offset, edits| { + import_alias_visit::source_offset(edits, offset) + }); + format!( + "{}: {message}. Fix: use a literal or a CSS variable", + locate(filename, source, offset) + ) + }) + .collect::>() + .join("\n") + })? + { + let (computed, number_edits) = if namespace_edits.is_empty() { + (std::borrow::Cow::Borrowed(computed.as_str()), Vec::new()) + } else { + import_alias_visit::pixelify_emotion_values(&computed, filename, &namespace_code) + }; + let (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((source, &layers)), + Some(&calls), + option, + source_map, + resolver, + )?; + let mut files: std::collections::BTreeSet = + output.dependencies.into_iter().collect(); + files.extend(read); + output.dependencies = files.into_iter().collect(); + return Ok(output); + } // Step 3: Handle vanilla-extract style files (.css.ts, .css.js) // `processed_code` is Some only when vanilla-extract generation succeeded; // otherwise the untouched `transformed_code` is parsed directly (no copy). - let processed_code: Option = if utils::is_vanilla_extract_file(filename) { - // Use transformed code (with imports already pointing to @devup-ui/react) - match vanilla_extract::execute_stylesheet(code, filename, &option, resolver) { + let mut own_rules = FxHashMap::default(); + let processed_code: Option = if utils::is_vanilla_extract_file(filename) + && stylesheet_policy::plan(&transformed_code, filename, &option, resolver, &|_| false) + == stylesheet_policy::Plan::Run + { + let (source, earlier_edits) = evaluated.unwrap_or((code, &[])); + // Instrument the authored APIs before aliasing can change declaration units. + let stylesheet = vanilla_extract::Stylesheet { + filename, + code, + source, + edits: earlier_edits, + }; + let executed = match values_run { + Some(calls) => vanilla_extract::execute_evaluated( + vanilla_extract::Evaluation { + stylesheet, + calls: Some(calls), + }, + &option, + resolver, + ), + None => vanilla_extract::execute_located(stylesheet, &option, resolver), + }; + match executed { Ok((collected, imports)) => { + own_rules = collected + .styles + .iter() + .filter_map(|(name, entry)| { + entry.own_plan.clone().map(|plan| (name.clone(), plan)) + }) + .collect(); dependencies = imports.dependencies; // Keyframes names are generated, so extract the referenced ones // first and substitute their names into the styles using them. @@ -539,26 +686,7 @@ fn extract_source( filename, )) } - // A stylesheet another one imports must give its own values, and an - // import cycle read too early fails as it does in ES modules, so both - // are reported rather than hidden behind plain extraction - Err(error) if error.starts_with(dead_properties::ERROR_CHANNEL) => { - return Err(error - .trim_start_matches(dead_properties::ERROR_CHANNEL) - .into()); - } - Err(error) - if module_loader::loading_for_stylesheet() - || error.contains(module_loader::IMPORT_CYCLE) => - { - return Err(error.into()); - } - // Plain extraction still compiles Devup UI's own APIs; the error is - // reported when calls it cannot compile remain - Err(error) => { - evaluation_error = Some(error); - None - } + Err(error) => return Err(error.into()), } } else { None @@ -576,6 +704,11 @@ fn extract_source( let code_to_parse = processed_code.as_deref().unwrap_or(&transformed_code); + let source_type = parsing::source_type(filename).map_err(|error| { + format!( + "{filename}:1:1: {error}. Fix: use a supported JavaScript or TypeScript file extension" + ) + })?; let (bucket, global, css_file) = resolve_css_target(filename, &option); let import_main = option.import_main_css && !global; // Presize to the exact final length (1 target + optional main-css entry) and @@ -586,9 +719,19 @@ fn extract_source( css_files.push(main_css_path(&option.css_dir)); } css_files.push(css_file.clone()); - let allocator = Allocator::default(); - - let mut program = parsing::parse(&allocator, filename, code_to_parse)?; + let mut program = if let Some(program) = original_program.filter(|_| code_to_parse == code) { + program + } else { + parsing::checked(&allocator, code_to_parse, source_type).map_err(|errors| { + let (source, earlier_edits) = evaluated.unwrap_or((code, &[])); + let edits: Vec<&[import_alias_visit::Edit]> = + [alias_edits.as_slice(), namespace_edits.as_slice()] + .into_iter() + .chain(earlier_edits.iter().copied()) + .collect(); + located_errors(filename, source, &edits, errors) + })? + }; let header_pragmas = jsx_pragmas::HeaderPragmas::capture(&program); let styled_capability = extractor::styled_capability::StyledCapability::reserve(&program); let inlined = if processed_code.is_none() { @@ -636,15 +779,17 @@ fn extract_source( ); visitor.import_stylex(inlined.stylex_vars, inlined.stylex_themes); visitor.import_css(inlined.css_styles); - if let Some(scoping) = inlined.scoping { - visitor.reuse_scoping(scoping); - } visitor.unknown_bindings(&inlined.unknown); visitor.changed_bindings(inlined.changed.clone()); visitor.takes_css_prop(css_prop); + visitor.reuse_scoping(inlined.scoping); + visitor.own_rules(own_rules, &program); visitor.import_styled(imported.components); visitor.reserve_styled(styled_capability); visitor.visit_program(&mut program); + if !visitor.own_failures.is_empty() { + return Err(visitor.own_failures.join("\n").into()); + } if !has_relevant_import && alias_edits.is_empty() && namespace_edits.is_empty() @@ -653,11 +798,6 @@ fn extract_source( // No element took the `css` prop the text seemed to give return Ok(unchanged()); } - if let Some(error) = evaluation_error - && imports_uncompiled(&program, &option.package) - { - return Err(error.into()); - } // Run the code a value computes, or tell rules the module computes from a // class it composes if (!visitor.errors.is_empty() || visitor.composes_unknown) @@ -667,13 +807,32 @@ fn extract_source( .any(|(_, error)| dead_properties::terminal_error(error)) && values_run.is_none() && !utils::is_vanilla_extract_file(filename) - && let Some((computed, value_edits, read)) = build_time_values::evaluate( + && let Some((computed, value_edits, read)) = build_time_values::evaluate_located( &transformed_code, filename, &option, resolver, &inlined.unknown, ) + .map_err(|errors| { + let (source, earlier_edits) = evaluated.unwrap_or((code, &[])); + errors + .into_iter() + .map(|(offset, message)| { + let offset = [alias_edits.as_slice(), namespace_edits.as_slice()] + .into_iter() + .chain(earlier_edits.iter().copied()) + .fold(offset, |offset, edits| { + import_alias_visit::source_offset(edits, offset) + }); + format!( + "{}: {message}. Fix: use a literal or a CSS variable", + locate(filename, source, offset) + ) + }) + .collect::>() + .join("\n") + })? { let (computed, number_edits) = if namespace_edits.is_empty() { (std::borrow::Cow::Borrowed(computed.as_str()), Vec::new()) @@ -782,21 +941,6 @@ fn main_css_path(css_dir: &str) -> String { /// kept for parse/sourcemap. Global (shared-chunk) files are emitted like /// single-css: into devup-ui.css with prefix-less global naming, so styles /// shared across routes ship once. -/// Whether `program` still imports a value from `package` that extraction did -/// not compile away -fn imports_uncompiled(program: &oxc_ast::ast::Program<'_>, package: &str) -> bool { - program.body.iter().any(|statement| { - matches!(statement, oxc_ast::ast::Statement::ImportDeclaration(import) - if import.source.value == package - && !import.import_kind.is_type() - && import.specifiers.iter().flatten().any(|specifier| matches!( - specifier, - oxc_ast::ast::ImportDeclarationSpecifier::ImportSpecifier(named) - if !named.import_kind.is_type() - ))) - }) -} - /// `errors` in source order, one per line, each led by `filename:line:column` /// of the code it is about; each layer of `edits`, last made first, maps the /// offsets back to `source` @@ -829,12 +973,23 @@ fn located_errors( /// `filename:line:column` of `offset` in `source` fn locate(filename: &str, source: &str, offset: usize) -> String { let before = source.get(..offset).unwrap_or(source); - let line_start = before.rfind('\n').map_or(0, |index| index + 1); - format!( - "{filename}:{}:{}", - before.matches('\n').count() + 1, - before[line_start..].chars().count() + 1 - ) + let (mut line, mut column) = (1, 1); + let mut characters = before.chars().peekable(); + while let Some(character) = characters.next() { + match character { + '\r' => { + characters.next_if_eq(&'\n'); + line += 1; + column = 1; + } + '\n' | '\u{2028}' | '\u{2029}' => { + line += 1; + column = 1; + } + _ => column += 1, + } + } + format!("{filename}:{line}:{column}") } /// A line for each binding `message` names that code changes, telling where @@ -2307,7 +2462,7 @@ mod tests { extract( "test.tsx", r"import {Box as C} from '@devup-ui/core' - + ", ExtractOption { package: "@devup-ui/core".to_string(), @@ -5410,7 +5565,7 @@ import clsx from 'clsx' ) .unwrap_err() .to_string() - .starts_with("Unknown file extension") + .starts_with("test.wrong:1:1: Unknown file extension") ); reset_class_map(); @@ -5429,7 +5584,7 @@ import clsx from 'clsx' ) .unwrap_err() .to_string(), - "test.tsx:1:11: Expected `from` but found `string`" + "test.tsx:1:11: Cannot parse source: Expected `from` but found `string`. Fix: correct the syntax at this location" ); } @@ -8305,7 +8460,7 @@ export { c as Lib };"#, extract( "test.jsx", r"import {Box} from '@devup-ui/core' - + ", ExtractOption { package: "@devup-ui/core".to_string(), @@ -14570,7 +14725,8 @@ globalCss({ .map(|error| error.to_string()) .unwrap_or_default(); assert!( - error.starts_with("JS execution error: TypeError"), + error.starts_with("broken.css.ts:3:") + && error.contains(": JS execution error: TypeError"), "{error}" ); @@ -15944,7 +16100,7 @@ export const a = {element}" )] #[case( "stylex.props(on && s.red, on && s.red)", - "{ className: on ? \"color-0-red--255\" : on ? \"color-0-red--255\" : \"\" }" + "(_s0 = !!on, { className: on ? \"color-0-red--255\" : _s0 ? \"color-0-red--255\" : \"\" })" )] #[case( "stylex.props(s['blue'], s.hover)", @@ -21177,7 +21333,7 @@ import orange from './tokens'; import * as tokens from './tokens'; import named from './named'; import { handler } from './handler'; -import cjs, { COMPILED, FROM_REQUIRE, DESTRUCTURED, MUTATED, DYNAMIC } from './cjs-tokens'; +import cjs, { COMPILED, FROM_REQUIRE, DESTRUCTURED, MUTATED, DYNAMIC as CJS_DYNAMIC } from './cjs-tokens'; import cjsObject, { OBJ, OVERRIDDEN, LATER } from './cjs-object'; import twelve from './cjs-value'; import twice from './cjs-twice'; @@ -21193,7 +21349,7 @@ export const f = { PRIMARY, unused, SIZE: tokens.SIZE }; export const g =
; export const h = ; export const i = Devup['css']({ color: SIZE }); -export const l = ; +export const l = ; export const j = ; export const k = styled('div')({ color: SIZE });", ExtractOption { @@ -22724,7 +22880,6 @@ export const b = 2 ? 1 : 2} />;" .is_ok() ); for code in [ - "const Math = { max: () => 1 };\nexport const a = css({ width: Math.max(1, 2) });", "const Math = { PI: 3 };\nexport const a = css({ width: Math.PI, height: Math.max(1, 2) });", "import Math from './math';\nexport const a = css({ width: Math.max(1, 2) });", "export const a = (Math) => css({ width: Math.max(1, 2) });", @@ -22741,6 +22896,18 @@ export const b = 2 ? 1 : 2} />;" .unwrap_or_default(); assert!(message.contains("Math."), "{code}: {message}"); } + reset_class_map(); + reset_file_map(); + let computed = extract( + "test.tsx", + "import { css } from '@devup-ui/react';\nconst Math = { max: () => 1 };\nexport const a = css({ width: Math.max(1, 2) });", + ExtractOption::default(), + ) + .unwrap(); + assert_eq!( + static_values(&computed), + BTreeSet::from([("width".to_string(), "4px".to_string())]) + ); } #[test] @@ -23624,7 +23791,7 @@ export const App = () => <>
;" + "export const App = ({ c }) => <>
;" ), ( "test.tsx", diff --git a/libs/extractor/src/module_loader.rs b/libs/extractor/src/module_loader.rs index ed0e664c5..27ddf384d 100644 --- a/libs/extractor/src/module_loader.rs +++ b/libs/extractor/src/module_loader.rs @@ -1,6 +1,7 @@ use std::cell::RefCell; use std::collections::BTreeSet; use std::fmt::Write; +use std::rc::Rc; use oxc_allocator::Allocator; use oxc_ast::AstKind; @@ -10,38 +11,27 @@ use oxc_ast::ast::{ }; use oxc_parser::Parser; use oxc_semantic::SemanticBuilder; -use oxc_span::{GetSpan, SourceType}; -use oxc_syntax::symbol::SymbolId; +use oxc_span::{GetSpan, SourceType, Span}; use rustc_hash::{FxHashMap, FxHashSet}; use crate::{ExtractOption, ModuleResolver, utils::is_vanilla_extract_file}; +mod import_bindings; +pub(crate) mod operations; +mod retained_css; +mod script; +#[cfg(test)] +mod tests; +mod validate; + +use import_bindings::{BindingSource, ImportBindings}; +use retained_css::{CssImport, RetainedCss}; +pub(crate) use script::{Body, Origin, SCRIPT_PATH, Script, Unit}; +pub(crate) use validate::validate; + /// The object the package's API is bound to while a stylesheet runs pub(crate) const PACKAGE_BINDING: &str = "__vanilla_extract__"; -/// A console whose calls do nothing, as what a module logs while it runs -/// changes no value -/// Run before a stylesheet: what differs between builds fails the evaluation, -/// so a stylesheet never gives different rules for the same source. `new -/// Date(0)` and other fixed dates stay. -pub(crate) const DETERMINISM: &str = r#"(() => { - const refuse = (name) => () => { throw new Error(`${name} gives a different value each build: a stylesheet cannot read it`); }; - Math.random = refuse("Math.random"); - const RealDate = Date; - globalThis.Date = new Proxy(RealDate, { - construct: (target, args, newTarget) => args.length === 0 ? refuse("new Date()")() : Reflect.construct(target, args, newTarget), - apply: refuse("Date()"), - get: (target, key, receiver) => key === "now" ? refuse("Date.now") : Reflect.get(target, key, receiver), - }); -})(); -"#; - -pub(crate) const CONSOLE: &str = "if (typeof console === \"undefined\") globalThis.console = new Proxy({}, { get: () => () => undefined });\n"; - -/// Loop iterations an evaluation may run before it fails instead of hanging -/// the build -pub(crate) const LOOP_ITERATION_LIMIT: u64 = 10_000_000; - /// The end of the error a module read before its evaluation throws pub(crate) const IMPORT_CYCLE: &str = "before its initialization: it is part of an import cycle"; @@ -85,28 +75,70 @@ impl Drop for Evaluating { } } -/// Whether a stylesheet is being evaluated, so the one extracted now is loaded -/// by it and must not fall back to plain extraction -pub(crate) fn loading_for_stylesheet() -> bool { - EVALUATING.with_borrow(|stack| !stack.is_empty()) -} - /// Whether the stylesheet evaluated now is one another evaluation imports pub(crate) fn evaluating_import() -> bool { EVALUATING.with_borrow(|stack| stack.len() > 1) } +#[cfg(test)] +pub(crate) fn loading_for_stylesheet() -> bool { + EVALUATING.with_borrow(|stack| !stack.is_empty()) +} + fn is_evaluating(filename: &str) -> bool { EVALUATING.with_borrow(|stack| stack.iter().any(|entry| entry == filename)) } +/// Why a module could not be loaded +enum LoadError { + NoResolver, + Unresolved, + CssPath(retained_css::PathError), + /// The module's own error, told where it is already + Failed(String), +} + +impl LoadError { + /// The error told for the import of `specifier` by `importer` at the place + /// `place` gives + fn describe(self, specifier: &str, importer: &str, place: impl FnOnce() -> String) -> String { + match self { + Self::NoResolver | Self::Unresolved if retained_css::is_css(specifier) => format!( + "{}: Cannot resolve CSS '{specifier}' from '{importer}': the stylesheet loads CSS through this helper and the file cannot be found. Fix: correct the path or import CSS from a component module; after creating a missing file, re-save the stylesheet to retry", + place() + ), + Self::NoResolver => format!( + "{}: Cannot load '{specifier}' without a module resolver. Fix: build through a Devup UI plugin, which resolves imports, or write what '{specifier}' provides in this file", + place() + ), + Self::Unresolved => format!( + "{}: Cannot resolve '{specifier}' from '{importer}'. Fix: check that '{specifier}' exists with a supported extension and that the bundler's resolver can find it from this file", + place() + ), + Self::Failed(error) => error, + Self::CssPath(error) => format!("{}: {}", place(), error.describe()), + } + } +} + +/// A module's definition, as a part of the script +enum Definition { + Generated(String), + Module { + head: String, + body: String, + tail: String, + origin: Rc, + }, +} + /// Loads what a stylesheet imports, each module once, as JavaScript that /// defines the module's exports object ahead of the stylesheet pub(crate) struct ModuleLoader<'r> { resolver: Option<&'r ModuleResolver>, option: &'r ExtractOption, /// Definitions of the loaded modules, each after the ones it uses - definitions: Vec, + definitions: Vec, loaded: FxHashMap, /// `(path, name)` of the modules being defined, outermost first loading: Vec<(String, String)>, @@ -114,6 +146,8 @@ pub(crate) struct ModuleLoader<'r> { /// bindings are read when used, as ES modules read an import cycle pending: FxHashSet, next_module: usize, + retained_css: RetainedCss, + css_bindings: FxHashMap, /// Every file read, including those the loaded stylesheets read pub dependencies: BTreeSet, /// What the stylesheet imports for its side effects, and the stylesheets it @@ -122,6 +156,22 @@ pub(crate) struct ModuleLoader<'r> { } impl<'r> ModuleLoader<'r> { + /// Instantiates CSS objects without inventing any of their exports. + pub(crate) fn prepare_css( + &self, + context: &mut boa_engine::Context, + ) -> boa_engine::JsResult<()> { + for (name, file) in &self.css_bindings { + let object = crate::evaluation_sandbox::css_object(context, file)?; + context.register_global_property( + boa_engine::JsString::from(name.as_str()), + object, + boa_engine::property::Attribute::empty(), + )?; + } + Ok(()) + } + pub(crate) fn new(resolver: Option<&'r ModuleResolver>, option: &'r ExtractOption) -> Self { Self { resolver, @@ -131,6 +181,8 @@ impl<'r> ModuleLoader<'r> { loading: Vec::new(), pending: FxHashSet::default(), next_module: 0, + retained_css: RetainedCss::default(), + css_bindings: FxHashMap::default(), dependencies: BTreeSet::new(), kept_imports: Vec::new(), } @@ -142,18 +194,68 @@ impl<'r> ModuleLoader<'r> { } } - /// The loaded modules' definitions, to run before the stylesheet - pub(crate) fn prelude(&self) -> String { - self.definitions.concat() + fn keep_entry_import(&mut self, specifier: &str) { + if retained_css::is_generated(specifier, self.option) + || !retained_css::is_css(specifier) + || self.retained_css.keep_root(specifier, self.resolver) + { + self.keep_import(specifier); + } + } + + /// The script to run: the loaded modules' definitions, then the `entry` + /// stylesheet + pub(crate) fn script(&self, entry: &ModuleScript) -> Script { + let mut script = Script::default(); + for definition in &self.definitions { + match definition { + Definition::Generated(text) => script.generated(text), + Definition::Module { + head, + body, + tail, + origin, + } => { + script.generated(head); + script.body(body, origin); + script.generated(tail); + } + } + } + script.body(&entry.body, &entry.origin); + script } /// The name of the exports object of `specifier` imported by `importer` - fn load(&mut self, specifier: &str, importer: &str, direct: bool) -> Result { - let resolver = self - .resolver - .ok_or_else(|| format!("Cannot load '{specifier}' without a module resolver"))?; - let module = resolver(specifier, importer) - .ok_or_else(|| format!("Cannot resolve '{specifier}' from '{importer}'"))?; + fn load(&mut self, specifier: &str, importer: &str, direct: bool) -> Result { + let resolver = self.resolver.ok_or(LoadError::NoResolver)?; + let module = resolver(specifier, importer).ok_or(LoadError::Unresolved)?; + if retained_css::is_css(&module.path) { + if let Some(kept) = self + .retained_css + .retain( + CssImport { + specifier, + path: &module.path, + direct, + }, + resolver, + &mut self.kept_imports, + ) + .map_err(LoadError::CssPath)? + { + self.keep_import(&kept); + } + if let Some(name) = self.loaded.get(&module.path) { + return Ok(name.clone()); + } + let name = format!("__css_{}__", self.next_module); + self.next_module += 1; + self.css_bindings.insert(name.clone(), module.path.clone()); + self.pending.insert(name.clone()); + self.loaded.insert(module.path, name.clone()); + return Ok(name); + } let stylesheet = is_vanilla_extract_file(&module.path); if direct && stylesheet { self.keep_import(specifier); @@ -171,12 +273,13 @@ impl<'r> ModuleLoader<'r> { // Created before the modules it imports, so a cycle among them can // reach it; reading it before it starts evaluating is an error if self.definitions.is_empty() { - self.definitions.push(MODULE_HELPER.to_string()); + self.definitions + .push(Definition::Generated(MODULE_HELPER.to_string())); } - self.definitions.push(format!( + self.definitions.push(Definition::Generated(format!( "const {name}$ = __module__({:?});\nconst {name} = {name}$.module;\n", module.path - )); + ))); if is_evaluating(&module.path) { // The stylesheet importing it is evaluated on its own, so it never // starts here @@ -188,7 +291,7 @@ impl<'r> ModuleLoader<'r> { self.loading.push((module.path.clone(), name.clone())); let defined = self.define(&name, module, stylesheet, resolver); let (path, _) = self.loading.pop().unwrap_or_default(); - defined?; + defined.map_err(LoadError::Failed)?; self.loaded.insert(path, name.clone()); Ok(name) } @@ -200,27 +303,45 @@ impl<'r> ModuleLoader<'r> { stylesheet: bool, resolver: &ModuleResolver, ) -> Result<(), String> { - let code = if stylesheet { + validate(crate::vanilla_extract::Stylesheet { + filename: &module.path, + code: &module.code, + source: &module.code, + edits: &[], + })?; + let unit = if stylesheet { // Extracted the way the bundler extracts it, so the names it // exports are the ones its own CSS uses let output = crate::extract_with_source_map( &module.path, &module.code, self.option.clone(), - false, + true, Some(resolver), ) .map_err(|error| error.to_string())?; + let unit = Unit::retained(&module.path, &output, &module.code)?; self.dependencies.extend(output.dependencies); - output.code + unit } else { - module.code + let input = crate::vanilla_extract::Stylesheet { + filename: &module.path, + code: &module.code, + source: &module.code, + edits: &[], + }; + let instrumented = + crate::dead_properties::instrument_located(input, &self.option.package)?; + Unit::instrumented( + crate::vanilla_extract::Stylesheet { + code: &instrumented.code, + edits: &[&instrumented.edits], + ..input + }, + |offset| instrumented.generated(offset), + )? }; - let instrumented = - crate::dead_properties::instrument(&code, &module.path, &self.option.package) - .map_err(|error| format!("{}{error}", crate::dead_properties::ERROR_CHANNEL))?; - let script = crate::vanilla_extract::strip_typescript(&instrumented.code, &module.path); - let module_script = module_script(&script, &module.path, self, false)?; + let module_script = module_script(&unit, self, false)?; // Live bindings: a read before the binding is initialized fails as it // does in an ES module let getters: Vec = module_script @@ -234,10 +355,16 @@ impl<'r> ModuleLoader<'r> { // Its exports are what `module.exports` holds once it ran; the // default follows bundler interop (`__esModule` marks a compiled // ES module) - self.definitions.push(format!( - "(function () {{\n{name}$.start();\nconst module = {{ exports: {{}} }};\nconst exports = module.exports;\n{}\nconst e = module.exports;\nObject.defineProperty({name}, \"__exports__\", {{ value: e }});\nif (e !== null && (typeof e === \"object\" || typeof e === \"function\")) for (const key of Object.keys(e)) if (key !== \"default\") Object.defineProperty({name}, key, {{ get: () => e[key], enumerable: true }});\nObject.defineProperty({name}, \"default\", {{ value: e !== null && typeof e === \"object\" && e.__esModule ? e.default : e, enumerable: true }});\n}})();\n", - module_script.body, - )); + self.definitions.push(Definition::Module { + head: format!( + "(function () {{\n{name}$.start();\nconst module = {{ exports: {{}} }};\nconst exports = module.exports;\n" + ), + body: module_script.body, + tail: format!( + "\nconst e = module.exports;\nObject.defineProperty({name}, \"__exports__\", {{ value: e }});\nif (e !== null && (typeof e === \"object\" || typeof e === \"function\")) for (const key of Object.keys(e)) if (key !== \"default\") Object.defineProperty({name}, key, {{ get: () => e[key], enumerable: true }});\nObject.defineProperty({name}, \"default\", {{ value: e !== null && typeof e === \"object\" && e.__esModule ? e.default : e, enumerable: true }});\n}})();\n" + ), + origin: module_script.origin, + }); return Ok(()); } let mut spreads = String::new(); @@ -247,11 +374,15 @@ impl<'r> ModuleLoader<'r> { "for (const key of Object.keys({spread})) if (key !== \"default\" && !(key in {name})) Object.defineProperty({name}, key, {{ get: () => {spread}[key], enumerable: true }});" ); } - self.definitions.push(format!( - "(function () {{\n{name}$.start();\nObject.defineProperties({name}, {{ {} }});\n{spreads}{}\n}})();\n", - getters.join(", "), - module_script.body, - )); + self.definitions.push(Definition::Module { + head: format!( + "(function () {{\n{name}$.start();\nObject.defineProperties({name}, {{ {} }});\n{spreads}", + getters.join(", ") + ), + body: module_script.body, + tail: "\n})();\n".to_string(), + origin: module_script.origin, + }); Ok(()) } } @@ -260,6 +391,8 @@ impl<'r> ModuleLoader<'r> { /// keywords dropped pub(crate) struct ModuleScript { pub body: String, + /// What `body` was made of, to tell where its code was written + pub origin: Rc, /// `(exported name, expression)` exports: Vec<(String, String)>, /// Modules re-exported whole @@ -268,27 +401,69 @@ pub(crate) struct ModuleScript { commonjs: bool, } +/// The replacements of a module's code, applied as its statements are copied +struct Rewrites<'s> { + script: &'s str, + /// `(start, end, replacement)`, by start + list: Vec<(u32, u32, String)>, +} + +impl Rewrites<'_> { + /// Copies the text of `span` into `body`, replacements made + fn write(&self, body: &mut Body, span: Span) { + let mut copied = span.start as usize; + for (start, end, replacement) in &self.list { + if *start >= span.start && *end <= span.end { + body.copy(self.script, copied, *start as usize); + body.synthesize(*start as usize, replacement); + copied = *end as usize; + } + } + body.copy(self.script, copied, span.end as usize); + } +} + pub(crate) fn module_script( - script: &str, - filename: &str, + unit: &Rc, loader: &mut ModuleLoader<'_>, entry: bool, ) -> Result { + let (script, filename) = (unit.script(), unit.filename()); + if entry { + loader.retained_css.root = filename.to_string(); + } + let load_module = |loader: &mut ModuleLoader<'_>, specifier: &str, at: u32| { + loader + .load(specifier, filename, entry) + .map_err(|error| error.describe(specifier, filename, || unit.place(at as usize))) + }; let allocator = Allocator::default(); - let program = Parser::new(&allocator, script, SourceType::mjs()) - .parse() - .program; - let semantic = SemanticBuilder::new() + let parsed = Parser::new(&allocator, script, SourceType::mjs()).parse(); + let program = parsed.program; + let built = SemanticBuilder::new() .with_build_nodes(true) - .build(&program) - .semantic; + .with_check_syntax_error(true) + .build(&program); + if let Some(error) = parsed + .diagnostics + .iter() + .chain(built.diagnostics.iter()) + .next() + { + return Err(format!( + "{}: JS execution error: SyntaxError: {error}. Fix: correct the syntax at this location", + unit.place(error.labels.first().map_or(0, |label| { + usize::try_from(label.offset()).unwrap_or(script.len()) + })) + )); + } + let semantic = built.semantic; let package = loader.option.package.clone(); // Imports of a module still evaluating are read where they are used, as ES // modules read an import cycle let mut modules: FxHashMap = FxHashMap::default(); - let mut lazy: FxHashMap = FxHashMap::default(); - let mut lazy_names: FxHashMap = FxHashMap::default(); + let mut bindings = ImportBindings::default(); for statement in &program.body { let Statement::ImportDeclaration(import) = statement else { continue; @@ -296,55 +471,32 @@ pub(crate) fn module_script( let source = import.source.value.as_str(); let specifiers = import.specifiers.as_deref().map_or(&[][..], |s| s); if specifiers.is_empty() { - if entry && !source.starts_with(package.as_str()) { - loader.keep_import(source); + if !crate::package_specifier::is_package(source, &package) + && (entry || !retained_css::is_generated(source, loader.option)) + { + if entry { + loader.keep_entry_import(source); + } else { + load_module(loader, source, import.source.span.start)?; + } } continue; } let module = if source == package || source == "@vanilla-extract/css" { PACKAGE_BINDING.to_string() } else { - loader.load(source, filename, entry)? + load_module(loader, source, import.source.span.start)? }; if loader.pending.contains(&module) { - let lazy_module = &module; - for specifier in specifiers { - let binding = match specifier { - ImportDeclarationSpecifier::ImportSpecifier(specifier) => { - format!("{lazy_module}[{:?}]", specifier.imported.name()) - } - ImportDeclarationSpecifier::ImportDefaultSpecifier(_) => { - format!("{lazy_module}[\"default\"]") - } - ImportDeclarationSpecifier::ImportNamespaceSpecifier(_) => module.clone(), - }; - let local = specifier.local(); - if let Some(symbol) = local.symbol_id.get() { - lazy.insert(symbol, binding.clone()); - } - lazy_names.insert(local.name.to_string(), binding); - } - } - modules.insert(import.span.start, module); - } - let mut replacements: Vec<(u32, u32, String)> = Vec::new(); - for (symbol, binding) in &lazy { - for reference in semantic.scoping().get_resolved_reference_ids(*symbol) { - let node = semantic.scoping().get_reference(*reference).node_id(); - let span = semantic.nodes().kind(node).span(); - let replacement = match semantic.nodes().parent_kind(node) { - AstKind::ObjectProperty(property) if property.shorthand => { - format!( - "{}: {binding}", - &script[span.start as usize..span.end as usize] - ) - } - AstKind::ExportSpecifier(_) => continue, - _ => binding.clone(), + let source = match loader.css_bindings.get(&module) { + Some(_) => BindingSource::Css(&module), + None => BindingSource::Module(&module), }; - replacements.push((span.start, span.end, replacement)); + bindings.link(specifiers, source); } + modules.insert(import.span.start, module); } + let mut replacements = bindings.rewrites(&semantic, script); // CommonJS: `require` of a literal path loads the module like an import let commonjs = !program.body.iter().any(Statement::is_module_declaration) && semantic @@ -363,34 +515,27 @@ pub(crate) fn module_script( if let AstKind::CallExpression(call) = semantic.nodes().parent_kind(node) && let [Argument::StringLiteral(specifier)] = call.arguments.as_slice() { - let module = loader.load(specifier.value.as_str(), filename, entry)?; - replacements.push(( - call.span.start, - call.span.end, - format!("(\"__exports__\" in {module} ? {module}.__exports__ : {module})"), - )); + let module = load_module(loader, specifier.value.as_str(), specifier.span.start)?; + let replacement = if loader.css_bindings.contains_key(&module) { + module + } else { + format!("(\"__exports__\" in {module} ? {module}.__exports__ : {module})") + }; + replacements.push((call.span.start, call.span.end, replacement)); } } } replacements.sort_by_key(|(start, ..)| *start); - let text = |span: oxc_span::Span| { - let mut code = String::new(); - let mut copied = span.start; - for (start, end, replacement) in &replacements { - if *start >= span.start && *end <= span.end { - code.push_str(&script[copied as usize..*start as usize]); - code.push_str(replacement); - copied = *end; - } - } - code.push_str(&script[copied as usize..span.end as usize]); - code + let rewrites = Rewrites { + script, + list: replacements, }; - let mut body = String::with_capacity(script.len()); + let mut body = Body::new(script.len()); let mut exports = Vec::new(); let mut spreads = Vec::new(); for statement in &program.body { + let end = statement.span().end as usize; match statement { Statement::ImportDeclaration(import) => { let Some(module) = modules.get(&import.span.start) else { @@ -399,6 +544,7 @@ pub(crate) fn module_script( if loader.pending.contains(module) { continue; } + let at = import.span.start as usize; let mut named = Vec::new(); for specifier in import.specifiers.iter().flatten() { match specifier { @@ -409,17 +555,23 @@ pub(crate) fn module_script( named.push(format!("\"default\": {}", specifier.local.name)); } ImportDeclarationSpecifier::ImportNamespaceSpecifier(specifier) => { - let _ = writeln!(body, "const {} = {module};", specifier.local.name); + body.synthesize( + at, + &format!("const {} = {module};\n", specifier.local.name), + ); } } } if !named.is_empty() { - let _ = writeln!(body, "const {{ {} }} = {module};", named.join(", ")); + body.synthesize( + at, + &format!("const {{ {} }} = {module};\n", named.join(", ")), + ); } } Statement::ExportDeclaration(export) => { - body.push_str(&text(export.declaration.span())); - body.push('\n'); + rewrites.write(&mut body, export.declaration.span()); + body.synthesize(end, "\n"); for name in declared_names(&export.declaration) { exports.push((name.clone(), name)); } @@ -427,14 +579,15 @@ pub(crate) fn module_script( Statement::ExportNamedDeclaration(export) => { for specifier in &export.specifiers { let local = export_name(&specifier.local); - exports.push(( - export_name(&specifier.exported), - lazy_names.get(&local).cloned().unwrap_or(local), - )); + exports.push((export_name(&specifier.exported), bindings.exported(local))); } } Statement::ExportFromDeclaration(export) => { - let module = loader.load(export.source.value.as_str(), filename, entry)?; + let module = load_module( + loader, + export.source.value.as_str(), + export.source.span.start, + )?; for specifier in &export.specifiers { exports.push(( export_name(&specifier.exported), @@ -450,32 +603,40 @@ pub(crate) fn module_script( ExportDefaultDeclarationKind::ClassDeclaration(class) => class.id.as_ref(), _ => None, }; - let declaration = text(export.declaration.span()); + let declaration = export.declaration.span(); let local = if let Some(id) = id { - body.push_str(&declaration); + rewrites.write(&mut body, declaration); id.name.to_string() } else { - let _ = write!(body, "const __default__ = ({declaration});"); + body.synthesize(declaration.start as usize, "const __default__ = ("); + rewrites.write(&mut body, declaration); + body.synthesize(declaration.end as usize, ");"); "__default__".to_string() }; - body.push('\n'); + body.synthesize(end, "\n"); exports.push(("default".to_string(), local)); } Statement::ExportAllDeclaration(export) => { - let module = loader.load(export.source.value.as_str(), filename, entry)?; + let module = load_module( + loader, + export.source.value.as_str(), + export.source.span.start, + )?; match &export.exported { Some(exported) => exports.push((export_name(exported), module)), None => spreads.push(module), } } statement => { - body.push_str(&text(statement.span())); - body.push('\n'); + rewrites.write(&mut body, statement.span()); + body.synthesize(end, "\n"); } } } + let (body, origin) = body.finish(unit); Ok(ModuleScript { body, + origin, exports, spreads, commonjs, @@ -500,3 +661,6 @@ pub(crate) fn declared_names(declaration: &Declaration<'_>) -> Vec { .collect(), } } + +#[cfg(test)] +mod coverage_tests; diff --git a/libs/extractor/src/module_loader/coverage_tests.rs b/libs/extractor/src/module_loader/coverage_tests.rs new file mode 100644 index 000000000..eed95613f --- /dev/null +++ b/libs/extractor/src/module_loader/coverage_tests.rs @@ -0,0 +1,65 @@ +use rstest::rstest; + +use super::{ModuleLoader, Unit, module_script}; +use crate::ExtractOption; + +#[test] +fn loaded_module_bodies_execute_before_the_importing_entry() -> Result<(), String> { + // Given + let resolver = |specifier: &str, _: &str| { + (specifier == "./tokens").then(|| crate::ResolvedModule { + path: "tokens.ts".to_string(), + code: "export const color = 'red';".to_string(), + }) + }; + let option = ExtractOption::default(); + let mut loader = ModuleLoader::new(Some(&resolver), &option); + let code = "import { color } from './tokens'; const result = color;"; + let unit = Unit::written("entry.ts", code, code, &[])?; + let entry = module_script(&unit, &mut loader, true)?; + let script = loader.script(&entry); + let mut context = boa_engine::Context::default(); + // When + let value = context + .eval(boa_engine::Source::from_bytes(&format!( + "{}\nresult", + script.text + ))) + .map_err(|error| error.to_string())?; + // Then + assert_eq!( + value + .to_string(&mut context) + .map_err(|error| error.to_string())? + .to_std_string_escaped(), + "red" + ); + assert_eq!( + loader.dependencies.into_iter().collect::>(), + ["tokens.ts"] + ); + Ok(()) +} + +#[rstest] +#[case("return 1;", "SyntaxError")] +#[case("const a = 1; const a = 2;", "SyntaxError")] +fn module_script_rejects_generated_parse_and_semantic_errors_with_file_fallback( + #[case] code: &str, + #[case] category: &str, +) -> Result<(), String> { + // Given + let unit = Unit::generated("invalid.ts", code); + let option = ExtractOption::default(); + let mut loader = ModuleLoader::new(None, &option); + // When + let error = module_script(&unit, &mut loader, true) + .err() + .ok_or("invalid generated module accepted")?; + // Then + assert!( + error.starts_with(&format!("invalid.ts:1:1: JS execution error: {category}:")), + "{error}" + ); + Ok(()) +} diff --git a/libs/extractor/src/module_loader/import_bindings.rs b/libs/extractor/src/module_loader/import_bindings.rs new file mode 100644 index 000000000..41a9950dd --- /dev/null +++ b/libs/extractor/src/module_loader/import_bindings.rs @@ -0,0 +1,83 @@ +//! Live import reads, including CSS exports whose values only the bundler knows. + +use oxc_ast::{AstKind, ast::ImportDeclarationSpecifier}; +use oxc_semantic::Semantic; +use oxc_span::GetSpan; +use oxc_syntax::symbol::SymbolId; +use rustc_hash::FxHashMap; + +pub(super) enum BindingSource<'a> { + Module(&'a str), + Css(&'a str), +} + +#[derive(Default)] +pub(super) struct ImportBindings { + symbols: FxHashMap, + names: FxHashMap, +} + +impl ImportBindings { + pub(super) fn link( + &mut self, + specifiers: &[ImportDeclarationSpecifier<'_>], + source: BindingSource<'_>, + ) { + for specifier in specifiers { + let binding = match &source { + BindingSource::Module(module) => match specifier { + ImportDeclarationSpecifier::ImportSpecifier(specifier) => { + format!("{module}[{:?}]", specifier.imported.name()) + } + ImportDeclarationSpecifier::ImportDefaultSpecifier(_) => { + format!("{module}[\"default\"]") + } + ImportDeclarationSpecifier::ImportNamespaceSpecifier(_) => module.to_string(), + }, + BindingSource::Css(module) => match specifier { + ImportDeclarationSpecifier::ImportSpecifier(specifier) => { + format!("{module}[{:?}]", specifier.imported.name()) + } + ImportDeclarationSpecifier::ImportDefaultSpecifier(_) + | ImportDeclarationSpecifier::ImportNamespaceSpecifier(_) => module.to_string(), + }, + }; + let local = specifier.local(); + self.names.insert(local.name.to_string(), binding.clone()); + if let Some(symbol) = local.symbol_id.get() { + self.symbols.insert(symbol, binding); + } + } + } + + pub(super) fn exported(&self, local: String) -> String { + self.names.get(&local).cloned().unwrap_or(local) + } + + pub(super) fn rewrites( + &self, + semantic: &Semantic<'_>, + script: &str, + ) -> Vec<(u32, u32, String)> { + let mut replacements = Vec::new(); + for (symbol, binding) in &self.symbols { + for reference in semantic.scoping().get_resolved_reference_ids(*symbol) { + let node = semantic.scoping().get_reference(*reference).node_id(); + let span = semantic.nodes().kind(node).span(); + let parent = semantic.nodes().parent_kind(node); + let expression = binding; + let replacement = match parent { + AstKind::ObjectProperty(property) if property.shorthand => format!( + "{}: {expression}", + &script[usize::try_from(span.start).unwrap_or(script.len()) + ..usize::try_from(span.end).unwrap_or(script.len())] + ), + AstKind::ExportSpecifier(_) => continue, + _ => expression.clone(), + }; + replacements.push((span.start, span.end, replacement)); + } + } + replacements + } +} diff --git a/libs/extractor/src/module_loader/operations.rs b/libs/extractor/src/module_loader/operations.rs new file mode 100644 index 000000000..6f425d262 --- /dev/null +++ b/libs/extractor/src/module_loader/operations.rs @@ -0,0 +1,205 @@ +//! Execution markers for iterable operations Boa can fail without a call frame. + +use std::{ + cell::RefCell, + hash::{Hash, Hasher}, +}; + +use boa_engine::{Context, JsArgs, JsResult, JsString, NativeFunction}; +use oxc_ast_visit::{Visit, walk}; +use oxc_span::{GetSpan, SourceType}; + +#[derive(Default)] +struct Active(RefCell>); + +struct Arrays<'s> { + source: &'s str, + helper: &'s str, + changes: Vec<(usize, String)>, + sites: Vec, +} + +impl<'a> Visit<'a> for Arrays<'_> { + fn visit_array_expression(&mut self, array: &oxc_ast::ast::ArrayExpression<'a>) { + let mut endings = Vec::new(); + if array + .elements + .iter() + .any(oxc_ast::ast::ArrayExpressionElement::is_spread) + { + let id = self.sites.len(); + self.sites.push(crate::locate( + super::SCRIPT_PATH, + self.source, + array.span.start as usize, + )); + self.changes.push(( + array.span.start as usize, + format!("({}({id}), {}({id}, ", self.helper, self.helper), + )); + endings.push((array.span.end as usize, "))".to_string())); + for element in &array.elements { + if let oxc_ast::ast::ArrayExpressionElement::SpreadElement(spread) = element { + let site = self.sites.len(); + self.sites.push(crate::locate( + super::SCRIPT_PATH, + self.source, + spread.span.start as usize, + )); + let argument = spread.argument.span(); + self.changes + .push((argument.start as usize, format!("{}({site}, ", self.helper))); + endings.push((argument.end as usize, ", true)".to_string())); + } + } + } + walk::walk_array_expression(self, array); + self.changes.extend(endings); + } +} + +pub(crate) struct Operations { + pub code: String, + edits: Vec, + source: String, + helper: String, + sites: Vec, +} + +impl Operations { + pub(crate) fn source_offset(&self, offset: usize) -> usize { + crate::import_alias_visit::source_offset(&self.edits, offset) + } + + pub(crate) fn new(source: &str) -> Self { + let mut hash = rustc_hash::FxHasher::default(); + source.hash(&mut hash); + let helper = crate::fresh_name::fresh_name( + &format!("__devup_operation_site_{:x}__", hash.finish()), + source, + ); + let allocator = oxc_allocator::Allocator::default(); + let parsed = oxc_parser::Parser::new(&allocator, source, SourceType::default()).parse(); + let mut arrays = Arrays { + source, + helper: &helper, + changes: Vec::new(), + sites: Vec::new(), + }; + arrays.visit_program(&parsed.program); + arrays.changes.sort_by_key(|(at, _)| *at); + let mut code = String::new(); + let mut edits = Vec::new(); + let mut copied = 0; + for (at, text) in arrays.changes { + code.push_str(&source[copied..at]); + code.push_str(&text); + edits.push((at, at, text.len())); + copied = at; + } + code.push_str(&source[copied..]); + let sites = arrays.sites; + Self { + code, + edits, + source: source.to_string(), + helper, + sites, + } + } + + pub(crate) fn prepare(&self, context: &mut Context) -> JsResult<()> { + context.insert_data(Active::default()); + context.register_global_builtin_callable( + JsString::from(self.helper.as_str()), + 2, + NativeFunction::from_copy_closure_with_captures( + |_, args, sites, context| { + let id = args.get_or_undefined(0).to_u32(context)?; + let index = usize::try_from(id).map_err(|error| { + boa_engine::JsNativeError::range().with_message(error.to_string()) + })?; + if let Some(active) = context.get_data::() { + let mut active = active.0.borrow_mut(); + if args.len() == 1 { + if let Some(site) = sites.get(index) { + active.push((id, site.clone(), site.clone())); + } + } else if args.len() == 3 { + if let Some((_, _, place)) = active.last_mut() + && let Some(site) = sites.get(index) + { + site.clone_into(place); + } + } else if let Some(index) = active + .iter() + .rposition(|(operation, _, _)| *operation == id) + { + active.truncate(index); + } + } + Ok(args.get_or_undefined(1).clone()) + }, + self.sites.clone(), + ), + ) + } + + /// Rebase engine/read frames, adding a currently executing operation only + /// when Boa supplied no script frame. A completed array has no active marker. + pub(crate) fn explain(&self, error: &str, context: &Context) -> String { + let marker = format!("({}:", super::SCRIPT_PATH); + let active = context + .get_data::() + .and_then(|active| active.0.borrow().last().cloned()); + let mut first_frame = None; + let mut explained = error + .lines() + .filter(|line| *line != format!(" at {} (native)", self.helper)) + .filter_map(|line| { + let line = if line.contains("at (") { + line.strip_suffix(" (native)").unwrap_or(line) + } else { + line + }; + let Some((prefix, position)) = line.rsplit_once(&marker) else { + return Some(line.to_string()); + }; + let Some((row, column)) = position.trim_end_matches(')').split_once(':') else { + return Some(line.to_string()); + }; + let (Ok(row), Ok(column)) = (row.parse::(), column.parse::()) else { + return Some(line.to_string()); + }; + let offset = crate::source_map::Lines::new(&self.code) + .code_point_offset(row.saturating_sub(1), column.saturating_sub(1)); + let mut inserted = 0; + let generated = self.edits.iter().any(|(at, _, length)| { + let start = at + inserted; + inserted += length; + (start..start + length).contains(&offset) + }); + let original = crate::import_alias_visit::source_offset(&self.edits, offset); + let place = crate::locate(super::SCRIPT_PATH, &self.source, original); + let operation = + generated || active.as_ref().is_some_and(|(_, array, _)| *array == place); + first_frame.get_or_insert(operation); + if operation && line.starts_with(" at ") { + return None; + } + Some(format!("{prefix}({place})")) + }) + .collect::>() + .join("\n"); + if first_frame.unwrap_or(true) + && let Some((_, _, site)) = active + { + let at = explained.find("\n at ").unwrap_or(explained.len()); + explained.insert_str(at, &format!("\n at ({site})")); + } + explained + } +} + +#[cfg(test)] +mod coverage_tests; diff --git a/libs/extractor/src/module_loader/operations/coverage_tests.rs b/libs/extractor/src/module_loader/operations/coverage_tests.rs new file mode 100644 index 000000000..8125573bd --- /dev/null +++ b/libs/extractor/src/module_loader/operations/coverage_tests.rs @@ -0,0 +1,37 @@ +use boa_engine::{Context, Source}; +use rstest::rstest; + +use super::Operations; + +#[rstest] +#[case("TypeError: iterator failed\n at eval (devup-ui-stylesheet:unknown)")] +#[case("TypeError: iterator failed\n at eval (devup-ui-stylesheet:x:y)")] +fn unlocated_engine_frames_survive_operation_rebasing(#[case] error: &str) { + // Given + let operations = Operations::new("[...[]]"); + // When / Then + assert_eq!(operations.explain(error, &Context::default()), error); +} + +#[test] +fn failed_spread_uses_operation_site_when_boa_has_no_script_frame() -> Result<(), String> { + // Given + let source = "[...null]"; + let operations = Operations::new(source); + let mut context = Context::default(); + operations + .prepare(&mut context) + .map_err(|error| error.to_string())?; + // When + let error = context + .eval(Source::from_bytes(&operations.code)) + .err() + .ok_or("invalid spread succeeded")?; + let explained = operations.explain(&error.to_string(), &context); + // Then + assert!( + explained.contains("at (devup-ui-stylesheet:1:2)"), + "{explained}" + ); + Ok(()) +} diff --git a/libs/extractor/src/module_loader/retained_css.rs b/libs/extractor/src/module_loader/retained_css.rs new file mode 100644 index 000000000..34b635d88 --- /dev/null +++ b/libs/extractor/src/module_loader/retained_css.rs @@ -0,0 +1,174 @@ +//! CSS loads survive removal of evaluated helpers without becoming content dependencies. + +use rustc_hash::FxHashSet; + +use crate::{ExtractOption, ModuleResolver}; + +pub(super) fn file_part(specifier: &str) -> &str { + specifier + .split_once('?') + .map_or(specifier, |(path, _)| path) +} + +pub(super) fn is_css(specifier: &str) -> bool { + file_part(specifier) + .rsplit_once('.') + .is_some_and(|(_, extension)| extension.eq_ignore_ascii_case("css")) +} + +pub(super) fn is_generated(specifier: &str, option: &ExtractOption) -> bool { + let Some(file) = file_part(specifier) + .strip_prefix(&option.css_dir) + .and_then(|suffix| suffix.strip_prefix('/')) + else { + return false; + }; + file == "devup-ui.css" + || file + .strip_prefix("devup-ui-") + .and_then(|suffix| suffix.strip_suffix(".css")) + .is_some_and(|number| { + !number.is_empty() && number.bytes().all(|byte| byte.is_ascii_digit()) + }) +} + +pub(super) struct CssImport<'a> { + pub specifier: &'a str, + pub path: &'a str, + pub direct: bool, +} + +#[derive(Debug)] +pub(super) struct PathError { + root: String, + target: String, +} + +impl PathError { + pub(super) fn describe(&self) -> String { + format!( + "Cannot retain CSS '{}' relative to '{}': the paths have different Windows drives or incompatible roots. Fix: keep the stylesheet and CSS on the same drive, or import CSS from a component module", + self.target, self.root + ) + } +} + +#[derive(Default)] +pub(super) struct RetainedCss { + pub root: String, + files: FxHashSet, + deferred_roots: Vec, + resolved: bool, +} + +impl RetainedCss { + pub(super) fn keep_root(&mut self, specifier: &str, resolver: Option<&ModuleResolver>) -> bool { + if self.resolved { + if let Some(module) = resolver.and_then(|resolve| resolve(specifier, &self.root)) { + return self.files.insert(identity(&module.path)); + } + } else { + self.deferred_roots.push(specifier.to_string()); + } + true + } + + pub(super) fn retain( + &mut self, + import: CssImport<'_>, + resolver: &ModuleResolver, + kept_imports: &mut Vec, + ) -> Result, PathError> { + // Root-only bare loads still belong to the bundler. Resolve their identities + // only when an evaluated closure needs resolved-file deduplication. + for specifier in self.deferred_roots.drain(..) { + if let Some(module) = resolver(&specifier, &self.root) + && !self.files.insert(identity(&module.path)) + { + kept_imports.retain(|kept| *kept != specifier); + } + } + self.resolved = true; + if !self.files.insert(identity(import.path)) { + return Ok(None); + } + if import.direct { + return Ok(Some(import.specifier.to_string())); + } + let relative = relative_specifier(&self.root, import.path)?; + let suffix = import + .specifier + .find('?') + .map_or("", |at| &import.specifier[at..]); + Ok(Some(format!("{relative}{suffix}"))) + } +} + +struct PortablePath { + volume: String, + segments: Vec, +} + +impl PortablePath { + fn parse(path: &str) -> Self { + let path = file_part(path).replace('\\', "/"); + let (volume, tail) = if path.as_bytes().get(1) == Some(&b':') { + (path[..2].to_string(), &path[2..]) + } else if path.starts_with('/') { + ("/".to_string(), path.as_str()) + } else { + (String::new(), path.as_str()) + }; + let mut segments = Vec::new(); + for segment in tail.split('/') { + match segment { + "" | "." => {} + ".." if segments.last().is_some_and(|last| last != "..") => { + segments.pop(); + } + ".." if !volume.is_empty() => {} + segment => segments.push(segment.to_string()), + } + } + Self { volume, segments } + } +} + +fn identity(path: &str) -> String { + let parsed = PortablePath::parse(path); + let path = format!("{}/{}", parsed.volume, parsed.segments.join("/")); + if parsed.volume.ends_with(':') { + path.to_ascii_lowercase() + } else { + path + } +} + +pub(super) fn relative_specifier(root: &str, target: &str) -> Result { + let mut root_path = PortablePath::parse(root); + root_path.segments.pop(); + let target_path = PortablePath::parse(target); + if !root_path.volume.eq_ignore_ascii_case(&target_path.volume) { + return Err(PathError { + root: root.to_string(), + target: target.to_string(), + }); + } + let windows = root_path.volume.ends_with(':'); + let common = root_path + .segments + .iter() + .zip(&target_path.segments) + .take_while(|(left, right)| { + if windows { + left.eq_ignore_ascii_case(right) + } else { + left == right + } + }) + .count(); + let parents = "../".repeat(root_path.segments.len() - common); + let tail = target_path.segments[common..].join("/"); + let prefix = if parents.is_empty() { "./" } else { &parents }; + Ok(format!("{prefix}{tail}")) +} diff --git a/libs/extractor/src/module_loader/script.rs b/libs/extractor/src/module_loader/script.rs new file mode 100644 index 000000000..7183449f0 --- /dev/null +++ b/libs/extractor/src/module_loader/script.rs @@ -0,0 +1,231 @@ +//! The script a stylesheet is evaluated as, and where each part of it was +//! written + +use std::rc::Rc; + +use crate::source_map::{Lines, Trace}; +#[cfg(test)] +use crate::vanilla_extract::strip_typescript; + +mod explain; +mod retained; +#[cfg(test)] +mod tests; +mod written; + +pub(crate) use explain::SCRIPT_PATH; + +/// A module as written, and how its script traces back to it +struct Written { + source: String, + trace: Trace, + retained: Option>, + instrumentation: Vec<(usize, bool)>, +} + +/// A module turned into a script: its TypeScript stripped +pub(crate) struct Unit { + filename: String, + script: String, + /// `None` for a module the build generated, which no author wrote + written: Option, +} + +impl Unit { + #[cfg(test)] + pub(crate) fn generated(filename: &str, code: &str) -> Rc { + Rc::new(Self { + filename: filename.to_string(), + script: strip_typescript(code, filename), + written: None, + }) + } + + pub(crate) fn filename(&self) -> &str { + &self.filename + } + + pub(crate) fn script(&self) -> &str { + &self.script + } + + /// `filename:line:column` of the byte `offset` of the script as written, + /// `None` where the build generated it + fn locate(&self, offset: usize) -> Option { + let written = self.written.as_ref()?; + if written.retained.as_ref().is_some_and(|ranges| { + !ranges + .iter() + .any(|(start, end)| (*start..*end).contains(&offset)) + }) { + return None; + } + let offset = written.trace.resolve(&self.script, &written.source, offset); + Some(crate::locate(&self.filename, &written.source, offset)) + } + + /// Where the byte `offset` of the script is, down to the file for generated + /// code + pub(crate) fn place(&self, offset: usize) -> String { + self.locate(offset) + .unwrap_or_else(|| format!("{}:1:1", self.filename)) + } +} + +/// A part of a module's body, up to the next one +struct Piece { + start: usize, + script: usize, + /// Whether the text is the script's, so a position in it moves with it + copied: bool, +} + +/// What a module's body was made of +pub(crate) struct Origin { + unit: Rc, + pieces: Vec, +} + +impl Origin { + fn script_offset(&self, offset: usize) -> usize { + let piece = &self.pieces[self.pieces.partition_point(|piece| piece.start <= offset) - 1]; + if piece.copied { + piece.script + offset - piece.start + } else { + piece.script + } + } + + fn locate(&self, offset: usize) -> Option { + self.unit.locate(self.script_offset(offset)) + } + + fn frame(&self, offset: usize) -> explain::FrameOrigin { + let script = self.script_offset(offset); + if self.unit.written.as_ref().is_some_and(|written| { + let at = written + .instrumentation + .partition_point(|(start, _)| *start <= script); + at.checked_sub(1) + .is_some_and(|at| written.instrumentation[at].1) + }) { + explain::FrameOrigin::Instrumentation + } else { + self.locate(offset).map_or( + explain::FrameOrigin::Generated, + explain::FrameOrigin::Authored, + ) + } + } +} + +/// The body of a module, built from its script and the code made to go with it +pub(crate) struct Body { + text: String, + pieces: Vec, +} + +impl Body { + pub(crate) fn new(capacity: usize) -> Self { + Self { + text: String::with_capacity(capacity), + pieces: vec![Piece { + start: 0, + script: 0, + copied: false, + }], + } + } + + /// Adds `script[from..to]` + pub(crate) fn copy(&mut self, script: &str, from: usize, to: usize) { + self.pieces.push(Piece { + start: self.text.len(), + script: from, + copied: true, + }); + self.text.push_str(&script[from..to]); + } + + /// Adds `text` made for the code at byte `at` of the script + pub(crate) fn synthesize(&mut self, at: usize, text: &str) { + self.pieces.push(Piece { + start: self.text.len(), + script: at, + copied: false, + }); + self.text.push_str(text); + } + + pub(crate) fn finish(self, unit: &Rc) -> (String, Rc) { + ( + self.text, + Rc::new(Origin { + unit: unit.clone(), + pieces: self.pieces, + }), + ) + } +} + +#[cfg(test)] +mod coverage_tests; + +/// The script an evaluation runs: the loaded modules, then the stylesheet +#[derive(Default)] +pub(crate) struct Script { + pub text: String, + /// `(start, end, origin)` of the bodies of modules in `text`; the rest is + /// code the build generated + parts: Vec<(usize, usize, Rc)>, +} + +impl Script { + pub(crate) fn authored(&self, offset: usize) -> bool { + self.parts.iter().any(|(start, end, origin)| { + (*start..*end).contains(&offset) + && matches!( + origin.frame(offset - start), + explain::FrameOrigin::Authored(_) + ) + }) + } + + pub(crate) fn generated(&mut self, text: &str) { + self.text.push_str(text); + } + + pub(crate) fn body(&mut self, text: &str, origin: &Rc) { + let start = self.text.len(); + self.text.push_str(text); + self.parts.push((start, self.text.len(), origin.clone())); + } + + /// `filename:line:column` of the script as written for a position the + /// engine printed, `None` in code the build generated + fn frame(&self, lines: &Lines<'_>, line: u32, column: u32) -> explain::FrameOrigin { + let offset = lines.code_point_offset(line.saturating_sub(1), column.saturating_sub(1)); + let Some((start, _, origin)) = self + .parts + .iter() + .find(|(start, end, _)| (*start..*end).contains(&offset)) + else { + return explain::FrameOrigin::Generated; + }; + origin.frame(offset - start) + } + + /// The error the engine gave for this script, its positions in the code as + /// written; `fallback` names the file when no position is + pub(crate) fn explain(&self, error: &str, fallback: &str) -> String { + let lines = Lines::new(&self.text); + if self.parts.is_empty() { + return explain::describe(error, |_, _| None, &format!("{fallback}:1:1")); + } + explain::describe_frames( + error, + |line, column| self.frame(&lines, line, column), + &format!("{fallback}:1:1"), + ) + } +} diff --git a/libs/extractor/src/module_loader/script/coverage_tests.rs b/libs/extractor/src/module_loader/script/coverage_tests.rs new file mode 100644 index 000000000..1d9f041c2 --- /dev/null +++ b/libs/extractor/src/module_loader/script/coverage_tests.rs @@ -0,0 +1,112 @@ +use rstest::rstest; +use serial_test::serial; + +use super::Unit; +use crate::{ExtractOption, ExtractOutput}; + +#[test] +fn generated_only_script_errors_use_file_fallback() { + // Given + let mut script = super::Script::default(); + script.generated("throw new Error('generated');"); + // When + let error = script.explain("TypeError: generated", "generated.css.ts"); + // Then + assert_eq!( + error, + "generated.css.ts:1:1: JS execution error: TypeError: generated. Fix: check that every value read, called or constructed here exists when the stylesheet is evaluated at build time" + ); +} + +#[test] +fn retained_code_rejects_malformed_source_map_instead_of_inventing_a_trace() -> Result<(), String> { + // Given + let output = ExtractOutput { + code: "const width = 4;".to_string(), + styles: Default::default(), + map: Some("{invalid".to_string()), + css_file: None, + dependencies: Vec::new(), + }; + // When + let error = Unit::retained("retained.ts", &output, "const width: number = 4;") + .err() + .ok_or("malformed map accepted")?; + // Then + assert!(error.starts_with("retained.ts:1:1: Cannot read stylesheet source map:")); + Ok(()) +} + +#[test] +fn retained_code_uses_written_trace_when_extraction_leaves_the_source_unchanged() +-> Result<(), String> { + // Given + let code = "const width: number = 4;"; + let output = ExtractOutput { + code: code.to_string(), + styles: Default::default(), + map: None, + css_file: None, + dependencies: Vec::new(), + }; + // When + let unit = Unit::retained("retained.ts", &output, code)?; + // Then + assert_eq!( + unit.place(unit.script().find('4').ok_or("missing initializer")?), + "retained.ts:1:23" + ); + Ok(()) +} + +#[rstest] +#[case(None)] +#[case(Some(r#"{"version":3,"sources":[],"names":[],"mappings":""}"#))] +fn retained_code_rejects_missing_original_source_instead_of_inventing_a_trace( + #[case] map: Option<&str>, +) -> Result<(), String> { + // Given + let output = ExtractOutput { + code: "const width = 4;".to_string(), + styles: Default::default(), + map: map.map(str::to_string), + css_file: None, + dependencies: Vec::new(), + }; + // When + let error = Unit::retained("retained.ts", &output, "const width: number = 4;") + .err() + .ok_or("missing map accepted")?; + // Then + assert!(error.starts_with("retained.ts:1:1: Cannot trace retained stylesheet code")); + Ok(()) +} + +#[test] +#[serial] +fn retained_initializers_keep_original_sites_while_extracted_glue_has_only_file_fallback() +-> Result<(), String> { + // Given + css::file_map::reset_file_map(); + let code = "import { css } from '@devup-ui/react';\nconst width: number = 4;\nexport function value() { return width; }\nexport const rule = css({ w: width });"; + let output = + crate::extract_with_source_map("retained.ts", code, ExtractOption::default(), true, None) + .map_err(|error| error.to_string())?; + // When + let unit = Unit::retained("retained.ts", &output, code)?; + // Then + assert_eq!( + unit.place( + unit.script() + .find("= 4") + .ok_or("missing retained initializer")? + + 2 + ), + "retained.ts:2:23" + ); + assert_eq!( + unit.locate(unit.script().find("rule").ok_or("missing extracted rule")?), + None + ); + Ok(()) +} diff --git a/libs/extractor/src/module_loader/script/explain.rs b/libs/extractor/src/module_loader/script/explain.rs new file mode 100644 index 000000000..c72aad1dd --- /dev/null +++ b/libs/extractor/src/module_loader/script/explain.rs @@ -0,0 +1,220 @@ +//! The errors the script engine gives, told with the code as written + +use crate::module_loader::IMPORT_CYCLE; + +#[cfg(test)] +mod tests; + +/// The name the evaluated script goes by, which the engine prints in its +/// positions +pub(crate) const SCRIPT_PATH: &str = "devup-ui-stylesheet"; + +const FRAME_SEPARATOR: &str = "\n at "; + +/// Where the engine says an error or a call happened +enum Place { + /// `line` and `column`, counted from 1, of the script + Script(u32, u32), + /// A function the build provides + Native, + /// A script no author wrote or no position is known for + Foreign, +} + +struct Frame<'t> { + function: &'t str, + place: Place, +} + +pub(super) enum FrameOrigin { + Authored(String), + Instrumentation, + Generated, +} + +/// What `inner` of `(inner)` says, `None` when it is not a place +fn place(inner: &str) -> Option { + if inner.starts_with(crate::evaluation_sandbox::INTERNAL_SOURCE) { + return Some(Place::Foreign); + } + if inner == "native" { + return Some(Place::Native); + } + if let Some(position) = inner + .strip_prefix(SCRIPT_PATH) + .and_then(|position| position.strip_prefix(':')) + { + return Some( + position + .split_once(':') + .and_then(|(line, column)| { + Some(Place::Script(line.parse().ok()?, column.parse().ok()?)) + }) + .unwrap_or(Place::Foreign), + ); + } + ["unknown at ", "eval at ", "json at "] + .iter() + .any(|prefix| inner.starts_with(prefix)) + .then_some(Place::Foreign) +} + +/// `text` without the place it ends with, and the place +fn split_place(text: &str) -> Option<(&str, Place)> { + let text = if text.starts_with(" (") { + text.strip_suffix(" (native)").unwrap_or(text) + } else { + text + }; + let (before, inner) = text.strip_suffix(')')?.rsplit_once(" (")?; + Some((before, place(inner)?)) +} + +/// The position a syntax error ends with: `message at line 3, col 7`. The +/// engine gives the early errors of a whole script, like a name declared twice, +/// the position 1:1 whichever they are, which is no position. +fn split_position(text: &str) -> Option<(&str, Place)> { + let (before, position) = text.rsplit_once(" at line ")?; + let (line, column) = position.split_once(", col ")?; + Some(( + before, + match (line.parse().ok()?, column.parse().ok()?) { + (1, 1) => Place::Foreign, + (line, column) => Place::Script(line, column), + }, + )) +} + +/// The error without the call stack, the call stack from the innermost call, +/// and where the error itself was raised +fn parse(error: &str) -> (String, Vec>, Option) { + let mut segments: Vec<&str> = error.split(FRAME_SEPARATOR).collect(); + let mut frames = Vec::new(); + while segments.len() > 1 + && let Some((function, place)) = segments.last().and_then(|last| split_place(last)) + { + frames.push(Frame { function, place }); + segments.pop(); + } + frames.reverse(); + let head = segments.join(FRAME_SEPARATOR); + let (head, placed) = + split_place(&head).map_or((head.as_str(), None), |(head, place)| (head, Some(place))); + match split_position(head) { + Some((cause, place)) => (cause.to_string(), frames, Some(place)), + None => (head.to_string(), frames, placed), + } +} + +/// What to change for an error to stop +fn fix(cause: &str) -> String { + if cause.starts_with("ReferenceError: Cannot read CSS export '") { + return "use the class in a runtime component (className={styles.card}) or write the style with css()".to_string(); + } + if cause.contains(IMPORT_CYCLE) { + return "break the import cycle, or read the imported value only after both modules finished evaluating, for example inside a function called later".to_string(); + } + if let Some(name) = cause + .strip_prefix("ReferenceError: ") + .and_then(|message| message.strip_suffix(" is not defined")) + { + return format!( + "declare or import `{name}` before this code reads it; it is not available when the stylesheet is evaluated at build time" + ); + } + if cause.starts_with("SyntaxError") { + return "correct the syntax at this location".to_string(); + } + if cause.starts_with("TypeError") { + return "check that every value read, called or constructed here exists when the stylesheet is evaluated at build time".to_string(); + } + "remove or guard the code that fails here, or correct the cause above, so the stylesheet evaluates without throwing".to_string() +} + +/// The error `error` the engine gave, led by where the code as written raised +/// it, with its call stack in that code and what to fix. `locate` tells where +/// the code as written is for a position of the script, `None` for code the +/// build generated; `fallback` leads when no position is known +pub(super) fn describe( + error: &str, + locate: impl Fn(u32, u32) -> Option, + fallback: &str, +) -> String { + let (_, frames, _) = parse(error); + let generated: Vec<_> = frames + .iter() + .filter_map(|frame| { + let helper = ["__devup_read_site_", "__devup_operation_site_"] + .iter() + .any(|prefix| { + frame + .function + .strip_prefix(prefix) + .and_then(|suffix| suffix.strip_suffix("__")) + .is_some_and(|hash| { + !hash.is_empty() && hash.bytes().all(|byte| byte.is_ascii_hexdigit()) + }) + }); + match (&frame.place, helper) { + (Place::Script(line, column), true) => Some((*line, *column)), + _ => None, + } + }) + .collect(); + describe_frames( + error, + |line, column| { + if generated.contains(&(line, column)) { + FrameOrigin::Instrumentation + } else { + locate(line, column).map_or(FrameOrigin::Generated, FrameOrigin::Authored) + } + }, + fallback, + ) +} + +pub(super) fn describe_frames( + error: &str, + locate: impl Fn(u32, u32) -> FrameOrigin, + fallback: &str, +) -> String { + let (cause, frames, raised) = parse(error); + let in_script = |place: &Place| match place { + Place::Script(line, column) => match locate(*line, *column) { + FrameOrigin::Authored(location) => Some(location), + FrameOrigin::Instrumentation | FrameOrigin::Generated => None, + }, + Place::Native | Place::Foreign => None, + }; + let mut at = raised + .filter(|_| frames.is_empty()) + .and_then(|place| in_script(&place)); + let mut calls = Vec::new(); + for (index, frame) in frames.iter().enumerate() { + if matches!(frame.place, Place::Native) { + if frames.get(index + 1).is_some_and(|next| match next.place { + Place::Script(line, column) => { + matches!(locate(line, column), FrameOrigin::Instrumentation) + } + Place::Native | Place::Foreign => false, + }) { + continue; + } + calls.push(format!("{FRAME_SEPARATOR}{} (native)", frame.function)); + } else if let Some(located) = in_script(&frame.place) { + calls.push(format!("{FRAME_SEPARATOR}{} ({located})", frame.function)); + at.get_or_insert(located); + } + } + let cause = cause.trim_end_matches('.'); + format!( + "{}: JS execution error: {cause}. Fix: {}{}", + at.as_deref().unwrap_or(fallback), + fix(cause), + calls.concat() + ) +} + +#[cfg(test)] +mod coverage_tests; diff --git a/libs/extractor/src/module_loader/script/explain/coverage_tests.rs b/libs/extractor/src/module_loader/script/explain/coverage_tests.rs new file mode 100644 index 000000000..bf8364b18 --- /dev/null +++ b/libs/extractor/src/module_loader/script/explain/coverage_tests.rs @@ -0,0 +1,25 @@ +use super::describe; + +#[test] +fn internal_guard_and_instrumentation_frames_are_removed_when_reporting_author_calls() +-> Result<(), String> { + // Given + let error = "ReferenceError: read denied\n at guard (:1:4)\n at __devup_read_site_123__ (devup-ui-stylesheet:2:3)\n at __devup_operation_site_456__ (devup-ui-stylesheet:2:4)\n at user (devup-ui-stylesheet:3:7)"; + // When + let result = describe( + error, + |line, column| Some(format!("original.ts:{line}:{column}")), + "fallback.ts:1:1", + ); + // Then + let (head, rest) = result + .split_once(". Fix: ") + .ok_or("missing diagnostic delimiter")?; + assert_eq!( + head, + "original.ts:3:7: JS execution error: ReferenceError: read denied" + ); + let (_, calls) = rest.split_once('\n').ok_or("missing author call")?; + assert_eq!(calls, " at user (original.ts:3:7)"); + Ok(()) +} diff --git a/libs/extractor/src/module_loader/script/explain/tests.rs b/libs/extractor/src/module_loader/script/explain/tests.rs new file mode 100644 index 000000000..716d65905 --- /dev/null +++ b/libs/extractor/src/module_loader/script/explain/tests.rs @@ -0,0 +1,157 @@ +use super::*; + +/// The code as written has lines 2 to 5; the rest of the script is generated +fn locate(line: u32, column: u32) -> Option { + (2..=5) + .contains(&line) + .then(|| format!("/a.ts:{}:{column}", line - 1)) +} + +fn tell(error: &str) -> String { + describe(error, locate, "/fallback.ts") +} + +/// `told` split into what leads it, the fix and the call stack +fn parts(told: &str) -> (&str, &str, &str) { + let (lead, rest) = told.split_once(". Fix: ").unwrap_or((told, "")); + let (fix, calls) = rest.split_once('\n').unwrap_or((rest, "")); + (lead, fix, calls) +} + +#[test] +fn the_innermost_place_in_written_code_leads() { + let error = "Error: boom (devup-ui-stylesheet:3:7)\n at get (devup-ui-stylesheet:9:3)\n at fail (devup-ui-stylesheet:3:7)\n at
(devup-ui-stylesheet:4:2)"; + let told = tell(error); + let (lead, fix, calls) = parts(&told); + assert_eq!(lead, "/a.ts:2:7: JS execution error: Error: boom"); + assert!(fix.starts_with("remove or guard the code"), "{fix}"); + assert_eq!(calls, " at fail (/a.ts:2:7)\n at
(/a.ts:3:2)"); +} + +#[test] +fn calls_into_generated_code_are_dropped() { + let error = "Error: boom (devup-ui-stylesheet:9:3)\n at get (devup-ui-stylesheet:9:3)\n at
(devup-ui-stylesheet:2:5)"; + let told = tell(error); + let (lead, _, calls) = parts(&told); + assert_eq!(lead, "/a.ts:1:5: JS execution error: Error: boom"); + assert_eq!(calls, " at
(/a.ts:1:5)"); +} + +#[test] +fn what_is_native_is_kept_and_what_has_no_place_is_dropped() { + let error = "Error: boom (native)\n at map (native)\n at e (eval at :1:2)\n at j (json at :1:2)\n at u (unknown at :4:5)\n at p (devup-ui-stylesheet:?:?)\n at
(devup-ui-stylesheet:3:1)"; + let told = tell(error); + let (lead, _, calls) = parts(&told); + assert_eq!(lead, "/a.ts:2:1: JS execution error: Error: boom"); + assert_eq!(calls, " at map (native)\n at
(/a.ts:2:1)"); +} + +#[test] +fn a_syntax_error_is_told_where_the_engine_says() { + let told = tell("SyntaxError: unexpected token '}', primary expression at line 4, col 2"); + let (lead, fix, calls) = parts(&told); + assert_eq!( + lead, + "/a.ts:3:2: JS execution error: SyntaxError: unexpected token '}', primary expression" + ); + assert_eq!(fix, "correct the syntax at this location"); + assert_eq!(calls, ""); +} + +#[test] +fn the_early_errors_of_a_script_have_no_place() { + let told = tell("SyntaxError: lexical name declared multiple times at line 1, col 1 (native)"); + let (lead, _, calls) = parts(&told); + assert_eq!( + lead, + "/fallback.ts: JS execution error: SyntaxError: lexical name declared multiple times" + ); + assert_eq!(calls, ""); +} + +#[test] +fn an_error_without_calls_is_told_where_it_was_raised() { + let told = tell("Error: x (devup-ui-stylesheet:2:3)"); + assert_eq!(parts(&told).0, "/a.ts:1:3: JS execution error: Error: x"); +} + +#[test] +fn an_error_in_generated_code_or_without_a_place_leads_with_the_file() { + for error in [ + "Error: x (devup-ui-stylesheet:9:3)", + "Error: x (unknown at :9:3)", + "Error: x (native)", + "x", + ] { + let told = tell(error); + let (lead, _, calls) = parts(&told); + assert!( + lead.starts_with("/fallback.ts: JS execution error: "), + "{lead}" + ); + assert_eq!(calls, ""); + } +} + +#[test] +fn what_the_message_holds_stays_in_it() { + let told = tell("Error: a\n at b (c)\n at
(devup-ui-stylesheet:3:1)"); + let (lead, _, calls) = parts(&told); + assert_eq!( + lead, + "/a.ts:2:1: JS execution error: Error: a\n at b (c)" + ); + assert_eq!(calls, " at
(/a.ts:2:1)"); + + let told = tell("Error: value (x)"); + assert_eq!( + parts(&told).0, + "/fallback.ts: JS execution error: Error: value (x)" + ); + + let told = tell("Error: done..."); + assert_eq!( + parts(&told).0, + "/fallback.ts: JS execution error: Error: done" + ); +} + +#[test] +fn every_kind_of_error_has_a_fix() { + assert!( + fix(&format!( + "ReferenceError: Cannot access 'a' of 'b' {IMPORT_CYCLE}" + )) + .starts_with("break the import cycle") + ); + assert_eq!( + fix("ReferenceError: window is not defined"), + "declare or import `window` before this code reads it; it is not available when the stylesheet is evaluated at build time" + ); + assert_eq!(fix("SyntaxError: x"), "correct the syntax at this location"); + assert!(fix("TypeError: x is not a function").starts_with("check that every value")); + assert!(fix("RangeError: x").starts_with("remove or guard the code")); + assert!(fix("ReferenceError: x is not initialized").starts_with("remove or guard the code")); +} + +#[test] +fn generic_fix_is_preserved_when_user_prose_mentions_css_exports() { + // Given + for error in [ + "Error: Cannot read CSS export 'card' of '/styles.css'", + "ReferenceError: user says ReferenceError: Cannot read CSS export 'card'", + ] { + // When + let told = tell(error); + // Then + assert_eq!( + parts(&told).0, + format!("/fallback.ts: JS execution error: {error}") + ); + assert_eq!( + parts(&told).1, + "remove or guard the code that fails here, or correct the cause above, so the stylesheet evaluates without throwing" + ); + assert_eq!(told.matches("Fix:").count(), 1); + } +} diff --git a/libs/extractor/src/module_loader/script/retained.rs b/libs/extractor/src/module_loader/script/retained.rs new file mode 100644 index 000000000..77d792c51 --- /dev/null +++ b/libs/extractor/src/module_loader/script/retained.rs @@ -0,0 +1,107 @@ +use std::rc::Rc; + +use oxc_allocator::Allocator; +use oxc_ast::ast::{Declaration, Statement}; +use oxc_parser::Parser; +use oxc_sourcemap::SourceMap; +use oxc_span::{GetSpan, SourceType}; + +use super::{Unit, Written}; +use crate::source_map::{Trace, marks}; +use crate::vanilla_extract::{Stylesheet, strip_typescript_marked}; + +fn initializers(code: &str) -> Vec<(String, usize, usize)> { + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, code, SourceType::ts()).parse(); + parsed + .program + .body + .iter() + .flat_map(|statement| { + let declaration = match statement { + Statement::VariableDeclaration(declaration) => Some(declaration.as_ref()), + Statement::ExportDeclaration(export) => match &export.declaration { + Declaration::VariableDeclaration(declaration) => Some(declaration.as_ref()), + _ => None, + }, + _ => None, + }; + declaration + .into_iter() + .flat_map(|declaration| &declaration.declarations) + .filter_map(|declarator| { + let name = declarator.id.get_identifier_name()?; + let span = declarator.init.as_ref()?.span(); + Some((name.to_string(), span.start as usize, span.end as usize)) + }) + }) + .collect() +} + +impl Unit { + pub(crate) fn retained( + filename: &str, + output: &crate::ExtractOutput, + source: &str, + ) -> Result, String> { + let code = &output.code; + if code == source { + return Self::written(filename, code, source, &[]); + } + crate::module_loader::validate(Stylesheet { + filename, + code, + source: code, + edits: &[], + })?; + let generated = strip_typescript_marked(code, filename); + let unavailable = || { + format!( + "{filename}:1:1: Cannot trace retained stylesheet code. Fix: report this internal source-map failure" + ) + }; + let map = SourceMap::from_json_string(output.map.as_deref().ok_or_else(unavailable)?) + .map_err(|error| format!("{filename}:1:1: Cannot read stylesheet source map: {error}. Fix: report this internal source-map failure"))?; + let parsed = map.get_source_content(0).ok_or_else(unavailable)?; + let originals = initializers(source); + let copied: Vec<_> = initializers(parsed) + .into_iter() + .filter_map(|(name, start, end)| { + let (_, from, _) = originals.iter().find(|(original_name, from, to)| { + *original_name == name && source[*from..*to] == parsed[start..end] + })?; + Some((start, end, *from)) + }) + .collect(); + let trace = Trace::new(&marks(&map, code, parsed), &[]); + let mut mapped = Vec::new(); + let mut ranges = Vec::new(); + let mut tokens = generated.marks.iter().peekable(); + while let Some(&(start, at)) = tokens.next() { + let parsed_at = trace.resolve(code, parsed, at); + if let Some(&(from, _, original)) = copied + .iter() + .find(|(from, to, _)| (*from..*to).contains(&parsed_at)) + { + let end = tokens + .peek() + .map_or(generated.code.len(), |(next, _)| *next); + mapped.push((start, original + parsed_at - from)); + ranges.push((start, end)); + } + } + Ok(Rc::new(Self { + filename: filename.to_string(), + script: generated.code.clone(), + written: Some(Written { + source: source.to_string(), + trace: Trace::new(&mapped, &[]), + retained: Some(ranges), + instrumentation: Vec::new(), + }), + })) + } +} + +#[cfg(test)] +mod coverage_tests; diff --git a/libs/extractor/src/module_loader/script/retained/coverage_tests.rs b/libs/extractor/src/module_loader/script/retained/coverage_tests.rs new file mode 100644 index 000000000..67ed6a840 --- /dev/null +++ b/libs/extractor/src/module_loader/script/retained/coverage_tests.rs @@ -0,0 +1,15 @@ +use super::initializers; + +#[test] +fn initializer_ranges_include_local_constants_but_ignore_exported_functions() { + // Given + let source = "const local = 1; export function helper() {} export const token = 'red';"; + // When + let ranges = initializers(source); + // Then + let values = ranges + .iter() + .map(|(name, start, end)| (name.as_str(), &source[*start..*end])) + .collect::>(); + assert_eq!(values, [("local", "1"), ("token", "'red'")]); +} diff --git a/libs/extractor/src/module_loader/script/tests.rs b/libs/extractor/src/module_loader/script/tests.rs new file mode 100644 index 000000000..5fbe0b0ff --- /dev/null +++ b/libs/extractor/src/module_loader/script/tests.rs @@ -0,0 +1,167 @@ +use super::*; +use oxc_ast_visit::{Visit, walk}; +use oxc_span::SourceType; + +#[derive(Default)] +struct Calls(Vec); + +impl<'a> Visit<'a> for Calls { + fn visit_call_expression(&mut self, call: &oxc_ast::ast::CallExpression<'a>) { + self.0.push(call.span.start as usize); + walk::walk_call_expression(self, call); + } +} + +#[rstest::rstest] +#[case("()=>{}")] +#[case("function(){}")] +#[case("(value)=>value")] +#[case("async()=>{}")] +fn anonymous_calls_keep_original_expression_starts_when_formatted( + #[case] function: &str, +) -> Result<(), String> { + // Given + let source = + format!("const label: string='a b';\nexport const value=({function}).read('key');"); + let unit = Unit::written("anonymous.ts", &source, &source, &[])?; + let allocator = oxc_allocator::Allocator::default(); + let parsed = oxc_parser::Parser::new(&allocator, unit.script(), SourceType::mjs()).parse(); + let mut calls = Calls::default(); + calls.visit_program(&parsed.program); + let at = *calls.0.first().ok_or("missing call")?; + let expected = source.find(function).ok_or("missing original function")?; + // When + let location = unit.place(at); + // Then + assert_eq!(location, crate::locate("anonymous.ts", &source, expected)); + Ok(()) +} + +#[rstest::rstest] +fn anonymous_calls_keep_distinct_original_sites_when_prior_layers_change( + #[values("\n", "\r\n", "\r", "\u{2028}", "\u{2029}")] newline: &str, +) -> Result<(), String> { + // Given + let source = format!( + "import {{style}} from '@vanilla-extract/css';{newline}const label: string='😀 한';{newline}export const value=style({{first:(()=>{{}}).read('key')?'a b':'c',second:(()=>{{}}).read('key')?'d':'e'}});" + ); + let aliased = source.replace("@vanilla-extract/css", "@devup-ui/react"); + let alias_start = source + .find("@vanilla-extract/css") + .ok_or("missing import")?; + let aliases = [( + alias_start, + alias_start + "@vanilla-extract/css".len(), + "@devup-ui/react".len(), + )]; + let prefix = "const earlier: number=0;\n"; + let code = format!("{prefix}{aliased}"); + let earlier = [(0, 0, prefix.len())]; + let unit = Unit::written("duplicates.ts", &code, &source, &[&earlier, &aliases])?; + let allocator = oxc_allocator::Allocator::default(); + let parsed = oxc_parser::Parser::new(&allocator, unit.script(), SourceType::mjs()).parse(); + let mut calls = Calls::default(); + calls.visit_program(&parsed.program); + let expected: Vec<_> = source + .match_indices("()=>{}") + .map(|(at, _)| crate::locate("duplicates.ts", &source, at)) + .collect(); + // When + let locations: Vec<_> = calls.0.iter().skip(1).map(|&at| unit.place(at)).collect(); + // Then + assert_eq!(locations, expected); + Ok(()) +} + +#[test] +fn a_generated_module_is_told_by_its_file() { + let unit = Unit::generated("/m.css.ts", "export const a: number = 1;"); + assert_eq!(unit.filename(), "/m.css.ts"); + assert_eq!(unit.script(), "export const a = 1;\n"); + assert_eq!(unit.locate(7), None); + assert_eq!(unit.place(7), "/m.css.ts:1:1"); +} + +#[test] +fn a_written_module_is_told_down_to_the_token() -> Result<(), String> { + let code = "const label: string = 'x';\nexport const a: number = fail(label);\n"; + let unit = Unit::written("/m.ts", code, code, &[])?; + let script = unit.script(); + assert_eq!( + unit.place(script.find("fail").unwrap_or_default()), + "/m.ts:2:26" + ); + assert_eq!( + unit.place(script.find("(label").unwrap_or_default()), + "/m.ts:2:30" + ); + assert_eq!(unit.place(0), "/m.ts:1:1"); + Ok(()) +} + +#[test] +fn edits_of_the_source_are_undone() -> Result<(), String> { + let source = "import { style } from '@vanilla-extract/css'; export const a: number = fail();\n"; + let code = "import { style } from '@devup-ui/react'; export const a: number = fail();\n"; + let edits = [(22, 44, 17)]; + let unit = Unit::written("/m.ts", code, source, &[&edits])?; + let script = unit.script(); + assert_eq!( + unit.place(script.find("fail").unwrap_or_default()), + "/m.ts:1:72" + ); + Ok(()) +} + +#[test] +fn a_body_is_told_by_the_script_it_was_made_of() -> Result<(), String> { + let code = "aaaa();\nbbbb();\n"; + let unit = Unit::written("/m.ts", code, code, &[])?; + let script = unit.script().to_string(); + assert_eq!(script, code); + let mut body = Body::new(script.len()); + body.copy(&script, 0, 2); + body.synthesize(3, "XYZ"); + body.copy(&script, 4, 6); + body.synthesize(8, "\n"); + let (text, origin) = body.finish(&unit); + assert_eq!(text, "aaXYZ()\n"); + assert_eq!(origin.locate(0).as_deref(), Some("/m.ts:1:1")); + assert_eq!(origin.locate(1).as_deref(), Some("/m.ts:1:2")); + assert_eq!(origin.locate(3).as_deref(), Some("/m.ts:1:4")); + assert_eq!(origin.locate(5).as_deref(), Some("/m.ts:1:5")); + assert_eq!(origin.locate(6).as_deref(), Some("/m.ts:1:6")); + assert_eq!(origin.locate(7).as_deref(), Some("/m.ts:2:1")); + Ok(()) +} + +#[test] +fn a_script_is_told_in_its_modules_and_not_in_its_glue() -> Result<(), String> { + let code = "throw 1;\n"; + let unit = Unit::written("/m.ts", code, code, &[])?; + let mut body = Body::new(code.len()); + body.copy(unit.script(), 0, unit.script().len()); + let (text, origin) = body.finish(&unit); + let mut script = Script::default(); + script.generated("(function () {\n"); + script.body(&text, &origin); + script.generated("})();\n"); + assert_eq!(script.text, "(function () {\nthrow 1;\n})();\n"); + + let told = script.explain( + "1 (devup-ui-stylesheet:2:1)\n at
(devup-ui-stylesheet:2:1)\n at
(devup-ui-stylesheet:3:1)", + "/m.ts", + ); + assert!( + told.starts_with("/m.ts:1:1: JS execution error: 1. Fix: "), + "{told}" + ); + assert!(told.ends_with("\n at
(/m.ts:1:1)"), "{told}"); + + let told = script.explain("Error: x (devup-ui-stylesheet:0:0)", "/m.ts"); + assert!( + told.starts_with("/m.ts:1:1: JS execution error: Error: x. Fix: "), + "{told}" + ); + Ok(()) +} diff --git a/libs/extractor/src/module_loader/script/written.rs b/libs/extractor/src/module_loader/script/written.rs new file mode 100644 index 000000000..180b54ae5 --- /dev/null +++ b/libs/extractor/src/module_loader/script/written.rs @@ -0,0 +1,54 @@ +use std::rc::Rc; + +use super::{Unit, Written}; +use crate::{ + import_alias_visit::Edit, + source_map::Trace, + vanilla_extract::{Stylesheet, strip_typescript_marked}, +}; + +impl Unit { + pub(crate) fn written( + filename: &str, + code: &str, + source: &str, + edits: &[&[Edit]], + ) -> Result, String> { + Self::instrumented( + Stylesheet { + filename, + code, + source, + edits, + }, + |_| false, + ) + } + + pub(crate) fn instrumented( + input: Stylesheet<'_>, + generated: impl Fn(usize) -> bool, + ) -> Result, String> { + crate::module_loader::validate(input)?; + let stripped = strip_typescript_marked(input.code, input.filename); + let mut instrumentation = Vec::new(); + let mut previous = false; + for &(at, original) in &stripped.marks { + let current = generated(original); + if current != previous { + instrumentation.push((at, current)); + previous = current; + } + } + Ok(Rc::new(Self { + filename: input.filename.to_string(), + script: stripped.code.clone(), + written: Some(Written { + source: input.source.to_string(), + trace: Trace::new(&stripped.marks, input.edits), + retained: None, + instrumentation, + }), + })) + } +} diff --git a/libs/extractor/src/module_loader/serialization.rs b/libs/extractor/src/module_loader/serialization.rs new file mode 100644 index 000000000..0d6066ade --- /dev/null +++ b/libs/extractor/src/module_loader/serialization.rs @@ -0,0 +1,108 @@ +use boa_engine::{Context, JsObject, JsResult, JsValue}; +use rustc_hash::{FxHashMap, FxHashSet}; + +use super::{array_items, is_plain_object, js_str, json_string, own_keys, string_code, to_text}; + +/// Unsupported values return `None`; reading user properties must still propagate exceptions. +pub(super) fn value_to_code( + value: &JsValue, + context: &mut Context, + names: &FxHashMap, + seen: &mut Vec, +) -> JsResult> { + crate::evaluation_sandbox::check_export(value, context)?; + if let Some(text) = js_str(value) { + return Ok(Some(string_code(&text, names))); + } + if value.is_null_or_undefined() || value.is_number() || value.as_boolean().is_some() { + return to_text(value, context).map(Some); + } + let Some(object) = value.as_object() else { + return Ok(None); + }; + if object.is_callable() + || seen + .iter() + .any(|visited| JsObject::equals(visited, &object)) + { + return Ok(None); + } + seen.push(object.clone()); + let mut parts = Vec::new(); + let mut supported = true; + let code = if let Some(items) = array_items(value, context)? { + for item in &items { + if supported { + match value_to_code(item, context, names, seen)? { + Some(code) => parts.push(code), + None => supported = false, + } + } else { + check_data_exports(item, context)?; + } + } + format!("[{}]", parts.join(", ")) + } else if is_plain_object(&object, context) { + for (key, name) in own_keys(&object, context)? { + if supported { + let item = object.get(key, context)?; + match value_to_code(&item, context, names, seen)? { + Some(code) => parts.push(format!("{}: {code}", json_string(&name))), + None => supported = false, + } + } else if let Some(item) = object + .borrow() + .properties() + .get(&key) + .and_then(|property| property.value().cloned()) + { + check_data_exports(&item, context)?; + } + } + if parts.is_empty() { + "{}".to_string() + } else { + format!("{{ {} }}", parts.join(", ")) + } + } else { + seen.pop(); + return Ok(None); + }; + seen.pop(); + Ok(supported.then_some(code)) +} + +/// After fallback is selected, inspect stored data without re-running getters or coercions. +fn check_data_exports(value: &JsValue, context: &mut Context) -> JsResult<()> { + let mut pending = vec![value.clone()]; + let mut visited = FxHashSet::default(); + while let Some(value) = pending.pop() { + crate::evaluation_sandbox::check_export(&value, context)?; + let Some(object) = value.as_object() else { + continue; + }; + if object.is_callable() + || !visited.insert(object.clone()) + || !(object.is_array() || is_plain_object(&object, context)) + { + continue; + } + let keys = own_keys(&object, context)?; + for (key, _) in keys.into_iter().rev() { + if let Some(item) = object + .borrow() + .properties() + .get(&key) + .and_then(|property| property.value().cloned()) + { + pending.push(item); + } + } + } + Ok(()) +} + +#[cfg(test)] +mod serialization_coverage_tests; +#[cfg(test)] +mod serialization_gate_boundaries_tests; diff --git a/libs/extractor/src/module_loader/serialization_coverage_tests.rs b/libs/extractor/src/module_loader/serialization_coverage_tests.rs new file mode 100644 index 000000000..24e3b5cd9 --- /dev/null +++ b/libs/extractor/src/module_loader/serialization_coverage_tests.rs @@ -0,0 +1,45 @@ +use boa_engine::{Context, Source}; +use rstest::rstest; +use rustc_hash::FxHashMap; + +use super::value_to_code; + +#[rstest] +#[case("new Map([['color', 'red']])")] +#[case("new Set(['red'])")] +#[case("new (class Color { constructor() { this.value = 'red'; } })()")] +#[case("[() => 'red']")] +fn serialization_declines_unsupported_exports_without_reporting_an_execution_error( + #[case] script: &str, +) -> Result<(), String> { + // Given + let mut context = Context::default(); + let value = context + .eval(Source::from_bytes(script)) + .map_err(|error| error.to_string())?; + // When + let code = value_to_code(&value, &mut context, &FxHashMap::default(), &mut Vec::new()) + .map_err(|error| error.to_string())?; + // Then + assert_eq!(code, None); + Ok(()) +} + +#[test] +fn serialization_propagates_a_thrown_getter_instead_of_declining_the_export() -> Result<(), String> +{ + // Given + let mut context = Context::default(); + let value = context + .eval(Source::from_bytes( + "({ get color() { throw new TypeError('getter failed'); } })", + )) + .map_err(|error| error.to_string())?; + // When + let error = value_to_code(&value, &mut context, &FxHashMap::default(), &mut Vec::new()) + .err() + .ok_or("getter exception swallowed")?; + // Then + assert!(error.to_string().starts_with("TypeError: getter failed")); + Ok(()) +} diff --git a/libs/extractor/src/module_loader/serialization_gate_boundaries_tests.rs b/libs/extractor/src/module_loader/serialization_gate_boundaries_tests.rs new file mode 100644 index 000000000..63f1c7a2c --- /dev/null +++ b/libs/extractor/src/module_loader/serialization_gate_boundaries_tests.rs @@ -0,0 +1,43 @@ +use super::value_to_code; +use boa_engine::{Context, Source}; +use rstest::rstest; +use rustc_hash::FxHashMap; + +#[rstest] +#[case( + "({get first(){reads++;return ()=>1},get later(){throw new Error('must remain skipped')}})", + 1 +)] +#[case( + "[{get first(){reads++;return ()=>1}}, {get later(){throw new Error('must remain skipped')}}]", + 1 +)] +#[case( + "({first:()=>1,nested:{get later(){throw new Error('must remain skipped')}}})", + 0 +)] +#[case( + "({get first(){reads++;return 1},get next(){reads++;return ()=>1},last:{value:1}})", + 2 +)] +#[case("[()=>1,()=>2,new Map(),(()=>{const v={};v.self=v;return v})()]", 0)] +fn gate_boundaries_export_fallback_preserves_getter_short_circuit( + #[case] expression: &str, + #[case] expected_reads: u32, +) -> Result<(), String> { + // Given + let mut context = Context::default(); + let value = context + .eval(Source::from_bytes(&format!("var reads=0;{expression}"))) + .map_err(|error| error.to_string())?; + // When + let code = value_to_code(&value, &mut context, &FxHashMap::default(), &mut Vec::new()) + .map_err(|error| error.to_string())?; + // Then + assert_eq!(code, None); + let reads = context + .eval(Source::from_bytes("reads")) + .map_err(|error| error.to_string())?; + assert_eq!(reads.as_number(), Some(f64::from(expected_reads))); + Ok(()) +} diff --git a/libs/extractor/src/module_loader/tests.rs b/libs/extractor/src/module_loader/tests.rs new file mode 100644 index 000000000..a7fd4631a --- /dev/null +++ b/libs/extractor/src/module_loader/tests.rs @@ -0,0 +1,327 @@ +use std::collections::HashMap; + +use css::file_map::reset_file_map; +use serial_test::serial; + +use crate::import_alias_visit::{Edit, transform_import_aliases_with_edits}; +use crate::vanilla_extract::{Stylesheet, execute_located}; +use crate::{ExtractOption, ImportAlias, ResolvedModule}; + +mod css_paths; +mod css_reads; +mod regressions; +mod retained_css; +mod sandbox; +mod side_effect_dependency; + +type Files = &'static [(&'static str, &'static str)]; + +const PACKAGE: &str = "@devup-ui/react"; + +fn resolve(files: Files, specifier: &str) -> Option { + files + .iter() + .find(|(path, _)| { + path.trim_end_matches(".ts") + == specifier.trim_start_matches('.').trim_end_matches(".ts") + }) + .map(|(path, code)| ResolvedModule { + path: (*path).to_string(), + code: (*code).to_string(), + }) +} + +/// The error evaluating `code` gives, `files` resolving what it imports +fn error_of(filename: &str, code: &str, files: Files) -> String { + reset_file_map(); + let resolver = move |specifier: &str, _: &str| resolve(files, specifier); + execute_located( + Stylesheet { + filename, + code, + source: code, + edits: &[], + }, + &ExtractOption::default(), + Some(&resolver), + ) + .err() + .unwrap_or_default() +} + +/// `error` split into what leads it, before ` Fix: `, the fix and its call +/// stack +fn parts(error: &str) -> (&str, &str, &str) { + let (lead, rest) = error.split_once(". Fix: ").unwrap_or((error, "")); + let (fix, calls) = rest.split_once('\n').unwrap_or((rest, "")); + (lead, fix, calls) +} + +#[test] +#[serial] +fn an_error_points_at_the_code_as_written() { + let error = error_of( + "/a.css.ts", + "import { createVar } from '@devup-ui/react';\nconst label: string = '한글😀'; const n: number = 1; throw new Error(label);\n", + &[], + ); + let (lead, fix, calls) = parts(&error); + assert_eq!(lead, "/a.css.ts:2:57: JS execution error: Error: 한글😀"); + assert!(fix.starts_with("remove or guard"), "{fix}"); + assert_eq!(calls, " at
(/a.css.ts:2:57)"); +} + +#[test] +#[serial] +fn lines_ending_with_carriage_returns_are_counted_once() { + let error = error_of( + "/a.css.ts", + "import { createVar } from '@devup-ui/react';\r\n\r\nthrow new Error('x');\r\n", + &[], + ); + assert!( + error.starts_with("/a.css.ts:3:7: JS execution error: Error: x. Fix: "), + "{error}" + ); +} + +#[test] +#[serial] +fn rewritten_imports_do_not_shift_what_follows() { + let aliases = HashMap::from([( + "@vanilla-extract/css".to_string(), + ImportAlias::NamedToNamed, + )]); + let source = "import { style } from '@vanilla-extract/css'; throw new Error('x');\n"; + let aliased = transform_import_aliases_with_edits(source, "/a.css.ts", PACKAGE, &aliases); + assert_ne!(aliased.code, source); + let edits: Vec<&[Edit]> = vec![aliased.edits.as_slice()]; + reset_file_map(); + let error = execute_located( + Stylesheet { + filename: "/a.css.ts", + code: &aliased.code, + source, + edits: &edits, + }, + &ExtractOption::default(), + None, + ) + .err() + .unwrap_or_default(); + let (lead, _, calls) = parts(&error); + assert_eq!(lead, "/a.css.ts:1:53: JS execution error: Error: x"); + assert_eq!(calls, " at
(/a.css.ts:1:53)"); +} + +#[test] +#[serial] +fn an_imported_module_is_told_by_its_own_file() { + let error = error_of( + "/a.css.ts", + "import { createVar } from '@devup-ui/react';\nimport { color } from './b';\nexport const token = createVar(color);\n", + &[( + "/b.ts", + "const label: string = 'helper boom';\n\nfunction fail(): string {\n throw new Error(label);\n}\nexport const color: string = fail();\n", + )], + ); + let (lead, _, calls) = parts(&error); + assert_eq!(lead, "/b.ts:4:9: JS execution error: Error: helper boom"); + assert_eq!(calls, " at fail (/b.ts:4:9)\n at
(/b.ts:6:34)"); +} + +#[test] +#[serial] +fn code_after_a_replaced_import_keeps_its_place() { + let error = error_of( + "/a.css.ts", + "import { out } from './b';\nexport const x = out;\n", + &[ + ( + "/b.ts", + "import { f } from './c';\nexport const b = 'blue';\nexport const out = f();\n", + ), + ( + "/c.ts", + "import { b } from './b';\nexport const f = () => {\n const v = b;\n throw new Error(v);\n};\n", + ), + ], + ); + let (lead, _, calls) = parts(&error); + assert_eq!(lead, "/c.ts:4:9: JS execution error: Error: blue"); + assert_eq!(calls, " at f (/c.ts:4:9)\n at
(/b.ts:3:21)"); +} + +#[test] +#[serial] +fn a_default_export_keeps_its_place() { + let error = error_of( + "/a.css.ts", + "export const label: string = 'x';\nexport default (() => {\n throw new Error(label);\n})();\n", + &[], + ); + let (lead, _, calls) = parts(&error); + assert_eq!(lead, "/a.css.ts:3:9: JS execution error: Error: x"); + assert_eq!( + calls, + " at
(/a.css.ts:3:9)\n at
(/a.css.ts:4:3)" + ); +} + +#[test] +#[serial] +fn an_early_read_in_an_import_cycle_points_at_the_read() { + let error = error_of( + "/a.css.ts", + "import { createVar } from '@devup-ui/react';\nimport { color } from './b';\nexport const token = createVar(color);\n", + &[ + ( + "/b.ts", + "import { other } from './c';\nexport const color = other;\n", + ), + ( + "/c.ts", + "import { color } from './b';\nexport const other = color;\n", + ), + ], + ); + let (lead, fix, calls) = parts(&error); + assert_eq!( + lead, + "/c.ts:2:22: JS execution error: ReferenceError: Cannot access 'color' of '/b.ts' before its initialization: it is part of an import cycle" + ); + assert!(fix.starts_with("break the import cycle"), "{fix}"); + assert_eq!(calls, " at
(/c.ts:2:22)"); +} + +#[test] +#[serial] +fn a_throw_of_what_is_not_an_error_and_calls_the_build_gives_are_told() { + let error = error_of("/a.css.ts", "throw 'text';\n", &[]); + let (lead, _, calls) = parts(&error); + assert_eq!(lead, "/a.css.ts:1:7: JS execution error: \"text\""); + assert_eq!(calls, " at
(/a.css.ts:1:7)"); + + let error = error_of( + "/a.css.ts", + "[1].map(() => { throw new Error('in map'); });\n", + &[], + ); + let (lead, _, calls) = parts(&error); + assert_eq!(lead, "/a.css.ts:1:23: JS execution error: Error: in map"); + assert_eq!( + calls, + " at
(/a.css.ts:1:23)\n at map (native)\n at
(/a.css.ts:1:8)" + ); +} + +#[test] +#[serial] +fn duplicate_lexical_declarations_are_located_before_execution() { + let error = error_of("/a.css.ts", "let a = 1;\nlet a = 2;\n", &[]); + let (lead, fix, calls) = parts(&error); + assert!( + lead.starts_with("/a.css.ts:1:5: JS execution error: SyntaxError:"), + "{error}" + ); + assert_eq!(fix, "correct the syntax at this location"); + assert_eq!(calls, ""); +} + +#[test] +#[serial] +fn retained_imported_functions_keep_their_original_frame() { + let error = error_of( + "/a.css.ts", + "import { fail } from './d.css';\nexport const x = fail();\n", + &[( + "/d.css.ts", + "import { style } from '@devup-ui/react';\nexport const s = style({ color: 'red' });\nexport const fail = () => { throw new Error('late'); };\n", + )], + ); + let (lead, _, calls) = parts(&error); + assert_eq!(lead, "/d.css.ts:3:35: JS execution error: Error: late"); + assert_eq!( + calls, + " at fail (/d.css.ts:3:35)\n at
(/a.css.ts:2:22)" + ); +} + +#[test] +#[serial] +fn an_imported_stylesheet_is_told_by_its_own_file() { + let error = error_of( + "/a.css.ts", + "import { d } from './d.css';\nexport const x = d;\n", + &[( + "/d.css.ts", + "import { style } from '@devup-ui/react';\n\nthrow new Error('nested');\nexport const d = style({});\n", + )], + ); + let (lead, _, calls) = parts(&error); + assert_eq!(lead, "/d.css.ts:3:7: JS execution error: Error: nested"); + assert_eq!(calls, " at
(/d.css.ts:3:7)"); +} + +#[test] +#[serial] +fn an_import_that_cannot_be_loaded_is_told_where_it_is_written() { + reset_file_map(); + let code = "import { b } from './b';\nexport const x = b;\n"; + let without_resolver = execute_located( + Stylesheet { + filename: "/a.css.ts", + code, + source: code, + edits: &[], + }, + &ExtractOption::default(), + None, + ) + .err() + .unwrap_or_default(); + assert!( + without_resolver + .starts_with("/a.css.ts:1:19: Cannot load './b' without a module resolver. Fix: "), + "{without_resolver}" + ); + + for (code, expected) in [ + ( + "import { b } from './missing';\nexport const x = b;\n", + "/a.css.ts:1:19: Cannot resolve './missing' from '/a.css.ts'. Fix: ", + ), + ( + "export { x } from './missing';\n", + "/a.css.ts:1:19: Cannot resolve './missing' from '/a.css.ts'. Fix: ", + ), + ( + "export * from './missing';\n", + "/a.css.ts:1:15: Cannot resolve './missing' from '/a.css.ts'. Fix: ", + ), + ( + "import { b } from './m';\nexport const x = b;\n", + "/m.ts:1:19: Cannot resolve './nowhere' from '/m.ts'. Fix: ", + ), + ] { + let error = error_of( + "/a.css.ts", + code, + &[( + "/m.ts", + "import { z } from './nowhere';\nexport const b = z;\n", + )], + ); + assert!(error.starts_with(expected), "{error}"); + } + + let required = error_of( + "/a.css.js", + "const { color } = require('./missing');\nexports.card = color;\n", + &[], + ); + assert!( + required.starts_with("/a.css.js:1:27: Cannot resolve './missing' from '/a.css.js'. Fix: "), + "{required}" + ); +} diff --git a/libs/extractor/src/module_loader/tests/css_elision.rs b/libs/extractor/src/module_loader/tests/css_elision.rs new file mode 100644 index 000000000..49451d586 --- /dev/null +++ b/libs/extractor/src/module_loader/tests/css_elision.rs @@ -0,0 +1,172 @@ +use serial_test::serial; + +use crate::{ExtractOption, ResolvedModule}; + +#[rstest::rstest] +#[case("import styles from './styles.module.css';")] +#[case("import {card} from './styles.module.css';")] +#[case("import * as styles from './styles.module.css';")] +#[serial] +fn css_retention_keeps_unused_root_value_imports_when_source_is_javascript( + #[case] import: &str, + #[values("js", "jsx", "mjs")] extension: &str, +) -> Result<(), String> { + // Given + css::file_map::reset_file_map(); + let source = format!( + "import './before.css';{import}import './after.css';export const value=['red'].join('');" + ); + let filename = format!("/src/card.css.{extension}"); + let resolver = |specifier: &str, _: &str| { + (specifier == "./styles.module.css").then(|| ResolvedModule { + path: "/src/styles.module.css".to_string(), + code: super::super::retained_css::CSS.to_string(), + }) + }; + // When + let (_, imports) = crate::vanilla_extract::execute_stylesheet( + &source, + &filename, + &ExtractOption::default(), + Some(&resolver), + )?; + // Then + assert_eq!( + imports.kept_imports, + ["./before.css", "./styles.module.css", "./after.css"] + ); + Ok(()) +} + +#[rstest::rstest] +#[case("const styles=require('./styles.module.css');")] +#[serial] +fn css_retention_keeps_unused_value_loads_when_helper_is_commonjs( + #[case] declaration: &str, +) -> Result<(), Box> { + // Given + css::file_map::reset_file_map(); + let helper = format!("{declaration}exports.color='red';"); + let resolver = move |specifier: &str, importer: &str| match (specifier, importer) { + ("./helper", "/src/card.css.ts") => Some(ResolvedModule { + path: "/src/helper.cjs".to_string(), + code: helper.clone(), + }), + ("./styles.module.css", "/src/helper.cjs") => Some(ResolvedModule { + path: "/src/styles.module.css".to_string(), + code: super::super::retained_css::CSS.to_string(), + }), + _ => None, + }; + // When + let output = crate::extract_with_modules( + "/src/card.css.ts", + super::super::retained_css::ENTRY, + ExtractOption::default(), + false, + &resolver, + )?; + // Then + assert!( + output.code.contains("import \"./styles.module.css\";"), + "{}", + output.code + ); + assert_eq!(output.dependencies, ["/src/helper.cjs"]); + Ok(()) +} + +#[rstest::rstest] +#[case("function neverCalled(){return styles.card;}", true)] +#[case("type Styles = typeof styles;", false)] +#[serial] +fn css_retention_distinguishes_value_and_type_references_when_root_is_typescript( + #[case] reference: &str, + #[case] retained: bool, +) -> Result<(), String> { + // Given + css::file_map::reset_file_map(); + let source = format!( + "import styles from './styles.module.css';{reference}export const value=['red'].join('');" + ); + let resolver = |specifier: &str, _: &str| { + (specifier == "./styles.module.css").then(|| ResolvedModule { + path: "/src/styles.module.css".to_string(), + code: super::super::retained_css::CSS.to_string(), + }) + }; + // When + let (_, imports) = crate::vanilla_extract::execute_stylesheet( + &source, + "/src/card.css.ts", + &ExtractOption::default(), + Some(&resolver), + )?; + // Then + let expected = if retained { + vec!["./styles.module.css"] + } else { + vec![] + }; + assert_eq!(imports.kept_imports, expected); + Ok(()) +} + +#[test] +#[serial] +fn css_retention_keeps_unused_value_loads_when_root_is_commonjs() -> Result<(), String> { + // Given + css::file_map::reset_file_map(); + let resolver = |specifier: &str, _: &str| { + (specifier == "./styles.module.css").then(|| ResolvedModule { + path: "/src/styles.module.css".to_string(), + code: super::super::retained_css::CSS.to_string(), + }) + }; + // When + let (_, imports) = crate::vanilla_extract::execute_stylesheet( + "const styles=require('./styles.module.css');const value=['red'].join('');", + "/src/card.css.cjs", + &ExtractOption::default(), + Some(&resolver), + )?; + // Then + assert_eq!(imports.kept_imports, ["./styles.module.css"]); + Ok(()) +} + +#[test] +#[serial] +fn css_retention_rejects_actual_read_when_helper_is_commonjs() { + // Given + css::file_map::reset_file_map(); + let resolver = |specifier: &str, importer: &str| match (specifier, importer) { + ("./helper", "/src/card.css.ts") => Some(ResolvedModule { + path: "/src/helper.cjs".to_string(), + code: "const styles=require('./styles.module.css');\nexports.color=styles.card;" + .to_string(), + }), + ("./styles.module.css", "/src/helper.cjs") => Some(ResolvedModule { + path: "/src/styles.module.css".to_string(), + code: super::super::retained_css::CSS.to_string(), + }), + _ => None, + }; + // When + let error = crate::extract_with_modules( + "/src/card.css.ts", + super::super::retained_css::ENTRY, + ExtractOption::default(), + false, + &resolver, + ) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + // Then + assert!(error.starts_with("/src/helper.cjs:2:15:"), "{error}"); + assert!( + error.contains("export 'card'") && error.contains("/src/styles.module.css"), + "{error}" + ); +} diff --git a/libs/extractor/src/module_loader/tests/css_observations.rs b/libs/extractor/src/module_loader/tests/css_observations.rs new file mode 100644 index 000000000..e00e7d65c --- /dev/null +++ b/libs/extractor/src/module_loader/tests/css_observations.rs @@ -0,0 +1,191 @@ +use serial_test::serial; + +use super::super::retained_css::{CSS, ENTRY}; +use crate::{ExtractOption, ExtractOutput, ResolvedModule}; + +fn extract_helper(helper: &str) -> Result> { + let helper = helper.to_string(); + let resolver = move |specifier: &str, importer: &str| match (specifier, importer) { + ("./helper", "/src/card.css.ts") => Some(ResolvedModule { + path: "/src/helper.ts".to_string(), + code: helper.clone(), + }), + ("./styles.module.css", "/src/helper.ts") => Some(ResolvedModule { + path: "/src/styles.module.css".to_string(), + code: CSS.to_string(), + }), + _ => None, + }; + crate::extract_with_modules( + "/src/card.css.ts", + ENTRY, + ExtractOption::default(), + false, + &resolver, + ) +} + +#[rstest::rstest] +#[case("styles.card", "card")] +#[case("styles['card']", "card")] +#[case("styles['']", "")] +#[case("styles[Symbol.toPrimitive]", "Symbol(Symbol.toPrimitive)")] +#[case("styles.valueOf", "valueOf")] +#[case("styles.toString", "toString")] +#[case("String(styles)", "Symbol(Symbol.toPrimitive)")] +#[case("+styles", "Symbol(Symbol.toPrimitive)")] +#[case("styles + ''", "Symbol(Symbol.toPrimitive)")] +#[case("`${styles}`", "Symbol(Symbol.toPrimitive)")] +#[case("'card' in styles", "has:card")] +#[case("Object.keys(styles)", "ownKeys")] +#[case("Reflect.ownKeys(styles)", "ownKeys")] +#[case("(()=>{for(const key in styles){}return 'blue';})()", "ownKeys")] +#[case("({...styles})", "ownKeys")] +#[case("[...styles]", "Symbol(Symbol.iterator)")] +#[case("JSON.stringify(styles)", "toJSON")] +#[case( + "Object.getOwnPropertyDescriptor(styles,'card')", + "getOwnPropertyDescriptor:card" +)] +#[case( + "Reflect.getOwnPropertyDescriptor(styles,'card')", + "getOwnPropertyDescriptor:card" +)] +#[case("Object.getOwnPropertyDescriptors(styles)", "ownKeys")] +#[case("Object.getPrototypeOf(styles)", "getPrototypeOf")] +#[case("Reflect.getPrototypeOf(styles)", "getPrototypeOf")] +#[case("styles.__proto__", "__proto__")] +#[case( + "Object.prototype.hasOwnProperty.call(styles,'card')", + "getOwnPropertyDescriptor:card" +)] +#[case("Reflect.has(styles,'card')", "has:card")] +#[case("Reflect.get(styles,'card')", "card")] +#[case("(()=>{const {card}=styles;return card;})()", "card")] +#[case("styles[(()=> 'card')()]", "card")] +#[serial] +fn css_observation_is_fatal_when_top_level_read_is_caught( + #[case] observation: &str, + #[case] key: &str, + #[values( + "import styles from './styles.module.css';", + "import * as styles from './styles.module.css';" + )] + import: &str, +) { + // Given: the fallback is a wrong CSS value, and error mutation cannot erase evidence. + css::file_map::reset_file_map(); + let helper = format!( + "{import}\nlet color: string='blue';\ntry{{\n {observation};\n}}catch(error){{error.message='mutated';error.stack='';color='red';}}\nexport {{color}};" + ); + // When + let error = extract_helper(&helper) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + // Then + let column = match observation { + "(()=>{for(const key in styles){}return 'blue';})()" => 9, + "({...styles})" => 5, + "[...styles]" => 4, + "`${styles}`" => 6, + "(()=>{const {card}=styles;return card;})()" => 16, + _ => 3, + }; + assert!( + error.starts_with(&format!("/src/helper.ts:4:{column}:")), + "{observation}: {error}" + ); + assert!(error.contains(&format!("export '{key}'")), "{error}"); + assert!( + error.contains("/src/styles.module.css") && error.contains("generated by the bundler"), + "{error}" + ); + assert_eq!(error.matches("Fix:").count(), 1, "{error}"); + assert!( + error.contains("className={styles.card}") && error.contains("css()"), + "{error}" + ); + assert!( + !error.contains("mutated") && !error.contains("devup-ui-stylesheet"), + "{error}" + ); +} + +#[rstest::rstest] +#[case("import styles from './styles.module.css';", "styles.card")] +#[case("import * as styles from './styles.module.css';", "styles.card")] +#[case("import {card} from './styles.module.css';", "card")] +#[case("import {card} from './styles.module.css';", "typeof card")] +#[serial] +fn css_observation_keeps_first_site_once_when_called_read_is_caught_repeatedly( + #[case] import: &str, + #[case] read: &str, +) { + // Given + css::file_map::reset_file_map(); + let helper = format!( + "{import}\nconst label: string='한글😀';\nfunction read(){{\n try{{return {read};}}catch(error){{error.message='mutated';return 'red';}}\n}}\nread();export const color=read();" + ); + // When + let error = extract_helper(&helper) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + // Then + let column = if read == "typeof card" { 21 } else { 14 }; + assert!( + error.starts_with(&format!("/src/helper.ts:4:{column}:")), + "{error}" + ); + assert_eq!(error.matches("JS execution error:").count(), 1, "{error}"); + assert_eq!(error.matches("Fix:").count(), 1, "{error}"); + assert!( + error.contains("export 'card'") && !error.contains("mutated"), + "{error}" + ); +} + +#[rstest::rstest] +#[case("import styles from './styles.module.css';")] +#[case("import * as styles from './styles.module.css';")] +#[serial] +fn css_observation_allows_known_object_when_typeof_and_passing_do_not_inspect_it( + #[case] import: &str, +) -> Result<(), Box> { + // Given: passing to both called and uncalled functions must not read properties. + css::file_map::reset_file_map(); + let helper = format!( + "{import}\nfunction identity(value){{return value;}}\nfunction neverCalled(value){{return value.card;}}\nconst holder={{styles}};const passed=identity(holder.styles);const thunk=()=>neverCalled(passed);identity(thunk);\nexport const color=typeof passed==='object'?'blue':'red';" + ); + // When + let output = extract_helper(&helper)?; + // Then + assert!(output.styles.iter().any( + |style| matches!(style, crate::ExtractStyleValue::Static(value) if value.value() == "blue") + )); + assert_eq!(output.dependencies, ["/src/helper.ts"]); + assert!(output.code.contains("import \"./styles.module.css\";")); + Ok(()) +} + +#[test] +#[serial] +fn css_observation_preserves_first_key_when_dynamic_keys_repeat_at_one_site() { + // Given + css::file_map::reset_file_map(); + let helper = "import styles from './styles.module.css';\nlet color='blue';\nfor(const key of ['card','other']){\n try{color=styles[key];}catch(error){error.message='mutated';color='red';}\n}\nexport {color};"; + // When + let error = extract_helper(helper) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + // Then + assert!(error.starts_with("/src/helper.ts:4:13:"), "{error}"); + assert!( + error.contains("export 'card'") && !error.contains("export 'other'"), + "{error}" + ); + assert_eq!(error.matches("JS execution error:").count(), 1, "{error}"); + assert_eq!(error.matches("Fix:").count(), 1, "{error}"); +} diff --git a/libs/extractor/src/module_loader/tests/css_paths.rs b/libs/extractor/src/module_loader/tests/css_paths.rs new file mode 100644 index 000000000..7806029b8 --- /dev/null +++ b/libs/extractor/src/module_loader/tests/css_paths.rs @@ -0,0 +1,114 @@ +use crate::module_loader::retained_css::{is_generated, relative_specifier}; + +#[rstest::rstest] +#[case("/src/card.css.ts", "/src/styles.css", "./styles.css")] +#[case( + "/src/pages/card.css.ts", + "/styles/global.css", + "../../styles/global.css" +)] +#[case( + "C:\\app\\src\\card.css.ts", + "c:/app/src/../styles/global.css?inline", + "../styles/global.css" +)] +#[case( + "src/pages/card.css.ts", + "src/styles/global.css", + "../styles/global.css" +)] +#[case("card.css.ts", "global.css", "./global.css")] +fn css_retention_relativizes_when_paths_have_compatible_roots( + #[case] root: &str, + #[case] target: &str, + #[case] expected: &str, +) -> Result<(), crate::module_loader::retained_css::PathError> { + // Given / When + let relative = relative_specifier(root, target)?; + // Then + assert_eq!(relative, expected); + assert!(relative.starts_with("./") || relative.starts_with("../")); + assert!(!relative.contains('\\')); + Ok(()) +} + +#[rstest::rstest] +#[case("/src/card.css.ts", "/../../styles/global.css", "../styles/global.css")] +#[case("/../../src/card.css.ts", "/styles/global.css", "../styles/global.css")] +#[case( + "C:\\src\\card.css.ts", + "c:\\..\\..\\styles\\global.css", + "../styles/global.css" +)] +fn css_retention_clamps_parent_traversal_when_absolute_paths_reach_volume_root( + #[case] root: &str, + #[case] target: &str, + #[case] expected: &str, +) -> Result<(), crate::module_loader::retained_css::PathError> { + // Given / When + let relative = relative_specifier(root, target)?; + // Then + assert_eq!(relative, expected); + Ok(()) +} + +#[rstest::rstest] +#[case( + "src/card.css.ts", + "../../styles/global.css", + "../../../styles/global.css" +)] +#[case( + "../../src/card.css.ts", + "styles/global.css", + "../../../styles/global.css" +)] +fn css_retention_preserves_parent_segments_when_relative_paths_start_above_base( + #[case] root: &str, + #[case] target: &str, + #[case] expected: &str, +) -> Result<(), crate::module_loader::retained_css::PathError> { + // Given / When + let relative = relative_specifier(root, target)?; + // Then + assert_eq!(relative, expected); + Ok(()) +} + +#[rstest::rstest] +#[case("C:/app/card.css.ts", "D:/styles/global.css")] +#[case("src/card.css.ts", "/styles/global.css")] +fn css_retention_rejects_when_paths_have_incompatible_roots( + #[case] root: &str, + #[case] target: &str, +) -> Result<(), String> { + // Given / When + let error = relative_specifier(root, target) + .err() + .ok_or("expected incompatible roots")?; + // Then + assert!(error.describe().contains(root) && error.describe().contains(target)); + assert!(error.describe().contains("Fix:")); + Ok(()) +} + +#[rstest::rstest] +#[case("df/styles/devup-ui.css", true)] +#[case("df/styles/devup-ui-17.css?inline", true)] +#[case("df/styles/my.module.css", false)] +#[case("df/styles/sub/user.css", false)] +#[case("df/styles-other/devup-ui.css", false)] +fn css_retention_exempts_only_generated_loads_when_css_directory_contains_user_files( + #[case] specifier: &str, + #[case] expected: bool, +) { + // Given + let option = crate::ExtractOption { + css_dir: "df/styles".to_string(), + ..crate::ExtractOption::default() + }; + // When + let generated = is_generated(specifier, &option); + // Then + assert_eq!(generated, expected); +} diff --git a/libs/extractor/src/module_loader/tests/css_reads.rs b/libs/extractor/src/module_loader/tests/css_reads.rs new file mode 100644 index 000000000..4b0bac1e4 --- /dev/null +++ b/libs/extractor/src/module_loader/tests/css_reads.rs @@ -0,0 +1,272 @@ +use serial_test::serial; + +use super::retained_css::{CSS, ENTRY}; +use crate::{ExtractOption, ResolvedModule}; + +#[path = "css_observations.rs"] +mod observations; + +#[path = "css_elision.rs"] +mod elision; + +#[rstest::rstest] +#[case("import styles from './styles.module.css';", false)] +#[case("import {card as cardClass} from './styles.module.css';", false)] +#[case("import * as styles from './styles.module.css';", false)] +#[case( + "import styles, {card as cardClass} from './styles.module.css';import * as namespace from './styles.module.css';", + false +)] +#[case( + "import styles from './styles.module.css';function neverCalled(){return styles.card;}", + true +)] +#[case( + "import {card} from './styles.module.css';function neverCalled(){return card;}", + true +)] +#[case( + "import * as styles from './styles.module.css';function neverCalled(){return styles.card;}", + true +)] +#[case("import './styles.module.css';", true)] +#[serial] +fn css_retention_follows_default_elision_when_css_bindings_are_unused_or_uncalled( + #[case] declarations: &str, + #[case] retained_in_ts: bool, + #[values("ts", "tsx", "mts", "cts", "js", "jsx", "mjs")] extension: &str, +) -> Result<(), Box> { + // Given + css::file_map::reset_file_map(); + let helper = format!("{declarations}export const color='red';"); + let helper_path = format!("/src/helper.{extension}"); + let retained = retained_in_ts || matches!(extension, "js" | "jsx" | "mjs" | "cjs"); + let resolver = move |specifier: &str, importer: &str| match (specifier, importer) { + ("./helper", "/src/card.css.ts") => Some(ResolvedModule { + path: helper_path.clone(), + code: helper.clone(), + }), + ("./styles.module.css", importer) if importer == helper_path => Some(ResolvedModule { + path: "/src/styles.module.css".to_string(), + code: CSS.to_string(), + }), + _ => None, + }; + // When + let output = crate::extract_with_modules( + "/src/card.css.ts", + ENTRY, + ExtractOption::default(), + false, + &resolver, + )?; + // Then + assert_eq!( + output.code.contains("import \"./styles.module.css\";"), + retained, + "{}", + output.code + ); + assert_eq!(output.dependencies, [format!("/src/helper.{extension}")]); + assert!(output.styles.iter().any( + |style| matches!(style, crate::ExtractStyleValue::Static(value) if value.value() == "red") + )); + Ok(()) +} + +#[rstest::rstest] +#[case("import styles from './styles.module.css';", "styles.card", "card")] +#[case("import styles from './styles.module.css';", "styles", "toJSON")] +#[case( + "import {card as cardClass} from './styles.module.css';", + "cardClass", + "card" +)] +#[case( + "import * as styles from './styles.module.css';", + "styles.card", + "card" +)] +#[case("import styles from './styles.module.css';", "styles['card']", "card")] +#[case("import * as styles from './styles.module.css';", "styles", "toJSON")] +#[serial] +fn css_retention_rejects_actual_read_at_original_site_when_export_is_unknown( + #[case] import: &str, + #[case] read: &str, + #[case] key: &str, +) { + // Given: type erasure and Unicode must not shift the read's original location. + css::file_map::reset_file_map(); + let helper = + format!("{import}\nconst label: string='한글😀';\nexport const color: string = {read};"); + let resolver = move |specifier: &str, importer: &str| match (specifier, importer) { + ("./helper", "/src/card.css.ts") => Some(ResolvedModule { + path: "/src/helper.ts".to_string(), + code: helper.clone(), + }), + ("./styles.module.css", "/src/helper.ts") => Some(ResolvedModule { + path: "/src/styles.module.css".to_string(), + code: CSS.to_string(), + }), + _ => None, + }; + // When + let error = crate::extract_with_modules( + "/src/card.css.ts", + ENTRY, + ExtractOption::default(), + false, + &resolver, + ) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + // Then + let site = if read == "styles" { + "/src/card.css.ts:1:88:" + } else { + "/src/helper.ts:3:30:" + }; + assert!(error.starts_with(site), "{error}"); + assert!( + error.contains("/src/styles.module.css") && error.contains(&format!("export '{key}'")), + "{error}" + ); + assert!( + error.contains("generated by the bundler") && error.contains("unknown at build time"), + "{error}" + ); + assert!(error.contains("Fix: use the class in a runtime component (className={styles.card}) or write the style with css()"), "{error}"); + assert_eq!(error.matches("Fix:").count(), 1, "{error}"); + assert!(!error.contains("devup-ui-stylesheet"), "{error}"); +} + +#[rstest::rstest] +#[case("import styles from './styles.module.css';", "styles.card")] +#[case("import {card} from './styles.module.css';", "card")] +#[case("import * as styles from './styles.module.css';", "styles.card")] +#[serial] +fn css_retention_rejects_called_function_read_when_css_value_is_requested( + #[case] import: &str, + #[case] read: &str, +) { + // Given + css::file_map::reset_file_map(); + let helper = format!( + "{import}\nfunction read(): string {{\n return {read};\n}}\nexport const color=read();" + ); + let resolver = move |specifier: &str, importer: &str| match (specifier, importer) { + ("./helper", "/src/card.css.ts") => Some(ResolvedModule { + path: "/src/helper.ts".to_string(), + code: helper.clone(), + }), + ("./styles.module.css", "/src/helper.ts") => Some(ResolvedModule { + path: "/src/styles.module.css".to_string(), + code: CSS.to_string(), + }), + _ => None, + }; + // When + let error = crate::extract_with_modules( + "/src/card.css.ts", + ENTRY, + ExtractOption::default(), + false, + &resolver, + ) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + // Then + assert!(error.starts_with("/src/helper.ts:3:10:"), "{error}"); + assert!( + error.contains("export 'card'") && error.contains("/src/styles.module.css"), + "{error}" + ); + assert!(error.contains("at read (/src/helper.ts:3:10)"), "{error}"); + assert_eq!(error.matches("Fix:").count(), 1, "{error}"); + assert!(error.contains("Fix: use the class in a runtime component (className={styles.card}) or write the style with css()"), "{error}"); +} + +#[rstest::rstest] +#[case("import styles from './styles.module.css';")] +#[case("import {card} from './styles.module.css';")] +#[case("import * as styles from './styles.module.css';")] +#[serial] +fn css_retention_elides_root_unused_css_bindings_when_typescript_erases_them( + #[case] import: &str, +) -> Result<(), String> { + // Given + css::file_map::reset_file_map(); + let source = format!( + "import './before.css';{import}import './after.css';export const value=['red'].join('');" + ); + let resolver = |_: &str, _: &str| -> Option { + panic!("root-only unused CSS loads remain bundler-owned") + }; + // When + let (_, imports) = crate::vanilla_extract::execute_stylesheet( + &source, + "/src/card.css.ts", + &ExtractOption::default(), + Some(&resolver), + )?; + // Then + assert_eq!(imports.kept_imports, ["./before.css", "./after.css"]); + Ok(()) +} + +#[test] +#[serial] +fn css_retention_preserves_order_when_unused_helper_bindings_are_erased() -> Result<(), String> { + // Given + css::file_map::reset_file_map(); + let resolver = |specifier: &str, importer: &str| { + let (path, code) = match (specifier, importer) { + ("./helper", "/src/card.css.ts") => ( + "/src/helper.ts", + "import styles from './first.module.css';import './child';import {card} from './last.module.css';export const color='red';", + ), + ("./child", "/src/helper.ts") => ("/src/child.ts", "import './child.css';"), + ("./first.module.css", "/src/helper.ts") => ("/src/first.module.css", CSS), + ("./last.module.css", "/src/helper.ts") => ("/src/last.module.css", CSS), + ("./child.css", "/src/child.ts") => ("/src/child.css", CSS), + _ => return None, + }; + Some(ResolvedModule { + path: path.to_string(), + code: code.to_string(), + }) + }; + // When + let (_, imports) = crate::vanilla_extract::execute_stylesheet( + "import {color} from './helper';export const value=color;", + "/src/card.css.ts", + &ExtractOption::default(), + Some(&resolver), + )?; + // Then + assert_eq!(imports.kept_imports, ["./child.css"]); + Ok(()) +} + +#[test] +#[serial] +fn css_retention_ignores_type_only_imports_when_no_css_is_loaded() -> Result<(), String> { + // Given + css::file_map::reset_file_map(); + let source = "import type Styles from './styles.module.css';import {type Card} from './other.module.css';export const color='red';"; + let resolver = |_: &str, _: &str| -> Option { + panic!("type-only CSS must not be resolved") + }; + // When + let (_, imports) = crate::vanilla_extract::execute_stylesheet( + source, + "/src/card.css.ts", + &ExtractOption::default(), + Some(&resolver), + )?; + // Then + assert_eq!(imports.kept_imports, Vec::::new()); + Ok(()) +} diff --git a/libs/extractor/src/module_loader/tests/regressions.rs b/libs/extractor/src/module_loader/tests/regressions.rs new file mode 100644 index 000000000..7317821e1 --- /dev/null +++ b/libs/extractor/src/module_loader/tests/regressions.rs @@ -0,0 +1,188 @@ +use serial_test::serial; + +use super::{error_of, parts}; + +#[rstest::rstest] +#[case("globalStyle('body', { get width() { throw new Error('serialize'); } })")] +#[case("globalFontFace('font', [{ get src() { throw new Error('serialize'); } }])")] +#[case("styleVariants({ a: { toJSON() { throw new Error('serialize'); } } })")] +#[case("style({ toJSON() { throw new Error('serialize'); } })")] +#[case("keyframes({ toJSON() { throw new Error('serialize'); } })")] +#[case("fontFace({ toJSON() { throw new Error('serialize'); } })")] +#[case("({ get width() { throw new Error('serialize'); } })")] +#[case("[1, { get width() { throw new Error('serialize'); } }]")] +#[serial] +fn serialization_exceptions_keep_the_original_throw_location( + #[case] expression: &str, +) -> Result<(), String> { + let source = format!( + "import {{ style, globalStyle, globalFontFace, styleVariants, keyframes, fontFace }} from '@devup-ui/react';\nexport const result = {expression};\n" + ); + let expected = crate::locate( + "/a.css.ts", + &source, + source.find("new Error").ok_or("missing throw fixture")?, + ); + + let error = error_of("/a.css.ts", &source, &[]); + + assert!( + error.starts_with(&format!( + "{expected}: JS execution error: Error: serialize. Fix: " + )), + "{error}" + ); + Ok(()) +} + +#[test] +#[serial] +fn fatal_entry_syntax_is_rejected_before_stripping() { + let source = "import { style } from '@devup-ui/react';\nexport const broken = ;\n"; + + let error = error_of("/a.css.ts", source, &[]); + + assert!( + error.starts_with("/a.css.ts:2:23: JS execution error: SyntaxError:"), + "{error}" + ); + assert!(error.contains("Fix: correct the syntax"), "{error}"); +} + +#[rstest::rstest] +#[case(&[("/b.ts", "export const broken = ;\n")], "./b")] +#[case(&[("/b.css.ts", "export const broken = ;\n")], "./b.css")] +#[serial] +fn fatal_import_syntax_is_located_in_the_imported_file( + #[case] files: super::Files, + #[case] specifier: &str, +) { + let filename = files[0].0; + let entry = format!("import {{ broken }} from '{specifier}';\nexport const x = broken;\n"); + + let error = error_of("/a.css.ts", &entry, files); + + assert!( + error.starts_with(&format!( + "{filename}:1:23: JS execution error: SyntaxError:" + )), + "{error}" + ); +} + +#[test] +#[serial] +fn alias_edits_are_applied_to_parser_diagnostic_labels() -> Result<(), String> { + use crate::import_alias_visit::transform_import_aliases_with_edits; + use crate::vanilla_extract::{Stylesheet, execute_located}; + let source = "import { style } from '@vanilla-extract/css'; export const broken = ;"; + let aliases = std::collections::HashMap::from([( + "@vanilla-extract/css".to_string(), + crate::ImportAlias::NamedToNamed, + )]); + let aliased = + transform_import_aliases_with_edits(source, "/a.css.ts", super::PACKAGE, &aliases); + let expected = crate::locate( + "/a.css.ts", + source, + source.rfind(';').ok_or("missing semicolon fixture")?, + ); + + let error = execute_located( + Stylesheet { + filename: "/a.css.ts", + code: &aliased.code, + source, + edits: &[&aliased.edits], + }, + &crate::ExtractOption::default(), + None, + ) + .err() + .ok_or("expected invalid syntax to fail")?; + + assert!( + error.starts_with(&format!("{expected}: JS execution error: SyntaxError:")), + "{error}" + ); + Ok(()) +} + +#[test] +#[serial] +fn typed_retained_nested_functions_keep_original_coordinates() -> Result<(), String> { + const IMPORTED: &str = "import { style } from '@devup-ui/react';\nexport const s = style({});\nexport const invoke = (): string => {\n const inner = (): string => { throw new Error('nested retained'); };\n return inner();\n};"; + let source = "import { invoke } from './b.css';\nexport const x = invoke();"; + let imported = IMPORTED; + let expected = crate::locate( + "/b.css.ts", + imported, + imported.find("new Error").ok_or("missing throw fixture")?, + ); + + let error = error_of("/a.css.ts", source, &[("/b.css.ts", IMPORTED)]); + + let (lead, _, calls) = parts(&error); + assert_eq!( + lead, + format!("{expected}: JS execution error: Error: nested retained") + ); + assert!( + calls.starts_with(&format!(" at inner ({expected})")), + "{error}" + ); + assert!(calls.contains("at invoke (/b.css.ts:5:"), "{error}"); + assert!(calls.ends_with("at
(/a.css.ts:2:24)"), "{error}"); + Ok(()) +} + +#[test] +#[serial] +fn retained_function_frames_survive_style_extraction_inside_the_function() -> Result<(), String> { + const IMPORTED: &str = "import { style, css } from '@devup-ui/react';\nexport const s = style({});\nexport const fail = () => { const c = css({ color: 'red' }); throw new Error('compiled retained'); };"; + let expected = crate::locate( + "/b.css.ts", + IMPORTED, + IMPORTED.find("new Error").ok_or("missing throw fixture")?, + ); + + let error = error_of( + "/a.css.ts", + "import { fail } from './b.css';\nexport const x = fail();", + &[("/b.css.ts", IMPORTED)], + ); + + assert!( + error.starts_with(&format!( + "{expected}: JS execution error: Error: compiled retained. Fix:" + )), + "{error}" + ); + Ok(()) +} + +#[rstest::rstest] +#[case("style")] +#[case("fontFace")] +#[case("keyframes")] +#[serial] +fn cyclic_json_values_report_the_serialization_exception(#[case] api: &str) -> Result<(), String> { + let source = format!( + "import {{ {api} }} from '@devup-ui/react';\nconst o = {{}}; o.self = o;\nexport const result = {api}(o);" + ); + let expected = crate::locate( + "/a.css.ts", + &source, + source.find("(o)").ok_or("missing call fixture")?, + ); + + let error = error_of("/a.css.ts", &source, &[]); + + assert!( + error.starts_with(&format!( + "{expected}: JS execution error: TypeError: cyclic object value. Fix:" + )), + "{error}" + ); + Ok(()) +} diff --git a/libs/extractor/src/module_loader/tests/retained_css.rs b/libs/extractor/src/module_loader/tests/retained_css.rs new file mode 100644 index 000000000..248ac6bd8 --- /dev/null +++ b/libs/extractor/src/module_loader/tests/retained_css.rs @@ -0,0 +1,263 @@ +use serial_test::serial; + +use crate::{ExtractOption, ResolvedModule}; + +pub(super) const ENTRY: &str = "import {style} from '@devup-ui/react';import {color} from './helper';export const card=style({color});"; +pub(super) const CSS: &str = ".card { color: red; }"; + +#[rstest::rstest] +#[case( + "/src/card.css.ts", + "/src/helper.ts", + "./global.css", + "/src/global.css", + "./global.css" +)] +#[case( + "/src/pages/card.css.ts", + "/src/helpers/helper.ts", + "./global.css", + "/src/helpers/global.css", + "../helpers/global.css" +)] +#[case( + "/src/card.css.ts", + "/src/helper.ts", + "theme/package.css", + "/node_modules/theme/dist/package.css", + "../node_modules/theme/dist/package.css" +)] +#[case( + "/src/card.css.ts", + "/src/helper.ts", + "./global.CSS?inline", + "/src/global.CSS?inline", + "./global.CSS?inline" +)] +#[case( + "C:/app/src/card.css.ts", + "C:/app/helpers/helper.ts", + "./global.css", + "C:\\app\\helpers\\global.css", + "../helpers/global.css" +)] +#[serial] +fn css_retention_emits_portable_specifiers_when_helper_loads_css( + #[case] root: &'static str, + #[case] helper: &'static str, + #[case] imported: &'static str, + #[case] target: &'static str, + #[case] emitted: &'static str, +) -> Result<(), Box> { + // Given: the resolver discriminates importer directories and package exports. + css::file_map::reset_file_map(); + let helper_code = format!("import {imported:?};export const color='red';"); + let resolver = move |specifier: &str, importer: &str| { + let (path, code) = if (specifier, importer) == ("./helper", root) { + (helper, helper_code.as_str()) + } else if (specifier, importer) == (imported, helper) + || (specifier, importer) == (emitted, root) + { + (target, CSS) + } else { + return None; + }; + Some(ResolvedModule { + path: path.to_string(), + code: code.to_string(), + }) + }; + // When + let output = + crate::extract_with_modules(root, ENTRY, ExtractOption::default(), false, &resolver)?; + // Then: the emitted load resolves to the helper's actual CSS, not root/global.css. + let allocator = oxc_allocator::Allocator::default(); + let parsed = + oxc_parser::Parser::new(&allocator, &output.code, oxc_span::SourceType::mjs()).parse(); + let loads: Vec<_> = parsed + .program + .body + .iter() + .filter_map(|statement| match statement { + oxc_ast::ast::Statement::ImportDeclaration(import) + if !crate::package_specifier::is_package( + import.source.value.as_str(), + "@devup-ui/react", + ) => + { + Some(import.source.value.as_str()) + } + _ => None, + }) + .collect(); + assert_eq!(loads, [emitted]); + assert_eq!( + resolver(loads[0], root).map(|module| module.path), + Some(target.to_string()) + ); + assert_eq!(output.dependencies, [helper]); + Ok(()) +} + +#[rstest::rstest] +#[case("import './root.css';import {color} from './helper';import './after.css';", vec!["./root.css", "./shared.css?inline", "./child.css", "./helper.css", "./after.css"])] +#[case("import './shared-alias.css';import {color} from './helper';import './after.css';", vec!["./shared-alias.css", "./child.css", "./helper.css", "./after.css"])] +#[case("import {color} from './helper';import './shared-alias.css';import './after.css';", vec!["./shared.css?inline", "./child.css", "./helper.css", "./after.css"])] +#[case("import './shared-alias.css';import './shared.css?inline';import {color} from './helper';", vec!["./shared-alias.css", "./child.css", "./helper.css"])] +#[serial] +fn css_retention_orders_and_deduplicates_when_root_and_helpers_interleave( + #[case] imports: &str, + #[case] expected: Vec<&str>, +) -> Result<(), String> { + // Given + css::file_map::reset_file_map(); + let resolver = |specifier: &str, importer: &str| { + let (path, code) = match (specifier, importer) { + ("./helper", "/src/card.css.ts") => ( + "/src/helper.ts", + "import './child';import './helper.css';import './child';export const color='red';", + ), + ("./child", "/src/helper.ts") => ( + "/src/child.ts", + "import './shared.css?inline';import './child.css';", + ), + ("./shared.css?inline", "/src/child.ts" | "/src/card.css.ts") + | ("./shared-alias.css", "/src/card.css.ts") => ("/src/shared.css", CSS), + ("./root.css", "/src/card.css.ts") => ("/src/root.css", CSS), + ("./after.css", "/src/card.css.ts") => ("/src/after.css", CSS), + ("./child.css", "/src/child.ts" | "/src/card.css.ts") => ("/src/child.css", CSS), + ("./helper.css", "/src/helper.ts" | "/src/card.css.ts") => ("/src/helper.css", CSS), + _ => return None, + }; + Some(ResolvedModule { + path: path.to_string(), + code: code.to_string(), + }) + }; + let source = format!("{imports}export const value=color;"); + // When + let (_, output) = crate::vanilla_extract::execute_stylesheet( + &source, + "/src/card.css.ts", + &ExtractOption::default(), + Some(&resolver), + )?; + // Then + assert_eq!(output.kept_imports, expected); + for specifier in &output.kept_imports { + assert!( + resolver(specifier, "/src/card.css.ts").is_some(), + "{specifier}" + ); + } + assert_eq!( + output.dependencies.into_iter().collect::>(), + ["/src/child.ts", "/src/helper.ts"] + ); + Ok(()) +} + +#[test] +#[serial] +fn css_retention_locates_missing_file_when_helper_imports_it() { + // Given + css::file_map::reset_file_map(); + let resolver = |specifier: &str, importer: &str| { + ((specifier, importer) == ("./helper", "/src/card.css.ts")).then(|| ResolvedModule { + path: "/src/helper.ts".to_string(), + code: "const label: string='한글😀';\nimport './missing.css';export const color='red';" + .to_string(), + }) + }; + // When + let error = crate::extract_with_modules( + "/src/card.css.ts", + ENTRY, + ExtractOption::default(), + false, + &resolver, + ) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + // Then + assert!(error.starts_with("/src/helper.ts:2:8:"), "{error}"); + assert!( + error.contains("./missing.css") && error.contains("cannot be found"), + "{error}" + ); + assert!( + error.contains("stylesheet loads CSS through this helper"), + "{error}" + ); + assert!( + error.contains("Fix: correct the path or import CSS from a component module"), + "{error}" + ); +} + +#[test] +#[serial] +fn css_retention_locates_drive_error_when_target_is_on_another_drive() { + // Given + css::file_map::reset_file_map(); + let resolver = |specifier: &str, importer: &str| match (specifier, importer) { + ("./helper", "C:/src/card.css.ts") => Some(ResolvedModule { + path: "C:/src/helper.ts".to_string(), + code: "import './global.css';export const color='red';".to_string(), + }), + ("./global.css", "C:/src/helper.ts") => Some(ResolvedModule { + path: "D:/styles/global.css".to_string(), + code: CSS.to_string(), + }), + _ => None, + }; + // When + let error = crate::extract_with_modules( + "C:/src/card.css.ts", + ENTRY, + ExtractOption::default(), + false, + &resolver, + ) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + // Then + assert!(error.starts_with("C:/src/helper.ts:1:8:"), "{error}"); + assert!( + error.contains("different Windows drives") && error.contains("D:/styles/global.css"), + "{error}" + ); + assert!(error.contains("Fix:"), "{error}"); +} + +#[test] +#[serial] +fn css_retention_preserves_exemptions_when_helper_has_generated_and_api_loads() -> Result<(), String> +{ + // Given + css::file_map::reset_file_map(); + let option = ExtractOption { + css_dir: "df/styles".to_string(), + ..ExtractOption::default() + }; + let resolver = |specifier: &str, importer: &str| { + assert_eq!((specifier, importer), ("./helper", "/src/card.css.ts")); + Some(ResolvedModule { path: "/src/helper.ts".to_string(), code: "import '@devup-ui/react/devup-ui.css';import '@devup-ui/react/compat';import 'df/styles/devup-ui.css';export const color='red';".to_string() }) + }; + // When + let (_, imports) = crate::vanilla_extract::execute_stylesheet( + "import {color} from './helper';export const value=color;", + "/src/card.css.ts", + &option, + Some(&resolver), + )?; + // Then + assert_eq!(imports.kept_imports, Vec::::new()); + assert_eq!( + imports.dependencies.into_iter().collect::>(), + ["/src/helper.ts"] + ); + Ok(()) +} diff --git a/libs/extractor/src/module_loader/tests/sandbox.rs b/libs/extractor/src/module_loader/tests/sandbox.rs new file mode 100644 index 000000000..9a76c008d --- /dev/null +++ b/libs/extractor/src/module_loader/tests/sandbox.rs @@ -0,0 +1,124 @@ +use serial_test::serial; + +use super::{error_of, parts}; + +#[rstest::rstest] +#[case("new Date(0)", "Date")] +#[case("Math.random()", "Math.random")] +#[case("globalThis['window']", "window")] +#[case( + "(() => { try { Date.now(); } catch (error) { delete error.message; throw error; } })()", + "Date" +)] +#[case( + "(() => { try { Date.now(); } catch (error) { error.message = 'forged'; } return 'red'; })()", + "Date" +)] +#[serial] +fn forbidden_reads_keep_the_actual_original_site_and_immutable_cause( + #[case] expression: &str, + #[case] name: &str, +) -> Result<(), String> { + let source = + format!("import {{ style }} from '@devup-ui/react';\nexport const card = {expression};"); + let token = match name { + "Math.random" => "Math", + "window" => "globalThis", + _ => name, + }; + let offset = source.find(token).ok_or("missing read fixture")?; + let expected = crate::locate("/a.css.ts", &source, offset); + + let error = error_of("/a.css.ts", &source, &[]); + + assert!( + error.starts_with(&format!( + "{expected}: JS execution error: ReferenceError: `{name}` cannot be read at build time:" + )), + "{error}" + ); + assert_eq!(error.matches("Fix:").count(), 1, "{error}"); + assert!(!error.contains("forged"), "{error}"); + for internal in [ + "devup-ui-stylesheet", + "", + "__devup_read_site_", + ] { + assert!(!error.contains(internal), "{error}"); + } + Ok(()) +} + +#[test] +#[serial] +fn a_retained_stylesheet_function_reports_its_own_forbidden_read() { + const IMPORTED: &str = "import { style } from '@devup-ui/react';\nexport const s = style({});\nexport const read = (): string => {\n return Date.now();\n};"; + + let error = error_of( + "/a.css.ts", + "import { read } from './b.css';\nexport const result = read();", + &[("/b.css.ts", IMPORTED)], + ); + + assert!( + error.starts_with("/b.css.ts:4:10: JS execution error: ReferenceError: `Date`"), + "{error}" + ); + assert!(!error.contains("devup-ui-stylesheet"), "{error}"); +} + +#[rstest::rstest] +#[case("[...null]")] +#[case("[...[...null]]")] +#[case("[...[], ...null]")] +#[serial] +fn an_iterable_operation_without_an_engine_frame_is_located_exactly(#[case] expression: &str) { + let source = format!( + "import {{ style }} from '@devup-ui/react';\nconst unused = {expression};\nexport const card = style({{ color: 'red' }});" + ); + + let error = error_of("/a.css.ts", &source, &[]); + + let (lead, _, _) = parts(&error); + let column = match expression { + "[...[...null]]" => 21, + "[...[], ...null]" => 24, + _ => 17, + }; + assert_eq!( + lead, + format!( + "/a.css.ts:2:{column}: JS execution error: TypeError: cannot convert 'null' or 'undefined' to object" + ), + "{error}" + ); + assert!(!error.contains("devup-ui-stylesheet"), "{error}"); +} + +#[test] +#[serial] +fn iterable_markers_preserve_receiver_lexical_values_and_evaluation_order() -> Result<(), String> { + let source = "const events = [];\nconst iterable = { value: 'red', *[Symbol.iterator]() { events.push(this.value); yield this.value; } };\nexport const values = (() => { const local = 'blue'; return [events.push('before'), ...iterable, local, events.push('after')]; })();\nexport const order = events;"; + + let (collected, _) = crate::vanilla_extract::execute_stylesheet( + source, + "/a.css.ts", + &crate::ExtractOption::default(), + None, + )?; + + assert_eq!( + collected.constant_exports, + [ + ( + "values".to_string(), + "[1, \"red\", \"blue\", 3]".to_string() + ), + ( + "order".to_string(), + "[\"before\", \"red\", \"after\"]".to_string() + ) + ] + ); + Ok(()) +} diff --git a/libs/extractor/src/module_loader/tests/side_effect_dependency.rs b/libs/extractor/src/module_loader/tests/side_effect_dependency.rs new file mode 100644 index 000000000..f8c1383bd --- /dev/null +++ b/libs/extractor/src/module_loader/tests/side_effect_dependency.rs @@ -0,0 +1,304 @@ +use serial_test::serial; + +use crate::{ExtractOption, ResolvedModule}; + +const ENTRY: &str = "import {style} from '@devup-ui/react'; import {color} from './helper'; export const card=style({color});"; + +#[rstest::rstest] +#[case( + "throw new Error('side-effect boom');", + "/src/effect.ts:1:7", + "Error: side-effect boom" +)] +#[case( + "const label: string = '한글😀';\nfunction fail(): void {\n throw new Error('side-effect boom');\n}\nfail();", + "/src/effect.ts:3:9", + "Error: side-effect boom" +)] +#[case( + "try{Date.now()}catch{}", + "/src/effect.ts:1:5", + "ReferenceError: `Date`" +)] +#[case( + "const label: string = '한글😀';\ntry {\n Date.now();\n} catch {}", + "/src/effect.ts:3:3", + "ReferenceError: `Date`" +)] +#[serial] +fn side_effect_dependency_failures_keep_original_locations( + #[case] effect: &'static str, + #[case] location: &str, + #[case] cause: &str, +) { + // Given + css::file_map::reset_file_map(); + let resolver = move |specifier: &str, importer: &str| match (specifier, importer) { + ("./helper", "/src/card.css.ts") => Some(ResolvedModule { + path: "/src/helper.ts".to_string(), + code: "import './effect';export const color='red';".to_string(), + }), + ("./effect", "/src/helper.ts") => Some(ResolvedModule { + path: "/src/effect.ts".to_string(), + code: effect.to_string(), + }), + _ => None, + }; + // When + let error = crate::extract_with_modules( + "/src/card.css.ts", + ENTRY, + ExtractOption::default(), + false, + &resolver, + ) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + // Then + assert!( + error.starts_with(&format!("{location}: JS execution error: {cause}")), + "{error}" + ); + assert!(error.contains(". Fix: "), "{error}"); + assert!(error.contains(&format!("({location})")), "{error}"); + assert!(!error.contains("devup-ui-stylesheet"), "{error}"); +} + +#[test] +#[serial] +fn side_effect_dependency_missing_import_is_located_in_helper() { + // Given + css::file_map::reset_file_map(); + let resolver = |specifier: &str, importer: &str| { + (specifier == "./helper" && importer == "/src/card.css.ts").then(|| ResolvedModule { + path: "/src/helper.ts".to_string(), + code: "const label: string = '한글😀';\nimport './missing';\nexport const color='red';" + .to_string(), + }) + }; + // When + let error = crate::extract_with_modules( + "/src/card.css.ts", + ENTRY, + ExtractOption::default(), + false, + &resolver, + ) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + // Then + assert!( + error.starts_with( + "/src/helper.ts:2:8: Cannot resolve './missing' from '/src/helper.ts'. Fix: " + ), + "{error}" + ); +} + +#[test] +#[serial] +fn side_effect_dependency_pure_module_is_tracked_in_output() +-> Result<(), Box> { + // Given + css::file_map::reset_file_map(); + let resolver = |specifier: &str, importer: &str| match (specifier, importer) { + ("./helper", "/src/card.css.ts") => Some(ResolvedModule { + path: "/src/helper.ts".to_string(), + code: "import './effect';export const color='red';".to_string(), + }), + ("./effect", "/src/helper.ts") => Some(ResolvedModule { + path: "/src/effect.ts".to_string(), + code: "export const unused: string = 'pure';".to_string(), + }), + _ => None, + }; + // When + let output = crate::extract_with_modules( + "/src/card.css.ts", + ENTRY, + ExtractOption::default(), + false, + &resolver, + )?; + // Then + assert_eq!(output.dependencies, ["/src/effect.ts", "/src/helper.ts"]); + assert!(output.styles.iter().any(|style| matches!(style, + crate::ExtractStyleValue::Static(value) if value.property() == "color" && value.value() == "red" + ))); + Ok(()) +} + +#[test] +#[serial] +fn side_effect_dependency_duplicate_diamond_cycle_executes_once_in_order() -> Result<(), String> { + // Given + css::file_map::reset_file_map(); + let resolver = |specifier: &str, importer: &str| { + let (path, code) = match (specifier, importer) { + ("./helper", "/src/card.css.ts") => ( + "/src/helper.ts", + "import {events} from './init';import './left';import './left';import './right';events.push('helper');export const color=events.join(',');", + ), + ("./init", "/src/helper.ts" | "/src/left.ts" | "/src/right.ts" | "/src/shared.ts") => { + ("/src/init.ts", "export const events=['init'];") + } + ("./left", "/src/helper.ts" | "/src/shared.ts") => ( + "/src/left.ts", + "import {events} from './init';import './shared';events.push('left');", + ), + ("./right", "/src/helper.ts") => ( + "/src/right.ts", + "import {events} from './init';import './shared-alias';events.push('right');", + ), + ("./shared", "/src/left.ts") | ("./shared-alias", "/src/right.ts") => ( + "/src/shared.ts", + "import {events} from './init';import './left';events.push('shared');", + ), + _ => return None, + }; + Some(ResolvedModule { + path: path.to_string(), + code: code.to_string(), + }) + }; + // When + let (collected, imports) = crate::vanilla_extract::execute_stylesheet( + "import {color} from './helper';export const order=color;", + "/src/card.css.ts", + &ExtractOption::default(), + Some(&resolver), + )?; + // Then + assert_eq!( + collected.constant_exports, + [( + "order".to_string(), + "\"init,shared,left,right,helper\"".to_string() + )] + ); + assert_eq!( + imports.dependencies.into_iter().collect::>(), + [ + "/src/helper.ts", + "/src/init.ts", + "/src/left.ts", + "/src/right.ts", + "/src/shared.ts" + ] + ); + Ok(()) +} + +#[rstest::rstest] +#[case("./root-effect")] +#[case("external-package")] +#[case("./global.css")] +#[case("@devup-ui/react-values")] +#[serial] +fn side_effect_dependency_entry_imports_stay_kept(#[case] specifier: &str) -> Result<(), String> { + // Given + css::file_map::reset_file_map(); + let source = format!("import '{specifier}';export const color='red';"); + let resolver = |_: &str, _: &str| -> Option { + panic!("entry bare import must not be loaded") + }; + // When + let (collected, imports) = crate::vanilla_extract::execute_stylesheet( + &source, + "/src/card.css.ts", + &ExtractOption::default(), + Some(&resolver), + )?; + // Then + assert_eq!( + collected.constant_exports, + [("color".to_string(), "\"red\"".to_string())] + ); + assert_eq!(imports.kept_imports, [specifier]); + assert_eq!(imports.dependencies.len(), 0); + Ok(()) +} + +#[test] +#[serial] +fn side_effect_dependency_helper_css_and_api_subpaths_are_not_evaluated() -> Result<(), String> { + // Given + css::file_map::reset_file_map(); + let resolver = |specifier: &str, importer: &str| { + match (specifier, importer) { + ("./helper", "/src/card.css.ts") => Some(ResolvedModule { + path: "/src/helper.ts".to_string(), + code: "import './global.css';import '@devup-ui/react';import '@devup-ui/react/compat';import '@devup-ui/react/styles';export const color='red';".to_string(), + }), + ("./global.css", "/src/helper.ts") => Some(ResolvedModule { + path: "/src/global.css".to_string(), + code: ".card { color: red; }".to_string(), + }), + _ => panic!("API subpaths must not be resolved: {specifier} from {importer}"), + } + }; + // When + let (collected, imports) = crate::vanilla_extract::execute_stylesheet( + "import {color} from './helper';export const colorValue=color;", + "/src/card.css.ts", + &ExtractOption::default(), + Some(&resolver), + )?; + // Then + assert_eq!( + collected.constant_exports, + [("colorValue".to_string(), "\"red\"".to_string())] + ); + assert_eq!(imports.dependencies.len(), 1); + assert!(imports.dependencies.contains("/src/helper.ts")); + assert_eq!(imports.kept_imports, ["./global.css"]); + Ok(()) +} + +#[test] +#[serial] +fn side_effect_dependency_external_sibling_package_executes() -> Result<(), String> { + // Given + css::file_map::reset_file_map(); + let resolver = |specifier: &str, importer: &str| { + match (specifier, importer) { + ("./helper", "/src/card.css.ts") => Some(ResolvedModule { + path: "/src/helper.ts".to_string(), + code: "import {state} from './state';import '@devup-ui/react-values';export const color=state.color;" + .to_string(), + }), + ("@devup-ui/react-values", "/src/helper.ts") => Some(ResolvedModule { + path: "/node_modules/@devup-ui/react-values/index.js".to_string(), + code: "import {state} from './state';state.color='blue';".to_string(), + }), + ("./state", "/src/helper.ts" | "/node_modules/@devup-ui/react-values/index.js") => Some(ResolvedModule { + path: "/src/state.ts".to_string(), + code: "export const state={color:'red'};".to_string(), + }), + _ => None, + } + }; + // When + let (collected, imports) = crate::vanilla_extract::execute_stylesheet( + "import {color} from './helper';export const colorValue=color;", + "/src/card.css.ts", + &ExtractOption::default(), + Some(&resolver), + )?; + // Then + assert_eq!( + collected.constant_exports, + [("colorValue".to_string(), "\"blue\"".to_string())] + ); + assert_eq!( + imports.dependencies.into_iter().collect::>(), + [ + "/node_modules/@devup-ui/react-values/index.js", + "/src/helper.ts", + "/src/state.ts" + ] + ); + Ok(()) +} diff --git a/libs/extractor/src/module_loader/validate.rs b/libs/extractor/src/module_loader/validate.rs new file mode 100644 index 000000000..12f3d47bf --- /dev/null +++ b/libs/extractor/src/module_loader/validate.rs @@ -0,0 +1,37 @@ +//! Reject invalid source before stripping can discard the failing program. + +use oxc_allocator::Allocator; +use oxc_parser::Parser; +use oxc_semantic::SemanticBuilder; +use oxc_span::SourceType; + +use crate::import_alias_visit::source_offset; +use crate::vanilla_extract::Stylesheet; + +pub(crate) fn validate(input: Stylesheet<'_>) -> Result<(), String> { + let allocator = Allocator::default(); + let source_type = SourceType::from_path(input.filename).unwrap_or_else(|_| SourceType::ts()); + let parsed = Parser::new(&allocator, input.code, source_type).parse(); + let semantic = SemanticBuilder::new() + .with_check_syntax_error(true) + .build(&parsed.program); + if let Some(error) = parsed + .diagnostics + .iter() + .chain(semantic.diagnostics.iter()) + .next() + { + let offset = error.labels.first().map_or(0, |label| { + usize::try_from(label.offset()).unwrap_or(input.code.len()) + }); + let offset = input + .edits + .iter() + .fold(offset, |at, edits| source_offset(edits, at)); + return Err(format!( + "{}: JS execution error: SyntaxError: {error}. Fix: correct the syntax at this location", + crate::locate(input.filename, input.source, offset) + )); + } + Ok(()) +} diff --git a/libs/extractor/src/module_reference.rs b/libs/extractor/src/module_reference.rs new file mode 100644 index 000000000..ec9100b59 --- /dev/null +++ b/libs/extractor/src/module_reference.rs @@ -0,0 +1,95 @@ +use oxc_allocator::Allocator; +use oxc_ast::{ + AstKind, + ast::{Argument, Statement}, +}; +use oxc_parser::Parser; +use oxc_semantic::SemanticBuilder; +use oxc_span::SourceType; + +/// An original module reference's one-based source coordinates. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct ModuleReferenceLocation { + pub line: usize, + pub column: usize, +} + +/// Locate all matching imports, re-exports and unshadowed literal `require` calls. +/// +/// Positions point at the original specifier literal, in source order. Empty +/// results mean no matching AST reference exists, not a synthetic `1:1` site. +#[must_use] +pub fn original_module_reference_locations( + filename: &str, + source: &str, + specifier: &str, +) -> Vec { + let allocator = Allocator::default(); + let source_type = SourceType::from_path(filename).unwrap_or_else(|_| SourceType::ts()); + let parsed = Parser::new(&allocator, source, source_type).parse(); + let program = parsed.program; + let mut offsets = Vec::new(); + for statement in &program.body { + let literal = match statement { + Statement::ImportDeclaration(import) => Some(&import.source), + Statement::ExportFromDeclaration(export) => Some(&export.source), + Statement::ExportAllDeclaration(export) => Some(&export.source), + _ => None, + }; + if let Some(literal) = literal + && literal.value == specifier + { + offsets.push(literal.span.start); + } + } + let semantic = SemanticBuilder::new() + .with_build_nodes(true) + .build(&program) + .semantic; + if let Some(references) = semantic + .scoping() + .root_unresolved_references() + .get("require") + { + for reference in references { + let node = semantic.scoping().get_reference(*reference).node_id(); + if let AstKind::CallExpression(call) = semantic.nodes().parent_kind(node) + && let [Argument::StringLiteral(literal)] = call.arguments.as_slice() + && literal.value == specifier + { + offsets.push(literal.span.start); + } + } + } + offsets.sort_unstable(); + offsets.dedup(); + offsets + .into_iter() + .filter_map(|offset| { + let prefix = source.get(..usize::try_from(offset).ok()?)?; + let mut location = ModuleReferenceLocation { line: 1, column: 1 }; + let mut chars = prefix.chars().peekable(); + while let Some(character) = chars.next() { + match character { + '\r' => { + if chars.peek() == Some(&'\n') { + chars.next(); + } + location.line += 1; + location.column = 1; + } + '\n' | '\u{2028}' | '\u{2029}' => { + location.line += 1; + location.column = 1; + } + _ => location.column += 1, + } + } + Some(location) + }) + .collect() +} + +#[cfg(test)] +#[path = "module_reference_tests.rs"] +mod tests; diff --git a/libs/extractor/src/module_reference_tests.rs b/libs/extractor/src/module_reference_tests.rs new file mode 100644 index 000000000..d1d5fdcdb --- /dev/null +++ b/libs/extractor/src/module_reference_tests.rs @@ -0,0 +1,111 @@ +use super::*; + +#[rstest::rstest] +#[case("\n")] +#[case("\r")] +#[case("\r\n")] +#[case("\u{2028}")] +#[case("\u{2029}")] +fn locations_when_extensionless_typescript_uses_javascript_line_separators( + #[case] separator: &str, +) { + // Given + let source = format!( + "type Color = string;{separator}import {{{separator} color{separator}}} from './colors';{separator}const 이름 = '✓🦀'; require('./colors');" + ); + // When + let locations = original_module_reference_locations("theme", &source, "./colors"); + // Then + assert_eq!( + locations, + vec![ + ModuleReferenceLocation { line: 4, column: 8 }, + ModuleReferenceLocation { + line: 5, + column: 26 + }, + ] + ); +} + +#[test] +fn locations_when_unknown_extension_uses_typescript() { + // Given + let source = "type Color = string;\r\nexport { color } from './colors';"; + // When + let locations = original_module_reference_locations("theme.custom", source, "./colors"); + // Then + assert_eq!( + locations, + vec![ModuleReferenceLocation { + line: 2, + column: 23 + }] + ); +} + +#[test] +fn locations_when_typescript_imports_and_reexports_span_lines() { + // Given + let source = "type Color = string;\nimport {\n color\n} from './colors';\nexport { color } from './colors';\nexport * from './colors';"; + // When + let locations = original_module_reference_locations("theme.ts", source, "./colors"); + // Then + assert_eq!( + locations, + vec![ + ModuleReferenceLocation { line: 4, column: 8 }, + ModuleReferenceLocation { + line: 5, + column: 23 + }, + ModuleReferenceLocation { + line: 6, + column: 15 + }, + ] + ); +} + +#[test] +fn locations_when_only_global_literal_require_is_a_reference() { + // Given + let source = "// require('./colors')\nconst text = \"./colors\";\nfunction f(require) { require('./colors'); }\nconst a = require(\n './colors'\n);\nrequire(variable);\nobj.require('./colors');\nrequire('./other');"; + // When + let locations = original_module_reference_locations("theme.cts", source, "./colors"); + // Then + assert_eq!( + locations, + vec![ModuleReferenceLocation { line: 5, column: 2 }] + ); +} + +#[test] +fn locations_when_references_are_absent_are_not_invented() { + // Given + let source = "const text = './colors';"; + // When + let locations = original_module_reference_locations("theme.ts", source, "./colors"); + // Then + assert_eq!(locations, vec![]); +} + +#[test] +fn locations_when_duplicate_specifiers_and_unicode_are_present() { + // Given + let source = "const 이름 = '✓'; require('./colors');\nimport './colors';\nrequire('./colors');"; + // When + let locations = original_module_reference_locations("theme.ts", source, "./colors"); + // Then + assert_eq!( + locations, + vec![ + ModuleReferenceLocation { + line: 1, + column: 25 + }, + ModuleReferenceLocation { line: 2, column: 8 }, + ModuleReferenceLocation { line: 3, column: 9 }, + ] + ); +} diff --git a/libs/extractor/src/mutations.rs b/libs/extractor/src/mutations.rs index a9f33dea0..709cbcc33 100644 --- a/libs/extractor/src/mutations.rs +++ b/libs/extractor/src/mutations.rs @@ -5,17 +5,20 @@ use oxc_allocator::CloneIn; use oxc_ast::AstKind; use oxc_ast::ast::{ - Expression, JSXAttributeName, JSXElementName, ObjectPropertyKind, Program, + Expression, IdentifierReference, JSXAttributeName, JSXElementName, ObjectPropertyKind, Program, VariableDeclarationKind, }; use oxc_ast_visit::{Visit, walk}; -use oxc_semantic::{AstNodes, SemanticBuilder}; +use oxc_semantic::{AstNodes, Scoping, SemanticBuilder}; use oxc_span::GetSpan; use oxc_syntax::node::NodeId; use oxc_syntax::operator::UnaryOperator; use oxc_syntax::scope::ScopeFlags; use rustc_hash::FxHashMap; +use crate::css_prop::{CssProp, CssTakers, binding_of}; +use crate::imported_constants::jsx_root_identifier; + /// How code uses a top-level binding #[derive(Debug)] pub(crate) enum Use { @@ -106,25 +109,25 @@ const READING_FUNCTIONS: [&str; 8] = [ /// The uses of each top-level binding of `program` that may change what it /// holds; `style` tells the names of the style APIs, whose arguments the /// build reads and which never run, and `css` the `css` props the build reads +/// and the entry absorbing Emotion's own `jsx`. A name only counts for a +/// reference to the binding of the module, never to a local of the same name. pub(crate) fn uses( program: &Program<'_>, style: &dyn Fn(&str) -> bool, - css: Option<&crate::css_prop::CssTakers<'_>>, + css: Option<(CssProp, &str)>, ) -> FxHashMap> { let allocator = oxc_allocator::Allocator::default(); - // ClassNames callers retain semantic IDs for inlining and compilation. - // Analyze a separate tree so building mutation nodes cannot reset those IDs. - let isolated = css - .filter(|css| css.has_class_names()) - .map(|_| program.clone_in(&allocator)); - let program = isolated.as_ref().unwrap_or(program); + // Callers retain semantic IDs for inlining and compilation. Analyze a + // separate tree so building mutation nodes cannot reset those IDs. + let isolated = program.clone_in(&allocator); + let program = &isolated; let semantic = SemanticBuilder::new() .with_build_nodes(true) .build(program) .semantic; let scoping = semantic.scoping(); let nodes = semantic.nodes(); - let is_global = |name: &str| scoping.get_root_binding(name.into()).is_none(); + let takers = css.map(|(css_prop, compat)| CssTakers::new(program, scoping, css_prop, compat)); let mut uses: FxHashMap> = FxHashMap::default(); for (name, symbol) in scoping.get_bindings(scoping.root_scope_id()) { let init = match nodes.kind(scoping.symbol_declaration(*symbol)) { @@ -135,8 +138,7 @@ pub(crate) fn uses( nodes, scoping, style, - css, - is_global: &is_global, + css: takers.as_ref(), init, }; let found: Vec = scoping @@ -155,10 +157,9 @@ pub(crate) fn uses( struct Context<'s, 'a> { nodes: &'s AstNodes<'a>, - scoping: &'s oxc_semantic::Scoping, + scoping: &'s Scoping, style: &'s dyn Fn(&str) -> bool, - css: Option<&'s crate::css_prop::CssTakers<'s>>, - is_global: &'s dyn Fn(&str) -> bool, + css: Option<&'s CssTakers<'s>>, /// What the binding is declared as, when a `const` or `let` gives it init: Option<&'s Expression<'a>>, } @@ -381,12 +382,12 @@ impl Context<'_, '_> { match callee { Expression::Identifier(identifier) if READING_FUNCTIONS.contains(&identifier.name.as_str()) - && (self.is_global)(&identifier.name) => + && self.is_global(identifier) => { Some(("", identifier.name.as_str())) } Expression::StaticMemberExpression(member) => match &member.object { - Expression::Identifier(object) if (self.is_global)(&object.name) => { + Expression::Identifier(object) if self.is_global(object) => { Some((object.name.as_str(), member.property.name.as_str())) } _ => None, @@ -416,15 +417,12 @@ impl Context<'_, '_> { /// Whether `callee`, called at `id`, is the `css` or `cx` a `` /// child function around takes, whose calls the build compiles fn is_class_names_call(&self, id: NodeId, callee: &Expression<'_>) -> bool { - let (Some(css), Expression::Identifier(callee)) = (self.css, callee) else { - return false; - }; - let Some(symbol) = crate::css_prop::binding_of(self.scoping, callee) else { + let Some(css) = self.css else { return false; }; self.nodes.ancestor_ids(id).any(|ancestor| { matches!(self.nodes.kind(ancestor), AstKind::JSXElement(element) - if css.class_names_calls(element, self.scoping).contains(&symbol)) + if css.calls_class_names(&css.class_names_calls(element), callee)) }) } @@ -437,7 +435,7 @@ impl Context<'_, '_> { .as_identifier() .is_some_and(|name| name.name == "css") && matches!(self.nodes.parent_kind(id), AstKind::JSXOpeningElement(element) - if css.css_prop.takes(&element.name, |root| (self.style)(root))) + if css.takes(&element.name, |identifier| self.is_style_reference(identifier))) }) } @@ -447,7 +445,7 @@ impl Context<'_, '_> { self.css.is_some_and(|css| { matches!(self.nodes.parent_kind(self.nodes.parent_id(id)), AstKind::CallExpression(call) if matches!(call.arguments.get(1), Some(oxc_ast::ast::Argument::ObjectExpression(props)) - if css.property(call, |root| (self.style)(root)) + if css.property(call, |identifier| self.is_style_reference(identifier)) .and_then(|at| props.properties.get(at)) .is_some_and(|css| css.span() == property.span))) }) @@ -522,7 +520,7 @@ impl Context<'_, '_> { fn is_commonjs_export(&self, member: &oxc_ast::ast::MemberExpression<'_>) -> bool { let is_global = |expression: &Expression<'_>, name: &str| { matches!(expression, Expression::Identifier(identifier) - if identifier.name == name && (self.is_global)(name)) + if identifier.name == name && self.is_global(identifier)) }; match member.object() { Expression::StaticMemberExpression(inner) => { @@ -536,22 +534,28 @@ impl Context<'_, '_> { } } + /// Whether `identifier` reads a global, and not a local or an import of + /// the module that shares the name + fn is_global(&self, identifier: &IdentifierReference<'_>) -> bool { + binding_of(self.scoping, identifier).is_none() + } + + /// Whether `identifier` reads the top-level binding the style predicate + /// names: not a local of the same name, and not a global + fn is_style_reference(&self, identifier: &IdentifierReference<'_>) -> bool { + (self.style)(&identifier.name) + && binding_of(self.scoping, identifier).is_some_and(|symbol| { + self.scoping + .get_root_binding(identifier.name.as_str().into()) + == Some(symbol) + }) + } + fn is_style(&self, callee: &Expression<'_>) -> bool { let mut expression = callee; loop { match crate::utils::unwrap_syntax_only(expression) { - Expression::Identifier(identifier) => { - return (self.style)(&identifier.name) - && self.css.is_none_or(|css| { - !css.has_class_names() - || crate::css_prop::binding_of(self.scoping, identifier) - .is_some_and(|symbol| { - self.scoping - .get_root_binding(identifier.name.as_str().into()) - == Some(symbol) - }) - }); - } + Expression::Identifier(identifier) => return self.is_style_reference(identifier), Expression::StaticMemberExpression(member) => expression = &member.object, Expression::CallExpression(call) => expression = &call.callee, _ => return false, @@ -560,19 +564,7 @@ impl Context<'_, '_> { } fn is_style_element(&self, name: &JSXElementName<'_>) -> bool { - if self - .css - .is_some_and(crate::css_prop::CssTakers::has_class_names) - && let JSXElementName::IdentifierReference(identifier) = name - && !crate::css_prop::binding_of(self.scoping, identifier).is_some_and(|symbol| { - self.scoping - .get_root_binding(identifier.name.as_str().into()) - == Some(symbol) - }) - { - return false; - } - crate::imported_constants::jsx_root(name).is_some_and(|root| (self.style)(root)) + jsx_root_identifier(name).is_some_and(|identifier| self.is_style_reference(identifier)) } } @@ -704,7 +696,8 @@ await a;" #[test] fn reads() { insta::assert_debug_snapshot!(describe( - "const a = { x: 1, arrow: () => 1, plain() { return 1; }, nested() { return function () { return this; }; }, self() { return this; } }; + "import { css, Box } from '@devup-ui/react'; import * as Devup from '@devup-ui/react'; +const a = { x: 1, arrow: () => 1, plain() { return 1; }, nested() { return function () { return this; }; }, self() { return this; } }; String(a.x); parseInt(a.x); structuredClone(a); Object.keys(a); Object.freeze(a); Object.hasOwn(a, 'x'); Object.getOwnPropertyNames(a); JSON.stringify(a); Math.max(a.x); console.log(a); Array.isArray(a); css(a); css({ ...a, color: f(a) }); Devup.css(a); css.x`${a}`; css(1)(a); css({ w: a.self() }); ; {a}; ; ; {a}; @@ -754,3 +747,6 @@ function inner() { const kept = { a }; }" )); } } + +#[cfg(test)] +mod scope_tests; diff --git a/libs/extractor/src/mutations/scope_tests.rs b/libs/extractor/src/mutations/scope_tests.rs new file mode 100644 index 000000000..a248f707b --- /dev/null +++ b/libs/extractor/src/mutations/scope_tests.rs @@ -0,0 +1,164 @@ +use oxc_allocator::Allocator; +use oxc_parser::Parser; +use oxc_span::SourceType; + +use super::{Use, uses}; +use crate::css_prop::CssProp; + +const COMPAT: &str = "@devup-ui/react/compat"; + +/// The uses of the bindings of `code`, one line for each, with `styles` the +/// names the module imports from the style packages +fn describe(code: &str, styles: &[&str], css: Option<(CssProp, &str)>) -> Vec { + let allocator = Allocator::default(); + let program = Parser::new(&allocator, code, SourceType::tsx()) + .parse() + .program; + let line = |at: u32| { + code[at as usize..] + .lines() + .next() + .unwrap_or_default() + .to_string() + }; + let mut found: Vec = uses(&program, &|name| styles.contains(&name), css) + .into_iter() + .filter(|(name, _)| name == "a") + .flat_map(|(name, uses)| { + uses.into_iter().map(move |found| match found { + Use::Changes { at, depth } => format!("{name} changes {depth}: {}", line(at)), + Use::Escapes { at, path, into } => { + format!("{name} escapes {path:?} into {into:?}: {}", line(at)) + } + Use::Calls { at, path } => format!("{name} calls {path:?}: {}", line(at)), + }) + }) + .collect(); + found.sort(); + found +} + +#[test] +fn a_local_named_like_a_style_api_is_not_one() { + let styles = ["css", "Box", "Devup"]; + let imports = "import { css, Box } from '@devup-ui/react';\nimport * as Devup from '@devup-ui/react';\nconst a = {};\n"; + for (code, escapes) in [ + ("css(a);", 0), + ("css.x(a);", 0), + (";", 0), + (";", 0), + ("function f(css) { css(a); }", 1), + ("function f() { const css = g; css(a); }", 1), + ("function f(css) { css.x(a); }", 1), + ("function f(Box) { return ; }", 1), + ("function f(Devup) { return ; }", 1), + ] { + let code = format!("{imports}{code}"); + assert_eq!(describe(&code, &styles, None).len(), escapes, "{code}"); + } +} + +#[test] +fn a_global_named_like_a_style_api_is_not_one() { + let styles = ["css", "Box", "Devup"]; + for code in [ + "css(a);", + "css.x(a);", + ";", + ";", + ] { + let code = format!("const a = {{}};\n{code}"); + assert_eq!(describe(&code, &styles, None).len(), 1, "{code}"); + } +} + +#[test] +fn a_local_named_like_a_global_is_not_one() { + let a = "const a = {};\n"; + for (code, expected) in [ + ("Object.assign(a, {});", vec!["a changes 1: a, {});"]), + ( + "function f(Object) { Object.assign(a, {}); }", + vec!["a escapes [] into None: a, {}); }"], + ), + ("String(a);", vec![]), + ( + "function f(String) { String(a); }", + vec!["a escapes [] into None: a); }"], + ), + ("JSON.stringify(a);", vec![]), + ( + "function f(JSON) { JSON.stringify(a); }", + vec!["a escapes [] into None: a); }"], + ), + ] { + assert_eq!( + describe(&format!("{a}{code}"), &[], None), + expected, + "{code}" + ); + } +} + +#[test] +fn a_local_named_like_a_class_names_binding_is_not_one() { + let prelude = "import { ClassNames } from '@emotion/react';\nconst a = {};\n"; + for (code, escapes) in [ + ( + "const x = {({ css }) => css(f(a))};", + 0, + ), + ( + "const x = {({ cx }) => cx`${a}`};", + 0, + ), + ( + "const x = {({ css }) => ((css) => css(f(a)))(css)};", + 1, + ), + ( + "const x = {({ cx }) => ((cx) => cx`${a}`)(cx)};", + 1, + ), + ( + "function h(ClassNames) { return {({ css }) => css(f(a))}; }", + 1, + ), + ] { + let code = format!("{prelude}{code}"); + let css = Some((CssProp::Elements, COMPAT)); + assert_eq!(describe(&code, &[], css).len(), escapes, "{code}"); + } +} + +#[test] +fn a_local_named_like_an_element_builder_takes_no_css_prop() { + let prelude = "import { jsx } from '@emotion/react';\nimport { Box } from '@devup-ui/react';\nconst a = {};\n"; + for (css_prop, code, escapes) in [ + (CssProp::Elements, "jsx('div', { css: f(a) });", 0), + (CssProp::Elements, "jsx(Box, { css: f(a) });", 0), + (CssProp::Elements, ";", 0), + (CssProp::Elements, "
;", 0), + (CssProp::Elements, ";", 1), + (CssProp::Everywhere, ";", 0), + ( + CssProp::Elements, + "function g(jsx) { jsx('div', { css: f(a) }); }", + 1, + ), + ( + CssProp::Elements, + "function g(Box) { jsx(Box, { css: f(a) }); }", + 1, + ), + ( + CssProp::Elements, + "function g(Box) { return ; }", + 1, + ), + ] { + let code = format!("{prelude}{code}"); + let css = Some((css_prop, COMPAT)); + assert_eq!(describe(&code, &["Box"], css).len(), escapes, "{code}"); + } +} diff --git a/libs/extractor/src/own_css_bindings.rs b/libs/extractor/src/own_css_bindings.rs new file mode 100644 index 000000000..24e9d8f31 --- /dev/null +++ b/libs/extractor/src/own_css_bindings.rs @@ -0,0 +1,117 @@ +use super::BoundRules; +use crate::{ + own_css_plan::{Node, Plan}, + utils::{get_string_by_property_key, unwrap_syntax_only}, + visit::DevupVisitor, +}; +use oxc_allocator::{CloneIn, GetAllocator}; +use oxc_ast::ast::{Expression, ObjectPropertyKind, Program, VariableDeclarator}; +use oxc_ast_visit::Visit; +use rustc_hash::FxHashMap; + +struct Calls { + named: FxHashMap, + spans: FxHashMap<(u32, u32), Plan>, +} + +impl<'a> Visit<'a> for Calls { + fn visit_variable_declarator(&mut self, it: &VariableDeclarator<'a>) { + if let Some(name) = it.id.get_binding_identifier() + && let Some(plan) = self.named.remove(name.name.as_str()) + && let Some(Expression::CallExpression(call)) = &it.init + { + self.spans.insert((call.span.start, call.span.end), plan); + } + } +} + +impl<'a> DevupVisitor<'a> { + pub(crate) fn own_rules(&mut self, named: FxHashMap, program: &Program<'a>) { + let mut calls = Calls { + named, + spans: FxHashMap::default(), + }; + calls.visit_program(program); + self.own_rules = calls.spans; + } + + pub(super) fn bind_own_rules( + &self, + mut plan: Plan, + array: &oxc_ast::ast::ArrayExpression<'a>, + ) -> BoundRules<'a> { + let original: Vec<_> = array + .elements + .iter() + .filter_map(|item| { + item.as_expression() + .map(|e| e.clone_in_with_semantic_ids(self.ast.allocator())) + }) + .collect(); + let mut slots: Vec<_> = original + .iter() + .map(|e| e.clone_in_with_semantic_ids(self.ast.allocator())) + .collect(); + for expression in &mut slots { + self.style_values.read_in(&self.ast, expression); + } + for node in &mut plan.nodes { + match node { + Node::Object(properties) => { + for property in properties { + if let Expression::ObjectExpression(object) = + unwrap_syntax_only(&slots[property.slot]) + && let Some(ObjectPropertyKind::ObjectProperty(key)) = + object.properties.first() + && let Some(name) = get_string_by_property_key(&key.key) + { + property.name = name; + } + } + } + Node::Scalar(_) | Node::Array(_) => {} + } + } + BoundRules { + plan, + original, + slots, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn binding_visitor_only_associates_named_call_initializers() { + // Given + let allocator = oxc_allocator::Allocator::default(); + let parsed = oxc_parser::Parser::new( + &allocator, + "const retained=css([]), ignored=0;const {destructured}=source;", + oxc_span::SourceType::mjs(), + ) + .parse(); + assert_eq!(parsed.diagnostics.len(), 0); + let mut calls = Calls { + named: ["retained", "ignored", "destructured"] + .into_iter() + .map(|name| (name.into(), Plan::default())) + .collect(), + spans: FxHashMap::default(), + }; + // When + calls.visit_program(&parsed.program); + // Then + assert_eq!( + calls.spans.keys().copied().collect::>(), + vec![(15, 22)] + ); + assert_eq!( + calls.named.keys().map(String::as_str).collect::>(), + vec!["destructured"] + ); + } +} diff --git a/libs/extractor/src/own_css_class.rs b/libs/extractor/src/own_css_class.rs new file mode 100644 index 000000000..8ed074d9e --- /dev/null +++ b/libs/extractor/src/own_css_class.rs @@ -0,0 +1,130 @@ +use super::{BoundRules, RulePart}; +use crate::{ + ExtractStyleProp, + composition::{KnownPart, KnownStyles, set_prop_order}, + extractor::ExtractResult, + gen_class_name::{gen_class_names, merge_expression_for_class_name}, + own_css_plan::Node, + utils::unwrap_syntax_only, + visit::{DevupVisitor, Text}, +}; +use oxc_allocator::{CloneIn, GetAllocator}; +use oxc_ast::ast::Expression; +use oxc_span::SPAN; + +impl<'a> DevupVisitor<'a> { + pub(super) fn own_class( + &mut self, + rules: &mut BoundRules<'a>, + part: RulePart, + offset: u32, + ) -> Expression<'a> { + let known = part.roots.iter().any(|id| match rules.plan.nodes[*id] { + Node::Scalar(slot) => self.reads_known_styles(&rules.original[slot]), + Node::Object(_) | Node::Array(_) => false, + }); + if known { + let mut parts = Vec::new(); + for &root in &part.roots { + match rules.plan.nodes[root] { + Node::Object(_) | Node::Array(_) => { + let ExtractResult { + mut styles, + style_order, + .. + } = crate::own_css_lower::RuleCompiler { + ast: &self.ast, + plan: &rules.plan, + slots: &rules.slots, + } + .lower(root); + self.own_errors(offset, &styles); + if let Some(order) = style_order { + for prop in &mut styles { + set_prop_order(prop, order); + } + } + parts.push(KnownPart::Styles(vec![KnownStyles::Known( + styles + .into_iter() + .flat_map(ExtractStyleProp::into_extract) + .collect(), + )])); + } + Node::Scalar(slot) => { + self.known_parts(&rules.original[slot], &mut parts, Text::Classes); + } + } + } + let (result, known) = self.composed_class::(offset, parts); + if let Some(known) = known { + self.css_styles = Some((offset, known)); + } + return result; + } + let mut classes = Vec::new(); + let result = if !part.composing + && let [root] = part.roots.as_slice() + { + crate::own_css_lower::RuleCompiler { + ast: &self.ast, + plan: &rules.plan, + slots: &rules.slots, + } + .lower(*root) + } else { + for &root in &part.roots { + if let Node::Scalar(slot) = rules.plan.nodes[root] + && match unwrap_syntax_only(&rules.slots[slot]) { + Expression::StringLiteral(_) | Expression::TemplateLiteral(_) => true, + Expression::Identifier(identifier) => identifier.name != "undefined", + _ => false, + } + { + classes + .push(rules.slots[slot].clone_in_with_semantic_ids(self.ast.allocator())); + } + } + let root = crate::own_css_merge::merge(&mut rules.plan, &part.roots); + crate::own_css_lower::RuleCompiler { + ast: &self.ast, + plan: &rules.plan, + slots: &rules.slots, + } + .lower(root) + }; + let ExtractResult { + mut styles, + style_order, + .. + } = result; + self.own_errors(offset, &styles); + let mut recorded = crate::composition::Composition::default(); + let mut props: Vec<_> = styles + .iter() + .map(|p| p.clone_in(self.ast.allocator())) + .collect(); + if let Some(order) = style_order { + for prop in &mut props { + set_prop_order(prop, order); + } + } + recorded.apply(&self.ast, props); + let class = gen_class_names( + &self.ast, + &mut styles, + style_order, + self.split_filename.as_deref(), + ); + self.styles + .extend(styles.into_iter().flat_map(ExtractStyleProp::into_extract)); + if classes.is_empty() + && matches!(class, Some(Expression::StringLiteral(_))) + && let Some(known) = recorded.unconditional() + { + self.css_styles = Some((offset, known)); + } + merge_expression_for_class_name(&self.ast, classes.into_iter().chain(class)) + .unwrap_or_else(|| Expression::new_string_literal(SPAN, "", None, &self.ast)) + } +} diff --git a/libs/extractor/src/own_css_context.rs b/libs/extractor/src/own_css_context.rs new file mode 100644 index 000000000..49e8eea20 --- /dev/null +++ b/libs/extractor/src/own_css_context.rs @@ -0,0 +1,100 @@ +use css::{ + at_rule::{MediaCombination, combine_media_queries, normalize_query}, + style_selector::{AtRule, AtRuleKind, StyleSelector}, +}; + +#[path = "own_css_key.rs"] +mod key; +#[path = "own_css_selector.rs"] +mod selector; +#[cfg(test)] +#[path = "own_css_selector_tests.rs"] +mod tests; +pub(crate) use key::under; + +pub(crate) enum Frame { + Selector(String), + At(AtRuleKind, String), + Layer(String), + Params(String), +} + +struct Link { + parent: Option, + frame: Frame, + selected: bool, +} + +#[derive(Default)] +pub(crate) struct Contexts { + links: Vec, +} + +impl Contexts { + pub fn push(&mut self, parent: Option, frame: Frame) -> usize { + let selected = self.selected(parent) || matches!(frame, Frame::Selector(_) | Frame::At(..)); + let id = self.links.len(); + self.links.push(Link { + parent, + frame, + selected, + }); + id + } + + pub fn selected(&self, path: Option) -> bool { + path.is_some_and(|id| self.links[id].selected) + } + + pub fn materialize( + &self, + path: Option, + ) -> Option<(Option, Option)> { + let mut links = Vec::new(); + let mut cursor = path; + while let Some(id) = cursor { + links.push(&self.links[id].frame); + cursor = self.links[id].parent; + } + let mut selector = selector::SelectorText::default(); + let mut wrappers: Vec = Vec::new(); + let mut layers = Vec::new(); + for frame in links.into_iter().rev() { + match frame { + Frame::Selector(template) => selector.nest(template), + Frame::At(kind, query) => { + let query = normalize_query(query); + if let Some(last) = wrappers.last_mut() + && last.kind == AtRuleKind::Media + && *kind == AtRuleKind::Media + { + match combine_media_queries(&last.query, &query) { + MediaCombination::Merged(query) => { + last.query = query; + continue; + } + MediaCombination::Never => return None, + MediaCombination::Nest => {} + } + } + wrappers.push(AtRule { kind: *kind, query }); + } + Frame::Layer(layer) => layers.push(layer.as_str()), + Frame::Params(params) => selector.params(params), + } + } + let selector = selector.text(); + let selector = if let Some(last) = wrappers.pop() { + Some(StyleSelector::At { + kind: last.kind, + query: last.query, + selector, + outer: wrappers, + file: None, + }) + } else { + selector.map(StyleSelector::Selector) + }; + Some((selector, (!layers.is_empty()).then(|| layers.join(".")))) + } +} diff --git a/libs/extractor/src/own_css_key.rs b/libs/extractor/src/own_css_key.rs new file mode 100644 index 000000000..f6ea78315 --- /dev/null +++ b/libs/extractor/src/own_css_key.rs @@ -0,0 +1,33 @@ +use super::Frame; +use css::{ + at_rule::{media_shorthand_query, split_at_rule_key}, + style_selector::{AtRuleKind, StyleSelector, is_selector_name}, + utils::to_kebab_case, +}; + +pub(crate) fn under(key: &str) -> Option { + if let Some((kind, query)) = split_at_rule_key(key) { + return Some(Frame::At(kind, query.to_string())); + } + let name = key.strip_prefix('_').unwrap_or(key); + let is_name = name + .strip_prefix('-') + .unwrap_or(name) + .starts_with(|c: char| c.is_ascii_alphabetic()) + && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-'); + if key.starts_with('_') || (!key.contains('&') && is_name) { + let name = to_kebab_case(name); + if let Some(query) = media_shorthand_query(&name) { + return Some(Frame::At(AtRuleKind::Media, query.to_string())); + } + return is_selector_name(&name) + .then(|| Frame::Selector(StyleSelector::from(name.as_ref()).to_string())); + } + Some(Frame::Selector(if key.contains('&') { + key.to_string() + } else if key.starts_with([':', '[']) { + format!("&{key}") + } else { + format!("& {key}") + })) +} diff --git a/libs/extractor/src/own_css_lower.rs b/libs/extractor/src/own_css_lower.rs new file mode 100644 index 000000000..55e8d5954 --- /dev/null +++ b/libs/extractor/src/own_css_lower.rs @@ -0,0 +1,242 @@ +use crate::{ + ExtractStyleProp, + extractor::{ + ExtractResult, + extract_style_from_expression::{ + LiteralHandling, at_rule_record_kind, extract_style_from_expression, misplaced, + place_in_layer, + }, + }, + own_css_context::{Contexts, Frame, under}, + own_css_plan::{Node, Plan}, + utils::{RESPONSIVE_ARRAY, SELECTOR_NAME, get_string_by_literal_expression}, +}; +use css::{disassemble_property, style_selector::split_selector_list}; +use oxc_allocator::{CloneIn, GetAllocator}; +use oxc_ast::{ast::Expression, builder::AstBuilder}; + +struct Cursor { + id: usize, + name: Option, + level: u8, + single: bool, + path: Option, +} +enum Step { + Value(Cursor), + Array(usize), +} + +pub(crate) struct RuleCompiler<'r, 'a> { + pub ast: &'r AstBuilder<'a>, + pub plan: &'r Plan, + pub slots: &'r [Expression<'a>], +} + +impl<'a> RuleCompiler<'_, 'a> { + pub fn lower(&self, root: usize) -> ExtractResult<'a> { + let Self { ast, plan, slots } = *self; + let mut pending = vec![Step::Value(Cursor { + id: root, + name: None, + level: 0, + single: false, + path: None, + })]; + let mut contexts = Contexts::default(); + let mut result = ExtractResult::default(); + while let Some(step) = pending.pop() { + let cursor = match step { + Step::Value(cursor) => cursor, + Step::Array(start) => { + let styles = result.styles.split_off(start); + result.styles.push(ExtractStyleProp::StaticArray(styles)); + continue; + } + }; + let Cursor { + id, + name, + level, + single, + mut path, + } = cursor; + let node = &plan.nodes[id]; + if let Some(name) = name.as_deref() { + let object = match node { + Node::Object(properties) => Some(properties), + Node::Scalar(_) | Node::Array(_) => None, + }; + if name == "as" { + continue; + } + if let Some(properties) = object { + let record = at_rule_record_kind(name); + if name == "selectors" || name == "@layer" || record.is_some() { + for property in properties.iter().rev() { + let keys = if name == "selectors" { + split_selector_list(property.name.trim()) + } else { + vec![property.name.as_str()] + }; + for key in keys.into_iter().rev() { + let frame = if name == "@layer" { + Some(Frame::Layer(key.to_string())) + } else if let Some(kind) = record { + Some(Frame::At(kind, key.to_string())) + } else { + under(key) + }; + if let Some(frame) = frame { + let path = Some(contexts.push(path, frame)); + pending.push(Step::Value(Cursor { + id: property.value, + name: None, + level, + single, + path, + })); + } else { + result.styles.push(misplaced( + 0, + key.to_string(), + SELECTOR_NAME, + )); + } + } + } + continue; + } + } + if name.starts_with([':', '.', '#', '[', '*', '>', '+', '~', '_']) + || name.contains('&') + || css::at_rule::split_at_rule_key(name).is_some() + { + let keys = split_selector_list(name); + for key in keys.into_iter().rev() { + if let Some(frame) = under(key) { + pending.push(Step::Value(Cursor { + id, + name: None, + level, + single, + path: Some(contexts.push(path, frame)), + })); + } else { + result + .styles + .push(misplaced(0, key.to_string(), SELECTOR_NAME)); + } + } + continue; + } + } + match node { + Node::Object(properties) => { + let root_object = name.is_none() && !contexts.selected(path); + if !root_object + && contexts.selected(path) + && let Some(property) = properties.iter().find(|p| p.name == "params") + && let Node::Array(items) = &plan.nodes[property.value] + { + let params: Vec<_> = items + .iter() + .filter_map(|id| match plan.nodes[*id] { + Node::Scalar(slot) => { + get_string_by_literal_expression(&slots[slot]) + .filter(|s| !s.is_empty()) + } + Node::Array(_) | Node::Object(_) => None, + }) + .collect(); + path = Some(contexts.push(path, Frame::Params(params.join(",")))); + } + let properties: Vec<_> = if root_object { + properties.iter().collect() + } else { + properties.iter().rev().collect() + }; + for property in properties { + if root_object { + if property.name == "styleOrder" { + if id == root + && let Node::Scalar(slot) = plan.nodes[property.value] + { + result.style_order = + crate::utils::get_number_by_literal_expression( + &slots[slot], + ) + .and_then(|n| { + n.clamp(0.0, 255.0).trunc().to_string().parse().ok() + }); + } + continue; + } + if matches!(property.name.as_str(), "props" | "styleVars") + || css::is_special_property::is_special_property(&property.name) + { + continue; + } + } else if property.name == "params" { + continue; + } + let start = pending.len(); + for name in disassemble_property(&property.name) { + pending.push(Step::Value(Cursor { + id: property.value, + name: Some(name.into_owned()), + level: if root_object { 0 } else { level }, + single: if root_object { false } else { single }, + path, + })); + } + pending[start..].reverse(); + } + } + Node::Array(items) => { + if single { + result + .styles + .push(misplaced(0, "".into(), RESPONSIVE_ARRAY)); + continue; + } + pending.push(Step::Array(result.styles.len())); + for (index, &id) in items.iter().enumerate().rev() { + let level = index.to_le_bytes()[0]; + pending.push(Step::Value(Cursor { + id, + name: name.clone(), + level, + single: true, + path, + })); + } + } + Node::Scalar(slot) => { + let Some((selector, layer)) = contexts.materialize(path) else { + continue; + }; + let mut expression = slots[*slot].clone_in_with_semantic_ids(ast.allocator()); + let mut styles = extract_style_from_expression( + ast, + name.as_deref(), + &mut expression, + level, + &selector, + if single { + LiteralHandling::KeepSingleClass + } else { + LiteralHandling::ExpandResponsiveThemeToken + }, + ) + .styles; + if let Some(layer) = layer { + place_in_layer(&mut styles, &layer); + } + result.styles.extend(styles); + } + } + } + result + } +} diff --git a/libs/extractor/src/own_css_merge.rs b/libs/extractor/src/own_css_merge.rs new file mode 100644 index 000000000..7781b9155 --- /dev/null +++ b/libs/extractor/src/own_css_merge.rs @@ -0,0 +1,34 @@ +use crate::own_css_plan::{Node, Plan}; + +pub(crate) fn merge(plan: &mut Plan, roots: &[usize]) -> usize { + let root = plan.nodes.len(); + plan.nodes.push(Node::Object(Vec::new())); + for &source in roots { + let mut pending = vec![(root, source)]; + while let Some((target, source)) = pending.pop() { + let (Node::Object(added), Node::Object(properties)) = + (&plan.nodes[source], &plan.nodes[target]) + else { + continue; + }; + let added = added.clone(); + let mut properties = properties.clone(); + for mut property in added { + if let Some(index) = properties.iter().position(|p| p.name == property.name) { + let previous = properties.remove(index); + if let (Node::Object(_), Node::Object(_)) = + (&plan.nodes[previous.value], &plan.nodes[property.value]) + { + let id = plan.nodes.len(); + plan.nodes.push(plan.nodes[previous.value].clone()); + pending.push((id, property.value)); + property.value = id; + } + } + properties.push(property); + } + plan.nodes[target] = Node::Object(properties); + } + } + root +} diff --git a/libs/extractor/src/own_css_plan.rs b/libs/extractor/src/own_css_plan.rs new file mode 100644 index 000000000..62797eac8 --- /dev/null +++ b/libs/extractor/src/own_css_plan.rs @@ -0,0 +1,112 @@ +//! Rust-owned evaluated rules; indices keep capture, cloning and destruction stack-safe. +#[derive(Debug, Clone)] +pub(crate) enum Node { + Scalar(usize), + Array(Vec), + Object(Vec), +} + +#[derive(Debug, Clone)] +pub(crate) struct Property { + pub name: String, + pub slot: usize, + pub value: usize, +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct Plan { + pub nodes: Vec, + pub slots: Vec, + pub roots: Vec, + pub deep: bool, + pub composing: bool, + pub template: Option>, + pub origin: Option<(String, String)>, +} + +impl Plan { + pub fn needs_bridge(&self) -> bool { + self.deep + || (self.template.is_some() + && self + .roots + .iter() + .any(|id| matches!(self.nodes[*id], Node::Array(_) | Node::Object(_)))) + } + pub fn code(&self, root: usize) -> String { + enum Step<'a> { + Node(usize), + Text(&'a str), + Key(&'a Property), + } + let mut pending = vec![Step::Node(root)]; + let mut code = String::new(); + while let Some(step) = pending.pop() { + match step { + Step::Text(text) => code.push_str(text), + Step::Key(key) => { + let expression = &self.slots[key.slot]; + if expression == &crate::vanilla_extract::json_string(&key.name) { + code.push_str(expression); + } else { + code.push('['); + code.push_str(expression); + code.push(']'); + } + code.push(':'); + } + Step::Node(id) => match &self.nodes[id] { + Node::Scalar(slot) => code.push_str(&self.slots[*slot]), + Node::Array(items) => { + code.push('['); + pending.push(Step::Text("]")); + for (index, &item) in items.iter().enumerate().rev() { + pending.push(Step::Node(item)); + if index > 0 { + pending.push(Step::Text(",")); + } + } + } + Node::Object(properties) => { + code.push('{'); + pending.push(Step::Text("}")); + for (index, key) in properties.iter().enumerate().rev() { + pending.push(Step::Node(key.value)); + pending.push(Step::Key(key)); + if index > 0 { + pending.push(Step::Text(",")); + } + } + } + }, + } + } + code + } + + pub fn bounded_call(&self) -> String { + let keys: rustc_hash::FxHashSet<_> = self + .nodes + .iter() + .filter_map(|node| match node { + Node::Object(properties) => Some(properties), + Node::Scalar(_) | Node::Array(_) => None, + }) + .flatten() + .map(|p| p.slot) + .collect(); + let slots: Vec<_> = self + .slots + .iter() + .enumerate() + .map(|(index, code)| { + if keys.contains(&index) { + format!("({{[{code}]:0}})") + } else { + code.clone() + } + }) + .collect(); + format!("css([{}])", slots.join(",")) + } +} diff --git a/libs/extractor/src/own_css_selector.rs b/libs/extractor/src/own_css_selector.rs new file mode 100644 index 000000000..bdfe38d97 --- /dev/null +++ b/libs/extractor/src/own_css_selector.rs @@ -0,0 +1,132 @@ +use std::{borrow::Cow, collections::VecDeque}; + +use css::style_selector::{StyleSelector, split_selector_list}; + +#[derive(Default)] +pub(super) struct SelectorText<'a> { + pieces: Vec>, + order: VecDeque, + pending: VecDeque<(usize, usize)>, + list: bool, + lexical_safe: bool, +} + +impl<'a> SelectorText<'a> { + fn piece(&mut self, value: Cow<'a, str>) -> usize { + let id = self.pieces.len(); + self.pieces.push(value); + id + } + + pub fn nest(&mut self, template: &'a str) { + if self.order.is_empty() { + self.lexical_safe = true; + self.list = split_selector_list(template).len() > 1; + let id = self.piece(Cow::Borrowed(template)); + self.order.push_back(id); + self.pending + .extend(positions(template, &mut 0).into_iter().map(|at| (id, at))); + } else if template.matches('&').count() == 1 && !self.list { + if let Some((before, after)) = template.split_once('&') { + if after.starts_with(|c: char| c.is_ascii_alphanumeric() || c == '-') { + self.lexical_safe = false; + } + let mut depth = 0; + let before_positions = positions(before, &mut depth); + if depth > 0 { + self.pending.clear(); + } + if !before.is_empty() { + let id = self.piece(Cow::Borrowed(before)); + self.order.push_front(id); + for at in before_positions.into_iter().rev() { + self.pending.push_front((id, at)); + } + } + let after_positions = positions(after, &mut depth); + if !after.is_empty() { + let id = self.piece(Cow::Borrowed(after)); + self.order.push_back(id); + self.pending + .extend(after_positions.into_iter().map(|at| (id, at))); + } + self.list = split_selector_list(template).len() > 1; + } + } else { + let parent = self.text().map(StyleSelector::Selector); + let nested = StyleSelector::nest_selector(parent.as_ref(), template).to_string(); + self.list = split_selector_list(&nested).len() > 1; + self.lexical_safe = true; + let candidates = positions(&nested, &mut 0); + let id = self.piece(Cow::Owned(nested)); + self.order.clear(); + self.order.push_back(id); + self.pending = candidates.into_iter().map(|at| (id, at)).collect(); + } + } + + pub fn params(&mut self, params: &str) { + if !self.lexical_safe { + if let Some(text) = self.text() { + let text = + css::add_selector_params(StyleSelector::Selector(text), params).to_string(); + let candidates = positions(&text, &mut 0); + let id = self.piece(Cow::Owned(text)); + self.order.clear(); + self.order.push_back(id); + self.pending = candidates.into_iter().map(|at| (id, at)).collect(); + self.lexical_safe = true; + } + return; + } + let target = self + .pending + .pop_back() + .or_else(|| self.order.back().map(|&id| (id, self.pieces[id].len()))); + if let Some((id, at)) = target { + let value = self.pieces[id].to_mut(); + value.insert_str(at, &format!("({params})")); + } + } + + pub fn text(&self) -> Option { + (!self.order.is_empty()).then(|| { + self.order + .iter() + .map(|&id| self.pieces[id].as_ref()) + .collect() + }) + } +} + +fn positions(value: &str, depth: &mut usize) -> Vec { + let bytes = value.as_bytes(); + let mut found = Vec::new(); + let mut index = 0; + while let Some(&byte) = bytes.get(index) { + match byte { + b'(' | b'[' => *depth += 1, + b')' | b']' => *depth = depth.saturating_sub(1), + b':' if *depth == 0 => { + let start = index + 1 + usize::from(bytes.get(index + 1) == Some(&b':')); + let end = value[start..] + .find(|c: char| !(c.is_ascii_alphanumeric() || c == '-')) + .map_or(value.len(), |length| start + length); + if bytes.get(end) != Some(&b'(') { + let probe = StyleSelector::Selector(format!(":{} x", &value[start..end])); + if css::add_selector_params(probe, "") + .to_string() + .ends_with(" x") + { + found.push(end); + } + } + index = end; + continue; + } + _ => {} + } + index += 1; + } + found +} diff --git a/libs/extractor/src/own_css_selector_tests.rs b/libs/extractor/src/own_css_selector_tests.rs new file mode 100644 index 000000000..e819e7d56 --- /dev/null +++ b/libs/extractor/src/own_css_selector_tests.rs @@ -0,0 +1,46 @@ +use super::selector::SelectorText; +use css::style_selector::StyleSelector; + +#[rstest::rstest] +#[case(&["&:nth-child", "&:hover", "=2n"])] +#[case(&["&:nth-child:nth-of-type", "=2n", "=odd"])] +#[case(&["&:nth-child", ":is(&)", "=2n"])] +#[case(&["&:hover", ":is([role=group],[data-group]):nth-child &", "=2n"])] +#[case(&["&:nth-child", "&[data-x='a:b']:nth-of-type", "=odd", "=2n"])] +#[case(&["&:nth-child, & > a", "&:hover", "=odd"])] +#[case(&["&:nth-child", "& &", "=2n", "=odd"])] +#[case(&["&:nth", "&-child > a", "=2n"])] +fn deep_object_selector_rope_matches_existing_selector_operations(#[case] operations: &[&str]) { + // Given + let mut expected = None; + let mut actual = SelectorText::default(); + // When + for operation in operations { + if let Some(params) = operation.strip_prefix('=') { + expected = expected.map(|selector| css::add_selector_params(selector, params)); + actual.params(params); + } else { + expected = Some(StyleSelector::nest_selector(expected.as_ref(), operation)); + actual.nest(operation); + } + } + // Then + assert_eq!(actual.text(), expected.map(|selector| selector.to_string())); +} + +#[test] +fn deep_object_selector_rope_keeps_all_parameterized_occurrences() { + // Given + let mut selector = SelectorText::default(); + // When + for _ in 0..100_000 { + selector.nest("&:nth-child"); + selector.params("2n"); + selector.nest("&:hover"); + } + // Then + assert_eq!( + selector.text(), + Some(format!("&{}", ":nth-child(2n):hover".repeat(100_000))) + ); +} diff --git a/libs/extractor/src/own_css_template.rs b/libs/extractor/src/own_css_template.rs new file mode 100644 index 000000000..ef3eeae52 --- /dev/null +++ b/libs/extractor/src/own_css_template.rs @@ -0,0 +1,102 @@ +use super::{BoundRules, RulePart}; +use crate::{ + ExtractStyleProp, + css_utils::{TemplateStyles, css_to_style_template}, + gen_class_name::{gen_class_names, merge_expression_for_class_name}, + own_css_plan::Node, + utils::unplaced_error, + visit::DevupVisitor, +}; +use oxc_allocator::{CloneIn, FromIn, GetAllocator}; +use oxc_ast::ast::{Expression, Str}; +use oxc_span::SPAN; + +impl<'a> DevupVisitor<'a> { + pub(super) fn own_template( + &mut self, + rules: &mut BoundRules<'a>, + offset: u32, + ) -> Expression<'a> { + let quasis = rules + .plan + .template + .iter() + .flatten() + .enumerate() + .map(|(index, raw)| { + oxc_ast::ast::TemplateElement::new( + SPAN, + oxc_ast::ast::TemplateElementValue { + raw: Str::from_in(raw, self.ast.allocator()), + cooked: None, + }, + index == rules.plan.roots.len(), + &self.ast, + ) + }); + let expressions = rules + .plan + .roots + .iter() + .map(|root| match rules.plan.nodes[*root] { + Node::Scalar(slot) => { + rules.original[slot].clone_in_with_semantic_ids(self.ast.allocator()) + } + Node::Array(_) | Node::Object(_) => Expression::new_object_expression( + SPAN, + oxc_allocator::Vec::new_in(&self.ast), + &self.ast, + ), + }); + let mut template = oxc_ast::ast::TemplateLiteral::new( + SPAN, + oxc_allocator::Vec::from_iter_in(quasis, &self.ast), + oxc_allocator::Vec::from_iter_in(expressions, &self.ast), + &self.ast, + ); + self.style_values.read_in_text(&self.ast, &mut template); + self.own_errors(offset, &crate::dead_properties::template_errors(&template)); + let TemplateStyles { + styles, + statements, + unplaced, + } = css_to_style_template(&template, 0, &None); + let mut props: Vec<_> = styles + .into_iter() + .map(|s| ExtractStyleProp::Static(s.into())) + .collect(); + self.own_errors(offset, &props); + for index in unplaced { + self.errors + .push((offset, unplaced_error(&template.expressions[index]))); + } + let class = gen_class_names(&self.ast, &mut props, None, self.split_filename.as_deref()); + self.styles + .extend(props.into_iter().flat_map(ExtractStyleProp::into_extract)); + let mut mixins = Vec::new(); + for index in statements { + let root = rules.plan.roots[index]; + let part = match &rules.plan.nodes[root] { + Node::Scalar(slot) => { + let mut value = + rules.original[*slot].clone_in_with_semantic_ids(self.ast.allocator()); + self.style_values.read_in(&self.ast, &mut value); + mixins.push(value); + continue; + } + Node::Array(items) => RulePart { + roots: items.clone(), + composing: true, + }, + Node::Object(_) => RulePart { + roots: vec![root], + composing: false, + }, + }; + mixins.push(self.own_class(rules, part, offset)); + } + self.css_styles = None; + merge_expression_for_class_name(&self.ast, mixins.into_iter().chain(class)) + .unwrap_or_else(|| Expression::new_string_literal(SPAN, "", None, &self.ast)) + } +} diff --git a/libs/extractor/src/own_css_visit.rs b/libs/extractor/src/own_css_visit.rs new file mode 100644 index 000000000..2607e4ff8 --- /dev/null +++ b/libs/extractor/src/own_css_visit.rs @@ -0,0 +1,61 @@ +use super::DevupVisitor; +use crate::{ExtractStyleProp, own_css_plan::Plan, utils::unused_error}; +use oxc_ast::ast::{Argument, Expression}; + +#[path = "own_css_bindings.rs"] +mod bindings; +#[path = "own_css_class.rs"] +mod class; +#[path = "own_css_template.rs"] +mod template; + +struct BoundRules<'a> { + plan: Plan, + original: Vec>, + slots: Vec>, +} + +struct RulePart { + roots: Vec, + composing: bool, +} + +impl<'a> DevupVisitor<'a> { + pub(super) fn compile_own_rules(&mut self, expression: &mut Expression<'a>) -> bool { + if let Expression::CallExpression(call) = expression + && let Some(plan) = self.own_rules.remove(&(call.span.start, call.span.end)) + && let Some(Expression::ArrayExpression(array)) = + call.arguments.first().and_then(Argument::as_expression) + { + let offset = call.span.start; + let origin = plan.origin.clone(); + let mut rules = self.bind_own_rules(plan, array); + let start = self.errors.len(); + *expression = if rules.plan.template.is_some() { + self.own_template(&mut rules, offset) + } else { + let part = RulePart { + roots: rules.plan.roots.clone(), + composing: rules.plan.composing, + }; + self.own_class(&mut rules, part, offset) + }; + if let Some((location, api)) = origin { + self.own_failures + .extend(self.errors.drain(start..).map(|(_, message)| { + let message = message.replacen("`css()`", &format!("`{api}()`"), 1); + format!("{location}: {message}") + })); + } + return true; + } + false + } + + fn own_errors(&mut self, offset: u32, styles: &[ExtractStyleProp<'a>]) { + if let Some((offset, value)) = crate::dead_properties::located_value(styles, offset, false) + { + self.errors.push((offset, unused_error("css", &value))); + } + } +} diff --git a/libs/extractor/src/package_specifier.rs b/libs/extractor/src/package_specifier.rs new file mode 100644 index 000000000..a3a12ebe4 --- /dev/null +++ b/libs/extractor/src/package_specifier.rs @@ -0,0 +1,227 @@ +/// Whether an import names the package itself or one of its subpaths. +pub(crate) fn is_package(source: &str, package: &str) -> bool { + source == package + || source + .strip_prefix(package) + .is_some_and(|rest| rest.starts_with('/')) +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + use serial_test::serial; + + use crate::{ExtractOption, ExtractStyleValue, ResolvedModule, extract, extract_with_modules}; + + #[rstest] + #[case("@devup-ui/react", "@devup-ui/react", true)] + #[case("@devup-ui/react/compat", "@devup-ui/react", true)] + #[case("@devup-ui/react/", "@devup-ui/react", true)] + #[case("@devup-ui/react-values", "@devup-ui/react", false)] + #[case("@devup-ui/reactive", "@devup-ui/react", false)] + #[case("@devup-ui/react-values/colors", "@devup-ui/react", false)] + #[case("@devup-ui/reac", "@devup-ui/react", false)] + #[case("prefix/@devup-ui/react", "@devup-ui/react", false)] + #[case("@my/ui", "@my/ui", true)] + #[case("@my/ui/tokens", "@my/ui", true)] + #[case("@my/ui-tokens", "@my/ui", false)] + #[case("@my/ui?query", "@my/ui", false)] + #[case("@my/ui#fragment", "@my/ui", false)] + #[case("@my/ui\\tokens", "@my/ui", false)] + #[case("@emotion/react", "@emotion/react", true)] + #[case("@emotion/react/jsx-runtime", "@emotion/react", true)] + #[case("@emotion/react-values", "@emotion/react", false)] + #[case("@emotion/styled/base", "@emotion/styled", true)] + #[case("@emotion/styled-extra", "@emotion/styled", false)] + #[case("@stylexjs/stylex", "@stylexjs/stylex", true)] + #[case("@stylexjs/stylex/subpath", "@stylexjs/stylex", true)] + #[case("@stylexjs/stylex-extra", "@stylexjs/stylex", false)] + #[case("", "@devup-ui/react", false)] + fn package_boundary_matches_only_package_or_slash_suffix( + #[case] source: &str, + #[case] package: &str, + #[case] expected: bool, + ) { + // Given a source and package, when comparing, then require an exact or slash-delimited boundary. + assert_eq!(super::is_package(source, package), expected); + } + + #[rstest] + #[case(("@devup-ui/react", "@devup-ui/react-values"))] + #[case(("@my/ui", "@my/ui-tokens"))] + #[serial] + fn package_boundary_sibling_values_are_static( + #[case] (package, sibling): (&'static str, &'static str), + #[values(("/shared.css.ts", "css({ color: value })"), ("/shared.tsx", "css({ color: value })"), ("/shared.tsx", ""))] + (filename, consumer): (&str, &str), + ) -> Result<(), Box> { + // Given + let imported = if consumer.starts_with('<') { + "Box" + } else { + "css" + }; + let source = format!( + "import {{{imported}}} from '{package}';import {{value}} from '{sibling}';export const result={consumer};" + ); + let resolver = move |specifier: &str, _: &str| { + (specifier == sibling).then(|| ResolvedModule { + path: "/values.ts".to_string(), + code: "export const value='red';".to_string(), + }) + }; + let option = ExtractOption { + package: package.to_string(), + ..ExtractOption::default() + }; + // When + let output = extract_with_modules(filename, &source, option, false, &resolver)?; + // Then + assert!(output.styles.iter().any(|style| matches!(style, + ExtractStyleValue::Static(style) if style.property == "color" && style.value == "red" + )), "{:?}", output.styles); + assert!(!output.code.contains("--"), "{}", output.code); + assert_eq!(output.dependencies, vec!["/values.ts"]); + Ok(()) + } + + #[rstest] + #[case(("@devup-ui/react", "@devup-ui/react"))] + #[case(("@devup-ui/react", "@devup-ui/react/styles"))] + #[case(("@devup-ui/react", "@devup-ui/react/compat"))] + #[case(("@my/ui", "@my/ui"))] + #[case(("@my/ui", "@my/ui/styles"))] + #[case(("@my/ui", "@my/ui/compat"))] + #[serial] + fn package_boundary_real_api_computes_styles( + #[case] (package, api): (&str, &str), + ) -> Result<(), Box> { + // Given + let source = format!( + "import {{css}} from '{api}';const twice=(n)=>n*2;export const result=css({{width:twice(3)}});" + ); + let option = ExtractOption { + package: package.to_string(), + ..ExtractOption::default() + }; + // When + let output = extract("/control.tsx", &source, option)?; + // Then + let extracts = api == package || api == format!("{package}/compat"); + assert_eq!(output.styles.iter().any(|style| matches!(style, + ExtractStyleValue::Static(style) if style.property == "width" && style.value == "24px" + )), extracts, "{:?}", output.styles); + Ok(()) + } + + #[rstest] + #[case("@devup-ui/react-values", false)] + #[case("@devup-ui/react", true)] + #[case("@devup-ui/react/styles", true)] + #[case("@devup-ui/react/compat", true)] + fn package_boundary_external_css_is_not_precomputed( + #[case] source: &str, + #[case] expected: bool, + ) { + // Given + let code = format!( + "import {{css}} from '{source}';const twice=(n)=>n*2;export const result=css({{width:twice(3)}});" + ); + // When + let computed = crate::build_time_values::has_build_time_values( + "/control.tsx", + &code, + &ExtractOption::default(), + None, + ); + // Then + assert_eq!(computed, expected); + } + + #[test] + fn package_boundary_external_css_arguments_are_left_untouched() { + // Given + let code = "import {css} from '@devup-ui/react-values';const twice=(n)=>n*2;export const result=css({width:twice(3)});"; + // When + let computed = crate::build_time_values::evaluate_located( + code, + "/control.tsx", + &ExtractOption::default(), + None, + &crate::imported_constants::Unknown::default(), + ) + .unwrap_or_else(|error| panic!("{error:?}")); + // Then + assert_eq!(computed, None); + } + + #[rstest] + #[case("@devup-ui/react-values", true)] + #[case("@devup-ui/react", false)] + #[case("@devup-ui/react/styles", false)] + #[case("@devup-ui/react/compat", false)] + fn package_boundary_side_effect_import_is_kept(#[case] source: &str, #[case] expected: bool) { + // Given + let option = ExtractOption::default(); + let code = format!("import '{source}';export const value='red';"); + let unit = crate::module_loader::Unit::generated("/entry.css.ts", &code); + let mut loader = crate::module_loader::ModuleLoader::new(None, &option); + // When + crate::module_loader::module_script(&unit, &mut loader, true) + .unwrap_or_else(|error| panic!("{error}")); + // Then + assert_eq!( + loader.kept_imports, + if expected { vec![source] } else { vec![] } + ); + } + + #[test] + #[serial] + fn package_boundary_sibling_css_export_is_not_stolen() -> Result<(), Box> + { + // Given + let source = "import {Box} from '@devup-ui/react';import {rules} from './helper';export const result=;"; + let resolver = |specifier: &str, _: &str| { + match specifier { + "./helper" => Some(ResolvedModule { + path: "/helper.ts".to_string(), + code: "import {css} from '@devup-ui/react-values';export const rules=css({color:'blue'});".to_string(), + }), + "@devup-ui/react-values" => Some(ResolvedModule { + path: "/values.ts".to_string(), + code: "export function css(rules){return 'external-'+rules.color;}".to_string(), + }), + _ => None, + } + }; + // When + let output = extract_with_modules( + "/entry.tsx", + source, + ExtractOption::default(), + false, + &resolver, + )?; + // Then + assert_eq!(output.styles.len(), 1, "{:?}", output.styles); + assert!(output.styles.iter().any(|style| matches!(style, ExtractStyleValue::Static(style) if style.property == "color" && style.value == "green"))); + assert!(output.code.contains("rules"), "{}", output.code); + Ok(()) + } + + #[rstest] + #[case("@emotion/react", true)] + #[case("@emotion/react/jsx-runtime", true)] + #[case("@emotion/styled", true)] + #[case("@emotion/styled/base", true)] + #[case("@emotion/react-values", false)] + #[case("@emotion/styled-extra", false)] + fn package_boundary_emotion_recognition_is_unchanged( + #[case] source: &str, + #[case] expected: bool, + ) { + // Given an import specifier, when detecting Emotion, then only its package or subpaths qualify. + assert_eq!(crate::css_prop::is_emotion(source), expected); + } +} diff --git a/libs/extractor/src/parsing.rs b/libs/extractor/src/parsing.rs index cd6ea928b..26f82bb78 100644 --- a/libs/extractor/src/parsing.rs +++ b/libs/extractor/src/parsing.rs @@ -1,7 +1,8 @@ -use oxc_allocator::Allocator; +use oxc_allocator::{Allocator, CloneIn}; use oxc_ast::ast::Program; -use oxc_parser::Parser; +use oxc_parser::{ParseOptions, Parser, ParserReturn}; use oxc_span::{SourceType, UnknownExtension}; +#[cfg(test)] use std::error::Error; pub(super) mod goal_policy; @@ -15,13 +16,71 @@ pub(super) fn source_type(filename: &str) -> Result( + allocator: &'a Allocator, + code: &'a str, + source_type: SourceType, +) -> ParserReturn<'a> { + Parser::new(allocator, code, source_type) + .with_options(ParseOptions { + parse_regular_expression: true, + ..ParseOptions::default() + }) + .parse() +} + +/// Validate physical grammar while keeping validation's semantic IDs off the returned AST. +pub(super) fn checked<'a>( + allocator: &'a Allocator, + code: &'a str, + source_type: SourceType, +) -> Result, Vec<(u32, String)>> { + let parsed = parse_physical(allocator, code, source_type); + let parser_error = parsed + .diagnostics + .errors() + .filter(|error| { + !goal_policy::accepts_declaration_goal(&parsed, &error.to_string(), &error.labels) + }) + .min_by_key(|error| { + goal_policy::is_declaration_goal(&parsed, &error.to_string(), &error.labels) + }); + let validation_allocator = Allocator::default(); + let semantic_diagnostics = if parser_error.is_none() && !parsed.fatal_error { + let validation_program = parsed.program.clone_in(&validation_allocator); + oxc_semantic::SemanticBuilder::new() + .with_check_syntax_error(true) + .build(&validation_program) + .diagnostics + } else { + Default::default() + }; + if let Some(error) = parser_error.or_else(|| semantic_diagnostics.errors().next()) { + let offset = error + .labels + .iter() + .find(|label| label.primary()) + .or_else(|| error.labels.first()) + .map_or(0, oxc_span::LabeledSpan::offset); + return Err(vec![( + offset, + format!( + "Cannot parse source: {}. Fix: correct the syntax at this location", + error.to_string().trim_end_matches('.') + ), + )]); + } + Ok(parsed.program) +} + /// Parse source, reporting syntax errors at their primary source locations. +#[cfg(test)] pub(super) fn parse<'a>( allocator: &'a Allocator, filename: &str, code: &'a str, ) -> Result, Box> { - let parsed = Parser::new(allocator, code, source_type(filename)?).parse(); + let parsed = parse_physical(allocator, code, source_type(filename)?); let mut errors: Vec<_> = parsed .diagnostics .errors() diff --git a/libs/extractor/src/parsing/goal_policy.rs b/libs/extractor/src/parsing/goal_policy.rs index c8f8ce28f..eb2797309 100644 --- a/libs/extractor/src/parsing/goal_policy.rs +++ b/libs/extractor/src/parsing/goal_policy.rs @@ -6,15 +6,12 @@ use oxc_span::{GetSpan, LabeledSpan}; mod tests; /// Recognize only Oxc 0.154's goal mismatch on a direct hybrid-CJS declaration. -pub(crate) fn accepts_declaration_goal( +pub(crate) fn is_declaration_goal( parsed: &ParserReturn<'_>, message: &str, labels: &[LabeledSpan], ) -> bool { - if parsed.fatal_error - || !parsed.program.source_type.is_javascript() - || !parsed.program.source_type.is_commonjs() - { + if !parsed.program.source_type.is_javascript() || !parsed.program.source_type.is_commonjs() { return false; } let keyword = match message { @@ -28,7 +25,7 @@ pub(crate) fn accepts_declaration_goal( if label.primary() || label.label().is_some() || label.len() != 6 { return false; } - parsed.program.body.iter().any(|statement| { + let matches_ast = parsed.program.body.iter().any(|statement| { let declaration_keyword = match statement { Statement::ImportDeclaration(_) => "import", Statement::ExportDeclaration(_) @@ -45,7 +42,20 @@ pub(crate) fn accepts_declaration_goal( usize::try_from(start).unwrap_or(usize::MAX) ..usize::try_from(label.span().end).unwrap_or(usize::MAX), ) == Some(keyword.as_bytes()) - }) + }); + matches_ast + || (parsed.fatal_error + && parsed.program.body.is_empty() + && label.offset() == 0 + && parsed.program.source_text.as_bytes().get(..6) == Some(keyword.as_bytes())) +} + +pub(crate) fn accepts_declaration_goal( + parsed: &ParserReturn<'_>, + message: &str, + labels: &[LabeledSpan], +) -> bool { + !parsed.fatal_error && is_declaration_goal(parsed, message, labels) } /// Check without modifying stock diagnostics, AST nodes or source-type metadata. diff --git a/libs/extractor/src/parsing/hybrid_tests.rs b/libs/extractor/src/parsing/hybrid_tests.rs index 6d80efa05..5156f4ac5 100644 --- a/libs/extractor/src/parsing/hybrid_tests.rs +++ b/libs/extractor/src/parsing/hybrid_tests.rs @@ -7,6 +7,60 @@ use rstest::rstest; use serial_test::serial; use std::collections::HashMap; +#[rstest] +#[case::new_target("console.log(new.target);")] +#[case::top_level_return("return 1;")] +#[case::legacy_comment("