From 4d249130d4292aa5f241380cbfc0a21f9e6c8275 Mon Sep 17 00:00:00 2001 From: mellyeliu Date: Thu, 5 Nov 2026 02:03:15 -0800 Subject: [PATCH 01/79] feat(rust-plugin): add native Rust StyleX compiler --- packages/@stylexjs/rust-plugin/.gitattributes | 2 + packages/@stylexjs/rust-plugin/.gitignore | 3 + packages/@stylexjs/rust-plugin/Cargo.lock | 1706 ++++++ packages/@stylexjs/rust-plugin/Cargo.toml | 47 + packages/@stylexjs/rust-plugin/README.md | 47 + packages/@stylexjs/rust-plugin/babel.d.ts | 14 + packages/@stylexjs/rust-plugin/babel.js | 337 ++ packages/@stylexjs/rust-plugin/build.rs | 4 + packages/@stylexjs/rust-plugin/host.js | 514 ++ packages/@stylexjs/rust-plugin/index.d.ts | 96 + packages/@stylexjs/rust-plugin/index.js | 128 + packages/@stylexjs/rust-plugin/package.json | 36 + .../@stylexjs/rust-plugin/rust-toolchain.toml | 4 + .../rust-plugin/scripts/benchmark.cjs | 264 + .../@stylexjs/rust-plugin/scripts/build.cjs | 74 + .../rust-plugin/scripts/native-jest-setup.cjs | 26 + .../@stylexjs/rust-plugin/scripts/parity.cjs | 72 + .../@stylexjs/rust-plugin/scripts/test.cjs | 38 + .../scripts/unfocus-jest-setup.cjs | 14 + .../rust-plugin/src/core/animations.rs | 208 + .../@stylexjs/rust-plugin/src/core/compile.rs | 230 + .../@stylexjs/rust-plugin/src/core/legacy.rs | 351 ++ .../@stylexjs/rust-plugin/src/core/media.rs | 709 +++ .../rust-plugin/src/core/media_value.rs | 142 + .../@stylexjs/rust-plugin/src/core/mod.rs | 1644 ++++++ .../rust-plugin/src/core/priority.rs | 61 + .../@stylexjs/rust-plugin/src/core/process.rs | 306 + .../rust-plugin/src/core/properties.rs | 778 +++ .../rust-plugin/src/core/validate.rs | 76 + .../@stylexjs/rust-plugin/src/core/values.rs | 644 ++ packages/@stylexjs/rust-plugin/src/lib.rs | 24 + packages/@stylexjs/rust-plugin/src/node.rs | 402 ++ .../@stylexjs/rust-plugin/src/shared/mod.rs | 11 + .../rust-plugin/src/shared/nested_utils.rs | 170 + .../@stylexjs/rust-plugin/src/shared/types.rs | 194 + .../rust-plugin/src/utils/diagnostics.rs | 111 + .../@stylexjs/rust-plugin/src/utils/host.rs | 198 + .../@stylexjs/rust-plugin/src/utils/mod.rs | 7 + .../@stylexjs/rust-plugin/src/utils/parser.rs | 631 ++ .../rust-plugin/src/utils/parser_limits.rs | 1020 ++++ .../rust-plugin/src/utils/parser_modes.rs | 180 + .../rust-plugin/src/utils/source_compare.rs | 512 ++ .../rust-plugin/src/utils/source_edits.rs | 1839 ++++++ .../rust-plugin/src/utils/source_typed.rs | 871 +++ .../rust-plugin/src/utils/transform/atoms.rs | 441 ++ .../src/utils/transform/bindings.rs | 72 + .../src/utils/transform/creation/dynamic.rs | 460 ++ .../utils/transform/creation/evaluation.rs | 1363 +++++ .../src/utils/transform/creation/fragments.rs | 226 + .../utils/transform/creation/host_closures.rs | 87 + .../src/utils/transform/creation/imports.rs | 138 + .../src/utils/transform/creation/scopes.rs | 123 + .../src/utils/transform/creation/tokens.rs | 609 ++ .../src/utils/transform/evaluate.rs | 1151 ++++ .../src/utils/transform/evaluate/builtins.rs | 1249 ++++ .../utils/transform/evaluate/context_hooks.rs | 375 ++ .../utils/transform/evaluate/current_tests.rs | 155 + .../src/utils/transform/evaluate/decimal.rs | 130 + .../evaluate/diagnostic_review_tests.rs | 87 + .../src/utils/transform/evaluate/failure.rs | 105 + .../src/utils/transform/evaluate/hooks.rs | 134 + .../utils/transform/evaluate/hooks_tests.rs | 195 + .../transform/evaluate/identity_tests.rs | 134 + .../src/utils/transform/evaluate/integer.rs | 62 + .../src/utils/transform/evaluate/js.rs | 596 ++ .../utils/transform/evaluate/legacy_tests.rs | 766 +++ .../transform/evaluate/property_error.rs | 32 + .../utils/transform/evaluate/review_tests.rs | 123 + .../src/utils/transform/evaluate/safety.rs | 1349 +++++ .../src/utils/transform/evaluate/snapshot.rs | 78 + .../transform/evaluate/snapshot_tests.rs | 157 + .../transform/evaluate/string_replace.rs | 87 + .../transform/evaluate/unsupported_nodes.rs | 41 + .../src/utils/transform/file_identity.rs | 247 + .../rust-plugin/src/utils/transform/mod.rs | 162 + .../src/utils/transform/options.rs | 493 ++ .../rust-plugin/src/utils/transform/render.rs | 301 + .../src/utils/transform/render_tests.rs | 133 + .../src/utils/transform/runtime.rs | 586 ++ .../rust-plugin/src/utils/transform/state.rs | 237 + .../src/utils/transform/stylex_create.rs | 859 +++ .../src/utils/transform/stylex_props.rs | 1042 ++++ .../src/utils/transform/validation.rs | 453 ++ .../rust-plugin/src/visitors/imports.rs | 363 ++ .../@stylexjs/rust-plugin/src/visitors/mod.rs | 3 + .../tests/babel_facade_current.cjs | 541 ++ .../rust-plugin/tests/core_current.rs | 1123 ++++ .../rust-plugin/tests/creation_current.rs | 866 +++ .../rust-plugin/tests/evaluator_current.rs | 247 + .../fixtures/swc_ecma_parser-38.0.2.crate | Bin 0 -> 180823 bytes .../fixtures/swc_ecma_parser-38.0.2.manifest | 42 + .../rust-plugin/tests/imports_current.rs | 148 + .../rust-plugin/tests/native_api_current.cjs | 354 ++ .../tests/parser_resource_limits.rs | 468 ++ .../tests/props_runtime_current.rs | 447 ++ .../rust-plugin/tests/source_preservation.rs | 1266 ++++ .../rust-plugin/tests/unplugin_current.cjs | 125 + .../tests/vendor_parser_provenance.rs | 178 + .../@stylexjs/rust-plugin/vendor/README.md | 10 + .../swc_ecma_parser/.cargo_vcs_info.json | 6 + .../vendor/swc_ecma_parser/Cargo.lock | 2087 +++++++ .../vendor/swc_ecma_parser/Cargo.toml | 158 + .../vendor/swc_ecma_parser/Cargo.toml.orig | 81 + .../swc_ecma_parser/examples/parse-all.rs | 75 + .../vendor/swc_ecma_parser/examples/parse.rs | 92 + .../vendor/swc_ecma_parser/examples/perf.rs | 51 + .../swc_ecma_parser/examples/typescript.rs | 43 + .../vendor/swc_ecma_parser/src/context.rs | 120 + .../vendor/swc_ecma_parser/src/error.rs | 796 +++ .../vendor/swc_ecma_parser/src/legacy.rs | 993 ++++ .../swc_ecma_parser/src/lexer/capturing.rs | 223 + .../swc_ecma_parser/src/lexer/char_ext.rs | 57 + .../src/lexer/comments_buffer.rs | 69 + .../vendor/swc_ecma_parser/src/lexer/jsx.rs | 270 + .../vendor/swc_ecma_parser/src/lexer/mod.rs | 2461 ++++++++ .../swc_ecma_parser/src/lexer/number.rs | 94 + .../swc_ecma_parser/src/lexer/search.rs | 159 + .../vendor/swc_ecma_parser/src/lexer/state.rs | 691 +++ .../vendor/swc_ecma_parser/src/lexer/table.rs | 405 ++ .../vendor/swc_ecma_parser/src/lexer/token.rs | 906 +++ .../swc_ecma_parser/src/lexer/whitespace.rs | 167 + .../vendor/swc_ecma_parser/src/lib.rs | 287 + .../src/parser/class_and_fn.rs | 2140 +++++++ .../vendor/swc_ecma_parser/src/parser/expr.rs | 2911 +++++++++ .../swc_ecma_parser/src/parser/ident.rs | 212 + .../swc_ecma_parser/src/parser/input.rs | 515 ++ .../vendor/swc_ecma_parser/src/parser/jsx.rs | 613 ++ .../swc_ecma_parser/src/parser/macros.rs | 105 + .../vendor/swc_ecma_parser/src/parser/mod.rs | 921 +++ .../swc_ecma_parser/src/parser/module_item.rs | 1143 ++++ .../swc_ecma_parser/src/parser/object.rs | 521 ++ .../vendor/swc_ecma_parser/src/parser/pat.rs | 1273 ++++ .../swc_ecma_parser/src/parser/state.rs | 40 + .../vendor/swc_ecma_parser/src/parser/stmt.rs | 3435 +++++++++++ .../swc_ecma_parser/src/parser/tests.rs | 1069 ++++ .../swc_ecma_parser/src/parser/typescript.rs | 5233 +++++++++++++++++ .../src/parser/typescript_stubs.rs | 172 + .../vendor/swc_ecma_parser/src/parser/util.rs | 142 + .../swc_ecma_parser/src/parser/verifier.rs | 24 + .../vendor/swc_ecma_parser/src/syntax.rs | 659 +++ 140 files changed, 67383 insertions(+) create mode 100644 packages/@stylexjs/rust-plugin/.gitattributes create mode 100644 packages/@stylexjs/rust-plugin/.gitignore create mode 100644 packages/@stylexjs/rust-plugin/Cargo.lock create mode 100644 packages/@stylexjs/rust-plugin/Cargo.toml create mode 100644 packages/@stylexjs/rust-plugin/README.md create mode 100644 packages/@stylexjs/rust-plugin/babel.d.ts create mode 100644 packages/@stylexjs/rust-plugin/babel.js create mode 100644 packages/@stylexjs/rust-plugin/build.rs create mode 100644 packages/@stylexjs/rust-plugin/host.js create mode 100644 packages/@stylexjs/rust-plugin/index.d.ts create mode 100644 packages/@stylexjs/rust-plugin/index.js create mode 100644 packages/@stylexjs/rust-plugin/package.json create mode 100644 packages/@stylexjs/rust-plugin/rust-toolchain.toml create mode 100644 packages/@stylexjs/rust-plugin/scripts/benchmark.cjs create mode 100644 packages/@stylexjs/rust-plugin/scripts/build.cjs create mode 100644 packages/@stylexjs/rust-plugin/scripts/native-jest-setup.cjs create mode 100644 packages/@stylexjs/rust-plugin/scripts/parity.cjs create mode 100644 packages/@stylexjs/rust-plugin/scripts/test.cjs create mode 100644 packages/@stylexjs/rust-plugin/scripts/unfocus-jest-setup.cjs create mode 100644 packages/@stylexjs/rust-plugin/src/core/animations.rs create mode 100644 packages/@stylexjs/rust-plugin/src/core/compile.rs create mode 100644 packages/@stylexjs/rust-plugin/src/core/legacy.rs create mode 100644 packages/@stylexjs/rust-plugin/src/core/media.rs create mode 100644 packages/@stylexjs/rust-plugin/src/core/media_value.rs create mode 100644 packages/@stylexjs/rust-plugin/src/core/mod.rs create mode 100644 packages/@stylexjs/rust-plugin/src/core/priority.rs create mode 100644 packages/@stylexjs/rust-plugin/src/core/process.rs create mode 100644 packages/@stylexjs/rust-plugin/src/core/properties.rs create mode 100644 packages/@stylexjs/rust-plugin/src/core/validate.rs create mode 100644 packages/@stylexjs/rust-plugin/src/core/values.rs create mode 100644 packages/@stylexjs/rust-plugin/src/lib.rs create mode 100644 packages/@stylexjs/rust-plugin/src/node.rs create mode 100644 packages/@stylexjs/rust-plugin/src/shared/mod.rs create mode 100644 packages/@stylexjs/rust-plugin/src/shared/nested_utils.rs create mode 100644 packages/@stylexjs/rust-plugin/src/shared/types.rs create mode 100644 packages/@stylexjs/rust-plugin/src/utils/diagnostics.rs create mode 100644 packages/@stylexjs/rust-plugin/src/utils/host.rs create mode 100644 packages/@stylexjs/rust-plugin/src/utils/mod.rs create mode 100644 packages/@stylexjs/rust-plugin/src/utils/parser.rs create mode 100644 packages/@stylexjs/rust-plugin/src/utils/parser_limits.rs create mode 100644 packages/@stylexjs/rust-plugin/src/utils/parser_modes.rs create mode 100644 packages/@stylexjs/rust-plugin/src/utils/source_compare.rs create mode 100644 packages/@stylexjs/rust-plugin/src/utils/source_edits.rs create mode 100644 packages/@stylexjs/rust-plugin/src/utils/source_typed.rs create mode 100644 packages/@stylexjs/rust-plugin/src/utils/transform/atoms.rs create mode 100644 packages/@stylexjs/rust-plugin/src/utils/transform/bindings.rs create mode 100644 packages/@stylexjs/rust-plugin/src/utils/transform/creation/dynamic.rs create mode 100644 packages/@stylexjs/rust-plugin/src/utils/transform/creation/evaluation.rs create mode 100644 packages/@stylexjs/rust-plugin/src/utils/transform/creation/fragments.rs create mode 100644 packages/@stylexjs/rust-plugin/src/utils/transform/creation/host_closures.rs create mode 100644 packages/@stylexjs/rust-plugin/src/utils/transform/creation/imports.rs create mode 100644 packages/@stylexjs/rust-plugin/src/utils/transform/creation/scopes.rs create mode 100644 packages/@stylexjs/rust-plugin/src/utils/transform/creation/tokens.rs create mode 100644 packages/@stylexjs/rust-plugin/src/utils/transform/evaluate.rs create mode 100644 packages/@stylexjs/rust-plugin/src/utils/transform/evaluate/builtins.rs create mode 100644 packages/@stylexjs/rust-plugin/src/utils/transform/evaluate/context_hooks.rs create mode 100644 packages/@stylexjs/rust-plugin/src/utils/transform/evaluate/current_tests.rs create mode 100644 packages/@stylexjs/rust-plugin/src/utils/transform/evaluate/decimal.rs create mode 100644 packages/@stylexjs/rust-plugin/src/utils/transform/evaluate/diagnostic_review_tests.rs create mode 100644 packages/@stylexjs/rust-plugin/src/utils/transform/evaluate/failure.rs create mode 100644 packages/@stylexjs/rust-plugin/src/utils/transform/evaluate/hooks.rs create mode 100644 packages/@stylexjs/rust-plugin/src/utils/transform/evaluate/hooks_tests.rs create mode 100644 packages/@stylexjs/rust-plugin/src/utils/transform/evaluate/identity_tests.rs create mode 100644 packages/@stylexjs/rust-plugin/src/utils/transform/evaluate/integer.rs create mode 100644 packages/@stylexjs/rust-plugin/src/utils/transform/evaluate/js.rs create mode 100644 packages/@stylexjs/rust-plugin/src/utils/transform/evaluate/legacy_tests.rs create mode 100644 packages/@stylexjs/rust-plugin/src/utils/transform/evaluate/property_error.rs create mode 100644 packages/@stylexjs/rust-plugin/src/utils/transform/evaluate/review_tests.rs create mode 100644 packages/@stylexjs/rust-plugin/src/utils/transform/evaluate/safety.rs create mode 100644 packages/@stylexjs/rust-plugin/src/utils/transform/evaluate/snapshot.rs create mode 100644 packages/@stylexjs/rust-plugin/src/utils/transform/evaluate/snapshot_tests.rs create mode 100644 packages/@stylexjs/rust-plugin/src/utils/transform/evaluate/string_replace.rs create mode 100644 packages/@stylexjs/rust-plugin/src/utils/transform/evaluate/unsupported_nodes.rs create mode 100644 packages/@stylexjs/rust-plugin/src/utils/transform/file_identity.rs create mode 100644 packages/@stylexjs/rust-plugin/src/utils/transform/mod.rs create mode 100644 packages/@stylexjs/rust-plugin/src/utils/transform/options.rs create mode 100644 packages/@stylexjs/rust-plugin/src/utils/transform/render.rs create mode 100644 packages/@stylexjs/rust-plugin/src/utils/transform/render_tests.rs create mode 100644 packages/@stylexjs/rust-plugin/src/utils/transform/runtime.rs create mode 100644 packages/@stylexjs/rust-plugin/src/utils/transform/state.rs create mode 100644 packages/@stylexjs/rust-plugin/src/utils/transform/stylex_create.rs create mode 100644 packages/@stylexjs/rust-plugin/src/utils/transform/stylex_props.rs create mode 100644 packages/@stylexjs/rust-plugin/src/utils/transform/validation.rs create mode 100644 packages/@stylexjs/rust-plugin/src/visitors/imports.rs create mode 100644 packages/@stylexjs/rust-plugin/src/visitors/mod.rs create mode 100644 packages/@stylexjs/rust-plugin/tests/babel_facade_current.cjs create mode 100644 packages/@stylexjs/rust-plugin/tests/core_current.rs create mode 100644 packages/@stylexjs/rust-plugin/tests/creation_current.rs create mode 100644 packages/@stylexjs/rust-plugin/tests/evaluator_current.rs create mode 100644 packages/@stylexjs/rust-plugin/tests/fixtures/swc_ecma_parser-38.0.2.crate create mode 100644 packages/@stylexjs/rust-plugin/tests/fixtures/swc_ecma_parser-38.0.2.manifest create mode 100644 packages/@stylexjs/rust-plugin/tests/imports_current.rs create mode 100644 packages/@stylexjs/rust-plugin/tests/native_api_current.cjs create mode 100644 packages/@stylexjs/rust-plugin/tests/parser_resource_limits.rs create mode 100644 packages/@stylexjs/rust-plugin/tests/props_runtime_current.rs create mode 100644 packages/@stylexjs/rust-plugin/tests/source_preservation.rs create mode 100644 packages/@stylexjs/rust-plugin/tests/unplugin_current.cjs create mode 100644 packages/@stylexjs/rust-plugin/tests/vendor_parser_provenance.rs create mode 100644 packages/@stylexjs/rust-plugin/vendor/README.md create mode 100644 packages/@stylexjs/rust-plugin/vendor/swc_ecma_parser/.cargo_vcs_info.json create mode 100644 packages/@stylexjs/rust-plugin/vendor/swc_ecma_parser/Cargo.lock create mode 100644 packages/@stylexjs/rust-plugin/vendor/swc_ecma_parser/Cargo.toml create mode 100644 packages/@stylexjs/rust-plugin/vendor/swc_ecma_parser/Cargo.toml.orig create mode 100644 packages/@stylexjs/rust-plugin/vendor/swc_ecma_parser/examples/parse-all.rs create mode 100644 packages/@stylexjs/rust-plugin/vendor/swc_ecma_parser/examples/parse.rs create mode 100644 packages/@stylexjs/rust-plugin/vendor/swc_ecma_parser/examples/perf.rs create mode 100644 packages/@stylexjs/rust-plugin/vendor/swc_ecma_parser/examples/typescript.rs create mode 100644 packages/@stylexjs/rust-plugin/vendor/swc_ecma_parser/src/context.rs create mode 100644 packages/@stylexjs/rust-plugin/vendor/swc_ecma_parser/src/error.rs create mode 100644 packages/@stylexjs/rust-plugin/vendor/swc_ecma_parser/src/legacy.rs create mode 100644 packages/@stylexjs/rust-plugin/vendor/swc_ecma_parser/src/lexer/capturing.rs create mode 100644 packages/@stylexjs/rust-plugin/vendor/swc_ecma_parser/src/lexer/char_ext.rs create mode 100644 packages/@stylexjs/rust-plugin/vendor/swc_ecma_parser/src/lexer/comments_buffer.rs create mode 100644 packages/@stylexjs/rust-plugin/vendor/swc_ecma_parser/src/lexer/jsx.rs create mode 100644 packages/@stylexjs/rust-plugin/vendor/swc_ecma_parser/src/lexer/mod.rs create mode 100644 packages/@stylexjs/rust-plugin/vendor/swc_ecma_parser/src/lexer/number.rs create mode 100644 packages/@stylexjs/rust-plugin/vendor/swc_ecma_parser/src/lexer/search.rs create mode 100644 packages/@stylexjs/rust-plugin/vendor/swc_ecma_parser/src/lexer/state.rs create mode 100644 packages/@stylexjs/rust-plugin/vendor/swc_ecma_parser/src/lexer/table.rs create mode 100644 packages/@stylexjs/rust-plugin/vendor/swc_ecma_parser/src/lexer/token.rs create mode 100644 packages/@stylexjs/rust-plugin/vendor/swc_ecma_parser/src/lexer/whitespace.rs create mode 100644 packages/@stylexjs/rust-plugin/vendor/swc_ecma_parser/src/lib.rs create mode 100644 packages/@stylexjs/rust-plugin/vendor/swc_ecma_parser/src/parser/class_and_fn.rs create mode 100644 packages/@stylexjs/rust-plugin/vendor/swc_ecma_parser/src/parser/expr.rs create mode 100644 packages/@stylexjs/rust-plugin/vendor/swc_ecma_parser/src/parser/ident.rs create mode 100644 packages/@stylexjs/rust-plugin/vendor/swc_ecma_parser/src/parser/input.rs create mode 100644 packages/@stylexjs/rust-plugin/vendor/swc_ecma_parser/src/parser/jsx.rs create mode 100644 packages/@stylexjs/rust-plugin/vendor/swc_ecma_parser/src/parser/macros.rs create mode 100644 packages/@stylexjs/rust-plugin/vendor/swc_ecma_parser/src/parser/mod.rs create mode 100644 packages/@stylexjs/rust-plugin/vendor/swc_ecma_parser/src/parser/module_item.rs create mode 100644 packages/@stylexjs/rust-plugin/vendor/swc_ecma_parser/src/parser/object.rs create mode 100644 packages/@stylexjs/rust-plugin/vendor/swc_ecma_parser/src/parser/pat.rs create mode 100644 packages/@stylexjs/rust-plugin/vendor/swc_ecma_parser/src/parser/state.rs create mode 100644 packages/@stylexjs/rust-plugin/vendor/swc_ecma_parser/src/parser/stmt.rs create mode 100644 packages/@stylexjs/rust-plugin/vendor/swc_ecma_parser/src/parser/tests.rs create mode 100644 packages/@stylexjs/rust-plugin/vendor/swc_ecma_parser/src/parser/typescript.rs create mode 100644 packages/@stylexjs/rust-plugin/vendor/swc_ecma_parser/src/parser/typescript_stubs.rs create mode 100644 packages/@stylexjs/rust-plugin/vendor/swc_ecma_parser/src/parser/util.rs create mode 100644 packages/@stylexjs/rust-plugin/vendor/swc_ecma_parser/src/parser/verifier.rs create mode 100644 packages/@stylexjs/rust-plugin/vendor/swc_ecma_parser/src/syntax.rs diff --git a/packages/@stylexjs/rust-plugin/.gitattributes b/packages/@stylexjs/rust-plugin/.gitattributes new file mode 100644 index 000000000..abd1ae448 --- /dev/null +++ b/packages/@stylexjs/rust-plugin/.gitattributes @@ -0,0 +1,2 @@ +vendor/** -text +tests/fixtures/** -text diff --git a/packages/@stylexjs/rust-plugin/.gitignore b/packages/@stylexjs/rust-plugin/.gitignore new file mode 100644 index 000000000..87e28b76e --- /dev/null +++ b/packages/@stylexjs/rust-plugin/.gitignore @@ -0,0 +1,3 @@ +/target/ +/native/ +*.tgz diff --git a/packages/@stylexjs/rust-plugin/Cargo.lock b/packages/@stylexjs/rust-plugin/Cargo.lock new file mode 100644 index 000000000..1be3f60d6 --- /dev/null +++ b/packages/@stylexjs/rust-plugin/Cargo.lock @@ -0,0 +1,1706 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "ar_archive_writer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6" +dependencies = [ + "object", +] + +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + +[[package]] +name = "ast_node" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eb025ef00a6da925cf40870b9c8d008526b6004ece399cb0974209720f0b194" +dependencies = [ + "quote", + "swc_macros_common", + "syn 2.0.119", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + +[[package]] +name = "better_scoped_tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd228125315b132eed175bf47619ac79b945b26e56b848ba203ae4ea8603609" +dependencies = [ + "scoped-tls", +] + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +dependencies = [ + "allocator-api2", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "bytes-str" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dde6d05e75a31ec9610eb6446a6f0a10dd30ff5100d720fee4c7c7a9008b5ba" +dependencies = [ + "bytes", + "serde", +] + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "375989decec1a0fa424442bed520fa12ede43a48475ce8c7417d2c69b32b5f6b" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "compact_str" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "ryu", + "static_assertions", +] + +[[package]] +name = "convert_case" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1af709f1f33454bf52eadfc8c78b3b9ef9cb26fb54d16dc9cd9a7299f899fd1b" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "ctor" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "debugid" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" +dependencies = [ + "serde", + "uuid", +] + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "dragonbox_ecma" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd8e701084c37e7ef62d3f9e453b618130cbc0ef3573847785952a3ac3f746bf" + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "from_variant" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5ff35a391aef949120a0340d690269b3d9f63460a6106e99bd07b961f345ea9" +dependencies = [ + "swc_macros_common", + "syn 2.0.119", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hstr" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83bb87e4b300d73412f6dcc7022ee7741452b51b155c2b06e5994d0770c2dbe2" +dependencies = [ + "hashbrown 0.14.5", + "new_debug_unreachable", + "once_cell", + "rustc-hash", + "serde", + "triomphe", +] + +[[package]] +name = "icu_collator" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24cb0205c419b2036da3f9082964905d6849b3f2130dd4a517940c45899a544b" +dependencies = [ + "icu_collator_data", + "icu_collections", + "icu_locale_core", + "icu_locale_fallback", + "icu_normalizer", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "zerovec", +] + +[[package]] +name = "icu_collator_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183f47e821a327661c1ebe152f61d5b6c2000010d228cadbbf226e0e2b8c2634" + +[[package]] +name = "icu_collections" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" +dependencies = [ + "displaydoc", + "litemap", + "serde", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "406e9ae5b95d8aa37db6293405931b766a6172d1848daee22d70eb44aa78a43c" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3632ebbd0c5c3637ba1a40bdf8288f2cb9d2268e73441a69d5fc73d7cfbaa87c" + +[[package]] +name = "icu_normalizer" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "write16", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" + +[[package]] +name = "icu_properties" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" + +[[package]] +name = "icu_provider" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" +dependencies = [ + "displaydoc", + "icu_locale_core", + "serde", + "stable_deref_trait", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "if_chain" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd62e6b5e86ea8eeeb8db1de02880a6abc01a397b2ebb64b5d74ac255318f5cb" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "is-macro" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8267aa6001e25494f3015f9663bbd88a18240c74483afa5f0934a1b3e4c388e9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libloading" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "napi" +version = "3.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "907ff7366fb7182e28edd0f08ef8ac5d6b13b8d65a3c0b3a10bf3ac092437d6b" +dependencies = [ + "bitflags", + "ctor", + "futures", + "libc", + "napi-build", + "napi-sys", + "nohash-hasher", + "rustc-hash", + "serde", + "serde_json", +] + +[[package]] +name = "napi-build" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b899b545d3aa6dca985939059f258c5488d34e4ecf39c274e20009748f4b846d" + +[[package]] +name = "napi-derive" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d32eb00f92f84cf462fc328f49082b29c2a7d2eebeb1d447d19c1f9fdd9f89ee" +dependencies = [ + "convert_case", + "ctor", + "napi-derive-backend", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "napi-derive-backend" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1fe28c64d8fc12e69f9b817d0febdbe7914555760f1e353106281b8ff99cc90" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "semver", + "syn 3.0.9", +] + +[[package]] +name = "napi-sys" +version = "3.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d153beb7d5c973f4b9395be8a83054a28968b61188f1dd1a2cc2321af22daea2" +dependencies = [ + "libloading", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", + "serde", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "object" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "par-core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e96cbd21255b7fb29a5d51ef38a779b517a91abd59e2756c039583f43ef4c90f" +dependencies = [ + "once_cell", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher 1.0.4", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "serde_core", + "writeable", + "zerovec", +] + +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "psm" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622" +dependencies = [ + "ar_archive_writer", + "cc", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +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 = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "seq-macro" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "foldhash", + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" + +[[package]] +name = "smartstring" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29" +dependencies = [ + "autocfg", + "static_assertions", + "version_check", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stacker" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "string_enum" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36a4951ca7bd1cfd991c241584a9824a70f6aff1e7d4f693fb3f2465e4030e" +dependencies = [ + "quote", + "swc_macros_common", + "syn 2.0.119", +] + +[[package]] +name = "stylex_rust_compiler" +version = "0.19.1" +dependencies = [ + "anyhow", + "icu_collator", + "icu_locale_core", + "indexmap", + "napi", + "napi-build", + "napi-derive", + "pretty_assertions", + "regex", + "ryu-js", + "serde", + "serde_json", + "stacker", + "swc_common", + "swc_ecma_ast", + "swc_ecma_codegen", + "swc_ecma_parser", + "swc_ecma_transforms_base", + "swc_ecma_visit", +] + +[[package]] +name = "swc_allocator" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d7eefd2c8b228a8c73056482b2ae4b3a1071fbe07638e3b55ceca8570cc48bb" +dependencies = [ + "allocator-api2", + "bumpalo", + "hashbrown 0.14.5", + "rustc-hash", +] + +[[package]] +name = "swc_atoms" +version = "9.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "845f31910b5236db42dba106e8277681098d183b9b65b8dfa88ca8abe464aeff" +dependencies = [ + "hstr", + "once_cell", + "serde", +] + +[[package]] +name = "swc_common" +version = "21.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da38f2cee8e659bf0ec7f51ec5b37ec58c9127de755d3fe0b2c2353ec9474b09" +dependencies = [ + "anyhow", + "ast_node", + "better_scoped_tls", + "bytes-str", + "either", + "from_variant", + "num-bigint", + "once_cell", + "rustc-hash", + "serde", + "siphasher 0.3.11", + "swc_atoms", + "swc_eq_ignore_macros", + "swc_sourcemap", + "swc_visit", + "tracing", + "unicode-width", + "url", +] + +[[package]] +name = "swc_ecma_ast" +version = "23.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "550ee54eab536fe357090fec6d42d083c28cf44cc9bcfa93b1ea5e1606f3b2f7" +dependencies = [ + "bitflags", + "is-macro", + "num-bigint", + "once_cell", + "phf", + "rustc-hash", + "serde", + "string_enum", + "swc_atoms", + "swc_common", + "swc_visit", + "unicode-id-start", +] + +[[package]] +name = "swc_ecma_codegen" +version = "26.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39230b073d1d785ac7a905354161e21970d15956e348f46a85deb0da0d4d5132" +dependencies = [ + "ascii", + "compact_str", + "dragonbox_ecma", + "memchr", + "num-bigint", + "once_cell", + "regex", + "rustc-hash", + "serde", + "swc_allocator", + "swc_atoms", + "swc_common", + "swc_ecma_ast", + "swc_ecma_codegen_macros", + "tracing", +] + +[[package]] +name = "swc_ecma_codegen_macros" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e276dc62c0a2625a560397827989c82a93fd545fcf6f7faec0935a82cc4ddbb8" +dependencies = [ + "proc-macro2", + "swc_macros_common", + "syn 2.0.119", +] + +[[package]] +name = "swc_ecma_parser" +version = "38.0.2" +dependencies = [ + "bitflags", + "either", + "num-bigint", + "phf", + "rustc-hash", + "seq-macro", + "serde", + "smartstring", + "stacker", + "swc_atoms", + "swc_common", + "swc_ecma_ast", + "tracing", +] + +[[package]] +name = "swc_ecma_transforms_base" +version = "41.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6be824dc326da1f7673d1e241790626e5f39f09e1d896175134143408eeaa081" +dependencies = [ + "better_scoped_tls", + "indexmap", + "once_cell", + "par-core", + "phf", + "rustc-hash", + "serde", + "swc_atoms", + "swc_common", + "swc_ecma_ast", + "swc_ecma_parser", + "swc_ecma_utils", + "swc_ecma_visit", + "tracing", +] + +[[package]] +name = "swc_ecma_utils" +version = "29.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d69b480aa02b5ff2ab951478d8e7633eeda42940aeb5fe0386eebc19dd3b1e4" +dependencies = [ + "dragonbox_ecma", + "indexmap", + "num_cpus", + "once_cell", + "par-core", + "rustc-hash", + "swc_atoms", + "swc_common", + "swc_ecma_ast", + "swc_ecma_visit", + "tracing", +] + +[[package]] +name = "swc_ecma_visit" +version = "23.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad65d392ed427dc9e94f16a3d802b02e27722c21227639c8d5f45f19757b447b" +dependencies = [ + "new_debug_unreachable", + "num-bigint", + "swc_atoms", + "swc_common", + "swc_ecma_ast", + "swc_visit", + "tracing", +] + +[[package]] +name = "swc_eq_ignore_macros" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c16ce73424a6316e95e09065ba6a207eba7765496fed113702278b7711d4b632" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "swc_macros_common" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aae1efbaa74943dc5ad2a2fb16cbd78b77d7e4d63188f3c5b4df2b4dcd2faaae" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "swc_sourcemap" +version = "10.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9ce7c46dc9730284761c88b34d15a8cfbaf40884705f3deec65b9493765ac8" +dependencies = [ + "base64-simd", + "bitvec", + "bytes-str", + "data-encoding", + "debugid", + "if_chain", + "rustc-hash", + "serde", + "serde_json", + "unicode-id-start", + "url", +] + +[[package]] +name = "swc_visit" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7df72a1be54e379864cebf04f26abb4d7ee637977f62f8ec8387233f7a27e88" +dependencies = [ + "either", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tinystr" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "triomphe" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d15f4f5898bcfc5d8a8e45ed30b2f108d710c1a2e5489dbf80e078f707181bc" +dependencies = [ + "serde", + "stable_deref_trait", +] + +[[package]] +name = "unicode-id-start" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0825d2e09dcae814a4273c1e1435979d414ede25b2ee235b1545da6682483765" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf16_iter" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c7de35d9d8757dbc1f5125dc8b51438c68b4430546a735c3a9b91aaaaf9a18a" +dependencies = [ + "utf_types", +] + +[[package]] +name = "utf8_iter" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af3bd9d3e571c26fcce9d033b87ea5ab061b8d2ffe9b7a5675d810d68f03edf0" +dependencies = [ + "utf_types", +] + +[[package]] +name = "utf_types" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6" + +[[package]] +name = "uuid" +version = "1.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + +[[package]] +name = "wasm-bindgen" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" +dependencies = [ + "cfg-if", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yoke" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "zerovec" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" +dependencies = [ + "serde", + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/packages/@stylexjs/rust-plugin/Cargo.toml b/packages/@stylexjs/rust-plugin/Cargo.toml new file mode 100644 index 000000000..70ea2f32f --- /dev/null +++ b/packages/@stylexjs/rust-plugin/Cargo.toml @@ -0,0 +1,47 @@ +[package] +name = "stylex_rust_compiler" +edition = "2021" +license = "MIT" +version = "0.19.1" +rust-version = "1.88" + +[lib] +name = "stylex_swc_plugin" +crate-type = ["cdylib", "rlib"] + +[features] +default = [] +node = ["dep:napi", "dep:napi-derive", "dep:napi-build"] + +[dependencies] +anyhow = "1.0.98" +indexmap = { version = "2.10.0", features = ["serde"] } +regex = "1.11.1" +serde = { version = "1.0.219", features = ["derive"] } +serde_json = { version = "1.0.140", features = ["preserve_order"] } +ryu-js = "1.0.2" +stacker = "0.1.23" +icu_collator = "2.4.0" +icu_locale_core = "2.4.0" +swc_common = { version = "21.0.1", features = ["sourcemap"] } +swc_ecma_ast = { version = "23.0.0", features = ["serde-impl"] } +swc_ecma_codegen = "26.0.1" +swc_ecma_parser = { version = "38.0.2", features = ["flow", "unstable"] } +swc_ecma_visit = "23.0.0" +swc_ecma_transforms_base = "41.0.1" +napi = { version = "3.9.0", default-features = false, features = ["napi8", "serde-json"], optional = true } +napi-derive = { version = "3.5.0", optional = true } + +[build-dependencies] +napi-build = { version = "2.3.0", optional = true } + +[dev-dependencies] +pretty_assertions = "1.4.1" + +[patch.crates-io] +swc_ecma_parser = { path = "vendor/swc_ecma_parser" } + +[profile.release] +lto = "thin" +codegen-units = 1 +strip = "debuginfo" diff --git a/packages/@stylexjs/rust-plugin/README.md b/packages/@stylexjs/rust-plugin/README.md new file mode 100644 index 000000000..aea45077b --- /dev/null +++ b/packages/@stylexjs/rust-plugin/README.md @@ -0,0 +1,47 @@ +# @stylexjs/rust-plugin + +Native Rust implementation of the StyleX compiler. It accepts the same options +as `@stylexjs/babel-plugin` and is tested against it: the Babel plugin's unchanged +test suite runs against this compiler, and differential tests compare its output +with the Babel plugin's. + +The package is private for now: the native binary is built from source for the +host platform (`npm run build:native`); prebuilt binaries are not published. + +## Usage + +```js +const { transformSync, processStylexRules } = require('@stylexjs/rust-plugin'); + +const { code, map, metadata } = transformSync(source, { + filename: '/src/Button.js', + sourceMaps: true, + unstable_moduleResolution: { type: 'commonJS', rootDir: process.cwd() }, +}); +const css = processStylexRules(metadata.stylex, { useLayers: true }); +``` + +Function-valued options (`env` functions, custom module resolution, `debugFilePath`) +are called back on the calling JavaScript thread. + +When other Babel plugins must run first, use the Babel adapter in place of +`@stylexjs/babel-plugin`: + +```js +plugins: [myPlugin, [require('@stylexjs/rust-plugin/babel'), stylexOptions]]; +``` + +`@stylexjs/unplugin` uses this package when configured with `compiler: 'rust'`. + +## Development + +```bash +npm run build:native --workspace @stylexjs/rust-plugin # cargo build --release, copies native/*.node +npm run test:native --workspace @stylexjs/rust-plugin # Rust tests, native API, bundler, Babel suite +npm run test:parity --workspace @stylexjs/rust-plugin -- babel # reference run of the same suite +npm run bench --workspace @stylexjs/rust-plugin # whole-file timings against Babel +``` + +`vendor/swc_ecma_parser` is the published 38.0.2 crate with one visibility change +(`TokenFlags` re-exported) so the parser can enforce nesting limits; see +`vendor/README.md`. diff --git a/packages/@stylexjs/rust-plugin/babel.d.ts b/packages/@stylexjs/rust-plugin/babel.d.ts new file mode 100644 index 000000000..0760bae39 --- /dev/null +++ b/packages/@stylexjs/rust-plugin/babel.d.ts @@ -0,0 +1,14 @@ +import type { PluginObj } from '@babel/core'; +import type { CompilerOptions, ProcessOptions, Rule } from './index'; + +declare function styleXTransform(): PluginObj; +declare namespace styleXTransform { + function withOptions( + options: CompilerOptions, + ): [typeof styleXTransform, CompilerOptions]; + function processStylexRules( + rules: readonly Rule[], + options?: ProcessOptions | boolean, + ): string; +} +export = styleXTransform; diff --git a/packages/@stylexjs/rust-plugin/babel.js b/packages/@stylexjs/rust-plugin/babel.js new file mode 100644 index 000000000..49eb8b4d2 --- /dev/null +++ b/packages/@stylexjs/rust-plugin/babel.js @@ -0,0 +1,337 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +'use strict'; + +const compiler = require('./index'); +const { createRequire } = require('node:module'); +const babelCore = require('@babel/core'); +const babelRequire = createRequire(require.resolve('@babel/core')); +const generate = babelRequire('@babel/generator').default; +const { TraceMap, originalPositionFor } = babelRequire( + '@jridgewell/trace-mapping', +); +const { codeFrameColumns } = require('@babel/code-frame'); +const DIAGNOSTIC_PREFIX = 'STYLEX_DIAGNOSTIC:'; + +function parseOptions(state, filename) { + const parserOpts = { ...state.file.opts.parserOpts }; + const plugins = [...(parserOpts.plugins || [])]; + const syntax = { jsx: false, flow: false, typescript: false }; + const types = babelCore.types; + types.traverseFast(state.file.ast.program, (node) => { + if (node.type.startsWith('JSX')) syntax.jsx = true; + if (types.isFlow(node)) syntax.flow = true; + if (types.isTypeScript(node)) syntax.typescript = true; + }); + // Parser overrides (notably Hermes) supply an AST without adding their syntax + // to parserOpts.plugins. Recover grammar only; never rerun user visitors. + if ( + (state.file.opts.plugins || []).some((plugin) => + /hermes/.test(plugin.key || ''), + ) + ) { + syntax.jsx = true; + syntax.flow = !syntax.typescript; + } + const has = (name) => + plugins.some( + (plugin) => (Array.isArray(plugin) ? plugin[0] : plugin) === name, + ); + if (syntax.typescript && !has('typescript')) plugins.push('typescript'); + if (syntax.flow && !syntax.typescript && !has('flow')) + plugins.push(['flow', { all: true }]); + if (syntax.jsx && !has('jsx')) plugins.push('jsx'); + parserOpts.plugins = plugins; + return { + filename, + babelrc: false, + configFile: false, + sourceType: state.file.opts.sourceType, + parserOpts, + }; +} + +function sourcePosition(source, offset) { + // SWC reports UTF-8 offsets. Babel's columns count UTF-16 code units. + const prefix = Buffer.from(source, 'utf8') + .subarray(0, offset) + .toString('utf8'); + const lines = prefix.split(/\r\n|\n|\r|\u2028|\u2029/); + return { line: lines.length, column: lines[lines.length - 1].length + 1 }; +} + +function diagnosticFromError(error) { + if (error != null && error.stylexDiagnostic != null) + return error.stylexDiagnostic; + if (error != null && error.diagnostic != null) return error.diagnostic; + const message = error != null ? error.message : null; + if (typeof message !== 'string' || !message.startsWith(DIAGNOSTIC_PREFIX)) + return null; + try { + const diagnostic = JSON.parse(message.slice(DIAGNOSTIC_PREFIX.length)); + if ( + typeof diagnostic.message === 'string' && + ['Error', 'SyntaxError'].includes(diagnostic.name) + ) + return diagnostic; + } catch (_) { + // A malformed native envelope is a native error, not a StyleX diagnostic. + } + return null; +} + +function diagnosticError(diagnostic, source, state) { + let message = diagnostic.message; + const range = diagnostic.range; + if ( + range != null && + Number.isInteger(range.start) && + Number.isInteger(range.end) + ) { + const start = sourcePosition(source, range.start); + const end = sourcePosition(source, range.end); + // Exactly the standard Babel File.buildCodeFrameError behavior: a multiline + // node highlights its start only, not every line through its end. + message += + '\n' + + codeFrameColumns( + source, + { + start, + end: start.line === end.line ? end : undefined, + }, + { highlightCode: state.file.opts.highlightCode !== false }, + ); + } + const Constructor = diagnostic.name === 'SyntaxError' ? SyntaxError : Error; + const error = new Constructor(message); + error.code = + diagnostic.kind === 'parse' ? 'BABEL_PARSE_ERROR' : 'BABEL_TRANSFORM_ERROR'; + return error; +} + +function metadataRules(rules) { + return rules.map(([name, fields, priority]) => { + const output = + fields.constKey != null && fields.constVal != null + ? { + constKey: fields.constKey, + constVal: fields.constVal, + ltr: fields.ltr, + rtl: fields.rtl ?? null, + } + : { ltr: fields.ltr, rtl: fields.rtl ?? null }; + return [name, output, priority]; + }); +} + +function logDiagnostic(method, message) { + if (method === 'warn') { + console.warn(message); + return; + } + // Current Babel uses two console arguments for its package-prefixed errors. + const match = /^\[([^\]]+)\] ([\s\S]*)$/.exec(message); + if (match != null) console[method](`[${match[1]}]`, match[2]); + else console[method](message); +} + +function mayUseStyleX(program, options) { + const sources = new Set(['@stylexjs/stylex', 'stylex', '@stylexjs/atoms']); + for (const entry of Array.isArray(options.importSources) + ? options.importSources + : []) { + const source = typeof entry === 'string' ? entry : entry && entry.from; + if (typeof source === 'string') sources.add(source); + } + const sx = + options.sxPropName === false + ? null + : typeof options.sxPropName === 'string' + ? options.sxPropName + : 'sx'; + let relevant = false; + babelCore.types.traverseFast(program, (node) => { + if (relevant) return; + if ( + node.type === 'ImportDeclaration' && + node.importKind !== 'type' && + node.importKind !== 'typeof' && + sources.has(node.source.value) + ) + relevant = true; + if ( + node.type === 'CallExpression' && + node.callee.type === 'Identifier' && + node.callee.name === 'require' && + node.arguments.length === 1 && + node.arguments[0].type === 'StringLiteral' && + sources.has(node.arguments[0].value) + ) + relevant = true; + if ( + sx != null && + node.type === 'JSXOpeningElement' && + node.name.type === 'JSXIdentifier' && + node.name.name[0] === node.name.name[0].toLowerCase() && + node.attributes.some( + (attr) => + attr.type === 'JSXAttribute' && + attr.name.type === 'JSXIdentifier' && + attr.name.name === sx, + ) + ) + relevant = true; + }); + return relevant; +} + +function lineStarts(source) { + const starts = [0]; + const breaks = /\r\n|\n|\r|\u2028|\u2029/g; + for (let match; (match = breaks.exec(source)); ) + starts.push(match.index + match[0].length); + return starts; +} + +function remapLocations(parsed, nativeMap, priorMap, source) { + const native = new TraceMap(nativeMap); + const prior = priorMap == null ? null : new TraceMap(priorMap); + const starts = lineStarts(source); + function trace(position) { + let result = originalPositionFor(native, { + line: position.line, + column: position.column, + }); + if (result.line == null) return null; + if (prior != null) + result = originalPositionFor(prior, { + line: result.line, + column: result.column, + }); + if (result.line == null || starts[result.line - 1] == null) return null; + return { + line: result.line, + column: result.column, + index: starts[result.line - 1] + result.column, + }; + } + function remap(node) { + if (node.loc == null) return; + const start = trace(node.loc.start); + const end = trace(node.loc.end); + if (start == null || end == null || end.index < start.index) { + node.loc = null; + delete node.start; + delete node.end; + return; + } + // Locations belong to this Babel input file. Leave filename unset so the + // generator uses its configured sourceFileName instead of registering the + // native compiler's absolute parse filename as a second source. + node.loc = { + start, + end, + identifierName: node.loc.identifierName, + }; + node.start = start.index; + node.end = end.index; + } + babelCore.types.traverseFast(parsed.program, remap); + for (const comment of parsed.comments || []) remap(comment); +} + +function styleXTransform() { + return { + name: 'stylex-rust', + visitor: { + Program: { + enter(program, state) { + state.file.metadata.stylex = []; + // No compile/reparse for unrelated files. Their original locations and + // syntax-plugin AST stay intact for subsequent Babel passes. + if (!mayUseStyleX(program.node, state.opts)) return; + const filename = state.file.opts.filename || 'unknown.js'; + const grammar = parseOptions(state, filename); + const original = babelCore.parseSync(state.file.code, grammar); + const originalGenerated = generate( + original.program, + { comments: true }, + state.file.code, + ).code; + const current = generate( + program.node, + { comments: true, sourceMaps: true, sourceFileName: filename }, + state.file.code, + ); + const unchangedInput = originalGenerated === current.code; + const source = unchangedInput ? state.file.code : current.code; + let result; + try { + result = compiler.transformSync(source, { + ...state.opts, + filename, + __sourceFileName: state.file.opts.filename || '', + ...(state.file.opts.sourceType == null + ? {} + : { __sourceType: state.file.opts.sourceType }), + sourceMaps: true, + }); + } catch (error) { + const diagnostic = diagnosticFromError(error); + if (diagnostic != null) + throw diagnosticError(diagnostic, source, state); + throw error; + } + if (result.diagnostic != null) + throw diagnosticError(result.diagnostic, source, state); + if (result.metadata != null && Array.isArray(result.metadata.stylex)) + state.file.metadata.stylex = metadataRules(result.metadata.stylex); + for (const warning of result.warnings || []) + logDiagnostic('warn', warning); + for (const error of result.errors || []) + logDiagnostic('error', error); + // Even a relevant import can be type-only, unused or contain no StyleX + // DSL. Never replace a no-op AST merely to refresh its printed source. + if (result.code === source) return; + const parsed = babelCore.parseSync(result.code, grammar); + if (parsed == null) + throw new Error( + 'The native StyleX compiler produced no JavaScript program', + ); + if (result.map == null) + throw new Error( + 'The native StyleX compiler did not provide a source map for changed code', + ); + remapLocations( + parsed, + result.map, + unchangedInput ? null : current.map, + state.file.code, + ); + program.node.body = parsed.program.body; + program.node.directives = parsed.program.directives; + program.node.interpreter = parsed.program.interpreter; + for (const field of [ + 'leadingComments', + 'innerComments', + 'trailingComments', + ]) { + if (parsed.program[field] == null) delete program.node[field]; + else program.node[field] = parsed.program[field]; + } + state.file.ast.comments = parsed.comments; + program.scope.crawl(); + }, + }, + }, + }; +} + +styleXTransform.withOptions = (options) => [styleXTransform, options]; +styleXTransform.processStylexRules = compiler.processStylexRules; +module.exports = styleXTransform; diff --git a/packages/@stylexjs/rust-plugin/build.rs b/packages/@stylexjs/rust-plugin/build.rs new file mode 100644 index 000000000..23cb14cef --- /dev/null +++ b/packages/@stylexjs/rust-plugin/build.rs @@ -0,0 +1,4 @@ +fn main() { + #[cfg(feature = "node")] + napi_build::setup(); +} diff --git a/packages/@stylexjs/rust-plugin/host.js b/packages/@stylexjs/rust-plugin/host.js new file mode 100644 index 000000000..dab2c4229 --- /dev/null +++ b/packages/@stylexjs/rust-plugin/host.js @@ -0,0 +1,514 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const url = require('node:url'); + +const EXTENSIONS = ['.js', '.ts', '.tsx', '.jsx', '.mjs', '.cjs']; + +// Binary operators the compiler may apply to host values; a fixed table, never +// evaluated source text. +const BINARY = { + '+': (a, b) => a + b, + '-': (a, b) => a - b, + '*': (a, b) => a * b, + '/': (a, b) => a / b, + '%': (a, b) => a % b, + '**': (a, b) => a ** b, + '==': (a, b) => a == b, // eslint-disable-line eqeqeq + '!=': (a, b) => a != b, // eslint-disable-line eqeqeq + '===': (a, b) => a === b, + '!==': (a, b) => a !== b, + '<': (a, b) => a < b, + '<=': (a, b) => a <= b, + '>': (a, b) => a > b, + '>=': (a, b) => a >= b, + '&': (a, b) => a & b, + '|': (a, b) => a | b, + '^': (a, b) => a ^ b, + '<<': (a, b) => a << b, + '>>': (a, b) => a >> b, + '>>>': (a, b) => a >>> b, + in: (a, b) => a in b, + instanceof: (a, b) => a instanceof b, +}; +function binary(operator, left, right) { + if (!Object.hasOwn(BINARY, operator)) { + throw new TypeError(`Unsupported StyleX host operator ${operator}`); + } + return BINARY[operator](left, right); +} + +const UNARY = { + '+': (value) => +value, + '-': (value) => -value, + '~': (value) => ~value, + '!': (value) => !value, + typeof: (value) => typeof value, + void: () => undefined, +}; +function unary(operator, value) { + if (!Object.hasOwn(UNARY, operator)) { + throw new TypeError(`Unsupported StyleX host operator ${operator}`); + } + return UNARY[operator](value); +} + +// JavaScript ToPrimitive (ECMA-262 7.1.1) on the host's own values. +function toPrimitive(value, hint) { + if ( + value === null || + (typeof value !== 'object' && typeof value !== 'function') + ) { + return value; + } + const exotic = value[Symbol.toPrimitive]; + if (exotic != null) { + const result = exotic.call(value, hint); + if ( + result === null || + (typeof result !== 'object' && typeof result !== 'function') + ) { + return result; + } + throw new TypeError('Cannot convert object to primitive value'); + } + for (const name of hint === 'string' + ? ['toString', 'valueOf'] + : ['valueOf', 'toString']) { + const method = value[name]; + if (typeof method === 'function') { + const result = method.call(value); + if ( + result === null || + (typeof result !== 'object' && typeof result !== 'function') + ) { + return result; + } + } + } + throw new TypeError('Cannot convert object to primitive value'); +} + +function candidates(file) { + const extension = path.extname(file); + const base = EXTENSIONS.includes(extension) + ? file.slice(0, -extension.length) + : file; + return [file, ...EXTENSIONS.map((suffix) => base + suffix)]; +} + +function aliasedPaths(source, aliases) { + const result = [source]; + for (const [alias, replacements] of Object.entries(aliases || {})) { + const values = Array.isArray(replacements) ? replacements : [replacements]; + const [before, after] = alias.split('*'); + if (alias.includes('*')) { + if (source.startsWith(before) && source.endsWith(after)) { + const middle = source.slice( + before.length, + after.length ? -after.length : undefined, + ); + for (const replacement of values) + result.push(replacement.split('*').join(middle)); + } + } else if (alias === source) { + result.push(...values); + } + } + return result; +} + +function resolveFile(source, filename, aliases, rootDir) { + const { moduleResolve } = require('@dual-bundle/import-meta-resolve'); + for (const candidate of candidates(source)) { + if (candidate.startsWith('.')) { + try { + return url.fileURLToPath( + moduleResolve(candidate, url.pathToFileURL(filename)), + ); + } catch { + continue; + } + } + for (const alias of aliasedPaths(candidate, aliases)) { + if (alias.startsWith('/ROOT/') && rootDir != null) { + const found = candidates(path.join(rootDir, alias.slice(6))).find( + (file) => fs.existsSync(file), + ); + if (found) return found; + continue; + } + if (path.isAbsolute(alias)) { + const found = candidates(alias).find((file) => fs.existsSync(file)); + if (found) return found; + continue; + } + try { + return url.fileURLToPath( + moduleResolve(alias, url.pathToFileURL(filename)), + ); + } catch { + continue; + } + } + } + return undefined; +} + +function createHost(options, filename, binding) { + // Values the compiler cannot copy (functions, class instances, Map/Date, + // cycles, symbols) stay here; the compiler reads them lazily by id. The same + // JavaScript value always has the same id within a transform. + const handles = []; + const handleIds = new Map(); + function handle(value) { + let id = handleIds.get(value); + if (id == null) { + id = String(handles.length); + handles.push(value); + handleIds.set(value, id); + } + return { type: 'handle', value: id }; + } + function isPlain(value) { + if (Array.isArray(value)) return true; + const prototype = Object.getPrototypeOf(value); + return ( + (prototype === Object.prototype || prototype === null) && + Object.getOwnPropertySymbols(value).length === 0 + ); + } + function encode(value, path = new Set()) { + if (value === undefined) return { type: 'undefined' }; + if (value === null) return { type: 'null' }; + if (typeof value === 'boolean') return { type: 'boolean', value }; + if (typeof value === 'number') { + return { + type: 'number', + value: Object.is(value, -0) ? '-0' : String(value), + }; + } + if (typeof value === 'string') return { type: 'string', value }; + if (typeof value !== 'object' || path.has(value) || !isPlain(value)) { + return handle(value); + } + path.add(value); + const result = Array.isArray(value) + ? { type: 'array', value: value.map((item) => encode(item, path)) } + : { + type: 'object', + value: Object.entries(value).map(([key, item]) => [ + key, + encode(item, path), + ]), + }; + path.delete(value); + return result; + } + // Compiler functions passed to JavaScript run back on the compiler thread. + const compilerFunctions = new Map(); + function compilerFunction(id) { + let wrapper = compilerFunctions.get(id); + if (wrapper == null) { + wrapper = function (...args) { + let result; + try { + result = binding.callCompilerFunction( + id, + encode(this), + args.map((arg) => encode(arg)), + ); + } catch (error) { + // The compiler function failed because user code it called threw: + // that exception now propagates through the caller, which may catch it. + if (state.hasThrown) { + const thrown = state.thrown; + state.hasThrown = false; + state.thrown = undefined; + throw thrown; + } + throw error; + } + return decode(result); + }; + compilerFunctions.set(id, wrapper); + } + return wrapper; + } + function decode(value) { + switch (value.type) { + case 'undefined': + return undefined; + case 'null': + return null; + case 'boolean': + case 'string': + return value.value; + case 'number': + return Number(value.value); + case 'array': + return value.value.map(decode); + case 'object': + return Object.fromEntries( + value.value.map(([key, item]) => [key, decode(item)]), + ); + case 'handle': + if (!Object.hasOwn(handles, value.value)) { + throw new TypeError(`Unknown StyleX host value ${value.value}`); + } + return handles[value.value]; + case 'closure': + return compilerFunction(value.value); + default: + throw new TypeError(`Unknown StyleX host value: ${value.type}`); + } + } + const functions = new Map(); + const functionsByIdentity = new Map(); + const seen = new Set(); + // Callbacks and non-JSON numbers are tagged in-band, so user data must never + // be able to spell a tag (for example an `env` object loaded from JSON). + const RESERVED = [ + '__stylexCallback', + '__stylexUndefined', + '__stylexNumber', + '__stylexHandle', + ]; + const { isProxy } = require('node:util').types; + // `env` values are read by compiled code, so anything a copy would not + // preserve (class instances, Map/Date, proxies, symbols, holes, cycles, very + // large arrays) stays a live JavaScript value the compiler reads lazily. + function opaqueEnvValue(value) { + if (typeof value === 'symbol' || typeof value === 'bigint') return true; + if (value == null || typeof value !== 'object') return false; + if (isProxy(value) || seen.has(value)) return true; + if (Array.isArray(value)) { + if (value.length > 10000) return true; + for (let index = 0; index < value.length; index++) { + if (!(index in value)) return true; + } + return Object.getPrototypeOf(value) !== Array.prototype; + } + return !isPlain(value); + } + function serialize(value, receiver, inEnv = false) { + if (inEnv && opaqueEnvValue(value)) { + return { __stylexHandle: handle(value).value }; + } + if (typeof value === 'function') { + let id = functionsByIdentity.get(value); + if (id == null) { + id = String(functions.size); + functions.set(id, { callback: value, receiver }); + functionsByIdentity.set(value, id); + } + return { __stylexCallback: id }; + } + if (value === undefined) return { __stylexUndefined: true }; + if ( + typeof value === 'number' && + (Object.is(value, -0) || !Number.isFinite(value)) + ) { + return { __stylexNumber: Object.is(value, -0) ? '-0' : String(value) }; + } + if (value != null && typeof value === 'object') { + if (seen.has(value)) + throw new TypeError('StyleX options contain a cyclic object'); + const reserved = RESERVED.find((key) => Object.hasOwn(value, key)); + if (reserved != null) { + throw new TypeError( + `StyleX options cannot contain the key ${reserved}`, + ); + } + seen.add(value); + const result = Array.isArray(value) + ? value.map((item) => serialize(item, value, inEnv)) + : Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + serialize( + item, + value, + inEnv || (value === options && key === 'env'), + ), + ]), + ); + seen.delete(value); + return result; + } + return value; + } + const serialized = serialize(options); + const resolutionCache = new Map(); + const canonicalCache = new Map(); + function canonical(file) { + if (canonicalCache.has(file)) return canonicalCache.get(file); + const mode = options.unstable_moduleResolution || {}; + if (mode.type === 'custom') return mode.getCanonicalFilePath(file); + let directory = path.dirname(file); + let result; + for (;;) { + const manifest = path.join(directory, 'package.json'); + if (fs.existsSync(manifest)) { + try { + const pkg = JSON.parse(fs.readFileSync(manifest, 'utf8')); + result = `${pkg.name}:${path.relative(directory, file)}`; + } catch (error) { + console.error(error); + } + break; + } + const parent = path.dirname(directory); + if (parent === directory || directory === '') break; + directory = parent; + } + if (result == null) { + result = + mode.rootDir != null + ? path.relative(mode.rootDir, file) + : `_unknown_path_:${path.basename(file)}`; + } + canonicalCache.set(file, result); + return result; + } + function resolve(source, from = filename) { + const key = `${from}\0${source}`; + if (resolutionCache.has(key)) return resolutionCache.get(key); + const mode = options.unstable_moduleResolution || {}; + const resolved = + mode.type === 'custom' + ? mode.filePathResolver(source, from, options.aliases) + : resolveFile(source, from, options.aliases, mode.rootDir); + resolutionCache.set(key, resolved); + return resolved; + } + const packageCache = new Map(); + function packageDetails(file) { + if (packageCache.has(file)) return packageCache.get(file); + let directory = path.dirname(file); + let result; + for (;;) { + const manifest = path.join(directory, 'package.json'); + if (fs.existsSync(manifest)) { + try { + result = [ + JSON.parse(fs.readFileSync(manifest, 'utf8')).name, + directory, + ]; + } catch (error) { + console.error(error); + } + break; + } + const parent = path.dirname(directory); + if (parent === directory || directory === '') break; + directory = parent; + } + packageCache.set(file, result); + return result; + } + function debugFile(file) { + if (typeof options.debugFilePath === 'function') { + // Babel interpolates the result into `${name}:${line}` unless it is ''. + const name = options.debugFilePath(file); + return name === '' ? '' : `${name}`; + } + const current = packageDetails(process.cwd()); + const pkg = packageDetails(file); + if (pkg) { + const relative = path.relative(pkg[1], file); + return current && current[0] === pkg[0] + ? relative + : `${pkg[0]}:${relative}`; + } + const mode = options.unstable_moduleResolution || {}; + const short = + mode.type === 'commonJS' && + typeof mode.rootDir === 'string' && + mode.rootDir + ? path.relative(mode.rootDir, file) + : file.split(path.sep).slice(-2).join('/'); + const index = file.indexOf('node_modules'); + if (index !== -1) { + const name = file + .slice(index + 'node_modules'.length + 1) + .split(path.sep)[0]; + if (name) return `${name}:${short}`; + } + return mode.type === 'haste' ? path.basename(file) : short; + } + // The first exception thrown by user code; the facade rethrows it unchanged + // once the native call returns, exactly as Babel would have propagated it. + const state = { thrown: undefined, hasThrown: false }; + function run(id, args) { + if (id === '@callbackArity') { + const entry = functions.get(String(args[0])); + if (entry == null) + throw new TypeError(`Unknown StyleX host callback ${args[0]}`); + return entry.callback.length; + } + if (id === '@callbackValue') { + const entry = functions.get(String(args[0])); + if (entry == null) + throw new TypeError(`Unknown StyleX host callback ${args[0]}`); + return entry.callback; + } + if (id === '@get') return args[0][args[1]]; + if (id === '@call') return Reflect.apply(args[0], args[1], args.slice(2)); + if (id === '@typeof') return typeof args[0]; + if (id === '@toPrimitive') return toPrimitive(args[0], args[1]); + if (id === '@toString') return String(args[0]); + if (id === '@toNumber') return Number(args[0]); + if (id === '@truthy') return Boolean(args[0]); + if (id === '@sameValue') return Object.is(args[0], args[1]); + if (id === '@binary') return binary(args[0], args[1], args[2]); + if (id === '@unary') return unary(args[0], args[1]); + if (id === '@debugFile') return debugFile(args[0]); + // Babel only tests the resolver's result for truthiness and later uses the + // canonical path in string concatenation, so custom resolvers may return + // any value. + if (id === '@resolveImport') { + const resolved = resolve(args[0], args[1]); + return resolved ? `${canonical(resolved)}` : undefined; + } + if (id === '@canonicalFile') return `${canonical(args[0])}`; + if (id === '@rewriteImport') { + const resolved = resolve(args[0], args[1]); + if (!resolved) return undefined; + let result = path + .relative(path.dirname(args[1]), resolved) + .split(path.sep) + .join('/'); + const extension = EXTENSIONS.find((suffix) => result.endsWith(suffix)); + if (extension) result = result.slice(0, -extension.length); + return result.startsWith('.') ? result : `./${result}`; + } + const entry = functions.get(id); + if (entry == null) + throw new TypeError(`Unknown StyleX host callback ${id}`); + return entry.callback.apply(entry.receiver, args); + } + function invoke(id, encodedArgs) { + try { + return encode(run(id, encodedArgs.map(decode))); + } catch (error) { + if (!state.hasThrown) { + state.hasThrown = true; + state.thrown = error; + } + // Not a host value: the compiler treats it as a failed callback. + return { type: 'thrown' }; + } + } + return { options: serialized, invoke, state }; +} + +module.exports = { createHost }; diff --git a/packages/@stylexjs/rust-plugin/index.d.ts b/packages/@stylexjs/rust-plugin/index.d.ts new file mode 100644 index 000000000..cbebbc54c --- /dev/null +++ b/packages/@stylexjs/rust-plugin/index.d.ts @@ -0,0 +1,96 @@ +export type Rule = [ + string, + { + ltr: string; + rtl: string | null; + constKey?: string; + constVal?: string | number; + }, + number, +]; +export type ModuleResolution = + | { type: 'haste'; themeFileExtension?: string } + | { + type: 'commonJS' | 'experimental_crossFileParsing'; + rootDir?: string; + themeFileExtension?: string; + } + | { + type: 'custom'; + themeFileExtension?: string; + filePathResolver( + importPath: string, + sourceFilePath: string, + aliases?: Record, + ): string | void; + getCanonicalFilePath(filePath: string): string; + }; +export interface CompilerOptions { + filename?: string; + sourceMaps?: boolean; + classNamePrefix?: string; + dev?: boolean; + debug?: boolean; + enableDebugDataProp?: boolean; + enableDevClassNames?: boolean; + enableInlinedConditionalMerge?: boolean; + enableMediaQueryOrder?: boolean; + enableLegacyValueFlipping?: boolean; + enableLogicalStylesPolyfill?: boolean; + enableFontSizePxToRem?: boolean; + enableMinifiedKeys?: boolean; + sxPropName?: string | false; + styleResolution?: + | 'property-specificity' + | 'application-order' + | 'legacy-expand-shorthands'; + propertyValidationMode?: 'throw' | 'warn' | 'silent'; + env?: Readonly>; + aliases?: Record; + importSources?: readonly (string | { from: string; as: string })[]; + runtimeInjection?: boolean | string | { from: string; as: string }; + rewriteAliases?: boolean; + treeshakeCompensation?: boolean; + unstable_moduleResolution?: ModuleResolution; + debugFilePath?: (filename: string) => string; + test?: boolean; + [option: string]: unknown; +} +export interface SourceMap { + version: 3; + sources: string[]; + sourcesContent: (string | null)[]; + names: string[]; + mappings: string; +} +export interface TransformResult { + code: string; + map: SourceMap | null; + metadata: { stylex: Rule[] }; + warnings: string[]; + errors: string[]; +} +export interface ProcessOptions { + useLayers?: + | boolean + | { + before?: readonly string[]; + after?: readonly string[]; + prefix?: string; + }; + enableLTRRTLComments?: boolean; + legacyDisableLayers?: boolean; + useLegacyClassnamesSort?: boolean; +} +export function transformSync( + source: string, + options?: CompilerOptions, +): TransformResult; +export function transform( + source: string, + options?: CompilerOptions, +): Promise; +export function processStylexRules( + rules: readonly Rule[], + options?: ProcessOptions | boolean, +): string; diff --git a/packages/@stylexjs/rust-plugin/index.js b/packages/@stylexjs/rust-plugin/index.js new file mode 100644 index 000000000..853e3e8ab --- /dev/null +++ b/packages/@stylexjs/rust-plugin/index.js @@ -0,0 +1,128 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +const path = require('node:path'); +const { codeFrameColumns } = require('@babel/code-frame'); + +let binding; +function getBinding() { + if (binding == null) { + const filename = `stylex.${process.platform}-${process.arch}.node`; + try { + binding = require(path.join(__dirname, 'native', filename)); + } catch (cause) { + throw new Error( + `The StyleX native compiler for ${process.platform}-${process.arch} is not built. Run npm run build:native --workspace @stylexjs/rust-plugin.`, + { cause }, + ); + } + } + return binding; +} + +function sourcePosition(source, offset) { + const prefix = Buffer.from(source).subarray(0, offset).toString('utf8'); + const lines = prefix.split(/\r\n|[\n\r\u2028\u2029]/u); + return { line: lines.length, column: lines[lines.length - 1].length + 1 }; +} + +function rethrowDiagnostic(cause, source, filename) { + const prefix = 'STYLEX_DIAGNOSTIC:'; + if (!cause.message?.startsWith(prefix)) throw cause; + const diagnostic = JSON.parse(cause.message.slice(prefix.length)); + const parse = diagnostic.kind === 'parse'; + let message = diagnostic.message; + let start; + if (diagnostic.range) { + start = sourcePosition(source, diagnostic.range.start); + const end = sourcePosition(source, diagnostic.range.end); + // Babel's parser errors end with the position, e.g. "(1:6)". + if (parse) message += ` (${start.line}:${start.column - 1})`; + const location = { start }; + if (end.line === start.line) location.end = end; + message += `\n${codeFrameColumns(source, location, { highlightCode: false })}`; + } + const Constructor = + parse || diagnostic.name === 'SyntaxError' ? SyntaxError : Error; + const error = new Constructor(`${filename}: ${message}`, { cause }); + // The fields tooling (bundler overlays, Jest) reads from Babel's errors. + error.code = parse ? 'BABEL_PARSE_ERROR' : 'BABEL_TRANSFORM_ERROR'; + if (parse && start != null) { + error.loc = { + line: start.line, + column: start.column - 1, + index: Buffer.from(source) + .subarray(0, diagnostic.range.start) + .toString('utf8').length, + }; + error.pos = error.loc.index; + } + error.stylexDiagnostic = diagnostic; + throw error; +} + +const MAX_NESTED_TRANSFORMS = 64; +let depth = 0; + +function transformSync(source, options = {}) { + if (typeof source !== 'string') { + throw new TypeError('StyleX transformSync expects JavaScript source text'); + } + const { filename = 'unknown.js', ...pluginOptions } = options; + const host = require('./host').createHost( + pluginOptions, + filename, + getBinding(), + ); + // A transform started from a callback of another one nests native frames on + // the JS stack; past this depth V8 could not report the overflow safely. + if (depth >= MAX_NESTED_TRANSFORMS) { + throw new RangeError('Maximum call stack size exceeded'); + } + let output; + depth++; + try { + output = getBinding().transformSync( + source, + filename, + host.options, + host.invoke, + ); + } catch (cause) { + // A user callback threw: propagate that exception, as Babel does, rather + // than the compiler error it caused. + if (host.state.hasThrown) throw host.state.thrown; + rethrowDiagnostic(cause, source, filename); + } finally { + depth--; + } + if (host.state.hasThrown) throw host.state.thrown; + const { map, metadata, errors, warnings } = JSON.parse(output.json); + return { code: output.code, map, metadata, errors, warnings }; +} + +async function transform(source, options = {}) { + return transformSync(source, options); +} + +function processStylexRules(rules, options = {}) { + // Mirror the JS compiler's tolerance: it reads only the first three entries + // of a rule and coerces the priority with JavaScript number semantics. + const normalized = Array.from(rules, (rule) => { + const priority = Number(rule[2]); + return [ + rule[0], + rule[1], + Number.isFinite(priority) ? priority : String(priority), + ]; + }); + return getBinding().processStylexRules(JSON.stringify(normalized), options); +} + +module.exports = { transformSync, transform, processStylexRules }; diff --git a/packages/@stylexjs/rust-plugin/package.json b/packages/@stylexjs/rust-plugin/package.json new file mode 100644 index 000000000..fa1df3e75 --- /dev/null +++ b/packages/@stylexjs/rust-plugin/package.json @@ -0,0 +1,36 @@ +{ + "name": "@stylexjs/rust-plugin", + "version": "0.19.1", + "private": true, + "description": "Native Rust compiler for StyleX.", + "main": "index.js", + "types": "index.d.ts", + "exports": { + ".": { "types": "./index.d.ts", "default": "./index.js" }, + "./babel": { "types": "./babel.d.ts", "default": "./babel.js" } + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/meta/stylex.git" + }, + "engines": { "node": ">=18" }, + "scripts": { + "build:native": "node scripts/build.cjs", + "test:native": "node scripts/test.cjs", + "test:parity": "node scripts/parity.cjs", + "bench": "node scripts/benchmark.cjs", + "prepack": "npm run build:native" + }, + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@dual-bundle/import-meta-resolve": "^4.1.0" + }, + "peerDependencies": { + "@babel/core": "^7.26.8" + }, + "peerDependenciesMeta": { "@babel/core": { "optional": true } }, + "files": ["index.js", "index.d.ts", "babel.js", "babel.d.ts", "host.js", "native/*.node", "README.md"], + "preferUnplugged": true, + "sideEffects": false +} diff --git a/packages/@stylexjs/rust-plugin/rust-toolchain.toml b/packages/@stylexjs/rust-plugin/rust-toolchain.toml new file mode 100644 index 000000000..bf5eb1a84 --- /dev/null +++ b/packages/@stylexjs/rust-plugin/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.96.1" +components = ["clippy", "rustfmt"] +profile = "minimal" diff --git a/packages/@stylexjs/rust-plugin/scripts/benchmark.cjs b/packages/@stylexjs/rust-plugin/scripts/benchmark.cjs new file mode 100644 index 000000000..a3d1eb346 --- /dev/null +++ b/packages/@stylexjs/rust-plugin/scripts/benchmark.cjs @@ -0,0 +1,264 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +// Compares whole-file transforms (parse, compile, print, CSS metadata) of the +// current Babel plugin and the native compiler on the repository's benchmark +// fixtures. A fixture only reports a ratio when both engines produce the same +// StyleX metadata and the same program (compared after reprinting both outputs +// with Babel), so the timings describe comparable work. +// +// node scripts/benchmark.cjs [--iterations=N] [--json=out.json] [--only=name] + +const fs = require('node:fs'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const { isDeepStrictEqual } = require('node:util'); + +const packageRoot = path.resolve(__dirname, '..'); +const repository = path.resolve(packageRoot, '../../..'); +const fixtures = path.join(repository, 'packages/benchmarks'); +const args = Object.fromEntries( + process.argv.slice(2).map((arg) => { + const [key, value = 'true'] = arg.replace(/^--/, '').split('='); + return [key, value]; + }), +); + +const baseOptions = { + classNamePrefix: 'x', + stylexSheetName: '<>', + unstable_moduleResolution: { type: 'haste' }, +}; + +function file(...parts) { + const filename = path.join(fixtures, ...parts); + return { filename, source: fs.readFileSync(filename, 'utf8') }; +} + +function cases() { + const perf = [ + 'create-basic', + 'create-complex', + 'createTheme-basic', + 'createTheme-complex', + 'colors.stylex', + 'sizes.stylex', + ].map((name) => file('perf/fixtures', `${name}.js`)); + const dynamic = file('size/fixtures/lotsOfStylesDynamic.js'); + const large = file('size/fixtures/lotsOfStyles.js'); + const callbacks = { + filename: path.join(fixtures, 'perf/fixtures/callbacks.js'), + source: [ + "import * as stylex from '@stylexjs/stylex';", + ...Array.from( + { length: 40 }, + (_, index) => + `export const s${index} = stylex.create({ root: { color: stylex.env.mix('red', ${index}), padding: ${index} } });`, + ), + ].join('\n'), + options: { + env: { + mix: (color, amount) => + `color-mix(in srgb, ${color} ${amount}%, white)`, + }, + unstable_moduleResolution: { + type: 'custom', + filePathResolver: () => undefined, + getCanonicalFilePath: (filename) => path.basename(filename), + }, + }, + }; + const noStylex = { + filename: path.join(fixtures, 'perf/fixtures/plain.js'), + source: fs.readFileSync( + require.resolve('@babel/core/lib/transformation/file/file.js'), + 'utf8', + ), + }; + return [ + { name: 'tiny (create-basic)', files: [perf[0]] }, + { name: 'complex (create-complex)', files: [perf[1]] }, + { name: 'theme (createTheme-complex)', files: [perf[3]] }, + { name: 'dynamic (lotsOfStylesDynamic)', files: [dynamic] }, + { name: 'large (lotsOfStyles 7 MB)', files: [large], iterations: 3 }, + { name: 'multi-file (all perf fixtures)', files: perf }, + { name: 'callbacks (env functions + custom resolver)', files: [callbacks] }, + { name: 'no StyleX (plain module)', files: [noStylex] }, + ].filter((entry) => args.only == null || entry.name.includes(args.only)); +} + +function loadEngine(engine) { + if (engine === 'babel') { + const babel = require('@babel/core'); + const plugin = require('@stylexjs/babel-plugin'); + return ({ filename, source, options }) => { + const result = babel.transformSync(source, { + filename, + babelrc: false, + configFile: false, + parserOpts: { plugins: ['jsx', ['flow', { all: true }]] }, + plugins: [[plugin, { ...baseOptions, ...options }]], + }); + return { code: result.code, metadata: result.metadata.stylex }; + }; + } + const native = require(packageRoot); + return ({ filename, source, options }) => { + const result = native.transformSync(source, { + ...baseOptions, + ...options, + filename, + }); + return { code: result.code, metadata: result.metadata.stylex }; + }; +} + +function normalizeCode(code) { + const babel = require('@babel/core'); + const generate = require('@babel/generator').default; + const ast = babel.parseSync(code, { + babelrc: false, + configFile: false, + filename: 'output.js', + parserOpts: { plugins: ['jsx', ['flow', { all: true }]] }, + }); + return generate(ast.program, { compact: true, comments: false }).code; +} + +function normalizeMetadata(rules) { + return (rules || []).map(([key, value, priority]) => [ + key, + { ...value, rtl: value.rtl ?? null }, + priority, + ]); +} + +function quantile(sorted, q) { + return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * q))]; +} + +function measure(run, files, iterations) { + const warmup = Math.min(5, iterations); + for (let i = 0; i < warmup; i += 1) for (const input of files) run(input); + const samples = []; + for (let i = 0; i < iterations; i += 1) { + const start = process.hrtime.bigint(); + for (const input of files) run(input); + samples.push(Number(process.hrtime.bigint() - start) / 1e6); + } + samples.sort((a, b) => a - b); + return { + median: quantile(samples, 0.5), + p90: quantile(samples, 0.9), + samples: samples.length, + }; +} + +// Cold time: a fresh process loads the engine and performs the first transform. +function cold(engine, entry) { + const script = ` + const start = process.hrtime.bigint(); + const run = require(${JSON.stringify(__filename)}).loadEngine(${JSON.stringify(engine)}); + const entry = require(${JSON.stringify(__filename)}).cases().find((item) => item.name === ${JSON.stringify(entry.name)}); + for (const input of entry.files) run(input); + process.stdout.write(String(Number(process.hrtime.bigint() - start) / 1e6)); + `; + const samples = []; + for (let i = 0; i < 3; i += 1) { + const result = spawnSync(process.execPath, ['-e', script], { + cwd: packageRoot, + encoding: 'utf8', + env: { ...process.env, STYLEX_BENCHMARK_CHILD: '1' }, + }); + if (result.status !== 0) throw new Error(result.stderr); + samples.push(Number(result.stdout)); + } + samples.sort((a, b) => a - b); + return samples[1]; +} + +function main() { + const engines = { babel: loadEngine('babel'), rust: loadEngine('rust') }; + const iterations = Number(args.iterations || 30); + const report = []; + for (const entry of cases()) { + const outputs = {}; + const errors = {}; + for (const [name, run] of Object.entries(engines)) { + try { + outputs[name] = entry.files.map((input) => { + const result = run(input); + return [ + normalizeMetadata(result.metadata), + normalizeCode(result.code), + ]; + }); + } catch (error) { + errors[name] = String(error.message).split('\n')[0]; + } + } + const comparable = + Object.keys(errors).length === 0 && + isDeepStrictEqual(outputs.babel, outputs.rust); + const row = { + name: entry.name, + comparable, + errors, + rules: (outputs.babel || []).reduce( + (sum, [rules]) => sum + rules.length, + 0, + ), + bytes: entry.files.reduce( + (sum, input) => sum + Buffer.byteLength(input.source), + 0, + ), + }; + for (const [name, run] of Object.entries(engines)) { + row[name] = + errors[name] != null + ? { warm: null, cold: null } + : { + warm: measure(run, entry.files, entry.iterations || iterations), + cold: args.cold === 'false' ? null : cold(name, entry), + }; + } + row.warmSpeedup = comparable + ? row.babel.warm.median / row.rust.warm.median + : null; + row.coldSpeedup = + comparable && row.rust.cold ? row.babel.cold / row.rust.cold : null; + report.push(row); + } + const format = (value) => (value == null ? 'n/a' : value.toFixed(2)); + console.log( + `node ${process.version} ${process.platform}-${process.arch}; warm = median of ${iterations} runs after warmup; cold = median of 3 fresh processes (load + first transform)`, + ); + console.log( + '| fixture | bytes | rules | code + metadata equal | babel warm ms | rust warm ms | warm speedup | babel cold ms | rust cold ms | cold speedup |', + ); + console.log('|---|---:|---:|:---:|---:|---:|---:|---:|---:|---:|'); + for (const row of report) { + console.log( + `| ${row.name} | ${row.bytes} | ${row.rules} | ${row.comparable ? 'yes' : 'NO'} | ${format(row.babel.warm?.median)} | ${format(row.rust.warm?.median)} | ${row.warmSpeedup ? `${format(row.warmSpeedup)}x` : 'n/a'} | ${format(row.babel.cold)} | ${format(row.rust.cold)} | ${row.coldSpeedup ? `${format(row.coldSpeedup)}x` : 'n/a'} |`, + ); + } + for (const row of report) { + for (const [engine, message] of Object.entries(row.errors)) + console.log(`${row.name}: ${engine} failed: ${message}`); + } + if (args.json) + fs.writeFileSync( + path.resolve(args.json), + `${JSON.stringify({ node: process.version, platform: `${process.platform}-${process.arch}`, report }, null, 2)}\n`, + ); + if (report.some((row) => !row.comparable)) process.exitCode = 1; +} + +module.exports = { cases, loadEngine }; +if (require.main === module && !process.env.STYLEX_BENCHMARK_CHILD) main(); diff --git a/packages/@stylexjs/rust-plugin/scripts/build.cjs b/packages/@stylexjs/rust-plugin/scripts/build.cjs new file mode 100644 index 000000000..68b1e516e --- /dev/null +++ b/packages/@stylexjs/rust-plugin/scripts/build.cjs @@ -0,0 +1,74 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const root = path.resolve(__dirname, '..'); +const result = spawnSync( + 'cargo', + [ + 'build', + '--locked', + '--manifest-path', + path.join(root, 'Cargo.toml'), + '--features', + 'node', + '--release', + '--message-format=json', + ], + { + cwd: root, + encoding: 'utf8', + maxBuffer: 16 * 1024 * 1024, + env: process.env, + }, +); +if (result.error) { + // For example cargo is not installed or not on PATH. + console.error(`Could not run cargo: ${result.error.message}`); + process.exit(1); +} +if (result.stderr) process.stderr.write(result.stderr); +if (result.status !== 0) { + for (const line of result.stdout.split('\n').filter(Boolean)) { + try { + const item = JSON.parse(line); + if (item.reason === 'compiler-message') + process.stderr.write( + item.message.rendered || `${item.message.message}\n`, + ); + } catch { + process.stderr.write(`${line}\n`); + } + } + if (result.error) process.stderr.write(`${result.error.message}\n`); + process.exit(result.status || 1); +} +const artifact = result.stdout + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)) + .find( + (item) => + item.reason === 'compiler-artifact' && + item.target?.name === 'stylex_swc_plugin' && + item.target.crate_types?.includes('cdylib'), + ); +const library = artifact?.filenames.find((file) => + /\.(so|dylib|dll)$/.test(file), +); +if (!library) + throw new Error('Cargo did not produce the StyleX native compiler'); +const nativeDir = path.join(root, 'native'); +fs.mkdirSync(nativeDir, { recursive: true }); +const filename = `stylex.${process.platform}-${process.arch}.node`; +fs.copyFileSync(library, path.join(nativeDir, filename)); +console.log(`Built ${filename}`); diff --git a/packages/@stylexjs/rust-plugin/scripts/native-jest-setup.cjs b/packages/@stylexjs/rust-plugin/scripts/native-jest-setup.cjs new file mode 100644 index 000000000..b9ac3123c --- /dev/null +++ b/packages/@stylexjs/rust-plugin/scripts/native-jest-setup.cjs @@ -0,0 +1,26 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +const path = require('node:path'); + +test.only = test; +it.only = it; +describe.only = describe; +global.fit = test; +global.fdescribe = describe; + +function plugin() { + const native = require('../babel.js'); + native.default = native; + return native; +} + +jest.doMock(path.resolve(__dirname, '../../babel-plugin/src/index.js'), plugin); +jest.doMock(path.resolve(__dirname, '../../babel-plugin/lib/index.js'), plugin); +jest.doMock('@stylexjs/babel-plugin', plugin); diff --git a/packages/@stylexjs/rust-plugin/scripts/parity.cjs b/packages/@stylexjs/rust-plugin/scripts/parity.cjs new file mode 100644 index 000000000..bd94468da --- /dev/null +++ b/packages/@stylexjs/rust-plugin/scripts/parity.cjs @@ -0,0 +1,72 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +const path = require('node:path'); +const fs = require('node:fs'); +const { execFileSync, spawnSync } = require('node:child_process'); + +const packageRoot = path.resolve(__dirname, '..'); +const repository = path.resolve(packageRoot, '../../..'); +const babelRoot = path.join(repository, 'packages/@stylexjs/babel-plugin'); +const engine = process.argv[2] || 'rust'; +if (!['rust', 'babel'].includes(engine)) + throw new Error('Expected rust or babel engine'); +const output = path.resolve( + process.env.STYLEX_TEST_OUTPUT || path.join(repository, '.build/rust-parity'), +); +fs.mkdirSync(output, { recursive: true }); +const tracked = execFileSync('git', ['-C', repository, 'ls-files'], { + encoding: 'utf8', +}) + .trim() + .split('\n'); +const tests = tracked.filter( + (file) => + file.startsWith('packages/@stylexjs/babel-plugin/') && + file.includes('/__tests__/') && + file.endsWith('.js') && + !file.includes('/__fixtures__/'), +); +if (!tests.length) throw new Error('No tracked Babel tests discovered'); +const config = require(path.join(babelRoot, 'jest.config.js')); +const temporaryConfig = path.join(output, `${engine}.config.json`); +fs.writeFileSync( + temporaryConfig, + JSON.stringify({ + ...config, + rootDir: babelRoot, + ...(engine === 'rust' + ? { + collectCoverage: false, + coverageThreshold: undefined, + setupFilesAfterEnv: [path.join(__dirname, 'native-jest-setup.cjs')], + } + : { + setupFilesAfterEnv: [path.join(__dirname, 'unfocus-jest-setup.cjs')], + }), + }), +); +const args = [ + path.join(repository, 'node_modules/jest/bin/jest.js'), + '--config', + temporaryConfig, + '--runInBand', + '--ci', + '--runTestsByPath', + ...tests.map((file) => path.join(repository, file)), + '--json', + `--outputFile=${path.join(output, `${engine}.results.json`)}`, +]; +if (engine === 'babel') args.push('--coverage'); +const result = spawnSync(process.execPath, args, { + cwd: babelRoot, + env: process.env, + stdio: 'inherit', +}); +process.exitCode = result.status == null ? 1 : result.status; diff --git a/packages/@stylexjs/rust-plugin/scripts/test.cjs b/packages/@stylexjs/rust-plugin/scripts/test.cjs new file mode 100644 index 000000000..7ec4e58c8 --- /dev/null +++ b/packages/@stylexjs/rust-plugin/scripts/test.cjs @@ -0,0 +1,38 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const root = path.resolve(__dirname, '..'); +const steps = [ + [ + 'cargo', + ['test', '--locked', '--manifest-path', path.join(root, 'Cargo.toml')], + ], + [ + process.execPath, + ['--test', path.join(root, 'tests/babel_facade_current.cjs')], + ], + [ + process.execPath, + ['--test', path.join(root, 'tests/native_api_current.cjs')], + ], + [process.execPath, ['--test', path.join(root, 'tests/unplugin_current.cjs')]], + [process.execPath, [path.join(__dirname, 'parity.cjs'), 'rust']], +]; +for (const [command, args] of steps) { + const result = spawnSync(command, args, { + cwd: root, + env: process.env, + stdio: 'inherit', + }); + if (result.status !== 0) + process.exit(result.status == null ? 1 : result.status); +} diff --git a/packages/@stylexjs/rust-plugin/scripts/unfocus-jest-setup.cjs b/packages/@stylexjs/rust-plugin/scripts/unfocus-jest-setup.cjs new file mode 100644 index 000000000..56a6b4d49 --- /dev/null +++ b/packages/@stylexjs/rust-plugin/scripts/unfocus-jest-setup.cjs @@ -0,0 +1,14 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +test.only = test; +it.only = it; +describe.only = describe; +global.fit = test; +global.fdescribe = describe; diff --git a/packages/@stylexjs/rust-plugin/src/core/animations.rs b/packages/@stylexjs/rust-plugin/src/core/animations.rs new file mode 100644 index 000000000..8eaaeb2f7 --- /dev/null +++ b/packages/@stylexjs/rust-plugin/src/core/animations.rs @@ -0,0 +1,208 @@ +//! Keyframes, anchor fallbacks, and view-transition CSS use the same preprocessor. +use super::{CompiledValueResult, CreateOptions, RuleEntry, RuleFields, StyleValue}; +use indexmap::IndexMap; + +fn preprocess( + style: &IndexMap, + options: &CreateOptions, + normalize_before_dash: bool, + warnings: &mut Vec, +) -> Result, String> { + let mut expanded = IndexMap::new(); + let mut checked = options.clone(); + if options.property_validation_mode == "warn" { + checked.property_validation_mode = "throw".to_owned(); + } + for (key, value) in super::sort_indexmap_entries(style) { + let pairs = if options.style_resolution == "legacy-expand-shorthands" { + super::legacy::expand(key, value, &checked) + } else { + super::properties::expand_property(key, value, &checked) + }; + let pairs = match pairs { + Err(error) if options.property_validation_mode == "warn" => { + warnings.push(format!("[stylex] {error}")); + continue; + } + result => result?, + }; + for (key, value) in pairs { + if matches!(value, StyleValue::Null) { + continue; + } + let dashed = super::dashify(&key); + let transformed = super::transform_value( + if normalize_before_dash { &key } else { &dashed }, + &value, + options, + )?; + expanded.insert(dashed, transformed); + } + } + Ok(expanded) +} +fn directional( + style: &IndexMap, + options: &CreateOptions, + rtl: bool, + fallback: bool, +) -> IndexMap { + let mut result = IndexMap::new(); + for (key, value) in style { + if let Some(pairs) = super::directional_pair(key, value, options, rtl) { + for (key, value) in pairs { + result.insert(key, value); + } + } else if fallback { + result.insert(key.clone(), value.clone()); + } + } + result +} +fn declarations(style: &IndexMap) -> String { + style + .iter() + .map(|(key, value)| format!("{key}:{value};")) + .collect() +} + +pub fn compile_keyframes_with_diagnostics( + frames: &IndexMap, + options: Option<&CreateOptions>, +) -> Result<(String, RuleEntry, Vec), String> { + let options = options.cloned().unwrap_or_default(); + let mut warnings = Vec::new(); + let mut ltr = String::new(); + let mut rtl = String::new(); + let mut stable = String::new(); + for (frame, value) in super::sort_indexmap_entries(frames) { + let StyleValue::Object(style) = value else { + return Err("Every frame within a keyframes() call must be an object.".to_owned()); + }; + let style = preprocess(style, &options, true, &mut warnings)?; + ltr.push_str(&format!( + "{frame}{{{}}}", + declarations(&directional(&style, &options, false, false)) + )); + rtl.push_str(&format!( + "{frame}{{{}}}", + declarations(&directional(&style, &options, true, true)) + )); + stable.push_str(&format!( + "{frame}{{{}}}", + declarations(&directional( + &style, + &CreateOptions::default(), + false, + false + )) + )); + } + let name = format!( + "{}{}-B", + options.class_name_prefix, + super::hash_public(&format!("<>{stable}")) + ); + let rule = RuleEntry( + name.clone(), + RuleFields { + ltr: format!("@keyframes {name}{{{ltr}}}"), + rtl: if rtl == ltr { + None + } else { + Some(format!("@keyframes {name}{{{rtl}}}")) + }, + const_key: None, + const_val: None, + }, + 0.0, + ); + Ok((name, rule, warnings)) +} + +pub fn compile_position_try_with_diagnostics( + style: &IndexMap, + options: Option<&CreateOptions>, +) -> Result<(String, RuleEntry, Vec), String> { + super::validate_position_try_properties(style)?; + let options = options.cloned().unwrap_or_default(); + let mut warnings = Vec::new(); + let style = preprocess(style, &options, false, &mut warnings)?; + let stringify = |style: IndexMap| { + let mut keys: Vec<_> = style.keys().collect(); + keys.sort_by(|a, b| a.encode_utf16().cmp(b.encode_utf16())); + keys.into_iter() + .map(|key| format!("{key}:{};", style[key])) + .collect::() + }; + let ltr = stringify(directional(&style, &options, false, false)); + let rtl = stringify(directional(&style, &options, true, true)); + let name = format!( + "--{}{}", + options.class_name_prefix, + super::hash_public(<r) + ); + let rule = RuleEntry( + name.clone(), + RuleFields { + ltr: format!("@position-try {name} {{{ltr}}}"), + rtl: if rtl == ltr { + None + } else { + Some(format!("@position-try {name} {{{rtl}}}")) + }, + const_key: None, + const_val: None, + }, + 0.0, + ); + Ok((name, rule, warnings)) +} + +pub fn compile_view_transition_class_with_diagnostics( + styles: &IndexMap, + options: Option<&CreateOptions>, +) -> Result<(CompiledValueResult, Vec), String> { + let options = options.cloned().unwrap_or_default(); + let mut strings = IndexMap::new(); + let mut warnings = Vec::new(); + for (section, value) in super::sort_indexmap_entries(styles) { + let StyleValue::Object(style) = value else { + return Err("viewTransitionClass() sections must be objects.".to_owned()); + }; + let style = preprocess(style, &options, true, &mut warnings)?; + strings.insert( + format!("::view-transition-{}", super::dashify(section)), + declarations(&style), + ); + } + let hash_input = strings + .iter() + .map(|(selector, body)| format!("{selector}:{body};")) + .collect::(); + let name = format!( + "{}{}", + options.class_name_prefix, + super::hash_public(&hash_input) + ); + let ltr = strings + .iter() + .map(|(selector, body)| format!("{selector}(*.{name}){{{body}}}")) + .collect(); + Ok(( + CompiledValueResult { + value: name.clone(), + rules: vec![RuleEntry( + name, + RuleFields { + ltr, + rtl: None, + const_key: None, + const_val: None, + }, + 1.0, + )], + }, + warnings, + )) +} diff --git a/packages/@stylexjs/rust-plugin/src/core/compile.rs b/packages/@stylexjs/rust-plugin/src/core/compile.rs new file mode 100644 index 000000000..096bdd704 --- /dev/null +++ b/packages/@stylexjs/rust-plugin/src/core/compile.rs @@ -0,0 +1,230 @@ +//! Current ordered pre-rule compilation, including explicit cancellation slots. +use super::{ + CompiledCreateResult, CompiledNamespace, CompiledProperty, CreateOptions, FlatRule, StyleValue, +}; +use indexmap::IndexMap; +use std::collections::HashSet; + +type Group = (String, Vec); + +pub fn compile_stylex_create( + namespaces: &IndexMap, + options: Option<&CreateOptions>, +) -> Result { + let options = options.cloned().unwrap_or_default(); + let mut result = CompiledCreateResult { + namespaces: Vec::new(), + rules: Vec::new(), + warnings: Vec::new(), + }; + let mut injected = HashSet::new(); + for (name, value) in super::sort_indexmap_entries(namespaces) { + let StyleValue::Object(style) = value else { + return Err("A StyleX namespace must be an object.".to_owned()); + }; + super::validate::namespace(style, &[])?; + let style = if options.enable_media_query_order { + super::media::transform_style(style, 0)? + } else { + style.clone() + }; + let mut last = IndexMap::new(); + // Babel reduceRight keeps only the last complete group for each key. + // Do not merge a preceding conditional set into a later scalar/null. + for (key, rules) in flatten(&style, &[], &options, &mut result.warnings)? { + last.shift_remove(&key); + last.insert(key, rules); + } + let mut properties = Vec::new(); + let mut class_paths = IndexMap::new(); + for (key, rules) in last { + let mut classes = Vec::new(); + let mut seen = HashSet::new(); + for rule in rules { + if matches!(rule.value, StyleValue::Null) { + continue; + } + let (class, entry) = + super::compile_rule(&rule.property, &rule.value, &rule.key_path, &options)?; + class_paths.insert(class.clone(), rule.key_path.clone()); + if seen.insert(class.clone()) { + classes.push(class.clone()); + } + // styleXCreateSet registers a classname only on its first use. + if injected.insert(class) { + result.rules.push(entry); + } + } + let display = if options.enable_minified_keys && !key.starts_with("--") { + let hash = super::create_short_hash_public(&format!("<>{key}")); + if options.debug { + format!("{key}-k{hash}") + } else { + format!("k{hash}") + } + } else { + key.clone() + }; + properties.push(CompiledProperty { + source_name: key, + name: display, + value: if classes.is_empty() { + None + } else { + Some(classes.join(" ")) + }, + }); + } + result.namespaces.push(CompiledNamespace { + name: name.clone(), + properties, + css_marker: true, + class_paths, + }); + } + Ok(result) +} + +fn expand( + key: &str, + value: &StyleValue, + options: &CreateOptions, + warnings: &mut Vec, +) -> Result, String> { + let mut checked = options.clone(); + if options.property_validation_mode == "warn" { + checked.property_validation_mode = "throw".to_owned(); + } + let result = if options.style_resolution == "legacy-expand-shorthands" { + super::legacy::expand(key, value, &checked) + } else { + super::properties::expand_property(key, value, &checked) + }; + match result { + Err(error) if options.property_validation_mode == "warn" => { + warnings.push(format!("[stylex] {error}")); + Ok(Vec::new()) + } + result => result, + } +} + +fn make_rule(property: &str, value: StyleValue, path: &[String], original: &str) -> FlatRule { + let key_path = if path.iter().any(|key| key == original) { + path.iter() + .map(|key| { + if key == original { + property.to_owned() + } else { + key.clone() + } + }) + .collect() + } else { + path.iter() + .cloned() + .chain(std::iter::once(property.to_owned())) + .collect() + }; + FlatRule { + js_key: property.to_owned(), + property: property.to_owned(), + value, + key_path, + } +} + +fn flatten( + style: &IndexMap, + path: &[String], + options: &CreateOptions, + warnings: &mut Vec, +) -> Result, String> { + let mut out = Vec::new(); + for (raw, value) in super::sort_indexmap_entries(style) { + let key = if raw.starts_with("var(--") && raw.ends_with(')') { + &raw[4..raw.len() - 1] + } else { + raw.as_str() + }; + match value { + StyleValue::Null | StyleValue::String(_) | StyleValue::Number(_) => { + for (property, value) in expand(key, value, options, warnings)? { + out.push(( + property.clone(), + vec![make_rule(&property, value, path, key)], + )); + } + } + StyleValue::Array(values) => { + let mut parts: IndexMap> = IndexMap::new(); + for value in values { + if matches!(value, StyleValue::Object(_) | StyleValue::Array(_)) { + return Err( + "A style array value can only contain strings or numbers.".to_owned() + ); + } + for (property, value) in expand(key, value, options, warnings)? { + let values = parts.entry(property).or_default(); + // Upstream filters Boolean before deduplicating fallback values. + let truthy = match &value { + StyleValue::Null => false, + StyleValue::Number(v) => *v != 0.0 && !v.is_nan(), + StyleValue::String(v) => !v.is_empty(), + _ => true, + }; + if truthy && !values.contains(&value) { + values.push(value); + } + } + } + for (property, values) in parts { + let value = match values.len() { + 0 => StyleValue::Null, + 1 => values[0].clone(), + _ => StyleValue::Array(values), + }; + out.push(( + property.clone(), + vec![make_rule(&property, value, path, raw)], + )); + } + } + StyleValue::Object(inner) + if !key.starts_with(':') && !key.starts_with('@') && !key.starts_with('[') => + { + let mut equivalent: IndexMap> = IndexMap::new(); + for (condition, value) in super::sort_indexmap_entries(inner) { + let next = if path.is_empty() { + vec![key.to_owned(), condition.clone()] + } else { + path.iter() + .cloned() + .chain(std::iter::once(condition.clone())) + .collect() + }; + for (property, rules) in flatten( + &IndexMap::from([(key.to_owned(), value.clone())]), + &next, + options, + warnings, + )? { + equivalent.entry(property).or_default().extend(rules); + } + } + out.extend(equivalent); + } + StyleValue::Object(inner) => { + let next: Vec<_> = path + .iter() + .cloned() + .chain(std::iter::once(raw.clone())) + .collect(); + for (property, rules) in flatten(inner, &next, options, warnings)? { + out.push((format!("{key}_{property}"), rules)); + } + } + } + } + Ok(out) +} diff --git a/packages/@stylexjs/rust-plugin/src/core/legacy.rs b/packages/@stylexjs/rust-plugin/src/core/legacy.rs new file mode 100644 index 000000000..1bbdb6fc3 --- /dev/null +++ b/packages/@stylexjs/rust-plugin/src/core/legacy.rs @@ -0,0 +1,351 @@ +//! Legacy shorthand expansion retained as a separate, explicit resolution mode. +use super::{CreateOptions, StyleValue}; + +fn parts(value: &StyleValue) -> Vec { + let StyleValue::String(value) = value else { + return vec![value.clone()]; + }; + let mut tokens = super::split_shorthand_tokens(value.trim()); + if tokens.len() > 1 + && tokens + .last() + .is_some_and(|value| value.eq_ignore_ascii_case("!important")) + { + tokens.pop(); + tokens + .iter_mut() + .for_each(|value| value.push_str(" !important")); + } + tokens.into_iter().map(StyleValue::String).collect() +} +fn pair(value: &StyleValue, names: [&str; 2]) -> Vec<(String, StyleValue)> { + let values = parts(value); + let a = values.first().cloned().unwrap_or(StyleValue::Null); + let b = values.get(1).cloned().unwrap_or_else(|| a.clone()); + vec![(names[0].to_owned(), a), (names[1].to_owned(), b)] +} +fn quad(value: &StyleValue, names: [&str; 4]) -> Vec<(String, StyleValue)> { + let values = parts(value); + let a = values.first().cloned().unwrap_or(StyleValue::Null); + let b = values.get(1).cloned().unwrap_or_else(|| a.clone()); + let c = values.get(2).cloned().unwrap_or_else(|| a.clone()); + let d = values.get(3).cloned().unwrap_or_else(|| b.clone()); + names + .into_iter() + .zip([a, b, c, d]) + .map(|(key, value)| (key.to_owned(), value)) + .collect() +} +fn cancel(value: &StyleValue, key: &str, slots: &[&str]) -> Vec<(String, StyleValue)> { + std::iter::once((key.to_owned(), value.clone())) + .chain( + slots + .iter() + .map(|key| ((*key).to_owned(), StyleValue::Null)), + ) + .collect() +} + +pub(crate) fn expand( + key: &str, + value: &StyleValue, + options: &CreateOptions, +) -> Result, String> { + let out = match key { + "border" => [ + "borderTop", + "borderInlineEnd", + "borderBottom", + "borderInlineStart", + ] + .iter() + .map(|key| ((*key).to_owned(), value.clone())) + .collect(), + "borderColor" => quad( + value, + [ + "borderTopColor", + "borderInlineEndColor", + "borderBottomColor", + "borderInlineStartColor", + ], + ), + "borderStyle" => quad( + value, + [ + "borderTopStyle", + "borderInlineEndStyle", + "borderBottomStyle", + "borderInlineStartStyle", + ], + ), + "borderWidth" => quad( + value, + [ + "borderTopWidth", + "borderInlineEndWidth", + "borderBottomWidth", + "borderInlineStartWidth", + ], + ), + "borderRadius" => quad( + value, + [ + "borderStartStartRadius", + "borderStartEndRadius", + "borderEndEndRadius", + "borderEndStartRadius", + ], + ), + "borderHorizontal" => ["borderInlineStart", "borderInlineEnd"] + .iter() + .map(|key| ((*key).to_owned(), value.clone())) + .collect(), + "borderVertical" => ["borderTop", "borderBottom"] + .iter() + .map(|key| ((*key).to_owned(), value.clone())) + .collect(), + "borderHorizontalColor" | "borderInlineColor" => vec![ + ("borderInlineStartColor".to_owned(), value.clone()), + ("borderInlineEndColor".to_owned(), value.clone()), + ], + "borderHorizontalStyle" | "borderInlineStyle" => vec![ + ("borderInlineStartStyle".to_owned(), value.clone()), + ("borderInlineEndStyle".to_owned(), value.clone()), + ], + "borderHorizontalWidth" | "borderInlineWidth" => vec![ + ("borderInlineStartWidth".to_owned(), value.clone()), + ("borderInlineEndWidth".to_owned(), value.clone()), + ], + "borderVerticalColor" | "borderBlockColor" => vec![ + ("borderTopColor".to_owned(), value.clone()), + ("borderBottomColor".to_owned(), value.clone()), + ], + "borderVerticalStyle" | "borderBlockStyle" => vec![ + ("borderTopStyle".to_owned(), value.clone()), + ("borderBottomStyle".to_owned(), value.clone()), + ], + "borderVerticalWidth" | "borderBlockWidth" => vec![ + ("borderTopWidth".to_owned(), value.clone()), + ("borderBottomWidth".to_owned(), value.clone()), + ], + "margin" => quad( + value, + [ + "marginTop", + "marginInlineEnd", + "marginBottom", + "marginInlineStart", + ], + ), + "padding" => quad( + value, + [ + "paddingTop", + "paddingInlineEnd", + "paddingBottom", + "paddingInlineStart", + ], + ), + "inset" => quad( + value, + ["top", "insetInlineEnd", "bottom", "insetInlineStart"], + ), + "insetBlock" => pair(value, ["top", "bottom"]), + "marginBlock" | "marginVertical" => pair(value, ["marginTop", "marginBottom"]), + "paddingBlock" | "paddingVertical" => pair(value, ["paddingTop", "paddingBottom"]), + "marginInline" | "marginHorizontal" | "paddingInline" | "paddingHorizontal" + | "insetInline" => { + let (a, b, slots) = if key.starts_with("margin") { + ( + "marginInlineStart", + "marginInlineEnd", + ["marginLeft", "marginRight"], + ) + } else if key.starts_with("padding") { + ( + "paddingInlineStart", + "paddingInlineEnd", + ["paddingLeft", "paddingRight"], + ) + } else { + ("insetInlineStart", "insetInlineEnd", ["left", "right"]) + }; + pair(value, [a, b]) + .into_iter() + .flat_map(|(key, value)| cancel(&value, &key, &slots)) + .collect() + } + "marginStart" | "marginEnd" => cancel( + value, + if key == "marginStart" { + "marginInlineStart" + } else { + "marginInlineEnd" + }, + &["marginLeft", "marginRight"], + ), + "paddingStart" | "paddingEnd" => cancel( + value, + if key == "paddingStart" { + "paddingInlineStart" + } else { + "paddingInlineEnd" + }, + &["paddingLeft", "paddingRight"], + ), + "start" | "end" | "insetInlineStart" | "insetInlineEnd" => cancel( + value, + if key == "start" || key == "insetInlineStart" { + "insetInlineStart" + } else { + "insetInlineEnd" + }, + &["left", "right"], + ), + "marginLeft" | "marginRight" => { + cancel(value, key, &["marginInlineStart", "marginInlineEnd"]) + } + "paddingLeft" | "paddingRight" => { + cancel(value, key, &["paddingInlineStart", "paddingInlineEnd"]) + } + "left" | "right" => cancel(value, key, &["insetInlineStart", "insetInlineEnd"]), + "gap" | "gridGap" => pair(value, ["rowGap", "columnGap"]), + "overflow" => pair(value, ["overflowX", "overflowY"]), + "containIntrinsicSize" => { + let mut merged: Vec = Vec::new(); + for part in parts(value) { + if matches!(merged.last(),Some(StyleValue::String(v)) if v=="auto") + && !matches!(part, StyleValue::Null) + { + merged.pop(); + let text = match part { + StyleValue::String(v) => v, + StyleValue::Number(v) => super::values::js_number(v), + _ => String::new(), + }; + merged.push(StyleValue::String(format!("auto {text}"))); + } else { + merged.push(part); + } + } + let a = merged.first().cloned().unwrap_or(StyleValue::Null); + vec![ + ("containIntrinsicWidth".to_owned(), a.clone()), + ( + "containIntrinsicHeight".to_owned(), + merged.get(1).cloned().unwrap_or(a), + ), + ] + } + "listStyle" => match list_style(value) { + Ok(value) => value, + Err(error) => { + return if options.property_validation_mode == "throw" { + Err(error) + } else { + Ok(Vec::new()) + } + } + }, + "float" | "clear" => { + let value = match value { + StyleValue::String(v) if v == "inline-start" || v == "start" => { + StyleValue::String(format!("var({})", super::LOGICAL_FLOAT_START_VAR)) + } + StyleValue::String(v) if v == "inline-end" || v == "end" => { + StyleValue::String(format!("var({})", super::LOGICAL_FLOAT_END_VAR)) + } + value => value.clone(), + }; + vec![(key.to_owned(), value)] + } + // Unlike application-order, aliases only acquire the expansions named + // above. Logical longhands such as marginInlineStart are not aliases. + "borderBlockStart" + | "borderBlockEnd" + | "containIntrinsicBlockSize" + | "containIntrinsicInlineSize" + | "borderStartColor" + | "borderEndColor" + | "borderStartStyle" + | "borderEndStyle" + | "borderStartWidth" + | "borderEndWidth" => vec![(key.to_owned(), value.clone())], + _ => vec![(super::canonical_property_name(key), value.clone())], + }; + Ok(out) +} +fn list_style(value: &StyleValue) -> Result, String> { + let names = ["listStyleType", "listStylePosition", "listStyleImage"]; + if matches!(value, StyleValue::Null) { + return Ok(names + .into_iter() + .map(|key| (key.to_owned(), StyleValue::Null)) + .collect()); + } + let values = parts(value); + if values.len() == 1 + && matches!(&values[0],StyleValue::String(v) if matches!(v.as_str(),"inherit"|"initial"|"revert"|"unset")) + { + return Ok(names + .into_iter() + .map(|key| (key.to_owned(), values[0].clone())) + .collect()); + } + let mut ty = None; + let mut pos = None; + let mut image = None; + let mut rest = Vec::new(); + let error = || { + format!( + "invalid \"listStyle\" value of {}", + match value { + StyleValue::String(v) => serde_json::to_string(v).unwrap(), + _ => "null".to_owned(), + } + ) + }; + for value in values { + let StyleValue::String(value) = value else { + continue; + }; + if matches!(value.as_str(), "inherit" | "initial" | "revert" | "unset") + || value.contains("var(--") + { + return Err(error()); + } + if matches!(value.as_str(), "inside" | "outside") { + if pos.replace(value).is_some() { + return Err(error()); + } + } else if value != "none" + && (value.chars().all(|c| c.is_ascii_lowercase() || c == '-') + || value.starts_with('"') + || value.starts_with('\'')) + { + if ty.replace(value).is_some() { + return Err(error()); + } + } else { + rest.push(value); + } + } + for value in rest { + if value == "none" && ty.is_none() { + ty = Some(value); + } else if image.replace(value).is_some() { + return Err(error()); + } + } + Ok(names + .into_iter() + .zip([ty, pos, image]) + .map(|(key, value)| { + ( + key.to_owned(), + value.map(StyleValue::String).unwrap_or(StyleValue::Null), + ) + }) + .collect()) +} diff --git a/packages/@stylexjs/rust-plugin/src/core/media.rs b/packages/@stylexjs/rust-plugin/src/core/media.rs new file mode 100644 index 000000000..fa562a389 --- /dev/null +++ b/packages/@stylexjs/rust-plugin/src/core/media.rs @@ -0,0 +1,709 @@ +//! Media-query syntax tree used by responsive cancellation and final CSS sorting. +//! Numeric interval and media-type behavior follows style-value-parser/at-queries. +use super::{ + values::{dimension, js_number}, + StyleValue, +}; +use indexmap::IndexMap; +use regex::Regex; +use std::sync::OnceLock; + +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum Query { + Type { key: String, not: bool, only: bool }, + Pair { key: String, value: String }, + Feature(String), + Not(Box), + And(Vec), + Or(Vec), +} +fn always() -> Query { + Query::Type { + key: "all".to_owned(), + not: false, + only: false, + } +} +fn never() -> Query { + Query::Type { + key: "all".to_owned(), + not: true, + only: false, + } +} +fn is_never(q: &Query) -> bool { + matches!(q,Query::Type{key,not:true,..} if key=="all") +} +fn is_always(q: &Query) -> bool { + matches!(q,Query::Type{key,not:false,only:false} if key=="all") +} +fn contains_type(q: &Query) -> bool { + match q { + Query::Type { .. } => true, + Query::Not(q) => contains_type(q), + Query::And(q) | Query::Or(q) => q.iter().any(contains_type), + _ => false, + } +} + +fn split_top<'a>(input: &'a str, separator: &str) -> Vec<&'a str> { + let mut out = Vec::new(); + let mut start = 0; + let mut depth = 0; + let mut quote = None; + let mut escaped = false; + for (i, c) in input.char_indices() { + if escaped { + escaped = false; + continue; + } + if c == '\\' { + escaped = true; + continue; + } + if let Some(q) = quote { + if c == q { + quote = None; + } + continue; + } + if c == '\'' || c == '"' { + quote = Some(c); + continue; + } + if c == '(' { + depth += 1; + } else if c == ')' { + depth -= 1; + } + if depth == 0 && input[i..].starts_with(separator) { + out.push(input[start..i].trim()); + start = i + separator.len(); + } + } + out.push(input[start..].trim()); + out +} +fn outer(input: &str) -> bool { + if !input.starts_with('(') || !input.ends_with(')') { + return false; + } + let mut depth = 0; + for (i, c) in input.char_indices() { + if c == '(' { + depth += 1; + } else if c == ')' { + depth -= 1; + if depth == 0 && i + 1 < input.len() { + return false; + } + } + } + depth == 0 +} + +pub(crate) fn parse(input: &str) -> Result { + let input = input + .strip_prefix("@media ") + .ok_or_else(|| "Invalid media query syntax.".to_owned())?; + let mut depth = 0; + for c in input.chars() { + if c == '(' { + depth += 1; + } else if c == ')' { + depth -= 1; + if depth < 0 { + return Err("Invalid media query syntax.".to_owned()); + } + } + } + if depth != 0 { + return Err("Invalid media query syntax.".to_owned()); + } + parse_expr(input).map(|q| resolve_types(normalize(q))) +} +fn parse_expr(input: &str) -> Result { + let input = input.trim(); + let fail = || "Invalid media query syntax.".to_owned(); + for sep in [",", " or ", " and "] { + let parts = split_top(input, sep); + if parts.len() > 1 { + let queries = parts + .into_iter() + .map(parse_expr) + .collect::, _>>()?; + return Ok(if sep == " and " { + Query::And(queries) + } else { + Query::Or(queries) + }); + } + } + if let Some(rest) = input.strip_prefix("not ") { + if matches!(rest, "screen" | "print" | "all") { + return Ok(Query::Type { + key: rest.to_owned(), + not: true, + only: false, + }); + } + return Ok(Query::Not(Box::new(parse_expr(rest)?))); + } + if let Some(rest) = input.strip_prefix("only ") { + if matches!(rest, "screen" | "print" | "all") { + return Ok(Query::Type { + key: rest.to_owned(), + not: false, + only: true, + }); + } + return Err(fail()); + } + if matches!(input, "screen" | "print" | "all") { + return Ok(Query::Type { + key: input.to_owned(), + not: false, + only: false, + }); + } + if outer(input) { + let inside = input[1..input.len() - 1].trim(); + if inside.contains(" and ") + || inside.contains(" or ") + || inside.starts_with("not ") + || matches!(inside, "screen" | "print" | "all") + { + return parse_expr(inside); + } + let pair = split_top(inside, ":"); + if pair.len() == 2 && !pair[0].is_empty() && !pair[1].is_empty() { + return Ok(Query::Pair { + key: pair[0].to_owned(), + value: super::media_value::normalize(pair[1])?, + }); + } + static RANGE: OnceLock = OnceLock::new(); + let re = RANGE.get_or_init(|| { + Regex::new(r"^(?:(.+?)\s*(<=|>=|<|>)\s*)?(width|height)\s*(<=|>=|<|>)\s*(.+?)$") + .unwrap() + }); + if let Some(c) = re.captures(inside) { + let key = &c[3]; + let mut out = Vec::new(); + if let (Some(value), Some(op)) = (c.get(1), c.get(2)) { + out.push(range_pair(key, value.as_str(), op.as_str(), true)?); + } + out.push(range_pair(key, &c[5], &c[4], false)?); + return Ok(if out.len() == 1 { + out.remove(0) + } else { + Query::And(out) + }); + } + static REVERSED: OnceLock = OnceLock::new(); + if let Some(c) = REVERSED + .get_or_init(|| Regex::new(r"^(.+?)\s*(<=|>=|<|>)\s*(width|height)$").unwrap()) + .captures(inside) + { + return range_pair(&c[3], &c[1], &c[2], true); + } + if matches!(inside, "color" | "monochrome" | "grid" | "color-index") { + return Ok(Query::Feature(inside.to_owned())); + } + } + Err(fail()) +} +fn range_pair(key: &str, value: &str, op: &str, reversed: bool) -> Result { + let (number, unit) = + dimension(value.trim()).ok_or_else(|| "Invalid media query syntax.".to_owned())?; + if unit.is_empty() { + return Err("Invalid media query syntax.".to_owned()); + } + let mut value = number + .parse::() + .map_err(|_| "Invalid media query syntax.".to_owned())?; + let min = (op.starts_with('>')) != reversed; + if !op.ends_with('=') { + value += if min { 0.01 } else { -0.01 }; + } + Ok(Query::Pair { + key: format!("{}-{key}", if min { "min" } else { "max" }), + value: format!("{}{unit}", js_number(value)), + }) +} + +fn normalize(q: Query) -> Query { + match q { + Query::And(queries) => { + let mut flat = Vec::new(); + for q in queries { + match normalize(q) { + Query::And(q) => flat.extend(q), + q => flat.push(q), + } + } + let merged = merge_intervals(flat); + if merged.is_empty() { + never() + } else { + Query::And(merged) + } + } + Query::Or(q) => Query::Or(q.into_iter().map(normalize).collect()), + Query::Not(q) => match normalize(*q) { + Query::Not(q) => normalize(*q), + q if is_never(&q) => always(), + q => Query::Not(Box::new(q)), + }, + q => q, + } +} +/// Mirrors style-value-parser `mergeIntervalsForAnd`: returns the rule list an +/// `and` should hold (empty when the ranges cannot intersect). +fn merge_intervals(q: Vec) -> Vec { + for (i, rule) in q.iter().enumerate() { + if let Query::Not(inner) = rule { + if let Query::And(parts) = &**inner { + if parts.len() == 2 { + let branches = parts + .iter() + .map(|part| { + let mut branch: Vec<_> = q + .iter() + .enumerate() + .filter(|(j, _)| *j != i) + .map(|(_, q)| q.clone()) + .collect(); + branch.push(Query::Not(Box::new(part.clone()))); + merge_intervals(branch) + }) + .filter(|branch| !branch.is_empty()) + .map(|mut branch| { + if branch.len() == 1 { + branch.remove(0) + } else { + Query::And(branch) + } + }) + .collect(); + return vec![Query::Or(branches)]; + } + } + } + } + let mut ranges: IndexMap = IndexMap::new(); + for rule in &q { + let (key, value, negative) = match rule { + Query::Pair { key, value } => (key, value, false), + Query::Not(inner) => match &**inner { + Query::Pair { key, value } => (key, value, true), + _ => return q.clone(), + }, + _ => return q.clone(), + }; + let Some((number, unit)) = dimension(value) else { + return q.clone(); + }; + let Ok(value) = number.parse::() else { + return q.clone(); + }; + if unit.is_empty() { + return q.clone(); + } + let (dim, min) = match key.as_str() { + "min-width" => ("width", true), + "max-width" => ("width", false), + "min-height" => ("height", true), + "max-height" => ("height", false), + _ => return q.clone(), + }; + let range = ranges.entry(dim.to_owned()).or_insert(( + unit.to_owned(), + f64::NEG_INFINITY, + f64::INFINITY, + )); + if range.0 != unit { + return q.clone(); + } + if negative { + if min { + range.2 = range.2.min( + value + - if dim == "width" && unit.eq_ignore_ascii_case("px") { + 0.02 + } else { + 0.01 + }, + ); + } else { + range.1 = range.1.max(value + 0.01); + } + } else if min { + range.1 = range.1.max(value); + } else { + range.2 = range.2.min(value); + } + } + let mut out = Vec::new(); + for (dim, (unit, min, max)) in ranges { + if min > max { + return Vec::new(); + } + if min.is_finite() { + out.push(Query::Pair { + key: format!("min-{dim}"), + value: format!("{}{unit}", js_number(min)), + }); + } + if max.is_finite() { + out.push(Query::Pair { + key: format!("max-{dim}"), + value: format!("{}{unit}", js_number(max)), + }); + } + } + if out.is_empty() { + q + } else { + out + } +} +fn negate(q: Query) -> Query { + match q { + Query::Type { key, not, only } => Query::Type { + key, + not: !not, + only, + }, + Query::Not(q) => *q, + Query::And(q) => Query::Or(q.into_iter().map(negate).collect()), + Query::Or(q) => Query::And(q.into_iter().map(negate).collect()), + q => Query::Not(Box::new(q)), + } +} +fn requirement(q: &Query) -> Option<&str> { + match q { + Query::Type { + key, not: false, .. + } => Some(key), + Query::Type { key, not: true, .. } => Some(if key == "screen" { + "print" + } else if key == "print" { + "screen" + } else { + "never" + }), + Query::Not(q) => match &**q { + Query::Type { + key, not: false, .. + } => Some(if key == "screen" { + "print" + } else if key == "print" { + "screen" + } else { + "never" + }), + _ => None, + }, + _ => None, + } +} +fn resolve_types(q: Query) -> Query { + if !contains_type(&q) { + return q; + } + match q { + Query::Or(queries) => { + let mut flat = Vec::new(); + for q in queries { + match resolve_types(q) { + Query::Or(q) => flat.extend(q), + q => flat.push(q), + } + } + flat.retain(|q| !is_never(q)); + if flat.iter().any(is_always) { + always() + } else if flat.is_empty() { + never() + } else if flat.len() == 1 { + flat.remove(0) + } else { + Query::Or(flat) + } + } + Query::And(queries) => { + let resolved: Vec<_> = queries.into_iter().map(resolve_types).collect(); + if let Some(i) = resolved.iter().position(|q| matches!(q, Query::Or(_))) { + let Query::Or(branches) = &resolved[i] else { + unreachable!() + }; + return resolve_types(Query::Or( + branches + .iter() + .map(|q| { + Query::And( + std::iter::once(q.clone()) + .chain( + resolved + .iter() + .enumerate() + .filter(|(j, _)| *j != i) + .map(|(_, q)| q.clone()), + ) + .collect(), + ) + }) + .collect(), + )); + } + let mut ty = "all".to_owned(); + let mut anchor = None; + let mut rest = Vec::new(); + for q in resolved { + if let Query::Type { + key, not: false, .. + } = &q + { + if key != "all" { + if ty == "all" { + ty = key.clone(); + anchor = Some(q.clone()); + } else if ty != *key { + return never(); + } + } + continue; + } + if let Some(req) = requirement(&q) { + if req == "never" { + return never(); + } + if req != "all" { + if ty == "all" { + ty = req.to_owned(); + } else if ty != req { + return never(); + } + } + } else { + rest.push(q); + } + } + if ty != "all" { + rest.insert( + 0, + anchor.unwrap_or(Query::Type { + key: ty, + not: false, + only: false, + }), + ); + } + match rest.len() { + 0 => always(), + 1 => rest.remove(0), + _ => Query::And(rest), + } + } + Query::Not(inner) => { + let inner = resolve_types(*inner); + match inner { + Query::Type { key, not, only } => Query::Type { + key, + not: !not, + only, + }, + Query::And(_) | Query::Or(_) => resolve_types(negate(inner)), + q => Query::Not(Box::new(q)), + } + } + q => q, + } +} + +pub(crate) fn serialize(q: &Query, top: bool) -> String { + match q { + Query::Type { key, not, only } => format!( + "{}{key}", + if *not { + "not " + } else if *only { + "only " + } else { + "" + } + ), + Query::Pair { key, value } => format!("({key}: {value})"), + Query::Feature(key) => format!("({key})"), + Query::Not(q) => { + if matches!(&**q, Query::And(_) | Query::Or(_) | Query::Not(_)) { + format!("(not ({}))", serialize(q, false)) + } else { + format!("(not {})", serialize(q, false)) + } + } + Query::And(q) => q + .iter() + .map(|q| serialize(q, false)) + .collect::>() + .join(" and "), + Query::Or(q) => { + let rules: Vec<_> = q + .iter() + .filter(|q| !matches!(q, Query::Or(rules) if rules.is_empty())) + .collect(); + match rules.as_slice() { + [] => "not all".to_owned(), + [rule] => serialize(rule, top), + rules => rules + .iter() + .map(|q| { + let text = serialize(q, false); + if !top && matches!(q, Query::And(_) | Query::Or(_)) { + format!("({text})") + } else { + text + } + }) + .collect::>() + .join(if top { ", " } else { " or " }), + } + } + } +} + +pub(crate) fn transform_style( + style: &IndexMap, + depth: usize, +) -> Result, String> { + let mut result = IndexMap::new(); + for (key, value) in style { + result.insert( + key.clone(), + match value { + StyleValue::Object(value) => StyleValue::Object(transform_style(value, depth + 1)?), + value => value.clone(), + }, + ); + } + if depth >= 1 { + let keys: Vec<_> = result + .keys() + .filter(|key| key.starts_with("@media ")) + .cloned() + .collect(); + let parsed = keys + .iter() + .map(|key| parse(key)) + .collect::, _>>()?; + for (i, key) in keys.iter().enumerate() { + let current = parsed[i].clone(); + let negatives: Vec<_> = parsed[i + 1..] + .iter() + .cloned() + .map(|q| Query::Not(Box::new(q))) + .collect(); + let combined = if negatives.is_empty() { + current + } else { + match current { + Query::Or(branches) => Query::Or( + branches + .into_iter() + .map(|q| { + Query::And(std::iter::once(q).chain(negatives.clone()).collect()) + }) + .collect(), + ), + q => Query::And(std::iter::once(q).chain(negatives).collect()), + } + }; + let new = resolve_types(normalize(combined)); + let value = result.shift_remove(key).unwrap(); + result.insert(format!("@media {}", serialize(&new, true)), value); + } + } + Ok(result) +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct WidthKey { + pub context: String, + pub min: bool, + pub value: f64, +} +fn bounds(q: &Query) -> Option> { + match q { + Query::Pair { key, value } => { + let key = key.to_ascii_lowercase(); + let min = match key.as_str() { + "min-width" => true, + "max-width" => false, + _ => return Some(Vec::new()), + }; + let (number, unit) = dimension(value)?; + let value = number.parse::().ok()?; + if value == 0.0 || unit.eq_ignore_ascii_case("px") { + Some(vec![(min, value)]) + } else { + None + } + } + Query::And(q) => { + let mut out = Vec::new(); + for q in q { + out.extend(bounds(q)?); + } + Some(out) + } + Query::Type { not: false, .. } => Some(Vec::new()), + _ => None, + } +} +pub(crate) fn width_chain_key(rule: &str) -> String { + let mut remaining = rule; + let mut preludes = Vec::new(); + while remaining.starts_with('@') { + let Some(brace) = remaining.find('{') else { + break; + }; + preludes.push(remaining[..brace].trim_end()); + remaining = &remaining[brace + 1..]; + } + preludes.join("{") +} + +pub(crate) fn width_key(rule: &str) -> Option { + let mut remaining = rule; + let mut preludes = Vec::new(); + while remaining.starts_with('@') { + let brace = remaining.find('{')?; + preludes.push(remaining[..brace].trim_end().to_owned()); + remaining = &remaining[brace + 1..]; + } + let media: Vec<_> = preludes + .iter() + .enumerate() + .filter(|(_, p)| p.starts_with("@media ")) + .map(|(i, _)| i) + .collect(); + if media.len() != 1 { + return None; + } + let i = media[0]; + // Babel's parser constructs a MediaQuery, so width inspection sees the + // parser's normalized/media-type-resolved AST, not the raw syntax tree. + let parsed = parse(&preludes[i]).ok()?; + let found = bounds(&parsed)?; + if found.len() != 1 { + return None; + } + let (min, value) = found[0]; + preludes[i] = "@media".to_owned(); + Some(WidthKey { + context: preludes.join("{"), + min, + value, + }) +} diff --git a/packages/@stylexjs/rust-plugin/src/core/media_value.rs b/packages/@stylexjs/rust-plugin/src/core/media_value.rs new file mode 100644 index 000000000..a4c9004dc --- /dev/null +++ b/packages/@stylexjs/rust-plugin/src/core/media_value.rs @@ -0,0 +1,142 @@ +//! Values accepted by MediaQuery's simple-pair and Calc parsers. +use super::values::{dimension, js_number}; + +fn error() -> String { + "Invalid media query syntax.".to_owned() +} +fn ident(s: &str) -> bool { + let mut chars = s.chars(); + let Some(first) = chars.next() else { + return false; + }; + (first == '-' || first == '_' || first.is_alphabetic() || first as u32 >= 128) + && chars.all(|c| c == '-' || c == '_' || c.is_alphanumeric() || c as u32 >= 128) +} +fn number(s: &str) -> Result { + let (num, unit) = dimension(s).ok_or_else(error)?; + if !unit.is_empty() { + return Err(error()); + } + num.parse::().map(js_number).map_err(|_| error()) +} +pub(crate) fn normalize(s: &str) -> Result { + let s = s.trim(); + if s.starts_with("calc(") && s.ends_with(')') { + let mut parser = Calc { + input: &s[5..s.len() - 1], + at: 0, + }; + let expression = parser.expression(false)?; + if parser.at != parser.input.len() { + return Err(error()); + } + return Ok(format!("calc({expression})")); + } + let fraction = s.split('/').collect::>(); + if fraction.len() == 2 { + return Ok(format!( + "{} / {}", + number(fraction[0].trim())?, + number(fraction[1].trim())? + )); + } + if let Some((num, unit)) = dimension(s) { + if !unit.is_empty() && !ident(unit) { + return Err(error()); + } + return Ok(format!( + "{}{}", + num.parse::().map(js_number).map_err(|_| error())?, + unit + )); + } + if ident(s) { + Ok(s.to_owned()) + } else { + Err(error()) + } +} +struct Calc<'a> { + input: &'a str, + at: usize, +} +impl Calc<'_> { + fn whitespace(&mut self) { + while self + .input + .as_bytes() + .get(self.at) + .is_some_and(u8::is_ascii_whitespace) + { + self.at += 1; + } + } + fn expression(&mut self, group: bool) -> Result { + let mut out = self.term()?; + loop { + self.whitespace(); + let Some(&c) = self.input.as_bytes().get(self.at) else { + return if group { Err(error()) } else { Ok(out) }; + }; + if c == b')' && group { + self.at += 1; + return Ok(out); + } + if !matches!(c, b'+' | b'-' | b'*' | b'/') { + return Err(error()); + } + // A sign adjacent to a number belongs to that number token, not a + // CSS delimiter. Media Calc rejects that unseparated second value. + if matches!(c, b'+' | b'-') + && self + .input + .as_bytes() + .get(self.at + 1) + .is_some_and(|c| c.is_ascii_digit() || *c == b'.') + { + return Err(error()); + } + self.at += 1; + let right = self.term()?; + out.push(' '); + out.push(c as char); + out.push(' '); + out.push_str(&right); + } + } + fn term(&mut self) -> Result { + self.whitespace(); + if self.input.as_bytes().get(self.at) == Some(&b'(') { + self.at += 1; + return Ok(format!("({})", self.expression(true)?)); + } + let tail = &self.input[self.at..]; + if let Some((num, _)) = dimension(tail) { + self.at += num.len(); + let begin = self.at; + if self.input.as_bytes().get(self.at) == Some(&b'%') { + self.at += 1; + } else { + while let Some(c) = self.input[self.at..].chars().next() { + if c.is_alphanumeric() || matches!(c, '_' | '-') || c as u32 >= 128 { + self.at += c.len_utf8(); + } else { + break; + } + } + } + return Ok(format!( + "{}{}", + num.parse::().map(js_number).map_err(|_| error())?, + &self.input[begin..self.at] + )); + } + for constant in ["-infinity", "infinity", "NaN", "pi", "e"] { + if tail.starts_with(constant) { + self.at += constant.len(); + return Ok(constant.to_owned()); + } + } + Err(error()) + } +} diff --git a/packages/@stylexjs/rust-plugin/src/core/mod.rs b/packages/@stylexjs/rust-plugin/src/core/mod.rs new file mode 100644 index 000000000..8b5b649a6 --- /dev/null +++ b/packages/@stylexjs/rust-plugin/src/core/mod.rs @@ -0,0 +1,1644 @@ +use indexmap::IndexMap; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::path::Path; + +mod animations; +mod compile; +mod legacy; +mod media; +mod media_value; +mod priority; +mod process; +pub(crate) mod properties; +mod validate; +pub(crate) mod values; + +pub use animations::{ + compile_keyframes_with_diagnostics, compile_position_try_with_diagnostics, + compile_view_transition_class_with_diagnostics, +}; +pub use compile::compile_stylex_create; +pub use priority::get_priority; +pub use process::{process_stylex_rules, process_stylex_rules_checked}; +pub use properties::canonical_property_name; +pub use values::{dynamic_number_suffix, transform_value}; + +/// Shared preprocessing for AST adapters as well as the pure compiler. +pub fn preprocess_property( + key: &str, + value: &StyleValue, + options: &CreateOptions, +) -> Result, String> { + if options.style_resolution == "legacy-expand-shorthands" { + legacy::expand(key, value, options) + } else { + properties::expand_property(key, value, options) + } +} + +use crate::shared::{ + flatten_nested_consts_config, flatten_nested_overrides_config, flatten_nested_string_config, + flatten_nested_vars_config, insert_unflattened_value, +}; + +pub const LOGICAL_FLOAT_START_VAR: &str = "--stylex-logical-start"; +pub const LOGICAL_FLOAT_END_VAR: &str = "--stylex-logical-end"; + +#[derive(Debug, Clone, PartialEq)] +#[allow(dead_code)] +pub enum StyleValue { + Null, + String(String), + Number(f64), + Array(Vec), + Object(IndexMap), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CreateOptions { + pub class_name_prefix: String, + pub debug: bool, + pub enable_debug_class_names: bool, + pub enable_minified_keys: bool, + pub enable_font_size_px_to_rem: bool, + pub enable_media_query_order: bool, + pub enable_legacy_value_flipping: bool, + pub enable_logical_styles_polyfill: bool, + pub style_resolution: String, + pub property_validation_mode: String, +} + +impl Default for CreateOptions { + fn default() -> Self { + Self::defaults() + } +} + +impl CreateOptions { + pub fn defaults() -> Self { + Self { + class_name_prefix: "x".to_owned(), + debug: false, + enable_debug_class_names: true, + enable_minified_keys: true, + enable_font_size_px_to_rem: false, + enable_media_query_order: true, + enable_legacy_value_flipping: false, + enable_logical_styles_polyfill: false, + style_resolution: "property-specificity".to_owned(), + property_validation_mode: "silent".to_owned(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CompiledProperty { + pub source_name: String, + pub name: String, + pub value: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CompiledNamespace { + pub name: String, + pub properties: Vec, + pub css_marker: bool, + pub class_paths: IndexMap>, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct CompiledCreateResult { + pub namespaces: Vec, + pub rules: Vec, + pub warnings: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct CompiledValueResult { + pub value: String, + pub rules: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct CompiledConstsResult { + pub values: IndexMap, + pub rules: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct CompiledDefineVarsResult { + pub values: IndexMap, + pub rules: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct CompiledThemeResult { + pub value: IndexMap, + pub rules: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +struct FlatRule { + js_key: String, + property: String, + value: StyleValue, + key_path: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct RuleFields { + #[serde(default)] + pub ltr: String, + #[serde(default)] + pub rtl: Option, + #[serde(default, rename = "constKey", skip_serializing_if = "Option::is_none")] + pub const_key: Option, + #[serde(default, rename = "constVal", skip_serializing_if = "Option::is_none")] + pub const_val: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RuleEntry( + pub String, + pub RuleFields, + #[serde(deserialize_with = "js_number_or_name")] pub f64, +); + +/// Rule priorities arrive from JavaScript, where a non-finite number has no +/// JSON form; the host sends those as "NaN", "Infinity" or "-Infinity". +fn js_number_or_name<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result { + #[derive(Deserialize)] + #[serde(untagged)] + enum Priority { + Number(f64), + Name(String), + } + match Priority::deserialize(deserializer)? { + Priority::Number(value) => Ok(value), + Priority::Name(name) => match name.as_str() { + "NaN" => Ok(f64::NAN), + "Infinity" => Ok(f64::INFINITY), + "-Infinity" => Ok(f64::NEG_INFINITY), + _ => Err(serde::de::Error::custom(format!( + "invalid rule priority {name:?}" + ))), + }, + } +} + +/// Object-map semantics for a single generator: overwrite values without +/// moving their first insertion position. Do not dedupe overall file metadata. +pub fn dedupe_rules_last_write(rules: Vec) -> Vec { + let mut map = IndexMap::new(); + for rule in rules { + map.insert(rule.0.clone(), rule); + } + map.into_values().collect() +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct LayersConfig { + #[serde(default)] + pub before: Vec, + #[serde(default)] + pub after: Vec, + #[serde(default)] + pub prefix: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum UseLayers { + Bool(bool), + Config(LayersConfig), +} + +impl Default for UseLayers { + fn default() -> Self { + Self::Bool(false) + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProcessStylexRulesConfig { + #[serde(default, rename = "useLayers")] + pub use_layers: UseLayers, + #[serde(default, rename = "enableLTRRTLComments")] + pub enable_ltr_rtl_comments: bool, + #[serde(default, rename = "legacyDisableLayers")] + pub legacy_disable_layers: bool, + #[serde(default, rename = "useLegacyClassnamesSort")] + pub use_legacy_classnames_sort: bool, +} + +fn sort_indexmap_entries(map: &IndexMap) -> Vec<(&String, &T)> { + let mut entries = map.iter().collect::>(); + let index = |key: &str| { + key.parse::() + .ok() + .filter(|n| *n != u32::MAX && n.to_string() == key) + }; + entries.sort_by(|(a, _), (b, _)| match (index(a), index(b)) { + (Some(a), Some(b)) => a.cmp(&b), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + _ => std::cmp::Ordering::Equal, + }); + entries +} + +/// Mirrors Babel `splitValue`: the top-level postcss-value-parser nodes with +/// spaces and dividers (`/`, `,`, `:`) dropped. +fn split_shorthand_tokens(input: &str) -> Vec { + let mut tokens = Vec::new(); + let mut current = String::new(); + let mut paren_depth = 0usize; + let mut quote = None::; + + for character in input.chars() { + match quote { + Some(active_quote) => { + current.push(character); + if character == active_quote { + quote = None; + if paren_depth == 0 { + tokens.push(std::mem::take(&mut current)); + } + } + } + None => match character { + '"' | '\'' => { + if paren_depth == 0 && !current.is_empty() { + tokens.push(std::mem::take(&mut current)); + } + quote = Some(character); + current.push(character); + } + '(' => { + paren_depth += 1; + current.push(character); + } + ')' => { + paren_depth = paren_depth.saturating_sub(1); + current.push(character); + } + character + if paren_depth == 0 + && (character.is_whitespace() || matches!(character, '/' | ',' | ':')) => + { + if !current.is_empty() { + tokens.push(std::mem::take(&mut current)); + } + } + _ => current.push(character), + }, + } + } + + if !current.is_empty() { + tokens.push(current); + } + + tokens +} + +fn compile_rule( + key: &str, + value: &StyleValue, + key_path: &[String], + options: &CreateOptions, +) -> Result<(String, RuleEntry), String> { + let canonical_key = key.to_owned(); + let dashed_key = if key.starts_with("--") { + key.to_owned() + } else { + dashify(key) + }; + let transformed_values = transform_values(&canonical_key, value, options)?; + let pseudos = key_path + .iter() + .filter(|segment| segment.starts_with(':') || segment.starts_with('[')) + .cloned() + .collect::>(); + let at_rules = key_path + .iter() + .filter(|segment| segment.starts_with('@')) + .cloned() + .collect::>(); + let const_rules = key_path + .iter() + .filter(|segment| segment.starts_with("var(--")) + .cloned() + .collect::>(); + let mut all_at_rules = at_rules.clone(); + all_at_rules.extend(const_rules.iter().cloned()); + let modifier_hash_string = if pseudos.is_empty() && all_at_rules.is_empty() { + "null".to_owned() + } else { + sort_pseudos(&pseudos).join("") + sort_at_rules(&all_at_rules).join("").as_str() + }; + let value_as_string = transformed_values.join(", "); + let string_to_hash = format!("{}{}{}", dashed_key, value_as_string, modifier_hash_string); + let class_name = format!( + "{}{}", + options.class_name_prefix, + hash_string(&format!("<>{}", string_to_hash)) + ); + + let sorted_pseudos = sort_pseudos(&pseudos); + let mut sorted_at_rules = sort_at_rules(&at_rules); + sorted_at_rules.extend(const_rules.iter().cloned()); + let (ltr_rule, rtl_rule) = build_css_rules( + &class_name, + &dashed_key, + &transformed_values, + &sorted_pseudos, + &sorted_at_rules, + options, + ); + let priority = get_priority(&dashed_key) + + pseudos + .iter() + .map(|pseudo| get_priority(pseudo)) + .sum::() + + all_at_rules + .iter() + .map(|at_rule| get_priority(at_rule)) + .sum::(); + + Ok(( + class_name.clone(), + RuleEntry( + class_name, + RuleFields { + ltr: ltr_rule, + rtl: rtl_rule, + const_key: None, + const_val: None, + }, + priority, + ), + )) +} + +fn build_css_rules( + class_name: &str, + key: &str, + values: &[String], + pseudos: &[String], + at_rules: &[String], + options: &CreateOptions, +) -> (String, Option) { + const THUMB_VARIANTS: [&str; 3] = [ + "::-webkit-slider-thumb", + "::-moz-range-thumb", + "::-ms-thumb", + ]; + let pseudo = pseudos + .iter() + .filter(|p| !p.starts_with("::")) + .chain( + pseudos + .iter() + .filter(|p| p.starts_with("::") && p.as_str() != "::thumb"), + ) + .cloned() + .collect::(); + let extra_class = if pseudo.contains(":where(") { + format!(".{}", class_name) + } else { + String::new() + }; + let mut selector = format!( + ".{}{}{}{}", + class_name, + extra_class, + at_rules + .iter() + .map(|_| format!(".{}", class_name)) + .collect::(), + pseudo + ); + if pseudos.iter().any(|pseudo| pseudo == "::thumb") { + selector = THUMB_VARIANTS + .iter() + .map(|suffix| format!("{}{}", selector, suffix)) + .collect::>() + .join(", "); + } + let wrap = |decls: String| { + at_rules + .iter() + .fold(format!("{}{{{}}}", selector, decls), |acc, at_rule| { + format!("{}{{{}}}", at_rule, acc) + }) + }; + let ltr_decls = declaration_pairs(key, values, options, false) + .unwrap_or_default() + .into_iter() + .map(|(property, value)| format!("{}:{}", property, value)) + .collect::>() + .join(";"); + let ltr_rule = wrap(ltr_decls.clone()); + let rtl_rule = declaration_pairs(key, values, options, true).and_then(|pairs| { + let rtl_decls = pairs + .into_iter() + .map(|(property, value)| format!("{}:{}", property, value)) + .collect::>() + .join(";"); + if rtl_decls == ltr_decls { + None + } else { + Some(wrap(rtl_decls)) + } + }); + (ltr_rule, rtl_rule) +} + +fn declaration_pairs( + key: &str, + values: &[String], + options: &CreateOptions, + is_rtl: bool, +) -> Option> { + let mut declarations = Vec::new(); + for value in values { + if let Some(pairs) = directional_pair(key, value, options, is_rtl) { + declarations.extend(pairs); + } + } + if is_rtl && declarations.is_empty() { + None + } else { + Some(declarations) + } +} + +fn directional_pair( + key: &str, + value: &str, + options: &CreateOptions, + is_rtl: bool, +) -> Option> { + if options.style_resolution == "legacy-expand-shorthands" { + if !options.enable_logical_styles_polyfill { + return if is_rtl { + None + } else { + Some(vec![(key.to_owned(), value.to_owned())]) + }; + } + if let Some(property) = map_inline_polyfill_property(key, is_rtl) { + return Some(vec![(property, value.to_owned())]); + } + } + if is_rtl { + map_rtl_pair(key, value, options).map(|pair| vec![pair]) + } else { + Some(vec![map_ltr_pair(key, value)]) + } +} + +fn map_inline_polyfill_property(key: &str, is_rtl: bool) -> Option { + Some( + match (key, is_rtl) { + ("margin-inline-start", false) => "margin-left", + ("margin-inline-start", true) => "margin-right", + ("margin-inline-end", false) => "margin-right", + ("margin-inline-end", true) => "margin-left", + ("padding-inline-start", false) => "padding-left", + ("padding-inline-start", true) => "padding-right", + ("padding-inline-end", false) => "padding-right", + ("padding-inline-end", true) => "padding-left", + ("border-inline-start", false) => "border-left", + ("border-inline-start", true) => "border-right", + ("border-inline-end", false) => "border-right", + ("border-inline-end", true) => "border-left", + ("border-inline-start-width", false) => "border-left-width", + ("border-inline-start-width", true) => "border-right-width", + ("border-inline-end-width", false) => "border-right-width", + ("border-inline-end-width", true) => "border-left-width", + ("border-inline-start-color", false) => "border-left-color", + ("border-inline-start-color", true) => "border-right-color", + ("border-inline-end-color", false) => "border-right-color", + ("border-inline-end-color", true) => "border-left-color", + ("border-inline-start-style", false) => "border-left-style", + ("border-inline-start-style", true) => "border-right-style", + ("border-inline-end-style", false) => "border-right-style", + ("border-inline-end-style", true) => "border-left-style", + ("border-start-start-radius", false) => "border-top-left-radius", + ("border-start-start-radius", true) => "border-top-right-radius", + ("border-end-start-radius", false) => "border-bottom-left-radius", + ("border-end-start-radius", true) => "border-bottom-right-radius", + ("border-start-end-radius", false) => "border-top-right-radius", + ("border-start-end-radius", true) => "border-top-left-radius", + ("border-end-end-radius", false) => "border-bottom-right-radius", + ("border-end-end-radius", true) => "border-bottom-left-radius", + ("inset-inline-start", false) => "left", + ("inset-inline-start", true) => "right", + ("inset-inline-end", false) => "right", + ("inset-inline-end", true) => "left", + _ => return None, + } + .to_owned(), + ) +} + +fn map_ltr_pair(key: &str, value: &str) -> (String, String) { + let property = match key { + "margin-start" => "margin-left", + "margin-end" => "margin-right", + "padding-start" => "padding-left", + "padding-end" => "padding-right", + "border-start" => "border-left", + "border-end" => "border-right", + "border-start-width" => "border-left-width", + "border-end-width" => "border-right-width", + "border-start-color" => "border-left-color", + "border-end-color" => "border-right-color", + "border-start-style" => "border-left-style", + "border-end-style" => "border-right-style", + "border-top-start-radius" => "border-top-left-radius", + "border-bottom-start-radius" => "border-bottom-left-radius", + "border-top-end-radius" => "border-top-right-radius", + "border-bottom-end-radius" => "border-bottom-right-radius", + "start" => "left", + "end" => "right", + _ => key, + }; + let mapped_value = match key { + "float" | "clear" => match value { + "inline-start" | "start" => "left".to_owned(), + "inline-end" | "end" => "right".to_owned(), + _ => value.to_owned(), + }, + "background-position" => map_background_position(value, false), + _ => value.to_owned(), + }; + (property.to_owned(), mapped_value) +} + +fn map_rtl_pair(key: &str, value: &str, options: &CreateOptions) -> Option<(String, String)> { + Some(match key { + "margin-start" => ("margin-right".to_owned(), value.to_owned()), + "margin-end" => ("margin-left".to_owned(), value.to_owned()), + "padding-start" => ("padding-right".to_owned(), value.to_owned()), + "padding-end" => ("padding-left".to_owned(), value.to_owned()), + "border-start" => ("border-right".to_owned(), value.to_owned()), + "border-end" => ("border-left".to_owned(), value.to_owned()), + "border-start-width" => ("border-right-width".to_owned(), value.to_owned()), + "border-end-width" => ("border-left-width".to_owned(), value.to_owned()), + "border-start-color" => ("border-right-color".to_owned(), value.to_owned()), + "border-end-color" => ("border-left-color".to_owned(), value.to_owned()), + "border-start-style" => ("border-right-style".to_owned(), value.to_owned()), + "border-end-style" => ("border-left-style".to_owned(), value.to_owned()), + "border-top-start-radius" => ("border-top-right-radius".to_owned(), value.to_owned()), + "border-bottom-start-radius" => ("border-bottom-right-radius".to_owned(), value.to_owned()), + "border-top-end-radius" => ("border-top-left-radius".to_owned(), value.to_owned()), + "border-bottom-end-radius" => ("border-bottom-left-radius".to_owned(), value.to_owned()), + "float" | "clear" => match value { + "inline-start" | "start" => (key.to_owned(), "right".to_owned()), + "inline-end" | "end" => (key.to_owned(), "left".to_owned()), + _ => return None, + }, + "start" => ("right".to_owned(), value.to_owned()), + "end" => ("left".to_owned(), value.to_owned()), + "background-position" => { + if !value + .split(' ') + .any(|word| word == "start" || word == "end") + { + return None; + } + let mapped = map_background_position(value, true); + if mapped == value { + return None; + } + (key.to_owned(), mapped) + } + "cursor" if options.enable_legacy_value_flipping => { + (key.to_owned(), map_legacy_cursor(value)?) + } + "box-shadow" | "text-shadow" if options.enable_legacy_value_flipping => { + (key.to_owned(), flip_shadow_value(value)?) + } + _ => return None, + }) +} + +fn map_background_position(value: &str, is_rtl: bool) -> String { + value + .split(' ') + .map(|word| match (word, is_rtl) { + ("start", false) | ("insetInlineStart", false) => "left".to_owned(), + ("end", false) | ("insetInlineEnd", false) => "right".to_owned(), + ("start", true) | ("insetInlineStart", true) => "right".to_owned(), + ("end", true) | ("insetInlineEnd", true) => "left".to_owned(), + _ => word.to_owned(), + }) + .collect::>() + .join(" ") +} + +fn map_legacy_cursor(value: &str) -> Option { + Some( + match value { + "e-resize" => "w-resize", + "w-resize" => "e-resize", + "ne-resize" => "nw-resize", + "nesw-resize" => "nwse-resize", + "nw-resize" => "ne-resize", + "nwse-resize" => "nesw-resize", + "se-resize" => "sw-resize", + "sw-resize" => "se-resize", + _ => return None, + } + .to_owned(), + ) +} + +fn flip_shadow_value(value: &str) -> Option { + let tokens = values::tokenize(value).ok()?; + let mut groups = Vec::new(); + let mut group: Vec = Vec::new(); + // postcss-value-parser folds whitespace around a divider into the divider + // node, so a group never starts or ends with a space. + let mut push = |group: &mut Vec| { + while matches!(group.last(), Some(values::Token::Space)) { + group.pop(); + } + let start = group + .iter() + .position(|token| !matches!(token, values::Token::Space)) + .unwrap_or(group.len()); + if start < group.len() { + groups.push(values::render(&group[start..])); + } + group.clear(); + }; + for token in tokens { + if matches!(token, values::Token::Div(_)) { + push(&mut group); + } else { + group.push(token); + } + } + push(&mut group); + let flipped = groups + .into_iter() + .map(|definition| { + let mut parts = definition.split(' ').map(str::to_owned).collect::>(); + let index = if parts + .first() + .and_then(|word| values::dimension(word)) + .is_none() + { + 1 + } else { + 0 + }; + if let Some(value) = parts.get_mut(index) { + if value != "0" { + *value = value + .strip_prefix('-') + .map(str::to_owned) + .unwrap_or_else(|| format!("-{value}")); + } + } + parts.join(" ") + }) + .collect::>() + .join(","); + if flipped == value { + None + } else { + Some(flipped) + } +} + +fn transform_values( + key: &str, + value: &StyleValue, + options: &CreateOptions, +) -> Result, String> { + let mut values = match value { + StyleValue::Array(values) => values + .iter() + .map(|value| transform_single_value(key, value, options)) + .collect::, _>>()?, + _ => vec![transform_single_value(key, value, options)?], + }; + if values + .iter() + .any(|value| value.starts_with("var(") && value.ends_with(')')) + { + values = variable_fallbacks(values)?; + } + Ok(values) +} + +fn transform_single_value( + key: &str, + value: &StyleValue, + options: &CreateOptions, +) -> Result { + transform_value(key, value, options) +} + +fn variable_fallbacks(values: Vec) -> Result, String> { + let is_var = |value: &str| value.starts_with("var(") && value.ends_with(')'); + let first_var = values.iter().position(|value| is_var(value)); + let Some(first_var) = first_var else { + return Ok(values); + }; + let last_var = values + .iter() + .rposition(|value| is_var(value)) + .expect("var exists"); + let values_before_first_var = values[..first_var].to_vec(); + let mut var_values = values[first_var..=last_var] + .iter() + .rev() + .cloned() + .collect::>(); + let values_after_last_var = values[last_var + 1..].to_vec(); + + if var_values.iter().any(|value| !is_var(value)) { + return Err("All variables passed to firstThatWorks() must be contiguous.".to_owned()); + } + + var_values = var_values + .into_iter() + .map(|value| { + value + .strip_prefix("var(") + .and_then(|value| value.strip_suffix(')')) + .unwrap_or(&value) + .to_owned() + }) + .collect(); + + let mut result = if values_before_first_var.is_empty() { + vec![compose_vars(&var_values)] + } else { + values_before_first_var + .into_iter() + .map(|value| { + let mut vars = var_values.clone(); + vars.push(value); + compose_vars(&vars) + }) + .collect::>() + }; + result.extend(values_after_last_var); + Ok(result) +} + +fn compose_vars(vars: &[String]) -> String { + let Some((first, rest)) = vars.split_first() else { + return String::new(); + }; + if !rest.is_empty() { + format!("var({first},{})", compose_vars(rest)) + } else if first.starts_with("--") { + format!("var({first})") + } else { + first.clone() + } +} + +fn dashify(input: &str) -> String { + let mut output = String::new(); + let mut previous_lowercase = false; + for (index, character) in input.chars().enumerate() { + if character.is_ascii_uppercase() && (index == 0 || previous_lowercase) { + output.push('-'); + // The JS regex consumes the lowercase/uppercase pair. An adjacent + // uppercase character cannot start a second match. + previous_lowercase = false; + } else { + previous_lowercase = character.is_ascii_lowercase(); + } + output.push(character); + } + output.to_lowercase() +} + +fn sort_pseudos(pseudos: &[String]) -> Vec { + let mut output = Vec::new(); + let mut classes = Vec::new(); + for pseudo in pseudos { + if pseudo.starts_with("::") { + classes.sort_by(|a: &String, b: &String| process::locale_compare(a, b)); + output.append(&mut classes); + output.push(pseudo.clone()); + } else { + classes.push(pseudo.clone()); + } + } + classes.sort_by(|a, b| process::locale_compare(a, b)); + output.extend(classes); + output +} + +fn sort_at_rules(at_rules: &[String]) -> Vec { + let mut values = at_rules.to_vec(); + values.sort_by(|a, b| a.encode_utf16().cmp(b.encode_utf16())); + values +} + +pub fn compile_define_consts( + constants: &IndexMap, + filename: &str, + export_name: &str, + options: Option<&CreateOptions>, +) -> Result { + let file_name = Path::new(filename) + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or(filename); + compile_define_consts_with_export_id( + constants, + &format!("{}//{}", file_name, export_name), + options, + ) +} + +/// Canonical file identity is resolved by the host, never guessed by the core. +pub fn compile_define_consts_with_export_id( + constants: &IndexMap, + export_id: &str, + options: Option<&CreateOptions>, +) -> Result { + let options = options.cloned().unwrap_or_default(); + let mut values = IndexMap::new(); + let mut rules = Vec::new(); + + for (key, value) in sort_indexmap_entries(constants) { + let const_key = if let Some(name) = key.strip_prefix("--") { + name.to_owned() + } else { + format!( + "{}{}", + options.class_name_prefix, + hash_string(&format!("{}.{}", export_id, key)) + ) + }; + let const_val = match value { + StyleValue::String(string) => Value::String(string.clone()), + StyleValue::Number(number) => { + Value::Number(serde_json::Number::from_f64(*number).ok_or_else(|| { + "defineConsts() only supports finite numeric values.".to_owned() + })?) + } + _ => return Err("defineConsts() only supports string and number values.".to_owned()), + }; + values.insert(key.clone(), const_val.clone()); + rules.push(RuleEntry( + const_key.clone(), + RuleFields { + ltr: String::new(), + rtl: None, + const_key: Some(const_key.clone()), + const_val: Some(const_val), + }, + 0.0, + )); + } + + Ok(CompiledConstsResult { + values, + rules: dedupe_rules_last_write(rules), + }) +} + +pub fn compile_define_consts_nested( + constants: &IndexMap, + filename: &str, + export_name: &str, + options: Option<&CreateOptions>, +) -> Result { + let file_name = Path::new(filename) + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or(filename); + compile_define_consts_nested_with_export_id( + constants, + &format!("{}//{}", file_name, export_name), + options, + ) +} + +pub fn compile_define_consts_nested_with_export_id( + constants: &IndexMap, + export_id: &str, + options: Option<&CreateOptions>, +) -> Result { + let flat_constants = flatten_nested_consts_config(constants)?; + let compiled = compile_define_consts_with_export_id(&flat_constants, export_id, options)?; + + let mut nested_root = serde_json::Map::new(); + for (key, value) in &compiled.values { + insert_unflattened_value(&mut nested_root, key, value.clone())?; + } + + let mut nested_values = IndexMap::new(); + for (key, value) in nested_root { + nested_values.insert(key, value); + } + + Ok(CompiledConstsResult { + values: nested_values, + rules: compiled.rules, + }) +} + +pub fn compile_define_vars( + variables: &IndexMap, + export_id: &str, + options: Option<&CreateOptions>, +) -> Result { + let options = options.cloned().unwrap_or_else(CreateOptions::defaults); + let var_group_hash = format!("{}{}", options.class_name_prefix, hash_string(export_id)); + let mut rules_by_at_rule = IndexMap::>::new(); + let mut values = IndexMap::new(); + + for (key, value) in sort_indexmap_entries(variables) { + let variable_name = if let Some(name) = key.strip_prefix("--") { + name.to_owned() + } else { + format!( + "{}{}", + options.class_name_prefix, + hash_string(&format!("{}.{}", export_id, key)) + ) + }; + values.insert( + key.clone(), + Value::String(format!("var(--{})", variable_name)), + ); + collect_define_vars_by_at_rule(key, &variable_name, value, &mut rules_by_at_rule, &[])?; + } + values.insert( + "__varGroupHash__".to_owned(), + Value::String(var_group_hash.clone()), + ); + + let mut rules = Vec::new(); + for (at_rule, declarations) in rules_by_at_rule { + let suffix = if at_rule == "default" { + String::new() + } else { + format!("-{}", hash_string(&at_rule)) + }; + let selector = format!(":root, .{}", var_group_hash); + let mut ltr = format!("{}{{{}}}", selector, declarations.join("")); + if at_rule != "default" { + ltr = wrap_with_define_var_at_rules(<r, &at_rule); + } + rules.push(RuleEntry( + format!("{}{}", var_group_hash, suffix), + RuleFields { + ltr, + rtl: None, + const_key: None, + const_val: None, + }, + define_var_priority(&at_rule), + )); + } + + Ok(CompiledDefineVarsResult { + values, + rules: dedupe_rules_last_write(rules), + }) +} + +pub fn compile_define_vars_nested( + variables: &IndexMap, + export_id: &str, + options: Option<&CreateOptions>, +) -> Result { + let flat_variables = flatten_nested_vars_config(variables)?; + let compiled = compile_define_vars(&flat_variables, export_id, options)?; + let mut nested_root = serde_json::Map::new(); + for (key, value) in &compiled.values { + if key == "__varGroupHash__" { + continue; + } + insert_unflattened_value(&mut nested_root, key, value.clone())?; + } + + let mut nested_values = IndexMap::new(); + for (key, value) in nested_root { + nested_values.insert(key, value); + } + nested_values.insert( + "__varGroupHash__".to_owned(), + compiled + .values + .get("__varGroupHash__") + .cloned() + .unwrap_or(Value::Null), + ); + + Ok(CompiledDefineVarsResult { + values: nested_values, + rules: compiled.rules, + }) +} + +pub fn compile_create_theme( + theme_vars: &IndexMap, + overrides: &IndexMap, + options: Option<&CreateOptions>, +) -> Result { + let options = options.cloned().unwrap_or_else(CreateOptions::defaults); + let Some(StyleValue::String(var_group_hash)) = theme_vars.get("__varGroupHash__") else { + return Err("Can only override variables theme created with defineVars().".to_owned()); + }; + + let mut rules_by_at_rule = IndexMap::>::new(); + let mut sorted_override_keys = overrides.keys().cloned().collect::>(); + sorted_override_keys.sort_by(|a, b| a.encode_utf16().cmp(b.encode_utf16())); + + for key in sorted_override_keys { + let Some(theme_var) = theme_vars.get(&key) else { + continue; + }; + let StyleValue::String(theme_var_ref) = theme_var else { + continue; + }; + let Some(name_hash) = theme_var_ref + .strip_prefix("var(--") + .and_then(|value| value.strip_suffix(')')) + else { + continue; + }; + let value = overrides + .get(&key) + .ok_or_else(|| "Missing theme override value.".to_owned())?; + collect_theme_vars_by_at_rule(name_hash, value, &mut rules_by_at_rule, &[])?; + } + + let mut sorted_at_rules = rules_by_at_rule.keys().cloned().collect::>(); + sorted_at_rules.sort_by(|left, right| { + if left == "default" { + std::cmp::Ordering::Less + } else if right == "default" { + std::cmp::Ordering::Greater + } else { + process::locale_compare(left, right) + } + }); + + let at_rules_string_for_hash = sorted_at_rules + .iter() + .map(|at_rule| wrap_with_at_rules(&rules_by_at_rule[at_rule].join(""), at_rule)) + .collect::(); + + let override_class_name = format!( + "{}{}", + options.class_name_prefix, + hash_string(&at_rules_string_for_hash) + ); + + let mut rules = Vec::new(); + for at_rule in &sorted_at_rules { + let declarations = rules_by_at_rule[at_rule].join(""); + let rule = format!( + ".{name}, .{name}:root{{{decls}}}", + name = override_class_name, + decls = declarations + ); + let priority = 0.4 + priority_for_theme_at_rule(at_rule) / 10.0; + let suffix = if at_rule == "default" { + String::new() + } else { + format!("-{}", hash_string(at_rule)) + }; + let ltr = if at_rule == "default" { + rule + } else { + wrap_with_at_rules(&rule, at_rule) + }; + rules.push(RuleEntry( + format!("{}{}", override_class_name, suffix), + RuleFields { + ltr, + rtl: None, + const_key: None, + const_val: None, + }, + priority, + )); + } + + Ok(CompiledThemeResult { + value: IndexMap::from([ + ( + var_group_hash.clone(), + Value::String(format!("{} {}", override_class_name, var_group_hash)), + ), + ("$$css".to_owned(), Value::Bool(true)), + ]), + rules: dedupe_rules_last_write(rules), + }) +} + +pub fn compile_create_theme_nested( + theme_vars: &IndexMap, + overrides: &IndexMap, + options: Option<&CreateOptions>, +) -> Result { + let Some(var_group_hash) = theme_vars.get("__varGroupHash__").cloned() else { + return Err( + "Can only override variables theme created with unstable_defineVarsNested()." + .to_owned(), + ); + }; + + let mut nested_refs = theme_vars.clone(); + nested_refs.shift_remove("__varGroupHash__"); + + let mut flat_theme_vars = flatten_nested_string_config(&nested_refs)?; + flat_theme_vars.insert("__varGroupHash__".to_owned(), var_group_hash); + let flat_overrides = flatten_nested_overrides_config(overrides)?; + + compile_create_theme(&flat_theme_vars, &flat_overrides, options).map_err(|error| { + if error == "Can only override variables theme created with defineVars()." { + "Can only override variables theme created with unstable_defineVarsNested().".to_owned() + } else { + error + } + }) +} + +fn collect_theme_vars_by_at_rule( + name_hash: &str, + value: &StyleValue, + collection: &mut IndexMap>, + at_rules: &[String], +) -> Result<(), String> { + match value { + StyleValue::String(value) => { + let key = if at_rules.is_empty() { + "default".to_owned() + } else { + let mut sorted = at_rules.to_vec(); + sorted.sort_by(|a, b| a.encode_utf16().cmp(b.encode_utf16())); + sorted.join("__$$__") + }; + collection + .entry(key) + .or_default() + .push(format!("--{}:{};", name_hash, value)); + Ok(()) + } + StyleValue::Number(value) => { + let key = if at_rules.is_empty() { + "default".to_owned() + } else { + let mut sorted = at_rules.to_vec(); + sorted.sort_by(|a, b| a.encode_utf16().cmp(b.encode_utf16())); + sorted.join("__$$__") + }; + collection.entry(key).or_default().push(format!( + "--{}:{};", + name_hash, + values::js_number(*value) + )); + Ok(()) + } + StyleValue::Null => Ok(()), + StyleValue::Array(_) => Err("Array is not supported in defineVars".to_owned()), + StyleValue::Object(map) => { + if !map.contains_key("default") { + return Err("Default value is not defined for variable.".to_owned()); + } + for (at_rule, inner_value) in map { + let mut next_rules = at_rules.to_vec(); + if at_rule != "default" { + next_rules.push(at_rule.clone()); + } + collect_theme_vars_by_at_rule(name_hash, inner_value, collection, &next_rules)?; + } + Ok(()) + } + } +} + +fn wrap_with_at_rules(ltr: &str, at_rule: &str) -> String { + at_rule + .split("__$$__") + .fold(ltr.to_owned(), |acc, at_rule| { + format!("{}{{{}}}", at_rule, acc) + }) +} + +fn priority_for_theme_at_rule(at_rule: &str) -> f64 { + if at_rule == "default" { + 1.0 + } else { + (1 + at_rule.split("__$$__").count()) as f64 + } +} + +fn collect_define_vars_by_at_rule( + key: &str, + name_hash: &str, + value: &StyleValue, + collection: &mut IndexMap>, + at_rules: &[String], +) -> Result<(), String> { + match value { + StyleValue::String(string) => { + let collection_key = if at_rules.is_empty() { + "default".to_owned() + } else { + let mut sorted = at_rules.to_vec(); + sorted.sort_by(|a, b| a.encode_utf16().cmp(b.encode_utf16())); + sorted.join("__$$__") + }; + collection + .entry(collection_key) + .or_default() + .push(format!("--{}:{};", name_hash, string)); + Ok(()) + } + StyleValue::Number(number) => { + let collection_key = if at_rules.is_empty() { + "default".to_owned() + } else { + let mut sorted = at_rules.to_vec(); + sorted.sort_by(|a, b| a.encode_utf16().cmp(b.encode_utf16())); + sorted.join("__$$__") + }; + collection.entry(collection_key).or_default().push(format!( + "--{}:{};", + name_hash, + values::js_number(*number) + )); + Ok(()) + } + StyleValue::Null => Ok(()), + StyleValue::Array(_) => Err("Array is not supported in defineVars".to_owned()), + StyleValue::Object(object) => { + if !object.contains_key("default") { + return Err(format!( + "Default value is not defined for {} variable.", + key + )); + } + for (at_rule, nested_value) in object { + let next_rules = if at_rule == "default" { + at_rules.to_vec() + } else { + at_rules + .iter() + .cloned() + .chain(std::iter::once(at_rule.clone())) + .collect() + }; + collect_define_vars_by_at_rule( + key, + name_hash, + nested_value, + collection, + &next_rules, + )?; + } + Ok(()) + } + } +} + +fn wrap_with_define_var_at_rules(ltr: &str, at_rule: &str) -> String { + at_rule + .split("__$$__") + .fold(ltr.to_owned(), |acc, at_rule| { + format!("{}{{{}}}", at_rule, acc) + }) +} + +fn define_var_priority(at_rule: &str) -> f64 { + if at_rule == "default" { + 0.1 + } else { + (1 + at_rule.split("__$$__").count()) as f64 / 10.0 + } +} + +fn validate_position_try_properties(styles: &IndexMap) -> Result<(), String> { + for key in styles.keys() { + if !matches!( + key.as_str(), + "anchorName" + | "positionAnchor" + | "positionArea" + | "top" + | "right" + | "bottom" + | "left" + | "inset" + | "insetBlock" + | "insetBlockEnd" + | "insetBlockStart" + | "insetInline" + | "insetInlineEnd" + | "insetInlineStart" + | "margin" + | "marginBlock" + | "marginBlockEnd" + | "marginBlockStart" + | "marginInline" + | "marginInlineEnd" + | "marginInlineStart" + | "marginTop" + | "marginBottom" + | "marginLeft" + | "marginRight" + | "width" + | "height" + | "minWidth" + | "minHeight" + | "maxWidth" + | "maxHeight" + | "blockSize" + | "inlineSize" + | "minBlockSize" + | "minInlineSize" + | "maxBlockSize" + | "maxInlineSize" + | "alignSelf" + | "justifySelf" + | "placeSelf" + ) { + return Err("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)".to_owned()); + } + } + Ok(()) +} + +pub fn hash_public(input: &str) -> String { + hash_string(input) +} + +fn hash_string(input: &str) -> String { + to_base_n( + murmurhash2_32_gc(input, 1), + b"0123456789abcdefghijklmnopqrstuvwxyz", + ) +} + +fn create_short_hash(input: &str) -> String { + to_base62(murmurhash2_32_gc(input, 1) % 62_u32.pow(5)) +} + +pub(crate) fn create_short_hash_public(input: &str) -> String { + create_short_hash(input) +} + +fn murmurhash2_32_gc(input: &str, seed: u32) -> u32 { + // JS charCodeAt operates on UTF-16 code units, then masks each to 8 bits. + // UTF-8 bytes (and Unicode scalar counts) produce incompatible CSS hashes. + let bytes: Vec = input + .encode_utf16() + .map(|unit| (unit & 0xff) as u8) + .collect(); + let mut len = bytes.len() as u32; + let mut h = seed ^ len; + let mut i = 0usize; + + while len >= 4 { + let mut k = (bytes[i] as u32) + | ((bytes[i + 1] as u32) << 8) + | ((bytes[i + 2] as u32) << 16) + | ((bytes[i + 3] as u32) << 24); + + k = (k & 0xffff) + .wrapping_mul(0x5bd1e995) + .wrapping_add((((k >> 16).wrapping_mul(0x5bd1e995)) & 0xffff) << 16); + k ^= k >> 24; + k = (k & 0xffff) + .wrapping_mul(0x5bd1e995) + .wrapping_add((((k >> 16).wrapping_mul(0x5bd1e995)) & 0xffff) << 16); + + h = (h & 0xffff) + .wrapping_mul(0x5bd1e995) + .wrapping_add((((h >> 16).wrapping_mul(0x5bd1e995)) & 0xffff) << 16) + ^ k; + + i += 4; + len -= 4; + } + + match len { + 3 => { + h ^= (bytes[i + 2] as u32) << 16; + h ^= (bytes[i + 1] as u32) << 8; + h ^= bytes[i] as u32; + h = (h & 0xffff) + .wrapping_mul(0x5bd1e995) + .wrapping_add((((h >> 16).wrapping_mul(0x5bd1e995)) & 0xffff) << 16); + } + 2 => { + h ^= (bytes[i + 1] as u32) << 8; + h ^= bytes[i] as u32; + h = (h & 0xffff) + .wrapping_mul(0x5bd1e995) + .wrapping_add((((h >> 16).wrapping_mul(0x5bd1e995)) & 0xffff) << 16); + } + 1 => { + h ^= bytes[i] as u32; + h = (h & 0xffff) + .wrapping_mul(0x5bd1e995) + .wrapping_add((((h >> 16).wrapping_mul(0x5bd1e995)) & 0xffff) << 16); + } + _ => {} + } + + h ^= h >> 13; + h = (h & 0xffff) + .wrapping_mul(0x5bd1e995) + .wrapping_add((((h >> 16).wrapping_mul(0x5bd1e995)) & 0xffff) << 16); + h ^= h >> 15; + h +} + +fn to_base62(value: u32) -> String { + if value == 0 { + return String::new(); + } + to_base_n( + value, + b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", + ) +} + +fn to_base_n(mut value: u32, alphabet: &[u8]) -> String { + if value == 0 { + return "0".to_owned(); + } + let mut result = String::new(); + let base = alphabet.len() as u32; + while value > 0 { + let remainder = (value % base) as usize; + result.insert(0, alphabet[remainder] as char); + value /= base; + } + result +} + +fn layer_name(index: usize, layer_prefix: &str) -> String { + if layer_prefix.is_empty() { + format!("priority{}", index + 1) + } else { + format!("{}.priority{}", layer_prefix, index + 1) + } +} + +fn group_rules_by_priority(rules: Vec) -> Vec> { + let mut grouped = Vec::>::new(); + // JS `===` on `Math.floor(priority / 1000)`: a NaN level never matches, + // so every NaN-priority rule starts a group of its own. + let mut current_level: Option = None; + + for rule in rules { + let priority_level = (rule.2 / 1000.0).floor(); + if current_level == Some(priority_level) { + grouped.last_mut().expect("group exists").push(rule); + } else { + current_level = Some(priority_level); + grouped.push(vec![rule]); + } + } + + grouped +} + +fn get_logical_float_vars(rules: &[RuleEntry]) -> String { + let has_logical_float = rules.iter().any(|rule| { + rule.1.ltr.contains(LOGICAL_FLOAT_START_VAR) + || rule.1.ltr.contains(LOGICAL_FLOAT_END_VAR) + || rule + .1 + .rtl + .as_ref() + .map(|value| { + value.contains(LOGICAL_FLOAT_START_VAR) || value.contains(LOGICAL_FLOAT_END_VAR) + }) + .unwrap_or(false) + }); + + if has_logical_float { + format!( + ":root, [dir=\"ltr\"] {{\n {}: left;\n {}: right;\n}}\n[dir=\"rtl\"] {{\n {}: right;\n {}: left;\n}}\n", + LOGICAL_FLOAT_START_VAR, + LOGICAL_FLOAT_END_VAR, + LOGICAL_FLOAT_START_VAR, + LOGICAL_FLOAT_END_VAR + ) + } else { + String::new() + } +} + +fn add_ancestor_selector(selector: &str, ancestor_selector: &str) -> String { + if selector.starts_with("@keyframes") || selector.starts_with("@position-try") { + return selector.to_owned(); + } + if !selector.starts_with('@') { + return format!("{} {}", ancestor_selector, selector); + } + + let last_at_rule = selector.rfind('@').expect("at-rule"); + let at_rule_bracket_index = selector[last_at_rule..] + .find('{') + .map(|index| index + last_at_rule) + .expect("at-rule opening brace"); + let media_query_part = &selector[..=at_rule_bracket_index]; + let rest = &selector[at_rule_bracket_index + 1..]; + format!("{}{} {}", media_query_part, ancestor_selector, rest) +} + +fn add_specificity_level(selector: &str, index: usize) -> String { + if selector.starts_with("@keyframes") || selector.starts_with("@position-try") { + return selector.to_owned(); + } + + let pseudo = ":not(#\\#)".repeat(index); + let insertion_index = if selector.contains("::") { + selector.find("::").unwrap_or(selector.len()) + } else { + selector.rfind('{').unwrap_or(selector.len()) + }; + + format!( + "{}{}{}", + &selector[..insertion_index], + pseudo, + &selector[insertion_index..] + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn process_rules_matches_reference_behavior_for_constants_and_layers() { + let rules = vec![ + RuleEntry( + "constColor".to_owned(), + RuleFields { + ltr: String::new(), + rtl: None, + const_key: Some("constColor".to_owned()), + const_val: Some(Value::String("var(--brand-color)".to_owned())), + }, + 0.0, + ), + RuleEntry( + "brand-color".to_owned(), + RuleFields { + ltr: String::new(), + rtl: None, + const_key: Some("brand-color".to_owned()), + const_val: Some(Value::String("#123456".to_owned())), + }, + 0.0, + ), + RuleEntry( + "alpha".to_owned(), + RuleFields { + ltr: ".alpha{color:var(--constColor)}".to_owned(), + rtl: Some("@media print{.alpha{color:var(--constColor)}}".to_owned()), + const_key: None, + const_val: None, + }, + 3000.0, + ), + ]; + + let output = process_stylex_rules( + &rules, + Some(&ProcessStylexRulesConfig { + use_layers: UseLayers::Config(LayersConfig { + before: vec!["reset".to_owned()], + after: vec!["utilities".to_owned()], + prefix: "stylex".to_owned(), + }), + enable_ltr_rtl_comments: true, + ..Default::default() + }), + ); + + assert_eq!( + output, + "\n@layer reset, stylex.priority1, utilities;\n@layer stylex.priority1{\n/* @ltr begin */.alpha{color:#123456}/* @ltr end */\n/* @rtl begin */@media print{.alpha{color:#123456}}/* @rtl end */\n}" + ); + } +} diff --git a/packages/@stylexjs/rust-plugin/src/core/priority.rs b/packages/@stylexjs/rust-plugin/src/core/priority.rs new file mode 100644 index 000000000..a95fc88d3 --- /dev/null +++ b/packages/@stylexjs/rust-plugin/src/core/priority.rs @@ -0,0 +1,61 @@ +//! Shared rule priorities, including relational and compound pseudo selectors. +use regex::Regex; +use std::sync::OnceLock; + +pub fn get_priority(key: &str) -> f64 { + if key.starts_with("--") { + return 1.0; + } + for (prefix, priority) in [ + ("@supports", 30.0), + ("@media", 200.0), + ("@container", 300.0), + ] { + if key.starts_with(prefix) { + return priority; + } + } + static PARTS: OnceLock = OnceLock::new(); + let parts: Vec<_> = PARTS + .get_or_init(|| Regex::new(r"::[a-zA-Z-]+|:[a-zA-Z-]+(?:\([^)]*\))?").unwrap()) + .find_iter(key) + .map(|m| m.as_str()) + .collect(); + if parts.len() > 1 && !parts.iter().any(|part| part.contains('(')) { + return parts + .iter() + .map(|part| { + if part.starts_with("::") { + 5000.0 + } else { + super::properties::pseudo_priority(part) + } + }) + .sum(); + } + if key.starts_with("::") { + return 5000.0; + } + static RELATIONAL: OnceLock> = OnceLock::new(); + let relational=RELATIONAL.get_or_init(||vec![ + (Regex::new(r"^:where\(\.[0-9a-zA-Z_-]+(:[a-zA-Z-]+)\s+\*\)$").unwrap(),10.0), + (Regex::new(r"^:where\(:has\(\.[0-9a-zA-Z_-]+(:[a-zA-Z-]+)\)\)$").unwrap(),15.0), + (Regex::new(r"^:where\(\.[0-9a-zA-Z_-]+(:[a-zA-Z-]+)\s+~\s+\*,\s+:has\(~\s\.[0-9a-zA-Z_-]+(:[a-zA-Z-]+)\)\)$").unwrap(),20.0), + (Regex::new(r"^:where\(\.[0-9a-zA-Z_-]+(:[a-zA-Z-]+)\s+~\s+\*\)$").unwrap(),30.0), + (Regex::new(r"^:where\(:has\(~\s\.[0-9a-zA-Z_-]+(:[a-zA-Z-]+)\)\)$").unwrap(),40.0), + ]); + for (re, base) in relational { + if let Some(c) = re.captures(key) { + let first = super::properties::pseudo_priority(&c[1]); + let second = c + .get(2) + .map(|m| super::properties::pseudo_priority(m.as_str())) + .unwrap_or(first); + return base + first.max(second) / 100.0; + } + } + if key.starts_with(':') { + return super::properties::pseudo_priority(key.split('(').next().unwrap_or(key)); + } + super::properties::default_priority(key) +} diff --git a/packages/@stylexjs/rust-plugin/src/core/process.rs b/packages/@stylexjs/rust-plugin/src/core/process.rs new file mode 100644 index 000000000..fa2364361 --- /dev/null +++ b/packages/@stylexjs/rust-plugin/src/core/process.rs @@ -0,0 +1,306 @@ +//! Final stylesheet processing: constant resolution, total ordering, and layers. +use super::{ProcessStylexRulesConfig, RuleEntry, RuleFields, UseLayers}; +use indexmap::IndexMap; +use regex::Regex; +use serde_json::Value; +use std::{cmp::Ordering, collections::HashSet, sync::OnceLock}; + +pub fn process_stylex_rules( + rules: &[RuleEntry], + config: Option<&ProcessStylexRulesConfig>, +) -> String { + process_stylex_rules_checked(rules, config).unwrap_or_else(|error| panic!("{error}")) +} +/// Host adapters should map this error into a JS Error, not unwind over FFI. +pub fn process_stylex_rules_checked( + rules: &[RuleEntry], + config: Option<&ProcessStylexRulesConfig>, +) -> Result { + if rules.is_empty() { + return Ok(String::new()); + } + let config = config.cloned().unwrap_or_default(); + let mut constants = IndexMap::new(); + for rule in rules { + if rule.1.const_key.is_some() { + if let Some(value) = &rule.1.const_val { + if !value.is_null() { + constants.insert(format!("var(--{})", rule.0), value.clone()); + } + } + } + } + let keys: Vec<_> = constants.keys().cloned().collect(); + for key in keys { + let value = resolve(&constants[&key], &constants, &mut HashSet::new())?; + constants.insert(key, value); + } + let mut processed = Vec::new(); + for rule in rules { + if rule.1.const_key.is_some() + && rule + .1 + .const_val + .as_ref() + .is_some_and(|value| !value.is_null()) + { + continue; + } + let mut rule = rule.clone(); + rule.2 = adjust_priority(&rule.1.ltr, rule.2, &constants); + rule.1.ltr = replace_constants(&rule.1.ltr, &constants); + rule.1.rtl = rule + .1 + .rtl + .map(|value| replace_constants(&value, &constants)); + processed.push(rule); + } + let mut width_keys = std::collections::HashMap::new(); + let mut chains = std::collections::HashMap::new(); + for rule in &processed { + let chain = super::media::width_chain_key(&rule.1.ltr); + let key = chains + .entry(chain) + .or_insert_with(|| super::media::width_key(&rule.1.ltr)) + .clone(); + width_keys.entry(rule.1.ltr.clone()).or_insert(key); + } + processed.sort_by(|a, b| { + // Mirrors the JS comparator: a NaN priority difference ends the + // comparison as "equal" rather than falling through to the tie-breaks. + let Some(priority) = a.2.partial_cmp(&b.2) else { + return Ordering::Equal; + }; + if priority != Ordering::Equal { + return priority; + } + if config.use_legacy_classnames_sort { + return locale_compare(&a.0, &b.0); + } + let property = locale_compare(declared_property(&a.1.ltr), declared_property(&b.1.ltr)); + if property != Ordering::Equal { + return property; + } + let widths = compare_width(width_keys[&a.1.ltr].as_ref(), width_keys[&b.1.ltr].as_ref()); + if widths != Ordering::Equal { + return widths; + } + let declaration = locale_compare(declaration(&a.1.ltr), declaration(&b.1.ltr)); + if declaration != Ordering::Equal { + return declaration; + } + locale_compare(&a.1.ltr, &b.1.ltr) + }); + let float_vars = super::get_logical_float_vars(&processed); + let grouped = super::group_rules_by_priority(processed); + let (use_layers, before, after, prefix) = match &config.use_layers { + UseLayers::Bool(value) => (*value, Vec::new(), Vec::new(), String::new()), + UseLayers::Config(value) => ( + true, + value.before.clone(), + value.after.clone(), + value.prefix.clone(), + ), + }; + let header = if use_layers { + format!( + "\n@layer {};\n", + before + .into_iter() + .chain((0..grouped.len()).map(|i| super::layer_name(i, &prefix))) + .chain(after) + .collect::>() + .join(", ") + ) + } else { + String::new() + }; + let mut css = Vec::new(); + for (i, group) in grouped.iter().enumerate() { + if !use_layers { + css.push(collect(group, i, false, &config)); + continue; + } + let unlayered: Vec<_> = group.iter().filter(|r| r.2 == 0.0).cloned().collect(); + let layerable: Vec<_> = group.iter().filter(|r| r.2 > 0.0).cloned().collect(); + let mut parts = Vec::new(); + if !unlayered.is_empty() { + parts.push(collect(&unlayered, i, true, &config)); + } + if !layerable.is_empty() { + parts.push(format!( + "@layer {}{{\n{}\n}}", + super::layer_name(i, &prefix), + collect(&layerable, i, true, &config) + )); + } + css.push(parts.join("\n")); + } + Ok(format!("{float_vars}{header}{}", css.join("\n"))) +} +fn var_pattern() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"var\((--[A-Za-z0-9_-]+)\)").unwrap()) +} +fn resolve( + value: &Value, + constants: &IndexMap, + visited: &mut HashSet, +) -> Result { + let Some(value) = value.as_str() else { + return Ok(value.clone()); + }; + let mut output = String::new(); + let mut at = 0; + for captures in var_pattern().captures_iter(value) { + let m = captures.get(0).unwrap(); + let name = &captures[1]; + let key = format!("var({name})"); + output.push_str(&value[at..m.start()]); + if visited.contains(name) { + return Err(format!("circular reference detected for constant {name}")); + } + if let Some(value) = constants.get(&key) { + visited.insert(name.to_owned()); + output.push_str(&value_text(&resolve(value, constants, visited)?)); + visited.remove(name); + } else { + output.push_str(m.as_str()); + } + at = m.end(); + } + output.push_str(&value[at..]); + Ok(Value::String(output)) +} +fn value_text(value: &Value) -> String { + match value { + Value::String(v) => v.clone(), + Value::Number(v) => super::values::js_number(v.as_f64().unwrap()), + value => value.to_string(), + } +} +fn replace_constants(input: &str, constants: &IndexMap) -> String { + let mut output = input.to_owned(); + for (name, value) in constants { + let replacement = value_text(value); + output = output.replace(name, &replacement); + if replacement.starts_with("var(") && replacement.ends_with(')') { + let inside = replacement[4..replacement.len() - 1].trim(); + let target = inside.split(',').next().unwrap_or(inside).trim(); + let old = &name[4..name.len() - 1]; + output = output.replace(&format!("{old}:"), &format!("{target}:")); + } + } + output +} +fn adjust_priority(input: &str, priority: f64, constants: &IndexMap) -> f64 { + let mut adjusted = priority; + for m in var_pattern().find_iter(input) { + if !input[m.end()..].starts_with('{') { + continue; + } + let placeholder = m.as_str(); + let share = super::get_priority(placeholder); + if let Some(Value::String(value)) = constants.get(placeholder) { + if value.starts_with('@') && adjusted >= share { + adjusted += super::get_priority(value) - share; + } + } + } + adjusted +} +fn declaration(rule: &str) -> &str { + rule.rfind('{').map(|i| &rule[i..]).unwrap_or(rule) +} +fn declared_property(rule: &str) -> &str { + let decl = rule.rfind('{').map(|i| &rule[i + 1..]).unwrap_or(rule); + decl.find(':').map(|i| &decl[..i]).unwrap_or(decl) +} +fn compare_width( + a: Option<&super::media::WidthKey>, + b: Option<&super::media::WidthKey>, +) -> Ordering { + match (a, b) { + (None, None) => Ordering::Equal, + (None, Some(_)) => Ordering::Greater, + (Some(_), None) => Ordering::Less, + (Some(a), Some(b)) => locale_compare(&a.context, &b.context) + .then_with(|| b.min.cmp(&a.min)) + .then_with(|| { + if a.min { + a.value.total_cmp(&b.value) + } else { + b.value.total_cmp(&a.value) + } + }), + } +} +fn collect( + group: &[RuleEntry], + index: usize, + layers: bool, + config: &ProcessStylexRulesConfig, +) -> String { + let mut unique = IndexMap::<&str, &RuleFields>::new(); + for rule in group { + unique.insert(&rule.0, &rule.1); + } + static THEMES: OnceLock = OnceLock::new(); + let themes = + THEMES.get_or_init(|| Regex::new(r"\.([a-zA-Z0-9]+), \.([a-zA-Z0-9]+):root").unwrap()); + let prepare = |rule: &str| { + let rule = if !layers && !config.legacy_disable_layers { + super::add_specificity_level(rule, index) + } else { + rule.to_owned() + }; + themes + .replace_all(&rule, ".$1.$1, .$1.$1:root") + .into_owned() + }; + unique + .into_values() + .flat_map(|rule| { + let ltr = prepare(&rule.ltr); + match rule.rtl.as_ref().filter(|v| !v.is_empty()) { + None => vec![ltr], + Some(rtl) => { + let rtl = prepare(rtl); + if config.enable_ltr_rtl_comments { + vec![ + format!("/* @ltr begin */{ltr}/* @ltr end */"), + format!("/* @rtl begin */{rtl}/* @rtl end */"), + ] + } else { + vec![ + super::add_ancestor_selector(<r, "html:not([dir='rtl'])"), + super::add_ancestor_selector(&rtl, "html[dir='rtl']"), + ] + } + } + } + }) + .collect::>() + .join("\n") +} + +/// Babel's default localeCompare contracts use English variant collation, +/// punctuation significant, numeric sorting off and no case-first override. +/// Compiled ICU data is immutable; initialize the collator once per process. +pub(crate) fn locale_compare(a: &str, b: &str) -> Ordering { + use icu_collator::{ + options::{AlternateHandling, CaseLevel, CollatorOptions, Strength}, + Collator, CollatorBorrowed, + }; + static COLLATOR: OnceLock> = OnceLock::new(); + COLLATOR + .get_or_init(|| { + let mut options = CollatorOptions::default(); + options.strength = Some(Strength::Tertiary); + options.alternate_handling = Some(AlternateHandling::NonIgnorable); + options.case_level = Some(CaseLevel::Off); + Collator::try_new(icu_locale_core::locale!("en-US").into(), options) + .expect("compiled English collation data") + }) + .compare(a, b) +} diff --git a/packages/@stylexjs/rust-plugin/src/core/properties.rs b/packages/@stylexjs/rust-plugin/src/core/properties.rs new file mode 100644 index 000000000..17047768c --- /dev/null +++ b/packages/@stylexjs/rust-plugin/src/core/properties.rs @@ -0,0 +1,778 @@ +//! Property contracts from current Babel preprocess-rules and shared priorities. +use super::{CreateOptions, StyleValue}; + +/// Ordered application-order cancellation slots. Alias wrappers that only +/// rename their value deliberately do not recursively acquire new slots. +pub fn shorthand_cancellations(key: &str) -> &'static [&'static str] { + match key { + "animation" => &[ + "animationComposition", + "animationName", + "animationDuration", + "animationTimingFunction", + "animationDelay", + "animationIterationCount", + "animationDirection", + "animationFillMode", + "animationPlayState", + "animationRange", + "animationRangeEnd", + "animationRangeStart", + "animationTimeline", + ], + "animationRange" => &["animationRangeEnd", "animationRangeStart"], + "background" => &[ + "backgroundAttachment", + "backgroundClip", + "backgroundColor", + "backgroundImage", + "backgroundOrigin", + "backgroundPosition", + "backgroundPositionX", + "backgroundPositionY", + "backgroundRepeat", + "backgroundSize", + ], + "backgroundPosition" => &["backgroundPositionX", "backgroundPositionY"], + "border" => &[ + "borderWidth", + "borderInlineWidth", + "borderInlineStartWidth", + "borderLeftWidth", + "borderInlineEndWidth", + "borderRightWidth", + "borderBlockWidth", + "borderTopWidth", + "borderBottomWidth", + "borderStyle", + "borderInlineStyle", + "borderInlineStartStyle", + "borderLeftStyle", + "borderInlineEndStyle", + "borderRightStyle", + "borderBlockStyle", + "borderTopStyle", + "borderBottomStyle", + "borderColor", + "borderInlineColor", + "borderInlineStartColor", + "borderLeftColor", + "borderInlineEndColor", + "borderRightColor", + "borderBlockColor", + "borderTopColor", + "borderBottomColor", + ], + "borderInline" => &[ + "borderInlineWidth", + "borderInlineStartWidth", + "borderLeftWidth", + "borderInlineEndWidth", + "borderRightWidth", + "borderInlineStyle", + "borderInlineStartStyle", + "borderLeftStyle", + "borderInlineEndStyle", + "borderRightStyle", + "borderInlineColor", + "borderInlineStartColor", + "borderLeftColor", + "borderInlineEndColor", + "borderRightColor", + ], + "borderBlock" => &[ + "borderBlockWidth", + "borderTopWidth", + "borderBottomWidth", + "borderBlockStyle", + "borderTopStyle", + "borderBottomStyle", + "borderBlockColor", + "borderTopColor", + "borderBottomColor", + ], + "borderTop" => &["borderTopWidth", "borderTopStyle", "borderTopColor"], + "borderBottom" => &[ + "borderBottomWidth", + "borderBottomStyle", + "borderBottomColor", + ], + "borderInlineStart" => &[ + "borderInlineStartWidth", + "borderLeftWidth", + "borderRightWidth", + "borderInlineStartStyle", + "borderLeftStyle", + "borderRightStyle", + "borderInlineStartColor", + "borderLeftColor", + "borderRightColor", + ], + "borderInlineEnd" => &[ + "borderInlineEndWidth", + "borderLeftWidth", + "borderRightWidth", + "borderInlineEndStyle", + "borderLeftStyle", + "borderRightStyle", + "borderInlineEndColor", + "borderLeftColor", + "borderRightColor", + ], + "borderLeft" => &[ + "borderLeftWidth", + "borderInlineStartWidth", + "borderInlineEndWidth", + "borderLeftStyle", + "borderInlineStartStyle", + "borderInlineEndStyle", + "borderLeftColor", + "borderInlineStartColor", + "borderInlineEndColor", + ], + "borderRight" => &[ + "borderRightWidth", + "borderInlineStartWidth", + "borderInlineEndWidth", + "borderRightStyle", + "borderInlineStartStyle", + "borderInlineEndStyle", + "borderRightColor", + "borderInlineStartColor", + "borderInlineEndColor", + ], + "borderInlineWidth" => &[ + "borderInlineStartWidth", + "borderLeftWidth", + "borderInlineEndWidth", + "borderRightWidth", + ], + "borderInlineStyle" => &[ + "borderInlineStartStyle", + "borderLeftStyle", + "borderInlineEndStyle", + "borderRightStyle", + ], + "borderInlineColor" => &[ + "borderInlineStartColor", + "borderLeftColor", + "borderInlineEndColor", + "borderRightColor", + ], + "borderBlockWidth" => &["borderTopWidth", "borderBottomWidth"], + "borderBlockStyle" => &["borderTopStyle", "borderBottomStyle"], + "borderBlockColor" => &["borderTopColor", "borderBottomColor"], + "borderColor" => &[ + "borderInlineColor", + "borderInlineStartColor", + "borderLeftColor", + "borderInlineEndColor", + "borderRightColor", + "borderBlockColor", + "borderTopColor", + "borderBottomColor", + ], + "borderStyle" => &[ + "borderInlineStyle", + "borderInlineStartStyle", + "borderLeftStyle", + "borderInlineEndStyle", + "borderRightStyle", + "borderBlockStyle", + "borderTopStyle", + "borderBottomStyle", + ], + "borderWidth" => &[ + "borderInlineWidth", + "borderInlineStartWidth", + "borderLeftWidth", + "borderInlineEndWidth", + "borderRightWidth", + "borderBlockWidth", + "borderTopWidth", + "borderBottomWidth", + ], + "borderInlineStartColor" | "borderInlineEndColor" => { + &["borderLeftColor", "borderRightColor"] + } + "borderInlineStartStyle" | "borderInlineEndStyle" => { + &["borderLeftStyle", "borderRightStyle"] + } + "borderInlineStartWidth" | "borderInlineEndWidth" => { + &["borderLeftWidth", "borderRightWidth"] + } + "borderLeftColor" | "borderRightColor" => { + &["borderInlineStartColor", "borderInlineEndColor"] + } + "borderLeftStyle" | "borderRightStyle" => { + &["borderInlineStartStyle", "borderInlineEndStyle"] + } + "borderLeftWidth" | "borderRightWidth" => { + &["borderInlineStartWidth", "borderInlineEndWidth"] + } + "borderRadius" => &[ + "borderStartStartRadius", + "borderStartEndRadius", + "borderEndStartRadius", + "borderEndEndRadius", + "borderTopLeftRadius", + "borderTopRightRadius", + "borderBottomLeftRadius", + "borderBottomRightRadius", + ], + "borderStartStartRadius" | "borderStartEndRadius" => { + &["borderTopLeftRadius", "borderTopRightRadius"] + } + "borderEndStartRadius" | "borderEndEndRadius" => { + &["borderBottomLeftRadius", "borderBottomRightRadius"] + } + "borderTopLeftRadius" | "borderTopRightRadius" => { + &["borderStartStartRadius", "borderStartEndRadius"] + } + "borderBottomLeftRadius" | "borderBottomRightRadius" => { + &["borderEndStartRadius", "borderEndEndRadius"] + } + "cornerShape" => &[ + "cornerStartStartShape", + "cornerStartEndShape", + "cornerEndStartShape", + "cornerEndEndShape", + "cornerTopLeftShape", + "cornerTopRightShape", + "cornerBottomLeftShape", + "cornerBottomRightShape", + ], + "cornerStartStartShape" | "cornerStartEndShape" => { + &["cornerTopLeftShape", "cornerTopRightShape"] + } + "cornerEndStartShape" | "cornerEndEndShape" => { + &["cornerBottomLeftShape", "cornerBottomRightShape"] + } + "cornerTopLeftShape" | "cornerTopRightShape" => { + &["cornerStartStartShape", "cornerStartEndShape"] + } + "cornerBottomLeftShape" | "cornerBottomRightShape" => { + &["cornerEndStartShape", "cornerEndEndShape"] + } + "borderImage" => &[ + "borderImageOutset", + "borderImageRepeat", + "borderImageSlice", + "borderImageSource", + "borderImageWidth", + ], + "columnRule" => &["columnRuleColor", "columnRuleStyle", "columnRuleWidth"], + "columns" => &["columnCount", "columnWidth"], + "container" => &["containerName", "containerType"], + "containIntrinsicSize" => &["containIntrinsicWidth", "containIntrinsicHeight"], + "flex" => &["flexGrow", "flexShrink", "flexBasis"], + "flexFlow" => &["flexDirection", "flexWrap"], + "font" => &[ + "fontFamily", + "fontSize", + "fontStretch", + "fontStyle", + "fontVariant", + "fontVariantAlternates", + "fontVariantCaps", + "fontVariantEastAsian", + "fontVariantEmoji", + "fontVariantLigatures", + "fontVariantNumeric", + "fontVariantPosition", + "fontWeight", + "lineHeight", + ], + "fontVariant" => &[ + "fontVariantAlternates", + "fontVariantCaps", + "fontVariantEastAsian", + "fontVariantEmoji", + "fontVariantLigatures", + "fontVariantNumeric", + "fontVariantPosition", + ], + "gap" => &["rowGap", "columnGap"], + "grid" => &[ + "gridTemplate", + "gridTemplateAreas", + "gridTemplateColumns", + "gridTemplateRows", + "gridAutoRows", + "gridAutoColumns", + "gridAutoFlow", + ], + "gridArea" => &[ + "gridRow", + "gridRowStart", + "gridRowEnd", + "gridColumn", + "gridColumnStart", + "gridColumnEnd", + ], + "gridRow" => &["gridRowStart", "gridRowEnd"], + "gridColumn" => &["gridColumnStart", "gridColumnEnd"], + "gridTemplate" => &[ + "gridTemplateAreas", + "gridTemplateColumns", + "gridTemplateRows", + ], + "inset" => &[ + "insetInline", + "insetInlineStart", + "insetInlineEnd", + "left", + "right", + "insetBlock", + "top", + "bottom", + ], + "insetInline" => &["insetInlineStart", "insetInlineEnd", "left", "right"], + "insetBlock" => &["top", "bottom"], + "insetInlineStart" | "insetInlineEnd" => &["left", "right"], + "left" | "right" => &["insetInlineStart", "insetInlineEnd"], + "listStyle" => &["listStyleImage", "listStylePosition", "listStyleType"], + "margin" => &[ + "marginInline", + "marginInlineStart", + "marginLeft", + "marginInlineEnd", + "marginRight", + "marginBlock", + "marginTop", + "marginBottom", + ], + "marginInline" => &[ + "marginInlineStart", + "marginLeft", + "marginInlineEnd", + "marginRight", + ], + "marginBlock" => &["marginTop", "marginBottom"], + "marginInlineStart" | "marginInlineEnd" => &["marginLeft", "marginRight"], + "marginLeft" | "marginRight" => &["marginInlineStart", "marginInlineEnd"], + "mask" => &[ + "maskClip", + "maskComposite", + "maskImage", + "maskMode", + "maskOrigin", + "maskPosition", + "maskRepeat", + "maskSize", + ], + "maskBorder" => &[ + "maskBorderMode", + "maskBorderOutset", + "maskBorderRepeat", + "maskBorderSlice", + "maskBorderSource", + "maskBorderWidth", + ], + "offset" => &[ + "offsetAnchor", + "offsetDistance", + "offsetPath", + "offsetPosition", + "offsetRotate", + ], + "outline" => &[ + "outlineColor", + "outlineOffset", + "outlineStyle", + "outlineWidth", + ], + "overflow" => &["overflowX", "overflowY"], + "padding" => &[ + "paddingInline", + "paddingStart", + "paddingLeft", + "paddingEnd", + "paddingRight", + "paddingBlock", + "paddingTop", + "paddingBottom", + ], + "paddingInline" => &["paddingStart", "paddingLeft", "paddingEnd", "paddingRight"], + "paddingBlock" => &["paddingTop", "paddingBottom"], + "paddingInlineStart" | "paddingInlineEnd" => &["paddingLeft", "paddingRight"], + "paddingLeft" | "paddingRight" => &["paddingInlineStart", "paddingInlineEnd"], + "placeContent" => &["alignContent", "justifyContent"], + "placeItems" => &["alignItems", "justifyItems"], + "placeSelf" => &["alignSelf", "justifySelf"], + "scrollMargin" => &[ + "scrollMarginBlock", + "scrollMarginTop", + "scrollMarginBottom", + "scrollMarginInline", + "scrollMarginInlineStart", + "scrollMarginInlineEnd", + "scrollMarginLeft", + "scrollMarginRight", + ], + "scrollMarginBlock" => &["scrollMarginTop", "scrollMarginBottom"], + "scrollMarginInline" => &[ + "scrollMarginInlineStart", + "scrollMarginInlineEnd", + "scrollMarginLeft", + "scrollMarginRight", + ], + "scrollMarginInlineStart" | "scrollMarginInlineEnd" => { + &["scrollMarginLeft", "scrollMarginRight"] + } + "scrollMarginLeft" | "scrollMarginRight" => { + &["scrollMarginInlineStart", "scrollMarginInlineEnd"] + } + "scrollPadding" => &[ + "scrollPaddingBlock", + "scrollPaddingTop", + "scrollPaddingBottom", + "scrollPaddingInline", + "scrollPaddingInlineStart", + "scrollPaddingInlineEnd", + "scrollPaddingLeft", + "scrollPaddingRight", + ], + "scrollPaddingBlock" => &["scrollPaddingTop", "scrollPaddingBottom"], + "scrollPaddingInline" => &[ + "scrollPaddingInlineStart", + "scrollPaddingInlineEnd", + "scrollPaddingLeft", + "scrollPaddingRight", + ], + "scrollPaddingInlineStart" | "scrollPaddingInlineEnd" => { + &["scrollPaddingLeft", "scrollPaddingRight"] + } + "scrollPaddingLeft" | "scrollPaddingRight" => { + &["scrollPaddingInlineStart", "scrollPaddingInlineEnd"] + } + "scrollSnapType" => &["scrollSnapTypeX", "scrollSnapTypeY"], + "scrollTimeline" => &["scrollTimelineName", "scrollTimelineAxis"], + "textDecoration" => &[ + "textDecorationColor", + "textDecorationLine", + "textDecorationStyle", + "textDecorationThickness", + ], + "textEmphasis" => &["textEmphasisColor", "textEmphasisStyle"], + "transition" => &[ + "transitionBehavior", + "transitionDelay", + "transitionDuration", + "transitionProperty", + "transitionTimingFunction", + ], + "borderHorizontal" + | "borderVertical" + | "borderBlockStart" + | "borderEnd" + | "borderBlockEnd" + | "borderStart" + | "borderHorizontalWidth" + | "borderHorizontalStyle" + | "borderHorizontalColor" + | "borderVerticalWidth" + | "borderVerticalStyle" + | "borderVerticalColor" + | "borderStartColor" + | "borderEndColor" + | "borderStartStyle" + | "borderEndStyle" + | "borderStartWidth" + | "borderEndWidth" + | "gridGap" + | "marginStart" + | "marginEnd" + | "marginHorizontal" + | "marginVertical" + | "paddingStart" + | "paddingEnd" + | "paddingHorizontal" + | "paddingVertical" + | "start" + | "end" => shorthand_cancellations(&canonical_property_name(key)), + _ => &[], + } +} + +pub fn canonical_property_name(key: &str) -> String { + match key { + "borderHorizontal" => "borderInline", + "borderVertical" => "borderBlock", + "borderBlockStart" => "borderTop", + "borderBlockEnd" => "borderBottom", + "borderStart" => "borderInlineStart", + "borderEnd" => "borderInlineEnd", + "blockSize" => "height", + "inlineSize" => "width", + "minBlockSize" => "minHeight", + "minInlineSize" => "minWidth", + "maxBlockSize" => "maxHeight", + "maxInlineSize" => "maxWidth", + "borderHorizontalWidth" => "borderInlineWidth", + "borderHorizontalStyle" => "borderInlineStyle", + "borderHorizontalColor" => "borderInlineColor", + "borderVerticalWidth" => "borderBlockWidth", + "borderVerticalStyle" => "borderBlockStyle", + "borderVerticalColor" => "borderBlockColor", + "borderBlockStartColor" => "borderTopColor", + "borderBlockEndColor" => "borderBottomColor", + "borderBlockStartStyle" => "borderTopStyle", + "borderBlockEndStyle" => "borderBottomStyle", + "borderBlockStartWidth" => "borderTopWidth", + "borderBlockEndWidth" => "borderBottomWidth", + "borderStartColor" => "borderInlineStartColor", + "borderEndColor" => "borderInlineEndColor", + "borderStartStyle" => "borderInlineStartStyle", + "borderEndStyle" => "borderInlineEndStyle", + "borderStartWidth" => "borderInlineStartWidth", + "borderEndWidth" => "borderInlineEndWidth", + "borderTopStartRadius" => "borderStartStartRadius", + "borderTopEndRadius" => "borderStartEndRadius", + "borderBottomStartRadius" => "borderEndStartRadius", + "borderBottomEndRadius" => "borderEndEndRadius", + "containIntrinsicBlockSize" => "containIntrinsicHeight", + "containIntrinsicInlineSize" => "containIntrinsicWidth", + "gridGap" => "gap", + "gridRowGap" => "rowGap", + "gridColumnGap" => "columnGap", + "marginBlockStart" => "marginTop", + "marginBlockEnd" => "marginBottom", + "marginStart" => "marginInlineStart", + "marginEnd" => "marginInlineEnd", + "marginHorizontal" => "marginInline", + "marginVertical" => "marginBlock", + "overflowBlock" => "overflowY", + "overflowInline" => "overflowX", + "paddingBlockStart" => "paddingTop", + "paddingBlockEnd" => "paddingBottom", + "paddingStart" => "paddingInlineStart", + "paddingEnd" => "paddingInlineEnd", + "paddingHorizontal" => "paddingInline", + "paddingVertical" => "paddingBlock", + "scrollMarginBlockStart" => "scrollMarginTop", + "scrollMarginBlockEnd" => "scrollMarginBottom", + "insetBlockStart" => "top", + "insetBlockEnd" => "bottom", + "start" => "insetInlineStart", + "end" => "insetInlineEnd", + _ => key, + } + .to_owned() +} + +pub(crate) fn expand_property( + key: &str, + value: &StyleValue, + options: &CreateOptions, +) -> Result, String> { + let error = match (options.style_resolution.as_str(), key) { + (_, "all") => Some("all is not supported"), + ("property-specificity", "animation") => Some("animation is not supported"), + ("property-specificity", "background") => Some("background is not supported. Use background-color, border-image etc. instead."), + ("property-specificity", "border") => Some("border is not supported. Use border-width, border-style and border-color instead."), + ("property-specificity", "borderInline" | "borderHorizontal") => Some("borderInline is not supported. Use borderInlineWidth, borderInlineStyle and borderInlineColor instead."), + ("property-specificity", "borderBlock" | "borderVertical") => Some("borderBlock is not supported. Use borderBlockWidth, borderBlockStyle and borderBlockColor instead."), + ("property-specificity", "borderTop" | "borderBlockStart") => Some("borderTop is not supported. Use borderTopWidth, borderTopStyle and borderTopColor instead."), + ("property-specificity", "borderInlineEnd" | "borderEnd") => Some("borderInlineEnd is not supported. Use borderInlineEndWidth, borderInlineEndStyle and borderInlineEndColor instead."), + ("property-specificity", "borderRight") => Some("borderRight is not supported. Use borderRightWidth, borderRightStyle and borderRightColor instead."), + ("property-specificity", "borderBottom" | "borderBlockEnd") => Some("borderBottom is not supported. Use borderBottomWidth, borderBottomStyle and borderBottomColor instead."), + ("property-specificity", "borderInlineStart" | "borderStart") => Some("borderInlineStart is not supported. Use borderInlineStartWidth, borderInlineStartStyle and borderInlineStartColor instead."), + ("property-specificity", "borderLeft") => Some("`borderLeft` is not supported. You could use `borderLeftWidth`, `borderLeftStyle` and `borderLeftColor`, but it is preferable to use `borderInlineStartWidth`, `borderInlineStartStyle` and `borderInlineStartColor`."), + _ => None, + }; + if let Some(error) = error { + return if options.property_validation_mode == "throw" { + Err(error.to_owned()) + } else { + Ok(Vec::new()) + }; + } + // property-specificity has no grid gap aliases; the legacy names are kept. + let name = match (options.style_resolution.as_str(), key) { + ("property-specificity", "gridGap" | "gridRowGap" | "gridColumnGap") => key.to_owned(), + _ => canonical_property_name(key), + }; + let mut result = vec![(name, value.clone())]; + if options.style_resolution == "application-order" { + result.extend( + shorthand_cancellations(key) + .iter() + .map(|key| ((*key).to_owned(), StyleValue::Null)), + ); + } + Ok(result) +} + +pub(crate) fn default_priority(key: &str) -> f64 { + match key { + "animation" | "background" | "border" | "border-block" | "border-inline" | "margin" + | "padding" | "font" | "grid" | "grid-template" | "grid-area" | "all" | "inset" + | "scroll-margin" | "scroll-padding" => 1000.0, + "animation-range" + | "scroll-timeline" + | "view-timeline" + | "background-position" + | "border-color" + | "border-style" + | "border-width" + | "border-block-start" + | "border-top" + | "border-block-end" + | "border-bottom" + | "border-inline-color" + | "border-inline-style" + | "border-inline-width" + | "border-inline-start" + | "border-left" + | "border-inline-end" + | "border-right" + | "border-image" + | "border-radius" + | "corner-shape" + | "caret" + | "outline" + | "grid-gap" + | "gap" + | "place-content" + | "place-items" + | "place-self" + | "margin-block" + | "margin-inline" + | "overscroll-behavior" + | "padding-block" + | "padding-inline" + | "columns" + | "column-rule" + | "contain-intrinsic-size" + | "container" + | "flex" + | "flex-flow" + | "font-variant" + | "grid-template-areas" + | "grid-row" + | "grid-column" + | "list-style" + | "mask" + | "mask-border" + | "offset" + | "overflow" + | "inset-block" + | "inset-inline" + | "scroll-margin-block" + | "scroll-margin-inline" + | "scroll-padding-block" + | "scroll-padding-inline" + | "scroll-snap-type" + | "text-decoration" + | "text-emphasis" + | "transition" => 2000.0, + "border-top-color" + | "border-top-style" + | "border-top-width" + | "border-bottom-color" + | "border-bottom-style" + | "border-bottom-width" + | "border-left-color" + | "border-left-style" + | "border-left-width" + | "border-right-color" + | "border-right-style" + | "border-right-width" + | "border-top-left-radius" + | "border-top-right-radius" + | "border-bottom-left-radius" + | "border-bottom-right-radius" + | "corner-top-left-shape" + | "corner-top-right-shape" + | "corner-bottom-left-shape" + | "corner-bottom-right-shape" + | "height" + | "width" + | "max-height" + | "max-width" + | "min-height" + | "min-width" + | "margin-top" + | "margin-bottom" + | "margin-left" + | "margin-right" + | "overscroll-behavior-y" + | "overscroll-behavior-x" + | "padding-top" + | "padding-bottom" + | "padding-left" + | "padding-right" + | "overflow-y" + | "overflow-x" + | "top" + | "bottom" + | "left" + | "right" + | "scroll-margin-top" + | "scroll-margin-bottom" + | "scroll-margin-left" + | "scroll-margin-right" + | "scroll-padding-top" + | "scroll-padding-bottom" + | "scroll-padding-left" + | "scroll-padding-right" => 4000.0, + _ => 3000.0, + } +} + +pub(crate) fn pseudo_priority(key: &str) -> f64 { + match key { + ":is" | ":where" | ":not" => 40.0, + ":has" => 45.0, + ":dir" => 50.0, + ":lang" => 51.0, + ":first-child" => 52.0, + ":first-of-type" => 53.0, + ":last-child" => 54.0, + ":last-of-type" => 55.0, + ":only-child" => 56.0, + ":only-of-type" => 57.0, + ":nth-child" => 60.0, + ":nth-last-child" => 61.0, + ":nth-of-type" => 62.0, + ":nth-last-of-type" => 63.0, + ":empty" => 70.0, + ":link" => 80.0, + ":any-link" => 81.0, + ":local-link" => 82.0, + ":target-within" => 83.0, + ":target" => 84.0, + ":visited" => 85.0, + ":enabled" => 91.0, + ":disabled" => 92.0, + ":required" => 93.0, + ":optional" => 94.0, + ":read-only" => 95.0, + ":read-write" => 96.0, + ":placeholder-shown" => 97.0, + ":in-range" => 98.0, + ":out-of-range" => 99.0, + ":default" => 100.0, + ":checked" | ":indeterminate" => 101.0, + ":blank" => 102.0, + ":valid" => 103.0, + ":invalid" => 104.0, + ":user-invalid" => 105.0, + ":autofill" => 110.0, + ":picture-in-picture" => 120.0, + ":modal" => 121.0, + ":fullscreen" => 122.0, + ":paused" => 123.0, + ":playing" => 124.0, + ":current" => 125.0, + ":past" => 126.0, + ":future" => 127.0, + ":hover" => 130.0, + ":focusWithin" => 140.0, + ":focus" => 150.0, + ":focusVisible" => 160.0, + ":active" => 170.0, + _ => 40.0, + } +} diff --git a/packages/@stylexjs/rust-plugin/src/core/validate.rs b/packages/@stylexjs/rust-plugin/src/core/validate.rs new file mode 100644 index 000000000..ce643d951 --- /dev/null +++ b/packages/@stylexjs/rust-plugin/src/core/validate.rs @@ -0,0 +1,76 @@ +//! Pure value validation from Babel preprocess-rules/basic-validation.js. +use super::StyleValue; +use indexmap::IndexMap; + +pub(crate) fn namespace( + style: &IndexMap, + conditions: &[String], +) -> Result<(), String> { + for (key, value) in style { + match value { + StyleValue::Null | StyleValue::String(_) | StyleValue::Number(_) => {} + StyleValue::Array(values) => { + if values + .iter() + .any(|v| matches!(v, StyleValue::Array(_) | StyleValue::Object(_))) + { + return Err( + "A style array value can only contain strings or numbers.".to_owned() + ); + } + } + StyleValue::Object(value) => { + if key.starts_with('@') || key.starts_with(':') || key.starts_with('[') { + if conditions.contains(key) { + return Err( + "The same pseudo selector or at-rule cannot be used more than once." + .to_owned(), + ); + } + let mut next = conditions.to_vec(); + next.push(key.clone()); + namespace(value, &next)?; + } else { + conditional(value, &[])?; + } + } + } + } + Ok(()) +} +fn conditional(style: &IndexMap, conditions: &[String]) -> Result<(), String> { + for (key, value) in style { + if !(key.starts_with('@') + || key.starts_with(':') + || key.starts_with('[') + || key.starts_with("var(--") + || key == "default") + { + return Err("Invalid pseudo or at-rule.".to_owned()); + } + if conditions.contains(key) { + return Err( + "The same pseudo selector or at-rule cannot be used more than once.".to_owned(), + ); + } + match value { + StyleValue::Null | StyleValue::String(_) | StyleValue::Number(_) => {} + StyleValue::Array(values) => { + if values + .iter() + .any(|v| matches!(v, StyleValue::Array(_) | StyleValue::Object(_))) + { + return Err( + "A style value can only contain an array, string or number.".to_owned() + ); + } + } + StyleValue::Object(value) => { + let mut next = conditions.to_vec(); + next.push(key.clone()); + conditional(value, &next)?; + } + } + } + Ok(()) +} diff --git a/packages/@stylexjs/rust-plugin/src/core/values.rs b/packages/@stylexjs/rust-plugin/src/core/values.rs new file mode 100644 index 000000000..b4a057983 --- /dev/null +++ b/packages/@stylexjs/rust-plugin/src/core/values.rs @@ -0,0 +1,644 @@ +//! CSS-value lexical normalization; never run numeric rewrites over strings or URLs. +//! Mirrors Babel shared/utils/transform-value and its ordered AST normalizers. +use super::{CreateOptions, StyleValue}; +use regex::Regex; +use std::sync::OnceLock; + +#[derive(Clone, Debug)] +pub(crate) enum Token { + Word(String), + Space, + Div(char), + String(char, String), + Comment(String), + Function(String, Vec), + Url(String, String), +} + +fn whitespace(c: char) -> bool { + matches!(c, ' ' | '\t' | '\n' | '\r' | '\u{c}') +} +fn next(input: &str, at: usize) -> Option<(char, usize)> { + input + .get(at..)? + .chars() + .next() + .map(|c| (c, at + c.len_utf8())) +} + +pub(crate) fn tokenize(input: &str) -> Result, String> { + let mut at = 0; + let tokens = tokens_until(input, &mut at, false, false)?; + Ok(tokens) +} + +/// `calc` mirrors postcss-value-parser: directly inside a function named exactly +/// `calc`, `/` and `*` are operator words rather than dividers. +fn tokens_until( + input: &str, + at: &mut usize, + function: bool, + calc: bool, +) -> Result, String> { + let mut out = Vec::new(); + while let Some((c, end)) = next(input, *at) { + if c == ')' && function { + *at = end; + return Ok(out); + } + if whitespace(c) { + *at = end; + while let Some((c, end)) = next(input, *at) { + if !whitespace(c) { + break; + } + *at = end; + } + out.push(Token::Space); + } else if input[*at..].starts_with("/*") { + let begin = *at + 2; + if let Some(end) = input[begin..].find("*/") { + out.push(Token::Comment(input[begin..begin + end].to_owned())); + *at = begin + end + 2; + } else { + out.push(Token::Comment(input[begin..].to_owned())); + *at = input.len(); + } + } else if c == '\'' || c == '"' { + let begin = end; + *at = end; + let mut closed = false; + while let Some((v, end)) = next(input, *at) { + if v == c { + out.push(Token::String(c, input[begin..*at].to_owned())); + *at = end; + closed = true; + break; + } + *at = end; + if v == '\\' { + if let Some((_, end)) = next(input, *at) { + *at = end; + } + } + } + if !closed { + return Err(if function { + "Rule contains an unclosed function" + } else { + "Rule contains an unclosed string" + } + .to_owned()); + } + } else if calc && matches!(c, '/' | '*') { + out.push(Token::Word(c.to_string())); + *at = end; + } else if matches!(c, ',' | '/' | ':') { + out.push(Token::Div(c)); + *at = end; + } else if c == '(' { + *at = end; + out.push(Token::Function( + String::new(), + tokens_until(input, at, true, false)?, + )); + } else { + let begin = *at; + *at = end; + if c == '\\' { + if let Some((_, end)) = next(input, *at) { + *at = end; + } + } + while let Some((v, end)) = next(input, *at) { + if whitespace(v) + || matches!(v, '(' | ')' | ',' | '/' | ':' | '\'' | '"') + || (calc && v == '*') + { + break; + } + *at = end; + if v == '\\' { + if let Some((_, end)) = next(input, *at) { + *at = end; + } + } + } + let word = input[begin..*at].to_owned(); + if input[*at..].starts_with('(') { + *at += 1; + if word.eq_ignore_ascii_case("url") { + let begin = *at; + let mut quote = None; + let mut closed = false; + while let Some((v, end)) = next(input, *at) { + if v == ')' && quote.is_none() { + out.push(Token::Url( + word.clone(), + input[begin..*at].trim_matches(whitespace).to_owned(), + )); + *at = end; + closed = true; + break; + } + if v == '\'' || v == '"' { + if quote == Some(v) { + quote = None; + } else if quote.is_none() { + quote = Some(v); + } + } + *at = end; + if v == '\\' { + if let Some((_, end)) = next(input, *at) { + *at = end; + } + } + } + if !closed { + return Err("Rule contains an unclosed function".to_owned()); + } + } else { + let calc = word == "calc"; + out.push(Token::Function(word, tokens_until(input, at, true, calc)?)); + } + } else { + out.push(Token::Word(word)); + } + } + } + if function { + Err("Rule contains an unclosed function".to_owned()) + } else { + Ok(out) + } +} + +pub(crate) fn js_number(value: f64) -> String { + ryu_js::Buffer::new().format(value).to_owned() +} + +pub(crate) fn dimension(value: &str) -> Option<(&str, &str)> { + static NUMBER: OnceLock = OnceLock::new(); + let re = NUMBER.get_or_init(|| { + Regex::new(r"^[+-]?(?:[0-9]*\.[0-9]+|[0-9]+\.?[0-9]*)(?:[eE][+-]?[0-9]+)?").unwrap() + }); + let number = re.find(value)?; + Some((&value[..number.end()], &value[number.end()..])) +} + +pub fn get_number_suffix(key: &str) -> &'static str { + if key.starts_with("--") + || matches!( + key, + "WebkitLineClamp" + | "animationIterationCount" + | "aspectRatio" + | "borderImageOutset" + | "borderImageSlice" + | "borderImageWidth" + | "counterSet" + | "counterReset" + | "columnCount" + | "flex" + | "flexGrow" + | "flexShrink" + | "flexOrder" + | "gridRow" + | "gridRowStart" + | "gridRowEnd" + | "gridColumn" + | "gridColumnStart" + | "gridColumnEnd" + | "gridArea" + | "fontSizeAdjust" + | "fontWeight" + | "hyphenateLimitChars" + | "lineClamp" + | "lineHeight" + | "maskBorderOutset" + | "maskBorderSlice" + | "maskBorderWidth" + | "opacity" + | "order" + | "orphans" + | "tabSize" + | "widows" + | "zIndex" + | "fillOpacity" + | "floodOpacity" + | "rotate" + | "scale" + | "shapeImageThreshold" + | "stopOpacity" + | "strokeDasharray" + | "strokeDashoffset" + | "strokeMiterlimit" + | "strokeOpacity" + | "strokeWidth" + | "mathDepth" + | "zoom" + ) + { + "" + } else if matches!( + key, + "animationDelay" + | "animationDuration" + | "transitionDelay" + | "transitionDuration" + | "voiceDuration" + ) { + "ms" + } else { + "px" + } +} + +pub fn dynamic_number_suffix(key: &str) -> &'static str { + let suffix = get_number_suffix(key); + if is_length_property(key) || suffix == "ms" { + suffix + } else { + "" + } +} + +pub fn is_length_property(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" + | "outlineOffset" + | "outlineWidth" + | "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" + | "textIndent" + | "textUnderlineOffset" + | "top" + | "verticalAlign" + | "width" + | "wordSpacing" + | "offsetDistance" + | "overflowClipMargin" + | "scrollSnapMarginBottom" + | "scrollSnapMarginLeft" + | "scrollSnapMarginRight" + | "scrollSnapMarginTop" + | "shapeMargin" + | "tabSize" + | "textDecorationThickness" + | "transformOrigin" + | "translate" + | "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" + ) +} + +pub fn transform_value( + key: &str, + value: &StyleValue, + options: &CreateOptions, +) -> Result { + let raw = match value { + StyleValue::String(value) => value.clone(), + StyleValue::Number(value) => { + // JS Math.round ties toward +infinity, unlike f64::round for negatives. + let scaled = *value * 10000.0; + let rounded = if scaled.is_finite() { + let floor = scaled.floor(); + (if scaled - floor < 0.5 { + floor + } else { + floor + 1.0 + }) / 10000.0 + } else { + scaled / 10000.0 + }; + format!("{}{}", js_number(rounded), get_number_suffix(key)) + } + _ => return Err(format!("Invalid value for property {key}")), + }; + if matches!( + key, + "content" | "hyphenateCharacter" | "hyphenate-character" + ) { + return Ok(quote_content(&raw)); + } + let mut tokens = tokenize(&raw)?; + normalize_tokens(&mut tokens, key, options, false, true); + Ok(render(&tokens)) +} + +fn normalize_tokens( + tokens: &mut Vec, + key: &str, + options: &CreateOptions, + in_function: bool, + top: bool, +) { + while matches!(tokens.first(), Some(Token::Space)) { + tokens.remove(0); + } + while matches!(tokens.last(), Some(Token::Space)) { + tokens.pop(); + } + let mut out = Vec::new(); + for token in std::mem::take(tokens) { + match token { + Token::Space => { + if !matches!(out.last(), Some(Token::Space | Token::Div(_))) { + out.push(Token::Space); + } + } + Token::Div(c) => { + if matches!(out.last(), Some(Token::Space)) { + out.pop(); + } + out.push(Token::Div(c)); + } + Token::Word(mut word) => { + if word == "!important" && top && matches!(out.last(), Some(Token::Space)) { + out.pop(); + } + if let Some((number, unit)) = + dimension(&word).map(|(number, unit)| (number.to_owned(), unit.to_owned())) + { + if let Ok(mut value) = number.parse::() { + let mut unit = unit.to_owned(); + if unit == "ms" && value >= 10.0 { + value /= 1000.0; + unit = "s".to_owned(); + word = format!("{}s", js_number(value)); + } + if !key.starts_with("--") && number == "0" { + match unit.as_str() { + "deg" | "grad" | "turn" | "rad" => { + word = "0deg".to_owned(); + unit = "deg".to_owned(); + } + "ms" | "s" => { + word = "0s".to_owned(); + unit = "s".to_owned(); + } + "fr" => word = "0fr".to_owned(), + "%" => word = "0%".to_owned(), + _ if !in_function => { + word = "0".to_owned(); + unit.clear(); + } + _ => {} + } + } + if (0.0..1.0).contains(&value) { + word = format!("{}{}", js_number(value).replacen("0.", ".", 1), unit); + } + } + } + if top + && matches!(key, "transitionProperty" | "willChange") + && !word.starts_with("--") + { + word = super::dashify(&word); + } + // This normalizer is last, after leading-zero removal. + if key == "fontSize" && options.enable_font_size_px_to_rem { + if let Some((number, "px")) = dimension(&word) { + if let Ok(value) = number.parse::() { + word = format!("{}rem", js_number(value / 16.0)); + } + } + } + out.push(Token::Word(word)); + } + Token::Function(name, mut children) => { + normalize_tokens(&mut children, key, options, true, false); + out.push(Token::Function(name, children)); + } + Token::String(quote, value) => out.push(Token::String( + if value.is_empty() { '"' } else { quote }, + value, + )), + Token::Url(name, raw) => out.push(Token::Url( + name, + if raw == "''" { "\"\"".to_owned() } else { raw }, + )), + token => out.push(token), + } + } + *tokens = out; +} + +pub(crate) fn render(tokens: &[Token]) -> String { + tokens + .iter() + .map(|token| match token { + Token::Word(word) => word.clone(), + Token::Space => " ".to_owned(), + Token::Div(',') => ",".to_owned(), + Token::Div(c) => format!(" {c} "), + Token::String(quote, value) => format!("{quote}{value}{quote}"), + Token::Comment(value) => format!("/*{value}*/"), + Token::Function(name, children) => format!("{name}({})", render(children)), + Token::Url(name, raw) => format!("{name}({raw})"), + }) + .collect() +} + +fn content_keyword(value: &str) -> bool { + matches!( + value, + "normal" + | "none" + | "open-quote" + | "close-quote" + | "no-open-quote" + | "no-close-quote" + | "inherit" + | "initial" + | "revert" + | "revert-layer" + | "unset" + ) +} +fn quote_content(raw: &str) -> String { + let value = raw.trim(); + if content_keyword(value) + || [ + "attr(", + "counter(", + "counters(", + "url(", + "linear-gradient(", + "image-set(", + "var(--", + ] + .iter() + .any(|function| value.contains(function)) + { + return value.to_owned(); + } + if let Ok(tokens) = tokenize(value) { + let list: Vec<_> = tokens + .iter() + .filter(|t| !matches!(t, Token::Space | Token::Div(_))) + .collect(); + if list.iter().any(|t| matches!(t, Token::String(_, _))) + && list.iter().all(|t| match t { + Token::String(_, _) | Token::Function(_, _) | Token::Url(_, _) => true, + Token::Word(value) => content_keyword(value), + _ => false, + }) + { + return value.to_owned(); + } + } + let mut escaped = String::new(); + let mut chars = value.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '\\' => { + escaped.push('\\'); + if let Some(next) = chars.next() { + escaped.push(next); + if next == '\r' && chars.peek() == Some(&'\n') { + escaped.push(chars.next().unwrap()); + } + } else { + escaped.push('\\'); + } + } + '"' => escaped.push_str("\\\""), + '\r' => { + if chars.peek() == Some(&'\n') { + chars.next(); + } + escaped.push_str("\\A "); + } + '\n' | '\u{c}' => escaped.push_str("\\A "), + _ => escaped.push(c), + } + } + format!("\"{escaped}\"") +} diff --git a/packages/@stylexjs/rust-plugin/src/lib.rs b/packages/@stylexjs/rust-plugin/src/lib.rs new file mode 100644 index 000000000..9cf27b29d --- /dev/null +++ b/packages/@stylexjs/rust-plugin/src/lib.rs @@ -0,0 +1,24 @@ +//! Native StyleX compiler. The JavaScript Babel plugin remains the compatibility oracle. + +mod core; +mod shared; +mod utils; +mod visitors; + +// N-API registrations only exist in the cdylib; unit tests exercise the compiler directly. +#[cfg(all(feature = "node", not(test)))] +mod node; + +pub use core::{ + compile_create_theme, compile_create_theme_nested, compile_define_consts, + compile_define_consts_nested, compile_define_vars, compile_define_vars_nested, + process_stylex_rules, process_stylex_rules_checked, CreateOptions, LayersConfig, + ProcessStylexRulesConfig, RuleEntry, RuleFields, StyleValue, UseLayers, +}; +pub use shared::{ + flatten_nested_consts_config, flatten_nested_overrides_config, flatten_nested_string_config, + flatten_nested_vars_config, unflatten_object, CollectedImports, ImportSource, + RuntimeInjectionOption, StyleXTransformOptions, TransformOutput, +}; +pub use utils::{normalize_js, process_metadata_to_css, transform_file, transform_source}; +pub use visitors::collect_stylex_imports; diff --git a/packages/@stylexjs/rust-plugin/src/node.rs b/packages/@stylexjs/rust-plugin/src/node.rs new file mode 100644 index 000000000..7ac25b1da --- /dev/null +++ b/packages/@stylexjs/rust-plugin/src/node.rs @@ -0,0 +1,402 @@ +use std::cell::{Cell, RefCell}; +use std::rc::Rc; +use std::sync::mpsc; + +use napi::bindgen_prelude::*; +use napi_derive::napi; +use serde::Serialize; +use serde_json::Value; + +use crate::utils::host::{self, HostCallback, HostValue}; +use crate::{ + transform_source, ProcessStylexRulesConfig, RuleEntry, StyleXTransformOptions, TransformOutput, +}; + +fn invalid_arg(error: impl std::fmt::Display) -> Error { + Error::new(Status::InvalidArg, error.to_string()) +} + +fn compiler_error(error: anyhow::Error) -> Error { + Error::new( + Status::InvalidArg, + crate::utils::diagnostics::from_error(&error).envelope(), + ) +} + +/// The compiled program travels as a plain string; everything else is one JSON +/// document, which V8 parses far faster than per-property N-API object creation +/// (metadata for large files holds hundreds of thousands of rules). +#[napi(object)] +pub struct NativeTransformOutput { + pub code: String, + pub json: String, +} + +#[derive(Serialize)] +struct OrderedFields<'a> { + #[serde(rename = "constKey", skip_serializing_if = "Option::is_none")] + const_key: Option<&'a str>, + #[serde(rename = "constVal", skip_serializing_if = "Option::is_none")] + const_val: Option<&'a Value>, + ltr: &'a str, + rtl: Option<&'a str>, +} + +#[derive(Serialize)] +struct OrderedRule<'a>(&'a str, OrderedFields<'a>, f64); + +#[derive(Serialize)] +struct Metadata<'a> { + stylex: Vec>, +} + +#[derive(Serialize)] +struct Rest<'a> { + map: &'a Option, + metadata: Metadata<'a>, + errors: &'a [String], + warnings: &'a [String], +} + +fn ordered_rule(rule: &RuleEntry) -> OrderedRule<'_> { + let RuleEntry(name, fields, priority) = rule; + OrderedRule( + name, + OrderedFields { + const_key: fields.const_key.as_deref(), + const_val: fields.const_val.as_ref(), + ltr: &fields.ltr, + rtl: fields.rtl.as_deref(), + }, + *priority, + ) +} + +/// Compiler passes recurse over the syntax tree, so deeply nested input needs +/// more stack than a JavaScript thread (or a 4 MiB worker thread) provides. The +/// compiler runs on its own thread with this much reserved, uncommitted stack; +/// JavaScript callbacks are always executed back on the calling JS thread. +const COMPILER_STACK_SIZE: usize = 512 * 1024 * 1024; + +/// Compiler thread -> JS thread. +enum CompilerMessage { + /// Run a host operation; answer on the enclosed channel. + Call(String, Vec, mpsc::Sender), + /// Result of a compiler function that JavaScript called. + ClosureDone(std::result::Result), + Done(Box>), +} + +/// JS thread -> compiler thread, while the compiler waits for a host operation. +enum HostReply { + Value(std::result::Result), + /// JavaScript called a compiler function (see `host::register_closure`). + CallClosure(String, HostValue, Vec), +} + +type HostFunction = FunctionRef, Value>; + +/// Host calls bounce between the JS thread and the compiler thread; parking a +/// thread and waking it costs tens of microseconds, longer than most replies +/// take, so spin briefly first. Spinning only pays off when the other thread +/// has a core of its own; with one or two cores it would hold the CPU the +/// reply needs. +fn recv_spinning(receiver: &mpsc::Receiver) -> std::result::Result { + static SPIN: std::sync::OnceLock = std::sync::OnceLock::new(); + let spin = *SPIN + .get_or_init(|| std::thread::available_parallelism().is_ok_and(|cores| cores.get() >= 4)); + if spin { + let start = std::time::Instant::now(); + while start.elapsed() < std::time::Duration::from_micros(50) { + match receiver.try_recv() { + Ok(value) => return Ok(value), + Err(mpsc::TryRecvError::Disconnected) => return Err(mpsc::RecvError), + Err(mpsc::TryRecvError::Empty) => std::thread::yield_now(), + } + } + } + receiver.recv() +} + +struct Job { + source: String, + filename: String, + options: StyleXTransformOptions, + has_callback: bool, + sender: mpsc::Sender, +} + +/// One long-lived compiler thread per JS thread, so a transform does not pay +/// for creating a thread with a large stack. +struct CompilerThread { + jobs: mpsc::Sender, + busy: Cell, +} + +impl CompilerThread { + fn spawn() -> std::io::Result { + let (jobs, queue) = mpsc::channel::(); + std::thread::Builder::new() + .name("stylex-compiler".to_owned()) + .stack_size(COMPILER_STACK_SIZE) + .spawn(move || { + for job in queue { + run_job(job); + } + })?; + Ok(Self { + jobs, + busy: Cell::new(false), + }) + } +} + +struct IdleOnDrop(Rc); +impl Drop for IdleOnDrop { + fn drop(&mut self) { + self.0.busy.set(false); + } +} + +thread_local! { + static WORKER: RefCell>> = const { RefCell::new(None) }; +} + +fn run_job(job: Job) { + let Job { + source, + filename, + options, + has_callback, + sender, + } = job; + let operation = || transform_source(&source, &filename, &options); + let compile = || { + if !has_callback { + return operation(); + } + let requests = sender.clone(); + let handler: Rc = Rc::new(move |id: &str, arguments: &[HostValue]| { + let gone = || anyhow::anyhow!("the JavaScript host is gone"); + let (reply, response) = mpsc::channel(); + requests + .send(CompilerMessage::Call( + id.to_owned(), + arguments.to_vec(), + reply, + )) + .map_err(|_| gone())?; + loop { + match recv_spinning(&response).map_err(|_| gone())? { + HostReply::Value(result) => { + return result.map_err(|error| anyhow::anyhow!(error)) + } + HostReply::CallClosure(id, this, arguments) => { + let result = host::call_closure(&id, &this, &arguments) + .map_err(|error| format!("{error:#}")); + requests + .send(CompilerMessage::ClosureDone(result)) + .map_err(|_| gone())?; + } + } + } + }); + host::with_callback(handler, operation) + }; + // A panic must not take the shared thread down with it. + let output = std::panic::catch_unwind(std::panic::AssertUnwindSafe(compile)) + .unwrap_or_else(|_| Err(anyhow::anyhow!("the StyleX compiler panicked"))); + // The receiver only disappears if the JS thread already failed. + let _ = sender.send(CompilerMessage::Done(Box::new(output))); +} + +/// The innermost transform on this JS thread and the host operation it is +/// currently running, so compiler functions called from JavaScript can be +/// routed back to the waiting compiler thread. +struct Session { + receiver: mpsc::Receiver, + callback: Option, + pending: RefCell>>, +} + +thread_local! { + static SESSIONS: RefCell>> = const { RefCell::new(Vec::new()) }; +} + +enum PumpEnd { + Done(Box>), + Closure(std::result::Result), +} + +/// Serves the compiler's host requests on the JS thread until it finishes the +/// transform or the compiler function JavaScript is waiting for. +fn pump(env: &Env, session: &Session) -> Result { + loop { + match recv_spinning(&session.receiver) { + Ok(CompilerMessage::Call(id, arguments, reply)) => { + session.pending.borrow_mut().push(reply.clone()); + let result = match &session.callback { + Some(callback) => call_host(env, callback, id, &arguments), + None => Err(format!("a JavaScript host callback is required for {id}")), + }; + session.pending.borrow_mut().pop(); + let _ = reply.send(HostReply::Value(result)); + } + Ok(CompilerMessage::ClosureDone(result)) => return Ok(PumpEnd::Closure(result)), + Ok(CompilerMessage::Done(output)) => return Ok(PumpEnd::Done(output)), + Err(_) => { + return Err(Error::new( + Status::GenericFailure, + "the StyleX compiler thread stopped unexpectedly", + )) + } + } + } +} + +#[napi(js_name = "transformSync")] +pub fn transform_sync( + env: Env, + source: String, + filename: String, + options: Value, + callback: Option, Value>>, +) -> Result { + let options: StyleXTransformOptions = serde_json::from_value(options).map_err(invalid_arg)?; + let has_callback = callback.is_some(); + let callback = callback.map(|callback| callback.create_ref()).transpose()?; + let (sender, receiver) = mpsc::channel(); + let job = Job { + source, + filename, + options, + has_callback, + sender, + }; + let worker = WORKER.with(|worker| { + let mut worker = worker.borrow_mut(); + if worker.as_ref().is_none_or(|worker| worker.busy.get()) { + None + } else { + worker.clone() + } + .or_else(|| { + let fresh = Rc::new(CompilerThread::spawn().ok()?); + if worker.is_none() { + *worker = Some(fresh.clone()); + } + Some(fresh) + }) + }); + let Some(worker) = worker else { + return Err(Error::new( + Status::GenericFailure, + "could not start the StyleX compiler thread", + )); + }; + // A transform started from inside a host callback finds the thread busy and + // gets its own (see the `busy` check above). + worker.busy.set(true); + let _idle = IdleOnDrop(worker.clone()); + worker + .jobs + .send(job) + .map_err(|_| Error::new(Status::GenericFailure, "the StyleX compiler thread is gone"))?; + let session = Rc::new(Session { + receiver, + callback, + pending: RefCell::new(Vec::new()), + }); + SESSIONS.with(|sessions| sessions.borrow_mut().push(session.clone())); + let ended = pump(&env, &session); + SESSIONS.with(|sessions| sessions.borrow_mut().pop()); + let output = match ended { + Ok(PumpEnd::Done(output)) => *output, + Ok(PumpEnd::Closure(_)) => { + return Err(Error::new( + Status::GenericFailure, + "a StyleX compiler function returned outside of a call", + )) + } + Err(error) => return Err(error), + }; + let output = output.map_err(compiler_error)?; + let json = serde_json::to_string(&Rest { + map: &output.source_map, + metadata: Metadata { + stylex: output.metadata_stylex.iter().map(ordered_rule).collect(), + }, + errors: &output.errors, + warnings: &output.warnings, + }) + .map_err(invalid_arg)?; + Ok(NativeTransformOutput { + code: output.code, + json, + }) +} + +fn call_host( + env: &Env, + callback: &HostFunction, + id: String, + arguments: &[HostValue], +) -> std::result::Result { + let arguments = serde_json::to_value(arguments).map_err(|error| error.to_string())?; + let result = callback + .borrow_back(env) + .map_err(|error| error.to_string())? + .call((id, arguments).into()) + .map_err(|error| error.to_string())?; + serde_json::from_value(result).map_err(|error| error.to_string()) +} + +/// Called by the host when JavaScript invokes a compiler function it received +/// as an argument; runs it on the waiting compiler thread. +#[napi(js_name = "callCompilerFunction")] +pub fn call_compiler_function( + env: Env, + id: String, + this: Value, + arguments: Value, +) -> Result { + let this: HostValue = serde_json::from_value(this).map_err(invalid_arg)?; + let arguments: Vec = serde_json::from_value(arguments).map_err(invalid_arg)?; + let session = SESSIONS.with(|sessions| sessions.borrow().last().cloned()); + let reply = session + .as_ref() + .and_then(|session| session.pending.borrow().last().cloned()); + let (Some(session), Some(reply)) = (session, reply) else { + return Err(Error::new( + Status::GenericFailure, + "a StyleX compiler function can only be called while StyleX is compiling", + )); + }; + reply + .send(HostReply::CallClosure(id, this, arguments)) + .map_err(|_| Error::new(Status::GenericFailure, "the StyleX compiler thread is gone"))?; + match pump(&env, &session)? { + PumpEnd::Closure(Ok(value)) => serde_json::to_value(value).map_err(invalid_arg), + PumpEnd::Closure(Err(message)) => Err(Error::new(Status::GenericFailure, message)), + PumpEnd::Done(_) => Err(Error::new( + Status::GenericFailure, + "the StyleX compiler finished while a compiler function was running", + )), + } +} + +#[napi(js_name = "processStylexRules")] +pub fn process_rules(rules: String, config: Value) -> Result { + let rules: Vec = serde_json::from_str(&rules).map_err(invalid_arg)?; + let config = if config.is_null() { + ProcessStylexRulesConfig::default() + } else if let Some(use_layers) = config.as_bool() { + ProcessStylexRulesConfig { + use_layers: crate::UseLayers::Bool(use_layers), + ..Default::default() + } + } else { + serde_json::from_value(config).map_err(invalid_arg)? + }; + crate::core::process_stylex_rules_checked(&rules, Some(&config)).map_err(invalid_arg) +} diff --git a/packages/@stylexjs/rust-plugin/src/shared/mod.rs b/packages/@stylexjs/rust-plugin/src/shared/mod.rs new file mode 100644 index 000000000..ca0dbeab6 --- /dev/null +++ b/packages/@stylexjs/rust-plugin/src/shared/mod.rs @@ -0,0 +1,11 @@ +mod nested_utils; +mod types; + +pub use nested_utils::{ + flatten_nested_consts_config, flatten_nested_overrides_config, flatten_nested_string_config, + flatten_nested_vars_config, insert_unflattened_value, unflatten_object, +}; +pub use types::{ + CollectedImports, ImportSource, RuntimeInjectionOption, StyleXTransformOptions, ThemeImport, + TransformOutput, +}; diff --git a/packages/@stylexjs/rust-plugin/src/shared/nested_utils.rs b/packages/@stylexjs/rust-plugin/src/shared/nested_utils.rs new file mode 100644 index 000000000..7a1e54879 --- /dev/null +++ b/packages/@stylexjs/rust-plugin/src/shared/nested_utils.rs @@ -0,0 +1,170 @@ +use indexmap::IndexMap; +use serde_json::Value; + +use crate::core::StyleValue; + +const SEPARATOR: char = '.'; + +pub fn flatten_nested_vars_config( + object: &IndexMap, +) -> Result, String> { + let mut result = IndexMap::new(); + flatten_nested_vars_into(object, "", &mut result)?; + Ok(result) +} + +pub fn flatten_nested_overrides_config( + object: &IndexMap, +) -> Result, String> { + flatten_nested_vars_config(object) +} + +pub fn flatten_nested_string_config( + object: &IndexMap, +) -> Result, String> { + let mut result = IndexMap::new(); + flatten_nested_string_into(object, "", &mut result)?; + Ok(result) +} + +pub fn flatten_nested_consts_config( + object: &IndexMap, +) -> Result, String> { + let mut result = IndexMap::new(); + flatten_nested_consts_into(object, "", &mut result)?; + Ok(result) +} + +pub fn unflatten_object( + flat_obj: &IndexMap, +) -> Result, String> { + let mut root = serde_json::Map::new(); + for (key, value) in flat_obj { + if is_special_key(key) || !key.contains(SEPARATOR) { + root.insert(key.clone(), value.clone()); + continue; + } + insert_unflattened_value(&mut root, key, value.clone())?; + } + Ok(root.into_iter().collect()) +} + +fn flatten_nested_vars_into( + object: &IndexMap, + prefix: &str, + result: &mut IndexMap, +) -> Result<(), String> { + for (key, value) in object { + validate_nested_key(key)?; + let full_key = extend_key(prefix, key); + if is_vars_leaf(value) { + result.insert(full_key, value.clone()); + continue; + } + match value { + StyleValue::Object(nested) => flatten_nested_vars_into(nested, &full_key, result)?, + _ => { + result.insert(full_key, value.clone()); + } + } + } + Ok(()) +} + +fn flatten_nested_string_into( + object: &IndexMap, + prefix: &str, + result: &mut IndexMap, +) -> Result<(), String> { + for (key, value) in object { + validate_nested_key(key)?; + let full_key = extend_key(prefix, key); + match value { + StyleValue::String(_) => { + result.insert(full_key, value.clone()); + } + StyleValue::Object(nested) => flatten_nested_string_into(nested, &full_key, result)?, + _ => return Err("Only string values are allowed in nested string config.".to_owned()), + } + } + Ok(()) +} + +fn flatten_nested_consts_into( + object: &IndexMap, + prefix: &str, + result: &mut IndexMap, +) -> Result<(), String> { + for (key, value) in object { + validate_nested_key(key)?; + let full_key = extend_key(prefix, key); + match value { + StyleValue::String(_) | StyleValue::Number(_) => { + result.insert(full_key, value.clone()); + } + StyleValue::Object(nested) => flatten_nested_consts_into(nested, &full_key, result)?, + _ => return Err("defineConsts() only supports string and number values.".to_owned()), + } + } + Ok(()) +} + +fn validate_nested_key(key: &str) -> Result<(), String> { + if key.contains(SEPARATOR) { + return Err(format!( + "Key \"{}\" must not contain the \".\" character. Use nested objects instead of dots in key names.", + key + )); + } + Ok(()) +} + +fn extend_key(prefix: &str, key: &str) -> String { + if prefix.is_empty() { + key.to_owned() + } else { + format!("{}.{}", prefix, key) + } +} + +fn is_vars_leaf(value: &StyleValue) -> bool { + match value { + StyleValue::String(_) | StyleValue::Number(_) | StyleValue::Null => true, + StyleValue::Array(_) => false, + StyleValue::Object(object) => { + if !object.contains_key("default") { + return false; + } + object + .keys() + .all(|key| key == "default" || key.starts_with('@')) + } + } +} + +fn is_special_key(key: &str) -> bool { + matches!(key, "__varGroupHash__" | "$$css") +} + +pub fn insert_unflattened_value( + object: &mut serde_json::Map, + key_path: &str, + value: Value, +) -> Result<(), String> { + let mut current = object; + let mut parts = key_path.split(SEPARATOR).peekable(); + while let Some(part) = parts.next() { + if parts.peek().is_none() { + current.insert(part.to_owned(), value); + return Ok(()); + } + if !current.contains_key(part) { + current.insert(part.to_owned(), Value::Object(serde_json::Map::new())); + } + let Some(Value::Object(next)) = current.get_mut(part) else { + return Err(format!("Duplicate nested key path `{}`.", key_path)); + }; + current = next; + } + Ok(()) +} diff --git a/packages/@stylexjs/rust-plugin/src/shared/types.rs b/packages/@stylexjs/rust-plugin/src/shared/types.rs new file mode 100644 index 000000000..78060de7e --- /dev/null +++ b/packages/@stylexjs/rust-plugin/src/shared/types.rs @@ -0,0 +1,194 @@ +use crate::core::RuleEntry; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use swc_ecma_ast::{Id, Ident}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ImportSource { + Named(String), + Aliased { + from: String, + #[serde(rename = "as")] + as_: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum RuntimeInjectionOption { + Bool(bool), + Path(String), + Named { + from: String, + #[serde(rename = "as")] + as_: String, + }, +} +impl Default for RuntimeInjectionOption { + fn default() -> Self { + Self::Bool(false) + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub struct StyleXTransformOptions { + #[serde(default, rename = "importSources")] + pub import_sources: Vec, + #[serde(default, rename = "rewriteAliases")] + pub rewrite_aliases: bool, + #[serde(default, rename = "runtimeInjection")] + pub runtime_injection: RuntimeInjectionOption, + #[serde(flatten, default)] + pub additional_options: serde_json::Map, +} +impl<'de> Deserialize<'de> for StyleXTransformOptions { + fn deserialize>(deserializer: D) -> Result { + let value = serde_json::Value::deserialize(deserializer)?; + let mut raw = value + .as_object() + .cloned() + .ok_or_else(|| serde::de::Error::custom("StyleX options must be an object"))?; + let mut invalid = serde_json::Map::new(); + let sources = raw + .remove("importSources") + .unwrap_or(serde_json::Value::Null); + let import_sources = if sources.is_null() { + Vec::new() + } else { + match serde_json::from_value::>(sources.clone()) { + Ok(value) => value, + Err(_) => { + invalid.insert("importSources".to_owned(), sources); + Vec::new() + } + } + }; + let injection = raw + .remove("runtimeInjection") + .unwrap_or(serde_json::Value::Null); + let runtime_injection = if injection.is_null() { + RuntimeInjectionOption::default() + } else { + match serde_json::from_value::(injection.clone()) { + Ok(value) => value, + Err(_) => { + invalid.insert("runtimeInjection".to_owned(), injection); + RuntimeInjectionOption::default() + } + } + }; + let rewrite = raw + .remove("rewriteAliases") + .unwrap_or(serde_json::Value::Bool(false)); + let rewrite_aliases = rewrite.as_bool().unwrap_or(false); + if !invalid.is_empty() { + raw.insert( + "__invalidOptions".to_owned(), + serde_json::Value::Object(invalid), + ); + } + Ok(Self { + import_sources, + runtime_injection, + rewrite_aliases, + additional_options: raw, + }) + } +} +impl StyleXTransformOptions { + pub fn matches_source(&self, source: &str) -> bool { + source == "@stylexjs/stylex" + || source == "stylex" + || self.import_sources.iter().any(|entry| match entry { + ImportSource::Named(name) => name == source, + ImportSource::Aliased { from, .. } => from == source, + }) + } + pub fn import_alias(&self, source: &str) -> Option<&str> { + self.import_sources.iter().find_map(|entry| match entry { + ImportSource::Aliased { from, as_ } if from == source => Some(as_.as_str()), + _ => None, + }) + } + pub fn runtime_injection_enabled(&self) -> bool { + !matches!(self.runtime_injection, RuntimeInjectionOption::Bool(false)) + } + pub fn runtime_injection_path(&self) -> &str { + match &self.runtime_injection { + RuntimeInjectionOption::Bool(_) => "@stylexjs/stylex/lib/stylex-inject", + RuntimeInjectionOption::Path(path) => path, + RuntimeInjectionOption::Named { from, .. } => from, + } + } + pub fn runtime_injection_name(&self) -> Option<&str> { + match &self.runtime_injection { + RuntimeInjectionOption::Named { as_, .. } => Some(as_), + _ => None, + } + } + pub fn bool_option(&self, key: &str, default: bool) -> bool { + self.additional_options + .get(key) + .and_then(|value| value.as_bool()) + .unwrap_or(default) + } +} + +/// Strings remain for public import inventory compatibility. Transform decisions use IDs. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct CollectedImports { + pub create_imports: Vec, + pub props_imports: Vec, + pub attrs_imports: Vec, + pub keyframes_imports: Vec, + pub position_try_imports: Vec, + pub when_imports: Vec, + pub first_that_works_imports: Vec, + pub env_imports: Vec, + pub define_vars_imports: Vec, + pub define_vars_nested_imports: Vec, + pub define_marker_imports: Vec, + pub define_consts_imports: Vec, + pub define_consts_nested_imports: Vec, + pub create_theme_imports: Vec, + pub create_theme_nested_imports: Vec, + pub view_transition_class_imports: Vec, + pub named_imports: Vec, + pub namespace_imports: Vec, + pub sources: Vec, + pub theme_imports: HashMap, + pub namespace_bindings: Vec, + pub runtime_source_hint: Option, + pub method_bindings: HashMap, + pub theme_bindings: HashMap, + pub atom_bindings: HashMap, +} +impl CollectedImports { + pub fn matches_namespace(&self, ident: &Ident) -> bool { + self.namespace_bindings.contains(&ident.to_id()) + } + pub fn matches_method(&self, ident: &Ident, method: &str) -> bool { + self.method_bindings + .get(&ident.to_id()) + .is_some_and(|name| name == method) + } + pub fn theme_for(&self, ident: &Ident) -> Option<&ThemeImport> { + self.theme_bindings.get(&ident.to_id()) + } +} +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ThemeImport { + pub import_path: String, + pub imported_name: String, +} +#[derive(Debug, Clone, Default, PartialEq)] +pub struct TransformOutput { + pub code: String, + pub collected_imports: CollectedImports, + pub metadata_stylex: Vec, + pub metadata_registered: bool, + pub source_map: Option, + pub errors: Vec, + pub warnings: Vec, +} diff --git a/packages/@stylexjs/rust-plugin/src/utils/diagnostics.rs b/packages/@stylexjs/rust-plugin/src/utils/diagnostics.rs new file mode 100644 index 000000000..4dbb56b13 --- /dev/null +++ b/packages/@stylexjs/rust-plugin/src/utils/diagnostics.rs @@ -0,0 +1,111 @@ +//! Structured native compiler diagnostics. Rendering code frames belongs to the host. +use serde::{Deserialize, Serialize}; +use std::fmt; +use swc_common::Span; + +#[cfg(any(test, feature = "node"))] +pub(crate) const ENVELOPE_PREFIX: &str = "STYLEX_DIAGNOSTIC:"; +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DiagnosticRange { + /// UTF-8 offsets in the exact input source, excluding SWC's one-based file origin. + pub start: usize, + pub end: usize, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Diagnostic { + pub name: String, + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kind: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub range: Option, +} +impl Diagnostic { + pub fn syntax(message: impl Into, span: Span) -> Self { + Self { + name: "SyntaxError".to_owned(), + message: message.into(), + kind: None, + range: (!span.is_dummy()).then(|| DiagnosticRange { + start: span.lo.0.saturating_sub(1) as usize, + end: span.hi.0.saturating_sub(1) as usize, + }), + } + } + pub fn plain(message: impl Into) -> Self { + Self { + name: "Error".to_owned(), + message: message.into(), + kind: None, + range: None, + } + } + pub fn parse(message: impl Into, range: DiagnosticRange) -> Self { + Self { + name: "SyntaxError".to_owned(), + message: message.into(), + kind: Some("parse".to_owned()), + range: Some(range), + } + } + pub fn into_error(self) -> anyhow::Error { + anyhow::Error::new(DiagnosticError(self)) + } + #[cfg(any(test, feature = "node"))] + pub fn envelope(&self) -> String { + format!( + "{ENVELOPE_PREFIX}{}", + serde_json::to_string(self).expect("serializable compiler diagnostic") + ) + } +} +#[derive(Debug)] +pub struct DiagnosticError(pub Diagnostic); +impl fmt::Display for DiagnosticError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0.message) + } +} +impl std::error::Error for DiagnosticError {} +pub(crate) fn syntax_error(message: impl Into, span: Span) -> anyhow::Error { + Diagnostic::syntax(message, span).into_error() +} +pub(crate) fn plain_error(message: impl Into) -> anyhow::Error { + Diagnostic::plain(message).into_error() +} +#[cfg(any(test, feature = "node"))] +pub(crate) fn from_error(error: &anyhow::Error) -> Diagnostic { + error + .downcast_ref::() + .map(|error| error.0.clone()) + .unwrap_or_else(|| Diagnostic::plain(error.to_string())) +} + +#[cfg(test)] +mod tests { + use super::{from_error, Diagnostic, ENVELOPE_PREFIX}; + use swc_common::{BytePos, Span}; + + #[test] + fn native_diagnostic_envelope_preserves_range_and_error_kind() { + let diagnostic = + Diagnostic::syntax("invalid StyleX call", Span::new(BytePos(4), BytePos(12))); + let error = diagnostic.clone().into_error(); + assert_eq!(from_error(&error), diagnostic); + let envelope = from_error(&error).envelope(); + let decoded: Diagnostic = + serde_json::from_str(envelope.strip_prefix(ENVELOPE_PREFIX).unwrap()).unwrap(); + assert_eq!(decoded, diagnostic); + assert_eq!(decoded.range.unwrap().start, 3); + assert_eq!(from_error(&anyhow::anyhow!("ordinary error")).name, "Error"); + let parsed = Diagnostic::parse( + "invalid source", + super::DiagnosticRange { start: 2, end: 3 }, + ); + let envelope = parsed.envelope(); + let decoded: Diagnostic = + serde_json::from_str(envelope.strip_prefix(ENVELOPE_PREFIX).unwrap()).unwrap(); + assert_eq!(decoded.kind.as_deref(), Some("parse")); + assert_eq!(decoded.name, "SyntaxError"); + } +} diff --git a/packages/@stylexjs/rust-plugin/src/utils/host.rs b/packages/@stylexjs/rust-plugin/src/utils/host.rs new file mode 100644 index 000000000..a88a94c1a --- /dev/null +++ b/packages/@stylexjs/rust-plugin/src/utils/host.rs @@ -0,0 +1,198 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use anyhow::{anyhow, Result}; +use serde::{Deserialize, Serialize}; + +/// The host owns JavaScript functions and filesystem resolution. Compiler work stays in Rust. +pub(crate) type HostCallback = dyn Fn(&str, &[HostValue]) -> Result; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(tag = "type", content = "value", rename_all = "camelCase")] +pub(crate) enum HostValue { + Undefined, + Null, + Boolean(bool), + Number(String), + String(String), + Array(Vec), + Object(Vec<(String, HostValue)>), + /// A JavaScript value the host keeps for the current transform (functions, + /// class instances, `Map`/`Date`, cyclic objects, symbols). Operations on it + /// run as real JavaScript on the JS thread, lazily, when the compiler asks. + Handle(String), + /// A compiler function passed to JavaScript; the host exposes it as a + /// callable that runs it back on the compiler thread. + Closure(String), +} + +/// A compiler function JavaScript may call: `(this, arguments) -> result`. +pub(crate) type HostClosure = dyn Fn(&HostValue, &[HostValue]) -> Result; + +thread_local! { + static CALLBACKS: RefCell>> = RefCell::new(Vec::new()); + static CLOSURES: RefCell>> = RefCell::new(Vec::new()); +} + +#[cfg(any(test, feature = "node"))] +struct CallbackScope; +#[cfg(any(test, feature = "node"))] +impl Drop for CallbackScope { + fn drop(&mut self) { + CALLBACKS.with(|stack| { + let mut stack = stack.borrow_mut(); + stack.pop(); + // Closure ids are only meaningful within the outermost transform. + if stack.is_empty() { + CLOSURES.with(|closures| closures.borrow_mut().clear()); + } + }); + } +} + +#[cfg(any(test, feature = "node"))] +pub(crate) fn with_callback(callback: Rc, operation: impl FnOnce() -> T) -> T { + CALLBACKS.with(|stack| stack.borrow_mut().push(callback)); + let _scope = CallbackScope; + operation() +} + +pub(crate) fn invoke_callback(id: &str, arguments: &[HostValue]) -> Result { + let callback = CALLBACKS.with(|stack| stack.borrow().last().cloned()); + let Some(callback) = callback else { + return Err(anyhow!("a JavaScript host callback is required for {id}")); + }; + callback(id, arguments) +} + +pub(crate) fn invoke_string(id: &str, arguments: &[HostValue]) -> Result> { + match invoke_callback(id, arguments)? { + HostValue::String(value) => Ok(Some(value)), + HostValue::Undefined | HostValue::Null => Ok(None), + _ => Err(anyhow!( + "host callback {id} must return a string or undefined" + )), + } +} + +/// Reads `handle[key]` in JavaScript (getters run now, not earlier). +pub(crate) fn host_get(handle: &str, key: &HostValue) -> Result { + invoke_callback("@get", &[HostValue::Handle(handle.to_owned()), key.clone()]) +} + +/// Calls the JavaScript function `handle` with `this` and `arguments`. +pub(crate) fn host_call( + handle: &str, + this: &HostValue, + arguments: &[HostValue], +) -> Result { + let mut values = Vec::with_capacity(arguments.len() + 2); + values.push(HostValue::Handle(handle.to_owned())); + values.push(this.clone()); + values.extend_from_slice(arguments); + invoke_callback("@call", &values) +} + +/// The actual JavaScript function behind an options callback id (for example +/// `stylex.env.fn` passed as a value), as a handle with its real identity. +pub(crate) fn host_callback_value(callback_id: &str) -> Result { + invoke_callback( + "@callbackValue", + &[HostValue::String(callback_id.to_owned())], + ) +} + +/// JavaScript `typeof` of a handle. +pub(crate) fn host_typeof(handle: &str) -> Result { + invoke_string("@typeof", &[HostValue::Handle(handle.to_owned())])? + .ok_or_else(|| anyhow!("host typeof returned no type")) +} + +/// JavaScript ToPrimitive with `hint` ("string", "number" or "default"). +pub(crate) fn host_to_primitive(handle: &str, hint: &str) -> Result { + invoke_callback( + "@toPrimitive", + &[ + HostValue::Handle(handle.to_owned()), + HostValue::String(hint.to_owned()), + ], + ) +} + +/// JavaScript `String(value)` (explicit conversion; symbols allowed). +pub(crate) fn host_to_string(value: &HostValue) -> Result { + invoke_string("@toString", std::slice::from_ref(value))? + .ok_or_else(|| anyhow!("host String() returned no string")) +} + +/// JavaScript `Number(value)`. +pub(crate) fn host_to_number(value: &HostValue) -> Result { + invoke_callback("@toNumber", std::slice::from_ref(value)) +} + +/// JavaScript `Boolean(value)`. +pub(crate) fn host_truthy(value: &HostValue) -> Result { + match invoke_callback("@truthy", std::slice::from_ref(value))? { + HostValue::Boolean(value) => Ok(value), + _ => Err(anyhow!("host Boolean() returned a non-boolean")), + } +} + +/// JavaScript `Object.is(left, right)`. +pub(crate) fn host_same_value(left: &HostValue, right: &HostValue) -> Result { + match invoke_callback("@sameValue", &[left.clone(), right.clone()])? { + HostValue::Boolean(value) => Ok(value), + _ => Err(anyhow!("host Object.is returned a non-boolean")), + } +} + +/// JavaScript `left right` for a binary operator (`+`, `===`, `<`, +/// `in`, `instanceof`, ...), performed by the host on real values. +pub(crate) fn host_binary( + operator: &str, + left: &HostValue, + right: &HostValue, +) -> Result { + invoke_callback( + "@binary", + &[ + HostValue::String(operator.to_owned()), + left.clone(), + right.clone(), + ], + ) +} + +/// JavaScript ` value` for `+`, `-`, `~`, `!`, `typeof` or `void`. +pub(crate) fn host_unary(operator: &str, value: &HostValue) -> Result { + invoke_callback( + "@unary", + &[HostValue::String(operator.to_owned()), value.clone()], + ) +} + +/// Makes `closure` callable from JavaScript for the rest of this transform. +pub(crate) fn register_closure(closure: Rc) -> String { + CLOSURES.with(|closures| { + let mut closures = closures.borrow_mut(); + closures.push(closure); + (closures.len() - 1).to_string() + }) +} + +/// Runs a closure registered with [`register_closure`] for a JavaScript caller. +pub(crate) fn call_closure( + id: &str, + this: &HostValue, + arguments: &[HostValue], +) -> Result { + let closure = CLOSURES.with(|closures| { + id.parse::() + .ok() + .and_then(|index| closures.borrow().get(index).cloned()) + }); + let Some(closure) = closure else { + return Err(anyhow!("unknown StyleX compiler function {id}")); + }; + closure(this, arguments) +} diff --git a/packages/@stylexjs/rust-plugin/src/utils/mod.rs b/packages/@stylexjs/rust-plugin/src/utils/mod.rs new file mode 100644 index 000000000..db056471e --- /dev/null +++ b/packages/@stylexjs/rust-plugin/src/utils/mod.rs @@ -0,0 +1,7 @@ +pub(crate) mod diagnostics; +pub(crate) mod host; +mod parser; +mod transform; + +pub use parser::normalize_js; +pub use transform::{process_metadata_to_css, transform_file, transform_source}; diff --git a/packages/@stylexjs/rust-plugin/src/utils/parser.rs b/packages/@stylexjs/rust-plugin/src/utils/parser.rs new file mode 100644 index 000000000..cdebc5d17 --- /dev/null +++ b/packages/@stylexjs/rust-plugin/src/utils/parser.rs @@ -0,0 +1,631 @@ +use anyhow::{anyhow, Result}; +use swc_common::{ + comments::SingleThreadedComments, sync::Lrc, FileName, SourceFile, SourceMap, Spanned, +}; +use swc_ecma_ast::{EsVersion, Expr, Module, ModuleItem}; +use swc_ecma_codegen::{text_writer::JsWriter, Config as CodegenConfig, Emitter}; +use swc_ecma_parser::{ + error::{Error, SyntaxError}, + PResult, Parser, Syntax, +}; +#[cfg(test)] +use swc_ecma_parser::{with_file_parser, FlowSyntax, TsSyntax}; + +#[path = "parser_limits.rs"] +pub(crate) mod parser_limits; +pub(crate) use parser_limits::with_bounded_file_parser; +use parser_limits::BoundedLexer; + +#[path = "parser_modes.rs"] +pub(crate) mod parser_modes; +pub(crate) use parser_modes::SourceType; + +pub(crate) fn syntax_for_file(filename: &str) -> Syntax { + parser_modes::supported_syntax(parser_limits::syntax_for_filename(filename)) +} + +pub(crate) struct ParsedModule { + pub source_map: Lrc, + pub file: Lrc, + pub comments: SingleThreadedComments, + pub syntax: Syntax, + pub source_type: SourceType, + pub module: Module, +} + +fn parse_file( + file: &SourceFile, + comments: &SingleThreadedComments, + syntax: Syntax, + op: impl for<'a> FnOnce(&mut Parser>) -> PResult, +) -> Result { + let mut errors = Vec::new(); + let result = with_bounded_file_parser( + file, + syntax, + EsVersion::Es2022, + Some(comments), + &mut errors, + |parser| { + let value = op(parser)?; + // SWC's current token type is behind its `unstable` feature. Its + // lexer represents EOF by a zero-width span at the source end; + // checking the buffered token (not the lexer cursor) also catches + // trailing tokens already consumed by lookahead. + let span = parser.input().cur_span(); + if span.lo != file.end_pos || span.hi != file.end_pos { + return Err(Error::new( + span, + SyntaxError::Unexpected { + got: parser.input().dump_cur(), + expected: "end of input", + }, + )); + } + Ok(value) + }, + ); + + let value = match result { + Ok(value) => Some(value), + Err(error) => { + errors.push(error); + None + } + }; + if let Some(error) = errors.first() { + // Preserve the parser's first readable message and byte range. The host + // adds its filename/code frame; this is a parse error, not a StyleX + // validation error, and recovered diagnostics are still fatal. + let span = error.span(); + let relative = |position: swc_common::BytePos| { + position + .0 + .saturating_sub(file.start_pos.0) + .min(file.end_pos.0 - file.start_pos.0) as usize + }; + return Err(crate::utils::diagnostics::Diagnostic::parse( + error.kind().msg().into_owned(), + crate::utils::diagnostics::DiagnosticRange { + start: relative(span.lo), + end: relative(span.hi), + }, + ) + .into_error()); + } + // A fatal parse error is always included in `errors` above. + Ok(value.expect("successful parse without diagnostics")) +} + +pub(crate) fn parse_module(source: &str, filename: &str) -> Result { + parse_module_with_syntax(source, filename, syntax_for_file(filename)) +} + +pub(crate) fn parse_module_with_syntax( + source: &str, + filename: &str, + syntax: Syntax, +) -> Result { + parse_module_with_options(source, filename, syntax, SourceType::Module) +} + +pub(crate) fn parse_module_with_options( + source: &str, + filename: &str, + syntax: Syntax, + source_type: SourceType, +) -> Result { + let syntax = parser_modes::supported_syntax(syntax); + let source_map = Lrc::new(SourceMap::default()); + let file = + source_map.new_source_file(FileName::Real(filename.into()).into(), source.to_owned()); + let comments = SingleThreadedComments::default(); + let (module, source_type) = parse_file(&file, &comments, syntax, |parser| { + parser_modes::parse_source(parser, source_type) + })?; + Ok(ParsedModule { + source_map, + file, + comments, + syntax, + source_type, + module, + }) +} + +pub(crate) fn parse_expr(source: &str, filename: &str) -> Result> { + parse_expr_with_syntax(source, filename, syntax_for_file(filename)) +} + +pub(crate) fn parse_expr_with_syntax( + source: &str, + filename: &str, + syntax: Syntax, +) -> Result> { + let syntax = parser_modes::supported_syntax(syntax); + let source_map = Lrc::new(SourceMap::default()); + let file = + source_map.new_source_file(FileName::Real(filename.into()).into(), source.to_owned()); + let comments = SingleThreadedComments::default(); + parse_file(&file, &comments, syntax, |parser| parser.parse_expr()) +} + +pub(crate) fn parse_module_items(source: &str, filename: &str) -> Result> { + Ok(parse_module(source, filename)?.module.body) +} + +pub fn normalize_js(code: &str, filename: &str) -> Result { + let parsed = parse_module(code, filename)?; + let mut output = Vec::new(); + { + let mut emitter = Emitter { + cfg: CodegenConfig::default(), + cm: parsed.source_map.clone(), + comments: Some(&parsed.comments), + wr: JsWriter::new(parsed.source_map.clone(), "\n", &mut output, None), + }; + emitter + .emit_module(&parsed.module) + .map_err(|error| anyhow!("failed to emit {}: {}", filename, error))?; + } + String::from_utf8(output).map_err(|error| anyhow!(error)) +} + +#[cfg(test)] +mod tests { + use super::*; + use swc_ecma_ast::{Decl, Lit, Pat, Stmt}; + + #[test] + fn selects_flow_for_javascript_and_typescript_for_typescript() { + for filename in ["fixture.js", "fixture.jsx"] { + assert!(matches!( + syntax_for_file(filename), + Syntax::Flow(FlowSyntax { + jsx: true, + all: true, + .. + }) + )); + } + assert!(matches!( + syntax_for_file("fixture.ts"), + Syntax::Typescript(TsSyntax { tsx: false, .. }) + )); + assert!(matches!( + syntax_for_file("fixture.tsx"), + Syntax::Typescript(TsSyntax { tsx: true, .. }) + )); + } + + #[test] + fn parses_flow_only_syntax_and_preserves_type_nodes() { + let source = "type Props = {| +label: string, value: ?number |};\nconst value: Props = {label: 'hello', value: null};"; + for filename in ["fixture.js", "fixture.jsx"] { + let parsed = parse_module(source, filename).expect("Flow module"); + assert!(matches!( + &parsed.module.body[0], + ModuleItem::Stmt(Stmt::Decl(Decl::TsTypeAlias(_))) + )); + let ModuleItem::Stmt(Stmt::Decl(Decl::Var(var))) = &parsed.module.body[1] else { + panic!("expected typed variable"); + }; + let Pat::Ident(binding) = &var.decls[0].name else { + panic!("expected binding"); + }; + assert!(binding.type_ann.is_some()); + } + assert!(parse_module(source, "fixture.ts").is_err()); + } + + #[test] + fn parses_typescript_and_tsx_without_stripping_types() { + let parsed = + parse_module("const value: number = 1 satisfies number;", "fixture.ts").unwrap(); + let ModuleItem::Stmt(Stmt::Decl(Decl::Var(var))) = &parsed.module.body[0] else { + panic!("expected typed variable"); + }; + assert!(matches!( + &**var.decls[0].init.as_ref().unwrap(), + Expr::TsSatisfies(_) + )); + let expr = parse_expr("