From 92f89d085ec013824ecd3383450894b9f7c4ee47 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 31 Aug 2026 15:26:10 +0200 Subject: [PATCH 1/5] feat(stylex): the fru compiler at crates/stylex Clean-room Rust compiler for StyleX, byte-identical to @stylexjs/babel-plugin 0.19.0: identical rule metadata, identical generated JavaScript, identical assembled CSS, or a hard error. Parity is enforced by a differential harness (live Babel plugin as oracle, 5,325 job-modes, 0 FAIL, in CI) living with the compiler's source of truth; this copy is vendored from there (lovable@2ccd45e9ac6) and is not edited in place. Unsupported features hard-error rather than diverge (runtimeInjection: true, haste, legacy option modes, Flow-only syntax). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Lre78sAtefzg8D2PJVpNxa --- Cargo.lock | 31 + Cargo.toml | 2 + crates/stylex/Cargo.toml | 26 + crates/stylex/src/api.rs | 287 ++ crates/stylex/src/assemble.rs | 1270 ++++++ crates/stylex/src/cache.rs | 957 +++++ crates/stylex/src/errors.rs | 496 +++ crates/stylex/src/eval/cross_file.rs | 126 + crates/stylex/src/eval/functions.rs | 595 +++ crates/stylex/src/eval/methods.rs | 1579 ++++++++ crates/stylex/src/eval/mod.rs | 2132 +++++++++++ crates/stylex/src/eval/value.rs | 409 ++ crates/stylex/src/fxhash.rs | 86 + crates/stylex/src/hash.rs | 109 + crates/stylex/src/imports.rs | 640 ++++ crates/stylex/src/jsrt.rs | 485 +++ crates/stylex/src/lib.rs | 52 + crates/stylex/src/module_resolution.rs | 1733 +++++++++ crates/stylex/src/options.rs | 632 +++ crates/stylex/src/rules.rs | 133 + crates/stylex/src/shared/create.rs | 332 ++ crates/stylex/src/shared/create_theme.rs | 363 ++ crates/stylex/src/shared/css_value.rs | 579 +++ crates/stylex/src/shared/dashify.rs | 73 + crates/stylex/src/shared/define_consts.rs | 189 + crates/stylex/src/shared/define_vars.rs | 619 +++ crates/stylex/src/shared/dev_naming.rs | 282 ++ crates/stylex/src/shared/dynamic.rs | 624 +++ crates/stylex/src/shared/fallbacks.rs | 100 + crates/stylex/src/shared/flatten.rs | 643 ++++ crates/stylex/src/shared/generate_rule.rs | 482 +++ crates/stylex/src/shared/keyframes.rs | 243 ++ crates/stylex/src/shared/markers.rs | 74 + crates/stylex/src/shared/media_query.rs | 1246 ++++++ crates/stylex/src/shared/nested.rs | 734 ++++ crates/stylex/src/shared/normalize_value.rs | 467 +++ crates/stylex/src/shared/position_try.rs | 279 ++ crates/stylex/src/shared/priorities.rs | 832 ++++ crates/stylex/src/shared/pseudo_sort.rs | 125 + crates/stylex/src/shared/resolution.rs | 1874 +++++++++ crates/stylex/src/shared/rtl.rs | 495 +++ crates/stylex/src/shared/split_css_value.rs | 121 + crates/stylex/src/shared/transform_value.rs | 187 + crates/stylex/src/shared/types.rs | 294 ++ crates/stylex/src/shared/view_transition.rs | 209 + crates/stylex/src/shared/when.rs | 213 + crates/stylex/src/state.rs | 773 ++++ crates/stylex/src/timings.rs | 117 + crates/stylex/src/transform/ast_backend.rs | 1349 +++++++ crates/stylex/src/transform/atoms.rs | 131 + crates/stylex/src/transform/dce.rs | 182 + crates/stylex/src/transform/js_out.rs | 591 +++ crates/stylex/src/transform/merge.rs | 873 +++++ crates/stylex/src/transform/visitor.rs | 3835 +++++++++++++++++++ 54 files changed, 31310 insertions(+) create mode 100644 crates/stylex/Cargo.toml create mode 100644 crates/stylex/src/api.rs create mode 100644 crates/stylex/src/assemble.rs create mode 100644 crates/stylex/src/cache.rs create mode 100644 crates/stylex/src/errors.rs create mode 100644 crates/stylex/src/eval/cross_file.rs create mode 100644 crates/stylex/src/eval/functions.rs create mode 100644 crates/stylex/src/eval/methods.rs create mode 100644 crates/stylex/src/eval/mod.rs create mode 100644 crates/stylex/src/eval/value.rs create mode 100644 crates/stylex/src/fxhash.rs create mode 100644 crates/stylex/src/hash.rs create mode 100644 crates/stylex/src/imports.rs create mode 100644 crates/stylex/src/jsrt.rs create mode 100644 crates/stylex/src/lib.rs create mode 100644 crates/stylex/src/module_resolution.rs create mode 100644 crates/stylex/src/options.rs create mode 100644 crates/stylex/src/rules.rs create mode 100644 crates/stylex/src/shared/create.rs create mode 100644 crates/stylex/src/shared/create_theme.rs create mode 100644 crates/stylex/src/shared/css_value.rs create mode 100644 crates/stylex/src/shared/dashify.rs create mode 100644 crates/stylex/src/shared/define_consts.rs create mode 100644 crates/stylex/src/shared/define_vars.rs create mode 100644 crates/stylex/src/shared/dev_naming.rs create mode 100644 crates/stylex/src/shared/dynamic.rs create mode 100644 crates/stylex/src/shared/fallbacks.rs create mode 100644 crates/stylex/src/shared/flatten.rs create mode 100644 crates/stylex/src/shared/generate_rule.rs create mode 100644 crates/stylex/src/shared/keyframes.rs create mode 100644 crates/stylex/src/shared/markers.rs create mode 100644 crates/stylex/src/shared/media_query.rs create mode 100644 crates/stylex/src/shared/nested.rs create mode 100644 crates/stylex/src/shared/normalize_value.rs create mode 100644 crates/stylex/src/shared/position_try.rs create mode 100644 crates/stylex/src/shared/priorities.rs create mode 100644 crates/stylex/src/shared/pseudo_sort.rs create mode 100644 crates/stylex/src/shared/resolution.rs create mode 100644 crates/stylex/src/shared/rtl.rs create mode 100644 crates/stylex/src/shared/split_css_value.rs create mode 100644 crates/stylex/src/shared/transform_value.rs create mode 100644 crates/stylex/src/shared/types.rs create mode 100644 crates/stylex/src/shared/view_transition.rs create mode 100644 crates/stylex/src/shared/when.rs create mode 100644 crates/stylex/src/state.rs create mode 100644 crates/stylex/src/timings.rs create mode 100644 crates/stylex/src/transform/ast_backend.rs create mode 100644 crates/stylex/src/transform/atoms.rs create mode 100644 crates/stylex/src/transform/dce.rs create mode 100644 crates/stylex/src/transform/js_out.rs create mode 100644 crates/stylex/src/transform/merge.rs create mode 100644 crates/stylex/src/transform/visitor.rs diff --git a/Cargo.lock b/Cargo.lock index 312f227f..fd653e5a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -904,6 +904,27 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fru" +version = "0.1.16" +dependencies = [ + "blake3", + "memchr", + "oxc_allocator", + "oxc_ast", + "oxc_ast_visit", + "oxc_codegen", + "oxc_parser", + "oxc_semantic", + "oxc_sourcemap", + "oxc_span", + "oxc_syntax", + "ryu-js", + "serde", + "serde_json", + "thiserror", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -1837,6 +1858,7 @@ dependencies = [ "rolldown", "rolldown_common", "rolldown_plugin", + "rolldown_sourcemap", "rolldown_utils", "serde_json", "tokio", @@ -1848,6 +1870,7 @@ name = "oj_cache" version = "0.1.16" dependencies = [ "blake3", + "fru", "proptest", "serde", "serde_json", @@ -1858,6 +1881,7 @@ dependencies = [ name = "oj_compiler" version = "0.1.16" dependencies = [ + "fru", "glob", "memchr", "oxc_allocator", @@ -1943,6 +1967,7 @@ dependencies = [ "anyhow", "axum", "blake3", + "fru", "futures-util", "glob", "json-strip-comments", @@ -3825,6 +3850,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "ryu-js" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04d056b875a9d2e6cb9a61d127afee9ac5999b9f87bcb32079d1318e505be714" + [[package]] name = "same-file" version = "1.0.6" diff --git a/Cargo.toml b/Cargo.toml index 6f2e7849..b74e9738 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ resolver = "2" members = [ "crates/oj_compiler", + "crates/stylex", "crates/oj_css", "crates/oj_env", "crates/oj_config", @@ -26,6 +27,7 @@ oj_css = { version = "0.1.16", path = "crates/oj_css" } oj_env = { version = "0.1.16", path = "crates/oj_env" } oj_config = { version = "0.1.16", path = "crates/oj_config" } oj_server = { version = "0.1.16", path = "crates/oj_server" } +fru = { version = "0.1.16", path = "crates/stylex" } oxc_allocator = "0.146.0" oxc_parser = "0.146.0" diff --git a/crates/stylex/Cargo.toml b/crates/stylex/Cargo.toml new file mode 100644 index 00000000..7122165d --- /dev/null +++ b/crates/stylex/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "fru" +version = "0.1.16" +edition = "2024" +rust-version = "1.95" +license = "MIT" +description = "Clean-room StyleX compiler, byte-identical to @stylexjs/babel-plugin 0.19.0" + +[dependencies] +oxc_allocator = { workspace = true } +oxc_parser = { workspace = true } +oxc_ast = { workspace = true } +oxc_ast_visit = { workspace = true } +oxc_semantic = { workspace = true } +oxc_span = { workspace = true } +oxc_syntax = { workspace = true } +oxc_codegen = { workspace = true } +oxc_sourcemap = "8.1.2" +memchr = { workspace = true } +blake3 = { workspace = true } +serde = { version = "1", features = ["derive", "rc"] } +# preserve_order: JS object key order is semantic (alias last-wins); the default +# alphabetizing Map silently reorders keys through every JSON bridge. +serde_json = { version = "1", features = ["preserve_order", "raw_value"] } +thiserror = "2" +ryu-js = "1" diff --git a/crates/stylex/src/api.rs b/crates/stylex/src/api.rs new file mode 100644 index 00000000..578557e0 --- /dev/null +++ b/crates/stylex/src/api.rs @@ -0,0 +1,287 @@ +//! Public transform surface (design-core.md §3): the memchr pre-gate, the +//! splice compile (CLI/harness/rolldown), and the AST compile (oj dev path). + +use std::path::Path; + +use memchr::memmem; +use oxc_allocator::Allocator; +use oxc_ast::ast::Program; +use oxc_parser::Parser; +use oxc_span::SourceType; + +use crate::errors::StylexError; +use crate::eval::value::JsObjectMap; +use crate::imports::scan_imports; +use crate::module_resolution::{FsProvider, THEME_FILE_EXTENSION}; +use crate::options::ResolvedOptions; +use crate::rules::StylexRule; +use crate::state::CompileState; +use crate::transform::ast_backend::apply_plan; +use crate::transform::visitor::transform_module; + +pub struct FileContext<'s> { + /// Absolute path the source pretends to live at. + pub filename: &'s Path, + pub source_text: &'s str, + /// process.cwd() equivalent; pins `$$css` debug-string derivation. + pub cwd: &'s Path, +} + +#[derive(Debug)] +pub struct SourceCompileResult { + pub code: String, + pub map: Option, + pub rules: Vec, + pub modified: bool, + /// (var name, compiled namespaces) per create call, pre-DCE, in order. + pub create_objects: Vec<(Option, std::sync::Arc)>, +} + +pub fn might_contain_stylex(source: &str, options: &ResolvedOptions) -> bool { + options + .import_sources + .iter() + .map(crate::options::ImportSource::from_specifier) + .any(|needle| memmem::find(source.as_bytes(), needle.as_bytes()).is_some()) + // Atoms compile off a hardcoded source no importSources setting covers. + || memmem::find(source.as_bytes(), crate::imports::ATOMS_SOURCE.as_bytes()).is_some() + // The sx prop compiles with no stylex import in the file at all, so + // it needs the same needle `is_dormant` already carries. + || options + .sx_prop_name + .as_deref() + .is_some_and(|sx| memmem::find(source.as_bytes(), sx.as_bytes()).is_some()) + // A rewritable import source must carry the hardcoded `.stylex` + // suffix, so the literal is a sound pre-gate for that pass too. + || (options.rewrite_aliases + && memmem::find(source.as_bytes(), THEME_FILE_EXTENSION.as_bytes()).is_some()) + || has_string_escapes(source) +} + +// A `\u`/`\x` escape can cook into an import-source match the raw needles +// miss (`"@stylexjs/stylex"`); parse and decide post-parse. +fn has_string_escapes(source: &str) -> bool { + memmem::find(source.as_bytes(), b"\\u").is_some() + || memmem::find(source.as_bytes(), b"\\x").is_some() +} + +/// `None` = pre-gate skip (the file cannot reference any stylex import source). +pub fn transform_source( + ctx: &FileContext<'_>, + options: &ResolvedOptions, + fs: &dyn FsProvider, +) -> Result, StylexError> { + let allocator = Allocator::default(); + transform_source_in(&allocator, ctx, options, fs) +} + +/// [`transform_source`] parsing into the caller's arena (reusable across jobs +/// via `reset()` — nothing in the result borrows it). +pub fn transform_source_in( + allocator: &Allocator, + ctx: &FileContext<'_>, + options: &ResolvedOptions, + fs: &dyn FsProvider, +) -> Result, StylexError> { + transform_source_mapped_in(allocator, ctx, options, fs, false) +} + +/// [`transform_source_in`] that also builds a v3 sourcemap; emitted text is +/// byte-identical either way, so the map stays opt-in. +pub fn transform_source_mapped_in( + allocator: &Allocator, + ctx: &FileContext<'_>, + options: &ResolvedOptions, + fs: &dyn FsProvider, + want_map: bool, +) -> Result, StylexError> { + if !might_contain_stylex(ctx.source_text, options) { + return Ok(None); + } + let filename = ctx.filename.to_string_lossy().replace('\\', "/"); + let program = { + let _t = crate::timings::start(crate::timings::Stage::Parse); + parse_program(allocator, ctx.source_text, &filename)? + }; + if is_dormant(&program, ctx.source_text, options)? { + return Ok(Some(SourceCompileResult { + code: ctx.source_text.to_string(), + map: None, + rules: Vec::new(), + modified: false, + create_objects: Vec::new(), + })); + } + let filename_for_map = want_map.then(|| filename.clone()); + let mut state = CompileState::build( + &program, + options, + Some(filename), + ctx.cwd.to_string_lossy().replace('\\', "/"), + )?; + let out = { + let _t = crate::timings::start(crate::timings::Stage::Transform); + transform_module(&program, ctx.source_text, &mut state, fs, false, want_map)? + }; + let map = out.splice_map.as_ref().map(|m| { + render_sourcemap( + m, + filename_for_map.as_deref().unwrap_or_default(), + ctx.source_text, + ) + }); + Ok(Some(SourceCompileResult { + code: out.code, + map, + rules: state.rules, + modified: out.modified, + create_objects: out.create_objects, + })) +} + +/// [`transform_source`] that also returns the fs-dependency log the cache +/// needs (crate::cache); recording costs one package.json read per hit dir. +pub fn transform_source_with_dep_log( + ctx: &FileContext<'_>, + options: &ResolvedOptions, + fs: &dyn FsProvider, +) -> Result<(Option, crate::cache::DepLog), StylexError> { + let allocator = Allocator::default(); + transform_source_with_dep_log_in(&allocator, ctx, options, fs) +} + +/// [`transform_source_with_dep_log`] in the caller's arena (see +/// [`transform_source_in`]). +pub fn transform_source_with_dep_log_in( + allocator: &Allocator, + ctx: &FileContext<'_>, + options: &ResolvedOptions, + fs: &dyn FsProvider, +) -> Result<(Option, crate::cache::DepLog), StylexError> { + let recorder = crate::cache::RecordingFs::new(fs, ctx.cwd); + let result = transform_source_in(allocator, ctx, options, &recorder)?; + Ok((result, recorder.into_log())) +} + +/// AST-backend result: rules plus whether the caller's program was mutated. +#[derive(Debug)] +pub struct CompileResult { + pub modified: bool, + pub rules: Vec, + /// (var name, compiled namespaces) per create call, pre-DCE, in order. + pub create_objects: Vec<(Option, std::sync::Arc)>, +} + +/// oj dev path: mutates the caller's AST in place; the program must be the +/// parse of `ctx.source_text` (synthesized-node spans point into it). +pub fn transform_program<'a>( + allocator: &'a Allocator, + program: &mut Program<'a>, + ctx: &FileContext<'_>, + options: &ResolvedOptions, + fs: &dyn FsProvider, +) -> Result { + if !might_contain_stylex(ctx.source_text, options) { + return Ok(CompileResult { + modified: false, + rules: Vec::new(), + create_objects: Vec::new(), + }); + } + if is_dormant(program, ctx.source_text, options)? { + return Ok(CompileResult { + modified: false, + rules: Vec::new(), + create_objects: Vec::new(), + }); + } + let filename = ctx.filename.to_string_lossy().replace('\\', "/"); + let cwd = ctx.cwd.to_string_lossy().replace('\\', "/"); + // Read-only analysis at a shorter reborrow (the AST is covariant over + // the arena lifetime) yields an owned plan; the mutation follows. + let (plan, modified, rules, create_objects) = { + let program_ref: &Program<'_> = &*program; + let mut state = CompileState::build(program_ref, options, Some(filename), cwd)?; + let out = { + let _t = crate::timings::start(crate::timings::Stage::Transform); + transform_module(program_ref, ctx.source_text, &mut state, fs, true, false)? + }; + (out.plan, out.modified, state.rules, out.create_objects) + }; + apply_plan(allocator, program, &plan)?; + Ok(CompileResult { + modified, + rules, + create_objects, + }) +} + +/// [`transform_program`] with the cache's fs-dependency log, mirroring +/// [`transform_source_with_dep_log`]. +pub fn transform_program_with_dep_log<'a>( + allocator: &'a Allocator, + program: &mut Program<'a>, + ctx: &FileContext<'_>, + options: &ResolvedOptions, + fs: &dyn FsProvider, +) -> Result<(CompileResult, crate::cache::DepLog), StylexError> { + let recorder = crate::cache::RecordingFs::new(fs, ctx.cwd); + let result = transform_program(allocator, program, ctx, options, &recorder)?; + Ok((result, recorder.into_log())) +} + +// Dormant needs no stylex binding AND no possible sx prop: that transform +// fires with no import at all and synthesizes one. +fn is_dormant( + program: &Program<'_>, + source: &str, + options: &ResolvedOptions, +) -> Result { + // `rewriteAliases` runs in Program.exit over every import declaration, with + // no stylex binding required anywhere in the file. + if options.rewrite_aliases { + return Ok(false); + } + if let Some(sx_prop) = &options.sx_prop_name + && memmem::find(source.as_bytes(), sx_prop.as_bytes()).is_some() + { + return Ok(false); + } + let _t = crate::timings::start(crate::timings::Stage::ImportScan); + Ok(scan_imports(program, options)?.is_dormant()) +} + +/// Serializes splice positions as a v3 sourcemap with the original inlined. +fn render_sourcemap( + map: &crate::transform::js_out::SpliceMap, + filename: &str, + source_text: &str, +) -> String { + let mut builder = oxc_sourcemap::SourceMapBuilder::default(); + let src_id = builder.set_source_and_content(filename, source_text); + builder.set_file(filename); + for &(dst_line, dst_col, src_line, src_col) in &map.tokens { + builder.add_token(dst_line, dst_col, src_line, src_col, Some(src_id), None); + } + builder.into_sourcemap().to_json_string() +} + +// The oracle parses every file with the typescript+jsx plugins regardless of +// extension (`input.js` with `f(0)` is a generic call, not comparisons). +pub fn parse_program<'a>( + allocator: &'a Allocator, + source: &'a str, + _filename: &str, +) -> Result, StylexError> { + let ret = Parser::new(allocator, source, SourceType::tsx()).parse(); + if !ret.panicked && !ret.diagnostics.has_errors() { + return Ok(ret.program); + } + let detail = ret + .diagnostics + .errors() + .next() + .map(|d| d.message.to_string()) + .unwrap_or_else(|| "unknown parse error".to_string()); + Err(StylexError::parse_error(&detail)) +} diff --git a/crates/stylex/src/assemble.rs b/crates/stylex/src/assemble.rs new file mode 100644 index 00000000..dd450b21 --- /dev/null +++ b/crates/stylex/src/assemble.rs @@ -0,0 +1,1270 @@ +// parity: stylex-0.19.0 packages/@stylexjs/babel-plugin/src/index.js processStylexRules + +use std::cmp::Ordering; +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::hash::BuildHasherDefault; + +use crate::jsrt::{locale_key, utf16_cmp}; +use crate::rules::StylexRule; + +const LOGICAL_FLOAT_START_VAR: &str = "--stylex-logical-start"; +const LOGICAL_FLOAT_END_VAR: &str = "--stylex-logical-end"; + +pub type Comparator = fn(&str, &str) -> Ordering; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum LayersConfig { + Off, + On { + before: Vec, + after: Vec, + prefix: Option, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AssembleConfig { + pub use_layers: LayersConfig, + pub use_legacy_classnames_sort: bool, + pub legacy_disable_layers: bool, + pub enable_ltr_rtl_comments: bool, +} + +impl Default for AssembleConfig { + fn default() -> Self { + AssembleConfig { + use_layers: LayersConfig::Off, + use_legacy_classnames_sort: false, + legacy_disable_layers: false, + enable_ltr_rtl_comments: false, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub enum AssembleError { + // Message text mirrors the upstream throw byte for byte. + #[error("circular reference detected for constant {0}")] + CircularConst(String), + #[error("unsupported processStylexRules config: {0}")] + Unsupported(String), + #[error("invalid processStylexRules config: {0}")] + InvalidConfig(String), +} + +impl AssembleConfig { + // Mirrors the upstream raw-config parse: `boolean` is shorthand for `{useLayers}`, + // `useLayers !== false` turns layers on, falsy prefix means no prefix. + pub fn from_json(v: &serde_json::Value) -> Result { + use serde_json::Value; + let obj = match v { + Value::Bool(b) => { + return Ok(AssembleConfig { + use_layers: if *b { + LayersConfig::on_empty() + } else { + LayersConfig::Off + }, + ..AssembleConfig::default() + }); + } + Value::Null => return Ok(AssembleConfig::default()), + Value::Object(o) => o, + other => { + return Err(AssembleError::InvalidConfig(format!( + "expected boolean or object, got {other}" + ))); + } + }; + let use_layers = match obj.get("useLayers") { + None | Some(Value::Null) | Some(Value::Bool(false)) => LayersConfig::Off, + Some(Value::Bool(true)) => LayersConfig::on_empty(), + Some(Value::Object(l)) => { + let list = |key: &str| -> Result, AssembleError> { + match l.get(key) { + None | Some(Value::Null) => Ok(vec![]), + Some(Value::Array(a)) => a + .iter() + .map(|s| { + s.as_str().map(str::to_string).ok_or_else(|| { + AssembleError::InvalidConfig(format!( + "useLayers.{key} entries must be strings" + )) + }) + }) + .collect(), + Some(other) => Err(AssembleError::InvalidConfig(format!( + "useLayers.{key} must be an array, got {other}" + ))), + } + }; + let prefix = match l.get("prefix") { + None | Some(Value::Null) => None, + Some(Value::String(s)) => Some(s.clone()), + Some(other) => { + return Err(AssembleError::InvalidConfig(format!( + "useLayers.prefix must be a string, got {other}" + ))); + } + }; + LayersConfig::On { + before: list("before")?, + after: list("after")?, + prefix, + } + } + Some(other) => { + return Err(AssembleError::InvalidConfig(format!( + "useLayers must be boolean or object, got {other}" + ))); + } + }; + Ok(AssembleConfig { + use_layers, + use_legacy_classnames_sort: obj.get("useLegacyClassnamesSort").is_some_and(js_truthy), + legacy_disable_layers: obj.get("legacyDisableLayers").is_some_and(js_truthy), + enable_ltr_rtl_comments: obj.get("enableLTRRTLComments").is_some_and(js_truthy), + }) + } +} + +impl LayersConfig { + fn on_empty() -> Self { + LayersConfig::On { + before: vec![], + after: vec![], + prefix: None, + } + } +} + +fn js_truthy(v: &serde_json::Value) -> bool { + use serde_json::Value; + match v { + Value::Null => false, + Value::Bool(b) => *b, + Value::Number(n) => n.as_f64().is_some_and(|f| f != 0.0 && !f.is_nan()), + Value::String(s) => !s.is_empty(), + Value::Array(_) | Value::Object(_) => true, + } +} + +// JS string|number const value; `Other` covers metadata that upstream would +// stringify via String(). +#[derive(Clone, Debug, PartialEq)] +enum ConstVal { + Str(String), + Num(f64), + Other(serde_json::Value), +} + +impl ConstVal { + fn from_json(v: &serde_json::Value) -> ConstVal { + match v { + serde_json::Value::String(s) => ConstVal::Str(s.clone()), + serde_json::Value::Number(n) => ConstVal::Num(n.as_f64().unwrap_or(f64::NAN)), + other => match crate::rules::non_finite_from_tag(other) { + Some(x) => ConstVal::Num(x), + None => ConstVal::Other(other.clone()), + }, + } + } + + fn to_js_string(&self) -> String { + match self { + ConstVal::Str(s) => s.clone(), + ConstVal::Num(n) => crate::jsrt::js_number_to_string(*n), + ConstVal::Other(v) => js_string_of_value(v), + } + } +} + +fn js_string_of_value(v: &serde_json::Value) -> String { + use serde_json::Value; + match v { + Value::Null => "null".to_string(), + Value::Bool(b) => b.to_string(), + Value::Number(n) => crate::jsrt::js_number_to_string(n.as_f64().unwrap_or(f64::NAN)), + Value::String(s) => s.clone(), + // Array.prototype.toString: elements joined by ',', null → ''. + Value::Array(a) => a + .iter() + .map(|e| { + if e.is_null() { + String::new() + } else { + js_string_of_value(e) + } + }) + .collect::>() + .join(","), + Value::Object(_) => "[object Object]".to_string(), + } +} + +pub fn assemble(rules: &[StylexRule], cfg: &AssembleConfig) -> Result { + assemble_impl(rules, cfg, None) +} + +pub fn assemble_with_comparator( + rules: &[StylexRule], + cfg: &AssembleConfig, + cmp: Comparator, +) -> Result { + assemble_impl(rules, cfg, Some(cmp)) +} + +fn is_const_rule(r: &StylexRule) -> bool { + r.const_key.is_some() && matches!(&r.const_val, Some(v) if !v.is_null()) +} + +fn const_decl(r: &StylexRule) -> (String, ConstVal) { + ( + format!("var(--{})", r.class_name), + ConstVal::from_json(r.const_val.as_ref().unwrap()), + ) +} + +// Insertion-ordered "var(--keyhash)" → value map; duplicate keys keep their +// slot, value last-wins (JS Map.set). Each value is then resolved in place. +fn collect_resolved_consts( + decls: impl Iterator, +) -> Result, AssembleError> { + let mut consts: Vec<(String, ConstVal)> = Vec::new(); + for (key, val) in decls { + match consts.iter_mut().find(|(k, _)| *k == key) { + Some(slot) => slot.1 = val, + None => consts.push((key, val)), + } + } + for i in 0..consts.len() { + let value = consts[i].1.clone(); + let resolved = resolve_constant(&value, &consts, &mut Vec::new())?; + consts[i].1 = resolved; + } + Ok(consts) +} + +fn rule_has_logical_float(r: &StylexRule) -> bool { + r.ltr.contains(LOGICAL_FLOAT_START_VAR) + || r.ltr.contains(LOGICAL_FLOAT_END_VAR) + || r.rtl.as_deref().is_some_and(|t| { + t.contains(LOGICAL_FLOAT_START_VAR) || t.contains(LOGICAL_FLOAT_END_VAR) + }) +} + +fn logical_float_block(has_logical_float: bool) -> String { + if has_logical_float { + format!( + ":root, [dir=\"ltr\"] {{\n {LOGICAL_FLOAT_START_VAR}: left;\n \ + {LOGICAL_FLOAT_END_VAR}: right;\n}}\n[dir=\"rtl\"] {{\n \ + {LOGICAL_FLOAT_START_VAR}: right;\n {LOGICAL_FLOAT_END_VAR}: left;\n}}\n" + ) + } else { + String::new() + } +} + +fn layer_parts(cfg: &AssembleConfig) -> (bool, &[String], &[String], &str) { + match &cfg.use_layers { + LayersConfig::On { + before, + after, + prefix, + } => (true, before, after, prefix.as_deref().unwrap_or("")), + LayersConfig::Off => (false, &[], &[], ""), + } +} + +fn layer_name(prefix: &str, index: usize) -> String { + if prefix.is_empty() { + format!("priority{}", index + 1) + } else { + format!("{prefix}.priority{}", index + 1) + } +} + +fn layers_header(cfg: &AssembleConfig, group_count: usize) -> String { + let (_, before, after, prefix) = layer_parts(cfg); + let names: Vec = before + .iter() + .cloned() + .chain((0..group_count).map(|i| layer_name(prefix, i))) + .chain(after.iter().cloned()) + .collect(); + format!("\n@layer {};\n", names.join(", ")) +} + +// One deduped class's output: the ltr line, or the ltr/rtl pair joined by the +// same '\n' the group join would use. +fn rule_chunk(ltr: &str, rtl: Option<&str>, index: usize, cfg: &AssembleConfig) -> String { + let use_layers_on = matches!(cfg.use_layers, LayersConfig::On { .. }); + let mut ltr = ltr.to_string(); + let mut rtl = rtl.map(str::to_string); + if !use_layers_on && !cfg.legacy_disable_layers { + ltr = add_specificity_level(<r, index); + rtl = rtl.map(|r| add_specificity_level(&r, index)); + } + ltr = double_theme_classes(<r); + rtl = rtl.map(|r| double_theme_classes(&r)); + match rtl { + Some(rtl) if cfg.enable_ltr_rtl_comments => { + format!("/* @ltr begin */{ltr}/* @ltr end */\n/* @rtl begin */{rtl}/* @rtl end */") + } + Some(rtl) => format!( + "{}\n{}", + add_ancestor_selector(<r, "html:not([dir='rtl'])"), + add_ancestor_selector(&rtl, "html[dir='rtl']") + ), + None => ltr, + } +} + +fn compare_rules( + a: &StylexRule, + b: &StylexRule, + cfg: &AssembleConfig, + cmp: Comparator, +) -> Ordering { + let diff = a.priority - b.priority; + if diff != 0.0 { + return if diff < 0.0 { + Ordering::Less + } else { + Ordering::Greater + }; + } + if cfg.use_legacy_classnames_sort { + cmp(&a.class_name, &b.class_name) + } else { + match cmp(decl_slice(&a.ltr), decl_slice(&b.ltr)) { + Ordering::Equal => cmp(&a.ltr, &b.ltr), + other => other, + } + } +} + +fn assemble_impl( + rules: &[StylexRule], + cfg: &AssembleConfig, + custom_cmp: Option, +) -> Result { + if rules.is_empty() { + return Ok(String::new()); + } + + let non_const: Vec<&StylexRule> = rules.iter().filter(|r| !is_const_rule(r)).collect(); + let consts = + collect_resolved_consts(rules.iter().filter(|r| is_const_rule(r)).map(const_decl))?; + let prepared_consts = prepare_consts(&consts); + + let mut sorted: Vec = match custom_cmp { + Some(cmp) => { + let mut cloned: Vec = non_const.iter().map(|r| (*r).clone()).collect(); + cloned.sort_by(|a, b| compare_rules(a, b, cfg, cmp)); + cloned + } + None => keyed_sorted_clone(&non_const, cfg.use_legacy_classnames_sort), + }; + + // Logical-float detection reads the pre-substitution rule text. + let has_logical_float = non_const.iter().any(|r| rule_has_logical_float(r)); + let logical_float_vars = logical_float_block(has_logical_float); + + for rule in &mut sorted { + if let Some(ltr) = substitute_consts(&rule.ltr, &prepared_consts) { + rule.ltr = ltr.into(); + } + if let Some(rtl) = &rule.rtl + && let Some(rtl_text) = substitute_consts(rtl, &prepared_consts) + { + rule.rtl = Some(rtl_text.into()); + } + } + + // Consecutive runs of floor(priority/1000); input is priority-sorted so runs + // are the priority bands. + let mut groups: Vec> = Vec::new(); + let mut last_level = -1.0f64; + for rule in sorted { + let level = (rule.priority / 1000.0).floor(); + match groups.last_mut() { + Some(last) if level == last_level => last.push(rule), + _ => { + last_level = level; + groups.push(vec![rule]); + } + } + } + + let (use_layers_on, _, _, layer_prefix) = layer_parts(cfg); + let header = if use_layers_on { + layers_header(cfg, groups.len()) + } else { + String::new() + }; + + let mut group_blocks: Vec = Vec::with_capacity(groups.len()); + for (index, group) in groups.iter().enumerate() { + let pri = group[0].priority; + + // Last-wins dedupe by className, first-occurrence order (JS Map semantics). + let mut order: Vec<&str> = Vec::with_capacity(group.len()); + let mut latest: HashMap<&str, &StylexRule> = HashMap::with_capacity(group.len()); + for rule in group { + if latest.insert(&rule.class_name, rule).is_none() { + order.push(&rule.class_name); + } + } + + let mut chunks: Vec = Vec::with_capacity(order.len()); + for class_name in order { + let rule = latest[class_name]; + // Empty rtl is falsy upstream and behaves exactly like absent. + let rtl = rule.rtl.as_deref().filter(|s| !s.is_empty()); + chunks.push(rule_chunk(&rule.ltr, rtl, index, cfg)); + } + let collected = chunks.join("\n"); + group_blocks.push(if use_layers_on && pri > 0.0 { + format!( + "@layer {}{{\n{}\n}}", + layer_name(layer_prefix, index), + collected + ) + } else { + collected + }); + } + + Ok(format!( + "{logical_float_vars}{header}{}", + group_blocks.join("\n") + )) +} + +// localeCompare levels of one string, laid out [primary n][secondary n] +// [tertiary n]; Fallback marks a char outside the pinned collation alphabet. +enum CollKey { + Verified { n: usize, buf: Vec }, + Fallback, +} + +impl CollKey { + fn derive(s: &str) -> CollKey { + let n = s.chars().count(); + let mut buf = vec![0u8; 3 * n]; + for (i, c) in s.chars().enumerate() { + match locale_key(c) { + Ok((p, sec, ter)) => { + buf[i] = p; + buf[n + i] = sec; + buf[2 * n + i] = ter; + } + Err(_) => return CollKey::Fallback, + } + } + CollKey::Verified { n, buf } + } + + fn verified(&self) -> bool { + matches!(self, CollKey::Verified { .. }) + } + + // Equal primary sequences imply equal char counts, so the secondary and + // tertiary segment comparisons are always aligned. + fn cmp_verified(&self, other: &CollKey) -> Ordering { + match (self, other) { + (CollKey::Verified { n: na, buf: ba }, CollKey::Verified { n: nb, buf: bb }) => ba + [..*na] + .cmp(&bb[..*nb]) + .then_with(|| ba[*na..2 * na].cmp(&bb[*nb..2 * nb])) + .then_with(|| ba[2 * na..].cmp(&bb[2 * nb..])), + _ => unreachable!("cmp_verified on a fallback collation key"), + } + } + + // default_locale_cmp semantics: either side unverified → UTF-16 order. + fn cmp_or<'x>( + &self, + other: &CollKey, + fallback: impl FnOnce() -> (&'x str, &'x str), + ) -> Ordering { + if self.verified() && other.verified() { + self.cmp_verified(other) + } else { + let (a, b) = fallback(); + utf16_cmp(a, b) + } + } +} + +enum SortKeys { + Legacy(CollKey), + Standard { decl: CollKey, ltr: CollKey }, +} + +impl SortKeys { + fn for_rule(r: &StylexRule, legacy: bool) -> SortKeys { + if legacy { + SortKeys::Legacy(CollKey::derive(&r.class_name)) + } else { + SortKeys::Standard { + decl: CollKey::derive(decl_slice(&r.ltr)), + ltr: CollKey::derive(&r.ltr), + } + } + } + + fn verified(&self) -> bool { + match self { + SortKeys::Legacy(k) => k.verified(), + SortKeys::Standard { decl, ltr } => decl.verified() && ltr.verified(), + } + } + + fn cmp_with_fallback(&self, other: &SortKeys, a: &StylexRule, b: &StylexRule) -> Ordering { + match (self, other) { + (SortKeys::Legacy(ka), SortKeys::Legacy(kb)) => { + ka.cmp_or(kb, || (&a.class_name, &b.class_name)) + } + ( + SortKeys::Standard { decl: da, ltr: la }, + SortKeys::Standard { decl: db, ltr: lb }, + ) => match da.cmp_or(db, || (decl_slice(&a.ltr), decl_slice(&b.ltr))) { + Ordering::Equal => la.cmp_or(lb, || (&a.ltr, &b.ltr)), + other => other, + }, + _ => unreachable!("mixed sort key kinds"), + } + } + + fn cmp_verified(&self, other: &SortKeys) -> Ordering { + match (self, other) { + (SortKeys::Legacy(ka), SortKeys::Legacy(kb)) => ka.cmp_verified(kb), + ( + SortKeys::Standard { decl: da, ltr: la }, + SortKeys::Standard { decl: db, ltr: lb }, + ) => da.cmp_verified(db).then_with(|| la.cmp_verified(lb)), + _ => unreachable!("mixed sort key kinds"), + } + } +} + +// Collation keys derived once per rule instead of once per comparison; the +// stable sort and the per-pair fallback keep comparator semantics identical. +fn keyed_sorted_clone(non_const: &[&StylexRule], legacy: bool) -> Vec { + let mut entries: Vec<(SortKeys, &StylexRule)> = non_const + .iter() + .map(|r| (SortKeys::for_rule(r, legacy), *r)) + .collect(); + entries.sort_by(|(ka, a), (kb, b)| { + let diff = a.priority - b.priority; + if diff != 0.0 { + return if diff < 0.0 { + Ordering::Less + } else { + Ordering::Greater + }; + } + ka.cmp_with_fallback(kb, a, b) + }); + entries.into_iter().map(|(_, r)| r.clone()).collect() +} + +// rule.slice(rule.lastIndexOf('{')): lastIndexOf misses → -1 → JS slice(-1) is +// the final character. +fn decl_slice(rule: &str) -> &str { + match rule.rfind('{') { + Some(i) => &rule[i..], + None => rule + .char_indices() + .last() + .map(|(i, _)| &rule[i..]) + .unwrap_or(""), + } +} + +fn resolve_constant( + value: &ConstVal, + consts: &[(String, ConstVal)], + visited: &mut Vec, +) -> Result { + let ConstVal::Str(s) = value else { + return Ok(value.clone()); + }; + let mut result = s.clone(); + let mut scan_from = 0usize; + while let Some((start, end)) = find_var_ref(&result, scan_from) { + let ref_name = result[start + 4..end - 1].to_string(); + if visited.contains(&ref_name) { + return Err(AssembleError::CircularConst(ref_name)); + } + let ref_key = format!("var({ref_name})"); + let Some((_, ref_val)) = consts.iter().find(|(k, _)| *k == ref_key) else { + scan_from = end; + continue; + }; + visited.push(ref_name.clone()); + let replacement = resolve_constant(&ref_val.clone(), consts, visited)?; + let needle = result[start..end].to_string(); + result = js_replace_first(&result, &needle, &replacement.to_js_string()); + visited.retain(|v| *v != ref_name); + scan_from = 0; + } + Ok(ConstVal::Str(result)) +} + +// Scanner for /var\((--[A-Za-z0-9_-]+)\)/ from a byte offset; returns the byte +// span of the whole match. +fn find_var_ref(s: &str, from: usize) -> Option<(usize, usize)> { + let bytes = s.as_bytes(); + let mut pos = from; + loop { + let off = s.get(pos..)?.find("var(--")?; + let start = pos + off; + let mut j = start + 6; + while j < bytes.len() && is_var_ident_byte(bytes[j]) { + j += 1; + } + if j > start + 6 && j < bytes.len() && bytes[j] == b')' { + return Some((start, j + 1)); + } + pos = start + 1; + } +} + +fn is_var_ident_byte(b: u8) -> bool { + b.is_ascii_alphanumeric() || b == b'_' || b == b'-' +} + +// The per-const strings substitution needs, derived once per assemble instead +// of once per rule × const. +struct PreparedConst { + var_ref: String, + replacement: String, + key_rewrite: Option<(String, String)>, +} + +fn prepare_consts(consts: &[(String, ConstVal)]) -> Vec { + consts + .iter() + .map(|(var_ref, const_val)| { + let replacement = const_val.to_js_string(); + // A const resolving to var(...) also rewrites `--constName:` declaration + // keys so the target variable stays overridable. Trims are ES TrimString. + let key_rewrite = + (replacement.starts_with("var(") && replacement.ends_with(')')).then(|| { + let inside = crate::jsrt::js_trim(&replacement[4..replacement.len() - 1]); + let target_name = match inside.find(',') { + Some(ci) => crate::jsrt::js_trim(&inside[..ci]), + None => inside, + }; + let const_name = &var_ref[4..var_ref.len() - 1]; + (format!("{const_name}:"), format!("{target_name}:")) + }); + PreparedConst { + var_ref: var_ref.clone(), + replacement, + key_rewrite, + } + }) + .collect() +} + +// The contains gates only skip js_replace_all calls that would be identity +// copies; a hit re-probes for var(-- because replacements can add/remove refs. +/// `None` when no const reference matched: callers keep sharing the rule's +/// Arc'd text instead of materializing an identical String. +fn substitute_consts(text: &str, consts: &[PreparedConst]) -> Option { + let mut out: Option = None; + let mut has_ref = text.contains("var(--"); + for c in consts { + let cur = out.as_deref().unwrap_or(text); + if has_ref && cur.contains(c.var_ref.as_str()) { + let next = js_replace_all(cur, &c.var_ref, &c.replacement); + has_ref = next.contains("var(--"); + out = Some(next); + } + let cur = out.as_deref().unwrap_or(text); + if let Some((needle, target)) = &c.key_rewrite + && cur.contains(needle.as_str()) + { + let next = js_replace_all(cur, needle, target); + has_ref = next.contains("var(--"); + out = Some(next); + } + } + out +} + +// JS GetSubstitution for string-pattern replace/replaceAll: only $$, $&, $`, $' +// are active (no capture groups exist). +fn js_substitution(out: &mut Vec, full: &str, pos: usize, matched: &str, replacement: &str) { + let rep = replacement.as_bytes(); + let mut i = 0; + while i < rep.len() { + if rep[i] == b'$' && i + 1 < rep.len() { + match rep[i + 1] { + b'$' => { + out.push(b'$'); + i += 2; + continue; + } + b'&' => { + out.extend_from_slice(matched.as_bytes()); + i += 2; + continue; + } + b'`' => { + out.extend_from_slice(&full.as_bytes()[..pos]); + i += 2; + continue; + } + b'\'' => { + out.extend_from_slice(&full.as_bytes()[pos + matched.len()..]); + i += 2; + continue; + } + _ => {} + } + } + out.push(rep[i]); + i += 1; + } +} + +fn js_replace_first(s: &str, needle: &str, replacement: &str) -> String { + let Some(pos) = s.find(needle) else { + return s.to_string(); + }; + let mut out = Vec::with_capacity(s.len()); + out.extend_from_slice(&s.as_bytes()[..pos]); + js_substitution(&mut out, s, pos, needle, replacement); + out.extend_from_slice(&s.as_bytes()[pos + needle.len()..]); + String::from_utf8(out).expect("byte-splices preserve UTF-8") +} + +fn js_replace_all(s: &str, needle: &str, replacement: &str) -> String { + debug_assert!(!needle.is_empty()); + let mut out = Vec::with_capacity(s.len()); + let mut pos = 0; + while let Some(off) = s[pos..].find(needle) { + let at = pos + off; + out.extend_from_slice(&s.as_bytes()[pos..at]); + js_substitution(&mut out, s, at, needle, replacement); + pos = at + needle.len(); + } + out.extend_from_slice(&s.as_bytes()[pos..]); + String::from_utf8(out).expect("byte-splices preserve UTF-8") +} + +// :not(#\#) polyfill; inserted before the first '::' when present, else before +// the last '{'; @keyframes exempt (only @keyframes — @property etc. are not). +fn add_specificity_level(selector: &str, index: usize) -> String { + if selector.starts_with("@keyframes") { + return selector.to_string(); + } + let pseudo = ":not(#\\#)".repeat(index); + let split_at = match selector.find("::") { + Some(i) => i, + None => match selector.rfind('{') { + Some(i) => i, + // lastIndexOf miss → -1 → JS slice(0,-1) / slice(-1) split before the + // final character. + None => selector.char_indices().last().map(|(i, _)| i).unwrap_or(0), + }, + }; + format!( + "{}{}{}", + &selector[..split_at], + pseudo, + &selector[split_at..] + ) +} + +fn add_ancestor_selector(selector: &str, ancestor_selector: &str) -> String { + if selector.starts_with("@keyframes") { + return selector.to_string(); + } + if !selector.starts_with('@') { + return format!("{ancestor_selector} {selector}"); + } + let last_at_rule = selector.rfind('@').unwrap_or(0); + match selector[last_at_rule..].find('{') { + Some(off) => { + let bracket = last_at_rule + off; + format!( + "{}{} {}", + &selector[..bracket + 1], + ancestor_selector, + &selector[bracket + 1..] + ) + } + // indexOf miss → -1 → slice(0,0) prefix and the whole string as rest. + None => format!("{ancestor_selector} {selector}"), + } +} + +// /\.([a-zA-Z0-9]+), \.([a-zA-Z0-9]+):root/g → '.$1.$1, .$1.$1:root' — the +// second class is intentionally overwritten by the first, as upstream does. +fn double_theme_classes(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out: Vec = Vec::with_capacity(s.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'.' + && let Some((end, class_end)) = match_theme_pair(bytes, i) + { + let class = &s[i + 1..class_end]; + out.extend_from_slice(format!(".{class}.{class}, .{class}.{class}:root").as_bytes()); + i = end; + continue; + } + out.push(bytes[i]); + i += 1; + } + String::from_utf8(out).expect("ASCII-anchored splices preserve UTF-8") +} + +fn match_theme_pair(bytes: &[u8], start: usize) -> Option<(usize, usize)> { + let mut j = start + 1; + while j < bytes.len() && bytes[j].is_ascii_alphanumeric() { + j += 1; + } + if j == start + 1 { + return None; + } + let class_end = j; + if !bytes[j..].starts_with(b", .") { + return None; + } + j += 3; + let second_start = j; + while j < bytes.len() && bytes[j].is_ascii_alphanumeric() { + j += 1; + } + if j == second_start || !bytes[j..].starts_with(b":root") { + return None; + } + Some((j + 5, class_end)) +} + +// The one comparator shared with pseudo sorting (jsrt::default_locale_cmp); +// re-exported so existing callers keep their import path. +pub use crate::jsrt::default_locale_cmp; + +fn class_hash64(s: &str) -> u64 { + let mut h = 0xcbf29ce484222325u64; + for b in s.bytes() { + h ^= b as u64; + h = h.wrapping_mul(0x100000001b3); + } + h +} + +// Hash values are precomputed 64-bit class hashes; pass them through verbatim. +#[derive(Default)] +struct IdentityHasher(u64); + +impl std::hash::Hasher for IdentityHasher { + fn finish(&self) -> u64 { + self.0 + } + fn write(&mut self, bytes: &[u8]) { + for &b in bytes { + self.0 = (self.0 << 8) | u64::from(b); + } + } + fn write_u64(&mut self, x: u64) { + self.0 = x; + } +} + +struct PreparedRule { + class_name: std::sync::Arc, + class_hash: u64, + priority: f64, + keys: SortKeys, + // ltr/rtl carry the const substitutions; chunk memoizes the emitted text + // per group index. + ltr: String, + rtl: Option, + chunk: Option<(usize, String)>, +} + +struct FilePrep { + rules: Vec, + const_decls: Vec<(String, ConstVal)>, + // safe = every sort key verified and every priority finite: the exact + // preconditions under which sorted-by-key order equals the stable sort. + safe: bool, + logical_float: bool, +} + +fn prepare_file(rules: &[StylexRule], legacy: bool, consts: &[PreparedConst]) -> FilePrep { + let mut prep = FilePrep { + rules: Vec::with_capacity(rules.len()), + const_decls: Vec::new(), + safe: true, + logical_float: false, + }; + for r in rules { + if is_const_rule(r) { + prep.const_decls.push(const_decl(r)); + continue; + } + prep.logical_float |= rule_has_logical_float(r); + let keys = SortKeys::for_rule(r, legacy); + prep.safe &= r.priority.is_finite() && keys.verified(); + let ltr = substitute_consts(&r.ltr, consts).unwrap_or_else(|| r.ltr.to_string()); + let rtl = r + .rtl + .as_ref() + .map(|t| substitute_consts(t, consts).unwrap_or_else(|| t.to_string())); + prep.rules.push(PreparedRule { + class_name: r.class_name.clone(), + class_hash: class_hash64(&r.class_name), + priority: r.priority, + keys, + ltr, + rtl, + chunk: None, + }); + } + prep +} + +// Strict total order = comparator order + the (file, seq) input position; with +// safe files this reproduces the one-shot stable sort exactly. +fn entry_cmp(files: &[FilePrep], a: (u32, u32), b: (u32, u32)) -> Ordering { + let pa = &files[a.0 as usize].rules[a.1 as usize]; + let pb = &files[b.0 as usize].rules[b.1 as usize]; + let diff = pa.priority - pb.priority; + if diff != 0.0 { + return if diff < 0.0 { + Ordering::Less + } else { + Ordering::Greater + }; + } + pa.keys.cmp_verified(&pb.keys).then_with(|| a.cmp(&b)) +} + +// Per-config incremental state: prepared rules, resolved consts, and the +// maintained global sort order that make a single-file swap re-emit O(rules). +struct IncrState { + cfg: AssembleConfig, + dirty: BTreeSet, + structural: bool, + consts: Vec<(String, ConstVal)>, + prepared_consts: Vec, + paths: Vec, + files: Vec, + sorted: Vec<(u32, u32)>, +} + +const MAX_INCR_STATES: usize = 4; + +// Incremental assembly for the HMR path (design-core.md §7): file-keyed rule +// sets, canonical emission order = BTreeMap file order, memoized full assemble. +#[derive(Default)] +pub struct RuleRegistry { + files: BTreeMap>, + generation: u64, + cache: Option<(u64, AssembleConfig, Result)>, + incr: Vec, +} + +impl RuleRegistry { + pub fn new() -> Self { + RuleRegistry::default() + } + + pub fn set_file_rules(&mut self, file: &str, rules: Vec) { + if self.files.get(file).is_some_and(|old| *old == rules) { + return; + } + let existed = self.files.insert(file.to_string(), rules).is_some(); + for state in &mut self.incr { + if existed { + state.dirty.insert(file.to_string()); + } else { + state.structural = true; + } + } + self.generation += 1; + } + + pub fn remove_file(&mut self, file: &str) -> bool { + let removed = self.files.remove(file).is_some(); + if removed { + for state in &mut self.incr { + state.structural = true; + } + self.generation += 1; + } + removed + } + + pub fn generation(&self) -> u64 { + self.generation + } + + pub fn file_count(&self) -> usize { + self.files.len() + } + + // The one-shot-equivalent rule list: files in path order, rules in file order. + pub fn all_rules(&self) -> Vec { + self.files.values().flatten().cloned().collect() + } + + pub fn emit(&mut self, cfg: &AssembleConfig) -> Result { + if let Some((generation, cached_cfg, result)) = &self.cache + && *generation == self.generation + && cached_cfg == cfg + { + return result.clone(); + } + let result = match self.try_incremental(cfg) { + Some(css) => { + // Debug cross-check: the incremental path must match one-shot output. + #[cfg(debug_assertions)] + { + let full = assemble(&self.all_rules(), cfg); + debug_assert_eq!( + full.as_deref(), + Ok(css.as_str()), + "incremental emit diverged from one-shot assemble" + ); + } + Ok(css) + } + None => { + let result = assemble(&self.all_rules(), cfg); + self.rebuild_incr_state(cfg); + result + } + }; + self.cache = Some((self.generation, cfg.clone(), result.clone())); + result + } + + // The fast path: re-prepare only the swapped files and splice them into the + // cached sort order. None = fall back to the one-shot path. + fn try_incremental(&mut self, cfg: &AssembleConfig) -> Option { + let Self { files, incr, .. } = self; + let state = incr.iter_mut().find(|s| s.cfg == *cfg)?; + if state.structural { + return None; + } + + let mut raw_consts: Vec<(String, ConstVal)> = Vec::new(); + for (rank, path) in state.paths.iter().enumerate() { + if state.dirty.contains(path) { + let rules = files.get(path)?; + raw_consts.extend(rules.iter().filter(|r| is_const_rule(r)).map(const_decl)); + } else { + raw_consts.extend(state.files[rank].const_decls.iter().cloned()); + } + } + let resolved = collect_resolved_consts(raw_consts.into_iter()).ok()?; + if resolved != state.consts { + return None; + } + + let dirty: Vec = std::mem::take(&mut state.dirty).into_iter().collect(); + for path in &dirty { + let rank = state.paths.binary_search(path).ok()? as u32; + let prep = prepare_file( + files.get(path)?, + state.cfg.use_legacy_classnames_sort, + &state.prepared_consts, + ); + if !prep.safe { + return None; + } + state.files[rank as usize] = prep; + let mut fresh: Vec<(u32, u32)> = (0..state.files[rank as usize].rules.len()) + .map(|seq| (rank, seq as u32)) + .collect(); + fresh.sort_unstable_by(|&a, &b| entry_cmp(&state.files, a, b)); + state.sorted = merge_sorted(&state.files, &state.sorted, rank, fresh); + } + + if files.values().all(|rules| rules.is_empty()) { + return Some(String::new()); + } + Some(render(state)) + } + + // Full rebuild of the per-config state; drops it instead when the rule set + // is outside the safe preconditions (or consts fail to resolve). + fn rebuild_incr_state(&mut self, cfg: &AssembleConfig) { + self.incr.retain(|s| s.cfg != *cfg); + let raw = self + .files + .values() + .flat_map(|rules| rules.iter().filter(|r| is_const_rule(r)).map(const_decl)); + let Ok(consts) = collect_resolved_consts(raw) else { + return; + }; + let prepared_consts = prepare_consts(&consts); + let mut paths = Vec::with_capacity(self.files.len()); + let mut fps = Vec::with_capacity(self.files.len()); + for (path, rules) in &self.files { + let prep = prepare_file(rules, cfg.use_legacy_classnames_sort, &prepared_consts); + if !prep.safe { + return; + } + paths.push(path.clone()); + fps.push(prep); + } + let mut sorted: Vec<(u32, u32)> = fps + .iter() + .enumerate() + .flat_map(|(rank, f)| (0..f.rules.len()).map(move |seq| (rank as u32, seq as u32))) + .collect(); + sorted.sort_unstable_by(|&a, &b| entry_cmp(&fps, a, b)); + if self.incr.len() >= MAX_INCR_STATES { + self.incr.remove(0); + } + self.incr.push(IncrState { + cfg: cfg.clone(), + dirty: BTreeSet::new(), + structural: false, + consts, + prepared_consts, + paths, + files: fps, + sorted, + }); + } +} + +// old minus `rank`'s stale entries, merged with that file's fresh pre-sorted +// entries by binary-searched insertion points (strict entry_cmp total order). +fn merge_sorted( + files: &[FilePrep], + old: &[(u32, u32)], + rank: u32, + fresh: Vec<(u32, u32)>, +) -> Vec<(u32, u32)> { + let kept: Vec<(u32, u32)> = old.iter().copied().filter(|e| e.0 != rank).collect(); + let mut merged = Vec::with_capacity(kept.len() + fresh.len()); + let mut prev = 0usize; + for &candidate in &fresh { + let at = prev + + kept[prev..].partition_point(|&e| entry_cmp(files, e, candidate) == Ordering::Less); + merged.extend_from_slice(&kept[prev..at]); + merged.push(candidate); + prev = at; + } + merged.extend_from_slice(&kept[prev..]); + merged +} + +// First slot inline: an empty Vec never allocates, so only a real 64-bit hash +// collision costs a heap allocation. +type SlotMap = HashMap), BuildHasherDefault>; + +fn render(state: &mut IncrState) -> String { + let IncrState { + cfg, files, sorted, .. + } = state; + + struct GroupMeta { + first_priority: f64, + emit: Vec<(u32, u32)>, + } + let mut groups: Vec = Vec::new(); + let mut slots: Vec<(u32, u32)> = Vec::new(); + let mut slot_map: SlotMap = SlotMap::default(); + let mut last_band = 0.0f64; + let mut first_priority = 0.0f64; + let mut open = false; + for &(rank, seq) in sorted.iter() { + let p = &files[rank as usize].rules[seq as usize]; + let band = (p.priority / 1000.0).floor(); + if !open || band != last_band { + if open { + groups.push(GroupMeta { + first_priority, + emit: std::mem::take(&mut slots), + }); + } + slot_map.clear(); + open = true; + last_band = band; + first_priority = p.priority; + } + // JS Map semantics: a repeated class keeps its first slot, latest value wins. + match slot_map.entry(p.class_hash) { + std::collections::hash_map::Entry::Vacant(vacant) => { + vacant.insert((slots.len() as u32, Vec::new())); + slots.push((rank, seq)); + } + std::collections::hash_map::Entry::Occupied(mut occupied) => { + let same_class = |&si: &u32| { + let (er, es) = slots[si as usize]; + files[er as usize].rules[es as usize].class_name == p.class_name + }; + let (first, rest) = occupied.get_mut(); + let existing = std::iter::once(*first).find(|si| same_class(si)); + let existing = existing.or_else(|| rest.iter().copied().find(|si| same_class(si))); + match existing { + Some(si) => slots[si as usize] = (rank, seq), + None => { + rest.push(slots.len() as u32); + slots.push((rank, seq)); + } + } + } + } + } + if open { + groups.push(GroupMeta { + first_priority, + emit: slots, + }); + } + + let (use_layers_on, _, _, layer_prefix) = layer_parts(cfg); + let header = if use_layers_on { + layers_header(cfg, groups.len()) + } else { + String::new() + }; + let logical = logical_float_block(files.iter().any(|f| f.logical_float)); + + let mut out = String::with_capacity(logical.len() + header.len() + 64 * sorted.len().min(8192)); + out.push_str(&logical); + out.push_str(&header); + for (index, group) in groups.iter().enumerate() { + if index > 0 { + out.push('\n'); + } + let wrap = use_layers_on && group.first_priority > 0.0; + if wrap { + out.push_str("@layer "); + out.push_str(&layer_name(layer_prefix, index)); + out.push_str("{\n"); + } + for (j, &(rank, seq)) in group.emit.iter().enumerate() { + if j > 0 { + out.push('\n'); + } + let p = &mut files[rank as usize].rules[seq as usize]; + if !matches!(&p.chunk, Some((i, _)) if *i == index) { + let rtl = p.rtl.as_deref().filter(|s| !s.is_empty()); + let chunk = rule_chunk(&p.ltr, rtl, index, cfg); + p.chunk = Some((index, chunk)); + } + out.push_str(&p.chunk.as_ref().expect("chunk just memoized").1); + } + if wrap { + out.push_str("\n}"); + } + } + out +} diff --git a/crates/stylex/src/cache.rs b/crates/stylex/src/cache.rs new file mode 100644 index 00000000..e5aab9c2 --- /dev/null +++ b/crates/stylex/src/cache.rs @@ -0,0 +1,957 @@ +//! Local content-addressed compile cache (docs/design-cache.md): entries pair +//! the result with an fs-observation log, replayed to validate every hit. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::Mutex; +use std::sync::atomic::{AtomicU64, Ordering}; + +use serde::{Deserialize, Serialize}; + +use crate::api::{ + FileContext, SourceCompileResult, might_contain_stylex, transform_source_with_dep_log_in, +}; +use crate::errors::StylexError; +use crate::eval::value::{EvalValue, JsObjectMap}; +use crate::module_resolution::{FsProvider, ResolveConfig, path_relative}; +use crate::options::ResolvedOptions; +use crate::rules::StylexRule; + +// v2: options key material moved to the precomputed ResolvedOptions::cache_repr. +pub const CACHE_SCHEMA_VERSION: u32 = 2; + +/// Bump on any behavior-affecting compiler change (the STYLEX_PASS_VERSION +/// pattern): the crate version alone does not move per commit. +pub const STYLEX_PASS_VERSION: &str = "0.19.0-rs.2"; + +pub fn compiler_fingerprint() -> &'static str { + static FINGERPRINT: std::sync::OnceLock = std::sync::OnceLock::new(); + FINGERPRINT.get_or_init(|| { + format!( + "{}+{STYLEX_PASS_VERSION}+schema{CACHE_SCHEMA_VERSION}", + env!("CARGO_PKG_VERSION") + ) + }) +} + +// ------------------------------------------------------------ dep recording + +/// One fs observation made during a compile, with its full outcome. +#[derive(Clone, Debug, PartialEq)] +pub enum DepEvent { + NearestPackage { + from: PathBuf, + found: Option<(String, PathBuf)>, + /// blake3 of the found package.json bytes: any edit there is a miss. + package_json_hash: Option, + }, + ResolveImport { + specifier: String, + importer: PathBuf, + resolved: Option, + }, + Exists { + path: PathBuf, + found: bool, + }, + /// The `fs.existsSync` probe of the alias branches (directories count). + ExistsAny { + path: PathBuf, + found: bool, + }, +} + +/// Every fs observation of one compile, with the root the paths anchor to. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct DepLog { + /// Canonicalized compile root (`ctx.cwd`); in-root paths serialize relative. + pub root: PathBuf, + /// The root as the caller spelled it (macOS tempdirs alias /var → /private/var). + pub raw_root: PathBuf, + pub events: Vec, +} + +/// [`FsProvider`] wrapper logging every call + outcome for cache validation. +pub struct RecordingFs<'a> { + inner: &'a dyn FsProvider, + root: PathBuf, + raw_root: PathBuf, + events: Mutex>, +} + +impl<'a> RecordingFs<'a> { + pub fn new(inner: &'a dyn FsProvider, root: &Path) -> Self { + let canonical = inner.canonicalize_root(root); + Self { + inner, + root: canonical, + raw_root: root.to_path_buf(), + events: Mutex::new(Vec::new()), + } + } + + pub fn into_log(self) -> DepLog { + DepLog { + root: self.root, + raw_root: self.raw_root, + events: self + .events + .into_inner() + .unwrap_or_else(std::sync::PoisonError::into_inner), + } + } + + fn record(&self, event: DepEvent) { + let mut events = self + .events + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if !events.contains(&event) { + events.push(event); + } + } +} + +impl FsProvider for RecordingFs<'_> { + fn nearest_package(&self, from: &Path) -> Option<(String, PathBuf)> { + let found = self.inner.nearest_package(from); + let package_json_hash = found + .as_ref() + .and_then(|(_, dir)| self.inner.hash_file(&dir.join("package.json"))); + self.record(DepEvent::NearestPackage { + from: from.to_path_buf(), + found: found.clone(), + package_json_hash, + }); + found + } + + fn resolve_import( + &self, + specifier: &str, + importer: &Path, + config: ResolveConfig<'_>, + ) -> Option { + let resolved = self.inner.resolve_import(specifier, importer, config); + self.record(DepEvent::ResolveImport { + specifier: specifier.to_string(), + importer: importer.to_path_buf(), + resolved: resolved.clone(), + }); + resolved + } + + fn exists(&self, p: &Path) -> bool { + let found = self.inner.exists(p); + self.record(DepEvent::Exists { + path: p.to_path_buf(), + found, + }); + found + } + + fn exists_any(&self, p: &Path) -> bool { + let found = self.inner.exists_any(p); + self.record(DepEvent::ExistsAny { + path: p.to_path_buf(), + found, + }); + found + } + + fn hash_file(&self, p: &Path) -> Option { + self.inner.hash_file(p) + } +} + +// --------------------------------------------------------------------- key + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct CacheKey([u8; 32]); + +impl CacheKey { + pub fn hex(&self) -> String { + blake3::Hash::from(self.0).to_hex().to_string() + } +} + +/// `filename_key` must be the compile path relative to `ctx.cwd` (see +/// [`filename_key`]) so entries are shareable across worktrees. +pub fn cache_key(source: &str, filename_key: &str, options: &ResolvedOptions) -> CacheKey { + cache_key_with_fingerprint(source, filename_key, options, compiler_fingerprint()) +} + +pub fn cache_key_with_fingerprint( + source: &str, + filename_key: &str, + options: &ResolvedOptions, + fingerprint: &str, +) -> CacheKey { + // The precomputed Debug capture keys every ResolvedOptions field, present + // and future — no per-field listing to drift out of sync with options.rs. + let mut hasher = blake3::Hasher::new(); + for field in [source, filename_key, &options.cache_repr, fingerprint] { + hasher.update(&(field.len() as u64).to_le_bytes()); + hasher.update(field.as_bytes()); + } + CacheKey(*hasher.finalize().as_bytes()) +} + +pub fn filename_key(cwd: &Path, filename: &Path) -> String { + path_relative(cwd, filename) +} + +// ------------------------------------------------------------- wire format + +#[derive(Serialize, Deserialize)] +struct EntryWire { + schema: u32, + fingerprint: String, + root: String, + deps: Vec, + result: ResultWire, +} + +/// In-root paths serialize root-relative so a hit can revalidate from any +/// worktree; an absolute path pins the entry to its recorded root. +#[derive(Serialize, Deserialize, Clone, PartialEq)] +#[serde(tag = "t")] +enum PathWire { + #[serde(rename = "r")] + Rel { p: String }, + #[serde(rename = "a")] + Abs { p: String }, +} + +#[derive(Serialize, Deserialize)] +#[serde(tag = "t")] +enum DepEventWire { + #[serde(rename = "pkg")] + NearestPackage { + from: PathWire, + found: Option<(String, PathWire)>, + hash: Option, + }, + #[serde(rename = "imp")] + ResolveImport { + specifier: String, + importer: PathWire, + resolved: Option, + }, + #[serde(rename = "ex")] + Exists { path: PathWire, found: bool }, + #[serde(rename = "exa")] + ExistsAny { path: PathWire, found: bool }, +} + +#[derive(Serialize, Deserialize)] +struct ResultWire { + code: String, + map: Option, + rules: Vec, + modified: bool, + create_objects: Vec<(Option, ObjWire)>, +} + +#[derive(Serialize, Deserialize, Clone, PartialEq)] +struct ObjWire { + entries: Vec<(String, ValueWire)>, + css_type: Option, +} + +#[derive(Serialize, Deserialize, Clone, PartialEq)] +#[serde(tag = "t")] +enum ValueWire { + Null, + Undef, + Bool { + v: bool, + }, + /// f64 bits — exact for NaN payloads, -0.0, and infinities. + Num { + bits: u64, + }, + Str { + v: String, + }, + Arr { + v: Vec, + }, + Obj { + v: ObjWire, + }, +} + +fn encode_value(value: &EvalValue) -> ValueWire { + match value { + EvalValue::Null => ValueWire::Null, + EvalValue::Undefined => ValueWire::Undef, + EvalValue::Bool(b) => ValueWire::Bool { v: *b }, + EvalValue::Num(n) => ValueWire::Num { bits: n.to_bits() }, + EvalValue::Str(s) => ValueWire::Str { v: s.clone() }, + EvalValue::Arr(items) => ValueWire::Arr { + v: items.iter().map(encode_value).collect(), + }, + EvalValue::Obj(map) => ValueWire::Obj { + v: encode_object(map), + }, + } +} + +fn decode_value(wire: ValueWire) -> EvalValue { + match wire { + ValueWire::Null => EvalValue::Null, + ValueWire::Undef => EvalValue::Undefined, + ValueWire::Bool { v } => EvalValue::Bool(v), + ValueWire::Num { bits } => EvalValue::Num(f64::from_bits(bits)), + ValueWire::Str { v } => EvalValue::Str(v), + ValueWire::Arr { v } => EvalValue::Arr(v.into_iter().map(decode_value).collect()), + ValueWire::Obj { v } => EvalValue::Obj(Arc::new(decode_object(v))), + } +} + +fn encode_object(map: &JsObjectMap) -> ObjWire { + ObjWire { + entries: map + .entries() + .map(|(k, v)| (k.to_string(), encode_value(v))) + .collect(), + css_type: map.css_type().map(str::to_string), + } +} + +fn decode_object(wire: ObjWire) -> JsObjectMap { + let mut map = JsObjectMap::new(); + for (k, v) in wire.entries { + map.insert(k, decode_value(v)); + } + if let Some(syntax) = wire.css_type { + map.set_css_type(syntax); + } + map +} + +fn encode_result(result: &SourceCompileResult) -> ResultWire { + ResultWire { + code: result.code.clone(), + map: result.map.clone(), + rules: result.rules.clone(), + modified: result.modified, + create_objects: result + .create_objects + .iter() + .map(|(name, obj)| (name.clone(), encode_object(obj))) + .collect(), + } +} + +fn decode_result(wire: ResultWire) -> SourceCompileResult { + SourceCompileResult { + code: wire.code, + map: wire.map, + rules: wire.rules, + modified: wire.modified, + create_objects: wire + .create_objects + .into_iter() + .map(|(name, obj)| (name, Arc::new(decode_object(obj)))) + .collect(), + } +} + +fn to_wire_path(log: &DepLog, p: &Path) -> PathWire { + for root in [&log.root, &log.raw_root] { + if let Ok(rel) = p.strip_prefix(root) { + return PathWire::Rel { + p: rel.to_string_lossy().replace('\\', "/"), + }; + } + } + PathWire::Abs { + p: p.to_string_lossy().into_owned(), + } +} + +fn to_wire_event(log: &DepLog, event: &DepEvent) -> DepEventWire { + match event { + DepEvent::NearestPackage { + from, + found, + package_json_hash, + } => DepEventWire::NearestPackage { + from: to_wire_path(log, from), + found: found + .as_ref() + .map(|(name, dir)| (name.clone(), to_wire_path(log, dir))), + hash: package_json_hash.clone(), + }, + DepEvent::ResolveImport { + specifier, + importer, + resolved, + } => DepEventWire::ResolveImport { + specifier: specifier.clone(), + importer: to_wire_path(log, importer), + resolved: resolved.as_ref().map(|p| to_wire_path(log, p)), + }, + DepEvent::Exists { path, found } => DepEventWire::Exists { + path: to_wire_path(log, path), + found: *found, + }, + DepEvent::ExistsAny { path, found } => DepEventWire::ExistsAny { + path: to_wire_path(log, path), + found: *found, + }, + } +} + +// ---------------------------------------------------------- hit validation + +struct Anchor { + current_root: PathBuf, + same_root: bool, +} + +/// `None` on an absolute path under a different root: such entries are +/// root-pinned because relative derivations cannot reconstruct them. +fn from_wire_path(anchor: &Anchor, wire: &PathWire) -> Option { + match wire { + PathWire::Rel { p } => Some(anchor.current_root.join(p)), + PathWire::Abs { p } if anchor.same_root => Some(PathBuf::from(p)), + PathWire::Abs { .. } => None, + } +} + +fn deps_valid( + entry: &EntryWire, + current_root: &Path, + fs: &dyn FsProvider, + config: ResolveConfig<'_>, +) -> bool { + let canonical = fs.canonicalize_root(current_root); + let anchor = Anchor { + same_root: canonical == Path::new(&entry.root), + current_root: canonical, + }; + entry + .deps + .iter() + .all(|dep| dep_valid(dep, &anchor, fs, config)) +} + +fn dep_valid( + dep: &DepEventWire, + anchor: &Anchor, + fs: &dyn FsProvider, + config: ResolveConfig<'_>, +) -> bool { + match dep { + DepEventWire::NearestPackage { from, found, hash } => { + let Some(from) = from_wire_path(anchor, from) else { + return false; + }; + let expected = match found { + None => None, + Some((name, dir)) => match from_wire_path(anchor, dir) { + Some(dir) => Some((name.clone(), dir)), + None => return false, + }, + }; + if fs.nearest_package(&from) != expected { + return false; + } + let recomputed = expected + .as_ref() + .and_then(|(_, dir)| fs.hash_file(&dir.join("package.json"))); + recomputed == *hash + } + DepEventWire::ResolveImport { + specifier, + importer, + resolved, + } => { + let Some(importer) = from_wire_path(anchor, importer) else { + return false; + }; + let expected = match resolved { + None => None, + Some(p) => match from_wire_path(anchor, p) { + Some(p) => Some(p), + None => return false, + }, + }; + fs.resolve_import(specifier, &importer, config) == expected + } + DepEventWire::Exists { path, found } => { + let Some(path) = from_wire_path(anchor, path) else { + return false; + }; + fs.exists(&path) == *found + } + DepEventWire::ExistsAny { path, found } => { + let Some(path) = from_wire_path(anchor, path) else { + return false; + }; + fs.exists_any(&path) == *found + } + } +} + +// ------------------------------------------------------------------- store + +const DEFAULT_MAX_BYTES: u64 = 2 * 1024 * 1024 * 1024; + +/// One file per key under `dir`; atomic tmp+rename writes, lock-free. +pub struct CacheStore { + dir: PathBuf, + max_bytes: u64, + written_since_gc: AtomicU64, +} + +impl CacheStore { + pub fn new(dir: &Path) -> std::io::Result { + Self::with_max_bytes(dir, DEFAULT_MAX_BYTES) + } + + pub fn with_max_bytes(dir: &Path, max_bytes: u64) -> std::io::Result { + std::fs::create_dir_all(dir)?; + Ok(Self { + dir: dir.to_path_buf(), + max_bytes, + written_since_gc: AtomicU64::new(0), + }) + } + + pub fn dir(&self) -> &Path { + &self.dir + } + + fn entry_path(&self, key: &CacheKey) -> PathBuf { + self.dir.join(key.hex()) + } + + /// A hit only when the entry parses, matches the compiler fingerprint, + /// and every logged fs observation replays identically against `fs`. + pub fn get_validated( + &self, + key: &CacheKey, + current_root: &Path, + fs: &dyn FsProvider, + config: ResolveConfig<'_>, + ) -> Option { + let path = self.entry_path(key); + let bytes = { + let _t = crate::timings::start(crate::timings::Stage::CacheRead); + std::fs::read(&path).ok()? + }; + let entry = { + let _t = crate::timings::start(crate::timings::Stage::CacheParse); + serde_json::from_slice::(&bytes) + }; + let Ok(entry) = entry else { + let _ = std::fs::remove_file(&path); + return None; + }; + if entry.schema != CACHE_SCHEMA_VERSION || entry.fingerprint != compiler_fingerprint() { + return None; + } + { + let _t = crate::timings::start(crate::timings::Stage::CacheReplay); + if !deps_valid(&entry, current_root, fs, config) { + return None; + } + } + let _t = crate::timings::start(crate::timings::Stage::CacheDecode); + Some(decode_result(entry.result)) + } + + pub fn put( + &self, + key: &CacheKey, + result: &SourceCompileResult, + deps: &DepLog, + ) -> std::io::Result<()> { + let entry = EntryWire { + schema: CACHE_SCHEMA_VERSION, + fingerprint: compiler_fingerprint().to_string(), + root: deps.root.to_string_lossy().into_owned(), + deps: deps.events.iter().map(|e| to_wire_event(deps, e)).collect(), + result: encode_result(result), + }; + let bytes = serde_json::to_vec(&entry).map_err(std::io::Error::other)?; + static TMP_COUNTER: AtomicU64 = AtomicU64::new(0); + let tmp = self.dir.join(format!( + ".tmp-{}-{}", + std::process::id(), + TMP_COUNTER.fetch_add(1, Ordering::Relaxed) + )); + std::fs::write(&tmp, &bytes)?; + std::fs::rename(&tmp, self.entry_path(key))?; + let written = self + .written_since_gc + .fetch_add(bytes.len() as u64, Ordering::Relaxed) + + bytes.len() as u64; + if written > self.max_bytes / 8 { + self.written_since_gc.store(0, Ordering::Relaxed); + self.gc()?; + } + Ok(()) + } + + /// Deletes oldest entries beyond the byte cap (and stale tmp files); + /// returns bytes freed. GC only causes misses, never wrong output. + pub fn gc(&self) -> std::io::Result { + let mut entries: Vec<(PathBuf, u64, std::time::SystemTime)> = Vec::new(); + let mut total: u64 = 0; + for dirent in std::fs::read_dir(&self.dir)? { + let dirent = dirent?; + let path = dirent.path(); + let Ok(meta) = dirent.metadata() else { + continue; + }; + let modified = meta.modified().unwrap_or(std::time::UNIX_EPOCH); + let name = dirent.file_name(); + let is_tmp = name.to_string_lossy().starts_with(".tmp-"); + if is_tmp { + let stale = modified + .elapsed() + .is_ok_and(|age| age > std::time::Duration::from_secs(3600)); + if stale { + let _ = std::fs::remove_file(&path); + } + continue; + } + total += meta.len(); + entries.push((path, meta.len(), modified)); + } + if total <= self.max_bytes { + return Ok(0); + } + entries.sort_by(|a, b| (a.2, &a.0).cmp(&(b.2, &b.0))); + let mut freed = 0; + for (path, len, _) in entries { + if total - freed <= self.max_bytes { + break; + } + if std::fs::remove_file(&path).is_ok() { + freed += len; + } + } + Ok(freed) + } +} + +// ------------------------------------------------------------- verify mode + +#[derive(Clone, Debug, PartialEq)] +pub struct VerifyMismatch { + pub field: &'static str, + pub cached: String, + pub fresh: String, +} + +#[derive(Debug, PartialEq)] +pub enum CacheStatus { + Hit, + Miss, + /// The pre-gate skipped the file; nothing to cache. + Gated, + /// Verify mode found a stale/corrupt hit; the fresh result was returned + /// and the entry repaired. + Poisoned(VerifyMismatch), +} + +fn snippet(text: &str) -> String { + let mut s: String = text.chars().take(160).collect(); + if s.len() < text.len() { + s.push('…'); + } + s +} + +/// Byte-exact comparison via the lossless wire encoding of each field. +pub fn compare_results( + cached: &SourceCompileResult, + fresh: &SourceCompileResult, +) -> Result<(), VerifyMismatch> { + let mismatch = |field, cached: &str, fresh: &str| VerifyMismatch { + field, + cached: snippet(cached), + fresh: snippet(fresh), + }; + if cached.code != fresh.code { + return Err(mismatch("code", &cached.code, &fresh.code)); + } + if cached.map != fresh.map { + return Err(mismatch( + "map", + &format!("{:?}", cached.map), + &format!("{:?}", fresh.map), + )); + } + if cached.modified != fresh.modified { + return Err(mismatch( + "modified", + &cached.modified.to_string(), + &fresh.modified.to_string(), + )); + } + if cached.rules != fresh.rules { + return Err(mismatch( + "rules", + &format!("{:?}", cached.rules), + &format!("{:?}", fresh.rules), + )); + } + let encode = |result: &SourceCompileResult| { + serde_json::to_string( + &result + .create_objects + .iter() + .map(|(name, obj)| (name.clone(), encode_object(obj))) + .collect::>(), + ) + .unwrap_or_default() + }; + let (cached_objs, fresh_objs) = (encode(cached), encode(fresh)); + if cached_objs != fresh_objs { + return Err(mismatch("create_objects", &cached_objs, &fresh_objs)); + } + Ok(()) +} + +/// Cache-fronted [`crate::api::transform_source`]. With `verify` it compiles +/// even on a hit, byte-compares, and repairs + reports a poisoned entry. +pub fn compile_through_cache( + store: &CacheStore, + ctx: &FileContext<'_>, + options: &ResolvedOptions, + fs: &dyn FsProvider, + verify: bool, +) -> Result<(Option, CacheStatus), StylexError> { + let allocator = oxc_allocator::Allocator::default(); + compile_through_cache_in(&allocator, store, ctx, options, fs, verify) +} + +/// [`compile_through_cache`] compiling misses into the caller's arena (see +/// [`crate::api::transform_source_in`]); at most one compile lands per call. +pub fn compile_through_cache_in( + allocator: &oxc_allocator::Allocator, + store: &CacheStore, + ctx: &FileContext<'_>, + options: &ResolvedOptions, + fs: &dyn FsProvider, + verify: bool, +) -> Result<(Option, CacheStatus), StylexError> { + if !might_contain_stylex(ctx.source_text, options) { + return Ok((None, CacheStatus::Gated)); + } + let key = cache_key( + ctx.source_text, + &filename_key(ctx.cwd, ctx.filename), + options, + ); + // The key hashes every ResolvedOptions field, so an entry under it was + // recorded with this resolver config; replaying under it is sound. + let hit = store.get_validated(&key, ctx.cwd, fs, ResolveConfig::of(options)); + if let Some(hit) = hit { + if !verify { + return Ok((Some(hit), CacheStatus::Hit)); + } + let (fresh, log) = transform_source_with_dep_log_in(allocator, ctx, options, fs)?; + // The gate is pure on (source, options): an entry under this key + // proves the gate passed, so fresh is always Some here. + let Some(fresh) = fresh else { + return Ok((None, CacheStatus::Gated)); + }; + return Ok(match compare_results(&hit, &fresh) { + Ok(()) => (Some(fresh), CacheStatus::Hit), + Err(report) => { + let _ = store.put(&key, &fresh, &log); + (Some(fresh), CacheStatus::Poisoned(report)) + } + }); + } + let (fresh, log) = transform_source_with_dep_log_in(allocator, ctx, options, fs)?; + let Some(fresh) = fresh else { + return Ok((None, CacheStatus::Gated)); + }; + // A failed entry write must never fail the compile. + let _ = store.put(&key, &fresh, &log); + Ok((Some(fresh), CacheStatus::Miss)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::options::CompilerOptions; + + fn options(json: serde_json::Value) -> ResolvedOptions { + CompilerOptions::from_json(&json) + .expect("options parse") + .resolve() + .expect("options resolve") + } + + #[test] + fn key_is_sensitive_to_every_dimension() { + let base_opts = options(serde_json::json!({})); + let base = cache_key("src", "a/input.ts", &base_opts); + assert_eq!(base, cache_key("src", "a/input.ts", &base_opts)); + assert_ne!(base, cache_key("src2", "a/input.ts", &base_opts)); + assert_ne!(base, cache_key("src", "a/other.ts", &base_opts)); + assert_ne!( + base, + cache_key( + "src", + "a/input.ts", + &options(serde_json::json!({"dev": true})) + ) + ); + assert_ne!( + base, + cache_key( + "src", + "a/input.ts", + &options(serde_json::json!({"classNamePrefix": "y"})) + ) + ); + assert_ne!( + base, + cache_key( + "src", + "a/input.ts", + &options(serde_json::json!({"env": {"k": "red"}})) + ) + ); + assert_ne!( + cache_key( + "src", + "a/input.ts", + &options(serde_json::json!({"env": {"k": "red"}})) + ), + cache_key( + "src", + "a/input.ts", + &options(serde_json::json!({"env": {"k": "blue"}})) + ) + ); + assert_ne!( + base, + cache_key_with_fingerprint("src", "a/input.ts", &base_opts, "other-version") + ); + // Length-prefixed fields: shifting bytes across a boundary re-keys. + assert_ne!( + cache_key("ab", "c", &base_opts), + cache_key("a", "bc", &base_opts) + ); + } + + #[test] + fn value_wire_roundtrip_is_lossless() { + let mut inner = JsObjectMap::new(); + inner.insert("width", EvalValue::Str("1px".into())); + inner.set_css_type("".to_string()); + let mut obj = JsObjectMap::new(); + obj.insert("2", EvalValue::Num(-0.0)); + obj.insert("b", EvalValue::Num(f64::NAN)); + obj.insert("0", EvalValue::Num(f64::NEG_INFINITY)); + obj.insert("a", EvalValue::Undefined); + obj.insert( + "arr", + EvalValue::Arr(vec![EvalValue::Null, EvalValue::Obj(inner.into())]), + ); + let value = EvalValue::Obj(obj.into()); + let wire = encode_value(&value); + let rewire = encode_value(&decode_value(wire.clone())); + assert!(wire == rewire, "round-trip drifted"); + if let EvalValue::Obj(map) = decode_value(wire) { + assert_eq!( + map.keys().collect::>(), + vec!["0", "2", "b", "a", "arr"] + ); + } else { + panic!("expected object"); + } + } + + #[test] + fn puts_are_atomic_under_concurrent_writers() { + let dir = std::env::temp_dir().join(format!("stylex-cache-atomic-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let store = CacheStore::new(&dir).expect("store"); + let opts = options(serde_json::json!({})); + let key = cache_key("const a = 1;", "input.ts", &opts); + let result = SourceCompileResult { + code: "X".repeat(4096), + map: None, + rules: Vec::new(), + modified: false, + create_objects: Vec::new(), + }; + let deps = DepLog::default(); + std::thread::scope(|scope| { + for _ in 0..8 { + scope.spawn(|| { + for _ in 0..50 { + store.put(&key, &result, &deps).expect("put"); + if let Some(read) = store.get_validated( + &key, + Path::new("/nonexistent"), + &NoFs, + ResolveConfig::default(), + ) { + assert_eq!(read.code, result.code, "torn read"); + } + } + }); + } + }); + let read = store + .get_validated( + &key, + Path::new("/nonexistent"), + &NoFs, + ResolveConfig::default(), + ) + .expect("entry after writers"); + assert_eq!(read.code, result.code); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn gc_deletes_oldest_beyond_cap() { + let dir = std::env::temp_dir().join(format!("stylex-cache-gc-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let store = CacheStore::with_max_bytes(&dir, 1).expect("store"); + let opts = options(serde_json::json!({})); + let result = SourceCompileResult { + code: "y".repeat(512), + map: None, + rules: Vec::new(), + modified: false, + create_objects: Vec::new(), + }; + for i in 0..4 { + let key = cache_key(&format!("src{i}"), "input.ts", &opts); + store.put(&key, &result, &DepLog::default()).expect("put"); + } + let remaining = std::fs::read_dir(&dir).expect("dir").count(); + assert!(remaining <= 1, "cap not enforced: {remaining} entries left"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Empty-log validation needs an FsProvider; no call should ever land. + struct NoFs; + impl FsProvider for NoFs { + fn nearest_package(&self, _: &Path) -> Option<(String, PathBuf)> { + unreachable!("empty dep log must not touch the fs") + } + fn resolve_import(&self, _: &str, _: &Path, _: ResolveConfig<'_>) -> Option { + unreachable!("empty dep log must not touch the fs") + } + fn exists(&self, _: &Path) -> bool { + unreachable!("empty dep log must not touch the fs") + } + } +} diff --git a/crates/stylex/src/errors.rs b/crates/stylex/src/errors.rs new file mode 100644 index 00000000..b36a6b6e --- /dev/null +++ b/crates/stylex/src/errors.rs @@ -0,0 +1,496 @@ +use thiserror::Error; + +// Upstream-mirrored codes carry the exact user-facing message text of +// @stylexjs/babel-plugin@0.19; the Unsupported*/Unknown/Invalid codes are ours. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum ErrorCode { + IllegalArgumentLength, + NonStaticValue, + NonStyleObject, + IllegalNamespaceValue, + IllegalPropValue, + IllegalPropArrayValue, + NoObjectSpreads, + InvalidMediaQuerySyntax, + CyclicConstReference, + CyclicDefineVarsReference, + UnclosedFunction, + UnclosedString, + BannedShorthand, + UnsupportedOption, + UnsupportedApi, + UnknownOption, + InvalidOptionValue, + DuplicateConditional, + InvalidPseudoOrAtRule, + NonContiguousVars, + ShorthandFallback, + InvalidListStyleValue, + EmptyValue, + NonObjectKeyframe, + UnboundCallValue, + NonExportNamedDeclaration, + CannotGenerateHash, + InvalidWhenSelector, + UpstreamTypeError, + ParseError, + PositionTryInvalidProperty, + ViewTransitionClassInvalidProperty, + NestedKeySeparator, + NestedThemeInvalidVars, + InvalidDefineVarsValue, + ArrayInDefineVars, + MissingDefaultValue, + UnknownDefineVarsReference, + InvalidDefineVarsFunctionValue, + ThemeWithoutVarGroup, + OnlyNamedParameters, + AstBackend, +} + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +#[error("{message}")] +pub struct StylexError { + pub code: ErrorCode, + pub message: String, +} + +impl StylexError { + pub fn new(code: ErrorCode, message: impl Into) -> Self { + Self { + code, + message: message.into(), + } + } + + // parity: babel-plugin src/shared/messages.js illegalArgumentLength + pub fn illegal_argument_length(fn_name: &str, arg_length: usize) -> Self { + let plural = if arg_length == 1 { "" } else { "s" }; + Self::new( + ErrorCode::IllegalArgumentLength, + format!("{fn_name}() should have {arg_length} argument{plural}."), + ) + } + + // parity: babel-plugin src/shared/messages.js nonStaticValue + pub fn non_static_value(fn_name: &str) -> Self { + Self::new( + ErrorCode::NonStaticValue, + format!("Only static values are allowed inside of a {fn_name}() call."), + ) + } + + // parity: babel-plugin src/shared/messages.js nonStyleObject + pub fn non_style_object(fn_name: &str) -> Self { + Self::new( + ErrorCode::NonStyleObject, + format!("{fn_name}() can only accept an object."), + ) + } + + pub fn illegal_namespace_value() -> Self { + Self::new( + ErrorCode::IllegalNamespaceValue, + "A StyleX namespace must be an object.", + ) + } + + pub fn illegal_prop_value() -> Self { + Self::new( + ErrorCode::IllegalPropValue, + "A style value can only contain an array, string or number.", + ) + } + + pub fn illegal_prop_array_value() -> Self { + Self::new( + ErrorCode::IllegalPropArrayValue, + "A style array value can only contain strings or numbers.", + ) + } + + pub fn no_object_spreads() -> Self { + Self::new( + ErrorCode::NoObjectSpreads, + "Object spreads are not allowed in create() calls.", + ) + } + + pub fn invalid_media_query_syntax() -> Self { + Self::new( + ErrorCode::InvalidMediaQuerySyntax, + "Invalid media query syntax.", + ) + } + + // parity: babel-plugin src/index.js:572 (const resolution) + pub fn cyclic_const_reference(const_ref: &str) -> Self { + Self::new( + ErrorCode::CyclicConstReference, + format!("circular reference detected for constant {const_ref}"), + ) + } + + // parity: babel-plugin src/shared/messages.js cyclicDefineVarsReference + pub fn cyclic_define_vars_reference(cycle: &str) -> Self { + Self::new( + ErrorCode::CyclicDefineVarsReference, + format!("Cyclic same-group references in defineVars() are not allowed: {cycle}."), + ) + } + + pub fn unclosed_function() -> Self { + Self::new( + ErrorCode::UnclosedFunction, + "Rule contains an unclosed function", + ) + } + + pub fn unclosed_string() -> Self { + Self::new( + ErrorCode::UnclosedString, + "Rule contains an unclosed string", + ) + } + + /// `None` when `property` is not a banned shorthand under property-specificity. + pub fn banned_shorthand(property: &str) -> Option { + banned_shorthand_message(property).map(|m| Self::new(ErrorCode::BannedShorthand, m)) + } + + pub fn unsupported_option(description: &str) -> Self { + Self::new( + ErrorCode::UnsupportedOption, + format!( + "Unsupported option `{description}`: a deliberate v1 gap of the Rust StyleX compiler. See crates/stylex/docs." + ), + ) + } + + pub fn unsupported_api(api: &str) -> Self { + Self::new( + ErrorCode::UnsupportedApi, + format!( + "Unsupported API `{api}`: a deliberate v1 gap of the Rust StyleX compiler. See crates/stylex/docs." + ), + ) + } + + /// Loud-over-lossy doctrine: upstream emits the corrupt lone surrogate; + /// this compiler refuses (documented rust-rejects-more divergence). + pub fn lone_surrogate(context: &str) -> Self { + Self::new( + ErrorCode::UnsupportedApi, + format!( + "Unsupported API `{context} producing a lone UTF-16 surrogate`: a deliberate v1 gap of the Rust StyleX compiler. See crates/stylex/docs." + ), + ) + } + + pub fn only_named_parameters() -> Self { + Self::new( + ErrorCode::OnlyNamedParameters, + "Only named parameters are allowed in Dynamic Style functions. Destructuring, spreading or default values are not allowed.", + ) + } + + pub fn unknown_option(key: &str) -> Self { + Self::new( + ErrorCode::UnknownOption, + format!( + "Unknown option `{key}`: not a recognized @stylexjs/babel-plugin option. See crates/stylex/docs." + ), + ) + } + + pub fn duplicate_conditional() -> Self { + Self::new( + ErrorCode::DuplicateConditional, + "The same pseudo selector or at-rule cannot be used more than once.", + ) + } + + pub fn invalid_pseudo_or_at_rule() -> Self { + Self::new( + ErrorCode::InvalidPseudoOrAtRule, + "Invalid pseudo or at-rule.", + ) + } + + pub fn non_contiguous_vars() -> Self { + Self::new( + ErrorCode::NonContiguousVars, + "All variables passed to firstThatWorks() must be contiguous.", + ) + } + + // parity: babel-plugin src/shared/preprocess-rules/index.js:50 + pub fn shorthand_fallback() -> Self { + Self::new( + ErrorCode::ShorthandFallback, + "Cannot use fallbacks for shorthands. Use the expansion instead.", + ) + } + + // parity: legacy-expand-shorthands.js listStyle; `text` is already + // JSON-quoted by the caller (the two spellings differ by a quote pair). + pub fn invalid_list_style(text: &str) -> Self { + Self::new( + ErrorCode::InvalidListStyleValue, + format!("invalid \"listStyle\" value of {text}"), + ) + } + + // Upstream crashes with a TypeError here; structured stand-in (W2 divergence). + pub fn empty_value() -> Self { + Self::new(ErrorCode::EmptyValue, "Cannot normalize an empty value") + } + + pub fn non_object_keyframe() -> Self { + Self::new( + ErrorCode::NonObjectKeyframe, + "Every frame within a keyframes() call must be an object.", + ) + } + + // parity: babel-plugin src/shared/messages.js unboundCallValue + pub fn unbound_call_value(fn_name: &str) -> Self { + Self::new( + ErrorCode::UnboundCallValue, + format!("{fn_name}() calls must be bound to a bare variable."), + ) + } + + // parity: babel-plugin src/shared/messages.js nonExportNamedDeclaration + pub fn non_export_named_declaration(fn_name: &str) -> Self { + Self::new( + ErrorCode::NonExportNamedDeclaration, + format!("The return value of {fn_name}() must be bound to a named export."), + ) + } + + // parity: babel-plugin src/shared/messages.js cannotGenerateHash + pub fn cannot_generate_hash(fn_name: &str) -> Self { + Self::new( + ErrorCode::CannotGenerateHash, + format!( + "Unable to generate hash for {fn_name}(). Check that the file has a valid extension and that unstable_moduleResolution is configured." + ), + ) + } + + /// The exact `message` comes from when.js validatePseudoSelector. + pub fn invalid_when_selector(message: &str) -> Self { + Self::new(ErrorCode::InvalidWhenSelector, message) + } + + pub fn parse_error(detail: &str) -> Self { + Self::new( + ErrorCode::ParseError, + format!("Failed to parse the source file: {detail}"), + ) + } + + // parity: babel-plugin src/shared/messages.js POSITION_TRY_INVALID_PROPERTY + pub fn position_try_invalid_property() -> Self { + Self::new( + ErrorCode::PositionTryInvalidProperty, + "Invalid property in `positionTry()` call. It may only contain, positionAnchor, positionArea, inset properties (top, left, insetInline etc.), margin properties, size properties (height, inlineSize, etc.), and self-alignment properties (alignSelf, justifySelf, placeSelf)", + ) + } + + // parity: babel-plugin src/shared/messages.js VIEW_TRANSITION_CLASS_INVALID_PROPERTY + pub fn view_transition_class_invalid_property() -> Self { + Self::new( + ErrorCode::ViewTransitionClassInvalidProperty, + "Invalid property in `viewTransitionClass()` call. It may only contain group, imagePair, old, and new properties", + ) + } + + // parity: babel-plugin src/shared/stylex-nested-utils.js flattenImpl key check + pub fn nested_key_contains_separator(key: &str) -> Self { + Self::new( + ErrorCode::NestedKeySeparator, + format!( + "Key \"{key}\" must not contain the \".\" character. Use nested objects instead of dots in key names. See: https://www.designtokens.org/tr/drafts/format/#character-restrictions" + ), + ) + } + + // parity: visitors/stylex-create-theme-nested.js __varGroupHash__ check + pub fn nested_theme_invalid_vars() -> Self { + Self::new( + ErrorCode::NestedThemeInvalidVars, + "Can only override variables theme created with unstable_defineVarsNested().", + ) + } + + // parity: shared/stylex-vars-utils.js + visitors/stylex-define-vars.js + pub fn invalid_define_vars_value() -> Self { + Self::new( + ErrorCode::InvalidDefineVarsValue, + "Invalid value in defineVars", + ) + } + + // parity: same plain-Error text in defineVars and createTheme collection. + pub fn array_in_define_vars() -> Self { + Self::new( + ErrorCode::ArrayInDefineVars, + "Array is not supported in defineVars", + ) + } + + /// `key` is present everywhere except getDefaultValue's keyless variant. + pub fn missing_default_value(key: Option<&str>) -> Self { + let message = match key { + Some(key) => format!("Default value is not defined for {key} variable."), + None => "Default value is not defined for variable.".to_string(), + }; + Self::new(ErrorCode::MissingDefaultValue, message) + } + + // parity: babel-plugin src/shared/messages.js unknownDefineVarsReference + pub fn unknown_define_vars_reference(key: &str, dependency: &str) -> Self { + Self::new( + ErrorCode::UnknownDefineVarsReference, + format!( + "Unknown same-group reference \"{dependency}\" found while resolving \"{key}\" in defineVars()." + ), + ) + } + + // parity: babel-plugin src/shared/messages.js invalidDefineVarsFunctionValue + pub fn invalid_define_vars_function_value() -> Self { + Self::new( + ErrorCode::InvalidDefineVarsFunctionValue, + "Function values in defineVars() must be zero-argument and return a static value supported by defineVars().", + ) + } + + // parity: visitors/stylex-create-theme.js + shared/stylex-create-theme.js + pub fn theme_without_var_group() -> Self { + Self::new( + ErrorCode::ThemeWithoutVarGroup, + "Can only override variables theme created with defineVars().", + ) + } + + // Upstream crashes with "Cannot convert undefined or null to object"; + // structured stand-in, same compile-fails outcome (W3 divergence). + pub fn upstream_type_crash(context: &str) -> Self { + Self::new( + ErrorCode::UpstreamTypeError, + format!( + "Upstream crashes with a TypeError on {context}; the Rust compiler rejects it instead." + ), + ) + } +} + +// parity: babel-plugin src/shared/preprocess-rules/property-specificity.js +// (throwing shorthands plus the aliases that point at them). +pub fn banned_shorthand_message(property: &str) -> Option<&'static str> { + Some(match property { + "all" => "all is not supported", + "animation" => "animation is not supported", + "background" => { + "background is not supported. Use background-color, border-image etc. instead." + } + "border" => { + "border is not supported. Use border-width, border-style and border-color instead." + } + "borderInline" | "borderHorizontal" => { + "borderInline is not supported. Use borderInlineWidth, borderInlineStyle and borderInlineColor instead." + } + "borderBlock" | "borderVertical" => { + "borderBlock is not supported. Use borderBlockWidth, borderBlockStyle and borderBlockColor instead." + } + "borderTop" | "borderBlockStart" => { + "borderTop is not supported. Use borderTopWidth, borderTopStyle and borderTopColor instead." + } + "borderInlineEnd" | "borderEnd" => { + "borderInlineEnd is not supported. Use borderInlineEndWidth, borderInlineEndStyle and borderInlineEndColor instead." + } + "borderRight" => { + "borderRight is not supported. Use borderRightWidth, borderRightStyle and borderRightColor instead." + } + "borderBottom" | "borderBlockEnd" => { + "borderBottom is not supported. Use borderBottomWidth, borderBottomStyle and borderBottomColor instead." + } + "borderInlineStart" | "borderStart" => { + "borderInlineStart is not supported. Use borderInlineStartWidth, borderInlineStartStyle and borderInlineStartColor instead." + } + "borderLeft" => { + "`borderLeft` is not supported. You could use `borderLeftWidth`, `borderLeftStyle` and `borderLeftColor`, but it is preferable to use `borderInlineStartWidth`, `borderInlineStartStyle` and `borderInlineStartColor`." + } + _ => return None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn message_text_matches_upstream() { + assert_eq!( + StylexError::illegal_argument_length("create", 1).message, + "create() should have 1 argument." + ); + assert_eq!( + StylexError::illegal_argument_length("keyframes", 2).message, + "keyframes() should have 2 arguments." + ); + assert_eq!( + StylexError::non_static_value("create").message, + "Only static values are allowed inside of a create() call." + ); + assert_eq!( + StylexError::non_style_object("create").message, + "create() can only accept an object." + ); + assert_eq!( + StylexError::cyclic_const_reference("colors.bg").message, + "circular reference detected for constant colors.bg" + ); + assert_eq!( + StylexError::cyclic_define_vars_reference("a -> b -> a").message, + "Cyclic same-group references in defineVars() are not allowed: a -> b -> a." + ); + } + + #[test] + fn banned_shorthand_table() { + assert_eq!( + banned_shorthand_message("border").unwrap(), + "border is not supported. Use border-width, border-style and border-color instead." + ); + // Aliases throw with the message of the shorthand they point at. + assert_eq!( + banned_shorthand_message("borderHorizontal"), + banned_shorthand_message("borderInline") + ); + assert_eq!( + banned_shorthand_message("borderBlockStart"), + banned_shorthand_message("borderTop") + ); + assert!( + banned_shorthand_message("borderLeft") + .unwrap() + .starts_with("`borderLeft`") + ); + assert_eq!(banned_shorthand_message("marginTop"), None); + let err = StylexError::banned_shorthand("all").unwrap(); + assert_eq!(err.code, ErrorCode::BannedShorthand); + assert_eq!(err.message, "all is not supported"); + } + + #[test] + fn display_is_the_message() { + let e = StylexError::unknown_option("fooBar"); + assert_eq!(e.to_string(), e.message); + assert!(e.message.contains("fooBar")); + } +} diff --git a/crates/stylex/src/eval/cross_file.rs b/crates/stylex/src/eval/cross_file.rs new file mode 100644 index 00000000..92f8f161 --- /dev/null +++ b/crates/stylex/src/eval/cross_file.rs @@ -0,0 +1,126 @@ +//! `.stylex` theme-file import proxies: hash-only, no file contents are read. +// parity: evaluate-path.js createVarGroupProxy/resolveVarGroupKey/:595-654 + +use crate::hash::hash; +use crate::module_resolution::gen_file_based_identifier; +use crate::options::ResolvedOptions; + +// parity: evaluation-errors.js — exact user-facing texts (first line is what +// pins compare after babel's code-frame formatting). +pub const IMPORT_PATH_RESOLUTION_ERROR: &str = "Could not resolve the path to the imported file.\nPlease ensure that the theme file has a .stylex.js or .stylex.ts extension and follows the\nrules for defining variables:\n\nhttps://stylexjs.com/docs/learn/theming/defining-variables/#rules-when-defining-variables\n"; + +pub const IMPORT_FILE_EVAL_ERROR: &str = "There was an error when attempting to evaluate the imported file.\nPlease ensure that the imported file is self-contained and does not rely on dynamic behavior.\n"; + +pub const NON_CONSTANT: &str = "Referenced value is not a constant.\n\n"; + +pub const USED_BEFORE_DECLARATION: &str = "Referenced value is used before declaration.\n\n"; + +pub const UNINITIALIZED_CONST: &str = "Referenced constant is not initialized.\n\n"; + +pub const UNDEFINED_CONST: &str = "Referenced constant is not defined."; + +pub const PATH_WITHOUT_NODE: &str = + "Unexpected error:\nCould not resolve the code being evaluated.\n"; + +pub const UNEXPECTED_MEMBER_LOOKUP: &str = + "Unexpected error:\nCould not determine the property being accessed.\n"; + +pub const OBJECT_METHOD: &str = "Unsupported object method.\n\n"; + +pub fn unsupported_expression(node_type: &str) -> String { + format!("Unsupported expression: {node_type}\n\n") +} + +pub fn unsupported_operator(op: &str) -> String { + format!("Unsupported operator: {op}\n\n") +} + +/// Stand-in for the upstream JS Proxy over a theme-file export: every member +/// access resolves to a `var(--…)` string derived from hashes alone. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VarGroupProxy { + /// Canonical theme-file name (`pkg:relPath` form). + pub file_name: String, + pub export_name: String, + pub var_group_hash: String, + class_name_prefix: String, + debug_class_names: bool, +} + +impl VarGroupProxy { + pub fn new(file_name: String, export_name: String, options: &ResolvedOptions) -> Self { + let var_group_hash = format!( + "{}{}", + options.class_name_prefix, + hash(&gen_file_based_identifier(&file_name, &export_name, None)) + ); + VarGroupProxy { + file_name, + export_name, + var_group_hash, + class_name_prefix: options.class_name_prefix.clone(), + debug_class_names: options.debug && options.enable_debug_class_names, + } + } + + // parity: evaluate-path.js resolveVarGroupKey. + pub fn resolve_key(&self, key: &str) -> String { + if key.starts_with("--") { + return format!("var({key})"); + } + let hashed = hash(&gen_file_based_identifier( + &self.file_name, + &self.export_name, + Some(key), + )); + let var_name = if self.debug_class_names { + let mut safe: String = key + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect(); + if key.starts_with(|c: char| c.is_ascii_digit()) { + safe.insert(0, '_'); + } + format!("{safe}-{}{hashed}", self.class_name_prefix) + } else { + format!("{}{hashed}", self.class_name_prefix) + }; + format!("var(--{var_name})") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn proxy(options: &ResolvedOptions) -> VarGroupProxy { + VarGroupProxy::new( + "probe-pkg:src/tokens.stylex.ts".to_string(), + "colors".to_string(), + options, + ) + } + + #[test] + fn oracle_pinned_hashes() { + // Pinned live against @stylexjs/babel-plugin 0.19.0 (probe 2026-08-27). + let options = ResolvedOptions::default(); + let p = proxy(&options); + assert_eq!(p.var_group_hash, "x73pkp7"); + assert_eq!(p.resolve_key("accent"), "var(--x8fssgy)"); + assert_eq!(p.resolve_key("--raw"), "var(--raw)"); + } + + #[test] + fn debug_class_names_var_safe_keys() { + let options = crate::options::CompilerOptions::from_json( + &serde_json::json!({ "debug": true, "enableDebugClassNames": true }), + ) + .unwrap() + .resolve() + .unwrap(); + let p = proxy(&options); + assert_eq!(p.resolve_key("2fast"), "var(--_2fast-x1xncp22)"); + assert_eq!(p.resolve_key("weird key!"), "var(--weird_key_-x1ixwx2f)"); + } +} diff --git a/crates/stylex/src/eval/functions.rs b/crates/stylex/src/eval/functions.rs new file mode 100644 index 00000000..8e3cb0ce --- /dev/null +++ b/crates/stylex/src/eval/functions.rs @@ -0,0 +1,595 @@ +//! Injected-callable seam between the evaluator and the emit-API modules. +// parity: stylex-create.js:140-206 FunctionConfig wiring + +use std::sync::Arc; + +use crate::errors::StylexError; +use crate::eval::value::EvalValue; +use crate::state::CompileState; + +/// The compile-time callables `stylex.create` evaluation may dispatch to. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum StylexCallable { + FirstThatWorks, + Keyframes, + PositionTry, + DefaultMarker, + WhenAncestor, + WhenDescendant, + WhenSiblingBefore, + WhenSiblingAfter, + WhenAnySibling, + /// `types.(…)` for defineVars contexts (integrator-wired). + Types(String), + /// `unstable_conditional(x)` — the upstream identity fn for typing. + Conditional, +} + +pub const WHEN_MEMBERS: [(&str, StylexCallable); 5] = [ + ("ancestor", StylexCallable::WhenAncestor), + ("descendant", StylexCallable::WhenDescendant), + ("siblingBefore", StylexCallable::WhenSiblingBefore), + ("siblingAfter", StylexCallable::WhenSiblingAfter), + ("anySibling", StylexCallable::WhenAnySibling), +]; + +/// The seam the emit-API slice plugs its real semantics into; `state` lets +/// keyframes/positionTry record their injectable rules. +pub trait EvalCallables { + fn call( + &self, + callee: &StylexCallable, + args: &[EvalValue], + state: &mut CompileState<'_>, + ) -> Result; +} + +/// Placeholder while the emit-API modules are mid-flight: every dispatch is a +/// structured unsupported_api error naming the callable. +pub struct StubCallables; + +impl EvalCallables for StubCallables { + fn call( + &self, + callee: &StylexCallable, + _args: &[EvalValue], + _state: &mut CompileState<'_>, + ) -> Result { + let name = match callee { + StylexCallable::FirstThatWorks => "firstThatWorks", + StylexCallable::Keyframes => "keyframes", + StylexCallable::PositionTry => "positionTry", + StylexCallable::DefaultMarker => "defaultMarker", + StylexCallable::WhenAncestor => "when.ancestor", + StylexCallable::WhenDescendant => "when.descendant", + StylexCallable::WhenSiblingBefore => "when.siblingBefore", + StylexCallable::WhenSiblingAfter => "when.siblingAfter", + StylexCallable::WhenAnySibling => "when.anySibling", + StylexCallable::Types(_) => "types.*", + StylexCallable::Conditional => "unstable_conditional", + }; + Err(StylexError::unsupported_api(name)) + } +} + +// ---- theming (defineVars / createTheme) evaluator support ------------------ + +use oxc_ast::ast::{ + Argument, ArrayExpressionElement, CallExpression, Expression, ObjectPropertyKind, PropertyKind, + UnaryOperator, +}; +use oxc_span::GetSpan; + +use crate::eval::cross_file::VarGroupProxy; +use crate::eval::{ + Callable, EvalOutcome, Evaluator, FunctionRegistry, JsObj, JsValue, RegistryEntry, + collect_member_chain, from_eval_value, is_invalid_method, is_valid_callee, js_to_string, + truthy, unwrap_parens, +}; +use crate::imports::{ImportTable, StylexNamedImport}; +use crate::shared::types::TYPES_MEMBERS; + +fn types_object() -> JsValue { + let mut obj = JsObj::default(); + for name in TYPES_MEMBERS { + obj.insert( + name.to_string(), + JsValue::Callable(Callable::Stylex(StylexCallable::Types(name.to_string()))), + ); + } + JsValue::object(obj) +} + +/// FunctionConfig of visitors/stylex-define-vars.js and the second argument of +/// visitors/stylex-create-theme.js: keyframes, positionTry, types, env. +pub fn vars_registry(imports: &ImportTable, env: &EvalValue) -> FunctionRegistry { + let mut registry = FunctionRegistry::default(); + for (local, binding) in &imports.named { + let entry = match binding { + StylexNamedImport::Keyframes => RegistryEntry::Callable(StylexCallable::Keyframes), + StylexNamedImport::PositionTry => RegistryEntry::Callable(StylexCallable::PositionTry), + StylexNamedImport::Types => RegistryEntry::Value(types_object()), + StylexNamedImport::Env => RegistryEntry::Value(from_eval_value(env)), + StylexNamedImport::Conditional => RegistryEntry::Callable(StylexCallable::Conditional), + _ => continue, + }; + registry.identifiers.insert(local.clone(), entry); + } + for namespace in &imports.stylex_namespaces { + let mut obj = JsObj::default(); + obj.insert("types".to_string(), types_object()); + obj.insert("env".to_string(), from_eval_value(env)); + registry.identifiers.insert( + namespace.clone(), + RegistryEntry::Value(JsValue::object(obj)), + ); + for (name, callable) in [ + ("keyframes", StylexCallable::Keyframes), + ("positionTry", StylexCallable::PositionTry), + ("unstable_conditional", StylexCallable::Conditional), + ] { + registry + .member_callables + .insert((namespace.clone(), name.to_string()), callable); + } + } + registry +} + +/// Mirrors `identifiers[varId.name] = selfReferenceProxy` (set after the first +/// evaluation, visible to function leaves invoked during normalization). +pub fn register_self_reference( + ev: &mut Evaluator<'_, '_>, + export_name: &str, + proxy: VarGroupProxy, +) { + ev.registry.identifiers.insert( + export_name.to_string(), + RegistryEntry::Value(JsValue::proxy(proxy)), + ); +} + +/// Body and param count of an evaluated expression-body arrow (`Callable::Arrow`). +pub fn arrow_info<'a>(ev: &Evaluator<'a, '_>, key: u32) -> Option<(&'a Expression<'a>, usize)> { + ev.arrow_bodies + .get(&key) + .map(|(body, params)| (*body, params.len())) +} + +/// The self-reference proxy's `onAccess` stand-in: walks a function body in +/// evaluation order (lazy conditionals, last-only sequences, eager logicals). +pub struct DepTracker<'p> { + self_proxy: &'p VarGroupProxy, + pub deps: Vec, + depth: u32, +} + +impl<'p> DepTracker<'p> { + pub fn new(self_proxy: &'p VarGroupProxy) -> Self { + DepTracker { + self_proxy, + deps: Vec::new(), + depth: 0, + } + } + + fn record(&mut self, key: String) { + if !self.deps.iter().any(|d| d == &key) { + self.deps.push(key); + } + } + + // The proxy's __IS_PROXY/toString/__varGroupHash__ traps bypass onAccess. + fn record_access(&mut self, key: &str) { + if !matches!(key, "__IS_PROXY" | "__varGroupHash__" | "toString") { + self.record(key.to_string()); + } + } + + fn is_self(&self, value: &JsValue) -> bool { + matches!(value, JsValue::Proxy(p) if p.as_ref() == self.self_proxy) + } + + fn eval_value<'a>( + &mut self, + ev: &mut Evaluator<'a, '_>, + expr: &'a Expression<'a>, + ) -> Option { + match ev.eval(expr) { + Ok(EvalOutcome::Value(v)) => Some(v), + _ => None, + } + } + + /// `None` halts the walk: the real evaluation deopts or errors right there. + pub fn walk<'a>(&mut self, ev: &mut Evaluator<'a, '_>, expr: &'a Expression<'a>) -> Option<()> { + self.depth += 1; + let result = if self.depth > 128 { + None + } else { + self.walk_inner(ev, expr) + }; + self.depth -= 1; + result + } + + fn walk_inner<'a>( + &mut self, + ev: &mut Evaluator<'a, '_>, + expr: &'a Expression<'a>, + ) -> Option<()> { + match expr { + Expression::ParenthesizedExpression(e) => self.walk(ev, &e.expression), + Expression::TSAsExpression(e) => self.walk(ev, &e.expression), + Expression::TSSatisfiesExpression(e) => self.walk(ev, &e.expression), + Expression::ArrowFunctionExpression(_) => Some(()), + Expression::SequenceExpression(seq) => match seq.expressions.last() { + Some(last) => self.walk(ev, last), + None => None, + }, + Expression::StringLiteral(_) + | Expression::NumericLiteral(_) + | Expression::BooleanLiteral(_) + | Expression::NullLiteral(_) => Some(()), + Expression::TemplateLiteral(tpl) => { + for expr in &tpl.expressions { + self.walk(ev, expr)?; + } + Some(()) + } + Expression::ConditionalExpression(cond) => { + self.walk(ev, &cond.test)?; + let test = self.eval_value(ev, &cond.test)?; + if truthy(&test) { + self.walk(ev, &cond.consequent) + } else { + self.walk(ev, &cond.alternate) + } + } + Expression::StaticMemberExpression(_) | Expression::ComputedMemberExpression(_) => { + self.walk_member(ev, expr) + } + Expression::Identifier(id) => { + let name = id.name.as_str(); + if ev.registry.identifiers.contains_key(name) { + return Some(()); + } + let Some(symbol) = ev.state.symbol_of(id) else { + return match name { + "undefined" | "Infinity" | "NaN" => Some(()), + _ => None, + }; + }; + let info = ev.state.binding_info(symbol); + if matches!( + info.decl, + crate::state::BindingDecl::NamedImport + | crate::state::BindingDecl::DefaultImport + | crate::state::BindingDecl::NamespaceImport + ) { + return Some(()); + } + if ev.state.is_non_constant(symbol) + || ev.state.is_mutated(symbol) + || id.span.start < info.span.end + { + return None; + } + if ev.state.binding_override(symbol).is_some() { + return Some(()); + } + match info.decl { + crate::state::BindingDecl::Declarator(declarator) + if declarator.id.get_binding_identifier().is_some() => + { + match &declarator.init { + Some(init) => self.walk(ev, init), + None => None, + } + } + _ => None, + } + } + Expression::UnaryExpression(unary) => match unary.operator { + UnaryOperator::Void => Some(()), + UnaryOperator::Delete => None, + UnaryOperator::Typeof + if matches!( + unary.argument, + Expression::FunctionExpression(_) + | Expression::ArrowFunctionExpression(_) + | Expression::ClassExpression(_) + ) => + { + Some(()) + } + _ => self.walk(ev, &unary.argument), + }, + Expression::ArrayExpression(array) => { + for element in &array.elements { + match element { + ArrayExpressionElement::Elision(_) + | ArrayExpressionElement::SpreadElement(_) => return None, + _ => self.walk(ev, element.to_expression())?, + } + } + Some(()) + } + Expression::ObjectExpression(object) => { + for property in &object.properties { + match property { + ObjectPropertyKind::SpreadProperty(spread) => { + self.walk(ev, &spread.argument)?; + } + ObjectPropertyKind::ObjectProperty(prop) => { + if prop.method || prop.kind != PropertyKind::Init { + return None; + } + if prop.computed { + let key_expr = prop.key.as_expression()?; + self.walk(ev, key_expr)?; + let key_value = self.eval_value(ev, key_expr)?; + js_to_string(&key_value)?; + } + self.walk(ev, &prop.value)?; + } + } + } + Some(()) + } + Expression::LogicalExpression(logical) => { + // parity: both operands evaluate eagerly upstream. + self.walk(ev, &logical.left)?; + self.walk(ev, &logical.right) + } + Expression::BinaryExpression(binary) => { + self.walk(ev, &binary.left)?; + self.walk(ev, &binary.right) + } + Expression::CallExpression(call) => self.walk_call(ev, call), + _ => None, + } + } + + fn walk_member<'a>( + &mut self, + ev: &mut Evaluator<'a, '_>, + expr: &'a Expression<'a>, + ) -> Option<()> { + if let Some((base, parts)) = collect_member_chain(expr) + && parts.len() >= 2 + { + self.walk(ev, base)?; + let base_value = self.eval_value(ev, base)?; + if self.is_self(&base_value) { + self.record(parts.join(".")); + return Some(()); + } + let mut current = base_value; + for (i, key) in parts.iter().enumerate() { + current = match ev.member_lookup(¤t, key, expr.span()) { + Ok(EvalOutcome::Value(v)) => v, + _ => return None, + }; + if self.is_self(¤t) && i + 1 < parts.len() { + self.record(parts[i + 1..].join(".")); + return Some(()); + } + } + return Some(()); + } + let (object_expr, key) = match expr { + Expression::StaticMemberExpression(member) => { + (&member.object, member.property.name.to_string()) + } + Expression::ComputedMemberExpression(member) => { + self.walk(ev, &member.expression)?; + let key_value = self.eval_value(ev, &member.expression)?; + (&member.object, js_to_string(&key_value)?) + } + _ => return None, + }; + self.walk(ev, object_expr)?; + let object = self.eval_value(ev, object_expr)?; + if self.is_self(&object) { + self.record_access(&key); + } + Some(()) + } + + fn walk_args_only<'a>( + &mut self, + ev: &mut Evaluator<'a, '_>, + call: &'a CallExpression<'a>, + ) -> Option<()> { + for argument in &call.arguments { + match argument { + Argument::SpreadElement(_) => return None, + _ => self.walk(ev, argument.to_expression())?, + } + } + Some(()) + } + + fn walk_arrow_call<'a>( + &mut self, + ev: &mut Evaluator<'a, '_>, + key: u32, + call: &'a CallExpression<'a>, + ) -> Option<()> { + let (body, params) = ev.arrow_bodies.get(&key).map(|(b, p)| (*b, p.clone()))?; + let mut args = Vec::with_capacity(call.arguments.len()); + for argument in &call.arguments { + match argument { + Argument::SpreadElement(_) => return None, + _ => { + let expr = argument.to_expression(); + self.walk(ev, expr)?; + args.push(self.eval_value(ev, expr)?); + } + } + } + let mut saved: Vec<(String, Option)> = Vec::with_capacity(params.len()); + for (i, name) in params.iter().enumerate() { + let value = args.get(i).cloned().unwrap_or(JsValue::Undefined); + let previous = ev + .registry + .identifiers + .insert(name.clone(), RegistryEntry::Value(value)); + saved.push((name.clone(), previous)); + } + let result = self.walk(ev, body); + for (name, previous) in saved.into_iter().rev() { + match previous { + Some(entry) => { + ev.registry.identifiers.insert(name, entry); + } + None => { + ev.registry.identifiers.remove(&name); + } + } + } + result + } + + fn walk_call<'a>( + &mut self, + ev: &mut Evaluator<'a, '_>, + call: &'a CallExpression<'a>, + ) -> Option<()> { + if call.optional { + return None; + } + let callee = unwrap_parens(&call.callee); + if let Expression::Identifier(id) = callee { + let name = id.name.as_str(); + if let Some(entry) = ev.registry.identifiers.get(name) { + return match entry { + RegistryEntry::Callable(_) => self.walk_args_only(ev, call), + RegistryEntry::Value(_) => None, + }; + } + if ev.state.symbol_of(id).is_none() && is_valid_callee(name) { + return match name { + "Math" => None, + _ => self.walk_args_only(ev, call), + }; + } + self.walk(ev, callee)?; + return match self.eval_value(ev, callee)? { + JsValue::Callable(Callable::Stylex(_)) => self.walk_args_only(ev, call), + JsValue::Callable(Callable::Arrow(key)) => self.walk_arrow_call(ev, key, call), + _ => None, + }; + } + if let Some(member) = callee.as_member_expression() { + let property = + member + .static_property_name() + .map(str::to_string) + .or_else(|| match member { + oxc_ast::ast::MemberExpression::ComputedMemberExpression(m) => { + match &m.expression { + Expression::StringLiteral(lit) => Some(lit.value.to_string()), + _ => None, + } + } + _ => None, + }); + let property = property?; + if let Expression::Identifier(object) = member.object() { + let object_name = object.name.as_str(); + let is_member_callable = ev + .registry + .member_callables + .contains_key(&(object_name.to_string(), property.clone())); + if !is_member_callable + && is_valid_callee(object_name) + && !is_invalid_method(&property) + { + return match crate::eval::methods::lookup_global_static(object_name, &property) + { + crate::eval::methods::StaticMember::Fn(_) => self.walk_args_only(ev, call), + crate::eval::methods::StaticMember::Unknown => None, + _ => None, + }; + } + if is_member_callable { + return self.walk_args_only(ev, call); + } + } + self.walk(ev, member.object())?; + let object_value = self.eval_value(ev, member.object())?; + if matches!(&object_value, JsValue::Proxy(_)) && property == "toString" { + return self.walk_args_only(ev, call); + } + if let JsValue::Obj(obj) = &object_value + && let Some(JsValue::Callable(callable)) = obj.get(&property) + { + match callable.clone() { + Callable::Stylex(_) => return self.walk_args_only(ev, call), + Callable::Arrow(key) => return self.walk_arrow_call(ev, key, call), + Callable::Opaque => {} + } + } + return None; + } + None + } +} + +/// The vars-context keyframes closure skips visitor validation upstream; +/// Object.entries shapes strings/arrays/primitives into frame objects instead. +pub fn coerce_vars_keyframes_frames(frames: &EvalValue) -> Result { + fn entries_object(value: &EvalValue) -> Option { + let mut out = crate::eval::value::JsObjectMap::new(); + match value { + EvalValue::Str(s) => { + for (i, unit) in s.encode_utf16().enumerate() { + out.insert( + i.to_string(), + EvalValue::Str(String::from_utf16_lossy(&[unit])), + ); + } + } + EvalValue::Arr(items) => { + for (i, v) in items.iter().enumerate() { + out.insert(i.to_string(), v.clone()); + } + } + EvalValue::Num(_) | EvalValue::Bool(_) => {} + _ => return None, + } + Some(out) + } + let frame_entries: Vec<(String, EvalValue)> = match frames { + EvalValue::Obj(map) => map + .entries() + .map(|(k, v)| (k.to_string(), v.clone())) + .collect(), + EvalValue::Null | EvalValue::Undefined => { + return Err(StylexError::upstream_type_crash( + "a nullish keyframes() argument in a defineVars/createTheme context", + )); + } + other => entries_object(other) + .expect("non-object frames are strings, arrays or primitives here") + .entries() + .map(|(k, v)| (k.to_string(), v.clone())) + .collect(), + }; + let mut out = crate::eval::value::JsObjectMap::new(); + for (key, frame) in frame_entries { + let coerced = match &frame { + EvalValue::Obj(_) | EvalValue::Null => frame, + // Object.entries(undefined) crashes exactly like a null frame does. + EvalValue::Undefined => EvalValue::Null, + other => EvalValue::Obj(Arc::new( + entries_object(other).expect("frame values here are strings/arrays/primitives"), + )), + }; + out.insert(key, coerced); + } + Ok(EvalValue::Obj(Arc::new(out))) +} diff --git a/crates/stylex/src/eval/methods.rs b/crates/stylex/src/eval/methods.rs new file mode 100644 index 00000000..2adbfa9a --- /dev/null +++ b/crates/stylex/src/eval/methods.rs @@ -0,0 +1,1579 @@ +//! The VALID_CALLEES global surface and prototype-method dispatch. +// parity: evaluate-path.js:945-1053, transcribed for every reachable method. + +use std::rc::Rc; + +use crate::errors::{ErrorCode, StylexError}; +use crate::eval::value::array_index; +use crate::eval::{JsObj, JsValue, is_nullish, js_to_number, js_to_string, truthy}; +use crate::jsrt::{js_number_to_string, js_slice_utf16_checked, js_to_fixed, js_trim, utf16_cmp}; + +pub(crate) const VALID_CALLEES: [&str; 5] = ["String", "Number", "Math", "Object", "Array"]; +pub(crate) const INVALID_METHODS: [&str; 7] = [ + "random", + "assign", + "defineProperties", + "defineProperty", + "freeze", + "seal", + "splice", +]; + +pub(crate) fn is_valid_callee(name: &str) -> bool { + VALID_CALLEES.contains(&name) +} + +pub(crate) fn is_invalid_method(name: &str) -> bool { + INVALID_METHODS.contains(&name) +} + +/// What `global[object][property]` holds upstream. +pub(crate) enum StaticMember { + /// A function we implement: called with evaluated args. + Fn(&'static str), + /// A data property: upstream throws `func.apply is not a function`. + NonCallable, + /// Callable upstream but not modelled: structured loud error. + Unsupported, + /// Absent upstream: falls through to the value-callee branch. + Unknown, +} + +fn func_apply_error() -> StylexError { + StylexError::new(ErrorCode::NonStaticValue, "func.apply is not a function") +} + +fn unsupported_global(name: &str) -> StylexError { + StylexError::unsupported_api(&format!("compile-time call to {name}")) +} + +const MATH_FNS: [&str; 34] = [ + "abs", "acos", "acosh", "asin", "asinh", "atan", "atan2", "atanh", "cbrt", "ceil", "clz32", + "cos", "cosh", "exp", "expm1", "floor", "fround", "hypot", "imul", "log", "log1p", "log2", + "log10", "max", "min", "pow", "round", "sign", "sin", "sinh", "sqrt", "tan", "tanh", "trunc", +]; +const MATH_CONSTS: [&str; 8] = [ + "E", "LN10", "LN2", "LOG10E", "LOG2E", "PI", "SQRT1_2", "SQRT2", +]; + +pub(crate) fn lookup_global_static(object: &str, property: &str) -> StaticMember { + match object { + "Math" => { + if MATH_FNS.contains(&property) { + StaticMember::Fn(property_static("Math", property)) + } else if MATH_CONSTS.contains(&property) { + StaticMember::NonCallable + } else if property == "f16round" || property == "sumPrecise" { + StaticMember::Unsupported + } else { + StaticMember::Unknown + } + } + "Object" => match property { + "keys" + | "values" + | "entries" + | "fromEntries" + | "is" + | "hasOwn" + | "getOwnPropertyNames" + | "getOwnPropertySymbols" + | "isExtensible" + | "isFrozen" + | "isSealed" => StaticMember::Fn(property_static("Object", property)), + "create" + | "getOwnPropertyDescriptor" + | "getOwnPropertyDescriptors" + | "getPrototypeOf" + | "groupBy" + | "preventExtensions" + | "setPrototypeOf" => StaticMember::Unsupported, + "length" | "name" | "prototype" => StaticMember::NonCallable, + _ => StaticMember::Unknown, + }, + "Array" => match property { + "isArray" | "of" | "from" => StaticMember::Fn(property_static("Array", property)), + "fromAsync" => StaticMember::Unsupported, + "length" | "name" | "prototype" => StaticMember::NonCallable, + _ => StaticMember::Unknown, + }, + "String" => match property { + "fromCharCode" | "fromCodePoint" => { + StaticMember::Fn(property_static("String", property)) + } + "raw" => StaticMember::Unsupported, + "length" | "name" | "prototype" => StaticMember::NonCallable, + _ => StaticMember::Unknown, + }, + "Number" => match property { + "isFinite" | "isInteger" | "isNaN" | "isSafeInteger" | "parseFloat" | "parseInt" => { + StaticMember::Fn(property_static("Number", property)) + } + "EPSILON" | "MAX_SAFE_INTEGER" | "MAX_VALUE" | "MIN_SAFE_INTEGER" | "MIN_VALUE" + | "NaN" | "NEGATIVE_INFINITY" | "POSITIVE_INFINITY" | "length" | "name" + | "prototype" => StaticMember::NonCallable, + _ => StaticMember::Unknown, + }, + _ => StaticMember::Unknown, + } +} + +// Canonical "&'static str" for the (object, property) pair so `Fn` can carry it. +fn property_static(object: &'static str, property: &str) -> &'static str { + let joined: &[(&str, &[&str])] = &[ + ("Math", &MATH_FNS), + ( + "Object", + &[ + "keys", + "values", + "entries", + "fromEntries", + "is", + "hasOwn", + "getOwnPropertyNames", + "getOwnPropertySymbols", + "isExtensible", + "isFrozen", + "isSealed", + ], + ), + ("Array", &["isArray", "of", "from"]), + ("String", &["fromCharCode", "fromCodePoint"]), + ( + "Number", + &[ + "isFinite", + "isInteger", + "isNaN", + "isSafeInteger", + "parseFloat", + "parseInt", + ], + ), + ]; + joined + .iter() + .find(|(o, _)| *o == object) + .and_then(|(_, list)| list.iter().find(|p| **p == property)) + .copied() + .expect("property listed in the matching table") +} + +/// The arrow-closure hook: methods with callbacks call back into the evaluator. +pub(crate) trait ArrowCaller { + fn call_arrow(&mut self, key: u32, args: Vec) -> Result; +} + +fn call_callback( + ev: &mut dyn ArrowCaller, + callback: &JsValue, + args: Vec, + method: &str, +) -> Result { + match callback { + JsValue::Callable(crate::eval::Callable::Arrow(key)) => ev.call_arrow(*key, args), + JsValue::Callable(_) => Err(unsupported_global(&format!( + "a non-arrow callback in {method}()" + ))), + _ => Err(StylexError::new( + ErrorCode::NonStaticValue, + format!( + "{} is not a function", + js_to_string(callback).unwrap_or_default() + ), + )), + } +} + +fn lone_surrogate_slice(context: &'static str) -> StylexError { + StylexError::lone_surrogate(context) +} + +fn utf16_len(s: &str) -> usize { + s.encode_utf16().count() +} + +// ES ToIntegerOrInfinity. +fn to_integer_or_infinity(n: f64) -> f64 { + if n.is_nan() { 0.0 } else { n.trunc() } +} + +fn relative_index(n: f64, len: usize) -> isize { + let len = len as f64; + let k = to_integer_or_infinity(n); + let clamped = if k < 0.0 { + (len + k).max(0.0) + } else { + k.min(len) + }; + clamped as isize +} + +fn arg(args: &[JsValue], i: usize) -> JsValue { + args.get(i).cloned().unwrap_or(JsValue::Undefined) +} + +fn num_arg(args: &[JsValue], i: usize) -> f64 { + js_to_number(&arg(args, i)) +} + +fn str_arg(args: &[JsValue], i: usize, method: &str) -> Result { + js_to_string(&arg(args, i)) + .ok_or_else(|| unsupported_global(&format!("a function argument to {method}()"))) +} + +// parity: ES SameValue. +fn same_value(a: &JsValue, b: &JsValue) -> bool { + match (a, b) { + (JsValue::Num(x), JsValue::Num(y)) => { + if x.is_nan() && y.is_nan() { + true + } else { + x == y && x.is_sign_positive() == y.is_sign_positive() + } + } + _ => crate::eval::js_strict_eq(a, b), + } +} + +/// ES-ordered own enumerable string keys (index keys ascending, then insertion). +fn es_own_keys(obj: &JsObj) -> Vec { + let mut index_keys: Vec<(u32, &str)> = Vec::new(); + let mut named: Vec<&str> = Vec::new(); + for (key, _) in obj.entries() { + match array_index(key) { + Some(n) => index_keys.push((n, key)), + None => named.push(key), + } + } + index_keys.sort_by_key(|(n, _)| *n); + index_keys + .into_iter() + .map(|(_, k)| k.to_string()) + .chain(named.into_iter().map(str::to_string)) + .collect() +} + +fn keyable(value: &JsValue, method: &str) -> Result, StylexError> { + Ok(match value { + JsValue::Null | JsValue::Undefined => { + return Err(StylexError::new( + ErrorCode::NonStaticValue, + "Cannot convert undefined or null to object", + )); + } + JsValue::Obj(obj) => Some(KeySource::Obj(Rc::clone(obj))), + JsValue::Arr(items) => Some(KeySource::Arr(items.len())), + JsValue::Str(s) => Some(KeySource::Str(utf16_len(s))), + JsValue::Num(_) | JsValue::Bool(_) | JsValue::Proxy(_) => None, + JsValue::Callable(_) => { + return Err(unsupported_global(&format!("{method} of a function"))); + } + }) +} + +enum KeySource { + Obj(Rc), + Arr(usize), + Str(usize), +} + +fn own_keys_of(value: &JsValue, method: &str) -> Result, StylexError> { + Ok(match keyable(value, method)? { + None => Vec::new(), + Some(KeySource::Obj(obj)) => es_own_keys(&obj), + Some(KeySource::Arr(len)) | Some(KeySource::Str(len)) => { + (0..len).map(|i| i.to_string()).collect() + } + }) +} + +fn own_value_of(value: &JsValue, key: &str) -> Result { + Ok(match value { + JsValue::Obj(obj) => obj.get(key).cloned().unwrap_or(JsValue::Undefined), + JsValue::Arr(items) => array_index(key) + .and_then(|i| items.get(i as usize)) + .cloned() + .unwrap_or(JsValue::Undefined), + JsValue::Str(s) => match array_index(key) { + Some(i) if (i as usize) < utf16_len(s) => JsValue::Str( + js_slice_utf16_checked(s, i as usize, i as isize + 1) + .map_err(|_| lone_surrogate_slice("string indexing"))?, + ), + _ => JsValue::Undefined, + }, + _ => JsValue::Undefined, + }) +} + +pub(crate) fn call_global_static( + ev: &mut dyn ArrowCaller, + object: &str, + method: &'static str, + args: &[JsValue], +) -> Result { + match object { + "Math" => call_math(method, args), + "Object" => call_object_static(method, args), + "Array" => call_array_static(ev, method, args), + "String" => call_string_static(method, args), + "Number" => call_number_static(method, args), + _ => Err(unsupported_global(object)), + } +} + +fn call_math(method: &str, args: &[JsValue]) -> Result { + let nums: Vec = args.iter().map(js_to_number).collect(); + let x = nums.first().copied().unwrap_or(f64::NAN); + let y = nums.get(1).copied().unwrap_or(f64::NAN); + let result = match method { + // parity: JS Math.min/max — any NaN operand wins (Rust min/max drop it). + "min" => nums.iter().copied().fold(f64::INFINITY, |a, b| { + if a.is_nan() || b.is_nan() { + f64::NAN + } else { + a.min(b) + } + }), + "max" => nums.iter().copied().fold(f64::NEG_INFINITY, |a, b| { + if a.is_nan() || b.is_nan() { + f64::NAN + } else { + a.max(b) + } + }), + "abs" => x.abs(), + "acos" => x.acos(), + "acosh" => x.acosh(), + "asin" => x.asin(), + "asinh" => x.asinh(), + "atan" => x.atan(), + "atan2" => x.atan2(y), + "atanh" => x.atanh(), + "cbrt" => x.cbrt(), + "ceil" => x.ceil(), + "clz32" => f64::from(crate::eval::to_uint32(x).leading_zeros()), + "cos" => x.cos(), + "cosh" => x.cosh(), + "exp" => x.exp(), + "expm1" => x.exp_m1(), + "floor" => x.floor(), + "fround" => x as f32 as f64, + "hypot" => nums.iter().copied().fold(0.0f64, f64::hypot), + "imul" => f64::from(crate::eval::to_int32(x).wrapping_mul(crate::eval::to_int32(y))), + "log" => x.ln(), + "log1p" => x.ln_1p(), + "log2" => x.log2(), + "log10" => x.log10(), + "pow" => crate::eval::js_pow(x, y), + "round" => crate::jsrt::js_math_round(x), + "sign" => { + if x.is_nan() || x == 0.0 { + x + } else if x > 0.0 { + 1.0 + } else { + -1.0 + } + } + "sin" => x.sin(), + "sinh" => x.sinh(), + "sqrt" => x.sqrt(), + "tan" => x.tan(), + "tanh" => x.tanh(), + "trunc" => x.trunc(), + _ => return Err(unsupported_global(&format!("Math.{method}"))), + }; + Ok(JsValue::Num(result)) +} + +fn call_object_static(method: &str, args: &[JsValue]) -> Result { + let target = arg(args, 0); + match method { + "keys" => Ok(JsValue::array( + own_keys_of(&target, "Object.keys")? + .into_iter() + .map(JsValue::Str) + .collect(), + )), + "values" => { + let keys = own_keys_of(&target, "Object.values")?; + let values = keys + .iter() + .map(|k| own_value_of(&target, k)) + .collect::, _>>()?; + Ok(JsValue::array(values)) + } + "entries" => { + let keys = own_keys_of(&target, "Object.entries")?; + let entries = keys + .into_iter() + .map(|k| { + let value = own_value_of(&target, &k)?; + Ok(JsValue::array(vec![JsValue::Str(k), value])) + }) + .collect::, StylexError>>()?; + Ok(JsValue::array(entries)) + } + "fromEntries" => { + let JsValue::Arr(entries) = &target else { + return Err(unsupported_global( + "Object.fromEntries of a non-array iterable", + )); + }; + let mut obj = JsObj::default(); + for entry in entries.iter() { + let JsValue::Arr(pair) = entry else { + return Err(unsupported_global( + "Object.fromEntries of non-array entries", + )); + }; + let key = js_to_string(&pair.first().cloned().unwrap_or(JsValue::Undefined)) + .ok_or_else(|| unsupported_global("a function key in Object.fromEntries"))?; + obj.insert(key, pair.get(1).cloned().unwrap_or(JsValue::Undefined)); + } + Ok(JsValue::object(obj)) + } + "is" => Ok(JsValue::Bool(same_value(&target, &arg(args, 1)))), + "hasOwn" => { + let key = js_to_string(&arg(args, 1)) + .ok_or_else(|| unsupported_global("a function key in Object.hasOwn"))?; + let has = match keyable(&target, "Object.hasOwn")? { + None => false, + Some(KeySource::Obj(obj)) => obj.get(&key).is_some(), + Some(KeySource::Arr(len)) | Some(KeySource::Str(len)) => { + key == "length" || array_index(&key).is_some_and(|i| (i as usize) < len) + } + }; + Ok(JsValue::Bool(has)) + } + "getOwnPropertyNames" => { + let mut keys = own_keys_of(&target, "Object.getOwnPropertyNames")?; + if matches!(target, JsValue::Arr(_) | JsValue::Str(_)) { + keys.push("length".to_string()); + } + Ok(JsValue::array(keys.into_iter().map(JsValue::Str).collect())) + } + "getOwnPropertySymbols" => match target { + JsValue::Null | JsValue::Undefined => Err(StylexError::new( + ErrorCode::NonStaticValue, + "Cannot convert undefined or null to object", + )), + _ => Ok(JsValue::array(Vec::new())), + }, + "isExtensible" => Ok(JsValue::Bool(matches!( + target, + JsValue::Obj(_) | JsValue::Arr(_) | JsValue::Proxy(_) | JsValue::Callable(_) + ))), + "isFrozen" | "isSealed" => Ok(JsValue::Bool(!matches!( + target, + JsValue::Obj(_) | JsValue::Arr(_) | JsValue::Proxy(_) | JsValue::Callable(_) + ))), + _ => Err(unsupported_global(&format!("Object.{method}"))), + } +} + +fn call_array_static( + ev: &mut dyn ArrowCaller, + method: &str, + args: &[JsValue], +) -> Result { + match method { + "isArray" => Ok(JsValue::Bool(matches!(arg(args, 0), JsValue::Arr(_)))), + "of" => Ok(JsValue::array(args.to_vec())), + "from" => { + let source = arg(args, 0); + let items: Vec = match &source { + JsValue::Str(s) => s.chars().map(|c| JsValue::Str(c.to_string())).collect(), + JsValue::Arr(items) => items.as_ref().clone(), + JsValue::Obj(obj) => { + let len = + js_to_number(&obj.get("length").cloned().unwrap_or(JsValue::Undefined)); + let len = to_integer_or_infinity(len).max(0.0); + if !(0.0..=65535.0).contains(&len) { + return Err(unsupported_global("Array.from of a huge array-like")); + } + (0..len as usize) + .map(|i| { + obj.get(&i.to_string()) + .cloned() + .unwrap_or(JsValue::Undefined) + }) + .collect() + } + JsValue::Num(_) | JsValue::Bool(_) => Vec::new(), + _ => return Err(unsupported_global("Array.from of this source")), + }; + let mapper = arg(args, 1); + if matches!(mapper, JsValue::Undefined) { + return Ok(JsValue::array(items)); + } + let mut out = Vec::with_capacity(items.len()); + for (i, item) in items.into_iter().enumerate() { + out.push(call_callback( + ev, + &mapper, + vec![item, JsValue::Num(i as f64)], + "Array.from", + )?); + } + Ok(JsValue::array(out)) + } + _ => Err(unsupported_global(&format!("Array.{method}"))), + } +} + +// ES ToUint16. +fn to_uint16(n: f64) -> u16 { + (crate::eval::to_uint32(n) & 0xFFFF) as u16 +} + +fn utf16_units_to_string(units: &[u16], context: &'static str) -> Result { + String::from_utf16(units).map_err(|_| lone_surrogate_slice(context)) +} + +fn call_string_static(method: &str, args: &[JsValue]) -> Result { + match method { + "fromCharCode" => { + let units: Vec = args.iter().map(|a| to_uint16(js_to_number(a))).collect(); + Ok(JsValue::Str(utf16_units_to_string( + &units, + "String.fromCharCode", + )?)) + } + "fromCodePoint" => { + let mut out = String::new(); + for value in args { + let n = js_to_number(value); + if n.fract() != 0.0 || !(0.0..=1_114_111.0).contains(&n) { + return Err(StylexError::new( + ErrorCode::NonStaticValue, + format!("Invalid code point {}", js_number_to_string(n)), + )); + } + let code = n as u32; + match char::from_u32(code) { + Some(c) => out.push(c), + None => return Err(lone_surrogate_slice("String.fromCodePoint")), + } + } + Ok(JsValue::Str(out)) + } + _ => Err(unsupported_global(&format!("String.{method}"))), + } +} + +fn call_number_static(method: &str, args: &[JsValue]) -> Result { + let value = arg(args, 0); + match method { + "isFinite" => Ok(JsValue::Bool( + matches!(value, JsValue::Num(n) if n.is_finite()), + )), + "isNaN" => Ok(JsValue::Bool( + matches!(value, JsValue::Num(n) if n.is_nan()), + )), + "isInteger" => Ok(JsValue::Bool( + matches!(value, JsValue::Num(n) if n.is_finite() && n.fract() == 0.0), + )), + "isSafeInteger" => Ok(JsValue::Bool( + matches!(value, JsValue::Num(n) if n.is_finite() && n.fract() == 0.0 && n.abs() <= 9_007_199_254_740_991.0), + )), + "parseInt" => { + let input = js_to_string(&value) + .ok_or_else(|| unsupported_global("a function argument to parseInt()"))?; + Ok(JsValue::Num(js_parse_int(&input, num_arg(args, 1)))) + } + "parseFloat" => { + let input = js_to_string(&value) + .ok_or_else(|| unsupported_global("a function argument to parseFloat()"))?; + Ok(JsValue::Num(js_parse_float(&input))) + } + _ => Err(unsupported_global(&format!("Number.{method}"))), + } +} + +// parity: ES parseInt (trim, sign, radix with 0x handling, maximal prefix). +pub(crate) fn js_parse_int(input: &str, radix: f64) -> f64 { + let s = js_trim(input); + let (sign, s) = match s.strip_prefix('-') { + Some(rest) => (-1.0, rest), + None => (1.0, s.strip_prefix('+').unwrap_or(s)), + }; + let mut radix = crate::eval::to_int32(radix); + let mut s = s; + if radix == 16 || radix == 0 { + if let Some(rest) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) { + s = rest; + radix = 16; + } else if radix == 0 { + radix = 10; + } + } + if !(2..=36).contains(&radix) { + return f64::NAN; + } + let digits: Vec = s + .chars() + .map_while(|c| c.to_digit(36).filter(|d| *d < radix as u32)) + .collect(); + if digits.is_empty() { + return f64::NAN; + } + let mut out = 0.0f64; + for d in digits { + out = out * f64::from(radix) + f64::from(d); + } + sign * out +} + +// parity: ES parseFloat (maximal StrDecimalLiteral prefix). +pub(crate) fn js_parse_float(input: &str) -> f64 { + let s = js_trim(input); + let bytes = s.as_bytes(); + let mut i = 0; + if i < bytes.len() && (bytes[i] == b'+' || bytes[i] == b'-') { + i += 1; + } + if s[i..].starts_with("Infinity") { + return if bytes.first() == Some(&b'-') { + f64::NEG_INFINITY + } else { + f64::INFINITY + }; + } + let digits_start = i; + while i < bytes.len() && bytes[i].is_ascii_digit() { + i += 1; + } + if i < bytes.len() && bytes[i] == b'.' { + i += 1; + while i < bytes.len() && bytes[i].is_ascii_digit() { + i += 1; + } + } + if i == digits_start || (i == digits_start + 1 && bytes[digits_start] == b'.') { + return f64::NAN; + } + let mantissa_end = i; + if i < bytes.len() && (bytes[i] == b'e' || bytes[i] == b'E') { + let mut j = i + 1; + if j < bytes.len() && (bytes[j] == b'+' || bytes[j] == b'-') { + j += 1; + } + let exp_digits = j; + while j < bytes.len() && bytes[j].is_ascii_digit() { + j += 1; + } + if j > exp_digits { + i = j; + } + } + let _ = mantissa_end; + s[..i].parse::().unwrap_or(f64::NAN) +} + +/// Prototype-method dispatch for evaluated receivers (the `func == null` +/// fallback in evaluate-path.js, where real JS methods run). +pub(crate) enum ProtoLookup { + Fn(&'static str), + Unsupported(&'static str), + NotFound, +} + +const STR_METHODS: [&str; 26] = [ + "at", + "charAt", + "charCodeAt", + "codePointAt", + "concat", + "endsWith", + "includes", + "indexOf", + "lastIndexOf", + "padEnd", + "padStart", + "repeat", + "replace", + "replaceAll", + "slice", + "split", + "startsWith", + "substr", + "substring", + "toLowerCase", + "toString", + "toUpperCase", + "trim", + "trimEnd", + "trimStart", + "valueOf", +]; +const STR_UNSUPPORTED: [&str; 22] = [ + "anchor", + "big", + "blink", + "bold", + "fixed", + "fontcolor", + "fontsize", + "isWellFormed", + "italics", + "link", + "localeCompare", + "match", + "matchAll", + "normalize", + "search", + "small", + "strike", + "sub", + "sup", + "toLocaleLowerCase", + "toLocaleUpperCase", + "toWellFormed", +]; + +const ARR_METHODS: [&str; 25] = [ + "at", + "concat", + "every", + "filter", + "find", + "findIndex", + "findLast", + "findLastIndex", + "flat", + "flatMap", + "forEach", + "includes", + "indexOf", + "join", + "lastIndexOf", + "map", + "reduce", + "reduceRight", + "reverse", + "slice", + "some", + "sort", + "toReversed", + "toSorted", + "toString", +]; +const ARR_UNSUPPORTED: [&str; 13] = [ + "copyWithin", + "entries", + "fill", + "keys", + "pop", + "push", + "shift", + "splice", + "toLocaleString", + "toSpliced", + "unshift", + "values", + "with", +]; + +pub(crate) const NUM_METHODS: [&str; 3] = ["toFixed", "toString", "valueOf"]; +pub(crate) const NUM_UNSUPPORTED: [&str; 3] = ["toExponential", "toLocaleString", "toPrecision"]; + +const OBJ_METHODS: [&str; 6] = [ + "hasOwnProperty", + "isPrototypeOf", + "propertyIsEnumerable", + "toLocaleString", + "toString", + "valueOf", +]; +const OBJ_UNSUPPORTED: [&str; 4] = [ + "__defineGetter__", + "__defineSetter__", + "__lookupGetter__", + "__lookupSetter__", +]; + +fn find_static(table: &'static [&'static str], name: &str) -> Option<&'static str> { + table.iter().find(|m| **m == name).copied() +} + +pub(crate) fn lookup_proto_method(receiver: &JsValue, name: &str) -> ProtoLookup { + let (methods, unsupported): (&'static [&'static str], &'static [&'static str]) = match receiver + { + JsValue::Str(_) => (&STR_METHODS, &STR_UNSUPPORTED), + JsValue::Arr(_) => (&ARR_METHODS, &ARR_UNSUPPORTED), + JsValue::Num(_) => (&NUM_METHODS, &NUM_UNSUPPORTED), + JsValue::Bool(_) => (&["toString", "valueOf"][..], &[][..]), + JsValue::Obj(_) => (&OBJ_METHODS, &OBJ_UNSUPPORTED), + _ => (&[][..], &[][..]), + }; + if let Some(found) = find_static(methods, name) { + ProtoLookup::Fn(found) + } else if let Some(found) = find_static(unsupported, name) { + ProtoLookup::Unsupported(found) + } else { + ProtoLookup::NotFound + } +} + +pub(crate) fn call_proto_method( + ev: &mut dyn ArrowCaller, + receiver: &JsValue, + method: &'static str, + args: &[JsValue], +) -> Result { + match receiver { + JsValue::Str(s) => call_string_method(s, method, args), + JsValue::Arr(items) => call_array_method(ev, receiver, items, method, args), + JsValue::Num(n) => call_number_method(*n, method, args), + JsValue::Bool(b) => match method { + "toString" => Ok(JsValue::Str(b.to_string())), + "valueOf" => Ok(JsValue::Bool(*b)), + _ => Err(unsupported_global(method)), + }, + JsValue::Obj(obj) => call_object_method(receiver, obj, method, args), + _ => Err(unsupported_global(method)), + } +} + +fn checked_slice( + s: &str, + start: isize, + end: isize, + context: &'static str, +) -> Result { + let start = start.max(0) as usize; + js_slice_utf16_checked(s, start, end).map_err(|_| lone_surrogate_slice(context)) +} + +fn call_string_method( + s: &str, + method: &'static str, + args: &[JsValue], +) -> Result { + let len = utf16_len(s) as isize; + let units = || s.encode_utf16().collect::>(); + match method { + "toString" | "valueOf" => Ok(JsValue::Str(s.to_string())), + "toUpperCase" => Ok(JsValue::Str(s.to_uppercase())), + "toLowerCase" => Ok(JsValue::Str(s.to_lowercase())), + "trim" => Ok(JsValue::Str(js_trim(s).to_string())), + "trimStart" => Ok(JsValue::Str( + s.trim_start_matches(crate::jsrt::is_js_whitespace) + .to_string(), + )), + "trimEnd" => Ok(JsValue::Str( + s.trim_end_matches(crate::jsrt::is_js_whitespace) + .to_string(), + )), + "charAt" => { + let i = to_integer_or_infinity(num_arg(args, 0)); + if i < 0.0 || i >= len as f64 { + return Ok(JsValue::Str(String::new())); + } + Ok(JsValue::Str(checked_slice( + s, + i as isize, + i as isize + 1, + "String.prototype.charAt", + )?)) + } + "at" => { + let mut i = to_integer_or_infinity(num_arg(args, 0)); + if i < 0.0 { + i += len as f64; + } + if i < 0.0 || i >= len as f64 { + return Ok(JsValue::Undefined); + } + Ok(JsValue::Str(checked_slice( + s, + i as isize, + i as isize + 1, + "String.prototype.at", + )?)) + } + "charCodeAt" => { + let i = to_integer_or_infinity(num_arg(args, 0)); + if i < 0.0 || i >= len as f64 { + return Ok(JsValue::Num(f64::NAN)); + } + Ok(JsValue::Num(f64::from(units()[i as usize]))) + } + "codePointAt" => { + let i = to_integer_or_infinity(num_arg(args, 0)); + if i < 0.0 || i >= len as f64 { + return Ok(JsValue::Undefined); + } + let us = units(); + let first = us[i as usize]; + let code = if (0xD800..=0xDBFF).contains(&first) + && let Some(second) = us.get(i as usize + 1) + && (0xDC00..=0xDFFF).contains(second) + { + 0x10000 + (u32::from(first) - 0xD800) * 0x400 + (u32::from(*second) - 0xDC00) + } else { + u32::from(first) + }; + Ok(JsValue::Num(f64::from(code))) + } + "indexOf" | "lastIndexOf" | "includes" | "startsWith" | "endsWith" => { + let needle = str_arg(args, 0, method)?; + let position = args.get(1).map(js_to_number); + string_search(s, &needle, position, method) + } + "slice" => { + let start = relative_index(num_arg(args, 0), len as usize); + let end = match args.get(1) { + None | Some(JsValue::Undefined) => len, + Some(v) => relative_index(js_to_number(v), len as usize), + }; + if start >= end { + return Ok(JsValue::Str(String::new())); + } + Ok(JsValue::Str(checked_slice( + s, + start, + end, + "String.prototype.slice", + )?)) + } + "substring" => { + let a = to_integer_or_infinity(num_arg(args, 0)).clamp(0.0, len as f64) as isize; + let b = match args.get(1) { + None | Some(JsValue::Undefined) => len, + Some(v) => to_integer_or_infinity(js_to_number(v)).clamp(0.0, len as f64) as isize, + }; + let (start, end) = if a <= b { (a, b) } else { (b, a) }; + Ok(JsValue::Str(checked_slice( + s, + start, + end, + "String.prototype.substring", + )?)) + } + "substr" => { + let mut start = to_integer_or_infinity(num_arg(args, 0)) as isize; + if start < 0 { + start = (len + start).max(0); + } + let count = match args.get(1) { + None | Some(JsValue::Undefined) => len - start, + Some(v) => to_integer_or_infinity(js_to_number(v)) as isize, + }; + // parity: JS adds as floats, so Infinity clamps instead of wrapping. + let end = start.saturating_add(count.max(0)).min(len); + if start >= end { + return Ok(JsValue::Str(String::new())); + } + Ok(JsValue::Str(checked_slice( + s, + start, + end, + "String.prototype.substr", + )?)) + } + "concat" => { + let mut out = s.to_string(); + for value in args { + out.push_str(&js_to_string(value).ok_or_else(|| { + unsupported_global("a function argument to String.prototype.concat()") + })?); + } + Ok(JsValue::Str(out)) + } + "repeat" => { + let n = to_integer_or_infinity(num_arg(args, 0)); + if n < 0.0 || n.is_infinite() { + return Err(StylexError::new( + ErrorCode::NonStaticValue, + format!( + "Invalid count value: {}", + js_number_to_string(num_arg(args, 0)) + ), + )); + } + if n * len as f64 > 65535.0 { + return Err(unsupported_global("String.prototype.repeat beyond 64KiB")); + } + Ok(JsValue::Str(s.repeat(n as usize))) + } + "padStart" | "padEnd" => { + let target = to_integer_or_infinity(num_arg(args, 0)); + let filler = match args.get(1) { + None | Some(JsValue::Undefined) => " ".to_string(), + Some(v) => js_to_string(v).ok_or_else(|| { + unsupported_global("a function argument to String.prototype.pad()") + })?, + }; + if target > 65535.0 { + return Err(unsupported_global("String.prototype.pad beyond 64KiB")); + } + if target <= len as f64 || filler.is_empty() { + return Ok(JsValue::Str(s.to_string())); + } + let missing = target as usize - len as usize; + let filler_units: Vec = filler.encode_utf16().collect(); + let pad_units: Vec = filler_units.iter().copied().cycle().take(missing).collect(); + let pad = utf16_units_to_string(&pad_units, "String.prototype.pad")?; + Ok(JsValue::Str(if method == "padStart" { + format!("{pad}{s}") + } else { + format!("{s}{pad}") + })) + } + "split" => { + let limit = match args.get(1) { + None | Some(JsValue::Undefined) => u32::MAX, + Some(v) => crate::eval::to_uint32(js_to_number(v)), + }; + let separator = match args.first() { + None | Some(JsValue::Undefined) => { + if limit == 0 { + return Ok(JsValue::array(Vec::new())); + } + return Ok(JsValue::array(vec![JsValue::Str(s.to_string())])); + } + Some(v) => js_to_string(v).ok_or_else(|| { + unsupported_global("a function separator in String.prototype.split()") + })?, + }; + let mut parts: Vec = Vec::new(); + if separator.is_empty() { + let us = units(); + for i in 0..us.len().min(limit as usize) { + parts.push(JsValue::Str(checked_slice( + s, + i as isize, + i as isize + 1, + "String.prototype.split", + )?)); + } + } else { + for piece in s.split(&separator) { + if parts.len() >= limit as usize { + break; + } + parts.push(JsValue::Str(piece.to_string())); + } + } + Ok(JsValue::array(parts)) + } + "replace" | "replaceAll" => Err(unsupported_global("String.prototype.replace")), + _ => Err(unsupported_global(method)), + } +} + +fn string_search( + s: &str, + needle: &str, + position: Option, + method: &str, +) -> Result { + let hay: Vec = s.encode_utf16().collect(); + let pat: Vec = needle.encode_utf16().collect(); + let len = hay.len(); + let find_from = |from: usize| -> Option { + if pat.is_empty() { + return Some(from.min(len)); + } + if pat.len() > len { + return None; + } + (from..=len.saturating_sub(pat.len())).find(|&i| hay[i..i + pat.len()] == pat[..]) + }; + match method { + "indexOf" => { + let from = position + .map_or(0.0, to_integer_or_infinity) + .clamp(0.0, len as f64) as usize; + Ok(JsValue::Num(find_from(from).map_or(-1.0, |i| i as f64))) + } + "includes" => { + let from = position + .map_or(0.0, to_integer_or_infinity) + .clamp(0.0, len as f64) as usize; + Ok(JsValue::Bool(find_from(from).is_some())) + } + "lastIndexOf" => { + let from = match position { + None => len as f64, + Some(p) => { + let p = to_integer_or_infinity(p); + if p.is_nan() { len as f64 } else { p } + } + } + .clamp(0.0, len as f64) as usize; + if pat.is_empty() { + return Ok(JsValue::Num(from.min(len) as f64)); + } + let mut best: f64 = -1.0; + let mut i = 0usize; + while let Some(found) = find_from(i) { + if found > from { + break; + } + best = found as f64; + i = found + 1; + } + Ok(JsValue::Num(best)) + } + "startsWith" => { + let from = position + .map_or(0.0, to_integer_or_infinity) + .clamp(0.0, len as f64) as usize; + Ok(JsValue::Bool( + from + pat.len() <= len && hay[from..from + pat.len()] == pat[..], + )) + } + "endsWith" => { + let end = match position { + None => len, + Some(p) => to_integer_or_infinity(p).clamp(0.0, len as f64) as usize, + }; + Ok(JsValue::Bool( + pat.len() <= end && hay[end - pat.len()..end] == pat[..], + )) + } + _ => Err(unsupported_global(method)), + } +} + +fn element_string(value: &JsValue, method: &str) -> Result { + if is_nullish(value) { + return Ok(String::new()); + } + js_to_string(value) + .ok_or_else(|| unsupported_global(&format!("a function element in {method}()"))) +} + +fn call_array_method( + ev: &mut dyn ArrowCaller, + receiver: &JsValue, + items: &Rc>, + method: &'static str, + args: &[JsValue], +) -> Result { + let len = items.len(); + let callback_args = + |item: &JsValue, i: usize| vec![item.clone(), JsValue::Num(i as f64), receiver.clone()]; + match method { + "toString" | "join" => { + let separator = match (method, args.first()) { + ("join", Some(JsValue::Undefined)) | ("join", None) | ("toString", _) => { + ",".to_string() + } + ("join", Some(v)) => js_to_string(v).ok_or_else(|| { + unsupported_global("a function separator in Array.prototype.join()") + })?, + _ => ",".to_string(), + }; + let parts = items + .iter() + .map(|item| element_string(item, "Array.prototype.join")) + .collect::, _>>()?; + Ok(JsValue::Str(parts.join(&separator))) + } + "at" => { + let mut i = to_integer_or_infinity(num_arg(args, 0)); + if i < 0.0 { + i += len as f64; + } + if i < 0.0 || i >= len as f64 { + return Ok(JsValue::Undefined); + } + Ok(items[i as usize].clone()) + } + "includes" => Ok(JsValue::Bool( + items + .iter() + .any(|item| same_value_zero(item, &arg(args, 0))), + )), + "indexOf" => Ok(JsValue::Num( + items + .iter() + .position(|item| crate::eval::js_strict_eq(item, &arg(args, 0))) + .map_or(-1.0, |i| i as f64), + )), + "lastIndexOf" => Ok(JsValue::Num( + items + .iter() + .rposition(|item| crate::eval::js_strict_eq(item, &arg(args, 0))) + .map_or(-1.0, |i| i as f64), + )), + "slice" => { + let start = relative_index(num_arg(args, 0), len); + let end = match args.get(1) { + None | Some(JsValue::Undefined) => len as isize, + Some(v) => relative_index(js_to_number(v), len), + }; + if start >= end { + return Ok(JsValue::array(Vec::new())); + } + Ok(JsValue::array(items[start as usize..end as usize].to_vec())) + } + "concat" => { + let mut out = items.as_ref().clone(); + for value in args { + match value { + JsValue::Arr(more) => out.extend(more.iter().cloned()), + other => out.push(other.clone()), + } + } + Ok(JsValue::array(out)) + } + "flat" => { + let depth = match args.first() { + None | Some(JsValue::Undefined) => 1.0, + Some(v) => to_integer_or_infinity(js_to_number(v)), + }; + let mut out = Vec::new(); + flatten_into(&mut out, items, depth); + Ok(JsValue::array(out)) + } + "reverse" | "toReversed" => Ok(JsValue::array( + items.iter().rev().cloned().collect::>(), + )), + "sort" | "toSorted" => { + let comparator = arg(args, 0); + let mut sorted: Vec = items.as_ref().clone(); + // ES SortCompare: undefined elements sort last, before holes. + let mut error: Option = None; + if matches!(comparator, JsValue::Undefined) { + let mut keyed: Vec<(Option, JsValue)> = Vec::with_capacity(sorted.len()); + for item in sorted { + let key = if matches!(item, JsValue::Undefined) { + None + } else { + match js_to_string(&item) { + Some(k) => Some(k), + None => { + return Err(unsupported_global( + "a function element in Array.prototype.sort()", + )); + } + } + }; + keyed.push((key, item)); + } + keyed.sort_by(|(a, _), (b, _)| match (a, b) { + (None, None) => std::cmp::Ordering::Equal, + (None, Some(_)) => std::cmp::Ordering::Greater, + (Some(_), None) => std::cmp::Ordering::Less, + (Some(a), Some(b)) => utf16_cmp(a, b), + }); + sorted = keyed.into_iter().map(|(_, v)| v).collect(); + } else { + sorted.sort_by(|a, b| { + if error.is_some() { + return std::cmp::Ordering::Equal; + } + if matches!(a, JsValue::Undefined) || matches!(b, JsValue::Undefined) { + return match ( + matches!(a, JsValue::Undefined), + matches!(b, JsValue::Undefined), + ) { + (true, true) => std::cmp::Ordering::Equal, + (true, false) => std::cmp::Ordering::Greater, + (false, true) => std::cmp::Ordering::Less, + _ => unreachable!(), + }; + } + match call_callback( + ev, + &comparator, + vec![a.clone(), b.clone()], + "Array.prototype.sort", + ) { + Ok(v) => { + let n = js_to_number(&v); + if n < 0.0 { + std::cmp::Ordering::Less + } else if n > 0.0 { + std::cmp::Ordering::Greater + } else { + std::cmp::Ordering::Equal + } + } + Err(e) => { + error = Some(e); + std::cmp::Ordering::Equal + } + } + }); + if let Some(e) = error { + return Err(e); + } + } + Ok(JsValue::array(sorted)) + } + "map" | "flatMap" => { + let callback = arg(args, 0); + let mut out = Vec::with_capacity(len); + for (i, item) in items.iter().enumerate() { + let mapped = + call_callback(ev, &callback, callback_args(item, i), "Array.prototype.map")?; + if method == "flatMap" { + match mapped { + JsValue::Arr(inner) => out.extend(inner.iter().cloned()), + other => out.push(other), + } + } else { + out.push(mapped); + } + } + Ok(JsValue::array(out)) + } + "filter" => { + let callback = arg(args, 0); + let mut out = Vec::new(); + for (i, item) in items.iter().enumerate() { + if truthy(&call_callback( + ev, + &callback, + callback_args(item, i), + "Array.prototype.filter", + )?) { + out.push(item.clone()); + } + } + Ok(JsValue::array(out)) + } + "forEach" => { + let callback = arg(args, 0); + for (i, item) in items.iter().enumerate() { + call_callback( + ev, + &callback, + callback_args(item, i), + "Array.prototype.forEach", + )?; + } + Ok(JsValue::Undefined) + } + "some" | "every" => { + let callback = arg(args, 0); + for (i, item) in items.iter().enumerate() { + let passed = truthy(&call_callback( + ev, + &callback, + callback_args(item, i), + "Array.prototype.some", + )?); + if method == "some" && passed { + return Ok(JsValue::Bool(true)); + } + if method == "every" && !passed { + return Ok(JsValue::Bool(false)); + } + } + Ok(JsValue::Bool(method == "every")) + } + "find" | "findIndex" | "findLast" | "findLastIndex" => { + let callback = arg(args, 0); + let indices: Vec = if method.contains("Last") { + (0..len).rev().collect() + } else { + (0..len).collect() + }; + for i in indices { + let item = &items[i]; + if truthy(&call_callback( + ev, + &callback, + callback_args(item, i), + "Array.prototype.find", + )?) { + return Ok(if method.ends_with("Index") { + JsValue::Num(i as f64) + } else { + item.clone() + }); + } + } + Ok(if method.ends_with("Index") { + JsValue::Num(-1.0) + } else { + JsValue::Undefined + }) + } + "reduce" | "reduceRight" => { + let callback = arg(args, 0); + let mut order: Vec = (0..len).collect(); + if method == "reduceRight" { + order.reverse(); + } + let mut iter = order.into_iter(); + let mut acc = match args.get(1) { + Some(initial) => initial.clone(), + None => match iter.next() { + Some(i) => items[i].clone(), + None => { + return Err(StylexError::new( + ErrorCode::NonStaticValue, + "Reduce of empty array with no initial value", + )); + } + }, + }; + for i in iter { + acc = call_callback( + ev, + &callback, + vec![ + acc, + items[i].clone(), + JsValue::Num(i as f64), + receiver.clone(), + ], + "Array.prototype.reduce", + )?; + } + Ok(acc) + } + _ => Err(unsupported_global(method)), + } +} + +// ES SameValueZero (Array.prototype.includes). +fn same_value_zero(a: &JsValue, b: &JsValue) -> bool { + match (a, b) { + (JsValue::Num(x), JsValue::Num(y)) => (x.is_nan() && y.is_nan()) || x == y, + _ => crate::eval::js_strict_eq(a, b), + } +} + +fn flatten_into(out: &mut Vec, items: &[JsValue], depth: f64) { + for item in items { + match item { + JsValue::Arr(inner) if depth >= 1.0 => flatten_into(out, inner, depth - 1.0), + other => out.push(other.clone()), + } + } +} + +fn call_number_method( + n: f64, + method: &'static str, + args: &[JsValue], +) -> Result { + match method { + "valueOf" => Ok(JsValue::Num(n)), + "toString" => { + let radix = match args.first() { + None | Some(JsValue::Undefined) => 10.0, + Some(v) => to_integer_or_infinity(js_to_number(v)), + }; + if radix == 10.0 { + return Ok(JsValue::Str(js_number_to_string(n))); + } + if !(2.0..=36.0).contains(&radix) { + return Err(StylexError::new( + ErrorCode::NonStaticValue, + "toString() radix must be between 2 and 36", + )); + } + if !n.is_finite() { + return Ok(JsValue::Str(js_number_to_string(n))); + } + if n.fract() != 0.0 || n.abs() >= 9_007_199_254_740_992.0 { + return Err(unsupported_global( + "Number.prototype.toString with a non-integral radix argument", + )); + } + let radix = radix as u32; + let negative = n < 0.0; + let mut magnitude = n.abs() as u64; + let mut digits = Vec::new(); + loop { + let digit = (magnitude % u64::from(radix)) as u32; + digits.push(char::from_digit(digit, radix).expect("digit < radix")); + magnitude /= u64::from(radix); + if magnitude == 0 { + break; + } + } + if negative { + digits.push('-'); + } + Ok(JsValue::Str(digits.into_iter().rev().collect())) + } + "toFixed" => { + let digits = to_integer_or_infinity(num_arg(args, 0)); + if !(0.0..=100.0).contains(&digits) { + return Err(StylexError::new( + ErrorCode::NonStaticValue, + "toFixed() digits argument must be between 0 and 100", + )); + } + Ok(JsValue::Str(js_to_fixed(n, digits as usize))) + } + _ => Err(unsupported_global(method)), + } +} + +fn call_object_method( + receiver: &JsValue, + obj: &Rc, + method: &'static str, + args: &[JsValue], +) -> Result { + match method { + "toString" | "toLocaleString" => Ok(JsValue::Str("[object Object]".to_string())), + "valueOf" => Ok(receiver.clone()), + "hasOwnProperty" | "propertyIsEnumerable" => { + let key = js_to_string(&arg(args, 0)).ok_or_else(|| { + unsupported_global("a function key in Object.prototype.hasOwnProperty()") + })?; + Ok(JsValue::Bool(obj.get(&key).is_some())) + } + "isPrototypeOf" => Ok(JsValue::Bool(false)), + _ => Err(unsupported_global(method)), + } +} + +/// The global identifier-callee surface (`String(x)`, `Array(…)`, …). +pub(crate) fn call_global_identifier( + name: &str, + args: &[JsValue], +) -> Result, StylexError> { + match name { + // parity: global.Math is not callable — the TypeError escapes. + "Math" => Err(func_apply_error()), + "String" => Ok(match args.first() { + None => Some(JsValue::Str(String::new())), + Some(v) => js_to_string(v).map(JsValue::Str), + }), + "Number" => Ok(Some(JsValue::Num(args.first().map_or(0.0, js_to_number)))), + "Array" => { + if args.len() == 1 + && let JsValue::Num(n) = args[0] + { + if n.fract() != 0.0 || !(0.0..=4_294_967_295.0).contains(&n) { + return Err(StylexError::new( + ErrorCode::NonStaticValue, + "Invalid array length", + )); + } + if n > 65535.0 { + return Err(unsupported_global("Array() beyond 65535 elements")); + } + // Holes approximated as undefined (visible only via own-keys). + return Ok(Some(JsValue::array(vec![JsValue::Undefined; n as usize]))); + } + Ok(Some(JsValue::array(args.to_vec()))) + } + "Object" => match args.first() { + None | Some(JsValue::Null | JsValue::Undefined) => { + Ok(Some(JsValue::object(JsObj::default()))) + } + Some(v @ (JsValue::Obj(_) | JsValue::Arr(_) | JsValue::Proxy(_))) => { + Ok(Some(v.clone())) + } + Some(_) => Err(unsupported_global("Object() wrapper objects")), + }, + _ => Ok(None), + } +} diff --git a/crates/stylex/src/eval/mod.rs b/crates/stylex/src/eval/mod.rs new file mode 100644 index 00000000..dc6441f9 --- /dev/null +++ b/crates/stylex/src/eval/mod.rs @@ -0,0 +1,2132 @@ +//! Compile-time partial evaluator over the oxc AST, default-deny past the +//! verified surface. parity: evaluate-path.js + parse-stylex-create-arg.js + +pub mod cross_file; +pub mod functions; +pub mod methods; +pub mod value; + +use std::collections::BTreeMap; + +use crate::fxhash::FxHashMap; +use std::rc::Rc; +use std::sync::Arc; + +use oxc_ast::ast::{ + Argument, ArrayExpressionElement, BinaryExpression, CallExpression, ChainElement, Expression, + IdentifierReference, LogicalExpression, ObjectExpression, ObjectPropertyKind, PropertyKey, + PropertyKind, TemplateLiteral, UnaryExpression, +}; +use oxc_span::{GetSpan, Span}; +use oxc_syntax::operator::{BinaryOperator, LogicalOperator, UnaryOperator}; +use oxc_syntax::symbol::SymbolId; + +use crate::errors::{ErrorCode, StylexError}; +use crate::eval::cross_file as errs; +use crate::eval::cross_file::VarGroupProxy; +use crate::eval::functions::{EvalCallables, StylexCallable, WHEN_MEMBERS}; +use crate::eval::methods::ArrowCaller; +use crate::eval::value::{EvalValue, JsObjectMap, array_index}; +use crate::imports::{ImportTable, ImportedSymbol, StylexNamedImport}; +use crate::jsrt::{js_number_to_string, js_slice_utf16, js_slice_utf16_checked, utf16_cmp}; +use crate::module_resolution::FsProvider; +use crate::shared::dynamic::{DynamicFn, inline_style_for_leaf}; +use crate::state::{BindingDecl, CompileState}; + +/// Evaluator-local value model; `Rc` containers keep JS allocation identity +/// through clones (`x === x` via the node cache), like real objects upstream. +#[derive(Debug, Clone)] +pub enum JsValue { + Null, + Undefined, + Bool(bool), + Num(f64), + Str(String), + Arr(Rc>), + Obj(Rc), + Proxy(Rc), + Callable(Callable), +} + +impl JsValue { + pub fn array(items: Vec) -> Self { + JsValue::Arr(Rc::new(items)) + } + + pub fn object(obj: JsObj) -> Self { + JsValue::Obj(Rc::new(obj)) + } + + pub fn proxy(proxy: VarGroupProxy) -> Self { + JsValue::Proxy(Rc::new(proxy)) + } +} + +#[derive(Debug, Clone)] +pub enum Callable { + Stylex(StylexCallable), + /// A local expression-body arrow, callable at compile time; the key is + /// its span start in the evaluator's arrow-body table. + Arrow(u32), + /// Function values calls never dispatch to. + Opaque, +} + +/// Insertion-ordered object; ES OwnPropertyKeys ordering is restored when the +/// value converts to `JsObjectMap`, which every observable path goes through. +#[derive(Debug, Clone, Default)] +pub struct JsObj { + entries: Vec<(String, JsValue)>, + /// Lazy key→position table so wide objects avoid quadratic scans. + // Boxed to keep the usually-None field one word in every JsObj. + #[allow(clippy::box_collection)] + index: Option>>, + /// CSSType `instanceof` brand (the syntax): out-of-band like a prototype — + /// spreads copy entries only, aliases share the allocation. + css_type: Option, +} + +impl JsObj { + pub fn get(&self, key: &str) -> Option<&JsValue> { + if let Some(index) = &self.index { + index.get(key).map(|&i| &self.entries[i].1) + } else { + self.entries.iter().find(|(k, _)| k == key).map(|(_, v)| v) + } + } + + pub fn insert(&mut self, key: String, value: JsValue) { + if self.index.is_none() && self.entries.len() >= value::NAMED_INDEX_THRESHOLD { + self.index = Some(Box::new( + self.entries + .iter() + .enumerate() + .map(|(i, (k, _))| (k.clone(), i)) + .collect(), + )); + } + if let Some(index) = &mut self.index { + match index.entry(key) { + std::collections::hash_map::Entry::Occupied(e) => { + self.entries[*e.get()].1 = value; + } + std::collections::hash_map::Entry::Vacant(e) => { + self.entries.push((e.key().clone(), value)); + e.insert(self.entries.len() - 1); + } + } + } else if let Some(entry) = self.entries.iter_mut().find(|(k, _)| *k == key) { + entry.1 = value; + } else { + self.entries.push((key, value)); + } + } + + pub fn entries(&self) -> impl Iterator { + self.entries.iter().map(|(k, v)| (k.as_str(), v)) + } + + pub fn css_type(&self) -> Option<&str> { + self.css_type.as_deref() + } + + pub fn set_css_type(&mut self, syntax: String) { + self.css_type = Some(syntax); + } +} + +#[derive(Debug, Clone)] +pub struct Deopt { + pub reason: String, + pub span: Span, +} + +#[derive(Debug)] +pub enum EvalOutcome { + Value(JsValue), + /// Upstream deopt: hard error in create/defineVars contexts, bail-to-runtime + /// marker in props()/stylex() merge contexts. + NonStatic(Deopt), +} + +pub type EvalResult = Result; + +/// Proxies convert to empty objects (the upstream JS Proxy has no own keys); +/// functions to `undefined` (downstream validation rejects both identically). +pub fn to_eval_value(value: &JsValue) -> EvalValue { + match value { + JsValue::Null => EvalValue::Null, + JsValue::Undefined | JsValue::Callable(_) => EvalValue::Undefined, + JsValue::Bool(b) => EvalValue::Bool(*b), + JsValue::Num(n) => EvalValue::Num(*n), + JsValue::Str(s) => EvalValue::Str(s.clone()), + JsValue::Arr(items) => EvalValue::Arr(items.iter().map(to_eval_value).collect()), + JsValue::Obj(obj) => { + let mut map = JsObjectMap::new(); + for (k, v) in obj.entries() { + map.insert(k.to_string(), to_eval_value(v)); + } + if let Some(syntax) = obj.css_type() { + map.set_css_type(syntax.to_string()); + } + EvalValue::Obj(Arc::new(map)) + } + JsValue::Proxy(_) => EvalValue::Obj(JsObjectMap::new().into()), + } +} + +pub fn from_eval_value(value: &EvalValue) -> JsValue { + match value { + EvalValue::Null => JsValue::Null, + EvalValue::Undefined => JsValue::Undefined, + EvalValue::Bool(b) => JsValue::Bool(*b), + EvalValue::Num(n) => JsValue::Num(*n), + EvalValue::Str(s) => JsValue::Str(s.clone()), + EvalValue::Arr(items) => JsValue::array(items.iter().map(from_eval_value).collect()), + EvalValue::Obj(map) => { + let mut obj = JsObj::default(); + for (k, v) in map.entries() { + obj.insert(k.to_string(), from_eval_value(v)); + } + if let Some(syntax) = map.css_type() { + obj.set_css_type(syntax.to_string()); + } + JsValue::object(obj) + } + } +} + +#[derive(Debug, Clone)] +enum RegistryEntry { + Callable(StylexCallable), + Value(JsValue), +} + +/// Outcome of the babel-`resolve()` walk over declarator inits. +enum Resolved<'a> { + Override(EvalValue), + Expr(&'a Expression<'a>), + Identifier(&'a IdentifierReference<'a>), + /// Declarator without an initializer (babel: null init path). + NullInit, + /// A non-init declaration node, named as babel would report it. + DeclNode(&'static str, Span), +} + +impl Resolved<'_> { + fn span(&self) -> Option { + match self { + Resolved::Expr(expr) => Some(expr.span()), + Resolved::Identifier(id) => Some(id.span), + Resolved::DeclNode(_, span) => Some(*span), + Resolved::Override(_) | Resolved::NullInit => None, + } + } +} + +/// The `FunctionConfig` equivalent the visitor hands the evaluator. +// parity: stylex-create.js:171-206 + state-manager.js applyStylexEnv +#[derive(Debug, Clone, Default)] +pub struct FunctionRegistry { + identifiers: BTreeMap, + member_callables: BTreeMap<(String, String), StylexCallable>, +} + +fn when_object() -> JsValue { + let mut obj = JsObj::default(); + for (name, callable) in WHEN_MEMBERS { + obj.insert( + name.to_string(), + JsValue::Callable(Callable::Stylex(callable)), + ); + } + JsValue::object(obj) +} + +impl FunctionRegistry { + /// FunctionConfig of visitors/stylex-keyframes.js: firstThatWorks + env only. + pub fn for_keyframes(imports: &ImportTable, env: &EvalValue) -> Self { + let mut registry = Self::restricted_base(imports, env); + for (local, binding) in &imports.named { + if *binding == StylexNamedImport::FirstThatWorks { + registry.identifiers.insert( + local.clone(), + RegistryEntry::Callable(StylexCallable::FirstThatWorks), + ); + } + } + for namespace in &imports.stylex_namespaces { + registry.member_callables.insert( + (namespace.clone(), "firstThatWorks".to_string()), + StylexCallable::FirstThatWorks, + ); + } + registry + } + + /// FunctionConfig of visitors/stylex-props.js: defaultMarker + env only. + pub fn for_props(imports: &ImportTable, env: &EvalValue) -> Self { + let mut registry = Self::restricted_base(imports, env); + for (local, binding) in &imports.named { + if *binding == StylexNamedImport::DefaultMarker { + registry.identifiers.insert( + local.clone(), + RegistryEntry::Callable(StylexCallable::DefaultMarker), + ); + } + } + for namespace in &imports.stylex_namespaces { + registry.member_callables.insert( + (namespace.clone(), "defaultMarker".to_string()), + StylexCallable::DefaultMarker, + ); + } + registry + } + + /// FunctionConfig of visitors/stylex-define-consts.js: env only. + pub fn for_consts(imports: &ImportTable, env: &EvalValue) -> Self { + Self::restricted_base(imports, env) + } + + /// FunctionConfig of visitors/stylex-view-transition-class.js: + /// firstThatWorks + keyframes + env. + pub fn for_view_transition(imports: &ImportTable, env: &EvalValue) -> Self { + let mut registry = Self::for_keyframes(imports, env); + for (local, binding) in &imports.named { + if *binding == StylexNamedImport::Keyframes { + registry.identifiers.insert( + local.clone(), + RegistryEntry::Callable(StylexCallable::Keyframes), + ); + } + } + for namespace in &imports.stylex_namespaces { + registry.member_callables.insert( + (namespace.clone(), "keyframes".to_string()), + StylexCallable::Keyframes, + ); + } + registry + } + + fn restricted_base(imports: &ImportTable, env: &EvalValue) -> Self { + let mut registry = FunctionRegistry::default(); + for (local, binding) in &imports.named { + if *binding == StylexNamedImport::Env { + registry + .identifiers + .insert(local.clone(), RegistryEntry::Value(from_eval_value(env))); + } + } + for namespace in &imports.stylex_namespaces { + let mut obj = JsObj::default(); + obj.insert("env".to_string(), from_eval_value(env)); + registry.identifiers.insert( + namespace.clone(), + RegistryEntry::Value(JsValue::object(obj)), + ); + } + registry + } + + pub fn for_create(imports: &ImportTable, env: &EvalValue) -> Self { + let mut registry = FunctionRegistry::default(); + for (local, binding) in &imports.named { + let entry = match binding { + StylexNamedImport::FirstThatWorks => { + RegistryEntry::Callable(StylexCallable::FirstThatWorks) + } + StylexNamedImport::Keyframes => RegistryEntry::Callable(StylexCallable::Keyframes), + StylexNamedImport::PositionTry => { + RegistryEntry::Callable(StylexCallable::PositionTry) + } + StylexNamedImport::DefaultMarker => { + RegistryEntry::Callable(StylexCallable::DefaultMarker) + } + StylexNamedImport::When => RegistryEntry::Value(when_object()), + StylexNamedImport::Env => RegistryEntry::Value(from_eval_value(env)), + _ => continue, + }; + registry.identifiers.insert(local.clone(), entry); + } + for namespace in &imports.stylex_namespaces { + let mut obj = JsObj::default(); + obj.insert("when".to_string(), when_object()); + obj.insert("env".to_string(), from_eval_value(env)); + registry.identifiers.insert( + namespace.clone(), + RegistryEntry::Value(JsValue::object(obj)), + ); + for (name, callable) in [ + ("firstThatWorks", StylexCallable::FirstThatWorks), + ("keyframes", StylexCallable::Keyframes), + ("positionTry", StylexCallable::PositionTry), + ("defaultMarker", StylexCallable::DefaultMarker), + ] { + registry + .member_callables + .insert((namespace.clone(), name.to_string()), callable); + } + } + registry + } +} + +pub struct Evaluator<'a, 'env> { + pub state: &'env mut CompileState<'a>, + pub fs: &'env dyn FsProvider, + pub callables: &'env dyn EvalCallables, + registry: FunctionRegistry, + /// parity: FunctionConfig.disableImports — named-import resolution off. + disable_imports: bool, + arrow_bodies: BTreeMap, Vec)>, + /// Off by default: the transform path still hard-errors on dynamic styles. + dynamic_namespaces: bool, + depth: u32, + /// parity: evaluateCached `seen` — per-`evaluate()` node cache, giving + /// same-binding references the same (Rc-identical) value. + seen: FxHashMap<(u32, u32), SeenEntry>, +} + +#[derive(Debug, Clone)] +enum SeenEntry { + InFlight, + Value(JsValue), +} + +macro_rules! value_or_return { + ($self:expr, $expr:expr) => { + match $self.eval($expr)? { + EvalOutcome::Value(v) => v, + deopt => return Ok(deopt), + } + }; +} + +impl<'a, 'env> Evaluator<'a, 'env> { + pub fn for_create( + state: &'env mut CompileState<'a>, + fs: &'env dyn FsProvider, + callables: &'env dyn EvalCallables, + ) -> Self { + let registry = FunctionRegistry::for_create(&state.imports, &state.options.env); + Evaluator { + state, + fs, + callables, + registry, + disable_imports: false, + arrow_bodies: BTreeMap::new(), + dynamic_namespaces: false, + depth: 0, + seen: FxHashMap::default(), + } + } + + /// Enables the parse-stylex-create-arg.js arrow-namespace path. + pub fn allow_dynamic_namespaces(&mut self) { + self.dynamic_namespaces = true; + } + + pub fn with_registry( + state: &'env mut CompileState<'a>, + fs: &'env dyn FsProvider, + callables: &'env dyn EvalCallables, + registry: FunctionRegistry, + disable_imports: bool, + ) -> Self { + Evaluator { + state, + fs, + callables, + registry, + disable_imports, + arrow_bodies: BTreeMap::new(), + dynamic_namespaces: false, + depth: 0, + seen: FxHashMap::default(), + } + } + + fn deopt(&self, span: Span, reason: impl Into) -> EvalResult { + Ok(EvalOutcome::NonStatic(Deopt { + reason: reason.into(), + span, + })) + } + + fn value(&self, value: JsValue) -> EvalResult { + Ok(EvalOutcome::Value(value)) + } + + /// A fresh top-level `evaluate()` call (fresh `seen`, as upstream defaults). + pub fn eval_entry(&mut self, expr: &'a Expression<'a>) -> EvalResult { + let _t = crate::timings::start(crate::timings::Stage::Eval); + self.seen.clear(); + self.eval(expr) + } + + /// parity: nested `evaluate(...)` without the shared seen map (array + /// elements, call-object fallback, arrow-closure bodies). + fn eval_fresh(&mut self, expr: &'a Expression<'a>) -> EvalResult { + let saved = std::mem::take(&mut self.seen); + let result = self.eval(expr); + self.seen = saved; + result + } + + // parity: evaluateCached — value hits replay (preserving identity), and a + // node already in flight deopts with literally "Currently evaluating". + pub fn eval(&mut self, expr: &'a Expression<'a>) -> EvalResult { + let span = expr.span(); + self.eval_cached(span, |ev| ev.eval_inner(expr)) + } + + fn eval_cached( + &mut self, + span: Span, + eval_fn: impl FnOnce(&mut Self) -> EvalResult, + ) -> EvalResult { + self.depth += 1; + if self.depth > 128 { + self.depth -= 1; + return Err(StylexError::new( + ErrorCode::NonStaticValue, + "StyleX evaluation exceeded the recursion limit.", + )); + } + let key = (span.start, span.end); + match self.seen.get(&key) { + Some(SeenEntry::InFlight) => { + self.depth -= 1; + return self.deopt(span, "Currently evaluating"); + } + Some(SeenEntry::Value(v)) => { + let v = v.clone(); + self.depth -= 1; + return self.value(v); + } + None => {} + } + self.seen.insert(key, SeenEntry::InFlight); + let result = eval_fn(self); + if let Ok(EvalOutcome::Value(v)) = &result { + self.seen.insert(key, SeenEntry::Value(v.clone())); + } + self.depth -= 1; + result + } + + fn eval_inner(&mut self, expr: &'a Expression<'a>) -> EvalResult { + match expr { + Expression::ParenthesizedExpression(e) => self.eval(&e.expression), + Expression::TSAsExpression(e) => self.eval(&e.expression), + Expression::TSSatisfiesExpression(e) => self.eval(&e.expression), + Expression::ArrowFunctionExpression(arrow) => { + let simple_params = arrow.params.rest.is_none(); + let expression_body = + !matches!(arrow.body, oxc_ast::ast::ArrowFunctionBody::FunctionBody(_)); + // parity: evaluate-path.js:369 builds a callable closure only + // for direct-identifier params; anything else deopts. + let strict: Option> = arrow + .params + .items + .iter() + .map(|p| match &p.pattern { + // Defaulted params are babel AssignmentPatterns: no closure. + oxc_ast::ast::BindingPattern::BindingIdentifier(id) + if p.initializer.is_none() => + { + Some(id.name.to_string()) + } + _ => None, + }) + .collect(); + if let (true, Some(params), Some(body)) = ( + simple_params && expression_body, + strict, + arrow.body.as_expression(), + ) { + self.arrow_bodies.insert(arrow.span.start, (body, params)); + self.value(JsValue::Callable(Callable::Arrow(arrow.span.start))) + } else { + self.deopt( + arrow.span, + errs::unsupported_expression("ArrowFunctionExpression"), + ) + } + } + Expression::SequenceExpression(seq) => match seq.expressions.last() { + Some(last) => self.eval(last), + None => self.deopt(seq.span, errs::PATH_WITHOUT_NODE), + }, + Expression::StringLiteral(lit) => { + if lit.lone_surrogates { + return Err(StylexError::lone_surrogate("a string literal")); + } + self.value(JsValue::Str(lit.value.to_string())) + } + Expression::NumericLiteral(lit) => self.value(JsValue::Num(lit.value)), + Expression::BooleanLiteral(lit) => self.value(JsValue::Bool(lit.value)), + Expression::NullLiteral(_) => self.value(JsValue::Null), + Expression::TemplateLiteral(tpl) => self.eval_template(tpl), + Expression::ConditionalExpression(cond) => { + let test = value_or_return!(self, &cond.test); + if truthy(&test) { + self.eval(&cond.consequent) + } else { + self.eval(&cond.alternate) + } + } + Expression::StaticMemberExpression(_) | Expression::ComputedMemberExpression(_) => { + self.eval_member(expr) + } + Expression::Identifier(id) => self.eval_identifier(id), + Expression::UnaryExpression(unary) => self.eval_unary(unary), + Expression::ArrayExpression(array) => { + let mut out = Vec::with_capacity(array.elements.len()); + for element in &array.elements { + match element { + ArrayExpressionElement::Elision(elision) => { + return self.deopt(elision.span, errs::PATH_WITHOUT_NODE); + } + ArrayExpressionElement::SpreadElement(spread) => { + return self + .deopt(spread.span, errs::unsupported_expression("SpreadElement")); + } + _ => { + let expr = element.to_expression(); + match self.eval_fresh(expr)? { + EvalOutcome::Value(v) => out.push(v), + deopt => return Ok(deopt), + } + } + } + } + self.value(JsValue::array(out)) + } + Expression::ObjectExpression(object) => self.eval_object(object), + Expression::LogicalExpression(logical) => self.eval_logical(logical), + Expression::BinaryExpression(binary) => self.eval_binary(binary), + Expression::CallExpression(call) => self.eval_call(call), + Expression::ChainExpression(chain) => { + let name = match &chain.expression { + ChainElement::CallExpression(_) => "OptionalCallExpression", + ChainElement::TSNonNullExpression(_) => "TSNonNullExpression", + _ => "OptionalMemberExpression", + }; + self.deopt(chain.span, errs::unsupported_expression(name)) + } + other => self.deopt( + other.span(), + errs::unsupported_expression(babel_type_name(other)), + ), + } + } + + fn eval_template(&mut self, tpl: &'a TemplateLiteral<'a>) -> EvalResult { + let mut out = String::new(); + for (i, quasi) in tpl.quasis.iter().enumerate() { + if quasi.lone_surrogates { + return Err(StylexError::lone_surrogate("a template literal")); + } + match &quasi.value.cooked { + Some(cooked) => out.push_str(cooked), + None => out.push_str("undefined"), + } + if let Some(expr) = tpl.expressions.get(i) { + let value = value_or_return!(self, expr); + match js_to_string(&value) { + Some(s) => out.push_str(&s), + None => { + return self.deopt( + expr.span(), + errs::unsupported_expression(babel_type_name(expr)), + ); + } + } + } + } + self.value(JsValue::Str(out)) + } + + // parity: _evaluate isReferencedIdentifier branch — real scope resolution + // through oxc_semantic, then babel resolve() through declarator inits. + fn eval_identifier(&mut self, id: &'a IdentifierReference<'a>) -> EvalResult { + let name = id.name.as_str(); + let span = id.span; + if let Some(entry) = self.registry.identifiers.get(name) { + return self.value(match entry { + RegistryEntry::Callable(c) => JsValue::Callable(Callable::Stylex(c.clone())), + RegistryEntry::Value(v) => v.clone(), + }); + } + let Some(symbol) = self.state.symbol_of(id) else { + return match name { + "undefined" => self.value(JsValue::Undefined), + "Infinity" => self.value(JsValue::Num(f64::INFINITY)), + "NaN" => self.value(JsValue::Num(f64::NAN)), + _ => self.deopt(span, errs::UNDEFINED_CONST), + }; + }; + let info = self.state.binding_info(symbol); + if matches!(info.decl, BindingDecl::NamedImport) && !self.disable_imports { + return self.eval_named_import(name, span); + } + if matches!(info.decl, BindingDecl::DefaultImport) { + return self.deopt(span, errs::IMPORT_FILE_EVAL_ERROR); + } + if self.state.is_non_constant(symbol) || self.state.is_mutated(symbol) { + return self.deopt(span, errs::NON_CONSTANT); + } + if span.start < info.span.end { + return self.deopt(span, errs::USED_BEFORE_DECLARATION); + } + if matches!(name, "undefined" | "Infinity" | "NaN") { + return self.deopt(span, errs::UNINITIALIZED_CONST); + } + // babel resolve(): `binding.kind === 'module'` fails resolution. + if matches!( + info.decl, + BindingDecl::NamedImport | BindingDecl::DefaultImport | BindingDecl::NamespaceImport + ) { + return self.deopt(span, errs::UNDEFINED_CONST); + } + let mut visited: Vec<(u32, u32)> = vec![(span.start, span.end)]; + let resolved = self.resolve_symbol_decl(symbol, &mut visited); + if let Some(rspan) = resolved.span() + && (rspan == span || (rspan.start <= span.start && span.end <= rspan.end)) + { + // babel: `resolved === path` (or resolved is an ancestor). + return self.deopt(span, errs::UNDEFINED_CONST); + } + match resolved { + Resolved::Override(value) => self.value(from_eval_value(&value)), + Resolved::Expr(expr) => self.eval(expr), + Resolved::Identifier(other) => { + self.eval_cached(other.span, |ev| ev.eval_identifier(other)) + } + Resolved::NullInit => self.deopt(span, errs::PATH_WITHOUT_NODE), + Resolved::DeclNode(type_name, _) => { + self.deopt(span, errs::unsupported_expression(type_name)) + } + } + } + + fn eval_named_import(&mut self, name: &str, span: Span) -> EvalResult { + let Some(record) = self.state.imports.import_record(name).cloned() else { + return self.deopt(span, errs::UNDEFINED_CONST); + }; + let ImportedSymbol::Named(imported) = record.imported else { + return self.deopt(span, errs::UNDEFINED_CONST); + }; + match self.state.resolve_import_path(self.fs, &record.source) { + Some(canonical) => { + self.state.record_treeshake_import( + &record.source, + record.decl_span.start, + record.source_span, + ); + let proxy = VarGroupProxy::new(canonical, imported, self.state.options); + self.value(JsValue::proxy(proxy)) + } + None => self.deopt(span, errs::IMPORT_PATH_RESOLUTION_ERROR), + } + } + + // parity: babel path.resolve() — follows declarator inits through + // identifier chains with the `resolved` cycle stack and ancestor checks. + fn resolve_symbol_decl( + &mut self, + symbol: SymbolId, + visited: &mut Vec<(u32, u32)>, + ) -> Resolved<'a> { + let info = self.state.binding_info(symbol); + let decl_key = (info.span.start, info.span.end); + let terminal = |info: crate::state::BindingInfo<'a>| match info.decl { + BindingDecl::Declarator(_) => Resolved::DeclNode("VariableDeclarator", info.span), + BindingDecl::Opaque(type_name) => Resolved::DeclNode(type_name, info.span), + // Imports never reach here (kind "module" stops resolve earlier). + _ => Resolved::DeclNode("Identifier", info.span), + }; + if visited.contains(&decl_key) { + // babel: _resolve returns undefined on a revisit; the caller's + // `|| this` lands back on the declaration node itself. + return terminal(info); + } + visited.push(decl_key); + match info.decl { + BindingDecl::Declarator(declarator) => { + if declarator.id.get_binding_identifier().is_none() { + return terminal(info); + } + if let Some(value) = self.state.binding_override(symbol) { + return Resolved::Override(value.clone()); + } + match &declarator.init { + None => Resolved::NullInit, + Some(init) => self.resolve_expr(init, visited), + } + } + _ => terminal(info), + } + } + + fn resolve_expr( + &mut self, + expr: &'a Expression<'a>, + visited: &mut Vec<(u32, u32)>, + ) -> Resolved<'a> { + let span = expr.span(); + let key = (span.start, span.end); + if visited.contains(&key) { + return Resolved::Expr(expr); + } + visited.push(key); + let Expression::Identifier(id) = expr else { + return Resolved::Expr(expr); + }; + let stop = Resolved::Identifier(id); + let Some(symbol) = self.state.symbol_of(id) else { + return stop; + }; + // babel `binding.constant` counts violations only, not isMutated. + if self.state.is_non_constant(symbol) { + return stop; + } + let info = self.state.binding_info(symbol); + if matches!( + info.decl, + BindingDecl::NamedImport | BindingDecl::DefaultImport | BindingDecl::NamespaceImport + ) { + return stop; + } + let resolved = self.resolve_symbol_decl(symbol, visited); + // babel: a result that is (or contains) this reference fails this level. + if let Some(rspan) = resolved.span() + && rspan.start <= id.span.start + && id.span.end <= rspan.end + { + return stop; + } + resolved + } + + fn eval_member(&mut self, expr: &'a Expression<'a>) -> EvalResult { + // parity: evaluate-path.js getFullMemberPath — a ≥2-part static chain + // over a theme proxy resolves as one dotted key. + if let Some((base, parts)) = collect_member_chain(expr) + && parts.len() >= 2 + { + let base_value = value_or_return!(self, base); + if let JsValue::Proxy(proxy) = &base_value { + return self.value(JsValue::Str(proxy.resolve_key(&parts.join(".")))); + } + let mut current = base_value; + for (i, key) in parts.iter().enumerate() { + match self.member_lookup(¤t, key, expr.span())? { + EvalOutcome::Value(v) => current = v, + deopt => return Ok(deopt), + } + if let JsValue::Proxy(proxy) = ¤t + && i + 1 < parts.len() + { + let rest = parts[i + 1..].join("."); + return self.value(JsValue::Str(proxy.resolve_key(&rest))); + } + } + return self.value(current); + } + let (object_expr, key) = match expr { + Expression::StaticMemberExpression(member) => { + (&member.object, member.property.name.to_string()) + } + Expression::ComputedMemberExpression(member) => { + let key_value = value_or_return!(self, &member.expression); + match js_to_string(&key_value) { + Some(key) => (&member.object, key), + None => { + return self + .deopt(member.expression.span(), errs::UNEXPECTED_MEMBER_LOOKUP); + } + } + } + _ => unreachable!("eval_member only receives member expressions"), + }; + let object = value_or_return!(self, object_expr); + self.member_lookup(&object, &key, expr.span()) + } + + fn member_lookup(&mut self, object: &JsValue, key: &str, span: Span) -> EvalResult { + match object { + JsValue::Proxy(proxy) => self.value(match key { + "__IS_PROXY" => JsValue::Bool(true), + "__varGroupHash__" => JsValue::Str(proxy.var_group_hash.clone()), + "toString" => JsValue::Callable(Callable::Opaque), + _ => JsValue::Str(proxy.resolve_key(key)), + }), + JsValue::Obj(obj) => self.value(obj.get(key).cloned().unwrap_or(JsValue::Undefined)), + JsValue::Arr(items) => self.value(match key { + "length" => JsValue::Num(items.len() as f64), + _ => array_index(key) + .and_then(|i| items.get(i as usize)) + .cloned() + .unwrap_or(JsValue::Undefined), + }), + JsValue::Str(s) => { + let value = match key { + "length" => JsValue::Num(s.encode_utf16().count() as f64), + _ => match array_index(key) { + Some(i) if (i as usize) < s.encode_utf16().count() => JsValue::Str( + crate::jsrt::js_slice_utf16_checked(s, i as usize, i as isize + 1) + .map_err(|_| StylexError::lone_surrogate("string indexing"))?, + ), + _ => JsValue::Undefined, + }, + }; + self.value(value) + } + JsValue::Num(_) | JsValue::Bool(_) | JsValue::Callable(_) => { + self.value(JsValue::Undefined) + } + JsValue::Null | JsValue::Undefined => { + let kind = if matches!(object, JsValue::Null) { + "null" + } else { + "undefined" + }; + let _ = span; + // parity: upstream lets the raw TypeError escape the plugin. + Err(StylexError::new( + ErrorCode::NonStaticValue, + format!("Cannot read properties of {kind} (reading '{key}')"), + )) + } + } + } + + fn eval_unary(&mut self, unary: &'a UnaryExpression<'a>) -> EvalResult { + if unary.operator == UnaryOperator::Void { + return self.value(JsValue::Undefined); + } + if unary.operator == UnaryOperator::Typeof + && matches!( + unary.argument, + Expression::FunctionExpression(_) + | Expression::ArrowFunctionExpression(_) + | Expression::ClassExpression(_) + ) + { + return self.value(JsValue::Str("function".to_string())); + } + let arg = value_or_return!(self, &unary.argument); + match unary.operator { + UnaryOperator::LogicalNot => self.value(JsValue::Bool(!truthy(&arg))), + UnaryOperator::UnaryPlus => self.value(JsValue::Num(js_to_number(&arg))), + UnaryOperator::UnaryNegation => self.value(JsValue::Num(-js_to_number(&arg))), + UnaryOperator::BitwiseNot => { + self.value(JsValue::Num(f64::from(!to_int32(js_to_number(&arg))))) + } + UnaryOperator::Typeof => self.value(JsValue::Str( + match arg { + JsValue::Undefined => "undefined", + JsValue::Null | JsValue::Arr(_) | JsValue::Obj(_) | JsValue::Proxy(_) => { + "object" + } + JsValue::Bool(_) => "boolean", + JsValue::Num(_) => "number", + JsValue::Str(_) => "string", + JsValue::Callable(_) => "function", + } + .to_string(), + )), + UnaryOperator::Void => unreachable!("handled above"), + UnaryOperator::Delete => self.deopt( + unary.span, + errs::unsupported_operator(unary.operator.as_str()), + ), + } + } + + fn eval_object(&mut self, object: &'a ObjectExpression<'a>) -> EvalResult { + let mut obj = JsObj::default(); + for property in &object.properties { + match property { + ObjectPropertyKind::SpreadProperty(spread) => { + let value = value_or_return!(self, &spread.argument); + spread_into(&mut obj, &value); + } + ObjectPropertyKind::ObjectProperty(prop) => { + if prop.method || prop.kind != PropertyKind::Init { + return self.deopt(prop.span, errs::OBJECT_METHOD); + } + let key = if prop.computed { + let value = value_or_return!( + self, + prop.key + .as_expression() + .expect("computed keys are expressions",) + ); + match js_to_string(&value) { + Some(key) => key, + None => { + return self.deopt(prop.span, errs::UNEXPECTED_MEMBER_LOOKUP); + } + } + } else { + match static_property_key(&prop.key) { + Some(key) => key, + None => { + return self.deopt(prop.span, errs::UNEXPECTED_MEMBER_LOOKUP); + } + } + }; + let value = value_or_return!(self, &prop.value); + obj.insert(key, value); + } + } + } + self.value(JsValue::object(obj)) + } + + // parity: evaluate-path.js isLogicalExpression — both sides evaluate with + // fresh confidence; `0 ?? x` deopts with literally "unknown error". + fn eval_logical(&mut self, logical: &'a LogicalExpression<'a>) -> EvalResult { + let left = self.eval(&logical.left)?; + let right = self.eval(&logical.right)?; + if let EvalOutcome::Value(l) = &left { + match logical.operator { + LogicalOperator::Or => { + if truthy(l) { + return Ok(EvalOutcome::Value(l.clone())); + } + if let EvalOutcome::Value(r) = &right { + return Ok(EvalOutcome::Value(r.clone())); + } + } + LogicalOperator::And => { + if !truthy(l) { + return Ok(EvalOutcome::Value(l.clone())); + } + if let EvalOutcome::Value(r) = &right { + return Ok(EvalOutcome::Value(r.clone())); + } + } + LogicalOperator::Coalesce => { + if !is_nullish(l) { + if truthy(l) { + return Ok(EvalOutcome::Value(l.clone())); + } + // Non-nullish falsy left: upstream falls to "unknown error". + } else if let EvalOutcome::Value(r) = &right { + return Ok(EvalOutcome::Value(r.clone())); + } + } + } + } + match left { + EvalOutcome::NonStatic(d) => Ok(EvalOutcome::NonStatic(d)), + _ => match right { + EvalOutcome::NonStatic(d) => Ok(EvalOutcome::NonStatic(d)), + _ => self.deopt(logical.span, "unknown error"), + }, + } + } + + fn eval_binary(&mut self, binary: &'a BinaryExpression<'a>) -> EvalResult { + let left = value_or_return!(self, &binary.left); + let right = value_or_return!(self, &binary.right); + let num = + |f: fn(f64, f64) -> f64| JsValue::Num(f(js_to_number(&left), js_to_number(&right))); + let int = |f: fn(i32, i32) -> i32| { + JsValue::Num(f64::from(f( + to_int32(js_to_number(&left)), + to_int32(js_to_number(&right)), + ))) + }; + let cmp = |wanted: std::cmp::Ordering, or_equal: bool| -> JsValue { + JsValue::Bool(match js_compare(&left, &right) { + Some(ordering) => { + ordering == wanted || (or_equal && ordering == std::cmp::Ordering::Equal) + } + None => false, + }) + }; + use std::cmp::Ordering::{Greater, Less}; + let value = match binary.operator { + BinaryOperator::Addition => match js_add(&left, &right) { + Some(v) => v, + None => { + return self.deopt( + binary.span, + errs::unsupported_expression("BinaryExpression"), + ); + } + }, + BinaryOperator::Subtraction => num(|l, r| l - r), + BinaryOperator::Multiplication => num(|l, r| l * r), + BinaryOperator::Division => num(|l, r| l / r), + BinaryOperator::Remainder => num(|l, r| l % r), + BinaryOperator::Exponential => num(js_pow), + BinaryOperator::LessThan => cmp(Less, false), + BinaryOperator::LessEqualThan => cmp(Less, true), + BinaryOperator::GreaterThan => cmp(Greater, false), + BinaryOperator::GreaterEqualThan => cmp(Greater, true), + BinaryOperator::Equality => JsValue::Bool(js_loose_eq(&left, &right)), + // parity: evaluate-path.js:919 — `!=` is (mistakenly) strict upstream. + BinaryOperator::Inequality => JsValue::Bool(!js_strict_eq(&left, &right)), + BinaryOperator::StrictEquality => JsValue::Bool(js_strict_eq(&left, &right)), + BinaryOperator::StrictInequality => JsValue::Bool(!js_strict_eq(&left, &right)), + BinaryOperator::BitwiseOR => int(|l, r| l | r), + BinaryOperator::BitwiseAnd => int(|l, r| l & r), + BinaryOperator::BitwiseXOR => int(|l, r| l ^ r), + BinaryOperator::ShiftLeft => int(|l, r| l.wrapping_shl(r as u32 & 31)), + BinaryOperator::ShiftRight => int(|l, r| l.wrapping_shr(r as u32 & 31)), + BinaryOperator::ShiftRightZeroFill => { + let l = to_uint32(js_to_number(&left)); + let r = to_uint32(js_to_number(&right)) & 31; + JsValue::Num(f64::from(l >> r)) + } + BinaryOperator::In => { + let key = match js_to_string(&left) { + Some(k) => k, + None => { + return self.deopt( + binary.span, + errs::unsupported_expression("BinaryExpression"), + ); + } + }; + match &right { + JsValue::Obj(obj) => JsValue::Bool(obj.get(&key).is_some()), + JsValue::Arr(items) => JsValue::Bool( + key == "length" + || array_index(&key).is_some_and(|i| (i as usize) < items.len()), + ), + JsValue::Proxy(_) => JsValue::Bool(false), + _ => { + return Err(StylexError::new( + ErrorCode::NonStaticValue, + format!( + "Cannot use 'in' operator to search for '{key}' in {}", + js_to_string(&right).unwrap_or_default() + ), + )); + } + } + } + BinaryOperator::Instanceof => match &right { + JsValue::Callable(_) => JsValue::Bool(false), + _ => { + return Err(StylexError::new( + ErrorCode::NonStaticValue, + "Right-hand side of 'instanceof' is not callable", + )); + } + }, + }; + self.value(value) + } + + fn eval_call(&mut self, call: &'a CallExpression<'a>) -> EvalResult { + if call.optional { + return self.deopt( + call.span, + errs::unsupported_expression("OptionalCallExpression"), + ); + } + let callee = unwrap_parens(&call.callee); + if let Expression::Identifier(id) = callee { + let name = id.name.as_str(); + // parity: `!path.scope.getBinding(name) && isValidCallee(name)`. + if self.state.symbol_of(id).is_none() && methods::is_valid_callee(name) { + let args = match self.eval_arguments_raw(call)? { + Ok(args) => args, + Err(deopt) => return Ok(EvalOutcome::NonStatic(deopt)), + }; + return match methods::call_global_identifier(name, &args)? { + Some(value) => self.value(value), + None => self.deopt(call.span, errs::unsupported_expression("CallExpression")), + }; + } + if let Some(entry) = self.registry.identifiers.get(name).cloned() { + return match entry { + RegistryEntry::Callable(callable) => self.dispatch(callable, call), + RegistryEntry::Value(value) => self.invoke_value(&value, call), + }; + } + return match self.eval_cached(id.span, |ev| ev.eval_identifier(id))? { + EvalOutcome::Value(value) => self.invoke_value(&value, call), + deopt => Ok(deopt), + }; + } + if let Some(member) = callee.as_member_expression() { + // babel property extraction: static identifiers, computed + // identifiers BY NAME, and computed string literals. + let (prop_static, prop_string): (Option<&str>, Option<&str>) = match member { + oxc_ast::ast::MemberExpression::StaticMemberExpression(m) => { + (Some(m.property.name.as_str()), None) + } + oxc_ast::ast::MemberExpression::ComputedMemberExpression(m) => { + match &m.expression { + Expression::Identifier(prop) => (Some(prop.name.as_str()), None), + Expression::StringLiteral(lit) => (None, Some(lit.value.as_str())), + _ => (None, None), + } + } + oxc_ast::ast::MemberExpression::PrivateFieldExpression(_) => (None, None), + }; + if let Expression::Identifier(object) = member.object() { + let object_name = object.name.as_str(); + if let Some(prop) = prop_static { + // parity: no binding check — a shadowed `Math` still hits + // the global for known methods. + if methods::is_valid_callee(object_name) && !methods::is_invalid_method(prop) { + match methods::lookup_global_static(object_name, prop) { + methods::StaticMember::Fn(found) => { + let args = match self.eval_arguments_raw(call)? { + Ok(args) => args, + Err(deopt) => return Ok(EvalOutcome::NonStatic(deopt)), + }; + let result = + methods::call_global_static(self, object_name, found, &args)?; + return self.value(result); + } + methods::StaticMember::NonCallable => { + return self.func_apply_error(call); + } + methods::StaticMember::Unsupported => { + return Err(StylexError::unsupported_api(&format!( + "compile-time call to {object_name}.{prop}" + ))); + } + methods::StaticMember::Unknown => {} + } + } + } + if let Some(prop) = prop_static.or(prop_string) + && let Some(callable) = self + .registry + .member_callables + .get(&(object_name.to_string(), prop.to_string())) + .cloned() + { + return self.dispatch(callable, call); + } + } + // parity: numeric-literal receivers bind no `this`; known Number + // methods throw upstream before the value fallback runs. + if let Expression::NumericLiteral(_) = unwrap_parens(member.object()) + && let Some(prop) = prop_static + && let Some(known) = methods::NUM_METHODS + .iter() + .chain(methods::NUM_UNSUPPORTED.iter()) + .find(|m| **m == prop) + { + if let Err(deopt) = self.eval_arguments_raw(call)? { + return Ok(EvalOutcome::NonStatic(deopt)); + } + return Err(StylexError::new( + ErrorCode::NonStaticValue, + format!("Number.prototype.{known} requires that 'this' be a Number"), + )); + } + let Some(property) = prop_static.or(prop_string).map(str::to_string) else { + return self.deopt(call.span, errs::unsupported_expression("CallExpression")); + }; + // parity: evaluate-path.js:1017-1031 — the object is evaluated in a + // fresh state, so its deopt reason never escapes this branch. + let object_value = match self.eval_fresh(member.object())? { + EvalOutcome::Value(v) => v, + EvalOutcome::NonStatic(_) => { + return self.deopt(call.span, errs::unsupported_expression("CallExpression")); + } + }; + if let JsValue::Proxy(proxy) = &object_value + && property == "toString" + { + let hash = proxy.var_group_hash.clone(); + return match self.eval_arguments(call)? { + Ok(_) => self.value(JsValue::Str(hash)), + Err(deopt) => Ok(EvalOutcome::NonStatic(deopt)), + }; + } + return self.invoke_member(&object_value, &property, call); + } + self.deopt(call.span, errs::unsupported_expression("CallExpression")) + } + + // parity: the `if (func)` tail — arrows/callables run, truthy non-functions + // throw `func.apply is not a function`, falsy values fall to the deopt. + fn invoke_value(&mut self, value: &JsValue, call: &'a CallExpression<'a>) -> EvalResult { + match value { + JsValue::Callable(Callable::Stylex(callable)) => self.dispatch(callable.clone(), call), + JsValue::Callable(Callable::Arrow(key)) => self.eval_arrow_call(*key, call), + other if truthy(other) => self.func_apply_error(call), + _ => self.deopt(call.span, errs::unsupported_expression("CallExpression")), + } + } + + fn func_apply_error(&mut self, call: &'a CallExpression<'a>) -> EvalResult { + if let Err(deopt) = self.eval_arguments_raw(call)? { + return Ok(EvalOutcome::NonStatic(deopt)); + } + Err(StylexError::new( + ErrorCode::NonStaticValue, + "func.apply is not a function", + )) + } + + fn invoke_member( + &mut self, + object_value: &JsValue, + property: &str, + call: &'a CallExpression<'a>, + ) -> EvalResult { + // Own layer first (a JS property read), then the prototype tables. + let own = match object_value { + JsValue::Obj(obj) => obj.get(property).cloned(), + _ => match self.member_lookup(object_value, property, call.span)? { + EvalOutcome::Value(JsValue::Undefined) => None, + EvalOutcome::Value(v) => Some(v), + deopt => return Ok(deopt), + }, + }; + if let Some(found) = own { + return self.invoke_value(&found, call); + } + match methods::lookup_proto_method(object_value, property) { + methods::ProtoLookup::Fn(found) => { + let args = match self.eval_arguments_raw(call)? { + Ok(args) => args, + Err(deopt) => return Ok(EvalOutcome::NonStatic(deopt)), + }; + let receiver = object_value.clone(); + let result = methods::call_proto_method(self, &receiver, found, &args)?; + self.value(result) + } + methods::ProtoLookup::Unsupported(found) => Err(StylexError::unsupported_api( + &format!("compile-time call to {found}"), + )), + methods::ProtoLookup::NotFound => { + self.deopt(call.span, errs::unsupported_expression("CallExpression")) + } + } + } + + // parity: the evaluate-path closure — args bind as identifiers, and a + // non-confident body THROWS the deopt reason out of the whole transform. + fn eval_arrow_call(&mut self, key: u32, call: &'a CallExpression<'a>) -> EvalResult { + if !self.arrow_bodies.contains_key(&key) { + return self.deopt(call.span, errs::unsupported_expression("CallExpression")); + } + let args = match self.eval_arguments_raw(call)? { + Ok(args) => args, + Err(deopt) => return Ok(EvalOutcome::NonStatic(deopt)), + }; + let value = self.call_arrow_values(key, args)?; + self.value(value) + } + + /// The evaluatedFn closure body: args bind as registry identifiers over a + /// fresh evaluate() call, and a non-confident body THROWS its reason. + fn call_arrow_values(&mut self, key: u32, args: Vec) -> Result { + let Some((body, params)) = self.arrow_bodies.get(&key).map(|(b, p)| (*b, p.clone())) else { + return Err(StylexError::new( + ErrorCode::NonStaticValue, + errs::unsupported_expression("CallExpression"), + )); + }; + let mut saved: Vec<(String, Option)> = Vec::with_capacity(params.len()); + for (i, name) in params.iter().enumerate() { + let value = args.get(i).cloned().unwrap_or(JsValue::Undefined); + let previous = self + .registry + .identifiers + .insert(name.clone(), RegistryEntry::Value(value)); + saved.push((name.clone(), previous)); + } + let result = self.eval_fresh(body); + for (name, previous) in saved.into_iter().rev() { + match previous { + Some(entry) => { + self.registry.identifiers.insert(name, entry); + } + None => { + self.registry.identifiers.remove(&name); + } + } + } + match result? { + EvalOutcome::Value(v) => Ok(v), + EvalOutcome::NonStatic(d) => Err(StylexError::new(ErrorCode::NonStaticValue, d.reason)), + } + } + + fn dispatch(&mut self, callable: StylexCallable, call: &'a CallExpression<'a>) -> EvalResult { + let raw = match self.eval_arguments_raw(call)? { + Ok(args) => args, + Err(deopt) => return Ok(EvalOutcome::NonStatic(deopt)), + }; + // parity: when.js fromProxy — only the when.* fns read a theme proxy, + // via its toString() (the var-group hash). + let is_when = matches!( + callable, + StylexCallable::WhenAncestor + | StylexCallable::WhenDescendant + | StylexCallable::WhenSiblingBefore + | StylexCallable::WhenSiblingAfter + | StylexCallable::WhenAnySibling + ); + let args: Vec = raw + .iter() + .map(|value| match value { + JsValue::Proxy(proxy) if is_when => EvalValue::Str(proxy.var_group_hash.clone()), + other => to_eval_value(other), + }) + .collect(); + let result = self.callables.call(&callable, &args, self.state)?; + self.value(from_eval_value(&result)) + } + + fn eval_arguments( + &mut self, + call: &'a CallExpression<'a>, + ) -> Result, Deopt>, StylexError> { + Ok(self + .eval_arguments_raw(call)? + .map(|args| args.iter().map(to_eval_value).collect())) + } + + fn eval_arguments_raw( + &mut self, + call: &'a CallExpression<'a>, + ) -> Result, Deopt>, StylexError> { + let mut out = Vec::with_capacity(call.arguments.len()); + for argument in &call.arguments { + match argument { + Argument::SpreadElement(spread) => { + return Ok(Err(Deopt { + reason: errs::unsupported_expression("SpreadElement"), + span: spread.span, + })); + } + _ => match self.eval(argument.to_expression())? { + EvalOutcome::Value(v) => out.push(v), + EvalOutcome::NonStatic(deopt) => return Ok(Err(deopt)), + }, + } + } + Ok(Ok(out)) + } +} + +impl ArrowCaller for Evaluator<'_, '_> { + fn call_arrow(&mut self, key: u32, args: Vec) -> Result { + self.call_arrow_values(key, args) + } +} + +#[derive(Debug)] +pub struct EvaluatedCreateArg { + pub namespaces: JsObjectMap, + /// Source byte offset of each namespace property (first occurrence wins); + /// the visitor derives `CreateContext.namespace_lines` from these. + pub key_spans: Vec<(String, u32)>, + /// Arrow namespaces (`fns` upstream): namespace → params + inline styles. + pub fns: Vec<(String, DynamicFn)>, +} + +/// Evaluates one `stylex.create` argument into namespace objects. +// parity: parse-stylex-create-arg.js evaluateStyleXCreateArg (static namespaces) +pub fn evaluate_stylex_create_arg<'a>( + evaluator: &mut Evaluator<'a, '_>, + object: &'a ObjectExpression<'a>, +) -> Result { + let _t = crate::timings::start(crate::timings::Stage::Eval); + let mut namespaces = JsObjectMap::new(); + let mut key_spans: Vec<(String, u32)> = Vec::new(); + let mut fns: Vec<(String, DynamicFn)> = Vec::new(); + for property in &object.properties { + let prop = match property { + ObjectPropertyKind::ObjectProperty(prop) + if !prop.method && prop.kind == PropertyKind::Init => + { + prop + } + // parity: non-plain properties re-evaluate the whole argument. + _ => return reevaluate_create_object(evaluator, object), + }; + let key = if prop.computed { + let key_expr = prop + .key + .as_expression() + .expect("computed keys are expressions"); + match evaluator.eval_entry(key_expr)? { + EvalOutcome::Value(v) => match js_to_string(&v) { + Some(key) => key, + None => return Err(StylexError::non_static_value("create")), + }, + // parity: evaluateObjKey drops the deopt reason for namespace keys. + EvalOutcome::NonStatic(_) => { + return Err(StylexError::non_static_value("create")); + } + } + } else { + static_property_key(&prop.key).ok_or_else(|| StylexError::non_static_value("create"))? + }; + if let Expression::ArrowFunctionExpression(arrow) = unwrap_parens(&prop.value) { + if !evaluator.dynamic_namespaces { + return Err(StylexError::unsupported_api( + "dynamic styles (arrow-function namespaces)", + )); + } + // Defaulted params live in `initializer` (babel: AssignmentPattern). + let params: Option> = if arrow.params.rest.is_some() { + None + } else { + arrow + .params + .items + .iter() + .map(|p| match &p.pattern { + oxc_ast::ast::BindingPattern::BindingIdentifier(id) + if p.initializer.is_none() => + { + Some(id.name.to_string()) + } + _ => None, + }) + .collect() + }; + let Some(params) = params else { + return Err(StylexError::only_named_parameters()); + }; + // parity: only expression bodies that are object literals compile; + // everything else re-evaluates the whole argument. + let body = arrow.body.as_expression().map(unwrap_parens); + let Some(Expression::ObjectExpression(body_obj)) = body else { + return reevaluate_create_object(evaluator, object); + }; + let mut fn_def = DynamicFn { + params, + inline_styles: Vec::new(), + }; + let partial = evaluate_partial_object(evaluator, body_obj, &[], &mut fn_def); + let Some(value) = partial? else { + // parity: the arrow branch drops every partial-eval deopt reason. + return Err(StylexError::non_static_value("create")); + }; + if !key_spans.iter().any(|(k, _)| *k == key) { + key_spans.push((key.clone(), prop.span.start)); + } + namespaces.insert(key.clone(), to_eval_value(&JsValue::object(value))); + // parity: `fns[key] =` is a [[Set]]; "__proto__" never lands. + if key != "__proto__" { + match fns.iter_mut().find(|(k, _)| *k == key) { + Some(slot) => slot.1 = fn_def, + None => fns.push((key, fn_def)), + } + } + continue; + } + match evaluator.eval_entry(&prop.value)? { + EvalOutcome::Value(v) => { + if !key_spans.iter().any(|(k, _)| *k == key) { + key_spans.push((key.clone(), prop.span.start)); + } + namespaces.insert(key, to_eval_value(&v)); + } + EvalOutcome::NonStatic(deopt) => { + return Err(StylexError::new(ErrorCode::NonStaticValue, deopt.reason)); + } + } + } + Ok(EvaluatedCreateArg { + namespaces, + key_spans, + fns, + }) +} + +// parity: `return evaluate(path, …)` over the whole create argument. +fn reevaluate_create_object<'a>( + evaluator: &mut Evaluator<'a, '_>, + object: &'a ObjectExpression<'a>, +) -> Result { + evaluator.seen.clear(); + match evaluator.eval_object(object)? { + EvalOutcome::Value(v) => Ok(EvaluatedCreateArg { + namespaces: match to_eval_value(&v) { + EvalValue::Obj(map) => Arc::unwrap_or_clone(map), + _ => JsObjectMap::new(), + }, + key_spans: Vec::new(), + fns: Vec::new(), + }), + EvalOutcome::NonStatic(deopt) => { + Err(StylexError::new(ErrorCode::NonStaticValue, deopt.reason)) + } + } +} + +/// `None` = non-confident: the caller reports the reasonless create error. +// parity: parse-stylex-create-arg.js evaluatePartialObjectRecursively +fn evaluate_partial_object<'a>( + evaluator: &mut Evaluator<'a, '_>, + object: &'a ObjectExpression<'a>, + key_path: &[String], + fn_def: &mut DynamicFn, +) -> Result, StylexError> { + let mut obj = JsObj::default(); + for property in &object.properties { + match property { + ObjectPropertyKind::SpreadProperty(spread) => { + match evaluator.eval_entry(&spread.argument)? { + EvalOutcome::Value(v) => spread_into(&mut obj, &v), + EvalOutcome::NonStatic(_) => return Ok(None), + } + } + ObjectPropertyKind::ObjectProperty(prop) => { + if prop.method || prop.kind != PropertyKind::Init { + return Ok(None); + } + let mut key = if prop.computed { + let key_expr = prop + .key + .as_expression() + .expect("computed keys are expressions"); + match evaluator.eval_entry(key_expr)? { + EvalOutcome::Value(v) => match js_to_string(&v) { + Some(key) => key, + None => return Ok(None), + }, + EvalOutcome::NonStatic(_) => return Ok(None), + } + } else { + match static_property_key(&prop.key) { + Some(key) => key, + None => return Ok(None), + } + }; + // defineConsts at-rule placeholders stay wrapped past the root. + if key_path.is_empty() && key.starts_with("var(") && key.ends_with(')') { + key = js_slice_utf16(&key, 4, -1); + } + if let Expression::ObjectExpression(inner) = unwrap_parens(&prop.value) { + let mut nested = key_path.to_vec(); + nested.push(key.clone()); + match evaluate_partial_object(evaluator, inner, &nested, fn_def)? { + Some(inner_obj) => obj.insert(key, JsValue::object(inner_obj)), + None => return Ok(None), + } + } else { + match evaluator.eval_entry(&prop.value)? { + EvalOutcome::Value(v) => obj.insert(key, v), + EvalOutcome::NonStatic(_) => { + let (var_name, style) = + inline_style_for_leaf(&prop.value, key_path, &key); + obj.insert(key, JsValue::Str(format!("var({var_name})"))); + fn_def.insert_inline(var_name, style); + } + } + } + } + } + } + Ok(Some(obj)) +} + +// parity: stylex-create.js validateStyleXCreate (arg count / object / spread). +pub fn validate_create_arg<'a, 'b>( + call: &'b CallExpression<'a>, +) -> Result<&'b ObjectExpression<'a>, StylexError> { + if call.arguments.len() != 1 { + return Err(StylexError::illegal_argument_length("create", 1)); + } + let arg = call.arguments[0] + .as_expression() + .map(unwrap_parens) + .ok_or_else(|| StylexError::non_style_object("create"))?; + let Expression::ObjectExpression(object) = arg else { + return Err(StylexError::non_style_object("create")); + }; + if object + .properties + .iter() + .any(|p| matches!(p, ObjectPropertyKind::SpreadProperty(_))) + { + return Err(StylexError::no_object_spreads()); + } + Ok(object) +} + +pub fn unwrap_parens<'a, 'b>(expr: &'b Expression<'a>) -> &'b Expression<'a> { + match expr { + Expression::ParenthesizedExpression(e) => unwrap_parens(&e.expression), + _ => expr, + } +} + +fn collect_member_chain<'a, 'b>( + expr: &'b Expression<'a>, +) -> Option<(&'b Expression<'a>, Vec)> { + let mut parts: Vec = Vec::new(); + let mut current = expr; + loop { + match current { + Expression::StaticMemberExpression(member) => { + parts.push(member.property.name.to_string()); + current = &member.object; + } + Expression::ComputedMemberExpression(member) => { + match &member.expression { + Expression::StringLiteral(lit) => parts.push(lit.value.to_string()), + Expression::NumericLiteral(lit) => { + parts.push(js_number_to_string(lit.value)); + } + _ => return None, + } + current = &member.object; + } + _ => break, + } + } + if parts.len() < 2 { + return None; + } + parts.reverse(); + Some((current, parts)) +} + +fn static_property_key(key: &PropertyKey<'_>) -> Option { + match key { + PropertyKey::StaticIdentifier(id) => Some(id.name.to_string()), + PropertyKey::StringLiteral(lit) => Some(lit.value.to_string()), + PropertyKey::NumericLiteral(lit) => Some(js_number_to_string(lit.value)), + _ => None, + } +} + +// parity: Object.assign source-iteration; non-object primitives are no-ops. +fn spread_into(target: &mut JsObj, value: &JsValue) { + match value { + JsValue::Obj(obj) => { + for (k, v) in obj.entries() { + target.insert(k.to_string(), v.clone()); + } + } + JsValue::Arr(items) => { + for (i, item) in items.iter().enumerate() { + target.insert(i.to_string(), item.clone()); + } + } + JsValue::Str(s) => { + let units = s.encode_utf16().count(); + for i in 0..units { + let unit = js_slice_utf16_checked(s, i, i as isize + 1) + .unwrap_or_else(|_| '\u{FFFD}'.to_string()); + target.insert(i.to_string(), JsValue::Str(unit)); + } + } + _ => {} + } +} + +fn babel_type_name(expr: &Expression<'_>) -> &'static str { + match expr { + Expression::BigIntLiteral(_) => "BigIntLiteral", + Expression::RegExpLiteral(_) => "RegExpLiteral", + Expression::FunctionExpression(_) => "FunctionExpression", + Expression::ClassExpression(_) => "ClassExpression", + Expression::AssignmentExpression(_) => "AssignmentExpression", + Expression::AwaitExpression(_) => "AwaitExpression", + Expression::NewExpression(_) => "NewExpression", + Expression::TaggedTemplateExpression(_) => "TaggedTemplateExpression", + Expression::ThisExpression(_) => "ThisExpression", + Expression::UpdateExpression(_) => "UpdateExpression", + Expression::YieldExpression(_) => "YieldExpression", + Expression::ImportExpression(_) | Expression::CallExpression(_) => "CallExpression", + Expression::ImportMeta(_) | Expression::NewTarget(_) => "MetaProperty", + Expression::JSXElement(_) => "JSXElement", + Expression::JSXFragment(_) => "JSXFragment", + Expression::TSTypeAssertion(_) => "TSTypeAssertion", + Expression::TSNonNullExpression(_) => "TSNonNullExpression", + Expression::TSInstantiationExpression(_) => "TSInstantiationExpression", + Expression::PrivateInExpression(_) => "BinaryExpression", + Expression::ArrowFunctionExpression(_) => "ArrowFunctionExpression", + Expression::SequenceExpression(_) => "SequenceExpression", + Expression::Identifier(_) => "Identifier", + Expression::Super(_) => "Super", + Expression::PrivateFieldExpression(_) => "MemberExpression", + _ => "Expression", + } +} + +const VALID_CALLEES: [&str; 5] = ["String", "Number", "Math", "Object", "Array"]; +const INVALID_METHODS: [&str; 7] = [ + "random", + "assign", + "defineProperties", + "defineProperty", + "freeze", + "seal", + "splice", +]; + +fn is_valid_callee(name: &str) -> bool { + VALID_CALLEES.contains(&name) +} + +fn is_invalid_method(name: &str) -> bool { + INVALID_METHODS.contains(&name) +} + +pub fn truthy(value: &JsValue) -> bool { + match value { + JsValue::Null | JsValue::Undefined => false, + JsValue::Bool(b) => *b, + JsValue::Num(n) => *n != 0.0 && !n.is_nan(), + JsValue::Str(s) => !s.is_empty(), + _ => true, + } +} + +pub fn is_nullish(value: &JsValue) -> bool { + matches!(value, JsValue::Null | JsValue::Undefined) +} + +/// JS ToString; `None` for function values (their source text is unmodelled). +pub fn js_to_string(value: &JsValue) -> Option { + Some(match value { + JsValue::Null => "null".to_string(), + JsValue::Undefined => "undefined".to_string(), + JsValue::Bool(b) => b.to_string(), + JsValue::Num(n) => js_number_to_string(*n), + JsValue::Str(s) => s.clone(), + JsValue::Arr(items) => { + let parts: Vec = items + .iter() + .map(|item| { + if is_nullish(item) { + Some(String::new()) + } else { + js_to_string(item) + } + }) + .collect::>()?; + parts.join(",") + } + JsValue::Obj(_) => "[object Object]".to_string(), + JsValue::Proxy(proxy) => proxy.var_group_hash.clone(), + JsValue::Callable(_) => return None, + }) +} + +pub fn js_to_number(value: &JsValue) -> f64 { + match value { + JsValue::Null => 0.0, + JsValue::Undefined | JsValue::Callable(_) => f64::NAN, + JsValue::Bool(b) => f64::from(*b as u8), + JsValue::Num(n) => *n, + JsValue::Str(s) => string_to_number(s), + other => js_to_string(other).map_or(f64::NAN, |s| string_to_number(&s)), + } +} + +// parity: ES StringToNumber (TrimString; hex/octal/binary; empty string is 0). +pub fn string_to_number(s: &str) -> f64 { + let trimmed: &str = crate::jsrt::js_trim(s); + if trimmed.is_empty() { + return 0.0; + } + if let Some(rest) = trimmed + .strip_prefix("0x") + .or_else(|| trimmed.strip_prefix("0X")) + { + return u64::from_str_radix(rest, 16).map_or(f64::NAN, |n| n as f64); + } + if let Some(rest) = trimmed + .strip_prefix("0o") + .or_else(|| trimmed.strip_prefix("0O")) + { + return u64::from_str_radix(rest, 8).map_or(f64::NAN, |n| n as f64); + } + if let Some(rest) = trimmed + .strip_prefix("0b") + .or_else(|| trimmed.strip_prefix("0B")) + { + return u64::from_str_radix(rest, 2).map_or(f64::NAN, |n| n as f64); + } + match trimmed { + "Infinity" | "+Infinity" => return f64::INFINITY, + "-Infinity" => return f64::NEG_INFINITY, + _ => {} + } + if !trimmed + .bytes() + .all(|b| b.is_ascii_digit() || matches!(b, b'+' | b'-' | b'.' | b'e' | b'E')) + { + return f64::NAN; + } + trimmed.parse::().unwrap_or(f64::NAN) +} + +fn js_add(left: &JsValue, right: &JsValue) -> Option { + let string_side = |v: &JsValue| { + matches!( + v, + JsValue::Str(_) | JsValue::Arr(_) | JsValue::Obj(_) | JsValue::Proxy(_) + ) + }; + if string_side(left) || string_side(right) { + Some(JsValue::Str(format!( + "{}{}", + js_to_string(left)?, + js_to_string(right)? + ))) + } else if matches!(left, JsValue::Callable(_)) || matches!(right, JsValue::Callable(_)) { + None + } else { + Some(JsValue::Num(js_to_number(left) + js_to_number(right))) + } +} + +// parity: JS `**` — |1| ** ±Infinity is NaN where Rust powf returns 1. +pub(crate) fn js_pow(base: f64, exponent: f64) -> f64 { + if exponent.is_infinite() && base.abs() == 1.0 { + return f64::NAN; + } + base.powf(exponent) +} + +fn is_object_like(value: &JsValue) -> bool { + matches!( + value, + JsValue::Obj(_) | JsValue::Arr(_) | JsValue::Proxy(_) | JsValue::Callable(_) + ) +} + +/// ES OrdinaryToPrimitive for the evaluator's object values: plain objects +/// and arrays have no useful valueOf, so both hints reach toString. +fn js_to_primitive(value: &JsValue) -> Option { + if is_object_like(value) { + js_to_string(value).map(JsValue::Str) + } else { + Some(value.clone()) + } +} + +// parity: ES Abstract Relational Comparison (ToPrimitive first; strings +// compare by UTF-16 code units, everything else through ToNumber). +fn js_compare(left: &JsValue, right: &JsValue) -> Option { + let (left, right) = (js_to_primitive(left)?, js_to_primitive(right)?); + if let (JsValue::Str(l), JsValue::Str(r)) = (&left, &right) { + return Some(utf16_cmp(l, r)); + } + let l = js_to_number(&left); + let r = js_to_number(&right); + l.partial_cmp(&r) +} + +pub(crate) fn js_strict_eq(left: &JsValue, right: &JsValue) -> bool { + match (left, right) { + (JsValue::Null, JsValue::Null) | (JsValue::Undefined, JsValue::Undefined) => true, + (JsValue::Bool(l), JsValue::Bool(r)) => l == r, + (JsValue::Num(l), JsValue::Num(r)) => l == r, + (JsValue::Str(l), JsValue::Str(r)) => l == r, + (JsValue::Obj(l), JsValue::Obj(r)) => Rc::ptr_eq(l, r), + (JsValue::Arr(l), JsValue::Arr(r)) => Rc::ptr_eq(l, r), + (JsValue::Proxy(l), JsValue::Proxy(r)) => Rc::ptr_eq(l, r), + // A cached arrow closure keeps its node key; two closures from the + // same arrow in one evaluation are the same function upstream. + (JsValue::Callable(Callable::Arrow(l)), JsValue::Callable(Callable::Arrow(r))) => l == r, + _ => false, + } +} + +// parity: ES Abstract Equality Comparison. +fn js_loose_eq(left: &JsValue, right: &JsValue) -> bool { + match (left, right) { + (JsValue::Null | JsValue::Undefined, JsValue::Null | JsValue::Undefined) => true, + (JsValue::Null | JsValue::Undefined, _) | (_, JsValue::Null | JsValue::Undefined) => false, + _ if is_object_like(left) && is_object_like(right) => js_strict_eq(left, right), + _ if is_object_like(left) => match js_to_primitive(left) { + Some(prim) => js_loose_eq(&prim, right), + None => false, + }, + _ if is_object_like(right) => match js_to_primitive(right) { + Some(prim) => js_loose_eq(left, &prim), + None => false, + }, + (JsValue::Str(l), JsValue::Str(r)) => l == r, + _ => js_to_number(left) == js_to_number(right), + } +} + +// parity: ES ToInt32/ToUint32. +pub(crate) fn to_int32(n: f64) -> i32 { + to_uint32(n) as i32 +} + +pub(crate) fn to_uint32(n: f64) -> u32 { + if !n.is_finite() || n == 0.0 { + return 0; + } + let modulus = 2f64.powi(32); + let mut m = n.trunc() % modulus; + if m < 0.0 { + m += modulus; + } + m as u32 +} + +#[cfg(test)] +mod js_obj_tests { + use super::*; + + #[test] + fn wide_objects_keep_insertion_order_and_overwrite_position() { + let n = value::NAMED_INDEX_THRESHOLD * 3; + let mut obj = JsObj::default(); + for i in 0..n { + obj.insert(format!("key{i}"), JsValue::Num(i as f64)); + } + obj.insert("key5".to_string(), JsValue::Str("updated".to_string())); + obj.insert("late".to_string(), JsValue::Bool(true)); + let keys: Vec<&str> = obj.entries().map(|(k, _)| k).collect(); + let mut expected: Vec = (0..n).map(|i| format!("key{i}")).collect(); + expected.push("late".to_string()); + assert_eq!(keys, expected); + assert!(matches!(obj.get("key5"), Some(JsValue::Str(s)) if s == "updated")); + assert!(obj.get("missing").is_none()); + } +} + +#[cfg(test)] +mod coercion_lattice_tests { + use super::*; + + fn grid_value(index: usize) -> JsValue { + // Positionally mirrors VALUES in conformance/src/gen-pins-coercion.mjs. + let n = JsValue::Num; + let s = |v: &str| JsValue::Str(v.to_string()); + match index { + 0 => JsValue::Undefined, + 1 => JsValue::Null, + 2 => JsValue::Bool(true), + 3 => JsValue::Bool(false), + 4 => n(0.0), + 5 => n(-0.0), + 6 => n(1.0), + 7 => n(-1.0), + 8 => n(0.5), + 9 => n(f64::NAN), + 10 => n(f64::INFINITY), + 11 => n(f64::NEG_INFINITY), + 12 => s(""), + 13 => s("0"), + 14 => s("00"), + 15 => s("1"), + 16 => s("2"), + 17 => s("10"), + 18 => s("a"), + 19 => s(" "), + 20 => s("\u{0085}"), + 21 => s("Infinity"), + 22 => JsValue::array(vec![]), + 23 => JsValue::array(vec![n(0.0)]), + 24 => JsValue::array(vec![n(1.0)]), + 25 => JsValue::array(vec![JsValue::array(vec![])]), + 26 => JsValue::array(vec![s("2")]), + 27 => JsValue::array(vec![s("10")]), + 28 => JsValue::array(vec![JsValue::Null]), + 29 => JsValue::array(vec![JsValue::Undefined]), + 30 => JsValue::array(vec![n(1.0), n(2.0)]), + 31 => JsValue::array(vec![s("a"), s("b")]), + 32 => JsValue::object(JsObj::default()), + 33 => { + let mut obj = JsObj::default(); + obj.insert("a".to_string(), n(1.0)); + JsValue::object(obj) + } + _ => unreachable!("grid index"), + } + } + + #[test] + fn lattice_matches_node_pins() { + // Vendored copies of this crate ship without testdata; the pin gate + // runs wherever the conformance harness lives. + let Ok(raw) = std::fs::read_to_string(concat!( + env!("CARGO_MANIFEST_DIR"), + "/testdata/pins/eval/coercion.json" + )) else { + eprintln!("skipping lattice_matches_node_pins: testdata not vendored"); + return; + }; + let pins: serde_json::Value = serde_json::from_str(&raw).unwrap(); + let values = pins["values"].as_array().unwrap(); + assert_eq!(values.len(), 34, "grid size drifted; update grid_value"); + let mut checked = 0; + for row in pins["results"].as_array().unwrap() { + let i = row["i"].as_u64().unwrap() as usize; + let j = row["j"].as_u64().unwrap() as usize; + let label = |op: &str| format!("{} {op} {}", values[i], values[j]); + let (left, right) = (grid_value(i), grid_value(j)); + assert_eq!( + js_loose_eq(&left, &right), + row["=="].as_bool().unwrap(), + "{}", + label("==") + ); + assert_eq!( + js_strict_eq(&left, &right), + row["==="].as_bool().unwrap(), + "{}", + label("===") + ); + use std::cmp::Ordering::{Equal, Greater, Less}; + let cmp = js_compare(&left, &right); + assert_eq!( + cmp == Some(Less), + row["<"].as_bool().unwrap(), + "{}", + label("<") + ); + assert_eq!( + matches!(cmp, Some(Less | Equal)), + row["<="].as_bool().unwrap(), + "{}", + label("<=") + ); + assert_eq!( + cmp == Some(Greater), + row[">"].as_bool().unwrap(), + "{}", + label(">") + ); + assert_eq!( + matches!(cmp, Some(Greater | Equal)), + row[">="].as_bool().unwrap(), + "{}", + label(">=") + ); + let sum = js_add(&left, &right).expect("grid holds no function values"); + let (tag, expected) = ( + row["add"]["t"].as_str().unwrap(), + row["add"]["v"].as_str().unwrap(), + ); + match (tag, &sum) { + ("num", JsValue::Num(x)) => { + assert_eq!(js_number_to_string(*x), expected, "{}", label("+")); + } + ("str", JsValue::Str(x)) => assert_eq!(x, expected, "{}", label("+")), + other => panic!("{}: unexpected sum shape {other:?}", label("+")), + } + checked += 1; + } + assert_eq!(checked, 34 * 34); + } +} diff --git a/crates/stylex/src/eval/value.rs b/crates/stylex/src/eval/value.rs new file mode 100644 index 00000000..f561a82c --- /dev/null +++ b/crates/stylex/src/eval/value.rs @@ -0,0 +1,409 @@ +use crate::fxhash::FxHashMap; +use std::sync::Arc; + +use serde_json::Value; + +#[derive(Debug, Clone, PartialEq)] +pub enum EvalValue { + Null, + Undefined, + Bool(bool), + Num(f64), + Str(String), + Arr(Vec), + /// Rc-shared: clones are refcount bumps; mutation goes through make_mut. + Obj(Arc), +} + +impl EvalValue { + // parity: JSON.stringify — undefined props drop, undefined array elements + // and non-finite numbers become null. + pub fn to_json(&self) -> Value { + match self { + EvalValue::Null | EvalValue::Undefined => Value::Null, + EvalValue::Bool(b) => Value::Bool(*b), + EvalValue::Num(n) => num_to_json(*n), + EvalValue::Str(s) => Value::String(s.clone()), + EvalValue::Arr(items) => Value::Array(items.iter().map(EvalValue::to_json).collect()), + EvalValue::Obj(map) => map.to_json(), + } + } + + /// Object key order follows the `Value` map's own iteration order + /// (alphabetical without serde_json's `preserve_order`); index keys re-sort anyway. + pub fn from_json(value: &Value) -> Self { + match value { + Value::Null => EvalValue::Null, + Value::Bool(b) => EvalValue::Bool(*b), + Value::Number(n) => EvalValue::Num(n.as_f64().unwrap_or(f64::NAN)), + Value::String(s) => EvalValue::Str(s.clone()), + Value::Array(items) => EvalValue::Arr(items.iter().map(Self::from_json).collect()), + Value::Object(map) => { + let mut obj = JsObjectMap::new(); + for (k, v) in map { + obj.insert(k.clone(), Self::from_json(v)); + } + EvalValue::Obj(Arc::new(obj)) + } + } + } +} + +fn num_to_json(n: f64) -> Value { + if !n.is_finite() { + return Value::Null; + } + if n.fract() == 0.0 && n.abs() < (i64::MAX as f64) { + return Value::Number((n as i64).into()); + } + serde_json::Number::from_f64(n).map_or(Value::Null, Value::Number) +} + +/// String-keyed map iterating in ES OwnPropertyKeys order: canonical array-index +/// keys ascending first, then the remaining keys in insertion order. +#[derive(Clone, Default)] +pub struct JsObjectMap { + index_entries: Vec<(u32, String, EvalValue)>, + named_entries: Vec<(String, EvalValue)>, + /// Lazy key→position table so wide objects avoid quadratic scans. + // Boxed to keep the usually-None field one word; the map sits inline in EvalValue. + #[allow(clippy::box_collection)] + named_index: Option>>, + /// CSSType `instanceof` brand (the syntax) — representation, not data: + /// never enumerated, serialized, or copied by spread. + css_type: Option, +} + +// Manual Debug: the lazy name index is derived state with randomized HashMap +// order, and the cache key fingerprints this repr — it must stay canonical. +impl std::fmt::Debug for JsObjectMap { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("JsObjectMap") + .field("index_entries", &self.index_entries) + .field("named_entries", &self.named_entries) + .field("css_type", &self.css_type()) + .finish() + } +} + +impl PartialEq for JsObjectMap { + fn eq(&self, other: &Self) -> bool { + self.index_entries == other.index_entries + && self.named_entries == other.named_entries + && self.css_type == other.css_type + } +} + +pub(crate) const NAMED_INDEX_THRESHOLD: usize = 32; + +// parity: ES ArrayIndex — ToString(ToUint32(key)) == key and key != 2^32 - 1, +// i.e. the canonical decimal form of an integer in [0, 2^32 - 1). +pub fn array_index(key: &str) -> Option { + let bytes = key.as_bytes(); + if bytes.is_empty() || bytes.len() > 10 || !bytes.iter().all(u8::is_ascii_digit) { + return None; + } + if bytes.len() > 1 && bytes[0] == b'0' { + return None; + } + let n: u64 = key.parse().ok()?; + if n >= u64::from(u32::MAX) { + return None; + } + Some(n as u32) +} + +impl JsObjectMap { + pub fn new() -> Self { + Self::default() + } + + pub fn len(&self) -> usize { + self.index_entries.len() + self.named_entries.len() + } + + pub fn is_empty(&self) -> bool { + self.index_entries.is_empty() && self.named_entries.is_empty() + } + + pub fn contains_key(&self, key: &str) -> bool { + self.get(key).is_some() + } + + pub fn get(&self, key: &str) -> Option<&EvalValue> { + if let Some(n) = array_index(key) { + self.index_entries + .binary_search_by_key(&n, |e| e.0) + .ok() + .map(|i| &self.index_entries[i].2) + } else if let Some(index) = &self.named_index { + index.get(key).map(|&i| &self.named_entries[i].1) + } else { + self.named_entries + .iter() + .find(|(k, _)| k == key) + .map(|(_, v)| v) + } + } + + fn named_position(&mut self, key: &str) -> Option { + if self.named_index.is_none() && self.named_entries.len() >= NAMED_INDEX_THRESHOLD { + self.named_index = Some(Box::new( + self.named_entries + .iter() + .enumerate() + .map(|(i, (k, _))| (k.clone(), i)) + .collect(), + )); + } + match &self.named_index { + Some(index) => index.get(key).copied(), + None => self.named_entries.iter().position(|(k, _)| k == key), + } + } + + /// Returns the previous value when overwriting; an overwrite keeps the key's position. + pub fn insert(&mut self, key: impl Into, value: EvalValue) -> Option { + let key = key.into(); + if let Some(n) = array_index(&key) { + match self.index_entries.binary_search_by_key(&n, |e| e.0) { + Ok(i) => Some(std::mem::replace(&mut self.index_entries[i].2, value)), + Err(i) => { + self.index_entries.insert(i, (n, key, value)); + None + } + } + } else if let Some(i) = self.named_position(&key) { + Some(std::mem::replace(&mut self.named_entries[i].1, value)) + } else { + if let Some(index) = &mut self.named_index { + index.insert(key.clone(), self.named_entries.len()); + } + self.named_entries.push((key, value)); + None + } + } + + pub fn remove(&mut self, key: &str) -> Option { + if let Some(n) = array_index(key) { + match self.index_entries.binary_search_by_key(&n, |e| e.0) { + Ok(i) => Some(self.index_entries.remove(i).2), + Err(_) => None, + } + } else { + let pos = self.named_position(key)?; + if let Some(index) = &mut self.named_index { + index.remove(key); + for i in index.values_mut() { + if *i > pos { + *i -= 1; + } + } + } + Some(self.named_entries.remove(pos).1) + } + } + + pub fn entries(&self) -> impl Iterator { + self.index_entries + .iter() + .map(|(_, k, v)| (k.as_str(), v)) + .chain(self.named_entries.iter().map(|(k, v)| (k.as_str(), v))) + } + + pub fn into_entries(self) -> impl Iterator { + self.index_entries + .into_iter() + .map(|(_, k, v)| (k, v)) + .chain(self.named_entries) + } + + pub fn keys(&self) -> impl Iterator { + self.entries().map(|(k, _)| k) + } + + pub fn css_type(&self) -> Option<&str> { + self.css_type.as_deref() + } + + pub fn set_css_type(&mut self, syntax: String) { + self.css_type = Some(syntax); + } + + pub fn to_json(&self) -> Value { + let mut map = serde_json::Map::new(); + for (k, v) in self.entries() { + if matches!(v, EvalValue::Undefined) { + continue; + } + map.insert(k.to_string(), v.to_json()); + } + Value::Object(map) + } +} + +impl> FromIterator<(K, EvalValue)> for JsObjectMap { + fn from_iter>(iter: T) -> Self { + let mut map = Self::new(); + for (k, v) in iter { + map.insert(k, v); + } + map + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn s(v: &str) -> EvalValue { + EvalValue::Str(v.to_string()) + } + + fn key_vec(map: &JsObjectMap) -> Vec<&str> { + map.keys().collect() + } + + #[test] + fn array_index_recognition() { + assert_eq!(array_index("0"), Some(0)); + assert_eq!(array_index("2"), Some(2)); + assert_eq!(array_index("10"), Some(10)); + assert_eq!(array_index("4294967294"), Some(4294967294)); + // 2^32 - 1 is a valid property key but NOT an array index per spec. + assert_eq!(array_index("4294967295"), None); + assert_eq!(array_index("4294967296"), None); + assert_eq!(array_index("02"), None); + assert_eq!(array_index("1.0"), None); + assert_eq!(array_index("-1"), None); + assert_eq!(array_index("+1"), None); + assert_eq!(array_index(""), None); + assert_eq!(array_index("00"), None); + assert_eq!(array_index("1e3"), None); + assert_eq!(array_index("a"), None); + } + + #[test] + fn numeric_keys_sort_ascending_before_named_keys() { + let mut map = JsObjectMap::new(); + map.insert("b", s("b")); + map.insert("10", s("ten")); + map.insert("a", s("a")); + map.insert("2", s("two")); + assert_eq!(key_vec(&map), vec!["2", "10", "b", "a"]); + } + + #[test] + fn non_canonical_numeric_strings_keep_insertion_order() { + let mut map = JsObjectMap::new(); + map.insert("02", s("x")); + map.insert("1", s("one")); + map.insert("1.0", s("y")); + map.insert("z", s("z")); + assert_eq!(key_vec(&map), vec!["1", "02", "1.0", "z"]); + } + + #[test] + fn overwrite_keeps_position_and_returns_old() { + let mut map = JsObjectMap::new(); + map.insert("a", s("1")); + map.insert("b", s("2")); + map.insert("c", s("3")); + assert_eq!(map.insert("a", s("updated")), Some(s("1"))); + assert_eq!(key_vec(&map), vec!["a", "b", "c"]); + assert_eq!(map.get("a"), Some(&s("updated"))); + assert_eq!(map.len(), 3); + + map.insert("5", s("five")); + map.insert("3", s("three")); + assert_eq!(map.insert("5", s("FIVE")), Some(s("five"))); + assert_eq!(key_vec(&map), vec!["3", "5", "a", "b", "c"]); + assert_eq!(map.get("5"), Some(&s("FIVE"))); + } + + #[test] + fn remove_and_len() { + let mut map = JsObjectMap::new(); + map.insert("2", s("two")); + map.insert("x", s("x")); + map.insert("y", s("y")); + assert_eq!(map.remove("x"), Some(s("x"))); + assert_eq!(map.remove("x"), None); + assert_eq!(map.remove("3"), None); + assert_eq!(map.remove("2"), Some(s("two"))); + assert_eq!(key_vec(&map), vec!["y"]); + assert_eq!(map.len(), 1); + assert!(!map.is_empty()); + assert!(map.contains_key("y")); + assert!(!map.contains_key("x")); + } + + #[test] + fn iteration_is_stable_across_calls() { + let map: JsObjectMap = [ + ("zeta", s("1")), + ("7", s("2")), + ("alpha", s("3")), + ("0", s("4")), + ] + .into_iter() + .collect(); + let first: Vec<_> = map.entries().collect(); + let second: Vec<_> = map.entries().collect(); + assert_eq!(first, second); + assert_eq!(key_vec(&map), vec!["0", "7", "zeta", "alpha"]); + } + + #[test] + fn wide_maps_keep_scan_semantics_past_the_index_threshold() { + let n = NAMED_INDEX_THRESHOLD * 3; + let mut map = JsObjectMap::new(); + let mut scan = Vec::new(); + for i in 0..n { + map.insert(format!("key{i}"), s(&i.to_string())); + scan.push((format!("key{i}"), s(&i.to_string()))); + } + assert_eq!(map.insert("key5", s("updated")), Some(s("5"))); + scan[5].1 = s("updated"); + assert_eq!(map.remove("key7"), Some(s("7"))); + assert_eq!(map.remove("key7"), None); + scan.remove(7); + map.insert("key7", s("readded")); + scan.push(("key7".to_string(), s("readded"))); + let expected: Vec<(&str, &EvalValue)> = scan.iter().map(|(k, v)| (k.as_str(), v)).collect(); + assert_eq!(map.entries().collect::>(), expected); + assert_eq!(map.get("key5"), Some(&s("updated"))); + assert_eq!(map.get("missing"), None); + let narrow: JsObjectMap = scan.iter().map(|(k, v)| (k.clone(), v.clone())).collect(); + assert_eq!(map, narrow); + } + + #[test] + fn to_json_follows_json_stringify_semantics() { + let mut obj = JsObjectMap::new(); + obj.insert("kept", EvalValue::Num(1.0)); + obj.insert("dropped", EvalValue::Undefined); + obj.insert("nan", EvalValue::Num(f64::NAN)); + obj.insert("frac", EvalValue::Num(0.5)); + obj.insert( + "arr", + EvalValue::Arr(vec![EvalValue::Undefined, EvalValue::Null, s("v")]), + ); + assert_eq!( + obj.to_json(), + json!({ "kept": 1, "nan": null, "frac": 0.5, "arr": [null, null, "v"] }) + ); + assert_eq!(EvalValue::Undefined.to_json(), Value::Null); + } + + #[test] + fn from_json_roundtrip_reorders_index_keys() { + let v = json!({ "10": "ten", "2": "two", "name": "x" }); + let EvalValue::Obj(map) = EvalValue::from_json(&v) else { + panic!("expected object"); + }; + assert_eq!(key_vec(&map), vec!["2", "10", "name"]); + assert_eq!(map.to_json(), v); + } +} diff --git a/crates/stylex/src/fxhash.rs b/crates/stylex/src/fxhash.rs new file mode 100644 index 00000000..a56e1776 --- /dev/null +++ b/crates/stylex/src/fxhash.rs @@ -0,0 +1,86 @@ +//! FxHash (the rustc/Firefox hasher): multiply-xor over 8-byte chunks. Internal +//! maps only — no iteration order reaches output (design-core §7 invariant). + +use std::hash::{BuildHasherDefault, Hasher}; + +const SEED: u64 = 0x51_7c_c1_b7_27_22_0a_95; + +pub type FxBuildHasher = BuildHasherDefault; +pub type FxHashMap = std::collections::HashMap; +pub type FxHashSet = std::collections::HashSet; + +#[derive(Default)] +pub struct FxHasher { + hash: u64, +} + +impl FxHasher { + #[inline] + fn add(&mut self, word: u64) { + self.hash = (self.hash.rotate_left(5) ^ word).wrapping_mul(SEED); + } +} + +impl Hasher for FxHasher { + #[inline] + fn write(&mut self, bytes: &[u8]) { + let mut chunks = bytes.chunks_exact(8); + for chunk in &mut chunks { + self.add(u64::from_le_bytes(chunk.try_into().expect("8-byte chunk"))); + } + let tail = chunks.remainder(); + if !tail.is_empty() { + let mut word = [0u8; 8]; + word[..tail.len()].copy_from_slice(tail); + self.add(u64::from_le_bytes(word)); + } + } + + #[inline] + fn write_u8(&mut self, n: u8) { + self.add(u64::from(n)); + } + + #[inline] + fn write_u32(&mut self, n: u32) { + self.add(u64::from(n)); + } + + #[inline] + fn write_u64(&mut self, n: u64) { + self.add(n); + } + + #[inline] + fn write_usize(&mut self, n: usize) { + self.add(n as u64); + } + + #[inline] + fn finish(&self) -> u64 { + self.hash + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hashes_are_stable_and_distinguish() { + let mut a = FxHasher::default(); + std::hash::Hash::hash("color", &mut a); + let mut b = FxHasher::default(); + std::hash::Hash::hash("color", &mut b); + let mut c = FxHasher::default(); + std::hash::Hash::hash("colour", &mut c); + assert_eq!(a.finish(), b.finish()); + assert_ne!(a.finish(), c.finish()); + // str Hash writes the bytes plus a 0xff terminator: no prefix collisions. + let mut d = FxHasher::default(); + std::hash::Hash::hash("col", &mut d); + let mut e = FxHasher::default(); + std::hash::Hash::hash("col\0or", &mut e); + assert_ne!(d.finish(), e.finish()); + } +} diff --git a/crates/stylex/src/hash.rs b/crates/stylex/src/hash.rs new file mode 100644 index 00000000..696f0f70 --- /dev/null +++ b/crates/stylex/src/hash.rs @@ -0,0 +1,109 @@ +// Behavior-equivalent to stylex@0.19.0 babel-plugin/src/shared/hash.js: the JS +// murmur reads UTF-16 code units masked to their low byte, with length in units. + +const M: u32 = 0x5bd1_e995; + +pub fn murmur2_32_gc(input: &str, seed: u32) -> u32 { + // ASCII fast path: UTF-16 units equal bytes (values and count), so the + // per-call Vec re-encode is pure allocation cost there. + if input.is_ascii() { + return murmur2_32_units(AsciiUnits(input.as_bytes()), seed); + } + let units: Vec = input.encode_utf16().collect(); + murmur2_32_units(Utf16Units(&units), seed) +} + +struct AsciiUnits<'a>(&'a [u8]); +struct Utf16Units<'a>(&'a [u16]); + +trait Units { + fn len(&self) -> usize; + fn low_byte(&self, i: usize) -> u32; +} + +impl Units for AsciiUnits<'_> { + fn len(&self) -> usize { + self.0.len() + } + #[inline] + fn low_byte(&self, i: usize) -> u32 { + u32::from(self.0[i]) + } +} + +impl Units for Utf16Units<'_> { + fn len(&self) -> usize { + self.0.len() + } + #[inline] + fn low_byte(&self, i: usize) -> u32 { + u32::from(self.0[i]) & 0xff + } +} + +fn murmur2_32_units(units: impl Units, seed: u32) -> u32 { + let mut l = units.len(); + let mut h = seed ^ (l as u32); + let mut i = 0usize; + + while l >= 4 { + let mut k = units.low_byte(i) + | (units.low_byte(i + 1) << 8) + | (units.low_byte(i + 2) << 16) + | (units.low_byte(i + 3) << 24); + k = k.wrapping_mul(M); + k ^= k >> 24; + k = k.wrapping_mul(M); + h = h.wrapping_mul(M) ^ k; + i += 4; + l -= 4; + } + + if l >= 3 { + h ^= units.low_byte(i + 2) << 16; + } + if l >= 2 { + h ^= units.low_byte(i + 1) << 8; + } + if l >= 1 { + h ^= units.low_byte(i); + h = h.wrapping_mul(M); + } + + h ^= h >> 13; + h = h.wrapping_mul(M); + h ^= h >> 15; + h +} + +pub fn to_base36(mut n: u32) -> String { + if n == 0 { + return "0".to_string(); + } + const DIGITS: &[u8; 36] = b"0123456789abcdefghijklmnopqrstuvwxyz"; + let mut buf = [0u8; 7]; + let mut idx = buf.len(); + while n > 0 { + idx -= 1; + buf[idx] = DIGITS[(n % 36) as usize]; + n /= 36; + } + String::from_utf8_lossy(&buf[idx..]).into_owned() +} + +pub fn hash(s: &str) -> String { + to_base36(murmur2_32_gc(s, 1)) +} + +// Upstream's toBase62 returns "" for 0; replicated deliberately. +pub fn create_short_hash(s: &str) -> String { + const DIGITS: &[u8; 62] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + let mut n = murmur2_32_gc(s, 1) % 916_132_832; + let mut out: Vec = Vec::new(); + while n > 0 { + out.push(DIGITS[(n % 62) as usize]); + n /= 62; + } + out.reverse(); + String::from_utf8(out).expect("base62 digits are ASCII") +} diff --git a/crates/stylex/src/imports.rs b/crates/stylex/src/imports.rs new file mode 100644 index 00000000..0287c374 --- /dev/null +++ b/crates/stylex/src/imports.rs @@ -0,0 +1,640 @@ +//! Top-level import/require scan feeding the stylex binding tables. +// parity: babel-plugin src/visitors/imports.js + src/index.js:76-91 (top level only) + +use std::collections::{BTreeMap, BTreeSet}; + +use oxc_ast::ast::{ + BindingPattern, Expression, ImportDeclaration, ImportDeclarationSpecifier, ModuleExportName, + Program, PropertyKey, Statement, VariableDeclaration, +}; +use oxc_span::Span; + +use crate::errors::StylexError; +use crate::options::ResolvedOptions; + +pub const ATOMS_SOURCE: &str = "@stylexjs/atoms"; + +/// `state.atomImports` marker for a default/namespace/require binding. +pub const ATOM_NAMESPACE: &str = "*"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +#[non_exhaustive] +pub enum StylexNamedImport { + Create, + Props, + Attrs, + Keyframes, + PositionTry, + ViewTransitionClass, + Include, + FirstThatWorks, + DefineVars, + DefineMarker, + DefineConsts, + CreateTheme, + Types, + When, + DefaultMarker, + Env, + DefineVarsNested, + DefineConstsNested, + CreateThemeNested, + Conditional, +} + +impl StylexNamedImport { + pub fn from_imported_name(name: &str) -> Option { + Some(match name { + "create" => Self::Create, + "props" => Self::Props, + "attrs" => Self::Attrs, + "keyframes" => Self::Keyframes, + "positionTry" => Self::PositionTry, + "viewTransitionClass" => Self::ViewTransitionClass, + "include" => Self::Include, + "firstThatWorks" => Self::FirstThatWorks, + "defineVars" => Self::DefineVars, + "defineMarker" => Self::DefineMarker, + "defineConsts" => Self::DefineConsts, + "createTheme" => Self::CreateTheme, + "types" => Self::Types, + "when" => Self::When, + "defaultMarker" => Self::DefaultMarker, + "env" => Self::Env, + "unstable_defineVarsNested" => Self::DefineVarsNested, + "unstable_defineConstsNested" => Self::DefineConstsNested, + "unstable_createThemeNested" => Self::CreateThemeNested, + "unstable_conditional" => Self::Conditional, + _ => return None, + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ImportedSymbol { + Named(String), + Default, + Namespace, +} + +/// One import binding as the evaluator sees it (every import declaration in +/// the file, not just stylex sources). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ImportRecord { + pub local: String, + pub source: String, + pub imported: ImportedSymbol, + /// Span of the declaring ImportDeclaration; treeshake compensation inserts + /// exactly before it (babel `importPath.insertBefore`). + pub decl_span: Span, + /// Span of the declaration's source string literal (raw text incl. quotes). + pub source_span: Span, +} + +#[derive(Debug, Clone, Default)] +pub struct ImportTable { + /// Locals bound to the whole stylex namespace (default/namespace/require). + pub stylex_namespaces: BTreeSet, + /// The same locals in source order (upstream state.stylexImport is an + /// insertion-ordered Set; the sx fallback walks it in order). + pub stylex_namespace_order: Vec, + /// Locals bound to individual stylex named imports. + pub named: BTreeMap, + /// Matched import-source specifiers (upstream `state.importPaths`). + pub import_paths: BTreeSet, + /// Every value import binding in the file, keyed by local name. + pub all_imports: BTreeMap, + /// `state.atomImports`: local name → imported name, or [`ATOM_NAMESPACE`]. + pub atom_imports: BTreeMap, + /// Locals of every `@stylexjs/atoms` specifier, type-only ones included; + /// the atoms visitor's scope-binding fallback accepts these too. + pub atom_binding_locals: BTreeSet, +} + +impl ImportTable { + /// No stylex binding of any kind: nothing in the file can compile, so the + /// caller may skip semantic analysis entirely. + pub fn is_dormant(&self) -> bool { + self.stylex_namespaces.is_empty() + && self.named.is_empty() + && self.import_paths.is_empty() + && self.atom_imports.is_empty() + && self.atom_binding_locals.is_empty() + } + + pub fn atom_import(&self, name: &str) -> Option<&str> { + self.atom_imports.get(name).map(String::as_str) + } + + pub fn is_atom_binding_local(&self, name: &str) -> bool { + self.atom_binding_locals.contains(name) + } + + fn add_stylex_namespace(&mut self, local: &str) { + if self.stylex_namespaces.insert(local.to_string()) { + self.stylex_namespace_order.push(local.to_string()); + } + } + + pub fn is_stylex_namespace(&self, name: &str) -> bool { + self.stylex_namespaces.contains(name) + } + + pub fn named_binding(&self, name: &str) -> Option { + self.named.get(name).copied() + } + + pub fn import_record(&self, local: &str) -> Option<&ImportRecord> { + self.all_imports.get(local) + } + + /// Import records whose specifier passes the theme-file suffix check. + pub fn theme_file_imports<'t>( + &'t self, + theme_extension: &'t str, + ) -> impl Iterator { + self.all_imports.values().filter(move |r| { + crate::module_resolution::is_theme_specifier(&r.source, theme_extension) + }) + } +} + +pub fn scan_imports( + program: &Program<'_>, + options: &ResolvedOptions, +) -> Result { + let mut table = ImportTable::default(); + for statement in &program.body { + match statement { + Statement::ImportDeclaration(decl) => { + scan_import_declaration(decl, options, &mut table)? + } + Statement::VariableDeclaration(decl) => scan_requires(decl, options, &mut table)?, + _ => {} + } + } + Ok(table) +} + +fn scan_import_declaration( + decl: &ImportDeclaration<'_>, + options: &ResolvedOptions, + table: &mut ImportTable, +) -> Result<(), StylexError> { + let source = decl.source.value.as_str(); + if source == ATOMS_SOURCE { + scan_atoms_declaration(decl, table); + return Ok(()); + } + if decl.import_kind.is_type() { + return Ok(()); + } + let Some(specifiers) = &decl.specifiers else { + return Ok(()); + }; + let is_stylex_source = options.is_import_source(source); + // `as` is subtractive: it turns off default, namespace and every other + // named import for this source, and turns on exactly one named export. + let alias = options.import_as(source); + for specifier in specifiers { + match specifier { + ImportDeclarationSpecifier::ImportDefaultSpecifier(spec) => { + let local = spec.local.name.to_string(); + if is_stylex_source && alias.is_none() { + table.import_paths.insert(source.to_string()); + table.add_stylex_namespace(&local); + } + table.all_imports.insert( + local.clone(), + ImportRecord { + local, + source: source.to_string(), + imported: ImportedSymbol::Default, + decl_span: decl.span, + source_span: decl.source.span, + }, + ); + } + ImportDeclarationSpecifier::ImportNamespaceSpecifier(spec) => { + let local = spec.local.name.to_string(); + if is_stylex_source && alias.is_none() { + table.import_paths.insert(source.to_string()); + table.add_stylex_namespace(&local); + } + table.all_imports.insert( + local.clone(), + ImportRecord { + local, + source: source.to_string(), + imported: ImportedSymbol::Namespace, + decl_span: decl.span, + source_span: decl.source.span, + }, + ); + } + ImportDeclarationSpecifier::ImportSpecifier(spec) => { + if spec.import_kind.is_type() { + continue; + } + let imported = match &spec.imported { + ModuleExportName::IdentifierName(id) => id.name.to_string(), + ModuleExportName::StringLiteral(lit) => lit.value.to_string(), + ModuleExportName::IdentifierReference(id) => id.name.to_string(), + }; + let local = spec.local.name.to_string(); + if is_stylex_source { + match alias { + Some(alias) if alias == imported => { + table.import_paths.insert(source.to_string()); + table.add_stylex_namespace(&local); + } + Some(_) => {} + None => { + table.import_paths.insert(source.to_string()); + if let Some(binding) = StylexNamedImport::from_imported_name(&imported) + { + table.named.insert(local.clone(), binding); + } + } + } + } + table.all_imports.insert( + local.clone(), + ImportRecord { + local, + source: source.to_string(), + imported: ImportedSymbol::Named(imported), + decl_span: decl.span, + source_span: decl.source.span, + }, + ); + } + } + } + Ok(()) +} + +// parity: imports.js ATOMS_SOURCES branch. `import type` locals are collected +// anyway: the atoms visitor's scope-binding fallback still resolves them. +fn scan_atoms_declaration(decl: &ImportDeclaration<'_>, table: &mut ImportTable) { + let Some(specifiers) = &decl.specifiers else { + return; + }; + let type_only = decl.import_kind.is_type(); + for specifier in specifiers { + let (local, imported) = match specifier { + ImportDeclarationSpecifier::ImportDefaultSpecifier(spec) => { + (spec.local.name.as_str(), ATOM_NAMESPACE.to_string()) + } + ImportDeclarationSpecifier::ImportNamespaceSpecifier(spec) => { + (spec.local.name.as_str(), ATOM_NAMESPACE.to_string()) + } + // An inline `type` specifier is not filtered out here upstream. + ImportDeclarationSpecifier::ImportSpecifier(spec) => ( + spec.local.name.as_str(), + match &spec.imported { + ModuleExportName::IdentifierName(id) => id.name.to_string(), + ModuleExportName::StringLiteral(lit) => lit.value.to_string(), + ModuleExportName::IdentifierReference(id) => id.name.to_string(), + }, + ), + }; + table.atom_binding_locals.insert(local.to_string()); + if !type_only { + table.atom_imports.insert(local.to_string(), imported); + } + } +} + +// parity: imports.js readRequires — top-level `const x = require('src')` only. +fn scan_requires( + decl: &VariableDeclaration<'_>, + options: &ResolvedOptions, + table: &mut ImportTable, +) -> Result<(), StylexError> { + for declarator in &decl.declarations { + let Some(Expression::CallExpression(call)) = &declarator.init else { + continue; + }; + let Expression::Identifier(callee) = &call.callee else { + continue; + }; + if callee.name != "require" || call.arguments.len() != 1 { + continue; + } + let Some(Expression::StringLiteral(source)) = call.arguments[0].as_expression() else { + continue; + }; + let source = source.value.as_str(); + if source == ATOMS_SOURCE { + scan_atoms_require(&declarator.id, table); + continue; + } + // readRequires never calls importAs: an aliased source still binds its + // namespace and its individual API names here. Not a bug to "fix". + if !options.is_import_source(source) { + continue; + } + table.import_paths.insert(source.to_string()); + match &declarator.id { + BindingPattern::BindingIdentifier(id) => { + table.add_stylex_namespace(&id.name); + } + BindingPattern::ObjectPattern(pattern) => { + for property in &pattern.properties { + let PropertyKey::StaticIdentifier(key) = &property.key else { + continue; + }; + let BindingPattern::BindingIdentifier(value) = &property.value else { + continue; + }; + if let Some(binding) = StylexNamedImport::from_imported_name(&key.name) { + table.named.insert(value.name.to_string(), binding); + } + } + } + _ => {} + } + } + Ok(()) +} + +// parity: imports.js readRequires ATOMS_SOURCES branch. +fn scan_atoms_require(id: &BindingPattern<'_>, table: &mut ImportTable) { + match id { + BindingPattern::BindingIdentifier(name) => { + table + .atom_imports + .insert(name.name.to_string(), ATOM_NAMESPACE.to_string()); + } + BindingPattern::ObjectPattern(pattern) => { + for property in &pattern.properties { + let PropertyKey::StaticIdentifier(key) = &property.key else { + continue; + }; + let BindingPattern::BindingIdentifier(value) = &property.value else { + continue; + }; + table + .atom_imports + .insert(value.name.to_string(), key.name.to_string()); + } + } + _ => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + use oxc_allocator::Allocator; + use oxc_parser::Parser; + use oxc_span::SourceType; + + fn scan(source: &str) -> ImportTable { + let allocator = Allocator::default(); + let ret = Parser::new(&allocator, source, SourceType::tsx()).parse(); + assert!(!ret.panicked, "parse failed"); + scan_imports(&ret.program, &ResolvedOptions::default()).unwrap() + } + + #[test] + fn namespace_default_and_named_forms() { + let table = scan( + "import * as stylex from '@stylexjs/stylex';\n\ + import sx from 'stylex';\n\ + import { create, keyframes as kf, when } from '@stylexjs/stylex';\n\ + import { helper } from './helpers';\n", + ); + assert!(table.is_stylex_namespace("stylex")); + assert!(table.is_stylex_namespace("sx")); + assert_eq!( + table.named_binding("create"), + Some(StylexNamedImport::Create) + ); + assert_eq!( + table.named_binding("kf"), + Some(StylexNamedImport::Keyframes) + ); + assert_eq!(table.named_binding("when"), Some(StylexNamedImport::When)); + assert_eq!(table.named_binding("helper"), None); + assert!(table.import_record("helper").is_some()); + assert_eq!( + table.import_record("kf").unwrap().imported, + ImportedSymbol::Named("keyframes".to_string()) + ); + assert!(table.import_paths.contains("stylex")); + assert!(table.import_paths.contains("@stylexjs/stylex")); + } + + #[test] + fn type_imports_are_skipped() { + let table = scan( + "import type { create } from '@stylexjs/stylex';\n\ + import { type props, keyframes } from '@stylexjs/stylex';\n", + ); + assert_eq!(table.named_binding("create"), None); + assert_eq!(table.named_binding("props"), None); + assert_eq!( + table.named_binding("keyframes"), + Some(StylexNamedImport::Keyframes) + ); + } + + #[test] + fn requires_top_level_only() { + let table = scan( + "const stylex = require('@stylexjs/stylex');\n\ + const { create, props: p } = require('stylex');\n\ + function f() { const nested = require('stylex'); }\n", + ); + assert!(table.is_stylex_namespace("stylex")); + assert!(!table.is_stylex_namespace("nested")); + assert_eq!( + table.named_binding("create"), + Some(StylexNamedImport::Create) + ); + assert_eq!(table.named_binding("p"), Some(StylexNamedImport::Props)); + } + + #[test] + fn custom_import_sources() { + let allocator = Allocator::default(); + let ret = Parser::new( + &allocator, + "import * as css from 'foo-bar';\nimport * as other from 'baz';", + SourceType::tsx(), + ) + .parse(); + let options = crate::options::CompilerOptions::from_json( + &serde_json::json!({ "importSources": ["foo-bar"] }), + ) + .unwrap() + .resolve() + .unwrap(); + let table = scan_imports(&ret.program, &options).unwrap(); + assert!(table.is_stylex_namespace("css")); + assert!(!table.is_stylex_namespace("other")); + } + + fn scan_with(source: &str, options: serde_json::Value) -> ImportTable { + let allocator = Allocator::default(); + let ret = Parser::new(&allocator, source, SourceType::tsx()).parse(); + assert!(!ret.panicked, "parse failed"); + let options = crate::options::CompilerOptions::from_json(&options) + .unwrap() + .resolve() + .unwrap(); + scan_imports(&ret.program, &options).unwrap() + } + + fn aliased(source: &str) -> ImportTable { + scan_with( + source, + serde_json::json!({ "importSources": [{ "from": "my-lib", "as": "css" }] }), + ) + } + + #[test] + fn aliased_source_binds_only_the_named_export() { + for (code, local) in [ + ("import { css } from 'my-lib';", "css"), + ("import { css as sx } from 'my-lib';", "sx"), + ("import { 'css' as sx } from 'my-lib';", "sx"), + ] { + let table = aliased(code); + assert!(table.is_stylex_namespace(local), "{code}"); + assert!(table.import_paths.contains("my-lib"), "{code}"); + } + // `as` is subtractive for everything else on that source. + for code in [ + "import * as css from 'my-lib';", + "import css from 'my-lib';", + "import { create } from 'my-lib';", + "import type { css } from 'my-lib';", + "export { css } from 'my-lib';", + ] { + let table = aliased(code); + assert!(table.is_dormant(), "{code}"); + } + // Mixed declarations register only the matching specifier. + let table = aliased("import d, { css, create } from 'my-lib';"); + assert!(table.is_stylex_namespace("css")); + assert!(!table.is_stylex_namespace("d")); + assert_eq!(table.named_binding("create"), None); + // The same export under two locals registers both. + let table = aliased("import { css, css as sx } from 'my-lib';"); + assert!(table.is_stylex_namespace("css")); + assert!(table.is_stylex_namespace("sx")); + } + + #[test] + fn require_ignores_the_alias() { + let table = aliased("const css = require('my-lib');"); + assert!(table.is_stylex_namespace("css")); + let table = aliased("const { create } = require('my-lib');"); + assert_eq!( + table.named_binding("create"), + Some(StylexNamedImport::Create) + ); + // `css` is not an API name, so the destructure path drops it. + let table = aliased("const { css } = require('my-lib');"); + assert!(!table.is_stylex_namespace("css")); + assert_eq!(table.named_binding("css"), None); + } + + #[test] + fn aliased_and_plain_sources_are_independent() { + let table = scan_with( + "import { css } from 'my-lib';\nimport * as stylex from 'other-lib';\n", + serde_json::json!({ + "importSources": [{ "from": "my-lib", "as": "css" }, "other-lib"] + }), + ); + assert!(table.is_stylex_namespace("css")); + assert!(table.is_stylex_namespace("stylex")); + // Matching is raw-string equality on the specifier. + let table = aliased("import { css } from 'my-lib/css';"); + assert!(table.is_dormant()); + // Overriding a built-in switches it to the aliased form too. + let table = scan_with( + "import * as stylex from '@stylexjs/stylex';\nimport { css } from '@stylexjs/stylex';\n", + serde_json::json!({ + "importSources": [{ "from": "@stylexjs/stylex", "as": "css" }] + }), + ); + assert!(!table.is_stylex_namespace("stylex")); + assert!(table.is_stylex_namespace("css")); + } + + #[test] + fn atom_import_forms() { + let table = scan( + "import d from '@stylexjs/atoms';\n\ + import * as ns from '@stylexjs/atoms';\n\ + import { color, 'padding' as p, type gap, default as def } from '@stylexjs/atoms';\n\ + const r = require('@stylexjs/atoms');\n\ + let l = require('@stylexjs/atoms');\n\ + const { color: c2, width } = require('@stylexjs/atoms');\n", + ); + let got: Vec<(&str, &str)> = table + .atom_imports + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())) + .collect(); + assert_eq!( + got, + vec![ + ("c2", "color"), + ("color", "color"), + ("d", "*"), + ("def", "default"), + ("gap", "gap"), + ("l", "*"), + ("ns", "*"), + ("p", "padding"), + ("r", "*"), + ("width", "width"), + ] + ); + assert!(!table.is_dormant()); + assert!(table.import_paths.is_empty()); + } + + #[test] + fn type_atom_imports_bind_without_atom_imports_entries() { + let table = scan("import type x from '@stylexjs/atoms';\nexport const a = x.display;\n"); + assert!(table.atom_imports.is_empty()); + assert!(table.is_atom_binding_local("x")); + assert!(!table.is_dormant()); + } + + #[test] + fn atoms_specifier_is_matched_literally() { + for source in [ + "import x from '@stylexjs/atoms/';", + "import x from '@stylexjs/atoms/babel-transform';", + "export { color } from '@stylexjs/atoms';", + "const x = require('@stylexjs/atoms').default;", + "function f() { const x = require('@stylexjs/atoms'); }", + ] { + let table = scan(source); + assert!(table.atom_imports.is_empty(), "{source}"); + assert!(table.atom_binding_locals.is_empty(), "{source}"); + } + } + + #[test] + fn theme_file_imports_are_listed() { + let table = scan( + "import { colors } from './tokens.stylex';\n\ + import { sizes } from './sizes.stylex.const';\n\ + import { helper } from './helpers';\n", + ); + let themed: Vec<&str> = table + .theme_file_imports(crate::module_resolution::THEME_FILE_EXTENSION) + .map(|r| r.local.as_str()) + .collect(); + assert_eq!(themed, vec!["colors", "sizes"]); + } +} diff --git a/crates/stylex/src/jsrt.rs b/crates/stylex/src/jsrt.rs new file mode 100644 index 00000000..21757c99 --- /dev/null +++ b/crates/stylex/src/jsrt.rs @@ -0,0 +1,485 @@ +use std::cmp::Ordering; + +pub fn js_number_to_string(x: f64) -> String { + if x.is_nan() { + return "NaN".to_string(); + } + if x.is_infinite() { + return if x > 0.0 { "Infinity" } else { "-Infinity" }.to_string(); + } + let mut buf = ryu_js::Buffer::new(); + buf.format(x).to_string() +} + +// ES Math.round: nearest integer, ties toward +Infinity (not half-away-from-zero). +// Rounding into zero from x in [-0.5, 0) yields -0, matching JS. +pub fn js_math_round(x: f64) -> f64 { + if !x.is_finite() || x == 0.0 || x.fract() == 0.0 { + return x; + } + let f = x.floor(); + let r = if x - f >= 0.5 { f + 1.0 } else { f }; + if r == 0.0 && x < 0.0 { -0.0 } else { r } +} + +#[cfg(test)] +mod round_tests { + use super::js_math_round; + + #[test] + fn negative_zero_and_ties() { + assert!(js_math_round(-0.1).is_sign_negative()); + assert!(js_math_round(-0.5).is_sign_negative()); + assert_eq!(js_math_round(-0.5), 0.0); + assert_eq!(js_math_round(0.5), 1.0); + assert_eq!(js_math_round(2.5), 3.0); + assert_eq!(js_math_round(-2.5), -2.0); + assert_eq!(js_math_round(0.49999999999999994), 0.0); + } +} + +// JS default string comparison (`<`, Array.prototype.sort) is UTF-16 code-unit +// order, which diverges from Rust's `str` Ord for astral-plane characters. +pub fn utf16_cmp(a: &str, b: &str) -> Ordering { + a.encode_utf16().cmp(b.encode_utf16()) +} + +// JS String.prototype.slice indexes UTF-16 code units; byte slicing panics on +// multibyte boundaries. Negative end counts from the end, as in JS. +pub fn js_slice_utf16(s: &str, start: usize, end: isize) -> String { + let units: Vec = s.encode_utf16().collect(); + let len = units.len() as isize; + let end = if end < 0 { + (len + end).max(0) + } else { + end.min(len) + } as usize; + if start >= end { + return String::new(); + } + String::from_utf16_lossy(&units[start..end]) +} + +/// A slice boundary split a surrogate pair: the doctrine is loud over lossy. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LoneSurrogate; + +/// `js_slice_utf16` that refuses to manufacture lone surrogates. +pub fn js_slice_utf16_checked(s: &str, start: usize, end: isize) -> Result { + let units: Vec = s.encode_utf16().collect(); + let len = units.len() as isize; + let end = if end < 0 { + (len + end).max(0) + } else { + end.min(len) + } as usize; + if start >= end { + return Ok(String::new()); + } + String::from_utf16(&units[start..end]).map_err(|_| LoneSurrogate) +} + +/// JS Number.prototype.toFixed: the spec strips the sign first, then rounds +/// the exact decimal expansion half-up (ties pick the larger n). +pub fn js_to_fixed(x: f64, digits: usize) -> String { + if x.is_nan() { + return "NaN".to_string(); + } + if x.abs() >= 1e21 || x.is_infinite() { + return js_number_to_string(x); + } + let negative = x.is_sign_negative() && x != 0.0; + // 1100 fractional digits covers the exact expansion of any finite f64. + let exact = format!("{:.1100}", x.abs()); + let (int_part, frac_part) = exact.split_once('.').unwrap_or((exact.as_str(), "")); + let mut int_digits: Vec = int_part.bytes().map(|b| b - b'0').collect(); + let mut frac_digits: Vec = frac_part.bytes().map(|b| b - b'0').collect(); + frac_digits.resize(1101.max(digits + 1), 0); + let kept = frac_digits[..digits].to_vec(); + let rest = &frac_digits[digits..]; + let first = rest.first().copied().unwrap_or(0); + let round_up = first >= 5; + let mut frac = kept; + if round_up { + let mut carry = 1u8; + for digit in frac.iter_mut().rev() { + let sum = *digit + carry; + *digit = sum % 10; + carry = sum / 10; + if carry == 0 { + break; + } + } + if carry > 0 { + for digit in int_digits.iter_mut().rev() { + let sum = *digit + carry; + *digit = sum % 10; + carry = sum / 10; + if carry == 0 { + break; + } + } + if carry > 0 { + int_digits.insert(0, carry); + } + } + } + let int_str: String = int_digits.iter().map(|d| (d + b'0') as char).collect(); + let int_str = int_str.trim_start_matches('0'); + let int_str = if int_str.is_empty() { "0" } else { int_str }; + // JS keeps the sign of tiny negatives ("-0.00"); literal -0 stays "0.00". + let sign = if negative { "-" } else { "" }; + if digits == 0 { + format!("{sign}{int_str}") + } else { + let frac_str: String = frac.iter().map(|d| (d + b'0') as char).collect(); + format!("{sign}{int_str}.{frac_str}") + } +} + +#[cfg(test)] +mod to_fixed_tests { + use super::js_to_fixed; + + #[test] + fn ties_round_half_up_on_the_magnitude() { + // 0.125 and ±2.5 are exactly representable: true ties (node-pinned). + assert_eq!(js_to_fixed(0.125, 2), "0.13"); + assert_eq!(js_to_fixed(-0.125, 2), "-0.13"); + // 1.005 is stored below the tie, so it rounds down (JS "1.00"). + assert_eq!(js_to_fixed(1.005, 2), "1.00"); + assert_eq!(js_to_fixed(1.5, 0), "2"); + assert_eq!(js_to_fixed(2.5, 0), "3"); + assert_eq!(js_to_fixed(-2.5, 0), "-3"); + assert_eq!(js_to_fixed(1.0, 2), "1.00"); + assert_eq!(js_to_fixed(0.0, 2), "0.00"); + assert_eq!(js_to_fixed(-0.0, 2), "0.00"); + assert_eq!(js_to_fixed(-0.004, 2), "-0.00"); + assert_eq!(js_to_fixed(f64::NAN, 2), "NaN"); + assert_eq!(js_to_fixed(1.45, 1), "1.4"); + assert_eq!(js_to_fixed(9.995, 2), "9.99"); + assert_eq!(js_to_fixed(0.999, 2), "1.00"); + } +} + +/// A character outside the collation alphabet verified against Node's ICU +/// (testdata/pins/collation.json); `locale_cmp` refuses to guess its order. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct UnverifiedChar(pub char); + +// (primary, secondary, tertiary) per char; ranks transcribed from the pinned +// `groups` in collation.json (gen-pins-collation.mjs, node en-US ICU). +pub(crate) fn locale_key(c: char) -> Result<(u8, u8, u8), UnverifiedChar> { + let key = match c { + ' ' => (0, 0, 0), + '_' => (1, 0, 0), + '-' => (2, 0, 0), + '–' => (3, 0, 0), + '—' => (4, 0, 0), + ',' => (5, 0, 0), + ';' => (6, 0, 0), + ':' => (7, 0, 0), + '!' => (8, 0, 0), + '¡' => (9, 0, 0), + '?' => (10, 0, 0), + '¿' => (11, 0, 0), + '.' => (12, 0, 0), + '·' => (13, 0, 0), + '\'' => (14, 0, 0), + '‘' => (14, 1, 0), + '’' => (14, 2, 0), + '‹' => (15, 0, 0), + '›' => (16, 0, 0), + '"' => (17, 0, 0), + '“' => (17, 1, 0), + '”' => (17, 2, 0), + '«' => (18, 0, 0), + '»' => (19, 0, 0), + '(' => (20, 0, 0), + ')' => (21, 0, 0), + '[' => (22, 0, 0), + ']' => (23, 0, 0), + '{' => (24, 0, 0), + '}' => (25, 0, 0), + '§' => (26, 0, 0), + '¶' => (27, 0, 0), + '@' => (28, 0, 0), + '*' => (29, 0, 0), + '/' => (30, 0, 0), + '\\' => (31, 0, 0), + '&' => (32, 0, 0), + '#' => (33, 0, 0), + '%' => (34, 0, 0), + '‰' => (35, 0, 0), + '†' => (36, 0, 0), + '‡' => (37, 0, 0), + '•' => (38, 0, 0), + '″' => (39, 0, 0), + '`' => (40, 0, 0), + '´' => (41, 0, 0), + '^' => (42, 0, 0), + '¯' => (43, 0, 0), + '¨' => (44, 0, 0), + '¸' => (45, 0, 0), + '°' => (46, 0, 0), + '©' => (47, 0, 0), + '®' => (48, 0, 0), + '←' => (49, 0, 0), + '→' => (50, 0, 0), + '↔' => (51, 0, 0), + '⇒' => (52, 0, 0), + '+' => (53, 0, 0), + '±' => (54, 0, 0), + '÷' => (55, 0, 0), + '×' => (56, 0, 0), + '<' => (57, 0, 0), + '=' => (58, 0, 0), + '≠' => (58, 1, 0), + '>' => (59, 0, 0), + '¬' => (60, 0, 0), + '|' => (61, 0, 0), + '¦' => (62, 0, 0), + '~' => (63, 0, 0), + '∞' => (64, 0, 0), + '≈' => (65, 0, 0), + '≤' => (66, 0, 0), + '≥' => (67, 0, 0), + '★' => (68, 0, 0), + '☆' => (69, 0, 0), + '♠' => (70, 0, 0), + '♣' => (71, 0, 0), + '♥' => (72, 0, 0), + '♦' => (73, 0, 0), + '✓' => (74, 0, 0), + '✗' => (75, 0, 0), + '✨' => (76, 0, 0), + '❤' => (77, 0, 0), + '⭐' => (78, 0, 0), + '🎉' => (79, 0, 0), + '👍' => (80, 0, 0), + '💯' => (81, 0, 0), + '🔥' => (82, 0, 0), + '🦄' => (83, 0, 0), + '😀' => (84, 0, 0), + '🚀' => (85, 0, 0), + '¤' => (86, 0, 0), + '¢' => (87, 0, 0), + '$' => (88, 0, 0), + '£' => (89, 0, 0), + '¥' => (90, 0, 0), + '€' => (91, 0, 0), + '0' => (92, 0, 0), + '1' => (93, 0, 0), + '2' => (94, 0, 0), + '²' => (94, 0, 1), + '3' => (95, 0, 0), + '³' => (95, 0, 1), + '4' => (96, 0, 0), + '5' => (97, 0, 0), + '6' => (98, 0, 0), + '7' => (99, 0, 0), + '8' => (100, 0, 0), + '9' => (101, 0, 0), + 'a' => (102, 0, 0), + 'A' => (102, 0, 1), + 'ª' => (102, 0, 2), + 'á' => (102, 1, 0), + 'Á' => (102, 1, 1), + 'à' => (102, 2, 0), + 'À' => (102, 2, 1), + 'â' => (102, 3, 0), + 'Â' => (102, 3, 1), + 'å' => (102, 4, 0), + 'Å' => (102, 4, 1), + 'ä' => (102, 5, 0), + 'Ä' => (102, 5, 1), + 'ã' => (102, 6, 0), + 'Ã' => (102, 6, 1), + 'b' => (103, 0, 0), + 'B' => (103, 0, 1), + 'c' => (104, 0, 0), + 'C' => (104, 0, 1), + 'ç' => (104, 1, 0), + 'Ç' => (104, 1, 1), + 'd' => (105, 0, 0), + 'D' => (105, 0, 1), + 'ð' => (105, 1, 0), + 'Ð' => (105, 1, 1), + 'e' => (106, 0, 0), + 'E' => (106, 0, 1), + 'é' => (106, 1, 0), + 'É' => (106, 1, 1), + 'è' => (106, 2, 0), + 'È' => (106, 2, 1), + 'ê' => (106, 3, 0), + 'Ê' => (106, 3, 1), + 'ë' => (106, 4, 0), + 'Ë' => (106, 4, 1), + 'f' => (107, 0, 0), + 'F' => (107, 0, 1), + 'g' => (108, 0, 0), + 'G' => (108, 0, 1), + 'h' => (109, 0, 0), + 'H' => (109, 0, 1), + 'i' => (110, 0, 0), + 'I' => (110, 0, 1), + 'í' => (110, 1, 0), + 'Í' => (110, 1, 1), + 'ì' => (110, 2, 0), + 'Ì' => (110, 2, 1), + 'î' => (110, 3, 0), + 'Î' => (110, 3, 1), + 'ï' => (110, 4, 0), + 'Ï' => (110, 4, 1), + 'j' => (111, 0, 0), + 'J' => (111, 0, 1), + 'k' => (112, 0, 0), + 'K' => (112, 0, 1), + 'l' => (113, 0, 0), + 'L' => (113, 0, 1), + 'm' => (114, 0, 0), + 'M' => (114, 0, 1), + 'n' => (115, 0, 0), + 'N' => (115, 0, 1), + 'ñ' => (115, 1, 0), + 'Ñ' => (115, 1, 1), + 'o' => (116, 0, 0), + 'O' => (116, 0, 1), + 'º' => (116, 0, 2), + 'ó' => (116, 1, 0), + 'Ó' => (116, 1, 1), + 'ò' => (116, 2, 0), + 'Ò' => (116, 2, 1), + 'ô' => (116, 3, 0), + 'Ô' => (116, 3, 1), + 'ö' => (116, 4, 0), + 'Ö' => (116, 4, 1), + 'õ' => (116, 5, 0), + 'Õ' => (116, 5, 1), + 'ø' => (116, 6, 0), + 'Ø' => (116, 6, 1), + 'p' => (117, 0, 0), + 'P' => (117, 0, 1), + 'q' => (118, 0, 0), + 'Q' => (118, 0, 1), + 'r' => (119, 0, 0), + 'R' => (119, 0, 1), + 's' => (120, 0, 0), + 'S' => (120, 0, 1), + 't' => (121, 0, 0), + 'T' => (121, 0, 1), + 'u' => (122, 0, 0), + 'U' => (122, 0, 1), + 'ú' => (122, 1, 0), + 'Ú' => (122, 1, 1), + 'ù' => (122, 2, 0), + 'Ù' => (122, 2, 1), + 'û' => (122, 3, 0), + 'Û' => (122, 3, 1), + 'ü' => (122, 4, 0), + 'Ü' => (122, 4, 1), + 'v' => (123, 0, 0), + 'V' => (123, 0, 1), + 'w' => (124, 0, 0), + 'W' => (124, 0, 1), + 'x' => (125, 0, 0), + 'X' => (125, 0, 1), + 'y' => (126, 0, 0), + 'Y' => (126, 0, 1), + 'ý' => (126, 1, 0), + 'Ý' => (126, 1, 1), + 'ÿ' => (126, 2, 0), + 'z' => (127, 0, 0), + 'Z' => (127, 0, 1), + 'þ' => (128, 0, 0), + 'Þ' => (128, 0, 1), + 'α' => (129, 0, 0), + 'β' => (130, 0, 0), + 'γ' => (131, 0, 0), + 'δ' => (132, 0, 0), + 'µ' => (133, 0, 0), + 'π' => (134, 0, 0), + 'Ω' => (135, 0, 0), + 'а' => (136, 0, 0), + 'б' => (137, 0, 0), + 'в' => (138, 0, 0), + '中' => (139, 0, 0), + '日' => (140, 0, 0), + _ => return Err(UnverifiedChar(c)), + }; + Ok(key) +} + +/// JS localeCompare as the oracle's node runs it: whole-string primary, +/// secondary (accents), then tertiary (case, quote shape) passes. +pub fn locale_cmp(a: &str, b: &str) -> Result { + let ka = a.chars().map(locale_key).collect::, _>>()?; + let kb = b.chars().map(locale_key).collect::, _>>()?; + let primary = ka.iter().map(|k| k.0).cmp(kb.iter().map(|k| k.0)); + if primary != Ordering::Equal { + return Ok(primary); + } + let secondary = ka.iter().map(|k| k.1).cmp(kb.iter().map(|k| k.1)); + if secondary != Ordering::Equal { + return Ok(secondary); + } + Ok(ka.iter().map(|k| k.2).cmp(kb.iter().map(|k| k.2))) +} + +/// The one localeCompare stand-in for rule and pseudo sorting; outside the +/// verified alphabet it falls back to UTF-16 order (divergence risk vs ICU). +pub fn default_locale_cmp(a: &str, b: &str) -> Ordering { + locale_cmp(a, b).unwrap_or_else(|UnverifiedChar(_)| utf16_cmp(a, b)) +} + +/// First char the pinned collation cannot order; hash-feeding sorts hard-error +/// on it instead of guessing (r4#4 policy), non-hash sorts keep the fallback. +pub fn unverified_collation_char(s: &str) -> Option { + s.chars().find(|&c| locale_key(c).is_err()) +} + +// ES TrimString WhiteSpace + LineTerminator: WhiteSpace is TAB VT FF SP NBSP +// ZWNBSP(FEFF) + Zs; LineTerminator is LF CR LS PS. NEL (U+0085) is excluded. +pub fn is_js_whitespace(c: char) -> bool { + matches!( + c, + '\u{2000}' + ..='\u{200A}' + | '\u{0009}' + | '\u{000A}' + | '\u{000B}' + | '\u{000C}' + | '\u{000D}' + | '\u{0020}' + | '\u{00A0}' + | '\u{1680}' + | '\u{2028}' + | '\u{2029}' + | '\u{202F}' + | '\u{205F}' + | '\u{3000}' + | '\u{FEFF}' + ) +} + +pub fn js_trim(s: &str) -> &str { + s.trim_matches(is_js_whitespace) +} + +#[cfg(test)] +mod js_trim_tests { + use super::js_trim; + + #[test] + fn trims_es_whitespace_rust_trim_misses() { + assert_eq!(js_trim("\u{FEFF}--real\u{FEFF}"), "--real"); + assert_eq!(js_trim(" \u{FEFF}\u{00A0}x\u{2028}\u{2029} "), "x"); + assert_eq!("\u{FEFF}x".trim(), "\u{FEFF}x"); + } + + #[test] + fn keeps_nel_rust_trim_removes() { + assert_eq!(js_trim("\u{0085}x\u{0085}"), "\u{0085}x\u{0085}"); + assert_eq!("\u{0085}x".trim(), "x"); + } +} diff --git a/crates/stylex/src/lib.rs b/crates/stylex/src/lib.rs new file mode 100644 index 00000000..2d73b4ba --- /dev/null +++ b/crates/stylex/src/lib.rs @@ -0,0 +1,52 @@ +pub mod api; +pub mod assemble; +pub mod cache; +pub mod errors; +pub mod eval; +pub mod fxhash; +pub mod hash; +pub mod imports; +pub mod jsrt; +pub mod module_resolution; +pub mod options; +pub mod rules; +pub mod state; +pub mod timings; + +pub mod transform { + pub mod ast_backend; + pub mod atoms; + pub mod dce; + pub mod js_out; + pub mod merge; + pub mod visitor; +} + +pub mod shared { + pub mod create; + pub mod create_theme; + pub mod css_value; + pub mod dashify; + pub mod define_consts; + pub mod define_vars; + pub mod dev_naming; + pub mod dynamic; + pub mod fallbacks; + pub mod flatten; + pub mod generate_rule; + pub mod keyframes; + pub mod markers; + pub mod media_query; + pub mod nested; + pub mod normalize_value; + pub mod position_try; + pub mod priorities; + pub mod pseudo_sort; + pub mod resolution; + pub mod rtl; + pub mod split_css_value; + pub mod transform_value; + pub mod types; + pub mod view_transition; + pub mod when; +} diff --git a/crates/stylex/src/module_resolution.rs b/crates/stylex/src/module_resolution.rs new file mode 100644 index 00000000..9865c163 --- /dev/null +++ b/crates/stylex/src/module_resolution.rs @@ -0,0 +1,1733 @@ +//! commonJS canonical file names, theme-file suffix checks, import resolution. +// parity: babel-plugin src/utils/state-manager.js:566-707 + file-based-identifier.js + +use std::path::{Component, Path, PathBuf}; + +use crate::options::{AliasMap, ModuleResolutionType, ResolvedOptions}; + +// parity: state-manager.js EXTENSIONS — probe order is observable via hashes. +pub const EXTENSIONS: [&str; 6] = [".js", ".ts", ".tsx", ".jsx", ".mjs", ".cjs"]; + +pub const THEME_FILE_EXTENSION: &str = ".stylex"; + +const ROOT_PLACEHOLDER: &str = "/ROOT/"; + +/// The two `filePathResolver` arguments beyond the specifier and the importer. +/// `rewriteAliases` passes `root_dir: None` — upstream omits the argument there. +#[derive(Debug, Clone, Copy, Default)] +pub struct ResolveConfig<'a> { + pub aliases: Option<&'a AliasMap>, + pub root_dir: Option<&'a Path>, +} + +impl<'a> ResolveConfig<'a> { + pub fn of(options: &'a ResolvedOptions) -> Self { + Self { + aliases: options.aliases.as_ref(), + root_dir: options + .unstable_module_resolution + .as_ref() + .and_then(|m| m.root_dir.as_deref()), + } + } + + /// `rewriteAliases`' call shape: aliases, never a rootDir. + pub fn aliases_only(options: &'a ResolvedOptions) -> Self { + Self { + aliases: options.aliases.as_ref(), + root_dir: None, + } + } +} + +/// Filesystem seam: tests confine walks to a fixture root; oj wraps its resolver. +pub trait FsProvider: Sync { + /// Nearest package.json walking up from dirname(from) — a package.json in + /// `from` itself is skipped. Unparseable package.json stops the walk (None). + fn nearest_package(&self, from: &Path) -> Option<(String, PathBuf)>; + fn resolve_import( + &self, + specifier: &str, + importer: &Path, + config: ResolveConfig<'_>, + ) -> Option; + fn exists(&self, p: &Path) -> bool; + /// `fs.existsSync`: unlike [`FsProvider::exists`] a directory counts, which + /// is what the `/ROOT/` and absolute alias branches probe with. + fn exists_any(&self, p: &Path) -> bool { + p.exists() + } + /// blake3 hex of the file's bytes; `None` when unreadable. Cache dep + /// recording/replay goes through this so one-shot drivers may memoize. + fn hash_file(&self, p: &Path) -> Option { + std::fs::read(p) + .ok() + .map(|bytes| blake3::hash(&bytes).to_hex().to_string()) + } + /// Canonical form of a compile root (unreadable roots pass through); cache + /// recording/replay anchors relative entry paths to it. + fn canonicalize_root(&self, root: &Path) -> PathBuf { + std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()) + } +} + +pub struct StdFs; + +impl FsProvider for StdFs { + fn nearest_package(&self, from: &Path) -> Option<(String, PathBuf)> { + nearest_package_walk(from, None) + } + + fn resolve_import( + &self, + specifier: &str, + importer: &Path, + config: ResolveConfig<'_>, + ) -> Option { + resolve_import_with(self, specifier, importer, config) + } + + fn exists(&self, p: &Path) -> bool { + p.is_file() + } +} + +/// `StdFs` whose package.json walk stops at `root`; pin fixtures use it so the +/// walk never escapes into the repository's own package.json files. +pub struct BoundedFs { + pub root: PathBuf, +} + +impl FsProvider for BoundedFs { + fn nearest_package(&self, from: &Path) -> Option<(String, PathBuf)> { + nearest_package_walk(from, Some(&self.root)) + } + + fn resolve_import( + &self, + specifier: &str, + importer: &Path, + config: ResolveConfig<'_>, + ) -> Option { + resolve_import_with(self, specifier, importer, config) + } + + fn exists(&self, p: &Path) -> bool { + p.is_file() + } +} + +// parity: state-manager.js getPackageNameAndPath (dirname-first recursion). +fn nearest_package_walk(from: &Path, stop_at: Option<&Path>) -> Option<(String, PathBuf)> { + let mut folder = from.parent()?; + loop { + if stop_at.is_some_and(|stop| !folder.starts_with(stop)) { + return None; + } + let candidate = folder.join("package.json"); + if candidate.is_file() { + let raw = std::fs::read_to_string(&candidate).ok()?; + let json: serde_json::Value = serde_json::from_str(&raw).ok()?; + return Some((js_name_string(json.get("name")), folder.to_path_buf())); + } + if folder == Path::new("/") || folder.as_os_str().is_empty() { + return None; + } + folder = folder.parent()?; + } +} + +// parity: JS template coercion of packageJson.name (missing name → "undefined"). +fn js_name_string(name: Option<&serde_json::Value>) -> String { + match name { + None => "undefined".to_string(), + Some(serde_json::Value::String(s)) => s.clone(), + Some(serde_json::Value::Null) => "null".to_string(), + Some(serde_json::Value::Bool(b)) => b.to_string(), + Some(serde_json::Value::Number(n)) => { + crate::jsrt::js_number_to_string(n.as_f64().unwrap_or(f64::NAN)) + } + Some(_) => "[object Object]".to_string(), + } +} + +// parity: state-manager.js getPossibleFilePaths — raw path first, then each +// extension appended to the path with any known code extension stripped. +pub fn possible_file_paths(file_path: &str) -> Vec { + let stripped = EXTENSIONS + .iter() + .find(|ext| file_path.ends_with(**ext)) + .map_or(file_path, |ext| &file_path[..file_path.len() - ext.len()]); + let mut out = vec![file_path.to_string()]; + out.extend(EXTENSIONS.iter().map(|ext| format!("{stripped}{ext}"))); + out +} + +// parity: state-manager.js possibleAliasedPaths — the raw specifier first, then +// every matching key's values in declaration order, each value array in order. +fn possible_aliased_paths(import_path: &str, aliases: Option<&AliasMap>) -> Vec { + let mut result = vec![import_path.to_string()]; + let Some(aliases) = aliases.filter(|a| !a.is_empty()) else { + return result; + }; + for (alias, values) in aliases { + match alias.split_once('*') { + // Only the first two split parts are read: a third `*` is ignored. + Some((before, rest)) => { + let after = rest.split('*').next().unwrap_or(""); + if !import_path.starts_with(before) || !import_path.ends_with(after) { + continue; + } + let end = import_path.len() - after.len(); + let capture = &import_path[before.len().min(end)..end]; + // Every `*` in the value takes the same capture. + result.extend(values.iter().map(|v| v.replace('*', capture))); + } + None if alias == import_path => result.extend(values.iter().cloned()), + None => {} + } + } + result +} + +fn resolve_import_with( + fs: &dyn FsProvider, + specifier: &str, + importer: &Path, + config: ResolveConfig<'_>, +) -> Option { + for candidate in possible_file_paths(specifier) { + // A dot-leading specifier short-circuits before aliases are consulted. + if candidate.starts_with('.') { + if let Some(p) = module_resolve(fs, &candidate, importer) { + return Some(p); + } + continue; + } + for possible in possible_aliased_paths(&candidate, config.aliases) { + // Turbopack's placeholder, honored only when a rootDir is set. + if let Some(rest) = possible.strip_prefix(ROOT_PLACEHOLDER) + && let Some(root) = config.root_dir + { + let joined = node_path_join(root, rest); + if let Some(p) = first_existing(fs, &joined) { + return Some(p); + } + continue; + } + if Path::new(&possible).is_absolute() { + if let Some(p) = first_existing(fs, &possible) { + return Some(p); + } + continue; + } + if let Some(p) = module_resolve(fs, &possible, importer) { + return Some(p); + } + } + } + None +} + +/// One `moduleResolve` attempt. It realpaths its answer, which the existsSync +/// branches never do, so two spellings of one file can hash differently. +fn module_resolve(fs: &dyn FsProvider, specifier: &str, importer: &Path) -> Option { + let realpath = |p: PathBuf| std::fs::canonicalize(&p).unwrap_or(p); + if is_relative_specifier(specifier) { + let dir = importer.parent()?; + let joined = join_url_segments( + &normalize_path(dir), + &url_path(strip_query_fragment(specifier)), + ); + return finalize_file(fs, joined).map(realpath); + } + // Any other dot-leading form is an invalid package name — always an error. + if specifier.starts_with('.') { + return None; + } + if specifier.starts_with('#') { + return package_imports_resolve(fs, specifier, importer).map(realpath); + } + package_resolve(fs, specifier, importer).ok().map(realpath) +} + +/// Node `path.join` over two POSIX segments: concatenate, then normalize — +/// an absolute second segment appends rather than replacing the base. +fn node_path_join(base: &Path, rest: &str) -> String { + let base = base.to_string_lossy(); + let joined = format!("{}/{rest}", base.trim_end_matches('/')); + normalize_path(Path::new(&joined)) + .to_string_lossy() + .into_owned() +} + +/// The `fs.existsSync` sweep both non-moduleResolve branches share: the literal +/// path first, then each extension; a directory is a valid hit. +fn first_existing(fs: &dyn FsProvider, path: &str) -> Option { + possible_file_paths(path) + .into_iter() + .map(PathBuf::from) + .find(|p| fs.exists_any(p)) +} + +// Node ESM package resolution as executed by import-meta-resolve 4.2.1 (the +// oracle's resolver); every throw collapses to None per specifier candidate. + +const CONDITIONS: [&str; 2] = ["node", "import"]; + +// parity: moduleResolve shouldBeTreatedAsRelativeOrAbsolutePath. +fn is_relative_specifier(s: &str) -> bool { + s == "." || s == ".." || s.starts_with("./") || s.starts_with("../") +} + +// URL joins turn `\` into `/` before file paths are resolved. +fn url_path(s: &str) -> String { + s.replace('\\', "/") +} + +// parity: URL parsing — the first `?` or `#` ends the pathname; query and +// fragment never reach the filesystem lookup. +fn strip_query_fragment(s: &str) -> &str { + &s[..s.find(['?', '#']).unwrap_or(s.len())] +} + +/// URL-reference join: empty and single-dot segments drop, double-dot pops +/// (clamped at the root). Percent-encoded dot variants count (WHATWG URL). +fn join_url_segments(base: &Path, rel: &str) -> PathBuf { + let mut out = base.to_path_buf(); + for seg in rel.split('/') { + if seg.is_empty() || matches_encoded(seg, ".") { + continue; + } + if matches_encoded(seg, "..") { + out.pop(); + } else { + out.push(seg); + } + } + out +} + +// parity: finalizeResolution's encoded-separator rejection (`%2f`/`%5c`, /i). +fn has_encoded_separator(p: &Path) -> bool { + let s = p.to_string_lossy(); + s.as_bytes().windows(3).any(|w| { + w[0] == b'%' + && ((w[1] == b'2' && (w[2] | 0x20) == b'f') || (w[1] == b'5' && (w[2] | 0x20) == b'c')) + }) +} + +/// fileURLToPath's percent-decode of the pathname; `None` mirrors the +/// URIError on a malformed escape (decode-to-invalid-UTF-8 included). +fn percent_decode_path(p: &Path) -> Option { + let s = p.to_str()?; + if !s.contains('%') { + return Some(p.to_path_buf()); + } + let hex = |b: u8| (b as char).to_digit(16).map(|d| d as u8); + let mut bytes = Vec::with_capacity(s.len()); + let mut rest = s.as_bytes(); + while let Some((&b, tail)) = rest.split_first() { + if b == b'%' { + let [hi, lo, tail @ ..] = tail else { + return None; + }; + bytes.push(hex(*hi)? * 16 + hex(*lo)?); + rest = tail; + } else { + bytes.push(b); + rest = tail; + } + } + Some(PathBuf::from(String::from_utf8(bytes).ok()?)) +} + +fn finalize_file(fs: &dyn FsProvider, p: PathBuf) -> Option { + if has_encoded_separator(&p) { + return None; + } + let decoded = percent_decode_path(&p)?; + fs.exists(&decoded).then_some(decoded) +} + +/// One expected char matches its literal (ASCII case-insensitive) or `%XX` +/// with hex decoding to either case, mirroring the upstream regex /i flag. +fn matches_encoded(segment: &str, word: &str) -> bool { + let mut rest = segment.as_bytes(); + for &want in word.as_bytes() { + let hex = |b: u8| (b as char).to_digit(16).map(|d| d as u8); + rest = match rest { + [b'%', hi, lo, tail @ ..] + if hex(*hi) + .zip(hex(*lo)) + .is_some_and(|(a, b)| (a * 16 + b).eq_ignore_ascii_case(&want)) => + { + tail + } + [b, tail @ ..] if *b != b'%' && b.eq_ignore_ascii_case(&want) => tail, + _ => return false, + }; + } + rest.is_empty() +} + +// parity: deprecatedInvalidSegmentRegEx — a `.`/`..`/`node_modules` segment +// (encoded variants included) anywhere; these throw, empty segments only warn. +fn has_deprecated_segment(text: &str) -> bool { + text.split(['/', '\\']).any(|seg| { + matches_encoded(seg, ".") + || matches_encoded(seg, "..") + || matches_encoded(seg, "node_modules") + }) +} + +// parity: isArrayIndex — canonical JS number round-trip within [0, 2^32-1). +fn is_js_array_index(key: &str) -> bool { + key.parse::().is_ok_and(|n| { + crate::jsrt::js_number_to_string(n) == key && (0.0..4_294_967_295.0).contains(&n) + }) +} + +// parity: the `new URL(target)` probe — an ASCII-alpha scheme then `:`. +fn parses_as_url(s: &str) -> bool { + let Some(colon) = s.find(':') else { + return false; + }; + let mut chars = s[..colon].chars(); + chars.next().is_some_and(|c| c.is_ascii_alphabetic()) + && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) +} + +struct PackageJson { + name: Option, + main: Option, + exports: Option, + imports: Option, +} + +/// `Malformed` (unparseable JSON, or JSON `null`) aborts the candidate — +/// unlike `Missing`, which downstream treats as an empty config. +enum PackageRead { + Missing, + Malformed, + Parsed(PackageJson), +} + +fn read_package_json(dir: &Path) -> PackageRead { + let Ok(raw) = std::fs::read_to_string(dir.join("package.json")) else { + return PackageRead::Missing; + }; + let Ok(json) = serde_json::from_str::(&raw) else { + return PackageRead::Malformed; + }; + if json.is_null() { + return PackageRead::Malformed; + } + let string_field = |key: &str| json.get(key).and_then(|v| v.as_str()).map(str::to_string); + let value_field = |key: &str| json.get(key).filter(|v| !v.is_null()).cloned(); + PackageRead::Parsed(PackageJson { + name: string_field("name"), + main: string_field("main"), + exports: value_field("exports"), + imports: value_field("imports"), + }) +} + +fn parse_package_name(specifier: &str) -> Option<(&str, String)> { + let mut separator = specifier.find('/'); + if specifier.starts_with('@') { + let scope_sep = separator?; + separator = specifier[scope_sep + 1..] + .find('/') + .map(|i| i + scope_sep + 1); + } + let name = separator.map_or(specifier, |i| &specifier[..i]); + if name.is_empty() || name.starts_with('.') || name.contains('%') || name.contains('\\') { + return None; + } + let subpath = format!(".{}", separator.map_or("", |i| &specifier[i..])); + Some((name, subpath)) +} + +enum ScopeConfig { + Found(PathBuf, Box), + NotFound, + Malformed, +} + +// parity: getPackageScopeConfig — nearest package.json above `from`, never +// crossing a node_modules boundary; a malformed read throws mid-walk. +fn package_scope_config(from: &Path) -> ScopeConfig { + let Some(mut dir) = from.parent() else { + return ScopeConfig::NotFound; + }; + loop { + if dir.file_name().is_some_and(|f| f == "node_modules") { + return ScopeConfig::NotFound; + } + match read_package_json(dir) { + PackageRead::Parsed(pkg) => { + return ScopeConfig::Found(dir.to_path_buf(), Box::new(pkg)); + } + PackageRead::Malformed => return ScopeConfig::Malformed, + PackageRead::Missing => {} + } + let Some(parent) = dir.parent() else { + return ScopeConfig::NotFound; + }; + dir = parent; + } +} + +/// `Invalid` mirrors thrown ERR_INVALID_PACKAGE_TARGET (target arrays skip +/// past it); `Fatal` mirrors every other throw (they abort the candidate). +#[derive(Clone, Copy)] +enum ResolveErrKind { + Invalid, + Fatal, +} + +fn package_resolve( + fs: &dyn FsProvider, + specifier: &str, + importer: &Path, +) -> Result { + let (name, subpath) = parse_package_name(specifier).ok_or(ResolveErrKind::Fatal)?; + // ResolveSelf: the importer's own scope wins when named + exporting. + match package_scope_config(importer) { + ScopeConfig::Malformed => return Err(ResolveErrKind::Fatal), + ScopeConfig::Found(scope_dir, pkg) => { + if pkg.name.as_deref() == Some(name) + && let Some(exports) = &pkg.exports + { + return package_exports_resolve(fs, &scope_dir, &subpath, exports); + } + } + ScopeConfig::NotFound => {} + } + let mut dir = importer.parent(); + while let Some(d) = dir { + let pkg_dir = d.join("node_modules").join(name); + if pkg_dir.is_dir() { + let pkg = match read_package_json(&pkg_dir) { + PackageRead::Malformed => return Err(ResolveErrKind::Fatal), + PackageRead::Parsed(pkg) => Some(pkg), + PackageRead::Missing => None, + }; + if let Some(exports) = pkg.as_ref().and_then(|p| p.exports.as_ref()) { + return package_exports_resolve(fs, &pkg_dir, &subpath, exports); + } + if subpath == "." { + return legacy_main_resolve(fs, &pkg_dir, pkg.and_then(|p| p.main)) + .ok_or(ResolveErrKind::Fatal); + } + let file = join_url_segments(&pkg_dir, &url_path(strip_query_fragment(&subpath[2..]))); + return finalize_file(fs, file).ok_or(ResolveErrKind::Fatal); + } + dir = d.parent(); + } + Err(ResolveErrKind::Fatal) +} + +// parity: legacyMainResolve — main, main+ext, main/index.*, then index.*. +fn legacy_main_resolve( + fs: &dyn FsProvider, + pkg_dir: &Path, + main: Option, +) -> Option { + let mut guesses: Vec = Vec::new(); + if let Some(main) = &main { + guesses.push(main.clone()); + for ext in [".js", ".json", ".node"] { + guesses.push(format!("{main}{ext}")); + } + for ext in [".js", ".json", ".node"] { + guesses.push(format!("{main}/index{ext}")); + } + } + for ext in [".js", ".json", ".node"] { + guesses.push(format!("index{ext}")); + } + guesses + .iter() + .map(|g| join_url_segments(pkg_dir, &url_path(strip_query_fragment(g)))) + .find_map(|p| finalize_file(fs, p)) +} + +/// resolvePackageTarget result algebra: `Null` mirrors JS null — target +/// arrays keep going, condition objects and top-level lookups stop on it. +enum TargetOutcome { + Resolved(PathBuf), + Null, + Invalid, + Fatal, +} + +fn finish_exports_target( + fs: &dyn FsProvider, + outcome: TargetOutcome, +) -> Result { + match outcome { + // Existence is checked once at the end, never inside target arrays. + TargetOutcome::Resolved(p) => finalize_file(fs, p).ok_or(ResolveErrKind::Fatal), + TargetOutcome::Null | TargetOutcome::Fatal => Err(ResolveErrKind::Fatal), + TargetOutcome::Invalid => Err(ResolveErrKind::Invalid), + } +} + +fn package_exports_resolve( + fs: &dyn FsProvider, + pkg_dir: &Path, + subpath: &str, + exports: &serde_json::Value, +) -> Result { + // parity: isConditionalExportsMainSugar — string/array, or an object with + // no dot-keys, stands for { ".": exports }; mixed keys are invalid. + let sugar_map; + let map: &serde_json::Map = match exports { + serde_json::Value::Object(map) => { + let dotted = map.keys().filter(|k| k.starts_with('.')).count(); + if dotted == map.len() { + map + } else if dotted == 0 { + sugar_map = serde_json::Map::from_iter([(".".to_string(), exports.clone())]); + &sugar_map + } else { + return Err(ResolveErrKind::Fatal); + } + } + serde_json::Value::String(_) | serde_json::Value::Array(_) => { + sugar_map = serde_json::Map::from_iter([(".".to_string(), exports.clone())]); + &sugar_map + } + // A boolean/number root is neither sugar nor a map: hasOwnProperty + // and getOwnPropertyNames both miss — always PATH_NOT_EXPORTED. + _ => return Err(ResolveErrKind::Fatal), + }; + if let Some(target) = map.get(subpath) + && !subpath.contains('*') + && !subpath.ends_with('/') + { + let outcome = resolve_package_target(fs, pkg_dir, target, "", false, false); + return finish_exports_target(fs, outcome); + } + let subpath_units: Vec = subpath.encode_utf16().collect(); + let mut best_match: Option<(&str, String)> = None; + for key in map.keys() { + let key_units: Vec = key.encode_utf16().collect(); + let Some(star) = key_units.iter().position(|&u| u == u16::from(b'*')) else { + continue; + }; + if key_units[star + 1..].contains(&u16::from(b'*')) { + continue; + } + let (prefix, trailer) = (&key_units[..star], &key_units[star + 1..]); + if subpath_units.starts_with(prefix) + && subpath_units.len() >= key_units.len() + && subpath_units.ends_with(trailer) + && best_match + .as_ref() + .is_none_or(|(best, _)| pattern_key_compare(best, key) == 1) + && let Ok(matched) = + String::from_utf16(&subpath_units[star..subpath_units.len() - trailer.len()]) + { + best_match = Some((key, matched)); + } + } + let (key, matched) = best_match.ok_or(ResolveErrKind::Fatal)?; + let outcome = resolve_package_target(fs, pkg_dir, &map[key], &matched, true, false); + finish_exports_target(fs, outcome) +} + +// parity: patternKeyCompare — -1 when `a` sorts first (wins), 1 when `b` +// does; positions and lengths count UTF-16 units like JS string indexing. +fn pattern_key_compare(a: &str, b: &str) -> i32 { + let a: Vec = a.encode_utf16().collect(); + let b: Vec = b.encode_utf16().collect(); + let a_star = a.iter().position(|&u| u == u16::from(b'*')); + let b_star = b.iter().position(|&u| u == u16::from(b'*')); + let base_a = a_star.map_or(a.len(), |i| i + 1); + let base_b = b_star.map_or(b.len(), |i| i + 1); + if base_a > base_b { + return -1; + } + if base_b > base_a { + return 1; + } + if a_star.is_none() { + return 1; + } + if b_star.is_none() { + return -1; + } + if a.len() > b.len() { + return -1; + } + if b.len() > a.len() { + return 1; + } + 0 +} + +fn resolve_package_target( + fs: &dyn FsProvider, + pkg_dir: &Path, + target: &serde_json::Value, + matched: &str, + pattern: bool, + internal: bool, +) -> TargetOutcome { + match target { + serde_json::Value::String(target) => { + resolve_target_string(fs, pkg_dir, target, matched, pattern, internal) + } + serde_json::Value::Array(items) => { + if items.is_empty() { + return TargetOutcome::Null; + } + let mut last_invalid = false; + for item in items { + match resolve_package_target(fs, pkg_dir, item, matched, pattern, internal) { + TargetOutcome::Null => last_invalid = false, + TargetOutcome::Invalid => last_invalid = true, + other => return other, + } + } + if last_invalid { + TargetOutcome::Invalid + } else { + TargetOutcome::Null + } + } + serde_json::Value::Object(map) => { + if map.keys().any(|k| is_js_array_index(k)) { + return TargetOutcome::Fatal; + } + for (key, value) in map { + if key == "default" || CONDITIONS.contains(&key.as_str()) { + return resolve_package_target(fs, pkg_dir, value, matched, pattern, internal); + } + } + TargetOutcome::Null + } + serde_json::Value::Null => TargetOutcome::Null, + _ => TargetOutcome::Invalid, + } +} + +fn resolve_target_string( + fs: &dyn FsProvider, + pkg_dir: &Path, + target: &str, + matched: &str, + pattern: bool, + internal: bool, +) -> TargetOutcome { + if !matched.is_empty() && !pattern && !target.ends_with('/') { + return TargetOutcome::Invalid; + } + if !target.starts_with("./") { + // Internal (#imports) targets may re-enter bare package resolution, + // but a target that parses as a URL is invalid outright. + if internal && !target.starts_with("../") && !target.starts_with('/') { + if parses_as_url(target) { + return TargetOutcome::Invalid; + } + let expanded = if pattern { + target.replace('*', matched) + } else { + format!("{target}{matched}") + }; + return match package_resolve(fs, &expanded, &pkg_dir.join("package.json")) { + Ok(p) => TargetOutcome::Resolved(p), + Err(ResolveErrKind::Invalid) => TargetOutcome::Invalid, + Err(ResolveErrKind::Fatal) => TargetOutcome::Fatal, + }; + } + return TargetOutcome::Invalid; + } + if has_deprecated_segment(&target[2..]) { + return TargetOutcome::Invalid; + } + let resolved = join_url_segments(pkg_dir, &url_path(strip_query_fragment(&target[2..]))); + // Containment: checked before `*` substitution, like upstream. + if !resolved.starts_with(normalize_path(pkg_dir)) { + return TargetOutcome::Invalid; + } + if matched.is_empty() { + return TargetOutcome::Resolved(resolved); + } + if has_deprecated_segment(matched) { + // parity: throwInvalidSubpath — not an invalid-target, arrays abort. + return TargetOutcome::Fatal; + } + let path = if pattern { + // parity: `*` substitutes into the href, so a `?`/`#` inside the + // matched subpath truncates the pathname there. + let replaced = resolved.to_string_lossy().replace('*', &url_path(matched)); + normalize_path(Path::new(strip_query_fragment(&replaced))) + } else { + join_url_segments(&resolved, &url_path(strip_query_fragment(matched))) + }; + TargetOutcome::Resolved(path) +} + +// parity: PACKAGE_IMPORTS_RESOLVE over the importer scope's "imports" map. +fn package_imports_resolve( + fs: &dyn FsProvider, + specifier: &str, + importer: &Path, +) -> Option { + if specifier == "#" || specifier.starts_with("#/") || specifier.ends_with('/') { + return None; + } + let ScopeConfig::Found(scope_dir, pkg) = package_scope_config(importer) else { + return None; + }; + let Some(serde_json::Value::Object(map)) = pkg.imports else { + return None; + }; + if let Some(target) = map.get(specifier) + && !specifier.contains('*') + { + return match resolve_package_target(fs, &scope_dir, target, "", false, true) { + TargetOutcome::Resolved(p) => finalize_file(fs, p), + _ => None, + }; + } + let specifier_units: Vec = specifier.encode_utf16().collect(); + let mut best_match: Option<(&str, String)> = None; + for key in map.keys() { + let key_units: Vec = key.encode_utf16().collect(); + let Some(star) = key_units.iter().position(|&u| u == u16::from(b'*')) else { + continue; + }; + if key_units[star + 1..].contains(&u16::from(b'*')) { + continue; + } + // Oracle quirk (import-meta-resolve 4.2.1): the startsWith prefix is + // the key minus its LAST UTF-16 unit, so only star-final keys match. + let (prefix, trailer) = (&key_units[..key_units.len() - 1], &key_units[star + 1..]); + if specifier_units.starts_with(prefix) + && specifier_units.len() >= key_units.len() + && specifier_units.ends_with(trailer) + && best_match + .as_ref() + .is_none_or(|(best, _)| pattern_key_compare(best, key) == 1) + && let Ok(matched) = + String::from_utf16(&specifier_units[star..specifier_units.len() - trailer.len()]) + { + best_match = Some((key, matched)); + } + } + let (key, matched) = best_match?; + match resolve_package_target(fs, &scope_dir, &map[key].clone(), &matched, true, true) { + TargetOutcome::Resolved(p) => finalize_file(fs, p), + _ => None, + } +} + +// parity: state-manager.js matchesFileSuffix — bare suffix or suffix + code ext. +pub fn matches_file_suffix(suffix: &str, filename: &str) -> bool { + if filename.ends_with(suffix) { + return true; + } + EXTENSIONS + .iter() + .any(|ext| filename.ends_with(&format!("{suffix}{ext}"))) +} + +// `.transformed` is a literal upstream, never derived from the option. +pub fn is_theme_specifier(specifier: &str, theme_extension: &str) -> bool { + matches_file_suffix(theme_extension, specifier) + || matches_file_suffix(&format!("{theme_extension}.const"), specifier) + || matches_file_suffix(".transformed", specifier) +} + +// parity: state-manager.js getCanonicalFilePath. +pub fn canonical_file_path(fs: &dyn FsProvider, file: &Path, root_dir: Option<&Path>) -> String { + if let Some((name, dir)) = fs.nearest_package(file) { + return format!("{name}:{}", path_relative(&dir, file)); + } + if let Some(root) = root_dir { + return path_relative(root, file); + } + let basename = file + .file_name() + .map(|f| f.to_string_lossy().into_owned()) + .unwrap_or_default(); + format!("_unknown_path_:{basename}") +} + +/// `importPathResolver`: `Some(canonical theme name)` mirrors +/// `['themeNameRef', …]`, `None` mirrors `false`. +pub fn import_path_resolver( + fs: &dyn FsProvider, + specifier: &str, + source_file: Option<&Path>, + options: &ResolvedOptions, +) -> Option { + let source_file = source_file?; + let module_resolution = options.unstable_module_resolution.as_ref()?; + if !is_theme_specifier(specifier, &module_resolution.theme_file_extension) { + return None; + } + match module_resolution.kind { + // Haste never touches the filesystem: the specifier is the module name. + ModuleResolutionType::Haste => Some(add_file_extension(specifier, source_file)), + ModuleResolutionType::CommonJs => { + let resolved = fs.resolve_import(specifier, source_file, ResolveConfig::of(options))?; + Some(canonical_file_path( + fs, + &resolved, + module_resolution.root_dir.as_deref(), + )) + } + } +} + +/// `rewriteAliases`' new import source, or `None` to leave it alone. parity: +/// index.js Program.exit hardcodes `.stylex` and passes no rootDir. +pub fn rewritten_import_source( + fs: &dyn FsProvider, + source: &str, + filename: &Path, + options: &ResolvedOptions, +) -> Option { + if !matches_file_suffix(THEME_FILE_EXTENSION, source) { + return None; + } + let resolved = fs.resolve_import(source, filename, ResolveConfig::aliases_only(options))?; + let relative = get_relative_path(filename, &resolved); + let stripped = EXTENSIONS + .iter() + .find(|ext| relative.ends_with(**ext)) + .map_or(relative.as_str(), |ext| { + &relative[..relative.len() - ext.len()] + }); + Some(stripped.to_string()) +} + +// parity: state-manager.js getRelativePath — posix, always `./`-prefixed. +pub fn get_relative_path(from: &Path, to: &Path) -> String { + let dir = from.parent().unwrap_or(Path::new("")); + let relative = path_relative(dir, to); + if relative.starts_with('.') { + relative + } else { + format!("./{relative}") + } +} + +// parity: state-manager.js addFileExtension — the extension is taken from the +// importing file, never validated, because haste cannot resolve the real one. +pub fn add_file_extension(imported: &str, source_file: &Path) -> String { + if EXTENSIONS.iter().any(|ext| imported.ends_with(ext)) { + return imported.to_string(); + } + format!("{imported}{}", node_extname(&source_file.to_string_lossy())) +} + +// parity: Node path.basename — trailing separators are ignored. +pub fn node_basename(path: &str) -> &str { + let trimmed = path.trim_end_matches('/'); + match trimmed.rfind('/') { + Some(idx) => &trimmed[idx + 1..], + None => trimmed, + } +} + +// parity: Node path.extname's scan-from-the-end state machine, including its +// empty answers for a dot-only basename (".stylex", ".."). +pub fn node_extname(path: &str) -> &str { + let bytes = path.as_bytes(); + let (mut start_dot, mut start_part, mut end) = (usize::MAX, 0usize, usize::MAX); + let mut matched_slash = true; + let mut pre_dot_state = 0i32; + for i in (0..bytes.len()).rev() { + let code = bytes[i]; + if code == b'/' { + if !matched_slash { + start_part = i + 1; + break; + } + continue; + } + if end == usize::MAX { + matched_slash = false; + end = i + 1; + } + if code == b'.' { + if start_dot == usize::MAX { + start_dot = i; + } else if pre_dot_state != 1 { + pre_dot_state = 1; + } + } else if start_dot != usize::MAX { + pre_dot_state = -1; + } + } + if start_dot == usize::MAX + || end == usize::MAX + || pre_dot_state == 0 + || (pre_dot_state == 1 && start_dot == end - 1 && start_dot == start_part + 1) + { + return ""; + } + &path[start_dot..end] +} + +// parity: shared/utils/file-based-identifier.js genFileBasedIdentifier. +pub fn gen_file_based_identifier(file_name: &str, export_name: &str, key: Option<&str>) -> String { + match key { + Some(key) => format!("{file_name}//{export_name}.{key}"), + None => format!("{file_name}//{export_name}"), + } +} + +/// Node `path.relative` over absolute POSIX-style paths. +pub fn path_relative(from: &Path, to: &Path) -> String { + let from = normalize_path(from); + let to = normalize_path(to); + let from_parts: Vec<&std::ffi::OsStr> = from + .components() + .filter_map(|c| match c { + Component::Normal(p) => Some(p), + _ => None, + }) + .collect(); + let to_parts: Vec<&std::ffi::OsStr> = to + .components() + .filter_map(|c| match c { + Component::Normal(p) => Some(p), + _ => None, + }) + .collect(); + let common = from_parts + .iter() + .zip(&to_parts) + .take_while(|(a, b)| **a == **b) + .count(); + let mut segments: Vec = vec!["..".to_string(); from_parts.len() - common]; + segments.extend( + to_parts[common..] + .iter() + .map(|p| p.to_string_lossy().into_owned()), + ); + segments.join("/") +} + +/// Lexical `.`/`..` folding (no symlink resolution, matching Node URL joins). +pub fn normalize_path(path: &Path) -> PathBuf { + let mut out = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + if !out.pop() { + out.push(".."); + } + } + other => out.push(other), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn probe_order_is_raw_then_each_extension() { + assert_eq!( + possible_file_paths("./a.stylex"), + vec![ + "./a.stylex", + "./a.stylex.js", + "./a.stylex.ts", + "./a.stylex.tsx", + "./a.stylex.jsx", + "./a.stylex.mjs", + "./a.stylex.cjs", + ] + ); + // A known code extension is stripped before probing, raw stays first. + assert_eq!( + possible_file_paths("./a.stylex.js")[..3], + ["./a.stylex.js", "./a.stylex.js", "./a.stylex.ts"].map(String::from) + ); + assert_eq!(possible_file_paths("./a.stylex.cjs")[0], "./a.stylex.cjs"); + assert_eq!(possible_file_paths("./a.stylex.cjs")[1], "./a.stylex.js"); + } + + fn expansions(specifier: &str, aliases: &[(&str, &[&str])]) -> Vec { + let map: AliasMap = aliases + .iter() + .map(|(k, v)| { + ( + (*k).to_string(), + v.iter().map(|s| (*s).to_string()).collect(), + ) + }) + .collect(); + possible_aliased_paths(specifier, Some(&map)) + } + + #[test] + fn alias_expansion_order_and_capture() { + // The raw specifier is always candidate #0; matches follow in + // declaration order, each value array in its own order. + assert_eq!( + expansions( + "@lib/c.stylex", + &[("@lib/*", &["/a/*", "/b/*"]), ("@x", &["/z"])] + ), + ["@lib/c.stylex", "/a/c.stylex", "/b/c.stylex"] + ); + // A star-free key is exact equality, not a prefix. + assert_eq!( + expansions("@lib/c.stylex", &[("@lib", &["/a"])]), + ["@lib/c.stylex"] + ); + // Suffix keys, a bare star, and the ignored third star. + assert_eq!( + expansions("~c.stylex", &[("~*.stylex", &["/a/*.stylex.ts"])])[1], + "/a/c.stylex.ts" + ); + assert_eq!( + expansions("@x/c.stylex", &[("*", &["/a/*"])])[1], + "/a/@x/c.stylex" + ); + assert_eq!( + expansions("@a/c.stylex", &[("@a/*.stylex*", &["/a/*.stylex.ts"])])[1], + "/a/c.stylex.ts" + ); + // Every star in the value takes the same capture; overlapping + // before/after (JS slice with start > end) captures the empty string. + assert_eq!( + expansions("@d/d.stylex", &[("@d/*", &["/l/*/*"])])[1], + "/l/d.stylex/d.stylex" + ); + assert_eq!( + expansions( + "@lib/c.stylex", + &[("@lib/c.stylex*c.stylex", &["/alt/*c.stylex"])] + )[1], + "/alt/c.stylex" + ); + // An empty map short-circuits to the raw specifier alone. + assert_eq!(expansions("@lib/c.stylex", &[]), ["@lib/c.stylex"]); + assert_eq!( + possible_aliased_paths("@lib/c.stylex", None), + ["@lib/c.stylex"] + ); + } + + #[test] + fn relative_paths_for_rewritten_sources() { + let rel = |from: &str, to: &str| get_relative_path(Path::new(from), Path::new(to)); + assert_eq!( + rel("/r/src/input.ts", "/r/lib/c.stylex.ts"), + "../lib/c.stylex.ts" + ); + // A same-directory target still gains the `./` prefix. + assert_eq!( + rel("/r/src/input.ts", "/r/src/c.stylex.ts"), + "./c.stylex.ts" + ); + assert_eq!(rel("/r/src/input.ts", "/r/lib"), "../lib"); + } + + #[test] + fn root_placeholder_joins_like_node() { + assert_eq!(node_path_join(Path::new("/r"), "lib/c.ts"), "/r/lib/c.ts"); + // An absolute rest appends instead of replacing the base. + assert_eq!(node_path_join(Path::new("/r"), "/lib/c.ts"), "/r/lib/c.ts"); + assert_eq!( + node_path_join(Path::new("/r/"), "lib/deep/../c.ts"), + "/r/lib/c.ts" + ); + } + + #[test] + fn suffix_matching() { + assert!(matches_file_suffix(".stylex", "foo.stylex")); + assert!(matches_file_suffix(".stylex", "foo.stylex.ts")); + assert!(matches_file_suffix(".stylex", "a/b/foo.stylex.mjs")); + assert!(!matches_file_suffix(".stylex", "foo.stylex.const.ts")); + assert!(!matches_file_suffix(".stylex", "foostylex.ts")); + assert!(matches_file_suffix(".stylex.const", "foo.stylex.const.ts")); + assert!(is_theme_specifier("./x.stylex", THEME_FILE_EXTENSION)); + assert!(is_theme_specifier( + "./x.stylex.const.js", + THEME_FILE_EXTENSION + )); + assert!(is_theme_specifier( + "./x.transformed.js", + THEME_FILE_EXTENSION + )); + assert!(!is_theme_specifier("./helpers", THEME_FILE_EXTENSION)); + assert!(!is_theme_specifier("./helpers.ts", THEME_FILE_EXTENSION)); + } + + #[test] + fn suffix_matching_under_a_configured_extension() { + assert!(matches_file_suffix("cssvars", "src/defaultcssvars.js")); + assert!(matches_file_suffix("", "src/whatever.ts")); + assert!(matches_file_suffix("/vars", "src/theme/vars.ts")); + assert!(!matches_file_suffix(".css", "src/tokens.css.mts")); + assert!(!matches_file_suffix(".css", "src/tokens.CSS.ts")); + assert!(!matches_file_suffix(".css", "src/tokensXcssY.ts")); + assert!(is_theme_specifier("./tokens.css", ".css")); + assert!(is_theme_specifier("./tokens.css.const", ".css")); + // `.transformed` stays allowed, the default extension stops resolving. + assert!(is_theme_specifier("./tokens.transformed", ".css")); + assert!(!is_theme_specifier("./tokens.stylex", ".css")); + } + + #[test] + fn file_based_identifier() { + assert_eq!( + gen_file_based_identifier("pkg:src/t.stylex.ts", "colors", None), + "pkg:src/t.stylex.ts//colors" + ); + assert_eq!( + gen_file_based_identifier("pkg:t.stylex.ts", "colors", Some("accent")), + "pkg:t.stylex.ts//colors.accent" + ); + assert_eq!( + gen_file_based_identifier("f", "e", Some("button.primary")), + "f//e.button.primary" + ); + } + + fn node_fixture() -> PathBuf { + static ONCE: std::sync::Once = std::sync::Once::new(); + let root = std::env::temp_dir().join("stylex-rs-node-resolution-fixture"); + ONCE.call_once(|| { + let write = |rel: &str, content: &str| { + let path = root.join(rel); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, content).unwrap(); + }; + write( + "package.json", + r##"{"name":"self-pkg","exports":{"./self.stylex":"./src/self.stylex.js"},"imports":{"#tok/*":"./src/hashed/*.js","#hash/*.stylex":"./src/hashed/*.stylex.js"}}"##, + ); + write("src/self.stylex.js", ""); + write("src/hashed/deep.stylex.js", ""); + write( + "node_modules/design-tokens/package.json", + r#"{"name":"design-tokens","exports":{"./theme.stylex":"./out/theme.stylex.js","./pat/*.stylex":"./out/pat-*.stylex.js","./cond.stylex":{"browser":"./out/browser.js","node":"./out/node.js","default":"./out/default.js"},"./blocked.stylex":null}}"#, + ); + write("node_modules/design-tokens/out/theme.stylex.js", ""); + write("node_modules/design-tokens/out/pat-deep.stylex.js", ""); + write("node_modules/design-tokens/out/node.js", ""); + write("node_modules/design-tokens/out/default.js", ""); + write("node_modules/plain-tokens/package.json", r#"{"name":"plain-tokens"}"#); + write("node_modules/plain-tokens/lib/theme.stylex.js", ""); + write( + "node_modules/tokens.stylex/package.json", + r#"{"name":"tokens.stylex","main":"lib/entry"}"#, + ); + write("node_modules/tokens.stylex/lib/entry.js", ""); + }); + // resolve_import realpaths results; macOS tempdirs live under a + // /var → /private/var symlink, so compare against the real root. + std::fs::canonicalize(&root).unwrap_or(root) + } + + #[test] + fn node_package_resolution() { + let root = node_fixture(); + let importer = root.join("src/input.ts"); + let resolve = |spec: &str| { + StdFs + .resolve_import(spec, &importer, ResolveConfig::default()) + .map(|p| path_relative(&root, &p)) + }; + // exports: exact, pattern, conditions (node beats default), blocked. + assert_eq!( + resolve("design-tokens/theme.stylex").as_deref(), + Some("node_modules/design-tokens/out/theme.stylex.js") + ); + assert_eq!( + resolve("design-tokens/pat/deep.stylex").as_deref(), + Some("node_modules/design-tokens/out/pat-deep.stylex.js") + ); + assert_eq!( + resolve("design-tokens/cond.stylex").as_deref(), + Some("node_modules/design-tokens/out/node.js") + ); + assert_eq!(resolve("design-tokens/blocked.stylex"), None); + assert_eq!(resolve("design-tokens/missing.stylex"), None); + // no exports: URL-join subpath; bare root: legacy main resolution. + assert_eq!( + resolve("plain-tokens/lib/theme.stylex").as_deref(), + Some("node_modules/plain-tokens/lib/theme.stylex.js") + ); + assert_eq!( + resolve("tokens.stylex").as_deref(), + Some("node_modules/tokens.stylex/lib/entry.js") + ); + // self-reference through the scope package's own exports. + assert_eq!( + resolve("self-pkg/self.stylex").as_deref(), + Some("src/self.stylex.js") + ); + // imports maps: star-final keys match; the oracle's non-final-star + // quirk (prefix = key minus last char) keeps #hash/*.stylex dead. + assert_eq!( + resolve("#tok/deep.stylex").as_deref(), + Some("src/hashed/deep.stylex.js") + ); + assert_eq!(resolve("#hash/deep.stylex"), None); + } + + /// Every expectation here was executed against import-meta-resolve 4.2.1 + /// (scratchpad imr-r4-probe.mjs, 2026-08-28) — codex r4 findings 2 + 11. + fn hostile_fixture() -> PathBuf { + static ONCE: std::sync::Once = std::sync::Once::new(); + let root = std::env::temp_dir().join("stylex-rs-node-resolution-hostile"); + ONCE.call_once(|| { + let write = |rel: &str, content: &str| { + let path = root.join(rel); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, content).unwrap(); + }; + write( + "package.json", + r##"{"name":"hostile-root","imports":{"#tok/*":"./src/hashed/*.js","#url":"https://example.com/x.js","#bare":"sealed/tokens/x.stylex","#barestar/*":"sealed/tokens/*.stylex","#arr/*":[null,"./src/hashed/*.js"],"#dead/*.stylex":"./src/hashed/*.stylex.js"}}"##, + ); + write("src/input.ts", ""); + write("src/hashed/deep.stylex.js", ""); + write("src/hashed/x.js", ""); + write("src/esc.js", ""); + write("src/esc%2fx.stylex.js", ""); + write("src/.hidden.stylex.js", ""); + write( + "node_modules/sealed/package.json", + r#"{"name":"sealed","exports":{ + "./tokens/*.stylex":"./public/*.stylex.js", + "./arrnull.stylex":[null,"./ok.stylex.js"], + "./arrbad.stylex":["bad-no-dot-slash","./ok.stylex.js"], + "./arrempty.stylex":[], + "./arrnested.stylex":[[],"./ok.stylex.js"], + "./arrmiss.stylex":["./gone.stylex.js","./ok.stylex.js"], + "./multistar/*.stylex":"./out/*-*.stylex.js", + "./nested.stylex":{"node":{"unknown":"./inner.stylex.js"},"default":"./default.stylex.js"}, + "./nesteddef.stylex":{"node":{"unknown":"./inner.stylex.js","default":"./innerdef.stylex.js"},"default":"./default.stylex.js"}, + "./num.stylex":{"0":"./zero.stylex.js","default":"./default.stylex.js"}, + "./numarr.stylex":[{"0":"./zero.stylex.js"},"./ok.stylex.js"], + "./badtarget.stylex":"./../escape.stylex.js", + "./nmtarget.stylex":"./node_modules/inner.stylex.js", + "./dslash.stylex":".//ok.stylex.js", + "./dotseg.stylex":"./././ok.stylex.js", + "./urltarget.stylex":"file:///etc/passwd", + "./numtarget.stylex":[42,"./ok.stylex.js"] + }}"#, + ); + for f in [ + "public/x.stylex.js", + "public/a%2fb.stylex.js", + "public/a\\b.stylex.js", + "public/a/b.stylex.js", + "ok.stylex.js", + "out/deep-deep.stylex.js", + "default.stylex.js", + "inner.stylex.js", + "innerdef.stylex.js", + "zero.stylex.js", + "private.stylex.js", + "node_modules/inner.stylex.js", + ] { + write(&format!("node_modules/sealed/{f}"), ""); + } + write("node_modules/escape.stylex.js", ""); + write("node_modules/badjson/package.json", "{ nope"); + write("node_modules/badjson/lib/x.stylex.js", ""); + write("node_modules/badjson/index.js", ""); + write("node_modules/plain/lib/x.stylex.js", ""); + write("node_modules/plain/index.js", ""); + write("scoped/package.json", "{ also nope"); + write("scoped/input.ts", ""); + }); + std::fs::canonicalize(&root).unwrap_or(root) + } + + #[test] + fn exports_invalid_segments_reject() { + let root = hostile_fixture(); + let importer = root.join("src/input.ts"); + let resolve = |spec: &str| { + StdFs + .resolve_import(spec, &importer, ResolveConfig::default()) + .map(|p| path_relative(&root, &p)) + }; + assert_eq!( + resolve("sealed/tokens/x.stylex").as_deref(), + Some("node_modules/sealed/public/x.stylex.js") + ); + // `..`/`.`/node_modules subpath segments reject even when the + // normalized file exists (finding 2's private-file escape). + assert_eq!(resolve("sealed/tokens/../private.stylex"), None); + assert_eq!(resolve("sealed/tokens/%2e%2e/private.stylex"), None); + assert_eq!(resolve("sealed/tokens/%2E%2E/private.stylex"), None); + assert_eq!(resolve("sealed/tokens/./x.stylex"), None); + assert_eq!(resolve("sealed/tokens/node_modules/x.stylex"), None); + assert_eq!(resolve("sealed/tokens/NoDe_MoDuLeS/x.stylex"), None); + assert_eq!(resolve("sealed/tokens/%6eode_modules/x.stylex"), None); + assert_eq!(resolve("sealed/tokens/..\\private.stylex"), None); + // Encoded separators reject at finalize; the literal files exist. + assert_eq!(resolve("sealed/tokens/a%2fb.stylex"), None); + assert_eq!(resolve("sealed/tokens/a%5Cb.stylex"), None); + assert_eq!(resolve("./esc%2fx.stylex"), None); + // A raw backslash is a URL path separator, not a filename char. + assert_eq!( + resolve("sealed/tokens/a\\b.stylex").as_deref(), + Some("node_modules/sealed/public/a/b.stylex.js") + ); + // Target-side invalid segments reject; empty segments only warn. + assert_eq!(resolve("sealed/badtarget.stylex"), None); + assert_eq!(resolve("sealed/nmtarget.stylex"), None); + assert_eq!(resolve("sealed/dotseg.stylex"), None); + assert_eq!( + resolve("sealed/dslash.stylex").as_deref(), + Some("node_modules/sealed/ok.stylex.js") + ); + assert_eq!(resolve("sealed/urltarget.stylex"), None); + // No-exports packages take the raw URL join — `..` allowed there. + assert_eq!( + resolve("plain/lib/x.stylex").as_deref(), + Some("node_modules/plain/lib/x.stylex.js") + ); + assert_eq!( + resolve("plain/lib/../lib/x.stylex").as_deref(), + Some("node_modules/plain/lib/x.stylex.js") + ); + assert_eq!( + resolve("plain/lib/../../escape.stylex").as_deref(), + Some("node_modules/escape.stylex.js") + ); + // Dot-leading non-relative specifiers are invalid package names. + assert_eq!(resolve(".hidden.stylex"), None); + } + + #[test] + fn exports_result_algebra() { + let root = hostile_fixture(); + let importer = root.join("src/input.ts"); + let resolve = |spec: &str| { + StdFs + .resolve_import(spec, &importer, ResolveConfig::default()) + .map(|p| path_relative(&root, &p)) + }; + // Arrays: null and invalid targets fall through; fatal errors and + // missing files do not; an exhausted/empty array is not exported. + assert_eq!( + resolve("sealed/arrnull.stylex").as_deref(), + Some("node_modules/sealed/ok.stylex.js") + ); + assert_eq!( + resolve("sealed/arrbad.stylex").as_deref(), + Some("node_modules/sealed/ok.stylex.js") + ); + assert_eq!( + resolve("sealed/arrnested.stylex").as_deref(), + Some("node_modules/sealed/ok.stylex.js") + ); + assert_eq!( + resolve("sealed/numtarget.stylex").as_deref(), + Some("node_modules/sealed/ok.stylex.js") + ); + assert_eq!(resolve("sealed/arrempty.stylex"), None); + assert_eq!(resolve("sealed/arrmiss.stylex"), None); + // Multi-star targets replace every star with the matched subpath. + assert_eq!( + resolve("sealed/multistar/deep.stylex").as_deref(), + Some("node_modules/sealed/out/deep-deep.stylex.js") + ); + // A matched condition whose object has no active inner key yields + // null and STOPS — the outer default is never consulted. + assert_eq!(resolve("sealed/nested.stylex"), None); + assert_eq!( + resolve("sealed/nesteddef.stylex").as_deref(), + Some("node_modules/sealed/innerdef.stylex.js") + ); + // Numeric condition keys are invalid-package-config, even alongside + // a default, and abort target arrays instead of falling through. + assert_eq!(resolve("sealed/num.stylex"), None); + assert_eq!(resolve("sealed/numarr.stylex"), None); + // Malformed package.json is fatal, not absent-with-legacy-fallback. + assert_eq!(resolve("badjson/lib/x.stylex"), None); + assert_eq!(resolve("badjson"), None); + // Missing package.json (dir exists) still gets legacy resolution. + assert_eq!( + resolve("plain").as_deref(), + Some("node_modules/plain/index.js") + ); + } + + #[test] + fn imports_algebra_and_malformed_scope() { + let root = hostile_fixture(); + let importer = root.join("src/input.ts"); + let resolve = |spec: &str| { + StdFs + .resolve_import(spec, &importer, ResolveConfig::default()) + .map(|p| path_relative(&root, &p)) + }; + assert_eq!( + resolve("#tok/deep.stylex").as_deref(), + Some("src/hashed/deep.stylex.js") + ); + // Subpath validation applies to imports patterns too. + assert_eq!(resolve("#tok/../esc"), None); + assert_eq!(resolve("#tok/x/"), None); + // Internal targets that parse as URLs are invalid. + assert_eq!(resolve("#url"), None); + // Bare re-entry through another package's exports, exact + pattern. + assert_eq!( + resolve("#bare").as_deref(), + Some("node_modules/sealed/public/x.stylex.js") + ); + assert_eq!( + resolve("#barestar/x").as_deref(), + Some("node_modules/sealed/public/x.stylex.js") + ); + // Null-in-array fallback holds for imports maps as well. + assert_eq!(resolve("#arr/x").as_deref(), Some("src/hashed/x.js")); + // Non-star-final keys stay dead (prefix = key minus last char). + assert_eq!(resolve("#dead/deep.stylex"), None); + // A malformed scope package.json is fatal for every bare/# import. + let scoped = root.join("scoped/input.ts"); + assert_eq!( + StdFs.resolve_import("sealed/tokens/x.stylex", &scoped, ResolveConfig::default()), + None + ); + assert_eq!( + StdFs.resolve_import("#tok/deep.stylex", &scoped, ResolveConfig::default()), + None + ); + } + + /// Every expectation executed against import-meta-resolve 4.2.1 + /// (scratchpad imr-r5-probe.mjs, 2026-08-28) — codex r5 findings 3-5. + fn r5_fixture() -> PathBuf { + static ONCE: std::sync::Once = std::sync::Once::new(); + let root = std::env::temp_dir().join("stylex-rs-node-resolution-r5"); + ONCE.call_once(|| { + let write = |rel: &str, content: &str| { + let path = root.join(rel); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, content).unwrap(); + }; + write( + "package.json", + r##"{"name":"r5-root","imports":{"#x*é":"./src/star/*.stylex.js","#y*":"./src/star/*.stylex.js","#q*":"./src/star/*.stylex.js?mode=theme","#é*":"./src/star/*.stylex.js","#bool.stylex":["bad-bool","./src/fallback.stylex.js"],"#num.stylex":["bad-num","./src/fallback.stylex.js"]}}"##, + ); + for f in [ + "src/input.ts", + "src/star/foo.stylex.stylex.js", + "src/star/*foo.stylex.stylex.js", + "src/star/-theme.stylex.js", + "src/fallback.stylex.js", + "src/x%20.stylex.js", + "src/x .stylex.js", + "src/only-enc%21.stylex.js", + "src/pct é.stylex.js", + "src/q.stylex", + ] { + write(f, ""); + } + write( + "node_modules/bad-bool/package.json", + r#"{"name":"bad-bool","exports":true}"#, + ); + write("node_modules/bad-bool/index.js", ""); + write( + "node_modules/bad-num/package.json", + r#"{"name":"bad-num","exports":42}"#, + ); + write("node_modules/bad-num/index.js", ""); + write( + "node_modules/qexp/package.json", + r##"{"name":"qexp","exports":{ + "./q.stylex":"./out/q.stylex.js?mode=x", + "./frag.stylex":"./out/q.stylex.js#frag", + "./qstar/*.stylex":"./out/*.stylex.js?v=*", + "./enc/*.stylex":"./out/*.stylex.js", + "./malformed.stylex":"./out/q%2g.stylex.js", + "./encdot/*.stylex":"./out/%2e%2e/*.stylex.js" + }}"##, + ); + for f in [ + "out/q.stylex.js", + "out/deep.stylex.js", + "out/sp ace.stylex.js", + "out/q%2g.stylex.js", + "escape.stylex.js", + ] { + write(&format!("node_modules/qexp/{f}"), ""); + } + }); + std::fs::canonicalize(&root).unwrap_or(root) + } + + #[test] + fn r5_unicode_imports_keys() { + let root = r5_fixture(); + let importer = root.join("src/input.ts"); + let resolve = |spec: &str| { + StdFs + .resolve_import(spec, &importer, ResolveConfig::default()) + .map(|p| path_relative(&root, &p)) + }; + // The é key's quirk prefix is "#x*" (slice(0,-1) in UTF-16 units); + // this specifier byte-panicked before the fix. + assert_eq!(resolve("#xfoo.stylex"), None); + // A literal-star specifier does satisfy the quirk prefix; matched + // starts at the star's UNIT index, keeping the star itself. + assert_eq!( + resolve("#x*foo.stylexé").as_deref(), + Some("src/star/*foo.stylex.stylex.js") + ); + assert_eq!( + resolve("#yfoo.stylex").as_deref(), + Some("src/star/foo.stylex.stylex.js") + ); + // Multibyte prefix: matched must slice at unit index 2, not byte 3. + assert_eq!( + resolve("#éfoo.stylex").as_deref(), + Some("src/star/foo.stylex.stylex.js") + ); + } + + #[test] + fn r5_file_url_semantics() { + let root = r5_fixture(); + let importer = root.join("src/input.ts"); + let resolve = |spec: &str| { + StdFs + .resolve_import(spec, &importer, ResolveConfig::default()) + .map(|p| path_relative(&root, &p)) + }; + // Percent-decoding at finalization: the literal-percent file loses. + assert_eq!( + resolve("./x%20.stylex").as_deref(), + Some("src/x .stylex.js") + ); + assert_eq!(resolve("./x .stylex").as_deref(), Some("src/x .stylex.js")); + assert_eq!(resolve("./only-enc%21.stylex"), None); + assert_eq!( + resolve("./pct%20é.stylex").as_deref(), + Some("src/pct é.stylex.js") + ); + // Query/fragment end the pathname (extensions append after them, so + // only the raw extensionless candidate can hit). + assert_eq!( + resolve("./q.stylex?mode=theme").as_deref(), + Some("src/q.stylex") + ); + assert_eq!(resolve("./q.stylex#frag").as_deref(), Some("src/q.stylex")); + // Malformed escapes reject (fileURLToPath URIError). + assert_eq!(resolve("./q%2g.stylex"), None); + assert_eq!(resolve("./x%2G.stylex"), None); + // Encoded dot-dot pops in relative URL joins. + assert_eq!( + resolve("./star/%2e%2e/q.stylex").as_deref(), + Some("src/q.stylex") + ); + // Targets: query/fragment stripped for the fs lookup. + assert_eq!( + resolve("qexp/q.stylex").as_deref(), + Some("node_modules/qexp/out/q.stylex.js") + ); + assert_eq!( + resolve("qexp/frag.stylex").as_deref(), + Some("node_modules/qexp/out/q.stylex.js") + ); + assert_eq!( + resolve("qexp/qstar/deep.stylex").as_deref(), + Some("node_modules/qexp/out/deep.stylex.js") + ); + assert_eq!( + resolve("qexp/enc/sp%20ace.stylex").as_deref(), + Some("node_modules/qexp/out/sp ace.stylex.js") + ); + assert_eq!( + resolve("#q-theme").as_deref(), + Some("src/star/-theme.stylex.js") + ); + // A query inside the subpath is part of the exports-key match. + assert_eq!(resolve("qexp/enc/deep.stylex?x"), None); + assert_eq!(resolve("qexp/malformed.stylex"), None); + // Encoded dot-dot in a target is invalid-segment, never a pop. + assert_eq!(resolve("qexp/encdot/deep.stylex"), None); + } + + #[test] + fn r5_primitive_exports_roots_are_fatal() { + let root = r5_fixture(); + let importer = root.join("src/input.ts"); + let resolve = |spec: &str| { + StdFs + .resolve_import(spec, &importer, ResolveConfig::default()) + .map(|p| path_relative(&root, &p)) + }; + // exports:true / exports:42 → PATH_NOT_EXPORTED, aborting the target + // array — the fallback entry must never resolve. + assert_eq!(resolve("#bool.stylex"), None); + assert_eq!(resolve("#num.stylex"), None); + assert_eq!(resolve("bad-bool"), None); + assert_eq!(resolve("bad-bool/sub.stylex"), None); + } + + #[test] + fn segment_and_key_helpers() { + assert!(has_deprecated_segment("..")); + assert!(has_deprecated_segment("a/../b")); + assert!(has_deprecated_segment("a/%2e%2E/b")); + assert!(has_deprecated_segment("%2e.")); + assert!(has_deprecated_segment("a\\..\\b")); + assert!(has_deprecated_segment("x/node_modules/y")); + assert!(has_deprecated_segment("x/NODE_MODULES/y")); + assert!(has_deprecated_segment("x/%6eode_modul%65s/y")); + assert!(!has_deprecated_segment("a//b")); + assert!(!has_deprecated_segment("a/.b/b")); + assert!(!has_deprecated_segment("a/..b/b")); + assert!(!has_deprecated_segment("a/node_modulesx/b")); + assert!(!has_deprecated_segment("a/%2f/b")); + assert!(is_js_array_index("0")); + assert!(is_js_array_index("42")); + assert!(is_js_array_index("1.5")); + assert!(!is_js_array_index("01")); + assert!(!is_js_array_index("-1")); + assert!(!is_js_array_index("0x10")); + assert!(!is_js_array_index(" 1")); + assert!(!is_js_array_index("4294967295")); + assert!(is_js_array_index("4294967294")); + assert!(!is_js_array_index("default")); + assert!(!is_js_array_index("NaN")); + assert!(!is_js_array_index("Infinity")); + assert!(parses_as_url("https://example.com/x.js")); + assert!(parses_as_url("file:///etc/passwd")); + assert!(parses_as_url("a:")); + assert!(!parses_as_url("1a:b")); + assert!(!parses_as_url("a/b:c")); + assert!(!parses_as_url("no-colon")); + } + + #[test] + fn relative_paths() { + let rel = |a: &str, b: &str| path_relative(Path::new(a), Path::new(b)); + assert_eq!(rel("/a/b", "/a/b/c/d.ts"), "c/d.ts"); + assert_eq!(rel("/a/b", "/a/x/d.ts"), "../x/d.ts"); + assert_eq!(rel("/a/b/c", "/a"), "../.."); + assert_eq!( + normalize_path(Path::new("/a/b/../c/./d.ts")), + PathBuf::from("/a/c/d.ts") + ); + } +} diff --git a/crates/stylex/src/options.rs b/crates/stylex/src/options.rs new file mode 100644 index 00000000..0b281224 --- /dev/null +++ b/crates/stylex/src/options.rs @@ -0,0 +1,632 @@ +use std::path::PathBuf; + +use serde_json::Value; + +use crate::errors::{ErrorCode, StylexError}; +use crate::eval::value::{EvalValue, JsObjectMap}; +use crate::module_resolution::THEME_FILE_EXTENSION; + +/// Raw `@stylexjs/babel-plugin` options as they appear in conformance jobs. +/// Fields hold unvalidated JSON; all typing happens in [`CompilerOptions::resolve`]. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct CompilerOptions { + pub dev: Option, + pub test: Option, + pub debug: Option, + pub class_name_prefix: Option, + pub import_sources: Option, + pub runtime_injection: Option, + pub style_resolution: Option, + pub property_validation_mode: Option, + pub unstable_module_resolution: Option, + pub treeshake_compensation: Option, + pub sx_prop_name: Option, + pub enable_debug_class_names: Option, + pub enable_debug_data_prop: Option, + pub enable_dev_class_names: Option, + pub enable_font_size_px_to_rem: Option, + pub enable_inlined_conditional_merge: Option, + pub enable_media_query_order: Option, + pub enable_minified_keys: Option, + pub enable_legacy_value_flipping: Option, + pub enable_logical_styles_polyfill: Option, + pub enable_ltr_rtl_comments: Option, + pub aliases: Option, + pub rewrite_aliases: Option, + pub debug_file_path: Option, + pub env: Option, + pub include: Option, + pub exclude: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum StyleResolution { + PropertySpecificity, + ApplicationOrder, + LegacyExpandShorthands, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum PropertyValidationMode { + Silent, + Warn, + Throw, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum ModuleResolutionType { + CommonJs, + Haste, +} + +/// `custom` / `experimental_crossFileParsing` stay unsupported; `root_dir` is +/// always `None` under haste (see below). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ModuleResolution { + pub kind: ModuleResolutionType, + pub root_dir: Option, + /// `themeFileExtension ?? '.stylex'`; gates hashing only, never hashed. + pub theme_file_extension: String, +} + +impl ModuleResolution { + /// Upstream derives the consts suffix, it is never configured separately. + pub fn consts_file_extension(&self) -> String { + format!("{}.const", self.theme_file_extension) + } +} + +/// `aliases` in declaration order: the candidate list is consumed in order and +/// the first target that resolves on disk wins, so a map would lose the answer. +pub type AliasMap = Vec<(String, Vec)>; +/// One resolved `importSources` entry. `Aliased` names the single named export +/// that carries the whole stylex namespace for that source. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ImportSource { + Plain(String), + Aliased { from: String, as_name: String }, +} + +impl ImportSource { + pub fn from_specifier(&self) -> &str { + match self { + ImportSource::Plain(s) => s, + ImportSource::Aliased { from, .. } => from, + } + } +} + +/// Resolved `runtimeInjection`: the module the inject call is imported from, +/// plus the named export to import (`None` = default import). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuntimeInjection { + pub from: String, + pub as_name: Option, +} + +const DEFAULT_INJECT_PATH: &str = "@stylexjs/stylex/lib/stylex-inject"; + +// parity: babel-plugin src/utils/state-manager.js setOptions + +// src/shared/utils/default-options.js (0.19.0 ??-chains). +#[derive(Debug, Clone, PartialEq)] +pub struct ResolvedOptions { + pub class_name_prefix: String, + pub dev: bool, + pub debug: bool, + pub test: bool, + pub enable_debug_class_names: bool, + pub enable_debug_data_prop: bool, + pub enable_dev_class_names: bool, + pub enable_font_size_px_to_rem: bool, + pub enable_inlined_conditional_merge: bool, + pub enable_media_query_order: bool, + pub enable_minified_keys: bool, + pub enable_legacy_value_flipping: bool, + pub enable_logical_styles_polyfill: bool, + pub enable_ltr_rtl_comments: bool, + pub sx_prop_name: Option, + /// `Object.freeze({...options.env})`: always an object, injected into the + /// evaluator as `stylex.env` / the `env` named import. + pub env: EvalValue, + pub import_sources: Vec, + pub runtime_injection: Option, + pub style_resolution: StyleResolution, + pub property_validation_mode: PropertyValidationMode, + pub treeshake_compensation: bool, + pub unstable_module_resolution: Option, + pub aliases: Option, + pub rewrite_aliases: bool, + /// Canonical `{:?}` capture of every other field, computed once at + /// resolve: cache/memo keys read it instead of reformatting per call. + pub cache_repr: String, +} + +impl ResolvedOptions { + /// parity: `state.importSources.includes(source)` — the `.map(from)` list. + pub fn is_import_source(&self, source: &str) -> bool { + self.import_sources + .iter() + .any(|entry| entry.from_specifier() == source) + } + + /// parity: `state.importAs(source)` — first object entry whose `from` + /// matches; string entries are invisible to it. + pub fn import_as(&self, source: &str) -> Option<&str> { + self.import_sources.iter().find_map(|entry| match entry { + ImportSource::Aliased { from, as_name } if from == source => Some(as_name.as_str()), + _ => None, + }) + } + + /// parity: `state.importSources[i]` after the `.map(from)` projection. + pub fn import_source_at(&self, index: usize) -> Option<&str> { + self.import_sources + .get(index) + .map(ImportSource::from_specifier) + } +} + +impl Default for ResolvedOptions { + fn default() -> Self { + CompilerOptions::default() + .resolve() + .expect("empty options always resolve") + } +} + +pub fn resolve_options(raw: &CompilerOptions) -> Result { + raw.resolve() +} + +impl CompilerOptions { + pub fn from_json(value: &Value) -> Result { + let mut opts = Self::default(); + let map = match value { + Value::Null => return Ok(opts), + Value::Object(map) => map, + other => { + return Err(StylexError::new( + ErrorCode::InvalidOptionValue, + format!( + "Expected the @stylexjs/babel-plugin options to be an object, but got `{}`.", + json_text(other) + ), + )); + } + }; + for (key, v) in map { + let slot = match key.as_str() { + "dev" => &mut opts.dev, + "test" => &mut opts.test, + "debug" => &mut opts.debug, + "classNamePrefix" => &mut opts.class_name_prefix, + "importSources" => &mut opts.import_sources, + "runtimeInjection" => &mut opts.runtime_injection, + "styleResolution" => &mut opts.style_resolution, + "propertyValidationMode" => &mut opts.property_validation_mode, + "unstable_moduleResolution" => &mut opts.unstable_module_resolution, + "treeshakeCompensation" => &mut opts.treeshake_compensation, + "sxPropName" => &mut opts.sx_prop_name, + "enableDebugClassNames" => &mut opts.enable_debug_class_names, + "enableDebugDataProp" => &mut opts.enable_debug_data_prop, + "enableDevClassNames" => &mut opts.enable_dev_class_names, + "enableFontSizePxToRem" => &mut opts.enable_font_size_px_to_rem, + "enableInlinedConditionalMerge" => &mut opts.enable_inlined_conditional_merge, + "enableMediaQueryOrder" => &mut opts.enable_media_query_order, + "enableMinifiedKeys" => &mut opts.enable_minified_keys, + "enableLegacyValueFlipping" => &mut opts.enable_legacy_value_flipping, + "enableLogicalStylesPolyfill" => &mut opts.enable_logical_styles_polyfill, + "enableLTRRTLComments" => &mut opts.enable_ltr_rtl_comments, + "aliases" => &mut opts.aliases, + "rewriteAliases" => &mut opts.rewrite_aliases, + "debugFilePath" => &mut opts.debug_file_path, + "env" => &mut opts.env, + "include" => &mut opts.include, + "exclude" => &mut opts.exclude, + // Inert upstream: state-manager.js hardcodes definedStylexCSSVariables to `{}`, + // and useLayers is a processStylexRules config key the plugin never reads. + "definedStylexCSSVariables" | "useLayers" => continue, + _ => return Err(StylexError::unknown_option(key)), + }; + *slot = Some(v.clone()); + } + Ok(opts) + } + + // Divergence from upstream: type-invalid values hard-error (InvalidOptionValue) + // instead of logAndDefault's log-and-fall-back, except runtimeInjection below. + pub fn resolve(&self) -> Result { + let dev = bool_opt("options.dev", &self.dev)?.unwrap_or(false); + let debug = bool_opt("options.debug", &self.debug)?.unwrap_or(dev); + let enable_debug_class_names = bool_opt( + "options.enableDebugClassNames", + &self.enable_debug_class_names, + )? + .unwrap_or(false); + let enable_debug_data_prop = + bool_opt("options.enableDebugDataProp", &self.enable_debug_data_prop)?.unwrap_or(debug); + let enable_dev_class_names = + bool_opt("options.enableDevClassNames", &self.enable_dev_class_names)?.unwrap_or(dev); + + let enable_font_size_px_to_rem = bool_opt( + "options.enableFontSizePxToRem", + &self.enable_font_size_px_to_rem, + )? + .unwrap_or(false); + + let enable_inlined_conditional_merge = bool_opt( + "options.enableInlinedConditionalMerge", + &self.enable_inlined_conditional_merge, + )? + .unwrap_or(true); + let enable_minified_keys = + bool_opt("options.enableMinifiedKeys", &self.enable_minified_keys)?.unwrap_or(true); + let enable_media_query_order = bool_opt( + "options.enableMediaQueryOrder", + &self.enable_media_query_order, + )? + .unwrap_or(true); + + let enable_legacy_value_flipping = bool_opt( + "options.enableLegacyValueFlipping", + &self.enable_legacy_value_flipping, + )? + .unwrap_or(false); + let enable_logical_styles_polyfill = bool_opt( + "options.enableLogicalStylesPolyfill", + &self.enable_logical_styles_polyfill, + )? + .unwrap_or(false); + let enable_ltr_rtl_comments = bool_opt( + "options.enableLTRRTLComments", + &self.enable_ltr_rtl_comments, + )? + .unwrap_or(false); + if enable_ltr_rtl_comments { + return Err(StylexError::unsupported_option( + "enableLTRRTLComments: true", + )); + } + + let sx_prop_name = match get(&self.sx_prop_name) { + None => Some("sx".to_string()), + Some(Value::String(s)) => Some(s.clone()), + Some(Value::Bool(false)) => None, + Some(other) => { + return Err(invalid_value( + "options.sxPropName", + other, + "a string or the literal false", + )); + } + }; + + let test = bool_opt("options.test", &self.test)?.unwrap_or(false); + + // The one logAndDefault shape reproduced here: a type-invalid value + // (null and `{from}` included) falls back to off instead of throwing. + let runtime_injection = match get(&self.runtime_injection) { + Some(Value::Bool(true)) => Some(RuntimeInjection { + from: DEFAULT_INJECT_PATH.to_string(), + as_name: None, + }), + Some(Value::String(s)) => Some(RuntimeInjection { + from: s.clone(), + as_name: None, + }), + Some(Value::Object(map)) => match (map.get("from"), map.get("as")) { + (Some(Value::String(from)), Some(Value::String(as_name))) => { + Some(RuntimeInjection { + from: from.clone(), + as_name: Some(as_name.clone()), + }) + } + _ => None, + }, + _ => None, + }; + + let class_name_prefix = match get(&self.class_name_prefix) { + None => "x".to_string(), + Some(Value::String(s)) => s.clone(), + Some(other) => return Err(invalid_value("options.classNamePrefix", other, "a string")), + }; + + let mut import_sources = vec![ + ImportSource::Plain("@stylexjs/stylex".to_string()), + ImportSource::Plain("stylex".to_string()), + ]; + match get(&self.import_sources) { + None => {} + Some(Value::Array(items)) => { + for item in items { + match item { + Value::String(s) => import_sources.push(ImportSource::Plain(s.clone())), + // Upstream's z.object strips keys outside the shape. + Value::Object(map) => match (map.get("from"), map.get("as")) { + (Some(Value::String(from)), Some(Value::String(as_name))) => { + import_sources.push(ImportSource::Aliased { + from: from.clone(), + as_name: as_name.clone(), + }); + } + _ => { + return Err(invalid_value( + "options.importSources[]", + item, + "a string or an object with string `from` and `as`", + )); + } + }, + other => { + return Err(invalid_value( + "options.importSources[]", + other, + "a string or an object with string `from` and `as`", + )); + } + } + } + } + Some(other) => return Err(invalid_value("options.importSources", other, "an array")), + } + + let style_resolution = match get(&self.style_resolution) { + None => StyleResolution::PropertySpecificity, + Some(v) => match v.as_str() { + Some("property-specificity") => StyleResolution::PropertySpecificity, + Some("application-order") => StyleResolution::ApplicationOrder, + Some("legacy-expand-shorthands") => StyleResolution::LegacyExpandShorthands, + _ => { + return Err(invalid_value( + "options.styleResolution", + v, + "one of \"application-order\" | \"property-specificity\" | \"legacy-expand-shorthands\"", + )); + } + }, + }; + + let property_validation_mode = match get(&self.property_validation_mode) { + None => PropertyValidationMode::Silent, + Some(v) => match v.as_str() { + Some("silent") => PropertyValidationMode::Silent, + Some("throw") => PropertyValidationMode::Throw, + Some("warn") => PropertyValidationMode::Warn, + _ => { + return Err(invalid_value( + "options.propertyValidationMode", + v, + "one of \"throw\" | \"warn\" | \"silent\"", + )); + } + }, + }; + + let unstable_module_resolution = + resolve_module_resolution(&self.unstable_module_resolution)?; + + let treeshake_compensation = bool_opt( + "options.treeshakeCompensation", + &self.treeshake_compensation, + )? + .unwrap_or(false); + + let env = match get(&self.env) { + None => EvalValue::Obj(JsObjectMap::new().into()), + Some(value @ Value::Object(_)) => EvalValue::from_json(value), + Some(other) => return Err(invalid_value("options.env", other, "an object")), + }; + let aliases = resolve_aliases(get(&self.aliases)); + // parity: `typeof options.rewriteAliases === 'boolean' ? … : false` — + // a non-boolean reads as off with no diagnostic. + let rewrite_aliases = get(&self.rewrite_aliases) == Some(&Value::Bool(true)); + if get(&self.debug_file_path).is_some() { + return Err(StylexError::unsupported_option("debugFilePath")); + } + if get(&self.include).is_some() { + return Err(StylexError::unsupported_option("include")); + } + if get(&self.exclude).is_some() { + return Err(StylexError::unsupported_option("exclude")); + } + + let mut resolved = ResolvedOptions { + class_name_prefix, + dev, + debug, + test, + enable_debug_class_names, + enable_debug_data_prop, + enable_dev_class_names, + enable_font_size_px_to_rem, + enable_inlined_conditional_merge, + enable_media_query_order, + enable_minified_keys, + enable_legacy_value_flipping, + enable_logical_styles_polyfill, + enable_ltr_rtl_comments, + sx_prop_name, + env, + import_sources, + runtime_injection, + style_resolution, + property_validation_mode, + treeshake_compensation, + unstable_module_resolution, + aliases, + rewrite_aliases, + cache_repr: String::new(), + }; + // The empty-field capture keeps the repr a pure function of the inputs. + resolved.cache_repr = format!("{resolved:?}"); + Ok(resolved) + } +} + +/// The second reproduced `logAndDefault` (see `runtime_injection` above): one +/// malformed entry discards the whole map, valid siblings included. +fn resolve_aliases(raw: Option<&Value>) -> Option { + let raw = raw?; + match parse_alias_map(raw) { + Some(map) => Some(map), + None => { + eprintln!( + "[fru] Expected (options.aliases) to be a map of string to string or \ + string[], but got `{}`. Ignoring every alias.", + json_text(raw) + ); + None + } + } +} + +fn parse_alias_map(raw: &Value) -> Option { + let entries: Vec<(String, &Value)> = match raw { + // parity: z.objectOf only checks `typeof value === 'object'`, then + // `for (const key in value)` — an array validates with index keys. + Value::Object(map) => map.iter().map(|(k, v)| (k.clone(), v)).collect(), + Value::Array(items) => items + .iter() + .enumerate() + .map(|(i, v)| (i.to_string(), v)) + .collect(), + _ => return None, + }; + entries + .into_iter() + .map(|(key, value)| { + let values = match value { + Value::String(s) => vec![s.clone()], + Value::Array(items) => items + .iter() + .map(|item| item.as_str().map(str::to_string)) + .collect::>>()?, + _ => return None, + }; + Some((key, values)) + }) + .collect() +} + +// parity: validate.js logAndDefault — upstream reports the bad value and falls +// back to the default rather than failing the build. +fn warn_and_default(name: &str, value: &Value, expected: &str, default: T) -> T { + eprintln!("[fru] Expected ({name}) to be {expected}, but got `{value}`; using the default",); + default +} + +fn resolve_module_resolution(raw: &Option) -> Result, StylexError> { + let map = match get(raw) { + None => return Ok(None), + Some(Value::Object(map)) => map, + Some(other) => { + return Ok(warn_and_default( + "options.unstable_moduleResolution", + other, + "an object", + None, + )); + } + }; + let ty = match map.get("type") { + Some(Value::String(s)) => s.as_str(), + _ => { + return Ok(warn_and_default( + "options.unstable_moduleResolution.type", + map.get("type").unwrap_or(&Value::Null), + "a string", + None, + )); + } + }; + let kind = match ty { + "commonJS" => ModuleResolutionType::CommonJs, + "haste" => ModuleResolutionType::Haste, + "custom" | "experimental_crossFileParsing" => { + return Err(StylexError::unsupported_option(&format!( + "unstable_moduleResolution.type: \"{ty}\"" + ))); + } + _ => { + return Ok(warn_and_default( + "options.unstable_moduleResolution.type", + map.get("type").unwrap(), + "one of \"commonJS\" | \"haste\" | \"custom\" | \"experimental_crossFileParsing\"", + None, + )); + } + }; + // parity: validate.js's object combinator iterates the SHAPE's keys and + // builds a fresh result, so anything else in the map is simply dropped. + let theme_file_extension = match map.get("themeFileExtension") { + None | Some(Value::Null) => THEME_FILE_EXTENSION.to_string(), + Some(Value::String(s)) => s.clone(), + Some(other) => { + return Ok(warn_and_default( + "options.unstable_moduleResolution.themeFileExtension", + other, + "a string", + None, + )); + } + }; + // Haste's validator shape has no rootDir, so whatever is passed is dropped + // before any type check ever sees it. + if kind == ModuleResolutionType::Haste { + return Ok(Some(ModuleResolution { + kind, + root_dir: None, + theme_file_extension, + })); + } + let root_dir = match map.get("rootDir") { + Some(Value::String(s)) => Some(PathBuf::from(s)), + None | Some(Value::Null) => None, + Some(other) => { + return Err(invalid_value( + "options.unstable_moduleResolution.rootDir", + other, + "a string", + )); + } + }; + Ok(Some(ModuleResolution { + kind, + root_dir, + theme_file_extension, + })) +} + +// Upstream `??` semantics: an explicit null reads as absent. +fn get(v: &Option) -> Option<&Value> { + match v { + Some(Value::Null) | None => None, + Some(x) => Some(x), + } +} + +fn bool_opt(name: &str, v: &Option) -> Result, StylexError> { + match get(v) { + None => Ok(None), + Some(Value::Bool(b)) => Ok(Some(*b)), + Some(other) => Err(invalid_value(name, other, "a boolean")), + } +} + +fn invalid_value(name: &str, got: &Value, expected: &str) -> StylexError { + StylexError::new( + ErrorCode::InvalidOptionValue, + format!( + "Expected ({name}) to be {expected}, but got `{}`.", + json_text(got) + ), + ) +} + +fn json_text(v: &Value) -> String { + serde_json::to_string(v).unwrap_or_else(|_| "".to_string()) +} diff --git a/crates/stylex/src/rules.rs b/crates/stylex/src/rules.rs new file mode 100644 index 00000000..7897e8a7 --- /dev/null +++ b/crates/stylex/src/rules.rs @@ -0,0 +1,133 @@ +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; + +// JSON cannot carry non-finite constVal numbers, so runners and pins tag them +// as {"__jsnum": "Infinity"|"-Infinity"|"NaN"}; assembly decodes via this pair. +pub fn non_finite_to_tag(x: f64) -> Option { + if x.is_finite() { + return None; + } + Some(serde_json::json!({ "__jsnum": crate::jsrt::js_number_to_string(x) })) +} + +pub fn non_finite_from_tag(v: &serde_json::Value) -> Option { + let obj = v.as_object().filter(|o| o.len() == 1)?; + match obj.get("__jsnum")?.as_str()? { + "Infinity" => Some(f64::INFINITY), + "-Infinity" => Some(f64::NEG_INFINITY), + "NaN" => Some(f64::NAN), + _ => None, + } +} + +// Wire mirror of babel metadata.stylex tuples [className, {ltr, rtl?, constKey?, +// constVal?}, priority]; shared with the oj fork — change only with both sides. +// Arc'd text: clones are refcount bumps; the convert memo shares strings. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct StylexRule { + pub class_name: Arc, + pub ltr: Arc, + #[serde(default)] + pub rtl: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub const_key: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub const_val: Option, + pub priority: f64, +} + +impl StylexRule { + // [className, {ltr, rtl?, constKey?, constVal?}, priority] as babel emits it. + pub fn from_metadata_tuple(v: &serde_json::Value) -> Result { + let tuple = v + .as_array() + .filter(|a| a.len() == 3) + .ok_or("expected a 3-tuple")?; + let class_name = tuple[0].as_str().ok_or("className must be a string")?; + let obj = tuple[1].as_object().ok_or("rule body must be an object")?; + let ltr = obj + .get("ltr") + .and_then(|l| l.as_str()) + .ok_or("ltr must be a string")?; + let rtl = match obj.get("rtl") { + None | Some(serde_json::Value::Null) => None, + Some(serde_json::Value::String(s)) => Some(s.as_str().into()), + Some(_) => return Err("rtl must be a string or null".to_string()), + }; + let const_key = match obj.get("constKey") { + None | Some(serde_json::Value::Null) => None, + Some(serde_json::Value::String(s)) => Some(s.as_str().into()), + Some(_) => return Err("constKey must be a string or null".to_string()), + }; + let const_val = obj.get("constVal").cloned(); + let priority = tuple[2].as_f64().ok_or("priority must be a number")?; + Ok(StylexRule { + class_name: class_name.into(), + ltr: ltr.into(), + rtl, + const_key, + const_val, + priority, + }) + } + + pub fn to_metadata_tuple(&self) -> serde_json::Value { + let mut body = serde_json::Map::new(); + body.insert( + "ltr".to_string(), + serde_json::Value::String(self.ltr.to_string()), + ); + if let Some(rtl) = &self.rtl { + body.insert( + "rtl".to_string(), + serde_json::Value::String(rtl.to_string()), + ); + } + if let Some(const_key) = &self.const_key { + body.insert( + "constKey".to_string(), + serde_json::Value::String(const_key.to_string()), + ); + } + if let Some(const_val) = &self.const_val { + body.insert("constVal".to_string(), const_val.clone()); + } + serde_json::json!([self.class_name, body, self.priority]) + } +} + +#[cfg(test)] +mod non_finite_tag_tests { + use super::*; + + #[test] + fn tagged_const_val_round_trips_through_metadata_tuples() { + for (x, tag) in [ + (f64::INFINITY, "Infinity"), + (f64::NEG_INFINITY, "-Infinity"), + (f64::NAN, "NaN"), + ] { + let tagged = non_finite_to_tag(x).unwrap(); + assert_eq!(tagged, serde_json::json!({ "__jsnum": tag })); + let rule = StylexRule { + class_name: "xc".into(), + ltr: "".into(), + rtl: None, + const_key: Some("xc".into()), + const_val: Some(tagged.clone()), + priority: 0.0, + }; + let round = StylexRule::from_metadata_tuple(&rule.to_metadata_tuple()).unwrap(); + assert_eq!(round.const_val, Some(tagged.clone())); + let decoded = non_finite_from_tag(&tagged).unwrap(); + assert!(decoded.is_nan() == x.is_nan() && (x.is_nan() || decoded == x)); + } + assert_eq!(non_finite_to_tag(1.5), None); + assert_eq!( + non_finite_from_tag(&serde_json::json!({"__jsnum": "1"})), + None + ); + assert_eq!(non_finite_from_tag(&serde_json::json!("Infinity")), None); + } +} diff --git a/crates/stylex/src/shared/create.rs b/crates/stylex/src/shared/create.rs new file mode 100644 index 00000000..b035bd03 --- /dev/null +++ b/crates/stylex/src/shared/create.rs @@ -0,0 +1,332 @@ +//! `stylex.create` namespace compilation over already-evaluated style objects. +// parity: babel-plugin src/shared/stylex-create.js + visitors/stylex-create.js:252-283 + +use crate::errors::StylexError; +use crate::eval::value::{EvalValue, JsObjectMap}; +use crate::fxhash::FxHashMap; +use crate::fxhash::FxHashSet; +use crate::hash::create_short_hash; +use crate::options::{ModuleResolutionType, ResolvedOptions}; +use crate::rules::StylexRule; +use crate::shared::dev_naming::{ + DebugPathInfo, add_source_map_data, convert_to_test_styles, inject_dev_class_names, +}; +use crate::shared::flatten::{ + PreRule, flatten_raw_style_object, media_order_transform, validate_namespace, +}; +use std::borrow::Cow; +use std::collections::BTreeMap; +use std::sync::Arc; + +/// Per-call inputs the babel visitor reads from the AST / process state. +/// Wave 3 (AST front end) fills these; pins feed them synthetically. +#[derive(Debug, Clone)] +pub struct CreateContext<'a> { + pub options: &'a ResolvedOptions, + /// Absolute source filename ('/'-separated); `None` only in AST-less tests. + pub filename: Option, + /// process.cwd() equivalent; unused until wave 3 resolves packages itself. + pub cwd: String, + /// Name of the variable the create() result is assigned to, if any. + pub var_name: Option, + /// 1-based source line of each namespace property (babel `loc.start.line`). + pub namespace_lines: Option>, + /// Nearest package.json walking up from dirname(filename): (name, dir). + pub file_package: Option<(String, String)>, + /// Name of the nearest package.json walking up from dirname(cwd). + pub cwd_package_name: Option, +} + +impl<'a> CreateContext<'a> { + pub fn new(options: &'a ResolvedOptions) -> Self { + CreateContext { + options, + filename: None, + cwd: String::new(), + var_name: None, + namespace_lines: None, + file_package: None, + cwd_package_name: None, + } + } +} + +/// className → keyPath, first-seen order with last-write-wins values. +pub type ClassPathsInNamespace = Vec<(String, Vec)>; + +#[derive(Debug, Clone, PartialEq)] +pub struct CreateOutput { + /// namespace name → compiled object (className strings / null, `$$css`). + /// Rc-shared with the namespace memo: prod-mode consumers never clone. + pub compiled: Arc, + /// Injected rules in traversal order, deduped by className (first wins). + pub rules: Vec, + /// namespace → className → keyPath (classPathsPerNamespace upstream); + /// built only when the caller has dynamic-fn namespaces to rewrite. + pub class_paths: Vec<(String, ClassPathsInNamespace)>, +} + +/// The namespace an atom compiles under (`@stylexjs/atoms` styleInput key). +pub const INLINE_NAMESPACE: &str = "__inline__"; + +/// parity: atoms babel-transform compileStaticStyle — styleXCreateSet plus the +/// dev-class step only, never source-map data and never test styles. +pub fn compile_atom( + property: &str, + value: &str, + ctx: &CreateContext<'_>, +) -> Result { + let mut raw = JsObjectMap::new(); + raw.insert(property, EvalValue::Str(value.to_string())); + let mut namespaces = JsObjectMap::new(); + namespaces.insert(INLINE_NAMESPACE, EvalValue::Obj(raw.into())); + let mut out = compile_namespaces_core(&namespaces, ctx, false)?; + if ctx.options.dev && ctx.options.enable_dev_class_names { + out.compiled = inject_dev_class_names(out.compiled, None, ctx.filename.as_deref()); + } + Ok(out) +} + +/// Dynamic-fn namespaces are wave 3: callers reject them with `unsupported_api` +/// before this point (`EvalValue` cannot represent functions). +pub fn compile_namespaces( + namespaces: &JsObjectMap, + ctx: &CreateContext<'_>, + need_class_paths: bool, +) -> Result { + let mut out = compile_namespaces_core(namespaces, ctx, need_class_paths)?; + let options = ctx.options; + if options.debug && options.enable_debug_data_prop { + let debug_paths = DebugPathInfo { + file_package: ctx.file_package.clone(), + cwd_package_name: ctx.cwd_package_name.clone(), + root_dir: options + .unstable_module_resolution + .as_ref() + .and_then(|m| m.root_dir.as_ref()) + .map(|r| r.to_string_lossy().into_owned()), + is_haste: options + .unstable_module_resolution + .as_ref() + .is_some_and(|m| m.kind == ModuleResolutionType::Haste), + }; + out.compiled = add_source_map_data( + out.compiled, + ctx.namespace_lines.as_ref(), + ctx.filename.as_deref(), + &debug_paths, + ); + } + if options.dev && options.enable_dev_class_names { + out.compiled = inject_dev_class_names( + out.compiled, + ctx.var_name.as_deref(), + ctx.filename.as_deref(), + ); + } + if options.test { + out.compiled = convert_to_test_styles( + &out.compiled, + ctx.var_name.as_deref(), + ctx.filename.as_deref(), + ); + } + Ok(out) +} + +fn compile_namespaces_core( + namespaces: &JsObjectMap, + ctx: &CreateContext<'_>, + need_class_paths: bool, +) -> Result { + let _t = crate::timings::start(crate::timings::Stage::Create); + let options = ctx.options; + let mut resolved_namespaces = JsObjectMap::new(); + let mut rules: Vec = Vec::new(); + let mut class_paths: Vec<(String, ClassPathsInNamespace)> = Vec::new(); + let mut seen_class_names: FxHashSet> = FxHashSet::default(); + + for (namespace_name, namespace_value) in namespaces.entries() { + // The evaluator's `value[key] =` [[Set]] never creates a "__proto__" + // namespace, and Object.keys(namespaces) cannot see prototype entries. + if namespace_name == "__proto__" { + continue; + } + validate_namespace(namespace_value, &[])?; + let EvalValue::Obj(namespace) = namespace_value else { + unreachable!("validate_namespace only accepts objects"); + }; + + let transformed; + let namespace_ref = match media_order_transform(namespace, options)? { + Some(rebuilt) => { + transformed = rebuilt; + &transformed + } + None => namespace, + }; + let flattened = flatten_raw_style_object(namespace_ref, options)?; + let deduped = dedupe_last_wins(flattened); + + let mut namespace_obj = JsObjectMap::new(); + let mut paths_in_namespace: Vec<(String, Vec)> = Vec::new(); + let mut namespace_rules: Vec = Vec::new(); + for (key, pre_rule) in &deduped { + // Variables-as-keys skip minification to avoid dynamic-style regressions. + let display_key = if options.enable_minified_keys && !key.starts_with("--") { + let hashed = minified_key_hash(key.as_ref()); + if options.debug { + format!("{key}-k{hashed}") + } else { + format!("k{hashed}") + } + } else { + key.to_string() + }; + + let computed = pre_rule.compiled(options)?; + let mut class_names: Vec<&str> = Vec::new(); + for entry in computed.iter().flatten() { + let name: &str = &entry.0.class_name; + if !class_names.contains(&name) { + class_names.push(name); + } + } + let joined = class_names.join(" "); + namespace_obj.insert( + display_key, + if joined.is_empty() { + EvalValue::Null + } else { + EvalValue::Str(joined) + }, + ); + for (decl, key_path) in computed.into_iter().flatten() { + if need_class_paths { + match paths_in_namespace + .iter_mut() + .find(|(name, _)| name.as_str() == &*decl.class_name) + { + Some(slot) => { + slot.1 = key_path.iter().map(|c| c.to_string()).collect(); + } + None => paths_in_namespace.push(( + decl.class_name.to_string(), + key_path.iter().map(|c| c.to_string()).collect(), + )), + } + } + namespace_rules.push((*decl).clone()); + } + } + namespace_obj.insert("$$css", EvalValue::Bool(true)); + resolved_namespaces.insert( + namespace_name.to_string(), + EvalValue::Obj(Arc::new(namespace_obj)), + ); + for rule in namespace_rules { + if seen_class_names.insert(rule.class_name.clone()) { + rules.push(rule); + } + } + if need_class_paths { + class_paths.push((namespace_name.to_string(), paths_in_namespace)); + } + } + + Ok(CreateOutput { + compiled: Arc::new(resolved_namespaces), + rules, + class_paths, + }) +} + +// parity: stylex-create.js reduceRight dedupe — the LAST occurrence of a key +// wins and keeps its (last) position. +fn dedupe_last_wins<'a>( + flattened: Vec<(Cow<'a, str>, PreRule<'a>)>, +) -> Vec<(Cow<'a, str>, PreRule<'a>)> { + let mut seen: FxHashSet<&str> = FxHashSet::default(); + let mut keep = vec![false; flattened.len()]; + for (i, (key, _)) in flattened.iter().enumerate().rev() { + if seen.insert(key) { + keep[i] = true; + } + } + flattened + .into_iter() + .zip(keep) + .filter_map(|(entry, keep)| keep.then_some(entry)) + .collect() +} + +thread_local! { + // createShortHash('<'+'>'+key) is pure; flattened keys repeat across every + // file in a design-system corpus, so the hash is computed once per process. + static MINIFIED_KEYS: std::cell::RefCell> = + std::cell::RefCell::new(FxHashMap::default()); +} + +fn minified_key_hash(key: &str) -> String { + MINIFIED_KEYS.with(|memo| { + let mut memo = memo.borrow_mut(); + match memo.get(key) { + Some(hashed) => hashed.clone(), + None => { + let hashed = create_short_hash(&format!("<>{key}")); + memo.insert(key.to_string(), hashed.clone()); + hashed + } + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn obj(entries: &[(&str, EvalValue)]) -> EvalValue { + EvalValue::Obj( + entries + .iter() + .map(|(k, v)| ((*k).to_string(), v.clone())) + .collect::() + .into(), + ) + } + + fn s(v: &str) -> EvalValue { + EvalValue::Str(v.to_string()) + } + + #[test] + fn minimal_namespace_compiles_to_known_answers() { + let namespaces: JsObjectMap = [( + "a".to_string(), + obj(&[("color", s("blue")), ("backgroundColor", s("red"))]), + )] + .into_iter() + .collect(); + let options = ResolvedOptions::default(); + let ctx = CreateContext::new(&options); + let out = compile_namespaces(&namespaces, &ctx, false).unwrap(); + assert_eq!( + out.compiled.to_json(), + serde_json::json!({ + "a": { "kMwMTN": "xju2f9n", "kWkggS": "xrkmrrc", "$$css": true } + }) + ); + assert_eq!(out.rules.len(), 2); + assert_eq!(&*out.rules[0].ltr, ".xju2f9n{color:blue}"); + assert_eq!(&*out.rules[1].ltr, ".xrkmrrc{background-color:red}"); + } + + #[test] + fn non_object_namespace_errors() { + let namespaces: JsObjectMap = [("a".to_string(), EvalValue::Null)].into_iter().collect(); + let options = ResolvedOptions::default(); + let ctx = CreateContext::new(&options); + let err = compile_namespaces(&namespaces, &ctx, false).unwrap_err(); + assert_eq!(err.message, "A StyleX namespace must be an object."); + } +} diff --git a/crates/stylex/src/shared/create_theme.rs b/crates/stylex/src/shared/create_theme.rs new file mode 100644 index 00000000..8a139778 --- /dev/null +++ b/crates/stylex/src/shared/create_theme.rs @@ -0,0 +1,363 @@ +//! `stylex.createTheme` over evaluated theme vars + overrides. +// parity: babel-plugin src/shared/stylex-create-theme.js + visitors/stylex-create-theme.js + +use crate::errors::StylexError; +use crate::eval::JsValue; +use crate::eval::value::{EvalValue, JsObjectMap}; +use crate::hash::hash; +use crate::jsrt::{js_slice_utf16, locale_cmp, unverified_collation_char, utf16_cmp}; +use crate::options::ResolvedOptions; +use crate::rules::StylexRule; +use crate::shared::define_vars::{ + collect_vars_by_at_rule, es_ordered_entries, priority_for_at_rule, wrap_with_at_rules, +}; +use crate::shared::types::as_css_type_js; + +#[derive(Debug, Clone, PartialEq)] +pub struct CreateThemeOutput { + /// Replaces the call: `{[varGroupHash]: "overrideClass varGroupHash", $$css}`. + pub js_output: JsObjectMap, + /// One rule per at-rule bucket, priority 0.4 + n/10, "default" first. + pub rules: Vec, +} + +pub fn create_theme( + theme_vars: &JsValue, + overrides: &JsValue, + options: &ResolvedOptions, +) -> Result { + let override_entries: Vec<(String, JsValue)> = match overrides { + JsValue::Obj(obj) => es_ordered_entries(obj) + .into_iter() + .map(|(k, v)| (k.to_string(), v.clone())) + .collect(), + JsValue::Arr(items) => items + .iter() + .enumerate() + .map(|(i, v)| (i.to_string(), v.clone())) + .collect(), + JsValue::Proxy(_) => Vec::new(), + _ => return Err(StylexError::non_style_object("createTheme")), + }; + let var_group_hash = match theme_vars { + JsValue::Proxy(proxy) => proxy.var_group_hash.clone(), + JsValue::Obj(obj) => match obj.get("__varGroupHash__") { + Some(JsValue::Str(s)) if !s.is_empty() => s.clone(), + _ => return Err(StylexError::theme_without_var_group()), + }, + _ => return Err(StylexError::theme_without_var_group()), + }; + + // Sorted with the default comparator (plain UTF-16), not localeCompare. + let mut sorted_keys: Vec<&str> = override_entries.iter().map(|(k, _)| k.as_str()).collect(); + sorted_keys.sort_by(|a, b| utf16_cmp(a, b)); + + let mut collection: Vec<(String, Vec)> = Vec::new(); + for key in sorted_keys { + let raw = override_entries + .iter() + .find(|(k, _)| k == key) + .map(|(_, v)| v.clone()) + .expect("key comes from the entry list"); + let value = match as_css_type_js(&raw) { + Some((_, inner)) => inner, + None => raw, + }; + let name_hash = theme_var_name_hash(theme_vars, key)?; + collect_vars_by_at_rule(key, &name_hash, &value, &mut collection, &[])?; + } + + let mut sorted_at_rules: Vec<&str> = collection.iter().map(|(k, _)| k.as_str()).collect(); + // This sort feeds atRulesStringForHash: an unverified collation char would + // silently change class names, so it hard-errors (r4#4 policy). + for at_rule in &sorted_at_rules { + if let Some(c) = unverified_collation_char(at_rule) { + return Err(StylexError::new( + crate::errors::ErrorCode::UnsupportedApi, + format!( + "createTheme() cannot sort at-rule {at_rule:?}: {c:?} is outside the pinned collation alphabet and the order feeds the theme hash" + ), + )); + } + } + sorted_at_rules.sort_by(|a, b| { + if *a == "default" { + std::cmp::Ordering::Less + } else if *b == "default" { + std::cmp::Ordering::Greater + } else { + locale_cmp(a, b).expect("unverified chars rejected above") + } + }); + + let decls_of = |at_rule: &str| -> String { + collection + .iter() + .find(|(k, _)| k == at_rule) + .map(|(_, decls)| decls.concat()) + .unwrap_or_default() + }; + // The hash input wraps every bucket, the "default" one literally as default{…}. + let at_rules_string_for_hash: String = sorted_at_rules + .iter() + .map(|at_rule| wrap_with_at_rules(&decls_of(at_rule), at_rule)) + .collect(); + let override_class_name = format!( + "{}{}", + options.class_name_prefix, + hash(&at_rules_string_for_hash) + ); + + let mut rules = Vec::new(); + for at_rule in &sorted_at_rules { + let decls = decls_of(at_rule); + let rule = format!(".{override_class_name}, .{override_class_name}:root{{{decls}}}"); + let (ltr, suffix) = if *at_rule == "default" { + (rule, String::new()) + } else { + ( + wrap_with_at_rules(&rule, at_rule), + format!("-{}", hash(at_rule)), + ) + }; + rules.push(StylexRule { + class_name: format!("{override_class_name}{suffix}").into(), + ltr: ltr.into(), + rtl: None, + const_key: None, + const_val: None, + priority: 0.4 + priority_for_at_rule(at_rule) / 10.0, + }); + } + + let mut js_output = JsObjectMap::new(); + js_output.insert( + var_group_hash.clone(), + EvalValue::Str(format!("{override_class_name} {var_group_hash}")), + ); + js_output.insert("$$css", EvalValue::Bool(true)); + Ok(CreateThemeOutput { js_output, rules }) +} + +// parity: `themeVars[key].slice(6, -1)` — trims `var(--` and `)`. +fn theme_var_name_hash(theme_vars: &JsValue, key: &str) -> Result { + match theme_vars { + JsValue::Proxy(proxy) => { + // Mirror the proxy traps: __varGroupHash__ answers the hash string. + let resolved = match key { + "__varGroupHash__" => proxy.var_group_hash.clone(), + "__IS_PROXY" | "toString" => { + return Err(StylexError::upstream_type_crash( + "a proxy trap value without .slice in createTheme", + )); + } + _ => proxy.resolve_key(key), + }; + Ok(js_slice_utf16(&resolved, 6, -1)) + } + JsValue::Obj(obj) => match obj.get(key) { + Some(JsValue::Str(s)) => Ok(js_slice_utf16(s, 6, -1)), + None | Some(JsValue::Undefined) => Err(StylexError::new( + crate::errors::ErrorCode::NonStaticValue, + "Cannot read properties of undefined (reading 'slice')", + )), + Some(_) => Err(StylexError::upstream_type_crash( + "a non-string theme variable in createTheme", + )), + }, + _ => Err(StylexError::theme_without_var_group()), + } +} + +/// Visitor-level JS-output rewrite for `dev`/`test`; no-op otherwise. +// parity: visitors/stylex-create-theme.js isTest/isDev branches +pub fn apply_theme_dev_naming( + js_output: JsObjectMap, + filename: Option<&str>, + var_name: &str, + options: &ResolvedOptions, +) -> JsObjectMap { + if !options.test && !options.dev { + return js_output; + } + let filename = filename.unwrap_or("UnknownFile"); + let basename = filename + .rsplit('/') + .next() + .unwrap_or(filename) + .split('.') + .next() + .unwrap_or_default(); + let dev_class_name = format!("{basename}__{var_name}"); + let mut out = JsObjectMap::new(); + out.insert( + dev_class_name.clone(), + EvalValue::Str(dev_class_name.clone()), + ); + if options.test { + out.insert("$$css", EvalValue::Bool(true)); + return out; + } + for (k, v) in js_output.entries() { + out.insert(k, v.clone()); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::eval::JsObj; + use crate::eval::cross_file::VarGroupProxy; + + fn probe_proxy() -> VarGroupProxy { + // Pinned via live-oracle probe 2026-08-28 (probe3, /tmp fixture pkg). + VarGroupProxy::new( + "probe-pkg:src/tokens.stylex.ts".to_string(), + "vars".to_string(), + &ResolvedOptions::default(), + ) + } + + fn overrides(entries: &[(&str, JsValue)]) -> JsValue { + let mut obj = JsObj::default(); + for (k, v) in entries { + obj.insert((*k).to_string(), v.clone()); + } + JsValue::object(obj) + } + + #[test] + fn cross_file_basic_matches_oracle() { + let options = ResolvedOptions::default(); + let proxy = JsValue::proxy(probe_proxy()); + let out = create_theme( + &proxy, + &overrides(&[ + ("accent", JsValue::Str("rebeccapurple".into())), + ("gap", JsValue::Str("12px".into())), + ]), + &options, + ) + .unwrap(); + assert_eq!(out.rules.len(), 1); + assert_eq!(&*out.rules[0].class_name, "xxlf0b4"); + assert_eq!( + &*out.rules[0].ltr, + ".xxlf0b4, .xxlf0b4:root{--x15glzcj:rebeccapurple;--x1e9wu2u:12px;}" + ); + assert!(out.rules[0].priority == 0.5); + assert_eq!( + out.js_output.get("xf9pnhg"), + Some(&EvalValue::Str("xxlf0b4 xf9pnhg".to_string())) + ); + assert_eq!(out.js_output.get("$$css"), Some(&EvalValue::Bool(true))); + } + + #[test] + fn empty_and_all_null_overrides_hash_the_empty_string() { + // Pinned via live-oracle probe 2026-08-28: both compile to xph554m. + let options = ResolvedOptions::default(); + let proxy = JsValue::proxy(probe_proxy()); + let empty = create_theme(&proxy, &overrides(&[]), &options).unwrap(); + assert!(empty.rules.is_empty()); + assert_eq!( + empty.js_output.get("xf9pnhg"), + Some(&EvalValue::Str("xph554m xf9pnhg".to_string())) + ); + let all_null = + create_theme(&proxy, &overrides(&[("accent", JsValue::Null)]), &options).unwrap(); + assert_eq!( + all_null.js_output.get("xf9pnhg"), + empty.js_output.get("xf9pnhg") + ); + } + + #[test] + fn missing_var_group_hash_is_rejected() { + let options = ResolvedOptions::default(); + let err = create_theme( + &JsValue::object(JsObj::default()), + &overrides(&[]), + &options, + ) + .unwrap_err(); + assert_eq!( + err.message, + "Can only override variables theme created with defineVars()." + ); + let err = + create_theme(&JsValue::Str("nope".into()), &overrides(&[]), &options).unwrap_err(); + assert_eq!(err.code, crate::errors::ErrorCode::ThemeWithoutVarGroup); + } + + #[test] + fn same_file_map_lookup_and_unknown_key() { + let options = ResolvedOptions::default(); + let mut vars = JsObj::default(); + vars.insert("accent".to_string(), JsValue::Str("var(--xx91c37)".into())); + vars.insert( + "__varGroupHash__".to_string(), + JsValue::Str("x7tlvcs".into()), + ); + let theme_vars = JsValue::object(vars); + // Pinned via live-oracle probe 2026-08-28 (probe3 same-file green theme). + let out = create_theme( + &theme_vars, + &overrides(&[("accent", JsValue::Str("green".into()))]), + &options, + ) + .unwrap(); + assert_eq!(&*out.rules[0].class_name, "x7u453x"); + assert_eq!( + &*out.rules[0].ltr, + ".x7u453x, .x7u453x:root{--xx91c37:green;}" + ); + let err = create_theme( + &theme_vars, + &overrides(&[("missing", JsValue::Str("green".into()))]), + &options, + ) + .unwrap_err(); + assert_eq!( + err.message, + "Cannot read properties of undefined (reading 'slice')" + ); + } + + #[test] + fn dev_and_test_naming() { + let dev_options = ResolvedOptions { + dev: true, + debug: true, + ..ResolvedOptions::default() + }; + let mut js = JsObjectMap::new(); + js.insert("x7tlvcs", EvalValue::Str("x7u453x x7tlvcs".into())); + js.insert("$$css", EvalValue::Bool(true)); + let dev = apply_theme_dev_naming( + js.clone(), + Some("/fix/src/both.stylex.ts"), + "myT", + &dev_options, + ); + let keys: Vec<&str> = dev.keys().collect(); + assert_eq!(keys, vec!["both__myT", "x7tlvcs", "$$css"]); + assert_eq!( + dev.get("both__myT"), + Some(&EvalValue::Str("both__myT".into())) + ); + + let test_options = ResolvedOptions { + test: true, + ..ResolvedOptions::default() + }; + let test = + apply_theme_dev_naming(js.clone(), Some("/x/theme.ts"), "myTheme", &test_options); + let keys: Vec<&str> = test.keys().collect(); + assert_eq!(keys, vec!["theme__myTheme", "$$css"]); + + let plain = apply_theme_dev_naming(js.clone(), None, "t", &ResolvedOptions::default()); + assert_eq!(plain, js); + } +} diff --git a/crates/stylex/src/shared/css_value.rs b/crates/stylex/src/shared/css_value.rs new file mode 100644 index 00000000..4f3cb360 --- /dev/null +++ b/crates/stylex/src/shared/css_value.rs @@ -0,0 +1,579 @@ +//! CSS value tokenizer, behavior-equivalent to postcss-value-parser@4.2.0. +// parity: postcss-value-parser lib/{parse,walk,stringify,unit}.js + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Kind { + Word, + Space, + Div, + Str, + Comment, + Func, + UnicodeRange, +} + +// Flat node shape mirroring the JS objects; unused fields stay at defaults. +#[derive(Debug, Clone, PartialEq)] +pub struct Node { + pub kind: Kind, + pub value: String, + pub source_index: usize, + pub source_end_index: usize, + pub before: String, + pub after: String, + pub quote: u8, + pub unclosed: bool, + pub nodes: Vec, +} + +impl Node { + fn new(kind: Kind) -> Self { + Node { + kind, + value: String::new(), + source_index: 0, + source_end_index: 0, + before: String::new(), + after: String::new(), + quote: 0, + unclosed: false, + nodes: Vec::new(), + } + } + + fn leaf(kind: Kind, value: String, source_index: usize, source_end_index: usize) -> Self { + Node { + value, + source_index, + source_end_index, + ..Node::new(kind) + } + } +} + +fn utf8(bytes: &[u8]) -> String { + String::from_utf8_lossy(bytes).into_owned() +} + +fn find_byte(haystack: &[u8], needle: u8, from: usize) -> Option { + if from >= haystack.len() { + return None; + } + memchr::memchr(needle, &haystack[from..]).map(|i| i + from) +} + +fn find_subslice(haystack: &[u8], needle: &[u8], from: usize) -> Option { + if from >= haystack.len() { + return None; + } + haystack[from..] + .windows(needle.len()) + .position(|w| w == needle) + .map(|i| i + from) +} + +fn tokens<'a>(stack: &'a mut [Node], root: &'a mut Vec) -> &'a mut Vec { + match stack.last_mut() { + Some(top) => &mut top.nodes, + None => root, + } +} + +fn is_unicode_range(token: &[u8]) -> bool { + token.len() > 2 + && (token[0] == b'u' || token[0] == b'U') + && token[1] == b'+' + && token[2..] + .iter() + .all(|c| c.is_ascii_hexdigit() || *c == b'?' || *c == b'-') +} + +pub fn parse(input: &str) -> Vec { + // The buffer can grow (unclosed string/url get a synthetic closer); `max` + // stays at the original length, exactly like upstream's captured `value.length`. + let mut value: Vec = input.as_bytes().to_vec(); + let max = value.len(); + let mut pos: usize = 0; + + let mut root: Vec = Vec::new(); + let mut stack: Vec = Vec::new(); + // Upstream's `parent` is undefined until the first function opens and stays + // the root wrapper afterwards; that changes the whitespace-before-'/' rule. + let mut entered_function = false; + + let mut name = String::new(); + let mut before_pending = String::new(); + let mut after_pending = String::new(); + + while pos < max { + let code = value[pos]; + let in_calc = stack.last().is_some_and(|f| f.value == "calc"); + + if code <= 32 { + // whitespace run + let mut next = pos + 1; + while value.get(next).is_some_and(|c| *c <= 32) { + next += 1; + } + let token = utf8(&value[pos..next]); + let next_code = value.get(next).copied(); + let in_function = !stack.is_empty(); + let slash_takes_before = match stack.last() { + Some(f) => f.value != "calc", + None => !entered_function, + }; + let cur = tokens(&mut stack, &mut root); + if next_code == Some(b')') && in_function { + after_pending = token; + } else if let Some(prev) = cur.last_mut().filter(|p| p.kind == Kind::Div) { + prev.source_end_index += token.len(); + prev.after = token; + } else if next_code == Some(b',') + || next_code == Some(b':') + || (next_code == Some(b'/') + && value.get(next + 1) != Some(&b'*') + && slash_takes_before) + { + before_pending = token; + } else { + cur.push(Node::leaf(Kind::Space, token, pos, next)); + } + pos = next; + } else if code == b'\'' || code == b'"' { + let quote = code; + let mut next = pos; + let mut unclosed = false; + loop { + let mut escape = false; + match find_byte(&value, quote, next + 1) { + Some(idx) => { + next = idx; + let mut escape_pos = next; + while escape_pos > 0 && value[escape_pos - 1] == b'\\' { + escape_pos -= 1; + escape = !escape; + } + } + None => { + value.push(quote); + next = value.len() - 1; + unclosed = true; + } + } + if !escape { + break; + } + } + let mut node = Node::leaf( + Kind::Str, + utf8(&value[pos + 1..next]), + pos, + if unclosed { next } else { next + 1 }, + ); + node.quote = quote; + node.unclosed = unclosed; + tokens(&mut stack, &mut root).push(node); + pos = next + 1; + } else if code == b'/' && value.get(pos + 1) == Some(&b'*') { + let mut node = Node::new(Kind::Comment); + node.source_index = pos; + let next = match find_subslice(&value, b"*/", pos) { + Some(idx) => { + node.source_end_index = idx + 2; + idx + } + None => { + node.unclosed = true; + node.source_end_index = value.len(); + value.len() + } + }; + if next > pos + 2 { + node.value = utf8(&value[pos + 2..next]); + } + tokens(&mut stack, &mut root).push(node); + pos = next + 2; + } else if (code == b'/' || code == b'*') && in_calc { + // operator word directly inside calc() + let node = Node::leaf( + Kind::Word, + (code as char).to_string(), + pos - before_pending.len(), + pos + 1, + ); + tokens(&mut stack, &mut root).push(node); + pos += 1; + } else if code == b'/' || code == b',' || code == b':' { + let mut node = Node::leaf( + Kind::Div, + (code as char).to_string(), + pos - before_pending.len(), + pos + 1, + ); + node.before = std::mem::take(&mut before_pending); + tokens(&mut stack, &mut root).push(node); + pos += 1; + } else if code == b'(' { + let mut next = pos + 1; + while value.get(next).is_some_and(|c| *c <= 32) { + next += 1; + } + let paren_open = pos; + let mut func = Node::new(Kind::Func); + func.source_index = pos - name.len(); + func.value = std::mem::take(&mut name); + func.before = utf8(&value[paren_open + 1..next]); + let code_at_next = value.get(next).copied(); + pos = next; + + if func.value == "url" && code_at_next != Some(b'\'') && code_at_next != Some(b'"') { + // unquoted url(): raw consumption up to the matching ')' + let mut next2 = next - 1; + loop { + let mut escape = false; + match find_byte(&value, b')', next2 + 1) { + Some(idx) => { + next2 = idx; + let mut escape_pos = next2; + while escape_pos > 0 && value[escape_pos - 1] == b'\\' { + escape_pos -= 1; + escape = !escape; + } + } + None => { + value.push(b')'); + next2 = value.len() - 1; + func.unclosed = true; + } + } + if !escape { + break; + } + } + let mut whitespace_pos = next2; + loop { + whitespace_pos -= 1; + if value[whitespace_pos] > 32 { + break; + } + } + if paren_open < whitespace_pos { + if pos != whitespace_pos + 1 { + func.nodes = vec![Node::leaf( + Kind::Word, + utf8(&value[pos..whitespace_pos + 1]), + pos, + whitespace_pos + 1, + )]; + } + if func.unclosed && whitespace_pos + 1 != next2 { + func.nodes.push(Node::leaf( + Kind::Space, + utf8(&value[whitespace_pos + 1..next2]), + whitespace_pos + 1, + next2, + )); + } else { + func.after = utf8(&value[whitespace_pos + 1..next2]); + } + } + pos = next2 + 1; + func.source_end_index = if func.unclosed { next2 } else { pos }; + tokens(&mut stack, &mut root).push(func); + } else { + entered_function = true; + func.source_end_index = pos + 1; + stack.push(func); + } + } else if code == b')' && !stack.is_empty() { + pos += 1; + let mut func = stack.pop().expect("checked non-empty"); + func.after = std::mem::take(&mut after_pending); + func.source_end_index = pos; + tokens(&mut stack, &mut root).push(func); + } else { + // word + let mut next = pos; + let mut cur = code; + loop { + if cur == b'\\' { + next += 1; + } + next += 1; + if next >= max { + break; + } + let c = value[next]; + let is_break = c <= 32 + || c == b'\'' + || c == b'"' + || c == b',' + || c == b':' + || c == b'/' + || c == b'(' + || (c == b'*' && in_calc) + || (c == b')' && !stack.is_empty()); + if is_break { + break; + } + cur = c; + } + let token = utf8(&value[pos..next.min(value.len())]); + if value.get(next) == Some(&b'(') { + name = token; + } else if is_unicode_range(token.as_bytes()) { + tokens(&mut stack, &mut root).push(Node::leaf( + Kind::UnicodeRange, + token, + pos, + next, + )); + } else { + tokens(&mut stack, &mut root).push(Node::leaf(Kind::Word, token, pos, next)); + } + pos = next; + } + } + + while let Some(mut func) = stack.pop() { + func.unclosed = true; + func.source_end_index = value.len(); + tokens(&mut stack, &mut root).push(func); + } + root +} + +pub fn walk(nodes: &mut [Node], f: &mut F) { + for node in nodes.iter_mut() { + f(node); + if node.kind == Kind::Func { + walk(&mut node.nodes, f); + } + } +} + +pub fn stringify(nodes: &[Node]) -> String { + let mut out = String::new(); + for node in nodes { + stringify_node(node, &mut out); + } + out +} + +fn stringify_node(node: &Node, out: &mut String) { + match node.kind { + Kind::Word | Kind::Space | Kind::UnicodeRange => out.push_str(&node.value), + Kind::Str => { + let quote = node.quote as char; + out.push(quote); + out.push_str(&node.value); + if !node.unclosed { + out.push(quote); + } + } + Kind::Comment => { + out.push_str("/*"); + out.push_str(&node.value); + if !node.unclosed { + out.push_str("*/"); + } + } + Kind::Div => { + out.push_str(&node.before); + out.push_str(&node.value); + out.push_str(&node.after); + } + Kind::Func => { + out.push_str(&node.value); + out.push('('); + out.push_str(&node.before); + for child in &node.nodes { + stringify_node(child, out); + } + out.push_str(&node.after); + if !node.unclosed { + out.push(')'); + } + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Dimension { + pub number: String, + pub unit: String, +} + +// https://www.w3.org/TR/css-syntax-3/#starts-with-a-number +fn like_number(b: &[u8]) -> bool { + match b.first() { + Some(b'+') | Some(b'-') => match b.get(1) { + Some(c) if c.is_ascii_digit() => true, + Some(b'.') => b.get(2).is_some_and(|c| c.is_ascii_digit()), + _ => false, + }, + Some(b'.') => b.get(1).is_some_and(|c| c.is_ascii_digit()), + Some(c) => c.is_ascii_digit(), + None => false, + } +} + +pub fn unit(value: &str) -> Option { + let b = value.as_bytes(); + if b.is_empty() || !like_number(b) { + return None; + } + let mut pos = 0; + if b[0] == b'+' || b[0] == b'-' { + pos += 1; + } + while b.get(pos).is_some_and(|c| c.is_ascii_digit()) { + pos += 1; + } + if b.get(pos) == Some(&b'.') && b.get(pos + 1).is_some_and(|c| c.is_ascii_digit()) { + pos += 2; + while b.get(pos).is_some_and(|c| c.is_ascii_digit()) { + pos += 1; + } + } + if matches!(b.get(pos), Some(b'e') | Some(b'E')) { + let c1 = b.get(pos + 1); + let c2 = b.get(pos + 2); + let consumed = if c1.is_some_and(|c| c.is_ascii_digit()) { + Some(2) + } else if matches!(c1, Some(b'+') | Some(b'-')) && c2.is_some_and(|c| c.is_ascii_digit()) { + Some(3) + } else { + None + }; + if let Some(n) = consumed { + pos += n; + while b.get(pos).is_some_and(|c| c.is_ascii_digit()) { + pos += 1; + } + } + } + Some(Dimension { + number: value[..pos].to_string(), + unit: value[pos..].to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn roundtrip(input: &str) -> String { + stringify(&parse(input)) + } + + #[test] + fn roundtrips_preserve_source() { + for input in [ + "1px solid red", + " 1px solid red ", + "calc( 100% - 10px )", + "calc(100%/3)", + "url( ./a.png )", + "url(data:image/png;base64,iVBOR)", + "url(\"a b.png\")", + "var(--x, 10px)", + "\"say \\\"hi\\\"\"", + "'a' \"b\"", + "U+0025-00FF", + "translateX(0px) translateY(0px)", + "16 / 9", + "16/9", + "/* c */ 10px", + "rgb(255 0 0 / 0.5)", + ] { + assert_eq!(roundtrip(input), input, "roundtrip({input:?})"); + } + } + + #[test] + fn unclosed_string_and_function() { + let nodes = parse("\"abc"); + assert_eq!(nodes.len(), 1); + assert!(nodes[0].unclosed); + assert_eq!(nodes[0].kind, Kind::Str); + assert_eq!(stringify(&nodes), "\"abc"); + + let nodes = parse("calc(100%"); + assert_eq!(nodes.len(), 1); + assert!(nodes[0].unclosed); + assert_eq!(nodes[0].kind, Kind::Func); + assert_eq!(stringify(&nodes), "calc(100%"); + } + + #[test] + fn whitespace_before_div_becomes_before() { + let nodes = parse("a , b"); + let div = nodes.iter().find(|n| n.kind == Kind::Div).unwrap(); + assert_eq!(div.before, " "); + assert_eq!(div.after, " "); + } + + // parity: parse.js keeps `parent` pointing at the root wrapper after a + // function closes, so whitespace before '/' stays a space node there. + #[test] + fn slash_whitespace_after_closed_function_stays_space() { + let nodes = parse("calc(10px) 2px / 3px"); + let kinds: Vec = nodes.iter().map(|n| n.kind).collect(); + assert_eq!( + kinds, + vec![ + Kind::Func, + Kind::Space, + Kind::Word, + Kind::Space, + Kind::Div, + Kind::Word + ] + ); + let div = &nodes[4]; + assert_eq!(div.before, ""); + } + + #[test] + fn unit_matches_reference_behavior() { + assert_eq!( + unit("500ms"), + Some(Dimension { + number: "500".into(), + unit: "ms".into() + }) + ); + assert_eq!( + unit(".5.5px"), + Some(Dimension { + number: ".5".into(), + unit: ".5px".into() + }) + ); + assert_eq!( + unit("1e-7px"), + Some(Dimension { + number: "1e-7".into(), + unit: "px".into() + }) + ); + assert_eq!( + unit("-0px"), + Some(Dimension { + number: "-0".into(), + unit: "px".into() + }) + ); + assert_eq!(unit("Infinityms"), None); + assert_eq!(unit("px"), None); + assert_eq!(unit(""), None); + assert_eq!( + unit("5e"), + Some(Dimension { + number: "5".into(), + unit: "e".into() + }) + ); + } +} diff --git a/crates/stylex/src/shared/dashify.rs b/crates/stylex/src/shared/dashify.rs new file mode 100644 index 00000000..432312f5 --- /dev/null +++ b/crates/stylex/src/shared/dashify.rs @@ -0,0 +1,73 @@ +//! camelCase -> dash-case plus the dev-classname sanitizer. + +/// Equivalent of `str.replace(/(^|[a-z])([A-Z])/g, '$1-$2').toLowerCase()`. +// parity: babel-plugin src/shared/utils/dashify.js +pub fn dashify(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 4); + let mut prev_is_ascii_lower = false; + for (i, c) in s.chars().enumerate() { + if c.is_ascii_uppercase() && (i == 0 || prev_is_ascii_lower) { + out.push('-'); + } + prev_is_ascii_lower = c.is_ascii_lowercase(); + out.push(c); + } + out.to_lowercase() +} + +/// Custom properties keep their exact spelling; everything else is dashified. +// parity: babel-plugin src/shared/utils/convert-to-className.js:40 +pub fn dashed_key(key: &str) -> String { + if key.starts_with("--") { + return key.to_string(); + } + // ASCII lower/digit/'-'/'_' strings are dashify fixed points (no dash + // insertion, toLowerCase identity): skip the two-string rebuild. + if key + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_') + { + return key.to_string(); + } + dashify(key) +} + +/// Equivalent of `className.replace(/[^.a-zA-Z0-9_-]/g, '')`. +// parity: babel-plugin src/utils/dev-classname.js +pub fn sanitize_dev_class_name(class_name: &str) -> String { + class_name + .chars() + .filter(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dashify_basics() { + assert_eq!(dashify("backgroundColor"), "background-color"); + // leading uppercase gets a leading dash (the `^` alternative) + assert_eq!(dashify("WebkitTransform"), "-webkit-transform"); + // consecutive capitals only split after a lowercase + assert_eq!(dashify("backgroundURL"), "background-url"); + assert_eq!(dashify("notARealProp"), "not-areal-prop"); + } + + #[test] + fn custom_property_passthrough() { + assert_eq!(dashed_key("--customProp"), "--customProp"); + assert_eq!(dashify("--customProp"), "--custom-prop"); + assert_eq!(dashed_key("borderTopColor"), "border-top-color"); + } + + #[test] + fn sanitizer_strips_disallowed() { + assert_eq!( + sanitize_dev_class_name("My File__s.a b?c\u{2192}d"), + "MyFile__s.abcd" + ); + assert_eq!(sanitize_dev_class_name(""), ""); + } +} diff --git a/crates/stylex/src/shared/define_consts.rs b/crates/stylex/src/shared/define_consts.rs new file mode 100644 index 00000000..4fc60459 --- /dev/null +++ b/crates/stylex/src/shared/define_consts.rs @@ -0,0 +1,189 @@ +//! `stylex.defineConsts` over an already-evaluated constants object. +// parity: babel-plugin src/shared/stylex-define-consts.js + visitors/stylex-define-consts.js + +use crate::errors::StylexError; +use crate::eval::value::{EvalValue, JsObjectMap}; +use crate::hash::hash; +use crate::options::ResolvedOptions; +use crate::rules::StylexRule; + +#[derive(Debug, Clone, PartialEq)] +pub struct DefineConstsOutput { + /// Replaces the call: the input values verbatim. + pub js_output: JsObjectMap, + /// One `[constKey, {constKey, constVal, ltr: "", rtl: null}, 0]` per entry. + pub rules: Vec, +} + +/// `canonical_file_name` is state-manager fileNameForHashing output; callers +/// map a missing one to `cannot_generate_hash("defineConsts")` first. +pub fn define_consts( + constants: &EvalValue, + canonical_file_name: &str, + export_name: &str, + options: &ResolvedOptions, +) -> Result { + let entries = object_entries(constants, "defineConsts")?; + let export_id = format!("{canonical_file_name}//{export_name}"); + + let mut js_output = JsObjectMap::new(); + let mut rules = Vec::new(); + for (key, value) in entries { + if matches!(value, EvalValue::Undefined) { + return Err(StylexError::upstream_type_crash( + "an undefined defineConsts value", + )); + } + let const_key = if let Some(rest) = key.strip_prefix("--") { + rest.to_string() + } else if options.debug && options.enable_debug_class_names { + format!( + "{}-{}{}", + var_safe_key(&key), + options.class_name_prefix, + hash(&format!("{export_id}.{key}")) + ) + } else { + format!( + "{}{}", + options.class_name_prefix, + hash(&format!("{export_id}.{key}")) + ) + }; + js_output.insert(key, value.clone()); + rules.push(StylexRule { + class_name: const_key.as_str().into(), + ltr: "".into(), + rtl: None, + const_key: Some(const_key.into()), + const_val: Some(value.to_json()), + priority: 0.0, + }); + } + Ok(DefineConstsOutput { js_output, rules }) +} + +// parity: visitor `typeof value !== 'object' || value == null` — arrays pass +// and Object.entries turns them into index-keyed entries. +fn object_entries( + value: &EvalValue, + fn_name: &str, +) -> Result, StylexError> { + match value { + EvalValue::Obj(map) => Ok(map + .entries() + .map(|(k, v)| (k.to_string(), v.clone())) + .collect()), + EvalValue::Arr(items) => Ok(items + .iter() + .enumerate() + .map(|(i, v)| (i.to_string(), v.clone())) + .collect()), + _ => Err(StylexError::non_style_object(fn_name)), + } +} + +// parity: `(key[0] >= '0' && key[0] <= '9' ? '_' + key : key).replace(/[^a-zA-Z0-9]/g, '_')` +// — the replace runs per UTF-16 unit (astral chars become two underscores). +fn var_safe_key(key: &str) -> String { + let prefixed: std::borrow::Cow<'_, str> = + if key.chars().next().is_some_and(|c| c.is_ascii_digit()) { + format!("_{key}").into() + } else { + key.into() + }; + prefixed + .encode_utf16() + .map(|unit| match char::from_u32(u32::from(unit)) { + Some(c) if c.is_ascii_alphanumeric() => c, + _ => '_', + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn consts(entries: &[(&str, EvalValue)]) -> EvalValue { + EvalValue::Obj( + entries + .iter() + .map(|(k, v)| ((*k).to_string(), v.clone())) + .collect::() + .into(), + ) + } + + #[test] + fn basic_output_matches_oracle() { + // Pinned via live-oracle probe 2026-08-27: tokens.stylex.ts under + // rootDir /fake/root, export name `colors`. + let options = ResolvedOptions::default(); + let input = consts(&[ + ("bg", EvalValue::Str("red".to_string())), + ("size", EvalValue::Num(12.0)), + ]); + let out = define_consts(&input, "tokens.stylex.ts", "colors", &options).unwrap(); + assert_eq!(out.js_output.to_json(), input.to_json()); + assert_eq!(out.rules.len(), 2); + assert_eq!(&*out.rules[0].class_name, "xjvywqn"); + assert_eq!(out.rules[0].const_key.as_deref(), Some("xjvywqn")); + assert_eq!(out.rules[0].const_val, Some(serde_json::json!("red"))); + assert_eq!(&*out.rules[0].ltr, ""); + assert_eq!(out.rules[0].rtl, None); + assert!(out.rules[0].priority == 0.0); + assert_eq!(&*out.rules[1].class_name, "xont6ws"); + assert_eq!(out.rules[1].const_val, Some(serde_json::json!(12))); + } + + #[test] + fn custom_property_keys_skip_hashing() { + let options = ResolvedOptions::default(); + let out = define_consts( + &consts(&[ + ("--already", EvalValue::Str("x".to_string())), + ("--", EvalValue::Str("y".to_string())), + ]), + "tokens.stylex.ts", + "c", + &options, + ) + .unwrap(); + assert_eq!(&*out.rules[0].class_name, "already"); + assert_eq!(&*out.rules[1].class_name, ""); + } + + #[test] + fn var_safe_key_rules() { + assert_eq!(var_safe_key("myColor"), "myColor"); + assert_eq!(var_safe_key("2xl"), "_2xl"); + assert_eq!(var_safe_key("foo-bar"), "foo_bar"); + assert_eq!(var_safe_key("ké y"), "k__y"); + assert_eq!(var_safe_key("a😀b"), "a__b"); + } + + #[test] + fn non_object_rejected_and_arrays_pass() { + let options = ResolvedOptions::default(); + let err = define_consts( + &EvalValue::Str("x".to_string()), + "tokens.stylex.ts", + "c", + &options, + ) + .unwrap_err(); + assert_eq!(err.message, "defineConsts() can only accept an object."); + let out = define_consts( + &EvalValue::Arr(vec![ + EvalValue::Str("a".to_string()), + EvalValue::Str("b".to_string()), + ]), + "t.stylex.ts", + "c", + &options, + ) + .unwrap(); + assert_eq!(out.js_output.keys().collect::>(), vec!["0", "1"],); + } +} diff --git a/crates/stylex/src/shared/define_vars.rs b/crates/stylex/src/shared/define_vars.rs new file mode 100644 index 00000000..020fdd10 --- /dev/null +++ b/crates/stylex/src/shared/define_vars.rs @@ -0,0 +1,619 @@ +//! `stylex.defineVars` over an evaluated variables object. +// parity: stylex-define-vars.js + stylex-vars-utils.js + its visitor (normalize) + +use std::collections::{BTreeMap, BTreeSet}; + +use crate::errors::StylexError; +use crate::eval::cross_file::VarGroupProxy; +use crate::eval::functions::{DepTracker, arrow_info, register_self_reference}; +use crate::eval::value::{EvalValue, JsObjectMap, array_index}; +use crate::eval::{Callable, EvalOutcome, Evaluator, JsObj, JsValue}; +use crate::hash::hash; +use crate::jsrt::{js_number_to_string, utf16_cmp}; +use crate::module_resolution::gen_file_based_identifier; +use crate::options::ResolvedOptions; +use crate::rules::StylexRule; +use crate::shared::types::as_css_type_js; + +pub const SPLIT_TOKEN: &str = "__$$__"; + +#[derive(Debug, Clone, PartialEq)] +pub struct DefineVarsOutput { + /// Replaces the call: `{key: "var(--nameHash)", …, __varGroupHash__}`. + pub js_output: JsObjectMap, + /// `@property` rules (priority 0) then the grouped var rules (0.1 + n/10). + pub rules: Vec, +} + +/// ES OwnPropertyKeys order over the evaluator-local object representation. +pub fn es_ordered_entries<'v>(obj: &'v JsObj) -> Vec<(&'v str, &'v JsValue)> { + let mut index: Vec<(u32, &'v str, &'v JsValue)> = Vec::new(); + let mut named: Vec<(&'v str, &'v JsValue)> = Vec::new(); + for (k, v) in obj.entries() { + match array_index(k) { + Some(n) => index.push((n, k, v)), + None => named.push((k, v)), + } + } + index.sort_by_key(|(n, _, _)| *n); + index + .into_iter() + .map(|(_, k, v)| (k, v)) + .chain(named) + .collect() +} + +/// `canonical_file_name` is the state-manager fileNameForHashing output; the +/// caller maps a missing one to `cannot_generate_hash("defineVars")` first. +pub fn define_vars<'a>( + ev: &mut Evaluator<'a, '_>, + value: &JsValue, + canonical_file_name: &str, + export_name: &str, +) -> Result { + let entries: Vec<(String, JsValue)> = match value { + JsValue::Obj(obj) => es_ordered_entries(obj) + .into_iter() + .map(|(k, v)| (k.to_string(), v.clone())) + .collect(), + JsValue::Arr(items) => items + .iter() + .enumerate() + .map(|(i, v)| (i.to_string(), v.clone())) + .collect(), + // Object.entries over the upstream proxy sees no own keys. + JsValue::Proxy(_) => Vec::new(), + _ => return Err(StylexError::non_style_object("defineVars")), + }; + let options: &ResolvedOptions = ev.state.options; + let proxy = VarGroupProxy::new( + canonical_file_name.to_string(), + export_name.to_string(), + options, + ); + register_self_reference(ev, export_name, proxy.clone()); + + let mut normalized: Vec<(String, JsValue)> = Vec::new(); + let mut dep_map: Vec<(String, Vec)> = Vec::new(); + for (key, v) in &entries { + let mut deps = Vec::new(); + let nv = normalize_value(ev, &proxy, v, key, &mut deps, true)?; + normalized.push((key.clone(), nv)); + dep_map.push((key.clone(), deps)); + } + + let keys: BTreeSet<&str> = normalized.iter().map(|(k, _)| k.as_str()).collect(); + for (key, deps) in &dep_map { + for dep in deps { + if dep != "__varGroupHash__" && !keys.contains(dep.as_str()) { + return Err(StylexError::unknown_define_vars_reference(key, dep)); + } + } + } + assert_no_define_vars_cycles(&dep_map)?; + + define_vars_core(&normalized, canonical_file_name, export_name, options) +} + +// parity: visitors/stylex-define-vars.js normalizeDefineVarsValue. +fn normalize_value<'a>( + ev: &mut Evaluator<'a, '_>, + self_proxy: &VarGroupProxy, + value: &JsValue, + root_key: &str, + deps: &mut Vec, + allow_css_type: bool, +) -> Result { + match value { + JsValue::Callable(Callable::Arrow(key)) => { + evaluate_define_vars_function(ev, self_proxy, *key, root_key, deps) + } + // Injected callables carry fn.length >= 1 upstream. + JsValue::Callable(Callable::Stylex(_)) => { + Err(StylexError::invalid_define_vars_function_value()) + } + // Opaque = shapes upstream deopts on before normalize; same message. + JsValue::Callable(Callable::Opaque) => Err(StylexError::non_static_value("defineVars")), + JsValue::Str(_) | JsValue::Num(_) | JsValue::Null => Ok(value.clone()), + JsValue::Arr(_) => Err(StylexError::array_in_define_vars()), + JsValue::Obj(_) if as_css_type_js(value).is_some() => { + if allow_css_type { + Ok(value.clone()) + } else { + Err(StylexError::invalid_define_vars_function_value()) + } + } + JsValue::Obj(obj) => { + if matches!(obj.get("default"), None | Some(JsValue::Undefined)) { + return Err(StylexError::missing_default_value(Some(root_key))); + } + let nested: Vec<(String, JsValue)> = es_ordered_entries(obj) + .into_iter() + .map(|(k, v)| (k.to_string(), v.clone())) + .collect(); + let mut out = JsObj::default(); + for (k, v) in nested { + out.insert( + k, + normalize_value(ev, self_proxy, &v, root_key, deps, false)?, + ); + } + Ok(JsValue::object(out)) + } + // The proxy's 'default' trap answers a var string; it has no own keys. + JsValue::Proxy(_) => Ok(JsValue::object(JsObj::default())), + JsValue::Undefined | JsValue::Bool(_) => Err(StylexError::invalid_define_vars_value()), + } +} + +// parity: visitors/stylex-define-vars.js evaluateDefineVarsFunction — every +// throw out of fn() is swallowed into nonStaticValue. +fn evaluate_define_vars_function<'a>( + ev: &mut Evaluator<'a, '_>, + self_proxy: &VarGroupProxy, + arrow_key: u32, + root_key: &str, + deps: &mut Vec, +) -> Result { + let Some((body, param_count)) = arrow_info(ev, arrow_key) else { + return Err(StylexError::non_static_value("defineVars")); + }; + if param_count != 0 { + return Err(StylexError::invalid_define_vars_function_value()); + } + let mut tracker = DepTracker::new(self_proxy); + let _ = tracker.walk(ev, body); + for dep in tracker.deps { + if !deps.contains(&dep) { + deps.push(dep); + } + } + let result = match ev.eval(body) { + Ok(EvalOutcome::Value(v)) => v, + Ok(EvalOutcome::NonStatic(_)) | Err(_) => { + return Err(StylexError::non_static_value("defineVars")); + } + }; + if let JsValue::Callable(_) = result { + return Err(StylexError::invalid_define_vars_function_value()); + } + if as_css_type_js(&result).is_some() { + return Ok(result); + } + normalize_value(ev, self_proxy, &result, root_key, deps, false) +} + +// parity: visitors/stylex-define-vars.js assertNoDefineVarsCycles. +fn assert_no_define_vars_cycles(dep_map: &[(String, Vec)]) -> Result<(), StylexError> { + let map: BTreeMap<&str, &[String]> = dep_map + .iter() + .map(|(k, deps)| (k.as_str(), deps.as_slice())) + .collect(); + let mut visited: BTreeSet = BTreeSet::new(); + let mut in_stack: BTreeSet = BTreeSet::new(); + let mut stack: Vec = Vec::new(); + fn visit( + key: &str, + map: &BTreeMap<&str, &[String]>, + visited: &mut BTreeSet, + in_stack: &mut BTreeSet, + stack: &mut Vec, + ) -> Result<(), StylexError> { + if in_stack.contains(key) { + let start = stack.iter().position(|k| k == key).unwrap_or(0); + let mut parts: Vec<&str> = stack[start..].iter().map(String::as_str).collect(); + parts.push(key); + return Err(StylexError::cyclic_define_vars_reference( + &parts.join(" -> "), + )); + } + if visited.contains(key) { + return Ok(()); + } + visited.insert(key.to_string()); + in_stack.insert(key.to_string()); + stack.push(key.to_string()); + if let Some(deps) = map.get(key) { + for dep in deps.iter() { + if map.contains_key(dep.as_str()) { + visit(dep, map, visited, in_stack, stack)?; + } + } + } + stack.pop(); + in_stack.remove(key); + Ok(()) + } + for (key, _) in dep_map { + visit(key, &map, &mut visited, &mut in_stack, &mut stack)?; + } + Ok(()) +} + +/// Shared-core defineVars over pre-normalized entries (the nested wrapper feeds +/// flattened dot-keys). parity: shared/stylex-define-vars.js styleXDefineVars. +pub fn define_vars_core( + normalized: &[(String, JsValue)], + canonical_file_name: &str, + export_name: &str, + options: &ResolvedOptions, +) -> Result { + let export_id = gen_file_based_identifier(canonical_file_name, export_name, None); + let var_group_hash = format!("{}{}", options.class_name_prefix, hash(&export_id)); + let debug_names = options.debug && options.enable_debug_class_names; + + let mut js_output = JsObjectMap::new(); + let mut typed: EsKeyedMap<(Option, String)> = EsKeyedMap::new(); + let mut css_entries: Vec<(String, String, JsValue)> = Vec::new(); + for (key, value) in normalized { + let name_hash = if let Some(rest) = key.strip_prefix("--") { + rest.to_string() + } else { + let hashed = hash(&gen_file_based_identifier( + canonical_file_name, + export_name, + Some(key), + )); + if debug_names { + format!( + "{}-{}{hashed}", + var_safe_key(key), + options.class_name_prefix + ) + } else { + format!("{}{hashed}", options.class_name_prefix) + } + }; + let css_value = match as_css_type_js(value) { + Some((syntax, inner)) => { + typed.insert(&name_hash, (get_default_value(&inner)?, syntax)); + inner + } + None => value.clone(), + }; + js_output.insert(key.clone(), EvalValue::Str(format!("var(--{name_hash})"))); + css_entries.push((key.clone(), name_hash, css_value)); + } + js_output.insert("__varGroupHash__", EvalValue::Str(var_group_hash.clone())); + + let mut collection: Vec<(String, Vec)> = Vec::new(); + for (key, name_hash, value) in &css_entries { + collect_vars_by_at_rule(key, name_hash, value, &mut collection, &[])?; + } + + // parity: `{...injectableTypes, ...injectableStyles}` — an ES keyed object, + // so a colliding group-rule key overwrites the @property rule in place. + let mut merged: EsKeyedMap = EsKeyedMap::new(); + for (name_hash, (initial, syntax)) in typed.entries() { + let initial_part = initial + .as_ref() + .map(|iv| format!(" initial-value: {iv}")) + .unwrap_or_default(); + merged.insert( + &name_hash, + StylexRule { + class_name: name_hash.as_str().into(), + ltr: format!( + "@property --{name_hash} {{ syntax: \"{syntax}\"; inherits: true;{initial_part} }}" + ) + .into(), + rtl: None, + const_key: None, + const_val: None, + priority: 0.0, + }, + ); + } + // Object.entries(rulesByAtRule) enumerates numeric at-rule keys first. + for (at_key, decls) in es_keyed_order(&collection) { + let body = format!(":root, .{var_group_hash}{{{}}}", decls.join("")); + let (ltr, suffix) = if at_key == "default" { + (body, String::new()) + } else { + ( + wrap_with_at_rules(&body, at_key), + format!("-{}", hash(at_key)), + ) + }; + let class_name = format!("{var_group_hash}{suffix}"); + merged.insert( + &class_name.clone(), + StylexRule { + class_name: class_name.into(), + ltr: ltr.into(), + rtl: None, + const_key: None, + const_val: None, + priority: priority_for_at_rule(at_key) / 10.0, + }, + ); + } + let rules = merged.into_values(); + Ok(DefineVarsOutput { js_output, rules }) +} + +/// ES keyed-object stand-in for the upstream rule maps: canonical index keys +/// ascending first, insertion order after, overwrite keeps the key's position. +struct EsKeyedMap { + index_entries: Vec<(u32, V)>, + named_entries: Vec<(String, V)>, +} + +impl EsKeyedMap { + fn new() -> Self { + Self { + index_entries: Vec::new(), + named_entries: Vec::new(), + } + } + + fn insert(&mut self, key: &str, value: V) { + // parity: `obj[key] = v` — a "__proto__" key hits the prototype + // setter and creates no own entry (typedVariables, r5#7). + if key == "__proto__" { + return; + } + if let Some(n) = array_index(key) { + match self.index_entries.binary_search_by_key(&n, |e| e.0) { + Ok(i) => self.index_entries[i].1 = value, + Err(i) => self.index_entries.insert(i, (n, value)), + } + } else if let Some(entry) = self.named_entries.iter_mut().find(|(k, _)| k == key) { + entry.1 = value; + } else { + self.named_entries.push((key.to_string(), value)); + } + } + + fn entries(&self) -> impl Iterator { + self.index_entries + .iter() + .map(|(n, v)| (n.to_string(), v)) + .chain(self.named_entries.iter().map(|(k, v)| (k.clone(), v))) + } + + fn into_values(self) -> Vec { + self.index_entries + .into_iter() + .map(|(_, v)| v) + .chain(self.named_entries.into_iter().map(|(_, v)| v)) + .collect() + } +} + +/// Object.entries order over the insertion-ordered at-rule collection. +fn es_keyed_order(collection: &[(String, Vec)]) -> Vec<(&str, &Vec)> { + let mut index: Vec<(u32, &str, &Vec)> = Vec::new(); + let mut named: Vec<(&str, &Vec)> = Vec::new(); + for (k, v) in collection { + match array_index(k) { + Some(n) => index.push((n, k, v)), + None => named.push((k, v)), + } + } + index.sort_by_key(|(n, _, _)| *n); + index + .into_iter() + .map(|(_, k, v)| (k, v)) + .chain(named) + .collect() +} + +// parity: stylex-vars-utils.js collectVarsByAtRule (booleans, functions and +// undefined fall through every branch and drop silently). +pub fn collect_vars_by_at_rule( + key: &str, + name_hash: &str, + value: &JsValue, + collection: &mut Vec<(String, Vec)>, + at_rules: &[String], +) -> Result<(), StylexError> { + let leaf = match value { + JsValue::Str(s) => Some(s.clone()), + JsValue::Num(n) => Some(js_number_to_string(*n)), + _ => None, + }; + if let Some(val) = leaf { + let combo = if at_rules.is_empty() { + "default".to_string() + } else { + let mut sorted = at_rules.to_vec(); + sorted.sort_by(|a, b| utf16_cmp(a, b)); + sorted.join(SPLIT_TOKEN) + }; + let decl = format!("--{name_hash}:{val};"); + if let Some((_, decls)) = collection.iter_mut().find(|(k, _)| *k == combo) { + decls.push(decl); + } else { + collection.push((combo, vec![decl])); + } + return Ok(()); + } + match value { + JsValue::Null => Ok(()), + JsValue::Arr(_) => Err(StylexError::array_in_define_vars()), + JsValue::Obj(obj) => { + if matches!(obj.get("default"), None | Some(JsValue::Undefined)) { + return Err(StylexError::missing_default_value(Some(key))); + } + let nested: Vec<(String, JsValue)> = es_ordered_entries(obj) + .into_iter() + .map(|(k, v)| (k.to_string(), v.clone())) + .collect(); + for (at_rule, v) in nested { + if at_rule == "default" { + collect_vars_by_at_rule(key, name_hash, &v, collection, at_rules)?; + } else { + let mut extended = at_rules.to_vec(); + extended.push(at_rule); + collect_vars_by_at_rule(key, name_hash, &v, collection, &extended)?; + } + } + Ok(()) + } + _ => Ok(()), + } +} + +// parity: stylex-vars-utils.js wrapWithAtRules — later split parts wrap outside. +pub fn wrap_with_at_rules(ltr: &str, at_rule_key: &str) -> String { + at_rule_key + .split(SPLIT_TOKEN) + .fold(ltr.to_string(), |acc, at_rule| { + format!("{at_rule}{{{acc}}}") + }) +} + +pub fn priority_for_at_rule(at_rule_key: &str) -> f64 { + if at_rule_key == "default" { + 1.0 + } else { + 1.0 + at_rule_key.split(SPLIT_TOKEN).count() as f64 + } +} + +// parity: stylex-vars-utils.js getDefaultValue (keyless error message). +pub fn get_default_value(value: &JsValue) -> Result, StylexError> { + match value { + JsValue::Str(s) => Ok(Some(s.clone())), + JsValue::Num(n) => Ok(Some(js_number_to_string(*n))), + JsValue::Null | JsValue::Undefined => Ok(None), + JsValue::Arr(_) => Err(StylexError::array_in_define_vars()), + JsValue::Obj(obj) => match obj.get("default") { + None | Some(JsValue::Undefined) => Err(StylexError::missing_default_value(None)), + Some(inner) => { + let inner = inner.clone(); + get_default_value(&inner) + } + }, + JsValue::Proxy(proxy) => { + let resolved = proxy.resolve_key("default"); + Ok(Some(resolved)) + } + JsValue::Bool(_) | JsValue::Callable(_) => Err(StylexError::invalid_define_vars_value()), + } +} + +// parity: `(key[0] in 0-9 ? '_'+key : key).replace(/[^a-zA-Z0-9]/g, '_')`. +fn var_safe_key(key: &str) -> String { + let prefixed: std::borrow::Cow<'_, str> = + if key.chars().next().is_some_and(|c| c.is_ascii_digit()) { + format!("_{key}").into() + } else { + key.into() + }; + prefixed + .encode_utf16() + .map(|unit| match char::from_u32(u32::from(unit)) { + Some(c) if c.is_ascii_alphanumeric() => c, + _ => '_', + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn at_rule_grouping_helpers_match_oracle() { + // Pinned via live-oracle probe 2026-08-28 (probe1 "basic multi-atrule"). + assert_eq!( + wrap_with_at_rules( + ":root, .x{--a:1;}", + "@media (max-width: 600px)__$$__@supports (display: grid)" + ), + "@supports (display: grid){@media (max-width: 600px){:root, .x{--a:1;}}}" + ); + assert!(priority_for_at_rule("default") == 1.0); + assert!(priority_for_at_rule("@media print") == 2.0); + assert!(priority_for_at_rule("@media a__$$__@supports b") == 3.0); + } + + #[test] + fn collect_skips_null_and_opaque_values() { + let mut collection = Vec::new(); + collect_vars_by_at_rule("a", "xh", &JsValue::Null, &mut collection, &[]).unwrap(); + collect_vars_by_at_rule("a", "xh", &JsValue::Bool(true), &mut collection, &[]).unwrap(); + collect_vars_by_at_rule("a", "xh", &JsValue::Undefined, &mut collection, &[]).unwrap(); + collect_vars_by_at_rule( + "a", + "xh", + &JsValue::Callable(Callable::Opaque), + &mut collection, + &[], + ) + .unwrap(); + assert!(collection.is_empty()); + let err = collect_vars_by_at_rule("a", "xh", &JsValue::array(vec![]), &mut collection, &[]) + .unwrap_err(); + assert_eq!(err.message, "Array is not supported in defineVars"); + } + + #[test] + fn missing_default_messages() { + let mut obj = JsObj::default(); + obj.insert("@media print".to_string(), JsValue::Str("x".to_string())); + let mut collection = Vec::new(); + let err = collect_vars_by_at_rule( + "gap", + "xh", + &JsValue::object(obj.clone()), + &mut collection, + &[], + ) + .unwrap_err(); + assert_eq!( + err.message, + "Default value is not defined for gap variable." + ); + let err = get_default_value(&JsValue::object(obj)).unwrap_err(); + assert_eq!(err.message, "Default value is not defined for variable."); + assert_eq!(get_default_value(&JsValue::Null).unwrap(), None); + assert_eq!( + get_default_value(&JsValue::Num(4.0)).unwrap().as_deref(), + Some("4") + ); + let err = get_default_value(&JsValue::Bool(true)).unwrap_err(); + assert_eq!(err.message, "Invalid value in defineVars"); + } + + #[test] + fn cycle_error_text_matches_oracle() { + // Pinned via live-oracle probe 2026-08-28 (probe2 cycle shapes). + let dep_map = vec![ + ("a".to_string(), vec!["b".to_string()]), + ("b".to_string(), vec!["c".to_string()]), + ("c".to_string(), vec!["a".to_string()]), + ]; + let err = assert_no_define_vars_cycles(&dep_map).unwrap_err(); + assert_eq!( + err.message, + "Cyclic same-group references in defineVars() are not allowed: a -> b -> c -> a." + ); + let self_cycle = vec![("a".to_string(), vec!["a".to_string()])]; + let err = assert_no_define_vars_cycles(&self_cycle).unwrap_err(); + assert_eq!( + err.message, + "Cyclic same-group references in defineVars() are not allowed: a -> a." + ); + let ok = vec![ + ("x".to_string(), vec!["y".to_string()]), + ("y".to_string(), vec![]), + ]; + assert!(assert_no_define_vars_cycles(&ok).is_ok()); + } + + #[test] + fn es_ordered_entries_reorders_index_keys() { + let mut obj = JsObj::default(); + obj.insert("b".to_string(), JsValue::Str("1".into())); + obj.insert("2".to_string(), JsValue::Str("2".into())); + obj.insert("05".to_string(), JsValue::Str("3".into())); + let keys: Vec<&str> = es_ordered_entries(&obj) + .into_iter() + .map(|(k, _)| k) + .collect(); + assert_eq!(keys, vec!["2", "b", "05"]); + } +} diff --git a/crates/stylex/src/shared/dev_naming.rs b/crates/stylex/src/shared/dev_naming.rs new file mode 100644 index 00000000..9700b82d --- /dev/null +++ b/crates/stylex/src/shared/dev_naming.rs @@ -0,0 +1,282 @@ +//! Dev class names, test-mode replacement, and `$$css` debug source strings. +// parity: babel-plugin src/utils/{dev-classname,add-sourcemap-data}.js + +use crate::eval::value::{EvalValue, JsObjectMap}; +use crate::shared::dashify::sanitize_dev_class_name; +use std::collections::BTreeMap; +use std::sync::Arc; + +/// Filesystem-derived inputs wave 3 computes; upstream's package.json walk +/// starts at dirname, so a package.json in cwd itself is skipped. +#[derive(Debug, Clone, Default)] +pub struct DebugPathInfo { + /// Nearest package.json walking up from dirname(filename): (name, dir). + pub file_package: Option<(String, String)>, + /// Name of the nearest package.json walking up from dirname(cwd). + pub cwd_package_name: Option, + /// unstable_moduleResolution commonJS rootDir, when configured. + pub root_dir: Option, + pub is_haste: bool, +} + +/// The `{basename}__{varName.}` prefix shared by every namespace of one call. +fn dev_class_prefix(var_name: Option<&str>, filename: &str) -> String { + let basename = path_basename(filename) + .split('.') + .next() + .unwrap_or_default(); + match var_name { + Some(v) => format!("{basename}__{v}."), + None => format!("{basename}__"), + } +} + +// parity: dev-classname.js namespaceToDevClassName +pub fn namespace_to_dev_class_name( + namespace: &str, + var_name: Option<&str>, + filename: &str, +) -> String { + sanitize_dev_class_name(&format!( + "{}{namespace}", + dev_class_prefix(var_name, filename) + )) +} + +/// Prepends the self-mapping dev-classname key to every compiled namespace. +pub fn inject_dev_class_names( + compiled: Arc, + var_name: Option<&str>, + filename: Option<&str>, +) -> Arc { + let prefix = dev_class_prefix(var_name, filename.unwrap_or("UnknownFile")); + let mut result = JsObjectMap::new(); + for (namespace, value) in compiled.entries() { + let dev_class = sanitize_dev_class_name(&format!("{prefix}{namespace}")); + let mut obj = JsObjectMap::new(); + obj.insert(dev_class.clone(), EvalValue::Str(dev_class)); + if let EvalValue::Obj(inner) = value { + for (k, v) in inner.entries() { + obj.insert(k.to_string(), v.clone()); + } + } + result.insert(namespace.to_string(), EvalValue::Obj(Arc::new(obj))); + } + Arc::new(result) +} + +// parity: dev-classname.js convertToTestStyles +pub fn convert_to_test_styles( + compiled: &Arc, + var_name: Option<&str>, + filename: Option<&str>, +) -> Arc { + let prefix = dev_class_prefix(var_name, filename.unwrap_or("UnknownFile")); + let mut result = JsObjectMap::new(); + for (namespace, _) in compiled.entries() { + let dev_class = sanitize_dev_class_name(&format!("{prefix}{namespace}")); + let mut obj = JsObjectMap::new(); + obj.insert(dev_class.clone(), EvalValue::Str(dev_class)); + obj.insert("$$css", EvalValue::Bool(true)); + result.insert(namespace.to_string(), EvalValue::Obj(Arc::new(obj))); + } + Arc::new(result) +} + +/// Replaces `$$css: true` with `":"` per namespace whose +/// source line is known (upstream skips namespaces it cannot find in the AST). +pub fn add_source_map_data( + compiled: Arc, + namespace_lines: Option<&BTreeMap>, + filename: Option<&str>, + info: &DebugPathInfo, +) -> Arc { + let mut short: Option = None; + let mut result = JsObjectMap::new(); + for (namespace, value) in compiled.entries() { + let line = namespace_lines + .and_then(|lines| lines.get(namespace)) + .copied(); + match (line, filename) { + (Some(line), Some(filename)) if line > 0 => { + let mut obj = match value { + EvalValue::Obj(inner) => (**inner).clone(), + other => { + result.insert(namespace.to_string(), other.clone()); + continue; + } + }; + let short = short.get_or_insert_with(|| create_short_filename(filename, info)); + let css_value = if short.is_empty() { + EvalValue::Bool(true) + } else { + EvalValue::Str(format!("{short}:{line}")) + }; + obj.insert("$$css", css_value); + result.insert(namespace.to_string(), EvalValue::Obj(Arc::new(obj))); + } + _ => { + result.insert(namespace.to_string(), value.clone()); + } + } + } + Arc::new(result) +} + +// parity: add-sourcemap-data.js createShortFilename (debugFilePath unsupported) +pub fn create_short_filename(filename: &str, info: &DebugPathInfo) -> String { + if let Some((name, dir)) = &info.file_package { + let relative = path_relative(dir, filename); + if info.cwd_package_name.as_deref() == Some(name.as_str()) { + return relative; + } + return format!("{name}:{relative}"); + } + if let Some(prefix) = package_prefix(filename) { + return format!("{prefix}:{}", short_path(filename, info)); + } + if info.is_haste { + return path_basename(filename).to_string(); + } + short_path(filename, info) +} + +// parity: add-sourcemap-data.js getShortPath +fn short_path(filename: &str, info: &DebugPathInfo) -> String { + if let Some(root_dir) = &info.root_dir { + return path_relative(root_dir, filename); + } + let segments: Vec<&str> = filename.split('/').collect(); + let start = segments.len().saturating_sub(2); + segments[start..].join("/") +} + +// parity: add-sourcemap-data.js getPackagePrefix ('' prefix is falsy upstream) +fn package_prefix(filename: &str) -> Option { + let idx = filename.find("node_modules")?; + let rest = filename.get(idx + "node_modules".len() + 1..)?; + let prefix = rest.split('/').next().unwrap_or_default(); + (!prefix.is_empty()).then(|| prefix.to_string()) +} + +fn path_basename(path: &str) -> &str { + path.rsplit('/').next().unwrap_or(path) +} + +/// `path.relative` over already-normalized absolute '/'-separated paths. +fn path_relative(from: &str, to: &str) -> String { + let from_parts: Vec<&str> = from.split('/').filter(|s| !s.is_empty()).collect(); + let to_parts: Vec<&str> = to.split('/').filter(|s| !s.is_empty()).collect(); + let common = from_parts + .iter() + .zip(to_parts.iter()) + .take_while(|(a, b)| a == b) + .count(); + let mut parts: Vec<&str> = vec![".."; from_parts.len() - common]; + parts.extend(&to_parts[common..]); + parts.join("/") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dev_class_names() { + assert_eq!( + namespace_to_dev_class_name("root", Some("styles"), "/fake/root/pins.ts"), + "pins__styles.root" + ); + assert_eq!( + namespace_to_dev_class_name("a", None, "/fake/root/pins.ts"), + "pins__a" + ); + assert_eq!( + namespace_to_dev_class_name("weird ns$!name", Some("styles"), "/fake/root/pins.ts"), + "pins__styles.weirdnsname" + ); + assert_eq!( + namespace_to_dev_class_name("0", Some("styles"), "/fake/root/pins.ts"), + "pins__styles.0" + ); + } + + #[test] + fn short_filename_branches() { + // Pinned via probes 2026-08-27 against @stylexjs/babel-plugin 0.19.0. + let plain = DebugPathInfo::default(); + assert_eq!( + create_short_filename("/fake/root/pins.ts", &plain), + "root/pins.ts" + ); + assert_eq!(create_short_filename("pins.ts", &plain), "pins.ts"); + + let rooted = DebugPathInfo { + root_dir: Some("/fake".to_string()), + ..DebugPathInfo::default() + }; + assert_eq!( + create_short_filename("/fake/deep/nested/dir/pins.ts", &rooted), + "deep/nested/dir/pins.ts" + ); + let rooted_outside = DebugPathInfo { + root_dir: Some("/fake/other".to_string()), + ..DebugPathInfo::default() + }; + assert_eq!( + create_short_filename("/fake/deep/pins.ts", &rooted_outside), + "../deep/pins.ts" + ); + + let pkg = DebugPathInfo { + file_package: Some(("fixture-pkg".to_string(), "/tmp/x/fixture-pkg".to_string())), + ..DebugPathInfo::default() + }; + assert_eq!( + create_short_filename("/tmp/x/fixture-pkg/src/thing.ts", &pkg), + "fixture-pkg:src/thing.ts" + ); + let same_pkg = DebugPathInfo { + cwd_package_name: Some("fixture-pkg".to_string()), + ..pkg + }; + assert_eq!( + create_short_filename("/tmp/x/fixture-pkg/src/thing.ts", &same_pkg), + "src/thing.ts" + ); + + assert_eq!( + create_short_filename("/fake/proj/node_modules/some-lib/dist/x.ts", &plain), + "some-lib:dist/x.ts" + ); + let rooted = DebugPathInfo { + root_dir: Some("/fake".to_string()), + ..DebugPathInfo::default() + }; + assert_eq!( + create_short_filename("/fake/proj/node_modules/some-lib/dist/x.ts", &rooted), + "some-lib:proj/node_modules/some-lib/dist/x.ts" + ); + } + + #[test] + fn short_filename_haste_arm() { + // Last resort only: a package or node_modules ancestor still wins. + let haste = DebugPathInfo { + is_haste: true, + ..DebugPathInfo::default() + }; + assert_eq!( + create_short_filename("/html/js/components/Foo.react.js", &haste), + "Foo.react.js" + ); + let in_package = DebugPathInfo { + file_package: Some(("fixture-pkg".to_string(), "/tmp/x/fixture-pkg".to_string())), + ..haste + }; + assert_eq!( + create_short_filename("/tmp/x/fixture-pkg/src/Foo.js", &in_package), + "fixture-pkg:src/Foo.js" + ); + } +} diff --git a/crates/stylex/src/shared/dynamic.rs b/crates/stylex/src/shared/dynamic.rs new file mode 100644 index 00000000..8fb066a1 --- /dev/null +++ b/crates/stylex/src/shared/dynamic.rs @@ -0,0 +1,624 @@ +//! Function-valued namespaces -> CSS-variable rules + a structured arrow value. +// parity: visitors/parse-stylex-create-arg.js + stylex-create.js:228-485 + +use oxc_ast::ast::Expression; +use oxc_span::{GetSpan, Span}; + +use crate::eval::unwrap_parens; +use crate::eval::value::{EvalValue, JsObjectMap}; +use crate::hash::hash; +use crate::options::{ResolvedOptions, StyleResolution}; +use crate::rules::StylexRule; +use crate::shared::flatten::StyleScalar; +use crate::shared::resolution::flat_map_expanded_shorthands; +use crate::shared::transform_value::get_number_suffix; + +/// One CSS-variable-backed dynamic leaf, recorded during create-arg evaluation. +#[derive(Debug, Clone, PartialEq)] +pub struct InlineStyle { + pub key_path: Vec, + /// Span of the original (paren-unwrapped) leaf expression in the source. + pub span: Span, + /// "" ⇒ plain `expr != null ? expr : undefined`; else the + /// `((val) => typeof val === "number" ? val + "" : …)(expr)` coercion. + pub unit: String, + pub has_nullish_fallback: bool, + pub safe_to_skip_null_check: bool, +} + +/// The `fns[namespace]` payload of parse-stylex-create-arg.js. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct DynamicFn { + pub params: Vec, + /// varName → leaf; insertion-ordered, re-writes keep the first position. + pub inline_styles: Vec<(String, InlineStyle)>, +} + +impl DynamicFn { + pub fn insert_inline(&mut self, var_name: String, style: InlineStyle) { + match self.inline_styles.iter_mut().find(|(n, _)| *n == var_name) { + Some(slot) => slot.1 = style, + None => self.inline_styles.push((var_name, style)), + } + } +} + +/// Builds the var name + metadata for one non-static leaf. +// parity: parse-stylex-create-arg.js:181-248 +pub fn inline_style_for_leaf( + expr: &Expression<'_>, + key_path: &[String], + key: &str, +) -> (String, InlineStyle) { + let expr = unwrap_parens(expr); + let mut full_key_path: Vec = key_path.to_vec(); + full_key_path.push(key.to_string()); + let var_name = if key_path.is_empty() { + format!("--x-{key}") + } else { + format!("--x-{}", hash(&full_key_path.join("_"))) + }; + let prop_name = full_key_path + .iter() + .find(|k| !k.starts_with(':') && !k.starts_with('@') && *k != "default") + .map(String::as_str) + .unwrap_or(key); + let unit = if is_time_unit_prop(prop_name) || is_length_unit_prop(prop_name) { + get_number_suffix(prop_name) + } else { + "" + }; + let style = InlineStyle { + key_path: full_key_path, + span: expr.span(), + unit: unit.to_string(), + has_nullish_fallback: has_explicit_nullish_fallback(expr), + safe_to_skip_null_check: is_safe_to_skip_null_check(expr), + }; + (var_name, style) +} + +// parity: stylex-create.js isSafeToSkipNullCheck (babel ASTs carry no parens). +fn is_safe_to_skip_null_check(expr: &Expression<'_>) -> bool { + match unwrap_parens(expr) { + Expression::TemplateLiteral(_) + | Expression::StringLiteral(_) + | Expression::NumericLiteral(_) + | Expression::BooleanLiteral(_) => true, + Expression::BinaryExpression(bin) => { + matches!(bin.operator.as_str(), "+" | "-" | "*" | "/" | "%" | "**") + } + Expression::UnaryExpression(unary) => matches!(unary.operator.as_str(), "-" | "+"), + Expression::ConditionalExpression(cond) => { + is_safe_to_skip_null_check(&cond.consequent) + && is_safe_to_skip_null_check(&cond.alternate) + } + Expression::LogicalExpression(logical) => match logical.operator.as_str() { + "??" | "||" => { + is_safe_to_skip_null_check(&logical.left) + || is_safe_to_skip_null_check(&logical.right) + } + "&&" => { + is_safe_to_skip_null_check(&logical.left) + && is_safe_to_skip_null_check(&logical.right) + } + _ => false, + }, + _ => false, + } +} + +// parity: stylex-create.js hasExplicitNullishFallback +fn has_explicit_nullish_fallback(expr: &Expression<'_>) -> bool { + match unwrap_parens(expr) { + Expression::NullLiteral(_) => true, + Expression::Identifier(id) => id.name == "undefined", + Expression::UnaryExpression(unary) => unary.operator.as_str() == "void", + Expression::ConditionalExpression(cond) => { + has_explicit_nullish_fallback(&cond.consequent) + || has_explicit_nullish_fallback(&cond.alternate) + } + Expression::LogicalExpression(logical) => { + has_explicit_nullish_fallback(&logical.left) + || has_explicit_nullish_fallback(&logical.right) + } + _ => false, + } +} + +/// One piece of a rewritten class-string concatenation. +#[derive(Debug, Clone, PartialEq)] +pub enum ClassPart { + /// String-literal chunk (class name plus trailing space when not last). + Lit(String), + /// ` != null ? "" : ` — expr printed from its span. + Guarded { lit: String, expr: Span }, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct InlineVar { + pub var_name: String, + /// "" ⇒ nullish wrapper, else the typeof-number unit-coercion wrapper. + pub unit: String, + pub expr: Span, +} + +/// Compiled arrow: `(params) => [staticObj?, conditionalObj?, vars]` (bare vars +/// when both partitions empty); `$$css` keys carry `css_tag`. +#[derive(Debug, Clone, PartialEq)] +pub struct DynamicCompiled { + pub params: Vec, + /// key → string-literal segments; empty vec prints/evaluates as "". + pub static_props: Vec<(String, Vec)>, + pub conditional_props: Vec<(String, Vec)>, + pub css_tag: EvalValue, + pub inline_vars: Vec, +} + +impl DynamicCompiled { + /// False only for namespaces with no compiled props: the arrow returns the + /// bare vars object and the `$$css` tag is dropped entirely (upstream). + pub fn has_container(&self) -> bool { + !self.static_props.is_empty() || !self.conditional_props.is_empty() + } +} + +/// Compiled namespace -> arrow value; `injected_lookup` mirrors the visitor's +/// merged injectedStyles map. parity: stylex-create.js:294-485 fns branch. +pub fn compile_dynamic_namespace( + compiled_ns: &JsObjectMap, + class_paths: &[(String, Vec)], + fn_def: &DynamicFn, + injected_lookup: &[StylexRule], + options: &ResolvedOptions, +) -> DynamicCompiled { + let orig_class_paths: Vec<(&str, String)> = class_paths + .iter() + .map(|(class_name, path)| (class_name.as_str(), path.join("_"))) + .collect(); + let dynamic_styles: Vec<(String, &InlineStyle)> = + if options.style_resolution == StyleResolution::LegacyExpandShorthands { + legacy_expand_dynamic_styles(fn_def, options) + } else { + fn_def + .inline_styles + .iter() + .map(|(_, style)| (style.key_path.join("_"), style)) + .collect() + }; + let nullish_vars: Vec<(&str, &InlineStyle)> = fn_def + .inline_styles + .iter() + .filter(|(_, style)| style.has_nullish_fallback) + .map(|(name, style)| (name.as_str(), style)) + .collect(); + + let mut css_tag = EvalValue::Bool(true); + let mut static_props: Vec<(String, Vec)> = Vec::new(); + let mut conditional_props: Vec<(String, Vec)> = Vec::new(); + + for (key, value) in compiled_ns.entries() { + if key == "$$css" { + css_tag = value.clone(); + continue; + } + // JS "".split(' ') is [""]; both roads end at a static "" prop. + let class_list: Vec<&str> = match value { + EvalValue::Str(s) => s.split(' ').collect(), + _ => Vec::new(), + }; + let mut is_static = true; + let mut parts: Vec = Vec::new(); + for (index, cls) in class_list.iter().enumerate() { + let mut expr: Option<&InlineStyle> = orig_class_paths + .iter() + .find(|(name, _)| name == cls) + .and_then(|(_, path)| { + dynamic_styles + .iter() + .find(|(p, _)| p == path) + .map(|(_, style)| *style) + }); + if expr.is_none() && !nullish_vars.is_empty() { + expr = injected_lookup + .iter() + .find(|rule| &*rule.class_name == *cls) + .and_then(|rule| { + placeholder_var_names(&rule.ltr).into_iter().find_map(|v| { + nullish_vars + .iter() + .find(|(name, _)| *name == v) + .map(|(_, style)| *style) + }) + }); + } + let is_last = index == class_list.len() - 1; + let lit = if is_last { + (*cls).to_string() + } else { + format!("{cls} ") + }; + match expr { + Some(style) if !style.safe_to_skip_null_check => { + is_static = false; + parts.push(ClassPart::Guarded { + lit, + expr: style.span, + }); + } + _ => parts.push(ClassPart::Lit(lit)), + } + } + if is_static { + let segments = parts + .into_iter() + .map(|part| match part { + ClassPart::Lit(lit) => lit, + ClassPart::Guarded { .. } => unreachable!("static props hold literals only"), + }) + .collect(); + static_props.push((key.to_string(), segments)); + } else { + conditional_props.push((key.to_string(), parts)); + } + } + + DynamicCompiled { + params: fn_def.params.clone(), + static_props, + conditional_props, + css_tag, + inline_vars: fn_def + .inline_styles + .iter() + .map(|(var_name, style)| InlineVar { + var_name: var_name.clone(), + unit: style.unit.clone(), + expr: style.span, + }) + .collect(), + } +} + +/// The path each leaf is looked up by: everything up to and including the +/// first segment that is not a pseudo/at-rule condition. +fn truncated_key(key_path: &[String]) -> String { + let end = key_path + .iter() + .position(|k| !k.starts_with(':') && !k.starts_with('@')) + .map_or(key_path.len(), |i| i + 1); + key_path[..end].join("_") +} + +/// One entry per expanded longhand, key and path rewritten; nulls drop out. +// parity: visitors/stylex-create.js:333 legacyExpandShorthands +fn legacy_expand_dynamic_styles<'a>( + fn_def: &'a DynamicFn, + options: &ResolvedOptions, +) -> Vec<(String, &'a InlineStyle)> { + let mut out = Vec::with_capacity(fn_def.inline_styles.len()); + for (index, (_, style)) in fn_def.inline_styles.iter().enumerate() { + let key = truncated_key(&style.key_path); + let path = style.key_path.join("_"); + let placeholder = StyleScalar::Str(std::borrow::Cow::Owned(format!("p{index}"))); + let Ok(pairs) = flat_map_expanded_shorthands( + std::borrow::Cow::Borrowed(key.as_str()), + Some(placeholder), + false, + options, + ) else { + continue; + }; + for (new_key, value) in pairs { + if !matches!(value, Some(StyleScalar::Str(_))) { + continue; + } + let new_path = if path == key { + new_key.into_owned() + } else if let Some(rest) = path.strip_prefix(&format!("{key}_")) { + format!("{new_key}_{rest}") + } else { + path.replacen(&format!("_{key}"), &format!("_{new_key}"), 1) + }; + out.push((new_path, style)); + } + } + out +} + +/// The `/var\((--x-[^,)]+)[^)]*\)/g` capture list over one rule body. +fn placeholder_var_names(rule: &str) -> Vec<&str> { + let mut names = Vec::new(); + let mut search = 0; + while let Some(pos) = rule[search..].find("var(") { + let after = search + pos + 4; + let rest = &rule[after..]; + if rest.starts_with("--x-") { + let name_len = rest.find([',', ')']).unwrap_or(rest.len()); + if name_len > 4 + && let Some(close) = rest.find(')') + && close >= name_len + { + names.push(&rest[..name_len]); + search = after + close + 1; + continue; + } + } + search = after; + } + names +} + +/// Per-variable `@property` rules (priority 0), deduped first-seen/last-wins. +// parity: stylex-create.js:228-248 injectedInheritStyles +pub fn inherit_rules(fns: &[(String, DynamicFn)]) -> Vec { + let mut rules: Vec = Vec::new(); + for (_, fn_def) in fns { + for (var_name, style) in &fn_def.inline_styles { + // Pseudo-elements can only access css vars via inheritance. + let inherits = style.key_path.iter().any(|k| k.starts_with("::")); + let rule = StylexRule { + class_name: var_name.as_str().into(), + ltr: format!("@property {var_name} {{ syntax: \"*\"; inherits: {inherits};}}") + .into(), + rtl: None, + const_key: None, + const_val: None, + priority: 0.0, + }; + match rules.iter_mut().find(|r| &*r.class_name == var_name) { + Some(slot) => *slot = rule, + None => rules.push(rule), + } + } + } + rules +} + +fn is_time_unit_prop(key: &str) -> bool { + matches!( + key, + "animationDelay" + | "animationDuration" + | "transitionDelay" + | "transitionDuration" + | "voiceDuration" + ) +} + +// parity: transform-value.js lengthUnits (verbatim, duplicates and all) +fn is_length_unit_prop(key: &str) -> bool { + matches!( + key, + "backgroundPositionX" + | "backgroundPositionY" + | "blockSize" + | "borderBlockEndWidth" + | "borderBlockStartWidth" + | "borderBlockWidth" + | "borderVerticalWidth" + | "borderBottomLeftRadius" + | "borderBottomRightRadius" + | "borderBottomWidth" + | "borderEndEndRadius" + | "borderEndStartRadius" + | "borderInlineEndWidth" + | "borderEndWidth" + | "borderInlineStartWidth" + | "borderStartWidth" + | "borderInlineWidth" + | "borderHorizontalWidth" + | "borderLeftWidth" + | "borderRightWidth" + | "borderSpacing" + | "borderStartEndRadius" + | "borderStartStartRadius" + | "borderTopLeftRadius" + | "borderTopRightRadius" + | "borderTopWidth" + | "bottom" + | "columnGap" + | "columnRuleWidth" + | "columnWidth" + | "containIntrinsicBlockSize" + | "containIntrinsicHeight" + | "containIntrinsicInlineSize" + | "containIntrinsicWidth" + | "flexBasis" + | "fontSize" + | "fontSmooth" + | "height" + | "inlineSize" + | "insetBlockEnd" + | "insetBlockStart" + | "insetInlineEnd" + | "insetInlineStart" + | "left" + | "letterSpacing" + | "marginBlockEnd" + | "marginBlockStart" + | "marginBottom" + | "marginInlineEnd" + | "marginEnd" + | "marginInlineStart" + | "marginStart" + | "marginLeft" + | "marginRight" + | "marginTop" + | "maxBlockSize" + | "maxHeight" + | "maxInlineSize" + | "maxWidth" + | "minBlockSize" + | "minHeight" + | "minInlineSize" + | "minWidth" + | "offsetDistance" + | "outlineOffset" + | "outlineWidth" + | "overflowClipMargin" + | "paddingBlockEnd" + | "paddingBlockStart" + | "paddingBottom" + | "paddingInlineEnd" + | "paddingEnd" + | "paddingInlineStart" + | "paddingStart" + | "paddingLeft" + | "paddingRight" + | "paddingTop" + | "perspective" + | "right" + | "rowGap" + | "scrollMarginBlockEnd" + | "scrollMarginBlockStart" + | "scrollMarginBottom" + | "scrollMarginInlineEnd" + | "scrollMarginInlineStart" + | "scrollMarginLeft" + | "scrollMarginRight" + | "scrollMarginTop" + | "scrollPaddingBlockEnd" + | "scrollPaddingBlockStart" + | "scrollPaddingBottom" + | "scrollPaddingInlineEnd" + | "scrollPaddingInlineStart" + | "scrollPaddingLeft" + | "scrollPaddingRight" + | "scrollPaddingTop" + | "scrollSnapMarginBottom" + | "scrollSnapMarginLeft" + | "scrollSnapMarginRight" + | "scrollSnapMarginTop" + | "shapeMargin" + | "tabSize" + | "textDecorationThickness" + | "textIndent" + | "textUnderlineOffset" + | "top" + | "transformOrigin" + | "translate" + | "verticalAlign" + | "width" + | "wordSpacing" + | "border" + | "borderBlock" + | "borderBlockEnd" + | "borderBlockStart" + | "borderBottom" + | "borderLeft" + | "borderRadius" + | "borderRight" + | "borderTop" + | "borderWidth" + | "columnRule" + | "containIntrinsicSize" + | "gap" + | "inset" + | "insetBlock" + | "insetInline" + | "margin" + | "marginBlock" + | "marginVertical" + | "marginInline" + | "marginHorizontal" + | "offset" + | "outline" + | "padding" + | "paddingBlock" + | "paddingVertical" + | "paddingInline" + | "paddingHorizontal" + | "scrollMargin" + | "scrollMarginBlock" + | "scrollMarginInline" + | "scrollPadding" + | "scrollPaddingBlock" + | "scrollPaddingInline" + | "scrollSnapMargin" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wrapper_unit_pins() { + // width: length prop → px; tabSize: length ∩ unitless → "" wrapper; + // opacity/color: not in either set → nullish wrapper. + let unit = |prop: &str| { + if is_time_unit_prop(prop) || is_length_unit_prop(prop) { + get_number_suffix(prop) + } else { + "" + } + }; + assert_eq!(unit("width"), "px"); + assert_eq!(unit("animationDuration"), "ms"); + assert_eq!(unit("tabSize"), ""); + assert_eq!(unit("opacity"), ""); + assert_eq!(unit("color"), ""); + assert_eq!(unit("strokeWidth"), ""); + } + + #[test] + fn placeholder_var_name_scan_matches_regex() { + assert_eq!( + placeholder_var_names(".x{width:var(--x-width)}"), + vec!["--x-width"] + ); + assert_eq!( + placeholder_var_names(".x{width:var(--x-a, 10px)}"), + vec!["--x-a"] + ); + assert_eq!( + placeholder_var_names(".x{margin:var(--x-a) var(--x-b)}"), + vec!["--x-a", "--x-b"] + ); + // Non-placeholder vars and the bare prefix do not match. + assert!(placeholder_var_names(".x{color:var(--other)}").is_empty()); + assert!(placeholder_var_names(".x{color:var(--x-)}").is_empty()); + } + + #[test] + fn inherit_rules_dedupe_and_pseudo_element_inherit() { + let style = |path: &[&str]| InlineStyle { + key_path: path.iter().map(|s| s.to_string()).collect(), + span: Span::default(), + unit: String::new(), + has_nullish_fallback: false, + safe_to_skip_null_check: false, + }; + let fns = vec![ + ( + "a".to_string(), + DynamicFn { + params: vec!["w".to_string()], + inline_styles: vec![("--x-width".to_string(), style(&["width"]))], + }, + ), + ( + "b".to_string(), + DynamicFn { + params: vec!["w".to_string()], + inline_styles: vec![ + ("--x-width".to_string(), style(&["width"])), + ("--x-abc".to_string(), style(&["::before", "width"])), + ], + }, + ), + ]; + let rules = inherit_rules(&fns); + assert_eq!(rules.len(), 2); + assert_eq!( + &*rules[0].ltr, + "@property --x-width { syntax: \"*\"; inherits: false;}" + ); + assert_eq!( + &*rules[1].ltr, + "@property --x-abc { syntax: \"*\"; inherits: true;}" + ); + assert!(rules.iter().all(|r| r.priority == 0.0)); + } +} diff --git a/crates/stylex/src/shared/fallbacks.rs b/crates/stylex/src/shared/fallbacks.rs new file mode 100644 index 00000000..0ceb37d1 --- /dev/null +++ b/crates/stylex/src/shared/fallbacks.rs @@ -0,0 +1,100 @@ +//! Fallback arrays: contiguous `var()` runs collapse into nested `var(a, var(b, …))`. +// parity: babel-plugin src/shared/utils/convert-to-className.js:85-121 + +use crate::errors::StylexError; + +fn is_var(value: &str) -> bool { + value.starts_with("var(") && value.ends_with(')') +} + +pub fn has_var_fallback(values: &[String]) -> bool { + values.iter().any(|v| is_var(v)) +} + +/// Errors with `NON_CONTIGUOUS_VARS` when a non-var value sits between vars. +pub fn variable_fallbacks(values: &[String]) -> Result, StylexError> { + let first_var = values.iter().position(|v| is_var(v)); + let last_var = values.iter().rposition(|v| is_var(v)); + let (Some(first_var), Some(last_var)) = (first_var, last_var) else { + // Unreachable via convertStyleToClassName (caller pre-checks has_var_fallback). + return Ok(values.to_vec()); + }; + + let values_before = &values[..first_var]; + let mut var_values: Vec<&String> = values[first_var..=last_var].iter().collect(); + var_values.reverse(); + let values_after = &values[last_var + 1..]; + + if var_values.iter().any(|v| !is_var(v)) { + return Err(StylexError::non_contiguous_vars()); + } + let var_names: Vec<&str> = var_values.iter().map(|v| &v[4..v.len() - 1]).collect(); + + let mut out = Vec::new(); + if values_before.is_empty() { + out.push(compose_vars(&var_names)); + } else { + for val in values_before { + let mut args = var_names.clone(); + args.push(val); + out.push(compose_vars(&args)); + } + } + out.extend(values_after.iter().cloned()); + Ok(out) +} + +// parity: convert-to-className.js composeVars +fn compose_vars(args: &[&str]) -> String { + match args { + [] => unreachable!("compose_vars is never called with zero args"), + [first] if first.starts_with("--") => format!("var({first})"), + [first] => (*first).to_string(), + [first, rest @ ..] => format!("var({first},{})", compose_vars(rest)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::errors::ErrorCode; + + fn v(items: &[&str]) -> Vec { + items.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn oracle_pinned_shapes() { + // Pinned via probes 2026-08-27 against @stylexjs/babel-plugin 0.19.0. + assert_eq!( + variable_fallbacks(&v(&["var(--a)", "red"])).unwrap(), + v(&["var(--a)", "red"]) + ); + assert_eq!( + variable_fallbacks(&v(&["red", "var(--a)", "var(--b)", "blue"])).unwrap(), + v(&["var(--b,var(--a,red))", "blue"]) + ); + assert_eq!( + variable_fallbacks(&v(&["red", "blue", "var(--a)", "var(--b)"])).unwrap(), + v(&["var(--b,var(--a,red))", "var(--b,var(--a,blue))"]) + ); + assert_eq!( + variable_fallbacks(&v(&["var(--a)", "var(--b)"])).unwrap(), + v(&["var(--b,var(--a))"]) + ); + assert_eq!( + variable_fallbacks(&v(&["var(--a)"])).unwrap(), + v(&["var(--a)"]) + ); + } + + #[test] + fn non_contiguous_vars_throw() { + let err = variable_fallbacks(&v(&["var(--a)", "red", "var(--b)"])).unwrap_err(); + assert_eq!(err.code, ErrorCode::NonContiguousVars); + assert_eq!( + err.message, + "All variables passed to firstThatWorks() must be contiguous." + ); + } +} diff --git a/crates/stylex/src/shared/flatten.rs b/crates/stylex/src/shared/flatten.rs new file mode 100644 index 00000000..a75e8514 --- /dev/null +++ b/crates/stylex/src/shared/flatten.rs @@ -0,0 +1,643 @@ +//! Raw style namespace → flat `[key, PreRule]` entries, plus basic validation. +// parity: babel-plugin src/shared/preprocess-rules/{flatten-raw-style-obj,basic-validation,PreRule}.js + +use crate::errors::StylexError; +use crate::eval::value::{EvalValue, JsObjectMap}; +use crate::options::ResolvedOptions; +use crate::shared::generate_rule::{CompiledDecl, convert_style_to_class_name}; +use crate::shared::media_query::{MediaQueryError, is_media_key, last_media_query_wins_transform}; +use crate::shared::resolution::flat_map_expanded_shorthands; +use std::borrow::Cow; +use std::sync::Arc; + +#[derive(Clone, Debug, PartialEq)] +pub enum StyleScalar<'a> { + Str(Cow<'a, str>), + Num(f64), + /// A legacy shorthand whose split produced no part for this side; it + /// renders as an empty declaration instead of throwing on an empty value. + Undefined, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum PreRuleValue<'a> { + Single(StyleScalar<'a>), + Multi(Vec>), +} + +#[derive(Clone, Debug, PartialEq)] +pub enum PreRule<'a> { + Null, + Rule { + property: Cow<'a, str>, + value: PreRuleValue<'a>, + key_path: Vec>, + }, + Set(Vec>), +} + +/// One compiled slot: `None` mirrors upstream's `null` ComputedStyle; the +/// second element is the classesToOriginalPath keyPath for that class. +pub type ComputedStyle<'a, 'b> = Option<(CompiledDecl, &'b [Cow<'a, str>])>; + +/// The property-value branch's per-property (condition, rule) groupings. +type ConditionGroups<'a> = Vec<(Cow<'a, str>, Vec<(&'a str, PreRule<'a>)>)>; + +impl<'a> PreRule<'a> { + // parity: PreRule.js PreRuleSet.create + fn create_set(rules: Vec>) -> PreRule<'a> { + let mut flat: Vec = Vec::with_capacity(rules.len()); + for rule in rules { + match rule { + PreRule::Set(inner) => flat.extend(inner), + other => flat.push(other), + } + } + match flat.len() { + 0 => PreRule::Null, + 1 => flat.into_iter().next().expect("len checked"), + _ => PreRule::Set(flat), + } + } + + pub fn compiled( + &self, + options: &ResolvedOptions, + ) -> Result>, StylexError> { + match self { + PreRule::Null => Ok(vec![None]), + PreRule::Rule { + property, + value, + key_path, + } => { + // Condition lists stay in keyPath order; the sorted orders are + // derived inside the conversion, on its memo-miss path only. + let pseudos: Vec<&str> = key_path + .iter() + .filter(|k| k.starts_with(':') || k.starts_with('[')) + .map(Cow::as_ref) + .collect(); + let at_rules: Vec<&str> = key_path + .iter() + .filter(|k| k.starts_with('@')) + .map(Cow::as_ref) + .collect(); + let const_rules: Vec<&str> = key_path + .iter() + .filter(|k| k.starts_with("var(--")) + .map(Cow::as_ref) + .collect(); + let decl = convert_style_to_class_name( + property, + value, + &pseudos, + &at_rules, + &const_rules, + options, + )?; + Ok(vec![Some((decl, key_path.as_slice()))]) + } + PreRule::Set(rules) => { + let mut tuples: Vec> = Vec::new(); + for rule in rules { + tuples.extend(rule.compiled(options)?.into_iter().flatten().map(Some)); + } + if tuples.is_empty() { + Ok(vec![None]) + } else { + Ok(tuples) + } + } + } + } +} + +// Upstream copies via `obj[key]=value`, so "__proto__" is a [[Set]]: primitives +// vanish, objects become the prototype, array/null protos break isPlainObject. +struct ProtoSplit<'a> { + own: Vec<(&'a str, &'a EvalValue)>, + proto: Option<&'a JsObjectMap>, + non_plain: bool, +} + +fn proto_split(obj: &JsObjectMap) -> ProtoSplit<'_> { + let mut split = ProtoSplit { + own: Vec::with_capacity(obj.len()), + proto: None, + non_plain: false, + }; + for (key, val) in obj.entries() { + if key == "__proto__" { + match val { + EvalValue::Obj(p) => split.proto = Some(p), + EvalValue::Null | EvalValue::Arr(_) => split.non_plain = true, + _ => {} + } + } else { + split.own.push((key, val)); + } + } + // a non-plain link anywhere up the chain breaks the constructor lookup + if let Some(p) = split.proto + && proto_split(p).non_plain + { + split.non_plain = true; + } + split +} + +// for..in order: own keys, then unshadowed prototype-chain keys. +fn for_in_entries(obj: &JsObjectMap) -> Vec<(&str, &EvalValue)> { + let mut entries = Vec::new(); + let mut cursor = Some(obj); + while let Some(map) = cursor { + let split = proto_split(map); + for (key, val) in split.own { + if !entries.iter().any(|(k, _)| *k == key) { + entries.push((key, val)); + } + } + cursor = split.proto; + } + entries +} + +fn is_plain(obj: &JsObjectMap) -> bool { + !proto_split(obj).non_plain +} + +// parity: basic-validation.js validateNamespace +pub fn validate_namespace(namespace: &EvalValue, conditions: &[String]) -> Result<(), StylexError> { + let EvalValue::Obj(ns) = namespace else { + return Err(StylexError::illegal_namespace_value()); + }; + if !is_plain(ns) { + return Err(StylexError::illegal_namespace_value()); + } + for (key, val) in for_in_entries(ns) { + match val { + EvalValue::Null | EvalValue::Str(_) | EvalValue::Num(_) => {} + EvalValue::Arr(items) => { + for item in items { + match item { + EvalValue::Null | EvalValue::Str(_) | EvalValue::Num(_) => {} + _ => return Err(StylexError::illegal_prop_array_value()), + } + } + } + // isPlainObject gates the object branches; non-plain values fall + // through to ILLEGAL_PROP_VALUE exactly as upstream's last throw. + EvalValue::Obj(inner) if is_plain(inner) => { + if key.starts_with('@') || key.starts_with(':') || key.starts_with('[') { + if conditions.iter().any(|c| c == key) { + return Err(StylexError::duplicate_conditional()); + } + let mut nested = conditions.to_vec(); + nested.push(key.to_string()); + validate_namespace(val, &nested)?; + } else { + validate_conditional_styles(val, &[])?; + } + } + EvalValue::Obj(_) | EvalValue::Undefined | EvalValue::Bool(_) => { + return Err(StylexError::illegal_prop_value()); + } + } + } + Ok(()) +} + +// parity: basic-validation.js validateConditionalStyles +fn validate_conditional_styles(val: &EvalValue, conditions: &[String]) -> Result<(), StylexError> { + let EvalValue::Obj(obj) = val else { + unreachable!("callers only pass objects"); + }; + for (key, v) in for_in_entries(obj) { + if !(key.starts_with('@') + || key.starts_with(':') + || key.starts_with('[') + || key.starts_with("var(--") + || key == "default") + { + return Err(StylexError::invalid_pseudo_or_at_rule()); + } + if conditions.iter().any(|c| c == key) { + return Err(StylexError::duplicate_conditional()); + } + match v { + EvalValue::Null | EvalValue::Str(_) | EvalValue::Num(_) => {} + EvalValue::Arr(items) => { + // parity quirk: arrays inside conditions report ILLEGAL_PROP_VALUE, + // not ILLEGAL_PROP_ARRAY_VALUE. + for item in items { + match item { + EvalValue::Null | EvalValue::Str(_) | EvalValue::Num(_) => {} + _ => return Err(StylexError::illegal_prop_value()), + } + } + } + EvalValue::Obj(inner) if is_plain(inner) => { + let mut nested = conditions.to_vec(); + nested.push(key.to_string()); + validate_conditional_styles(v, &nested)?; + } + EvalValue::Obj(_) | EvalValue::Undefined | EvalValue::Bool(_) => { + return Err(StylexError::illegal_prop_value()); + } + } + } + Ok(()) +} + +/// The `enableMediaQueryOrder` pre-pass: `Some(rebuilt)` when anything can +/// change, so the caller can hold the rebuild and flatten borrows either way. +pub fn media_order_transform( + style: &JsObjectMap, + options: &ResolvedOptions, +) -> Result, StylexError> { + if options.enable_media_query_order && dfs_can_change(style, 0) { + Ok(Some(dfs_process_map(style, 0)?)) + } else { + Ok(None) + } +} + +pub fn flatten_raw_style_object<'a>( + style: &'a JsObjectMap, + options: &ResolvedOptions, +) -> Result, PreRule<'a>)>, StylexError> { + flatten_inner(style, &[], options) +} + +/// Anything that would make `dfs_process_queries` differ from its input: a +/// rewritable media key, a "__proto__" entry, or a CSSType brand it would drop. +fn dfs_can_change(obj: &JsObjectMap, depth: usize) -> bool { + obj.css_type().is_some() + || obj.entries().any(|(key, val)| { + key == "__proto__" + || (depth >= 1 && is_media_key(key)) + || matches!(val, EvalValue::Obj(inner) if dfs_can_change(inner, depth + 1)) + }) +} + +// parity: style-value-parser media-query-transform.js dfsProcessQueries; the +// delete+reinsert moves every rewritten @media key to the end of its siblings. +// Map-in skips a redundant top-level deep clone the EvalValue wrapper needed. +fn dfs_process_map(obj: &JsObjectMap, depth: usize) -> Result { + let mut result = JsObjectMap::new(); + // Object.entries-style clone: own keys only — a "__proto__"-carried + // prototype (and its inherited entries) is stripped here, before flatten. + for (key, val) in proto_split(obj).own { + let processed = match val { + EvalValue::Obj(inner) => EvalValue::Obj(Arc::new(dfs_process_map(inner, depth + 1)?)), + other => other.clone(), + }; + result.insert(key, processed); + } + if depth >= 1 { + let media_keys: Vec = result + .keys() + .filter(|k| is_media_key(k)) + .map(str::to_string) + .collect(); + if !media_keys.is_empty() { + let rewritten = last_media_query_wins_transform(&media_keys).map_err(|e| match e { + MediaQueryError::Syntax(err) => err, + // Never claim upstream's syntax error for a form we cannot verify. + MediaQueryError::Unverified { input } => StylexError::unsupported_api(&format!( + "media query `{input}` (unverified tokenizer form)" + )), + })?; + for (old_key, new_key) in media_keys.iter().zip(rewritten) { + let value = result.remove(old_key).expect("key collected from map"); + result.insert(new_key, value); + } + } + } + Ok(result) +} + +/// The unanchored `/var\(--[a-z0-9]+\)/` key unwrap in flatten-raw-style-obj.js: +/// any key CONTAINING that pattern is sliced `[4..len-1]` wholesale. +fn unwrap_var_key(key: &str) -> Cow<'_, str> { + let bytes = key.as_bytes(); + let mut search = 0; + while let Some(pos) = key[search..].find("var(--") { + let start = search + pos; + let mut i = start + 6; + while i < bytes.len() && (bytes[i].is_ascii_lowercase() || bytes[i].is_ascii_digit()) { + i += 1; + } + if i > start + 6 && i < bytes.len() && bytes[i] == b')' { + // Upstream does key.slice(4, -1) in UTF-16 units regardless of + // where the match sits; byte slicing panics on multibyte keys. + return Cow::Owned(crate::jsrt::js_slice_utf16(key, 4, -1)); + } + search = start + 1; + } + Cow::Borrowed(key) +} + +fn js_truthy(scalar: &StyleScalar) -> bool { + match scalar { + StyleScalar::Str(s) => !s.is_empty(), + StyleScalar::Num(n) => *n != 0.0 && !n.is_nan(), + StyleScalar::Undefined => false, + } +} + +fn same_value_zero(a: &StyleScalar, b: &StyleScalar) -> bool { + match (a, b) { + (StyleScalar::Str(x), StyleScalar::Str(y)) => x == y, + (StyleScalar::Num(x), StyleScalar::Num(y)) => x == y || (x.is_nan() && y.is_nan()), + (StyleScalar::Undefined, StyleScalar::Undefined) => true, + _ => false, + } +} + +/// `None` is JS `null`; validation rejected every other non-scalar already. +fn scalar_of<'a>(value: &'a EvalValue) -> Option> { + match value { + EvalValue::Str(s) => Some(StyleScalar::Str(Cow::Borrowed(s))), + EvalValue::Num(n) => Some(StyleScalar::Num(*n)), + _ => None, + } +} + +fn rule_key_path<'a>( + key_path: &[Cow<'a, str>], + includes_key: &str, + property: Cow<'a, str>, +) -> Vec> { + if key_path.iter().any(|k| k == includes_key) { + key_path + .iter() + .map(|k| { + if k == includes_key { + property.clone() + } else { + k.clone() + } + }) + .collect() + } else { + let mut path = key_path.to_vec(); + path.push(property); + path + } +} + +fn flatten_inner<'a>( + style: &'a JsObjectMap, + key_path: &[Cow<'a, str>], + options: &ResolvedOptions, +) -> Result, PreRule<'a>)>, StylexError> { + let mut flattened: Vec<(Cow<'a, str>, PreRule<'a>)> = Vec::new(); + // for..in reach: prototype entries planted by "__proto__" keys stay + // visible here unless the media-query clone above already stripped them. + for (raw_key, value) in for_in_entries(style) { + let key: Cow<'a, str> = unwrap_var_key(raw_key); + + match value { + EvalValue::Null | EvalValue::Str(_) | EvalValue::Num(_) => { + let scalar = scalar_of(value); + for (property, expanded) in + flat_map_expanded_shorthands(key.clone(), scalar, false, options)? + { + match expanded { + None => flattened.push((property, PreRule::Null)), + Some(expanded) => { + let path = rule_key_path(key_path, &key, property.clone()); + flattened.push(( + property.clone(), + PreRule::Rule { + property, + value: PreRuleValue::Single(expanded), + key_path: path, + }, + )); + } + } + } + } + EvalValue::Arr(items) => { + // Fallback arrays: each element expands on its own, then the + // per-property value lists are merged in first-seen order. + let mut equivalent: Vec<(Cow<'a, str>, Vec>)> = Vec::new(); + for item in items { + let scalar = scalar_of(item); + for (property, expanded) in + flat_map_expanded_shorthands(key.clone(), scalar, false, options)? + { + let slot = match equivalent.iter_mut().find(|(p, _)| *p == property) { + Some(slot) => &mut slot.1, + None => { + equivalent.push((property, Vec::new())); + &mut equivalent.last_mut().expect("just pushed").1 + } + }; + if let Some(value) = expanded { + slot.push(value); + } + } + } + for (property, values) in equivalent { + let mut deduped: Vec = Vec::new(); + for value in values { + if js_truthy(&value) && !deduped.iter().any(|d| same_value_zero(d, &value)) + { + deduped.push(value); + } + } + let pre_rule = match deduped.len() { + 0 => PreRule::Null, + 1 => PreRule::Rule { + property: property.clone(), + value: PreRuleValue::Single(deduped.into_iter().next().expect("len 1")), + key_path: rule_key_path(key_path, raw_key, property.clone()), + }, + _ => PreRule::Rule { + property: property.clone(), + value: PreRuleValue::Multi(deduped), + key_path: rule_key_path(key_path, raw_key, property.clone()), + }, + }; + flattened.push((property, pre_rule)); + } + } + EvalValue::Obj(obj) + if !key.starts_with(':') && !key.starts_with('@') && !key.starts_with('[') => + { + // Property-value objects, e.g. color: { default, ':hover' }. + let mut equivalent: ConditionGroups<'a> = Vec::new(); + for (condition, inner_value) in for_in_entries(obj) { + let nested_path: Vec> = if key_path.is_empty() { + vec![key.clone(), Cow::Borrowed(condition)] + } else { + let mut p = key_path.to_vec(); + p.push(Cow::Borrowed(condition)); + p + }; + for (property, pre_rule) in + flatten_value_as_property(&key, inner_value, &nested_path, options)? + { + match equivalent.iter_mut().find(|(p, _)| *p == property) { + Some((_, conds)) => { + match conds.iter_mut().find(|(c, _)| *c == condition) { + Some((_, slot)) => *slot = pre_rule, + None => conds.push((condition, pre_rule)), + } + } + None => equivalent.push((property, vec![(condition, pre_rule)])), + } + } + } + for (property, conds) in equivalent { + let rules: Vec = conds.into_iter().map(|(_, r)| r).collect(); + flattened.push((property, PreRule::create_set(rules))); + } + } + EvalValue::Obj(obj) => { + // Pseudo / at-rule / attribute objects, e.g. ':hover': { … }. + let mut nested_path = key_path.to_vec(); + nested_path.push(Cow::Borrowed(raw_key)); + for (property, pre_rule) in flatten_inner(obj, &nested_path, options)? { + flattened.push((Cow::Owned(format!("{key}_{property}")), pre_rule)); + } + } + // Upstream's flatten silently skips other types (validation ran first). + EvalValue::Undefined | EvalValue::Bool(_) => {} + } + } + Ok(flattened) +} + +/// The property-value-object recursion, without upstream's intermediate +/// `{key: value}` object: identical to flatten_inner on that single-key map. +fn flatten_value_as_property<'a>( + key: &Cow<'a, str>, + value: &'a EvalValue, + nested_path: &[Cow<'a, str>], + options: &ResolvedOptions, +) -> Result, PreRule<'a>)>, StylexError> { + match value { + EvalValue::Null | EvalValue::Str(_) | EvalValue::Num(_) => { + let scalar = scalar_of(value); + let mut out = Vec::new(); + for (property, expanded) in + flat_map_expanded_shorthands(key.clone(), scalar, false, options)? + { + match expanded { + None => out.push((property, PreRule::Null)), + Some(expanded) => { + let path = rule_key_path(nested_path, key, property.clone()); + out.push(( + property.clone(), + PreRule::Rule { + property, + value: PreRuleValue::Single(expanded), + key_path: path, + }, + )); + } + } + } + Ok(out) + } + EvalValue::Arr(items) => { + let mut equivalent: Vec<(Cow<'a, str>, Vec>)> = Vec::new(); + for item in items { + let scalar = scalar_of(item); + for (property, expanded) in + flat_map_expanded_shorthands(key.clone(), scalar, false, options)? + { + let slot = match equivalent.iter_mut().find(|(p, _)| *p == property) { + Some(slot) => &mut slot.1, + None => { + equivalent.push((property, Vec::new())); + &mut equivalent.last_mut().expect("just pushed").1 + } + }; + if let Some(value) = expanded { + slot.push(value); + } + } + } + let mut out = Vec::new(); + for (property, values) in equivalent { + let mut deduped: Vec = Vec::new(); + for value in values { + if js_truthy(&value) && !deduped.iter().any(|d| same_value_zero(d, &value)) { + deduped.push(value); + } + } + let pre_rule = match deduped.len() { + 0 => PreRule::Null, + 1 => PreRule::Rule { + property: property.clone(), + value: PreRuleValue::Single(deduped.into_iter().next().expect("len 1")), + key_path: rule_key_path(nested_path, key, property.clone()), + }, + _ => PreRule::Rule { + property: property.clone(), + value: PreRuleValue::Multi(deduped), + key_path: rule_key_path(nested_path, key, property.clone()), + }, + }; + out.push((property, pre_rule)); + } + Ok(out) + } + // Deeper nesting (condition inside a property-value object): group by + // property and condition into per-property sets, exactly the + // property-value branch's shape one level down. + EvalValue::Obj(obj) => { + let mut out = Vec::new(); + let mut equivalent: ConditionGroups<'a> = Vec::new(); + for (condition, inner) in for_in_entries(obj) { + let mut p = nested_path.to_vec(); + p.push(Cow::Borrowed(condition)); + for (property, pre_rule) in flatten_value_as_property(key, inner, &p, options)? { + match equivalent.iter_mut().find(|(pr, _)| *pr == property) { + Some((_, conds)) => match conds.iter_mut().find(|(c, _)| *c == condition) { + Some((_, slot)) => *slot = pre_rule, + None => conds.push((condition, pre_rule)), + }, + None => equivalent.push((property, vec![(condition, pre_rule)])), + } + } + } + for (property, conds) in equivalent { + let rules: Vec = conds.into_iter().map(|(_, r)| r).collect(); + out.push((property, PreRule::create_set(rules))); + } + Ok(out) + } + EvalValue::Undefined | EvalValue::Bool(_) => Ok(Vec::new()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unwrap_var_key_matches_upstream_regex() { + assert_eq!(unwrap_var_key("var(--abc)"), Cow::Borrowed("--abc")); + assert_eq!(unwrap_var_key("var(--abc123)"), Cow::Borrowed("--abc123")); + // Uppercase var names do not match the flatten-level regex. + assert_eq!( + unwrap_var_key("var(--myVar)"), + Cow::Borrowed("var(--myVar)") + ); + assert_eq!(unwrap_var_key("var(--abc"), Cow::Borrowed("var(--abc")); + assert_eq!(unwrap_var_key("var(--)"), Cow::Borrowed("var(--)")); + // Unanchored match slices the whole key. + assert_eq!( + unwrap_var_key("xxvar(--ab)yy"), + Cow::::Owned("r(--ab)y".to_string()) + ); + assert_eq!(unwrap_var_key("color"), Cow::Borrowed("color")); + } +} diff --git a/crates/stylex/src/shared/generate_rule.rs b/crates/stylex/src/shared/generate_rule.rs new file mode 100644 index 00000000..5065c53a --- /dev/null +++ b/crates/stylex/src/shared/generate_rule.rs @@ -0,0 +1,482 @@ +//! Style entry → className + injectable CSS rule. +// parity: babel-plugin src/shared/utils/{convert-to-className,generate-css-rule}.js + +use crate::errors::StylexError; +use crate::fxhash::FxHashMap; +use crate::hash::hash; +use crate::options::ResolvedOptions; +use crate::rules::StylexRule; +use crate::shared::dashify::dashed_key; +use crate::shared::fallbacks::{has_var_fallback, variable_fallbacks}; +use crate::shared::flatten::{PreRuleValue, StyleScalar}; +use crate::shared::normalize_value::CssValueError; +use crate::shared::priorities::get_priority; +use crate::shared::pseudo_sort::{sort_at_rules, sort_pseudos}; +use crate::shared::rtl::{RtlContext, generate_ltr, generate_rtl}; +use crate::shared::transform_value::{transform_value_num, transform_value_str}; +use std::cell::RefCell; +use std::rc::Rc; + +fn css_value_error(e: CssValueError) -> StylexError { + match e { + CssValueError::UnclosedFunction => StylexError::unclosed_function(), + CssValueError::UnclosedString => StylexError::unclosed_string(), + CssValueError::EmptyValue => StylexError::empty_value(), + } +} + +fn transform_scalar( + key: &str, + scalar: &StyleScalar, + font_size_px_to_rem: bool, +) -> Result { + match scalar { + StyleScalar::Str(s) => { + transform_value_str(key, s.as_ref(), font_size_px_to_rem).map_err(css_value_error) + } + StyleScalar::Num(n) => { + transform_value_num(key, *n, font_size_px_to_rem).map_err(css_value_error) + } + // normalizeValue short-circuits on nullish, so the declaration is empty. + StyleScalar::Undefined => Ok(String::new()), + } +} + +/// One compiled declaration, memo-shared behind `Rc`: a hit is a refcount bump. +pub type CompiledDecl = Rc; + +struct ConvertMemo { + key: String, + map: FxHashMap, +} + +thread_local! { + static CONVERT_MEMO: RefCell = RefCell::new(ConvertMemo { + key: String::new(), + map: FxHashMap::default(), + }); +} + +const CONVERT_MEMO_CAP: usize = 1 << 16; + +/// Length-prefixed segments with fixed `|` section breaks: injective, so equal +/// keys mean equal inputs. +fn push_memo_part(key: &mut String, part: &str) { + let mut buf = [0u8; 20]; + let mut n = part.len(); + let mut i = buf.len(); + loop { + i -= 1; + buf[i] = b'0' + (n % 10) as u8; + n /= 10; + if n == 0 { + break; + } + } + key.push_str(str::from_utf8(&buf[i..]).expect("ascii digits")); + key.push(':'); + key.push_str(part); +} + +fn push_memo_scalar(key: &mut String, scalar: &StyleScalar) { + match scalar { + StyleScalar::Str(s) => { + key.push('s'); + push_memo_part(key, s); + } + StyleScalar::Num(n) => { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let bits = n.to_bits(); + let mut buf = [0u8; 16]; + for (j, b) in buf.iter_mut().enumerate() { + *b = HEX[((bits >> (60 - j * 4)) & 0xf) as usize]; + } + key.push('n'); + key.push_str(str::from_utf8(&buf).expect("ascii digits")); + } + StyleScalar::Undefined => key.push('u'), + } +} + +// The fingerprint covers every option the uncached path reads: px-to-rem, +// debug class names + prefix, and the three RtlContext fields. +fn encode_memo_key( + key: &mut String, + property: &str, + value: &PreRuleValue<'_>, + pseudos: &[&str], + at_rules: &[&str], + const_rules: &[&str], + options: &ResolvedOptions, +) { + key.push(if options.enable_font_size_px_to_rem { + 'R' + } else { + 'r' + }); + key.push(if options.debug && options.enable_debug_class_names { + 'D' + } else { + 'd' + }); + key.push(match options.style_resolution { + crate::options::StyleResolution::PropertySpecificity => 'p', + crate::options::StyleResolution::ApplicationOrder => 'a', + crate::options::StyleResolution::LegacyExpandShorthands => 'l', + }); + key.push(if options.enable_logical_styles_polyfill { + 'L' + } else { + 'g' + }); + key.push(if options.enable_legacy_value_flipping { + 'F' + } else { + 'f' + }); + push_memo_part(key, &options.class_name_prefix); + key.push('|'); + push_memo_part(key, property); + key.push('|'); + match value { + PreRuleValue::Single(scalar) => { + key.push('S'); + push_memo_scalar(key, scalar); + } + PreRuleValue::Multi(scalars) => { + key.push('M'); + for scalar in scalars { + push_memo_scalar(key, scalar); + } + } + } + for list in [pseudos, at_rules, const_rules] { + key.push('|'); + for item in list { + push_memo_part(key, item); + } + } +} + +/// `pseudos`/`at_rules`/`const_rules` arrive in keyPath order; sorting for the +/// hash input and selector happens here, on the miss path only. +pub fn convert_style_to_class_name( + property: &str, + value: &PreRuleValue<'_>, + pseudos: &[&str], + at_rules: &[&str], + const_rules: &[&str], + options: &ResolvedOptions, +) -> Result { + CONVERT_MEMO.with(|memo| { + let mut memo = memo.borrow_mut(); + let ConvertMemo { key, map } = &mut *memo; + key.clear(); + encode_memo_key( + key, + property, + value, + pseudos, + at_rules, + const_rules, + options, + ); + if let Some(hit) = map.get(key.as_str()) { + return Ok(Rc::clone(hit)); + } + let computed = Rc::new(convert_style_to_class_name_uncached( + property, + value, + pseudos, + at_rules, + const_rules, + options, + )?); + if map.len() >= CONVERT_MEMO_CAP { + map.clear(); + } + map.insert(key.clone(), Rc::clone(&computed)); + Ok(computed) + }) +} + +fn convert_style_to_class_name_uncached( + property: &str, + value: &PreRuleValue<'_>, + pseudos: &[&str], + at_rules: &[&str], + const_rules: &[&str], + options: &ResolvedOptions, +) -> Result { + // Upstream evaluates the sorting PreRule.pseudos/atRules getters as call + // arguments, so a collation error precedes any value-normalization error. + let sorted_pseudos = sort_pseudos(pseudos)?; + let sorted_at_rules = sort_at_rules(at_rules); + let mut at_and_const = sorted_at_rules.clone(); + at_and_const.extend(const_rules.iter().copied()); + let sorted_at_and_const = sort_at_rules(&at_and_const); + + let dashed = dashed_key(property); + + let px_to_rem = options.enable_font_size_px_to_rem; + let (values, is_array) = match value { + PreRuleValue::Single(scalar) => { + (vec![transform_scalar(property, scalar, px_to_rem)?], false) + } + PreRuleValue::Multi(scalars) => ( + scalars + .iter() + .map(|s| transform_scalar(property, s, px_to_rem)) + .collect::, _>>()?, + true, + ), + }; + let values = if is_array && has_var_fallback(&values) { + variable_fallbacks(&values)? + } else { + values + }; + + // The hash concatenates with `+` (so a JS `undefined` stringifies) while + // the declaration is built with `Array.join`, which renders it empty. + let hash_values: Vec<&str> = match value { + PreRuleValue::Single(StyleScalar::Undefined) => vec!["undefined"], + _ => values.iter().map(String::as_str).collect(), + }; + + // parity: the '<>' prefix and 'null' modifier keep upstream hashes stable; + // built in place, byte-for-byte `<>{dashed}{values.join(", ")}{modifier}`. + let modifier_len: usize = sorted_pseudos.iter().map(|s| s.len()).sum::() + + sorted_at_and_const.iter().map(|s| s.len()).sum::(); + let mut hash_input = String::with_capacity( + 2 + dashed.len() + + hash_values.iter().map(|v| v.len() + 2).sum::() + + modifier_len.max(4), + ); + hash_input.push_str("<>"); + hash_input.push_str(&dashed); + for (i, v) in hash_values.iter().enumerate() { + if i > 0 { + hash_input.push_str(", "); + } + hash_input.push_str(v); + } + if modifier_len == 0 { + hash_input.push_str("null"); + } else { + for p in &sorted_pseudos { + hash_input.push_str(p); + } + for a in &sorted_at_and_const { + hash_input.push_str(a); + } + } + let hashed = hash(&hash_input); + let class_name: std::sync::Arc = if options.debug && options.enable_debug_class_names { + format!("{property}-{}{hashed}", options.class_name_prefix).into() + } else { + format!("{}{hashed}", options.class_name_prefix).into() + }; + + let rule = generate_css_rule( + &class_name, + &dashed, + &values, + &sorted_pseudos, + &sorted_at_rules, + const_rules, + RtlContext::of(options), + ); + Ok(rule) +} + +pub fn generate_css_rule( + class_name: &str, + key: &str, + values: &[String], + pseudos: &[&str], + at_rules: &[&str], + const_rules: &[&str], + ctx: RtlContext, +) -> StylexRule { + let mut ltr_decls = String::with_capacity( + values + .iter() + .map(|v| key.len() + v.len() + 2) + .sum::(), + ); + for (i, v) in values.iter().enumerate() { + if i > 0 { + ltr_decls.push(';'); + } + let (k, v) = generate_ltr(key, v, ctx); + ltr_decls.push_str(&k); + ltr_decls.push(':'); + ltr_decls.push_str(&v); + } + let mut rtl_decls = String::new(); + for v in values { + if let Some((k, v)) = generate_rtl(key, v, ctx) { + if !rtl_decls.is_empty() { + rtl_decls.push(';'); + } + rtl_decls.push_str(&k); + rtl_decls.push(':'); + rtl_decls.push_str(&v); + } + } + + let ltr = build_nested_css_rule(class_name, <r_decls, pseudos, at_rules, const_rules); + let rtl = if rtl_decls.is_empty() { + None + } else { + Some(build_nested_css_rule( + class_name, + &rtl_decls, + pseudos, + at_rules, + const_rules, + )) + }; + + let priority = get_priority(key) + + pseudos.iter().map(|p| get_priority(p)).sum::() + + at_rules.iter().map(|a| get_priority(a)).sum::() + + const_rules.iter().map(|c| get_priority(c)).sum::(); + + StylexRule { + class_name: class_name.into(), + ltr: ltr.into(), + rtl: rtl.map(Into::into), + const_key: None, + const_val: None, + priority, + } +} + +const THUMB_VARIANTS: [&str; 3] = [ + "::-webkit-slider-thumb", + "::-moz-range-thumb", + "::-ms-thumb", +]; + +// parity: generate-css-rule.js buildNestedCSSRule +fn build_nested_css_rule( + class_name: &str, + decls: &str, + pseudos: &[&str], + at_rules: &[&str], + const_rules: &[&str], +) -> String { + // Pseudo-classes before pseudo-elements, insertion order within each — + // the two-buffer concat of upstream, built as one string in two passes. + let mut pseudo = String::new(); + for p in pseudos { + if *p != "::thumb" && !p.starts_with("::") { + pseudo.push_str(p); + } + } + for p in pseudos { + if *p != "::thumb" && p.starts_with("::") { + pseudo.push_str(p); + } + } + let combined_len = at_rules.len() + const_rules.len(); + + let class_copies = 1 + usize::from(pseudo.contains(":where(")) + combined_len; + let mut selector = String::with_capacity(class_copies * (class_name.len() + 1) + pseudo.len()); + for _ in 0..class_copies { + selector.push('.'); + selector.push_str(class_name); + } + selector.push_str(&pseudo); + if pseudos.contains(&"::thumb") { + selector = THUMB_VARIANTS + .iter() + .map(|suffix| format!("{selector}{suffix}")) + .collect::>() + .join(", "); + } + + // Nesting parity: iterating at_rules then const_rules and wrapping each + // time leaves the last-iterated outermost; emit them reversed in one pass. + let wrappers = at_rules.iter().chain(const_rules.iter()); + let mut rule = String::with_capacity( + selector.len() + decls.len() + 2 + wrappers.clone().map(|w| w.len() + 2).sum::(), + ); + for at_rule in wrappers.rev() { + rule.push_str(at_rule); + rule.push('{'); + } + rule.push_str(&selector); + rule.push('{'); + rule.push_str(decls); + rule.push('}'); + for _ in 0..combined_len { + rule.push('}'); + } + rule +} + +#[cfg(test)] +mod tests { + use super::*; + + fn defaults() -> ResolvedOptions { + ResolvedOptions::default() + } + + #[test] + fn known_answer_color_red() { + let decl = convert_style_to_class_name( + "color", + &PreRuleValue::Single(StyleScalar::Str(std::borrow::Cow::Borrowed("red"))), + &[], + &[], + &[], + &defaults(), + ) + .unwrap(); + assert_eq!(&*decl.class_name, "x1e2nbdu"); + assert_eq!(&*decl.ltr, ".x1e2nbdu{color:red}"); + assert_eq!(decl.rtl, None); + assert_eq!(decl.priority, 3000.0); + } + + #[test] + fn thumb_and_where_selector_shapes() { + let decl = convert_style_to_class_name( + "width", + &PreRuleValue::Single(StyleScalar::Num(16.0)), + &[":hover", "::thumb"], + &[], + &[], + &defaults(), + ) + .unwrap(); + assert_eq!( + &*decl.ltr, + format!( + ".{c}:hover::-webkit-slider-thumb, .{c}:hover::-moz-range-thumb, .{c}:hover::-ms-thumb{{width:16px}}", + c = decl.class_name + ) + ); + assert_eq!(decl.priority, 9130.0); + + let decl = convert_style_to_class_name( + "color", + &PreRuleValue::Single(StyleScalar::Str(std::borrow::Cow::Borrowed("blue"))), + &[":where(.x-default-marker:hover *)"], + &[], + &[], + &defaults(), + ) + .unwrap(); + assert_eq!(&*decl.class_name, "xobp4yc"); + assert_eq!( + &*decl.ltr, + ".xobp4yc.xobp4yc:where(.x-default-marker:hover *){color:blue}" + ); + assert_eq!(decl.priority, 3011.3); + } +} diff --git a/crates/stylex/src/shared/keyframes.rs b/crates/stylex/src/shared/keyframes.rs new file mode 100644 index 00000000..1fd3c0dd --- /dev/null +++ b/crates/stylex/src/shared/keyframes.rs @@ -0,0 +1,243 @@ +//! `stylex.keyframes` over an already-evaluated frames object. +// parity: babel-plugin src/shared/stylex-keyframes.js + visitors/stylex-keyframes.js + +use crate::errors::StylexError; +use crate::eval::value::{EvalValue, JsObjectMap}; +use crate::hash::hash; +use crate::options::ResolvedOptions; +use crate::rules::StylexRule; +use crate::shared::dashify::dashify; +use crate::shared::flatten::StyleScalar; +use crate::shared::normalize_value::CssValueError; +use crate::shared::resolution::flat_map_expanded_shorthands; +use crate::shared::rtl::{RtlContext, generate_ltr, generate_rtl}; +use crate::shared::transform_value::{transform_value_num, transform_value_str}; + +/// Returns the animation name and its single injectable rule (priority 0). +pub fn keyframes( + frames: &EvalValue, + options: &ResolvedOptions, +) -> Result<(String, StylexRule), StylexError> { + let frames = assert_valid_keyframes(frames)?; + + let ctx = RtlContext::of(options); + let mut ltr_frames: Vec<(String, JsObjectMap)> = Vec::new(); + let mut rtl_frames: Vec<(String, JsObjectMap)> = Vec::new(); + let mut stable_frames: Vec<(String, JsObjectMap)> = Vec::new(); + for (frame_name, frame_value) in frames.entries() { + let EvalValue::Obj(frame) = frame_value else { + // Validation admits `null` frames (typeof null is 'object'); upstream + // then crashes in Object.keys. + return Err(StylexError::upstream_type_crash("a null keyframes frame")); + }; + let transformed = expand_dashify_transform(frame, options)?; + let mut ltr = JsObjectMap::new(); + let mut rtl = JsObjectMap::new(); + let mut stable = JsObjectMap::new(); + for (key, value) in transformed.entries() { + let EvalValue::Str(value) = value else { + unreachable!("transform stage only stores strings"); + }; + let (lk, lv) = generate_ltr(key, value, ctx); + ltr.insert(lk, EvalValue::Str(lv)); + let (rk, rv) = + generate_rtl(key, value, ctx).unwrap_or_else(|| (key.to_string(), value.clone())); + rtl.insert(rk, EvalValue::Str(rv)); + let (sk, sv) = generate_ltr(key, value, RtlContext::DEFAULTS); + stable.insert(sk, EvalValue::Str(sv)); + } + ltr_frames.push((frame_name.to_string(), ltr)); + rtl_frames.push((frame_name.to_string(), rtl)); + stable_frames.push((frame_name.to_string(), stable)); + } + + let ltr_string = construct_keyframes_string(<r_frames); + let rtl_string = construct_keyframes_string(&rtl_frames); + // The name hashes a third serialization built with DEFAULT options, so it + // is direction- and option-agnostic (`objMapEntry(frame, generateLtr)`). + let stable_string = construct_keyframes_string(&stable_frames); + let animation_name = format!( + "{}{}-B", + options.class_name_prefix, + hash(&format!("<>{stable_string}")) + ); + + let ltr = format!("@keyframes {animation_name}{{{ltr_string}}}"); + let rtl = + (ltr_string != rtl_string).then(|| format!("@keyframes {animation_name}{{{rtl_string}}}")); + + let rule = StylexRule { + class_name: animation_name.as_str().into(), + ltr: ltr.into(), + rtl: rtl.map(Into::into), + const_key: None, + const_val: None, + priority: 0.0, + }; + Ok((animation_name, rule)) +} + +// parity: visitors/stylex-keyframes.js assertValidKeyframes +fn assert_valid_keyframes(frames: &EvalValue) -> Result<&JsObjectMap, StylexError> { + let EvalValue::Obj(map) = frames else { + return Err(StylexError::non_style_object("keyframes")); + }; + for (_key, value) in map.entries() { + // `typeof value === 'object' && !Array.isArray(value)`: null passes. + match value { + EvalValue::Obj(_) | EvalValue::Null => {} + _ => return Err(StylexError::non_object_keyframe()), + } + } + Ok(map) +} + +// parity: stylex-keyframes.js expand+dashify+transformValue pipe; each stage +// rebuilds the object, so duplicate keys collapse first-position last-value. +fn expand_dashify_transform( + frame: &JsObjectMap, + options: &ResolvedOptions, +) -> Result { + let mut expanded = JsObjectMap::new(); + for (key, value) in frame.entries() { + let value_is_array = matches!(value, EvalValue::Arr(_)); + let scalar = match value { + EvalValue::Str(s) => Some(StyleScalar::Str(std::borrow::Cow::Borrowed(s.as_str()))), + EvalValue::Num(n) => Some(StyleScalar::Num(*n)), + _ => None, + }; + // Only string/number values survive; arrays, nulls, and objects drop. + for (property, expanded_value) in flat_map_expanded_shorthands( + std::borrow::Cow::Borrowed(key), + scalar, + value_is_array, + options, + )? { + match expanded_value { + Some(StyleScalar::Str(s)) => { + expanded.insert(property.to_string(), EvalValue::Str(s.into_owned())); + } + Some(StyleScalar::Num(n)) => { + expanded.insert(property, EvalValue::Num(n)); + } + Some(StyleScalar::Undefined) | None => {} + } + } + } + let mut dashed = JsObjectMap::new(); + for (key, value) in expanded.entries() { + dashed.insert(dashify(key), value.clone()); + } + let mut transformed = JsObjectMap::new(); + for (key, value) in dashed.entries() { + // transformValue sees the already-dashed key: dashed forms miss the + // camelCase ms-suffix and unitless tables (e.g. transition-duration: 500px). + let out = match value { + EvalValue::Str(s) => transform_value_str(key, s, options.enable_font_size_px_to_rem), + EvalValue::Num(n) => transform_value_num(key, *n, options.enable_font_size_px_to_rem), + _ => unreachable!("expand stage only stores scalars"), + } + .map_err(css_value_error)?; + transformed.insert(key, EvalValue::Str(out)); + } + Ok(transformed) +} + +fn construct_keyframes_string(frames: &[(String, JsObjectMap)]) -> String { + let mut out = String::new(); + for (name, decls) in frames { + out.push_str(name); + out.push('{'); + for (k, v) in decls.entries() { + let EvalValue::Str(v) = v else { + unreachable!("frames hold string declarations"); + }; + out.push_str(k); + out.push(':'); + out.push_str(v); + out.push(';'); + } + out.push('}'); + } + out +} + +fn css_value_error(e: CssValueError) -> StylexError { + match e { + CssValueError::UnclosedFunction => StylexError::unclosed_function(), + CssValueError::UnclosedString => StylexError::unclosed_string(), + CssValueError::EmptyValue => StylexError::empty_value(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn obj(entries: &[(&str, EvalValue)]) -> EvalValue { + EvalValue::Obj( + entries + .iter() + .map(|(k, v)| ((*k).to_string(), v.clone())) + .collect::() + .into(), + ) + } + + #[test] + fn basic_frames_match_oracle() { + // Pinned via live-oracle probe 2026-08-27. + let frames = obj(&[ + ("from", obj(&[("opacity", EvalValue::Num(0.0))])), + ("to", obj(&[("opacity", EvalValue::Num(1.0))])), + ]); + let options = ResolvedOptions::default(); + let (name, rule) = keyframes(&frames, &options).unwrap(); + assert_eq!(name, "x18re5ia-B"); + assert_eq!( + &*rule.ltr, + "@keyframes x18re5ia-B{from{opacity:0;}to{opacity:1;}}" + ); + assert_eq!(rule.rtl, None); + assert!(rule.priority == 0.0); + } + + #[test] + fn rtl_emitted_only_when_different() { + let options = ResolvedOptions::default(); + let frames = obj(&[ + ( + "from", + obj(&[("float", EvalValue::Str("inline-start".to_string()))]), + ), + ( + "to", + obj(&[("float", EvalValue::Str("inline-end".to_string()))]), + ), + ]); + let (_, rule) = keyframes(&frames, &options).unwrap(); + assert_eq!( + &*rule.ltr, + "@keyframes x1uod70n-B{from{float:left;}to{float:right;}}" + ); + assert_eq!( + rule.rtl.as_deref(), + Some("@keyframes x1uod70n-B{from{float:right;}to{float:left;}}") + ); + } + + #[test] + fn frame_validation() { + let options = ResolvedOptions::default(); + let err = keyframes(&EvalValue::Str("x".to_string()), &options).unwrap_err(); + assert_eq!(err.message, "keyframes() can only accept an object."); + let err = + keyframes(&obj(&[("from", EvalValue::Str("x".to_string()))]), &options).unwrap_err(); + assert_eq!( + err.message, + "Every frame within a keyframes() call must be an object." + ); + let err = keyframes(&obj(&[("from", EvalValue::Null)]), &options).unwrap_err(); + assert_eq!(err.code, crate::errors::ErrorCode::UpstreamTypeError); + } +} diff --git a/crates/stylex/src/shared/markers.rs b/crates/stylex/src/shared/markers.rs new file mode 100644 index 00000000..30c751d0 --- /dev/null +++ b/crates/stylex/src/shared/markers.rs @@ -0,0 +1,74 @@ +//! `stylex.defaultMarker` / `stylex.defineMarker` compiled objects. +// parity: shared/stylex-defaultMarker.js + visitors/stylex-define-marker.js + +use crate::eval::value::{EvalValue, JsObjectMap}; +use crate::hash::hash; +use crate::options::ResolvedOptions; + +/// `{prefix}-default-marker`; ResolvedOptions always carries a prefix, so the +/// upstream `classNamePrefix == null` no-dash branch is unreachable here. +pub fn default_marker_class_name(options: &ResolvedOptions) -> String { + format!("{}-default-marker", options.class_name_prefix) +} + +pub fn default_marker_object(options: &ResolvedOptions) -> JsObjectMap { + self_map_object(default_marker_class_name(options)) +} + +pub fn define_marker_id( + canonical_file_name: &str, + export_name: &str, + options: &ResolvedOptions, +) -> String { + format!( + "{}{}", + options.class_name_prefix, + hash(&format!("{canonical_file_name}//{export_name}")) + ) +} + +/// `canonical_file_name` is state-manager fileNameForHashing output; callers +/// map a missing one to `cannot_generate_hash("defineMarker")` first. +pub fn define_marker_object( + canonical_file_name: &str, + export_name: &str, + options: &ResolvedOptions, +) -> JsObjectMap { + self_map_object(define_marker_id(canonical_file_name, export_name, options)) +} + +fn self_map_object(id: String) -> JsObjectMap { + let mut obj = JsObjectMap::new(); + obj.insert(id.clone(), EvalValue::Str(id)); + obj.insert("$$css", EvalValue::Bool(true)); + obj +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn oracle_pinned_objects() { + // Pinned via live-oracle probe 2026-08-27. + let options = ResolvedOptions::default(); + assert_eq!( + default_marker_object(&options).to_json(), + serde_json::json!({ "x-default-marker": "x-default-marker", "$$css": true }) + ); + let prefixed = ResolvedOptions { + class_name_prefix: "pfx".to_string(), + ..ResolvedOptions::default() + }; + assert_eq!(default_marker_class_name(&prefixed), "pfx-default-marker"); + // tokens.stylex.ts under rootDir /fake/root, export name `marker`. + assert_eq!( + define_marker_object("tokens.stylex.ts", "marker", &options).to_json(), + serde_json::json!({ "xleysvp": "xleysvp", "$$css": true }) + ); + assert_eq!( + define_marker_id("tokens.stylex.ts", "marker", &prefixed), + "pfxleysvp" + ); + } +} diff --git a/crates/stylex/src/shared/media_query.rs b/crates/stylex/src/shared/media_query.rs new file mode 100644 index 00000000..17e1df20 --- /dev/null +++ b/crates/stylex/src/shared/media_query.rs @@ -0,0 +1,1246 @@ +// parity: style-value-parser/src/at-queries/media-query.js (parser, normalize, +// toString) and media-query-transform.js (last-media-query-wins), both at 0.19.0. + +use crate::errors::StylexError; +use crate::jsrt::js_number_to_string; +use std::fmt; + +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +pub enum MediaQueryError { + #[error("{0}")] + Syntax(StylexError), + // Forms our tokenizer cannot faithfully replicate (escapes, non-ASCII): + // refusing beats guessing a serialization no oracle pin covers. + #[error("unverified media query form: {input}")] + Unverified { input: String }, +} + +impl MediaQueryError { + fn syntax() -> Self { + MediaQueryError::Syntax(StylexError::invalid_media_query_syntax()) + } +} + +/// The exact key filter `lastMediaQueryWinsTransform` applies to style-object +/// siblings: `'@media\t…'` and bare `'@media'` pass through verbatim. +pub fn is_media_key(key: &str) -> bool { + key.starts_with("@media ") +} + +#[derive(Debug, Clone, PartialEq)] +pub enum MediaRuleValue { + Length { value: f64, unit: String }, + Number(f64), + // idents and calc() expressions both live as plain strings upstream + Str(String), + Fraction(f64, f64), +} + +#[derive(Debug, Clone, PartialEq)] +pub enum MediaQueryRule { + Keyword { key: String, not: bool, only: bool }, + WordRule(String), + Pair { key: String, value: MediaRuleValue }, + Not(Box), + And(Vec), + Or(Vec), +} + +#[derive(Debug, Clone, PartialEq)] +pub struct MediaQuery { + pub queries: MediaQueryRule, +} + +impl MediaQuery { + pub fn new(rule: MediaQueryRule) -> Self { + MediaQuery { + queries: normalize(rule), + } + } + + pub fn parse(input: &str) -> Result { + if input.bytes().any(|b| b >= 0x80 || b == b'\\' || b == b'\0') { + return Err(MediaQueryError::Unverified { + input: input.to_string(), + }); + } + let tokens = tokenize(input); + let mut parser = Parser { + tokens: &tokens, + pos: 0, + }; + let Some(rule) = parser.media_query() else { + return Err(MediaQueryError::syntax()); + }; + if parser.pos != tokens.len() { + return Err(MediaQueryError::syntax()); + } + Ok(MediaQuery::new(rule)) + } +} + +impl fmt::Display for MediaQuery { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "@media {}", rule_to_string(&self.queries, true)) + } +} + +/// Depth>=1 sibling `@media` rewrite: earlier keys gain ANDed not-clauses of +/// later siblings; every key (last included) re-serializes via normalize. +pub fn last_media_query_wins_transform( + sibling_keys: &[String], +) -> Result, MediaQueryError> { + let queries = sibling_keys + .iter() + .map(|key| MediaQuery::parse(key)) + .collect::, _>>()?; + let mut out = Vec::with_capacity(queries.len()); + for (i, current) in queries.iter().enumerate() { + let negations: Vec = queries[i + 1..] + .iter() + .map(|q| MediaQueryRule::Not(Box::new(q.queries.clone()))) + .collect(); + if negations.is_empty() { + out.push(current.to_string()); + continue; + } + let combined = match ¤t.queries { + MediaQueryRule::Or(rules) => MediaQueryRule::Or( + rules + .iter() + .map(|rule| { + let mut branch = vec![rule.clone()]; + branch.extend(negations.iter().cloned()); + MediaQueryRule::And(branch) + }) + .collect(), + ), + other => { + let mut rules = vec![other.clone()]; + rules.extend(negations); + MediaQueryRule::And(rules) + } + }; + out.push(MediaQuery::new(combined).to_string()); + } + Ok(out) +} + +// --------------------------------------------------------------------------- +// serialization + +fn rule_to_string(rule: &MediaQueryRule, is_top_level: bool) -> String { + match rule { + MediaQueryRule::Keyword { key, not, only } => { + let prefix = if *not { + "not " + } else if *only { + "only " + } else { + "" + }; + let is_typed = *not || *only; + if is_top_level || is_typed { + format!("{prefix}{key}") + } else { + format!("({key})") + } + } + MediaQueryRule::WordRule(key) => format!("({key})"), + MediaQueryRule::Pair { key, value } => match value { + MediaRuleValue::Fraction(a, b) => { + format!( + "({key}: {} / {})", + js_number_to_string(*a), + js_number_to_string(*b) + ) + } + MediaRuleValue::Str(s) => format!("({key}: {s})"), + MediaRuleValue::Length { value, unit } => { + format!("({key}: {}{unit})", js_number_to_string(*value)) + } + MediaRuleValue::Number(n) => format!("({key}: {})", js_number_to_string(*n)), + }, + MediaQueryRule::Not(inner) => match **inner { + MediaQueryRule::And(_) | MediaQueryRule::Or(_) | MediaQueryRule::Not(_) => { + format!("(not ({}))", rule_to_string(inner, false)) + } + _ => format!("(not {})", rule_to_string(inner, false)), + }, + MediaQueryRule::And(rules) => rules + .iter() + .map(|r| rule_to_string(r, false)) + .collect::>() + .join(" and "), + MediaQueryRule::Or(rules) => { + let valid: Vec<&MediaQueryRule> = rules + .iter() + .filter(|r| !matches!(r, MediaQueryRule::Or(inner) if inner.is_empty())) + .collect(); + if valid.is_empty() { + return "not all".to_string(); + } + if valid.len() == 1 { + return rule_to_string(valid[0], is_top_level); + } + let formatted: Vec = valid + .iter() + .map(|r| match r { + MediaQueryRule::And(_) | MediaQueryRule::Or(_) => { + let inner = rule_to_string(r, false); + if is_top_level { + inner + } else { + format!("({inner})") + } + } + _ => rule_to_string(r, false), + }) + .collect(); + formatted.join(if is_top_level { ", " } else { " or " }) + } + } +} + +// --------------------------------------------------------------------------- +// normalization + +fn normalize(rule: MediaQueryRule) -> MediaQueryRule { + match rule { + MediaQueryRule::And(rules) => { + let mut flattened = Vec::new(); + for r in rules { + match normalize(r) { + MediaQueryRule::And(inner) => flattened.extend(inner), + other => flattened.push(other), + } + } + let merged = merge_intervals_for_and(&flattened); + if merged.is_empty() { + MediaQueryRule::Keyword { + key: "all".to_string(), + not: true, + only: false, + } + } else { + MediaQueryRule::And(merged) + } + } + MediaQueryRule::Or(rules) => MediaQueryRule::Or(rules.into_iter().map(normalize).collect()), + MediaQueryRule::Not(inner) => { + let operand = normalize(*inner); + if let MediaQueryRule::Keyword { key, not: true, .. } = &operand + && key == "all" + { + return MediaQueryRule::Keyword { + key: "all".to_string(), + not: false, + only: false, + }; + } + if let MediaQueryRule::Not(inner2) = operand { + return normalize(*inner2); + } + MediaQueryRule::Not(Box::new(operand)) + } + other => other, + } +} + +fn as_numeric_minmax(rule: &MediaQueryRule) -> Option<(&str, f64, &str)> { + if let MediaQueryRule::Pair { + key, + value: MediaRuleValue::Length { value, unit }, + } = rule + && (key == "min-width" || key == "max-width" || key == "min-height" || key == "max-height") + { + Some((key.as_str(), *value, unit.as_str())) + } else { + None + } +} + +const MERGE_EPSILON: f64 = 0.01; + +fn merge_intervals_for_and(rules: &[MediaQueryRule]) -> Vec { + // (not (A and B)) with exactly two operands distributes into an OR of the + // two negated branches before any interval math happens. + for (idx, rule) in rules.iter().enumerate() { + if let MediaQueryRule::Not(inner) = rule + && let MediaQueryRule::And(inner_rules) = &**inner + && inner_rules.len() == 2 + { + let others: Vec = rules + .iter() + .enumerate() + .filter(|(i, _)| *i != idx) + .map(|(_, r)| r.clone()) + .collect(); + let branches: Vec> = inner_rules + .iter() + .map(|negated| { + let mut input = others.clone(); + input.push(MediaQueryRule::Not(Box::new(negated.clone()))); + merge_intervals_for_and(&input) + }) + .collect(); + return vec![MediaQueryRule::Or( + branches + .into_iter() + .filter(|branch| !branch.is_empty()) + .map(|branch| { + if branch.len() == 1 { + branch.into_iter().next().expect("len checked") + } else { + MediaQueryRule::And(branch) + } + }) + .collect(), + )]; + } + } + + let dims = ["width", "height"]; + let mut intervals: [Vec<(f64, f64)>; 2] = [Vec::new(), Vec::new()]; + let mut units: [Option; 2] = [None, None]; + let mut has_unit_conflict = false; + + for rule in rules { + let mut matched = false; + for (d, dim) in dims.iter().enumerate() { + let min_key = format!("min-{dim}"); + let max_key = format!("max-{dim}"); + if let Some((key, value, unit)) = as_numeric_minmax(rule) + && (key == min_key || key == max_key) + { + match &units[d] { + None if intervals[d].is_empty() => units[d] = Some(unit.to_string()), + Some(u) if u != unit => has_unit_conflict = true, + _ => {} + } + intervals[d].push(if key == min_key { + (value, f64::INFINITY) + } else { + (f64::NEG_INFINITY, value) + }); + matched = true; + break; + } + if let MediaQueryRule::Not(inner) = rule + && let Some((key, value, unit)) = as_numeric_minmax(inner) + && (key == min_key || key == max_key) + { + match &units[d] { + None if intervals[d].is_empty() => units[d] = Some(unit.to_string()), + Some(u) if u != unit => has_unit_conflict = true, + _ => {} + } + intervals[d].push(if key == min_key { + (f64::NEG_INFINITY, value - MERGE_EPSILON) + } else { + (value + MERGE_EPSILON, f64::INFINITY) + }); + matched = true; + break; + } + } + if !matched { + // any rule that is not a numeric min/max pair (or its negation) + // disables merging for the whole conjunction + return rules.to_vec(); + } + } + + if has_unit_conflict { + return rules.to_vec(); + } + + let mut result = Vec::new(); + for (d, dim) in dims.iter().enumerate() { + if intervals[d].is_empty() { + continue; + } + let mut lower = f64::NEG_INFINITY; + let mut upper = f64::INFINITY; + for (l, u) in &intervals[d] { + if *l > lower { + lower = *l; + } + if *u < upper { + upper = *u; + } + } + if lower > upper { + return Vec::new(); + } + let unit = units[d].clone().unwrap_or_default(); + if lower != f64::NEG_INFINITY { + result.push(MediaQueryRule::Pair { + key: format!("min-{dim}"), + value: MediaRuleValue::Length { + value: lower, + unit: unit.clone(), + }, + }); + } + if upper != f64::INFINITY { + result.push(MediaQueryRule::Pair { + key: format!("max-{dim}"), + value: MediaRuleValue::Length { value: upper, unit }, + }); + } + } + if result.is_empty() { + rules.to_vec() + } else { + result + } +} + +// --------------------------------------------------------------------------- +// tokenizer (CSS Syntax 3 subset; escapes/non-ASCII are gated in parse()) + +#[derive(Debug, Clone, PartialEq)] +enum Tok { + Whitespace, + Ident(String), + AtKeyword(String), + Function(String), + Number(f64), + Percentage(f64), + Dimension { value: f64, unit: String }, + Colon, + Comma, + OpenParen, + CloseParen, + Delim(u8), + // strings, brackets, semicolons, comments, CDO/CDC: tokens the media query + // grammar can never match, so one opaque variant preserves the rejection + Unsupported, +} + +fn is_ws(b: u8) -> bool { + matches!(b, b' ' | b'\t' | b'\n' | b'\r' | 0x0C) +} + +fn is_ident_start(b: u8) -> bool { + b.is_ascii_alphabetic() || b == b'_' +} + +fn is_ident_char(b: u8) -> bool { + is_ident_start(b) || b.is_ascii_digit() || b == b'-' +} + +fn would_start_ident(bytes: &[u8], i: usize) -> bool { + match bytes.get(i) { + Some(b'-') => matches!(bytes.get(i + 1), Some(&b) if is_ident_start(b) || b == b'-'), + Some(&b) => is_ident_start(b), + None => false, + } +} + +fn would_start_number(bytes: &[u8], i: usize) -> bool { + match bytes.get(i) { + Some(b'+') | Some(b'-') => match bytes.get(i + 1) { + Some(b'.') => matches!(bytes.get(i + 2), Some(b) if b.is_ascii_digit()), + Some(b) => b.is_ascii_digit(), + None => false, + }, + Some(b'.') => matches!(bytes.get(i + 1), Some(b) if b.is_ascii_digit()), + Some(b) => b.is_ascii_digit(), + None => false, + } +} + +fn consume_ident(bytes: &[u8], i: &mut usize) -> String { + let start = *i; + while *i < bytes.len() && is_ident_char(bytes[*i]) { + *i += 1; + } + String::from_utf8_lossy(&bytes[start..*i]).into_owned() +} + +fn consume_number(bytes: &[u8], i: &mut usize) -> f64 { + let start = *i; + if matches!(bytes.get(*i), Some(b'+') | Some(b'-')) { + *i += 1; + } + while matches!(bytes.get(*i), Some(b) if b.is_ascii_digit()) { + *i += 1; + } + if bytes.get(*i) == Some(&b'.') && matches!(bytes.get(*i + 1), Some(b) if b.is_ascii_digit()) { + *i += 2; + while matches!(bytes.get(*i), Some(b) if b.is_ascii_digit()) { + *i += 1; + } + } + if matches!(bytes.get(*i), Some(b'e') | Some(b'E')) { + let mut j = *i + 1; + if matches!(bytes.get(j), Some(b'+') | Some(b'-')) { + j += 1; + } + if matches!(bytes.get(j), Some(b) if b.is_ascii_digit()) { + *i = j; + while matches!(bytes.get(*i), Some(b) if b.is_ascii_digit()) { + *i += 1; + } + } + } + String::from_utf8_lossy(&bytes[start..*i]) + .parse::() + .unwrap_or(f64::NAN) +} + +fn tokenize(input: &str) -> Vec { + let bytes = input.as_bytes(); + let mut tokens = Vec::new(); + let mut i = 0; + while i < bytes.len() { + let b = bytes[i]; + if is_ws(b) { + while i < bytes.len() && is_ws(bytes[i]) { + i += 1; + } + tokens.push(Tok::Whitespace); + } else if b == b'/' && bytes.get(i + 1) == Some(&b'*') { + i += 2; + while i < bytes.len() && !(bytes[i] == b'*' && bytes.get(i + 1) == Some(&b'/')) { + i += 1; + } + i = (i + 2).min(bytes.len()); + tokens.push(Tok::Unsupported); + } else if would_start_number(bytes, i) { + let value = consume_number(bytes, &mut i); + if would_start_ident(bytes, i) { + let unit = consume_ident(bytes, &mut i); + tokens.push(Tok::Dimension { value, unit }); + } else if bytes.get(i) == Some(&b'%') { + i += 1; + tokens.push(Tok::Percentage(value)); + } else { + tokens.push(Tok::Number(value)); + } + } else if b == b'-' && bytes.get(i..i + 3) == Some(b"-->") { + i += 3; + tokens.push(Tok::Unsupported); + } else if b == b'<' && bytes.get(i..i + 4) == Some(b"