diff --git a/.changepacks/changepack_log_argument_spread_contract.json b/.changepacks/changepack_log_argument_spread_contract.json new file mode 100644 index 000000000..6201d0dbc --- /dev/null +++ b/.changepacks/changepack_log_argument_spread_contract.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Keep JavaScript argument-spread semantics in css() and styled(): compose supported literal arrays statically, and reject strings, other iterables and unknown spreads with a located composition error instructing users to pass the value itself instead. No iterators execute at build time or at styling runtime. Preserve optional producer coordinates and checked prelude offsets for located collision diagnostics.", + "date": "2026-10-07T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_atom_hoist_names.json b/.changepacks/changepack_log_atom_hoist_names.json new file mode 100644 index 000000000..83747a572 --- /dev/null +++ b/.changepacks/changepack_log_atom_hoist_names.json @@ -0,0 +1,10 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "packages/next-plugin/package.json": "Patch", + "packages/rsbuild-plugin/package.json": "Patch", + "packages/vite-plugin/package.json": "Patch" + }, + "note": "Make atomHoist class names deterministic and match generated references to emitted CSS, freeze hoisting from predeclared route reach, and preserve dynamic !important, keyframes, and first-value responsive-token identities. No new build errors.", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_coordinator_port_isolation.json b/.changepacks/changepack_log_coordinator_port_isolation.json new file mode 100644 index 000000000..667a75271 --- /dev/null +++ b/.changepacks/changepack_log_coordinator_port_isolation.json @@ -0,0 +1,7 @@ +{ + "changes": { + "packages/next-plugin/package.json": "Patch" + }, + "note": "Isolate the coordinator port file per owning process when another live next dev/build holds it, record the owner pid, and report port, pid and recovery when the coordinator is missing or unreachable", + "date": "2026-10-02T12:41:12.3693972Z" +} diff --git a/.changepacks/changepack_log_counter_constructor_fixture.json b/.changepacks/changepack_log_counter_constructor_fixture.json new file mode 100644 index 000000000..c23ce30dd --- /dev/null +++ b/.changepacks/changepack_log_counter_constructor_fixture.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Qualify authentic original-numbered counter constructors across Rust test crates through non-default dev-only fixture support. Normal extraction, generated JavaScript, CSS naming and cache format remain unchanged; no new build errors or runtime helpers.", + "date": "2026-10-09T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_counter_owner_separation.json b/.changepacks/changepack_log_counter_owner_separation.json new file mode 100644 index 000000000..87d598aca --- /dev/null +++ b/.changepacks/changepack_log_counter_owner_separation.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Separate original CSS allocation ownership from naming policy so the counter naming restoration can retain deferred original owners. Generated CSS, JavaScript and cache behavior are unchanged. No new build errors.", + "date": "2026-10-09T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_counter_slot_preparation.json b/.changepacks/changepack_log_counter_slot_preparation.json new file mode 100644 index 000000000..cec864e1a --- /dev/null +++ b/.changepacks/changepack_log_counter_slot_preparation.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Prepare typed access to actual CSS counter slots while preserving existing allocation keys, namespace streams and rollback behavior. Generated CSS, JavaScript, cache behavior and snapshots are unchanged. No new build errors.", + "date": "2026-10-09T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_deterministic_numbering.json b/.changepacks/changepack_log_deterministic_numbering.json new file mode 100644 index 000000000..053739d3d --- /dev/null +++ b/.changepacks/changepack_log_deterministic_numbering.json @@ -0,0 +1,13 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "packages/plugin-utils/package.json": "Patch", + "packages/vite-plugin/package.json": "Patch", + "packages/webpack-plugin/package.json": "Patch", + "packages/rsbuild-plugin/package.json": "Patch", + "packages/next-plugin/package.json": "Patch", + "packages/bun-plugin/package.json": "Patch" + }, + "note": "Class names and file numbers no longer depend on the order files are processed in. Every plugin walks the files it can extract (the project's source and the included packages) before the first transform and numbers them in path order (a file collapsed into a bucket is numbered as the bucket, and only files that mention the package, StyleX or an aliased package take numbers) through the new seedFileMap, so identical builds give identical class prefixes, CSS file names and output hashes; in development, files that appear later are numbered after the existing ones. Styles are given class names in a fixed order, not the order of a hash set. The exported class, file and canonical maps are canonical JSON (sorted keys). Stylesheet evaluation (vanilla-extract) refuses Math.random(), Date.now(), new Date() and Date(), which differ between builds, with a build error; fixed dates such as new Date(0) are unchanged. resetBuildState starts a build from its own options: the prefix, atom hoisting, routes, buckets, names, numbers and styles an earlier build in the process left are cleared, and the prefix is set on every setup even when it is absent. Engine snapshots from concurrent transforms are written one after another per file, so an older snapshot is never written last. File lists are ordered by code point rather than the locale", + "date": "2026-10-01T00:00:00.000Z" +} \ No newline at end of file diff --git a/.changepacks/changepack_log_dormant_counter_allocator.json b/.changepacks/changepack_log_dormant_counter_allocator.json new file mode 100644 index 000000000..527a7daf8 --- /dev/null +++ b/.changepacks/changepack_log_dormant_counter_allocator.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Prepare the exact legacy counter allocator and captured, map-independent naming inputs for the authoritative counter-cache transition. The new path is dormant until activation; current CSS, JavaScript and cache behavior are unchanged. No new build errors.", + "date": "2026-10-09T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_dormant_counter_kernel.json b/.changepacks/changepack_log_dormant_counter_kernel.json new file mode 100644 index 000000000..01550ec58 --- /dev/null +++ b/.changepacks/changepack_log_dormant_counter_kernel.json @@ -0,0 +1,5 @@ +{ + "changes": { "bindings/devup-ui-wasm/package.json": "Patch" }, + "note": "Add a dormant Rust-only scoped prepared/live Counter kernel with independent retained evidence and exact rollback. Production Current extraction, cache4 and JavaScript output remain unchanged; no new activated JavaScript build errors.", + "date": "2026-10-09T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_dormant_counter_producers.json b/.changepacks/changepack_log_dormant_counter_producers.json new file mode 100644 index 000000000..41271cad3 --- /dev/null +++ b/.changepacks/changepack_log_dormant_counter_producers.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Prepare dormant original-owned static, dynamic and keyframe counter producers with exact legacy allocation inputs and request order, retaining source-site variable separation. Current production naming, generated output and cache behavior remain unchanged. No new build errors.", + "date": "2026-10-09T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_dormant_counter_replay.json b/.changepacks/changepack_log_dormant_counter_replay.json new file mode 100644 index 000000000..63921bfda --- /dev/null +++ b/.changepacks/changepack_log_dormant_counter_replay.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Prepare dormant frozen-input declaration replay and complete counter expansion witnesses for the authoritative cache transition. The test-driven path preserves reset, typography, keyframe and cleanup evidence without changing current CSS, JavaScript or cache behavior. No new build errors.", + "date": "2026-10-09T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_dormant_numbered_sites.json b/.changepacks/changepack_log_dormant_numbered_sites.json new file mode 100644 index 000000000..b254de6ff --- /dev/null +++ b/.changepacks/changepack_log_dormant_numbered_sites.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Prepare dormant original-file numeric registration and numbered source-scope capture for the counter transition, preserving source positions, roles and ownership independently of delivery. Current production naming, generated output and cache behavior remain unchanged. No new build errors.", + "date": "2026-10-09T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_dormant_producer_policy.json b/.changepacks/changepack_log_dormant_producer_policy.json new file mode 100644 index 000000000..1fd549a03 --- /dev/null +++ b/.changepacks/changepack_log_dormant_producer_policy.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Prepare dormant construction-time producer policy so original counter ownership survives deferred style unions, including dynamic and keyframe producers. Current production naming, generated output and cache behavior remain unchanged. No new build errors.", + "date": "2026-10-09T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_environment_stable_names.json b/.changepacks/changepack_log_environment_stable_names.json new file mode 100644 index 000000000..dcd8ba16e --- /dev/null +++ b/.changepacks/changepack_log_environment_stable_names.json @@ -0,0 +1,12 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "packages/react/package.json": "Patch", + "packages/components/package.json": "Patch", + "packages/next-plugin/package.json": "Patch", + "packages/vite-plugin/package.json": "Patch", + "packages/rsbuild-plugin/package.json": "Patch" + }, + "note": "Stabilize class and runtime-variable names across build environments, bound unnumbered source identities with compact deterministic names, reset generated variables on their owning elements, and preserve authored evaluation order and lazy selection. Exact content-name and source-name collisions report both contents and their best real source locations; conflicting source-variable roles and unprotected naming caches now fail closed without rejecting literal CSS content strings.", + "date": "2026-10-05T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_exclusive_attempt.json b/.changepacks/changepack_log_exclusive_attempt.json new file mode 100644 index 000000000..83e571cef --- /dev/null +++ b/.changepacks/changepack_log_exclusive_attempt.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Enforce build-time exclusive ownership of naming state and exact rollback when extraction or output construction fails, without changing production naming or cache format. Native lock-held recursive callbacks and same-owner configuration administration during an active exact attempt fail synchronously as programmer-contract violations; normal compilation gains no new build errors.", + "date": "2026-10-09T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_static_scoped_names.json b/.changepacks/changepack_log_static_scoped_names.json new file mode 100644 index 000000000..7267d0485 --- /dev/null +++ b/.changepacks/changepack_log_static_scoped_names.json @@ -0,0 +1,13 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "packages/vite-plugin/package.json": "Patch", + "packages/webpack-plugin/package.json": "Patch", + "packages/rsbuild-plugin/package.json": "Patch", + "packages/next-plugin/package.json": "Patch", + "packages/bun-plugin/package.json": "Patch", + "packages/components/package.json": "Patch" + }, + "note": "Compile proven static responsive arrays directly to classes and isolate content-named atoms by their existing delivered sheet identity. Safe original sources keep independent D9 counter namespaces even when their CSS shares a collapsed bucket. Build adapters supply an independent names-only project root; resolver IDs, D9 numbering and stylesheet placement stay unchanged. Versioned self-contained naming snapshots ignore and rebuild obsolete or invalid cache data that reaches WASM; cache build errors are reserved for real exact name collisions with located diagnostics. Static Textarea font-size arrays retain their declarations and DOM while compiling directly to classes.", + "date": "2026-10-06T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_styled_creation.json b/.changepacks/changepack_log_styled_creation.json new file mode 100644 index 000000000..748ebc975 --- /dev/null +++ b/.changepacks/changepack_log_styled_creation.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Evaluate supported conditional styled rule arguments once when the component is created, preserving branch laziness, authored field order, static selectors and layers, typography and runtime value associations. Keep rule capture outside attrs wrappers while authored attrs callbacks remain render-time. Responsive typography retains its shared base class and order-aware class selection without introducing empty styles for proven inert base-preset lookups. This scheduling repair adds no build errors or styling runtime.", + "date": "2026-10-07T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_typography_classes.json b/.changepacks/changepack_log_typography_classes.json new file mode 100644 index 000000000..c47791b47 --- /dev/null +++ b/.changepacks/changepack_log_typography_classes.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Select dynamic typography and enum classes from the authored value exactly once in source order, without typography CSS variables or declarations. Preserve responsive and selector-scoped presets, conditional style order, template coercion and the existing spread contract. No new build errors or styling runtime are introduced.", + "date": "2026-10-07T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_w31s_u1.json b/.changepacks/changepack_log_w31s_u1.json new file mode 100644 index 000000000..677972168 --- /dev/null +++ b/.changepacks/changepack_log_w31s_u1.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Keep dormant Rust counter state owned by the sheet with pure linkage checks and explicit literal ownership. The doc-hidden CounterSheet constructor now takes only the sheet. Production Current naming and cache4 remain unchanged; no new JavaScript build errors.", + "date": "2026-10-10T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_w31t_u2.json b/.changepacks/changepack_log_w31t_u2.json new file mode 100644 index 000000000..9e4572937 --- /dev/null +++ b/.changepacks/changepack_log_w31t_u2.json @@ -0,0 +1,5 @@ +{ + "changes": { "bindings/devup-ui-wasm/package.json": "Patch" }, + "note": "Add dormant Rust strict snapshot6 export and consuming admission APIs without changing production cache4 or JavaScript extraction. Invalid candidates and stale installation authority return typed Rust rejections; no new ordinary JavaScript build errors.", + "date": "2026-10-10T00:00:00.000Z" +} diff --git a/bindings/devup-ui-wasm/Cargo.toml b/bindings/devup-ui-wasm/Cargo.toml index fa769a310..e79916945 100644 --- a/bindings/devup-ui-wasm/Cargo.toml +++ b/bindings/devup-ui-wasm/Cargo.toml @@ -42,6 +42,8 @@ getrandom = { version = "0.4", features = ["wasm_js"] } boa_engine = { version = "0.22", features = ["js"] } [dev-dependencies] +extractor = { path = "../../libs/extractor", features = ["counter-fixtures"] } +css = { path = "../../libs/css", features = ["admission-observer"] } wasm-bindgen-test = "0.3.78" serial_test = "4.0.1" insta = "1.48.0" diff --git a/bindings/devup-ui-wasm/src/administration.rs b/bindings/devup-ui-wasm/src/administration.rs new file mode 100644 index 000000000..dc90f1f38 --- /dev/null +++ b/bindings/devup-ui-wasm/src/administration.rs @@ -0,0 +1,8 @@ +/// Native programmer contract: ordinary extraction never administers build state. +/// Real linkage activation will replace this panic with a recoverable typed error. +pub(crate) fn with_administration(operation: &str, build: impl FnOnce() -> R) -> R { + css::admission::with_admission(|| { + css::admission::assert_administration_allowed(operation); + build() + }) +} diff --git a/bindings/devup-ui-wasm/src/admission_contract_tests.rs b/bindings/devup-ui-wasm/src/admission_contract_tests.rs new file mode 100644 index 000000000..47312985c --- /dev/null +++ b/bindings/devup-ui-wasm/src/admission_contract_tests.rs @@ -0,0 +1,131 @@ +use super::*; +use exact_test_support::{authority, fixture}; +use rstest::rstest; +use serial_test::serial; +use std::panic::{AssertUnwindSafe, catch_unwind}; + +#[rstest] +#[case("set_debug", || set_debug(true))] +#[case("set_prefix", || set_prefix(Some("changed".into())))] +#[case("set_naming_root", || set_naming_root(Some("/changed".into()), None))] +#[case("set_atom_hoist", || set_atom_hoist(Some(2)))] +#[case("import_canonical_map_internal", || import_canonical_map_internal(HashMap::new()))] +#[case("import_file_routes_internal", || import_file_routes_internal(HashMap::new()))] +#[case("register_theme_internal", || register_theme_internal(sheet::theme::Theme::default()))] +#[case("register_shorthands_internal", || register_shorthands_internal(BTreeMap::new()))] +#[case("set_module_resolver", || set_module_resolver_internal(None))] +#[case("seed_file_map", || seed_file_map(vec!["changed.tsx".into()]))] +#[case("reset_build_state_internal", reset_build_state_internal)] +#[case("import_sheet_internal", || { import_sheet_internal(StyleSheet::default()).unwrap_or_else(|error| panic!("sheet import failed: {error}")); })] +#[case("cache_restore::clear", cache_restore::clear)] +#[case("cache_restore::seeded", || cache_restore::seeded(&["changed.tsx".into()]))] +#[case("cache_restore::classes", || cache_restore::classes(None))] +#[case("cache_restore::files", || cache_restore::files(None))] +#[case("cache_restore::import", || { cache_restore::import(StyleSheet::default()).unwrap_or_else(|error| panic!("cache import failed: {error}")); })] +#[case("StyleSheet::set_theme", || StyleSheet::default().set_theme(sheet::theme::Theme::default()))] +#[serial] +fn guarded_entry_panics_with_exact_message_before_mutation( + #[case] operation: &str, + #[case] administer: fn(), +) { + // Given + fixture(); + set_prefix(Some("retained".into())); + set_atom_hoist(Some(7)); + register_shorthands_internal(BTreeMap::from([("custom".into(), vec!["color".into()])])); + let before = authority(); + cache_names::record(&Err("sticky retained".into())); + // When + let panic = match catch_unwind(AssertUnwindSafe(|| { + let _: Result<(), ()> = css::exact_attempt::with_exclusive_attempt(|| { + administer(); + Ok(()) + }); + })) { + Err(payload) => payload, + Ok(()) => panic!("guarded administration did not panic"), + }; + // Then + let message = match panic.downcast_ref::() { + Some(message) => message.as_str(), + None => *panic + .downcast_ref::<&str>() + .unwrap_or_else(|| panic!("guarded administration panic is not a string")), + }; + assert_eq!(message, format!("{operation}: an exact attempt is active")); + assert_eq!(authority(), before); + assert_eq!(get_prefix(), Some("retained".into())); + assert_eq!(css::atom_hoist::atom_hoist_threshold(), Some(7)); + assert_eq!( + css::get_custom_shorthand_names(), + vec!["custom".to_string()] + ); + assert_eq!(cache_names::check(), Err("sticky retained".into())); + register_shorthands_internal(BTreeMap::new()); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn sheet_entry_marker_recovers_after_unwind_and_allows_sequential_calls() { + // Given + reset_build_state_internal(); + let _poison_cleanup = exact_test_support::SheetPoisonCleanup::new(); + // When + let result = catch_unwind(AssertUnwindSafe(|| { + with_style_sheet_mut(|_| panic!("sheet unwind")); + })); + // Then + assert!(result.is_err()); + with_admission(|| { + with_style_sheet_mut(|sheet| sheet.add_css("after.tsx", concat!("body{", "color:red}"))); + with_style_sheet(|sheet| assert!(sheet.css.contains_key("after.tsx"))); + }); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn failed_extraction_leaves_cache_authority_and_ordered_fresh_seed_replay_unchanged() { + // Given + reset_build_state_internal(); + let serialized = export_sheet_internal() + .unwrap_or_else(|error| panic!("cache fixture export failed: {error}")); + seed_file_map(vec!["before.tsx".into()]); + import_sheet_internal( + serde_json::from_str(&serialized) + .unwrap_or_else(|error| panic!("cache fixture decoding failed: {error}")), + ) + .unwrap_or_else(|error| panic!("cache fixture import failed: {error}")); + seed_file_map(vec!["b.tsx".into(), "b.tsx".into()]); + seed_file_map(vec!["a.tsx".into(), "b.tsx".into()]); + let before = authority(); + // When + let failed = exact_test_support::compile( + "broken.tsx", + "import {Box} from '@devup-ui/react'; const x= { + export_sheet_internal() + .unwrap_or_else(|error| panic!("gateway sheet export failed: {error}")); + } + Self::GetCss => { + get_css_internal(None, false) + .unwrap_or_else(|error| panic!("gateway CSS read failed: {error}")); + } + Self::Theme => register_theme_internal(sheet::theme::Theme::default()), + Self::ClassCompanion => cache_restore::classes(None), + Self::FileCompanion => cache_restore::files(None), + Self::SheetImport => { + import_sheet_internal(StyleSheet::default()) + .unwrap_or_else(|error| panic!("gateway sheet import failed: {error}")); + } + Self::Reset => reset_build_state(), + Self::Seed => seed_file_map(vec!["worker.tsx".into()]), + Self::Prefix => set_prefix(Some("worker".into())), + Self::Debug => set_debug(false), + Self::NamingRoot => set_naming_root(None, None), + Self::Canonical => import_canonical_map_internal(HashMap::new()), + Self::Routes => import_file_routes_internal(HashMap::new()), + Self::Hoist => set_atom_hoist(None), + Self::Shorthands => register_shorthands_internal(BTreeMap::new()), + Self::Resolver => set_module_resolver_internal(None), + Self::NativeTheme => StyleSheet::default().set_theme(sheet::theme::Theme::default()), + Self::NativeExport => { + serde_json::to_string(&StyleSheet::default().export_snapshot()) + .unwrap_or_else(|error| panic!("native export encoding failed: {error}")); + } + } + } +} + +#[rstest] +#[case(Gateway::ExportSheet)] +#[case(Gateway::GetCss)] +#[case(Gateway::Theme)] +#[case(Gateway::ClassCompanion)] +#[case(Gateway::FileCompanion)] +#[case(Gateway::SheetImport)] +#[case(Gateway::Reset)] +#[case(Gateway::Seed)] +#[case(Gateway::Prefix)] +#[case(Gateway::Debug)] +#[case(Gateway::NamingRoot)] +#[case(Gateway::Canonical)] +#[case(Gateway::Routes)] +#[case(Gateway::Hoist)] +#[case(Gateway::Shorthands)] +#[case(Gateway::Resolver)] +#[case(Gateway::NativeTheme)] +#[case(Gateway::NativeExport)] +#[serial] +fn public_gateway_waits_for_exact_release( + #[case] gateway: Gateway, + #[values(false, true)] commit: bool, +) { + // Given + fixture(); + let (done, received) = channel(); + // When + std::thread::scope(|scope| { + let result = exact(|| { + mutate_authority(); + let contender = scope.spawn(move || { + gateway.call(); + done.send(()) + .unwrap_or_else(|error| panic!("gateway completion send failed: {error}")); + }); + css::admission::observer::wait_for_thread(contender.thread().id()); + assert_eq!(received.try_recv(), Err(TryRecvError::Empty)); + if commit { Ok(()) } else { Err("abort".into()) } + }); + // Then + assert_eq!(result, if commit { Ok(()) } else { Err("abort".into()) }); + received + .recv() + .unwrap_or_else(|error| panic!("gateway completion receive failed: {error}")); + }); + reset_build_state_internal(); +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn export_observes_only_aborted_or_committed_sheet(#[case] commit: bool) { + // Given + fixture(); + let before = with_style_sheet(extraction_rollback::SheetData::capture); + let (done, received) = channel(); + // When + std::thread::scope(|scope| { + let mut committed = None; + let result = exact(|| { + mutate_authority(); + committed = Some(with_style_sheet(extraction_rollback::SheetData::capture)); + let contender = scope.spawn(move || { + let exported = export_sheet_internal() + .unwrap_or_else(|error| panic!("contender sheet export failed: {error}")); + let sheet = with_style_sheet(extraction_rollback::SheetData::capture); + done.send((exported, sheet)) + .unwrap_or_else(|error| panic!("export observation send failed: {error}")); + }); + css::admission::observer::wait_for_thread(contender.thread().id()); + assert_eq!(received.try_recv(), Err(TryRecvError::Empty)); + if commit { Ok(()) } else { Err("abort".into()) } + }); + // Then + assert_eq!(result.is_ok(), commit); + let (exported, observed) = received + .recv() + .unwrap_or_else(|error| panic!("export observation receive failed: {error}")); + assert_eq!( + observed, + if commit { + committed.unwrap_or_else(|| panic!("committed sheet capture missing")) + } else { + before + } + ); + assert_eq!( + exported, + export_sheet_internal() + .unwrap_or_else(|error| panic!("released sheet export failed: {error}")) + ); + }); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn admission_precedes_sheet_lock_for_sheet_to_css_callbacks() { + // Given + fixture(); + let (done, received) = channel(); + // When + std::thread::scope(|scope| { + with_admission(|| { + let contender = scope.spawn(move || { + with_style_sheet_mut(|sheet| { + let _name = css::keyframes_to_keyframes_name("worker", None); + sheet.add_css("worker.tsx", "body{margin:3px}"); + }); + done.send(()).unwrap_or_else(|error| { + panic!("sheet callback completion send failed: {error}") + }); + }); + css::admission::observer::wait_for_thread(contender.thread().id()); + with_style_sheet(|sheet| assert!(sheet.css.contains_key("raw.tsx"))); + }); + // Then + received + .recv() + .unwrap_or_else(|error| panic!("sheet callback completion receive failed: {error}")); + }); + assert!(with_style_sheet(|sheet| sheet + .css + .contains_key("worker.tsx"))); + reset_build_state_internal(); +} diff --git a/bindings/devup-ui-wasm/src/admission_guard_tests.rs b/bindings/devup-ui-wasm/src/admission_guard_tests.rs new file mode 100644 index 000000000..a5888ac1c --- /dev/null +++ b/bindings/devup-ui-wasm/src/admission_guard_tests.rs @@ -0,0 +1,182 @@ +use super::*; +use serial_test::serial; + +fn during_exact(build: impl FnOnce()) { + let result: Result<(), ()> = css::exact_attempt::with_exclusive_attempt(|| { + build(); + Ok(()) + }); + assert_eq!(result, Ok(())); +} + +#[test] +#[serial] +#[should_panic(expected = "set_debug: an exact attempt is active")] +fn debug_rejects_owner_administration() { + during_exact(|| set_debug(true)); +} + +#[test] +#[serial] +#[should_panic(expected = "set_prefix: an exact attempt is active")] +fn prefix_rejects_owner_administration() { + during_exact(|| set_prefix(Some("other".into()))); +} + +#[test] +#[serial] +#[should_panic(expected = "set_naming_root: an exact attempt is active")] +fn naming_root_rejects_owner_administration() { + during_exact(|| set_naming_root(Some("/other".into()), None)); +} + +#[test] +#[serial] +#[should_panic(expected = "import_canonical_map_internal: an exact attempt is active")] +fn canonical_import_rejects_owner_administration() { + during_exact(|| import_canonical_map_internal(HashMap::new())); +} + +#[test] +#[serial] +#[should_panic(expected = "import_file_routes_internal: an exact attempt is active")] +fn routes_import_rejects_owner_administration() { + during_exact(|| import_file_routes_internal(HashMap::new())); +} + +#[test] +#[serial] +#[should_panic(expected = "set_atom_hoist: an exact attempt is active")] +fn hoist_rejects_owner_administration() { + during_exact(|| set_atom_hoist(Some(2))); +} + +#[test] +#[serial] +#[should_panic(expected = "register_theme_internal: an exact attempt is active")] +fn theme_registration_rejects_owner_administration() { + during_exact(|| register_theme_internal(sheet::theme::Theme::default())); +} + +#[test] +#[serial] +#[should_panic(expected = "register_shorthands_internal: an exact attempt is active")] +fn shorthands_reject_owner_administration() { + during_exact(|| register_shorthands_internal(BTreeMap::new())); +} + +#[test] +#[serial] +#[should_panic(expected = "set_module_resolver: an exact attempt is active")] +fn resolver_selection_rejects_owner_administration() { + during_exact(|| set_module_resolver_internal(None)); +} + +#[test] +#[serial] +#[should_panic(expected = "seed_file_map: an exact attempt is active")] +fn seed_rejects_owner_administration() { + during_exact(|| seed_file_map(vec!["other.tsx".into()])); +} + +#[test] +#[serial] +#[should_panic(expected = "reset_build_state_internal: an exact attempt is active")] +fn reset_rejects_owner_administration() { + during_exact(reset_build_state_internal); +} + +#[test] +#[serial] +#[should_panic(expected = "import_sheet_internal: an exact attempt is active")] +fn sheet_import_rejects_owner_administration() { + during_exact(|| { + import_sheet_internal(StyleSheet::default()) + .unwrap_or_else(|error| panic!("sheet import failed: {error}")); + }); +} + +#[test] +#[serial] +#[should_panic(expected = "cache_restore::clear: an exact attempt is active")] +fn companion_clear_rejects_owner_administration() { + during_exact(cache_restore::clear); +} + +#[test] +#[serial] +#[should_panic(expected = "cache_restore::seeded: an exact attempt is active")] +fn companion_seed_rejects_owner_administration() { + during_exact(|| cache_restore::seeded(&["other.tsx".into()])); +} + +#[test] +#[serial] +#[should_panic(expected = "cache_restore::classes: an exact attempt is active")] +fn class_companion_rejects_owner_administration() { + during_exact(|| cache_restore::classes(None)); +} + +#[test] +#[serial] +#[should_panic(expected = "cache_restore::files: an exact attempt is active")] +fn file_companion_rejects_owner_administration() { + during_exact(|| cache_restore::files(None)); +} + +#[test] +#[serial] +#[should_panic(expected = "cache_restore::import: an exact attempt is active")] +fn cache_import_rejects_owner_administration() { + during_exact(|| { + cache_restore::import(StyleSheet::default()) + .unwrap_or_else(|error| panic!("cache import failed: {error}")); + }); +} + +#[test] +#[serial] +#[should_panic(expected = "StyleSheet::set_theme: an exact attempt is active")] +fn native_theme_setter_rejects_owner_administration() { + during_exact(|| StyleSheet::default().set_theme(sheet::theme::Theme::default())); +} + +#[test] +#[serial] +#[should_panic(expected = "style sheet: recursive lock-held entry")] +fn sheet_read_rejects_recursive_read() { + let _poison_cleanup = exact_test_support::SheetPoisonCleanup::new(); + with_style_sheet(|_| { + with_style_sheet(|_| {}); + }); +} + +#[test] +#[serial] +#[should_panic(expected = "style sheet: recursive lock-held entry")] +fn sheet_read_rejects_recursive_write() { + let _poison_cleanup = exact_test_support::SheetPoisonCleanup::new(); + with_style_sheet(|_| { + with_style_sheet_mut(|_| {}); + }); +} + +#[test] +#[serial] +#[should_panic(expected = "style sheet: recursive lock-held entry")] +fn sheet_write_rejects_recursive_read() { + let _poison_cleanup = exact_test_support::SheetPoisonCleanup::new(); + with_style_sheet_mut(|_| { + with_style_sheet(|_| {}); + }); +} + +#[test] +#[serial] +#[should_panic(expected = "style sheet: recursive lock-held entry")] +fn sheet_write_rejects_recursive_write() { + let _poison_cleanup = exact_test_support::SheetPoisonCleanup::new(); + with_style_sheet_mut(|_| { + with_style_sheet_mut(|_| {}); + }); +} diff --git a/bindings/devup-ui-wasm/src/atom_hoist_measurement.rs b/bindings/devup-ui-wasm/src/atom_hoist_measurement.rs new file mode 100644 index 000000000..64038e6e2 --- /dev/null +++ b/bindings/devup-ui-wasm/src/atom_hoist_measurement.rs @@ -0,0 +1,244 @@ +//! Test-only measurements under immutable, predeclared module reach. + +mod error_tests; +mod report; +mod snapshot; + +use snapshot::Snapshot; + +use super::{ + code_extract_internal, register_theme_internal, reset_build_state_internal, seed_file_map, + set_atom_hoist, with_style_sheet, +}; +use std::collections::{HashMap, HashSet}; + +const ROUTE_IDS: [u32; 8] = [0, 1, 2, 3, 4, 5, 6, 7]; +const ROUTES: usize = ROUTE_IDS.len(); +const SHARED_MODULES: usize = 32; +const SHARED_ATOMS: usize = 80; +const PRIVATE_ATOMS: usize = 25; +const PROPS: [(&str, &str); 8] = [ + ("w", "width"), + ("h", "height"), + ("p", "padding"), + ("m", "margin"), + ("minW", "min-width"), + ("minH", "min-height"), + ("maxW", "max-width"), + ("maxH", "max-height"), +]; + +struct Module { + path: String, + atoms: Vec, +} + +struct Atom { + property: (&'static str, &'static str), + value: String, +} + +impl Module { + fn source(&self) -> String { + let mut elements = String::new(); + for atom in &self.atoms { + elements.push_str(""); + } + format!("import {{ Box }} from '@devup-ui/react'; const x = <>{elements};") + } + + fn declarations(&self) -> Vec { + self.atoms + .iter() + .map(|atom| format!("{}:{}", atom.property.1, atom.value)) + .collect() + } +} + +fn fixture() -> Vec { + let atoms = |count, start| { + (0..count) + .map(|i| Atom { + property: PROPS[i % PROPS.len()], + value: format!("{}px", start + i), + }) + .collect() + }; + let mut modules: Vec = (0..SHARED_MODULES) + .map(|i| Module { + path: format!("src/shared/s{i:02}.tsx"), + atoms: atoms(SHARED_ATOMS, 100_000), + }) + .collect(); + modules.extend((0..ROUTES).map(|route| Module { + path: format!("src/routes/r{route}.tsx"), + atoms: atoms(PRIVATE_ATOMS, 1_000_000 + route * PRIVATE_ATOMS), + })); + modules.push(Module { + path: "src/unknown.tsx".to_string(), + atoms: atoms(1, 2_000_000), + }); + modules +} + +#[derive(Debug)] +pub(super) struct ExtractionError { + file: String, + message: String, +} + +impl std::fmt::Display for ExtractionError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "{}: {}", self.file, self.message) + } +} + +impl std::error::Error for ExtractionError {} + +struct BuildState; + +impl Drop for BuildState { + fn drop(&mut self) { + reset_build_state_internal(); + } +} + +struct Measurement { + before: Snapshot, + after: Snapshot, +} + +fn build(modules: &[Module], hoist: bool) -> Result { + reset_build_state_internal(); + let _state = BuildState; + register_theme_internal(sheet::theme::Theme::default()); + seed_file_map(modules.iter().map(|m| m.path.clone()).collect()); + let all_routes: HashSet = ROUTE_IDS.into_iter().collect(); + let mut reach: HashMap> = modules[..SHARED_MODULES] + .iter() + .map(|m| (m.path.clone(), all_routes.clone())) + .collect(); + reach.extend(ROUTE_IDS.iter().enumerate().map(|(r, id)| { + ( + modules[SHARED_MODULES + r].path.clone(), + HashSet::from([*id]), + ) + })); + super::import_file_routes_internal(reach); + set_atom_hoist(hoist.then_some(2)); + let code = modules + .iter() + .map(|module| { + code_extract_internal( + &module.path, + &module.source(), + "@devup-ui/react", + "df".to_string(), + false, + false, + true, + HashMap::new(), + ) + .map(|output| output.code()) + .map_err(|message| ExtractionError { + file: module.path.clone(), + message, + }) + }) + .collect::, _>>()?; + Ok(Snapshot::capture(modules, &code)) +} + +fn measure(hoist: bool) -> Result { + // Given: every shared module reaches all routes before any extraction. + let mut modules = fixture(); + let before = build(&modules, hoist)?; + before.assert_loaded_definitions(&modules); + let copies = if hoist { 1 } else { SHARED_MODULES }; + for declaration in modules[0].declarations() { + let needle = format!("{{{declaration}}}"); + assert_eq!( + before + .sheets + .iter() + .map(|s| s.matches(&needle).count()) + .sum::(), + copies + ); + assert_eq!( + before.sheets[0].matches(&needle).count(), + usize::from(hoist) + ); + } + // When: deploy a fresh complete build after changing one upstream constant. + for module in &mut modules[..SHARED_MODULES] { + module.atoms[0].value = "900000px".to_string(); + } + let after = build(&modules, hoist)?; + // Then: fresh transformed code still loads the exact new declarations. + after.assert_loaded_definitions(&modules); + assert_eq!(after.changed(&before).0, copies); + assert_eq!(after.sheets[0] != before.sheets[0], hoist); + assert_eq!( + after + .sheets + .iter() + .map(|s| s.matches("{width:900000px}").count()) + .sum::(), + copies + ); + assert_eq!( + after + .sheets + .iter() + .map(|s| s.matches("{width:100000px}").count()) + .sum::(), + 0 + ); + for (i, module) in modules.iter().enumerate().skip(SHARED_MODULES) { + for declaration in module.declarations() { + assert!(after.sheets[i + 1].contains(&format!("{{{declaration}}}"))); + assert!(!after.sheets[0].contains(&format!("{{{declaration}}}"))); + } + } + Ok(Measurement { before, after }) +} + +fn checked_measurements() -> Result<(Measurement, Measurement), ExtractionError> { + let baseline = measure(false)?; + let atom = measure(true)?; + let redundant_payload = (SHARED_MODULES - 1) + * fixture()[0] + .declarations() + .iter() + .map(String::len) + .sum::(); + // Retain at least one route's share of eliminated declaration payload after + // paying the ACTUAL lossless-name/header/import costs. No fixed percentage. + assert!(baseline.before.raw_bytes() >= atom.before.raw_bytes() + redundant_payload / ROUTES); + let baseline_changed = baseline.after.changed(&baseline.before); + let atom_changed = atom.after.changed(&atom.before); + assert_eq!(baseline_changed.0 - atom_changed.0, SHARED_MODULES - 1); + assert!(baseline_changed.1 >= atom_changed.1 + (SHARED_MODULES - 1) * "width:900000px".len()); + Ok((baseline, atom)) +} + +/// Callable replacement body; the caller must retain its serial-test guard. +pub(super) fn assert_predeclared_shared_savings() -> Result<(), ExtractionError> { + let artifacts = build_predeclared_measurement_artifacts()?; + report::assert_artifacts(&artifacts); + println!("{}", artifacts["report"]); + Ok(()) +} + +/// Build complete artifacts independently of the caller's stdout/env policy. +/// The caller must retain its serial-test guard. +pub(super) fn build_predeclared_measurement_artifacts() -> Result +{ + let (baseline, atom) = checked_measurements()?; + Ok(report::artifacts(&baseline, &atom)) +} 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 new file mode 100644 index 000000000..00a1410cc --- /dev/null +++ b/bindings/devup-ui-wasm/src/atom_hoist_measurement/error_tests.rs @@ -0,0 +1,30 @@ +use super::{ExtractionError, build, fixture}; +use serial_test::serial; + +#[test] +#[serial] +fn build_reports_failing_file_when_fixture_contains_malformed_jsx() { + // Given: a malformed first atom in the otherwise unchanged real fixture. + let mut modules = fixture(); + modules[0].atoms[0].value = "\"".to_string(); + // When: extraction fails through the measurement build boundary. + let error: ExtractionError = match build(&modules, true) { + Ok(_) => panic!("malformed JSX must fail extraction"), + Err(error) => error, + }; + // Then: the typed error retains its failing file and parser context. + assert_eq!(error.file, "src/shared/s00.tsx"); + assert_eq!(error.message, "Parser panicked"); + assert_eq!(error.to_string(), "src/shared/s00.tsx: Parser panicked"); + assert_eq!(css::atom_hoist::atom_hoist_threshold(), None); + assert_eq!(css::file_routes::get_file_routes().len(), 0); + + // Given: a valid fixture after the failed build has released its state. + let valid = fixture(); + // When: a subsequent build extracts that fixture without hoisting. + let snapshot = build(&valid, false).unwrap_or_else(|error| panic!("{error}")); + // Then: every emitted class loads the valid fixture's exact declaration. + snapshot.assert_loaded_definitions(&valid); + assert!(!snapshot.sheets[0].contains("{width:100000px}")); + assert!(snapshot.sheets[1].contains("{width:100000px}")); +} diff --git a/bindings/devup-ui-wasm/src/atom_hoist_measurement/report.rs b/bindings/devup-ui-wasm/src/atom_hoist_measurement/report.rs new file mode 100644 index 000000000..505f7e38e --- /dev/null +++ b/bindings/devup-ui-wasm/src/atom_hoist_measurement/report.rs @@ -0,0 +1,119 @@ +use super::{Measurement, PRIVATE_ATOMS, ROUTES, SHARED_ATOMS, SHARED_MODULES, Snapshot, fixture}; +use serde_json::{Value, json}; + +fn report(baseline: &Measurement, atom: &Measurement) -> Value { + json!({ + "invalidationModel": "fresh deployment builds; exact stylesheet content comparison", + "fixture": {"routes": ROUTES, "sharedModules": SHARED_MODULES, + "sharedAtoms": SHARED_ATOMS, "privateAtomsPerRoute": PRIVATE_ATOMS, + "unknownLocalModules": 1, "threshold": 2}, + "sessionRaw": {"baseline": baseline.before.raw_bytes(), "atom": atom.before.raw_bytes()}, + "invalidationRaw": {"baseline": baseline.after.changed(&baseline.before).1, + "atom": atom.after.changed(&atom.before).1}, + "changedSheets": {"baseline": baseline.after.changed(&baseline.before).0, + "atom": atom.after.changed(&atom.before).0}, + "sheetCountSavings": {"numerator": SHARED_MODULES - 1, "denominator": SHARED_MODULES}, + "jsxClassNameRawBytes": {"baseline": baseline.before.name_bytes(), "atom": atom.before.name_bytes()}, + "cssSelectorNameRawBytes": {"baseline": baseline.before.selector_name_bytes(), + "atom": atom.before.selector_name_bytes()}, + "gzip": "external measurement required; no compression dependency added" + }) +} + +pub(super) fn artifacts(baseline: &Measurement, atom: &Measurement) -> Value { + json!({ + "report": report(baseline, atom), + "baseline": {"beforeSheets": baseline.before.sheets, "afterSheets": baseline.after.sheets, + "beforeCode": baseline.before.code, "afterCode": baseline.after.code}, + "atom": {"beforeSheets": atom.before.sheets, "afterSheets": atom.after.sheets, + "beforeCode": atom.before.code, "afterCode": atom.after.code}, + "paths": fixture().iter().map(|m| &m.path).collect::>() + }) +} + +pub(super) fn assert_artifacts(artifacts: &Value) { + // Given: the original fixture, independently of the exported JSON. + let modules = fixture(); + // When: consume the complete stdout representation as an external tool does. + let serialized = artifacts.to_string(); + let decoded: Value = + serde_json::from_str(&serialized).unwrap_or_else(|error| panic!("{error}")); + // Then: paths, code and sheets retain their fixture identity and deployment. + let paths: Vec = + serde_json::from_value(decoded["paths"].clone()).unwrap_or_else(|error| panic!("{error}")); + assert_eq!( + paths, + modules.iter().map(|m| m.path.clone()).collect::>() + ); + let summary = &decoded["report"]; + assert_eq!( + summary["fixture"], + json!({"routes": ROUTES, "sharedModules": SHARED_MODULES, + "sharedAtoms": SHARED_ATOMS, "privateAtomsPerRoute": PRIVATE_ATOMS, + "unknownLocalModules": 1, "threshold": 2}) + ); + assert_eq!( + summary["sheetCountSavings"], + json!({"numerator": SHARED_MODULES - 1, + "denominator": SHARED_MODULES}) + ); + for (name, copies) in [("baseline", SHARED_MODULES), ("atom", 1)] { + let mut modules = fixture(); + let before = Snapshot { + sheets: serde_json::from_value(decoded[name]["beforeSheets"].clone()) + .unwrap_or_else(|error| panic!("{error}")), + code: serde_json::from_value(decoded[name]["beforeCode"].clone()) + .unwrap_or_else(|error| panic!("{error}")), + }; + let after = Snapshot { + sheets: serde_json::from_value(decoded[name]["afterSheets"].clone()) + .unwrap_or_else(|error| panic!("{error}")), + code: serde_json::from_value(decoded[name]["afterCode"].clone()) + .unwrap_or_else(|error| panic!("{error}")), + }; + assert_eq!(before.sheets.len(), modules.len() + 1); + assert_eq!(before.code.len(), modules.len()); + before.assert_loaded_definitions(&modules); + for module in &mut modules[..SHARED_MODULES] { + module.atoms[0].value = "900000px".to_string(); + } + assert_eq!(after.sheets.len(), modules.len() + 1); + assert_eq!(after.code.len(), modules.len()); + after.assert_loaded_definitions(&modules); + assert_eq!( + before.sheets[0].contains("{width:100000px}"), + name == "atom" + ); + assert_eq!( + after + .sheets + .iter() + .map(|s| s.matches("{width:900000px}").count()) + .sum::(), + copies + ); + assert_eq!( + after + .sheets + .iter() + .map(|s| s.matches("{width:100000px}").count()) + .sum::(), + 0 + ); + assert_eq!(after.changed(&before).0, copies); + assert_eq!(summary["sessionRaw"][name], json!(before.raw_bytes())); + assert_eq!( + summary["invalidationRaw"][name], + json!(after.changed(&before).1) + ); + assert_eq!(summary["changedSheets"][name], json!(copies)); + assert_eq!( + summary["jsxClassNameRawBytes"][name], + json!(before.name_bytes()) + ); + assert_eq!( + summary["cssSelectorNameRawBytes"][name], + json!(before.selector_name_bytes()) + ); + } +} diff --git a/bindings/devup-ui-wasm/src/atom_hoist_measurement/snapshot.rs b/bindings/devup-ui-wasm/src/atom_hoist_measurement/snapshot.rs new file mode 100644 index 000000000..4bcb098df --- /dev/null +++ b/bindings/devup-ui-wasm/src/atom_hoist_measurement/snapshot.rs @@ -0,0 +1,87 @@ +use super::{Module, with_style_sheet}; +use std::collections::HashSet; + +fn classes(code: &str) -> Vec<&str> { + code.split("className=\"") + .skip(1) + .flat_map(|part| part.split('"').take(1).flat_map(str::split_whitespace)) + .collect() +} + +pub(super) struct Snapshot { + pub(super) sheets: Vec, + pub(super) code: Vec, +} + +impl Snapshot { + pub(super) fn capture(modules: &[Module], code: &[String]) -> Self { + let mut sheets = vec![with_style_sheet(|s| s.create_css(None, false))]; + sheets.extend( + modules + .iter() + .map(|m| with_style_sheet(|s| s.create_css(Some(&m.path), true))), + ); + Self { + sheets, + code: code.to_vec(), + } + } + + pub(super) fn assert_loaded_definitions(&self, modules: &[Module]) { + for (i, module) in modules.iter().enumerate() { + let names = classes(&self.code[i]); + let expected = module.declarations(); + assert_eq!( + names.len(), + expected.len(), + "{}: static JSX classes", + module.path + ); + assert!(!self.code[i].contains(" usize { + self.sheets.iter().map(String::len).sum() + } + + pub(super) fn name_bytes(&self) -> usize { + self.code + .iter() + .flat_map(|code| classes(code)) + .map(str::len) + .sum() + } + + pub(super) fn selector_name_bytes(&self) -> usize { + let names: HashSet<&str> = self.code.iter().flat_map(|code| classes(code)).collect(); + names + .iter() + .map(|name| { + name.len() + * self + .sheets + .iter() + .map(|sheet| sheet.matches(&format!(".{name}{{")).count()) + .sum::() + }) + .sum() + } + + pub(super) fn changed(&self, before: &Self) -> (usize, usize) { + let changed: Vec<&String> = self + .sheets + .iter() + .zip(&before.sheets) + .filter_map(|(after, before)| (after != before).then_some(after)) + .collect(); + (changed.len(), changed.iter().map(|sheet| sheet.len()).sum()) + } +} diff --git a/bindings/devup-ui-wasm/src/atom_tests.rs b/bindings/devup-ui-wasm/src/atom_tests.rs new file mode 100644 index 000000000..554a224d6 --- /dev/null +++ b/bindings/devup-ui-wasm/src/atom_tests.rs @@ -0,0 +1,275 @@ +use super::*; +use serial_test::serial; +use std::collections::HashSet; + +mod lifecycle; + +fn setup() { + reset_build_state_internal(); + css::debug::set_debug(false); + register_theme_internal(sheet::theme::Theme::default()); + css::atom_hoist::set_atom_hoist(Some(2)); + css::file_routes::set_file_routes(HashMap::from([ + ("a.tsx".to_string(), HashSet::from([0, 1])), + ("b.tsx".to_string(), HashSet::from([0, 1])), + ("private.tsx".to_string(), HashSet::from([0])), + ("styles.css.ts".to_string(), HashSet::from([0, 1])), + ])); + seed_file_map(vec!["a.tsx".into(), "b.tsx".into(), "private.tsx".into()]); +} + +fn compile(file: &str, source: &str) -> Output { + code_extract_internal( + file, + source, + "@devup-ui/react", + "df".to_string(), + false, + false, + false, + HashMap::from([( + "@vanilla-extract/css".to_string(), + ImportAlias::NamedToNamed, + )]), + ) + .unwrap_or_else(|error| panic!("{error}")) +} + +fn emitted(file: &str) -> String { + with_style_sheet(|sheet| { + format!( + "{}{}", + sheet.create_css(None, false), + sheet.create_css(Some(file), false) + ) + }) +} + +fn assert_references_resolve(code: &str, css: &str) { + // Read generated payloads, not import paths, identifiers or CSS property keys. + let literals = + css::utils::compile_regex(r#""(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`"#); + let class = css::utils::compile_regex( + r"^(?:du-)?(?:(?:(?:[a-z_][a-z0-9_]*|[a-z0-9_]*a-d|F(?:L[a-z0-9_]{0,16}|H[a-z0-9_]{16}))-)?[OR](?:L[a-z0-9_-]+|H[a-z0-9_]{16})|(?:[a-z_][a-z0-9_]*|[a-z0-9_]*a-d)-(?:[a-z_][a-z0-9_]*|[a-z0-9_]*a-d))$", + ); + let animation = css::utils::compile_regex(r"^(?:du-)?K(?:L[a-z0-9_-]+|H[a-z0-9_]{16})$"); + let variable = css::utils::compile_regex( + r"^---(?:du-)?S(?:[a-z_][a-z0-9_]*|U[a-z0-9_]+)-[a-z_][a-z0-9_]*(?:-[a-z_][a-z0-9_]*)?$", + ); + let mut classes = 0; + for literal in literals.find_iter(code) { + let before = code[..literal.start()].trim_end(); + if before.ends_with("import") || before.ends_with("from") { + continue; + } + let quoted = literal.as_str(); + let property_key = code[literal.end()..].trim_start().starts_with(':'); + for token in quoted[1..quoted.len() - 1].split_whitespace() { + if variable.is_match(token) { + assert!( + css.contains(&format!("var({token})")), + "unemitted variable {token}" + ); + } else if animation.is_match(token) { + assert!( + css.contains(&format!("@keyframes {token}{{")), + "unemitted keyframes {token}" + ); + } else if class.is_match(token) && !property_key { + let selector = format!(".{token}"); + assert!( + css.match_indices(&selector).any(|(at, _)| { + matches!( + css[at + selector.len()..].chars().next(), + Some('{' | ':' | '[' | '.' | '#' | ' ' | ',' | '>' | '+' | '~') + ) + }), + "unemitted class {token}\n{css}" + ); + classes += 1; + } + } + } + assert!(classes > 0, "fixture must generate classes: {code}"); +} + +#[rstest::rstest] +#[case(r#"
"#)] +#[case(r#"
"#)] +#[case(r#"
"#)] +#[case(r#"
"#)] +#[case(r#"
"#)] +#[case(r#"const fade="KHaaaaaaaaaaaaaaaa"; const cls="OLcolor-vred";"#)] +#[case(r#"const fade="du-KLsfrom-e-"; const cls="OLcolor-vred";"#)] +#[case(r#"
"#)] +#[should_panic(expected = "unemitted")] +fn reference_checker_rejects_dangling_payloads_when_another_class_resolves(#[case] code: &str) { + // Given / When / Then: a valid class cannot hide another unresolved reference. + assert_references_resolve(code, &format!(".{}{{color:red}}", "OLcolor-vred")); +} + +#[test] +fn reference_checker_ignores_nonpayload_names_when_valid_classes_are_present() { + // Given + let code = r#"import "devup-ui"; import "df/devup-ui/a.css"; + const OLmissing=()=>({"font-family":"Arial"}); + export const cls="du-c-a du-OLcolor-vred";"#; + // When / Then + assert_references_resolve( + code, + &format!( + ".{}{{padding:0}}.{}{{color:red}}", + "du-c-a", "du-OLcolor-vred" + ), + ); +} + +#[test] +#[should_panic(expected = "fixture must generate classes")] +fn reference_checker_rejects_fixtures_when_no_class_is_generated() { + assert_references_resolve("export const x=
;", ""); +} + +const SOURCE_A: &str = r#" +import { Box, css, styled, keyframes } from '@devup-ui/react'; +const fade = keyframes({from:{opacity:0,transform:'scale(0)'},to:{opacity:1}}); +const cls = css({color:'red', _hover:{color:'blue'}, p:['1px',null,'2px']}); +const Card = styled('div', {display:'flex', color:'red'}); +export const view = <>; +"#; + +const SOURCE_B: &str = r#" +import { Box, css, keyframes } from '@devup-ui/react'; +const fade = keyframes({from:{opacity:0,transform:'scale(0)'},to:{opacity:1}}); +const cls = css({color:'red', _hover:{color:'blue'}, p:['1px',null,'2px']}); +export const view = ; +"#; + +#[test] +#[serial] +fn atom_references_and_output_are_stable_when_extraction_order_changes() { + // Given + let build = |reverse: bool| { + setup(); + let mut sources = [("a.tsx", SOURCE_A), ("b.tsx", SOURCE_B)]; + if reverse { + sources.reverse(); + } + let mut output = BTreeMap::new(); + // When + for (file, source) in sources { + let result = compile(file, source); + assert!(result.updated_base_style); + output.insert(file, result.code); + } + // Then + for (file, code) in &output { + assert_references_resolve(code, &emitted(file)); + } + let css = with_style_sheet(|sheet| sheet.create_css(None, false)); + assert!(css.contains("@layer o1,o2"), "{css}"); + assert!(css.contains("!important")); + assert!(!css.contains("differentRuntimeCode")); + assert!( + !with_style_sheet(|sheet| sheet.create_css(Some("a.tsx"), false)) + .contains("background:red") + ); + (output, css, emitted("a.tsx"), emitted("b.tsx")) + }; + assert_eq!(build(false), build(true)); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn atom_names_and_emission_are_stable_when_debug_changes_with_prefix() { + // Given + let build = |debug| { + setup(); + set_prefix(Some("du-".to_string())); + css::debug::set_debug(debug); + // When + let output = compile("a.tsx", SOURCE_A); + let css = emitted("a.tsx"); + // Then + assert_references_resolve(&output.code, &css); + (output.code, css) + }; + assert_eq!(build(false), build(true)); + reset_build_state_internal(); + css::debug::set_debug(false); +} + +#[test] +#[serial] +fn same_atom_stays_local_when_a_private_bucket_also_uses_it() { + // Given + setup(); + let source = r#"import {Box} from '@devup-ui/react'; export const x=;"#; + // When + let shared = compile("a.tsx", source); + let private = compile("private.tsx", source); + // Then + assert_references_resolve(&shared.code, &emitted("a.tsx")); + assert_references_resolve(&private.code, &emitted("private.tsx")); + let global = with_style_sheet(|sheet| sheet.create_css(None, false)); + let local = with_style_sheet(|sheet| sheet.create_css(Some("private.tsx"), false)); + assert_eq!(global.matches("color:red").count(), 1); + assert_eq!(local.matches("color:red").count(), 1); + assert_ne!(shared.code, private.code); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn compat_apis_reference_emitted_atoms_when_bucket_is_predeclared() { + // Given + setup(); + let sources = [ + ( + "a.tsx", + r"import * as stylex from '@stylexjs/stylex'; + const s=stylex.create({root:{color:'red',':hover':{color:'blue'}}}); + export const x=
;", + ), + ( + "styles.css.ts", + r"import {style,layer,keyframes} from '@vanilla-extract/css'; + const l=layer('cards'); const fade=keyframes({from:{opacity:0},to:{opacity:1}}); + export const card=style({'@layer':{[l]:{color:'red',animation:`${fade} 1s`}}, + selectors:{'&:hover':{color:'blue'}}});", + ), + ]; + // When + for (file, source) in sources { + let output = compile(file, source); + // Then + assert_references_resolve(&output.code, &emitted(file)); + } + reset_build_state_internal(); +} + +#[test] +#[serial] +fn local_ordered_styles_signal_shared_layer_order_changes() { + // Given + setup(); + let plain = compile( + "private.tsx", + r#"import {Box} from '@devup-ui/react'; export const x=;"#, + ); + assert!(!plain.updated_base_style); + let source = r#"import {Box} from '@devup-ui/react'; export const x=;"#; + // When + let output = compile("private.tsx", source); + // Then + assert!(output.updated_base_style); + assert_references_resolve(&output.code, &emitted("private.tsx")); + assert!(with_style_sheet(|sheet| sheet.create_css(None, false)).contains("@layer o1;")); + assert!(!compile("private.tsx", source).updated_base_style); + reset_build_state_internal(); +} diff --git a/bindings/devup-ui-wasm/src/atom_tests/lifecycle.rs b/bindings/devup-ui-wasm/src/atom_tests/lifecycle.rs new file mode 100644 index 000000000..f2395b0f3 --- /dev/null +++ b/bindings/devup-ui-wasm/src/atom_tests/lifecycle.rs @@ -0,0 +1,285 @@ +use super::*; + +const RED: &str = "import {Box} from '@devup-ui/react'; export const x=;"; + +#[test] +#[serial] +fn unknown_and_late_reach_stay_local_until_reset() { + // Given + setup(); + compile("a.tsx", RED); + let mut routes = css::file_routes::get_file_routes(); + routes.insert("unknown.tsx".into(), HashSet::from([0, 1])); + routes.insert("private.tsx".into(), HashSet::from([0, 1])); + css::file_routes::set_file_routes(routes.clone()); + css::atom_hoist::set_atom_hoist(Some(1)); + // When + for file in ["unknown.tsx", "private.tsx"] { + let output = compile(file, RED); + // Then + assert_references_resolve(&output.code, &emitted(file)); + assert!( + with_style_sheet(|sheet| sheet.create_css(Some(file), false)).contains("color:red") + ); + } + reset_build_state_internal(); + css::atom_hoist::set_atom_hoist(Some(2)); + css::file_routes::set_file_routes(routes); + let output = compile("unknown.tsx", RED); + assert_references_resolve(&output.code, &emitted("unknown.tsx")); + assert!( + !with_style_sheet(|sheet| sheet.create_css(Some("unknown.tsx"), false)) + .contains("color:red") + ); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn imported_sheet_keeps_recorded_placement_after_configuration_reset() { + // Given + setup(); + let shared = compile("a.tsx", RED); + let private = compile("private.tsx", RED); + let global = with_style_sheet(|sheet| sheet.create_css(None, false)); + let local = with_style_sheet(|sheet| sheet.create_css(Some("private.tsx"), false)); + let serialized = export_sheet_internal().unwrap_or_else(|error| panic!("{error}")); + reset_build_state_internal(); + let imported = serde_json::from_str(&serialized).unwrap_or_else(|error| panic!("{error}")); + // When + import_sheet_internal(imported).unwrap_or_else(|error| panic!("{error}")); + seed_file_map(vec!["a.tsx".into(), "b.tsx".into(), "private.tsx".into()]); + // Then + assert_eq!( + with_style_sheet(|sheet| sheet.create_css(None, false)), + global + ); + assert_eq!( + with_style_sheet(|sheet| sheet.create_css(Some("private.tsx"), false)), + local + ); + assert_references_resolve(&shared.code, &emitted("a.tsx")); + assert_references_resolve(&private.code, &emitted("private.tsx")); + css::atom_hoist::set_atom_hoist(Some(2)); + css::file_routes::set_file_routes(HashMap::from([( + "private.tsx".into(), + HashSet::from([0, 1]), + )])); + let resumed = compile("private.tsx", RED); + assert_eq!(private.code, resumed.code); + let shared_again = compile("b.tsx", RED); + assert_references_resolve(&shared_again.code, &emitted("b.tsx")); + assert!( + !with_style_sheet(|sheet| sheet.create_css(Some("b.tsx"), false)).contains("color:red") + ); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn canonical_bucket_reach_is_frozen_before_imported_styles_are_processed() { + // Given + setup(); + import_canonical_map_internal(HashMap::from([ + ("member.tsx".into(), "bucket.tsx".into()), + ("peer.tsx".into(), "bucket.tsx".into()), + ])); + css::file_routes::set_file_routes(HashMap::from([ + ("member.tsx".into(), HashSet::from([0])), + ("peer.tsx".into(), HashSet::from([1])), + ])); + let resolver = |specifier: &str, _importer: &str| { + (specifier == "./tokens").then(|| ResolvedModule { + path: "tokens.ts".to_string(), + code: "export const styles={color:'red',_hover:{color:'blue'}};".to_string(), + }) + }; + // When + let output = code_extract_with_modules_internal( + "member.tsx", + "import {Box} from '@devup-ui/react'; import {styles} from './tokens'; export const x=;", + "@devup-ui/react", + "df".to_string(), + false, + false, + false, + HashMap::new(), + &resolver, + ) + .unwrap_or_else(|error| panic!("{error}")); + // Then + assert_references_resolve(&output.code, &emitted("bucket.tsx")); + assert!(with_style_sheet(|sheet| sheet.create_css(None, false)).contains("color:red")); + assert!( + !with_style_sheet(|sheet| sheet.create_css(Some("bucket.tsx"), false)) + .contains("color:red") + ); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn important_and_plain_dynamic_values_emit_distinct_rules_in_every_mode() { + // Given + let source = r"import {Box} from '@devup-ui/react'; + export const x=<>;"; + for atom in [false, true] { + setup(); + if !atom { + css::atom_hoist::set_atom_hoist(None); + } + // When + let output = compile("private.tsx", source); + // Then + let css = emitted("private.tsx"); + let properties = with_style_sheet(|sheet| { + sheet.properties["private.tsx"] + .values() + .flat_map(BTreeMap::values) + .flatten() + .cloned() + .collect::>() + }); + let rules: Vec<_> = properties + .iter() + .filter(|prop| prop.property == "color") + .collect(); + let resets: Vec<_> = properties.iter().filter(|prop| prop.owner_reset).collect(); + assert_eq!(rules.len(), 3); + assert_eq!(resets.len(), 3); + assert_eq!( + rules + .iter() + .map(|prop| &prop.class_name) + .collect::>() + .len(), + 3 + ); + assert_eq!( + rules + .iter() + .map(|prop| &prop.value) + .collect::>() + .len(), + 3 + ); + let assignments = css::utils::compile_regex( + r#"["'](---(?:du-)?S[a-z0-9_U-]+)["']\s*:\s*(tone\b|differentCode\b|`\$\{tone\}`)"#, + ); + let sites: Vec<_> = assignments.captures_iter(&output.code).collect(); + assert_eq!(sites.len(), 3, "{}", output.code); + assert_eq!( + sites + .iter() + .map(|site| &site[1]) + .collect::>() + .len(), + 3 + ); + assert_eq!(&sites[0][2], "tone"); + assert_eq!(&sites[1][2], "differentCode"); + assert!(matches!(&sites[2][2], "tone" | "`${tone}`")); + assert_references_resolve(&output.code, &css); + for (index, site) in sites.iter().enumerate() { + let variable = &site[1]; + let value = format!( + "var({variable}){}", + if index == 2 { " !important" } else { "" } + ); + let rule = rules + .iter() + .find(|prop| prop.value == value) + .unwrap_or_else(|| panic!("missing site color rule for {value}\n{css}")); + assert!(!rule.owner_reset); + assert!(output.code.contains(&rule.class_name)); + let owned: Vec<_> = resets + .iter() + .filter(|prop| prop.class_name == rule.class_name) + .collect(); + assert_eq!(owned.len(), 1); + assert_eq!(owned[0].property, variable); + assert_eq!(owned[0].value, "initial"); + assert_eq!(owned[0].selector, None); + assert_eq!(owned[0].layer, None); + let selector = format!(".{}{{", rule.class_name); + assert_eq!(css.matches(&selector).count(), 1, "{css}"); + let block = css + .split_once(&selector) + .unwrap_or_else(|| panic!("missing own class block {selector}\n{css}")) + .1; + let block = block + .split_once('}') + .unwrap_or_else(|| panic!("unclosed class block {selector}\n{css}")) + .0; + let declarations: HashSet<_> = + block.split(';').filter(|value| !value.is_empty()).collect(); + assert_eq!( + declarations, + HashSet::from([ + format!("{variable}:initial").as_str(), + format!("color:{value}").as_str(), + ]) + ); + } + } + reset_build_state_internal(); +} + +#[test] +#[serial] +fn keyframe_content_and_scope_resolve_when_using_single_css() { + // Given + setup(); + let source = r"import {Box,keyframes} from '@devup-ui/react'; + const a=keyframes({from:{opacity:0,transform:'scale(0)'},to:{opacity:1}}); + const b=keyframes({from:{transform:'scale(0)',opacity:0},to:{opacity:1}}); + const c=keyframes({from:{opacity:0,transform:'scale(0)'},to:{opacity:0.5}}); + export const x=<>;"; + // When + let output = code_extract_internal( + "a.tsx", + source, + "@devup-ui/react", + "df".into(), + true, + false, + false, + HashMap::new(), + ) + .unwrap_or_else(|error| panic!("{error}")); + // Then + let css = with_style_sheet(|sheet| sheet.create_css(None, false)); + assert_references_resolve(&output.code, &css); + with_style_sheet(|sheet| { + assert_eq!(sheet.keyframes[""].len(), 2); + for name in sheet.keyframes[""].keys() { + assert!(output.code.contains(name)); + assert!(css.contains(&format!("@keyframes {name}{{"))); + } + }); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn global_css_keyframes_use_the_same_scope_as_their_declaration() { + // Given + setup(); + let source = r"import {Box,keyframes,globalCss} from '@devup-ui/react'; + const fade=keyframes({from:{opacity:0},to:{opacity:1}}); + globalCss({body:{animation:`${fade} 1s`}}); export const x=;"; + // When + let output = compile("a.tsx", source); + // Then + assert_references_resolve(&output.code, &emitted("a.tsx")); + let global = with_style_sheet(|sheet| sheet.create_css(None, false)); + let local = with_style_sheet(|sheet| sheet.create_css(Some("a.tsx"), false)); + with_style_sheet(|sheet| { + for name in sheet.keyframes["a.tsx"].keys() { + assert!(global.contains(&format!("animation:{name} 1s")), "{global}"); + assert!(local.contains(&format!("@keyframes {name}{{"))); + assert!(!global.contains(&format!("@keyframes {name}{{"))); + } + }); + reset_build_state_internal(); +} diff --git a/bindings/devup-ui-wasm/src/cache6_adoption_tests.rs b/bindings/devup-ui-wasm/src/cache6_adoption_tests.rs new file mode 100644 index 000000000..72911af66 --- /dev/null +++ b/bindings/devup-ui-wasm/src/cache6_adoption_tests.rs @@ -0,0 +1,163 @@ +use super::cache6_test_support::*; + +#[rstest] +#[case(0)] +#[case(1)] +#[case(2)] +#[case(3)] +#[case(4)] +#[case(5)] +#[serial] +fn genuine_state_is_adopted_before_work_when_raw6_is_complete(#[case] family: u8) { + // Given + let _guard = Guard::new(); + configure_typography(); + output("a", family); + with_style_sheet_mut(|sheet| { + sheet.add_css("literal", "body{}"); + sheet.add_import("literal", "literal.css"); + sheet.add_font_face( + "literal", + &BTreeMap::from([("font-family".into(), "literal".into())]), + ); + sheet.add_property("first", "color", 0, "blue", None, None, None); + sheet.add_property("second", "color", 0, "blue", None, None, None); + }); + seed_file_map(vec!["unused".into()]); + css::class_map::with_class_map_mut(|map| { + map.insert("unused".into(), HashMap::from([("reservation".into(), 0)])); + }); + let bytes = encoded(); + let expected_maps = cache6_session::Maps::capture(); + with_style_sheet_mut(|sheet| sheet.source_ids = css::file_map::get_original_ids()); + let expected_sheet = with_style_sheet(LiveCheckpoint::capture); + fresh(); + configure_typography(); + exact_test_support::compile( + "manual.tsx", + "import {css} from '@devup-ui/react';export const x=css({color:'green'});", + ) + .unwrap_or_else(|error| panic!("{error}")); + assert_ne!(with_style_sheet(|sheet| sheet.names.len()), 0); + // When + assert_eq!(cache6_restore::import(&bytes), Ok(())); + // Then + assert_eq!(cache6_session::Maps::capture(), expected_maps); + assert_eq!(with_style_sheet(LiveCheckpoint::capture), expected_sheet); + assert_eq!(with_style_sheet(|sheet| sheet.names.len()), 0); + cache6_restore::RESTORE.with_borrow(|state| assert!(state.session.is_some())); +} + +#[rstest] +#[case(None)] +#[case(Some(BTreeSet::new()))] +#[case(Some(BTreeSet::from(["a".into()])))] +#[serial] +fn empty_counter_state_and_plan_are_adopted_when_snapshot_has_no_records( + #[case] plan: Option>, +) { + // Given + let _guard = Guard::new(); + update( + &Default::default(), + OutputRoute { + raw_source: "a", + single_css: true, + import_main_css: false, + }, + ) + .unwrap_or_else(|error| panic!("{error:?}")); + css::atom_hoist::restore_atom_plan(plan.clone()); + with_style_sheet_mut(|sheet| sheet.atom_plan.clone_from(&plan)); + let bytes = encoded(); + let expected = authority(); + fresh(); + let absent = with_style_sheet(LiveCheckpoint::capture); + // When + assert_eq!(cache6_restore::import(&bytes), Ok(())); + // Then + assert_eq!(authority(), expected); + assert_ne!(with_style_sheet(LiveCheckpoint::capture), absent); + assert_eq!(css::atom_hoist::atom_plan(), plan); +} + +#[test] +#[serial] +fn two_lineages_survive_adoption_when_equal_names_share_a_slot() { + // Given + let _guard = Guard::new(); + for source in ["a", "b"] { + let items = with_original( + FixtureRequest { + filename: source, + source: "abcdef", + }, + || { + let mut declaration = ExtractStaticStyle::new("color", "red", 0, None); + declaration.style_order = Some(0); + FxHashSet::from_iter([ExtractStyleValue::Static(declaration)]) + }, + ) + .unwrap_or_else(|error| panic!("{error:?}")); + update( + &items, + OutputRoute { + raw_source: "a", + single_css: false, + import_main_css: false, + }, + ) + .unwrap_or_else(|error| panic!("{error:?}")); + } + let bytes = encoded(); + let value: serde_json::Value = + serde_json::from_slice(&bytes).unwrap_or_else(|error| panic!("{error:?}")); + let witnesses: Vec<_> = value["evidence"]["counters"] + .as_object() + .unwrap_or_else(|| panic!("namespaces")) + .values() + .flat_map(|slots| { + slots + .as_object() + .unwrap_or_else(|| panic!("slots")) + .values() + }) + .flat_map(|value| value.as_array().unwrap_or_else(|| panic!("witnesses"))) + .collect(); + assert_eq!(witnesses.len(), 2); + assert_eq!(witnesses[0]["proof"], witnesses[1]["proof"]); + assert_ne!(witnesses[0]["lineage"], witnesses[1]["lineage"]); + with_style_sheet_mut(|sheet| sheet.source_ids = css::file_map::get_original_ids()); + let expected = authority(); + fresh(); + // When + assert_eq!(cache6_restore::import(&bytes), Ok(())); + // Then + assert_eq!(authority(), expected); +} + +#[test] +#[serial] +fn ordinary_compiler_stays_current_when_constructor_feature_is_enabled() { + // Given + let _guard = Guard::new(); + // When + with_original( + FixtureRequest { + filename: "fixture", + source: "const a=1;", + }, + || { + exact_test_support::compile( + "ordinary.tsx", + "import {css} from '@devup-ui/react';export const x=css({color:'red'});", + ) + }, + ) + .unwrap_or_else(|error| panic!("{error:?}")) + .unwrap_or_else(|error| panic!("{error:?}")); + // Then + assert_eq!(css::file_map::original_id("ordinary.tsx"), None); + assert_ne!(with_style_sheet(|sheet| sheet.names.len()), 0); + assert!(with_style_sheet_mut(|sheet| CounterSheet::new(sheet).export_snapshot6()).is_err()); +} diff --git a/bindings/devup-ui-wasm/src/cache6_configuration_tests.rs b/bindings/devup-ui-wasm/src/cache6_configuration_tests.rs new file mode 100644 index 000000000..1dd7b589c --- /dev/null +++ b/bindings/devup-ui-wasm/src/cache6_configuration_tests.rs @@ -0,0 +1,215 @@ +use super::cache6_test_support::*; + +#[path = "cache6_transition_tests.rs"] +mod transitions; + +#[rstest] +#[case(false, 0)] +#[case(false, 1)] +#[case(false, 2)] +#[case(false, 3)] +#[case(false, 4)] +#[case(true, 0)] +#[case(true, 1)] +#[case(true, 2)] +#[case(true, 3)] +#[case(true, 4)] +#[serial] +fn active_ingress_rejects_immediately_when_exact_depth_is_one_or_two( + #[case] nested: bool, + #[case] ingress: u8, +) { + // Given + let _guard = Guard::new(); + output("cached", 0); + let bytes = encoded(); + fresh(); + with_style_sheet_mut(|sheet| { + sheet.add_css("sentinel", concat!("body", "{", "color:blue", "}")) + }); + seed_file_map(vec!["sentinel".into()]); + assert_eq!(cache6_restore::import(&bytes), Ok(())); + output("new", 1); + seed_file_map(vec!["journal".into()]); + let before = authority(); + // When + cache6_restore::RESTORE.with_borrow(|before_session| { + let refusal = || { + let result = match ingress { + 0 => cache6_restore::import(b"malformed"), + 1 => cache6_restore::classes(None), + 2 => cache6_restore::files(Some(BTreeMap::from([("wrong".into(), 77)]))), + 3 => cache6_restore::seeded(&["wrong".into()]), + 4 => cache6_restore::clear(), + _ => panic!("ingress"), + }; + // Then: observe inside the deepest callback, before CSS Err rollback. + assert_eq!( + result, + Err(EvidenceError::ActiveExact( + css::admission::ActiveExactAttempt + )) + ); + assert_eq!(authority(), before); + cache6_restore::RESTORE.with_borrow(|state| assert_eq!(state, before_session)); + result + }; + let result = css::exact_attempt::with_exclusive_attempt(|| { + if nested { + css::exact_attempt::with_exclusive_attempt(refusal) + } else { + refusal() + } + }); + assert_eq!( + result, + Err(EvidenceError::ActiveExact( + css::admission::ActiveExactAttempt + )) + ); + }); +} + +#[rstest] +#[case(0)] +#[case(1)] +#[case(2)] +#[case(3)] +#[serial] +fn install_rechecks_actual_authority_when_validation_precedes_configuration_change( + #[case] change: u8, +) { + // Given + let _guard = Guard::new(); + output("cached", 0); + let bytes = encoded(); + let certificate = snapshot6::validate_snapshot( + snapshot6::parse(&bytes).unwrap_or_else(|error| panic!("{error:?}")), + ) + .unwrap_or_else(|error| panic!("{error:?}")); + fresh(); + with_style_sheet_mut(|sheet| { + sheet.add_css("sentinel", concat!("body", "{", "color:blue", "}")) + }); + seed_file_map(vec!["sentinel".into()]); + match change { + 0 => set_prefix(Some("different".into())), + 1 => set_debug(true), + 2 => set_atom_hoist(Some(2)), + 3 => css::atom_hoist::restore_atom_plan(Some(BTreeSet::new())), + _ => panic!("change"), + } + let before = authority(); + // When + let result = with_style_sheet_mut(|sheet| certificate.install_into(sheet)); + // Then + assert_eq!(result, Err(EvidenceError::Configuration)); + assert_eq!(authority(), before); +} + +#[rstest] +#[case(0)] +#[case(1)] +#[case(2)] +#[case(3)] +#[case(4)] +#[case(5)] +#[serial] +fn replacement_cannot_clear_target_latch_when_a_restore_session_exists(#[case] ingress: u8) { + // Given + let _guard = Guard::new(); + output("a", 0); + let bytes = encoded(); + fresh(); + with_style_sheet_mut(|sheet| sheet.add_css("prior", concat!("body", "{", "color:blue", "}"))); + seed_file_map(vec!["prior-seed".into()]); + let (before_maps, before_sheet) = cache6_session::Restore::before(); + assert_eq!(cache6_restore::import(&bytes), Ok(())); + let mut expected_session = cache6_session::Session::installed(before_maps, before_sheet); + let seeds = vec![ + vec!["journal-b".into(), "journal-b".into()], + vec!["journal-a".into(), "journal-b".into()], + ]; + for batch in &seeds { + seed_file_map(batch.clone()); + } + expected_session.seeds = seeds; + let expected_restore = cache6_session::Restore { + session: Some(expected_session), + ..Default::default() + }; + with_style_sheet_mut(|sheet| { + sheet.properties.clear(); + sheet.add_css("literal", "body{}"); + }); + let before = authority(); + let rejection = with_style_sheet(snapshot6::check_install_target); + assert!(matches!(rejection, Err(EvidenceError::Kernel(_)))); + // When + let result = match ingress { + 0 => cache6_restore::import(&bytes), + 1 => cache6_restore::import(b"bad"), + 2 => cache6_restore::classes(None), + 3 => cache6_restore::files(None), + 4 => cache6_restore::classes(Some(BTreeMap::new())), + 5 => cache6_restore::files(Some(BTreeMap::new())), + _ => panic!("ingress"), + }; + // Then + assert_eq!(result, Ok(())); + assert_eq!(authority(), before); + assert_eq!(with_style_sheet(snapshot6::check_install_target), rejection); + cache6_restore::RESTORE.with_borrow(|state| assert_eq!(state, &expected_restore)); +} + +#[test] +#[serial] +fn fresh_build_settings_survive_adoption_and_late_rejection_when_cached_routing_differs() { + // Given + let _guard = Guard::new(); + configure_typography(); + output("a", 3); + let bytes = encoded(); + fresh(); + let theme = sheet::theme::Theme { + breakpoints: vec![19, 31], + ..Default::default() + }; + register_theme_internal(theme); + register_shorthands_internal(BTreeMap::from([("freshInset".into(), vec!["left".into()])])); + import_canonical_map_internal(HashMap::from([("a".into(), "fresh-root".into())])); + import_file_routes_internal(HashMap::from([( + "a".into(), + std::collections::HashSet::from([9]), + )])); + let theme_css = with_style_sheet(|sheet| sheet.theme.to_css()); + // When + assert_eq!(cache6_restore::import(&bytes), Ok(())); + assert_eq!(cache6_restore::files(None), Ok(())); + // Then + assert_eq!(with_style_sheet(|sheet| sheet.theme.to_css()), theme_css); + assert_eq!(css::file_map::canonical("a"), "fresh-root"); + assert_eq!( + css::get_custom_shorthand_names(), + vec!["freshInset".to_string()] + ); + assert_eq!(get_prefix(), None); + assert!(!is_debug()); + assert_eq!(css::atom_hoist::atom_hoist_threshold(), None); + let resolver = |_: &str, _: &str| { + Some(ResolvedModule { + path: "fresh-tokens.ts".into(), + code: "export const COLOR='blue';".into(), + }) + }; + let result = code_extract_with_modules_internal("fresh.ts", concat!("import {css} from '@devup-ui/react';import {COLOR} from './tokens';export const value=css(", "{", "color:COLOR", "}", ");"), + "@devup-ui/react", "df".into(), false, false, false, HashMap::new(), &resolver) + .unwrap_or_else(|error| panic!("{error}")); + assert!( + result + .css() + .unwrap_or_else(|| panic!("css")) + .contains("color:blue") + ); + register_shorthands_internal(BTreeMap::new()); +} diff --git a/bindings/devup-ui-wasm/src/cache6_damage_tests.rs b/bindings/devup-ui-wasm/src/cache6_damage_tests.rs new file mode 100644 index 000000000..4c62a123b --- /dev/null +++ b/bindings/devup-ui-wasm/src/cache6_damage_tests.rs @@ -0,0 +1,152 @@ +use super::cache6_test_support::*; +use serde_json::{Value, json}; + +fn damage(value: &mut Value, family: u8) { + if family >= 4 { + let name = value["keyframes"]["a"] + .as_object() + .unwrap_or_else(|| panic!("frames")) + .keys() + .next() + .unwrap_or_else(|| panic!("name")) + .clone(); + value["keyframes"]["a"][&name]["from"][0][1] = json!("damaged"); + let expansion = &mut value["evidence"]["counters"]["D9-0"]["0"][0]["proof"]["emission"]["expansion"] + ["Keyframes"]; + expansion["steps"][0][1][0][1] = json!("damaged"); + expansion["record"]["Keyframes"]["steps"][0][1][0][1] = json!("damaged"); + } else { + let records = value["properties"]["a"]["255"]["0"] + .as_array_mut() + .unwrap_or_else(|| panic!("records")); + let record = records + .iter_mut() + .find(|record| record["r"] == false) + .unwrap_or_else(|| panic!("consumer")); + let old = record.clone(); + record["v"] = json!("damaged"); + let expansion = + &mut value["evidence"]["counters"]["D9-0"]["0"][0]["proof"]["emission"]["expansion"]; + match family { + 0 => expansion["Static"][0]["Property"]["record"]["v"] = json!("damaged"), + 1 | 2 => expansion["Dynamic"]["consumer"]["Property"]["record"]["v"] = json!("damaged"), + 3 => { + let member = expansion["Typography"]["members"] + .as_array_mut() + .unwrap_or_else(|| panic!("members")) + .iter_mut() + .find(|member| member["Property"]["record"] == old) + .unwrap_or_else(|| panic!("member")); + member["Property"]["record"]["v"] = json!("damaged"); + } + _ => panic!("family"), + } + } +} + +#[rstest] +#[case(0, false)] +#[case(0, true)] +#[case(1, false)] +#[case(1, true)] +#[case(2, false)] +#[case(2, true)] +#[case(3, false)] +#[case(3, true)] +#[case(4, false)] +#[case(4, true)] +#[case(5, false)] +#[case(5, true)] +#[serial] +fn coordinated_record_damage_is_cold_when_independent_seed_is_unchanged( + #[case] family: u8, + #[case] supplied: bool, +) { + // Given + let _guard = Guard::new(); + configure_typography(); + let cold = output("a", family); + let bytes = encoded(); + let expected_maps = maps(); + let mut value: Value = + serde_json::from_slice(&bytes).unwrap_or_else(|error| panic!("{error:?}")); + damage(&mut value, family); + let damaged = serde_json::to_vec(&value).unwrap_or_else(|error| panic!("{error:?}")); + fresh(); + configure_typography(); + if supplied { + companions(&expected_maps, false); + } + let before = authority(); + // When + assert_eq!(cache6_restore::import(&damaged), Ok(())); + // Then + assert_eq!(authority(), before); + assert_eq!(cache_names::check(), Ok(())); + assert_eq!(output("a", family), cold); +} + +#[rstest] +#[case(0)] +#[case(1)] +#[case(2)] +#[case(3)] +#[case(4)] +#[case(5)] +#[case(6)] +#[serial] +fn incompatible_raw_inputs_are_cold_when_version_or_shape_is_wrong(#[case] kind: u8) { + // Given + let _guard = Guard::new(); + let cold = output("a", 0); + let mut value: Value = + serde_json::from_slice(&encoded()).unwrap_or_else(|error| panic!("{error:?}")); + let bytes = match kind { + 0 => { + fresh(); + exact_test_support::compile( + "current.tsx", + "import {css} from '@devup-ui/react';export const x=css({color:'blue'});", + ) + .unwrap_or_else(|error| panic!("{error}")); + export_sheet_internal() + .unwrap_or_else(|error| panic!("{error}")) + .into_bytes() + } + 1 => { + value["atomNamingVersion"] = json!(5); + serde_json::to_vec(&value).unwrap_or_else(|error| panic!("{error}")) + } + 2 => { + value["atomNamingVersion"] = json!(7); + serde_json::to_vec(&value).unwrap_or_else(|error| panic!("{error}")) + } + 3 => { + value + .as_object_mut() + .unwrap_or_else(|| panic!("object")) + .remove("atomNamingVersion"); + serde_json::to_vec(&value).unwrap_or_else(|error| panic!("{error}")) + } + 4 => b"{truncated".to_vec(), + 5 => b"null".to_vec(), + 6 => { + let raw = String::from_utf8(encoded()).unwrap_or_else(|error| panic!("{error}")); + raw.replacen( + "\"atomNamingVersion\":6", + "\"atomNamingVersion\":6,\"atomNamingVersion\":6", + 1, + ) + .into_bytes() + } + _ => panic!("kind"), + }; + fresh(); + let before = authority(); + // When + assert_eq!(cache6_restore::import(&bytes), Ok(())); + // Then + assert_eq!(authority(), before); + assert_eq!(cache_names::check(), Ok(())); + assert_eq!(output("a", 0), cold); +} diff --git a/bindings/devup-ui-wasm/src/cache6_nested_tests.rs b/bindings/devup-ui-wasm/src/cache6_nested_tests.rs new file mode 100644 index 000000000..383faf76e --- /dev/null +++ b/bindings/devup-ui-wasm/src/cache6_nested_tests.rs @@ -0,0 +1,77 @@ +use super::cache6_test_support::*; +use std::panic::{AssertUnwindSafe, catch_unwind}; + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn inner_failure_restores_depth_one_state_when_outer_continues_or_aborts(#[case] commit: bool) { + // Given + let _guard = Guard::new(); + output("a", 0); + let before = authority(); + let mut local = None; + // When + let result = exact_test_support::exact(|| { + output("outer", 1); + let depth_one = authority(); + let inner: Result<(), String> = exact_test_support::exact(|| { + output("inner", 2); + Err("depth two failure".into()) + }); + assert_eq!(inner, Err("depth two failure".into())); + assert_eq!(authority(), depth_one); + local = Some(depth_one); + if commit { + Ok(()) + } else { + Err("outer abort".into()) + } + }); + // Then + assert_eq!( + result, + if commit { + Ok(()) + } else { + Err("outer abort".into()) + } + ); + assert_eq!( + authority(), + if commit { + local.unwrap_or_else(|| panic!("local")) + } else { + before + } + ); +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn committed_inner_success_is_subordinate_when_outer_errors_or_unwinds(#[case] unwind: bool) { + // Given + let _guard = Guard::new(); + output("a", 0); + let before = authority(); + // When + let result = catch_unwind(AssertUnwindSafe(|| { + exact_test_support::exact::<()>(|| { + exact_test_support::exact(|| { + output("inner", 1); + Ok(()) + })?; + assert_ne!(authority(), before); + assert!(!unwind, "outer unwind"); + Err("outer abort".into()) + }) + })); + // Then + match result { + Ok(value) => assert_eq!(value, Err("outer abort".into())), + Err(payload) => assert_eq!(payload.downcast_ref::<&str>(), Some(&"outer unwind")), + } + assert_eq!(authority(), before); +} diff --git a/bindings/devup-ui-wasm/src/cache6_protocol_tests.rs b/bindings/devup-ui-wasm/src/cache6_protocol_tests.rs new file mode 100644 index 000000000..aa851de25 --- /dev/null +++ b/bindings/devup-ui-wasm/src/cache6_protocol_tests.rs @@ -0,0 +1,189 @@ +use super::cache6_test_support::*; + +#[rstest] +#[case(false, false)] +#[case(false, true)] +#[case(true, false)] +#[case(true, true)] +#[serial] +fn complete_restore_works_when_companions_arrive_in_either_order( + #[case] before: bool, + #[case] reversed: bool, +) { + // Given + let _guard = Guard::new(); + output("a", 1); + let bytes = encoded(); + let expected = maps(); + fresh(); + if before { + companions(&expected, reversed); + } + // When + assert_eq!(cache6_restore::import(&bytes), Ok(())); + if !before { + companions(&expected, reversed); + } + // Then + assert_eq!(maps(), expected); + assert_eq!(encoded(), bytes); + cache6_restore::RESTORE.with_borrow(|state| assert!(state.session.is_some())); +} + +#[rstest] +#[case(0)] +#[case(1)] +#[case(2)] +#[serial] +fn restore_accepts_optional_companions_when_only_one_or_neither_is_supplied(#[case] supplied: u8) { + // Given + let _guard = Guard::new(); + output("a", 0); + let bytes = encoded(); + let expected = maps(); + fresh(); + match supplied { + 0 => {} + 1 => { + assert_eq!(cache6_restore::classes(Some(expected.0.clone())), Ok(())); + } + 2 => { + assert_eq!(cache6_restore::files(Some(expected.1.clone())), Ok(())); + } + _ => panic!("supplied"), + } + // When + assert_eq!(cache6_restore::import(&bytes), Ok(())); + // Then + assert_eq!(maps(), expected); +} + +#[rstest] +#[case(false, false, false)] +#[case(false, true, false)] +#[case(true, false, false)] +#[case(true, true, false)] +#[case(false, false, true)] +#[case(false, true, true)] +#[case(true, false, true)] +#[case(true, true, true)] +#[serial] +fn invalid_companion_leaves_exact_cold_state_when_supplied_before_or_after( + #[case] before: bool, + #[case] classes: bool, + #[case] unequal: bool, +) { + // Given + let _guard = Guard::new(); + output("cached", 0); + let bytes = encoded(); + fresh(); + with_style_sheet_mut(|sheet| { + sheet.add_css("sentinel", concat!("body", "{", "color:blue", "}")) + }); + seed_file_map(vec!["sentinel".into()]); + let expected = authority(); + let invalid = || { + if classes { + cache6_restore::classes(unequal.then(BTreeMap::new)) + } else { + cache6_restore::files(unequal.then(BTreeMap::new)) + } + }; + if before { + assert_eq!(invalid(), Ok(())); + } + // When + assert_eq!(cache6_restore::import(&bytes), Ok(())); + if !before { + assert_eq!(invalid(), Ok(())); + } + // Then + assert_eq!(authority(), expected); + assert_eq!(cache_names::check(), Ok(())); + cache6_restore::RESTORE.with_borrow(|state| assert!(state.session.is_none())); +} + +#[test] +#[serial] +fn companions_never_install_when_no_sheet_was_supplied() { + // Given + let _guard = Guard::new(); + let before = authority(); + // When + companions( + &( + BTreeMap::from([("poison".into(), BTreeMap::from([("wrong".into(), 77)]))]), + BTreeMap::from([("poison".into(), 88)]), + ), + true, + ); + // Then + assert_eq!(authority(), before); + cache6_restore::RESTORE.with_borrow(|state| assert!(state.session.is_none())); +} + +#[test] +#[serial] +fn late_matching_companions_preserve_fresh_work_when_live_maps_have_extended() { + // Given + let _guard = Guard::new(); + output("cached", 0); + let bytes = encoded(); + let expected = maps(); + fresh(); + assert_eq!(cache6_restore::import(&bytes), Ok(())); + output("fresh", 1); + let extended = authority(); + assert_ne!(maps(), expected); + // When + companions(&expected, true); + // Then + assert_eq!(authority(), extended); + cache6_restore::RESTORE.with_borrow(|state| assert!(state.session.is_some())); +} + +#[test] +#[serial] +fn reset_forgets_session_when_another_build_begins() { + // Given + let _guard = Guard::new(); + output("a", 0); + let bytes = encoded(); + fresh(); + assert_eq!(cache6_restore::import(&bytes), Ok(())); + // When + reset_build_state_internal(); + // Then + cache6_restore::RESTORE + .with_borrow(|state| assert_eq!(state, &cache6_session::Restore::default())); + assert_eq!( + with_style_sheet(LiveCheckpoint::capture), + LiveCheckpoint::capture(&StyleSheet::default()) + ); +} + +#[test] +#[serial] +fn clear_forgets_only_protocol_when_an_inactive_genuine_session_has_fresh_work() { + // Given: prior state differs from installed and subsequently extended live state. + let _guard = Guard::new(); + output("cached", 1); + let bytes = encoded(); + fresh(); + output("prior", 0); + let prior = authority(); + assert_eq!(cache6_restore::import(&bytes), Ok(())); + output("fresh", 2); + seed_file_map(vec!["journal".into(), "journal".into()]); + let live = authority(); + assert_ne!(live, prior); + cache6_restore::RESTORE.with_borrow(|state| assert!(state.session.is_some())); + // When + let result = cache6_restore::clear(); + // Then: forgetting must not retire/replay or restore the before-checkpoint/maps. + assert_eq!(result, Ok(())); + assert_eq!(authority(), live); + cache6_restore::RESTORE + .with_borrow(|state| assert_eq!(state, &cache6_session::Restore::default())); +} diff --git a/bindings/devup-ui-wasm/src/cache6_restore.rs b/bindings/devup-ui-wasm/src/cache6_restore.rs new file mode 100644 index 000000000..0058dab3e --- /dev/null +++ b/bindings/devup-ui-wasm/src/cache6_restore.rs @@ -0,0 +1,142 @@ +//! Dormant Rust-only raw6 protocol; public JavaScript still dispatches cache4. +use crate::{ + cache6_session::{Companion, Restore, Session}, + with_style_sheet, with_style_sheet_mut, +}; +use sheet::{ + cache_snapshot::{ClassMap, FileMap}, + snapshot6::{self, EvidenceError}, +}; +use std::cell::RefCell; + +thread_local! { + pub(super) static RESTORE: RefCell = RefCell::new(Restore::default()); +} + +fn admitted(build: impl FnOnce() -> Result) -> Result { + css::admission::with_admission(|| { + css::admission::check_administration_allowed().map_err(EvidenceError::ActiveExact)?; + build() + }) +} + +/// Adopt only strict admitted raw6; malformed candidates remain silent cold. +/// # Errors +/// Returns typed active-exact rejection before any session or live mutation. +pub fn import(bytes: &[u8]) -> Result<(), EvidenceError> { + admitted(|| { + // Admission stays held with exact inactive through validation, retirement and install; + // none of these operations invokes user code or enters an exact scope. + if with_style_sheet(snapshot6::check_install_target).is_err() { + return Ok(()); + } + RESTORE.with_borrow_mut(|state| { + let candidate = snapshot6::parse(bytes) + .and_then(snapshot6::validate_snapshot) + .and_then(|candidate| { + candidate + .compare_companions(state.classes.supplied()?, state.files.supplied()?)?; + Ok(candidate) + }); + match candidate { + Err(_) => { + state.retire(); + Ok(()) + } + Ok(candidate) => { + state.retire(); + let (maps, sheet) = Restore::before(); + match with_style_sheet_mut(|target| candidate.install_into(target)) { + Ok(()) => { + state.session = Some(Session::installed(maps, sheet)); + state.classes = Companion::Unseen; + state.files = Companion::Unseen; + Ok(()) + } + Err(_) => Ok(()), + } + } + } + }) + }) +} + +/// Corroborate the original snapshot's class map, not later live additions. +/// # Errors +/// Returns typed active-exact rejection before companion or session mutation. +pub fn classes(incoming: Option) -> Result<(), EvidenceError> { + admitted(|| { + if with_style_sheet(snapshot6::check_install_target).is_err() { + return Ok(()); + } + RESTORE.with_borrow_mut(|state| { + match &state.session { + Some(session) if incoming.as_ref() == Some(&session.classes) => { + state.classes = Companion::Unseen; + } + Some(_) => { + state.retire(); + state.classes = Companion::from(incoming); + } + None => state.classes = Companion::from(incoming), + } + Ok(()) + }) + }) +} + +/// Corroborate the original snapshot's file map, not later live additions. +/// # Errors +/// Returns typed active-exact rejection before companion or session mutation. +pub fn files(incoming: Option) -> Result<(), EvidenceError> { + admitted(|| { + if with_style_sheet(snapshot6::check_install_target).is_err() { + return Ok(()); + } + RESTORE.with_borrow_mut(|state| { + match &state.session { + Some(session) if incoming.as_ref() == Some(&session.files) => { + state.files = Companion::Unseen; + } + Some(_) => { + state.retire(); + state.files = Companion::from(incoming); + } + None => state.files = Companion::from(incoming), + } + Ok(()) + }) + }) +} + +/// Record one seed call without flattening batches or selecting configuration. +/// # Errors +/// Returns typed active-exact rejection before journal mutation. +pub fn seeded(files: &[String]) -> Result<(), EvidenceError> { + admitted(|| { + record_seed(files); + Ok(()) + }) +} + +/// Forget the protocol at an existing build reset, without replaying old data. +/// # Errors +/// Returns typed active-exact rejection before session mutation. +pub fn clear() -> Result<(), EvidenceError> { + admitted(|| { + forget(); + Ok(()) + }) +} + +pub(crate) fn record_seed(files: &[String]) { + RESTORE.with_borrow_mut(|state| { + if let Some(session) = &mut state.session { + session.seeds.push(files.to_vec()); + } + }); +} + +pub(crate) fn forget() { + RESTORE.with_borrow_mut(|state| *state = Restore::default()); +} diff --git a/bindings/devup-ui-wasm/src/cache6_rollback_tests.rs b/bindings/devup-ui-wasm/src/cache6_rollback_tests.rs new file mode 100644 index 000000000..917bb7a09 --- /dev/null +++ b/bindings/devup-ui-wasm/src/cache6_rollback_tests.rs @@ -0,0 +1,213 @@ +use super::cache6_test_support::*; +use std::panic::{AssertUnwindSafe, catch_unwind}; + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn outer_rollback_restores_private_state_when_inner_kernel_committed_then_output_failed( + #[case] unwind: bool, +) { + // Given + let _guard = Guard::new(); + output("a", 0); + let before = authority(); + // When + let result = catch_unwind(AssertUnwindSafe(|| { + exact_test_support::exact::<()>(|| { + output("b", 1); + assert_ne!(authority(), before); + assert!(!unwind, "after committed kernel"); + Err("later Output error".into()) + }) + })); + // Then + match result { + Ok(value) => assert_eq!(value, Err("later Output error".into())), + Err(payload) => assert_eq!( + payload.downcast_ref::<&str>(), + Some(&"after committed kernel") + ), + } + assert_eq!(authority(), before); + let retry = output("b", 1); + fresh(); + output("a", 0); + assert_eq!(output("b", 1), retry); +} + +#[test] +#[serial] +fn rollback_restores_latch_when_committed_inner_state_is_then_damaged() { + // Given + let _guard = Guard::new(); + output("a", 0); + let before = authority(); + // When + let result: Result<(), String> = exact_test_support::exact(|| { + output("b", 1); + with_style_sheet_mut(|sheet| { + sheet.properties.clear(); + sheet.add_css("literal", "body{}"); + }); + assert!(matches!( + with_style_sheet(snapshot6::check_install_target), + Err(EvidenceError::ActiveExact(_)) + )); + Err("abort latched inner".into()) + }); + // Then + assert_eq!(result, Err("abort latched inner".into())); + assert_eq!(authority(), before); + assert_eq!(with_style_sheet(snapshot6::check_install_target), Ok(())); + let retry = output("b", 1); + fresh(); + output("a", 0); + assert_eq!(output("b", 1), retry); +} + +#[test] +#[serial] +fn existing_rejected_state_is_captured_when_enclosing_attempt_aborts() { + // Given + let _guard = Guard::new(); + output("a", 0); + with_style_sheet_mut(|sheet| { + sheet.properties.clear(); + sheet.add_css("literal", "body{}"); + }); + let before = authority(); + let rejection = with_style_sheet(snapshot6::check_install_target); + assert!(matches!(rejection, Err(EvidenceError::Kernel(_)))); + // When + let result: Result<(), String> = exact_test_support::exact(|| { + with_style_sheet_mut(|sheet| *sheet = StyleSheet::default()); + output("new", 1); + Err("rejected outer".into()) + }); + // Then + assert_eq!(result, Err("rejected outer".into())); + assert_eq!(authority(), before); + assert_eq!(with_style_sheet(snapshot6::check_install_target), rejection); +} + +#[test] +#[serial] +fn finish_failure_restores_guarded_cleanup_when_real_base_owner_was_registered() { + // Given + let _guard = Guard::new(); + let items = with_original( + FixtureRequest { + filename: "a", + source: "abcdef", + }, + || { + FxHashSet::from_iter([ExtractStyleValue::Dynamic(ExtractDynamicStyle::new( + "color", + 0, + "tone", + Some(css::style_selector::StyleSelector::Global( + "body".into(), + "a".into(), + )), + ))]) + }, + ) + .unwrap_or_else(|error| panic!("{error:?}")); + update( + &items, + OutputRoute { + raw_source: "a", + single_css: false, + import_main_css: false, + }, + ) + .unwrap_or_else(|error| panic!("{error:?}")); + let before = authority(); + // When + let result = with_admission(|| { + let rollback = extraction_rollback::ExtractionRollback::capture(); + let result: Result<(), UpdateError> = + css::exact_attempt::with_exclusive_attempt(|| { + with_style_sheet_mut(|sheet| { + CounterSheet::new(sheet).with_attempt(|attempt| { + attempt + .prepare( + &Default::default(), + UpdateRequest { + raw_source: "a", + single_css: false, + }, + )? + .finish(|sheet, effects| { + assert!(effects.default_collected); + let _output = Output::from_prospective( + metadata(), + ProspectiveSheet { sheet, effects }, + OutputRoute { + raw_source: "a", + single_css: false, + import_main_css: false, + }, + ); + Err("after real cleanup".into()) + }) + }) + }) + }); + drop(rollback); + result + }); + // Then + assert_eq!( + result, + Err(UpdateError::Output("after real cleanup".into())) + ); + assert_eq!(authority(), before); +} + +#[test] +#[serial] +fn final_validation_restores_output_when_map_authority_changes_after_finish() { + // Given + let _guard = Guard::new(); + output("a", 0); + let items = ir("b", 1); + let before = authority(); + // When + let result: Result> = with_admission(|| { + let rollback = extraction_rollback::ExtractionRollback::capture(); + let result = css::exact_attempt::with_exclusive_attempt(|| { + with_style_sheet_mut(|sheet| { + CounterSheet::new(sheet).with_attempt(|attempt| { + let completed = attempt + .prepare( + &items, + UpdateRequest { + raw_source: "b", + single_css: false, + }, + )? + .finish(|sheet, effects| { + Ok(Output::from_prospective( + metadata(), + ProspectiveSheet { sheet, effects }, + OutputRoute { + raw_source: "b", + single_css: false, + import_main_css: false, + }, + )) + })?; + css::class_map::with_class_map_mut(HashMap::clear); + Ok(completed) + }) + }) + }); + drop(rollback); + result + }); + // Then + assert!(matches!(result, Err(UpdateError::Kernel(_)))); + assert_eq!(authority(), before); +} diff --git a/bindings/devup-ui-wasm/src/cache6_seed_tests.rs b/bindings/devup-ui-wasm/src/cache6_seed_tests.rs new file mode 100644 index 000000000..0d079f968 --- /dev/null +++ b/bindings/devup-ui-wasm/src/cache6_seed_tests.rs @@ -0,0 +1,161 @@ +use super::cache6_test_support::*; + +fn batches() { + seed_file_map(vec!["b".into(), "b".into()]); + seed_file_map(vec!["a".into(), "b".into()]); + seed_file_map(vec!["c".into(), "a".into(), "c".into()]); +} + +#[test] +#[serial] +fn seeded_journals_only_when_an_inactive_genuine_session_is_installed() { + // Given + let _guard = Guard::new(); + output("cached", 1); + let bytes = encoded(); + fresh(); + output("prior", 0); + let (before_maps, before_sheet) = cache6_session::Restore::before(); + assert_eq!(cache6_restore::import(&bytes), Ok(())); + let mut expected_session = cache6_session::Session::installed(before_maps, before_sheet); + let before = authority(); + let seeds = vec![ + vec!["b".into(), "b".into()], + vec!["a".into(), "b".into()], + vec!["c".into(), "a".into(), "c".into()], + ]; + // When + for batch in &seeds { + assert_eq!(cache6_restore::seeded(batch), Ok(())); + } + // Then: no allocator, sheet, private Counter state or map mutation. + assert_eq!(authority(), before); + expected_session.seeds = seeds; + let expected = cache6_session::Restore { + session: Some(expected_session), + ..Default::default() + }; + cache6_restore::RESTORE.with_borrow(|state| assert_eq!(state, &expected)); +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn seeded_batches_replay_when_a_late_invalid_companion_retires_the_session(#[case] classes: bool) { + // Given: independent cold replay applies seeds through the public allocator entry. + let _guard = Guard::new(); + output("cached", 1); + let bytes = encoded(); + fresh(); + output("prior", 0); + batches(); + let expected = authority(); + fresh(); + output("prior", 0); + assert_eq!(cache6_restore::import(&bytes), Ok(())); + for batch in [ + vec!["b".into(), "b".into()], + vec!["a".into(), "b".into()], + vec!["c".into(), "a".into(), "c".into()], + ] { + assert_eq!(cache6_restore::seeded(&batch), Ok(())); + } + output("fresh", 2); + // When + let result = if classes { + cache6_restore::classes(None) + } else { + cache6_restore::files(None) + }; + // Then + assert_eq!(result, Ok(())); + assert_eq!(authority(), expected); + let expected_restore = cache6_session::Restore { + classes: if classes { + cache6_session::Companion::Invalid + } else { + cache6_session::Companion::Unseen + }, + files: if classes { + cache6_session::Companion::Unseen + } else { + cache6_session::Companion::Invalid + }, + session: None, + }; + cache6_restore::RESTORE.with_borrow(|state| assert_eq!(state, &expected_restore)); +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn late_rollback_replays_seed_calls_when_batches_repeat_and_current_canonical_changes( + #[case] classes: bool, +) { + // Given + let _guard = Guard::new(); + import_canonical_map_internal(HashMap::from([("a".into(), "root".into())])); + batches(); + let cold_maps = cache6_session::Maps::capture(); + let cold = output("a", 1); + fresh(); + update( + &Default::default(), + OutputRoute { + raw_source: "empty", + single_css: true, + import_main_css: false, + }, + ) + .unwrap_or_else(|error| panic!("{error:?}")); + let bytes = encoded(); + fresh(); + assert_eq!(cache6_restore::import(&bytes), Ok(())); + batches(); + import_canonical_map_internal(HashMap::from([("a".into(), "root".into())])); + output("fresh", 0); + // When + let result = if classes { + cache6_restore::classes(None) + } else { + cache6_restore::files(None) + }; + // Then + assert_eq!(result, Ok(())); + assert_eq!(cache6_session::Maps::capture(), cold_maps); + assert_eq!(output("a", 1), cold); + assert_eq!( + css::file_map::get_original_ids(), + BTreeMap::from([("b".into(), 0), ("a".into(), 1), ("c".into(), 2)]) + ); +} + +#[test] +#[serial] +fn repeated_restore_keeps_fresh_seeds_when_old_session_is_retired() { + // Given + let _guard = Guard::new(); + update( + &Default::default(), + OutputRoute { + raw_source: "empty", + single_css: true, + import_main_css: false, + }, + ) + .unwrap_or_else(|error| panic!("{error:?}")); + let bytes = encoded(); + fresh(); + assert_eq!(cache6_restore::import(&bytes), Ok(())); + batches(); + // When + assert_eq!(cache6_restore::import(&bytes), Ok(())); + assert_eq!(cache6_restore::files(None), Ok(())); + // Then + assert_eq!( + css::file_map::get_original_ids(), + BTreeMap::from([("b".into(), 0), ("a".into(), 1), ("c".into(), 2)]) + ); +} diff --git a/bindings/devup-ui-wasm/src/cache6_session.rs b/bindings/devup-ui-wasm/src/cache6_session.rs new file mode 100644 index 000000000..3bc3ac895 --- /dev/null +++ b/bindings/devup-ui-wasm/src/cache6_session.rs @@ -0,0 +1,117 @@ +use crate::{with_style_sheet, with_style_sheet_mut}; +use sheet::{ + cache_snapshot::{ClassMap, FileMap}, + live_checkpoint::LiveCheckpoint, +}; +use std::collections::{BTreeMap, BTreeSet}; + +#[derive(Debug, Default, PartialEq, Eq)] +pub(super) enum Companion { + #[default] + Unseen, + Invalid, + Supplied(T), +} +impl Companion { + pub(super) const fn supplied(&self) -> Result, sheet::snapshot6::EvidenceError> { + match self { + Self::Unseen => Ok(None), + Self::Invalid => Err(sheet::snapshot6::EvidenceError::Companion), + Self::Supplied(value) => Ok(Some(value)), + } + } +} +impl From> for Companion { + fn from(value: Option) -> Self { + match value { + Some(value) => Self::Supplied(value), + None => Self::Invalid, + } + } +} + +#[derive(Debug, PartialEq, Eq)] +pub(super) struct Maps { + pub(super) classes: ClassMap, + pub(super) files: FileMap, + originals: BTreeMap, + plan: Option>, +} +impl Maps { + pub(super) fn capture() -> Self { + Self { + classes: css::class_map::with_class_map(|map| { + map.iter() + .map(|(file, slots)| { + ( + file.clone(), + slots + .iter() + .map(|(key, slot)| (key.clone(), *slot)) + .collect(), + ) + }) + .collect() + }), + files: css::file_map::get_file_map().into_iter().collect(), + originals: css::file_map::get_original_ids(), + plan: css::atom_hoist::atom_plan(), + } + } + fn restore(self) { + css::class_map::set_class_map( + self.classes + .into_iter() + .map(|(file, slots)| (file, slots.into_iter().collect())) + .collect(), + ); + css::file_map::set_file_map(self.files.into_iter().collect()); + css::file_map::set_original_ids(self.originals); + css::atom_hoist::restore_atom_plan(self.plan); + } +} + +#[derive(Debug, PartialEq, Eq)] +pub(super) struct Session { + pub(super) classes: ClassMap, + pub(super) files: FileMap, + before_maps: Maps, + before_sheet: LiveCheckpoint, + pub(super) seeds: Vec>, +} +impl Session { + pub(super) fn installed(before_maps: Maps, before_sheet: LiveCheckpoint) -> Self { + let installed = Maps::capture(); + Self { + classes: installed.classes, + files: installed.files, + before_maps, + before_sheet, + seeds: Vec::new(), + } + } + fn retire(self) { + self.before_maps.restore(); + with_style_sheet_mut(|sheet| self.before_sheet.restore(sheet)); + for batch in self.seeds { + css::file_map::seed_file_numbers(&batch); + } + } +} + +#[derive(Debug, Default, PartialEq, Eq)] +pub(super) struct Restore { + pub(super) classes: Companion, + pub(super) files: Companion, + pub(super) session: Option, +} +impl Restore { + pub(super) fn retire(&mut self) { + if let Some(session) = self.session.take() { + session.retire(); + } + } + pub(super) fn before() -> (Maps, LiveCheckpoint) { + (Maps::capture(), with_style_sheet(LiveCheckpoint::capture)) + } +} diff --git a/bindings/devup-ui-wasm/src/cache6_test_support.rs b/bindings/devup-ui-wasm/src/cache6_test_support.rs new file mode 100644 index 000000000..43fb40528 --- /dev/null +++ b/bindings/devup-ui-wasm/src/cache6_test_support.rs @@ -0,0 +1,212 @@ +pub(super) use super::*; +pub(super) use extractor::{ + counter_test_support::{FixtureRequest, with_original}, + extract_style::{ + ExtractDynamicStyle, ExtractKeyframes, extract_static_style::ExtractStaticStyle, + }, +}; +pub(super) use prospective_output::{OutputMetadata, OutputRoute, ProspectiveSheet}; +pub(super) use rstest::rstest; +pub(super) use serial_test::serial; +pub(super) use sheet::{ + cache_snapshot::{ClassMap, FileMap}, + counter_kernel::{CounterSheet, UpdateError, UpdateRequest}, + live_checkpoint::LiveCheckpoint, + snapshot6::{self, EvidenceError}, +}; +pub(super) use std::collections::BTreeSet; + +pub(super) struct Guard; +impl Guard { + pub(super) fn new() -> Self { + fresh(); + Self + } +} +impl Drop for Guard { + fn drop(&mut self) { + fresh(); + GLOBAL_STYLE_SHEET.clear_poison(); + } +} +pub(super) fn fresh() { + reset_build_state_internal(); + set_debug(false); + register_theme_internal(sheet::theme::Theme::default()); +} +pub(super) fn ir(file: &str, family: u8) -> FxHashSet { + with_original( + FixtureRequest { + filename: file, + source: "a\nbcd", + }, + || { + let item = match family { + 0 => ExtractStyleValue::Static(ExtractStaticStyle::new("color", "red", 0, None)), + 1 => ExtractStyleValue::Dynamic( + ExtractDynamicStyle::new("color", 0, "tone", None).at_role(2, 1), + ), + 2 => ExtractStyleValue::Dynamic(ExtractDynamicStyle::new( + "color", + 0, + "tone !important", + None, + )), + 3 => ExtractStyleValue::Static(ExtractStaticStyle::new( + "typography", + "heading", + 0, + None, + )), + 4 | 5 => { + let mut frames = ExtractKeyframes::default(); + let member = ExtractStaticStyle::new("opacity", "0", 0, None); + let mut members = vec![member.clone(), member]; + if family == 5 { + members.push(ExtractStaticStyle::new("typography", "heading", 0, None)); + } + frames.keyframes.insert("from".into(), members); + frames.keyframes.insert("to".into(), vec![]); + ExtractStyleValue::Keyframes(frames) + } + _ => panic!("family"), + }; + FxHashSet::from_iter([item]) + }, + ) + .unwrap_or_else(|error| panic!("{error:?}")) +} +pub(super) fn metadata() -> OutputMetadata { + OutputMetadata { + code: "fixture metadata, not Counter JS".into(), + map: Some("fixture-map".into()), + css_file: Some("fixture.css".into()), + dependencies: vec!["tokens.ts".into()], + } +} +pub(super) fn update( + items: &FxHashSet, + route: OutputRoute<'_>, +) -> Result> { + with_admission(|| { + let rollback = extraction_rollback::ExtractionRollback::capture(); + let result = css::exact_attempt::with_exclusive_attempt(|| { + with_style_sheet_mut(|sheet| { + CounterSheet::new(sheet).with_attempt(|attempt| { + attempt + .prepare( + items, + UpdateRequest { + raw_source: route.raw_source, + single_css: route.single_css, + }, + )? + .finish(|sheet, effects| { + Ok(Output::from_prospective( + metadata(), + ProspectiveSheet { sheet, effects }, + route, + )) + }) + }) + }) + }); + if result.is_ok() { + rollback.commit(); + } + result + }) +} +pub(super) fn output(file: &str, family: u8) -> ObservedOutput { + let items = ir(file, family); + let output = update( + &items, + OutputRoute { + raw_source: file, + single_css: false, + import_main_css: false, + }, + ) + .unwrap_or_else(|error| panic!("{error:?}")); + ObservedOutput::capture(&output, file) +} +#[derive(Debug, PartialEq, Eq)] +pub(super) struct ObservedOutput { + code: String, + map: Option, + css_file: Option, + dependencies: Vec, + css: Option, + base: bool, + global_css: String, + local_css: String, +} +impl ObservedOutput { + pub(super) fn capture(output: &Output, file: &str) -> Self { + Self { + code: output.code(), + map: output.map(), + css_file: output.css_file(), + dependencies: output.dependencies(), + css: output.css(), + base: output.updated_base_style(), + global_css: with_style_sheet(|sheet| sheet.create_css(None, false)), + local_css: with_style_sheet(|sheet| { + sheet.create_css(Some(&css::file_map::canonical(file)), false) + }), + } + } +} +pub(super) fn encoded() -> Vec { + with_style_sheet_mut(|sheet| { + serde_json::to_vec( + &CounterSheet::new(sheet) + .export_snapshot6() + .unwrap_or_else(|error| panic!("{error:?}")), + ) + .unwrap_or_else(|error| panic!("{error:?}")) + }) +} +pub(super) fn maps() -> (ClassMap, FileMap) { + let maps = cache6_session::Maps::capture(); + (maps.classes, maps.files) +} +#[derive(Debug, PartialEq, Eq)] +pub(super) struct Authority { + sheet: LiveCheckpoint, + maps: cache6_session::Maps, + theme: String, +} +pub(super) fn authority() -> Authority { + Authority { + sheet: with_style_sheet(LiveCheckpoint::capture), + maps: cache6_session::Maps::capture(), + theme: with_style_sheet(|sheet| sheet.theme.to_css()), + } +} +pub(super) fn companions(maps: &(ClassMap, FileMap), reversed: bool) { + if reversed { + cache6_restore::files(Some(maps.1.clone())).unwrap_or_else(|error| panic!("{error:?}")); + cache6_restore::classes(Some(maps.0.clone())).unwrap_or_else(|error| panic!("{error:?}")); + } else { + cache6_restore::classes(Some(maps.0.clone())).unwrap_or_else(|error| panic!("{error:?}")); + cache6_restore::files(Some(maps.1.clone())).unwrap_or_else(|error| panic!("{error:?}")); + } +} +pub(super) fn configure_typography() { + let mut theme = sheet::theme::Theme::default(); + let frame = sheet::theme::Typography { + font_size: Some("14px".into()), + font_family: None, + font_style: None, + font_weight: None, + line_height: None, + letter_spacing: None, + text_transform: None, + }; + theme.typography.insert( + "heading".into(), + sheet::theme::Typographies(vec![Some(frame), None]), + ); + register_theme_internal(theme); +} diff --git a/bindings/devup-ui-wasm/src/cache6_transition_tests.rs b/bindings/devup-ui-wasm/src/cache6_transition_tests.rs new file mode 100644 index 000000000..b08f66b81 --- /dev/null +++ b/bindings/devup-ui-wasm/src/cache6_transition_tests.rs @@ -0,0 +1,94 @@ +use crate::cache6_test_support::*; + +fn empty_export(plan: BTreeSet) -> Vec { + fresh(); + update( + &Default::default(), + OutputRoute { + raw_source: "empty", + single_css: true, + import_main_css: false, + }, + ) + .unwrap_or_else(|error| panic!("{error:?}")); + css::atom_hoist::restore_atom_plan(Some(plan.clone())); + with_style_sheet_mut(|sheet| sheet.atom_plan = Some(plan)); + encoded() +} + +fn cold_before(plan: Option>) { + fresh(); + css::atom_hoist::restore_atom_plan(plan.clone()); + with_style_sheet_mut(|sheet| { + sheet.atom_plan = plan; + sheet.add_css("prior", concat!("body", "{", "color:blue", "}")); + sheet.add_import("prior", "prior.css"); + }); + css::class_map::with_class_map_mut(|map| { + map.insert("prior".into(), HashMap::from([("reserved".into(), 0)])); + }); + seed_file_map(vec!["prior-seed".into()]); +} + +#[test] +#[serial] +fn replacement_rejects_q_when_validation_must_observe_installed_p_before_retirement() { + // Given: independently kernel-owned empty exports, no candidate-derived settings. + let _guard = Guard::new(); + let p = empty_export(BTreeSet::new()); + let q = empty_export(BTreeSet::from(["q".into()])); + cold_before(None); + let expected_cold = authority(); + assert_eq!(cache6_restore::import(&p), Ok(())); + assert_eq!(css::atom_hoist::atom_plan(), Some(BTreeSet::new())); + assert!(matches!( + snapshot6::parse(&q).and_then(snapshot6::validate_snapshot), + Err(EvidenceError::Configuration) + )); + // When + assert_eq!(cache6_restore::import(&q), Ok(())); + // Then: rejection retires P, never adopts Q into the unconstrained cold target. + assert_eq!(authority(), expected_cold); + cache6_restore::RESTORE.with_borrow(|state| { + assert_eq!(state, &cache6_session::Restore::default()); + }); +} + +#[test] +#[serial] +fn replacement_stays_cold_when_consuming_install_refuses_after_prior_retirement() { + // Given: independent cold replay includes both plans, full typed state and maps. + let _guard = Guard::new(); + let p = empty_export(BTreeSet::new()); + let q = empty_export(BTreeSet::from(["q".into()])); + let seeds = vec![ + vec!["b".into(), "b".into()], + vec!["a".into(), "b".into()], + vec!["c".into(), "a".into(), "c".into()], + ]; + cold_before(Some(BTreeSet::new())); + for batch in &seeds { + seed_file_map(batch.clone()); + } + let expected_cold = authority(); + cold_before(Some(BTreeSet::new())); + assert_eq!(cache6_restore::import(&p), Ok(())); + for batch in &seeds { + seed_file_map(batch.clone()); + } + // Ordinary global plan transition: target remains unlatched, no authority install. + css::atom_hoist::restore_atom_plan(None); + assert_eq!(with_style_sheet(snapshot6::check_install_target), Ok(())); + assert!( + snapshot6::parse(&q) + .and_then(snapshot6::validate_snapshot) + .is_ok() + ); + // When: validation permits Q under None; retiring P restores independent Some(empty). + assert_eq!(cache6_restore::import(&q), Ok(())); + // Then: consuming recheck refuses Q, preserving the complete replayed cold authority. + assert_eq!(authority(), expected_cold); + cache6_restore::RESTORE.with_borrow(|state| { + assert_eq!(state, &cache6_session::Restore::default()); + }); +} diff --git a/bindings/devup-ui-wasm/src/cache_admission_tests.rs b/bindings/devup-ui-wasm/src/cache_admission_tests.rs new file mode 100644 index 000000000..dc7d91dc5 --- /dev/null +++ b/bindings/devup-ui-wasm/src/cache_admission_tests.rs @@ -0,0 +1,48 @@ +use super::*; + +#[path = "cache_allocator_tests.rs"] +mod allocators; +#[path = "cache_naming_coverage_tests.rs"] +mod naming_coverage; +#[path = "cache_reset_tests.rs"] +mod resets; +#[path = "cache_seed_tests.rs"] +mod seeds; +#[path = "cache_typography_coverage_tests.rs"] +mod typography_coverage; + +fn fresh() { + reset_build_state_internal(); + set_debug(false); + register_theme_internal(sheet::theme::Theme::default()); +} + +fn output(file: &str, source: &str, single: bool) -> (String, String, String) { + let compiled = code_extract_internal( + file, + source, + "@devup-ui/react", + "df".into(), + single, + false, + false, + HashMap::new(), + ) + .unwrap_or_else(|error| panic!("{error}")); + with_style_sheet(|sheet| { + ( + compiled.code(), + sheet.create_css(None, false), + sheet.create_css(Some(file), false), + ) + }) +} + +fn companions(value: &serde_json::Value) { + cache_restore::classes(Some( + serde_json::from_value(value["classMap"].clone()).unwrap_or_else(|error| panic!("{error}")), + )); + cache_restore::files(Some( + serde_json::from_value(value["fileMap"].clone()).unwrap_or_else(|error| panic!("{error}")), + )); +} diff --git a/bindings/devup-ui-wasm/src/cache_allocator_proof.rs b/bindings/devup-ui-wasm/src/cache_allocator_proof.rs new file mode 100644 index 000000000..5b58547f8 --- /dev/null +++ b/bindings/devup-ui-wasm/src/cache_allocator_proof.rs @@ -0,0 +1,123 @@ +use css::{ + Naming, + content_name::AtomContent, + sparse_site::{Site, SourceFile}, +}; +use sheet::{StyleSheet, StyleSheetProperty, cache_snapshot::ClassMap}; + +fn namespace_id(namespace: &str) -> Option { + namespace.strip_prefix("D9-")?.parse().ok() +} + +fn counter_label(number: usize) -> String { + let encoded = Site { + file: SourceFile::D9(0), + at: number, + role: 0, + } + .variable_name(""); + let label = encoded.trim_start_matches("---Sa-"); + match label.strip_suffix("ad") { + Some(head) => format!("{head}a-d"), + None => label.to_string(), + } +} + +fn variable_owner(value: &str, prefix: &str, owner_label: &str) -> bool { + let variable = value.strip_prefix("var(").and_then(|value| { + value + .strip_suffix(')') + .or_else(|| value.strip_suffix(") !important")) + }); + let mut number = owner_label.to_string(); + number.retain(|character| character != '-'); + variable.is_some_and(|variable| variable.starts_with(&format!("---{prefix}S{number}-"))) +} + +fn declaration_matches( + property: &StyleSheetProperty, + position: (u8, u8), + slot: (&str, &str), +) -> bool { + let (key, owner_label) = slot; + let prefix = css::get_prefix().unwrap_or_default(); + [false, true].into_iter().any(|dynamic| { + let content = AtomContent { + property: &property.property, + value: Some(&property.value), + naming: Naming::Own, + level: position.0, + order: position.1, + selector: property.selector.as_ref(), + layer: property.layer.as_deref(), + dynamic, + } + .content(); + css::atom_name::hex(&content.lossless) == key + && (!dynamic || variable_owner(&property.value, &prefix, owner_label)) + }) +} + +pub(super) fn validate(sheet: &StyleSheet, classes: &ClassMap) -> bool { + if classes.keys().any(|namespace| { + namespace_id(namespace) + .is_some_and(|id| !sheet.source_ids.values().any(|source| *source == id)) + || (namespace.starts_with("D9-") && namespace_id(namespace).is_none()) + }) { + return false; + } + let prefix = css::get_prefix().unwrap_or_default(); + sheet.properties.values().all(|orders| { + orders.iter().all(|(order, levels)| { + levels.iter().all(|(level, properties)| { + properties.iter().all(|property| { + if property.owner_reset || sheet.names.contains_key(&property.class_name) { + return true; + } + let Some((owner, owner_label)) = sheet + .source_ids + .values() + .filter_map(|owner| { + usize::try_from(*owner) + .ok() + .map(|number| (*owner, counter_label(number))) + }) + .filter(|(_, label)| { + property + .class_name + .starts_with(&format!("{prefix}{label}-")) + }) + .max_by_key(|(_, label)| label.len()) + else { + return true; + }; + let Some(slots) = classes.get(&format!("D9-{owner}")) else { + return false; + }; + slots.iter().any(|(key, slot)| { + property.class_name + == format!("{prefix}{owner_label}-{}", counter_label(*slot)) + && if property.typography { + crate::cache_special_proof::counter_typography( + property, + (levels, *order), + key, + ) + .is_some() + } else { + let owns_reset = levels.values().flatten().any(|other| { + other.owner_reset && other.class_name == property.class_name + }); + (!owns_reset || key.starts_with("642d")) + && declaration_matches( + property, + (*level, *order), + (key, &owner_label), + ) + } + }) + }) + }) + }) + }) +} diff --git a/bindings/devup-ui-wasm/src/cache_allocator_tests.rs b/bindings/devup-ui-wasm/src/cache_allocator_tests.rs new file mode 100644 index 000000000..e17021313 --- /dev/null +++ b/bindings/devup-ui-wasm/src/cache_allocator_tests.rs @@ -0,0 +1,130 @@ +use super::*; +use std::fmt::Write; + +const SOURCE: &str = + "import {Box} from '@devup-ui/react';export const x=(p)=>;"; + +enum MapDamage { + ClassGap, + FileGap, + SourceGap, + SwappedSlots, + MissingClasses, + MissingSources, + WrongKey, + WrongVariableSource, +} + +#[rstest] +#[case(MapDamage::ClassGap)] +#[case(MapDamage::FileGap)] +#[case(MapDamage::SourceGap)] +#[case(MapDamage::SwappedSlots)] +#[case(MapDamage::MissingClasses)] +#[case(MapDamage::MissingSources)] +#[case(MapDamage::WrongKey)] +#[case(MapDamage::WrongVariableSource)] +#[serial] +fn invalid_allocator_snapshot_is_cold_with_matching_or_absent_companions( + #[case] damage: MapDamage, + #[values(false, true)] matching: bool, +) { + // Given: real counter declarations and their complete self-contained allocator snapshot. + fresh(); + seed_file_map(vec!["fresh.tsx".into(), "other.tsx".into()]); + let cold = output("fresh.tsx", SOURCE, false); + let mut value = snapshot(); + match damage { + MapDamage::ClassGap => { + let classes = value["classMap"]["D9-0"] + .as_object_mut() + .unwrap_or_else(|| panic!("counter namespace")); + *classes + .values_mut() + .find(|id| **id == 0) + .unwrap_or_else(|| panic!("slot zero")) = 99.into(); + } + MapDamage::FileGap => value["fileMap"]["fresh.tsx"] = 99.into(), + MapDamage::SourceGap => value["sourceIds"]["fresh.tsx"] = 99.into(), + MapDamage::SwappedSlots => { + for id in value["classMap"]["D9-0"] + .as_object_mut() + .unwrap_or_else(|| panic!("counter namespace")) + .values_mut() + { + if *id == 0 { + *id = 1.into(); + } else if *id == 1 { + *id = 0.into(); + } + } + } + MapDamage::MissingClasses => value["classMap"] = serde_json::json!({}), + MapDamage::MissingSources => value["sourceIds"] = serde_json::json!({}), + MapDamage::WrongKey => { + let classes = value["classMap"]["D9-0"] + .as_object_mut() + .unwrap_or_else(|| panic!("counter namespace")); + let key = classes + .keys() + .next() + .cloned() + .unwrap_or_else(|| panic!("key")); + let slot = classes.remove(&key).unwrap_or_else(|| panic!("slot")); + classes.insert("wrong-key".into(), slot); + } + MapDamage::WrongVariableSource => { + for property in value["properties"]["fresh.tsx"]["255"]["0"] + .as_array_mut() + .unwrap_or_else(|| panic!("properties")) + { + for field in ["p", "v"] { + if let Some(text) = property[field].as_str() { + property[field] = text.replace("---Sa-", "---Sb-").into(); + } + } + } + } + } + fresh(); + seed_file_map(vec!["fresh.tsx".into(), "other.tsx".into()]); + let before = snapshot(); + if matching { + companions(&value); + } + // When: invalid allocator state reaches authoritative admission without a companion veto. + assert_eq!(import(value), Ok(())); + // Then: fresh numbering survives, no cached declarations are adopted, and full output is cold. + assert_eq!(snapshot(), before); + assert_eq!(output("fresh.tsx", SOURCE, false), cold); + fresh(); +} + +#[rstest] +#[case(1)] +#[case(31)] +#[serial] +fn current_counter_cache_restores_file_and_slot_ad_blocker_splices(#[case] slots: usize) { + // Given: a complete real cache crossing both original-file and counter-slot label boundaries. + let configure = || { + fresh(); + set_prefix(Some("du-FLa-".into())); + seed_file_map((0..31).map(|file| format!("{file:02}.tsx")).collect()); + }; + configure(); + let mut elements = String::new(); + for value in 0..slots { + write!(elements, "").unwrap_or_else(|error| panic!("{error}")); + } + let source = format!("import {{Box}} from '@devup-ui/react';export const x=<>{elements}; "); + let cold = output("30.tsx", &source, false); + let value = snapshot(); + assert!(cold.0.contains("du-FLa-a-d-"), "{}", cold.0); + configure(); + // When: the valid snapshot is restored without any companion map authority. + assert_eq!(import(value.clone()), Ok(())); + // Then: admission keeps the cache and the complete generated output exactly. + assert_eq!(snapshot(), value); + assert_eq!(output("30.tsx", &source, false), cold); + fresh(); +} diff --git a/bindings/devup-ui-wasm/src/cache_atom_proof.rs b/bindings/devup-ui-wasm/src/cache_atom_proof.rs new file mode 100644 index 000000000..751de4644 --- /dev/null +++ b/bindings/devup-ui-wasm/src/cache_atom_proof.rs @@ -0,0 +1,68 @@ +use css::{Naming, content_name::AtomContent}; +use sheet::StyleSheet; + +#[path = "cache_reset_proof.rs"] +mod resets; + +pub(crate) fn validate(sheet: &StyleSheet) -> Result<(), String> { + if !resets::validate(sheet) { + return Err("cached owner reset has no exact dynamic consumer proof".into()); + } + crate::cache_special_proof::validate_frames(sheet)?; + for orders in sheet.properties.values() { + for (order, levels) in orders { + for (level, properties) in levels { + for property in properties { + let Some(claim) = sheet.names.get(&property.class_name) else { + continue; + }; + if property.owner_reset { + continue; + } + if property.typography { + if crate::cache_special_proof::typography( + property, + (levels, *order), + &claim.descriptor, + ) + .is_none() + { + return Err(format!( + "cached typography `{}` has a corrupt exact declaration proof", + property.class_name + )); + } + continue; + } + let valid = [Naming::Own, Naming::Risky].into_iter().any(|naming| { + [false, true].into_iter().any(|dynamic| { + let content = AtomContent { + property: &property.property, + value: Some(&property.value), + naming, + level: *level, + order: *order, + selector: property.selector.as_ref(), + layer: property.layer.as_deref(), + dynamic, + } + .content(); + content.descriptor == claim.descriptor + && crate::cache_descriptor::name_matches( + &content, + &property.class_name, + ) + }) + }); + if !valid { + return Err(format!( + "cached atom `{}` has a corrupt exact declaration proof", + property.class_name + )); + } + } + } + } + } + Ok(()) +} diff --git a/bindings/devup-ui-wasm/src/cache_descriptor.rs b/bindings/devup-ui-wasm/src/cache_descriptor.rs new file mode 100644 index 000000000..7695eaf4d --- /dev/null +++ b/bindings/devup-ui-wasm/src/cache_descriptor.rs @@ -0,0 +1,44 @@ +pub(crate) struct Cursor<'a>(pub(crate) &'a [u8]); + +impl<'a> Cursor<'a> { + pub(crate) fn take(&mut self, length: usize) -> Option<&'a [u8]> { + let (head, tail) = self.0.split_at_checked(length)?; + self.0 = tail; + Some(head) + } + + pub(crate) fn byte(&mut self) -> Option { + self.take(1)?.first().copied() + } + + pub(crate) fn count(&mut self) -> Option { + usize::try_from(u64::from_be_bytes(self.take(8)?.try_into().ok()?)).ok() + } + + pub(crate) fn text(&mut self) -> Option<&'a str> { + let length = self.count()?; + std::str::from_utf8(self.take(length)?).ok() + } +} + +pub(crate) fn name_matches(content: &css::content_name::ContentName, name: &str) -> bool { + name.rfind(|character: char| character.is_ascii_uppercase()) + .and_then(|marker| marker.checked_sub(1)) + .is_some_and(|domain| content.name(&name[..domain]) == name) +} + +pub(crate) fn hex_bytes(value: &str) -> Option> { + let digit = |byte| match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + _ => None, + }; + let (chunks, remainder) = value.as_bytes().as_chunks::<2>(); + if !remainder.is_empty() { + return None; + } + chunks + .iter() + .map(|pair| Some(digit(pair[0])? * 16 + digit(pair[1])?)) + .collect() +} diff --git a/bindings/devup-ui-wasm/src/cache_names.rs b/bindings/devup-ui-wasm/src/cache_names.rs new file mode 100644 index 000000000..a3af1e6c4 --- /dev/null +++ b/bindings/devup-ui-wasm/src/cache_names.rs @@ -0,0 +1,173 @@ +use sheet::StyleSheet; +use std::cell::RefCell; + +thread_local! { + static ERROR: RefCell> = const { RefCell::new(None) }; +} + +pub(crate) fn check() -> Result<(), String> { + ERROR.with_borrow(|error| match error { + Some(error) => Err(error.clone()), + None => Ok(()), + }) +} + +pub(crate) fn record(result: &Result<(), String>) { + if let Err(incoming) = result { + ERROR.with_borrow_mut(|error| { + if error.is_none() { + *error = Some(incoming.clone()); + } + }); + } +} + +pub(crate) fn clear() { + ERROR.with_borrow_mut(|error| *error = None); +} + +fn content_name(name: &str) -> bool { + let Some(marker) = name.rfind(|character: char| character.is_ascii_uppercase()) else { + return false; + }; + let Some(domain) = marker + .checked_sub(1) + .and_then(|index| name.as_bytes().get(index)) + else { + return false; + }; + if !matches!(domain, b'O' | b'R' | b'K') { + return false; + } + let payload = &name[marker + 1..]; + match name.as_bytes()[marker] { + b'H' => { + payload.len() == 16 + && payload + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') + } + b'L' => { + payload.len() <= 16 + && payload.bytes().all(|byte| { + byte.is_ascii_lowercase() + || byte.is_ascii_digit() + || matches!(byte, b'_' | b'-') + }) + } + _ => false, + } +} + +pub(crate) fn validate(sheet: &StyleSheet) -> Result<(), String> { + crate::cache_atom_proof::validate(sheet)?; + for (file, orders) in &sheet.properties { + for property in orders.values().flat_map(|levels| levels.values()).flatten() { + if property.hoisted + && !sheet + .atom_plan + .as_ref() + .is_some_and(|plan| plan.contains(file)) + { + return Err(format!( + "cached hoisted atom `{}` has no frozen bucket proof", + property.class_name + )); + } + } + } + for property in sheet + .properties + .values() + .flat_map(|orders| orders.values()) + .flat_map(|levels| levels.values()) + .flatten() + { + if property.owner_reset { + crate::cache_source_names::validate(&property.property, &sheet.names)?; + } + if let Some(reference) = property.value.strip_prefix("var(") { + crate::cache_source_names::validate(reference, &sheet.names)?; + } + } + let atoms = sheet.properties.iter().flat_map(|(file, orders)| { + orders.iter().flat_map(move |(order, levels)| { + levels.values().flatten().map(move |property| { + ( + property.class_name.as_str(), + !file.is_empty() && *order != 0 && !property.hoisted, + ) + }) + }) + }); + let frames = sheet + .keyframes + .values() + .flat_map(|frames| frames.keys().map(|name| (name.as_str(), false))); + let prefix = css::get_prefix().unwrap_or_default(); + for (name, per_file) in atoms.chain(frames) { + let generated = name.strip_prefix(&prefix).is_some_and(content_name); + if per_file + && generated + && name + .rfind(|character: char| character.is_ascii_uppercase()) + .and_then(|index| index.checked_sub(1)) + == Some(prefix.len()) + && name.starts_with(&prefix) + { + return Err(format!( + "cached per-file generated name `{name}` has no sheet namespace; rebuild all caches with the current naming contract" + )); + } + if generated && !sheet.names.contains_key(name) { + return Err(format!( + "cached generated name `{name}` has no exact descriptor claim; rebuild all caches with the current naming contract" + )); + } + if generated + && let Some(domain) = name + .rfind(|character: char| character.is_ascii_uppercase()) + .and_then(|index| index.checked_sub(1)) + && let Some(scope) = name[..domain].strip_suffix('-') + && scope.len() >= prefix.len() + && let Some(marker) = scope.rfind('F') + && marker >= prefix.len() + && matches!(scope.as_bytes().get(marker + 1), Some(b'L' | b'H')) + { + let claim = sheet.names.get(scope).ok_or_else(|| format!( + "cached generated scope `{scope}` has no exact sheet-key claim; rebuild all caches with the current naming contract" + ))?; + let content = css::content_name::ContentName::scope(&claim.content); + if claim.descriptor != content.descriptor || content.name(&scope[..marker]) != scope { + return Err(format!( + "cached generated scope `{scope}` has a corrupt exact sheet-key claim; rebuild all caches" + )); + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn current_content_grammar_does_not_confuse_legacy_counters_or_names() { + for name in ["OLcolor-vred", "prefix-RHaaaaaaaaaaaaaaaa", "KL"] { + assert!(content_name(name), "{name}"); + } + for name in [ + "a-a", + "Rcolor-vred", + "Ksfrom", + "a1-h-123", + "OHshort", + "OLlonger_than_sixteen_payload", + "prefix-L", + "OXred", + ] { + assert!(!content_name(name), "{name}"); + } + } +} diff --git a/bindings/devup-ui-wasm/src/cache_naming_coverage_tests.rs b/bindings/devup-ui-wasm/src/cache_naming_coverage_tests.rs new file mode 100644 index 000000000..772b8a112 --- /dev/null +++ b/bindings/devup-ui-wasm/src/cache_naming_coverage_tests.rs @@ -0,0 +1,109 @@ +use super::*; + +#[rstest] +#[case(0)] +#[case(30)] +#[serial] +fn dynamic_counter_owner_round_trip_keeps_the_validated_label(#[case] owner: usize) { + // Given: real dynamic declarations at the plain and ad-blocker owner-label boundaries. + let configure = || { + fresh(); + set_prefix(Some("du-FLa-".into())); + seed_file_map((0..=owner).map(|id| format!("{id:02}.tsx")).collect()); + }; + configure(); + let file = format!("{owner:02}.tsx"); + let source = "import {Box} from '@devup-ui/react';export const x=(p)=>;"; + let cold = output(&file, source, false); + let value = snapshot(); + assert!(cold.2.contains("color:var(---du-FLa-S"), "{}", cold.2); + configure(); + // When: current admission reuses the already validated owner label for variable proof. + assert_eq!(import(value.clone()), Ok(())); + // Then: the complete allocator/reset/declaration snapshot and full output round trip. + assert_eq!(snapshot(), value); + assert_eq!(output(&file, source, false), cold); + fresh(); +} + +#[test] +#[serial] +fn hoisted_declaration_without_bucket_proof_is_cold() { + // Given: a genuine current atom whose stored placement falsely claims hoisting. + fresh(); + let source = "import {Box} from '@devup-ui/react';export const x=;"; + let cold = output("fresh.tsx", source, false); + let mut value = snapshot(); + value["properties"]["fresh.tsx"]["255"]["0"][0]["h"] = true.into(); + fresh(); + let before = snapshot(); + companions(&value); + // When: admission checks actual placement against the frozen plan. + assert_eq!(import(value), Ok(())); + // Then: the entire snapshot remains cold and extraction remains usable. + assert_eq!(snapshot(), before); + assert_eq!(output("fresh.tsx", source, false), cold); + fresh(); +} + +#[test] +#[serial] +fn invalid_manual_generated_claim_does_not_replace_live_sheet() { + // Given: authored manual controls plus a generated-looking name lacking its claim. + fresh(); + let mut live = StyleSheet::default(); + live.add_property( + "manual-card", + "color", + 0, + "red", + None, + None, + Some("fresh.tsx"), + ); + assert_eq!(import_sheet_internal(live), Ok(())); + let before = snapshot(); + let css = with_style_sheet(|sheet| sheet.create_css(Some("fresh.tsx"), false)); + assert!( + css.contains(&format!(".manual-card{{color:{}}}", "red")), + "{css}" + ); + let mut incoming = StyleSheet::default(); + incoming.add_property("OLcolor-vblue", "color", 0, "blue", None, Some(0), None); + // When: in-memory manual import still enforces the generated-name contract. + assert_eq!(import_sheet_internal(incoming), Ok(())); + // Then: authored controls stay intact and the rejected claim is not sticky. + assert_eq!(snapshot(), before); + assert_eq!( + with_style_sheet(|sheet| sheet.create_css(Some("fresh.tsx"), false)), + css + ); + assert_eq!(cache_names::check(), Ok(())); + fresh(); +} + +#[test] +#[serial] +fn wrong_keyframe_descriptor_variant_is_cold() { + // Given: actual keyframe output, with an atom variant substituted in the frame proof. + fresh(); + let source = "import {keyframes} from '@devup-ui/react';export const x=keyframes({from:{opacity:0},to:{opacity:1}});"; + let cold = output("fresh.tsx", source, false); + let mut value = snapshot(); + let claim = value["names"] + .as_object_mut() + .unwrap_or_else(|| panic!("names object")) + .iter_mut() + .find(|(name, _)| name.starts_with('K')) + .unwrap_or_else(|| panic!("keyframe claim")) + .1; + claim["descriptor"][1] = 1.into(); + fresh(); + let before = snapshot(); + // When: exact frame decoding rejects the wrong semantic variant. + assert_eq!(import(value), Ok(())); + // Then: no cached state is installed and complete binding output is cold. + assert_eq!(snapshot(), before); + assert_eq!(output("fresh.tsx", source, false), cold); + fresh(); +} diff --git a/bindings/devup-ui-wasm/src/cache_protocol_tests.rs b/bindings/devup-ui-wasm/src/cache_protocol_tests.rs new file mode 100644 index 000000000..29d5ea412 --- /dev/null +++ b/bindings/devup-ui-wasm/src/cache_protocol_tests.rs @@ -0,0 +1,148 @@ +use super::*; +use rstest::rstest; +use serial_test::serial; +use std::collections::HashSet; + +#[test] +#[serial] +fn source_ids_roundtrip_restores_dynamic_names_when_extracting_in_reverse_order() { + // Given + reset_build_state_internal(); + css::file_map::seed_file_numbers(&["a.tsx".into(), "z.tsx".into()]); + let source = "import {Box} from '@devup-ui/react'; export const View=({pad})=>;"; + let before = code_extract_internal( + "z.tsx", + source, + "@devup-ui/react", + "df".into(), + false, + false, + false, + HashMap::new(), + ) + .unwrap_or_else(|error| panic!("{error}")); + let ids = css::file_map::get_original_ids(); + assert_eq!( + ids, + BTreeMap::from([("a.tsx".into(), 0), ("z.tsx".into(), 1)]) + ); + let exported = export_sheet_internal().unwrap_or_else(|error| panic!("{error}")); + let files = css::file_map::get_file_map(); + let classes = with_class_map(Clone::clone); + let imported: StyleSheet = + serde_json::from_str(&exported).unwrap_or_else(|error| panic!("{error}")); + reset_build_state_internal(); + // When + import_sheet_internal(imported).unwrap_or_else(|error| panic!("{error}")); + set_file_map(files); + set_class_map(classes); + let restored_ids = css::file_map::get_original_ids(); + let after = code_extract_internal( + "z.tsx", + source, + "@devup-ui/react", + "df".into(), + false, + false, + false, + HashMap::new(), + ) + .unwrap_or_else(|error| panic!("{error}")); + // Then + assert_eq!(restored_ids, ids); + assert_eq!(after.code(), before.code()); + reset_build_state_internal(); +} + +#[rstest] +#[case(false, false)] +#[case(false, true)] +#[case(true, true)] +#[serial] +fn legacy_serialized_import_is_cold_and_rewritten_current( + #[case] with_styles: bool, + #[case] atom_mode: bool, +) { + // Given + reset_build_state_internal(); + let mut legacy = StyleSheet::default(); + if with_styles { + legacy.add_property("old", "color", 0, "red", None, None, Some("private.tsx")); + } + let legacy_json = serde_json::to_string(&legacy).unwrap_or_else(|error| panic!("{error}")); + let local = StyleSheet::default().create_css(Some("private.tsx"), false); + let global = StyleSheet::default().create_css(None, false); + css::atom_hoist::set_atom_hoist(atom_mode.then_some(2)); + let imported = serde_json::from_str(&legacy_json).unwrap_or_else(|error| panic!("{error}")); + import_sheet_internal(imported).unwrap_or_else(|error| panic!("{error}")); + // When + let exported = export_sheet_internal().unwrap_or_else(|error| panic!("{error}")); + // Then + assert_ne!(exported, legacy_json); + let json: serde_json::Value = + serde_json::from_str(&exported).unwrap_or_else(|error| panic!("{error}")); + assert_eq!(json["atomNamingVersion"], 4); + let restored: StyleSheet = + serde_json::from_str(&exported).unwrap_or_else(|error| panic!("{error}")); + assert_eq!(restored.atom_plan, None); + assert_eq!(restored.create_css(Some("private.tsx"), false), local); + assert_eq!(restored.create_css(None, false), global); + import_sheet_internal(restored).unwrap_or_else(|error| panic!("{error}")); + assert_eq!( + export_sheet_internal().unwrap_or_else(|error| panic!("{error}")), + exported + ); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn exported_generation_roundtrips_frozen_atom_placement() { + // Given + reset_build_state_internal(); + css::atom_hoist::set_atom_hoist(Some(2)); + css::file_routes::set_file_routes(HashMap::from([ + ("shared.tsx".into(), HashSet::from([0, 1])), + ("private.tsx".into(), HashSet::from([0])), + ])); + for file in ["shared.tsx", "private.tsx"] { + code_extract_internal( + file, + "import {Box} from '@devup-ui/react'; export const x=;", + "@devup-ui/react", + "df".into(), + false, + false, + false, + HashMap::new(), + ) + .unwrap_or_else(|error| panic!("{error}")); + } + let exported = export_sheet_internal().unwrap_or_else(|error| panic!("{error}")); + let global = with_style_sheet(|sheet| sheet.create_css(None, false)); + let local = with_style_sheet(|sheet| sheet.create_css(Some("private.tsx"), false)); + let imported = serde_json::from_str(&exported).unwrap_or_else(|error| panic!("{error}")); + reset_build_state_internal(); + // When + import_sheet_internal(imported).unwrap_or_else(|error| panic!("{error}")); + // Then + assert_eq!(global.matches("color:red").count(), 1); + assert_eq!(local.matches("color:red").count(), 1); + assert_eq!( + with_style_sheet(|sheet| sheet.create_css(None, false)), + global + ); + assert_eq!( + with_style_sheet(|sheet| sheet.create_css(Some("private.tsx"), false)), + local + ); + assert!( + !with_style_sheet(|sheet| sheet.create_css(Some("shared.tsx"), false)) + .contains("color:red") + ); + assert_eq!( + export_sheet_internal().unwrap_or_else(|error| panic!("{error}")), + exported + ); + reset_build_state_internal(); +} diff --git a/bindings/devup-ui-wasm/src/cache_reset_proof.rs b/bindings/devup-ui-wasm/src/cache_reset_proof.rs new file mode 100644 index 000000000..40337c327 --- /dev/null +++ b/bindings/devup-ui-wasm/src/cache_reset_proof.rs @@ -0,0 +1,36 @@ +use sheet::StyleSheet; + +pub(super) fn validate(sheet: &StyleSheet) -> bool { + sheet.properties.values().all(|orders| { + orders.values().all(|levels| { + levels.iter().all(|(level, properties)| { + properties + .iter() + .filter(|property| property.owner_reset) + .all(|reset| { + *level == 0 + && reset.value == "initial" + && reset.property.starts_with("--") + && reset.selector.is_none() + && reset.layer.is_none() + && !reset.typography + && levels.values().flatten().any(|consumer| { + !consumer.owner_reset + && !consumer.typography + && consumer.class_name == reset.class_name + && consumer.hoisted == reset.hoisted + && consumer.value.strip_prefix("var(").and_then(|value| { + value + .strip_suffix(')') + .or_else(|| value.strip_suffix(") !important")) + }) == Some(reset.property.as_str()) + && sheet + .names + .get(&consumer.class_name) + .is_none_or(|claim| claim.descriptor.starts_with(&[1, 2])) + }) + }) + }) + }) + }) +} diff --git a/bindings/devup-ui-wasm/src/cache_reset_tests.rs b/bindings/devup-ui-wasm/src/cache_reset_tests.rs new file mode 100644 index 000000000..16634e716 --- /dev/null +++ b/bindings/devup-ui-wasm/src/cache_reset_tests.rs @@ -0,0 +1,124 @@ +use super::*; + +const STATIC: &str = "import {css} from '@devup-ui/react';export const x=css({color:'red'});"; +const DYNAMIC: &str = + "import {Box} from '@devup-ui/react';export const x=(p)=>;"; + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn forged_static_reset_is_cold_when_companions_match_or_are_absent(#[case] matching: bool) { + // Given: a real complete snapshot with a static declaration disguised as a reset. + fresh(); + let cold = output("reset.tsx", STATIC, true); + let mut value = snapshot(); + let property = &mut value["properties"][""]["255"]["0"][0]; + property["r"] = true.into(); + property["v"] = "blue".into(); + fresh(); + if matching { + companions(&value); + } + // When: the corrupt current snapshot reaches admission. + assert_eq!(import(value), Ok(())); + // Then: no poisoned declaration survives and whole output is cold. + assert_eq!(with_style_sheet(|sheet| sheet.properties.len()), 0); + assert_eq!(output("reset.tsx", STATIC, true), cold); + fresh(); +} + +enum ResetDamage { + Value, + Selector, + Level, + Layer, + Typography, + Variable, + Orphan, +} + +#[rstest] +#[case(ResetDamage::Value)] +#[case(ResetDamage::Selector)] +#[case(ResetDamage::Level)] +#[case(ResetDamage::Layer)] +#[case(ResetDamage::Typography)] +#[case(ResetDamage::Variable)] +#[case(ResetDamage::Orphan)] +#[serial] +fn corrupt_owner_reset_restores_nothing( + #[case] damage: ResetDamage, + #[values(false, true)] seeded: bool, +) { + // Given: independently cold output and a damaged genuine owner reset. + fresh(); + if seeded { + seed_file_map(vec!["reset.tsx".into()]); + } + let cold = output("reset.tsx", DYNAMIC, false); + let mut value = snapshot(); + let properties = value["properties"]["reset.tsx"]["255"]["0"] + .as_array_mut() + .unwrap_or_else(|| panic!("properties")); + let reset = properties + .iter_mut() + .find(|property| property["r"] == true) + .unwrap_or_else(|| panic!("owner reset")); + match damage { + ResetDamage::Value => reset["v"] = "inherit".into(), + ResetDamage::Selector => reset["s"] = serde_json::json!({"Selector": "&:hover"}), + ResetDamage::Level => { + let reset = reset.clone(); + properties.retain(|property| property["r"] != true); + value["properties"]["reset.tsx"]["255"]["1"] = serde_json::json!([reset]); + } + ResetDamage::Layer => reset["l"] = "t".into(), + ResetDamage::Typography => reset["t"] = true.into(), + ResetDamage::Variable => reset["p"] = "--theme".into(), + ResetDamage::Orphan => properties.retain(|property| property["r"] == true), + } + fresh(); + if seeded { + seed_file_map(vec!["reset.tsx".into()]); + } + // When: malformed reset state is admitted through the serialized API. + assert_eq!(import(value), Ok(())); + // Then: all cache state is discarded, including the reset exemption. + assert_eq!(with_style_sheet(|sheet| sheet.properties.len()), 0); + assert_eq!(output("reset.tsx", DYNAMIC, false), cold); + fresh(); +} + +#[test] +#[serial] +fn invalid_reset_claim_does_not_latch_a_false_collision() { + // Given: a live exact red claim and corrupt descriptor bytes hidden behind r=true. + fresh(); + output("live.tsx", STATIC, true); + let before = snapshot(); + let mut value = before.clone(); + value["properties"][""]["255"]["0"][0]["r"] = true.into(); + value["names"]["OLcolor-vred"]["descriptor"] + .as_array_mut() + .unwrap_or_else(|| panic!("descriptor")) + .push(99.into()); + // When: the invalid snapshot overlaps a genuine live claim. + assert_eq!(import(value), Ok(())); + // Then: the live sheet remains unchanged and extraction is not poisoned by a latch. + assert_eq!(snapshot(), before); + assert!( + code_extract_internal( + "later.tsx", + STATIC, + "@devup-ui/react", + "df".into(), + true, + false, + false, + HashMap::new() + ) + .is_ok() + ); + fresh(); +} diff --git a/bindings/devup-ui-wasm/src/cache_restore.rs b/bindings/devup-ui-wasm/src/cache_restore.rs new file mode 100644 index 000000000..bba9d9901 --- /dev/null +++ b/bindings/devup-ui-wasm/src/cache_restore.rs @@ -0,0 +1,245 @@ +use std::{cell::RefCell, collections::BTreeMap}; + +use sheet::{ + StyleSheet, + cache_snapshot::{CacheRestore, ClassMap, FileMap}, +}; + +use crate::{administration::with_administration, cache_names, with_style_sheet_mut}; + +#[path = "cache_allocator_proof.rs"] +mod allocator; + +struct Authority { + classes: ClassMap, + files: FileMap, + before: StyleSheet, + before_classes: ClassMap, + before_files: FileMap, + before_sources: BTreeMap, + before_plan: Option>, + fresh_seeds: Vec>, +} + +#[derive(Default)] +enum Companion { + #[default] + Unseen, + Invalid, + Supplied(T), +} + +impl Companion { + fn matches(&self, expected: &T) -> bool { + match self { + Self::Unseen => true, + Self::Invalid => false, + Self::Supplied(value) => value == expected, + } + } +} + +impl From> for Companion { + fn from(value: Option) -> Self { + match value { + Some(value) => Self::Supplied(value), + None => Self::Invalid, + } + } +} + +#[derive(Default)] +struct Restore { + classes: Companion, + files: Companion, + authority: Option, +} + +thread_local! { + static RESTORE: RefCell = RefCell::new(Restore::default()); +} + +pub(crate) fn absent() -> StyleSheet { + let mut sheet = StyleSheet::default(); + sheet.cache_restore = CacheRestore::Rejected; + sheet +} + +fn set_classes(classes: ClassMap) { + css::class_map::set_class_map( + classes + .into_iter() + .map(|(file, classes)| (file, classes.into_iter().collect())) + .collect(), + ); +} + +fn set_files(files: FileMap) { + css::file_map::set_file_map(files.into_iter().collect()); +} + +fn rollback(state: &mut Restore) { + if let Some(mut authority) = state.authority.take() { + set_classes(authority.before_classes); + set_files(authority.before_files); + css::file_map::set_original_ids(authority.before_sources); + css::atom_hoist::restore_atom_plan(authority.before_plan); + with_style_sheet_mut(|current| { + authority.before.theme = std::mem::take(&mut current.theme); + *current = authority.before; + }); + for files in authority.fresh_seeds { + css::file_map::seed_file_numbers(&files); + } + } +} + +pub(crate) fn clear() { + with_administration("cache_restore::clear", || { + RESTORE.with_borrow_mut(|state| *state = Restore::default()); + }); +} + +pub(crate) fn seeded(files: &[String]) { + with_administration("cache_restore::seeded", || { + RESTORE.with_borrow_mut(|state| { + if let Some(authority) = &mut state.authority { + authority.fresh_seeds.push(files.to_vec()); + } + }); + }); +} + +pub(crate) fn classes(incoming: Option) { + with_administration("cache_restore::classes", || { + let incoming = Companion::from(incoming); + RESTORE.with_borrow_mut(|state| { + if state + .authority + .as_ref() + .is_some_and(|authority| incoming.matches(&authority.classes)) + { + state.classes = Companion::Unseen; + return; + } + if state + .authority + .as_ref() + .is_some_and(|authority| !incoming.matches(&authority.classes)) + { + rollback(state); + } + state.classes = incoming; + }); + }); +} + +pub(crate) fn files(incoming: Option) { + with_administration("cache_restore::files", || { + let incoming = Companion::from(incoming); + RESTORE.with_borrow_mut(|state| { + if state + .authority + .as_ref() + .is_some_and(|authority| incoming.matches(&authority.files)) + { + state.files = Companion::Unseen; + return; + } + if state + .authority + .as_ref() + .is_some_and(|authority| !incoming.matches(&authority.files)) + { + rollback(state); + } + state.files = incoming; + }); + }); +} + +pub(crate) fn import(mut incoming: StyleSheet) -> Result<(), String> { + with_administration("cache_restore::import", || { + let result = + RESTORE.with_borrow_mut(|state| match std::mem::take(&mut incoming.cache_restore) { + CacheRestore::Rejected => { + rollback(state); + Ok(()) + } + CacheRestore::Manual => { + if cache_names::validate(&incoming).is_err() { + return Ok(()); + } + with_style_sheet_mut(|current| { + let claims = + sheet::name_registry::preflight(¤t.names, incoming.names.clone()) + .map_err(|error| error.to_string())?; + incoming.names = current.names.clone(); + incoming.names.extend(claims); + incoming.theme = std::mem::take(&mut current.theme); + css::atom_hoist::restore_atom_plan(incoming.atom_plan.clone()); + *current = incoming; + Ok(()) + }) + } + CacheRestore::Serialized { classes, files } => { + if !allocator::validate(&incoming, &classes) + || cache_names::validate(&incoming).is_err() + || !state.classes.matches(&classes) + || !state.files.matches(&files) + { + rollback(state); + return Ok(()); + } + let claims = crate::with_style_sheet(|current| { + sheet::name_registry::preflight(¤t.names, incoming.names.clone()) + .map_err(|error| error.to_string()) + })?; + rollback(state); + with_style_sheet_mut(|current| { + incoming.names = current.names.clone(); + incoming.names.extend(claims); + let before_classes = css::class_map::with_class_map(|map| { + map.iter() + .map(|(file, classes)| { + ( + file.clone(), + classes + .iter() + .map(|(key, id)| (key.clone(), *id)) + .collect(), + ) + }) + .collect() + }); + let before_files = css::file_map::with_file_map(|map| { + map.iter().map(|(file, id)| (file.clone(), *id)).collect() + }); + let before_sources = css::file_map::get_original_ids(); + let before_plan = css::atom_hoist::atom_plan(); + set_classes(classes.clone()); + set_files(files.clone()); + css::file_map::set_original_ids(incoming.source_ids.clone()); + css::atom_hoist::restore_atom_plan(incoming.atom_plan.clone()); + incoming.theme = std::mem::take(&mut current.theme); + let before = std::mem::replace(current, incoming); + state.authority = Some(Authority { + classes, + files, + before, + before_classes, + before_files, + before_sources, + before_plan, + fresh_seeds: Vec::new(), + }); + state.classes = Companion::Unseen; + state.files = Companion::Unseen; + Ok(()) + }) + } + }); + cache_names::record(&result); + result + }) +} diff --git a/bindings/devup-ui-wasm/src/cache_seed_tests.rs b/bindings/devup-ui-wasm/src/cache_seed_tests.rs new file mode 100644 index 000000000..7692f6d72 --- /dev/null +++ b/bindings/devup-ui-wasm/src/cache_seed_tests.rs @@ -0,0 +1,37 @@ +use super::*; + +fn seed_batches() { + seed_file_map(vec!["b.tsx".into(), "b.tsx".into()]); + seed_file_map(vec!["a.tsx".into(), "b.tsx".into()]); + seed_file_map(vec!["c.tsx".into(), "a.tsx".into(), "c.tsx".into()]); +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn rollback_keeps_seed_call_order_and_repeated_files(#[case] class_companion: bool) { + // Given: independently cold incremental numbering and an admitted empty cache. + const SOURCE: &str = + "import {Box} from '@devup-ui/react';export const x=(p)=>;"; + fresh(); + seed_batches(); + let cold = output("a.tsx", SOURCE, false); + let cold_ids = css::file_map::get_original_ids(); + let cold_files = css::file_map::get_file_map(); + fresh(); + let empty = snapshot(); + assert_eq!(import(empty), Ok(())); + seed_batches(); + // When: an invalid companion rolls back cached state after three fresh seed calls. + if class_companion { + cache_restore::classes(None); + } else { + cache_restore::files(None); + } + // Then: original/delivery numbering and the full emitted output match those same cold calls. + assert_eq!(css::file_map::get_original_ids(), cold_ids); + assert_eq!(css::file_map::get_file_map(), cold_files); + assert_eq!(output("a.tsx", SOURCE, false), cold); + fresh(); +} diff --git a/bindings/devup-ui-wasm/src/cache_source_names.rs b/bindings/devup-ui-wasm/src/cache_source_names.rs new file mode 100644 index 000000000..b5a562ffc --- /dev/null +++ b/bindings/devup-ui-wasm/src/cache_source_names.rs @@ -0,0 +1,97 @@ +use css::content_name::ContentName; +use sheet::name_registry::NameRegistry; + +pub(crate) fn validate(text: &str, names: &NameRegistry) -> Result<(), String> { + let mut scanned = 0; + for (start, _) in text.match_indices("---") { + if start < scanned { + continue; + } + let tail = &text[start..]; + let end = tail + .find(|character: char| { + !(character.is_ascii_alphanumeric() + || character >= '\u{80}' + || matches!(character, '-' | '_')) + }) + .unwrap_or(tail.len()); + scanned = start + end; + let variable = &tail[..end]; + let Some(marker) = variable.rfind(|character: char| character.is_ascii_uppercase()) else { + continue; + }; + let (prefix, file_part) = match variable.as_bytes()[marker] { + b'L' | b'H' if variable[..marker].ends_with("SU") => { + (&variable[..marker - 1], &variable[marker - 1..]) + } + b'U' if variable[..marker].ends_with('S') => { + return Err(format!( + "cached source variable `{variable}` uses an unprotected source naming contract; rebuild all caches" + )); + } + _ => continue, + }; + let Some((file_part, _position)) = file_part.split_once('-') else { + return Err(format!( + "cached source variable `{variable}` has no source position; rebuild all caches" + )); + }; + let name = &variable[..prefix.len() + file_part.len()]; + let Some(claim) = names.get(name) else { + return Err(format!( + "cached source variable `{variable}` has no exact source claim; rebuild all caches with the current naming contract" + )); + }; + if claim.descriptor != claim.content.as_bytes() + || ContentName::source(&claim.content).name(prefix) != name + { + return Err(format!( + "cached source variable `{variable}` has an invalid exact source claim; rebuild all caches with the current naming contract" + )); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use css::style_origin::RealLocation; + use rstest::rstest; + use sheet::name_registry::NameClaim; + + #[rstest] + #[case("---Sa-a", true)] + #[case("---Mixed-SUH-prefix-Sa-b-c", true)] + #[case("--theme-color", true)] + #[case("--v1-color-0-selector", true)] + #[case("plain text", true)] + #[case("---lowercase", true)] + #[case("var(---SULa-b)", true)] + #[case("---SULa-b-c", true)] + #[case("var(---SULmissing-b)", false)] + #[case("---SULa", false)] + #[case("var(---SUa-b)", false)] + fn cached_source_variables_require_exact_claims_without_confusing_other_namespaces( + #[case] text: &str, + #[case] protected: bool, + ) { + // Given: one exact short source claim and arbitrary cached CSS tokens. + let names = NameRegistry::from([( + "---SULa".into(), + NameClaim { + descriptor: b"a".to_vec(), + content: "a".into(), + origin: None, + location: RealLocation::ModuleExport { + file: "/real/a.tsx".into(), + binding: None, + }, + }, + )]); + // When: the production cache boundary scans both reset/value token shapes. + let result = validate(text, &names); + // Then: only source-variable tokens require source proof and a position. + assert_eq!(result.is_ok(), protected, "{result:?}"); + } +} diff --git a/bindings/devup-ui-wasm/src/cache_special_proof.rs b/bindings/devup-ui-wasm/src/cache_special_proof.rs new file mode 100644 index 000000000..0d7a4b63d --- /dev/null +++ b/bindings/devup-ui-wasm/src/cache_special_proof.rs @@ -0,0 +1,152 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use css::{ + Naming, + content_name::{AtomContent, ContentName}, +}; +use sheet::{StyleSheet, StyleSheetProperty}; + +use crate::cache_descriptor::{Cursor, name_matches}; + +type FrameSteps = Vec<(String, Vec<(String, String)>)>; + +fn frames(descriptor: &[u8]) -> Option { + let mut cursor = Cursor(descriptor); + if cursor.take(2)? != [1, 3] { + return None; + } + let count = cursor.count()?; + let mut steps = Vec::new(); + for _ in 0..count { + let step = cursor.text()?.to_string(); + let count = cursor.count()?; + let mut properties = Vec::new(); + for _ in 0..count { + properties.push((cursor.text()?.to_string(), cursor.text()?.to_string())); + } + steps.push((step, properties)); + } + cursor.0.is_empty().then_some(steps) +} + +pub(crate) fn validate_frames(sheet: &StyleSheet) -> Result<(), String> { + for frames_map in sheet.keyframes.values() { + for (name, steps) in frames_map { + if let Some(claim) = sheet.names.get(name) { + let valid = frames(&claim.descriptor).is_some_and(|decoded| { + let content = ContentName::keyframes(&decoded); + name_matches(&content, name) + && decoded.into_iter().collect::>() == *steps + }); + if !valid { + return Err(format!( + "cached keyframes `{name}` have a corrupt exact frame proof" + )); + } + } + } + } + Ok(()) +} + +fn declarations(value: &str) -> Option> { + let bytes = crate::cache_descriptor::hex_bytes(value.strip_prefix('t')?)?; + let mut cursor = Cursor(&bytes); + let count = cursor.count()?; + let mut result = BTreeSet::new(); + for _ in 0..count { + result.insert(( + cursor.byte()?, + cursor.text()?.to_string(), + cursor.text()?.to_string(), + )); + } + (cursor.0.is_empty() && result.len() == count).then_some(result) +} + +pub(crate) fn typography( + property: &StyleSheetProperty, + group: (&BTreeMap>, u8), + descriptor: &[u8], +) -> Option<()> { + let mut cursor = Cursor(descriptor); + if cursor.take(2)? != [1, 1] { + return None; + } + let naming = match cursor.byte()? { + 0 => Naming::Own, + 1 => Naming::Risky, + _ => return None, + }; + if cursor.text()? != "typography" || cursor.byte()? != 1 { + return None; + } + let value = cursor.text()?; + let level = cursor.byte()?; + let order = cursor.byte()?; + if order != group.1 { + return None; + } + let content = AtomContent { + property: "typography", + value: Some(value), + naming, + level, + order, + selector: property.selector.as_ref(), + layer: property.layer.as_deref()?.strip_suffix(".t"), + dynamic: false, + } + .content(); + if content.descriptor != descriptor || !name_matches(&content, &property.class_name) { + return None; + } + let actual = group + .0 + .iter() + .flat_map(|(level, properties)| { + properties + .iter() + .filter(|other| other.class_name == property.class_name && other.typography) + .map(|other| (*level, other.property.clone(), other.value.clone())) + }) + .collect(); + (declarations(value)? == actual).then_some(()) +} + +pub(crate) fn counter_typography( + property: &StyleSheetProperty, + group: (&BTreeMap>, u8), + key: &str, +) -> Option<()> { + let lossless = String::from_utf8(crate::cache_descriptor::hex_bytes(key)?).ok()?; + let tail = lossless.strip_prefix("typography-v")?; + let value = tail.split('-').next()?; + let suffix = tail.strip_prefix(value)?; + let level = match suffix.strip_prefix("-l") { + Some(level) => level.split('-').next()?.parse().ok()?, + None => 0, + }; + let content = AtomContent { + property: "typography", + value: Some(value), + naming: Naming::Own, + level, + order: group.1, + selector: property.selector.as_ref(), + layer: property.layer.as_deref()?.strip_suffix(".t"), + dynamic: false, + } + .content(); + let actual = group + .0 + .iter() + .flat_map(|(level, properties)| { + properties + .iter() + .filter(|other| other.class_name == property.class_name && other.typography) + .map(|other| (*level, other.property.clone(), other.value.clone())) + }) + .collect(); + (content.lossless == lossless && declarations(value)? == actual).then_some(()) +} diff --git a/bindings/devup-ui-wasm/src/cache_typography_coverage_tests.rs b/bindings/devup-ui-wasm/src/cache_typography_coverage_tests.rs new file mode 100644 index 000000000..3341ec815 --- /dev/null +++ b/bindings/devup-ui-wasm/src/cache_typography_coverage_tests.rs @@ -0,0 +1,158 @@ +use super::*; + +#[path = "cache_typography_descriptor_tests.rs"] +mod descriptors; + +fn configure(numbered: bool) { + fresh(); + register_theme_internal( + serde_json::from_value(serde_json::json!({ + "typography":{"body":[{"fontSize":"14px","fontFamily":"Sans"},null,{"fontSize":"18px"}]} + })) + .unwrap_or_else(|error| panic!("{error}")), + ); + if numbered { + seed_file_map(vec!["fresh.tsx".into()]); + } +} + +fn source(responsive: bool) -> &'static str { + if responsive { + "import {Box} from '@devup-ui/react';export const x=;" + } else { + "import {Box} from '@devup-ui/react';export const x=;" + } +} + +#[rstest] +#[serial] +fn current_typography_restores_exact_state_without_fresh_theme_authority( + #[values(false, true)] numbered: bool, + #[values(false, true)] responsive: bool, + #[values(false, true)] matching: bool, +) { + // Given: actual preset expansion, including the original-owner counter path. + configure(numbered); + let cold = output("fresh.tsx", source(responsive), false); + let value = snapshot(); + assert!(cold.2.contains("font-size:18px"), "{}", cold.2); + if numbered { + assert!(cold.0.contains("a-a"), "{}", cold.0); + assert_eq!( + value["classMap"]["D9-0"] + .as_object() + .unwrap_or_else(|| panic!("counter class map")) + .len(), + 1 + ); + } + fresh(); + let configured_theme = with_style_sheet(|sheet| sheet.theme.typography.len()); + if matching { + companions(&value); + } + // When: the cache alone proves its declarations with no current preset available. + assert_eq!(import(value.clone()), Ok(())); + // Then: this is admission, not a silent cold miss, and fresh theme stays fresh. + assert_eq!(snapshot(), value); + assert_eq!( + with_style_sheet(|sheet| sheet.theme.typography.len()), + configured_theme + ); + assert_eq!( + with_style_sheet(|sheet| sheet.create_css(Some("fresh.tsx"), false)), + cold.2 + ); + register_theme_internal( + serde_json::from_value(serde_json::json!({ + "typography":{"body":[{"fontSize":"14px","fontFamily":"Sans"},null,{"fontSize":"18px"}]} + })) + .unwrap_or_else(|error| panic!("{error}")), + ); + assert_eq!(output("fresh.tsx", source(responsive), false), cold); + fresh(); +} + +#[rstest] +#[serial] +fn corrupt_typography_declarations_are_cold_not_sticky( + #[values(false, true)] numbered: bool, + #[values(false, true)] responsive: bool, +) { + // Given: a real cache with one expanded declaration changed but its identity retained. + configure(numbered); + let cold = output("fresh.tsx", source(responsive), false); + let mut value = snapshot(); + let mut changed = false; + for orders in value["properties"] + .as_object_mut() + .unwrap_or_else(|| panic!("properties object")) + .values_mut() + { + for levels in orders + .as_object_mut() + .unwrap_or_else(|| panic!("orders object")) + .values_mut() + { + for properties in levels + .as_object_mut() + .unwrap_or_else(|| panic!("levels object")) + .values_mut() + { + for property in properties + .as_array_mut() + .unwrap_or_else(|| panic!("declaration array")) + { + if property["t"] == true && property["p"] == "font-size" { + property["v"] = "99px".into(); + changed = true; + } + } + } + } + } + assert!(changed); + configure(numbered); + let before = snapshot(); + companions(&value); + // When: matching companions cannot excuse the corrupt declaration proof. + assert_eq!(import(value), Ok(())); + // Then: state and complete JS/global/local CSS are independently cold; no error latched. + assert_eq!(snapshot(), before); + assert_eq!(output("fresh.tsx", source(responsive), false), cold); + assert_eq!(cache_names::check(), Ok(())); + fresh(); +} + +#[rstest] +#[case("g0")] +#[case("0g")] +#[case("0")] +#[serial] +fn malformed_counter_identity_is_cold_with_matching_companions(#[case] key: &str) { + // Given: a real current counter typography cache with a non-hex or truncated slot key. + configure(true); + let cold = output("fresh.tsx", source(false), false); + let mut value = snapshot(); + let classes = value["classMap"]["D9-0"] + .as_object_mut() + .unwrap_or_else(|| panic!("counter class map")); + let previous = classes + .keys() + .next() + .cloned() + .unwrap_or_else(|| panic!("counter slot key")); + let slot = classes + .remove(&previous) + .unwrap_or_else(|| panic!("counter slot value")); + classes.insert(key.into(), slot); + configure(true); + let before = snapshot(); + companions(&value); + // When: the malformed identity reaches the counter proof itself. + assert_eq!(import(value), Ok(())); + // Then: no partial adoption or false collision, with full cold output preserved. + assert_eq!(snapshot(), before); + assert_eq!(output("fresh.tsx", source(false), false), cold); + fresh(); +} diff --git a/bindings/devup-ui-wasm/src/cache_typography_descriptor_tests.rs b/bindings/devup-ui-wasm/src/cache_typography_descriptor_tests.rs new file mode 100644 index 000000000..928aaf523 --- /dev/null +++ b/bindings/devup-ui-wasm/src/cache_typography_descriptor_tests.rs @@ -0,0 +1,281 @@ +use super::*; +use crate::cache_descriptor::Cursor; +use css::{Naming, content_name::AtomContent}; + +#[rstest] +#[case(None)] +#[case(Some("first.tsx"))] +#[case(Some("second.tsx"))] +#[serial] +fn get_css_selects_global_or_numbered_file_with_main_import_control( + #[case] file: Option<&str>, + #[values(false, true)] import_main_css: bool, +) { + // Given: distinct file declarations and real global typography CSS. + configure(false); + let first = output( + "first.tsx", + "import {Box}from '@devup-ui/react';export const x=;", + false, + ); + let second = output( + "second.tsx", + "import {Box}from '@devup-ui/react';export const x=;", + false, + ); + assert!(first.2.contains("color:red"), "{}", first.2); + assert!(second.2.contains("color:blue"), "{}", second.2); + assert_ne!(first.2, second.2); + assert!(second.1.contains("font-size:14px"), "{}", second.1); + let file_num = file.map(|file| { + with_file_map(|map| { + map.get_by_left(file) + .copied() + .unwrap_or_else(|| panic!("registered file: {file}")) + }) + }); + let expected = match file { + None => second.1, + Some("first.tsx") => first.2, + Some("second.tsx") => second.2, + Some(other) => panic!("unexpected fixture: {other}"), + }; + // When + let actual = get_css_internal(file_num, import_main_css) + .unwrap_or_else(|error| panic!("getCss: {error}")); + // Then: file selection is exact; only numbered files receive the requested main import. + let main_import = "@import \"./devup-ui.css\";"; + assert_eq!( + actual.matches(main_import).count(), + usize::from(file.is_some() && import_main_css) + ); + assert_eq!(actual.replace(main_import, ""), expected); + fresh(); +} + +#[rstest] +#[serial] +fn get_css_uses_global_output_when_file_number_is_unmapped( + #[values(false, true)] import_main_css: bool, +) { + // Given + configure(false); + let expected = output("first.tsx", source(false), false).1; + // When + let actual = get_css_internal(Some(usize::MAX), import_main_css); + // Then: the existing failed lookup passes None to CSS generation. + assert_eq!(actual, Ok(expected)); + fresh(); +} + +enum Damage { + Header, + Naming, + Property, + ValueTag, + Order, + Trailing, +} + +#[rstest] +#[case(Damage::Header)] +#[case(Damage::Naming)] +#[case(Damage::Property)] +#[case(Damage::ValueTag)] +#[case(Damage::Order)] +#[case(Damage::Trailing)] +#[serial] +fn malformed_typography_descriptor_is_cold_before_claim_preflight(#[case] damage: Damage) { + // Given: an actual content-named typography atom and its versioned proof. + configure(false); + let cold = output("fresh.tsx", source(false), false); + let mut value = snapshot(); + let claim = value["names"] + .as_object_mut() + .unwrap_or_else(|| panic!("names object")) + .values_mut() + .find(|claim| { + claim["descriptor"] + .as_array() + .is_some_and(|bytes| bytes.get(1) == Some(&1.into())) + }) + .unwrap_or_else(|| panic!("typography claim")); + let mut descriptor: Vec = serde_json::from_value(claim["descriptor"].clone()) + .unwrap_or_else(|error| panic!("descriptor: {error}")); + match damage { + Damage::Header => descriptor[1] = 3, + Damage::Naming => descriptor[2] = 2, + Damage::Property => descriptor[11] = b'x', + Damage::ValueTag => descriptor[21] = 0, + Damage::Order => { + let mut cursor = Cursor(&descriptor[3..]); + assert_eq!(cursor.text(), Some("typography")); + assert_eq!(cursor.byte(), Some(1)); + assert!(cursor.text().is_some()); + assert_eq!(cursor.byte(), Some(0)); + let index = descriptor.len() - cursor.0.len(); + descriptor[index] = 1; + } + Damage::Trailing => descriptor.push(99), + } + claim["descriptor"] = + serde_json::to_value(descriptor).unwrap_or_else(|error| panic!("descriptor JSON: {error}")); + configure(false); + let before = snapshot(); + // When: cache admission encounters the corrupt exact typography proof. + assert_eq!(import(value), Ok(())); + // Then: the failure is a cold miss, not a real-name collision or partial adoption. + assert_eq!(snapshot(), before); + assert_eq!(output("fresh.tsx", source(false), false), cold); + assert_eq!(cache_names::check(), Ok(())); + fresh(); +} + +#[test] +#[serial] +fn current_typography_claim_collision_keeps_both_locations_and_live_state() { + // Given: two genuinely different preset expansions with real source locations. + let source = + "import {Box} from '@devup-ui/react';\nexport const x=;"; + let configure = |size: &str| { + fresh(); + register_theme_internal( + serde_json::from_value(serde_json::json!({"typography":{"body":{"fontSize":size}}})) + .unwrap_or_else(|error| panic!("theme fixture: {error}")), + ); + }; + configure("18px"); + output("cached.tsx", source, true); + let mut incoming = snapshot(); + let cached_claim = incoming["names"] + .as_object() + .unwrap_or_else(|| panic!("cached names object")) + .values() + .find(|claim| { + claim["descriptor"] + .as_array() + .is_some_and(|bytes| bytes.get(1) == Some(&1.into())) + }) + .unwrap_or_else(|| panic!("cached typography claim")) + .clone(); + configure("14px"); + output("live.tsx", source, true); + let before = snapshot(); + let live_name = before["names"] + .as_object() + .unwrap_or_else(|| panic!("live names object")) + .iter() + .find(|(_, claim)| { + claim["descriptor"] + .as_array() + .is_some_and(|bytes| bytes.get(1) == Some(&1.into())) + }) + .unwrap_or_else(|| panic!("live typography claim")) + .0 + .clone(); + incoming["properties"] = serde_json::json!({}); + incoming["names"] + .as_object_mut() + .unwrap_or_else(|| panic!("incoming names object")) + .insert(live_name.clone(), cached_claim); + let column = source + .split('\n') + .nth(1) + .unwrap_or_else(|| panic!("authored second line")) + .find("'body'") + .unwrap_or_else(|| panic!("authored typography operand")) + + 1; + // When: a complete current snapshot presents conflicting exact bytes under a live name. + let error = import(incoming) + .err() + .unwrap_or_else(|| panic!("conflicting claims must fail")); + // Then: both source sites survive, no live state changes, and the genuine error stays sticky. + assert!( + error.contains(&format!("cached.tsx:2:{column}:")), + "{error}" + ); + assert!(error.contains(&format!("live.tsx:2:{column}:")), "{error}"); + assert!(error.contains(&format!("`{live_name}`")), "{error}"); + assert_eq!(snapshot(), before); + let extraction_error = code_extract_internal( + "live.tsx", + source, + "@devup-ui/react", + "df".into(), + true, + false, + false, + HashMap::new(), + ) + .err() + .unwrap_or_else(|| panic!("sticky collision rejects extraction")); + assert_eq!(extraction_error, error); + assert_eq!(get_css_internal(None, false), Err(error.clone())); + assert_eq!(snapshot(), before); + assert_eq!(cache_names::check(), Err(error)); + fresh(); +} + +#[rstest] +#[case(None)] +#[case(Some("t0"))] +#[case(Some("tgg"))] +#[case(Some("t0000000000000001"))] +#[serial] +fn risky_typography_proof_accepts_only_exact_complete_declarations( + #[case] malformed: Option<&str>, +) { + // Given: real expanded declarations, named with Risky provenance at the pure proof boundary. + configure(false); + output("fresh.tsx", source(false), false); + let (mut property, mut levels, descriptor) = with_style_sheet(|sheet| { + let levels = &sheet.properties["fresh.tsx"][&255]; + let property = levels + .values() + .flatten() + .find(|property| property.typography) + .unwrap_or_else(|| panic!("expanded typography property")); + ( + property.clone(), + levels.clone(), + sheet.names[&property.class_name].descriptor.clone(), + ) + }); + let mut cursor = Cursor(&descriptor[3..]); + assert_eq!(cursor.text(), Some("typography")); + assert_eq!(cursor.byte(), Some(1)); + let original = cursor + .text() + .unwrap_or_else(|| panic!("typography declaration encoding")); + let content = AtomContent { + property: "typography", + value: Some(malformed.unwrap_or(original)), + naming: Naming::Risky, + level: 0, + order: 255, + selector: property.selector.as_ref(), + layer: None, + dynamic: false, + } + .content(); + let old_name = property.class_name.clone(); + property.class_name = content.name("FLfresh_ptsx-"); + for properties in levels.values_mut() { + *properties = properties + .iter() + .cloned() + .map(|mut other| { + if other.class_name == old_name { + other.class_name.clone_from(&property.class_name); + } + other + }) + .collect(); + } + // When: provenance and declaration decoding are checked against the actual expanded group. + let result = + crate::cache_special_proof::typography(&property, (&levels, 255), &content.descriptor); + // Then: Risky is valid, but odd/non-hex/truncated declaration encodings cannot prove it. + assert_eq!(result, malformed.is_none().then_some(())); + fresh(); +} diff --git a/bindings/devup-ui-wasm/src/cache_v4_tests.rs b/bindings/devup-ui-wasm/src/cache_v4_tests.rs new file mode 100644 index 000000000..29270b3bc --- /dev/null +++ b/bindings/devup-ui-wasm/src/cache_v4_tests.rs @@ -0,0 +1,229 @@ +use super::*; +use rstest::rstest; +use serial_test::serial; +#[path = "cache_admission_tests.rs"] +mod admission; +mod collisions; +mod configuration; +mod special; + +fn compile() -> (String, String) { + let output = code_extract_internal( + "fresh.tsx", + "import {Box} from '@devup-ui/react';export const View=(p)=>;", + "@devup-ui/react", + "df".into(), + false, + false, + false, + HashMap::new(), + ) + .unwrap_or_else(|error| panic!("{error}")); + ( + output.code(), + with_style_sheet(|sheet| sheet.create_css(Some("fresh.tsx"), false)), + ) +} + +fn snapshot() -> serde_json::Value { + serde_json::from_str(&export_sheet_internal().unwrap_or_else(|error| panic!("{error}"))) + .unwrap_or_else(|error| panic!("{error}")) +} + +fn import(value: serde_json::Value) -> Result<(), String> { + import_sheet_internal(serde_json::from_value(value).unwrap_or_else(|_| cache_restore::absent())) +} + +#[rstest] +#[case(None)] +#[case(Some(0))] +#[case(Some(3))] +#[case(Some(5))] +#[serial] +fn serialized_old_or_future_generation_restores_nothing(#[case] version: Option) { + // Given: an independently compiled cold output and a poisoned old snapshot. + reset_build_state_internal(); + let cold = compile(); + reset_build_state_internal(); + let mut poison = StyleSheet::default(); + poison.add_property( + "old-alias", + "color", + 0, + "magenta", + None, + None, + Some("fresh.tsx"), + ); + import_sheet_internal(poison).unwrap_or_else(|error| panic!("{error}")); + let mut value: serde_json::Value = + serde_json::from_str(&export_sheet_internal().unwrap_or_else(|error| panic!("{error}"))) + .unwrap_or_else(|error| panic!("{error}")); + let object = value + .as_object_mut() + .unwrap_or_else(|| panic!("snapshot object")); + match version { + Some(version) => { + object.insert("atomNamingVersion".into(), version.into()); + } + None => { + object.remove("atomNamingVersion"); + } + } + reset_build_state_internal(); + // When: serialized admission runs through the existing typed internal API. + let incoming = serde_json::from_value(value).unwrap_or_else(|error| panic!("{error}")); + import_sheet_internal(incoming).unwrap_or_else(|error| panic!("{error}")); + // Then: no old rule/claim survives; complete JS and CSS are the cold bytes. + assert_eq!(with_style_sheet(|sheet| sheet.properties.len()), 0); + assert_eq!(compile(), cold); + reset_build_state_internal(); +} + +#[rstest] +#[case("names")] +#[case("atom_plan")] +#[case("properties")] +#[case("css")] +#[case("keyframes")] +#[case("global_css_files")] +#[case("imports")] +#[case("font_faces")] +#[case("sourceIds")] +#[case("classMap")] +#[case("fileMap")] +#[serial] +fn current_snapshot_requires_even_empty_restore_fields(#[case] field: &str) { + // Given: independent cold output and a current snapshot with one missing field. + reset_build_state_internal(); + let cold = compile(); + let mut value = snapshot(); + value + .as_object_mut() + .unwrap_or_else(|| panic!("snapshot object")) + .remove(field); + reset_build_state_internal(); + // When: the partially written object reaches serialized admission. + assert_eq!(import(value), Ok(())); + // Then: no partial state survives and complete output is cold. + assert_eq!(with_style_sheet(|sheet| sheet.properties.len()), 0); + assert_eq!(compile(), cold); + reset_build_state_internal(); +} + +#[rstest] +#[case(serde_json::json!(null))] +#[case(serde_json::json!(42))] +#[case(serde_json::json!([]))] +#[case(serde_json::json!({"atomNamingVersion":"4"}))] +#[case(serde_json::json!({"atomNamingVersion":4,"properties":false}))] +#[serial] +fn malformed_typed_cache_objects_are_absent(#[case] value: serde_json::Value) { + // Given: a separately compiled cold fixture and malformed serialized data. + reset_build_state_internal(); + let cold = compile(); + reset_build_state_internal(); + // When: the typed deserializer receives the malformed value. + assert_eq!(import(value), Ok(())); + // Then: extraction and current rewrite remain usable and byte-exact. + assert_eq!(compile(), cold); + assert_eq!(snapshot()["atomNamingVersion"], 4); + reset_build_state_internal(); +} + +#[rstest] +#[case(false, false)] +#[case(false, true)] +#[case(true, false)] +#[case(true, true)] +#[serial] +fn companion_mismatch_undoes_only_cache_restore(#[case] before: bool, #[case] class_map: bool) { + // Given: a poisoned authoritative snapshot and genuine fresh D9 configuration. + reset_build_state_internal(); + seed_file_map(vec!["fresh.tsx".into()]); + compile(); + let mut value = snapshot(); + value["sourceIds"]["fresh.tsx"] = 777.into(); + value["fileMap"]["fresh.tsx"] = 888.into(); + value["classMap"] = serde_json::json!({"fresh.tsx":{"poison":999}}); + reset_build_state_internal(); + seed_file_map(vec!["configured.tsx".into()]); + let cold = compile(); + reset_build_state_internal(); + seed_file_map(vec!["configured.tsx".into()]); + let mismatch = || { + if class_map { + cache_restore::classes(Some(BTreeMap::new())); + } else { + cache_restore::files(Some(BTreeMap::new())); + } + }; + // When: an old/missing companion disagrees before or after sheet admission. + if before { + mismatch(); + } + assert_eq!(import(value), Ok(())); + if !before { + mismatch(); + } + // Then: cached IDs/counters/rules disappear, but prepass configuration remains. + assert_eq!( + css::file_map::get_original_ids(), + BTreeMap::from([("configured.tsx".into(), 0)]) + ); + assert_eq!(with_style_sheet(|sheet| sheet.properties.len()), 0); + assert_eq!(with_class_map(HashMap::len), 0); + assert_eq!(compile(), cold); + reset_build_state_internal(); +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn complete_current_restore_works_in_both_companion_orders(#[case] before: bool) { + // Given: a real current cache including original-owner counters and sparse variables. + reset_build_state_internal(); + seed_file_map(vec!["a.tsx".into(), "fresh.tsx".into()]); + let output = compile(); + let value = snapshot(); + let classes: sheet::cache_snapshot::ClassMap = + serde_json::from_value(value["classMap"].clone()).unwrap_or_else(|error| panic!("{error}")); + let files: sheet::cache_snapshot::FileMap = + serde_json::from_value(value["fileMap"].clone()).unwrap_or_else(|error| panic!("{error}")); + reset_build_state_internal(); + // When: only the complete sheet can authorize companion state in either order. + if before { + cache_restore::classes(Some(classes.clone())); + cache_restore::files(Some(files.clone())); + } + assert_eq!(import(value.clone()), Ok(())); + if !before { + cache_restore::classes(Some(classes)); + cache_restore::files(Some(files)); + } + // Then: the entire naming snapshot and full JS/CSS round trip exactly. + assert_eq!(snapshot(), value); + assert_eq!(compile(), output); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn companions_without_sheet_authority_never_allocate_poisoned_state() { + // Given: independent cold output and arbitrary legacy map data. + reset_build_state_internal(); + let cold = compile(); + reset_build_state_internal(); + // When: raw companions are imported without a sheet. + cache_restore::classes(Some(BTreeMap::from([( + "fresh.tsx".into(), + BTreeMap::from([("poison".into(), 999)]), + )]))); + cache_restore::files(Some(BTreeMap::from([("fresh.tsx".into(), 888)]))); + // Then: neither map becomes live state and complete output remains cold. + assert_eq!(css::file_map::get_file_map().len(), 0); + assert_eq!(with_class_map(HashMap::len), 0); + assert_eq!(compile(), cold); + reset_build_state_internal(); +} diff --git a/bindings/devup-ui-wasm/src/cache_v4_tests/collisions.rs b/bindings/devup-ui-wasm/src/cache_v4_tests/collisions.rs new file mode 100644 index 000000000..c3132cd3e --- /dev/null +++ b/bindings/devup-ui-wasm/src/cache_v4_tests/collisions.rs @@ -0,0 +1,107 @@ +use super::*; + +fn colored(file: &str, color: &str) -> Result { + code_extract_internal( + file, + &format!( + "import {{css}} from '@devup-ui/react';\nexport const x=css({{color:'{color}'}});" + ), + "@devup-ui/react", + "df".into(), + true, + false, + false, + HashMap::new(), + ) +} + +#[test] +#[serial] +fn current_exact_claim_conflict_is_atomic_located_and_sticky_until_reset() { + // Given: complete current blue proof and a live red atom from a real second site. + reset_build_state_internal(); + colored("cached.tsx", "blue").unwrap_or_else(|error| panic!("{error}")); + let mut value = snapshot(); + value["properties"] = serde_json::json!({}); + value["names"]["OLcolor-vred"] = value["names"]["OLcolor-vblue"].clone(); + reset_build_state_internal(); + colored("live.tsx", "red").unwrap_or_else(|error| panic!("{error}")); + let before = snapshot(); + // When: a structurally complete current claim conflicts with live exact bytes. + let error = import(value) + .err() + .unwrap_or_else(|| panic!("exact collision accepted")); + // Then: both best sites/contents are retained, and no state is partially imported. + for evidence in ["cached.tsx:2:27:", "live.tsx:2:27:", "`'blue'`", "`'red'`"] { + assert!(error.contains(evidence), "{error}"); + } + assert_eq!(snapshot(), before); + assert_eq!(import(serde_json::json!({})), Ok(())); + cache_restore::classes(None); + cache_restore::files(None); + assert_eq!(colored("later.tsx", "green").err(), Some(error.clone())); + assert_eq!(import(before), Ok(())); + assert_eq!(colored("later.tsx", "green").err(), Some(error)); + reset_build_state_internal(); + assert!(colored("later.tsx", "green").is_ok()); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn corrupt_referenced_atom_descriptor_is_a_cold_miss_not_a_collision() { + // Given: real generated output with a referenced descriptor corrupted in storage. + reset_build_state_internal(); + let cold = compile(); + let mut value = snapshot(); + let names = value["names"] + .as_object_mut() + .unwrap_or_else(|| panic!("claims")); + let claim = names + .values_mut() + .find(|claim| { + claim["descriptor"] + .as_array() + .is_some_and(|bytes| bytes.first() == Some(&1.into())) + }) + .unwrap_or_else(|| panic!("atom claim")); + claim["descriptor"] + .as_array_mut() + .unwrap_or_else(|| panic!("descriptor bytes")) + .push(99.into()); + reset_build_state_internal(); + // When: structural proof validation precedes any state mutation. + assert_eq!(import(value), Ok(())); + // Then: nothing is adopted and full output is independently cold. + assert_eq!(with_style_sheet(|sheet| sheet.names.len()), 0); + assert_eq!(compile(), cold); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn manual_in_memory_names_remain_trusted_without_serialized_generation() { + // Given: a freshly constructed manual sheet, not generation-less JSON. + reset_build_state_internal(); + let mut sheet = StyleSheet::default(); + sheet.add_property( + "manual-card", + "color", + 0, + "red", + None, + None, + Some("card.tsx"), + ); + // When: the existing in-memory API imports explicit construction. + assert_eq!(import_sheet_internal(sheet), Ok(())); + // Then: the authored manual class is still usable in emitted CSS. + assert!( + with_style_sheet(|sheet| sheet.create_css(Some("card.tsx"), false)).contains(concat!( + ".manual-card{", + "color:red", + "}" + )) + ); + reset_build_state_internal(); +} diff --git a/bindings/devup-ui-wasm/src/cache_v4_tests/configuration.rs b/bindings/devup-ui-wasm/src/cache_v4_tests/configuration.rs new file mode 100644 index 000000000..03f4bc0f5 --- /dev/null +++ b/bindings/devup-ui-wasm/src/cache_v4_tests/configuration.rs @@ -0,0 +1,54 @@ +use super::*; + +#[test] +#[serial] +fn late_companion_mismatch_preserves_fresh_configuration_and_prepass() { + // Given: restored cache followed by legitimate fresh names-only and routing inputs. + reset_build_state_internal(); + compile(); + let value = snapshot(); + reset_build_state_internal(); + assert_eq!(import(value), Ok(())); + set_prefix(Some("du-FLa-".into())); + set_naming_root(Some("/checkout".into()), Some("/checkout".into())); + set_debug(true); + register_theme_internal( + serde_json::from_value(serde_json::json!({"colors":{"default":{"primary":"#123456"}}})) + .unwrap_or_else(|error| panic!("{error}")), + ); + register_shorthands_internal(BTreeMap::from([( + "insetX".into(), + vec!["left".into(), "right".into()], + )])); + let theme_css = with_style_sheet(|sheet| sheet.theme.to_css()); + import_canonical_map_internal(HashMap::from([("child.tsx".into(), "bucket.tsx".into())])); + import_file_routes_internal(HashMap::from([( + "child.tsx".into(), + std::collections::HashSet::from([1, 2]), + )])); + set_atom_hoist(Some(2)); + seed_file_map(vec!["child.tsx".into()]); + // When: malformed companion data invalidates only authoritative cached state. + cache_restore::classes(None); + // Then: fresh prefix/root/debug/canonical/routes/hoist/D9 inputs remain in effect. + assert_eq!(get_prefix(), Some("du-FLa-".into())); + assert_eq!(css::naming_root::key("/checkout/src/a.tsx"), "src/a.tsx"); + assert!(is_debug()); + assert_eq!(css::file_map::canonical("child.tsx"), "bucket.tsx"); + assert_eq!(css::file_map::original_id("child.tsx"), Some(0)); + assert_eq!( + css::file_map::get_file_map().get_by_left("bucket.tsx"), + Some(&0) + ); + assert!(css::atom_hoist::is_hoisted_bucket("child.tsx")); + assert_eq!(with_style_sheet(|sheet| sheet.properties.len()), 0); + assert_eq!(with_style_sheet(|sheet| sheet.theme.to_css()), theme_css); + assert_eq!( + css::get_custom_shorthand_names(), + vec!["insetX".to_string()] + ); + register_shorthands_internal(BTreeMap::new()); + register_theme_internal(sheet::theme::Theme::default()); + set_debug(false); + reset_build_state_internal(); +} diff --git a/bindings/devup-ui-wasm/src/cache_v4_tests/special.rs b/bindings/devup-ui-wasm/src/cache_v4_tests/special.rs new file mode 100644 index 000000000..eba06a229 --- /dev/null +++ b/bindings/devup-ui-wasm/src/cache_v4_tests/special.rs @@ -0,0 +1,140 @@ +use super::*; + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn current_typography_and_keyframe_proofs_round_trip_or_go_cold(#[case] corrupt: bool) { + // Given: real responsive typography and keyframes with deliberately nonlexical step order. + reset_build_state_internal(); + register_theme_internal( + serde_json::from_value(serde_json::json!({ + "typography":{"body":[{"fontSize":"14px","fontFamily":"Sans"},null,{"fontSize":"18px"}]} + })) + .unwrap_or_else(|error| panic!("{error}")), + ); + let source = "import {Box,keyframes} from '@devup-ui/react';export const k=keyframes({to:{opacity:1},from:{opacity:0}});export const x=;"; + let output = code_extract_internal( + "special.tsx", + source, + "@devup-ui/react", + "df".into(), + false, + false, + false, + HashMap::new(), + ) + .unwrap_or_else(|error| panic!("{error}")); + let cold_css = with_style_sheet(|sheet| sheet.create_css(Some("special.tsx"), false)); + let mut value = snapshot(); + assert!(with_style_sheet(|sheet| sheet + .properties + .values() + .flat_map(|orders| orders.values()) + .flat_map(|levels| levels.values()) + .flatten() + .any(|property| property.typography))); + if corrupt { + let claim = value["names"] + .as_object_mut() + .unwrap_or_else(|| panic!("claims")) + .iter_mut() + .find(|(name, _)| name.starts_with('K')) + .unwrap_or_else(|| panic!("keyframes proof")) + .1; + claim["descriptor"] + .as_array_mut() + .unwrap_or_else(|| panic!("descriptor bytes")) + .push(99.into()); + } + reset_build_state_internal(); + // When: complete current data is admitted without changing fresh theme configuration. + assert_eq!(import(value), Ok(())); + // Then: valid cached output is preserved, but corrupted proof adopts nothing. + if corrupt { + assert_eq!(with_style_sheet(|sheet| sheet.properties.len()), 0); + } else { + assert_eq!( + with_style_sheet(|sheet| sheet.create_css(Some("special.tsx"), false)), + cold_css + ); + } + let fresh = code_extract_internal( + "special.tsx", + source, + "@devup-ui/react", + "df".into(), + false, + false, + false, + HashMap::new(), + ) + .unwrap_or_else(|error| panic!("{error}")); + assert_eq!(fresh.code(), output.code()); + assert_eq!( + with_style_sheet(|sheet| sheet.create_css(Some("special.tsx"), false)), + cold_css + ); + register_theme_internal(sheet::theme::Theme::default()); + reset_build_state_internal(); +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn validated_companions_do_not_block_a_later_complete_snapshot(#[case] before: bool) { + // Given: a previously admitted cache whose companions have been validated. + reset_build_state_internal(); + seed_file_map(vec!["fresh.tsx".into()]); + compile(); + let initial = snapshot(); + reset_build_state_internal(); + assert_eq!(import(initial.clone()), Ok(())); + cache_restore::classes(Some( + serde_json::from_value(initial["classMap"].clone()) + .unwrap_or_else(|error| panic!("{error}")), + )); + cache_restore::files(Some( + serde_json::from_value(initial["fileMap"].clone()) + .unwrap_or_else(|error| panic!("{error}")), + )); + seed_file_map(vec!["later.tsx".into()]); + code_extract_internal( + "later.tsx", + "import {Box} from '@devup-ui/react';export const x=;", + "@devup-ui/react", + "df".into(), + false, + false, + false, + HashMap::new(), + ) + .unwrap_or_else(|error| panic!("{error}")); + let later = snapshot(); + let classes = || { + Some( + serde_json::from_value(later["classMap"].clone()) + .unwrap_or_else(|error| panic!("{error}")), + ) + }; + let files = || { + Some( + serde_json::from_value(later["fileMap"].clone()) + .unwrap_or_else(|error| panic!("{error}")), + ) + }; + // When: existing APIs restore the next complete snapshot in either order. + if before { + cache_restore::classes(classes()); + cache_restore::files(files()); + } + assert_eq!(import(later.clone()), Ok(())); + if !before { + cache_restore::classes(classes()); + cache_restore::files(files()); + } + // Then: no old companion observation vetoes or partially restores the new snapshot. + assert_eq!(snapshot(), later); + reset_build_state_internal(); +} diff --git a/bindings/devup-ui-wasm/src/compact_source_tests.rs b/bindings/devup-ui-wasm/src/compact_source_tests.rs new file mode 100644 index 000000000..38179e308 --- /dev/null +++ b/bindings/devup-ui-wasm/src/compact_source_tests.rs @@ -0,0 +1,143 @@ +use super::*; +use rstest::rstest; +use serial_test::serial; + +mod cache; + +fn compile(file: &str, source: &str) -> Result { + code_extract_internal( + file, + source, + "@devup-ui/react", + "df".into(), + true, + false, + false, + HashMap::new(), + ) +} + +fn fresh() { + reset_build_state_internal(); + css::debug::set_debug(false); + register_theme_internal(sheet::theme::Theme::default()); +} + +#[test] +#[serial] +fn full_output_repeats_when_100_kb_unnumbered_source_runs_in_fresh_builds() { + // Given: 100 KB of comments before multiple dynamic assignments. + let source = format!( + "{}import {{Box}} from '@devup-ui/react';\nexport const View=(p)=>;", + "// filler\n".repeat(10_240) + ); + let run = || { + fresh(); + let output = compile("/real/large.tsx", &source).unwrap_or_else(|error| panic!("{error}")); + let css = with_style_sheet(|sheet| sheet.create_css(None, false)); + let names = with_style_sheet(|sheet| sheet.names.clone()); + ( + output.code, + output.css, + output.css_file, + output.map, + output.updated_base_style, + output.dependencies, + css, + names, + ) + }; + // When: the same received source runs twice without retained compiler state. + let first = run(); + let second = run(); + // Then: complete generated output and claims repeat, with bounded emitted names. + assert_eq!(first, second); + let variables: Vec<_> = first + .0 + .split('"') + .filter(|text| text.starts_with("---SUH")) + .collect(); + assert_eq!(variables.len(), 2); + assert!(variables.iter().all(|name| name.len() <= 50)); + assert!(variables.iter().all(|name| first.6.contains(name))); + let source_claims: Vec<_> = first + .7 + .values() + .filter(|claim| claim.content == source) + .collect(); + assert_eq!(source_claims.len(), 1); + assert_eq!(source_claims[0].descriptor, source.as_bytes()); + reset_build_state_internal(); +} + +#[rstest] +#[case("/real/a.tsx", "/real/b.tsx")] +#[case("/real/b.tsx", "/real/a.tsx")] +#[serial] +fn generated_output_ignores_filenames_when_received_source_is_identical( + #[case] first: &str, + #[case] second: &str, +) { + // Given: an unnumbered source with distinct source-ordered responsive roles. + let source = "import {Box} from '@devup-ui/react';\nexport const View=(p)=>;"; + fresh(); + let before = compile(first, source) + .unwrap_or_else(|error| panic!("{error}")) + .code(); + let css_before = with_style_sheet(|sheet| sheet.create_css(None, false)); + // When: another real filename supplies exactly the same received source. + let after = compile(second, source) + .unwrap_or_else(|error| panic!("{error}")) + .code(); + // Then: names and whole compiled code agree, and no duplicate CSS is emitted. + assert_eq!(after, before); + assert_eq!( + with_style_sheet(|sheet| sheet.create_css(None, false)), + css_before + ); + let variables: std::collections::BTreeSet<_> = after + .split('"') + .filter(|text| text.starts_with("---SUH")) + .collect(); + assert_eq!(variables.len(), 3); + reset_build_state_internal(); +} + +#[rstest] +#[case("")] +#[case("Mixed-SUH-prefix-")] +#[serial] +fn full_generated_output_agrees_when_received_source_uses_lf_crlf_or_bom(#[case] prefix: &str) { + // Given: equivalent received text with runtime branches and responsive roles. + let lf = "import {Box} from '@devup-ui/react';\nexport const View=(p)=>;\n"; + let sources = [ + lf.to_string(), + lf.replace('\n', "\r\n"), + format!("\u{feff}{lf}"), + format!("\u{feff}{}", lf.replace('\n', "\r\n")), + ]; + // When: each normalization variant runs through the actual native binding path. + let outputs: Vec<_> = sources + .iter() + .map(|source| { + fresh(); + css::set_prefix(Some(prefix.into())); + let output = + compile("/real/normalized.tsx", source).unwrap_or_else(|error| panic!("{error}")); + ( + output.code, + output.css, + output.css_file, + output.map, + output.updated_base_style, + output.dependencies, + with_style_sheet(|sheet| sheet.create_css(None, false)), + ) + }) + .collect(); + // Then: full generated JS/CSS/output metadata are byte-identical, not just names. + for output in &outputs[1..] { + assert_eq!(output, &outputs[0]); + } + reset_build_state_internal(); +} diff --git a/bindings/devup-ui-wasm/src/compact_source_tests/cache.rs b/bindings/devup-ui-wasm/src/compact_source_tests/cache.rs new file mode 100644 index 000000000..f9beb2f23 --- /dev/null +++ b/bindings/devup-ui-wasm/src/compact_source_tests/cache.rs @@ -0,0 +1,197 @@ +use super::*; + +const SOURCE: &str = + "import {Box} from '@devup-ui/react';\nexport const View=(p)=>;"; + +#[rstest] +#[case("'---SULmissing-b'")] +#[case("'var(---SUHaaaaaaaaaaaaaaaa-a)'")] +#[serial] +fn literal_css_content_roundtrips_when_it_resembles_a_generated_source_variable( + #[case] content: &str, +) { + // Given: a literal CSS string, not a generated variable consumer or reset. + fresh(); + let literal = serde_json::to_string(content).unwrap_or_else(|error| panic!("{error}")); + let source = format!( + "import {{css}} from '@devup-ui/react';export const cls=css({{content:{literal}}});" + ); + compile("/real/content.ts", &source).unwrap_or_else(|error| panic!("{error}")); + let snapshot = export_sheet_internal().unwrap_or_else(|error| panic!("{error}")); + let cached = serde_json::from_str(&snapshot).unwrap_or_else(|error| panic!("{error}")); + fresh(); + // When: actual sheet cache import validates generated source claims. + let restored = import_sheet_internal(cached); + // Then: source-like text in literal content remains a valid static declaration. + assert_eq!(restored, Ok(())); + assert_eq!( + export_sheet_internal().unwrap_or_else(|error| panic!("{error}")), + snapshot + ); + reset_build_state_internal(); +} + +#[rstest] +#[case("")] +#[case("Mixed---SUH-한😀-")] +#[serial] +fn source_claims_roundtrip_when_cached_sheet_is_imported_into_a_fresh_build(#[case] prefix: &str) { + // Given: a complete cache with exact full-source claims. + fresh(); + css::set_prefix(Some(prefix.into())); + let before = compile("/real/a.tsx", SOURCE) + .unwrap_or_else(|error| panic!("{error}")) + .code(); + let css_before = with_style_sheet(|sheet| sheet.create_css(None, false)); + let snapshot = export_sheet_internal().unwrap_or_else(|error| panic!("{error}")); + let imported: StyleSheet = + serde_json::from_str(&snapshot).unwrap_or_else(|error| panic!("{error}")); + let source_claims: BTreeMap<_, _> = imported + .names + .iter() + .filter(|(name, _)| name.starts_with("---")) + .map(|(name, claim)| (name.clone(), claim.clone())) + .collect(); + assert_eq!(source_claims.len(), 1); + fresh(); + css::set_prefix(Some(prefix.into())); + // When: the cache is restored and identical text comes from another real file. + import_sheet_internal(imported).unwrap_or_else(|error| panic!("{error}")); + let after = compile("/real/z.tsx", SOURCE) + .unwrap_or_else(|error| panic!("{error}")) + .code(); + // Then: names, complete JS/CSS and exact source claims remain unchanged. + assert_eq!(after, before); + assert_eq!( + with_style_sheet(|sheet| sheet.create_css(None, false)), + css_before + ); + with_style_sheet(|sheet| { + for (name, claim) in source_claims { + assert_eq!(sheet.names[&name], claim); + } + }); + reset_build_state_internal(); +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn missing_source_claim_metadata_restores_nothing_and_matches_cold_output( + #[case] retain_reset: bool, +) { + // Given: all atom claims exist, but the exact source claim is missing. + fresh(); + let cold = compile("/real/a.tsx", SOURCE).unwrap_or_else(|error| panic!("{error}")); + let cold_css = with_style_sheet(|sheet| sheet.create_css(None, false)); + let snapshot = export_sheet_internal().unwrap_or_else(|error| panic!("{error}")); + let mut incomplete: StyleSheet = + serde_json::from_str(&snapshot).unwrap_or_else(|error| panic!("{error}")); + incomplete + .names + .retain(|name, _| !name.starts_with("---SUH")); + if !retain_reset { + for orders in incomplete.properties.values_mut() { + for levels in orders.values_mut() { + for properties in levels.values_mut() { + properties.retain(|property| !property.owner_reset); + } + } + } + } + fresh(); + // When: a current snapshot lacks source proof but retains valid atom proof. + assert_eq!(import_sheet_internal(incomplete), Ok(())); + // Then: no cached source/atom alias survives and complete output is cold. + assert_eq!(with_style_sheet(|sheet| sheet.names.len()), 0); + assert_eq!(css::file_map::get_original_ids(), BTreeMap::new()); + let next = compile("/real/a.tsx", SOURCE).unwrap_or_else(|error| panic!("{error}")); + assert_eq!(next.code(), cold.code()); + assert_eq!( + with_style_sheet(|sheet| sheet.create_css(None, false)), + cold_css + ); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn unequal_cached_source_claims_report_both_real_files_before_replacing_the_sheet() { + // Given: an existing exact source claim and a different full source under its name. + fresh(); + compile("/real/first.tsx", SOURCE).unwrap_or_else(|error| panic!("{error}")); + let snapshot = export_sheet_internal().unwrap_or_else(|error| panic!("{error}")); + let cached: StyleSheet = + serde_json::from_str(&snapshot).unwrap_or_else(|error| panic!("{error}")); + let (name, mut claim) = cached + .names + .into_iter() + .find(|(name, _)| name.starts_with("---SUH")) + .unwrap_or_else(|| panic!("source claim")); + let other = format!("{SOURCE}\n// another received source"); + claim.content.clone_from(&other); + claim.descriptor = other.as_bytes().to_vec(); + let css::style_origin::RealLocation::Exact(origin) = &mut claim.location else { + panic!("real witness") + }; + origin.file = "/real/second.tsx".into(); + claim.origin = Some(origin.clone()); + let mut incoming = StyleSheet::default(); + incoming.names.insert(name, claim); + // When: importing overlapping claims without any atom descriptor difference. + let error = import_sheet_internal(incoming) + .err() + .unwrap_or_else(|| panic!("unequal exact source")); + // Then: both real witnesses/full sources are printed and the old sheet survives. + assert!(error.contains("/real/first.tsx:2:"), "{error}"); + assert!(error.contains("/real/second.tsx:2:"), "{error}"); + assert!(error.contains(SOURCE), "{error}"); + assert!(error.contains(&other), "{error}"); + assert_eq!( + export_sheet_internal().unwrap_or_else(|error| panic!("{error}")), + snapshot + ); + reset_build_state_internal(); +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn corrupt_source_proof_restores_nothing_even_when_the_named_entry_exists( + #[case] corrupt_content: bool, +) { + // Given: a source variable whose claim exists but has inconsistent proof bytes. + fresh(); + let cold = compile("/real/a.tsx", SOURCE).unwrap_or_else(|error| panic!("{error}")); + let cold_css = with_style_sheet(|sheet| sheet.create_css(None, false)); + let snapshot = export_sheet_internal().unwrap_or_else(|error| panic!("{error}")); + let mut corrupt: StyleSheet = + serde_json::from_str(&snapshot).unwrap_or_else(|error| panic!("{error}")); + let claim = corrupt + .names + .iter_mut() + .find(|(name, _)| name.starts_with("---SUH")) + .unwrap_or_else(|| panic!("source claim")) + .1; + if corrupt_content { + claim.content.push_str("\n// forged source"); + claim.descriptor = claim.content.as_bytes().to_vec(); + } else { + claim.descriptor.push(0); + } + fresh(); + // When: source proof and content-only naming disagree at admission. + assert_eq!(import_sheet_internal(corrupt), Ok(())); + // Then: existence alone cannot adopt the original alias or any cached atom. + assert_eq!(with_style_sheet(|sheet| sheet.names.len()), 0); + assert_eq!(css::file_map::get_original_ids(), BTreeMap::new()); + let next = compile("/real/a.tsx", SOURCE).unwrap_or_else(|error| panic!("{error}")); + assert_eq!(next.code(), cold.code()); + assert_eq!( + with_style_sheet(|sheet| sheet.create_css(None, false)), + cold_css + ); + reset_build_state_internal(); +} diff --git a/bindings/devup-ui-wasm/src/content_location_contract_tests.rs b/bindings/devup-ui-wasm/src/content_location_contract_tests.rs new file mode 100644 index 000000000..532721788 --- /dev/null +++ b/bindings/devup-ui-wasm/src/content_location_contract_tests.rs @@ -0,0 +1,265 @@ +use super::*; +use css::{content_hash::FingerprintBits, style_origin::RealLocation}; +use serial_test::serial; +use std::fmt::Write; + +fn extract(file: &str, source: &str) -> extractor::ExtractOutput { + extractor::extract( + file, + source, + extractor::ExtractOption { + single_css: true, + ..Default::default() + }, + ) + .unwrap_or_else(|error| panic!("{error}")) +} + +#[test] +#[serial] +fn pair_keeps_distinct_content_names_when_native_calls_share_one_boa_position() { + // Given + reset_build_state_internal(); + let source = "import {style} from '@devup-ui/react';\nfunction pair(a,b){return [a,b];}\nexport const pairResult=pair(style({color:'red'}),style({color:'blue'}));"; + // When + let output = code_extract_internal( + "pair.css.ts", + source, + "@devup-ui/react", + "df".into(), + true, + false, + false, + HashMap::new(), + ) + .unwrap_or_else(|error| panic!("{error}")); + // Then + assert!(output.code().contains("OLcolor-vred"), "{}", output.code()); + assert!(output.code().contains("OLcolor-vblue"), "{}", output.code()); + with_style_sheet(|sheet| assert_eq!(sheet.names.len(), 2)); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn resolver_direct_and_opaque_factories_keep_identical_names_when_environment_order_reverses() { + // Given + let source = "import {style} from '@devup-ui/react';import {make} from './factory';export const cls=make({fontFamily:'abcdefghijklmnopqrstuvwx'});"; + let bodies = [ + "import {style} from '@devup-ui/react';export const make=(rule)=>style(rule);", + "import {style} from '@devup-ui/react';export const make=(rule)=>((factory)=>factory)(style)(rule);", + ]; + // When + let mut outputs = Vec::new(); + for order in [[0, 1], [1, 0]] { + reset_build_state_internal(); + let mut results = BTreeMap::new(); + for index in order { + let resolver = move |specifier: &str, _: &str| { + (specifier == "./factory").then(|| ResolvedModule { + path: "factory.ts".into(), + code: bodies[index].into(), + }) + }; + let output = code_extract_with_modules_internal( + "resolver.css.ts", + source, + "@devup-ui/react", + "df".into(), + true, + false, + false, + HashMap::new(), + &resolver, + ) + .unwrap_or_else(|error| panic!("{error}")); + results.insert(index, output.code().clone()); + } + outputs.push(results); + } + // Then + assert_eq!(outputs[0], outputs[1]); + assert_eq!(outputs[0][&0], outputs[0][&1]); + with_style_sheet(|sheet| assert_eq!(sheet.names.len(), 1)); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn opaque_factory_preserves_this_arguments_and_direct_eval_when_called_through_a_helper() { + // Given + reset_build_state_internal(); + let source = "import {style} from '@devup-ui/react';const adapt=(factory)=>function(value){if(this.tag!==value||arguments.length!==1)throw new Error('changed call');return factory({fontFamily:eval('value')});};const make=adapt(style);export const cls=make.call({tag:'abcdefghijklmnopqrstuvwx'},'abcdefghijklmnopqrstuvwx');"; + // When + let output = code_extract_internal( + "semantics.css.ts", + source, + "@devup-ui/react", + "df".into(), + true, + false, + false, + HashMap::new(), + ) + .unwrap_or_else(|error| panic!("{error}")); + // Then + assert!( + !output.code().contains("__devup_style_origin"), + "{}", + output.code() + ); + with_style_sheet(|sheet| { + assert!( + sheet + .names + .values() + .any(|claim| claim.content.contains("abcdefghijklmnopqrstuvwx")), + "{:?}", + sheet.names + ); + }); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn forced_fingerprint_collision_reports_each_real_location_tier_when_width_is_an_input() { + // Given + let bits = FingerprintBits::new(1).unwrap_or_else(|| panic!("valid width")); + let modes = [("style", "OH"), ("keyframes", "KH")] + .into_iter() + .flat_map(|(factory, domain)| { + ["exact", "call", "export"].map(|mode| (factory, domain, mode)) + }); + // When / Then + for (factory, domain, mode) in modes { + reset_build_state_internal(); + let mut source = format!( + "import {{{factory}}} from '@devup-ui/react';\nconst identity=(factory)=>factory;const native=identity({factory});\n" + ); + for index in 0..3 { + let declaration = format!("{{fontFamily:'abcdefghijklmnopqrstuvwx{index}'}}"); + let rule = if factory == "keyframes" { + format!("{{from:{declaration}}}") + } else { + declaration + }; + let call = match mode { + "exact" => format!("{factory}({rule})"), + "call" => format!("identity({factory})({rule})"), + "export" => format!( + "eval({})", + serde_json::to_string(&format!("native({rule})")) + .unwrap_or_else(|error| panic!("{error}")) + ), + _ => unreachable!(), + }; + writeln!( + source, + "const local{index}={call};export {{local{index} as actual{index}}};" + ) + .unwrap_or_else(|error| panic!("{error}")); + } + let output = extract("tiers.css.ts", &source); + let sheet = StyleSheet::default(); + let error = sheet + .preflight_styles_with_bits(&output.styles, ("tiers.css.ts", true), bits) + .err() + .unwrap_or_else(|| panic!("three distinct atoms must collide in one bit")); + assert_eq!(sheet.names.len(), 0); + assert_ne!(error.first.descriptor, error.second.descriptor); + assert!(error.name.starts_with(domain), "{error}"); + let text = error.to_string(); + for claim in [&error.first, &error.second] { + assert!(text.contains(&claim.content), "{text}"); + match (&claim.location, mode) { + (RealLocation::Exact(origin), "exact") + | (RealLocation::ProducedByCall(origin), "call") => { + let offset = source + .find(&origin.expression) + .unwrap_or_else(|| panic!("not authored: {origin:?}")); + let before = &source[..offset]; + assert_eq!(origin.file, "tiers.css.ts"); + assert_eq!( + origin.line, + before.bytes().filter(|byte| *byte == b'\n').count() + 1 + ); + assert_eq!( + origin.column, + before + .rsplit('\n') + .next() + .unwrap_or_default() + .chars() + .count() + + 1 + ); + } + ( + RealLocation::ModuleExport { + file, + binding: Some(binding), + }, + "export", + ) => { + assert_eq!(file, "tiers.css.ts"); + assert!(binding.starts_with("actual"), "{binding}"); + assert!(source.contains(&format!("as {binding}")), "{binding}"); + assert!( + text.contains("position unavailable (produced inside eval or a callback)"), + "{text}" + ); + } + _ => panic!("wrong tier for {mode}: {claim:?}"), + } + } + assert!(text.contains("change one declaration"), "{text}"); + if mode == "call" { + assert!( + text.contains("produced by the call at tiers.css.ts:"), + "{text}" + ); + } + } + reset_build_state_internal(); +} + +#[test] +#[serial] +fn cached_opaque_claims_still_reject_exact_collisions_when_no_witness_exists() { + // Given + reset_build_state_internal(); + let bits = FingerprintBits::new(1).unwrap_or_else(|| panic!("valid width")); + let mut sheet = StyleSheet::default(); + let mut collision = None; + // When + for index in 0..3 { + let file = format!("cached{index}.css.ts"); + let source = format!( + "import {{style}} from '@devup-ui/react';const native=style;export const actual=eval(\"native({{fontFamily:'abcdefghijklmnopqrstuvwx{index}'}})\");" + ); + let output = extract(&file, &source); + match sheet.preflight_styles_with_bits(&output.styles, (&file, true), bits) { + Ok(claims) => { + sheet.names.extend(claims); + let json = serde_json::to_string(&sheet.export_snapshot()) + .unwrap_or_else(|error| panic!("{error}")); + sheet = serde_json::from_str(&json).unwrap_or_else(|error| panic!("{error}")); + } + Err(error) => { + collision = Some(error); + break; + } + } + } + // Then + let error = collision.unwrap_or_else(|| panic!("one-bit collision was not detected")); + for claim in [&error.first, &error.second] { + assert_eq!(claim.origin, None); + assert!( + matches!(&claim.location, RealLocation::ModuleExport { binding: Some(binding), .. } if binding == "actual"), + "{claim:?}" + ); + } + reset_build_state_internal(); +} diff --git a/bindings/devup-ui-wasm/src/content_name_tests.rs b/bindings/devup-ui-wasm/src/content_name_tests.rs new file mode 100644 index 000000000..a0338ad7f --- /dev/null +++ b/bindings/devup-ui-wasm/src/content_name_tests.rs @@ -0,0 +1,257 @@ +use super::*; +use serial_test::serial; + +fn compile(file: &str, source: &str) -> Result { + code_extract_internal( + file, + source, + "@devup-ui/react", + "df".into(), + true, + false, + false, + HashMap::new(), + ) +} + +#[test] +#[serial] +fn public_extraction_uses_fixed_short_and_first80_descriptor_goldens() { + // Given + reset_build_state_internal(); + css::debug::set_debug(false); + let source = "import {css} from '@devup-ui/react'; export const x=css({color:'red',fontFamily:'abcdefghijklmnopqrstuvwx'});"; + let mut bytes = vec![1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 11]; + bytes.extend_from_slice(b"font-family"); + bytes.extend_from_slice(&[1, 0, 0, 0, 0, 0, 0, 0, 24]); + bytes.extend_from_slice(b"abcdefghijklmnopqrstuvwx"); + bytes.extend_from_slice(&[0, 255, 0, 0]); + // When + let output = compile("golden.tsx", source).unwrap_or_else(|error| panic!("{error}")); + // Then + let expected = format!("OH{}", css::content_hash::fingerprint(&bytes)); + assert!(output.code().contains("OLcolor-vred"), "{}", output.code()); + assert!(output.code().contains(&expected), "{}", output.code()); + with_style_sheet(|sheet| assert_eq!(sheet.names[&expected].descriptor, bytes)); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn restored_forced_collision_reports_both_actual_sites_before_global_css_removal() { + // Given + reset_build_state_internal(); + css::debug::set_debug(false); + compile( + "a.tsx", + "import {css} from '@devup-ui/react';\nexport const x=css({color:'blue'});", + ) + .unwrap_or_else(|error| panic!("{error}")); + compile( + "b.tsx", + "import {globalCss} from '@devup-ui/react'; globalCss({body:{padding:'11px'}});", + ) + .unwrap_or_else(|error| panic!("{error}")); + let serialized = export_sheet_internal().unwrap_or_else(|error| panic!("{error}")); + let mut forced: StyleSheet = + serde_json::from_str(&serialized).unwrap_or_else(|error| panic!("{error}")); + let original = forced.names["OLcolor-vblue"].clone(); + forced.names.insert("OLcolor-vred".into(), original); + import_sheet_internal(forced).unwrap_or_else(|error| panic!("{error}")); + let before = export_sheet_internal().unwrap_or_else(|error| panic!("{error}")); + let css_before = with_style_sheet(|sheet| sheet.create_css(None, false)); + // When + let error = compile( + "b.tsx", + "import {css} from '@devup-ui/react';\nexport const x=css({color:'red'});", + ) + .err() + .unwrap_or_else(|| panic!("forced collision was accepted")); + // Then + assert!(error.contains("a.tsx:2:27:"), "{error}"); + assert!(error.contains("b.tsx:2:27:"), "{error}"); + assert!(error.contains("`'blue'`"), "{error}"); + assert!(error.contains("`'red'`"), "{error}"); + assert_eq!( + export_sheet_internal().unwrap_or_else(|error| panic!("{error}")), + before + ); + assert_eq!( + with_style_sheet(|sheet| sheet.create_css(None, false)), + css_before + ); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn sheet_import_merges_exact_claims_and_rejects_unequal_claims_atomically() { + // Given + reset_build_state_internal(); + css::debug::set_debug(false); + compile( + "b.tsx", + "import {css} from '@devup-ui/react'; export const x=css({color:'red'});", + ) + .unwrap_or_else(|error| panic!("{error}")); + let before = export_sheet_internal().unwrap_or_else(|error| panic!("{error}")); + let original: StyleSheet = + serde_json::from_str(&before).unwrap_or_else(|error| panic!("{error}")); + let mut incoming = StyleSheet::default(); + incoming.names = original.names; + incoming + .names + .get_mut("OLcolor-vred") + .unwrap_or_else(|| panic!("missing claim")) + .descriptor + .push(99); + // When + let rejected = import_sheet_internal(incoming); + // Then + assert!(rejected.is_err()); + assert_eq!( + export_sheet_internal().unwrap_or_else(|error| panic!("{error}")), + before + ); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn missing_cached_atom_claim_restores_no_state_and_produces_cold_bytes() { + // Given + reset_build_state_internal(); + css::debug::set_debug(false); + let cold = compile( + "source.tsx", + "import {css} from '@devup-ui/react';export const x=css({color:'red'});", + ) + .unwrap_or_else(|error| panic!("{error}")); + let cold_css = with_style_sheet(|sheet| sheet.create_css(None, false)); + let snapshot = export_sheet_internal().unwrap_or_else(|error| panic!("{error}")); + let mut incomplete: StyleSheet = + serde_json::from_str(&snapshot).unwrap_or_else(|error| panic!("{error}")); + incomplete.names.clear(); + reset_build_state_internal(); + // When + assert_eq!(import_sheet_internal(incomplete), Ok(())); + // Then + assert_eq!(with_style_sheet(|sheet| sheet.names.len()), 0); + let next = compile( + "source.tsx", + "import {css} from '@devup-ui/react';export const x=css({color:'red'});", + ) + .unwrap_or_else(|error| panic!("{error}")); + assert_eq!(next.code(), cold.code()); + assert_eq!( + with_style_sheet(|sheet| sheet.create_css(None, false)), + cold_css + ); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn evaluated_helper_composition_and_keyframes_keep_factory_call_sites() { + // Given + reset_build_state_internal(); + css::debug::set_debug(false); + let source = "import {style,keyframes} from '@devup-ui/react';\nconst make=()=>style({color:'red'});\nconst base=make();\nexport const combined=style([base,{padding:'4px'}]);\nexport const fade=keyframes({from:{opacity:0},to:{opacity:1}});"; + // When + let output = compile("actual.css.ts", source).unwrap_or_else(|error| panic!("{error}")); + // Then + assert!( + !output.code().contains("__devup_style_origin"), + "{}", + output.code() + ); + assert!(!output.code().contains("__origin"), "{}", output.code()); + with_style_sheet(|sheet| { + assert!( + sheet + .names + .values() + .any(|claim| claim + .origin + .as_ref() + .is_some_and(|origin| origin.file == "actual.css.ts" + && origin.line == 2 + && origin.expression == "style({color:'red'})")) + ); + assert!( + sheet + .names + .values() + .any(|claim| claim + .origin + .as_ref() + .is_some_and(|origin| origin.file == "actual.css.ts" + && origin.line == 5 + && origin.expression == "keyframes({from:{opacity:0},to:{opacity:1}})")) + ); + assert!(sheet.names.values().all(|claim| claim.origin.is_some())); + }); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn imported_stylesheet_helpers_keep_the_actual_foreign_factory_site() { + // Given + reset_build_state_internal(); + css::debug::set_debug(false); + let source = "import {style} from '@devup-ui/react';import {make} from './helper';export const result=make();"; + let resolver = |specifier: &str, _: &str| { + (specifier == "./helper").then(|| ResolvedModule { + path: "helper.ts".into(), + code: "import {style} from '@devup-ui/react';\nexport const make=()=>style({color:'red'});".into(), + }) + }; + // When + let output = code_extract_with_modules_internal( + "root.css.ts", + source, + "@devup-ui/react", + "df".into(), + true, + false, + false, + HashMap::new(), + &resolver, + ) + .unwrap_or_else(|error| panic!("{error}")); + // Then + assert!(!output.code().contains("__origin"), "{}", output.code()); + with_style_sheet(|sheet| { + assert!( + sheet + .names + .values() + .any(|claim| claim + .origin + .as_ref() + .is_some_and(|origin| origin.file == "helper.ts" + && origin.line == 2 + && origin.expression == "style({color:'red'})")) + ); + }); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn fingerprint_claim_has_a_real_producer_when_factory_flows_through_a_function() { + // Given + reset_build_state_internal(); + css::debug::set_debug(false); + let source = "import {style} from '@devup-ui/react';const identity=(factory)=>factory;export const cls=identity(style)({fontFamily:'abcdefghijklmnopqrstuvwx'});"; + // When + compile("higher-order.css.ts", source).unwrap_or_else(|error| panic!("{error}")); + // Then + with_style_sheet(|sheet| { + assert!(sheet.names.values().all(|claim| matches!(&claim.location, + css::style_origin::RealLocation::ProducedByCall(origin) if origin.file == "higher-order.css.ts" + && origin.expression == "identity(style)({fontFamily:'abcdefghijklmnopqrstuvwx'})")), "{:?}", sheet.names); + }); + reset_build_state_internal(); +} diff --git a/bindings/devup-ui-wasm/src/exact_class_tests.rs b/bindings/devup-ui-wasm/src/exact_class_tests.rs new file mode 100644 index 000000000..d2a988145 --- /dev/null +++ b/bindings/devup-ui-wasm/src/exact_class_tests.rs @@ -0,0 +1,96 @@ +use super::*; +use exact_test_support::{authority, exact, fixture, mutate_authority}; +use rstest::rstest; +use serial_test::serial; + +#[test] +#[serial] +fn exact_success_keeps_authoritative_mutations_and_sequential_sheet_entries() { + // Given + fixture(); + // When + let result = exact(|| { + mutate_authority(); + with_style_sheet(|sheet| assert!(sheet.css.contains_key("tentative.tsx"))); + with_style_sheet_mut(|sheet| sheet.add_import("tentative.tsx", "url('committed.css')")); + css::class_map::reset_class_map(); + set_class_map(HashMap::from([( + "committed".into(), + HashMap::from([("kept".into(), 88)]), + )])); + Ok(()) + }); + // Then + assert_eq!(result, Ok(())); + assert_eq!( + css::class_map::get_class_map(), + HashMap::from([("committed".into(), HashMap::from([("kept".into(), 88)]))]) + ); + assert!(with_style_sheet( + |sheet| sheet.imports["tentative.tsx"].contains("url('committed.css')") + )); + assert!(css::file_map::original_id("tentative.tsx").is_some()); + reset_build_state_internal(); +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn abort_restores_full_class_map_after_owner_reset_or_replacement(#[case] replace: bool) { + // Given + fixture(); + let before = authority(); + // When + let result: Result<(), String> = exact(|| { + if replace { + set_class_map(HashMap::from([("replacement".into(), HashMap::new())])); + } else { + css::class_map::reset_class_map(); + } + let inner = css::class_map::Attempt::begin(); + let _name = css::keyframes_to_keyframes_name("new after replacement", None); + inner.commit(); + Err("abort".into()) + }); + // Then + assert_eq!(result, Err("abort".into())); + assert_eq!(authority(), before); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn abort_reverses_both_no_site_requests_after_inner_commit() { + use css::allocation_input::{LegacyDeclaration, LegacyInput, LegacyVariable, capture_context}; + // Given + fixture(); + let before = authority(); + // When + let result: Result<(), String> = exact(|| { + let inner = css::class_map::Attempt::begin(); + let class = LegacyInput::Declaration(LegacyDeclaration { + property: "width".into(), + level: 0, + value: None, + selector: None, + order: None, + }); + let class_context = capture_context(&class, None, css::CounterOwner::Inactive); + let class = css::counter_names::allocate_name(&class, &class_context); + let variable = LegacyInput::Variable(LegacyVariable { + property: "width".into(), + level: 0, + selector: Some(format!(".{}", class.name)), + }); + let context = capture_context(&variable, None, css::CounterOwner::Inactive); + let _variable = css::counter_names::allocate_name(&variable, &context); + assert_eq!(css::class_map::get_class_map()[""].len(), 2); + inner.commit(); + Err("output error".into()) + }); + // Then + assert_eq!(result, Err("output error".into())); + assert_eq!(authority(), before); + reset_build_state_internal(); +} diff --git a/bindings/devup-ui-wasm/src/exact_nested_tests.rs b/bindings/devup-ui-wasm/src/exact_nested_tests.rs new file mode 100644 index 000000000..3abd03297 --- /dev/null +++ b/bindings/devup-ui-wasm/src/exact_nested_tests.rs @@ -0,0 +1,222 @@ +use super::*; +use exact_test_support::{authority, compile, exact, fixture, mutate_authority}; +use rstest::rstest; +use serial_test::serial; +use std::panic::{AssertUnwindSafe, catch_unwind}; + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn real_inner_parser_error_restores_local_authority_when_outer_finishes(#[case] commit: bool) { + // Given + fixture(); + let source = "import {Box} from '@devup-ui/react';const x= result, + Err(payload) => std::panic::resume_unwind(payload), + }; + assert_eq!(inner.err(), Some(expected)); + assert_eq!(authority(), local); + committed = Some(local); + if commit { + Ok(()) + } else { + Err("outer abort".into()) + } + }); + // Then + assert_eq!( + result, + if commit { + Ok(()) + } else { + Err("outer abort".into()) + } + ); + assert_eq!( + authority(), + if commit { + committed.unwrap_or_else(|| panic!("committed authority capture missing")) + } else { + before + } + ); + assert_eq!(cache_names::check(), Ok(())); + reset_build_state_internal(); +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn real_inner_resolver_unwind_preserves_original_panic_when_outer_finishes(#[case] commit: bool) { + // Given + fixture(); + let before = authority(); + let mut committed = None; + let resolver = + |_: &str, _: &str| -> Option { panic!("nested resolver unwind") }; + // When + let result = exact(|| { + mutate_authority(); + let local = authority(); + let panic = match catch_unwind(AssertUnwindSafe(|| { + code_extract_with_modules_internal( + "inner.tsx", + "import {Box} from '@devup-ui/react';import {COLOR} from './tokens';export const x=;", + "@devup-ui/react", + "df".into(), + false, + false, + false, + HashMap::new(), + &resolver, + ) + })) { + Err(payload) => payload, + Ok(_) => panic!("nested resolver did not panic"), + }; + assert_eq!( + panic.downcast_ref::<&str>(), + Some(&"nested resolver unwind") + ); + assert_eq!(authority(), local); + committed = Some(local); + if commit { + Ok(()) + } else { + Err("outer abort".into()) + } + }); + // Then + assert_eq!( + result, + if commit { + Ok(()) + } else { + Err("outer abort".into()) + } + ); + assert_eq!( + authority(), + if commit { + committed.unwrap_or_else(|| panic!("committed authority capture missing")) + } else { + before + } + ); + assert_eq!(cache_names::check(), Ok(())); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn real_inner_compile_success_is_reversed_when_outer_aborts() { + // Given + fixture(); + seed_file_map(vec!["inner.tsx".into()]); + let before = authority(); + // When + let result: Result<(), String> = exact(|| { + let output = compile( + "inner.tsx", + "import {Box} from '@devup-ui/react';export const x=;", + ) + .unwrap_or_else(|error| panic!("inner compile failed: {error}")); + assert!( + output + .css() + .unwrap_or_else(|| panic!("inner CSS missing")) + .contains("width:12px") + ); + assert_ne!(authority(), before); + Err("outer abort".into()) + }); + // Then + assert_eq!(result, Err("outer abort".into())); + assert_eq!(authority(), before); + reset_build_state_internal(); +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn native_outer_resolver_can_call_real_nested_compiler(#[case] commit: bool) { + // Given + fixture(); + let before = authority(); + let calls = std::rc::Rc::new(std::cell::Cell::new(0usize)); + let resolver_calls = std::rc::Rc::clone(&calls); + let resolver = move |_: &str, _: &str| { + resolver_calls.set( + resolver_calls + .get() + .checked_add(1) + .unwrap_or_else(|| panic!("resolver call counter overflow")), + ); + let local = authority(); + let error = compile( + "nested-broken.tsx", + "import {Box} from '@devup-ui/react';const x=;" + } else { + "import {Box,css} from '@devup-ui/react';import {COLOR} from './tokens';export const x=;export const y=css(runtime());" + }; + // When: both outer and inner now enter the production common boundary. + let result = code_extract_with_modules_internal( + "outer.tsx", + source, + "@devup-ui/react", + "df".into(), + false, + false, + false, + HashMap::new(), + &resolver, + ); + // Then + assert!(calls.get() > 0); + assert_eq!(result.is_ok(), commit); + if commit { + assert!( + result + .unwrap_or_else(|error| panic!("outer compile failed: {error}")) + .css() + .unwrap_or_else(|| panic!("outer CSS missing")) + .contains("color:red") + ); + assert_ne!(authority(), before); + } else { + assert!( + result + .err() + .unwrap_or_else(|| panic!("outer invalid fixture unexpectedly compiled")) + .contains("build time") + ); + assert_eq!(authority(), before); + } + reset_build_state_internal(); +} diff --git a/bindings/devup-ui-wasm/src/exact_plan_tests.rs b/bindings/devup-ui-wasm/src/exact_plan_tests.rs new file mode 100644 index 000000000..6c52a3cae --- /dev/null +++ b/bindings/devup-ui-wasm/src/exact_plan_tests.rs @@ -0,0 +1,47 @@ +use super::*; +use exact_test_support::{authority, compile, fixture}; +use rstest::rstest; +use serial_test::serial; +use std::collections::{BTreeSet, HashSet}; + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn real_parser_failure_restores_unfrozen_plan_when_routes_are_configured(#[case] eligible: bool) { + // Given: configuration precedes capture and the real common extraction entry. + fixture(); + set_atom_hoist(Some(2)); + import_file_routes_internal(HashMap::from([( + "plan.tsx".into(), + if eligible { + HashSet::from([1, 2]) + } else { + HashSet::from([1]) + }, + )])); + assert_eq!(css::atom_hoist::atom_plan(), None); + let before = authority(); + // When: extraction freezes a new plan before the parser fails. + let error = compile( + "broken-plan.tsx", + "import {Box} from '@devup-ui/react';const x= = exact(|| { + extract_without_source_map( + "raw.tsx", + source, + ExtractOption { + package: "@devup-ui/react".into(), + css_dir: "df".into(), + single_css: false, + import_main_css: false, + import_aliases: HashMap::new(), + }, + ) + .unwrap_or_else(|error| panic!("private allocation control failed: {error}")); + let mut allocated = css::class_map::get_class_map(); + let private = allocated + .remove(&format!("D9-{original}")) + .unwrap_or_else(|| panic!("private allocation control namespace missing")); + assert_eq!(private.values().copied().collect::>(), vec![0]); + assert_eq!(allocated, classes); + Err("control abort".into()) + }); + assert_eq!(control, Err("control abort".into())); + assert_eq!(authority(), before); + // When + let error = code_extract_internal( + "raw.tsx", + source, + "@devup-ui/react", + "df".into(), + false, + false, + false, + HashMap::new(), + ) + .err() + .unwrap_or_else(|| panic!("Output collision fixture unexpectedly compiled")); + // Then + assert_eq!(authority(), before); + assert_eq!(cache_names::check(), Err(error.clone())); + assert_eq!(get_css_internal(None, false), Err(error.clone())); + assert_eq!(compile("later.tsx", "const x=1;").err(), Some(error)); + reset_build_state_internal(); +} + +#[rstest] +#[case("prepare error")] +#[case("output error")] +#[serial] +fn simulated_failure_restores_full_live_records_after_inner_commit(#[case] failure: &str) { + // Given + fixture(); + configure_fresh_plan(); + assert_eq!(css::atom_hoist::atom_plan(), None); + let before = authority(); + // When + let result: Result<(), String> = exact(|| { + mutate_authority(); + assert_eq!( + css::atom_hoist::atom_plan(), + Some(std::collections::BTreeSet::from(["tentative.tsx".into()])) + ); + Err(failure.into()) + }); + // Then + assert_eq!(result, Err(failure.into())); + assert_eq!(authority(), before); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn unwind_restores_full_live_records_after_sheet_cleanup_and_update() { + // Given + fixture(); + configure_fresh_plan(); + assert_eq!(css::atom_hoist::atom_plan(), None); + let before = authority(); + // When + let panic = catch_unwind(AssertUnwindSafe(|| { + exact::<()>(|| { + mutate_authority(); + assert_eq!( + css::atom_hoist::atom_plan(), + Some(std::collections::BTreeSet::from(["tentative.tsx".into()])) + ); + panic!("emission unwind"); + }) + })); + // Then + assert!(panic.is_err()); + assert_eq!(authority(), before); + assert_eq!(cache_names::check(), Ok(())); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn resolver_unwind_restores_real_common_extraction_boundary() { + // Given + fixture(); + let before = authority(); + let resolver = |_: &str, _: &str| -> Option { panic!("resolver unwind") }; + // When + let panic = catch_unwind(AssertUnwindSafe(|| { + code_extract_with_modules_internal( + "root.tsx", + "import {Box} from '@devup-ui/react';import {COLOR} from './tokens';export const x=;", + "@devup-ui/react", + "df".into(), + false, + false, + false, + HashMap::new(), + &resolver, + ) + })); + // Then + assert!(panic.is_err()); + assert_eq!(authority(), before); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn evaluated_visitor_retry_commits_output_without_registering_unseeded_original() { + // Given + reset_build_state_internal(); + set_debug(false); + // When + let output = compile("retry.tsx", "import {css} from '@devup-ui/react';function a(){return 'red'}const b=()=>a();export const c=css({color:b(),padding:'16px'});") + .unwrap_or_else(|error| panic!("evaluated visitor retry failed: {error}")); + // Then + assert!( + output + .css() + .unwrap_or_else(|| panic!("retry CSS missing")) + .contains("color:red") + ); + assert!(with_style_sheet(|sheet| sheet + .names + .values() + .any(|claim| claim.content.contains("red")))); + assert_eq!(css::file_map::original_id("retry.tsx"), None); + reset_build_state_internal(); +} diff --git a/bindings/devup-ui-wasm/src/exact_test_support.rs b/bindings/devup-ui-wasm/src/exact_test_support.rs new file mode 100644 index 000000000..c9e2f7e34 --- /dev/null +++ b/bindings/devup-ui-wasm/src/exact_test_support.rs @@ -0,0 +1,171 @@ +use super::*; +use extraction_rollback::{ExtractionRollback, SheetData}; +use std::collections::BTreeSet; + +/// Cleans only poison deliberately created by a sheet-lock panic test. +pub(super) struct SheetPoisonCleanup(()); + +impl SheetPoisonCleanup { + pub(super) fn new() -> Self { + assert!( + !GLOBAL_STYLE_SHEET.is_poisoned(), + "unexpected prior sheet poison" + ); + Self(()) + } +} + +impl Drop for SheetPoisonCleanup { + fn drop(&mut self) { + // The enclosing test scope outlives the intentionally unwound sheet guard. + GLOBAL_STYLE_SHEET.clear_poison(); + } +} + +#[derive(Debug, PartialEq, Eq)] +pub(super) struct Authority { + classes: HashMap>, + files: bimap::BiHashMap, + originals: BTreeMap, + plan: Option>, + sheet: SheetData, + theme: String, +} + +pub(super) fn authority() -> Authority { + with_admission(|| Authority { + classes: css::class_map::get_class_map(), + files: css::file_map::get_file_map(), + originals: css::file_map::get_original_ids(), + plan: css::atom_hoist::atom_plan(), + sheet: with_style_sheet(SheetData::capture), + theme: with_style_sheet(|sheet| sheet.theme.to_css()), + }) +} + +pub(super) fn compile(file: &str, source: &str) -> Result { + code_extract_internal( + file, + source, + "@devup-ui/react", + "df".into(), + false, + false, + false, + HashMap::new(), + ) +} + +pub(super) fn fixture() { + reset_build_state_internal(); + set_debug(false); + let mut theme = sheet::theme::Theme::default(); + let mut colors = sheet::theme::ColorTheme::default(); + colors.add_color("retained", "#123456"); + theme.add_color_theme("default", colors); + register_theme_internal(theme); + code_extract_internal( + "retained.tsx", + "import {css} from '@devup-ui/react';export const x=css({color:'blue'});", + "@devup-ui/react", + "df".into(), + true, + false, + false, + HashMap::new(), + ) + .unwrap_or_else(|error| panic!("retained fixture extraction failed: {error}")); + set_class_map(HashMap::from([ + ("empty".into(), HashMap::new()), + ( + "retained".into(), + HashMap::from([("non-length".into(), 37)]), + ), + ])); + set_file_map(std::iter::once(("retained.tsx".into(), 29)).collect()); + css::file_map::set_original_ids(BTreeMap::from([("retained.tsx".into(), 42)])); + css::atom_hoist::restore_atom_plan(None); + with_style_sheet_mut(|sheet| { + sheet.atom_plan = Some(BTreeSet::from(["retained.tsx".into()])); + sheet.source_ids.insert("sheet-only".into(), 91); + sheet.add_css("raw.tsx", "body{margin:1px}"); + sheet.add_import("raw.tsx", "url('retained.css')"); + sheet.add_font_face( + "raw.tsx", + &BTreeMap::from([("font-family".into(), "retained".into())]), + ); + sheet.add_keyframes( + "ordered", + BTreeMap::from([( + "from".into(), + vec![ + ("z-index".into(), "2".into()), + ("opacity".into(), "0".into()), + ("opacity".into(), "1".into()), + ], + )]), + Some("raw.tsx"), + ); + let props = sheet + .properties + .values_mut() + .flat_map(BTreeMap::values_mut) + .flat_map(BTreeMap::values_mut) + .next() + .unwrap_or_else(|| panic!("retained fixture properties missing")); + let mut distinct = props + .iter() + .next() + .unwrap_or_else(|| panic!("retained fixture property record missing")) + .clone(); + distinct.class_name = "same-ord-distinct-record".into(); + props.insert(distinct); + }); +} + +pub(super) fn exact(build: impl FnOnce() -> Result) -> Result { + with_admission(|| { + cache_names::check()?; + let rollback = ExtractionRollback::capture(); + let result = css::exact_attempt::with_exclusive_attempt(build); + if result.is_ok() { + rollback.commit(); + } + result + }) +} + +pub(super) fn mutate_authority() { + let _file_num = css::file_map::get_file_num_by_filename("tentative.tsx"); + css::file_map::get_or_insert_original_id("tentative.tsx") + .unwrap_or_else(|error| panic!("tentative original registration failed: {error}")); + css::atom_hoist::freeze_atom_plan(); + let inner = css::class_map::Attempt::begin(); + css::class_map::with_class_map_mut(|map| { + map.remove("empty"); + map.insert( + "retained".into(), + HashMap::from([("replacement".into(), 99)]), + ); + map.insert("new-empty".into(), HashMap::new()); + }); + let _name = css::keyframes_to_keyframes_name("inserted", None); + inner.commit(); + with_style_sheet_mut(|sheet| { + sheet.names.clear(); + sheet.properties.clear(); + sheet.keyframes.clear(); + sheet.rm_global_css("raw.tsx", false); + sheet.add_css("tentative.tsx", "body{margin:2px}"); + sheet.atom_plan = None; + sheet.source_ids.clear(); + }); +} + +pub(super) fn configure_fresh_plan() { + set_atom_hoist(Some(1)); + import_file_routes_internal(HashMap::from([( + "tentative.tsx".into(), + std::collections::HashSet::from([1]), + )])); +} diff --git a/bindings/devup-ui-wasm/src/extraction_rollback.rs b/bindings/devup-ui-wasm/src/extraction_rollback.rs new file mode 100644 index 000000000..e5d65bd04 --- /dev/null +++ b/bindings/devup-ui-wasm/src/extraction_rollback.rs @@ -0,0 +1,23 @@ +use crate::{with_style_sheet, with_style_sheet_mut}; +pub(crate) use sheet::live_checkpoint::LiveCheckpoint as SheetData; + +/// Outside CSS exact ownership and inside admission, so CSS abort runs first. +pub(crate) struct ExtractionRollback(Option); + +impl ExtractionRollback { + pub(crate) fn capture() -> Self { + Self(Some(with_style_sheet(SheetData::capture))) + } + + pub(crate) fn commit(mut self) { + self.0 = None; + } +} + +impl Drop for ExtractionRollback { + fn drop(&mut self) { + if let Some(before) = self.0.take() { + with_style_sheet_mut(|sheet| before.restore(sheet)); + } + } +} diff --git a/bindings/devup-ui-wasm/src/lib.rs b/bindings/devup-ui-wasm/src/lib.rs index 0a2347ac2..6fed49d19 100644 --- a/bindings/devup-ui-wasm/src/lib.rs +++ b/bindings/devup-ui-wasm/src/lib.rs @@ -1,7 +1,7 @@ -use css::class_map::{set_class_map, with_class_map}; -use css::file_map::{ - canonical, is_global, set_canonical_map, set_file_map, with_canonical_map, with_file_map, -}; +use css::class_map::with_class_map; +use css::file_map::{canonical, is_global, set_canonical_map, with_canonical_map, with_file_map}; +#[cfg(test)] +use css::{class_map::set_class_map, file_map::set_file_map}; use extractor::extract_style::extract_style_value::ExtractStyleValue; use extractor::{ ExtractOption, ImportAlias, ModuleResolver, ResolvedModule, extract, extract_with_modules, @@ -14,6 +14,76 @@ use std::collections::{BTreeMap, HashMap}; use std::fmt::Display; use std::sync::{LazyLock, Mutex}; use wasm_bindgen::prelude::*; +mod administration; +#[cfg(test)] +mod admission_contract_tests; +#[cfg(all(test, not(target_arch = "wasm32")))] +mod admission_exclusion_tests; +#[cfg(test)] +mod admission_guard_tests; +#[cfg(test)] +mod exact_class_tests; +#[cfg(test)] +mod exact_nested_tests; +#[cfg(test)] +mod exact_plan_tests; +#[cfg(test)] +mod exact_rollback_tests; +#[cfg(test)] +mod exact_test_support; +mod extraction_rollback; +mod sheet_entry; +use administration::with_administration; +use css::admission::with_admission; +#[cfg(test)] +mod cache6_adoption_tests; +#[cfg(test)] +mod cache6_configuration_tests; +#[cfg(test)] +mod cache6_damage_tests; +#[cfg(test)] +mod cache6_nested_tests; +#[cfg(test)] +mod cache6_protocol_tests; +#[doc(hidden)] +pub mod cache6_restore; +#[cfg(test)] +mod cache6_rollback_tests; +#[cfg(test)] +mod cache6_seed_tests; +mod cache6_session; +#[cfg(test)] +mod cache6_test_support; +mod cache_atom_proof; +mod cache_descriptor; +mod cache_names; +mod cache_restore; +mod cache_source_names; +mod cache_special_proof; +#[cfg(test)] +mod cache_v4_tests; +#[cfg(test)] +mod compact_source_tests; +#[cfg(test)] +mod content_location_contract_tests; +#[cfg(test)] +mod content_name_tests; +#[cfg(test)] +mod naming_root_tests; +#[cfg(test)] +mod original_counter_tests; +#[cfg(test)] +mod prefix_cache_tests; +#[doc(hidden)] +pub mod prospective_output; +#[cfg(test)] +mod prospective_output_tests; +#[cfg(test)] +mod scoped_cache_tests; +#[cfg(test)] +mod scoped_cascade_tests; +#[cfg(test)] +mod static_theme_tests; static GLOBAL_STYLE_SHEET: LazyLock> = LazyLock::new(|| Mutex::new(StyleSheet::default())); @@ -32,20 +102,26 @@ fn with_style_sheet(f: F) -> R where F: FnOnce(&StyleSheet) -> R, { - let guard = GLOBAL_STYLE_SHEET - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - f(&guard) + with_admission(|| { + let _entry = sheet_entry::SheetEntry::enter(); + let guard = GLOBAL_STYLE_SHEET + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + f(&guard) + }) } fn with_style_sheet_mut(f: F) -> R where F: FnOnce(&mut StyleSheet) -> R, { - let mut guard = GLOBAL_STYLE_SHEET - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - f(&mut guard) + with_admission(|| { + let _entry = sheet_entry::SheetEntry::enter(); + let mut guard = GLOBAL_STYLE_SHEET + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + f(&mut guard) + }) } #[cfg(not(tarpaulin_include))] @@ -74,7 +150,7 @@ impl Output { css_file: Option, import_main_css: bool, dependencies: Vec, - ) -> Self { + ) -> Result { // Use the bucket identity (single-importer collapse) so the sheet's CSS // naming + property bucket + emitted chunk match the canonical class names // the extractor already baked into `code`. Identity when no map is loaded. @@ -83,14 +159,22 @@ impl Output { let canonical_filename = canonical(&filename); let global = single_css || is_global(&filename); with_style_sheet_mut(|sheet| { + sheet + .preflight_styles(&styles, &canonical_filename, global) + .map_err(|error| { + let message = error.to_string(); + cache_names::record(&Err(message.clone())); + message + })?; // globalCss (@font-face / global selectors) is per-SOURCE-file, never // collapsed. rm_global_css MUST use the RAW filename so a collapsed // member (sharing the bucket-root's canonical) never wipes the root's // globalCss. Atom property bucketing still uses canonical_filename. let default_collected = sheet.rm_global_css(&filename, global); - let (collected, updated_base_style) = - sheet.update_styles(&styles, &canonical_filename, global); - Self { + let (collected, updated_base_style) = sheet + .update_styles(&styles, &canonical_filename, global) + .map_err(|error| error.to_string())?; + Ok(Self { code, map, css_file, @@ -110,7 +194,7 @@ impl Output { )) } }, - } + }) }) } @@ -157,7 +241,7 @@ impl Output { #[wasm_bindgen(js_name = "setDebug")] pub fn set_debug(debug: bool) { - css::debug::set_debug(debug); + with_administration("set_debug", || css::debug::set_debug(debug)); } #[wasm_bindgen(js_name = "isDebug")] @@ -208,7 +292,18 @@ pub fn is_debug() -> bool { /// ``` #[wasm_bindgen(js_name = "setPrefix")] pub fn set_prefix(prefix: Option) { - css::set_prefix(prefix); + with_administration("set_prefix", || css::set_prefix(prefix)); +} + +/// Set the build's names-only project root before extraction in every compiler. +/// This never changes resolver IDs, extraction keys, buckets or D9 numbering. +/// +/// Supply the existing ID basis when relative extraction IDs are not root-relative. +#[wasm_bindgen(js_name = "setNamingRoot")] +pub fn set_naming_root(root: Option, relative_base: Option) { + with_administration("set_naming_root", || { + css::naming_root::set_context(root, relative_base); + }); } #[wasm_bindgen(js_name = "getPrefix")] @@ -218,21 +313,22 @@ pub fn get_prefix() -> Option { } /// Internal function to import a `StyleSheet` (testable without `JsValue`) -pub fn import_sheet_internal(sheet: StyleSheet) { - with_style_sheet_mut(|global_sheet| *global_sheet = sheet); +pub fn import_sheet_internal(sheet: StyleSheet) -> Result<(), String> { + with_administration("import_sheet_internal", || cache_restore::import(sheet)) } #[wasm_bindgen(js_name = "importSheet")] #[cfg(not(tarpaulin_include))] pub fn import_sheet(sheet_object: JsValue) -> Result<(), JsValue> { - let sheet: StyleSheet = serde_wasm_bindgen::from_value(sheet_object).map_err(js_error)?; - import_sheet_internal(sheet); - Ok(()) + let sheet: StyleSheet = + serde_wasm_bindgen::from_value(sheet_object).unwrap_or_else(|_| cache_restore::absent()); + import_sheet_internal(sheet).map_err(js_error) } /// Internal function to export `StyleSheet` as JSON string (testable without `JsValue`) pub fn export_sheet_internal() -> Result { - with_style_sheet(serde_json::to_string).map_err(|e| e.to_string()) + with_style_sheet(|sheet| serde_json::to_string(&sheet.export_snapshot())) + .map_err(|e| e.to_string()) } #[wasm_bindgen(js_name = "exportSheet")] @@ -243,13 +339,20 @@ pub fn export_sheet() -> Result { /// Internal function to export class map as JSON string (testable without `JsValue`) pub fn export_class_map_internal() -> Result { - with_class_map(serde_json::to_string).map_err(|e| e.to_string()) + with_class_map(|map| { + let sorted: BTreeMap<&String, BTreeMap<&String, &usize>> = map + .iter() + .map(|(file, classes)| (file, classes.iter().collect())) + .collect(); + serde_json::to_string(&sorted) + }) + .map_err(|e| e.to_string()) } #[wasm_bindgen(js_name = "importClassMap")] #[cfg(not(tarpaulin_include))] pub fn import_class_map(sheet_object: JsValue) -> Result<(), JsValue> { - set_class_map(serde_wasm_bindgen::from_value(sheet_object).map_err(js_error)?); + cache_restore::classes(serde_wasm_bindgen::from_value(sheet_object).ok()); Ok(()) } @@ -261,13 +364,17 @@ pub fn export_class_map() -> Result { /// Internal function to export file map as JSON string (testable without `JsValue`) pub fn export_file_map_internal() -> Result { - with_file_map(serde_json::to_string).map_err(|e| e.to_string()) + with_file_map(|map| { + let sorted: BTreeMap<&String, &usize> = map.iter().collect(); + serde_json::to_string(&sorted) + }) + .map_err(|e| e.to_string()) } #[wasm_bindgen(js_name = "importFileMap")] #[cfg(not(tarpaulin_include))] pub fn import_file_map(sheet_object: JsValue) -> Result<(), JsValue> { - set_file_map(serde_wasm_bindgen::from_value(sheet_object).map_err(js_error)?); + cache_restore::files(serde_wasm_bindgen::from_value(sheet_object).ok()); Ok(()) } @@ -279,18 +386,22 @@ pub fn export_file_map() -> Result { /// Internal function to import the canonical (bucket) map (testable without `JsValue`) pub fn import_canonical_map_internal(map: HashMap) { - set_canonical_map(map); + with_administration("import_canonical_map_internal", || set_canonical_map(map)); } /// Internal function to export the canonical map as JSON string (testable without `JsValue`) pub fn export_canonical_map_internal() -> Result { - with_canonical_map(serde_json::to_string).map_err(|e| e.to_string()) + with_canonical_map(|map| { + let sorted: BTreeMap<&String, &String> = map.iter().collect(); + serde_json::to_string(&sorted) + }) + .map_err(|e| e.to_string()) } #[wasm_bindgen(js_name = "importCanonicalMap")] #[cfg(not(tarpaulin_include))] pub fn import_canonical_map(map_object: JsValue) -> Result<(), JsValue> { - set_canonical_map(serde_wasm_bindgen::from_value(map_object).map_err(js_error)?); + import_canonical_map_internal(serde_wasm_bindgen::from_value(map_object).map_err(js_error)?); Ok(()) } @@ -300,24 +411,66 @@ pub fn export_canonical_map() -> Result { export_canonical_map_internal().map_err(js_error) } +/// Number every file in `files` now, in path order, keeping numbers files +/// already hold, so class prefixes depend on the paths and not on the order +/// workers reach files in. +#[wasm_bindgen(js_name = "seedFileMap")] +pub fn seed_file_map(files: Vec) { + with_administration("seed_file_map", || { + cache_restore::seeded(&files); + cache6_restore::record_seed(&files); + css::file_map::seed_file_numbers(&files); + }); +} + +/// Forget everything one build left in the engine. +/// +/// That is names, numbers, styles, buckets, routes, the prefix, atom hoisting +/// and the module resolver, so the next build starts from its own options +/// alone. Theme, shorthands and debug mode are set by every build, and stay. +pub fn reset_build_state_internal() { + with_administration("reset_build_state_internal", || { + cache_names::clear(); + cache_restore::clear(); + cache6_restore::forget(); + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + css::file_map::reset_canonical_map(); + css::file_routes::set_file_routes(HashMap::new()); + css::atom_hoist::set_atom_hoist(None); + css::atom_hoist::restore_atom_plan(None); + css::set_prefix(None); + css::naming_root::set_root(None); + with_style_sheet_mut(|sheet| *sheet = StyleSheet::default()); + set_module_resolver_internal(None); + }); +} + +#[wasm_bindgen(js_name = "resetBuildState")] +pub fn reset_build_state() { + reset_build_state_internal(); +} /// Set the atom-level hoist threshold. /// -/// When set to `Some(n)`, a style atom whose content is used by `>= n` distinct -/// routes is emitted into the shared global `devup-ui.css` (shipped once) instead -/// of duplicated into each per-route chunk. `None` (the default) disables atom -/// hoisting entirely (identity behavior). +/// When set to `Some(n)`, buckets with predeclared reach of `>= n` routes emit +/// their atoms into shared CSS. Eligibility freezes before the first source; +/// unknown or late reach stays local until `resetBuildState`. +/// `None` (the default) preserves ordinary per-file naming and placement. /// -/// MUST be called BEFORE `codeExtract` so atoms receive global (shared) class -/// names; enabling it afterwards leaves per-file names and nothing hoists. +/// Set this before `codeExtract` so extraction and emission share one plan. /// Pair with `importFileRoutes` to provide the file -> routes mapping. #[wasm_bindgen(js_name = "setAtomHoist")] pub fn set_atom_hoist(threshold: Option) { - css::atom_hoist::set_atom_hoist(threshold); + with_administration("set_atom_hoist", || { + css::atom_hoist::set_atom_hoist(threshold); + }); } /// Internal function to import the file -> routes map (testable without `JsValue`) pub fn import_file_routes_internal(map: HashMap>) { - css::file_routes::set_file_routes(map); + with_administration("import_file_routes_internal", || { + css::file_routes::set_file_routes(map); + }); } /// Import the file -> set-of-route-ids mapping used to decide atom hoisting. @@ -328,9 +481,7 @@ pub fn import_file_routes_internal(map: HashMap Result<(), JsValue> { - css::file_routes::set_file_routes( - serde_wasm_bindgen::from_value(map_object).map_err(js_error)?, - ); + import_file_routes_internal(serde_wasm_bindgen::from_value(map_object).map_err(js_error)?); Ok(()) } @@ -426,38 +577,47 @@ fn code_extract_internal_impl( source_map: SourceMapMode, resolver: Option<&ModuleResolver>, ) -> Result { - let option = ExtractOption { - package: package.to_string(), - css_dir, - single_css, - import_main_css: import_main_css_in_code, - import_aliases, - }; - let extracted = match (resolver, source_map) { - (Some(resolver), mode) => extract_with_modules( - filename, - code, - option, - matches!(mode, SourceMapMode::Generate), - resolver, - ), - (None, SourceMapMode::Generate) => extract(filename, code, option), - (None, SourceMapMode::Skip) => extract_without_source_map(filename, code, option), - }; - - match extracted { - Ok(output) => Ok(Output::new( - output.code, - output.styles, - output.map, - single_css, - filename.to_string(), - output.css_file, - import_main_css_in_css, - output.dependencies, - )), - Err(error) => Err(error.to_string()), - } + with_admission(|| { + cache_names::check()?; + let rollback = extraction_rollback::ExtractionRollback::capture(); + let result = css::exact_attempt::with_exclusive_attempt(|| { + let option = ExtractOption { + package: package.to_string(), + css_dir, + single_css, + import_main_css: import_main_css_in_code, + import_aliases, + }; + let extracted = match (resolver, source_map) { + (Some(resolver), mode) => extract_with_modules( + filename, + code, + option, + matches!(mode, SourceMapMode::Generate), + resolver, + ), + (None, SourceMapMode::Generate) => extract(filename, code, option), + (None, SourceMapMode::Skip) => extract_without_source_map(filename, code, option), + }; + match extracted { + Ok(output) => Output::new( + output.code, + output.styles, + output.map, + single_css, + filename.to_string(), + output.css_file, + import_main_css_in_css, + output.dependencies, + ), + Err(error) => Err(error.to_string()), + } + }); + if result.is_ok() { + rollback.commit(); + } + result + }) } /// Set how the imports of the files being extracted are resolved. @@ -467,7 +627,13 @@ fn code_extract_internal_impl( #[wasm_bindgen(js_name = "setModuleResolver")] #[cfg(not(tarpaulin_include))] pub fn set_module_resolver(resolver: Option) { - MODULE_RESOLVER.with_borrow_mut(|current| *current = resolver); + set_module_resolver_internal(resolver); +} + +fn set_module_resolver_internal(resolver: Option) { + with_administration("set_module_resolver", || { + MODULE_RESOLVER.with_borrow_mut(|current| *current = resolver); + }); } #[cfg(not(tarpaulin_include))] @@ -504,25 +670,29 @@ fn code_extract_js( import_aliases: JsValue, 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) - }); - code_extract_internal_impl( - filename, - code, - package, - css_dir, - single_css, - import_main_css_in_code, - import_main_css_in_css, - import_aliases, - source_map, - resolver - .as_ref() - .map(|resolver| resolver as &ModuleResolver), - ) - .map_err(js_error) + with_admission(|| { + 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) + } + }); + code_extract_internal_impl( + filename, + code, + package, + css_dir, + single_css, + import_main_css_in_code, + import_main_css_in_css, + import_aliases, + source_map, + resolver + .as_ref() + .map(|resolver| resolver as &ModuleResolver), + ) + .map_err(js_error) + }) } #[cfg(not(tarpaulin_include))] @@ -597,7 +767,9 @@ pub fn code_extract_without_source_map( /// Internal function to register theme (testable without `JsValue`) pub fn register_theme_internal(theme: sheet::theme::Theme) { - with_style_sheet_mut(|sheet| sheet.set_theme(theme)); + with_administration("register_theme_internal", || { + with_style_sheet_mut(|sheet| sheet.set_theme(theme)); + }); } #[wasm_bindgen(js_name = "registerTheme")] @@ -611,7 +783,9 @@ pub fn register_theme(theme_object: JsValue) -> Result<(), JsValue> { /// Internal function to register custom style-property shorthands. pub fn register_shorthands_internal(shorthands: BTreeMap>) { - css::set_custom_shorthands(shorthands); + with_administration("register_shorthands_internal", || { + css::set_custom_shorthands(shorthands); + }); } #[wasm_bindgen(js_name = "registerShorthands")] @@ -631,18 +805,26 @@ pub fn get_default_theme() -> Result, JsValue> { #[wasm_bindgen(js_name = "getCss")] #[cfg(not(tarpaulin_include))] pub fn get_css(file_num: Option, import_main_css: bool) -> Result { - Ok(with_style_sheet(|sheet| { - if let Some(file_num) = file_num { - with_file_map(|map| { - sheet.create_css( - map.get_by_right(&file_num).map(String::as_str), - import_main_css, - ) - }) - } else { - sheet.create_css(None, import_main_css) - } - })) + get_css_internal(file_num, import_main_css).map_err(js_error) +} + +/// Internal CSS retrieval using the same sticky-error and file-selection contract as getCss. +pub fn get_css_internal(file_num: Option, import_main_css: bool) -> Result { + with_admission(|| { + cache_names::check()?; + Ok(with_style_sheet(|sheet| { + if let Some(file_num) = file_num { + with_file_map(|map| { + sheet.create_css( + map.get_by_right(&file_num).map(String::as_str), + import_main_css, + ) + }) + } else { + sheet.create_css(None, import_main_css) + } + })) + }) } #[wasm_bindgen(js_name = "getThemeInterface")] @@ -674,6 +856,15 @@ pub fn has_devup_ui_wasm(filename: &str, code: &str, package: &str) -> bool { has_devup_ui(filename, code, package) } +#[cfg(test)] +mod atom_tests; + +#[cfg(test)] +mod cache_protocol_tests; + +#[cfg(test)] +mod atom_hoist_measurement; + #[cfg(test)] #[allow(clippy::expect_used, clippy::unwrap_used)] mod tests { @@ -707,16 +898,30 @@ mod tests { reset_file_routes(); register_theme_internal(sheet::theme::Theme::default()); - // a.tsx -> route 0, b.tsx -> route 1. bg:red is in BOTH (routes {0,1}, count 2 => HOIST). - // width:11px only in a (route {0}, private). width:22px only in b (private). + css::atom_hoist::restore_atom_plan(None); let mut fr = HashMap::new(); fr.insert("a.tsx".to_string(), HashSet::from([0u32])); fr.insert("b.tsx".to_string(), HashSet::from([1u32])); + fr.insert("shared-a.tsx".to_string(), HashSet::from([0u32, 1])); + fr.insert("shared-b.tsx".to_string(), HashSet::from([0u32, 1])); set_file_routes(fr); set_atom_hoist(Some(2)); - let srca = r#"import { Box } from "@devup-ui/react"; const x = ;"#; - let srcb = r#"import { Box } from "@devup-ui/react"; const x = ;"#; + for file in ["shared-a.tsx", "shared-b.tsx"] { + code_extract_internal( + file, + r#"import { Box } from "@devup-ui/react"; const x = ;"#, + "@devup-ui/react", + "df".to_string(), + false, + false, + false, + HashMap::new(), + ) + .unwrap(); + } + let srca = r#"import { Box } from "@devup-ui/react"; const x = ;"#; + let srcb = r#"import { Box } from "@devup-ui/react"; const x = ;"#; code_extract_internal( "a.tsx", srca, @@ -771,337 +976,28 @@ mod tests { set_atom_hoist(None); reset_file_routes(); + css::atom_hoist::restore_atom_plan(None); } - /// Env-gated artifact emitter for split-native measurement. Writes REAL - /// devup CSS output (header, `@layer`, naming, dedup all authentic) for three - /// delivery models across several workloads, so an external script can - /// measure gzip/brotli + multi-route session + incremental-invalidation - /// bytes. Set `DEVUP_EMIT_MEASURE=1` to run; no-op (and zero cost) otherwise - /// so the normal test suite stays clean. + /// Export real before/after sheets and code for external gzip measurement. + /// `DEVUP_EMIT_MEASURE=1` enables structured stdout without shared temp files. #[test] #[serial] - #[allow(clippy::items_after_statements, clippy::format_push_string)] fn emit_split_measurement_artifacts() { - use css::atom_hoist::set_atom_hoist; - use css::class_map::reset_class_map; - use css::file_map::reset_file_map; - use css::file_routes::{reset_file_routes, set_file_routes}; - use std::collections::{HashMap, HashSet}; - use std::fs; - - if std::env::var("DEVUP_EMIT_MEASURE").is_err() { - return; - } - - let props = [ - "w", - "h", - "p", - "m", - "minW", - "minH", - "maxW", - "maxH", - "fontSize", - "lineHeight", - "borderRadius", - "gap", - ]; - let atom = |key: &str, px: usize| format!(""); - let build = |els: &[String]| { - format!( - "import {{ Box }} from \"@devup-ui/react\"; const x = <>{};", - els.join("") - ) - }; - let reset = || { - { - let mut s = GLOBAL_STYLE_SHEET.lock().unwrap(); - *s = StyleSheet::default(); - } - reset_class_map(); - reset_file_map(); - reset_file_routes(); - register_theme_internal(sheet::theme::Theme::default()); - }; - - let out = std::env::temp_dir().join("devup-split-measure"); - let _ = fs::remove_dir_all(&out); - fs::create_dir_all(&out).unwrap(); - - // (name, routes, universal atoms, private atoms/route) - let workloads = [ - ("shared_heavy", 8usize, 80usize, 25usize), - ("balanced", 8usize, 50usize, 50usize), - ("disjoint", 8usize, 20usize, 60usize), - ]; - let mut manifest = String::from("["); - for (wi, (name, n, u, p)) in workloads.iter().enumerate() { - let (n, u, p) = (*n, *u, *p); - let universal: Vec = (0..u) - .map(|i| atom(props[i % props.len()], 100_000 + i)) - .collect(); - let make_priv = |r: usize| -> Vec { - (0..p) - .map(|i| { - atom( - props[i % props.len()], - 1_000_000 + wi * 1_000_000 + r * p + i, - ) - }) - .collect() - }; - let sources: Vec = (0..n) - .map(|r| { - let mut e = universal.clone(); - e.extend(make_priv(r)); - build(&e) - }) - .collect(); - let run = |single: bool| { - reset(); - for (r, src) in sources.iter().enumerate() { - code_extract_internal( - &format!("r{r}.tsx"), - src, - "@devup-ui/react", - "df".to_string(), - single, - false, - false, - HashMap::new(), - ) - .unwrap(); - } - }; - - // single-css: one shared file with every atom. - run(true); - fs::write( - out.join(format!("{name}_single.css")), - with_style_sheet(|s| s.create_css(None, false)), - ) - .unwrap(); - - // per-file: shared base (theme/base only) + one full chunk per route. - run(false); - fs::write( - out.join(format!("{name}_perfile_base.css")), - with_style_sheet(|s| s.create_css(None, false)), - ) - .unwrap(); - for r in 0..n { - fs::write( - out.join(format!("{name}_perfile_r{r}.css")), - with_style_sheet(|s| s.create_css(Some(&format!("r{r}.tsx")), false)), - ) - .unwrap(); - } - - // atom-B: hoisted shared base (universal atoms) + per-route delta. - // CRITICAL: atom_hoist must be enabled BEFORE extraction so atoms get - // GLOBAL names (shared identity across files). Enabling it only at - // create_css time leaves per-file names, so the same universal atom - // looks like N distinct atoms (one per file) and never hoists. - reset(); - let mut fr = HashMap::new(); - for r in 0..n { - fr.insert(format!("r{r}.tsx"), HashSet::from([r as u32])); - } - set_file_routes(fr); - set_atom_hoist(Some(n)); - for (r, src) in sources.iter().enumerate() { - code_extract_internal( - &format!("r{r}.tsx"), - src, - "@devup-ui/react", - "df".to_string(), - false, - false, - false, - HashMap::new(), - ) - .unwrap(); - } - fs::write( - out.join(format!("{name}_atomb_base.css")), - with_style_sheet(|s| s.create_css(None, false)), - ) - .unwrap(); - for r in 0..n { - fs::write( - out.join(format!("{name}_atomb_r{r}.css")), - with_style_sheet(|s| s.create_css(Some(&format!("r{r}.tsx")), false)), - ) - .unwrap(); - } - set_atom_hoist(None); - reset_file_routes(); - - manifest.push_str(&format!( - "{}{{\"name\":\"{name}\",\"n\":{n},\"u\":{u},\"p\":{p}}}", - if wi > 0 { "," } else { "" } - )); + if std::env::var_os("DEVUP_EMIT_MEASURE").is_some() { + let artifacts = + super::atom_hoist_measurement::build_predeclared_measurement_artifacts() + .unwrap_or_else(|error| panic!("{error}")); + println!("{artifacts}"); } - manifest.push(']'); - fs::write(out.join("manifest.json"), manifest).unwrap(); - reset(); - set_atom_hoist(None); - println!("[EMIT] artifacts -> {}", out.display()); } - /// SPLIT-NATIVE LOCK: atom-level route-aware hoisting (global-named - /// shared-base + per-route delta) is a STRICT upgrade over the per-file mode - /// on the metrics that split actually competes on -- multi-route SESSION - /// bytes and incremental-deploy INVALIDATION bytes -- NOT on fresh-single- - /// route bytes (where per-file already hits the theoretical floor). - /// - /// This test supersedes an earlier "no win" lock that was built on a - /// measurement bug: enabling atom_hoist AFTER extraction left per-file class - /// names, so the same universal atom looked like N distinct atoms and never - /// hoisted -- making atom-B byte-identical to per-file (a no-op, not a - /// truth). The fix, asserted here, is that atom_hoist MUST be enabled BEFORE - /// extraction so atoms get GLOBAL (shared) names. + /// Compare usable sheets with predeclared shared-module reach and count + /// deployment invalidations after changing a common upstream declaration. #[test] #[serial] - // byte sizes are tiny so ratios are exact; doc prose names models literally - #[allow(clippy::cast_precision_loss, clippy::doc_markdown)] fn atom_b_beats_per_file_on_session_and_invalidation() { - use css::atom_hoist::set_atom_hoist; - use css::class_map::reset_class_map; - use css::file_map::reset_file_map; - use css::file_routes::{reset_file_routes, set_file_routes}; - use std::collections::{HashMap, HashSet}; - - // Realistic design-system workload: many shared primitives, fewer - // route-private atoms. Routes are disjoint on private atoms. - const ROUTES: usize = 8; - const UNIVERSAL: usize = 80; - const PRIVATE: usize = 25; - - let props = ["w", "h", "p", "m", "minW", "minH", "maxW", "maxH"]; - let atom = |key: &str, px: usize| format!(""); - let build_source = |elements: &[String]| -> String { - let body = elements.join(""); - format!("import {{ Box }} from \"@devup-ui/react\"; const x = <>{body};") - }; - let reset_engine = || { - { - let mut s = GLOBAL_STYLE_SHEET.lock().unwrap(); - *s = StyleSheet::default(); - } - reset_class_map(); - reset_file_map(); - reset_file_routes(); - register_theme_internal(sheet::theme::Theme::default()); - }; - - let universal_atoms: Vec = (0..UNIVERSAL) - .map(|i| atom(props[i % props.len()], 100_000 + i)) - .collect(); - let make_private = |route: usize| -> Vec { - (0..PRIVATE) - .map(|i| atom(props[i % props.len()], 1_000_000 + route * PRIVATE + i)) - .collect() - }; - let sources: Vec = (0..ROUTES) - .map(|r| { - let mut e = universal_atoms.clone(); - e.extend(make_private(r)); - build_source(&e) - }) - .collect(); - let extract_all = |single_css: bool| { - for (r, src) in sources.iter().enumerate() { - code_extract_internal( - &format!("r{r}.tsx"), - src, - "@devup-ui/react", - "df".to_string(), - single_css, - false, - false, - HashMap::new(), - ) - .unwrap(); - } - }; - - // ---- per-file: atom_hoist OFF, multi-css. Each chunk carries all of - // its route's atoms (universals duplicated into every chunk). ---- - reset_engine(); - extract_all(false); - let pf_base = with_style_sheet(|s| s.create_css(None, false)).len(); - let pf_chunks: Vec = (0..ROUTES) - .map(|r| with_style_sheet(|s| s.create_css(Some(&format!("r{r}.tsx")), false)).len()) - .collect(); - - // ---- atom-B: enable hoist + routes BEFORE extraction so atoms get - // GLOBAL names; universals (used by all ROUTES) hoist into the base, - // privates stay in their per-route delta. ---- - reset_engine(); - let mut fr = HashMap::new(); - for r in 0..ROUTES { - fr.insert(format!("r{r}.tsx"), HashSet::from([r as u32])); - } - set_file_routes(fr); - set_atom_hoist(Some(ROUTES)); - extract_all(false); - let ab_base = with_style_sheet(|s| s.create_css(None, false)).len(); - let ab_deltas: Vec = (0..ROUTES) - .map(|r| with_style_sheet(|s| s.create_css(Some(&format!("r{r}.tsx")), false)).len()) - .collect(); - set_atom_hoist(None); - reset_file_routes(); - reset_engine(); - - // Session = visit every route once (base cached after the first route). - let pf_session = pf_base + pf_chunks.iter().sum::(); - let ab_session = ab_base + ab_deltas.iter().sum::(); - // Invalidation = one route's styles change; returning user re-downloads - // only the file(s) whose hash changed. - let pf_invalidation = pf_chunks[0]; - let ab_invalidation = ab_deltas[0]; - - let session_margin = (pf_session as f64 - ab_session as f64) / pf_session as f64 * 100.0; - let invalidation_margin = - (pf_invalidation as f64 - ab_invalidation as f64) / pf_invalidation as f64 * 100.0; - println!( - "[SPLIT] base: per-file={pf_base}B atom-B={ab_base}B | chunk: per-file={}B atom-B-delta={}B", - pf_chunks[0], ab_deltas[0] - ); - println!( - "[SPLIT] session: per-file={pf_session}B atom-B={ab_session}B ({session_margin:.1}% smaller) | invalidation: per-file={pf_invalidation}B atom-B={ab_invalidation}B ({invalidation_margin:.1}% smaller)" - ); - - // Regression guard against the no-op-hoist bug: hoisting MUST have moved - // the universal atoms into the base, so the base is large and the delta - // is much smaller than a full per-file chunk. - assert!( - ab_base > pf_base + 500, - "hoist no-op: atom-B base ({ab_base}B) should hold the universal atoms, \ - but is barely larger than the empty per-file base ({pf_base}B). \ - atom_hoist was likely enabled AFTER extraction." - ); - assert!( - (ab_deltas[0] as f64) < (pf_chunks[0] as f64) * 0.6, - "hoist no-op: atom-B delta ({}B) should be far smaller than the full \ - per-file chunk ({}B) once universals are hoisted out", - ab_deltas[0], - pf_chunks[0] - ); - // The split-native wins this whole investigation hinges on. - assert!( - session_margin >= 15.0, - "atom-B should beat per-file on multi-route session bytes by >=15% \ - (got {session_margin:.1}%)" - ); - assert!( - invalidation_margin >= 30.0, - "atom-B should beat per-file on incremental-deploy invalidation by \ - >=30% (got {invalidation_margin:.1}%)" - ); + super::atom_hoist_measurement::assert_predeclared_shared_savings().unwrap(); } #[test] @@ -1548,7 +1444,8 @@ mod tests { Some("devup-ui-0.css".to_string()), false, Vec::new(), - ); + ) + .unwrap_or_else(|error| panic!("{error}")); // Test getters assert_ne!(output.code(), ""); @@ -1575,7 +1472,8 @@ mod tests { None, false, Vec::new(), - ); + ) + .unwrap_or_else(|error| panic!("{error}")); // Test updated_base_style getter let _ = output.updated_base_style(); @@ -1638,7 +1536,8 @@ mod tests { Some("devup-ui.css".to_string()), true, // import_main_css = true Vec::new(), - ); + ) + .unwrap_or_else(|error| panic!("{error}")); assert!(output.css().is_some()); } @@ -1677,7 +1576,8 @@ mod tests { None, false, Vec::new(), - ); + ) + .unwrap_or_else(|error| panic!("{error}")); // The updated_base_style should be true because global CSS was removed assert!(output.updated_base_style()); @@ -1873,7 +1773,7 @@ mod tests { custom_sheet.add_property("custom.tsx", "color", 0, "red", None, Some(0), None); // Import the custom sheet - import_sheet_internal(custom_sheet); + import_sheet_internal(custom_sheet).unwrap_or_else(|error| panic!("{error}")); // Verify the sheet was imported by exporting it let result = export_sheet_internal(); @@ -1929,6 +1829,60 @@ mod tests { assert!(json_str.starts_with('{') || json_str.starts_with("[]")); } + #[test] + #[serial] + fn test_exported_maps_are_canonical_json() { + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + css::file_map::reset_canonical_map(); + css::class_map::set_class_map(HashMap::from([ + ( + "b.tsx".to_string(), + HashMap::from([("z".to_string(), 1), ("a".to_string(), 0)]), + ), + ("a.tsx".to_string(), HashMap::from([("k".to_string(), 0)])), + ])); + assert_eq!( + export_class_map_internal().unwrap(), + r#"{"a.tsx":{"k":0},"b.tsx":{"a":0,"z":1}}"# + ); + seed_file_map(vec!["b.tsx".to_string(), "a.tsx".to_string()]); + assert_eq!( + export_file_map_internal().unwrap(), + r#"{"a.tsx":0,"b.tsx":1}"# + ); + import_canonical_map_internal(HashMap::from([ + ("y".to_string(), "b".to_string()), + ("x".to_string(), "a".to_string()), + ])); + assert_eq!( + export_canonical_map_internal().unwrap(), + r#"{"x":"a","y":"b"}"# + ); + reset_build_state(); + assert_eq!(export_class_map_internal().unwrap(), "{}"); + assert_eq!(export_file_map_internal().unwrap(), "{}"); + assert_eq!(export_canonical_map_internal().unwrap(), "{}"); + assert_eq!(get_prefix(), None); + } + + #[test] + #[serial] + fn test_numbers_do_not_depend_on_the_order_files_are_seen() { + let files = ["src/b.tsx", "src/a.tsx", "src/c.tsx"]; + let mut maps = Vec::new(); + for order in [[0, 1, 2], [2, 1, 0], [1, 2, 0]] { + reset_build_state_internal(); + seed_file_map(files.iter().map(ToString::to_string).collect()); + for index in order { + let _ = css::file_map::get_file_num_by_filename(files[index]); + } + maps.push(export_file_map_internal().unwrap()); + } + assert_eq!(maps[0], r#"{"src/a.tsx":0,"src/b.tsx":1,"src/c.tsx":2}"#); + assert_eq!(maps[0], maps[1]); + assert_eq!(maps[0], maps[2]); + } #[test] #[serial] fn test_code_extract_internal_success() { diff --git a/bindings/devup-ui-wasm/src/naming_root_tests.rs b/bindings/devup-ui-wasm/src/naming_root_tests.rs new file mode 100644 index 000000000..b6b0b73ab --- /dev/null +++ b/bindings/devup-ui-wasm/src/naming_root_tests.rs @@ -0,0 +1,256 @@ +use super::*; +use rstest::rstest; +use serial_test::serial; + +const SOURCE: &str = "import {Box} from '@devup-ui/react'; export const x=;"; + +fn compile(file: &str) -> Output { + code_extract_internal( + file, + SOURCE, + "@devup-ui/react", + "df".into(), + false, + false, + false, + HashMap::new(), + ) + .unwrap_or_else(|error| panic!("{error}")) +} + +fn snapshot() -> StyleSheet { + serde_json::from_str(&export_sheet_internal().unwrap_or_else(|error| panic!("{error}"))) + .unwrap_or_else(|error| panic!("{error}")) +} + +#[test] +#[serial] +fn configured_prefix_is_not_misread_as_a_generated_scope_in_shared_css() { + // Given + reset_build_state_internal(); + set_prefix(Some("du-FLa-".into())); + code_extract_internal( + "x.tsx", + SOURCE, + "@devup-ui/react", + "df".into(), + true, + false, + false, + HashMap::new(), + ) + .unwrap_or_else(|error| panic!("{error}")); + let incoming = snapshot(); + // When + let imported = import_sheet_internal(incoming); + // Then + assert_eq!(imported, Ok(())); + reset_build_state_internal(); +} + +#[rstest] +#[case("du-FLa-")] +#[case("FHprefix-")] +#[serial] +fn configured_prefix_does_not_require_a_fallback_claim_for_a_d9_scope(#[case] prefix: &str) { + reset_build_state_internal(); + set_prefix(Some(prefix.into())); + seed_file_map(vec!["x.tsx".into()]); + let styles = std::iter::once(ExtractStyleValue::Static( + extractor::extract_style::extract_static_style::ExtractStaticStyle::new( + "color", "red", 0, None, + ) + .with_naming(css::Naming::Risky), + )) + .collect(); + with_style_sheet_mut(|sheet| sheet.update_styles(&styles, "x.tsx", false)) + .unwrap_or_else(|error| panic!("{error}")); + let incoming = snapshot(); + + let restored = import_sheet_internal(incoming); + + assert_eq!(restored, Ok(())); + reset_build_state_internal(); +} + +#[rstest] +#[case("C:/repo/a", "C:\\repo\\a\\src\\x.tsx", "src/x.tsx")] +#[case("/repo/a", "/repo/a/src/../x.tsx", "x.tsx")] +#[case("/repo/a", "/repo/shared/x.tsx", "../shared/x.tsx")] +#[case("C:/repo/a", "D:\\shared\\x.tsx", "d:/shared/x.tsx")] +#[case("/repo/a", "../shared/x.tsx", "../shared/x.tsx")] +#[case("//server/share/a", "//server/other/x.tsx", "//server/other/x.tsx")] +#[serial] +fn naming_keys_are_relative_when_a_compatible_root_exists( + #[case] root: &str, + #[case] file: &str, + #[case] expected: &str, +) { + // Given + reset_build_state_internal(); + set_naming_root(Some(root.into()), None); + // When + let key = css::naming_root::key(file); + // Then + assert_eq!(key, expected); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn no_root_and_reset_preserve_the_existing_raw_key_contract() { + // Given + set_naming_root(Some("C:/repo".into()), None); + reset_build_state_internal(); + // When + let key = css::naming_root::key("C:\\repo\\x.tsx"); + // Then + assert_eq!(key, "C:\\repo\\x.tsx"); +} + +#[test] +#[serial] +fn checkout_roots_do_not_change_full_output_when_only_naming_context_differs() { + // Given: no D9 IDs, identical source and root-relative file/sheet identities. + let mut outputs = Vec::new(); + // When: two independent builds use the new channel, not rewritten extraction IDs. + for root in ["/checkout/one", "/different/place/two"] { + reset_build_state_internal(); + css::debug::set_debug(false); + set_naming_root(Some(root.into()), None); + let file = format!("{root}/src/x.tsx"); + let output = compile(&file); + let sheet = snapshot(); + assert!(sheet.properties.contains_key(&file)); + let claims = sheet + .names + .iter() + .filter(|(name, _)| name.starts_with('F') && !name.contains('-')) + .collect::>(); + assert_eq!(claims.len(), 1); + assert_eq!(claims[0].1.content, "src/x.tsx"); + assert_eq!(claims[0].1.descriptor, b"src/x.tsx"); + outputs.push((output.code(), sheet.create_css(Some(&file), false))); + } + // Then: every emitted JS and CSS byte matches; delivery keys remain absolute. + assert_eq!(outputs[0], outputs[1]); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn cwd_relative_ids_keep_checkout_stable_names_when_compiler_context_differs() { + // Given: Webpack keeps cwd-relative IDs even when compiler.context is elsewhere. + let mut outputs = Vec::new(); + // When + for checkout in ["one", "two"] { + reset_build_state_internal(); + set_naming_root( + Some(format!("/checkouts/{checkout}")), + Some("/runner".into()), + ); + let file = format!("../checkouts/{checkout}/src/x.tsx"); + let output = compile(&file); + outputs.push(( + output.code(), + with_style_sheet(|sheet| { + assert!(sheet.properties.contains_key(&file)); + sheet.create_css(Some(&file), false) + }), + )); + } + // Then: resolver/delivery IDs stay raw, but every CSS/JS byte is checkout-stable. + assert_eq!(outputs[0], outputs[1]); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn scope_collision_reports_full_relative_keys_and_both_real_locations() { + // Given: a narrow width forces two different sheet keys to the same prefix. + reset_build_state_internal(); + set_naming_root(Some("/checkout".into()), None); + let bits = css::content_hash::FingerprintBits::new(1).unwrap_or_else(|| panic!("valid width")); + let first_name = + css::content_name::ContentName::scope("src/first.tsx").name_with_bits("", bits); + let second = (0..64) + .map(|index| format!("src/second{index}.tsx")) + .find(|key| { + css::content_name::ContentName::scope(key).name_with_bits("", bits) == first_name + }) + .unwrap_or_else(|| panic!("narrow scope collision fixture")); + let second_file = format!("/checkout/{second}"); + let source = "import {Box} from '@devup-ui/react';export const x=;"; + let styles = + extractor::extract_without_source_map(&second_file, source, ExtractOption::default()) + .unwrap_or_else(|error| panic!("{error}")) + .styles; + let mut sheet = StyleSheet::default(); + let first_styles = extractor::extract_without_source_map( + "/checkout/src/first.tsx", + source, + ExtractOption::default(), + ) + .unwrap_or_else(|error| panic!("{error}")) + .styles; + sheet.names = sheet + .preflight_styles_with_bits(&first_styles, ("/checkout/src/first.tsx", false), bits) + .unwrap_or_else(|error| panic!("{error}")); + // When + let error = sheet + .preflight_styles_with_bits(&styles, (&second_file, false), bits) + .err() + .unwrap_or_else(|| panic!("scope collision accepted")) + .to_string(); + // Then + for value in [ + "src/first.tsx", + &second, + "/checkout/src/first.tsx:1:", + &format!("{second_file}:1:"), + ] { + assert!(error.contains(value), "{error}"); + } + reset_build_state_internal(); +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn cache_rejects_missing_or_corrupt_scope_claims_before_any_extraction(#[case] corrupt: bool) { + // Given + reset_build_state_internal(); + set_naming_root(Some("/checkout".into()), None); + let cold = compile("/checkout/src/x.tsx"); + let cold_css = with_style_sheet(|sheet| sheet.create_css(Some("/checkout/src/x.tsx"), false)); + let mut incoming = snapshot(); + let scope = incoming + .names + .keys() + .find(|name| name.starts_with('F')) + .unwrap_or_else(|| panic!("missing scope")) + .clone(); + if corrupt { + incoming + .names + .get_mut(&scope) + .unwrap_or_else(|| panic!("missing scope")) + .descriptor + .push(1); + } else { + incoming.names.remove(&scope); + } + reset_build_state_internal(); + set_naming_root(Some("/checkout".into()), None); + // When + assert_eq!(import_sheet_internal(incoming), Ok(())); + // Then: corrupt scope proof adopts no aliases; the names-only root stays configured. + assert_eq!(with_style_sheet(|sheet| sheet.names.len()), 0); + assert_eq!(compile("/checkout/src/x.tsx").code(), cold.code()); + assert_eq!( + with_style_sheet(|sheet| sheet.create_css(Some("/checkout/src/x.tsx"), false)), + cold_css + ); + reset_build_state_internal(); +} diff --git a/bindings/devup-ui-wasm/src/original_counter_tests.rs b/bindings/devup-ui-wasm/src/original_counter_tests.rs new file mode 100644 index 000000000..689c27acf --- /dev/null +++ b/bindings/devup-ui-wasm/src/original_counter_tests.rs @@ -0,0 +1,229 @@ +use super::*; +use rstest::rstest; +use serial_test::serial; +mod cache; +mod controls; +mod environments; + +fn setup() { + reset_build_state_internal(); + css::debug::set_debug(false); + seed_file_map(vec!["a.tsx".into(), "child.tsx".into()]); + set_canonical_map(HashMap::from([("child.tsx".into(), "a.tsx".into())])); +} + +fn compile(file: &str, source: &str) -> Output { + code_extract_internal( + file, + source, + "@devup-ui/react", + "df".into(), + false, + false, + false, + HashMap::new(), + ) + .unwrap_or_else(|error| panic!("{error}")) +} + +fn delivered() -> String { + with_style_sheet(|sheet| sheet.create_css(Some("a.tsx"), false)) +} + +#[rstest] +#[case("import {Box} from '@devup-ui/react';export const x=;")] +#[case("import {css} from '@devup-ui/react';export const x=css({color:'red'});")] +#[case( + "import * as stylex from '@stylexjs/stylex';export const x=stylex.create({red:{color:'red'}});" +)] +#[serial] +fn single_atom_uses_each_original_first_slot(#[case] source: &str) { + // Given + setup(); + // When + let outputs: Vec<_> = ["child.tsx", "a.tsx"] + .into_iter() + .map(|file| compile(file, source)) + .collect(); + // Then + for (output, name) in outputs.iter().zip(["b-a", "a-a"]) { + assert!(output.code().contains(name), "{}", output.code()); + assert!(delivered().contains(&format!(".{name}{{color:red}}"))); + } + css::class_map::with_class_map(|map| { + assert_eq!(map.len(), 2); + assert!(map.values().all(|slots| slots.len() == 1)); + }); + with_style_sheet(|sheet| assert_eq!(sheet.names.len(), 0)); + reset_build_state_internal(); +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn collapsed_literals_keep_original_first_slots_when_source_order_reverses(#[case] reverse: bool) { + // Given: two numbered originals deliver into the same canonical sheet. + setup(); + let mut inputs = [("a.tsx", "red", "a"), ("child.tsx", "blue", "b")]; + if reverse { + inputs.reverse(); + } + // When: each literal is compiled through the real binding pipeline. + for (file, color, owner) in inputs { + let source = format!( + "import {{Box}} from '@devup-ui/react';export const x=;" + ); + let output = compile(file, &source); + // Then: names, canonical delivery, allocator and registry agree. + assert!( + output + .code() + .contains(&format!("className=\"{owner}-a {owner}-b\"")), + "{}", + output.code() + ); + assert!(output.code().contains("df/devup-ui-0.css")); + assert_eq!(output.css_file(), Some("df/devup-ui-0.css".into())); + let css = delivered(); + assert!( + css.contains(&format!(".{owner}-a{{color:{color}}}")), + "{css}" + ); + assert!( + css.contains(&format!(".{owner}-b{{padding:16px}}")), + "{css}" + ); + } + css::class_map::with_class_map(|map| { + assert_eq!(map.len(), 2); + for id in ["D9-0", "D9-1"] { + let slots = map + .get(id) + .unwrap_or_else(|| panic!("missing {id}: {map:?}")); + assert_eq!(slots.len(), 2); + assert_eq!( + slots + .values() + .copied() + .collect::>(), + [0, 1].into_iter().collect() + ); + } + }); + with_style_sheet(|sheet| { + assert_eq!(sheet.names.len(), 0); + assert_eq!(sheet.properties.len(), 1); + }); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn equal_content_survives_deferred_union_when_original_scopes_have_ended() { + // Given: equal declarations captured in independent raw-source scopes. + setup(); + let source = "import {Box} from '@devup-ui/react';export const x=;"; + let outputs: Vec<_> = ["a.tsx", "child.tsx"] + .into_iter() + .map(|file| { + extract_without_source_map( + file, + source, + ExtractOption { + css_dir: "df".into(), + ..Default::default() + }, + ) + .unwrap_or_else(|error| panic!("{error}")) + }) + .collect(); + let union: FxHashSet<_> = outputs + .iter() + .flat_map(|output| output.styles.iter().cloned()) + .collect(); + // When: the union is emitted only after both extraction scopes have ended. + let mut sheet = StyleSheet::default(); + sheet + .update_styles(&union, "a.tsx", false) + .unwrap_or_else(|error| panic!("{error}")); + // Then: neither owner's reference was discarded and emission uses existing slots. + assert_eq!(union.len(), 2); + let css = sheet.create_css(Some("a.tsx"), false); + for (output, name) in outputs.iter().zip(["a-a", "b-a"]) { + assert!(output.code.contains(name), "{}", output.code); + assert!(css.contains(&format!(".{name}{{color:red}}")), "{css}"); + } + assert_eq!(sheet.names.len(), 0); + css::class_map::with_class_map(|map| { + assert_eq!(map.len(), 2); + assert!(map.values().all(|slots| slots.len() == 1)); + }); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn dynamic_site_owner_names_the_class_and_delivered_reset_without_registry_claims() { + // Given + setup(); + let source = "import {Box} from '@devup-ui/react';export const x=(tone)=>;"; + // When + let output = compile("child.tsx", source); + // Then + assert!(output.code().contains("b-a"), "{}", output.code()); + let css = delivered(); + let variable = css::Site { + file: css::sparse_site::SourceFile::D9(1), + at: source + .find("tone}/>") + .unwrap_or_else(|| panic!("missing assignment")), + role: 0, + } + .variable_name(""); + assert!(output.code().contains(&variable), "{}", output.code()); + assert!( + css.contains(&format!(".b-a{{{variable}:initial;color:var({variable})}}")), + "{css}" + ); + css::class_map::with_class_map(|map| { + assert_eq!(map.len(), 1); + assert_eq!(map["D9-1"].len(), 1); + }); + with_style_sheet(|sheet| assert_eq!(sheet.names.len(), 0)); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn conditional_typography_constructor_keeps_each_original_counter() { + // Given + setup(); + register_theme_internal( + serde_json::from_value(serde_json::json!({"typography":{"body":{"fontSize":"14px"}}})) + .unwrap_or_else(|error| panic!("{error}")), + ); + let source = + "import {Box} from '@devup-ui/react';export const x=;"; + // When + let outputs: Vec<_> = ["a.tsx", "child.tsx"] + .into_iter() + .map(|file| compile(file, source)) + .collect(); + // Then + for (output, name) in outputs.iter().zip(["a-a", "b-a"]) { + assert!(output.code().contains(name), "{}", output.code()); + assert!( + delivered().contains(&format!(".{name}:hover{{font-size:14px}}")), + "{}", + delivered() + ); + } + css::class_map::with_class_map(|map| { + assert_eq!(map.len(), 2); + assert!(map.values().all(|slots| slots.len() == 1)); + }); + with_style_sheet(|sheet| assert_eq!(sheet.names.len(), 0)); + reset_build_state_internal(); + register_theme_internal(sheet::theme::Theme::default()); +} diff --git a/bindings/devup-ui-wasm/src/original_counter_tests/cache.rs b/bindings/devup-ui-wasm/src/original_counter_tests/cache.rs new file mode 100644 index 000000000..33dfd613f --- /dev/null +++ b/bindings/devup-ui-wasm/src/original_counter_tests/cache.rs @@ -0,0 +1,57 @@ +use super::*; + +#[rstest] +#[case("")] +#[case("du-FLa-")] +#[serial] +fn cache_restores_original_counters_without_extra_slots_or_dangling_references( + #[case] prefix: &str, +) { + // Given + setup(); + set_prefix(Some(prefix.into())); + let sources = [ + ( + "a.tsx", + "import {Box} from '@devup-ui/react';export const x=;", + ), + ( + "child.tsx", + "import {Box} from '@devup-ui/react';export const x=;", + ), + ]; + let before = sources.map(|(file, source)| compile(file, source).code()); + let sheet_json = export_sheet_internal().unwrap_or_else(|error| panic!("{error}")); + let classes = export_class_map_internal().unwrap_or_else(|error| panic!("{error}")); + let files = css::file_map::get_file_map(); + let canonical_map = css::file_map::get_canonical_map(); + let ids = css::file_map::get_original_ids(); + let css_before = delivered(); + reset_build_state_internal(); + set_prefix(Some(prefix.into())); + set_file_map(files.clone()); + set_canonical_map(canonical_map.clone()); + set_class_map(serde_json::from_str(&classes).unwrap_or_else(|error| panic!("{error}"))); + import_sheet_internal( + serde_json::from_str(&sheet_json).unwrap_or_else(|error| panic!("{error}")), + ) + .unwrap_or_else(|error| panic!("{error}")); + // When + let after: Vec<_> = sources + .iter() + .rev() + .map(|(file, source)| compile(file, source).code()) + .collect(); + // Then + assert_eq!(after, before.into_iter().rev().collect::>()); + assert_eq!(delivered(), css_before); + assert_eq!( + export_class_map_internal().unwrap_or_else(|error| panic!("{error}")), + classes + ); + assert_eq!(css::file_map::get_file_map(), files); + assert_eq!(css::file_map::get_canonical_map(), canonical_map); + assert_eq!(css::file_map::get_original_ids(), ids); + with_style_sheet(|sheet| assert_eq!(sheet.names.len(), 0)); + reset_build_state_internal(); +} diff --git a/bindings/devup-ui-wasm/src/original_counter_tests/controls.rs b/bindings/devup-ui-wasm/src/original_counter_tests/controls.rs new file mode 100644 index 000000000..62d20f59a --- /dev/null +++ b/bindings/devup-ui-wasm/src/original_counter_tests/controls.rs @@ -0,0 +1,159 @@ +use super::*; +use extractor::extract_style::{ExtractStyleProperty, extract_static_style::ExtractStaticStyle}; + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn a_numbered_root_never_lends_its_counter_when_the_member_is_unnumbered(#[case] reverse: bool) { + // Given + reset_build_state_internal(); + seed_file_map(vec!["a.tsx".into()]); + set_canonical_map(HashMap::from([("child.tsx".into(), "a.tsx".into())])); + let mut files = ["a.tsx", "child.tsx"]; + if reverse { + files.reverse(); + } + // When + for file in files { + let output = compile( + file, + "import {Box} from '@devup-ui/react';export const x=;", + ); + let name = if file == "a.tsx" { + "a-a" + } else { + "a-OLcolor-vred" + }; + // Then + assert!(output.code().contains(name), "{}", output.code()); + assert!(delivered().contains(&format!(".{name}{{color:red}}"))); + } + css::class_map::with_class_map(|map| { + assert_eq!(map.len(), 1); + assert_eq!(map["D9-0"].len(), 1); + }); + with_style_sheet(|sheet| assert_eq!(sheet.names.len(), 1)); + reset_build_state_internal(); +} + +#[rstest] +#[case(false, false)] +#[case(true, false)] +#[case(false, true)] +#[serial] +fn shared_delivery_stays_content_named_when_single_or_hoisted( + #[case] single: bool, + #[case] hoisted: bool, +) { + // Given + setup(); + if hoisted { + css::atom_hoist::set_atom_hoist(Some(1)); + css::atom_hoist::restore_atom_plan(Some(std::iter::once("a.tsx".into()).collect())); + } + let order = if single || hoisted { 255 } else { 0 }; + let source = format!( + "import {{Box,globalCss}} from '@devup-ui/react';globalCss({{body:{{color:'blue'}}}});export const x=;" + ); + // When + let output = code_extract_internal( + "child.tsx", + &source, + "@devup-ui/react", + "df".into(), + single, + false, + false, + HashMap::new(), + ) + .unwrap_or_else(|error| panic!("{error}")); + // Then + assert!(output.code().contains("OLcolor-vred"), "{}", output.code()); + let css = with_style_sheet(|sheet| sheet.create_css(None, false)); + assert!(css.contains("color:red"), "{css}"); + assert!(css.contains(concat!("body{", "color:blue", "}")), "{css}"); + css::class_map::with_class_map(|map| assert_eq!(map.len(), 0)); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn diagnostics_and_naming_transitions_preserve_semantic_identity() { + // Given + setup(); + let source = "import {Box} from '@devup-ui/react';export const x=;"; + let mut styles: Vec<_> = ["a.tsx", "child.tsx"] + .into_iter() + .map(|file| { + extract_without_source_map(file, source, ExtractOption::default()) + .unwrap_or_else(|error| panic!("{error}")) + .styles + .into_iter() + .next() + .unwrap_or_else(|| panic!("missing style")) + }) + .collect(); + // When + for style in &mut styles { + match style { + ExtractStyleValue::Static(style) => { + style.origin = css::style_origin::Origin::from_location( + css::style_origin::RealLocation::ModuleExport { + file: "unrelated-diagnostic.css.ts".into(), + binding: None, + }, + ); + } + other => panic!("expected literal: {other:?}"), + } + } + // Then + for (style, name) in styles.iter().zip(["a-a", "b-a"]) { + assert_eq!( + style + .extract(Some("a.tsx")) + .unwrap_or_else(|| panic!("missing class")) + .to_string(), + name + ); + } + assert_ne!(styles[0], styles[1]); + for style in &mut styles { + style.join_naming(css::Naming::Risky); + } + assert_eq!(styles[0], styles[1]); + assert_eq!(styles.iter().cloned().collect::>().len(), 1); + let literal = ExtractStaticStyle::new("color", "red", 0, None); + assert_eq!(literal.extract(Some("child.tsx")).to_string(), "b-a"); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn counter_only_atoms_do_not_claim_an_unused_unnumbered_canonical_scope() { + // Given + reset_build_state_internal(); + seed_file_map(vec!["child.tsx".into()]); + set_canonical_map(HashMap::from([( + "child.tsx".into(), + "unseeded-root.tsx".into(), + )])); + // When + let output = compile( + "child.tsx", + "import {Box} from '@devup-ui/react';export const x=;", + ); + // Then + assert!(output.code().contains("a-a")); + with_style_sheet(|sheet| { + assert_eq!(sheet.names.len(), 0); + assert!( + sheet + .create_css(Some("unseeded-root.tsx"), false) + .contains(concat!(".a-a{", "color:red", "}")) + ); + }); + css::class_map::with_class_map(|map| assert_eq!(map["D9-0"].len(), 1)); + reset_build_state_internal(); +} diff --git a/bindings/devup-ui-wasm/src/original_counter_tests/environments.rs b/bindings/devup-ui-wasm/src/original_counter_tests/environments.rs new file mode 100644 index 000000000..87c5a7f59 --- /dev/null +++ b/bindings/devup-ui-wasm/src/original_counter_tests/environments.rs @@ -0,0 +1,255 @@ +use super::*; + +#[test] +#[serial] +fn imported_outer_render_condition_keeps_css_and_original_counter_classes() { + // Given: D9-1 owns both literal atoms despite canonical delivery through D9-0. + let source = "import {Box,css} from '@devup-ui/react';import {enabled} from './tokens';export const witness=css({color:enabled?'red':'red'});export const x=;export const y=enabled&&;"; + let mut witnesses = Vec::new(); + for enabled in [Some(true), Some(false), None] { + setup(); + let calls = std::rc::Rc::new(std::cell::Cell::new(0)); + let provider_calls = std::rc::Rc::clone(&calls); + let resolver = move |specifier: &str, importer: &str| { + assert_eq!(specifier, "./tokens"); + assert_eq!(importer, "child.tsx"); + provider_calls.set(provider_calls.get() + 1); + enabled.map(|value| ResolvedModule { + path: "tokens.ts".into(), + code: format!("export const enabled={value};"), + }) + }; + // When: the same source is extracted with each distinct provider outcome. + let output = code_extract_with_modules_internal( + "child.tsx", + source, + "@devup-ui/react", + "df".into(), + false, + false, + false, + HashMap::new(), + &resolver, + ) + .unwrap_or_else(|error| panic!("enabled={enabled:?}: {error}")); + // Then: style resolution changes, but the runtime render guard and atoms do not. + assert!(calls.get() > 0, "provider was not called: {enabled:?}"); + let code = output.code(); + let controller = code + .split("export const y = ") + .nth(1) + .and_then(|expression| expression.split_once("&&")) + .map_or_else( + || panic!("missing render condition: {code}"), + |(condition, _)| condition.trim(), + ); + assert_eq!(controller, "enabled", "{code}"); + assert!( + code.lines().any(|line| line.starts_with("import ") + && line.contains("enabled") + && line.contains("./tokens")), + "runtime import was removed: {code}" + ); + let witness = code + .split("export const witness = ") + .nth(1) + .and_then(|expression| expression.split("export const x = ").next()) + .unwrap_or_else(|| panic!("missing style-resolution witness: {code}")); + assert_eq!(witness.contains("enabled"), enabled.is_none(), "{code}"); + let classes: Vec<_> = code + .split("className=") + .skip(1) + .map(|field| { + field + .strip_prefix('"') + .and_then(|literal| literal.split_once('"')) + .map_or_else( + || panic!("nonliteral className: {code}"), + |(name, _)| name.to_owned(), + ) + }) + .collect(); + assert_eq!(classes, ["b-a", "b-b"], "{code}"); + let css = delivered(); + assert!(css.contains(".b-a{padding:16px}"), "{css}"); + assert!(css.contains(".b-b{height:12px}"), "{css}"); + assert!( + css.contains(concat!(".a-RLcolor-vred{", "color:red", "}")), + "{css}" + ); + assert_eq!(output.css(), Some(css.clone())); + assert_eq!(output.css_file(), Some("df/devup-ui-0.css".into())); + css::class_map::with_class_map(|map| { + assert_eq!(map.len(), 1); + let slots = map + .get("D9-1") + .unwrap_or_else(|| panic!("missing original owner: {map:?}")); + assert_eq!(slots.len(), 2); + assert_eq!( + slots + .values() + .copied() + .collect::>(), + [0, 1].into_iter().collect() + ); + }); + with_style_sheet(|sheet| { + assert_eq!(sheet.names.len(), 1); + assert!(sheet.names.contains_key("a-RLcolor-vred")); + }); + println!( + "enabled={enabled:?}; classes={classes:?}; full_css={css:?}; controller={controller}" + ); + witnesses.push((css, classes)); + reset_build_state_internal(); + } + for pair in witnesses.windows(2) { + assert_eq!(pair[0], pair[1]); + } +} + +#[rstest] +#[case( + "export const tone='red';export const enabled=true;export const styles={color:'green',m:2};" +)] +#[case( + "export const tone='blue';export const enabled=false;export const styles={bg:'yellow',w:'8px'};" +)] +#[serial] +fn risky_import_values_presence_and_shape_leave_local_padding_slot_unchanged( + #[case] module: &'static str, +) { + // Given + setup(); + let resolver = |specifier: &str, _: &str| { + (specifier == "./tokens").then(|| ResolvedModule { + path: "tokens.ts".into(), + code: module.into(), + }) + }; + let source = "import {Box} from '@devup-ui/react';import {tone,enabled,styles} from './tokens';export const x=;export const y=enabled&&;"; + // When + let output = code_extract_with_modules_internal( + "child.tsx", + source, + "@devup-ui/react", + "df".into(), + false, + false, + false, + HashMap::new(), + &resolver, + ) + .unwrap_or_else(|error| panic!("{error}")); + // Then + assert!(output.code().contains("b-a"), "{}", output.code()); + assert!( + delivered().contains(".b-a{padding:16px}"), + "{}", + delivered() + ); + css::class_map::with_class_map(|map| { + assert_eq!(map.len(), 1); + assert_eq!(map["D9-1"].len(), 2); + }); + with_style_sheet(|sheet| { + assert!( + sheet + .names + .keys() + .any(|name| name.contains("-RL") || name.contains("-RH")) + ); + assert!(!sheet.names.contains_key("b-a")); + }); + reset_build_state_internal(); +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn nested_stylesheet_scope_restores_consumer_owner_without_copying_producer_rules( + #[case] producer_first: bool, +) { + // Given + reset_build_state_internal(); + seed_file_map(vec!["a.css.ts".into(), "child.css.ts".into()]); + set_canonical_map(HashMap::from([("child.css.ts".into(), "a.css.ts".into())])); + let producer = "import {style} from '@devup-ui/react';export const red=style({color:'red'});"; + let consumer = "import {style} from '@devup-ui/react';import {red} from './a.css';export const selected=style({selectors:{[`.${red} &`]:{padding:'4px'}}});export const local=style({margin:'8px'});"; + let resolver = |specifier: &str, _: &str| { + (specifier == "./a.css").then(|| ResolvedModule { + path: "a.css.ts".into(), + code: producer.into(), + }) + }; + let run_producer = || { + code_extract_with_modules_internal( + "a.css.ts", + producer, + "@devup-ui/react", + "df".into(), + false, + false, + false, + HashMap::new(), + &resolver, + ) + .unwrap_or_else(|error| panic!("{error}")) + }; + if producer_first { + run_producer(); + } + // When + let output = code_extract_with_modules_internal( + "child.css.ts", + consumer, + "@devup-ui/react", + "df".into(), + false, + false, + false, + HashMap::new(), + &resolver, + ) + .unwrap_or_else(|error| panic!("{error}")); + let before_producer = with_style_sheet(|sheet| sheet.create_css(Some("a.css.ts"), false)); + if !producer_first { + run_producer(); + } + // Then + let code = output.code(); + let selected = code + .lines() + .find_map(|line| { + line.strip_prefix("export const selected = \"") + .and_then(|line| line.strip_suffix("\";")) + }) + .unwrap_or_else(|| panic!("missing selected export: {code}")); + assert!(selected.starts_with("a-RH"), "{code}"); + assert!(code.contains("a-RLmargin-v8px"), "{code}"); + if !producer_first { + assert!(!before_producer.contains("color:red"), "{before_producer}"); + } + let css = with_style_sheet(|sheet| sheet.create_css(Some("a.css.ts"), false)); + assert!(css.contains(concat!(".a-a{", "color:red", "}")), "{css}"); + assert!( + css.contains(&format!(".a-a .{selected}{{padding:4px}}")), + "{css}" + ); + assert!( + css.contains(concat!(".a-RLmargin-v8px{", "margin:8px", "}")), + "{css}" + ); + assert_eq!(css.matches("color:red").count(), 1); + css::class_map::with_class_map(|map| { + assert_eq!(map.len(), 1); + assert_eq!(map["D9-0"].len(), 1); + }); + with_style_sheet(|sheet| { + assert_eq!(sheet.names.len(), 2); + assert!(sheet.names.contains_key(selected)); + assert!(sheet.names.contains_key("a-RLmargin-v8px")); + }); + reset_build_state_internal(); +} diff --git a/bindings/devup-ui-wasm/src/prefix_cache_tests.rs b/bindings/devup-ui-wasm/src/prefix_cache_tests.rs new file mode 100644 index 000000000..ffa6dbaa6 --- /dev/null +++ b/bindings/devup-ui-wasm/src/prefix_cache_tests.rs @@ -0,0 +1,215 @@ +use super::*; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case("OL")] +#[case("RL")] +#[case("KL")] +#[case("OH")] +#[serial] +fn seeded_counter_cache_round_trips_when_prefix_resembles_content(#[case] prefix: &str) { + // Given: actual safe-own counter output under an unrestricted configured prefix. + reset_build_state_internal(); + set_prefix(Some(prefix.into())); + seed_file_map(vec!["x.tsx".into()]); + let output = code_extract_internal( + "x.tsx", + "import {Box} from '@devup-ui/react';export const x=;", + "@devup-ui/react", + "df".into(), + false, + false, + false, + HashMap::new(), + ) + .unwrap_or_else(|error| panic!("{error}")); + let incoming: StyleSheet = + serde_json::from_str(&export_sheet_internal().unwrap_or_else(|error| panic!("{error}"))) + .unwrap_or_else(|error| panic!("{error}")); + let class = format!("{prefix}a-a"); + assert!(output.code().contains(&class)); + assert!(!incoming.names.contains_key(&class)); + let before = incoming.create_css(Some("x.tsx"), false); + reset_build_state_internal(); + set_prefix(Some(prefix.into())); + // When + let imported = import_sheet_internal(incoming); + // Then: cached CSS remains usable instead of being mistaken for an unclaimed atom. + assert_eq!(imported, Ok(())); + assert_eq!( + with_style_sheet(|sheet| sheet.create_css(Some("x.tsx"), false)), + before + ); + reset_build_state_internal(); +} + +fn compiled_content(single: bool) -> Output { + code_extract_internal( + "x.tsx", + "import {Box,keyframes} from '@devup-ui/react';export const k=keyframes({from:{opacity:0},to:{opacity:1}});export const x=;", + "@devup-ui/react", + "df".into(), + single, + false, + false, + HashMap::new(), + ).unwrap_or_else(|error| panic!("{error}")) +} + +fn exported_content(prefix: &str, single: bool) -> StyleSheet { + reset_build_state_internal(); + set_prefix(Some(prefix.into())); + compiled_content(single); + serde_json::from_str(&export_sheet_internal().unwrap_or_else(|error| panic!("{error}"))) + .unwrap_or_else(|error| panic!("{error}")) +} + +#[rstest] +#[case("OL")] +#[case("RL")] +#[case("KL")] +#[case("OH")] +#[serial] +fn generated_cache_round_trips_when_prefix_resembles_content( + #[case] prefix: &str, + #[values(false, true)] single: bool, +) { + // Given: genuine short/hashed content atoms and global keyframes, not legacy counters. + let incoming = exported_content(prefix, single); + let namespace = if single { + prefix.into() + } else { + format!("{prefix}FLx_ptsx-") + }; + assert!( + incoming + .names + .contains_key(&format!("{namespace}OLcolor-vred")) + ); + assert!( + incoming + .names + .keys() + .any(|name| name.starts_with(&format!("{namespace}OH"))) + ); + assert_eq!( + incoming + .keyframes + .values() + .map(BTreeMap::len) + .sum::(), + 1 + ); + let before = incoming.create_css(None, false); + let claims = incoming.names.clone(); + reset_build_state_internal(); + set_prefix(Some(prefix.into())); + // When + let imported = import_sheet_internal(incoming); + // Then: the real imported CSS and exact claims survive the prefix boundary. + assert_eq!(imported, Ok(())); + with_style_sheet(|sheet| { + assert_eq!(sheet.create_css(None, false), before); + assert_eq!(sheet.names, claims); + }); + reset_build_state_internal(); +} + +enum ClaimDamage { + MissingAtom, + MissingScope, + CorruptScope, +} + +#[rstest] +#[case("OL")] +#[case("RL")] +#[case("KL")] +#[case("OH")] +#[serial] +fn genuine_generated_cache_rejects_damaged_claims_when_prefix_resembles_content( + #[case] prefix: &str, + #[values( + ClaimDamage::MissingAtom, + ClaimDamage::MissingScope, + ClaimDamage::CorruptScope + )] + damage: ClaimDamage, +) { + // Given: only one required exact claim is removed or corrupted in real scoped output. + let mut incoming = exported_content(prefix, false); + let cold_css = incoming.create_css(Some("x.tsx"), false); + let cold_code = compiled_content(false).code(); + let scope = format!("{prefix}FLx_ptsx"); + match damage { + ClaimDamage::MissingAtom => { + incoming + .names + .remove(&format!("{scope}-OLcolor-vred")) + .unwrap_or_else(|| panic!("missing atom claim fixture")); + } + ClaimDamage::MissingScope => { + incoming + .names + .remove(&scope) + .unwrap_or_else(|| panic!("missing scope claim fixture")); + } + ClaimDamage::CorruptScope => { + incoming + .names + .get_mut(&scope) + .unwrap_or_else(|| panic!("missing scope claim fixture")) + .descriptor + .push(1); + } + } + reset_build_state_internal(); + set_prefix(Some(prefix.into())); + // When + let imported = import_sheet_internal(incoming); + // Then: recognizing a user prefix must not adopt any damaged generated namespace. + assert_eq!(imported, Ok(())); + assert_eq!(with_style_sheet(|sheet| sheet.names.len()), 0); + assert_eq!(compiled_content(false).code(), cold_code); + assert_eq!( + with_style_sheet(|sheet| sheet.create_css(Some("x.tsx"), false)), + cold_css + ); + reset_build_state_internal(); +} + +#[rstest] +#[case("OL")] +#[case("RL")] +#[case("KL")] +#[case("OH")] +#[serial] +fn previous_global_namespace_is_rejected_in_per_file_cache_with_content_shaped_prefix( + #[case] prefix: &str, +) { + // Given: valid global atoms and claims placed in an old nonzero-order per-file bucket. + let mut incoming = exported_content(prefix, true); + let cold = exported_content(prefix, false); + let cold_css = cold.create_css(Some("x.tsx"), false); + let cold_code = compiled_content(false).code(); + let orders = incoming + .properties + .remove("") + .unwrap_or_else(|| panic!("missing single CSS bucket fixture")); + incoming.properties.insert("x.tsx".into(), orders); + reset_build_state_internal(); + set_prefix(Some(prefix.into())); + // When + let imported = import_sheet_internal(incoming); + // Then: stripping the configured prefix cannot allow previous cross-sheet aliases back in. + assert_eq!(imported, Ok(())); + assert_eq!(with_style_sheet(|sheet| sheet.names.len()), 0); + assert_eq!(with_style_sheet(|sheet| sheet.properties.len()), 0); + assert_eq!(compiled_content(false).code(), cold_code); + assert_eq!( + with_style_sheet(|sheet| sheet.create_css(Some("x.tsx"), false)), + cold_css + ); + reset_build_state_internal(); +} diff --git a/bindings/devup-ui-wasm/src/prospective_output.rs b/bindings/devup-ui-wasm/src/prospective_output.rs new file mode 100644 index 000000000..04fa2aa42 --- /dev/null +++ b/bindings/devup-ui-wasm/src/prospective_output.rs @@ -0,0 +1,50 @@ +//! Pure Output materialization from the kernel's borrowed prospective sheet. +use crate::Output; +use sheet::{StyleSheet, counter_kernel::UpdateEffects}; + +/// Owned compilation metadata; contains no naming authority. +pub struct OutputMetadata { + pub code: String, + pub map: Option, + pub css_file: Option, + pub dependencies: Vec, +} +/// Actual kernel finish inputs; no second update is performed. +pub struct ProspectiveSheet<'a> { + pub sheet: &'a StyleSheet, + pub effects: UpdateEffects, +} +/// Existing raw-source routing options used by Output rendering. +pub struct OutputRoute<'a> { + pub raw_source: &'a str, + pub single_css: bool, + pub import_main_css: bool, +} +impl Output { + /// Render actual prepared effects without allocating names or cleaning twice. + #[must_use] + pub fn from_prospective( + metadata: OutputMetadata, + prepared: ProspectiveSheet<'_>, + route: OutputRoute<'_>, + ) -> Self { + let global = route.single_css || css::file_map::is_global(route.raw_source); + let canonical = css::file_map::canonical(route.raw_source); + let effects = prepared.effects; + Self { + code: metadata.code, + map: metadata.map, + css_file: metadata.css_file, + dependencies: metadata.dependencies, + updated_base_style: effects.updated_base_style || effects.default_collected, + css: if effects.collected || effects.default_collected { + Some(prepared.sheet.create_css( + if global { None } else { Some(&canonical) }, + route.import_main_css, + )) + } else { + None + }, + } + } +} diff --git a/bindings/devup-ui-wasm/src/prospective_output_tests.rs b/bindings/devup-ui-wasm/src/prospective_output_tests.rs new file mode 100644 index 000000000..02320c58e --- /dev/null +++ b/bindings/devup-ui-wasm/src/prospective_output_tests.rs @@ -0,0 +1,223 @@ +use super::cache6_test_support::*; + +#[test] +#[serial] +fn prospective_output_updates_base_when_first_order_zero_static_finish_has_no_cleanup() { + // Given + let _guard = Guard::new(); + let items = with_original( + FixtureRequest { + filename: "first", + source: "abcdef", + }, + || { + let mut declaration = ExtractStaticStyle::new("color", "red", 0, None); + declaration.style_order = Some(0); + FxHashSet::from_iter([ExtractStyleValue::Static(declaration)]) + }, + ) + .unwrap_or_else(|error| panic!("{error:?}")); + // When + let output = with_style_sheet_mut(|sheet| { + CounterSheet::new(sheet).with_attempt(|attempt| { + attempt + .prepare( + &items, + UpdateRequest { + raw_source: "first", + single_css: false, + }, + )? + .finish(|sheet, effects| { + // Then: effects are produced by the real prepare/finish, not fabricated. + assert!(effects.updated_base_style); + assert!(!effects.default_collected); + Ok::<_, String>(Output::from_prospective( + metadata(), + ProspectiveSheet { sheet, effects }, + OutputRoute { + raw_source: "first", + single_css: false, + import_main_css: false, + }, + )) + }) + }) + }) + .unwrap_or_else(|error| panic!("{error:?}")); + assert!(output.updated_base_style()); +} + +#[rstest] +#[case(false, false)] +#[case(false, true)] +#[case(true, false)] +#[case(true, true)] +#[serial] +fn prospective_output_preserves_metadata_and_routing_when_real_kernel_inserts( + #[case] global: bool, + #[case] import_main: bool, +) { + // Given + let _guard = Guard::new(); + import_canonical_map_internal(HashMap::from([("raw".into(), "root".into())])); + let items = ir("raw", 0); + // When + let output = update( + &items, + OutputRoute { + raw_source: "raw", + single_css: global, + import_main_css: import_main, + }, + ) + .unwrap_or_else(|error| panic!("{error:?}")); + // Then + assert_eq!(output.code(), metadata().code); + assert_eq!(output.map(), Some("fixture-map".into())); + assert_eq!(output.css_file(), Some("fixture.css".into())); + assert_eq!(output.dependencies(), vec!["tokens.ts".to_string()]); + assert_eq!( + output.css(), + Some(with_style_sheet(|sheet| sheet.create_css( + if global { None } else { Some("root") }, + import_main + ))) + ); + assert!( + output + .css() + .unwrap_or_else(|| panic!("css")) + .contains("color:red") + ); +} + +#[test] +#[serial] +fn prospective_output_has_no_css_when_real_update_inserts_nothing() { + // Given + let _guard = Guard::new(); + let items = ir("a", 0); + update( + &items, + OutputRoute { + raw_source: "a", + single_css: false, + import_main_css: false, + }, + ) + .unwrap_or_else(|error| panic!("{error:?}")); + let maps_before = maps(); + // When + let output = update( + &items, + OutputRoute { + raw_source: "a", + single_css: false, + import_main_css: false, + }, + ) + .unwrap_or_else(|error| panic!("{error:?}")); + // Then + assert_eq!(output.css(), None); + assert!(!output.updated_base_style()); + assert_eq!(maps(), maps_before); +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn cleanup_only_output_updates_base_when_real_guard_removes_global_or_at_consumer( + #[case] at: bool, +) { + // Given + let _guard = Guard::new(); + let selector = if at { + css::style_selector::StyleSelector::At { + kind: css::style_selector::AtRuleKind::Media, + query: "print".into(), + selector: Some("body".into()), + outer: vec![], + file: Some("raw".into()), + } + } else { + css::style_selector::StyleSelector::Global("body".into(), "raw".into()) + }; + let items = with_original( + FixtureRequest { + filename: "raw", + source: "abcdef", + }, + || { + FxHashSet::from_iter([ExtractStyleValue::Dynamic(ExtractDynamicStyle::new( + "color", + 0, + "tone", + Some(selector), + ))]) + }, + ) + .unwrap_or_else(|error| panic!("{error:?}")); + update( + &items, + OutputRoute { + raw_source: "raw", + single_css: false, + import_main_css: false, + }, + ) + .unwrap_or_else(|error| panic!("{error:?}")); + let before = maps(); + // When + let output = update( + &Default::default(), + OutputRoute { + raw_source: "raw", + single_css: false, + import_main_css: false, + }, + ) + .unwrap_or_else(|error| panic!("{error:?}")); + // Then + assert!(output.updated_base_style()); + assert!(output.css().is_some()); + assert_eq!(maps(), before); + with_style_sheet(|sheet| { + let survivors = &sheet.properties["raw"][&255][&0]; + assert_eq!(survivors.len(), 1); + assert!( + survivors + .iter() + .all(|record| record.owner_reset && record.selector.is_none()) + ); + }); +} + +#[test] +#[serial] +fn canonical_global_route_renders_shared_css_when_single_css_option_is_false() { + // Given + let _guard = Guard::new(); + import_canonical_map_internal(HashMap::from([( + "raw".into(), + css::file_map::GLOBAL_BUCKET.into(), + )])); + let items = ir("raw", 0); + // When + let output = update( + &items, + OutputRoute { + raw_source: "raw", + single_css: false, + import_main_css: true, + }, + ) + .unwrap_or_else(|error| panic!("{error:?}")); + // Then + assert_eq!( + output.css(), + Some(with_style_sheet(|sheet| sheet.create_css(None, true))) + ); + assert!(with_style_sheet(|sheet| sheet.properties.contains_key(""))); +} diff --git a/bindings/devup-ui-wasm/src/scoped_cache_tests.rs b/bindings/devup-ui-wasm/src/scoped_cache_tests.rs new file mode 100644 index 000000000..db402fa9b --- /dev/null +++ b/bindings/devup-ui-wasm/src/scoped_cache_tests.rs @@ -0,0 +1,278 @@ +use super::*; +use rstest::rstest; +use serial_test::serial; + +#[test] +#[serial] +fn previous_per_file_content_names_cannot_restore_cross_sheet_aliases() { + reset_build_state_internal(); + let source = "import {Box} from '@devup-ui/react';export const x=;"; + let cold = code_extract_internal( + "x.tsx", + source, + "@devup-ui/react", + "df".into(), + false, + false, + false, + HashMap::new(), + ) + .unwrap_or_else(|error| panic!("{error}")); + let cold_css = with_style_sheet(|sheet| sheet.create_css(Some("x.tsx"), false)); + let exported = export_sheet_internal().unwrap_or_else(|error| panic!("{error}")); + let mut previous: StyleSheet = + serde_json::from_str(&exported).unwrap_or_else(|error| panic!("{error}")); + let prefix = "FLx_ptsx-"; + for levels in previous + .properties + .get_mut("x.tsx") + .unwrap_or_else(|| panic!("per-file fixture")) + .values_mut() + { + for properties in levels.values_mut() { + *properties = properties + .iter() + .cloned() + .map(|mut property| { + property.class_name = property + .class_name + .strip_prefix(prefix) + .unwrap_or_else(|| panic!("scoped fixture")) + .to_string(); + property + }) + .collect(); + } + } + previous.names = previous + .names + .into_iter() + .filter(|(name, _)| name != "FLx_ptsx") + .map(|(name, claim)| { + ( + name.strip_prefix(prefix).unwrap_or(&name).to_string(), + claim, + ) + }) + .collect(); + reset_build_state_internal(); + + let imported = import_sheet_internal(previous); + + assert_eq!(imported, Ok(())); + assert_eq!(with_style_sheet(|sheet| sheet.names.len()), 0); + let fresh = code_extract_internal( + "x.tsx", + source, + "@devup-ui/react", + "df".into(), + false, + false, + false, + HashMap::new(), + ) + .unwrap_or_else(|error| panic!("{error}")); + assert_eq!(fresh.code(), cold.code()); + assert_eq!( + with_style_sheet(|sheet| sheet.create_css(Some("x.tsx"), false)), + cold_css + ); + reset_build_state_internal(); +} + +#[rstest] +#[case("", 255)] +#[case("du-FLa-", 1)] +#[case("FHprefix-", 255)] +#[serial] +fn previous_hashed_per_file_names_are_rejected_even_with_exact_atom_claims( + #[case] prefix: &str, + #[case] order: u8, +) { + // Given: real exported long-content atoms, stripped of only their sheet namespace. + reset_build_state_internal(); + set_prefix(Some(prefix.into())); + let source = format!( + "import {{Box}} from '@devup-ui/react';export const x=;" + ); + let cold = code_extract_internal( + "x.tsx", + &source, + "@devup-ui/react", + "df".into(), + false, + false, + false, + HashMap::new(), + ) + .unwrap_or_else(|error| panic!("{error}")); + let cold_css = with_style_sheet(|sheet| sheet.create_css(Some("x.tsx"), false)); + let mut incoming: StyleSheet = + serde_json::from_str(&export_sheet_internal().unwrap_or_else(|error| panic!("{error}"))) + .unwrap_or_else(|error| panic!("{error}")); + let scope = format!("{prefix}FLx_ptsx"); + let scoped_prefix = format!("{scope}-"); + for properties in incoming + .properties + .get_mut("x.tsx") + .unwrap_or_else(|| panic!("missing per-file bucket fixture")) + .get_mut(&order) + .unwrap_or_else(|| panic!("missing style order fixture")) + .values_mut() + { + *properties = properties + .iter() + .cloned() + .map(|mut property| { + property.class_name = format!( + "{prefix}{}", + property + .class_name + .strip_prefix(&scoped_prefix) + .unwrap_or_else(|| panic!("missing scoped name fixture")) + ); + property + }) + .collect(); + } + incoming.names = incoming + .names + .into_iter() + .filter(|(name, _)| name != &scope) + .map(|(name, claim)| match name.strip_prefix(&scoped_prefix) { + Some(atom) => (format!("{prefix}{atom}"), claim), + None => (name, claim), + }) + .collect(); + reset_build_state_internal(); + set_prefix(Some(prefix.into())); + // When + assert_eq!(import_sheet_internal(incoming), Ok(())); + // Then: an exact atom claim cannot admit an old unscoped namespace. + assert_eq!(with_style_sheet(|sheet| sheet.names.len()), 0); + assert_eq!( + code_extract_internal( + "x.tsx", + &source, + "@devup-ui/react", + "df".into(), + false, + false, + false, + HashMap::new(), + ) + .unwrap_or_else(|error| panic!("{error}")) + .code(), + cold.code() + ); + assert_eq!( + with_style_sheet(|sheet| sheet.create_css(Some("x.tsx"), false)), + cold_css + ); + reset_build_state_internal(); +} + +#[rstest] +#[case(false, false, 0)] +#[case(true, false, 255)] +#[case(false, true, 255)] +#[case(false, false, 255)] +#[serial] +fn actual_exported_shared_and_scoped_names_round_trip( + #[case] single: bool, + #[case] hoist: bool, + #[case] order: u8, +) { + // Given: actual global base, single CSS, hoisted, or current per-file output. + reset_build_state_internal(); + set_prefix(Some("du-FLa-".into())); + if hoist { + set_atom_hoist(Some(2)); + import_file_routes_internal(HashMap::from([( + "x.tsx".into(), + std::collections::HashSet::from([0, 1]), + )])); + } + let source = format!( + "import {{Box,keyframes}} from '@devup-ui/react';export const k=keyframes({{from:{{opacity:0}},to:{{opacity:1}}}});export const x=;" + ); + code_extract_internal( + "x.tsx", + &source, + "@devup-ui/react", + "df".into(), + single, + false, + false, + HashMap::new(), + ) + .unwrap_or_else(|error| panic!("{error}")); + let exported = export_sheet_internal().unwrap_or_else(|error| panic!("{error}")); + let incoming: StyleSheet = + serde_json::from_str(&exported).unwrap_or_else(|error| panic!("{error}")); + let property = incoming + .properties + .values() + .flat_map(|orders| orders.values()) + .flat_map(|levels| levels.values()) + .flatten() + .next() + .unwrap_or_else(|| panic!("missing exported property fixture")); + let expected = if order == 0 { + "du-FLa-OLcolor-vred-o0" + } else if single || hoist { + "du-FLa-OLcolor-vred" + } else { + "du-FLa-FLx_ptsx-OLcolor-vred" + }; + assert_eq!(property.class_name, expected); + assert_eq!(property.hoisted, hoist); + assert_eq!( + incoming + .keyframes + .values() + .map(BTreeMap::len) + .sum::(), + 1 + ); + assert!( + incoming + .keyframes + .values() + .flat_map(|frames| frames.keys()) + .all(|name| name.starts_with("du-FLa-K")) + ); + let before = incoming.create_css(None, false); + reset_build_state_internal(); + set_prefix(Some("du-FLa-".into())); + // When: import runs without relying on the producer's active hoist configuration. + let result = import_sheet_internal(incoming); + // Then + assert_eq!(result, Ok(())); + assert_eq!( + with_style_sheet(|sheet| sheet.create_css(None, false)), + before + ); + reset_build_state_internal(); +} + +#[rstest] +#[case("a-a")] +#[case("Rcolor-vred")] +#[case("manual-card")] +#[serial] +fn generationless_serialized_counter_and_manual_names_are_cold(#[case] name: &str) { + // Given: legacy JSON with a real per-file declaration, no generation marker or names registry. + reset_build_state_internal(); + let json = serde_json::json!({ + "properties": {"x.tsx": {"255": {"0": [{"c": name, "p": "color", "v": "red", "s": null}]}}} + }); + let incoming: StyleSheet = + serde_json::from_value(json).unwrap_or_else(|error| panic!("{error}")); + // When + let result = import_sheet_internal(incoming); + // Then: serialized legacy names are never silently promoted to current state. + assert_eq!(result, Ok(())); + assert_eq!(with_style_sheet(|sheet| sheet.properties.len()), 0); + reset_build_state_internal(); +} diff --git a/bindings/devup-ui-wasm/src/scoped_cascade_tests.rs b/bindings/devup-ui-wasm/src/scoped_cascade_tests.rs new file mode 100644 index 000000000..911abb60f --- /dev/null +++ b/bindings/devup-ui-wasm/src/scoped_cascade_tests.rs @@ -0,0 +1,211 @@ +use super::*; +use rstest::rstest; +use serial_test::serial; + +fn compile(file: &str, source: &str, single: bool) -> Output { + code_extract_internal( + file, + source, + "@devup-ui/react", + "df".into(), + single, + false, + false, + HashMap::new(), + ) + .unwrap_or_else(|error| panic!("{error}")) +} + +#[rstest] +#[case(false, false)] +#[case(false, true)] +#[case(true, false)] +#[case(true, true)] +#[serial] +fn independent_sheets_cannot_override_responsive_atoms_when_base_content_matches( + #[case] seeded: bool, + #[case] reverse: bool, +) { + // Given: two delivered sheets share base content, but only A has a desktop override. + reset_build_state_internal(); + css::debug::set_debug(false); + if seeded { + seed_file_map(vec!["a.tsx".into(), "b.tsx".into()]); + } + let inputs = [ + ( + "a.tsx", + "import {Box} from '@devup-ui/react'; export const x=;", + ), + ( + "b.tsx", + "import {Box} from '@devup-ui/react'; export const x=;", + ), + ]; + let order = if reverse { [1, 0] } else { [0, 1] }; + // When: extraction arrives in either order without changing placement. + let mut outputs = BTreeMap::new(); + for index in order { + outputs.insert( + inputs[index].0, + compile(inputs[index].0, inputs[index].1, false), + ); + } + // Then: B's base selectors cannot match A's classes; each rule remains in its exact bucket. + with_style_sheet(|sheet| { + let base = |file: &str| { + sheet.properties[file][&255][&0] + .iter() + .find(|property| property.property == "flex-direction") + .unwrap_or_else(|| panic!("missing base rule")) + .class_name + .clone() + }; + let a = base("a.tsx"); + let b = base("b.tsx"); + assert_ne!(a, b); + assert!(outputs["a.tsx"].code().contains(&a)); + assert!(outputs["b.tsx"].code().contains(&b)); + assert!( + !sheet + .create_css(Some("b.tsx"), false) + .contains(&format!(".{a}{{")) + ); + assert_eq!( + sheet.properties["a.tsx"][&255] + .keys() + .copied() + .collect::>(), + vec![0, 1, 3] + ); + assert_eq!( + sheet.properties["b.tsx"][&255] + .keys() + .copied() + .collect::>(), + vec![0] + ); + assert_eq!(sheet.properties.get(""), None); + }); + reset_build_state_internal(); +} + +#[rstest] +#[case(false, false)] +#[case(false, true)] +#[case(true, false)] +#[case(true, true)] +#[serial] +fn shared_delivery_keeps_existing_rule_placement_when_atoms_are_unprefixed( + #[case] hoist: bool, + #[case] reverse: bool, +) { + // Given: single CSS or a frozen atom-hoist plan, not moved per-file rules. + reset_build_state_internal(); + css::debug::set_debug(false); + if hoist { + set_atom_hoist(Some(2)); + import_file_routes_internal(HashMap::from([ + ("a.tsx".into(), std::collections::HashSet::from([0, 1])), + ("b.tsx".into(), std::collections::HashSet::from([0, 1])), + ])); + } + let inputs = [ + ( + "a.tsx", + "import {Box} from '@devup-ui/react'; export const x=;", + ), + ( + "b.tsx", + "import {Box} from '@devup-ui/react'; export const x=;", + ), + ]; + // When + for index in if reverse { [1, 0] } else { [0, 1] } { + compile(inputs[index].0, inputs[index].1, !hoist); + } + // Then: the shared sheet retains base-before-responsive order; raw buckets do not change. + with_style_sheet(|sheet| { + let css = sheet.create_css(None, false); + assert!( + css.find("flex-direction:column") + .unwrap_or_else(|| panic!("missing base")) + < css + .find("@media") + .unwrap_or_else(|| panic!("missing responsive rule")) + ); + assert_eq!(css.matches("flex-direction:column").count(), 1); + assert!(css.contains("flex-direction:row")); + let expected = if hoist { + vec!["a.tsx", "b.tsx"] + } else { + vec![""] + }; + assert_eq!( + sheet + .properties + .keys() + .map(String::as_str) + .collect::>(), + expected + ); + for orders in sheet.properties.values() { + for property in orders[&255].values().flatten() { + assert!(property.class_name.starts_with('O')); + assert_eq!(property.hoisted, hoist); + } + } + }); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn collapsed_bucket_retains_its_identity_prefix_and_cache_claims() { + // Given: A and its child share a canonical bucket, B remains independently delivered. + reset_build_state_internal(); + import_canonical_map_internal(HashMap::from([("child.tsx".into(), "a.tsx".into())])); + compile( + "child.tsx", + "import {Box} from '@devup-ui/react'; export const x=;", + false, + ); + compile( + "b.tsx", + "import {Box} from '@devup-ui/react'; export const x=;", + false, + ); + let before = export_sheet_internal().unwrap_or_else(|error| panic!("{error}")); + let css_before = with_style_sheet(|sheet| sheet.create_css(Some("a.tsx"), false)); + // When: exact name claims round-trip through the existing cache protocol. + import_sheet_internal(serde_json::from_str(&before).unwrap_or_else(|error| panic!("{error}"))) + .unwrap_or_else(|error| panic!("{error}")); + // Then + assert_eq!( + with_style_sheet(|sheet| sheet.create_css(Some("a.tsx"), false)), + css_before + ); + with_style_sheet(|sheet| { + assert_eq!( + sheet + .properties + .keys() + .map(String::as_str) + .collect::>(), + vec!["a.tsx", "b.tsx"] + ); + let a = sheet.properties["a.tsx"][&255][&0] + .iter() + .next() + .unwrap_or_else(|| panic!("missing A")); + let b = sheet.properties["b.tsx"][&255][&0] + .iter() + .next() + .unwrap_or_else(|| panic!("missing B")); + assert_ne!(a.class_name, b.class_name); + assert!(a.class_name.starts_with("FLa_ptsx-")); + assert!(sheet.names.contains_key(&a.class_name)); + assert_eq!(sheet.names["FLa_ptsx"].content, "a.tsx"); + }); + reset_build_state_internal(); +} diff --git a/bindings/devup-ui-wasm/src/sheet_entry.rs b/bindings/devup-ui-wasm/src/sheet_entry.rs new file mode 100644 index 000000000..82a263499 --- /dev/null +++ b/bindings/devup-ui-wasm/src/sheet_entry.rs @@ -0,0 +1,24 @@ +use std::cell::Cell; + +thread_local! { + static HELD: Cell = const { Cell::new(false) }; +} + +/// Marks only the live sheet data lock, not the enclosing admission scope. +pub(crate) struct SheetEntry; + +impl SheetEntry { + pub(crate) fn enter() -> Self { + HELD.with(|held| { + assert!(!held.get(), "style sheet: recursive lock-held entry"); + held.set(true); + }); + Self + } +} + +impl Drop for SheetEntry { + fn drop(&mut self) { + HELD.with(|held| held.set(false)); + } +} diff --git a/bindings/devup-ui-wasm/src/static_theme_tests.rs b/bindings/devup-ui-wasm/src/static_theme_tests.rs new file mode 100644 index 000000000..68314f15c --- /dev/null +++ b/bindings/devup-ui-wasm/src/static_theme_tests.rs @@ -0,0 +1,60 @@ +use super::*; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case("px='$gutter'", vec![0, 2])] +#[case("px={'$gutter'}", vec![0, 2])] +#[case("px={['$gutter']}", vec![0])] +#[case("_hover={{px:['$gutter']}}", vec![0])] +#[case("selectors={{'&:focus':{px:['$gutter']}}}", vec![0])] +#[serial] +fn theme_arrays_remain_static_when_array_indices_define_the_responsive_levels( + #[case] props: &str, + #[case] expected: Vec, +) { + // Given: a responsive token expanded only by the established root-token path. + reset_build_state_internal(); + register_theme_internal( + serde_json::from_value( + serde_json::json!({"length":{"default":{"gutter":["8px",null,"16px"]}}}), + ) + .unwrap_or_else(|error| panic!("{error}")), + ); + let source = format!("import {{Box}} from '@devup-ui/react'; export const x=;"); + // When + let output = code_extract_internal( + "theme.tsx", + &source, + "@devup-ui/react", + "df".into(), + true, + false, + false, + HashMap::new(), + ) + .unwrap_or_else(|error| panic!("{error}")); + // Then: class-only tokens have no tuple/inline spreads, and array tokens stay at index zero. + for marker in [ + "__devupAssignment", + "__devupLevel", + "__devupValue", + "style=", + ] { + assert!(!output.code().contains(marker), "{}", output.code()); + } + with_style_sheet(|sheet| { + let levels = sheet.properties[""][&255] + .iter() + .filter(|(_, properties)| { + properties + .iter() + .any(|property| property.property == "padding-left") + }) + .map(|(level, _)| *level) + .collect::>(); + assert_eq!(levels, expected); + }); + reset_build_state_internal(); + register_theme_internal(sheet::theme::Theme::default()); +} diff --git a/bindings/devup-ui-wasm/tests/content-names.mjs b/bindings/devup-ui-wasm/tests/content-names.mjs new file mode 100644 index 000000000..58b65a3a0 --- /dev/null +++ b/bindings/devup-ui-wasm/tests/content-names.mjs @@ -0,0 +1,162 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { createRequire } from 'node:module' + +const require = createRequire(import.meta.url) +const wasm = require('../pkg/index.js') +const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789_' +const size = (value) => { + const bytes = Buffer.alloc(8) + bytes.writeBigUInt64BE(BigInt(value)) + return bytes +} +const text = (value) => + Buffer.concat([size(Buffer.byteLength(value)), Buffer.from(value)]) +const atom = (property, value) => + Buffer.concat([ + Buffer.from([1, 1, 0]), + text(property), + Buffer.from([1]), + text(value), + Buffer.from([0, 255, 0, 0]), + ]) +const fingerprint = (descriptor) => { + const first80 = createHash('sha256') + .update(descriptor) + .digest() + .subarray(0, 10) + let value = BigInt(`0x${first80.toString('hex')}`) + let result = '' + for (let digit = 0; digit < 16; digit++) { + result = alphabet[Number(value % 37n)] + result + value /= 37n + } + assert.equal(value, 0n) + return result +} +const compile = (file, code) => + wasm.codeExtract(file, code, '@devup-ui/react', 'df', true, false, false, {}) + +wasm.resetBuildState() +wasm.setDebug(false) +if (process.argv[2] === 'goldens') { + const source = + "import {css,keyframes} from '@devup-ui/react'; export const x=css({color:'red',fontFamily:'abcdefghijklmnopqrstuvwx'});export const k=keyframes({from:{opacity:0},to:{opacity:1}});" + const output = compile('golden.tsx', source) + const family = + 'OH' + fingerprint(atom('font-family', 'abcdefghijklmnopqrstuvwx')) + assert.ok(output.code.includes('OLcolor-vred')) + assert.ok(output.code.includes(family)) + const frames = Buffer.concat([ + Buffer.from([1, 3]), + size(2), + text('from'), + size(1), + text('opacity'), + text('0'), + text('to'), + size(1), + text('opacity'), + text('1'), + ]) + const animation = 'KH' + fingerprint(frames) + assert.ok(output.code.includes(animation)) + assert.ok(wasm.getCss(null, false).includes('@keyframes ' + animation)) + const sheet = JSON.parse(wasm.exportSheet()) + assert.deepEqual(sheet.names[family].descriptor, [ + ...atom('font-family', 'abcdefghijklmnopqrstuvwx'), + ]) + assert.deepEqual(sheet.names[animation].descriptor, [...frames]) + output.free() +} else if (process.argv[2] === 'compact-sources') { + const source = + '// filler\n'.repeat(10_240) + + "import {Box} from '@devup-ui/react';\nexport const View=(p)=>;\n" + const root = '---SUH' + fingerprint(Buffer.from(source)) + const run = (received) => { + wasm.resetBuildState() + wasm.setDebug(false) + wasm.registerTheme({}) + const output = compile('/real/large.tsx', received) + const variables = output.code.match(/---SUH[a-z0-9_]{16}-[a-z0-9_]+/gu) + assert.equal(variables.length, 2) + assert.ok(variables.every((name) => name.startsWith(root + '-'))) + assert.ok(variables.every((name) => name.length <= 38)) + const css = wasm.getCss(null, false) + assert.ok(variables.every((name) => css.includes(name))) + const sheet = JSON.parse(wasm.exportSheet()) + assert.equal(sheet.names[root].content, source) + assert.deepEqual(sheet.names[root].descriptor, [...Buffer.from(source)]) + const result = { + code: output.code, + css: output.css, + map: output.map, + cssFile: output.cssFile, + updatedBaseStyle: output.updatedBaseStyle, + dependencies: output.dependencies, + completeCss: css, + } + output.free() + wasm.resetBuildState() + wasm.importSheet(sheet) + const restored = compile('/real/other.tsx', source) + assert.equal(restored.code, result.code) + assert.equal(wasm.getCss(null, false), css) + restored.free() + return result + } + const baseline = run(source) + for (const received of [ + source, + source.replaceAll('\n', '\r\n'), + '\uFEFF' + source, + '\uFEFF' + source.replaceAll('\n', '\r\n'), + ]) { + assert.deepEqual(run(received), baseline) + } +} else if (process.argv[2] === 'cache-conflict') { + compile( + 'a.tsx', + "import {css} from '@devup-ui/react';\nexport const x=css({color:'blue'});", + ).free() + compile( + 'b.tsx', + "import {globalCss} from '@devup-ui/react'; globalCss({body:{padding:'11px'}});", + ).free() + const sheet = JSON.parse(wasm.exportSheet()) + sheet.names['OLcolor-vred'] = sheet.names['OLcolor-vblue'] + wasm.importSheet(sheet) + const before = wasm.exportSheet() + const css = wasm.getCss(null, false) + assert.ok(css.includes('padding:11px')) + let collision + assert.throws( + () => + compile( + 'b.tsx', + "import {css} from '@devup-ui/react';\nexport const x=css({color:'red'});", + ), + (error) => { + assert.ok(error instanceof Error) + assert.ok(error.message.includes('a.tsx:2:27:')) + assert.ok(error.message.includes('b.tsx:2:27:')) + assert.ok(error.message.includes('OLcolor-vred')) + collision = error.message + return true + }, + ) + assert.equal(wasm.exportSheet(), before) + assert.throws( + () => wasm.getCss(null, false), + (error) => { + assert.ok(error instanceof Error) + assert.ok(error.message.includes('a.tsx:2:27:')) + assert.ok(error.message.includes('b.tsx:2:27:')) + assert.equal(error.message, collision) + return true + }, + ) +} else { + assert.fail('unknown fixture mode') +} +wasm.resetBuildState() diff --git a/bindings/devup-ui-wasm/tests/site_variable_names.rs b/bindings/devup-ui-wasm/tests/site_variable_names.rs new file mode 100644 index 000000000..3fa266ffc --- /dev/null +++ b/bindings/devup-ui-wasm/tests/site_variable_names.rs @@ -0,0 +1,170 @@ +#![cfg(not(target_arch = "wasm32"))] + +use std::collections::{BTreeMap, BTreeSet}; + +use extractor::extract_style::extract_style_value::ExtractStyleValue; +use extractor::{ExtractOption, ResolvedModule, extract, extract_with_modules}; +use rstest::rstest; +use serial_test::serial; +use sheet::StyleSheet; + +const FILE: &str = "/src/shared.tsx"; +const IMPORTED_SELECTOR: &str = "import {Box} from '@devup-ui/react';import {SEL} from 'conditional';export const View=({pad})=>;"; + +fn reset(debug: bool, atom: bool) { + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + css::file_map::reset_canonical_map(); + css::file_map::seed_file_numbers(&[FILE.to_string()]); + css::debug::set_debug(debug); + css::atom_hoist::set_atom_hoist(atom.then_some(1)); + css::atom_hoist::restore_atom_plan(None); + css::set_prefix(Some("test-".to_string())); +} + +fn imported_order( + order: [&'static str; 2], + mode: (bool, bool), +) -> (BTreeMap, String) { + reset(mode.0, mode.1); + let mut sheet = StyleSheet::default(); + let mut code = BTreeMap::new(); + let mut variables = BTreeSet::new(); + for selector in order { + let resolver = move |_: &str, _: &str| { + Some(ResolvedModule { + path: format!("/pkg/{}.ts", selector.trim_start_matches("&:")), + code: format!("export const SEL='{selector}';"), + }) + }; + let output = extract_with_modules( + FILE, + IMPORTED_SELECTOR, + ExtractOption::default(), + false, + &resolver, + ) + .unwrap_or_else(|error| panic!("imported selector extraction failed: {error}")); + for style in &output.styles { + if let ExtractStyleValue::Dynamic(style) = style { + variables.insert(style.variable_name()); + } + } + sheet + .update_styles(&output.styles, FILE, false) + .unwrap_or_else(|error| panic!("{error}")); + code.insert(selector.to_string(), output.code); + } + assert_eq!( + variables.len(), + 1, + "one original assignment must share one variable" + ); + let css = sheet.create_css(Some(FILE), false); + assert!(css.contains(":hover"), "missing hover rule: {css}"); + assert!(css.contains(":focus"), "missing focus rule: {css}"); + (code, css) +} + +#[rstest] +#[case(false, false)] +#[case(true, false)] +#[case(false, true)] +#[case(true, true)] +#[serial] +fn imported_selector_bytes_match_when_environment_order_reverses( + #[case] debug: bool, + #[case] atom: bool, +) { + // Given: the same source assignment, with an environment-dependent import. + let mode = (debug, atom); + // When: both environments extract into the real sheet in opposite orders. + let ab = imported_order(["&:hover", "&:focus"], mode); + let ba = imported_order(["&:focus", "&:hover"], mode); + // Then: every byte of both emitted JS modules and CSS agrees. + assert_eq!(ab, ba); +} + +#[rstest] +#[case( + "import {Box} from '@devup-ui/react';export const View=({pad,gap,left,right})=>;", + 6 +)] +#[case( + "import {Box} from '@devup-ui/react';export const View=({pad})=>;", + 2 +)] +#[case( + "import * as stylex from '@stylexjs/stylex';const styles=stylex.create({dynamic:(width,height)=>({width,height,paddingLeft:width,paddingRight:height})});export const View=({width,height})=>
;", + 4 +)] +#[case( + "import styled from '@emotion/styled';export const View=styled.div`padding: ${p=>p.pad}; margin: ${p=>p.gap};`;", + 2 +)] +#[case( + "import styled from '@emotion/styled';export const View=styled.div`padding: ${p=>p.pad}px; margin: ${p=>p.gap}em;`;", + 2 +)] +#[serial] +fn different_assignments_remain_distinct_when_only_site_names_variables( + #[case] source: &str, + #[case] minimum_styles: usize, +) { + for mode in [(false, false), (true, false), (false, true), (true, true)] { + // Given: real shorthand, responsive, multi-parameter or template syntax. + reset(mode.0, mode.1); + // When: the native extractor and sheet compile it. + let mut option = ExtractOption::default(); + option.import_aliases.insert( + "@emotion/styled".to_string(), + extractor::ImportAlias::DefaultToNamed("styled".to_string()), + ); + let output = extract(FILE, source, option) + .unwrap_or_else(|error| panic!("dynamic fixture extraction failed: {error}")); + let mut sheet = StyleSheet::default(); + sheet + .update_styles(&output.styles, FILE, false) + .unwrap_or_else(|error| panic!("{error}")); + let css = sheet.create_css(Some(FILE), false); + let mut assignments = BTreeMap::new(); + let mut count = 0; + for style in &output.styles { + if let ExtractStyleValue::Dynamic(style) = style { + count += 1; + let variable = style.variable_name(); + assert!(variable.starts_with("---test-S")); + assert!( + output.code.contains(&variable), + "missing JS assignment: {}", + output.code + ); + assert!( + css.contains(&format!("var({variable})")), + "missing CSS use: {css}" + ); + // Then: sharing is legal only for identical assignment values. + if let Some(previous) = assignments.insert(variable.clone(), style.identifier()) { + assert_eq!( + previous, + style.identifier(), + "different values alias {variable}: {}", + output.code + ); + } + } + } + assert!( + count >= minimum_styles, + "fixture did not exercise its dynamic styles: {}", + output.code + ); + assert!( + assignments.len() >= 2, + "fixture must preserve distinct values" + ); + } +} + +#[path = "site_variable_names/sparse.rs"] +mod sparse; diff --git a/bindings/devup-ui-wasm/tests/site_variable_names/sparse.rs b/bindings/devup-ui-wasm/tests/site_variable_names/sparse.rs new file mode 100644 index 000000000..07f65ab93 --- /dev/null +++ b/bindings/devup-ui-wasm/tests/site_variable_names/sparse.rs @@ -0,0 +1,261 @@ +use super::*; + +fn compiled(source: &str, mode: (bool, bool, bool)) -> (String, String, BTreeSet) { + reset(mode.0, mode.1); + let mut option = ExtractOption { + single_css: mode.2, + ..ExtractOption::default() + }; + option.import_aliases.insert( + "@emotion/styled".to_string(), + extractor::ImportAlias::DefaultToNamed("styled".to_string()), + ); + let output = extract(FILE, source, option) + .unwrap_or_else(|error| panic!("sparse site extraction failed: {error}")); + let variables = output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Dynamic(style) => Some(style.variable_name()), + _ => None, + }) + .collect(); + let mut sheet = StyleSheet::default(); + sheet + .update_styles(&output.styles, FILE, mode.2) + .unwrap_or_else(|error| panic!("{error}")); + ( + output.code, + sheet.create_css((!mode.2).then_some(FILE), false), + variables, + ) +} + +#[rstest] +#[case(false, false, false)] +#[case(true, false, false)] +#[case(false, true, false)] +#[case(true, true, false)] +#[case(false, false, true)] +#[case(true, false, true)] +#[case(false, true, true)] +#[case(true, true, true)] +#[serial] +fn full_outputs_match_when_line_endings_and_leading_bom_change( + #[case] debug: bool, + #[case] atom: bool, + #[case] single: bool, +) { + // Given: alias edits and multiple composite values sharing a template span. + let source = "import styled from '@emotion/styled';\nexport const View=styled.div`\n padding: ${p=>p.pad}px;\n margin: ${p=>p.gap}em;\n`;\n"; + let expected = compiled(source, (debug, atom, single)); + // When: the same source is received with CRLF, with and without a BOM. + for input in [ + source.replace('\n', "\r\n"), + format!("\u{feff}{source}"), + format!("\u{feff}{}", source.replace('\n', "\r\n")), + ] { + let actual = compiled(&input, (debug, atom, single)); + // Then: full JS, CSS (including resets), and identities agree byte for byte. + assert_eq!(actual, expected); + } +} + +#[test] +#[serial] +fn golden_site_uses_d9_and_received_expression_offset_in_base37() { + // Given: D9 source 27 and an expression starting at received-source byte 52. + reset(false, false); + css::file_map::set_original_ids(BTreeMap::from([(FILE.to_string(), 27)])); + let source = "import {Box} from '@devup-ui/react';const V=;"; + // When: the real extractor chooses the sparse identity. + let output = extract(FILE, source, ExtractOption::default()) + .unwrap_or_else(|error| panic!("golden extraction failed: {error}")); + let names: Vec<_> = output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Dynamic(style) => Some(style.variable_name()), + _ => None, + }) + .collect(); + // Then: both coordinates use base37, not decimal or extraction counters. + assert_eq!(names, ["---test-Saa-az"]); +} + +#[test] +#[serial] +fn stylex_site_uses_value_expression_offset_when_property_key_is_earlier() { + // Given: a StyleX property with distinct key and value-expression positions. + reset(false, false); + let source = "import * as stylex from '@stylexjs/stylex';const styles=stylex.create({dynamic:(amount)=>({width: /* value */ amount})});export const View=({amount})=>
;"; + let at = source + .find("amount})") + .unwrap_or_else(|| panic!("fixture must contain the value expression")); + let key_at = source + .find("width:") + .unwrap_or_else(|| panic!("fixture must contain the property key")); + assert!(key_at < at); + let expected = css::Site { + file: css::sparse_site::SourceFile::D9(0), + at, + role: 0, + } + .variable_name("test-"); + let key_name = css::Site { + file: css::sparse_site::SourceFile::D9(0), + at: key_at, + role: 0, + } + .variable_name("test-"); + // When: the extractor assigns a sparse identity to the dynamic StyleX value. + let output = extract(FILE, source, ExtractOption::default()) + .unwrap_or_else(|error| panic!("StyleX position extraction failed: {error}")); + let names: Vec<_> = output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Dynamic(style) => Some(style.variable_name()), + _ => None, + }) + .collect(); + // Then: the source-derived golden uses the value offset, never the property key. + assert_eq!(names, [expected]); + assert_ne!(names, [key_name]); +} + +#[test] +#[serial] +fn unnumbered_names_use_source_alone_when_filenames_and_arrival_order_change() { + // Given: identical, distinct and BOM/CRLF-normalized sources without D9 numbers. + let source = "import {Box} from '@devup-ui/react';export const View=({pad})=>;"; + let different = format!("{source}// distinct received source"); + let normalized = format!("\u{feff}{}", source.replace(';', ";\r\n")); + let lf = source.replace(';', ";\n"); + let names = |reverse| { + reset(false, false); + css::file_map::set_original_ids(BTreeMap::new()); + let mut inputs = [ + ("/unseeded/first.tsx", source), + ("/different/root/second.tsx", source), + ("/unseeded/third.tsx", different.as_str()), + ("/unseeded/lf.tsx", lf.as_str()), + ("/unseeded/bom-crlf.tsx", normalized.as_str()), + ]; + if reverse { + inputs.reverse(); + } + inputs + .into_iter() + .map(|(filename, input)| { + let output = extract(filename, input, ExtractOption::default()) + .unwrap_or_else(|error| panic!("unnumbered extraction failed: {error}")); + let variables = output + .styles + .into_iter() + .filter_map(|style| match style { + ExtractStyleValue::Dynamic(style) => Some(style.variable_name()), + _ => None, + }) + .collect::>(); + (filename, variables) + }) + .collect::>() + }; + // When: every file is extracted in both arrival orders. + let first = names(false); + let later = names(true); + // Then: full normalized source, never filename or arrival order, determines each name. + assert_eq!(first, later); + let identical = &first["/unseeded/first.tsx"]; + assert_eq!(identical.len(), 1); + assert_eq!(identical, &first["/different/root/second.tsx"]); + assert_ne!(identical, &first["/unseeded/third.tsx"]); + assert_eq!(first["/unseeded/lf.tsx"], first["/unseeded/bom-crlf.tsx"]); + assert!(identical.iter().all(|name| name.starts_with("---test-SU"))); +} + +#[test] +#[serial] +fn later_template_role_stays_named_when_an_earlier_import_becomes_dynamic() { + // Given: two source interpolations; an import is static in only one environment. + let source = "import styled from '@emotion/styled';import {FIRST} from 'conditional';export const View=styled.div`padding:${FIRST}px;margin:${p=>p.gap}em;`;"; + let environment = |known: bool| { + reset(false, false); + let mut option = ExtractOption::default(); + option.import_aliases.insert( + "@emotion/styled".to_string(), + extractor::ImportAlias::DefaultToNamed("styled".to_string()), + ); + let resolver = move |_: &str, _: &str| { + known.then(|| ResolvedModule { + path: "/pkg/conditional.ts".to_string(), + code: "export const FIRST=1;".to_string(), + }) + }; + let output = extract_with_modules(FILE, source, option, false, &resolver) + .unwrap_or_else(|error| panic!("template role extraction failed: {error}")); + let dynamic: Vec<_> = output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Dynamic(style) => Some(style), + _ => None, + }) + .collect(); + let later: Vec<_> = dynamic + .iter() + .filter(|style| style.property() == "margin") + .map(|style| style.variable_name()) + .collect(); + (dynamic.len(), later) + }; + // When: the earlier interpolation changes its dynamic eligibility. + let known = environment(true); + let runtime = environment(false); + // Then: a source-order role cannot shift with the count of extracted dynamic values. + assert_eq!(known.0, 1); + assert_eq!(runtime.0, 2); + assert_eq!(known.1.len(), 1); + assert_eq!(known.1, runtime.1); +} + +#[test] +#[serial] +fn generated_namespace_stays_disjoint_when_theme_tokens_resemble_site_names() { + // Given: legitimate theme keys occupying the old and plausible generated grammars. + let theme: sheet::theme::Theme = serde_json::from_str(r##"{ + "colors":{"default":{"test-S0-121":"#f00","test-Sa-a":"#00f","test-Saa-ad-b":"#f0f","test.SUfoo.a":"#0f0","_token":"#000","1token":"#fff"}} + }"##).unwrap_or_else(|error| panic!("legitimate theme keys must remain accepted: {error}")); + reset(false, false); + let mut sheet = StyleSheet::default(); + sheet.set_theme(theme); + let theme_css = sheet.create_css(None, false); + let source = "import {Box} from '@devup-ui/react';export const View=({pad})=>;"; + // When: the real extractor names an owner variable alongside theme variables. + let output = extract(FILE, source, ExtractOption::default()) + .unwrap_or_else(|error| panic!("theme extraction failed: {error}")); + let variables: BTreeSet<_> = output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Dynamic(style) => Some(style.variable_name()), + _ => None, + }) + .collect(); + // Then: the theme remains intact; generated and legacy namespaces cannot equal it. + assert!(theme_css.contains("--test-Sa-a:")); + assert_eq!(variables.len(), 1); + for variable in variables { + assert!(variable.starts_with("---test-S")); + assert!(!theme_css.contains(&format!("{variable}:"))); + for mode in [(false, false), (true, false), (false, true), (true, true)] { + css::debug::set_debug(mode.0); + css::atom_hoist::set_atom_hoist(mode.1.then_some(1)); + assert_ne!(variable, css::sheet_to_variable_name("padding", 0, None)); + } + } + css::debug::set_debug(false); + css::atom_hoist::set_atom_hoist(None); + sheet.set_theme(sheet::theme::Theme::default()); +} diff --git a/bindings/devup-ui-wasm/tests/source_naming.rs b/bindings/devup-ui-wasm/tests/source_naming.rs new file mode 100644 index 000000000..594607018 --- /dev/null +++ b/bindings/devup-ui-wasm/tests/source_naming.rs @@ -0,0 +1,169 @@ +use std::collections::BTreeMap; + +use extractor::{ExtractOption, ResolvedModule, extract_with_modules}; +use rstest::rstest; +use serial_test::serial; + +type EnvironmentOutputs = BTreeMap, String)>; + +fn environment_order( + source: &str, + values: [&str; 2], + order: [usize; 2], +) -> (EnvironmentOutputs, String) { + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + css::file_map::reset_canonical_map(); + css::debug::set_debug(false); + css::atom_hoist::set_atom_hoist(None); + css::atom_hoist::restore_atom_plan(None); + let stylesheet = source.contains("import {style}"); + let file = if stylesheet { + "/src/shared.css.ts" + } else { + "/src/shared.tsx" + }; + css::file_map::seed_file_numbers(&[file.to_string()]); + let mut outputs = BTreeMap::new(); + let mut final_sheet = sheet::StyleSheet::default(); + for environment in order { + let value = values[environment].to_string(); + let resolver = move |specifier: &str, _: &str| { + Some(ResolvedModule { + path: if specifier == "./barrel" { + "/src/barrel.ts".to_string() + } else if environment == 0 { + "/src/data.ts".to_string() + } else { + "/src/data.client.ts".to_string() + }, + code: if specifier == "./barrel" { + concat!("export {", "value", "} from './data';").to_string() + } else { + format!("export const value={value};") + }, + }) + }; + let output = extract_with_modules(file, source, ExtractOption::default(), true, &resolver) + .unwrap_or_else(|error| panic!("{error}")); + final_sheet + .update_styles(&output.styles, file, false) + .unwrap_or_else(|error| panic!("{error}")); + let mut sheet = sheet::StyleSheet::default(); + sheet + .update_styles(&output.styles, file, false) + .unwrap_or_else(|error| panic!("{error}")); + let css = sheet.create_css(Some(file), false); + assert!(css.contains("padding:16px"), "{css}"); + if stylesheet { + assert!(!css.contains(".a-a{"), "{css}"); + } else { + assert!(css.contains(".a-a{padding:16px}"), "{css}"); + assert!(output.code.contains("a-a"), "{}", output.code); + } + let expected_colors: &[&str] = match values[environment] { + "'red'" | "true" | "['red']" => &["red"], + "'blue'" | "false" => &["blue"], + "['red','blue']" => &["red", "blue"], + value => panic!("unexpected fixture value: {value}"), + }; + for color in expected_colors { + let declaration = format!("color:{color}"); + let identities: Vec<_> = css + .split('}') + .filter_map(|rule| rule.rsplit_once('{')) + .filter(|(_, body)| *body == declaration) + .map(|(selector, _)| { + let selector = selector + .rsplit_once('{') + .map_or(selector, |(_, selector)| selector); + selector + .rsplit_once("*/") + .map_or(selector, |(_, selector)| selector) + }) + .collect(); + assert_ne!(identities.len(), 0, "{css}"); + for identity in &identities { + assert_ne!(*identity, ".a-a", "{css}"); + assert!( + output.code.contains(identity.trim_start_matches('.')), + "{} / {css}", + output.code + ); + } + if source.contains("export const local") { + assert_eq!(identities.len(), 2, "{css}"); + assert_ne!(identities[0], identities[1], "{css}"); + assert!(identities.contains(&".a-b"), "{css}"); + } + } + assert!( + output + .map + .as_ref() + .unwrap_or_else(|| panic!("missing original-source map")) + .contains(&source.replace('"', "\\\"")) + ); + outputs.insert(environment, (output.code, output.map, css)); + } + (outputs, final_sheet.create_css(Some(file), false)) +} + +#[rstest] +#[case::relative("import {value} from './data';", "value", ["'red'", "'blue'"])] +#[case::bare("import {value} from 'data';", "value", ["'red'", "'blue'"])] +#[case::reexport("import {value} from './barrel';", "value", ["'red'", "'blue'"])] +#[case::local_chain( + "import {value} from './data';const a=value;const b=a;", + "b", + ["'red'", "'blue'"] +)] +#[case::control( + "import {value} from './data';", + "value ? 'red' : 'blue'", + ["true", "false"] +)] +#[case::responsive_shape( + "import {value} from './data';", + "value", + ["['red']", "['red','blue']"] +)] +#[case::equal_content("import {value} from './data';", "value", ["'red'", "'red'"])] +#[case::functions( + "import {value} from './data';function get(){return value;}const a=()=>get();", + "a()", + ["'red'", "'blue'"] +)] +#[serial] +fn names_when_environment_order_changes( + #[case] declarations: &str, + #[case] expression: &str, + #[case] values: [&str; 2], +) { + // Given: the same source resolves plain on the server and remapped on the client. + let local = if values == ["'red'", "'red'"] { + "export const local=css({color:'red'});" + } else { + "" + }; + let source = format!( + "import {{css}} from '@devup-ui/react';{declarations}export const cls=css({{color:{expression},padding:'16px'}});{local}" + ); + // When: both environments compile in opposite orders. + let forward = environment_order(&source, values, [0, 1]); + let reverse = environment_order(&source, values, [1, 0]); + // Then: full code/maps and materialized CSS agree, with no imported Own slots. + assert_eq!(forward, reverse); +} + +#[test] +#[serial] +fn stylesheet_names_when_environment_order_changes() { + // Given: executed styles depend on external module data. + let source = "import {style} from '@devup-ui/react';import {value} from './data';const choose=()=>value;export const cls=style({color:choose(),padding:'16px'});"; + // When: module loading runs under both environment orders. + let forward = environment_order(source, ["'red'", "'blue'"], [0, 1]); + let reverse = environment_order(source, ["'red'", "'blue'"], [1, 0]); + // Then: evaluated stylesheet code, original-source maps and CSS are identical. + assert_eq!(forward, reverse); +} diff --git a/bindings/devup-ui-wasm/tests/value-evaluation.mjs b/bindings/devup-ui-wasm/tests/value-evaluation.mjs new file mode 100644 index 000000000..2333cb549 --- /dev/null +++ b/bindings/devup-ui-wasm/tests/value-evaluation.mjs @@ -0,0 +1,318 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { createRequire } from 'node:module' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' + +const require = createRequire(import.meta.url) +const landing = createRequire( + new URL('../../../apps/landing/package.json', import.meta.url), +) +const React = landing('react') +assert.ok( + !process.argv[4] || process.env.VALUE_EVALUATION_QA === '1', + 'alternate WASM is private QA only', +) +const wasm = require(process.argv[4] ?? '../pkg/index.js') +const cases = Object.freeze({ + 'extract_conditional_style_props-2': ['Box', 'margin', 'a === b ? c : d'], + 'extract_dynamic_logical_case-3': ['Box', 'margin', 'a ?? b'], + 'extract_responsive_conditional_style_props-3': [ + 'Box', + 'margin', + 'a === b ? c : [d, e, f, "2px"]', + ], + 'extract_responsive_conditional_style_props-4': [ + 'Box', + 'margin', + 'a === b ? c : [d, e, f, x === y ? "4px" : "2px"]', + ], + 'extract_responsive_conditional_style_props-5': [ + 'Box', + 'margin', + 'a === b ? [d, e, f, x === y ? "4px" : "2px"] : c', + ], + member_expression_with_dynamic_values: [ + 'Box', + 'bg', + '({ a: first, b: second })[key]', + ], + props_direct_hybrid_responsive_select: [ + 'Flex', + 'gap', + '[[a, 1, c], [d, e, 2]][idx]', + ], + props_direct_variable_array_responsive_select: [ + 'Flex', + 'gap', + '[[a, b, c], [d, e, f]][idx]', + ], + 'props_direct_variable_array_responsive_select-2': [ + 'Flex', + 'gap', + '[, [d, e, f]][idx]', + ], + props_direct_variable_object_responsive_select: [ + 'Flex', + 'gap', + '{ 0: [a, b, c], "1": [d, e, f] }[idx]', + ], + logical_or: ['Box', 'margin', 'a || b'], + logical_and: ['Box', 'margin', 'a && b'], + getter_member: ['Box', 'margin', 'store.value'], + null_member: ['Box', 'margin', 'a === b ? c : store.value'], + index_side_effects: ['Flex', 'gap', '[[a, b, c], [d, e, f]][index()]'], +}) +for (const fixture of Object.values(cases)) Object.freeze(fixture) +const name = process.argv[2] +assert.ok(Object.hasOwn(cases, name), `unknown fixture ${name}`) +const [component, prop, expression] = cases[name] +const singleCss = process.argv[3] === 'single' +assert.ok(['single', 'ordinary'].includes(process.argv[3])) +wasm.resetBuildState() +wasm.setDebug(false) +wasm.registerTheme({ breakpoints: [0, 100, 200, 300, 400] }) +const source = `import { ${component} } from '@devup-ui/react'; globalThis.element = <${component} ${prop}={${expression}} />;` +const output = wasm.codeExtract( + 'value.tsx', + source, + '@devup-ui/react', + 'df', + singleCss, + false, + false, + {}, +) +const code = output.code +const css = + wasm.getCss(null, false) + (singleCss ? '' : '\n' + (output.css ?? '')) +output.free() +// Transpile only JSX; execute both programs in native Node VM contexts below. +const programs = JSON.parse( + execFileSync( + 'bun', + [ + '--eval', + ` +const input = await Bun.stdin.text(); +const transpiler = new Bun.Transpiler({loader:'tsx', tsconfig:{compilerOptions:{jsx:'react',jsxFactory:'React.createElement'}}}); +process.stdout.write(JSON.stringify(JSON.parse(input).map(code => transpiler.transformSync(code.replace(/import\\s*(?:\\{[^}]*\\}\\s*from\\s*)?["'][^"']+["'];?/g,''))))); +`, + ], + { + input: JSON.stringify([source, code]), + encoding: 'utf8', + timeout: 30_000, + cwd: fileURLToPath(new URL('../../../', import.meta.url)), + }, + ), +) + +// Fixtures are immutable; each execution owns its trace and read counters. +const scenarios = Object.freeze([ + ...[true, false].flatMap((equal) => + [0, 1].map((index) => Object.freeze({ equal, index })), + ), + ...[0, false, '', null, undefined].map((left) => + Object.freeze({ equal: true, index: 0, left, nullish: true }), + ), + ...[0, false, ''].map((left) => + Object.freeze({ equal: true, index: 0, left, nullish: true, tdz: 'b' }), + ), + Object.freeze({ equal: true, index: 0, throwing: 'd,e,f,x,y' }), + Object.freeze({ equal: false, index: 1, throwing: 'd,e,f,x,y' }), + Object.freeze({ equal: false, index: 1, throwing: 'c' }), + Object.freeze({ equal: true, index: 0, throwing: 'c' }), + Object.freeze({ equal: true, index: 0, tdz: 'd' }), + Object.freeze({ equal: false, index: 1, tdz: 'd' }), + Object.freeze({ equal: false, index: 1, tdz: 'c' }), + Object.freeze({ equal: true, index: 0, tdz: 'c' }), + Object.freeze({ + equal: true, + index: 0, + throwing: 'b', + left: '7px', + nullish: true, + }), + Object.freeze({ equal: true, index: 0, throwing: 'second' }), + Object.freeze({ equal: true, index: 0, changing: true }), + Object.freeze({ equal: true, index: 0, changingControl: true }), + Object.freeze({ equal: true, index: 0, nullMember: true }), + Object.freeze({ equal: false, index: 1, nullMember: true }), +]) +function evaluate(program, scenario) { + const trace = [] + const reads = new Map() + const values = { + a: scenario.equal ? 'same' : 'other', + b: 'same', + c: '11px', + d: '22px', + e: '33px', + f: '44px', + x: scenario.index, + y: 0, + first: 'red', + second: 'blue', + idx: scenario.index, + key: scenario.index ? 'b' : 'a', + } + if (prop === 'gap') Object.assign(values, { a: '11px', b: '12px', c: '13px' }) + if (scenario.nullish) Object.assign(values, { a: scenario.left, b: '55px' }) + const sandbox = { React, Box: component, Flex: component } + const read = (key) => { + trace.push(key) + const count = reads.get(key) ?? 0 + reads.set(key, count + 1) + if (scenario.throwing?.split(',').includes(key)) + throw new ReferenceError(key) + if (scenario.changingControl && key === 'a') return count ? 'other' : 'same' + if (scenario.changing && (key === 'idx' || key === 'key')) { + return key === 'idx' ? count % 2 : count % 2 ? 'b' : 'a' + } + return values[key] + } + for (const key of Object.keys(values)) + Object.defineProperty(sandbox, key, { get: () => read(key) }) + Object.assign(sandbox, { + store: scenario.nullMember + ? null + : { + get value() { + trace.push('getter') + return '66px' + }, + }, + index: () => { + trace.push('index') + return scenario.index + }, + }) + try { + runInNewContext( + `(() => { ${program}\n${scenario.tdz ? `let ${scenario.tdz};` : ''} })()`, + sandbox, + { timeout: 1000 }, + ) + return { trace, element: sandbox.element, error: null } + } catch (error) { + // Cross-realm errors are serialized at this execution boundary, never ignored. + assert.ok(error && typeof error.name === 'string') + return { trace, element: null, error: error.name } + } +} + +function selectedValues(element) { + const classes = new Set((element.props.className ?? '').split(/\s+/)) + const selected = new Map() + const property = prop === 'bg' ? 'background' : prop + // Parse nested rule blocks, carrying the actual media breakpoint into leaf rules. + function rules(text, level) { + let cursor = 0 + while (cursor < text.length) { + const open = text.indexOf('{', cursor) + if (open < 0) break + const header = text.slice(cursor, open).trim() + let end = open + 1 + let depth = 1 + while (depth && end < text.length) { + if (text[end] === '{') depth++ + if (text[end] === '}') depth-- + end++ + } + assert.equal(depth, 0, 'balanced emitted CSS') + const body = text.slice(open + 1, end - 1) + if (header.startsWith('@')) { + const minimum = /min-width:\s*(\d+)px/.exec(header) + rules(body, minimum ? Number(minimum[1]) / 100 : level) + } else { + const identifiers = [...header.matchAll(/\.([\w-]+)/g)].map( + (match) => match[1], + ) + if (identifiers.some((className) => classes.has(className))) { + for (const declaration of body.split(';')) { + const colon = declaration.indexOf(':') + if (declaration.slice(0, colon).trim() !== property) continue + const raw = declaration.slice(colon + 1).trim() + const variable = /^var\((--[^,)]+)\)$/.exec(raw) + const value = variable ? element.props.style?.[variable[1]] : raw + selected.set( + level, + value == null || value === false || value === '' + ? null + : String(value) === '0px' + ? '0' + : String(value), + ) + } + } + } + cursor = end + } + } + rules(css, 0) + return [...selected] + .filter(([, value]) => value !== null) + .sort(([a], [b]) => a - b) +} + +const failures = [] +for (const [index, scenario] of scenarios.entries()) { + // Given: the authored expression, with observable identifier/getter/index reads. + const authored = evaluate(programs[0], scenario) + // When: execute public-WASM output with exactly the same runtime bindings. + const compiled = evaluate(programs[1], scenario) + const value = authored.element?.props[prop] + // Devup's AND controller is a style guard, including JavaScript's numeric zero. + const guarded = name === 'logical_and' && scenario.nullish && !scenario.left + const expected = ( + guarded ? [] : Array.isArray(value) ? Array.from(value) : [value] + ).flatMap((entry, level) => + entry == null || entry === false || entry === '' + ? [] + : [ + [ + level, + prop === 'gap' && typeof entry === 'number' && entry !== 0 + ? `${entry * 4}px` + : String(entry) === '0px' + ? '0' + : String(entry), + ], + ], + ) + const actual = compiled.element ? selectedValues(compiled.element) : [] + const evidence = { + name, + mode: process.argv[3], + index, + scenario, + authored: { + trace: authored.trace, + error: authored.error, + values: expected, + }, + compiled: { trace: compiled.trace, error: compiled.error, values: actual }, + } + try { + // Then: selection, values, errors and exact source evaluation order/count agree. + assert.deepEqual(compiled.trace, authored.trace) + assert.equal(compiled.error, authored.error) + assert.deepEqual(actual, expected) + } catch (error) { + if (!(error instanceof assert.AssertionError)) throw error + failures.push(evidence) + } +} +if (process.env.VALUE_EVALUATION_QA === '1') { + process.stdout.write( + JSON.stringify({ + name, + mode: process.argv[3], + scenarios: scenarios.length, + failures, + }) + '\n', + ) +} else { + assert.deepEqual(failures, [], JSON.stringify(failures, null, 2)) +} diff --git a/e2e/environment-cascade-driver.ts b/e2e/environment-cascade-driver.ts new file mode 100644 index 000000000..825d9ecce --- /dev/null +++ b/e2e/environment-cascade-driver.ts @@ -0,0 +1,142 @@ +import { execFileSync } from 'node:child_process' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const repo = fileURLToPath(new URL('../', import.meta.url)) + +export const cascadeModes = [ + { name: 'ordinary', atomHoist: undefined, singleCss: false }, + { name: 'singleCss', atomHoist: undefined, singleCss: true }, + { name: 'atomHoist', atomHoist: 2, singleCss: false }, +] as const + +export const cascadeOrders = [ + ['A', 'B'], + ['B', 'A'], +] as const + +type CompiledFile = { + readonly source: string + readonly code: string + readonly cssFile: string + readonly css: string + readonly markup: string +} + +export type CascadePair = { + readonly A: CompiledFile + readonly B: CompiledFile + readonly shared: string +} + +// Authored once, unchanged for both extraction orders and delivery orders. +const sources = { + A: `export function View() { return ; }`, + B: `export function View() { return ; }`, +} as const + +function parseFile(value: unknown): CompiledFile { + if ( + typeof value !== 'object' || + value === null || + !('source' in value) || + typeof value.source !== 'string' || + !('code' in value) || + typeof value.code !== 'string' || + !('cssFile' in value) || + typeof value.cssFile !== 'string' || + !('css' in value) || + typeof value.css !== 'string' || + !('markup' in value) || + typeof value.markup !== 'string' + ) + throw new TypeError('Invalid public-WASM cascade file receipt') + return { + source: value.source, + code: value.code, + cssFile: value.cssFile, + css: value.css, + markup: value.markup, + } +} + +export function compileCascadePair( + mode: (typeof cascadeModes)[number], + order: (typeof cascadeOrders)[number], + seeded: boolean, +): CascadePair { + const root = mkdtempSync(join(tmpdir(), 'devup-cascade-pair-')) + try { + const result: unknown = JSON.parse( + execFileSync( + 'bun', + [ + '--eval', + String.raw` +import { createRequire } from 'node:module'; +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +const require = createRequire(${JSON.stringify(join(repo, 'apps/landing/package.json'))}); +const React = require('react'); +const { renderToStaticMarkup } = require('react-dom/server'); +const wasm = (await import(${JSON.stringify(pathToFileURL(resolve(repo, 'bindings/devup-ui-wasm/pkg/index.js')).href)})).default; +const filenames = { A: 'a.tsx', B: 'b.tsx' }; +const authored = ${JSON.stringify(sources)}; +wasm.resetBuildState(); +wasm.setDebug(false); +wasm.setPrefix(undefined); +if (${seeded}) wasm.seedFileMap(Object.values(filenames)); +wasm.setAtomHoist(${JSON.stringify(mode.atomHoist) ?? 'undefined'}); +wasm.importFileRoutes({ 'a.tsx': [0, 1], 'b.tsx': [0, 1] }); +const outputs = {}; +try { + for (const id of ${JSON.stringify(order)}) { + const source = 'import React from ' + JSON.stringify(pathToFileURL(require.resolve('react')).href) + '; import { Box } from "@devup-ui/react"; ' + authored[id]; + const output = wasm.codeExtract(filenames[id], source, '@devup-ui/react', 'df/devup-ui', ${mode.singleCss}, false, false, {}); + outputs[id] = { output, source }; + } + const transpiler = new Bun.Transpiler({ loader: 'tsx', tsconfig: { compilerOptions: { jsx: 'react', jsxFactory: 'React.createElement' } } }); + const files = {}; + for (const id of ['A', 'B']) { + const { output, source } = outputs[id]; + // Preserve raw compiled JS in the receipt; remove only bundler CSS imports for SSR. + const executable = output.code.replace(/import\s*["'][^"']+\.css(?:\?[^"']*)?["'];?/g, ''); + const modulePath = join(${JSON.stringify(root)}, id + '.mjs'); + writeFileSync(modulePath, transpiler.transformSync(executable)); + const { View } = await import(pathToFileURL(modulePath).href); + const number = output.cssFile?.match(/\/devup-ui-(\d+)\.css$/); + if (!${mode.singleCss} && !number) throw new TypeError('Missing public per-file CSS number'); + files[id] = { source, code: output.code, cssFile: output.cssFile ?? '', + css: ${mode.singleCss} ? '' : wasm.getCss(Number(number[1]), false), + markup: renderToStaticMarkup(React.createElement(View)) }; + } + process.stdout.write(JSON.stringify({ ...files, shared: wasm.getCss(null, false) })); +} finally { + for (const { output } of Object.values(outputs)) output.free(); +} +`, + ], + { cwd: root, encoding: 'utf8', timeout: 30_000 }, + ), + ) + if ( + typeof result !== 'object' || + result === null || + !('A' in result) || + !('B' in result) || + !('shared' in result) || + typeof result.shared !== 'string' + ) + throw new TypeError('Invalid public-WASM cascade pair receipt') + return { + A: parseFile(result.A), + B: parseFile(result.B), + shared: result.shared, + } + } finally { + rmSync(root, { recursive: true, force: true }) + } +} diff --git a/e2e/environment-cascade-pair.spec.ts b/e2e/environment-cascade-pair.spec.ts new file mode 100644 index 000000000..db1d00498 --- /dev/null +++ b/e2e/environment-cascade-pair.spec.ts @@ -0,0 +1,99 @@ +import { expect, test } from '@playwright/test' + +import { + cascadeModes, + cascadeOrders, + compileCascadePair, +} from './environment-cascade-driver' + +for (const mode of cascadeModes) { + test.describe(`cascade pair: ${mode.name}`, () => { + test('full output is identical when seeded extraction order reverses', async ({ + browserName, + }, testInfo) => { + // Given: D9 assigns both original IDs and CSS file numbers in path order. + const forward = compileCascadePair(mode, cascadeOrders[0], true) + // When: sources arrive BA rather than AB. + const reverse = compileCascadePair(mode, cascadeOrders[1], true) + // Then: compare complete source/code/markup/CSS/path bytes, without normalization. + await testInfo.attach(`${browserName}-seeded-full-output`, { + body: JSON.stringify({ forward, reverse }, null, 2), + contentType: 'application/json', + }) + expect(reverse).toEqual(forward) + }) + + for (const seeded of [false, true]) { + for (const extraction of cascadeOrders) { + for (const delivery of cascadeOrders) { + for (const width of [320, 1440]) { + test(`seeded=${seeded}, extract=${extraction.join('')}, sheets=${delivery.join('')}, width=${width}`, async ({ + page, + }, testInfo) => { + // Given: real public WASM compiles two unchanged authored files. + // The unseeded case is essential: 1968's private D9 counters mask the alias. + const pair = compileCascadePair(mode, extraction, seeded) + await testInfo.attach('raw-public-output', { + body: JSON.stringify(pair, null, 2), + contentType: 'application/json', + }) + const declarations = /(?:flex-direction|display):/ + if (mode.singleCss || mode.atomHoist !== undefined) { + expect(pair.shared).toMatch(declarations) + expect(pair.A.css).not.toMatch(declarations) + expect(pair.B.css).not.toMatch(declarations) + } else { + expect(pair.shared).not.toMatch(declarations) + expect(pair.A.css).toMatch(declarations) + expect(pair.B.css).toMatch(declarations) + } + const sheets = [ + { id: 'shared', css: pair.shared }, + ...delivery.map((id) => ({ id, css: pair[id].css })), + ] + await page.route('https://cascade.test/*.css', async (route) => { + const sheet = sheets.find( + ({ id }) => + route.request().url() === `https://cascade.test/${id}.css`, + ) + if (!sheet) + throw new TypeError('Unexpected cascade stylesheet request') + await route.fulfill({ + status: 200, + contentType: 'text/css', + body: sheet.css, + }) + }) + await page.setViewportSize({ width, height: 900 }) + // When: Chromium loads independent stylesheet links in explicit AB or BA order. + await page.setContent( + `${sheets.map(({ id }) => ``).join('')}${pair.A.markup}${pair.B.markup}`, + { waitUntil: 'load' }, + ) + // Then: #751 placement stays intact and no later base rule crosses file scopes. + expect( + await page.evaluate(() => + Array.from(document.styleSheets, (sheet) => sheet.href), + ), + ).toEqual( + sheets.map(({ id }) => `https://cascade.test/${id}.css`), + ) + expect( + await page.getByTestId('A').evaluate((element) => { + const style = getComputedStyle(element) + return [style.flexDirection, style.display] + }), + ).toEqual(width === 1440 ? ['row', 'flex'] : ['column', 'none']) + expect( + await page.getByTestId('B').evaluate((element) => { + const style = getComputedStyle(element) + return [style.flexDirection, style.display] + }), + ).toEqual(['column', 'none']) + }) + } + } + } + } + }) +} diff --git a/e2e/environment-dynamic-driver.ts b/e2e/environment-dynamic-driver.ts new file mode 100644 index 000000000..457bff047 --- /dev/null +++ b/e2e/environment-dynamic-driver.ts @@ -0,0 +1,63 @@ +import { execFileSync } from 'node:child_process' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const repo = fileURLToPath(new URL('../', import.meta.url)) + +export const dynamicModes = [ + { atomHoist: undefined, singleCss: false }, + { atomHoist: undefined, singleCss: true }, + { atomHoist: 2, singleCss: false }, + { atomHoist: 2, singleCss: true }, +] as const + +export function renderDynamicSite( + source: string, + render: string, + mode: (typeof dynamicModes)[number], +): string { + const root = mkdtempSync(join(tmpdir(), 'devup-dynamic-sites-')) + try { + return execFileSync( + 'bun', + [ + '--eval', + String.raw` +import { createRequire } from 'node:module'; +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +const require = createRequire(${JSON.stringify(join(repo, 'apps/landing/package.json'))}); +const React = require('react'); +const { renderToStaticMarkup } = require('react-dom/server'); +const wasm = (await import(${JSON.stringify(pathToFileURL(resolve(repo, 'bindings/devup-ui-wasm/pkg/index.js')).href)})).default; +const filename = 'src/view.tsx'; +const source = 'import React from ' + JSON.stringify(pathToFileURL(require.resolve('react')).href) + '; import { Box } from "@devup-ui/react"; ' + ${JSON.stringify(source)}; +wasm.setDebug(false); +wasm.seedFileMap([filename]); +wasm.setAtomHoist(${JSON.stringify(mode.atomHoist) ?? 'undefined'}); +wasm.importFileRoutes({ [filename]: [0, 1] }); +const output = wasm.codeExtract(filename, source, '@devup-ui/react', 'df/devup-ui', ${mode.singleCss}, false, false, {}); +try { + const css = wasm.getCss(null, false) + (${mode.singleCss} ? '' : '\n' + (output.css ?? '')); + // Only bundler-consumed CSS imports are removed; generated JSX executes intact. + const code = output.code.replace(/import\s*["'][^"']+\.css(?:\?[^"']*)?["'];?/g, ''); + const transpiler = new Bun.Transpiler({ loader: 'tsx', tsconfig: { compilerOptions: { jsx: 'react', jsxFactory: 'React.createElement' } } }); + const modulePath = join(${JSON.stringify(root)}, 'view.mjs'); + writeFileSync(modulePath, transpiler.transformSync(code)); + const { View } = await import(pathToFileURL(modulePath).href); + const markup = renderToStaticMarkup(${render}); + process.stdout.write('' + markup + ''); +} finally { + output.free(); +} +`, + ], + { cwd: root, encoding: 'utf8', timeout: 30_000 }, + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } +} diff --git a/e2e/environment-dynamic-sites.spec.ts b/e2e/environment-dynamic-sites.spec.ts new file mode 100644 index 000000000..493883757 --- /dev/null +++ b/e2e/environment-dynamic-sites.spec.ts @@ -0,0 +1,204 @@ +import { expect, test } from '@playwright/test' + +import { dynamicModes, renderDynamicSite } from './environment-dynamic-driver' + +const parameters = `({ pad, id, children }: { readonly pad: string | undefined; readonly id: string; readonly children?: React.ReactNode })` +const nested = `React.createElement(View, { pad: '16px', id: 'parent' }, React.createElement(View, { pad: undefined, id: 'child' }))` + +for (const mode of dynamicModes) { + test.describe(`atomHoist=${mode.atomHoist ?? 'off'}, singleCss=${mode.singleCss}`, () => { + test('undefined child padding stays initial when parent padding is dynamic', async ({ + page, + }) => { + // Given: different source sites, real public WASM output and React SSR. + const html = renderDynamicSite( + `export function View({ outer, inner }: { readonly outer: string; readonly inner: string | undefined }) { return ; }`, + `React.createElement(View, { outer: '16px', inner: undefined })`, + mode, + ) + // When: Chromium applies the generated CSS to the generated markup. + await page.setContent(html) + // Then: an omitted child variable cannot inherit the parent's value. + expect( + await page + .getByTestId('parent') + .evaluate((el) => getComputedStyle(el).padding), + ).toBe('16px') + expect( + await page + .getByTestId('child') + .evaluate((el) => getComputedStyle(el).padding), + ).toBe('0px') + }) + + test('recursive same-site padding does not inherit a missing value', async ({ + page, + }) => { + // Given: one compiled View rendered twice, nested at the same dynamic site. + const html = renderDynamicSite( + `export function View${parameters} { return {children}; }`, + nested, + mode, + ) + // When: Chromium resolves the omitted inner value. + await page.setContent(html) + // Then: the parent's present inline value wins; the child starts at zero. + expect( + await page + .locator('#parent') + .evaluate((el) => getComputedStyle(el).padding), + ).toBe('16px') + expect( + await page + .locator('#child') + .evaluate((el) => getComputedStyle(el).padding), + ).toBe('0px') + }) + + for (const consumer of [ + { + name: 'before pseudo-element', + props: `_before={{ content: '""', p: pad }}`, + children: '', + pseudo: '::before', + }, + { + name: 'direct descendant', + props: `selectors={{ '& > span': { p: pad } }}`, + children: '', + pseudo: null, + }, + ] as const) { + test(`valid dynamic padding reaches the ${consumer.name} consumer`, async ({ + page, + }) => { + // Given: the value belongs to the owner, not the consumer subject. + const html = renderDynamicSite( + `export function View${parameters} { return ${consumer.children}; }`, + `React.createElement(View, { pad: '16px', id: 'parent' })`, + mode, + ) + // When: Chromium inherits the generated variable into the consumer. + await page.setContent(html) + // Then: resetting on the pseudo-element or span would incorrectly erase it. + expect( + await page + .locator(consumer.pseudo ? '#parent' : '#consumer') + .evaluate( + (el, pseudo) => getComputedStyle(el, pseudo).padding, + consumer.pseudo, + ), + ).toBe('16px') + }) + } + + test('undefined dynamic color retains normal computed parent inheritance', async ({ + page, + }) => { + // Given: a red dynamic value on an ancestor whose computed color is green. + const html = renderDynamicSite( + `export function View${parameters} { return {children}; }`, + `React.createElement(View, { pad: 'red', id: 'ancestor' }, React.createElement('section', { style: { color: 'green' } }, React.createElement(View, { pad: undefined, id: 'child' })))`, + mode, + ) + // When: Chromium resolves the missing child color. + await page.setContent(html) + // Then: color inherits the immediate parent's computed green, not stale red. + expect( + await page + .locator('#child') + .evaluate((el) => getComputedStyle(el).color), + ).toBe('rgb(0, 128, 0)') + }) + + test('theme and manual variables remain inherited beside dynamic resets', async ({ + page, + }) => { + // Given: authored variables and a generated dynamic value share an owner. + const html = renderDynamicSite( + `export function View${parameters} { return {children}; }`, + `React.createElement('section', { style: { '--brand': 'green', '--manual': 'rgb(1, 2, 3)' } }, ${nested})`, + mode, + ) + // When: the child omits only the generated variable. + await page.setContent(html) + // Then: neither theme nor manual custom properties are reset. + expect( + await page + .locator('#child') + .evaluate((el) => [ + getComputedStyle(el).color, + getComputedStyle(el).backgroundColor, + ]), + ).toEqual(['rgb(0, 128, 0)', 'rgb(1, 2, 3)']) + }) + + for (const condition of [ + { name: 'responsive', props: 'p={[null, pad]}', active: 'viewport' }, + { name: 'hover', props: '_hover={{ p: pad }}', active: 'hover' }, + ] as const) { + test(`${condition.name} padding applies when active`, async ({ + page, + }) => { + // Given: dynamic padding available only under the selected condition. + const html = renderDynamicSite( + `export function View${parameters} { return content; }`, + `React.createElement(View, { pad: '16px', id: 'parent' })`, + mode, + ) + await page.setViewportSize({ width: 1600, height: 900 }) + await page.setContent(html) + // When: the condition is active (desktop viewport or actual hover). + if (condition.active === 'hover') await page.locator('#parent').hover() + // Then: the inline value wins over the unconditional own reset. + expect( + await page + .locator('#parent') + .evaluate((el) => getComputedStyle(el).padding), + ).toBe('16px') + }) + + test(`${condition.name} missing value is reset on its own element outside the condition`, async ({ + page, + }) => { + // Given: same-site recursion while neither conditional consumer is active. + const html = renderDynamicSite( + `export function View${parameters} { return {children}; }`, + nested, + mode, + ) + await page.setViewportSize({ width: 320, height: 900 }) + await page.mouse.move(319, 899) + // When: Chromium computes variables before media/hover rules can apply. + await page.setContent(html) + // Then: every generated variable has exactly one unconditional OWN reset. + const resets = await page.locator('#parent').evaluate((parent) => { + const child = document.querySelector('#child') + if ( + !(parent instanceof HTMLElement) || + !(child instanceof HTMLElement) + ) + throw new TypeError('Expected rendered elements') + return Array.from(parent.style) + .filter((name) => name.startsWith('--')) + .map((name) => ({ + present: getComputedStyle(parent).getPropertyValue(name).trim(), + missing: getComputedStyle(child).getPropertyValue(name).trim(), + ownRules: Array.from(document.styleSheets) + .flatMap((sheet) => Array.from(sheet.cssRules)) + .filter( + (rule) => + rule instanceof CSSStyleRule && + rule.style.getPropertyValue(name).trim() === 'initial' && + /^\.[\w-]+(?:\.[\w-]+)*$/.test(rule.selectorText) && + parent.matches(rule.selectorText), + ).length, + })) + }) + expect(resets.length).toBeGreaterThan(0) + for (const reset of resets) + expect(reset).toEqual({ present: '16px', missing: '', ownRules: 1 }) + }) + } + }) +} diff --git a/e2e/landing-zero-runtime.spec.ts b/e2e/landing-zero-runtime.spec.ts index e0a69157b..eb3ab531f 100644 --- a/e2e/landing-zero-runtime.spec.ts +++ b/e2e/landing-zero-runtime.spec.ts @@ -38,7 +38,7 @@ test.describe('Landing Page - Zero Runtime Validation', () => { test('no runtime CSS-in-JS globals are present', async ({ page }) => { const runtimeGlobals = await page.evaluate(() => { - const win = window as Record + const win = window return { // Common CSS-in-JS runtime indicators __devup__: '__devup__' in win, @@ -147,17 +147,31 @@ test.describe('Landing Page - Zero Runtime Validation', () => { .evaluate((element) => Array.from(element.classList)) expect(headingClasses.length).toBeGreaterThan(1) - const atomicClasses = headingClasses.filter((className) => - /^(?:[a-z]-)?[a-z0-9_]{1,3}$/i.test(className), + const contentAtom = + /^[OR](?:H[a-z0-9_]{16}|L(?=[a-z0-9_-]{1,16}$)(?:d-)?[a-z0-9_]{1,16}(?:-[vlosgay0-3][a-z0-9_]{0,14}){0,7})$/ + const privateCounterAtom = + /^(?:(?![a-z0-9_]{0,5}ad-)[a-z_][a-z0-9_]{0,6}|[a-z_][a-z0-9_]{0,4}a-d|a-d)-(?:(?![a-z0-9_]{0,11}ad$)[a-z_][a-z0-9_]{0,12}|[a-z_][a-z0-9_]{0,10}a-d|a-d)$/ + // Landing has no project prefix. D9 retains its existing anti-ad splice. + const d9Scope = + '(?:(?![a-z0-9_]{0,5}ad-)[a-z_][a-z0-9_]{0,6}|[a-z_][a-z0-9_]{0,4}a-d|a-d)' + const fallbackScope = + 'F(?:H[a-z0-9_]{16}|L(?=[a-z0-9_]{1,16}-)(?:[a-z0-9]|_(?:[dupchoemslnaq]|x[0-9a-f]+_)){1,16})' + const scopedContentAtom = new RegExp( + `^(?:${d9Scope}|${fallbackScope})-${contentAtom.source.slice(1, -1)}$`, ) + const isCompiledAtom = (className: string) => + contentAtom.test(className) || + (className.length <= (className.startsWith('F') ? 37 : 27) && + scopedContentAtom.test(className)) || + privateCounterAtom.test(className) + const atomicClasses = headingClasses.filter(isCompiledAtom) expect(atomicClasses.length).toBeGreaterThan(1) expect( headingClasses.every( (className) => - /^(?:[a-z]-)?[a-z0-9_]{1,3}$/i.test(className) || - /^typo-[a-z0-9]+$/i.test(className), + isCompiledAtom(className) || /^typo-[a-z0-9]{1,59}$/i.test(className), ), - `Expected compact base-37 classes, received: ${headingClasses.join(' ')}`, + `Expected compiled content, private base-37 or typography classes, received: ${headingClasses.join(' ')}`, ).toBe(true) const generatedRuntimeClasses = await page.evaluate(() => diff --git a/e2e/playwright.cascade.config.ts b/e2e/playwright.cascade.config.ts new file mode 100644 index 000000000..38e8b2432 --- /dev/null +++ b/e2e/playwright.cascade.config.ts @@ -0,0 +1,16 @@ +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { defineConfig } from '@playwright/test' + +export default defineConfig({ + testDir: '.', + testMatch: 'environment-cascade-pair.spec.ts', + outputDir: join(tmpdir(), `devup-cascade-results-${process.pid}`), + workers: 1, + retries: 0, + timeout: 60_000, + globalTimeout: 600_000, + reporter: 'line', + projects: [{ name: 'chromium', use: { browserName: 'chromium' } }], +}) diff --git a/libs/css/Cargo.toml b/libs/css/Cargo.toml index afd8a90df..9bff9687c 100644 --- a/libs/css/Cargo.toml +++ b/libs/css/Cargo.toml @@ -17,6 +17,10 @@ serde = { version = "1.0.229", features = ["derive"] } regex-lite = "0.1" bimap = { version = "0.6.3" } +[features] +# Test witness only; enabled by the binding dev-dependency, never by default. +admission-observer = [] + [dev-dependencies] rstest = "0.27.0" serial_test = "4.0.1" diff --git a/libs/css/src/admission.rs b/libs/css/src/admission.rs new file mode 100644 index 000000000..af001ec7c --- /dev/null +++ b/libs/css/src/admission.rs @@ -0,0 +1,140 @@ +//! Synchronous ownership of CSS state, before any individual data root is acquired. + +#[cfg(not(target_arch = "wasm32"))] +use std::sync::{Mutex, MutexGuard}; +use std::{cell::Cell, marker::PhantomData, rc::Rc}; + +#[cfg(not(target_arch = "wasm32"))] +static GATE: Mutex<()> = Mutex::new(()); +thread_local! { + static OWNER_DEPTH: Cell = const { Cell::new(0) }; + static EXACT_DEPTH: Cell = const { Cell::new(0) }; +} + +pub(crate) struct Lease { + #[cfg(not(target_arch = "wasm32"))] + _guard: Option>, + _thread: PhantomData>, +} + +pub(crate) fn enter() -> Lease { + let nested = OWNER_DEPTH.with(|depth| depth.get() > 0); + #[cfg(not(target_arch = "wasm32"))] + let guard = if nested { + None + } else { + #[cfg(any(test, feature = "admission-observer"))] + observer::arriving(); + let guard = GATE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + #[cfg(any(test, feature = "admission-observer"))] + observer::acquired(); + Some(guard) + }; + #[cfg(target_arch = "wasm32")] + let _ = nested; + OWNER_DEPTH.with(|depth| depth.set(depth.get() + 1)); + Lease { + #[cfg(not(target_arch = "wasm32"))] + _guard: guard, + _thread: PhantomData, + } +} + +impl Drop for Lease { + fn drop(&mut self) { + OWNER_DEPTH.with(|depth| depth.set(depth.get() - 1)); + } +} + +/// Hold native CSS admission through a synchronous callback. +/// +/// Same-owner nesting skips only the admission mutex, never a data-root lock. +/// Do not suspend or wait for a competing CSS caller inside the callback. +pub fn with_admission(build: impl FnOnce() -> R) -> R { + let _lease = enter(); + build() +} + +/// Reject owner administration that would invalidate an exact attempt's inputs. +/// The caller must obtain admission first. +/// +/// # Panics +/// Panics with `{operation}: an exact attempt is active` before mutation. +/// Later linked activation must use a typed error where this is recoverable. +#[doc(hidden)] +pub fn assert_administration_allowed(operation: &str) { + EXACT_DEPTH.with(|depth| { + assert!(depth.get() == 0, "{operation}: an exact attempt is active"); + }); +} + +/// Administration would invalidate an active exact attempt. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ActiveExactAttempt; + +impl std::fmt::Display for ActiveExactAttempt { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("an exact attempt is active") + } +} +impl std::error::Error for ActiveExactAttempt {} + +/// Check installation eligibility while the caller holds admission. +/// # Errors +/// Returns `ActiveExactAttempt` before mutation when any exact scope is active. +#[doc(hidden)] +pub fn check_administration_allowed() -> Result<(), ActiveExactAttempt> { + EXACT_DEPTH.with(|depth| match depth.get() { + 0 => Ok(()), + _ => Err(ActiveExactAttempt), + }) +} + +#[cfg(test)] +#[path = "admission_typed_tests.rs"] +mod typed_tests; + +pub(crate) struct ExactScope(PhantomData>); + +impl ExactScope { + pub(crate) fn enter() -> Self { + EXACT_DEPTH.with(|depth| depth.set(depth.get() + 1)); + Self(PhantomData) + } +} + +impl Drop for ExactScope { + fn drop(&mut self) { + EXACT_DEPTH.with(|depth| depth.set(depth.get() - 1)); + } +} + +#[cfg(any(test, feature = "admission-observer"))] +#[doc(hidden)] +#[path = "admission_observer.rs"] +pub mod observer; + +#[cfg(all(test, target_arch = "wasm32"))] +mod wasm_tests { + #[test] + fn permits_immutable_reborrows_when_wasm_callbacks_hold_refcell_reads() { + // Given / When / Then + crate::class_map::with_class_map(|outer| { + crate::class_map::with_class_map(|inner| assert_eq!(inner, outer)); + }); + crate::file_map::with_file_map(|outer| { + crate::file_map::with_file_map(|inner| assert_eq!(inner, outer)); + }); + crate::file_map::with_canonical_map(|outer| { + crate::file_map::with_canonical_map(|inner| assert_eq!(inner, outer)); + }); + crate::file_routes::with_file_routes(|outer| { + crate::file_routes::with_file_routes(|inner| assert_eq!(inner, outer)); + }); + crate::with_prefix(|outer| { + crate::with_prefix(|inner| assert_eq!(inner, outer)); + }); + } +} diff --git a/libs/css/src/admission_guard_tests.rs b/libs/css/src/admission_guard_tests.rs new file mode 100644 index 000000000..0089206af --- /dev/null +++ b/libs/css/src/admission_guard_tests.rs @@ -0,0 +1,182 @@ +use std::collections::{BTreeMap, HashMap}; + +use serial_test::serial; + +use crate::{admission::with_admission, exact_attempt::with_exclusive_attempt}; + +fn during_exact(mutate: impl FnOnce()) { + let result: Result<(), ()> = with_exclusive_attempt(|| { + mutate(); + Ok(()) + }); + assert_eq!(result, Ok(())); +} + +#[test] +#[serial] +#[should_panic(expected = "set_prefix: an exact attempt is active")] +fn rejects_prefix_when_exact_is_active() { + // Given / When / Then: mutation must panic before changing configuration. + during_exact(|| crate::set_prefix(Some("other".into()))); +} + +#[test] +#[serial] +#[should_panic(expected = "set_debug: an exact attempt is active")] +fn rejects_debug_when_exact_is_active() { + during_exact(|| crate::debug::set_debug(true)); +} + +#[test] +#[serial] +#[should_panic(expected = "set_custom_shorthands: an exact attempt is active")] +fn rejects_shorthands_when_exact_is_active() { + during_exact(|| crate::set_custom_shorthands(BTreeMap::new())); +} + +#[test] +#[serial] +#[should_panic(expected = "set_atom_hoist: an exact attempt is active")] +fn rejects_threshold_when_exact_is_active() { + during_exact(|| crate::atom_hoist::set_atom_hoist(Some(2))); +} + +#[test] +#[serial] +#[should_panic(expected = "set_file_routes: an exact attempt is active")] +fn rejects_routes_when_exact_is_active() { + during_exact(|| crate::file_routes::set_file_routes(HashMap::new())); +} + +#[test] +#[serial] +#[should_panic(expected = "reset_file_routes: an exact attempt is active")] +fn rejects_route_reset_when_exact_is_active() { + during_exact(crate::file_routes::reset_file_routes); +} + +#[test] +#[serial] +#[should_panic(expected = "set_canonical_map: an exact attempt is active")] +fn rejects_canonical_when_exact_is_active() { + during_exact(|| crate::file_map::set_canonical_map(HashMap::new())); +} + +#[test] +#[serial] +#[should_panic(expected = "reset_canonical_map: an exact attempt is active")] +fn rejects_canonical_reset_when_exact_is_active() { + during_exact(crate::file_map::reset_canonical_map); +} + +#[test] +#[serial] +#[should_panic(expected = "set_collapsed_buckets: an exact attempt is active")] +fn rejects_collapsed_when_exact_is_active() { + during_exact(|| crate::naming::set_collapsed_buckets(&HashMap::new())); +} + +#[test] +#[serial] +#[should_panic(expected = "set_root: an exact attempt is active")] +fn rejects_root_when_exact_is_active() { + during_exact(|| crate::naming_root::set_root(None)); +} + +#[test] +#[serial] +#[should_panic(expected = "set_context: an exact attempt is active")] +fn rejects_context_when_exact_is_active() { + during_exact(|| crate::naming_root::set_context(None, None)); +} + +#[test] +#[serial] +#[should_panic(expected = "set_theme_token_levels: an exact attempt is active")] +fn rejects_token_levels_when_exact_is_active() { + during_exact(|| crate::theme_tokens::set_theme_token_levels(BTreeMap::new(), BTreeMap::new())); +} + +#[test] +#[serial] +#[should_panic(expected = "set_theme_token_values: an exact attempt is active")] +fn rejects_token_values_when_exact_is_active() { + during_exact(|| crate::theme_tokens::set_theme_token_values(BTreeMap::new(), BTreeMap::new())); +} + +#[test] +#[serial] +#[should_panic(expected = "set_typography_keys: an exact attempt is active")] +fn rejects_typography_keys_when_exact_is_active() { + during_exact(|| crate::theme_tokens::set_typography_keys(vec![])); +} + +#[test] +#[serial] +#[should_panic(expected = "content_typography::set: an exact attempt is active")] +fn rejects_presets_when_exact_is_active() { + during_exact(|| crate::content_typography::set(BTreeMap::new())); +} + +#[test] +#[serial] +#[should_panic(expected = "set_file_map: an exact attempt is active")] +fn rejects_file_replacement_when_exact_is_active() { + // Given / When / Then: the exact scope rejects replacement before mutation. + during_exact(|| crate::file_map::set_file_map(bimap::BiHashMap::new())); +} + +#[test] +#[serial] +#[should_panic(expected = "reset_file_map: an exact attempt is active")] +fn rejects_file_reset_when_exact_is_active() { + // Given / When / Then: neither delivery nor original state may be cleared. + during_exact(crate::file_map::reset_file_map); +} + +#[test] +#[serial] +#[should_panic(expected = "seed_file_numbers: an exact attempt is active")] +fn rejects_file_seed_when_exact_is_active() { + // Given / When / Then: the prepass rejects even an empty batch during exact. + during_exact(|| crate::file_map::seed_file_numbers(&[])); +} + +#[test] +#[serial] +#[should_panic(expected = "set_original_ids: an exact attempt is active")] +fn rejects_original_replacement_when_exact_is_active() { + // Given / When / Then: original authority replacement is administrative. + during_exact(|| crate::file_map::set_original_ids(BTreeMap::new())); +} + +#[test] +#[serial] +#[should_panic(expected = "seed_original_ids: an exact attempt is active")] +fn rejects_original_seed_when_exact_is_active() { + // Given / When / Then: direct crate-level prepass seeding cannot bypass the guard. + during_exact(|| crate::sparse_site::source_ids::seed_original_ids(&[])); +} + +#[test] +#[serial] +#[should_panic(expected = "restore_atom_plan: an exact attempt is active")] +fn rejects_plan_restore_when_exact_is_active() { + // Given / When / Then: plan replacement or clearing must await scope exit. + during_exact(|| crate::atom_hoist::restore_atom_plan(None)); +} + +#[test] +#[serial] +fn keeps_outer_administration_guard_when_nested_exact_finishes() { + // Given / When + let result: Result<(), ()> = with_exclusive_attempt(|| { + with_exclusive_attempt::<(), ()>(|| Ok(()))?; + let blocked = std::panic::catch_unwind(|| crate::set_prefix(None)); + assert!(blocked.is_err()); + Ok(()) + }); + // Then + assert_eq!(result, Ok(())); + with_admission(|| crate::admission::assert_administration_allowed("after success")); +} diff --git a/libs/css/src/admission_input_tests.rs b/libs/css/src/admission_input_tests.rs new file mode 100644 index 000000000..16464a84b --- /dev/null +++ b/libs/css/src/admission_input_tests.rs @@ -0,0 +1,271 @@ +use std::{ + collections::{BTreeMap, BTreeSet, HashMap, HashSet}, + sync::mpsc, +}; + +use serial_test::serial; + +use crate::{ + admission::{observer::wait_for_thread, with_admission}, + allocation_input::{AllocationFile, capture_context}, + atom_hoist::{atom_plan, freeze_atom_plan, restore_atom_plan, set_atom_hoist}, + counter_test_helpers::declaration, + exact_attempt::with_exclusive_attempt, + file_map, +}; + +fn blocked_then( + contender: impl FnOnce() -> R + Send + 'static, + inspect: impl FnOnce(), + inspect_restored: impl FnOnce(), +) -> R { + let (sent, received) = mpsc::channel(); + let worker = with_admission(|| { + let mut worker = None; + let result: Result<(), ()> = with_exclusive_attempt(|| { + worker = Some(std::thread::spawn(move || { + sent.send(contender()) + .unwrap_or_else(|error| panic!("contender send failed: {error}")); + })); + let Some(worker) = worker.as_ref() else { + panic!("contender worker was not spawned"); + }; + wait_for_thread(worker.thread().id()); + assert!(matches!( + received.try_recv(), + Err(mpsc::TryRecvError::Empty) + )); + inspect(); + Err(()) + }); + assert_eq!(result, Err(())); + inspect_restored(); + worker.unwrap_or_else(|| panic!("contender worker was not retained")) + }); + let output = received + .recv() + .unwrap_or_else(|error| panic!("contender receive failed: {error}")); + worker + .join() + .unwrap_or_else(|payload| std::panic::resume_unwind(payload)); + output +} + +#[test] +#[serial] +fn configuration_setter_waits_when_exact_capture_owns_inputs() { + // Given + crate::set_prefix(Some("owner-".into())); + crate::debug::set_debug(false); + set_atom_hoist(None); + // When + blocked_then( + || crate::set_prefix(Some("writer-".into())), + || { + let captured = capture_context( + &declaration(Some("red")), + None, + crate::CounterOwner::Inactive, + ); + assert_eq!(captured.config.prefix, "owner-"); + }, + || {}, + ); + // Then + assert_eq!(crate::get_prefix(), Some("writer-".into())); + crate::set_prefix(None); +} + +#[test] +#[serial] +fn original_registration_waits_when_owner_restores_before_release() { + // Given + let before = BTreeMap::new(); + file_map::set_original_ids(before.clone()); + // When + let registered = blocked_then( + || file_map::get_or_insert_original_id("writer"), + || { + assert_eq!(file_map::get_or_insert_original_id("owner"), Ok(0)); + assert_eq!(file_map::original_id("writer"), None); + assert_eq!( + file_map::get_original_ids(), + BTreeMap::from([("owner".into(), 0)]) + ); + }, + || { + assert_eq!(file_map::get_original_ids(), before); + }, + ); + // Then + assert_eq!(registered, Ok(0)); + assert_eq!( + file_map::get_original_ids(), + BTreeMap::from([("writer".into(), 0)]) + ); + file_map::set_original_ids(BTreeMap::new()); +} + +#[test] +#[serial] +fn plan_reader_waits_when_owner_restores_before_release() { + // Given + let before = None; + restore_atom_plan(before.clone()); + set_atom_hoist(Some(1)); + crate::file_routes::set_file_routes(HashMap::from([("tentative".into(), HashSet::from([7]))])); + file_map::reset_canonical_map(); + // When + let observed = blocked_then( + atom_plan, + || { + freeze_atom_plan(); + assert_eq!(atom_plan(), Some(BTreeSet::from(["tentative".into()]))); + }, + || { + assert_eq!(atom_plan(), before); + }, + ); + // Then + assert_eq!(observed, None); + set_atom_hoist(None); + crate::file_routes::reset_file_routes(); +} + +#[test] +#[serial] +fn seed_waits_and_registers_originals_and_canonical_delivery_when_owner_releases() { + // Given + file_map::reset_file_map(); + file_map::set_canonical_map(HashMap::from([("child".into(), "root".into())])); + // When + blocked_then( + || file_map::seed_file_numbers(&["child".into(), "root".into()]), + || { + assert_eq!(file_map::get_original_ids(), BTreeMap::new()); + assert_eq!(file_map::get_file_map(), bimap::BiHashMap::new()); + }, + || {}, + ); + // Then + assert_eq!( + file_map::get_original_ids(), + BTreeMap::from([("child".into(), 0), ("root".into(), 1)]) + ); + assert_eq!( + file_map::get_file_map(), + bimap::BiHashMap::from_iter([("root".into(), 0)]) + ); + file_map::reset_canonical_map(); + file_map::reset_file_map(); +} + +#[test] +#[serial] +fn reset_waits_and_clears_files_and_originals_when_owner_releases() { + // Given + file_map::reset_file_map(); + file_map::seed_file_numbers(&["owner".into()]); + // When + blocked_then( + file_map::reset_file_map, + || { + assert_eq!(file_map::original_id("owner"), Some(0)); + assert_eq!(file_map::get_file_num_by_filename("owner"), 0); + }, + || {}, + ); + // Then + assert_eq!(file_map::get_original_ids(), BTreeMap::new()); + assert_eq!(file_map::get_file_map(), bimap::BiHashMap::new()); +} + +#[test] +#[serial] +fn canonical_setter_waits_and_updates_collapsed_state_when_owner_releases() { + // Given + file_map::reset_canonical_map(); + // When + blocked_then( + || file_map::set_canonical_map(HashMap::from([("child".into(), "root".into())])), + || { + assert_eq!(file_map::canonical("child"), "child"); + assert!(!crate::naming::is_shared_namespace(Some("root"))); + }, + || {}, + ); + // Then + assert_eq!(file_map::canonical("child"), "root"); + assert!(crate::naming::is_shared_namespace(Some("root"))); + file_map::reset_canonical_map(); +} + +#[test] +#[serial] +fn capture_and_plan_allow_nested_distinct_roots_when_owner_reenters() { + // Given + crate::set_prefix(Some("captured-".into())); + set_atom_hoist(Some(1)); + restore_atom_plan(None); + crate::file_routes::set_file_routes(HashMap::from([("child".into(), HashSet::from([7]))])); + file_map::set_canonical_map(HashMap::from([("child".into(), "root".into())])); + // When + let captured = with_exclusive_attempt::<_, ()>(|| { + freeze_atom_plan(); + Ok(capture_context( + &declaration(Some("red")), + Some("child"), + crate::CounterOwner::D9(42), + )) + }) + .unwrap_or_else(|()| panic!("nested capture attempt unexpectedly failed")); + // Then + assert_eq!(captured.file, Some(AllocationFile::Original(42))); + assert_eq!(captured.config.prefix, "captured-"); + assert_eq!( + captured.delivery, + Some(crate::allocation_input::CapturedDelivery { + canonical: "root".into(), + hoisted: true + }) + ); + assert_eq!(atom_plan(), Some(BTreeSet::from(["root".into()]))); + set_atom_hoist(None); + restore_atom_plan(None); + crate::file_routes::reset_file_routes(); + file_map::reset_canonical_map(); + crate::set_prefix(None); +} + +#[test] +#[serial] +fn delivery_registration_waits_when_owner_restores_before_release() { + // Given + let before = bimap::BiHashMap::from_iter([("seed".into(), 42)]); + let originals = BTreeMap::from([("seed".into(), 32)]); + file_map::set_file_map(before.clone()); + file_map::set_original_ids(originals.clone()); + // When + let registered = blocked_then( + || file_map::get_file_num_by_filename("writer"), + || { + assert_eq!(file_map::get_file_num_by_filename("owner"), 1); + assert_eq!( + file_map::get_file_map(), + bimap::BiHashMap::from_iter([("seed".into(), 42), ("owner".into(), 1)]) + ); + }, + || { + assert_eq!(file_map::get_file_map(), before); + assert_eq!(file_map::get_original_ids(), originals); + }, + ); + // Then + assert_eq!(registered, 1); + assert_eq!( + file_map::get_file_map(), + bimap::BiHashMap::from_iter([("seed".into(), 42), ("writer".into(), 1)]) + ); + assert_eq!(file_map::get_original_ids(), originals); + file_map::reset_file_map(); +} diff --git a/libs/css/src/admission_interleaving_tests.rs b/libs/css/src/admission_interleaving_tests.rs new file mode 100644 index 000000000..8c22595c9 --- /dev/null +++ b/libs/css/src/admission_interleaving_tests.rs @@ -0,0 +1,205 @@ +use std::{collections::HashMap, sync::mpsc}; + +use serial_test::serial; + +use crate::{ + admission::{observer::wait_for_thread, with_admission}, + allocation_input::NameMode, + class_map::{ + get_class_map, reset_class_map, set_class_map, with_class_map, with_class_map_mut, + }, + counter_names::allocate_name, + counter_test_helpers::{context, declaration}, + exact_attempt::with_exclusive_attempt, +}; + +fn after_abort(contender: impl FnOnce() -> R + Send + 'static) -> R { + let (sent, received) = mpsc::channel(); + let worker = with_admission(|| { + let mut worker = None; + let result: Result<(), ()> = with_exclusive_attempt(|| { + let _owner_allocation = + allocate_name(&declaration(Some("red")), &context(NameMode::Counter, None)); + worker = Some(std::thread::spawn(move || { + sent.send(contender()) + .unwrap_or_else(|error| panic!("contender send failed: {error}")); + })); + let Some(worker) = worker.as_ref() else { + panic!("contender worker was not spawned"); + }; + wait_for_thread(worker.thread().id()); + assert!(matches!( + received.try_recv(), + Err(mpsc::TryRecvError::Empty) + )); + Err(()) + }); + assert_eq!(result, Err(())); + worker.unwrap_or_else(|| panic!("contender worker was not retained")) + }); + let output = received + .recv() + .unwrap_or_else(|error| panic!("contender receive failed: {error}")); + worker + .join() + .unwrap_or_else(|payload| std::panic::resume_unwind(payload)); + output +} + +#[test] +#[serial] +fn public_read_waits_and_observes_snapshot_when_owner_aborts() { + // Given + let before = HashMap::from([("empty".into(), HashMap::new())]); + set_class_map(before.clone()); + // When + let observed = after_abort(get_class_map); + // Then + assert_eq!(observed, before); + reset_class_map(); +} + +#[test] +#[serial] +fn public_callback_read_waits_when_owner_aborts() { + // Given + reset_class_map(); + // When + let observed = after_abort(|| with_class_map(Clone::clone)); + // Then + assert_eq!(observed, HashMap::new()); +} + +#[test] +#[serial] +fn public_allocation_survives_when_owner_aborts() { + // Given + reset_class_map(); + // When + let name = after_abort(|| { + allocate_name( + &declaration(Some("blue")), + &context(NameMode::Counter, None), + ) + }); + // Then + assert_eq!(name.name, "app-a"); + assert_eq!( + get_class_map(), + HashMap::from([( + String::new(), + HashMap::from([("color-0-blue--255".into(), 0)]), + )]) + ); + reset_class_map(); +} + +#[test] +#[serial] +fn tentative_reuse_becomes_retained_allocation_when_owner_aborts() { + // Given + reset_class_map(); + // When + let name = + after_abort(|| allocate_name(&declaration(Some("red")), &context(NameMode::Counter, None))); + // Then: bypass would reuse the owner's receipt and then lose this key. + assert_eq!(name.name, "app-a"); + assert_eq!( + get_class_map(), + HashMap::from([( + String::new(), + HashMap::from([("color-0-red--255".into(), 0)]), + )]) + ); + reset_class_map(); +} + +#[test] +#[serial] +fn arbitrary_mutation_survives_when_owner_aborts() { + // Given + reset_class_map(); + // When + after_abort(|| { + with_class_map_mut(|map| { + map.insert("writer".into(), HashMap::from([("kept".into(), 47)])); + }); + }); + // Then + assert_eq!( + get_class_map(), + HashMap::from([("writer".into(), HashMap::from([("kept".into(), 47)]))]) + ); + reset_class_map(); +} + +#[test] +#[serial] +fn reset_runs_after_restore_when_owner_aborts() { + // Given + set_class_map(HashMap::from([("old-empty".into(), HashMap::new())])); + // When + after_abort(reset_class_map); + // Then + assert_eq!(get_class_map(), HashMap::new()); +} + +#[test] +#[serial] +fn replacement_runs_after_restore_when_owner_aborts() { + // Given + reset_class_map(); + let replacement = HashMap::from([("replacement-empty".into(), HashMap::new())]); + let expected = replacement.clone(); + // When + after_abort(move || set_class_map(replacement)); + // Then + assert_eq!(get_class_map(), expected); + reset_class_map(); +} + +#[test] +#[serial] +fn read_observes_committed_map_when_owner_succeeds() { + // Given + reset_class_map(); + let (sent, received) = mpsc::channel(); + // When + let worker = with_admission(|| { + let mut worker = None; + let result = with_exclusive_attempt::<_, ()>(|| { + let _owner_allocation = + allocate_name(&declaration(Some("red")), &context(NameMode::Counter, None)); + worker = Some(std::thread::spawn(move || { + sent.send(get_class_map()) + .unwrap_or_else(|error| panic!("committed map send failed: {error}")); + })); + let Some(worker) = worker.as_ref() else { + panic!("map reader worker was not spawned"); + }; + wait_for_thread(worker.thread().id()); + assert!(matches!( + received.try_recv(), + Err(mpsc::TryRecvError::Empty) + )); + Ok(()) + }); + assert_eq!(result, Ok(())); + worker.unwrap_or_else(|| panic!("map reader worker was not retained")) + }); + let observed = received + .recv() + .unwrap_or_else(|error| panic!("committed map receive failed: {error}")); + worker + .join() + .unwrap_or_else(|payload| std::panic::resume_unwind(payload)); + // Then + assert_eq!( + observed, + HashMap::from([( + String::new(), + HashMap::from([("color-0-red--255".into(), 0)]), + )]) + ); + reset_class_map(); +} diff --git a/libs/css/src/admission_observer.rs b/libs/css/src/admission_observer.rs new file mode 100644 index 000000000..7cce71dc2 --- /dev/null +++ b/libs/css/src/admission_observer.rs @@ -0,0 +1,43 @@ +//! Gate-arrival witness only; this does not participate in admission decisions. + +use std::{ + collections::HashSet, + sync::{Condvar, LazyLock, Mutex}, + thread::ThreadId, +}; + +static BLOCKED: LazyLock>> = LazyLock::new(|| Mutex::new(HashSet::new())); +static CHANGED: Condvar = Condvar::new(); + +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn arriving() { + let mut threads = BLOCKED + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + threads.insert(std::thread::current().id()); + drop(threads); + CHANGED.notify_all(); +} + +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn acquired() { + let mut threads = BLOCKED + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + threads.remove(&std::thread::current().id()); + drop(threads); + CHANGED.notify_all(); +} + +/// Wait for this particular thread to arrive before the normal outer gate lock. +/// The observing test must own admission so the contender cannot acquire it. +pub fn wait_for_thread(thread: ThreadId) { + let blocked = BLOCKED + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + drop( + CHANGED + .wait_while(blocked, |blocked| !blocked.contains(&thread)) + .unwrap_or_else(std::sync::PoisonError::into_inner), + ); +} diff --git a/libs/css/src/admission_root_tests.rs b/libs/css/src/admission_root_tests.rs new file mode 100644 index 000000000..8186dc6e3 --- /dev/null +++ b/libs/css/src/admission_root_tests.rs @@ -0,0 +1,95 @@ +use serial_test::serial; + +#[test] +#[serial] +#[should_panic(expected = "class_map: a data root is already held")] +fn rejects_class_read_recursion_when_class_callback_holds_lock() { + // Given / When / Then + crate::class_map::with_class_map(|_| crate::class_map::get_class_map()); +} + +#[test] +#[serial] +#[should_panic(expected = "class_map: a data root is already held")] +fn rejects_class_mutation_recursion_when_class_callback_holds_lock() { + crate::class_map::with_class_map_mut(|_| crate::class_map::reset_class_map()); +} + +#[test] +#[serial] +#[should_panic(expected = "file_map: a data root is already held")] +fn rejects_file_recursion_when_file_callback_holds_lock() { + crate::file_map::with_file_map(|_| crate::file_map::get_file_num_by_filename("recursive")); +} + +#[test] +#[serial] +#[should_panic(expected = "canonical_map: a data root is already held")] +fn rejects_canonical_recursion_when_canonical_callback_holds_lock() { + crate::file_map::with_canonical_map(|_| crate::file_map::canonical("recursive")); +} + +#[test] +#[serial] +#[should_panic(expected = "file_routes: a data root is already held")] +fn rejects_routes_recursion_when_routes_callback_holds_lock() { + crate::file_routes::with_file_routes(|_| crate::file_routes::get_file_routes()); +} + +#[test] +#[serial] +#[should_panic(expected = "file_routes: a data root is already held")] +fn rejects_routes_recursion_when_lazy_iterator_runs_under_lock() { + let _route_count = crate::file_routes::route_count_for_files(std::iter::once_with(|| { + let _file_routes = crate::file_routes::get_file_routes(); + "recursive" + })); +} + +#[test] +#[serial] +#[should_panic(expected = "prefix: a data root is already held")] +fn rejects_prefix_recursion_when_prefix_callback_holds_lock() { + crate::with_prefix(|_| crate::get_prefix()); +} + +#[test] +#[serial] +#[should_panic(expected = "key_buf: a data root is already held")] +fn rejects_scratch_recursion_when_builder_holds_borrow() { + crate::class_slot_for_key("recursive", |_| { + crate::class_slot_for_key("recursive", |_| {}); + }); +} + +#[test] +#[serial] +fn releases_root_marker_when_callback_unwinds() { + // Given + crate::class_map::reset_class_map(); + // When + let failed = std::panic::catch_unwind(|| { + crate::class_map::with_class_map(|_| panic!("callback unwound")); + }); + // Then + assert!(failed.is_err()); + assert_eq!( + crate::class_map::get_class_map(), + std::collections::HashMap::new() + ); +} + +#[test] +#[serial] +fn permits_distinct_root_and_sequential_reentry_when_owner_is_nested() { + // Given + crate::set_prefix(Some("nested-".into())); + crate::debug::set_debug(false); + // When + let name = crate::admission::with_admission(|| { + crate::admission::with_admission(|| crate::keyframes_name_of_escaped("frames")) + }); + // Then + assert_eq!(name, "nested-Kframes"); + crate::set_prefix(None); +} diff --git a/libs/css/src/admission_typed_tests.rs b/libs/css/src/admission_typed_tests.rs new file mode 100644 index 000000000..b79965d02 --- /dev/null +++ b/libs/css/src/admission_typed_tests.rs @@ -0,0 +1,44 @@ +use super::{ActiveExactAttempt, check_administration_allowed, with_admission}; + +#[rstest::rstest] +#[case(false)] +#[case(true)] +#[serial_test::serial] +fn typed_administration_rejects_when_exact_scope_is_active(#[case] nested: bool) { + // Given + let check = || { + if nested { + crate::exact_attempt::with_exclusive_attempt(|| { + Ok::<_, ()>(check_administration_allowed()) + }) + } else { + Ok(check_administration_allowed()) + } + }; + // When + let result = with_admission(|| crate::exact_attempt::with_exclusive_attempt(check)); + // Then + assert_eq!(result, Ok(Err(ActiveExactAttempt))); +} + +#[test] +#[serial_test::serial] +fn typed_administration_accepts_when_exact_scope_has_finished() { + // Given + let finished = crate::exact_attempt::with_exclusive_attempt(|| Ok::<_, ()>(())); + assert_eq!(finished, Ok(())); + // When + let result = with_admission(check_administration_allowed); + // Then + assert_eq!(result, Ok(())); +} + +#[test] +fn active_exact_error_has_a_typed_diagnostic() { + // Given + let error: &dyn std::error::Error = &ActiveExactAttempt; + // When + let diagnostic = error.to_string(); + // Then + assert_eq!(diagnostic, "an exact attempt is active"); +} diff --git a/libs/css/src/allocation_input.rs b/libs/css/src/allocation_input.rs new file mode 100644 index 000000000..46c6ca668 --- /dev/null +++ b/libs/css/src/allocation_input.rs @@ -0,0 +1,156 @@ +//! Captured inputs for dormant legacy naming, independent of sheet expansion evidence. + +use serde::{Deserialize, Serialize}; + +use crate::CounterOwner; + +/// Exact naming input before effective declaration expansion. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum LegacyInput { + /// A declaration's legacy naming dimensions. + Declaration(LegacyDeclaration), + /// The exact old decimal hash/string supplied to keyframe naming. + Keyframes(String), + /// A no-site variable's legacy naming dimensions. + Variable(LegacyVariable), +} + +/// Authored declaration dimensions, not an emitted expansion witness. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct LegacyDeclaration { + pub property: String, + pub level: u8, + pub value: Option, + pub selector: Option, + pub order: Option, +} + +/// Legacy no-site variable input; authored source-site variables are separate. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct LegacyVariable { + pub property: String, + pub level: u8, + pub selector: Option, +} + +/// Baseline naming branch, captured before pure projection. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum NameMode { + Counter, + Debug, + AtomHoist, +} + +/// Captured naming configuration; serialized values confer no build authority. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CapturedNameConfig { + pub prefix: String, + pub mode: NameMode, +} + +/// Allocation identity, deliberately separate from canonical delivery. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum AllocationFile { + /// Low-level #751 filename namespace and its already resolved ordinal. + Legacy { filename: String, ordinal: usize }, + /// Explicit original numeric authority supplied by a future extractor adapter. + Original(u32), +} + +impl AllocationFile { + pub(crate) fn namespace(&self) -> String { + match self { + Self::Legacy { filename, .. } => filename.clone(), + Self::Original(id) => format!("D9-{id}"), + } + } + + pub(crate) fn ordinal(&self) -> usize { + match self { + Self::Legacy { ordinal, .. } => *ordinal, + Self::Original(id) => id + .to_be_bytes() + .into_iter() + .fold(0usize, |number, byte| (number << 8) | usize::from(byte)), + } + } +} + +/// Frozen delivery inputs used only to reproduce baseline atom scopes. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CapturedDelivery { + pub canonical: String, + pub hoisted: bool, +} + +/// Complete map-independent inputs for key/name projection. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AllocationContext { + pub config: CapturedNameConfig, + pub file: Option, + pub delivery: Option, +} + +/// Capture live producer inputs once; admission must use the resulting frozen data. +/// +/// Only the inactive/unnumbered low-level branch resolves a legacy file ordinal. +/// Original ownership never reads or falls back to canonical delivery numbering. +#[must_use] +pub fn capture_context( + input: &LegacyInput, + filename: Option<&str>, + owner: CounterOwner, +) -> AllocationContext { + let _admission = crate::admission::enter(); + let mode = if crate::atom_hoist::is_atom_hoist() { + NameMode::AtomHoist + } else if crate::debug::is_debug() { + NameMode::Debug + } else { + NameMode::Counter + }; + let filename = match input { + LegacyInput::Declaration(declaration) if declaration.order == Some(0) => None, + LegacyInput::Declaration(_) | LegacyInput::Keyframes(_) => filename, + LegacyInput::Variable(_) => None, + }; + let needs_ordinal = match (mode, input) { + (NameMode::Counter, _) | (NameMode::Debug, LegacyInput::Declaration(_)) => true, + (NameMode::Debug, LegacyInput::Keyframes(_) | LegacyInput::Variable(_)) + | (NameMode::AtomHoist, _) => false, + }; + let file = filename.and_then(|filename| match owner { + CounterOwner::D9(id) => Some(AllocationFile::Original(id)), + CounterOwner::Inactive | CounterOwner::Unnumbered => { + needs_ordinal.then(|| AllocationFile::Legacy { + filename: filename.to_string(), + ordinal: crate::file_map::get_file_num_by_filename(filename), + }) + } + }); + let delivery = filename.map(|filename| CapturedDelivery { + canonical: crate::file_map::canonical(filename), + hoisted: mode == NameMode::AtomHoist + && matches!(input, LegacyInput::Declaration(_)) + && crate::atom_hoist::is_hoisted_bucket(filename), + }); + AllocationContext { + config: CapturedNameConfig { + prefix: crate::get_prefix().unwrap_or_default(), + mode, + }, + file, + delivery, + } +} + +pub(crate) fn effective_file<'a>( + input: &LegacyInput, + context: &'a AllocationContext, +) -> Option<&'a AllocationFile> { + match input { + LegacyInput::Declaration(declaration) if declaration.order == Some(0) => None, + LegacyInput::Declaration(_) | LegacyInput::Keyframes(_) => context.file.as_ref(), + LegacyInput::Variable(_) => None, + } +} diff --git a/libs/css/src/atom_hoist.rs b/libs/css/src/atom_hoist.rs index 259e4a6a7..50a514024 100644 --- a/libs/css/src/atom_hoist.rs +++ b/libs/css/src/atom_hoist.rs @@ -1,18 +1,92 @@ +use std::collections::{BTreeSet, HashMap, HashSet}; use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{LazyLock, Mutex}; -// Atom-level hoist threshold. 0 = disabled (default). N = a style atom whose -// content is used by >= N distinct routes is emitted into the shared global -// devup-ui.css (shipped once) instead of duplicated across per-route chunks. +use crate::{file_map::canonical, file_routes::with_file_routes}; + +static FROZEN_PLAN: LazyLock>>> = LazyLock::new(|| Mutex::new(None)); + +/// Freeze every eligible canonical bucket before any source is processed. +pub fn freeze_atom_plan() { + let _admission = crate::admission::enter(); + if let Some(threshold) = atom_hoist_threshold() { + let _root = crate::root_held::RootHeld::enter("atom_plan"); + let mut plan = FROZEN_PLAN + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if plan.is_none() { + let buckets = with_file_routes(|routes| { + let mut buckets: HashMap> = HashMap::new(); + for (file, reach) in routes { + buckets.entry(canonical(file)).or_default().extend(reach); + } + buckets + }); + *plan = Some( + buckets + .into_iter() + .filter_map(|(bucket, routes)| (routes.len() >= threshold).then_some(bucket)) + .collect(), + ); + } + } +} + +/// Snapshot the immutable build plan for sheet persistence. +pub fn atom_plan() -> Option> { + let _admission = crate::admission::enter(); + let _root = crate::root_held::RootHeld::enter("atom_plan"); + FROZEN_PLAN + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() +} + +/// Restore a cached plan, or clear it when starting a new build. +pub fn restore_atom_plan(plan: Option>) { + let _admission = crate::admission::enter(); + crate::admission::assert_administration_allowed("restore_atom_plan"); + let _root = crate::root_held::RootHeld::enter("atom_plan"); + *FROZEN_PLAN + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = plan; +} + +/// Restore only the frozen-plan snapshot owned by a private exact attempt. +pub(crate) fn restore_atom_plan_snapshot(snapshot: Option>) { + let _admission = crate::admission::enter(); + let _root = crate::root_held::RootHeld::enter("atom_plan"); + *FROZEN_PLAN + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = snapshot; +} + +/// Whether this bucket was eligible at the build's extraction boundary. +pub fn is_hoisted_bucket(filename: &str) -> bool { + let _admission = crate::admission::enter(); + freeze_atom_plan(); + let _root = crate::root_held::RootHeld::enter("atom_plan"); + FROZEN_PLAN + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + .is_some_and(|plan| plan.contains(&canonical(filename))) +} + +// Zero disables atom mode; eligible bucket reach is frozen before extraction. static ATOM_HOIST_THRESHOLD: AtomicUsize = AtomicUsize::new(0); #[inline(always)] pub fn set_atom_hoist(threshold: Option) { + let _admission = crate::admission::enter(); + crate::admission::assert_administration_allowed("set_atom_hoist"); ATOM_HOIST_THRESHOLD.store(threshold.unwrap_or(0), Ordering::Relaxed); } #[inline(always)] #[must_use] pub fn atom_hoist_threshold() -> Option { + let _admission = crate::admission::enter(); match ATOM_HOIST_THRESHOLD.load(Ordering::Relaxed) { 0 => None, v => Some(v), @@ -22,6 +96,7 @@ pub fn atom_hoist_threshold() -> Option { #[inline(always)] #[must_use] pub fn is_atom_hoist() -> bool { + let _admission = crate::admission::enter(); ATOM_HOIST_THRESHOLD.load(Ordering::Relaxed) != 0 } diff --git a/libs/css/src/atom_name.rs b/libs/css/src/atom_name.rs new file mode 100644 index 000000000..f93cf7057 --- /dev/null +++ b/libs/css/src/atom_name.rs @@ -0,0 +1,59 @@ +use crate::{atom_hoist::is_hoisted_bucket, file_map::canonical, style_selector::StyleSelector}; +use std::fmt::Write; + +/// Lossless byte encoding with no delimiter or CSS identifier ambiguity. +pub fn hex(value: &str) -> String { + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + let mut result = String::with_capacity(value.len() * 2); + for byte in value.bytes() { + result.push(char::from(DIGITS[usize::from(byte >> 4)])); + result.push(char::from(DIGITS[usize::from(byte & 15)])); + } + result +} + +/// Names identify placement, never the order a source was encountered. +pub fn scope(filename: Option<&str>) -> String { + let _admission = crate::admission::enter(); + match filename { + None => "g".to_string(), + Some(file) if is_hoisted_bucket(file) => "h".to_string(), + Some(file) => format!("l-{}", hex(&canonical(file))), + } +} + +/// Structural selector identity excludes the source owner used only for cleanup. +pub fn selector_key(selector: Option<&StyleSelector>, layer: Option<&str>) -> String { + let mut key = match selector { + None => "n".to_string(), + Some(StyleSelector::Selector(text)) => format!("s-{}", hex(text)), + Some(StyleSelector::Global(text, _)) => format!("g-{}", hex(text)), + Some(StyleSelector::At { + kind, + query, + selector, + outer, + file: _, + }) => { + let mut key = String::from("a"); + for rule in outer { + let _ = write!(key, "-{}-{}", rule.kind, hex(&rule.query)); + } + let _ = write!(key, "-end-{kind}-{}-", hex(query)); + match selector { + None => key.push('n'), + Some(text) => { + let _ = write!(key, "s-{}", hex(text)); + } + } + key + } + }; + match layer { + None => key.push_str("-n"), + Some(layer) => { + let _ = write!(key, "-s-{}", hex(layer)); + } + } + key +} diff --git a/libs/css/src/class_map.rs b/libs/css/src/class_map.rs index 010715047..9ae083235 100644 --- a/libs/css/src/class_map.rs +++ b/libs/css/src/class_map.rs @@ -23,6 +23,7 @@ pub fn with_class_map(f: F) -> R where F: FnOnce(&HashMap>) -> R, { + let _admission = crate::admission::enter(); #[cfg(target_arch = "wasm32")] #[cfg(not(tarpaulin_include))] { @@ -30,6 +31,7 @@ where } #[cfg(not(target_arch = "wasm32"))] { + let _root = crate::root_held::RootHeld::enter("class_map"); let guard = GLOBAL_CLASS_MAP .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); @@ -42,6 +44,7 @@ pub fn with_class_map_mut(f: F) -> R where F: FnOnce(&mut HashMap>) -> R, { + let _admission = crate::admission::enter(); #[cfg(target_arch = "wasm32")] #[cfg(not(tarpaulin_include))] { @@ -49,6 +52,7 @@ where } #[cfg(not(target_arch = "wasm32"))] { + let _root = crate::root_held::RootHeld::enter("class_map"); let mut guard = GLOBAL_CLASS_MAP .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); @@ -69,6 +73,91 @@ pub fn get_class_map() -> HashMap> { with_class_map(Clone::clone) } +#[derive(Default)] +struct Journal { + attempts: usize, + inserted: Vec<(String, String)>, +} + +thread_local! { + static JOURNAL: std::cell::RefCell = std::cell::RefCell::new(Journal::default()); +} + +/// Remember a name handed out while an [`Attempt`] runs, so dropping the +/// attempt gives the slot back. +pub(crate) fn record_insert(filename_key: &str, key: &str) { + let _admission = crate::admission::enter(); + JOURNAL.with(|journal| { + let _root = crate::root_held::RootHeld::enter("journal"); + let mut journal = journal.borrow_mut(); + if journal.attempts > 0 { + journal + .inserted + .push((filename_key.to_string(), key.to_string())); + } + }); +} + +/// A stretch of extraction whose names are kept only when it is committed. +/// +/// Names an attempt hands out that is then discarded (an evaluation retry or a +/// build error) would otherwise leave counter slots that a later environment +/// finds taken. +pub struct Attempt { + mark: usize, + committed: bool, +} + +impl Attempt { + #[must_use] + pub fn begin() -> Self { + let _admission = crate::admission::enter(); + JOURNAL.with(|journal| { + let _root = crate::root_held::RootHeld::enter("journal"); + let mut journal = journal.borrow_mut(); + journal.attempts += 1; + Self { + mark: journal.inserted.len(), + committed: false, + } + }) + } + + pub fn commit(mut self) { + let _admission = crate::admission::enter(); + self.committed = true; + } +} + +impl Drop for Attempt { + fn drop(&mut self) { + let _admission = crate::admission::enter(); + let undone = JOURNAL.with(|journal| { + let _root = crate::root_held::RootHeld::enter("journal"); + let mut journal = journal.borrow_mut(); + journal.attempts -= 1; + let undone = if self.committed { + Vec::new() + } else { + journal.inserted.split_off(self.mark) + }; + if journal.attempts == 0 { + journal.inserted.clear(); + } + undone + }); + if !undone.is_empty() { + with_class_map_mut(|map| { + for (file, key) in undone.iter().rev() { + if let Some(names) = map.get_mut(file) { + names.remove(key); + } + } + }); + } + } +} + #[cfg(test)] mod tests { use serial_test::serial; diff --git a/libs/css/src/content_hash.rs b/libs/css/src/content_hash.rs new file mode 100644 index 000000000..0bd3f14d8 --- /dev/null +++ b/libs/css/src/content_hash.rs @@ -0,0 +1,269 @@ +//! Fixed SHA-256 (FIPS 180-4), independent of host hashers and dependencies. + +const INITIAL: [u32; 8] = [ + 0x6a09_e667, + 0xbb67_ae85, + 0x3c6e_f372, + 0xa54f_f53a, + 0x510e_527f, + 0x9b05_688c, + 0x1f83_d9ab, + 0x5be0_cd19, +]; +const ROUND: [u32; 64] = [ + 0x428a_2f98, + 0x7137_4491, + 0xb5c0_fbcf, + 0xe9b5_dba5, + 0x3956_c25b, + 0x59f1_11f1, + 0x923f_82a4, + 0xab1c_5ed5, + 0xd807_aa98, + 0x1283_5b01, + 0x2431_85be, + 0x550c_7dc3, + 0x72be_5d74, + 0x80de_b1fe, + 0x9bdc_06a7, + 0xc19b_f174, + 0xe49b_69c1, + 0xefbe_4786, + 0x0fc1_9dc6, + 0x240c_a1cc, + 0x2de9_2c6f, + 0x4a74_84aa, + 0x5cb0_a9dc, + 0x76f9_88da, + 0x983e_5152, + 0xa831_c66d, + 0xb003_27c8, + 0xbf59_7fc7, + 0xc6e0_0bf3, + 0xd5a7_9147, + 0x06ca_6351, + 0x1429_2967, + 0x27b7_0a85, + 0x2e1b_2138, + 0x4d2c_6dfc, + 0x5338_0d13, + 0x650a_7354, + 0x766a_0abb, + 0x81c2_c92e, + 0x9272_2c85, + 0xa2bf_e8a1, + 0xa81a_664b, + 0xc24b_8b70, + 0xc76c_51a3, + 0xd192_e819, + 0xd699_0624, + 0xf40e_3585, + 0x106a_a070, + 0x19a4_c116, + 0x1e37_6c08, + 0x2748_774c, + 0x34b0_bcb5, + 0x391c_0cb3, + 0x4ed8_aa4a, + 0x5b9c_ca4f, + 0x682e_6ff3, + 0x748f_82ee, + 0x78a5_636f, + 0x84c8_7814, + 0x8cc7_0208, + 0x90be_fffa, + 0xa450_6ceb, + 0xbef9_a3f7, + 0xc671_78f2, +]; + +fn compress(state: &mut [u32; 8], block: &[u8]) { + let mut words = [0_u32; 64]; + for (word, bytes) in words.iter_mut().zip(block.as_chunks::<4>().0) { + *word = u32::from_be_bytes(*bytes); + } + for i in 16..64 { + let x = words[i - 15]; + let y = words[i - 2]; + let s0 = x.rotate_right(7) ^ x.rotate_right(18) ^ (x >> 3); + let s1 = y.rotate_right(17) ^ y.rotate_right(19) ^ (y >> 10); + words[i] = words[i - 16] + .wrapping_add(s0) + .wrapping_add(words[i - 7]) + .wrapping_add(s1); + } + let [ + mut reg_a, + mut reg_b, + mut reg_c, + mut reg_d, + mut reg_e, + mut reg_f, + mut reg_g, + mut reg_h, + ] = *state; + for (word, constant) in words.into_iter().zip(ROUND) { + let s1 = reg_e.rotate_right(6) ^ reg_e.rotate_right(11) ^ reg_e.rotate_right(25); + let choose = (reg_e & reg_f) ^ (!reg_e & reg_g); + let t1 = reg_h + .wrapping_add(s1) + .wrapping_add(choose) + .wrapping_add(constant) + .wrapping_add(word); + let s0 = reg_a.rotate_right(2) ^ reg_a.rotate_right(13) ^ reg_a.rotate_right(22); + let majority = (reg_a & reg_b) ^ (reg_a & reg_c) ^ (reg_b & reg_c); + let t2 = s0.wrapping_add(majority); + reg_h = reg_g; + reg_g = reg_f; + reg_f = reg_e; + reg_e = reg_d.wrapping_add(t1); + reg_d = reg_c; + reg_c = reg_b; + reg_b = reg_a; + reg_a = t1.wrapping_add(t2); + } + for (part, added) in state + .iter_mut() + .zip([reg_a, reg_b, reg_c, reg_d, reg_e, reg_f, reg_g, reg_h]) + { + *part = part.wrapping_add(added); + } +} + +/// SHA-256 bytes, with the standard big-endian bit length and padding. +#[must_use] +pub fn sha256(bytes: &[u8]) -> [u8; 32] { + let mut state = INITIAL; + let (chunks, tail) = bytes.as_chunks::<64>(); + for block in chunks { + compress(&mut state, block); + } + let mut padding = [0_u8; 128]; + padding[..tail.len()].copy_from_slice(tail); + padding[tail.len()] = 0x80; + let length = if tail.len() < 56 { 64 } else { 128 }; + let bit_length = u64::try_from(bytes.len()) + .unwrap_or(u64::MAX) + .wrapping_mul(8); + padding[length - 8..length].copy_from_slice(&bit_length.to_be_bytes()); + for block in padding[..length].as_chunks::<64>().0 { + compress(&mut state, block); + } + let mut result = [0_u8; 32]; + for (out, word) in result.as_chunks_mut::<4>().0.iter_mut().zip(state) { + out.copy_from_slice(&word.to_be_bytes()); + } + result +} + +/// First 80 SHA-256 bits, big-endian, in sixteen zero-padded base-37 digits. +#[must_use] +pub fn fingerprint(bytes: &[u8]) -> String { + fingerprint_with_bits(bytes, FingerprintBits::PRODUCTION) +} + +/// A nonzero prefix width bounded by the production fingerprint's 80 bits. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FingerprintBits(u8); + +impl FingerprintBits { + pub const PRODUCTION: Self = Self(80); + + #[must_use] + pub const fn new(bits: u8) -> Option { + if bits > 0 && bits <= 80 { + Some(Self(bits)) + } else { + None + } + } + + #[must_use] + pub const fn digits(self) -> usize { + let maximum = (1_u128 << self.0) - 1; + let mut capacity = 37_u128; + let mut digits = 1; + while capacity <= maximum { + capacity *= 37; + digits += 1; + } + digits + } +} + +/// The same fixed digest/encoding at an explicitly supplied prefix width. +#[must_use] +pub fn fingerprint_with_bits(bytes: &[u8], bits: FingerprintBits) -> String { + let digest = sha256(bytes); + let value = digest[..10] + .iter() + .fold(0_u128, |value, byte| (value << 8) | u128::from(*byte)); + encode(value >> (80 - bits.0), bits.digits()) +} + +fn encode(mut value: u128, length: usize) -> String { + const ALPHABET: &[u8; 37] = b"abcdefghijklmnopqrstuvwxyz0123456789_"; + let mut digits = vec![b'a'; length]; + for digit in digits.iter_mut().rev() { + let index = usize::try_from(value % 37).unwrap_or_default(); + *digit = ALPHABET[index]; + value /= 37; + } + digits.into_iter().map(char::from).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn standard_sha_goldens_cover_empty_single_and_multiple_blocks() { + for (input, expected) in [ + ( + "", + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + ), + ( + "abc", + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + ), + ( + "abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq", + "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1", + ), + ( + "abcdefghbcdefghicdefghijdefghijkefghijklfghijklmghijklmnhijklmnoijklmnopjklmnopqklmnopqrlmnopqrsmnopqrstnopqrstu", + "cf5b16a778af8380036ce59e7b0492370b249b11e8f07a51afac45037afee9d1", + ), + ] { + let actual: String = sha256(input.as_bytes()) + .iter() + .flat_map(|byte| { + const HEX: &[u8; 16] = b"0123456789abcdef"; + [ + char::from(HEX[usize::from(byte >> 4)]), + char::from(HEX[usize::from(byte & 15)]), + ] + }) + .collect(); + assert_eq!(actual, expected); + } + } + + #[test] + fn eighty_bits_keep_leading_zeroes_and_big_endian_order() { + assert_eq!(encode(0, 16), "aaaaaaaaaaaaaaaa"); + assert_eq!(encode(37, 16), "aaaaaaaaaaaaaaba"); + assert_eq!(fingerprint(b"abc"), encode(0xba78_16bf_8f01_cfea_4141, 16)); + } + + #[test] + fn nist_complete_block_golden_keeps_the_first_eighty_bits() { + let input = vec![b'a'; 1_000_000]; + let digest = sha256(&input); + assert_eq!( + &digest[..10], + &[0xcd, 0xc7, 0x6e, 0x5c, 0x99, 0x14, 0xfb, 0x92, 0x81, 0xa1] + ); + } +} diff --git a/libs/css/src/content_name.rs b/libs/css/src/content_name.rs new file mode 100644 index 000000000..a465534bc --- /dev/null +++ b/libs/css/src/content_name.rs @@ -0,0 +1,231 @@ +//! Versioned semantic descriptors shared by extraction and sheet registration. + +use crate::{ + Naming, + naming::escape_into, + style_selector::{AtRuleKind, StyleSelector}, +}; + +/// The declaration identity, without runtime values or delivery/cleanup owners. +pub struct AtomContent<'a> { + pub property: &'a str, + pub value: Option<&'a str>, + pub naming: Naming, + pub level: u8, + pub order: u8, + pub selector: Option<&'a StyleSelector>, + pub layer: Option<&'a str>, + pub dynamic: bool, +} + +/// Exact versioned bytes plus their injective, escaped text representation. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ContentName { + pub descriptor: Vec, + pub lossless: String, + pub domain: char, +} + +fn count(bytes: &mut Vec, length: usize) { + bytes.extend_from_slice(&u64::try_from(length).unwrap_or(u64::MAX).to_be_bytes()); +} + +fn text(bytes: &mut Vec, value: &str) { + count(bytes, value.len()); + bytes.extend_from_slice(value.as_bytes()); +} + +fn option(bytes: &mut Vec, value: Option<&str>) { + bytes.push(u8::from(value.is_some())); + if let Some(value) = value { + text(bytes, value); + } +} + +const fn kind(kind: AtRuleKind) -> u8 { + match kind { + AtRuleKind::Media => 0, + AtRuleKind::Supports => 1, + AtRuleKind::Container => 2, + AtRuleKind::Layer => 3, + } +} + +fn selector(bytes: &mut Vec, lossless: &mut String, value: Option<&StyleSelector>) { + match value { + None => bytes.push(0), + Some(StyleSelector::Selector(value)) => { + bytes.push(1); + text(bytes, value); + lossless.push_str("-s"); + escape_into(lossless, value); + } + Some(StyleSelector::Global(value, _)) => { + bytes.push(2); + text(bytes, value); + lossless.push_str("-g"); + escape_into(lossless, value); + } + Some(StyleSelector::At { + kind: rule_kind, + query, + selector: inner, + outer, + .. + }) => { + bytes.push(3); + count(bytes, outer.len()); + lossless.push_str("-a"); + for rule in outer { + bytes.push(kind(rule.kind)); + text(bytes, &rule.query); + lossless.push(char::from(b'0' + kind(rule.kind))); + escape_into(lossless, &rule.query); + lossless.push('-'); + } + bytes.push(kind(*rule_kind)); + text(bytes, query); + option(bytes, inner.as_deref()); + lossless.push_str("e-"); + lossless.push(char::from(b'0' + kind(*rule_kind))); + escape_into(lossless, query); + if let Some(inner) = inner { + lossless.push_str("-s"); + escape_into(lossless, inner); + } + } + } +} + +impl AtomContent<'_> { + /// Fixed order: version, variant, provenance, property, value, level, order, + /// structured selector/at-rules, layer. Strings/counts use big-endian u64. + #[must_use] + pub fn content(&self) -> ContentName { + let property = self.property.trim(); + let value = self.value.map(|value| { + let normalized = crate::optimize_value::optimize_value(value); + crate::content_value::emitted(&normalized).into_owned() + }); + let mut bytes = vec![ + 1, + if self.dynamic { 2 } else { 1 }, + match self.naming { + Naming::Own => 0, + Naming::Risky => 1, + }, + ]; + text(&mut bytes, property); + option(&mut bytes, value.as_deref()); + bytes.extend_from_slice(&[self.level, self.order]); + let mut lossless = String::new(); + if self.dynamic { + lossless.push_str("d-"); + } + escape_into(&mut lossless, property); + if let Some(value) = value.as_deref() { + lossless.push_str("-v"); + escape_into(&mut lossless, value); + } + if self.level != 0 { + lossless.push_str("-l"); + crate::write_u8(&mut lossless, self.level); + } + if self.order != 255 { + lossless.push_str("-o"); + crate::write_u8(&mut lossless, self.order); + } + selector(&mut bytes, &mut lossless, self.selector); + option(&mut bytes, self.layer); + if let Some(layer) = self.layer { + lossless.push_str("-y"); + escape_into(&mut lossless, layer); + } + ContentName { + descriptor: bytes, + lossless, + domain: match self.naming { + Naming::Own => 'O', + Naming::Risky => 'R', + }, + } + } +} + +impl ContentName { + /// Exact canonical sheet key, distinct from source sites and declaration content. + #[must_use] + pub fn scope(key: &str) -> Self { + let mut lossless = String::new(); + escape_into(&mut lossless, key); + Self { + descriptor: key.as_bytes().to_vec(), + lossless, + domain: 'F', + } + } + + /// Exact normalized source bytes, in the unnumbered site's separate domain. + #[must_use] + pub fn source(source: &str) -> Self { + let mut lossless = String::new(); + escape_into(&mut lossless, source); + Self { + descriptor: source.as_bytes().to_vec(), + lossless, + domain: 'U', + } + } + + /// Keyframe step and declaration order are significant, exactly as emitted. + #[must_use] + pub fn keyframes(steps: &[(String, Vec<(String, String)>)]) -> Self { + let mut bytes = vec![1, 3]; + let mut lossless = String::new(); + count(&mut bytes, steps.len()); + for (step, properties) in steps { + text(&mut bytes, step); + count(&mut bytes, properties.len()); + lossless.push('s'); + escape_into(&mut lossless, step); + for (property, value) in properties { + text(&mut bytes, property); + text(&mut bytes, value); + lossless.push_str("-p"); + escape_into(&mut lossless, property); + lossless.push_str("-v"); + escape_into(&mut lossless, value); + } + lossless.push_str("-e-"); + } + Self { + descriptor: bytes, + lossless, + domain: 'K', + } + } + + /// Lossless wins ties; the prefix and domain are included in both lengths. + #[must_use] + pub fn name(&self, prefix: &str) -> String { + self.name_with_bits(prefix, crate::content_hash::FingerprintBits::PRODUCTION) + } + + /// Width is an input to naming, never global state or witness-dependent. + #[must_use] + pub fn name_with_bits( + &self, + prefix: &str, + bits: crate::content_hash::FingerprintBits, + ) -> String { + if self.lossless.len() <= bits.digits() { + format!("{prefix}{}L{}", self.domain, self.lossless) + } else { + format!( + "{prefix}{}H{}", + self.domain, + crate::content_hash::fingerprint_with_bits(&self.descriptor, bits) + ) + } + } +} diff --git a/libs/css/src/content_name_tests.rs b/libs/css/src/content_name_tests.rs new file mode 100644 index 000000000..dfa996fc5 --- /dev/null +++ b/libs/css/src/content_name_tests.rs @@ -0,0 +1,212 @@ +use super::content_name::{AtomContent, ContentName}; +use super::{ + Naming, + content_hash::fingerprint, + naming::private_counter, + style_selector::{AtRule, AtRuleKind, StyleSelector}, +}; +use serial_test::serial; + +fn atom(value: Option<&str>) -> AtomContent<'_> { + AtomContent { + property: "color", + value, + naming: Naming::Own, + level: 0, + order: 255, + selector: None, + layer: None, + dynamic: false, + } +} + +#[test] +fn descriptor_bytes_and_short_names_are_fixed_goldens() { + // Given + let input = atom(Some("red")); + // When + let content = input.content(); + // Then + assert_eq!( + content.descriptor, + [ + 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 5, b'c', b'o', b'l', b'o', b'r', 1, 0, 0, 0, 0, 0, 0, 0, + 3, b'r', b'e', b'd', 0, 255, 0, 0, + ] + ); + assert_eq!(content.name("du-"), "du-OLcolor-vred"); +} + +#[test] +fn names_choose_the_complete_escaped_minimum_and_lossless_on_ties() { + // Given + let descriptor = b"abc".to_vec(); + // When / Then + for length in [0, 15, 16, 17, 500] { + let content = ContentName { + descriptor: descriptor.clone(), + lossless: "x".repeat(length), + domain: 'R', + }; + let name = content.name("prefix-"); + let expected = if length <= 16 { + format!("prefix-RL{}", "x".repeat(length)) + } else { + format!("prefix-RH{}", fingerprint(b"abc")) + }; + assert_eq!(name, expected); + assert_eq!(name.len(), 9 + length.min(16)); + } +} + +#[test] +fn parameterized_width_preserves_prefix_bits_padding_and_lossless_ties() { + // Given + use crate::content_hash::{FingerprintBits, fingerprint_with_bits}; + let descriptor = b"abc".to_vec(); + // When / Then + for (width, expected) in [(1, "b"), (5, "x"), (6, "bj")] { + let bits = FingerprintBits::new(width).unwrap_or_else(|| panic!("valid width")); + assert_eq!(fingerprint_with_bits(b"abc", bits), expected); + } + for width in 1..=80 { + let bits = FingerprintBits::new(width).unwrap_or_else(|| panic!("valid width")); + for length in [bits.digits() - 1, bits.digits(), bits.digits() + 1] { + let content = ContentName { + descriptor: descriptor.clone(), + lossless: "x".repeat(length), + domain: 'R', + }; + let name = content.name_with_bits("prefix-", bits); + assert_eq!(name.len(), 9 + length.min(bits.digits())); + assert_eq!(name.starts_with("prefix-RL"), length <= bits.digits()); + } + } + assert_eq!(FingerprintBits::new(0), None); + assert_eq!(FingerprintBits::new(81), None); + assert_eq!(FingerprintBits::PRODUCTION.digits(), 16); + assert_eq!( + fingerprint_with_bits(b"abc", FingerprintBits::PRODUCTION), + fingerprint(b"abc") + ); +} + +#[test] +fn selector_structure_and_layer_are_not_flattened_or_cleanup_owned() { + // Given + let at = |file| StyleSelector::At { + kind: AtRuleKind::Media, + query: "print".into(), + selector: None, + outer: vec![AtRule { + kind: AtRuleKind::Supports, + query: "(display:grid)".into(), + }], + file, + }; + let first = at(Some("a.tsx".into())); + let second = at(Some("b.tsx".into())); + let mut input = atom(Some("red")); + input.selector = Some(&first); + // When + let a = input.content(); + input.selector = Some(&second); + let b = input.content(); + input.layer = Some("print"); + let layer = input.content(); + // Then + assert_eq!(a, b); + assert_ne!(a.descriptor, layer.descriptor); + assert_ne!(a.name(""), layer.name("")); +} + +#[test] +fn semantic_fields_and_variants_remain_separate_even_with_equal_values() { + // Given + let mut input = atom(None); + let missing = input.content(); + // When + input.value = Some(""); + let empty = input.content(); + input.naming = Naming::Risky; + let risky = input.content(); + input.dynamic = true; + let dynamic = input.content(); + // Then + let names = [ + missing.name(""), + empty.name(""), + risky.name(""), + dynamic.name(""), + ]; + assert_eq!( + names + .into_iter() + .collect::>() + .len(), + 4 + ); +} + +#[test] +fn keyframes_keep_step_and_declaration_order_in_the_descriptor() { + // Given + let mut steps = vec![( + "from".into(), + vec![ + ("opacity".into(), "0".into()), + ("color".into(), "red".into()), + ], + )]; + let first = ContentName::keyframes(&steps); + // When + steps[0].1.reverse(); + let reversed = ContentName::keyframes(&steps); + // Then + assert_ne!(first.descriptor, reversed.descriptor); + assert_ne!(first.name(""), reversed.name("")); + assert_eq!( + ContentName::keyframes(&[]).descriptor, + [1, 3, 0, 0, 0, 0, 0, 0, 0, 0] + ); + assert_eq!(ContentName::keyframes(&[]).name(""), "KL"); +} + +#[test] +#[serial] +fn only_original_d9_private_own_atoms_take_counter_slots_even_in_atom_mode() { + // Given + crate::file_map::reset_file_map(); + crate::file_map::reset_canonical_map(); + crate::class_map::reset_class_map(); + crate::atom_hoist::restore_atom_plan(None); + crate::atom_hoist::set_atom_hoist(Some(2)); + crate::file_routes::set_file_routes(std::collections::HashMap::from([( + "shared.tsx".into(), + std::collections::HashSet::from([0, 1]), + )])); + crate::file_map::seed_file_numbers(&[ + "a.tsx".into(), + "private.tsx".into(), + "shared.tsx".into(), + ]); + // When + let private = crate::sheet_to_classname_content(&atom(Some("red")), Some("private.tsx")); + let shared = crate::sheet_to_classname_content(&atom(Some("red")), Some("shared.tsx")); + let unnumbered = crate::sheet_to_classname_content(&atom(Some("red")), Some("unknown.tsx")); + // Then + assert_eq!(private, "b-a"); + assert_eq!(shared, "OLcolor-vred"); + assert_eq!(unnumbered, "FLunknown_ptsx-OLcolor-vred"); + assert_eq!( + private_counter(Some("private.tsx"), Naming::Risky, 255), + None + ); + assert_eq!(private_counter(Some("private.tsx"), Naming::Own, 0), None); + assert_eq!(private_counter(None, Naming::Own, 255), None); + crate::atom_hoist::set_atom_hoist(None); + crate::atom_hoist::restore_atom_plan(None); + crate::file_routes::reset_file_routes(); + crate::file_map::reset_file_map(); + crate::class_map::reset_class_map(); +} diff --git a/libs/css/src/content_typography.rs b/libs/css/src/content_typography.rs new file mode 100644 index 000000000..def2feb11 --- /dev/null +++ b/libs/css/src/content_typography.rs @@ -0,0 +1,77 @@ +use std::{ + collections::BTreeMap, + sync::{LazyLock, RwLock}, +}; + +pub type Declarations = Vec<(u8, String, String)>; +static PRESETS: LazyLock>> = + LazyLock::new(|| RwLock::new(BTreeMap::new())); + +pub fn set(presets: BTreeMap) { + let _admission = crate::admission::enter(); + crate::admission::assert_administration_allowed("content_typography::set"); + let _root = crate::root_held::RootHeld::enter("content_typography"); + *PRESETS + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = presets; +} + +/// The effective declarations of one conditional typography atom. +pub fn declarations(value: &str, level: u8) -> Declarations { + let _admission = crate::admission::enter(); + let (preset, yielded) = value.split_once('|').unwrap_or((value, "")); + let yielded: Vec<_> = yielded + .split(',') + .filter_map(|item| { + let (property, level) = item.split_once(':')?; + Some((property, level.parse::().ok()?)) + }) + .collect(); + let root = crate::root_held::RootHeld::enter("content_typography"); + let presets = PRESETS + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut result: Declarations = Vec::new(); + for (from, property, value) in presets.get(preset).into_iter().flatten() { + let target = level.max(*from); + match result + .iter_mut() + .find(|(level, name, _)| *level == target && name == property) + { + Some((_, _, previous)) => previous.clone_from(value), + None => result.push((target, property.clone(), value.clone())), + } + } + drop(presets); + drop(root); + result.retain(|(level, property, _)| { + !yielded + .iter() + .any(|(name, from)| *name == property.as_str() && level >= from) + }); + result +} + +pub fn identity(value: &str, level: u8) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let declarations = declarations(value, level); + let mut bytes = Vec::new(); + bytes.extend_from_slice( + &u64::try_from(declarations.len()) + .unwrap_or(u64::MAX) + .to_be_bytes(), + ); + for (level, property, value) in declarations { + bytes.push(level); + for field in [&property, &value] { + bytes.extend_from_slice(&u64::try_from(field.len()).unwrap_or(u64::MAX).to_be_bytes()); + bytes.extend_from_slice(field.as_bytes()); + } + } + let mut identity = String::from("t"); + for byte in bytes { + identity.push(char::from(HEX[usize::from(byte >> 4)])); + identity.push(char::from(HEX[usize::from(byte & 15)])); + } + identity +} diff --git a/libs/css/src/content_typography_tests.rs b/libs/css/src/content_typography_tests.rs new file mode 100644 index 000000000..b293fca9d --- /dev/null +++ b/libs/css/src/content_typography_tests.rs @@ -0,0 +1,53 @@ +use super::content_typography::{declarations, identity, set}; +use serial_test::serial; +use std::collections::BTreeMap; + +#[test] +#[serial] +fn effective_typography_merges_levels_and_yields_only_actual_declarations() { + // Given + set(BTreeMap::from([( + "body".into(), + vec![ + (0, "color".into(), "red".into()), + (1, "color".into(), "blue".into()), + (2, "font-size".into(), "16px".into()), + ], + )])); + // When + let merged = declarations("body", 1); + let yielded = declarations("body|color:1,invalid,font-size:no", 1); + // Then + assert_eq!( + merged, + vec![ + (1, "color".into(), "blue".into()), + (2, "font-size".into(), "16px".into()) + ] + ); + assert_eq!(yielded, vec![(2, "font-size".into(), "16px".into())]); + assert_eq!(declarations("missing", 0), vec![]); + set(BTreeMap::new()); +} + +#[test] +#[serial] +fn content_identity_tracks_theme_values_not_preset_labels() { + // Given + set(BTreeMap::from([ + ("body".into(), vec![(0, "color".into(), "red".into())]), + ("same".into(), vec![(0, "color".into(), "red".into())]), + ])); + let before = identity("body", 0); + let equivalent = identity("same", 0); + // When + set(BTreeMap::from([( + "body".into(), + vec![(0, "color".into(), "blue".into())], + )])); + let after = identity("body", 0); + // Then + assert_eq!(before, equivalent); + assert_ne!(before, after); + set(BTreeMap::new()); +} diff --git a/libs/css/src/content_value.rs b/libs/css/src/content_value.rs new file mode 100644 index 000000000..ab946444f --- /dev/null +++ b/libs/css/src/content_value.rs @@ -0,0 +1,30 @@ +use crate::utils::compile_regex; +use regex_lite::Regex; +use std::{borrow::Cow, sync::LazyLock}; + +static TOKEN: LazyLock = LazyLock::new(|| compile_regex(r"\$\w[\w.-]*")); + +/// The declaration value after theme references are expanded for emission. +pub fn emitted(value: &str) -> Cow<'_, str> { + if !value.contains('$') { + return Cow::Borrowed(value); + } + match TOKEN.replace_all(value, |caps: ®ex_lite::Captures| { + format!("var(--{})", caps[0][1..].replace('.', "-")) + }) { + Cow::Owned(value) => Cow::Owned(value), + Cow::Borrowed(_) => Cow::Borrowed(value), + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn effective_values_expand_only_authored_theme_tokens() { + assert_eq!(emitted("1px solid $line.100"), "1px solid var(--line-100)"); + assert_eq!(emitted("$text"), "var(--text)"); + assert_eq!(emitted("var(--text)"), "var(--text)"); + assert_eq!(emitted("$"), "$"); + } +} diff --git a/libs/css/src/counter_allocation.rs b/libs/css/src/counter_allocation.rs new file mode 100644 index 000000000..b7ba9ab87 --- /dev/null +++ b/libs/css/src/counter_allocation.rs @@ -0,0 +1,239 @@ +use std::collections::HashMap; + +use crate::{class_map, num_to_nm_base::num_to_nm_base}; + +/// A numeric slot obtained from a class-map namespace, not a content or debug name. +/// +/// Slots are namespace-local and remain subject to class-map attempt rollback. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct CounterSlot(usize); + +impl CounterSlot { + /// Return the actual numeric slot from the map lookup or insertion. + #[must_use] + pub const fn index(self) -> usize { + self.0 + } + + /// Render the existing base-37 spelling, including the ad-blocker splice. + #[must_use] + pub fn name(self) -> String { + num_to_nm_base(self.0) + } +} + +/// Borrow-probe an exact key, allocating owned key bytes only on insertion. +pub(crate) fn reserve_counter(namespace: &str, key: &str) -> CounterSlot { + class_map::with_class_map_mut(|map| { + if let Some(file_entry) = map.get_mut(namespace) { + if let Some(&num) = file_entry.get(key) { + CounterSlot(num) + } else { + let len = file_entry.len(); + file_entry.insert(key.to_string(), len); + class_map::record_insert(namespace, key); + CounterSlot(len) + } + } else { + let mut inner = HashMap::with_capacity(1); + inner.insert(key.to_string(), 0); + map.insert(namespace.to_string(), inner); + class_map::record_insert(namespace, key); + CounterSlot(0) + } + }) +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + use serial_test::serial; + + use super::*; + use crate::class_map::{Attempt, get_class_map, reset_class_map, set_class_map}; + + #[test] + #[serial] + fn reuses_slot_when_key_repeats() { + // Given: a key already allocated in the real map. + reset_class_map(); + let first = reserve_counter("D9-0", " exact-키 "); + let before = get_class_map(); + // When: the exact key is requested again. + let repeated = reserve_counter("D9-0", " exact-키 "); + // Then: neither the slot nor the map changes. + assert_eq!(repeated, first); + assert_eq!(get_class_map(), before); + reset_class_map(); + } + + #[test] + #[serial] + fn allocates_next_slot_when_key_is_distinct() { + // Given: one allocation, with whitespace significant to key identity. + reset_class_map(); + reserve_counter("D9-0", "key"); + // When: a distinct key is requested in the existing namespace. + let next = reserve_counter("D9-0", " key "); + // Then: the slot follows the existing map length without normalization. + assert_eq!((next.index(), next.name()), (1, "b".to_string())); + assert_eq!( + get_class_map(), + HashMap::from([( + "D9-0".to_string(), + HashMap::from([("key".to_string(), 0), (" key ".to_string(), 1)]), + )]) + ); + reset_class_map(); + } + + #[test] + #[serial] + fn starts_at_zero_when_namespace_is_distinct() { + // Given: a private namespace with two keys. + reset_class_map(); + reserve_counter("D9-0", "key"); + reserve_counter("D9-0", "other"); + // When: the same key is requested in the shared namespace. + let shared = reserve_counter("", "other"); + // Then: the namespaces retain independent streams. + assert_eq!((shared.index(), shared.name()), (0, "a".to_string())); + assert_eq!(get_class_map()["D9-0"]["other"], 1); + assert_eq!(get_class_map()[""]["other"], 0); + reset_class_map(); + } + + #[test] + #[serial] + fn reuses_stored_slot_when_map_is_preseeded() { + // Given: an existing slot whose value differs from the map length. + let seeded = HashMap::from([( + "D9-7".to_string(), + HashMap::from([("seeded".to_string(), 30)]), + )]); + set_class_map(seeded.clone()); + // When: the seeded key is reused during an abandoned attempt. + let reused = { + let _attempt = Attempt::begin(); + reserve_counter("D9-7", "seeded") + }; + // Then: lookup returns the stored slot and rollback leaves it intact. + assert_eq!((reused.index(), reused.name()), (30, "a-d".to_string())); + assert_eq!(get_class_map(), seeded); + reset_class_map(); + } + + #[test] + #[serial] + fn releases_insertions_when_attempt_is_abandoned() { + // Given: one retained entry and one as-yet unseen namespace. + reset_class_map(); + reserve_counter("D9-0", "retained"); + // When: both insertion paths run inside an abandoned attempt. + { + let _attempt = Attempt::begin(); + reserve_counter("D9-0", "discarded"); + reserve_counter("D9-1", "discarded"); + } + // Then: only inserted keys are removed; their slots are available again. + assert_eq!( + get_class_map(), + HashMap::from([ + ( + "D9-0".to_string(), + HashMap::from([("retained".to_string(), 0)]) + ), + ("D9-1".to_string(), HashMap::new()), + ]) + ); + assert_eq!(reserve_counter("D9-0", "replacement").index(), 1); + assert_eq!(reserve_counter("D9-1", "replacement").index(), 0); + reset_class_map(); + } + + #[test] + #[serial] + fn rolls_back_committed_inner_when_outer_is_abandoned() { + // Given: a retained entry outside the outer attempt. + reset_class_map(); + reserve_counter("D9-0", "retained"); + // When: the inner attempt commits both insertion paths, but the outer drops. + { + let _outer = Attempt::begin(); + let inner = Attempt::begin(); + reserve_counter("D9-0", "inner"); + reserve_counter("D9-1", "inner"); + inner.commit(); + } + // Then: the inner commit did not escape the outer rollback journal. + assert_eq!( + get_class_map(), + HashMap::from([ + ( + "D9-0".to_string(), + HashMap::from([("retained".to_string(), 0)]) + ), + ("D9-1".to_string(), HashMap::new()), + ]) + ); + reset_class_map(); + } + + #[rstest] + #[case(26, "_")] + #[case(27, "aa")] + #[case(29, "ac")] + #[case(30, "a-d")] + #[case(31, "ae")] + #[serial] + fn renders_boundary_when_slot_is_inserted(#[case] count: usize, #[case] expected: &str) { + // Given: a dense namespace with exactly count existing slots. + set_class_map(HashMap::from([( + "D9-30".to_string(), + (0..count) + .map(|index| (format!("seed-{index}"), index)) + .collect(), + )])); + // When: the next slot is actually allocated, not constructed from spelling. + let slot = reserve_counter("D9-30", "boundary"); + // Then: the numeric address and existing base-37 spelling agree. + assert_eq!(slot.index(), count); + assert_eq!(slot.name(), expected); + assert_eq!(get_class_map()["D9-30"]["boundary"], count); + reset_class_map(); + } + + #[test] + #[serial] + fn preserves_file_and_slot_splices_when_owned_output_crosses_ad() { + // Given: original file 30 and thirty already allocated keys. + set_class_map(HashMap::from([( + "D9-30".to_string(), + (0..30) + .map(|index| (format!("seed-{index}"), index)) + .collect(), + )])); + let prefix = crate::get_prefix(); + crate::set_prefix(None); + let content = crate::content_name::AtomContent { + property: "color", + value: Some("red"), + level: 0, + order: 255, + naming: crate::Naming::Own, + selector: None, + layer: None, + dynamic: false, + }; + // When: the existing owned caller allocates and renders slot 30. + let name = crate::sheet_to_classname_owned( + &content, + Some("counter-boundary.tsx"), + crate::CounterOwner::D9(30), + ); + // Then: both file and slot retain their separate ad-blocker splices. + assert_eq!(name, "a-d-a-d"); + crate::set_prefix(prefix); + reset_class_map(); + } +} diff --git a/libs/css/src/counter_context_tests.rs b/libs/css/src/counter_context_tests.rs new file mode 100644 index 000000000..672a23289 --- /dev/null +++ b/libs/css/src/counter_context_tests.rs @@ -0,0 +1,200 @@ +use std::collections::{BTreeSet, HashMap}; + +use rstest::rstest; +use serial_test::serial; + +use crate::CounterOwner; +use crate::allocation_input::{ + AllocationFile, CapturedDelivery, LegacyDeclaration, LegacyInput, NameMode, capture_context, +}; +use crate::class_map::{get_class_map, reset_class_map}; +use crate::counter_names::{allocate_name, allocation_key}; +use crate::counter_test_helpers::{declaration, variable}; + +fn reset() { + crate::set_prefix(None); + crate::debug::set_debug(false); + crate::atom_hoist::set_atom_hoist(None); + crate::atom_hoist::restore_atom_plan(None); + crate::file_map::reset_canonical_map(); + crate::file_map::reset_file_map(); + reset_class_map(); +} + +#[rstest] +#[case(CounterOwner::Inactive)] +#[case(CounterOwner::Unnumbered)] +#[serial] +fn captures_legacy_ordinal_when_low_level_filename_is_supplied(#[case] owner: CounterOwner) { + // Given: delivery numbering already contains one unrelated file. + reset(); + let _first = crate::file_map::get_file_num_by_filename("first"); + crate::set_prefix(Some("app-".to_string())); + // When: the dormant low-level producer captures and allocates two requests for a new filename. + let first = capture_context(&declaration(None), Some("second"), owner); + let repeated = capture_context(&declaration(None), Some("second"), owner); + let allocated = allocate_name(&declaration(None), &first); + // Then: captured ordinal is reused, and no canonical/original authority is fabricated. + assert_eq!(first, repeated); + assert_eq!( + first.file, + Some(AllocationFile::Legacy { + filename: "second".to_string(), + ordinal: 1 + }) + ); + assert_eq!(allocated.name, "app-b-a"); + assert_eq!(crate::file_map::get_file_map().len(), 2); + assert_eq!(crate::file_map::get_original_ids().len(), 0); + reset(); +} + +#[test] +#[serial] +fn captures_numeric_owner_when_canonical_delivery_has_a_different_number() { + // Given: two original IDs collapse into delivery root number zero. + reset(); + crate::file_map::set_canonical_map(HashMap::from([("child".to_string(), "root".to_string())])); + crate::file_map::seed_file_numbers(&["child".to_string(), "root".to_string()]); + let before = crate::file_map::get_file_map(); + // When: an explicit original owner is captured for delivery root. + let ctx = capture_context(&declaration(None), Some("root"), CounterOwner::D9(30)); + let allocated = allocate_name(&declaration(None), &ctx); + // Then: neither delivery ordinal nor canonical root chooses the private namespace. + assert_eq!(ctx.file, Some(AllocationFile::Original(30))); + assert_eq!(allocated.name, "a-d-a"); + assert_eq!( + allocation_key(&declaration(None), &ctx), + Some(("D9-30".to_string(), "color-0---255-a-d".to_string())) + ); + assert_eq!(crate::file_map::get_file_map(), before); + reset(); +} + +#[rstest] +#[case(variable(), "--a")] +#[case(LegacyInput::Declaration(LegacyDeclaration { + property: "color".to_string(), level: 0, value: None, selector: None, order: Some(0), +}), "a")] +#[serial] +fn avoids_file_registration_when_request_is_shared( + #[case] input: LegacyInput, + #[case] expected: &str, +) { + // Given: an unknown delivery filename and explicit private owner. + reset(); + // When: a shared/base request is captured and allocated. + let ctx = capture_context(&input, Some("unseen"), CounterOwner::D9(9)); + let allocated = allocate_name(&input, &ctx); + // Then: no delivery registration or original private namespace occurs. + assert_eq!(ctx.file, None); + assert_eq!(ctx.delivery, None); + assert_eq!(crate::file_map::get_file_map().len(), 0); + assert_eq!( + get_class_map().keys().cloned().collect::>(), + vec![String::new()] + ); + assert_eq!(allocated.name, expected); + reset(); +} + +#[rstest] +#[case(declaration(Some("red")), true)] +#[case(LegacyInput::Keyframes("123".to_string()), false)] +#[case(variable(), false)] +#[serial] +fn captures_debug_without_unneeded_file_ordinal( + #[case] input: LegacyInput, + #[case] registers: bool, +) { + // Given: debug enabled and a delivery file absent from the ordinal map. + reset(); + crate::debug::set_debug(true); + // When: the request captures its baseline mode. + let ctx = capture_context(&input, Some("a"), CounterOwner::Inactive); + let allocated = allocate_name(&input, &ctx); + // Then: only declaration debug requires a file suffix; no counter is reserved. + assert_eq!(ctx.config.mode, NameMode::Debug); + assert_eq!( + crate::file_map::get_file_map().len(), + usize::from(registers) + ); + assert_eq!(get_class_map(), HashMap::new()); + assert_eq!( + allocated.name, + match input { + LegacyInput::Declaration(_) => "color-0-red--255-a", + LegacyInput::Keyframes(_) => "k-123", + LegacyInput::Variable(_) => "--color-0-", + } + ); + reset(); +} + +#[test] +#[serial] +fn captures_atom_before_debug_when_both_are_enabled() { + // Given: atom+debug enabled with an explicitly frozen hoisted canonical bucket. + reset(); + crate::debug::set_debug(true); + crate::atom_hoist::set_atom_hoist(Some(2)); + crate::atom_hoist::restore_atom_plan(Some(BTreeSet::from(["root".to_string()]))); + crate::file_map::set_canonical_map(HashMap::from([("child".to_string(), "root".to_string())])); + // When: a declaration captures its original identity and delivery scope. + let ctx = capture_context(&declaration(None), Some("child"), CounterOwner::D9(7)); + let allocated = allocate_name(&declaration(None), &ctx); + // Then: atom baseline wins without borrowing/registering a delivery file ordinal. + assert_eq!(ctx.config.mode, NameMode::AtomHoist); + assert_eq!(ctx.file, Some(AllocationFile::Original(7))); + assert_eq!( + ctx.delivery, + Some(CapturedDelivery { + canonical: "root".to_string(), + hoisted: true + }) + ); + assert_eq!(allocated.name, "a1-h-636f6c6f72-0-n--255"); + assert_eq!(crate::file_map::get_file_map().len(), 0); + reset(); +} + +#[rstest] +#[case(declaration(None), "a1-l-61-636f6c6f72-0-n--255")] +#[case(LegacyInput::Keyframes("123".to_string()), "k1-l-61-313233")] +#[case(variable(), "--v1-636f6c6f72-0-")] +#[serial] +fn avoids_file_ordinals_when_inactive_atom_request_is_captured( + #[case] input: LegacyInput, + #[case] expected: &str, +) { + // Given: atom mode with a frozen empty plan and an unknown legacy filename. + reset(); + crate::atom_hoist::set_atom_hoist(Some(2)); + crate::atom_hoist::restore_atom_plan(Some(BTreeSet::new())); + // When: capture and allocation run for each baseline input kind. + let ctx = capture_context(&input, Some("a"), CounterOwner::Inactive); + let allocated = allocate_name(&input, &ctx); + // Then: baseline names need no invented file ordinal or counter reservation. + assert_eq!(ctx.file, None); + assert_eq!(allocated.name, expected); + assert_eq!(crate::file_map::get_file_map().len(), 0); + assert_eq!(get_class_map(), HashMap::new()); + reset(); +} + +#[rstest] +#[case(None)] +#[case(Some(String::new()))] +#[serial] +fn captures_empty_prefix_when_prefix_is_absent_or_empty(#[case] prefix: Option) { + // Given: the two legacy equivalent empty prefix configurations. + reset(); + crate::set_prefix(prefix); + // When: a shared counter request captures its config and allocates. + let ctx = capture_context(&declaration(None), None, CounterOwner::Inactive); + let allocated = allocate_name(&declaration(None), &ctx); + // Then: the captured normalized prefix produces the exact unprefixed name. + assert_eq!(ctx.config.prefix, ""); + assert_eq!(allocated.name, "a"); + reset(); +} diff --git a/libs/css/src/counter_names.rs b/libs/css/src/counter_names.rs new file mode 100644 index 000000000..7a1971df2 --- /dev/null +++ b/libs/css/src/counter_names.rs @@ -0,0 +1,112 @@ +//! Dormant #751 allocation requests and map-independent proof projections. + +use serde::{Deserialize, Serialize}; + +use crate::allocation_input::{ + AllocationContext, AllocationFile, LegacyInput, NameMode, effective_file, +}; +use crate::counter_allocation::reserve_counter; +use crate::num_to_nm_base::num_to_nm_base; + +/// A produced name with the address returned by its actual allocation request. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AllocatedName { + pub name: String, + pub address: NameAddress, +} + +/// Candidate allocation proof; neither serialization nor rendering admits a cache. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum NameAddress { + Counter { + namespace: String, + legacy_key: String, + slot: usize, + }, + Baseline { + mode: NameMode, + input: LegacyInput, + context: AllocationContext, + }, +} + +/// Request the real legacy key once, returning its actual stored numeric slot. +#[must_use] +pub fn allocate_name(input: &LegacyInput, context: &AllocationContext) -> AllocatedName { + match allocation_key(input, context) { + Some((namespace, legacy_key)) => { + let slot = reserve_counter(&namespace, &legacy_key); + AllocatedName { + name: render_name(input, context, slot.index()), + address: NameAddress::Counter { + namespace, + legacy_key, + slot: slot.index(), + }, + } + } + None => AllocatedName { + name: render_name(input, context, 0), + address: NameAddress::Baseline { + mode: context.config.mode, + input: input.clone(), + context: context.clone(), + }, + }, + } +} + +/// Reconstruct namespace/key using only captured inputs, without reserving a slot. +#[must_use] +pub fn allocation_key( + input: &LegacyInput, + context: &AllocationContext, +) -> Option<(String, String)> { + match context.config.mode { + NameMode::Debug | NameMode::AtomHoist => None, + NameMode::Counter => { + let file = effective_file(input, context); + let namespace = file.map_or_else(String::new, AllocationFile::namespace); + let key = match input { + LegacyInput::Declaration(declaration) => { + let mut key = crate::counter_render::declaration_body(declaration, false); + if let Some(file) = file { + key.push('-'); + key.push_str(&num_to_nm_base(file.ordinal())); + } + key + } + LegacyInput::Keyframes(keyframes) => format!("k-{keyframes}"), + LegacyInput::Variable(variable) => { + crate::counter_render::variable_body(variable, false) + } + }; + Some((namespace, key)) + } + } +} + +/// Render a candidate slot without accessing maps or current configuration. +/// +/// Baseline branches ignore `slot`. Admission must separately check the actual +/// namespace/key/slot and captured configuration against current-build authority. +#[must_use] +pub fn render_name(input: &LegacyInput, context: &AllocationContext, slot: usize) -> String { + match context.config.mode { + NameMode::AtomHoist => crate::counter_render::atom_name(input, context), + NameMode::Debug => crate::counter_render::debug_name(input, context), + NameMode::Counter => { + let prefix = &context.config.prefix; + let base = num_to_nm_base(slot); + match input { + LegacyInput::Variable(_) => format!("--{prefix}{base}"), + LegacyInput::Declaration(_) | LegacyInput::Keyframes(_) => { + match effective_file(input, context) { + Some(file) => format!("{prefix}{}-{base}", num_to_nm_base(file.ordinal())), + None => format!("{prefix}{base}"), + } + } + } + } + } +} diff --git a/libs/css/src/counter_names_tests.rs b/libs/css/src/counter_names_tests.rs new file mode 100644 index 000000000..5329b89b4 --- /dev/null +++ b/libs/css/src/counter_names_tests.rs @@ -0,0 +1,258 @@ +use std::collections::HashMap; + +use rstest::rstest; +use serial_test::serial; + +use crate::allocation_input::{ + AllocationFile, LegacyDeclaration, LegacyInput, LegacyVariable, NameMode, +}; +use crate::class_map::{get_class_map, reset_class_map, set_class_map}; +use crate::counter_names::{NameAddress, allocate_name, allocation_key}; +use crate::counter_test_helpers::{context, declaration, variable}; + +#[test] +#[serial] +fn preserves_ordered_751_trace_when_streams_interleave() { + // Given: shared and legacy private contexts with independent streams. + reset_class_map(); + let shared = context(NameMode::Counter, None); + let private = context( + NameMode::Counter, + Some(AllocationFile::Legacy { + filename: "b.tsx".to_string(), + ordinal: 1, + }), + ); + let base = LegacyInput::Declaration(LegacyDeclaration { + property: "color".to_string(), + level: 0, + value: Some("red".to_string()), + selector: None, + order: Some(0), + }); + let animation = LegacyInput::Keyframes("18446744073709551615".to_string()); + // When: requests alternate shared/private declarations, keyframes and variables. + let names = [ + allocate_name(&declaration(Some("red")), &shared), + allocate_name(&declaration(Some("red")), &private), + allocate_name(&animation, &shared), + allocate_name(&variable(), &private), + allocate_name(&animation, &private), + allocate_name(&base, &private), + allocate_name(&declaration(Some("blue")), &private), + allocate_name(&declaration(Some("red")), &shared), + ]; + // Then: exact 84af request arithmetic yields pinned names and map addresses. + assert_eq!( + names.map(|allocated| allocated.name), + [ + "app-a", "app-b-a", "app-b", "--app-c", "app-b-b", "app-d", "app-b-c", "app-a", + ] + ); + assert_eq!( + get_class_map(), + HashMap::from([ + ( + String::new(), + HashMap::from([ + ("color-0-red--255".to_string(), 0), + ("k-18446744073709551615".to_string(), 1), + ("color-0-".to_string(), 2), + ("color-0-red--0".to_string(), 3), + ]) + ), + ( + "b.tsx".to_string(), + HashMap::from([ + ("color-0-red--255-b".to_string(), 0), + ("k-18446744073709551615".to_string(), 1), + ("color-0-blue--255-b".to_string(), 2), + ]) + ), + ]) + ); + reset_class_map(); +} + +#[rstest] +#[case(None, "color-0---255")] +#[case(Some(""), "color-0---255")] +#[case(Some(" red; "), "color-0-red--255")] +#[case(Some("#ffffff"), "color-0-#FFF--255")] +#[case(Some("rgba(255, 0, 0, 0.5)"), "color-0-#FF000080--255")] +#[case(Some("0px"), "color-0-0--255")] +#[serial] +fn reuses_normalized_key_when_raw_input_differs(#[case] value: Option<&str>, #[case] key: &str) { + // Given: the golden normalized key already owns slot 30, not map length 1. + set_class_map(HashMap::from([( + String::new(), + HashMap::from([(key.to_string(), 30)]), + )])); + let ctx = context(NameMode::Counter, None); + // When: the raw input requests its legacy allocation. + let allocated = allocate_name(&declaration(value), &ctx); + // Then: the actual stored slot, including ad splice, is returned without another key. + assert_eq!(allocated.name, "app-a-d"); + assert_eq!( + allocated.address, + NameAddress::Counter { + namespace: String::new(), + legacy_key: key.to_string(), + slot: 30, + } + ); + assert_eq!(get_class_map()[""].len(), 1); + reset_class_map(); +} + +#[test] +#[serial] +fn preserves_selector_order_and_raw_variable_property_when_keys_are_built() { + // Given: normalized declaration dimensions and a variable with significant property whitespace. + reset_class_map(); + let ctx = context(NameMode::Counter, None); + let inputs = [ + LegacyInput::Declaration(LegacyDeclaration { + property: " color ".to_string(), + level: 10, + value: Some(" red; ".to_string()), + selector: Some(" &:hover ".to_string()), + order: Some(100), + }), + LegacyInput::Variable(LegacyVariable { + property: " color ".to_string(), + level: 10, + selector: Some(" &:hover ".to_string()), + }), + LegacyInput::Variable(LegacyVariable { + property: "color".to_string(), + level: 10, + selector: Some("&:hover".to_string()), + }), + ]; + // When: each distinct legacy request is allocated in sequence. + let names = inputs + .each_ref() + .map(|input| allocate_name(input, &ctx).name); + // Then: only declaration property and selector whitespace normalize. + assert_eq!(names, ["app-a", "--app-b", "--app-c"]); + assert_eq!( + get_class_map()[""], + HashMap::from([ + ("color-10-red-&:hover-100".to_string(), 0), + (" color -10-&:hover".to_string(), 1), + ("color-10-&:hover".to_string(), 2), + ]) + ); + reset_class_map(); +} + +#[rstest] +#[case(26, "_")] +#[case(27, "aa")] +#[case(30, "a-d")] +#[case(36, "aj")] +#[serial] +fn retains_file_and_slot_encoding_when_boundaries_are_crossed( + #[case] ordinal: usize, + #[case] encoded: &str, +) { + // Given: a dense private map and a captured file ordinal at the same boundary. + let ctx = context( + NameMode::Counter, + Some(AllocationFile::Legacy { + filename: "boundary".to_string(), + ordinal, + }), + ); + set_class_map(HashMap::from([( + "boundary".to_string(), + (0..ordinal) + .map(|index| (format!("seed-{index}"), index)) + .collect(), + )])); + // When: a new declaration receives the next actual slot. + let allocated = allocate_name(&declaration(Some("red")), &ctx); + // Then: prefix/file/slot splices remain independent, and address is numeric. + assert_eq!(allocated.name, format!("app-{encoded}-{encoded}")); + assert_eq!( + allocated.address, + NameAddress::Counter { + namespace: "boundary".to_string(), + legacy_key: format!("color-0-red--255-{encoded}"), + slot: ordinal, + } + ); + reset_class_map(); +} + +#[test] +#[serial] +fn isolates_original_owner_when_delivery_ordinal_is_supplied() { + // Given: original 30 and delivery ordinal 1, never an owner fallback. + reset_class_map(); + let original = context(NameMode::Counter, Some(AllocationFile::Original(30))); + let delivery = context( + NameMode::Counter, + Some(AllocationFile::Legacy { + filename: "root.tsx".to_string(), + ordinal: 1, + }), + ); + // When: the same declaration is requested in both allocation domains. + let names = [ + allocate_name(&declaration(None), &original), + allocate_name(&declaration(None), &delivery), + ]; + // Then: original numeric authority is separate from the supplied delivery stream. + assert_eq!(names[0].name, "app-a-d-a"); + assert_eq!(names[1].name, "app-b-a"); + assert_eq!( + allocation_key(&declaration(None), &original), + Some(("D9-30".to_string(), "color-0---255-a-d".to_string(),)) + ); + assert_eq!(get_class_map().len(), 2); + reset_class_map(); +} + +#[test] +#[serial] +fn reuses_none_empty_and_default_order_when_legacy_inputs_are_equivalent() { + // Given: a shared declaration stream and raw requests with equivalent legacy keys. + reset_class_map(); + let ctx = context(NameMode::Counter, None); + let mut explicit = LegacyDeclaration { + property: "color".to_string(), + level: 0, + value: Some(String::new()), + selector: Some(" ".to_string()), + order: Some(255), + }; + let inputs = [ + declaration(None), + LegacyInput::Declaration(explicit.clone()), + LegacyInput::Declaration({ + explicit.order = Some(1); + explicit.clone() + }), + LegacyInput::Declaration({ + explicit.selector = Some("hover".to_string()); + explicit + }), + ]; + // When: equivalent and genuinely distinct requests run in authored order. + let names = inputs + .each_ref() + .map(|input| allocate_name(input, &ctx).name); + // Then: None/empty/default reuse one slot, while actual order/selector remain key dimensions. + assert_eq!(names, ["app-a", "app-a", "app-b", "app-c"]); + assert_eq!( + get_class_map()[""], + HashMap::from([ + ("color-0---255".to_string(), 0), + ("color-0---1".to_string(), 1), + ("color-0--hover-1".to_string(), 2), + ]) + ); + reset_class_map(); +} diff --git a/libs/css/src/counter_owner.rs b/libs/css/src/counter_owner.rs new file mode 100644 index 000000000..478c9247f --- /dev/null +++ b/libs/css/src/counter_owner.rs @@ -0,0 +1,21 @@ +/// Original counter identity, independent of canonical delivery and diagnostics. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum CounterOwner { + /// No extraction context; the low-level filename contract applies. + #[default] + Inactive, + /// An unnumbered original must not borrow a canonical root's counter. + Unnumbered, + /// The original source's predeclared D9 number. + D9(u32), +} + +impl CounterOwner { + #[must_use] + pub const fn from_source(source: &crate::sparse_site::SourceFile) -> Self { + match source { + crate::sparse_site::SourceFile::D9(id) => Self::D9(*id), + crate::sparse_site::SourceFile::Unnumbered(_) => Self::Unnumbered, + } + } +} diff --git a/libs/css/src/counter_proof_tests.rs b/libs/css/src/counter_proof_tests.rs new file mode 100644 index 000000000..632f5437c --- /dev/null +++ b/libs/css/src/counter_proof_tests.rs @@ -0,0 +1,235 @@ +use std::collections::{BTreeSet, HashMap}; + +use rstest::rstest; +use serial_test::serial; + +use crate::allocation_input::{ + AllocationFile, CapturedDelivery, LegacyDeclaration, LegacyInput, LegacyVariable, NameMode, +}; +use crate::class_map::{Attempt, get_class_map, reset_class_map}; +use crate::counter_names::{NameAddress, allocate_name, allocation_key, render_name}; +use crate::counter_test_helpers::{context, declaration, variable}; + +#[rstest] +#[case(NameMode::Debug, declaration(None), "app-color-0---255-a-d")] +#[case( + NameMode::Debug, + declaration(Some("#ffffff")), + "app-color-0-_h_FFF--255-a-d" +)] +#[case(NameMode::Debug, variable(), "--app-color-0-")] +#[case(NameMode::Debug, LegacyInput::Keyframes("00123".to_string()), "app-k-00123")] +#[case(NameMode::AtomHoist, declaration(None), "app-a1-h-636f6c6f72-0-n--255")] +#[case( + NameMode::AtomHoist, + declaration(Some("")), + "app-a1-h-636f6c6f72-0-s---255" +)] +#[case( + NameMode::AtomHoist, + declaration(Some("red;")), + "app-a1-h-636f6c6f72-0-s-726564--255" +)] +#[case(NameMode::AtomHoist, variable(), "--app-v1-636f6c6f72-0-")] +#[case(NameMode::AtomHoist, LegacyInput::Keyframes("00123".to_string()), "app-k1-l-61-3030313233")] +#[serial] +fn emits_baseline_golden_when_mode_bypasses_counter( + #[case] mode: NameMode, + #[case] input: LegacyInput, + #[case] expected: &str, +) { + // Given: an original owner and hoisted delivery with distinct spelling inputs. + reset_class_map(); + let mut ctx = context(mode, Some(AllocationFile::Original(30))); + ctx.delivery = Some(CapturedDelivery { + canonical: "a".to_string(), + hoisted: true, + }); + // When: a baseline request is produced. + let allocated = allocate_name(&input, &ctx); + // Then: exact baseline bytes and full frozen proof return without a reservation. + assert_eq!(allocated.name, expected); + assert_eq!( + allocated.address, + NameAddress::Baseline { + mode, + input, + context: ctx, + } + ); + assert_eq!(get_class_map(), HashMap::new()); +} + +#[test] +#[serial] +fn keeps_raw_atom_selector_when_debug_trims_and_encodes_it() { + // Given: whitespace and punctuation in declaration/variable selectors. + reset_class_map(); + let inputs = [ + LegacyInput::Declaration(LegacyDeclaration { + property: " color ".to_string(), + level: 255, + value: Some(".8".to_string()), + selector: Some(" &:hover ".to_string()), + order: Some(10), + }), + LegacyInput::Variable(LegacyVariable { + property: " color ".to_string(), + level: 255, + selector: Some(" &:hover ".to_string()), + }), + ]; + let debug = context(NameMode::Debug, None); + let atom = context(NameMode::AtomHoist, None); + // When: both baseline modes project the same raw inputs. + let debug_names = inputs + .each_ref() + .map(|input| allocate_name(input, &debug).name); + let atom_names = inputs + .each_ref() + .map(|input| allocate_name(input, &atom).name); + // Then: 84af trims debug selectors but atom hex preserves their raw bytes. + assert_eq!( + debug_names, + [ + "app-color-255-_d_8-_a__c_hover-10", + "--app- color -255-_a__c_hover", + ] + ); + assert_eq!( + atom_names, + [ + "app-a1-g-636f6c6f72-255-s-2e38-20263a686f76657220-10", + "--app-v1-636f6c6f72-255-20263a686f76657220", + ] + ); +} + +#[test] +#[serial] +fn distinguishes_local_global_and_base_atom_scope_when_delivery_differs() { + // Given: an unhoisted local canonical bucket and an order0 declaration. + reset_class_map(); + let mut local = context(NameMode::AtomHoist, Some(AllocationFile::Original(7))); + local.delivery = Some(CapturedDelivery { + canonical: "a".to_string(), + hoisted: false, + }); + let global = context(NameMode::AtomHoist, None); + let base = LegacyInput::Declaration(LegacyDeclaration { + property: "color".to_string(), + level: 0, + value: None, + selector: None, + order: Some(0), + }); + // When: atom declarations and keyframes project their established scopes. + let names = [ + allocate_name(&declaration(None), &local).name, + allocate_name(&base, &local).name, + allocate_name(&LegacyInput::Keyframes("spin".to_string()), &global).name, + ]; + // Then: base declarations are global, while local scope uses captured canonical bytes. + assert_eq!( + names, + [ + "app-a1-l-61-636f6c6f72-0-n--255", + "app-a1-g-636f6c6f72-0-n--0", + "app-k1-g-7370696e" + ] + ); +} + +#[test] +#[serial] +fn projects_without_map_mutation_when_fresh_live_state_disagrees() { + // Given: frozen contexts whose identities do not exist in fresh live maps. + let ctx = context( + NameMode::Counter, + Some(AllocationFile::Legacy { + filename: "missing.tsx".to_string(), + ordinal: 30, + }), + ); + let atom = context(NameMode::AtomHoist, None); + let debug = context(NameMode::Debug, None); + reset_class_map(); + crate::file_map::reset_file_map(); + crate::file_map::reset_canonical_map(); + crate::set_prefix(Some("fresh-".to_string())); + crate::debug::set_debug(true); + crate::atom_hoist::set_atom_hoist(Some(3)); + crate::atom_hoist::restore_atom_plan(Some(BTreeSet::from(["fresh.tsx".to_string()]))); + let classes = get_class_map(); + let files = crate::file_map::get_file_map(); + let originals = crate::file_map::get_original_ids(); + let canonical = crate::file_map::get_canonical_map(); + let plan = crate::atom_hoist::atom_plan(); + // When: pure proof functions reconstruct keys and counter/baseline names. + let key = allocation_key(&declaration(None), &ctx); + let names = [ + render_name(&declaration(None), &ctx, 30), + render_name(&declaration(None), &debug, usize::MAX), + render_name(&declaration(None), &atom, usize::MAX), + ]; + let baseline_keys = [ + allocation_key(&variable(), &debug), + allocation_key(&variable(), &atom), + ]; + // Then: frozen bytes prevail; no map or plan changes and no slot is reserved. + assert_eq!( + key, + Some(("missing.tsx".to_string(), "color-0---255-a-d".to_string())) + ); + assert_eq!( + names, + [ + "app-a-d-a-d", + "app-color-0---255", + "app-a1-g-636f6c6f72-0-n--255" + ] + ); + assert_eq!(baseline_keys, [None, None]); + assert_eq!(get_class_map(), classes); + assert_eq!(crate::file_map::get_file_map(), files); + assert_eq!(crate::file_map::get_original_ids(), originals); + assert_eq!(crate::file_map::get_canonical_map(), canonical); + assert_eq!(crate::atom_hoist::atom_plan(), plan); + crate::set_prefix(None); + crate::debug::set_debug(false); + crate::atom_hoist::set_atom_hoist(None); + crate::atom_hoist::restore_atom_plan(None); +} + +#[test] +#[serial] +fn rolls_back_inner_committed_allocations_when_outer_attempt_drops() { + // Given: a retained shared declaration outside an attempt. + reset_class_map(); + let shared = context(NameMode::Counter, None); + let original = context(NameMode::Counter, Some(AllocationFile::Original(7))); + let _retained = allocate_name(&declaration(Some("red")), &shared); + // When: nested extraction commits shared variable/keyframe and private requests, then outer abandons. + { + let _outer = Attempt::begin(); + let _outer_name = allocate_name(&declaration(Some("blue")), &shared); + let inner = Attempt::begin(); + let _variable = allocate_name(&variable(), &shared); + let _keyframe = allocate_name(&LegacyInput::Keyframes("123".to_string()), &shared); + let _private = allocate_name(&declaration(None), &original); + let _reused = allocate_name(&declaration(Some("red")), &shared); + inner.commit(); + } + // Then: every new slot rolls back, while reuse preserves its pre-attempt slot. + assert_eq!( + get_class_map(), + HashMap::from([ + ( + String::new(), + HashMap::from([("color-0-red--255".to_string(), 0)]) + ), + ("D9-7".to_string(), HashMap::new()), + ]) + ); + reset_class_map(); +} diff --git a/libs/css/src/counter_render.rs b/libs/css/src/counter_render.rs new file mode 100644 index 000000000..7b6bdb258 --- /dev/null +++ b/libs/css/src/counter_render.rs @@ -0,0 +1,97 @@ +use std::borrow::Cow; + +use crate::allocation_input::{ + AllocationContext, LegacyDeclaration, LegacyInput, LegacyVariable, effective_file, +}; +use crate::atom_name::hex; +use crate::legacy_variable_names::encode_selector; +use crate::num_to_nm_base::num_to_nm_base; +use crate::optimize_value::optimize_value; + +pub(crate) fn declaration_body(declaration: &LegacyDeclaration, debug: bool) -> String { + let value = declaration + .value + .as_deref() + .map_or(Cow::Borrowed(""), optimize_value); + let selector = declaration.selector.as_deref().unwrap_or_default().trim(); + let (value, selector) = if debug { + ( + Cow::Owned(encode_selector(&value)), + Cow::Owned(encode_selector(selector)), + ) + } else { + (value, Cow::Borrowed(selector)) + }; + format!( + "{}-{}-{value}-{selector}-{}", + declaration.property.trim(), + declaration.level, + declaration.order.unwrap_or(255), + ) +} + +pub(crate) fn variable_body(variable: &LegacyVariable, debug: bool) -> String { + let selector = variable.selector.as_deref().unwrap_or_default().trim(); + let selector = if debug { + Cow::Owned(encode_selector(selector)) + } else { + Cow::Borrowed(selector) + }; + format!("{}-{}-{selector}", variable.property, variable.level) +} + +pub(crate) fn debug_name(input: &LegacyInput, context: &AllocationContext) -> String { + let prefix = &context.config.prefix; + match input { + LegacyInput::Declaration(declaration) => { + let body = declaration_body(declaration, true); + match effective_file(input, context) { + Some(file) => format!("{prefix}{body}-{}", num_to_nm_base(file.ordinal())), + None => format!("{prefix}{body}"), + } + } + LegacyInput::Keyframes(keyframes) => format!("{prefix}k-{keyframes}"), + LegacyInput::Variable(variable) => format!("--{prefix}{}", variable_body(variable, true)), + } +} + +pub(crate) fn atom_name(input: &LegacyInput, context: &AllocationContext) -> String { + let prefix = &context.config.prefix; + match input { + LegacyInput::Declaration(declaration) => { + let scope = if declaration.order == Some(0) { + "g".to_string() + } else { + match &context.delivery { + None => "g".to_string(), + Some(delivery) if delivery.hoisted => "h".to_string(), + Some(delivery) => format!("l-{}", hex(&delivery.canonical)), + } + }; + let value = match declaration.value.as_deref() { + None => "n".to_string(), + Some(value) => format!("s-{}", hex(&optimize_value(value))), + }; + format!( + "{prefix}a1-{scope}-{}-{}-{value}-{}-{}", + hex(declaration.property.trim()), + declaration.level, + hex(declaration.selector.as_deref().unwrap_or_default()), + declaration.order.unwrap_or(255), + ) + } + LegacyInput::Keyframes(keyframes) => { + let scope = match &context.delivery { + None => "g".to_string(), + Some(delivery) => format!("l-{}", hex(&delivery.canonical)), + }; + format!("{prefix}k1-{scope}-{}", hex(keyframes)) + } + LegacyInput::Variable(variable) => format!( + "--{prefix}v1-{}-{}-{}", + hex(variable.property.trim()), + variable.level, + hex(variable.selector.as_deref().unwrap_or_default()), + ), + } +} diff --git a/libs/css/src/counter_test_helpers.rs b/libs/css/src/counter_test_helpers.rs new file mode 100644 index 000000000..688eb9492 --- /dev/null +++ b/libs/css/src/counter_test_helpers.rs @@ -0,0 +1,33 @@ +use crate::allocation_input::{ + AllocationContext, AllocationFile, CapturedNameConfig, LegacyDeclaration, LegacyInput, + LegacyVariable, NameMode, +}; + +pub(super) fn context(mode: NameMode, file: Option) -> AllocationContext { + AllocationContext { + config: CapturedNameConfig { + prefix: "app-".to_string(), + mode, + }, + file, + delivery: None, + } +} + +pub(super) fn declaration(value: Option<&str>) -> LegacyInput { + LegacyInput::Declaration(LegacyDeclaration { + property: "color".to_string(), + level: 0, + value: value.map(str::to_string), + selector: None, + order: None, + }) +} + +pub(super) fn variable() -> LegacyInput { + LegacyInput::Variable(LegacyVariable { + property: "color".to_string(), + level: 0, + selector: None, + }) +} diff --git a/libs/css/src/debug.rs b/libs/css/src/debug.rs index 4966f0df1..8b93a4440 100644 --- a/libs/css/src/debug.rs +++ b/libs/css/src/debug.rs @@ -4,11 +4,14 @@ static DEBUG: AtomicBool = AtomicBool::new(false); #[inline(always)] pub fn set_debug(value: bool) { + let _admission = crate::admission::enter(); + crate::admission::assert_administration_allowed("set_debug"); DEBUG.store(value, Ordering::Relaxed); } #[inline(always)] pub fn is_debug() -> bool { + let _admission = crate::admission::enter(); DEBUG.load(Ordering::Relaxed) } diff --git a/libs/css/src/exact_attempt.rs b/libs/css/src/exact_attempt.rs new file mode 100644 index 000000000..e41bd1961 --- /dev/null +++ b/libs/css/src/exact_attempt.rs @@ -0,0 +1,75 @@ +//! Exact mutable CSS naming-state rollback, subordinate to CSS admission. + +use std::collections::{BTreeMap, BTreeSet, HashMap}; + +use bimap::BiHashMap; + +use crate::{ + admission, + atom_hoist::{atom_plan, restore_atom_plan_snapshot}, + class_map::{Attempt, get_class_map, set_class_map}, + file_map::{get_file_map, get_original_ids, restore_file_map_snapshot}, + sparse_site::source_ids::restore_original_ids_snapshot, +}; + +struct NamingSnapshot { + classes: HashMap>, + files: BiHashMap, + originals: BTreeMap, + plan: Option>, +} + +struct ExactAttempt { + attempt: Option, + before: Option, +} + +impl Drop for ExactAttempt { + fn drop(&mut self) { + drop(self.attempt.take()); + if let Some(before) = self.before.take() { + set_class_map(before.classes); + restore_file_map_snapshot(before.files); + restore_original_ids_snapshot(before.originals); + restore_atom_plan_snapshot(before.plan); + } + } +} + +/// Build under exclusive admission, restoring mutable CSS naming state on error/unwind. +/// +/// Full class, delivery-file, original-ID and frozen-plan snapshots are restored +/// after journal rollback, even while an enclosing exact scope remains active. +/// Nested exact attempts and standalone journal attempts remain subordinate. +/// Success commits only after `build` returns `Ok`. +/// +/// # Errors +/// Returns the callback's error unchanged. +/// # Panics +/// Propagates callback panics after journal rollback and exact map restoration. +/// Panics/traps that do not run destructors cannot be rolled back. +pub fn with_exclusive_attempt(build: impl FnOnce() -> Result) -> Result { + admission::with_admission(|| { + let before = NamingSnapshot { + classes: get_class_map(), + files: get_file_map(), + originals: get_original_ids(), + plan: atom_plan(), + }; + let _scope = admission::ExactScope::enter(); + let mut exact = ExactAttempt { + before: Some(before), + attempt: Some(Attempt::begin()), + }; + match build() { + Ok(output) => { + if let Some(attempt) = exact.attempt.take() { + attempt.commit(); + } + exact.before = None; + Ok(output) + } + Err(error) => Err(error), + } + }) +} diff --git a/libs/css/src/exact_attempt_nesting_tests.rs b/libs/css/src/exact_attempt_nesting_tests.rs new file mode 100644 index 000000000..f0b86bdbb --- /dev/null +++ b/libs/css/src/exact_attempt_nesting_tests.rs @@ -0,0 +1,251 @@ +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; + +use bimap::BiHashMap; +use rstest::rstest; +use serial_test::serial; + +use crate::{ + admission::with_admission, + atom_hoist::{atom_plan, freeze_atom_plan, restore_atom_plan, set_atom_hoist}, + class_map::{get_class_map, set_class_map, with_class_map_mut}, + counter_allocation::reserve_counter, + exact_attempt::with_exclusive_attempt, + file_map::{ + get_file_map, get_file_num_by_filename, get_or_insert_original_id, get_original_ids, + reset_canonical_map, set_file_map, set_original_ids, + }, + file_routes::{reset_file_routes, set_file_routes}, +}; + +#[derive(Clone, Debug, PartialEq, Eq)] +struct State { + classes: HashMap>, + files: BiHashMap, + originals: BTreeMap, + plan: Option>, +} + +fn snapshot() -> State { + State { + classes: get_class_map(), + files: get_file_map(), + originals: get_original_ids(), + plan: atom_plan(), + } +} + +fn fixture(seeded: bool, threshold: usize) -> State { + let state = if seeded { + State { + classes: HashMap::from([ + ("old-empty".into(), HashMap::new()), + ("seed".into(), HashMap::from([("kept".into(), 30)])), + ]), + files: BiHashMap::from_iter([("seed".into(), 42)]), + originals: BTreeMap::from([("seed".into(), 32)]), + plan: None, + } + } else { + State { + classes: HashMap::new(), + files: BiHashMap::new(), + originals: BTreeMap::new(), + plan: None, + } + }; + set_class_map(state.classes.clone()); + set_file_map(state.files.clone()); + set_original_ids(state.originals.clone()); + restore_atom_plan(None); + reset_canonical_map(); + set_file_routes(HashMap::from([("eligible".into(), HashSet::from([7]))])); + set_atom_hoist(Some(threshold)); + state +} + +fn register(stage: &str) { + reserve_counter(stage, "temporary"); + with_class_map_mut(|classes| { + classes.insert(format!("{stage}-empty"), HashMap::new()); + }); + let _ = get_file_num_by_filename(stage); + get_or_insert_original_id(stage) + .unwrap_or_else(|error| panic!("original registration for {stage} failed: {error:?}")); +} + +fn expected_registration(before: &State, stage: &str) -> State { + let mut expected = before.clone(); + expected + .classes + .insert(stage.into(), HashMap::from([("temporary".into(), 0)])); + expected + .classes + .insert(format!("{stage}-empty"), HashMap::new()); + expected.files.insert(stage.into(), before.files.len()); + expected.originals.insert( + stage.into(), + u32::try_from(before.originals.len()) + .unwrap_or_else(|error| panic!("fixture original count exceeds u32: {error}")), + ); + expected +} + +fn expected_plan(threshold: usize) -> BTreeSet { + if threshold == 1 { + BTreeSet::from(["eligible".into()]) + } else { + BTreeSet::new() + } +} + +fn cleanup() { + set_class_map(HashMap::new()); + set_file_map(BiHashMap::new()); + set_original_ids(BTreeMap::new()); + restore_atom_plan(None); + set_atom_hoist(None); + reset_file_routes(); +} + +#[rstest] +#[case(true, true, 1)] +#[case(true, false, 2)] +#[case(false, true, 2)] +#[case(false, false, 1)] +#[serial] +fn restores_all_maps_when_nested_error_precedes_outer_exit( + #[case] outer_commits: bool, + #[case] seeded: bool, + #[case] threshold: usize, +) { + // Given + with_admission(|| { + let before = fixture(seeded, threshold); + let outer = expected_registration(&before, "outer"); + assert_eq!(snapshot(), before); + // When + let result = with_exclusive_attempt(|| { + register("outer"); + assert_eq!(snapshot(), outer); + let inner: Result<(), &str> = with_exclusive_attempt(|| { + register("inner"); + freeze_atom_plan(); + let mut tentative = expected_registration(&outer, "inner"); + tentative.plan = Some(expected_plan(threshold)); + assert_eq!(snapshot(), tentative); + set_class_map(HashMap::new()); + Err("inner") + }); + assert_eq!(inner, Err("inner")); + assert_eq!(snapshot(), outer); + if outer_commits { Ok(()) } else { Err("outer") } + }); + // Then + assert_eq!(result, if outer_commits { Ok(()) } else { Err("outer") }); + assert_eq!(snapshot(), if outer_commits { outer } else { before }); + cleanup(); + }); +} + +#[rstest] +#[case(true, 1)] +#[case(false, 2)] +#[serial] +fn restores_all_maps_when_nested_unwind_is_caught_by_outer( + #[case] outer_commits: bool, + #[case] threshold: usize, +) { + // Given + with_admission(|| { + let before = fixture(true, threshold); + let outer = expected_registration(&before, "outer"); + assert_eq!(snapshot(), before); + // When + let result = with_exclusive_attempt(|| { + register("outer"); + assert_eq!(snapshot(), outer); + let panic = std::panic::catch_unwind(|| { + with_exclusive_attempt::<(), ()>(|| { + register("inner"); + freeze_atom_plan(); + let mut tentative = expected_registration(&outer, "inner"); + tentative.plan = Some(expected_plan(threshold)); + assert_eq!(snapshot(), tentative); + panic!("nested build"); + }) + }); + let Err(payload) = panic else { + panic!("nested build unexpectedly returned without panicking"); + }; + assert_eq!(payload.downcast_ref::<&str>(), Some(&"nested build")); + assert_eq!(snapshot(), outer); + if outer_commits { Ok(()) } else { Err(()) } + }); + // Then + assert_eq!(result, if outer_commits { Ok(()) } else { Err(()) }); + assert_eq!(snapshot(), if outer_commits { outer } else { before }); + cleanup(); + }); +} + +#[rstest] +#[case(1)] +#[case(2)] +#[serial] +fn restores_all_maps_when_outer_aborts_after_inner_commit(#[case] threshold: usize) { + // Given + with_admission(|| { + let before = fixture(true, threshold); + assert_eq!(snapshot(), before); + // When + let result: Result<(), ()> = with_exclusive_attempt(|| { + register("outer"); + let outer = expected_registration(&before, "outer"); + assert_eq!(snapshot(), outer); + with_exclusive_attempt::<(), ()>(|| { + register("inner"); + freeze_atom_plan(); + Ok(()) + })?; + let mut committed = expected_registration(&outer, "inner"); + committed.plan = Some(expected_plan(threshold)); + assert_eq!(snapshot(), committed); + Err(()) + }); + // Then + assert_eq!(result, Err(())); + assert_eq!(snapshot(), before); + cleanup(); + }); +} + +#[test] +#[serial] +fn restores_all_maps_when_nested_unwind_escapes_outer() { + // Given + with_admission(|| { + let before = fixture(true, 1); + assert_eq!(snapshot(), before); + // When + let panic = std::panic::catch_unwind(|| { + with_exclusive_attempt::<(), ()>(|| { + register("outer"); + with_exclusive_attempt::<(), ()>(|| { + register("inner"); + freeze_atom_plan(); + panic!("escaping nested build"); + }) + }) + }); + // Then + let Err(payload) = panic else { + panic!("escaping nested build unexpectedly returned without panicking"); + }; + assert_eq!( + payload.downcast_ref::<&str>(), + Some(&"escaping nested build") + ); + assert_eq!(snapshot(), before); + cleanup(); + }); +} diff --git a/libs/css/src/exact_attempt_tests.rs b/libs/css/src/exact_attempt_tests.rs new file mode 100644 index 000000000..d0f78cce7 --- /dev/null +++ b/libs/css/src/exact_attempt_tests.rs @@ -0,0 +1,209 @@ +use std::collections::HashMap; + +#[path = "exact_attempt_nesting_tests.rs"] +mod nesting; + +use serial_test::serial; + +use crate::{ + allocation_input::NameMode, + class_map::{Attempt, get_class_map, reset_class_map, set_class_map, with_class_map_mut}, + counter_allocation::reserve_counter, + counter_names::allocate_name, + counter_test_helpers::{context, declaration, variable}, + exact_attempt::with_exclusive_attempt, +}; + +fn seeded() -> HashMap> { + HashMap::from([ + ("old-empty".into(), HashMap::new()), + ("seed".into(), HashMap::from([("kept".into(), 30)])), + ]) +} + +#[test] +#[serial] +fn restores_exact_namespaces_and_slots_when_build_errors() { + // Given + let before = seeded(); + set_class_map(before.clone()); + // When + let result: Result<(), &str> = with_exclusive_attempt(|| { + assert_eq!(reserve_counter("seed", "kept").index(), 30); + reserve_counter("old-empty", "temporary"); + reserve_counter("new", "temporary"); + with_class_map_mut(|map| { + map.insert("new-empty".into(), HashMap::new()); + map.get_mut("seed") + .unwrap_or_else(|| panic!("seed namespace missing before slot replacement")) + .insert("kept".into(), 90); + }); + Err("output error") + }); + // Then + assert_eq!(result, Err("output error")); + assert_eq!(get_class_map(), before); + crate::admission::with_admission(|| { + crate::admission::assert_administration_allowed("after error"); + }); + reset_class_map(); +} + +#[test] +#[serial] +fn restores_exact_map_when_owner_resets_it() { + // Given + let before = seeded(); + set_class_map(before.clone()); + // When + let result: Result<(), ()> = with_exclusive_attempt(|| { + reserve_counter("seed", "temporary"); + reset_class_map(); + Err(()) + }); + // Then + assert_eq!(result, Err(())); + assert_eq!(get_class_map(), before); + reset_class_map(); +} + +#[test] +#[serial] +fn restores_snapshot_after_journal_drop_when_owner_replaces_it() { + // Given: the replacement collides with an outer receipt and a retained key. + let before = seeded(); + set_class_map(before.clone()); + // When + let result: Result<(), ()> = with_exclusive_attempt(|| { + with_class_map_mut(|map| { + map.get_mut("seed") + .unwrap_or_else(|| panic!("seed namespace missing before key removal")) + .remove("kept"); + }); + reserve_counter("seed", "kept"); + set_class_map(HashMap::from([( + "seed".into(), + HashMap::from([("kept".into(), 73)]), + )])); + Err(()) + }); + // Then + assert_eq!(result, Err(())); + assert_eq!(get_class_map(), before); + reset_class_map(); +} + +#[test] +#[serial] +fn reverses_both_no_site_requests_when_inner_commits_and_outer_aborts() { + // Given + let before = seeded(); + set_class_map(before.clone()); + let captured = context(NameMode::Counter, None); + // When + let result: Result<(), ()> = with_exclusive_attempt(|| { + let inner = Attempt::begin(); + let class = allocate_name(&declaration(Some("var(--app-b)")), &captured); + let variable = allocate_name(&variable(), &captured); + assert_eq!( + (class.name.as_str(), variable.name.as_str()), + ("app-a", "--app-b") + ); + inner.commit(); + Err(()) + }); + // Then + assert_eq!(result, Err(())); + assert_eq!(get_class_map(), before); + reset_class_map(); +} + +#[test] +#[serial] +fn restores_exact_map_when_prepared_work_unwinds() { + // Given + let before = seeded(); + set_class_map(before.clone()); + // When + let result = std::panic::catch_unwind(|| { + with_exclusive_attempt::<(), ()>(|| { + let _prepared = Attempt::begin(); + reserve_counter("new", "prepared"); + with_class_map_mut(|map| { + map.clear(); + }); + panic!("prepared output panicked") + }) + }); + // Then + assert!(result.is_err()); + assert_eq!(get_class_map(), before); + crate::admission::with_admission(|| { + crate::admission::assert_administration_allowed("after unwind"); + }); + reset_class_map(); +} + +#[test] +#[serial] +fn commits_only_successful_retry_when_prepared_attempt_drops() { + // Given + set_class_map(seeded()); + // When + let result = with_exclusive_attempt::<_, ()>(|| { + { + let _prepared = Attempt::begin(); + reserve_counter("retry", "discarded"); + } + with_exclusive_attempt::<_, ()>(|| Ok(reserve_counter("retry", "kept").index())) + }); + // Then + assert_eq!(result, Ok(0)); + let mut expected = seeded(); + expected.insert("retry".into(), HashMap::from([("kept".into(), 0)])); + assert_eq!(get_class_map(), expected); + reset_class_map(); +} + +#[test] +#[serial] +fn reverses_nested_exact_commit_when_outer_errors() { + // Given + let before = seeded(); + set_class_map(before.clone()); + // When + let result: Result<(), ()> = with_exclusive_attempt(|| { + with_exclusive_attempt::<_, ()>(|| Ok(reserve_counter("inner", "committed")))?; + Err(()) + }); + // Then + assert_eq!(result, Err(())); + assert_eq!(get_class_map(), before); + reset_class_map(); +} + +#[test] +#[serial] +fn retains_outer_work_when_nested_exact_attempt_errors() { + // Given + set_class_map(seeded()); + // When + let result = with_exclusive_attempt::<_, ()>(|| { + reserve_counter("outer", "retained"); + let inner: Result<(), ()> = with_exclusive_attempt(|| { + reset_class_map(); + Err(()) + }); + assert_eq!(inner, Err(())); + Ok(reserve_counter("outer", "next").index()) + }); + // Then + assert_eq!(result, Ok(1)); + let mut expected = seeded(); + expected.insert( + "outer".into(), + HashMap::from([("retained".into(), 0), ("next".into(), 1)]), + ); + assert_eq!(get_class_map(), expected); + reset_class_map(); +} diff --git a/libs/css/src/file_map.rs b/libs/css/src/file_map.rs index 2a8c86a94..9849eea05 100644 --- a/libs/css/src/file_map.rs +++ b/libs/css/src/file_map.rs @@ -23,6 +23,7 @@ pub fn with_file_map(f: F) -> R where F: FnOnce(&BiHashMap) -> R, { + let _admission = crate::admission::enter(); #[cfg(target_arch = "wasm32")] #[cfg(not(tarpaulin_include))] { @@ -30,6 +31,7 @@ where } #[cfg(not(target_arch = "wasm32"))] { + let _root = crate::root_held::RootHeld::enter("file_map"); let guard = GLOBAL_FILE_MAP .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); @@ -42,6 +44,7 @@ fn with_file_map_mut(f: F) -> R where F: FnOnce(&mut BiHashMap) -> R, { + let _admission = crate::admission::enter(); #[cfg(target_arch = "wasm32")] #[cfg(not(tarpaulin_include))] { @@ -49,6 +52,7 @@ where } #[cfg(not(target_arch = "wasm32"))] { + let _root = crate::root_held::RootHeld::enter("file_map"); let mut guard = GLOBAL_FILE_MAP .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); @@ -56,12 +60,21 @@ where } } +pub use crate::sparse_site::source_ids::{ + get_or_insert_original_id, get_original_ids, original_id, set_original_ids, +}; + /// for test pub fn reset_file_map() { + let _admission = crate::admission::enter(); + crate::admission::assert_administration_allowed("reset_file_map"); with_file_map_mut(BiHashMap::clear); + set_original_ids(std::collections::BTreeMap::new()); } pub fn set_file_map(new_map: BiHashMap) { + let _admission = crate::admission::enter(); + crate::admission::assert_administration_allowed("set_file_map"); with_file_map_mut(|map| *map = new_map); } @@ -69,6 +82,11 @@ pub fn get_file_map() -> BiHashMap { with_file_map(Clone::clone) } +/// Restore only the delivery snapshot owned by a private exact attempt. +pub(crate) fn restore_file_map_snapshot(snapshot: BiHashMap) { + with_file_map_mut(|map| *map = snapshot); +} + #[inline] #[must_use] pub fn get_file_num_by_filename(filename: &str) -> usize { @@ -93,6 +111,22 @@ pub fn get_file_num_by_filename(filename: &str) -> usize { }) } +/// Give every file in `files` a number now, in path order. +/// +/// Numbers files already hold are kept, and a file collapsed into a bucket is numbered as the bucket. The numbers files get then depend on the paths alone, +/// not on which file a worker reaches first; files that appear later (in +/// development) are numbered after the existing ones. +pub fn seed_file_numbers(files: &[String]) { + let _admission = crate::admission::enter(); + crate::admission::assert_administration_allowed("seed_file_numbers"); + crate::sparse_site::source_ids::seed_original_ids(files); + let mut sorted: Vec = files.iter().map(|file| canonical(file)).collect(); + sorted.sort_unstable(); + sorted.dedup(); + for file in sorted { + let _ = get_file_num_by_filename(&file); + } +} #[must_use] pub fn get_filename_by_file_num(file_num: usize) -> String { with_file_map(|map| { @@ -121,6 +155,7 @@ pub fn with_canonical_map(f: F) -> R where F: FnOnce(&std::collections::HashMap) -> R, { + let _admission = crate::admission::enter(); #[cfg(target_arch = "wasm32")] #[cfg(not(tarpaulin_include))] { @@ -128,6 +163,7 @@ where } #[cfg(not(target_arch = "wasm32"))] { + let _root = crate::root_held::RootHeld::enter("canonical_map"); let guard = GLOBAL_CANONICAL_MAP .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); @@ -140,6 +176,7 @@ fn with_canonical_map_mut(f: F) -> R where F: FnOnce(&mut std::collections::HashMap) -> R, { + let _admission = crate::admission::enter(); #[cfg(target_arch = "wasm32")] #[cfg(not(tarpaulin_include))] { @@ -147,6 +184,7 @@ where } #[cfg(not(target_arch = "wasm32"))] { + let _root = crate::root_held::RootHeld::enter("canonical_map"); let mut guard = GLOBAL_CANONICAL_MAP .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); @@ -156,10 +194,16 @@ where /// for test pub fn reset_canonical_map() { + let _admission = crate::admission::enter(); + crate::admission::assert_administration_allowed("reset_canonical_map"); with_canonical_map_mut(std::collections::HashMap::clear); + crate::naming::set_collapsed_buckets(&std::collections::HashMap::new()); } pub fn set_canonical_map(new_map: std::collections::HashMap) { + let _admission = crate::admission::enter(); + crate::admission::assert_administration_allowed("set_canonical_map"); + crate::naming::set_collapsed_buckets(&new_map); with_canonical_map_mut(|map| *map = new_map); } @@ -196,6 +240,47 @@ mod tests { use super::*; + #[test] + #[serial] + fn test_seed_file_numbers_numbers_buckets_not_their_members() { + reset_file_map(); + set_canonical_map(std::collections::HashMap::from([( + "x.tsx".to_string(), + "a.tsx".to_string(), + )])); + seed_file_numbers(&[ + "x.tsx".to_string(), + "b.tsx".to_string(), + "a.tsx".to_string(), + ]); + assert_eq!(get_file_num_by_filename("a.tsx"), 0); + assert_eq!(get_file_num_by_filename("b.tsx"), 1); + assert_eq!(get_file_map().len(), 2); + reset_canonical_map(); + reset_file_map(); + } + + #[test] + #[serial] + fn test_seed_file_numbers_is_independent_of_order() { + reset_file_map(); + seed_file_numbers(&[ + "b.tsx".to_string(), + "a.tsx".to_string(), + "b.tsx".to_string(), + ]); + assert_eq!(get_file_num_by_filename("a.tsx"), 0); + assert_eq!(get_file_num_by_filename("b.tsx"), 1); + seed_file_numbers(&[ + "c.tsx".to_string(), + "0.tsx".to_string(), + "a.tsx".to_string(), + ]); + assert_eq!(get_file_num_by_filename("a.tsx"), 0); + assert_eq!(get_file_num_by_filename("0.tsx"), 2); + assert_eq!(get_file_num_by_filename("c.tsx"), 3); + reset_file_map(); + } #[test] #[serial] fn test_set_and_get_file_map() { diff --git a/libs/css/src/file_routes.rs b/libs/css/src/file_routes.rs index 341660f3a..089054955 100644 --- a/libs/css/src/file_routes.rs +++ b/libs/css/src/file_routes.rs @@ -27,6 +27,7 @@ pub fn with_file_routes(f: F) -> R where F: FnOnce(&HashMap>) -> R, { + let _admission = crate::admission::enter(); #[cfg(target_arch = "wasm32")] #[cfg(not(tarpaulin_include))] { @@ -34,6 +35,7 @@ where } #[cfg(not(target_arch = "wasm32"))] { + let _root = crate::root_held::RootHeld::enter("file_routes"); let guard = GLOBAL_FILE_ROUTES .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); @@ -46,6 +48,7 @@ fn with_file_routes_mut(f: F) -> R where F: FnOnce(&mut HashMap>) -> R, { + let _admission = crate::admission::enter(); #[cfg(target_arch = "wasm32")] #[cfg(not(tarpaulin_include))] { @@ -53,6 +56,7 @@ where } #[cfg(not(target_arch = "wasm32"))] { + let _root = crate::root_held::RootHeld::enter("file_routes"); let mut guard = GLOBAL_FILE_ROUTES .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); @@ -62,10 +66,14 @@ where /// for test pub fn reset_file_routes() { + let _admission = crate::admission::enter(); + crate::admission::assert_administration_allowed("reset_file_routes"); with_file_routes_mut(HashMap::clear); } pub fn set_file_routes(new_map: HashMap>) { + let _admission = crate::admission::enter(); + crate::admission::assert_administration_allowed("set_file_routes"); with_file_routes_mut(|map| *map = new_map); } @@ -135,6 +143,27 @@ mod tests { reset_file_routes(); } + #[test] + #[serial] + fn route_count_includes_remaining_sets_when_three_registered_files_overlap() { + // Given + set_file_routes(HashMap::from([ + ("a.tsx".to_string(), HashSet::from([0, 1])), + ("b.tsx".to_string(), HashSet::from([1, 2])), + ("c.tsx".to_string(), HashSet::from([2, 3])), + ])); + + // When / Then + for files in [ + ["a.tsx", "b.tsx", "c.tsx", "unknown.tsx"], + ["unknown.tsx", "c.tsx", "b.tsx", "a.tsx"], + ] { + assert_eq!(route_count_for_files(files), 4); + } + + reset_file_routes(); + } + #[test] #[serial] fn test_route_count_for_files_without_registered_routes() { diff --git a/libs/css/src/legacy_variable_names.rs b/libs/css/src/legacy_variable_names.rs new file mode 100644 index 000000000..91d905d64 --- /dev/null +++ b/libs/css/src/legacy_variable_names.rs @@ -0,0 +1,59 @@ +use std::fmt::Write; + +pub(crate) fn encode_selector(selector: &str) -> String { + let mut result = String::with_capacity(selector.len() + 8); + for character in selector.chars() { + let encoded = match character { + '&' => "_a_", + ':' => "_c_", + '(' => "_lp_", + ')' => "_rp_", + '[' => "_lb_", + ']' => "_rb_", + '=' => "_eq_", + '>' => "_gt_", + '<' => "_lt_", + '~' => "_tl_", + '+' => "_pl_", + ' ' => "_s_", + '*' => "_st_", + '.' => "_d_", + '#' => "_h_", + ',' => "_cm_", + '"' => "_dq_", + '\'' => "_sq_", + '/' => "_sl_", + '\\' => "_bs_", + '%' => "_pc_", + '^' => "_cr_", + '$' => "_dl_", + '|' => "_pp_", + '@' => "_at_", + '!' => "_ex_", + '?' => "_qm_", + ';' => "_sc_", + '{' => "_lc_", + '}' => "_rc_", + character if character.is_ascii_alphanumeric() || matches!(character, '-' | '_') => { + result.push(character); + continue; + } + character => { + let _ = write!(result, "_u{:04x}_", u32::from(character)); + continue; + } + }; + result.push_str(encoded); + } + result +} + +pub(crate) fn write_u8(output: &mut String, value: u8) { + if value >= 100 { + output.push(char::from(b'0' + value / 100)); + output.push(char::from(b'0' + (value / 10) % 10)); + } else if value >= 10 { + output.push(char::from(b'0' + value / 10)); + } + output.push(char::from(b'0' + value % 10)); +} diff --git a/libs/css/src/lib.rs b/libs/css/src/lib.rs index 576ba53b9..22b9ae5f8 100644 --- a/libs/css/src/lib.rs +++ b/libs/css/src/lib.rs @@ -1,19 +1,66 @@ +pub mod admission; +#[cfg(test)] +mod admission_guard_tests; +#[cfg(all(test, not(target_arch = "wasm32")))] +mod admission_input_tests; +#[cfg(all(test, not(target_arch = "wasm32")))] +mod admission_interleaving_tests; +#[cfg(all(test, not(target_arch = "wasm32")))] +mod admission_root_tests; +pub mod allocation_input; pub mod at_rule; pub mod atom_hoist; +pub mod atom_name; pub mod class_map; mod constant; +pub mod content_hash; +pub mod content_name; +#[cfg(test)] +mod content_name_tests; +pub mod content_typography; +#[cfg(test)] +mod content_typography_tests; +pub mod content_value; +mod counter_allocation; +#[cfg(test)] +mod counter_context_tests; +pub mod counter_names; +#[cfg(test)] +mod counter_names_tests; +mod counter_owner; +#[cfg(test)] +mod counter_proof_tests; +mod counter_render; +#[cfg(test)] +mod counter_test_helpers; pub mod debug; +pub mod exact_attempt; +#[cfg(test)] +mod exact_attempt_tests; pub mod file_map; pub mod file_routes; pub mod is_special_property; +mod legacy_variable_names; +pub mod naming; +#[cfg(test)] +mod naming_coverage_tests; +pub mod naming_root; +pub mod naming_scope; mod num_to_nm_base; pub mod optimize_multi_css_value; pub mod optimize_value; pub mod rm_css_comment; +mod root_held; +#[cfg(test)] +mod scoped_name_tests; mod selector_separator; +pub mod sparse_site; +pub mod style_origin; pub mod style_selector; pub mod theme_tokens; pub mod utils; +use legacy_variable_names::encode_selector; +use legacy_variable_names::write_u8; use std::borrow::Cow; use std::collections::BTreeMap; @@ -21,10 +68,13 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{LazyLock, RwLock}; use crate::constant::{GLOBAL_ENUM_STYLE_PROPERTY, GLOBAL_STYLE_PROPERTY}; +pub use crate::counter_allocation::CounterSlot; +pub use crate::counter_owner::CounterOwner; use crate::debug::is_debug; -use crate::file_map::get_file_num_by_filename; + +pub use crate::naming::{Naming, Site}; use crate::num_to_nm_base::num_to_nm_base; -use crate::optimize_value::optimize_value; +pub use crate::sparse_site::{sheet_to_variable_name, sheet_to_variable_name_at}; use crate::style_selector::StyleSelector; use crate::utils::to_kebab_case; @@ -35,14 +85,18 @@ mod prefix_state { static GLOBAL_PREFIX: RefCell> = const { RefCell::new(None) }; } pub fn set_prefix(prefix: Option) { + let _admission = crate::admission::enter(); + crate::admission::assert_administration_allowed("set_prefix"); GLOBAL_PREFIX.with(|p| *p.borrow_mut() = prefix); } pub fn get_prefix() -> Option { + let _admission = crate::admission::enter(); GLOBAL_PREFIX.with(|p| p.borrow().clone()) } /// Run `f` with the current prefix as `&str` (empty when unset) without cloning. #[cfg(not(tarpaulin_include))] pub(crate) fn with_prefix(f: impl FnOnce(&str) -> R) -> R { + let _admission = crate::admission::enter(); GLOBAL_PREFIX.with(|p| f(p.borrow().as_deref().unwrap_or_default())) } } @@ -53,11 +107,16 @@ mod prefix_state { use std::sync::Mutex; static GLOBAL_PREFIX: LazyLock>> = LazyLock::new(|| Mutex::new(None)); pub fn set_prefix(prefix: Option) { + let _admission = crate::admission::enter(); + crate::admission::assert_administration_allowed("set_prefix"); + let _root = crate::root_held::RootHeld::enter("prefix"); *GLOBAL_PREFIX .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) = prefix; } pub fn get_prefix() -> Option { + let _admission = crate::admission::enter(); + let _root = crate::root_held::RootHeld::enter("prefix"); GLOBAL_PREFIX .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) @@ -65,6 +124,8 @@ mod prefix_state { } /// Run `f` with the current prefix as `&str` (empty when unset) without cloning. pub(crate) fn with_prefix(f: impl FnOnce(&str) -> R) -> R { + let _admission = crate::admission::enter(); + let _root = crate::root_held::RootHeld::enter("prefix"); f(GLOBAL_PREFIX .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) @@ -130,7 +191,7 @@ pub fn merge_selector(class_name: &str, selector: Option<&StyleSelector>) -> Str let sel = selector.map(StyleSelector::as_class_str); // `extra_amps` is `(&-count - 1)`, nonzero ONLY for the rare multi-`&` selector. // Locate the FIRST `&` with a `memchr`-backed `find` (bailing to 0 for the common - // zero/one-`&` selectors — `.a`, `.a:hover`, `theme-dark` — without spinning up the + // zero/one-`&` selectors ??`.a`, `.a:hover`, `theme-dark` ??without spinning up the // `filter(...).count()` iterator over the whole string), then count only the `&`s in // the tail AFTER it. That tail count already excludes the first `&`, so it equals // `count - 1` directly, dropping the `saturating_sub`. Byte-identical capacity. @@ -187,6 +248,7 @@ impl ExactSizeIterator for DisassembleProperty {} #[must_use] pub fn disassemble_property(property: &str) -> DisassembleProperty { + let _admission = crate::admission::enter(); // Nested selector keys (`&:hover`, `:focus`, `.parent &`) are not properties; // keep them verbatim so class names and case survive. if property.starts_with(':') || property.contains('&') { @@ -195,6 +257,7 @@ pub fn disassemble_property(property: &str) -> DisassembleProperty { if let Some(properties) = HAS_CUSTOM_SHORTHANDS .load(Ordering::Relaxed) .then(|| { + let _root = crate::root_held::RootHeld::enter("shorthands"); CUSTOM_SHORTHANDS .read() .ok() @@ -225,7 +288,7 @@ pub fn disassemble_property(property: &str) -> DisassembleProperty { // Build `-` directly into ONE buffer instead of allocating // a `to_kebab_case(property)` String and copying it into a second // presized buffer. This inlines `to_kebab_case`'s exact conversion - // (ASCII-uppercase char → `-` before it when not first, then its + // (ASCII-uppercase char ??`-` before it when not first, then its // lowercase; other chars copied verbatim) after the leading `-`. // The `i != 0` guard matches `to_kebab_case`, so the vendor // prefix's uppercase first char (`W`/`M`/`m`→lowercase) gets no @@ -258,6 +321,9 @@ static HAS_CUSTOM_SHORTHANDS: AtomicBool = AtomicBool::new(false); /// Replace the custom shorthand registry used by style extraction. pub fn set_custom_shorthands(shorthands: BTreeMap>) { + let _admission = crate::admission::enter(); + crate::admission::assert_administration_allowed("set_custom_shorthands"); + let _root = crate::root_held::RootHeld::enter("shorthands"); if let Ok(mut registry) = CUSTOM_SHORTHANDS.write() { let shorthands: BTreeMap> = shorthands .into_iter() @@ -281,6 +347,8 @@ pub fn set_custom_shorthands(shorthands: BTreeMap>) { #[must_use] pub fn get_custom_shorthand_names() -> Vec { + let _admission = crate::admission::enter(); + let _root = crate::root_held::RootHeld::enter("shorthands"); CUSTOM_SHORTHANDS.read().map_or_else( |_| Vec::new(), |registry| registry.keys().cloned().collect(), @@ -323,7 +391,7 @@ pub fn is_enum_property(property: &str) -> bool { /// Borrow the phf expansion map for `(property, value)` without materializing a `Vec`. /// /// The caller iterates `.entries()`/reads `.len()` directly off the static map, so the -/// enum-property extract hot path (every `display`/`alignItems`/… prop per breakpoint +/// enum-property extract hot path (every `display`/`alignItems`/??prop per breakpoint /// level) no longer allocates a throwaway `Vec` per call. #[must_use] pub fn get_enum_property_value( @@ -347,144 +415,56 @@ pub fn get_enum_property_map(property: &str) -> Option = const { std::cell::RefCell::new(String::new()) }; } -/// Get-or-insert a key in the per-file class map and return its base-37 name. +/// Get-or-insert a key in the per-file class map and return its numeric slot. /// `build_key` fills the supplied reusable buffer with the key bytes; the buffer /// is borrowed for the probe so the common already-present path allocates /// nothing, and only a real insert clones the key into an owned `String`. /// Single home for the class naming algorithm shared by keyframes, classname /// and variable-name generation. -fn class_num_for_key(filename_key: &str, build_key: impl FnOnce(&mut String)) -> String { +fn class_slot_for_key(filename_key: &str, build_key: impl FnOnce(&mut String)) -> CounterSlot { + let _admission = crate::admission::enter(); KEY_BUF.with(|buf| { + let _root = crate::root_held::RootHeld::enter("key_buf"); let mut key = buf.borrow_mut(); key.clear(); build_key(&mut key); - class_map::with_class_map_mut(|map| { - // Probe first so the owned filename key is only allocated on the - // first style for a file, not on every generated name. - if let Some(file_entry) = map.get_mut(filename_key) { - // Borrow-probe the common already-present-key path so the owned - // `String` is only materialized on a genuine insert, never on - // the hot repeat-property path. - if let Some(&num) = file_entry.get(key.as_str()) { - num_to_nm_base(num) - } else { - let len = file_entry.len(); - file_entry.insert(key.clone(), len); - num_to_nm_base(len) - } - } else { - // First style seen for this file: build the inner map presized to - // exactly the one entry we insert, so the initial insert never starts - // from a zero-capacity map (which would rehash/grow on the first few - // inserts). Output/behavior is byte-identical — same single entry, - // same `0` numbering — this only fixes the allocation shape. - let mut inner = std::collections::HashMap::with_capacity(1); - inner.insert(key.clone(), 0); - map.insert(filename_key.to_string(), inner); - num_to_nm_base(0) - } - }) + counter_allocation::reserve_counter(filename_key, key.as_str()) }) } +/// Render the existing name from the slot returned by the single allocation request. +fn class_num_for_key(filename_key: &str, build_key: impl FnOnce(&mut String)) -> String { + class_slot_for_key(filename_key, build_key).name() +} + +/// An animation's name from its escaped content: equal animations share one +/// name wherever they are met. #[must_use] -pub fn keyframes_to_keyframes_name(keyframes: &str, filename: Option<&str>) -> String { - with_prefix(|prefix| { - if is_debug() { - let mut result = String::with_capacity(prefix.len() + 2 + keyframes.len()); - result.push_str(prefix); - result.push_str("k-"); - result.push_str(keyframes); - result - } else { - let filename_key = filename.unwrap_or_default(); - let class_num = class_num_for_key(filename_key, |key| { - key.reserve(2 + keyframes.len()); - key.push_str("k-"); - key.push_str(keyframes); - }); - if let Some(fname) = filename { - let file_num = num_to_nm_base(get_file_num_by_filename(fname)); - let mut result = - String::with_capacity(prefix.len() + file_num.len() + 1 + class_num.len()); - result.push_str(prefix); - result.push_str(&file_num); - result.push('-'); - result.push_str(&class_num); - result - } else { - let mut result = String::with_capacity(prefix.len() + class_num.len()); - result.push_str(prefix); - result.push_str(&class_num); - result - } - } - }) +pub fn keyframes_name_of_escaped(escaped: &str) -> String { + let _admission = crate::admission::enter(); + with_prefix(|prefix| format!("{prefix}{}{escaped}", if is_debug() { "k-" } else { "K" })) } -/// ASCII lookup table for selector encoding. `None` means pass through (alphanumeric, `-`, `_`) -/// or fall through to the Unicode escape path. -const SELECTOR_ENCODE: [Option<&str>; 128] = { - let mut table: [Option<&str>; 128] = [None; 128]; - table[b'&' as usize] = Some("_a_"); - table[b':' as usize] = Some("_c_"); - table[b'(' as usize] = Some("_lp_"); - table[b')' as usize] = Some("_rp_"); - table[b'[' as usize] = Some("_lb_"); - table[b']' as usize] = Some("_rb_"); - table[b'=' as usize] = Some("_eq_"); - table[b'>' as usize] = Some("_gt_"); - table[b'<' as usize] = Some("_lt_"); - table[b'~' as usize] = Some("_tl_"); - table[b'+' as usize] = Some("_pl_"); - table[b' ' as usize] = Some("_s_"); - table[b'*' as usize] = Some("_st_"); - table[b'.' as usize] = Some("_d_"); - table[b'#' as usize] = Some("_h_"); - table[b',' as usize] = Some("_cm_"); - table[b'"' as usize] = Some("_dq_"); - table[b'\'' as usize] = Some("_sq_"); - table[b'/' as usize] = Some("_sl_"); - table[b'\\' as usize] = Some("_bs_"); - table[b'%' as usize] = Some("_pc_"); - table[b'^' as usize] = Some("_cr_"); - table[b'$' as usize] = Some("_dl_"); - table[b'|' as usize] = Some("_pp_"); - table[b'@' as usize] = Some("_at_"); - table[b'!' as usize] = Some("_ex_"); - table[b'?' as usize] = Some("_qm_"); - table[b';' as usize] = Some("_sc_"); - table[b'{' as usize] = Some("_lc_"); - table[b'}' as usize] = Some("_rc_"); - table -}; - -fn encode_selector(selector: &str) -> String { - use std::fmt::Write; - let mut result = String::with_capacity(selector.len() + 8); - for c in selector.chars() { - if c.is_ascii() { - let byte = c as u8; - if let Some(encoded) = SELECTOR_ENCODE[byte as usize] { - result.push_str(encoded); - } else if c.is_ascii_alphanumeric() || c == '-' || c == '_' { - result.push(c); - } else { - // ASCII but not in table and not alphanumeric/-/_ - let _ = write!(result, "_u{:04x}_", c as u32); - } - } else { - let _ = write!(result, "_u{:04x}_", c as u32); - } +#[must_use] +pub fn keyframes_to_keyframes_name(keyframes: &str, filename: Option<&str>) -> String { + let _admission = crate::admission::enter(); + if atom_hoist::is_atom_hoist() { + let scope = filename.map_or_else( + || "g".to_string(), + |file| format!("l-{}", atom_name::hex(&file_map::canonical(file))), + ); + return with_prefix(|prefix| format!("{prefix}k1-{scope}-{}", atom_name::hex(keyframes))); } - result + let mut escaped = String::with_capacity(keyframes.len() + 8); + naming::escape_into(&mut escaped, keyframes); + keyframes_name_of_escaped(&escaped) } pub fn sheet_to_classname( @@ -495,144 +475,74 @@ pub fn sheet_to_classname( style_order: Option, filename: Option<&str>, ) -> String { - // base style - let filename = if style_order == Some(0) { - None - } else { - filename - }; - // `optimize_value` returns `Cow`: unmodifiable values (`red`, `14px`, - // `$primary`) borrow straight from `value` with zero allocation, and the - // key/result builders below only ever read `&optimized` as `&str`. - let optimized = value.map_or(Cow::Borrowed(""), optimize_value); - if is_debug() { - let selector = selector.unwrap_or_default().trim(); - let encoded = if selector.is_empty() { - String::new() - } else { - encode_selector(selector) - }; - // Encode the value like the selector: `.8`, `$text` or `#FFF` would break the - // CSS selector, and spaces would split the class attribute into several classes. - let value = encode_selector(&optimized); - let file_suffix = filename.map(get_file_num_by_filename); - let order = style_order.unwrap_or(255); - let prop = property.trim(); - with_prefix(|prefix| { - // Estimate capacity: prefix + prop + separators + level(1-3) + value + encoded + order(1-3) + file - let mut result = - String::with_capacity(prefix.len() + prop.len() + value.len() + encoded.len() + 16); - result.push_str(prefix); - result.push_str(prop); - result.push('-'); - write_u8(&mut result, level); - result.push('-'); - result.push_str(&value); - result.push('-'); - result.push_str(&encoded); - result.push('-'); - write_u8(&mut result, order); - if let Some(fnum) = file_suffix { - result.push('-'); - result.push_str(&num_to_nm_base(fnum)); - } - result - }) - } else { - let trimmed_selector = selector.unwrap_or_default().trim(); - let order = style_order.unwrap_or(255); - let file_num_str = filename.map(|f| num_to_nm_base(get_file_num_by_filename(f))); - let trimmed_prop = property.trim(); - - let filename_key = filename.unwrap_or_default(); - // Build key into the reusable buffer; probe borrows it, insert clones it. - let clas_num = class_num_for_key(filename_key, |key| { - key.reserve(trimmed_prop.len() + optimized.len() + trimmed_selector.len() + 16); - key.push_str(trimmed_prop); - key.push('-'); - write_u8(key, level); - key.push('-'); - key.push_str(&optimized); - key.push('-'); - key.push_str(trimmed_selector); - key.push('-'); - write_u8(key, order); - if let Some(fstr) = &file_num_str { - key.push('-'); - key.push_str(fstr); - } - }); - with_prefix(|prefix| { - if let Some(fstr) = &file_num_str { - let mut result = String::with_capacity(prefix.len() + 8 + clas_num.len()); - result.push_str(prefix); - result.push_str(fstr); - result.push('-'); - result.push_str(&clas_num); - result - } else { - let mut result = String::with_capacity(prefix.len() + clas_num.len()); - result.push_str(prefix); - result.push_str(&clas_num); - result - } - }) - } + sheet_to_classname_named( + property, + level, + value, + selector, + style_order, + filename, + Naming::Own, + ) } -/// Write a u8 value to a string without allocating via format! -#[inline] -fn write_u8(s: &mut String, v: u8) { - if v >= 100 { - s.push((b'0' + v / 100) as char); - s.push((b'0' + (v / 10) % 10) as char); - s.push((b'0' + v % 10) as char); - } else if v >= 10 { - s.push((b'0' + v / 10) as char); - s.push((b'0' + v % 10) as char); - } else { - s.push((b'0' + v) as char); - } +pub fn sheet_to_classname_named( + property: &str, + level: u8, + value: Option<&str>, + selector: Option<&str>, + style_order: Option, + filename: Option<&str>, + naming: Naming, +) -> String { + let selector = selector.map(|text| StyleSelector::Selector(text.trim().to_string())); + sheet_to_classname_content( + &content_name::AtomContent { + property: property.trim(), + value, + level, + order: style_order.unwrap_or(255), + naming, + selector: selector.as_ref(), + layer: None, + dynamic: false, + }, + filename, + ) } +/// Name the same structured content that the sheet validates before insertion. #[must_use] -pub fn sheet_to_variable_name(property: &str, level: u8, selector: Option<&str>) -> String { - if is_debug() { - let selector = selector.unwrap_or_default().trim(); - let encoded = if selector.is_empty() { - String::new() - } else { - encode_selector(selector) - }; - with_prefix(|prefix| { - let mut result = - String::with_capacity(2 + prefix.len() + property.len() + 4 + encoded.len()); - result.push_str("--"); - result.push_str(prefix); - result.push_str(property); - result.push('-'); - write_u8(&mut result, level); - result.push('-'); - result.push_str(&encoded); - result - }) - } else { - let trimmed_selector = selector.unwrap_or_default().trim(); - let base_name = class_num_for_key("", |key| { - key.reserve(property.len() + 4 + trimmed_selector.len()); - key.push_str(property); - key.push('-'); - write_u8(key, level); - key.push('-'); - key.push_str(trimmed_selector); - }); - with_prefix(|prefix| { - let mut result = String::with_capacity(2 + prefix.len() + base_name.len()); - result.push_str("--"); - result.push_str(prefix); - result.push_str(&base_name); - result - }) +pub fn sheet_to_classname_content( + content: &content_name::AtomContent<'_>, + filename: Option<&str>, +) -> String { + sheet_to_classname_owned(content, filename, CounterOwner::Inactive) +} + +/// Keep original counter ownership separate from canonical content and delivery scope. +#[must_use] +pub fn sheet_to_classname_owned( + content: &content_name::AtomContent<'_>, + filename: Option<&str>, + owner: CounterOwner, +) -> String { + let _admission = crate::admission::enter(); + let descriptor = content.content(); + match naming::owned_private_counter(owner, (filename, content.order), content.naming) { + Some(id) => { + let scope = format!("D9-{id}"); + let number = class_num_for_key(&scope, |key| { + key.push_str(&atom_name::hex(&descriptor.lossless)); + }); + let file = num_to_nm_base(usize::try_from(id).unwrap_or_default()); + with_prefix(|prefix| format!("{prefix}{file}-{number}")) + } + None => naming_scope::name( + &descriptor, + (filename, content.order), + content_hash::FingerprintBits::PRODUCTION, + ), } } @@ -760,20 +670,18 @@ mod tests { reset_class_map(); assert_eq!( sheet_to_classname("background", 0, Some("red"), None, None, None), - "a" - ); - assert_eq!( - sheet_to_classname("background", 0, Some("red"), Some("hover"), None, None), - "b" + "OLbackground-vred" ); + let hover = sheet_to_classname("background", 0, Some("red"), Some("hover"), None, None); + assert_eq!(hover.len(), 18); + assert!(hover.starts_with("OH")); assert_eq!( sheet_to_classname("background", 1, None, None, None, None), - "c" - ); - assert_eq!( - sheet_to_classname("background", 1, None, Some("hover"), None, None), - "d" + "OLbackground-l1" ); + let responsive_hover = sheet_to_classname("background", 1, None, Some("hover"), None, None); + assert_eq!(responsive_hover.len(), 18); + assert_ne!(responsive_hover, hover); reset_class_map(); assert_eq!( @@ -828,13 +736,7 @@ mod tests { sheet_to_classname("background", 0, Some("#FF000080"), None, None, None), ); - class_map::with_class_map(|map| { - assert_eq!( - map.get("") - .and_then(|entry| entry.get("background-0-#FF000080--255")), - Some(&2) - ); - }); + assert_eq!(get_class_map().len(), 0, "the shared sheet has no counter"); assert_eq!( sheet_to_classname("background", 0, Some("#fff"), None, None, None), sheet_to_classname(" background ", 0, Some("#FFF"), None, None, None), @@ -845,14 +747,6 @@ mod tests { sheet_to_classname("background", 0, Some("#FFF"), None, None, None), ); - class_map::with_class_map(|map| { - assert_eq!( - map.get("") - .and_then(|entry| entry.get("background-0-#FFF--255")), - Some(&3) - ); - }); - assert_eq!( sheet_to_classname("background", 0, Some("#ffffff"), None, None, None), sheet_to_classname("background", 0, Some("#FFFFFF"), None, None, None), @@ -863,13 +757,6 @@ mod tests { sheet_to_classname("background", 0, Some("#FFFFFFaa"), None, None, None), ); - class_map::with_class_map(|map| { - assert_eq!( - map.get("") - .and_then(|entry| entry.get("background-0-#FFFA--255")), - Some(&4) - ); - }); assert_eq!( sheet_to_classname( "background", @@ -892,118 +779,129 @@ mod tests { reset_class_map(); assert_eq!( sheet_to_classname("background", 0, None, None, None, None), - "a" + "OLbackground" ); assert_eq!( sheet_to_classname("background", 0, None, None, Some(1), None), - "b" + "OLbackground-o1" ); reset_class_map(); assert_eq!( sheet_to_classname("width", 0, Some("0px"), None, None, None), - "a" + "OLwidth-v0" ); assert_eq!( sheet_to_classname("width", 0, Some("0em"), None, None, None), - "a" + "OLwidth-v0" ); assert_eq!( sheet_to_classname("width", 0, Some("0rem"), None, None, None), - "a" + "OLwidth-v0" ); assert_eq!( sheet_to_classname("width", 0, Some("0vh"), None, None, None), - "a" + "OLwidth-v0" ); assert_eq!( sheet_to_classname("width", 0, Some("0%"), None, None, None), - "a" + "OLwidth-v0" ); assert_eq!( sheet_to_classname("width", 0, Some("0dvh"), None, None, None), - "a" + "OLwidth-v0" ); assert_eq!( sheet_to_classname("width", 0, Some("0dvw"), None, None, None), - "a" + "OLwidth-v0" ); assert_eq!( sheet_to_classname("width", 0, Some("0vw"), None, None, None), - "a" + "OLwidth-v0" ); assert_eq!( sheet_to_classname("width", 0, Some("0"), None, None, None), - "a" + "OLwidth-v0" ); + let border = sheet_to_classname("border", 0, Some("solid 0 red"), None, None, None); + assert_eq!(border.len(), 18); assert_eq!( sheet_to_classname("border", 0, Some("solid 0px red"), None, None, None), - "b" + border ); assert_eq!( sheet_to_classname("border", 0, Some("solid 0% red"), None, None, None), - "b" + border ); assert_eq!( sheet_to_classname("border", 0, Some("solid 0em red"), None, None, None), - "b" + border ); assert_eq!( sheet_to_classname("border", 0, Some("solid 0rem red"), None, None, None), - "b" + border ); assert_eq!( sheet_to_classname("border", 0, Some("solid 0vh red"), None, None, None), - "b" + border ); assert_eq!( sheet_to_classname("border", 0, Some("solid 0vw red"), None, None, None), - "b" + border ); assert_eq!( sheet_to_classname("border", 0, Some("solid 0dvh red"), None, None, None), - "b" + border ); assert_eq!( sheet_to_classname("border", 0, Some("solid 0dvw red"), None, None, None), - "b" + border ); assert_eq!( sheet_to_classname("test", 0, Some("0px 0"), None, None, None), - "c" + "OLtest-v0_s0" ); assert_eq!( sheet_to_classname("test", 0, Some("0em 0"), None, None, None), - "c" + "OLtest-v0_s0" ); assert_eq!( sheet_to_classname("test", 0, Some("0rem 0"), None, None, None), - "c" + "OLtest-v0_s0" ); assert_eq!( sheet_to_classname("test", 0, Some("0vh 0"), None, None, None), - "c" + "OLtest-v0_s0" ); assert_eq!( sheet_to_classname("test", 0, Some("0vw 0"), None, None, None), - "c" + "OLtest-v0_s0" ); assert_eq!( sheet_to_classname("test", 0, Some("0dvh 0"), None, None, None), - "c" + "OLtest-v0_s0" ); assert_eq!( sheet_to_classname("test", 0, Some("0 0vh"), None, None, None), - "c" + "OLtest-v0_s0" ); assert_eq!( sheet_to_classname("test", 0, Some("0 0vw"), None, None, None), - "c" + "OLtest-v0_s0" ); reset_class_map(); + let transition = sheet_to_classname( + "transition", + 0, + Some("all .3s ease-in-out"), + None, + None, + None, + ); + assert_eq!(transition.len(), 18); assert_eq!( sheet_to_classname( "transition", @@ -1013,7 +911,7 @@ mod tests { None, None ), - "a" + transition ); assert_eq!( sheet_to_classname( @@ -1024,7 +922,7 @@ mod tests { None, None ), - "a" + transition ); } @@ -1034,19 +932,18 @@ mod tests { set_debug(true); assert_eq!( sheet_to_classname("background", 0, None, None, None, None), - "background-0---255" - ); - assert_eq!( - sheet_to_classname("background", 0, Some("red"), Some("hover"), None, None), - "background-0-red-hover-255" + "OLbackground" ); + let hover = sheet_to_classname("background", 0, Some("red"), Some("hover"), None, None); + assert!(hover.starts_with("OH")); + assert_eq!(hover.len(), 18); assert_eq!( sheet_to_classname("background", 1, None, None, None, None), - "background-1---255" + "OLbackground-l1" ); - assert_eq!( + assert_ne!( sheet_to_classname("background", 1, Some("red"), Some("hover"), None, None), - "background-1-red-hover-255" + hover ); } @@ -1054,20 +951,19 @@ mod tests { #[serial] fn test_debug_sheet_to_classname_encodes_value() { set_debug(true); - for (property, value, expected) in [ - ("scale", "0.8", "scale-0-_d_8--255"), - ("background", "$text", "background-0-_dl_text--255"), - ("color", "#fff", "color-0-_h_FFF--255"), - ("height", "50%", "height-0-50_pc_--255"), - ( - "transition", - "all .2s ease-in-out", - "transition-0-all_s__d_2s_s_ease-in-out--255", - ), + for (property, value) in [ + ("scale", "0.8"), + ("background", "$text"), + ("color", "#fff"), + ("height", "50%"), + ("transition", "all .2s ease-in-out"), ] { - assert_eq!( - sheet_to_classname(property, 0, Some(value), None, None, None), - expected + let name = sheet_to_classname(property, 0, Some(value), None, None, None); + assert!(name.len() <= 18, "{name}"); + assert!( + name.bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')), + "{name}" ); } set_debug(false); @@ -1077,16 +973,11 @@ mod tests { #[serial] fn test_debug_sheet_to_classname_with_filename() { reset_class_map(); + file_map::reset_file_map(); set_debug(true); - // Debug mode + filename triggers the file_suffix branch (lines 234-235) let class_name = sheet_to_classname("background", 0, Some("red"), None, None, Some("test.tsx")); - assert!(class_name.contains("background-0-red--255-")); - // Should have a file number suffix - assert!( - class_name.split('-').count() >= 6, - "Expected file suffix in debug classname: {class_name}" - ); + assert_eq!(class_name, "FLtest_ptsx-OLbackground-vred"); set_debug(false); } @@ -1181,13 +1072,15 @@ mod tests { fn test_keyframes_to_keyframes_name() { reset_class_map(); set_debug(false); - assert_eq!(keyframes_to_keyframes_name("spin", None), num_to_nm_base(0)); - assert_eq!(keyframes_to_keyframes_name("spin", None), num_to_nm_base(0)); + assert_eq!(keyframes_to_keyframes_name("spin", None), "Kspin"); + assert_eq!(keyframes_to_keyframes_name("spin", None), "Kspin"); + assert_eq!(keyframes_to_keyframes_name("spin2", None), "Kspin2"); assert_eq!( - keyframes_to_keyframes_name("spin2", None), - num_to_nm_base(1) + keyframes_to_keyframes_name("spin", Some("a.tsx")), + "Kspin", + "equal animations share one name in every file" ); - reset_class_map(); + assert_eq!(get_class_map().len(), 0, "no counter is used"); set_debug(true); assert_eq!(keyframes_to_keyframes_name("spin", None), "k-spin"); assert_eq!(keyframes_to_keyframes_name("spin1", None), "k-spin1"); @@ -1236,7 +1129,7 @@ mod tests { let class1 = sheet_to_classname("background", 0, Some("red"), None, None, None); assert!(class1.starts_with("app-")); - assert_eq!(class1, "app-a"); + assert_eq!(class1, "app-OLbackground-vred"); let class2 = sheet_to_classname("color", 0, Some("blue"), None, None, None); assert!(class2.starts_with("app-")); @@ -1252,7 +1145,7 @@ mod tests { set_prefix(Some("my-".to_string())); let class_name = sheet_to_classname("background", 0, Some("red"), None, None, None); - assert_eq!(class_name, "my-background-0-red--255"); + assert_eq!(class_name, "my-OLbackground-vred"); let with_selector = sheet_to_classname("background", 0, Some("red"), Some("hover"), None, None); @@ -1308,24 +1201,21 @@ mod tests { fn test_keyframes_to_keyframes_name_with_filename() { reset_class_map(); set_debug(false); - // Test with filename to cover lines 148-151 let name = keyframes_to_keyframes_name("spin", Some("test.tsx")); - // Should include file number prefix - assert!(name.contains('-')); - // Same keyframe in same file should return same name let name2 = keyframes_to_keyframes_name("spin", Some("test.tsx")); assert_eq!(name, name2); - // Different file should have different prefix let name3 = keyframes_to_keyframes_name("spin", Some("other.tsx")); - assert_ne!(name, name3); + assert_eq!(name, name3, "the name is the content, not the file"); + assert_ne!(name, keyframes_to_keyframes_name("spin2", Some("test.tsx"))); } #[test] #[serial] fn test_sheet_to_classname_with_filename() { reset_class_map(); + file_map::reset_file_map(); set_debug(false); // Test with filename to cover the filename branch let class1 = sheet_to_classname("background", 0, Some("red"), None, None, Some("test.tsx")); @@ -1336,10 +1226,12 @@ mod tests { let class2 = sheet_to_classname("background", 0, Some("red"), None, None, Some("test.tsx")); assert_eq!(class1, class2); - // Different file should have different prefix let class3 = sheet_to_classname("background", 0, Some("red"), None, None, Some("other.tsx")); - assert_ne!(class1, class3); + assert_ne!( + class1, class3, + "independently delivered sheets must retain distinct identities without D9" + ); } #[test] diff --git a/libs/css/src/naming.rs b/libs/css/src/naming.rs new file mode 100644 index 000000000..489d20a43 --- /dev/null +++ b/libs/css/src/naming.rs @@ -0,0 +1,193 @@ +//! Production names that never depend on which build, environment or +//! processing order reached a style first. +//! +//! Short counter names are only handed out inside a namespace one original +//! file owns, and only to styles whose existence does not depend on how an +//! import resolves ([`Naming::Own`]). Everything else is named by what it +//! declares, so equal styles share one name wherever they are met. + +use std::collections::{HashMap, HashSet}; +use std::fmt::Write; +use std::sync::{LazyLock, Mutex}; + +use crate::CounterOwner; + +/// Whether a style may take a slot of its file's counter. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum Naming { + /// Value and existence derived solely from this file's source and theme. + #[default] + Own, + /// Chosen or valued by something that may differ between environments. + Risky, +} + +impl Naming { + #[must_use] + pub const fn join(self, other: Self) -> Self { + match (self, other) { + (Self::Own, Self::Own) => Self::Own, + _ => Self::Risky, + } + } +} + +pub use crate::sparse_site::Site; + +/// Shared eligibility for class allocation and content/scope registry claims. +#[must_use] +pub fn owned_private_counter( + owner: CounterOwner, + delivery: (Option<&str>, u8), + naming: Naming, +) -> Option { + let _admission = crate::admission::enter(); + let (filename, order) = delivery; + if naming != Naming::Own || order == 0 { + return None; + } + let file = filename.filter(|file| !crate::atom_hoist::is_hoisted_bucket(file))?; + match owner { + CounterOwner::D9(id) => Some(id), + CounterOwner::Unnumbered => None, + CounterOwner::Inactive => private_counter(Some(file), naming, order), + } +} + +static COLLAPSED: LazyLock>> = LazyLock::new(|| Mutex::new(HashSet::new())); + +/// Remember which buckets hold more than one original file. +pub fn set_collapsed_buckets(canonical: &HashMap) { + let _admission = crate::admission::enter(); + crate::admission::assert_administration_allowed("set_collapsed_buckets"); + let _root = crate::root_held::RootHeld::enter("collapsed"); + *COLLAPSED + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = canonical + .iter() + .filter(|(file, bucket)| file != bucket) + .map(|(_, bucket)| bucket.clone()) + .collect(); +} + +/// Whether styles named in `filename`'s namespace are met from several files. +pub fn is_shared_namespace(filename: Option<&str>) -> bool { + let _admission = crate::admission::enter(); + filename.is_none_or(|bucket| { + let _root = crate::root_held::RootHeld::enter("collapsed"); + COLLAPSED + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .contains(bucket) + }) +} + +/// Lowercase letters and digits stay; every other character becomes an +/// `_`-led code, so the encoding is lossless and holds no `-`. +pub fn escape_into(out: &mut String, text: &str) { + for c in text.chars() { + let code = match c { + 'a'..='z' | '0'..='9' => { + out.push(c); + continue; + } + '-' => 'd', + '_' => 'u', + '.' => 'p', + '%' => 'c', + '#' => 'h', + '(' => 'o', + ')' => 'e', + ',' => 'm', + ' ' => 's', + '/' => 'l', + ':' => 'n', + '&' => 'a', + '"' => 'q', + _ => { + let _ = write!(out, "_x{:x}_", u32::from(c)); + continue; + } + }; + out.push('_'); + out.push(code); + } +} + +/// The name of a class by what it declares. +pub fn content_class( + property: &str, + level: u8, + value: Option<&str>, + selector: &str, + style_order: u8, +) -> String { + let selector = (!selector.is_empty()) + .then(|| crate::style_selector::StyleSelector::Selector(selector.to_string())); + crate::content_name::AtomContent { + property, + level, + value, + selector: selector.as_ref(), + order: style_order, + naming: Naming::Risky, + layer: None, + dynamic: false, + } + .content() + .name("") +} + +/// Only a source numbered before extraction may consume its private counter. +#[must_use] +pub fn private_counter(filename: Option<&str>, naming: Naming, order: u8) -> Option { + let _admission = crate::admission::enter(); + if naming != Naming::Own || order == 0 || is_shared_namespace(filename) { + return None; + } + filename + .filter(|file| !crate::atom_hoist::is_hoisted_bucket(file)) + .and_then(crate::file_map::original_id) +} + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + + #[test] + fn join_is_risky_when_either_side_is() { + assert_eq!(Naming::Own.join(Naming::Own), Naming::Own); + assert_eq!(Naming::Own.join(Naming::Risky), Naming::Risky); + assert_eq!(Naming::Risky.join(Naming::Own), Naming::Risky); + assert_eq!(Naming::Risky.join(Naming::Risky), Naming::Risky); + } + + #[test] + fn escape_is_lossless_and_free_of_separators() { + let mut out = String::new(); + escape_into(&mut out, "a-B_1%é"); + assert_eq!(out, "a_d_x42__u1_c_xe9_"); + } + + #[test] + fn content_class_tells_none_from_empty_value_and_keeps_defaults_out() { + assert_eq!(content_class("color", 0, None, "", 255), "RLcolor"); + assert_eq!(content_class("color", 0, Some(""), "", 255), "RLcolor-v"); + let named = content_class("color", 1, Some("red"), "&:hover", 0); + assert_eq!(named.len(), 18); + assert!(named.starts_with("RH")); + } + + #[test] + #[serial] + fn shared_namespace_is_the_base_sheet_or_a_collapsed_bucket() { + set_collapsed_buckets(&HashMap::from([("/b.tsx".into(), "/a.tsx".into())])); + + assert!(is_shared_namespace(None)); + assert!(is_shared_namespace(Some("/a.tsx"))); + assert!(!is_shared_namespace(Some("/b.tsx"))); + assert!(!is_shared_namespace(Some("/c.tsx"))); + set_collapsed_buckets(&HashMap::new()); + } +} diff --git a/libs/css/src/naming_coverage_tests.rs b/libs/css/src/naming_coverage_tests.rs new file mode 100644 index 000000000..69832927e --- /dev/null +++ b/libs/css/src/naming_coverage_tests.rs @@ -0,0 +1,109 @@ +use crate::style_selector::{AtRule, AtRuleKind, StyleSelector}; +use crate::{Naming, atom_name, content_name::AtomContent}; +use serial_test::serial; +use std::collections::{BTreeSet, HashMap}; + +struct Configuration; + +impl Drop for Configuration { + fn drop(&mut self) { + crate::atom_hoist::set_atom_hoist(None); + crate::atom_hoist::restore_atom_plan(None); + crate::file_map::reset_canonical_map(); + crate::set_prefix(None); + crate::debug::set_debug(false); + } +} + +#[test] +#[serial] +fn placement_scope_uses_canonical_buckets_not_original_cleanup_owners() { + // Given: distinct originals delivering into local and hoisted canonical buckets. + let _configuration = Configuration; + crate::atom_hoist::set_atom_hoist(Some(2)); + crate::atom_hoist::restore_atom_plan(Some(BTreeSet::from(["shared.tsx".into()]))); + crate::file_map::set_canonical_map(HashMap::from([ + ("child.tsx".into(), "a.tsx".into()), + ("shared-child.tsx".into(), "shared.tsx".into()), + ])); + // When: the public pure placement helper resolves each scope. + let scopes = [ + None, + Some("child.tsx"), + Some("a.tsx"), + Some("shared-child.tsx"), + ] + .map(atom_name::scope); + // Then: local aliases coincide, but global and hoisted delivery stay separate. + assert_eq!(scopes, ["g", "l-612e747378", "l-612e747378", "h"]); +} + +#[test] +fn nested_layer_selector_identity_keeps_inner_selector_but_not_cleanup_file() { + // Given: equal layer structure with a pseudo selector, and one different selector. + let selector = |inner: &str, file: &str| StyleSelector::At { + kind: AtRuleKind::Layer, + query: "cards".into(), + selector: Some(inner.into()), + outer: vec![AtRule { + kind: AtRuleKind::Layer, + query: "base".into(), + }], + file: Some(file.into()), + }; + let a = selector("&:hover", "a.tsx"); + let b = selector("&:hover", "b.tsx"); + let focus = selector("&:focus", "a.tsx"); + let content = |selector| { + AtomContent { + property: "color", + value: Some("red"), + naming: Naming::Own, + level: 0, + order: 255, + selector: Some(selector), + layer: None, + dynamic: false, + } + .content() + }; + // When: lossless selector keys and versioned descriptors are built from the same structure. + let keys = [&a, &b, &focus].map(|value| atom_name::selector_key(Some(value), None)); + let names = [&a, &b, &focus].map(content); + // Then: cleanup does not rename a rule, while a changed pseudo-selector must. + assert_eq!(keys[0], keys[1]); + assert_ne!(keys[0], keys[2]); + assert_eq!( + keys[0], + "a-layer-62617365-end-layer-6361726473-s-263a686f766572-n" + ); + assert_eq!(names[0], names[1]); + assert_ne!(names[0].descriptor, names[2].descriptor); + assert_ne!(names[0].name(""), names[2].name("")); + assert!(names[0].lossless.contains("-a3base-e-3cards-s_a_nhover")); +} + +#[test] +#[serial] +fn pure_keyframe_api_respects_atom_scope_and_legacy_content_controls() { + // Given: a custom prefix and canonical delivery alias in atom mode. + let _configuration = Configuration; + crate::set_prefix(Some("du-".into())); + crate::debug::set_debug(false); + crate::atom_hoist::set_atom_hoist(Some(2)); + crate::file_map::set_canonical_map(HashMap::from([("child.tsx".into(), "a.tsx".into())])); + // When: the existing low-level API names global/local animations, then its legacy control. + let global = crate::keyframes_to_keyframes_name("spin", None); + let child = crate::keyframes_to_keyframes_name("spin", Some("child.tsx")); + let canonical = crate::keyframes_to_keyframes_name("spin", Some("a.tsx")); + let other = crate::keyframes_to_keyframes_name("spin", Some("b.tsx")); + crate::atom_hoist::set_atom_hoist(None); + let legacy = crate::keyframes_to_keyframes_name("spin", Some("child.tsx")); + // Then: atom placement is canonical and injective, legacy stays content-only. + assert_eq!(global, "du-k1-g-7370696e"); + assert_eq!(child, "du-k1-l-612e747378-7370696e"); + assert_eq!(child, canonical); + assert_ne!(child, other); + assert_ne!(global, child); + assert_eq!(legacy, "du-Kspin"); +} diff --git a/libs/css/src/naming_root.rs b/libs/css/src/naming_root.rs new file mode 100644 index 000000000..1800664db --- /dev/null +++ b/libs/css/src/naming_root.rs @@ -0,0 +1,98 @@ +use std::sync::{LazyLock, Mutex}; + +#[derive(Clone)] +struct NamingContext { + root: String, + relative_base: Option, +} + +static ROOT: LazyLock>> = LazyLock::new(|| Mutex::new(None)); + +/// Set the independent names-only root; resolver and delivery identities stay unchanged. +pub fn set_root(root: Option) { + let _admission = crate::admission::enter(); + crate::admission::assert_administration_allowed("set_root"); + set_context(root, None); +} + +/// A relative-ID basis is names-only too; raw resolver and sheet keys are never rewritten. +pub fn set_context(root: Option, relative_base: Option) { + let _admission = crate::admission::enter(); + crate::admission::assert_administration_allowed("set_context"); + let _root = crate::root_held::RootHeld::enter("naming_root"); + *ROOT + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = root.map(|root| NamingContext { + root, + relative_base, + }); +} + +fn parts(path: &str) -> (String, Vec<&str>) { + let segments: Vec<_> = path.split('/').filter(|part| !part.is_empty()).collect(); + let (root, start) = if path.starts_with("//") && segments.len() >= 2 { + (format!("//{}/{}", segments[0], segments[1]), 2) + } else if segments.first().is_some_and(|part| part.ends_with(':')) { + (segments[0].to_ascii_lowercase(), 1) + } else if path.starts_with('/') { + ("/".into(), 0) + } else { + (String::new(), 0) + }; + let mut normalized = Vec::new(); + for part in &segments[start..] { + match *part { + "." => {} + ".." if normalized.last().is_some_and(|last| *last != "..") => { + normalized.pop(); + } + ".." if !root.is_empty() => {} + _ => normalized.push(*part), + } + } + (root, normalized) +} + +/// Relativize only naming keys; outsiders keep `..`, incompatible roots keep absolute keys. +#[must_use] +pub fn key(filename: &str) -> String { + let _admission = crate::admission::enter(); + let root = { + let _root = crate::root_held::RootHeld::enter("naming_root"); + ROOT.lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + }; + let Some(context) = root.as_ref() else { + return filename.to_string(); + }; + let filename = filename.replace('\\', "/"); + let filename = match &context.relative_base { + Some(base) if parts(&filename).0.is_empty() => { + format!("{}/{filename}", base.replace('\\', "/")) + } + _ => filename, + }; + let root = context.root.replace('\\', "/"); + let (file_root, file) = parts(&filename); + if file_root.is_empty() { + return file.join("/"); + } + let (root_root, root) = parts(&root); + if file_root != root_root { + return if file_root == "/" { + format!("/{}", file.join("/")) + } else { + format!("{file_root}/{}", file.join("/")) + }; + } + let common = file + .iter() + .zip(&root) + .take_while(|(file, root)| file == root) + .count(); + std::iter::repeat_n("..", root.len().saturating_sub(common)) + .chain(file[common..].iter().copied()) + .collect::>() + .join("/") +} diff --git a/libs/css/src/naming_scope.rs b/libs/css/src/naming_scope.rs new file mode 100644 index 000000000..eb14412af --- /dev/null +++ b/libs/css/src/naming_scope.rs @@ -0,0 +1,36 @@ +use crate::{content_hash::FingerprintBits, content_name::ContentName}; + +/// A content atom is global only when #751 already delivers its rules in one shared sheet. +#[must_use] +pub fn local_file(filename: Option<&str>, order: u8) -> Option<&str> { + let _admission = crate::admission::enter(); + filename.filter(|file| order != 0 && !crate::atom_hoist::is_hoisted_bucket(file)) +} + +#[must_use] +pub fn fallback(filename: Option<&str>, order: u8) -> Option { + let _admission = crate::admission::enter(); + local_file(filename, order) + .filter(|file| crate::file_map::original_id(file).is_none()) + .map(|file| ContentName::scope(&crate::naming_root::key(&crate::file_map::canonical(file)))) +} + +/// Content names and exact claims use this same scope prefix and fingerprint width. +#[must_use] +pub fn name(content: &ContentName, source: (Option<&str>, u8), bits: FingerprintBits) -> String { + let _admission = crate::admission::enter(); + let prefix = crate::get_prefix().unwrap_or_default(); + let scope = match local_file(source.0, source.1) { + None => String::new(), + Some(file) => { + (match crate::file_map::original_id(file) { + Some(id) => { + crate::num_to_nm_base::num_to_nm_base(usize::try_from(id).unwrap_or_default()) + } + None => fallback(Some(file), source.1) + .map_or_else(String::new, |scope| scope.name_with_bits("", bits)), + }) + "-" + } + }; + content.name_with_bits(&format!("{prefix}{scope}"), bits) +} diff --git a/libs/css/src/root_held.rs b/libs/css/src/root_held.rs new file mode 100644 index 000000000..b2910ca37 --- /dev/null +++ b/libs/css/src/root_held.rs @@ -0,0 +1,32 @@ +//! A marker lives only as long as its particular native data guard. + +use std::{cell::RefCell, marker::PhantomData, rc::Rc}; + +thread_local! { + static HELD: RefCell> = const { RefCell::new(Vec::new()) }; +} + +pub(crate) struct RootHeld { + root: &'static str, + _thread: PhantomData>, +} + +impl RootHeld { + pub(crate) fn enter(root: &'static str) -> Self { + HELD.with(|held| { + let mut held = held.borrow_mut(); + assert!(!held.contains(&root), "{root}: a data root is already held"); + held.push(root); + }); + Self { + root, + _thread: PhantomData, + } + } +} + +impl Drop for RootHeld { + fn drop(&mut self) { + HELD.with(|held| held.borrow_mut().retain(|root| *root != self.root)); + } +} diff --git a/libs/css/src/scoped_name_tests.rs b/libs/css/src/scoped_name_tests.rs new file mode 100644 index 000000000..4710d5cc3 --- /dev/null +++ b/libs/css/src/scoped_name_tests.rs @@ -0,0 +1,85 @@ +use crate::{Naming, class_map, file_map, naming_root, sheet_to_classname_named}; +use serial_test::serial; + +#[rstest::rstest] +#[case(30)] +#[case(u32::MAX)] +#[serial] +fn d9_scope_bound_includes_the_existing_anti_ad_splice(#[case] id: u32) { + // Given + file_map::reset_file_map(); + crate::set_prefix(None); + file_map::set_original_ids(std::collections::BTreeMap::from([("x.tsx".into(), id)])); + // When + let name = sheet_to_classname_named( + "font-family", + 0, + Some(&"long".repeat(1000)), + None, + None, + Some("x.tsx"), + Naming::Risky, + ); + // Then + assert!(name.len() <= 27, "{name}"); + assert!(name.contains("-RH")); + if id == 30 { + assert!(name.starts_with("a-d-"), "{name}"); + } + file_map::reset_file_map(); +} + +#[test] +#[serial] +fn risky_atoms_use_d9_identity_without_taking_any_counter_slots() { + // Given + class_map::reset_class_map(); + file_map::reset_file_map(); + crate::set_prefix(None); + file_map::seed_file_numbers(&["a.tsx".into(), "b.tsx".into()]); + // When + let names = ["a.tsx", "b.tsx"].map(|file| { + sheet_to_classname_named( + "color", + 0, + Some("red"), + None, + None, + Some(file), + Naming::Risky, + ) + }); + // Then + assert_ne!(names[0], names[1]); + assert_eq!(names, ["a-RLcolor-vred", "b-RLcolor-vred"]); + assert_eq!(class_map::get_class_map().len(), 0); + file_map::reset_file_map(); +} + +#[test] +#[serial] +fn long_scope_and_content_are_bounded_without_arrival_numbering() { + // Given + file_map::reset_file_map(); + naming_root::set_root(Some("/checkout".into())); + crate::set_prefix(Some("du-".into())); + let file = format!("/checkout/src/{}.tsx", "long".repeat(1000)); + let value = "content".repeat(1000); + // When + let name = sheet_to_classname_named( + "font-family", + 0, + Some(&value), + None, + None, + Some(&file), + Naming::Risky, + ); + // Then + assert_eq!(name.len(), 40); + assert!(name.starts_with("du-FH")); + assert!(name.contains("-RH")); + assert_eq!(file_map::get_file_map().len(), 0); + naming_root::set_root(None); + crate::set_prefix(None); +} diff --git a/libs/css/src/sparse_site.rs b/libs/css/src/sparse_site.rs new file mode 100644 index 000000000..b2bf1e0e7 --- /dev/null +++ b/libs/css/src/sparse_site.rs @@ -0,0 +1,237 @@ +use std::sync::Arc; + +pub(crate) mod source_ids; + +use crate::{ + atom_hoist, atom_name, class_num_for_key, content_hash::FingerprintBits, + content_name::ContentName, debug::is_debug, encode_selector, num_to_nm_base::num_to_nm_base, + with_prefix, write_u8, +}; +use source_ids::original_id; + +/// D9 original identity or normalized received source text, never a path fallback. +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum SourceFile { + D9(u32), + Unnumbered(Arc), +} + +impl SourceFile { + /// Look up D9 by filename; otherwise retain received source without BOM or CR bytes. + #[must_use] + pub fn from_source(filename: &str, source: &str) -> Self { + if let Some(number) = original_id(filename) { + Self::D9(number) + } else { + let source = source.strip_prefix('\u{feff}').unwrap_or(source); + Self::Unnumbered(Arc::from(source.replace('\r', ""))) + } + } +} + +/// Sparse source position plus a role index from the source's syntax order. +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct Site { + pub file: SourceFile, + pub at: usize, + pub role: usize, +} + +/// The numeric alphabet has no separator; class-name ad-blocker fixups do not apply. +fn number(number: usize) -> String { + let mut name = num_to_nm_base(number); + name.retain(|character| character != '-'); + name +} + +impl Site { + /// Grammar: `---S(|U)-[-]`. + /// The source file part is at most 18 bytes; lossless wins ties with 80 bits. + #[must_use] + pub fn variable_name(&self, prefix: &str) -> String { + self.variable_name_with_bits(prefix, FingerprintBits::PRODUCTION) + } + + /// Width is explicit for the same production naming/collision path. + #[must_use] + pub fn variable_name_with_bits(&self, prefix: &str, bits: FingerprintBits) -> String { + let mut name = format!("---{prefix}S"); + match &self.file { + SourceFile::D9(file) => { + const { assert!(usize::BITS >= u32::BITS) }; + #[expect( + clippy::expect_used, + reason = "supported native and wasm32 targets hold every D9 u32" + )] + let file = usize::try_from(*file).expect("D9 u32 fits the target's usize"); + name.push_str(&number(file)); + } + SourceFile::Unnumbered(source) => { + name.push_str(&ContentName::source(source).name_with_bits("", bits)); + } + } + name.push('-'); + name.push_str(&number(self.at)); + if self.role != 0 { + name.push('-'); + name.push_str(&number(self.role)); + } + name + } +} + +#[must_use] +pub fn sheet_to_variable_name(property: &str, level: u8, selector: Option<&str>) -> String { + sheet_to_variable_name_at(property, level, selector, None) +} + +/// Name the variable for a dynamic assignment's original source site. +/// +/// Known sites use only original source identity, source offset and source role in every +/// naming mode. One assignment can feed several selectors or properties; +/// distinct assignments need distinct sites because custom properties inherit. +#[must_use] +pub fn sheet_to_variable_name_at( + property: &str, + level: u8, + selector: Option<&str>, + site: Option, +) -> String { + let _admission = crate::admission::enter(); + if let Some(site) = site { + return with_prefix(|prefix| site.variable_name(prefix)); + } + if atom_hoist::is_atom_hoist() { + return with_prefix(|prefix| { + format!( + "--{prefix}v1-{}-{level}-{}", + atom_name::hex(property.trim()), + atom_name::hex(selector.unwrap_or_default()) + ) + }); + } + if is_debug() { + let selector = selector.unwrap_or_default().trim(); + let encoded = if selector.is_empty() { + String::new() + } else { + encode_selector(selector) + }; + with_prefix(|prefix| { + let mut result = + String::with_capacity(2 + prefix.len() + property.len() + 4 + encoded.len()); + result.push_str("--"); + result.push_str(prefix); + result.push_str(property); + result.push('-'); + write_u8(&mut result, level); + result.push('-'); + result.push_str(&encoded); + result + }) + } else { + let trimmed_selector = selector.unwrap_or_default().trim(); + let base_name = class_num_for_key("", |key| { + key.reserve(property.len() + 4 + trimmed_selector.len()); + key.push_str(property); + key.push('-'); + write_u8(key, level); + key.push('-'); + key.push_str(trimmed_selector); + }); + with_prefix(|prefix| { + let mut result = String::with_capacity(2 + prefix.len() + base_name.len()); + result.push_str("--"); + result.push_str(prefix); + result.push_str(&base_name); + result + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn grammar_keeps_numbers_and_roles_injective_when_base37_crosses_a_digit() { + // Given: both the first multi-digit number and the ad-blocker suffix. + let sites = [(0, 0, 0), (27, 30, 0), (30, 27, 0), (27, 30, 1)]; + // When: sparse identities are encoded. + let names: Vec<_> = sites + .into_iter() + .map(|(file, at, role)| { + Site { + file: SourceFile::D9(file), + at, + role, + } + .variable_name("test-") + }) + .collect(); + // Then: separators distinguish file, position and role without decimal fallback. + assert_eq!( + names, + [ + "---test-Sa-a", + "---test-Saa-ad", + "---test-Sad-aa", + "---test-Saa-ad-b" + ] + ); + } + + #[test] + #[serial_test::serial] + fn source_encoding_keeps_unnumbered_and_d9_names_disjoint() { + // Given: source text resembling numeric names and containing namespace or escape characters. + crate::file_map::set_original_ids(std::collections::BTreeMap::new()); + let sources = ["a", "U", "a-b", "a_db", "é"]; + // When: unnumbered source text is encoded losslessly. + let names: Vec<_> = sources + .into_iter() + .map(|source| { + Site { + file: SourceFile::from_source("/unnumbered.tsx", source), + at: 0, + role: 0, + } + .variable_name("") + }) + .collect(); + // Then: neither a D9 identity nor another original source can alias them. + assert_eq!( + names, + [ + "---SULa-a", + "---SUL_x55_-a", + "---SULa_db-a", + "---SULa_udb-a", + "---SUL_xe9_-a" + ] + ); + } + + #[test] + #[serial_test::serial] + fn unnumbered_identity_retains_source_when_bom_and_cr_are_received() { + // Given: equivalent received sources and no D9 identities. + crate::file_map::set_original_ids(std::collections::BTreeMap::new()); + let inputs = [ + ("/a.tsx", "a\nb"), + ("/b.tsx", "\u{feff}a\r\nb"), + ("/c.tsx", "a\rb"), + ]; + // When: the boundary constructs each source identity. + let files = inputs.map(|(filename, source)| SourceFile::from_source(filename, source)); + // Then: normalization retains the full source text, not the filename or a hash. + assert_eq!( + files, + [ + SourceFile::Unnumbered(Arc::from("a\nb")), + SourceFile::Unnumbered(Arc::from("a\nb")), + SourceFile::Unnumbered(Arc::from("ab")), + ] + ); + } +} diff --git a/libs/css/src/sparse_site/source_ids.rs b/libs/css/src/sparse_site/source_ids.rs new file mode 100644 index 000000000..5aa20418c --- /dev/null +++ b/libs/css/src/sparse_site/source_ids.rs @@ -0,0 +1,115 @@ +use std::{ + collections::BTreeMap, + num::TryFromIntError, + sync::{LazyLock, Mutex}, +}; + +static ORIGINAL_IDS: LazyLock>> = + LazyLock::new(|| Mutex::new(BTreeMap::new())); + +fn with_original_ids(f: impl FnOnce(&mut BTreeMap) -> R) -> R { + let _admission = crate::admission::enter(); + let _root = crate::root_held::RootHeld::enter("original_ids"); + f(&mut ORIGINAL_IDS + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner)) +} + +/// D9 number of `file`, if it belongs to the numbered original sources. +#[must_use] +pub fn original_id(file: &str) -> Option { + with_original_ids(|ids| ids.get(file).copied()) +} + +fn original_id_from_len(len: usize) -> Result { + u32::try_from(len) +} + +/// Reuse the exact original filename's D9 number, or register it at the current length. +/// +/// # Errors +/// Returns `TryFromIntError` without inserting when the registry length exceeds `u32::MAX`. +pub fn get_or_insert_original_id(filename: &str) -> Result { + with_original_ids(|ids| { + if let Some(id) = ids.get(filename) { + return Ok(*id); + } + let id = original_id_from_len(ids.len())?; + ids.insert(filename.to_string(), id); + Ok(id) + }) +} + +#[must_use] +pub fn get_original_ids() -> BTreeMap { + with_original_ids(|ids| ids.clone()) +} + +pub fn set_original_ids(new_ids: BTreeMap) { + let _admission = crate::admission::enter(); + crate::admission::assert_administration_allowed("set_original_ids"); + with_original_ids(|ids| *ids = new_ids); +} + +/// Restore only the original-ID snapshot owned by a private exact attempt. +pub(crate) fn restore_original_ids_snapshot(snapshot: BTreeMap) { + with_original_ids(|ids| *ids = snapshot); +} + +pub(crate) fn seed_original_ids(files: &[String]) { + let _admission = crate::admission::enter(); + crate::admission::assert_administration_allowed("seed_original_ids"); + let mut originals: Vec<&String> = files.iter().collect(); + originals.sort_unstable(); + originals.dedup(); + with_original_ids(|ids| { + for file in originals { + if !ids.contains_key(file) + && let Ok(id) = u32::try_from(ids.len()) + { + ids.insert(file.clone(), id); + } + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[serial_test::serial] + fn registration_preserves_set_ids_when_their_numbers_differ_from_length() { + // Given: restored IDs are not inferred from their positions. + set_original_ids(BTreeMap::from([("seed.tsx".into(), 42)])); + // When: an existing filename and a new exact filename are registered. + let reused = get_or_insert_original_id("seed.tsx"); + let inserted = get_or_insert_original_id("Seed.tsx"); + // Then: set IDs survive and append uses length, not max ID or path normalization. + assert_eq!(reused, Ok(42)); + assert_eq!(inserted, Ok(1)); + assert_eq!(original_id("Seed.tsx"), Some(1)); + set_original_ids(BTreeMap::new()); + } + + #[test] + fn original_id_conversion_accepts_zero_when_registry_is_empty() { + // Given / When: the empty registry's length is converted. + let id = original_id_from_len(0); + // Then: the first original gets zero. + assert_eq!(id, Ok(0)); + } + + #[test] + #[cfg(target_pointer_width = "64")] + fn original_id_conversion_rejects_length_when_it_exceeds_u32() { + // Given: the last representable ID, without allocating registry entries. + let last = usize::try_from(u32::MAX).unwrap_or_else(|error| panic!("{error}")); + // When: the boundary and its successor are converted by the production helper. + let boundary = original_id_from_len(last); + let overflow = original_id_from_len(last + 1); + // Then: overflow is a typed conversion error, never an aliased ID. + assert_eq!(boundary, Ok(u32::MAX)); + assert!(overflow.is_err()); + } +} diff --git a/libs/css/src/style_origin.rs b/libs/css/src/style_origin.rs new file mode 100644 index 000000000..3995bb513 --- /dev/null +++ b/libs/css/src/style_origin.rs @@ -0,0 +1,155 @@ +use serde::{Deserialize, Serialize}; +use std::{ + cmp::Ordering, + hash::{Hash, Hasher}, +}; + +/// A diagnostic witness from the actual source read, never a generated offset. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub struct StyleOrigin { + pub file: String, + pub line: usize, + #[serde(rename = "col")] + pub column: usize, + #[serde(rename = "originalExpression")] + pub expression: String, +} + +/// Ordered diagnostic tiers; none claims a generated position is source text. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub enum RealLocation { + Exact(StyleOrigin), + ProducedByCall(StyleOrigin), + ModuleExport { + file: String, + binding: Option, + }, +} + +impl std::fmt::Display for RealLocation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Exact(origin) => write!( + f, + "{}:{}:{}: `{}`", + origin.file, origin.line, origin.column, origin.expression + ), + Self::ProducedByCall(origin) => write!( + f, + "produced by the call at {}:{}:{}: `{}`", + origin.file, origin.line, origin.column, origin.expression + ), + Self::ModuleExport { file, binding } => { + write!(f, "{file}")?; + if let Some(binding) = binding { + write!(f, " export `{binding}`")?; + } + write!( + f, + ": position unavailable (produced inside eval or a callback)" + ) + } + } + } +} + +/// Diagnostic metadata does not participate in a style's semantic identity. +#[derive(Clone, Debug, Default)] +pub struct Origin(pub Option>, pub Option>); + +impl PartialEq for Origin { + fn eq(&self, _: &Self) -> bool { + true + } +} +impl Eq for Origin {} +impl Hash for Origin { + fn hash(&self, _: &mut H) {} +} +impl PartialOrd for Origin { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} +impl Ord for Origin { + fn cmp(&self, _: &Self) -> Ordering { + Ordering::Equal + } +} + +impl Origin { + pub fn fill(&mut self, origin: Option<&StyleOrigin>) { + if self.0.is_none() { + self.0 = origin.cloned().map(Box::new); + } + } + + #[must_use] + pub fn location(&self) -> Option { + self.0 + .as_deref() + .cloned() + .map(RealLocation::Exact) + .or_else(|| self.1.as_deref().cloned()) + } + + #[must_use] + pub fn from_location(location: RealLocation) -> Self { + match location { + RealLocation::Exact(origin) => Self(Some(Box::new(origin)), None), + RealLocation::ProducedByCall(_) | RealLocation::ModuleExport { .. } => { + Self(None, Some(Box::new(location))) + } + } + } + + pub fn fill_from(&mut self, origin: &Self) { + if self.0.is_none() && self.1.is_none() { + self.0.clone_from(&origin.0); + self.1.clone_from(&origin.1); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn filling_missing_witness_keeps_the_first_real_expression() { + let first = StyleOrigin { + file: "src/first.tsx".into(), + line: 2, + column: 7, + expression: "first.value".into(), + }; + let later = StyleOrigin { + file: "src/later.tsx".into(), + expression: "later.value".into(), + ..first + }; + let mut origin = Origin::default(); + + origin.fill(Some(&first)); + origin.fill(Some(&later)); + + assert_eq!(origin.location(), Some(RealLocation::Exact(first))); + } + + #[test] + fn diagnostic_witnesses_do_not_change_style_ordering() { + let left = Origin::from_location(RealLocation::ModuleExport { + file: "src/left.css.ts".into(), + binding: Some("left".into()), + }); + let right = Origin::from_location(RealLocation::ModuleExport { + file: "src/right.css.ts".into(), + binding: Some("right".into()), + }); + + let ordering = left.partial_cmp(&right); + + assert_eq!(ordering, Some(Ordering::Equal)); + assert_ne!(left.location(), right.location()); + } +} diff --git a/libs/css/src/theme_tokens.rs b/libs/css/src/theme_tokens.rs index 3b2346aed..018f2f282 100644 --- a/libs/css/src/theme_tokens.rs +++ b/libs/css/src/theme_tokens.rs @@ -5,6 +5,8 @@ use std::sync::{LazyLock, RwLock}; struct ThemeTokenRegistry { length: BTreeMap>, shadow: BTreeMap>, + first_length: BTreeMap, + first_shadow: BTreeMap, typography: Vec, } @@ -15,13 +17,57 @@ pub fn set_theme_token_levels( length: BTreeMap>, shadow: BTreeMap>, ) { + let _admission = crate::admission::enter(); + crate::admission::assert_administration_allowed("set_theme_token_levels"); + let _root = crate::root_held::RootHeld::enter("theme_tokens"); if let Ok(mut registry) = TOKEN_REGISTRY.write() { registry.length = length; registry.shadow = shadow; + registry.first_length.clear(); + registry.first_shadow.clear(); } } +/// Register effective default literals separately from responsive variable levels. +pub fn set_theme_token_values(length: BTreeMap, shadow: BTreeMap) { + let _admission = crate::admission::enter(); + crate::admission::assert_administration_allowed("set_theme_token_values"); + let _root = crate::root_held::RootHeld::enter("theme_tokens"); + if let Ok(mut registry) = TOKEN_REGISTRY.write() { + let normalize = |values: BTreeMap| { + values + .into_iter() + .map(|(token, value)| { + ( + token, + crate::optimize_value::optimize_value(&value).into_owned(), + ) + }) + .collect() + }; + registry.first_length = normalize(length); + registry.first_shadow = normalize(shadow); + } +} + +/// Resolve the first default literal in the property's token namespace. +pub fn get_first_theme_token_value(property: &str, value: &str) -> Option { + let _admission = crate::admission::enter(); + let token = value.strip_prefix('$')?; + let _root = crate::root_held::RootHeld::enter("theme_tokens"); + let registry = TOKEN_REGISTRY.read().ok()?; + let values = if property == "box-shadow" { + ®istry.first_shadow + } else { + ®istry.first_length + }; + values.get(token).cloned() +} + pub fn set_typography_keys(keys: Vec) { + let _admission = crate::admission::enter(); + crate::admission::assert_administration_allowed("set_typography_keys"); + let _root = crate::root_held::RootHeld::enter("theme_tokens"); if let Ok(mut registry) = TOKEN_REGISTRY.write() { registry.typography = keys; } @@ -30,6 +76,8 @@ pub fn set_typography_keys(keys: Vec) { /// Typography names defined by the registered theme, so a dynamic /// `typography` value under a selector can resolve to one class per name. pub fn get_typography_keys() -> Vec { + let _admission = crate::admission::enter(); + let _root = crate::root_held::RootHeld::enter("theme_tokens"); TOKEN_REGISTRY .read() .map(|registry| registry.typography.clone()) @@ -40,7 +88,9 @@ pub fn get_typography_keys() -> Vec { /// Returns the responsive breakpoint levels if the token is defined /// with more than one level, regardless of which CSS property it's used on. pub fn get_responsive_theme_token(value: &str) -> Option> { + let _admission = crate::admission::enter(); let token = value.strip_prefix('$')?; + let _root = crate::root_held::RootHeld::enter("theme_tokens"); let registry = TOKEN_REGISTRY.read().ok()?; registry @@ -61,6 +111,7 @@ pub fn is_responsive_theme_token(value: &str) -> bool { // instead of each getting its own `return false;` statement. fn lookup(value: &str) -> Option { let token = value.strip_prefix('$')?; + let _root = crate::root_held::RootHeld::enter("theme_tokens"); let registry = TOKEN_REGISTRY.read().ok()?; Some( @@ -72,6 +123,7 @@ pub fn is_responsive_theme_token(value: &str) -> bool { ) } + let _admission = crate::admission::enter(); lookup(value).unwrap_or(false) } @@ -80,6 +132,67 @@ mod tests { use super::*; use serial_test::serial; + #[test] + #[serial] + fn theme_token_values_resolve_in_the_property_namespace() { + // Given + set_theme_token_values( + BTreeMap::from([("shared".into(), "8px".into())]), + BTreeMap::from([("shared".into(), "0 1px 2px black".into())]), + ); + // When / Then + assert_eq!( + get_first_theme_token_value("width", "$shared"), + Some("8px".into()) + ); + assert_eq!( + get_first_theme_token_value("box-shadow", "$shared"), + Some("0 1px 2px black".into()) + ); + assert_eq!(get_first_theme_token_value("width", "$missing"), None); + assert_eq!(get_first_theme_token_value("width", "shared"), None); + set_theme_token_levels(BTreeMap::new(), BTreeMap::new()); + } + + #[test] + #[serial] + fn theme_token_values_normalize_zero_literals() { + // Given + let zero = BTreeMap::from([("zero".into(), "0px".into())]); + // When + set_theme_token_values(zero.clone(), zero); + // Then + assert_eq!( + get_first_theme_token_value("width", "$zero"), + Some("0".into()) + ); + assert_eq!( + get_first_theme_token_value("box-shadow", "$zero"), + Some("0".into()) + ); + set_theme_token_levels(BTreeMap::new(), BTreeMap::new()); + } + + #[test] + #[serial] + fn theme_token_values_reset_when_levels_are_replaced() { + // Given + set_theme_token_values( + BTreeMap::from([("shared".into(), "8px".into())]), + BTreeMap::from([("shared".into(), "0 1px 2px black".into())]), + ); + // When + set_theme_token_levels( + BTreeMap::from([("shared".into(), vec![0, 2])]), + BTreeMap::from([("shared".into(), vec![0, 3])]), + ); + // Then + assert_eq!(get_first_theme_token_value("width", "$shared"), None); + assert_eq!(get_first_theme_token_value("box-shadow", "$shared"), None); + assert_eq!(get_responsive_theme_token("$shared"), Some(vec![0, 2])); + set_theme_token_levels(BTreeMap::new(), BTreeMap::new()); + } + #[test] #[serial] fn test_get_responsive_theme_token() { diff --git a/libs/css/tests/compact_sources.rs b/libs/css/tests/compact_sources.rs new file mode 100644 index 000000000..0b45b2c76 --- /dev/null +++ b/libs/css/tests/compact_sources.rs @@ -0,0 +1,63 @@ +use css::{Site, sparse_site::SourceFile}; +use css::{content_hash::FingerprintBits, content_name::ContentName}; +use std::sync::Arc; + +#[test] +fn variable_length_is_bounded_when_unnumbered_source_is_100_kb() { + // Given: a large received source and the largest supported position/role. + let source = "// filler\n".repeat(10_240); + let site = Site { + file: SourceFile::Unnumbered(Arc::from(source)), + at: usize::MAX, + role: usize::MAX, + }; + // When: the production naming API emits its variable. + let name = site.variable_name("test-"); + // Then: source size cannot inflate the file part beyond 18 bytes. + let digits = if usize::BITS == 64 { 13 } else { 7 }; + assert!( + name.len() <= 4 + 5 + 18 + 2 + 2 * digits, + "length={}", + name.len() + ); + assert_eq!(name.len(), 4 + 5 + 18 + 2 + 2 * digits); +} + +#[test] +fn lossless_wins_when_source_payload_ties_the_fingerprint_length() { + // Given: normalized source payloads at either side of the 16-byte boundary. + let sources = ["a".repeat(15), "a".repeat(16), "a".repeat(17)]; + // When: the shared min-length algorithm names each source. + let names = sources.map(|source| ContentName::source(&source).name("")); + // Then: ties stay lossless and only the longer source uses a fingerprint. + assert_eq!(names[0], format!("UL{}", "a".repeat(15))); + assert_eq!(names[1], format!("UL{}", "a".repeat(16))); + assert!(names[2].starts_with("UH")); + assert_eq!(names[2].len(), 18); +} + +#[test] +fn source_fingerprint_is_fixed_when_source_has_multiple_sha_blocks() { + // Given: the NIST two-block input, independent of Rust/compiler versions. + let source = "abcdefghbcdefghicdefghijdefghijkefghijklfghijklmghijklmnhijklmnoijklmnopjklmnopqklmnopqrlmnopqrsmnopqrstnopqrstu"; + // When: the source is named without a filename or diagnostic witness. + let name = ContentName::source(source).name(""); + // Then: the first 80 SHA-256 bits match the independent fixed byte golden. + assert_eq!(name, "UHc8yjo0690l8os8yl"); +} + +#[test] +fn narrow_width_names_collide_when_distinct_sources_have_the_same_digest_prefix() { + // Given: three distinct sources competing for two one-bit fingerprints. + let bits = FingerprintBits::new(1).unwrap_or_else(|| panic!("valid width")); + let sites = ["// source zero", "// source one", "// source two"].map(|source| Site { + file: SourceFile::Unnumbered(Arc::from(source)), + at: 10, + role: 2, + }); + // When: the production function receives the explicit narrow width. + let names = sites.map(|site| site.variable_name_with_bits("", bits)); + // Then: the collision path is reachable without globals or a test-only hasher. + assert!(names[0] == names[1] || names[0] == names[2] || names[1] == names[2]); + assert!(names.iter().all(|name| name.starts_with("---SUH"))); +} diff --git a/libs/extractor/Cargo.toml b/libs/extractor/Cargo.toml index 33565e1c7..22d4fc31d 100644 --- a/libs/extractor/Cargo.toml +++ b/libs/extractor/Cargo.toml @@ -11,6 +11,9 @@ categories = ["development-tools", "wasm", "web-programming"] [lints] workspace = true +[features] +counter-fixtures = [] + [dependencies] oxc_parser = "0.151.0" oxc_syntax = "0.151.0" diff --git a/libs/extractor/src/as_member_boundary_tests.rs b/libs/extractor/src/as_member_boundary_tests.rs new file mode 100644 index 000000000..eeedeadd3 --- /dev/null +++ b/libs/extractor/src/as_member_boundary_tests.rs @@ -0,0 +1,34 @@ +use super::{As, rename, resolve}; +use crate::coverage_tests::expression; +use oxc_allocator::Allocator; +use oxc_ast::{ast::Expression, builder::AstBuilder}; +use rstest::rstest; + +#[rstest] +#[case("motion.button", "motion.button")] +#[case("motion.controls.button", "motion.controls.button")] +#[case("(motion.controls).button", "motion.controls.button")] +fn residual_member_name_retains_unresolved_receiver_chain( + #[case] source: &str, + #[case] expected: &str, +) { + // Given: no declared object exists for the constant evaluator to fold. + let allocator = Allocator::default(); + let ast = AstBuilder::new(&allocator); + let Expression::JSXElement(mut element) = expression(&allocator, "
") else { + panic!("JSX fixture") + }; + let value = expression(&allocator, source); + // When + let resolved = resolve(&ast, &element, value, "div"); + // Then: observe both JSX names, not just successful resolution. + let As::Name(name) = resolved else { + panic!("static member must remain a JSX binding") + }; + rename(&ast, &mut element, name); + assert_eq!(element.opening_element.name.to_string(), expected); + let Some(closing) = &element.closing_element else { + panic!("paired JSX fixture") + }; + assert_eq!(closing.name.to_string(), expected); +} diff --git a/libs/extractor/src/as_visit.rs b/libs/extractor/src/as_visit.rs index 3676c7db3..b8dceb8f5 100644 --- a/libs/extractor/src/as_visit.rs +++ b/libs/extractor/src/as_visit.rs @@ -11,6 +11,10 @@ use oxc_syntax::operator::LogicalOperator; use crate::utils::unwrap_syntax_only; +#[cfg(test)] +#[path = "as_member_boundary_tests.rs"] +mod member_boundary_tests; + /// What an element with an `as` becomes pub enum As<'a> { /// The element renamed diff --git a/libs/extractor/src/assignment_blocker_support.rs b/libs/extractor/src/assignment_blocker_support.rs new file mode 100644 index 000000000..26b757d69 --- /dev/null +++ b/libs/extractor/src/assignment_blocker_support.rs @@ -0,0 +1,44 @@ +use crate::{ + ExtractStyleValue, + assignment_test_support::{evaluate, extracted, jsx_js}, + extract_style::style_property::StyleProperty, +}; + +pub(super) fn selected(source: &str, result: &str) -> String { + let output = extracted(source); + let records = output + .styles + .iter() + .filter_map(|value| { + let style = value.extract(None)?; + match (value, style) { + (ExtractStyleValue::Static(value), StyleProperty::ClassName(class)) => Some(( + class, + format!("{}:{}:{}", value.property, value.value, value.level), + )), + ( + ExtractStyleValue::Dynamic(value), + StyleProperty::Variable { + class_name, + variable_name, + .. + }, + ) => Some(( + class_name, + format!( + "{}:var({}):{}", + value.property(), + variable_name, + value.level() + ), + )), + _ => None, + } + }) + .collect::>(); + let records = serde_json::to_string(&records).unwrap_or_else(|error| panic!("{error}")); + evaluate(&format!( + "{} const records={records}; const selected=node=>String(node.className??'').split(/\\s+/).filter(Boolean).map(name=>{{const entry=records.find(([key])=>key===name);if(!entry)throw Error('unbound class '+name);return entry[1]}}).sort(); const assigned=node=>selected(node).map(value=>value.replace(/var\\(([^)]+)\\)/g,(_,variable)=>String(node.style[variable]))); {result}", + jsx_js(&output.code) + )) +} diff --git a/libs/extractor/src/assignment_blocker_tests.rs b/libs/extractor/src/assignment_blocker_tests.rs new file mode 100644 index 000000000..b07c65e8e --- /dev/null +++ b/libs/extractor/src/assignment_blocker_tests.rs @@ -0,0 +1,150 @@ +use crate::assignment_blocker_support::selected; +use crate::assignment_test_support::{compiled_jsx, evaluate}; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case( + false, + "[\"color:red:0\",\"padding:4px:0\"]", + "[\"background:red:0\",\"margin:12px:0\"]" +)] +#[case( + true, + "[\"color:blue:0\",\"color:green:0\",\"padding:4px:0\"]", + "[\"margin:8px:0\"]" +)] +#[serial] +fn composition_selects_source_rules_when_conditions_change( + #[case] flag: bool, + #[case] a: &str, + #[case] b: &str, +) { + // Given: the exact rejected compositions with distinct overridden values. + let source = format!( + "import {{css}} from '@devup-ui/react';function render(cond,flag){{return [css({{color:'red',p:1}},cond&&{{color:'blue',_hover:{{color:'green'}}}}),css([{{m:1}},flag?{{m:2}}:{{m:3,bg:'red'}}])]}}const nodes=render({flag},{flag});" + ); + // When: both class strings are evaluated and matched to emitted declarations. + let actual = selected( + &source, + "JSON.stringify(nodes.map(className=>selected({className})));", + ); + // Then: each condition chooses only its authored rules. + assert_eq!(actual, format!("[{a},{b}]")); +} + +#[rstest] +#[case(true, "[\"box-shadow:none:0\",\"box-shadow:none:2\"]")] +#[case(false, "[]")] +#[serial] +fn nested_selector_keeps_branch_bindings_when_responsive_styles_are_selected( + #[case] enabled: bool, + #[case] expected: &str, +) { + // Given: an AND style object with a nested responsive assignment. + let source = format!( + "import {{Box}} from '@devup-ui/react';function render(enabled){{return }}const node=render({enabled});" + ); + // When: the actual emitted branch executes. + let actual = selected(&source, "JSON.stringify(selected(node));"); + // Then: the enabled branch is bound and the disabled branch is empty. + assert_eq!(actual, expected); +} + +#[rstest] +#[case("sm", false, "12px")] +#[case("md", true, "28px")] +#[case("lg", false, "20px")] +#[serial] +fn nested_shorthand_members_keep_classes_when_consumers_coalesce( + #[case] size: &str, + #[case] icon: bool, + #[case] value: &str, +) { + // Given: px's two consumers and the exact nested icon/size choices. + let source = format!( + "import {{Box}} from '@devup-ui/react';function render(size,icon){{return }}const node=render('{size}',{icon});" + ); + // When: the selected classes execute through both maps. + let actual = selected(&source, "JSON.stringify(selected(node));"); + // Then: both padding sides use the source's selected value. + assert_eq!( + actual, + format!("[\"padding-left:{value}:0\",\"padding-right:{value}:0\"]"), + "{}", + compiled_jsx(&source) + ); +} + +#[rstest] +#[case("[1,0.5,...some][a]", "some:[0.25],a:2")] +#[case("[...arr,1][idx]", "arr:[0.25],idx:0")] +#[serial] +fn spread_array_selects_raw_value_when_index_is_dynamic( + #[case] expression: &str, + #[case] input: &str, +) { + // Given: each distinct rejected spread-array shape selects the spread's numeric value. + let source = format!( + "import {{Box}} from '@devup-ui/react';function render({{some,arr,a,idx}}){{return }}const node=render({{{input}}});JSON.stringify([String(node.className??'').split(/\\s+/).filter(Boolean).length,Object.values(node.style??{{}})]);" + ); + // When: the actual class and inline assignment are evaluated jointly. + let actual = evaluate(&compiled_jsx(&source)); + // Then: a dynamic class exists and receives 0.25 rather than tuple projections of a number. + assert_eq!(actual, "[1,[0.25]]"); +} + +#[rstest] +#[case( + "}>{state.child}", + "[\"f\",\"tag\",\"child\"]" +)] +#[case( + "jsx(Box,{...f(),[state.key]:state.value})", + "[\"f\",\"key\",\"value\"]" +)] +#[case( + "jsx(Box,{className:'z',onClick:state.click,[state.key]:2,...f(),[state.key]:1,title:'x'})", + "[\"click\",\"key\",\"f\",\"key\"]" +)] +#[case("", "[\"rest\"]")] +#[serial] +fn spread_reads_remain_once_and_ordered_when_adjacent_fields_execute( + #[case] expression: &str, + #[case] expected: &str, +) { + // Given: getters expose computed keys, element type and children around spreads. + let source = format!( + "import {{Box}} from '@devup-ui/react';import {{jsx}} from 'react/jsx-runtime';function render(state,f){{return {expression}}}let trace=[];const state={{}};for(const [key,value]of Object.entries({{tag:'b',child:'child',key:'x',value:3,click:'handler',rest:{{id:1}}}}))Object.defineProperty(state,key,{{get(){{trace.push(key);return value}}}});const f=()=>{{trace.push('f');return {{id:2}}}};const jsx=(tag,props)=>props;const node=render(state,f);JSON.stringify(trace);" + ); + // When: emitted full props execute in Boa. + let actual = evaluate(&compiled_jsx(&source)); + // Then: source order and count, including both authored key reads, remain exact. + assert_eq!(actual, expected); +} + +#[rstest] +#[case("({lg:'buttonLg',md:'button',sm:'buttonSm',tag:'tag'}as const)[state.size]")] +#[case("({lg:'buttonLg',md:'button',sm:'buttonSm',tag:'tag'})[state.size]")] +#[serial] +fn typography_reads_stay_before_children_when_literal_maps_are_selected(#[case] expression: &str) { + // Given: both rejected typography forms and observable key/child accessors. + let source = format!( + "import {{Text}} from '@devup-ui/react';function render(state){{return {{state.child}}}}let trace=[];const state={{get size(){{trace.push('size');return 'sm'}},get child(){{trace.push('child');return 'text'}}}};const node=render(state);JSON.stringify(trace);" + ); + // When: emitted attributes and children execute. + let actual = evaluate(&compiled_jsx(&source)); + // Then: the source's lookup precedes its child. + assert_eq!(actual, "[\"size\",\"child\"]"); +} + +#[test] +#[serial] +fn jsx_call_reads_order_before_background_when_controller_changes() { + // Given: consecutive controller reads differ, so reversing them changes both outputs. + let source = "import {Box}from '@devup-ui/react';import {jsx}from 'react/jsx-runtime';function render(state){return jsx(Box,{styleOrder:state.active?5:10,bg:state.active?'red':'blue'})}let count=0;const state={get active(){return ++count===1}};const jsx=(tag,props)=>props;const node=render(state);"; + // When: actual emitted classes select an order and a value. + let actual = selected(source, "JSON.stringify([count,selected(node)]);"); + // Then: the second read selects blue while the first selects order 5 (order asserted separately). + assert_eq!(actual, "[2,[\"background:blue:0\"]]"); +} diff --git a/libs/extractor/src/assignment_capture.rs b/libs/extractor/src/assignment_capture.rs new file mode 100644 index 000000000..4ee3c7f26 --- /dev/null +++ b/libs/extractor/src/assignment_capture.rs @@ -0,0 +1,205 @@ +use oxc_allocator::{FromIn, GetAllocator}; +use oxc_ast::{ + ast::{Expression, ObjectProperty, ObjectPropertyKind, Str}, + builder::AstBuilder, +}; +use oxc_ast_visit::VisitMut; +use oxc_span::{GetSpan, SPAN}; + +use crate::{ExtractStyleProp, utils::call_with_values}; + +#[path = "assignment_creation_projection.rs"] +mod creation_projection; + +#[cfg(test)] +#[path = "styled_creation_tests.rs"] +mod styled_creation_tests; + +pub(crate) fn coalesce(styles: &mut [ExtractStyleProp<'_>]) { + for index in 0..styles.len() { + let (previous, rest) = styles.split_at_mut(index); + if let Some(ExtractStyleProp::Evaluated { binding, styles: consumers, .. }) = rest.first_mut() + && let Some(ExtractStyleProp::Evaluated { styles: owner, .. }) = previous.iter_mut().find(|style| { + matches!(style, ExtractStyleProp::Evaluated { binding: existing, .. } if existing == binding) + }) + { + owner.append(consumers); + crate::assignment_consumers::merge(owner); + rest[0] = ExtractStyleProp::StaticArray(vec![]); + } + } +} + +pub(crate) fn take_evaluations<'a>( + styles: &mut [ExtractStyleProp<'a>], +) -> Vec<(String, Expression<'a>)> { + let mut values = Vec::new(); + for style in styles { + match style { + ExtractStyleProp::Evaluated { + binding, + evaluation, + .. + } => { + if let Some(value) = evaluation.take() { + values.push((binding.clone(), value)); + } + } + ExtractStyleProp::StaticArray(styles) => values.extend(take_evaluations(styles)), + _ => {} + } + } + values +} + +pub(crate) fn orders( + styles: &mut [ExtractStyleProp<'_>], + alternate: Option, + is_alternate: bool, +) { + for style in styles { + match style { + ExtractStyleProp::Evaluated { + alternate_order, + alternate_class, + .. + } => { + *alternate_order = + Some(crate::assignment_lowering::AlternateOrder { value: alternate }); + *alternate_class = is_alternate; + } + ExtractStyleProp::StaticArray(styles) => orders(styles, alternate, is_alternate), + _ => {} + } + } +} + +pub(crate) fn class_expression<'a>( + ast: &AstBuilder<'a>, + styles: &mut [ExtractStyleProp<'a>], + expression: Option>, +) -> Option> { + let mut values = take_evaluations(styles); + values.sort_by_key(|(_, value)| crate::provenance::source_offset(value.span().start)); + expression.map(|expression| { + if values.is_empty() { + expression + } else { + call_with_values(ast, values, expression) + } + }) +} + +pub(crate) fn component<'a>( + ast: &AstBuilder<'a>, + component: &mut Expression<'a>, + styles: &mut [ExtractStyleProp<'a>], +) { + let mut values = take_evaluations(styles); + values.sort_by_key(|(_, value)| crate::provenance::source_offset(value.span().start)); + if !values.is_empty() + && let Expression::ArrowFunctionExpression(component) = component + && let Some(body) = component.body.as_expression_mut() + { + let original = std::mem::replace(body, Expression::new_null_literal(SPAN, ast)); + *body = call_with_values(ast, values, original); + } +} + +/// Literal fields are evaluated when styled is created, not by its render body. +pub(crate) struct StyledCreation<'b, 'a> { + pub(crate) source: &'b Expression<'a>, + pub(crate) styles: &'b mut [ExtractStyleProp<'a>], +} + +pub(crate) fn styled_creation<'a>( + ast: &AstBuilder<'a>, + component: &mut Expression<'a>, + input: StyledCreation<'_, 'a>, +) { + creation_projection::styled_creation(ast, component, input); +} + +fn literal_fields<'b, 'a>( + source: &'b Expression<'a>, + fields: &mut Vec<(&'b Expression<'a>, bool)>, +) { + if let Expression::ObjectExpression(object) = crate::utils::unwrap_syntax_only(source) { + for property in &object.properties { + match property { + ObjectPropertyKind::ObjectProperty(property) => { + let value = &property.value; + match crate::utils::unwrap_syntax_only(value) { + Expression::ObjectExpression(_) => literal_fields(value, fields), + Expression::ArrowFunctionExpression(_) + | Expression::FunctionExpression(_) => {} + _ if crate::static_assignment::literal_source(value) => {} + _ => fields.push(( + value, + property.key.name().is_some_and(|name| name == "typography"), + )), + } + } + ObjectPropertyKind::SpreadProperty(spread) => { + literal_fields(&spread.argument, fields); + } + } + } + } +} + +struct CreationReference { + code: String, + name: String, + span: oxc_span::Span, + typography: bool, + variables: Vec, +} + +struct CreationReferences<'b, 'a> { + ast: &'b AstBuilder<'a>, + replacements: &'b [CreationReference], +} + +impl<'a> VisitMut<'a> for CreationReferences<'_, 'a> { + fn visit_expression(&mut self, expression: &mut Expression<'a>) { + if matches!(expression, Expression::ArrowFunctionExpression(function) if function.span != SPAN) + || matches!(expression, Expression::FunctionExpression(_)) + { + return; + } + let code = crate::utils::expression_to_code(expression); + if let Some(reference) = self.replacements.iter().find(|reference| { + reference.code == code + && (reference.span == expression.span() + || expression.span() == SPAN && reference.typography) + }) { + *expression = Expression::new_identifier( + expression.span(), + Str::from_in(reference.name.as_str(), self.ast.allocator()), + self.ast, + ); + } else { + oxc_ast_visit::walk_mut::walk_expression(self, expression); + } + } + + fn visit_object_property(&mut self, property: &mut ObjectProperty<'a>) { + if let Some(reference) = self.replacements.iter().find(|reference| { + property.key.name().is_some_and(|key| { + reference + .variables + .iter() + .any(|variable| variable == key.as_ref()) + }) + }) { + property.value = Expression::new_identifier( + property.value.span(), + Str::from_in(reference.name.as_str(), self.ast.allocator()), + self.ast, + ); + } else { + oxc_ast_visit::walk_mut::walk_object_property(self, property); + } + } +} diff --git a/libs/extractor/src/assignment_class_reference.rs b/libs/extractor/src/assignment_class_reference.rs new file mode 100644 index 000000000..0c237be31 --- /dev/null +++ b/libs/extractor/src/assignment_class_reference.rs @@ -0,0 +1,34 @@ +use oxc_ast::{ast::Expression, builder::AstBuilder}; +use oxc_ast_visit::VisitMut; +use oxc_span::{GetSpan, Span}; + +use crate::utils::expression_to_code; + +pub(super) fn replace<'a>( + ast: &AstBuilder<'a>, + source: &Expression<'a>, + class: &mut Expression<'a>, +) { + ClassReference { + ast, + span: source.span(), + code: expression_to_code(source), + } + .visit_expression(class); +} + +struct ClassReference<'b, 'a> { + ast: &'b AstBuilder<'a>, + span: Span, + code: String, +} + +impl<'a> VisitMut<'a> for ClassReference<'_, 'a> { + fn visit_expression(&mut self, expression: &mut Expression<'a>) { + if expression.span() == self.span && expression_to_code(expression) == self.code { + *expression = Expression::new_identifier(expression.span(), "__devupValue", self.ast); + } else { + oxc_ast_visit::walk_mut::walk_expression(self, expression); + } + } +} diff --git a/libs/extractor/src/assignment_composition.rs b/libs/extractor/src/assignment_composition.rs new file mode 100644 index 000000000..f595762ab --- /dev/null +++ b/libs/extractor/src/assignment_composition.rs @@ -0,0 +1,199 @@ +use crate::utils::{ + call_with_values, get_string_by_literal_expression, unwrap_syntax_only, unwrap_syntax_only_mut, +}; +use oxc_allocator::{CloneIn, FromIn, GetAllocator}; +use oxc_ast::{ + ast::{ + Argument, ArrayExpressionElement, BinaryOperator, ComputedMemberExpression, Expression, + LogicalOperator, Str, + }, + builder::AstBuilder, +}; +use oxc_span::{GetSpan, GetSpanMut}; + +pub(crate) fn capture<'a>( + ast: &AstBuilder<'a>, + arguments: &mut [Argument<'a>], +) -> Vec<(String, Expression<'a>)> { + let mut captures = Captures { + ast, + reads: Vec::new(), + }; + for argument in arguments { + if let Some(value) = argument.as_expression_mut() { + captures.part(value); + } + } + captures.reads +} + +struct Captures<'b, 'a> { + ast: &'b AstBuilder<'a>, + reads: Vec<(String, Expression<'a>)>, +} + +impl<'a> Captures<'_, 'a> { + fn read(&mut self, value: &mut Expression<'a>) { + let ast = self.ast; + if get_string_by_literal_expression(value).is_some() { + return; + } + let name = format!("__devupCompose{}", self.reads.len()); + let reference = Expression::new_identifier( + value.span(), + Str::from_in(name.as_str(), ast.allocator()), + ast, + ); + self.reads.push((name, std::mem::replace(value, reference))); + } + + fn part(&mut self, value: &mut Expression<'a>) { + let ast = self.ast; + match unwrap_syntax_only_mut(value) { + Expression::ArrayExpression(array) => { + for element in &mut array.elements { + if let Some(value) = element.as_expression_mut() { + self.part(value); + } + } + } + Expression::ConditionalExpression(conditional) => { + let con = class(&conditional.consequent, ast); + let alt = class(&conditional.alternate, ast); + let name = self.choice( + &mut conditional.test, + con.clone_in(ast.allocator()), + alt.clone_in(ast.allocator()), + ); + if con.is_some() { + conditional.consequent = slot(ast, &name, 1); + } + if alt.is_some() { + conditional.alternate = slot(ast, &name, 1); + } + } + Expression::LogicalExpression(logical) => { + if logical.operator == LogicalOperator::And + || !matches!( + unwrap_syntax_only(&logical.left), + Expression::ObjectExpression(_) + ) + { + let right = class(&logical.right, ast); + let name = if logical.operator == LogicalOperator::And { + self.choice(&mut logical.left, right.clone_in(ast.allocator()), None) + } else { + let original = logical.left.clone_in(ast.allocator()); + let controller = + Expression::new_identifier(oxc_span::SPAN, "__devupChoice", ast); + let test = if logical.operator == LogicalOperator::Coalesce { + Expression::new_binary_expression( + oxc_span::SPAN, + controller.clone_in(ast.allocator()), + BinaryOperator::Inequality, + Expression::new_null_literal(oxc_span::SPAN, ast), + ast, + ) + } else { + controller.clone_in(ast.allocator()) + }; + let selected = Expression::new_conditional_expression( + oxc_span::SPAN, + test, + controller.clone_in(ast.allocator()), + right.clone_in(ast.allocator()).unwrap_or_else(|| { + Expression::new_string_literal(oxc_span::SPAN, "", None, ast) + }), + ast, + ); + self.pair(&mut logical.left, original, selected) + }; + if right.is_some() { + logical.right = slot(ast, &name, 1); + } + } + } + Expression::Identifier(identifier) if identifier.name == "undefined" => {} + Expression::Identifier(_) + | Expression::StaticMemberExpression(_) + | Expression::ComputedMemberExpression(_) + | Expression::TemplateLiteral(_) => self.read(value), + _ => {} + } + } + + fn choice( + &mut self, + value: &mut Expression<'a>, + con: Option>, + alt: Option>, + ) -> String { + let ast = self.ast; + let original = value.clone_in(ast.allocator()); + let controller = Expression::new_identifier(oxc_span::SPAN, "__devupChoice", ast); + let selected = Expression::new_conditional_expression( + oxc_span::SPAN, + controller.clone_in(ast.allocator()), + con.unwrap_or_else(|| Expression::new_string_literal(oxc_span::SPAN, "", None, ast)), + alt.unwrap_or_else(|| Expression::new_string_literal(oxc_span::SPAN, "", None, ast)), + ast, + ); + self.pair(value, original, selected) + } + + fn pair( + &mut self, + value: &mut Expression<'a>, + original: Expression<'a>, + selected: Expression<'a>, + ) -> String { + let ast = self.ast; + let controller = Expression::new_identifier(oxc_span::SPAN, "__devupChoice", ast); + let name = format!("__devupCompose{}", self.reads.len()); + let tuple = Expression::new_array_expression( + oxc_span::SPAN, + oxc_allocator::Vec::from_array_in( + [ + ArrayExpressionElement::from(controller), + ArrayExpressionElement::from(selected), + ], + ast, + ), + ast, + ); + let result = call_with_values(ast, vec![("__devupChoice".to_string(), original)], tuple); + self.reads.push((name.clone(), result)); + let span = value.span(); + *value = slot(ast, &name, 0); + *value.span_mut() = span; + name + } +} + +fn class<'a>(value: &Expression<'a>, ast: &AstBuilder<'a>) -> Option> { + match unwrap_syntax_only(value) { + Expression::Identifier(identifier) if identifier.name == "undefined" => None, + Expression::Identifier(_) + | Expression::StaticMemberExpression(_) + | Expression::ComputedMemberExpression(_) + | Expression::StringLiteral(_) + | Expression::TemplateLiteral(_) => Some(value.clone_in(ast.allocator())), + _ => None, + } +} + +fn slot<'a>(ast: &AstBuilder<'a>, name: &str, index: u8) -> Expression<'a> { + Expression::ComputedMemberExpression(ComputedMemberExpression::boxed( + oxc_span::SPAN, + Expression::new_identifier(oxc_span::SPAN, Str::from_in(name, ast.allocator()), ast), + Expression::new_numeric_literal( + oxc_span::SPAN, + f64::from(index), + None, + oxc_syntax::number::NumberBase::Decimal, + ast, + ), + false, + ast, + )) +} diff --git a/libs/extractor/src/assignment_consumers.rs b/libs/extractor/src/assignment_consumers.rs new file mode 100644 index 000000000..62b971949 --- /dev/null +++ b/libs/extractor/src/assignment_consumers.rs @@ -0,0 +1,90 @@ +use crate::{ExtractStyleProp, utils::expression_to_code}; +use oxc_span::GetSpan; + +pub(crate) fn merge(styles: &mut Vec>) { + let mut result = Vec::new(); + for style in std::mem::take(styles) { + match style { + ExtractStyleProp::StaticArray(mut inner) => { + merge(&mut inner); + result.extend(inner); + } + style => result.push(style), + } + } + for style in result { + let existing = styles + .iter_mut() + .find(|existing| match (&style, &**existing) { + ( + ExtractStyleProp::Conditional { + condition: left, .. + }, + ExtractStyleProp::Conditional { + condition: right, .. + }, + ) + | ( + ExtractStyleProp::MemberExpression { + expression: left, .. + }, + ExtractStyleProp::MemberExpression { + expression: right, .. + }, + ) => { + left.span() == right.span() + && expression_to_code(left) == expression_to_code(right) + } + _ => false, + }); + match (existing, style) { + ( + Some(ExtractStyleProp::Conditional { + consequent, + alternate, + .. + }), + ExtractStyleProp::Conditional { + consequent: added_con, + alternate: added_alt, + .. + }, + ) => { + append_branch(consequent, added_con); + append_branch(alternate, added_alt); + } + ( + Some(ExtractStyleProp::MemberExpression { map, .. }), + ExtractStyleProp::MemberExpression { map: added, .. }, + ) => { + for (key, added) in added { + let selected = map + .entry(key) + .or_insert_with(|| Box::new(ExtractStyleProp::StaticArray(vec![]))); + let original = + std::mem::replace(selected.as_mut(), ExtractStyleProp::StaticArray(vec![])); + let mut consumers = vec![original, *added]; + merge(&mut consumers); + **selected = ExtractStyleProp::StaticArray(consumers); + } + } + (_, style) => styles.push(style), + } + } +} + +fn append_branch<'a>( + branch: &mut Option>>, + added: Option>>, +) { + if let Some(added) = added { + let mut styles = branch + .take() + .into_iter() + .map(|style| *style) + .chain([*added]) + .collect(); + merge(&mut styles); + *branch = Some(Box::new(ExtractStyleProp::StaticArray(styles))); + } +} diff --git a/libs/extractor/src/assignment_container_tests.rs b/libs/extractor/src/assignment_container_tests.rs new file mode 100644 index 000000000..73815d3c6 --- /dev/null +++ b/libs/extractor/src/assignment_container_tests.rs @@ -0,0 +1,145 @@ +use crate::assignment_blocker_support::selected; +use crate::assignment_test_support::{compiled_jsx, evaluate, extracted}; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case("{state.child}")] +#[case(" span':{p:state.pad}}} id={state.id}>{state.child}")] +#[case("jsx(Box,{_hover:{p:state.pad},id:state.id,children:state.child})")] +#[case("jsx(Box,{selectors:{'& > span':{p:state.pad}},id:state.id,children:state.child})")] +#[serial] +fn selector_container_assigns_leaf_when_adjacent_reads_are_captured(#[case] expression: &str) { + // Given: an observable scalar leaf inside a selector object and later operands. + let source = format!( + "import {{Box}}from '@devup-ui/react';import {{jsx}}from 'react/jsx-runtime';function render(state){{return {expression}}}let trace=[];const state={{get pad(){{trace.push('pad');return '16px'}},get id(){{trace.push('id');return 'target'}},get child(){{trace.push('child');return 'text'}}}};const jsx=(tag,props)=>props;const node=render(state);" + ); + // When: actual emitted classes are joined to their actual inline variables. + let actual = selected(&source, "JSON.stringify([trace,assigned(node),node.id]);"); + // Then: padding receives the leaf, never the containing object, in source order. + assert_eq!( + actual, + "[[\"pad\",\"id\",\"child\"],[\"padding:16px:0\"],\"target\"]", + "{}", + compiled_jsx(&source) + ); +} + +#[rstest] +#[case("{state.child}")] +#[case( + " span':{['p']:state.pad,color:state.color},'& > b':{p:state.other}}} id={state.id}>{state.child}" +)] +#[case("jsx(Box,{_hover:{['p']:state.pad,color:state.color},id:state.id,children:state.child})")] +#[serial] +fn selector_container_keeps_variable_associations_when_properties_are_distinct( + #[case] expression: &str, +) { + // Given: different property values, computed literal keys and adjacent getters. + let source = format!( + "import {{Box}}from '@devup-ui/react';import {{jsx}}from 'react/jsx-runtime';function render(state){{return {expression}}}let trace=[];const state={{}};for(const[key,value]of Object.entries({{pad:'16px',color:'red',other:'24px',id:'target',child:'text'}}))Object.defineProperty(state,key,{{get(){{trace.push(key);return value}}}});const jsx=(tag,props)=>props;const node=render(state);" + ); + let descendant = expression.contains("selectors"); + // When: selected classes consume the variable assigned by their authored leaf. + let actual = selected(&source, "JSON.stringify([trace,assigned(node)]);"); + // Then: different leaves neither alias nor receive an entire object. + let expected = if descendant { + "[[\"pad\",\"color\",\"other\",\"id\",\"child\"],[\"color:red:0\",\"padding:16px:0\",\"padding:24px:0\"]]" + } else { + "[[\"pad\",\"color\",\"id\",\"child\"],[\"color:red:0\",\"padding:16px:0\"]]" + }; + assert_eq!(actual, expected); +} + +#[rstest] +#[case("0")] +#[case("false")] +#[case("null")] +#[case("undefined")] +#[case("''")] +#[case("NaN")] +#[serial] +fn and_zero_is_no_style_when_controller_is_falsy(#[case] controller: &str) { + // Given: main/#751's AND style guard with observable left and right getters. + let source = format!( + "import {{Box}}from '@devup-ui/react';function render(state){{return }}let trace=[];const state={{get a(){{trace.push('a');return {controller}}},get b(){{trace.push('b');return '16px'}},get id(){{trace.push('id');return 'target'}}}};const node=render(state);JSON.stringify([trace,String(node.className??'').trim(),Object.values(node.style??{{}})]);" + ); + // When: the compiled guard selects its no-style branch. + let actual = evaluate(&compiled_jsx(&source)); + // Then: even zero supplies no class/value, a runs once and b never runs. + assert_eq!(actual, "[[\"a\",\"id\"],\"\",[]]"); +} + +#[test] +#[serial] +fn literal_selectors_keep_compiling_when_children_are_conditional_arrays() { + // Given: literal selectors/keys and lazy conditional array children. + let source = "import {Box,css}from '@devup-ui/react';function render(state){return span':{['p']:state.pad}}} id={state.id}>{state.flag?[state.child]:[]}}let trace=[];const state={pad:'16px',id:'target',flag:false,get child(){trace.push('child');return 'text'}};const node=render(state);"; + // When: the actual output executes the false child branch. + let actual = selected( + source, + "JSON.stringify([trace,assigned(node),node.children]);", + ); + // Then: selector classes remain selected and the unused child is not read. + assert_eq!(actual, "[[],[\"padding:16px:0\"],[[]]]"); +} + +#[test] +#[serial] +fn css_and_literal_keeps_compiling_when_background_is_guarded() { + // Given: the previously valid main/#751 literal CSS guard. + let source = "import {css}from '@devup-ui/react';const LIGHT='white';function render(dark){return css({bg:dark&&LIGHT})}const node=render(false);"; + // When: extraction compiles the guard without rejecting valid CSS. + let output = extracted(source); + // Then: its no-style branch executes without a generated class. + assert_eq!( + evaluate(&format!( + "{}JSON.stringify(node);", + crate::assignment_test_support::jsx_js(&output.code) + )), + "\"\"" + ); +} + +#[rstest] +#[case( + " span':{p:state.pad},'& > b':{p:state.pad}}} id={state.id}>{state.child}" +)] +#[case( + "jsx(Box,{selectors:{'& > span':{p:state.pad},'& > b':{p:state.pad}},id:state.id,children:state.child})" +)] +#[serial] +fn selector_container_keeps_source_sites_when_repeated_getters_return_different_values( + #[case] expression: &str, +) { + // Given: BOM/CRLF/multibyte source and identical getter syntax at different sites. + let source = format!( + "\u{feff}import {{Box}}from '@devup-ui/react';\r\nimport {{jsx}}from 'react/jsx-runtime';\r\nconst label='한';function render(state){{return {expression}}}let trace=[];let reads=0;const state={{get pad(){{trace.push('pad');return ++reads===1?'16px':'24px'}},get id(){{trace.push('id');return 'target'}},get child(){{trace.push('child');return 'text'}}}};const jsx=(tag,props)=>props;const node=render(state);" + ); + // When: each selected class consumes its own source assignment. + let actual = selected(&source, "JSON.stringify([trace,assigned(node)]);"); + // Then: repeated syntax remains two ordered reads with different variable values. + assert_eq!( + actual, + "[[\"pad\",\"pad\",\"id\",\"child\"],[\"padding:16px:0\",\"padding:24px:0\"]]" + ); +} + +#[rstest] +#[case("0")] +#[case("false")] +#[case("null")] +#[case("undefined")] +#[case("''")] +#[case("NaN")] +#[serial] +fn css_and_zero_is_no_style_when_controller_is_falsy(#[case] controller: &str) { + // Given: the same style-guard convention on the css() caller path. + let source = format!( + "import {{css}}from '@devup-ui/react';function render(state){{return css({{bg:state.a&&'white'}})}}let reads=0;const state={{get a(){{reads++;return {controller}}}}};const node=render(state);JSON.stringify([reads,node]);" + ); + // When: the falsy controller selects no CSS class. + let actual = evaluate(&compiled_jsx(&source)); + // Then: all falsy controllers, including zero, are read once and yield no style. + assert_eq!(actual, "[1,\"\"]"); +} diff --git a/libs/extractor/src/assignment_creation_projection.rs b/libs/extractor/src/assignment_creation_projection.rs new file mode 100644 index 000000000..74ec9803b --- /dev/null +++ b/libs/extractor/src/assignment_creation_projection.rs @@ -0,0 +1,76 @@ +use super::{ + CreationReference, CreationReferences, ExtractStyleProp, StyledCreation, call_with_values, + literal_fields, take_evaluations, +}; +use oxc_allocator::{CloneIn, GetAllocator}; +use oxc_ast::{ast::Expression, builder::AstBuilder}; +use oxc_ast_visit::VisitMut; +use oxc_span::{GetSpan, SPAN}; + +pub(super) fn styled_creation<'a>( + ast: &AstBuilder<'a>, + component: &mut Expression<'a>, + input: StyledCreation<'_, 'a>, +) { + let mut values = take_evaluations(input.styles); + let evaluation_count = values.len(); + let mut fields = Vec::new(); + literal_fields(input.source, &mut fields); + let records = input + .styles + .iter() + .flat_map(ExtractStyleProp::extract) + .collect::>(); + let source_code = crate::utils::expression_to_code(input.source); + let component_code = crate::utils::expression_to_code(component); + let mut replacements = Vec::new(); + for (field, typography) in fields { + if values.iter().any(|(_, value)| value.span() == field.span()) { + continue; + } + let mut name = format!( + "__devupStyled{}", + crate::provenance::source_offset(field.span().start) + ); + while source_code.contains(&name) || component_code.contains(&name) { + name.push('_'); + } + let start = field.span().start; + let site_span = oxc_span::Span::new(start, start.saturating_add(1)); + let variables = records + .iter() + .filter_map(|record| match record { + crate::ExtractStyleValue::Dynamic(style) + if style.site.as_ref().is_some_and(|site| { + crate::sparse_sites::contains_site(site_span, site) + }) => + { + crate::extract_style::compiler_projection::variable_for(style, start, &name) + } + _ => None, + }) + .collect(); + replacements.push(CreationReference { + code: crate::utils::expression_to_code(field), + name: name.clone(), + span: field.span(), + typography, + variables, + }); + values.push((name, field.clone_in(ast.allocator()))); + } + let mut replace = CreationReferences { + ast, + replacements: &replacements, + }; + replace.visit_expression(component); + // Only lowered evaluations use the references; capture arguments remain authored. + for (_, value) in values.iter_mut().take(evaluation_count) { + replace.visit_expression(value); + } + values.sort_by_key(|(_, value)| crate::provenance::source_offset(value.span().start)); + if !values.is_empty() { + let original = std::mem::replace(component, Expression::new_null_literal(SPAN, ast)); + *component = call_with_values(ast, values, original); + } +} diff --git a/libs/extractor/src/assignment_lowering.rs b/libs/extractor/src/assignment_lowering.rs new file mode 100644 index 000000000..6602058a7 --- /dev/null +++ b/libs/extractor/src/assignment_lowering.rs @@ -0,0 +1,214 @@ +use oxc_allocator::{CloneIn, FromIn, GetAllocator}; +use oxc_ast::{ + ast::{ + BinaryOperator, ChainElement, ComputedMemberExpression, Expression, LogicalOperator, Str, + UnaryOperator, + }, + builder::AstBuilder, +}; +use oxc_span::{GetSpan, SPAN}; +use oxc_syntax::number::NumberBase; + +pub(crate) use crate::assignment_capture::{ + class_expression, coalesce, component, orders, take_evaluations, +}; +use crate::{ + ExtractStyleProp, + utils::{call_with_values, unwrap_syntax_only}, +}; + +pub(crate) fn styled_argument<'a>( + ast: &AstBuilder<'a>, + source: &Expression<'a>, + styles: &mut Vec>, +) { + if matches!( + unwrap_syntax_only(source), + Expression::ConditionalExpression(_) + | Expression::LogicalExpression(_) + | Expression::ComputedMemberExpression(_) + ) && !crate::static_assignment::literal_source(source) + { + *styles = vec![ExtractStyleProp::Evaluated { + binding: crate::sparse_sites::binding_name(source.span().start), + styles: std::mem::take(styles), + source: source.clone_in(ast.allocator()), + evaluation: None, + alternate_order: None, + alternate_class: false, + }]; + } +} + +pub(crate) fn projection<'a>(ast: &AstBuilder<'a>, binding: &str, slot: u8) -> Expression<'a> { + let value = Expression::new_chain_expression( + SPAN, + ChainElement::ComputedMemberExpression(ComputedMemberExpression::boxed( + SPAN, + Expression::new_identifier(SPAN, Str::from_in(binding, ast.allocator()), ast), + Expression::new_numeric_literal(SPAN, f64::from(slot), None, NumberBase::Decimal, ast), + true, + ast, + )), + ast, + ); + if matches!(slot, 0 | 3) { + Expression::new_logical_expression( + SPAN, + value, + LogicalOperator::Coalesce, + Expression::new_string_literal(SPAN, "", None, ast), + ast, + ) + } else { + value + } +} + +pub(super) fn empty<'a>(ast: &AstBuilder<'a>) -> Expression<'a> { + Expression::new_object_expression(SPAN, oxc_allocator::Vec::new_in(ast), ast) +} + +pub(crate) struct Lowering<'b, 'a> { + pub(crate) ast: &'b AstBuilder<'a>, + pub(crate) order: Option, + pub(crate) filename: Option<&'b str>, + pub(crate) alternate_order: Option, +} + +#[derive(Debug, Clone, Copy)] +pub struct AlternateOrder { + pub(super) value: Option, +} + +pub(super) struct Classes<'a> { + pub(super) primary: Expression<'a>, + pub(super) alternate: Option>, +} + +impl<'a> Lowering<'_, 'a> { + pub(crate) fn lower( + &self, + source: &Expression<'a>, + styles: &mut [ExtractStyleProp<'a>], + ) -> Expression<'a> { + let ast = self.ast; + let source = unwrap_syntax_only(source); + if let [ + ExtractStyleProp::StaticArray(inner) + | ExtractStyleProp::Evaluated { styles: inner, .. }, + ] = styles + { + return self.lower(source, inner); + } + match (source, styles) { + ( + Expression::ConditionalExpression(source), + [ + ExtractStyleProp::Conditional { + consequent, + alternate, + .. + }, + ], + ) => Expression::new_conditional_expression( + SPAN, + source.test.clone_in(ast.allocator()), + self.branch(&source.consequent, consequent), + self.branch(&source.alternate, alternate), + ast, + ), + ( + Expression::LogicalExpression(source), + [ + ExtractStyleProp::Conditional { + consequent, + alternate, + .. + }, + ], + ) => { + let (left, right) = match source.operator { + LogicalOperator::And => (alternate, consequent), + LogicalOperator::Or | LogicalOperator::Coalesce => (consequent, alternate), + }; + let selected = self.branch(&source.left, left); + let value = projection(ast, "__devupLeft", 2); + let test = match source.operator { + LogicalOperator::And => Expression::new_unary_expression( + SPAN, + UnaryOperator::LogicalNot, + value, + ast, + ), + LogicalOperator::Or => value, + LogicalOperator::Coalesce => Expression::new_logical_expression( + SPAN, + Expression::new_binary_expression( + SPAN, + value.clone_in(ast.allocator()), + BinaryOperator::StrictInequality, + Expression::new_null_literal(SPAN, ast), + ast, + ), + LogicalOperator::And, + Expression::new_binary_expression( + SPAN, + value, + BinaryOperator::StrictInequality, + Expression::new_unary_expression( + SPAN, + UnaryOperator::Void, + Expression::new_numeric_literal( + SPAN, + 0.0, + None, + NumberBase::Decimal, + ast, + ), + ast, + ), + ast, + ), + ast, + ), + }; + let left = call_with_values( + ast, + vec![("__devupLeft".to_string(), selected)], + Expression::new_conditional_expression( + SPAN, + test, + Expression::new_identifier(SPAN, "__devupLeft", ast), + Expression::new_null_literal(SPAN, ast), + ast, + ), + ); + Expression::new_logical_expression( + SPAN, + left, + LogicalOperator::Coalesce, + self.branch(&source.right, right), + ast, + ) + } + (Expression::ComputedMemberExpression(source), styles) => self.member(source, styles), + (Expression::ArrayExpression(source), styles) => self.array(source, styles), + (Expression::ObjectExpression(source), styles) => self.object(source, styles), + (_, styles) => self.leaf(source, styles), + } + } + + fn branch( + &self, + source: &Expression<'a>, + styles: &mut Option>>, + ) -> Expression<'a> { + self.lower( + source, + styles + .as_mut() + .map_or(&mut [], |style| std::slice::from_mut(style.as_mut())), + ) + } +} diff --git a/libs/extractor/src/assignment_lowering_tests.rs b/libs/extractor/src/assignment_lowering_tests.rs new file mode 100644 index 000000000..9d00d579d --- /dev/null +++ b/libs/extractor/src/assignment_lowering_tests.rs @@ -0,0 +1,184 @@ +use crate::assignment_test_support::{compiled_jsx, evaluate, extracted, lowered}; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case("flag ? selected : absent", "const flag=true, selected='4px';")] +#[case("flag ? absent : selected", "const flag=false, selected='4px';")] +#[case("selected ?? absent", "const selected=0;")] +#[case("selected || absent", "const selected='4px';")] +#[case("selected && absent", "const selected=0;")] +#[case( + "[flag ? selected : absent, e, f, '2px']", + "const flag=true, selected='4px', e='8px', f='12px';" +)] +#[case( + "[flag ? selected : absent, e, f, nested ? '2px' : '3px']", + "const flag=true, nested=false, selected='4px', e='8px', f='12px';" +)] +#[case( + "[flag ? absent : selected, e, f, nested ? '2px' : '3px']", + "const flag=false, nested=true, selected='4px', e='8px', f='12px';" +)] +#[case( + "flag ? [selected, '2px'] : [absent, '3px']", + "const flag=true, selected='4px';" +)] +#[test] +#[serial] +fn unselected_branch_is_unread_when_it_is_in_the_tdz( + #[case] expression: &str, + #[case] setup: &str, +) { + // Given: the unselected branch cannot be read before its lexical declaration. + let compiled = lowered(expression); + // When: the lowered assignment is evaluated in that authored scope. + let actual = evaluate(&format!( + "{setup} const node={compiled}; let absent; JSON.stringify('ok');" + )); + // Then: selection succeeds without reading that branch. + assert_eq!(actual, "\"ok\""); +} + +#[rstest] +#[case("reads.flag ? reads.a : reads.b")] +#[case("reads.a ?? reads.b")] +#[case("reads.a || reads.b")] +#[case("reads.zero && reads.b")] +#[case("({a:reads.a,b:reads.b})[index()]")] +#[case("[[reads.a,1,reads.c],[reads.d,reads.e,2]][index()]")] +#[case("[, [reads.d,reads.e,reads.f]][index()]")] +#[case("[[reads.a,reads.b,reads.c],[reads.d,reads.e,reads.f]][index()]")] +#[case("({0:[reads.a,reads.b,reads.c],1:[reads.d,reads.e,reads.f]})[index()]")] +#[case("[reads.a, reads.b, reads.c]")] +#[case("({b:reads.b,a:reads.a})[key]")] +#[case("({a:reads.a,a:reads.b})['a']")] +#[test] +#[serial] +fn reads_follow_source_when_selections_and_responsive_values_are_lowered(#[case] expression: &str) { + // Given: getters and key coercion expose every read, including eager literal entries. + let setup = "let trace=[]; const reads={}; for(const [key,value] of Object.entries({flag:true,a:'4px',b:'8px',c:'12px',d:'16px',e:'20px',f:'24px',zero:0})) Object.defineProperty(reads,key,{get(){trace.push(key);return value}}); const index=()=>{trace.push('index');return 1}; const key={toString(){trace.push('key');return 'a'}};"; + let expected = evaluate(&format!( + "{setup} const value=({expression}); JSON.stringify(trace);" + )); + let compiled = lowered(expression); + // When: the generated class and inline assignment are evaluated together. + let actual = evaluate(&format!( + "{setup} const node={compiled}; JSON.stringify(trace);" + )); + // Then: the exact authored read order and count survive. + assert_eq!(actual, expected, "{expression}"); +} + +#[rstest] +#[case("state.choose ? state.a : state.b", "['4px']")] +#[case("state.a ?? state.b", "['4px']")] +#[case( + "[state.choose ? state.a : state.b, state.c, state.d, '2px']", + "['4px','12px','16px']" +)] +#[case("({a:state.a,b:state.b})[state.key]", "['8px']")] +#[case( + "[[state.a,1,state.c],[state.d,state.e,2]][state.index]", + "['16px','20px']" +)] +#[case("[, [state.d,state.e,state.f]][state.index]", "['16px','20px','24px']")] +#[case( + "[[state.a,state.b,state.c],[state.d,state.e,state.f]][state.index]", + "['16px','20px','24px']" +)] +#[case( + "({0:[state.a,state.b,state.c],1:[state.d,state.e,state.f]})[state.index]", + "['16px','20px','24px']" +)] +#[case("({a:state.a,a:state.b})['a']", "['8px']")] +#[case("({a:[state.a,state.b]})['a']", "['4px','8px']")] +#[test] +#[serial] +fn selected_values_survive_when_full_jsx_is_compiled( + #[case] expression: &str, + #[case] expected: &str, +) { + // Given: responsive and scalar selections with distinguishable branch values. + let source = format!( + "import {{Box}} from '@devup-ui/react'; function render(state){{return }} const node=render({{choose:true,a:'4px',b:'8px',c:'12px',d:'16px',e:'20px',f:'24px',key:'b',index:1}}); JSON.stringify(Object.values(node.style));" + ); + let compiled = compiled_jsx(&source); + // When: the emitted JSX's actual attributes are evaluated. + let actual = evaluate(&compiled); + // Then: only values belonging to the selected assignment reach its variables. + assert_eq!( + actual, + evaluate(&format!("JSON.stringify({expected})")), + "{compiled}" + ); +} + +#[test] +#[serial] +fn attribute_reads_stay_ordered_when_shorthand_and_children_share_capture() { + // Given: source attributes and children with observable getters around a selected shorthand. + let source = "import {Box} from '@devup-ui/react'; function render(state){return {state.child}} let trace=[]; const state={}; for(const [key,value] of Object.entries({before:'before',choose:true,a:'4px',b:'8px',after:'after',child:'child'})) Object.defineProperty(state,key,{get(){trace.push(key);return value}}); const node=render(state); JSON.stringify(trace);"; + let compiled = compiled_jsx(source); + // When: all consumers and adjacent authored reads execute through the emitted expression. + let actual = evaluate(&compiled); + // Then: px reads its selected value once, between the adjacent attributes and before its child. + assert_eq!( + actual, "[\"before\",\"choose\",\"a\",\"after\",\"child\"]", + "{compiled}" + ); +} + +#[test] +#[serial] +fn yields_stay_in_the_generator_when_selected_values_are_captured() { + // Given: lazy yielded alternatives and a later yielded attribute. + let source = "import {Box} from '@devup-ui/react'; function* render(state){return } const g=render({flag:true,a:'chosen',after:'later'}); const first=g.next(); const second=g.next('4px'); const last=g.next('title'); let absent; JSON.stringify([first.value,second.value,Object.values(last.value.style),last.value.title]);"; + let compiled = compiled_jsx(source); + // When: the authored generator is resumed in order. + let actual = evaluate(&compiled); + // Then: yields remain in that scope and the unselected branch remains unread. + assert_eq!( + actual, "[\"chosen\",\"later\",[\"4px\"],\"title\"]", + "{compiled}" + ); +} + +#[test] +#[serial] +fn conditional_order_selects_matching_classes_when_assignment_values_are_shared() { + // Given: both cascade orders consume the same responsive assignment. + let source = "import {Box} from '@devup-ui/react';function render(state){return };const nodes=[render({order:true,a:'4px',b:'8px'}),render({order:false,a:'4px',b:'8px'})];"; + let output = extracted(source); + let expected = [1, 2].map(|order| { + let mut classes = output + .styles + .iter() + .filter_map(|value| match value { + crate::ExtractStyleValue::Dynamic(style) if style.style_order() == Some(order) => { + match value.extract(None) { + Some(crate::extract_style::style_property::StyleProperty::Variable { + class_name, + .. + }) => Some(class_name), + _ => None, + } + } + _ => None, + }) + .collect::>(); + classes.sort(); + assert_eq!(classes.len(), 2); + classes + }); + // When: each input condition selects its declared cascade order. + let actual = evaluate(&format!( + "{} JSON.stringify(nodes.map(node=>node.className.split(' ').filter(Boolean).sort()));", + compiled_jsx(source) + )); + // Then: the selected classes agree with that input's emitted CSS order. + assert_eq!( + actual, + serde_json::to_string(&expected).unwrap_or_else(|error| panic!("{error}")) + ); +} diff --git a/libs/extractor/src/assignment_member.rs b/libs/extractor/src/assignment_member.rs new file mode 100644 index 000000000..f750ac8b2 --- /dev/null +++ b/libs/extractor/src/assignment_member.rs @@ -0,0 +1,121 @@ +use oxc_allocator::{CloneIn, GetAllocator}; +use oxc_ast::ast::{ComputedMemberExpression, Expression, ObjectPropertyKind}; + +use crate::{ + ExtractStyleProp, + assignment_lowering::Lowering, + utils::{ + get_number_by_literal_expression, get_string_by_literal_expression, + get_string_by_property_key, unwrap_syntax_only, + }, +}; + +impl<'a> Lowering<'_, 'a> { + pub(super) fn member( + &self, + source: &ComputedMemberExpression<'a>, + styles: &mut [ExtractStyleProp<'a>], + ) -> Expression<'a> { + let ast = self.ast; + let mut member = source.clone_in(ast.allocator()); + if get_string_by_literal_expression(&source.expression).is_some() { + return self.leaf( + &Expression::ComputedMemberExpression(oxc_allocator::Box::new_in(member, ast)), + styles, + ); + } + let map = match styles { + [ExtractStyleProp::MemberExpression { map, .. }] => Some(map), + _ => None, + }; + match unwrap_syntax_only(&source.object) { + Expression::ObjectExpression(object) => { + let mut object = object.clone_in(ast.allocator()); + let mut map = map; + for property in &mut object.properties { + if let ObjectPropertyKind::ObjectProperty(property) = property { + let key = get_string_by_property_key(&property.key); + if key.as_deref() == Some("__proto__") + && !property.computed + && !property.shorthand + { + continue; + } + let selected = map + .as_mut() + .and_then(|map| key.as_ref().and_then(|key| map.get_mut(key))); + property.value = match selected { + Some(selected) => { + self.lower(&property.value, std::slice::from_mut(selected.as_mut())) + } + None => self.lower(&property.value, &mut []), + }; + } + } + member.object = Expression::ObjectExpression(object); + Expression::ComputedMemberExpression(oxc_allocator::Box::new_in(member, ast)) + } + Expression::ArrayExpression(array) => { + let mut array = array.clone_in(ast.allocator()); + let mut map = map; + for (index, element) in array.elements.iter_mut().enumerate() { + if let Some(value) = element.as_expression_mut() { + let selected = map.as_mut().and_then(|map| map.get_mut(&index.to_string())); + *value = match selected { + Some(selected) => { + self.lower(value, std::slice::from_mut(selected.as_mut())) + } + None => self.lower(value, &mut []), + }; + } + } + member.object = Expression::ArrayExpression(array); + Expression::ComputedMemberExpression(oxc_allocator::Box::new_in(member, ast)) + } + _ => self.leaf( + &Expression::ComputedMemberExpression(oxc_allocator::Box::new_in(member, ast)), + styles, + ), + } + } +} + +pub(super) fn selected_array(source: &Expression<'_>) -> bool { + let Expression::ComputedMemberExpression(source) = unwrap_syntax_only(source) else { + return false; + }; + let key = get_string_by_literal_expression(&source.expression); + match unwrap_syntax_only(&source.object) { + Expression::ObjectExpression(object) => object + .properties + .iter() + .rev() + .find_map(|property| match property { + ObjectPropertyKind::ObjectProperty(property) + if get_string_by_property_key(&property.key).as_deref() == key.as_deref() => + { + Some(matches!( + unwrap_syntax_only(&property.value), + Expression::ArrayExpression(_) + )) + } + _ => None, + }) + .unwrap_or(false), + Expression::ArrayExpression(array) => array + .elements + .iter() + .enumerate() + .find_map(|(index, value)| { + (get_number_by_literal_expression(&source.expression) + == u32::try_from(index).ok().map(f64::from)) + .then(|| { + value.as_expression().is_some_and(|value| { + matches!(unwrap_syntax_only(value), Expression::ArrayExpression(_)) + }) + }) + }) + .unwrap_or(false), + _ => false, + } +} diff --git a/libs/extractor/src/assignment_mixed_container_support.rs b/libs/extractor/src/assignment_mixed_container_support.rs new file mode 100644 index 000000000..fafd6bbf6 --- /dev/null +++ b/libs/extractor/src/assignment_mixed_container_support.rs @@ -0,0 +1,78 @@ +use crate::assignment_test_support::{evaluate, extracted, jsx_js}; +use crate::{ExtractStyleValue, extract_style::style_property::StyleProperty}; + +#[test] +#[serial_test::serial] +fn unrelated_typography_record_does_not_change_selected_selector_association() { + // Given + let source = "import {Box,css}from '@devup-ui/react';const unused=()=>css({typography:'heading'});let trace=[];const node=;"; + assert!( + extracted(source) + .styles + .iter() + .any(|value| matches!(value, ExtractStyleValue::Typography(name) if name == "heading")) + ); + // When + let actual = associated(source); + // Then + assert_eq!(actual, "[\"&:hover|color:red:0|\"]"); +} + +pub(super) fn associated(source: &str) -> String { + let output = extracted(source); + let records = output + .styles + .iter() + .filter_map(|value| { + let (class, property, value, level, selector, layer) = + match (value, value.extract(None)?) { + (ExtractStyleValue::Static(value), StyleProperty::ClassName(class)) => ( + class, + value.property.clone(), + value.value.clone(), + value.level, + value.selector.as_ref(), + value.layer.as_deref(), + ), + ( + ExtractStyleValue::Dynamic(value), + StyleProperty::Variable { + class_name, + variable_name, + .. + }, + ) => ( + class_name, + value.property().to_string(), + format!("var({variable_name})"), + value.level(), + value.selector(), + value.layer(), + ), + _ => return None, + }; + Some(( + class, + format!( + "{}|{property}:{value}:{level}|{}", + selector.map(ToString::to_string).unwrap_or_default(), + layer.unwrap_or_default() + ), + )) + }) + .collect::>(); + let records = serde_json::to_string(&records).unwrap_or_else(|error| panic!("{error}")); + let original = source_trace(source); + evaluate(&format!( + "{}const records={records};const classes=String(node.className??'').split(/\\s+/).filter(Boolean);const assigned=classes.map(name=>{{const record=records.find(([key])=>key===name);if(!record)throw Error('unbound class '+name);return record[1].replace(/var\\(([^)]+)\\)/g,(_,variable)=>String(node.style[variable]))}}).sort();if(JSON.stringify(trace)!=={})throw Error('source trace mismatch '+JSON.stringify(trace));JSON.stringify(assigned);", + jsx_js(&output.code), + serde_json::to_string(&original).unwrap_or_else(|error| panic!("{error}")) + )) +} + +pub(super) fn source_trace(source: &str) -> String { + evaluate(&format!( + "const Box='div';{}JSON.stringify(trace);", + jsx_js(source) + )) +} diff --git a/libs/extractor/src/assignment_mixed_container_tests.rs b/libs/extractor/src/assignment_mixed_container_tests.rs new file mode 100644 index 000000000..6b9a5759d --- /dev/null +++ b/libs/extractor/src/assignment_mixed_container_tests.rs @@ -0,0 +1,223 @@ +use crate::assignment_blocker_support::selected; +use crate::assignment_test_support::{compiled_jsx, evaluate}; +use rstest::rstest; +use serial_test::serial; + +#[path = "assignment_mixed_container_support.rs"] +mod support; + +#[rstest] +#[case("{state.child}")] +#[case(" span':{p:state.pad,color:'red'}}} id={state.id}>{state.child}")] +#[case("jsx(Box,{_hover:{p:state.pad,color:'red'},id:state.id,children:state.child})")] +#[case( + "jsx(Box,{selectors:{'& > span':{p:state.pad,color:'red'}},id:state.id,children:state.child})" +)] +#[serial] +fn mixed_container_reads_leaf_once_when_static_class_is_present(#[case] expression: &str) { + // Given: literal color and observable padding before adjacent operands. + let source = fixture(expression, "true", "return '16px'"); + let original = support::source_trace(&source); + assert_eq!(original, "[\"pad\",\"id\",\"child\"]"); + // When: the native compiled JSX executes its actual selected classes/variables. + let actual = selected(&source, "JSON.stringify([trace,assigned(node)]);"); + // Then: the literal class survives and padding is captured before id/child. + assert_eq!( + actual, + "[[\"pad\",\"id\",\"child\"],[\"color:red:0\",\"padding:16px:0\"]]" + ); +} + +#[rstest] +#[case( + "{state.child}", + "true", + "red" +)] +#[case( + "{state.child}", + "false", + "blue" +)] +#[case( + "jsx(Box,{_hover:{color:state.flag?'red':'blue',p:state.pad},id:state.id,children:state.child})", + "true", + "red" +)] +#[case( + "jsx(Box,{_hover:{color:state.flag?'red':'blue',p:state.pad},id:state.id,children:state.child})", + "false", + "blue" +)] +#[case( + " span':{color:state.flag?'red':'blue',p:state.pad}}} id={state.id}>{state.child}", + "false", + "blue" +)] +#[serial] +fn mixed_container_selects_only_authored_branch_when_color_is_conditional( + #[case] expression: &str, + #[case] flag: &str, + #[case] color: &str, +) { + // Given: static color alternatives controlled by an observable getter. + let source = fixture(expression, flag, "return '16px'"); + let original = support::source_trace(&source); + assert_eq!(original, "[\"flag\",\"pad\",\"id\",\"child\"]"); + // When: the selected branch and dynamic sibling execute together. + let actual = selected(&source, "JSON.stringify([trace,assigned(node)]);"); + // Then: there is exactly one color class, with one ordered predicate read. + assert_eq!( + actual, + format!("[[\"flag\",\"pad\",\"id\",\"child\"],[\"color:{color}:0\",\"padding:16px:0\"]]") + ); +} + +#[rstest] +#[case("{state.child}")] +#[case("jsx(Box,{_hover:{p:state.pad,color:'red'},id:state.id,children:state.child})")] +#[serial] +fn mixed_container_stops_adjacent_operands_when_first_leaf_throws(#[case] expression: &str) { + // Given: the first authored leaf throws before adjacent operands. + let source = fixture(expression, "true", "throw Error('pad failed')").replace( + "const node=render(state);", + "let error;try{render(state)}catch(value){error=value.message}", + ); + assert_eq!(support::source_trace(&source), "[\"pad\"]"); + // When: compiled source encounters the failing leaf. + let actual = evaluate(&format!( + "{}JSON.stringify([trace,error]);", + compiled_jsx(&source) + )); + // Then: neither id nor child was evaluated. + assert_eq!(actual, "[[\"pad\"],\"pad failed\"]"); +} + +#[rstest] +#[case( + "true", + "[[\"flag\",\"pad\",\"id\",\"child\"],[\"color:red:0\",\"padding:16px:0\"]]" +)] +#[case("false", "[[\"flag\",\"pad\",\"id\",\"child\"],[\"padding:16px:0\"]]")] +#[serial] +fn mixed_container_keeps_no_style_branch_when_static_color_is_guarded( + #[case] flag: &str, + #[case] expected: &str, +) { + // Given: a no-style logical color branch and a dynamic sibling. + let source = fixture( + "{state.child}", + flag, + "return '16px'", + ); + // When: the guard is selected. + let actual = selected(&source, "JSON.stringify([trace,assigned(node)]);"); + // Then: the false branch contributes no class, without skipping padding. + assert_eq!(actual, expected); +} + +fn fixture(expression: &str, flag: &str, pad: &str) -> String { + format!( + "import {{Box}}from '@devup-ui/react';import {{jsx}}from 'react/jsx-runtime';function render(state){{return {expression}}}let trace=[];const state={{get flag(){{trace.push('flag');return {flag}}},get pad(){{trace.push('pad');{pad}}},get id(){{trace.push('id');return 'target'}},get child(){{trace.push('child');return 'text'}}}};const jsx=(tag,props)=>props;const node=render(state);" + ) +} + +#[rstest] +#[case( + " span':{p:state.pad,color:state.flag?'red':'blue'},'& > b':{p:state.pad,color:'green'}}} id={state.id}>{state.child}" +)] +#[case( + "jsx(Box,{selectors:{'& > span':{p:state.pad,color:state.flag?'red':'blue'},'& > b':{p:state.pad,color:'green'}},id:state.id,children:state.child})" +)] +#[serial] +fn nested_mixed_selectors_keep_associations_when_identical_getters_return_distinct_values( + #[case] expression: &str, +) { + // Given: identical property/getter syntax under different authored selectors. + let source = fixture(expression, "false", "return ++reads===1?'16px':'24px'") + .replace("let trace=[];", "let trace=[];let reads=0;"); + // When: emitted class identities are joined to selector/property/variable records. + let actual = support::associated(&source); + // Then: each selector receives its own value, while native source trace is unchanged. + assert_eq!( + actual, + "[\"& > b|color:green:0|\",\"& > b|padding:24px:0|\",\"& > span|color:blue:0|\",\"& > span|padding:16px:0|\"]" + ); +} + +#[rstest] +#[case( + "{state.child}" +)] +#[case( + "jsx(Box,{_hover:{p:['4px',state.pad],color:state.flag?'red':'blue'},id:state.id,children:state.child})" +)] +#[serial] +fn mixed_array_field_keeps_levels_when_literal_and_dynamic_values_coexist( + #[case] expression: &str, +) { + // Given: a responsive field with a literal role and a dynamic role. + let source = fixture(expression, "false", "return '16px'"); + // When: extraction preserves the field's semantic IR before flattening. + let actual = support::associated(&source); + // Then: literal and dynamic padding retain their distinct levels and selector. + assert_eq!( + actual, + "[\"&:hover|color:blue:0|\",\"&:hover|padding:16px:1|\",\"&:hover|padding:4px:0|\"]" + ); +} + +#[rstest] +#[case( + "true", + "[\"&:hover:focus|color:red:0|\",\"&:hover:focus|padding:16px:0|\",\"&:hover|margin:8px:0|\"]" +)] +#[case("false", "[\"&:hover|margin:8px:0|\"]")] +#[serial] +fn mixed_nested_container_is_lazy_when_logical_branch_has_no_styles( + #[case] flag: &str, + #[case] expected: &str, +) { + // Given: an entire guarded selector object next to an unconditional literal. + let source = fixture( + "{state.child}", + flag, + "return '16px'", + ); + // When: the compiled object is compared with authored native source execution. + let actual = support::associated(&source); + // Then: the false branch never reads padding or emits either guarded class. + assert_eq!(actual, expected); +} + +#[test] +#[serial] +fn mixed_layer_container_keeps_layer_when_properties_are_repeated() { + // Given: repeated padding under distinct source-authored layers. + let source = fixture("{state.child}", "true", "return ++reads===1?'16px':'24px'") + .replace("let trace=[];", "let trace=[];let reads=0;"); + // When: actual emitted classes and inline assignments execute. + let actual = support::associated(&source); + // Then: layer, selector, property and selected value remain paired. + assert_eq!( + actual, + "[\"&:hover|color:blue:0|second\",\"&:hover|color:red:0|first\",\"&:hover|padding:16px:0|first\",\"&:hover|padding:24px:0|second\"]" + ); +} + +#[test] +#[serial] +fn literal_only_container_has_no_capture_when_no_runtime_leaf_exists() { + // Given: a pure literal selector object and observable adjacent operands. + let source = fixture( + "{state.child}", + "true", + "return 'unused'", + ); + // When: the compiler extracts the static container. + let output = compiled_jsx(&source); + // Then: it introduces no assignment/field/value capture for that container. + assert!(!output.contains("__devupAssignment")); + assert!(!output.contains("__devupField")); + assert!(!output.contains("__devupValue")); +} diff --git a/libs/extractor/src/assignment_object.rs b/libs/extractor/src/assignment_object.rs new file mode 100644 index 000000000..a6e8b89d2 --- /dev/null +++ b/libs/extractor/src/assignment_object.rs @@ -0,0 +1,116 @@ +use crate::{ + ExtractStyleProp, ExtractStyleValue, + assignment_lowering::{Classes, Lowering, empty, projection}, + gen_class_name::{gen_class_names, merge_expression_for_class_name}, + utils::{call_with_values, get_string_by_property_key, merge_object_expressions}, +}; +use oxc_allocator::{CloneIn, GetAllocator}; +use oxc_ast::ast::{Expression, ObjectExpression, ObjectPropertyKind}; +use oxc_span::GetSpan; + +impl<'a> Lowering<'_, 'a> { + pub(super) fn object( + &self, + source: &ObjectExpression<'a>, + styles: &[ExtractStyleProp<'a>], + ) -> Expression<'a> { + let ast = self.ast; + let mut raw = source.clone_in(ast.allocator()); + let mut values = Vec::new(); + let mut refs = Vec::new(); + let mut assigned = vec![false; styles.len()]; + for (index, property) in raw.properties.iter_mut().enumerate() { + if let ObjectPropertyKind::ObjectProperty(property) = property { + let names = get_string_by_property_key(&property.key) + .map(|key| { + css::disassemble_property(&key) + .map(|name| css::utils::to_kebab_case(&name).into_owned()) + .collect::>() + }) + .unwrap_or_default(); + let mut consumers = styles + .iter() + .enumerate() + .filter_map(|(index, style)| { + let consumes = + if crate::assignment_owner::contains_consumer(source.span(), style) { + crate::assignment_owner::contains_consumer( + property.value.span(), + style, + ) + } else { + style.extract().iter().any(|value| match value { + ExtractStyleValue::Static(value) => { + names.contains(&value.property) + } + ExtractStyleValue::Dynamic(value) => { + names.iter().any(|name| name == value.property()) + } + ExtractStyleValue::Typography(_) => { + names.iter().any(|name| name == "typography") + } + _ => false, + }) + }; + assigned[index] |= consumes; + if consumes { + Some(style.clone_in(ast.allocator())) + } else { + None + } + }) + .collect::>(); + let binding = format!("__devupField{index}"); + values.push((binding.clone(), self.lower(&property.value, &mut consumers))); + property.value = projection(ast, &binding, 2); + refs.push(binding); + } + } + let mut residual = styles + .iter() + .zip(assigned) + .filter(|(_, assigned)| !assigned) + .map(|(style, _)| style.clone_in(ast.allocator())) + .collect::>(); + let alternate = self.alternate_order.and_then(|order| { + let mut residual = residual + .iter() + .map(|style| style.clone_in(ast.allocator())) + .collect::>(); + gen_class_names(ast, &mut residual, order.value, self.filename) + }); + let residual = gen_class_names(ast, &mut residual, self.order, self.filename); + let classes = |slot| { + let residual = if slot == 3 { &alternate } else { &residual }; + merge_expression_for_class_name( + ast, + refs.iter().map(|name| projection(ast, name, slot)).chain( + residual + .as_ref() + .map(|class| class.clone_in(ast.allocator())), + ), + ) + .unwrap_or_else(|| Expression::new_string_literal(oxc_span::SPAN, "", None, ast)) + }; + let inline = merge_object_expressions( + ast, + &refs + .iter() + .map(|name| projection(ast, name, 1)) + .collect::>(), + ) + .unwrap_or_else(|| empty(ast)); + call_with_values( + ast, + values, + self.pair( + Classes { + primary: classes(0), + alternate: self.alternate_order.map(|_| classes(3)), + }, + inline, + Expression::ObjectExpression(oxc_allocator::Box::new_in(raw, ast)), + ), + ) + } +} diff --git a/libs/extractor/src/assignment_owner.rs b/libs/extractor/src/assignment_owner.rs new file mode 100644 index 000000000..6828a3e0e --- /dev/null +++ b/libs/extractor/src/assignment_owner.rs @@ -0,0 +1,177 @@ +use std::{cell::RefCell, collections::BTreeMap}; + +use css::Site; +use oxc_ast::ast::Expression; +use oxc_span::GetSpan; + +use crate::utils::expression_to_code; +use crate::{ExtractStyleProp, ExtractStyleValue}; + +struct Owner { + start: u32, + assignment: String, + sites: BTreeMap>, +} + +thread_local! { + static OWNER: RefCell> = const { RefCell::new(None) }; +} + +pub(crate) struct AssignmentOwner(Option); + +impl AssignmentOwner { + pub(crate) fn enter(expression: &Expression<'_>) -> Self { + Self(OWNER.with_borrow_mut(|owner| { + owner.replace(Owner { + start: expression.span().start, + assignment: expression_to_code(expression), + sites: BTreeMap::new(), + }) + })) + } + + pub(crate) fn active() -> bool { + OWNER.with_borrow(Option::is_some) + } +} + +impl Drop for AssignmentOwner { + fn drop(&mut self) { + OWNER.with_borrow_mut(|owner| *owner = self.0.take()); + } +} + +/// A complete selected assignment registers once; atoms only consume its site. +pub(crate) fn site(start: u32, level: u8, assignment: &str) -> Option { + OWNER.with_borrow_mut(|owner| match owner { + Some(owner) => owner + .sites + .entry(level) + .or_insert_with(|| { + crate::provenance::site_at(owner.start, usize::from(level), &owner.assignment) + }) + .clone(), + None => crate::provenance::site_at(start, 0, assignment), + }) +} + +pub(crate) fn scalar( + source: &Expression<'_>, + styles: &[ExtractStyleProp<'_>], +) -> Option { + let values: Vec<_> = styles.iter().flat_map(ExtractStyleProp::extract).collect(); + let [ExtractStyleValue::Dynamic(first), ..] = values.as_slice() else { + return None; + }; + if styles.iter().all(always_present) + && values.iter().all(|value| { + matches!(value, ExtractStyleValue::Dynamic(style) + if style.property() == first.property() && style.level() == first.level() + && style.selector() == first.selector() && style.layer() == first.layer() + && style.naming() == first.naming() && !style.important()) + }) + && scalar_shape(source) + && !expression_to_code(source).contains('`') + { + let mut selected = first.clone(); + selected.replace_identifier(expression_to_code(source).trim().trim_end_matches(';')); + Some(ExtractStyleValue::Dynamic(selected)) + } else { + None + } +} + +pub(crate) fn contains_assignment( + span: oxc_span::Span, + style: &crate::extract_style::extract_dynamic_style::ExtractDynamicStyle, +) -> bool { + style + .site + .as_ref() + .is_some_and(|site| crate::sparse_sites::contains_site(span, site)) +} + +pub(crate) fn contains_consumer(span: oxc_span::Span, style: &ExtractStyleProp<'_>) -> bool { + let contains = |source: &Expression<'_>| { + let start = crate::provenance::source_offset(source.span().start); + (crate::provenance::source_offset(span.start)..crate::provenance::source_offset(span.end)) + .contains(&start) + }; + match style { + ExtractStyleProp::Static(ExtractStyleValue::Dynamic(style)) => { + contains_assignment(span, style) + } + ExtractStyleProp::Static(_) | ExtractStyleProp::Unreadable { .. } => false, + ExtractStyleProp::StaticArray(styles) => { + styles.iter().any(|style| contains_consumer(span, style)) + } + ExtractStyleProp::Evaluated { source, .. } => contains(source), + ExtractStyleProp::Conditional { condition, .. } + | ExtractStyleProp::Enum { condition, .. } => contains(condition), + ExtractStyleProp::Expression { expression, .. } + | ExtractStyleProp::MemberExpression { expression, .. } => contains(expression), + } +} + +pub(crate) fn take_consumers<'a>( + span: oxc_span::Span, + styles: &mut [ExtractStyleProp<'a>], +) -> Vec> { + let mut consumers = Vec::new(); + for style in styles { + match style { + ExtractStyleProp::StaticArray(styles) => consumers.extend(take_consumers(span, styles)), + style if contains_consumer(span, style) => consumers.push(std::mem::replace( + style, + ExtractStyleProp::StaticArray(vec![]), + )), + _ => {} + } + } + consumers +} + +fn always_present(style: &ExtractStyleProp<'_>) -> bool { + match style { + ExtractStyleProp::Static(ExtractStyleValue::Dynamic(_)) => true, + ExtractStyleProp::StaticArray(styles) => { + !styles.is_empty() && styles.iter().all(always_present) + } + ExtractStyleProp::Conditional { + consequent: Some(consequent), + alternate: Some(alternate), + .. + } => always_present(consequent) && always_present(alternate), + _ => false, + } +} + +fn scalar_shape(source: &Expression<'_>) -> bool { + use crate::utils::unwrap_syntax_only; + match unwrap_syntax_only(source) { + Expression::ArrayExpression(_) => false, + Expression::ConditionalExpression(source) => { + scalar_shape(&source.consequent) && scalar_shape(&source.alternate) + } + Expression::LogicalExpression(source) => { + scalar_shape(&source.left) && scalar_shape(&source.right) + } + Expression::ComputedMemberExpression(source) => match unwrap_syntax_only(&source.object) { + Expression::ArrayExpression(source) => source + .elements + .iter() + .filter_map(|value| value.as_expression()) + .all(scalar_shape), + Expression::ObjectExpression(source) => { + source.properties.iter().all(|property| match property { + oxc_ast::ast::ObjectPropertyKind::ObjectProperty(property) => { + scalar_shape(&property.value) + } + oxc_ast::ast::ObjectPropertyKind::SpreadProperty(_) => false, + }) + } + _ => true, + }, + _ => true, + } +} diff --git a/libs/extractor/src/assignment_residual_tests.rs b/libs/extractor/src/assignment_residual_tests.rs new file mode 100644 index 000000000..487835483 --- /dev/null +++ b/libs/extractor/src/assignment_residual_tests.rs @@ -0,0 +1,218 @@ +use crate::assignment_blocker_support::selected; +use crate::assignment_test_support::{compiled_jsx, evaluate, extracted}; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case("")] +#[case("jsx(Box,{className:state.flag?'p-4':'p-8',color:state.color})")] +#[serial] +fn literal_class_rules_are_retained_when_adjacent_assignments_are_captured( + #[case] expression: &str, +) { + // Given: a literal-bearing Tailwind class expression beside a dynamic style. + let source = format!( + "import {{Box}}from '@devup-ui/react';import {{jsx}}from 'react/jsx-runtime';function render(state){{return {expression}}}let trace=[];const input={{get flag(){{trace.push('flag');return true}},get color(){{trace.push('color');return 'red'}}}};const jsx=(tag,props)=>props;const node=render(input);" + ); + // When: actual selected classes are associated with emitted declarations. + let actual = selected( + &source, + "JSON.stringify([trace,selected(node).filter(value=>value.startsWith('padding:'))]);", + ); + // Then: capture does not hide the literal class from compile-time extraction. + assert_eq!(actual, "[[\"flag\",\"color\"],[\"padding:1rem:0\"]]"); +} + +#[rstest] +#[case( + "css({color:'red',p:1},state.cond&&{color:'blue',_hover:{color:'green'}})", + "[\"cond\"]" +)] +#[case( + "css({color:'red'},state.flag?state.other:{color:'blue'})", + "[\"flag\",\"other\"]" +)] +#[case( + "css({color:'red'},state.cond&&state.other,state.flag?'x':null,[undefined,false])", + "[\"cond\",\"other\",\"flag\"]" +)] +#[serial] +fn composition_reads_controls_and_classes_once_when_parts_are_conditional( + #[case] expression: &str, + #[case] expected: &str, +) { + // Given: controllers and class values expose the source argument order. + let source = format!( + "import {{css}}from '@devup-ui/react';function render(state){{return {expression}}}let trace=[];const input={{get cond(){{trace.push('cond');return true}},get flag(){{trace.push('flag');return true}},get other(){{trace.push('other');return 'external'}}}};const node=render(input);JSON.stringify(trace);" + ); + // When: the composed class expression is evaluated. + let actual = evaluate(&compiled_jsx(&source)); + // Then: a controller shared by class and rule selection is read only once. + assert_eq!(actual, expected); +} + +#[test] +#[serial] +fn selector_values_remain_distinct_when_multiple_properties_share_a_condition() { + // Given: two dynamic properties and one logical selector clause. + let source = "import {Box}from '@devup-ui/react';function render(state){return }const node=render({flag:true,color:'red',background:'blue'});"; + // When: the shared condition selects both property assignments. + let actual = selected(source, "JSON.stringify(assigned(node));"); + // Then: the values use distinct source roles, never one overwritten variable. + assert_eq!(actual, "[\"background:blue:0\",\"color:red:0\"]"); +} + +#[rstest] +#[case("({a:'red',b:'blue'})[state.key]", "missing")] +#[case("({a:'red',b:'blue'})[state.key]", "toString")] +#[case("({a:'red',b:'blue'})[state.key]", "constructor")] +#[case("({a:'red',__proto__:'blue'})[state.key]", "__proto__")] +#[case("[1,2][state.key]", "9")] +#[serial] +fn missing_and_prototype_selections_are_empty_when_no_authored_entry_applies( + #[case] expression: &str, + #[case] key: &str, +) { + // Given: a static class alongside a lookup which selects no literal-defined CSS value. + let source = format!( + "import {{Box}}from '@devup-ui/react';function render(state){{return }}const node=render({{key:'{key}'}});" + ); + // When: selected class fragments are rendered in a template. + let actual = selected(&source, "JSON.stringify(selected(node));"); + // Then: no undefined/prototype fragment is rendered as a class. + assert_eq!(actual, "[\"padding:4px:0\"]"); +} + +#[rstest] +#[case("state.flag && state.color", false, "[0,[]]")] +#[case("state.flag ? state.color : null", false, "[0,[]]")] +#[case("state.flag ? false : state.color", true, "[0,[]]")] +#[case("state.flag && state.color", true, "[1,[\"red\"]]")] +#[case("state.flag ? state.color : null", true, "[1,[\"red\"]]")] +#[case("state.flag ? false : state.color", false, "[1,[\"red\"]]")] +#[case("(state.flag ? false : state.color) ?? 'blue'", true, "[0,[]]")] +#[case("(state.flag ? null : state.color) ?? 'blue'", true, "[1,[]]")] +#[case("(state.flag ? false : state.color) || 'blue'", true, "[1,[]]")] +#[serial] +fn no_style_branches_stay_absent_when_dynamic_alternatives_exist( + #[case] expression: &str, + #[case] flag: bool, + #[case] expected: &str, +) { + // Given: a logical/conditional branch which deliberately supplies no styles. + let source = format!( + "import {{Box}}from '@devup-ui/react';function render(state){{return }}const node=render({{flag:{flag},color:'red'}});JSON.stringify([String(node.className??'').split(/\\s+/).filter(Boolean).length,Object.values(node.style??{{}})]);" + ); + // When: class and inline value selection execute together. + let actual = evaluate(&compiled_jsx(&source)); + // Then: only the selected value branch carries its inline assignment. + assert_eq!(actual, expected); +} + +#[rstest] +#[case( + "{state.child}" +)] +#[case( + "jsx(Box,{className:state.className,color:state.color,style:state.style,as:state.tag,...state.rest,title:state.title,children:state.child})" +)] +#[serial] +fn adjacent_reads_remain_authored_when_class_style_type_spread_and_children_mix( + #[case] expression: &str, +) { + // Given: getters expose every adjacent source operand, including className and style. + let source = format!( + "import {{Box}}from '@devup-ui/react';import {{jsx}}from 'react/jsx-runtime';function render(state){{return {expression}}}let trace=[];const input={{}};for(const[key,value]of Object.entries({{className:'external',color:'red',style:{{}},tag:'section',rest:{{id:1}},title:'title',child:'child'}}))Object.defineProperty(input,key,{{get(){{trace.push(key);return value}}}});const jsx=(tag,props)=>props;const node=render(input);JSON.stringify(trace);" + ); + // When: the real emitted expression executes. + let actual = evaluate(&compiled_jsx(&source)); + // Then: no member read is called pure or moved across another read. + assert_eq!( + actual, + "[\"className\",\"color\",\"style\",\"tag\",\"rest\",\"title\",\"child\"]" + ); +} + +#[test] +#[serial] +fn asynchronous_nested_selector_reads_stay_lazy_when_awaits_resume() { + // Given: a lazy selector branch with awaited responsive values and a later title. + let source = "import {Box}from '@devup-ui/react';async function render(state){return }let trace=[];const state={flag:true};for(const[key,value]of Object.entries({first:'none',last:'none',title:'title'}))Object.defineProperty(state,key,{get(){trace.push(key);return Promise.resolve(value)}});let result;render(state).then(node=>{result=[trace,Object.values(node.style),node.title]},error=>{result=['error',String(error)]});"; + let compiled = compiled_jsx(source); + // When: Boa's actual promise jobs resume every authored await. + let mut context = boa_engine::Context::default(); + context + .eval(boa_engine::Source::from_bytes(&compiled)) + .unwrap_or_else(|error| panic!("{error}\n{compiled}")); + context.run_jobs().unwrap_or_else(|error| panic!("{error}")); + let actual = context + .eval(boa_engine::Source::from_bytes("JSON.stringify(result)")) + .unwrap_or_else(|error| panic!("{error}")) + .as_string() + .unwrap_or_else(|| panic!("resolved JSON")) + .to_std_string_escaped(); + // Then: await remains outside synchronous captures and value order is preserved. + assert_eq!( + actual, "[[\"first\",\"last\",\"title\"],[\"none\",\"none\"],\"title\"]", + "{compiled}" + ); +} + +#[rstest] +#[case(true, Some(5), "state.flag?5:10")] +#[case(false, Some(10), "state.flag?5:10")] +#[case(true, None, "state.flag?undefined:3")] +#[case(false, Some(3), "state.flag?undefined:3")] +#[serial] +fn call_cascade_order_matches_source_when_values_are_shared( + #[case] flag: bool, + #[case] order: Option, + #[case] order_expression: &str, +) { + // Given: a JSX call with conditional orders and a dynamic CSS value. + let source = format!( + "import {{Box}}from '@devup-ui/react';import {{jsx}}from 'react/jsx-runtime';function render(state){{return jsx(Box,{{styleOrder:{order_expression},color:state.color}})}}const jsx=(tag,props)=>props;const node=render({{flag:{flag},color:'red'}});" + ); + let output = extracted(&source); + let classes=output.styles.iter().filter(|value|matches!(value,crate::ExtractStyleValue::Dynamic(style)if style.style_order()==order)).filter_map(|value|match value.extract(None){Some(crate::extract_style::style_property::StyleProperty::Variable{class_name,..})=>Some(class_name),_=>None}).collect::>(); + assert_eq!(classes.len(), 1); + // When: the input flag selects one authored order. + let actual = evaluate(&format!( + "{}JSON.stringify([node.className,Object.values(node.style)]);", + compiled_jsx(&source) + )); + // Then: the class carrying that CSS order uses the same captured red value. + assert_eq!( + actual, + serde_json::to_string(&(classes[0].clone(), vec!["red"])) + .unwrap_or_else(|error| panic!("{error}")) + ); +} + +#[test] +#[serial] +fn generator_computed_keys_stay_after_spreads_when_yield_resumes() { + // Given: the rejected JSX-call spread shape with a yielding computed value. + let source = "import {Box}from '@devup-ui/react';import {jsx}from 'react/jsx-runtime';function* render(f,state){return jsx(Box,{...f(),[state.key]:yield state.value})}let trace=[];const input={get key(){trace.push('key');return 'title'},get value(){trace.push('value');return 'yielded'}};const f=()=>{trace.push('f');return {id:1}};const jsx=(tag,props)=>props;const generator=render(f,input);const first=generator.next();const last=generator.next('resumed');JSON.stringify([trace,first.value,last.value.title]);"; + // When: the actual generator is resumed. + let actual = evaluate(&compiled_jsx(source)); + // Then: the spread runs once, followed by key/value, with yield in the authored scope. + assert_eq!( + actual, + "[[\"f\",\"key\",\"value\"],\"yielded\",\"resumed\"]" + ); +} + +#[test] +#[serial] +fn spread_property_getters_run_once_when_class_and_style_are_composed() { + // Given: source object enumeration exposes className/style getters before a later title. + let source = "import {Box}from '@devup-ui/react';function render(f,state){return }let trace=[];const input={get title(){trace.push('title');return 'later'}};const f=()=>{trace.push('f');return {get className(){trace.push('class');return 'external'},get style(){trace.push('style');return {color:'red'}}}};const node=render(f,input);JSON.stringify([trace,node.className,node.style]);"; + // When: the emitted spread and generated composition execute. + let actual = evaluate(&compiled_jsx(source)); + // Then: enumeration getters run once in source position, not again for composition. + assert_eq!( + actual, + "[[\"f\",\"class\",\"style\",\"title\"],\"external\",{\"color\":\"red\"}]" + ); +} diff --git a/libs/extractor/src/assignment_test_support.rs b/libs/extractor/src/assignment_test_support.rs new file mode 100644 index 000000000..db8bcee01 --- /dev/null +++ b/libs/extractor/src/assignment_test_support.rs @@ -0,0 +1,217 @@ +use boa_engine::{Context, Source}; +use oxc_allocator::Allocator; +use oxc_ast::{ast::Statement, builder::AstBuilder}; +use oxc_parser::Parser; +use oxc_span::GetSpan; +use oxc_span::SourceType; + +use crate::{ + extractor::extract_style_from_expression::{LiteralHandling, extract_style_from_expression}, + gen_class_name::gen_class_names, + gen_style::gen_styles, + provenance::SiteScope, + utils::expression_to_code, +}; + +fn code(expression: &oxc_ast::ast::Expression<'_>) -> String { + expression_to_code(expression) + .trim() + .trim_end_matches(';') + .to_string() +} + +pub(super) fn lowered(expression: &str) -> String { + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + let source = format!("({expression});"); + let _sites = SiteScope::enter("assignment.tsx", &source, &[]); + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let mut program = Parser::new(&allocator, &source, SourceType::ts()) + .parse() + .program; + let Statement::ExpressionStatement(statement) = &mut program.body[0] else { + panic!("fixture expression"); + }; + let mut styles = extract_style_from_expression( + &builder, + Some("gap"), + &mut statement.expression, + 0, + &None, + LiteralHandling::ExpandResponsiveThemeToken, + ) + .styles; + let class = gen_class_names(&builder, &mut styles, None, None) + .map_or_else(|| "''".to_string(), |value| code(&value)); + let style = + gen_styles(&builder, &styles, None).map_or_else(|| "{}".to_string(), |value| code(&value)); + let values = crate::assignment_lowering::take_evaluations(&mut styles); + let params = values + .iter() + .map(|(name, _)| name.as_str()) + .collect::>() + .join(","); + let arguments = values + .iter() + .map(|(_, value)| code(value)) + .collect::>() + .join(","); + if values.is_empty() { + format!("({{className:{class},style:{style}}})") + } else { + format!("(({params})=>({{className:{class},style:{style}}}))({arguments})") + } +} + +pub(super) fn evaluate(script: &str) -> String { + Context::default() + .eval(Source::from_bytes(script)) + .unwrap_or_else(|error| panic!("{error}\n{script}")) + .as_string() + .unwrap_or_else(|| panic!("fixture returns JSON")) + .to_std_string_escaped() +} + +struct JsxProps<'a>(AstBuilder<'a>); + +impl<'a> oxc_ast_visit::VisitMut<'a> for JsxProps<'a> { + fn visit_expression(&mut self, expression: &mut oxc_ast::ast::Expression<'a>) { + use oxc_allocator::{CloneIn, GetAllocator}; + use oxc_ast::ast::{ + Expression, JSXAttributeItem, JSXAttributeName, JSXAttributeValue, ObjectPropertyKind, + PropertyKey, PropertyKind, + }; + oxc_ast_visit::walk_mut::walk_expression(self, expression); + let unwrapped = crate::utils::unwrap_syntax_only(expression); + if unwrapped.span() != expression.span() { + *expression = unwrapped.clone_in(self.0.allocator()); + } + if let Expression::JSXElement(element) = expression { + let builder = &self.0; + let allocator = builder.allocator(); + let mut properties = oxc_allocator::Vec::new_in(builder); + let tag = element.opening_element.name.to_string(); + if matches!( + &element.opening_element.name, + oxc_ast::ast::JSXElementName::MemberExpression(_) + ) || tag.starts_with(|character: char| character.is_ascii_uppercase()) + { + properties.push(ObjectPropertyKind::new_object_property( + oxc_span::SPAN, + PropertyKind::Init, + PropertyKey::new_static_identifier(oxc_span::SPAN, "tag", builder), + Expression::new_identifier(oxc_span::SPAN, allocator.alloc_str(&tag), builder), + false, + false, + false, + builder, + )); + } + for attribute in &element.opening_element.attributes { + match attribute { + JSXAttributeItem::Attribute(attribute) => { + let value = match &attribute.value { + Some(JSXAttributeValue::ExpressionContainer(container)) => container + .expression + .as_expression() + .map(|value| value.clone_in(allocator)), + Some(JSXAttributeValue::StringLiteral(value)) => { + Some(Expression::StringLiteral(value.clone_in(allocator))) + } + _ => None, + }; + if let Some(value) = value { + let name = match &attribute.name { + JSXAttributeName::Identifier(name) => name.name.to_string(), + JSXAttributeName::NamespacedName(name) => { + format!("{}:{}", name.namespace.name, name.name.name) + } + }; + properties.push(ObjectPropertyKind::new_object_property( + oxc_span::SPAN, + PropertyKind::Init, + PropertyKey::new_string_literal( + oxc_span::SPAN, + allocator.alloc_str(&name), + None, + builder, + ), + value, + false, + false, + false, + builder, + )); + } + } + JSXAttributeItem::SpreadAttribute(spread) => { + properties.push(ObjectPropertyKind::new_spread_property( + oxc_span::SPAN, + spread.argument.clone_in(allocator), + builder, + )); + } + } + } + let children = element + .children + .iter() + .filter_map(|child| match child { + oxc_ast::ast::JSXChild::ExpressionContainer(container) => container + .expression + .as_expression() + .map(|value| value.clone_in(allocator)), + _ => None, + }) + .map(oxc_ast::ast::ArrayExpressionElement::from); + properties.push(ObjectPropertyKind::new_object_property( + oxc_span::SPAN, + PropertyKind::Init, + PropertyKey::new_static_identifier(oxc_span::SPAN, "children", builder), + Expression::new_array_expression( + oxc_span::SPAN, + oxc_allocator::Vec::from_iter_in(children, builder), + builder, + ), + false, + false, + false, + builder, + )); + *expression = Expression::new_object_expression(oxc_span::SPAN, properties, builder); + } + } +} + +pub(super) fn extracted(source: &str) -> crate::ExtractOutput { + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + crate::extract_without_source_map( + "assignment.tsx", + source, + crate::ExtractOption { + single_css: true, + ..crate::ExtractOption::default() + }, + ) + .unwrap_or_else(|error| panic!("{error}")) +} + +pub(super) fn compiled_jsx(source: &str) -> String { + let output = extracted(source); + jsx_js(&output.code) +} + +pub(super) fn jsx_js(source: &str) -> String { + use oxc_ast_visit::VisitMut; + let allocator = Allocator::default(); + let mut program = Parser::new(&allocator, source, SourceType::tsx()) + .parse() + .program; + program + .body + .retain(|statement| !matches!(statement, Statement::ImportDeclaration(_))); + JsxProps(AstBuilder::new(&allocator)).visit_program(&mut program); + oxc_codegen::Codegen::new().build(&program).code +} diff --git a/libs/extractor/src/assignment_value.rs b/libs/extractor/src/assignment_value.rs new file mode 100644 index 000000000..541db2624 --- /dev/null +++ b/libs/extractor/src/assignment_value.rs @@ -0,0 +1,228 @@ +use oxc_allocator::{CloneIn, FromIn, GetAllocator}; +use oxc_ast::ast::{ArrayExpression, ArrayExpressionElement, BinaryOperator, Expression, Str}; +use oxc_span::{GetSpan, SPAN}; + +use crate::{ + ExtractStyleProp, ExtractStyleValue, + assignment_lowering::{Classes, Lowering, empty, projection}, + assignment_member::selected_array, + gen_class_name::{gen_class_names, merge_expression_for_class_name}, + gen_style::gen_styles, + utils::{call_with_values, merge_object_expressions}, +}; + +#[path = "assignment_class_reference.rs"] +mod assignment_class_reference; + +#[derive(Clone, Debug)] +pub struct RewriteEdge { + pub before: crate::extract_style::ExtractDynamicStyle, + pub after: crate::extract_style::ExtractDynamicStyle, + pub invocation: crate::extract_style::compiler_associations::InvocationId, + pub scope: Option, +} +pub(crate) fn rewrite(style: &mut crate::extract_style::ExtractDynamicStyle, consumer: &str) { + let before = style.clone(); + style.replace_identifier(consumer); + crate::extract_style::compiler_receipts::JOURNAL.with_borrow_mut(|journal| { + if let Some(journal) = journal + && let Some(invocation) = journal.graph.invocation + { + journal.graph.rewrites.push(RewriteEdge { + before, + after: style.clone(), + invocation, + scope: journal.graph.scope, + }); + } + }); +} + +impl<'a> Lowering<'_, 'a> { + pub(super) fn array( + &self, + source: &ArrayExpression<'a>, + styles: &[ExtractStyleProp<'a>], + ) -> Expression<'a> { + let ast = self.ast; + let mut values = Vec::new(); + let mut refs = Vec::new(); + let mut raw = source.clone_in(ast.allocator()); + for (index, element) in source.elements.iter().enumerate() { + if let Some(value) = element.as_expression() { + let mut selected: Vec<_> = styles + .iter() + .filter(|style| { + if crate::assignment_owner::contains_consumer(value.span(), style) { + return true; + } + style.extract().iter().any(|value| match value { + ExtractStyleValue::Dynamic(style) => { + usize::from(style.level()) == index + } + ExtractStyleValue::Static(style) => usize::from(style.level()) == index, + ExtractStyleValue::Typography(_) => index == 0, + _ => false, + }) + }) + .map(|style| style.clone_in(ast.allocator())) + .collect(); + let binding = format!("__devupLevel{index}"); + values.push((binding.clone(), self.lower(value, &mut selected))); + raw.elements[index] = ArrayExpressionElement::from(projection(ast, &binding, 2)); + refs.push(binding); + } + } + let class = + merge_expression_for_class_name(ast, refs.iter().map(|name| projection(ast, name, 0))) + .unwrap_or_else(|| Expression::new_string_literal(SPAN, "", None, ast)); + let alternate = self.alternate_order.map(|_| { + merge_expression_for_class_name(ast, refs.iter().map(|name| projection(ast, name, 3))) + .unwrap_or_else(|| Expression::new_string_literal(SPAN, "", None, ast)) + }); + let inline: Vec<_> = refs.iter().map(|name| projection(ast, name, 1)).collect(); + let inline = merge_object_expressions(ast, &inline).unwrap_or_else(|| empty(ast)); + call_with_values( + ast, + values, + self.pair( + Classes { + primary: class, + alternate, + }, + inline, + Expression::ArrayExpression(oxc_allocator::Box::new_in(raw, ast)), + ), + ) + } + + pub(super) fn leaf( + &self, + source: &Expression<'a>, + styles: &[ExtractStyleProp<'a>], + ) -> Expression<'a> { + let ast = self.ast; + let mut values = styles + .iter() + .flat_map(ExtractStyleProp::extract) + .collect::>(); + let input = values + .iter() + .find_map(|value| match value { + ExtractStyleValue::Dynamic(style) + if matches!(source, Expression::TemplateLiteral(_)) || style.important() => + { + Some(Expression::new_identifier( + source.span(), + Str::from_in(style.identifier(), ast.allocator()), + ast, + )) + } + _ => None, + }) + .unwrap_or_else(|| source.clone_in(ast.allocator())); + let important = values + .iter() + .any(|value| matches!(value, ExtractStyleValue::Dynamic(style) if style.important())); + let normalized = values.iter().any(|value| matches!(value, ExtractStyleValue::Dynamic(_) if matches!(source, Expression::TemplateLiteral(_)))) || important; + let suffix = match source { + Expression::TemplateLiteral(template) if normalized => { + template.quasis.last().map_or("", |quasi| { + let original = quasi.value.raw.as_str(); + let trimmed = original.trim_end_matches(';'); + let cleaned = if important { + trimmed.strip_suffix(" !important").unwrap_or(trimmed) + } else { + trimmed + }; + original.strip_prefix(cleaned).unwrap_or("") + }) + } + _ if important => " !important", + _ => "", + }; + let responsive = selected_array(source); + let _scope = crate::extract_style::compiler_projection::ConsumerScope::enter(self.filename); + for value in &mut values { + if let ExtractStyleValue::Dynamic(style) = value { + let consumer = if responsive { + format!("__devupValue?.[{}]", style.level()) + } else { + "__devupValue".to_string() + }; + rewrite(style, &consumer); + } + } + let mut props: Vec<_> = values.into_iter().map(ExtractStyleProp::Static).collect(); + let mut class_props = styles + .iter() + .map(|style| style.clone_in(ast.allocator())) + .collect::>(); + let class_alternatives = self.alternate_order.map(|_| { + class_props + .iter() + .map(|style| style.clone_in(ast.allocator())) + .collect::>() + }); + let mut class = gen_class_names(ast, &mut class_props, self.order, self.filename) + .unwrap_or_else(|| Expression::new_string_literal(SPAN, "", None, ast)); + assignment_class_reference::replace(ast, source, &mut class); + let alternate = + self.alternate_order + .zip(class_alternatives) + .map(|(order, mut alternative)| { + let mut class = + gen_class_names(ast, &mut alternative, order.value, self.filename) + .unwrap_or_else(|| Expression::new_string_literal(SPAN, "", None, ast)); + assignment_class_reference::replace(ast, source, &mut class); + class + }); + props.reverse(); + let inline = gen_styles(ast, &props, self.filename).unwrap_or_else(|| empty(ast)); + let mut value = Expression::new_identifier(SPAN, "__devupValue", ast); + if !suffix.is_empty() { + value = Expression::new_binary_expression( + SPAN, + value, + BinaryOperator::Addition, + Expression::new_string_literal( + SPAN, + Str::from_in(suffix, ast.allocator()), + None, + ast, + ), + ast, + ); + } + call_with_values( + ast, + vec![("__devupValue".to_string(), input)], + self.pair( + Classes { + primary: class, + alternate, + }, + inline, + value, + ), + ) + } + + pub(super) fn pair( + &self, + classes: Classes<'a>, + inline: Expression<'a>, + value: Expression<'a>, + ) -> Expression<'a> { + let mut slots = oxc_allocator::Vec::from_iter_in( + [classes.primary, inline, value] + .into_iter() + .map(ArrayExpressionElement::from), + self.ast, + ); + if let Some(alternate) = classes.alternate { + slots.push(ArrayExpressionElement::from(alternate)); + } + Expression::new_array_expression(SPAN, slots, self.ast) + } +} diff --git a/libs/extractor/src/build_time_values.rs b/libs/extractor/src/build_time_values.rs index 1e8bdd7e3..90f68f187 100644 --- a/libs/extractor/src/build_time_values.rs +++ b/libs/extractor/src/build_time_values.rs @@ -21,6 +21,8 @@ use oxc_syntax::reference::ReferenceId; use oxc_syntax::symbol::SymbolId; use rustc_hash::{FxHashMap, FxHashSet}; +use css::Naming; + use crate::stylex::StylexFunction; use crate::utils::{binding_root, get_string_by_literal_expression, unwrap_syntax_only}; use crate::{ExtractOption, ModuleResolver}; @@ -910,35 +912,43 @@ impl<'a> Visit<'a> for Reads<'_> { /// 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 +type Evaluation = ( + String, + Vec, + BTreeSet, + Vec<(usize, usize)>, +); + pub(crate) fn evaluate( code: &str, filename: &str, option: &ExtractOption, resolver: Option<&ModuleResolver>, unknown: &crate::imported_constants::Unknown, -) -> Option<( - String, - Vec, - BTreeSet, -)> { +) -> Option { let (mut values, dependencies) = compute(code, filename, option, resolver, unknown)?; - values.sort_unstable_by_key(|(span, _)| span.start); + values.sort_unstable_by_key(|(span, ..)| span.start); let mut result = String::with_capacity(code.len()); let mut edits = Vec::with_capacity(values.len()); + let mut risky = Vec::new(); let mut copied = 0; - for (span, literal) in values { + for (span, literal, naming) in values { let (start, end) = (span.start as usize, span.end as usize); result.push_str(&code[copied..start]); + if naming == Naming::Risky { + risky.push((result.len(), result.len() + literal.len())); + } result.push_str(&literal); edits.push((start, end, literal.len())); copied = end; } result.push_str(&code[copied..]); - Some((result, edits, dependencies)) + Some((result, edits, dependencies, risky)) } -/// A value's source text by the span of the code computing it -type Replacement = (Span, String); +/// A value's source text by the span of the code computing it, and whether it +/// depends on an import that may resolve differently between environments +type Replacement = (Span, String, Naming); /// Run before the values: the code checked to read nothing that differs /// between builds or pages, nothing does even if the check missed it @@ -1082,12 +1092,18 @@ fn compute( if found.rules_only && !literal.starts_with(['{', '[', '"']) { continue; } + let naming = if found.closure.imports.is_empty() { + Naming::Own + } else { + Naming::Risky + }; computed.push(( found.span, match found.shorthand { Some(key) => format!("{key}: {literal}"), None => literal, }, + naming, )); } (!computed.is_empty()).then_some((computed, changes.dependencies())) diff --git a/libs/extractor/src/class_evaluation.rs b/libs/extractor/src/class_evaluation.rs new file mode 100644 index 000000000..66adcac32 --- /dev/null +++ b/libs/extractor/src/class_evaluation.rs @@ -0,0 +1,192 @@ +use oxc_allocator::{CloneIn, FromIn, GetAllocator}; +use oxc_ast::{ + ast::{BinaryOperator, Expression, LogicalOperator, Str, UnaryOperator}, + builder::AstBuilder, +}; +use oxc_span::{GetSpan, GetSpanMut, SPAN}; +use oxc_syntax::number::NumberBase; + +use crate::{ + assignment_lowering::projection, + utils::{call_with_values, unwrap_syntax_only}, +}; + +#[path = "class_template.rs"] +mod class_template; + +pub(super) struct ClassEvaluation<'b, 'a>(pub(super) &'b AstBuilder<'a>); + +pub(crate) fn capture<'a>( + ast: &AstBuilder<'a>, + source: &Expression<'a>, + prepared: &mut [Option>], +) -> Option<(String, Expression<'a>)> { + if matches!(unwrap_syntax_only(source), Expression::StringLiteral(_)) { + return None; + } + let name = format!("__devupClass{}", source.span().start); + let mut value = match prepared { + [Some(primary), Some(alternate)] => { + ClassEvaluation(ast).lower(source, [primary, alternate]) + } + [Some(primary)] => primary.clone_in(ast.allocator()), + _ => return None, + }; + *value.span_mut() = source.span(); + let joint = prepared.len() == 2; + for (index, prepared) in prepared.iter_mut().enumerate() { + *prepared = Some(if joint { + projection(ast, &name, if index == 0 { 0 } else { 3 }) + } else { + Expression::new_identifier( + source.span(), + Str::from_in(name.as_str(), ast.allocator()), + ast, + ) + }); + } + Some((name, value)) +} + +impl<'a> ClassEvaluation<'_, 'a> { + pub(super) fn lower( + &self, + source: &Expression<'a>, + variants: [&Expression<'a>; 2], + ) -> Expression<'a> { + let ast = self.0; + let source = unwrap_syntax_only(source); + let [primary, alternate] = variants.map(unwrap_syntax_only); + match (source, primary, alternate) { + (Expression::StringLiteral(_), _, _) => self.tuple( + [ + primary.clone_in(ast.allocator()), + source.clone_in(ast.allocator()), + alternate.clone_in(ast.allocator()), + ], + false, + ), + ( + Expression::ConditionalExpression(source), + Expression::ConditionalExpression(primary), + Expression::ConditionalExpression(alternate), + ) => Expression::new_conditional_expression( + source.span, + source.test.clone_in(ast.allocator()), + self.lower( + &source.consequent, + [&primary.consequent, &alternate.consequent], + ), + self.lower( + &source.alternate, + [&primary.alternate, &alternate.alternate], + ), + ast, + ), + ( + Expression::LogicalExpression(source), + Expression::LogicalExpression(primary), + Expression::LogicalExpression(alternate), + ) => { + let left = self.lower(&source.left, [&primary.left, &alternate.left]); + let raw = projection(ast, "__devupClassLeft", 2); + let test = match source.operator { + LogicalOperator::And => { + Expression::new_unary_expression(SPAN, UnaryOperator::LogicalNot, raw, ast) + } + LogicalOperator::Or => raw, + LogicalOperator::Coalesce => Expression::new_logical_expression( + SPAN, + Expression::new_binary_expression( + SPAN, + raw.clone_in(ast.allocator()), + BinaryOperator::StrictInequality, + Expression::new_null_literal(SPAN, ast), + ast, + ), + LogicalOperator::And, + Expression::new_binary_expression( + SPAN, + raw, + BinaryOperator::StrictInequality, + Expression::new_unary_expression( + SPAN, + UnaryOperator::Void, + Expression::new_numeric_literal( + SPAN, + 0.0, + None, + NumberBase::Decimal, + ast, + ), + ast, + ), + ast, + ), + ast, + ), + }; + let selected = call_with_values( + ast, + vec![("__devupClassLeft".to_string(), left)], + Expression::new_conditional_expression( + SPAN, + test, + Expression::new_identifier(SPAN, "__devupClassLeft", ast), + Expression::new_null_literal(SPAN, ast), + ast, + ), + ); + Expression::new_logical_expression( + source.span, + selected, + LogicalOperator::Coalesce, + self.lower(&source.right, [&primary.right, &alternate.right]), + ast, + ) + } + ( + Expression::TemplateLiteral(source), + Expression::TemplateLiteral(primary), + Expression::TemplateLiteral(alternate), + ) => self.template(source, [primary, alternate]), + _ => { + let value = Expression::new_identifier(SPAN, "__devupClassValue", ast); + call_with_values( + ast, + vec![( + "__devupClassValue".to_string(), + source.clone_in(ast.allocator()), + )], + self.tuple( + [ + value.clone_in(ast.allocator()), + value.clone_in(ast.allocator()), + value, + ], + true, + ), + ) + } + } + } + + pub(super) fn tuple(&self, values: [Expression<'a>; 3], shared: bool) -> Expression<'a> { + let [primary, raw, alternate] = values; + Expression::new_array_expression( + SPAN, + oxc_allocator::Vec::from_iter_in( + [ + primary, + Expression::new_boolean_literal(SPAN, shared, self.0), + raw, + alternate, + ] + .into_iter() + .map(oxc_ast::ast::ArrayExpressionElement::from), + self.0, + ), + self.0, + ) + } +} diff --git a/libs/extractor/src/class_template.rs b/libs/extractor/src/class_template.rs new file mode 100644 index 000000000..1dd104456 --- /dev/null +++ b/libs/extractor/src/class_template.rs @@ -0,0 +1,117 @@ +use oxc_allocator::{CloneIn, FromIn, GetAllocator}; +use oxc_ast::ast::{Expression, Str, TemplateElement, TemplateElementValue, TemplateLiteral}; +use oxc_span::SPAN; + +use super::ClassEvaluation; +use crate::{assignment_lowering::projection, utils::call_with_values}; + +impl<'a> ClassEvaluation<'_, 'a> { + pub(super) fn template( + &self, + source: &TemplateLiteral<'a>, + variants: [&TemplateLiteral<'a>; 2], + ) -> Expression<'a> { + let ast = self.0; + let [primary, alternate] = variants; + let mut bindings = Vec::with_capacity(source.expressions.len()); + let mut values = Vec::with_capacity(source.expressions.len()); + for (index, ((source, primary), alternate)) in source + .expressions + .iter() + .zip(&primary.expressions) + .zip(&alternate.expressions) + .enumerate() + { + let name = format!("__devupClassPart{index}"); + values.push(( + name.clone(), + self.coerced(self.lower(source, [primary, alternate])), + )); + bindings.push(name); + } + let prepared = |template: &TemplateLiteral<'a>, slot| { + Expression::new_template_literal( + template.span, + template.quasis.clone_in(ast.allocator()), + oxc_allocator::Vec::from_iter_in( + bindings.iter().map(|name| projection(ast, name, slot)), + ast, + ), + ast, + ) + }; + call_with_values( + ast, + values, + self.tuple( + [ + prepared(primary, 0), + prepared(source, 2), + prepared(alternate, 3), + ], + false, + ), + ) + } + + fn coerced(&self, value: Expression<'a>) -> Expression<'a> { + let ast = self.0; + let string = Expression::new_identifier(SPAN, "__devupClassString", ast); + let coerced = call_with_values( + ast, + vec![( + "__devupClassString".to_string(), + Expression::new_template_literal( + SPAN, + oxc_allocator::Vec::from_array_in( + [ + TemplateElement::new( + SPAN, + TemplateElementValue { + raw: Str::from_in("", ast.allocator()), + cooked: None, + }, + false, + ast, + ), + TemplateElement::new( + SPAN, + TemplateElementValue { + raw: Str::from_in("", ast.allocator()), + cooked: None, + }, + true, + ast, + ), + ], + ast, + ), + oxc_allocator::Vec::from_array_in( + [projection(ast, "__devupClassPart", 2)], + ast, + ), + ast, + ), + )], + self.tuple( + [ + string.clone_in(ast.allocator()), + string.clone_in(ast.allocator()), + string, + ], + false, + ), + ); + call_with_values( + ast, + vec![("__devupClassPart".to_string(), value)], + Expression::new_conditional_expression( + SPAN, + projection(ast, "__devupClassPart", 1), + coerced, + Expression::new_identifier(SPAN, "__devupClassPart", ast), + ast, + ), + ) + } +} diff --git a/libs/extractor/src/compiler_associations.rs b/libs/extractor/src/compiler_associations.rs new file mode 100644 index 000000000..51793efab --- /dev/null +++ b/libs/extractor/src/compiler_associations.rs @@ -0,0 +1,229 @@ +pub use super::compiler_projection::{OrderEdge, RewriteEdge, VariableUse}; +use super::compiler_receipts::{self as receipts, ReceiptId, WitnessDisposition}; +use super::compiler_request::context; +use super::extract_style_value::ExtractStyleValue as Value; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SurvivorId(pub(crate) usize); +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct InvocationId(pub(crate) usize); +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ScopeId(pub(crate) usize); +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct OperandWitnessId(pub(crate) usize); +#[derive(Clone, Debug)] +pub struct OperandWitness { + pub id: OperandWitnessId, + pub operand: Value, + pub survivor: SurvivorId, + pub invocation: InvocationId, +} +#[derive(Clone, Debug)] +pub struct FinalAssociation { + pub operand: Value, + pub survivor: SurvivorId, + pub receipt: ReceiptId, +} +#[derive(Clone, Debug)] +pub struct NamingObservation { + pub operand: Value, + pub route: Option, + pub receipt: ReceiptId, + pub invocation: InvocationId, + pub scope: Option, +} +#[derive(Clone, Debug)] +pub struct ActualInvocation { + pub id: InvocationId, + pub raw: String, + pub original: u32, +} +#[derive(Clone, Debug)] +pub struct ConsumerFrame { + pub id: ScopeId, + pub parent: Option, + pub route: Option, + pub invocation: InvocationId, +} +#[derive(Clone, Default)] +pub struct Graph { + pub operands: Vec, + pub emissions: Vec, + pub naming: Vec, + pub invocations: Vec, + pub frames: Vec, + pub variables: Vec, + pub rewrites: Vec, + pub orders: Vec, + pub(crate) invocation: Option, + pub(crate) scope: Option, +} +impl Graph { + pub(crate) fn route(&self) -> Option { + self.scope + .and_then(|scope| self.frames[scope.0].route.clone()) + } +} +pub(crate) struct RawScope(Option); +impl RawScope { + pub(crate) fn enter(raw: &str, original: u32) -> Self { + Self(receipts::JOURNAL.with_borrow_mut(|journal| { + journal.as_mut().and_then(|journal| { + let graph = &mut journal.graph; + let id = InvocationId(graph.invocations.len()); + graph.invocations.push(ActualInvocation { + id, + raw: raw.into(), + original, + }); + graph.invocation.replace(id) + }) + })) + } +} +impl Drop for RawScope { + fn drop(&mut self) { + receipts::JOURNAL.with_borrow_mut(|journal| { + if let Some(journal) = journal { + journal.graph.invocation = self.0; + } + }); + } +} +pub(crate) struct ConsumerScope(Option); +impl ConsumerScope { + pub(crate) fn enter(route: Option<&str>) -> Self { + Self(receipts::JOURNAL.with_borrow_mut(|journal| { + journal.as_mut().and_then(|journal| { + let graph = &mut journal.graph; + let invocation = graph.invocation?; + let parent = graph.scope; + let inherited = parent.and_then(|id| graph.frames[id.0].route.clone()); + let id = ScopeId(graph.frames.len()); + graph.frames.push(ConsumerFrame { + id, + parent, + route: route.map(str::to_string).or(inherited), + invocation, + }); + graph.scope.replace(id) + }) + })) + } +} +impl Drop for ConsumerScope { + fn drop(&mut self) { + receipts::JOURNAL.with_borrow_mut(|journal| { + if let Some(journal) = journal { + journal.graph.scope = self.0; + } + }); + } +} +#[derive(Default)] +pub(crate) struct CollectedStyles(rustc_hash::FxHashMap>); +impl CollectedStyles { + pub(crate) fn insert(&mut self, value: Value) { + receipts::JOURNAL.with_borrow_mut(|journal| { + let survivor = *self.0.entry(value.clone()).or_insert_with(|| { + journal + .as_ref() + .map(|journal| SurvivorId(journal.graph.operands.len())) + }); + if let (Some(journal), Some(survivor)) = (journal, survivor) + && let Some(invocation) = journal.graph.invocation + { + journal.graph.operands.push(OperandWitness { + id: OperandWitnessId(journal.graph.operands.len()), + operand: value, + survivor, + invocation, + }); + } + }); + } + pub(crate) fn extend(&mut self, values: impl IntoIterator) { + for value in values { + self.insert(value); + } + } + pub(crate) fn iter(&self) -> impl Iterator { + self.0.keys() + } + pub(crate) fn len(&self) -> usize { + self.0.len() + } + pub(crate) fn is_empty(&self) -> bool { + self.len() == 0 + } + pub(crate) fn into_set(self) -> rustc_hash::FxHashSet { + self.0.into_keys().collect() + } +} +pub(crate) fn complete(filename: &str, option: &crate::ExtractOption, styles: &CollectedStyles) { + if crate::compiler_policy::active() && !receipts::reservation() { + let mut sorted: Vec<_> = styles.iter().collect(); + sorted.sort(); + let bucket = crate::css_bucket(filename, option); + for value in sorted { + match value { + Value::Static(_) | Value::Dynamic(_) | Value::Keyframes(_) => {} + Value::Typography(_) | Value::Css(_) | Value::Import(_) | Value::FontFace(_) => { + continue; + } + } + let result = context(value, bucket.as_deref()).and_then(|context| { + let found = receipts::JOURNAL.with_borrow(|journal| { + journal + .as_ref() + .and_then(|journal| journal.find(value, &context)) + }); + found.map_or_else( + || receipts::acquire(value, bucket.as_deref(), context).map(|value| value.0), + Ok, + ) + }); + if let Some(receipt) = receipts::collect_operand(result, value) { + receipts::JOURNAL.with_borrow_mut(|journal| { + if let Some(journal) = journal + && let Some(Some(survivor)) = styles.0.get(value) + { + journal.witnesses[receipt.0].disposition = WitnessDisposition::Emission; + journal.graph.emissions.push(FinalAssociation { + operand: value.clone(), + survivor: *survivor, + receipt, + }); + } + }); + } + } + } +} +pub(crate) fn ordered(before: &Value, after: &Value, receipt: ReceiptId) { + receipts::JOURNAL.with_borrow_mut(|journal| { + if let Some(journal) = journal + && let Some(invocation) = journal.graph.invocation + { + let graph = &mut journal.graph; + for variable in &mut graph.variables { + let original = Value::Dynamic(variable.original.clone()); + if variable.invocation == invocation + && variable.scope == graph.scope + && (super::compiler_request::same(before, &original) + || super::compiler_request::same(after, &original)) + && !variable.receipts.contains(&receipt) + { + variable.receipts.push(receipt); + } + } + graph.orders.push(OrderEdge { + before: before.clone(), + after: after.clone(), + receipt, + invocation, + scope: graph.scope, + }); + } + }); +} diff --git a/libs/extractor/src/compiler_boundary_tests.rs b/libs/extractor/src/compiler_boundary_tests.rs new file mode 100644 index 000000000..dfc06b44b --- /dev/null +++ b/libs/extractor/src/compiler_boundary_tests.rs @@ -0,0 +1,268 @@ +use super::{ObserveScope, associated, failed, input, one, reset}; +use crate::compiler_policy::{CounterCompileError, with_counter_extract}; +use crate::extract_style::{ + compiler_projection as projection, compiler_receipts as receipts, + extract_style_value::ExtractStyleValue as Value, +}; +use projection::ProjectionError as Fault; +use receipts::coverage_tests::maps; +use rstest::rstest; +use serial_test::serial; +use std::cell::Cell; + +const SOURCE: &str = "import {Box,globalCss} from '@devup-ui/react'; const a=; globalCss`body{margin:0;}`; globalCss({body:{opacity:1},imports:['a.css'],fontFaces:[{fontFamily:'X',src:'x.woff'}]});"; + +fn journal(action: impl FnOnce(&mut receipts::Journal) -> T) -> T { + receipts::JOURNAL.with_borrow_mut(|journal| action(one(journal.as_mut()))) +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn current_parser_operands_are_rejected_by_counter_consumers(#[case] variable: bool) { + // Given: Current operands are acquired by the public parser before Counter starts. + reset(); + let current = crate::extract_without_source_map("current.tsx", SOURCE, Default::default()) + .unwrap_or_else(|error| panic!("{error}")); + let before = maps(); + let _observer = ObserveScope::enter(move |retry| { + assert!(!retry); + let operand = one(current + .styles + .iter() + .filter(|value| matches!(value, Value::Dynamic(style) if style.property() == "color"))); + let observed = maps(); + assert!(matches!( + projection::context(operand, Some("boundary.tsx")), + Err(Fault::Producer( + crate::extract_style::CounterProducerError::WrongPolicy + )) + )); + // When: the active Counter consumer receives a real Current operand. + let projected = if variable { + projection::inline(operand, Some("boundary.tsx")).map(|value| value.variable_name) + } else { + crate::gen_class_name::class_name(operand, Some("boundary.tsx")) + }; + assert_eq!(projected, None); + assert_eq!(maps(), observed); + }); + let error = failed(input("boundary.tsx", SOURCE)); + // Then: the typed terminal rejects publication and all four maps roll back. + assert!( + matches!(error.downcast_ref(), Some(Fault::Terminal(message)) if message.contains("counter producer requires retained CounterOriginal policy")) + ); + assert_eq!(maps(), before); + drop(_observer); + let (_, _, witnesses, _) = associated(input("boundary.tsx", SOURCE)); + assert_eq!(witnesses.len(), 3); +} + +#[test] +#[serial] +fn authored_nonallocating_operands_reject_allocation_without_inventing_demands() { + // Given: Typography, CSS, Import and FontFace are real authored parser outputs. + reset(); + let before = maps(); + let _observer = ObserveScope::enter(|retry| { + assert!(!retry); + let operands = journal(|journal| { + journal + .graph + .operands + .iter() + .map(|row| row.operand.clone()) + .collect::>() + }); + let mut checked = 0; + for operand in operands { + match operand { + Value::Typography(_) | Value::Css(_) | Value::Import(_) | Value::FontFace(_) => { + let observed = maps(); + // When: allocation protocol is fault-injected, not a naming demand. + let result = projection::produce(&operand, Some("boundary.tsx")); + assert!(matches!( + result, + Err(Fault::Producer( + crate::extract_style::CounterProducerError::WrongPolicy + )) + )); + let result = projection::context(&operand, Some("boundary.tsx")); + assert!(matches!( + result, + Err(Fault::Producer( + crate::extract_style::CounterProducerError::WrongPolicy + )) + )); + assert_eq!(maps(), observed); + assert_eq!(receipts::collect_operand(result, &operand), None); + checked += 1; + } + Value::Static(_) | Value::Dynamic(_) | Value::Keyframes(_) => {} + } + } + assert_eq!(checked, 4); + }); + let error = failed(input("boundary.tsx", SOURCE)); + // Then: every no-origin operand is transported, with exact rollback. + assert!( + matches!(error.downcast_ref(), Some(Fault::Terminal(message)) if message.lines().count() == 4) + ); + assert_eq!(maps(), before); +} + +#[rstest] +#[case(0)] +#[case(1)] +#[case(2)] +#[case(3)] +#[case(4)] +#[serial] +fn no_site_variable_uses_actual_acquired_receipts_or_rejects_ambiguity(#[case] mode: u8) { + // Given: remove only the site from actual parsed Counter dynamic operands. + reset(); + let before = maps(); + let _observer = ObserveScope::enter(move |retry| { + assert!(!retry); + let mut values = journal(|journal| { + journal + .graph + .operands + .iter() + .filter_map(|row| match &row.operand { + Value::Dynamic(style) => Some(style.clone()), + Value::Static(_) + | Value::Typography(_) + | Value::Keyframes(_) + | Value::Css(_) + | Value::Import(_) + | Value::FontFace(_) => None, + }) + .collect::>() + }); + assert_eq!(values.len(), 2); + for style in &mut values { + assert!(style.site().is_some()); + style.site = None; + } + let operand = Value::Dynamic(values[0].clone()); + let expected = match mode { + 0 => None, + 1..=4 => { + // These are explicitly injected no-site requests, never claimed authored. + assert!( + crate::gen_class_name::class_name(&operand, Some("boundary.tsx")).is_some() + ); + let produced = journal(|journal| one(journal.witnesses.last()).produced.clone()); + let receipts::ProducedData::Dynamic(produced) = produced else { + panic!("dynamic production") + }; + assert_eq!(produced.site, None); + assert!(produced.variable_allocation.is_some()); + let expected = produced.variable.clone(); + if mode == 2 { + assert!( + crate::gen_class_name::class_name(&operand, Some("second.tsx")).is_some() + ); + } + if mode == 3 || mode == 4 { + let other = match mode { + 3 => Value::Dynamic(values[1].clone()), + 4 => journal(|journal| { + one(journal + .graph + .operands + .iter() + .filter(|row| matches!(row.operand, Value::Static(_)))) + .operand + .clone() + }), + _ => panic!("association fault mode"), + }; + assert!( + crate::gen_class_name::class_name(&other, Some("boundary.tsx")).is_some() + ); + journal(|journal| { + let id = one(journal.witnesses.last()).id; + let edge = one(journal.graph.orders.last_mut()); + edge.before = operand.clone(); + assert_eq!(edge.receipt, id); + }); + } + Some(expected) + } + _ => panic!("test mode"), + }; + let observed = maps(); + // When: the actual inline extractor consumer resolves the injected site loss. + let actual = projection::inline(&operand, Some("boundary.tsx")); + match mode { + 0 | 3 | 4 => assert!(actual.is_none()), + 1 | 2 => { + let actual = one(actual); + assert_eq!(Some(actual.variable_name), expected); + assert_eq!(actual.identifier, values[0].identifier()); + } + _ => panic!("test mode"), + } + assert_eq!(maps(), observed); + }); + let published = Cell::new(false); + let result = with_counter_extract(input("boundary.tsx", SOURCE), |_, batch| { + published.set(true); + batch.consume(|view| { + assert_eq!(view.associations.emissions.len(), 3); + Err::<(), _>(37) + }) + }); + // Then: either a typed terminal or exact consumer rejection rolls back everything. + match mode { + 0 | 3 | 4 => { + assert!(!published.get()); + let error = match one(result.err()) { + CounterCompileError::Compile(error) => error, + CounterCompileError::Consumer(error) => panic!("unexpected consumer {error}"), + }; + assert!( + matches!(error.downcast_ref(), Some(Fault::Terminal(message)) if message.contains("counter variable requires an unambiguous finalized receipt")) + ); + } + 1 | 2 => { + assert!(published.get()); + assert!(matches!(result, Err(CounterCompileError::Consumer(37)))); + } + _ => panic!("test mode"), + } + assert_eq!(maps(), before); + drop(_observer); + let (_, _, witnesses, _) = associated(input("boundary.tsx", SOURCE)); + assert_eq!(witnesses.len(), 3); +} + +#[test] +#[serial] +fn rollback_allows_clean_real_parser_retry_after_boundary_rejection() { + // Given: a real consumed batch rejection, with all naming state restored. + reset(); + let before = maps(); + let result = with_counter_extract(input("boundary.tsx", SOURCE), |_, batch| { + batch.consume(|_| Err::<(), _>(37)) + }); + assert!(matches!(result, Err(CounterCompileError::Consumer(37)))); + assert_eq!(maps(), before); + // When: the identical real source is compiled again without injection. + let (metadata, _, witnesses, graph) = associated(input("boundary.tsx", SOURCE)); + // Then: the true D9 original and all rendered class/variable receipts agree. + assert_eq!(css::file_map::get_original_ids()["boundary.tsx"], 0); + assert_eq!(witnesses.len(), 3); + assert_eq!(graph.emissions.len(), 3); + for witness in witnesses { + assert_eq!(witness.produced.class().original, 0); + assert!( + metadata + .code + .contains(&witness.produced.class().allocation.name) + ); + } +} diff --git a/libs/extractor/src/compiler_coverage_tests.rs b/libs/extractor/src/compiler_coverage_tests.rs new file mode 100644 index 000000000..0a80eff96 --- /dev/null +++ b/libs/extractor/src/compiler_coverage_tests.rs @@ -0,0 +1,279 @@ +use crate::compiler_policy::tests::{ObserveScope, associated, failed, input, one, reset}; +use crate::compiler_policy::{CounterCompileError, with_counter_extract}; +use crate::extract_style::{ + compiler_associations as associations, compiler_projection as projection, + compiler_receipts as receipts, extract_style_value::ExtractStyleValue as Value, +}; +use projection::ProjectionError as Fault; +use rstest::rstest; +use serial_test::serial; +use std::{ + cell::Cell, + collections::{BTreeMap, HashMap}, + convert::Infallible, +}; + +fn journal(action: impl FnOnce(&mut receipts::Journal) -> T) -> T { + receipts::JOURNAL.with_borrow_mut(|journal| { + let journals = journal.as_mut_slice(); + assert_eq!(journals.len(), 1); + action(&mut journals[0]) + }) +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct Maps { + classes: HashMap>, + files: BTreeMap, + originals: BTreeMap, + canonical: HashMap, +} +pub(crate) fn maps() -> Maps { + Maps { + classes: css::class_map::get_class_map(), + files: css::file_map::get_file_map().into_iter().collect(), + originals: css::file_map::get_original_ids(), + canonical: css::file_map::get_canonical_map(), + } +} +fn operands() -> Vec { + journal(|journal| { + let rows = &journal.graph.operands; + rows.iter().map(|row| row.operand.clone()).collect() + }) +} + +#[rstest] +#[case( + "import * as stylex from '@stylexjs/stylex'; const k=stylex.keyframes({from:{'opacity':0}});" +)] +#[case("import {keyframes} from '@devup-ui/react'; const k=keyframes({from:{'opacity':0}});")] +#[case("import {keyframes} from '@devup-ui/react'; const k=keyframes`from{opacity:0;}`;")] +#[case( + "import * as stylex from '@stylexjs/stylex'; const s=stylex.create({base:(height)=>({'height':height})}); const a=stylex.props(s.base(value));" +)] +#[serial] +fn parser_rejects_configuration_corruption_when_projection_demands_a_name(#[case] body: &str) { + // Given: a real resolver crosses the compilation boundary before naming. + reset(); + let before = maps(); + let resolver = |_: &str, _: &str| { + journal(|journal| journal.config.prefix.push_str("corrupt")); + Some(crate::ResolvedModule { + path: "constant.ts".into(), + code: "export const v=1;".into(), + }) + }; + let source = format!( + "import {{v}} from './constant'; import {{css as probeCss}} from '@devup-ui/react'; const probe=probeCss({{opacity:v}}); {body}" + ); + let mut request = input("coverage.tsx", &source); + request.resolver = Some(&resolver); + // When: the actual visitor attempts its class/keyframe/early-variable projection. + let error = failed(request); + // Then: typed, located compilation failure rolls every naming map back. + assert!( + matches!(error.downcast_ref::(), Some(projection::ProjectionError::Terminal(message)) if message.contains("coverage.tsx:") && message.contains("counter naming configuration changed")), + "{error}" + ); + assert_eq!(maps(), before); +} + +#[test] +#[serial] +fn sealing_rejects_a_real_variable_when_its_finalized_association_is_lost() { + // Given: parsed variable uses, not a fabricated journal or batch. + reset(); + let before = maps(); + let _observer = ObserveScope::enter(|retry| { + assert!(!retry); + let operand = operands().remove(0); + let expected = journal(|journal| { + journal.graph.variables[0].receipts.clear(); + let produced = &journal.witnesses[0].produced; + produced.class().allocation.name.clone() + }); + let class = crate::gen_class_name::class_name(&operand, Some("coverage.tsx")); + assert_eq!(class, Some(expected)); + journal(|journal| { + let graph = &mut journal.graph; + assert_eq!(graph.variables.len(), 1); + assert_eq!(graph.variables[0].receipts.len(), 1); + graph.variables[0].receipts.clear(); + }); + }); + let published = Cell::new(false); + // When: sealing sees the damaged real association after successful parsing. + let result = with_counter_extract( + input( + "coverage.tsx", + "import {Box} from '@devup-ui/react'; const a=;", + ), + |_, batch| { + published.set(true); + batch.consume(|_| Ok::<_, Infallible>(())) + }, + ); + // Then: conversion preserves the Variable variant and the consumer never runs. + assert!(!published.get()); + let error = match one(result.err()) { + CounterCompileError::Compile(error) => error, + CounterCompileError::Consumer(error) => match error {}, + }; + assert!(matches!(error.downcast_ref(), Some(Fault::Variable))); + assert_eq!( + error.to_string(), + "counter variable requires an unambiguous finalized receipt" + ); + assert_eq!(maps(), before); +} + +#[test] +#[serial] +fn acquired_context_failure_keeps_real_demand_when_origin_scope_has_ended() { + // Given: an actual parsed dynamic use whose expression scope has ended. + reset(); + let before = maps(); + let _observer = ObserveScope::enter(|retry| { + assert!(!retry); + let operand = operands().remove(0); + let original = journal(|journal| journal.graph.variables[0].original.clone()); + let site = one(original.site()); + let at = one(u32::try_from(site.at)); + let _demand = projection::DemandScope::enter(at); + let context = one(projection::context(&operand, Some("other.tsx"))); + // When: acquisition produces for the real route, not the requested context. + let result = receipts::acquire(&operand, Some("coverage.tsx"), context); + assert!(matches!(result, Err(Fault::Context))); + assert!(receipts::collect(result, None).is_none()); + }); + let error = failed(input( + "coverage.tsx", + "import {Box} from '@devup-ui/react';\nconst a=;", + )); + // Then: the typed transport uses the real demand, not a fabricated 1:1. + assert!(error.is::()); + assert_eq!( + error.to_string(), + "coverage.tsx:2:21: counter naming configuration changed within compilation" + ); + assert_eq!(maps(), before); +} + +#[test] +#[serial] +fn consumed_parser_batch_preserves_consumer_error_and_rolls_back_all_maps() { + // Given: a real batch containing static and dynamic finalized productions. + reset(); + let before = maps(); + // When: the sealed-view consumer rejects a nonempty batch. + let result = with_counter_extract( + input( + "coverage.tsx", + "import {Box} from '@devup-ui/react'; const a=;", + ), + |metadata, batch| { + batch.consume(|view| { + assert_eq!(view.styles.len(), 2); + assert_eq!(view.witnesses.len(), 2); + assert_eq!(view.associations.emissions.len(), 2); + assert!(view.styles.windows(2).all(|pair| pair[0] <= pair[1])); + for witness in view.witnesses { + let name = &witness.produced.class().allocation.name; + assert!(metadata.code.contains(name)); + } + Err::<(), _>(37) + }) + }, + ); + // Then: the exact consumer value, not a Compile error, crosses the API. + assert!(matches!(result, Err(CounterCompileError::Consumer(37)))); + assert_eq!(maps(), before); +} + +#[test] +#[serial] +fn collection_retains_real_parser_operands_when_duplicates_collapse() { + // Given: the observer borrows actual pre-union operands in the active invocation. + reset(); + let _observer = ObserveScope::enter(|retry| { + assert!(!retry); + let operands = operands(); + assert_eq!(operands.len(), 2); + let mut collected = associations::CollectedStyles::default(); + // When: both genuine duplicates enter through the production extend inlet. + collected.extend(operands); + // Then: one survivor retains both original occurrence associations. + assert_eq!(collected.len(), 1); + journal(|journal| { + let rows = &journal.graph.operands; + assert_eq!(rows.len(), 4); + assert_eq!(rows[2].survivor, rows[3].survivor); + assert_eq!(rows[2].invocation, rows[0].invocation); + }); + }); + let (_, styles, _, _) = associated(input( + "coverage.tsx", + "import {css} from '@devup-ui/react'; const a=css({color:'red'}); const b=css({color:'red'});", + )); + assert_eq!(styles.len(), 1); +} + +#[test] +#[serial] +fn operand_diagnostics_keep_location_when_real_parser_operands_are_transport_errors() { + // Given: static, dynamic, keyframe and raw-CSS operands from the real visitor. + reset(); + let before = maps(); + let _observer = ObserveScope::enter(|retry| { + assert!(!retry); + let operands = operands(); + assert_eq!(operands.len(), 6); + assert_eq!(receipts::collect(Ok(operands.len()), None), Some(6)); + // When: the projection protocol transports a failure for each actual operand. + for operand in operands { + if matches!( + operand, + Value::Css(_) | Value::Import(_) | Value::FontFace(_) + ) { + let before = maps(); + let class = crate::gen_class_name::class_name(&operand, Some("coverage.tsx")); + assert_eq!(class, None); + assert_eq!(maps(), before); + } + assert_eq!(receipts::collect_operand(Ok(17), &operand), Some(17)); + let rejected = Err::<(), _>(projection::ProjectionError::Context); + assert_eq!(receipts::collect_operand(rejected, &operand), None); + } + }); + let error = failed(input( + "coverage.tsx", + "import {Box,keyframes,globalCss} from '@devup-ui/react'; const a=; const k=keyframes({from:{opacity:0}}); globalCss`body{margin:0;}`; globalCss({imports:['a.css'],fontFaces:[{fontFamily:'X',src:'x.woff'}]});", + )); + // Then: three source-located operands and truthful demand transport survive erasure. + let message = error.to_string(); + assert_eq!(message.lines().count(), 6); + assert_eq!(message.matches("coverage.tsx:").count(), 6, "{message}"); + let distinct: std::collections::BTreeSet<_> = message.lines().collect(); + assert_eq!(distinct.len(), 4, "{message}"); + assert_eq!(maps(), before); +} + +#[test] +fn retry_error_formats_as_a_continuation_when_erased_for_transport() { + // Given: actual parser/evaluator output requiring a clean continuation. + let values = crate::build_time_values::evaluate( + "import {css} from '@devup-ui/react'; const pick=()=>1; const s=css({opacity:pick()});", + "coverage.tsx", + &Default::default(), + None, + &Default::default(), + ); + let values = one(values); + assert_ne!(values.1.len(), 0); + let error = crate::compiler_policy::retry(values, &[]); + // When: an error consumer renders the erased diagnostic. + let diagnostic = error.to_string(); + // Then: transport describes deferred evaluation, not a terminal naming failure. + assert_eq!(diagnostic, "deferred Counter evaluation continuation"); +} diff --git a/libs/extractor/src/compiler_diagnostics.rs b/libs/extractor/src/compiler_diagnostics.rs new file mode 100644 index 000000000..bb85893d1 --- /dev/null +++ b/libs/extractor/src/compiler_diagnostics.rs @@ -0,0 +1,110 @@ +use super::{CounterProducerError, compiler_receipts as receipts}; +use crate::import_alias_visit::Edit; +use std::error::Error; + +#[derive(Clone, Debug)] +pub(crate) enum ProjectionError { + Producer(CounterProducerError), + Context, + Variable, + Site(String), + Terminal(String), +} +impl std::fmt::Display for ProjectionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Producer(error) => error.fmt(f), + Self::Context => f.write_str("counter naming configuration changed within compilation"), + Self::Variable => { + f.write_str("counter variable requires an unambiguous finalized receipt") + } + Self::Site(message) | Self::Terminal(message) => f.write_str(message), + } + } +} +impl Error for ProjectionError {} +impl From for crate::compiler_policy::CounterCompileError { + fn from(error: ProjectionError) -> Self { + Self::Compile(Box::new(error)) + } +} +pub(crate) fn collect(result: Result, offset: Option) -> Option { + match result { + Ok(value) => Some(value), + Err(error) => { + receipts::JOURNAL.with_borrow_mut(|journal| { + if let Some(journal) = journal { + journal.errors.push(( + offset.unwrap_or_else(super::compiler_projection::demand), + error, + offset + .map_or_else(crate::style_origin::current, crate::style_origin::at) + .location(), + )); + } + }); + None + } + } +} +pub(crate) fn retain_terminal(error: &(dyn Error + 'static)) { + if let Some(error) = error.downcast_ref::() { + collect::<()>(Err(error.clone()), None); + } +} +pub(crate) fn check_terminal( + filename: &str, + source: &str, + edits: &[&[Edit]], +) -> Result<(), Box> { + if crate::compiler_policy::active() { + for (at, message) in crate::provenance::site_errors() { + collect::<()>(Err(ProjectionError::Site(message)), Some(at)); + } + } + let errors = receipts::JOURNAL.with_borrow_mut(|journal| { + journal + .as_mut() + .map_or_else(Vec::new, |journal| std::mem::take(&mut journal.errors)) + }); + if errors.is_empty() { + return Ok(()); + } + let message = errors + .into_iter() + .map(|(at, error, origin)| match (&error, origin) { + (ProjectionError::Terminal(message), _) => message.clone(), + (_, Some(origin)) => format!("{origin}: {error}"), + (_, None) + if at == 0 + || crate::sparse_sites::counter_owner() == css::CounterOwner::Unnumbered => + { + format!( + "{}: {error}", + css::style_origin::RealLocation::ModuleExport { + file: filename.into(), + binding: None + } + ) + } + (_, None) => { + crate::located_errors(filename, source, edits, vec![(at, error.to_string())]) + } + }) + .collect::>() + .join("\n"); + Err(Box::new(ProjectionError::Terminal(message))) +} +pub(crate) fn check_aux( + filename: &str, + source: &str, + errors: Vec<(u32, String)>, +) -> Result<(), Box> { + if crate::compiler_policy::active() { + for (offset, message) in errors { + collect::<()>(Err(ProjectionError::Site(message)), Some(offset)); + } + check_terminal(filename, source, &[])?; + } + Ok(()) +} diff --git a/libs/extractor/src/compiler_policy.rs b/libs/extractor/src/compiler_policy.rs new file mode 100644 index 000000000..ae0c0bcf1 --- /dev/null +++ b/libs/extractor/src/compiler_policy.rs @@ -0,0 +1,186 @@ +use crate::extract_style::compiler_projection as projection; +use crate::extract_style::compiler_receipts::{self as receipts, CompilerReceiptBatch}; +use crate::import_alias_visit::Edit; +use crate::provenance::SiteScope; +use crate::{Evaluated, ExtractOption, ExtractOutput, ModuleResolver}; +use std::{cell::Cell, collections::BTreeSet, error::Error}; + +/// Existing compiler inputs, with the exact received filename. +pub struct CompilerInput<'a> { + pub filename: &'a str, + pub code: &'a str, + pub option: ExtractOption, + pub source_map: bool, + pub resolver: Option<&'a ModuleResolver>, +} +/// JavaScript metadata; final styles belong exclusively to the sealed batch. +pub struct CompilerOutputMetadata { + pub code: String, + pub map: Option, + pub css_file: Option, + pub dependencies: Vec, +} +/// Compilation and consumer failure remain distinct. +#[derive(Debug)] +pub enum CounterCompileError { + Compile(Box), + Consumer(E), +} +thread_local! { + static ORIGINAL: Cell> = const { Cell::new(None) }; +} +struct Invocation(Option); +impl Drop for Invocation { + fn drop(&mut self) { + ORIGINAL.set(self.0); + } +} +pub(crate) fn active() -> bool { + ORIGINAL.get().is_some() +} +pub(crate) fn current(input: CompilerInput<'_>) -> Result> { + let _invocation = Invocation(ORIGINAL.replace(None)); + let _sites = SiteScope::suspend_counter(); + let _journal = receipts::JournalScope::replace(None); + let source = crate::provenance::normalize_source(input.code); + invoke(&input, &source, None) +} +fn invoke( + input: &CompilerInput<'_>, + source: &str, + evaluated: Option>, +) -> Result> { + crate::extract_source( + input.filename, + source, + evaluated, + input.option.clone(), + input.source_map, + input.resolver, + ) +} +pub(crate) fn inherited(input: CompilerInput<'_>) -> Result> { + if !active() { + return current(input); + } + let _reservation = receipts::ReservationScope::enter(); + let result = registered(input); + if let Err(error) = &result { + receipts::retain_terminal(error.as_ref()); + } + result +} +fn registered(input: CompilerInput<'_>) -> Result> { + let original = css::file_map::get_or_insert_original_id(input.filename)?; + let _invocation = Invocation(ORIGINAL.replace(Some(original))); + let _raw = + crate::extract_style::compiler_associations::RawScope::enter(input.filename, original); + let source = crate::provenance::normalize_source(input.code); + let _sites = SiteScope::enter_counter_numbered(original, &source, &[]); + probe(&input, &source) +} +/// Compile through the real parser and consume a generatively branded receipt batch. +/// +/// # Errors +/// Compilation or consumption failure rolls back the complete naming attempt. +pub fn with_counter_extract( + input: CompilerInput<'_>, + consume: impl for<'compile> FnOnce( + CompilerOutputMetadata, + CompilerReceiptBatch<'compile>, + ) -> Result, +) -> Result> { + css::exact_attempt::with_exclusive_attempt(|| { + let _journal = receipts::JournalScope::replace(Some(receipts::Journal::new())); + let output = registered(input).map_err(CounterCompileError::Compile)?; + let metadata = CompilerOutputMetadata { + code: output.code, + map: output.map, + css_file: output.css_file, + dependencies: output.dependencies, + }; + receipts::with_sealed(output.styles, |batch| consume(metadata, batch)) + }) +} +fn probe(input: &CompilerInput<'_>, source: &str) -> Result> { + let result: Result<_, Box> = css::exact_attempt::with_exclusive_attempt(|| { + let mut frame = receipts::Probe::enter(); + let output = invoke(input, source, None); + projection::check_terminal(input.filename, source, &[])?; + let output = output?; + frame.commit(); + Ok(output) + }); + match result { + Ok(output) => Ok(output), + Err(error) => match error.downcast::() { + Err(error) => Err(error), + Ok(plan) => continue_retry(input, source, *plan), + }, + } +} +#[derive(Debug)] +struct RetryPlan { + computed: String, + value_edits: Vec, + alias_edits: Vec, + read: BTreeSet, + risky: Vec<(usize, usize)>, +} +impl std::fmt::Display for RetryPlan { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("deferred Counter evaluation continuation") + } +} +impl Error for RetryPlan {} +type RetryValues = (String, Vec, BTreeSet, Vec<(usize, usize)>); +pub(crate) fn retry( + (computed, value_edits, read, risky): RetryValues, + alias_edits: &[Edit], +) -> Box { + Box::new(RetryPlan { + computed, + value_edits, + alias_edits: alias_edits.to_vec(), + read, + risky, + }) +} +fn continue_retry( + input: &CompilerInput<'_>, + source: &str, + plan: RetryPlan, +) -> Result> { + #[cfg(test)] + tests::observe(true); + css::exact_attempt::with_exclusive_attempt(|| { + let mut frame = receipts::Probe::enter(); + let edits = [plan.value_edits.as_slice(), plan.alias_edits.as_slice()]; + let output = invoke( + input, + &plan.computed, + Some(Evaluated { + source, + edits: &edits, + risky: &plan.risky, + }), + ); + projection::check_terminal(input.filename, source, &edits)?; + let mut output = output?; + let mut dependencies: BTreeSet<_> = output.dependencies.into_iter().collect(); + dependencies.extend(plan.read); + output.dependencies = dependencies.into_iter().collect(); + frame.commit(); + Ok(output) + }) +} +pub(crate) fn sites(source: (&str, &str, bool), edits: &[&[Edit]]) -> SiteScope { + match ORIGINAL.get() { + None => SiteScope::enter(source.0, source.1, edits), + Some(original) if source.2 => SiteScope::enter_counter_generated(original, source.1), + Some(original) => SiteScope::enter_counter_numbered(original, source.1, edits), + } +} +#[cfg(test)] +#[path = "compiler_policy_tests.rs"] +pub(crate) mod tests; diff --git a/libs/extractor/src/compiler_policy_tests.rs b/libs/extractor/src/compiler_policy_tests.rs new file mode 100644 index 000000000..ab034045f --- /dev/null +++ b/libs/extractor/src/compiler_policy_tests.rs @@ -0,0 +1,236 @@ +use super::*; +use crate::extract_style::CounterProducerError::UnnumberedSite; +use crate::extract_style::compiler_associations::Graph; +pub(crate) fn one(values: impl IntoIterator) -> T { + let mut values = Vec::from_iter(values); + assert_eq!(values.len(), 1); + values.remove(0) +} +fn retain_unnumbered(at: Option) { + let error = projection::ProjectionError::Producer(UnnumberedSite); + receipts::collect::<()>(Err(error), at); +} +pub(crate) fn failed(input: CompilerInput<'_>) -> Box { + let result = with_counter_extract(input, |_, _| Ok::<_, std::convert::Infallible>(())); + match one(result.err()) { + CounterCompileError::Compile(error) => error, + CounterCompileError::Consumer(error) => match error {}, + } +} +use crate::extract_style::compiler_receipts::{PRODUCTIONS, ReceiptWitness}; +use crate::extract_style::extract_style_value::ExtractStyleValue; +use serial_test::serial; +#[path = "compiler_boundary_tests.rs"] +mod boundaries; +#[path = "compiler_surface_tests.rs"] +mod surface_tests; +pub(crate) use surface_tests::names; + +pub(crate) use crate::sparse_sites::tests::observe_variable; +pub(crate) fn input<'a>(filename: &'a str, code: &'a str) -> CompilerInput<'a> { + CompilerInput { + filename, + code, + option: ExtractOption::default(), + source_map: false, + resolver: None, + } +} +pub(crate) fn associated( + input: CompilerInput<'_>, +) -> ( + CompilerOutputMetadata, + Vec, + Vec, + Graph, +) { + with_counter_extract(input, |metadata, batch| { + batch.consume(|view| { + Ok::<_, ()>(( + metadata, + view.styles.to_vec(), + view.witnesses.to_vec(), + view.associations.clone(), + )) + }) + }) + .unwrap_or_else(|error| panic!("{error:?}")) +} +pub(crate) fn offset(source: &str, token: &str) -> u32 { + u32::try_from( + source + .find(token) + .unwrap_or_else(|| panic!("authored token")), + ) + .unwrap_or_else(|_| panic!("Oxc offset")) +} + +type Observer = Box; +thread_local! { + static OBSERVER: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; +} +pub(crate) fn observe(retry: bool) { + let mut observer = OBSERVER.take(); + if let Some(observer) = &mut observer { + observer(retry); + } + OBSERVER.set(observer); +} +pub(crate) struct ObserveScope(std::collections::HashMap); +impl ObserveScope { + pub(crate) fn enter(observer: impl FnMut(bool) + 'static) -> Self { + OBSERVER.set(Some(Box::new(observer))); + Self(css::file_map::get_canonical_map()) + } +} +impl Drop for ObserveScope { + fn drop(&mut self) { + OBSERVER.set(None); + css::file_map::set_canonical_map(std::mem::take(&mut self.0)); + } +} + +pub(crate) fn reset() { + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + PRODUCTIONS.set(0); +} +pub(crate) fn compile( + filename: &str, + code: &str, +) -> ( + CompilerOutputMetadata, + Vec, + Vec, +) { + let mut source = input(filename, code); + source.source_map = true; + let (metadata, styles, receipts, _) = associated(source); + (metadata, styles, receipts) +} +#[test] +#[serial] +fn raw_originals_register_before_no_style_returns() { + reset(); + let raw = "raw/../A.tsx"; + let _ = compile(raw, "const a = 1;"); + let _ = compile("B.tsx", "const b = 2;"); + assert_eq!( + css::file_map::get_original_ids(), + std::collections::BTreeMap::from([(raw.into(), 0), ("B.tsx".into(), 1)]) + ); + assert_eq!(PRODUCTIONS.get(), 0); +} +#[test] +#[serial] +fn seeded_raw_original_is_reused_by_real_parser() { + reset(); + css::file_map::set_original_ids(std::collections::BTreeMap::from([("A.tsx".into(), 7)])); + let (_, styles, _) = compile( + "A.tsx", + "import {Box} from '@devup-ui/react'; const a = ;", + ); + let ExtractStyleValue::Dynamic(style) = &styles[0] else { + panic!("dynamic") + }; + assert_eq!( + style.site().map(|site| &site.file), + Some(&css::sparse_site::SourceFile::D9(7)) + ); + assert_eq!(css::file_map::get_original_ids()["A.tsx"], 7); +} + +#[test] +#[serial] +fn counter_site_terminal_prevents_otherwise_evaluable_retry() { + reset(); + let source = + "import {css} from '@devup-ui/react'; const pick=()=> 'red'; const s=css({color:pick()});"; + let at = offset(source, "pick()"); + let visits = std::rc::Rc::new(Cell::new(0)); + let observed = visits.clone(); + let _observer = ObserveScope::enter(move |retry| { + if !retry { + observed.set(observed.get() + 1); + crate::provenance::site_at(at, 0, "first"); + crate::provenance::site_at(at, 0, "second"); + } + }); + let error = failed(input("terminal.tsx", source)); + assert_eq!( + visits.get(), + 1, + "a terminal role collision must precede evaluation" + ); + assert!(error.is::(), "{error}"); +} + +#[test] +#[serial] +fn module_terminal_survives_parent_early_error_and_string_transport() { + reset(); + let mut injected = false; + let _observer = ObserveScope::enter(move |retry| { + if !retry && !injected { + injected = true; + retain_unnumbered(Some(0)); + } + }); + let resolver = |specifier: &str, _: &str| { + Some(if specifier == "./middle.css" { + crate::ResolvedModule { + path: "middle.css.ts".into(), + code: "import {s} from './child.css'; import {style} from '@devup-ui/react'; export const s2=style([s]);".into(), + } + } else { + crate::ResolvedModule { + path: "child.css.ts".into(), + code: "import {style} from '@devup-ui/react'; export const s=style({color:'red'});" + .into(), + } + }) + }; + let mut source = input( + "parent.css.ts", + "import {s2} from './middle.css'; import {style} from '@devup-ui/react'; export const root=style([s2]);", + ); + source.resolver = Some(&resolver); + let error = failed(source); + assert!( + error.is::(), + "typed fatal was erased: {error}" + ); + assert!(error.to_string().contains("child.css.ts"), "{error}"); +} + +#[test] +#[serial] +fn successful_no_style_invoke_drains_pending_typed_terminal() { + reset(); + let resolver = |_: &str, _: &str| { + retain_unnumbered(None); + Some(crate::ResolvedModule { + path: "constant.ts".into(), + code: "export const v=1;".into(), + }) + }; + let published = Cell::new(false); + let mut source = input( + "empty.css.ts", + "import {v} from './constant'; import {css} from '@devup-ui/react'; const x=v;", + ); + source.resolver = Some(&resolver); + let result = with_counter_extract(source, |_, batch| { + published.set(true); + batch.consume(|_| Ok::<_, ()>(())) + }); + let Err(CounterCompileError::Compile(error)) = result else { + panic!("pending fatal must not publish") + }; + assert!(error.is::(), "{error}"); + assert!(!published.get()); + assert!( + error.to_string().contains("position unavailable"), + "{error}" + ); +} diff --git a/libs/extractor/src/compiler_projection.rs b/libs/extractor/src/compiler_projection.rs new file mode 100644 index 000000000..45d2774d6 --- /dev/null +++ b/libs/extractor/src/compiler_projection.rs @@ -0,0 +1,199 @@ +pub(crate) use super::compiler_associations::{CollectedStyles, ConsumerScope, RawScope, complete}; +pub(crate) use super::compiler_diagnostics::{ProjectionError, check_aux, check_terminal}; +pub(crate) use super::compiler_request::{configuration, same}; +use super::{CounterProducerError, ExtractDynamicStyle as Dynamic, ProducerPolicy}; +use super::{ + compiler_receipts as receipts, extract_style_value::ExtractStyleValue as Value, + style_property::StyleProperty, +}; +pub use crate::assignment_value::RewriteEdge; +pub use crate::gen_class_name::OrderEdge; +pub use crate::gen_style::VariableUse; +use css::allocation_input::{AllocationContext, CapturedNameConfig}; +use receipts::ProducedData; +use std::cell::Cell; +use std::rc::Rc; +type ProjectionResult = Result; + +thread_local! { + static DEMAND: Cell = const { Cell::new(0) }; +} +pub(crate) struct DemandScope(u32); +impl DemandScope { + pub(crate) fn enter(offset: u32) -> Self { + Self(DEMAND.replace(offset)) + } +} +impl Drop for DemandScope { + fn drop(&mut self) { + DEMAND.set(self.0); + } +} +pub(crate) fn demand() -> u32 { + DEMAND.get() +} +pub(crate) fn frozen_configuration() -> Result { + receipts::JOURNAL.with_borrow(|journal| match journal { + Some(journal) if journal.config == configuration() => Ok(journal.config.clone()), + Some(_) | None => Err(ProjectionError::Context), + }) +} +pub(crate) fn produce(value: &Value, filename: Option<&str>) -> ProjectionResult { + match value { + Value::Static(style) => style + .counter_produce(filename) + .map(Rc::new) + .map(ProducedData::Allocation), + Value::Dynamic(style) => style + .counter_produce(filename) + .map(Rc::new) + .map(ProducedData::Dynamic), + Value::Keyframes(frames) => frames + .counter_produce(filename) + .map(Rc::new) + .map(ProducedData::Allocation), + Value::Typography(_) | Value::Css(_) | Value::Import(_) | Value::FontFace(_) => { + Err(CounterProducerError::WrongPolicy) + } + } + .map_err(ProjectionError::Producer) +} +pub(crate) use crate::gen_class_name::{keyframe_name, static_name}; +pub(crate) fn context(value: &Value, route: Option<&str>) -> ProjectionResult { + let inherited = receipts::JOURNAL + .with_borrow(|journal| journal.as_ref().and_then(|journal| journal.graph.route())); + super::compiler_request::context(value, route.or(inherited.as_deref())) +} +fn try_variable(style: &Dynamic, consumer: &str, route: Option<&str>) -> ProjectionResult { + if !crate::compiler_policy::active() { + return Ok(style.variable_name()); + } + let config = frozen_configuration()?; + #[cfg(test)] + crate::compiler_policy::tests::observe_variable(); + receipts::JOURNAL.with_borrow_mut(|journal| { + let journal = journal.as_mut().ok_or(ProjectionError::Context)?; + let graph = &mut journal.graph; + let invocation = graph.invocation.ok_or(ProjectionError::Context)?; + let original = graph + .rewrites + .iter() + .rev() + .find(|edge| { + edge.invocation == invocation + && edge.scope == graph.scope + && same( + &Value::Dynamic(edge.after.clone()), + &Value::Dynamic(style.clone()), + ) + }) + .map_or_else(|| style.clone(), |edge| edge.before.clone()); + let value = Value::Dynamic(original.clone()); + let mut ids = Vec::new(); + for edge in &graph.orders { + if edge.invocation == invocation + && edge.scope == graph.scope + && (same(&edge.before, &value) || same(&edge.after, &value)) + && !ids.contains(&edge.receipt) + { + ids.push(edge.receipt); + } + } + let variable = match (original.producer_policy(), original.site()) { + (ProducerPolicy::Current, _) => { + return Err(ProjectionError::Producer(CounterProducerError::WrongPolicy)); + } + (ProducerPolicy::CounterOriginal(_), Some(site)) => match &site.file { + css::sparse_site::SourceFile::D9(_) => site.variable_name(&config.prefix), + css::sparse_site::SourceFile::Unnumbered(_) => { + return Err(ProjectionError::Producer( + CounterProducerError::UnnumberedSite, + )); + } + }, + (ProducerPolicy::CounterOriginal(_), None) => { + let mut names = ids + .iter() + .map(|id| match &journal.witnesses[id.0].produced { + ProducedData::Dynamic(value) => Ok(value.variable.clone()), + ProducedData::Allocation(_) => Err(ProjectionError::Variable), + }); + let first = names.next().ok_or(ProjectionError::Variable)??; + for next in names { + if next? != first { + return Err(ProjectionError::Variable); + } + } + first + } + }; + graph.variables.push(VariableUse { + original, + consumer: consumer.into(), + route: route.map(str::to_string), + config, + invocation, + scope: graph.scope, + receipts: ids, + }); + Ok(variable) + }) +} +pub(crate) fn variable_for(style: &Dynamic, offset: u32, consumer: &str) -> Option { + receipts::collect(try_variable(style, consumer, None), Some(offset)) +} +pub(crate) struct InlineAssignment { + pub(crate) variable_name: String, + pub(crate) identifier: String, +} +pub(crate) fn variables_ready(variables: &[VariableUse]) -> bool { + variables.iter().all(|usage| !usage.receipts.is_empty()) +} +pub(crate) fn inline(value: &Value, filename: Option<&str>) -> Option { + if !crate::compiler_policy::active() { + return match value.extract(filename) { + Some(StyleProperty::Variable { + variable_name, + identifier, + .. + }) => Some(InlineAssignment { + variable_name, + identifier, + }), + Some(StyleProperty::ClassName(_)) | None => None, + }; + } + match value { + Value::Dynamic(style) => { + receipts::collect_operand(try_variable(style, style.identifier(), filename), value).map( + |variable_name| InlineAssignment { + variable_name, + identifier: style.identifier().to_string(), + }, + ) + } + Value::Static(_) + | Value::Typography(_) + | Value::Keyframes(_) + | Value::Css(_) + | Value::Import(_) + | Value::FontFace(_) => None, + } +} +pub(crate) fn auxiliary( + source: &str, + filename: &str, +) -> Option<( + receipts::ReservationScope, + crate::sparse_sites::SiteScope, + RawScope, +)> { + match crate::sparse_sites::producer_policy() { + ProducerPolicy::Current => None, + ProducerPolicy::CounterOriginal(original) => Some(( + receipts::ReservationScope::enter(), + crate::sparse_sites::SiteScope::enter_counter_generated(original, source), + RawScope::enter(filename, original), + )), + } +} diff --git a/libs/extractor/src/compiler_receipt_tests.rs b/libs/extractor/src/compiler_receipt_tests.rs new file mode 100644 index 000000000..76857c933 --- /dev/null +++ b/libs/extractor/src/compiler_receipt_tests.rs @@ -0,0 +1,240 @@ +use super::*; +use crate::compiler_policy::tests::{associated, compile, input, names, reset}; +use crate::extract_style::ProducerPolicy as Policy; +use serial_test::serial; +use std::collections::{BTreeMap, HashMap}; + +#[test] +#[serial] +fn parser_class_and_inline_projections_produce_once() { + reset(); + let code = "import {Box} from '@devup-ui/react'; const a = ;"; + let (metadata, styles, witnesses, graph) = associated(input("A.tsx", code)); + assert_eq!(PRODUCTIONS.get(), 2); + assert_eq!(styles.len(), 2); + assert_eq!(graph.naming.len(), 2, "root memo hits must not reacquire"); + let code = &metadata.code; + for witness in witnesses { + assert!(code.contains(&witness.produced.class().allocation.name)); + if let ProducedData::Dynamic(dynamic) = &witness.produced { + assert!(code.contains(&dynamic.variable)); + assert_eq!(dynamic.variable_allocation, None); + } + } +} + +#[test] +#[serial] +fn abandoned_retry_restores_maps_and_receipts_before_clean_continuation() { + use crate::compiler_policy::tests::ObserveScope; + reset(); + let retried = std::rc::Rc::new(std::cell::Cell::new(0)); + let observed = retried.clone(); + let mut injected = false; + let _observer = ObserveScope::enter(move |retry| { + if !retry && !injected { + injected = true; + crate::extract_class_map_from_code( + "actual-aux.tsx", + "import {css} from '@devup-ui/react'; const aux=css({color:'green'});", + &Default::default(), + &Default::default(), + css::Naming::Own, + ) + .unwrap_or_else(|error| panic!("{error}")); + JOURNAL.with_borrow_mut(|journal| { + let journal = journal.as_mut().unwrap_or_else(|| panic!("journal")); + let operand = &journal.graph.operands[0]; + let receipt = journal + .witnesses + .iter() + .find(|receipt| { + super::super::compiler_request::same(&receipt.operand, &operand.operand) + }) + .unwrap_or_else(|| panic!("real parsed receipt")); + journal.graph.emissions.push( + crate::extract_style::compiler_associations::FinalAssociation { + operand: operand.operand.clone(), + survivor: operand.survivor, + receipt: receipt.id, + }, + ); + assert!( + !journal.graph.variables.is_empty() + && !journal.graph.rewrites.is_empty() + && !journal.graph.frames.is_empty() + ); + }); + } + if retry { + observed.set(observed.get() + 1); + assert_eq!( + css::class_map::get_class_map(), + std::collections::HashMap::new(), + "abandoned class maps" + ); + assert_eq!( + css::file_map::get_file_map().len(), + 0, + "abandoned delivery map" + ); + assert_eq!( + css::file_map::get_original_ids(), + std::collections::BTreeMap::from([("A.tsx".into(), 0)]) + ); + JOURNAL.with_borrow(|journal| { + let journal = journal + .as_ref() + .unwrap_or_else(|| panic!("Counter journal")); + assert_eq!(journal.witnesses.len(), 0, "abandoned receipts"); + assert_eq!(journal.errors.len(), 0, "abandoned diagnostics"); + let graph = &journal.graph; + assert_eq!( + [ + graph.operands.len(), + graph.emissions.len(), + graph.naming.len(), + graph.frames.len(), + graph.variables.len(), + graph.rewrites.len(), + graph.orders.len() + ], + [0; 7], + "every abandoned graph table" + ); + assert_eq!(graph.invocations.len(), 1, "abandoned auxiliary invocation"); + assert_eq!(graph.invocation, Some(graph.invocations[0].id)); + assert_eq!(graph.scope, None); + }); + } + }); + let source = "import {Box,css,styled} from '@devup-ui/react'; const pick=()=> 'red'; const a=; const C=styled.div({width:size}); const s=css({color:pick()});"; + let (metadata, _, witnesses) = compile("A.tsx", source); + assert_eq!(retried.get(), 1, "legitimate evaluable fallback must retry"); + let maps = css::class_map::get_class_map(); + let produced_names = names(&witnesses); + drop(_observer); + reset(); + let (computed, computed_edits, _, _) = crate::build_time_values::evaluate( + source, + "A.tsx", + &crate::ExtractOption::default(), + None, + &Default::default(), + ) + .unwrap_or_else(|| panic!("evaluable call")); + assert_ne!(computed_edits.len(), 0); + let (clean, _, clean_witnesses) = compile("A.tsx", &computed); + assert_eq!(metadata.code, clean.code); + assert_eq!(maps, css::class_map::get_class_map()); + assert_eq!(produced_names, names(&clean_witnesses)); +} + +#[test] +#[serial] +fn producer_identifier_is_immutable_and_rewrite_consumers_are_explicit() { + reset(); + let (metadata, _, receipts, graph) = associated(input( + "A.tsx", + "import {Box} from '@devup-ui/react'; const a=;", + )); + let receipt = receipts.iter().find(|receipt| matches!(&receipt.produced, ProducedData::Dynamic(value) if value.identifier == "read()")).unwrap_or_else(|| panic!("real producer")); + let ProducedData::Dynamic(produced) = &receipt.produced else { + panic!("dynamic") + }; + assert_eq!(produced.identifier, "read()"); + let usage = graph + .variables + .iter() + .find(|usage| usage.consumer == "__devupValue" && usage.original.identifier() == "read()") + .unwrap_or_else(|| panic!("sanctioned consumer")); + assert_eq!(usage.original.identifier(), "read()"); + assert!(usage.receipts.contains(&receipt.id)); + assert_eq!( + usage.receipts.len(), + 2, + "primary and alternate orders share one actual use" + ); + assert_eq!(usage.original.style_order(), None); + assert!(graph.rewrites.iter().any(|edge| edge.scope == usage.scope + && edge.before.identifier() == "read()" + && edge.after.identifier() == "__devupValue")); + assert!(metadata.code.contains("__devupValue")); +} +#[test] +#[serial] +fn discarded_child_keeps_full_collapsed_originals_without_completion() { + reset(); + let _canonical = crate::compiler_policy::tests::ObserveScope::enter(|_| {}); + css::file_map::set_canonical_map(HashMap::from([ + ("child.css.ts".into(), "delivery.css.ts".into()), + ("parent.css.ts".into(), "delivery.css.ts".into()), + ])); + let resolver = |_: &str, _: &str| { + Some(crate::ResolvedModule { path: "child.css.ts".into(), code: "import {globalStyle} from '@devup-ui/react'; export const tone='red'; globalStyle('body',{ background: tone });".into() }) + }; + let mut source = input( + "parent.css.ts", + "import {tone} from './child.css'; import {globalStyle} from '@devup-ui/react'; globalStyle('body',{ background: tone });", + ); + source.resolver = Some(&resolver); + let (_, styles, receipts, graph) = associated(source); + assert_eq!(styles.len(), 1); + assert_eq!(receipts.len(), 1, "discarded global child must not reserve"); + assert_eq!(graph.emissions.len(), 1); + let invocations = &graph.invocations; + assert!(graph.operands.iter().any(|operand| matches!(&operand.operand, ExtractStyleValue::Static(style) if style.producer_policy() == Policy::CounterOriginal(1)))); + assert!( + invocations + .iter() + .any(|invocation| invocation.raw == "child.css.ts" && invocation.original == 1) + ); + assert!( + graph + .naming + .iter() + .all(|use_| invocations[use_.invocation.0].raw != "child.css.ts") + ); + assert_eq!(invocations[receipts[0].invocation.0].raw, "parent.css.ts"); +} +#[test] +#[serial] +fn repeated_keyframes_retain_ordered_members_raw_invocation_and_exact_survivor() { + reset(); + let _canonical = crate::compiler_policy::tests::ObserveScope::enter(|_| {}); + let raw = "raw/../keys.tsx"; + css::file_map::set_original_ids(BTreeMap::from([(raw.into(), 7)])); + css::file_map::set_canonical_map(HashMap::from([(raw.into(), "delivery.tsx".into())])); + let (_, styles, receipts, graph) = associated(input( + raw, + "import {keyframes} from '@devup-ui/react'; const a=keyframes({from:{color:'red',opacity:0},to:{color:'blue',opacity:1}}); const b=keyframes({from:{color:'red',opacity:0},to:{color:'blue',opacity:1}});", + )); + assert_eq!(styles.len(), 1); + assert_eq!(receipts.len(), 1); + assert_eq!(graph.operands.len(), 2); + assert_eq!(graph.emissions.len(), 1); + assert_eq!(graph.invocations[0].raw, raw); + assert_eq!(receipts[0].route.as_deref(), Some("delivery.tsx")); + for operand in &graph.operands { + let ExtractStyleValue::Keyframes(frames) = &operand.operand else { + panic!("keyframes") + }; + assert_eq!(frames.producer_policy(), Policy::CounterOriginal(7)); + let members: Vec<_> = frames + .keyframes + .values() + .flatten() + .map(crate::extract_style::extract_static_style::ExtractStaticStyle::property) + .collect(); + assert_eq!(members, ["color", "opacity", "color", "opacity"]); + assert!( + frames + .keyframes + .values() + .flatten() + .all(|member| member.producer_policy() == Policy::CounterOriginal(7)) + ); + assert_eq!(operand.survivor, graph.emissions[0].survivor); + } + assert_eq!(graph.emissions[0].receipt, receipts[0].id); +} diff --git a/libs/extractor/src/compiler_receipts.rs b/libs/extractor/src/compiler_receipts.rs new file mode 100644 index 000000000..d579340ff --- /dev/null +++ b/libs/extractor/src/compiler_receipts.rs @@ -0,0 +1,237 @@ +use super::compiler_associations::{Graph, InvocationId, NamingObservation}; +pub(crate) use super::compiler_diagnostics::{collect, retain_terminal}; +use super::compiler_projection::{ProjectionError, configuration}; +use super::{ProducedAllocation, ProducedDynamic, extract_style_value::ExtractStyleValue}; +use crate::compiler_policy::CounterCompileError as CompileError; +use css::allocation_input::{AllocationContext, CapturedNameConfig}; +use css::style_origin::RealLocation; +use std::{ + cell::{Cell, RefCell}, + marker::PhantomData, + rc::Rc, +}; + +/// Actual producer output, not an independently minted authority certificate. +#[derive(Clone, Debug)] +pub enum ProducedData { + Allocation(Rc), + Dynamic(Rc), +} +impl ProducedData { + pub fn class(&self) -> &ProducedAllocation { + match self { + Self::Allocation(value) => value, + Self::Dynamic(value) => &value.class, + } + } +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WitnessDisposition { + Emission, + ReservationOnly, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ReceiptId(pub(crate) usize); +/// Immutable full operand and real allocation association. +#[derive(Clone, Debug)] +pub struct ReceiptWitness { + pub id: ReceiptId, + pub operand: ExtractStyleValue, + pub produced: ProducedData, + pub route: Option, + pub invocation: InvocationId, + pub disposition: WitnessDisposition, +} +type CompileBrand<'compile> = fn(&'compile ()) -> &'compile (); +/// Opaque, invariant, thread-local attempt handoff. Only the compiler can seal it. +pub struct CompilerReceiptBatch<'compile> { + styles: Vec, + journal: Journal, + brand: PhantomData<(CompileBrand<'compile>, Rc<()>)>, +} +pub struct ReceiptBatchView<'view> { + pub styles: &'view [ExtractStyleValue], + pub witnesses: &'view [ReceiptWitness], + pub associations: &'view Graph, + seal: PhantomData<&'view ()>, +} +impl CompilerReceiptBatch<'_> { + /// Consume the handoff; no borrowed view can escape the callback. + /// # Errors + /// Returns the consumer's error unchanged. + pub fn consume( + self, + consumer: impl for<'view> FnOnce(ReceiptBatchView<'view>) -> Result, + ) -> Result { + consumer(ReceiptBatchView { + styles: &self.styles, + witnesses: &self.journal.witnesses, + associations: &self.journal.graph, + seal: PhantomData, + }) + } +} +#[derive(Clone)] +pub(crate) struct Journal { + pub(crate) witnesses: Vec, + pub(crate) graph: Graph, + pub(crate) errors: Vec<(u32, ProjectionError, Option)>, + pub(crate) config: CapturedNameConfig, +} +thread_local! { + pub(crate) static JOURNAL: RefCell> = const { RefCell::new(None) }; + static RESERVATION: Cell = const { Cell::new(false) }; + #[cfg(test)] + pub(crate) static PRODUCTIONS: Cell = const { Cell::new(0) }; +} +impl Journal { + pub(crate) fn new() -> Self { + Self { + witnesses: Vec::new(), + graph: Graph::default(), + errors: Vec::new(), + config: configuration(), + } + } + pub(crate) fn find( + &self, + operand: &ExtractStyleValue, + context: &AllocationContext, + ) -> Option { + self.witnesses + .iter() + .find(|witness| { + witness.produced.class().context == *context + && super::compiler_projection::same(&witness.operand, operand) + }) + .map(|witness| witness.id) + } +} +pub(crate) struct JournalScope(Option, bool); +impl JournalScope { + pub(crate) fn replace(value: Option) -> Self { + Self(JOURNAL.replace(value), RESERVATION.replace(false)) + } +} +impl Drop for JournalScope { + fn drop(&mut self) { + JOURNAL.replace(self.0.take()); + RESERVATION.set(self.1); + } +} +pub(crate) struct Probe(Option); +impl Probe { + pub(crate) fn enter() -> Self { + Self(JOURNAL.with_borrow(Clone::clone)) + } + pub(crate) fn commit(&mut self) { + self.0 = None; + } +} +impl Drop for Probe { + fn drop(&mut self) { + if let Some(journal) = self.0.take() { + JOURNAL.set(Some(journal)); + } + } +} +pub(crate) struct ReservationScope(bool); +impl ReservationScope { + pub(crate) fn enter() -> Self { + Self(RESERVATION.replace(true)) + } +} +impl Drop for ReservationScope { + fn drop(&mut self) { + RESERVATION.set(self.0); + } +} +pub(crate) fn acquire( + operand: &ExtractStyleValue, + route: Option<&str>, + context: AllocationContext, +) -> Result<(ReceiptId, ProducedData), ProjectionError> { + super::compiler_projection::frozen_configuration()?; + JOURNAL.with_borrow_mut(|journal| { + let journal = journal.as_mut().ok_or(ProjectionError::Context)?; + let invocation = journal.graph.invocation.ok_or(ProjectionError::Context)?; + let effective = route.map(str::to_string).or_else(|| journal.graph.route()); + let id = journal + .find(operand, &context) + .unwrap_or(ReceiptId(journal.witnesses.len())); + if id.0 == journal.witnesses.len() { + #[cfg(test)] + PRODUCTIONS.set(PRODUCTIONS.get() + 1); + let produced = super::compiler_projection::produce(operand, effective.as_deref())?; + if produced.class().context != context { + return Err(ProjectionError::Context); + } + journal.witnesses.push(ReceiptWitness { + id, + operand: operand.clone(), + produced, + route: effective, + invocation, + disposition: WitnessDisposition::ReservationOnly, + }); + } + journal.graph.naming.push(NamingObservation { + operand: operand.clone(), + route: route.map(str::to_string), + receipt: id, + invocation, + scope: journal.graph.scope, + }); + Ok((id, journal.witnesses[id.0].produced.clone())) + }) +} +pub(crate) fn reservation() -> bool { + RESERVATION.get() +} +fn operand_origin(operand: &ExtractStyleValue) -> Option { + match operand { + ExtractStyleValue::Static(style) => style.origin.location(), + ExtractStyleValue::Dynamic(style) => style.origin.location(), + ExtractStyleValue::Keyframes(style) => style.origin.location(), + ExtractStyleValue::Typography(_) + | ExtractStyleValue::Css(_) + | ExtractStyleValue::Import(_) + | ExtractStyleValue::FontFace(_) => None, + } +} +pub(crate) fn collect_operand( + result: Result, + operand: &ExtractStyleValue, +) -> Option { + match (result, operand_origin(operand)) { + (Ok(value), _) => Some(value), + (Err(error), None) => collect(Err(error), None), + (Err(error), Some(origin)) => collect( + Err(ProjectionError::Terminal(format!("{origin}: {error}"))), + None, + ), + } +} +pub(crate) fn with_sealed( + styles: rustc_hash::FxHashSet, + consume: impl for<'compile> FnOnce(CompilerReceiptBatch<'compile>) -> Result, +) -> Result> { + let mut styles: Vec<_> = styles.into_iter().collect(); + styles.sort(); + let journal = JOURNAL.take().ok_or(ProjectionError::Context)?; + if !super::compiler_projection::variables_ready(&journal.graph.variables) { + return Err(ProjectionError::Variable.into()); + } + consume(CompilerReceiptBatch { + styles, + journal, + brand: PhantomData, + }) + .map_err(CompileError::Consumer) +} +#[cfg(test)] +#[path = "compiler_coverage_tests.rs"] +pub(crate) mod coverage_tests; +#[cfg(test)] +#[path = "compiler_receipt_tests.rs"] +pub(crate) mod tests; diff --git a/libs/extractor/src/compiler_request.rs b/libs/extractor/src/compiler_request.rs new file mode 100644 index 000000000..067f28eec --- /dev/null +++ b/libs/extractor/src/compiler_request.rs @@ -0,0 +1,112 @@ +use super::extract_style_value::ExtractStyleValue as Value; +use super::{CounterProducerError, ExtractDynamicStyle, ProducerPolicy}; +use super::{compiler_diagnostics::ProjectionError, extract_static_style::ExtractStaticStyle}; +use css::allocation_input::{ + AllocationContext, CapturedNameConfig, LegacyDeclaration, LegacyInput, +}; + +pub(crate) fn configuration() -> CapturedNameConfig { + css::allocation_input::capture_context( + &LegacyInput::Keyframes(String::new()), + None, + css::CounterOwner::Inactive, + ) + .config +} + +fn static_key(style: &ExtractStaticStyle) -> impl PartialEq + '_ { + ( + style.property(), + style.value(), + style.level(), + style.selector(), + style.style_order(), + style.layer(), + style.theme_token_resolution, + style.naming, + style.producer_policy(), + ) +} +fn dynamic_key(style: &ExtractDynamicStyle) -> impl PartialEq + '_ { + ( + style.property(), + style.level(), + style.selector(), + style.style_order(), + style.layer(), + style.important(), + style.naming, + style.identifier(), + style.site(), + style.producer_policy(), + ) +} +pub(crate) fn same(a: &Value, b: &Value) -> bool { + match (a, b) { + (Value::Static(a), Value::Static(b)) => static_key(a) == static_key(b), + (Value::Dynamic(a), Value::Dynamic(b)) => dynamic_key(a) == dynamic_key(b), + (Value::Keyframes(a), Value::Keyframes(b)) => { + a.producer_policy() == b.producer_policy() + && a.keyframes + .iter() + .map(|(key, values)| (key, values.len())) + .eq(b.keyframes.iter().map(|(key, values)| (key, values.len()))) + && a.keyframes.values().flatten().map(static_key).eq(b + .keyframes + .values() + .flatten() + .map(static_key)) + } + ( + Value::Typography(_) + | Value::Css(_) + | Value::Import(_) + | Value::FontFace(_) + | Value::Static(_) + | Value::Dynamic(_) + | Value::Keyframes(_), + _, + ) => false, + } +} +fn declaration(property: &str, level: u8, order: Option) -> LegacyInput { + LegacyInput::Declaration(LegacyDeclaration { + property: property.to_string(), + level, + order, + value: None, + selector: None, + }) +} +pub(crate) fn context( + value: &Value, + filename: Option<&str>, +) -> Result { + let (policy, input) = match value { + Value::Static(style) => ( + style.producer_policy(), + declaration(style.property(), style.level(), style.style_order()), + ), + Value::Dynamic(style) => ( + style.producer_policy(), + declaration(style.property(), style.level(), style.style_order()), + ), + Value::Keyframes(frames) => ( + frames.producer_policy(), + LegacyInput::Keyframes(String::new()), + ), + Value::Typography(_) | Value::Css(_) | Value::Import(_) | Value::FontFace(_) => { + return Err(ProjectionError::Producer(CounterProducerError::WrongPolicy)); + } + }; + match policy { + ProducerPolicy::Current => { + Err(ProjectionError::Producer(CounterProducerError::WrongPolicy)) + } + ProducerPolicy::CounterOriginal(original) => Ok(css::allocation_input::capture_context( + &input, + filename, + css::CounterOwner::D9(original), + )), + } +} diff --git a/libs/extractor/src/compiler_surface_tests.rs b/libs/extractor/src/compiler_surface_tests.rs new file mode 100644 index 000000000..a758b5281 --- /dev/null +++ b/libs/extractor/src/compiler_surface_tests.rs @@ -0,0 +1,242 @@ +use super::{compile, reset}; +use crate::ExtractStyleValue as Value; +use crate::assignment_test_support::{evaluate, jsx_js}; +use crate::compiler_policy::tests::{associated, failed, input, offset}; +use crate::extract_style::ProducerPolicy as Policy; +use crate::extract_style::compiler_receipts::coverage_tests::maps; +use crate::extract_style::compiler_receipts::{PRODUCTIONS, ReceiptWitness}; +use serial_test::serial; +pub(crate) fn names(receipts: &[ReceiptWitness]) -> Vec { + receipts + .iter() + .map(|receipt| receipt.produced.class().allocation.name.clone()) + .collect() +} + +#[test] +#[serial] +fn lazy_reversed_demands_precede_sorted_materialization() { + reset(); + let (_, _, witnesses) = compile( + "A.tsx", + "import {Box} from '@devup-ui/react'; const a = ;", + ); + let properties: Vec<_> = witnesses + .iter() + .take(2) + .map(|witness| match &witness.operand { + Value::Static(style) => style.property(), + _ => panic!("static"), + }) + .collect(); + assert_eq!(properties, ["color", "background"]); + assert_eq!(PRODUCTIONS.get(), 2); +} + +#[test] +#[serial] +fn dynamic_whole_operand_runs_once_through_real_counter_entry() { + reset(); + let source = "import {Box} from '@devup-ui/react'; let reads=0; const value=()=>{reads++;return 'red'}; const a=;"; + let (metadata, _, _) = compile("A.tsx", source); + let result = evaluate(&format!( + "{}\nJSON.stringify([reads,Object.values(a.style)]);", + jsx_js(&metadata.code) + )); + assert_eq!(result, "[1,[\"red\"]]"); +} + +#[test] +#[serial] +fn preunion_global_aliases_complete_one_missing_root_demand_with_exact_receipt() { + reset(); + let (_, styles, receipts, graph) = associated(input( + "A.tsx", + "import {globalCss} from '@devup-ui/react'; globalCss({body:{color:'red'}}); globalCss({body:{color:'red'}});", + )); + assert_eq!(styles.len(), 1); + assert_eq!(receipts.len(), 1); + assert_eq!(graph.operands.len(), 2); + assert_eq!(graph.emissions.len(), 1); + assert_eq!(graph.naming.len(), 1); + assert_eq!(PRODUCTIONS.get(), 1); + assert_eq!(graph.emissions[0].receipt, receipts[0].id); + assert!( + graph + .operands + .iter() + .all(|operand| operand.survivor == graph.emissions[0].survivor) + ); + assert_eq!(graph.emissions[0].operand, styles[0]); +} +#[test] +#[serial] +fn styled_none_reuses_creation_context_and_reads_once_before_two_renders() { + reset(); + let (metadata, _, receipts, graph) = associated(input( + "A.tsx", + "import {styled} from '@devup-ui/react'; const A=styled.div({width:state.size});", + )); + let actual = evaluate(&format!( + "let reads=0; const state={{get size(){{reads++;return '13px'}}}}; {} const created=reads; const first=A({{}}); const second=A({{}}); JSON.stringify([created,reads,first.className===second.className]);", + jsx_js(&metadata.code) + )); + assert_eq!(actual, "[1,1,true]"); + assert_eq!(receipts.len(), 1); + assert!( + graph + .variables + .iter() + .any(|usage| usage.consumer.starts_with("__devupStyled") + && usage.receipts == [receipts[0].id]) + ); + assert!( + graph + .variables + .iter() + .any(|usage| usage.route.is_none() && usage.receipts == [receipts[0].id]) + ); + assert_eq!(receipts[0].route.as_deref(), Some("A.tsx")); +} +#[test] +#[serial] +fn nested_public_current_shadows_counter_then_restores_root_names() { + use crate::compiler_policy::tests::ObserveScope; + reset(); + let nested = "import {Box} from '@devup-ui/react'; const a=;"; + let _ordinal = css::file_map::get_file_num_by_filename("A.tsx"); + let current = move || { + crate::extract_without_source_map("nested.tsx", nested, Default::default()) + .unwrap_or_else(|error| panic!("{error}")) + }; + let expected = current().code; + reset(); + let _observer = ObserveScope::enter(move |retry| { + assert!(!retry); + let current = current(); + assert_eq!( + current.code, expected, + "public Current must keep its own names" + ); + assert!(current.styles.iter().all(|value| matches!(value, Value::Static(style) if style.producer_policy() == Policy::Current))); + }); + let (metadata, _, receipts, graph) = associated(input( + "A.tsx", + "import {globalCss,Box} from '@devup-ui/react'; globalCss({body:{color:'red'}}); const a=;", + )); + assert_eq!(receipts.len(), 2); + assert!(receipts.iter().all(|receipt| matches!(&receipt.operand, Value::Static(style) if style.producer_policy() == Policy::CounterOriginal(0)))); + assert!( + metadata + .code + .contains(&receipts[0].produced.class().allocation.name) + ); + assert!( + graph + .invocations + .iter() + .all(|invocation| invocation.raw == "A.tsx") + ); +} +#[test] +#[serial] +fn supported_parser_surfaces_keep_generated_receipts_with_generate_and_skip_maps() { + for code in [ + "import {Box} from '@devup-ui/react'; const a=;", + "import {Box} from '@devup-ui/react'; const a=;", + "import {keyframes} from '@devup-ui/react'; const a=keyframes({from:{opacity:0},to:{opacity:1}});", + "import {styled} from '@devup-ui/react'; const A=styled.div({color:'red'});", + "import * as stylex from '@stylexjs/stylex'; const s=stylex.create({base:{color:'red'}}); const a=stylex.props(s.base);", + "import {Box} from '@devup-ui/react'; const a=;", + "import {Box} from '@devup-ui/react'; const a=;", + ] { + for mapped in [true, false] { + reset(); + let mut source = input("matrix.tsx", code); + source.source_map = mapped; + let (metadata, styles, receipts, graph) = associated(source); + assert_eq!(metadata.map.is_some(), mapped); + let generated = styles + .iter() + .filter(|value| !matches!(value, Value::Typography(_))) + .count(); + assert_eq!(graph.emissions.len(), generated); + assert!(receipts.iter().all(|receipt| { + metadata + .code + .contains(&receipt.produced.class().allocation.name) + })); + } + } +} +#[test] +#[serial] +fn keyframes_keep_one_parent_receipt_without_member_demands() { + reset(); + let (metadata, styles, witnesses) = compile( + "A.tsx", + "import {keyframes} from '@devup-ui/react'; const k=keyframes({from:{opacity:0},to:{opacity:1}});", + ); + assert_eq!(styles.len(), 1); + assert_eq!(PRODUCTIONS.get(), 1); + let name = &witnesses[0].produced.class().allocation.name; + assert!(metadata.code.contains(name)); +} + +#[test] +#[serial] +fn auxiliary_generated_operands_report_real_markers_or_unavailable_positions() { + use crate::compiler_policy::tests::ObserveScope; + use crate::extract_style::{compiler_projection, compiler_receipts}; + for (mapped, visitor_error) in [(true, false), (false, false), (false, true)] { + reset(); + let before = maps(); + let source = "import {Box} from '@devup-ui/react';\nconst a=;"; + let at = offset(source, "value"); + let origin = crate::style_origin::actual("A.tsx", source, oxc_span::Span::new(at, at + 5)) + .unwrap_or_else(|| panic!("authored origin")); + let expected = format!("A.tsx:{}:{}", origin.line, origin.column); + let marker = serde_json::to_string(&origin).unwrap_or_else(|_| panic!("origin JSON")); + let operand = if mapped { + format!("{}({:?}, value)", crate::style_origin::MARKER, marker) + } else { + "value".into() + }; + let _observer = ObserveScope::enter(move |retry| { + assert!(!retry); + let partial = if visitor_error { + "import * as stylex from '@stylexjs/stylex'; const s=stylex.create({base:{...stylex.include(missing.base)}});".into() + } else { + format!( + "import {{Box}} from '@devup-ui/react'; const a=;" + ) + }; + let Err(error) = crate::extract_class_map_from_code( + "A.tsx", + &partial, + &crate::ExtractOption::default(), + &rustc_hash::FxHashSet::default(), + css::Naming::Own, + ) else { + panic!("auxiliary terminal") + }; + assert!(error.is::()); + if visitor_error { + assert!(error.to_string().contains("stylex.include"), "{error}"); + } + if mapped { + assert!(error.to_string().contains(&expected), "{error}"); + } else { + assert!( + error.to_string().contains("position unavailable"), + "{error}" + ); + assert!(!error.to_string().contains("A.tsx:1:1"), "{error}"); + } + compiler_receipts::retain_terminal(error.as_ref()); + }); + let error = failed(input("A.tsx", source)); + assert!(error.is::()); + assert_eq!(maps(), before); + } +} diff --git a/libs/extractor/src/counter_fixture_capture_tests.rs b/libs/extractor/src/counter_fixture_capture_tests.rs new file mode 100644 index 000000000..d0f4a6947 --- /dev/null +++ b/libs/extractor/src/counter_fixture_capture_tests.rs @@ -0,0 +1,152 @@ +use crate::{ + counter_test_support::{FixtureRequest, with_original}, + extract_style::{ + ExtractDynamicStyle, ExtractKeyframes, ProducerPolicy, + extract_static_style::ExtractStaticStyle, extract_style_value::ExtractStyleValue, + }, +}; +use css::file_map::{get_original_ids, set_original_ids}; +use std::collections::BTreeMap; + +#[test] +#[serial_test::serial] +fn registration_preserves_exact_filename_when_restored_id_is_not_length() { + // Given: a restored ID is unrelated to registry length. + let saved = get_original_ids(); + set_original_ids(BTreeMap::from([("seed.tsx".into(), 42)])); + // When: constructors register repeated and case-distinct raw filenames. + let policies = ["seed.tsx", "seed.tsx", "Seed.tsx", "./seed.tsx"].map(|filename| { + with_original( + FixtureRequest { + filename, + source: "tone", + }, + || ExtractStaticStyle::new("color", "red", 0, None).producer_policy(), + ) + .unwrap_or_else(|error| panic!("fixture: {error}")) + }); + // Then: exact reuse preserves 42; new raw spellings append at length. + assert_eq!( + policies, + [ + ProducerPolicy::CounterOriginal(42), + ProducerPolicy::CounterOriginal(42), + ProducerPolicy::CounterOriginal(1), + ProducerPolicy::CounterOriginal(2), + ] + ); + assert_eq!( + get_original_ids(), + BTreeMap::from([ + ("seed.tsx".into(), 42), + ("Seed.tsx".into(), 1), + ("./seed.tsx".into(), 2), + ]) + ); + set_original_ids(saved); +} + +#[test] +#[serial_test::serial] +fn numeric_sites_match_entry_normalization_when_received_source_has_bom_and_crlf() { + // Given: both sources parse as the same normalized LF source. + let saved = get_original_ids(); + set_original_ids(BTreeMap::new()); + // When: real constructors attach the normalized AST offset and syntax role. + let styles = ["\u{feff}a\r\nbcd", "a\nbcd"].map(|source| { + with_original( + FixtureRequest { + filename: "raw.tsx", + source, + }, + || ExtractDynamicStyle::new("color", 0, "tone", None).at_role(2, 3), + ) + .unwrap_or_else(|error| panic!("fixture: {error}")) + }); + // Then: normalization occurs once, retaining numbered position and role. + let expected = css::Site { + file: css::sparse_site::SourceFile::D9(0), + at: 2, + role: 3, + }; + assert_eq!(styles[0].site(), Some(&expected)); + assert_eq!(styles[1].site(), Some(&expected)); + assert_eq!( + styles[0].producer_policy(), + ProducerPolicy::CounterOriginal(0) + ); + set_original_ids(saved); +} + +#[test] +#[serial_test::serial] +fn constructors_retain_policy_before_union_when_fixtures_are_deferred() { + // Given: real constructors, including basic/static, no-site and empty parents. + let saved = get_original_ids(); + set_original_ids(BTreeMap::new()); + let values = with_original( + FixtureRequest { + filename: "a", + source: "tone", + }, + || { + let static_style = ExtractStaticStyle::new("color", "red", 0, None); + let basic = ExtractStaticStyle::new_basic("color", "red", 0, None); + let dynamic = ExtractDynamicStyle::new("color", 0, "tone", None); + let site = dynamic.clone().at_role(0, 1); + let empty = ExtractKeyframes::default(); + let mut populated = ExtractKeyframes::default(); + populated.keyframes.insert("to".into(), vec![basic.clone()]); + assert_eq!( + static_style.producer_policy(), + ProducerPolicy::CounterOriginal(0) + ); + assert_eq!(basic.producer_policy(), ProducerPolicy::CounterOriginal(0)); + assert_eq!( + dynamic.producer_policy(), + ProducerPolicy::CounterOriginal(0) + ); + assert_eq!(site.producer_policy(), ProducerPolicy::CounterOriginal(0)); + assert_eq!(empty.producer_policy(), ProducerPolicy::CounterOriginal(0)); + assert_eq!( + populated.producer_policy(), + ProducerPolicy::CounterOriginal(0) + ); + vec![ + ExtractStyleValue::Static(static_style), + ExtractStyleValue::Static(basic), + ExtractStyleValue::Dynamic(dynamic), + ExtractStyleValue::Dynamic(site), + ExtractStyleValue::Keyframes(empty), + ExtractStyleValue::Keyframes(populated), + ] + }, + ) + .unwrap_or_else(|error| panic!("fixture: {error}")); + // When: the owned unions are cloned after the fixture TLS has ended. + let deferred = values.clone(); + // Then: the retained policy and numbered site survive; Current is restored. + assert_eq!(values, deferred); + for value in deferred { + match value { + ExtractStyleValue::Static(style) => { + assert_eq!(style.producer_policy(), ProducerPolicy::CounterOriginal(0)); + } + ExtractStyleValue::Dynamic(style) => { + assert_eq!(style.producer_policy(), ProducerPolicy::CounterOriginal(0)); + } + ExtractStyleValue::Keyframes(frames) => { + assert_eq!(frames.producer_policy(), ProducerPolicy::CounterOriginal(0)); + } + ExtractStyleValue::Typography(_) + | ExtractStyleValue::Css(_) + | ExtractStyleValue::Import(_) + | ExtractStyleValue::FontFace(_) => panic!("unexpected constructor union"), + } + } + assert_eq!( + ExtractKeyframes::default().producer_policy(), + ProducerPolicy::Current + ); + set_original_ids(saved); +} diff --git a/libs/extractor/src/counter_fixture_current_tests.rs b/libs/extractor/src/counter_fixture_current_tests.rs new file mode 100644 index 000000000..9fae9f712 --- /dev/null +++ b/libs/extractor/src/counter_fixture_current_tests.rs @@ -0,0 +1,89 @@ +use crate::{ + ExtractOption, + counter_test_support::{FixtureRequest, with_original}, + extract, + extract_style::{ + CounterProducerError, ExtractDynamicStyle, ProducerPolicy, + extract_static_style::ExtractStaticStyle, extract_style_value::ExtractStyleValue, + }, +}; + +#[test] +#[serial_test::serial] +fn ordinary_extract_stays_current_when_called_inside_and_outside_counter_fixture() { + // Given: feature-enabled ordinary extraction with a static and dynamic declaration. + css::set_prefix(Some("p".into())); + css::debug::set_debug(false); + css::atom_hoist::set_atom_hoist(None); + css::file_map::reset_file_map(); + css::file_map::reset_canonical_map(); + css::class_map::reset_class_map(); + let source = + "import { Box } from '@devup-ui/react'; export const a = ;"; + let outside = extract("current.tsx", source, ExtractOption::default()) + .unwrap_or_else(|error| panic!("ordinary: {error}")); + let before = css::class_map::get_class_map(); + // When: the same real compiler entry runs inside a Counter constructor fixture. + let (inside, restored) = with_original( + FixtureRequest { + filename: "fixture", + source: "tone", + }, + || { + let inside = extract("current.tsx", source, ExtractOption::default()) + .unwrap_or_else(|error| panic!("nested ordinary: {error}")); + let restored = ExtractDynamicStyle::new("color", 0, "tone", None).at(0); + (inside, restored) + }, + ) + .unwrap_or_else(|error| panic!("fixture: {error}")); + let after = extract("current.tsx", source, ExtractOption::default()) + .unwrap_or_else(|error| panic!("after ordinary: {error}")); + // Then: byte output and style identities do not change or reserve Counter names. + assert_eq!(inside.code, outside.code); + assert_eq!(inside.map, outside.map); + assert_eq!(inside.css_file, outside.css_file); + assert_eq!(inside.styles, outside.styles); + assert_eq!(after.code, outside.code); + assert_eq!(after.styles, outside.styles); + assert_eq!(css::class_map::get_class_map(), before); + assert_eq!( + restored.producer_policy(), + ProducerPolicy::CounterOriginal(0) + ); + assert_eq!( + restored.site().map(|site| (&site.file, site.at, site.role)), + Some((&css::sparse_site::SourceFile::D9(0), 0, 0)) + ); + assert_eq!( + ExtractStaticStyle::new("color", "red", 0, None).producer_policy(), + ProducerPolicy::Current + ); + assert_eq!(inside.styles.len(), 2); + for value in inside.styles { + match value { + ExtractStyleValue::Static(style) => { + assert_eq!(style.producer_policy(), ProducerPolicy::Current); + assert_eq!( + style.counter_produce(None), + Err(CounterProducerError::WrongPolicy) + ); + } + ExtractStyleValue::Dynamic(style) => { + assert_eq!(style.producer_policy(), ProducerPolicy::Current); + assert_eq!( + style.counter_produce(None), + Err(CounterProducerError::WrongPolicy) + ); + } + ExtractStyleValue::Keyframes(_) + | ExtractStyleValue::Typography(_) + | ExtractStyleValue::Css(_) + | ExtractStyleValue::Import(_) + | ExtractStyleValue::FontFace(_) => panic!("unexpected ordinary style"), + } + } + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + css::set_prefix(None); +} diff --git a/libs/extractor/src/counter_fixture_scope_tests.rs b/libs/extractor/src/counter_fixture_scope_tests.rs new file mode 100644 index 000000000..f24908ccc --- /dev/null +++ b/libs/extractor/src/counter_fixture_scope_tests.rs @@ -0,0 +1,118 @@ +use crate::{ + counter_test_support::{FixtureRequest, with_original}, + extract_style::{ExtractDynamicStyle, ProducerPolicy}, + sparse_sites::{SiteScope, producer_policy, site_at, site_errors}, +}; +use css::file_map::{get_original_ids, set_original_ids}; +use std::{collections::BTreeMap, panic::catch_unwind}; + +#[test] +#[serial_test::serial] +fn nesting_restores_prior_counter_when_inner_constructor_scope_succeeds() { + // Given: an existing private numbered scope with prior assignment state. + let saved = get_original_ids(); + set_original_ids(BTreeMap::new()); + let _prior = SiteScope::enter_counter_numbered(42, "prior", &[]); + let prior_site = site_at(1, 2, "prior"); + // When: nested fixtures construct owned IR from different raw originals. + let (outer, inner, restored) = with_original( + FixtureRequest { + filename: "a", + source: "tone", + }, + || { + let outer = ExtractDynamicStyle::new("color", 0, "tone", None).at(0); + let inner = with_original( + FixtureRequest { + filename: "b", + source: "other", + }, + || ExtractDynamicStyle::new("color", 0, "other", None).at_role(1, 3), + ) + .unwrap_or_else(|error| panic!("inner: {error}")); + (outer, inner, producer_policy()) + }, + ) + .unwrap_or_else(|error| panic!("outer: {error}")); + // Then: both nested and pre-existing contexts restore, including assignments. + assert_eq!(outer.producer_policy(), ProducerPolicy::CounterOriginal(0)); + assert_eq!(inner.producer_policy(), ProducerPolicy::CounterOriginal(1)); + assert_eq!(restored, ProducerPolicy::CounterOriginal(0)); + assert_eq!(producer_policy(), ProducerPolicy::CounterOriginal(42)); + assert_eq!(site_at(1, 2, "prior"), prior_site); + assert_eq!(site_errors(), vec![]); + set_original_ids(saved); +} + +#[rstest::rstest] +#[case(false)] +#[case(true)] +#[serial_test::serial] +fn closure_error_restores_context_when_prior_policy_is_current_or_counter(#[case] counter: bool) { + // Given: either ordinary Current or private Counter scope has prior sites. + let saved = get_original_ids(); + set_original_ids(BTreeMap::new()); + let _prior = if counter { + SiteScope::enter_counter_numbered(42, "prior", &[]) + } else { + SiteScope::enter("prior", "prior", &[]) + }; + let policy = producer_policy(); + let site = site_at(1, 2, "prior"); + // When: the synchronous closure returns its own typed error. + let result = with_original( + FixtureRequest { + filename: "inner", + source: "tone", + }, + || { + assert_eq!(producer_policy(), ProducerPolicy::CounterOriginal(0)); + Err::<(), _>(crate::extract_style::CounterProducerError::WrongPolicy) + }, + ); + // Then: closure Err is preserved and neither policy nor assignments leak. + assert_eq!( + result, + Ok(Err(crate::extract_style::CounterProducerError::WrongPolicy)) + ); + assert_eq!(producer_policy(), policy); + assert_eq!(site_at(1, 2, "prior"), site); + assert_eq!(site_errors(), vec![]); + set_original_ids(saved); +} + +#[rstest::rstest] +#[case(false)] +#[case(true)] +#[serial_test::serial] +fn unwind_restores_context_when_prior_policy_is_current_or_counter(#[case] counter: bool) { + // Given: a prior context exists before the feature-only fixture starts. + let saved = get_original_ids(); + set_original_ids(BTreeMap::new()); + let _prior = if counter { + SiteScope::enter_counter_numbered(42, "prior", &[]) + } else { + SiteScope::enter("prior", "prior", &[]) + }; + let policy = producer_policy(); + let site = site_at(1, 2, "prior"); + // When: the closure unwinds through the fixture's existing RAII scope. + let result = catch_unwind(|| { + with_original( + FixtureRequest { + filename: "inner", + source: "tone", + }, + || { + assert_eq!(producer_policy(), ProducerPolicy::CounterOriginal(0)); + panic!("fixture unwind") + }, + ) + }); + // Then: the panic propagates, restoring the complete prior TLS context. + assert!(result.is_err()); + assert_eq!(producer_policy(), policy); + assert_eq!(site_at(1, 2, "prior"), site); + assert_eq!(site_errors(), vec![]); + set_original_ids(saved); +} diff --git a/libs/extractor/src/counter_test_support.rs b/libs/extractor/src/counter_test_support.rs new file mode 100644 index 000000000..61fdb5e11 --- /dev/null +++ b/libs/extractor/src/counter_test_support.rs @@ -0,0 +1,33 @@ +//! Constructor fixtures only; ordinary extraction always selects Current policy. + +use crate::provenance::{SiteScope, normalize_source}; + +/// Exact original filename and source as received before normalization. +#[derive(Clone, Copy, Debug)] +pub struct FixtureRequest<'a> { + /// Raw filename, without canonicalization or case folding. + pub filename: &'a str, + /// Received source, before alias or evaluation edits. + pub source: &'a str, +} + +/// The existing original-registration conversion error. +pub type FixtureError = std::num::TryFromIntError; + +/// Construct owned IR under a registered original, restoring prior TLS on every exit. +/// +/// This is not a Counter JavaScript compiler entry. Calling ordinary extraction +/// inside `build` still constructs Current IR and temporarily replaces this scope. +/// A closure returning `Result` retains its result as the successful outer value. +/// +/// # Errors +/// Returns the registration error when the original registry length exceeds `u32::MAX`. +pub fn with_original( + request: FixtureRequest<'_>, + build: impl FnOnce() -> T, +) -> Result { + let original = css::file_map::get_or_insert_original_id(request.filename)?; + let normalized = normalize_source(request.source); + let _scope = SiteScope::enter_counter_numbered(original, &normalized, &[]); + Ok(build()) +} diff --git a/libs/extractor/src/coverage_tests.rs b/libs/extractor/src/coverage_tests.rs new file mode 100644 index 000000000..763066785 --- /dev/null +++ b/libs/extractor/src/coverage_tests.rs @@ -0,0 +1,48 @@ +mod capture; +mod elements; +mod generation; +mod naming; +mod normalization; +mod operands; +mod origins; +mod residual_operands; + +use oxc_allocator::{Allocator, CloneIn}; +use oxc_ast::ast::{Expression, Statement}; +use oxc_parser::Parser; +use oxc_span::SourceType; + +pub(crate) fn expression<'a>(allocator: &'a Allocator, source: &'a str) -> Expression<'a> { + let parsed = Parser::new(allocator, source, SourceType::tsx()).parse(); + assert_eq!(parsed.diagnostics.len(), 0, "{source}"); + let Statement::ExpressionStatement(statement) = &parsed.program.body[0] else { + panic!("expression fixture"); + }; + crate::utils::unwrap_syntax_only(&statement.expression).clone_in(allocator) +} + +fn dynamic(property: &str, identifier: &str) -> crate::ExtractStyleValue { + crate::ExtractStyleValue::Dynamic( + crate::extract_style::extract_dynamic_style::ExtractDynamicStyle::new( + property, 0, identifier, None, + ), + ) +} + +pub(crate) fn code(expression: &Expression<'_>) -> String { + crate::utils::expression_to_code(expression) + .trim() + .trim_end_matches(';') + .to_string() +} + +#[test] +#[should_panic(expected = "expression fixture")] +fn residual_expression_fixture_rejects_valid_declaration_statement() { + // Given: valid TS, but not the expression-statement fixture contract. + let allocator = Allocator::default(); + // When / Then: the helper intentionally rejects the statement shape. + expression(&allocator, "let value = 1;"); +} + +mod spread_alignment; diff --git a/libs/extractor/src/coverage_tests/capture.rs b/libs/extractor/src/coverage_tests/capture.rs new file mode 100644 index 000000000..754491f87 --- /dev/null +++ b/libs/extractor/src/coverage_tests/capture.rs @@ -0,0 +1,141 @@ +use super::{code, expression}; +use crate::{ExtractStyleProp, ExtractStyleValue, assignment_test_support::evaluate}; +use oxc_allocator::Allocator; +use oxc_ast::builder::AstBuilder; +use oxc_span::{GetSpan, SPAN}; + +#[test] +fn component_capture_orders_evaluations_inside_each_render() { + // Given: consumers are stored in reverse order, while spans retain authored order. + let allocator = Allocator::default(); + let ast = AstBuilder::new(&allocator); + let mut component = expression(&allocator, "()=>({value:first+second})"); + let mut styles = + [("second", 20), ("first", 10)].map(|(name, start)| ExtractStyleProp::Evaluated { + styles: vec![], + source: expression(&allocator, "input"), + binding: name.into(), + evaluation: Some(oxc_ast::ast::Expression::new_call_expression( + oxc_span::Span::new(start, start + 1), + expression(&allocator, name), + None, + oxc_allocator::Vec::new_in(&ast), + false, + &ast, + )), + alternate_order: None, + alternate_class: false, + }); + // When + crate::assignment_capture::component(&ast, &mut component, &mut styles); + let actual = evaluate(&format!( + "let trace=[];function first(){{trace.push('first');return 2}}function second(){{trace.push('second');return 3}}const render={};const before=trace.length;const a=render(),b=render();JSON.stringify([before,trace,a.value,b.value]);", + code(&component) + )); + // Then + assert_eq!( + actual, + "[0,[\"first\",\"second\",\"first\",\"second\"],5,5]" + ); + assert!(styles.iter().all(|style| matches!( + style, + ExtractStyleProp::Evaluated { + evaluation: None, + .. + } + ))); +} + +#[test] +fn class_capture_leaves_missing_variants_untouched() { + // Given + let allocator = Allocator::default(); + let ast = AstBuilder::new(&allocator); + let source = expression(&allocator, "state.value"); + let mut variants = [None, Some(expression(&allocator, "('alternate')"))]; + // When + let captured = crate::class_evaluation::capture(&ast, &source, &mut variants); + // Then + assert!(captured.is_none()); + assert!(variants[0].is_none()); + assert_eq!( + crate::utils::get_string_by_literal_expression( + variants[1] + .as_ref() + .unwrap_or_else(|| panic!("alternate retained")) + ) + .as_deref(), + Some("alternate") + ); +} + +#[test] +fn constant_typography_class_still_evaluates_its_authored_operand_once() { + // Given: precomputed class data must not erase observable source evaluation. + let allocator = Allocator::default(); + let ast = AstBuilder::new(&allocator); + let source = expression(&allocator, "state.value"); + let mut styles = vec![ExtractStyleProp::Static(ExtractStyleValue::Typography( + "heading".into(), + ))]; + let mut next = 0; + // When + let (_, capture) = crate::element_evaluation::typography(&ast, &mut styles, &source, &mut next) + .unwrap_or_else(|| panic!("typography capture")); + let actual = evaluate(&format!( + "let reads=0;const state={{get value(){{reads++;return 'heading'}}}};const value={};JSON.stringify([reads,value]);", + code(&capture) + )); + // Then + assert_eq!(actual, "[1,\"typo-heading\"]"); + assert_eq!(capture.span(), source.span()); +} + +#[test] +fn synthesized_attribute_capture_uses_the_authored_attribute_coordinate() { + // Given + let allocator = Allocator::default(); + let ast = AstBuilder::new(&allocator); + let mut source = expression(&allocator, ""); + let oxc_ast::ast::Expression::JSXElement(element) = &mut source else { + panic!("element fixture") + }; + let oxc_ast::ast::JSXAttributeItem::Attribute(attribute) = + &mut element.opening_element.attributes[0] + else { + panic!("attribute fixture") + }; + let span = attribute.span; + *crate::visit::attribute_value_mut(attribute).unwrap_or_else(|| panic!("id value")) = + oxc_ast::ast::Expression::new_identifier(SPAN, "synthesized", &ast); + // When + let values = crate::element_evaluation::capture(&ast, element, &mut 0); + // Then: scheduling gets the original id position rather than the synthetic zero. + assert_eq!(values.len(), 1); + assert_eq!(values[0].1.span(), span); + assert_eq!(code(&values[0].1), "synthesized"); +} + +#[test] +fn creation_capture_does_not_rewrite_an_authored_callback_body() { + // Given + let allocator = Allocator::default(); + let ast = AstBuilder::new(&allocator); + let source = expression(&allocator, "({color:state.value})"); + let mut component = expression(&allocator, "()=>state.value"); + // When + crate::assignment_capture::styled_creation( + &ast, + &mut component, + crate::assignment_capture::StyledCreation { + source: &source, + styles: &mut [], + }, + ); + let actual = evaluate(&format!( + "let reads=0;const state={{get value(){{return ++reads}}}};const render={};const created=reads;const first=render(),second=render();JSON.stringify([created,first,second,reads]);", + code(&component) + )); + // Then: creating the literal and invoking the independent callback are separate reads. + assert_eq!(actual, "[1,2,3,3]"); +} diff --git a/libs/extractor/src/coverage_tests/elements.rs b/libs/extractor/src/coverage_tests/elements.rs new file mode 100644 index 000000000..cbaeab5d5 --- /dev/null +++ b/libs/extractor/src/coverage_tests/elements.rs @@ -0,0 +1,161 @@ +use crate::assignment_blocker_support::selected; +use crate::assignment_test_support::{compiled_jsx, evaluate}; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case("styled.div")] +#[case("css")] +#[serial] +fn hard_string_spread_reports_the_authored_call(#[case] factory: &str) { + // Given + let source = format!( + "import {{styled,css}} from '@devup-ui/react';\nexport const A = {factory}(...'color:red;');" + ); + let offset = source + .find(&format!("{factory}(...")) + .unwrap_or_else(|| panic!("authored call")); + let before = &source[..offset]; + let line = before.bytes().filter(|byte| *byte == b'\n').count() + 1; + let column = before + .rsplit('\n') + .next() + .unwrap_or_else(|| panic!("authored line")) + .len() + + 1; + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + // When + let Err(actual) = crate::extract("spread.tsx", &source, crate::ExtractOption::default()) else { + panic!("string iteration is not static style composition"); + }; + // Then + assert_eq!( + actual.to_string(), + format!( + "spread.tsx:{line}:{column}: Cannot compose `...\"color:red;\"` at build time: each style must be a rule object, a class, or a condition choosing between them; pass the value itself instead of spreading it" + ) + ); +} + +#[rstest] +#[case("styled.div", "{[Symbol.iterator]: function*(){yield {color:'red'}}}")] +#[case("css", "{[Symbol.iterator]: function*(){yield {color:'red'}}}")] +#[case("styled.div", "globalThis.styles")] +#[case("css", "globalThis.styles")] +#[serial] +fn hard_unsupported_spread_reports_the_authored_call(#[case] factory: &str, #[case] operand: &str) { + // Given: a valid iterator or a value whose iterable contents are unknown. + let source = format!( + "import {{styled,css}} from '@devup-ui/react';\nexport const A = {factory}(...{operand});" + ); + let offset = source + .find(&format!("{factory}(...")) + .unwrap_or_else(|| panic!("authored call")); + let before = &source[..offset]; + let line = before.bytes().filter(|byte| *byte == b'\n').count() + 1; + let column = before + .rsplit('\n') + .next() + .unwrap_or_else(|| panic!("authored line")) + .chars() + .count() + + 1; + let allocator = oxc_allocator::Allocator::default(); + let expression = oxc_parser::Parser::new(&allocator, operand, oxc_span::SourceType::ts()) + .parse_expression() + .unwrap_or_else(|error| panic!("valid spread operand: {error:?}")); + let code = crate::utils::readable_code(&expression); + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + // When + let Err(actual) = crate::extract("spread.tsx", &source, crate::ExtractOption::default()) else { + panic!("unsupported argument spread must not execute its iterator"); + }; + // Then: the call, rather than its computed iterator key, owns the diagnostic. + assert_eq!( + actual.to_string(), + format!( + "spread.tsx:{line}:{column}: Cannot compose `...{code}` at build time: each style must be a rule object, a class, or a condition choosing between them; pass the value itself instead of spreading it" + ) + ); +} + +#[test] +#[serial] +fn hard_array_css_spread_selects_the_later_literal_rule() { + // Given + let source = "import {css}from '@devup-ui/react';const node={className:css(...[{color:'blue'},{color:'red'}])};"; + // When + let actual = selected(source, "JSON.stringify(assigned(node));"); + // Then + assert_eq!(actual, "[\"color:red:0\"]"); +} + +#[rstest] +#[case("styled.div")] +#[case("styled('div')")] +#[serial] +fn hard_array_styled_spread_reads_its_getter_at_creation_only(#[case] factory: &str) { + // Given + let source = format!( + "import {{styled}}from '@devup-ui/react';let reads=0;Object.defineProperty(globalThis,'color',{{get(){{reads++;return 'red'}}}});const A={factory}(...[{{color:globalThis.color}}]);const created=reads;const first=A({{}}),second=A({{}});" + ); + // When + let actual = selected( + &source, + "JSON.stringify([created,reads,first.className===second.className,Object.values(first.style),Object.values(second.style),assigned(first),assigned(second)]);", + ); + // Then + assert_eq!( + actual, + "[1,1,true,[\"red\"],[\"red\"],[\"color:red:0\"],[\"color:red:0\"]]" + ); +} + +#[rstest] +#[case( + "", + true, + "[[\"enabled\"],[\"padding:1rem:0\"]]" +)] +#[case("", false, "[[\"enabled\"],[]]")] +#[case( + "jsx(Box,{className:state.enabled&&'p-4'})", + true, + "[[\"enabled\"],[\"padding:1rem:0\"]]" +)] +#[case( + "jsx(Box,{className:state.enabled&&'p-4'})", + false, + "[[\"enabled\"],[]]" +)] +#[serial] +fn rebuilt_tailwind_logical_class_omits_falsy_values( + #[case] expression: &str, + #[case] enabled: bool, + #[case] expected: &str, +) { + // Given + let source = format!( + "import {{Box}}from '@devup-ui/react';import {{jsx}}from 'react/jsx-runtime';let trace=[];const state={{get enabled(){{trace.push('enabled');return {enabled}}}}};const node={expression};" + ); + // When + let actual = selected( + &source, + "function jsx(tag,props){return props}JSON.stringify([trace,assigned(node)]);", + ); + // Then: the true branch selects its emitted padding; false never becomes a class word. + assert_eq!(actual, expected); +} + +#[test] +#[serial] +fn conditional_styled_argument_keeps_its_creation_evaluation_when_rendered_twice() { + // Given: the whole authored rule argument selects a static rule at creation. + let source = "import {styled}from '@devup-ui/react';let reads=0;Object.defineProperty(globalThis,'flag',{get(){reads++;return true}});const A=styled.div(flag?{color:'red'}:{color:'blue'});const created=reads;const first=A({}),second=A({});JSON.stringify([created,reads,first.className===second.className]);"; + // When + let actual = evaluate(&compiled_jsx(source)); + // Then: rendering never reevaluates the already selected argument. + assert_eq!(actual, "[1,1,true]"); +} diff --git a/libs/extractor/src/coverage_tests/generation.rs b/libs/extractor/src/coverage_tests/generation.rs new file mode 100644 index 000000000..a4865a690 --- /dev/null +++ b/libs/extractor/src/coverage_tests/generation.rs @@ -0,0 +1,79 @@ +use super::{code, dynamic, expression}; +use crate::{ExtractStyleProp, assignment_test_support::evaluate, gen_style::gen_styles}; +use oxc_allocator::Allocator; +use oxc_ast::builder::AstBuilder; +use serial_test::serial; +use std::collections::BTreeMap; + +#[test] +#[serial] +fn inline_value_is_absent_when_one_sided_condition_is_false() { + // Given + let allocator = Allocator::default(); + let ast = AstBuilder::new(&allocator); + let styles = [ExtractStyleProp::Conditional { + condition: expression(&allocator, "enabled"), + consequent: Some(Box::new(ExtractStyleProp::Static(dynamic( + "color", "chosen", + )))), + alternate: None, + }]; + // When + let generated = + code(&gen_styles(&ast, &styles, None).unwrap_or_else(|| panic!("inline value"))); + let actual = evaluate(&format!( + "let enabled=false,chosen='red';const absent={generated};enabled=true;const present={generated};JSON.stringify([Object.values(absent),Object.values(present)]);" + )); + // Then + assert_eq!(actual, "[[null],[\"red\"]]"); +} + +#[test] +#[serial] +fn shared_variable_selects_only_the_requested_branch() { + // Given: identical property identity and distinct observable inputs. + let allocator = Allocator::default(); + let ast = AstBuilder::new(&allocator); + let styles = [ExtractStyleProp::Conditional { + condition: expression(&allocator, "enabled"), + consequent: Some(Box::new(ExtractStyleProp::Static(dynamic( + "color", + "state.first", + )))), + alternate: Some(Box::new(ExtractStyleProp::Static(dynamic( + "color", + "state.second", + )))), + }]; + // When + let generated = + code(&gen_styles(&ast, &styles, None).unwrap_or_else(|| panic!("inline value"))); + let actual = evaluate(&format!( + "let enabled=true,trace=[];const state={{get first(){{trace.push('first');return 'red'}},get second(){{trace.push('second');return 'blue'}}}};const first={generated};enabled=false;const second={generated};JSON.stringify([trace,Object.keys(first).length,Object.keys(second).length,Object.values(first),Object.values(second)]);" + )); + // Then + assert_eq!(actual, "[[\"first\",\"second\"],1,1,[\"red\"],[\"blue\"]]"); +} + +#[test] +#[serial] +fn single_member_variable_reads_its_value_without_reading_the_selector() { + // Given + let allocator = Allocator::default(); + let ast = AstBuilder::new(&allocator); + let styles = [ExtractStyleProp::MemberExpression { + map: BTreeMap::from([( + "one".into(), + Box::new(ExtractStyleProp::Static(dynamic("color", "state.value"))), + )]), + expression: expression(&allocator, "state.key"), + }]; + // When + let generated = + code(&gen_styles(&ast, &styles, None).unwrap_or_else(|| panic!("inline value"))); + let actual = evaluate(&format!( + "let reads=0;const state={{get value(){{reads++;return 'red'}},get key(){{throw Error('unused selector')}}}};const result={generated};JSON.stringify([reads,Object.values(result)]);" + )); + // Then: this IR seam has already selected the sole variable consumer. + assert_eq!(actual, "[1,[\"red\"]]"); +} diff --git a/libs/extractor/src/coverage_tests/naming.rs b/libs/extractor/src/coverage_tests/naming.rs new file mode 100644 index 000000000..32890980e --- /dev/null +++ b/libs/extractor/src/coverage_tests/naming.rs @@ -0,0 +1,100 @@ +use super::{dynamic, expression}; +use crate::{ExtractStyleProp, ExtractStyleValue, provenance::join_naming}; +use css::Naming; +use oxc_allocator::Allocator; +use rstest::rstest; +use std::collections::BTreeMap; + +#[rstest] +#[case("conditional")] +#[case("enum")] +#[case("member")] +#[case("expression")] +fn imported_provenance_reaches_every_nested_consumer(#[case] shape: &str) { + // Given: mixed preexisting ownership; joining Own must not erase Risky. + let allocator = Allocator::default(); + let source = expression(&allocator, "input"); + let mut already_risky = dynamic("color", "first"); + already_risky.join_naming(Naming::Risky); + let values = vec![already_risky, dynamic("background-color", "second")]; + let leaf = || { + ExtractStyleProp::StaticArray( + values + .iter() + .cloned() + .map(ExtractStyleProp::Static) + .collect(), + ) + }; + let prop = match shape { + "conditional" => ExtractStyleProp::Conditional { + condition: source, + consequent: Some(Box::new(leaf())), + alternate: Some(Box::new(leaf())), + }, + "enum" => ExtractStyleProp::Enum { + condition: source, + map: BTreeMap::from([("a".into(), vec![leaf()]), ("b".into(), vec![leaf()])]), + }, + "member" => ExtractStyleProp::MemberExpression { + expression: source, + map: BTreeMap::from([ + ("a".into(), Box::new(leaf())), + ("b".into(), Box::new(leaf())), + ]), + }, + "expression" => ExtractStyleProp::Expression { + expression: source, + styles: values, + }, + _ => panic!("fixture shape"), + }; + let mut props = [prop]; + // When + join_naming(&mut props, Naming::Risky); + join_naming(&mut props, Naming::Own); + // Then: both branches/entries retain every property and all become Risky. + let actual: Vec<_> = props[0] + .extract() + .into_iter() + .map(|value| match value { + ExtractStyleValue::Dynamic(style) => (style.property().to_string(), style.naming()), + other => panic!("unexpected record {other:?}"), + }) + .collect(); + let pair = vec![ + ("color".to_string(), Naming::Risky), + ("background-color".to_string(), Naming::Risky), + ]; + let expected = if shape == "expression" { + pair + } else { + pair.iter().chain(&pair).cloned().collect() + }; + assert_eq!(actual, expected); +} + +#[test] +fn typography_identity_is_unchanged_when_dependency_naming_is_joined() { + // Given + let mut value = ExtractStyleValue::Typography("heading".into()); + let expected = value.clone(); + // When + value.join_naming(Naming::Risky); + // Then + assert_eq!(value, expected); +} + +#[test] +fn assignment_binding_avoids_every_authored_prefix_collision() { + // Given + let _scope = crate::sparse_sites::SiteScope::enter( + "collision.tsx", + "let __devupAssignment17;let __devupAssignment17_;", + &[], + ); + // When + let binding = crate::sparse_sites::binding_name(17); + // Then: name must be safe as a JS binding, including the already suffixed name. + assert_eq!(binding, "__devupAssignment17__"); +} diff --git a/libs/extractor/src/coverage_tests/normalization.rs b/libs/extractor/src/coverage_tests/normalization.rs new file mode 100644 index 000000000..fdc6c6550 --- /dev/null +++ b/libs/extractor/src/coverage_tests/normalization.rs @@ -0,0 +1,65 @@ +use super::{code, dynamic, expression}; +use crate::{ExtractStyleProp, assignment_test_support::evaluate}; +use oxc_allocator::Allocator; +use oxc_ast::builder::AstBuilder; +use serial_test::serial; + +#[test] +#[serial] +fn important_leaf_keeps_clean_inline_value_and_original_raw_suffix() { + // Given + let allocator = Allocator::default(); + let ast = AstBuilder::new(&allocator); + let source = expression(&allocator, "normalized"); + let mut styles = [ExtractStyleProp::Static(dynamic( + "color", + "normalized !important", + ))]; + let lowering = crate::assignment_lowering::Lowering { + ast: &ast, + order: None, + filename: None, + alternate_order: None, + }; + // When + let generated = lowering.lower(&source, &mut styles); + let actual = evaluate(&format!( + "const normalized='red';const result={};JSON.stringify([Object.values(result[1]),result[2]]);", + code(&generated) + )); + // Then + assert_eq!(actual, "[[\"red\"],\"red !important\"]"); +} + +#[test] +#[serial] +fn risky_partial_class_map_uses_import_dependent_atom_identity() { + use crate::extract_style::ExtractStyleProperty; + + // Given + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + let source = "import {css}from '@devup-ui/react';export const sample=css({color:'red'});"; + let names = rustc_hash::FxHashSet::from_iter(["sample".to_string()]); + let option = crate::ExtractOption::default(); + // When + let actual = crate::extract_class_map_from_code( + "partial.tsx", + source, + &option, + &names, + css::Naming::Risky, + ) + .unwrap_or_else(|error| panic!("class map: {error}")); + // Then: compare to independently constructed risky content, not the output itself. + let style = crate::extract_style::extract_static_style::ExtractStaticStyle::new( + "color", "red", 0, None, + ) + .with_naming(css::Naming::Risky); + let crate::extract_style::style_property::StyleProperty::ClassName(expected) = + style.extract(Some("partial.tsx")) + else { + panic!("static class") + }; + assert_eq!(actual.get("sample"), Some(&expected)); +} diff --git a/libs/extractor/src/coverage_tests/operands.rs b/libs/extractor/src/coverage_tests/operands.rs new file mode 100644 index 000000000..6606290b5 --- /dev/null +++ b/libs/extractor/src/coverage_tests/operands.rs @@ -0,0 +1,162 @@ +use super::{code, dynamic, expression}; +use crate::{ + ExtractStyleProp, ExtractStyleValue, + assignment_test_support::{compiled_jsx, evaluate}, +}; +use oxc_allocator::Allocator; +use oxc_ast::builder::AstBuilder; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case("motion.button", "button")] +#[case("motion.controls.button", "button")] +#[serial] +fn static_member_types_keep_the_receiver_chain(#[case] tag: &str, #[case] expected: &str) { + // Given + let source = format!( + "import {{Box}}from '@devup-ui/react';const motion={{button:'button',controls:{{button:'button'}}}};const node=;JSON.stringify(node.tag);" + ); + // When + let actual = evaluate(&compiled_jsx(&source)); + // Then + assert_eq!(actual, format!("\"{expected}\"")); +} + +#[test] +#[serial] +fn array_shape_cannot_collapse_to_one_scalar_assignment() { + // Given + let allocator = Allocator::default(); + let source = expression(&allocator, "[state.value]"); + let styles = [ExtractStyleProp::Static(dynamic("color", "state.value"))]; + // When + let scalar = crate::assignment_owner::scalar(&source, &styles); + // Then: responsive arrays require the structured consumer, not a scalar variable. + assert_eq!(scalar, None); +} + +#[test] +fn object_spread_is_not_a_literal_evaluation_proof() { + // Given + let allocator = Allocator::default(); + let source = expression(&allocator, "({...{color:'red'}})"); + // When + let literal = crate::static_assignment::literal_source(&source); + // Then + assert!(!literal); +} + +#[test] +fn dynamic_records_require_inline_assignment_even_inside_class_containers() { + // Given + let style = + ExtractStyleProp::StaticArray(vec![ExtractStyleProp::Static(dynamic("color", "input"))]); + // When + let class_only = crate::static_assignment::class_only(&style); + // Then + assert!(!class_only); +} + +#[rstest] +#[case("state", true)] +#[case("read()", false)] +fn private_member_purity_follows_the_receiver(#[case] receiver: &str, #[case] expected: bool) { + use oxc_ast_visit::Visit; + + struct PrivateReads(Vec); + impl<'a> oxc_ast_visit::Visit<'a> for PrivateReads { + fn visit_expression(&mut self, expression: &oxc_ast::ast::Expression<'a>) { + if matches!( + expression, + oxc_ast::ast::Expression::PrivateFieldExpression(_) + ) { + self.0.push(crate::utils::is_pure(expression)); + } + oxc_ast_visit::walk::walk_expression(self, expression); + } + } + // Given + let allocator = Allocator::default(); + let source = format!("class Receiver{{#value;method(state,read){{return {receiver}.#value}}}}"); + let parsed = oxc_parser::Parser::new(&allocator, &source, oxc_span::SourceType::tsx()).parse(); + assert_eq!(parsed.diagnostics.len(), 0); + let mut reads = PrivateReads(vec![]); + // When + reads.visit_program(&parsed.program); + // Then + assert_eq!(reads.0, vec![expected]); +} + +#[test] +#[serial] +fn static_selector_identity_distinguishes_layered_declarations() { + // Given + let plain = crate::extract_style::extract_static_style::ExtractStaticStyle::new( + "color", "red", 0, None, + ); + let mut layered = plain.clone(); + layered.layer = Some("base".into()); + // When + let identity = ( + plain.class_selector().map(std::borrow::Cow::into_owned), + layered.class_selector().map(std::borrow::Cow::into_owned), + ); + // Then + assert_eq!(identity, (None, Some("@layer base".into()))); +} + +#[rstest] +#[case("({color:state.color})", "[\"red\",[\"color\"]]")] +#[case("[state.color]", "[\"red\",[\"color\"]]")] +#[serial] +fn lowered_container_keeps_raw_values_when_special_records_are_not_consumers( + #[case] source: &str, + #[case] expected: &str, +) { + // Given: typed IR with a dynamic consumer and an unrelated special record. + let allocator = Allocator::default(); + let ast = AstBuilder::new(&allocator); + let source = expression(&allocator, source); + let mut styles = [ + ExtractStyleProp::Static(dynamic("color", "state.color")), + ExtractStyleProp::Static(ExtractStyleValue::Typography("heading".into())), + ]; + let lowering = crate::assignment_lowering::Lowering { + ast: &ast, + order: None, + filename: None, + alternate_order: None, + }; + // When + let generated = lowering.lower(&source, &mut styles); + let actual = evaluate(&format!( + "let trace=[];const state={{get color(){{trace.push('color');return 'red'}}}};const result={};JSON.stringify([Array.isArray(result[2])?result[2][0]:result[2].color,trace]);", + code(&generated) + )); + // Then + assert_eq!(actual, expected); +} + +#[test] +#[serial] +fn missing_member_consumers_preserve_all_literal_field_reads_before_selection() { + // Given + let allocator = Allocator::default(); + let ast = AstBuilder::new(&allocator); + let source = expression(&allocator, "({a:state.first,b:state.second})[state.key]"); + let lowering = crate::assignment_lowering::Lowering { + ast: &ast, + order: None, + filename: None, + alternate_order: None, + }; + // When + let generated = lowering.lower(&source, &mut []); + let actual = evaluate(&format!( + "let trace=[];const state={{get first(){{trace.push('first');return 'red'}},get second(){{trace.push('second');return 'blue'}},get key(){{trace.push('key');return 'b'}}}};const result={};JSON.stringify([trace,result[2]]);", + code(&generated) + )); + // Then + assert_eq!(actual, "[[\"first\",\"second\",\"key\"],\"blue\"]"); +} diff --git a/libs/extractor/src/coverage_tests/origins.rs b/libs/extractor/src/coverage_tests/origins.rs new file mode 100644 index 000000000..8c3d4bb1b --- /dev/null +++ b/libs/extractor/src/coverage_tests/origins.rs @@ -0,0 +1,158 @@ +use super::{dynamic, expression}; +use crate::{ExtractStyleProp, ExtractStyleValue}; +use css::style_origin::{Origin, RealLocation, StyleOrigin}; +use oxc_allocator::Allocator; +use rstest::rstest; + +#[test] +fn expression_records_receive_missing_origins_without_overwriting_exact_ones() { + // Given + let allocator = Allocator::default(); + let expected = StyleOrigin { + file: "authored.tsx".into(), + line: 4, + column: 9, + expression: "state.color".into(), + }; + let original = StyleOrigin { + file: "inner.tsx".into(), + line: 2, + column: 3, + expression: "makeFrames()".into(), + }; + let keyframes = crate::extract_style::extract_keyframes::ExtractKeyframes { + origin: Origin(Some(Box::new(original.clone())), None), + ..Default::default() + }; + let mut props = [ExtractStyleProp::Expression { + expression: expression(&allocator, "state.color"), + styles: vec![ + dynamic("color", "state.color"), + ExtractStyleValue::Keyframes(keyframes), + ], + }]; + // When + crate::style_origin::fill(&mut props, &Origin(Some(Box::new(expected.clone())), None)); + // Then + let records = props[0].extract(); + let ExtractStyleValue::Dynamic(value) = &records[0] else { + panic!("dynamic fixture") + }; + assert_eq!(value.origin.0.as_deref(), Some(&expected)); + let ExtractStyleValue::Keyframes(value) = &records[1] else { + panic!("keyframes fixture") + }; + assert_eq!(value.origin.0.as_deref(), Some(&original)); +} + +#[test] +fn cyclic_composition_gets_export_locations_without_revisiting_bases() { + // Given + let mut collected = crate::vanilla_extract::CollectedStyles::default(); + collected.styles.insert( + "a".into(), + crate::vanilla_extract::StyleEntry { + bases: std::iter::once("b".to_string()).collect(), + ..Default::default() + }, + ); + collected.styles.insert( + "b".into(), + crate::vanilla_extract::StyleEntry { + bases: std::iter::once("a".to_string()).collect(), + ..Default::default() + }, + ); + collected + .keyframes + .insert("unexported".into(), Default::default()); + // When + crate::style_export_locations::associate( + &mut collected, + &[("public".into(), "a".into())], + "actual.css.ts", + ); + // Then + let exported = Some(RealLocation::ModuleExport { + file: "actual.css.ts".into(), + binding: Some("public".into()), + }); + assert_eq!(collected.styles["a"].location, exported); + assert_eq!(collected.styles["b"].location, exported); + assert_eq!( + collected.keyframes["unexported"].location, + Some(RealLocation::ModuleExport { + file: "actual.css.ts".into(), + binding: None + }) + ); +} + +#[test] +fn absent_location_preserves_source_bytes() { + // Given + let source = "({get value(){return '\\u0061'}}) /* authored */"; + // When + let actual = crate::evaluation_origin::mark_location(source.into(), None); + // Then + assert_eq!(actual, source); +} + +#[rstest] +#[case( + "import * as api from '@devup-ui/react';const x=api['style']({color:'red'});", + "api['style']({color:'red'})" +)] +#[case( + "import {'style' as factory} from '@devup-ui/react';const x=factory({color:'red'});", + "factory({color:'red'})" +)] +fn source_packets_keep_exact_authored_factory_calls(#[case] source: &str, #[case] expected: &str) { + use oxc_ast_visit::Visit; + + struct Packets(Vec); + impl<'a> oxc_ast_visit::Visit<'a> for Packets { + fn visit_call_expression(&mut self, call: &oxc_ast::ast::CallExpression<'a>) { + if let Some(oxc_ast::ast::Argument::StringLiteral(packet)) = call.arguments.last() + && let Ok(origin) = serde_json::from_str::(&packet.value) + { + self.0.push(origin); + } + oxc_ast_visit::walk::walk_call_expression(self, call); + } + } + // Given + let allocator = Allocator::default(); + // When + let instrumented = crate::evaluation_origin::instrument( + source, + "packet.css.ts", + &crate::ExtractOption::default(), + ); + let parsed = + oxc_parser::Parser::new(&allocator, &instrumented, oxc_span::SourceType::ts()).parse(); + // Then: inspect the packet consumed by the evaluator, not prose/code formatting. + assert_eq!(parsed.diagnostics.len(), 0); + let mut packets = Packets(vec![]); + packets.visit_program(&parsed.program); + assert_eq!(packets.0.len(), 1); + assert_eq!(packets.0[0].file, "packet.css.ts"); + assert_eq!(packets.0[0].expression, expected); +} + +#[rstest] +#[case("import factory from '@devup-ui/react';factory({color:'red'});")] +#[case("import * as api from '@devup-ui/react';({style:x=>x}).style({color:'red'});")] +#[case( + "import * as api from '@devup-ui/react';function run(api){return api['style']({color:'red'})}" +)] +fn non_factory_bindings_receive_no_origin_arguments(#[case] source: &str) { + // Given / When + let actual = crate::evaluation_origin::instrument( + source, + "nonfactory.css.ts", + &crate::ExtractOption::default(), + ); + // Then + assert_eq!(actual, source); +} diff --git a/libs/extractor/src/coverage_tests/residual_operands.rs b/libs/extractor/src/coverage_tests/residual_operands.rs new file mode 100644 index 000000000..b6683247f --- /dev/null +++ b/libs/extractor/src/coverage_tests/residual_operands.rs @@ -0,0 +1,64 @@ +use super::{code, dynamic, expression}; +use crate::extract_style::{ + extract_css::ExtractCss, extract_keyframes::ExtractKeyframes, + extract_static_style::ExtractStaticStyle, style_property::StyleProperty, +}; +use crate::{ExtractStyleProp, ExtractStyleValue, assignment_test_support::evaluate}; +use oxc_allocator::Allocator; +use oxc_ast::builder::AstBuilder; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case("({color:state.color,other:state.other})", true)] +#[case("[state.color,state.other]", false)] +#[serial] +fn residual_special_records_do_not_consume_color_fields_or_responsive_slots( + #[case] source: &str, + #[case] object: bool, +) { + // Given: real special records, which are not property/level consumers. + let allocator = Allocator::default(); + let ast = AstBuilder::new(&allocator); + let source = expression(&allocator, source); + let frames = ExtractKeyframes { + keyframes: [( + "from".into(), + vec![ExtractStaticStyle::new("opacity", "0", 0, None)], + )] + .into(), + ..Default::default() + }; + let Some(StyleProperty::ClassName(frame_class)) = + ExtractStyleValue::Keyframes(frames.clone()).extract(None) + else { + panic!("keyframes class") + }; + let mut styles = [ + ExtractStyleProp::Static(dynamic("color", "state.color")), + ExtractStyleProp::Static(ExtractStyleValue::Keyframes(frames)), + ExtractStyleProp::Static(ExtractStyleValue::Css(ExtractCss { + css: "body { color: red; }".into(), + file: "residual.css".into(), + })), + ]; + let lowering = crate::assignment_lowering::Lowering { + ast: &ast, + order: None, + filename: None, + alternate_order: None, + }; + // When + let generated = lowering.lower(&source, &mut styles); + let actual = evaluate(&format!( + "let trace=[];const state={{get color(){{trace.push('color');return 'red'}},get other(){{trace.push('other');return 'blue'}}}};const result={};JSON.stringify([Array.isArray(result[2])?result[2]:[result[2].color,result[2].other],trace,result[0].split(' ').filter(x=>x==='{}').length,Object.values(result[1])]);", + code(&generated), + frame_class, + )); + // Then: object residual class appears once; arrays omit non-level records. + let expected_count = usize::from(object); + assert_eq!( + actual, + format!("[[\"red\",\"blue\"],[\"color\",\"other\"],{expected_count},[\"red\"]]") + ); +} diff --git a/libs/extractor/src/coverage_tests/spread_alignment.rs b/libs/extractor/src/coverage_tests/spread_alignment.rs new file mode 100644 index 000000000..973e3629d --- /dev/null +++ b/libs/extractor/src/coverage_tests/spread_alignment.rs @@ -0,0 +1,46 @@ +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case(";", "{bg: \"red\"}")] +#[case(";", "{}")] +#[case(";", "{...{bg: \"red\"}}")] +#[case("export const j = css(...{ bg: 'red' });", "{ bg: 'red' }")] +#[serial] +fn legacy_object_spread_rejects_with_the_authored_call_and_fix( + #[case] statement: &str, + #[case] operand: &str, +) { + // Given: preserve the original noniterable sources as rejection controls. + let source = format!("import {{ css }} from '@devup-ui/react';\n{statement}"); + let offset = source + .find("css(...") + .unwrap_or_else(|| panic!("authored call")); + let before = &source[..offset]; + let line = before.bytes().filter(|byte| *byte == b'\n').count() + 1; + let column = before + .rsplit('\n') + .next() + .unwrap_or_else(|| panic!("authored line")) + .chars() + .count() + + 1; + let allocator = oxc_allocator::Allocator::default(); + let expression = oxc_parser::Parser::new(&allocator, operand, oxc_span::SourceType::ts()) + .parse_expression() + .unwrap_or_else(|error| panic!("valid spread operand: {error:?}")); + let code = crate::utils::readable_code(&expression); + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + // When + let Err(actual) = crate::extract("spread.tsx", &source, crate::ExtractOption::default()) else { + panic!("noniterable object argument spread must be rejected"); + }; + // Then + assert_eq!( + actual.to_string(), + format!( + "spread.tsx:{line}:{column}: Cannot compose `...{code}` at build time: each style must be a rule object, a class, or a condition choosing between them; pass the value itself instead of spreading it" + ) + ); +} diff --git a/libs/extractor/src/css_utils.rs b/libs/extractor/src/css_utils.rs index f0d67200b..dfe3b6d07 100644 --- a/libs/extractor/src/css_utils.rs +++ b/libs/extractor/src/css_utils.rs @@ -428,12 +428,15 @@ pub fn css_to_style_template( // Value is just the expression - use expression code directly let identifier = dynamic_expr_code(&css.expressions[*idx], &shared_allocator); - styles.push(CssToStyleResult::Dynamic(ExtractDynamicStyle::new( - style.property(), - style.level(), - &identifier, - style.selector().cloned(), - ))); + styles.push(CssToStyleResult::Dynamic( + ExtractDynamicStyle::new( + style.property(), + style.level(), + &identifier, + style.selector().cloned(), + ) + .at(css.expressions[*idx].span().start), + )); } else { // Value has surrounding text - need to create template literal // Reconstruct the template literal by replacing placeholders with ${expr} syntax @@ -502,12 +505,22 @@ pub fn css_to_style_template( // Wrap in template literal backticks let final_identifier = format!("`{template_literal}`"); - styles.push(CssToStyleResult::Dynamic(ExtractDynamicStyle::new( - style.property(), - style.level(), - &final_identifier, - style.selector().cloned(), - ))); + styles.push(CssToStyleResult::Dynamic( + ExtractDynamicStyle::new( + style.property(), + style.level(), + &final_identifier, + style.selector().cloned(), + ) + .at_role( + css.span.start, + found_placeholders + .iter() + .map(|(_, index)| *index) + .min() + .map_or(0, |index| index + 1), + ), + )); } } } diff --git a/libs/extractor/src/dependency_coverage_tests.rs b/libs/extractor/src/dependency_coverage_tests.rs new file mode 100644 index 000000000..60cba7964 --- /dev/null +++ b/libs/extractor/src/dependency_coverage_tests.rs @@ -0,0 +1,36 @@ +use super::Dependencies; +use css::Naming; +use oxc_allocator::Allocator; +use oxc_ast_visit::Visit; +use oxc_semantic::SemanticBuilder; +use rstest::rstest; + +#[rstest] +#[case("require('module')", Naming::Risky)] +#[case("import('module')", Naming::Risky)] +#[case("require('module').value", Naming::Risky)] +#[case("({require:x=>x}).require('module')", Naming::Own)] +#[case("ordinary('module')", Naming::Own)] +fn raw_dependency_syntax_keeps_its_naming_distinction( + #[case] source: &str, + #[case] expected: Naming, +) { + // Given: Dependencies classifies raw syntax, not resolver execution or shadow policy. + let allocator = Allocator::default(); + let parsed = oxc_parser::Parser::new(&allocator, source, oxc_span::SourceType::ts()).parse(); + assert_eq!(parsed.diagnostics.len(), 0); + let scoping = SemanticBuilder::new() + .build(&parsed.program) + .semantic + .into_scoping(); + let namings = rustc_hash::FxHashMap::default(); + let mut dependencies = Dependencies { + scoping: &scoping, + namings: &namings, + naming: Naming::Own, + }; + // When + dependencies.visit_program(&parsed.program); + // Then + assert_eq!(dependencies.naming, expected); +} diff --git a/libs/extractor/src/element_capture.rs b/libs/extractor/src/element_capture.rs new file mode 100644 index 000000000..fd6c18bbc --- /dev/null +++ b/libs/extractor/src/element_capture.rs @@ -0,0 +1,116 @@ +//! Capture authored element operands without changing their scheduling coordinates. + +use oxc_allocator::{CloneIn, FromIn, GetAllocator}; +use oxc_ast::{ + ast::{ + Expression, JSXAttributeItem, JSXAttributeName, JSXChild, JSXElement, + JSXExpressionContainer, Str, + }, + builder::AstBuilder, +}; +use oxc_span::{GetSpan, GetSpanMut}; +use oxc_syntax::operator::LogicalOperator; + +use super::needed; +use crate::{utils::stays_attribute, visit::attribute_value_mut}; + +#[cfg(test)] +#[path = "element_capture_tests.rs"] +mod tests; + +pub(crate) fn default_type<'a>( + ast: &AstBuilder<'a>, + value: Expression<'a>, + default: &str, +) -> Expression<'a> { + if matches!(crate::utils::unwrap_syntax_only(&value), Expression::StringLiteral(literal) if !literal.value.is_empty()) + { + return value; + } + let span = value.span(); + Expression::new_logical_expression( + span, + Expression::new_parenthesized_expression(span, value, ast), + LogicalOperator::Or, + Expression::new_string_literal(span, Str::from_in(default, ast.allocator()), None, ast), + ast, + ) +} + +pub(crate) fn capture<'a>( + ast: &AstBuilder<'a>, + element: &mut JSXElement<'a>, + next: &mut usize, +) -> Vec<(String, Expression<'a>)> { + if !needed(element) { + return vec![]; + } + let mut values = Vec::new(); + let mut read = |value: &mut Expression<'a>| { + let name = format!("__devupSpread{next}"); + *next += 1; + let reference = Expression::new_identifier( + value.span(), + Str::from_in(name.as_str(), ast.allocator()), + ast, + ); + values.push((name, std::mem::replace(value, reference))); + }; + for attribute in &mut element.opening_element.attributes { + match attribute { + JSXAttributeItem::Attribute(attribute) => { + let span = attribute.span; + let class_name = matches!(&attribute.name, JSXAttributeName::Identifier(name) if name.name == "className"); + let retained = match &attribute.name { + JSXAttributeName::Identifier(name) => { + stays_attribute(&name.name) + || matches!(name.name.as_str(), "className" | "style") + } + JSXAttributeName::NamespacedName(_) => true, + }; + if retained && let Some(value) = attribute_value_mut(attribute) { + if class_name { + continue; + } + if value.span().start == 0 { + *value.span_mut() = span; + } + read(value); + } + } + JSXAttributeItem::SpreadAttribute(_) => {} + } + } + for child in &mut element.children { + match child { + JSXChild::ExpressionContainer(container) => { + if let Some(value) = container.expression.as_expression_mut() { + read(value); + } + } + JSXChild::Spread(spread) => read(&mut spread.expression), + JSXChild::Element(element) => { + let span = element.span; + let mut value = Expression::JSXElement(element.clone_in(ast.allocator())); + read(&mut value); + *child = JSXChild::ExpressionContainer(JSXExpressionContainer::boxed( + span, + value.into(), + ast, + )); + } + JSXChild::Fragment(fragment) => { + let span = fragment.span; + let mut value = Expression::JSXFragment(fragment.clone_in(ast.allocator())); + read(&mut value); + *child = JSXChild::ExpressionContainer(JSXExpressionContainer::boxed( + span, + value.into(), + ast, + )); + } + JSXChild::Text(_) => {} + } + } + values +} diff --git a/libs/extractor/src/element_capture_tests.rs b/libs/extractor/src/element_capture_tests.rs new file mode 100644 index 000000000..4b2c668f3 --- /dev/null +++ b/libs/extractor/src/element_capture_tests.rs @@ -0,0 +1,282 @@ +use crate::assignment_blocker_support::selected; +use crate::assignment_test_support::{compiled_jsx, evaluate}; +use rstest::rstest; +use serial_test::serial; + +#[test] +#[serial] +fn nested_types_keep_authored_order_when_getters_change_results() { + // Given: the rejected exact nested shape, with changing global identifier getters. + let source = "import {Box}from '@devup-ui/react';function render(){return }let trace=[],reads=0;for(const key of ['x','y'])Object.defineProperty(globalThis,key,{get(){trace.push(key);return ++reads===1?'section':'article'}});const node=render();"; + // When: execute emitted captures and join the child's selected class to padding. + let actual = selected( + source, + "JSON.stringify([trace,reads,node.tag,node.children[0].tag,assigned(node.children[0])]);", + ); + // Then: x precedes y once each, and the selected inner type retains padding4. + assert_eq!( + actual, + "[[\"x\",\"y\"],2,\"section\",\"article\",[\"padding:4px:0\"]]" + ); +} + +#[rstest] +#[case("")] +#[case("jsx(Box,{as:state.x,children:jsx(Box,{as:state.y,p:1})})")] +#[serial] +fn nested_type_stops_before_child_when_outer_getter_throws(#[case] expression: &str) { + // Given: an outer type getter that fails before the inner type is reached. + let source = format!( + "import {{Box}}from '@devup-ui/react';import {{jsx}}from 'react/jsx-runtime';function render(state){{return {expression}}}let trace=[],error;const state={{get x(){{trace.push('x');throw Error('outer')}},get y(){{trace.push('y');return 'a'}}}};const jsx=(tag,props)=>({{...props,tag}});try{{render(state)}}catch(e){{error=e.message}}JSON.stringify([trace,error]);" + ); + // When: execute the transformed expression. + let actual = evaluate(&compiled_jsx(&source)); + // Then: a child cannot run ahead of the throwing authored outer operand. + assert_eq!(actual, "[[\"x\"],\"outer\"]"); +} + +#[rstest] +#[case("")] +#[case("jsx(Box,{as:state.x,children:jsx(Text,{as:state.y,children:jsx(Box,{as:state.z,p:1})})})")] +#[serial] +fn nested_types_keep_three_levels_when_defaults_differ(#[case] expression: &str) { + // Given: three authored type sites whose values change on every getter read. + let source = format!( + "import {{Box,Text}}from '@devup-ui/react';import {{jsx}}from 'react/jsx-runtime';function render(state){{return {expression}}}let trace=[],reads=0;const state={{}};for(const key of ['x','y','z'])Object.defineProperty(state,key,{{get(){{trace.push(key);return ++reads===3?'article':null}}}});const jsx=(tag,props)=>({{...props,tag,children:props.children?[props.children]:[]}});const node=render(state);" + ); + // When: observe all three selected types and the innermost class association. + let actual = selected( + &source, + "const middle=node.children[0],inner=middle.children[0];JSON.stringify([trace,reads,node.tag,middle.tag,inner.tag,assigned(inner)]);", + ); + // Then: fallbacks remain component-specific while authored nesting stays ordered. + assert_eq!( + actual, + "[[\"x\",\"y\",\"z\"],3,\"div\",\"span\",\"article\",[\"padding:4px:0\"]]" + ); +} + +#[rstest] +#[case( + "{state.child}", + "Box", + "div" +)] +#[case( + "{state.child}", + "Text", + "span" +)] +#[case( + "jsx(Box,{id:state.id,className:state.className,style:state.style,p:state.pad,as:state.type,...{title:state.spread.title},children:state.child})", + "Box", + "div" +)] +#[case( + "jsx(Text,{id:state.id,className:state.className,style:state.style,p:state.pad,as:state.type,...{title:state.spread.title},children:state.child})", + "Text", + "span" +)] +#[serial] +fn captured_type_keeps_sibling_order_when_styles_and_spreads_are_present( + #[case] expression: &str, + #[case] component: &str, + #[case] default: &str, +) { + // Given: type is authored after ordinary/style operands and before a spread/child. + // A written title key keeps unknown CSS-spread composition in the #756 gate. + let source = format!( + "import {{{component}}}from '@devup-ui/react';import {{jsx}}from 'react/jsx-runtime';function render(state){{return {expression}}}let trace=[];const state={{}};for(const[key,value]of Object.entries({{id:'target',className:'',style:{{opacity:'0.5'}},pad:'16px',type:false,spread:{{title:'spread'}},child:'text'}}))Object.defineProperty(state,key,{{get(){{trace.push(key);return value}}}});const jsx=(tag,props)=>({{...props,tag}});const node=render(state);" + ); + // When: observe execution and selected padding rather than generated spellings. + let actual = selected( + &source, + "JSON.stringify([trace,node.tag,node.id,node.title,node.style.opacity,assigned(node)]);", + ); + // Then: fallback adds no extra read and cannot move type before its siblings. + assert_eq!( + actual, + format!( + "[[\"id\",\"className\",\"style\",\"pad\",\"type\",\"spread\",\"child\"],\"{default}\",\"target\",\"spread\",\"0.5\",[\"padding:16px:0\"]]" + ) + ); +} + +#[rstest] +#[case("")] +#[case("jsx(Box,{id:state.id,as:state.type,children:jsx(Box,{as:state.inner,p:1})})")] +#[serial] +fn captured_type_skips_later_reads_when_ordinary_sibling_throws(#[case] expression: &str) { + // Given: an ordinary getter throws before both the outer type and the child. + let source = format!( + "import {{Box}}from '@devup-ui/react';import {{jsx}}from 'react/jsx-runtime';function render(state){{return {expression}}}let trace=[],error;const state={{get id(){{trace.push('id');throw Error('sibling')}},get type(){{trace.push('type');return 'a'}},get inner(){{trace.push('inner');return 'b'}}}};const jsx=(tag,props)=>props;try{{render(state)}}catch(e){{error=e.message}}JSON.stringify([trace,error]);" + ); + // When: execute the transformed source. + let actual = evaluate(&compiled_jsx(&source)); + // Then: source timing, not type-priority scheduling, controls the exception. + assert_eq!(actual, "[[\"id\"],\"sibling\"]"); +} + +#[rstest] +#[case("0")] +#[case("false")] +#[case("null")] +#[case("undefined")] +#[case("''")] +#[serial] +fn selected_type_defaults_when_runtime_value_is_falsy(#[case] value: &str) { + // Given: literal object/array selections and scalar types on both caller paths. + let expressions = [ + "", + "", + "", + "", + "jsx(Box,{as:state.type,id:state.id,p:1})", + "jsx(Text,{as:{a:state.type}[state.key],id:state.id,p:1})", + "jsx(Text,{as:[state.type][state.index],id:state.id,p:1})", + ]; + for expression in expressions { + let source = format!( + "import {{Box,Text}}from '@devup-ui/react';import {{jsx}}from 'react/jsx-runtime';function render(state){{return {expression}}}let reads=0;const state={{get type(){{reads++;return {value}}},id:'target',key:'a',index:0}};const jsx=(tag,props)=>({{...props,tag}});const node=render(state);" + ); + // When: run the emitted selected type with a falsy authored value. + let actual = selected(&source, "JSON.stringify([reads,node.tag,assigned(node)]);"); + // Then: all falsy values use the component's default without losing padding. + let default = if expression.contains("Text") { + "span" + } else { + "div" + }; + assert_eq!( + actual, + format!("[1,\"{default}\",[\"padding:4px:0\"]]"), + "{expression}" + ); + } +} + +#[rstest] +#[case("", "div")] +#[case("", "span")] +#[case("jsx(Box,{as:{a:'a',b:'button'}[state.key],id:state.id,p:1})", "div")] +#[case("jsx(Text,{as:['div','a'][state.index],id:state.id,p:1})", "span")] +#[serial] +fn literal_member_type_defaults_when_selected_key_is_missing( + #[case] expression: &str, + #[case] default: &str, +) { + // Given: the existing literal-member type shapes with missing object/array keys. + let source = format!( + "import {{Box,Text}}from '@devup-ui/react';import {{jsx}}from 'react/jsx-runtime';function render(state){{return {expression}}}let trace=[];const state={{get key(){{trace.push('key');return 'missing'}},get index(){{trace.push('key');return 7}},get id(){{trace.push('id');return 'target'}}}};const jsx=(tag,props)=>({{...props,tag}});const node=render(state);" + ); + // When: execute the actual member selection and selected class. + let actual = selected(&source, "JSON.stringify([trace,node.tag,assigned(node)]);"); + // Then: undefined selection defaults, retaining key-before-id timing and padding4. + assert_eq!( + actual, + format!("[[\"key\",\"id\"],\"{default}\",[\"padding:4px:0\"]]") + ); +} + +#[rstest] +#[case("state.flag?:null")] +#[case("state.flag&&")] +#[case("state.flag?jsx(Box,{as:state.x,children:jsx(Text,{as:state.y,p:1})}):null")] +#[serial] +fn nested_type_capture_stays_lazy_when_branch_is_not_selected(#[case] expression: &str) { + // Given: an unselected branch containing both type getters. + let source = format!( + "import {{Box,Text}}from '@devup-ui/react';import {{jsx}}from 'react/jsx-runtime';function render(state){{return {expression}}}let trace=[];const state={{flag:false,get x(){{trace.push('x');throw Error('unused')}},get y(){{trace.push('y');throw Error('unused')}}}};const jsx=(tag,props)=>props;render(state);JSON.stringify(trace);" + ); + // When: execute the false branch. + let actual = evaluate(&compiled_jsx(&source)); + // Then: wrapper metadata never moves captures outside their lazy source scope. + assert_eq!(actual, "[]"); +} + +#[rstest] +#[case("0")] +#[case("false")] +#[case("null")] +#[case("undefined")] +#[case("''")] +#[serial] +fn literal_member_type_defaults_when_selected_entry_is_falsy(#[case] value: &str) { + // Given: literal entries rather than runtime getters, selected by a runtime key. + let expressions = [ + format!(""), + format!(""), + format!("jsx(Box,{{as:{{a:{value}}}[state.key],id:state.id,p:1}})"), + format!("jsx(Text,{{as:[{value}][state.index],id:state.id,p:1}})"), + ]; + for expression in expressions { + let source = format!( + "import {{Box,Text}}from '@devup-ui/react';import {{jsx}}from 'react/jsx-runtime';function render(state){{return {expression}}}let trace=[];const state={{get key(){{trace.push('key');return 'a'}},get index(){{trace.push('key');return 0}},get id(){{trace.push('id');return 'target'}}}};const jsx=(tag,props)=>({{...props,tag}});const node=render(state);" + ); + // When: execute the selected literal entry and its associated class. + let actual = selected(&source, "JSON.stringify([trace,node.tag,assigned(node)]);"); + // Then: literal-member lowering preserves the same established default. + let default = if expression.contains("Text") { + "span" + } else { + "div" + }; + assert_eq!( + actual, + format!("[[\"key\",\"id\"],\"{default}\",[\"padding:4px:0\"]]") + ); + } +} + +#[rstest] +#[case("Button", "button")] +#[case("Image", "img")] +#[case("Input", "input")] +#[serial] +fn captured_type_defaults_to_component_tag_when_value_is_empty( + #[case] component: &str, + #[case] default: &str, +) { + // Given: the remaining distinct component default tags on both caller paths. + for expression in [ + format!("<{component} as={{state.type}} id={{state.id}}/>"), + format!("jsx({component},{{as:state.type,id:state.id}})"), + ] { + let source = format!( + "import {{{component}}}from '@devup-ui/react';import {{jsx}}from 'react/jsx-runtime';function render(state){{return {expression}}}let reads=0;const state={{get type(){{reads++;return ''}},id:'target'}};const jsx=(tag,props)=>({{...props,tag}});const node=render(state);JSON.stringify([reads,node.tag]);" + ); + // When: execute the captured falsy type. + let actual = evaluate(&compiled_jsx(&source)); + // Then: capture never hardcodes Box's div fallback for another component. + assert_eq!(actual, format!("[1,\"{default}\"]")); + } +} + +#[rstest] +#[case("0")] +#[case("false")] +#[case("null")] +#[case("undefined")] +#[case("''")] +#[serial] +fn jsx_runtime_type_defaults_when_authored_literal_is_falsy(#[case] value: &str) { + // Given: falsy literal types, including fixed-key literal member selections. + for (component, default) in [("Box", "div"), ("Text", "span")] { + for tag in [ + value.to_string(), + format!("({{a:{value}}})['a']"), + format!("[{value}][0]"), + ] { + let source = format!( + "import {{{component}}}from '@devup-ui/react';import {{jsx}}from 'react/jsx-runtime';function render(state){{return jsx({component},{{id:state.id,as:{tag},p:1}})}}let trace=[];const state={{get id(){{trace.push('id');return 'target'}}}};const jsx=(tag,props)=>({{...props,tag}});const node=render(state);" + ); + // When: execute the authored literal and its selected padding class. + let actual = selected(&source, "JSON.stringify([trace,node.tag,assigned(node)]);"); + // Then: CSS literal stringification cannot stand in for type defaulting. + assert_eq!( + actual, + format!("[[\"id\"],\"{default}\",[\"padding:4px:0\"]]") + ); + } + } +} diff --git a/libs/extractor/src/element_evaluation.rs b/libs/extractor/src/element_evaluation.rs new file mode 100644 index 000000000..4d5098f66 --- /dev/null +++ b/libs/extractor/src/element_evaluation.rs @@ -0,0 +1,182 @@ +use oxc_allocator::{CloneIn, FromIn, GetAllocator}; +use oxc_ast::{ + ast::{Expression, JSXAttributeItem, JSXAttributeName, JSXAttributeValue, JSXElement, Str}, + builder::AstBuilder, +}; +use oxc_span::{GetSpan, GetSpanMut, SPAN}; + +use crate::{ + ExtractStyleProp, ExtractStyleValue, + utils::{get_string_by_literal_expression, stays_attribute}, +}; + +#[path = "element_capture.rs"] +mod element_capture; +pub(crate) use element_capture::{capture, default_type}; + +#[path = "element_typography.rs"] +mod element_typography; +pub(crate) use element_typography::capture as typography; + +#[cfg(test)] +#[path = "assignment_container_tests.rs"] +mod assignment_container_tests; + +#[cfg(test)] +#[path = "assignment_mixed_container_tests.rs"] +mod assignment_mixed_container_tests; + +pub(crate) fn needed(element: &JSXElement<'_>) -> bool { + let values = element.opening_element.attributes.iter().filter(|attribute| matches!(attribute, + JSXAttributeItem::Attribute(attribute) if matches!(attribute.value, Some(JSXAttributeValue::ExpressionContainer(_))))).count(); + element.opening_element.attributes.iter().any(|attribute| matches!(attribute, JSXAttributeItem::SpreadAttribute(_))) || element.opening_element.attributes.iter().any(|attribute| matches!(attribute, + JSXAttributeItem::Attribute(attribute) + if matches!(&attribute.name, JSXAttributeName::Identifier(name) if !stays_attribute(&name.name) || name.name == "className") + && matches!(&attribute.value, Some(JSXAttributeValue::ExpressionContainer(container)) + if container.expression.as_expression().is_some_and(|value| get_string_by_literal_expression(value).is_none())))) + && (values > 1 || !element.children.is_empty()) +} + +pub(crate) fn raw_typography<'a>(ast: &AstBuilder<'a>, source: &Expression<'a>) -> Expression<'a> { + use oxc_ast::ast::{TemplateElement, TemplateElementValue}; + let span = source.span(); + let value = Expression::new_identifier(span, "__devupTypography", ast); + let class = Expression::new_conditional_expression( + span, + value.clone_in(ast.allocator()), + Expression::new_template_literal( + span, + oxc_allocator::Vec::from_array_in( + [ + TemplateElement::new( + span, + TemplateElementValue { + raw: Str::from("typo-"), + cooked: None, + }, + false, + ast, + ), + TemplateElement::new( + span, + TemplateElementValue { + raw: Str::from(""), + cooked: None, + }, + true, + ast, + ), + ], + ast, + ), + oxc_allocator::Vec::from_array_in([value], ast), + ast, + ), + Expression::new_string_literal(span, "", None, ast), + ast, + ); + let mut result = crate::utils::call_with_values( + ast, + vec![( + "__devupTypography".to_string(), + source.clone_in(ast.allocator()), + )], + class, + ); + *result.span_mut() = span; + result +} + +pub(crate) fn snapshot<'a>(ast: &AstBuilder<'a>, value: &Expression<'a>) -> Expression<'a> { + let body = Expression::new_object_expression( + value.span(), + oxc_allocator::Vec::from_array_in( + [oxc_ast::ast::ObjectPropertyKind::new_spread_property( + value.span(), + Expression::new_identifier(SPAN, "__devupSpreadValue", ast), + ast, + )], + ast, + ), + ast, + ); + let mut result = crate::utils::call_with_values( + ast, + vec![( + "__devupSpreadValue".to_string(), + value.clone_in(ast.allocator()), + )], + body, + ); + *result.span_mut() = value.span(); + result +} + +pub(crate) fn scalar<'a>( + ast: &AstBuilder<'a>, + styles: &mut [ExtractStyleProp<'a>], + source: &Expression<'a>, + next: &mut usize, +) -> Option<(String, Expression<'a>)> { + if !styles.is_empty() + && matches!( + crate::utils::unwrap_syntax_only(source), + Expression::ObjectExpression(_) | Expression::ArrayExpression(_) + ) + { + let mut consumers = crate::assignment_owner::take_consumers(source.span(), styles); + crate::assignment_consumers::merge(&mut consumers); + if !consumers.is_empty() { + let remaining = + std::mem::replace(&mut styles[0], ExtractStyleProp::StaticArray(vec![])); + styles[0] = ExtractStyleProp::StaticArray(vec![ + remaining, + ExtractStyleProp::Evaluated { + binding: crate::sparse_sites::binding_name(source.span().start), + styles: consumers, + source: source.clone_in(ast.allocator()), + evaluation: None, + alternate_order: None, + alternate_class: false, + }, + ]); + } + return None; + } + if !styles.iter().all(|style| { + matches!( + style, + ExtractStyleProp::Static(ExtractStyleValue::Dynamic(_)) + ) + }) || styles.is_empty() + { + return None; + } + let name = format!("__devupSpread{next}"); + *next += 1; + let input = styles + .iter() + .find_map(|style| match style { + ExtractStyleProp::Static(ExtractStyleValue::Dynamic(style)) + if style.important() + || matches!( + crate::utils::unwrap_syntax_only(source), + Expression::TemplateLiteral(_) + ) => + { + Some(Expression::new_identifier( + source.span(), + Str::from_in(style.identifier(), ast.allocator()), + ast, + )) + } + _ => None, + }) + .unwrap_or_else(|| source.clone_in(ast.allocator())); + for style in styles { + if let ExtractStyleProp::Static(ExtractStyleValue::Dynamic(style)) = style { + style.replace_identifier(&name); + } + } + Some((name, input)) +} diff --git a/libs/extractor/src/element_typography.rs b/libs/extractor/src/element_typography.rs new file mode 100644 index 000000000..8c50d8d4e --- /dev/null +++ b/libs/extractor/src/element_typography.rs @@ -0,0 +1,88 @@ +use oxc_allocator::{CloneIn, FromIn, GetAllocator}; +use oxc_ast::{ + ast::{Expression, Str}, + builder::AstBuilder, +}; +use oxc_span::{GetSpan, GetSpanMut}; + +use crate::{ExtractStyleProp, utils::get_string_by_literal_expression}; + +#[path = "typography_scalar_projection.rs"] +mod scalar_projection; + +pub(crate) fn capture<'a>( + ast: &AstBuilder<'a>, + styles: &mut Vec>, + source: &Expression<'a>, + next: &mut usize, +) -> Option<(String, Expression<'a>)> { + let structured = !styles.is_empty() + && matches!( + crate::utils::unwrap_syntax_only(source), + Expression::ArrayExpression(_) + ) + && !crate::static_assignment::literal_source(source) + || styles.iter().any(|style| { + matches!( + style, + ExtractStyleProp::Conditional { .. } | ExtractStyleProp::MemberExpression { .. } + ) + }); + let scalar = if structured && styles.iter().all(scalar_projection::base_only) { + crate::gen_class_name::gen_class_names(ast, styles, None, None) + .filter(|class| scalar_projection::preserves_reads(source, class)) + } else { + None + }; + if structured && scalar.is_none() { + *styles = vec![ExtractStyleProp::Evaluated { + binding: crate::sparse_sites::binding_name(source.span().start), + styles: std::mem::take(styles), + source: source.clone_in(ast.allocator()), + evaluation: None, + alternate_order: None, + alternate_class: false, + }]; + return None; + } + if let [ExtractStyleProp::Enum { condition, .. }] = styles.as_mut_slice() { + let name = format!("__devupSpread{next}"); + *next += 1; + let input = std::mem::replace( + condition, + Expression::new_identifier( + source.span(), + Str::from_in(name.as_str(), ast.allocator()), + ast, + ), + ); + return Some((name, input)); + } + let mut class = + scalar.or_else(|| crate::gen_class_name::gen_class_names(ast, styles, None, None))?; + if matches!(class, Expression::StringLiteral(_)) + && get_string_by_literal_expression(source).is_none() + { + class = crate::utils::call_with_values( + ast, + vec![( + "__devupTypography".to_string(), + source.clone_in(ast.allocator()), + )], + class, + ); + } + *class.span_mut() = source.span(); + let name = format!("__devupSpread{next}"); + *next += 1; + let values = styles.iter().flat_map(ExtractStyleProp::extract).collect(); + *styles = vec![ExtractStyleProp::Expression { + styles: values, + expression: Expression::new_identifier( + source.span(), + Str::from_in(name.as_str(), ast.allocator()), + ast, + ), + }]; + Some((name, class)) +} diff --git a/libs/extractor/src/evaluation_location.rs b/libs/extractor/src/evaluation_location.rs new file mode 100644 index 000000000..b8e1084a2 --- /dev/null +++ b/libs/extractor/src/evaluation_location.rs @@ -0,0 +1,208 @@ +use std::path::Path; + +use boa_engine::{Context, vm::SourcePath}; +use css::style_origin::{RealLocation, StyleOrigin}; +use oxc_allocator::Allocator; +use oxc_ast::ast::{CallExpression, Program}; +use oxc_ast_visit::{Visit, walk}; +use oxc_codegen::Codegen; +use oxc_parser::Parser; +use oxc_semantic::SemanticBuilder; +use oxc_span::{SourceType, Span}; +use oxc_transformer::{TransformOptions, Transformer}; + +use crate::import_alias_visit::{Edit, source_offset}; + +#[cfg(test)] +#[path = "evaluation_lookup_coverage_tests.rs"] +mod coverage_tests; + +#[derive(Clone)] +pub(crate) struct StrippedSource { + pub code: String, + calls: Vec<(Span, Span)>, +} + +impl StrippedSource { + pub(crate) fn new(code: &str, filename: &str) -> Self { + let allocator = Allocator::default(); + let source_type = SourceType::from_path(filename).unwrap_or_else(|_| SourceType::ts()); + let mut program = Parser::new(&allocator, code, source_type).parse().program; + let authored = calls(&program); + let scoping = SemanticBuilder::new() + .with_enum_eval(true) + .build(&program) + .semantic + .into_scoping(); + let options = TransformOptions::default(); + let _ = Transformer::new(&allocator, Path::new("input.css.ts"), &options) + .build_with_scoping(scoping, &mut program); + let transformed = calls(&program); + let generated = Codegen::new().build(&program).code; + let parsed = Parser::new(&allocator, &generated, SourceType::mjs()).parse(); + let printed = calls(&parsed.program); + let mapped = if printed.len() == transformed.len() { + printed + .into_iter() + .zip(transformed) + .filter(|(_, original)| authored.contains(original)) + .collect() + } else { + Vec::new() + }; + Self { + code: generated, + calls: mapped, + } + } + + pub(crate) fn authored_calls( + &self, + source: (&str, &str), + edits: &[Edit], + ) -> Vec<(Span, StyleOrigin)> { + let allocator = Allocator::default(); + let source_type = SourceType::from_path(source.0).unwrap_or_else(|_| SourceType::ts()); + let program = Parser::new(&allocator, source.1, source_type) + .parse() + .program; + let authored = calls(&program); + self.calls + .iter() + .filter_map(|(generated, original)| { + let start = source_offset(edits, usize::try_from(original.start).ok()?); + let end = source_offset(edits, usize::try_from(original.end).ok()?); + let span = Span::new(u32::try_from(start).ok()?, u32::try_from(end).ok()?); + if !authored.contains(&span) { + return None; + } + crate::style_origin::evaluated_at(source.0, source.1, span) + .map(|origin| (*generated, origin)) + }) + .collect() + } +} + +fn calls(program: &Program<'_>) -> Vec { + struct Calls(Vec); + impl<'a> Visit<'a> for Calls { + fn visit_call_expression(&mut self, call: &CallExpression<'a>) { + self.0.push(call.span); + walk::walk_call_expression(self, call); + } + } + let mut visitor = Calls(Vec::new()); + visitor.visit_program(program); + visitor.0 +} + +#[derive(Clone, Debug)] +pub(crate) struct CopiedSource { + pub generated: std::ops::Range, + pub original: usize, +} + +pub(crate) fn append_mapped( + body: &mut String, + copies: &mut Vec, + text: (String, Vec), +) { + let start = body.len(); + body.push_str(&text.0); + copies.extend(text.1.into_iter().map(|mut copy| { + copy.generated.start += start; + copy.generated.end += start; + copy + })); +} + +pub(crate) struct EvaluationLookup { + pub file: String, + pub run: String, + pub prefix: usize, + pub copies: Vec, + pub calls: Vec<(Span, StyleOrigin)>, +} + +impl EvaluationLookup { + pub(crate) fn produced_by(&self, context: &Context) -> Option { + let frames: Vec<_> = context + .stack_trace() + .map(boa_engine::vm::CallFrame::position) + .collect(); + if frames + .iter() + .any(|frame| matches!(frame.path, SourcePath::Eval)) + { + return None; + } + for frame in frames.into_iter().rev() { + let SourcePath::Path(path) = frame.path else { + continue; + }; + if path.as_ref() != Path::new(&self.file) { + continue; + } + let Some(offset) = copied_body_offset( + &self.run, + frame + .position + .map(|position| (position.line_number(), position.column_number())), + self.prefix, + ) else { + continue; + }; + let Some(copy) = self + .copies + .iter() + .find(|copy| copy.generated.contains(&offset)) + else { + continue; + }; + let original = copy.original + offset - copy.generated.start; + let Some((_, origin)) = self + .calls + .iter() + .filter(|(span, _)| { + usize::try_from(span.start).is_ok_and(|start| start <= original) + && usize::try_from(span.end).is_ok_and(|end| original < end) + }) + .max_by_key(|(span, _)| span.size()) + else { + continue; + }; + return Some(RealLocation::ProducedByCall(origin.clone())); + } + None + } +} + +fn copied_body_offset( + source: &str, + coordinate: Option<(u32, u32)>, + prefix: usize, +) -> Option { + let (line, column) = coordinate?; + let row = usize::try_from(line.checked_sub(1)?).ok()?; + let mut start = 0; + let text = source + .split_inclusive('\n') + .enumerate() + .find_map(|(index, text)| { + if index == row { + Some(text) + } else { + start += text.len(); + None + } + })?; + let mut units = column.checked_sub(1)?; + for character in text.chars() { + if units == 0 { + return start.checked_sub(prefix); + } + units = units.checked_sub(u32::try_from(character.len_utf16()).ok()?)?; + start += character.len_utf8(); + } + (units == 0).then_some(start)?.checked_sub(prefix) +} diff --git a/libs/extractor/src/evaluation_location_tests.rs b/libs/extractor/src/evaluation_location_tests.rs new file mode 100644 index 000000000..99a30ff53 --- /dev/null +++ b/libs/extractor/src/evaluation_location_tests.rs @@ -0,0 +1,134 @@ +use css::style_origin::RealLocation; + +use crate::{ExtractOption, vanilla_extract::execute_stylesheet}; + +#[test] +fn opaque_calls_use_authored_outermost_producers_when_boa_returns_parent_positions() { + // Given + let cases = [ + ( + "const identity=f=>f;export const cls=identity(style)({color:'red'});", + "identity(style)({color:'red'})", + ), + ( + "const bound=style.bind(null);export const cls=bound({color:'red'});", + "bound({color:'red'})", + ), + ( + "export const cls=style.call(null,{color:'red'});", + "style.call(null,{color:'red'})", + ), + ( + "export const cls=style.apply(null,[{color:'red'}]);", + "style.apply(null,[{color:'red'}])", + ), + ( + "export const cls=[{color:'red'}].map(style);", + "[{color:'red'}].map(style)", + ), + ( + "function run(cb,f){return cb(f);}export const cls=run(function callback(f){return f({color:'red'});},style);", + "run(function callback(f){return f({color:'red'});},style)", + ), + ( + "const identity=x=>x;const native=identity(style);export const cls=identity(native({color:'red'}));", + "identity(native({color:'red'}))", + ), + ( + "function pair(a,b){return [a,b];}const opaque=f=>f;export const cls=pair(opaque(style)({color:'red'}),opaque(style)({color:'blue'}));", + "pair(opaque(style)({color:'red'}),opaque(style)({color:'blue'}))", + ), + ]; + // When / Then + for (body, expected) in cases { + let source = format!("import {{style}} from '@devup-ui/react';\n{body}"); + let (collected, _) = execute_stylesheet( + &source, + "call-proof.css.ts", + &ExtractOption::default(), + None, + ) + .unwrap_or_else(|error| panic!("{error}")); + assert_ne!(collected.styles.len(), 0); + for entry in collected.styles.values() { + assert_eq!(entry.origin, None, "{body}"); + assert!( + matches!(&entry.location, Some(RealLocation::ProducedByCall(origin)) + if origin.file == "call-proof.css.ts" && origin.expression == expected), + "{body}: {entry:?}" + ); + } + } +} + +#[test] +fn nested_opaque_native_entry_clears_enclosing_factory_witness_when_no_packet_exists() { + // Given + let source = "import {style,styleVariants} from '@devup-ui/react';const identity=f=>f;export const classes=styleVariants({one:1},()=>identity(style)({color:'red'}));"; + // When + let (collected, _) = + execute_stylesheet(source, "nested.css.ts", &ExtractOption::default(), None) + .unwrap_or_else(|error| panic!("{error}")); + // Then + let inner = collected + .styles + .values() + .find(|entry| entry.json.contains("red")) + .unwrap_or_else(|| panic!("missing inner style")); + assert_eq!(inner.origin, None); + assert!( + matches!(&inner.location, Some(RealLocation::ProducedByCall(origin)) if origin.expression.starts_with("styleVariants(")), + "{inner:?}" + ); + assert!(collected.styles.values().any(|entry| { + entry + .origin + .as_ref() + .is_some_and(|origin| origin.expression.starts_with("styleVariants(")) + })); +} + +#[test] +fn eval_uses_the_actual_nested_export_binding_when_source_path_has_no_file_linkage() { + // Given + let source = "import {style} from '@devup-ui/react';const native=style;const result={nested:[eval(\"native({color:'red'})\"),eval(\"native({color:'blue'})\")],get untouched(){throw new Error('metadata invoked a getter');}};result.self=result;export {result as actualExport};"; + // When + let (collected, _) = execute_stylesheet(source, "eval.css.ts", &ExtractOption::default(), None) + .unwrap_or_else(|error| panic!("{error}")); + // Then + assert_eq!(collected.styles.len(), 2); + for entry in collected.styles.values() { + assert_eq!(entry.origin, None); + assert_eq!( + entry.location, + Some(RealLocation::ModuleExport { + file: "eval.css.ts".into(), + binding: Some("actualExport".into()) + }) + ); + } +} + +#[test] +fn eval_default_export_uses_default_binding_when_generated_local_name_is_not_authored() { + // Given + let source = "import {style} from '@devup-ui/react';const native=style;export default eval(\"native({color:'red'})\");"; + // When + let (collected, _) = + execute_stylesheet(source, "default.css.ts", &ExtractOption::default(), None) + .unwrap_or_else(|error| panic!("{error}")); + // Then + assert_eq!(collected.styles.len(), 1); + assert_eq!( + collected + .styles + .values() + .next() + .unwrap_or_else(|| panic!("missing style")) + .location, + Some(RealLocation::ModuleExport { + file: "default.css.ts".into(), + binding: Some("default".into()) + }) + ); +} diff --git a/libs/extractor/src/evaluation_lookup_coverage_tests.rs b/libs/extractor/src/evaluation_lookup_coverage_tests.rs new file mode 100644 index 000000000..5fb6961af --- /dev/null +++ b/libs/extractor/src/evaluation_lookup_coverage_tests.rs @@ -0,0 +1,164 @@ +use super::{CopiedSource, EvaluationLookup, StrippedSource, copied_body_offset}; +use boa_engine::{Context, JsValue, NativeFunction, Source, js_string}; +use css::style_origin::{RealLocation, StyleOrigin}; +use rstest::rstest; +use std::{cell::RefCell, path::Path}; + +thread_local! { + static LOOKUP: RefCell> = const { RefCell::new(None) }; +} + +fn observed_value(context: &Context) -> JsValue { + let location = LOOKUP.with_borrow(|lookup| { + lookup + .as_ref() + .unwrap_or_else(|| panic!("lookup fixture")) + .produced_by(context) + }); + JsValue::from(js_string!( + serde_json::to_string(&location).unwrap_or_else(|error| panic!("location JSON: {error}")) + )) +} + +struct LookupScope; +impl Drop for LookupScope { + fn drop(&mut self) { + LOOKUP.with_borrow_mut(|lookup| *lookup = None); + } +} + +#[rstest] +#[case("no-path")] +#[case("other-file")] +#[case("prefix")] +#[case("no-copy")] +#[case("no-call")] +#[case("matched")] +fn producer_lookup_requires_a_complete_authored_file_chain(#[case] mode: &str) { + // Given + let source = "function run(){return observed()}run();"; + let origin = StyleOrigin { + file: "authored.css.ts".into(), + line: 3, + column: 7, + expression: "opaque(style)({color:'red'})".into(), + }; + let copies = if mode == "no-copy" { + vec![] + } else { + vec![CopiedSource { + generated: 0..source.len(), + original: 0, + }] + }; + let calls = if mode == "no-call" { + vec![] + } else { + vec![( + oxc_span::Span::new( + 0, + u32::try_from(source.len()).unwrap_or_else(|error| panic!("fixture span: {error}")), + ), + origin.clone(), + )] + }; + LOOKUP.with_borrow_mut(|lookup| { + *lookup = Some(EvaluationLookup { + file: "run.js".into(), + run: source.into(), + prefix: if mode == "prefix" { + source.len() + 1 + } else { + 0 + }, + copies, + calls, + }); + }); + let _scope = LookupScope; + let mut context = Context::default(); + context + .register_global_builtin_callable( + js_string!("observed"), + 0, + NativeFunction::from_fn_ptr(|_, _, context| Ok(observed_value(context))), + ) + .unwrap_or_else(|error| panic!("native observer: {error}")); + let input = Source::from_bytes(source); + let input = match mode { + "no-path" => input, + "other-file" => input.with_path(Path::new("other.js")), + _ => input.with_path(Path::new("run.js")), + }; + // When + let actual = context + .eval(input) + .unwrap_or_else(|error| panic!("lookup execution: {error}")) + .as_string() + .unwrap_or_else(|| panic!("serialized location")) + .to_std_string_escaped(); + // Then: incomplete chains must never invent an Exact or ProducedByCall witness. + let expected = if mode == "matched" { + Some(RealLocation::ProducedByCall(origin)) + } else { + None + }; + assert_eq!( + serde_json::from_str::>(&actual) + .unwrap_or_else(|error| panic!("location result: {error}")), + expected + ); +} + +#[test] +fn stripped_call_mapping_rejects_non_call_authored_coordinates() { + // Given + let stripped = StrippedSource::new("factory();", "actual.css.ts"); + // When + let actual = stripped.authored_calls(("actual.css.ts", "notACall;"), &[]); + // Then + assert_eq!(actual.len(), 0); +} + +#[rstest] +#[case(("a", 1, 2), Some(1))] +#[case(("a", 1, 3), None)] +#[case(("😀", 1, 3), Some(4))] +#[case(("😀", 1, 2), None)] +#[case(("a\nb", 2, 2), Some(3))] +fn end_columns_map_utf16_units_to_exact_byte_offsets( + #[case] coordinate: (&str, u32, u32), + #[case] expected: Option, +) { + // Given / When + let actual = copied_body_offset(coordinate.0, Some((coordinate.1, coordinate.2)), 0); + // Then + assert_eq!(actual, expected); +} + +#[rstest] +#[case(("a", None, 0), None)] +#[case(("a", Some((1, 1)), 0), Some(0))] +#[case(("a", Some((1, 1)), 1), None)] +#[case(("a", Some((1, 2)), 1), Some(0))] +#[case(("a", Some((1, 2)), 2), None)] +#[case(("a", Some((1, 3)), 0), None)] +#[case(("a", Some((0, 1)), 0), None)] +#[case(("a", Some((1, 0)), 0), None)] +#[case(("a", Some((2, 1)), 0), None)] +#[case(("😀", Some((1, 2)), 0), None)] +#[case(("😀", Some((1, 3)), 0), Some(4))] +#[case(("a\n😀b", Some((2, 1)), 2), Some(0))] +#[case(("a\n😀b", Some((2, 3)), 2), Some(4))] +#[case(("a\n😀b", Some((2, 4)), 2), Some(5))] +#[case(("", Some((1, 1)), 0), None)] +#[case(("a\n", Some((2, 1)), 0), None)] +fn hard_copied_body_coordinates_preserve_absence_and_prefix_boundaries( + #[case] input: (&str, Option<(u32, u32)>, usize), + #[case] expected: Option, +) { + // Given / When + let actual = copied_body_offset(input.0, input.1, input.2); + // Then + assert_eq!(actual, expected); +} diff --git a/libs/extractor/src/evaluation_origin.rs b/libs/extractor/src/evaluation_origin.rs new file mode 100644 index 000000000..90b5a5c4d --- /dev/null +++ b/libs/extractor/src/evaluation_origin.rs @@ -0,0 +1,267 @@ +use css::style_origin::{RealLocation, StyleOrigin}; +use oxc_allocator::Allocator; +use oxc_ast::ast::{ + CallExpression, Expression, ImportDeclarationSpecifier, Statement, VariableDeclarator, +}; +use oxc_ast_visit::{Visit, walk}; +use oxc_parser::Parser; +use oxc_semantic::{Scoping, SemanticBuilder}; +use oxc_span::{GetSpan, SourceType}; +use oxc_syntax::symbol::SymbolId; +use std::collections::{BTreeMap, BTreeSet}; + +pub(crate) const PACKET: &str = "__devup_source_v1"; + +pub(crate) fn json(origin: &StyleOrigin) -> String { + format!( + "{{\"file\":{},\"line\":{},\"col\":{},\"originalExpression\":{}}}", + crate::vanilla_extract::json_string(&origin.file), + origin.line, + origin.column, + crate::vanilla_extract::json_string(&origin.expression) + ) +} + +pub(crate) fn mark_location(code: String, location: Option<&RealLocation>) -> String { + match location { + None => code, + Some(location) => { + let serialized = match location { + RealLocation::Exact(origin) => format!("{{\"Exact\":{}}}", json(origin)), + RealLocation::ProducedByCall(origin) => { + format!("{{\"ProducedByCall\":{}}}", json(origin)) + } + RealLocation::ModuleExport { file, binding } => format!( + "{{\"ModuleExport\":{{\"file\":{},\"binding\":{}}}}}", + crate::vanilla_extract::json_string(file), + binding + .as_deref() + .map_or_else(|| "null".into(), crate::vanilla_extract::json_string) + ), + }; + format!( + "{}({}, {code})", + crate::style_origin::MARKER, + crate::vanilla_extract::json_string(&serialized) + ) + } + } +} + +pub(crate) fn instrument(code: &str, filename: &str, option: &crate::ExtractOption) -> String { + instrument_with_edits(code, filename, option).0 +} + +pub(crate) fn instrument_with_edits( + code: &str, + filename: &str, + option: &crate::ExtractOption, +) -> (String, Vec) { + let allocator = Allocator::default(); + let source_type = SourceType::from_path(filename).unwrap_or_else(|_| SourceType::ts()); + let program = Parser::new(&allocator, code, source_type).parse().program; + let scoping = SemanticBuilder::new() + .build(&program) + .semantic + .into_scoping(); + let mut factories = BTreeSet::new(); + let mut namespaces = BTreeSet::new(); + for statement in &program.body { + if let Statement::ImportDeclaration(import) = statement + && (import.source.value.as_str() == option.package.as_str() + || option + .import_aliases + .contains_key(import.source.value.as_str())) + && let Some(specifiers) = &import.specifiers + { + for specifier in specifiers { + match specifier { + ImportDeclarationSpecifier::ImportSpecifier(import) => { + let name = import.imported.name(); + if factory_name(name.as_str()) + && let Some(symbol) = import.local.symbol_id.get() + { + factories.insert(symbol); + } + } + ImportDeclarationSpecifier::ImportNamespaceSpecifier(import) => { + if let Some(symbol) = import.local.symbol_id.get() { + namespaces.insert(symbol); + } + } + ImportDeclarationSpecifier::ImportDefaultSpecifier(_) => {} + } + } + } + } + loop { + let before = factories.len(); + Aliases { + scoping: &scoping, + factories: &mut factories, + namespaces: &namespaces, + } + .visit_program(&program); + if factories.len() == before { + break; + } + } + let mut calls = Calls { + file: filename, + source: code, + scoping: &scoping, + factories: &factories, + namespaces: &namespaces, + insertions: BTreeMap::new(), + }; + calls.visit_program(&program); + let mut instrumented = code.to_string(); + let edits = calls + .insertions + .iter() + .map(|(position, text)| (*position, *position, text.len())) + .collect(); + for (position, text) in calls.insertions.into_iter().rev() { + instrumented.insert_str(position, &text); + } + (instrumented, edits) +} + +fn factory_name(name: &str) -> bool { + matches!(name, "style" | "styleVariants" | "keyframes") +} + +fn symbol(expression: &Expression<'_>, scoping: &Scoping) -> Option { + match crate::utils::unwrap_syntax_only(expression) { + Expression::Identifier(name) => name + .reference_id + .get() + .and_then(|reference| scoping.get_reference(reference).symbol_id()), + _ => None, + } +} + +struct ApiBindings<'a> { + scoping: &'a Scoping, + factories: &'a BTreeSet, + namespaces: &'a BTreeSet, +} +impl ApiBindings<'_> { + fn factory(&self, expression: &Expression<'_>) -> bool { + match crate::utils::unwrap_syntax_only(expression) { + Expression::Identifier(_) => symbol(expression, self.scoping) + .is_some_and(|symbol| self.factories.contains(&symbol)), + Expression::StaticMemberExpression(member) => { + factory_name(member.property.name.as_str()) + && symbol(&member.object, self.scoping) + .is_some_and(|symbol| self.namespaces.contains(&symbol)) + } + Expression::ComputedMemberExpression(member) => { + crate::utils::get_string_by_literal_expression(&member.expression) + .is_some_and(|name| factory_name(&name)) + && symbol(&member.object, self.scoping) + .is_some_and(|symbol| self.namespaces.contains(&symbol)) + } + _ => false, + } + } +} + +struct Aliases<'a> { + scoping: &'a Scoping, + factories: &'a mut BTreeSet, + namespaces: &'a BTreeSet, +} +impl<'a> Visit<'a> for Aliases<'_> { + fn visit_variable_declarator(&mut self, declaration: &VariableDeclarator<'a>) { + if let Some(symbol) = declaration + .id + .get_binding_identifier() + .and_then(|binding| binding.symbol_id.get()) + && self.scoping.symbol_flags(symbol).is_const_variable() + && let Some(init) = &declaration.init + && (ApiBindings { + scoping: self.scoping, + factories: self.factories, + namespaces: self.namespaces, + }) + .factory(init) + { + self.factories.insert(symbol); + } + walk::walk_variable_declarator(self, declaration); + } +} + +struct Calls<'a> { + file: &'a str, + source: &'a str, + scoping: &'a Scoping, + factories: &'a BTreeSet, + namespaces: &'a BTreeSet, + insertions: BTreeMap, +} +impl<'a> Visit<'a> for Calls<'_> { + fn visit_call_expression(&mut self, call: &CallExpression<'a>) { + if (ApiBindings { + scoping: self.scoping, + factories: self.factories, + namespaces: self.namespaces, + }) + .factory(&call.callee) + && let Some(origin) = + crate::style_origin::evaluated_at(self.file, self.source, call.span) + && let Ok(end) = usize::try_from(call.span.end) + { + let mut packet = if call.arguments.is_empty() { + String::from("undefined,undefined,") + } else { + String::from(",undefined,undefined,") + }; + packet.push_str(&crate::vanilla_extract::json_string(PACKET)); + packet.push(','); + packet.push_str(&crate::vanilla_extract::json_string(&json(&origin))); + let position = call.arguments.last().map_or(end - 1, |argument| { + usize::try_from(argument.span().end).unwrap_or(end - 1) + }); + self.insertions + .entry(position) + .or_default() + .push_str(&packet); + } + walk::walk_call_expression(self, call); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn source_packets_preserve_call_evaluation_parentheses_and_trailing_comments() { + // Given + let source = "import {style} from '@devup-ui/react';const alias=style;alias(({color:eval('value')}),/* , */);"; + // When + let instrumented = instrument(source, "actual.css.ts", &crate::ExtractOption::default()); + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, &instrumented, SourceType::ts()).parse(); + // Then + assert_eq!(parsed.diagnostics.len(), 0, "{instrumented}"); + assert!(!instrumented.contains("=>"), "{instrumented}"); + assert!( + instrumented.contains("alias(({color:eval('value')}),undefined,undefined,"), + "{instrumented}" + ); + assert!(instrumented.contains(PACKET), "{instrumented}"); + } + + #[test] + fn a_shadowed_style_function_does_not_receive_compiler_arguments() { + // Given + let source = "import {style} from '@devup-ui/react';function run(style){return style({color:'red'});}const custom=(...args)=>args.length;run(custom);"; + // When + let instrumented = instrument(source, "shadow.css.ts", &crate::ExtractOption::default()); + // Then + assert_eq!(instrumented, source); + } +} diff --git a/libs/extractor/src/extract_style/counter_identity_tests.rs b/libs/extractor/src/extract_style/counter_identity_tests.rs new file mode 100644 index 000000000..bebc8ad46 --- /dev/null +++ b/libs/extractor/src/extract_style/counter_identity_tests.rs @@ -0,0 +1,231 @@ +use css::{Naming, Site, sparse_site::SourceFile, style_selector::StyleSelector}; +use rstest::rstest; + +use super::counter_selector::CounterSelector; +use super::{ + ProducerPolicy, + extract_dynamic_style::ExtractDynamicStyle, + extract_keyframes::ExtractKeyframes, + extract_static_style::ExtractStaticStyle, + policy_test_support::{assert_laws, set_lengths, trace}, +}; +use crate::sparse_sites::SiteScope; + +fn counter_records(original: u32) -> (ExtractStaticStyle, ExtractDynamicStyle, ExtractKeyframes) { + let _scope = SiteScope::enter_counter_numbered(original, "tone", &[]); + ( + ExtractStaticStyle::new_basic("color", "red", 0, None), + ExtractDynamicStyle::new("color", 0, "tone", None), + ExtractKeyframes::default(), + ) +} + +#[rstest] +#[case(Naming::Own)] +#[case(Naming::Risky)] +fn private_static_originals_survive_unions_when_provenance_changes(#[case] naming: Naming) { + // Given: equivalent authored declarations constructed under two original owners. + let (mut first, _, _) = counter_records(4); + let (mut second, _, _) = counter_records(9); + first.style_order = Some(255); + second.style_order = Some(255); + first.naming = naming; + second.naming = naming; + let values = [first.clone(), second.clone(), first.clone()]; + // When: both union implementations discard equal records. + let lengths = set_lengths(&values); + // Then: the duplicate dedups but neither private original is lost. + assert_eq!(lengths, (2, 2)); + assert_eq!(first.producer_policy(), ProducerPolicy::CounterOriginal(4)); + assert_eq!(second.producer_policy(), ProducerPolicy::CounterOriginal(9)); + assert_laws(&values); +} + +#[test] +fn shared_static_equivalence_retains_raw_originals_when_order_is_zero() { + // Given: basic declarations retain distinct original captures before union. + let (first, _, _) = counter_records(4); + let (second, _, _) = counter_records(9); + // When: order0 declarations are compared and unioned. + let lengths = set_lengths(&[first.clone(), second.clone()]); + // Then: intentional shared equivalence does not rewrite the stored capture. + assert_eq!(lengths, (1, 1)); + assert_eq!(trace(&first), trace(&second)); + assert_eq!(first.producer_policy(), ProducerPolicy::CounterOriginal(4)); + assert_eq!(second.producer_policy(), ProducerPolicy::CounterOriginal(9)); +} + +#[test] +fn private_dynamic_and_empty_frames_survive_unions_when_sites_and_steps_are_absent() { + // Given: no-site consumers and empty keyframes cannot infer owners from children/sites. + let (_, first, first_frames) = counter_records(4); + let (_, second, second_frames) = counter_records(9); + // When: each kind is unioned after all construction scopes have ended. + let dynamic_lengths = set_lengths(&[first.clone(), second.clone(), first.clone()]); + let frame_lengths = set_lengths(&[ + first_frames.clone(), + second_frames.clone(), + first_frames.clone(), + ]); + // Then: both private original owners survive for each record kind. + assert_eq!(dynamic_lengths, (2, 2)); + assert_eq!(frame_lengths, (2, 2)); + assert_laws(&[first, second]); + assert_laws(&[first_frames, second_frames]); +} + +#[test] +fn dynamic_sites_remain_distinct_when_order_zero_ignores_allocation_owner() { + // Given: shared-order consumers still carry different authored variable sites. + let (_, mut first, _) = counter_records(4); + let (_, mut second, _) = counter_records(9); + first.style_order = Some(0); + second.style_order = Some(0); + first.site = Some(Site { + file: SourceFile::D9(4), + at: 2, + role: 0, + }); + second.site = Some(Site { + file: SourceFile::D9(9), + at: 2, + role: 0, + }); + let mut alternate_role = first.clone(); + alternate_role + .site + .as_mut() + .unwrap_or_else(|| panic!("fixture site")) + .role = 1; + // When: shared declarations with distinct variable consumers are unioned. + let values = [first, second, alternate_role]; + let lengths = set_lengths(&values); + // Then: site file and syntax role still prevent incompatible consumer merging. + assert_eq!(lengths, (3, 3)); + assert_laws(&values); +} + +#[test] +fn provenance_is_identity_neutral_when_counter_original_is_retained() { + // Given: static and no-site dynamic copies belong to the same original. + let (mut style, dynamic, _) = counter_records(4); + style.style_order = Some(1); + let mut risky_dynamic = dynamic.clone(); + risky_dynamic.naming = Naming::Risky; + // When: the copies acquire Risky provenance without construction changes. + let risky_style = style.clone().with_naming(Naming::Risky); + // Then: counter equality and hash traces do not reintroduce eligibility. + assert_eq!(set_lengths(&[style.clone(), risky_style.clone()]), (1, 1)); + assert_eq!(trace(&style), trace(&risky_style)); + assert_eq!( + set_lengths(&[dynamic.clone(), risky_dynamic.clone()]), + (1, 1) + ); + assert_eq!(trace(&dynamic), trace(&risky_dynamic)); +} + +#[test] +fn no_site_dynamic_records_share_identity_when_order_is_zero() { + // Given: distinct original captures without authored variable sites. + let (_, mut first, _) = counter_records(4); + let (_, mut second, _) = counter_records(9); + first.style_order = Some(0); + second.style_order = Some(0); + // When: intentional shared allocation-owner equivalence is projected. + let lengths = set_lengths(&[first.clone(), second.clone()]); + // Then: sharing never erases either retained original capture. + assert_eq!(lengths, (1, 1)); + assert_eq!(trace(&first), trace(&second)); + assert_eq!(first.producer_policy(), ProducerPolicy::CounterOriginal(4)); + assert_eq!(second.producer_policy(), ProducerPolicy::CounterOriginal(9)); +} + +#[test] +fn debug_output_stays_unchanged_when_records_carry_counter_policy() { + // Given: Current and counter records have otherwise identical visible fields. + let (style, dynamic, frames) = counter_records(4); + let current = ExtractStaticStyle::new_basic("color", "red", 0, None); + let current_dynamic = ExtractDynamicStyle::new("color", 0, "tone", None); + let current_frames = ExtractKeyframes::default(); + // When: the existing custom Debug implementations format both domains. + let actual = [ + format!("{style:?}"), + format!("{dynamic:?}"), + format!("{frames:?}"), + ]; + // Then: adding policy metadata changes none of the existing debug/snapshot text. + assert_eq!( + actual, + [ + format!("{current:?}"), + format!("{current_dynamic:?}"), + format!("{current_frames:?}") + ] + ); +} + +#[test] +fn mixed_domains_obey_total_order_when_shared_and_private_records_coexist() { + // Given: Current plus shared/private counter records, including duplicate originals. + let (shared, dynamic, frames) = counter_records(4); + let (other_shared, other_dynamic, other_frames) = counter_records(9); + let mut private = shared.clone(); + private.style_order = Some(1); + let mut private_other = other_shared.clone(); + private_other.style_order = Some(255); + let current = ExtractStaticStyle::new_basic("color", "red", 0, None); + let current_dynamic = ExtractDynamicStyle::new("color", 0, "tone", None); + let current_frames = ExtractKeyframes::default(); + // When: all pairs, triples and both set insertion orders are checked. + assert_laws(&[ + current.clone(), + shared.clone(), + other_shared, + private, + private_other, + ]); + assert_laws(&[current_dynamic.clone(), dynamic.clone(), other_dynamic]); + assert_laws(&[current_frames.clone(), frames.clone(), other_frames]); + // Then: even order0 equivalence never crosses the Current/counter domain boundary. + assert!(current < shared); + assert!(current_dynamic < dynamic); + assert!(current_frames < frames); +} + +#[test] +fn counter_selector_order_matches_equality_when_global_cleanup_files_differ() { + // Given: global records are identical except for cleanup-file semantics. + let mut statics = vec![]; + let mut dynamics = vec![]; + let mut keyframes = vec![]; + for selector in [ + None, + Some(StyleSelector::from("&:hover")), + Some(StyleSelector::from("print")), + Some(StyleSelector::Global("body".into(), "a.tsx".into())), + Some(StyleSelector::Global("body".into(), "b.tsx".into())), + ] { + let _scope = SiteScope::enter_counter_numbered(7, "tone", &[]); + let style = ExtractStaticStyle::new("color", "red", 0, selector.clone()); + let mut frames = ExtractKeyframes::default(); + frames.keyframes.insert("from".into(), vec![style.clone()]); + keyframes.push(frames); + statics.push(style); + dynamics.push(ExtractDynamicStyle::new("color", 0, "tone", selector)); + } + // When: all selector kinds, including the global filename tie, are compared and unioned. + assert_laws(&statics); + assert_laws(&dynamics); + assert_laws(&keyframes); + for left in &statics { + for right in &statics { + let left = CounterSelector(&left.selector); + let right = CounterSelector(&right.selector); + assert_eq!(left.partial_cmp(&right), Some(left.cmp(&right))); + } + } + // Then: cleanup-file distinctions survive ordered sets just as they survive hash sets. + assert_eq!(set_lengths(&statics), (5, 5)); + assert_eq!(set_lengths(&dynamics), (5, 5)); + assert_eq!(set_lengths(&keyframes), (5, 5)); +} diff --git a/libs/extractor/src/extract_style/counter_keyframe_input.rs b/libs/extractor/src/extract_style/counter_keyframe_input.rs new file mode 100644 index 000000000..1fb212a02 --- /dev/null +++ b/libs/extractor/src/extract_style/counter_keyframe_input.rs @@ -0,0 +1,56 @@ +use std::{ + collections::BTreeMap, + hash::{DefaultHasher, Hash, Hasher}, +}; + +use css::allocation_input::NameMode; + +use super::{ExtractKeyframes, extract_static_style::ExtractStaticStyle}; + +pub(super) struct LegacyMember<'a>(pub(super) &'a ExtractStaticStyle); + +impl Hash for LegacyMember<'_> { + fn hash(&self, state: &mut H) { + let style = self.0; + style.property.hash(state); + style.value.hash(state); + style.level.hash(state); + style.selector.hash(state); + style.style_order.hash(state); + style.layer.hash(state); + style.theme_token_resolution.hash(state); + } +} + +pub(super) fn legacy_map(frames: &ExtractKeyframes) -> BTreeMap<&String, Vec>> { + frames + .keyframes + .iter() + .map(|(step, styles)| (step, styles.iter().map(LegacyMember).collect())) + .collect() +} + +pub(super) fn input(frames: &ExtractKeyframes, mode: NameMode) -> String { + match mode { + NameMode::Counter | NameMode::Debug => { + let mut hasher = DefaultHasher::new(); + legacy_map(frames).hash(&mut hasher); + hasher.finish().to_string() + } + NameMode::AtomHoist => { + let mut content = String::new(); + for (step, styles) in &frames.keyframes { + content.push_str(&css::atom_name::hex(step)); + content.push('{'); + for style in styles { + content.push_str(&css::atom_name::hex(style.property())); + content.push(':'); + content.push_str(&css::atom_name::hex(style.value())); + content.push(';'); + } + content.push('}'); + } + content + } + } +} diff --git a/libs/extractor/src/extract_style/counter_keyframe_test_reference.rs b/libs/extractor/src/extract_style/counter_keyframe_test_reference.rs new file mode 100644 index 000000000..253436c42 --- /dev/null +++ b/libs/extractor/src/extract_style/counter_keyframe_test_reference.rs @@ -0,0 +1,68 @@ +use crate::extract_style::{ + ExtractKeyframes, + extract_static_style::{ExtractStaticStyle, ThemeTokenResolution}, +}; +use css::style_selector::{AtRule, AtRuleKind, StyleSelector}; +use std::{collections::BTreeMap, hash::Hash}; + +#[derive(Hash)] +enum ReferenceSelector<'a> { + At { + kind: AtRuleKind, + query: &'a str, + selector: &'a Option, + outer: &'a Vec, + file: &'a Option, + }, + Selector(&'a str), + Global(&'a str, &'a str), +} + +#[derive(Hash)] +pub(super) struct ReferenceMember<'a> { + property: &'a str, + value: &'a str, + level: u8, + selector: Option>, + style_order: Option, + layer: &'a Option, + resolution: ThemeTokenResolution, +} + +pub(super) fn reference(style: &ExtractStaticStyle) -> ReferenceMember<'_> { + ReferenceMember { + property: &style.property, + value: &style.value, + level: style.level, + selector: style.selector.as_ref().map(|selector| match selector { + StyleSelector::At { + kind, + query, + selector, + outer, + file, + } => ReferenceSelector::At { + kind: *kind, + query, + selector, + outer, + file, + }, + StyleSelector::Selector(text) => ReferenceSelector::Selector(text), + StyleSelector::Global(text, file) => ReferenceSelector::Global(text, file), + }), + style_order: style.style_order, + layer: &style.layer, + resolution: style.theme_token_resolution, + } +} + +pub(super) fn reference_map( + frames: &ExtractKeyframes, +) -> BTreeMap>> { + frames + .keyframes + .iter() + .map(|(step, styles)| (step.clone(), styles.iter().map(reference).collect())) + .collect() +} diff --git a/libs/extractor/src/extract_style/counter_producer.rs b/libs/extractor/src/extract_style/counter_producer.rs new file mode 100644 index 000000000..ad264a1d1 --- /dev/null +++ b/libs/extractor/src/extract_style/counter_producer.rs @@ -0,0 +1,201 @@ +use css::allocation_input::{ + AllocationContext, LegacyDeclaration, LegacyInput, LegacyVariable, NameMode, capture_context, +}; +use css::counter_names::{AllocatedName, allocate_name}; +use css::optimize_multi_css_value::{check_multi_css_optimize, optimize_multi_css_value}; +use css::{CounterOwner, Site, sparse_site::SourceFile}; + +use super::counter_producer_selector::class_selector; +use super::{ + ExtractDynamicStyle, ExtractKeyframes, ProducerPolicy, extract_static_style::ExtractStaticStyle, +}; + +/// Actual allocation receipt; original authority remains present even for shared names. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ProducedAllocation { + pub original: u32, + pub input: LegacyInput, + pub context: AllocationContext, + pub allocation: AllocatedName, +} + +/// Class-to-variable association, with separate no-site variable allocation evidence. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ProducedDynamic { + pub class: ProducedAllocation, + pub variable: String, + pub variable_allocation: Option, + pub site: Option, + pub identifier: String, + pub important: bool, +} + +/// Invalid dormant producer authority, checked before any counter reservation. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CounterProducerError { + /// The parent or a keyframe member was constructed under Current policy. + WrongPolicy, + /// The assignment site has no numeric original authority. + UnnumberedSite, +} + +impl std::fmt::Display for CounterProducerError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::WrongPolicy => { + f.write_str("counter producer requires retained CounterOriginal policy") + } + Self::UnnumberedSite => { + f.write_str("counter producer requires a numbered assignment site") + } + } + } +} + +impl std::error::Error for CounterProducerError {} + +const fn retained_original(policy: ProducerPolicy) -> Result { + match policy { + ProducerPolicy::Current => Err(CounterProducerError::WrongPolicy), + ProducerPolicy::CounterOriginal(original) => Ok(original), + } +} + +fn produce(original: u32, input: LegacyInput, context: AllocationContext) -> ProducedAllocation { + let allocation = allocate_name(&input, &context); + ProducedAllocation { + original, + input, + context, + allocation, + } +} + +pub(super) fn produce_static( + style: &ExtractStaticStyle, + filename: Option<&str>, +) -> Result { + let original = retained_original(style.producer_policy())?; + let value = style.resolved_value(); + let value = if style.property() != "content" && check_multi_css_optimize(style.property()) { + optimize_multi_css_value(&value).into_owned() + } else { + value.into_owned() + }; + let mut declaration = LegacyDeclaration { + property: style.property().to_string(), + level: style.level(), + value: Some(value), + selector: None, + order: style.style_order(), + }; + let context = capture_context( + &LegacyInput::Declaration(declaration.clone()), + filename, + CounterOwner::D9(original), + ); + declaration.selector = class_selector(style.selector(), style.layer(), context.config.mode); + Ok(produce( + original, + LegacyInput::Declaration(declaration), + context, + )) +} + +pub(super) fn produce_keyframes( + frames: &ExtractKeyframes, + filename: Option<&str>, +) -> Result { + let original = retained_original(frames.producer_policy())?; + for style in frames.keyframes.values().flatten() { + retained_original(style.producer_policy())?; + } + let context = capture_context( + &LegacyInput::Keyframes(String::new()), + filename, + CounterOwner::D9(original), + ); + let input = LegacyInput::Keyframes(super::counter_keyframe_input::input( + frames, + context.config.mode, + )); + Ok(produce(original, input, context)) +} + +pub(super) fn produce_dynamic( + style: &ExtractDynamicStyle, + filename: Option<&str>, +) -> Result { + let original = retained_original(style.producer_policy())?; + if let Some(site) = style.site() { + match &site.file { + SourceFile::D9(_) => {} + SourceFile::Unnumbered(_) => return Err(CounterProducerError::UnnumberedSite), + } + } + let mut declaration = LegacyDeclaration { + property: style.property().to_string(), + level: style.level(), + value: style.important().then(|| "!important".to_string()), + selector: None, + order: style.style_order(), + }; + let context = capture_context( + &LegacyInput::Declaration(declaration.clone()), + filename, + CounterOwner::D9(original), + ); + declaration.selector = class_selector(style.selector(), style.layer(), context.config.mode); + let variable_input = LegacyInput::Variable(LegacyVariable { + property: style.property().to_string(), + level: style.level(), + selector: declaration.selector.clone(), + }); + let variable_context = AllocationContext { + config: context.config.clone(), + file: None, + delivery: None, + }; + let (class, variable, variable_allocation) = match style.site() { + Some(site) => { + let variable = site.variable_name(&context.config.prefix); + declaration.value = Some(format!( + "var({variable}){}", + if style.important() { " !important" } else { "" } + )); + ( + produce(original, LegacyInput::Declaration(declaration), context), + variable, + None, + ) + } + None => match context.config.mode { + NameMode::Counter | NameMode::Debug => { + let class = produce(original, LegacyInput::Declaration(declaration), context); + let allocated = produce(original, variable_input, variable_context); + (class, allocated.allocation.name.clone(), Some(allocated)) + } + NameMode::AtomHoist => { + let allocated = produce(original, variable_input, variable_context); + let variable = allocated.allocation.name.clone(); + declaration.value = Some(format!( + "var({variable}){}", + if style.important() { " !important" } else { "" } + )); + ( + produce(original, LegacyInput::Declaration(declaration), context), + variable, + Some(allocated), + ) + } + }, + }; + Ok(ProducedDynamic { + class, + variable, + variable_allocation, + site: style.site().cloned(), + identifier: style.identifier().to_string(), + important: style.important(), + }) +} diff --git a/libs/extractor/src/extract_style/counter_producer_keyframe_tests.rs b/libs/extractor/src/extract_style/counter_producer_keyframe_tests.rs new file mode 100644 index 000000000..9ee657549 --- /dev/null +++ b/libs/extractor/src/extract_style/counter_producer_keyframe_tests.rs @@ -0,0 +1,258 @@ +use super::counter_keyframe_input::{LegacyMember, legacy_map}; +use super::counter_producer_test_support::{address, cleanup, produced, reset, scope}; +use super::extract_static_style::{ExtractStaticStyle, ThemeTokenResolution}; +use super::policy_test_support::trace; +use super::{CounterProducerError, ExtractKeyframes, ProducerPolicy}; +use css::{ + CounterOwner, Naming, + allocation_input::{LegacyInput, NameMode}, + class_map::get_class_map, + style_selector::{AtRule, AtRuleKind, StyleSelector}, +}; +use rstest::rstest; +use serial_test::serial; +use std::{ + collections::BTreeMap, + hash::{DefaultHasher, Hash, Hasher}, +}; + +#[path = "counter_keyframe_test_reference.rs"] +mod reference; +use reference::{ReferenceMember, reference, reference_map}; + +#[derive(Clone, Copy)] +enum Field { + Property, + Value, + Level, + Selector, + Order, + Layer, + Resolution, +} + +#[rstest] +#[case(Field::Property)] +#[case(Field::Value)] +#[case(Field::Level)] +#[case(Field::Selector)] +#[case(Field::Order)] +#[case(Field::Layer)] +#[case(Field::Resolution)] +fn seven_field_trace_matches_baseline_when_each_legacy_field_changes(#[case] field: Field) { + // Given: a raw member with nonempty fields and a separate derived baseline reference. + let mut style = ExtractStaticStyle::new("color", "$tone", 1, Some("hover".into())); + style.layer = Some("ui".into()); + let before = trace(&LegacyMember(&style)); + // When: precisely one legacy field changes. + match field { + Field::Property => style.property = "background".into(), + Field::Value => style.value = "red".into(), + Field::Level => style.level = 2, + Field::Selector => { + style.selector = Some(StyleSelector::Global("&:hover".into(), "raw".into())); + } + Field::Order => style.style_order = Some(255), + Field::Layer => style.layer = Some("other".into()), + Field::Resolution => style.theme_token_resolution = ThemeTokenResolution::FirstValue, + } + // Then: each field affects the exact seven-call trace, not a tuple or new IR hash. + assert_ne!(trace(&LegacyMember(&style)), before); + assert_eq!(trace(&LegacyMember(&style)), trace(&reference(&style))); +} + +#[rstest] +#[case(None)] +#[case(Some(StyleSelector::Selector(" &:hover ".into())))] +#[case(Some(StyleSelector::Global("&:hover".into(), "raw-owner".into())))] +#[case(Some(StyleSelector::At { kind: AtRuleKind::Container, query: "(width>1px)".into(), selector: Some("&".into()), outer: vec![AtRule { kind: AtRuleKind::Supports, query: "(display:grid)".into() }], file: Some("raw-owner".into()) }))] +fn selector_hash_trace_matches_baseline_when_all_selector_domains_are_used( + #[case] selector: Option, +) { + // Given: full raw selector fields including outer rules and cleanup filenames. + let style = ExtractStaticStyle::new_basic("color", "red", 0, selector); + // When: the borrowed legacy member hashes its seven fields. + let actual = trace(&LegacyMember(&style)); + // Then: an independently derived enum reproduces discriminants and field framing. + assert_eq!(actual, trace(&reference(&style))); +} + +#[test] +#[serial] +fn container_hash_matches_baseline_when_steps_have_duplicates_and_empty_vectors() { + // Given: ordered map keys, empty vectors, duplicate members and raw None orders. + reset(NameMode::Counter); + let _scope = scope("a"); + let style = ExtractStaticStyle::new("color", "red", 0, None); + let frames = ExtractKeyframes { + keyframes: BTreeMap::from([ + ("to".into(), vec![style.clone(), style]), + ("from".into(), vec![]), + ]), + ..ExtractKeyframes::default() + }; + let before = get_class_map(); + let reference = reference_map(&frames); + let mut hasher = DefaultHasher::new(); + reference.hash(&mut hasher); + // When: pure map hashing and the single actual request are evaluated. + assert_eq!(trace(&legacy_map(&frames)), trace(&reference)); + assert_eq!(get_class_map(), before); + let receipt = produced(frames.counter_produce(Some("delivery"))); + // Then: standard map/vector framing yields one decimal keyframe request only. + assert_eq!( + receipt.input, + LegacyInput::Keyframes(hasher.finish().to_string()) + ); + address(&receipt, "D9-0", &format!("k-{}", hasher.finish()), 0); + assert_eq!( + get_class_map() + .values() + .map(std::collections::HashMap::len) + .sum::(), + 1 + ); + cleanup(); +} + +#[test] +fn legacy_trace_excludes_metadata_when_new_authority_and_naming_change() { + // Given: a raw member's seven-field trace. + let mut style = ExtractStaticStyle::new("color", "$tone", 0, None); + let before = trace(&LegacyMember(&style)); + // When: every new metadata dimension changes without touching legacy fields. + style.naming = Naming::Risky; + style.counter_owner = CounterOwner::D9(99); + style.producer_policy = ProducerPolicy::CounterOriginal(98); + style.origin = css::style_origin::Origin( + Some(Box::new(css::style_origin::StyleOrigin { + file: "raw".into(), + line: 1, + column: 2, + expression: "tone".into(), + })), + Some(Box::new(css::style_origin::RealLocation::ModuleExport { + file: "bucket".into(), + binding: Some("tone".into()), + })), + ); + // Then: the internal legacy map hash cannot inherit policy or content identity. + assert_eq!(trace(&LegacyMember(&style)), before); +} + +#[rstest] +#[case(NameMode::Counter)] +#[case(NameMode::Debug)] +#[case(NameMode::AtomHoist)] +#[serial] +fn current_child_rejects_before_request_when_parent_has_counter_authority(#[case] mode: NameMode) { + // Given: a counter parent with a manually mixed Current child. + reset(mode); + let current = ExtractStaticStyle::new("color", "red", 0, None); + let _scope = scope("a"); + let mut frames = ExtractKeyframes::default(); + frames.keyframes.insert("to".into(), vec![current]); + let before = get_class_map(); + // When: the new boundary inspects all children before capture/allocation. + let error = frames.counter_produce(None).err(); + // Then: wrong child policy cannot reserve even shared/debug/atom names. + assert_eq!(error, Some(CounterProducerError::WrongPolicy)); + assert_eq!(get_class_map(), before); + cleanup(); +} + +#[test] +#[serial] +fn debug_keyframes_use_decimal_legacy_hash_when_no_counter_is_reserved() { + // Given: raw themed fields whose effective lookup can later change. + reset(NameMode::Debug); + let _scope = scope("a"); + let mut frames = ExtractKeyframes::default(); + frames.keyframes.insert( + "to".into(), + vec![ + ExtractStaticStyle::new("width", "$space", 0, None) + .with_theme_token_resolution(ThemeTokenResolution::FirstValue), + ], + ); + let before = trace(&legacy_map(&frames)); + css::theme_tokens::set_theme_token_values( + BTreeMap::from([("space".into(), "24px".into())]), + BTreeMap::new(), + ); + let mut hasher = DefaultHasher::new(); + reference_map(&frames).hash(&mut hasher); + // When: debug production projects the raw legacy map, not resolved members. + let receipt = produced(frames.counter_produce(Some("delivery"))); + // Then: debug remains decimal k- and effective lookup cannot alter its hash trace. + assert_eq!( + receipt.input, + LegacyInput::Keyframes(hasher.finish().to_string()) + ); + assert_eq!(receipt.allocation.name, format!("pk-{}", hasher.finish())); + assert_eq!(trace(&legacy_map(&frames)), before); + assert_eq!(get_class_map().len(), 0); + cleanup(); +} + +#[test] +#[serial] +fn atom_input_is_old_hex_content_when_keyframes_delivery_is_hoisted() { + // Given: a hoisted declaration bucket and a counter child from a different original. + reset(NameMode::AtomHoist); + css::debug::set_debug(true); + css::atom_hoist::restore_atom_plan(Some(std::collections::BTreeSet::from(["root".into()]))); + let mut frames = { + let _scope = scope("a"); + ExtractKeyframes::default() + }; + let child = { + let _scope = scope("b"); + ExtractStaticStyle::new("color", "red", 0, None) + }; + frames.keyframes.insert("to".into(), vec![child]); + // When: the one keyframe name is produced from the old step/field string. + let receipt = produced(frames.counter_produce(Some("root"))); + // Then: atom k1 stays local (never h) and receives hex content, not a decimal digest. + assert_eq!(receipt.original, 0); + assert_eq!( + receipt.input, + LegacyInput::Keyframes("746f{636f6c6f72:726564;}".into()) + ); + assert_eq!( + receipt.allocation.name, + format!( + "pk1-l-726f6f74-{}", + css::atom_name::hex("746f{636f6c6f72:726564;}") + ) + ); + assert_eq!(get_class_map().len(), 0); + cleanup(); +} + +#[test] +#[serial] +fn empty_keyframes_retain_original_when_deferred_beyond_scope_exit() { + // Given: two empty frame parents cannot infer authority from any children. + reset(NameMode::Counter); + let a = { + let _scope = scope("a"); + ExtractKeyframes::default() + }; + let b = { + let _scope = scope("b"); + ExtractKeyframes::default() + }; + let mut reference = DefaultHasher::new(); + BTreeMap::>>::new().hash(&mut reference); + // When: deferred records request private keyframe names. + let receipts = [a, b].map(|frames| produced(frames.counter_produce(Some("delivery")))); + // Then: empty map hash is unchanged while original namespaces remain distinct. + assert_eq!( + receipts[0].input, + LegacyInput::Keyframes(reference.finish().to_string()) + ); + assert_eq!(receipts[0].allocation.name, "pa-a"); + assert_eq!(receipts[1].allocation.name, "pb-a"); + cleanup(); +} diff --git a/libs/extractor/src/extract_style/counter_producer_order_tests.rs b/libs/extractor/src/extract_style/counter_producer_order_tests.rs new file mode 100644 index 000000000..8a2281def --- /dev/null +++ b/libs/extractor/src/extract_style/counter_producer_order_tests.rs @@ -0,0 +1,173 @@ +use super::counter_producer_test_support::{ + address, cleanup, produced, reset, scope, static_style, +}; +use super::{ExtractDynamicStyle, ExtractKeyframes}; +use css::{ + allocation_input::NameMode, + class_map::{Attempt, get_class_map}, + counter_names::{allocation_key, render_name}, +}; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case(None, "pa", "--pc", "pd")] +#[case(Some("delivery"), "pa-a", "--pa", "pa-c")] +#[serial] +fn shared_stream_interleaves_when_static_dynamic_and_keyframes_request_names( + #[case] filename: Option<&str>, + #[case] first: &str, + #[case] variable: &str, + #[case] frame: &str, +) { + // Given: one captured original with no-site dynamic and empty frames. + reset(NameMode::Counter); + let _scope = scope("original"); + let static_record = static_style("original"); + let dynamic = ExtractDynamicStyle::new("width", 0, "tone", None); + let frames = ExtractKeyframes::default(); + // When: established traversal requests static, dynamic, then keyframes. + let s = produced(static_record.counter_produce(filename)); + let d = produced(dynamic.counter_produce(filename)); + let k = produced(frames.counter_produce(filename)); + // Then: CLASS precedes VARIABLE; keyframes compete in the declaration stream. + assert_eq!(s.allocation.name, first); + assert_eq!( + d.class.allocation.name, + if filename.is_some() { "pa-b" } else { "pb" } + ); + assert_eq!(d.variable, variable); + assert_eq!(k.allocation.name, frame); + address( + &d.class, + if filename.is_some() { "D9-0" } else { "" }, + if filename.is_some() { + "width-0---255-a" + } else { + "width-0---255" + }, + 1, + ); + let v = d + .variable_allocation + .as_ref() + .unwrap_or_else(|| panic!("variable receipt")); + address(v, "", "width-0-", if filename.is_some() { 0 } else { 2 }); + let before = get_class_map(); + assert_eq!(produced(dynamic.counter_produce(filename)), d); + assert_eq!(produced(frames.counter_produce(filename)), k); + assert_eq!(get_class_map(), before); + cleanup(); +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn caller_rollback_owns_both_requests_when_inner_attempt_commits(#[case] private: bool) { + // Given: a preseeded static slot must survive an abandoned caller attempt. + reset(NameMode::Counter); + let _scope = scope("original"); + let filename = private.then_some("delivery"); + let seed = produced(static_style("original").counter_produce(filename)); + let dynamic = ExtractDynamicStyle::new("width", 0, "tone", None); + // When: an inner committed adapter use is followed by outer abandonment. + let abandoned = { + let _outer = Attempt::begin(); + assert_eq!( + produced(static_style("original").counter_produce(filename)), + seed + ); + let inner = Attempt::begin(); + let receipt = produced(dynamic.counter_produce(filename)); + inner.commit(); + receipt + }; + assert_eq!( + get_class_map() + .values() + .map(std::collections::HashMap::len) + .sum::(), + 1 + ); + let retried = produced(dynamic.counter_produce(filename)); + // Then: both new requests retry at their old slots, while preseed reuse survives. + assert_eq!(retried, abandoned); + assert_eq!( + produced(static_style("original").counter_produce(filename)), + seed + ); + assert_eq!( + get_class_map() + .values() + .map(std::collections::HashMap::len) + .sum::(), + 3 + ); + cleanup(); +} + +#[test] +#[serial] +fn reused_dynamic_survives_when_later_attempt_is_abandoned() { + // Given: both requests already exist before a caller begins. + reset(NameMode::Counter); + let _scope = scope("original"); + let dynamic = ExtractDynamicStyle::new("color", 0, "tone", None); + let seed = produced(dynamic.counter_produce(Some("delivery"))); + let before = get_class_map(); + // When: the caller reuses both reservations and drops its attempt. + { + let _attempt = Attempt::begin(); + assert_eq!(produced(dynamic.counter_produce(Some("delivery"))), seed); + } + // Then: reused class and variable evidence remain valid. + assert_eq!(get_class_map(), before); + cleanup(); +} + +#[test] +#[serial] +fn ad_boundary_is_preserved_when_original_and_slot_both_need_splices() { + // Given: original 30 and thirty independent declarations preseed the private stream. + reset(NameMode::Counter); + css::file_map::set_original_ids((0..31).map(|id| (format!("f{id:02}"), id)).collect()); + let _scope = scope("f30"); + for number in 0..30 { + let style = super::extract_static_style::ExtractStaticStyle::new( + "--x", + &number.to_string(), + 0, + None, + ); + produced(style.counter_produce(Some("delivery"))); + } + // When: slot 30 is actually allocated. + let receipt = produced(static_style("f30").counter_produce(Some("delivery"))); + // Then: both file and slot use the baseline ad-blocker splice. + assert_eq!(receipt.allocation.name, "pa-d-a-d"); + address(&receipt, "D9-30", "color-0-red--255-a-d", 30); + cleanup(); +} + +#[test] +#[serial] +fn pure_receipt_projections_leave_maps_unchanged_when_live_configuration_changes() { + // Given: one real allocation captured with prefix p and private placement. + reset(NameMode::Counter); + let style = static_style("original"); + let receipt = produced(style.counter_produce(Some("delivery"))); + let before = get_class_map(); + css::set_prefix(Some("changed".into())); + css::debug::set_debug(true); + css::atom_hoist::set_atom_hoist(Some(2)); + // When: captured proof operations and cloned equality are evaluated. + let key = allocation_key(&receipt.input, &receipt.context); + let name = render_name(&receipt.input, &receipt.context, 0); + assert_eq!(receipt, receipt.clone()); + // Then: pure projection uses frozen authority and performs no new reservation. + assert_eq!(key, Some(("D9-0".into(), "color-0-red--255-a".into()))); + assert_eq!(name, "pa-a"); + assert_eq!(get_class_map(), before); + cleanup(); +} diff --git a/libs/extractor/src/extract_style/counter_producer_selector.rs b/libs/extractor/src/extract_style/counter_producer_selector.rs new file mode 100644 index 000000000..3fbf059c2 --- /dev/null +++ b/libs/extractor/src/extract_style/counter_producer_selector.rs @@ -0,0 +1,22 @@ +use css::{allocation_input::NameMode, style_selector::StyleSelector}; + +/// Project the baseline selector/layer key using only the already captured mode. +pub(super) fn class_selector( + selector: Option<&StyleSelector>, + layer: Option<&str>, + mode: NameMode, +) -> Option { + match mode { + NameMode::AtomHoist => Some(css::atom_name::selector_key(selector, layer)), + NameMode::Counter | NameMode::Debug => { + let selector = selector.map(|selector| selector.as_class_str().into_owned()); + match layer { + Some(layer) => Some(format!( + "{}@layer {layer}", + selector.as_deref().unwrap_or_default() + )), + None => selector, + } + } + } +} diff --git a/libs/extractor/src/extract_style/counter_producer_site_tests.rs b/libs/extractor/src/extract_style/counter_producer_site_tests.rs new file mode 100644 index 000000000..eedb10724 --- /dev/null +++ b/libs/extractor/src/extract_style/counter_producer_site_tests.rs @@ -0,0 +1,251 @@ +use super::counter_producer_test_support::{cleanup, produced, reset, scope}; +use super::{CounterProducerError, ExtractDynamicStyle}; +use css::{ + Site, + allocation_input::{LegacyInput, NameMode}, + class_map::get_class_map, + sparse_site::SourceFile, +}; +use rstest::rstest; +use serial_test::serial; +use std::{collections::BTreeSet, sync::Arc}; + +#[rstest] +#[case(NameMode::Counter)] +#[case(NameMode::Debug)] +#[case(NameMode::AtomHoist)] +#[serial] +fn numeric_sites_refine_class_when_files_roles_and_scopes_differ(#[case] mode: NameMode) { + // Given: two originals and multiple syntax roles with identical controllers/property. + reset(mode); + let first = { + let _scope = scope("a"); + ExtractDynamicStyle::new("color", 0, "tone !important", None).at(0) + }; + let role = { + let _scope = scope("a"); + ExtractDynamicStyle::new("color", 0, "tone !important", None).at_role(0, 1) + }; + let second = { + let _scope = scope("b"); + ExtractDynamicStyle::new("color", 0, "tone !important", None).at(0) + }; + let before = get_class_map(); + // When: deferred records produce shared classes under a later unrelated scope. + let _other = scope("later"); + let receipts = [first, role, second].map(|mut style| { + style.style_order = Some(0); + produced(style.counter_produce(Some("delivery"))) + }); + // Then: sites reserve no variable slot and each actual var consumer differs. + assert_eq!( + receipts + .iter() + .map(|r| r.variable.as_str()) + .collect::>(), + vec!["---pSa-a", "---pSa-a-b", "---pSb-a"] + ); + assert_eq!( + receipts + .iter() + .map(|r| r.class.allocation.name.clone()) + .collect::>() + .len(), + 3 + ); + for receipt in receipts { + assert_eq!(receipt.variable_allocation, None); + assert_eq!(receipt.identifier, "tone"); + assert!(receipt.important); + let LegacyInput::Declaration(input) = receipt.class.input else { + panic!("class") + }; + assert_eq!( + input.value, + Some(format!("var({}) !important", receipt.variable)) + ); + } + match mode { + NameMode::Counter => assert_eq!( + get_class_map().get("").map(std::collections::HashMap::len), + Some(3) + ), + NameMode::Debug | NameMode::AtomHoist => assert_eq!(get_class_map(), before), + } + cleanup(); +} + +#[test] +#[serial] +fn folded_owner_keeps_normalized_numeric_site_when_alias_edits_are_reversed() { + // Given: numbered raw source contains BOM/CRLF and an inserted alias prelude. + reset(NameMode::Counter); + let original = + css::file_map::get_or_insert_original_id("a").unwrap_or_else(|error| panic!("{error}")); + let edits = [(0, 0, 10)]; + let style = { + let _scope = crate::sparse_sites::SiteScope::enter_counter_numbered( + original, + "\u{feff}a\r\nbcd", + &[&edits], + ); + crate::sparse_sites::retain_folded_owner(17, 16); + ExtractDynamicStyle::new("color", 0, "tone", None).at_role(17, 2) + }; + // When: a deferred record produces its assignment variable outside the scope. + let receipt = produced(style.counter_produce(None)); + // Then: raw owner offset 6 becomes normalized offset 2, retaining syntax role 2. + assert_eq!( + receipt.site, + Some(Site { + file: SourceFile::D9(0), + at: 2, + role: 2 + }) + ); + assert_eq!(receipt.variable, "---pSa-c-c"); + cleanup(); +} + +#[rstest] +#[case(NameMode::Counter)] +#[case(NameMode::Debug)] +#[case(NameMode::AtomHoist)] +#[serial] +fn unnumbered_site_rejects_before_render_when_parent_is_counter(#[case] mode: NameMode) { + // Given: a valid retained parent but a manually unnumbered assignment site. + reset(mode); + let _scope = scope("a"); + let mut style = ExtractDynamicStyle::new("color", 0, "tone", None); + style.site = Some(Site { + file: SourceFile::Unnumbered(Arc::from("source")), + at: 0, + role: 0, + }); + style.style_order = Some(0); + let before = get_class_map(); + // When: the dormant boundary attempts production, including no filename. + let error = style.counter_produce(None).err(); + // Then: no hash fallback or counter request is possible. + assert_eq!(error, Some(CounterProducerError::UnnumberedSite)); + assert_eq!(get_class_map(), before); + cleanup(); +} + +#[test] +#[serial] +fn assignment_original_is_independent_when_site_is_attached_under_other_scope() { + // Given: parent original a and captured assignment original b. + reset(NameMode::Counter); + let mut style = { + let _scope = scope("a"); + ExtractDynamicStyle::new("color", 0, "tone", None) + }; + { + let _scope = scope("b"); + style = style.at(1); + } + // When: deferred production uses private class ownership. + let receipt = produced(style.counter_produce(Some("delivery"))); + // Then: the parent and numeric assignment each retain their own authority. + assert_eq!(receipt.class.original, 0); + assert_eq!(receipt.class.allocation.name, "pa-a"); + assert_eq!(receipt.variable, "---pSb-b"); + cleanup(); +} + +#[rstest] +#[case(NameMode::Counter, false)] +#[case(NameMode::Counter, true)] +#[case(NameMode::Debug, false)] +#[case(NameMode::Debug, true)] +#[case(NameMode::AtomHoist, false)] +#[case(NameMode::AtomHoist, true)] +#[serial] +fn no_site_class_uses_legacy_value_when_mode_and_importance_choose_branch( + #[case] mode: NameMode, + #[case] important: bool, +) { + // Given: a no-site dynamic with an explicit construction original. + reset(mode); + let _scope = scope("a"); + let style = ExtractDynamicStyle::new( + "color", + 1, + if important { "tone !important" } else { "tone" }, + None, + ); + // When: the class and separate variable envelope are produced. + let receipt = produced(style.counter_produce(None)); + // Then: only atom mode uses the full variable consumer in the class key. + let LegacyInput::Declaration(input) = receipt.class.input else { + panic!("class") + }; + let expected = match mode { + NameMode::Counter | NameMode::Debug => important.then(|| "!important".into()), + NameMode::AtomHoist => Some(format!( + "var({}){}", + receipt.variable, + if important { " !important" } else { "" } + )), + }; + assert_eq!(input.value, expected); + let variable = receipt + .variable_allocation + .unwrap_or_else(|| panic!("variable")); + assert_eq!(variable.original, 0); + assert_eq!(variable.context.file, None); + assert_eq!(variable.allocation.name, receipt.variable); + cleanup(); +} + +#[test] +#[serial] +fn atom_precedes_debug_when_delivery_is_hoisted() { + // Given: both modes enabled, a hoisted declaration bucket and explicit prefix. + reset(NameMode::AtomHoist); + css::debug::set_debug(true); + css::atom_hoist::restore_atom_plan(Some(BTreeSet::from(["root".into()]))); + let _scope = scope("a"); + let style = ExtractDynamicStyle::new("color", 0, "tone", Some("hover".into())); + // When: no-site variable and class render from one captured context. + let receipt = produced(style.counter_produce(Some("root"))); + // Then: a1 can be hoisted, while v1 stays global and no counters are reserved. + assert_eq!(receipt.class.context.config.mode, NameMode::AtomHoist); + assert!(receipt.class.allocation.name.starts_with("pa1-h-")); + assert!(receipt.variable.starts_with("--pv1-")); + assert_eq!(get_class_map().len(), 0); + cleanup(); +} + +#[test] +#[serial] +fn no_site_dynamic_retains_original_when_two_empty_site_records_are_deferred() { + // Given: neither record can recover its parent authority from an assignment site. + reset(NameMode::Counter); + let first = { + let _scope = scope("a"); + ExtractDynamicStyle::new("color", 0, "tone", None) + }; + let second = { + let _scope = scope("b"); + ExtractDynamicStyle::new("color", 0, "tone", None) + }; + // When: both deferred records produce private classes outside their original scopes. + let receipts = [first, second].map(|style| produced(style.counter_produce(Some("delivery")))); + // Then: class streams retain distinct parents while the no-site variable is shared. + assert_eq!(receipts[0].class.original, 0); + assert_eq!(receipts[1].class.original, 1); + assert_eq!(receipts[0].class.allocation.name, "pa-a"); + assert_eq!(receipts[1].class.allocation.name, "pb-a"); + assert_eq!(receipts[0].variable, "--pa"); + assert_eq!(receipts[1].variable, "--pa"); + assert_eq!( + receipts[1] + .variable_allocation + .as_ref() + .map(|receipt| receipt.original), + Some(1) + ); + cleanup(); +} diff --git a/libs/extractor/src/extract_style/counter_producer_test_support.rs b/libs/extractor/src/extract_style/counter_producer_test_support.rs new file mode 100644 index 000000000..939afad98 --- /dev/null +++ b/libs/extractor/src/extract_style/counter_producer_test_support.rs @@ -0,0 +1,45 @@ +use super::{ProducedAllocation, extract_static_style::ExtractStaticStyle}; +use crate::sparse_sites::SiteScope; +use css::{allocation_input::NameMode, counter_names::NameAddress}; + +pub(super) fn reset(mode: NameMode) { + css::set_prefix(Some("p".to_string())); + css::debug::set_debug(mode == NameMode::Debug); + css::atom_hoist::set_atom_hoist((mode == NameMode::AtomHoist).then_some(2)); + css::atom_hoist::restore_atom_plan(None); + css::file_map::reset_canonical_map(); + css::file_map::reset_file_map(); + css::class_map::reset_class_map(); + css::theme_tokens::set_theme_token_values(Default::default(), Default::default()); +} + +pub(super) fn scope(filename: &str) -> SiteScope { + let original = css::file_map::get_or_insert_original_id(filename) + .unwrap_or_else(|error| panic!("{error}")); + SiteScope::enter_counter_numbered(original, "abcdefghij", &[]) +} + +pub(super) fn cleanup() { + reset(NameMode::Counter); + css::set_prefix(None); +} + +pub(super) fn static_style(filename: &str) -> ExtractStaticStyle { + let _scope = scope(filename); + ExtractStaticStyle::new("color", "red", 0, None) +} + +pub(super) fn address(receipt: &ProducedAllocation, namespace: &str, key: &str, slot: usize) { + assert_eq!( + receipt.allocation.address, + NameAddress::Counter { + namespace: namespace.to_string(), + legacy_key: key.to_string(), + slot, + } + ); +} + +pub(super) fn produced(value: Result) -> T { + value.unwrap_or_else(|error| panic!("{error}")) +} diff --git a/libs/extractor/src/extract_style/counter_producer_tests.rs b/libs/extractor/src/extract_style/counter_producer_tests.rs new file mode 100644 index 000000000..da25358fc --- /dev/null +++ b/libs/extractor/src/extract_style/counter_producer_tests.rs @@ -0,0 +1,211 @@ +use super::counter_producer_test_support::{ + address, cleanup, produced, reset, scope, static_style, +}; +use super::extract_static_style::{ExtractStaticStyle, ThemeTokenResolution}; +use super::{CounterProducerError, ExtractDynamicStyle, ExtractKeyframes}; +use crate::sparse_sites::SiteScope; +use css::{ + Naming, + allocation_input::{LegacyDeclaration, LegacyInput, NameMode}, + class_map::get_class_map, +}; +use rstest::rstest; +use serial_test::serial; +use std::collections::{BTreeMap, HashMap}; + +#[rstest] +#[case(None, Some("delivery"), "D9-0", "color-0-red--255-a", "pa-a")] +#[case(Some(1), Some("delivery"), "D9-0", "color-0-red--1-a", "pa-a")] +#[case(Some(255), Some("delivery"), "D9-0", "color-0-red--255-a", "pa-a")] +#[case(Some(0), Some("delivery"), "", "color-0-red--0", "pa")] +#[case(None, None, "", "color-0-red--255", "pa")] +#[serial] +fn static_retains_original_when_orders_choose_private_or_shared( + #[case] order: Option, + #[case] filename: Option<&str>, + #[case] namespace: &str, + #[case] key: &str, + #[case] name: &str, +) { + // Given: a deferred numeric record, with unrelated delivery numbering. + reset(NameMode::Counter); + let mut style = static_style("original"); + style.style_order = order; + let _delivery = css::file_map::get_file_num_by_filename("unrelated"); + // When: its real dormant request is produced after scope exit. + let receipt = produced(style.counter_produce(filename)); + // Then: original authority and actual legacy address are independent of delivery. + assert_eq!(receipt.original, 0); + address(&receipt, namespace, key, 0); + assert_eq!(receipt.allocation.name, name); + cleanup(); +} + +#[rstest] +#[case("margin", "1px 1px 1px 1px", "1px 1px 1px 1px")] +#[case("font-family", "'Roboto', sans-serif", "Roboto,sans-serif")] +#[case("content", "'a b'", "'a b'")] +#[case("typography", "heading", "heading")] +#[case("width", "$missing", "$missing")] +#[serial] +fn static_uses_authored_projection_when_emission_can_expand( + #[case] property: &str, + #[case] value: &str, + #[case] expected: &str, +) { + // Given: normalized raw IR; content and typography must not use emission identity. + reset(NameMode::Counter); + let _scope = scope("original"); + let style = ExtractStaticStyle::new(property, value, 0, None); + // When: the adapter prepares the exact legacy naming input. + let receipt = produced(style.counter_produce(None)); + // Then: naming contains the legacy resolved/multi-value projection only. + assert_eq!( + receipt.input, + LegacyInput::Declaration(LegacyDeclaration { + property: property.into(), + level: 0, + value: Some(expected.into()), + selector: None, + order: None, + }) + ); + cleanup(); +} + +#[test] +#[serial] +fn first_value_is_captured_when_theme_lookup_differs_from_authored_token() { + // Given: an authored token with a concrete first lookup and layered selector. + reset(NameMode::Counter); + css::theme_tokens::set_theme_token_values( + BTreeMap::from([("space".into(), "24px".into())]), + BTreeMap::new(), + ); + let _scope = scope("original"); + let style = ExtractStaticStyle::new_with_layer( + "width", + "$space", + 2, + Some("hover".into()), + Some("ui".into()), + ) + .with_theme_token_resolution(ThemeTokenResolution::FirstValue); + // When: one request captures the currently resolved first value. + let receipt = produced(style.counter_produce(Some("delivery"))); + // Then: its retained input is literal, not a CSS variable or raw token. + let LegacyInput::Declaration(input) = receipt.input else { + panic!("declaration") + }; + assert_eq!(input.value, Some("24px".into())); + assert_eq!(input.selector, Some("&:hover@layer ui".into())); + cleanup(); +} + +#[rstest] +#[case(Naming::Own)] +#[case(Naming::Risky)] +#[serial] +fn collapsed_delivery_keeps_private_original_when_naming_eligibility_changes( + #[case] naming: Naming, +) { + // Given: distinct originals collapsed into one delivery root. + reset(NameMode::Counter); + let first = static_style("a").with_naming(naming); + let second = static_style("b").with_naming(naming); + css::file_map::set_canonical_map(HashMap::from([ + ("a".into(), "root".into()), + ("b".into(), "root".into()), + ])); + // When: both deferred records request their private classes. + let receipts = [ + produced(first.counter_produce(Some("a"))), + produced(second.counter_produce(Some("b"))), + ]; + // Then: collapsed delivery never aliases original private counters. + assert_eq!(receipts[0].allocation.name, "pa-a"); + assert_eq!(receipts[1].allocation.name, "pb-a"); + address(&receipts[0], "D9-0", "color-0-red--255-a", 0); + address(&receipts[1], "D9-1", "color-0-red--255-b", 0); + cleanup(); +} + +#[rstest] +#[case(NameMode::Counter)] +#[case(NameMode::Debug)] +#[case(NameMode::AtomHoist)] +#[serial] +fn wrong_policy_rejects_all_parents_when_shared_and_order_zero(#[case] mode: NameMode) { + // Given: ordinary numbered scopes remain Current despite numeric owners. + reset(mode); + let _scope = SiteScope::enter_numbered(7, "tone", &[]); + let style = ExtractStaticStyle::new_basic("color", "red", 0, None); + let dynamic = ExtractDynamicStyle::new("color", 0, "tone", None).at(0); + let frames = ExtractKeyframes::default(); + let before = get_class_map(); + // When: all parent kinds attempt shared dormant production. + let errors = [ + style.counter_produce(None).err(), + dynamic.counter_produce(None).err(), + frames.counter_produce(None).err(), + ]; + // Then: policy rejection precedes all reservations in every mode. + assert_eq!(errors, [Some(CounterProducerError::WrongPolicy); 3]); + assert_eq!(get_class_map(), before); + cleanup(); +} + +#[rstest] +#[case(CounterProducerError::WrongPolicy)] +#[case(CounterProducerError::UnnumberedSite)] +fn errors_are_typed_when_displayed(#[case] error: CounterProducerError) { + // Given: typed adapter errors. + let as_error: &dyn std::error::Error = &error; + // When: the boundary presents a diagnostic. + let message = as_error.to_string(); + // Then: diagnostics exist without an underlying fabricated cause. + assert_ne!(message, ""); + assert_eq!(as_error.source().map(ToString::to_string), None); +} + +#[rstest] +#[case(NameMode::Counter)] +#[case(NameMode::Debug)] +#[case(NameMode::AtomHoist)] +#[serial] +fn selector_layer_projection_uses_captured_mode_when_live_mode_later_changes( + #[case] mode: NameMode, +) { + // Given: raw selector bytes retained by new_basic, plus an authored layer. + reset(mode); + let _scope = scope("a"); + let mut style = ExtractStaticStyle::new_basic( + "color", + "red", + 0, + Some(css::style_selector::StyleSelector::Selector( + " &:hover ".into(), + )), + ); + style.layer = Some("ui".into()); + // When: one adapter invocation captures its selector, prefix and placement. + let receipt = produced(style.counter_produce(Some("delivery"))); + css::debug::set_debug(true); + css::atom_hoist::set_atom_hoist(Some(2)); + css::set_prefix(Some("later".into())); + // Then: frozen ordinary/debug bytes differ from the raw-hex atom selector key. + let LegacyInput::Declaration(input) = &receipt.input else { + panic!("class") + }; + let selector = match mode { + NameMode::Counter | NameMode::Debug => " &:hover @layer ui".to_string(), + NameMode::AtomHoist => "s-20263a686f76657220-s-7569".to_string(), + }; + assert_eq!(input.selector, Some(selector)); + assert_eq!(receipt.context.config.prefix, "p"); + assert_eq!( + css::counter_names::render_name(&receipt.input, &receipt.context, 0), + receipt.allocation.name + ); + cleanup(); +} diff --git a/libs/extractor/src/extract_style/counter_selector.rs b/libs/extractor/src/extract_style/counter_selector.rs new file mode 100644 index 000000000..0b97a5d3a --- /dev/null +++ b/libs/extractor/src/extract_style/counter_selector.rs @@ -0,0 +1,65 @@ +use std::cmp::Ordering; + +use css::style_selector::StyleSelector; + +#[derive(PartialEq, Eq, Hash)] +pub(super) struct CounterSelector<'a>(pub(super) &'a Option); + +impl CounterSelector<'_> { + fn global_file(&self) -> Option<&str> { + self.0.as_ref().and_then(|selector| match selector { + StyleSelector::Global(_, file) => Some(file.as_str()), + StyleSelector::At { .. } | StyleSelector::Selector(_) => None, + }) + } +} + +impl PartialOrd for CounterSelector<'_> { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for CounterSelector<'_> { + fn cmp(&self, other: &Self) -> Ordering { + self.0 + .cmp(other.0) + .then_with(|| self.global_file().cmp(&other.global_file())) + } +} + +#[cfg(test)] +mod tests { + use css::style_selector::AtRuleKind; + + use super::*; + + #[test] + fn partial_order_preserves_full_equality_when_global_cleanup_owners_differ() { + // Given: absent, scoped, at-rule and otherwise identical global selectors. + let selectors = [ + None, + Some(StyleSelector::Selector(":hover".into())), + Some(StyleSelector::At { + kind: AtRuleKind::Media, + query: "screen".into(), + selector: None, + outer: vec![], + file: None, + }), + Some(StyleSelector::Global("body".into(), "a.tsx".into())), + Some(StyleSelector::Global("body".into(), "b.tsx".into())), + ]; + // When: each pair is compared through both ordering interfaces. + for left in &selectors { + for right in &selectors { + let left = CounterSelector(left); + let right = CounterSelector(right); + let ordering = left.cmp(&right); + // Then: partial ordering has no owner-losing equality tie. + assert_eq!(left.partial_cmp(&right), Some(ordering)); + assert_eq!(left == right, ordering == Ordering::Equal); + } + } + } +} diff --git a/libs/extractor/src/extract_style/current_identity_tests.rs b/libs/extractor/src/extract_style/current_identity_tests.rs new file mode 100644 index 000000000..fa7021123 --- /dev/null +++ b/libs/extractor/src/extract_style/current_identity_tests.rs @@ -0,0 +1,209 @@ +use css::{ + CounterOwner, Naming, Site, + sparse_site::SourceFile, + style_origin::{Origin, StyleOrigin}, + style_selector::StyleSelector, +}; +use rstest::rstest; + +use super::{ + ProducerPolicy, + extract_dynamic_style::ExtractDynamicStyle, + extract_keyframes::ExtractKeyframes, + extract_static_style::{ExtractStaticStyle, ThemeTokenResolution}, + policy_test_support::{old_dynamic, old_keyframes, old_static, set_lengths, trace}, +}; + +fn static_fixtures() -> Vec { + let mut styles = vec![]; + for naming in [Naming::Own, Naming::Risky] { + for owner in [ + CounterOwner::Inactive, + CounterOwner::Unnumbered, + CounterOwner::D9(0), + CounterOwner::D9(9), + ] { + for order in [None, Some(0), Some(1), Some(255)] { + for resolution in [ + ThemeTokenResolution::CssVariable, + ThemeTokenResolution::FirstValue, + ] { + let mut style = ExtractStaticStyle::new_with_layer( + "color", + "$text", + 2, + Some(StyleSelector::from("&:hover")), + Some("theme".into()), + ); + style.naming = naming; + style.counter_owner = owner; + style.style_order = order; + style.theme_token_resolution = resolution; + styles.push(style); + } + } + } + } + styles.push(ExtractStaticStyle::new("background", "red", 0, None)); + for file in ["a.tsx", "b.tsx"] { + styles.push(ExtractStaticStyle::new( + "color", + "red", + 0, + Some(StyleSelector::Global("body".into(), file.into())), + )); + } + styles +} + +#[test] +fn static_hash_writes_match_old_projection_when_policy_is_current() { + // Given: owner eligibility, order and resolution boundaries in the old domain. + let styles = static_fixtures(); + // When: records and independent old-field projections are hashed. + let traces: Vec<_> = styles + .iter() + .map(|style| (trace(style), trace(&old_static(style)))) + .collect(); + // Then: no policy byte or changed field sequence reaches the hasher. + for (actual, expected) in traces { + assert_eq!(actual, expected); + } +} + +#[test] +fn static_sets_and_order_match_old_projection_when_policy_is_current() { + // Given: both meaningful and deliberately equivalent owner dimensions. + let styles = static_fixtures(); + let old: Vec<_> = styles.iter().map(old_static).collect(); + // When: both domains are compared and unioned. + let lengths = set_lengths(&styles); + // Then: equality, total ordering and set cardinalities remain the old contract. + assert_eq!( + lengths, + ( + old.iter().collect::>().len(), + old.iter().collect::>().len() + ) + ); + for (left, old_left) in styles.iter().zip(&old) { + for (right, old_right) in styles.iter().zip(&old) { + assert_eq!(left == right, old_left == old_right); + assert_eq!(left.cmp(right), old_left.cmp(old_right)); + assert_eq!(left.partial_cmp(right), old_left.partial_cmp(old_right)); + } + } +} + +fn dynamic_fixtures() -> Vec { + let mut styles = vec![]; + for naming in [Naming::Own, Naming::Risky] { + for order in [None, Some(0), Some(1), Some(255)] { + for file in [ + None, + Some(SourceFile::D9(0)), + Some(SourceFile::Unnumbered("received".into())), + ] { + for important in [false, true] { + let mut style = ExtractDynamicStyle::new( + "color", + 2, + if important { "tone !important" } else { "tone" }, + Some(StyleSelector::from("&:hover")), + ); + style.naming = naming; + style.style_order = order; + style.layer = Some("theme".into()); + style.site = file.clone().map(|file| Site { + file, + at: 8, + role: 1, + }); + styles.push(style); + } + } + } + } + styles.push(ExtractDynamicStyle::new("opacity", 0, "other", None)); + for file in ["a.tsx", "b.tsx"] { + styles.push(ExtractDynamicStyle::new( + "color", + 0, + "tone", + Some(StyleSelector::Global("body".into(), file.into())), + )); + } + styles +} + +#[test] +fn dynamic_identity_matches_old_projection_when_policy_is_current() { + // Given: site/no-site, important, provenance and actual-order boundaries. + let styles = dynamic_fixtures(); + // When: the new records are projected into the old derived-field sequence. + let old: Vec<_> = styles.iter().map(old_dynamic).collect(); + // Then: the exact hash writes, equality and ordering match, including optional sites. + for (left, old_left) in styles.iter().zip(&old) { + assert_eq!(left.producer_policy(), ProducerPolicy::Current); + assert_eq!(trace(left), trace(old_left)); + for (right, old_right) in styles.iter().zip(&old) { + assert_eq!(left == right, old_left == old_right); + assert_eq!(left.cmp(right), old_left.cmp(old_right)); + } + } + assert_eq!( + set_lengths(&styles), + ( + old.iter().collect::>().len(), + old.iter().collect::>().len() + ) + ); +} + +#[rstest] +#[case(false)] +#[case(true)] +fn keyframe_identity_matches_old_projection_when_steps_are_present_or_empty( + #[case] populated: bool, +) { + // Given: ordered steps with Current children and a distinct diagnostic origin. + let mut first = ExtractKeyframes::default(); + if populated { + first.keyframes.insert( + "to".into(), + vec![ExtractStaticStyle::new("opacity", "1", 0, None)], + ); + first.keyframes.insert( + "from".into(), + vec![ExtractStaticStyle::new_basic("opacity", "0", 0, None)], + ); + } + let mut second = first.clone(); + second.origin = Origin( + Some(Box::new(StyleOrigin { + file: "elsewhere.tsx".into(), + line: 4, + column: 9, + expression: "frames()".into(), + })), + None, + ); + let styles = [ExtractKeyframes::default(), first, second]; + // When: frames and independent recursively old child projections are hashed/compared. + let old: Vec<_> = styles.iter().map(old_keyframes).collect(); + // Then: empty defaults, ordered maps and diagnostic-neutral identity are unchanged. + for (left, old_left) in styles.iter().zip(&old) { + assert_eq!(trace(left), trace(old_left)); + for (right, old_right) in styles.iter().zip(&old) { + assert_eq!(left == right, old_left == old_right); + assert_eq!(left.cmp(right), old_left.cmp(old_right)); + } + } + assert_eq!( + set_lengths(&styles), + ( + old.iter().collect::>().len(), + old.iter().collect::>().len() + ) + ); +} diff --git a/libs/extractor/src/extract_style/dynamic_identity.rs b/libs/extractor/src/extract_style/dynamic_identity.rs new file mode 100644 index 000000000..f57e42d0a --- /dev/null +++ b/libs/extractor/src/extract_style/dynamic_identity.rs @@ -0,0 +1,114 @@ +use std::{ + cmp::Ordering, + hash::{Hash, Hasher}, +}; + +use css::{Naming, Site, style_origin::Origin, style_selector::StyleSelector}; + +use super::ExtractDynamicStyle; +use crate::extract_style::ProducerPolicy; +use crate::extract_style::counter_selector::CounterSelector; + +#[derive(PartialEq, Eq, Hash, PartialOrd, Ord)] +struct Identity<'a> { + property: &'a str, + level: u8, + identifier: &'a str, + selector: &'a Option, + order: Option, + important: bool, + layer: &'a Option, + naming: Naming, + site: &'a Option, + origin: &'a Origin, +} + +#[derive(PartialEq, Eq, Hash, PartialOrd, Ord)] +struct CounterIdentity<'a> { + property: &'a str, + level: u8, + identifier: &'a str, + selector: CounterSelector<'a>, + order: Option, + important: bool, + layer: &'a Option, + site: &'a Option, + owner: Option, +} + +impl ExtractDynamicStyle { + fn identity(&self) -> Identity<'_> { + Identity { + property: &self.property, + level: self.level, + identifier: &self.identifier, + selector: &self.selector, + order: self.style_order, + important: self.important, + layer: &self.layer, + naming: self.naming, + site: &self.site, + origin: &self.origin, + } + } + + fn counter_identity(&self, original: u32) -> CounterIdentity<'_> { + CounterIdentity { + property: &self.property, + level: self.level, + identifier: &self.identifier, + selector: CounterSelector(&self.selector), + order: self.style_order, + important: self.important, + layer: &self.layer, + site: &self.site, + owner: ProducerPolicy::declaration_owner(original, self.style_order), + } + } +} + +impl PartialEq for ExtractDynamicStyle { + fn eq(&self, other: &Self) -> bool { + match (self.producer_policy(), other.producer_policy()) { + (ProducerPolicy::Current, ProducerPolicy::Current) => { + self.identity() == other.identity() + } + (ProducerPolicy::Current, ProducerPolicy::CounterOriginal(_)) + | (ProducerPolicy::CounterOriginal(_), ProducerPolicy::Current) => false, + (ProducerPolicy::CounterOriginal(left), ProducerPolicy::CounterOriginal(right)) => { + self.counter_identity(left) == other.counter_identity(right) + } + } + } +} +impl Eq for ExtractDynamicStyle {} +impl Hash for ExtractDynamicStyle { + fn hash(&self, state: &mut H) { + match self.producer_policy() { + ProducerPolicy::Current => self.identity().hash(state), + ProducerPolicy::CounterOriginal(original) => { + 1u8.hash(state); + self.counter_identity(original).hash(state); + } + } + } +} +impl PartialOrd for ExtractDynamicStyle { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} +impl Ord for ExtractDynamicStyle { + fn cmp(&self, other: &Self) -> Ordering { + match (self.producer_policy(), other.producer_policy()) { + (ProducerPolicy::Current, ProducerPolicy::Current) => { + self.identity().cmp(&other.identity()) + } + (ProducerPolicy::Current, ProducerPolicy::CounterOriginal(_)) => Ordering::Less, + (ProducerPolicy::CounterOriginal(_), ProducerPolicy::Current) => Ordering::Greater, + (ProducerPolicy::CounterOriginal(left), ProducerPolicy::CounterOriginal(right)) => self + .counter_identity(left) + .cmp(&other.counter_identity(right)), + } + } +} diff --git a/libs/extractor/src/extract_style/extract_dynamic_style.rs b/libs/extractor/src/extract_style/extract_dynamic_style.rs index f09b03b07..504c927a9 100644 --- a/libs/extractor/src/extract_style/extract_dynamic_style.rs +++ b/libs/extractor/src/extract_style/extract_dynamic_style.rs @@ -1,13 +1,19 @@ use std::fmt::{Debug, Formatter}; use css::{ - sheet_to_classname, sheet_to_variable_name, + Naming, Site, + content_name::{AtomContent, ContentName}, + sheet_to_classname_owned, sheet_to_variable_name_at, + style_origin::Origin, style_selector::{StyleSelector, optimize_selector}, }; use crate::extract_style::{ExtractStyleProperty, style_property::StyleProperty}; -#[derive(PartialEq, Clone, Eq, Hash, Ord, PartialOrd)] +#[path = "dynamic_identity.rs"] +mod identity; + +#[derive(Clone)] pub struct ExtractDynamicStyle { /// property property: String, @@ -24,6 +30,13 @@ pub struct ExtractDynamicStyle { important: bool, pub(crate) layer: Option, + + pub(crate) naming: Naming, + + /// Where in the original sources it was written; names its variable + pub(crate) site: Option, + pub origin: Origin, + producer_policy: super::ProducerPolicy, } impl Debug for ExtractDynamicStyle { @@ -40,6 +53,9 @@ impl Debug for ExtractDynamicStyle { if let Some(layer) = &self.layer { s.field("layer", layer); } + if self.naming != Naming::Own { + s.field("naming", &self.naming); + } s.finish() } } @@ -100,6 +116,23 @@ fn runtime_code(identifier: &str) -> String { } impl ExtractDynamicStyle { + /// Produce retained class and assignment-variable allocations on the dormant path. + /// + /// # Errors + /// Rejects Current construction and unnumbered assignment sites before reservation. + pub fn counter_produce( + &self, + filename: Option<&str>, + ) -> Result { + super::counter_producer::produce_dynamic(self, filename) + } + + /// The immutable identity policy selected when this record was constructed. + #[must_use] + pub const fn producer_policy(&self) -> super::ProducerPolicy { + self.producer_policy + } + /// create a new `ExtractDynamicStyle` pub fn new( property: &str, @@ -118,9 +151,43 @@ impl ExtractDynamicStyle { style_order: None, important, layer: None, + naming: Naming::Own, + site: None, + origin: crate::style_origin::current(), + producer_policy: crate::sparse_sites::producer_policy(), } } + /// Place it where the code being read has it at `start`. + #[must_use] + pub fn at(self, start: u32) -> Self { + self.at_role(start, 0) + } + + pub(crate) fn with_assignment_site(mut self, start: u32) -> Self { + self.site = crate::assignment_owner::site(start, self.level, &self.identifier); + self + } + + /// Select a sub-role by source syntax order, not dynamic extraction order. + #[must_use] + pub fn at_role(mut self, start: u32, role: usize) -> Self { + self.site = crate::provenance::site_at(start, role, &self.identifier); + self + } + + /// The CSS variable that carries its value. + #[must_use] + pub fn variable_name(&self) -> String { + let selector = super::class_selector(self.selector.as_ref(), self.layer()); + sheet_to_variable_name_at( + self.property.as_str(), + self.level, + selector.as_deref(), + self.site.clone(), + ) + } + pub const fn property(&self) -> &str { self.property.as_str() } @@ -137,6 +204,10 @@ impl ExtractDynamicStyle { self.identifier.as_str() } + pub(crate) fn replace_identifier(&mut self, identifier: &str) { + self.identifier = identifier.to_string(); + } + pub const fn style_order(&self) -> Option { self.style_order } @@ -145,28 +216,70 @@ impl ExtractDynamicStyle { self.important } + pub const fn naming(&self) -> Naming { + self.naming + } + + /// Read source identity without changing its position or syntax role. + pub const fn site(&self) -> Option<&Site> { + self.site.as_ref() + } + + /// Original allocation identity already carried by the dynamic source site. + pub fn counter_owner(&self) -> css::CounterOwner { + self.site + .as_ref() + .map_or(css::CounterOwner::Inactive, |site| { + css::CounterOwner::from_source(&site.file) + }) + } + pub fn layer(&self) -> Option<&str> { self.layer.as_deref() } + + #[must_use] + pub fn effective_value(&self) -> String { + format!( + "var({}){}", + self.variable_name(), + if self.important { " !important" } else { "" } + ) + } + + fn atom_content<'a>(&'a self, value: &'a str) -> AtomContent<'a> { + AtomContent { + property: &self.property, + value: Some(value), + naming: self.naming, + level: self.level, + order: self.style_order.unwrap_or(255), + selector: self.selector.as_ref(), + layer: self.layer.as_deref(), + dynamic: true, + } + } + + #[must_use] + pub fn content_name(&self) -> ContentName { + self.atom_content(&self.effective_value()).content() + } } impl ExtractStyleProperty for ExtractDynamicStyle { fn extract(&self, filename: Option<&str>) -> StyleProperty { - let selector = super::class_selector(self.selector.as_ref(), self.layer()); + let variable_name = self.variable_name(); + let declaration = format!( + "var({variable_name}){}", + if self.important { " !important" } else { "" } + ); StyleProperty::Variable { - class_name: sheet_to_classname( - self.property.as_str(), - self.level, - None, - selector.as_deref(), - self.style_order, + class_name: sheet_to_classname_owned( + &self.atom_content(&declaration), filename, + self.counter_owner(), ), - variable_name: sheet_to_variable_name( - self.property.as_str(), - self.level, - selector.as_deref(), - ), + variable_name, identifier: self.identifier.clone(), } } diff --git a/libs/extractor/src/extract_style/extract_keyframes.rs b/libs/extractor/src/extract_style/extract_keyframes.rs index b62eeab9d..50467bc34 100644 --- a/libs/extractor/src/extract_style/extract_keyframes.rs +++ b/libs/extractor/src/extract_style/extract_keyframes.rs @@ -1,45 +1,82 @@ -use std::{ - collections::BTreeMap, - hash::{DefaultHasher, Hash, Hasher}, -}; +use std::collections::BTreeMap; -use css::keyframes_to_keyframes_name; +use css::{content_name::ContentName, style_origin::Origin}; use crate::extract_style::{ ExtractStyleProperty, extract_static_style::ExtractStaticStyle, style_property::StyleProperty, }; -#[derive(Debug, Default, PartialEq, Clone, Eq, Hash, Ord, PartialOrd)] +#[path = "keyframe_identity.rs"] +mod identity; + +#[derive(Clone)] pub struct ExtractKeyframes { pub keyframes: BTreeMap>, + pub origin: Origin, + pub(crate) producer_policy: super::ProducerPolicy, } -impl ExtractStyleProperty for ExtractKeyframes { - fn extract(&self, filename: Option<&str>) -> StyleProperty { - let mut hasher = DefaultHasher::new(); - self.keyframes.hash(&mut hasher); - // Format the u64 hash into a stack buffer instead of a throwaway heap - // `String`; `keyframes_to_keyframes_name` only reads it as `&str` and - // copies it into its own key, so the owned allocation was pure waste. - let mut buf = [0u8; 20]; - let hash_key = write_u64(&mut buf, hasher.finish()); - StyleProperty::ClassName(keyframes_to_keyframes_name(hash_key, filename)) +impl Default for ExtractKeyframes { + fn default() -> Self { + Self { + keyframes: BTreeMap::new(), + origin: Origin::default(), + producer_policy: crate::sparse_sites::producer_policy(), + } } } -/// Writes `value`'s decimal digits into the tail of `buf` and returns the -/// written slice as `&str`. A `u64` is at most 20 decimal digits, so `buf` -/// never overflows. -fn write_u64(buf: &mut [u8; 20], mut value: u64) -> &str { - let mut pos = buf.len(); - loop { - pos -= 1; - buf[pos] = b'0' + (value % 10) as u8; - value /= 10; - if value == 0 { - break; - } +impl std::fmt::Debug for ExtractKeyframes { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ExtractKeyframes") + .field("keyframes", &self.keyframes) + .finish() + } +} + +impl ExtractKeyframes { + /// Produce one dormant keyframe allocation, never member allocations. + /// + /// # Errors + /// Rejects Current construction on the parent or any child before reservation. + pub fn counter_produce( + &self, + filename: Option<&str>, + ) -> Result { + super::counter_producer::produce_keyframes(self, filename) + } + + /// The immutable identity policy selected when this record was constructed. + #[must_use] + pub const fn producer_policy(&self) -> super::ProducerPolicy { + self.producer_policy + } + + #[must_use] + pub fn effective_steps(&self) -> Vec<(String, Vec<(String, String)>)> { + self.keyframes + .iter() + .map(|(step, styles)| { + ( + step.clone(), + styles + .iter() + .map(|style| (style.property().to_string(), style.effective_value())) + .collect(), + ) + }) + .collect() + } + + #[must_use] + pub fn content_name(&self) -> ContentName { + ContentName::keyframes(&self.effective_steps()) + } +} + +impl ExtractStyleProperty for ExtractKeyframes { + fn extract(&self, _filename: Option<&str>) -> StyleProperty { + let content = self.content_name(); + StyleProperty::ClassName(content.name(css::get_prefix().as_deref().unwrap_or_default())) } - // Only ASCII digits were written, so this slice is valid UTF-8. - str::from_utf8(&buf[pos..]).unwrap_or("0") } diff --git a/libs/extractor/src/extract_style/extract_static_style.rs b/libs/extractor/src/extract_style/extract_static_style.rs index 1adc78be6..c53abd906 100644 --- a/libs/extractor/src/extract_style/extract_static_style.rs +++ b/libs/extractor/src/extract_style/extract_static_style.rs @@ -2,10 +2,14 @@ use std::borrow::Cow; use std::fmt::{Debug, Formatter}; use css::{ + Naming, + content_name::{AtomContent, ContentName}, optimize_multi_css_value::{check_multi_css_optimize, optimize_multi_css_value}, optimize_value::optimize_value, - sheet_to_classname, + sheet_to_classname_owned, + style_origin::Origin, style_selector::{StyleSelector, optimize_selector}, + theme_tokens::get_first_theme_token_value, }; use crate::{ @@ -24,7 +28,7 @@ pub enum ThemeTokenResolution { FirstValue, } -#[derive(PartialEq, Clone, Eq, Hash, Ord, PartialOrd)] +#[derive(Clone)] pub struct ExtractStaticStyle { /// property pub property: String, @@ -40,22 +44,49 @@ pub struct ExtractStaticStyle { pub layer: Option, /// How theme tokens should be resolved when converting to CSS. pub theme_token_resolution: ThemeTokenResolution, + /// Which counter, if any, names the class; kept so the sheet names it + /// again the same way. + pub naming: Naming, + /// Captured original allocation identity, retained for deferred sheet emission. + pub counter_owner: css::CounterOwner, + pub origin: Origin, + pub(crate) producer_policy: super::ProducerPolicy, } impl Debug for ExtractStaticStyle { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - f.debug_struct("ExtractStaticStyle") - .field("property", &self.property) + let mut s = f.debug_struct("ExtractStaticStyle"); + s.field("property", &self.property) .field("value", &self.value) .field("level", &self.level) .field("selector", &self.selector) .field("style_order", &self.style_order) - .field("layer", &self.layer) - .finish() + .field("layer", &self.layer); + if self.naming != Naming::Own { + s.field("naming", &self.naming); + } + s.finish() } } impl ExtractStaticStyle { + /// Produce a dormant counter allocation without changing production dispatch. + /// + /// # Errors + /// Returns `WrongPolicy` unless construction retained a counter original. + pub fn counter_produce( + &self, + filename: Option<&str>, + ) -> Result { + super::counter_producer::produce_static(self, filename) + } + + /// The immutable identity policy selected when this record was constructed. + #[must_use] + pub const fn producer_policy(&self) -> super::ProducerPolicy { + self.producer_policy + } + /// Normalize a static style value, shared by `new` and `new_basic`. /// /// When `apply_aspect_ratio` is `true`, the `aspect-ratio` value is reduced @@ -105,6 +136,10 @@ impl ExtractStaticStyle { style_order: None, layer: None, theme_token_resolution: ThemeTokenResolution::CssVariable, + naming: Naming::Own, + counter_owner: crate::sparse_sites::counter_owner(), + origin: crate::style_origin::current(), + producer_policy: crate::sparse_sites::producer_policy(), } } @@ -137,6 +172,10 @@ impl ExtractStaticStyle { style_order: Some(0), layer: None, theme_token_resolution: ThemeTokenResolution::CssVariable, + naming: Naming::Own, + counter_owner: crate::sparse_sites::counter_owner(), + origin: crate::style_origin::current(), + producer_policy: crate::sparse_sites::producer_policy(), } } @@ -146,6 +185,12 @@ impl ExtractStaticStyle { self } + #[must_use] + pub const fn with_naming(mut self, naming: Naming) -> Self { + self.naming = naming; + self + } + /// Get the layer name #[must_use] pub fn layer(&self) -> Option<&str> { @@ -188,26 +233,59 @@ impl ExtractStaticStyle { pub const fn theme_token_resolution(&self) -> ThemeTokenResolution { self.theme_token_resolution } + + /// Effective value shared by generated class identity and emitted declaration. + pub fn resolved_value(&self) -> Cow<'_, str> { + match self.theme_token_resolution() { + ThemeTokenResolution::CssVariable => Cow::Borrowed(&self.value), + ThemeTokenResolution::FirstValue => { + get_first_theme_token_value(&self.property, &self.value) + .map_or(Cow::Borrowed(&self.value), Cow::Owned) + } + } + } + + #[must_use] + pub fn content_name(&self) -> ContentName { + let value = self.effective_value(); + self.atom_content(&value).content() + } + + #[must_use] + pub fn effective_value(&self) -> String { + if self.property == "typography" { + return css::content_typography::identity(&self.value, self.level); + } + let value = self.resolved_value(); + let value = if self.property != "content" && check_multi_css_optimize(&self.property) { + optimize_multi_css_value(&value).into_owned() + } else { + value.into_owned() + }; + css::content_value::emitted(&value).into_owned() + } + + fn atom_content<'a>(&'a self, value: &'a str) -> AtomContent<'a> { + AtomContent { + property: &self.property, + value: Some(value), + naming: self.naming, + level: self.level, + order: self.style_order.unwrap_or(255), + selector: self.selector.as_ref(), + layer: self.layer.as_deref(), + dynamic: false, + } + } } impl ExtractStyleProperty for ExtractStaticStyle { fn extract(&self, filename: Option<&str>) -> StyleProperty { - let s = self.class_selector(); - // `self.value` is already the result of `optimize_value(convert_value(..))` - // (computed in the constructors), so re-running convert_value + optimize_value - // here is redundant. Only the multi-css optimization is not applied at construction. - let v = if check_multi_css_optimize(&self.property) { - optimize_multi_css_value(&self.value) - } else { - std::borrow::Cow::Borrowed(self.value.as_str()) - }; - StyleProperty::ClassName(sheet_to_classname( - &self.property, - self.level, - Some(v.as_ref()), - s.as_deref(), - self.style_order, + let value = self.effective_value(); + StyleProperty::ClassName(sheet_to_classname_owned( + &self.atom_content(&value), filename, + self.counter_owner, )) } } @@ -216,6 +294,19 @@ impl ExtractStyleProperty for ExtractStaticStyle { mod tests { use super::*; + #[test] + fn content_strings_keep_quotes_when_effective_values_name_and_emit_them() { + for value in ["\"\"", "\"red\"", "'hello world'"] { + let style = ExtractStaticStyle::new("content", value, 0, None); + assert_eq!(style.effective_value(), value); + assert_ne!( + style.content_name(), + ExtractStaticStyle::new("content", value.trim_matches(['\'', '"']), 0, None) + .content_name() + ); + } + } + #[test] fn test_extract_static_style() { let style = ExtractStaticStyle::new("color", "red", 0, None); diff --git a/libs/extractor/src/extract_style/extract_style_value.rs b/libs/extractor/src/extract_style/extract_style_value.rs index 588057752..18cbe52de 100644 --- a/libs/extractor/src/extract_style/extract_style_value.rs +++ b/libs/extractor/src/extract_style/extract_style_value.rs @@ -34,6 +34,18 @@ impl ExtractStyleValue { | ExtractStyleValue::FontFace(_) => None, } } + pub const fn join_naming(&mut self, naming: css::Naming) { + match self { + ExtractStyleValue::Static(style) => style.naming = style.naming.join(naming), + ExtractStyleValue::Dynamic(style) => style.naming = style.naming.join(naming), + ExtractStyleValue::Keyframes(_) + | ExtractStyleValue::Typography(_) + | ExtractStyleValue::Css(_) + | ExtractStyleValue::Import(_) + | ExtractStyleValue::FontFace(_) => {} + } + } + pub const fn set_style_order(&mut self, order: u8) { match self { ExtractStyleValue::Static(style) if style.style_order.is_none() => { diff --git a/libs/extractor/src/extract_style/keyframe_identity.rs b/libs/extractor/src/extract_style/keyframe_identity.rs new file mode 100644 index 000000000..bdcf644ad --- /dev/null +++ b/libs/extractor/src/extract_style/keyframe_identity.rs @@ -0,0 +1,84 @@ +use std::{ + cmp::Ordering, + collections::BTreeMap, + hash::{Hash, Hasher}, +}; + +use css::style_origin::Origin; + +use super::ExtractKeyframes; +use crate::extract_style::{ProducerPolicy, extract_static_style::ExtractStaticStyle}; + +#[derive(PartialEq, Eq, Hash, PartialOrd, Ord)] +struct Identity<'a> { + keyframes: &'a BTreeMap>, + origin: &'a Origin, +} + +#[derive(PartialEq, Eq, Hash, PartialOrd, Ord)] +struct CounterIdentity<'a> { + keyframes: &'a BTreeMap>, + original: u32, +} + +impl ExtractKeyframes { + const fn identity(&self) -> Identity<'_> { + Identity { + keyframes: &self.keyframes, + origin: &self.origin, + } + } + + const fn counter_identity(&self, original: u32) -> CounterIdentity<'_> { + CounterIdentity { + keyframes: &self.keyframes, + original, + } + } +} + +impl PartialEq for ExtractKeyframes { + fn eq(&self, other: &Self) -> bool { + match (self.producer_policy(), other.producer_policy()) { + (ProducerPolicy::Current, ProducerPolicy::Current) => { + self.identity() == other.identity() + } + (ProducerPolicy::Current, ProducerPolicy::CounterOriginal(_)) + | (ProducerPolicy::CounterOriginal(_), ProducerPolicy::Current) => false, + (ProducerPolicy::CounterOriginal(left), ProducerPolicy::CounterOriginal(right)) => { + self.counter_identity(left) == other.counter_identity(right) + } + } + } +} +impl Eq for ExtractKeyframes {} +impl Hash for ExtractKeyframes { + fn hash(&self, state: &mut H) { + match self.producer_policy() { + ProducerPolicy::Current => self.identity().hash(state), + ProducerPolicy::CounterOriginal(original) => { + 1u8.hash(state); + self.counter_identity(original).hash(state); + } + } + } +} +impl PartialOrd for ExtractKeyframes { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} +impl Ord for ExtractKeyframes { + fn cmp(&self, other: &Self) -> Ordering { + match (self.producer_policy(), other.producer_policy()) { + (ProducerPolicy::Current, ProducerPolicy::Current) => { + self.identity().cmp(&other.identity()) + } + (ProducerPolicy::Current, ProducerPolicy::CounterOriginal(_)) => Ordering::Less, + (ProducerPolicy::CounterOriginal(_), ProducerPolicy::Current) => Ordering::Greater, + (ProducerPolicy::CounterOriginal(left), ProducerPolicy::CounterOriginal(right)) => self + .counter_identity(left) + .cmp(&other.counter_identity(right)), + } + } +} diff --git a/libs/extractor/src/extract_style/mod.rs b/libs/extractor/src/extract_style/mod.rs index e101f8bbb..0303100ed 100644 --- a/libs/extractor/src/extract_style/mod.rs +++ b/libs/extractor/src/extract_style/mod.rs @@ -1,3 +1,35 @@ +#[doc(hidden)] +pub mod compiler_associations { + include!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/compiler_associations.rs" + )); +} +pub(crate) mod compiler_diagnostics { + include!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/compiler_diagnostics.rs" + )); +} +pub(crate) mod compiler_projection { + include!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/compiler_projection.rs" + )); +} +#[doc(hidden)] +pub mod compiler_receipts { + include!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/compiler_receipts.rs" + )); +} +pub(crate) mod compiler_request { + include!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/compiler_request.rs" + )); +} pub(super) mod constant; pub(super) mod extract_css; pub(super) mod extract_dynamic_style; @@ -6,6 +38,40 @@ pub(super) mod extract_import; pub(super) mod extract_keyframes; pub mod extract_static_style; pub mod extract_style_value; +mod producer_policy; +pub use producer_policy::ProducerPolicy; +mod counter_keyframe_input; +mod counter_producer; +mod counter_producer_selector; +pub use counter_producer::{CounterProducerError, ProducedAllocation, ProducedDynamic}; +pub use extract_dynamic_style::ExtractDynamicStyle; +pub use extract_keyframes::ExtractKeyframes; +#[cfg(test)] +mod counter_identity_tests; +#[cfg(test)] +mod counter_producer_keyframe_tests; +#[cfg(test)] +mod counter_producer_order_tests; +#[cfg(test)] +mod counter_producer_site_tests; +#[cfg(test)] +mod counter_producer_test_support; +#[cfg(test)] +mod counter_producer_tests; +mod counter_selector; +#[cfg(test)] +mod current_identity_tests; +#[cfg(test)] +mod original_owner_tests; +#[cfg(test)] +mod policy_capture_tests; +#[cfg(test)] +mod policy_deferred_tests; +#[cfg(test)] +mod policy_literal_tests; +#[cfg(test)] +mod policy_test_support; +mod static_identity; pub mod style_property; use crate::extract_style::style_property::StyleProperty; @@ -16,6 +82,9 @@ fn class_selector<'a>( selector: Option<&'a StyleSelector>, layer: Option<&str>, ) -> Option> { + if css::atom_hoist::is_atom_hoist() { + return Some(Cow::Owned(css::atom_name::selector_key(selector, layer))); + } let selector = selector.map(StyleSelector::as_class_str); match layer { Some(layer) => Some(Cow::Owned(format!( diff --git a/libs/extractor/src/extract_style/original_owner_tests.rs b/libs/extractor/src/extract_style/original_owner_tests.rs new file mode 100644 index 000000000..94dd8fa24 --- /dev/null +++ b/libs/extractor/src/extract_style/original_owner_tests.rs @@ -0,0 +1,131 @@ +use css::{CounterOwner, Naming}; +use rustc_hash::FxHashSet; +use serial_test::serial; + +use super::{ + ExtractStyleProperty, extract_dynamic_style::ExtractDynamicStyle, + extract_static_style::ExtractStaticStyle, extract_style_value::ExtractStyleValue, +}; +use crate::sparse_sites::SiteScope; + +#[test] +#[serial] +fn nested_raw_scopes_restore_owners_when_construction_is_deferred() { + // Given + css::file_map::reset_file_map(); + css::file_map::seed_file_numbers(&["a.tsx".into(), "child.tsx".into()]); + let outer = SiteScope::enter("a.tsx", "outer", &[]); + let first = ExtractStaticStyle::new("color", "red", 0, None); + // When + let nested = { + let _inner = SiteScope::enter("child.tsx", "inner", &[]); + ExtractStaticStyle::new_basic("color", "red", 0, None) + }; + let restored = ExtractStaticStyle::new("color", "red", 0, None); + drop(outer); + // Then + assert_eq!(first.counter_owner, CounterOwner::D9(0)); + assert_eq!(nested.counter_owner, CounterOwner::D9(1)); + assert_eq!(first, restored); + assert_eq!(crate::sparse_sites::counter_owner(), CounterOwner::Inactive); + css::file_map::reset_file_map(); +} + +#[test] +#[serial] +fn direct_order_and_naming_transitions_keep_only_counter_bearing_owners_distinct() { + // Given + css::file_map::reset_file_map(); + css::file_map::seed_file_numbers(&["a.tsx".into(), "child.tsx".into()]); + let mut styles: Vec<_> = ["a.tsx", "child.tsx"] + .into_iter() + .map(|file| { + let _scope = SiteScope::enter(file, "source", &[]); + ExtractStaticStyle::new_basic("color", "red", 0, None) + }) + .collect(); + assert_eq!(styles[0], styles[1]); + // When + for style in &mut styles { + style.style_order = Some(255); + } + // Then + assert_ne!(styles[0], styles[1]); + assert_eq!(styles.iter().cloned().collect::>().len(), 2); + assert_eq!( + styles + .iter() + .cloned() + .collect::>() + .len(), + 2 + ); + let risky: Vec<_> = styles + .into_iter() + .map(|style| style.with_naming(Naming::Risky)) + .collect(); + assert_eq!(risky[0], risky[1]); + assert_eq!(risky.iter().cloned().collect::>().len(), 1); + css::file_map::reset_file_map(); +} + +#[test] +#[serial] +fn dynamic_class_uses_site_file_when_delivery_is_collapsed() { + // Given + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + css::file_map::seed_file_numbers(&["a.tsx".into(), "child.tsx".into()]); + css::file_map::set_canonical_map(std::collections::HashMap::from([( + "child.tsx".into(), + "a.tsx".into(), + )])); + let style = { + let _scope = SiteScope::enter("child.tsx", "tone", &[]); + ExtractDynamicStyle::new("color", 0, "tone", None).at(0) + }; + // When + let result = style.extract(Some("a.tsx")); + // Then + match result { + super::style_property::StyleProperty::Variable { + class_name, + variable_name, + .. + } => { + assert_eq!(class_name, "b-a"); + assert_eq!(variable_name, "---Sb-a"); + } + super::style_property::StyleProperty::ClassName(_) => panic!("dynamic class lost variable"), + } + css::class_map::with_class_map(|map| { + assert_eq!(map.len(), 1); + assert_eq!(map["D9-1"].len(), 1); + }); + css::file_map::reset_canonical_map(); + css::file_map::reset_file_map(); + css::class_map::reset_class_map(); +} + +#[test] +#[serial] +fn provenance_join_leaves_original_owner_available_without_affecting_content() { + // Given + let mut value = ExtractStyleValue::Static(ExtractStaticStyle::new("color", "red", 0, None)); + let before = match &value { + ExtractStyleValue::Static(style) => style.content_name(), + other => panic!("{other:?}"), + }; + // When + value.join_naming(Naming::Risky); + value.set_style_order(0); + // Then + match value { + ExtractStyleValue::Static(style) => { + assert_eq!(style.counter_owner, CounterOwner::Inactive); + assert_ne!(style.content_name(), before); + assert_eq!(style.style_order, Some(0)); + } + other => panic!("{other:?}"), + } +} diff --git a/libs/extractor/src/extract_style/policy_capture_tests.rs b/libs/extractor/src/extract_style/policy_capture_tests.rs new file mode 100644 index 000000000..0a7b9177a --- /dev/null +++ b/libs/extractor/src/extract_style/policy_capture_tests.rs @@ -0,0 +1,180 @@ +use css::{CounterOwner, Naming}; +use rstest::rstest; + +use super::{ + ProducerPolicy, + extract_dynamic_style::ExtractDynamicStyle, + extract_keyframes::ExtractKeyframes, + extract_static_style::{ExtractStaticStyle, ThemeTokenResolution}, + extract_style_value::ExtractStyleValue, + policy_test_support::trace, +}; +use crate::sparse_sites::{SiteScope, producer_policy, retain_folded_owner, site_at, site_errors}; + +#[test] +fn constructors_capture_original_when_basic_layered_dynamic_and_empty_records_are_built() { + // Given: only the test counter scope selects the dormant construction input. + let _scope = SiteScope::enter_counter_numbered(7, "tone", &[]); + // When: every constructor/default family is used without a variable site or frame member. + let records = [ + ExtractStyleValue::Static(ExtractStaticStyle::new("color", "red", 0, None)), + ExtractStyleValue::Static(ExtractStaticStyle::new_basic("color", "red", 0, None)), + ExtractStyleValue::Static(ExtractStaticStyle::new_with_layer( + "color", + "red", + 0, + None, + Some("theme".into()), + )), + ExtractStyleValue::Dynamic(ExtractDynamicStyle::new("color", 0, "tone", None)), + ExtractStyleValue::Keyframes(ExtractKeyframes::default()), + ]; + // Then: construction, not site/member inference, retains the original in each kind. + for record in records { + let policy = match record { + ExtractStyleValue::Static(style) => style.producer_policy(), + ExtractStyleValue::Dynamic(style) => { + assert_eq!(style.counter_owner(), CounterOwner::Inactive); + style.producer_policy() + } + ExtractStyleValue::Keyframes(frames) => { + assert_eq!(frames.keyframes.len(), 0); + assert_eq!(frames.origin.0, None); + assert_eq!(frames.origin.1, None); + frames.producer_policy() + } + ExtractStyleValue::Typography(_) + | ExtractStyleValue::Css(_) + | ExtractStyleValue::Import(_) + | ExtractStyleValue::FontFace(_) => panic!("unexpected constructor record"), + }; + assert_eq!(policy, ProducerPolicy::CounterOriginal(7)); + } +} + +#[rstest] +#[case(false)] +#[case(true)] +fn ordinary_scopes_capture_current_when_nested_inside_counter_scope(#[case] numbered: bool) { + // Given: an outer counter context with deferred records. + let outer = SiteScope::enter_counter_numbered(7, "outer", &[]); + let retained = ExtractStaticStyle::new("color", "red", 0, None); + let before = trace(&retained); + // When: either ordinary scope path is entered and dropped. + let current = { + let _inner = if numbered { + SiteScope::enter_numbered(9, "inner", &[]) + } else { + SiteScope::enter("policy-current.tsx", "inner", &[]) + }; + assert_eq!(producer_policy(), ProducerPolicy::Current); + assert_eq!(trace(&retained), before); + ( + ExtractStaticStyle::new("color", "red", 0, None), + ExtractDynamicStyle::new("color", 0, "tone", None), + ExtractKeyframes::default(), + ) + }; + drop(outer); + // Then: existing constructors stay Current, old records stay counter, absent scope is Current. + assert_eq!(current.0.producer_policy(), ProducerPolicy::Current); + assert_eq!(current.1.producer_policy(), ProducerPolicy::Current); + assert_eq!(current.2.producer_policy(), ProducerPolicy::Current); + assert_eq!( + retained.producer_policy(), + ProducerPolicy::CounterOriginal(7) + ); + assert_eq!(producer_policy(), ProducerPolicy::Current); + assert_eq!(trace(&retained), before); +} + +#[test] +fn nested_counter_scope_restores_sites_and_policy_when_inner_context_ends() { + // Given: outer folded ownership and a pending source-role error. + let _outer = SiteScope::enter_counter_numbered(7, "abcdefghijkl", &[]); + retain_folded_owner(10, 2); + let _ = site_at(10, 0, "outer"); + let _ = site_at(10, 0, "conflict"); + // When: a nested counter context constructs a record and is dropped. + let deferred = { + let _inner = SiteScope::enter_counter_numbered(9, "inner", &[]); + assert_eq!(site_errors(), vec![]); + ExtractDynamicStyle::new("color", 0, "tone", None).at(0) + }; + // Then: outer site/error state restores, while the record retains its inner construction owner. + assert_eq!(producer_policy(), ProducerPolicy::CounterOriginal(7)); + assert_eq!(site_at(10, 0, "outer").map(|site| site.at), Some(2)); + assert_eq!(site_errors().len(), 1); + assert_eq!( + deferred.producer_policy(), + ProducerPolicy::CounterOriginal(9) + ); + assert_eq!(deferred.counter_owner(), CounterOwner::D9(9)); +} + +#[test] +fn site_attachment_preserves_capture_when_other_scope_normalizes_authored_positions() { + // Given: a consumer is built before receiving a site under another context. + let style = { + let _scope = SiteScope::enter_counter_numbered(7, "tone", &[]); + ExtractDynamicStyle::new("color", 0, "tone", None) + }; + let edits = [(0, 0, 10)]; + let _other = SiteScope::enter_counter_numbered(9, "\u{feff}a\r\nb", &[&edits]); + // When: a source-ordered role is attached with BOM/CR and edit normalization. + let placed = style.at_role(16, 1); + // Then: variable-site identity is normalized without replacing producer policy. + let site = placed + .site() + .unwrap_or_else(|| panic!("active fixture scope")); + assert_eq!((site.at, site.role), (2, 1)); + assert_eq!(placed.counter_owner(), CounterOwner::D9(9)); + assert_eq!(placed.producer_policy(), ProducerPolicy::CounterOriginal(7)); +} + +#[test] +fn record_edits_preserve_capture_when_identifier_order_and_provenance_change() { + // Given: deferred static and dynamic records have retained one original. + let (style, mut dynamic) = { + let _scope = SiteScope::enter_counter_numbered(7, "tone", &[]); + ( + ExtractStaticStyle::new("color", "$text", 0, None), + ExtractDynamicStyle::new("color", 0, "tone", None), + ) + }; + // When: ordinary record edits happen after the scope has ended. + let style = style + .with_naming(Naming::Risky) + .with_theme_token_resolution(ThemeTokenResolution::FirstValue); + dynamic.replace_identifier("other"); + let mut value = ExtractStyleValue::Dynamic(dynamic); + value.join_naming(Naming::Risky); + value.set_style_order(0); + let ExtractStyleValue::Dynamic(dynamic) = value else { + panic!("dynamic fixture"); + }; + // Then: no setter, source site or current context rewrites the capture. + assert_eq!(style.producer_policy(), ProducerPolicy::CounterOriginal(7)); + assert_eq!( + dynamic.producer_policy(), + ProducerPolicy::CounterOriginal(7) + ); + assert_eq!(dynamic.identifier(), "other"); + assert_eq!(dynamic.style_order(), Some(0)); + assert_eq!(dynamic.site(), None); +} + +#[test] +fn assignment_site_preserves_capture_when_consumer_is_attached_in_another_scope() { + // Given: a consumer already belongs to one original before assignment ownership is attached. + let style = { + let _scope = SiteScope::enter_counter_numbered(7, "tone", &[]); + ExtractDynamicStyle::new("color", 0, "tone", None) + }; + let _scope = SiteScope::enter_numbered(9, "tone", &[]); + // When: the assignment-site adapter fills the consumer's source location. + let placed = style.with_assignment_site(0); + // Then: the variable site follows the assignment but immutable producer authority is retained. + assert_eq!(placed.counter_owner(), CounterOwner::D9(9)); + assert_eq!(placed.producer_policy(), ProducerPolicy::CounterOriginal(7)); +} diff --git a/libs/extractor/src/extract_style/policy_deferred_tests.rs b/libs/extractor/src/extract_style/policy_deferred_tests.rs new file mode 100644 index 000000000..956d7c1ff --- /dev/null +++ b/libs/extractor/src/extract_style/policy_deferred_tests.rs @@ -0,0 +1,120 @@ +use oxc_allocator::Allocator; +use rstest::rstest; + +use super::{ + ProducerPolicy, extract_dynamic_style::ExtractDynamicStyle, + extract_keyframes::ExtractKeyframes, extract_static_style::ExtractStaticStyle, + extract_style_value::ExtractStyleValue, policy_test_support::expression, +}; +use crate::{ExtractStyleProp, sparse_sites::SiteScope}; + +#[derive(Clone, Copy, Debug)] +enum Wrapper { + Array, + Evaluated, + Expression, + Member, + Enum, + Conditional, +} + +fn wrap( + kind: Wrapper, + styles: Vec, + allocator: &Allocator, +) -> ExtractStyleProp<'_> { + let source = expression(allocator, "tone;"); + let props = || { + styles + .iter() + .cloned() + .map(ExtractStyleProp::Static) + .collect::>() + }; + match kind { + Wrapper::Array => ExtractStyleProp::StaticArray(props()), + Wrapper::Evaluated => ExtractStyleProp::Evaluated { + styles: props(), + source, + binding: "captured".into(), + evaluation: None, + alternate_order: None, + alternate_class: false, + }, + Wrapper::Expression => ExtractStyleProp::Expression { + styles, + expression: source, + }, + Wrapper::Member => ExtractStyleProp::MemberExpression { + map: [( + "selected".into(), + Box::new(ExtractStyleProp::StaticArray(props())), + )] + .into(), + expression: source, + }, + Wrapper::Enum => ExtractStyleProp::Enum { + condition: source, + map: [("selected".into(), props())].into(), + }, + Wrapper::Conditional => ExtractStyleProp::Conditional { + condition: source, + consequent: Some(Box::new(ExtractStyleProp::StaticArray(props()))), + alternate: None, + }, + } +} + +#[rstest] +#[case(Wrapper::Array)] +#[case(Wrapper::Evaluated)] +#[case(Wrapper::Expression)] +#[case(Wrapper::Member)] +#[case(Wrapper::Enum)] +#[case(Wrapper::Conditional)] +fn deferred_records_retain_policy_when_wrapper_is_cloned_and_extracted(#[case] kind: Wrapper) { + // Given: mixed originals, including a no-site dynamic and empty keyframes. + let allocator = Allocator::default(); + let records = { + let _outer = SiteScope::enter_counter_numbered(7, "outer", &[]); + let style = + ExtractStyleValue::Static(ExtractStaticStyle::new_basic("color", "red", 0, None)); + let dynamic = { + let _inner = SiteScope::enter_counter_numbered(9, "inner", &[]); + ExtractStyleValue::Dynamic(ExtractDynamicStyle::new("color", 0, "tone", None)) + }; + vec![ + style, + dynamic, + ExtractStyleValue::Keyframes(ExtractKeyframes::default()), + ] + }; + let wrapped = wrap(kind, records, &allocator); + let _current = SiteScope::enter_numbered(42, "current", &[]); + // When: AST cloning plus borrowed/owning extraction occurs under a different Current context. + let cloned = wrapped.clone_in(&allocator); + let borrowed = cloned.extract(); + let owned = cloned.into_extract(); + // Then: recursive projections preserve original captures and diagnostic/site absence. + assert_eq!(borrowed, owned); + assert_eq!(borrowed.len(), 3); + let expected = [7, 9, 7]; + for (record, original) in borrowed.iter().zip(expected) { + let policy = match record { + ExtractStyleValue::Static(style) => style.producer_policy(), + ExtractStyleValue::Dynamic(style) => { + assert_eq!(style.site(), None); + style.producer_policy() + } + ExtractStyleValue::Keyframes(frames) => { + assert_eq!(frames.keyframes.len(), 0); + frames.producer_policy() + } + ExtractStyleValue::Typography(_) + | ExtractStyleValue::Css(_) + | ExtractStyleValue::Import(_) + | ExtractStyleValue::FontFace(_) => panic!("unexpected deferred record"), + }; + assert_eq!(policy, ProducerPolicy::CounterOriginal(original)); + } +} diff --git a/libs/extractor/src/extract_style/policy_literal_tests.rs b/libs/extractor/src/extract_style/policy_literal_tests.rs new file mode 100644 index 000000000..d2cbc85e1 --- /dev/null +++ b/libs/extractor/src/extract_style/policy_literal_tests.rs @@ -0,0 +1,217 @@ +use css::{ + file_map::{get_or_insert_original_id, reset_file_map}, + style_selector::StyleSelector, +}; +use oxc_allocator::Allocator; +use oxc_ast::{ast::Expression, builder::AstBuilder}; +use oxc_ast_visit::VisitMut; +use oxc_parser::Parser; +use oxc_span::SourceType; +use rstest::rstest; +use serial_test::serial; + +use super::{ + ProducerPolicy, + extract_style_value::ExtractStyleValue, + policy_test_support::{expression, set_lengths}, +}; +use crate::{ + ExtractStyleProp, + extractor::{ + extract_keyframes_from_expression::extract_keyframes_from_expression, + extract_style_from_expression::{ + LiteralHandling, dynamic_style, extract_style_from_expression, + }, + extract_style_from_stylex::extract_stylex_namespace_styles, + }, + sparse_sites::SiteScope, + visit::DevupVisitor, +}; + +#[test] +#[serial] +fn conditional_typography_literals_retain_originals_when_real_branches_are_extracted() { + // Given: registered originals and actual preset branches, not manually built typography atoms. + reset_file_map(); + let ids = ["policy-typo-a.tsx", "policy-typo-b.tsx"] + .map(|name| get_or_insert_original_id(name).unwrap_or_else(|_| panic!("register fixture"))); + let previous = css::theme_tokens::get_typography_keys(); + css::theme_tokens::set_typography_keys(vec!["body".into(), "title".into()]); + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let selector = Some(StyleSelector::from("&:hover")); + let mut records = vec![]; + // When: the real literal and dynamic typography paths construct their conditional atoms. + for original in ids { + let _scope = SiteScope::enter_counter_numbered(original, "tone", &[]); + let mut literal = expression(&allocator, "('body');"); + records.extend( + extract_style_from_expression( + &builder, + Some("typography"), + &mut literal, + 2, + &selector, + LiteralHandling::ExpandResponsiveThemeToken, + ) + .styles + .into_iter() + .flat_map(ExtractStyleProp::into_extract), + ); + let condition = expression(&allocator, "tone;"); + records + .extend(dynamic_style(&builder, "typography", &condition, 2, &selector).into_extract()); + } + // Then: each owner retains both presets under the original selector/level; duplicates dedup only within an owner. + assert_eq!(records.len(), 6); + assert_eq!(set_lengths(&records), (4, 4)); + for (original, records) in ids.into_iter().zip(records.as_chunks::<3>().0) { + for record in records { + let ExtractStyleValue::Static(style) = record else { + panic!("conditional typography atom"); + }; + assert_eq!( + style.producer_policy(), + ProducerPolicy::CounterOriginal(original) + ); + assert_eq!((style.property(), style.level()), ("typography", 2)); + assert_eq!(style.selector(), selector.as_ref()); + assert!(matches!(style.value(), "body" | "title")); + } + } + css::theme_tokens::set_typography_keys(previous); + reset_file_map(); +} + +#[test] +#[serial] +fn raw_stylex_literals_retain_originals_when_numeric_css_bypasses_devup_scaling() { + // Given: raw StyleX numbers would be scaled incorrectly by replacing its literal with new(). + reset_file_map(); + let ids = ["policy-stylex-a.tsx", "policy-stylex-b.tsx"] + .map(|name| get_or_insert_original_id(name).unwrap_or_else(|_| panic!("register fixture"))); + let allocator = Allocator::default(); + let source = "({card:{paddingLeft:4}});"; + let parsed = expression(&allocator, source); + let Expression::ObjectExpression(object) = crate::utils::unwrap_syntax_only(&parsed) else { + panic!("StyleX object"); + }; + let mut records = vec![]; + let mut errors = vec![]; + // When: real StyleX namespaces are extracted before the union. + for original in ids { + let _scope = SiteScope::enter_counter_numbered(original, source, &[]); + for (_, styles, _, _) in extract_stylex_namespace_styles( + object, + &Default::default(), + &Default::default(), + &mut errors, + ) { + records.extend(styles.into_iter().flat_map(ExtractStyleProp::into_extract)); + } + } + // Then: both raw 4px declarations retain distinct originals, rather than scaled 16px. + assert_eq!(errors, vec![]); + assert_eq!(set_lengths(&records), (2, 2)); + assert_eq!(records.len(), 2); + for (record, original) in records.iter().zip(ids) { + let ExtractStyleValue::Static(style) = record else { + panic!("raw StyleX atom"); + }; + assert_eq!((style.property(), style.value()), ("padding-left", "4px")); + assert_eq!( + style.producer_policy(), + ProducerPolicy::CounterOriginal(original) + ); + } + reset_file_map(); +} + +#[rstest] +#[case("({});", 0)] +#[case("tone;", 0)] +#[case("({to:{opacity:1},from:{opacity:0}});", 2)] +fn object_keyframes_capture_parent_and_children_when_real_expression_is_read( + #[case] source: &str, + #[case] steps: usize, +) { + // Given: empty, unreadable and populated object-expression keyframe inputs. + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let mut input = expression(&allocator, source); + let _scope = SiteScope::enter_counter_numbered(7, source, &[]); + // When: the actual extractor constructs its default then fills ordered steps. + let frames = extract_keyframes_from_expression(&builder, &mut input).keyframes; + // Then: the parent retains an original even without steps; children retain the same capture. + assert_eq!(frames.producer_policy(), ProducerPolicy::CounterOriginal(7)); + assert_eq!(frames.keyframes.len(), steps); + for styles in frames.keyframes.values() { + assert_eq!(styles.len(), 1); + assert_eq!( + styles[0].producer_policy(), + ProducerPolicy::CounterOriginal(7) + ); + assert_eq!(styles[0].property(), "opacity"); + } + if steps == 2 { + assert_eq!( + frames + .keyframes + .keys() + .map(String::as_str) + .collect::>(), + vec!["from", "to"] + ); + } +} + +#[rstest] +#[case("", 0)] +#[case("from { opacity: 0; } to { opacity: 1; }", 2)] +#[serial] +fn template_keyframe_literal_captures_original_when_visitor_inserts_record( + #[case] css: &str, + #[case] steps: usize, +) { + // Given: registered original authority and the actual tagged-template visitor path. + reset_file_map(); + css::class_map::reset_class_map(); + let original = get_or_insert_original_id("policy-frames.tsx") + .unwrap_or_else(|_| panic!("register fixture")); + let source = + format!("import {{keyframes}} from '@devup-ui/react'; const frames=keyframes`{css}`;"); + let allocator = Allocator::default(); + let mut parsed = Parser::new(&allocator, &source, SourceType::ts()).parse(); + assert_eq!(parsed.diagnostics.len(), 0); + let _scope = SiteScope::enter_counter_numbered(original, &source, &[]); + let mut visitor = DevupVisitor::new( + &allocator, + "policy-frames.tsx", + "@devup-ui/react", + vec![], + None, + ); + // When: the visitor constructs and inserts template frames through the real literal seam. + visitor.visit_program(&mut parsed.program); + // Then: even an empty template retains the captured parent original before set insertion. + assert_eq!(visitor.errors, vec![]); + assert_eq!(visitor.styles.len(), 1); + let Some(ExtractStyleValue::Keyframes(frames)) = visitor.styles.iter().next() else { + panic!("template frames"); + }; + assert_eq!( + frames.producer_policy(), + ProducerPolicy::CounterOriginal(original) + ); + assert_eq!(frames.keyframes.len(), steps); + for styles in frames.keyframes.values() { + for style in styles { + assert_eq!( + style.producer_policy(), + ProducerPolicy::CounterOriginal(original) + ); + } + } + reset_file_map(); + css::class_map::reset_class_map(); +} diff --git a/libs/extractor/src/extract_style/policy_test_support.rs b/libs/extractor/src/extract_style/policy_test_support.rs new file mode 100644 index 000000000..589ff21f2 --- /dev/null +++ b/libs/extractor/src/extract_style/policy_test_support.rs @@ -0,0 +1,156 @@ +use std::{ + cmp::Ordering, + collections::BTreeMap, + hash::{Hash, Hasher}, +}; + +use css::{CounterOwner, Naming, Site, style_origin::Origin, style_selector::StyleSelector}; +use oxc_allocator::{Allocator, CloneIn}; +use oxc_ast::ast::{Expression, Statement}; +use oxc_parser::Parser; +use oxc_span::SourceType; +use rustc_hash::FxHashSet; + +use super::{ + extract_dynamic_style::ExtractDynamicStyle, + extract_keyframes::ExtractKeyframes, + extract_static_style::{ExtractStaticStyle, ThemeTokenResolution}, +}; + +#[derive(Default)] +struct RecordingHasher(Vec>); + +impl Hasher for RecordingHasher { + fn finish(&self) -> u64 { + 0 + } + fn write(&mut self, bytes: &[u8]) { + self.0.push(bytes.to_vec()); + } +} + +pub(super) fn trace(value: &impl Hash) -> (Vec>, u64) { + let mut state = RecordingHasher::default(); + value.hash(&mut state); + let finish = state.finish(); + (state.0, finish) +} + +#[derive(PartialEq, Eq, Hash, PartialOrd, Ord)] +pub(super) struct OldStatic<'a> { + property: &'a str, + value: &'a str, + level: u8, + selector: &'a Option, + order: Option, + layer: &'a Option, + resolution: ThemeTokenResolution, + naming: Naming, + owner: CounterOwner, +} + +pub(super) fn old_static(style: &ExtractStaticStyle) -> OldStatic<'_> { + OldStatic { + property: &style.property, + value: &style.value, + level: style.level, + selector: &style.selector, + order: style.style_order, + layer: &style.layer, + resolution: style.theme_token_resolution, + naming: style.naming, + owner: if style.naming == Naming::Own && style.style_order != Some(0) { + style.counter_owner + } else { + CounterOwner::Inactive + }, + } +} + +#[derive(PartialEq, Eq, Hash, PartialOrd, Ord)] +pub(super) struct OldDynamic<'a> { + property: &'a str, + level: u8, + identifier: &'a str, + selector: Option<&'a StyleSelector>, + order: Option, + important: bool, + layer: &'a Option, + naming: Naming, + site: &'a Option, + origin: &'a Origin, +} + +pub(super) fn old_dynamic(style: &ExtractDynamicStyle) -> OldDynamic<'_> { + OldDynamic { + property: style.property(), + level: style.level(), + identifier: style.identifier(), + selector: style.selector(), + order: style.style_order(), + important: style.important(), + layer: &style.layer, + naming: style.naming, + site: &style.site, + origin: &style.origin, + } +} + +#[derive(PartialEq, Eq, Hash, PartialOrd, Ord)] +pub(super) struct OldKeyframes<'a> { + keyframes: BTreeMap>>, + origin: &'a Origin, +} + +pub(super) fn old_keyframes(frames: &ExtractKeyframes) -> OldKeyframes<'_> { + OldKeyframes { + keyframes: frames + .keyframes + .iter() + .map(|(step, styles)| (step.clone(), styles.iter().map(old_static).collect())) + .collect(), + origin: &frames.origin, + } +} + +pub(super) fn set_lengths(styles: &[T]) -> (usize, usize) { + ( + styles.iter().cloned().collect::>().len(), + styles + .iter() + .cloned() + .collect::>() + .len(), + ) +} + +pub(super) fn assert_laws(values: &[T]) { + for left in values { + for right in values { + let order = left.cmp(right); + assert_eq!(left.partial_cmp(right), Some(order)); + assert_eq!(left == right, order == Ordering::Equal); + assert_eq!(order, right.cmp(left).reverse()); + if left == right { + assert_eq!(trace(left), trace(right)); + } + for third in values { + if left <= right && right <= third { + assert!(left <= third); + } + } + } + } + let mut reversed = values.to_vec(); + reversed.reverse(); + assert_eq!(set_lengths(values), set_lengths(&reversed)); +} + +pub(super) fn expression<'a>(allocator: &'a Allocator, source: &'a str) -> Expression<'a> { + let parsed = Parser::new(allocator, source, SourceType::ts()).parse(); + assert_eq!(parsed.diagnostics.len(), 0); + let Some(Statement::ExpressionStatement(statement)) = parsed.program.body.first() else { + panic!("fixture expression statement"); + }; + statement.expression.clone_in(allocator) +} diff --git a/libs/extractor/src/extract_style/producer_policy.rs b/libs/extractor/src/extract_style/producer_policy.rs new file mode 100644 index 000000000..1e1749d6e --- /dev/null +++ b/libs/extractor/src/extract_style/producer_policy.rs @@ -0,0 +1,17 @@ +/// Identity policy captured when a producer constructs an extractor record. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ProducerPolicy { + /// Preserve the production identity and naming contract. + Current, + /// Retain an original numeric owner for the dormant counter path. + CounterOriginal(u32), +} + +impl ProducerPolicy { + pub(super) const fn declaration_owner(original: u32, order: Option) -> Option { + match order { + Some(0) => None, + Some(_) | None => Some(original), + } + } +} diff --git a/libs/extractor/src/extract_style/static_identity.rs b/libs/extractor/src/extract_style/static_identity.rs new file mode 100644 index 000000000..7f0a3ed32 --- /dev/null +++ b/libs/extractor/src/extract_style/static_identity.rs @@ -0,0 +1,114 @@ +use std::{ + cmp::Ordering, + hash::{Hash, Hasher}, +}; + +use css::{CounterOwner, Naming, style_selector::StyleSelector}; + +use super::ProducerPolicy; +use super::counter_selector::CounterSelector; +use super::extract_static_style::{ExtractStaticStyle, ThemeTokenResolution}; + +#[derive(PartialEq, Eq, Hash, PartialOrd, Ord)] +struct Identity<'a> { + property: &'a str, + value: &'a str, + level: u8, + selector: &'a Option, + order: Option, + layer: &'a Option, + resolution: ThemeTokenResolution, + naming: Naming, + owner: CounterOwner, +} + +#[derive(PartialEq, Eq, Hash, PartialOrd, Ord)] +struct CounterIdentity<'a> { + property: &'a str, + value: &'a str, + level: u8, + selector: CounterSelector<'a>, + order: Option, + layer: &'a Option, + resolution: ThemeTokenResolution, + owner: Option, +} + +impl ExtractStaticStyle { + fn counter_identity(&self, original: u32) -> CounterIdentity<'_> { + CounterIdentity { + property: &self.property, + value: &self.value, + level: self.level, + selector: CounterSelector(&self.selector), + order: self.style_order, + layer: &self.layer, + resolution: self.theme_token_resolution, + owner: ProducerPolicy::declaration_owner(original, self.style_order), + } + } + + fn identity(&self) -> Identity<'_> { + Identity { + property: &self.property, + value: &self.value, + level: self.level, + selector: &self.selector, + order: self.style_order, + layer: &self.layer, + resolution: self.theme_token_resolution, + naming: self.naming, + owner: if self.naming == Naming::Own && self.style_order != Some(0) { + self.counter_owner + } else { + CounterOwner::Inactive + }, + } + } +} + +impl PartialEq for ExtractStaticStyle { + fn eq(&self, other: &Self) -> bool { + match (self.producer_policy(), other.producer_policy()) { + (ProducerPolicy::Current, ProducerPolicy::Current) => { + self.identity() == other.identity() + } + (ProducerPolicy::Current, ProducerPolicy::CounterOriginal(_)) + | (ProducerPolicy::CounterOriginal(_), ProducerPolicy::Current) => false, + (ProducerPolicy::CounterOriginal(left), ProducerPolicy::CounterOriginal(right)) => { + self.counter_identity(left) == other.counter_identity(right) + } + } + } +} +impl Eq for ExtractStaticStyle {} +impl Hash for ExtractStaticStyle { + fn hash(&self, state: &mut H) { + match self.producer_policy() { + ProducerPolicy::Current => self.identity().hash(state), + ProducerPolicy::CounterOriginal(original) => { + 1u8.hash(state); + self.counter_identity(original).hash(state); + } + } + } +} +impl PartialOrd for ExtractStaticStyle { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} +impl Ord for ExtractStaticStyle { + fn cmp(&self, other: &Self) -> Ordering { + match (self.producer_policy(), other.producer_policy()) { + (ProducerPolicy::Current, ProducerPolicy::Current) => { + self.identity().cmp(&other.identity()) + } + (ProducerPolicy::Current, ProducerPolicy::CounterOriginal(_)) => Ordering::Less, + (ProducerPolicy::CounterOriginal(_), ProducerPolicy::Current) => Ordering::Greater, + (ProducerPolicy::CounterOriginal(left), ProducerPolicy::CounterOriginal(right)) => self + .counter_identity(left) + .cmp(&other.counter_identity(right)), + } + } +} diff --git a/libs/extractor/src/extractor/extract_keyframes_from_expression.rs b/libs/extractor/src/extractor/extract_keyframes_from_expression.rs index ae468ce3d..8ab35fddd 100644 --- a/libs/extractor/src/extractor/extract_keyframes_from_expression.rs +++ b/libs/extractor/src/extractor/extract_keyframes_from_expression.rs @@ -16,12 +16,16 @@ use oxc_ast::{ ast::{Expression, ObjectPropertyKind}, builder::AstBuilder, }; +use oxc_span::GetSpan; pub fn extract_keyframes_from_expression<'a>( ast_builder: &AstBuilder<'a>, expression: &mut Expression<'a>, ) -> KeyframesExtractResult { - let mut keyframes = ExtractKeyframes::default(); + let mut keyframes = ExtractKeyframes { + origin: crate::style_origin::for_span(expression.span()), + ..ExtractKeyframes::default() + }; let Expression::ObjectExpression(obj) = unwrap_syntax_only_mut(expression) else { return KeyframesExtractResult { keyframes, diff --git a/libs/extractor/src/extractor/extract_style_from_expression.rs b/libs/extractor/src/extractor/extract_style_from_expression.rs index 4b8f7f27b..5ebd161e0 100644 --- a/libs/extractor/src/extractor/extract_style_from_expression.rs +++ b/libs/extractor/src/extractor/extract_style_from_expression.rs @@ -9,6 +9,7 @@ use crate::{ extractor::{ ExtractResult, extract_style_from_member_expression::extract_style_from_member_expression, }, + provenance::{join_naming, mark, provenance_of}, utils::{ expression_to_code, get_number_by_literal_expression, get_str_by_property_key, get_string_by_literal_expression, get_string_by_property_key, is_same_expression, @@ -16,7 +17,7 @@ use crate::{ }, }; use css::{ - add_selector_params, + Naming, add_selector_params, at_rule::{media_shorthand_query, split_at_rule_key}, disassemble_property, get_enum_property_map, get_enum_property_value, is_enum_property, is_special_property::is_special_property, @@ -35,6 +36,13 @@ use oxc_ast::{ use oxc_span::{GetSpan, SPAN}; use std::collections::BTreeMap; +mod boolean_expression; +#[cfg(test)] +mod boolean_or_tests; +mod class_only_expression; +#[cfg(test)] +mod typography_class_tests; + const IGNORED_IDENTIFIERS: [&str; 3] = ["undefined", "NaN", "Infinity"]; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -107,7 +115,11 @@ pub(crate) fn flatten_spreads<'a>(ast_builder: &AstBuilder<'a>, object: &mut Obj &mut inner.properties, oxc_allocator::Vec::new_in(ast_builder), ); - for property in spread_properties { + let naming = provenance_of(unwrap_syntax_only(&spread.argument)); + for mut property in spread_properties { + if let ObjectPropertyKind::ObjectProperty(property) = &mut property { + mark(&mut property.value, naming); + } push_replacing(&mut object.properties, property); } } else { @@ -203,6 +215,9 @@ fn create_static_styles<'a>( styles } +/// The styles `expression` gives, each carrying what its value depends on so +/// production class names stay the same wherever an import resolves +/// differently. pub fn extract_style_from_expression<'a>( ast_builder: &AstBuilder<'a>, name: Option<&str>, @@ -210,10 +225,101 @@ pub fn extract_style_from_expression<'a>( level: u8, selector: &Option, literal_handling: LiteralHandling, +) -> ExtractResult<'a> { + let naming = provenance_of(expression); + let origin = crate::style_origin::for_span(expression.span()); + let _origin = crate::style_origin::CurrentOrigin::enter(expression.span()); + let owns_assignment = !crate::assignment_owner::AssignmentOwner::active() + && name.is_some_and(|name| { + !matches!(name, "as" | "typography" | "selectors") + && get_enum_property_map(name).is_none() + }) + && matches!( + unwrap_syntax_only(expression), + Expression::ConditionalExpression(_) + | Expression::LogicalExpression(_) + | Expression::ArrayExpression(_) + | Expression::ComputedMemberExpression(_) + ); + let source = owns_assignment.then(|| expression.clone_in(ast_builder.allocator())); + let _assignment = (owns_assignment + && name.is_some_and(|name| !name.starts_with(['_', '@']) && !is_nested_selector_key(name))) + .then(|| crate::assignment_owner::AssignmentOwner::enter(expression)); + let mut result = extract_style_values( + ast_builder, + name, + expression, + level, + selector, + literal_handling, + ); + if naming != Naming::Own { + join_naming(&mut result.styles, naming); + } + crate::style_origin::fill(&mut result.styles, &origin); + if let Some(source) = source { + if result.styles.is_empty() { + return result; + } + if crate::static_assignment::literal_source(&source) + && result + .styles + .iter() + .all(crate::static_assignment::class_only) + { + return result; + } + if !matches!(unwrap_syntax_only(&source), Expression::ArrayExpression(_)) + && let Some(scalar) = crate::assignment_owner::scalar(&source, &result.styles) + { + result.styles = vec![ExtractStyleProp::Static(scalar)]; + return result; + } + result.styles = vec![ExtractStyleProp::Evaluated { + binding: crate::sparse_sites::binding_name(source.span().start), + styles: result.styles, + source, + evaluation: None, + alternate_order: None, + alternate_class: false, + }]; + } + result +} + +fn extract_style_values<'a>( + ast_builder: &AstBuilder<'a>, + name: Option<&str>, + expression: &mut Expression<'a>, + level: u8, + selector: &Option, + literal_handling: LiteralHandling, ) -> ExtractResult<'a> { let mut typo = false; let expression = unwrap_syntax_only_mut(expression); + if let Some(name) = name + && (name == "typography" || get_enum_property_map(name).is_some()) + && class_only_expression::runtime_scalar(expression) + && get_string_by_literal_expression(expression).is_none() + && !matches!( + expression, + Expression::BooleanLiteral(_) | Expression::NullLiteral(_) + ) + && !matches!(expression, Expression::Identifier(value) if IGNORED_IDENTIFIERS.contains(&value.name.as_str())) + { + return ExtractResult { + styles: vec![dynamic_style( + ast_builder, + name, + expression, + level, + selector, + )], + ..ExtractResult::default() + }; + } + if name.is_none() && selector.is_none() { let mut style_order = None; let mut style_vars = None; @@ -494,6 +600,9 @@ pub fn extract_style_from_expression<'a>( } typo = name == "typography"; } + if matches!(expression, Expression::BooleanLiteral(_)) { + return ExtractResult::default(); + } if let Some(value) = get_string_by_literal_expression(expression) { if let Some(name) = name { ExtractResult { @@ -607,7 +716,7 @@ pub fn extract_style_from_expression<'a>( } else if typo { vec![ExtractStyleProp::Expression { expression: Expression::new_template_literal( - SPAN, + expression.span(), oxc_allocator::Vec::from_array_in( [ TemplateElement::new( @@ -657,66 +766,6 @@ pub fn extract_style_from_expression<'a>( }; if IGNORED_IDENTIFIERS.contains(&identifier.name.as_str()) { ExtractResult::default() - } else if typo - && let Some(style) = conditional_typography( - ast_builder, - &Expression::new_identifier(SPAN, identifier.name.as_str(), ast_builder), - level, - selector, - ) - { - ExtractResult { - styles: vec![style], - ..ExtractResult::default() - } - } else if typo { - ExtractResult { - styles: vec![ExtractStyleProp::Expression { - expression: Expression::new_conditional_expression( - SPAN, - Expression::new_identifier( - SPAN, - identifier.name.as_str(), - ast_builder, - ), - Expression::new_template_literal( - SPAN, - oxc_allocator::Vec::from_array_in( - [ - TemplateElement::new( - SPAN, - TemplateElementValue { - raw: Str::from("typo-"), - cooked: None, - }, - false, - ast_builder, - ), - TemplateElement::new( - SPAN, - TemplateElementValue { - raw: Str::from(""), - cooked: None, - }, - true, - ast_builder, - ), - ], - ast_builder, - ), - oxc_allocator::Vec::from_array_in( - [expression.clone_in(ast_builder.allocator())], - ast_builder, - ), - ast_builder, - ), - Expression::new_string_literal(SPAN, "", None, ast_builder), - ast_builder, - ), - styles: vec![], - }], - ..ExtractResult::default() - } } else { ExtractResult { styles: vec![dynamic_style( @@ -788,7 +837,21 @@ pub fn extract_style_from_expression<'a>( LogicalOperator::Or => ExtractResult { styles: vec![ExtractStyleProp::Conditional { condition: logical.left.clone_in(ast_builder.allocator()), - consequent: None, + consequent: name + .filter(|_| !boolean_expression::is_boolean(&logical.left)) + .map(|_| { + Box::new(ExtractStyleProp::StaticArray( + extract_style_from_expression( + ast_builder, + name, + &mut logical.left, + level, + selector, + literal_handling, + ) + .styles, + )) + }), alternate: res, }], ..ExtractResult::default() @@ -1021,7 +1084,8 @@ pub fn extract_style_from_expression<'a>( level, &format!("({})", code.trim_end().trim_end_matches(';')), selector.clone(), - ), + ) + .with_assignment_site(expression.span().start), ))], ..ExtractResult::default() } @@ -1056,7 +1120,8 @@ pub(crate) fn place_in_layer(props: &mut [ExtractStyleProp<'_>], layer: &str) { match prop { ExtractStyleProp::Static(ExtractStyleValue::Static(style)) => nest(&mut style.layer), ExtractStyleProp::Static(ExtractStyleValue::Dynamic(style)) => nest(&mut style.layer), - ExtractStyleProp::StaticArray(props) => place_in_layer(props, layer), + ExtractStyleProp::StaticArray(props) + | ExtractStyleProp::Evaluated { styles: props, .. } => place_in_layer(props, layer), ExtractStyleProp::Conditional { consequent, alternate, @@ -1116,7 +1181,8 @@ fn declared_from(props: &[ExtractStyleProp<'_>]) -> Declared { }; Declared::from([((selector.cloned(), property), level)]) } - ExtractStyleProp::StaticArray(props) => declared_from(props), + ExtractStyleProp::StaticArray(props) + | ExtractStyleProp::Evaluated { styles: props, .. } => declared_from(props), ExtractStyleProp::Conditional { consequent, alternate, @@ -1185,7 +1251,8 @@ fn skip_declared(props: &mut [ExtractStyleProp<'_>], declared: &Declared) { style.value = format!("{}|{}", style.value, skipped.join(",")); } } - ExtractStyleProp::StaticArray(props) => skip_declared(props, declared), + ExtractStyleProp::StaticArray(props) + | ExtractStyleProp::Evaluated { styles: props, .. } => skip_declared(props, declared), ExtractStyleProp::Conditional { consequent, alternate, @@ -1285,6 +1352,10 @@ fn typography_atom(name: &str, level: u8, selector: &Option) -> E style_order: None, layer: None, theme_token_resolution: ThemeTokenResolution::default(), + naming: css::Naming::Own, + counter_owner: crate::sparse_sites::counter_owner(), + origin: crate::style_origin::current(), + producer_policy: crate::sparse_sites::producer_policy(), } } @@ -1333,7 +1404,14 @@ pub fn dynamic_style<'a>( level: u8, selector: &Option, ) -> ExtractStyleProp<'a> { - if let Some(map) = get_enum_property_map(name) { + if name == "typography" { + conditional_typography(ast_builder, expression, level, selector).unwrap_or_else(|| { + ExtractStyleProp::Expression { + expression: crate::element_evaluation::raw_typography(ast_builder, expression), + styles: vec![], + } + }) + } else if let Some(map) = get_enum_property_map(name) { ExtractStyleProp::Enum { condition: expression.clone_in(ast_builder.allocator()), map: map @@ -1353,12 +1431,15 @@ pub fn dynamic_style<'a>( .collect(), } } else { - ExtractStyleProp::Static(ExtractStyleValue::Dynamic(ExtractDynamicStyle::new( - name, - level, - &expression_to_code(expression), - selector.clone(), - ))) + ExtractStyleProp::Static(ExtractStyleValue::Dynamic( + ExtractDynamicStyle::new( + name, + level, + &expression_to_code(expression), + selector.clone(), + ) + .with_assignment_site(expression.span().start), + )) } } diff --git a/libs/extractor/src/extractor/extract_style_from_expression/boolean_expression.rs b/libs/extractor/src/extractor/extract_style_from_expression/boolean_expression.rs new file mode 100644 index 000000000..fc241a230 --- /dev/null +++ b/libs/extractor/src/extractor/extract_style_from_expression/boolean_expression.rs @@ -0,0 +1,82 @@ +use oxc_ast::ast::{Expression, UnaryOperator}; + +use crate::utils::unwrap_syntax_only; + +/// Prove a boolean result from syntax, without inferring identifier or call types. +pub(super) fn is_boolean(expression: &Expression<'_>) -> bool { + match unwrap_syntax_only(expression) { + Expression::BooleanLiteral(_) => true, + Expression::UnaryExpression(unary) => unary.operator == UnaryOperator::LogicalNot, + Expression::BinaryExpression(binary) => { + binary.operator.is_equality() + || binary.operator.is_compare() + || binary.operator.is_relational() + } + Expression::ConditionalExpression(conditional) => { + is_boolean(&conditional.consequent) && is_boolean(&conditional.alternate) + } + Expression::LogicalExpression(logical) => { + is_boolean(&logical.left) && is_boolean(&logical.right) + } + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::is_boolean; + use oxc_allocator::Allocator; + use oxc_ast::ast::Statement; + use oxc_parser::Parser; + use oxc_span::SourceType; + use rstest::rstest; + + #[rstest] + #[case("true", true)] + #[case("false", true)] + #[case("a == b", true)] + #[case("a != b", true)] + #[case("a === b", true)] + #[case("a !== b", true)] + #[case("a < b", true)] + #[case("a <= b", true)] + #[case("a > b", true)] + #[case("a >= b", true)] + #[case("a in b", true)] + #[case("a instanceof b", true)] + #[case("!a", true)] + #[case("!!a", true)] + #[case("((a === b) as boolean) satisfies boolean", true)] + #[case("(a === b)!", true)] + #[case("flag ? a === b : !a", true)] + #[case("a === b || !a", true)] + #[case("a === b && !a", true)] + #[case("a === b ?? !a", true)] + #[case("a", false)] + #[case("a as boolean", false)] + #[case("Boolean(a)", false)] + #[case("'true'", false)] + #[case("a + b", false)] + #[case("a & b", false)] + #[case("+a", false)] + #[case("a === b || '4px'", false)] + #[case("a === b && value", false)] + #[case("flag ? true : value", false)] + fn boolean_result_is_proven_when_syntax_guarantees_it( + #[case] expression: &str, + #[case] expected: bool, + ) { + // Given: parsed syntax, with no identifier-resolution assumptions. + let allocator = Allocator::default(); + let source = format!("({expression});"); + let parsed = Parser::new(&allocator, &source, SourceType::ts()).parse(); + assert_eq!(parsed.diagnostics.len(), 0); + let Statement::ExpressionStatement(statement) = &parsed.program.body[0] else { + panic!("expected expression statement"); + }; + // When: syntax-only wrappers are stripped for result classification. + let actual = is_boolean(&statement.expression); + // Then: only guaranteed booleans are excluded from CSS consumers. + assert_eq!(actual, expected); + } +} diff --git a/libs/extractor/src/extractor/extract_style_from_expression/boolean_or_tests.rs b/libs/extractor/src/extractor/extract_style_from_expression/boolean_or_tests.rs new file mode 100644 index 000000000..b89fb4115 --- /dev/null +++ b/libs/extractor/src/extractor/extract_style_from_expression/boolean_or_tests.rs @@ -0,0 +1,157 @@ +use crate::assignment_blocker_support::selected; +use crate::assignment_test_support::{compiled_jsx, evaluate, extracted}; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case("state.a === state.b || state.a", true, "[[\"a\",\"b\"],[],[]]")] +#[case( + "state.a === state.b || state.a", + false, + "[[\"a\",\"b\",\"a\"],[\"margin:9px:0\"],[\"9px\"]]" +)] +#[case("state.a === state.b || '1px'", true, "[[\"a\",\"b\"],[],[]]")] +#[case( + "state.a === state.b || '1px'", + false, + "[[\"a\",\"b\"],[\"margin:1px:0\"],[]]" +)] +#[case("[null, state.a === state.b || '4px']", true, "[[\"a\",\"b\"],[],[]]")] +#[case( + "[null, state.a === state.b || '4px']", + false, + "[[\"a\",\"b\"],[\"margin:4px:1\"],[]]" +)] +#[case( + "[null, state.a === state.b || state.a]", + true, + "[[\"a\",\"b\"],[],[]]" +)] +#[case( + "[null, state.a === state.b || state.a]", + false, + "[[\"a\",\"b\",\"a\"],[\"margin:9px:1\"],[\"9px\"]]" +)] +#[serial] +fn boolean_or_selects_no_css_when_left_is_true( + #[case] expression: &str, + #[case] flag: bool, + #[case] expected: &str, +) { + // Given: the three browser failures and their dynamic responsive counterpart. + let source = format!( + "import {{Box}}from '@devup-ui/react';function render(state){{return }}let trace=[];const node=render({{get a(){{trace.push('a');return '9px'}},get b(){{trace.push('b');return {flag}?'9px':'8px'}}}});" + ); + // When: emitted class and inline projections execute jointly in Boa. + let actual = selected( + &source, + "JSON.stringify([trace,assigned(node),Object.values(node.style??{})]);", + ); + // Then: true supplies neither consumer, false supplies only the RHS at its level. + assert_eq!(actual, expected); +} + +#[rstest] +#[case("!state.flag || state.rhs", false)] +#[case("!state.flag || state.rhs", true)] +#[case("((!state.flag) as boolean) || state.rhs", false)] +#[case("((!state.flag) as boolean) || state.rhs", true)] +#[serial] +fn boolean_or_keeps_rhs_lazy_when_controller_is_negated( + #[case] expression: &str, + #[case] flag: bool, +) { + // Given: an unselected RHS throws, so eager evaluation cannot pass. + let source = format!( + "import {{Box}}from '@devup-ui/react';function render(state){{return }}let trace=[];const node=render({{get flag(){{trace.push('flag');return {flag}}},get rhs(){{trace.push('rhs');if(!{flag})throw Error('unselected');return '7px'}}}});" + ); + // When: the real logical controller is evaluated exactly once. + let actual = selected(&source, "JSON.stringify([trace,assigned(node)]);"); + // Then: only a false left operand reads/selects the dynamic RHS. + let expected = if flag { + "[[\"flag\",\"rhs\"],[\"margin:7px:0\"]]" + } else { + "[[\"flag\"],[]]" + }; + assert_eq!(actual, expected); +} + +#[rstest] +#[case("'6px'", "[[\"left\"],[\"margin:6px:0\"]]")] +#[case("''", "[[\"left\",\"right\"],[\"margin:7px:0\"]]")] +#[serial] +fn value_or_retains_css_when_left_is_a_runtime_string(#[case] left: &str, #[case] expected: &str) { + // Given: a value-producing OR, not a boolean controller. + let source = format!( + "import {{Box}}from '@devup-ui/react';function render(state){{return }}let trace=[];const node=render({{get left(){{trace.push('left');return {left}}},get right(){{trace.push('right');return '7px'}}}});" + ); + // When: the source logical expression selects its actual CSS value. + let actual = selected(&source, "JSON.stringify([trace,assigned(node)]);"); + // Then: a truthy string remains CSS and each selected getter runs once. + assert_eq!(actual, expected); +} + +#[rstest] +#[case(true, "[2,0]")] +#[case(false, "[2,1]")] +#[serial] +fn static_css_boolean_or_compiles_when_rhs_is_a_literal( + #[case] flag: bool, + #[case] expected: &str, +) { + // Given: css() can know every possible declaration without a boolean variable rule. + let source = format!( + "import {{css}}from '@devup-ui/react';function render(state){{return css({{margin:state.a===state.b||'1px'}})}}let reads=0;const className=render({{get a(){{reads++;return 'x'}},get b(){{reads++;return {flag}?'x':'y'}}}});JSON.stringify([reads,String(className??'').split(/\\s+/).filter(Boolean).length]);" + ); + // When: extraction succeeds and its emitted class expression executes. + let actual = evaluate(&compiled_jsx(&source)); + // Then: no dynamic-css build error or true-branch class is introduced. + assert_eq!(actual, expected); +} + +#[rstest] +#[case("state.a === state.b || state.a", 0)] +#[case("[null, state.a === state.b || state.a]", 1)] +#[serial] +fn boolean_or_rhs_keeps_whole_source_site_when_responsive_slots_are_skipped( + #[case] expression: &str, + #[case] level: u8, +) { + // Given: one authored assignment, with a skipped level before the responsive RHS. + let source = format!( + "import {{Box}}from '@devup-ui/react';function render(state){{return }}" + ); + let at = source + .find(expression) + .unwrap_or_else(|| panic!("fixture source")); + // When: the remaining CSS consumer is extracted from the false branch. + let output = extracted(&source); + let sites = output + .styles + .iter() + .filter_map(|value| match value { + crate::ExtractStyleValue::Dynamic(style) => style + .site + .as_ref() + .map(|site| (site.at, site.role, style.level())), + _ => None, + }) + .collect::>(); + // Then: the RHS consumes the whole expression site and its original level role. + assert_eq!(sites, vec![(at, usize::from(level), level)]); +} + +#[rstest] +#[case("state.a === state.b || '1px'")] +#[case("[null, state.a === state.b || '4px']")] +#[serial] +fn boolean_or_preserves_existing_class_when_true_has_no_css(#[case] expression: &str) { + // Given: the authored existing class must keep supplying its prior margin rule. + let source = format!( + "import {{Box}}from '@devup-ui/react';function render(state){{return }}const node=render({{a:'x',b:'x'}});JSON.stringify([String(node.className).split(/\\s+/).filter(Boolean),Object.values(node.style??{{}})]);" + ); + // When: true selects the classless boolean branch. + let actual = evaluate(&compiled_jsx(&source)); + // Then: neither a generated margin rule nor an inline value overrides the prior class. + assert_eq!(actual, "[[\"prior\",\"exists\"],[]]"); +} diff --git a/libs/extractor/src/extractor/extract_style_from_expression/class_only_expression.rs b/libs/extractor/src/extractor/extract_style_from_expression/class_only_expression.rs new file mode 100644 index 000000000..9b3ec46a9 --- /dev/null +++ b/libs/extractor/src/extractor/extract_style_from_expression/class_only_expression.rs @@ -0,0 +1,25 @@ +use oxc_ast::ast::Expression; + +use crate::utils::unwrap_syntax_only; + +/// Scalar class selections retain their entire authored operand, rather than +/// separately evaluating a logical predicate and the value it selected. +pub(super) fn runtime_scalar(expression: &Expression<'_>) -> bool { + match unwrap_syntax_only(expression) { + Expression::LogicalExpression(value) => { + runtime_scalar(&value.left) && runtime_scalar(&value.right) + } + Expression::ComputedMemberExpression(value) => !matches!( + unwrap_syntax_only(&value.object), + Expression::ObjectExpression(_) | Expression::ArrayExpression(_) + ), + Expression::ConditionalExpression(_) + | Expression::TemplateLiteral(_) + | Expression::ObjectExpression(_) + | Expression::ArrayExpression(_) + | Expression::ArrowFunctionExpression(_) + | Expression::FunctionExpression(_) + | Expression::ClassExpression(_) => false, + _ => true, + } +} diff --git a/libs/extractor/src/extractor/extract_style_from_expression/typography_class_tests.rs b/libs/extractor/src/extractor/extract_style_from_expression/typography_class_tests.rs new file mode 100644 index 000000000..fe73c0233 --- /dev/null +++ b/libs/extractor/src/extractor/extract_style_from_expression/typography_class_tests.rs @@ -0,0 +1,234 @@ +use rstest::rstest; +use serial_test::serial; + +use crate::assignment_test_support::{extracted, jsx_js}; + +mod class_only_tests; +mod context_tests; +mod support; + +#[rstest] +#[case(("t", &[][..]))] +#[case(("typography", &["t"][..]))] +#[case(("state.t", &["t"][..]))] +#[case(("state[state.key]", &["key", "t"][..]))] +#[case(("state.pick(state.arg)", &["arg", "call"][..]))] +#[case(("state.flag?state.t:state.bad", &["flag", "t"][..]))] +#[case(("state.flag?state.bad:state.t", &["flag", "t"][..]))] +#[case(("state.t||state.bad", &["t"][..]))] +#[case(("state.t??state.bad", &["t"][..]))] +#[case(("state.flag&&state.t", &["flag", "t"][..]))] +#[case(("state?.t", &["t"][..]))] +#[case(("(state.t, state.t)", &["t", "t"][..]))] +#[case(("`${state.t}`", &["t"][..]))] +#[serial] +fn selects_typography_once_when_scalar_is_dynamic( + #[case] selection: (&str, &[&str]), + #[values(false, true)] runtime: bool, + #[values("alone", "before", "after")] order: &str, +) { + // Given: observable operands and a throwing unselected branch. + let (expression, reads) = selection; + let mut source = support::fixture(expression, runtime, order); + if expression == "state.flag?state.bad:state.t" { + source = source.replace("return true", "return false"); + } + let mut trace = vec!["seed"]; + if order == "before" { + trace.push("c"); + } + trace.extend_from_slice(reads); + if order == "after" { + trace.push("c"); + } + let classes = if order == "alone" { + vec!["typo-body"] + } else { + vec!["external", "typo-body"] + }; + // When: both native engines execute the real extraction output. + let output = extracted(&source); + let actual = support::observe(&format!( + "{}JSON.stringify([trace,String(node.className??'').split(/\\s+/).filter(Boolean).sort(),Object.entries(node.style??{{}})]);", + jsx_js(&output.code) + )); + // Then: selected class, scheduling and absence of CSS consumers are joint contracts. + assert_eq!(actual, serde_json::json!([trace, classes, []])); + support::class_only(&output); +} + +#[rstest] +#[case("t")] +#[case("typography")] +#[case("state.t")] +#[case("state[state.key]")] +#[case("state.pick(state.arg)")] +#[case("state.flag?state.t:state.bad")] +#[case("state.t??state.empty")] +#[serial] +fn omits_typography_when_selected_value_is_falsy( + #[case] expression: &str, + #[values(false, true)] runtime: bool, + #[values("alone", "before", "after")] order: &str, +) { + // Given: every falsy value admitted by the existing identifier convention. + for value in ["false", "null", "undefined", "0", "''", "NaN"] { + let source = support::fixture(expression, runtime, order) + .replace("let value='body'", &format!("let value={value}")); + // When: the authored selection executes, including a lazy nullish fallback. + let output = extracted(&source); + let actual = support::observe(&format!( + "{}JSON.stringify([String(node.className??'').split(/\\s+/).filter(Boolean),Object.entries(node.style??{{}})]);", + jsx_js(&output.code) + )); + // Then: no typography class or CSS variable survives. + let classes: Vec<&str> = if order == "alone" { + vec![] + } else { + vec!["external"] + }; + assert_eq!(actual, serde_json::json!([classes, []])); + support::class_only(&output); + } +} + +#[rstest] +#[case("state.t")] +#[case("state[state.key]")] +#[case("state.pick(state.arg)")] +#[case("state.flag?state.t:state.bad")] +#[serial] +fn reads_selected_value_at_its_authored_position_when_classname_mutates_it( + #[case] expression: &str, + #[values(false, true)] runtime: bool, + #[values("before", "after")] order: &str, +) { + // Given: className changes a mutable fixture value, not a resolver-foldable const. + let source = support::fixture(expression, runtime, order) + .replace("trace.push('c');", "trace.push('c');value='heading';"); + // When: the generated class selection observes that source order. + let actual = support::observe(&format!( + "{}JSON.stringify(String(node.className).split(/\\s+/).filter(Boolean).sort());", + jsx_js(&extracted(&source).code) + )); + // Then: moving typography across className changes which preset is selected. + let class = if order == "before" { + "typo-heading" + } else { + "typo-body" + }; + assert_eq!(actual, serde_json::json!(["external", class])); +} + +#[rstest] +#[case("state.t")] +#[case("state[state.key]")] +#[case("state.pick(state.arg)")] +#[case("state.flag?state.t:state.bad")] +#[serial] +fn stops_later_operands_when_classname_throws( + #[case] expression: &str, + #[values(false, true)] runtime: bool, + #[values("before", "after")] order: &str, +) { + // Given: className throws, so only earlier typography operands may execute. + let source = support::fixture(expression, runtime, order) + .replace("return 'external'", "throw Error('class failed')") + .replace( + "const node=render(state.seed,state);", + "let error;try{render(state.seed,state)}catch(value){error=value.message}", + ); + let result = "JSON.stringify([trace,error]);"; + let original = support::observe(&format!("{}{result}", jsx_js(&source))); + // When: compiled operands execute under the same exception boundary. + let actual = support::observe(&format!("{}{result}", jsx_js(&extracted(&source).code))); + // Then: throwing and counting source witnesses match, not merely generated text. + assert_eq!(actual, original); + assert_eq!(actual[1], "class failed"); + if order == "before" { + assert_eq!(actual[0], serde_json::json!(["seed", "c"])); + } +} + +#[rstest] +#[case("state.t||state.empty")] +#[case("state.t??state.empty")] +#[serial] +fn reads_fallback_once_when_left_typography_is_nullish( + #[case] expression: &str, + #[values(false, true)] runtime: bool, + #[values("alone", "before", "after")] order: &str, +) { + // Given: distinct left/fallback values and an observable logical left operand. + let source = support::fixture(expression, runtime, order) + .replace("let value='body'", "let value=null") + .replace( + "trace.push('empty');return value", + "trace.push('empty');return 'heading'", + ); + let mut trace = vec!["seed"]; + if order == "before" { + trace.push("c"); + } + trace.extend(["t", "empty"]); + if order == "after" { + trace.push("c"); + } + // When: a fallback is selected at the typography operand's authored position. + let output = extracted(&source); + let actual = support::observe(&format!( + "{}JSON.stringify([trace,String(node.className??'').split(/\\s+/).filter(name=>name.startsWith('typo-')),Object.entries(node.style??{{}})]);", + jsx_js(&output.code) + )); + // Then: left and fallback each read once, selecting only the fallback class. + assert_eq!(actual, serde_json::json!([trace, ["typo-heading"], []])); + support::class_only(&output); +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn guard_is_lazy_when_typography_branch_is_unselected(#[case] runtime: bool) { + // Given: a false style guard protecting a throwing branch. + let source = support::fixture("state.flag&&state.bad", runtime, "after") + .replace("return true", "return false"); + // When: the authored logical selection executes. + let output = extracted(&source); + let actual = support::observe(&format!( + "{}JSON.stringify([trace,String(node.className).trim(),Object.entries(node.style??{{}})]);", + jsx_js(&output.code) + )); + // Then: the branch stays unexecuted and no typography class/variable is emitted. + assert_eq!( + actual, + serde_json::json!([["seed", "flag", "c"], "external", []]) + ); + support::class_only(&output); +} + +#[rstest] +#[serial] +fn template_typography_keeps_empty_string_class_when_authored_value_is_coerced( + #[values(false, true)] runtime: bool, + #[values("alone", "before", "after")] order: &str, +) { + // Given: the existing template path produces a string, not a falsy raw preset. + let source = support::fixture("`${state.t}`", runtime, order) + .replace("let value='body'", "let value=''"); + // When: extraction captures the template's authored read before later operands. + let output = extracted(&source); + let actual = support::observe(&format!( + "{}JSON.stringify([trace,String(node.className).split(/\\s+/).filter(name=>name.startsWith('typo-')),Object.entries(node.style??{{}})]);", + jsx_js(&output.code) + )); + // Then: source scheduling and the pre-existing typo- class convention both survive. + let trace = match order { + "alone" => vec!["seed", "t"], + "before" => vec!["seed", "c", "t"], + "after" => vec!["seed", "t", "c"], + _ => panic!("fixture order"), + }; + assert_eq!(actual, serde_json::json!([trace, ["typo-"], []])); + support::class_only(&output); +} diff --git a/libs/extractor/src/extractor/extract_style_from_expression/typography_class_tests/class_only_tests.rs b/libs/extractor/src/extractor/extract_style_from_expression/typography_class_tests/class_only_tests.rs new file mode 100644 index 000000000..673e868f3 --- /dev/null +++ b/libs/extractor/src/extractor/extract_style_from_expression/typography_class_tests/class_only_tests.rs @@ -0,0 +1,186 @@ +use rstest::rstest; +use serial_test::serial; + +use super::support; +use crate::assignment_test_support::{extracted, jsx_js}; + +#[rstest] +#[case("state.t")] +#[case("state[state.key]")] +#[case("state.pick(state.arg)")] +#[case("state.flag?state.t:state.bad")] +#[case("state.t||state.bad")] +#[case("state.t??state.bad")] +#[serial] +fn positioning_keeps_expanded_classes_when_classname_changes_the_selected_value( + #[case] expression: &str, + #[values(false, true)] runtime: bool, + #[values("before", "after")] order: &str, +) { + // Given: an enum class-only property with an order-sensitive selection. + let source = support::fixture(expression, runtime, order) + .replace("typography", "positioning") + .replace("let value='body'", "let value='top-left'") + .replace("trace.push('c');", "trace.push('c');value='bottom-right';"); + // When: selected generated classes are joined to their actual CSS declarations. + let output = extracted(&source); + let actual = support::observe(&format!( + "{}const records={};JSON.stringify([String(node.className).split(/\\s+/).filter(name=>name!=='external').map(name=>records.find(([key])=>key===name)[1]).sort(),Object.entries(node.style??{{}})]);", + jsx_js(&output.code), + support::declarations(&output) + )); + // Then: no enum declaration/variable appears and the correct two edge classes win. + let declarations = if order == "before" { + vec!["bottom:0:0", "right:0:0"] + } else { + vec!["left:0:0", "top:0:0"] + }; + assert_eq!(actual, serde_json::json!([declarations, []])); + assert_eq!( + output + .styles + .iter() + .filter(|value| matches!(value, crate::ExtractStyleValue::Dynamic(_))) + .count(), + 0 + ); +} + +#[rstest] +#[case("before", &["seed", "c", "t"][..])] +#[case("after", &["seed", "t", "c"][..])] +#[serial] +fn as_keeps_type_and_order_when_adjacent_to_classname( + #[case] order: &str, + #[case] trace: &[&str], + #[values(false, true)] runtime: bool, +) { + // Given: a class-free tag operand beside an observable className. + let source = support::fixture("state.t", runtime, order) + .replace("typography", "as") + .replace("let value='body'", "let value='section'"); + // When: both caller forms execute the compiled type capture. + let output = extracted(&source); + let actual = support::observe(&format!( + "{}JSON.stringify([trace,node.tag,node.className,Object.entries(node.style??{{}})]);", + jsx_js(&output.code) + )); + // Then: the selected tag and operand schedule are unchanged. + assert_eq!( + actual, + serde_json::json!([trace, "section", "external", []]) + ); + assert_eq!(output.styles.len(), 0); +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn typography_keeps_supported_unknown_spread_when_it_follows_selection(#[case] runtime: bool) { + // Given: a mutable spread supplies only an ordinary id, not #756 fallback semantics. + let source = support::fixture("state.t", runtime, "alone") + .replace("/>", " {...state.rest}/>") + .replace("typography:state.t}", "typography:state.t,...state.rest}") + .replace( + "get c()", + "get rest(){trace.push('rest');return {id:'target'}},get c()", + ); + // When: the supported unknown-spread program runs through extraction. + let output = extracted(&source); + let actual = support::observe(&format!( + "{}JSON.stringify([trace,String(node.className).trim(),node.id,Object.entries(node.style??{{}})]);", + jsx_js(&output.code) + )); + // Then: selection precedes the once-read spread, retaining its ordinary property. + assert_eq!( + actual, + serde_json::json!([["seed", "t", "rest"], "typo-body", "target", []]) + ); + support::class_only(&output); +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn call_typography_coerces_selected_value_before_next_operand(#[case] runtime: bool) { + // Given: receiver/argument-sensitive call returns an observably coerced preset. + let source = support::fixture("state.pick(state.arg)", runtime, "after").replace( + "let value='body'", + "let value={toString(){trace.push('coerce');return 'body'}}", + ); + // When: the entire call is captured, then its result is used as a class. + let actual = support::observe(&format!( + "{}JSON.stringify([trace,String(node.className).split(/\\s+/).filter(Boolean).sort()]);", + jsx_js(&extracted(&source).code) + )); + // Then: coercion, receiver and arguments are each observed exactly once in order. + assert_eq!( + actual, + serde_json::json!([ + ["seed", "arg", "call", "coerce", "c"], + ["external", "typo-body"] + ]) + ); +} + +#[rstest] +#[serial] +fn positioning_selects_ordered_atoms_when_style_order_is_conditional( + #[values(false, true)] runtime: bool, + #[values(false, true)] active: bool, +) { + // Given: conditional order precedes an observable enum selection. + let source = support::fixture("state.t", runtime, "after") + .replace("typography", "positioning") + .replace("let value='body'", "let value='top-left'") + .replace( + "positioning:state.t", + "styleOrder:state.flag?5:10,positioning:state.t", + ) + .replace( + "positioning={state.t}", + "styleOrder={state.flag?5:10} positioning={state.t}", + ) + .replace( + "return true", + if active { + "return true" + } else { + "return false" + }, + ); + // When: both order branches generate their classes after capturing the raw enum value. + let output = extracted(&source); + let records = output + .styles + .iter() + .filter_map(|value| { + let crate::ExtractStyleValue::Static(style) = value else { + return None; + }; + let crate::extract_style::style_property::StyleProperty::ClassName(class) = + value.extract(None)? + else { + return None; + }; + Some((class, (&style.property, &style.value, style.style_order))) + }) + .collect::>(); + let actual = support::observe(&format!( + "{}const records={};JSON.stringify([trace,String(node.className).split(/\\s+/).filter(name=>name&&name!=='external').map(name=>records.find(([key])=>key===name)[1]).sort(),Object.entries(node.style??{{}})]);", + jsx_js(&output.code), + serde_json::to_string(&records).unwrap_or_else(|error| panic!("{error}")) + )); + // Then: exactly the selected order's left/top atoms match emitted CSS, with one enum read. + let order = if active { 5 } else { 10 }; + assert_eq!( + actual, + serde_json::json!([ + ["seed", "flag", "t", "c"], + [["left", "0", order], ["top", "0", order]], + [] + ]) + ); +} diff --git a/libs/extractor/src/extractor/extract_style_from_expression/typography_class_tests/context_tests.rs b/libs/extractor/src/extractor/extract_style_from_expression/typography_class_tests/context_tests.rs new file mode 100644 index 000000000..37e109ed1 --- /dev/null +++ b/libs/extractor/src/extractor/extract_style_from_expression/typography_class_tests/context_tests.rs @@ -0,0 +1,125 @@ +use rstest::rstest; +use serial_test::serial; + +use super::support; +use crate::{ + ExtractStyleValue, + assignment_test_support::{extracted, jsx_js}, +}; + +#[rstest] +#[case("state.t", &["t"][..])] +#[case("state[state.key]", &["key", "t"][..])] +#[case("state.pick(state.arg)", &["arg", "call"][..])] +#[case("state.flag?state.t:state.bad", &["flag", "t"][..])] +#[case("state.t||state.bad", &["t"][..])] +#[serial] +fn selector_typography_selects_only_scoped_preset_when_value_is_dynamic( + #[case] expression: &str, + #[case] reads: &[&str], + #[values(false, true)] runtime: bool, +) { + // Given: a dynamic preset inside a mixed selector object with an adjacent literal. + let _theme = support::TypographyTheme::register(); + let source = support::fixture(expression, runtime, "after"); + let source = if runtime { + source.replace( + &format!("typography:{expression}"), + &format!("_hover:{{typography:{expression},color:'red'}}"), + ) + } else { + source.replace( + &format!("typography={{{expression}}}"), + &format!("_hover={{{{typography:{expression},color:'red'}}}}"), + ) + }; + // When: class-only and declaration consumers lower together at the selector field. + let output = extracted(&source); + let actual = support::observe(&format!( + "{}const records={};JSON.stringify([String(node.className).split(/\\s+/).filter(name=>name&&name!=='external').map(name=>records.find(([key])=>key===name)[1]).sort(),Object.entries(node.style??{{}})]);", + jsx_js(&output.code), + support::declarations(&output) + )); + // Then: only body is selected, and its CSS atom is still scoped to hover. + assert_eq!( + actual, + serde_json::json!([["color:red:0", "typography:body:0"], []]) + ); + let mut trace = vec!["seed"]; + trace.extend_from_slice(reads); + trace.push("c"); + assert_eq!( + support::observe(&format!("{}JSON.stringify(trace);", jsx_js(&output.code))), + serde_json::json!(trace) + ); + for value in &output.styles { + let ExtractStyleValue::Static(style) = value else { + panic!("scoped static atom expected") + }; + assert_eq!( + style.selector.as_ref().map(ToString::to_string).as_deref(), + Some("&:hover") + ); + } +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn responsive_typography_keeps_breakpoint_when_dynamic_preset_follows_static_preset( + #[case] runtime: bool, + #[values("alone", "before", "after")] order: &str, +) { + // Given: a shared static root preset and a dynamic preset at breakpoint one. + let _theme = support::TypographyTheme::register(); + let source = support::fixture("['heading',state.t]", runtime, order); + // When: the two class roles are selected through the same authored array operand. + let output = extracted(&source); + let actual = support::observe(&format!( + "{}const records={};JSON.stringify([String(node.className).split(/\\s+/).filter(name=>name&&name!=='external').map(name=>name==='typo-heading'?name:records.find(([key])=>key===name)[1]).sort(),Object.entries(node.style??{{}})]);", + jsx_js(&output.code), + support::declarations(&output) + )); + // Then: the later breakpoint selects body, without applying all presets globally. + assert_eq!( + actual, + serde_json::json!([["typo-heading", "typography:body:1"], []]) + ); + let trace = match order { + "alone" => vec!["seed", "t"], + "before" => vec!["seed", "c", "t"], + "after" => vec!["seed", "t", "c"], + _ => panic!("fixture order"), + }; + assert_eq!( + support::observe(&format!("{}JSON.stringify(trace);", jsx_js(&output.code))), + serde_json::json!(trace) + ); + assert_eq!( + output + .styles + .iter() + .filter(|value| matches!(value, ExtractStyleValue::Dynamic(_))) + .count(), + 0 + ); +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn static_typography_remains_shared_when_a_literal_preset_is_authored(#[case] runtime: bool) { + // Given: the pre-existing static preset path, with an adjacent dynamic className. + let source = support::fixture("'heading'", runtime, "after"); + // When: the literal is extracted normally rather than rerouted into dynamic CSS. + let output = extracted(&source); + let actual = support::observe(&format!( + "{}JSON.stringify(String(node.className).split(/\\s+/).filter(Boolean).sort());", + jsx_js(&output.code) + )); + // Then: its shared preset class survives without declarations or variables. + assert_eq!(actual, serde_json::json!(["external", "typo-heading"])); + support::class_only(&output); +} diff --git a/libs/extractor/src/extractor/extract_style_from_expression/typography_class_tests/support.rs b/libs/extractor/src/extractor/extract_style_from_expression/typography_class_tests/support.rs new file mode 100644 index 000000000..1726fddd8 --- /dev/null +++ b/libs/extractor/src/extractor/extract_style_from_expression/typography_class_tests/support.rs @@ -0,0 +1,108 @@ +use crate::{ExtractOutput, ExtractStyleValue}; + +pub(super) struct TypographyTheme; + +impl TypographyTheme { + pub(super) fn register() -> Self { + css::theme_tokens::set_typography_keys(vec!["body".to_string(), "heading".to_string()]); + css::content_typography::set( + [("body", "14px"), ("heading", "24px")] + .into_iter() + .map(|(name, size)| { + ( + name.to_string(), + vec![(0, "font-size".to_string(), size.to_string())], + ) + }) + .collect(), + ); + Self + } +} + +impl Drop for TypographyTheme { + fn drop(&mut self) { + css::theme_tokens::set_typography_keys(vec![]); + css::content_typography::set(Default::default()); + } +} + +pub(super) fn fixture(expression: &str, runtime: bool, order: &str) -> String { + let mut fields = vec![("typography", expression)]; + match order { + "before" => fields.insert(0, ("className", "state.c")), + "after" => fields.push(("className", "state.c")), + "alone" => {} + _ => panic!("fixture order"), + } + let element = if runtime { + format!( + "jsx(Box,{{{}}})", + fields + .iter() + .map(|(key, value)| format!("{key}:{value}")) + .collect::>() + .join(",") + ) + } else { + format!( + "", + fields + .iter() + .map(|(key, value)| format!("{key}={{{value}}}")) + .collect::>() + .join(" ") + ) + }; + let source = format!( + "import {{Box}}from '@devup-ui/react';import {{jsx}}from 'react/jsx-runtime';function render(t,state){{return {element}}}let trace=[];let value='body';let state={{get seed(){{trace.push('seed');return value}},get t(){{trace.push('t');return value}},get key(){{trace.push('key');return 't'}},get flag(){{trace.push('flag');return true}},get bad(){{throw Error('not lazy')}},get empty(){{trace.push('empty');return value}},get arg(){{trace.push('arg');return 7}},pick(arg){{trace.push(this===state&&arg===7?'call':'broken');return value}},get c(){{trace.push('c');return 'external'}}}};const jsx=(tag,props)=>({{tag,...props}});const node=render(state.seed,state);" + ); + if expression == "typography" { + source.replace("const node=", "let reads=0;Object.defineProperty(globalThis,'typography',{get(){if(++reads>1)throw Error('second read');return state.t}});const node=") + } else { + source + } +} + +pub(super) fn observe(script: &str) -> serde_json::Value { + let script = format!("const Box='div';{script}"); + let boa = crate::assignment_test_support::evaluate(&script); + let node = crate::named_capture_support::evaluate(&script); + assert_eq!(boa, node, "native engines disagree\n{script}"); + serde_json::from_str(&node).unwrap_or_else(|error| panic!("{error}\n{node}")) +} + +pub(super) fn class_only(output: &ExtractOutput) { + assert_eq!( + output + .styles + .iter() + .filter(|value| matches!(value, ExtractStyleValue::Dynamic(_))) + .count(), + 0, + "typography must not create CSS variables" + ); + assert_eq!( + output.styles.iter().filter(|value| matches!(value, ExtractStyleValue::Static(style) if style.property == "typography")).count(), + 0, + "root typography must not create CSS declarations" + ); +} + +pub(super) fn declarations(output: &ExtractOutput) -> String { + let records = output + .styles + .iter() + .filter_map(|value| match (value, value.extract(None)?) { + ( + ExtractStyleValue::Static(style), + crate::extract_style::style_property::StyleProperty::ClassName(class), + ) => Some(( + class, + format!("{}:{}:{}", style.property, style.value, style.level), + )), + _ => None, + }) + .collect::>(); + serde_json::to_string(&records).unwrap_or_else(|error| panic!("{error}")) +} diff --git a/libs/extractor/src/extractor/extract_style_from_member_expression.rs b/libs/extractor/src/extractor/extract_style_from_member_expression.rs index b52e3169e..df73161cf 100644 --- a/libs/extractor/src/extractor/extract_style_from_member_expression.rs +++ b/libs/extractor/src/extractor/extract_style_from_member_expression.rs @@ -17,7 +17,7 @@ use oxc_ast::{ ast::{ArrayExpressionElement, ComputedMemberExpression, Expression, ObjectPropertyKind}, builder::AstBuilder, }; -use oxc_span::SPAN; +use oxc_span::Span; use std::collections::BTreeMap; pub(super) fn extract_style_from_member_expression<'a>( @@ -57,18 +57,29 @@ pub(super) fn extract_style_from_member_expression<'a>( _ => false, } .then(|| mem.object.clone_in(ast_builder.allocator())); - let runtime = |whole: &Expression<'a>, offset: u32| { + let span = mem.span; + let runtime = |whole: &Expression<'a>| { runtime_member( ast_builder, name, whole.clone_in(ast_builder.allocator()), mem_expression, - offset, + span, level, selector, ) }; + if matches!(&mem.object, Expression::ArrayExpression(_)) + && spread.is_some() + && get_number_by_literal_expression(mem_expression).is_none() + { + return ExtractResult { + styles: vec![runtime(&mem.object)], + ..ExtractResult::default() + }; + } + if let Expression::ArrayExpression(array) = &mut mem.object && !array.elements.is_empty() { @@ -96,21 +107,14 @@ pub(super) fn extract_style_from_member_expression<'a>( } } return ExtractResult { - styles: spread - .iter() - .map(|whole| runtime(whole, mem.span.start)) - .collect(), + styles: spread.iter().map(runtime).collect(), ..ExtractResult::default() }; } let mut map = BTreeMap::new(); for (idx, p) in array.elements.iter_mut().enumerate() { - if let ArrayExpressionElement::SpreadElement(sp) = p - && let Some(whole) = &spread - { - map.insert(idx.to_string(), Box::new(runtime(whole, sp.span.start))); - } else if let Some(p) = p.as_expression_mut() { + if let Some(p) = p.as_expression_mut() { map.insert( idx.to_string(), Box::new(ExtractStyleProp::StaticArray( @@ -148,7 +152,7 @@ pub(super) fn extract_style_from_member_expression<'a>( .any(|p| matches!(p, ObjectPropertyKind::SpreadProperty(_))); if replaced && let Some(whole) = &spread { return ExtractResult { - styles: vec![runtime(whole, mem.span.start)], + styles: vec![runtime(whole)], ..ExtractResult::default() }; } @@ -206,7 +210,7 @@ pub(super) fn extract_style_from_member_expression<'a>( name, mem.object.clone_in(ast_builder.allocator()), mem_expression, - mem.span.start, + span, level, selector, )); @@ -225,12 +229,12 @@ fn runtime_member<'a>( name: Option<&str>, object: Expression<'a>, key: &Expression<'a>, - offset: u32, + span: Span, level: u8, selector: &Option, ) -> ExtractStyleProp<'a> { let member = Expression::ComputedMemberExpression(ComputedMemberExpression::boxed( - SPAN, + span, object, key.clone_in(ast_builder.allocator()), false, @@ -239,7 +243,7 @@ fn runtime_member<'a>( match name { Some(name) => dynamic_style(ast_builder, name, &member, level, selector), None => ExtractStyleProp::Unreadable { - offset, + offset: span.start, code: readable_code(&member), prop: false, }, diff --git a/libs/extractor/src/extractor/extract_style_from_styled.rs b/libs/extractor/src/extractor/extract_style_from_styled.rs index 425ed0245..741e69ac8 100644 --- a/libs/extractor/src/extractor/extract_style_from_styled.rs +++ b/libs/extractor/src/extractor/extract_style_from_styled.rs @@ -184,11 +184,17 @@ pub fn extract_style_from_styled<'a>( imports: &FxHashMap, attrs: &[Expression<'a>], ) -> (ExtractResult<'a>, Expression<'a>, Vec<(u32, String)>) { + let _scope = crate::extract_style::compiler_projection::ConsumerScope::enter(split_filename); let mut composed_classes = Vec::new(); + let mut composition_reads = Vec::new(); let mut errors = Vec::new(); if let Expression::CallExpression(call) = expression && extract_base_tag_and_class_name(ast_builder, &call.callee, imports).is_some() { + if style_arguments(ast_builder, &call.arguments).is_some() { + composition_reads = + crate::assignment_composition::capture(ast_builder, &mut call.arguments); + } match style_arguments(ast_builder, &call.arguments) { Some(StyleArguments { classes, rules }) => { call.arguments = @@ -274,12 +280,13 @@ pub fn extract_style_from_styled<'a>( None, ast_builder, )); - let component = create_styled_component( + let mut component = create_styled_component( ast_builder, &base.name, &class_name, &gen_styles(ast_builder, &props_styles, None), ); + crate::assignment_lowering::component(ast_builder, &mut component, &mut props_styles); let styled_component = base.render(ast_builder, apply_attrs(ast_builder, component, attrs)); let result = ExtractResult { @@ -298,6 +305,10 @@ pub fn extract_style_from_styled<'a>( // Case 2: styled.div({ bg: "red" }), styled("div")({ bg: "red" }), // or styled("div", { bg: "red" }) + // Retain authored fields before extraction rewrites conditional/member values. + let creation_source = call.arguments[style_index] + .to_expression() + .clone_in(ast_builder.allocator()); // Extract styles from object expression let ExtractResult { mut styles, @@ -308,11 +319,7 @@ pub fn extract_style_from_styled<'a>( } = extract_style_from_expression( ast_builder, None, - if let Argument::SpreadElement(spread) = &mut call.arguments[style_index] { - &mut spread.argument - } else { - call.arguments[style_index].to_expression_mut() - }, + call.arguments[style_index].to_expression_mut(), 0, &None, LiteralHandling::ExpandResponsiveThemeToken, @@ -324,6 +331,7 @@ pub fn extract_style_from_styled<'a>( .into_iter() .map(|(offset, code)| (offset, build_time_error("styled", &code, STYLE_OBJECT))), ); + crate::assignment_lowering::styled_argument(ast_builder, &creation_source, &mut styles); if let Some(default_class_name) = base.styles.take() { styles.extend(default_class_name.into_iter().map(ExtractStyleProp::Static)); } @@ -337,13 +345,22 @@ pub fn extract_style_from_styled<'a>( split_filename, )), ); - let component = create_styled_component( + let mut component = create_styled_component( ast_builder, &base.name, &class_name, &gen_styles(ast_builder, &styles, None), ); - let styled_component = base.render(ast_builder, apply_attrs(ast_builder, component, attrs)); + component = apply_attrs(ast_builder, component, attrs); + crate::assignment_capture::styled_creation( + ast_builder, + &mut component, + crate::assignment_capture::StyledCreation { + source: &creation_source, + styles: &mut styles, + }, + ); + let styled_component = base.render(ast_builder, component); let result = ExtractResult { styles, @@ -368,7 +385,16 @@ pub fn extract_style_from_styled<'a>( }; ( result.unwrap_or_else(ExtractResult::default), - new_expr.unwrap_or_else(|| expression.clone_in(ast_builder.allocator())), + new_expr.map_or_else( + || expression.clone_in(ast_builder.allocator()), + |value| { + if composition_reads.is_empty() { + value + } else { + call_with_values(ast_builder, composition_reads, value) + } + }, + ), errors, ) } diff --git a/libs/extractor/src/extractor/extract_style_from_stylex.rs b/libs/extractor/src/extractor/extract_style_from_stylex.rs index 69ff713a8..56006d0ea 100644 --- a/libs/extractor/src/extractor/extract_style_from_stylex.rs +++ b/libs/extractor/src/extractor/extract_style_from_stylex.rs @@ -7,7 +7,6 @@ use crate::stylex::{ dynamic_number_suffix, is_include_call_static, normalize_stylex_property, stylex_value, }; use css::optimize_value::optimize_value; -use css::sheet_to_variable_name; use css::style_selector::StyleSelector; use oxc_ast::ast::{ Argument, ArrowFunctionExpression, BindingPattern, Expression, ObjectExpression, @@ -36,6 +35,10 @@ fn raw_static_style<'a>( style_order: None, layer: None, theme_token_resolution: Default::default(), + naming: css::Naming::Own, + counter_owner: crate::sparse_sites::counter_owner(), + origin: crate::style_origin::current(), + producer_policy: crate::sparse_sites::producer_policy(), })) } @@ -367,12 +370,18 @@ fn extract_stylex_dynamic_namespace<'a>( if let Some(param_idx) = is_dynamic { // Dynamic property: generate CSS variable - let var_name = sheet_to_variable_name(&css_property, 0, None); - css_vars.push((param_idx, var_name, dynamic_number_suffix(&css_property))); let param_name = ¶m_names[param_idx]; - styles.push(ExtractStyleProp::Static(ExtractStyleValue::Dynamic( - ExtractDynamicStyle::new(&css_property, 0, param_name, None), - ))); + let style = ExtractDynamicStyle::new(&css_property, 0, param_name, None) + .at(prop.value.span().start); + let Some(variable) = crate::extract_style::compiler_projection::variable_for( + &style, + prop.value.span().start, + param_name, + ) else { + continue; + }; + css_vars.push((param_idx, variable, dynamic_number_suffix(&css_property))); + styles.push(ExtractStyleProp::Static(ExtractStyleValue::Dynamic(style))); continue; } match leaf(&css_property, &prop.value) { diff --git a/libs/extractor/src/gen_class_name.rs b/libs/extractor/src/gen_class_name.rs index 3b274b7fd..863d6679a 100644 --- a/libs/extractor/src/gen_class_name.rs +++ b/libs/extractor/src/gen_class_name.rs @@ -1,15 +1,73 @@ -use crate::ExtractStyleProp; -use crate::extract_style::style_property::StyleProperty; +use crate::extract_style::compiler_associations as associations; +use crate::extract_style::compiler_projection; use crate::extractor::extract_style_from_expression::yield_typography; -use crate::prop_modify_utils::convert_class_name; -use crate::utils::is_same_expression; +use crate::{ExtractStyleProp, ExtractStyleValue as Value}; use oxc_allocator::{CloneIn, FromIn, GetAllocator}; -use oxc_ast::ast::{ - ComputedMemberExpression, Expression, ObjectPropertyKind, PropertyKey, PropertyKind, Str, - StringLiteral, TemplateElement, TemplateElementValue, -}; +use oxc_ast::ast::{Expression, Str, TemplateElement, TemplateElementValue}; use oxc_ast::builder::AstBuilder; -use oxc_span::SPAN; +use oxc_span::{GetSpan, GetSpanMut, SPAN}; + +#[path = "gen_class_name_branches.rs"] +mod branches; + +#[derive(Clone, Debug)] +pub struct OrderEdge { + pub before: Value, + pub after: Value, + pub receipt: crate::extract_style::compiler_receipts::ReceiptId, + pub invocation: crate::extract_style::compiler_associations::InvocationId, + pub scope: Option, +} +pub(crate) fn class_name(value: &Value, filename: Option<&str>) -> Option { + projected_class(value, filename, None) +} +fn projected_class( + value: &Value, + filename: Option<&str>, + before: Option<&Value>, +) -> Option { + use crate::extract_style::compiler_receipts as receipts; + use crate::extract_style::style_property::StyleProperty; + if !crate::compiler_policy::active() { + return value.extract(filename).map(|value| match value { + StyleProperty::ClassName(name) + | StyleProperty::Variable { + class_name: name, .. + } => name, + }); + } + match value { + Value::Typography(name) => Some(format!("typo-{name}")), + Value::Css(_) | Value::Import(_) | Value::FontFace(_) => None, + Value::Static(_) | Value::Dynamic(_) | Value::Keyframes(_) => { + let result = compiler_projection::context(value, filename) + .and_then(|context| receipts::acquire(value, filename, context)); + receipts::collect_operand(result, value).map(|(receipt, produced)| { + associations::ordered(before.unwrap_or(value), value, receipt); + produced.class().allocation.name.clone() + }) + } + } +} + +pub(crate) fn keyframe_name( + frames: &crate::extract_style::ExtractKeyframes, + filename: Option<&str>, +) -> Option { + use crate::extract_style::ExtractStyleProperty; + if !crate::compiler_policy::active() { + return Some(crate::visit::style_property_into_string( + frames.extract(filename), + )); + } + class_name(&Value::Keyframes(frames.clone()), filename) +} +pub(crate) fn static_name( + style: &crate::extract_style::extract_static_style::ExtractStaticStyle, + filename: Option<&str>, +) -> Option { + class_name(&Value::Static(style.clone()), filename) +} pub fn gen_class_names<'a>( ast_builder: &AstBuilder<'a>, @@ -18,6 +76,7 @@ pub fn gen_class_names<'a>( filename: Option<&str>, ) -> Option> { yield_typography(style_props); + crate::assignment_lowering::coalesce(style_props); merge_expression_for_class_name( ast_builder, style_props @@ -33,61 +92,45 @@ fn gen_class_name<'a>( style_order: Option, filename: Option<&str>, ) -> Option> { + let projection = branches::ClassProjection { + ast: ast_builder, + order: style_order, + filename, + }; match style_prop { - ExtractStyleProp::Enum { map, condition } => { - let properties = map.iter_mut().filter_map(|(key, value)| { - merge_expression_for_class_name( - ast_builder, - value - .iter_mut() - .filter_map(|v| gen_class_name(ast_builder, v, style_order, filename)), - ) - .map(|class_name| { - ObjectPropertyKind::new_object_property( - SPAN, - PropertyKind::Init, - PropertyKey::StringLiteral(StringLiteral::boxed( - SPAN, - Str::from_in(key, ast_builder.allocator()), - None, - ast_builder, - )), - class_name, - false, - false, - false, - ast_builder, - ) - }) - }); - let obj = Expression::new_object_expression( - SPAN, - oxc_allocator::Vec::from_iter_in(properties, ast_builder), - ast_builder, - ); - Some(convert_class_name( + ExtractStyleProp::Evaluated { + source, + styles, + binding, + evaluation, + alternate_order, + alternate_class, + } => { + crate::assignment_consumers::merge(styles); + let mut value = crate::assignment_lowering::Lowering { + ast: ast_builder, + order: style_order, + filename, + alternate_order: *alternate_order, + } + .lower(source, styles); + let _ = gen_class_names(ast_builder, styles, style_order, filename); + *value.span_mut() = source.span(); + *evaluation = Some(value); + Some(crate::assignment_lowering::projection( ast_builder, - &Expression::ComputedMemberExpression(ComputedMemberExpression::boxed( - SPAN, - obj, - condition.clone_in(ast_builder.allocator()), - false, - ast_builder, - )), + binding, + if *alternate_class { 3 } else { 0 }, )) } + ExtractStyleProp::Enum { map, condition } => Some(projection.enum_class(map, condition)), ExtractStyleProp::Static(st) => { + let before = crate::compiler_policy::active().then(|| st.clone()); if let Some(style_order) = style_order { st.set_style_order(style_order); } - st.extract(filename).map(|style| { - let v = Str::from_in( - &match style { - StyleProperty::ClassName(cls) => cls, - StyleProperty::Variable { class_name, .. } => class_name, - }, - ast_builder.allocator(), - ); + projected_class(st, filename, before.as_ref()).map(|name| { + let v = Str::from_in(&name, ast_builder.allocator()); Expression::new_string_literal(SPAN, v, None, ast_builder) }) } @@ -101,75 +144,14 @@ fn gen_class_name<'a>( consequent, alternate, .. - } => { - let consequent = consequent - .as_mut() - .and_then(|ref mut con| { - gen_class_name(ast_builder, con.as_mut(), style_order, filename) - }) - .unwrap_or_else(|| Expression::new_string_literal(SPAN, "", None, ast_builder)); - - let alternate = alternate - .as_mut() - .and_then(|ref mut alt| gen_class_name(ast_builder, alt, style_order, filename)) - .unwrap_or_else(|| Expression::new_string_literal(SPAN, "", None, ast_builder)); - if is_same_expression(&consequent, &alternate) { - Some(consequent) - } else { - Some(Expression::new_conditional_expression( - SPAN, - condition.clone_in(ast_builder.allocator()), - consequent, - alternate, - ast_builder, - )) - } - } + } => Some(projection.conditional_class(condition, (consequent, alternate))), ExtractStyleProp::Expression { expression, .. } => { Some(expression.clone_in(ast_builder.allocator())) } ExtractStyleProp::Unreadable { .. } => None, // direct select ExtractStyleProp::MemberExpression { map, expression } => { - let exp = Expression::ComputedMemberExpression(ComputedMemberExpression::boxed( - SPAN, - Expression::new_object_expression( - SPAN, - oxc_allocator::Vec::from_iter_in( - map.iter_mut().filter_map(|(key, value)| { - gen_class_name(ast_builder, value.as_mut(), style_order, filename).map( - |expr| { - ObjectPropertyKind::new_object_property( - SPAN, - PropertyKind::Init, - PropertyKey::StringLiteral(StringLiteral::boxed( - SPAN, - Str::from_in(key, ast_builder.allocator()), - None, - ast_builder, - )), - expr, - false, - false, - false, - ast_builder, - ) - }, - ) - }), - ast_builder, - ), - ast_builder, - ), - expression.clone_in(ast_builder.allocator()), - false, - ast_builder, - )); - if let Expression::Identifier(_) = &expression { - Some(convert_class_name(ast_builder, &exp)) - } else { - Some(exp) - } + Some(projection.member_class(map, expression)) } } } diff --git a/libs/extractor/src/gen_class_name_branches.rs b/libs/extractor/src/gen_class_name_branches.rs new file mode 100644 index 000000000..58f3ddca7 --- /dev/null +++ b/libs/extractor/src/gen_class_name_branches.rs @@ -0,0 +1,146 @@ +use super::{ExtractStyleProp, gen_class_name, merge_expression_for_class_name}; +use crate::{prop_modify_utils::convert_class_name, utils::is_same_expression}; +use oxc_allocator::{CloneIn, FromIn, GetAllocator}; +use oxc_ast::{ + ast::{ + ComputedMemberExpression, Expression, ObjectPropertyKind, PropertyKey, PropertyKind, Str, + StringLiteral, + }, + builder::AstBuilder, +}; +use oxc_span::SPAN; +use std::collections::BTreeMap; + +pub(super) struct ClassProjection<'b, 'a> { + pub(super) ast: &'b AstBuilder<'a>, + pub(super) order: Option, + pub(super) filename: Option<&'b str>, +} + +impl<'a> ClassProjection<'_, 'a> { + fn class(&self, style: &mut ExtractStyleProp<'a>) -> Option> { + gen_class_name(self.ast, style, self.order, self.filename) + } + + pub(super) fn enum_class( + &self, + map: &mut BTreeMap>>, + condition: &Expression<'a>, + ) -> Expression<'a> { + let ast_builder = self.ast; + let properties = map.iter_mut().filter_map(|(key, value)| { + merge_expression_for_class_name( + ast_builder, + value.iter_mut().filter_map(|v| self.class(v)), + ) + .map(|class_name| { + ObjectPropertyKind::new_object_property( + SPAN, + PropertyKind::Init, + PropertyKey::StringLiteral(StringLiteral::boxed( + SPAN, + Str::from_in(key, ast_builder.allocator()), + None, + ast_builder, + )), + class_name, + false, + false, + false, + ast_builder, + ) + }) + }); + let obj = Expression::new_object_expression( + SPAN, + oxc_allocator::Vec::from_iter_in(properties, ast_builder), + ast_builder, + ); + convert_class_name( + ast_builder, + &Expression::ComputedMemberExpression(ComputedMemberExpression::boxed( + SPAN, + obj, + condition.clone_in(ast_builder.allocator()), + false, + ast_builder, + )), + ) + } + + pub(super) fn conditional_class( + &self, + condition: &Expression<'a>, + branches: ( + &mut Option>>, + &mut Option>>, + ), + ) -> Expression<'a> { + let ast_builder = self.ast; + let (consequent, alternate) = branches; + let consequent = consequent + .as_mut() + .and_then(|ref mut con| self.class(con.as_mut())) + .unwrap_or_else(|| Expression::new_string_literal(SPAN, "", None, ast_builder)); + let alternate = alternate + .as_mut() + .and_then(|ref mut alt| self.class(alt)) + .unwrap_or_else(|| Expression::new_string_literal(SPAN, "", None, ast_builder)); + if is_same_expression(&consequent, &alternate) { + consequent + } else { + Expression::new_conditional_expression( + SPAN, + condition.clone_in(ast_builder.allocator()), + consequent, + alternate, + ast_builder, + ) + } + } + + pub(super) fn member_class( + &self, + map: &mut BTreeMap>>, + expression: &Expression<'a>, + ) -> Expression<'a> { + let ast_builder = self.ast; + let exp = Expression::ComputedMemberExpression(ComputedMemberExpression::boxed( + SPAN, + Expression::new_object_expression( + SPAN, + oxc_allocator::Vec::from_iter_in( + map.iter_mut().filter_map(|(key, value)| { + self.class(value.as_mut()).map(|expr| { + ObjectPropertyKind::new_object_property( + SPAN, + PropertyKind::Init, + PropertyKey::StringLiteral(StringLiteral::boxed( + SPAN, + Str::from_in(key, ast_builder.allocator()), + None, + ast_builder, + )), + expr, + false, + false, + false, + ast_builder, + ) + }) + }), + ast_builder, + ), + ast_builder, + ), + expression.clone_in(ast_builder.allocator()), + false, + ast_builder, + )); + if let Expression::Identifier(_) = &expression { + convert_class_name(ast_builder, &exp) + } else { + exp + } + } +} diff --git a/libs/extractor/src/gen_style.rs b/libs/extractor/src/gen_style.rs index 85b2f086d..1c7fa7d59 100644 --- a/libs/extractor/src/gen_style.rs +++ b/libs/extractor/src/gen_style.rs @@ -1,5 +1,5 @@ use crate::ExtractStyleProp; -use crate::extract_style::style_property::StyleProperty; +use crate::extract_style::compiler_projection; use oxc_allocator::{CloneIn, FromIn, GetAllocator}; use oxc_ast::ast::{ ComputedMemberExpression, Expression, ObjectPropertyKind, PropertyKey, PropertyKind, Str, @@ -7,83 +7,33 @@ use oxc_ast::ast::{ }; use oxc_ast::builder::AstBuilder; use oxc_span::SPAN; -use rustc_hash::{FxBuildHasher, FxHashMap, FxHashSet}; use std::collections::BTreeMap; -/// Generate the object properties for every style in `styles` and push them, in -/// reverse generation order, into `properties`. Both the top-level style list -/// and a nested `StaticArray` arm need this identical -/// `iter → flat_map(gen_style) → rev → extend` pipeline, so it lives here once. -fn extend_reversed_styles<'a>( - ast_builder: &AstBuilder<'a>, - properties: &mut Vec>, - styles: &[ExtractStyleProp<'a>], - filename: Option<&str>, -) { - properties.extend( - styles - .iter() - .flat_map(|style| gen_style(ast_builder, style, filename)) - .rev(), - ); +#[path = "gen_style_conditional.rs"] +mod conditional; + +#[derive(Clone, Debug)] +pub struct VariableUse { + pub original: crate::extract_style::ExtractDynamicStyle, + pub consumer: String, + pub route: Option, + pub config: css::allocation_input::CapturedNameConfig, + pub invocation: crate::extract_style::compiler_associations::InvocationId, + pub scope: Option, + pub receipts: Vec, } +use crate::extract_style::compiler_projection::InlineAssignment; pub fn gen_styles<'a>( ast_builder: &AstBuilder<'a>, style_props: &[ExtractStyleProp<'a>], filename: Option<&str>, ) -> Option> { - if style_props.is_empty() { - return None; - } - let mut properties: Vec<_> = Vec::with_capacity(style_props.len()); - extend_reversed_styles(ast_builder, &mut properties, style_props, filename); - if properties.is_empty() { - return None; - } - Some(Expression::new_object_expression( - SPAN, - oxc_allocator::Vec::from_iter_in(properties, ast_builder), - ast_builder, - )) -} -/// Push `cond ? value : undefined` (`value_when_true`) or `cond ? undefined : value` -/// object properties for every property generated from `styles`. -fn push_one_sided_conditional<'a>( - ast_builder: &AstBuilder<'a>, - properties: &mut Vec>, - condition: &Expression<'a>, - styles: &ExtractStyleProp<'a>, - filename: Option<&str>, - value_when_true: bool, -) { - for p in gen_style(ast_builder, styles, filename) { - if let ObjectPropertyKind::ObjectProperty(p) = p { - let value = p.value.clone_in(ast_builder.allocator()); - let undefined = Expression::new_identifier(SPAN, "undefined", ast_builder); - let (consequent, alternate) = if value_when_true { - (value, undefined) - } else { - (undefined, value) - }; - properties.push(ObjectPropertyKind::new_object_property( - SPAN, - PropertyKind::Init, - p.key.clone_in(ast_builder.allocator()), - Expression::new_conditional_expression( - SPAN, - condition.clone_in(ast_builder.allocator()), - consequent, - alternate, - ast_builder, - ), - false, - false, - false, - ast_builder, - )); - } + conditional::InlineProjection { + ast: ast_builder, + filename, } + .styles(style_props) } fn gen_style<'a>( @@ -92,12 +42,18 @@ fn gen_style<'a>( filename: Option<&str>, ) -> Vec> { let mut properties = vec![]; + if let ExtractStyleProp::Evaluated { binding, .. } = style { + properties.push(ObjectPropertyKind::new_spread_property( + SPAN, + crate::assignment_lowering::projection(ast_builder, binding, 1), + ast_builder, + )); + } if let ExtractStyleProp::Static(st) = style { - if let Some(StyleProperty::Variable { + if let Some(InlineAssignment { variable_name, identifier, - .. - }) = st.extract(filename) + }) = compiler_projection::inline(st, filename) { properties.push(ObjectPropertyKind::new_object_property( SPAN, @@ -120,123 +76,32 @@ fn gen_style<'a>( )); } } else if let ExtractStyleProp::StaticArray(res) = style { - extend_reversed_styles(ast_builder, &mut properties, res, filename); + conditional::InlineProjection { + ast: ast_builder, + filename, + } + .extend_reversed(&mut properties, res); } else if let ExtractStyleProp::Conditional { condition, consequent, alternate, } = style { - let r = (consequent, alternate); - if let (None, Some(c)) = r { - push_one_sided_conditional(ast_builder, &mut properties, condition, c, filename, false); - } else if let (Some(c), None) = r { - push_one_sided_conditional(ast_builder, &mut properties, condition, c, filename, true); - } else if let (Some(c), Some(a)) = r { - let collect_c = gen_style(ast_builder, c, filename); - let collect_a = gen_style(ast_builder, a, filename); - if collect_c.is_empty() && collect_a.is_empty() { - return vec![]; - } - // Index the alternate branch by key ONCE so the two-sided merge is O(C+A) - // instead of the old O(C*A) nested `iter().any(..)` key comparisons. The map - // stores each key's FIRST index in `collect_a` (only inserting on the first - // sight of a key), exactly reproducing the previous "first matching q wins" - // ternary. `key.name()` is a borrowed `Cow` for the common static-ident - // props, so keys are cheap to hash and don't allocate. - let mut a_by_key: FxHashMap, usize> = - FxHashMap::with_capacity_and_hasher(collect_a.len(), FxBuildHasher); - for (j, q) in collect_a.iter().enumerate() { - if let ObjectPropertyKind::ObjectProperty(q) = q - && let Some(name) = q.key.name() - { - a_by_key.entry(name).or_insert(j); - } - } - // Set of consequent keys: an alternate entry is emitted alone (a-side pass - // below) iff NO consequent shares its key — identical to the old - // `collect_c.iter().any(..)` probe, now a single O(1) set lookup. - let mut c_keys: FxHashSet> = - FxHashSet::with_capacity_and_hasher(collect_c.len(), FxBuildHasher); - - for p in &collect_c { - // Written as "default false, set on success" rather than an - // if/else chain: the `else { false }` arms only ever existed to - // supply that default, and as bare `false` lines coverage could - // not attribute hits to them. - let mut matched = false; - if let ObjectPropertyKind::ObjectProperty(p) = p - && let Some(name) = p.key.name() - { - c_keys.insert(name.clone()); - if let Some(&j) = a_by_key.get(&name) - && let ObjectPropertyKind::ObjectProperty(q) = &collect_a[j] - { - properties.push(ObjectPropertyKind::new_object_property( - SPAN, - PropertyKind::Init, - p.key.clone_in(ast_builder.allocator()), - Expression::new_conditional_expression( - SPAN, - condition.clone_in(ast_builder.allocator()), - p.value.clone_in(ast_builder.allocator()), - q.value.clone_in(ast_builder.allocator()), - ast_builder, - ), - false, - false, - false, - ast_builder, - )); - matched = true; - } - } - if !matched && let ObjectPropertyKind::ObjectProperty(p) = p { - properties.push(ObjectPropertyKind::new_object_property( - SPAN, - PropertyKind::Init, - p.key.clone_in(ast_builder.allocator()), - p.value.clone_in(ast_builder.allocator()), - false, - false, - false, - ast_builder, - )); - } - } - - for q in &collect_a { - // Emit an alternate-only property when it has no consequent counterpart: - // either its key is absent from `c_keys`, or it has no resolvable key at - // all (the old probe's `matches!` never matched such an entry either). - let unmatched = if let ObjectPropertyKind::ObjectProperty(qq) = q { - qq.key.name().is_none_or(|name| !c_keys.contains(&name)) - } else { - false - }; - if unmatched && let ObjectPropertyKind::ObjectProperty(q) = q { - properties.push(ObjectPropertyKind::new_object_property( - SPAN, - PropertyKind::Init, - q.key.clone_in(ast_builder.allocator()), - q.value.clone_in(ast_builder.allocator()), - false, - false, - false, - ast_builder, - )); - } + properties.extend( + conditional::InlineProjection { + ast: ast_builder, + filename, } - } + .conditional(condition, (consequent.as_deref(), alternate.as_deref())), + ); } else if let ExtractStyleProp::MemberExpression { map, expression } = style { let mut tmp_map = BTreeMap::>::new(); for (key, value) in map { for style in value.extract() { - if let Some(StyleProperty::Variable { + if let Some(InlineAssignment { variable_name, identifier, - .. - }) = style.extract(filename) + }) = compiler_projection::inline(&style, filename) { tmp_map .entry(variable_name) @@ -306,24 +171,9 @@ fn gen_style<'a>( )); } } - // Cache each property's key once (`PropertyKey::name()` may allocate for computed - // keys) instead of recomputing it twice per comparison. The cached key stays a - // borrowed `Cow` for the common `StaticIdentifier` props (`color`, `padding`, - // ...), so sorting no longer heap-allocates an owned `String` per property. - // `Cow: Ord` compares by contents, and `Reverse` keeps the existing descending - // order, so the generated property order is byte-identical. - properties.sort_by_cached_key(|p| std::cmp::Reverse(object_property_key(p))); properties } -fn object_property_key<'k>(p: &ObjectPropertyKind<'k>) -> Option> { - if let ObjectPropertyKind::ObjectProperty(p) = p { - p.key.name() - } else { - None - } -} - #[cfg(test)] #[allow(clippy::expect_used, clippy::unwrap_used)] mod tests { diff --git a/libs/extractor/src/gen_style_conditional.rs b/libs/extractor/src/gen_style_conditional.rs new file mode 100644 index 000000000..8afe1cd7f --- /dev/null +++ b/libs/extractor/src/gen_style_conditional.rs @@ -0,0 +1,227 @@ +use super::{ExtractStyleProp, gen_style}; +use oxc_allocator::{CloneIn, GetAllocator}; +use oxc_ast::{ + ast::{Expression, ObjectPropertyKind, PropertyKind}, + builder::AstBuilder, +}; +use oxc_span::SPAN; +use rustc_hash::{FxBuildHasher, FxHashMap, FxHashSet}; + +pub(super) struct InlineProjection<'b, 'a> { + pub(super) ast: &'b AstBuilder<'a>, + pub(super) filename: Option<&'b str>, +} + +impl<'a> InlineProjection<'_, 'a> { + /// Extend with the same lazy, reversed leaf-generation schedule at every depth. + pub(super) fn extend_reversed( + &self, + properties: &mut Vec>, + styles: &[ExtractStyleProp<'a>], + ) { + properties.extend( + styles + .iter() + .flat_map(|style| gen_style(self.ast, style, self.filename)) + .rev(), + ); + } + + pub(super) fn styles(&self, style_props: &[ExtractStyleProp<'a>]) -> Option> { + let ast_builder = self.ast; + if style_props.is_empty() { + return None; + } + let mut properties: Vec<_> = Vec::with_capacity(style_props.len()); + self.extend_reversed(&mut properties, style_props); + let mut assigned = FxHashSet::default(); + properties.retain(|property| match property { + ObjectPropertyKind::ObjectProperty(property) => property + .key + .name() + .is_none_or(|key| assigned.insert(key.into_owned())), + ObjectPropertyKind::SpreadProperty(_) => true, + }); + if properties.is_empty() { + return None; + } + Some(Expression::new_object_expression( + SPAN, + oxc_allocator::Vec::from_iter_in(properties, ast_builder), + ast_builder, + )) + } + + fn one_sided( + &self, + condition: &Expression<'a>, + branch: (&ExtractStyleProp<'a>, bool), + ) -> Vec> { + let ast_builder = self.ast; + let (styles, value_when_true) = branch; + let mut properties = Vec::new(); + for p in gen_style(ast_builder, styles, self.filename) { + if let ObjectPropertyKind::ObjectProperty(p) = p { + let value = p.value.clone_in(ast_builder.allocator()); + let undefined = Expression::new_identifier(SPAN, "undefined", ast_builder); + let (consequent, alternate) = if value_when_true { + (value, undefined) + } else { + (undefined, value) + }; + properties.push(ObjectPropertyKind::new_object_property( + SPAN, + PropertyKind::Init, + p.key.clone_in(ast_builder.allocator()), + Expression::new_conditional_expression( + SPAN, + condition.clone_in(ast_builder.allocator()), + consequent, + alternate, + ast_builder, + ), + false, + false, + false, + ast_builder, + )); + } + } + properties + } + + pub(super) fn conditional( + &self, + condition: &Expression<'a>, + branches: (Option<&ExtractStyleProp<'a>>, Option<&ExtractStyleProp<'a>>), + ) -> Vec> { + let ast_builder = self.ast; + let mut properties = Vec::new(); + match branches { + (None, Some(c)) => return self.one_sided(condition, (c, false)), + (Some(c), None) => return self.one_sided(condition, (c, true)), + (None, None) => {} + (Some(c), Some(a)) => { + let collect_c = gen_style(ast_builder, c, self.filename); + let collect_a = gen_style(ast_builder, a, self.filename); + if collect_c.is_empty() && collect_a.is_empty() { + return vec![]; + } + // Keep the first alternate entry for each key, as before the split. + let mut a_by_key: FxHashMap, usize> = + FxHashMap::with_capacity_and_hasher(collect_a.len(), FxBuildHasher); + for (j, q) in collect_a.iter().enumerate() { + if let ObjectPropertyKind::ObjectProperty(q) = q + && let Some(name) = q.key.name() + { + a_by_key.entry(name).or_insert(j); + } + } + let mut c_keys: FxHashSet> = + FxHashSet::with_capacity_and_hasher(collect_c.len(), FxBuildHasher); + for p in &collect_c { + let mut matched = false; + if let ObjectPropertyKind::ObjectProperty(p) = p + && let Some(name) = p.key.name() + { + c_keys.insert(name.clone()); + if let Some(&j) = a_by_key.get(&name) + && let ObjectPropertyKind::ObjectProperty(q) = &collect_a[j] + { + properties.push(ObjectPropertyKind::new_object_property( + SPAN, + PropertyKind::Init, + p.key.clone_in(ast_builder.allocator()), + Expression::new_conditional_expression( + SPAN, + condition.clone_in(ast_builder.allocator()), + p.value.clone_in(ast_builder.allocator()), + q.value.clone_in(ast_builder.allocator()), + ast_builder, + ), + false, + false, + false, + ast_builder, + )); + matched = true; + } + } + if !matched && let ObjectPropertyKind::ObjectProperty(p) = p { + properties.push(ObjectPropertyKind::new_object_property( + SPAN, + PropertyKind::Init, + p.key.clone_in(ast_builder.allocator()), + p.value.clone_in(ast_builder.allocator()), + false, + false, + false, + ast_builder, + )); + } + } + for q in &collect_a { + let unmatched = if let ObjectPropertyKind::ObjectProperty(qq) = q { + qq.key.name().is_none_or(|name| !c_keys.contains(&name)) + } else { + false + }; + if unmatched && let ObjectPropertyKind::ObjectProperty(q) = q { + properties.push(ObjectPropertyKind::new_object_property( + SPAN, + PropertyKind::Init, + q.key.clone_in(ast_builder.allocator()), + q.value.clone_in(ast_builder.allocator()), + false, + false, + false, + ast_builder, + )); + } + } + } + } + properties + } +} + +#[cfg(test)] +mod tests { + #[test] + #[serial_test::serial] + fn inline_protocol_emits_no_variables_when_both_conditional_branches_are_absent() { + // Given: an actual parsed condition and the public compiler IR's absent branches. + crate::compiler_policy::tests::reset(); + let before = ( + css::class_map::get_class_map(), + css::file_map::get_file_map(), + css::file_map::get_original_ids(), + css::file_map::get_canonical_map(), + ); + let allocator = oxc_allocator::Allocator::default(); + let condition = oxc_parser::Parser::new(&allocator, "flag", oxc_span::SourceType::tsx()) + .parse_expression(); + assert!(condition.is_ok()); + condition.into_iter().for_each(|condition| { + let styles = [crate::ExtractStyleProp::Conditional { + condition, + consequent: None, + alternate: None, + }]; + let ast = oxc_ast::builder::AstBuilder::new(&allocator); + // When: the real inline compiler projects the typed empty conditional. + let generated = crate::gen_style::gen_styles(&ast, &styles, Some("coverage.tsx")); + // Then: neither an empty style object nor a variable/name allocation is invented. + assert!(generated.is_none()); + assert_eq!( + ( + css::class_map::get_class_map(), + css::file_map::get_file_map(), + css::file_map::get_original_ids(), + css::file_map::get_canonical_map() + ), + before + ); + }); + } +} diff --git a/libs/extractor/src/imported_constants.rs b/libs/extractor/src/imported_constants.rs index a600d1deb..200507436 100644 --- a/libs/extractor/src/imported_constants.rs +++ b/libs/extractor/src/imported_constants.rs @@ -15,14 +15,16 @@ use oxc_ast::builder::AstBuilder; use oxc_ast_visit::{Visit, VisitMut, walk, walk_mut}; use oxc_parser::Parser; use oxc_semantic::{Scoping, SemanticBuilder}; -use oxc_span::{SPAN, SourceType}; +use oxc_span::{GetSpan, SPAN, SourceType}; use oxc_syntax::number::NumberBase; use oxc_syntax::operator::BinaryOperator; use oxc_syntax::symbol::SymbolId; use rustc_hash::{FxHashMap, FxHashSet}; +use crate::provenance::mark; use crate::stylex::StylexFunction; use crate::{ExtractOption, ModuleResolver}; +use css::Naming; #[derive(Clone, Debug)] enum Constant { @@ -350,15 +352,10 @@ pub(crate) fn inline_constants<'a>( if read.names.is_empty() { return Inlined::default(); } - let mut modules = Modules { - resolver, - option, - exports: FxHashMap::default(), - loading: Vec::new(), - }; + let mut modules = Modules::new(resolver, option); let mut symbols: FxHashMap = FxHashMap::default(); let mut inlined = Inlined::default(); - let (scoping, reads_math) = { + let (scoping, reads_math, namings) = { let mut scope = ModuleScope::new(filename, program, None); scope .style_names @@ -424,13 +421,11 @@ pub(crate) fn inline_constants<'a>( // Scoping is only worth building when a style reads a name that may // hold a constant let reads_math = read.names.contains("Math") && !scope.binds("Math"); - if !reads_math && !read.names.iter().any(|name| scope.binds(name)) { - return Inlined::default(); - } let scoping = SemanticBuilder::new() .build(program) .semantic .into_scoping(); + let namings = crate::source_naming::symbols(program, &scoping, option); for name in &read.names { let bound = scope.binds(name); let constant = scope.lookup(&mut modules, name); @@ -477,14 +472,16 @@ pub(crate) fn inline_constants<'a>( symbols.extend(symbol.get().map(|symbol| (symbol, constant.clone()))); } } - (scoping, reads_math) + (scoping, reads_math, namings) }; inlined.dependencies = modules.exports.into_keys().collect(); - if !symbols.is_empty() || reads_math { + if !symbols.is_empty() || !namings.is_empty() || reads_math { Inline { ast_builder, scoping: &scoping, symbols: &symbols, + namings: &namings, + ambient: Naming::Own, style_roots: &style_roots, apis: &apis, objects: false, @@ -533,12 +530,7 @@ impl<'p, 'a, 'r> ChangeCheck<'p, 'a, 'r> { } Self { scope: std::cell::RefCell::new(scope), - modules: std::cell::RefCell::new(Modules { - resolver, - option, - exports: FxHashMap::default(), - loading: Vec::new(), - }), + modules: std::cell::RefCell::new(Modules::new(resolver, option)), } } @@ -721,7 +713,16 @@ struct Modules<'r> { loading: Vec, } -impl Modules<'_> { +impl<'r> Modules<'r> { + fn new(resolver: Option<&'r ModuleResolver>, option: &'r ExtractOption) -> Self { + Self { + resolver, + option, + exports: FxHashMap::default(), + loading: Vec::new(), + } + } + fn exports( &mut self, specifier: &str, @@ -1165,10 +1166,9 @@ impl<'p, 'a> ModuleScope<'p, 'a> { } ImportDeclarationSpecifier::ImportNamespaceSpecifier(_) => Imported::Namespace, }; - self.imports.insert( - specifier.local().name.to_string(), - (import.source.value.to_string(), imported), - ); + let local = specifier.local().name.to_string(); + self.imports + .insert(local, (import.source.value.to_string(), imported)); } } /// `const x = require('m')` and `const { a, b: c } = require('m')` @@ -1693,6 +1693,10 @@ struct Inline<'s, 'a> { ast_builder: &'s AstBuilder<'a>, scoping: &'s Scoping, symbols: &'s FxHashMap, + /// What each import, or constant reading one, depends on + namings: &'s FxHashMap, + /// What the condition choosing the code being visited depends on + ambient: Naming, style_roots: &'s FxHashSet<&'s str>, apis: &'s StyleApis<'s>, /// Inside what the build reads as style objects @@ -1701,7 +1705,54 @@ struct Inline<'s, 'a> { styles: bool, } +/// What the bindings an expression reads depend on +struct Dependencies<'s> { + scoping: &'s Scoping, + namings: &'s FxHashMap, + naming: Naming, +} + +#[cfg(test)] +#[path = "dependency_coverage_tests.rs"] +mod dependency_coverage_tests; + +impl<'a> Visit<'a> for Dependencies<'_> { + fn visit_call_expression(&mut self, call: &oxc_ast::ast::CallExpression<'a>) { + if matches!(&call.callee, Expression::Identifier(id) if id.name == "require") { + self.naming = Naming::Risky; + } + walk::walk_call_expression(self, call); + } + + fn visit_import_expression(&mut self, import: &oxc_ast::ast::ImportExpression<'a>) { + self.naming = Naming::Risky; + walk::walk_import_expression(self, import); + } + fn visit_identifier_reference(&mut self, identifier: &oxc_ast::ast::IdentifierReference<'a>) { + if let Some(naming) = identifier + .reference_id + .get() + .and_then(|reference| self.scoping.get_reference(reference).symbol_id()) + .and_then(|symbol| self.namings.get(&symbol)) + { + self.naming = self.naming.join(*naming); + } + } +} + impl<'a> Inline<'_, 'a> { + fn naming_of(&self, expression: &Expression<'a>) -> Naming { + let mut dependencies = Dependencies { + scoping: self.scoping, + namings: self.namings, + naming: self + .ambient + .join(crate::provenance::provenance_of(expression)), + }; + dependencies.visit_expression(expression); + dependencies.naming + } + fn constant(&self, expression: &Expression<'a>) -> Option { match expression { Expression::Identifier(identifier) => { @@ -1795,17 +1846,21 @@ impl<'a> Inline<'_, 'a> { /// The side of a condition or of `&&`, `||` or `??` a constant or literal /// chooses, taken out of `expression` - fn chosen(&self, expression: &mut Expression<'a>) -> Option> { + fn chosen(&self, expression: &mut Expression<'a>) -> Option<(Expression<'a>, Naming)> { use oxc_allocator::TakeIn; let allocator = self.ast_builder; match expression { Expression::ConditionalExpression(conditional) => { - Some(if self.holds(&conditional.test)? { - conditional.consequent.take_in(allocator) - } else { - conditional.alternate.take_in(allocator) - }) + let naming = self.naming_of(&conditional.test); + Some(( + if self.holds(&conditional.test)? { + conditional.consequent.take_in(allocator) + } else { + conditional.alternate.take_in(allocator) + }, + naming, + )) } Expression::LogicalExpression(logical) => { let left = self.operand(&logical.left)?; @@ -1816,11 +1871,15 @@ impl<'a> Inline<'_, 'a> { !matches!(left, Constant::Null | Constant::Undefined) } }; - Some(if keep_left { - logical.left.take_in(allocator) - } else { - logical.right.take_in(allocator) - }) + let naming = self.naming_of(&logical.left); + Some(( + if keep_left { + logical.left.take_in(allocator) + } else { + logical.right.take_in(allocator) + }, + naming, + )) } _ => None, } @@ -1836,24 +1895,24 @@ impl<'a> Inline<'_, 'a> { .is_none()) } - fn literal(&self, constant: &Constant) -> Option> { + fn literal(&self, constant: &Constant, span: oxc_span::Span) -> Option> { let builder = self.ast_builder; match constant { Constant::String(value) => Some(Expression::new_string_literal( - SPAN, + span, Str::from_in(value.as_str(), builder.allocator()), None, builder, )), Constant::Number(value) => Some(Expression::new_numeric_literal( - SPAN, + span, *value, None, NumberBase::Decimal, builder, )), - Constant::Null => Some(Expression::new_null_literal(SPAN, builder)), - Constant::Bool(value) => Some(Expression::new_boolean_literal(SPAN, *value, builder)), + Constant::Null => Some(Expression::new_null_literal(span, builder)), + Constant::Bool(value) => Some(Expression::new_boolean_literal(span, *value, builder)), Constant::Record(entries) if self.objects => { let mut properties = oxc_allocator::Vec::with_capacity_in(entries.len(), builder); for (key, value) in entries.iter() { @@ -1868,21 +1927,21 @@ impl<'a> Inline<'_, 'a> { builder, ), ), - self.literal(value)?, + self.literal(value, span)?, false, false, false, builder, )); } - Some(Expression::new_object_expression(SPAN, properties, builder)) + Some(Expression::new_object_expression(span, properties, builder)) } Constant::Array(values) if self.objects => { let mut elements = oxc_allocator::Vec::with_capacity_in(values.len(), builder); for value in values.iter() { - elements.push(self.literal(value)?.into()); + elements.push(self.literal(value, span)?.into()); } - Some(Expression::new_array_expression(SPAN, elements, builder)) + Some(Expression::new_array_expression(span, elements, builder)) } _ => None, } @@ -1907,18 +1966,74 @@ impl<'a> Inline<'_, 'a> { impl<'a> VisitMut<'a> for Inline<'_, 'a> { fn visit_expression(&mut self, expression: &mut Expression<'a>) { if self.styles { - if let Some(literal) = self + let structural = match &*expression { + Expression::ObjectExpression(object) => { + object + .properties + .iter() + .fold(Naming::Own, |naming, property| { + naming.join(match property { + ObjectPropertyKind::SpreadProperty(spread) => { + self.naming_of(&spread.argument) + } + ObjectPropertyKind::ObjectProperty(property) + if property.computed => + { + property + .key + .as_expression() + .map_or(Naming::Own, |key| self.naming_of(key)) + } + ObjectPropertyKind::ObjectProperty(_) => Naming::Own, + }) + }) + } + Expression::ArrayExpression(array) => { + array.elements.iter().fold(Naming::Own, |naming, element| { + naming.join(match element { + ArrayExpressionElement::SpreadElement(spread) => { + self.naming_of(&spread.argument) + } + _ => Naming::Own, + }) + }) + } + _ => Naming::Own, + }; + mark(expression, structural); + if let Some(mut literal) = self .constant(expression) - .and_then(|constant| self.literal(&constant)) + .and_then(|constant| self.literal(&constant, expression.span())) { + mark(&mut literal, self.naming_of(expression)); *expression = literal; return; } - if let Some(chosen) = self.chosen(expression) { + let owner_start = expression.span().start; + if let Some((mut chosen, naming)) = self.chosen(expression) { + crate::sparse_sites::retain_folded_owner(chosen.span().start, owner_start); + crate::sparse_sites::retain_folded_owner( + crate::utils::unwrap_syntax_only(&chosen).span().start, + owner_start, + ); + let outer = self.ambient; + self.ambient = outer.join(naming); + mark(&mut chosen, self.ambient); *expression = chosen; self.visit_expression(expression); + self.ambient = outer; return; } + if let Expression::Identifier(identifier) = &*expression + && let Some(naming) = identifier + .reference_id + .get() + .and_then(|reference| self.scoping.get_reference(reference).symbol_id()) + .and_then(|symbol| self.namings.get(&symbol)) + .copied() + { + mark(expression, naming); + } } walk_mut::walk_expression(self, expression); } diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index a5853d5b5..d914cff2c 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -1,7 +1,44 @@ mod as_visit; +#[cfg(test)] +mod assignment_blocker_support; +#[cfg(test)] +mod assignment_blocker_tests; +mod assignment_capture; +mod assignment_composition; +mod assignment_consumers; +mod assignment_lowering; +#[cfg(test)] +mod assignment_lowering_tests; +mod assignment_member; +mod assignment_object; +mod assignment_owner; +#[cfg(test)] +mod assignment_residual_tests; +#[cfg(test)] +mod assignment_test_support; +mod assignment_value; mod build_time_values; +mod class_evaluation; +#[doc(hidden)] +pub mod compiler_policy; mod component; +#[cfg(all(test, feature = "counter-fixtures"))] +mod counter_fixture_capture_tests; +#[cfg(all(test, feature = "counter-fixtures"))] +mod counter_fixture_current_tests; +#[cfg(all(test, feature = "counter-fixtures"))] +mod counter_fixture_scope_tests; +#[cfg(feature = "counter-fixtures")] +#[doc(hidden)] +pub mod counter_test_support; +#[cfg(test)] +mod coverage_tests; mod css_utils; +mod element_evaluation; +mod evaluation_location; +#[cfg(test)] +mod evaluation_location_tests; +mod evaluation_origin; pub mod extract_style; mod extractor; mod gen_class_name; @@ -10,8 +47,26 @@ mod import_alias_visit; mod imported_constants; mod module_loader; mod mutations; +#[cfg(test)] +mod named_capture_order_tests; +#[cfg(test)] +mod named_capture_support; +#[cfg(test)] +mod named_capture_tests; +#[cfg(test)] +mod numbered_sites_tests; mod prop_modify_utils; +mod provenance; mod source_map; +mod source_naming; +mod sparse_sites; +mod static_assignment; +#[cfg(test)] +mod static_assignment_tests; +mod style_export_locations; +mod style_origin; +#[cfg(test)] +mod style_origin_tests; mod style_values; mod stylex; mod tailwind; @@ -19,6 +74,7 @@ mod util_type; mod utils; mod vanilla_extract; mod visit; +use crate::extract_style::compiler_projection; use crate::extract_style::extract_style_value::ExtractStyleValue; use crate::visit::DevupVisitor; use css::file_map::{canonical, get_file_num_by_filename, is_global}; @@ -50,6 +106,14 @@ pub enum ImportAlias { pub enum ExtractStyleProp<'a> { Static(ExtractStyleValue), StaticArray(Vec>), + Evaluated { + styles: Vec>, + source: Expression<'a>, + binding: String, + evaluation: Option>, + alternate_order: Option, + alternate_class: bool, + }, Conditional { condition: Expression<'a>, consequent: Option>>, @@ -84,6 +148,21 @@ impl<'a> ExtractStyleProp<'a> { ExtractStyleProp::StaticArray(arr) => { ExtractStyleProp::StaticArray(arr.iter().map(|s| s.clone_in(alloc)).collect()) } + ExtractStyleProp::Evaluated { + styles, + source, + binding, + evaluation, + alternate_order, + alternate_class, + } => Self::Evaluated { + styles: styles.iter().map(|style| style.clone_in(alloc)).collect(), + source: source.clone_in(alloc), + binding: binding.clone(), + evaluation: evaluation.as_ref().map(|value| value.clone_in(alloc)), + alternate_order: *alternate_order, + alternate_class: *alternate_class, + }, ExtractStyleProp::Conditional { condition, consequent, @@ -143,7 +222,8 @@ impl<'a> ExtractStyleProp<'a> { } (None, None) => vec![], }, - ExtractStyleProp::StaticArray(array) => { + ExtractStyleProp::StaticArray(array) + | ExtractStyleProp::Evaluated { styles: array, .. } => { array.iter().flat_map(ExtractStyleProp::extract).collect() } ExtractStyleProp::Expression { styles, .. } => styles.clone(), @@ -183,7 +263,8 @@ impl<'a> ExtractStyleProp<'a> { } (None, None) => vec![], }, - ExtractStyleProp::StaticArray(array) => array + ExtractStyleProp::StaticArray(array) + | ExtractStyleProp::Evaluated { styles: array, .. } => array .into_iter() .flat_map(ExtractStyleProp::into_extract) .collect(), @@ -281,20 +362,36 @@ fn extract_with_source_map( source_map: bool, resolver: Option<&ModuleResolver>, ) -> Result> { - extract_source(filename, code, None, option, source_map, resolver) + compiler_policy::current(compiler_policy::CompilerInput { + filename, + code, + option, + source_map, + resolver, + }) } -/// `evaluated` is the source `code` was computed from, with the layers of -/// edits, last made first, that map `code` back to it +/// The source `code` was computed from, with the layers of edits, last made +/// first, that map `code` back to it, and where values that depend on an +/// import that may differ between environments were written +#[derive(Clone, Copy)] +struct Evaluated<'a> { + source: &'a str, + edits: &'a [&'a [import_alias_visit::Edit]], + risky: &'a [(usize, usize)], +} + +/// `evaluated` is what `code` was computed from, when it was fn extract_source( filename: &str, code: &str, - evaluated: Option<(&str, &[&[import_alias_visit::Edit]])>, + evaluated: Option>, option: ExtractOption, source_map: bool, resolver: Option<&ModuleResolver>, ) -> Result> { // Step 1: Transform import aliases + css::atom_hoist::freeze_atom_plan(); // e.g., `import styled from '@emotion/styled'` → `import { styled } from '@devup-ui/react'` // e.g., `import { style } from '@vanilla-extract/css'` → `import { style } from '@devup-ui/react'` let (transformed_code, alias_edits) = import_alias_visit::transform_import_aliases_with_edits( @@ -319,8 +416,15 @@ fn extract_source( }); } + let stylesheet_naming = if utils::is_vanilla_extract_file(filename) { + source_naming::stylesheet(code, filename, &option) + } else { + css::Naming::Own + }; let mut dependencies = std::collections::BTreeSet::new(); let mut evaluation_error = None; + let _evaluation_origins = utils::is_vanilla_extract_file(filename) + .then(|| style_origin::EvaluationScope::enter(filename, code, &alias_edits)); // 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). @@ -344,6 +448,7 @@ fn extract_source( ), &option, &referenced, + stylesheet_naming, )? }; let code = vanilla_extract::collected_styles_to_code_with_keyframes( @@ -417,6 +522,24 @@ fn extract_source( if fatal_error { return Err("Parser panicked".into()); } + let (source, earlier_edits) = evaluated.map_or((code, &[][..]), |evaluated| { + (evaluated.source, evaluated.edits) + }); + let edits: Vec<&[import_alias_visit::Edit]> = std::iter::once(alias_edits.as_slice()) + .chain(earlier_edits.iter().copied()) + .collect(); + let _origins = if processed_code.is_some() { + style_origin::OriginScope::generated(&mut program) + } else { + style_origin::OriginScope::enter((filename, source), &edits, &program) + }; + if let Some(evaluated) = evaluated { + provenance::MarkRanges(evaluated.risky).visit_program(&mut program); + } + if processed_code.is_some() && stylesheet_naming == css::Naming::Risky { + provenance::MarkRanges(&[(0, code_to_parse.len())]).visit_program(&mut program); + } + let _sites = compiler_policy::sites((filename, source, processed_code.is_some()), &edits); let inlined = if processed_code.is_none() { imported_constants::inline_constants( &oxc_ast::builder::AstBuilder::new(&allocator), @@ -439,7 +562,11 @@ fn extract_source( visitor.import_stylex(inlined.stylex_vars, inlined.stylex_themes); visitor.unknown_bindings(&inlined.unknown); visitor.changed_bindings(inlined.changed.clone()); + let attempt = css::class_map::Attempt::begin(); visitor.visit_program(&mut program); + #[cfg(test)] + compiler_policy::tests::observe(false); + compiler_projection::check_terminal(filename, source, &edits)?; if let Some(error) = evaluation_error && imports_uncompiled(&program, &option.package) { @@ -450,7 +577,7 @@ fn extract_source( if (!visitor.errors.is_empty() || visitor.composes_unknown) && evaluated.is_none() && !utils::is_vanilla_extract_file(filename) - && let Some((computed, value_edits, read)) = build_time_values::evaluate( + && let Some((computed, value_edits, read, risky)) = build_time_values::evaluate( &transformed_code, filename, &option, @@ -458,10 +585,22 @@ fn extract_source( &inlined.unknown, ) { + compiler_projection::check_terminal(filename, source, &edits)?; + if compiler_policy::active() { + return Err(compiler_policy::retry( + (computed, value_edits, read, risky), + &alias_edits, + )); + } + drop(attempt); let mut output = extract_source( filename, &computed, - Some((code, &[value_edits.as_slice(), alias_edits.as_slice()])), + Some(Evaluated { + source: code, + edits: &[value_edits.as_slice(), alias_edits.as_slice()], + risky: &risky, + }), option, source_map, resolver, @@ -472,16 +611,16 @@ fn extract_source( output.dependencies = files.into_iter().collect(); return Ok(output); } - let (source, earlier_edits) = evaluated.unwrap_or((code, &[])); - let edits: Vec<&[import_alias_visit::Edit]> = std::iter::once(alias_edits.as_slice()) - .chain(earlier_edits.iter().copied()) - .collect(); + compiler_projection::check_terminal(filename, source, &edits)?; + visitor.errors.extend(provenance::site_errors()); visitor.errors.append(&mut visitor.unknown_parts); if !visitor.errors.is_empty() { let mut message = located_errors(filename, source, &edits, visitor.errors); message += &changed_notes(&message, filename, source, &edits, &inlined.changed); return Err(message.into()); } + compiler_projection::complete(filename, &option, &visitor.styles); + compiler_projection::check_terminal(filename, source, &edits)?; let codegen_options = if source_map { CodegenOptions { source_map_path: Some(PathBuf::from(filename)), @@ -491,9 +630,15 @@ fn extract_source( CodegenOptions::default() }; let result = Codegen::new().with_options(codegen_options).build(&program); - // A stylesheet's output is generated, so its map stays on that code let map = result.map.map(|map| { - if processed_code.is_some() || edits.iter().all(|edits| edits.is_empty()) { + if processed_code.is_some() { + source_map::remap( + map, + code_to_parse, + source, + &[&[(0, source.len(), code_to_parse.len())]], + ) + } else if edits.iter().all(|edits| edits.is_empty()) { map } else { source_map::remap(map, code_to_parse, source, &edits) @@ -501,8 +646,9 @@ fn extract_source( .to_json_string() }); + attempt.commit(); Ok(ExtractOutput { - styles: visitor.styles, + styles: visitor.styles.into_set(), code: result.code, map, css_file: Some(css_file), @@ -552,6 +698,9 @@ fn located_errors( edits: &[&[import_alias_visit::Edit]], mut errors: Vec<(u32, String)>, ) -> String { + for (offset, _) in &mut errors { + *offset = provenance::source_offset(*offset); + } errors.sort_unstable(); errors.dedup(); errors @@ -640,7 +789,10 @@ fn extract_class_map_from_code( partial_code: &str, option: &ExtractOption, style_names: &FxHashSet, + naming: css::Naming, ) -> Result, Box> { + let source = sparse_sites::source().unwrap_or_else(|| partial_code.to_string()); + let _reservation = compiler_projection::auxiliary(&source, filename); let source_type = SourceType::from_path(filename)?; let (bucket, global, css_file) = resolve_css_target(filename, option); let css_files = vec![css_file]; @@ -652,8 +804,17 @@ fn extract_class_map_from_code( .. } = Parser::new(&allocator, partial_code, source_type).parse(); if fatal_error { + compiler_projection::check_aux( + filename, + &source, + vec![(0, "auxiliary parser failed".into())], + )?; Ok(FxHashMap::default()) } else { + let _origins = style_origin::OriginScope::generated(&mut program); + if naming == css::Naming::Risky { + provenance::MarkRanges(&[(0, partial_code.len())]).visit_program(&mut program); + } let mut visitor = DevupVisitor::new( &allocator, filename, @@ -662,6 +823,8 @@ fn extract_class_map_from_code( if global { None } else { Some(bucket) }, ); visitor.visit_program(&mut program); + visitor.errors.extend(visitor.unknown_parts); + compiler_projection::check_aux(filename, &source, visitor.errors)?; let result = Codegen::new().build(&program); @@ -4365,7 +4528,7 @@ import clsx from 'clsx' extract( "test.tsx", r#"import { css } from "@devup-ui/core"; -; +; "#, ExtractOption { package: "@devup-ui/core".to_string(), @@ -4384,7 +4547,7 @@ import clsx from 'clsx' extract( "test.tsx", r#"import { css } from "@devup-ui/core"; -; +; "#, ExtractOption { package: "@devup-ui/core".to_string(), @@ -4403,7 +4566,7 @@ import clsx from 'clsx' extract( "test.tsx", r#"import { css } from "@devup-ui/core"; -; +; "#, ExtractOption { package: "@devup-ui/core".to_string(), @@ -13631,6 +13794,30 @@ globalCss({ assert!(!output.code.contains("css("), "{}", output.code); } + #[test] + #[serial] + fn test_stylesheets_read_nothing_that_differs_between_builds() { + for (read, fails) in [ + ("Math.random()", true), + ("Date.now()", true), + ("new Date()", true), + ("Date()", true), + ("new Date(0).getTime()", false), + ("Date.UTC(2020, 0, 1)", false), + ] { + reset_class_map(); + reset_file_map(); + let result = extract( + "when.css.ts", + &format!( + "import {{ style }} from '@devup-ui/react';\nconst n = {read};\nexport const a = style({{ opacity: String(n) }});" + ), + ExtractOption::default(), + ); + assert_eq!(result.is_err(), fails, "{read}"); + } + } + #[test] #[serial] fn test_vanilla_extract_execution_fallback() { @@ -14355,6 +14542,7 @@ export const card = style({ import_aliases: HashMap::new(), }, &style_names, + css::Naming::Own, ) .unwrap(); @@ -14558,45 +14746,51 @@ const Button = styled.button({ bg: 'red' }) #[rstest] #[case( r#""#, - r#"
"# + r#"
"# + )] + #[case( + r#""#, + r#"
"# )] - #[case(r#""#, r#"
"#)] #[case( r#""#, - r#"
"# + r#"
"# )] #[case( "", - "
" + "((__devupClass51) =>
)(`p-${size} OHdt14ba2xfy0fpkw6 ${tone}-text`)" )] #[case( r"", - r"
" + r#"((__devupClass51) =>
)(`OLpadding-v1rem \`q\` \${y} a\\b\rc ${x}`)"# )] #[case( "", - r#"
"# + r#"((__devupClass51) =>
)(`custom ${on ? "OLpadding-v1rem" : x}`)"# )] #[case( r#""#, - r#"
"# + r#"((__devupClass51) =>
)(on ? "OLpadding-v1rem" : "custom")"# )] #[case( r#""#, - r#"
"# + r#"((__devupClass51) =>
)(on ? "custom" : "OLpadding-v1rem")"# )] #[case( r#""#, - r#"
"# + r#"((__devupClass51) =>
)(on && "OLpadding-v1rem")"# )] #[case( r#""#, - r#"
"# + r#"((__devupClass51) =>
)("OLpadding-v1rem" || x)"# + )] + #[case( + r#""#, + r#"
"# )] - #[case(r#""#, r#"
"#)] #[case( "", - "-
" + "-((__devupClass51) =>
)(`icon-${name} ${a}${b}`)" )] #[case( r#""#, @@ -14604,17 +14798,20 @@ const Button = styled.button({ bg: 'red' }) )] #[case( r#""#, - r#"-
"# + r#"-((__devupClass51) =>
)(on && "custom")"# )] #[case( r#""#, - r#"-
"# + r#"-((__devupClass51) =>
)(on ? "a1" : "b1")"# )] #[case( "", - "-
" + "-((__devupClass51) =>
)(`custom ${x}`)" + )] + #[case( + "", + "-((__devupClass51) =>
)(cls)" )] - #[case("", "-
")] #[serial] fn test_tailwind_keeps_other_classes(#[case] jsx: &str, #[case] expected: &str) { reset_class_map(); @@ -15445,9 +15642,7 @@ globalStyle('body', { margin: 2 }) fn test_stylex_numbers_follow_stylex_units() { reset_class_map(); reset_file_map(); - let output = extract( - "test.tsx", - r"import stylex from '@stylexjs/stylex'; + let source = r"import stylex from '@stylexjs/stylex'; const styles = stylex.create({ base: { fontSize: 16, @@ -15463,10 +15658,8 @@ const styles = stylex.create({ }, dynamic: (opacity, delay) => ({ opacity, transitionDelay: delay, bottom: 3 }), }); -const result = stylex.props(styles.dynamic(o, d));", - ExtractOption::default(), - ) - .unwrap(); +const result = stylex.props(styles.dynamic(o, d));"; + let output = extract("test.tsx", source, ExtractOption::default()).unwrap(); let values = static_values(&output); for (property, value) in [ ("font-size", "16px"), @@ -15487,14 +15680,37 @@ const result = stylex.props(styles.dynamic(o, d));", "{property}: {value} not in {values:?}" ); } - assert!(output.code.contains(r#""--a": o"#), "{}", output.code); - assert!( + let opacity_site = css::Site { + file: css::sparse_site::SourceFile::Unnumbered(source.into()), + at: source.find("opacity, transitionDelay").unwrap(), + role: 0, + }; + let delay_site = css::Site { + file: css::sparse_site::SourceFile::Unnumbered(source.into()), + at: source.find("transitionDelay: delay").unwrap() + "transitionDelay: ".len(), + role: 0, + }; + assert_eq!( output - .code - .contains(r#"((v) => typeof v === "number" ? v + "ms" : v)(d)"#), - "{}", - output.code + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Dynamic(style) => Some(style.site.clone()), + _ => None, + }) + .collect::>(), + std::collections::HashSet::from([Some(opacity_site.clone()), Some(delay_site.clone())]) ); + for (site, value) in [ + (opacity_site, "o"), + ( + delay_site, + r#"((v) => typeof v === "number" ? v + "ms" : v)(d)"#, + ), + ] { + let assignment = format!(r#""{}": {value}"#, site.variable_name("")); + assert!(output.code.contains(&assignment), "{}", output.code); + } } #[test] @@ -19076,7 +19292,7 @@ export const f = (rest, handler, k) => ; export const h = (Base) => styled.div(Base); export const i = keyframes({ from: { opacity: 0 } } as const); -export const j = css(...{ bg: 'red' });", +export const j = css(...[{ bg: 'red' }]);", ExtractOption::default(), ) .unwrap() @@ -20489,7 +20705,7 @@ export const K = styled.div(base, cond && { color: 'blue' }, { margin: 1 });", ), ( "import { css } from '@devup-ui/react';\ncss({ color: 'red' }, ...rest);", - "Cannot compose `{ color: \"red\" }, ...rest` at build time: each style must be a rule object, a class, or a condition choosing between them", + "Cannot compose `{ color: \"red\" }, ...rest` at build time: each style must be a rule object, a class, or a condition choosing between them; pass the value itself instead of spreading it", ), ( "import { styled } from '@devup-ui/react';\nstyled.div({ color: 'red' }, getStyles());", diff --git a/libs/extractor/src/module_loader.rs b/libs/extractor/src/module_loader.rs index 4082ff395..3a7c8119b 100644 --- a/libs/extractor/src/module_loader.rs +++ b/libs/extractor/src/module_loader.rs @@ -14,6 +14,7 @@ use oxc_span::{GetSpan, SourceType}; use oxc_syntax::symbol::SymbolId; use rustc_hash::{FxHashMap, FxHashSet}; +use crate::evaluation_location::{CopiedSource, append_mapped}; use crate::{ExtractOption, ModuleResolver, utils::is_vanilla_extract_file}; /// The object the package's API is bound to while a stylesheet runs @@ -21,6 +22,21 @@ 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 @@ -172,20 +188,25 @@ impl<'r> ModuleLoader<'r> { let code = 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, - Some(resolver), - ) + let output = crate::compiler_policy::inherited(crate::compiler_policy::CompilerInput { + filename: &module.path, + code: &module.code, + option: self.option.clone(), + source_map: false, + resolver: Some(resolver), + }) .map_err(|error| error.to_string())?; self.dependencies.extend(output.dependencies); output.code } else { module.code }; - let script = crate::vanilla_extract::strip_typescript(&code, &module.path); + let instrumented = (!stylesheet && loading_for_stylesheet()) + .then(|| crate::evaluation_origin::instrument(&code, &module.path, self.option)); + let script = crate::vanilla_extract::strip_typescript( + instrumented.as_deref().unwrap_or(&code), + &module.path, + ); let module_script = module_script(&script, &module.path, self, false)?; // Live bindings: a read before the binding is initialized fails as it // does in an ES module @@ -227,7 +248,8 @@ impl<'r> ModuleLoader<'r> { pub(crate) struct ModuleScript { pub body: String, /// `(exported name, expression)` - exports: Vec<(String, String)>, + pub(crate) exports: Vec<(String, String)>, + pub(crate) copies: Vec, /// Modules re-exported whole spreads: Vec, /// Written with `module.exports` rather than `export` @@ -341,19 +363,31 @@ pub(crate) fn module_script( replacements.sort_by_key(|(start, ..)| *start); let text = |span: oxc_span::Span| { let mut code = String::new(); + let mut copies = Vec::new(); let mut copied = span.start; for (start, end, replacement) in &replacements { if *start >= span.start && *end <= span.end { + let length = usize::try_from(*start - copied).unwrap_or_default(); + copies.push(CopiedSource { + generated: code.len()..code.len() + length, + original: usize::try_from(copied).unwrap_or_default(), + }); code.push_str(&script[copied as usize..*start as usize]); code.push_str(replacement); copied = *end; } } + let length = usize::try_from(span.end - copied).unwrap_or_default(); + copies.push(CopiedSource { + generated: code.len()..code.len() + length, + original: usize::try_from(copied).unwrap_or_default(), + }); code.push_str(&script[copied as usize..span.end as usize]); - code + (code, copies) }; let mut body = String::with_capacity(script.len()); + let mut copies = Vec::new(); let mut exports = Vec::new(); let mut spreads = Vec::new(); for statement in &program.body { @@ -384,7 +418,7 @@ pub(crate) fn module_script( } } Statement::ExportDeclaration(export) => { - body.push_str(&text(export.declaration.span())); + append_mapped(&mut body, &mut copies, text(export.declaration.span())); body.push('\n'); for name in declared_names(&export.declaration) { exports.push((name.clone(), name)); @@ -418,10 +452,12 @@ pub(crate) fn module_script( }; let declaration = text(export.declaration.span()); let local = if let Some(id) = id { - body.push_str(&declaration); + append_mapped(&mut body, &mut copies, declaration); id.name.to_string() } else { - let _ = write!(body, "const __default__ = ({declaration});"); + body.push_str("const __default__ = ("); + append_mapped(&mut body, &mut copies, declaration); + body.push_str(");"); "__default__".to_string() }; body.push('\n'); @@ -435,7 +471,7 @@ pub(crate) fn module_script( } } statement => { - body.push_str(&text(statement.span())); + append_mapped(&mut body, &mut copies, text(statement.span())); body.push('\n'); } } @@ -443,6 +479,7 @@ pub(crate) fn module_script( Ok(ModuleScript { body, exports, + copies, spreads, commonjs, }) diff --git a/libs/extractor/src/named_capture_order_tests.rs b/libs/extractor/src/named_capture_order_tests.rs new file mode 100644 index 000000000..4083bd256 --- /dev/null +++ b/libs/extractor/src/named_capture_order_tests.rs @@ -0,0 +1,148 @@ +use crate::{ + ExtractStyleValue, + assignment_test_support::{extracted, jsx_js}, + extract_style::style_property::StyleProperty, + named_capture_support::evaluate, +}; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case(("`p-4 ${state.opaque('default')}`", "1rem", true))] +#[case(("state.flag?`p-4 ${state.opaque('default')}`:`p-8 ${state.opaque('default')}`", "1rem", true))] +#[case(("state.flag?`p-4 ${state.other()}`:`p-8 ${state.opaque('default')}`", "2rem", false))] +#[case(("state.left||`p-4 ${state.opaque('default')}`", "1rem", true))] +#[case(("state.left??`p-4 ${state.opaque('default')}`", "1rem", true))] +#[case(("state.flag&&`p-4 ${state.opaque('default')}`", "1rem", true))] +#[serial] +fn prepared_tailwind_variants_share_source_evaluation_when_order_is_conditional( + #[case] fixture: (&str, &str, bool), + #[values(false, true)] caller: bool, + #[values((false, false), (false, true), (true, false), (true, true))] order: (bool, bool), +) { + // Given: actual supported Tailwind surrounds an opaque call; order may be later. + let (class, value, flag) = fixture; + let (order_first, order_flag) = order; + let order = "state.order?5:10"; + let attrs = match (caller, order_first) { + (false, false) => format!( + "className={{{class}}} typography={{state.typo}} styleOrder={{{order}}} color={{state.color}}" + ), + (false, true) => format!( + "styleOrder={{{order}}} className={{{class}}} typography={{state.typo}} color={{state.color}}" + ), + (true, false) => { + format!("className:{class},typography:state.typo,styleOrder:{order},color:state.color") + } + (true, true) => { + format!("styleOrder:{order},className:{class},typography:state.typo,color:state.color") + } + }; + let expression = if caller { + format!("jsx(Box,{{{attrs}}})") + } else { + format!("") + }; + let source = format!( + "import {{Box}}from '@devup-ui/react';import {{jsx}}from 'react/jsx-runtime';function render(state){{return {expression}}}let trace=[];const state={{get flag(){{trace.push('flag');return {flag}}},get left(){{trace.push('left');return null}},get order(){{trace.push('order');return {order_flag}}},opaque(arg){{if(this!==state)throw Error('receiver');trace.push('opaque:'+arg);return {{toString(){{trace.push('coerce');return 'external'}}}}}},other(){{throw Error('unselected')}},get typo(){{trace.push('typo');return 'heading'}},get color(){{trace.push('color');return 'red'}}}};const jsx=(tag,props)=>props;const node=render(state);" + ); + let expected_trace = evaluate(&format!( + "const Box='div';{}", + jsx_js(&format!("{source}JSON.stringify(trace);")) + )); + let output = extracted(&source); + let records = output + .styles + .iter() + .filter_map(|style| match (style, style.extract(None)) { + (ExtractStyleValue::Static(style), Some(StyleProperty::ClassName(class))) + if style.property == "padding" && style.value == value => + { + Some((class, style.style_order)) + } + _ => None, + }) + .collect::>(); + let records = serde_json::to_string(&records).unwrap_or_else(|error| panic!("{error}")); + // When: the generated class selects one of both compile-time prepared orders. + let actual = evaluate(&format!( + "{} const records={records};JSON.stringify([trace,String(node.className).split(/\\s+/).flatMap(name=>records.filter(([key])=>key===name).map(([,order])=>order)),node.className.includes('external')]);", + jsx_js(&output.code) + )); + // Then: actual declaration order matches the captured later operand, no early read. + let selected_order = if order_flag { 5 } else { 10 }; + assert_eq!( + actual, + format!("[{expected_trace},[{selected_order}],true]"), + "{}", + output.code + ); +} + +#[rstest] +#[case("state.flag?state.opaque('default'):'p-8'", true, "null")] +#[case("state.flag?state.opaque('default'):'p-8'", false, "null")] +#[case("state.left&&`p-4 ${state.opaque('default')}`", true, "0")] +#[case("state.left||`p-4 ${state.opaque('default')}`", true, "'external'")] +#[case("state.left??`p-4 ${state.opaque('default')}`", true, "false")] +#[case( + "`p-4 ${state.left||state.opaque('default')} ${state.last()}`", + true, + "({toString(){trace.push('left-coerce');return 'external'}})" +)] +#[case( + "`p-4 ${state.left&&state.opaque('default')} ${state.last()}`", + true, + "({toString(){throw Error('unselected coercion')}})" +)] +#[case( + "`p-4 ${state.left} ${state.last()}`", + true, + "({toString(){trace.push('coerce-throw');throw Error('stop')}})" +)] +#[serial] +fn class_controls_remain_lazy_when_raw_truthiness_and_coercion_differ( + #[case] class: &str, + #[case] flag: bool, + #[case] left: &str, +) { + // Given: raw left values distinguish truthiness, nullishness and string conversion. + let source = format!( + "import {{Box}}from '@devup-ui/react';function render(state){{return }}let trace=[];const state={{get flag(){{trace.push('flag');return {flag}}},get left(){{trace.push('left');return {left}}},opaque(arg){{trace.push('opaque:'+arg);return {{toString(){{trace.push('coerce');return 'external'}}}}}},last(){{trace.push('last');return 'last'}},get order(){{trace.push('order');return false}},get color(){{trace.push('color');return 'red'}}}};let node,error;try{{node=render(state);String(node.className)}}catch(e){{error=e.message}}JSON.stringify([trace,error??null]);" + ); + // When: the source and compiled supported grammar execute with observable coercion. + let expected = evaluate(&format!("const Box='div';{}", jsx_js(&source))); + let actual = evaluate(&crate::assignment_test_support::compiled_jsx(&source)); + // Then: only selected branches coerce, once, before subsequent interpolation/order. + assert_eq!(actual, expected); +} + +#[rstest] +#[serial] +fn class_yield_stays_in_lazy_branch_when_generator_resumes(#[values(false, true)] flag: bool) { + // Given: a later order cannot be used inside the class argument, and yield is lazy. + let source = format!( + "import {{Box}}from '@devup-ui/react';function* render(state){{return }}let trace=[];const state={{get flag(){{trace.push('flag');return {flag}}},get value(){{trace.push('value');return 'pause'}},get order(){{trace.push('order');return false}},get color(){{trace.push('color');return 'red'}}}};const generator=render(state);const first=generator.next();const last=generator.next('external');JSON.stringify([trace,first.done,first.done?null:first.value,last.done]);" + ); + // When: native generator execution resumes a captured interpolation. + let expected = evaluate(&format!("const Box='div';{}", jsx_js(&source))); + let actual = evaluate(&crate::assignment_test_support::compiled_jsx(&source)); + // Then: yield remains in its original scope, before the later order read. + assert_eq!(actual, expected); +} + +#[rstest] +#[serial] +fn class_await_stays_lazy_when_a_later_order_operand_is_captured( + #[values(false, true)] flag: bool, +) { + // Given: a selected await and coercion precede a later interpolation and order. + let source = format!( + "import {{Box}}from '@devup-ui/react';async function render(state){{return }}let trace=[];const state={{get flag(){{trace.push('flag');return {flag}}},get value(){{trace.push('value');return Promise.resolve({{toString(){{trace.push('coerce');return 'external'}}}})}},last(){{trace.push('last');return 'last'}},get order(){{trace.push('order');return false}},get color(){{trace.push('color');return 'red'}}}};render(state).then(node=>JSON.stringify(trace),error=>JSON.stringify(['error',String(error)]));" + ); + // When: native promises resume the original lazy branch. + let expected = evaluate(&format!("const Box='div';{}", jsx_js(&source))); + let actual = evaluate(&crate::assignment_test_support::compiled_jsx(&source)); + // Then: no await moves into a new synchronous arrow body or crosses the later order. + assert_eq!(actual, expected); +} diff --git a/libs/extractor/src/named_capture_support.rs b/libs/extractor/src/named_capture_support.rs new file mode 100644 index 000000000..8fb0b0f76 --- /dev/null +++ b/libs/extractor/src/named_capture_support.rs @@ -0,0 +1,16 @@ +pub(super) fn evaluate(source: &str) -> String { + let source = serde_json::to_string(source).unwrap_or_else(|error| panic!("{error}")); + let script = format!( + "Promise.resolve(eval({source})).then(value=>process.stdout.write(String(value)),error=>{{console.error(error);process.exitCode=1}})" + ); + let output = std::process::Command::new("node") + .args(["-e", &script]) + .output() + .unwrap_or_else(|error| panic!("native Node observer: {error}")); + assert!( + output.status.success(), + "{}\n{script}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap_or_else(|error| panic!("{error}")) +} diff --git a/libs/extractor/src/named_capture_tests.rs b/libs/extractor/src/named_capture_tests.rs new file mode 100644 index 000000000..fc957f7df --- /dev/null +++ b/libs/extractor/src/named_capture_tests.rs @@ -0,0 +1,97 @@ +use crate::assignment_test_support::{compiled_jsx, jsx_js}; +use crate::named_capture_support::evaluate; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case( + "" +)] +#[case( + "jsx(Box,{boxSizing:'border-box',className:clsx(variants[variant],isError&&variant==='default'&&errorClassNames,className),typography:state.typo,...state.rest})" +)] +#[case( + "" +)] +#[case( + "jsx(Box,{className:state.combine('default',state.argument),typography:state.typo,...state.rest})" +)] +#[serial] +fn opaque_call_runs_at_authored_position_when_typography_and_spread_follow( + #[case] expression: &str, + #[values(false, true)] throws: bool, +) { + // Given: the rejected clsx shape and a receiver-sensitive opaque call. + let source = format!( + "import {{Box}}from '@devup-ui/react';import {{jsx}}from 'react/jsx-runtime';function render(state,clsx,variants,variant,isError,errorClassNames,className){{return {expression}}}let trace=[];const jsx=(tag,props)=>props;const variants={{get default(){{trace.push('variant');return 'p-4'}}}};let state={{get argument(){{trace.push('argument');return 'arg'}},combine(a,b){{if(this!==state)throw Error('receiver');trace.push('call:'+a+':'+b);if({throws})throw Error('stop');return 'external'}},get typo(){{trace.push('typo');return 'heading'}},get rest(){{trace.push('rest');return {{id:1}}}}}};const clsx=(...args)=>{{trace.push('clsx:'+args.join('|'));if({throws})throw Error('stop');return 'external'}};let node,error;try{{node=render(state,clsx,variants,'default',true,'error','custom')}}catch(e){{error=e.message}}JSON.stringify([trace,error??null,node?.className?.includes('external')??false]);" + ); + // When: source and compiled element expressions execute natively. + let expected = evaluate(&format!("const Box='div';{}", jsx_js(&source))); + let actual = evaluate(&compiled_jsx(&source)); + // Then: one intact call runs before later reads, or cuts them off on throw. + assert_eq!(actual, expected); +} + +#[rstest] +#[case("")] +#[case("jsx(Box,{typography})")] +#[case("{state.child}")] +#[case("jsx(Box,{title:state.before,typography,color:state.color,children:state.child})")] +#[serial] +fn raw_typography_is_read_once_when_global_getter_throws_on_second_read( + #[case] expression: &str, + #[values("'heading'", "''", "false", "0", "null")] value: &str, +) { + // Given: even a solitary identifier is an observable authored operand. + let source = format!( + "import {{Box}}from '@devup-ui/react';import {{jsx}}from 'react/jsx-runtime';function render(state){{return {expression}}}let trace=[],reads=0;Object.defineProperty(globalThis,'typography',{{get(){{trace.push('typo');if(++reads>1)throw Error('second');return {value}}}}});const state={{get before(){{trace.push('before');return 'title'}},get color(){{trace.push('color');return 'red'}},get child(){{trace.push('child');return 'child'}}}};const jsx=(tag,props)=>props;let node,error;try{{node=render(state)}}catch(e){{error=e.message}}" + ); + let expected = evaluate(&format!( + "const Box='div';{}", + jsx_js(&format!("{source}JSON.stringify([trace,error??null]);")) + )); + // When: the generated truthiness/interpolation executes. + let compiled = compiled_jsx(&source); + let actual = evaluate(&format!("{compiled}JSON.stringify([trace,error??null]);")); + // Then: the raw read is singular and siblings keep source order. + assert_eq!(actual, expected, "{compiled}"); + let classes = evaluate(&format!( + "{compiled}JSON.stringify(String(node?.className??'').split(/\\s+/).filter(v=>v.startsWith('typo-')));" + )); + assert_eq!( + classes, + if value == "'heading'" { + "[\"typo-heading\"]" + } else { + "[]" + } + ); +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn namespace_value_keeps_its_position_when_later_values_are_captured(#[case] throws: bool) { + // Given: xlink:href precedes handler/title/styles and children. + let source = format!( + "import {{Box}}from '@devup-ui/react';function render(state){{return {{state.child}}}}let trace=[];const state={{get href(){{trace.push('href');if({throws})throw Error('stop');return '#icon'}},get handler(){{trace.push('handler');return 1}},get title(){{trace.push('title');return 'title'}},get color(){{trace.push('color');return 'red'}},get child(){{trace.push('child');return 'child'}}}};let node,error;try{{node=render(state)}}catch(e){{error=e.message}}JSON.stringify([trace,error??null,node?.['xlink:href']??null]);" + ); + // When: quoted namespace keys are observed without changing their values. + let expected = evaluate(&format!("const Box='div';{}", jsx_js(&source))); + let actual = evaluate(&compiled_jsx(&source)); + // Then: an early namespace throw prevents every subsequent operand. + assert_eq!(actual, expected); +} + +#[test] +#[serial] +fn namespace_read_precedes_await_when_async_element_resumes() { + // Given: the namespace read precedes a later awaited style and child. + let source = "import {Box}from '@devup-ui/react';async function render(state){return {await state.child}}let trace=[];const state={get href(){trace.push('href');return '#icon'},get handler(){trace.push('handler');return 1},get title(){trace.push('title');return 'title'},get color(){trace.push('color');return Promise.resolve('red')},get child(){trace.push('child');return Promise.resolve('child')}};render(state).then(node=>JSON.stringify([trace,node['xlink:href'],node.children]),error=>JSON.stringify(['error',String(error)]));"; + // When: native promise jobs resume both source and emitted expressions. + let expected = evaluate(&format!("const Box='div';{}", jsx_js(source))); + let actual = evaluate(&compiled_jsx(source)); + // Then: namespace timing survives suspension at the actual invocation argument. + assert_eq!(actual, expected); +} diff --git a/libs/extractor/src/numbered_sites_tests.rs b/libs/extractor/src/numbered_sites_tests.rs new file mode 100644 index 000000000..9eb624da9 --- /dev/null +++ b/libs/extractor/src/numbered_sites_tests.rs @@ -0,0 +1,178 @@ +use css::{CounterOwner, file_map::*, sparse_site::SourceFile}; +use serial_test::serial; + +use crate::{ + extract_style::extract_static_style::ExtractStaticStyle, + sparse_sites::{ + SiteScope, binding_name, contains_site, counter_owner, normalize_source, + retain_folded_owner, site_at, site_errors, + }, +}; + +#[test] +#[serial] +fn original_ids_remain_independent_when_seeded_and_unknown_files_share_delivery() { + // Given: path-sorted originals collapse to fewer delivery numbers. + reset_file_map(); + reset_canonical_map(); + set_canonical_map(std::collections::HashMap::from([ + ("child.tsx".into(), "a.tsx".into()), + ("unknown.tsx".into(), "a.tsx".into()), + ])); + seed_file_numbers(&["z.tsx".into(), "child.tsx".into(), "a.tsx".into()]); + // When: registration happens before explicitly numbered scope entry. + let child = get_or_insert_original_id("child.tsx").unwrap_or_else(|error| panic!("{error}")); + let unknown = + get_or_insert_original_id("unknown.tsx").unwrap_or_else(|error| panic!("{error}")); + let name = { + let _scope = SiteScope::enter_numbered(unknown, "tone", &[]); + site_at(0, 0, "tone") + .unwrap_or_else(|| panic!("active numbered scope")) + .variable_name("prefix-") + }; + // Then: originals retain 0/1/2, append at 3, and never borrow delivery 0. + assert_eq!((child, unknown), (1, 3)); + assert_eq!(get_or_insert_original_id("unknown.tsx"), Ok(3)); + assert_eq!(original_id("z.tsx"), Some(2)); + assert_eq!(get_file_num_by_filename(&canonical("child.tsx")), 0); + assert_eq!(get_file_num_by_filename("z.tsx"), 1); + assert_eq!(get_file_num_by_filename(&canonical("unknown.tsx")), 0); + assert_eq!(get_file_map().len(), 2); + assert_eq!(name, "---prefix-Sd-a"); + reset_canonical_map(); + reset_file_map(); +} + +#[test] +#[serial] +fn identical_source_text_never_aliases_when_unknown_filenames_are_registered() { + // Given: two unseeded files with identical text and syntax roles. + reset_file_map(); + let source = "tone"; + // When: each exact filename is registered before its numbered scope. + let sites = ["first.tsx", "second.tsx"].map(|filename| { + let id = get_or_insert_original_id(filename).unwrap_or_else(|error| panic!("{error}")); + let _scope = SiteScope::enter_numbered(id, source, &[]); + site_at(0, 0, source).unwrap_or_else(|| panic!("active numbered scope")) + }); + // Then: distinct D9 IDs, not a source-text fallback, own the variable names. + assert_eq!(sites[0].file, SourceFile::D9(0)); + assert_eq!(sites[1].file, SourceFile::D9(1)); + assert_eq!(sites[0].variable_name(""), "---Sa-a"); + assert_eq!(sites[1].variable_name(""), "---Sb-a"); + assert_ne!(sites[0], sites[1]); + assert_eq!(get_file_map().len(), 0); + reset_file_map(); +} + +#[test] +#[serial] +fn no_style_original_consumes_an_id_when_registered_before_scope_entry() { + // Given: an unknown source has no styling at all. + reset_file_map(); + let plain = get_or_insert_original_id("plain.ts").unwrap_or_else(|error| panic!("{error}")); + { + let _scope = SiteScope::enter_numbered(plain, "export const answer = 42", &[]); + assert_eq!(counter_owner(), CounterOwner::D9(0)); + } + // When: a later source with a style registers and enters. + let styled = get_or_insert_original_id("styled.tsx").unwrap_or_else(|error| panic!("{error}")); + let site = { + let _scope = SiteScope::enter_numbered(styled, "tone", &[]); + site_at(0, 0, "tone").unwrap_or_else(|| panic!("active numbered scope")) + }; + // Then: no-style registration is retained independently of delivery allocation. + assert_eq!(get_or_insert_original_id("plain.ts"), Ok(0)); + assert_eq!(styled, 1); + assert_eq!(site.variable_name(""), "---Sb-a"); + assert_eq!(get_file_map().len(), 0); + reset_file_map(); +} + +#[test] +#[serial] +fn numbered_positions_and_roles_normalize_once_when_folded_after_multiple_edits() { + // Given: replacement then prelude insertion follow received BOM/CRLF bytes. + reset_file_map(); + let id = get_or_insert_original_id("normalized.tsx").unwrap_or_else(|error| panic!("{error}")); + let source = "\u{feff}a\r\nbcd"; + let replacement = [(4, 5, 3)]; + let prelude = [(0, 0, 10)]; + let _scope = SiteScope::enter_numbered(id, source, &[&prelude, &replacement]); + retain_folded_owner(19, 0x12 | (1 << 31)); + retain_folded_owner(0x14 | (1 << 31), 19); + // When: source roles are visited in reverse order, including a repeat assignment. + let right = site_at(20, 1, "right").unwrap_or_else(|| panic!("active scope")); + let left = site_at(20, 0, "left").unwrap_or_else(|| panic!("active scope")); + let reused = site_at(20, 0, "left"); + // Then: edit reversal precedes BOM/CR removal and folded ownership is applied once. + assert_eq!(normalize_source(source), "a\nbcd"); + assert_eq!((left.at, left.role, right.at, right.role), (2, 0, 2, 1)); + assert_eq!(left.variable_name(""), "---Sa-c"); + assert_eq!(right.variable_name(""), "---Sa-c-b"); + assert_eq!(reused, Some(left.clone())); + assert!(contains_site(oxc_span::Span::new(18, 19), &left)); + assert_eq!(site_errors(), vec![]); + reset_file_map(); +} + +#[test] +#[serial] +fn numbered_conflict_reports_branch_offset_when_folded_assignments_share_a_role() { + // Given: two selected branches retain the same authored owner. + reset_file_map(); + let id = get_or_insert_original_id("conflict.tsx").unwrap_or_else(|error| panic!("{error}")); + let _scope = SiteScope::enter_numbered(id, "abcdefghijkl", &[]); + retain_folded_owner(8, 2); + retain_folded_owner(10, 2); + let _ = site_at(8, 0, "first"); + // When: another assignment claims that owner's role in this extraction. + let _ = site_at(10, 0, "second"); + // Then: the branch's real offset and both conflicting assignments are reported. + let errors = site_errors(); + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].0, 10); + assert!(errors[0].1.contains("`first`")); + assert!(errors[0].1.contains("`second`")); + assert_eq!(site_errors(), vec![]); + reset_file_map(); +} + +#[test] +#[serial] +fn numbered_nested_scopes_restore_context_when_static_clones_are_deferred() { + // Given: the outer scope owns folded positions, assignments and a pending error. + reset_file_map(); + let outer_id = get_or_insert_original_id("outer.tsx").unwrap_or_else(|error| panic!("{error}")); + let inner_id = get_or_insert_original_id("inner.tsx").unwrap_or_else(|error| panic!("{error}")); + let outer = SiteScope::enter_numbered(outer_id, "__devupAssignment0", &[]); + retain_folded_owner(10, 2); + let _ = site_at(10, 0, "outer"); + let _ = site_at(10, 0, "conflict"); + let first = ExtractStaticStyle::new("color", "red", 0, None); + // When: an inner scope constructs a deferred clone, then ends. + let deferred = { + let _inner = SiteScope::enter_numbered(inner_id, "inner", &[]); + assert_eq!(counter_owner(), CounterOwner::D9(1)); + assert_eq!(binding_name(0), "__devupAssignment0"); + assert_eq!(site_at(10, 0, "inner").map(|site| site.at), Some(10)); + assert_eq!(site_errors(), vec![]); + let style = ExtractStaticStyle::new_basic("color", "red", 0, None); + let cloned = style.clone(); + assert_eq!(style.counter_owner, CounterOwner::D9(1)); + cloned + }; + let restored = ExtractStaticStyle::new("color", "red", 0, None); + // Then: outer metadata returns while the clone retains its construction owner. + assert_eq!(counter_owner(), CounterOwner::D9(0)); + assert_eq!(binding_name(0), "__devupAssignment0_"); + assert_eq!(site_at(10, 0, "outer").map(|site| site.at), Some(2)); + assert_eq!(site_errors().len(), 1); + assert_eq!(first.counter_owner, CounterOwner::D9(0)); + assert_eq!(restored.counter_owner, CounterOwner::D9(0)); + drop(outer); + assert_eq!(deferred.counter_owner, CounterOwner::D9(1)); + assert_eq!(counter_owner(), CounterOwner::Inactive); + assert_eq!(site_at(0, 0, "after"), None); + reset_file_map(); +} diff --git a/libs/extractor/src/prop_coverage_tests.rs b/libs/extractor/src/prop_coverage_tests.rs new file mode 100644 index 000000000..95a0e0a45 --- /dev/null +++ b/libs/extractor/src/prop_coverage_tests.rs @@ -0,0 +1,47 @@ +use super::may_hold; +use crate::coverage_tests::expression; +use oxc_allocator::Allocator; +use rstest::rstest; + +#[rstest] +#[case("({title:'text'})", false)] +#[case("({style:{color:'red'}})", true)] +#[case("({['title']:'text'})", true)] +#[case("({...rest})", true)] +fn literal_spread_keys_control_possible_inline_overrides( + #[case] source: &str, + #[case] expected: bool, +) { + // Given + let allocator = Allocator::default(); + let spread = expression(&allocator, source); + // When + let actual = may_hold(&spread, "style"); + // Then + assert_eq!(actual, expected); +} + +#[test] +fn unknown_property_keys_survive_object_prop_modification() { + // Given + let allocator = Allocator::default(); + let ast = oxc_ast::builder::AstBuilder::new(&allocator); + let mut source = expression(&allocator, "({[state.key]:state.value})"); + let expected = crate::coverage_tests::code(&source); + // When + let oxc_ast::ast::Expression::ObjectExpression(object) = &mut source else { + panic!("object fixture") + }; + let _result = super::modify_prop_object( + &ast, + &mut object.properties, + &mut [], + None, + None, + None, + None, + None, + ); + // Then + assert_eq!(crate::coverage_tests::code(&source), expected); +} diff --git a/libs/extractor/src/prop_modify_utils.rs b/libs/extractor/src/prop_modify_utils.rs index b8b1ce7da..5b41a4323 100644 --- a/libs/extractor/src/prop_modify_utils.rs +++ b/libs/extractor/src/prop_modify_utils.rs @@ -1,6 +1,4 @@ -use crate::extract_style::ExtractStyleProperty; use crate::extract_style::extract_css::ExtractCss; -use crate::extract_style::style_property::StyleProperty; use crate::gen_class_name::gen_class_names; use crate::gen_style::gen_styles; use crate::tailwind::{PROPERTY_RULES, PROPERTY_RULES_FILE, parse_class}; @@ -16,6 +14,14 @@ use oxc_ast::ast::{ use oxc_ast::builder::AstBuilder; use oxc_span::SPAN; +#[cfg(test)] +#[path = "prop_coverage_tests.rs"] +mod coverage_tests; + +#[cfg(test)] +#[path = "tailwind_logical_ir_tests.rs"] +mod tailwind_logical_ir_tests; + /// Combine two optional className expressions into a conditional expression. /// `condition ? con_expr : alt_expr`, falling back to `""` for the missing branch. /// Returns `None` only when both branches are `None`. @@ -62,23 +68,42 @@ fn resolve_class_name_expression<'a>( spread_props: &[Expression<'a>], filename: Option<&str>, conditional_branch: Option<(Expression<'a>, &mut [ExtractStyleProp<'a>], Option)>, -) -> (Option>, Vec) { +) -> ( + Option>, + Vec, + Option<(String, Expression<'a>)>, +) { + let (mut tailwind, primary) = + extract_tailwind_from_class_name(ast_builder, class_name_prop, style_order, filename); + let primary = primary.or_else(|| class_name_prop.clone_in(ast_builder.allocator())); if let Some((condition, alt_styles, alt_style_order)) = conditional_branch { - // Conditional styleOrder: generate className for both branches - let (con_expr, con_tailwind) = get_class_name_expression( + crate::assignment_lowering::orders(styles, alt_style_order, false); + crate::assignment_lowering::orders(alt_styles, alt_style_order, true); + let (alternate_tailwind, alternate) = extract_tailwind_from_class_name( ast_builder, class_name_prop, + alt_style_order, + filename, + ); + tailwind.extend(alternate_tailwind); + let mut prepared = [ + primary, + alternate.or_else(|| class_name_prop.clone_in(ast_builder.allocator())), + ]; + let capture = class_name_prop.as_ref().and_then(|source| { + crate::class_evaluation::capture(ast_builder, source, &mut prepared) + }); + let (con_expr, _) = get_class_name_expression( + ast_builder, + &prepared[0], styles, style_order, spread_props, filename, ); - let alt_class_name_prop = class_name_prop - .as_ref() - .map(|c| c.clone_in(ast_builder.allocator())); - let (alt_expr, alt_tailwind) = get_class_name_expression( + let (alt_expr, _) = get_class_name_expression( ast_builder, - &alt_class_name_prop, + &prepared[1], alt_styles, alt_style_order, spread_props, @@ -88,18 +113,21 @@ fn resolve_class_name_expression<'a>( let combined_expr = combine_conditional_class_name(ast_builder, condition, con_expr, alt_expr); - let mut all_tailwind = con_tailwind; - all_tailwind.extend(alt_tailwind); - (combined_expr, all_tailwind) + (combined_expr, tailwind, capture) } else { - get_class_name_expression( + let mut prepared = [primary]; + let capture = class_name_prop.as_ref().and_then(|source| { + crate::class_evaluation::capture(ast_builder, source, &mut prepared) + }); + let (expression, _) = get_class_name_expression( ast_builder, - class_name_prop, + &prepared[0], styles, style_order, spread_props, filename, - ) + ); + (expression, tailwind, capture) } } @@ -117,7 +145,7 @@ pub fn modify_prop_object<'a>( props_prop: Option>, filename: Option<&str>, conditional_branch: Option<(Expression<'a>, &mut [ExtractStyleProp<'a>], Option)>, -) -> Vec { +) -> (Vec, Option<(String, Expression<'a>)>) { let mut class_name_prop = None; let mut style_prop = None; let mut spread_props = vec![]; @@ -144,7 +172,7 @@ pub fn modify_prop_object<'a>( } } - let (class_name_expr, tailwind_styles) = resolve_class_name_expression( + let (class_name_expr, tailwind_styles, capture) = resolve_class_name_expression( ast_builder, &class_name_prop, styles, @@ -192,7 +220,7 @@ pub fn modify_prop_object<'a>( ast_builder, )); } - tailwind_styles + (tailwind_styles, capture) } /// modify JSX props /// Returns extracted Tailwind styles from static className strings @@ -208,7 +236,7 @@ pub fn modify_props<'a>( props_prop: Option>, filename: Option<&str>, conditional_branch: Option<(Expression<'a>, &mut [ExtractStyleProp<'a>], Option)>, -) -> Vec { +) -> (Vec, Option<(String, Expression<'a>)>) { let mut class_name_prop = None; let mut style_prop = None; let mut spread_props = vec![]; @@ -251,7 +279,7 @@ pub fn modify_props<'a>( } } } - let (class_name_expr, tailwind_styles) = resolve_class_name_expression( + let (class_name_expr, tailwind_styles, capture) = resolve_class_name_expression( ast_builder, &class_name_prop, styles, @@ -298,7 +326,7 @@ pub fn modify_props<'a>( ast_builder, )); } - tailwind_styles + (tailwind_styles, capture) } /// Returns (className expression, extracted Tailwind styles) @@ -310,15 +338,8 @@ pub fn get_class_name_expression<'a>( spread_props: &[Expression<'a>], filename: Option<&str>, ) -> (Option>, Vec) { - let mut tailwind = TailwindClassName { - style_order, - filename, - styles: Vec::new(), - property_rules: false, - }; - let compiled = class_name_prop - .as_ref() - .and_then(|class_name| tailwind.compile_expression(ast_builder, class_name)); + let (tailwind_styles, compiled) = + extract_tailwind_from_class_name(ast_builder, class_name_prop, style_order, filename); // A rebuilt `cond && "a"` still evaluates to `false`, which React would // render as `class="false"`, so it needs the same falsy guard as a passthrough. let class_name_to_use = compiled @@ -355,7 +376,35 @@ pub fn get_class_name_expression<'a>( } let expression = merge_string_expressions(ast_builder, &class_expressions); - (expression, tailwind.into_styles()) + (expression, tailwind_styles) +} + +/// Compile authored Tailwind classes before their shared evaluation is captured. +fn extract_tailwind_from_class_name<'a>( + ast_builder: &AstBuilder<'a>, + class_name_prop: &Option>, + style_order: Option, + filename: Option<&str>, +) -> (Vec, Option>) { + let mut tailwind = TailwindClassName { + style_order, + filename, + styles: Vec::new(), + property_rules: false, + }; + let compiled = class_name_prop + .as_ref() + .and_then(|class_name| tailwind.compile_expression(ast_builder, class_name)); + (tailwind.into_styles(), compiled) +} + +#[cfg(test)] +fn apply_style_order_to_styles(styles: &mut [ExtractStyleValue], style_order: Option) { + if let Some(order) = style_order { + for style in styles.iter_mut() { + style.set_style_order(order); + } + } } /// Compiles the Tailwind classes of a className: each becomes the classes of @@ -399,10 +448,10 @@ impl TailwindClassName<'_> { if let Some(order) = self.style_order { style.style_order = Some(order); } - let (StyleProperty::ClassName(name) - | StyleProperty::Variable { - class_name: name, .. - }) = style.extract(self.filename); + let name = crate::extract_style::compiler_projection::static_name( + &style, + self.filename, + )?; compiled.push_str(separator); compiled.push_str(&name); separator = " "; @@ -853,9 +902,31 @@ pub fn convert_style_vars<'a>( #[allow(clippy::expect_used, clippy::unwrap_used)] mod tests { use super::*; + use crate::extract_style::{ + extract_dynamic_style::ExtractDynamicStyle, extract_static_style::ExtractStaticStyle, + }; use crate::utils::expression_to_code; use oxc_allocator::Allocator; + #[test] + fn test_apply_style_order_to_all_styles() { + let mut styles = [ + ExtractStyleValue::Static(ExtractStaticStyle::new("color", "red", 0, None)), + ExtractStyleValue::Dynamic(ExtractDynamicStyle::new("padding", 0, "size", None)), + ]; + + apply_style_order_to_styles(&mut styles, Some(7)); + + let ExtractStyleValue::Static(static_style) = &styles[0] else { + panic!("expected static style"); + }; + let ExtractStyleValue::Dynamic(dynamic_style) = &styles[1] else { + panic!("expected dynamic style"); + }; + assert_eq!(static_style.style_order(), Some(7)); + assert_eq!(dynamic_style.style_order(), Some(7)); + } + #[test] fn test_merge_string_expressions_builds_template() { let allocator = Allocator::default(); diff --git a/libs/extractor/src/provenance.rs b/libs/extractor/src/provenance.rs new file mode 100644 index 000000000..8c8a002bc --- /dev/null +++ b/libs/extractor/src/provenance.rs @@ -0,0 +1,140 @@ +//! Where the values of styles come from, as far as production names care: +//! whether a style exists only because of what an import may resolve to, and +//! where in its original file a dynamic style was written. +//! +//! A risky expression is marked in its span, which keeps the position it was +//! written at: the mark is a high bit of the start and the span is empty, so +//! code generation never maps it. + +use css::Naming; +use oxc_ast::ast::Expression; +use oxc_ast_visit::{Visit, VisitMut, walk, walk_mut}; +use oxc_span::{GetSpan, GetSpanMut}; + +use crate::{ExtractStyleProp, utils::unwrap_syntax_only}; + +pub(crate) use crate::sparse_sites::{SiteScope, normalize_source, site_at, site_errors}; + +const RISKY: u32 = 1 << 31; + +/// A position in the source, whatever mark the span carries. +pub(crate) const fn source_offset(start: u32) -> u32 { + start & !RISKY +} + +pub(crate) const fn naming_of_start(start: u32) -> Naming { + if start & RISKY == 0 { + Naming::Own + } else { + Naming::Risky + } +} + +const fn naming_of_span(span: oxc_span::Span) -> Naming { + naming_of_start(span.start) +} + +/// Mark `expression` as depending on `naming`. +pub(crate) fn mark(expression: &mut Expression<'_>, naming: Naming) { + if naming == Naming::Risky { + let span = expression.span_mut(); + span.start |= RISKY; + span.end = span.start; + } +} + +struct Marks(Naming); + +impl<'a> Visit<'a> for Marks { + fn visit_expression(&mut self, expression: &Expression<'a>) { + self.0 = self.0.join(naming_of_span(expression.span())); + walk::walk_expression(self, expression); + } +} + +/// The identity of every mark within `expression`. +fn scan(expression: &Expression<'_>) -> Naming { + let mut marks = Marks(Naming::Own); + marks.visit_expression(expression); + marks.0 +} + +/// What the styles read from `expression` depend on. An object or array +/// leaves that to its members, a condition to the value it tests too, as +/// that may pick the branches; anything else is one value. +pub(crate) fn provenance_of(expression: &Expression<'_>) -> Naming { + let inner = unwrap_syntax_only(expression); + let wrapper = naming_of_span(expression.span()).join(naming_of_span(inner.span())); + match inner { + Expression::ObjectExpression(_) | Expression::ArrayExpression(_) => wrapper, + Expression::ConditionalExpression(conditional) => wrapper.join(scan(&conditional.test)), + Expression::LogicalExpression(logical) => wrapper.join(scan(&logical.left)), + _ => wrapper.join(scan(inner)), + } +} + +/// Join `naming` into every style of `props`. +pub(crate) fn join_naming(props: &mut [ExtractStyleProp<'_>], naming: Naming) { + for prop in props { + match prop { + ExtractStyleProp::Static(style) => style.join_naming(naming), + ExtractStyleProp::StaticArray(props) + | ExtractStyleProp::Evaluated { styles: props, .. } => join_naming(props, naming), + ExtractStyleProp::Conditional { + consequent, + alternate, + .. + } => { + for branch in [consequent, alternate].into_iter().flatten() { + join_naming(std::slice::from_mut(branch.as_mut()), naming); + } + } + ExtractStyleProp::Enum { map, .. } => { + for props in map.values_mut() { + join_naming(props, naming); + } + } + ExtractStyleProp::MemberExpression { map, .. } => { + for prop in map.values_mut() { + join_naming(std::slice::from_mut(prop.as_mut()), naming); + } + } + ExtractStyleProp::Expression { styles, .. } => { + for style in styles { + style.join_naming(naming); + } + } + ExtractStyleProp::Unreadable { .. } => {} + } + } +} + +/// Marks the expressions that lie within `ranges`, which a build-time +/// evaluation wrote in place of values that depend on an import. +pub(crate) struct MarkRanges<'r>(pub &'r [(usize, usize)]); + +impl<'a> VisitMut<'a> for MarkRanges<'_> { + fn visit_expression(&mut self, expression: &mut Expression<'a>) { + let start = source_offset(expression.span().start) as usize; + if self + .0 + .iter() + .any(|(from, to)| (*from..*to).contains(&start)) + { + mark(expression, Naming::Risky); + } + walk_mut::walk_expression(self, expression); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn marks_keep_the_position_they_were_written_at() { + assert_eq!(naming_of_start(10), Naming::Own); + assert_eq!(naming_of_start(0xa | RISKY), Naming::Risky); + assert_eq!(source_offset(0xa | RISKY), 10); + } +} diff --git a/libs/extractor/src/snapshots/extractor__tests__apply_var_typography-2.snap b/libs/extractor/src/snapshots/extractor__tests__apply_var_typography-2.snap index 312595a05..2c66abe69 100644 --- a/libs/extractor/src/snapshots/extractor__tests__apply_var_typography-2.snap +++ b/libs/extractor/src/snapshots/extractor__tests__apply_var_typography-2.snap @@ -1,8 +1,8 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Text} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr\"import {Text} from '@devup-ui/core'\n \n \",\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: {}, - code: ";\n", + code: " __devupTypography ? `typo-${__devupTypography}` : \"\")(a) : ((__devupTypography) => __devupTypography ? `typo-${__devupTypography}` : \"\")(b)} />;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__apply_var_typography-4.snap b/libs/extractor/src/snapshots/extractor__tests__apply_var_typography-4.snap index 0ec710378..38f228fcf 100644 --- a/libs/extractor/src/snapshots/extractor__tests__apply_var_typography-4.snap +++ b/libs/extractor/src/snapshots/extractor__tests__apply_var_typography-4.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box as DevupButton} from '@devup-ui/core'\n \n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box as DevupButton} from '@devup-ui/core'\n \n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -15,5 +15,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n
\n
;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n((__devupClass117, __devupSpread0) =>
\n
)(className, ((__devupTypography) => __devupTypography ? `typo-${__devupTypography}` : \"\")(typography));\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__apply_var_typography-5.snap b/libs/extractor/src/snapshots/extractor__tests__apply_var_typography-5.snap index 35b02b3a0..02edb6ac6 100644 --- a/libs/extractor/src/snapshots/extractor__tests__apply_var_typography-5.snap +++ b/libs/extractor/src/snapshots/extractor__tests__apply_var_typography-5.snap @@ -17,5 +17,5 @@ ToBTreeSet { "tag", ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n\n {children}\n ;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n((__devupSpread1, __devupSpread0) => \n {__devupSpread0}\n )({\n\t\"lg\": \"typo-buttonLg\",\n\t\"md\": \"typo-button\",\n\t\"sm\": \"typo-buttonSm\",\n\t\"tag\": \"typo-tag\"\n}[size] || \"\", children);\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__apply_var_typography-6.snap b/libs/extractor/src/snapshots/extractor__tests__apply_var_typography-6.snap index ba70c53a2..941fded4d 100644 --- a/libs/extractor/src/snapshots/extractor__tests__apply_var_typography-6.snap +++ b/libs/extractor/src/snapshots/extractor__tests__apply_var_typography-6.snap @@ -17,5 +17,5 @@ ToBTreeSet { "tag", ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n\n {children}\n ;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n((__devupSpread1, __devupSpread0) => \n {__devupSpread0}\n )({\n\t\"lg\": \"typo-buttonLg\",\n\t\"md\": \"typo-button\",\n\t\"sm\": \"typo-buttonSm\",\n\t\"tag\": \"typo-tag\"\n}[size] || \"\", children);\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__apply_var_typography.snap b/libs/extractor/src/snapshots/extractor__tests__apply_var_typography.snap index 1f65a8422..bd27f6546 100644 --- a/libs/extractor/src/snapshots/extractor__tests__apply_var_typography.snap +++ b/libs/extractor/src/snapshots/extractor__tests__apply_var_typography.snap @@ -1,8 +1,8 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Text} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr\"import {Text} from '@devup-ui/core'\n \n \",\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: {}, - code: ";\n", + code: " __devupTypography ? `typo-${__devupTypography}` : \"\")(variable)} />;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix-2.snap b/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix-2.snap index d997635d3..8436b9daf 100644 --- a/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix-2.snap +++ b/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix-2.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: false, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: false, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -23,5 +23,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/core/devup-ui-0.css\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui-0.css\";\n
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix-3.snap b/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix-3.snap index 3c0446efc..0e6512d87 100644 --- a/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix-3.snap +++ b/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix-3.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: false, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: false, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -23,5 +23,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/core/devup-ui-0.css\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui-0.css\";\n
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix.snap b/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix.snap index 50e7177f0..c11f8c8a5 100644 --- a/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix.snap +++ b/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: false, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: false, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -23,5 +23,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/core/devup-ui-0.css\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui-0.css\";\n
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-2.snap b/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-2.snap index 0519ae097..3266fee1c 100644 --- a/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-2.snap +++ b/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-2.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: false, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: false, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -23,5 +23,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/core/devup-ui-0.css\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui-0.css\";\n
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-3.snap b/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-3.snap index 831bd6971..c5ab22402 100644 --- a/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-3.snap +++ b/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-3.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: false, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: false, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -23,5 +23,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/core/devup-ui-0.css\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui-0.css\";\n
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-4.snap b/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-4.snap index 5784017e3..075fc206b 100644 --- a/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-4.snap +++ b/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-4.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: false, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: false, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -23,5 +23,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/core/devup-ui-0.css\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui-0.css\";\n
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix.snap b/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix.snap index ebb729213..b0d0c500e 100644 --- a/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix.snap +++ b/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: false, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: false, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -23,5 +23,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/core/devup-ui-0.css\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui-0.css\";\n
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__avoid_same_name_component.snap b/libs/extractor/src/snapshots/extractor__tests__avoid_same_name_component.snap index d3d2d6ac6..781b912d6 100644 --- a/libs/extractor/src/snapshots/extractor__tests__avoid_same_name_component.snap +++ b/libs/extractor/src/snapshots/extractor__tests__avoid_same_name_component.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.jsx\",\nr#\"import {Box} from '@devup-ui/core'\nimport {Button} from '@devup/ui'\n ;\n ; @@ -229,15 +230,15 @@ exports[`Button should render loading spinner when loading is true 1`] = ` exports[`Button should render loading spinner when loading is true and loadingSpinner is partial 1`] = ` "
- @@ -246,10 +247,10 @@ exports[`Button should render loading spinner when loading is true and loadingSp exports[`Button should render loading spinner when icon is provided and loading is true 1`] = ` "
- diff --git a/packages/components/src/components/Button/__tests__/index.browser.test.tsx b/packages/components/src/components/Button/__tests__/index.browser.test.tsx index 4f49187b9..a76866562 100644 --- a/packages/components/src/components/Button/__tests__/index.browser.test.tsx +++ b/packages/components/src/components/Button/__tests__/index.browser.test.tsx @@ -14,9 +14,7 @@ describe('Button', () => { it('should render default style when variant is default', () => { const { container } = render() expect(container).toMatchSnapshot() - expect(container.querySelector('button')).toHaveClass( - 'color-0-var_lp_--text_cm__h_272727_rp_--1', - ) + expect(container.querySelector('button')).toHaveClass('OHdsoizdtbkuep7e97') }) it('should disable', () => { @@ -28,9 +26,7 @@ describe('Button', () => { it('should render error style when danger is true and variant is default', () => { const { container } = render() expect(container).toMatchSnapshot() - expect(container.querySelector('button')).toHaveClass( - 'color-0-var_lp_--error_cm__h_D52B2E_rp_--1', - ) + expect(container.querySelector('button')).toHaveClass('OHak4bikju8xji8lqu') }) it('should render primary background color when danger is true and variant is primary', () => { @@ -40,15 +36,13 @@ describe('Button', () => { , ) expect(container).toMatchSnapshot() - expect(container.querySelector('button')).toHaveClass( - 'background-0-var_lp_--primary_cm__h_8163E1_rp_--1', - ) + expect(container.querySelector('button')).toHaveClass('OHbdbo4lswv91x4s_w') }) it('should not render error color when danger is false and variant is default', async () => { const { container } = render() const button = container.querySelector('button') - expect(button).toHaveClass('color-0-var_lp_--text_cm__h_272727_rp_--1') + expect(button).toHaveClass('OHdsoizdtbkuep7e97') expect(button).toMatchSnapshot() }) @@ -65,7 +59,12 @@ describe('Button', () => { } as unknown as React.ComponentProps const { container } = render(, ) expect(container).toMatchSnapshot() - expect(container.querySelector('button')).toHaveClass('font-size-0-15px--1') + expect(container.querySelector('button')).toHaveClass('OHbl76x2rm_e09_q_o') }) it('should have font size 15px when size is sm and variant is primary', () => { @@ -111,7 +110,7 @@ describe('Button', () => { , ) expect(container).toMatchSnapshot() - expect(container.querySelector('button')).toHaveClass('font-size-0-15px--1') + expect(container.querySelector('button')).toHaveClass('OHbl76x2rm_e09_q_o') }) it('should have font size 14px when size is sm and variant is default', () => { @@ -121,7 +120,7 @@ describe('Button', () => { , ) expect(container).toMatchSnapshot() - expect(container.querySelector('button')).toHaveClass('font-size-0-14px--1') + expect(container.querySelector('button')).toHaveClass('OHayncw4cji61v_2tv') }) it('should render icon when icon is provided', () => { @@ -168,9 +167,7 @@ describe('Button', () => { , ) expect(container).toMatchSnapshot() - expect(container.querySelector('button')).toHaveClass( - 'color-0-var_lp_--text_cm__h_272727_rp_--1', - ) + expect(container.querySelector('button')).toHaveClass('OHdsoizdtbkuep7e97') }) it('should have typography when typography is provided', () => { diff --git a/packages/components/src/components/Checkbox/__tests__/__snapshots__/index.browser.test.tsx.snap b/packages/components/src/components/Checkbox/__tests__/__snapshots__/index.browser.test.tsx.snap index 4845f4a2d..e5c6a3d02 100644 --- a/packages/components/src/components/Checkbox/__tests__/__snapshots__/index.browser.test.tsx.snap +++ b/packages/components/src/components/Checkbox/__tests__/__snapshots__/index.browser.test.tsx.snap @@ -1,13 +1,14 @@ // Bun Snapshot v1, https://bun.sh/docs/test/snapshots +// W31: 9 complete bodies reviewed in W31e-bun-alignment.json. exports[`Checkbox should render basic checkbox 1`] = ` "
-
-