diff --git a/Cargo.lock b/Cargo.lock index 312f227f..b3c93b84 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -904,6 +904,28 @@ 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_str", + "oxc_syntax", + "ryu-js", + "serde", + "serde_json", + "thiserror", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -1837,6 +1859,7 @@ dependencies = [ "rolldown", "rolldown_common", "rolldown_plugin", + "rolldown_sourcemap", "rolldown_utils", "serde_json", "tokio", @@ -1848,6 +1871,7 @@ name = "oj_cache" version = "0.1.16" dependencies = [ "blake3", + "fru", "proptest", "serde", "serde_json", @@ -1858,6 +1882,7 @@ dependencies = [ name = "oj_compiler" version = "0.1.16" dependencies = [ + "fru", "glob", "memchr", "oxc_allocator", @@ -1943,6 +1968,7 @@ dependencies = [ "anyhow", "axum", "blake3", + "fru", "futures-util", "glob", "json-strip-comments", @@ -3825,6 +3851,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/oj/Cargo.toml b/crates/oj/Cargo.toml index 668abad1..77181a13 100644 --- a/crates/oj/Cargo.toml +++ b/crates/oj/Cargo.toml @@ -22,6 +22,7 @@ tokio.workspace = true rolldown = "1.2.5" rolldown_common = "1.2.5" rolldown_plugin = "1.2.5" +rolldown_sourcemap = "1.2.5" rolldown_utils = "1.2.5" arcstr = "1.2.0" serde_json.workspace = true diff --git a/crates/oj/src/build.rs b/crates/oj/src/build.rs index 5c7240f1..93806442 100644 --- a/crates/oj/src/build.rs +++ b/crates/oj/src/build.rs @@ -212,6 +212,9 @@ struct OjCssPlugin { /// Source ids of `?worker` / `?worker&url` entries emitted as chunks, so the /// document-only build helpers can skip chunks that only a worker loads. worker_entries: Arc>>, + /// StyleX pass configured: swap the `@stylex;` directive for the build + /// sentinel so the assembled sheet can be spliced in after bundling. + stylex: bool, } /// The app build settings an inline worker bundle shares with its importer: @@ -772,6 +775,7 @@ async fn compile_stylesheet( resolve: &oj_css::CssResolveConfig, path: &str, id: &str, + stylex: bool, ) -> anyhow::Result { let cfg = oj_config::OjConfig { css: css.clone(), @@ -781,6 +785,11 @@ async fn compile_stylesheet( let is_stylus = oj_server::sidecar::is_stylus(path); let mut source = std::fs::read_to_string(path).map_err(|e| anyhow::anyhow!("cannot read {path}: {e}"))?; + // Mirrors the dev path: `@stylex;` handling precedes the sidecars. + let stylex_sentinel = stylex && oj_server::stylex::has_directive(&source); + if stylex_sentinel { + source = oj_server::stylex::substitute_directive(&source, STYLEX_SENTINEL_COMMENT); + } // Run the plugin transform chain on the raw CSS source first, so directive // transformers (UnoCSS `@apply`/`@unocss-include`, etc.) resolve before oj // preprocesses and compiles it. @@ -835,6 +844,10 @@ async fn compile_stylesheet( if tailwind || has_postcss { source = expand_css_via_sidecar(root, std::path::Path::new(path), &source)?; } + if stylex_sentinel { + // lightningcss drops comments; the unknown at-rule survives. + source = source.replace(STYLEX_SENTINEL_COMMENT, STYLEX_SENTINEL_AT); + } let css_id = match std::path::Path::new(path).strip_prefix(root) { Ok(rel) => format!("/{}", rel.display()), Err(_) => path.to_string(), @@ -1033,6 +1046,7 @@ impl Plugin for OjCssPlugin { let host = self.host.clone(); let css_transform_enabled = Arc::clone(&self.css_transform_enabled); let self_has_postcss = self.has_postcss; + let stylex = self.stylex; let css_cfg = self.css.clone(); let css_resolve = self.resolve.clone(); let html_inline = Arc::clone(&self.html_inline); @@ -1061,6 +1075,7 @@ impl Plugin for OjCssPlugin { &css_resolve, file, &id, + stylex, ) .await?; let stem = std::path::Path::new(file) @@ -1190,6 +1205,7 @@ impl Plugin for OjCssPlugin { &css_resolve, file, &id, + stylex, ) .await?; return Ok(Some(rolldown_plugin::HookLoadOutput { @@ -1252,6 +1268,7 @@ impl Plugin for OjCssPlugin { worker: worker.clone(), resolve: css_resolve.clone(), worker_entries: Arc::clone(&worker_entries), + stylex: false, }; let code = bundle_worker_inline(&root, file, worker.as_ref(), nested).await?; let literal = serde_json::Value::String(code).to_string(); @@ -1331,6 +1348,7 @@ impl Plugin for OjCssPlugin { &css_resolve, path, &id, + stylex, ) .await?; // A CSS module: the class map as default plus named exports per @@ -1359,6 +1377,110 @@ impl Plugin for OjCssPlugin { } } +/// What the `@stylex;` directive becomes while css sidecars run (postcss and +/// friends keep comments)… +const STYLEX_SENTINEL_COMMENT: &str = "/*__OJ_STYLEX__*/"; +/// …and what rides through `oj_css::compile_css`: lightningcss drops comments +/// but prints unknown at-rules verbatim, minified output included. +const STYLEX_SENTINEL_AT: &str = "@-oj-stylex-sentinel;"; + +/// The build-path StyleX pass: the string-level `stylex_pass` as a rolldown +/// transform hook (hooks are string-in/string-out and see raw TSX — rolldown's +/// builtin oxc lowering runs after them). The dev pipeline instead mutates its +/// own parsed AST via `stylex_pass_ast`; this seam stays on strings by nature. +/// Rules accumulate per module; the sheet is assembled once after bundling and +/// spliced into `collected_css`. +#[derive(Debug)] +struct OjStylexPlugin { + pass: Arc, + rules: StylexRules, +} + +/// Keyed by module path (mirrors the dev registry's BTreeMap) so the rule feed +/// into assembly is deterministic under rolldown's parallel module transforms. +type StylexRules = + Arc>>>; + +impl Plugin for OjStylexPlugin { + fn name(&self) -> Cow<'static, str> { + Cow::Borrowed("oj:stylex") + } + + fn register_hook_usage(&self) -> rolldown_plugin::HookUsage { + rolldown_plugin::HookUsage::Transform + } + + fn transform( + &self, + _ctx: SharedTransformPluginContext, + args: &HookTransformArgs<'_>, + ) -> impl std::future::Future + Send { + let id = args.id.to_string(); + let code = args.code.to_string(); + let pass = Arc::clone(&self.pass); + let rules = Arc::clone(&self.rules); + async move { + let path_str = id.split(['?', '#']).next().unwrap_or(&id); + let path = Path::new(path_str); + if !pass.is_candidate(path, &code) { + return Ok(None); + } + let out = oj_compiler::stylex::stylex_pass(path, &code, &pass) + .map_err(|e| anyhow::anyhow!("stylex: {e}"))?; + if !out.rules.is_empty() { + rules.lock().unwrap().insert(path.to_path_buf(), out.rules); + } + let map = out.map; + Ok(out.code.map(|code| rolldown_plugin::HookTransformOutput { + code: Some(code), + // The splice moves line positions, so reporting no map would + // leave rolldown composing against the pre-transform text. + map: match map.as_deref().and_then(parse_sourcemap) { + Some(map) => HookTransformOutputMap::Sourcemap(Box::new(map)), + None => HookTransformOutputMap::Null, + }, + ..Default::default() + })) + } + } +} + +/// The splice map arrives as JSON; rolldown wants an owned SourceMap. +fn parse_sourcemap(json: &str) -> Option { + let parsed: rolldown_sourcemap::JSONSourceMap = serde_json::from_str(json).ok()?; + rolldown_sourcemap::SourceMap::from_json(parsed).ok() +} + +/// Assemble the collected rules and splice the sheet over every sentinel in +/// `collected_css`. Returns the sheet when rules exist but no stylesheet +/// carried the directive, so the caller can emit it standalone. +fn join_stylex_css( + pass: &oj_server::stylex::StylexPassConfig, + rules: &StylexRules, + collected_css: &Arc>>, +) -> anyhow::Result> { + let all: Vec = + rules.lock().unwrap().values().flatten().cloned().collect(); + let mut entries = collected_css.lock().unwrap(); + let has_sentinel = entries + .iter() + .any(|(_, css)| css.contains(STYLEX_SENTINEL_AT)); + if all.is_empty() && !has_sentinel { + return Ok(None); + } + let sheet = oj_server::stylex::assemble_sheet(&all, pass) + .map_err(|e| anyhow::anyhow!("stylex assemble error: {e}"))?; + if has_sentinel { + for (_, css) in entries.iter_mut() { + if css.contains(STYLEX_SENTINEL_AT) { + *css = css.replace(STYLEX_SENTINEL_AT, &sheet); + } + } + return Ok(None); + } + Ok((!all.is_empty()).then_some(sheet)) +} + /// Tailwind / PostCSS over `css` (the file's content as processed so far, not /// re-read from disk), via the sidecar's line protocol. fn expand_css_via_sidecar(root: &Path, css_file: &Path, css: &str) -> anyhow::Result { @@ -2125,6 +2247,7 @@ async fn user_plugin_host( environments: &serde_json::Value, env_name: &str, mode: &str, + stylex_native: bool, ) -> Option> { let (file, plugins_format, label) = match oj_server::plugins::plugin_source(root)? { oj_server::plugins::PluginSource::OjPlugins(p) => { @@ -2133,13 +2256,18 @@ async fn user_plugin_host( } oj_server::plugins::PluginSource::ViteConfig(p) => (p, "vite", "vite.config".to_string()), }; - let config = serde_json::json!({ + let mut config = serde_json::json!({ "config": { "root": root.display().to_string(), "base": base, "mode": mode, "command": "build", "define": define, "environments": environments }, "env": { "command": "build", "mode": mode }, "environment": { "name": env_name, "mode": "build" }, "pluginsFormat": plugins_format, - }) - .to_string(); + }); + if stylex_native { + // Same dedupe as dev: the native pass replaces the pilot's Babel Vite + // plugin; hosting both would transform stylex modules twice. + config["nativePluginNames"] = serde_json::json!(["lovable:stylex-transform"]); + } + let config = config.to_string(); match PluginHost::spawn(root, &file, &config).await { Ok(host) => { println!("oj build ({env_name}): plugins from {label}"); @@ -2293,6 +2421,7 @@ pub async fn build( root: PathBuf, cli_mode: Option<&str>, cli: CliOptions, + stylex_config: Option, ) -> anyhow::Result<()> { let root = root .canonicalize() @@ -2485,6 +2614,16 @@ pub async fn build( let worker_entries: Arc>> = Arc::new(Mutex::new(std::collections::HashSet::new())); let css_split = css_code_split_of(&config); + // Build defaults to prod rules (no debug classnames) unless the config's + // `dev` key says otherwise; the dev-server default is NODE_ENV-driven. + let stylex_pass = oj_server::stylex::resolve_pass_config( + stylex_config.as_deref(), + &config, + &root, + mode != "production", + ) + .map_err(|e| anyhow::anyhow!("{e}"))?; + let stylex_rules: StylexRules = Arc::default(); let plugin_host = user_plugin_host( &root, &base, @@ -2492,6 +2631,7 @@ pub async fn build( &serde_json::json!(config.environments), "client", mode, + stylex_pass.is_some(), ) .await; let plugin_defines = plugin_config_defines(&plugin_host).await; @@ -2516,6 +2656,16 @@ pub async fn build( pairs.extend(plugin_defines); pairs }; + if let Some(pass) = &stylex_pass { + println!( + "oj build: stylex native pass enabled ({} include glob(s))", + pass.include.len() + ); + oj_plugins.push(Arc::new(OjStylexPlugin { + pass: Arc::new(pass.clone()), + rules: Arc::clone(&stylex_rules), + })); + } oj_plugins.push(Arc::new(OjCssPlugin { collected: Arc::clone(&collected_css), root: root.to_path_buf(), @@ -2535,6 +2685,7 @@ pub async fn build( minify: client_minify, })), worker_entries: Arc::clone(&worker_entries), + stylex: stylex_pass.is_some(), })); let out_ov = ro_output_overrides(ro_opts); // Vite's build import analysis (preload wrapping, polyfill) only applies to @@ -2752,6 +2903,39 @@ pub async fn build( } } + // StyleX joins before either CSS emission path reads `collected_css`: the + // assembled sheet replaces the sentinel in place. When rules exist but no + // stylesheet carried the directive, the sheet becomes its own entry: a + // synthetic `collected_css` member when CSS is combined, a standalone + // hashed stylesheet linked from every page when CSS is split (a synthetic + // entry would match no chunk's modules and be dropped). + let mut stylex_css_link: Option = None; + if let Some(pass) = &stylex_pass { + if let Some(sheet) = join_stylex_css(pass, &stylex_rules, &collected_css)? { + if css_split { + fs::create_dir_all(out_dir.join("assets"))?; + let hash = content_hash(sheet.as_bytes()); + let css_name = format!("assets/stylex-{}.css", &hash[..8]); + fs::write(out_dir.join(&css_name), &sheet)?; + emitted.push((css_name.clone(), sheet.len())); + for entry in &mut manifest_entries { + if entry.is_entry { + entry.css.push(css_name.clone()); + } + } + stylex_css_link = Some(format!( + "", + with_base(&css_name, &base) + )); + } else { + collected_css + .lock() + .unwrap() + .push((root.join("stylex.css").display().to_string(), sheet)); + } + } + } + // Non-split CSS is one combined stylesheet linked from every page. let combined_css_name: Option = if !css_split { let mut css_entries = collected_css.lock().unwrap().clone(); @@ -2899,6 +3083,7 @@ pub async fn build( &link_css_resolve, &src_str, &src_str, + stylex_pass.is_some(), ) .await .with_context(|| format!("stylesheet {href} linked from {}", doc.out_rel))?; @@ -2958,6 +3143,9 @@ pub async fn build( }); } + if let Some(link) = &stylex_css_link { + rewritten_html = insert_before_head(&rewritten_html, link); + } if let Some(css_name) = &combined_css_name { let link = format!( "", @@ -3713,6 +3901,7 @@ pub(crate) async fn build_ssr( &serde_json::json!(config.environments), "ssr", mode, + false, ) .await; let plugin_defines = plugin_config_defines(&plugin_host).await; @@ -3771,6 +3960,7 @@ pub(crate) async fn build_ssr( html_inline: Arc::new(Mutex::new(std::collections::HashMap::new())), worker: None, worker_entries: Arc::new(Mutex::new(std::collections::HashSet::new())), + stylex: false, })); let mut bundler = BundlerBuilder::default() .with_plugins(oj_plugins) @@ -3932,6 +4122,7 @@ async fn build_server_fns(root: &Path, out_dir: &Path, mode: &str) -> anyhow::Re html_inline: Arc::new(Mutex::new(std::collections::HashMap::new())), worker: None, worker_entries: Arc::new(Mutex::new(std::collections::HashSet::new())), + stylex: false, })]) .with_options(BundlerOptions { input: Some(vec![InputItem { @@ -4284,6 +4475,7 @@ async fn build_client_entry( &serde_json::json!(config.environments), "client", mode, + false, ) .await; let plugin_defines = plugin_config_defines(&plugin_host).await; @@ -4309,6 +4501,7 @@ async fn build_client_entry( html_inline: Arc::new(Mutex::new(std::collections::HashMap::new())), worker: None, worker_entries: Arc::new(Mutex::new(std::collections::HashSet::new())), + stylex: false, })); let mut bundler = BundlerBuilder::default() @@ -4542,6 +4735,7 @@ async fn build_library( html_inline: Arc::new(Mutex::new(std::collections::HashMap::new())), worker: None, worker_entries: Arc::new(Mutex::new(std::collections::HashSet::new())), + stylex: false, })]) .with_options(BundlerOptions { input: Some( @@ -5643,7 +5837,7 @@ mod tests { .unwrap(); fs::write(root.join("shared.js"), r#"export const shared = "ready";"#).unwrap(); - build(root.clone(), Some("production"), CliOptions::default()) + build(root.clone(), Some("production"), CliOptions::default(), None) .await .expect("synthetic shared-chunk fixture should build"); @@ -5776,7 +5970,7 @@ mod tests { fs::write(root.join("main.js"), "window.ready = true;").unwrap(); fs::write(root.join("public/asset.txt"), "public asset").unwrap(); - build(root.clone(), Some("production"), CliOptions::default()) + build(root.clone(), Some("production"), CliOptions::default(), None) .await .expect("synthetic public-directory fixture should build"); @@ -5810,7 +6004,7 @@ mod tests { .unwrap(); fs::write(root.join("server.js"), "export const flag = process.env.SOME_FLAG;\n").unwrap(); - build(root.clone(), Some("production"), CliOptions::default()) + build(root.clone(), Some("production"), CliOptions::default(), None) .await .expect("client build"); let mut client = String::new(); @@ -5869,7 +6063,7 @@ mod tests { .unwrap(); fs::write(root.join("public/manifest.webmanifest"), "{}").unwrap(); - build(root.clone(), Some("production"), CliOptions::default()) + build(root.clone(), Some("production"), CliOptions::default(), None) .await .expect("html asset fixture should build"); let html = fs::read_to_string(root.join("dist/index.html")).unwrap(); @@ -5916,7 +6110,7 @@ mod tests { "export default { build: { lib: { entry: 'src/index.ts', name: 'MyLib' } } };", ) .unwrap(); - build(root.clone(), Some("production"), CliOptions::default()) + build(root.clone(), Some("production"), CliOptions::default(), None) .await .expect("vite.config build.lib should build"); assert!(root.join("dist/my-lib.js").is_file(), "es output named after the unscoped package"); @@ -5936,7 +6130,7 @@ mod tests { "export default { build: { lib: { entry: { main: 'src/a.ts', extra: 'src/b.ts' }, cssFileName: 'theme' } } };", ) .unwrap(); - build(root.clone(), Some("production"), CliOptions::default()) + build(root.clone(), Some("production"), CliOptions::default(), None) .await .expect("multi-entry lib should build"); for f in ["main.mjs", "extra.mjs", "main.js", "extra.js"] { @@ -5955,7 +6149,7 @@ mod tests { "export default { build: { lib: { entry: 'src/index.ts', formats: ['umd'] } } };", ) .unwrap(); - let err = build(root.clone(), Some("production"), CliOptions::default()) + let err = build(root.clone(), Some("production"), CliOptions::default(), None) .await .expect_err("umd needs build.lib.name"); assert!(err.to_string().contains("build.lib.name"), "{err}"); @@ -5993,7 +6187,7 @@ mod tests { r#""#, ) .unwrap(); - build(root.clone(), Some("production"), CliOptions::default()) + build(root.clone(), Some("production"), CliOptions::default(), None) .await .expect("inline worker fixture should build"); let mut code = String::new(); @@ -6678,4 +6872,100 @@ mod tests { assert!(out.contains("var d = 1;"), "classic scripts untouched: {out}"); } + #[test] + fn stylex_sentinel_at_rule_survives_css_minification() { + // The whole reason the directive rides as an at-rule and not as the + // sidecar-phase comment: lightningcss drops comments (collapsing the + // then-empty layer), but prints unknown at-rules verbatim. + let src = format!("@layer stylex {{\n {STYLEX_SENTINEL_AT}\n}}\nbody{{margin:0}}"); + let out = oj_css::compile_css("/src/styles.css", &src, true).unwrap(); + assert_eq!( + out.css, + format!("@layer stylex{{{STYLEX_SENTINEL_AT}}}body{{margin:0}}") + ); + let commented = + format!("@layer stylex {{\n {STYLEX_SENTINEL_COMMENT}\n}}\nbody{{margin:0}}"); + let out = oj_css::compile_css("/src/styles.css", &commented, true).unwrap(); + assert!( + !out.css.contains("__OJ_STYLEX__"), + "comment unexpectedly survived — the two-stage swap can be dropped: {}", + out.css + ); + } + + fn stylex_test_pass() -> oj_server::stylex::StylexPassConfig { + oj_server::stylex::StylexPassConfig::new( + PathBuf::from("/app"), + PathBuf::from("/app"), + &["src/**".into()], + &[], + false, + true, + None, + ) + .unwrap() + } + + fn stylex_rule(class: &str, ltr: &str, priority: f64) -> oj_server::stylex::StylexRule { + oj_server::stylex::StylexRule { + class_name: class.into(), + ltr: ltr.into(), + rtl: None, + const_key: None, + const_val: None, + priority, + } + } + + #[test] + fn stylex_join_substitutes_every_sentinel_in_place() { + let pass = stylex_test_pass(); + let rules: StylexRules = Arc::default(); + rules.lock().unwrap().insert( + PathBuf::from("/app/src/a.tsx"), + vec![stylex_rule("x1", ".x1{color:red}", 3000.0)], + ); + let collected = Arc::new(Mutex::new(vec![ + ( + "/app/src/styles.css".to_string(), + format!("@layer stylex{{{STYLEX_SENTINEL_AT}}}"), + ), + ( + "/app/src/plain.css".to_string(), + "body{margin:0}".to_string(), + ), + ])); + let standalone = join_stylex_css(&pass, &rules, &collected).unwrap(); + assert!(standalone.is_none(), "sentinel found: no standalone sheet"); + let entries = collected.lock().unwrap(); + assert_eq!( + entries[0].1, + "@layer stylex{\n@layer priority1;\n@layer priority1{\n.x1{color:red}\n}}" + ); + assert_eq!(entries[1].1, "body{margin:0}", "non-sentinel css untouched"); + } + + #[test] + fn stylex_join_returns_a_standalone_sheet_without_a_sentinel() { + let pass = stylex_test_pass(); + let rules: StylexRules = Arc::default(); + rules.lock().unwrap().insert( + PathBuf::from("/app/src/a.tsx"), + vec![stylex_rule("x1", ".x1{color:red}", 3000.0)], + ); + let collected = Arc::new(Mutex::new(vec![( + "/app/src/plain.css".to_string(), + "body{margin:0}".to_string(), + )])); + let standalone = join_stylex_css(&pass, &rules, &collected).unwrap(); + assert_eq!( + standalone.as_deref(), + Some("\n@layer priority1;\n@layer priority1{\n.x1{color:red}\n}") + ); + // No rules and no sentinel: nothing to do. + let empty: StylexRules = Arc::default(); + assert!(join_stylex_css(&pass, &empty, &collected) + .unwrap() + .is_none()); + } } diff --git a/crates/oj/src/main.rs b/crates/oj/src/main.rs index d6e8bfb8..7856454e 100644 --- a/crates/oj/src/main.rs +++ b/crates/oj/src/main.rs @@ -38,6 +38,10 @@ enum Command { /// Vite's `--mode` for the dev server (default `development`). #[arg(long)] mode: Option, + /// StyleX config as JSON (same schema as the `stylex` config section; + /// also OJ_STYLEX_CONFIG=), for apps whose config can't carry it. + #[arg(long)] + stylex_config: Option, /// Enable the experimental on-disk module cache (also OJ_ENABLE_CACHE=1). /// Off by default; warm restarts then re-serve compiled modules from disk. #[arg(long)] @@ -105,6 +109,10 @@ enum Command { /// configured environment, so this is accepted as a no-op. #[arg(long)] app: bool, + /// StyleX config as JSON (same schema as the `stylex` config section; + /// also OJ_STYLEX_CONFIG=), for apps whose config can't carry it. + #[arg(long)] + stylex_config: Option, }, Preview { root: Option, @@ -148,6 +156,7 @@ async fn run() -> anyhow::Result<()> { host, config, mode, + stylex_config, enable_cache, no_cache, lazy, @@ -171,6 +180,7 @@ async fn run() -> anyhow::Result<()> { bundle, host, config, + stylex_config, enable_cache, no_cache, lazy, @@ -209,6 +219,7 @@ async fn run() -> anyhow::Result<()> { ssr_manifest, watch, app: _, + stylex_config, } => { let root = root.unwrap_or_else(|| { let playground = PathBuf::from("playground"); @@ -240,6 +251,7 @@ async fn run() -> anyhow::Result<()> { ssr_manifest, watch, }, + stylex_config, ) .await } diff --git a/crates/oj/src/ssr_dev.rs b/crates/oj/src/ssr_dev.rs index 8d2e600d..7392849b 100644 --- a/crates/oj/src/ssr_dev.rs +++ b/crates/oj/src/ssr_dev.rs @@ -45,6 +45,7 @@ pub async fn ssr_dev( bundle: false, host, config: None, + stylex_config: None, enable_cache: false, no_cache: false, lazy: false, diff --git a/crates/oj/src/start_dev.rs b/crates/oj/src/start_dev.rs index 5f01de4a..1467c709 100644 --- a/crates/oj/src/start_dev.rs +++ b/crates/oj/src/start_dev.rs @@ -88,6 +88,7 @@ pub async fn start_dev( bundle: false, host, config, + stylex_config: None, enable_cache: false, no_cache: false, lazy: false, diff --git a/crates/oj_cache/Cargo.toml b/crates/oj_cache/Cargo.toml index d0a3c4c5..9d65a005 100644 --- a/crates/oj_cache/Cargo.toml +++ b/crates/oj_cache/Cargo.toml @@ -7,6 +7,7 @@ description = "Content-addressed persistent cache for compiled module outputs" [dependencies] blake3.workspace = true +fru.workspace = true serde = { workspace = true, features = ["derive"] } serde_json.workspace = true diff --git a/crates/oj_cache/src/lib.rs b/crates/oj_cache/src/lib.rs index aa65a884..e8237732 100644 --- a/crates/oj_cache/src/lib.rs +++ b/crates/oj_cache/src/lib.rs @@ -111,6 +111,10 @@ pub struct CachedModule { /// context, and these are the `accept` declarations for the module graph. #[serde(default)] pub hot: Option, + /// StyleX rules extracted from this module, persisted so warm starts can + /// rebuild the server-side registry without retransforming. + #[serde(default)] + pub stylex_rules: Vec, } #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] @@ -220,6 +224,14 @@ mod tests { fs_allow: Vec::new(), watch_files: Vec::new(), hot: None, + stylex_rules: vec![fru::rules::StylexRule { + class_name: "xtest".into(), + ltr: ".xtest{color:red}".into(), + rtl: None, + const_key: None, + const_val: None, + priority: 3000.0, + }], } } @@ -258,6 +270,14 @@ mod tests { ); } + #[test] + fn entries_without_stylex_rules_still_deserialize() { + // Pre-stylex cache entries lack the field; serde(default) keeps them valid. + let legacy = r#"{"code":"x","map_data_url":null,"imports":[],"is_boundary":false}"#; + let module: CachedModule = serde_json::from_str(legacy).unwrap(); + assert!(module.stylex_rules.is_empty()); + } + #[test] fn corrupt_entries_are_dropped_not_served() { let cache = temp_cache("corrupt"); diff --git a/crates/oj_cache/tests/common/mod.rs b/crates/oj_cache/tests/common/mod.rs index dfb50c0d..6c9330f7 100644 --- a/crates/oj_cache/tests/common/mod.rs +++ b/crates/oj_cache/tests/common/mod.rs @@ -37,6 +37,7 @@ pub fn module(code: &str) -> CachedModule { fs_allow: vec!["/node_modules/react".into()], watch_files: vec!["/tailwind.config.ts".into()], hot: None, + stylex_rules: Vec::new(), } } diff --git a/crates/oj_cache/tests/faults.rs b/crates/oj_cache/tests/faults.rs index 49af279b..f24dbdfb 100644 --- a/crates/oj_cache/tests/faults.rs +++ b/crates/oj_cache/tests/faults.rs @@ -228,6 +228,7 @@ fn an_entry_is_only_ever_published_whole() { fs_allow: vec!["/tmp/a b/c".into()], watch_files: vec!["\\\\?\\C:\\x".into()], hot: None, + stylex_rules: Vec::new(), }; f.cache.put(&key, &written); assert_eq!(f.cache.get(&key), Some(written)); diff --git a/crates/oj_cache/tests/properties.rs b/crates/oj_cache/tests/properties.rs index 455db5ee..cf133602 100644 --- a/crates/oj_cache/tests/properties.rs +++ b/crates/oj_cache/tests/properties.rs @@ -32,6 +32,7 @@ fn cached_module() -> impl Strategy { fs_allow: pairs.iter().map(|(a, _)| a.clone()).collect(), watch_files: pairs.iter().map(|(_, b)| b.clone()).collect(), hot: None, + stylex_rules: Vec::new(), }, ) } diff --git a/crates/oj_compiler/Cargo.toml b/crates/oj_compiler/Cargo.toml index c9f3f11b..6a46ede1 100644 --- a/crates/oj_compiler/Cargo.toml +++ b/crates/oj_compiler/Cargo.toml @@ -23,6 +23,7 @@ serde_json.workspace = true oxc_codegen.workspace = true oxc_sourcemap = "8.0.2" thiserror.workspace = true +fru.workspace = true [dev-dependencies] proptest.workspace = true diff --git a/crates/oj_compiler/src/cjs.rs b/crates/oj_compiler/src/cjs.rs index 3969a59f..4449e0af 100644 --- a/crates/oj_compiler/src/cjs.rs +++ b/crates/oj_compiler/src/cjs.rs @@ -31,6 +31,7 @@ pub fn compile_dep( sourcemap: false, ssr: false, jsx: crate::JsxConfig::default(), + stylex: None, }; crate::compile_module(path, source_text, &opts, Some(resolve)) } else { @@ -217,6 +218,7 @@ export default (module.exports && module.exports.__esModule) ? module.exports["d dynamic_imports: Vec::new(), is_refresh_boundary: false, hot_accept: None, + stylex_rules: Vec::new(), }) } diff --git a/crates/oj_compiler/src/lib.rs b/crates/oj_compiler/src/lib.rs index f5f6545a..e2101da2 100644 --- a/crates/oj_compiler/src/lib.rs +++ b/crates/oj_compiler/src/lib.rs @@ -9,6 +9,7 @@ pub mod interop; pub mod ssr; pub mod json; pub mod pkgbundle; +pub mod stylex; use std::path::{Path, PathBuf}; use std::sync::LazyLock; @@ -131,15 +132,16 @@ impl JsxConfig { } #[derive(Debug, Clone)] -pub struct CompileOptions { +pub struct CompileOptions<'a> { pub dev: bool, pub refresh: bool, pub sourcemap: bool, pub ssr: bool, pub jsx: JsxConfig, + pub stylex: Option<&'a stylex::StylexPassConfig>, } -impl CompileOptions { +impl<'a> CompileOptions<'a> { pub fn dev() -> Self { Self { dev: true, @@ -147,6 +149,7 @@ impl CompileOptions { sourcemap: true, ssr: false, jsx: JsxConfig::default(), + stylex: None, } } @@ -157,6 +160,7 @@ impl CompileOptions { sourcemap: true, ssr: false, jsx: JsxConfig::default(), + stylex: None, } } } @@ -171,6 +175,7 @@ pub struct CompileOutput { /// `Some` when the module references `import.meta.hot` (it needs a hot /// context injected); what its `accept` calls declared. pub hot_accept: Option, + pub stylex_rules: Vec, } /// The `import.meta.hot.accept(...)` forms a module uses (Vite's @@ -299,6 +304,12 @@ pub fn compile_module_with_maps( let source_type = SourceType::from_path(path) .map_err(|_| CompileError::UnsupportedFileType(path.to_path_buf()))?; + // Cheap StyleX pre-gate (path glob + SIMD scan) before the parse; the pass + // itself runs on the parsed AST below. + let stylex_cfg = opts + .stylex + .filter(|cfg| cfg.is_candidate(path, source_text)); + let allocator = Allocator::default(); let parsed = Parser::new(&allocator, source_text, source_type).parse(); @@ -316,6 +327,25 @@ pub fn compile_module_with_maps( } let mut program = parsed.program; + // StyleX mutates the freshly parsed AST, before semantic/Transformer + // lowering (babel-parity position: sx JSX props and TS types still + // visible). Synthesized nodes carry replaced-node (or empty) spans, so the + // codegen sourcemap below maps the ORIGINAL source — the string seam's + // transformed-source map trade is gone. Asymmetry: the build path + // (rolldown plugin, oj/src/build.rs) stays on the string-level + // `stylex_pass`, string-in/string-out by nature. + let stylex_rules = match stylex_cfg { + Some(cfg) => { + stylex::stylex_pass_ast(&allocator, &mut program, path, source_text, cfg) + .map_err(|message| CompileError::Transform { + path: path.to_path_buf(), + message, + })? + .rules + } + None => Vec::new(), + }; + let semantic_ret = SemanticBuilder::new() .with_excess_capacity(2.0) .with_enum_eval(true) @@ -398,7 +428,8 @@ pub fn compile_module_with_maps( // Synthesized nodes (glob / dynamic-import-vars) carry generated-string spans // with no origin in this module's source; sourcemapping them panics oxc's - // builder on out-of-range spans, so skip the map for those modules. + // builder on out-of-range spans, so skip the map for those modules. StyleX + // nodes are exempt: they carry in-range replaced-node spans or empty spans. let codegen_options = CodegenOptions { source_map_path: (opts.sourcemap && !synthesized).then(|| path.to_path_buf()), ..CodegenOptions::default() @@ -421,6 +452,7 @@ pub fn compile_module_with_maps( dynamic_imports, is_refresh_boundary, hot_accept, + stylex_rules, }) } @@ -1022,6 +1054,7 @@ export const used: A extends B ? number : number = c + d; sourcemap: false, ssr: false, jsx: JsxConfig::default(), + stylex: None, }, None, ) @@ -1042,6 +1075,134 @@ export const used: A extends B ? number : number = c + d; ); } + fn stylex_cfg() -> stylex::StylexPassConfig { + stylex::StylexPassConfig::new( + PathBuf::from("/app"), + PathBuf::from("/app"), + &["src/**".into()], + &[], + true, + false, + None, + ) + .unwrap() + } + + #[test] + fn stylex_seam_compiles_source_and_attaches_rules() { + let cfg = stylex_cfg(); + let src = "import * as stylex from '@stylexjs/stylex';\n\ + const styles = stylex.create({ root: { color: 'red', ':hover': { color: 'blue' } } });\n\ + export const attrs = stylex.props(styles.root);\n"; + let mut opts = CompileOptions::dev(); + opts.stylex = Some(&cfg); + let out = compile_module(Path::new("/app/src/a.ts"), src, &opts, None).unwrap(); + assert!(!out.code.contains("stylex.create"), "create compiled away"); + assert!( + !out.code.contains("@stylexjs/stylex"), + "import removed via DCE" + ); + assert_eq!(out.stylex_rules.len(), 2, "base + hover rule"); + for rule in &out.stylex_rules { + assert!(out.code.contains(&*rule.class_name) || rule.ltr.contains(":hover")); + } + assert!( + out.map_data_url.is_some(), + "stylex-modified module keeps a real map" + ); + } + + fn b64_decode(s: &str) -> Vec { + const ALPHABET: &[u8; 64] = + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let idx = |c: u8| ALPHABET.iter().position(|&a| a == c).unwrap() as u32; + let bytes: Vec = s.bytes().filter(|&c| c != b'=' && c != b'\n').collect(); + let mut out = Vec::new(); + for chunk in bytes.chunks(4) { + let mut v = 0u32; + for (i, &c) in chunk.iter().enumerate() { + v |= idx(c) << (18 - 6 * i); + } + out.push((v >> 16) as u8); + if chunk.len() > 2 { + out.push((v >> 8) as u8); + } + if chunk.len() > 3 { + out.push(v as u8); + } + } + out + } + + #[test] + fn stylex_seam_sourcemap_maps_the_original_source() { + let cfg = stylex_cfg(); + // `styles` is exported so DCE keeps the compiled create object (an + // unexported, fully-inlined one is pruned along with its mapping). + let src = "import * as stylex from '@stylexjs/stylex';\n\ + export const styles = stylex.create({ root: { color: 'red' } });\n\ + export const attrs = stylex.props(styles.root);\n"; + let mut opts = CompileOptions::dev(); + opts.stylex = Some(&cfg); + let out = compile_module(Path::new("/app/src/a.ts"), src, &opts, None).unwrap(); + let url = out + .map_data_url + .expect("stylex-modified module keeps a real map"); + let json = String::from_utf8(b64_decode(url.rsplit(',').next().unwrap())).unwrap(); + let map = oxc_sourcemap::SourceMap::from_json_string(&json).unwrap(); + assert_eq!( + map.get_source_content(0), + Some(src), + "sourcesContent is the ORIGINAL source, not the transformed text" + ); + // The compiled create/props replacements keep mappings into their + // source lines (0-based lines 1 and 2). + assert!(map.get_tokens().any(|t| t.get_src_line() == 1), "{json}"); + assert!(map.get_tokens().any(|t| t.get_src_line() == 2), "{json}"); + let n_lines = src.lines().count() as u32; + assert!( + map.get_tokens().all(|t| t.get_src_line() < n_lines), + "every mapping stays inside the original source" + ); + } + + #[test] + fn stylex_seam_skips_unmatched_paths_and_stylex_free_sources() { + let cfg = stylex_cfg(); + let mut opts = CompileOptions::dev(); + opts.stylex = Some(&cfg); + let src = "import * as stylex from '@stylexjs/stylex';\nexport const x = stylex;\n"; + let outside = compile_module(Path::new("/app/lib/a.ts"), src, &opts, None).unwrap(); + assert!(outside.stylex_rules.is_empty()); + assert!( + outside.code.contains("@stylexjs/stylex"), + "untouched outside the glob" + ); + let plain = compile_module( + Path::new("/app/src/b.ts"), + "export const y = 1;", + &opts, + None, + ) + .unwrap(); + assert!(plain.stylex_rules.is_empty()); + } + + #[test] + fn stylex_authoring_error_surfaces_as_transform_error() { + let cfg = stylex_cfg(); + let mut opts = CompileOptions::dev(); + opts.stylex = Some(&cfg); + let src = "import * as stylex from '@stylexjs/stylex';\n\ + const dyn = Math.random();\n\ + export const styles = stylex.create({ root: { color: dyn } });\n"; + let err = compile_module(Path::new("/app/src/a.ts"), src, &opts, None).unwrap_err(); + assert!( + matches!(err, CompileError::Transform { .. }), + "stylex errors ride the transform-error path, got {err:?}" + ); + } + #[test] fn lexes_import_meta_hot_accept_forms_and_rewrites_dep_specifiers() { let mut rw = |spec: &str| spec.strip_prefix("./").map(|r| format!("/src/{r}")); @@ -1125,6 +1286,7 @@ export const used: A extends B ? number : number = c + d; sourcemap: false, ssr: false, jsx: JsxConfig::default(), + stylex: None, }, None, ) diff --git a/crates/oj_compiler/src/stylex.rs b/crates/oj_compiler/src/stylex.rs new file mode 100644 index 00000000..3086afc0 --- /dev/null +++ b/crates/oj_compiler/src/stylex.rs @@ -0,0 +1,388 @@ +// SPDX-License-Identifier: MIT +// StyleX pass plumbing: gate, config, and the dev/build compile seams. + +use std::path::{Path, PathBuf}; +use std::sync::LazyLock; + +use fru::api::{transform_program, transform_source_mapped_in, FileContext}; +use fru::module_resolution::StdFs; +use fru::options::{CompilerOptions, ResolvedOptions}; +use memchr::memmem::Finder; +use oxc_allocator::Allocator; +use oxc_ast::ast::Program; +use serde_json::json; + +pub use fru::rules::StylexRule; + +/// Bump when the pass's output semantics change: it is folded into cache keys +/// so modules persisted by an older pass are never replayed. +pub const STYLEX_PASS_VERSION: u32 = 2; + +static F_STYLEX: LazyLock> = LazyLock::new(|| Finder::new("stylex")); + +#[derive(Debug, Clone)] +pub struct StylexPassConfig { + /// The app root: the compiler's cwd (pins `$$css` debug-string derivation). + pub app_root: PathBuf, + /// Base directory the include/exclude globs are relative to, and the + /// `unstable_moduleResolution` rootDir for cross-file theme references. + pub root_dir: PathBuf, + pub include: Vec, + pub exclude: Vec, + pub dev: bool, + pub use_css_layers: bool, + pub class_name_prefix: Option, + options: ResolvedOptions, +} + +impl StylexPassConfig { + #[allow(clippy::too_many_arguments)] + pub fn new( + app_root: PathBuf, + root_dir: PathBuf, + include: &[String], + exclude: &[String], + dev: bool, + use_css_layers: bool, + class_name_prefix: Option, + ) -> Result { + let compile = |globs: &[String]| -> Result, String> { + globs + .iter() + .map(|g| { + glob::Pattern::new(g).map_err(|e| format!("invalid stylex glob {g:?}: {e}")) + }) + .collect() + }; + let raw = CompilerOptions { + dev: Some(json!(dev)), + class_name_prefix: class_name_prefix.as_ref().map(|prefix| json!(prefix)), + // Always on: oj's dev graph is unbundled and oxc's TS transform elides + // imports the pass leaves unused, so without the compensation imports a + // theme/const module would drop out of the graph and its rules with it. + treeshake_compensation: Some(json!(true)), + unstable_module_resolution: Some(json!({ + "type": "commonJS", + "rootDir": root_dir.to_string_lossy().replace('\\', "/"), + })), + ..CompilerOptions::default() + }; + let options = raw + .resolve() + .map_err(|e| format!("invalid stylex options: {e}"))?; + Ok(Self { + app_root, + root_dir, + include: compile(include)?, + exclude: compile(exclude)?, + dev, + use_css_layers, + class_name_prefix, + options, + }) + } + + pub fn matches_path(&self, file: &Path) -> bool { + let rel = file.strip_prefix(&self.root_dir).unwrap_or(file); + let rel = rel.to_string_lossy().replace('\\', "/"); + self.include.iter().any(|p| p.matches(&rel)) + && !self.exclude.iter().any(|p| p.matches(&rel)) + } + + /// Cheap per-module gate: the path must be included and the source must + /// mention "stylex" (SIMD scan) before the pass pays for anything. + pub fn is_candidate(&self, file: &Path, source: &str) -> bool { + crate::scan(&F_STYLEX, source) && self.matches_path(file) + } + + /// Canonical config identity for cache salting (callers hash it). + pub fn salt_input(&self) -> String { + let globs = + |v: &[glob::Pattern]| v.iter().map(|p| p.as_str()).collect::>().join(","); + format!( + "stylex-pass-v{STYLEX_PASS_VERSION};appRoot={};root={};include={};exclude={};dev={};layers={};prefix={}", + self.app_root.display(), + self.root_dir.display(), + globs(&self.include), + globs(&self.exclude), + self.dev, + self.use_css_layers, + self.class_name_prefix.as_deref().unwrap_or("x"), + ) + } +} + +#[derive(Debug, Default)] +pub struct StylexPassOutput { + /// `Some` = the transform changed the module; feed this to the pipeline. + pub code: Option, + /// v3 map for `code`; the splice shifts lines, so it must be forwarded. + pub map: Option, + pub rules: Vec, +} + +#[derive(Debug, Default)] +pub struct StylexAstOutput { + pub modified: bool, + pub rules: Vec, +} + +/// Dev-path seam: mutates oj's own parsed AST in place — one parse per module. +/// Synthesized nodes carry the span of the node they replace (or an empty +/// span), so the pipeline's sourcemap stays valid against the ORIGINAL source. +/// `program` must be the parse of `source_text`. +pub fn stylex_pass_ast<'a>( + allocator: &'a Allocator, + program: &mut Program<'a>, + path: &Path, + source_text: &str, + config: &StylexPassConfig, +) -> Result { + let ctx = FileContext { + filename: path, + source_text, + cwd: &config.app_root, + }; + // ProgramScoping::Rebuild (inside transform_program): Prebuilt would need + // a Semantic aliasing the &mut program (unsafe here) to save the ~2-25us + // internal SemanticBuilder pass measured on the showcase fixture modules. + match transform_program(allocator, program, &ctx, &config.options, &StdFs) { + Ok(result) => Ok(StylexAstOutput { + modified: result.modified, + rules: result.rules, + }), + Err(e) => Err(e.to_string()), + } +} + +/// String-level pass: kept for the BUILD path only (the rolldown transform +/// plugin is string-in/string-out by nature); the dev pipeline mutates its +/// own AST via `stylex_pass_ast` instead. +pub fn stylex_pass( + path: &Path, + source_text: &str, + config: &StylexPassConfig, +) -> Result { + let ctx = FileContext { + filename: path, + source_text, + cwd: &config.app_root, + }; + // The splice moves line positions, so the map is not optional here: a + // transform that reports none makes rolldown keep the pre-splice mapping. + let allocator = oxc_allocator::Allocator::default(); + match transform_source_mapped_in(&allocator, &ctx, &config.options, &StdFs, true) { + Ok(Some(result)) => Ok(StylexPassOutput { + map: result.modified.then_some(result.map).flatten(), + code: result.modified.then_some(result.code), + rules: result.rules, + }), + Ok(None) => Ok(StylexPassOutput::default()), + Err(e) => Err(e.to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use oxc_parser::Parser; + + fn config(include: &[&str], exclude: &[&str]) -> StylexPassConfig { + let s = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::>(); + StylexPassConfig::new( + PathBuf::from("/app"), + PathBuf::from("/app"), + &s(include), + &s(exclude), + true, + false, + None, + ) + .unwrap() + } + + #[test] + fn globs_match_root_relative_paths() { + let cfg = config(&["src/**"], &[]); + assert!(cfg.matches_path(Path::new("/app/src/main.ts"))); + assert!(cfg.matches_path(Path::new("/app/src/deep/Button.tsx"))); + assert!(!cfg.matches_path(Path::new("/app/lib/main.ts"))); + } + + #[test] + fn exclude_wins_over_include() { + let cfg = config(&["src/**"], &["src/vendor/**"]); + assert!(cfg.matches_path(Path::new("/app/src/a.ts"))); + assert!(!cfg.matches_path(Path::new("/app/src/vendor/b.ts"))); + } + + #[test] + fn candidate_needs_both_path_and_source_mention() { + let cfg = config(&["src/**"], &[]); + let file = Path::new("/app/src/main.ts"); + assert!(cfg.is_candidate(file, "import * as stylex from '@stylexjs/stylex';")); + assert!(!cfg.is_candidate(file, "export const x = 1;")); + assert!(!cfg.is_candidate(Path::new("/app/lib/x.ts"), "stylex")); + } + + #[test] + fn invalid_glob_is_a_config_error() { + let err = StylexPassConfig::new( + PathBuf::from("/app"), + PathBuf::from("/app"), + &["src/[".into()], + &[], + true, + false, + None, + ); + assert!(err.is_err()); + } + + #[test] + fn salt_input_changes_with_config_and_is_stable() { + let a = config(&["src/**"], &[]).salt_input(); + let b = config(&["src/**"], &[]).salt_input(); + let c = config(&["app/**"], &[]).salt_input(); + assert_eq!(a, b); + assert_ne!(a, c); + } + + #[test] + fn salt_input_changes_with_class_name_prefix() { + let base = config(&["src/**"], &[]); + let prefixed = StylexPassConfig::new( + PathBuf::from("/app"), + PathBuf::from("/app"), + &["src/**".into()], + &[], + true, + false, + Some("oj".into()), + ) + .unwrap(); + assert_ne!(base.salt_input(), prefixed.salt_input()); + } + + #[test] + fn pass_compiles_a_create_call_and_extracts_rules() { + let cfg = config(&["**"], &[]); + let src = "import * as stylex from '@stylexjs/stylex';\n\ + export const styles = stylex.create({ root: { color: 'red' } });\n"; + let out = stylex_pass(Path::new("/app/src/a.ts"), src, &cfg).unwrap(); + let code = out.code.expect("create call must modify the module"); + assert!(!code.contains("stylex.create"), "create must compile away"); + assert_eq!(out.rules.len(), 1); + assert!(out.rules[0].ltr.contains("color:red")); + assert!(code.contains(&*out.rules[0].class_name)); + } + + #[test] + fn pass_skips_non_stylex_modules_via_pre_gate() { + // Path gate passed, but no import source in the text ("stylex" alone + // is not an import source match inside the compiler's pre-gate). + let cfg = config(&["**"], &[]); + let out = stylex_pass(Path::new("/app/src/a.ts"), "const stylexish = 1;", &cfg).unwrap(); + assert!(out.code.is_none()); + assert!(out.rules.is_empty()); + } + + #[test] + fn pass_surfaces_authoring_errors() { + let cfg = config(&["**"], &[]); + let src = "import * as stylex from '@stylexjs/stylex';\n\ + const dyn = Math.random();\n\ + export const styles = stylex.create({ root: { color: dyn } });\n"; + let err = stylex_pass(Path::new("/app/src/a.ts"), src, &cfg).unwrap_err(); + assert!(!err.is_empty()); + } + + fn reprint(source: &str, path: &Path) -> String { + let allocator = Allocator::default(); + let parsed = Parser::new( + &allocator, + source, + oxc_span::SourceType::from_path(path).unwrap(), + ) + .parse(); + assert!(!parsed.panicked, "reprint parse failed"); + oxc_codegen::Codegen::new().build(&parsed.program).code + } + + #[test] + fn ast_and_string_seams_agree_on_rules_and_reprint() { + let cfg = config(&["**"], &[]); + let path = Path::new("/app/src/a.ts"); + let src = "import * as stylex from '@stylexjs/stylex';\n\ + const fade = stylex.keyframes({ from: { opacity: 0 }, to: { opacity: 1 } });\n\ + export const styles = stylex.create({\n\ + root: { color: 'red', ':hover': { color: 'blue' } },\n\ + anim: { animationName: fade },\n\ + });\n\ + export const attrs = stylex.props(styles.root, styles.anim);\n"; + + let string_out = stylex_pass(path, src, &cfg).unwrap(); + let string_code = string_out.code.expect("string seam must modify"); + + let allocator = Allocator::default(); + let parsed = Parser::new( + &allocator, + src, + oxc_span::SourceType::from_path(path).unwrap(), + ) + .parse(); + assert!(!parsed.panicked && parsed.diagnostics.is_empty()); + let mut program = parsed.program; + let ast_out = stylex_pass_ast(&allocator, &mut program, path, src, &cfg).unwrap(); + + assert!(ast_out.modified); + assert_eq!(string_out.rules, ast_out.rules, "identical rules"); + let ast_code = oxc_codegen::Codegen::new().build(&program).code; + assert_eq!( + reprint(&string_code, path), + ast_code, + "reprints byte-equal between the two seams" + ); + } + + #[test] + fn ast_pass_skips_stylex_free_modules_without_mutating() { + let cfg = config(&["**"], &[]); + let path = Path::new("/app/src/plain.ts"); + let src = "export const stylexish = 1;\n"; + let allocator = Allocator::default(); + let parsed = Parser::new( + &allocator, + src, + oxc_span::SourceType::from_path(path).unwrap(), + ) + .parse(); + let mut program = parsed.program; + let out = stylex_pass_ast(&allocator, &mut program, path, src, &cfg).unwrap(); + assert!(!out.modified); + assert!(out.rules.is_empty()); + let code = oxc_codegen::Codegen::new().build(&program).code; + assert_eq!(code, reprint(src, path), "program left untouched"); + } + + #[test] + fn class_name_prefix_changes_generated_classnames() { + let src = "import * as stylex from '@stylexjs/stylex';\n\ + export const styles = stylex.create({ root: { color: 'red' } });\n"; + let default_cfg = config(&["**"], &[]); + let prefixed = StylexPassConfig::new( + PathBuf::from("/app"), + PathBuf::from("/app"), + &["**".into()], + &[], + true, + false, + Some("oj".into()), + ) + .unwrap(); + let a = stylex_pass(Path::new("/app/src/a.ts"), src, &default_cfg).unwrap(); + let b = stylex_pass(Path::new("/app/src/a.ts"), src, &prefixed).unwrap(); + assert!(b.rules[0].class_name.starts_with("oj")); + assert_ne!(a.rules[0].class_name, b.rules[0].class_name); + } +} diff --git a/crates/oj_config/src/lib.rs b/crates/oj_config/src/lib.rs index 0f7d5d42..12a71653 100644 --- a/crates/oj_config/src/lib.rs +++ b/crates/oj_config/src/lib.rs @@ -820,6 +820,15 @@ pub fn load(root: &Path) -> Result { load_with(root, "serve", "development") } +/// Standalone `StylexConfig` as JSON, for `--stylex-config` / OJ_STYLEX_CONFIG +/// (projects whose oj/vite config cannot carry the section). +pub fn load_stylex_json(path: &Path) -> Result { + let source = std::fs::read_to_string(path) + .map_err(|e| ConfigError::Parse(path.to_path_buf(), e.to_string()))?; + serde_json::from_str(&source) + .map_err(|e| ConfigError::Schema(path.to_path_buf(), e.to_string())) +} + pub fn load_with(root: &Path, command: &str, mode: &str) -> Result { let Some(path) = CANDIDATES .iter() @@ -1376,6 +1385,49 @@ mod tests { assert!(environment_defines(&cfg, "client").is_empty()); } + #[test] + fn stylex_section_parses_camel_case_and_defaults() { + let json = r#"{"stylex":{ + "include":["src/**"], + "exclude":["src/vendor/**"], + "rootDir":"..", + "dev":true, + "useCssLayers":true, + "classNamePrefix":"oj"}}"#; + let cfg: OjConfig = serde_json::from_str(json).unwrap(); + let sx = cfg.stylex.unwrap(); + assert_eq!(sx.include, vec!["src/**".to_string()]); + assert_eq!(sx.exclude, vec!["src/vendor/**".to_string()]); + assert_eq!(sx.root_dir.as_deref(), Some("..")); + assert_eq!(sx.dev, Some(true)); + assert_eq!(sx.use_css_layers, Some(true)); + assert_eq!(sx.class_name_prefix.as_deref(), Some("oj")); + + // Every field is optional: an empty section still enables the pass. + let minimal: OjConfig = serde_json::from_str(r#"{"stylex":{}}"#).unwrap(); + let sx = minimal.stylex.unwrap(); + assert!(sx.include.is_empty() && sx.exclude.is_empty()); + assert!(sx.root_dir.is_none() && sx.dev.is_none() && sx.use_css_layers.is_none()); + assert!(sx.class_name_prefix.is_none()); + let absent: OjConfig = serde_json::from_str("{}").unwrap(); + assert!(absent.stylex.is_none()); + } + + #[test] + fn stylex_json_override_reads_the_same_schema() { + let dir = std::env::temp_dir().join(format!("oj-stylex-json-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("stylex.json"); + std::fs::write(&path, r#"{"include":["src/**"],"useCssLayers":true}"#).unwrap(); + let sx = load_stylex_json(&path).unwrap(); + assert_eq!(sx.include, vec!["src/**".to_string()]); + assert_eq!(sx.use_css_layers, Some(true)); + assert!(load_stylex_json(&dir.join("missing.json")).is_err()); + std::fs::write(&path, "not json").unwrap(); + assert!(load_stylex_json(&path).is_err()); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn json_config_loads_directly() { let dir = std::env::temp_dir().join(format!("oj-cfg-json-{}", std::process::id())); diff --git a/crates/oj_config/src/schema.rs b/crates/oj_config/src/schema.rs index 211352cf..ace52bc4 100644 --- a/crates/oj_config/src/schema.rs +++ b/crates/oj_config/src/schema.rs @@ -44,6 +44,7 @@ pub struct OjConfig { /// Vite's `html` block (`cspNonce`). pub html: Option, pub legacy: Option, + pub stylex: Option, } #[derive(Debug, Default, Clone, Deserialize)] @@ -73,6 +74,22 @@ impl From for BoolOrString { } } +/// Native StyleX pass. Presence of the section enables it. +#[derive(Debug, Default, Clone, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct StylexConfig { + /// Root-relative globs selecting the files the pass may transform. + pub include: Vec, + pub exclude: Vec, + /// Base dir for the globs (and debug names later); defaults to the root. + pub root_dir: Option, + /// Defaults to NODE_ENV != "production". + pub dev: Option, + pub use_css_layers: Option, + /// Generated class-name prefix (compiler default: "x"). + pub class_name_prefix: Option, +} + #[derive(Debug, Clone, Deserialize)] #[serde(untagged)] pub enum StringOrList { diff --git a/crates/oj_css/src/lib.rs b/crates/oj_css/src/lib.rs index e936db21..d34fac74 100644 --- a/crates/oj_css/src/lib.rs +++ b/crates/oj_css/src/lib.rs @@ -2193,4 +2193,5 @@ mod tests { // The rebased (dev) path parses the same way. assert!(compile_css_rebased("/src/vendor.css", src, false).is_ok()); } + } diff --git a/crates/oj_server/Cargo.toml b/crates/oj_server/Cargo.toml index 79f2aef3..3bd9593a 100644 --- a/crates/oj_server/Cargo.toml +++ b/crates/oj_server/Cargo.toml @@ -20,6 +20,7 @@ notify.workspace = true serde_json.workspace = true json-strip-comments = "3.1.2" blake3.workspace = true +fru.workspace = true simdutf8.workspace = true glob = "0.3.4" regex = "1.11" diff --git a/crates/oj_server/src/assets/plugin-host.mjs b/crates/oj_server/src/assets/plugin-host.mjs index 66758497..9020199a 100644 --- a/crates/oj_server/src/assets/plugin-host.mjs +++ b/crates/oj_server/src/assets/plugin-host.mjs @@ -365,6 +365,9 @@ const OJ_NATIVE_PLUGIN_NAMES = new Set([ "vite:react-swc", "vite:react-swc:resolve-runtime", ]); +// Extra names the server reimplements natively for this app (e.g. a JS +// StyleX plugin when the native stylex pass is enabled). +for (const name of initial.nativePluginNames ?? []) OJ_NATIVE_PLUGIN_NAMES.add(name); // Dev-tooling plugins oj cannot host: they drive a full Vite dev server (ws // error overlay, file watcher, a worker running tsc/eslint) that oj does not diff --git a/crates/oj_server/src/lib.rs b/crates/oj_server/src/lib.rs index 1353851a..19613f27 100644 --- a/crates/oj_server/src/lib.rs +++ b/crates/oj_server/src/lib.rs @@ -23,6 +23,7 @@ pub mod pkg_bundle; pub mod pkg_rolldown; pub mod plugins; pub mod sidecar; +pub mod stylex; pub mod svgr; use oj_graph::{HmrDecision, ModuleGraph}; use oj_resolver::OjResolver; @@ -228,6 +229,9 @@ pub struct DevServer { pub bundle: bool, pub host: Option, pub config: Option, + /// StylexConfig-as-JSON override (--stylex-config / OJ_STYLEX_CONFIG); + /// takes precedence over the config file's `stylex` section. + pub stylex_config: Option, /// Enable the experimental on-disk module cache (off by default). pub enable_cache: bool, /// Force the on-disk module cache off even if enabled. @@ -271,6 +275,7 @@ struct ServerState { preprocess: tokio::sync::OnceCell>, svelte: tokio::sync::OnceCell>, tailwind_urls: Mutex>, + stylex: Option>, has_postcss: bool, scss_additional_data: Option, sass_additional_data: Option, @@ -527,6 +532,16 @@ impl DevServer { let mut env_defines_digest = digest_defines(&defines); oj_compiler::set_import_meta_env(defines); + let stylex_state = resolve_stylex_state(self.stylex_config.as_deref(), &config, &root) + .map_err(|e| anyhow::anyhow!("{e}"))?; + if let Some(sx) = &stylex_state { + println!( + " stylex: native pass enabled ({} include glob(s))", + sx.pass.include.len() + ); + sx.seed_fake_rules_from_env(&root); + } + let server_cfg = config.server.clone().unwrap_or_default(); let port = self.port.or(server_cfg.port).unwrap_or(5199); let strict_port = oj_config::server_strict_port(&config); @@ -590,6 +605,11 @@ impl DevServer { "pluginsFormat": plugins_format, "ojStartMode": is_start, }); + if stylex_state.is_some() { + // The native pass replaces the pilot's Babel Vite plugin; hosting + // both would transform stylex modules twice. + plugin_cfg["nativePluginNames"] = serde_json::json!(["lovable:stylex-transform"]); + } if let Some(dir) = &ssr_bridge_dir { plugin_cfg["ssrBridge"] = serde_json::json!({ "dir": dir.display().to_string() }); } @@ -864,6 +884,7 @@ impl DevServer { preprocess: tokio::sync::OnceCell::new(), svelte: tokio::sync::OnceCell::new(), tailwind_urls: Mutex::new(std::collections::HashSet::new()), + stylex: stylex_state, has_postcss: has_postcss_config(&root), scss_additional_data: oj_config::css_additional_data(&config, "scss"), sass_additional_data: oj_config::css_additional_data(&config, "sass"), @@ -1020,6 +1041,7 @@ impl DevServer { .route("/@oj/worker.js", get(serve_worker_chunk)) .route("/@oj/routes.js", get(serve_oj_routes)) .route("/@oj/server-fn.js", get(|| async { js(SERVER_FN_JS) })) + .route("/@oj/stylex.css", get(serve_stylex_css)) .route( "/@oj/lingui-macro-shim.js", get(|| async { js(LINGUI_MACRO_SHIM_JS) }), @@ -3218,7 +3240,14 @@ async fn serve_path( match tokio::fs::read(&file).await { Ok(bytes) if ext == "html" => serve_html(&state, bytes, &url_of(&state.root, &file), &file).await, Ok(bytes) if ext == "css" => { - let source = String::from_utf8_lossy(&bytes).into_owned(); + let mut source = String::from_utf8_lossy(&bytes).into_owned(); + if let Some(sx) = &state.stylex { + if stylex::has_directive(&source) { + stylex_flush_dirty(&state).await; + sx.record_css_url(&url_of(&state.root, &file)); + source = stylex::substitute_directive(&source, &sx.assembled()); + } + } if is_tailwind_css(&source) { let url = url_of(&state.root, &file); return match compile_tailwind(&state, &url, &source).await { @@ -3408,6 +3437,7 @@ async fn ensure_module( css_exports: Vec::new(), fs_allow: Vec::new(), watch_files: Vec::new(), + stylex_rules: Vec::new(), }); register_in_graph(state, url, &module); return Ok((String::new(), module)); @@ -3442,6 +3472,7 @@ async fn ensure_module( css_exports: Vec::new(), fs_allow: Vec::new(), watch_files: Vec::new(), + stylex_rules: Vec::new(), }); register_in_graph(state, url, &module); return Ok((String::new(), module)); @@ -3473,6 +3504,7 @@ async fn ensure_module( css_exports: Vec::new(), fs_allow: Vec::new(), watch_files: Vec::new(), + stylex_rules: Vec::new(), }) } else { Arc::new(CachedModule { @@ -3486,6 +3518,7 @@ async fn ensure_module( css_exports: Vec::new(), fs_allow: Vec::new(), watch_files: Vec::new(), + stylex_rules: Vec::new(), }) }; register_in_graph(state, url, &module); @@ -3506,6 +3539,7 @@ async fn ensure_module( .map(|(_, _, k)| k.clone()); if let Some(key) = cached_key { if let Some(module) = memory_get(state, url, &key) { + stylex_register(state, file, &module); register_in_graph(state, url, &module); return Ok((key, module)); } @@ -3550,7 +3584,7 @@ async fn ensure_module( } else { None }; - let source = match plugin_loaded { + let mut source = match plugin_loaded { Some(code) => code, None => bytes_to_string( tokio::fs::read(file) @@ -3559,6 +3593,21 @@ async fn ensure_module( ) .map_err(|err| format!("read error for {url}: {err}"))?, }; + // `@stylex;` substitution runs before the tailwind/postcss handling so the + // sidecars see plain CSS. Such css is generation-dependent: it must never + // enter the mtime fast path (the file is unchanged when the sheet changes); + // content-keyed caching below stays sound because the sheet is inlined. + let mut stylex_css = false; + if file.extension().and_then(|e| e.to_str()) == Some("css") { + if let Some(sx) = &state.stylex { + if stylex::has_directive(&source) { + stylex_flush_dirty(state).await; + sx.record_css_url(url); + source = stylex::substitute_directive(&source, &sx.assembled()); + stylex_css = true; + } + } + } if !state.bundle && source.contains("import.meta.glob") { let patterns: Vec = oj_compiler::glob::glob_patterns(&source, file) .iter() @@ -3585,6 +3634,7 @@ async fn ensure_module( css_exports: Vec::new(), fs_allow: Vec::new(), watch_files: Vec::new(), + stylex_rules: Vec::new(), }); register_in_graph(state, url, &module); return Ok((String::new(), module)); @@ -3612,16 +3662,25 @@ async fn ensure_module( } else { mode.to_string() }; + // Fold the stylex config + pass identity into the key: rules ride the + // cached module, so a config change or pass bump must invalidate. + let mode_key = match &state.stylex { + Some(sx) => format!("{mode_key}:sx{}", sx.cache_salt), + None => mode_key, + }; let key = state.cache.key(source.as_bytes(), url, &mode_key); if let Some((mtime, size)) = stamp { - state - .mtime_keys - .lock() - .unwrap() - .insert(url.to_string(), (mtime, size, key.clone())); + if !stylex_css { + state + .mtime_keys + .lock() + .unwrap() + .insert(url.to_string(), (mtime, size, key.clone())); + } } if let Some(module) = memory_get(state, url, &key) { + stylex_register(state, file, &module); register_in_graph(state, url, &module); return Ok((key, module)); } @@ -3633,6 +3692,7 @@ async fn ensure_module( let _guard = lock.lock().await; if let Some(module) = memory_get(state, url, &key) { + stylex_register(state, file, &module); register_in_graph(state, url, &module); return Ok((key, module)); } @@ -3647,12 +3707,17 @@ async fn ensure_module( // those. Modules whose imports are all real files (svgr on disk, plain // source, deps) keep the fast persistent cache (Vite has no cross-restart // transform cache at all; this preserves oj's where it is sound). - if let Some(module) = state.persistent_cache.then(|| state.cache.get(&key)).flatten() { + if let Some(module) = state + .persistent_cache + .then(|| state.cache.get(&key)) + .flatten() + { let module = Arc::new(module); let needs_retransform = state.plugins_have_transform && !is_dep_early && imports_a_plugin_virtual(&module.imports, &state.root, &state.dir_cache); if !needs_retransform { + stylex_register(state, file, &module); memory_put(state, url, &key, &module); register_in_graph(state, url, &module); replay_module_parsed(state, file, &key, is_dep_early, is_server).await; @@ -3673,6 +3738,7 @@ async fn ensure_module( css_exports: Vec::new(), fs_allow: Vec::new(), watch_files: Vec::new(), + stylex_rules: Vec::new(), }); if state.persistent_cache { let _ = state @@ -3817,6 +3883,7 @@ async fn ensure_module( css_exports: Vec::new(), fs_allow: Vec::new(), watch_files: Vec::new(), + stylex_rules: Vec::new(), }); register_in_graph(state, url, &module); return Ok((String::new(), module)); @@ -3847,6 +3914,7 @@ async fn ensure_module( file.to_path_buf() }; let url_owned = url.to_string(); + let stylex_pass = state.stylex.as_ref().map(|sx| sx.pass.clone()); let sass_data = sass_additional_data_for(state, &url_owned); let sass_load_paths = sass_load_paths_for(state, &url_owned); let css_resolve = state.css_resolve.clone(); @@ -3888,6 +3956,7 @@ async fn ensure_module( css_exports: Vec::new(), fs_allow: Vec::new(), watch_files: Vec::new(), + stylex_rules: Vec::new(), }); } if is_css { @@ -3928,6 +3997,7 @@ async fn ensure_module( css_exports: output.exports.unwrap_or_default(), fs_allow: Vec::new(), watch_files: Vec::new(), + stylex_rules: Vec::new(), }); } // The first relative import nothing on disk satisfies. Vite's import @@ -4032,6 +4102,7 @@ async fn ensure_module( map_data_url: None, fs_allow: fs_allow_from(&factory.imports), watch_files: Vec::new(), + stylex_rules: Vec::new(), imports: factory.imports, require_map: factory.require_map, css_exports: Vec::new(), @@ -4106,11 +4177,13 @@ async fn ensure_module( sourcemap: true, ssr: false, jsx: oj_compiler::JsxConfig::default(), + stylex: None, } } else { oj_compiler::CompileOptions::dev() }; opts.jsx = jsx_config; + opts.stylex = stylex_pass.as_ref(); oj_compiler::compile_module_with_maps( &file_owned, interopped.as_deref().unwrap_or(&source), @@ -4133,6 +4206,7 @@ async fn ensure_module( map_data_url: output.map_data_url, fs_allow: fs_allow_from(&output.imports), watch_files: Vec::new(), + stylex_rules: output.stylex_rules, imports: output.imports, kind: if is_svelte { "svelte".into() @@ -4173,6 +4247,7 @@ async fn ensure_module( .cache_writes .try_send((key.clone(), Arc::clone(&module))); } + stylex_register(state, file, &module); memory_put(state, url, &key, &module); register_in_graph(state, url, &module); state @@ -4314,6 +4389,11 @@ fn module_weight(module: &CachedModule) -> usize { + strs(&module.watch_files) + pairs(&module.require_map) + pairs(&module.css_exports) + + module + .stylex_rules + .iter() + .map(|r| r.class_name.len() + r.ltr.len() + r.rtl.as_ref().map_or(0, |s| s.len()) + 64) + .sum::() } fn memory_cache_budget() -> usize { @@ -4495,6 +4575,55 @@ async fn serve_css_wrapper(state: &Arc, file: &Path, url: &str) -> .into_response() } +fn resolve_stylex_state( + override_path: Option<&Path>, + config: &oj_config::OjConfig, + root: &Path, +) -> Result>, String> { + let default_dev = std::env::var("NODE_ENV").as_deref() != Ok("production"); + Ok( + stylex::resolve_pass_config(override_path, config, root, default_dev)? + .map(|pass| Arc::new(stylex::StylexState::new(pass))), + ) +} + +/// Feed a compile-pipeline module's rules into the registry; runs on fresh +/// compiles and on every cache-hit path, so warm starts rebuild the registry. +fn stylex_register(state: &ServerState, file: &Path, module: &CachedModule) { + if let Some(sx) = &state.stylex { + sx.register(file, &module.stylex_rules); + } +} + +/// Re-ensure watcher-dirtied stylex modules so the registry is current before +/// a sheet is assembled, regardless of which client fetch lands first. +async fn stylex_flush_dirty(state: &Arc) { + let Some(sx) = &state.stylex else { return }; + for path in sx.take_dirty() { + if path.exists() { + let url = url_of(&state.root, &path); + let _ = Box::pin(ensure_module(state, &path, &url)).await; + } else { + sx.remove(&path); + } + } +} + +async fn serve_stylex_css(State(state): State>) -> Response { + let Some(sx) = &state.stylex else { + return (StatusCode::NOT_FOUND, "oj: stylex is not enabled").into_response(); + }; + stylex_flush_dirty(&state).await; + ( + [ + (header::CONTENT_TYPE, "text/css"), + (header::CACHE_CONTROL, "no-cache"), + ], + sx.assembled(), + ) + .into_response() +} + pub fn has_postcss_config(root: &Path) -> bool { find_postcss_config(root).is_some() } @@ -5815,7 +5944,7 @@ async fn serve_oj_routes(State(state): State>) -> Response { } } -fn dev_compile_opts(state: &ServerState) -> oj_compiler::CompileOptions { +fn dev_compile_opts(state: &ServerState) -> oj_compiler::CompileOptions<'static> { let mut opts = oj_compiler::CompileOptions::dev(); opts.jsx = state.jsx.clone(); opts @@ -7602,6 +7731,40 @@ async fn decide( } } + if let Some(sx) = &state.stylex { + let mut gated = false; + for path in &paths { + if path.components().any(|c| { + let c = c.as_os_str(); + c == "node_modules" || c == ".oj-cache" || c == "dist" + }) { + continue; + } + let compilable = path + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| COMPILABLE.contains(&e)); + if !compilable || !sx.pass.matches_path(path) { + continue; + } + if path.exists() { + sx.mark_dirty(path); + } else { + sx.remove(path); + } + gated = true; + } + if gated { + // Mirror the tailwind repush: the client refetches each sheet, and + // the serving paths flush dirty modules before assembling. + let timestamp = now_millis() as u64; + for url in sx.css_urls() { + updates.push(update_entry("css-update", &url, timestamp)); + } + updates.push(update_entry("css-update", "/@oj/stylex.css", timestamp)); + } + } + for path in &paths { // Vite's default watch ignores: `**/node_modules/**` and `**/.git/**` // at any depth (a nested package's node_modules included). diff --git a/crates/oj_server/src/stylex.rs b/crates/oj_server/src/stylex.rs new file mode 100644 index 00000000..232b5826 --- /dev/null +++ b/crates/oj_server/src/stylex.rs @@ -0,0 +1,443 @@ +// SPDX-License-Identifier: MIT +// StyleX serving: the per-module rule registry and sheet assembly. + +use std::collections::{BTreeMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use fru::assemble::{AssembleConfig, LayersConfig}; + +pub use oj_compiler::stylex::{StylexPassConfig, StylexRule}; + +/// The dev-server side of the StyleX pass: the pass config, the cache salt, +/// and the per-module rule registry the served stylesheet is assembled from. +pub struct StylexState { + pub pass: StylexPassConfig, + /// blake3 of the config identity + pass version, folded into cache keys so + /// a config or pass bump invalidates persisted modules. + pub cache_salt: String, + assemble_cfg: AssembleConfig, + registry: Mutex, +} + +#[derive(Default)] +struct StylexRegistry { + /// BTreeMap: file-path order pins a deterministic rule feed into assembly + /// regardless of request order (matches the compiler's RuleRegistry). + rules: BTreeMap>, + /// Served css URLs whose source contained the `@stylex;` directive; each + /// gets a css-update repush when any stylex-gated module changes. + css_urls: HashSet, + /// Watcher-marked modules, re-ensured lazily before the next assembly. + dirty: HashSet, + generation: u64, + assembled: Option<(u64, String)>, +} + +fn assemble_config(pass: &StylexPassConfig) -> AssembleConfig { + AssembleConfig { + use_layers: if pass.use_css_layers { + LayersConfig::On { + before: Vec::new(), + after: Vec::new(), + prefix: None, + } + } else { + LayersConfig::Off + }, + ..AssembleConfig::default() + } +} + +/// One-shot assembly for the build path (the dev server memoizes via +/// `StylexState::assembled` instead). +pub fn assemble_sheet(rules: &[StylexRule], pass: &StylexPassConfig) -> Result { + fru::assemble::assemble(rules, &assemble_config(pass)).map_err(|e| e.to_string()) +} + +/// Shared `stylex` config resolution for dev and build: `--stylex-config` / +/// OJ_STYLEX_CONFIG override the config file's `stylex` section. +/// The zero-config default: enabled iff package.json depends on +/// @stylexjs/stylex, over the whole project except node_modules. +fn auto_config(root: &Path) -> Option { + let manifest = std::fs::read_to_string(root.join("package.json")).ok()?; + let manifest: serde_json::Value = serde_json::from_str(&manifest).ok()?; + let has_dep = ["dependencies", "devDependencies"].iter().any(|key| { + manifest + .get(key) + .and_then(|deps| deps.get("@stylexjs/stylex")) + .is_some() + }); + if !has_dep { + return None; + } + println!(" stylex: auto-enabled (@stylexjs/stylex dependency detected)"); + Some(oj_config::StylexConfig { + include: vec!["**".to_string()], + exclude: vec!["node_modules/**".to_string()], + ..oj_config::StylexConfig::default() + }) +} + +pub fn resolve_pass_config( + override_path: Option<&Path>, + config: &oj_config::OjConfig, + root: &Path, + default_dev: bool, +) -> Result, String> { + let env_path = std::env::var("OJ_STYLEX_CONFIG") + .ok() + .filter(|v| !v.is_empty()) + .map(PathBuf::from); + let schema = match override_path.map(Path::to_path_buf).or(env_path) { + Some(path) => { + let path = if path.is_absolute() { + path + } else { + root.join(path) + }; + Some(oj_config::load_stylex_json(&path).map_err(|e| e.to_string())?) + } + None => config.stylex.clone(), + }; + let schema = match schema { + Some(schema) => schema, + // Auto pickup: a project that depends on @stylexjs/stylex gets the + // pass with defaults; a `stylex` config section overrides everything. + None => match auto_config(root) { + Some(schema) => schema, + None => return Ok(None), + }, + }; + let root_dir = match &schema.root_dir { + Some(dir) => { + let dir = PathBuf::from(dir); + if dir.is_absolute() { + dir + } else { + root.join(dir) + } + } + None => root.to_path_buf(), + }; + let pass = StylexPassConfig::new( + root.to_path_buf(), + root_dir, + &schema.include, + &schema.exclude, + schema.dev.unwrap_or(default_dev), + schema.use_css_layers.unwrap_or(false), + schema.class_name_prefix.clone(), + )?; + Ok(Some(pass)) +} + +impl StylexState { + pub fn new(pass: StylexPassConfig) -> Self { + let cache_salt = blake3::hash(pass.salt_input().as_bytes()) + .to_hex() + .to_string(); + let assemble_cfg = assemble_config(&pass); + Self { + pass, + cache_salt, + assemble_cfg, + registry: Mutex::new(StylexRegistry::default()), + } + } + + /// Idempotent: bumps the generation (dropping the memoized sheet) only + /// when this module's rules actually changed. + pub fn register(&self, file: &Path, rules: &[StylexRule]) { + let mut reg = self.registry.lock().unwrap(); + reg.dirty.remove(file); + let changed = match reg.rules.get(file) { + Some(prev) => prev.as_slice() != rules, + None => !rules.is_empty(), + }; + if !changed { + return; + } + if rules.is_empty() { + reg.rules.remove(file); + } else { + reg.rules.insert(file.to_path_buf(), rules.to_vec()); + } + reg.generation += 1; + reg.assembled = None; + } + + pub fn remove(&self, file: &Path) { + let mut reg = self.registry.lock().unwrap(); + reg.dirty.remove(file); + if reg.rules.remove(file).is_some() { + reg.generation += 1; + reg.assembled = None; + } + } + + pub fn mark_dirty(&self, file: &Path) { + self.registry + .lock() + .unwrap() + .dirty + .insert(file.to_path_buf()); + } + + pub fn take_dirty(&self) -> Vec { + self.registry.lock().unwrap().dirty.drain().collect() + } + + pub fn record_css_url(&self, url: &str) { + self.registry + .lock() + .unwrap() + .css_urls + .insert(url.to_string()); + } + + pub fn has_css_url(&self, url: &str) -> bool { + self.registry.lock().unwrap().css_urls.contains(url) + } + + pub fn css_urls(&self) -> Vec { + let mut urls: Vec = self + .registry + .lock() + .unwrap() + .css_urls + .iter() + .cloned() + .collect(); + urls.sort(); + urls + } + + pub fn generation(&self) -> u64 { + self.registry.lock().unwrap().generation + } + + /// The full assembled sheet, memoized per registry generation. + pub fn assembled(&self) -> String { + let mut reg = self.registry.lock().unwrap(); + if let Some((generation, css)) = ®.assembled { + if *generation == reg.generation { + return css.clone(); + } + } + let all: Vec = reg.rules.values().flatten().cloned().collect(); + // An assemble error (circular defineConsts reference) is an authoring + // error; keep serving a sheet so the page stays usable. + let css = match fru::assemble::assemble(&all, &self.assemble_cfg) { + Ok(css) => css, + Err(e) => { + eprintln!("oj: stylex assemble error: {e}"); + format!("/* oj: stylex assemble error: {e} */") + } + }; + reg.assembled = Some((reg.generation, css.clone())); + css + } + + /// TEMPORARY smoke seam: OJ_STYLEX_FAKE_RULES (a JSON StylexRule array) + /// seeds the registry at boot so serving is testable before the real pass. + pub fn seed_fake_rules_from_env(&self, root: &Path) { + let Ok(json) = std::env::var("OJ_STYLEX_FAKE_RULES") else { + return; + }; + match serde_json::from_str::>(&json) { + Ok(rules) => { + println!( + " stylex: seeded {} fake rule(s) from OJ_STYLEX_FAKE_RULES", + rules.len() + ); + self.register(&root.join("__oj_stylex_fake__"), &rules); + } + Err(e) => eprintln!("oj: OJ_STYLEX_FAKE_RULES is not a StylexRule array: {e}"), + } + } +} + +pub fn has_directive(source: &str) -> bool { + source.contains("@stylex;") +} + +/// Replace every `@stylex;` at-rule (the postcss-plugin convention) with the +/// assembled sheet; the caller checks `has_directive` first. +pub fn substitute_directive(source: &str, sheet: &str) -> String { + source.replace("@stylex;", sheet) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rule(class: &str, ltr: &str, priority: f64) -> StylexRule { + StylexRule { + class_name: class.into(), + ltr: ltr.into(), + rtl: None, + const_key: None, + const_val: None, + priority, + } + } + + fn state_with(use_css_layers: bool) -> StylexState { + StylexState::new( + StylexPassConfig::new( + PathBuf::from("/app"), + PathBuf::from("/app"), + &["src/**".into()], + &[], + true, + use_css_layers, + None, + ) + .unwrap(), + ) + } + + fn state() -> StylexState { + state_with(false) + } + + #[test] + fn register_is_idempotent_and_generation_tracks_change() { + let sx = state(); + let a = Path::new("/app/src/a.tsx"); + assert_eq!(sx.generation(), 0); + sx.register(a, &[rule("x1", ".x1{color:red}", 3000.0)]); + assert_eq!(sx.generation(), 1); + sx.register(a, &[rule("x1", ".x1{color:red}", 3000.0)]); + assert_eq!(sx.generation(), 1, "same rules must not bump"); + sx.register(a, &[rule("x2", ".x2{color:blue}", 3000.0)]); + assert_eq!(sx.generation(), 2); + sx.register(a, &[]); + assert_eq!(sx.generation(), 3, "losing all rules is a change"); + sx.register(Path::new("/app/src/b.tsx"), &[]); + assert_eq!(sx.generation(), 3, "empty on an unknown module is a no-op"); + } + + #[test] + fn assembly_sorts_by_priority_dedupes_and_polyfills_specificity() { + let sx = state(); + sx.register( + Path::new("/app/src/a.tsx"), + &[ + rule("x2", ".x2{margin:0}", 1000.0), + rule("x1", ".x1{color:red}", 3000.0), + ], + ); + sx.register( + Path::new("/app/src/b.tsx"), + &[rule("x1", ".x1{color:red}", 3000.0)], + ); + // Layers off: the second priority band gets the :not(#\#) polyfill. + assert_eq!(sx.assembled(), ".x2{margin:0}\n.x1:not(#\\#){color:red}"); + } + + #[test] + fn assembly_with_layers_emits_layer_header_and_blocks() { + let sx = state_with(true); + sx.register( + Path::new("/app/src/a.tsx"), + &[rule("x1", ".x1{color:red}", 3000.0)], + ); + assert_eq!( + sx.assembled(), + "\n@layer priority1;\n@layer priority1{\n.x1{color:red}\n}" + ); + } + + #[test] + fn assembly_substitutes_define_consts() { + let sx = state(); + let const_rule = StylexRule { + class_name: "xconsthash".into(), + ltr: String::new().into(), + rtl: None, + const_key: Some("--spacing".into()), + const_val: Some(Box::new(serde_json::json!("8px"))), + priority: 0.0, + }; + sx.register(Path::new("/app/src/tokens.stylex.ts"), &[const_rule]); + sx.register( + Path::new("/app/src/a.tsx"), + &[rule("x1", ".x1{padding:var(--xconsthash)}", 3000.0)], + ); + assert_eq!(sx.assembled(), ".x1{padding:8px}"); + } + + #[test] + fn assembly_is_memoized_per_generation() { + let sx = state(); + sx.register( + Path::new("/app/src/a.tsx"), + &[rule("x1", ".x1{color:red}", 0.0)], + ); + let first = sx.assembled(); + assert_eq!(sx.assembled(), first); + sx.remove(Path::new("/app/src/a.tsx")); + assert_eq!(sx.assembled(), ""); + } + + #[test] + fn cache_salt_differs_across_configs() { + let a = state().cache_salt; + let b = StylexState::new( + StylexPassConfig::new( + PathBuf::from("/app"), + PathBuf::from("/app"), + &["app/**".into()], + &[], + true, + false, + None, + ) + .unwrap(), + ) + .cache_salt; + let c = StylexState::new( + StylexPassConfig::new( + PathBuf::from("/app"), + PathBuf::from("/app"), + &["src/**".into()], + &[], + true, + false, + Some("oj".into()), + ) + .unwrap(), + ) + .cache_salt; + assert_ne!(a, b); + assert_ne!(a, c, "classNamePrefix must invalidate the cache"); + assert_eq!(a.len(), 64); + } + + #[test] + fn directive_substitution_replaces_the_at_rule_in_place() { + let src = "@layer stylex { @stylex; }\nbody { margin: 0 }\n"; + assert!(has_directive(src)); + let out = substitute_directive(src, ".x1{color:red}\n"); + assert_eq!( + out, + "@layer stylex { .x1{color:red}\n }\nbody { margin: 0 }\n" + ); + // "@stylexjs" in an import path is not the directive. + assert!(!has_directive("@import \"@stylexjs/open-props\";")); + } + + #[test] + fn dirty_set_drains_and_forgets_removed_modules() { + let sx = state(); + let a = PathBuf::from("/app/src/a.tsx"); + sx.mark_dirty(&a); + assert_eq!(sx.take_dirty(), vec![a.clone()]); + assert!(sx.take_dirty().is_empty()); + sx.mark_dirty(&a); + sx.register(&a, &[rule("x1", ".x1{}", 0.0)]); + assert!(sx.take_dirty().is_empty(), "register clears dirty"); + } +} diff --git a/crates/stylex/Cargo.toml b/crates/stylex/Cargo.toml new file mode 100644 index 00000000..f6cdb5ac --- /dev/null +++ b/crates/stylex/Cargo.toml @@ -0,0 +1,27 @@ +[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_str = "0.146.0" +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..1c0e9741 --- /dev/null +++ b/crates/stylex/src/api.rs @@ -0,0 +1,383 @@ +//! 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::{ATOMS_SOURCE, ImportTable, scan_imports}; +use crate::module_resolution::{FsProvider, THEME_FILE_EXTENSION}; +use crate::options::{ImportSource, 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)>, +} + +/// What [`transform_source_in_with_map`] renders into `SourceCompileResult::map`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MapMode { + Off, + /// Mappings and `sources` only; the consumer supplies `sourcesContent`. + Mappings, + WithContent, +} + +pub fn might_contain_stylex(source: &str, options: &ResolvedOptions) -> bool { + pre_gate(source, options).is_some() +} + +/// `None` = the file cannot reference any stylex import source; `Some(sx)` +/// carries whether the sx-prop needle hit, sparing the dormancy check a rescan. +fn pre_gate(source: &str, options: &ResolvedOptions) -> Option { + let bytes = source.as_bytes(); + // The sx prop compiles with no stylex import in the file at all. + if options + .sx_prop_name + .as_deref() + .is_some_and(|sx| memmem::find(bytes, sx.as_bytes()).is_some()) + { + return Some(true); + } + // Atoms compile off a hardcoded source no importSources setting covers; a + // rewritable import source must carry the hardcoded `.stylex` suffix. + let needles = || { + options + .import_sources + .iter() + .map(ImportSource::from_specifier) + .chain(std::iter::once(ATOMS_SOURCE)) + .chain(options.rewrite_aliases.then_some(THEME_FILE_EXTENSION)) + }; + // A needle containing another (or repeating an earlier one) can only hit + // where that one hits: the default set collapses to "stylex" alone. + let hit = needles().enumerate().any(|(i, needle)| { + let redundant = needles().enumerate().any(|(j, other)| { + (other.len() < needle.len() && needle.contains(other)) || (j < i && other == needle) + }); + !redundant && memmem::find(bytes, needle.as_bytes()).is_some() + }); + // A `\u`/`\x` escape can cook into an import-source match the raw needles + // miss (`"@stylexjs/stylex"`); parse and decide post-parse. + let escapes = + || memchr::memchr_iter(b'\\', bytes).any(|i| matches!(bytes.get(i + 1), Some(b'u' | b'x'))); + (hit || escapes()).then_some(false) +} + +/// `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> { + let mode = if want_map { + MapMode::WithContent + } else { + MapMode::Off + }; + transform_source_in_with_map(allocator, ctx, options, fs, mode) +} + +/// [`transform_source_mapped_in`] with the map's content policy chosen by the caller. +pub fn transform_source_in_with_map( + allocator: &Allocator, + ctx: &FileContext<'_>, + options: &ResolvedOptions, + fs: &dyn FsProvider, + map_mode: MapMode, +) -> Result, StylexError> { + let want_map = map_mode != MapMode::Off; + let Some(sx_hit) = pre_gate(ctx.source_text, options) else { + 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)? + }; + let Some(imports) = scan_unless_dormant(&program, sx_hit, options)? else { + 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_with_imports( + &program, + options, + Some(filename), + ctx.cwd.to_string_lossy().replace('\\', "/"), + imports, + ); + 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(), + (map_mode == MapMode::WithContent).then_some(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 { + let Some(sx_hit) = pre_gate(ctx.source_text, options) else { + return Ok(CompileResult { + modified: false, + rules: Vec::new(), + create_objects: Vec::new(), + }); + }; + let Some(imports) = scan_unless_dormant(program, sx_hit, options)? else { + 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_with_imports(program_ref, options, Some(filename), cwd, imports); + 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) + }; + { + let _t = crate::timings::start(crate::timings::Stage::ApplyPlan); + 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 (`None`): no stylex binding, no possible sx prop (it compiles with no +// import at all), and no `rewriteAliases` (rewrites imports with no binding). +fn scan_unless_dormant( + program: &Program<'_>, + sx_hit: bool, + options: &ResolvedOptions, +) -> Result, StylexError> { + let _t = crate::timings::start(crate::timings::Stage::ImportScan); + let imports = scan_imports(program, options)?; + Ok((options.rewrite_aliases || sx_hit || !imports.is_dormant()).then_some(imports)) +} + +/// Serializes splice positions as a v3 sourcemap, inlining the original when +/// `source_text` is given. +fn render_sourcemap( + map: &crate::transform::js_out::SpliceMap, + filename: &str, + source_text: Option<&str>, +) -> String { + let tokens: Vec = map + .tokens + .iter() + .map(|&(dst_line, dst_col, src_line, src_col)| { + oxc_sourcemap::Token::new(dst_line, dst_col, src_line, src_col, Some(0), None) + }) + .collect(); + oxc_sourcemap::SourceMap::new( + Some(filename.into()), + Vec::new(), + None, + vec![filename.into()], + vec![source_text.map(Into::into)], + tokens.into_boxed_slice(), + None, + ) + .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)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn options(json: serde_json::Value) -> ResolvedOptions { + crate::options::CompilerOptions::from_json(&json) + .unwrap() + .resolve() + .unwrap() + } + + #[test] + fn pre_gate_admits_every_needle_and_reports_sx() { + let opts = ResolvedOptions::default(); + assert_eq!(pre_gate("export const x = 1;\n", &opts), None); + for (source, sx) in [ + ("import * as s from '@stylexjs/stylex';", false), + ("import s from 'stylex';", false), + ("import { color } from '@stylexjs/atoms';", false), + ("const t = './tokens.stylex';", false), + ("
", true), + ("const tsx = 1;", true), + ("const s = '\\u0040stylexjs/stylex';", false), + ("const s = '\\x40';", false), + ] { + assert_eq!(pre_gate(source, &opts), Some(sx), "{source}"); + } + } + + #[test] + fn pre_gate_keeps_non_default_needles_and_honours_sx_off() { + let opts = options(serde_json::json!({ + "importSources": ["foo-bar", { "from": "my-stylex-lib", "as": "css" }], + "sxPropName": false, + })); + assert_eq!( + pre_gate("import { css } from 'foo-bar';", &opts), + Some(false) + ); + assert_eq!( + pre_gate("import { css } from 'my-stylex-lib';", &opts), + Some(false) + ); + assert_eq!(pre_gate("
", &opts), None); + let custom_sx = options(serde_json::json!({ "sxPropName": "css" })); + assert_eq!(pre_gate("
", &custom_sx), Some(true)); + assert_eq!(pre_gate("
", &custom_sx), None); + } +} diff --git a/crates/stylex/src/assemble.rs b/crates/stylex/src/assemble.rs new file mode 100644 index 00000000..af6298fa --- /dev/null +++ b/crates/stylex/src/assemble.rs @@ -0,0 +1,1585 @@ +// parity: stylex-0.19.0 packages/@stylexjs/babel-plugin/src/index.js processStylexRules + +use std::cmp::Ordering; +use std::collections::hash_map::Entry; +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::hash::{BuildHasher, BuildHasherDefault}; +use std::sync::{Arc, Weak}; + +use crate::fxhash::{FxHashMap, FxHashSet}; +use crate::jsrt::{ASCII_LOCALE_KEYS, locale_key, utf16_cmp}; +use crate::rules::StylexRule; +use crate::timings::{self, Stage}; + +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 { + fn has(text: &str) -> bool { + text.contains("--stylex-logical-") + && (text.contains(LOGICAL_FLOAT_START_VAR) || text.contains(LOGICAL_FLOAT_END_VAR)) + } + has(&r.ltr) || r.rtl.as_deref().is_some_and(has) +} + +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, + } +} + +// The common shape (no rtl, no theme pair) is pushed as slices; the rest takes +// rule_chunk. `", ."` is a literal every theme-pair match contains. +fn push_chunk(out: &mut String, ltr: &str, rtl: Option<&str>, index: usize, cfg: &AssembleConfig) { + if rtl.is_some() || ltr.contains(", .") { + out.push_str(&rule_chunk(ltr, rtl, index, cfg)); + return; + } + let use_layers_on = matches!(cfg.use_layers, LayersConfig::On { .. }); + if use_layers_on || cfg.legacy_disable_layers || index == 0 { + out.push_str(ltr); + } else { + push_specificity_level(out, ltr, index); + } +} + +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 consts_timer = timings::start(Stage::AssembleConsts); + let consts = + collect_resolved_consts(rules.iter().filter(|r| is_const_rule(r)).map(const_decl))?; + let prepared_consts = prepare_consts(&consts); + let non_const: Vec<&StylexRule> = rules.iter().filter(|r| !is_const_rule(r)).collect(); + drop(consts_timer); + + let sorted: Vec<&StylexRule> = match custom_cmp { + Some(cmp) => { + let _t = timings::start(Stage::AssembleSort); + let mut refs = non_const; + refs.sort_by(|a, b| compare_rules(a, b, cfg, cmp)); + refs + } + None => keyed_order(non_const, cfg.use_legacy_classnames_sort), + }; + + // Logical-float detection reads the pre-substitution rule text. + let logical_float_vars = logical_float_block(sorted.iter().any(|r| rule_has_logical_float(r))); + + let subst_timer = timings::start(Stage::AssembleSubst); + let substituted: Vec<(Option, Option)> = if prepared_consts.is_empty() { + Vec::new() + } else { + sorted + .iter() + .map(|r| { + ( + substitute_consts(&r.ltr, &prepared_consts), + r.rtl + .as_deref() + .and_then(|t| substitute_consts(t, &prepared_consts)), + ) + }) + .collect() + }; + drop(subst_timer); + let text_of = |i: usize| -> (&str, Option<&str>) { + let rule = sorted[i]; + let (ltr, rtl) = match substituted.get(i) { + Some((ltr, rtl)) => (ltr.as_deref(), rtl.as_deref()), + None => (None, None), + }; + (ltr.unwrap_or(&rule.ltr), rtl.or(rule.rtl.as_deref())) + }; + + let _render_timer = timings::start(Stage::AssembleRender); + // Consecutive runs of floor(priority/1000); input is priority-sorted so runs + // are the priority bands. + let mut bands: Vec<(usize, usize)> = Vec::new(); + let mut last_level = -1.0f64; + for (i, rule) in sorted.iter().enumerate() { + let level = (rule.priority / 1000.0).floor(); + match bands.last_mut() { + Some(band) if level == last_level => band.1 = i + 1, + _ => { + last_level = level; + bands.push((i, i + 1)); + } + } + } + + let (use_layers_on, _, _, layer_prefix) = layer_parts(cfg); + let header = if use_layers_on { + layers_header(cfg, bands.len()) + } else { + String::new() + }; + + let text_bytes: usize = sorted + .iter() + .map(|r| r.ltr.len() + r.rtl.as_ref().map_or(0, |t| t.len() + 40) + 1) + .sum(); + let mut out = String::with_capacity( + logical_float_vars.len() + header.len() + text_bytes + 32 * bands.len(), + ); + out.push_str(&logical_float_vars); + out.push_str(&header); + + let mut slots: Vec = Vec::new(); + let mut slot_of: FxHashMap<&str, usize> = FxHashMap::default(); + for (index, &(start, end)) in bands.iter().enumerate() { + // Last-wins dedupe by className, first-occurrence order (JS Map semantics). + slots.clear(); + slot_of.clear(); + for (offset, rule) in sorted[start..end].iter().enumerate() { + let i = start + offset; + match slot_of.entry(&rule.class_name) { + Entry::Vacant(vacant) => { + vacant.insert(slots.len()); + slots.push(i); + } + Entry::Occupied(occupied) => slots[*occupied.get()] = i, + } + } + + if index > 0 { + out.push('\n'); + } + let wrap = use_layers_on && sorted[start].priority > 0.0; + if wrap { + out.push_str("@layer "); + out.push_str(&layer_name(layer_prefix, index)); + out.push_str("{\n"); + } + for (j, &i) in slots.iter().enumerate() { + if j > 0 { + out.push('\n'); + } + let (ltr, rtl) = text_of(i); + // Empty rtl is falsy upstream and behaves exactly like absent. + push_chunk(&mut out, ltr, rtl.filter(|s| !s.is_empty()), index, cfg); + } + if wrap { + out.push_str("\n}"); + } + } + Ok(out) +} + +// localeCompare levels of one string laid out [primary n][secondary n][tertiary n]; +// the declaration slice is its last `decl` chars, so that key is each level's suffix. +enum RuleKey { + Verified { n: usize, decl: usize, buf: Vec }, + DeclOnly { decl: usize, buf: Vec }, + Fallback, +} + +type Segments<'a> = [&'a [u8]; 3]; + +fn cmp_segments(a: Segments<'_>, b: Segments<'_>) -> Ordering { + a[0].cmp(b[0]) + .then_with(|| a[1].cmp(b[1])) + .then_with(|| a[2].cmp(b[2])) +} + +impl RuleKey { + fn for_rule(r: &StylexRule, legacy: bool) -> RuleKey { + if legacy { + RuleKey::derive(&r.class_name, 0) + } else { + RuleKey::derive(&r.ltr, decl_slice(&r.ltr).len()) + } + } + + fn derive(s: &str, decl_bytes: usize) -> RuleKey { + let decl_start = s.len() - decl_bytes; + if s.is_ascii() { + let n = s.len(); + let mut buf = vec![0u8; 3 * n]; + for (i, &b) in s.as_bytes().iter().enumerate() { + match ASCII_LOCALE_KEYS[usize::from(b)] { + Some((p, sec, ter)) => { + buf[i] = p; + buf[n + i] = sec; + buf[2 * n + i] = ter; + } + None => return RuleKey::decl_only(s, decl_start, i), + } + } + return RuleKey::Verified { + n, + decl: decl_bytes, + buf, + }; + } + let n = s.chars().count(); + let mut buf = vec![0u8; 3 * n]; + for (i, (at, c)) in s.char_indices().enumerate() { + match locale_key(c) { + Ok((p, sec, ter)) => { + buf[i] = p; + buf[n + i] = sec; + buf[2 * n + i] = ter; + } + Err(_) => return RuleKey::decl_only(s, decl_start, at), + } + } + RuleKey::Verified { + n, + decl: s[decl_start..].chars().count(), + buf, + } + } + + // An unverified char at byte `at`; only a fully verified declaration suffix + // after it keeps a key. + fn decl_only(s: &str, decl_start: usize, at: usize) -> RuleKey { + if at >= decl_start || decl_start == s.len() { + return RuleKey::Fallback; + } + match RuleKey::derive(&s[decl_start..], s.len() - decl_start) { + RuleKey::Verified { n, buf, .. } => RuleKey::DeclOnly { decl: n, buf }, + _ => RuleKey::Fallback, + } + } + + fn verified(&self) -> bool { + matches!(self, RuleKey::Verified { .. }) + } + + fn decl_segments(&self) -> Option> { + match self { + RuleKey::Verified { n, decl, buf } => Some([ + &buf[n - decl..*n], + &buf[2 * n - decl..2 * n], + &buf[3 * n - decl..], + ]), + RuleKey::DeclOnly { decl, buf } => { + Some([&buf[..*decl], &buf[*decl..2 * decl], &buf[2 * decl..]]) + } + RuleKey::Fallback => None, + } + } + + fn full_segments(&self) -> Option> { + match self { + RuleKey::Verified { n, buf, .. } => Some([&buf[..*n], &buf[*n..2 * n], &buf[2 * n..]]), + _ => None, + } + } + + // Equal primary sequences imply equal char counts, so the secondary and + // tertiary segment comparisons are always aligned. + fn cmp_verified(&self, other: &RuleKey) -> Ordering { + match (self, other) { + ( + RuleKey::Verified { + n: na, + decl: da, + buf: ba, + }, + RuleKey::Verified { + n: nb, + decl: db, + buf: bb, + }, + ) => ba[na - da..*na] + .cmp(&bb[nb - db..*nb]) + .then_with(|| ba[2 * na - da..2 * na].cmp(&bb[2 * nb - db..2 * nb])) + .then_with(|| ba[3 * na - da..].cmp(&bb[3 * nb - db..])) + .then_with(|| 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 per step: either side unverified → UTF-16 order. + fn cmp_with_fallback( + &self, + other: &RuleKey, + a: &StylexRule, + b: &StylexRule, + legacy: bool, + ) -> Ordering { + if legacy { + return match (self.full_segments(), other.full_segments()) { + (Some(ka), Some(kb)) => cmp_segments(ka, kb), + _ => utf16_cmp(&a.class_name, &b.class_name), + }; + } + let decl = match (self.decl_segments(), other.decl_segments()) { + (Some(ka), Some(kb)) => cmp_segments(ka, kb), + _ => utf16_cmp(decl_slice(&a.ltr), decl_slice(&b.ltr)), + }; + if decl != Ordering::Equal { + return decl; + } + match (self.full_segments(), other.full_segments()) { + (Some(ka), Some(kb)) => cmp_segments(ka, kb), + _ => utf16_cmp(&a.ltr, &b.ltr), + } + } +} + +// Keys derived once per rule; the stable index sort and the per-pair fallback +// keep comparator semantics identical. +fn keyed_order(non_const: Vec<&StylexRule>, legacy: bool) -> Vec<&StylexRule> { + let dedupe_timer = timings::start(Stage::AssembleDedupe); + let mut unique = dedupe_keep_first(&non_const); + drop(dedupe_timer); + let keys_timer = timings::start(Stage::AssembleKeys); + let mut keys: Vec = unique + .iter() + .map(|r| RuleKey::for_rule(r, legacy)) + .collect(); + // Dropping duplicates is order-neutral only under a consistent total order + // (verified keys, finite priorities); otherwise sort the full input as before. + if keys + .iter() + .zip(&unique) + .any(|(k, r)| !k.verified() || !r.priority.is_finite()) + { + unique = non_const; + keys = unique + .iter() + .map(|r| RuleKey::for_rule(r, legacy)) + .collect(); + } + drop(keys_timer); + let _sort_timer = timings::start(Stage::AssembleSort); + let mut order: Vec = (0..unique.len() as u32).collect(); + order.sort_by(|&ia, &ib| { + let (a, b) = (unique[ia as usize], unique[ib as usize]); + let diff = a.priority - b.priority; + if diff != 0.0 { + return if diff < 0.0 { + Ordering::Less + } else { + Ordering::Greater + }; + } + keys[ia as usize].cmp_with_fallback(&keys[ib as usize], a, b, legacy) + }); + order.into_iter().map(|i| unique[i as usize]).collect() +} + +// Keep-first is output-neutral (copies sort Equal, adjacent); a class with two +// distinct texts keeps every copy, since a copy between them decides last-wins. +fn dedupe_keep_first<'a>(rules: &[&'a StylexRule]) -> Vec<&'a StylexRule> { + let mut seen: FxHashSet<(&str, &str, Option<&str>, u64)> = FxHashSet::default(); + let first: Vec = rules + .iter() + .map(|r| { + seen.insert(( + &r.class_name, + &r.ltr, + r.rtl.as_deref(), + r.priority.to_bits(), + )) + }) + .collect(); + if seen.len() == rules.len() { + return rules.to_vec(); + } + let mut texts: FxHashMap<&str, u32> = FxHashMap::default(); + for (r, _) in rules.iter().zip(&first).filter(|(_, fresh)| **fresh) { + *texts.entry(&r.class_name).or_default() += 1; + } + let single_text_classes = texts.len() == seen.len(); + rules + .iter() + .zip(first) + .filter(|(r, fresh)| *fresh || (!single_text_classes && texts[&*r.class_name] > 1)) + .map(|(r, _)| *r) + .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 { + if consts.is_empty() { + return None; + } + 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 { + let mut out = String::with_capacity(selector.len() + 9 * index); + push_specificity_level(&mut out, selector, index); + out +} + +fn push_specificity_level(out: &mut String, selector: &str, index: usize) { + if selector.starts_with("@keyframes") { + out.push_str(selector); + return; + } + push_split(out, selector, specificity_split(selector), index); +} + +fn specificity_split(selector: &str) -> usize { + 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), + }, + } +} + +fn push_split(out: &mut String, selector: &str, split_at: usize, index: usize) { + out.push_str(&selector[..split_at]); + for _ in 0..index { + out.push_str(":not(#\\#)"); + } + out.push_str(&selector[split_at..]); +} + +// Split point of a rule that renders as plain slices (no rtl, no theme pair, +// not @keyframes); None routes the rule through push_chunk. +fn plain_split(ltr: &str, rtl: Option<&str>) -> Option { + if rtl.is_some_and(|t| !t.is_empty()) || ltr.contains(", .") || ltr.starts_with("@keyframes") { + return None; + } + Some(specificity_split(ltr)) +} + +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: Arc, + class_hash: u64, + priority: f64, + key: RuleKey, + // ltr/rtl carry the const substitutions; unchanged text shares the rule's Arc. + ltr: Arc, + rtl: Option>, + plain_split: Option, +} + +struct FilePrep { + rules: PreparedRules, + 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, +} + +struct SharedPreparedRule { + source: StylexRule, + prepared: PreparedRule, +} + +enum PreparedRules { + Inline(Vec), + Shared(Vec>), +} + +impl PreparedRules { + fn len(&self) -> usize { + match self { + Self::Inline(rules) => rules.len(), + Self::Shared(rules) => rules.len(), + } + } +} + +impl std::ops::Index for PreparedRules { + type Output = PreparedRule; + + fn index(&self, i: usize) -> &Self::Output { + match self { + Self::Inline(rules) => &rules[i], + Self::Shared(rules) => &rules[i].prepared, + } + } +} + +type PreparedPool = HashMap>, BuildHasherDefault>; + +fn prefer_shared_preparation(files: &BTreeMap>) -> bool { + let mut seen = FxHashSet::default(); + let mut total = 0; + let hasher = crate::fxhash::FxBuildHasher::default(); + for rule in files.values().flatten().filter(|r| !is_const_rule(r)) { + total += 1; + seen.insert(hasher.hash_one(( + &rule.class_name, + &rule.ltr, + &rule.rtl, + rule.priority.to_bits(), + ))); + } + total >= 256 && seen.len() * 2 <= total +} + +fn prepare_rule(r: &StylexRule, legacy: bool, consts: &[PreparedConst]) -> PreparedRule { + let ltr = substitute_consts(&r.ltr, consts) + .map(Arc::from) + .unwrap_or_else(|| r.ltr.clone()); + let rtl = r.rtl.as_ref().map(|t| { + substitute_consts(t, consts) + .map(Arc::from) + .unwrap_or_else(|| t.clone()) + }); + PreparedRule { + class_name: r.class_name.clone(), + class_hash: class_hash64(&r.class_name), + priority: r.priority, + key: RuleKey::for_rule(r, legacy), + plain_split: plain_split(<r, rtl.as_deref()), + ltr, + rtl, + } +} + +fn prepare_file( + rules: &[StylexRule], + legacy: bool, + consts: &[PreparedConst], + pool: &mut Option, +) -> FilePrep { + let mut prep = FilePrep { + rules: if pool.is_some() { + PreparedRules::Shared(Vec::with_capacity(rules.len())) + } else { + PreparedRules::Inline(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); + match &mut prep.rules { + PreparedRules::Inline(rules) => rules.push(prepare_rule(r, legacy, consts)), + PreparedRules::Shared(rules) => { + let bucket = pool + .as_mut() + .expect("shared preparation pool") + .entry(class_hash64(&r.class_name)) + .or_default(); + let shared = bucket + .iter() + .filter_map(Weak::upgrade) + .find(|p| p.source == *r) + .unwrap_or_else(|| { + let shared = Arc::new(SharedPreparedRule { + source: r.clone(), + prepared: prepare_rule(r, legacy, consts), + }); + bucket.push(Arc::downgrade(&shared)); + shared + }); + rules.push(shared); + } + } + let prepared = &prep.rules[prep.rules.len() - 1]; + prep.safe &= prepared.priority.is_finite() && prepared.key.verified(); + } + 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.key.cmp_verified(&pb.key).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, + pool: Option, + 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 prepared = self.try_incremental(cfg).or_else(|| { + self.rebuild_incr_state(cfg); + self.try_incremental(cfg) + }); + let result = match prepared { + 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 => assemble(&self.all_rules(), cfg), + }; + 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, + &mut state.pool, + ); + 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 let Some(pool) = &mut state.pool { + let mut unique = 0; + pool.retain(|_, bucket| { + bucket.retain(|rule| rule.strong_count() != 0); + unique += bucket.len(); + !bucket.is_empty() + }); + if unique * 2 > state.sorted.len() { + return None; + } + } + + 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()); + let mut pool = prefer_shared_preparation(&self.files).then(PreparedPool::default); + for (path, rules) in &self.files { + let prep = prepare_file( + rules, + cfg.use_legacy_classnames_sort, + &prepared_consts, + &mut pool, + ); + 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, + pool, + 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: &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 = &files[rank as usize].rules[seq as usize]; + match p.plain_split { + Some(_) if use_layers_on || cfg.legacy_disable_layers || index == 0 => { + out.push_str(&p.ltr); + } + Some(split) => push_split(&mut out, &p.ltr, split, index), + None => { + let rtl = p.rtl.as_deref().filter(|s| !s.is_empty()); + push_chunk(&mut out, &p.ltr, rtl, index, cfg); + } + } + } + if wrap { + out.push_str("\n}"); + } + } + out +} + +#[cfg(test)] +mod preparation_tests { + use super::*; + + #[test] + fn edits_that_remove_repetition_switch_back_to_inline_storage() { + let rule = |i: usize| StylexRule { + class_name: format!("x{i}").into(), + ltr: format!(".x{i}{{width:{i}px}}").into(), + rtl: None, + const_key: None, + const_val: None, + priority: 3000.0, + }; + let mut registry = RuleRegistry::new(); + let cfg = AssembleConfig::default(); + for i in 0..64 { + registry.set_file_rules(&format!("{i:03}.ts"), vec![rule(0); 8]); + } + registry.emit(&cfg).unwrap(); + assert!(registry.incr[0].pool.is_some()); + for i in 0..64 { + registry.set_file_rules( + &format!("{i:03}.ts"), + (1 + i * 8..1 + (i + 1) * 8).map(rule).collect(), + ); + assert_eq!(registry.emit(&cfg), assemble(®istry.all_rules(), &cfg)); + } + assert!(registry.incr[0].pool.is_none()); + } +} 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..4b546a56 --- /dev/null +++ b/crates/stylex/src/eval/cross_file.rs @@ -0,0 +1,148 @@ +//! `.stylex` theme-file import proxies: hash-only, no file contents are read. +// parity: evaluate-path.js createVarGroupProxy/resolveVarGroupKey/:595-654 + +use std::rc::Rc; + +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)] +pub struct VarGroupProxy { + group: Rc, +} + +#[derive(Debug, PartialEq, Eq)] +struct VarGroup { + /// Canonical theme-file name (`pkg:relPath` form). + file_name: String, + export_name: String, + var_group_hash: String, + class_name_prefix: String, + debug_class_names: bool, +} + +impl PartialEq for VarGroupProxy { + fn eq(&self, other: &Self) -> bool { + Rc::ptr_eq(&self.group, &other.group) || self.group == other.group + } +} + +impl Eq for VarGroupProxy {} + +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 { + group: Rc::new(VarGroup { + 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, + }), + } + } + + pub fn var_group_hash(&self) -> &str { + &self.group.var_group_hash + } + + // parity: evaluate-path.js resolveVarGroupKey. + pub fn resolve_key(&self, key: &str) -> String { + if key.starts_with("--") { + return format!("var({key})"); + } + let group = &*self.group; + let hashed = hash(&gen_file_based_identifier( + &group.file_name, + &group.export_name, + Some(key), + )); + let var_name = if group.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}", group.class_name_prefix) + } else { + format!("{}{hashed}", group.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..36eb482c --- /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.add_member_callable(namespace, name, 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.to_mut().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 + .to_mut() + .identifiers + .insert(name.clone(), RegistryEntry::Value(value)); + saved.push((name.clone(), previous)); + } + let result = self.walk(ev, body); + let identifiers = &mut ev.registry.to_mut().identifiers; + for (name, previous) in saved.into_iter().rev() { + match previous { + Some(entry) => { + identifiers.insert(name, entry); + } + None => { + 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_callable(object_name, &property) + .is_some(); + 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..452a1321 --- /dev/null +++ b/crates/stylex/src/eval/mod.rs @@ -0,0 +1,2421 @@ +//! 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::borrow::Cow; +use std::cell::OnceCell; +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)] +pub struct JsObj { + repr: ObjRepr, +} + +#[derive(Debug, Clone)] +enum ObjRepr { + Built { + 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, + }, + /// A compiled map read in place; a slot converts once so repeat reads of + /// one entry keep one identity (`===`), like a real property. + Frozen { + map: Arc, + slots: OnceCell]>>, + }, +} + +type FrozenSlots = OnceCell]>>; + +fn frozen_slot<'o>(map: &'o JsObjectMap, slots: &'o FrozenSlots, i: usize) -> &'o JsValue { + let slots = slots.get_or_init(|| { + std::iter::repeat_with(OnceCell::new) + .take(map.len()) + .collect() + }); + slots[i].get_or_init(|| from_eval_value(map.entry_at(i).1)) +} + +enum Entries<'o> { + Built(std::slice::Iter<'o, (String, JsValue)>), + Frozen { + map: &'o JsObjectMap, + slots: &'o FrozenSlots, + next: usize, + }, +} + +impl<'o> Iterator for Entries<'o> { + type Item = (&'o str, &'o JsValue); + + fn next(&mut self) -> Option { + match self { + Entries::Built(iter) => iter.next().map(|(k, v)| (k.as_str(), v)), + Entries::Frozen { map, slots, next } => { + let i = *next; + if i >= map.len() { + return None; + } + *next += 1; + Some((map.entry_at(i).0, frozen_slot(map, slots, i))) + } + } + } +} + +impl Default for JsObj { + fn default() -> Self { + Self::built(Vec::new()) + } +} + +impl JsObj { + fn built(entries: Vec<(String, JsValue)>) -> Self { + Self { + repr: ObjRepr::Built { + entries, + index: None, + css_type: None, + }, + } + } + + pub fn with_capacity(n: usize) -> Self { + Self::built(Vec::with_capacity(n)) + } + + /// The object sequential `insert` would build from `entries`, which must + /// not repeat a key (callers hand over a map's entries). + pub fn from_unique_entries(entries: Vec<(String, JsValue)>) -> Self { + debug_assert!( + entries + .iter() + .map(|(k, _)| k.as_str()) + .collect::>() + .len() + == entries.len() + ); + Self::built(entries) + } + + pub fn frozen(map: Arc) -> Self { + Self { + repr: ObjRepr::Frozen { + map, + slots: OnceCell::new(), + }, + } + } + + fn frozen_map(&self) -> Option<&Arc> { + match &self.repr { + ObjRepr::Frozen { map, .. } => Some(map), + ObjRepr::Built { .. } => None, + } + } + + pub fn get(&self, key: &str) -> Option<&JsValue> { + match &self.repr { + ObjRepr::Built { entries, index, .. } => match index { + Some(index) => index.get(key).map(|&i| &entries[i].1), + None => entries.iter().find(|(k, _)| k == key).map(|(_, v)| v), + }, + ObjRepr::Frozen { map, slots } => { + let i = map.position(key)?; + Some(frozen_slot(map, slots, i)) + } + } + } + + pub fn insert(&mut self, key: String, value: JsValue) { + self.materialize(); + let ObjRepr::Built { entries, index, .. } = &mut self.repr else { + unreachable!("materialize leaves a built object"); + }; + if index.is_none() && entries.len() >= value::NAMED_INDEX_THRESHOLD { + *index = Some(Box::new( + entries + .iter() + .enumerate() + .map(|(i, (k, _))| (k.clone(), i)) + .collect(), + )); + } + if let Some(index) = index { + match index.entry(key) { + std::collections::hash_map::Entry::Occupied(e) => { + entries[*e.get()].1 = value; + } + std::collections::hash_map::Entry::Vacant(e) => { + entries.push((e.key().clone(), value)); + e.insert(entries.len() - 1); + } + } + } else if let Some(entry) = entries.iter_mut().find(|(k, _)| *k == key) { + entry.1 = value; + } else { + entries.push((key, value)); + } + } + + pub fn entries(&self) -> impl Iterator { + match &self.repr { + ObjRepr::Built { entries, .. } => Entries::Built(entries.iter()), + ObjRepr::Frozen { map, slots } => Entries::Frozen { + map, + slots, + next: 0, + }, + } + } + + pub fn css_type(&self) -> Option<&str> { + match &self.repr { + ObjRepr::Built { css_type, .. } => css_type.as_deref(), + ObjRepr::Frozen { map, .. } => map.css_type(), + } + } + + pub fn set_css_type(&mut self, syntax: String) { + self.materialize(); + if let ObjRepr::Built { css_type, .. } = &mut self.repr { + *css_type = Some(syntax); + } + } + + /// Frozen → Built; slots already handed out keep their identity. + fn materialize(&mut self) { + let ObjRepr::Frozen { map, slots } = &mut self.repr else { + return; + }; + let mut slots = slots.take(); + let entries = map + .entries() + .enumerate() + .map(|(i, (k, v))| { + let value = slots + .as_mut() + .and_then(|s| s[i].take()) + .unwrap_or_else(|| from_eval_value(v)); + (k.to_string(), value) + }) + .collect(); + let css_type = map.css_type().map(str::to_string); + self.repr = ObjRepr::Built { + entries, + index: None, + css_type, + }; + } + + fn into_map(self) -> Arc { + match self.repr { + ObjRepr::Built { + entries, css_type, .. + } => { + let entries = entries + .into_iter() + .map(|(k, v)| (k, into_eval_value(v))) + .collect(); + let mut map = JsObjectMap::from_unique_entries(entries); + if let Some(syntax) = css_type { + map.set_css_type(syntax); + } + Arc::new(map) + } + ObjRepr::Frozen { map, .. } => map, + } + } +} + +#[derive(Debug, Clone)] +pub struct Deopt { + pub reason: Cow<'static, str>, + 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) => EvalValue::Obj(obj_to_map(obj)), + JsValue::Proxy(_) => EvalValue::Obj(JsObjectMap::new().into()), + } +} + +fn obj_to_map(obj: &JsObj) -> Arc { + if let Some(map) = obj.frozen_map() { + return Arc::clone(map); + } + let entries = obj + .entries() + .map(|(k, v)| (k.to_string(), to_eval_value(v))) + .collect(); + let mut map = JsObjectMap::from_unique_entries(entries); + if let Some(syntax) = obj.css_type() { + map.set_css_type(syntax.to_string()); + } + Arc::new(map) +} + +/// `to_eval_value` for a value the caller is finished with: strings and +/// uniquely owned containers move; aliased containers fall back to copying. +pub fn into_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), + JsValue::Arr(items) => match Rc::try_unwrap(items) { + Ok(items) => EvalValue::Arr(items.into_iter().map(into_eval_value).collect()), + Err(shared) => EvalValue::Arr(shared.iter().map(to_eval_value).collect()), + }, + JsValue::Obj(obj) => EvalValue::Obj(match Rc::try_unwrap(obj) { + Ok(obj) => obj.into_map(), + Err(shared) => obj_to_map(&shared), + }), + 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) => JsValue::object(JsObj::frozen(Arc::clone(map))), + } +} + +/// `from_eval_value` for a value the caller is finished with. +pub fn into_js_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), + EvalValue::Arr(items) => JsValue::array(items.into_iter().map(into_js_value).collect()), + EvalValue::Obj(map) => JsValue::object(JsObj::frozen(map)), + } +} + +#[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: Vec<(String, &'static str, 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.add_member_callable( + namespace, + "firstThatWorks", + 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.add_member_callable(namespace, "defaultMarker", 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.add_member_callable(namespace, "keyframes", StylexCallable::Keyframes); + } + registry + } + + pub(crate) fn add_member_callable( + &mut self, + object: &str, + prop: &'static str, + callable: StylexCallable, + ) { + match self + .member_callables + .iter_mut() + .find(|(o, p, _)| o == object && *p == prop) + { + Some(slot) => slot.2 = callable, + None => self + .member_callables + .push((object.to_string(), prop, callable)), + } + } + + pub(crate) fn member_callable(&self, object: &str, prop: &str) -> Option<&StylexCallable> { + self.member_callables + .iter() + .find(|(o, p, _)| o == object && *p == prop) + .map(|(_, _, callable)| callable) + } + + 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.add_member_callable(namespace, name, callable); + } + } + registry + } +} + +pub struct Evaluator<'a, 'env> { + pub state: &'env mut CompileState<'a>, + pub fs: &'env dyn FsProvider, + pub callables: &'env dyn EvalCallables, + /// Arrow parameter binding writes through `to_mut`, so a borrowed registry + /// is copied only by evaluations that call arrows. + registry: Cow<'env, 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_node($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: Cow::Owned(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 { + Self::with_registry_cow(state, fs, callables, Cow::Owned(registry), disable_imports) + } + + pub fn with_registry_ref( + state: &'env mut CompileState<'a>, + fs: &'env dyn FsProvider, + callables: &'env dyn EvalCallables, + registry: &'env FunctionRegistry, + disable_imports: bool, + ) -> Self { + Self::with_registry_cow( + state, + fs, + callables, + Cow::Borrowed(registry), + disable_imports, + ) + } + + fn with_registry_cow( + state: &'env mut CompileState<'a>, + fs: &'env dyn FsProvider, + callables: &'env dyn EvalCallables, + registry: Cow<'env, 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) + } + + /// Converts a finished `eval_entry` result; the node cache still holds Rc + /// clones of its tree and would otherwise force `into_eval_value` to copy. + fn consume(&mut self, value: JsValue) -> EvalValue { + self.seen.clear(); + into_eval_value(value) + } + + /// 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)) + } + + // Only binding resolution (`eval`/`eval_cached`) can revisit a node within + // one `seen` lifetime; structural children skip the cache. + fn eval_node(&mut self, expr: &'a Expression<'a>) -> EvalResult { + self.enter()?; + let result = self.eval_inner(expr); + self.depth -= 1; + result + } + + fn enter(&mut self) -> Result<(), StylexError> { + self.depth += 1; + if self.depth > 128 { + self.depth -= 1; + return Err(StylexError::new( + ErrorCode::NonStaticValue, + "StyleX evaluation exceeded the recursion limit.", + )); + } + Ok(()) + } + + fn eval_cached( + &mut self, + span: Span, + eval_fn: impl FnOnce(&mut Self) -> EvalResult, + ) -> EvalResult { + self.enter()?; + 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_node(&e.expression), + Expression::TSAsExpression(e) => self.eval_node(&e.expression), + Expression::TSSatisfiesExpression(e) => self.eval_node(&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_node(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_node(&cond.consequent) + } else { + self.eval_node(&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::with_capacity(tpl.quasis.iter().map(|q| q.value.raw.len()).sum()); + 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(symbol, 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(into_js_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, symbol: SymbolId, name: &str, span: Span) -> EvalResult { + if let Some(proxy) = self.state.theme_proxy(symbol) { + return self.value(JsValue::proxy(proxy.clone())); + } + 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.state.record_theme_proxy(symbol, proxy.clone()); + 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): (_, Cow) = match expr { + Expression::StaticMemberExpression(member) => { + (&member.object, Cow::Borrowed(member.property.name.as_str())) + } + Expression::ComputedMemberExpression(member) => { + let key_value = value_or_return!(self, &member.expression); + match js_to_string(&key_value) { + Some(key) => (&member.object, Cow::Owned(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().to_string()), + "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::with_capacity(object.properties.len()); + 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_node(&logical.left)?; + let right = self.eval_node(&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_callable(object_name, prop).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().to_string(); + 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 + .to_mut() + .identifiers + .insert(name.clone(), RegistryEntry::Value(value)); + saved.push((name.clone(), previous)); + } + let result = self.eval_fresh(body); + let identifiers = &mut self.registry.to_mut().identifiers; + for (name, previous) in saved.into_iter().rev() { + match previous { + Some(entry) => { + identifiers.insert(name, entry); + } + None => { + 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().to_string()) + } + other => to_eval_value(other), + }) + .collect(); + let result = self.callables.call(&callable, &args, self.state)?; + self.value(into_js_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").into(), + span: spread.span, + })); + } + _ => match self.eval_node(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::with_capacity(object.properties.len()); + 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(), evaluator.consume(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, evaluator.consume(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 evaluator.consume(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::with_capacity(object.properties.len()); + 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 depth = 0; + let mut current = expr; + loop { + match current { + Expression::StaticMemberExpression(member) => current = &member.object, + Expression::ComputedMemberExpression(member) => match &member.expression { + Expression::StringLiteral(_) | Expression::NumericLiteral(_) => { + current = &member.object; + } + _ => return None, + }, + _ => break, + } + depth += 1; + } + if depth < 2 { + return None; + } + let mut parts: Vec> = Vec::with_capacity(depth); + let mut current = expr; + loop { + match current { + Expression::StaticMemberExpression(member) => { + parts.push(Cow::Borrowed(member.property.name.as_str())); + current = &member.object; + } + Expression::ComputedMemberExpression(member) => { + match &member.expression { + Expression::StringLiteral(lit) => { + parts.push(Cow::Borrowed(lit.value.as_str())); + } + Expression::NumericLiteral(lit) => { + parts.push(Cow::Owned(js_number_to_string(lit.value))); + } + _ => unreachable!("checked by the depth pass"), + } + current = &member.object; + } + _ => break, + } + } + 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().to_string(), + 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..b8e2364e --- /dev/null +++ b/crates/stylex/src/eval/value.rs @@ -0,0 +1,505 @@ +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 with_capacity(named: usize) -> Self { + Self { + named_entries: Vec::with_capacity(named), + ..Self::default() + } + } + + /// The map sequential `insert` would build from `entries`, which must not + /// repeat a key (callers hand over another map's or object's entries). + pub fn from_unique_entries(entries: Vec<(String, EvalValue)>) -> Self { + debug_assert!( + entries + .iter() + .map(|(k, _)| k.as_str()) + .collect::>() + .len() + == entries.len() + ); + if !entries.iter().any(|(k, _)| array_index(k).is_some()) { + return Self { + named_entries: entries, + ..Self::default() + }; + } + let mut index_entries = Vec::new(); + let mut named_entries = Vec::with_capacity(entries.len() - 1); + for (key, value) in entries { + match array_index(&key) { + Some(n) => index_entries.push((n, key, value)), + None => named_entries.push((key, value)), + } + } + index_entries.sort_unstable_by_key(|e| e.0); + Self { + index_entries, + named_entries, + ..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) + } + } + + /// Position of `key` in `entries()` order. + pub fn position(&self, key: &str) -> Option { + if let Some(n) = array_index(key) { + return self.index_entries.binary_search_by_key(&n, |e| e.0).ok(); + } + let named = match &self.named_index { + Some(index) => index.get(key).copied(), + None => self.named_entries.iter().position(|(k, _)| k == key), + }; + named.map(|i| i + self.index_entries.len()) + } + + /// The entry at a `position()` / `entries()` index. + pub fn entry_at(&self, i: usize) -> (&str, &EvalValue) { + let indexed = self.index_entries.len(); + if i < indexed { + let (_, k, v) = &self.index_entries[i]; + (k, v) + } else { + let (k, v) = &self.named_entries[i - indexed]; + (k, 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 from_unique_entries_matches_sequential_insert() { + let n = NAMED_INDEX_THRESHOLD * 2; + let entries: Vec<(String, EvalValue)> = (0..n) + .map(|i| { + let key = match i % 5 { + 0 => (n - i).to_string(), + 1 => format!("0{i}"), + 2 => "4294967295".to_string() + &i.to_string(), + _ => format!("key{i}"), + }; + (key, s(&i.to_string())) + }) + .chain([("__proto__".to_string(), s("p")), ("".to_string(), s("e"))]) + .collect(); + let sequential: JsObjectMap = entries.iter().cloned().collect(); + let direct = JsObjectMap::from_unique_entries(entries.clone()); + assert_eq!(direct, sequential); + assert_eq!( + direct.entries().collect::>(), + sequential.entries().collect::>() + ); + for (k, v) in &entries { + assert_eq!(direct.get(k), Some(v)); + } + let named_only: Vec<(String, EvalValue)> = + (0..3).map(|i| (format!("k{i}"), s("v"))).collect(); + let direct = JsObjectMap::from_unique_entries(named_only.clone()); + assert_eq!(direct, named_only.into_iter().collect()); + assert!(JsObjectMap::from_unique_entries(Vec::new()).is_empty()); + } + + #[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..e63351a3 --- /dev/null +++ b/crates/stylex/src/jsrt.rs @@ -0,0 +1,530 @@ +use std::cmp::Ordering; + +fn non_finite_text(x: f64) -> Option<&'static str> { + if x.is_nan() { + Some("NaN") + } else if x.is_infinite() { + Some(if x > 0.0 { "Infinity" } else { "-Infinity" }) + } else { + None + } +} + +pub fn js_number_to_string(x: f64) -> String { + if let Some(text) = non_finite_text(x) { + return text.to_string(); + } + let mut buf = ryu_js::Buffer::new(); + buf.format(x).to_string() +} + +pub fn write_js_number(x: f64, out: &mut String) { + if let Some(text) = non_finite_text(x) { + out.push_str(text); + return; + } + let mut buf = ryu_js::Buffer::new(); + out.push_str(buf.format(x)); +} + +// 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) const 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) +} + +// 256 wide so a byte index needs no bounds check; only ASCII strings read it. +pub(crate) static ASCII_LOCALE_KEYS: [Option<(u8, u8, u8)>; 256] = { + let mut table = [None; 256]; + let mut b = 0usize; + while b < 128 { + table[b] = match locale_key(b as u8 as char) { + Ok(key) => Some(key), + Err(_) => None, + }; + b += 1; + } + table +}; + +#[cfg(test)] +mod ascii_locale_key_tests { + use super::{ASCII_LOCALE_KEYS, locale_key}; + + #[test] + fn table_matches_locale_key_for_every_byte() { + for b in 0..=255u8 { + let want = (b < 128).then(|| locale_key(b as char).ok()).flatten(); + assert_eq!(ASCII_LOCALE_KEYS[usize::from(b)], want, "byte {b:#x}"); + } + assert!(ASCII_LOCALE_KEYS[usize::from(b'a')].is_some()); + assert!(ASCII_LOCALE_KEYS[usize::from(b'{')].is_some()); + } +} + +/// 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..0692f493 --- /dev/null +++ b/crates/stylex/src/lib.rs @@ -0,0 +1,53 @@ +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 scopes; +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..2d79b7db --- /dev/null +++ b/crates/stylex/src/module_resolution.rs @@ -0,0 +1,2178 @@ +//! 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 std::sync::{LazyLock, PoisonError, RwLock, RwLockReadGuard, RwLockWriteGuard}; +use std::time::SystemTime; + +use crate::fxhash::FxHashMap; +use crate::options::{AliasMap, ModuleResolutionType, ResolvedOptions}; +use crate::timings::{self, Stage}; + +// 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)> { + let _t = timings::start(Stage::Fs); + nearest_package_walk(from, None) + } + + fn resolve_import( + &self, + specifier: &str, + importer: &Path, + config: ResolveConfig<'_>, + ) -> Option { + let _t = timings::start(Stage::Fs); + resolve_import_with(self, specifier, importer, config) + } + + fn exists(&self, p: &Path) -> bool { + p.is_file() + } +} + +/// Directory-keyed memo over [`StdFs`]: `snapshot()` freezes the fs for the process; +/// `live()` re-stats package.json per level and re-parses only on an (mtime, size) change. +pub struct MemoFs { + live: bool, + nearest: PathMap>, + manifests: PathMap>, + resolve: PathMap>, + exists: PathMap, + exists_any: PathMap, + hashes: PathMap<(Option, Option)>, + canon: PathMap, +} + +// Keyed by the path's raw bytes: `Path` equality folds spellings (`a//b`, +// `a/b/`) whose walks return differently spelled directories. +type PathMap = RwLock, V>>; + +#[derive(Clone, Copy, PartialEq, Eq)] +struct Stamp { + modified: Option, + len: u64, +} + +impl Stamp { + fn of(meta: &std::fs::Metadata) -> Self { + Self { + modified: meta.modified().ok(), + len: meta.len(), + } + } +} + +#[derive(Clone)] +enum PackageProbe { + Name(String), + Broken, +} + +#[derive(Clone)] +struct Manifest { + stamp: Stamp, + probe: PackageProbe, +} + +struct ResolveMemo { + aliases: Option, + root_dir: Option, + by_specifier: FxHashMap>, +} + +impl ResolveMemo { + fn matches(&self, config: ResolveConfig<'_>) -> bool { + self.aliases.as_ref() == config.aliases + && self.root_dir.as_deref().map(Path::as_os_str) == config.root_dir.map(Path::as_os_str) + } +} + +fn path_key(p: &Path) -> &[u8] { + p.as_os_str().as_encoded_bytes() +} + +fn read_lock(lock: &RwLock) -> RwLockReadGuard<'_, T> { + lock.read().unwrap_or_else(PoisonError::into_inner) +} + +fn write_lock(lock: &RwLock) -> RwLockWriteGuard<'_, T> { + lock.write().unwrap_or_else(PoisonError::into_inner) +} + +static SHARED: LazyLock = LazyLock::new(MemoFs::live); + +impl MemoFs { + pub fn snapshot() -> Self { + Self::with_mode(false) + } + + pub fn live() -> Self { + Self::with_mode(true) + } + + /// Process-wide live instance: an embedder swaps `&StdFs` for + /// `MemoFs::shared()` at its call sites and nothing else changes. + pub fn shared() -> &'static MemoFs { + &SHARED + } + + fn with_mode(live: bool) -> Self { + Self { + live, + nearest: RwLock::default(), + manifests: RwLock::default(), + resolve: RwLock::default(), + exists: RwLock::default(), + exists_any: RwLock::default(), + hashes: RwLock::default(), + canon: RwLock::default(), + } + } + + pub fn invalidate_all(&self) { + write_lock(&self.nearest).clear(); + write_lock(&self.manifests).clear(); + write_lock(&self.resolve).clear(); + write_lock(&self.exists).clear(); + write_lock(&self.exists_any).clear(); + write_lock(&self.hashes).clear(); + write_lock(&self.canon).clear(); + } + + /// `None` mirrors `!candidate.is_file()`; the walk keeps climbing. + fn probe_manifest(&self, candidate: &Path) -> Option { + let key = path_key(candidate); + if !self.live + && let Some(hit) = read_lock(&self.manifests).get(key) + { + return hit.as_ref().map(|m| m.probe.clone()); + } + let stamp = match std::fs::metadata(candidate) { + Ok(meta) if meta.is_file() => Stamp::of(&meta), + _ => { + if !self.live { + write_lock(&self.manifests).insert(key.into(), None); + } + return None; + } + }; + if self.live + && let Some(Some(hit)) = read_lock(&self.manifests).get(key) + && hit.stamp == stamp + { + return Some(hit.probe.clone()); + } + let probe = read_manifest(candidate); + write_lock(&self.manifests).insert( + key.into(), + Some(Manifest { + stamp, + probe: probe.clone(), + }), + ); + Some(probe) + } + + fn memo_path(&self, map: &PathMap, p: &Path, compute: impl FnOnce() -> T) -> T { + let key = path_key(p); + if let Some(hit) = read_lock(map).get(key) { + return hit.clone(); + } + let value = compute(); + write_lock(map).insert(key.into(), value.clone()); + value + } +} + +impl FsProvider for MemoFs { + fn nearest_package(&self, from: &Path) -> Option<(String, PathBuf)> { + let _t = timings::start(Stage::Fs); + let mut folder = from.parent()?; + let mut visited: Vec<&Path> = Vec::new(); + let answer = loop { + if !self.live { + if let Some(hit) = read_lock(&self.nearest).get(path_key(folder)) { + break hit.clone(); + } + visited.push(folder); + } + match self.probe_manifest(&folder.join("package.json")) { + Some(PackageProbe::Name(name)) => break Some((name, folder.to_path_buf())), + Some(PackageProbe::Broken) => break None, + None => {} + } + if folder == Path::new("/") || folder.as_os_str().is_empty() { + break None; + } + match folder.parent() { + Some(parent) => folder = parent, + None => break None, + } + }; + if !visited.is_empty() { + let mut nearest = write_lock(&self.nearest); + for dir in visited { + nearest.insert(path_key(dir).into(), answer.clone()); + } + } + answer + } + + fn resolve_import( + &self, + specifier: &str, + importer: &Path, + config: ResolveConfig<'_>, + ) -> Option { + let _t = timings::start(Stage::Fs); + let dir = match importer.parent() { + Some(dir) if !self.live => dir, + _ => return resolve_import_with(self, specifier, importer, config), + }; + let key = path_key(dir); + if let Some(memos) = read_lock(&self.resolve).get(key) + && let Some(memo) = memos.iter().find(|m| m.matches(config)) + && let Some(hit) = memo.by_specifier.get(specifier) + { + return hit.clone(); + } + let resolved = resolve_import_with(self, specifier, importer, config); + let mut map = write_lock(&self.resolve); + let memos = map.entry(key.into()).or_default(); + let memo = match memos.iter().position(|m| m.matches(config)) { + Some(i) => &mut memos[i], + None => { + memos.push(ResolveMemo { + aliases: config.aliases.cloned(), + root_dir: config.root_dir.map(Path::to_path_buf), + by_specifier: FxHashMap::default(), + }); + memos.last_mut().expect("just pushed") + } + }; + memo.by_specifier + .insert(specifier.to_string(), resolved.clone()); + resolved + } + + fn exists(&self, p: &Path) -> bool { + if self.live { + return p.is_file(); + } + self.memo_path(&self.exists, p, || p.is_file()) + } + + fn exists_any(&self, p: &Path) -> bool { + if self.live { + return p.exists(); + } + self.memo_path(&self.exists_any, p, || p.exists()) + } + + fn hash_file(&self, p: &Path) -> Option { + let stamp = if self.live { + match std::fs::metadata(p) { + Ok(meta) if meta.is_file() => Some(Stamp::of(&meta)), + _ => return StdFs.hash_file(p), + } + } else { + None + }; + let key = path_key(p); + if let Some((seen, hash)) = read_lock(&self.hashes).get(key) + && (stamp.is_none() || *seen == stamp) + { + return hash.clone(); + } + let hash = StdFs.hash_file(p); + write_lock(&self.hashes).insert(key.into(), (stamp, hash.clone())); + hash + } + + fn canonicalize_root(&self, root: &Path) -> PathBuf { + if self.live { + return StdFs.canonicalize_root(root); + } + self.memo_path(&self.canon, root, || StdFs.canonicalize_root(root)) + } +} + +/// `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() { + return match read_manifest(&candidate) { + PackageProbe::Name(name) => Some((name, folder.to_path_buf())), + PackageProbe::Broken => None, + }; + } + if folder == Path::new("/") || folder.as_os_str().is_empty() { + return None; + } + folder = folder.parent()?; + } +} + +fn read_manifest(candidate: &Path) -> PackageProbe { + let Ok(raw) = std::fs::read_to_string(candidate) else { + return PackageProbe::Broken; + }; + match serde_json::from_str::(&raw) { + Ok(json) => PackageProbe::Name(js_name_string(json.get("name"))), + Err(_) => PackageProbe::Broken, + } +} + +// 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") + ); + } + + /// Fresh per test (tests mutate it): root manifest, a nested `pkg` + /// manifest, and files two and three levels below `pkg`. + fn memo_fixture(tag: &str) -> PathBuf { + let root = + std::env::temp_dir().join(format!("stylex-rs-memofs-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + 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":"root-pkg"}"#); + write("pkg/package.json", r#"{"name":"nested-pkg"}"#); + write("pkg/src/a/one.ts", ""); + write("pkg/src/b/two.ts", ""); + write("pkg/src/b/tokens.stylex.ts", ""); + write("other/deep/three.ts", ""); + std::fs::canonicalize(&root).unwrap_or(root) + } + + fn os_eq(a: Option<(String, PathBuf)>, b: Option<(String, PathBuf)>) -> bool { + match (a, b) { + (None, None) => true, + (Some((n1, d1)), Some((n2, d2))) => n1 == n2 && d1.as_os_str() == d2.as_os_str(), + _ => false, + } + } + + #[test] + fn memo_snapshot_matches_std_and_fills_every_visited_level() { + let root = memo_fixture("snapshot"); + let memo = MemoFs::snapshot(); + for rel in [ + "pkg/src/a/one.ts", + "pkg/src/b/two.ts", + "other/deep/three.ts", + "pkg/src/a/one.ts", + ] { + let file = root.join(rel); + assert!(os_eq( + memo.nearest_package(&file), + StdFs.nearest_package(&file) + )); + } + let filled = read_lock(&memo.nearest); + for dir in ["pkg/src/a", "pkg/src", "pkg", "other/deep", "other"] { + assert!( + filled.contains_key(path_key(&root.join(dir))), + "{dir} not filled" + ); + } + // Seven directories were visited (pkg/src/a, pkg/src/b, pkg/src, pkg, + // other/deep, other, root), each probed exactly once. + let manifests = read_lock(&memo.manifests); + assert!(manifests.contains_key(path_key(&root.join("pkg/package.json")))); + assert!(manifests.contains_key(path_key(&root.join("package.json")))); + assert_eq!( + manifests + .keys() + .filter(|k| k.starts_with(path_key(&root))) + .count(), + 7 + ); + } + + #[test] + fn memo_snapshot_freezes_while_live_revalidates_edits_and_new_manifests() { + let root = memo_fixture("live"); + let snapshot = MemoFs::snapshot(); + let live = MemoFs::live(); + let file = root.join("pkg/src/a/one.ts"); + let before = Some(("nested-pkg".to_string(), root.join("pkg"))); + assert!(os_eq(snapshot.nearest_package(&file), before.clone())); + assert!(os_eq(live.nearest_package(&file), before.clone())); + + // Edit: a different length guarantees a stamp change even inside one + // mtime tick. + std::fs::write( + root.join("pkg/package.json"), + r#"{"name":"nested-pkg-renamed"}"#, + ) + .unwrap(); + assert!(os_eq(snapshot.nearest_package(&file), before.clone())); + assert!(os_eq( + live.nearest_package(&file), + Some(("nested-pkg-renamed".to_string(), root.join("pkg"))) + )); + + // A new manifest at an intermediate level is seen live, not by snapshot. + std::fs::write(root.join("pkg/src/package.json"), r#"{"name":"src-pkg"}"#).unwrap(); + assert!(os_eq(snapshot.nearest_package(&file), before)); + assert!(os_eq( + live.nearest_package(&file), + Some(("src-pkg".to_string(), root.join("pkg/src"))) + )); + assert!(os_eq( + live.nearest_package(&file), + StdFs.nearest_package(&file) + )); + + // A broken manifest ends the live walk with no answer, like StdFs. + std::fs::write(root.join("pkg/src/package.json"), "{").unwrap(); + assert!(live.nearest_package(&file).is_none()); + assert!(StdFs.nearest_package(&file).is_none()); + + snapshot.invalidate_all(); + assert!(snapshot.nearest_package(&file).is_none()); + } + + #[test] + fn memo_keys_by_spelling_so_found_dirs_keep_the_callers_spelling() { + let root = memo_fixture("spelling"); + let memo = MemoFs::snapshot(); + let plain = root.join("pkg/src/a/one.ts"); + let doubled = PathBuf::from(format!("{}//pkg/src/a/one.ts", root.display())); + assert_eq!(Path::new(&plain), Path::new(&doubled)); + assert!(os_eq( + memo.nearest_package(&plain), + StdFs.nearest_package(&plain) + )); + assert!(os_eq( + memo.nearest_package(&doubled), + StdFs.nearest_package(&doubled) + )); + assert_ne!( + memo.nearest_package(&plain).unwrap().1.as_os_str(), + memo.nearest_package(&doubled).unwrap().1.as_os_str() + ); + } + + #[test] + fn memo_snapshot_resolve_is_keyed_by_importer_dir_and_config() { + let root = memo_fixture("resolve"); + let memo = MemoFs::snapshot(); + let a = root.join("pkg/src/b/two.ts"); + let b = root.join("pkg/src/b/other.ts"); + let with_root = ResolveConfig { + aliases: None, + root_dir: Some(&root), + }; + let expected = StdFs.resolve_import("./tokens.stylex", &a, with_root); + assert!(expected.is_some()); + assert_eq!( + memo.resolve_import("./tokens.stylex", &a, with_root), + expected + ); + assert_eq!( + memo.resolve_import("./tokens.stylex", &b, with_root), + expected + ); + assert_eq!( + memo.resolve_import("./tokens.stylex", &b, ResolveConfig::default()), + expected + ); + let memos = read_lock(&memo.resolve); + let by_dir = &memos[path_key(&root.join("pkg/src/b"))]; + assert_eq!(by_dir.len(), 2, "one memo per distinct config"); + assert!(by_dir.iter().all(|m| m.by_specifier.len() == 1)); + assert_eq!(memos.len(), 1, "one importer directory"); + } +} 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..465e2c26 --- /dev/null +++ b/crates/stylex/src/rules.rs @@ -0,0 +1,140 @@ +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; shared with the oj fork by type, change both sides. +// Arc'd text so clones are refcount bumps; const_val boxed because almost every rule is None. +#[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().map(Box::new); + 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 layout_tests { + #[test] + fn rule_struct_stays_compact() { + assert!(std::mem::size_of::() <= 80); + } +} + +#[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(Box::new(tagged.clone())), + priority: 0.0, + }; + let round = StylexRule::from_metadata_tuple(&rule.to_metadata_tuple()).unwrap(); + assert_eq!(round.const_val.as_deref(), Some(&tagged)); + 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/scopes.rs b/crates/stylex/src/scopes.rs new file mode 100644 index 00000000..ac33db07 --- /dev/null +++ b/crates/stylex/src/scopes.rs @@ -0,0 +1,2081 @@ +//! fru-native binding analysis (`FRU_NATIVE_SCOPES=1`): CompileState's queries from one Visit walk. +// parity: oxc_semantic 0.146 SemanticBuilder + Binder (declaration, hoisting, flags, resolution). + +use std::cell::Cell; +use std::sync::OnceLock; + +use oxc_ast::AstKind; +use oxc_ast::ast::*; +use oxc_ast_visit::{Visit, walk}; +use oxc_span::{GetSpan, Span}; +use oxc_str::{Ident, IdentBuildHasher, IdentHashMap}; +use oxc_syntax::node::NodeId; +use oxc_syntax::operator::UnaryOperator; +use oxc_syntax::reference::{ReferenceFlags, ReferenceId}; +use oxc_syntax::scope::{ScopeFlags, ScopeId}; +use oxc_syntax::symbol::{SymbolFlags, SymbolId}; + +use crate::state::{ + BindingDecl, BindingInfo, MUTATING_ARRAY_METHODS, RefParent, RefSite, + babel_is_export_declaration, babel_is_statement, binding_pattern_type_name, + callee_is_object_mutator, member_object_span, +}; + +/// Which scope analysis backs a [`crate::state::CompileState`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ScopeBackend { + Oxc, + Native, +} + +impl ScopeBackend { + /// `FRU_NATIVE_SCOPES=1` selects the native walk; anything else keeps oxc. + pub fn from_env() -> Self { + static NATIVE: OnceLock = OnceLock::new(); + let native = + *NATIVE.get_or_init(|| std::env::var("FRU_NATIVE_SCOPES").is_ok_and(|v| v == "1")); + if native { + ScopeBackend::Native + } else { + ScopeBackend::Oxc + } + } +} + +type Idx = u32; + +const ROOT: Idx = 0; + +enum DeclSite<'a> { + Declarator(&'a VariableDeclarator<'a>), + NamedImport, + DefaultImport, + NamespaceImport, + Param(&'static str), + Catch, + Function { expression: bool }, + Class { expression: bool }, + Enum, + Other, +} + +struct Symbol<'a> { + name: Ident<'a>, + flags: SymbolFlags, + /// The declaring node's span (babel `binding.path.node`), not the identifier's. + span: Span, + decl: DeclSite<'a>, + redeclared: bool, + var_for_head: bool, + violated: bool, + mutated: bool, +} + +struct Reference<'a> { + name: Ident<'a>, + start: u32, + scope: Idx, + flags: ReferenceFlags, + symbol: Option, + parent: RefParent<'a>, + mutation: bool, +} + +struct Scope<'a> { + parent: Option, + flags: ScopeFlags, + bindings: Vec<(Ident<'a>, Idx)>, + /// oxc `hoisting_variables`: `var` names that passed through this scope. + hoisted: Vec<(Ident<'a>, Idx)>, + /// Union of [`name_bit`] over `bindings`: a clear bit skips the scan. + mask: u64, +} + +/// `Ident::hash` writes its precomputed word once; this hasher just keeps it. +struct HashWord(u64); + +impl std::hash::Hasher for HashWord { + fn finish(&self) -> u64 { + self.0 + } + + fn write(&mut self, _: &[u8]) {} + + fn write_u64(&mut self, word: u64) { + self.0 = word; + } +} + +fn name_bit(name: Ident<'_>) -> u64 { + let mut word = HashWord(0); + std::hash::Hash::hash(&name, &mut word); + 1u64 << (word.0.wrapping_mul(0x9E37_79B9_7F4A_7C15) >> 58) +} + +struct Site { + scope: Idx, + top_stmt_start: u32, + program_level: bool, +} + +pub struct ScopeModel<'a> { + scopes: Vec>, + root_bindings: IdentHashMap<'a, Idx>, + symbols: Vec>, + refs: Vec>, + /// Indexed by the `NodeId` written into call/member/JSX-opening cells. + sites: Vec, + root_unresolved: Vec, +} + +impl<'a> ScopeModel<'a> { + pub fn build(program: &'a Program<'a>) -> Self { + let mut builder = Builder::new(program.source_text.len()); + builder.visit_program(program); + builder.model + } + + fn get_binding(&self, scope: Idx, name: Ident<'_>) -> Option { + self.get_binding_masked(scope, name, name_bit(name)) + } + + fn get_binding_masked(&self, scope: Idx, name: Ident<'_>, bit: u64) -> Option { + if scope == ROOT { + return self.root_bindings.get(&name).copied(); + } + let s = &self.scopes[scope as usize]; + if s.mask & bit == 0 { + return None; + } + s.bindings.iter().find(|(n, _)| *n == name).map(|(_, s)| *s) + } + + fn find_binding(&self, mut scope: Idx, name: Ident<'_>) -> Option { + let bit = name_bit(name); + loop { + if let Some(symbol) = self.get_binding_masked(scope, name, bit) { + return Some(symbol); + } + scope = self.scopes[scope as usize].parent?; + } + } + + fn site(&self, node: NodeId) -> &Site { + debug_assert!(node.index() != 0, "untagged node queried"); + &self.sites[node.index()] + } + + pub fn symbol_of(&self, id: &IdentifierReference<'a>) -> Option { + let reference = id.reference_id.get()?; + self.refs + .get(reference.index())? + .symbol + .map(|s| SymbolId::from_usize(s as usize)) + } + + /// (constantViolations non-empty, isMutated) for one symbol. + pub fn constness(&self, symbol: SymbolId) -> (bool, bool) { + let s = &self.symbols[symbol.index()]; + (s.redeclared || s.var_for_head || s.violated, s.mutated) + } + + pub fn binding_info(&self, symbol: SymbolId) -> BindingInfo<'a> { + let s = &self.symbols[symbol.index()]; + let decl = match s.decl { + DeclSite::Declarator(declarator) => BindingDecl::Declarator(declarator), + DeclSite::NamedImport => BindingDecl::NamedImport, + DeclSite::DefaultImport => BindingDecl::DefaultImport, + DeclSite::NamespaceImport => BindingDecl::NamespaceImport, + DeclSite::Param(type_name) => BindingDecl::Opaque(type_name), + DeclSite::Catch => BindingDecl::Opaque("CatchClause"), + DeclSite::Function { expression } => BindingDecl::Opaque(if expression { + "FunctionExpression" + } else { + "FunctionDeclaration" + }), + DeclSite::Class { expression } => BindingDecl::Opaque(if expression { + "ClassExpression" + } else { + "ClassDeclaration" + }), + DeclSite::Enum => BindingDecl::Opaque("TSEnumDeclaration"), + DeclSite::Other => BindingDecl::Opaque("Identifier"), + }; + BindingInfo { decl, span: s.span } + } + + pub fn root_binding(&self, name: &str) -> Option { + self.get_binding(ROOT, Ident::from(name)) + .map(|s| SymbolId::from_usize(s as usize)) + } + + pub fn uid_name_taken(&self, name: &str) -> bool { + let name = Ident::from(name); + self.root_unresolved + .iter() + .any(|&r| self.refs[r as usize].name == name) + || self.symbols.iter().any(|s| s.name == name) + } + + pub fn reference_starts_where( + &self, + names: &[&str], + keep: impl Fn(&str, RefSite<'_, 'a>) -> bool, + ) -> Vec { + let mut starts: Vec = self + .refs + .iter() + .filter(|r| { + names.contains(&r.name.as_str()) && keep(r.name.as_str(), RefSite::native(r.parent)) + }) + .map(|r| r.start) + .collect(); + starts.sort_unstable(); + starts + } + + pub fn resolves_to_root_binding(&self, node: NodeId, name: &str) -> bool { + let name = Ident::from(name); + let Some(root) = self.get_binding(ROOT, name) else { + return false; + }; + self.find_binding(self.site(node).scope, name) == Some(root) + } + + pub fn any_binding_at(&self, node: NodeId, name: &str) -> bool { + self.find_binding(self.site(node).scope, Ident::from(name)) + .is_some() + } + + pub fn is_program_level(&self, node: NodeId) -> bool { + self.site(node).program_level + } + + pub fn program_statement_start(&self, node: NodeId) -> u32 { + self.site(node).top_stmt_start + } + + #[cfg(test)] + pub(crate) fn symbol_ids(&self) -> impl Iterator + '_ { + (0..self.symbols.len()).map(SymbolId::from_usize) + } + + #[cfg(test)] + pub(crate) fn symbol_name(&self, symbol: SymbolId) -> &'a str { + self.symbols[symbol.index()].name.as_str() + } +} + +fn bound_names<'a>(pattern: &'a BindingPattern<'a>, f: &mut impl FnMut(&'a BindingIdentifier<'a>)) { + match pattern { + BindingPattern::BindingIdentifier(id) => f(id), + BindingPattern::ObjectPattern(object) => { + for property in &object.properties { + bound_names(&property.value, f); + } + if let Some(rest) = &object.rest { + bound_names(&rest.argument, f); + } + } + BindingPattern::ArrayPattern(array) => { + for element in array.elements.iter().flatten() { + bound_names(element, f); + } + if let Some(rest) = &array.rest { + bound_names(&rest.argument, f); + } + } + BindingPattern::AssignmentPattern(assignment) => bound_names(&assignment.left, f), + } +} + +fn upsert<'a>(entries: &mut Vec<(Ident<'a>, Idx)>, name: Ident<'a>, symbol: Idx) { + match entries.iter_mut().find(|(n, _)| *n == name) { + Some(entry) => entry.1 = symbol, + None => entries.push((name, symbol)), + } +} + +struct Builder<'a> { + model: ScopeModel<'a>, + current_scope: Idx, + /// Ancestor kinds, `Program` first; the parent of the node being visited. + stack: Vec>, + /// Ancestors that make a node non-program-level (functions, nested statements). + opaque_depth: u32, + top_stmt_start: u32, + reference_flags: ReferenceFlags, + pending: Vec, + ambient_depth: u32, + var_decl: Option<(VariableDeclarationKind, bool)>, + var_head: Option<*const VariableDeclarator<'a>>, + import_is_type: bool, + catch_clause_span: Span, +} + +impl<'a> Builder<'a> { + fn new(source_len: usize) -> Self { + let per_kb = |n: usize| ((source_len * n) / 1024).max(16); + let mut scopes = Vec::with_capacity(per_kb(6)); + scopes.push(Scope { + parent: None, + flags: ScopeFlags::Top, + bindings: Vec::new(), + hoisted: Vec::new(), + mask: 0, + }); + let mut sites = Vec::with_capacity(per_kb(12)); + sites.push(Site { + scope: ROOT, + top_stmt_start: 0, + program_level: false, + }); + Builder { + model: ScopeModel { + scopes, + root_bindings: IdentHashMap::with_capacity_and_hasher(per_kb(3), IdentBuildHasher), + symbols: Vec::with_capacity(per_kb(4)), + refs: Vec::with_capacity(per_kb(20)), + sites, + root_unresolved: Vec::new(), + }, + current_scope: ROOT, + stack: Vec::with_capacity(64), + opaque_depth: 0, + top_stmt_start: 0, + reference_flags: ReferenceFlags::empty(), + pending: Vec::with_capacity(per_kb(20)), + ambient_depth: 0, + var_decl: None, + var_head: None, + import_is_type: false, + catch_clause_span: Span::default(), + } + } + + fn scope_flags(&self, scope: Idx) -> ScopeFlags { + self.model.scopes[scope as usize].flags + } + + fn scope_has_binding(&self, scope: Idx, name: Ident<'a>) -> bool { + self.model.get_binding(scope, name).is_some() + } + + fn add_binding(&mut self, scope: Idx, name: Ident<'a>, symbol: Idx) { + if scope == ROOT { + self.model.root_bindings.insert(name, symbol); + } else { + let s = &mut self.model.scopes[scope as usize]; + s.mask |= name_bit(name); + upsert(&mut s.bindings, name, symbol); + } + } + + fn remove_binding(&mut self, scope: Idx, name: Ident<'a>) { + if scope == ROOT { + self.model.root_bindings.remove(&name); + } else { + self.model.scopes[scope as usize] + .bindings + .retain(|(n, _)| *n != name); + } + } + + fn check_redeclaration(&self, scope: Idx, name: Ident<'a>) -> Option { + let symbol = self.model.get_binding(scope, name).or_else(|| { + self.model.scopes[scope as usize] + .hoisted + .iter() + .find(|(n, _)| *n == name) + .map(|(_, s)| *s) + })?; + if self.model.symbols[symbol as usize] + .flags + .contains(SymbolFlags::FunctionExpression) + { + return None; + } + Some(symbol) + } + + fn create_symbol( + &mut self, + span: Span, + name: Ident<'a>, + flags: SymbolFlags, + scope: Idx, + decl: DeclSite<'a>, + ) -> Idx { + let var_for_head = + matches!(decl, DeclSite::Declarator(d) if self.var_head == Some(d as *const _)); + let symbol = self.model.symbols.len() as Idx; + self.model.symbols.push(Symbol { + name, + flags, + span, + decl, + redeclared: false, + var_for_head, + violated: false, + mutated: false, + }); + self.add_binding(scope, name, symbol); + symbol + } + + fn declare_symbol_on_scope( + &mut self, + span: Span, + name: Ident<'a>, + scope: Idx, + includes: SymbolFlags, + decl: DeclSite<'a>, + ) -> Idx { + if let Some(symbol) = self.check_redeclaration(scope, name) { + let s = &mut self.model.symbols[symbol as usize]; + s.redeclared = true; + s.flags |= includes; + return symbol; + } + self.create_symbol(span, name, includes, scope, decl) + } + + fn declare_symbol( + &mut self, + span: Span, + name: Ident<'a>, + includes: SymbolFlags, + decl: DeclSite<'a>, + ) -> Idx { + self.declare_symbol_on_scope(span, name, self.current_scope, includes, decl) + } + + fn declare_pattern( + &mut self, + pattern: &'a BindingPattern<'a>, + span: Span, + includes: SymbolFlags, + decl: impl Fn() -> DeclSite<'a>, + ) { + bound_names(pattern, &mut |ident| { + self.declare_symbol(span, ident.name, includes, decl()); + }); + } + + // parity: Binder for VariableDeclarator — lexical names bind here; `var` + // hoists to the nearest var scope, merging with any name met on the way. + fn bind_declarator(&mut self, decl: &'a VariableDeclarator<'a>) { + let (kind, declare) = self.var_decl.expect("declarator outside a declaration"); + let mut includes = match kind { + VariableDeclarationKind::Const + | VariableDeclarationKind::Using + | VariableDeclarationKind::AwaitUsing => { + SymbolFlags::BlockScopedVariable | SymbolFlags::ConstVariable + } + VariableDeclarationKind::Let => SymbolFlags::BlockScopedVariable, + VariableDeclarationKind::Var => SymbolFlags::FunctionScopedVariable, + }; + if declare { + includes |= SymbolFlags::Ambient; + } + if kind.is_lexical() { + self.declare_pattern(&decl.id, decl.span, includes, || DeclSite::Declarator(decl)); + return; + } + let mut target = self.current_scope; + let mut var_scopes: Vec = Vec::new(); + let mut scope = Some(self.current_scope); + while let Some(s) = scope { + if self.scope_flags(s).is_var() { + target = s; + break; + } + var_scopes.push(s); + scope = self.model.scopes[s as usize].parent; + } + bound_names(&decl.id, &mut |ident| { + let name = ident.name; + let mut declared = None; + for &s in &var_scopes { + if let Some(symbol) = self.check_redeclaration(s, name) { + self.model.symbols[symbol as usize].redeclared = true; + declared = Some(symbol); + if !self.scope_has_binding(target, name) { + self.remove_binding(s, name); + self.add_binding(target, name, symbol); + } + break; + } + } + let symbol = declared.unwrap_or_else(|| { + self.declare_symbol_on_scope( + decl.span, + name, + target, + includes, + DeclSite::Declarator(decl), + ) + }); + for &s in &var_scopes { + upsert(&mut self.model.scopes[s as usize].hoisted, name, symbol); + } + }); + } + + fn bind_function(&mut self, func: &'a Function<'a>) { + let Some(ident) = &func.id else { + return; + }; + let mut includes = SymbolFlags::Function; + if func.declare { + includes |= SymbolFlags::Ambient; + } + if func.is_expression() { + includes |= SymbolFlags::FunctionExpression; + } + if func.r#async || func.generator { + includes |= SymbolFlags::AsyncOrGeneratorFunction; + } + self.declare_symbol( + func.span, + ident.name, + includes, + DeclSite::Function { + expression: func.is_expression(), + }, + ); + } + + fn bind_class(&mut self, class: &'a Class<'a>) { + let Some(ident) = &class.id else { + return; + }; + let mut includes = SymbolFlags::Class; + if class.declare { + includes |= SymbolFlags::Ambient; + } + self.declare_symbol( + class.span, + ident.name, + includes, + DeclSite::Class { + expression: class.is_expression(), + }, + ); + } + + fn take_reference_flags(&mut self) -> ReferenceFlags { + if self.reference_flags.is_empty() { + ReferenceFlags::Read + } else { + std::mem::take(&mut self.reference_flags) + } + } + + // parity: state.rs reference_is_mutation + the pass-A member check, read + // off the ancestor stack instead of the node table. + fn reference_parent(&self, ref_span: Span) -> (RefParent<'a>, bool) { + let stack = &self.stack; + let mut i = stack.len(); + let mut child_span = ref_span; + while i > 0 { + match stack[i - 1] { + AstKind::ParenthesizedExpression(paren) => { + child_span = paren.span; + i -= 1; + } + _ => break, + } + } + if i == 0 { + return (RefParent::Other, false); + } + let parent = stack[i - 1]; + if let Some((object_span, property)) = member_object_span(parent) { + let info = RefParent::Member { property }; + if object_span != child_span { + return (info, false); + } + let mut j = i - 1; + let mut member_span = parent.span(); + while j > 0 { + match stack[j - 1] { + AstKind::ParenthesizedExpression(paren) => { + member_span = paren.span; + j -= 1; + } + _ => break, + } + } + if j == 0 { + return (info, false); + } + let mutation = match stack[j - 1] { + AstKind::AssignmentExpression(assignment) => assignment.left.span() == member_span, + AstKind::UpdateExpression(_) => true, + AstKind::UnaryExpression(unary) => unary.operator == UnaryOperator::Delete, + AstKind::CallExpression(call) => { + call.callee.span() == member_span + && property.is_some_and(|p| MUTATING_ARRAY_METHODS.contains(&p)) + } + _ => false, + }; + return (info, mutation); + } + if let AstKind::CallExpression(call) = parent { + let first_arg_span = call + .arguments + .first() + .and_then(|a| a.as_expression()) + .map(GetSpan::span); + let mutation = + first_arg_span == Some(child_span) && callee_is_object_mutator(&call.callee); + return (RefParent::Other, mutation); + } + (RefParent::Other, false) + } + + fn push_site(&mut self, cell: &Cell) { + let index = self.model.sites.len(); + cell.set(NodeId::from_usize(index)); + self.model.sites.push(Site { + scope: self.current_scope, + top_stmt_start: self.top_stmt_start, + program_level: self.opaque_depth == 0, + }); + } + + // parity: SemanticBuilder try_resolve_reference, flag rewrites included. + fn try_resolve(&mut self, reference: Idx, symbol: Idx) -> bool { + let symbol_flags = self.model.symbols[symbol as usize].flags; + let r = &mut self.model.refs[reference as usize]; + let flags = &mut r.flags; + let can_resolve = if flags.is_namespace() + && !flags.is_value_as_type() + && !symbol_flags.can_be_referenced_as_namespace() + { + false + } else { + (flags.is_value() && symbol_flags.can_be_referenced_by_value()) + || (flags.is_type() && symbol_flags.can_be_referenced_by_type()) + || (flags.is_value_as_type() && symbol_flags.can_be_referenced_by_value_as_type()) + }; + if !can_resolve { + return false; + } + if (symbol_flags.is_value() && flags.is_value()) + || (flags.is_namespace() && flags.is_read()) + { + *flags -= ReferenceFlags::Type; + } else { + *flags = ReferenceFlags::Type; + } + r.symbol = Some(symbol); + let s = &mut self.model.symbols[symbol as usize]; + if r.flags.is_write() { + s.violated = true; + } else if r.mutation { + s.mutated = true; + } + true + } + + fn walk_up_resolve(&mut self, reference: Idx) -> bool { + let r = &self.model.refs[reference as usize]; + let name = r.name; + let bit = name_bit(name); + let mut scope = Some(r.scope); + while let Some(s) = scope { + if let Some(symbol) = self.model.get_binding_masked(s, name, bit) + && self.try_resolve(reference, symbol) + { + return true; + } + scope = self.model.scopes[s as usize].parent; + } + false + } + + /// Resolves the references recorded since `checkpoint` against the scopes + /// open now (parameters must not see body declarations). + fn resolve_pending_from(&mut self, checkpoint: usize) { + let mut write = checkpoint; + for read in checkpoint..self.pending.len() { + let reference = self.pending[read]; + if !self.walk_up_resolve(reference) { + self.pending[write] = reference; + write += 1; + } + } + self.pending.truncate(write); + } + + fn resolve_all(&mut self) { + let pending = std::mem::take(&mut self.pending); + for reference in pending { + if !self.walk_up_resolve(reference) { + self.model.root_unresolved.push(reference); + } + } + } + + fn enter_ambient(&mut self, is_ambient: bool) { + self.ambient_depth += u32::from(is_ambient); + } + + fn leave_ambient(&mut self, is_ambient: bool) { + self.ambient_depth -= u32::from(is_ambient); + } + + fn in_ambient_context(&self) -> bool { + self.ambient_depth > 0 + } + + // parity: TSNamespaceDeclaration binder — a namespace holding only types + // is a NamespaceModule; `export { x }` aliases are read as instantiating. + fn namespace_is_instantiated(decl: &TSNamespaceDeclaration<'a>) -> bool { + match &decl.body { + TSNamespaceDeclarationBody::TSNamespaceDeclaration(inner) => { + Self::namespace_is_instantiated(inner) + } + TSNamespaceDeclarationBody::TSModuleBlock(block) => { + block.body.iter().any(Self::statement_instantiates) + } + } + } + + fn statement_instantiates(stmt: &Statement<'a>) -> bool { + match stmt { + Statement::TSInterfaceDeclaration(_) + | Statement::TSTypeAliasDeclaration(_) + | Statement::TSImportEqualsDeclaration(_) => false, + Statement::ExportDefaultDeclaration(export) => !matches!( + export.declaration, + ExportDefaultDeclarationKind::TSInterfaceDeclaration(_) + ), + Statement::ExportDeclaration(export) => match &export.declaration { + Declaration::TSNamespaceDeclaration(inner) => { + Self::namespace_is_instantiated(inner) + } + decl => !decl.is_type(), + }, + Statement::TSNamespaceDeclaration(inner) => Self::namespace_is_instantiated(inner), + _ => true, + } + } +} + +// parity: ast-helpers.js isProgramLevel, kept as a depth counter: a node counts +// when it is function-like or a statement not hanging off Program/export. +fn opaque_step(kind: AstKind<'_>, parent: Option>) -> bool { + kind.is_function_like() + || (babel_is_statement(kind) + && !parent.is_some_and(|p| { + matches!(p, AstKind::Program(_)) || babel_is_export_declaration(p) + })) +} + +impl<'a> Visit<'a> for Builder<'a> { + fn enter_node(&mut self, kind: AstKind<'a>) { + if self.stack.len() == 1 { + self.top_stmt_start = kind.span().start; + } + if opaque_step(kind, self.stack.last().copied()) { + self.opaque_depth += 1; + } + self.stack.push(kind); + } + + fn leave_node(&mut self, kind: AstKind<'a>) { + self.stack.pop(); + if opaque_step(kind, self.stack.last().copied()) { + self.opaque_depth -= 1; + } + } + + fn enter_scope(&mut self, flags: ScopeFlags, _scope_id: &Cell>) { + let index = self.model.scopes.len() as Idx; + self.model.scopes.push(Scope { + parent: Some(self.current_scope), + flags, + bindings: Vec::new(), + hoisted: Vec::new(), + mask: 0, + }); + self.current_scope = index; + } + + fn leave_scope(&mut self) { + self.current_scope = self.model.scopes[self.current_scope as usize] + .parent + .expect("leave_scope on the root scope"); + } + + fn visit_program(&mut self, program: &Program<'a>) { + self.stack.push(AstKind::Program(self.alloc(program))); + self.visit_statements(&program.body); + self.resolve_all(); + self.stack.pop(); + } + + // Leaves never parent a reference and are bound by their parents: skip + // their enter/leave bookkeeping (about half of all nodes). + fn visit_identifier_name(&mut self, _: &IdentifierName<'a>) {} + fn visit_binding_identifier(&mut self, _: &BindingIdentifier<'a>) {} + fn visit_label_identifier(&mut self, _: &LabelIdentifier<'a>) {} + fn visit_private_identifier(&mut self, _: &PrivateIdentifier<'a>) {} + fn visit_this_expression(&mut self, _: &ThisExpression) {} + fn visit_super(&mut self, _: &Super) {} + fn visit_boolean_literal(&mut self, _: &BooleanLiteral) {} + fn visit_null_literal(&mut self, _: &NullLiteral) {} + fn visit_numeric_literal(&mut self, _: &NumericLiteral<'a>) {} + fn visit_string_literal(&mut self, _: &StringLiteral<'a>) {} + fn visit_big_int_literal(&mut self, _: &BigIntLiteral<'a>) {} + fn visit_reg_exp_literal(&mut self, _: &RegExpLiteral<'a>) {} + fn visit_template_element(&mut self, _: &TemplateElement<'a>) {} + fn visit_jsx_text(&mut self, _: &JSXText<'a>) {} + fn visit_jsx_identifier(&mut self, _: &JSXIdentifier<'a>) {} + fn visit_jsx_empty_expression(&mut self, _: &JSXEmptyExpression) {} + fn visit_empty_statement(&mut self, _: &EmptyStatement) {} + fn visit_debugger_statement(&mut self, _: &DebuggerStatement) {} + fn visit_directive(&mut self, _: &Directive<'a>) {} + fn visit_hashbang(&mut self, _: &Hashbang<'a>) {} + fn visit_ts_type_annotation(&mut self, it: &TSTypeAnnotation<'a>) { + if !matches!( + it.type_annotation, + TSType::TSAnyKeyword(_) + | TSType::TSStringKeyword(_) + | TSType::TSNumberKeyword(_) + | TSType::TSBooleanKeyword(_) + | TSType::TSVoidKeyword(_) + | TSType::TSUndefinedKeyword(_) + | TSType::TSNullKeyword(_) + | TSType::TSUnknownKeyword(_) + | TSType::TSNeverKeyword(_) + ) { + walk::walk_ts_type_annotation(self, it); + } + } + + fn visit_identifier_reference(&mut self, ident: &IdentifierReference<'a>) { + let ident = self.alloc(ident); + let flags = self.take_reference_flags(); + let (parent, mutation) = self.reference_parent(ident.span); + let index = self.model.refs.len() as Idx; + self.model.refs.push(Reference { + name: ident.name, + start: ident.span.start, + scope: self.current_scope, + flags, + symbol: None, + parent, + mutation, + }); + ident + .reference_id + .set(Some(ReferenceId::from_usize(index as usize))); + self.pending.push(index); + } + + fn visit_call_expression(&mut self, expr: &CallExpression<'a>) { + let expr = self.alloc(expr); + let kind = AstKind::CallExpression(expr); + self.enter_node(kind); + self.push_site(&expr.node_id); + self.visit_expression(&expr.callee); + if let Some(type_arguments) = &expr.type_arguments { + self.visit_ts_type_parameter_instantiation(type_arguments); + } + self.visit_arguments(&expr.arguments); + self.leave_node(kind); + } + + fn visit_jsx_opening_element(&mut self, it: &JSXOpeningElement<'a>) { + let it = self.alloc(it); + let kind = AstKind::JSXOpeningElement(it); + self.enter_node(kind); + self.push_site(&it.node_id); + self.visit_jsx_element_name(&it.name); + if let Some(type_arguments) = &it.type_arguments { + self.visit_ts_type_parameter_instantiation(type_arguments); + } + self.visit_jsx_attribute_items(&it.attributes); + self.leave_node(kind); + } + + fn visit_member_expression(&mut self, it: &MemberExpression<'a>) { + if self.reference_flags.is_write() { + self.reference_flags = ReferenceFlags::Read | ReferenceFlags::MemberWriteTarget; + } else { + self.reference_flags -= ReferenceFlags::Write; + } + match it { + MemberExpression::ComputedMemberExpression(it) => { + self.visit_computed_member_expression(it); + } + MemberExpression::StaticMemberExpression(it) => self.visit_static_member_expression(it), + MemberExpression::PrivateFieldExpression(it) => self.visit_private_field_expression(it), + } + self.reference_flags = ReferenceFlags::empty(); + } + + fn visit_static_member_expression(&mut self, it: &StaticMemberExpression<'a>) { + let it = self.alloc(it); + let kind = AstKind::StaticMemberExpression(it); + self.enter_node(kind); + self.push_site(&it.node_id); + self.visit_expression(&it.object); + self.visit_identifier_name(&it.property); + self.leave_node(kind); + } + + fn visit_computed_member_expression(&mut self, it: &ComputedMemberExpression<'a>) { + let it = self.alloc(it); + let kind = AstKind::ComputedMemberExpression(it); + self.enter_node(kind); + self.push_site(&it.node_id); + self.visit_expression(&it.object); + self.reference_flags -= ReferenceFlags::MemberWriteTarget; + self.visit_expression(&it.expression); + self.leave_node(kind); + } + + fn visit_update_expression(&mut self, it: &UpdateExpression<'a>) { + let kind = AstKind::UpdateExpression(self.alloc(it)); + self.enter_node(kind); + self.reference_flags = ReferenceFlags::read_write(); + self.visit_simple_assignment_target(&it.argument); + self.leave_node(kind); + } + + fn visit_unary_expression(&mut self, it: &UnaryExpression<'a>) { + let kind = AstKind::UnaryExpression(self.alloc(it)); + self.enter_node(kind); + if it.operator == UnaryOperator::Delete && it.argument.is_member_expression() { + self.reference_flags = ReferenceFlags::Write; + } + self.visit_expression(&it.argument); + self.leave_node(kind); + } + + fn visit_assignment_expression(&mut self, expr: &AssignmentExpression<'a>) { + let kind = AstKind::AssignmentExpression(self.alloc(expr)); + self.enter_node(kind); + if !expr.operator.is_assign() { + self.reference_flags = ReferenceFlags::read_write(); + } + self.visit_assignment_target(&expr.left); + self.visit_expression(&expr.right); + self.leave_node(kind); + } + + fn visit_conditional_expression(&mut self, expr: &ConditionalExpression<'a>) { + let kind = AstKind::ConditionalExpression(self.alloc(expr)); + self.enter_node(kind); + let saved_flags = self.reference_flags; + self.reference_flags -= ReferenceFlags::MemberWriteTarget; + self.visit_expression(&expr.test); + self.reference_flags = saved_flags; + self.visit_expression(&expr.consequent); + self.visit_expression(&expr.alternate); + self.leave_node(kind); + } + + fn visit_simple_assignment_target(&mut self, it: &SimpleAssignmentTarget<'a>) { + if !self.reference_flags.is_write() { + self.reference_flags = ReferenceFlags::Write; + } + match it { + SimpleAssignmentTarget::AssignmentTargetIdentifier(it) => { + self.visit_identifier_reference(it); + } + SimpleAssignmentTarget::TSAsExpression(it) => self.visit_ts_as_expression(it), + SimpleAssignmentTarget::TSSatisfiesExpression(it) => { + self.visit_ts_satisfies_expression(it); + } + SimpleAssignmentTarget::TSNonNullExpression(it) => { + self.visit_ts_non_null_expression(it) + } + SimpleAssignmentTarget::TSTypeAssertion(it) => self.visit_ts_type_assertion(it), + _ => self.visit_member_expression(it.to_member_expression()), + } + } + + fn visit_assignment_target_property_identifier( + &mut self, + it: &AssignmentTargetPropertyIdentifier<'a>, + ) { + let kind = AstKind::AssignmentTargetPropertyIdentifier(self.alloc(it)); + self.enter_node(kind); + self.reference_flags = ReferenceFlags::Write; + self.visit_identifier_reference(&it.binding); + if let Some(init) = &it.init { + self.visit_expression(init); + } + self.leave_node(kind); + } + + fn visit_export_default_declaration_kind(&mut self, it: &ExportDefaultDeclarationKind<'a>) { + match it { + ExportDefaultDeclarationKind::FunctionDeclaration(it) => { + self.visit_function(it, ScopeFlags::Function); + } + ExportDefaultDeclarationKind::ClassDeclaration(it) => self.visit_class(it), + ExportDefaultDeclarationKind::TSInterfaceDeclaration(it) => { + self.visit_ts_interface_declaration(it); + } + ExportDefaultDeclarationKind::Identifier(it) => { + self.reference_flags = ReferenceFlags::Read | ReferenceFlags::Type; + self.visit_identifier_reference(it); + } + _ => self.visit_expression(it.to_expression()), + } + } + + fn visit_export_named_declaration(&mut self, it: &ExportNamedDeclaration<'a>) { + let kind = AstKind::ExportNamedDeclaration(self.alloc(it)); + self.enter_node(kind); + for specifier in &it.specifiers { + self.reference_flags = if it.export_kind.is_type() || specifier.export_kind.is_type() { + ReferenceFlags::Type + } else { + ReferenceFlags::Read | ReferenceFlags::Type + }; + self.visit_export_specifier(specifier); + } + self.leave_node(kind); + } + + fn visit_ts_export_assignment(&mut self, it: &TSExportAssignment<'a>) { + let kind = AstKind::TSExportAssignment(self.alloc(it)); + self.enter_node(kind); + if it.expression.is_identifier_reference() { + self.reference_flags = ReferenceFlags::Read | ReferenceFlags::Type; + } + self.visit_expression(&it.expression); + self.leave_node(kind); + } + + fn visit_ts_type_query(&mut self, ty: &TSTypeQuery<'a>) { + let kind = AstKind::TSTypeQuery(self.alloc(ty)); + self.enter_node(kind); + self.reference_flags = ReferenceFlags::ValueAsType; + self.visit_ts_type_query_expr_name(&ty.expr_name); + if let Some(type_arguments) = &ty.type_arguments { + self.visit_ts_type_parameter_instantiation(type_arguments); + } + self.reference_flags = ReferenceFlags::empty(); + self.leave_node(kind); + } + + fn visit_ts_property_signature(&mut self, sig: &TSPropertySignature<'a>) { + let kind = AstKind::TSPropertySignature(self.alloc(sig)); + self.enter_node(kind); + if sig.key.is_expression() { + self.reference_flags = ReferenceFlags::ValueAsType; + } + self.visit_property_key(&sig.key); + if let Some(type_annotation) = &sig.type_annotation { + self.visit_ts_type_annotation(type_annotation); + } + self.reference_flags = ReferenceFlags::empty(); + self.leave_node(kind); + } + + fn visit_ts_method_signature(&mut self, sig: &TSMethodSignature<'a>) { + let kind = AstKind::TSMethodSignature(self.alloc(sig)); + self.enter_node(kind); + self.enter_scope(ScopeFlags::empty(), &sig.scope_id); + if sig.computed { + self.reference_flags = ReferenceFlags::ValueAsType; + } + self.visit_property_key(&sig.key); + self.reference_flags = ReferenceFlags::empty(); + if let Some(type_parameters) = &sig.type_parameters { + self.visit_ts_type_parameter_declaration(type_parameters); + } + if let Some(this_param) = &sig.this_param { + self.visit_ts_this_parameter(this_param); + } + self.visit_formal_parameters(&sig.params); + if let Some(return_type) = &sig.return_type { + self.visit_ts_type_annotation(return_type); + } + self.leave_scope(); + self.leave_node(kind); + } + + fn visit_method_definition(&mut self, method: &MethodDefinition<'a>) { + let kind = AstKind::MethodDefinition(self.alloc(method)); + self.enter_node(kind); + self.visit_decorators(&method.decorators); + if method.computed && (method.r#type.is_abstract() || self.in_ambient_context()) { + self.reference_flags = ReferenceFlags::ValueAsType; + } + self.visit_property_key(&method.key); + self.reference_flags = ReferenceFlags::empty(); + let flags = match method.kind { + MethodDefinitionKind::Get => ScopeFlags::Function | ScopeFlags::GetAccessor, + MethodDefinitionKind::Set => ScopeFlags::Function | ScopeFlags::SetAccessor, + MethodDefinitionKind::Constructor => ScopeFlags::Function | ScopeFlags::Constructor, + MethodDefinitionKind::Method => ScopeFlags::Function, + }; + self.visit_function(&method.value, flags); + self.leave_node(kind); + } + + fn visit_property_definition(&mut self, prop: &PropertyDefinition<'a>) { + let kind = AstKind::PropertyDefinition(self.alloc(prop)); + self.enter_node(kind); + self.visit_decorators(&prop.decorators); + self.enter_ambient(prop.declare); + if prop.computed && (prop.r#type.is_abstract() || self.in_ambient_context()) { + self.reference_flags = ReferenceFlags::ValueAsType; + } + self.visit_property_key(&prop.key); + self.reference_flags = ReferenceFlags::empty(); + if let Some(type_annotation) = &prop.type_annotation { + self.visit_ts_type_annotation(type_annotation); + } + if let Some(value) = &prop.value { + self.visit_expression(value); + } + self.leave_node(kind); + self.leave_ambient(prop.declare); + } + + fn visit_ts_interface_heritage(&mut self, heritage: &TSInterfaceHeritage<'a>) { + let kind = AstKind::TSInterfaceHeritage(self.alloc(heritage)); + self.enter_node(kind); + self.reference_flags = ReferenceFlags::Type; + self.visit_ts_type_name(&heritage.type_name); + if let Some(type_arguments) = &heritage.type_arguments { + self.visit_ts_type_parameter_instantiation(type_arguments); + } + self.leave_node(kind); + } + + fn visit_ts_class_implements(&mut self, implements: &TSClassImplements<'a>) { + let kind = AstKind::TSClassImplements(self.alloc(implements)); + self.enter_node(kind); + self.reference_flags = ReferenceFlags::Type; + self.visit_ts_type_name(&implements.expression); + if let Some(type_arguments) = &implements.type_arguments { + self.visit_ts_type_parameter_instantiation(type_arguments); + } + self.leave_node(kind); + } + + fn visit_ts_type_reference(&mut self, ty: &TSTypeReference<'a>) { + let kind = AstKind::TSTypeReference(self.alloc(ty)); + self.enter_node(kind); + self.reference_flags = ReferenceFlags::Type; + self.visit_ts_type_name(&ty.type_name); + if let Some(type_arguments) = &ty.type_arguments { + self.visit_ts_type_parameter_instantiation(type_arguments); + } + self.leave_node(kind); + } + + fn visit_ts_qualified_name(&mut self, name: &TSQualifiedName<'a>) { + let kind = AstKind::TSQualifiedName(self.alloc(name)); + self.enter_node(kind); + if self.reference_flags.is_empty() { + self.reference_flags = + ReferenceFlags::Read | ReferenceFlags::Type | ReferenceFlags::Namespace; + } else { + self.reference_flags |= ReferenceFlags::Namespace; + } + self.visit_ts_type_name(&name.left); + self.visit_identifier_name(&name.right); + self.leave_node(kind); + } + + fn visit_class(&mut self, class: &Class<'a>) { + let class = self.alloc(class); + let kind = AstKind::Class(class); + self.enter_node(kind); + if class.is_declaration() { + self.bind_class(class); + } + self.visit_decorators(&class.decorators); + self.enter_ambient(class.declare); + self.enter_scope(ScopeFlags::StrictMode, &class.scope_id); + if class.is_expression() { + self.bind_class(class); + } + if let Some(type_parameters) = &class.type_parameters { + self.visit_ts_type_parameter_declaration(type_parameters); + } + if let Some(heritage) = &class.heritage { + if self.in_ambient_context() { + self.reference_flags = ReferenceFlags::ValueAsType; + } + self.visit_expression(&heritage.expression); + self.reference_flags = ReferenceFlags::empty(); + if let Some(type_arguments) = &heritage.type_arguments { + self.visit_ts_type_parameter_instantiation(type_arguments); + } + } + self.visit_ts_class_implements_list(&class.implements); + self.visit_class_body(&class.body); + self.leave_scope(); + self.leave_node(kind); + self.leave_ambient(class.declare); + } + + fn visit_function(&mut self, func: &Function<'a>, flags: ScopeFlags) { + let func = self.alloc(func); + let kind = AstKind::Function(func); + self.enter_node(kind); + self.enter_ambient(func.declare); + if func.is_declaration() { + self.bind_function(func); + } + self.enter_scope(flags, &func.scope_id); + if func.is_expression() { + self.bind_function(func); + } + let checkpoint = self.pending.len(); + if let Some(type_parameters) = &func.type_parameters { + self.visit_ts_type_parameter_declaration(type_parameters); + } + if let Some(this_param) = &func.this_param { + self.visit_ts_this_parameter(this_param); + } + self.visit_formal_parameters(&func.params); + if let Some(return_type) = &func.return_type { + self.visit_ts_type_annotation(return_type); + } + if func.params.has_parameter() || func.return_type.is_some() { + self.resolve_pending_from(checkpoint); + } + if let Some(body) = &func.body { + self.visit_function_body(body); + } + self.leave_scope(); + self.leave_node(kind); + self.leave_ambient(func.declare); + } + + fn visit_arrow_function_expression(&mut self, expr: &ArrowFunctionExpression<'a>) { + let kind = AstKind::ArrowFunctionExpression(self.alloc(expr)); + self.enter_node(kind); + self.enter_scope(ScopeFlags::Function | ScopeFlags::Arrow, &expr.scope_id); + let checkpoint = self.pending.len(); + if let Some(parameters) = &expr.type_parameters { + self.visit_ts_type_parameter_declaration(parameters); + } + self.visit_formal_parameters(&expr.params); + if let Some(return_type) = &expr.return_type { + self.visit_ts_type_annotation(return_type); + } + if expr.params.has_parameter() || expr.return_type.is_some() { + self.resolve_pending_from(checkpoint); + } + self.visit_arrow_function_body(&expr.body); + self.leave_scope(); + self.leave_node(kind); + } + + fn visit_formal_parameter(&mut self, param: &FormalParameter<'a>) { + let param = self.alloc(param); + let kind = AstKind::FormalParameter(param); + self.enter_node(kind); + let type_name = binding_pattern_type_name(¶m.pattern); + self.declare_pattern( + ¶m.pattern, + param.span, + SymbolFlags::FunctionScopedVariable, + || DeclSite::Param(type_name), + ); + self.visit_decorators(¶m.decorators); + self.visit_binding_pattern(¶m.pattern); + if let Some(type_annotation) = ¶m.type_annotation { + self.visit_ts_type_annotation(type_annotation); + } + if let Some(initializer) = ¶m.initializer { + self.visit_expression(initializer); + } + self.leave_node(kind); + } + + fn visit_formal_parameter_rest(&mut self, param: &FormalParameterRest<'a>) { + let param = self.alloc(param); + let kind = AstKind::FormalParameterRest(param); + self.enter_node(kind); + self.declare_pattern( + ¶m.rest.argument, + param.span, + SymbolFlags::FunctionScopedVariable, + || DeclSite::Other, + ); + self.visit_decorators(¶m.decorators); + self.visit_binding_rest_element(¶m.rest); + if let Some(type_annotation) = ¶m.type_annotation { + self.visit_ts_type_annotation(type_annotation); + } + self.leave_node(kind); + } + + fn visit_catch_clause(&mut self, clause: &CatchClause<'a>) { + self.catch_clause_span = clause.span; + walk::walk_catch_clause(self, clause); + } + + fn visit_catch_parameter(&mut self, param: &CatchParameter<'a>) { + let param = self.alloc(param); + let kind = AstKind::CatchParameter(param); + self.enter_node(kind); + let clause_span = self.catch_clause_span; + if let BindingPattern::BindingIdentifier(ident) = ¶m.pattern { + self.create_symbol( + clause_span, + ident.name, + SymbolFlags::FunctionScopedVariable | SymbolFlags::CatchVariable, + self.current_scope, + DeclSite::Catch, + ); + } else { + self.declare_pattern( + ¶m.pattern, + clause_span, + SymbolFlags::BlockScopedVariable | SymbolFlags::CatchVariable, + || DeclSite::Catch, + ); + } + let checkpoint = self.pending.len(); + self.visit_binding_pattern(¶m.pattern); + if let Some(type_annotation) = ¶m.type_annotation { + self.visit_ts_type_annotation(type_annotation); + } + self.resolve_pending_from(checkpoint); + self.leave_node(kind); + } + + fn visit_block_statement(&mut self, it: &BlockStatement<'a>) { + let kind = AstKind::BlockStatement(self.alloc(it)); + self.enter_node(kind); + let parent_scope = self.current_scope; + self.enter_scope(ScopeFlags::empty(), &it.scope_id); + if self.scope_flags(parent_scope).is_catch_clause() { + let parent = &mut self.model.scopes[parent_scope as usize]; + let (moved, mask) = (std::mem::take(&mut parent.bindings), parent.mask); + let block = &mut self.model.scopes[self.current_scope as usize]; + block.bindings = moved; + block.mask |= mask; + } + self.visit_statements(&it.body); + self.leave_scope(); + self.leave_node(kind); + } + + fn visit_for_in_statement(&mut self, stmt: &ForInStatement<'a>) { + let kind = AstKind::ForInStatement(self.alloc(stmt)); + self.enter_node(kind); + self.enter_scope(ScopeFlags::empty(), &stmt.scope_id); + self.var_head = var_head_declarator(&stmt.left); + self.visit_for_statement_left(&stmt.left); + self.var_head = None; + self.visit_expression(&stmt.right); + self.visit_statement(&stmt.body); + self.leave_scope(); + self.leave_node(kind); + } + + fn visit_for_of_statement(&mut self, stmt: &ForOfStatement<'a>) { + let kind = AstKind::ForOfStatement(self.alloc(stmt)); + self.enter_node(kind); + self.enter_scope(ScopeFlags::empty(), &stmt.scope_id); + self.var_head = var_head_declarator(&stmt.left); + self.visit_for_statement_left(&stmt.left); + self.var_head = None; + self.visit_expression(&stmt.right); + self.visit_statement(&stmt.body); + self.leave_scope(); + self.leave_node(kind); + } + + fn visit_variable_declaration(&mut self, it: &VariableDeclaration<'a>) { + let kind = AstKind::VariableDeclaration(self.alloc(it)); + self.enter_node(kind); + self.enter_ambient(it.declare); + let saved = self.var_decl.replace((it.kind, it.declare)); + self.visit_variable_declarators(&it.declarations); + self.var_decl = saved; + self.leave_node(kind); + self.leave_ambient(it.declare); + } + + fn visit_variable_declarator(&mut self, decl: &VariableDeclarator<'a>) { + let decl = self.alloc(decl); + let kind = AstKind::VariableDeclarator(decl); + self.enter_node(kind); + self.bind_declarator(decl); + self.visit_binding_pattern(&decl.id); + if let Some(type_annotation) = &decl.type_annotation { + self.visit_ts_type_annotation(type_annotation); + } + if let Some(init) = &decl.init { + self.visit_expression(init); + } + self.leave_node(kind); + } + + fn visit_import_declaration(&mut self, decl: &ImportDeclaration<'a>) { + let kind = AstKind::ImportDeclaration(self.alloc(decl)); + self.enter_node(kind); + self.import_is_type = decl.import_kind.is_type(); + if let Some(specifiers) = &decl.specifiers { + for specifier in specifiers { + self.visit_import_declaration_specifier(specifier); + } + } + self.leave_node(kind); + } + + fn visit_import_specifier(&mut self, specifier: &ImportSpecifier<'a>) { + let specifier = self.alloc(specifier); + let kind = AstKind::ImportSpecifier(specifier); + self.enter_node(kind); + let includes = if specifier.import_kind.is_type() || self.import_is_type { + SymbolFlags::TypeImport + } else { + SymbolFlags::Import + }; + self.declare_symbol( + specifier.span, + specifier.local.name, + includes, + DeclSite::NamedImport, + ); + self.leave_node(kind); + } + + fn visit_import_default_specifier(&mut self, specifier: &ImportDefaultSpecifier<'a>) { + let specifier = self.alloc(specifier); + let kind = AstKind::ImportDefaultSpecifier(specifier); + self.enter_node(kind); + let includes = if self.import_is_type { + SymbolFlags::TypeImport + } else { + SymbolFlags::Import + }; + self.declare_symbol( + specifier.span, + specifier.local.name, + includes, + DeclSite::DefaultImport, + ); + self.leave_node(kind); + } + + fn visit_import_namespace_specifier(&mut self, specifier: &ImportNamespaceSpecifier<'a>) { + let specifier = self.alloc(specifier); + let kind = AstKind::ImportNamespaceSpecifier(specifier); + self.enter_node(kind); + let includes = if self.import_is_type { + SymbolFlags::TypeImport + } else { + SymbolFlags::Import + }; + self.declare_symbol( + specifier.span, + specifier.local.name, + includes, + DeclSite::NamespaceImport, + ); + self.leave_node(kind); + } + + fn visit_ts_import_equals_declaration(&mut self, decl: &TSImportEqualsDeclaration<'a>) { + let decl = self.alloc(decl); + let kind = AstKind::TSImportEqualsDeclaration(decl); + self.enter_node(kind); + self.declare_symbol( + decl.span, + decl.id.name, + SymbolFlags::Import, + DeclSite::Other, + ); + self.reference_flags = if decl.import_kind.is_type() { + ReferenceFlags::Type + } else { + ReferenceFlags::Read | ReferenceFlags::Type + }; + self.visit_ts_module_reference(&decl.module_reference); + self.reference_flags = ReferenceFlags::empty(); + self.leave_node(kind); + } + + fn visit_ts_module_reference(&mut self, module_reference: &TSModuleReference<'a>) { + match module_reference { + TSModuleReference::ExternalModuleReference(reference) => { + self.visit_ts_external_module_reference(reference); + } + TSModuleReference::IdentifierReference(reference) => { + self.reference_flags |= ReferenceFlags::Namespace; + self.visit_identifier_reference(reference); + } + TSModuleReference::QualifiedName(name) => self.visit_ts_qualified_name(name), + } + } + + fn visit_ts_external_module_declaration(&mut self, decl: &TSExternalModuleDeclaration<'a>) { + self.enter_ambient(decl.declare); + walk::walk_ts_external_module_declaration(self, decl); + self.leave_ambient(decl.declare); + } + + fn visit_ts_global_declaration(&mut self, decl: &TSGlobalDeclaration<'a>) { + self.enter_ambient(decl.declare); + walk::walk_ts_global_declaration(self, decl); + self.leave_ambient(decl.declare); + } + + fn visit_ts_namespace_declaration(&mut self, decl: &TSNamespaceDeclaration<'a>) { + let decl = self.alloc(decl); + let kind = AstKind::TSNamespaceDeclaration(decl); + self.enter_node(kind); + self.enter_ambient(decl.declare); + let mut includes = if Self::namespace_is_instantiated(decl) { + SymbolFlags::ValueModule + } else { + SymbolFlags::NamespaceModule + }; + if decl.declare { + includes |= SymbolFlags::Ambient; + } + self.declare_symbol(decl.span, decl.id.name, includes, DeclSite::Other); + self.enter_scope(ScopeFlags::TsModuleBlock, &decl.scope_id); + self.visit_ts_namespace_declaration_body(&decl.body); + self.leave_scope(); + self.leave_node(kind); + self.leave_ambient(decl.declare); + } + + fn visit_ts_type_alias_declaration(&mut self, decl: &TSTypeAliasDeclaration<'a>) { + let decl = self.alloc(decl); + let kind = AstKind::TSTypeAliasDeclaration(decl); + self.enter_node(kind); + self.enter_ambient(decl.declare); + let mut includes = SymbolFlags::TypeAlias; + if decl.declare { + includes |= SymbolFlags::Ambient; + } + self.declare_symbol(decl.span, decl.id.name, includes, DeclSite::Other); + self.enter_scope(ScopeFlags::empty(), &decl.scope_id); + if let Some(type_parameters) = &decl.type_parameters { + self.visit_ts_type_parameter_declaration(type_parameters); + } + self.visit_ts_type(&decl.type_annotation); + self.leave_scope(); + self.leave_node(kind); + self.leave_ambient(decl.declare); + } + + fn visit_ts_interface_declaration(&mut self, decl: &TSInterfaceDeclaration<'a>) { + let decl = self.alloc(decl); + let kind = AstKind::TSInterfaceDeclaration(decl); + self.enter_node(kind); + self.enter_ambient(decl.declare); + let mut includes = SymbolFlags::Interface; + if decl.declare { + includes |= SymbolFlags::Ambient; + } + self.declare_symbol(decl.span, decl.id.name, includes, DeclSite::Other); + self.enter_scope(ScopeFlags::empty(), &decl.scope_id); + if let Some(type_parameters) = &decl.type_parameters { + self.visit_ts_type_parameter_declaration(type_parameters); + } + self.visit_ts_interface_heritages(&decl.extends); + self.visit_ts_interface_body(&decl.body); + self.leave_scope(); + self.leave_node(kind); + self.leave_ambient(decl.declare); + } + + fn visit_ts_enum_declaration(&mut self, decl: &TSEnumDeclaration<'a>) { + let decl = self.alloc(decl); + let kind = AstKind::TSEnumDeclaration(decl); + self.enter_node(kind); + self.enter_ambient(decl.declare); + let mut includes = if decl.r#const { + SymbolFlags::ConstEnum + } else { + SymbolFlags::RegularEnum + }; + if decl.declare { + includes |= SymbolFlags::Ambient; + } + self.declare_symbol(decl.span, decl.id.name, includes, DeclSite::Enum); + self.visit_ts_enum_body(&decl.body); + self.leave_node(kind); + self.leave_ambient(decl.declare); + } + + fn visit_ts_enum_member(&mut self, member: &TSEnumMember<'a>) { + let member = self.alloc(member); + let kind = AstKind::TSEnumMember(member); + self.enter_node(kind); + let name = match &member.id { + TSEnumMemberName::Identifier(ident) => ident.name, + TSEnumMemberName::String(lit) | TSEnumMemberName::ComputedString(lit) => { + Ident::from(lit.value) + } + TSEnumMemberName::ComputedTemplateString(template) => { + template.quasis.first().map_or(Ident::empty(), |quasi| { + Ident::from(quasi.value.cooked.unwrap_or(quasi.value.raw)) + }) + } + }; + self.declare_symbol(member.span, name, SymbolFlags::EnumMember, DeclSite::Other); + self.visit_ts_enum_member_name(&member.id); + if let Some(initializer) = &member.initializer { + self.visit_expression(initializer); + } + self.leave_node(kind); + } + + fn visit_ts_type_parameter(&mut self, param: &TSTypeParameter<'a>) { + let param = self.alloc(param); + let kind = AstKind::TSTypeParameter(param); + self.enter_node(kind); + let mut scope = self.current_scope; + if matches!( + self.stack.get(self.stack.len().wrapping_sub(2)), + Some(AstKind::TSInferType(_)) + ) { + let mut candidate = Some(self.current_scope); + while let Some(s) = candidate { + if self.scope_flags(s).is_ts_conditional() { + scope = s; + break; + } + candidate = self.model.scopes[s as usize].parent; + } + } + self.declare_symbol_on_scope( + param.span, + param.name.name, + scope, + SymbolFlags::TypeParameter, + DeclSite::Other, + ); + if let Some(constraint) = ¶m.constraint { + self.visit_ts_type(constraint); + } + if let Some(default) = ¶m.default { + self.visit_ts_type(default); + } + self.leave_node(kind); + } + + fn visit_ts_mapped_type(&mut self, it: &TSMappedType<'a>) { + let it = self.alloc(it); + let kind = AstKind::TSMappedType(it); + self.enter_node(kind); + self.enter_scope(ScopeFlags::empty(), &it.scope_id); + self.declare_symbol( + it.span, + it.key.name, + SymbolFlags::TypeParameter, + DeclSite::Other, + ); + self.visit_ts_type(&it.constraint); + if let Some(name_type) = &it.name_type { + self.visit_ts_type(name_type); + } + if let Some(type_annotation) = &it.type_annotation { + self.visit_ts_type(type_annotation); + } + self.leave_scope(); + self.leave_node(kind); + } +} + +fn var_head_declarator<'a>(left: &ForStatementLeft<'a>) -> Option<*const VariableDeclarator<'a>> { + match left { + ForStatementLeft::VariableDeclaration(decl) + if decl.kind == VariableDeclarationKind::Var => + { + decl.declarations.first().map(|d| d as *const _) + } + _ => None, + } +} + +/// Differential harness: every CompileState query answered by both backends +/// over the same program, compared by content (ids differ by construction). +#[cfg(test)] +pub(crate) mod tests { + use std::cell::RefCell; + use std::collections::BTreeSet; + + use oxc_allocator::Allocator; + use oxc_parser::Parser; + use oxc_span::SourceType; + + use super::*; + use crate::imports::ImportTable; + use crate::options::ResolvedOptions; + use crate::state::CompileState; + + struct Collector<'a> { + refs: Vec<&'a IdentifierReference<'a>>, + sites: Vec<(&'a Cell, u32)>, + } + + impl<'a> Visit<'a> for Collector<'a> { + fn visit_identifier_reference(&mut self, it: &IdentifierReference<'a>) { + self.refs.push(self.alloc(it)); + } + + fn visit_call_expression(&mut self, it: &CallExpression<'a>) { + let it = self.alloc(it); + self.sites.push((&it.node_id, it.span.start)); + walk::walk_call_expression(self, it); + } + + fn visit_static_member_expression(&mut self, it: &StaticMemberExpression<'a>) { + let it = self.alloc(it); + self.sites.push((&it.node_id, it.span.start)); + walk::walk_static_member_expression(self, it); + } + + fn visit_computed_member_expression(&mut self, it: &ComputedMemberExpression<'a>) { + let it = self.alloc(it); + self.sites.push((&it.node_id, it.span.start)); + walk::walk_computed_member_expression(self, it); + } + + fn visit_jsx_opening_element(&mut self, it: &JSXOpeningElement<'a>) { + let it = self.alloc(it); + self.sites.push((&it.node_id, it.span.start)); + walk::walk_jsx_opening_element(self, it); + } + } + + const PROBES: [&str; 7] = [ + "stylex", "_stylex", "_temp", "_styles", "_inject", "React", "x", + ]; + + /// One query surface, rendered as sorted text lines so a diff names the + /// exact query and construct. + fn snapshot( + state: &CompileState<'_>, + collector: &Collector<'_>, + names: &[String], + ) -> Vec { + let mut lines = Vec::new(); + let label = |decl: BindingDecl<'_>| match decl { + BindingDecl::Declarator(_) => "VariableDeclarator", + BindingDecl::NamedImport => "NamedImport", + BindingDecl::DefaultImport => "DefaultImport", + BindingDecl::NamespaceImport => "NamespaceImport", + BindingDecl::Opaque(type_name) => type_name, + }; + for id in &collector.refs { + let binding = state.symbol_of(id).map(|symbol| { + let info = state.binding_info(symbol); + format!( + "{} {}..{} violated={} mutated={}", + label(info.decl), + info.span.start, + info.span.end, + state.is_non_constant(symbol), + state.is_mutated(symbol) + ) + }); + lines.push(format!( + "ref {}@{} -> {:?}", + id.name, id.span.start, binding + )); + } + for (name, kind, span) in state.debug_symbols() { + lines.push(format!("symbol {name} {kind} {}..{}", span.start, span.end)); + } + let name_refs: Vec<&str> = names.iter().map(String::as_str).collect(); + let parents = RefCell::new(Vec::new()); + let starts = state.reference_starts_where(&name_refs, |name, site| { + parents + .borrow_mut() + .push(format!("parent {name} {:?}", site.parent())); + true + }); + lines.push(format!("starts {starts:?}")); + lines.extend(parents.into_inner()); + for name in names { + lines.push(format!( + "uid {name} taken={} root={}", + state.uid_name_taken(name), + state.root_binding(name).is_some() + )); + } + let sampled: Vec<&String> = names + .iter() + .step_by(names.len().div_ceil(40).max(1)) + .collect(); + for (cell, start) in &collector.sites { + let node = cell.get(); + lines.push(format!( + "site @{start} program_level={} stmt={}", + state.is_program_level(node), + state.program_statement_start(node) + )); + for name in &sampled { + lines.push(format!( + "site @{start} {name} any={} root={}", + state.any_binding_at(node, name), + state.resolves_to_root_binding(node, name) + )); + } + } + lines.sort(); + lines + } + + /// Lines the two backends disagree on for `source` (empty = parity). + pub(crate) fn diff_backends(source: &str) -> Vec { + let allocator = Allocator::default(); + let ret = Parser::new(&allocator, source, SourceType::tsx()).parse(); + if ret.panicked || ret.diagnostics.has_errors() { + return Vec::new(); + } + let program = &ret.program; + let mut collector = Collector { + refs: Vec::new(), + sites: Vec::new(), + }; + collector.visit_program(program); + let options = ResolvedOptions::default(); + let build = |backend| { + CompileState::build_with_imports_using( + program, + &options, + None, + String::new(), + ImportTable::default(), + backend, + ) + }; + let oxc = build(ScopeBackend::Oxc); + let mut names: BTreeSet = + collector.refs.iter().map(|r| r.name.to_string()).collect(); + names.extend(oxc.debug_symbols().into_iter().map(|(name, _, _)| name)); + names.extend(PROBES.iter().map(|p| p.to_string())); + let names: Vec = names.into_iter().collect(); + let expected = snapshot(&oxc, &collector, &names); + drop(oxc); + let native = build(ScopeBackend::Native); + let actual = snapshot(&native, &collector, &names); + let expected_set: BTreeSet<&String> = expected.iter().collect(); + let actual_set: BTreeSet<&String> = actual.iter().collect(); + let mut out: Vec = expected_set + .difference(&actual_set) + .map(|line| format!("oxc only: {line}")) + .collect(); + out.extend( + actual_set + .difference(&expected_set) + .map(|line| format!("native only: {line}")), + ); + out + } + + fn assert_parity(label: &str, source: &str) { + let diff = diff_backends(source); + assert!(diff.is_empty(), "{label}:\n{}", diff.join("\n")); + } + + /// An empty diff must mean parity, not an empty snapshot. + #[test] + fn harness_records_every_query_kind() { + let source = "import * as stylex from '@stylexjs/stylex'; const s = stylex.create({});\n\ +function f() { return stylex.props(s.a); }"; + let allocator = Allocator::default(); + let ret = Parser::new(&allocator, source, SourceType::tsx()).parse(); + let mut collector = Collector { + refs: Vec::new(), + sites: Vec::new(), + }; + collector.visit_program(&ret.program); + let options = ResolvedOptions::default(); + let state = CompileState::build_with_imports_using( + &ret.program, + &options, + None, + String::new(), + ImportTable::default(), + ScopeBackend::Native, + ); + let names = vec!["stylex".to_string(), "s".to_string()]; + let lines = snapshot(&state, &collector, &names); + let has = |prefix: &str| lines.iter().any(|l| l.starts_with(prefix)); + assert!( + has("ref stylex@54 -> Some(\"NamespaceImport 7..18"), + "{lines:#?}" + ); + assert!(has("symbol s VariableDeclarator 50..71"), "{lines:#?}"); + assert!( + has("parent stylex Member { property: Some(\"create\") }"), + "{lines:#?}" + ); + assert!(has("uid stylex taken=true root=true"), "{lines:#?}"); + assert!(has("site @54 program_level=true stmt=44"), "{lines:#?}"); + assert!( + lines.iter().any(|l| l.contains("program_level=false")), + "{lines:#?}" + ); + assert!( + lines.iter().any(|l| l.contains(" s any=true root=true")), + "{lines:#?}" + ); + } + + #[test] + fn native_matches_oxc_on_binding_shapes() { + let snippets: [(&str, &str); 24] = [ + ( + "var hoisting", + "{ var a = 1; } { var a = 2; } function f() { { var b; } var b; b; } a; b;", + ), + ( + "catch clauses", + "try {} catch (e) { var e; e; } try {} catch ({ m }) { m; } try {} catch { x; }", + ), + ( + "function expression name", + "const f = function n(n) { return n; }; (function g(g) {}); f; n;", + ), + ( + "params before body", + "function f(a = b, { c } = d) { var b, d; a; c; } const g = (p = q) => { let q; p; };", + ), + ( + "class shapes", + "class A extends B { static x = A; [k]() {} } const C = class D extends A {}; D; new A();", + ), + ( + "ts declarations", + "type T = number; interface I { x: T } enum E { A, B = A } namespace N { export const v = 1; } declare const d: E; N.v; E.A; d;", + ), + ( + "type-only references", + "const v = 1; type Q = typeof v; type R = v; let w: Q; import type { TT } from './t'; TT; w;", + ), + ( + "exports", + "const a = 1, b = 2; export { a, b as c }; export default a; export type { T }; type T = 1;", + ), + ( + "writes", + "let a = 1, b = 2, c = 3, d = {}, e = []; a = 2; b += 1; c++; d.x = 1; e.push(1); [a, b] = [b, a]; ({ a } = d); for (a of e) {} for (b in d) {}", + ), + ( + "mutations through parens", + "const u = {}; (u).x = 1; const v = {}; Object.assign((v), {}); const w = {}; Object.assign((w as any), {}); const q = []; (q)[\"x\"] = 1; delete (u).y; (v.z)++;", + ), + ( + "member keys", + "const o = {}, k = 'a'; o[k] = 1; o[k]; o.k; this[k] = 2; (o ? k : o).x = 1;", + ), + ( + "jsx", + "import * as React from 'react'; import { Foo } from './f'; const el =
{y}
; ; ;", + ), + ( + "nested statements", + "if (a) f(); else { g(); } for (;;) h(); while (x) { i(); } label: j(); switch (k) { case 1: l(); } try { m(); } finally { n(); }", + ), + ( + "stylex shapes", + "import * as stylex from '@stylexjs/stylex'; const s = stylex.create({ a: { color: 'red' } }); function C() { const t = stylex.create({}); return
; }", + ), + ( + "shadowed stylex", + "import * as stylex from '@stylexjs/stylex'; function f(stylex) { stylex.create({}); } { const stylex = 1; stylex.create({}); }", + ), + ( + "labels and breaks", + "outer: for (const a of b) { inner: for (const c of d) { if (c) break outer; continue inner; } }", + ), + ( + "generators and async", + "async function* g() { yield await p; } const h = async () => { for await (const x of y) {} };", + ), + ( + "destructuring params", + "function f({ a, b: [c, ...d] }, ...rest) { a; c; d; rest; } const g = ([p, { q = r }]) => p + q;", + ), + ( + "type params", + "function f(a: T): V { return a as any; } type M = { [P in K]: P }; type C = X extends infer R ? R : never;", + ), + ( + "declare contexts", + "declare class A extends B {} declare module 'm' { export const x: number; } declare global { interface Window { w: 1 } } abstract class Q { abstract [k](): void; declare [j]: 1; }", + ), + ( + "import equals", + "import fs = require('fs'); import n = N.M; namespace N { export namespace M {} } fs; n;", + ), + ( + "template and tagged", + "const t = `${a} ${b.c}`; tag`x${d}`; const { [e]: f } = g;", + ), + ( + "switch scope", + "switch (x) { case y: { let z = 1; z; } default: let w; w; } let v; v;", + ), + ( + "enum member names", + "enum E { 'a b' = 1, [`c`] = 2, D } E['a b'];", + ), + ]; + for (label, source) in snippets { + assert_parity(label, source); + } + } + + #[test] + fn native_matches_oxc_on_corpora() { + let mut sources = crate::transform::visitor::tests::corpus_sources(); + if let Ok(paths) = std::env::var("FRU_SCOPES_CORPUS") { + for path in paths.split(':').filter(|p| !p.is_empty()) { + let text = std::fs::read_to_string(path).expect("corpus ndjson"); + for line in text.lines() { + let job: serde_json::Value = serde_json::from_str(line).unwrap(); + let Some(source) = job.get("source").and_then(|s| s.as_str()) else { + continue; + }; + let id = job["id"].as_str().unwrap_or("?"); + sources.push((format!("{path}:{id}"), source.to_string())); + } + } + } + if sources.is_empty() { + eprintln!("skipping native_matches_oxc_on_corpora: conformance corpus not vendored"); + return; + } + let mut failures = Vec::new(); + let mut checked = 0usize; + for (filename, source) in &sources { + let diff = diff_backends(source); + checked += 1; + if !diff.is_empty() { + failures.push(format!("{filename}:\n {}", diff.join("\n "))); + } + } + eprintln!( + "scope parity: {checked} files, {} with differences", + failures.len() + ); + assert!( + failures.is_empty(), + "{} files differ:\n{}", + failures.len(), + failures.join("\n") + ); + } +} diff --git a/crates/stylex/src/shared/create.rs b/crates/stylex/src/shared/create.rs new file mode 100644 index 00000000..798e3eb7 --- /dev/null +++ b/crates/stylex/src/shared/create.rs @@ -0,0 +1,530 @@ +//! `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::dashify::sanitize_dev_class_name; +use crate::shared::dev_naming::{ + DebugPathInfo, convert_to_test_styles, create_short_filename, dev_class_prefix, +}; +use crate::shared::flatten::{ + PreRule, PreRuleValue, StyleScalar, flatten_raw_style_object, media_order_transform, + validate_namespace, +}; +use crate::shared::generate_rule::CompiledDecl; +use crate::shared::resolution::is_unexpanded_property; +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`). + 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 shape = DevShape { + dev_prefix: dev_prefix_for(ctx, None), + debug: None, + }; + compile_namespaces_core(&namespaces, ctx, false, &mut shape) +} + +/// 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 options = ctx.options; + let debug = if options.debug && options.enable_debug_data_prop { + ctx.namespace_lines + .as_ref() + .zip(ctx.filename.as_deref()) + .map(|(lines, filename)| DebugSource { + lines, + filename, + info: 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), + }, + short: None, + }) + } else { + None + }; + let mut shape = DevShape { + dev_prefix: dev_prefix_for(ctx, ctx.var_name.as_deref()), + debug, + }; + let mut out = compile_namespaces_core(namespaces, ctx, need_class_paths, &mut shape)?; + if options.test { + out.compiled = convert_to_test_styles( + &out.compiled, + ctx.var_name.as_deref(), + ctx.filename.as_deref(), + ); + } + Ok(out) +} + +struct DevShape<'a> { + dev_prefix: Option, + debug: Option>, +} + +struct DebugSource<'a> { + lines: &'a BTreeMap, + filename: &'a str, + info: DebugPathInfo, + short: Option, +} + +impl DevShape<'_> { + // parity: dev-classname.js — `{[devClass]: devClass, ...namespace}` + fn dev_class(&self, namespace: &str) -> Option { + let prefix = self.dev_prefix.as_ref()?; + Some(sanitize_dev_class_name(&format!("{prefix}{namespace}"))) + } + + // parity: add-sourcemap-data.js — namespaces without a known source line + // (upstream cannot find them in the AST) keep `$$css: true`. + fn css_marker(&mut self, namespace: &str) -> EvalValue { + let Some(debug) = &mut self.debug else { + return EvalValue::Bool(true); + }; + match debug.lines.get(namespace) { + Some(&line) if line > 0 => { + let short = debug + .short + .get_or_insert_with(|| create_short_filename(debug.filename, &debug.info)); + if short.is_empty() { + EvalValue::Bool(true) + } else { + EvalValue::Str(format!("{short}:{line}")) + } + } + _ => EvalValue::Bool(true), + } + } +} + +fn dev_prefix_for(ctx: &CreateContext<'_>, var_name: Option<&str>) -> Option { + (ctx.options.dev && ctx.options.enable_dev_class_names) + .then(|| dev_class_prefix(var_name, ctx.filename.as_deref().unwrap_or("UnknownFile"))) +} + +fn compile_namespaces_core( + namespaces: &JsObjectMap, + ctx: &CreateContext<'_>, + need_class_paths: bool, + shape: &mut DevShape<'_>, +) -> Result { + let _t = crate::timings::start(crate::timings::Stage::Create); + let options = ctx.options; + let mut resolved_namespaces = JsObjectMap::with_capacity(namespaces.len()); + let mut rules: Vec = Vec::new(); + let mut class_paths: Vec<(String, ClassPathsInNamespace)> = Vec::new(); + let mut seen_class_names: FxHashSet> = FxHashSet::default(); + let mut decls: Vec = Vec::new(); + + 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; + } + let EvalValue::Obj(namespace) = namespace_value else { + return Err(StylexError::illegal_namespace_value()); + }; + + let transformed; + let entries = if namespace.get("__proto__").is_none() + && namespace.entries().all(|(key, value)| { + matches!( + value, + EvalValue::Null | EvalValue::Str(_) | EvalValue::Num(_) + ) && !key.contains('(') + && is_unexpanded_property(key, options.style_resolution) + }) { + NamespaceEntries::Raw { + namespace, + index: 0, + } + } else { + validate_namespace(namespace_value)?; + let namespace_ref = match media_order_transform(namespace, options)? { + Some(rebuilt) => { + transformed = rebuilt; + &transformed + } + None => namespace, + }; + NamespaceEntries::Prepared( + dedupe_last_wins(flatten_raw_style_object(namespace_ref, options)?).into_iter(), + ) + }; + + let mut namespace_obj = JsObjectMap::with_capacity(entries.len() + 2); + if let Some(dev_class) = shape.dev_class(namespace_name) { + namespace_obj.insert(dev_class.clone(), EvalValue::Str(dev_class)); + } + let mut paths_in_namespace: Vec<(String, Vec)> = Vec::new(); + for (key, pre_rule) in entries { + // Variables-as-keys skip minification to avoid dynamic-style regressions. + let display_key = if options.enable_minified_keys && !key.starts_with("--") { + minified_display_key(&key, options.debug) + } else { + key.to_string() + }; + + decls.clear(); + pre_rule.for_each_compiled(options, &mut |decl, key_path| { + 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(), + )), + } + } + decls.push(decl); + })?; + + let mut joined = + String::with_capacity(decls.iter().map(|d| d.class_name.len() + 1).sum()); + let mut first = true; + for (i, decl) in decls.iter().enumerate() { + if decls[..i] + .iter() + .any(|seen| seen.class_name == decl.class_name) + { + continue; + } + if !first { + joined.push(' '); + } + first = false; + joined.push_str(&decl.class_name); + } + namespace_obj.insert( + display_key, + if joined.is_empty() { + EvalValue::Null + } else { + EvalValue::Str(joined) + }, + ); + for decl in &decls { + if seen_class_names.insert(decl.class_name.clone()) { + rules.push((**decl).clone()); + } + } + } + namespace_obj.insert("$$css", shape.css_marker(namespace_name)); + resolved_namespaces.insert( + namespace_name.to_string(), + EvalValue::Obj(Arc::new(namespace_obj)), + ); + if need_class_paths { + class_paths.push((namespace_name.to_string(), paths_in_namespace)); + } + } + + Ok(CreateOutput { + compiled: Arc::new(resolved_namespaces), + rules, + class_paths, + }) +} + +enum NamespaceEntries<'a> { + Raw { + namespace: &'a JsObjectMap, + index: usize, + }, + Prepared(std::vec::IntoIter<(Cow<'a, str>, PreRule<'a>)>), +} + +impl<'a> Iterator for NamespaceEntries<'a> { + type Item = (Cow<'a, str>, PreRule<'a>); + + fn size_hint(&self) -> (usize, Option) { + (self.len(), Some(self.len())) + } + fn next(&mut self) -> Option { + match self { + Self::Prepared(entries) => entries.next(), + Self::Raw { namespace, index } => { + if *index == namespace.len() { + return None; + } + let (key, value) = namespace.entry_at(*index); + *index += 1; + let key = Cow::Borrowed(key); + let scalar = match value { + EvalValue::Null => return Some((key, PreRule::Null)), + EvalValue::Num(n) => StyleScalar::Num(*n), + EvalValue::Str(s) => StyleScalar::Str(Cow::Borrowed(s)), + _ => unreachable!("raw entries are scalar"), + }; + Some(( + key.clone(), + PreRule::Rule { + property: key, + value: PreRuleValue::Single(scalar), + key_path: None, + }, + )) + } + } + } +} + +impl ExactSizeIterator for NamespaceEntries<'_> { + fn len(&self) -> usize { + match self { + Self::Raw { namespace, index } => namespace.len() - index, + Self::Prepared(entries) => entries.len(), + } + } +} + +// 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::with_capacity_and_hasher(flattened.len(), Default::default()); + if flattened.iter().all(|(key, _)| seen.insert(key)) { + return flattened; + } + seen.clear(); + 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 `k{hash}` is computed once per process. + static MINIFIED_KEYS: std::cell::RefCell> = + std::cell::RefCell::new(FxHashMap::default()); +} + +fn minified_display_key(key: &str, debug: bool) -> String { + let display = |k_hash: &str| { + if debug { + let mut out = String::with_capacity(key.len() + 1 + k_hash.len()); + out.push_str(key); + out.push('-'); + out.push_str(k_hash); + out + } else { + k_hash.to_string() + } + }; + MINIFIED_KEYS.with(|memo| { + let mut memo = memo.borrow_mut(); + match memo.get(key) { + Some(k_hash) => display(k_hash), + None => { + let hashed = create_short_hash(&format!("<>{key}")); + let mut k_hash = String::with_capacity(hashed.len() + 1); + k_hash.push('k'); + k_hash.push_str(&hashed); + let out = display(&k_hash); + memo.insert(key.to_string(), k_hash); + out + } + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::options::StyleResolution; + + 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 flat_namespace_keeps_numeric_order_and_css_marker_position() { + let mut namespaces = JsObjectMap::new(); + namespaces.insert( + "a", + obj(&[ + ("color", s("blue")), + ("2", s("b")), + ("1", s("a")), + ("$$css", EvalValue::Null), + ("width", EvalValue::Null), + ]), + ); + let options = ResolvedOptions { + style_resolution: StyleResolution::PropertySpecificity, + enable_minified_keys: false, + ..ResolvedOptions::default() + }; + let output = compile_namespaces(&namespaces, &CreateContext::new(&options), true).unwrap(); + let Some(EvalValue::Obj(namespace)) = output.compiled.get("a") else { + panic!("compiled namespace"); + }; + assert_eq!( + namespace.keys().collect::>(), + ["1", "2", "color", "$$css", "width"] + ); + assert_eq!(namespace.get("$$css"), Some(&EvalValue::Bool(true))); + assert_eq!(namespace.get("width"), Some(&EvalValue::Null)); + assert_eq!( + output.class_paths[0] + .1 + .iter() + .map(|(_, path)| path.clone()) + .collect::>(), + vec![vec!["1"], vec!["2"], vec!["color"]] + ); + } + + #[test] + fn flattening_errors_precede_scalar_normalization_errors() { + let mut namespaces = JsObjectMap::new(); + namespaces.insert("a", obj(&[("color", s("")), ("all", s("initial"))])); + let options = ResolvedOptions { + style_resolution: StyleResolution::PropertySpecificity, + property_validation_mode: crate::options::PropertyValidationMode::Throw, + ..ResolvedOptions::default() + }; + let error = + compile_namespaces(&namespaces, &CreateContext::new(&options), false).unwrap_err(); + assert_eq!(error, StylexError::banned_shorthand("all").unwrap()); + } + + #[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..69935e1c --- /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().to_string(), + 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().to_string(), + "__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..1b18c17e --- /dev/null +++ b/crates/stylex/src/shared/css_value.rs @@ -0,0 +1,581 @@ +//! CSS value tokenizer, behavior-equivalent to postcss-value-parser@4.2.0. +// parity: postcss-value-parser lib/{parse,walk,stringify,unit}.js + +use std::borrow::Cow; + +#[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: Cow<[u8]> = Cow::Borrowed(input.as_bytes()); + 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.to_mut().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.to_mut().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<'a> { + pub number: &'a str, + pub unit: &'a str, +} + +// 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], + unit: &value[pos..], + }) +} + +#[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", + unit: "ms" + }) + ); + assert_eq!( + unit(".5.5px"), + Some(Dimension { + number: ".5", + unit: ".5px" + }) + ); + assert_eq!( + unit("1e-7px"), + Some(Dimension { + number: "1e-7", + unit: "px" + }) + ); + assert_eq!( + unit("-0px"), + Some(Dimension { + number: "-0", + unit: "px" + }) + ); + assert_eq!(unit("Infinityms"), None); + assert_eq!(unit("px"), None); + assert_eq!(unit(""), None); + assert_eq!( + unit("5e"), + Some(Dimension { + number: "5", + unit: "e" + }) + ); + } +} diff --git a/crates/stylex/src/shared/dashify.rs b/crates/stylex/src/shared/dashify.rs new file mode 100644 index 00000000..d7a298b6 --- /dev/null +++ b/crates/stylex/src/shared/dashify.rs @@ -0,0 +1,75 @@ +//! camelCase -> dash-case plus the dev-classname sanitizer. + +use std::borrow::Cow; + +/// 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) -> Cow<'_, str> { + if key.starts_with("--") { + return Cow::Borrowed(key); + } + // 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 Cow::Borrowed(key); + } + Cow::Owned(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..467189c1 --- /dev/null +++ b/crates/stylex/src/shared/define_consts.rs @@ -0,0 +1,195 @@ +//! `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(Box::new(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.as_deref(), + 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.as_deref(), + 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..48740bf6 --- /dev/null +++ b/crates/stylex/src/shared/dev_naming.rs @@ -0,0 +1,219 @@ +//! 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::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. +pub(crate) 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) + )) +} + +// 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) +} + +// 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..d673e44b --- /dev/null +++ b/crates/stylex/src/shared/flatten.rs @@ -0,0 +1,769 @@ +//! 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: Option]>>, + }, + Set(Vec>), +} + +/// 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 flat = if rules.iter().any(|rule| matches!(rule, PreRule::Set(_))) { + 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), + } + } + flat + } else { + rules + }; + match flat.len() { + 0 => PreRule::Null, + 1 => flat.into_iter().next().expect("len checked"), + _ => PreRule::Set(flat), + } + } + + /// Visits the compiled declarations in upstream's ComputedStyle order with + /// each one's classesToOriginalPath keyPath; `null` slots are skipped. + pub fn for_each_compiled( + &self, + options: &ResolvedOptions, + f: &mut dyn FnMut(CompiledDecl, &[Cow<'a, str>]), + ) -> Result<(), StylexError> { + match self { + PreRule::Null => Ok(()), + PreRule::Rule { + property, + value, + key_path, + } => { + let key_path = key_path + .as_deref() + .unwrap_or_else(|| std::slice::from_ref(property)); + let decl = convert_style_to_class_name(property, value, key_path, options)?; + f(decl, key_path); + Ok(()) + } + PreRule::Set(rules) => { + for rule in rules { + rule.for_each_compiled(options, f)?; + } + Ok(()) + } + } + } +} + +// Upstream copies via `obj[key]=value`, so "__proto__" is a [[Set]]: primitives +// vanish, objects become the prototype, array/null protos break isPlainObject. +fn is_plain(obj: &JsObjectMap) -> bool { + match obj.get("__proto__") { + None => true, + Some(EvalValue::Obj(proto)) => is_plain(proto), + Some(EvalValue::Null | EvalValue::Arr(_)) => false, + Some(_) => true, + } +} + +enum ForIn<'a, I> { + Own(I), + Chain(std::vec::IntoIter<(&'a str, &'a EvalValue)>), +} + +impl<'a, I: Iterator> Iterator for ForIn<'a, I> { + type Item = (&'a str, &'a EvalValue); + + fn next(&mut self) -> Option { + match self { + ForIn::Own(own) => own.next(), + ForIn::Chain(chain) => chain.next(), + } + } +} + +// for..in order: own keys, then unshadowed prototype-chain keys — which is the +// map's own order, iterated in place, unless a "__proto__" key is present. +fn for_in_entries(obj: &JsObjectMap) -> impl Iterator { + if obj.get("__proto__").is_none() { + ForIn::Own(obj.entries()) + } else { + ForIn::Chain(for_in_chain(obj).into_iter()) + } +} + +fn for_in_chain(obj: &JsObjectMap) -> Vec<(&str, &EvalValue)> { + let mut entries = Vec::new(); + let mut cursor = Some(obj); + while let Some(map) = cursor { + cursor = None; + for (key, val) in map.entries() { + if key == "__proto__" { + if let EvalValue::Obj(proto) = val { + cursor = Some(proto); + } + } else if !entries.iter().any(|(k, _)| *k == key) { + entries.push((key, val)); + } + } + } + entries +} + +/// Enclosing condition keys, innermost first, linked through the call stack. +struct Conditions<'p> { + key: &'p str, + outer: Option<&'p Conditions<'p>>, +} + +fn is_enclosing_condition(conditions: Option<&Conditions<'_>>, key: &str) -> bool { + let mut cursor = conditions; + while let Some(condition) = cursor { + if condition.key == key { + return true; + } + cursor = condition.outer; + } + false +} + +// parity: basic-validation.js validateNamespace +pub fn validate_namespace(namespace: &EvalValue) -> Result<(), StylexError> { + validate_namespace_in(namespace, None) +} + +fn validate_namespace_in( + namespace: &EvalValue, + conditions: Option<&Conditions<'_>>, +) -> 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 is_enclosing_condition(conditions, key) { + return Err(StylexError::duplicate_conditional()); + } + let nested = Conditions { + key, + outer: conditions, + }; + validate_namespace_in(val, Some(&nested))?; + } else { + validate_conditional_styles(val, None)?; + } + } + 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: Option<&Conditions<'_>>, +) -> 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 is_enclosing_condition(conditions, 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 nested = Conditions { + key, + outer: conditions, + }; + validate_conditional_styles(v, Some(&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::with_capacity(obj.len()); + // Object.entries-style clone: own keys only — a "__proto__"-carried + // prototype (and its inherited entries) is stripped here, before flatten. + for (key, val) in obj.entries().filter(|(key, _)| *key != "__proto__") { + let processed = match val { + EvalValue::Obj(inner) if dfs_can_change(inner, depth + 1) => { + 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>, +) -> Option]>> { + if key_path.is_empty() { + return None; + } + Some(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 = Vec::with_capacity(key_path.len() + 1); + path.extend_from_slice(key_path); + path.push(property); + path.into_boxed_slice() + }) +} + +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::with_capacity(style.len()); + // 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(); + let mut leaf = 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 = Vec::with_capacity(key_path.len() + 1); + p.extend_from_slice(key_path); + p.push(Cow::Borrowed(condition)); + p + }; + leaf.clear(); + flatten_value_as_property( + key.clone(), + inner_value, + &nested_path, + options, + &mut leaf, + )?; + for (property, pre_rule) in leaf.drain(..) { + 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 = Vec::with_capacity(key_path.len() + 1); + nested_path.extend_from_slice(key_path); + nested_path.push(Cow::Borrowed(raw_key)); + for (property, pre_rule) in flatten_inner(obj, &nested_path, options)? { + let mut joined = String::with_capacity(key.len() + 1 + property.len()); + joined.push_str(&key); + joined.push('_'); + joined.push_str(&property); + flattened.push((Cow::Owned(joined), 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, + out: &mut Vec<(Cow<'a, str>, PreRule<'a>)>, +) -> Result<(), StylexError> { + 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 => 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(()) + } + 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); + } + } + } + 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(()) + } + // 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 equivalent: ConditionGroups<'a> = Vec::new(); + let mut leaf = Vec::new(); + for (condition, inner) in for_in_entries(obj) { + let mut p = Vec::with_capacity(nested_path.len() + 1); + p.extend_from_slice(nested_path); + p.push(Cow::Borrowed(condition)); + leaf.clear(); + flatten_value_as_property(key.clone(), inner, &p, options, &mut leaf)?; + for (property, pre_rule) in leaf.drain(..) { + 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(()) + } + EvalValue::Undefined | EvalValue::Bool(_) => Ok(()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn s(v: &str) -> EvalValue { + EvalValue::Str(v.to_string()) + } + + fn obj(entries: Vec<(&str, EvalValue)>) -> Arc { + let mut map = JsObjectMap::new(); + for (key, val) in entries { + map.insert(key, val); + } + Arc::new(map) + } + + // the pre-sharing rebuild: every level copied, every scalar cloned + fn deep_rebuild(map: &JsObjectMap, depth: usize) -> JsObjectMap { + let mut result = JsObjectMap::new(); + for (key, val) in map.entries().filter(|(key, _)| *key != "__proto__") { + let processed = match val { + EvalValue::Obj(inner) => EvalValue::Obj(Arc::new(deep_rebuild(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).expect("valid fixture"); + for (old_key, new_key) in media_keys.iter().zip(rewritten) { + let value = result.remove(old_key).expect("collected from map"); + result.insert(new_key, value); + } + } + } + result + } + + #[test] + fn dfs_process_map_shares_unchangeable_subtrees() { + let hover = obj(vec![ + ("color", s("blue")), + (":focus", EvalValue::Obj(obj(vec![("color", s("green"))]))), + ]); + let media = obj(vec![ + ("color", s("a")), + ( + "@media (min-width: 900px)", + EvalValue::Obj(obj(vec![("color", s("b"))])), + ), + ( + "@media (min-width: 700px)", + EvalValue::Obj(obj(vec![("color", s("c"))])), + ), + ( + "__proto__", + EvalValue::Obj(obj(vec![("inherited", s("x"))])), + ), + ]); + let root = obj(vec![ + ("color", s("red")), + (":hover", EvalValue::Obj(Arc::clone(&hover))), + ( + "@media (min-width: 600px)", + EvalValue::Obj(Arc::clone(&media)), + ), + ( + "__proto__", + EvalValue::Obj(obj(vec![("inherited", s("y"))])), + ), + ]); + assert!(dfs_can_change(&root, 0)); + let processed = dfs_process_map(&root, 0).expect("fixture transforms"); + assert_eq!(processed, deep_rebuild(&root, 0)); + assert!(!processed.contains_key("__proto__")); + + let Some(EvalValue::Obj(shared)) = processed.get(":hover") else { + panic!(":hover survives"); + }; + assert!(Arc::ptr_eq(shared, &hover)); + + let Some(EvalValue::Obj(rebuilt)) = processed.get("@media (min-width: 600px)") else { + panic!("depth-0 media key stays verbatim"); + }; + assert!(!Arc::ptr_eq(rebuilt, &media)); + assert!(!rebuilt.contains_key("__proto__")); + assert_eq!( + rebuilt.keys().collect::>(), + vec!["color", "@media not all", "@media (min-width: 700px)"] + ); + } + + #[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..eff27c8d --- /dev/null +++ b/crates/stylex/src/shared/generate_rule.rs @@ -0,0 +1,565 @@ +//! 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::borrow::Cow; +use std::cell::RefCell; +use std::hash::{BuildHasher, BuildHasherDefault}; +use std::rc::Rc; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; + +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, CompiledDecl>, +} + +thread_local! { + static CONVERT_MEMO: RefCell = RefCell::new(ConvertMemo { + key: String::new(), + map: FxHashMap::default(), + }); +} + +const CONVERT_MEMO_CAP: usize = 1 << 16; +const SHARD_COUNT: usize = 64; +const SHARD_CAP: usize = CONVERT_MEMO_CAP / SHARD_COUNT; + +type SharedMemo = FxHashMap, StylexRule>; + +// Second level behind the thread-local memo: worker pools (batch threads, oj's +// blocking pool) otherwise recompute every declaration once per thread. +static SHARED_MEMO: [Mutex; SHARD_COUNT] = + [const { Mutex::new(FxHashMap::with_hasher(BuildHasherDefault::new())) }; SHARD_COUNT]; + +// Threads never share a rule's `Arc`s: every registry clone/drop would +// then bounce the refcount line between cores (+20% create time at 18 threads). +fn private_copy(rule: &StylexRule) -> StylexRule { + StylexRule { + class_name: Arc::from(&*rule.class_name), + ltr: Arc::from(&*rule.ltr), + rtl: rule.rtl.as_deref().map(Arc::from), + const_key: rule.const_key.as_deref().map(Arc::from), + const_val: rule.const_val.clone(), + priority: rule.priority, + } +} + +fn shard_for(key: &str) -> MutexGuard<'static, SharedMemo> { + let hash = BuildHasherDefault::::new().hash_one(key); + SHARED_MEMO[(hash >> (64 - SHARD_COUNT.trailing_zeros())) as usize] + .lock() + .unwrap_or_else(PoisonError::into_inner) +} + +/// 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 keyPath carries the property too; the three condition classes are +// disjoint prefix families, so each list is a filter over the same slice. +fn pseudos_of<'k>(key_path: &'k [Cow<'_, str>]) -> impl Iterator { + key_path + .iter() + .map(Cow::as_ref) + .filter(|k| k.starts_with(':') || k.starts_with('[')) +} + +fn at_rules_of<'k>(key_path: &'k [Cow<'_, str>]) -> impl Iterator { + key_path + .iter() + .map(Cow::as_ref) + .filter(|k| k.starts_with('@')) +} + +fn const_rules_of<'k>(key_path: &'k [Cow<'_, str>]) -> impl Iterator { + key_path + .iter() + .map(Cow::as_ref) + .filter(|k| k.starts_with("var(--")) +} + +// 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<'_>, + key_path: &[Cow<'_, 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); + } + } + } + key.push('|'); + for item in pseudos_of(key_path) { + push_memo_part(key, item); + } + key.push('|'); + for item in at_rules_of(key_path) { + push_memo_part(key, item); + } + key.push('|'); + for item in const_rules_of(key_path) { + push_memo_part(key, item); + } +} + +/// Conditions are read from `key_path` 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<'_>, + key_path: &[Cow<'_, 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, key_path, options); + if let Some(hit) = map.get(key.as_str()) { + return Ok(Rc::clone(hit)); + } + let shared = shard_for(key) + .get_key_value(key.as_str()) + .map(|(k, v)| (Arc::clone(k), private_copy(v))); + let (shared_key, rule) = match shared { + Some(hit) => hit, + None => { + let _t = crate::timings::start(crate::timings::Stage::CreateMiss); + let pseudos: Vec<&str> = pseudos_of(key_path).collect(); + let at_rules: Vec<&str> = at_rules_of(key_path).collect(); + let const_rules: Vec<&str> = const_rules_of(key_path).collect(); + let rule = convert_style_to_class_name_uncached( + property, + value, + &pseudos, + &at_rules, + &const_rules, + options, + )?; + let shared_key: Arc = Arc::from(key.as_str()); + let mut shard = shard_for(key); + if shard.len() >= SHARD_CAP { + shard.clear(); + } + shard + .entry(Arc::clone(&shared_key)) + .or_insert_with(|| private_copy(&rule)); + (shared_key, rule) + } + }; + let computed = Rc::new(rule); + if map.len() >= CONVERT_MEMO_CAP { + map.clear(); + } + map.insert(shared_key, 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); + // A stable sort of an already sorted list is that list: the const-free + // case reads the at-rule order as is. + let sorted_at_and_const: Cow<[&str]> = if const_rules.is_empty() { + Cow::Borrowed(&sorted_at_rules) + } else { + let mut at_and_const = sorted_at_rules.clone(); + at_and_const.extend(const_rules.iter().copied()); + Cow::Owned(sort_at_rules(&at_and_const)) + }; + + let dashed = dashed_key(property); + + let px_to_rem = options.enable_font_size_px_to_rem; + let single; + let multi; + let mut values: &[String] = match value { + PreRuleValue::Single(scalar) => { + single = transform_scalar(property, scalar, px_to_rem)?; + std::slice::from_ref(&single) + } + PreRuleValue::Multi(scalars) => { + multi = scalars + .iter() + .map(|s| transform_scalar(property, s, px_to_rem)) + .collect::, _>>()?; + &multi + } + }; + let fallbacks; + if matches!(value, PreRuleValue::Multi(_)) && has_var_fallback(values) { + fallbacks = variable_fallbacks(values)?; + values = &fallbacks; + } + + // The hash concatenates with `+` (so a JS `undefined` stringifies) while + // the declaration is built with `Array.join`, which renders it empty. + let undefined_single = matches!(value, PreRuleValue::Single(StyleScalar::Undefined)); + let values_len: usize = if undefined_single { + "undefined".len() + } else { + values.iter().map(|v| v.len() + 2).sum() + }; + + // 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() + values_len + modifier_len.max(4)); + hash_input.push_str("<>"); + hash_input.push_str(&dashed); + if undefined_single { + hash_input.push_str("undefined"); + } else { + for (i, v) in 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.iter() { + hash_input.push_str(a); + } + } + let hashed = hash(&hash_input); + let debug_name = options.debug && options.enable_debug_class_names; + let mut class_name = String::with_capacity( + options.class_name_prefix.len() + + hashed.len() + + if debug_name { property.len() + 1 } else { 0 }, + ); + if debug_name { + class_name.push_str(property); + class_name.push('-'); + } + class_name.push_str(&options.class_name_prefix); + class_name.push_str(&hashed); + + 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(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)), + &[Cow::Borrowed(":hover"), Cow::Borrowed("::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(Cow::Borrowed("blue"))), + &[Cow::Borrowed(":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..87c0b198 --- /dev/null +++ b/crates/stylex/src/shared/keyframes.rs @@ -0,0 +1,245 @@ +//! `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.into_owned())); + let (rk, rv) = generate_rtl(key, value, ctx).map_or_else( + || (key.to_string(), value.clone()), + |(k, v)| (k.into_owned(), v.into_owned()), + ); + rtl.insert(rk, EvalValue::Str(rv)); + let (sk, sv) = generate_ltr(key, value, RtlContext::DEFAULTS); + stable.insert(sk, EvalValue::Str(sv.into_owned())); + } + 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..74ca8e09 --- /dev/null +++ b/crates/stylex/src/shared/media_query.rs @@ -0,0 +1,1272 @@ +// 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::fxhash::FxHashMap; +use crate::jsrt::js_number_to_string; +use std::cell::RefCell; +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)) + } +} + +thread_local! { + // The rewrite is a pure function of the sibling list, and a design-system + // corpus repeats the same few breakpoint sets in every file. + static TRANSFORM_MEMO: RefCell, Vec>> = + RefCell::new(FxHashMap::default()); +} + +const TRANSFORM_MEMO_CAP: usize = 1 << 16; + +/// 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> { + if let Some(hit) = TRANSFORM_MEMO.with(|memo| memo.borrow().get(sibling_keys).cloned()) { + return Ok(hit); + } + let out = transform_uncached(sibling_keys)?; + TRANSFORM_MEMO.with(|memo| { + let mut memo = memo.borrow_mut(); + if memo.len() >= TRANSFORM_MEMO_CAP { + memo.clear(); + } + memo.insert(sibling_keys.to_vec(), out.clone()); + }); + Ok(out) +} + +fn transform_uncached(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"