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/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 52062045f..29bc26b23 100644 --- a/libs/extractor/src/barrel.rs +++ b/libs/extractor/src/barrel.rs @@ -294,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> { diff --git a/libs/extractor/src/build_time_values.rs b/libs/extractor/src/build_time_values.rs index e6200125a..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); @@ -914,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()); @@ -942,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()) @@ -1006,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]; @@ -1025,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; } @@ -1044,62 +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'); - } - // 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] - ); + module.copy(code, span.start as usize, span.end as usize); + module.text.push('\n'); } - 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 index 310d45aac..5529f9c8c 100644 --- a/libs/extractor/src/build_time_values/scope_tests.rs +++ b/libs/extractor/src/build_time_values/scope_tests.rs @@ -1,6 +1,24 @@ 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(); 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/css_prop.rs b/libs/extractor/src/css_prop.rs index 754de43e6..e02eb6a48 100644 --- a/libs/extractor/src/css_prop.rs +++ b/libs/extractor/src/css_prop.rs @@ -277,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` 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..2301e844f --- /dev/null +++ b/libs/extractor/src/diagnostics_tests.rs @@ -0,0 +1,124 @@ +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}"); +} + +#[rstest::rstest] +#[case("css({ '@layer': { inherit: { color: props.color } } })", "inherit")] +#[case("css`@layer initial { color: ${props.color}; }`", "@layer initial")] +#[serial] +fn merge_layer_diagnostics_when_rules_capture_function_local_values( + #[case] rules: &str, + #[case] location: &str, +) { + // Given + let source = format!( + "import {{ css }} from '@devup-ui/react';\nexport function card(props) {{ return {rules}; }}" + ); + let offset = source.find(location).unwrap_or_default(); + let expected = crate::locate("/src/local-layer.tsx", &source, offset); + // When + let result = + extract_without_source_map("/src/local-layer.tsx", &source, ExtractOption::default()); + // Then + let Err(error) = result else { + panic!("invalid component layer must be rejected"); + }; + let error = error.to_string(); + assert!( + error + .lines() + .any(|line| line.starts_with(&format!("{expected}:"))), + "{error}" + ); + assert!( + error.contains("name the layer") && error.contains("globalCss"), + "{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/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/imported_constants.rs b/libs/extractor/src/imported_constants.rs index d2e652982..d096ef553 100644 --- a/libs/extractor/src/imported_constants.rs +++ b/libs/extractor/src/imported_constants.rs @@ -596,7 +596,7 @@ pub(crate) fn inline_constants<'a>( } fn is_style_package(option: &ExtractOption, source: &str) -> bool { - source.starts_with(&option.package) || source == crate::STYLEX_PACKAGE + crate::package_specifier::is_package(source, &option.package) || source == crate::STYLEX_PACKAGE } fn inline_in<'a>( @@ -1562,7 +1562,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)) }) } @@ -1579,7 +1579,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?; @@ -1661,7 +1662,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) }) } @@ -3072,6 +3073,9 @@ 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)] 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 4ef3322bc..ab79e63fc 100644 --- a/libs/extractor/src/imported_styled.rs +++ b/libs/extractor/src/imported_styled.rs @@ -376,13 +376,18 @@ impl<'a> Reader<'_, 'a> { copied = offset; } source.push_str(transformed.get(copied..)?); - let (computed, replacements, dependencies) = crate::build_time_values::evaluate( - &source, - path, - self.option, - Some(self.resolver), - unknown, - )?; + let (computed, replacements, dependencies) = + match crate::build_time_values::evaluate_located( + &source, + path, + self.option, + Some(self.resolver), + unknown, + ) { + Ok(value) => value?, + // A refused evaluation leaves the definition unreadable, never executable. + Err(_) => return None, + }; let mut result = String::new(); let mut original = 0; let mut before = 0; diff --git a/libs/extractor/src/imported_styled_coverage_tests.rs b/libs/extractor/src/imported_styled_coverage_tests.rs index 71e381c42..4feac75d2 100644 --- a/libs/extractor/src/imported_styled_coverage_tests.rs +++ b/libs/extractor/src/imported_styled_coverage_tests.rs @@ -90,3 +90,28 @@ fn imported_name_when_member_is_not_exact_does_not_select_a_definition( assert_eq!(name.as_deref(), expected); Ok(()) } + +#[test] +fn imported_evaluation_when_forbidden_helper_is_read_keeps_definition_opaque() { + // Given: a descriptor hides the locale-dependent read from the lexical check. + let allocator = Allocator::default(); + let option = ExtractOption::default(); + let resolver = |_: &str, _: &str| None; + let reader = Reader { + option: &option, + resolver: &resolver, + allocator: &allocator, + modules: FxHashMap::default(), + reading: Vec::new(), + dependencies: BTreeSet::new(), + }; + let source = "import { styled } from '@devup-ui/react'; const compare = Object.getOwnPropertyDescriptor(String.prototype, 'localeCompare').value; export const Base = styled.div({ padding: compare('a', 'b') });"; + // When: the imported-definition retry runs the actual deterministic sandbox. + let computed = reader.evaluate_values( + "/composition/forbidden.tsx", + source, + &crate::imported_constants::Unknown::default(), + ); + // Then: the forbidden helper prevents a computed definition from being published. + assert_eq!(computed, None); +} 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 533dc4364..ea5060d30 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; @@ -26,9 +28,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; @@ -37,9 +43,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; @@ -64,11 +76,36 @@ mod scope; #[cfg(test)] mod selector_array_units_tests; 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; @@ -373,18 +410,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) } @@ -401,6 +426,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() { @@ -509,14 +556,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. @@ -558,26 +704,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 @@ -595,6 +722,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 @@ -605,9 +737,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 mut inlined = if processed_code.is_none() { @@ -661,9 +803,13 @@ fn extract_source( 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() @@ -672,11 +818,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) @@ -686,13 +827,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()) @@ -801,21 +961,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` @@ -848,12 +993,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 @@ -2335,7 +2491,7 @@ mod tests { extract( "test.tsx", r"import {Box as C} from '@devup-ui/core' - + ", ExtractOption { package: "@devup-ui/core".to_string(), @@ -5438,7 +5594,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(); @@ -5457,7 +5613,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" ); } @@ -8333,7 +8489,7 @@ export { c as Lib };"#, extract( "test.jsx", r"import {Box} from '@devup-ui/core' - + ", ExtractOption { package: "@devup-ui/core".to_string(), @@ -14598,7 +14754,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}" ); @@ -21044,7 +21201,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'; @@ -21060,7 +21217,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 { @@ -22591,7 +22748,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) });", @@ -22608,6 +22764,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] @@ -23491,7 +23659,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/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("